opencode-agent-skills-md 1.0.0 → 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/{packages/opencode-agent-skills-md/src → src}/cli/main.ts +20 -4
  16. package/.beads/.local_version +0 -1
  17. package/.beads/README.md +0 -81
  18. package/.beads/config.yaml +0 -61
  19. package/.beads/deletions.jsonl +0 -1
  20. package/.beads/issues.jsonl +0 -64
  21. package/.beads/metadata.json +0 -4
  22. package/.gitattributes +0 -3
  23. package/.github/CODEOWNERS +0 -1
  24. package/.github/copilot-instructions.md +0 -78
  25. package/.github/dependabot.yml +0 -13
  26. package/.github/workflows/release.yml +0 -51
  27. package/.opencode/command/test-compaction.md +0 -9
  28. package/.opencode/command/test-find-skills.md +0 -7
  29. package/.opencode/command/test-read-skill-file.md +0 -14
  30. package/.opencode/command/test-run-skill-script.md +0 -13
  31. package/.opencode/command/test-skills.md +0 -14
  32. package/.opencode/command/test-use-skill.md +0 -10
  33. package/.opencode/skills/git-helper/SKILL.md +0 -65
  34. package/.opencode/skills/test-skill/SKILL.md +0 -43
  35. package/.opencode/skills/test-skill/example-config.json +0 -16
  36. package/.opencode/skills/test-skill/helper-docs.md +0 -29
  37. package/.opencode/skills/test-skill/scripts/echo-args +0 -14
  38. package/.opencode/skills/test-skill/scripts/greet +0 -6
  39. package/AGENTS.md +0 -43
  40. package/CHANGELOG.md +0 -178
  41. package/Justfile +0 -39
  42. package/README.md +0 -189
  43. package/openspec/changes/archive/2026-06-14-skills-core-decouple/specs/core-decoupling/spec.md +0 -74
  44. package/openspec/changes/archive/2026-06-14-skills-core-decouple/tasks.md +0 -64
  45. package/openspec/changes/archive/2026-06-14-skills-core-decouple/verify-report.md +0 -75
  46. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/apply-progress.md +0 -136
  47. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/archive-report.md +0 -77
  48. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/design.md +0 -89
  49. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/proposal.md +0 -65
  50. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/specs/core-decoupling/spec.md +0 -77
  51. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/tasks.md +0 -65
  52. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/verify-report.md +0 -165
  53. package/openspec/specs/core-decoupling/spec.md +0 -110
  54. package/packages/core/package.json +0 -30
  55. package/packages/core/src/content.d.ts +0 -16
  56. package/packages/core/src/content.ts +0 -30
  57. package/packages/core/src/debug.ts +0 -16
  58. package/packages/core/src/discovery.d.ts +0 -86
  59. package/packages/core/src/discovery.ts +0 -257
  60. package/packages/core/src/index.d.ts +0 -20
  61. package/packages/core/src/index.ts +0 -55
  62. package/packages/core/src/match.d.ts +0 -19
  63. package/packages/core/src/match.ts +0 -75
  64. package/packages/core/src/parse.d.ts +0 -26
  65. package/packages/core/src/parse.ts +0 -141
  66. package/packages/core/src/scripts.d.ts +0 -17
  67. package/packages/core/src/scripts.ts +0 -79
  68. package/packages/core/src/search.d.ts +0 -83
  69. package/packages/core/src/search.ts +0 -188
  70. package/packages/core/src/types.d.ts +0 -82
  71. package/packages/core/src/types.ts +0 -131
  72. package/packages/core/src/walk.ts +0 -109
  73. package/packages/core/tests/agnostic.test.ts +0 -346
  74. package/packages/core/tests/content.test.ts +0 -65
  75. package/packages/core/tests/discovery.test.ts +0 -370
  76. package/packages/core/tests/package-boundary.test.ts +0 -310
  77. package/packages/core/tests/parse-trigger.test.ts +0 -282
  78. package/packages/core/tests/search.test.ts +0 -374
  79. package/packages/core/tests/subpath.test.ts +0 -87
  80. package/packages/core/tsconfig.json +0 -10
  81. package/packages/opencode-agent-skills-md/package.json +0 -42
  82. package/packages/opencode-agent-skills-md/rolldown.config.js +0 -48
  83. package/packages/opencode-agent-skills-md/tests/cli-commands.test.ts +0 -1423
  84. package/packages/opencode-agent-skills-md/tests/e2e/startup-smoke.test.ts +0 -66
  85. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.claude/skills/claude-user-only-skill/SKILL.md +0 -8
  86. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/shared-skill/SKILL.md +0 -8
  87. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/user-only-skill/SKILL.md +0 -8
  88. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.claude/skills/claude-project-only-skill/SKILL.md +0 -8
  89. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/go-tester/SKILL.md +0 -12
  90. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/nested/team/nested-skill/SKILL.md +0 -8
  91. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/rust-tester/SKILL.md +0 -11
  92. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/SKILL.md +0 -8
  93. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/bin/echo.sh +0 -2
  94. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/docs/reference.md +0 -1
  95. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/shared-skill/SKILL.md +0 -8
  96. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/using-superpowers/SKILL.md +0 -8
  97. package/packages/opencode-agent-skills-md/tests/integration/helpers/mock-opencode.ts +0 -114
  98. package/packages/opencode-agent-skills-md/tests/integration/plugin.test.ts +0 -316
  99. package/packages/opencode-agent-skills-md/tests/integration/skill-discovery.test.ts +0 -315
  100. package/packages/opencode-agent-skills-md/tests/opencode/host.test.ts +0 -179
  101. package/packages/opencode-agent-skills-md/tests/opencode/plugin.test.ts +0 -551
  102. package/packages/opencode-agent-skills-md/tests/opencode/subpath.test.ts +0 -66
  103. package/packages/opencode-agent-skills-md/tests/opencode/tools.test.ts +0 -213
  104. package/packages/opencode-agent-skills-md/tests/package-boundary.test.ts +0 -346
  105. package/packages/opencode-agent-skills-md/tests/tools-security.test.ts +0 -72
  106. package/packages/opencode-agent-skills-md/tsconfig.build.json +0 -11
  107. package/packages/opencode-agent-skills-md/tsconfig.json +0 -10
  108. package/plans/001-ci-gate.md +0 -177
  109. package/plans/002-is-path-safe.md +0 -243
  110. package/plans/003-escape-prompts.md +0 -310
  111. package/plans/004-test-security-paths.md +0 -228
  112. package/plans/005-stop-swallowing-errors.md +0 -246
  113. package/plans/006-preserve-jsonc-commas.md +0 -144
  114. package/plans/007-write-before-purge.md +0 -144
  115. package/plans/008-reuse-walkdir-for-list-skill-files.md +0 -164
  116. package/plans/README.md +0 -43
  117. package/pnpm-workspace.yaml +0 -6
  118. package/tests/workspace.test.ts +0 -367
  119. package/tsconfig.json +0 -15
  120. /package/{packages/opencode-agent-skills-md/src → src}/cli/config.ts +0 -0
  121. /package/{packages/opencode-agent-skills-md/src → src}/cli/install.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
- });