opencode-idea 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ ## 0.0.1
4
+
5
+ 首个版本(包名 `opencode-idea`,从 `opencode-jetbrains-mcp` 重命名而来)。
6
+
7
+ **接入 IDE MCP**
8
+
9
+ - **`/open-in-idea` 命令**:用 IntelliJ IDEA 打开当前项目(复用已运行实例),并即时加载 IDE MCP。命令可重入,重复触发共享同一次启动,不会重复 spawn。
10
+ - **IDE 启动器** (`src/ide-launcher.js`):`open -a "IntelliJ IDEA" <dir>` 复用已运行实例(Toolbox CLI 的 `open -na` 会新开实例,故不使用)。
11
+ - **启动保护 (launch guard)**:每目录记录 `lastSpawnAt` / `attempts`;冷却 `launchCooldownMs`(默认 120s)、上限 `launchMaxAttempts`(默认 5)。
12
+ - **MCP 未开启时 fast-fail**:`/open-in-idea` 先短探测,未检测到 MCP 就立即返回并提示在 IDEA 中开启 MCP 服务和 Brave Mode,不轮询、不等待超时;用户开启后重试命令即可。
13
+ - **Streamable HTTP 端点**:注册给 OpenCode 的 URL 使用 `/stream`(IntelliJ 2026.2+)。OpenCode V2 的远程 MCP 客户端只支持 Streamable HTTP,旧版 `/sse`(POST 返回 405)不可用;插件探测同样走 `/stream`。
14
+ - **结果通知**(`feedback: "message"`,默认):命令结束后发一条标注清楚的通知,用户看到干净气泡(`metadata.displayText`),模型只看到"这是通知,请只回复『收到』";`false` 静默。
15
+
16
+ **IDE 工具优先**
17
+
18
+ - **系统提示直接调用引导** (`src/ide-guidance.js`):IDE 可用时,通过 `ctx.session.hook("context", …)` 往每次请求的 system 追加「项目内操作优先使用 IDEA MCP」引导 + 能力映射表,要求模型直接调用 `idea_*` 工具。`injectGuidance: false` 可关闭。
19
+ - **IDEA 工具描述增强** (`src/idea-tool-descriptions.js`):通过 `ctx.tool.transform` 只更新 `idea_*` / `idea.*` 工具的 description,强调直接调用、按当前 schema 传参。原生 `edit` / `write` / `patch` / `shell` 的定义、描述和可用性保持不变。
20
+ - **失败恢复指引**:IDEA MCP 工具缺失、连接被拒或超时时,提示用户执行 `/open-in-idea`,不反复探测或长时间等待。
21
+
22
+ **IDE 环境注入**
23
+
24
+ - **环境变量只来自 IDE 集成终端**:用 `execute_terminal_command printenv` 读取 IDE 实际运行环境(版本化 Node、goenv Go、SDKMAN Java、Maven 等),通过 V2 `shell` 的 `create.before` 钩子注入每个 shell;只补 SDK 变量并前置 PATH,不覆盖原有 PATH。需要 IDE MCP 可达 + Brave Mode。
25
+ - **抽取值** (`src/ide-env.js` 的 `SDK_ENV_KEYS`):`JAVA_HOME` / `JRE_HOME` / `JDK_HOME` / `VIRTUAL_ENV` / `PYTHONPATH` / `PYTHONHOME` / `GOROOT` / `GOPATH` / `GOBIN` / `NODE_PATH` / `NVM_BIN` / `NVM_DIR` / `MAVEN_HOME` / `M2_HOME` / `GRADLE_HOME` / `SDKMAN_DIR` / `CONDA_PREFIX`。
26
+
27
+ **其他**
28
+
29
+ - **纯手动**:启动不探测、不连接 IDE MCP;IDE MCP 注册、环境重读、引导注入全部由 `/open-in-idea` 触发。
30
+ - **根级 `index.js` 转发入口**:使仓库可作为 V2 本地目录插件直接加载(`plugins: [{ "package": "<repo>" }]`)。
31
+ - **配置项**:`ports` / `injectEnv` / `injectGuidance` / `openInIde` / `launchCooldownMs` / `launchMaxAttempts` / `mcpProbeTimeoutMs` / `feedback`。
32
+ - 仅支持 **OpenCode V2**;非 JetBrains 项目完全不受影响。
package/README.md ADDED
@@ -0,0 +1,197 @@
1
+ # opencode-idea
2
+
3
+ OpenCode 插件,面向 JetBrains 项目(IntelliJ IDEA / PyCharm / WebStorm 等),做三件事:
4
+
5
+ 1. **接入 IDE MCP** —— 用 `/open-in-idea` 把当前项目在 IntelliJ IDEA 中打开,并把这个 IDE 的 MCP 服务(60 个工具:检索、读、改、构建、重构、调试、数据库……)注册进 OpenCode。
6
+ 2. **优先使用 IDE 工具** —— IDE 可用时,向模型注入"项目内操作默认走 IDE MCP"的系统提示引导 + 能力映射;IDE 不可用时回退原生工具(正常回退,无需声明)。
7
+ 3. **注入 IDE 环境变量** —— 从 IDE 集成终端读取它实际运行的环境(版本化管理的 Node、goenv 的 Go、SDKMAN 的 Java、Maven……)注入每个 OpenCode shell。
8
+
9
+ 非 JetBrains 项目完全不受影响。**全部手动**:插件启动不探测、不连接 IDE,只有跑 `/open-in-idea` 时才连接。
10
+
11
+ ## 工作原理
12
+
13
+ ```text
14
+ setup(启动):
15
+ a. 读 IDE 集成终端环境 → 通过 shell create.before 钩子注入每个 shell(null: 未连 IDE 时为空)
16
+ b. 注册 /open-in-idea 命令 + session context 钩子(注入引导)
17
+ 不探测、不注册 MCP
18
+
19
+ /open-in-idea(手动触发):
20
+ 1. 探测本地 JetBrains IDE MCP 服务(/stream)
21
+ 2. 已就绪 → 直接连接
22
+ 3. 未就绪 → open -a "IntelliJ IDEA" <项目目录>(复用已运行实例),然后立即提示:
23
+ 在 IDEA 中开启 MCP 服务 + Brave Mode
24
+ 4. 本次命令 fast-fail 返回;用户手动开启后再次执行 `/open-in-idea`
25
+ 5. MCP 就绪时:
26
+ a. 注册 IDE MCP(url /stream,header IJ_MCP_SERVER_PROJECT_PATH 锁定当前项目)
27
+ b. 读 IDE 终端环境 → 更新注入每个 shell 的环境变量
28
+ c. 启用系统提示引导(直接调用 `idea_*` 工具)+ IDEA MCP 工具描述前缀
29
+ 6. 发一条结果通知(用户看到干净气泡,模型只回"收到")
30
+ ```
31
+
32
+ ## 在 IDE 中打开项目(`/open-in-idea`)
33
+
34
+ 在会话里输入 `/open-in-idea`:
35
+
36
+ 1. 先探测 `/stream`;已就绪 → 直接加载能力;
37
+ 2. 未就绪 → 用 `open -a "IntelliJ IDEA" <项目目录>` 拉起或激活 IDEA —— **复用已运行的实例**(不会重复开新实例);
38
+ 3. 如果 MCP 仍未就绪,命令会**立即 fast-fail**,提示在 IDEA 的 `Settings → MCP Server` 开启 MCP 服务并启用 **Brave Mode**;
39
+ 4. 用户手动完成设置后,再次执行 `/open-in-idea`;探测成功后才注册 IDE MCP、重读 IDE 环境并启用系统引导与 live tool catalog。
40
+
41
+ > **通知不触发 AI 干活**:默认 `feedback: "message"` 把结果作为一条**标注清楚的通知**发到会话里 —— 用户看到干净的通知气泡(经 `metadata.displayText`),模型只看到"这是通知,请只回复『收到』"的指令,因此不会执行任何操作、不会调用工具。`feedback: false` 则完全静默。
42
+
43
+ **可重入 / 启动保护**:IDEA 冷启动、索引或等待「Trust and Open Project」弹窗期间探测会失败。命令只发一次 MCP/Brave Mode 提示并 fast-fail,不会在后台轮询;重复触发共享同一次打开请求,按目录维护 `lastSpawnAt` / `attempts`,避免重复 spawn。开启 MCP + Brave Mode 后再次执行 `/open-in-idea` 即可接上。
44
+
45
+ **热更新会断开**:插件源码变更会触发 V2 热重载,注册随旧实例一起释放,`activePort` 归零。这是手动模式的预期行为 —— **改完插件后重跑一次 `/open-in-idea`** 即可恢复。
46
+
47
+ ## IDE 工具优先(直接调用引导)
48
+
49
+ IDE MCP 可用时,插件通过两层轻量引导提高模型主动使用 IDEA MCP 的概率;不会修改原生工具的定义、描述或可用性:
50
+
51
+ 1. **系统提示注入**(主):通过 `ctx.session.hook("context", …)` 往每次请求的 system 里追加一段「项目内操作优先使用 IDEA MCP」的直接调用引导 + 能力映射表(`src/ide-guidance.js`),含工具名(`idea_<name>`)与关键参数名:
52
+ - 检索 → `idea_search_text` / `idea_search_regex` / `idea_search_file`(参数 `q`,可带 `paths` 收窄)
53
+ - 读文件 → `idea_read_file`(`file_path` / `offset` / `limit`);目录 → `idea_list_directory_tree`
54
+ - 改文件 → `idea_apply_patch`(`input`);新建 → `idea_create_new_file`(`pathInProject`)
55
+ - 校验 → `idea_lint_files` / `idea_get_file_problems` / `idea_build_project`
56
+ - 重构/格式化 → `idea_rename_refactoring` / `idea_reformat_file`;版本 → `idea_git_status`
57
+ - 例外(直接用原生):IDE 不索引的目录、`git diff/log/blame`、MCP 无等价能力、IDE MCP 未连接/未注册
58
+
59
+ 连接成功后,插件只对 `idea_*` IDEA MCP 工具追加一段简短描述,要求模型直接调用、严格按当前 schema 传参;不会把整份工具目录重复塞进 system prompt,也不要求模型额外包一层 JavaScript 编排代码。
60
+
61
+ IDEA MCP 工具自身的 description/schema 仍是参数使用的权威来源;工具返回业务错误时先修正参数,不要误判成工具不可用。MCP 不可用时立即提示用户执行 `/open-in-idea`,不重复探测或等待。
62
+
63
+ 2. **IDEA MCP 工具描述增强**:插件通过 `ctx.tool.transform` 只更新 `idea_*` / `idea.*` 工具的 description,强调直接调用和按 schema 传参;原生 `edit` / `write` / `patch` / `shell` 完全不变。
64
+
65
+ 引导只在 **IDE MCP 可用**时生效;MCP 不可用时仅保留 fast-fail 恢复提示。`injectGuidance: false` 可关闭系统提示注入。
66
+
67
+ ## 环境变量注入
68
+
69
+ **唯一来源:IDE 集成终端**(`execute_terminal_command printenv`)。它反映 IDE 实际运行的环境 —— 版本化管理的 Node(如 fnm v14)、goenv 的 Go、SDKMAN 的 Java 等 —— 这些 .idea 配置里往往读不到。
70
+
71
+ 抽取的变量(`src/ide-env.js` 的 `SDK_ENV_KEYS`):`JAVA_HOME` / `JRE_HOME` / `JDK_HOME` / `VIRTUAL_ENV` / `PYTHONPATH` / `PYTHONHOME` / `GOROOT` / `GOPATH` / `GOBIN` / `NODE_PATH` / `NVM_BIN` / `NVM_DIR` / `MAVEN_HOME` / `M2_HOME` / `GRADLE_HOME` / `SDKMAN_DIR` / `CONDA_PREFIX`。
72
+
73
+ - 注入通过 V2 的 `shell` `create.before` 钩子:只补这些变量 + 前置 PATH,不覆盖你原有的 PATH。
74
+ - 需要 IDE MCP 可达 + **Brave Mode**;拿不到时环境为空(不影响其他功能)。
75
+
76
+ > 与 [opencode-env-loader](https://github.com/syllr/opencode-env-loader) 的关系:两者独立。env-loader 负责 `.opencode/env-loader/*` 的**人工** KEY=VALUE 覆盖;本插件负责从 **IDE 实际环境**推导。可以同时使用。
77
+
78
+ ## 前置条件
79
+
80
+ - JetBrains IDE 已开启 MCP 服务(IDE 设置里的 MCP Server / AI Assistant MCP),版本需 **2026.2+**(提供 Streamable HTTP 端点 `/stream`);若关闭,`/open-in-idea` 会立即提示如何重新开启。
81
+ - 要读 IDE 终端环境,需要在 IDE 里开启 **Brave Mode**(否则 `execute_terminal_command` 会等确认);MCP 未就绪时通知会同时提醒开启它。
82
+ - 插件注册给 OpenCode 的 MCP 配置形如:
83
+
84
+ ```jsonc
85
+ {
86
+ "type": "remote",
87
+ "url": "http://127.0.0.1:64342/stream",
88
+ "headers": { "IJ_MCP_SERVER_PROJECT_PATH": "<项目路径>" },
89
+ }
90
+ ```
91
+
92
+ > **为什么是 `/stream` 而不是 `/sse`**:OpenCode V2 的远程 MCP 客户端**只支持 Streamable HTTP**,没有旧版 SSE 传输。IDE 的 `/sse` 是旧版 SSE(POST 会返回 405),V2 连不上(报 `Error POSTing to endpoint`)。IDE 2026.2 起额外提供 `/stream` 这个 Streamable HTTP 端点。**插件的探测也走 `/stream`**(一次 `initialize`),协议统一。
93
+
94
+ 插件按 `ports` 顺序选择第一个可用端口,但会并发探测以避免多个端口的超时叠加;默认 `[64342, 6420, 6421, 63342]`。
95
+
96
+ ## 安装
97
+
98
+ ```json
99
+ {
100
+ "plugin": ["opencode-idea"]
101
+ }
102
+ ```
103
+
104
+ > 仅支持 **OpenCode V2**。若你日常跑的是 1.x 的 `opencode-ai`,它用的是旧插件 API,本插件不会生效;真机验证请用 V2 运行时。
105
+
106
+ ### 本地开发 / 真机验证
107
+
108
+ 插件未发布时,可以直接指向本地仓库目录。V2 解析**目录**插件时找的是 `<dir>/index.*`(或 `<dir>/server.*`),不会读 `package.json#main`,所以仓库根目录提供了 `index.js` 转发到 `src/index.js`。
109
+
110
+ 全局配置 `~/.config/opencode/opencode.json`(V2 桌面端/CLI 共用):
111
+
112
+ ```jsonc
113
+ {
114
+ "plugins": [{ "package": "/Users/yutao/Projects/opencode-idea" }],
115
+ }
116
+ ```
117
+
118
+ 也可以沿用旧键 `plugin` 的元组写法:
119
+
120
+ ```jsonc
121
+ {
122
+ "plugin": [["/Users/yutao/Projects/opencode-idea", {}]],
123
+ }
124
+ ```
125
+
126
+ 配置保存后 V2 会监听并自动重载(日志里能看到它开始 watch `index.js` / `src/*.js`),无需重启。改插件源码也会触发重载(此时需重跑 `/open-in-idea`)。
127
+
128
+ 另一种零配置方式:在 `~/.config/opencode/plugins/` 放一个转发文件(自动发现,但不能传 options):
129
+
130
+ ```js
131
+ // ~/.config/opencode/plugins/opencode-idea.js
132
+ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
133
+ ```
134
+
135
+ ## 配置
136
+
137
+ ```jsonc
138
+ {
139
+ "plugin": [
140
+ [
141
+ "opencode-idea",
142
+ {
143
+ "ports": [64342, 6420, 6421, 63342],
144
+ "injectEnv": true,
145
+ "injectGuidance": true, // IDE 可用时向系统提示注入「默认走 IDE MCP」引导
146
+ "openInIde": "idea", // false 关闭;或任意 macOS 应用名(如 "IntelliJ IDEA")
147
+ "launchCooldownMs": 120000, // 启动保护冷却
148
+ "launchMaxAttempts": 5,
149
+ "mcpProbeTimeoutMs": 1000, // 单个 MCP 端口的短探测超时
150
+ "feedback": "message", // "message"(默认,会话通知,AI 只回"收到")| false(静默)
151
+ },
152
+ ],
153
+ ],
154
+ }
155
+ ```
156
+
157
+ | 选项 | 类型 | 默认 | 说明 |
158
+ | ------------------- | -------------------- | ---------------------------- | ---------------------------------------------------------------- |
159
+ | `ports` | `number[]` | `[64342, 6420, 6421, 63342]` | 依次探测的 IDE MCP 端口 |
160
+ | `injectEnv` | `boolean` | `true` | 是否注入 IDE 终端环境变量 |
161
+ | `injectGuidance` | `boolean` | `true` | IDE 可用时是否向系统提示注入「默认走 IDE MCP」引导 |
162
+ | `openInIde` | `boolean \| string` | `"idea"` | 拉起哪个 IDE;`false` 关闭,`"idea"` 映射到 IntelliJ IDEA |
163
+ | `launchCooldownMs` | `number` | `120000` | 启动保护冷却:冷却期内不重复 spawn |
164
+ | `launchMaxAttempts` | `number` | `5` | 单次会话内最多 spawn 次数,超出后停止重试 |
165
+ | `mcpProbeTimeoutMs` | `number` | `1000` | 单个 MCP 端口的短探测超时 |
166
+ | `feedback` | `"message" \| false` | `"message"` | 结果反馈:`message` 会话通知(用户可见,AI 只回"收到");`false` 静默 |
167
+
168
+ ## 行为
169
+
170
+ - **JetBrains 项目**:启动即注入 IDE 终端环境(若此前已连过 IDE);`/open-in-idea` 接入 IDE MCP + 启用引导。
171
+ - **非 JetBrains 项目**:不改环境、不注册、不改描述。
172
+ - **纯手动**:启动**不探测、不连接 IDE MCP**;IDE MCP 注册、环境重读、系统提示引导全部由 `/open-in-idea` 触发。IDE 中途关闭不会自动撤销,需再执行一次 `/open-in-idea` 重新同步。
173
+ - **`/open-in-idea`**:手动打开当前项目并即时加载能力(已在 IDE 打开时则只重新加载);MCP 未开启时只发一次提示并 fast-fail,用户手动开启 MCP + Brave Mode 后重试。
174
+ - **IDEA 工具增强**:通过 `ctx.tool.transform` 只更新 `idea_*` / `idea.*` 工具 description,原生 `edit` / `write` / `patch` / `shell` 的定义、描述和可用性均不变。
175
+
176
+ ## 限制
177
+
178
+ - 仅支持 **OpenCode V2** 插件 API(`ctx.tool` / `ctx.mcp` / `ctx.shell` / `ctx.command` / `ctx.session` / `ctx.location`)。
179
+ - IDE MCP 注册需要 IDE **2026.2+** 的 Streamable HTTP 端点 `/stream`;更早版本只有 `/sse`,OpenCode V2 无法连接。
180
+ - 环境变量依赖 `execute_terminal_command` + Brave Mode;拿不到时环境为空。
181
+ - 探测只确认「MCP 服务是否在监听」,不校验「当前项目是否已在 IDE 打开」;MCP 关闭时会用一次短探测并立即给出设置提示。
182
+ - 打开 IDE 仅实现 macOS(`open -a`);首次打开若弹 Trust 对话框,需确认后重试。
183
+ - IDE MCP 没有「打开项目」工具,因此打开动作走 OS/CLI,而非 MCP。
184
+ - 插件热重载会断开 IDE 连接,需重跑 `/open-in-idea`。
185
+ - 插件只修改 IDEA MCP 工具 description,不修改原生工具定义、描述或可用性;默认调用方式为直接 `idea_*` 工具调用。
186
+
187
+ ## 测试
188
+
189
+ ```bash
190
+ npm test
191
+ ```
192
+
193
+ 使用 [Vitest](https://vitest.dev/),含纯函数测试、IDE 环境解析、环境合并、启动保护状态机、IDE 启动器、引导文本,以及「假 IDE MCP 服务」的端到端探测与插件集成测试。
194
+
195
+ ## License
196
+
197
+ MIT
package/UPGRADING.md ADDED
@@ -0,0 +1,8 @@
1
+ # 升级指南
2
+
3
+ ## 0.0.1
4
+
5
+ 初始版本,暂无迁移路径。
6
+
7
+ > 从旧包名 `opencode-jetbrains-mcp` 迁移:把 OpenCode 配置里的
8
+ > `opencode-jetbrains-mcp` 改为 `opencode-idea`,其余配置项不变。
package/index.js ADDED
@@ -0,0 +1,11 @@
1
+ // Local-development entrypoint.
2
+ //
3
+ // OpenCode V2 resolves a *directory* plugin by looking for `<dir>/server.*` or
4
+ // `<dir>/index.*` (it does not read `package.json#main` for local directories).
5
+ // Published consumers resolve the package by name through `main` (./src/index.js).
6
+ // This file lets the repository be loaded directly as a local plugin:
7
+ //
8
+ // "plugins": [{ "package": "/path/to/opencode-idea", "options": { ... } }]
9
+ //
10
+ // See README.md § "本地开发 / 真机验证".
11
+ export { default } from './src/index.js';
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "opencode-idea",
3
+ "version": "0.0.1",
4
+ "description": "OpenCode plugin that connects to JetBrains IDE MCP servers and prefers IDE tools when the current project is open in the IDE",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "files": [
8
+ "index.js",
9
+ "src/index.js",
10
+ "src/plugin.js",
11
+ "src/idea-mcp.js",
12
+ "src/ide-env.js",
13
+ "src/project.js",
14
+ "src/env.js",
15
+ "src/ide-launcher.js",
16
+ "src/ide-guidance.js",
17
+ "src/idea-tool-descriptions.js",
18
+ "skills",
19
+ "README.md",
20
+ "CHANGELOG.md",
21
+ "UPGRADING.md"
22
+ ],
23
+ "scripts": {
24
+ "test": "vitest run",
25
+ "prepublishOnly": "npm test"
26
+ },
27
+ "keywords": [
28
+ "opencode",
29
+ "opencode-plugin",
30
+ "jetbrains",
31
+ "intellij",
32
+ "idea",
33
+ "mcp",
34
+ "ide"
35
+ ],
36
+ "author": "yutao",
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/syllr/opencode-idea.git"
41
+ },
42
+ "devDependencies": {
43
+ "vitest": "^2"
44
+ },
45
+ "engines": {
46
+ "node": ">=18.0.0"
47
+ }
48
+ }
@@ -0,0 +1,36 @@
1
+ ---
2
+ name: jetbrains-mcp-setup
3
+ description: 配置并验证 opencode-idea 插件:开启 JetBrains IDE 的 MCP 服务、找到端口、写入 opencode.json、验证 OpenCode 能连上并优先使用 IDE 工具。当用户要「接入 IDEA/JetBrains 的 MCP」「让 OpenCode 用 IDE 工具编辑/构建」或排查插件不生效时使用。
4
+ ---
5
+
6
+ # JetBrains MCP 接入配置
7
+
8
+ ## 步骤
9
+
10
+ 1. **确认 IDE 侧已开启 MCP 服务**
11
+ - 在 JetBrains IDE 中打开 Settings → Tools → MCP Server(或 AI Assistant 的 MCP 设置),启用。
12
+ - 让用户在 IDE 里打开要操作的项目。
13
+
14
+ 2. **拿到 MCP 端口**
15
+ - IDE 通常显示形如 `http://127.0.0.1:<port>/sse` 的地址;记录 `<port>`。
16
+ - 若用户不确定,可用 `ports` 默认列表让插件自动探测。
17
+
18
+ 3. **在项目 `opencode.json` 注册插件**
19
+
20
+ ```jsonc
21
+ {
22
+ "plugin": [
23
+ ["opencode-idea", { "ports": [<port>] }]
24
+ ]
25
+ }
26
+ ```
27
+
28
+ 4. **验证**
29
+ - 重启 OpenCode(或触发插件重载)。
30
+ - 让用户确认:当前项目在 IDE 中打开时,OpenCode 是否优先用 IDE 工具;关闭项目后是否回退原生。
31
+
32
+ ## 排查
33
+
34
+ - 插件不生效:确认 OpenCode 是 **V2**(需要 `ctx.tool` / `ctx.mcp`)。
35
+ - 一直探测失败:确认 IDE MCP 在跑、端口正确、当前项目确实在 IDE 中打开。
36
+ - 判断依据:IDE MCP 的 `tools/call get_project_modules` 在项目打开时成功、未打开时返回 `isError`。
package/src/env.js ADDED
@@ -0,0 +1,39 @@
1
+ // Merges environment sources and applies them to a shell `create.before`
2
+ // invocation.
3
+
4
+ import path from 'node:path';
5
+
6
+ /**
7
+ * Merge env sources. Later sources win. `prependPath` entries are collected in
8
+ * order and applied ahead of the existing PATH.
9
+ *
10
+ * @param {Array<{ env?: Record<string, string>, prependPath?: string[] }>} sources
11
+ * @returns {{ env: Record<string, string>, prependPath: string[] }}
12
+ */
13
+ export function mergeEnv(sources) {
14
+ const env = {};
15
+ const prependPath = [];
16
+ for (const source of sources) {
17
+ Object.assign(env, source.env ?? {});
18
+ prependPath.push(...(source.prependPath ?? []));
19
+ }
20
+ return { env, prependPath: [...new Set(prependPath)] };
21
+ }
22
+
23
+ /**
24
+ * Apply a merged env to a shell invocation's `env` in place.
25
+ *
26
+ * @param {{ env: Record<string, string | undefined> }} input
27
+ * @param {{ env: Record<string, string>, prependPath: string[] }} merged
28
+ * @param {{ pathKey?: string }} [options]
29
+ */
30
+ export function applyEnv(input, merged, options = {}) {
31
+ const pathKey = options.pathKey ?? 'PATH';
32
+ for (const [key, value] of Object.entries(merged.env)) {
33
+ if (value !== undefined) input.env[key] = value;
34
+ }
35
+ if (merged.prependPath.length === 0) return;
36
+ const current = input.env[pathKey] ?? process.env[pathKey] ?? '';
37
+ const parts = [...merged.prependPath, ...current.split(path.delimiter).filter(Boolean)];
38
+ input.env[pathKey] = [...new Set(parts)].join(path.delimiter);
39
+ }
package/src/ide-env.js ADDED
@@ -0,0 +1,101 @@
1
+ // Reads the IDE terminal environment over MCP and extracts the SDK-related
2
+ // variables. The IDE terminal is the single source of truth for the environment
3
+ // the IDE actually runs with (version-managed Node, goenv Go, SDKMAN Java, ...).
4
+
5
+ import { callTool } from './idea-mcp.js';
6
+
7
+ /** SDK-related variables worth mirroring into OpenCode's shell environment. */
8
+ export const SDK_ENV_KEYS = [
9
+ 'JAVA_HOME',
10
+ 'JRE_HOME',
11
+ 'JDK_HOME',
12
+ 'VIRTUAL_ENV',
13
+ 'PYTHONPATH',
14
+ 'PYTHONHOME',
15
+ 'GOROOT',
16
+ 'GOPATH',
17
+ 'GOBIN',
18
+ 'NODE_PATH',
19
+ 'NVM_BIN',
20
+ 'NVM_DIR',
21
+ 'MAVEN_HOME',
22
+ 'M2_HOME',
23
+ 'GRADLE_HOME',
24
+ 'SDKMAN_DIR',
25
+ 'CONDA_PREFIX',
26
+ ];
27
+
28
+ /**
29
+ * Read SDK env from the IDE integrated terminal via `execute_terminal_command`.
30
+ * Requires the IDE MCP to be reachable and Brave Mode enabled (else the IDE
31
+ * waits for a confirmation and this returns `{}`).
32
+ *
33
+ * @param {number} port
34
+ * @param {string} projectPath
35
+ * @param {typeof fetch} [fetchImpl]
36
+ * @returns {Promise<Record<string, string>>}
37
+ */
38
+ export async function readIdeTerminalEnv(port, projectPath, fetchImpl = fetch) {
39
+ const result = await callTool(
40
+ port,
41
+ projectPath,
42
+ 'execute_terminal_command',
43
+ { command: 'printenv', executeInShell: true, timeout: 8000, maxLinesCount: 800 },
44
+ { fetchImpl, timeoutMs: 15000 },
45
+ ).catch(() => undefined);
46
+ if (!result || result.isError) return {};
47
+ const text = (result.content ?? [])
48
+ .filter((part) => part.type === 'text' && typeof part.text === 'string')
49
+ .map((part) => part.text)
50
+ .join('\n');
51
+ return extractSdkEnv(parseCommandOutput(text));
52
+ }
53
+
54
+ /**
55
+ * Parse the `execute_terminal_command` payload. It is JSON with a
56
+ * `command_output` field; fall back to treating the text as raw env output.
57
+ *
58
+ * @param {string} text
59
+ * @returns {Record<string, string>}
60
+ */
61
+ export function parseCommandOutput(text) {
62
+ try {
63
+ const parsed = JSON.parse(text);
64
+ if (parsed && typeof parsed.command_output === 'string') return parsePrintenv(parsed.command_output);
65
+ } catch {
66
+ // not JSON — treat as raw printenv output
67
+ }
68
+ return parsePrintenv(text);
69
+ }
70
+
71
+ /**
72
+ * Parse `KEY=VALUE` lines (printenv output) into an object.
73
+ *
74
+ * @param {string} text
75
+ * @returns {Record<string, string>}
76
+ */
77
+ export function parsePrintenv(text) {
78
+ const env = {};
79
+ for (const line of text.split('\n')) {
80
+ const index = line.indexOf('=');
81
+ if (index <= 0) continue;
82
+ const key = line.slice(0, index).trim();
83
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
84
+ env[key] = line.slice(index + 1);
85
+ }
86
+ return env;
87
+ }
88
+
89
+ /**
90
+ * Keep only the SDK-related variables.
91
+ *
92
+ * @param {Record<string, string>} env
93
+ * @returns {Record<string, string>}
94
+ */
95
+ export function extractSdkEnv(env) {
96
+ const out = {};
97
+ for (const key of SDK_ENV_KEYS) {
98
+ if (env[key]) out[key] = env[key];
99
+ }
100
+ return out;
101
+ }
@@ -0,0 +1,52 @@
1
+ // Direct IDEA MCP usage guidance injected into the model's system prompt.
2
+ // The plugin does not modify native OpenCode tools.
3
+
4
+ /** The guidance text appended to the system prompt. */
5
+ export const IDE_GUIDANCE = [
6
+ '## 项目内操作优先使用 IDEA MCP 工具',
7
+ '当前项目已在 JetBrains IDE 中打开。普通工具调用名称是 `idea_<name>`,例如 `idea_search_text`、`idea_read_file`、`idea_apply_patch`;项目内检索、读文件、改文件、校验和目录浏览优先直接调用这些 IDEA MCP 工具。',
8
+ '直接调用工具本身,不要为了调用 IDEA MCP 工具再包一层额外的 JavaScript 编排代码。',
9
+ '',
10
+ '常用工具与关键参数:',
11
+ '- 文本 / 正则 / 文件名检索:`idea_search_text` / `idea_search_regex` / `idea_search_file` —— 参数 `q`(不是 `query`),可带 `paths` 收窄。',
12
+ '- 符号 / 语义:`idea_search_symbol` / `idea_get_symbol_info` / `idea_analyze_calls`(只用 `depth=1`)。',
13
+ '- 读文件:`idea_read_file`(`file_path` / `offset` / `limit`)。',
14
+ '- 目录浏览:`idea_list_directory_tree`(代替 `ls` / `find`,`maxDepth` ≥ 2)。',
15
+ '- 修改已有文件:`idea_apply_patch`(参数 `input`,传 patch 文本)。',
16
+ '- 新建文件:`idea_create_new_file`(参数 `pathInProject`,可选 `text` / `overwrite`)。',
17
+ '- 校验:`idea_lint_files` / `idea_get_file_problems` / `idea_build_project`。',
18
+ '- 重构 / 格式化:`idea_rename_refactoring` / `idea_reformat_file`。',
19
+ '- 结构 / 依赖:`idea_get_project_modules` / `idea_get_project_dependencies`。',
20
+ '- 版本状态:`idea_git_status`;终端命令:`idea_execute_terminal_command`。',
21
+ '',
22
+ '调用纪律:',
23
+ '- 直接调用当前工具 schema 中声明的参数,不要猜参数名;工具自身的 description 是参数使用的权威来源。',
24
+ '- 工具返回 `File already exists` 等业务错误时,先修正调用参数,不要判断成工具不存在。',
25
+ '- 如果 IDEA MCP 未连接、工具缺失、连接被拒绝或超时,立即提示用户执行 `/open-in-idea`,不要反复探测或长时间等待。',
26
+ '',
27
+ '例外(直接使用原生工具):`node_modules/`、`target/` 等 IDE 不索引的目录;`git diff` / `log` / `blame` 等 git 独有语义;`lsof` / `nc` / `ping` 等 MCP 无等价能力;IDE MCP 未连接 / 未注册;项目根之外的文件。',
28
+ ].join('\n');
29
+
30
+ export const MCP_RECOVERY_GUIDANCE = [
31
+ '### IDEA MCP failure recovery',
32
+ 'If an idea.* / idea_* tool is missing, MCP is disconnected, the connection is refused, or a tool times out: stop retrying immediately; do not run extra probes or wait in tool calls. Tell the user that IDEA MCP is unavailable and ask them to run /open-in-idea to reconnect.',
33
+ ].join('\n');
34
+
35
+ /**
36
+ * Push the guidance onto a session `system` array. Called for every primary
37
+ * model request, so it stays idempotent per request.
38
+ *
39
+ * @param {unknown} system
40
+ */
41
+ export function appendIdeGuidance(system) {
42
+ if (!Array.isArray(system)) return;
43
+ if (system.some((part) => part?.text === IDE_GUIDANCE)) return;
44
+ system.push({ type: 'text', text: IDE_GUIDANCE });
45
+ }
46
+
47
+ /** @param {unknown} system */
48
+ export function appendIdeRecoveryGuidance(system) {
49
+ if (!Array.isArray(system)) return;
50
+ if (system.some((part) => part?.text === MCP_RECOVERY_GUIDANCE)) return;
51
+ system.push({ type: 'text', text: MCP_RECOVERY_GUIDANCE });
52
+ }
@@ -0,0 +1,96 @@
1
+ // Launching the JetBrains IDE for a project.
2
+ //
3
+ // RE-ENTRANCY: the Toolbox CLI scripts in /usr/local/bin (`idea`, `webstorm`,
4
+ // ...) run `open -na "<app>" --args <dir>` — `-n` forces a NEW IDE instance, so
5
+ // repeated calls spawn duplicates. We instead run `open -a "<app>" <dir>`,
6
+ // which hands the directory to an already-running instance (macOS "open
7
+ // documents" event) and only launches the app when none is running. Verified on
8
+ // macOS: `open -a "IntelliJ IDEA"` while IDEA is up leaves the process count
9
+ // unchanged.
10
+ //
11
+ // The caller performs the MCP availability check and fast-fails when it is
12
+ // unavailable; this module only requests opening/activating the project.
13
+
14
+ import { spawn } from 'node:child_process';
15
+
16
+ /** Default macOS app name for IntelliJ IDEA. */
17
+ export const DEFAULT_IDE_APP = 'IntelliJ IDEA';
18
+
19
+ /** Aliases accepted by the `openInIde` option. */
20
+ export const IDE_APPS = {
21
+ idea: 'IntelliJ IDEA',
22
+ intellij: 'IntelliJ IDEA',
23
+ 'intellij-idea': 'IntelliJ IDEA',
24
+ };
25
+
26
+ /**
27
+ * Resolve the `openInIde` option to a macOS app name, or `undefined` to disable.
28
+ *
29
+ * @param {boolean | string | undefined} value
30
+ * @returns {string | undefined}
31
+ */
32
+ export function resolveIdeApp(value) {
33
+ if (value === false) return undefined;
34
+ if (typeof value !== 'string') return DEFAULT_IDE_APP;
35
+ return IDE_APPS[value.toLowerCase()] ?? value;
36
+ }
37
+
38
+ /**
39
+ * Open `directory` in the IDE, reusing a running instance when possible.
40
+ * Never throws; resolves `true` when `open` reported success.
41
+ *
42
+ * @param {string} directory
43
+ * @param {{ app?: string, spawnImpl?: typeof spawn }} [options]
44
+ * @returns {Promise<boolean>}
45
+ */
46
+ export function openInIde(directory, options = {}) {
47
+ const app = options.app || DEFAULT_IDE_APP;
48
+ const spawnImpl = options.spawnImpl || spawn;
49
+ return new Promise((resolve) => {
50
+ let child;
51
+ try {
52
+ child = spawnImpl('open', ['-a', app, directory], { stdio: 'ignore' });
53
+ } catch {
54
+ resolve(false);
55
+ return;
56
+ }
57
+ child.once('error', () => resolve(false));
58
+ child.once('close', (code) => resolve(code === 0));
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Per-directory launch guard. Prevents a cold IDE (indexing / waiting for the
64
+ * Trust dialog) from being spawned repeatedly by overlapping reconciles or
65
+ * command invocations.
66
+ *
67
+ * @param {{ cooldownMs?: number, maxAttempts?: number, now?: () => number }} [options]
68
+ */
69
+ export function createLaunchGuard(options = {}) {
70
+ const cooldownMs = options.cooldownMs ?? 120000;
71
+ const maxAttempts = options.maxAttempts ?? 5;
72
+ const now = options.now ?? Date.now;
73
+ let lastSpawnAt = 0;
74
+ let attempts = 0;
75
+ const coolingDown = () => attempts > 0 && now() - lastSpawnAt < cooldownMs;
76
+ return {
77
+ /** True once the attempt budget is spent; callers should stop retrying. */
78
+ exhausted: () => attempts >= maxAttempts,
79
+ /** True while a recent spawn is still expected to come up. */
80
+ coolingDown,
81
+ /** True when it is safe to spawn a new instance now. */
82
+ canSpawn: () => attempts < maxAttempts && !coolingDown(),
83
+ /** Record a spawn attempt. */
84
+ markSpawn() {
85
+ lastSpawnAt = now();
86
+ attempts += 1;
87
+ },
88
+ /** Clear the guard after the IDE is confirmed serving. */
89
+ reset() {
90
+ lastSpawnAt = 0;
91
+ attempts = 0;
92
+ },
93
+ /** Current state, for diagnostics/tests. */
94
+ state: () => ({ lastSpawnAt, attempts }),
95
+ };
96
+ }
@@ -0,0 +1,195 @@
1
+ // JetBrains IDE MCP server: endpoint config and availability probe.
2
+ //
3
+ // HOW THE IDE MCP SERVER WORKS (verified against a real IntelliJ IDEA 2026.2
4
+ // server):
5
+ //
6
+ // * The IDE exposes a local MCP server. IntelliJ 2026.2+ exposes the modern
7
+ // Streamable-HTTP endpoint at `http://127.0.0.1:<port>/stream`; older builds
8
+ // only had the legacy SSE endpoint at `/sse`.
9
+ // * OpenCode V2's remote MCP client speaks ONLY Streamable HTTP, so the plugin
10
+ // registers `/stream` (see `serverConfig`). The legacy `/sse` endpoint is
11
+ // unusable from OpenCode (POST returns 405 → "Error POSTing to endpoint").
12
+ // * The project a call targets is selected by the HTTP header
13
+ // `IJ_MCP_SERVER_PROJECT_PATH`.
14
+ // * Availability probe: a single `initialize` POST to `/stream` succeeds when
15
+ // the MCP server is up. We do NOT check whether a specific project is open —
16
+ // the user triggers `/open-in-idea` for the project they want, so "server is
17
+ // up" is the only thing worth knowing.
18
+
19
+ export const PROJECT_HEADER = 'IJ_MCP_SERVER_PROJECT_PATH';
20
+
21
+ /** OpenCode MCP server name registered for the IDE. */
22
+ export const IDEA_SERVER_NAME = 'idea';
23
+
24
+ /** Streamable-HTTP endpoint exposed by the IDE MCP server (IntelliJ 2026.2+). */
25
+ export const MCP_STREAM_PATH = '/stream';
26
+
27
+ /** Ports to try when the IDE MCP port is not configured explicitly. */
28
+ export const DEFAULT_PORTS = [64342, 6420, 6421, 63342];
29
+
30
+ const PROTOCOL_VERSION = '2025-06-18';
31
+ // A probe is only a local availability check. Keep it short: a disabled IDE
32
+ // MCP endpoint must not make the user wait for the launch timeout before the
33
+ // actionable notification is sent.
34
+ const PROBE_TIMEOUT_MS = 1000;
35
+ const CALL_TIMEOUT_MS = 15000;
36
+
37
+ /**
38
+ * Build the OpenCode MCP server config for a given IDE port + project.
39
+ *
40
+ * @param {number} port
41
+ * @param {string} projectPath
42
+ */
43
+ export function serverConfig(port, projectPath) {
44
+ return {
45
+ type: 'remote',
46
+ url: `http://127.0.0.1:${port}${MCP_STREAM_PATH}`,
47
+ headers: { [PROJECT_HEADER]: projectPath },
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Is an IDE MCP server listening on `port`? One Streamable-HTTP `initialize`
53
+ * POST decides it: any 2xx JSON reply means the server is up.
54
+ *
55
+ * @param {number} port
56
+ * @param {{ fetchImpl?: typeof fetch, timeoutMs?: number }} [options]
57
+ * @returns {Promise<boolean>}
58
+ */
59
+ export async function probePort(port, options = {}) {
60
+ const fetchImpl = options.fetchImpl ?? fetch;
61
+ const controller = new AbortController();
62
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? PROBE_TIMEOUT_MS);
63
+ try {
64
+ const response = await fetchImpl(`http://127.0.0.1:${port}${MCP_STREAM_PATH}`, {
65
+ method: 'POST',
66
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
67
+ // Do not follow redirects. IntelliJ can keep its normal HTTP port open
68
+ // while MCP is disabled and redirect `/stream` to an HTML 404 page;
69
+ // following that redirect only adds latency and still means unavailable.
70
+ redirect: 'manual',
71
+ body: JSON.stringify({
72
+ jsonrpc: '2.0',
73
+ id: 1,
74
+ method: 'initialize',
75
+ params: {
76
+ protocolVersion: PROTOCOL_VERSION,
77
+ capabilities: {},
78
+ clientInfo: { name: 'opencode-idea', version: '0.0.1' },
79
+ },
80
+ }),
81
+ signal: controller.signal,
82
+ });
83
+ return response.ok;
84
+ } catch {
85
+ return false;
86
+ } finally {
87
+ clearTimeout(timer);
88
+ controller.abort();
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Find the first port whose IDE MCP server responds. Returns the port, or
94
+ * `undefined` when none is available.
95
+ *
96
+ * @param {number[]} ports
97
+ * @param {{ fetchImpl?: typeof fetch, timeoutMs?: number }} [options]
98
+ * @returns {Promise<number | undefined>}
99
+ */
100
+ export async function findIdePort(ports, options = {}) {
101
+ // Probe all candidate ports concurrently. These are fallback locations for
102
+ // one IDE MCP endpoint, not several active MCP servers. Sequential probes
103
+ // multiply the timeout by the number of candidates and made a disabled MCP
104
+ // look like a hang.
105
+ const results = await Promise.all(
106
+ ports.map(async (port) => [port, await probePort(port, options)]),
107
+ );
108
+ return results.find(([, available]) => available)?.[0];
109
+ }
110
+
111
+ /**
112
+ * Call one MCP tool over the Streamable-HTTP endpoint. Returns the raw tool
113
+ * result (with `isError`), or `undefined` on transport/connect failure.
114
+ *
115
+ * The IDE server answers each POST with a single JSON-RPC response (it does not
116
+ * stream for these calls), so this is a plain request/response.
117
+ *
118
+ * @param {number} port
119
+ * @param {string} projectPath
120
+ * @param {string} name
121
+ * @param {object} [args]
122
+ * @param {{ fetchImpl?: typeof fetch, timeoutMs?: number }} [options]
123
+ * @returns {Promise<{ isError?: boolean, content?: Array<{ type: string, text?: string }> } | undefined>}
124
+ */
125
+ export async function callTool(port, projectPath, name, args = {}, options = {}) {
126
+ const fetchImpl = options.fetchImpl ?? fetch;
127
+ const controller = new AbortController();
128
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? CALL_TIMEOUT_MS);
129
+ const post = (payload) =>
130
+ fetchImpl(`http://127.0.0.1:${port}${MCP_STREAM_PATH}`, {
131
+ method: 'POST',
132
+ headers: {
133
+ 'Content-Type': 'application/json',
134
+ Accept: 'application/json, text/event-stream',
135
+ [PROJECT_HEADER]: projectPath,
136
+ },
137
+ body: JSON.stringify(payload),
138
+ signal: controller.signal,
139
+ });
140
+ try {
141
+ await post({
142
+ jsonrpc: '2.0',
143
+ id: 1,
144
+ method: 'initialize',
145
+ params: {
146
+ protocolVersion: PROTOCOL_VERSION,
147
+ capabilities: {},
148
+ clientInfo: { name: 'opencode-idea', version: '0.0.1' },
149
+ },
150
+ });
151
+ const response = await post({
152
+ jsonrpc: '2.0',
153
+ id: 2,
154
+ method: 'tools/call',
155
+ params: { name, arguments: args },
156
+ });
157
+ if (!response.ok) return undefined;
158
+ const message = await parseToolResponse(response);
159
+ if (!message || message.error) return undefined;
160
+ return message.result;
161
+ } catch {
162
+ return undefined;
163
+ } finally {
164
+ clearTimeout(timer);
165
+ controller.abort();
166
+ }
167
+ }
168
+
169
+ /** Read a JSON-RPC reply that may arrive as plain JSON or as an SSE `message`. */
170
+ async function parseToolResponse(response) {
171
+ const text = await response.text();
172
+ const contentType = response.headers.get('content-type') ?? '';
173
+ if (!contentType.includes('text/event-stream')) {
174
+ try {
175
+ return JSON.parse(text);
176
+ } catch {
177
+ return undefined;
178
+ }
179
+ }
180
+ for (const block of text.split('\n\n')) {
181
+ const data = block
182
+ .split('\n')
183
+ .filter((line) => line.startsWith('data:'))
184
+ .map((line) => line.slice('data:'.length).trim())
185
+ .join('\n');
186
+ if (!data) continue;
187
+ try {
188
+ const message = JSON.parse(data);
189
+ if (message.id === 2) return message;
190
+ } catch {
191
+ // ignore malformed frame
192
+ }
193
+ }
194
+ return undefined;
195
+ }
@@ -0,0 +1,39 @@
1
+ // Adds concise, direct-call guidance to IDEA MCP tool descriptions.
2
+ //
3
+ // This transform only targets tools whose effective IDs belong to the IDEA MCP
4
+ // namespace. Native OpenCode tools are intentionally left untouched.
5
+
6
+ const IDEA_TOOL_PREFIX = /^(idea_|idea\.)/;
7
+ const IDEA_EDITING_TOOLS = new Set(['idea_apply_patch', 'idea_create_new_file']);
8
+
9
+ export const IDEA_DIRECT_NOTE =
10
+ '【IDEA MCP】项目内操作请直接调用本工具;参数严格按当前工具 schema,不要猜测参数名。';
11
+
12
+ export const IDEA_EDIT_NOTE =
13
+ '【IDEA MCP 编辑】项目内文件编辑请直接调用本工具;已有文件使用 idea_apply_patch,新文件使用 idea_create_new_file;不要用 shell 写项目文件。';
14
+
15
+ const isIdeaTool = (id) => typeof id === 'string' && IDEA_TOOL_PREFIX.test(id);
16
+
17
+ const noteFor = (id) => IDEA_EDITING_TOOLS.has(id) ? IDEA_EDIT_NOTE : IDEA_DIRECT_NOTE;
18
+
19
+ /**
20
+ * Prepend direct-call guidance to IDEA MCP tool descriptions only.
21
+ * The transform is idempotent and ignores missing descriptions.
22
+ *
23
+ * @param {{
24
+ * list: () => ReadonlyArray<{ id?: string }>,
25
+ * update: (id: string, update: (tool: { description?: string }) => void) => void,
26
+ * }} editor
27
+ */
28
+ export function applyIdeaToolGuidance(editor) {
29
+ for (const tool of editor.list()) {
30
+ const id = tool?.id;
31
+ if (!isIdeaTool(id)) continue;
32
+ editor.update(id, (draft) => {
33
+ if (typeof draft.description !== 'string') return;
34
+ const note = noteFor(id);
35
+ if (draft.description.startsWith(note)) return;
36
+ draft.description = note + draft.description;
37
+ });
38
+ }
39
+ }
package/src/index.js ADDED
@@ -0,0 +1 @@
1
+ export { default } from './plugin.js';
package/src/plugin.js ADDED
@@ -0,0 +1,389 @@
1
+ // opencode-idea — OpenCode plugin entry.
2
+ //
3
+ // For a JetBrains project this plugin:
4
+ // 1. injects the IDE integrated-terminal environment (JAVA_HOME / GOROOT /
5
+ // Node / Maven / ...) into every OpenCode shell;
6
+ // 2. registers the IDE MCP server with OpenCode (scoped to the project via the
7
+ // IJ_MCP_SERVER_PROJECT_PATH header), injects direct-call guidance for
8
+ // IDEA MCP tools, and updates only IDEA MCP tool descriptions;
9
+ // 3. registers the `/open-in-idea` command, which opens the current project
10
+ // in IntelliJ IDEA (reusing a running instance) and then loads the IDE
11
+ // capabilities immediately.
12
+ //
13
+ // Non-JetBrains projects are left untouched. Everything is manual: setup does
14
+ // NOT probe or connect the IDE (only the IDE-terminal env is read when a probe
15
+ // is requested). `/open-in-idea` probes, opens the IDE, and loads the IDE MCP +
16
+ // guidance on demand.
17
+ //
18
+ // `Plugin.define` from `@opencode/plugin` is an identity function, so a plain
19
+ // `{ id, setup }` object is the whole plugin contract. Exporting it directly
20
+ // keeps this package dependency-free at runtime.
21
+
22
+ import {
23
+ DEFAULT_PORTS,
24
+ IDEA_SERVER_NAME,
25
+ findIdePort,
26
+ serverConfig,
27
+ } from './idea-mcp.js';
28
+ import { readIdeTerminalEnv } from './ide-env.js';
29
+ import { currentProjectPath, hasIdeaDirectory } from './project.js';
30
+ import { applyEnv, mergeEnv } from './env.js';
31
+ import { applyIdeaToolGuidance } from './idea-tool-descriptions.js';
32
+ import {
33
+ createLaunchGuard,
34
+ openInIde,
35
+ resolveIdeApp,
36
+ } from './ide-launcher.js';
37
+ import {
38
+ appendIdeGuidance,
39
+ appendIdeRecoveryGuidance,
40
+ } from './ide-guidance.js';
41
+
42
+ const DEFAULT_LAUNCH_COOLDOWN_MS = 120000;
43
+ const DEFAULT_LAUNCH_MAX_ATTEMPTS = 5;
44
+ // MCP availability is a fast preflight. If it is not ready, fail fast and let
45
+ // the user enable MCP + Brave Mode in IDEA before retrying the command.
46
+ const DEFAULT_MCP_PROBE_TIMEOUT_MS = 1000;
47
+
48
+ /**
49
+ * @typedef {{
50
+ * status: 'connected' | 'mcp-unavailable' | 'launch-failed' | 'disabled',
51
+ * port?: number,
52
+ * opened?: boolean,
53
+ * unavailableNoticeSent?: boolean
54
+ * }} IdeCommandResult
55
+ */
56
+
57
+ /**
58
+ * @param {unknown} value
59
+ * @param {unknown} fallback
60
+ */
61
+ const asArray = (value, fallback) => (Array.isArray(value) && value.length > 0 ? value : fallback);
62
+
63
+ /**
64
+ * @param {unknown} value
65
+ * @param {number} fallback
66
+ */
67
+ const asPositiveNumber = (value, fallback) =>
68
+ typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
69
+
70
+ /** User-facing status text for a `/open-in-idea` result. */
71
+ /** @param {IdeCommandResult} result */
72
+ export function ideFeedback(result) {
73
+ switch (result?.status) {
74
+ case 'connected':
75
+ return 'IDE MCP 已连接;IDE MCP 与项目环境已即时加载。';
76
+ case 'mcp-unavailable':
77
+ return result?.opened
78
+ ? '已请求打开/激活 IntelliJ IDEA,但未检测到 MCP 服务。请在 IDEA 中开启 MCP 服务(Settings → MCP Server,需 2026.2+)并启用 Brave Mode,然后再次执行 /open-in-idea。'
79
+ : '未检测到 IntelliJ IDEA 的 MCP 服务。请确认 IDEA 已打开,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-idea。';
80
+ case 'launch-failed':
81
+ return '无法拉起 IntelliJ IDEA(未找到应用或启动失败)。请确认已安装 IDEA,或手动打开后重试 /open-in-idea。';
82
+ case 'disabled':
83
+ return '插件已通过 openInIde: false 关闭 IDE 启动。请手动打开 IntelliJ IDEA,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后重试 /open-in-idea。';
84
+ default:
85
+ return '已处理 /open-in-idea。';
86
+ }
87
+ }
88
+
89
+ /**
90
+ * The text the MODEL sees for `/open-in-idea` feedback. It is explicitly a
91
+ * notification, not a task: the model must only acknowledge it. The user sees
92
+ * the clean notice instead, via `metadata.displayText`.
93
+ */
94
+ export function idePromptText(result) {
95
+ return [`【插件通知】${ideFeedback(result)}`, '这是发给用户的通知,你无需处理。请只回复"收到"。'].join('\n\n');
96
+ }
97
+
98
+ /** `metadata` so the UI shows the clean notice while the model sees the instruction. */
99
+ export function idePromptMetadata(result) {
100
+ return { displayText: ideFeedback(result), comments: [] };
101
+ }
102
+
103
+ export default {
104
+ id: 'opencode-idea',
105
+
106
+ /**
107
+ * @param {{
108
+ * location?: unknown,
109
+ * options?: {
110
+ * ports?: number[],
111
+ * injectEnv?: boolean,
112
+ * openInIde?: boolean | string,
113
+ * launchCooldownMs?: number,
114
+ * launchMaxAttempts?: number,
115
+ * mcpProbeTimeoutMs?: number,
116
+ * feedback?: false | 'message',
117
+ * },
118
+ * command?: {
119
+ * transform: (cb: (editor: { add: (definition: {
120
+ * name: string,
121
+ * description?: string,
122
+ * execute: (input: { sessionID: string, prompt: { text: string }, delivery: unknown }) => Promise<void>,
123
+ * }) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
124
+ * },
125
+ * session?: {
126
+ * prompt: (input: { sessionID: string, text: string, delivery: unknown }) => Promise<unknown>,
127
+ * hook: (name: string, cb: (event: { system?: unknown[] }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
128
+ * },
129
+ * tool: {
130
+ * transform: (cb: (editor: {
131
+ * list: () => ReadonlyArray<{ id?: string }>,
132
+ * update: (id: string, update: (tool: { description?: string }) => void) => void,
133
+ * }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
134
+ * },
135
+ * mcp: {
136
+ * transform: (cb: (editor: { set: (name: string, config: unknown) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
137
+ * reload?: () => Promise<void>,
138
+ * },
139
+ * shell: { hook: (name: string, cb: (input: { env: Record<string, string | undefined> }) => void) => Promise<{ dispose: () => Promise<void> | void }> },
140
+ * }} ctx
141
+ */
142
+ async setup(ctx) {
143
+ const projectPath = currentProjectPath(ctx.location);
144
+ if (!projectPath) return;
145
+
146
+ const options = ctx.options || {};
147
+ const ports = asArray(options.ports, DEFAULT_PORTS);
148
+ const injectEnv = options.injectEnv !== false;
149
+ const injectGuidance = options.injectGuidance !== false;
150
+ const ideApp = resolveIdeApp(options.openInIde);
151
+ const mcpProbeTimeoutMs = asPositiveNumber(options.mcpProbeTimeoutMs, DEFAULT_MCP_PROBE_TIMEOUT_MS);
152
+ // "message" (default) injects a clearly-labelled notification that the
153
+ // model only acknowledges ("收到"); the user sees the clean notice via
154
+ // metadata.displayText. false is silent.
155
+ const feedback = options.feedback === false ? false : 'message';
156
+ const guard = createLaunchGuard({
157
+ cooldownMs: asPositiveNumber(options.launchCooldownMs, DEFAULT_LAUNCH_COOLDOWN_MS),
158
+ maxAttempts: asPositiveNumber(options.launchMaxAttempts, DEFAULT_LAUNCH_MAX_ATTEMPTS),
159
+ });
160
+
161
+ /** @type {number | undefined} */
162
+ let activePort;
163
+ let currentEnv = { env: {}, prependPath: [] };
164
+
165
+ /** @type {Array<{ dispose: () => Promise<void> | void }>} */
166
+ let registrations = [];
167
+ /** @type {{ dispose: () => Promise<void> | void } | undefined} */
168
+ let shellRegistration;
169
+ /** @type {{ dispose: () => Promise<void> | void } | undefined} */
170
+ let sessionRegistration;
171
+ /** @type {{ dispose: () => Promise<void> | void } | undefined} */
172
+ let commandRegistration;
173
+ let disposed = false;
174
+
175
+ const deactivateIde = async () => {
176
+ activePort = undefined;
177
+ const current = registrations.slice();
178
+ registrations.length = 0;
179
+ if (current.length === 0) return;
180
+ for (const registration of current) {
181
+ try {
182
+ await registration.dispose();
183
+ } catch {
184
+ // best effort
185
+ }
186
+ }
187
+ };
188
+
189
+ const activateIde = async (port) => {
190
+ const toolRegistration = await ctx.tool.transform((editor) => {
191
+ applyIdeaToolGuidance(editor);
192
+ });
193
+ const mcpRegistration = await ctx.mcp.transform((editor) => {
194
+ editor.set(IDEA_SERVER_NAME, serverConfig(port, projectPath));
195
+ });
196
+ registrations = [toolRegistration, mcpRegistration];
197
+ activePort = port;
198
+ if (typeof ctx.mcp.reload === 'function') await ctx.mcp.reload();
199
+ };
200
+
201
+ const reconcileOnce = async ({ probeIde = true } = {}) => {
202
+ if (disposed) return;
203
+
204
+ // Manual: only `/open-in-idea` probes the IDE. Setup calls this with
205
+ // `probeIde: false` so it never connects until the command asks.
206
+ const port = probeIde ? await findIdePort(ports, { timeoutMs: mcpProbeTimeoutMs }) : undefined;
207
+ const isJetBrains = hasIdeaDirectory(projectPath) || port !== undefined;
208
+ if (!isJetBrains) {
209
+ currentEnv = { env: {}, prependPath: [] };
210
+ await deactivateIde();
211
+ return;
212
+ }
213
+
214
+ // Environment: read from the IDE integrated terminal, the single source
215
+ // of truth for the environment the IDE actually runs with (version-managed
216
+ // Node, goenv Go, SDKMAN Java, Maven, ...). Only available once the IDE is
217
+ // connected, which is why the manual `/open-in-idea` drives it.
218
+ const terminal = port === undefined ? {} : await readIdeTerminalEnv(port, projectPath);
219
+ currentEnv = mergeEnv([{ env: terminal }]);
220
+
221
+ if (injectEnv && !shellRegistration) {
222
+ shellRegistration = await ctx.shell.hook('create.before', (input) => applyEnv(input, currentEnv));
223
+ }
224
+
225
+ if (port === activePort) return;
226
+ await deactivateIde();
227
+ if (port !== undefined) await activateIde(port);
228
+ };
229
+
230
+ // Serialize reconciles so overlapping callers can never register/activate
231
+ // the IDE concurrently (which would leak registrations).
232
+ let reconcileChain = Promise.resolve();
233
+ const reconcile = (options) => {
234
+ const next = reconcileChain.then(() => reconcileOnce(options));
235
+ reconcileChain = next.then(
236
+ () => {},
237
+ () => {},
238
+ );
239
+ return next;
240
+ };
241
+
242
+ // Re-entrant: repeat invocations share one open request so the IDE is not
243
+ // spawned twice while the application is starting.
244
+ /** @type {Promise<IdeCommandResult> | undefined} */
245
+ let launchInFlight;
246
+ let launchNoticeSent = false;
247
+ /**
248
+ * @param {{ onUnavailable?: (result: IdeCommandResult) => Promise<boolean> }} [options]
249
+ * @returns {Promise<IdeCommandResult>}
250
+ */
251
+ const ensureIde = ({ onUnavailable } = {}) => {
252
+ if (launchInFlight) return launchInFlight;
253
+ launchNoticeSent = false;
254
+ const reportUnavailable = async (opened) => {
255
+ if (launchNoticeSent || typeof onUnavailable !== 'function') return;
256
+ // Fast-fail: notify as soon as the endpoint is known to be missing so
257
+ // the user can enable MCP + Brave Mode and retry the command.
258
+ const delivered = await onUnavailable({ status: 'mcp-unavailable', opened });
259
+ if (delivered) launchNoticeSent = true;
260
+ };
261
+ const run = (async () => {
262
+ const serving = await findIdePort(ports, { timeoutMs: mcpProbeTimeoutMs }).catch(() => undefined);
263
+ if (serving !== undefined) {
264
+ guard.reset();
265
+ launchNoticeSent = false;
266
+ return { status: 'connected', port: serving, unavailableNoticeSent: false };
267
+ }
268
+ if (!ideApp) return { status: 'disabled' };
269
+ if (guard.exhausted()) {
270
+ await reportUnavailable(false);
271
+ return { status: 'mcp-unavailable', opened: false, unavailableNoticeSent: launchNoticeSent };
272
+ }
273
+ if (guard.coolingDown()) {
274
+ // There is no reason to wait here: MCP is disabled/not ready, and
275
+ // the user must enable it in IDEA before retrying the command.
276
+ await reportUnavailable(false);
277
+ return { status: 'mcp-unavailable', opened: false, unavailableNoticeSent: launchNoticeSent };
278
+ }
279
+ guard.markSpawn();
280
+ const launched = await openInIde(projectPath, { app: ideApp });
281
+ if (!launched) return { status: 'launch-failed' };
282
+ // `open -a` only requests activation/opening. Fast-fail if the MCP
283
+ // endpoint is not already available; the user can enable it in IDEA
284
+ // and run `/open-in-idea` again instead of waiting on a timer.
285
+ await reportUnavailable(true);
286
+ return { status: 'mcp-unavailable', opened: true, unavailableNoticeSent: launchNoticeSent };
287
+ })();
288
+ launchInFlight = run;
289
+ void run
290
+ .catch(() => {})
291
+ .then(() => {
292
+ if (launchInFlight === run) launchInFlight = undefined;
293
+ });
294
+ return run;
295
+ };
296
+
297
+ // Setup never probes or connects the IDE — everything is on `/open-in-idea`.
298
+ await reconcile({ probeIde: false });
299
+
300
+ const sendFeedback = async (input, result) => {
301
+ if (feedback !== 'message' || typeof ctx.session?.prompt !== 'function') return false;
302
+ try {
303
+ await ctx.session.prompt({
304
+ ...input.prompt,
305
+ sessionID: input.sessionID,
306
+ text: idePromptText(result),
307
+ metadata: idePromptMetadata(result),
308
+ delivery: input.delivery,
309
+ });
310
+ return true;
311
+ } catch {
312
+ // feedback is best effort
313
+ return false;
314
+ }
315
+ };
316
+
317
+ // `/open-in-idea`: manual, re-entrant trigger that also loads the IDE
318
+ // capabilities immediately.
319
+ if (typeof ctx.command?.transform === 'function') {
320
+ commandRegistration = await ctx.command.transform((editor) => {
321
+ editor.add({
322
+ name: 'open-in-idea',
323
+ description: '用 IntelliJ IDEA 打开当前项目,并即时加载 IDE MCP 与项目 SDK 环境',
324
+ execute: async (input) => {
325
+ /** @type {boolean} */
326
+ let unavailableNoticeSent = false;
327
+ const result = await ensureIde({
328
+ onUnavailable: async (notice) => {
329
+ unavailableNoticeSent = await sendFeedback(input, notice);
330
+ return unavailableNoticeSent;
331
+ },
332
+ });
333
+ const { port, status, unavailableNoticeSent: resultNoticeSent } = result;
334
+ try {
335
+ // Do not repeat the expensive probe when the preflight already
336
+ // found no MCP endpoint. The command will retry after the user
337
+ // enables MCP + Brave Mode.
338
+ await reconcile({ probeIde: port !== undefined });
339
+ } catch {
340
+ // capability load is best effort; feedback still matters
341
+ }
342
+ // The unavailable notice is the final result for this command. If
343
+ // it was already delivered, do not show the same warning twice.
344
+ const unavailableAlreadySent = unavailableNoticeSent || resultNoticeSent;
345
+ if (!unavailableAlreadySent || status !== 'mcp-unavailable') {
346
+ await sendFeedback(input, result);
347
+ }
348
+ },
349
+ });
350
+ });
351
+ }
352
+
353
+ // Direct-call guidance: while the IDEA MCP is available, append concise
354
+ // routing rules to every primary request. Native tools are left untouched.
355
+ if (injectGuidance && typeof ctx.session?.hook === 'function') {
356
+ sessionRegistration = await ctx.session.hook('context', (event) => {
357
+ appendIdeRecoveryGuidance(event?.system);
358
+ if (activePort === undefined) return;
359
+ appendIdeGuidance(event?.system);
360
+ });
361
+ }
362
+
363
+ return async () => {
364
+ disposed = true;
365
+ if (shellRegistration) {
366
+ try {
367
+ await shellRegistration.dispose();
368
+ } catch {
369
+ // best effort
370
+ }
371
+ }
372
+ if (sessionRegistration) {
373
+ try {
374
+ await sessionRegistration.dispose();
375
+ } catch {
376
+ // best effort
377
+ }
378
+ }
379
+ if (commandRegistration) {
380
+ try {
381
+ await commandRegistration.dispose();
382
+ } catch {
383
+ // best effort
384
+ }
385
+ }
386
+ await deactivateIde();
387
+ };
388
+ },
389
+ };
package/src/project.js ADDED
@@ -0,0 +1,29 @@
1
+ // Pure helpers for locating the current OpenCode project.
2
+
3
+ import { existsSync } from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ /**
7
+ * Resolve the absolute project root for the current plugin location.
8
+ * OpenCode exposes a `Location.Info` on the plugin context.
9
+ *
10
+ * @param {{ directory?: string, project?: { directory?: string } } | undefined} location
11
+ * @returns {string | undefined}
12
+ */
13
+ export function currentProjectPath(location) {
14
+ if (!location) return undefined;
15
+ const projectDirectory = location.project && location.project.directory;
16
+ return projectDirectory || location.directory || undefined;
17
+ }
18
+
19
+ /**
20
+ * True when the directory looks like a JetBrains project (has a `.idea` folder).
21
+ * This is a convenience signal only; the plugin does not require it, because an
22
+ * IDE may have created the project entry without writing `.idea` yet.
23
+ *
24
+ * @param {string | undefined} directory
25
+ * @returns {boolean}
26
+ */
27
+ export function hasIdeaDirectory(directory) {
28
+ return typeof directory === 'string' && existsSync(path.join(directory, '.idea'));
29
+ }