opencode-agent-skills-md 1.0.1 → 1.1.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 (129) hide show
  1. package/dist/cli.mjs +770 -0
  2. package/dist/plugin.mjs +1138 -0
  3. package/dist/src/cli/config.d.ts +144 -0
  4. package/dist/src/cli/install.d.ts +33 -0
  5. package/dist/src/cli/main.d.ts +11 -0
  6. package/dist/src/cli/real-fs.d.ts +6 -0
  7. package/dist/src/cli/status.d.ts +34 -0
  8. package/dist/src/cli/uninstall.d.ts +22 -0
  9. package/dist/src/host.d.ts +51 -0
  10. package/dist/src/index.d.ts +17 -0
  11. package/dist/src/plugin.d.ts +35 -0
  12. package/dist/src/sdk.d.ts +51 -0
  13. package/dist/src/tools.d.ts +86 -0
  14. package/package.json +48 -18
  15. package/.beads/.local_version +0 -1
  16. package/.beads/README.md +0 -81
  17. package/.beads/config.yaml +0 -61
  18. package/.beads/deletions.jsonl +0 -1
  19. package/.beads/issues.jsonl +0 -64
  20. package/.beads/metadata.json +0 -4
  21. package/.gitattributes +0 -3
  22. package/.github/CODEOWNERS +0 -1
  23. package/.github/copilot-instructions.md +0 -78
  24. package/.github/dependabot.yml +0 -13
  25. package/.github/workflows/release.yml +0 -51
  26. package/.opencode/command/test-compaction.md +0 -9
  27. package/.opencode/command/test-find-skills.md +0 -7
  28. package/.opencode/command/test-read-skill-file.md +0 -14
  29. package/.opencode/command/test-run-skill-script.md +0 -13
  30. package/.opencode/command/test-skills.md +0 -14
  31. package/.opencode/command/test-use-skill.md +0 -10
  32. package/.opencode/skills/git-helper/SKILL.md +0 -65
  33. package/.opencode/skills/test-skill/SKILL.md +0 -43
  34. package/.opencode/skills/test-skill/example-config.json +0 -16
  35. package/.opencode/skills/test-skill/helper-docs.md +0 -29
  36. package/.opencode/skills/test-skill/scripts/echo-args +0 -14
  37. package/.opencode/skills/test-skill/scripts/greet +0 -6
  38. package/AGENTS.md +0 -43
  39. package/CHANGELOG.md +0 -178
  40. package/Justfile +0 -39
  41. package/README.md +0 -220
  42. package/openspec/changes/archive/2026-06-14-skills-core-decouple/specs/core-decoupling/spec.md +0 -74
  43. package/openspec/changes/archive/2026-06-14-skills-core-decouple/tasks.md +0 -64
  44. package/openspec/changes/archive/2026-06-14-skills-core-decouple/verify-report.md +0 -75
  45. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/apply-progress.md +0 -136
  46. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/archive-report.md +0 -77
  47. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/design.md +0 -89
  48. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/proposal.md +0 -65
  49. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/specs/core-decoupling/spec.md +0 -77
  50. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/tasks.md +0 -65
  51. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/verify-report.md +0 -165
  52. package/openspec/specs/core-decoupling/spec.md +0 -110
  53. package/packages/core/package.json +0 -30
  54. package/packages/core/src/content.d.ts +0 -16
  55. package/packages/core/src/content.ts +0 -30
  56. package/packages/core/src/debug.ts +0 -16
  57. package/packages/core/src/discovery.d.ts +0 -86
  58. package/packages/core/src/discovery.ts +0 -257
  59. package/packages/core/src/index.d.ts +0 -20
  60. package/packages/core/src/index.ts +0 -55
  61. package/packages/core/src/match.d.ts +0 -19
  62. package/packages/core/src/match.ts +0 -75
  63. package/packages/core/src/parse.d.ts +0 -26
  64. package/packages/core/src/parse.ts +0 -141
  65. package/packages/core/src/scripts.d.ts +0 -17
  66. package/packages/core/src/scripts.ts +0 -79
  67. package/packages/core/src/search.d.ts +0 -83
  68. package/packages/core/src/search.ts +0 -188
  69. package/packages/core/src/types.d.ts +0 -82
  70. package/packages/core/src/types.ts +0 -131
  71. package/packages/core/src/walk.ts +0 -109
  72. package/packages/core/tests/agnostic.test.ts +0 -346
  73. package/packages/core/tests/content.test.ts +0 -65
  74. package/packages/core/tests/discovery.test.ts +0 -370
  75. package/packages/core/tests/package-boundary.test.ts +0 -310
  76. package/packages/core/tests/parse-trigger.test.ts +0 -282
  77. package/packages/core/tests/search.test.ts +0 -374
  78. package/packages/core/tests/subpath.test.ts +0 -87
  79. package/packages/core/tsconfig.json +0 -10
  80. package/packages/opencode-agent-skills-md/package.json +0 -66
  81. package/packages/opencode-agent-skills-md/rolldown.config.js +0 -47
  82. package/packages/opencode-agent-skills-md/tests/cli-commands.test.ts +0 -1423
  83. package/packages/opencode-agent-skills-md/tests/e2e/startup-smoke.test.ts +0 -66
  84. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.claude/skills/claude-user-only-skill/SKILL.md +0 -8
  85. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/shared-skill/SKILL.md +0 -8
  86. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/user-only-skill/SKILL.md +0 -8
  87. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.claude/skills/claude-project-only-skill/SKILL.md +0 -8
  88. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/go-tester/SKILL.md +0 -12
  89. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/nested/team/nested-skill/SKILL.md +0 -8
  90. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/rust-tester/SKILL.md +0 -11
  91. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/SKILL.md +0 -8
  92. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/bin/echo.sh +0 -2
  93. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/docs/reference.md +0 -1
  94. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/shared-skill/SKILL.md +0 -8
  95. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/using-superpowers/SKILL.md +0 -8
  96. package/packages/opencode-agent-skills-md/tests/integration/helpers/mock-opencode.ts +0 -114
  97. package/packages/opencode-agent-skills-md/tests/integration/plugin.test.ts +0 -316
  98. package/packages/opencode-agent-skills-md/tests/integration/skill-discovery.test.ts +0 -315
  99. package/packages/opencode-agent-skills-md/tests/opencode/host.test.ts +0 -179
  100. package/packages/opencode-agent-skills-md/tests/opencode/plugin.test.ts +0 -551
  101. package/packages/opencode-agent-skills-md/tests/opencode/subpath.test.ts +0 -66
  102. package/packages/opencode-agent-skills-md/tests/opencode/tools.test.ts +0 -213
  103. package/packages/opencode-agent-skills-md/tests/package-boundary.test.ts +0 -345
  104. package/packages/opencode-agent-skills-md/tests/tools-security.test.ts +0 -72
  105. package/packages/opencode-agent-skills-md/tsconfig.build.json +0 -11
  106. package/packages/opencode-agent-skills-md/tsconfig.json +0 -10
  107. package/plans/001-ci-gate.md +0 -177
  108. package/plans/002-is-path-safe.md +0 -243
  109. package/plans/003-escape-prompts.md +0 -310
  110. package/plans/004-test-security-paths.md +0 -228
  111. package/plans/005-stop-swallowing-errors.md +0 -246
  112. package/plans/006-preserve-jsonc-commas.md +0 -144
  113. package/plans/007-write-before-purge.md +0 -144
  114. package/plans/008-reuse-walkdir-for-list-skill-files.md +0 -164
  115. package/plans/README.md +0 -43
  116. package/pnpm-workspace.yaml +0 -6
  117. package/tests/workspace.test.ts +0 -367
  118. package/tsconfig.json +0 -15
  119. /package/{packages/opencode-agent-skills-md/src → src}/cli/config.ts +0 -0
  120. /package/{packages/opencode-agent-skills-md/src → src}/cli/install.ts +0 -0
  121. /package/{packages/opencode-agent-skills-md/src → src}/cli/main.ts +0 -0
  122. /package/{packages/opencode-agent-skills-md/src → src}/cli/real-fs.ts +0 -0
  123. /package/{packages/opencode-agent-skills-md/src → src}/cli/status.ts +0 -0
  124. /package/{packages/opencode-agent-skills-md/src → src}/cli/uninstall.ts +0 -0
  125. /package/{packages/opencode-agent-skills-md/src → src}/host.ts +0 -0
  126. /package/{packages/opencode-agent-skills-md/src → src}/index.ts +0 -0
  127. /package/{packages/opencode-agent-skills-md/src → src}/plugin.ts +0 -0
  128. /package/{packages/opencode-agent-skills-md/src → src}/sdk.ts +0 -0
  129. /package/{packages/opencode-agent-skills-md/src → src}/tools.ts +0 -0
