opencode-agent-skills-md 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.beads/.local_version +1 -0
- package/.beads/README.md +81 -0
- package/.beads/config.yaml +61 -0
- package/.beads/deletions.jsonl +1 -0
- package/.beads/issues.jsonl +64 -0
- package/.beads/metadata.json +4 -0
- package/.gitattributes +3 -0
- package/.github/CODEOWNERS +1 -0
- package/.github/copilot-instructions.md +78 -0
- package/.github/dependabot.yml +13 -0
- package/.github/workflows/release.yml +51 -0
- package/.opencode/command/test-compaction.md +9 -0
- package/.opencode/command/test-find-skills.md +7 -0
- package/.opencode/command/test-read-skill-file.md +14 -0
- package/.opencode/command/test-run-skill-script.md +13 -0
- package/.opencode/command/test-skills.md +14 -0
- package/.opencode/command/test-use-skill.md +10 -0
- package/.opencode/skills/git-helper/SKILL.md +65 -0
- package/.opencode/skills/test-skill/SKILL.md +43 -0
- package/.opencode/skills/test-skill/example-config.json +16 -0
- package/.opencode/skills/test-skill/helper-docs.md +29 -0
- package/.opencode/skills/test-skill/scripts/echo-args +14 -0
- package/.opencode/skills/test-skill/scripts/greet +6 -0
- package/AGENTS.md +43 -0
- package/CHANGELOG.md +178 -0
- package/Justfile +39 -0
- package/LICENSE +9 -0
- package/README.md +189 -0
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/specs/core-decoupling/spec.md +74 -0
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/tasks.md +64 -0
- package/openspec/changes/archive/2026-06-14-skills-core-decouple/verify-report.md +75 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/apply-progress.md +136 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/archive-report.md +77 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/design.md +89 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/proposal.md +65 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/specs/core-decoupling/spec.md +77 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/tasks.md +65 -0
- package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/verify-report.md +165 -0
- package/openspec/specs/core-decoupling/spec.md +110 -0
- package/package.json +35 -0
- package/packages/core/package.json +30 -0
- package/packages/core/src/content.d.ts +16 -0
- package/packages/core/src/content.ts +30 -0
- package/packages/core/src/debug.ts +16 -0
- package/packages/core/src/discovery.d.ts +86 -0
- package/packages/core/src/discovery.ts +257 -0
- package/packages/core/src/index.d.ts +20 -0
- package/packages/core/src/index.ts +55 -0
- package/packages/core/src/match.d.ts +19 -0
- package/packages/core/src/match.ts +75 -0
- package/packages/core/src/parse.d.ts +26 -0
- package/packages/core/src/parse.ts +141 -0
- package/packages/core/src/scripts.d.ts +17 -0
- package/packages/core/src/scripts.ts +79 -0
- package/packages/core/src/search.d.ts +83 -0
- package/packages/core/src/search.ts +188 -0
- package/packages/core/src/types.d.ts +82 -0
- package/packages/core/src/types.ts +131 -0
- package/packages/core/src/walk.ts +109 -0
- package/packages/core/tests/agnostic.test.ts +346 -0
- package/packages/core/tests/content.test.ts +65 -0
- package/packages/core/tests/discovery.test.ts +370 -0
- package/packages/core/tests/package-boundary.test.ts +310 -0
- package/packages/core/tests/parse-trigger.test.ts +282 -0
- package/packages/core/tests/search.test.ts +374 -0
- package/packages/core/tests/subpath.test.ts +87 -0
- package/packages/core/tsconfig.json +10 -0
- package/packages/opencode-agent-skills-md/package.json +42 -0
- package/packages/opencode-agent-skills-md/rolldown.config.js +48 -0
- package/packages/opencode-agent-skills-md/src/cli/config.ts +522 -0
- package/packages/opencode-agent-skills-md/src/cli/install.ts +111 -0
- package/packages/opencode-agent-skills-md/src/cli/main.ts +201 -0
- package/packages/opencode-agent-skills-md/src/cli/real-fs.ts +51 -0
- package/packages/opencode-agent-skills-md/src/cli/status.ts +183 -0
- package/packages/opencode-agent-skills-md/src/cli/uninstall.ts +157 -0
- package/packages/opencode-agent-skills-md/src/host.ts +119 -0
- package/packages/opencode-agent-skills-md/src/index.ts +25 -0
- package/packages/opencode-agent-skills-md/src/plugin.ts +343 -0
- package/packages/opencode-agent-skills-md/src/sdk.ts +71 -0
- package/packages/opencode-agent-skills-md/src/tools.ts +373 -0
- package/packages/opencode-agent-skills-md/tests/cli-commands.test.ts +1423 -0
- package/packages/opencode-agent-skills-md/tests/e2e/startup-smoke.test.ts +66 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.claude/skills/claude-user-only-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/shared-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/user-only-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.claude/skills/claude-project-only-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/go-tester/SKILL.md +12 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/nested/team/nested-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/rust-tester/SKILL.md +11 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/bin/echo.sh +2 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/docs/reference.md +1 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/shared-skill/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/using-superpowers/SKILL.md +8 -0
- package/packages/opencode-agent-skills-md/tests/integration/helpers/mock-opencode.ts +114 -0
- package/packages/opencode-agent-skills-md/tests/integration/plugin.test.ts +316 -0
- package/packages/opencode-agent-skills-md/tests/integration/skill-discovery.test.ts +315 -0
- package/packages/opencode-agent-skills-md/tests/opencode/host.test.ts +179 -0
- package/packages/opencode-agent-skills-md/tests/opencode/plugin.test.ts +551 -0
- package/packages/opencode-agent-skills-md/tests/opencode/subpath.test.ts +66 -0
- package/packages/opencode-agent-skills-md/tests/opencode/tools.test.ts +213 -0
- package/packages/opencode-agent-skills-md/tests/package-boundary.test.ts +346 -0
- package/packages/opencode-agent-skills-md/tests/tools-security.test.ts +72 -0
- package/packages/opencode-agent-skills-md/tsconfig.build.json +11 -0
- package/packages/opencode-agent-skills-md/tsconfig.json +10 -0
- package/plans/001-ci-gate.md +177 -0
- package/plans/002-is-path-safe.md +243 -0
- package/plans/003-escape-prompts.md +310 -0
- package/plans/004-test-security-paths.md +228 -0
- package/plans/005-stop-swallowing-errors.md +246 -0
- package/plans/006-preserve-jsonc-commas.md +144 -0
- package/plans/007-write-before-purge.md +144 -0
- package/plans/008-reuse-walkdir-for-list-skill-files.md +164 -0
- package/plans/README.md +43 -0
- package/pnpm-workspace.yaml +6 -0
- package/tests/workspace.test.ts +367 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Project documentation for scripted skill.
|
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
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
|
+
});
|