opencode-agent-skills-md 1.0.0

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.
Files changed (117) hide show
  1. package/.beads/.local_version +1 -0
  2. package/.beads/README.md +81 -0
  3. package/.beads/config.yaml +61 -0
  4. package/.beads/deletions.jsonl +1 -0
  5. package/.beads/issues.jsonl +64 -0
  6. package/.beads/metadata.json +4 -0
  7. package/.gitattributes +3 -0
  8. package/.github/CODEOWNERS +1 -0
  9. package/.github/copilot-instructions.md +78 -0
  10. package/.github/dependabot.yml +13 -0
  11. package/.github/workflows/release.yml +51 -0
  12. package/.opencode/command/test-compaction.md +9 -0
  13. package/.opencode/command/test-find-skills.md +7 -0
  14. package/.opencode/command/test-read-skill-file.md +14 -0
  15. package/.opencode/command/test-run-skill-script.md +13 -0
  16. package/.opencode/command/test-skills.md +14 -0
  17. package/.opencode/command/test-use-skill.md +10 -0
  18. package/.opencode/skills/git-helper/SKILL.md +65 -0
  19. package/.opencode/skills/test-skill/SKILL.md +43 -0
  20. package/.opencode/skills/test-skill/example-config.json +16 -0
  21. package/.opencode/skills/test-skill/helper-docs.md +29 -0
  22. package/.opencode/skills/test-skill/scripts/echo-args +14 -0
  23. package/.opencode/skills/test-skill/scripts/greet +6 -0
  24. package/AGENTS.md +43 -0
  25. package/CHANGELOG.md +178 -0
  26. package/Justfile +39 -0
  27. package/LICENSE +9 -0
  28. package/README.md +189 -0
  29. package/openspec/changes/archive/2026-06-14-skills-core-decouple/specs/core-decoupling/spec.md +74 -0
  30. package/openspec/changes/archive/2026-06-14-skills-core-decouple/tasks.md +64 -0
  31. package/openspec/changes/archive/2026-06-14-skills-core-decouple/verify-report.md +75 -0
  32. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/apply-progress.md +136 -0
  33. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/archive-report.md +77 -0
  34. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/design.md +89 -0
  35. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/proposal.md +65 -0
  36. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/specs/core-decoupling/spec.md +77 -0
  37. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/tasks.md +65 -0
  38. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/verify-report.md +165 -0
  39. package/openspec/specs/core-decoupling/spec.md +110 -0
  40. package/package.json +35 -0
  41. package/packages/core/package.json +30 -0
  42. package/packages/core/src/content.d.ts +16 -0
  43. package/packages/core/src/content.ts +30 -0
  44. package/packages/core/src/debug.ts +16 -0
  45. package/packages/core/src/discovery.d.ts +86 -0
  46. package/packages/core/src/discovery.ts +257 -0
  47. package/packages/core/src/index.d.ts +20 -0
  48. package/packages/core/src/index.ts +55 -0
  49. package/packages/core/src/match.d.ts +19 -0
  50. package/packages/core/src/match.ts +75 -0
  51. package/packages/core/src/parse.d.ts +26 -0
  52. package/packages/core/src/parse.ts +141 -0
  53. package/packages/core/src/scripts.d.ts +17 -0
  54. package/packages/core/src/scripts.ts +79 -0
  55. package/packages/core/src/search.d.ts +83 -0
  56. package/packages/core/src/search.ts +188 -0
  57. package/packages/core/src/types.d.ts +82 -0
  58. package/packages/core/src/types.ts +131 -0
  59. package/packages/core/src/walk.ts +109 -0
  60. package/packages/core/tests/agnostic.test.ts +346 -0
  61. package/packages/core/tests/content.test.ts +65 -0
  62. package/packages/core/tests/discovery.test.ts +370 -0
  63. package/packages/core/tests/package-boundary.test.ts +310 -0
  64. package/packages/core/tests/parse-trigger.test.ts +282 -0
  65. package/packages/core/tests/search.test.ts +374 -0
  66. package/packages/core/tests/subpath.test.ts +87 -0
  67. package/packages/core/tsconfig.json +10 -0
  68. package/packages/opencode-agent-skills-md/package.json +42 -0
  69. package/packages/opencode-agent-skills-md/rolldown.config.js +48 -0
  70. package/packages/opencode-agent-skills-md/src/cli/config.ts +522 -0
  71. package/packages/opencode-agent-skills-md/src/cli/install.ts +111 -0
  72. package/packages/opencode-agent-skills-md/src/cli/main.ts +201 -0
  73. package/packages/opencode-agent-skills-md/src/cli/real-fs.ts +51 -0
  74. package/packages/opencode-agent-skills-md/src/cli/status.ts +183 -0
  75. package/packages/opencode-agent-skills-md/src/cli/uninstall.ts +157 -0
  76. package/packages/opencode-agent-skills-md/src/host.ts +119 -0
  77. package/packages/opencode-agent-skills-md/src/index.ts +25 -0
  78. package/packages/opencode-agent-skills-md/src/plugin.ts +343 -0
  79. package/packages/opencode-agent-skills-md/src/sdk.ts +71 -0
  80. package/packages/opencode-agent-skills-md/src/tools.ts +373 -0
  81. package/packages/opencode-agent-skills-md/tests/cli-commands.test.ts +1423 -0
  82. package/packages/opencode-agent-skills-md/tests/e2e/startup-smoke.test.ts +66 -0
  83. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.claude/skills/claude-user-only-skill/SKILL.md +8 -0
  84. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/shared-skill/SKILL.md +8 -0
  85. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/user-only-skill/SKILL.md +8 -0
  86. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.claude/skills/claude-project-only-skill/SKILL.md +8 -0
  87. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/go-tester/SKILL.md +12 -0
  88. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/nested/team/nested-skill/SKILL.md +8 -0
  89. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/rust-tester/SKILL.md +11 -0
  90. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/SKILL.md +8 -0
  91. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/bin/echo.sh +2 -0
  92. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/docs/reference.md +1 -0
  93. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/shared-skill/SKILL.md +8 -0
  94. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/using-superpowers/SKILL.md +8 -0
  95. package/packages/opencode-agent-skills-md/tests/integration/helpers/mock-opencode.ts +114 -0
  96. package/packages/opencode-agent-skills-md/tests/integration/plugin.test.ts +316 -0
  97. package/packages/opencode-agent-skills-md/tests/integration/skill-discovery.test.ts +315 -0
  98. package/packages/opencode-agent-skills-md/tests/opencode/host.test.ts +179 -0
  99. package/packages/opencode-agent-skills-md/tests/opencode/plugin.test.ts +551 -0
  100. package/packages/opencode-agent-skills-md/tests/opencode/subpath.test.ts +66 -0
  101. package/packages/opencode-agent-skills-md/tests/opencode/tools.test.ts +213 -0
  102. package/packages/opencode-agent-skills-md/tests/package-boundary.test.ts +346 -0
  103. package/packages/opencode-agent-skills-md/tests/tools-security.test.ts +72 -0
  104. package/packages/opencode-agent-skills-md/tsconfig.build.json +11 -0
  105. package/packages/opencode-agent-skills-md/tsconfig.json +10 -0
  106. package/plans/001-ci-gate.md +177 -0
  107. package/plans/002-is-path-safe.md +243 -0
  108. package/plans/003-escape-prompts.md +310 -0
  109. package/plans/004-test-security-paths.md +228 -0
  110. package/plans/005-stop-swallowing-errors.md +246 -0
  111. package/plans/006-preserve-jsonc-commas.md +144 -0
  112. package/plans/007-write-before-purge.md +144 -0
  113. package/plans/008-reuse-walkdir-for-list-skill-files.md +164 -0
  114. package/plans/README.md +43 -0
  115. package/pnpm-workspace.yaml +6 -0
  116. package/tests/workspace.test.ts +367 -0
  117. package/tsconfig.json +15 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * OpenCode host adapter.