@@ -0,0 +1,1138 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import YAML from "yaml";
4
+ import { homedir } from "node:os";
5
+ import { tool } from "@opencode-ai/plugin";
6
+ //#region ../core/src/debug.ts
7
+ /**
8
+ * Debug-gated logging for bare `catch {}` blocks.
9
+ *
10
+ * Bare catches stay silent by default so malformed payloads and parse
11
+ * errors never surface as user-visible noise. Setting
12
+ * `OPENCODE_AGENT_SKILLS_DEBUG=1` makes the diagnostic context appear on
13
+ * stderr so developers can trace why a fallback fired.
14
+ *
15
+ * The env var is checked on every call (not cached at module load) so
16
+ * tests can toggle it without re-importing the module.
17
+ */
18
+ const debugLog = (...args) => {
19
+ if (!process.env.OPENCODE_AGENT_SKILLS_DEBUG) return;
20
+ console.error("[opencode-agent-skills-md]", ...args);
21
+ };
22
+ //#endregion
23
+ //#region ../core/src/walk.ts
24
+ /**
25
+ * Internal shared directory walker.
26
+ *
27
+ * Walks `baseDir` recursively up to `maxDepth` and invokes `visitor` for each
28
+ * non-skipped entry. The walker owns the traversal rules shared by skill
29
+ * discovery (`findSkillsRecursive`) and script enumeration (`findScripts`):
30
+ *
31
+ * - hidden directories (names starting with `.`) are skipped
32
+ * - `node_modules` and `.git` are skipped unconditionally
33
+ * - per-entry errors (read, stat, or visitor throw) are isolated so a
34
+ * single broken symlink or throwing visitor does not abort the walk
35
+ *
36
+ * Callers that need extra skip sets (e.g. `__pycache__`, `.venv` for the
37
+ * scripts layer) pass them via {@link WalkOptions.skipDirs}. The walker does
38
+ * NOT re-export its skip rules — each caller decides what extra paths are
39
+ * not its business to enter.
40
+ *
41
+ * The walker is internal to the core package: it is intentionally NOT
42
+ * re-exported from `packages/core/src/index.ts`. Callers import it
43
+ * directly from `./walk`.
44
+ */
45
+ /** Directories the walker skips on every invocation, regardless of caller. */
46
+ const ALWAYS_SKIP = new Set(["node_modules", ".git"]);
47
+ /**
48
+ * Walk `baseDir` recursively, invoking `visitor` for each non-skipped entry.
49
+ *
50
+ * The visitor is called depth-first with the entry's `parentPath` already
51
+ * populated (Node 20.12+), so callers can build absolute paths via
52
+ * `path.join(entry.parentPath, entry.name)` without restating the parent.
53
+ *
54
+ * `currentDepth` is the depth of the directory containing the entry:
55
+ * `0` for entries inside `baseDir`, `1` for entries inside its subdirs,
56
+ * and so on. Entries beyond `maxDepth` are never visited.
57
+ *
58
+ * The visitor may be sync or async; the walker awaits it so any state
59
+ * the visitor records (e.g. "skip this subtree") is visible to the
60
+ * subsequent recursive step.
61
+ *
62
+ * A missing or unreadable `baseDir` is not an error: the walker simply
63
+ * yields nothing. Per-entry failures (read, stat, or a throwing visitor)
64
+ * are likewise isolated to the offending entry.
65
+ */
66
+ const walkDir = async (baseDir, maxDepth, visitor, options = {}) => {
67
+ const skipDirs = options.skipDirs;
68
+ await walk(baseDir, 0, maxDepth, visitor, skipDirs);
69
+ };
70
+ const walk = async (dir, depth, maxDepth, visitor, skipDirs) => {
71
+ if (depth > maxDepth) return;
72
+ let entries;
73
+ try {
74
+ entries = await fs.readdir(dir, { withFileTypes: true });
75
+ } catch {
76
+ return;
77
+ }
78
+ for (const entry of entries) {
79
+ if (entry.name.startsWith(".")) continue;
80
+ if (ALWAYS_SKIP.has(entry.name)) continue;
81
+ if (skipDirs?.has(entry.name)) continue;
82
+ try {
83
+ await visitor(entry, depth);
84
+ } catch {
85
+ continue;
86
+ }
87
+ if (entry.isDirectory()) await walk(path.join(dir, entry.name), depth + 1, maxDepth, visitor, skipDirs);
88
+ }
89
+ };
90
+ //#endregion
91
+ //#region ../core/src/scripts.ts
92
+ /**
93
+ * Script discovery and path-safety helpers.
94
+ *
95
+ * Pure functions: filesystem reads only, no host dependencies.
96
+ */
97
+ /**
98
+ * Directory names the script walker skips on top of the unconditional
99
+ * `node_modules` / `.git` / hidden-dir rules owned by {@link walkDir}.
100
+ * These are common dependency / cache directories that never host skill
101
+ * scripts and would otherwise inflate the file scan.
102
+ */
103
+ const SCRIPT_SKIP_DIRS = new Set([
104
+ "__pycache__",
105
+ ".venv",
106
+ "venv",
107
+ ".tox",
108
+ ".nox"
109
+ ]);
110
+ /**
111
+ * Recursively find executable scripts in a skill's directory.
112
+ *
113
+ * Traversal is delegated to the shared {@link walkDir} utility, which owns
114
+ * hidden-dir / `node_modules` / `.git` skip rules and per-entry error
115
+ * isolation. The visitor checks each file entry's executable bit (the
116
+ * `0o111` mode mask) and pushes a `Script` record only for files that
117
+ * qualify.
118
+ *
119
+ * Output is sorted by `relativePath` so callers see a stable order
120
+ * regardless of the underlying `readdir` enumeration order.
121
+ */
122
+ const findScripts = async (skillPath, maxDepth = 10) => {
123
+ const scripts = [];
124
+ await walkDir(skillPath, maxDepth, async (entry) => {
125
+ if (!entry.isFile()) return;
126
+ const fullPath = path.join(entry.parentPath, entry.name);
127
+ const relPath = path.relative(skillPath, fullPath);
128
+ let stats;
129
+ try {
130
+ stats = await fs.stat(fullPath);
131
+ } catch {
132
+ return;
133
+ }
134
+ if (stats.mode & 73) scripts.push({
135
+ relativePath: relPath,
136
+ absolutePath: fullPath
137
+ });
138
+ }, { skipDirs: SCRIPT_SKIP_DIRS });
139
+ return scripts.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
140
+ };
141
+ /**
142
+ * Check if a path is safely within a base directory (no escape via .. or symlink).
143
+ *
144
+ * Uses fs.realpath to canonicalize both paths before comparing, which closes
145
+ * the symlink-escape attack: a symlink inside the skill directory that points
146
+ * outside will have its real path resolved and fail the prefix check.
147
+ *
148
+ * @returns Promise<boolean> — true if the resolved real path is within basePath
149
+ */
150
+ const isPathSafe = async (basePath, requestedPath) => {
151
+ const resolved = path.resolve(basePath, requestedPath);
152
+ try {
153
+ const resolvedReal = await fs.realpath(resolved);
154
+ const baseReal = await fs.realpath(basePath);
155
+ return resolvedReal.startsWith(baseReal + path.sep) || resolvedReal === baseReal;
156
+ } catch {
157
+ return false;
158
+ }
159
+ };
160
+ //#endregion
161
+ //#region ../core/src/parse.ts
162
+ /**
163
+ * YAML frontmatter parsing and skill frontmatter validation.
164
+ *
165
+ * Pure functions: no I/O, no host dependencies. The script-discovery step
166
+ * that follows parsing is delegated to `core/scripts.ts`.
167
+ */
168
+ /**
169
+ * Parse YAML frontmatter using the yaml library with safe options.
170
+ * Uses strict schema to prevent code execution from malicious YAML.
171
+ * Handles all YAML 1.2 features including multi-line strings (| and >).
172
+ *
173
+ * Two distinct failure modes:
174
+ * - Empty frontmatter (blank / whitespace-only input) returns `{}`
175
+ * without touching the parser. This is a valid zero-field case.
176
+ * - Malformed YAML (real syntax error) is caught and logged via the
177
+ * `debugLog` helper; the function still returns `{}` so callers see
178
+ * the same graceful fallback as before.
179
+ */
180
+ const parseYamlFrontmatter = (text) => {
181
+ if (text.trim().length === 0) return {};
182
+ try {
183
+ const result = YAML.parse(text, {
184
+ schema: "core",
185
+ maxAliasCount: 100
186
+ });
187
+ return typeof result === "object" && result !== null ? result : {};
188
+ } catch (error) {
189
+ debugLog("parseYamlFrontmatter: malformed YAML", error);
190
+ return {};
191
+ }
192
+ };
193
+ const NAME_REGEX = /^[\p{Ll}\p{N}-]+$/u;
194
+ const validateFrontmatter = (obj) => {
195
+ if (typeof obj !== "object" || obj === null) return null;
196
+ const o = obj;
197
+ if (typeof o.name !== "string" || !NAME_REGEX.test(o.name) || o.name.length === 0) return null;
198
+ if (typeof o.description !== "string" || o.description.length === 0) return null;
199
+ if (o.trigger !== void 0 && typeof o.trigger !== "string") return null;
200
+ if (o.license !== void 0 && typeof o.license !== "string") return null;
201
+ if (o["allowed-tools"] !== void 0 && !Array.isArray(o["allowed-tools"])) return null;
202
+ if (o.metadata !== void 0 && typeof o.metadata !== "object") return null;
203
+ const frontmatter = {
204
+ name: o.name,
205
+ description: o.description
206
+ };
207
+ if (o.trigger !== void 0) frontmatter.trigger = o.trigger;
208
+ if (o.license !== void 0) frontmatter.license = o.license;
209
+ if (o["allowed-tools"] !== void 0) frontmatter["allowed-tools"] = o["allowed-tools"];
210
+ if (o.metadata !== void 0) frontmatter.metadata = o.metadata;
211
+ return frontmatter;
212
+ };
213
+ /**
214
+ * Parse a SKILL.md file and validate its frontmatter.
215
+ * Returns null if parsing fails (with error logging).
216
+ */
217
+ const parseSkillFile = async (skillPath, relativePath, label) => {
218
+ const content = await fs.readFile(skillPath, "utf-8").catch((error) => {
219
+ debugLog("parseSkillFile: cannot read", skillPath, error);
220
+ return null;
221
+ });
222
+ if (!content) return null;
223
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
224
+ if (!frontmatterMatch?.[1] || !frontmatterMatch[2]) return null;
225
+ const frontmatterText = frontmatterMatch[1];
226
+ const skillContent = frontmatterMatch[2].trim();
227
+ let frontmatterObj;
228
+ try {
229
+ frontmatterObj = parseYamlFrontmatter(frontmatterText);
230
+ } catch {
231
+ return null;
232
+ }
233
+ const frontmatter = validateFrontmatter(frontmatterObj);
234
+ if (!frontmatter) return null;
235
+ const skillDirPath = path.dirname(skillPath);
236
+ const scripts = await findScripts(skillDirPath);
237
+ const rawNamespace = frontmatter.metadata?.namespace;
238
+ const namespace = typeof rawNamespace === "string" ? rawNamespace : void 0;
239
+ const rawTags = frontmatter.metadata?.tags;
240
+ const tags = Array.isArray(rawTags) ? rawTags.filter((t) => typeof t === "string") : [];
241
+ return {
242
+ name: frontmatter.name,
243
+ description: frontmatter.description,
244
+ trigger: frontmatter.trigger,
245
+ path: skillDirPath,
246
+ relativePath,
247
+ namespace,
248
+ tags,
249
+ label,
250
+ scripts,
251
+ template: skillContent
252
+ };
253
+ };
254
+ //#endregion
255
+ //#region ../core/src/discovery.ts
256
+ /**
257
+ * Skill discovery across filesystem roots.
258
+ *
259
+ * The core never hard-codes a host's directory layout. Callers pass the list
260
+ * of discovery roots; the default `getDefaultOpencodeRoots` reproduces the
261
+ * legacy OpenCode priority order. PR2 will call `discoverAllSkills` from the
262
+ * OpenCode host adapter with the same default.
263
+ */
264
+ /**
265
+ * Check if a file exists in a directory and return path info.
266
+ *
267
+ * @param directory - Directory to check
268
+ * @param relativePath - Relative path to use in result (caller-specific)
269
+ * @param filename - Name of file to look for (e.g., 'SKILL.md')
270
+ * @returns Path info if file exists, null otherwise
271
+ */
272
+ const findFile = async (directory, relativePath, filename) => {
273
+ const filePath = path.join(directory, filename);
274
+ try {
275
+ await fs.stat(filePath);
276
+ return {
277
+ filePath,
278
+ relativePath
279
+ };
280
+ } catch {
281
+ return null;
282
+ }
283
+ };
284
+ /**
285
+ * Recursively find SKILL.md files in a directory.
286
+ *
287
+ * The base directory itself is checked first: a SKILL.md placed at the root
288
+ * of a discovery root is returned with `relativePath = ""` and wins the
289
+ * shadowing tie-break over same-name skills in subdirectories (first found
290
+ * wins in `discoverAllSkills`).
291
+ *
292
+ * The traversal is delegated to the shared {@link walkDir} utility, which
293
+ * owns hidden-dir / `node_modules` / `.git` skip rules and per-entry error
294
+ * isolation. The visitor only checks each directory entry for SKILL.md and
295
+ * records the labeled result; recursion and skip semantics are the walker's
296
+ * job, not this function's.
297
+ *
298
+ * Output is sorted by `relativePath` so callers see a stable order across
299
+ * runs regardless of the underlying `readdir` enumeration order.
300
+ */
301
+ const findSkillsRecursive = async (baseDir, label, maxDepth = 3) => {
302
+ const results = [];
303
+ try {
304
+ await fs.access(baseDir);
305
+ const rootFile = await findFile(baseDir, "", "SKILL.md");
306
+ if (rootFile) results.push({
307
+ ...rootFile,
308
+ label
309
+ });
310
+ await walkDir(baseDir, maxDepth, async (entry) => {
311
+ if (!entry.isDirectory()) return;
312
+ const fullPath = path.join(entry.parentPath, entry.name);
313
+ const found = await findFile(fullPath, path.relative(baseDir, fullPath), "SKILL.md");
314
+ if (found) results.push({
315
+ ...found,
316
+ label
317
+ });
318
+ });
319
+ } catch (error) {
320
+ debugLog("findSkillsRecursive: cannot access baseDir", baseDir, error);
321
+ }
322
+ return results.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
323
+ };
324
+ /**
325
+ * Default recursion depth for the four priority discovery roots.
326
+ *
327
+ * Pre-refactor commit `c2d8e74` used `maxDepth: 1` for the Claude-side
328
+ * roots; commit `12de52a` ("fix(core): unify maxDepth to 3 across all
329
+ * discovery roots") widened them deliberately so deeply-nested Claude
330
+ * skills surface. The regression net in
331
+ * `tests/integration/skill-discovery.test.ts` pins this value so a
332
+ * future narrowing breaks loudly.
333
+ */
334
+ const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
335
+ /**
336
+ * Default discovery roots matching the pre-refactor OpenCode priority order
337
+ * (see commit `c2d8e74`, `src/skills.ts#discoverAllSkills`):
338
+ * 1. .opencode/skills/ (project - OpenCode)
339
+ * 2. .claude/skills/ (project - Claude)
340
+ * 3. ~/.config/opencode/skills/ (user - OpenCode)
341
+ * 4. ~/.claude/skills/ (user - Claude)
342
+ *
343
+ * No shadowing - unique names only. First match wins, duplicates are warned.
344
+ */
345
+ const getDefaultOpencodeRoots = (directory) => {
346
+ return [
347
+ {
348
+ path: path.join(directory, ".opencode", "skills"),
349
+ label: "project",
350
+ maxDepth: DEFAULT_DISCOVERY_MAX_DEPTH
351
+ },
352
+ {
353
+ path: path.join(directory, ".claude", "skills"),
354
+ label: "claude-project",
355
+ maxDepth: DEFAULT_DISCOVERY_MAX_DEPTH
356
+ },
357
+ {
358
+ path: path.join(homedir(), ".config", "opencode", "skills"),
359
+ label: "user",
360
+ maxDepth: DEFAULT_DISCOVERY_MAX_DEPTH
361
+ },
362
+ {
363
+ path: path.join(homedir(), ".claude", "skills"),
364
+ label: "claude-user",
365
+ maxDepth: DEFAULT_DISCOVERY_MAX_DEPTH
366
+ }
367
+ ];
368
+ };
369
+ /**
370
+ * Default callback for shadowed skill names. Emits a `console.warn` that
371
+ * identifies the surviving (existing) skill and the duplicate that was
372
+ * skipped. Hosts can override by passing `onDuplicate` to `discoverAllSkills`.
373
+ *
374
+ * @internal - exported for testing
375
+ */
376
+ const defaultOnDuplicate = (existing, duplicate) => {
377
+ console.warn(`Skill name conflict: '${existing.name}' at ${existing.path} shadows duplicate at ${duplicate.path}`);
378
+ };
379
+ /**
380
+ * Discover all skills from the provided roots.
381
+ *
382
+ * @param directory - Project directory (used to build the default roots).
383
+ * @param roots - Discovery roots. Defaults to the OpenCode priority order
384
+ * via `getDefaultOpencodeRoots(directory)`. Hosts pass an explicit list to
385
+ * override the layout.
386
+ * @param onDuplicate - Optional callback invoked when two roots produce a
387
+ * skill with the same `name`. Defaults to `console.warn` via
388
+ * `defaultOnDuplicate`. The first-discovered skill wins; the duplicate
389
+ * (second one encountered) is passed to the callback but never stored.
390
+ */
391
+ const discoverAllSkills = async (directory, roots = getDefaultOpencodeRoots(directory), onDuplicate = defaultOnDuplicate) => {
392
+ const allResults = [];
393
+ for (const { path: baseDir, label, maxDepth } of roots) allResults.push(...await findSkillsRecursive(baseDir, label, maxDepth));
394
+ const skillsByName = /* @__PURE__ */ new Map();
395
+ for (const { filePath, relativePath, label } of allResults) {
396
+ const skill = await parseSkillFile(filePath, relativePath, label);
397
+ if (!skill) continue;
398
+ if (skillsByName.has(skill.name)) {
399
+ onDuplicate(skillsByName.get(skill.name), skill);
400
+ continue;
401
+ }
402
+ skillsByName.set(skill.name, skill);
403
+ }
404
+ return skillsByName;
405
+ };
406
+ /**
407
+ * Resolve a skill by name, handling namespace prefixes.
408
+ * Supports: "skill-name", "project:skill-name", "user:skill-name", etc.
409
+ */
410
+ const resolveSkill = (skillName, skillsByName) => {
411
+ if (skillName.includes(":")) {
412
+ const [namespace, name] = skillName.split(":");
413
+ for (const skill of skillsByName.values()) if (skill.name === name && (skill.label === namespace || skill.namespace === namespace)) return skill;
414
+ return null;
415
+ }
416
+ return skillsByName.get(skillName) || null;
417
+ };
418
+ /**
419
+ * Recursively list all files in a directory, returning relative paths.
420
+ * Excludes SKILL.md since it's already loaded as the main content.
421
+ * Applies the same skip rules as walkDir (hidden dirs, node_modules, .git).
422
+ */
423
+ const listSkillFiles = async (skillPath, maxDepth = 3) => {
424
+ const files = [];
425
+ const walk = async (dir, depth) => {
426
+ let entries;
427
+ try {
428
+ entries = await fs.readdir(dir, { withFileTypes: true });
429
+ } catch {
430
+ return;
431
+ }
432
+ for (const entry of entries) {
433
+ if (entry.name.startsWith(".")) continue;
434
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
435
+ const fullPath = path.join(dir, entry.name);
436
+ const relPath = path.relative(skillPath, fullPath);
437
+ if (entry.name === "SKILL.md") continue;
438
+ if (entry.isDirectory()) {
439
+ if (depth < maxDepth) await walk(fullPath, depth + 1);
440
+ } else files.push(relPath);
441
+ }
442
+ };
443
+ await walk(skillPath, 0);
444
+ return files.sort();
445
+ };
446
+ //#endregion
447
+ //#region ../core/src/match.ts
448
+ /**
449
+ * Fuzzy string matching helpers used to suggest the closest skill or script
450
+ * name when a user request does not match exactly.
451
+ *
452
+ * Pure functions: no I/O, no host dependencies.
453
+ */
454
+ /**
455
+ * Calculate Levenshtein edit distance between two strings.
456
+ * Used for fuzzy matching suggestions when skill/script names are not found.
457
+ * @internal - exported for testing
458
+ */
459
+ const levenshtein = (a, b) => {
460
+ const m = a.length;
461
+ const n = b.length;
462
+ const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_, j) => i || j));
463
+ for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++) dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] !== b[j - 1] ? 1 : 0));
464
+ return dp[m][n];
465
+ };
466
+ /**
467
+ * Find the closest matching string from a list of candidates.
468
+ * Uses combined scoring: prefix match (strongest), substring match, then Levenshtein distance.
469
+ * Returns the best match if similarity is above 0.4 threshold, otherwise null.
470
+ * @internal - exported for testing
471
+ */
472
+ const findClosestMatch = (input, candidates) => {
473
+ if (candidates.length === 0) return null;
474
+ const inputLower = input.toLowerCase();
475
+ let bestMatch = null;
476
+ let bestScore = 0;
477
+ for (const candidate of candidates) {
478
+ const candidateLower = candidate.toLowerCase();
479
+ let score = 0;
480
+ if (candidateLower.startsWith(inputLower)) {
481
+ score = .9 + inputLower.length / candidateLower.length * .1;
482
+ const nextChar = candidateLower[inputLower.length];
483
+ if (nextChar && /[-_/.]/.test(nextChar)) score += .05;
484
+ } else if (inputLower.startsWith(candidateLower)) score = .8;
485
+ else if (candidateLower.includes(inputLower) || inputLower.includes(candidateLower)) score = .7;
486
+ else score = 1 - levenshtein(inputLower, candidateLower) / Math.max(inputLower.length, candidateLower.length);
487
+ if (score > bestScore) {
488
+ bestScore = score;
489
+ bestMatch = candidate;
490
+ }
491
+ }
492
+ return bestScore >= .4 ? bestMatch : null;
493
+ };
494
+ //#endregion
495
+ //#region ../core/src/content.ts
496
+ /**
497
+ * Format a list of skills as the inner bullet block used inside the
498
+ * `<available-skills>` synthetic injection.
499
+ */
500
+ const formatSkillListing = (skills) => {
501
+ return skills.map((s) => `- ${s.name}: ${s.description}`).join("\n");
502
+ };
503
+ /**
504
+ * Render the full `<available-skills>...</available-skills>` block that the
505
+ * host injects into a session on startup and after compaction.
506
+ */
507
+ const renderAvailableSkillsBlock = (skills) => {
508
+ return `<available-skills>
509
+ Use the use_skill, read_skill_file, run_skill_script, and get_available_skills tools to work with skills.
510
+
511
+ ${formatSkillListing(skills)}
512
+ </available-skills>`;
513
+ };
514
+ //#endregion
515
+ //#region ../core/src/search.ts
516
+ /**
517
+ * Tokenize a free-text query into lowercase, non-empty tokens.
518
+ *
519
+ * Whitespace is the only separator. Empty tokens (from leading or
520
+ * trailing whitespace) are dropped so the caller never has to filter
521
+ * them out before scoring.
522
+ */
523
+ const tokenize = (query) => {
524
+ return query.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
525
+ };
526
+ /**
527
+ * Check whether a skill matches at least one of the supplied keywords
528
+ * against its `metadata.tags`. OR semantics: a single tag hit is enough
529
+ * to keep the skill in the result set. An empty keyword list is a no-op
530
+ * (every skill passes).
531
+ */
532
+ const keywordMatch = (skill, keywords) => {
533
+ if (keywords.length === 0) return true;
534
+ const tags = skill.tags ?? [];
535
+ return keywords.some((kw) => tags.includes(kw));
536
+ };
537
+ /** Compute a Levenshtein-derived similarity in the 0..1 range. */
538
+ const similarity = (a, b) => {
539
+ if (a.length === 0 && b.length === 0) return 1;
540
+ return 1 - levenshtein(a, b) / Math.max(a.length, b.length);
541
+ };
542
+ /** Best description-token fuzzy similarity for a single query token. */
543
+ const bestDescriptionTokenSim = (descLower, token) => {
544
+ let best = 0;
545
+ for (const dt of descLower.split(/\s+/)) {
546
+ if (dt.length === 0) continue;
547
+ const sim = similarity(dt, token);
548
+ if (sim > best) best = sim;
549
+ }
550
+ return best;
551
+ };
552
+ /**
553
+ * Score a skill against a list of pre-tokenized, lowercase query
554
+ * tokens. Returns 0 when the skill has no chance of matching (used to
555
+ * drop it from the result). Positive scores compare higher = more
556
+ * relevant.
557
+ *
558
+ * The "description contains ALL tokens" tier (50) is applied as a
559
+ * per-token lift, not a flat bonus, so the ordering
560
+ * name exact > name prefix > name fuzzy > trigger > desc-all > desc-any
561
+ * is preserved even after the `max + 0.1 * sum` multi-token formula.
562
+ *
563
+ * The `trigger` tier (60) is a flat per-token contribution: any token
564
+ * that appears as a case-insensitive substring of `skill.trigger` adds
565
+ * 60 to that token's contribution. Trigger is sandwiched between the
566
+ * name tiers (≥70) and the description tiers (≤50) so the invariant
567
+ * name > trigger > description
568
+ * holds for single-token queries.
569
+ */
570
+ const scoreSkill = (skill, tokens) => {
571
+ if (tokens.length === 0) return 0;
572
+ const name = skill.name.toLowerCase();
573
+ const desc = skill.description.toLowerCase();
574
+ const trigger = skill.trigger?.toLowerCase() ?? "";
575
+ const descTier = tokens.every((t) => desc.includes(t)) ? 50 : 30;
576
+ const perToken = [];
577
+ for (const token of tokens) {
578
+ let s = 0;
579
+ if (name === token) s = Math.max(s, 100);
580
+ else if (name.startsWith(token)) s = Math.max(s, 90);
581
+ else {
582
+ const nameSim = similarity(name, token);
583
+ if (nameSim > .4) s = Math.max(s, 70 * nameSim);
584
+ }
585
+ if (trigger.length > 0 && trigger.includes(token)) s = Math.max(s, 60);
586
+ if (desc.includes(token)) s = Math.max(s, descTier);
587
+ else {
588
+ const descSim = bestDescriptionTokenSim(desc, token);
589
+ if (descSim > .4) s = Math.max(s, Math.min(60, 60 * descSim));
590
+ }
591
+ if (s === 0) return 0;
592
+ perToken.push(s);
593
+ }
594
+ return Math.max(...perToken) + .1 * perToken.reduce((a, b) => a + b, 0);
595
+ };
596
+ /**
597
+ * Filter, score, and rank skills against a free-text query and an
598
+ * optional tag-keyword filter. The keyword filter applies first
599
+ * (it is the cheaper predicate and can only narrow the candidate
600
+ * set), then the query is tokenized and scored.
601
+ *
602
+ * Returns a new array sorted by score descending. Skills with a score
603
+ * of 0 are dropped. When the query is empty AND no keywords are
604
+ * supplied, the input list is returned unchanged (the caller can use
605
+ * the unranked discovery for browsing).
606
+ */
607
+ const searchSkills = (skills, query, keywords) => {
608
+ let candidates = skills;
609
+ if (keywords && keywords.length > 0) candidates = candidates.filter((s) => keywordMatch(s, keywords));
610
+ if (!query || query.trim() === "") return candidates;
611
+ const tokens = tokenize(query);
612
+ if (tokens.length === 0) return candidates;
613
+ return candidates.map((skill) => ({
614
+ skill,
615
+ score: scoreSkill(skill, tokens)
616
+ })).filter(({ score }) => score > 0).sort((a, b) => b.score - a.score).map(({ skill }) => skill);
617
+ };
618
+ //#endregion
619
+ //#region src/host.ts
620
+ /**
621
+ * OpenCode host adapter.
622
+ *
623
+ * Wraps the OpenCode SDK client (`PluginInput["client"]`) and provides
624
+ * a bounded surface for content injection, session context, and filesystem
625
+ * access consumed by the plugin and skill tools.
626
+ *
627
+ * The boundary contracts (`SkillHostClient`, `SkillHostSession`,
628
+ * `SkillHostContext`) are declared in the `opencode-agent-skills-md-core`
629
+ * package per spec R2; this module IMPLEMENTS them over the OpenCode SDK
630
+ * client plus `node:fs/promises`. No other package may declare a concrete
631
+ * implementation — the plugin package owns exactly one.
632
+ */
633
+ /**
634
+ * Build an `OpencodeSkillHost` over the supplied OpenCode SDK client.
635
+ *
636
+ * The host is the only place in the codebase that touches the SDK's
637
+ * `client.session.prompt` and `client.session.messages` methods.
638
+ */
639
+ const createOpencodeSkillHost = (client) => {
640
+ const skillClient = {
641
+ async injectContent(sessionID, text, context) {
642
+ await client.session.prompt({
643
+ path: { id: sessionID },
644
+ body: {
645
+ noReply: true,
646
+ model: context?.model,
647
+ agent: context?.agent,
648
+ parts: [{
649
+ type: "text",
650
+ text,
651
+ synthetic: true
652
+ }]
653
+ }
654
+ });
655
+ },
656
+ async getSessionContext(sessionID) {
657
+ try {
658
+ const response = await client.session.messages({
659
+ path: { id: sessionID },
660
+ query: { limit: 50 }
661
+ });
662
+ if (response.data) {
663
+ for (const msg of response.data) if (msg.info.role === "user" && "model" in msg.info && msg.info.model) return {
664
+ model: msg.info.model,
665
+ agent: msg.info.agent
666
+ };
667
+ }
668
+ } catch (error) {
669
+ debugLog("getSessionContext: session lookup failed", sessionID, error?.name);
670
+ }
671
+ },
672
+ async readFile(filePath) {
673
+ return fs.readFile(filePath, "utf-8");
674
+ },
675
+ async readdir(dirPath) {
676
+ return fs.readdir(dirPath);
677
+ }
678
+ };
679
+ const session = (id) => ({ id });
680
+ return {
681
+ client: skillClient,
682
+ session
683
+ };
684
+ };
685
+ //#endregion
686
+ //#region src/tools.ts
687
+ /** Escape XML special characters to prevent wrapper breakout. */
688
+ const escapeXml = (s) => {
689
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
690
+ };
691
+ /** Wrap a shell argument in single quotes and escape embedded single quotes (Bourne-shell pattern). */
692
+ const escapeShellArg = (arg) => {
693
+ return "'" + arg.replace(/'/g, "'\\''") + "'";
694
+ };
695
+ /**
696
+ * Tool translation guide for skills written for Claude Code.
697
+ * Injected into skill content to help the AI use OpenCode equivalents.
698
+ */
699
+ const toolTranslation = `<tool-translation>
700
+ This skill may reference Claude Code tools. Use OpenCode equivalents:
701
+ - TodoWrite/TodoRead -> todowrite/todoread
702
+ - Task (subagents) -> task tool with subagent_type parameter
703
+ - Skill tool -> use_skill tool
704
+ - Read/Write/Edit/Bash/Glob/Grep/WebFetch -> lowercase (read/write/edit/bash/glob/grep/webfetch)
705
+ </tool-translation>`;
706
+ /**
707
+ * Build the four skill tool factories bound to the host, shell, and
708
+ * project directory. The returned object is what the plugin registers
709
+ * under its `tool` hook.
710
+ *
711
+ * The optional `onSkillLoaded` callback is threaded through to `UseSkill`
712
+ * so a successful load can update host session state (e.g., the loaded-
713
+ * skill set used to suppress duplicate match injection in `chat.message`).
714
+ */
715
+ const createSkillTools = (host, $, directory, onSkillLoaded) => {
716
+ return {
717
+ GetAvailableSkills: GetAvailableSkills(directory),
718
+ ReadSkillFile: ReadSkillFile(directory, host),
719
+ RunSkillScript: RunSkillScript(directory, $),
720
+ UseSkill: UseSkill(directory, host, onSkillLoaded)
721
+ };
722
+ };
723
+ /**
724
+ * Resolve a skill by name, or return a "not found" message with a
725
+ * close-match suggestion.
726
+ *
727
+ * Centralizes the duplicated resolve-then-suggest pattern that
728
+ * `use_skill`, `read_skill_file`, and `run_skill_script` all need.
729
+ * Returning a single `string` keeps the call site trivial:
730
+ *
731
+ * - skill found → returns `skill.name`
732
+ * - skill missing, suggestion → `Skill "<name>" not found. Did you mean "<suggestion>"?`
733
+ * - skill missing, no hint → `Skill "<name>" not found. Use get_available_skills to list available skills.`
734
+ *
735
+ * Not-found messages always start with the literal `Skill "` so callers
736
+ * can detect them with `result.startsWith('Skill "')`. The skill-name
737
+ * regex (`/^[\p{Ll}\p{N}-]+$/u`) forbids uppercase initial characters,
738
+ * so a legitimate skill name can never collide with that prefix.
739
+ *
740
+ * The helper does its own discovery; callers that need the `Skill` object
741
+ * (rather than just its name) re-resolve via a second `discoverAllSkills`
742
+ * call. Discovery is cheap (file-listing only) and the OS-level metadata
743
+ * cache absorbs most of the cost.
744
+ */
745
+ const resolveSkillOrSuggest = async (directory, skillName) => {
746
+ const skillsByName = await discoverAllSkills(directory);
747
+ const skill = resolveSkill(skillName, skillsByName);
748
+ if (skill) return skill.name;
749
+ const suggestion = findClosestMatch(skillName, Array.from(skillsByName.values()).map((s) => s.name));
750
+ if (suggestion) return `Skill "${skillName}" not found. Did you mean "${suggestion}"?`;
751
+ return `Skill "${skillName}" not found. Use get_available_skills to list available skills.`;
752
+ };
753
+ const GetAvailableSkills = (directory) => {
754
+ return tool({
755
+ description: "Get available skills with their descriptions. Optionally filter by free-text query and/or tag keywords.",
756
+ args: {
757
+ query: tool.schema.string().optional().describe("Free-text search query. Matched against skill name and description; relevance-ranked."),
758
+ keywords: tool.schema.array(tool.schema.string()).optional().describe("Optional list of tag keywords. Only skills whose metadata.tags include at least one entry are returned.")
759
+ },
760
+ async execute(args) {
761
+ const skillsByName = await discoverAllSkills(directory);
762
+ const allSkills = Array.from(skillsByName.values());
763
+ const matched = searchSkills(allSkills, args.query ?? "", args.keywords);
764
+ if (matched.length === 0) {
765
+ if (args.query) {
766
+ const allSkillNames = allSkills.map((s) => s.name);
767
+ const suggestion = findClosestMatch(args.query, allSkillNames);
768
+ if (suggestion) return `No skills found matching "${args.query}". Did you mean "${suggestion}"?`;
769
+ }
770
+ return "No skills found matching your query.";
771
+ }
772
+ return matched.map((s) => {
773
+ const scripts = s.scripts.length > 0 ? ` [scripts: ${s.scripts.map((sc) => sc.relativePath).join(", ")}]` : "";
774
+ const trigger = s.trigger && s.trigger.length > 0 ? `\n trigger: ${s.trigger}` : "";
775
+ return `${s.name} (${s.label})\n ${s.description}${trigger}${scripts}`;
776
+ }).join("\n\n");
777
+ }
778
+ });
779
+ };
780
+ const ReadSkillFile = (directory, host) => {
781
+ return tool({
782
+ description: "Read a supporting file from a skill's directory (docs, examples, configs).",
783
+ args: {
784
+ skill: tool.schema.string().describe("Name of the skill"),
785
+ filename: tool.schema.string().describe("File to read, relative to skill directory (e.g., 'anthropic-best-practices.md', 'scripts/helper.sh')")
786
+ },
787
+ async execute(args, ctx) {
788
+ const resolved = await resolveSkillOrSuggest(directory, args.skill);
789
+ if (resolved.startsWith("Skill \"")) return resolved;
790
+ const skill = (await discoverAllSkills(directory)).get(resolved);
791
+ if (!skill) return `Skill "${args.skill}" not found. Use get_available_skills to list available skills.`;
792
+ if (!await isPathSafe(skill.path, args.filename)) return `Invalid path: cannot access files outside skill directory.`;
793
+ const filePath = path.join(skill.path, args.filename);
794
+ try {
795
+ const content = await host.client.readFile(filePath);
796
+ const wrappedContent = `<skill-file skill="${escapeXml(skill.name)}" file="${escapeXml(args.filename)}">
797
+ <metadata>
798
+ <directory>${escapeXml(skill.path)}</directory>
799
+ </metadata>
800
+
801
+ <content>
802
+ ${content}
803
+ </content>
804
+ </skill-file>`;
805
+ const context = await host.client.getSessionContext(ctx.sessionID);
806
+ await host.client.injectContent(ctx.sessionID, wrappedContent, context);
807
+ return `File "${args.filename}" from skill "${skill.name}" loaded.`;
808
+ } catch {
809
+ try {
810
+ const files = await host.client.readdir(skill.path);
811
+ return `File "${args.filename}" not found. Available files: ${files.join(", ")}`;
812
+ } catch {
813
+ return `File "${args.filename}" not found in skill "${skill.name}".`;
814
+ }
815
+ }
816
+ }
817
+ });
818
+ };
819
+ const RunSkillScript = (directory, $) => {
820
+ return tool({
821
+ description: "Execute a script from a skill's directory. Scripts are run with the skill directory as CWD.",
822
+ args: {
823
+ skill: tool.schema.string().describe("Name of the skill"),
824
+ script: tool.schema.string().describe("Relative path to the script (e.g., 'build.sh', 'tools/deploy.sh')"),
825
+ arguments: tool.schema.array(tool.schema.string()).optional().describe("Arguments to pass to the script")
826
+ },
827
+ async execute(args) {
828
+ const resolved = await resolveSkillOrSuggest(directory, args.skill);
829
+ if (resolved.startsWith("Skill \"")) return resolved;
830
+ const skill = (await discoverAllSkills(directory)).get(resolved);
831
+ if (!skill) return `Skill "${args.skill}" not found. Use get_available_skills to list available skills.`;
832
+ const script = skill.scripts.find((s) => s.relativePath === args.script);
833
+ if (!script) {
834
+ const scriptPaths = skill.scripts.map((s) => s.relativePath);
835
+ const suggestion = findClosestMatch(args.script, scriptPaths);
836
+ if (suggestion) return `Script "${args.script}" not found in skill "${skill.name}". Did you mean "${suggestion}"?`;
837
+ const available = scriptPaths.join(", ") || "none";
838
+ return `Script "${args.script}" not found in skill "${skill.name}". Available scripts: ${available}`;
839
+ }
840
+ try {
841
+ $.cwd(skill.path);
842
+ const scriptArgs = (args.arguments || []).map(escapeShellArg).join(" ");
843
+ return await $`${script.absolutePath} ${scriptArgs}`.text();
844
+ } catch (error) {
845
+ if (error instanceof Error && "exitCode" in error) {
846
+ const shellError = error;
847
+ const stderr = shellError.stderr?.toString() || "";
848
+ const stdout = shellError.stdout?.toString() || "";
849
+ return `Script failed (exit ${shellError.exitCode}): ${stderr || stdout || shellError.message}`;
850
+ }
851
+ if (error instanceof Error) return `Script failed: ${error.message}`;
852
+ return `Script failed: ${String(error)}`;
853
+ }
854
+ }
855
+ });
856
+ };
857
+ const UseSkill = (directory, host, onSkillLoaded) => {
858
+ return tool({
859
+ description: "Load a skill's SKILL.md content into context. Skills contain proven workflows, techniques, and patterns.",
860
+ args: { skill: tool.schema.string().describe("Name of the skill (e.g., 'brainstorming', 'project:my-skill', 'user:my-skill')") },
861
+ async execute(args, ctx) {
862
+ const resolved = await resolveSkillOrSuggest(directory, args.skill);
863
+ if (resolved.startsWith("Skill \"")) return resolved;
864
+ const skill = (await discoverAllSkills(directory)).get(resolved);
865
+ if (!skill) return `Skill "${args.skill}" not found. Use get_available_skills to list available skills.`;
866
+ const skillFiles = await listSkillFiles(skill.path);
867
+ const scriptsXml = skill.scripts.length > 0 ? `\n <scripts>\n${skill.scripts.map((s) => ` <script>${escapeXml(s.relativePath)}<\/script>`).join("\n")}\n <\/scripts>` : "";
868
+ const filesXml = skillFiles.length > 0 ? `\n <files>\n${skillFiles.map((f) => ` <file>${escapeXml(f)}</file>`).join("\n")}\n </files>` : "";
869
+ const skillContent = `<skill name="${escapeXml(skill.name)}">
870
+ <metadata>
871
+ <source>${escapeXml(skill.label)}</source>
872
+ <directory>${escapeXml(skill.path)}</directory>${scriptsXml}${filesXml}
873
+ </metadata>
874
+
875
+ ${toolTranslation}
876
+
877
+ <content>
878
+ ${skill.template}
879
+ </content>
880
+ </skill>`;
881
+ const context = await host.client.getSessionContext(ctx.sessionID);
882
+ await host.client.injectContent(ctx.sessionID, skillContent, context);
883
+ onSkillLoaded?.(ctx.sessionID, skill.name);
884
+ const scriptInfo = skill.scripts.length > 0 ? `\nAvailable scripts: ${skill.scripts.map((s) => s.relativePath).join(", ")}` : "";
885
+ const filesInfo = skillFiles.length > 0 ? `\nAvailable files: ${skillFiles.join(", ")}` : "";
886
+ return `Skill "${skill.name}" loaded.${scriptInfo}${filesInfo}`;
887
+ }
888
+ });
889
+ };
890
+ //#endregion
891
+ //#region src/sdk.ts
892
+ /** Type guard: narrows `unknown` to `ChatTextPart` when `part.type === "text"`. */
893
+ const isChatTextPart = (part) => {
894
+ return typeof part === "object" && part !== null && part.type === "text";
895
+ };
896
+ /** Type guard: narrows `unknown` to `SessionCompactedEvent`. */
897
+ const isSessionCompactedEvent = (event) => {
898
+ return typeof event === "object" && event !== null && event.type === "session.compacted";
899
+ };
900
+ /** Type guard: narrows `unknown` to `SessionDeletedEvent`. */
901
+ const isSessionDeletedEvent = (event) => {
902
+ return typeof event === "object" && event !== null && event.type === "session.deleted";
903
+ };
904
+ //#endregion
905
+ //#region src/plugin.ts
906
+ const injectSkillsList = async (directory, host, sessionID, context, precomputed) => {
907
+ const skillsByName = precomputed ?? await discoverAllSkills(directory);
908
+ const skills = Array.from(skillsByName.values());
909
+ if (skills.length === 0) return;
910
+ await host.client.injectContent(sessionID, renderAvailableSkillsBlock(skills), context);
911
+ };
912
+ const maybeInjectSuperpowersBootstrap = async (directory, host, sessionID, context, precomputed) => {
913
+ if (process.env.OPENCODE_AGENT_SKILLS_SUPERPOWERS_MODE !== "true") return;
914
+ const usingSuperpowersSkill = (precomputed ?? await discoverAllSkills(directory)).get("using-superpowers");
915
+ if (!usingSuperpowersSkill) return;
916
+ const ctx = context ?? await host.client.getSessionContext(sessionID);
917
+ const content = `<EXTREMELY_IMPORTANT>
918
+ You have superpowers.
919
+
920
+ **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.**
921
+
922
+ ${usingSuperpowersSkill.template}
923
+
924
+ ${toolMapping}
925
+
926
+ ${skillsNamespace}
927
+ </EXTREMELY_IMPORTANT>`;
928
+ await host.client.injectContent(sessionID, content, ctx);
929
+ };
930
+ const toolMapping = `**Tool Mapping for OpenCode:**
931
+ - \`TodoWrite\` → \`todowrite\`
932
+ - \`Task\` tool with subagents → Use the \`task\` tool with \`subagent_type\`
933
+ - \`Skill\` tool → \`use_skill\`
934
+ - \`Read\`, \`Write\`, \`Edit\`, \`Bash\`, \`Glob\`, \`Grep\`, \`WebFetch\` → Use the native lowercase OpenCode tools`;
935
+ const skillsNamespace = `**Skill namespace priority:**
936
+ 1. Project: \`project:skill-name\`
937
+ 2. Claude project: \`claude-project:skill-name\`
938
+ 3. User: \`skill-name\`
939
+ 4. Claude user: \`claude-user:skill-name\`
940
+ 5. Marketplace: \`claude-plugins:skill-name\`
941
+
942
+ The first discovered match wins.`;
943
+ /**
944
+ * Render the matched-skill synthetic injection that asks the model to
945
+ * evaluate which of the matched skills (if any) it should activate.
946
+ *
947
+ * Each skill line carries a sub-line `trigger: <text>` whenever the
948
+ * skill has a non-empty `trigger`, so the model knows which user
949
+ * phrases should activate it. Skills with no trigger render exactly as
950
+ * before (`- name: description`).
951
+ */
952
+ const formatMatchedSkillsInjection = (matchedSkills) => {
953
+ return `<skill-evaluation-required>
954
+ SKILL EVALUATION PROCESS
955
+
956
+ The following skills may be relevant to your request:
957
+
958
+ ${matchedSkills.map((s) => {
959
+ return `- ${s.name}: ${s.description}` + (s.trigger && s.trigger.length > 0 ? `\n trigger: ${s.trigger}` : "");
960
+ }).join("\n")}
961
+
962
+ Step 1 - EVALUATE: Determine if these skills would genuinely help
963
+ Step 2 - DECIDE: Choose which skills (if any) are actually needed
964
+ Step 3 - ACTIVATE: Call use_skill("name") for each chosen skill
965
+
966
+ 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.
967
+ </skill-evaluation-required>`;
968
+ };
969
+ /**
970
+ * Lightweight keyword matching to replace ML embeddings.
971
+ *
972
+ * Per-token contribution:
973
+ * - name hit = 2x
974
+ * - trigger hit = 1.5x
975
+ * - desc hit = 1x
976
+ *
977
+ * The trigger tier (1.5x) sits between name (2x) and description (1x)
978
+ * so a trigger-matched skill outranks a description-matched skill at
979
+ * the same query, but a name-matched skill still wins overall.
980
+ */
981
+ const matchSkillsByKeyword = (userMessage, availableSkills) => {
982
+ const tokens = userMessage.toLowerCase().split(/\W+/).filter((t) => t.length > 2);
983
+ if (tokens.length === 0) return [];
984
+ return availableSkills.map((skill) => {
985
+ let score = 0;
986
+ const nameStr = skill.name.toLowerCase();
987
+ const descStr = skill.description.toLowerCase();
988
+ const triggerStr = skill.trigger?.toLowerCase() ?? "";
989
+ for (const token of tokens) {
990
+ if (nameStr.includes(token)) score += 2;
991
+ if (triggerStr.length > 0 && triggerStr.includes(token)) score += 1.5;
992
+ if (descStr.includes(token)) score += 1;
993
+ }
994
+ return {
995
+ skill,
996
+ score
997
+ };
998
+ }).filter((s) => s.score > 0).sort((a, b) => b.score - a.score).slice(0, 5).map((s) => s.skill);
999
+ };
1000
+ const SkillsPlugin = async ({ client, $, directory }) => {
1001
+ const host = createOpencodeSkillHost(client);
1002
+ const setupCompleteSessions = /* @__PURE__ */ new Set();
1003
+ const loadedSkillsPerSession = /* @__PURE__ */ new Map();
1004
+ const getLoadedSkills = (sessionID) => {
1005
+ let set = loadedSkillsPerSession.get(sessionID);
1006
+ if (!set) {
1007
+ set = /* @__PURE__ */ new Set();
1008
+ loadedSkillsPerSession.set(sessionID, set);
1009
+ }
1010
+ return set;
1011
+ };
1012
+ /**
1013
+ * Returns true when this chat.message is the first one for the session
1014
+ * AND no prior message in this session already injected the available-
1015
+ * skills block (which would mean the session was bootstrapped before
1016
+ * this plugin instance attached).
1017
+ */
1018
+ const isFirstMessageSetup = async (sessionID) => {
1019
+ if (setupCompleteSessions.has(sessionID)) return false;
1020
+ try {
1021
+ const existing = await client.session.messages({ path: { id: sessionID } });
1022
+ if (existing.data) {
1023
+ if (existing.data.some((msg) => {
1024
+ const m = msg;
1025
+ const parts = Array.isArray(m.parts) ? m.parts : Array.isArray(m.info?.parts) ? m.info.parts : null;
1026
+ if (!parts) return false;
1027
+ return parts.some((part) => {
1028
+ if (!isChatTextPart(part)) return false;
1029
+ return typeof part.text === "string" && part.text.includes("<available-skills>");
1030
+ });
1031
+ })) setupCompleteSessions.add(sessionID);
1032
+ }
1033
+ } catch (error) {
1034
+ debugLog("isFirstMessageSetup: failed to read existing messages", error);
1035
+ }
1036
+ return !setupCompleteSessions.has(sessionID);
1037
+ };
1038
+ /** Mark the session as bootstrapped and inject the available-skills block. */
1039
+ const injectBootstrapSkills = async (sessionID, skillsByName, context) => {
1040
+ setupCompleteSessions.add(sessionID);
1041
+ await maybeInjectSuperpowersBootstrap(directory, host, sessionID, context, skillsByName);
1042
+ await injectSkillsList(directory, host, sessionID, context, skillsByName);
1043
+ };
1044
+ /** Run keyword matching on the user message and inject the matched-skill prompt. */
1045
+ const handleKeywordMatch = async (userText, sessionID, summaries, context) => {
1046
+ if (!userText) return;
1047
+ if (summaries.length === 0) return;
1048
+ const matchedSkills = matchSkillsByKeyword(userText, summaries);
1049
+ const loadedSkills = getLoadedSkills(sessionID);
1050
+ const newSkills = matchedSkills.filter((s) => !loadedSkills.has(s.name));
1051
+ if (newSkills.length === 0) return;
1052
+ const injectionText = formatMatchedSkillsInjection(newSkills);
1053
+ await host.client.injectContent(sessionID, injectionText, context);
1054
+ };
1055
+ const tools = createSkillTools(host, $, directory, (sessionID, skillName) => {
1056
+ getLoadedSkills(sessionID).add(skillName);
1057
+ });
1058
+ return {
1059
+ "chat.message": async (input, output) => {
1060
+ const rawOutput = output;
1061
+ if (!rawOutput || typeof rawOutput !== "object") {
1062
+ debugLog("chat.message: missing or non-object output", output);
1063
+ return;
1064
+ }
1065
+ const safeOutput = rawOutput;
1066
+ if (typeof safeOutput.message?.sessionID !== "string") {
1067
+ debugLog("chat.message: missing sessionID on output", safeOutput);
1068
+ return;
1069
+ }
1070
+ const sessionID = safeOutput.message.sessionID;
1071
+ const skillsByName = await discoverAllSkills(directory);
1072
+ const summaries = Array.from(skillsByName.values()).map((skill) => ({
1073
+ name: skill.name,
1074
+ description: skill.description,
1075
+ trigger: skill.trigger
1076
+ }));
1077
+ const context = {
1078
+ model: safeOutput.message.model,
1079
+ agent: safeOutput.message.agent
1080
+ };
1081
+ if (await isFirstMessageSetup(sessionID)) {
1082
+ await injectBootstrapSkills(sessionID, skillsByName, context);
1083
+ return;
1084
+ }
1085
+ await handleKeywordMatch((Array.isArray(safeOutput.parts) ? safeOutput.parts : []).flatMap((part) => {
1086
+ if (!isChatTextPart(part)) return [];
1087
+ if (part.synthetic === true) return [];
1088
+ return typeof part.text === "string" ? [part.text] : [];
1089
+ }).join("\n").trim(), sessionID, summaries, context);
1090
+ },
1091
+ event: async ({ event }) => {
1092
+ if (isSessionCompactedEvent(event)) {
1093
+ const sessionID = event.properties.sessionID;
1094
+ if (typeof sessionID !== "string") {
1095
+ debugLog("event: session.compacted missing sessionID", event);
1096
+ return;
1097
+ }
1098
+ const context = await host.client.getSessionContext(sessionID);
1099
+ await maybeInjectSuperpowersBootstrap(directory, host, sessionID, context);
1100
+ await injectSkillsList(directory, host, sessionID, context);
1101
+ loadedSkillsPerSession.delete(sessionID);
1102
+ return;
1103
+ }
1104
+ if (isSessionDeletedEvent(event)) {
1105
+ const sessionID = event.properties.info?.id;
1106
+ if (typeof sessionID !== "string") {
1107
+ debugLog("event: session.deleted missing info.id", event);
1108
+ return;
1109
+ }
1110
+ setupCompleteSessions.delete(sessionID);
1111
+ loadedSkillsPerSession.delete(sessionID);
1112
+ }
1113
+ },
1114
+ tool: {
1115
+ get_available_skills: tools.GetAvailableSkills,
1116
+ read_skill_file: tools.ReadSkillFile,
1117
+ run_skill_script: tools.RunSkillScript,
1118
+ use_skill: tools.UseSkill
1119
+ }
1120
+ };
1121
+ };
1122
+ //#endregion
1123
+ //#region src/index.ts
1124
+ /**
1125
+ * OpenCode host adapter — root entrypoint.
1126
+ *
1127
+ * Re-exports the plugin factory as the package's default export so the
1128
+ * `rolldown` build can target this file directly. The root `src/plugin.ts`
1129
+ * shim forwards to this module to preserve the legacy import path while
1130
+ * `package.json` still resolves `dist/plugin.mjs` to the package main.
1131
+ *
1132
+ * Public surface:
1133
+ * - default export: SkillsPlugin (the @opencode-ai/plugin Plugin factory)
1134
+ * - named exports: SkillsPlugin, createOpencodeSkillHost
1135
+ */
1136
+ var src_default = SkillsPlugin;
1137
+ //#endregion
1138
+ export { SkillsPlugin, createOpencodeSkillHost, src_default as default };