opencode-idea 0.0.18 → 0.0.20

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/src/plugin.js CHANGED
@@ -14,17 +14,18 @@
14
14
  // its own project through the `IJ_MCP_SERVER_PROJECT_PATH` header; injects
15
15
  // per-project direct-call guidance; and hides the IDE tools that must not
16
16
  // reach the model (for every prefix);
17
- // 2. registers `/open-in-idea` (接入 root 与其全部子项目), `/delete-in-ide`
17
+ // 2. registers `/open-in-ide` (接入 root 与其全部子项目), `/delete-in-ide`
18
18
  // (删掉整个配置) and `/add-sub-module-in-ide <路径>` (登记一个子项目),
19
19
  // plus the `configure_ide_project` tool the MODEL calls to write the IDE a
20
20
  // user picked into the config;
21
- // 3. registers the `idea-standalone` skill (marked projects only).
21
+ // 3. registers the shipped skills (`skills/<id>/SKILL.md`) — marked projects
22
+ // only, and each one also gated on the IDE tools it drives.
22
23
  //
23
24
  // A root that was never configured is left untouched. Everything is manual:
24
25
  // setup does NOT probe or connect any IDE. The commands probe, open the IDE, and
25
26
  // load the MCP servers + guidance on demand.
26
27
  //
27
- // `/open-in-idea` opens each configured project in ITS OWN IDE, waits until its
28
+ // `/open-in-ide` opens each configured project in ITS OWN IDE, waits until its
28
29
  // MCP endpoint is ready, registers it under its prefix, and keeps the config in
29
30
  // `ctx.storage`. A live endpoint alone does NOT mean our project is open there
30
31
  // (the running IDE may have a different project open), so the open flow asks the
@@ -38,7 +39,7 @@
38
39
  // after OpenCode evicted the project's Location services (the Location's
39
40
  // LayerMap uses `idleTimeToLive: "60 minutes"`) or after the process
40
41
  // restarted — setup deliberately does not connect, so without this the
41
- // session would stay tool-less until the user ran `/open-in-idea` by hand.
42
+ // session would stay tool-less until the user ran `/open-in-ide` by hand.
42
43
  // `prompt` is awaited before the request is built, so the message that
43
44
  // triggers it already carries the tools;
44
45
  // * the `context` hook, when a request that HAD IDE tools no longer does;
@@ -54,7 +55,6 @@ import { existsSync } from 'node:fs';
54
55
  import { resolve } from 'node:path';
55
56
 
56
57
  import { callTool, serverConfig } from './mcp/idea.js';
57
- import { runLifecycleCapabilities } from './mcp/enhance.js';
58
58
  import { applyIdeToolDefaults, hideForcedArguments } from './mcp/idea-tool-defaults.js';
59
59
  import { currentProjectPath } from './project.js';
