opencode-idea 0.0.15 → 0.0.17
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 +174 -62
- package/package.json +5 -1
- package/skills/idea-standalone/SKILL.md +37 -89
- package/skills/idea-standalone/assets/AGENTS.template.md +66 -0
- package/src/ide-guidance.js +61 -84
- package/src/ide-guidance.template.md +71 -0
- package/src/ide-launcher.js +24 -1
- package/src/mcp/enhance.js +59 -0
- package/src/mcp/idea-tool-defaults.js +155 -0
- package/src/mcp/idea.js +23 -4
- package/src/plugin.js +232 -52
- package/src/skill/loader.js +45 -8
- package/src/template.js +94 -0
- package/skills/idea-run-config/SKILL.md +0 -118
package/src/plugin.js
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
// in IntelliJ IDEA (reusing a running instance), loads the IDE
|
|
10
10
|
// capabilities immediately, and marks the project as an IDEA project;
|
|
11
11
|
// `/close-in-idea` clears that mark;
|
|
12
|
-
// 3. registers the `idea-
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// 3. registers the `idea-standalone` skill (marked projects only), which
|
|
13
|
+
// pins the project's local-run discipline: every app starts from an IDEA
|
|
14
|
+
// run configuration, and starting or restarting one is the user's call.
|
|
15
15
|
//
|
|
16
16
|
// A project that was never marked is left untouched. Everything is manual:
|
|
17
17
|
// setup does NOT probe or connect the IDE. `/open-in-idea` probes, opens the
|
|
@@ -51,8 +51,11 @@ import {
|
|
|
51
51
|
findIdePort,
|
|
52
52
|
serverConfig,
|
|
53
53
|
} from './mcp/idea.js';
|
|
54
|
+
import { servingRunLifecycleTools } from './mcp/enhance.js';
|
|
55
|
+
import { applyIdeaToolDefaults, hideForcedArguments } from './mcp/idea-tool-defaults.js';
|
|
54
56
|
import { currentProjectPath } from './project.js';
|
|
55
57
|
import {
|
|
58
|
+
autoLaunchSupported,
|
|
56
59
|
createLaunchGuard,
|
|
57
60
|
openInIde,
|
|
58
61
|
resolveIdeApp,
|
|
@@ -116,18 +119,29 @@ const IDEA_TOOL_PREFIX = 'idea_';
|
|
|
116
119
|
* - Inspection KTS MCP: inspection.kts authoring helpers (PSI tree, API docs,
|
|
117
120
|
* examples, run). Also IntelliJ Platform plugin-development only.
|
|
118
121
|
* - Python Environment MCP: Python interpreter detection and configuration.
|
|
119
|
-
* Hidden; project
|
|
120
|
-
*
|
|
121
|
-
* - Database
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
122
|
+
* Hidden; a project's interpreter is chosen in the IDE's own settings, and the
|
|
123
|
+
* model has no business reconfiguring it.
|
|
124
|
+
* - Database: the connection tools (`create` / `edit`) stay hidden because the
|
|
125
|
+
* user configures data sources in the IDE. The query tools are hidden too —
|
|
126
|
+
* the IDE is not this plugin's idea of a database client, and a model that
|
|
127
|
+
* wants to inspect a database should be told to use a real one.
|
|
128
|
+
* - Plain file access: `idea_search_text` / `idea_search_regex` /
|
|
129
|
+
* `idea_search_file` / `idea_apply_patch` / `idea_create_new_file` each have a
|
|
130
|
+
* native tool that does the job (`grep` / `grep -E` / `glob` / `edit` /
|
|
131
|
+
* `write`); routing them through the IDE only adds a hop. `idea_read_file` is
|
|
132
|
+
* NOT hidden — it is the only way to read a file inside a jar (`<jar>!/<entry>`)
|
|
133
|
+
* or the JDK's `jrt://` image, so the guidance keeps it for exactly that and
|
|
134
|
+
* sends ordinary reads to the native tool.
|
|
135
|
+
* - IDE terminal: `idea_execute_terminal_command` does NOT inherit the project's
|
|
136
|
+
* environment. It was exposed on the assumption that it ran with the SDKs the
|
|
137
|
+
* user configured in the IDE (JDK, Node, Python, ...), but measured against a
|
|
138
|
+
* real IDE it brings no environment a native `shell` call does not already
|
|
139
|
+
* have — so it is a strictly worse shell, and hiding it removes an entire
|
|
140
|
+
* class of "why is it using an old JDK" confusion. Which tool to use instead
|
|
141
|
+
* is a routing decision, and it is made in `ide-guidance.js`: commands that
|
|
142
|
+
* need the project's environment go through a RUN CONFIGURATION (the IDE
|
|
143
|
+
* launches those with exactly the configured SDK), everything else goes
|
|
144
|
+
* through the native shell.
|
|
131
145
|
*/
|
|
132
146
|
export const HIDDEN_IDEA_TOOLS = [
|
|
133
147
|
// VCS
|
|
@@ -135,6 +149,8 @@ export const HIDDEN_IDEA_TOOLS = [
|
|
|
135
149
|
'idea_get_repositories',
|
|
136
150
|
// Router
|
|
137
151
|
'idea_execute_tool',
|
|
152
|
+
// IDE terminal: no advantage over the native shell (see the note above).
|
|
153
|
+
'idea_execute_terminal_command',
|
|
138
154
|
// Debugger
|
|
139
155
|
'idea_xdebug_control_session',
|
|
140
156
|
'idea_xdebug_evaluate_expression',
|
|
@@ -164,10 +180,29 @@ export const HIDDEN_IDEA_TOOLS = [
|
|
|
164
180
|
// Python Environment MCP
|
|
165
181
|
'idea_configure_python_interpreter',
|
|
166
182
|
'idea_get_python_environment',
|
|
167
|
-
// Database
|
|
168
|
-
//
|
|
183
|
+
// Database: connections are the user's, and the IDE is not this plugin's
|
|
184
|
+
// database client.
|
|
169
185
|
'idea_create_database_connection',
|
|
170
186
|
'idea_edit_database_connection',
|
|
187
|
+
'idea_list_database_connections',
|
|
188
|
+
'idea_test_database_connection',
|
|
189
|
+
'idea_list_database_schemas',
|
|
190
|
+
'idea_list_schema_object_kinds',
|
|
191
|
+
'idea_list_schema_objects',
|
|
192
|
+
'idea_introspect_schema',
|
|
193
|
+
'idea_get_database_object_description',
|
|
194
|
+
'idea_execute_sql_query',
|
|
195
|
+
'idea_fetch_query_result',
|
|
196
|
+
'idea_preview_table_data',
|
|
197
|
+
'idea_list_recent_sql_queries',
|
|
198
|
+
'idea_cancel_sql_query',
|
|
199
|
+
// Plain file access with a native equivalent (`idea_read_file` stays: only it
|
|
200
|
+
// can read inside a jar or the jrt:// image).
|
|
201
|
+
'idea_search_text',
|
|
202
|
+
'idea_search_regex',
|
|
203
|
+
'idea_search_file',
|
|
204
|
+
'idea_apply_patch',
|
|
205
|
+
'idea_create_new_file',
|
|
171
206
|
];
|
|
172
207
|
|
|
173
208
|
/**
|
|
@@ -296,7 +331,17 @@ function rewriteToolError(event, message) {
|
|
|
296
331
|
|
|
297
332
|
/**
|
|
298
333
|
* @typedef {{
|
|
299
|
-
* status:
|
|
334
|
+
* status:
|
|
335
|
+
* | 'connected'
|
|
336
|
+
* | 'tools-loading'
|
|
337
|
+
* | 'mcp-unavailable'
|
|
338
|
+
* | 'launch-failed'
|
|
339
|
+
* | 'disabled'
|
|
340
|
+
* | 'manual-launch'
|
|
341
|
+
* | 'closed',
|
|
342
|
+
* // `manual-launch` = this platform has no launcher at all (the user must
|
|
343
|
+
* // open the IDE by hand); `disabled` = the user turned launching off. Both
|
|
344
|
+
* // mean "no app to open", so only `autoLaunchSupported()` tells them apart.
|
|
300
345
|
* port?: number,
|
|
301
346
|
* opened?: boolean,
|
|
302
347
|
* unavailableNoticeSent?: boolean
|
|
@@ -332,6 +377,8 @@ export function ideFeedback(result) {
|
|
|
332
377
|
return '无法拉起 IntelliJ IDEA(未找到应用或启动失败)。请确认已安装 IDEA,或手动打开后重试 /open-in-idea。';
|
|
333
378
|
case 'disabled':
|
|
334
379
|
return '插件已通过 openInIde: false 关闭 IDE 启动。请手动打开 IntelliJ IDEA,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后重试 /open-in-idea。';
|
|
380
|
+
case 'manual-launch':
|
|
381
|
+
return '当前系统不支持自动打开 IDE(仅 macOS 实现了启动)。请手动打开 IntelliJ IDEA,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-idea。';
|
|
335
382
|
case 'closed':
|
|
336
383
|
return '已关闭当前项目的 IDEA 接入:已注销 IDE MCP 并清除项目标记。如需重新接入,请再次执行 /open-in-idea。';
|
|
337
384
|
default:
|
|
@@ -402,10 +449,19 @@ export default {
|
|
|
402
449
|
* tool?: {
|
|
403
450
|
* list?: () => Promise<ReadonlyArray<{ id?: string }>>,
|
|
404
451
|
* reload?: () => Promise<void>,
|
|
452
|
+
* transform?: (cb: (editor: {
|
|
453
|
+
* list: () => ReadonlyArray<{ id?: string, input?: unknown }>,
|
|
454
|
+
* get: (id: string) => ({ id?: string, input?: unknown } | undefined),
|
|
455
|
+
* add: (definition: unknown) => void,
|
|
456
|
+
* update: (id: string, update: (tool: { input?: unknown, description?: string }) => void) => void,
|
|
457
|
+
* remove: (id: string) => void,
|
|
458
|
+
* }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
405
459
|
* hook?: (name: string, cb: (event: {
|
|
406
460
|
* tool?: string,
|
|
407
461
|
* sessionID?: string,
|
|
462
|
+
* input?: Record<string, unknown>,
|
|
408
463
|
* status?: string,
|
|
464
|
+
* result?: unknown,
|
|
409
465
|
* error?: unknown,
|
|
410
466
|
* }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
411
467
|
* },
|
|
@@ -451,13 +507,16 @@ export default {
|
|
|
451
507
|
let sessionRegistration;
|
|
452
508
|
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
453
509
|
let toolRegistration;
|
|
510
|
+
let toolBeforeRegistration;
|
|
511
|
+
let toolTransformRegistration;
|
|
454
512
|
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
455
513
|
let promptRegistration;
|
|
456
514
|
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
457
515
|
let commandRegistration;
|
|
458
516
|
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
459
517
|
let skillRegistration;
|
|
460
|
-
|
|
518
|
+
/** Ids of the skills currently registered, so a sync can tell "unchanged". */
|
|
519
|
+
let registeredSkillIds = [];
|
|
461
520
|
let disposed = false;
|
|
462
521
|
|
|
463
522
|
// Latches when a request showed the `idea_*` tools are gone. It is cleared
|
|
@@ -516,8 +575,7 @@ export default {
|
|
|
516
575
|
// The skill lifecycle follows the mark: marking the project registers the
|
|
517
576
|
// IDEA skills, clearing the mark removes them again. Same semantics as the
|
|
518
577
|
// reconnect behavior, so the two can never disagree.
|
|
519
|
-
|
|
520
|
-
else await removeSkills();
|
|
578
|
+
await syncSkills();
|
|
521
579
|
};
|
|
522
580
|
|
|
523
581
|
// Resolve the mark once, so the request hooks can read it synchronously.
|
|
@@ -558,7 +616,11 @@ export default {
|
|
|
558
616
|
// Without this, a session that already captured a snapshot keeps the old
|
|
559
617
|
// tool list until the session is reopened.
|
|
560
618
|
if (typeof ctx.tool?.reload === 'function') await ctx.tool.reload();
|
|
561
|
-
|
|
619
|
+
const ready = await waitForIdeaTools();
|
|
620
|
+
// The catalog decides which of the shipped skills may be offered, so this
|
|
621
|
+
// is the first moment that question has an answer; see `syncSkills`.
|
|
622
|
+
await syncSkills();
|
|
623
|
+
return ready;
|
|
562
624
|
};
|
|
563
625
|
|
|
564
626
|
const activateIde = async (port) => {
|
|
@@ -692,7 +754,11 @@ export default {
|
|
|
692
754
|
}
|
|
693
755
|
return { status: 'connected', port: serving, unavailableNoticeSent: false };
|
|
694
756
|
}
|
|
695
|
-
|
|
757
|
+
// No app to open. Either the user turned launching off, or this platform
|
|
758
|
+
// has no launcher — say which, so the notice is actionable.
|
|
759
|
+
if (!ideApp) {
|
|
760
|
+
return { status: autoLaunchSupported() ? 'disabled' : 'manual-launch' };
|
|
761
|
+
}
|
|
696
762
|
if (guard.exhausted()) {
|
|
697
763
|
await reportUnavailable(false);
|
|
698
764
|
return { status: 'mcp-unavailable', opened: false, unavailableNoticeSent: launchNoticeSent };
|
|
@@ -813,41 +879,101 @@ export default {
|
|
|
813
879
|
}
|
|
814
880
|
};
|
|
815
881
|
|
|
882
|
+
/**
|
|
883
|
+
* The IDEA tools this project's IDE is serving right now.
|
|
884
|
+
*
|
|
885
|
+
* Empty is the honest answer for "does the IDE serve X?" before anything has
|
|
886
|
+
* connected — and it fails that way on error too, because the alternative
|
|
887
|
+
* (assuming a tool exists) is the one mistake that cannot be walked back
|
|
888
|
+
* after the model has seen it.
|
|
889
|
+
*
|
|
890
|
+
* @returns {Promise<Set<string>>}
|
|
891
|
+
*/
|
|
892
|
+
const servedIdeaToolNames = async () => {
|
|
893
|
+
if (typeof ctx.tool?.list !== 'function') return new Set();
|
|
894
|
+
try {
|
|
895
|
+
return new Set(toolIds(await ctx.tool.list()));
|
|
896
|
+
} catch {
|
|
897
|
+
return new Set();
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
/** Same ids in the same order — `readSkills()` sorts, so the order is stable. */
|
|
902
|
+
const isSameSkillSet = (skills, ids) =>
|
|
903
|
+
skills.length === ids.length && skills.every((skill, index) => skill.id === ids[index]);
|
|
904
|
+
|
|
816
905
|
// The shipped skills are static knowledge (`skills/<id>/SKILL.md`), e.g. the
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
//
|
|
906
|
+
// standalone-mode discipline (every app starts from an IDEA run
|
|
907
|
+
// configuration, and starting one is the user's call, so the skill has to be
|
|
908
|
+
// there to write the project's own rules). They pass two independent gates,
|
|
909
|
+
// because a skill can be wrong twice over:
|
|
910
|
+
//
|
|
911
|
+
// * the project mark — an unrelated project never sees an IDEA skill;
|
|
912
|
+
// * the tools the IDE actually serves — a skill that teaches a call the
|
|
913
|
+
// model cannot make is not knowledge, it is a failed call waiting to
|
|
914
|
+
// happen. `idea-standalone` drives `idea_rerun_run_configuration`, which
|
|
915
|
+
// only the companion plugin `idea-mcp-enhance` serves, so a stock IDE must
|
|
916
|
+
// not see it at all. Each skill declares its own dependency in its
|
|
917
|
+
// frontmatter (`requires:`), so this stays a data question.
|
|
918
|
+
//
|
|
919
|
+
// The catalog is unknown at setup: the mark is read from storage offline,
|
|
920
|
+
// before anything has connected to the IDE. So this is a reconcile rather
|
|
921
|
+
// than a one-shot — every catalog refresh re-runs it, and it offers exactly
|
|
922
|
+
// what is justified right now. Until the IDE has answered, the gated skills
|
|
923
|
+
// simply do not exist.
|
|
821
924
|
//
|
|
822
925
|
// Deliberately NOT wrapped in a catch: these are core features of the plugin,
|
|
823
926
|
// so a rejected definition must fail loudly. Swallowing it would ship a
|
|
824
927
|
// silently incomplete plugin — which is exactly how 0.0.6 lost
|
|
825
928
|
// `/open-in-idea` without anyone noticing until the command was gone.
|
|
826
|
-
const
|
|
827
|
-
if (
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
const
|
|
929
|
+
const syncSkillsOnce = async () => {
|
|
930
|
+
if (typeof ctx.skill?.transform !== 'function') return;
|
|
931
|
+
|
|
932
|
+
const served = await servedIdeaToolNames();
|
|
933
|
+
const wanted = (markCache === true ? readSkills() : [])
|
|
934
|
+
.filter(({ requires }) => requires.every((name) => served.has(name)))
|
|
935
|
+
.map(({ skill }) => skill);
|
|
936
|
+
if (isSameSkillSet(wanted, registeredSkillIds)) return;
|
|
937
|
+
|
|
938
|
+
// Tear down and register again, the same way the MCP server is
|
|
939
|
+
// re-registered: a transform that only ever adds would leave a stale skill
|
|
940
|
+
// behind, and one that only removes could never bring it back.
|
|
941
|
+
registeredSkillIds = [];
|
|
942
|
+
const previous = skillRegistration;
|
|
943
|
+
skillRegistration = undefined;
|
|
944
|
+
if (previous) {
|
|
945
|
+
try {
|
|
946
|
+
await previous.dispose();
|
|
947
|
+
} catch {
|
|
948
|
+
// best effort
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
if (wanted.length === 0) return;
|
|
831
952
|
skillRegistration = await ctx.skill.transform((editor) => {
|
|
832
|
-
for (const skill of
|
|
953
|
+
for (const skill of wanted) editor.add(skill);
|
|
833
954
|
});
|
|
955
|
+
registeredSkillIds = wanted.map((skill) => skill.id);
|
|
834
956
|
};
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
957
|
+
|
|
958
|
+
// Serialized like the reconciler, and for the same reason: one call disposes
|
|
959
|
+
// a registration before it makes the next, so two overlapping calls would
|
|
960
|
+
// leak one of them. That overlap is easy to reach — `/close-in-idea` can land
|
|
961
|
+
// while a reconnect is still in flight. With the chain in place, callers may
|
|
962
|
+
// fire this without awaiting.
|
|
963
|
+
let skillSyncChain = Promise.resolve();
|
|
964
|
+
const syncSkills = () => {
|
|
965
|
+
const run = () => syncSkillsOnce();
|
|
966
|
+
const next = skillSyncChain.then(run, run);
|
|
967
|
+
skillSyncChain = next.then(
|
|
968
|
+
() => {},
|
|
969
|
+
() => {},
|
|
970
|
+
);
|
|
971
|
+
return next;
|
|
846
972
|
};
|
|
847
|
-
// A project marked in an earlier run gets its skills
|
|
848
|
-
// marked for the first time gets them when
|
|
849
|
-
// go through `setMark` / this setup hook).
|
|
850
|
-
|
|
973
|
+
// A project marked in an earlier run gets its skills as soon as the IDE has
|
|
974
|
+
// answered; a project marked for the first time gets them when
|
|
975
|
+
// `/open-in-idea` runs (both paths go through `setMark` / this setup hook).
|
|
976
|
+
await syncSkills();
|
|
851
977
|
|
|
852
978
|
// `/open-in-idea`: manual, re-entrant trigger that also loads the IDE
|
|
853
979
|
// capabilities immediately, and marks the project so later messages keep the
|
|
@@ -932,11 +1058,25 @@ export default {
|
|
|
932
1058
|
if (markCache === true || serving) {
|
|
933
1059
|
appendIdeRecoveryGuidance(event?.system, { marked: markCache === true, serving });
|
|
934
1060
|
}
|
|
1061
|
+
// A skill follows its tools in both directions, and this is the one place
|
|
1062
|
+
// that sees the answer change WITHOUT a reconnect: a request that has
|
|
1063
|
+
// lost the enhance tools must not keep the skill that drives them. Fired,
|
|
1064
|
+
// not awaited — the hook is synchronous, and the chain in `syncSkills`
|
|
1065
|
+
// makes that safe. `refreshIdeTools` covers the other direction.
|
|
1066
|
+
//
|
|
1067
|
+
// Skipped when there is nothing it could change, so a project that never
|
|
1068
|
+
// touched the IDE does not pay a catalog read on every request.
|
|
1069
|
+
if (markCache === true || registeredSkillIds.length > 0) void syncSkills();
|
|
935
1070
|
if (!injectGuidance) return;
|
|
936
1071
|
// Guidance promises tools the model can call, so it only goes out when
|
|
937
1072
|
// they are actually in this request.
|
|
938
1073
|
if (!serving) return;
|
|
939
|
-
|
|
1074
|
+
// Same rule for the run-lifecycle tools: they come from the companion
|
|
1075
|
+
// IDEA plugin `idea-mcp-enhance`, so a stock IDE has none of them and
|
|
1076
|
+
// naming them would promise calls that can only fail.
|
|
1077
|
+
appendIdeGuidance(event?.system, projectPath, {
|
|
1078
|
+
runLifecycleTools: servingRunLifecycleTools(tools),
|
|
1079
|
+
});
|
|
940
1080
|
});
|
|
941
1081
|
|
|
942
1082
|
// 自愈入口(钩子型)。OpenCode 会 await `prompt`,所以在这里修好的连接会在
|
|
@@ -971,6 +1111,27 @@ export default {
|
|
|
971
1111
|
if (registrations.length > 0) needsRebuild = false;
|
|
972
1112
|
});
|
|
973
1113
|
|
|
1114
|
+
// The IDE's tool-catalog policy, in two halves that must stay in sync:
|
|
1115
|
+
// an argument this plugin always overwrites must not also be advertised,
|
|
1116
|
+
// or the model sets it, reasons about it, and is wrong. The schema half
|
|
1117
|
+
// runs first, so a request never sees a knob that is already decided; see
|
|
1118
|
+
// `mcp/idea-tool-defaults.js` for the measurements and for why this is a
|
|
1119
|
+
// narrow exception to "never touch tool definitions".
|
|
1120
|
+
//
|
|
1121
|
+
// Both halves are deliberately unconditional on the project mark: they are
|
|
1122
|
+
// properties of the tool call, not of whether this project is IDE-connected.
|
|
1123
|
+
if (typeof ctx.tool?.transform === 'function') {
|
|
1124
|
+
toolTransformRegistration = await ctx.tool.transform((editor) => {
|
|
1125
|
+
hideForcedArguments(editor);
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
if (typeof ctx.tool?.hook === 'function') {
|
|
1129
|
+
toolBeforeRegistration = await ctx.tool.hook('execute.before', (event) => {
|
|
1130
|
+
if (typeof event?.tool !== 'string') return;
|
|
1131
|
+
applyIdeaToolDefaults(event.tool, event.input);
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
|
|
974
1135
|
// The only self-initiated action: when an `idea_*` call fails in a way
|
|
975
1136
|
// that means the IDE MCP is not usable, run the open flow again. The
|
|
976
1137
|
// failure is classified — a plain business error (a bad path, a missing
|
|
@@ -983,9 +1144,14 @@ export default {
|
|
|
983
1144
|
if (!(await readMark())) return;
|
|
984
1145
|
const kind = classifyIdeaFailure(event.error);
|
|
985
1146
|
if (kind === 'none') return;
|
|
986
|
-
if (kind === 'project'
|
|
987
|
-
// The IDE is up; the
|
|
988
|
-
//
|
|
1147
|
+
if (kind === 'project') {
|
|
1148
|
+
// The IDE is up and answering; only the PROJECT is missing. That is
|
|
1149
|
+
// never a connection problem, so this must never fall through to
|
|
1150
|
+
// the reconnect below — on a platform with no launcher there is
|
|
1151
|
+
// nothing to call, and falling through would report "reconnected"
|
|
1152
|
+
// and send the model round after round after a call that can only
|
|
1153
|
+
// fail. Leave the IDE's own message, which names the real cause.
|
|
1154
|
+
if (!ideApp) return;
|
|
989
1155
|
await openInIde(projectPath, { app: ideApp });
|
|
990
1156
|
rewriteToolError(event, '该项目已在 IDEA 中重新打开,请重试刚才的操作。');
|
|
991
1157
|
return;
|
|
@@ -1013,6 +1179,20 @@ export default {
|
|
|
1013
1179
|
// best effort
|
|
1014
1180
|
}
|
|
1015
1181
|
}
|
|
1182
|
+
if (toolBeforeRegistration) {
|
|
1183
|
+
try {
|
|
1184
|
+
await toolBeforeRegistration.dispose();
|
|
1185
|
+
} catch {
|
|
1186
|
+
// best effort
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
if (toolTransformRegistration) {
|
|
1190
|
+
try {
|
|
1191
|
+
await toolTransformRegistration.dispose();
|
|
1192
|
+
} catch {
|
|
1193
|
+
// best effort
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1016
1196
|
if (promptRegistration) {
|
|
1017
1197
|
try {
|
|
1018
1198
|
await promptRegistration.dispose();
|
package/src/skill/loader.js
CHANGED
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
//
|
|
22
22
|
// The frontmatter reader below handles only the flat `key: value` form this
|
|
23
23
|
// package writes. It is not a YAML engine and must not grow into one.
|
|
24
|
+
//
|
|
25
|
+
// One frontmatter key is the loader's own rather than OpenCode's: `requires`, a
|
|
26
|
+
// comma-separated list of the tools the skill is written around. It is read here
|
|
27
|
+
// and deliberately kept OUT of the registered definition — the caller filters on
|
|
28
|
+
// it instead (see `readSkills`), because a skill whose tools are absent must not
|
|
29
|
+
// be registered at all.
|
|
24
30
|
|
|
25
31
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
26
32
|
import { fileURLToPath } from 'node:url';
|
|
@@ -67,6 +73,20 @@ export function parseSkillFrontmatter(text, source) {
|
|
|
67
73
|
return fields;
|
|
68
74
|
}
|
|
69
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Split a frontmatter list value: `a, b` -> `['a', 'b']`.
|
|
78
|
+
*
|
|
79
|
+
* @param {string | undefined} value
|
|
80
|
+
* @returns {string[]}
|
|
81
|
+
*/
|
|
82
|
+
function splitList(value) {
|
|
83
|
+
if (!value) return [];
|
|
84
|
+
return value
|
|
85
|
+
.split(',')
|
|
86
|
+
.map((entry) => entry.trim())
|
|
87
|
+
.filter((entry) => entry !== '');
|
|
88
|
+
}
|
|
89
|
+
|
|
70
90
|
/**
|
|
71
91
|
* Load one skill directory into a `Skill.Info`-shaped definition.
|
|
72
92
|
*
|
|
@@ -74,6 +94,20 @@ export function parseSkillFrontmatter(text, source) {
|
|
|
74
94
|
* @returns {{ id: string, name: string, description: string, path: string, content: string }}
|
|
75
95
|
*/
|
|
76
96
|
export function readSkill(id) {
|
|
97
|
+
return readSkillPackage(id).skill;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A skill plus the tools it needs before it is worth offering.
|
|
102
|
+
*
|
|
103
|
+
* The two are kept apart on purpose: `skill` must satisfy `Skill.Info` exactly,
|
|
104
|
+
* while `requires` is this package's own metadata and must NOT be registered —
|
|
105
|
+
* it is what the caller filters on.
|
|
106
|
+
*
|
|
107
|
+
* @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[] }}
|
|
109
|
+
*/
|
|
110
|
+
export function readSkillPackage(id) {
|
|
77
111
|
const file = new URL(`${id}/${SKILL_FILE_NAME}`, SKILLS_DIR);
|
|
78
112
|
const source = `skills/${id}/${SKILL_FILE_NAME}`;
|
|
79
113
|
const text = readFileSync(file, 'utf8');
|
|
@@ -83,23 +117,26 @@ export function readSkill(id) {
|
|
|
83
117
|
throw new Error(`${source}: frontmatter needs a "description", otherwise the skill is never offered`);
|
|
84
118
|
}
|
|
85
119
|
return {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
120
|
+
skill: {
|
|
121
|
+
id,
|
|
122
|
+
name: fields.name || id,
|
|
123
|
+
description,
|
|
124
|
+
path: fileURLToPath(file),
|
|
125
|
+
content: text.replace(FRONTMATTER, '').replace(/^\s+/, '').trimEnd(),
|
|
126
|
+
},
|
|
127
|
+
requires: splitList(fields.requires),
|
|
91
128
|
};
|
|
92
129
|
}
|
|
93
130
|
|
|
94
131
|
/**
|
|
95
|
-
* Every shipped skill, sorted by id.
|
|
132
|
+
* Every shipped skill, sorted by id, each with the tools it requires.
|
|
96
133
|
*
|
|
97
|
-
* @returns {Array<{ id: string, name: string, description: string, path: string, content: string }>}
|
|
134
|
+
* @returns {Array<{ skill: { id: string, name: string, description: string, path: string, content: string }, requires: string[] }>}
|
|
98
135
|
*/
|
|
99
136
|
export function readSkills() {
|
|
100
137
|
return readdirSync(SKILLS_DIR, { withFileTypes: true })
|
|
101
138
|
.filter((entry) => entry.isDirectory())
|
|
102
139
|
.map((entry) => entry.name)
|
|
103
140
|
.sort()
|
|
104
|
-
.map((id) =>
|
|
141
|
+
.map((id) => readSkillPackage(id));
|
|
105
142
|
}
|
package/src/template.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The smallest template renderer that makes a prompt document reviewable.
|
|
2
|
+
//
|
|
3
|
+
// WHY NOT A REAL ENGINE (handlebars / ejs / nunjucks / velocity):
|
|
4
|
+
//
|
|
5
|
+
// * HTML escaping is the default in all of them, and it is WRONG here. This
|
|
6
|
+
// renders Markdown for a model: `\`code\``, `<jar>!/<entry>` and `j2k<` must
|
|
7
|
+
// come out byte-for-byte. Configuring that off is possible, but the default
|
|
8
|
+
// is the trap, and the failure is silent mangling of the text a model reads.
|
|
9
|
+
// * It would be the package's first runtime dependency. This plugin ships to
|
|
10
|
+
// npm and is otherwise dependency-free at runtime.
|
|
11
|
+
// * Each engine brings its own whitespace rules, and whitespace IS structure
|
|
12
|
+
// here: one blank line separates sections, and a conditional that renders
|
|
13
|
+
// empty must not leave a gap behind.
|
|
14
|
+
//
|
|
15
|
+
// What is actually needed is tiny: a variable, and if/else. So that is all this
|
|
16
|
+
// does, and the template file stays readable as the document it produces.
|
|
17
|
+
//
|
|
18
|
+
// DIRECTIVES MUST OCCUPY A WHOLE LINE. A directive line emits nothing at all, so
|
|
19
|
+
// "that line is there" and "that line is not there" is the only difference a
|
|
20
|
+
// flag makes — no stray blank line, no half-eaten indentation. A directive that
|
|
21
|
+
// is not alone on its line is a mistake, and this throws rather than guessing.
|
|
22
|
+
|
|
23
|
+
const DIRECTIVE = /^\{\{\s*(#if\s+([A-Za-z0-9_]+)|else|\/if)\s*\}\}$/;
|
|
24
|
+
// Any control tag anywhere, used to catch a directive that is not alone on its
|
|
25
|
+
// line: that line would otherwise be emitted verbatim, silently changing which
|
|
26
|
+
// text a model reads.
|
|
27
|
+
const CONTROL_TAG = /\{\{\s*(#[^}]*|\/[^}]*|else\s*)\}\}/;
|
|
28
|
+
const VARIABLE = /\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Render a template against a data record.
|
|
32
|
+
*
|
|
33
|
+
* Supported: `{{name}}`, `{{#if name}}…{{/if}}`, `{{#if name}}…{{else}}…{{/if}}`.
|
|
34
|
+
* Nesting works. Unknown variables render as empty; unknown directives throw,
|
|
35
|
+
* because a typo in a directive silently changes which text a model sees.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} template
|
|
38
|
+
* @param {Record<string, unknown>} data
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function renderTemplate(template, data) {
|
|
42
|
+
const out = [];
|
|
43
|
+
/** @type {Array<{ active: boolean, taken: boolean }>} */
|
|
44
|
+
const stack = [];
|
|
45
|
+
const emitting = () => stack.every((frame) => frame.active);
|
|
46
|
+
|
|
47
|
+
// A trailing newline in the file is not part of the document.
|
|
48
|
+
const lines = template.replace(/\n$/, '').split('\n');
|
|
49
|
+
|
|
50
|
+
for (const [index, line] of lines.entries()) {
|
|
51
|
+
const match = DIRECTIVE.exec(line.trim());
|
|
52
|
+
|
|
53
|
+
if (!match) {
|
|
54
|
+
if (CONTROL_TAG.test(line)) {
|
|
55
|
+
throw new Error(`template line ${index + 1}: a directive must occupy the whole line`);
|
|
56
|
+
}
|
|
57
|
+
if (emitting()) out.push(substitute(line, data));
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const directive = match[1];
|
|
62
|
+
if (directive.startsWith('#if')) {
|
|
63
|
+
stack.push({ active: Boolean(data[match[2]]), taken: Boolean(data[match[2]]) });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (directive === 'else') {
|
|
67
|
+
if (stack.length === 0) throw new Error(`template line ${index + 1}: {{else}} outside {{#if}}`);
|
|
68
|
+
const frame = stack[stack.length - 1];
|
|
69
|
+
// An `{{else}}` after a taken branch stays off: `if/else` is exclusive even
|
|
70
|
+
// when both sides are truthy.
|
|
71
|
+
frame.active = !frame.taken;
|
|
72
|
+
frame.taken = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// `{{/if}}`
|
|
76
|
+
if (stack.length === 0) throw new Error(`template line ${index + 1}: {{/if}} without {{#if}}`);
|
|
77
|
+
stack.pop();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (stack.length > 0) throw new Error('template: {{#if}} was never closed');
|
|
81
|
+
|
|
82
|
+
return out.join('\n');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Replace `{{name}}` with its value. Values are inserted verbatim — see the file
|
|
87
|
+
* header for why nothing is escaped.
|
|
88
|
+
*/
|
|
89
|
+
function substitute(line, data) {
|
|
90
|
+
return line.replace(VARIABLE, (_whole, name) => {
|
|
91
|
+
const value = data[name];
|
|
92
|
+
return value === undefined || value === null ? '' : String(value);
|
|
93
|
+
});
|
|
94
|
+
}
|