opencode-idea 0.0.8 → 0.0.10
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 +59 -77
- package/package.json +6 -7
- package/skills/idea-run-config/SKILL.md +118 -0
- package/src/ide-guidance.js +58 -23
- package/src/{ide-env.js → mcp/ide-env.js} +1 -1
- package/src/{idea-mcp.js → mcp/idea.js} +2 -2
- package/src/plugin.js +271 -54
- package/src/project.js +0 -15
- package/src/skill/loader.js +105 -0
- package/CHANGELOG.md +0 -80
- package/src/ide-run-config-skill.js +0 -61
- package/src/ide-run-config-skill.md +0 -92
package/src/plugin.js
CHANGED
|
@@ -1,22 +1,31 @@
|
|
|
1
1
|
// opencode-idea — OpenCode plugin entry.
|
|
2
2
|
//
|
|
3
|
-
// For a
|
|
3
|
+
// For a project the user opted into IDEA this plugin:
|
|
4
4
|
// 1. injects the IDE integrated-terminal environment (JAVA_HOME / GOROOT /
|
|
5
5
|
// Node / Maven / ...) into every OpenCode shell;
|
|
6
6
|
// 2. registers the IDE MCP server with OpenCode (scoped to the project via the
|
|
7
7
|
// IJ_MCP_SERVER_PROJECT_PATH header), injects direct-call guidance for
|
|
8
8
|
// IDEA MCP tools, and updates only IDEA MCP tool descriptions;
|
|
9
9
|
// 3. registers the `/open-in-idea` command, which opens the current project
|
|
10
|
-
// in IntelliJ IDEA (reusing a running instance)
|
|
11
|
-
// capabilities immediately;
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
10
|
+
// in IntelliJ IDEA (reusing a running instance), loads the IDE
|
|
11
|
+
// capabilities immediately, and marks the project as an IDEA project;
|
|
12
|
+
// `/close-in-idea` clears that mark;
|
|
13
|
+
// 4. registers the `idea-run-config` skill (marked projects only), which
|
|
14
|
+
// teaches the model how to manage `.run/*.run.xml` run configurations — a
|
|
15
|
+
// capability the IDE MCP server does not expose.
|
|
15
16
|
//
|
|
16
|
-
//
|
|
17
|
-
// NOT probe or connect the IDE (only the IDE-terminal env is read
|
|
18
|
-
// is requested). `/open-in-idea` probes, opens the IDE, and loads
|
|
19
|
-
// guidance on demand.
|
|
17
|
+
// A project that was never marked is left untouched. Everything is manual:
|
|
18
|
+
// setup does NOT probe or connect the IDE (only the IDE-terminal env is read
|
|
19
|
+
// when a probe is requested). `/open-in-idea` probes, opens the IDE, and loads
|
|
20
|
+
// the IDE MCP + guidance on demand.
|
|
21
|
+
//
|
|
22
|
+
// Once the user runs `/open-in-idea` in a project, that project is marked as an
|
|
23
|
+
// IDEA project. The mark is persisted through `ctx.storage` (durable across
|
|
24
|
+
// restarts) and cleared by `/close-in-idea`. While a project is marked, every
|
|
25
|
+
// user message assumes the IDE is still up: the plugin asks OpenCode for the
|
|
26
|
+
// `idea` MCP server's status and, when it is not connected, re-runs the open
|
|
27
|
+
// flow before the request is built. Detection is by MCP status, never by tool
|
|
28
|
+
// errors, so a normal business error can never look like a dropped connection.
|
|
20
29
|
//
|
|
21
30
|
// `Plugin.define` from `@opencode/plugin` is an identity function, so a plain
|
|
22
31
|
// `{ id, setup }` object is the whole plugin contract. Exporting it directly
|
|
@@ -27,9 +36,9 @@ import {
|
|
|
27
36
|
IDEA_SERVER_NAME,
|
|
28
37
|
findIdePort,
|
|
29
38
|
serverConfig,
|
|
30
|
-
} from './idea
|
|
31
|
-
import { readIdeTerminalEnv } from './ide-env.js';
|
|
32
|
-
import { currentProjectPath
|
|
39
|
+
} from './mcp/idea.js';
|
|
40
|
+
import { readIdeTerminalEnv } from './mcp/ide-env.js';
|
|
41
|
+
import { currentProjectPath } from './project.js';
|
|
33
42
|
import { applyEnv, mergeEnv } from './env.js';
|
|
34
43
|
import {
|
|
35
44
|
createLaunchGuard,
|
|
@@ -40,7 +49,7 @@ import {
|
|
|
40
49
|
appendIdeGuidance,
|
|
41
50
|
appendIdeRecoveryGuidance,
|
|
42
51
|
} from './ide-guidance.js';
|
|
43
|
-
import {
|
|
52
|
+
import { readSkills } from './skill/loader.js';
|
|
44
53
|
|
|
45
54
|
const DEFAULT_LAUNCH_COOLDOWN_MS = 120000;
|
|
46
55
|
const DEFAULT_LAUNCH_MAX_ATTEMPTS = 5;
|
|
@@ -86,7 +95,7 @@ export const HIDDEN_IDEA_TOOLS = [
|
|
|
86
95
|
// Router
|
|
87
96
|
'idea_execute_tool',
|
|
88
97
|
// Terminal: the plugin reads the IDE env through its own direct MCP call
|
|
89
|
-
// (`src/ide-env.js`), so the model never needs this tool. Measured value for
|
|
98
|
+
// (`src/mcp/ide-env.js`), so the model never needs this tool. Measured value for
|
|
90
99
|
// the agent is nil: ~60s hard cutoff with no way to read the terminal buffer,
|
|
91
100
|
// and the native shell already keeps the output tail plus a full-output file.
|
|
92
101
|
'idea_execute_terminal_command',
|
|
@@ -149,10 +158,11 @@ function toolIds(tools) {
|
|
|
149
158
|
|
|
150
159
|
/**
|
|
151
160
|
* @typedef {{
|
|
152
|
-
* status: 'connected' | 'tools-loading' | 'mcp-unavailable' | 'launch-failed' | 'disabled',
|
|
161
|
+
* status: 'connected' | 'tools-loading' | 'mcp-unavailable' | 'launch-failed' | 'disabled' | 'closed',
|
|
153
162
|
* port?: number,
|
|
154
163
|
* opened?: boolean,
|
|
155
|
-
* unavailableNoticeSent?: boolean
|
|
164
|
+
* unavailableNoticeSent?: boolean,
|
|
165
|
+
* autoRecovered?: boolean
|
|
156
166
|
* }} IdeCommandResult
|
|
157
167
|
*/
|
|
158
168
|
|
|
@@ -172,6 +182,9 @@ const asPositiveNumber = (value, fallback) =>
|
|
|
172
182
|
/** User-facing status text for a `/open-in-idea` result. */
|
|
173
183
|
/** @param {IdeCommandResult} result */
|
|
174
184
|
export function ideFeedback(result) {
|
|
185
|
+
if (result?.autoRecovered) {
|
|
186
|
+
return '检测到 IDE MCP 连接已断开,已自动重新连接并恢复 IDEA 原生工具。';
|
|
187
|
+
}
|
|
175
188
|
switch (result?.status) {
|
|
176
189
|
case 'connected':
|
|
177
190
|
return 'IDE MCP 已连接;IDEA 原生工具已注册并可用于后续请求。';
|
|
@@ -185,6 +198,8 @@ export function ideFeedback(result) {
|
|
|
185
198
|
return '无法拉起 IntelliJ IDEA(未找到应用或启动失败)。请确认已安装 IDEA,或手动打开后重试 /open-in-idea。';
|
|
186
199
|
case 'disabled':
|
|
187
200
|
return '插件已通过 openInIde: false 关闭 IDE 启动。请手动打开 IntelliJ IDEA,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后重试 /open-in-idea。';
|
|
201
|
+
case 'closed':
|
|
202
|
+
return '已关闭当前项目的 IDEA 接入标记;IDE MCP 断开后将不再自动重连。如需重新接入,请再次执行 /open-in-idea。';
|
|
188
203
|
default:
|
|
189
204
|
return '已处理 /open-in-idea。';
|
|
190
205
|
}
|
|
@@ -232,8 +247,9 @@ export default {
|
|
|
232
247
|
* transform: (cb: (editor: { add: (definition: {
|
|
233
248
|
* id: string,
|
|
234
249
|
* name: string,
|
|
235
|
-
* description
|
|
236
|
-
*
|
|
250
|
+
* description?: string,
|
|
251
|
+
* autoinvoke?: boolean,
|
|
252
|
+
* path: string,
|
|
237
253
|
* content: string,
|
|
238
254
|
* }) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
239
255
|
* },
|
|
@@ -244,6 +260,8 @@ export default {
|
|
|
244
260
|
* agent?: string,
|
|
245
261
|
* system?: unknown[],
|
|
246
262
|
* tools?: Record<string, unknown>,
|
|
263
|
+
* prompt?: { text?: string },
|
|
264
|
+
* metadata?: Record<string, unknown>,
|
|
247
265
|
* }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
248
266
|
* },
|
|
249
267
|
* tool?: {
|
|
@@ -253,18 +271,19 @@ export default {
|
|
|
253
271
|
* mcp: {
|
|
254
272
|
* transform: (cb: (editor: { set: (name: string, config: unknown) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
255
273
|
* reload?: () => Promise<void>,
|
|
274
|
+
* list?: () => Promise<{ data?: ReadonlyArray<{ name?: string, status?: { status?: string } }> }>,
|
|
256
275
|
* },
|
|
257
276
|
* shell: { hook: (name: string, cb: (input: { env: Record<string, string | undefined> }) => void) => Promise<{ dispose: () => Promise<void> | void }> },
|
|
277
|
+
* storage?: {
|
|
278
|
+
* get: (key: string) => Promise<unknown>,
|
|
279
|
+
* set: (key: string, value: unknown) => Promise<void>,
|
|
280
|
+
* remove: (key: string) => Promise<void>,
|
|
281
|
+
* },
|
|
258
282
|
* }} ctx
|
|
259
283
|
*/
|
|
260
284
|
async setup(ctx) {
|
|
261
285
|
const projectPath = currentProjectPath(ctx.location);
|
|
262
286
|
if (!projectPath) return;
|
|
263
|
-
// Where IDEA tools can exist at all. Computed once: the recovery guidance is
|
|
264
|
-
// injected per model request, so it must not cost a filesystem check each
|
|
265
|
-
// time, and unrelated projects must not receive IDEA prompts they can never
|
|
266
|
-
// use.
|
|
267
|
-
const isIdeaProject = hasIdeaDirectory(projectPath);
|
|
268
287
|
|
|
269
288
|
const options = ctx.options || {};
|
|
270
289
|
const ports = asArray(options.ports, DEFAULT_PORTS);
|
|
@@ -291,11 +310,68 @@ export default {
|
|
|
291
310
|
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
292
311
|
let sessionRegistration;
|
|
293
312
|
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
313
|
+
let promptRegistration;
|
|
314
|
+
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
294
315
|
let commandRegistration;
|
|
295
316
|
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
296
317
|
let skillRegistration;
|
|
318
|
+
let skillsRegistered = false;
|
|
297
319
|
let disposed = false;
|
|
298
320
|
|
|
321
|
+
// Project-level mark: has the user opted this project into IDEA via
|
|
322
|
+
// `/open-in-idea`? One plugin instance serves one project (OpenCode
|
|
323
|
+
// activates plugins per Location), so a single boolean is the whole cache.
|
|
324
|
+
// The durable copy lives in `ctx.storage` under `project/<projectPath>`.
|
|
325
|
+
// The commands are the only writers: they persist first, then invalidate
|
|
326
|
+
// this cache so the next read comes back from storage.
|
|
327
|
+
const storage = ctx.storage;
|
|
328
|
+
const markKey = `project/${projectPath}`;
|
|
329
|
+
/** @type {boolean | undefined} undefined = a storage read is due */
|
|
330
|
+
let markCache;
|
|
331
|
+
// One auto-recovery run per project at a time; overlapping prompts share it.
|
|
332
|
+
/** @type {Promise<void> | undefined} */
|
|
333
|
+
let autoRecovering;
|
|
334
|
+
|
|
335
|
+
/** Read the durable mark into the cache; at most one storage read per invalidation. */
|
|
336
|
+
const readMark = async () => {
|
|
337
|
+
if (markCache !== undefined) return markCache;
|
|
338
|
+
if (!storage || typeof storage.get !== 'function') {
|
|
339
|
+
markCache = false;
|
|
340
|
+
return markCache;
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
markCache = (await storage.get(markKey)) === true;
|
|
344
|
+
} catch {
|
|
345
|
+
// storage is best effort; fall back to unmarked
|
|
346
|
+
markCache = false;
|
|
347
|
+
}
|
|
348
|
+
return markCache;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Persist the mark, then invalidate the cache so the next reader re-reads
|
|
353
|
+
* storage. Best effort: an in-memory invalidation still applies if the write
|
|
354
|
+
* fails.
|
|
355
|
+
* @param {boolean} marked
|
|
356
|
+
*/
|
|
357
|
+
const setMark = async (marked) => {
|
|
358
|
+
try {
|
|
359
|
+
if (marked) await storage?.set?.(markKey, true);
|
|
360
|
+
else await storage?.remove?.(markKey);
|
|
361
|
+
} catch {
|
|
362
|
+
// best effort
|
|
363
|
+
}
|
|
364
|
+
markCache = undefined;
|
|
365
|
+
// The skill lifecycle follows the mark: marking the project registers the
|
|
366
|
+
// IDEA skills, clearing the mark removes them again. Same semantics as the
|
|
367
|
+
// reconnect behavior, so the two can never disagree.
|
|
368
|
+
if (marked) await ensureSkills();
|
|
369
|
+
else await removeSkills();
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// Resolve the mark once, so the request hooks can read it synchronously.
|
|
373
|
+
await readMark();
|
|
374
|
+
|
|
299
375
|
const deactivateIde = async () => {
|
|
300
376
|
const current = registrations.slice();
|
|
301
377
|
registrations.length = 0;
|
|
@@ -364,7 +440,7 @@ export default {
|
|
|
364
440
|
// Manual: only `/open-in-idea` probes the IDE. Setup calls this with
|
|
365
441
|
// `probeIde: false` so it never connects until the command asks.
|
|
366
442
|
const port = probeIde ? await findIdePort(ports, { timeoutMs: mcpProbeTimeoutMs }) : undefined;
|
|
367
|
-
const isJetBrains =
|
|
443
|
+
const isJetBrains = markCache === true || port !== undefined;
|
|
368
444
|
if (!isJetBrains) {
|
|
369
445
|
currentEnv = { env: {}, prependPath: [] };
|
|
370
446
|
await deactivateIde();
|
|
@@ -484,6 +560,30 @@ export default {
|
|
|
484
560
|
return run;
|
|
485
561
|
};
|
|
486
562
|
|
|
563
|
+
/**
|
|
564
|
+
* The whole `/open-in-idea` flow, shared by the command and the auto-recovery
|
|
565
|
+
* path: ensure an IDE MCP endpoint, then register it and load capabilities.
|
|
566
|
+
*
|
|
567
|
+
* @param {{ onUnavailable?: (result: IdeCommandResult) => Promise<boolean> }} [options]
|
|
568
|
+
* @returns {Promise<IdeCommandResult>}
|
|
569
|
+
*/
|
|
570
|
+
const runOpenInIdea = async ({ onUnavailable } = {}) => {
|
|
571
|
+
const result = await ensureIde({ onUnavailable });
|
|
572
|
+
const { port } = result;
|
|
573
|
+
let toolsReady = false;
|
|
574
|
+
try {
|
|
575
|
+
// Do not repeat the expensive probe when the preflight already found no
|
|
576
|
+
// MCP endpoint. The command will retry after the user enables MCP +
|
|
577
|
+
// Brave Mode.
|
|
578
|
+
toolsReady = (await reconcile({ probeIde: port !== undefined })) === true;
|
|
579
|
+
} catch {
|
|
580
|
+
// capability load is best effort; feedback still matters
|
|
581
|
+
}
|
|
582
|
+
return result.status === 'connected' && !toolsReady
|
|
583
|
+
? { ...result, status: 'tools-loading' }
|
|
584
|
+
: result;
|
|
585
|
+
};
|
|
586
|
+
|
|
487
587
|
// Setup never probes or connects the IDE — everything is on `/open-in-idea`.
|
|
488
588
|
await reconcile({ probeIde: false });
|
|
489
589
|
|
|
@@ -504,66 +604,153 @@ export default {
|
|
|
504
604
|
}
|
|
505
605
|
};
|
|
506
606
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
607
|
+
/**
|
|
608
|
+
* Is the plugin's `idea` MCP server connected right now? OpenCode tracks
|
|
609
|
+
* every server's status in memory, and this is the authoritative "the IDE
|
|
610
|
+
* MCP is down" signal: it reflects the transport, not the result of any
|
|
611
|
+
* single tool call, so a business error can never look like a disconnect.
|
|
612
|
+
* `pending` means "still connecting" and is treated as healthy, so a server
|
|
613
|
+
* that is merely starting up is left alone.
|
|
614
|
+
*
|
|
615
|
+
* @returns {Promise<boolean>}
|
|
616
|
+
*/
|
|
617
|
+
const isIdeaConnected = async () => {
|
|
618
|
+
if (typeof ctx.mcp?.list !== 'function') return false;
|
|
619
|
+
try {
|
|
620
|
+
const result = await ctx.mcp.list();
|
|
621
|
+
const servers = Array.isArray(result) ? result : (result?.data ?? []);
|
|
622
|
+
return servers.some((server) => {
|
|
623
|
+
const status = server?.status?.status;
|
|
624
|
+
return server?.name === IDEA_SERVER_NAME && (status === 'connected' || status === 'pending');
|
|
625
|
+
});
|
|
626
|
+
} catch {
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Marked-project recovery: while the project is marked (the user ran
|
|
633
|
+
* `/open-in-idea`), treat the IDE as expected and rebuild the connection
|
|
634
|
+
* whenever its MCP server is not connected. Runs before the prompt's request
|
|
635
|
+
* is built, so a successful recovery makes its `idea_*` tools available to
|
|
636
|
+
* that very message.
|
|
637
|
+
*
|
|
638
|
+
* Only a confirmed reconnect is announced. A failed recovery stays silent:
|
|
639
|
+
* the per-request recovery guidance already tells the model what to say, and
|
|
640
|
+
* a notice per failed message would be noise (the launch guard, shared with
|
|
641
|
+
* the command, keeps it from re-spawning on every message).
|
|
642
|
+
*
|
|
643
|
+
* @param {string} sessionID the notice target; the mark itself is per-project
|
|
644
|
+
*/
|
|
645
|
+
const autoReconnect = (sessionID) => {
|
|
646
|
+
if (autoRecovering) return autoRecovering;
|
|
647
|
+
const run = (async () => {
|
|
648
|
+
try {
|
|
649
|
+
if (disposed) return;
|
|
650
|
+
if (await isIdeaConnected()) return;
|
|
651
|
+
const result = await runOpenInIdea();
|
|
652
|
+
// A found endpoint is what counts as recovered, even if its tool
|
|
653
|
+
// catalog is still registering (`tools-loading`).
|
|
654
|
+
if (result.port === undefined) return;
|
|
655
|
+
// Queue the notice so it is delivered after the user's own message,
|
|
656
|
+
// never mixed into the same model turn as it.
|
|
657
|
+
await sendFeedback(
|
|
658
|
+
{ sessionID, prompt: { text: '' }, delivery: 'queue' },
|
|
659
|
+
{ status: 'connected', autoRecovered: true },
|
|
660
|
+
);
|
|
661
|
+
} catch {
|
|
662
|
+
// auto-recovery is best effort
|
|
663
|
+
}
|
|
664
|
+
})();
|
|
665
|
+
autoRecovering = run;
|
|
666
|
+
void run.finally(() => {
|
|
667
|
+
if (autoRecovering === run) autoRecovering = undefined;
|
|
668
|
+
});
|
|
669
|
+
return run;
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
// The shipped skills are static knowledge (`skills/<id>/SKILL.md`), e.g. the
|
|
673
|
+
// run-configuration workflow: where `.run/*.run.xml` live, how to bootstrap a
|
|
674
|
+
// schema from a real example, and how to verify a write. Registered only for
|
|
675
|
+
// a project that has been marked as an IDEA project, so unrelated projects
|
|
676
|
+
// never see an IDEA-specific skill.
|
|
510
677
|
//
|
|
511
|
-
// Deliberately NOT wrapped in a catch:
|
|
678
|
+
// Deliberately NOT wrapped in a catch: these are core features of the plugin,
|
|
512
679
|
// so a rejected definition must fail loudly. Swallowing it would ship a
|
|
513
680
|
// silently incomplete plugin — which is exactly how 0.0.6 lost
|
|
514
681
|
// `/open-in-idea` without anyone noticing until the command was gone.
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
682
|
+
const ensureSkills = async () => {
|
|
683
|
+
if (skillsRegistered || typeof ctx.skill?.transform !== 'function') return;
|
|
684
|
+
skillsRegistered = true;
|
|
685
|
+
// Read the documents before the transform: callbacks must stay synchronous.
|
|
686
|
+
const skills = readSkills();
|
|
518
687
|
skillRegistration = await ctx.skill.transform((editor) => {
|
|
519
|
-
editor.add(
|
|
688
|
+
for (const skill of skills) editor.add(skill);
|
|
520
689
|
});
|
|
521
|
-
}
|
|
690
|
+
};
|
|
691
|
+
/** Remove the IDEA skills again when the project is unmarked. */
|
|
692
|
+
const removeSkills = async () => {
|
|
693
|
+
if (!skillsRegistered || !skillRegistration) return;
|
|
694
|
+
skillsRegistered = false;
|
|
695
|
+
const registration = skillRegistration;
|
|
696
|
+
skillRegistration = undefined;
|
|
697
|
+
try {
|
|
698
|
+
await registration.dispose();
|
|
699
|
+
} catch {
|
|
700
|
+
// best effort
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
// A project marked in an earlier run gets its skills at setup; a project
|
|
704
|
+
// marked for the first time gets them when `/open-in-idea` runs (both paths
|
|
705
|
+
// go through `setMark` / this setup hook).
|
|
706
|
+
if (markCache === true) await ensureSkills();
|
|
522
707
|
|
|
523
708
|
// `/open-in-idea`: manual, re-entrant trigger that also loads the IDE
|
|
524
|
-
// capabilities immediately
|
|
709
|
+
// capabilities immediately, and marks the project so later messages keep the
|
|
710
|
+
// IDE alive. `/close-in-idea` removes that mark. Both persist through
|
|
711
|
+
// `ctx.storage` and invalidate the in-memory cache.
|
|
525
712
|
if (typeof ctx.command?.transform === 'function') {
|
|
526
713
|
commandRegistration = await ctx.command.transform((editor) => {
|
|
527
714
|
editor.add({
|
|
528
715
|
name: 'open-in-idea',
|
|
529
716
|
description: '用 IntelliJ IDEA 打开当前项目,并即时加载 IDE MCP 与项目 SDK 环境',
|
|
530
717
|
execute: async (input) => {
|
|
718
|
+
// Mark the project before running: the mark means "this is an IDEA
|
|
719
|
+
// project", so it is persisted as soon as the user asks, whether or
|
|
720
|
+
// not the IDE happens to be reachable right now. `setMark` also
|
|
721
|
+
// registers the IDEA skills.
|
|
722
|
+
await setMark(true);
|
|
531
723
|
/** @type {boolean} */
|
|
532
724
|
let unavailableNoticeSent = false;
|
|
533
|
-
const result = await
|
|
725
|
+
const result = await runOpenInIdea({
|
|
534
726
|
onUnavailable: async (notice) => {
|
|
535
727
|
unavailableNoticeSent = await sendFeedback(input, notice);
|
|
536
728
|
return unavailableNoticeSent;
|
|
537
729
|
},
|
|
538
730
|
});
|
|
539
|
-
const { port } = result;
|
|
540
|
-
let toolsReady = false;
|
|
541
|
-
try {
|
|
542
|
-
// Do not repeat the expensive probe when the preflight already
|
|
543
|
-
// found no MCP endpoint. The command will retry after the user
|
|
544
|
-
// enables MCP + Brave Mode.
|
|
545
|
-
toolsReady = (await reconcile({ probeIde: port !== undefined })) === true;
|
|
546
|
-
} catch {
|
|
547
|
-
// capability load is best effort; feedback still matters
|
|
548
|
-
}
|
|
549
|
-
const finalResult = result.status === 'connected' && !toolsReady
|
|
550
|
-
? { ...result, status: 'tools-loading' }
|
|
551
|
-
: result;
|
|
552
731
|
// The unavailable notice is the final result for this command. If
|
|
553
732
|
// it was already delivered, do not show the same warning twice.
|
|
554
733
|
const unavailableAlreadySent = unavailableNoticeSent || result.unavailableNoticeSent;
|
|
555
|
-
if (!unavailableAlreadySent ||
|
|
556
|
-
await sendFeedback(input,
|
|
734
|
+
if (!unavailableAlreadySent || result.status !== 'mcp-unavailable') {
|
|
735
|
+
await sendFeedback(input, result);
|
|
557
736
|
}
|
|
558
737
|
},
|
|
559
738
|
});
|
|
739
|
+
editor.add({
|
|
740
|
+
name: 'close-in-idea',
|
|
741
|
+
description: '取消当前项目的 IDEA 接入标记,断开后不再自动重连',
|
|
742
|
+
execute: async (input) => {
|
|
743
|
+
await setMark(false);
|
|
744
|
+
await sendFeedback(input, { status: 'closed' });
|
|
745
|
+
},
|
|
746
|
+
});
|
|
560
747
|
});
|
|
561
748
|
}
|
|
562
749
|
|
|
563
750
|
// Hide the tools the model must not see, and point it at the rest. This hook
|
|
564
751
|
// runs for every agent-loop step (including tool-driven continuations), so
|
|
565
|
-
// it
|
|
566
|
-
//
|
|
752
|
+
// it stays a cheap, I/O-free function of the request: it checks the cached
|
|
753
|
+
// project mark and this request's own tool snapshot, and takes no action.
|
|
567
754
|
//
|
|
568
755
|
// `event.tools` is this request's tool snapshot, and OpenCode's own registry
|
|
569
756
|
// adds IDEA tools when the server connects and drops them when it stops
|
|
@@ -575,13 +762,36 @@ export default {
|
|
|
575
762
|
const tools = event?.tools;
|
|
576
763
|
const serving = tools !== undefined && Object.keys(tools).some((name) => name.startsWith('idea_'));
|
|
577
764
|
if (serving) removeIdeaToolDefinitions(tools, HIDDEN_IDEA_TOOLS);
|
|
578
|
-
if (
|
|
765
|
+
if (markCache === true || serving) appendIdeRecoveryGuidance(event?.system);
|
|
579
766
|
if (!injectGuidance) return;
|
|
580
767
|
// Guidance promises tools the model can call, so it only goes out when
|
|
581
768
|
// they are actually in this request.
|
|
582
769
|
if (!serving) return;
|
|
583
770
|
appendIdeGuidance(event?.system, projectPath);
|
|
584
771
|
});
|
|
772
|
+
|
|
773
|
+
// Project-level auto-recovery. The only self-initiated action in the
|
|
774
|
+
// plugin, and only for a marked project. It belongs to the `prompt` hook
|
|
775
|
+
// (not `context`) because the tool catalog for a request is already fixed
|
|
776
|
+
// by the time `context` runs: a `prompt`-time reconnect is what lets the
|
|
777
|
+
// message that triggered it use `idea_*` tools.
|
|
778
|
+
//
|
|
779
|
+
// The returned promise is awaited by OpenCode before the request is built,
|
|
780
|
+
// so a slow cold start delays that message — the accepted cost of treating
|
|
781
|
+
// a marked project's IDE as always up.
|
|
782
|
+
promptRegistration = await ctx.session.hook('prompt', async (event) => {
|
|
783
|
+
const sessionID = event?.sessionID;
|
|
784
|
+
if (typeof sessionID !== 'string') return;
|
|
785
|
+
// Never react to the plugin's own notices, or auto-recovery would feed
|
|
786
|
+
// on itself. Notices carry `metadata.displayText`; the prefix guards
|
|
787
|
+
// against an older notice shape.
|
|
788
|
+
const displayText = event?.metadata?.displayText;
|
|
789
|
+
if (typeof displayText === 'string') return;
|
|
790
|
+
const text = event?.prompt?.text;
|
|
791
|
+
if (typeof text === 'string' && text.startsWith('【插件通知】')) return;
|
|
792
|
+
if (!(await readMark())) return;
|
|
793
|
+
return autoReconnect(sessionID);
|
|
794
|
+
});
|
|
585
795
|
}
|
|
586
796
|
|
|
587
797
|
return async () => {
|
|
@@ -600,6 +810,13 @@ export default {
|
|
|
600
810
|
// best effort
|
|
601
811
|
}
|
|
602
812
|
}
|
|
813
|
+
if (promptRegistration) {
|
|
814
|
+
try {
|
|
815
|
+
await promptRegistration.dispose();
|
|
816
|
+
} catch {
|
|
817
|
+
// best effort
|
|
818
|
+
}
|
|
819
|
+
}
|
|
603
820
|
if (commandRegistration) {
|
|
604
821
|
try {
|
|
605
822
|
await commandRegistration.dispose();
|
package/src/project.js
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
// Pure helpers for locating the current OpenCode project.
|
|
2
2
|
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
|
|
6
3
|
/**
|
|
7
4
|
* Resolve the absolute project root for the current plugin location.
|
|
8
5
|
* OpenCode exposes a `Location.Info` on the plugin context.
|
|
@@ -15,15 +12,3 @@ export function currentProjectPath(location) {
|
|
|
15
12
|
const projectDirectory = location.project && location.project.directory;
|
|
16
13
|
return projectDirectory || location.directory || undefined;
|
|
17
14
|
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* True when the directory looks like a JetBrains project (has a `.idea` folder).
|
|
21
|
-
* This is a convenience signal only; the plugin does not require it, because an
|
|
22
|
-
* IDE may have created the project entry without writing `.idea` yet.
|
|
23
|
-
*
|
|
24
|
-
* @param {string | undefined} directory
|
|
25
|
-
* @returns {boolean}
|
|
26
|
-
*/
|
|
27
|
-
export function hasIdeaDirectory(directory) {
|
|
28
|
-
return typeof directory === 'string' && existsSync(path.join(directory, '.idea'));
|
|
29
|
-
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The skills this plugin ships, loaded from `skills/<id>/SKILL.md`.
|
|
2
|
+
//
|
|
3
|
+
// This directory holds the *code* (singular `src/skill/`); the documents live in
|
|
4
|
+
// the package-root `skills/` (plural), so reviewing the feature means two clearly
|
|
5
|
+
// named places: the loader here, the content there.
|
|
6
|
+
//
|
|
7
|
+
// The layout mirrors the convention OpenCode itself reads: one directory per
|
|
8
|
+
// skill, the document named `SKILL.md`, `name`/`description` in YAML
|
|
9
|
+
// frontmatter, and the directory as the base for any relative asset the skill
|
|
10
|
+
// ships next to it. Adding a skill is therefore a directory, not a code change.
|
|
11
|
+
//
|
|
12
|
+
// Three contract details are enforced deliberately:
|
|
13
|
+
// * `description` is required. OpenCode drops a skill without one from the
|
|
14
|
+
// model-facing list, so a missing description would silently disable the
|
|
15
|
+
// skill instead of failing.
|
|
16
|
+
// * the returned definition must satisfy `Skill.Info` exactly (`id` / `name` /
|
|
17
|
+
// `description` / `path` / `content`). A rejected definition fails the skill
|
|
18
|
+
// transform, and a transform failure disables the whole plugin.
|
|
19
|
+
// * `content` is the document body only. The frontmatter is metadata and must
|
|
20
|
+
// not leak into the model prompt.
|
|
21
|
+
//
|
|
22
|
+
// The frontmatter reader below handles only the flat `key: value` form this
|
|
23
|
+
// package writes. It is not a YAML engine and must not grow into one.
|
|
24
|
+
|
|
25
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
/** Package-root `skills/`: one sub-directory per skill, next to `src/`. */
|
|
29
|
+
const SKILLS_DIR = new URL('../../skills/', import.meta.url);
|
|
30
|
+
|
|
31
|
+
/** Document name inside each skill directory. */
|
|
32
|
+
export const SKILL_FILE_NAME = 'SKILL.md';
|
|
33
|
+
|
|
34
|
+
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n)?/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} value
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function unquote(value) {
|
|
41
|
+
const quote = value[0];
|
|
42
|
+
if (value.length >= 2 && (quote === '"' || quote === "'") && value.endsWith(quote)) {
|
|
43
|
+
return value.slice(1, -1);
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read the flat `key: value` frontmatter block a `SKILL.md` must start with.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} text
|
|
52
|
+
* @param {string} source Used in error messages.
|
|
53
|
+
* @returns {Record<string, string>}
|
|
54
|
+
*/
|
|
55
|
+
export function parseSkillFrontmatter(text, source) {
|
|
56
|
+
const match = FRONTMATTER.exec(text);
|
|
57
|
+
if (!match) throw new Error(`${source}: ${SKILL_FILE_NAME} must start with a YAML frontmatter block`);
|
|
58
|
+
/** @type {Record<string, string>} */
|
|
59
|
+
const fields = {};
|
|
60
|
+
for (const raw of match[1].split(/\r?\n/)) {
|
|
61
|
+
const line = raw.trim();
|
|
62
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
63
|
+
const entry = /^([A-Za-z0-9_/-]+):[ \t]*(.*)$/.exec(line);
|
|
64
|
+
if (!entry) throw new Error(`${source}: unsupported frontmatter line: ${line}`);
|
|
65
|
+
fields[entry[1]] = unquote(entry[2].trim());
|
|
66
|
+
}
|
|
67
|
+
return fields;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Load one skill directory into a `Skill.Info`-shaped definition.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} id Directory name, used as the skill id.
|
|
74
|
+
* @returns {{ id: string, name: string, description: string, path: string, content: string }}
|
|
75
|
+
*/
|
|
76
|
+
export function readSkill(id) {
|
|
77
|
+
const file = new URL(`${id}/${SKILL_FILE_NAME}`, SKILLS_DIR);
|
|
78
|
+
const source = `skills/${id}/${SKILL_FILE_NAME}`;
|
|
79
|
+
const text = readFileSync(file, 'utf8');
|
|
80
|
+
const fields = parseSkillFrontmatter(text, source);
|
|
81
|
+
const description = fields.description;
|
|
82
|
+
if (!description) {
|
|
83
|
+
throw new Error(`${source}: frontmatter needs a "description", otherwise the skill is never offered`);
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
id,
|
|
87
|
+
name: fields.name || id,
|
|
88
|
+
description,
|
|
89
|
+
path: fileURLToPath(file),
|
|
90
|
+
content: text.replace(FRONTMATTER, '').replace(/^\s+/, '').trimEnd(),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Every shipped skill, sorted by id.
|
|
96
|
+
*
|
|
97
|
+
* @returns {Array<{ id: string, name: string, description: string, path: string, content: string }>}
|
|
98
|
+
*/
|
|
99
|
+
export function readSkills() {
|
|
100
|
+
return readdirSync(SKILLS_DIR, { withFileTypes: true })
|
|
101
|
+
.filter((entry) => entry.isDirectory())
|
|
102
|
+
.map((entry) => entry.name)
|
|
103
|
+
.sort()
|
|
104
|
+
.map((id) => readSkill(id));
|
|
105
|
+
}
|