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.
- package/README.md +341 -170
- package/package.json +9 -1
- package/skills/idea-standalone/SKILL.md +39 -89
- package/skills/idea-standalone/assets/AGENTS.template.md +66 -0
- package/src/ide-guidance.js +141 -94
- package/src/ide-guidance.template.md +118 -0
- package/src/ide-launcher.js +87 -27
- package/src/ide-projects.js +262 -0
- package/src/mcp/enhance.js +71 -0
- package/src/mcp/idea-tool-defaults.js +170 -0
- package/src/mcp/idea.js +8 -74
- package/src/plugin.js +861 -343
- package/src/skill/loader.js +45 -8
- package/src/template.js +94 -0
- package/skills/idea-run-config/SKILL.md +0 -118
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Arguments this plugin OWNS on the IDE's own MCP tools.
|
|
2
|
+
//
|
|
3
|
+
// Three files, three different subjects — kept apart on purpose:
|
|
4
|
+
//
|
|
5
|
+
// * `idea.js` the IDE's bundled MCP server: endpoint, transport, probe
|
|
6
|
+
// * `idea-tool-defaults` (this file) that server's TOOL CATALOG, and the
|
|
7
|
+
// arguments on it that this plugin controls
|
|
8
|
+
// * `enhance.js` a companion plugin's tools, which may not be installed
|
|
9
|
+
//
|
|
10
|
+
// ## Why an argument can be owned at all
|
|
11
|
+
//
|
|
12
|
+
// `<prefix>build_project`(每个项目前缀各一份,例如 `ide_build_project`)是模型判断
|
|
13
|
+
// 刚写的代码能否编译的唯一手段,而它的结论依赖模型看不到的文件新鲜度:
|
|
14
|
+
// an INCREMENTAL build compares against the IDE's own view of the files and
|
|
15
|
+
// recompiles only what that view calls dirty. When the edit happened OUTSIDE the
|
|
16
|
+
// IDE, the view can still be clean and the build becomes a no-op that reports
|
|
17
|
+
// success — a false "it compiles", the worst possible answer for a model
|
|
18
|
+
// verifying its own edit.
|
|
19
|
+
//
|
|
20
|
+
// MEASURED (IntelliJ 2026.2.3, decompiled `AnalysisToolset` plus 6 live
|
|
21
|
+
// scenarios): `build_project` maps to
|
|
22
|
+
// `ProjectTaskManager.createAllModulesBuildTask(project, isIncrementalBuild = !rebuild)`,
|
|
23
|
+
// so `rebuild: true` is the switch that makes the answer independent of VFS
|
|
24
|
+
// freshness. All six scenarios (edit an existing file / add a new one, in any
|
|
25
|
+
// package, open in the editor or not) were detected with `rebuild: true`, and
|
|
26
|
+
// only intermittently without it.
|
|
27
|
+
//
|
|
28
|
+
// ## Two halves of the same decision
|
|
29
|
+
//
|
|
30
|
+
// An argument that is always overwritten must not also be advertised. Leaving it
|
|
31
|
+
// in the schema would let the model set it, reason about it, and be wrong, while
|
|
32
|
+
// the value it wrote is silently discarded. So [`FORCED_ARGUMENTS`] drives both
|
|
33
|
+
// halves from one table:
|
|
34
|
+
//
|
|
35
|
+
// * [hideForcedArguments] drops it from the published input schema
|
|
36
|
+
// * [applyIdeaToolDefaults] sets the value on every call
|
|
37
|
+
//
|
|
38
|
+
// ## On rewriting a tool's schema
|
|
39
|
+
//
|
|
40
|
+
// This is a departure from the earlier "never touch tool definitions" rule, and it
|
|
41
|
+
// is deliberately narrow: nothing here INVENTS or EXPLAINS. The IDE's own
|
|
42
|
+
// `build_project` description does not mention the argument at all — only its
|
|
43
|
+
// per-property description does — so removing the property leaves a schema that
|
|
44
|
+
// is still internally consistent and still entirely the IDE's. What a schema
|
|
45
|
+
// transform must never do is add advice; that belongs to the guidance text.
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Arguments this plugin sets on every call, and therefore must not advertise.
|
|
49
|
+
*
|
|
50
|
+
* 工具用**后缀**标识,不写完整名字:同一个 IDE 工具会以每个项目的前缀各出现一次
|
|
51
|
+
* (`ide_build_project` / `modelserver_build_project` / ...),写死 `idea_...` 会让
|
|
52
|
+
* 多项目场景里只有 root 被命中,子项目那几份原样漏过去 —— 那正是这个模块要避免的
|
|
53
|
+
* "schema 说一套、调用做另一套"。
|
|
54
|
+
*
|
|
55
|
+
* @type {ReadonlyArray<{ suffix: string, argument: string, value: unknown }>}
|
|
56
|
+
*/
|
|
57
|
+
export const FORCED_ARGUMENTS = [
|
|
58
|
+
{
|
|
59
|
+
suffix: 'build_project',
|
|
60
|
+
argument: 'rebuild',
|
|
61
|
+
value: true,
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/** 由前缀集合拼出这张表涉及的**全部**真实工具名。 */
|
|
66
|
+
export function forcedToolNames(prefixes) {
|
|
67
|
+
return prefixes.flatMap((prefix) => FORCED_ARGUMENTS.map((owned) => `${prefix}${owned.suffix}`));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Set every owned argument on one call.
|
|
72
|
+
*
|
|
73
|
+
* Mutates `input` in place and reports whether anything changed, so a caller can
|
|
74
|
+
* keep "the model asked for this" apart from "we forced it". Unknown tools and
|
|
75
|
+
* malformed drafts are left untouched — this function owns the tools it names and
|
|
76
|
+
* nothing else.
|
|
77
|
+
*
|
|
78
|
+
* Called from `execute.before`, which hands out a mutable draft. The runtime
|
|
79
|
+
* validates a tool call against the schema published for THAT request and only
|
|
80
|
+
* then runs the hook, so setting a property the schema no longer advertises is
|
|
81
|
+
* safe.
|
|
82
|
+
*
|
|
83
|
+
* @param {unknown} tool Effective tool name for this call (`event.tool`).
|
|
84
|
+
* @param {unknown} input Mutable argument draft for this call (`event.input`).
|
|
85
|
+
* @param {readonly string[]} prefixes 本会话所有项目的工具前缀(`ide_` 等)。
|
|
86
|
+
* @returns {boolean} Whether `input` was modified.
|
|
87
|
+
*/
|
|
88
|
+
export function applyIdeToolDefaults(tool, input, prefixes) {
|
|
89
|
+
if (typeof tool !== 'string') return false;
|
|
90
|
+
if (input === null || typeof input !== 'object') return false;
|
|
91
|
+
|
|
92
|
+
let changed = false;
|
|
93
|
+
for (const owned of FORCED_ARGUMENTS) {
|
|
94
|
+
const matches = prefixes.some((prefix) => tool === `${prefix}${owned.suffix}`);
|
|
95
|
+
if (!matches) continue;
|
|
96
|
+
if (input[owned.argument] === owned.value) continue;
|
|
97
|
+
input[owned.argument] = owned.value;
|
|
98
|
+
changed = true;
|
|
99
|
+
}
|
|
100
|
+
return changed;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Drop one property from a JSON schema, returning a new object.
|
|
105
|
+
*
|
|
106
|
+
* Returns the input unchanged (by reference) when there is nothing to remove, so
|
|
107
|
+
* callers can skip an otherwise pointless edit. Everything else — including
|
|
108
|
+
* `additionalProperties` — is copied through untouched: this function only takes
|
|
109
|
+
* something away, and must never become a place where schema semantics change.
|
|
110
|
+
*
|
|
111
|
+
* @param {unknown} schema
|
|
112
|
+
* @param {string} name
|
|
113
|
+
* @returns {unknown} The new schema, or `schema` itself when unchanged.
|
|
114
|
+
*/
|
|
115
|
+
export function removeSchemaProperty(schema, name) {
|
|
116
|
+
if (schema === null || typeof schema !== 'object') return schema;
|
|
117
|
+
const properties = schema.properties;
|
|
118
|
+
if (properties === null || typeof properties !== 'object') return schema;
|
|
119
|
+
if (!(name in properties)) return schema;
|
|
120
|
+
|
|
121
|
+
const nextProperties = { ...properties };
|
|
122
|
+
delete nextProperties[name];
|
|
123
|
+
|
|
124
|
+
const next = { ...schema, properties: nextProperties };
|
|
125
|
+
if (Array.isArray(schema.required)) {
|
|
126
|
+
const required = schema.required.filter((entry) => entry !== name);
|
|
127
|
+
// Drop an emptied `required` rather than leaving `[]` behind: the two mean
|
|
128
|
+
// the same thing on the wire, but a stale empty array reads like intent.
|
|
129
|
+
if (required.length > 0) next.required = required;
|
|
130
|
+
else delete next.required;
|
|
131
|
+
}
|
|
132
|
+
return next;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Remove every owned argument from the tools this plugin controls.
|
|
137
|
+
*
|
|
138
|
+
* Takes the `ctx.tool.transform` editor (`list` / `get` / `update`). Written
|
|
139
|
+
* against that surface rather than against OpenCode's types so it stays a pure
|
|
140
|
+
* function of what it is given — which is also what makes it testable without a
|
|
141
|
+
* runtime.
|
|
142
|
+
*
|
|
143
|
+
* 每个项目前缀各处理一遍:同一个 IDE 工具会在工具表里以多份出现,漏掉任何一份都会
|
|
144
|
+
* 让那一份的 schema 继续广告这个旋钮。
|
|
145
|
+
*
|
|
146
|
+
* Safe to run when the IDE is absent: `get` returns undefined for a tool that is
|
|
147
|
+
* not registered, and OpenCode replays the transform after every MCP catalog
|
|
148
|
+
* refresh, so the tools are adjusted once they appear.
|
|
149
|
+
*
|
|
150
|
+
* @param {{
|
|
151
|
+
* get: (id: string) => { input?: unknown } | undefined,
|
|
152
|
+
* update: (id: string, update: (tool: { input?: unknown }) => void) => void,
|
|
153
|
+
* }} editor
|
|
154
|
+
* @param {readonly string[]} prefixes
|
|
155
|
+
*/
|
|
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);
|
|
161
|
+
if (!tool) continue;
|
|
162
|
+
const next = removeSchemaProperty(tool.input, owned.argument);
|
|
163
|
+
if (next === tool.input) continue;
|
|
164
|
+
// Assign the whole schema: the editor documents that schemas are replaced,
|
|
165
|
+
// not mutated in place.
|
|
166
|
+
editor.update(name, (draft) => {
|
|
167
|
+
draft.input = next;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
package/src/mcp/idea.js
CHANGED
|
@@ -37,29 +37,23 @@ export function ideProjectPath(projectPath) {
|
|
|
37
37
|
return projectPath.replace(/\\/g, '/');
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/** OpenCode MCP server name registered for the IDE. */
|
|
41
|
-
export const IDEA_SERVER_NAME = 'idea';
|
|
42
|
-
|
|
43
40
|
/** Streamable-HTTP endpoint exposed by the IDE MCP server (IntelliJ 2026.2+). */
|
|
44
41
|
export const MCP_STREAM_PATH = '/stream';
|
|
45
42
|
|
|
46
|
-
/** Port the IDE MCP server listens on when not configured explicitly. */
|
|
47
|
-
export const DEFAULT_PORTS = [64342];
|
|
48
|
-
|
|
49
43
|
const PROTOCOL_VERSION = '2025-06-18';
|
|
50
|
-
// A probe is only a local availability check. Keep it short: a disabled IDE
|
|
51
|
-
// MCP endpoint must not make the user wait for the launch timeout before the
|
|
52
|
-
// actionable notification is sent.
|
|
53
|
-
const PROBE_TIMEOUT_MS = 1000;
|
|
54
44
|
const CALL_TIMEOUT_MS = 15000;
|
|
55
45
|
|
|
56
46
|
/**
|
|
57
47
|
* Build the OpenCode MCP server config for a given IDE port + project.
|
|
58
48
|
*
|
|
59
49
|
* `codemode: false` exposes the IDE tools directly on the provider tool list
|
|
60
|
-
* (as
|
|
61
|
-
* passes each tool's full schema to the model, so parameter names are
|
|
62
|
-
* without exploration. The rest of an MCP server's options are unchanged.
|
|
50
|
+
* (as `<server>_<tool>`) instead of routing them through Code Mode. Direct
|
|
51
|
+
* exposure passes each tool's full schema to the model, so parameter names are
|
|
52
|
+
* known without exploration. The rest of an MCP server's options are unchanged.
|
|
53
|
+
*
|
|
54
|
+
* The server NAME is not decided here: it is the project's tool prefix, chosen
|
|
55
|
+
* by the caller (root -> `ide`, a subproject -> its directory name). See
|
|
56
|
+
* `ide-projects.js` for why the prefix encodes the LEVEL, not the IDE product.
|
|
63
57
|
*
|
|
64
58
|
* @param {number} port
|
|
65
59
|
* @param {string} projectPath
|
|
@@ -95,66 +89,6 @@ function withExecutionTimeout(config, options) {
|
|
|
95
89
|
}
|
|
96
90
|
|
|
97
91
|
|
|
98
|
-
/**
|
|
99
|
-
* Is an IDE MCP server listening on `port`? One Streamable-HTTP `initialize`
|
|
100
|
-
* POST decides it: any 2xx JSON reply means the server is up.
|
|
101
|
-
*
|
|
102
|
-
* @param {number} port
|
|
103
|
-
* @param {{ fetchImpl?: typeof fetch, timeoutMs?: number }} [options]
|
|
104
|
-
* @returns {Promise<boolean>}
|
|
105
|
-
*/
|
|
106
|
-
export async function probePort(port, options = {}) {
|
|
107
|
-
const fetchImpl = options.fetchImpl ?? fetch;
|
|
108
|
-
const controller = new AbortController();
|
|
109
|
-
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? PROBE_TIMEOUT_MS);
|
|
110
|
-
try {
|
|
111
|
-
const response = await fetchImpl(`http://127.0.0.1:${port}${MCP_STREAM_PATH}`, {
|
|
112
|
-
method: 'POST',
|
|
113
|
-
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
|
|
114
|
-
// Do not follow redirects. IntelliJ can keep its normal HTTP port open
|
|
115
|
-
// while MCP is disabled and redirect `/stream` to an HTML 404 page;
|
|
116
|
-
// following that redirect only adds latency and still means unavailable.
|
|
117
|
-
redirect: 'manual',
|
|
118
|
-
body: JSON.stringify({
|
|
119
|
-
jsonrpc: '2.0',
|
|
120
|
-
id: 1,
|
|
121
|
-
method: 'initialize',
|
|
122
|
-
params: {
|
|
123
|
-
protocolVersion: PROTOCOL_VERSION,
|
|
124
|
-
capabilities: {},
|
|
125
|
-
clientInfo: { name: 'opencode-idea', version: '0.0.16' },
|
|
126
|
-
},
|
|
127
|
-
}),
|
|
128
|
-
signal: controller.signal,
|
|
129
|
-
});
|
|
130
|
-
return response.ok;
|
|
131
|
-
} catch {
|
|
132
|
-
return false;
|
|
133
|
-
} finally {
|
|
134
|
-
clearTimeout(timer);
|
|
135
|
-
controller.abort();
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Find the first port whose IDE MCP server responds. Returns the port, or
|
|
141
|
-
* `undefined` when none is available.
|
|
142
|
-
*
|
|
143
|
-
* @param {number[]} ports
|
|
144
|
-
* @param {{ fetchImpl?: typeof fetch, timeoutMs?: number }} [options]
|
|
145
|
-
* @returns {Promise<number | undefined>}
|
|
146
|
-
*/
|
|
147
|
-
export async function findIdePort(ports, options = {}) {
|
|
148
|
-
// Probe all candidate ports concurrently. These are fallback locations for
|
|
149
|
-
// one IDE MCP endpoint, not several active MCP servers. Sequential probes
|
|
150
|
-
// multiply the timeout by the number of candidates and made a disabled MCP
|
|
151
|
-
// look like a hang.
|
|
152
|
-
const results = await Promise.all(
|
|
153
|
-
ports.map(async (port) => [port, await probePort(port, options)]),
|
|
154
|
-
);
|
|
155
|
-
return results.find(([, available]) => available)?.[0];
|
|
156
|
-
}
|
|
157
|
-
|
|
158
92
|
/**
|
|
159
93
|
* Call one MCP tool over the Streamable-HTTP endpoint. Returns the raw tool
|
|
160
94
|
* result (with `isError`), or `undefined` on transport/connect failure.
|
|
@@ -193,7 +127,7 @@ export async function callTool(port, projectPath, name, args = {}, options = {})
|
|
|
193
127
|
params: {
|
|
194
128
|
protocolVersion: PROTOCOL_VERSION,
|
|
195
129
|
capabilities: {},
|
|
196
|
-
clientInfo: { name: 'opencode-idea', version: '0.0.
|
|
130
|
+
clientInfo: { name: 'opencode-idea', version: '0.0.18' },
|
|
197
131
|
},
|
|
198
132
|
});
|
|
199
133
|
// The IDE's Streamable-HTTP endpoint binds follow-up requests to the session
|