opencode-idea 0.0.17 → 0.0.19

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.
@@ -12,50 +12,110 @@
12
12
  // unavailable; this module only requests opening/activating the project.
13
13
 
14
14
  import { spawn } from 'node:child_process';
15
+ import { readdirSync } from 'node:fs';
16
+ import { homedir } from 'node:os';
17
+ import { join } from 'node:path';
15
18
 
16
- /** Default macOS app name for IntelliJ IDEA. */
19
+ /** 兜底 macOS 应用名:调用方没给 app 时用它。 */
17
20
  export const DEFAULT_IDE_APP = 'IntelliJ IDEA';
18
21
 
19
22
  /**
20
- * Can this platform launch the IDE at all?
23
+ * 每个 IDE 选项值对应的 macOS 应用名。
21
24
  *
22
- * Launching goes through macOS `open -a`, which is the only launcher this
23
- * plugin implements. On Windows there is no `open` command, so a spawn there
24
- * fails with ENOENT — silently, as a rejected promise that resolves `false`.
25
- * Rather than pretend, other platforms resolve no app at all and the caller
26
- * asks the user to open the IDE by hand.
27
- *
28
- * A function, not a constant: the answer is read when a command runs, so tests
29
- * can simulate another platform by replacing it. A constant would be bound at
30
- * import time and never re-read.
25
+ * 只包含受支持的 IDE(见 `ide-projects.js` 的 `SUPPORTED_IDES`)—— 应用名由项目
26
+ * 配置决定,不再由用户在 `openInIde` 里自由指定,所以这里是一张封闭的表。
31
27
  */
32
- export function autoLaunchSupported() {
33
- return process.platform === 'darwin';
34
- }
35
-
36
- /** Aliases accepted by the `openInIde` option. */
37
28
  export const IDE_APPS = {
38
29
  idea: 'IntelliJ IDEA',
39
- intellij: 'IntelliJ IDEA',
40
- 'intellij-idea': 'IntelliJ IDEA',
30
+ pycharm: 'PyCharm',
41
31
  };
42
32
 
43
33
  /**
44
- * Resolve the `openInIde` option to a macOS app name, or `undefined` to disable.
34
+ * 该 IDE 的 macOS 应用名,`undefined` 表示"不能自动拉起"。
45
35
  *
46
- * `undefined` covers two distinct reasons, and the caller tells them apart via
47
- * `autoLaunchSupported()`: the user turned launching off, or this platform has
48
- * no launcher. They deserve different user-facing wording.
36
+ * `undefined` 有两种原因,调用方用 `autoLaunchSupported()` 区分:这个平台没有
37
+ * 启动器,或这个 IDE 不在支持列表里。两者给用户的建议不同,不能混为一谈。
49
38
  *
50
- * @param {boolean | string | undefined} value
39
+ * @param {string} app
51
40
  * @param {{ platform?: string }} [options]
52
41
  * @returns {string | undefined}
53
42
  */
