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/src/plugin.js CHANGED
@@ -1,22 +1,31 @@
1
1
  // opencode-idea — OpenCode plugin entry.
2
2
  //
3
- // For a JetBrains project this plugin:
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) and then loads the IDE
11
- // capabilities immediately;
12
- // 4. registers the `idea-run-config` skill (JetBrains projects only),
13
- // which teaches the model how to manage `.run/*.run.xml` run
14
- // configurations — a capability the IDE MCP server does not expose.
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
- // Non-JetBrains projects are left untouched. Everything is manual: setup does
17
- // NOT probe or connect the IDE (only the IDE-terminal env is read when a probe
18
- // is requested). `/open-in-idea` probes, opens the IDE, and loads the IDE MCP +
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-mcp.js';
31
- import { readIdeTerminalEnv } from './ide-env.js';
32
- import { currentProjectPath, hasIdeaDirectory } from './project.js';
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 { runConfigSkill } from './ide-run-config-skill.js';
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: string,
236
- * location: string,
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 = isIdeaProject || port !== undefined;
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
- // The run-configuration skill is static knowledge — where `.run/*.run.xml`
508
- // live, how to bootstrap a schema from a real example, and how to verify a
509
- // write. JetBrains projects only, and independent of `/open-in-idea`.
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: this is a core feature of the plugin,
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
- if (isIdeaProject && typeof ctx.skill?.transform === 'function') {
516
- // Read the document before the transform: callbacks must stay synchronous.
517
- const runConfigSkillDefinition = runConfigSkill();
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(runConfigSkillDefinition);
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 ensureIde({
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 || finalResult.status !== 'mcp-unavailable') {
556
- await sendFeedback(input, finalResult);
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 is deliberately a pure function of the request: no I/O, no cached
566
- // connection state, and no action of its own.
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 (isIdeaProject || serving) appendIdeRecoveryGuidance(event?.system);
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
+ }