opencode-idea 0.0.16 → 0.0.18

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.
@@ -0,0 +1,118 @@
1
+ # IDE MCP:只有非 IDE 不可的能力才走 IDE
2
+
3
+ 本会话接入了 {{projectCount}} 个 JetBrains 项目。**每个功能在所有项目下都各有一份工具**,命令名就是
4
+ `<项目前缀>_<工具名>`;下面每条功能后面直接列出它在各项目里的命令名。
5
+
6
+ ## 本会话接入的项目与各自的 MCP 工具
7
+
8
+ {{routeTable}}
9
+
10
+ **子项目就是根目录下的目录**,只是被另一个 IDE 单独打开、配了另一套工具。所以这里说的"根项目",指的是
11
+ **根目录中扣掉这些子项目之后的部分** —— 操作某个子项目里的文件,就用那个子项目的命令,不要因为它"在根目录下"
12
+ 就用根项目的工具。
13
+
14
+ **规则:操作哪个目录下的文件/代码,就用那个目录对应的命令。** IDE 是按项目解析的 —— 跨项目调用要么直接报错,
15
+ 要么被解析到另一个项目上、作用在错误的代码上。
16
+
17
+ 按需读文件:要了解某个子项目自己的约定,读它的 `<子项目目录>/AGENTS.md`(它不会自动进入本会话的上下文,
18
+ 本会话是 root 打开的那一个)。
19
+
20
+ ## 总则(所有项目通用)
21
+
22
+ - 原生工具(当前工具列表里直接列出的那些)**直接调用**;不需要、也不应该用 Code Mode 的 `search` 去找。
23
+ - 下面这些 IDE 工具也是原生工具:直接调用(参数按各工具 schema 传),不要包进 `execute`;它们不在 Code Mode 的工具目录里,`search` 搜不到。
24
+ - **只有 IDE 才有的能力才用它们**:符号级检索、项目模型、检查与构建、重构与格式化、run configuration。
25
+ - **读写与搜索用原生工具**(`read` / `write` / `edit` / `grep` / `glob`):不要为读一个文件、写一个文件、搜一段文本去绕 IDE 工具。
26
+ - **唯一例外**:压缩包内的条目(路径带 `.jar!/`,原生 `read` 打不开)—— 见下面「读压缩包内的东西」。
27
+ - 某条功能下面没有你正要操作的那个项目的命令,就是那个项目没接上:提示用户执行 `/open-in-idea`,不要重试,也不要去别处找。
28
+
29
+ ## 工具(每条第一行是各项目的命令,接着是它的用法)
30
+
31
+ ### 符号(只有 IDE 的语义索引能给)
32
+
33
+ - {{cmd_search_symbol}}
34
+ **按名字找符号的声明位置**(`q` 传查询文本,**固定带上 `include_external: true`**):本插件最不可替代的能力 —— 它搜的是**符号**(类 / 接口 / 方法 / 函数 / 字段 / 类型 / 常量这类**有名字的定义**)的**声明位置**,不是"哪段文本提到过它"(那是 `grep` 的事);带上 `include_external` 才会连**依赖库和标准库**一起搜,于是你能直接定位第三方库里的实现 —— 这是原生 `grep` / `glob` 够不着的地方(IDE 自己的 `search_text` / `search_file` 也只搜项目内)。
35
+ - **典型用法 ①:代码里引用了某个库的 API(最常见)** —— 读代码时看到 `from fastapi import FastAPI`、`import org.apache.poi...FormulaParser`、`gorm.DeletedAt`、`from 'vue'` 这类引用时,**包名就在代码里**,直接**用「包名+符号名」当 `q` 搜**,一次定位到它的实现:
36
+ - **Java**:全限定名 —— `com.nimbusds.jose.util.Base64`(实测 1 条命中 `~/.m2/.../poi-5.3.0-sources.jar!/...`)
37
+ - **Go**:包名.符号 —— `gorm.DeletedAt`(实测 2 条,全在 `go/pkg/mod/gorm.io/gorm@...`)
38
+ - **Python**:模块路径给得越全越准 —— `torch.nn.modules.module.Module`(目标全在最前);`torch.nn.Module` 也能把目标排进前 2
39
+ - **Node/TS**:用 **import 的那层包名**(不是子模块路径)—— `vue.Teleport` 有效,`@vue/runtime-core.TeleportProps` 搜不到
40
+ - **典型用法 ②:调研一个库有哪些 API** —— 包名从依赖清单里拿(pom.xml / package.json / go.mod / requirements.txt),**直接拿包名当 `q` 搜**,结果基本就落在这个库里,再读入口:
41
+ - **Python**:`fastapi` → `fastapi/applications.py`、`fastapi/__init__.py`(**入口就是导出清单**:直接看到 `FastAPI`、`HTTPException` 这些公开名字)
42
+ - **Java**:`poi` → `~/.m2/.../poi-5.3.0-sources.jar!/org/apache/poi/...`;读该库的入口类
43
+ - **Go**:`gorm` → `go/pkg/mod/gorm.io/gorm@v1.31.0/...`
44
+ - **Node/TS 例外**:包名当 `q` 搜不到(实测 `element-plus` → 0 条)。要用**代码里 import 的符号名**;包名是简单标识符时可以「包名.符号」(`vue.Teleport` 有效),但**带 `-` 或 `@scope/` 的这么拼是 0 条**(`element-plus.ElMessage`、`@vue/runtime-core.TeleportProps` 都搜不到)。而裸符号名会跨多个子项目(`ElMessage` 命中 user / admin / vcop 三处),这时用 `paths` 圈到那个 `node_modules` —— `paths: ['frontend/user/**']` 就收敛到一处。
45
+ - **它只覆盖「项目已经引入」的依赖**:搜不到某个库,基本就是这个库还没进依赖 —— 别反复试:要么先把它加进依赖(pom / requirements / package.json),要么改用 web 搜索 / GitHub 看它的源码。
46
+ - **同名符号往往有多处定义**(标准库一份、几个依赖各一份,还有测试和示例),所以**按代码上下文选**:看调用处 import 的是哪个包、传参是什么类型、返回路径属于哪个库 —— 别把第一行当答案。
47
+ - **它返回的路径,原样交给同一个项目的 `<P>_read_file` 就是源码**:两者同属一个项目、相对路径的基准一致,所以不用拼绝对路径、不用管是什么语言 —— 压缩包里的条目它也照样读(`.class` 还会反编译)。
48
+ 例:`<P>_search_symbol({ q: "UvicornWorker", include_external: true })` 给出 `../../../Library/.../uvicorn/workers.py` → `<P>_read_file({ file_path: "那个字符串" })`。
49
+ - **依赖索引是按项目的**:任何依赖都用**它所在项目**的那个 MCP 工具去搜 —— 根项目的依赖用根项目的命令,子项目的依赖用子项目的命令。拿一个项目的工具去搜另一个项目的依赖,只会在那个项目自己的文件里找,**拿不到库**。
50
+ - **别把这条路径交给原生 `read`**:原生 read 的基准是本会话的工作目录(根项目),拿子项目的相对路径去读会落到错误的位置。
51
+ - {{cmd_get_symbol_info}}
52
+ **一个符号的签名 / 文档 / 声明位置**:`filePath` + `line` + `column`。
53
+ - {{cmd_analyze_calls}}
54
+ **调用关系**:`symbolFqn` 传符号名,`analysisKind` 取 `INCOMING_CALLS` / `OUTGOING_CALLS`;`depth` 控层数,`childOffset` 翻页。
55
+
56
+ ### 项目与编辑器
57
+
58
+ - {{cmd_list_directory_tree}}
59
+ **项目视角的目录树**(尊重内容根与排除项):`maxDepth` 控深度。
60
+ - {{cmd_get_project_modules}}
61
+ **项目模块及类型**。
62
+ - {{cmd_get_project_dependencies}}
63
+ **项目直接声明的依赖**。
64
+ - {{cmd_get_all_open_file_paths}}
65
+ **用户在 IDE 里打开了哪些文件**(含当前激活的那个)。
66
+ - {{cmd_open_file_in_editor}}
67
+ **把文件在 IDE 里打开并激活**,给用户看。
68
+
69
+ ### 重构与格式化
70
+
71
+ - {{cmd_rename_refactoring}}
72
+ **按符号改名**,项目内的引用一并更新。
73
+ - {{cmd_reformat_file}}
74
+ **按 IDE 的代码风格重排**。
75
+
76
+ ### 校验
77
+
78
+ - {{cmd_lint_files}}
79
+ **批量检查**:`files` 传数组;只列有问题的文件。
80
+ - {{cmd_get_file_problems}}
81
+ **单文件检查**:`filePath`;结果在 `errors`。
82
+ - {{cmd_build_project}}
83
+ **构建校验**:`filesToRebuild` 可只构建指定文件;有问题返回在 `problems`,没返回即没问题。
84
+
85
+ ### 读压缩包内的东西
86
+
87
+ - {{cmd_read_file}}
88
+ **压缩包内的条目**(路径形如 `libs/foo.jar!/com/x/Bar.class` 或 `jar://` URL)只有它能读 —— 原生 `read` 打不开压缩包,而它还能把 `.class` **反编译成源码**;「符号」那条搜出来的路径也用它(原样传即可)。
89
+
90
+ ### 运行配置
91
+
92
+ - {{cmd_get_run_configurations}}
93
+ **项目里有哪些运行配置**:传 `filePath` 可列该文件的运行入口。**不要翻 `.run/` 目录** —— 存在 `.idea/` 里的那些看不到。
94
+ - {{cmd_execute_run_configuration}}
95
+ **跑已有配置**:Shell Script 要 `EXECUTE_IN_TERMINAL: false`;覆盖参数(`programArguments` / `workingDirectory` / `envs`)需配置支持 `supportsDynamicLaunchOverrides`。
96
+ {{#if canCreateRunConfiguration}}
97
+ - {{cmd_create_shell_run_configuration}}
98
+ **新建项目级 Shell 配置**(`configurationName` + `scriptPath` / `scriptText` / `cloneFrom` 三选一;写 `.run/<name>.run.xml`):**重操作** —— 只给固定要跑、反复要用的流程建,**且建之前必须先取得用户同意**;只支持 Shell 类型,**其他类型请用户在 IDE 里建**。
99
+ {{/if}}
100
+ {{#if canListRunningConfigurations}}
101
+ - {{cmd_list_running_configurations}}
102
+ **当前真正在跑的配置**(几个实例 / 各自 pid / 退出码):停 / 重启前后都先用它看状态。
103
+ {{/if}}
104
+ {{#if canStopRunConfiguration}}
105
+ - {{cmd_stop_run_configuration}}
106
+ **停掉该配置的全部实例并等它们退出**:没在跑也返回成功。
107
+ {{/if}}
108
+ {{#if canRerunRunConfiguration}}
109
+ - {{cmd_rerun_run_configuration}}
110
+ **改完代码重启无热重载的进程**:在跑就重启、没在跑就启动;读 `started` 判断新进程是否真的起来了。不要用 `pkill` / `kill` / `shell` 代替。
111
+ {{/if}}
112
+
113
+ ## 命令执行(所有项目通用)
114
+
115
+ - 需要项目环境的命令(依赖 IDE 里配好的 JDK / Node / Python 版本等):用该项目前缀的 run configuration 工具,不要用原生 `shell`。
116
+ - run configuration 是**重资产**:一个项目通常只建几个(固定要跑的构建 / 装依赖 / dev server 之类),**不是随手一条命令就建一个**。要新建时先确认它**反复要用、且属于项目固定流程**,再**先告诉用户要建什么、为什么,取得同意才动手**,别自己决定。
117
+ - **配置一律不由你写**:不要手写 `.run/*.run.xml`,也不要手搓启动命令。
118
+ - 不需要项目环境的命令:用原生 `shell`。
@@ -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-idea 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
+ }
@@ -0,0 +1,71 @@
1
+ // MCP surface added by the companion IDEA plugin `idea-mcp-enhance`.
2
+ //
3
+ // Kept apart from `idea.js` on purpose. `idea.js` describes ONE thing: the MCP
4
+ // server IntelliJ itself bundles, which every supported IDE has. Everything in
5
+ // this file describes a SECOND, optional source of tools — a plugin the user may
6
+ // or may not have installed. Two different origins, two files:
7
+ //
8
+ // * native -> `idea.js` (always present; the IDE owns the catalog)
9
+ // * plugin -> this file (present only if the user installed the plugin)
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.
15
+ //
16
+ // Consequence of the split, and the reason it matters: a stock IDE serves a
17
+ // 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`).
19
+
20
+ /**
21
+ * The enhance plugin's tool names, as **suffixes**.
22
+ *
23
+ * OpenCode namespaces an MCP server's tools with the server name, and each
24
+ * project is registered under its own prefix (root = `ide`, a subproject = its
25
+ * directory name). So the wire name is `<prefix><suffix>` — for example
26
+ * `ide_rerun_run_configuration` or `modelserver_rerun_run_configuration`.
27
+ *
28
+ * 存后缀而不是完整名字:同一个工具在每个项目前缀下各有一份,写死任何一个前缀都会
29
+ * 让别的项目查不到它。
30
+ *
31
+ * Keep in sync with `RunLifecycleSchemas.kt` in the enhance repository — the
32
+ * suffixes are its contract, and there is no runtime way to discover "which
33
+ * plugin provided this tool": MCP carries no such marker.
34
+ */
35
+ export const CREATE_SHELL_RUN_CONFIGURATION_SUFFIX = 'create_shell_run_configuration';
36
+ export const LIST_RUNNING_CONFIGURATIONS_SUFFIX = 'list_running_configurations';
37
+ export const STOP_RUN_CONFIGURATION_SUFFIX = 'stop_run_configuration';
38
+ export const RERUN_RUN_CONFIGURATION_SUFFIX = 'rerun_run_configuration';
39
+
40
+ /** All of them, in the order the guidance lists them: create, see, stop, restart. */
41
+ export const RUN_LIFECYCLE_SUFFIXES = [
42
+ CREATE_SHELL_RUN_CONFIGURATION_SUFFIX,
43
+ LIST_RUNNING_CONFIGURATIONS_SUFFIX,
44
+ STOP_RUN_CONFIGURATION_SUFFIX,
45
+ RERUN_RUN_CONFIGURATION_SUFFIX,
46
+ ];
47
+
48
+ /**
49
+ * Which of the enhance plugin's tools **this project's prefix** carries in this
50
+ * request.
51
+ *
52
+ * 返回布尔组合而不是名字列表:提示词要的是"这段能不能写",而且每个项目各算各的
53
+ * —— 一个项目装了 enhance、另一个没装是可能的。
54
+ *
55
+ * 客户端版本差异同理:enhance 版本独立发布,旧版可能少几个工具,所以逐个判断,
56
+ * 提示词绝不能点名一个模型调不到的工具。
57
+ *
58
+ * @param {Record<string, unknown> | undefined} tools This request's tool record
59
+ * (`event.tools` from the `context` hook).
60
+ * @param {string} prefix 该项目的工具前缀(含下划线)。
61
+ */
62
+ export function runLifecycleCapabilities(tools, prefix) {
63
+ const has = (suffix) =>
64
+ Boolean(tools) && typeof tools === 'object' && `${prefix}${suffix}` in tools;
65
+ return {
66
+ canCreateRunConfiguration: has(CREATE_SHELL_RUN_CONFIGURATION_SUFFIX),
67
+ canListRunningConfigurations: has(LIST_RUNNING_CONFIGURATIONS_SUFFIX),
68
+ canStopRunConfiguration: has(STOP_RUN_CONFIGURATION_SUFFIX),
69
+ canRerunRunConfiguration: has(RERUN_RUN_CONFIGURATION_SUFFIX),
70
+ };
71
+ }