opencode-idea 0.0.16 → 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 +164 -61
- 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/mcp/enhance.js +59 -0
- package/src/mcp/idea-tool-defaults.js +155 -0
- package/src/mcp/idea.js +2 -2
- package/src/plugin.js +205 -47
- 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,6 +51,8 @@ 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 {
|
|
56
58
|
autoLaunchSupported,
|
|
@@ -117,18 +119,29 @@ const IDEA_TOOL_PREFIX = 'idea_';
|
|
|
117
119
|
* - Inspection KTS MCP: inspection.kts authoring helpers (PSI tree, API docs,
|
|
118
120
|
* examples, run). Also IntelliJ Platform plugin-development only.
|
|
119
121
|
* - Python Environment MCP: Python interpreter detection and configuration.
|
|
120
|
-
* Hidden; project
|
|
121
|
-
*
|
|
122
|
-
* - Database
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
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.
|
|
132
145
|
*/
|
|
133
146
|
export const HIDDEN_IDEA_TOOLS = [
|
|
134
147
|
// VCS
|
|
@@ -136,6 +149,8 @@ export const HIDDEN_IDEA_TOOLS = [
|
|
|
136
149
|
'idea_get_repositories',
|
|
137
150
|
// Router
|
|
138
151
|
'idea_execute_tool',
|
|
152
|
+
// IDE terminal: no advantage over the native shell (see the note above).
|
|
153
|
+
'idea_execute_terminal_command',
|
|
139
154
|
// Debugger
|
|
140
155
|
'idea_xdebug_control_session',
|
|
141
156
|
'idea_xdebug_evaluate_expression',
|
|
@@ -165,10 +180,29 @@ export const HIDDEN_IDEA_TOOLS = [
|
|
|
165
180
|
// Python Environment MCP
|
|
166
181
|
'idea_configure_python_interpreter',
|
|
167
182
|
'idea_get_python_environment',
|
|
168
|
-
// Database
|
|
169
|
-
//
|
|
183
|
+
// Database: connections are the user's, and the IDE is not this plugin's
|
|
184
|
+
// database client.
|
|
170
185
|
'idea_create_database_connection',
|
|
171
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',
|
|
172
206
|
];
|
|
173
207
|
|
|
174
208
|
/**
|
|
@@ -415,10 +449,19 @@ export default {
|
|
|
415
449
|
* tool?: {
|
|
416
450
|
* list?: () => Promise<ReadonlyArray<{ id?: string }>>,
|
|
417
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 }>,
|
|
418
459
|
* hook?: (name: string, cb: (event: {
|
|
419
460
|
* tool?: string,
|
|
420
461
|
* sessionID?: string,
|
|
462
|
+
* input?: Record<string, unknown>,
|
|
421
463
|
* status?: string,
|
|
464
|
+
* result?: unknown,
|
|
422
465
|
* error?: unknown,
|
|
423
466
|
* }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
424
467
|
* },
|
|
@@ -464,13 +507,16 @@ export default {
|
|
|
464
507
|
let sessionRegistration;
|
|
465
508
|
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
466
509
|
let toolRegistration;
|
|
510
|
+
let toolBeforeRegistration;
|
|
511
|
+
let toolTransformRegistration;
|
|
467
512
|
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
468
513
|
let promptRegistration;
|
|
469
514
|
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
470
515
|
let commandRegistration;
|
|
471
516
|
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
472
517
|
let skillRegistration;
|
|
473
|
-
|
|
518
|
+
/** Ids of the skills currently registered, so a sync can tell "unchanged". */
|
|
519
|
+
let registeredSkillIds = [];
|
|
474
520
|
let disposed = false;
|
|
475
521
|
|
|
476
522
|
// Latches when a request showed the `idea_*` tools are gone. It is cleared
|
|
@@ -529,8 +575,7 @@ export default {
|
|
|
529
575
|
// The skill lifecycle follows the mark: marking the project registers the
|
|
530
576
|
// IDEA skills, clearing the mark removes them again. Same semantics as the
|
|
531
577
|
// reconnect behavior, so the two can never disagree.
|
|
532
|
-
|
|
533
|
-
else await removeSkills();
|
|
578
|
+
await syncSkills();
|
|
534
579
|
};
|
|
535
580
|
|
|
536
581
|
// Resolve the mark once, so the request hooks can read it synchronously.
|
|
@@ -571,7 +616,11 @@ export default {
|
|
|
571
616
|
// Without this, a session that already captured a snapshot keeps the old
|
|
572
617
|
// tool list until the session is reopened.
|
|
573
618
|
if (typeof ctx.tool?.reload === 'function') await ctx.tool.reload();
|
|
574
|
-
|
|
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;
|
|
575
624
|
};
|
|
576
625
|
|
|
577
626
|
const activateIde = async (port) => {
|
|
@@ -830,41 +879,101 @@ export default {
|
|
|
830
879
|
}
|
|
831
880
|
};
|
|
832
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
|
+
|
|
833
905
|
// The shipped skills are static knowledge (`skills/<id>/SKILL.md`), e.g. the
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
//
|
|
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.
|
|
838
924
|
//
|
|
839
925
|
// Deliberately NOT wrapped in a catch: these are core features of the plugin,
|
|
840
926
|
// so a rejected definition must fail loudly. Swallowing it would ship a
|
|
841
927
|
// silently incomplete plugin — which is exactly how 0.0.6 lost
|
|
842
928
|
// `/open-in-idea` without anyone noticing until the command was gone.
|
|
843
|
-
const
|
|
844
|
-
if (
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
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;
|
|
848
952
|
skillRegistration = await ctx.skill.transform((editor) => {
|
|
849
|
-
for (const skill of
|
|
953
|
+
for (const skill of wanted) editor.add(skill);
|
|
850
954
|
});
|
|
955
|
+
registeredSkillIds = wanted.map((skill) => skill.id);
|
|
851
956
|
};
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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;
|
|
863
972
|
};
|
|
864
|
-
// A project marked in an earlier run gets its skills
|
|
865
|
-
// marked for the first time gets them when
|
|
866
|
-
// go through `setMark` / this setup hook).
|
|
867
|
-
|
|
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();
|
|
868
977
|
|
|
869
978
|
// `/open-in-idea`: manual, re-entrant trigger that also loads the IDE
|
|
870
979
|
// capabilities immediately, and marks the project so later messages keep the
|
|
@@ -949,11 +1058,25 @@ export default {
|
|
|
949
1058
|
if (markCache === true || serving) {
|
|
950
1059
|
appendIdeRecoveryGuidance(event?.system, { marked: markCache === true, serving });
|
|
951
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();
|
|
952
1070
|
if (!injectGuidance) return;
|
|
953
1071
|
// Guidance promises tools the model can call, so it only goes out when
|
|
954
1072
|
// they are actually in this request.
|
|
955
1073
|
if (!serving) return;
|
|
956
|
-
|
|
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
|
+
});
|
|
957
1080
|
});
|
|
958
1081
|
|
|
959
1082
|
// 自愈入口(钩子型)。OpenCode 会 await `prompt`,所以在这里修好的连接会在
|
|
@@ -988,6 +1111,27 @@ export default {
|
|
|
988
1111
|
if (registrations.length > 0) needsRebuild = false;
|
|
989
1112
|
});
|
|
990
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
|
+
|
|
991
1135
|
// The only self-initiated action: when an `idea_*` call fails in a way
|
|
992
1136
|
// that means the IDE MCP is not usable, run the open flow again. The
|
|
993
1137
|
// failure is classified — a plain business error (a bad path, a missing
|
|
@@ -1035,6 +1179,20 @@ export default {
|
|
|
1035
1179
|
// best effort
|
|
1036
1180
|
}
|
|
1037
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
|
+
}
|
|
1038
1196
|
if (promptRegistration) {
|
|
1039
1197
|
try {
|
|
1040
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
|
+
}
|