3
+ *
4
+ * Wraps the OpenCode SDK client (`PluginInput["client"]`) and provides
5
+ * a bounded surface for content injection, session context, and filesystem
6
+ * access consumed by the plugin and skill tools.
7
+ *
8
+ * The boundary contracts (`SkillHostClient`, `SkillHostSession`,
9
+ * `SkillHostContext`) are declared in the `opencode-agent-skills-md-core`
10
+ * package per spec R2; this module IMPLEMENTS them over the OpenCode SDK
11
+ * client plus `node:fs/promises`. No other package may declare a concrete
12
+ * implementation — the plugin package owns exactly one.
13
+ */
14
+
15
+ import * as fs from "node:fs/promises";
16
+ import type { PluginInput } from "@opencode-ai/plugin";
17
+ import type {
18
+ SkillHostClient,
19
+ SkillHostContext,
20
+ SkillHostSession,
21
+ } from "opencode-agent-skills-md-core";
22
+ import { debugLog } from "opencode-agent-skills-md-core";
23
+
24
+ /** Concrete OpenCode client (the SDK's generated client type). */
25
+ export type OpencodeClient = PluginInput["client"];
26
+
27
+ /**
28
+ * File access surface exposed alongside the host client. Tools
29
+ * consume these via the host instead of importing `node:fs/promises` so the
30
+ * boundary stays explicit and easy to stub in tests.
31
+ */
32
+ export interface OpencodeHostFileAccess {
33
+ readFile(path: string): Promise<string>;
34
+ readdir(path: string): Promise<string[]>;
35
+ }
36
+
37
+ /**
38
+ * Concrete OpenCode client surface.
39
+ *
40
+ * Structurally identical to the core boundary contract `SkillHostClient`
41
+ * (it implements all four methods). The alias is preserved for backward
42
+ * compatibility with prior plugin-package consumers and to make the
43
+ * OpenCode-specific implementation obvious at use sites.
44
+ */
45
+ export type OpencodeSkillHostClient = SkillHostClient;
46
+
47
+ /**
48
+ * The full host surface: a bounded client plus a session factory. Each call
49
+ * to `session(id)` returns a `SkillHostSession` carrying only the id the core
50
+ * needs to thread through host calls.
51
+ */
52
+ export interface OpencodeSkillHost {
53
+ client: OpencodeSkillHostClient;
54
+ session: (id: string) => SkillHostSession;
55
+ }
56
+
57
+ /**
58
+ * Build an `OpencodeSkillHost` over the supplied OpenCode SDK client.
59
+ *
60
+ * The host is the only place in the codebase that touches the SDK's
61
+ * `client.session.prompt` and `client.session.messages` methods.
62
+ */
63
+ export const createOpencodeSkillHost = (client: OpencodeClient): OpencodeSkillHost => {
64
+ const skillClient: OpencodeSkillHostClient = {
65
+ async injectContent(sessionID, text, context) {
66
+ await client.session.prompt({
67
+ path: { id: sessionID },
68
+ body: {
69
+ noReply: true,
70
+ model: context?.model,
71
+ agent: context?.agent,
72
+ parts: [{ type: "text", text, synthetic: true }],
73
+ },
74
+ });
75
+ },
76
+
77
+ async getSessionContext(sessionID) {
78
+ try {
79
+ const response = await client.session.messages({
80
+ path: { id: sessionID },
81
+ query: { limit: 50 },
82
+ });
83
+
84
+ if (response.data) {
85
+ for (const msg of response.data) {
86
+ if (
87
+ msg.info.role === "user" &&
88
+ "model" in msg.info &&
89
+ msg.info.model
90
+ ) {
91
+ return {
92
+ model: msg.info.model,
93
+ agent: msg.info.agent,
94
+ };
95
+ }
96
+ }
97
+ }
98
+ } catch (error) {
99
+ debugLog("getSessionContext: session lookup failed", sessionID, (error as Error)?.name);
100
+ // Fall through to undefined - mirrors the legacy behaviour where
101
+ // getSessionContext returns undefined on any lookup failure.
102
+ }
103
+
104
+ return undefined;
105
+ },
106
+
107
+ async readFile(filePath) {
108
+ return fs.readFile(filePath, "utf-8");
109
+ },
110
+
111
+ async readdir(dirPath) {
112
+ return fs.readdir(dirPath);
113
+ },
114
+ };
115
+
116
+ const session = (id: string): SkillHostSession => ({ id });
117
+
118
+ return { client: skillClient, session };
119
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * OpenCode host adapter — root entrypoint.
3
+ *
4
+ * Re-exports the plugin factory as the package's default export so the
5
+ * `rolldown` build can target this file directly. The root `src/plugin.ts`
6
+ * shim forwards to this module to preserve the legacy import path while
7
+ * `package.json` still resolves `dist/plugin.mjs` to the package main.
8
+ *
9
+ * Public surface:
10
+ * - default export: SkillsPlugin (the @opencode-ai/plugin Plugin factory)
11
+ * - named exports: SkillsPlugin, createOpencodeSkillHost
12
+ */
13
+
14
+ import { SkillsPlugin } from "./plugin";
15
+
16
+ export { SkillsPlugin };
17
+ export { createOpencodeSkillHost } from "./host";
18
+ export type {
19
+ OpencodeClient,
20
+ OpencodeSkillHost,
21
+ OpencodeSkillHostClient,
22
+ OpencodeHostFileAccess,
23
+ } from "./host";
24
+
25
+ export default SkillsPlugin;
@@ -0,0 +1,343 @@
1
+ /**
2
+ * OpenCode Agent Skills Plugin (host adapter).
3
+ *
4
+ * The plugin factory builds the host over the OpenCode SDK client, composes
5
+ * the four skill tools, and wires the chat.message and event hooks. The
6
+ * keyword matcher and session/loaded-skill bookkeeping are the only
7
+ * adapter-specific logic; everything else delegates to the portable core
8
+ * or the host.
9
+ */
10
+
11
+ import type { Plugin, PluginInput } from "@opencode-ai/plugin";
12
+ import {
13
+ discoverAllSkills,
14
+ renderAvailableSkillsBlock,
15
+ type Skill,
16
+ type SkillHostContext,
17
+ type SkillSummary,
18
+ } from "opencode-agent-skills-md-core";
19
+ import { createOpencodeSkillHost, type OpencodeSkillHostClient } from "./host";
20
+ import { createSkillTools } from "./tools";
21
+ import { debugLog } from "opencode-agent-skills-md-core";
22
+ import {
23
+ isChatTextPart,
24
+ isSessionCompactedEvent,
25
+ isSessionDeletedEvent,
26
+ type ChatMessageOutput,
27
+ } from "./sdk";
28
+
29
+ const injectSkillsList = async (
30
+ directory: string,
31
+ host: { client: OpencodeSkillHostClient },
32
+ sessionID: string,
33
+ context?: SkillHostContext,
34
+ precomputed?: Map<string, Skill>,
35
+ ): Promise<void> => {
36
+ const skillsByName = precomputed ?? await discoverAllSkills(directory);
37
+ const skills = Array.from(skillsByName.values());
38
+ if (skills.length === 0) return;
39
+ await host.client.injectContent(sessionID, renderAvailableSkillsBlock(skills), context);
40
+ };
41
+
42
+ const maybeInjectSuperpowersBootstrap = async (
43
+ directory: string,
44
+ host: { client: OpencodeSkillHostClient },
45
+ sessionID: string,
46
+ context?: SkillHostContext,
47
+ precomputed?: Map<string, Skill>,
48
+ ): Promise<void> => {
49
+ if (process.env.OPENCODE_AGENT_SKILLS_SUPERPOWERS_MODE !== 'true') return;
50
+ const skillsByName = precomputed ?? await discoverAllSkills(directory);
51
+ const usingSuperpowersSkill = skillsByName.get('using-superpowers');
52
+ if (!usingSuperpowersSkill) return;
53
+ const ctx = context ?? await host.client.getSessionContext(sessionID);
54
+ const content = `<EXTREMELY_IMPORTANT>
55
+ You have superpowers.
56
+
57
+ **IMPORTANT: The using-superpowers skill content is included below. It is ALREADY LOADED - do not call use_skill for it again. Use use_skill only for OTHER skills.**
58
+
59
+ ${usingSuperpowersSkill.template}
60
+
61
+ ${toolMapping}
62
+
63
+ ${skillsNamespace}
64
+ </EXTREMELY_IMPORTANT>`;
65
+ await host.client.injectContent(sessionID, content, ctx);
66
+ };
67
+
68
+ const toolMapping = `**Tool Mapping for OpenCode:**
69
+ - \`TodoWrite\` → \`todowrite\`
70
+ - \`Task\` tool with subagents → Use the \`task\` tool with \`subagent_type\`
71
+ - \`Skill\` tool → \`use_skill\`
72
+ - \`Read\`, \`Write\`, \`Edit\`, \`Bash\`, \`Glob\`, \`Grep\`, \`WebFetch\` → Use the native lowercase OpenCode tools`;
73
+
74
+ const skillsNamespace = `**Skill namespace priority:**
75
+ 1. Project: \`project:skill-name\`
76
+ 2. Claude project: \`claude-project:skill-name\`
77
+ 3. User: \`skill-name\`
78
+ 4. Claude user: \`claude-user:skill-name\`
79
+ 5. Marketplace: \`claude-plugins:skill-name\`
80
+
81
+ The first discovered match wins.`;
82
+
83
+ /**
84
+ * Render the matched-skill synthetic injection that asks the model to
85
+ * evaluate which of the matched skills (if any) it should activate.
86
+ *
87
+ * Each skill line carries a sub-line `trigger: <text>` whenever the
88
+ * skill has a non-empty `trigger`, so the model knows which user
89
+ * phrases should activate it. Skills with no trigger render exactly as
90
+ * before (`- name: description`).
91
+ */
92
+ export const formatMatchedSkillsInjection = (
93
+ matchedSkills: SkillSummary[]
94
+ ): string => {
95
+ const skillLines = matchedSkills
96
+ .map((s) => {
97
+ const head = `- ${s.name}: ${s.description}`;
98
+ const trigger = s.trigger && s.trigger.length > 0
99
+ ? `\n trigger: ${s.trigger}`
100
+ : "";
101
+ return head + trigger;
102
+ })
103
+ .join("\n");
104
+
105
+ return `<skill-evaluation-required>
106
+ SKILL EVALUATION PROCESS
107
+
108
+ The following skills may be relevant to your request:
109
+
110
+ ${skillLines}
111
+
112
+ Step 1 - EVALUATE: Determine if these skills would genuinely help
113
+ Step 2 - DECIDE: Choose which skills (if any) are actually needed
114
+ Step 3 - ACTIVATE: Call use_skill("name") for each chosen skill
115
+
116
+ IMPORTANT: This evaluation is invisible to users—they cannot see this prompt. Do NOT announce your decision. Simply activate relevant skills or proceed directly with the request.
117
+ </skill-evaluation-required>`;
118
+ };
119
+
120
+ /**
121
+ * Lightweight keyword matching to replace ML embeddings.
122
+ *
123
+ * Per-token contribution:
124
+ * - name hit = 2x
125
+ * - trigger hit = 1.5x
126
+ * - desc hit = 1x
127
+ *
128
+ * The trigger tier (1.5x) sits between name (2x) and description (1x)
129
+ * so a trigger-matched skill outranks a description-matched skill at
130
+ * the same query, but a name-matched skill still wins overall.
131
+ */
132
+ export const matchSkillsByKeyword = (userMessage: string, availableSkills: SkillSummary[]): SkillSummary[] => {
133
+ const tokens = userMessage.toLowerCase().split(/\W+/).filter(t => t.length > 2);
134
+ if (tokens.length === 0) return [];
135
+
136
+ const scored = availableSkills.map(skill => {
137
+ let score = 0;
138
+ const nameStr = skill.name.toLowerCase();
139
+ const descStr = skill.description.toLowerCase();
140
+ const triggerStr = skill.trigger?.toLowerCase() ?? "";
141
+
142
+ for (const token of tokens) {
143
+ if (nameStr.includes(token)) score += 2;
144
+ if (triggerStr.length > 0 && triggerStr.includes(token)) score += 1.5;
145
+ if (descStr.includes(token)) score += 1;
146
+ }
147
+ return { skill, score };
148
+ });
149
+
150
+ return scored
151
+ .filter(s => s.score > 0)
152
+ .sort((a, b) => b.score - a.score)
153
+ .slice(0, 5)
154
+ .map(s => s.skill);
155
+ };
156
+
157
+ // Synchronous factory to prevent any blocking during startup
158
+ export const SkillsPlugin: Plugin = async ({
159
+ client,
160
+ $,
161
+ directory,
162
+ }: PluginInput) => {
163
+ const host = createOpencodeSkillHost(client);
164
+
165
+ // Per-instance session state. Module-level state would leak across plugin
166
+ // instances (two plugins in the same process would share `setupComplete`
167
+ // and `loadedSkillsPerSession`), so these live in the factory closure.
168
+ const setupCompleteSessions = new Set<string>();
169
+ const loadedSkillsPerSession = new Map<string, Set<string>>();
170
+
171
+ const getLoadedSkills = (sessionID: string): Set<string> => {
172
+ let set = loadedSkillsPerSession.get(sessionID);
173
+ if (!set) {
174
+ set = new Set<string>();
175
+ loadedSkillsPerSession.set(sessionID, set);
176
+ }
177
+ return set;
178
+ };
179
+
180
+ /**
181
+ * Returns true when this chat.message is the first one for the session
182
+ * AND no prior message in this session already injected the available-
183
+ * skills block (which would mean the session was bootstrapped before
184
+ * this plugin instance attached).
185
+ */
186
+ const isFirstMessageSetup = async (sessionID: string): Promise<boolean> => {
187
+ if (setupCompleteSessions.has(sessionID)) return false;
188
+ try {
189
+ const existing = await client.session.messages({
190
+ path: { id: sessionID },
191
+ });
192
+ if (existing.data) {
193
+ const hasSkillsContent = existing.data.some((msg) => {
194
+ const m = msg as { parts?: unknown; info?: { parts?: unknown } };
195
+ const parts = Array.isArray(m.parts)
196
+ ? m.parts
197
+ : Array.isArray(m.info?.parts)
198
+ ? m.info.parts
199
+ : null;
200
+ if (!parts) return false;
201
+ return parts.some((part) => {
202
+ if (!isChatTextPart(part)) return false;
203
+ return typeof part.text === "string" && part.text.includes("<available-skills>");
204
+ });
205
+ });
206
+ if (hasSkillsContent) {
207
+ setupCompleteSessions.add(sessionID);
208
+ }
209
+ }
210
+ } catch (error) {
211
+ debugLog("isFirstMessageSetup: failed to read existing messages", error);
212
+ }
213
+ return !setupCompleteSessions.has(sessionID);
214
+ };
215
+
216
+ /** Mark the session as bootstrapped and inject the available-skills block. */
217
+ const injectBootstrapSkills = async (
218
+ sessionID: string,
219
+ skillsByName: Map<string, Skill>,
220
+ context: SkillHostContext,
221
+ ): Promise<void> => {
222
+ setupCompleteSessions.add(sessionID);
223
+ await maybeInjectSuperpowersBootstrap(directory, host, sessionID, context, skillsByName);
224
+ await injectSkillsList(directory, host, sessionID, context, skillsByName);
225
+ };
226
+
227
+ /** Run keyword matching on the user message and inject the matched-skill prompt. */
228
+ const handleKeywordMatch = async (
229
+ userText: string,
230
+ sessionID: string,
231
+ summaries: SkillSummary[],
232
+ context: SkillHostContext,
233
+ ): Promise<void> => {
234
+ if (!userText) return;
235
+ if (summaries.length === 0) return;
236
+
237
+ const matchedSkills = matchSkillsByKeyword(userText, summaries);
238
+ const loadedSkills = getLoadedSkills(sessionID);
239
+ const newSkills = matchedSkills.filter(s => !loadedSkills.has(s.name));
240
+ if (newSkills.length === 0) return;
241
+
242
+ const injectionText = formatMatchedSkillsInjection(newSkills);
243
+ await host.client.injectContent(sessionID, injectionText, context);
244
+ };
245
+
246
+ const tools = createSkillTools(
247
+ host,
248
+ $,
249
+ directory,
250
+ (sessionID, skillName) => {
251
+ getLoadedSkills(sessionID).add(skillName);
252
+ },
253
+ );
254
+
255
+ return {
256
+ "chat.message": async (input, output) => {
257
+ // Defensive: narrow the SDK payload through our local type so the
258
+ // plugin degrades gracefully if the SDK sends a partial / malformed
259
+ // shape. The plugin factory still returns the SDK's Hooks type, so
260
+ // the outer signature is inferred from the SDK contract.
261
+ const rawOutput = output as unknown;
262
+ if (!rawOutput || typeof rawOutput !== "object") {
263
+ debugLog("chat.message: missing or non-object output", output);
264
+ return;
265
+ }
266
+ const safeOutput = rawOutput as ChatMessageOutput;
267
+ if (typeof safeOutput.message?.sessionID !== "string") {
268
+ debugLog("chat.message: missing sessionID on output", safeOutput);
269
+ return;
270
+ }
271
+ const sessionID = safeOutput.message.sessionID;
272
+
273
+ // Single discovery per handler invocation. Both bootstrap and keyword
274
+ // matching consume the same snapshot; no cross-request caching.
275
+ const skillsByName = await discoverAllSkills(directory);
276
+ const summaries: SkillSummary[] = Array.from(skillsByName.values()).map(skill => ({
277
+ name: skill.name,
278
+ description: skill.description,
279
+ trigger: skill.trigger,
280
+ }));
281
+
282
+ const context: SkillHostContext = {
283
+ // The SDK's model field is `{ providerID, modelID }`; the local
284
+ // interface declares it as a loose `string` per the SDK-shape spec,
285
+ // so we cast here to land it on the boundary contract without
286
+ // pulling the full UserMessage type into the plugin module.
287
+ model: safeOutput.message.model as { providerID: string; modelID: string } | undefined,
288
+ agent: safeOutput.message.agent,
289
+ };
290
+
291
+ if (await isFirstMessageSetup(sessionID)) {
292
+ await injectBootstrapSkills(sessionID, skillsByName, context);
293
+ return;
294
+ }
295
+
296
+ const rawParts = Array.isArray(safeOutput.parts) ? safeOutput.parts : [];
297
+ const userText = rawParts
298
+ .flatMap((part): string[] => {
299
+ if (!isChatTextPart(part)) return [];
300
+ if (part.synthetic === true) return [];
301
+ return typeof part.text === "string" ? [part.text] : [];
302
+ })
303
+ .join("\n")
304
+ .trim();
305
+
306
+ await handleKeywordMatch(userText, sessionID, summaries, context);
307
+ },
308
+
309
+ event: async ({ event }) => {
310
+ // Defensive narrowing via local type guards; the SDK passes a
311
+ // broad Event union and we only care about two of its variants.
312
+ if (isSessionCompactedEvent(event)) {
313
+ const sessionID = event.properties.sessionID;
314
+ if (typeof sessionID !== "string") {
315
+ debugLog("event: session.compacted missing sessionID", event);
316
+ return;
317
+ }
318
+ const context = await host.client.getSessionContext(sessionID);
319
+ await maybeInjectSuperpowersBootstrap(directory, host, sessionID, context);
320
+ await injectSkillsList(directory, host, sessionID, context);
321
+ loadedSkillsPerSession.delete(sessionID);
322
+ return;
323
+ }
324
+
325
+ if (isSessionDeletedEvent(event)) {
326
+ const sessionID = event.properties.info?.id;
327
+ if (typeof sessionID !== "string") {
328
+ debugLog("event: session.deleted missing info.id", event);
329
+ return;
330
+ }
331
+ setupCompleteSessions.delete(sessionID);
332
+ loadedSkillsPerSession.delete(sessionID);
333
+ }
334
+ },
335
+
336
+ tool: {
337
+ get_available_skills: tools.GetAvailableSkills,
338
+ read_skill_file: tools.ReadSkillFile,
339
+ run_skill_script: tools.RunSkillScript,
340
+ use_skill: tools.UseSkill,
341
+ },
342
+ };
343
+ };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Local interfaces for the OpenCode hook payload shapes this plugin
3
+ * actually consumes.
4
+ *
5
+ * These intentionally mirror only the narrow slice of the SDK types
6
+ * the plugin reads — defining them locally (rather than importing the
7
+ * SDK's broad `UserMessage` / `Part` / `Event` types) keeps the
8
+ * adapter resilient to upstream shape changes and lets us narrow
9
+ * untyped runtime payloads safely.
10
+ *
11
+ * Internal only: this module is not re-exported from `src/index.ts`.
12
+ */
13
+
14
+ /** A text-bearing chat part. `text` is optional because some parts carry metadata only. */
15
+ export interface ChatTextPart {
16
+ type: "text";
17
+ text?: string;
18
+ synthetic?: boolean;
19
+ }
20
+
21
+ /** Minimal shape of the `chat.message` output payload the plugin reads. */
22
+ export interface ChatMessageOutput {
23
+ message: {
24
+ sessionID: string;
25
+ model?: string;
26
+ agent?: string;
27
+ };
28
+ parts: unknown[];
29
+ }
30
+
31
+ /** `session.compacted` event payload. */
32
+ export interface SessionCompactedEvent {
33
+ type: "session.compacted";
34
+ properties: { sessionID: string };
35
+ }
36
+
37
+ /** `session.deleted` event payload. */
38
+ export interface SessionDeletedEvent {
39
+ type: "session.deleted";
40
+ properties: { info: { id: string } };
41
+ }
42
+
43
+ /** Discriminated union of the session lifecycle events this plugin handles. */
44
+ export type SessionEvent = SessionCompactedEvent | SessionDeletedEvent;
45
+
46
+ /** Type guard: narrows `unknown` to `ChatTextPart` when `part.type === "text"`. */
47
+ export const isChatTextPart = (part: unknown): part is ChatTextPart => {
48
+ return (
49
+ typeof part === "object" &&
50
+ part !== null &&
51
+ (part as { type?: unknown }).type === "text"
52
+ );
53
+ };
54
+
55
+ /** Type guard: narrows `unknown` to `SessionCompactedEvent`. */
56
+ export const isSessionCompactedEvent = (event: unknown): event is SessionCompactedEvent => {
57
+ return (
58
+ typeof event === "object" &&
59
+ event !== null &&
60
+ (event as { type?: unknown }).type === "session.compacted"
61
+ );
62
+ };
63
+
64
+ /** Type guard: narrows `unknown` to `SessionDeletedEvent`. */
65
+ export const isSessionDeletedEvent = (event: unknown): event is SessionDeletedEvent => {
66
+ return (
67
+ typeof event === "object" &&
68
+ event !== null &&
69
+ (event as { type?: unknown }).type === "session.deleted"
70
+ );
71
+ };