60
60
  import {
@@ -405,26 +405,26 @@ export function ideFeedback(result) {
405
405
  return list ? `IDE MCP 已连接并接入:${list}。` : 'IDE MCP 已连接。';
406
406
  }
407
407
  case 'tools-loading':
408
- return 'IDE MCP 已连接,但原生 IDE 工具列表尚未完成注册。请稍后再次执行 /open-in-idea。';
408
+ return 'IDE MCP 已连接,但原生 IDE 工具列表尚未完成注册。请稍后再次执行 /open-in-ide。';
409
409
  case 'mcp-unavailable': {
410
410
  const where = nameOf(result?.project);
411
411
  const ide = appOf(result?.project);
412
412
  return result?.opened
413
- ? `已请求打开/激活 ${where} 并等待 MCP 服务启动,但未检测到 MCP 服务。请在 ${ide} 中开启 MCP 服务(Settings → MCP Server,需 2026.2+)并启用 Brave Mode,然后再次执行 /open-in-idea。`
414
- : `未检测到 ${where} 的 MCP 服务。请确认已打开,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-idea。`;
413
+ ? `已请求打开/激活 ${where} 并等待 MCP 服务启动,但未检测到 MCP 服务。请在 ${ide} 中开启 MCP 服务(Settings → MCP Server,需 2026.2+)并启用 Brave Mode,然后再次执行 /open-in-ide。`
414
+ : `未检测到 ${where} 的 MCP 服务。请确认已打开,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-ide。`;
415
415
  }
416
416
  case 'launch-failed':
417
- return `无法拉起 ${nameOf(result?.project)}(未找到应用或启动失败)。请确认它已安装 —— 没装的话先装上,再重试 /open-in-idea。`;
417
+ return `无法拉起 ${nameOf(result?.project)}(未找到应用或启动失败)。请确认它已安装 —— 没装的话先装上,再重试 /open-in-ide。`;
418
418
  case 'disabled':
419
- return '插件已通过 openInIde: false 关闭 IDE 启动。请手动打开 IDE,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后重试 /open-in-idea。';
419
+ return '插件已通过 openInIde: false 关闭 IDE 启动。请手动打开 IDE,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后重试 /open-in-ide。';
420
420
  case 'manual-launch':
421
- return '当前系统不支持自动打开 IDE(仅 macOS 实现了启动)。请手动打开 IDE,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-idea。';
421
+ return '当前系统不支持自动打开 IDE(仅 macOS 实现了启动)。请手动打开 IDE,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-ide。';
422
422
  case 'deleted':
423
- return '已删除本项目的 IDE 配置:已注销全部 IDE MCP 并清除配置。如需重新接入,请执行 /open-in-idea。';
423
+ return '已删除本项目的 IDE 配置:已注销全部 IDE MCP 并清除配置。如需重新接入,请执行 /open-in-ide。';
424
424
  case 'bad-argument':
425
425
  return `命令参数有误:${result?.message ?? ''}`;
426
426
  case 'not-configured':
427
- return '当前项目还没有 IDE 配置。请先执行 /open-in-idea 接入根项目,再登记子项目。';
427
+ return '当前项目还没有 IDE 配置。请先执行 /open-in-ide 接入根项目,再登记子项目。';
428
428
  case 'invalid-subproject':
429
429
  return `无法登记子项目:${result?.message ?? ''}`;
430
430
  case 'needs-ide':
@@ -435,7 +435,7 @@ export function ideFeedback(result) {
435
435
  }
436
436
 
437
437
  /**
438
- * The text the MODEL sees for `/open-in-idea` feedback. It is explicitly a
438
+ * The text the MODEL sees for `/open-in-ide` feedback. It is explicitly a
439
439
  * notification, not a task: the model must only acknowledge it. The user sees
440
440
  * the clean notice instead, via `metadata.displayText`.
441
441
  */
@@ -850,7 +850,7 @@ export default {
850
850
  * `open -a` only requests activation/opening. A cold IDE needs time before
851
851
  * its bundled MCP server listens, so poll until the endpoint appears AND
852
852
  * answers for THIS project. This is the step that makes a single
853
- * `/open-in-idea` enough on a cold start.
853
+ * `/open-in-ide` enough on a cold start.
854
854
  *
855
855
  * @param {{ path: string, app: string }} project
856
856
  * @returns {Promise<number | undefined>}
@@ -946,7 +946,7 @@ export default {
946
946
  };
947
947
 
948
948
  /**
949
- * `/open-in-idea` 的主流程:让每个已登记项目的 IDE 都就绪,然后把它们注册进
949
+ * `/open-in-ide` 的主流程:让每个已登记项目的 IDE 都就绪,然后把它们注册进
950
950
  * OpenCode。
951
951
  *
952
952
  * 每个项目各自拉起**自己的** IDE —— root 在 IDEA、某子项目在 PyCharm 是常态,
@@ -970,7 +970,7 @@ export default {
970
970
  let toolsReady = false;
971
971
  try {
972
972
  // 重新探测一次并整体注册:ensureProject 只保证 IDE 就绪,注册要按"当前真正
973
- // 打开着哪些项目"重来一遍,这样 `/open-in-idea` 每次都是同一套结果。
973
+ // 打开着哪些项目"重来一遍,这样 `/open-in-ide` 每次都是同一套结果。
974
974
  toolsReady = (await reconcile({ probeIde: true })) === true;
975
975
  } catch {
976
976
  // capability load is best effort; feedback still matters
@@ -987,7 +987,7 @@ export default {
987
987
  * The `context` hook latches the rebuild the moment a request shows the tools
988
988
  * are gone, but a latch alone only fires on the NEXT user message — one whole
989
989
  * turn later, which is exactly the gap that made a dead session sit there
990
- * until `/open-in-idea` was run by hand. This runs out of band (it never
990
+ * until `/open-in-ide` was run by hand. This runs out of band (it never
991
991
  * blocks the request) so the next request — the following agent-loop step, or
992
992
  * any later message — already carries the tools again.
993
993
  *
@@ -1065,20 +1065,21 @@ export default {
1065
1065
  const isSameSkillSet = (skills, ids) =>
1066
1066
  skills.length === ids.length && skills.every((skill, index) => skill.id === ids[index]);
1067
1067
 
1068
- // The shipped skills are static knowledge (`skills/<id>/SKILL.md`), e.g. the
1069
- // standalone-mode discipline (every app starts from an IDEA run
1070
- // configuration, and starting one is the user's call, so the skill has to be
1071
- // there to write the project's own rules). They pass two independent gates,
1068
+ // The shipped skills are static knowledge (`skills/<id>/SKILL.md`) that write
1069
+ // a project's own rules into its `AGENTS.md`: `idea-standalone` fixes how
1070
+ // resident apps are started, `idea-run-config-manager` fixes which repeated
1071
+ // flows are solidified into configurations. They pass two independent gates,
1072
1072
  // because a skill can be wrong twice over:
1073
1073
  //
1074
1074
  // * the project config — an unrelated project never sees an IDE skill;
1075
1075
  // * the tools the IDE actually serves — a skill that teaches a call the
1076
1076
  // model cannot make is not knowledge, it is a failed call waiting to
1077
- // happen. `idea-standalone` drives `rerun_run_configuration`, which only
1078
- // the companion plugin `idea-mcp-enhance` serves, so a stock IDE must not
1079
- // see it at all. Each skill declares its own dependency in its frontmatter
1080
- // (`requires:`), as a SUFFIX — tool names carry a per-project prefix, so
1081
- // a hard-coded `idea_...` could only ever match one project.
1077
+ // happen. Both skills drive tools that only the companion plugin
1078
+ // `idea-mcp-enhance` serves (rerun for standalone, create for the
1079
+ // manager), so a stock IDE must not see them at all. Each skill declares
1080
+ // its own dependency in its frontmatter (`requires:`), as a SUFFIX — tool
1081
+ // names carry a per-project prefix, so a hard-coded `idea_...` could only
1082
+ // ever match one project.
1082
1083
  //
1083
1084
  // The catalog is unknown at setup: the config is read from storage offline,
1084
1085
  // before anything has connected to the IDE. So this is a reconcile rather
@@ -1089,7 +1090,7 @@ export default {
1089
1090
  // Deliberately NOT wrapped in a catch: these are core features of the plugin,
1090
1091
  // so a rejected definition must fail loudly. Swallowing it would ship a
1091
1092
  // silently incomplete plugin — which is exactly how 0.0.6 lost
1092
- // `/open-in-idea` without anyone noticing until the command was gone.
1093
+ // `/open-in-ide` without anyone noticing until the command was gone.
1093
1094
  const syncSkillsOnce = async () => {
1094
1095
  if (typeof ctx.skill?.transform !== 'function') return;
1095
1096
 
@@ -1138,7 +1139,7 @@ export default {
1138
1139
  };
1139
1140
  // A project configured in an earlier run gets its skills as soon as the IDE
1140
1141
  // has answered; a project configured for the first time gets them when
1141
- // `/open-in-idea` runs (both paths go through `writeConfig` / this setup hook).
1142
+ // `/open-in-ide` runs (both paths go through `writeConfig` / this setup hook).
1142
1143
  await syncSkills();
1143
1144
 
1144
1145
  /**
@@ -1227,7 +1228,7 @@ export default {
1227
1228
  return { content: `已把根项目配置为 ${IDE_LABELS[ide]} 并接入。` };
1228
1229
  }
1229
1230
  if (!current) {
1230
- return { content: '当前项目还没有 IDE 配置。请先执行 /open-in-idea。' };
1231
+ return { content: '当前项目还没有 IDE 配置。请先执行 /open-in-ide。' };
1231
1232
  }
1232
1233
  const absPath = resolve(projectPath, rawPath);
1233
1234
  if (!existsSync(absPath)) {
@@ -1268,15 +1269,15 @@ export default {
1268
1269
  execute: async (args) => configureIdeProject(args),
1269
1270
  });
1270
1271
 
1271
- // `/open-in-idea` 接入 root 与其全部子项目;`/add-sub-module-in-ide <路径>` 登记
1272
+ // `/open-in-ide` 接入 root 与其全部子项目;`/add-sub-module-in-ide <路径>` 登记
1272
1273
  // 一个子项目(IDE 由模型问用户后写回);`/delete-in-ide` 删除整个配置。
1273
1274
  //
1274
- // 命名说明:命令名里的 `idea` 是历史包袱(工具前缀早已改成与产品无关的 `ide_`),
1275
- // 保留它是为了不改用户天天敲的名字;它的语义是"接入 IDE",不是"接入 IntelliJ"。
1275
+ // 三个名字都带 `-ide` 而不是 `-idea`:这里说的是"接入 IDE",与具体产品无关 —— root 可能是
1276
+ // IDEA、子项目可能是 PyCharm,和工具前缀(`ide_*` / `<目录名>_*`)是同一套口径。
1276
1277
  if (typeof ctx.command?.transform === 'function') {
1277
1278
  commandRegistration = await ctx.command.transform((editor) => {
1278
1279
  editor.add({
1279
- name: 'open-in-idea',
1280
+ name: 'open-in-ide',
1280
1281
  description: '接入当前项目:root 及其全部已登记子项目,各自用配置好的 IDE 打开',
1281
1282
  execute: async (input) => {
1282
1283
  const parsed = parseIdeArgument(input?.prompt?.text);
@@ -1350,7 +1351,7 @@ export default {
1350
1351
  execute: async (input) => {
1351
1352
  await writeConfig(null);
1352
1353
  // 注销全部 server。走串行化的 reconcile,所以在飞的重建(另一个
1353
- // `/open-in-idea`、请求期自愈)不会在配置已删之后再注册回来。
1354
+ // `/open-in-ide`、请求期自愈)不会在配置已删之后再注册回来。
1354
1355
  await reconcile({ probeIde: false });
1355
1356
  await sendFeedback(input, { status: 'deleted' });
1356
1357
  },
@@ -1420,8 +1421,8 @@ export default {
1420
1421
  // they are actually in this request.
1421
1422
  if (!serving) return;
1422
1423
  // 逐项目注入:每个项目那一节只写它自己的前缀,模型不需要自己换算。
1423
- // run-lifecycle 工具(来自配套插件 `idea-mcp-enhance`)也按每个项目分别判断,
1424
- // 所以一个项目装了 enhance、另一个没装时,两节的内容本来就不一样。
1424
+ // 配套插件 `idea-mcp-enhance` 的两组工具(run-lifecycle / deployment)也按每个项目分别判断,
1425
+ // 所以一个项目装了 enhance、另一个没装时,各节的内容本来就不一样。
1425
1426
  appendIdeGuidance(event?.system, currentProjects(), { tools });
1426
1427
  });
1427
1428
 
@@ -1434,7 +1435,7 @@ export default {
1434
1435
  // * `registrations.length === 0` — 本插件实例压根没有注册任何 IDE server,这
1435
1436
  // 正是 OpenCode 回收 Location 服务(LayerMap idleTimeToLive = 60 分钟)
1436
1437
  // 或进程重启后新实例的样子。setup 故意不连接,没有这一步会话就会永久
1437
- // 没有工具,只能靠用户手敲 `/open-in-idea`。
1438
+ // 没有工具,只能靠用户手敲 `/open-in-ide`。
1438
1439
  //
1439
1440
  // 只探测、不启动 IDE:走 `reconcile({ probeIde: true })`,而不是会 `open -a`
1440
1441
  // 的 `reviveIde()` —— 一条用户消息不该把 IDE 拉起来。
@@ -27,6 +27,13 @@
27
27
  // and deliberately kept OUT of the registered definition — the caller filters on
28
28
  // it instead (see `readSkills`), because a skill whose tools are absent must not
29
29
  // be registered at all.
30
+ //
31
+ // The other key is OpenCode's own, written in the portable frontmatter spelling
32
+ // OpenCode documents: a nested `metadata:` block holding
33
+ // `opencode/autoinvoke: false`. It is translated to the definition's
34
+ // `autoinvoke` — the name the registration API uses — which is what makes a
35
+ // skill user-invoked only: OpenCode keeps it registered and loadable on request,
36
+ // but leaves it out of the model's available list.
30
37
 
31
38
  import { readFileSync, readdirSync } from 'node:fs';
32
39
  import { fileURLToPath } from 'node:url';
@@ -87,11 +94,37 @@ function splitList(value) {
87
94
  .filter((entry) => entry !== '');
88
95
  }
89
96
 
97
+ /**
98
+ * Read `opencode/autoinvoke` out of the frontmatter, or `undefined` when absent.
99
+ *
100
+ * The spelling is OpenCode's portable one — a nested `metadata:` block holding
101
+ * `opencode/autoinvoke: false`. That shape is exactly what the flat reader above
102
+ * already produces: the indented line trims to `opencode/autoinvoke: false` and
103
+ * lands under that namespaced key, while the bare `metadata:` line comes out
104
+ * empty and is ignored. One nesting level is therefore supported on purpose, not
105
+ * by accident.
106
+ *
107
+ * The value must be `true` or `false`. Anything else throws: a typo that
108
+ * silently left the skill model-invocable is the failure this whole mechanism
109
+ * exists to prevent, so it must not pass quietly.
110
+ *
111
+ * @param {Record<string, string>} fields
112
+ * @param {string} source Used in error messages.
113
+ * @returns {boolean | undefined}
114
+ */
115
+ export function readAutoinvoke(fields, source) {
116
+ const raw = fields['opencode/autoinvoke'];
117
+ if (raw === undefined) return undefined;
118
+ if (raw === 'true') return true;
119
+ if (raw === 'false') return false;
120
+ throw new Error(`${source}: "opencode/autoinvoke" must be true or false, got "${raw}"`);
121
+ }
122
+
90
123
  /**
91
124
  * Load one skill directory into a `Skill.Info`-shaped definition.
92
125
  *
93
126
  * @param {string} id Directory name, used as the skill id.
94
- * @returns {{ id: string, name: string, description: string, path: string, content: string }}
127
+ * @returns {{ id: string, name: string, description: string, autoinvoke?: boolean, path: string, content: string }}
95
128
  */
96
129
  export function readSkill(id) {
97
130
  return readSkillPackage(id).skill;
@@ -104,8 +137,13 @@ export function readSkill(id) {
104
137
  * while `requires` is this package's own metadata and must NOT be registered —
105
138
  * it is what the caller filters on.
106
139
  *
140
+ * `autoinvoke` is OpenCode's, so it goes the other way: it ships in the
141
+ * frontmatter as `opencode/autoinvoke` and is translated onto the definition,
142
+ * where it picks user-invoked-only over model-invocable. It is only set when the
143
+ * frontmatter asks for it, so a skill that says nothing keeps the plain shape.
144
+ *
107
145
  * @param {string} id Directory name, used as the skill id.
108
- * @returns {{ skill: { id: string, name: string, description: string, path: string, content: string }, requires: string[] }}
146
+ * @returns {{ skill: { id: string, name: string, description: string, autoinvoke?: boolean, path: string, content: string }, requires: string[] }}
109
147
  */
110
148
  export function readSkillPackage(id) {
111
149
  const file = new URL(`${id}/${SKILL_FILE_NAME}`, SKILLS_DIR);
@@ -116,11 +154,13 @@ export function readSkillPackage(id) {
116
154
  if (!description) {
117
155
  throw new Error(`${source}: frontmatter needs a "description", otherwise the skill is never offered`);
118
156
  }
157
+ const autoinvoke = readAutoinvoke(fields, source);
119
158
  return {
120
159
  skill: {
121
160
  id,
122
161
  name: fields.name || id,
123
162
  description,
163
+ ...(autoinvoke === undefined ? {} : { autoinvoke }),
124
164
  path: fileURLToPath(file),
125
165
  content: text.replace(FRONTMATTER, '').replace(/^\s+/, '').trimEnd(),
126
166
  },
@@ -131,7 +171,7 @@ export function readSkillPackage(id) {
131
171
  /**
132
172
  * Every shipped skill, sorted by id, each with the tools it requires.
133
173
  *
134
- * @returns {Array<{ skill: { id: string, name: string, description: string, path: string, content: string }, requires: string[] }>}
174
+ * @returns {Array<{ skill: { id: string, name: string, description: string, autoinvoke?: boolean, path: string, content: string }, requires: string[] }>}
135
175
  */
136
176
  export function readSkills() {
137
177
  return readdirSync(SKILLS_DIR, { withFileTypes: true })