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/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-run-config` skill (marked projects only), which
13
- // teaches the model how to manage `.run/*.run.xml` run configurations — a
14
- // capability the IDE MCP server does not expose.
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 commands run through the IDE terminal instead, which is
120
- * already launched with the IDE's real environment.
121
- * - Database data sources: connections are configured by the user in the IDE,
122
- * so the AI only reads and queries them; create/edit stay hidden.
123
- *
124
- * NOT hidden: `idea_execute_terminal_command`. Project commands must run with
125
- * the SDKs the user configured in the IDE (JDK, Node, Python, ...), so the model
126
- * uses the IDE's integrated terminal for them. `executeInShell: true` runs the
127
- * command in the user's real shell (`zsh`/`bash`), which inherits the IDE
128
- * environment — including version managers such as fnm / SDKMAN whose selection
129
- * lives in shell init and cannot be reconstructed from a handful of variables.
130
- * Requires Brave Mode in the IDE; without it the IDE asks for confirmation.
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 data sources: the user configures them in the IDE, the AI only
168
- // reads and queries them.
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: 'connected' | 'tools-loading' | 'mcp-unavailable' | 'launch-failed' | 'disabled' | 'closed',
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
- let skillsRegistered = false;
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
- if (marked) await ensureSkills();
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
- return waitForIdeaTools();
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
- if (!ideApp) return { status: 'disabled' };
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
- // run-configuration workflow: where `.run/*.run.xml` live, how to bootstrap a
818
- // schema from a real example, and how to verify a write. Registered only for
819
- // a project that has been marked as an IDEA project, so unrelated projects
820
- // never see an IDEA-specific skill.
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 ensureSkills = async () => {
827
- if (skillsRegistered || typeof ctx.skill?.transform !== 'function') return;
828
- skillsRegistered = true;
829
- // Read the documents before the transform: callbacks must stay synchronous.
830
- const skills = readSkills();
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 skills) editor.add(skill);
953
+ for (const skill of wanted) editor.add(skill);
833
954
  });
955
+ registeredSkillIds = wanted.map((skill) => skill.id);
834
956
  };
835
- /** Remove the IDEA skills again when the project is unmarked. */
836
- const removeSkills = async () => {
837
- if (!skillsRegistered || !skillRegistration) return;
838
- skillsRegistered = false;
839
- const registration = skillRegistration;
840
- skillRegistration = undefined;
841
- try {
842
- await registration.dispose();
843
- } catch {
844
- // best effort
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 at setup; a project
848
- // marked for the first time gets them when `/open-in-idea` runs (both paths
849
- // go through `setMark` / this setup hook).
850
- if (markCache === true) await ensureSkills();
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
- appendIdeGuidance(event?.system, projectPath);
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' && ideApp) {
987
- // The IDE is up; the project simply is not open in it. Ask the IDE
988
- // to open the project again, and tell the model to retry.
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();
@@ -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
- id,
87
- name: fields.name || id,
88
- description,
89
- path: fileURLToPath(file),
90
- content: text.replace(FRONTMATTER, '').replace(/^\s+/, '').trimEnd(),
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) => readSkill(id));
141
+ .map((id) => readSkillPackage(id));
105
142
  }
@@ -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
+ }