opencode-idea 0.0.11 → 0.0.12

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/README.md CHANGED
@@ -1,12 +1,15 @@
1
1
  # opencode-idea
2
2
 
3
- OpenCode 插件,面向 JetBrains 项目 (IntelliJ IDEA / PyCharm / WebStorm 等),做三件事:
3
+ OpenCode 插件,面向 JetBrains 项目 (IntelliJ IDEA / PyCharm / WebStorm 等),做两件事:
4
4
 
5
5
  1. **接入 IDE MCP** —— 用 `/open-in-idea` 把当前项目在 IntelliJ IDEA 中打开,并把这个 IDE 的 MCP 服务 (60 个工具:
6
6
  检索、读、改、构建、重构、调试、数据库……)注册进 OpenCode。
7
7
  2. **优先使用 IDE 工具** —— IDE 可用时,向模型注入"项目内操作优先使用 IDEA MCP"的系统提示引导 + 能力映射;IDE 不可用时回退原生工具
8
8
  (正常回退,无需声明)。
9
- 3. **注入 IDE 环境变量** —— 从 IDE 集成终端读取它实际运行的环境,注入每个 OpenCode shell。
9
+
10
+ > **项目命令走 IDE 终端**:跑 `java` / `python` / `npm` 这类项目命令时,引导模型用 `idea_execute_terminal_command`
11
+ > (`executeInShell: true`)而不是原生 shell —— 它在 IDE 集成终端里执行,**天然继承用户在 IDE 里配好的 JDK / Node / Python 版本**
12
+ > (含 fnm / SDKMAN 这类靠 shell init 切版本的场景)。详见「项目命令执行」。
10
13
 
11
14
  非 JetBrains 项目完全不受影响。 **手动接入**:插件启动不探测、不连接 IDE,只有跑 `/open-in-idea` 才接入。跑过一次后,该**项目**
