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.
- package/dist/cli.mjs +770 -0
- package/dist/plugin.mjs +1138 -0
- package/dist/src/cli/config.d.ts +144 -0
- package/dist/src/cli/install.d.ts +33 -0
- package/dist/src/cli/main.d.ts +11 -0
- package/dist/src/cli/real-fs.d.ts +6 -0
- package/dist/src/cli/status.d.ts +34 -0
- package/dist/src/cli/uninstall.d.ts +22 -0
- package/dist/src/host.d.ts +51 -0
- package/dist/src/index.d.ts +17 -0
- package/dist/src/plugin.d.ts +35 -0
- package/dist/src/sdk.d.ts +51 -0
- package/dist/src/tools.d.ts +86 -0
- package/package.json +48 -18
- package/{packages/opencode-agent-skills-md/src → src}/cli/main.ts +20 -4
- package/.beads/.local_version +0 -1
- package/.beads/README.md +0 -81
- package/.beads/config.yaml +0 -61
- package/.beads/deletions.jsonl +0 -1
- package/.beads/issues.jsonl +0 -64
- package/.beads/metadata.json +0 -4
- package/.gitattributes +0 -3
- package/.github/CODEOWNERS +0 -1
- package/.github/copilot-instructions.md +0 -78
- package/.github/dependabot.yml +0 -13
- package/.github/workflows/release.yml +0 -51
- package/.opencode/command/test-compaction.md +0 -9
- package/.opencode/command/test-find-skills.md +0 -7
- package/.opencode/command/test-read-skill-file.md +0 -14
- package/.opencode/command/test-run-skill-script.md +0 -13
- package/.opencode/command/test-skills.md +0 -14
- package/.opencode/command/test-use-skill.md +0 -10
- package/.opencode/skills/git-helper/SKILL.md +0 -65
- package/.opencode/skills/test-skill/SKILL.md +0 -43
- package/.opencode/skills/test-skill/example-config.json +0 -16
- package/.opencode/skills/test-skill/helper-docs.md +0 -29
- package/.opencode/skills/test-skill/scripts/echo-args +0 -14
- package/.opencode/skills/test-skill/scripts/greet +0 -6
- package/AGENTS.md +0 -43
- package/CHANGELOG.md +0 -178
- package/Justfile +0 -39
- package/README.md +0 -189
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/specs/core-decoupling/spec.md +0 -74
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/tasks.md +0 -64
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/verify-report.md +0 -75
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/apply-progress.md +0 -136
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/archive-report.md +0 -77
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/design.md +0 -89
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/proposal.md +0 -65
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/specs/core-decoupling/spec.md +0 -77
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/tasks.md +0 -65
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/verify-report.md +0 -165
- package/openspec/specs/core-decoupling/spec.md +0 -110
- package/packages/core/package.json +0 -30
- package/packages/core/src/content.d.ts +0 -16
- package/packages/core/src/content.ts +0 -30
- package/packages/core/src/debug.ts +0 -16
- package/packages/core/src/discovery.d.ts +0 -86
- package/packages/core/src/discovery.ts +0 -257
- package/packages/core/src/index.d.ts +0 -20
- package/packages/core/src/index.ts +0 -55
- package/packages/core/src/match.d.ts +0 -19
- package/packages/core/src/match.ts +0 -75
- package/packages/core/src/parse.d.ts +0 -26
- package/packages/core/src/parse.ts +0 -141
- package/packages/core/src/scripts.d.ts +0 -17
- package/packages/core/src/scripts.ts +0 -79
- package/packages/core/src/search.d.ts +0 -83
- package/packages/core/src/search.ts +0 -188
- package/packages/core/src/types.d.ts +0 -82
- package/packages/core/src/types.ts +0 -131
- package/packages/core/src/walk.ts +0 -109
- package/packages/core/tests/agnostic.test.ts +0 -346
- package/packages/core/tests/content.test.ts +0 -65
- package/packages/core/tests/discovery.test.ts +0 -370
- package/packages/core/tests/package-boundary.test.ts +0 -310
- package/packages/core/tests/parse-trigger.test.ts +0 -282
- package/packages/core/tests/search.test.ts +0 -374
- package/packages/core/tests/subpath.test.ts +0 -87
- package/packages/core/tsconfig.json +0 -10
- package/packages/opencode-agent-skills-md/package.json +0 -42
- package/packages/opencode-agent-skills-md/rolldown.config.js +0 -48
- package/packages/opencode-agent-skills-md/tests/cli-commands.test.ts +0 -1423
- package/packages/opencode-agent-skills-md/tests/e2e/startup-smoke.test.ts +0 -66
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.claude/skills/claude-user-only-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/shared-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/user-only-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.claude/skills/claude-project-only-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/go-tester/SKILL.md +0 -12
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/nested/team/nested-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/rust-tester/SKILL.md +0 -11
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/bin/echo.sh +0 -2
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/docs/reference.md +0 -1
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/shared-skill/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/using-superpowers/SKILL.md +0 -8
- package/packages/opencode-agent-skills-md/tests/integration/helpers/mock-opencode.ts +0 -114
- package/packages/opencode-agent-skills-md/tests/integration/plugin.test.ts +0 -316
- package/packages/opencode-agent-skills-md/tests/integration/skill-discovery.test.ts +0 -315
- package/packages/opencode-agent-skills-md/tests/opencode/host.test.ts +0 -179
- package/packages/opencode-agent-skills-md/tests/opencode/plugin.test.ts +0 -551
- package/packages/opencode-agent-skills-md/tests/opencode/subpath.test.ts +0 -66
- package/packages/opencode-agent-skills-md/tests/opencode/tools.test.ts +0 -213
- package/packages/opencode-agent-skills-md/tests/package-boundary.test.ts +0 -346
- package/packages/opencode-agent-skills-md/tests/tools-security.test.ts +0 -72
- package/packages/opencode-agent-skills-md/tsconfig.build.json +0 -11
- package/packages/opencode-agent-skills-md/tsconfig.json +0 -10
- package/plans/001-ci-gate.md +0 -177
- package/plans/002-is-path-safe.md +0 -243
- package/plans/003-escape-prompts.md +0 -310
- package/plans/004-test-security-paths.md +0 -228
- package/plans/005-stop-swallowing-errors.md +0 -246
- package/plans/006-preserve-jsonc-commas.md +0 -144
- package/plans/007-write-before-purge.md +0 -144
- package/plans/008-reuse-walkdir-for-list-skill-files.md +0 -164
- package/plans/README.md +0 -43
- package/pnpm-workspace.yaml +0 -6
- package/tests/workspace.test.ts +0 -367
- package/tsconfig.json +0 -15
- /package/{packages/opencode-agent-skills-md/src → src}/cli/config.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/cli/install.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/cli/real-fs.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/cli/status.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/cli/uninstall.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/host.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/index.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/plugin.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/sdk.ts +0 -0
- /package/{packages/opencode-agent-skills-md/src → src}/tools.ts +0 -0
|
@@ -1,551 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { afterEach, beforeEach, describe, mock, test } from "node:test";
|
|
3
|
-
import type { SkillSummary } from "opencode-agent-skills-md-core";
|
|
4
|
-
import {
|
|
5
|
-
createFixtureWorkspace,
|
|
6
|
-
createMockOpencodeClient,
|
|
7
|
-
createShellRecorder,
|
|
8
|
-
type FixtureWorkspace,
|
|
9
|
-
} from "../integration/helpers/mock-opencode";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Helper functions in `src/opencode/plugin.ts` that were promoted to
|
|
13
|
-
* named exports in PR 2 so they can be tested in isolation. The
|
|
14
|
-
* `as unknown as PluginModule` cast keeps the test file type-safe while
|
|
15
|
-
* the dynamic import path gives the test runner a chance to load the
|
|
16
|
-
* module under test (and lets it throw a clean "is not a function"
|
|
17
|
-
* error in the RED state).
|
|
18
|
-
*/
|
|
19
|
-
type PluginModule = {
|
|
20
|
-
matchSkillsByKeyword: (userMessage: string, availableSkills: SkillSummary[]) => SkillSummary[];
|
|
21
|
-
formatMatchedSkillsInjection: (matchedSkills: SkillSummary[]) => string;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
async function loadPluginModule(): Promise<PluginModule> {
|
|
25
|
-
return (await import("../../src/plugin")) as unknown as PluginModule;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Tests for the OpenCode keyword matcher and the synthetic injection
|
|
30
|
-
* formatter. These were promoted to named exports in PR 2 of
|
|
31
|
-
* `trigger-aware-skill-discovery` so the trigger-aware behaviour can
|
|
32
|
-
* be exercised without standing up a full plugin session.
|
|
33
|
-
*
|
|
34
|
-
* Coverage:
|
|
35
|
-
* - matchSkillsByKeyword: trigger match (1.5x) outranks description match (1x) for the same query
|
|
36
|
-
* - matchSkillsByKeyword: trigger match does not outrank name match (2x) for the same query
|
|
37
|
-
* - matchSkillsByKeyword: skills without a trigger are scored as before (no regression)
|
|
38
|
-
* - formatMatchedSkillsInjection: trigger text appears in each matched-skill line
|
|
39
|
-
* - formatMatchedSkillsInjection: skills with no trigger render exactly as before
|
|
40
|
-
*/
|
|
41
|
-
describe("matchSkillsByKeyword", () => {
|
|
42
|
-
test("trigger match (1.5x) outranks description match (1x) at the same query (R4)", async () => {
|
|
43
|
-
const { matchSkillsByKeyword } = await loadPluginModule();
|
|
44
|
-
const descSkill: SkillSummary = {
|
|
45
|
-
name: "skill-x",
|
|
46
|
-
description: "auth helper for tokens",
|
|
47
|
-
};
|
|
48
|
-
const triggerSkill: SkillSummary = {
|
|
49
|
-
name: "skill-y",
|
|
50
|
-
description: "unrelated",
|
|
51
|
-
trigger: "auth login",
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const result = matchSkillsByKeyword("auth", [descSkill, triggerSkill]);
|
|
55
|
-
|
|
56
|
-
assert.equal(result.length, 2);
|
|
57
|
-
assert.equal(result[0]?.name, "skill-y", "trigger-matched skill ranks first");
|
|
58
|
-
assert.equal(result[1]?.name, "skill-x", "description-matched skill ranks second");
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test("name match (2x) still outranks trigger match (1.5x) at the same query", async () => {
|
|
62
|
-
const { matchSkillsByKeyword } = await loadPluginModule();
|
|
63
|
-
const nameSkill: SkillSummary = { name: "auth", description: "x" };
|
|
64
|
-
const triggerSkill: SkillSummary = { name: "skill-y", description: "x", trigger: "auth login" };
|
|
65
|
-
|
|
66
|
-
const result = matchSkillsByKeyword("auth", [nameSkill, triggerSkill]);
|
|
67
|
-
|
|
68
|
-
assert.equal(result[0]?.name, "auth", "name match wins over trigger match");
|
|
69
|
-
assert.equal(result[1]?.name, "skill-y");
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("skills with no trigger are scored only on name + description (no regression)", async () => {
|
|
73
|
-
const { matchSkillsByKeyword } = await loadPluginModule();
|
|
74
|
-
const noTriggerA: SkillSummary = { name: "alpha", description: "auth helper" };
|
|
75
|
-
const noTriggerB: SkillSummary = { name: "beta", description: "noise" };
|
|
76
|
-
|
|
77
|
-
const result = matchSkillsByKeyword("auth", [noTriggerA, noTriggerB]);
|
|
78
|
-
|
|
79
|
-
assert.equal(result.length, 1);
|
|
80
|
-
assert.equal(result[0]?.name, "alpha");
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
describe("formatMatchedSkillsInjection", () => {
|
|
85
|
-
test("renders the trigger text on a sub-line for each matched skill (R5)", async () => {
|
|
86
|
-
const { formatMatchedSkillsInjection } = await loadPluginModule();
|
|
87
|
-
const matched: SkillSummary[] = [
|
|
88
|
-
{ name: "skill-y", description: "unrelated", trigger: "auth login" },
|
|
89
|
-
];
|
|
90
|
-
|
|
91
|
-
const output = formatMatchedSkillsInjection(matched);
|
|
92
|
-
|
|
93
|
-
assert.match(output, /skill-y/, "name appears");
|
|
94
|
-
assert.match(output, /trigger: auth login/, "trigger text is rendered on its own line");
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("skills with no trigger render exactly as before (no extra line)", async () => {
|
|
98
|
-
const { formatMatchedSkillsInjection } = await loadPluginModule();
|
|
99
|
-
const matched: SkillSummary[] = [
|
|
100
|
-
{ name: "alpha", description: "auth helper" },
|
|
101
|
-
];
|
|
102
|
-
|
|
103
|
-
const output = formatMatchedSkillsInjection(matched);
|
|
104
|
-
|
|
105
|
-
assert.match(output, /- alpha: auth helper/);
|
|
106
|
-
assert.doesNotMatch(output, /trigger:/, "no trigger line when trigger is undefined");
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test("multiple matched skills each render their own trigger line", async () => {
|
|
110
|
-
const { formatMatchedSkillsInjection } = await loadPluginModule();
|
|
111
|
-
const matched: SkillSummary[] = [
|
|
112
|
-
{ name: "with-trigger", description: "x", trigger: "auth, login" },
|
|
113
|
-
{ name: "no-trigger", description: "y" },
|
|
114
|
-
];
|
|
115
|
-
|
|
116
|
-
const output = formatMatchedSkillsInjection(matched);
|
|
117
|
-
|
|
118
|
-
assert.match(output, /- with-trigger: x\s+trigger: auth, login/);
|
|
119
|
-
assert.match(output, /- no-trigger: y/);
|
|
120
|
-
});
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Regression coverage for the skill-loading callback wiring (PR 1 of
|
|
125
|
-
* `fix-skill-loading-regression`). After the core-decoupling refactor,
|
|
126
|
-
* `createSkillTools()` stopped threading `onSkillLoaded` through to
|
|
127
|
-
* `UseSkill`, so a successful `use_skill` call no longer updated the
|
|
128
|
-
* session's loaded-skill set, and a subsequent keyword-matched
|
|
129
|
-
* `chat.message` re-injected an evaluation prompt for the already-loaded
|
|
130
|
-
* skill.
|
|
131
|
-
*
|
|
132
|
-
* These tests pin both ends of the wiring at the host-adapter boundary:
|
|
133
|
-
* 1. The factory accepts a callback and the tool calls it.
|
|
134
|
-
* 2. The full plugin path (createSkillTools + UseSkill + plugin
|
|
135
|
-
* bookkeeping) updates `loadedSkillsPerSession` so a second
|
|
136
|
-
* matching chat.message does NOT re-inject the skill evaluation.
|
|
137
|
-
*/
|
|
138
|
-
describe("use_skill callback wiring (PR 1)", () => {
|
|
139
|
-
let workspace: FixtureWorkspace;
|
|
140
|
-
const previousSuperpowersMode = process.env.OPENCODE_AGENT_SKILLS_SUPERPOWERS_MODE;
|
|
141
|
-
|
|
142
|
-
beforeEach(async () => {
|
|
143
|
-
workspace = await createFixtureWorkspace();
|
|
144
|
-
process.env.OPENCODE_AGENT_SKILLS_SUPERPOWERS_MODE = "true";
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
afterEach(async () => {
|
|
148
|
-
if (workspace) {
|
|
149
|
-
await workspace.cleanup();
|
|
150
|
-
}
|
|
151
|
-
if (previousSuperpowersMode === undefined) {
|
|
152
|
-
delete process.env.OPENCODE_AGENT_SKILLS_SUPERPOWERS_MODE;
|
|
153
|
-
} else {
|
|
154
|
-
process.env.OPENCODE_AGENT_SKILLS_SUPERPOWERS_MODE = previousSuperpowersMode;
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
/** Drive `chat.message` with a plain text part (the only path the plugin's matcher inspects). */
|
|
159
|
-
async function sendMessage(
|
|
160
|
-
plugin: { "chat.message": (input: unknown, output: unknown) => Promise<void> },
|
|
161
|
-
sessionID: string,
|
|
162
|
-
text: string,
|
|
163
|
-
): Promise<void> {
|
|
164
|
-
await plugin["chat.message"](
|
|
165
|
-
{},
|
|
166
|
-
{
|
|
167
|
-
message: {
|
|
168
|
-
sessionID,
|
|
169
|
-
model: { providerID: "test-provider", modelID: "test-model" },
|
|
170
|
-
agent: "test-agent",
|
|
171
|
-
},
|
|
172
|
-
parts: [{ type: "text", text, synthetic: false }],
|
|
173
|
-
} as any,
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
test("createSkillTools forwards onSkillLoaded so UseSkill invokes it (R3)", async () => {
|
|
178
|
-
const { createSkillTools } = await import("../../src/tools");
|
|
179
|
-
const { createOpencodeSkillHost } = await import("../../src/host");
|
|
180
|
-
|
|
181
|
-
const client = createMockOpencodeClient();
|
|
182
|
-
const shell = createShellRecorder();
|
|
183
|
-
const host = createOpencodeSkillHost(client.client as any);
|
|
184
|
-
const calls: Array<{ sessionID: string; skillName: string }> = [];
|
|
185
|
-
|
|
186
|
-
const tools = createSkillTools(
|
|
187
|
-
host,
|
|
188
|
-
shell.shell,
|
|
189
|
-
workspace.projectRoot,
|
|
190
|
-
(sessionID, skillName) => calls.push({ sessionID, skillName }),
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
const result = await tools.UseSkill.execute(
|
|
194
|
-
{ skill: "scripted-skill" },
|
|
195
|
-
{ sessionID: "callback-test" } as any,
|
|
196
|
-
);
|
|
197
|
-
|
|
198
|
-
assert.match(result, /loaded\./i, "skill load returns the success message");
|
|
199
|
-
assert.equal(calls.length, 1, "onSkillLoaded should fire exactly once on a successful load");
|
|
200
|
-
assert.deepEqual(calls[0], { sessionID: "callback-test", skillName: "scripted-skill" });
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
test("plugin updates loadedSkillsPerSession so a repeat chat.message does not re-inject (R3 dedupe)", async () => {
|
|
204
|
-
const { SkillsPlugin } = await import("../../src");
|
|
205
|
-
|
|
206
|
-
const client = createMockOpencodeClient();
|
|
207
|
-
const shell = createShellRecorder();
|
|
208
|
-
const plugin = await SkillsPlugin({
|
|
209
|
-
client: client.client,
|
|
210
|
-
$: shell.shell,
|
|
211
|
-
directory: workspace.projectRoot,
|
|
212
|
-
} as any);
|
|
213
|
-
|
|
214
|
-
const SESSION = "session-dedupe-callback";
|
|
215
|
-
|
|
216
|
-
// First chat.message: bootstrap the session with the available-skills
|
|
217
|
-
// block; the keyword matcher is short-circuited on the first message.
|
|
218
|
-
await sendMessage(plugin, SESSION, "hello");
|
|
219
|
-
const promptsAfterBootstrap = client.prompts.length;
|
|
220
|
-
assert.ok(
|
|
221
|
-
client.prompts.some((p) => /<available-skills>/.test(p.text)),
|
|
222
|
-
"first message injects the available-skills block",
|
|
223
|
-
);
|
|
224
|
-
|
|
225
|
-
// Load scripted-skill via use_skill. With the regression, no callback
|
|
226
|
-
// fires and loadedSkillsPerSession is NOT updated.
|
|
227
|
-
const loadResult = await plugin.tool.use_skill.execute(
|
|
228
|
-
{ skill: "scripted-skill" },
|
|
229
|
-
{ sessionID: SESSION } as any,
|
|
230
|
-
);
|
|
231
|
-
assert.match(loadResult, /loaded\./i, "use_skill reports a successful load");
|
|
232
|
-
assert.ok(
|
|
233
|
-
client.prompts.slice(promptsAfterBootstrap).some((p) =>
|
|
234
|
-
/<skill name="scripted-skill">/.test(p.text),
|
|
235
|
-
),
|
|
236
|
-
"use_skill injects the skill content",
|
|
237
|
-
);
|
|
238
|
-
|
|
239
|
-
// Second chat.message with a keyword that also matches scripted-skill.
|
|
240
|
-
// Other skills may legitimately match too, but scripted-skill MUST be
|
|
241
|
-
// filtered out by the loaded-skill set after the fix. Before the fix,
|
|
242
|
-
// scripted-skill appears because loadedSkillsPerSession was never
|
|
243
|
-
// updated by use_skill (no callback was wired).
|
|
244
|
-
const promptsBeforeRepeat = client.prompts.length;
|
|
245
|
-
await sendMessage(plugin, SESSION, "use the script skill");
|
|
246
|
-
const newPrompts = client.prompts.slice(promptsBeforeRepeat);
|
|
247
|
-
const evaluationInjections = newPrompts.filter((p) =>
|
|
248
|
-
/<skill-evaluation-required>/.test(p.text),
|
|
249
|
-
);
|
|
250
|
-
for (const prompt of evaluationInjections) {
|
|
251
|
-
assert.doesNotMatch(
|
|
252
|
-
prompt.text,
|
|
253
|
-
/^- scripted-skill:/m,
|
|
254
|
-
"loaded-skill state must suppress scripted-skill from re-injection",
|
|
255
|
-
);
|
|
256
|
-
}
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
test("use_skill still loads when no callback is registered (R3 missing-callback)", async () => {
|
|
260
|
-
const { SkillsPlugin } = await import("../../src");
|
|
261
|
-
|
|
262
|
-
const client = createMockOpencodeClient();
|
|
263
|
-
const shell = createShellRecorder();
|
|
264
|
-
const plugin = await SkillsPlugin({
|
|
265
|
-
client: client.client,
|
|
266
|
-
$: shell.shell,
|
|
267
|
-
directory: workspace.projectRoot,
|
|
268
|
-
} as any);
|
|
269
|
-
|
|
270
|
-
const result = await plugin.tool.use_skill.execute(
|
|
271
|
-
{ skill: "scripted-skill" },
|
|
272
|
-
{ sessionID: "session-no-callback" } as any,
|
|
273
|
-
);
|
|
274
|
-
|
|
275
|
-
assert.match(result, /loaded\./i, "skill load returns the success message");
|
|
276
|
-
assert.ok(
|
|
277
|
-
client.prompts.some((p) => /<skill name="scripted-skill">/.test(p.text)),
|
|
278
|
-
"skill content was injected even with no callback registered",
|
|
279
|
-
);
|
|
280
|
-
});
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* PR 2 plugin refactor coverage. The closure-scoped refactor must:
|
|
285
|
-
* 1. Keep two plugin instances isolated (no shared session state).
|
|
286
|
-
* 2. Trigger exactly one discovery per chat.message handler call.
|
|
287
|
-
* 3. Preserve first-message bootstrap and subsequent-message matcher
|
|
288
|
-
* behavior.
|
|
289
|
-
*/
|
|
290
|
-
describe("plugin refactor (PR 2)", () => {
|
|
291
|
-
let workspace: FixtureWorkspace;
|
|
292
|
-
const previousSuperpowersMode = process.env.OPENCODE_AGENT_SKILLS_SUPERPOWERS_MODE;
|
|
293
|
-
|
|
294
|
-
beforeEach(async () => {
|
|
295
|
-
workspace = await createFixtureWorkspace();
|
|
296
|
-
process.env.OPENCODE_AGENT_SKILLS_SUPERPOWERS_MODE = "true";
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
afterEach(async () => {
|
|
300
|
-
if (workspace) await workspace.cleanup();
|
|
301
|
-
if (previousSuperpowersMode === undefined) {
|
|
302
|
-
delete process.env.OPENCODE_AGENT_SKILLS_SUPERPOWERS_MODE;
|
|
303
|
-
} else {
|
|
304
|
-
process.env.OPENCODE_AGENT_SKILLS_SUPERPOWERS_MODE = previousSuperpowersMode;
|
|
305
|
-
}
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
async function sendMessage(
|
|
309
|
-
plugin: { "chat.message": (input: unknown, output: unknown) => Promise<void> },
|
|
310
|
-
sessionID: string,
|
|
311
|
-
text: string,
|
|
312
|
-
): Promise<void> {
|
|
313
|
-
await plugin["chat.message"](
|
|
314
|
-
{},
|
|
315
|
-
{
|
|
316
|
-
message: {
|
|
317
|
-
sessionID,
|
|
318
|
-
model: { providerID: "test-provider", modelID: "test-model" },
|
|
319
|
-
agent: "test-agent",
|
|
320
|
-
},
|
|
321
|
-
parts: [{ type: "text", text, synthetic: false }],
|
|
322
|
-
} as any,
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
test("two plugin instances do not share session state (PR 2 isolation)", async () => {
|
|
327
|
-
const { SkillsPlugin } = await import("../../src");
|
|
328
|
-
const clientA = createMockOpencodeClient();
|
|
329
|
-
const clientB = createMockOpencodeClient();
|
|
330
|
-
const shell = createShellRecorder();
|
|
331
|
-
const pluginA = await SkillsPlugin({ client: clientA.client, $: shell.shell, directory: workspace.projectRoot } as any);
|
|
332
|
-
const pluginB = await SkillsPlugin({ client: clientB.client, $: shell.shell, directory: workspace.projectRoot } as any);
|
|
333
|
-
|
|
334
|
-
await sendMessage(pluginA, "shared-session-id", "hello");
|
|
335
|
-
assert.ok(clientA.prompts.some((p) => /<available-skills>/.test(p.text)), "plugin A bootstraps");
|
|
336
|
-
|
|
337
|
-
const promptsBBefore = clientB.prompts.length;
|
|
338
|
-
await sendMessage(pluginB, "shared-session-id", "hello");
|
|
339
|
-
assert.ok(
|
|
340
|
-
clientB.prompts.slice(promptsBBefore).some((p) => /<available-skills>/.test(p.text)),
|
|
341
|
-
"plugin B independently bootstraps the same session id",
|
|
342
|
-
);
|
|
343
|
-
|
|
344
|
-
await pluginA.tool.use_skill.execute(
|
|
345
|
-
{ skill: "scripted-skill" },
|
|
346
|
-
{ sessionID: "shared-session-id" } as any,
|
|
347
|
-
);
|
|
348
|
-
const promptsBBeforeKeyword = clientB.prompts.length;
|
|
349
|
-
await sendMessage(pluginB, "shared-session-id", "use the script skill");
|
|
350
|
-
const evaluationB = clientB.prompts
|
|
351
|
-
.slice(promptsBBeforeKeyword)
|
|
352
|
-
.filter((p) => /<skill-evaluation-required>/.test(p.text));
|
|
353
|
-
assert.ok(
|
|
354
|
-
evaluationB.some((p) => /^- scripted-skill:/m.test(p.text)),
|
|
355
|
-
"plugin B sees scripted-skill as not-loaded (its own loaded set is independent)",
|
|
356
|
-
);
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
test("chat.message discovers skills exactly once per handler invocation (PR 2 R3+R4+R5)", async () => {
|
|
360
|
-
const { SkillsPlugin } = await import("../../src");
|
|
361
|
-
const client = createMockOpencodeClient();
|
|
362
|
-
const shell = createShellRecorder();
|
|
363
|
-
const plugin = await SkillsPlugin({ client: client.client, $: shell.shell, directory: workspace.projectRoot } as any);
|
|
364
|
-
|
|
365
|
-
// The fixture has one duplicate (project + user `shared-skill`); each
|
|
366
|
-
// `discoverAllSkills` triggers exactly one `console.warn` via the
|
|
367
|
-
// default duplicate callback, so counting warns counts discoveries.
|
|
368
|
-
const warnSpy = mock.method(console, "warn", () => {});
|
|
369
|
-
try {
|
|
370
|
-
const warns = (): number => warnSpy.mock.calls.filter(
|
|
371
|
-
(c) => typeof c.arguments[0] === "string" && c.arguments[0].startsWith("Skill name conflict:"),
|
|
372
|
-
).length;
|
|
373
|
-
|
|
374
|
-
await sendMessage(plugin, "spy-session", "use the script skill");
|
|
375
|
-
assert.equal(warns(), 1, "single chat.message = single discovery");
|
|
376
|
-
|
|
377
|
-
const before = warns();
|
|
378
|
-
await sendMessage(plugin, "spy-session", "use the script skill again");
|
|
379
|
-
assert.equal(warns() - before, 1, "no cross-request caching");
|
|
380
|
-
} finally {
|
|
381
|
-
warnSpy.mock.restore();
|
|
382
|
-
}
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
test("first-message bootstrap and subsequent keyword match are both preserved (PR 2)", async () => {
|
|
386
|
-
const { SkillsPlugin } = await import("../../src");
|
|
387
|
-
const client = createMockOpencodeClient();
|
|
388
|
-
const shell = createShellRecorder();
|
|
389
|
-
const plugin = await SkillsPlugin({ client: client.client, $: shell.shell, directory: workspace.projectRoot } as any);
|
|
390
|
-
const SESSION = "preserved-session";
|
|
391
|
-
|
|
392
|
-
await sendMessage(plugin, SESSION, "first message");
|
|
393
|
-
assert.ok(client.prompts.some((p) => /<available-skills>/.test(p.text)), "first injects available-skills");
|
|
394
|
-
assert.ok(client.prompts.some((p) => /You have superpowers\./.test(p.text)), "first injects superpowers");
|
|
395
|
-
assert.ok(
|
|
396
|
-
!client.prompts.some((p) => /<skill-evaluation-required>/.test(p.text)),
|
|
397
|
-
"first message does NOT run matcher",
|
|
398
|
-
);
|
|
399
|
-
|
|
400
|
-
await plugin.tool.use_skill.execute({ skill: "scripted-skill" }, { sessionID: SESSION } as any);
|
|
401
|
-
|
|
402
|
-
const before = client.prompts.length;
|
|
403
|
-
await sendMessage(plugin, SESSION, "use the script skill");
|
|
404
|
-
const newPrompts = client.prompts.slice(before);
|
|
405
|
-
assert.ok(!newPrompts.some((p) => /<available-skills>/.test(p.text)), "subsequent does NOT re-inject available-skills");
|
|
406
|
-
const evals = newPrompts.filter((p) => /<skill-evaluation-required>/.test(p.text));
|
|
407
|
-
for (const p of evals) {
|
|
408
|
-
assert.doesNotMatch(p.text, /^- scripted-skill:/m, "loaded skills are filtered from match");
|
|
409
|
-
}
|
|
410
|
-
});
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* PR 3 diagnostic + SDK-shape hardening coverage. Verifies that:
|
|
415
|
-
* 1. Malformed hook payloads (null / undefined / partial) degrade
|
|
416
|
-
* gracefully — no throw, no spurious prompts.
|
|
417
|
-
* 2. The `debugLog` helper only emits to stderr when
|
|
418
|
-
* `OPENCODE_AGENT_SKILLS_DEBUG` is set, and stays silent otherwise.
|
|
419
|
-
* 3. The normal chat.message flow produces zero user-visible noise
|
|
420
|
-
* (no debug output, no exception).
|
|
421
|
-
*/
|
|
422
|
-
describe("PR 3 diagnostics + SDK shapes (R6 + R8)", () => {
|
|
423
|
-
let workspace: FixtureWorkspace;
|
|
424
|
-
const previousSuperpowersMode = process.env.OPENCODE_AGENT_SKILLS_SUPERPOWERS_MODE;
|
|
425
|
-
const previousDebugMode = process.env.OPENCODE_AGENT_SKILLS_DEBUG;
|
|
426
|
-
|
|
427
|
-
beforeEach(async () => {
|
|
428
|
-
workspace = await createFixtureWorkspace();
|
|
429
|
-
process.env.OPENCODE_AGENT_SKILLS_SUPERPOWERS_MODE = "true";
|
|
430
|
-
delete process.env.OPENCODE_AGENT_SKILLS_DEBUG;
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
afterEach(async () => {
|
|
434
|
-
if (workspace) await workspace.cleanup();
|
|
435
|
-
if (previousSuperpowersMode === undefined) {
|
|
436
|
-
delete process.env.OPENCODE_AGENT_SKILLS_SUPERPOWERS_MODE;
|
|
437
|
-
} else {
|
|
438
|
-
process.env.OPENCODE_AGENT_SKILLS_SUPERPOWERS_MODE = previousSuperpowersMode;
|
|
439
|
-
}
|
|
440
|
-
if (previousDebugMode === undefined) {
|
|
441
|
-
delete process.env.OPENCODE_AGENT_SKILLS_DEBUG;
|
|
442
|
-
} else {
|
|
443
|
-
process.env.OPENCODE_AGENT_SKILLS_DEBUG = previousDebugMode;
|
|
444
|
-
}
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
async function makePlugin() {
|
|
448
|
-
const { SkillsPlugin } = await import("../../src");
|
|
449
|
-
const client = createMockOpencodeClient();
|
|
450
|
-
const shell = createShellRecorder();
|
|
451
|
-
const plugin = await SkillsPlugin({
|
|
452
|
-
client: client.client,
|
|
453
|
-
$: shell.shell,
|
|
454
|
-
directory: workspace.projectRoot,
|
|
455
|
-
} as any);
|
|
456
|
-
return { plugin, client };
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
test("chat.message with undefined output degrades gracefully (no throw, no prompts)", async () => {
|
|
460
|
-
const { plugin, client } = await makePlugin();
|
|
461
|
-
await assert.doesNotReject(
|
|
462
|
-
async () => {
|
|
463
|
-
await plugin["chat.message"]({}, undefined);
|
|
464
|
-
},
|
|
465
|
-
"undefined output must not throw",
|
|
466
|
-
);
|
|
467
|
-
assert.equal(client.prompts.length, 0, "no prompts injected on malformed payload");
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
test("chat.message with null output degrades gracefully (no throw, no prompts)", async () => {
|
|
471
|
-
const { plugin, client } = await makePlugin();
|
|
472
|
-
await assert.doesNotReject(async () => {
|
|
473
|
-
await plugin["chat.message"]({}, null);
|
|
474
|
-
});
|
|
475
|
-
assert.equal(client.prompts.length, 0);
|
|
476
|
-
});
|
|
477
|
-
|
|
478
|
-
test("chat.message with missing sessionID degrades gracefully", async () => {
|
|
479
|
-
const { plugin, client } = await makePlugin();
|
|
480
|
-
await assert.doesNotReject(async () => {
|
|
481
|
-
await plugin["chat.message"]({}, { message: {}, parts: [] });
|
|
482
|
-
});
|
|
483
|
-
assert.equal(client.prompts.length, 0, "partial payload must not inject anything");
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
test("event handler with undefined event degrades gracefully", async () => {
|
|
487
|
-
const { plugin } = await makePlugin();
|
|
488
|
-
await assert.doesNotReject(async () => {
|
|
489
|
-
await plugin.event({ event: undefined });
|
|
490
|
-
});
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
test("event handler with unknown event type degrades gracefully", async () => {
|
|
494
|
-
const { plugin } = await makePlugin();
|
|
495
|
-
await assert.doesNotReject(async () => {
|
|
496
|
-
await plugin.event({ event: { type: "session.created" } });
|
|
497
|
-
});
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
test("debugLog emits to stderr when OPENCODE_AGENT_SKILLS_DEBUG is set", async () => {
|
|
501
|
-
const { debugLog } = await import("opencode-agent-skills-md-core");
|
|
502
|
-
process.env.OPENCODE_AGENT_SKILLS_DEBUG = "1";
|
|
503
|
-
const spy = mock.method(console, "error", () => {});
|
|
504
|
-
try {
|
|
505
|
-
debugLog("test-context", new Error("boom"));
|
|
506
|
-
assert.equal(spy.mock.calls.length, 1, "debug output appears when flag is set");
|
|
507
|
-
const firstCall = spy.mock.calls[0];
|
|
508
|
-
assert.ok(firstCall, "spy captured a call");
|
|
509
|
-
const args = firstCall.arguments;
|
|
510
|
-
assert.match(String(args[0]), /opencode-agent-skills-md/, "debug prefix is present");
|
|
511
|
-
assert.equal(args[1], "test-context");
|
|
512
|
-
} finally {
|
|
513
|
-
spy.mock.restore();
|
|
514
|
-
}
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
test("debugLog stays silent when OPENCODE_AGENT_SKILLS_DEBUG is unset", async () => {
|
|
518
|
-
const { debugLog } = await import("opencode-agent-skills-md-core");
|
|
519
|
-
delete process.env.OPENCODE_AGENT_SKILLS_DEBUG;
|
|
520
|
-
const spy = mock.method(console, "error", () => {});
|
|
521
|
-
try {
|
|
522
|
-
debugLog("test-context", new Error("boom"));
|
|
523
|
-
assert.equal(spy.mock.calls.length, 0, "no debug output when flag is unset");
|
|
524
|
-
} finally {
|
|
525
|
-
spy.mock.restore();
|
|
526
|
-
}
|
|
527
|
-
});
|
|
528
|
-
|
|
529
|
-
test("normal chat.message flow produces zero user-visible noise", async () => {
|
|
530
|
-
const { plugin, client } = await makePlugin();
|
|
531
|
-
const spy = mock.method(console, "error", () => {});
|
|
532
|
-
try {
|
|
533
|
-
const SESSION = "noise-test-session";
|
|
534
|
-
await plugin["chat.message"](
|
|
535
|
-
{},
|
|
536
|
-
{
|
|
537
|
-
message: {
|
|
538
|
-
sessionID: SESSION,
|
|
539
|
-
model: { providerID: "test-provider", modelID: "test-model" },
|
|
540
|
-
agent: "test-agent",
|
|
541
|
-
},
|
|
542
|
-
parts: [{ type: "text", text: "hello world", synthetic: false }],
|
|
543
|
-
} as any,
|
|
544
|
-
);
|
|
545
|
-
assert.equal(spy.mock.calls.length, 0, "normal flow is silent when debug flag is off");
|
|
546
|
-
assert.ok(client.prompts.length > 0, "normal flow still injects bootstrap content");
|
|
547
|
-
} finally {
|
|
548
|
-
spy.mock.restore();
|
|
549
|
-
}
|
|
550
|
-
});
|
|
551
|
-
});
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { createRequire } from "node:module";
|
|
3
|
-
import { describe, test } from "node:test";
|
|
4
|
-
|
|
5
|
-
const require = createRequire(import.meta.url);
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Package boundary smoke test for `opencode-agent-skills-md`.
|
|
9
|
-
*
|
|
10
|
-
* The plugin package now lives at `packages/opencode-agent-skills-md/` and is
|
|
11
|
-
* consumed via the workspace link `opencode-agent-skills-md`. Three
|
|
12
|
-
* guarantees pinned by this test:
|
|
13
|
-
*
|
|
14
|
-
* 1. The package's `exports` field resolves `.` to the plugin entry
|
|
15
|
-
* under the package's own `src/` directory (via the workspace link
|
|
16
|
-
* into `packages/opencode-agent-skills-md/`).
|
|
17
|
-
* 2. Importing the entry is safe at module-load time — it does not
|
|
18
|
-
* instantiate anything, it just re-exports the plugin factory.
|
|
19
|
-
* 3. The default export and the `SkillsPlugin` named export are both
|
|
20
|
-
* the plugin factory function consumed by OpenCode.
|
|
21
|
-
*/
|
|
22
|
-
describe("opencode-agent-skills-md package root export", () => {
|
|
23
|
-
test("resolves . via the workspace link to packages/opencode-agent-skills-md/src/index.ts", () => {
|
|
24
|
-
const resolved = require.resolve("opencode-agent-skills-md");
|
|
25
|
-
|
|
26
|
-
assert.match(
|
|
27
|
-
resolved,
|
|
28
|
-
/[\\/]packages[\\/]opencode-agent-skills-md[\\/]src[\\/]index\.ts$/,
|
|
29
|
-
`expected . to resolve under packages/opencode-agent-skills-md/src, got: ${resolved}`,
|
|
30
|
-
);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test("default export is the SkillsPlugin factory function", async () => {
|
|
34
|
-
const entryPath = require.resolve("opencode-agent-skills-md");
|
|
35
|
-
// Dynamic import is intentional: it proves module load is side-effect
|
|
36
|
-
// safe (the factory itself is not invoked).
|
|
37
|
-
const mod = await import(entryPath);
|
|
38
|
-
|
|
39
|
-
assert.equal(
|
|
40
|
-
typeof mod.default,
|
|
41
|
-
"function",
|
|
42
|
-
"default export should be the SkillsPlugin factory",
|
|
43
|
-
);
|
|
44
|
-
assert.equal(
|
|
45
|
-
typeof mod.SkillsPlugin,
|
|
46
|
-
"function",
|
|
47
|
-
"SkillsPlugin named export should be a function",
|
|
48
|
-
);
|
|
49
|
-
assert.equal(
|
|
50
|
-
mod.default,
|
|
51
|
-
mod.SkillsPlugin,
|
|
52
|
-
"default export and SkillsPlugin should be the same factory",
|
|
53
|
-
);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("module load is side-effect safe (does not throw, does not instantiate)", () => {
|
|
57
|
-
// Reaching this point already exercised module load via the require +
|
|
58
|
-
// dynamic import above. This test guards against future regressions
|
|
59
|
-
// where someone might add a top-level `new SomeSDK()` in the entry.
|
|
60
|
-
assert.doesNotThrow(() => {
|
|
61
|
-
// Re-resolve without invoking; the spec only requires that the
|
|
62
|
-
// entry module be importable in isolation.
|
|
63
|
-
require.resolve("opencode-agent-skills-md");
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
});
|