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,315 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import * as path from "node:path";
5
+ import { afterEach, beforeEach, describe, test } from "node:test";
6
+ import {
7
+ createFixtureWorkspace,
8
+ type FixtureWorkspace,
9
+ } from "./helpers/mock-opencode";
10
+
11
+ /**
12
+ * PR 3 of `trigger-aware-skill-discovery` — External Skill Normalization.
13
+ *
14
+ * The 21 SKILL.md files under `~/.claude/skills/` MUST match the canonical
15
+ * frontmatter shape defined in `sdd/trigger-aware-skill-discovery/spec`:
16
+ * - `name` matches `/^[\p{Ll}\p{N}-]+$/u` (lowercase alnum + hyphens)
17
+ * - file begins with `^---\n` on line 1 (no leading blank line)
18
+ * - `description` is non-empty
19
+ * - existing `trigger` text is preserved
20
+ *
21
+ * Before PR 3:
22
+ * - `docsify-docs-editor` had an invalid uppercase name → dropped silently
23
+ * - `good-comments` started with a blank line → dropped silently
24
+ * - the other 19 were discoverable but missing `license` / `metadata`
25
+ *
26
+ * This test is the safety net for the normalization pass. It loads the
27
+ * real `~/.claude/skills/` directory (override with
28
+ * `OPENCODE_AGENT_SKILLS_TEST_CLAUDE_USER` to point at a fixture copy)
29
+ * and asserts every skill is discoverable AND its trigger text is preserved.
30
+ *
31
+ * The expected count is 21 because that is the inventory the spec says
32
+ * must be normalized. If the count changes, the test forces a review of
33
+ * the canonical-skill list.
34
+ */
35
+ const CLAUDE_USER_SKILLS_DIR = process.env.OPENCODE_AGENT_SKILLS_TEST_CLAUDE_USER
36
+ ?? path.join(homedir(), ".claude", "skills");
37
+
38
+ const EXPECTED_SKILL_COUNT = 21;
39
+ const NAME_REGEX = /^[\p{Ll}\p{N}-]+$/u;
40
+
41
+ /**
42
+ * Expected `trigger` text per skill, captured from the frontmatter before
43
+ * normalization. The values are the string the parser sees after YAML
44
+ * unescaping (so single-quoted YAML strings come back without their quotes).
45
+ * The blockers (`docsify-docs-editor`, `good-comments`) DID have triggers
46
+ * even before the fix — they were just invisible to discovery.
47
+ */
48
+ const EXPECTED_TRIGGERS: Record<string, string> = {
49
+ "ast-grep": "ast-grep, structural search, AST pattern, find code pattern, refactor with AST, code search",
50
+ "code-review": "User requests a code review via phrases such as 'review this code', 'check my PR', 'look over this function', or 'give me feedback on this implementation'.",
51
+ "conceptual-grill": "User mentions \"grill me with no codebase\", \"stress-test this concept\", or requests architectural validation without providing code context.",
52
+ "diagnose": "User says \"diagnose this\", \"debug this\", reports a bug, says something is broken/throwing/failing, or describes a performance regression.",
53
+ "doc-comments": "Activated explicitly by requests for documentation or type hints, or proactively when provided code lacks metadata, uses weak typing ('any'), or features complex, undocumented logic.",
54
+ "docsify-docs-editor": "Activation occurs when a user initiates a documentation audit, scaffolding request, or precise edit targeting Docsify markdown files (README, setup, api-reference, architecture, quick-reference, tutorials).",
55
+ "element-reference": "User writes @<file>::<element> patterns for precise code references",
56
+ "good-comments": "Triggered when a user provides source code and requests documentation, comment improvement, or explanation of business logic, edge cases, and architectural decisions.",
57
+ "grill-me": "Activated when the user explicitly requests 'grill me', asks for a rigorous design review, or needs help resolving complex architectural decision trees.",
58
+ "handoff": "The user requests to pause, switch tasks, pass work to another agent, or explicitly asks for a handoff.",
59
+ "mermaid-diagram-generator": "Activated when a user explicitly requests a diagram, chart, graph, or visual representation of processes, data, or system structures.",
60
+ "opencode-choice": "User asks 'should I use a command, skill, or agent', 'when to use command vs agent vs skill', or is unsure which OpenCode extension fits their need.",
61
+ "production-readiness-review": "User requests a \"production readiness review\" or asks if the codebase is \"production-ready\".",
62
+ "readme-refactor": "User requests to update, refactor, rewrite, or polish a README file.",
63
+ "refactor-skill": "User provides a raw prompt, workflow, or unstructured instructions and requests a modular, production-ready skill format.",
64
+ "rename-refactoring": "Activates upon explicit user request to clean up variable names, or automatically as a self-correction step when detecting ambiguous or contextually mismatched identifiers (e.g., 'data', 'temp', 'obj').",
65
+ "review": "review a branch, PR, work-in-progress changes, or \"review since X\"",
66
+ "ubiquitous-language": "User wants to define domain terms, build a glossary, harden terminology, create a ubiquitous language, or mentions \"domain model\" or \"DDD\".",
67
+ "write-a-skill": "User wants to create, write, or build a new agent skill.",
68
+ };
69
+
70
+ describe("External skill normalization (PR 3)", () => {
71
+ test(`discovers exactly ${EXPECTED_SKILL_COUNT} skills under the user-level Claude skills directory`, async () => {
72
+ const { discoverAllSkills } = await import("opencode-agent-skills-md-core");
73
+ const skills = await discoverAllSkills("/tmp", [
74
+ { path: CLAUDE_USER_SKILLS_DIR, label: "claude-user", maxDepth: 3 },
75
+ ]);
76
+ assert.equal(
77
+ skills.size,
78
+ EXPECTED_SKILL_COUNT,
79
+ `expected ${EXPECTED_SKILL_COUNT} discoverable skills, got ${skills.size}. ` +
80
+ `Missing: ${Array.from({ length: EXPECTED_SKILL_COUNT }, (_, i) => `?`).join(",")}. ` +
81
+ `Found: ${Array.from(skills.keys()).join(", ")}`,
82
+ );
83
+ });
84
+
85
+ test("every discovered skill has a name matching the lowercase kebab-case regex", async () => {
86
+ const { discoverAllSkills } = await import("opencode-agent-skills-md-core");
87
+ const skills = await discoverAllSkills("/tmp", [
88
+ { path: CLAUDE_USER_SKILLS_DIR, label: "claude-user", maxDepth: 3 },
89
+ ]);
90
+ for (const [name, skill] of skills) {
91
+ assert.match(
92
+ name,
93
+ NAME_REGEX,
94
+ `skill name "${name}" must match ${NAME_REGEX} (skill at ${skill.path})`,
95
+ );
96
+ assert.equal(skill.name, name, `Map key and skill.name must agree for ${name}`);
97
+ }
98
+ });
99
+
100
+ test("every discovered skill has a non-empty description", async () => {
101
+ const { discoverAllSkills } = await import("opencode-agent-skills-md-core");
102
+ const skills = await discoverAllSkills("/tmp", [
103
+ { path: CLAUDE_USER_SKILLS_DIR, label: "claude-user", maxDepth: 3 },
104
+ ]);
105
+ for (const [name, skill] of skills) {
106
+ assert.ok(
107
+ typeof skill.description === "string" && skill.description.length > 0,
108
+ `${name} must have a non-empty description`,
109
+ );
110
+ }
111
+ });
112
+
113
+ test("docsify-docs-editor is discoverable (was invisible due to uppercase name)", async () => {
114
+ const { discoverAllSkills } = await import("opencode-agent-skills-md-core");
115
+ const skills = await discoverAllSkills("/tmp", [
116
+ { path: CLAUDE_USER_SKILLS_DIR, label: "claude-user", maxDepth: 3 },
117
+ ]);
118
+ assert.ok(
119
+ skills.has("docsify-docs-editor"),
120
+ "docsify-docs-editor should be discoverable after the name normalization",
121
+ );
122
+ });
123
+
124
+ test("good-comments is discoverable (was invisible due to leading blank line)", async () => {
125
+ const { discoverAllSkills } = await import("opencode-agent-skills-md-core");
126
+ const skills = await discoverAllSkills("/tmp", [
127
+ { path: CLAUDE_USER_SKILLS_DIR, label: "claude-user", maxDepth: 3 },
128
+ ]);
129
+ assert.ok(
130
+ skills.has("good-comments"),
131
+ "good-comments should be discoverable after removing the leading blank line",
132
+ );
133
+ });
134
+
135
+ test("every skill that previously had a trigger still has the same trigger after normalization", async () => {
136
+ const { discoverAllSkills } = await import("opencode-agent-skills-md-core");
137
+ const skills = await discoverAllSkills("/tmp", [
138
+ { path: CLAUDE_USER_SKILLS_DIR, label: "claude-user", maxDepth: 3 },
139
+ ]);
140
+ for (const [name, expectedTrigger] of Object.entries(EXPECTED_TRIGGERS)) {
141
+ const skill = skills.get(name);
142
+ assert.ok(skill, `${name} must be discoverable to check its trigger`);
143
+ assert.equal(
144
+ skill!.trigger,
145
+ expectedTrigger,
146
+ `${name} trigger text must be preserved verbatim by the normalization pass`,
147
+ );
148
+ }
149
+ });
150
+ });
151
+
152
+ /**
153
+ * Regression coverage for the discovery-breadth leg of
154
+ * `fix-skill-loading-regression` (PR 2). The pre-refactor discovery
155
+ * walked four standard locations in priority order:
156
+ *
157
+ * 1. .opencode/skills/ (project)
158
+ * 2. .claude/skills/ (project)
159
+ * 3. ~/.config/opencode/skills/ (user)
160
+ * 4. ~/.claude/skills/ (user)
161
+ *
162
+ * Each test uses the shared fixture workspace (which sets HOME to a
163
+ * temp dir so `homedir()` resolves to the user-side fixtures) and
164
+ * exercises a single layer of the spec R5 contract.
165
+ */
166
+ describe("discovery breadth — four-location priority (PR 2 R5)", () => {
167
+ let workspace: FixtureWorkspace;
168
+
169
+ beforeEach(async () => {
170
+ workspace = await createFixtureWorkspace();
171
+ });
172
+
173
+ afterEach(async () => {
174
+ if (workspace) {
175
+ await workspace.cleanup();
176
+ }
177
+ });
178
+
179
+ test("discoverAllSkills surfaces skills from all four priority locations", async () => {
180
+ const { discoverAllSkills } = await import("opencode-agent-skills-md-core");
181
+ const skills = await discoverAllSkills(workspace.projectRoot);
182
+
183
+ // .opencode/skills/ (project)
184
+ assert.ok(skills.has("scripted-skill"), "project .opencode/skills scripted-skill");
185
+ assert.ok(skills.has("using-superpowers"), "project .opencode/skills using-superpowers");
186
+ assert.ok(skills.has("nested-skill"), "project nested-skill under .opencode/skills");
187
+
188
+ // .claude/skills/ (project) — covered by claude-project-only-skill fixture.
189
+ assert.ok(
190
+ skills.has("claude-project-only-skill"),
191
+ "project .claude/skills claude-project-only-skill must surface",
192
+ );
193
+ assert.equal(
194
+ skills.get("claude-project-only-skill")?.label,
195
+ "claude-project",
196
+ "claude-project-only-skill must carry the claude-project label",
197
+ );
198
+
199
+ // ~/.config/opencode/skills/ (user) — covered by user-only-skill.
200
+ assert.ok(skills.has("user-only-skill"), "user ~/.config/opencode/skills user-only-skill");
201
+ assert.equal(
202
+ skills.get("user-only-skill")?.label,
203
+ "user",
204
+ "user-only-skill must carry the user label",
205
+ );
206
+
207
+ // ~/.claude/skills/ (user) — covered by claude-user-only-skill.
208
+ assert.ok(
209
+ skills.has("claude-user-only-skill"),
210
+ "user ~/.claude/skills claude-user-only-skill must surface",
211
+ );
212
+ assert.equal(
213
+ skills.get("claude-user-only-skill")?.label,
214
+ "claude-user",
215
+ "claude-user-only-skill must carry the claude-user label",
216
+ );
217
+ });
218
+
219
+ test("first-match-wins: project skill shadows the same-named user skill", async () => {
220
+ const { discoverAllSkills } = await import("opencode-agent-skills-md-core");
221
+ const skills = await discoverAllSkills(workspace.projectRoot);
222
+
223
+ assert.equal(
224
+ skills.get("shared-skill")?.label,
225
+ "project",
226
+ "shared-skill under .opencode/skills must shadow ~/.config/opencode/skills/shared-skill",
227
+ );
228
+ assert.equal(
229
+ skills.get("shared-skill")?.description,
230
+ "project version wins over user fixture",
231
+ "first-found description wins",
232
+ );
233
+ });
234
+
235
+ test("duplicate discovery emits the default shadow warning", async () => {
236
+ const { discoverAllSkills } = await import("opencode-agent-skills-md-core");
237
+ const warnings: string[] = [];
238
+ const original = console.warn;
239
+ console.warn = (msg: string) => warnings.push(msg);
240
+ try {
241
+ await discoverAllSkills(workspace.projectRoot);
242
+ } finally {
243
+ console.warn = original;
244
+ }
245
+
246
+ assert.ok(
247
+ warnings.some((w) => w.includes("shared-skill") && w.includes("shadows duplicate")),
248
+ "duplicate shared-skill must trigger the default shadow warning",
249
+ );
250
+ });
251
+ });
252
+
253
+ /**
254
+ * Spec R5 — partial-trigger regression.
255
+ *
256
+ * Pre-refactor, the keyword matcher was OR-style (any token scoring > 0
257
+ * kept the skill in the result set). The current scorer requires every
258
+ * token to contribute (AND across tokens). For a skill whose `trigger`
259
+ * is a partial substring of the user's query, the literal-token path
260
+ * must still surface the skill — the regression covered here is that
261
+ * a single-token query against a multi-word trigger must NOT silently
262
+ * drop the skill.
263
+ */
264
+ describe("discovery breadth — literal-token partial trigger (PR 2 R5)", () => {
265
+ let workspace: FixtureWorkspace;
266
+
267
+ beforeEach(async () => {
268
+ workspace = await createFixtureWorkspace();
269
+ });
270
+
271
+ afterEach(async () => {
272
+ if (workspace) {
273
+ await workspace.cleanup();
274
+ }
275
+ });
276
+
277
+ test("a skill whose trigger tokens are partial substrings of the query still appears", async () => {
278
+ const { discoverAllSkills, searchSkills } = await import("opencode-agent-skills-md-core");
279
+
280
+ // Lay down a skill whose trigger is a substring of the upcoming query.
281
+ const skillDir = path.join(workspace.projectRoot, ".opencode", "skills", "partial-trigger-skill");
282
+ await mkdir(skillDir, { recursive: true });
283
+ await writeFile(
284
+ path.join(skillDir, "SKILL.md"),
285
+ [
286
+ "---",
287
+ "name: partial-trigger-skill",
288
+ "description: helper for auth login flows",
289
+ "trigger: auth login",
290
+ "---",
291
+ "",
292
+ "# Partial Trigger Skill",
293
+ "",
294
+ ].join("\n"),
295
+ "utf-8",
296
+ );
297
+
298
+ const skills = await discoverAllSkills(workspace.projectRoot);
299
+ assert.ok(
300
+ skills.has("partial-trigger-skill"),
301
+ "fixture skill must be discoverable",
302
+ );
303
+
304
+ // "auth login" is a partial substring of the user's query "auth login flow".
305
+ // The single-token query "auth" must also surface the skill via trigger.
306
+ const byTriggerToken = searchSkills(
307
+ Array.from(skills.values()),
308
+ "auth",
309
+ );
310
+ assert.ok(
311
+ byTriggerToken.some((s) => s.name === "partial-trigger-skill"),
312
+ "literal-token search must surface a skill whose trigger contains the query token",
313
+ );
314
+ });
315
+ });
@@ -0,0 +1,179 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import * as path from "node:path";
5
+ import { after, before, describe, test } from "node:test";
6
+ import { createOpencodeSkillHost } from "../../src/host";
7
+
8
+ /**
9
+ * Host adapter contract test.
10
+ *
11
+ * Verifies that `createOpencodeSkillHost` translates calls into the correct
12
+ * OpenCode client surface, using a hand-rolled stub client. No real
13
+ * @opencode-ai/plugin runtime is loaded beyond the type import.
14
+ *
15
+ * Coverage:
16
+ * - injectContent -> client.session.prompt (noReply + synthetic + model/agent)
17
+ * - getSessionContext -> client.session.messages (walks user messages)
18
+ * - readFile passthrough (filesystem-backed)
19
+ * - readdir passthrough (filesystem-backed)
20
+ * - session(id) factory returns a SkillHostSession
21
+ */
22
+ describe("createOpencodeSkillHost", () => {
23
+ let workspace: string;
24
+ let fixtureFile: string;
25
+ let fixtureDir: string;
26
+
27
+ before(async () => {
28
+ workspace = await mkdtemp(path.join(tmpdir(), "opencode-agent-skills-md-host-"));
29
+ fixtureDir = path.join(workspace, "sub");
30
+ await mkdir(fixtureDir, { recursive: true });
31
+ fixtureFile = path.join(workspace, "hello.txt");
32
+ await writeFile(fixtureFile, "hello host", "utf8");
33
+ await writeFile(path.join(fixtureDir, "a.txt"), "alpha", "utf8");
34
+ await writeFile(path.join(fixtureDir, "b.txt"), "bravo", "utf8");
35
+ });
36
+
37
+ after(async () => {
38
+ if (workspace) {
39
+ await rm(workspace, { recursive: true, force: true });
40
+ }
41
+ });
42
+
43
+ test("injectContent translates to client.session.prompt with noReply + synthetic", async () => {
44
+ const prompts: Array<{
45
+ path: { id: string };
46
+ body: {
47
+ noReply?: boolean;
48
+ model?: { providerID: string; modelID: string };
49
+ agent?: string;
50
+ parts: Array<{ type: string; text: string; synthetic?: boolean }>;
51
+ };
52
+ }> = [];
53
+ const stub = {
54
+ session: {
55
+ prompt: async (input: typeof prompts[number]) => {
56
+ prompts.push(input);
57
+ },
58
+ messages: async () => ({ data: [] }),
59
+ },
60
+ };
61
+ const host = createOpencodeSkillHost(stub as any);
62
+
63
+ await host.client.injectContent("sess-1", "hello world", {
64
+ model: { providerID: "p", modelID: "m" },
65
+ agent: "a",
66
+ });
67
+
68
+ assert.equal(prompts.length, 1);
69
+ assert.deepEqual(prompts[0]!.path, { id: "sess-1" });
70
+ assert.equal(prompts[0]!.body.noReply, true);
71
+ assert.deepEqual(prompts[0]!.body.model, { providerID: "p", modelID: "m" });
72
+ assert.equal(prompts[0]!.body.agent, "a");
73
+ assert.equal(prompts[0]!.body.parts[0]!.type, "text");
74
+ assert.equal(prompts[0]!.body.parts[0]!.text, "hello world");
75
+ assert.equal(prompts[0]!.body.parts[0]!.synthetic, true);
76
+ });
77
+
78
+ test("injectContent omits model/agent when no context is provided", async () => {
79
+ const prompts: any[] = [];
80
+ const stub = {
81
+ session: {
82
+ prompt: async (input: any) => {
83
+ prompts.push(input);
84
+ },
85
+ messages: async () => ({ data: [] }),
86
+ },
87
+ };
88
+ const host = createOpencodeSkillHost(stub as any);
89
+
90
+ await host.client.injectContent("sess-2", "bare content");
91
+
92
+ assert.equal(prompts.length, 1);
93
+ assert.equal(prompts[0]!.body.model, undefined);
94
+ assert.equal(prompts[0]!.body.agent, undefined);
95
+ assert.equal(prompts[0]!.body.parts[0]!.text, "bare content");
96
+ });
97
+
98
+ test("getSessionContext walks client.session.messages and returns the first user model + agent", async () => {
99
+ const stub = {
100
+ session: {
101
+ prompt: async () => {},
102
+ messages: async () => ({
103
+ data: [
104
+ { info: { role: "assistant" } },
105
+ { info: { role: "user", model: { providerID: "p", modelID: "m" }, agent: "a" } },
106
+ { info: { role: "user", model: { providerID: "p2", modelID: "m2" }, agent: "a2" } },
107
+ ],
108
+ }),
109
+ },
110
+ };
111
+ const host = createOpencodeSkillHost(stub as any);
112
+
113
+ const ctx = await host.client.getSessionContext("sess-3");
114
+
115
+ assert.deepEqual(ctx, {
116
+ model: { providerID: "p", modelID: "m" },
117
+ agent: "a",
118
+ });
119
+ });
120
+
121
+ test("getSessionContext returns undefined when no user message carries a model", async () => {
122
+ const stub = {
123
+ session: {
124
+ prompt: async () => {},
125
+ messages: async () => ({
126
+ data: [
127
+ { info: { role: "assistant" } },
128
+ { info: { role: "user" } },
129
+ ],
130
+ }),
131
+ },
132
+ };
133
+ const host = createOpencodeSkillHost(stub as any);
134
+
135
+ const ctx = await host.client.getSessionContext("sess-4");
136
+
137
+ assert.equal(ctx, undefined);
138
+ });
139
+
140
+ test("getSessionContext returns undefined when client.session.messages throws", async () => {
141
+ const stub = {
142
+ session: {
143
+ prompt: async () => {},
144
+ messages: async () => {
145
+ throw new Error("network down");
146
+ },
147
+ },
148
+ };
149
+ const host = createOpencodeSkillHost(stub as any);
150
+
151
+ const ctx = await host.client.getSessionContext("sess-5");
152
+
153
+ assert.equal(ctx, undefined);
154
+ });
155
+
156
+ test("readFile reads file content from the host's filesystem", async () => {
157
+ const host = createOpencodeSkillHost({} as any);
158
+
159
+ const content = await host.client.readFile(fixtureFile);
160
+
161
+ assert.equal(content, "hello host");
162
+ });
163
+
164
+ test("readdir lists directory entries from the host's filesystem", async () => {
165
+ const host = createOpencodeSkillHost({} as any);
166
+
167
+ const entries = (await host.client.readdir(fixtureDir)).sort();
168
+
169
+ assert.deepEqual(entries, ["a.txt", "b.txt"]);
170
+ });
171
+
172
+ test("session(id) returns a SkillHostSession with the supplied id", () => {
173
+ const host = createOpencodeSkillHost({} as any);
174
+
175
+ const session = host.session("sess-factory");
176
+
177
+ assert.deepEqual(session, { id: "sess-factory" });
178
+ });
179
+ });