12
15
  被「标记」(持久化在 `ctx.storage`,重启 OpenCode 后仍在):之后插件在后台**每 5s 探活**该 IDE——**掉线就注销 `idea` MCP**(杀掉不会自愈的
@@ -18,14 +21,15 @@ stdio 僵尸桥 + 移除 `idea_*` 工具,让模型调不到、**不会挂起**),
18
21
  ```text
19
22
  setup(启动, 每项目一次):
20
23
  a. 读项目标记(ctx.storage: project/<项目路径>; OpenCode 重启后仍生效)
21
- b. 读 IDE 集成终端环境 → 通过 shell create.before 钩子注入每个 shell(null: 未连 IDE 时为空)
22
- c. 注册 /open-in-idea 与 /close-in-idea 命令 + session context 钩子 + tool execute.after 钩子
24
+ b. 注册 /open-in-idea 与 /close-in-idea 命令 + session context 钩子 + tool execute.after 钩子
23
25
  不探测、不注册 MCP
24
26
 
25
27
  /open-in-idea(手动触发, 阻塞到就绪):
26
28
  1. 写项目标记 = true + 同步内存缓存;注册 `idea-run-config` skill
27
29
  2. 探测本地 JetBrains IDE MCP 服务(/stream)
28
- 3. 未就绪 → open -a "IntelliJ IDEA" <项目目录>(复用已运行实例),然后轮询等到 MCP 就绪(冷启动约数十秒,上限 60s)
30
+ - 已就绪 → 再问 IDE「当前项目是否已打开」(`get_project_modules`,带项目路径)
31
+ · 已打开 → 不重复 open;未打开 → open -a 打开
32
+ - 未就绪(冷启动)→ open -a 拉起,再轮询等到就绪(上限 60s)
29
33
  4. **先注销旧的 idea MCP(杀掉旧的 stdio 桥子进程),再重新注册**
30
34
  (`idea stdioMcpServer`,env 带项目路径 + 端口;每项目一个子进程)
31
35
  —— 每次执行都重建,因为断掉的桥不会自愈、也不能靠 refresh 复活
@@ -57,12 +61,14 @@ setup(启动, 每项目一次):
57
61
 
58
62
  在会话里输入 `/open-in-idea`:
59
63
 
60
- 1. 先探测 `/stream`;已就绪 → 直接进入下一步;
61
- 2. 未就绪 → 用 `open -a "IntelliJ IDEA" <项目目录>` 拉起或激活 IDEA —— **复用已运行的实例**(不会重复开新实例),然后
62
- **轮询等到 MCP 就绪**(冷启动约数十秒,上限 60s);
63
- 3. 等不到(例如 IDE 里 MCP 服务未开启)→ 提示在 IDEA 的 `Settings → MCP Server` 开启 MCP 服务并启用 **Brave Mode**,用户设置好后
64
+ 1. **让运行中的 IDEA 实例打开/激活当前项目**:`open -a "IntelliJ IDEA" <项目目录>` —— **复用已运行的实例**(不会重复开新实例),
65
+ 项目已打开时只把它切到前台。**无论 MCP 是否已就绪都会执行这一步**:端点能应答只说明 IDEA 在运行(可能开的是**别的**项目),
66
+ 不代表当前项目已打开;
67
+ 2. 再探测 `/stream`;已就绪 → 直接进入下一步;
68
+ 3. 未就绪 → 说明 IDEA 是冷启动,继续用 `open -a` 拉起并**轮询等到 MCP 就绪**(冷启动约数十秒,上限 60s);
69
+ 4. 等不到(例如 IDE 里 MCP 服务未开启)→ 提示在 IDEA 的 `Settings → MCP Server` 开启 MCP 服务并启用 **Brave Mode**,用户设置好后
64
70
  再次执行 `/open-in-idea`;
65
- 4. 就绪后 **先注销旧的 `idea` MCP(杀掉旧的 stdio 桥子进程)→ 再重新注册**(每次执行都重建,因为断掉的桥不会自愈),然后重读
71
+ 5. 就绪后 **先注销旧的 `idea` MCP(杀掉旧的 stdio 桥子进程)→ 再重新注册**(每次执行都重建,因为断掉的桥不会自愈),然后重读
66
72
  IDE 环境、启用系统引导,并**等到 `idea_*` 工具进入注册表**才返回。
67
73
 
68
74
  > **通知不触发 AI 干活**:默认 `feedback: "message"` 把结果作为一条 **标注清楚的通知**发到会话里 —— 用户看到干净的通知气泡
@@ -118,20 +124,26 @@ IDE MCP 只能 **查**(`get_run_configurations`)和 **执行**(`execute_run_conf
118
124
  `setup` 时(读回标记)就会注册。未标记的项目不受影响。文档放在 `skills/<id>/SKILL.md`(每个 skill 一个目录,`name` /
119
125
  `description` 走 YAML frontmatter),由 `src/skill/loader.js` 扫描加载 —— **加新 skill 只需加一个目录,不用改代码**。
120
126
 
121
- ## 环境变量注入
127
+ ## 项目命令执行 (IDE 终端)
128
+
129
+ 跑项目相关的命令时,模型使用 **`idea_execute_terminal_command`**,而不是 OpenCode 原生 shell。判据是**命令是否依赖项目的
130
+ 工具链 / 运行时 / 依赖环境**,而不是语言或工具名:
122
131
 
123
- **唯一来源:IDE 集成终端**(`execute_terminal_command printenv`)。它反映 IDE 实际运行的环境 —— 这些环境往往在 .idea
124
- 配置里读不到。
132
+ - **走 IDE 终端**:任何会调用项目工具链的命令 —— Java / Python / Node / Go 的运行时,以及 Maven / Gradle、uv / pip /
133
+ poetry、cargo、bundler、make / cmake、包管理器、构建、测试、代码生成等。
134
+ - **走原生 shell**:纯操作系统级的通用命令(`ls` / `cat` / `cp` / `mkdir` / `git` 等)。
125
135
 