54
- export function resolveIdeApp(value, { platform = process.platform } = {}) {
43
+ export function appNameFor(app, { platform = process.platform } = {}) {
55
44
  if (platform !== 'darwin') return undefined;
56
- if (value === false) return undefined;
57
- if (typeof value !== 'string') return DEFAULT_IDE_APP;
58
- return IDE_APPS[value.toLowerCase()] ?? value;
45
+ return IDE_APPS[app];
46
+ }
47
+
48
+ /**
49
+ * 各平台上看"装了哪些 IDE"的目录。
50
+ *
51
+ * macOS 那两个位置是**实测**的(这台机器上就是这么装的);Windows / Linux 列的是各自最
52
+ * 常见的安装位置(Toolbox 默认 + 系统级目录),**未在真机上验证过** —— 探错的后果只是少一
53
+ * 行提示、让模型照常问用户,不会挡住任何选择,所以先按常见值给出来。
54
+ */
55
+ function ideInstallDirs(platform = process.platform) {
56
+ const home = homedir();
57
+ if (platform === 'darwin') return ['/Applications', join(home, 'Applications')];
58
+ if (platform === 'win32') {
59
+ const local = process.env.LOCALAPPDATA ?? join(home, 'AppData', 'Local');
60
+ return [
61
+ join(local, 'Programs', 'JetBrains'),
62
+ join(local, 'JetBrains', 'Toolbox', 'apps'),
63
+ 'C:\\Program Files\\JetBrains',
64
+ ];
65
+ }
66
+ return [join(home, '.local', 'share', 'JetBrains', 'Toolbox', 'apps'), '/opt', '/usr/local'];
67
+ }
68
+
69
+ /**
70
+ * 本机装了哪些受支持的 IDE(`IDE_APPS` 的键)。
71
+ *
72
+ * 按**安装目录**判断,不看进程 —— 用 `ps aux` 之类探进程只能发现"正在运行"的 IDE,装了
73
+ * 但没打开就会漏(实测踩过:PyCharm 明明装着,进程探测报"未发现")。一次目录读取就够,
74
+ * 比模型自己去跑命令快几个数量级。
75
+ *
76
+ * 返回空数组表示"**没探测出来**",不能当成"没装":调用方要么照常问用户,要么在提示里说明
77
+ * 是"没找到"。
78
+ *
79
+ * @param {{ platform?: string, dirs?: string[] }} [options] 仅供测试注入。
80
+ * @returns {string[]}
81
+ */
82
+ export function detectInstalledIdes(options = {}) {
83
+ const platform = options.platform ?? process.platform;
84
+ const dirs = options.dirs ?? ideInstallDirs(platform);
85
+ /** @type {Set<string>} */
86
+ const found = new Set();
87
+ for (const dir of dirs) {
88
+ let entries;
89
+ try {
90
+ entries = readdirSync(dir);
91
+ } catch {
92
+ // 目录不存在(例如没有 ~/Applications)就是没装,继续看下一个。
93
+ continue;
94
+ }
95
+ for (const entry of entries) {
96
+ const bundle = entry.endsWith('.app') ? entry.slice(0, -'.app'.length) : entry;
97
+ for (const [app, label] of Object.entries(IDE_APPS)) {
98
+ // `IntelliJ IDEA.app` 精确匹配;`PyCharm CE.app`、`PyCharm 2026.2`、Toolbox 的
99
+ // `PyCharm` 目录都是产品名开头,按前缀匹配。
100
+ if (bundle === label || bundle.startsWith(label)) found.add(app);
101
+ }
102
+ }
103
+ }
104
+ return [...found];
105
+ }
106
+
107
+ /**
108
+ * 这个平台有没有自动拉起 IDE 的能力?
109
+ *
110
+ * 只有 macOS 实现了启动(`open -a`)。Windows 上没有 `open` 命令,spawn 会以
111
+ * ENOENT 失败——表现为一个静默 resolve 成 `false` 的 promise。与其假装能启动,
112
+ * 不如让调用方直接告诉用户手动打开 IDE。
113
+ *
114
+ * 写成函数而不是常量:答案在命令真正执行时读取,测试才能用替换它的方式模拟
115
+ * 其它平台;常量会在 import 时定死,之后再也不重读。
116
+ */
117
+ export function autoLaunchSupported() {
118
+ return process.platform === 'darwin';
59
119
  }
60
120
 
61
121
  /**
@@ -0,0 +1,262 @@
1
+ // IDE 项目集合:一个 OpenCode 会话(root 项目)可以接入多个 JetBrains 项目 ——
2
+ // root 本身,加上 root 下任意层级的子目录;每个项目可以被**不同的** IDE 打开
3
+ // (root 用 IDEA、modelserver 用 PyCharm 是常态)。
4
+ //
5
+ // 这个模块只做纯数据:持久化标记的解析/序列化、工具前缀(= MCP server 名)的派生、
6
+ // 以及新增子项目前的校验。所有 I/O、探测、注册都留在 plugin.js,所以这里全部是
7
+ // 可以直接测的纯函数。
8
+ //
9
+ // ## 命名规则
10
+ //
11
+ // 工具前缀就是 MCP server 名,由项目的**层级**决定,与 IDE 产品无关:
12
+ //
13
+ // * root → `ide` root 用 IDEA 还是 PyCharm,它都叫 `ide_*`
14
+ // * 子项目 → 目录名 `modelserver` → `modelserver_*`
15
+ //
16
+ // 为什么不叫 `idea_*`:那会把"root 项目"和"IntelliJ IDEA 这个产品"绑死,而
17
+ // 「root 用 PyCharm 打开、子项目用 IDEA 打开」是完全合理的组合。
18
+ //
19
+ // ## 为什么前缀必须严格控制字符集
20
+ //
21
+ // 前缀会变成工具名的一部分(`modelserver_search_symbol`),模型要照着调。目录名
22
+ // 里但凡有空格、点、大写或非 ASCII,工具名就会变得难写、难赌 —— 跨平台还可能
23
+ // 产生"同名不同目录"。所以不规范的目录名**一律拒绝**(见 validateSubproject),
24
+ // 由用户改目录名,插件绝不自动改名或消歧。
25
+
26
+ import { basename, sep } from 'node:path';
27
+
28
+ /** root 项目的 MCP server 名。与 IDE 产品解耦,见文件头。 */
29
+ export const ROOT_SERVER_NAME = 'ide';
30
+
31
+ /** `ctx.options.ides` 允许的取值;未知值会被忽略。 */
32
+ export const SUPPORTED_IDES = ['idea', 'pycharm'];
33
+
34
+ /** 每个 IDE 选项值对应的显示名(提示词里写给模型看)。 */
35
+ export const IDE_LABELS = {
36
+ idea: 'IntelliJ IDEA',
37
+ pycharm: 'PyCharm',
38
+ };
39
+
40
+ /**
41
+ * 每个 IDE 的 MCP 端口。
42
+ *
43
+ * **固定值,不开放配置**:JetBrains 给内置 MCP server 分配的就是这个端口,跨版本、
44
+ * 跨项目都不变(IDEA 的 MCP 在 64342,它的内置服务是 63342;PyCharm 的 MCP 在 64462,
45
+ * 内置服务是 63343)。既然"哪个 IDE 在哪个端口"是产品常量,插件就不需要"扫描候选端口
46
+ * 找归属":探哪个端口由项目的 IDE 唯一决定,不经过任何用户配置。
47
+ *
48
+ * 导出成**可变对象**而不是冻结字面量:测试要把它们指到假端口上(见
49
+ * `tests/plugin.test.js` 的 `fakeCtx`),运行时没有任何人写它。
50
+ */
51
+ export const IDE_PORTS = {
52
+ idea: 64342,
53
+ pycharm: 64462,
54
+ };
55
+
56
+ /**
57
+ * 子项目目录名(= 工具前缀)必须匹配的形状。
58
+ *
59
+ * 小写字母或数字开头,其后只允许小写字母、数字、`-`、`_`。上限 48 是硬约束之一
60
+ * 的保守取值:工具名要在模型提示里反复出现,越短越好。
61
+ */
62
+ export const SUBPROJECT_PREFIX = /^[a-z0-9][a-z0-9_-]{0,47}$/;
63
+
64
+ /** 持久化标记里存的版本号,用于将来识别旧结构。 */
65
+ export const MARK_VERSION = 2;
66
+
67
+ /**
68
+ * @typedef {{ path: string, app: string }} IdeProject
69
+ * @typedef {{ root: IdeProject, children: IdeProject[] }} IdeConfig
70
+ */
71
+
72
+ /** 这个 root 的持久化标记 key。 */
73
+ export function markKeyFor(rootPath) {
74
+ return `project/${rootPath}`;
75
+ }
76
+
77
+ /** @param {unknown} entry @returns {boolean} */
78
+ function isProjectEntry(entry) {
79
+ return (
80
+ entry !== null &&
81
+ typeof entry === 'object' &&
82
+ typeof (/** @type {IdeProject} */ (entry).path) === 'string' &&
83
+ typeof (/** @type {IdeProject} */ (entry).app) === 'string'
84
+ );
85
+ }
86
+
87
+ /**
88
+ * 解析持久化的标记值。
89
+ *
90
+ * 兼容 v1:旧版本存的是布尔 `true`(只表示"root 已接入",没有 IDE 与子项目信息),
91
+ * 这里把它读成"root 用 idea 的 v1 配置"。任何无法识别的形状都当作**未配置**
92
+ * (`undefined`),因为一个读不懂的配置比没有配置更危险。
93
+ *
94
+ * @param {unknown} raw 从 `ctx.storage` 读到的原始值
95
+ * @param {string} rootPath
96
+ * @returns {IdeConfig | undefined}
97
+ */
98
+ export function parseMark(raw, rootPath) {
99
+ if (raw === true) return { root: { path: rootPath, app: 'idea' }, children: [] };
100
+ if (raw === null || typeof raw !== 'object') return undefined;
101
+ const record = /** @type {{ root?: unknown, children?: unknown }} */ (raw);
102
+ if (!isProjectEntry(record.root)) return undefined;
103
+ const root = /** @type {IdeProject} */ (record.root);
104
+ const children = Array.isArray(record.children)
105
+ ? record.children.filter(isProjectEntry).map((entry) => {
106
+ const child = /** @type {IdeProject} */ (entry);
107
+ return { path: child.path, app: child.app };
108
+ })
109
+ : [];
110
+ return { root: { path: root.path, app: root.app }, children };
111
+ }
112
+
113
+ /** @param {IdeConfig} config @returns {Record<string, unknown>} */
114
+ export function serializeMark(config) {
115
+ return {
116
+ version: MARK_VERSION,
117
+ root: { path: config.root.path, app: config.root.app },
118
+ children: config.children.map((child) => ({ path: child.path, app: child.app })),
119
+ };
120
+ }
121
+
122
+ /**
123
+ * 一个项目对应的 MCP server 名(= 工具前缀)。
124
+ *
125
+ * @param {string} path
126
+ * @param {string} rootPath
127
+ */
128
+ export function serverNameFor(path, rootPath) {
129
+ return path === rootPath ? ROOT_SERVER_NAME : basename(path);
130
+ }
131
+
132
+ /**
133
+ * 展开成"项目 + 前缀"的平铺列表,顺序固定:root 在最前,子项目按登记顺序。
134
+ *
135
+ * @param {IdeConfig} config
136
+ * @returns {Array<{ path: string, app: string, server: string, isRoot: boolean }>}
137
+ */
138
+ export function listProjects(config) {
139
+ const root = {
140
+ path: config.root.path,
141
+ app: config.root.app,
142
+ server: ROOT_SERVER_NAME,
143
+ isRoot: true,
144
+ };
145
+ const children = config.children.map((child) => ({
146
+ path: child.path,
147
+ app: child.app,
148
+ server: serverNameFor(child.path, config.root.path),
149
+ isRoot: false,
150
+ }));
151
+ return [root, ...children];
152
+ }
153
+
154
+ /** 当前配置里所有项目的工具前缀(带下划线),例如 `["ide_", "modelserver_"]`。 */
155
+ export function toolPrefixes(config) {
156
+ return listProjects(config).map((project) => `${project.server}_`);
157
+ }
158
+
159
+ /**
160
+ * 校验:把 `absPath` 登记为 root 的子项目是否合法。
161
+ *
162
+ * 全部规则都是**拒绝**,不做任何自动修正 —— 与插件其它地方一致:静默改一个名字,
163
+ * 等于把用户的目录和模型看到的工具名对不上,而且没人会发现。
164
+ *
165
+ * @param {IdeConfig} config
166
+ * @param {string} absPath 待登记的绝对路径
167
+ * @returns {{ ok: true } | { ok: false, code: string, message: string }}
168
+ */
169
+ export function validateSubproject(config, absPath) {
170
+ const rootPath = config.root.path;
171
+ const prefix = rootPath.endsWith(sep) ? rootPath : `${rootPath}${sep}`;
172
+ if (!absPath.startsWith(prefix)) {
173
+ return {
174
+ ok: false,
175
+ code: 'outside-root',
176
+ message: `必须位于 root 项目(${rootPath})之下`,
177
+ };
178
+ }
179
+ const name = basename(absPath);
180
+ if (!SUBPROJECT_PREFIX.test(name)) {
181
+ return {
182
+ ok: false,
183
+ code: 'invalid-name',
184
+ message: `目录名 \`${name}\` 不能作为工具前缀:要求 ^[a-z0-9][a-z0-9_-]{0,47}$,即以小写字母或数字开头,只含小写字母、数字、- 和 _`,
185
+ };
186
+ }
187
+ if (name === ROOT_SERVER_NAME) {
188
+ return {
189
+ ok: false,
190
+ code: 'reserved',
191
+ message: `\`${ROOT_SERVER_NAME}\` 是 root 项目保留的工具前缀,请在别处使用这个目录名`,
192
+ };
193
+ }
194
+ const existing = config.children.find((child) => child.path === absPath);
195
+ if (existing) {
196
+ return { ok: false, code: 'already-registered', message: `\`${name}\` 已经登记过了` };
197
+ }
198
+ const clash = config.children.find((child) => basename(child.path) === name);
199
+ if (clash) {
200
+ return {
201
+ ok: false,
202
+ code: 'duplicate-prefix',
203
+ message: `工具前缀 \`${name}\` 已被 ${clash.path} 占用;两个子项目不能同名,请重命名其中一个目录`,
204
+ };
205
+ }
206
+ return { ok: true };
207
+ }
208
+
209
+ /**
210
+ * 加入一个子项目,返回新配置(不改入参)。
211
+ *
212
+ * @param {IdeConfig} config
213
+ * @param {string} absPath
214
+ * @param {string} app
215
+ * @returns {IdeConfig}
216
+ */
217
+ export function withSubproject(config, absPath, app) {
218
+ return {
219
+ root: { ...config.root },
220
+ children: [...config.children.map((child) => ({ ...child })), { path: absPath, app }],
221
+ };
222
+ }
223
+
224
+ /**
225
+ * 从参数文本里切出子项目路径。
226
+ *
227
+ * 斜杠命令的参数是**整串**(`input.prompt.text`),不分词,所以这里自己切:
228
+ * 取第一个空白分隔的片段。多给的部分是错的,宁可报错也不要猜。
229
+ *
230
+ * @param {unknown} text
231
+ * @returns {{ ok: true, path: string } | { ok: false, message: string }}
232
+ */
233
+ export function parseSubprojectArgument(text) {
234
+ const raw = typeof text === 'string' ? text.trim() : '';
235
+ if (raw === '') return { ok: false, message: '缺少子项目路径参数' };
236
+ const parts = raw.split(/\s+/);
237
+ if (parts.length > 1) {
238
+ return {
239
+ ok: false,
240
+ message: `只接受一个参数(子项目路径),收到 ${parts.length} 个:\`${raw}\``,
241
+ };
242
+ }
243
+ return { ok: true, path: parts[0] };
244
+ }
245
+
246
+ /**
247
+ * 从参数文本里切出 IDE 名。
248
+ *
249
+ * `/open-in-ide pycharm` 是"AI 不配合时的直通逃生口":正常流程由 AI 向用户提问后
250
+ * 调工具写回,带参只是保险。空串表示"没给",由调用方决定是提问还是用默认值。
251
+ *
252
+ * @param {unknown} text
253
+ * @returns {{ ok: true, app: string | undefined } | { ok: false, message: string }}
254
+ */
255
+ export function parseIdeArgument(text) {
256
+ const raw = typeof text === 'string' ? text.trim().toLowerCase() : '';
257
+ if (raw === '') return { ok: true, app: undefined };
258
+ if (!SUPPORTED_IDES.includes(raw)) {
259
+ return { ok: false, message: `不支持的 IDE \`${raw}\`,可选:${SUPPORTED_IDES.join(' / ')}` };
260
+ }
261
+ return { ok: true, app: raw };
262
+ }
@@ -8,52 +8,134 @@
8
8
  // * native -> `idea.js` (always present; the IDE owns the catalog)
9
9
  // * plugin -> this file (present only if the user installed the plugin)
10
10
  //
11
- // The companion plugin (https://github.com/syllr/idea-mcp-enhance) fills the gaps
12
- // the bundled server leaves in run configuration handling: the bundled server can
13
- // only START an existing configuration — it cannot create one, stop one, restart
14
- // one, or say what is currently running.
11
+ // The companion plugin (https://github.com/syllr/idea-mcp-enhance) fills gaps the
12
+ // bundled server leaves in two places:
13
+ //
14
+ // * run configuration — the bundled server can only START an existing
15
+ // configuration; it cannot create one, stop one, restart one, or say what is
16
+ // currently running;
17
+ // * deployment — the bundled server has nothing at all for the IDE's
18
+ // Deployment settings or for moving files to/from a server.
19
+ //
20
+ // The two are kept as separate groups everywhere (here, in the capability
21
+ // functions, and in the guidance), because they are NOT the same precondition:
22
+ // deployment additionally needs `com.jetbrains.plugins.webDeployment`, which
23
+ // exists only in Ultimate. One boolean covering both would let a deployment tool
24
+ // answer "are the run-lifecycle tools here?".
15
25
  //
16
26
  // Consequence of the split, and the reason it matters: a stock IDE serves a
17
27
  // SHORTER catalog than an enhanced one. Nothing here may be assumed — every use
18
- // has to be gated on the tools really being in the request (`servingRunLifecycleTools`).
28
+ // has to be gated on the tools really being in the request.
19
29
 
20
30
  /**
21
- * The enhance plugin's tool names as OpenCode reports them.
31
+ * The enhance plugin's **run-lifecycle** tool names, as **suffixes**.
32
+ *
33
+ * OpenCode namespaces an MCP server's tools with the server name, and each
34
+ * project is registered under its own prefix (root = `ide`, a subproject = its
35
+ * directory name). So the wire name is `<prefix><suffix>` — for example
36
+ * `ide_rerun_run_configuration` or `modelserver_rerun_run_configuration`.
22
37
  *
23
- * OpenCode namespaces an MCP server's tools with the server name, and this
24
- * plugin registers the IDE server as `idea`, so the wire names are the enhance
25
- * plugin's own tool names (`list_running_configurations`, ...) carrying an
26
- * `idea_` prefix.
38
+ * 存后缀而不是完整名字:同一个工具在每个项目前缀下各有一份,写死任何一个前缀都会
39
+ * 让别的项目查不到它。
27
40
  *
28
41
  * Keep in sync with `RunLifecycleSchemas.kt` in the enhance repository — the
29
- * names are its contract, and there is no runtime way to discover "which plugin
30
- * provided this tool": MCP carries no such marker.
42
+ * suffixes are its contract, and there is no runtime way to discover "which
43
+ * plugin provided this tool": MCP carries no such marker.
31
44
  */
32
- export const CREATE_SHELL_RUN_CONFIGURATION_TOOL = 'idea_create_shell_run_configuration';
33
- export const LIST_RUNNING_CONFIGURATIONS_TOOL = 'idea_list_running_configurations';
34
- export const STOP_RUN_CONFIGURATION_TOOL = 'idea_stop_run_configuration';
35
- export const RERUN_RUN_CONFIGURATION_TOOL = 'idea_rerun_run_configuration';
45
+ export const CREATE_SHELL_RUN_CONFIGURATION_SUFFIX = 'create_shell_run_configuration';
46
+ export const LIST_RUNNING_CONFIGURATIONS_SUFFIX = 'list_running_configurations';
47
+ export const STOP_RUN_CONFIGURATION_SUFFIX = 'stop_run_configuration';
48
+ export const RERUN_RUN_CONFIGURATION_SUFFIX = 'rerun_run_configuration';
36
49
 
37
50
  /** All of them, in the order the guidance lists them: create, see, stop, restart. */
38
- export const RUN_LIFECYCLE_TOOLS = [
39
- CREATE_SHELL_RUN_CONFIGURATION_TOOL,
40
- LIST_RUNNING_CONFIGURATIONS_TOOL,
41
- STOP_RUN_CONFIGURATION_TOOL,
42
- RERUN_RUN_CONFIGURATION_TOOL,
51
+ export const RUN_LIFECYCLE_SUFFIXES = [
52
+ CREATE_SHELL_RUN_CONFIGURATION_SUFFIX,
53
+ LIST_RUNNING_CONFIGURATIONS_SUFFIX,
54
+ STOP_RUN_CONFIGURATION_SUFFIX,
55
+ RERUN_RUN_CONFIGURATION_SUFFIX,
56
+ ];
57
+
58
+ /**
59
+ * The enhance plugin's **deployment** tool names, as suffixes — same contract and
60
+ * same reason for suffixes as {@link RUN_LIFECYCLE_SUFFIXES}.
61
+ *
62
+ * Keep in sync with `DeploymentSchemas.kt` in the enhance repository.
63
+ *
64
+ * The four are gated together on one plugin (`com.jetbrains.plugins.webDeployment`,
65
+ * Ultimate only) rather than on version drift, so they usually appear or vanish as
66
+ * a set — the guidance still names them one by one, because "usually" is not
67
+ * "always" and a named tool the model cannot call is only a failed call.
68
+ */
69
+ export const LIST_DEPLOYMENT_SERVERS_SUFFIX = 'list_deployment_servers';
70
+ export const UPLOAD_TO_DEPLOYMENT_SUFFIX = 'upload_to_deployment';
71
+ export const DOWNLOAD_FROM_DEPLOYMENT_SUFFIX = 'download_from_deployment';
72
+ export const GET_DEPLOYMENT_TASK_STATUS_SUFFIX = 'get_deployment_task_status';
73
+
74
+ /** All of them: look at the servers, upload, download, then poll a transfer. */
75
+ export const DEPLOYMENT_SUFFIXES = [
76
+ LIST_DEPLOYMENT_SERVERS_SUFFIX,
77
+ UPLOAD_TO_DEPLOYMENT_SUFFIX,
78
+ DOWNLOAD_FROM_DEPLOYMENT_SUFFIX,
79
+ GET_DEPLOYMENT_TASK_STATUS_SUFFIX,
43
80
  ];
44
81
 
45
82
  /**
46
- * Which of the enhance plugin's tools this request actually carries.
83
+ * Does this request carry the tool called `suffix` under `prefix`?
84
+ *
85
+ * A malformed or missing snapshot answers "no": assuming a tool exists is the one
86
+ * mistake that cannot be walked back once the model has read the guidance.
47
87
  *
48
- * A subset rather than a boolean, because the enhance plugin is versioned
49
- * independently: an older release can ship fewer of these tools, and guidance
50
- * must never name one the model cannot call.
88
+ * Exported because the guidance asks this twice. The capability flags (below) ask
89
+ * "does this request have the tool somewhere" (any project will do); the command
90
+ * list next to each tool asks "under WHICH prefix", and that answer must be exact —
91
+ * 提示词里点名一个模型调不到的命令,是这个模块唯一要防的失败。
92
+ *
93
+ * @param {Record<string, unknown> | undefined} tools
94
+ * @param {string} prefix 该项目的工具前缀(含下划线)。
95
+ * @param {string} suffix
96
+ */
97
+ export const servesTool = (tools, prefix, suffix) =>
98
+ Boolean(tools) && typeof tools === 'object' && `${prefix}${suffix}` in tools;
99
+
100
+ /**
101
+ * Which of the enhance plugin's tools **this project's prefix** carries in this
102
+ * request.
103
+ *
104
+ * 返回布尔组合而不是名字列表:提示词要的是"这段能不能写",而且每个项目各算各的
105
+ * —— 一个项目装了 enhance、另一个没装是可能的。
106
+ *
107
+ * 客户端版本差异同理:enhance 版本独立发布,旧版可能少几个工具,所以逐个判断,
108
+ * 提示词绝不能点名一个模型调不到的工具。
51
109
  *
52
110
  * @param {Record<string, unknown> | undefined} tools This request's tool record
53
111
  * (`event.tools` from the `context` hook).
54
- * @returns {string[]} The present ones, in {@link RUN_LIFECYCLE_TOOLS} order.
112
+ * @param {string} prefix 该项目的工具前缀(含下划线)。
113
+ */
114
+ export function runLifecycleCapabilities(tools, prefix) {
115
+ return {
116
+ canCreateRunConfiguration: servesTool(tools, prefix, CREATE_SHELL_RUN_CONFIGURATION_SUFFIX),
117
+ canListRunningConfigurations: servesTool(tools, prefix, LIST_RUNNING_CONFIGURATIONS_SUFFIX),
118
+ canStopRunConfiguration: servesTool(tools, prefix, STOP_RUN_CONFIGURATION_SUFFIX),
119
+ canRerunRunConfiguration: servesTool(tools, prefix, RERUN_RUN_CONFIGURATION_SUFFIX),
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Same question for the deployment group, and the same answer shape — see
125
+ * {@link runLifecycleCapabilities}.
126
+ *
127
+ * The names repeat the tool's subject rather than the group (`canUploadToDeployment`,
128
+ * not `canUpload`): the guidance is one document about two groups, and a flag whose
129
+ * owner is unclear is how the wrong section gets the wrong text.
130
+ *
131
+ * @param {Record<string, unknown> | undefined} tools
132
+ * @param {string} prefix 该项目的工具前缀(含下划线)。
55
133
  */
56
- export function servingRunLifecycleTools(tools) {
57
- if (!tools || typeof tools !== 'object') return [];
58
- return RUN_LIFECYCLE_TOOLS.filter((name) => name in tools);
134
+ export function deploymentCapabilities(tools, prefix) {
135
+ return {
136
+ canListDeploymentServers: servesTool(tools, prefix, LIST_DEPLOYMENT_SERVERS_SUFFIX),
137
+ canUploadToDeployment: servesTool(tools, prefix, UPLOAD_TO_DEPLOYMENT_SUFFIX),
138
+ canDownloadFromDeployment: servesTool(tools, prefix, DOWNLOAD_FROM_DEPLOYMENT_SUFFIX),
139
+ canGetDeploymentTaskStatus: servesTool(tools, prefix, GET_DEPLOYMENT_TASK_STATUS_SUFFIX),
140
+ };
59
141
  }
@@ -9,8 +9,8 @@
9
9
  //
10
10
  // ## Why an argument can be owned at all
11
11
  //
12
- // `idea_build_project` is the only way a model can learn whether the code it just
13
- // wrote compiles, and its verdict depends on file freshness the model cannot see:
12
+ // `<prefix>build_project`(每个项目前缀各一份,例如 `ide_build_project`)是模型判断
13
+ // 刚写的代码能否编译的唯一手段,而它的结论依赖模型看不到的文件新鲜度:
14
14
  // an INCREMENTAL build compares against the IDE's own view of the files and
15
15
  // recompiles only what that view calls dirty. When the edit happened OUTSIDE the
16
16
  // IDE, the view can still be clean and the build becomes a no-op that reports
@@ -47,18 +47,25 @@
47
47
  /**
48
48
  * Arguments this plugin sets on every call, and therefore must not advertise.
49
49
  *
50
- * @type {ReadonlyArray<{ tool: string, argument: string, value: unknown }>}
50
+ * 工具用**后缀**标识,不写完整名字:同一个 IDE 工具会以每个项目的前缀各出现一次
51
+ * (`ide_build_project` / `modelserver_build_project` / ...),写死 `idea_...` 会让
52
+ * 多项目场景里只有 root 被命中,子项目那几份原样漏过去 —— 那正是这个模块要避免的
53
+ * "schema 说一套、调用做另一套"。
54
+ *
55
+ * @type {ReadonlyArray<{ suffix: string, argument: string, value: unknown }>}
51
56
  */
52
57
  export const FORCED_ARGUMENTS = [
53
58
  {
54
- tool: 'idea_build_project',
59
+ suffix: 'build_project',
55
60
  argument: 'rebuild',
56
61
  value: true,
57
62
  },
58
63
  ];
59
64
 
60
- /** The IDE tool whose verdict must not depend on the IDE's view of file freshness. */
61
- export const BUILD_PROJECT_TOOL = 'idea_build_project';
65
+ /** 由前缀集合拼出这张表涉及的**全部**真实工具名。 */
66
+ export function forcedToolNames(prefixes) {
67
+ return prefixes.flatMap((prefix) => FORCED_ARGUMENTS.map((owned) => `${prefix}${owned.suffix}`));
68
+ }
62
69
 
63
70
  /**
64
71
  * Set every owned argument on one call.
@@ -75,15 +82,17 @@ export const BUILD_PROJECT_TOOL = 'idea_build_project';
75
82
  *
76
83
  * @param {unknown} tool Effective tool name for this call (`event.tool`).
77
84
  * @param {unknown} input Mutable argument draft for this call (`event.input`).
85
+ * @param {readonly string[]} prefixes 本会话所有项目的工具前缀(`ide_` 等)。
78
86
  * @returns {boolean} Whether `input` was modified.
79
87
  */
80
- export function applyIdeaToolDefaults(tool, input) {
88
+ export function applyIdeToolDefaults(tool, input, prefixes) {
81
89
  if (typeof tool !== 'string') return false;
82
90
  if (input === null || typeof input !== 'object') return false;
83
91
 
84
92
  let changed = false;
85
93
  for (const owned of FORCED_ARGUMENTS) {
86
- if (owned.tool !== tool) continue;
94
+ const matches = prefixes.some((prefix) => tool === `${prefix}${owned.suffix}`);
95
+ if (!matches) continue;
87
96
  if (input[owned.argument] === owned.value) continue;
88
97
  input[owned.argument] = owned.value;
89
98
  changed = true;
@@ -131,6 +140,9 @@ export function removeSchemaProperty(schema, name) {
131
140
  * function of what it is given — which is also what makes it testable without a
132
141
  * runtime.
133
142
  *
143
+ * 每个项目前缀各处理一遍:同一个 IDE 工具会在工具表里以多份出现,漏掉任何一份都会
144
+ * 让那一份的 schema 继续广告这个旋钮。
145
+ *
134
146
  * Safe to run when the IDE is absent: `get` returns undefined for a tool that is
135
147
  * not registered, and OpenCode replays the transform after every MCP catalog
136
148
  * refresh, so the tools are adjusted once they appear.
@@ -139,16 +151,19 @@ export function removeSchemaProperty(schema, name) {
139
151
  * get: (id: string) => { input?: unknown } | undefined,
140
152
  * update: (id: string, update: (tool: { input?: unknown }) => void) => void,
141
153
  * }} editor
154
+ * @param {readonly string[]} prefixes
142
155
  */
143
- export function hideForcedArguments(editor) {
144
- for (const owned of FORCED_ARGUMENTS) {
145
- const tool = editor.get(owned.tool);
156
+ export function hideForcedArguments(editor, prefixes) {
157
+ for (const name of forcedToolNames(prefixes)) {
158
+ const owned = FORCED_ARGUMENTS.find((entry) => name.endsWith(entry.suffix));
159
+ if (!owned) continue;
160
+ const tool = editor.get(name);
146
161
  if (!tool) continue;
147
162
  const next = removeSchemaProperty(tool.input, owned.argument);
148
163
  if (next === tool.input) continue;
149
164
  // Assign the whole schema: the editor documents that schemas are replaced,
150
165
  // not mutated in place.
151
- editor.update(owned.tool, (draft) => {
166
+ editor.update(name, (draft) => {
152
167
  draft.input = next;
153
168
  });
154
169
  }