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,282 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import { describe, test, before, after } from "node:test";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* `trigger` frontmatter parsing — exercises the contract in R1/R2 of the
|
|
9
|
-
* `sdd/trigger-aware-skill-discovery` spec.
|
|
10
|
-
*
|
|
11
|
-
* Scenarios covered:
|
|
12
|
-
* - trigger string is parsed and surfaced on the resulting Skill
|
|
13
|
-
* - missing trigger is accepted; `Skill.trigger` is `undefined`
|
|
14
|
-
* - invalid (non-string) trigger rejects the file (parseSkillFile -> null)
|
|
15
|
-
*/
|
|
16
|
-
describe("parseSkillFile — trigger frontmatter", () => {
|
|
17
|
-
let workspace: string;
|
|
18
|
-
|
|
19
|
-
before(async () => {
|
|
20
|
-
workspace = await mkdtemp(path.join(tmpdir(), "opencode-agent-skills-md-trigger-"));
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
after(async () => {
|
|
24
|
-
if (workspace) {
|
|
25
|
-
await rm(workspace, { recursive: true, force: true });
|
|
26
|
-
}
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
async function writeSkill(
|
|
30
|
-
relDir: string,
|
|
31
|
-
body: string
|
|
32
|
-
): Promise<string> {
|
|
33
|
-
const dir = path.join(workspace, relDir);
|
|
34
|
-
await mkdir(dir, { recursive: true });
|
|
35
|
-
const skillPath = path.join(dir, "SKILL.md");
|
|
36
|
-
await writeFile(skillPath, body, "utf8");
|
|
37
|
-
return skillPath;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
test("parses a non-empty trigger string into Skill.trigger", async () => {
|
|
41
|
-
const skillPath = await writeSkill(
|
|
42
|
-
"with-trigger",
|
|
43
|
-
[
|
|
44
|
-
"---",
|
|
45
|
-
"name: with-trigger",
|
|
46
|
-
"description: skill whose frontmatter carries a trigger",
|
|
47
|
-
"trigger: auth, login",
|
|
48
|
-
"---",
|
|
49
|
-
"",
|
|
50
|
-
"# With Trigger",
|
|
51
|
-
].join("\n"),
|
|
52
|
-
);
|
|
53
|
-
|
|
54
|
-
const { parseSkillFile } = await import("../src/index.ts");
|
|
55
|
-
const skill = await parseSkillFile(skillPath, "with-trigger", "project");
|
|
56
|
-
|
|
57
|
-
assert.ok(skill, "expected parseSkillFile to return a Skill for valid trigger");
|
|
58
|
-
assert.equal(skill?.name, "with-trigger");
|
|
59
|
-
assert.equal(skill?.trigger, "auth, login");
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test("omitted trigger leaves Skill.trigger as undefined and still parses", async () => {
|
|
63
|
-
const skillPath = await writeSkill(
|
|
64
|
-
"no-trigger",
|
|
65
|
-
[
|
|
66
|
-
"---",
|
|
67
|
-
"name: no-trigger",
|
|
68
|
-
"description: skill whose frontmatter omits trigger",
|
|
69
|
-
"---",
|
|
70
|
-
"",
|
|
71
|
-
"# No Trigger",
|
|
72
|
-
].join("\n"),
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
const { parseSkillFile } = await import("../src/index.ts");
|
|
76
|
-
const skill = await parseSkillFile(skillPath, "no-trigger", "project");
|
|
77
|
-
|
|
78
|
-
assert.ok(skill, "expected parseSkillFile to succeed without trigger");
|
|
79
|
-
assert.equal(skill?.name, "no-trigger");
|
|
80
|
-
assert.equal(skill?.trigger, undefined);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test("non-string trigger value rejects the file (parseSkillFile -> null)", async () => {
|
|
84
|
-
const skillPath = await writeSkill(
|
|
85
|
-
"bad-trigger",
|
|
86
|
-
[
|
|
87
|
-
"---",
|
|
88
|
-
"name: bad-trigger",
|
|
89
|
-
"description: skill whose frontmatter has an invalid trigger",
|
|
90
|
-
"trigger: 123",
|
|
91
|
-
"---",
|
|
92
|
-
"",
|
|
93
|
-
"# Bad Trigger",
|
|
94
|
-
].join("\n"),
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
const { parseSkillFile } = await import("../src/index.ts");
|
|
98
|
-
const skill = await parseSkillFile(skillPath, "bad-trigger", "project");
|
|
99
|
-
|
|
100
|
-
assert.equal(skill, null, "expected parseSkillFile to return null for invalid trigger type");
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Safe narrowing in `validateFrontmatter` (PR1b).
|
|
106
|
-
*
|
|
107
|
-
* Each frontmatter field is validated individually before the result is
|
|
108
|
-
* constructed — no `as unknown as SkillFrontmatter` cast. These tests
|
|
109
|
-
* confirm `parseSkillFile` returns `null` for every shape the validator
|
|
110
|
-
* rejects, and that valid frontmatter round-trips through to the Skill.
|
|
111
|
-
*/
|
|
112
|
-
describe("parseSkillFile — safe frontmatter narrowing", () => {
|
|
113
|
-
let workspace: string;
|
|
114
|
-
|
|
115
|
-
before(async () => {
|
|
116
|
-
workspace = await mkdtemp(path.join(tmpdir(), "opencode-agent-skills-md-narrow-"));
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
after(async () => {
|
|
120
|
-
if (workspace) {
|
|
121
|
-
await rm(workspace, { recursive: true, force: true });
|
|
122
|
-
}
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
async function writeSkill(
|
|
126
|
-
relDir: string,
|
|
127
|
-
body: string
|
|
128
|
-
): Promise<string> {
|
|
129
|
-
const dir = path.join(workspace, relDir);
|
|
130
|
-
await mkdir(dir, { recursive: true });
|
|
131
|
-
const skillPath = path.join(dir, "SKILL.md");
|
|
132
|
-
await writeFile(skillPath, body, "utf8");
|
|
133
|
-
return skillPath;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
test("rejects non-string name (parseSkillFile -> null)", async () => {
|
|
137
|
-
const skillPath = await writeSkill(
|
|
138
|
-
"bad-name-type",
|
|
139
|
-
[
|
|
140
|
-
"---",
|
|
141
|
-
"name: 123",
|
|
142
|
-
"description: hi",
|
|
143
|
-
"---",
|
|
144
|
-
"",
|
|
145
|
-
"body",
|
|
146
|
-
].join("\n"),
|
|
147
|
-
);
|
|
148
|
-
|
|
149
|
-
const { parseSkillFile } = await import("../src/index.ts");
|
|
150
|
-
const skill = await parseSkillFile(skillPath, "bad-name-type", "project");
|
|
151
|
-
|
|
152
|
-
assert.equal(skill, null);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
test("rejects name that does not match the kebab-case regex (parseSkillFile -> null)", async () => {
|
|
156
|
-
const skillPath = await writeSkill(
|
|
157
|
-
"bad-name-shape",
|
|
158
|
-
[
|
|
159
|
-
"---",
|
|
160
|
-
"name: BadName",
|
|
161
|
-
"description: hi",
|
|
162
|
-
"---",
|
|
163
|
-
"",
|
|
164
|
-
"body",
|
|
165
|
-
].join("\n"),
|
|
166
|
-
);
|
|
167
|
-
|
|
168
|
-
const { parseSkillFile } = await import("../src/index.ts");
|
|
169
|
-
const skill = await parseSkillFile(skillPath, "bad-name-shape", "project");
|
|
170
|
-
|
|
171
|
-
assert.equal(skill, null);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
test("rejects empty description (parseSkillFile -> null)", async () => {
|
|
175
|
-
const skillPath = await writeSkill(
|
|
176
|
-
"no-description",
|
|
177
|
-
[
|
|
178
|
-
"---",
|
|
179
|
-
"name: no-description",
|
|
180
|
-
"description: \"\"",
|
|
181
|
-
"---",
|
|
182
|
-
"",
|
|
183
|
-
"body",
|
|
184
|
-
].join("\n"),
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
const { parseSkillFile } = await import("../src/index.ts");
|
|
188
|
-
const skill = await parseSkillFile(skillPath, "no-description", "project");
|
|
189
|
-
|
|
190
|
-
assert.equal(skill, null);
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test("rejects non-string license (parseSkillFile -> null)", async () => {
|
|
194
|
-
const skillPath = await writeSkill(
|
|
195
|
-
"bad-license",
|
|
196
|
-
[
|
|
197
|
-
"---",
|
|
198
|
-
"name: bad-license",
|
|
199
|
-
"description: hi",
|
|
200
|
-
"license: 42",
|
|
201
|
-
"---",
|
|
202
|
-
"",
|
|
203
|
-
"body",
|
|
204
|
-
].join("\n"),
|
|
205
|
-
);
|
|
206
|
-
|
|
207
|
-
const { parseSkillFile } = await import("../src/index.ts");
|
|
208
|
-
const skill = await parseSkillFile(skillPath, "bad-license", "project");
|
|
209
|
-
|
|
210
|
-
assert.equal(skill, null);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
test("rejects allowed-tools that is not an array (parseSkillFile -> null)", async () => {
|
|
214
|
-
const skillPath = await writeSkill(
|
|
215
|
-
"bad-tools",
|
|
216
|
-
[
|
|
217
|
-
"---",
|
|
218
|
-
"name: bad-tools",
|
|
219
|
-
"description: hi",
|
|
220
|
-
"allowed-tools: read",
|
|
221
|
-
"---",
|
|
222
|
-
"",
|
|
223
|
-
"body",
|
|
224
|
-
].join("\n"),
|
|
225
|
-
);
|
|
226
|
-
|
|
227
|
-
const { parseSkillFile } = await import("../src/index.ts");
|
|
228
|
-
const skill = await parseSkillFile(skillPath, "bad-tools", "project");
|
|
229
|
-
|
|
230
|
-
assert.equal(skill, null);
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
test("rejects metadata that is a primitive (parseSkillFile -> null)", async () => {
|
|
234
|
-
const skillPath = await writeSkill(
|
|
235
|
-
"bad-metadata",
|
|
236
|
-
[
|
|
237
|
-
"---",
|
|
238
|
-
"name: bad-metadata",
|
|
239
|
-
"description: hi",
|
|
240
|
-
"metadata: 7",
|
|
241
|
-
"---",
|
|
242
|
-
"",
|
|
243
|
-
"body",
|
|
244
|
-
].join("\n"),
|
|
245
|
-
);
|
|
246
|
-
|
|
247
|
-
const { parseSkillFile } = await import("../src/index.ts");
|
|
248
|
-
const skill = await parseSkillFile(skillPath, "bad-metadata", "project");
|
|
249
|
-
|
|
250
|
-
assert.equal(skill, null);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
test("valid frontmatter surfaces every optional field on the Skill", async () => {
|
|
254
|
-
const skillPath = await writeSkill(
|
|
255
|
-
"full-frontmatter",
|
|
256
|
-
[
|
|
257
|
-
"---",
|
|
258
|
-
"name: full-frontmatter",
|
|
259
|
-
"description: a skill with every optional field set",
|
|
260
|
-
"trigger: keyword",
|
|
261
|
-
"license: MIT",
|
|
262
|
-
"allowed-tools: [read, write]",
|
|
263
|
-
"metadata:",
|
|
264
|
-
" namespace: ns",
|
|
265
|
-
" tags: [a, b]",
|
|
266
|
-
"---",
|
|
267
|
-
"",
|
|
268
|
-
"# body",
|
|
269
|
-
].join("\n"),
|
|
270
|
-
);
|
|
271
|
-
|
|
272
|
-
const { parseSkillFile } = await import("../src/index.ts");
|
|
273
|
-
const skill = await parseSkillFile(skillPath, "full-frontmatter", "project");
|
|
274
|
-
|
|
275
|
-
assert.ok(skill, "expected parseSkillFile to return a Skill for valid frontmatter");
|
|
276
|
-
assert.equal(skill?.name, "full-frontmatter");
|
|
277
|
-
assert.equal(skill?.description, "a skill with every optional field set");
|
|
278
|
-
assert.equal(skill?.trigger, "keyword");
|
|
279
|
-
assert.equal(skill?.namespace, "ns");
|
|
280
|
-
assert.deepEqual(skill?.tags, ["a", "b"]);
|
|
281
|
-
});
|
|
282
|
-
});
|
|
@@ -1,374 +0,0 @@
|
|
|
1
|
-
import { describe, test } from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { levenshtein, findClosestMatch } from "../src/match";
|
|
4
|
-
import type { Skill } from "../src/index";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Build a minimal `Skill` for unit tests. Only the fields touched by the
|
|
8
|
-
* search layer are populated; everything else uses safe defaults so the
|
|
9
|
-
* shape stays close to the production type without forcing every test to
|
|
10
|
-
* set up the full Skill surface.
|
|
11
|
-
*/
|
|
12
|
-
function makeSkill(overrides: { tags?: string[]; [key: string]: unknown } = {}): Skill {
|
|
13
|
-
return {
|
|
14
|
-
name: "default-skill",
|
|
15
|
-
description: "default description",
|
|
16
|
-
path: "/default",
|
|
17
|
-
relativePath: "default",
|
|
18
|
-
label: "project",
|
|
19
|
-
scripts: [],
|
|
20
|
-
template: "",
|
|
21
|
-
...(overrides as Partial<Skill>),
|
|
22
|
-
} as Skill;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
describe("levenshtein", () => {
|
|
26
|
-
test("identical strings have distance 0", () => {
|
|
27
|
-
assert.equal(levenshtein("hello", "hello"), 0);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test("completely different strings have high distance", () => {
|
|
31
|
-
assert.equal(levenshtein("abc", "xyz"), 3);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test("single character difference", () => {
|
|
35
|
-
assert.equal(levenshtein("cat", "bat"), 1);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("insertion", () => {
|
|
39
|
-
assert.equal(levenshtein("cat", "cats"), 1);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test("deletion", () => {
|
|
43
|
-
assert.equal(levenshtein("cats", "cat"), 1);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test("substitution", () => {
|
|
47
|
-
assert.equal(levenshtein("cat", "cut"), 1);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("case sensitive", () => {
|
|
51
|
-
assert.equal(levenshtein("Cat", "cat"), 1);
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
describe("findClosestMatch", () => {
|
|
56
|
-
test("returns null for empty candidate list", () => {
|
|
57
|
-
assert.equal(findClosestMatch("test", []), null);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("exact match returns the match", () => {
|
|
61
|
-
const candidates = ["brainstorming", "git-helper", "pdf"];
|
|
62
|
-
assert.equal(findClosestMatch("pdf", candidates), "pdf");
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("prefix match - user types partial skill name", () => {
|
|
66
|
-
const candidates = ["brainstorming", "git-helper", "pdf"];
|
|
67
|
-
assert.equal(findClosestMatch("git", candidates), "git-helper");
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test("prefix match - longer match", () => {
|
|
71
|
-
const candidates = ["brainstorming", "git-helper", "pdf"];
|
|
72
|
-
assert.equal(findClosestMatch("brainstorm", candidates), "brainstorming");
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("typo correction via Levenshtein", () => {
|
|
76
|
-
const candidates = ["pattern", "git-helper", "pdf"];
|
|
77
|
-
assert.equal(findClosestMatch("patern", candidates), "pattern");
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test("case insensitive matching", () => {
|
|
81
|
-
const candidates = ["Brainstorming", "Git-Helper", "PDF"];
|
|
82
|
-
assert.equal(findClosestMatch("brainstorm", candidates), "Brainstorming");
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("case insensitive exact match", () => {
|
|
86
|
-
const candidates = ["Brainstorming", "Git-Helper", "PDF"];
|
|
87
|
-
assert.equal(findClosestMatch("PDF", candidates), "PDF");
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test("substring match", () => {
|
|
91
|
-
const candidates = ["document-processor", "git-helper", "pdf-reader"];
|
|
92
|
-
assert.equal(findClosestMatch("pdf", candidates), "pdf-reader");
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test("no close matches below threshold returns null", () => {
|
|
96
|
-
const candidates = ["brainstorming", "git-helper", "pdf"];
|
|
97
|
-
assert.equal(findClosestMatch("xyzabc", candidates), null);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("multiple similar candidates returns best match", () => {
|
|
101
|
-
const candidates = ["test", "testing", "tests"];
|
|
102
|
-
assert.equal(findClosestMatch("test", candidates), "test");
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test("prefix matching beats substring matching", () => {
|
|
106
|
-
const candidates = ["pdf-reader", "reader-pdf"];
|
|
107
|
-
assert.equal(findClosestMatch("pdf", candidates), "pdf-reader");
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test("handles hyphenated names", () => {
|
|
111
|
-
const candidates = ["git-helper", "github-actions", "gitlab-ci"];
|
|
112
|
-
assert.equal(findClosestMatch("git", candidates), "git-helper");
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test("script path matching", () => {
|
|
116
|
-
const candidates = ["build.sh", "scripts/deploy.sh", "tools/build.sh"];
|
|
117
|
-
assert.equal(findClosestMatch("deploy", candidates), "scripts/deploy.sh");
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test("typo in script name", () => {
|
|
121
|
-
const candidates = ["build.sh", "deploy.sh", "test.sh"];
|
|
122
|
-
assert.equal(findClosestMatch("biuld.sh", candidates), "build.sh");
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
type SearchModule = {
|
|
127
|
-
escapeRegex: (input: string) => string;
|
|
128
|
-
keywordMatch: (skill: Skill, keywords: string[]) => boolean;
|
|
129
|
-
scoreSkill: (skill: Skill, tokens: string[]) => number;
|
|
130
|
-
searchSkills: (
|
|
131
|
-
skills: Skill[],
|
|
132
|
-
query: string,
|
|
133
|
-
keywords?: string[]
|
|
134
|
-
) => Skill[];
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
async function loadSearchModule(): Promise<SearchModule> {
|
|
138
|
-
return (await import("../src/index")) as unknown as SearchModule;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
describe("escapeRegex", () => {
|
|
142
|
-
test("escapes a literal dot", async () => {
|
|
143
|
-
const { escapeRegex } = await loadSearchModule();
|
|
144
|
-
assert.equal(escapeRegex("a.b"), "a\\.b");
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test("escapes parentheses", async () => {
|
|
148
|
-
const { escapeRegex } = await loadSearchModule();
|
|
149
|
-
assert.equal(escapeRegex("(test)"), "\\(test\\)");
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
test("escapes character class metacharacters and the hyphen", async () => {
|
|
153
|
-
const { escapeRegex } = await loadSearchModule();
|
|
154
|
-
assert.equal(escapeRegex("[a-z]+"), "\\[a\\-z\\]\\+");
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
test("passes an already-safe string through unchanged", async () => {
|
|
158
|
-
const { escapeRegex } = await loadSearchModule();
|
|
159
|
-
assert.equal(escapeRegex("hello world"), "hello world");
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test("produces a pattern that compiles inside a larger regex", async () => {
|
|
163
|
-
const { escapeRegex } = await loadSearchModule();
|
|
164
|
-
const pattern = new RegExp(escapeRegex("(test+"));
|
|
165
|
-
assert.equal(pattern.test("(test+"), true);
|
|
166
|
-
assert.equal(pattern.test("xtest+"), false);
|
|
167
|
-
});
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
describe("keywordMatch", () => {
|
|
171
|
-
test("skill with matching tag is included", async () => {
|
|
172
|
-
const { keywordMatch } = await loadSearchModule();
|
|
173
|
-
const skill = makeSkill({ name: "go-tester", tags: ["go"] });
|
|
174
|
-
assert.equal(keywordMatch(skill, ["go"]), true);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
test("skill without matching tag is excluded", async () => {
|
|
178
|
-
const { keywordMatch } = await loadSearchModule();
|
|
179
|
-
const skill = makeSkill({ name: "rust-tester", tags: ["rust"] });
|
|
180
|
-
assert.equal(keywordMatch(skill, ["go"]), false);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
test("OR semantics across multiple keywords", async () => {
|
|
184
|
-
const { keywordMatch } = await loadSearchModule();
|
|
185
|
-
const skill = makeSkill({ name: "go-tester", tags: ["go"] });
|
|
186
|
-
assert.equal(keywordMatch(skill, ["go", "rust"]), true);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
test("skill with no tags does not match any keyword", async () => {
|
|
190
|
-
const { keywordMatch } = await loadSearchModule();
|
|
191
|
-
const skill = makeSkill({ name: "untagged" });
|
|
192
|
-
assert.equal(keywordMatch(skill, ["go"]), false);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
test("empty keyword list applies no filter", async () => {
|
|
196
|
-
const { keywordMatch } = await loadSearchModule();
|
|
197
|
-
const skill = makeSkill({ name: "untagged" });
|
|
198
|
-
assert.equal(keywordMatch(skill, []), true);
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
describe("scoreSkill", () => {
|
|
203
|
-
test("name exact outranks name prefix outranks name fuzzy outranks description match", async () => {
|
|
204
|
-
const { scoreSkill } = await loadSearchModule();
|
|
205
|
-
const exact = makeSkill({ name: "brain", description: "" });
|
|
206
|
-
const prefix = makeSkill({ name: "brainstorming", description: "" });
|
|
207
|
-
const fuzzy = makeSkill({ name: "braid", description: "" });
|
|
208
|
-
const descOnly = makeSkill({ name: "skill-x", description: "this is about brain" });
|
|
209
|
-
|
|
210
|
-
const sExact = scoreSkill(exact, ["brain"]);
|
|
211
|
-
const sPrefix = scoreSkill(prefix, ["brain"]);
|
|
212
|
-
const sFuzzy = scoreSkill(fuzzy, ["brain"]);
|
|
213
|
-
const sDesc = scoreSkill(descOnly, ["brain"]);
|
|
214
|
-
|
|
215
|
-
assert.ok(sExact > 0, "exact match scores positive");
|
|
216
|
-
assert.ok(sPrefix > 0, "prefix match scores positive");
|
|
217
|
-
assert.ok(sFuzzy > 0, "fuzzy match scores positive");
|
|
218
|
-
assert.ok(sDesc > 0, "description match scores positive");
|
|
219
|
-
assert.ok(sExact > sPrefix, `exact (${sExact}) > prefix (${sPrefix})`);
|
|
220
|
-
assert.ok(sPrefix > sFuzzy, `prefix (${sPrefix}) > fuzzy (${sFuzzy})`);
|
|
221
|
-
assert.ok(sFuzzy > sDesc, `fuzzy (${sFuzzy}) > description (${sDesc})`);
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
test("AND across tokens: a skill missing a token returns 0", async () => {
|
|
225
|
-
const { scoreSkill } = await loadSearchModule();
|
|
226
|
-
const skill = makeSkill({ name: "brainstorming", description: "x" });
|
|
227
|
-
assert.equal(scoreSkill(skill, ["brain", "logic"]), 0);
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
test("multi-token: all tokens in description ranks above name-only partial", async () => {
|
|
231
|
-
const { scoreSkill } = await loadSearchModule();
|
|
232
|
-
const allInDesc = makeSkill({
|
|
233
|
-
name: "tool-x",
|
|
234
|
-
description: "brain logic helper for reasoning",
|
|
235
|
-
});
|
|
236
|
-
const onlyInName = makeSkill({
|
|
237
|
-
name: "brain-helper",
|
|
238
|
-
description: "no second keyword here",
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
const sAllInDesc = scoreSkill(allInDesc, ["brain", "logic"]);
|
|
242
|
-
const sOnlyInName = scoreSkill(onlyInName, ["brain", "logic"]);
|
|
243
|
-
|
|
244
|
-
assert.ok(sAllInDesc > 0, "all-tokens-in-description scores positive");
|
|
245
|
-
assert.equal(sOnlyInName, 0, "AND fails when one token is missing from desc");
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
test("trigger-only match scores positive (R3)", async () => {
|
|
249
|
-
const { scoreSkill } = await loadSearchModule();
|
|
250
|
-
const skill = makeSkill({
|
|
251
|
-
name: "skill-x",
|
|
252
|
-
description: "unrelated description",
|
|
253
|
-
trigger: "oauth login",
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
const score = scoreSkill(skill, ["oauth"]);
|
|
257
|
-
|
|
258
|
-
assert.ok(score > 0, `trigger-only match should score positive, got ${score}`);
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
test("name exact beats trigger at the same query (R3 ordering)", async () => {
|
|
262
|
-
const { scoreSkill } = await loadSearchModule();
|
|
263
|
-
const nameExact = makeSkill({
|
|
264
|
-
name: "oauth",
|
|
265
|
-
description: "x",
|
|
266
|
-
});
|
|
267
|
-
const triggerOnly = makeSkill({
|
|
268
|
-
name: "skill-x",
|
|
269
|
-
description: "x",
|
|
270
|
-
trigger: "oauth login",
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
const sName = scoreSkill(nameExact, ["oauth"]);
|
|
274
|
-
const sTrigger = scoreSkill(triggerOnly, ["oauth"]);
|
|
275
|
-
|
|
276
|
-
assert.ok(sName > sTrigger, `name (${sName}) must beat trigger (${sTrigger})`);
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
test("trigger beats description at the same query (R3 ordering)", async () => {
|
|
280
|
-
const { scoreSkill } = await loadSearchModule();
|
|
281
|
-
const descOnly = makeSkill({
|
|
282
|
-
name: "skill-x",
|
|
283
|
-
description: "auth helper for tokens",
|
|
284
|
-
});
|
|
285
|
-
const triggerOnly = makeSkill({
|
|
286
|
-
name: "skill-y",
|
|
287
|
-
description: "unrelated",
|
|
288
|
-
trigger: "auth login",
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
const sDesc = scoreSkill(descOnly, ["auth"]);
|
|
292
|
-
const sTrigger = scoreSkill(triggerOnly, ["auth"]);
|
|
293
|
-
|
|
294
|
-
assert.ok(sTrigger > sDesc, `trigger (${sTrigger}) must beat description (${sDesc})`);
|
|
295
|
-
});
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
describe("searchSkills", () => {
|
|
299
|
-
test("query only: results are sorted by score DESC", async () => {
|
|
300
|
-
const { searchSkills } = await loadSearchModule();
|
|
301
|
-
const skills = [
|
|
302
|
-
makeSkill({ name: "skill-z", description: "brain related" }),
|
|
303
|
-
makeSkill({ name: "brainstorming", description: "x" }),
|
|
304
|
-
makeSkill({ name: "brain", description: "x" }),
|
|
305
|
-
];
|
|
306
|
-
|
|
307
|
-
const result = searchSkills(skills, "brain");
|
|
308
|
-
|
|
309
|
-
assert.equal(result.length, 3);
|
|
310
|
-
assert.equal(result[0]?.name, "brain", "exact name match should be first");
|
|
311
|
-
assert.equal(result[1]?.name, "brainstorming", "prefix name match should be second");
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
test("keywords only: pre-filter by tags then pass through", async () => {
|
|
315
|
-
const { searchSkills } = await loadSearchModule();
|
|
316
|
-
const skills = [
|
|
317
|
-
makeSkill({ name: "go-tester", tags: ["go"] }),
|
|
318
|
-
makeSkill({ name: "rust-tester", tags: ["rust"] }),
|
|
319
|
-
makeSkill({ name: "go-debug", tags: ["go", "testing"] }),
|
|
320
|
-
];
|
|
321
|
-
|
|
322
|
-
const result = searchSkills(skills, "", ["go"]);
|
|
323
|
-
|
|
324
|
-
assert.equal(result.length, 2, "exactly two skills are tagged 'go'");
|
|
325
|
-
const names = result.map((s) => s.name);
|
|
326
|
-
assert.ok(names.includes("go-tester"));
|
|
327
|
-
assert.ok(names.includes("go-debug"));
|
|
328
|
-
assert.ok(!names.includes("rust-tester"));
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
test("keywords + query: filter applies first, then scored", async () => {
|
|
332
|
-
const { searchSkills } = await loadSearchModule();
|
|
333
|
-
const skills = [
|
|
334
|
-
makeSkill({ name: "brain-tool", description: "x", tags: ["go"] }),
|
|
335
|
-
makeSkill({ name: "rust-tool", description: "x", tags: ["rust"] }),
|
|
336
|
-
makeSkill({ name: "go-helper", description: "x", tags: ["go"] }),
|
|
337
|
-
];
|
|
338
|
-
|
|
339
|
-
const result = searchSkills(skills, "brain", ["go"]);
|
|
340
|
-
|
|
341
|
-
assert.equal(result.length, 1);
|
|
342
|
-
assert.equal(result[0]?.name, "brain-tool");
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
test("query with regex-special characters does not throw", async () => {
|
|
346
|
-
const { searchSkills } = await loadSearchModule();
|
|
347
|
-
const skills = [
|
|
348
|
-
makeSkill({ name: "test-skill", description: "regular helper" }),
|
|
349
|
-
makeSkill({ name: "unrelated", description: "noise" }),
|
|
350
|
-
];
|
|
351
|
-
|
|
352
|
-
let result;
|
|
353
|
-
assert.doesNotThrow(() => {
|
|
354
|
-
result = searchSkills(skills, "(test+[?*$^|){}]\\");
|
|
355
|
-
});
|
|
356
|
-
assert.ok(Array.isArray(result), "returns an array");
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
test("empty query and no keywords returns the input list as-is", async () => {
|
|
360
|
-
const { searchSkills } = await loadSearchModule();
|
|
361
|
-
const skills = [
|
|
362
|
-
makeSkill({ name: "alpha" }),
|
|
363
|
-
makeSkill({ name: "beta" }),
|
|
364
|
-
];
|
|
365
|
-
|
|
366
|
-
const result = searchSkills(skills, "");
|
|
367
|
-
|
|
368
|
-
assert.equal(result.length, 2);
|
|
369
|
-
assert.deepEqual(
|
|
370
|
-
result.map((s) => s.name),
|
|
371
|
-
["alpha", "beta"]
|
|
372
|
-
);
|
|
373
|
-
});
|
|
374
|
-
});
|