126
- 抽取的变量 (`src/mcp/ide-env.js` 的 `SDK_ENV_KEYS`):`JAVA_HOME` / `JRE_HOME` / `JDK_HOME` / `VIRTUAL_ENV` / `PYTHONPATH` /
127
- `PYTHONHOME` / `GOROOT` / `GOPATH` / `GOBIN` / `NODE_PATH` / `NVM_BIN` / `NVM_DIR` / `MAVEN_HOME` / `M2_HOME` /
128
- `GRADLE_HOME` / `SDKMAN_DIR` / `CONDA_PREFIX`。
136
+ 原因:
129
137
 
130
- - 注入通过 V2 的 `shell` `create.before` 钩子:只补这些变量 + 前置 PATH,不覆盖你原有的 PATH。
131
- - 需要 IDE MCP 可达 + **Brave Mode**;拿不到时环境为空 (不影响其他功能)。
138
+ - **`executeInShell: true`**(必开):命令在用户真实 shell(`zsh`/`bash`)里执行,**继承 IDE 集成终端的环境** —— 也就是用户在
139
+ IDE 里配好的 JDK / Node / Python / Go / Maven / uv 等。原生 shell 未必能拿到同一套版本,尤其是 fnm / SDKMAN / pyenv 这类
140
+ "选择写在 shell init 里"的版本管理器,靠几个环境变量复现不出来。
141
+ - `timeout`(毫秒)按预期耗时给足;`maxLinesCount` 控制返回行数,超 2000 行会截断。
142
+ - `reuseExistingTerminalWindow: true` 复用同一个 IDE 终端窗口,不刷屏。
143
+ - 返回 `command_exit_code` + `command_output`;`cd` **不跨调用保留**,多步操作用 `&&` 串在一条命令里。
144
+ - 需要 IDE 里开启 **Brave Mode**,否则每条命令都要用户确认一次。
132
145
 
