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
@@ -1,66 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
3
- import * as fs from "node:fs/promises";
4
- import { tmpdir } from "node:os";
5
- import path from "node:path";
6
- import { before, describe, test } from "node:test";
7
-
8
- describe("plugin startup smoke", () => {
9
- let projectRoot: string;
10
-
11
- before(async () => {
12
- projectRoot = await mkdtemp(path.join(tmpdir(), "opencode-agent-skills-md-e2e-"));
13
- const skillDir = path.join(projectRoot, ".opencode", "skills", "smoke-skill");
14
- await mkdir(skillDir, { recursive: true });
15
- await writeFile(
16
- path.join(skillDir, "SKILL.md"),
17
- [
18
- "---",
19
- "name: smoke-skill",
20
- "description: minimal smoke skill",
21
- "---",
22
- "",
23
- "# Smoke Skill",
24
- "",
25
- "Smoke test content.",
26
- ].join("\n"),
27
- "utf8"
28
- );
29
- });
30
-
31
- test("imports the built plugin and completes a first message", async () => {
32
- const module = await import("../../dist/plugin.mjs");
33
- const SkillsPlugin = module.SkillsPlugin as (input: any) => Promise<any>;
34
-
35
- const prompts: Array<{ text: string }> = [];
36
- const plugin = await SkillsPlugin({
37
- client: {
38
- session: {
39
- messages: async () => ({ data: [] }),
40
- prompt: async ({ body }: any) => {
41
- prompts.push({ text: body.parts[0].text });
42
- },
43
- },
44
- },
45
- $: Object.assign(((strings: TemplateStringsArray, ...values: unknown[]) => ({ text: async () => String(values.join(" ")) })) as any, {
46
- cwd: () => undefined,
47
- }),
48
- directory: projectRoot,
49
- });
50
-
51
- await plugin["chat.message"](
52
- {},
53
- {
54
- message: {
55
- sessionID: "smoke-session",
56
- model: { providerID: "test-provider", modelID: "test-model" },
57
- agent: "smoke-agent",
58
- },
59
- parts: [{ type: "text", text: "hello smoke", synthetic: false }],
60
- } as any
61
- );
62
-
63
- assert.equal(prompts.length, 1);
64
- assert.match(prompts[0]!.text, /<available-skills>/);
65
- });
66
- });
@@ -1,8 +0,0 @@
1
- ---
2
- name: claude-user-only-skill
3
- description: user-level Claude skill fixture
4
- ---
5
-
6
- # Claude User Skill
7
-
8
- User-level skill under `~/.claude/skills/`.
@@ -1,8 +0,0 @@
1
- ---
2
- name: shared-skill
3
- description: user fixture that should be shadowed by project
4
- ---
5
-
6
- # Shared Skill (User)
7
-
8
- User version.
@@ -1,8 +0,0 @@
1
- ---
2
- name: user-only-skill
3
- description: user-only skill fixture
4
- ---
5
-
6
- # User Only Skill
7
-
8
- User-only skill.
@@ -1,8 +0,0 @@
1
- ---
2
- name: claude-project-only-skill
3
- description: project-level Claude skill fixture
4
- ---
5
-
6
- # Claude Project Skill
7
-
8
- Project-level skill under `.claude/skills/`.
@@ -1,12 +0,0 @@
1
- ---
2
- name: go-tester
3
- description: helper skill for testing go code
4
- metadata:
5
- tags:
6
- - go
7
- - testing
8
- ---
9
-
10
- # Go Tester
11
-
12
- Project skill tagged `go` and `testing`.
@@ -1,8 +0,0 @@
1
- ---
2
- name: nested-skill
3
- description: nested skill fixture
4
- ---
5
-
6
- # Nested Skill
7
-
8
- Nested project skill.
@@ -1,11 +0,0 @@
1
- ---
2
- name: rust-tester
3
- description: helper skill for testing rust code
4
- metadata:
5
- tags:
6
- - rust
7
- ---
8
-
9
- # Rust Tester
10
-
11
- Project skill tagged `rust` only.
@@ -1,8 +0,0 @@
1
- ---
2
- name: scripted-skill
3
- description: scripted skill fixture
4
- ---
5
-
6
- # Scripted Skill
7
-
8
- Scripted project skill.
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env sh
2
- printf 'scripted:%s\n' "$*"
@@ -1,8 +0,0 @@
1
- ---
2
- name: shared-skill
3
- description: project version wins over user fixture
4
- ---
5
-
6
- # Shared Skill
7
-
8
- Project version.
@@ -1,8 +0,0 @@
1
- ---
2
- name: using-superpowers
3
- description: bundled superpowers fixture
4
- ---
5
-
6
- # Using Superpowers
7
-
8
- Fixture content for the superpowers bootstrap path.
@@ -1,114 +0,0 @@
1
- import { mkdtemp, cp, rm } from "node:fs/promises";
2
- import * as fs from "node:fs/promises";
3
- import path from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { tmpdir } from "node:os";
6
-
7
- const here = path.dirname(fileURLToPath(import.meta.url));
8
- const fixtureRoot = path.resolve(here, "..", "..", "fixtures", "skills");
9
-
10
- export interface FixtureWorkspace {
11
- projectRoot: string;
12
- homeRoot: string;
13
- scriptedSkillPath: string;
14
- cleanup: () => Promise<void>;
15
- }
16
-
17
- export interface PromptRecord {
18
- text: string;
19
- sessionID: string;
20
- }
21
-
22
- export interface MockOpencodeClient {
23
- client: {
24
- session: {
25
- messages: (input: { path: { id: string } }) => Promise<{ data: unknown[] }>;
26
- prompt: (input: { path: { id: string }; body: { parts: Array<{ text: string }> } }) => Promise<void>;
27
- };
28
- };
29
- prompts: PromptRecord[];
30
- }
31
-
32
- export interface ShellRecorder {
33
- shell: ((strings: TemplateStringsArray, ...values: unknown[]) => { text: () => Promise<string> }) & {
34
- cwd: (directory: string) => ShellRecorder["shell"];
35
- calls: Array<{ cwd: string; command: string }>;
36
- };
37
- calls: Array<{ cwd: string; command: string }>;
38
- }
39
-
40
- export async function createFixtureWorkspace(): Promise<FixtureWorkspace> {
41
- const root = await mkdtemp(path.join(tmpdir(), "opencode-agent-skills-md-fixture-"));
42
- const projectRoot = path.join(root, "project");
43
- const homeRoot = path.join(root, "home");
44
-
45
- await cp(path.join(fixtureRoot, "project"), projectRoot, { recursive: true });
46
- await cp(path.join(fixtureRoot, "home"), homeRoot, { recursive: true });
47
-
48
- const scriptedSkillPath = path.join(projectRoot, ".opencode", "skills", "scripted-skill");
49
- await fs.chmod(path.join(scriptedSkillPath, "bin", "echo.sh"), 0o755);
50
-
51
- const previousHome = process.env.HOME;
52
- process.env.HOME = homeRoot;
53
-
54
- return {
55
- projectRoot,
56
- homeRoot,
57
- scriptedSkillPath,
58
- cleanup: async () => {
59
- if (previousHome === undefined) {
60
- delete process.env.HOME;
61
- } else {
62
- process.env.HOME = previousHome;
63
- }
64
- await rm(root, { recursive: true, force: true });
65
- },
66
- };
67
- }
68
-
69
- export function createMockOpencodeClient(initialMessages: unknown[] = []): MockOpencodeClient {
70
- const prompts: PromptRecord[] = [];
71
-
72
- return {
73
- prompts,
74
- client: {
75
- session: {
76
- messages: async () => ({ data: initialMessages }),
77
- prompt: async ({ path: sessionPath, body }) => {
78
- const text = body.parts[0]?.text ?? "";
79
- prompts.push({ text, sessionID: sessionPath.id });
80
- },
81
- },
82
- },
83
- };
84
- }
85
-
86
- export function createShellRecorder(): ShellRecorder {
87
- const calls: Array<{ cwd: string; command: string }> = [];
88
- let currentCwd = "";
89
-
90
- const shell = Object.assign(
91
- ((strings: TemplateStringsArray, ...values: unknown[]) => {
92
- const command = strings.reduce((acc, chunk, index) => {
93
- const value = values[index];
94
- const rendered = Array.isArray(value) ? value.join(" ") : String(value ?? "");
95
- return acc + chunk + rendered;
96
- }, "");
97
-
98
- calls.push({ cwd: currentCwd, command });
99
-
100
- return {
101
- text: async () => `cwd=${currentCwd}\n${command}`,
102
- };
103
- }) as ShellRecorder["shell"],
104
- {
105
- cwd(directory: string) {
106
- currentCwd = directory;
107
- return shell;
108
- },
109
- calls,
110
- }
111
- );
112
-
113
- return { shell, calls };
114
- }
@@ -1,316 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { afterEach, beforeEach, describe, test } from "node:test";
3
- import {
4
- createFixtureWorkspace,
5
- createMockOpencodeClient,
6
- createShellRecorder,
7
- } from "./helpers/mock-opencode";
8
-
9
- describe("plugin integration", () => {
10
- let workspace: Awaited<ReturnType<typeof createFixtureWorkspace>>;
11
- const previousSuperpowersMode = process.env.OPENCODE_AGENT_SKILLS_SUPERPOWERS_MODE;
12
-
13
- beforeEach(async () => {
14
- workspace = await createFixtureWorkspace();
15
- process.env.OPENCODE_AGENT_SKILLS_SUPERPOWERS_MODE = "true";
16
- });
17
-
18
- afterEach(async () => {
19
- if (workspace) {
20
- await workspace.cleanup();
21
- }
22
- if (previousSuperpowersMode === undefined) {
23
- delete process.env.OPENCODE_AGENT_SKILLS_SUPERPOWERS_MODE;
24
- } else {
25
- process.env.OPENCODE_AGENT_SKILLS_SUPERPOWERS_MODE = previousSuperpowersMode;
26
- }
27
- });
28
-
29
- test("discovers project and user skills deterministically", async () => {
30
- const { discoverAllSkills } = await import("opencode-agent-skills-md-core");
31
-
32
- const skills = await discoverAllSkills(workspace.projectRoot);
33
-
34
- assert.equal(skills.get("shared-skill")?.label, "project");
35
- assert.equal(skills.get("shared-skill")?.description, "project version wins over user fixture");
36
- assert.equal(skills.get("nested-skill")?.description, "nested skill fixture");
37
- assert.equal(skills.get("user-only-skill")?.label, "user");
38
- });
39
-
40
- test("loads startup context, tools, and reinjection hooks", async () => {
41
- const { SkillsPlugin } = await import("../../src");
42
-
43
- const client = createMockOpencodeClient();
44
- const shell = createShellRecorder();
45
- const plugin = await SkillsPlugin({ client: client.client, $: shell.shell, directory: workspace.projectRoot } as any);
46
-
47
- await plugin["chat.message"](
48
- {},
49
- {
50
- message: {
51
- sessionID: "session-startup",
52
- model: { providerID: "test-provider", modelID: "test-model" },
53
- agent: "test-agent",
54
- },
55
- parts: [{ type: "text", text: "use the discovery skill", synthetic: false }],
56
- } as any
57
- );
58
-
59
- assert.equal(client.prompts.length, 2);
60
- assert.ok(client.prompts.some((prompt) => /<available-skills>/.test(prompt.text)));
61
- assert.ok(client.prompts.some((prompt) => /You have superpowers\./.test(prompt.text)));
62
-
63
- await plugin.event({ event: { type: "session.compacted", properties: { sessionID: "session-startup" } } } as any);
64
-
65
- await plugin["chat.message"](
66
- {},
67
- {
68
- message: {
69
- sessionID: "session-startup",
70
- model: { providerID: "test-provider", modelID: "test-model" },
71
- agent: "test-agent",
72
- },
73
- parts: [{ type: "text", text: "run the script skill", synthetic: false }],
74
- } as any
75
- );
76
-
77
- assert.ok(client.prompts.length >= 2);
78
- });
79
-
80
- test("skill tools load content and execute scripts", async () => {
81
- const { SkillsPlugin } = await import("../../src");
82
-
83
- const client = createMockOpencodeClient();
84
- const shell = createShellRecorder();
85
- const plugin = await SkillsPlugin({ client: client.client, $: shell.shell, directory: workspace.projectRoot } as any);
86
-
87
- const loaded = await plugin.tool.use_skill.execute({ skill: "scripted-skill" }, { sessionID: "session-tools" } as any);
88
- assert.match(loaded, /loaded\./i);
89
- assert.equal(client.prompts.at(-1)?.text.includes("<skill name=\"scripted-skill\">"), true);
90
-
91
- const fileLoaded = await plugin.tool.read_skill_file.execute(
92
- { skill: "scripted-skill", filename: "docs/reference.md" },
93
- { sessionID: "session-tools" } as any
94
- );
95
- assert.match(fileLoaded, /loaded/i);
96
-
97
- const output = await plugin.tool.run_skill_script.execute(
98
- { skill: "scripted-skill", script: "bin/echo.sh", arguments: ["hello"] },
99
- { sessionID: "session-tools" } as any
100
- );
101
- assert.match(output, /hello/);
102
- assert.equal(shell.calls[0]?.cwd, workspace.scriptedSkillPath);
103
- });
104
-
105
- /**
106
- * Regression coverage for the skill-loading callback wiring (PR 1 of
107
- * `fix-skill-loading-regression`). Asserts the end-to-end behavior at the
108
- * integration layer:
109
- * - after `use_skill`, `onSkillLoaded` is observable via the session's
110
- * loaded-skill state
111
- * - the same keyword in a subsequent chat.message does NOT re-trigger
112
- * a <skill-evaluation-required> injection for the loaded skill
113
- *
114
- * With the regression, the loader does not update loaded-skill state so
115
- * the matcher re-emits an evaluation prompt for the already-loaded skill.
116
- */
117
- test("use_skill callback updates loaded-skill state and prevents duplicate match injection (PR 1)", async () => {
118
- const { SkillsPlugin } = await import("../../src");
119
-
120
- const client = createMockOpencodeClient();
121
- const shell = createShellRecorder();
122
- const plugin = await SkillsPlugin({
123
- client: client.client,
124
- $: shell.shell,
125
- directory: workspace.projectRoot,
126
- } as any);
127
-
128
- const SESSION = "session-loaded-state";
129
-
130
- // Bootstrap the session: first message injects <available-skills>.
131
- await plugin["chat.message"](
132
- {},
133
- {
134
- message: {
135
- sessionID: SESSION,
136
- model: { providerID: "test-provider", modelID: "test-model" },
137
- agent: "test-agent",
138
- },
139
- parts: [{ type: "text", text: "first message", synthetic: false }],
140
- } as any,
141
- );
142
- const promptsAfterBootstrap = client.prompts.length;
143
-
144
- // Load scripted-skill via use_skill.
145
- const loadResult = await plugin.tool.use_skill.execute(
146
- { skill: "scripted-skill" },
147
- { sessionID: SESSION } as any,
148
- );
149
- assert.match(loadResult, /loaded\./i, "use_skill reports a successful load");
150
- assert.ok(
151
- client.prompts.slice(promptsAfterBootstrap).some((p) =>
152
- /<skill name="scripted-skill">/.test(p.text),
153
- ),
154
- "use_skill injects the skill content into the session",
155
- );
156
-
157
- // Subsequent chat.message with a keyword that also matches scripted-skill.
158
- // Other skills may legitimately match too, but scripted-skill MUST be
159
- // filtered out by the loaded-skill set after the fix. Before the fix,
160
- // scripted-skill appears because loadedSkillsPerSession was never updated.
161
- const promptsBeforeRepeat = client.prompts.length;
162
- await plugin["chat.message"](
163
- {},
164
- {
165
- message: {
166
- sessionID: SESSION,
167
- model: { providerID: "test-provider", modelID: "test-model" },
168
- agent: "test-agent",
169
- },
170
- parts: [{ type: "text", text: "use the script skill", synthetic: false }],
171
- } as any,
172
- );
173
- const newPrompts = client.prompts.slice(promptsBeforeRepeat);
174
- const evaluationInjections = newPrompts.filter((p) =>
175
- /<skill-evaluation-required>/.test(p.text),
176
- );
177
- for (const prompt of evaluationInjections) {
178
- assert.doesNotMatch(
179
- prompt.text,
180
- /^- scripted-skill:/m,
181
- "loaded-skill state must suppress scripted-skill from re-injection",
182
- );
183
- }
184
- });
185
- });
186
-
187
- /**
188
- * `GetAvailableSkills` with the new `keywords` parameter and the safe-input
189
- * `query` path. These are RED tests for PR2 — the current tool has no
190
- * `keywords` arg, and the existing `new RegExp(args.query)` path crashes
191
- * on regex-special characters. The fixture skills in
192
- * `tests/fixtures/skills/project/.opencode/skills/{go-tester,rust-tester}`
193
- * carry `metadata.tags` so the search layer can filter against them.
194
- */
195
- describe("GetAvailableSkills with keywords", () => {
196
- let workspace: Awaited<ReturnType<typeof createFixtureWorkspace>>;
197
-
198
- beforeEach(async () => {
199
- workspace = await createFixtureWorkspace();
200
- });
201
-
202
- afterEach(async () => {
203
- if (workspace) {
204
- await workspace.cleanup();
205
- }
206
- });
207
-
208
- test("keywords=['go'] returns only skills whose tags include 'go'", async () => {
209
- const { SkillsPlugin } = await import("../../src");
210
- const client = createMockOpencodeClient();
211
- const shell = createShellRecorder();
212
- const plugin = await SkillsPlugin({
213
- client: client.client,
214
- $: shell.shell,
215
- directory: workspace.projectRoot,
216
- } as any);
217
-
218
- const result = await plugin.tool.get_available_skills.execute(
219
- { keywords: ["go"] } as any,
220
- { sessionID: "keywords-test" } as any
221
- );
222
-
223
- assert.match(result, /go-tester/);
224
- assert.doesNotMatch(result, /rust-tester/);
225
- });
226
-
227
- test("query + keywords applies both filters", async () => {
228
- const { SkillsPlugin } = await import("../../src");
229
- const client = createMockOpencodeClient();
230
- const shell = createShellRecorder();
231
- const plugin = await SkillsPlugin({
232
- client: client.client,
233
- $: shell.shell,
234
- directory: workspace.projectRoot,
235
- } as any);
236
-
237
- const result = await plugin.tool.get_available_skills.execute(
238
- { query: "tester", keywords: ["go"] } as any,
239
- { sessionID: "combined-test" } as any
240
- );
241
-
242
- // Only `go-tester` is tagged "go"; "rust-tester" is filtered out.
243
- assert.match(result, /go-tester/);
244
- assert.doesNotMatch(result, /rust-tester/);
245
- });
246
-
247
- test("query with regex-special characters does not throw", async () => {
248
- const { SkillsPlugin } = await import("../../src");
249
- const client = createMockOpencodeClient();
250
- const shell = createShellRecorder();
251
- const plugin = await SkillsPlugin({
252
- client: client.client,
253
- $: shell.shell,
254
- directory: workspace.projectRoot,
255
- } as any);
256
-
257
- // The legacy implementation crashed here because `new RegExp("(test+", "i")`
258
- // throws — the unescaped `(` and `+` are invalid regex syntax. After
259
- // the search-layer wiring, the tool must produce a string result
260
- // (matches or a clean no-match) without throwing. The fuzzy scorer
261
- // legitimately matches "go-tester" and "rust-tester" against the
262
- // substring "test" inside the escaped token, so a non-empty result
263
- // is expected and acceptable.
264
- const result = await plugin.tool.get_available_skills.execute(
265
- { query: "(test+" } as any,
266
- { sessionID: "regex-test" } as any
267
- );
268
-
269
- assert.ok(typeof result === "string", "returns a string result");
270
- });
271
- });
272
-
273
- /**
274
- * PR 2 plugin refactor coverage at the integration level: confirms the
275
- * event hook keeps working with the closure-scoped state. The event
276
- * handler reads `event.properties.info.id` (PR 1 had a typo where it
277
- * reused the `session.compacted` variable).
278
- */
279
- describe("plugin event hooks survive the PR 2 refactor", () => {
280
- let workspace: Awaited<ReturnType<typeof createFixtureWorkspace>>;
281
-
282
- beforeEach(async () => {
283
- workspace = await createFixtureWorkspace();
284
- });
285
-
286
- afterEach(async () => {
287
- if (workspace) await workspace.cleanup();
288
- });
289
-
290
- test("session.deleted reads event.properties.info.id (no closure-scope leakage)", async () => {
291
- const { SkillsPlugin } = await import("../../src");
292
- const client = createMockOpencodeClient();
293
- const shell = createShellRecorder();
294
- const plugin = await SkillsPlugin({ client: client.client, $: shell.shell, directory: workspace.projectRoot } as any);
295
-
296
- // Bootstrap, then delete — must not throw the "Cannot find name 'sessionID'"
297
- // bug that the original (pre-PR2) code would hit if a prior event
298
- // handler hadn't set `sessionID` first.
299
- await plugin["chat.message"](
300
- {},
301
- {
302
- message: {
303
- sessionID: "session-A",
304
- model: { providerID: "test-provider", modelID: "test-model" },
305
- agent: "test-agent",
306
- },
307
- parts: [{ type: "text", text: "first message", synthetic: false }],
308
- } as any,
309
- );
310
-
311
- await assert.doesNotReject(
312
- plugin.event({ event: { type: "session.deleted", properties: { info: { id: "session-A" } } } } as any),
313
- "session.deleted must resolve event.properties.info.id from its own branch",
314
- );
315
- });
316
- });