133
- > 与 [opencode-env-loader](https://github.com/syllr/opencode-env-loader) 的关系:两者独立。env-loader 负责
134
- > `.opencode/env-loader/*` 的 **人工** KEY=VALUE 覆盖;本插件负责从 **IDE 实际环境**推导。可以同时使用。
146
+ 引导文本由 `src/ide-guidance.js` 注入(`### 终端` 段),只在 IDE MCP 可用时生效。
135
147
 
136
148
  ## 前置条件
137
149
 
@@ -232,7 +244,6 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
232
244
  "package": "opencode-idea",
233
245
  "options": {
234
246
  "ports": [64342], // IDE MCP 端口;默认 [64342]
235
- "injectEnv": true,
236
247
  "injectGuidance": true, // IDE 可用时向系统提示注入「优先使用 IDEA MCP」引导
237
248
  "openInIde": "idea", // false 关闭;或任意 macOS 应用名(如 "IntelliJ IDEA")
238
249
  "transport": "stdio", // "stdio"(默认,本地桥,不会空闲断流)| "http"(远程 Streamable HTTP)
@@ -255,7 +266,6 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
255
266
  | 选项 | 类型 | 默认 | 说明 |
256
267
  | --------------------- | -------------------- | ----------- | --------------------------------------------------------------------------------------- |
257
268
  | `ports` | `number[]` | `[64342]` | 依次探测的 IDE MCP 端口 |
258
- | `injectEnv` | `boolean` | `true` | 是否注入 IDE 终端环境变量 |
259
269
  | `injectGuidance` | `boolean` | `true` | IDE 可用时是否向系统提示注入「优先使用 IDEA MCP」引导 |
260
270
  | `openInIde` | `boolean \| string` | `"idea"` | 拉起哪个 IDE;`false` 关闭,`"idea"` 映射到 IntelliJ IDEA |
261
271
  | `transport` | `"stdio" \| "http"` | `"stdio"` | `stdio` 注册 IDE 自带的 stdio 桥(本地子进程,不会空闲断流);`http` 用远程 Streamable HTTP |
@@ -301,13 +311,15 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
301
311
  - skill 定义的字段名**以 schema 为准**,不要照官方文档:`Skill.Info` 需要 `path` (AbsolutePath),文档里写的 `location`
302
312
  不存在 —— 用错会让 `ctx.skill.transform` 抛 `SchemaError`,而**任何 transform 失败都会禁用整个插件**(0.0.6 就是这样丢了
303
313
  `/open-in-idea`)。同理,`Skill.Info` 的完整字段是 `id` / `name` / `description?` / `autoinvoke?` / `path` / `content`。
304
- - 环境变量依赖 `execute_terminal_command` + Brave Mode;拿不到时环境为空。
314
+ - 项目命令走 `idea_execute_terminal_command` + **Brave Mode**;没开 Brave Mode 时每条命令都要用户确认一次。
305
315
  - 一批用不到的 IDEA MCP 工具被硬编码隐藏 (`src/plugin.js` 的 `HIDDEN_IDEA_TOOLS`,共 31 个):VCS (`idea_git_status` /
306
316
  `idea_get_repositories`,版本控制走原生 `git`)、Router 派发工具 (`idea_execute_tool`,未启用 router-only 时冗余)、Debugger
307
317
  (全部 `idea_xdebug_*`)、Dev Kit MCP、Inspection KTS MCP、解释器环境 MCP、以及数据库的建/改数据源 (数据源由用户在
308
- IDEA 里配置,AI 只读和查询)。`idea_execute_terminal_command` 也不暴露给模型 —— 它只由插件内部读取 IDE 环境,而那是插件直接调
309
- MCP,不经过模型工具表。
310
- - 探测只确认「MCP 服务是否在监听」,不校验「当前项目是否已在 IDE 打开」;MCP 关闭时会用一次短探测并立即给出设置提示。
318
+ IDEA 里配置,AI 只读和查询)。**`idea_execute_terminal_command` 不隐藏**:项目命令要在 IDE 环境里跑,模型需要它。
319
+ - 探测只确认「MCP 服务是否在监听」;**「当前项目是否已在 IDE 打开」靠一次项目作用域的 MCP 调用判断**
320
+ (`get_project_modules`,带 `IJ_MCP_SERVER_PROJECT_PATH`):返回模块 = 已打开 → 跳过 `open -a`;报
321
+ `Unable to determine the target project` = 未打开 → `open -a` 打开。MCP 关闭时会用一次短探测并立即给出设置提示。
322
+ 项目刚打开时的短暂"项目未就绪"由工具失败分类兜底(「IDE 在、但项目没打开」→ 重新打开并让模型重试)。
311
323
  - IDE **2026.2+** 提供官方 stdio 桥 (`idea stdioMcpServer`),插件默认用它注册。stdio 是子进程管道,不像 Streamable HTTP 那样
312
324
  会空闲断流;但它**启动时要求 IDE MCP 已在监听**,所以 `/open-in-idea` 必须先等就绪再注册(冷启动约数十秒)。`transport: "http"`
313
325
  可切回远程 Streamable HTTP(OpenCode 会因连接关闭把 server 置 failed 并移除工具,不自动重连)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-idea",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
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
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -9,9 +9,7 @@
9
9
  "src/index.js",
10
10
  "src/plugin.js",
11
11
  "src/mcp/idea.js",
12
- "src/mcp/ide-env.js",
13
12
  "src/project.js",
14
- "src/env.js",
15
13
  "src/ide-launcher.js",
16
14
  "src/ide-guidance.js",
17
15
  "src/skill/loader.js",
@@ -60,6 +60,17 @@ export function buildIdeGuidance(projectPath) {
60
60
  `- \`idea_get_run_configurations\`:读项目级 \`.run/*.run.xml\`(增删改直接改文件);传 \`filePath\` 可列该文件的运行入口。`,
61
61
  `- \`idea_execute_run_configuration\`:跑已有配置;Shell Script 要 \`EXECUTE_IN_TERMINAL: false\`;覆盖参数(\`programArguments\` / \`workingDirectory\` / \`envs\`)需配置支持 \`supportsDynamicLaunchOverrides\`。`,
62
62
  ``,
63
+ `### 终端`,
64
+ `- \`idea_execute_terminal_command\`:**项目内的一切命令执行都用它**,不要用原生 \`shell\`。`,
65
+ ` 判据是"命令是否依赖项目的工具链 / 运行时 / 依赖环境",不是语言或工具名 —— Java、Python、Node、Go 之外,`,
66
+ ` Maven / Gradle、uv / pip / poetry、cargo、bundler、make / cmake、包管理器与构建、测试、代码生成等,全都算。`,
67
+ ` 例外:纯操作系统级的通用命令(\`ls\` / \`cat\` / \`cp\` / \`mkdir\` / \`git\` 等)用原生 \`shell\` 即可。`,
68
+ ` 记不清就不确定时:只要命令会调用项目的工具链,就用它,不要退回原生 \`shell\`。`,
69
+ ` - 必须 \`executeInShell: true\`:在用户真实 shell 里执行,**天然继承 IDE 里配好的运行时环境**(JDK / Node / Python / Go / Maven / uv 等及各版本管理器);原生 shell 可能用错版本。`,
70
+ ` - 按预期耗时传 \`timeout\`(毫秒),长任务给足;必要时传 \`maxLinesCount\` 控制返回行数。`,
71
+ ` - 连续命令传 \`reuseExistingTerminalWindow: true\`,复用同一个 IDE 终端窗口。`,
72
+ ` - 结果带 \`command_exit_code\` 与 \`command_output\`;\`cd\` 不跨调用保留,多步时在一条命令里用 \`&&\`。`,
73
+ ``,
63
74
  `### 数据库 (数据源由用户在 IDE 里配置:不要自己建/改,不要向用户索取数据库凭据)`,
64
75
  `- \`idea_list_database_connections\`:先看有没有现成数据源;每条给 \`id\` / \`dbms\` / \`jdbcUrl\` / \`readOnly\` / \`isDDL\`。`,
65
76
  `- \`idea_test_database_connection\`(\`id\`):\`hasProblems\` 为 \`NO\` 才算通,另带 DBMS / 驱动版本与 ping。`,
package/src/mcp/idea.js CHANGED
@@ -146,7 +146,7 @@ export async function probePort(port, options = {}) {
146
146
  params: {
147
147
  protocolVersion: PROTOCOL_VERSION,
148
148
  capabilities: {},
149
- clientInfo: { name: 'opencode-idea', version: '0.0.11' },
149
+ clientInfo: { name: 'opencode-idea', version: '0.0.12' },
150
150
  },
151
151
  }),
152
152
  signal: controller.signal,
@@ -197,34 +197,43 @@ export async function callTool(port, projectPath, name, args = {}, options = {})
197
197
  const fetchImpl = options.fetchImpl ?? fetch;
198
198
  const controller = new AbortController();
199
199
  const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? CALL_TIMEOUT_MS);
200
- const post = (payload) =>
200
+ const post = (payload, sessionId) =>
201
201
  fetchImpl(`http://127.0.0.1:${port}${MCP_STREAM_PATH}`, {
202
202
  method: 'POST',
203
203
  headers: {
204
204
  'Content-Type': 'application/json',
205
205
  Accept: 'application/json, text/event-stream',
206
206
  [PROJECT_HEADER]: projectPath,
207
+ ...(sessionId ? { 'Mcp-Session-Id': sessionId } : {}),
207
208
  },
208
209
  body: JSON.stringify(payload),
209
210
  signal: controller.signal,
210
211
  });
211
212
  try {
212
- await post({
213
+ const initialized = await post({
213
214
  jsonrpc: '2.0',
214
215
  id: 1,
215
216
  method: 'initialize',
216
217
  params: {
217
218
  protocolVersion: PROTOCOL_VERSION,
218
219
  capabilities: {},
219
- clientInfo: { name: 'opencode-idea', version: '0.0.11' },
220
+ clientInfo: { name: 'opencode-idea', version: '0.0.12' },
220
221
  },
221
222
  });
222
- const response = await post({
223
- jsonrpc: '2.0',
224
- id: 2,
225
- method: 'tools/call',
226
- params: { name, arguments: args },
227
- });
223
+ // The IDE's Streamable-HTTP endpoint binds follow-up requests to the session
224
+ // created by `initialize`: without echoing its `Mcp-Session-Id` header the
225
+ // server rejects the call with "Bad Request: Server not initialized"
226
+ // (verified against IntelliJ IDEA 2026.2.3).
227
+ const sessionId = initialized.headers?.get?.('mcp-session-id') ?? undefined;
228
+ const response = await post(
229
+ {
230
+ jsonrpc: '2.0',
231
+ id: 2,
232
+ method: 'tools/call',
233
+ params: { name, arguments: args },
234
+ },
235
+ sessionId,
236
+ );
228
237
  if (!response.ok) return undefined;
229
238
  const message = await parseToolResponse(response);
230
239
  if (!message || message.error) return undefined;
package/src/plugin.js CHANGED
@@ -1,28 +1,29 @@
1
1
  // opencode-idea — OpenCode plugin entry.
2
2
  //
3
3
  // For a project the user opted into IDEA 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 — by default as the IDE's
4
+ // 1. registers the IDE MCP server with OpenCode — by default as the IDE's
7
5
  // stdio bridge (`idea stdioMcpServer`), scoped to the project through
8
6
  // `IJ_MCP_SERVER_PROJECT_PATH` — injects direct-call guidance for IDEA MCP
9
7
  // tools, and updates only IDEA MCP tool descriptions;
10
- // 3. registers the `/open-in-idea` command, which opens the current project
8
+ // 2. registers the `/open-in-idea` command, which opens the current project
11
9
  // in IntelliJ IDEA (reusing a running instance), loads the IDE
12
10
  // capabilities immediately, and marks the project as an IDEA project;
13
11
  // `/close-in-idea` clears that mark;
14
- // 4. registers the `idea-run-config` skill (marked projects only), which
12
+ // 3. registers the `idea-run-config` skill (marked projects only), which
15
13
  // teaches the model how to manage `.run/*.run.xml` run configurations — a
16
14
  // capability the IDE MCP server does not expose.
17
15
  //
18
16
  // A project that was never marked is left untouched. Everything is manual:
19
- // setup does NOT probe or connect the IDE (only the IDE-terminal env is read
20
- // when a probe is requested). `/open-in-idea` probes, opens the IDE, and loads
21
- // the IDE MCP + guidance on demand.
17
+ // setup does NOT probe or connect the IDE. `/open-in-idea` probes, opens the
18
+ // IDE, and loads the IDE MCP + guidance on demand.
22
19
  //
23
20
  // `/open-in-idea` opens the project in the IDE, waits until its MCP endpoint is
24
21
  // ready, registers `idea` (stdio, project-scoped) and marks the project in
25
22
  // `ctx.storage`; `/close-in-idea` unregisters the server and clears the mark.
23
+ // A live endpoint alone does NOT mean our project is open there (the running IDE
24
+ // may have a different project open), so the open flow asks the IDE directly —
25
+ // one project-scoped MCP call — and runs `open -a` only when the project is not
26
+ // open yet.
26
27
  // The mark gates the IDEA skills and the failure-driven recovery. After a manual
27
28
  // open, an `idea_*` call that fails because the IDE MCP is not usable (the server
28
29
  // dropped, or the project is not open in the IDE) re-runs the open flow; a plain
@@ -39,13 +40,12 @@
39
40
  import {
40
41
  DEFAULT_PORTS,
41
42
  IDEA_SERVER_NAME,
43
+ callTool,
42
44
  findIdePort,
43
45
  serverConfig,
44
46
  stdioServerConfig,
45
47
  } from './mcp/idea.js';
46
- import { readIdeTerminalEnv } from './mcp/ide-env.js';
47
48
  import { currentProjectPath } from './project.js';
48
- import { applyEnv, mergeEnv } from './env.js';
49
49
  import {
50
50
  createLaunchGuard,
51
51
  DEFAULT_IDE_APP,
@@ -106,9 +106,18 @@ const IDEA_READY_TOOL_IDS = new Set(['idea_read_file', 'idea_apply_patch']);
106
106
  * - Inspection KTS MCP: inspection.kts authoring helpers (PSI tree, API docs,
107
107
  * examples, run). Also IntelliJ Platform plugin-development only.
108
108
  * - Python Environment MCP: Python interpreter detection and configuration.
109
- * Hidden; the IDE-injected shell env already carries the SDK paths.
109
+ * Hidden; project commands run through the IDE terminal instead, which is
110
+ * already launched with the IDE's real environment.
110
111
  * - Database data sources: connections are configured by the user in the IDE,
111
112
  * so the AI only reads and queries them; create/edit stay hidden.
113
+ *
114
+ * NOT hidden: `idea_execute_terminal_command`. Project commands must run with
115
+ * the SDKs the user configured in the IDE (JDK, Node, Python, ...), so the model
116
+ * uses the IDE's integrated terminal for them. `executeInShell: true` runs the
117
+ * command in the user's real shell (`zsh`/`bash`), which inherits the IDE
118
+ * environment — including version managers such as fnm / SDKMAN whose selection
119
+ * lives in shell init and cannot be reconstructed from a handful of variables.
120
+ * Requires Brave Mode in the IDE; without it the IDE asks for confirmation.
112
121
  */
113
122
  export const HIDDEN_IDEA_TOOLS = [
114
123
  // VCS
@@ -116,11 +125,6 @@ export const HIDDEN_IDEA_TOOLS = [
116
125
  'idea_get_repositories',
117
126
  // Router
118
127
  'idea_execute_tool',
119
- // Terminal: the plugin reads the IDE env through its own direct MCP call
120
- // (`src/mcp/ide-env.js`), so the model never needs this tool. Measured value for
121
- // the agent is nil: ~60s hard cutoff with no way to read the terminal buffer,
122
- // and the native shell already keeps the output tail plus a full-output file.
123
- 'idea_execute_terminal_command',
124
128
  // Debugger
125
129
  'idea_xdebug_control_session',
126
130
  'idea_xdebug_evaluate_expression',
@@ -333,7 +337,6 @@ export default {
333
337
  * location?: unknown,
334
338
  * options?: {
335
339
  * ports?: number[],
336
- * injectEnv?: boolean,
337
340
  * openInIde?: boolean | string,
338
341
  * launchCooldownMs?: number,
339
342
  * launchMaxAttempts?: number,
@@ -402,7 +405,6 @@ export default {
402
405
 
403
406
  const options = ctx.options || {};
404
407
  const ports = asArray(options.ports, DEFAULT_PORTS);
405
- const injectEnv = options.injectEnv !== false;
406
408
  const injectGuidance = options.injectGuidance !== false;
407
409
  const ideApp = resolveIdeApp(options.openInIde);
408
410
  // Transport for the IDE MCP server. `stdio` (default) registers the IDE's
@@ -435,13 +437,10 @@ export default {
435
437
  cooldownMs: asPositiveNumber(options.launchCooldownMs, DEFAULT_LAUNCH_COOLDOWN_MS),
436
438
  maxAttempts: asPositiveNumber(options.launchMaxAttempts, DEFAULT_LAUNCH_MAX_ATTEMPTS),
437
439
  });
438
- let currentEnv = { env: {}, prependPath: [] };
439
440
 
440
441
  /** @type {Array<{ dispose: () => Promise<void> | void }>} */
441
442
  let registrations = [];
442
443
  /** @type {{ dispose: () => Promise<void> | void } | undefined} */
443
- let shellRegistration;
444
- /** @type {{ dispose: () => Promise<void> | void } | undefined} */
445
444
  let sessionRegistration;
446
445
  /** @type {{ dispose: () => Promise<void> | void } | undefined} */
447
446
  let toolRegistration;
@@ -573,22 +572,10 @@ export default {
573
572
  const port = probeIde ? await findIdePort(ports, { timeoutMs: mcpProbeTimeoutMs }) : undefined;
574
573
  const isJetBrains = markCache === true || port !== undefined;
575
574
  if (!isJetBrains) {
576
- currentEnv = { env: {}, prependPath: [] };
577
575
  await deactivateIde();
578
576
  return false;
579
577
  }
580
578
 
581
- // Environment: read from the IDE integrated terminal, the single source
582
- // of truth for the environment the IDE actually runs with (version-managed
583
- // Node, goenv Go, SDKMAN Java, Maven, ...). Only available once the IDE is
584
- // connected, which is why the manual `/open-in-idea` drives it.
585
- const terminal = port === undefined ? {} : await readIdeTerminalEnv(port, projectPath);
586
- currentEnv = mergeEnv([{ env: terminal }]);
587
-
588
- if (injectEnv && !shellRegistration) {
589
- shellRegistration = await ctx.shell.hook('create.before', (input) => applyEnv(input, currentEnv));
590
- }
591
-
592
579
  // Always tear the server down and register it again. A stdio bridge whose
593
580
  // IDE connection dropped becomes a permanent zombie: the child process is
594
581
  // still alive, so OpenCode keeps reporting it as connected, never restarts
@@ -635,6 +622,35 @@ export default {
635
622
  return undefined;
636
623
  };
637
624
 
625
+ /**
626
+ * Is `projectPath` actually OPEN in the IDE that serves `port`?
627
+ *
628
+ * A live endpoint only means the IDE is RUNNING — it may have a different
629
+ * project open (or sit on the Welcome screen), while everything we do is
630
+ * scoped to THIS project. So ask the IDE directly with one project-scoped
631
+ * call: the server resolves the project from `IJ_MCP_SERVER_PROJECT_PATH`
632
+ * BEFORE running the tool, and for a project that is not open it fails with
633
+ * "Unable to determine the target project ..." — the same shape
634
+ * `classifyIdeaFailure` maps to `project`. Any other outcome (a real result,
635
+ * or an unrelated tool error) means the project WAS resolved, i.e. open.
636
+ *
637
+ * A transport failure (`undefined`) counts as "not confirmed open", so the
638
+ * caller still asks the IDE to open the project: `open -a` is idempotent,
639
+ * and the only cost is a redundant focus.
640
+ *
641
+ * @param {number} port
642
+ */
643
+ const isProjectOpen = async (port) => {
644
+ const result = await callTool(port, projectPath, 'get_project_modules', {}).catch(() => undefined);
645
+ if (!result) return false;
646
+ if (result.isError !== true) return true;
647
+ const text = (result.content ?? [])
648
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
649
+ .map((part) => part.text)
650
+ .join('\n');
651
+ return classifyIdeaFailure({ message: text }) !== 'project';
652
+ };
653
+
638
654
  // Re-entrant: repeat invocations share one open request so the IDE is not
639
655
  // spawned twice while the application is starting.
640
656
  /** @type {Promise<IdeCommandResult> | undefined} */
@@ -659,6 +675,14 @@ export default {
659
675
  if (serving !== undefined) {
660
676
  guard.reset();
661
677
  launchNoticeSent = false;
678
+ // The endpoint being up proves only that the IDE is RUNNING — it may
679
+ // have a DIFFERENT project open. Ask the IDE whether THIS project is
680
+ // open, and only request an open when it is not: for an already-open
681
+ // project the IDE runs a short reopen-then-dispose cycle on a
682
+ // redundant open event, so skipping it is a real saving.
683
+ if (ideApp && !(await isProjectOpen(serving))) {
684
+ await openInIde(projectPath, { app: ideApp });
685
+ }
662
686
  return { status: 'connected', port: serving, unavailableNoticeSent: false };
663
687
  }
664
688
  if (!ideApp) return { status: 'disabled' };
@@ -937,13 +961,6 @@ export default {
937
961
  return async () => {
938
962
  disposed = true;
939
963
  if (heartbeatTimer) clearInterval(heartbeatTimer);
940
- if (shellRegistration) {
941
- try {
942
- await shellRegistration.dispose();
943
- } catch {
944
- // best effort
945
- }
946
- }
947
964
  if (sessionRegistration) {
948
965
  try {
949
966
  await sessionRegistration.dispose();
package/src/env.js DELETED
@@ -1,39 +0,0 @@
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
- }
@@ -1,101 +0,0 @@
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.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
- }