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,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill search and relevance ranking.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions: no I/O, no host dependencies. Consumers pass in a list
|
|
5
|
+
* of `Skill` objects (already discovered by `core/discovery.ts`) and a
|
|
6
|
+
* free-text query plus an optional tag-keyword filter, and get back the
|
|
7
|
+
* same skills sorted by relevance.
|
|
8
|
+
*
|
|
9
|
+
* Scoring model (mirrors the design doc):
|
|
10
|
+
*
|
|
11
|
+
* - name exact = 100
|
|
12
|
+
* - name prefix = 90
|
|
13
|
+
* - name fuzzy (sim > 0.4) = 70 * sim
|
|
14
|
+
* - trigger substring = 60
|
|
15
|
+
* - description ALL tokens = 50 (bonus, added on top of per-token max+sum)
|
|
16
|
+
* - description ANY token = 30
|
|
17
|
+
* - description fuzzy (best) = 60 * sim (capped at 60)
|
|
18
|
+
*
|
|
19
|
+
* For multi-token queries, every token MUST contribute (AND across
|
|
20
|
+
* tokens). The final score is `max(per-token) + 0.1 * sum(per-token)`.
|
|
21
|
+
*
|
|
22
|
+
* `escapeRegex` lets callers treat the user query as a literal
|
|
23
|
+
* substring without leaking the regex engine into a crash on
|
|
24
|
+
* unbalanced parentheses, plus signs, etc.
|
|
25
|
+
*/
|
|
26
|
+
import type { Skill } from "./types";
|
|
27
|
+
/**
|
|
28
|
+
* Escape every regex metacharacter in the input so the result is safe
|
|
29
|
+
* to embed in a `new RegExp(...)` call or treat as a literal substring.
|
|
30
|
+
*
|
|
31
|
+
* The escape set is intentionally defensive: it covers every character
|
|
32
|
+
* that the JS regex parser treats as syntax (`.*+?^${}()|[]\`) plus the
|
|
33
|
+
* hyphen, which is only a metacharacter inside a character class but is
|
|
34
|
+
* cheap to escape and avoids a footgun if the caller composes the
|
|
35
|
+
* result into a character class later.
|
|
36
|
+
*/
|
|
37
|
+
export declare function escapeRegex(input: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* Tokenize a free-text query into lowercase, non-empty tokens.
|
|
40
|
+
*
|
|
41
|
+
* Whitespace is the only separator. Empty tokens (from leading or
|
|
42
|
+
* trailing whitespace) are dropped so the caller never has to filter
|
|
43
|
+
* them out before scoring.
|
|
44
|
+
*/
|
|
45
|
+
export declare function tokenize(query: string): string[];
|
|
46
|
+
/**
|
|
47
|
+
* Check whether a skill matches at least one of the supplied keywords
|
|
48
|
+
* against its `metadata.tags`. OR semantics: a single tag hit is enough
|
|
49
|
+
* to keep the skill in the result set. An empty keyword list is a no-op
|
|
50
|
+
* (every skill passes).
|
|
51
|
+
*/
|
|
52
|
+
export declare function keywordMatch(skill: Skill, keywords: string[]): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Score a skill against a list of pre-tokenized, lowercase query
|
|
55
|
+
* tokens. Returns 0 when the skill has no chance of matching (used to
|
|
56
|
+
* drop it from the result). Positive scores compare higher = more
|
|
57
|
+
* relevant.
|
|
58
|
+
*
|
|
59
|
+
* The "description contains ALL tokens" tier (50) is applied as a
|
|
60
|
+
* per-token lift, not a flat bonus, so the ordering
|
|
61
|
+
* name exact > name prefix > name fuzzy > trigger > desc-all > desc-any
|
|
62
|
+
* is preserved even after the `max + 0.1 * sum` multi-token formula.
|
|
63
|
+
*
|
|
64
|
+
* The `trigger` tier (60) is a flat per-token contribution: any token
|
|
65
|
+
* that appears as a case-insensitive substring of `skill.trigger` adds
|
|
66
|
+
* 60 to that token's contribution. Trigger is sandwiched between the
|
|
67
|
+
* name tiers (≥70) and the description tiers (≤50) so the invariant
|
|
68
|
+
* name > trigger > description
|
|
69
|
+
* holds for single-token queries.
|
|
70
|
+
*/
|
|
71
|
+
export declare function scoreSkill(skill: Skill, tokens: string[]): number;
|
|
72
|
+
/**
|
|
73
|
+
* Filter, score, and rank skills against a free-text query and an
|
|
74
|
+
* optional tag-keyword filter. The keyword filter applies first
|
|
75
|
+
* (it is the cheaper predicate and can only narrow the candidate
|
|
76
|
+
* set), then the query is tokenized and scored.
|
|
77
|
+
*
|
|
78
|
+
* Returns a new array sorted by score descending. Skills with a score
|
|
79
|
+
* of 0 are dropped. When the query is empty AND no keywords are
|
|
80
|
+
* supplied, the input list is returned unchanged (the caller can use
|
|
81
|
+
* the unranked discovery for browsing).
|
|
82
|
+
*/
|
|
83
|
+
export declare function searchSkills(skills: Skill[], query: string, keywords?: string[]): Skill[];
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill search and relevance ranking.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions: no I/O, no host dependencies. Consumers pass in a list
|
|
5
|
+
* of `Skill` objects (already discovered by `core/discovery.ts`) and a
|
|
6
|
+
* free-text query plus an optional tag-keyword filter, and get back the
|
|
7
|
+
* same skills sorted by relevance.
|
|
8
|
+
*
|
|
9
|
+
* Scoring model (mirrors the design doc):
|
|
10
|
+
*
|
|
11
|
+
* - name exact = 100
|
|
12
|
+
* - name prefix = 90
|
|
13
|
+
* - name fuzzy (sim > 0.4) = 70 * sim
|
|
14
|
+
* - trigger substring = 60
|
|
15
|
+
* - description ALL tokens = 50 (bonus, added on top of per-token max+sum)
|
|
16
|
+
* - description ANY token = 30
|
|
17
|
+
* - description fuzzy (best) = 60 * sim (capped at 60)
|
|
18
|
+
*
|
|
19
|
+
* For multi-token queries, every token MUST contribute (AND across
|
|
20
|
+
* tokens). The final score is `max(per-token) + 0.1 * sum(per-token)`.
|
|
21
|
+
*
|
|
22
|
+
* `escapeRegex` lets callers treat the user query as a literal
|
|
23
|
+
* substring without leaking the regex engine into a crash on
|
|
24
|
+
* unbalanced parentheses, plus signs, etc.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { Skill } from "./types";
|
|
28
|
+
import { levenshtein } from "./match";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Escape every regex metacharacter in the input so the result is safe
|
|
32
|
+
* to embed in a `new RegExp(...)` call or treat as a literal substring.
|
|
33
|
+
*
|
|
34
|
+
* The escape set is intentionally defensive: it covers every character
|
|
35
|
+
* that the JS regex parser treats as syntax (`.*+?^${}()|[]\`) plus the
|
|
36
|
+
* hyphen, which is only a metacharacter inside a character class but is
|
|
37
|
+
* cheap to escape and avoids a footgun if the caller composes the
|
|
38
|
+
* result into a character class later.
|
|
39
|
+
*/
|
|
40
|
+
export const escapeRegex = (input: string): string => {
|
|
41
|
+
return input.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Tokenize a free-text query into lowercase, non-empty tokens.
|
|
46
|
+
*
|
|
47
|
+
* Whitespace is the only separator. Empty tokens (from leading or
|
|
48
|
+
* trailing whitespace) are dropped so the caller never has to filter
|
|
49
|
+
* them out before scoring.
|
|
50
|
+
*/
|
|
51
|
+
export const tokenize = (query: string): string[] => {
|
|
52
|
+
return query
|
|
53
|
+
.toLowerCase()
|
|
54
|
+
.split(/\s+/)
|
|
55
|
+
.filter((t) => t.length > 0);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check whether a skill matches at least one of the supplied keywords
|
|
60
|
+
* against its `metadata.tags`. OR semantics: a single tag hit is enough
|
|
61
|
+
* to keep the skill in the result set. An empty keyword list is a no-op
|
|
62
|
+
* (every skill passes).
|
|
63
|
+
*/
|
|
64
|
+
export const keywordMatch = (skill: Skill, keywords: string[]): boolean => {
|
|
65
|
+
if (keywords.length === 0) return true;
|
|
66
|
+
const tags = skill.tags ?? [];
|
|
67
|
+
return keywords.some((kw) => tags.includes(kw));
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Compute a Levenshtein-derived similarity in the 0..1 range. */
|
|
71
|
+
const similarity = (a: string, b: string): number => {
|
|
72
|
+
if (a.length === 0 && b.length === 0) return 1;
|
|
73
|
+
return 1 - levenshtein(a, b) / Math.max(a.length, b.length);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Best description-token fuzzy similarity for a single query token. */
|
|
77
|
+
const bestDescriptionTokenSim = (descLower: string, token: string): number => {
|
|
78
|
+
let best = 0;
|
|
79
|
+
for (const dt of descLower.split(/\s+/)) {
|
|
80
|
+
if (dt.length === 0) continue;
|
|
81
|
+
const sim = similarity(dt, token);
|
|
82
|
+
if (sim > best) best = sim;
|
|
83
|
+
}
|
|
84
|
+
return best;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Score a skill against a list of pre-tokenized, lowercase query
|
|
89
|
+
* tokens. Returns 0 when the skill has no chance of matching (used to
|
|
90
|
+
* drop it from the result). Positive scores compare higher = more
|
|
91
|
+
* relevant.
|
|
92
|
+
*
|
|
93
|
+
* The "description contains ALL tokens" tier (50) is applied as a
|
|
94
|
+
* per-token lift, not a flat bonus, so the ordering
|
|
95
|
+
* name exact > name prefix > name fuzzy > trigger > desc-all > desc-any
|
|
96
|
+
* is preserved even after the `max + 0.1 * sum` multi-token formula.
|
|
97
|
+
*
|
|
98
|
+
* The `trigger` tier (60) is a flat per-token contribution: any token
|
|
99
|
+
* that appears as a case-insensitive substring of `skill.trigger` adds
|
|
100
|
+
* 60 to that token's contribution. Trigger is sandwiched between the
|
|
101
|
+
* name tiers (≥70) and the description tiers (≤50) so the invariant
|
|
102
|
+
* name > trigger > description
|
|
103
|
+
* holds for single-token queries.
|
|
104
|
+
*/
|
|
105
|
+
export const scoreSkill = (skill: Skill, tokens: string[]): number => {
|
|
106
|
+
if (tokens.length === 0) return 0;
|
|
107
|
+
const name = skill.name.toLowerCase();
|
|
108
|
+
const desc = skill.description.toLowerCase();
|
|
109
|
+
const trigger = skill.trigger?.toLowerCase() ?? "";
|
|
110
|
+
|
|
111
|
+
// When every token appears in the description, each per-token
|
|
112
|
+
// description contribution is lifted from 30 (ANY) to 50 (ALL).
|
|
113
|
+
const descTier = tokens.every((t) => desc.includes(t)) ? 50 : 30;
|
|
114
|
+
|
|
115
|
+
// Per-token contribution. We pick the strongest tier for each token,
|
|
116
|
+
// then require every token to contribute (AND across tokens). The
|
|
117
|
+
// first token that contributes nothing forces the whole score to 0.
|
|
118
|
+
const perToken: number[] = [];
|
|
119
|
+
for (const token of tokens) {
|
|
120
|
+
let s = 0;
|
|
121
|
+
|
|
122
|
+
if (name === token) {
|
|
123
|
+
s = Math.max(s, 100);
|
|
124
|
+
} else if (name.startsWith(token)) {
|
|
125
|
+
s = Math.max(s, 90);
|
|
126
|
+
} else {
|
|
127
|
+
const nameSim = similarity(name, token);
|
|
128
|
+
if (nameSim > 0.4) s = Math.max(s, 70 * nameSim);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (trigger.length > 0 && trigger.includes(token)) {
|
|
132
|
+
s = Math.max(s, 60);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (desc.includes(token)) {
|
|
136
|
+
s = Math.max(s, descTier);
|
|
137
|
+
} else {
|
|
138
|
+
const descSim = bestDescriptionTokenSim(desc, token);
|
|
139
|
+
if (descSim > 0.4) s = Math.max(s, Math.min(60, 60 * descSim));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (s === 0) return 0; // AND: this token cannot be satisfied.
|
|
143
|
+
perToken.push(s);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const max = Math.max(...perToken);
|
|
147
|
+
const sum = perToken.reduce((a, b) => a + b, 0);
|
|
148
|
+
return max + 0.1 * sum;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Filter, score, and rank skills against a free-text query and an
|
|
153
|
+
* optional tag-keyword filter. The keyword filter applies first
|
|
154
|
+
* (it is the cheaper predicate and can only narrow the candidate
|
|
155
|
+
* set), then the query is tokenized and scored.
|
|
156
|
+
*
|
|
157
|
+
* Returns a new array sorted by score descending. Skills with a score
|
|
158
|
+
* of 0 are dropped. When the query is empty AND no keywords are
|
|
159
|
+
* supplied, the input list is returned unchanged (the caller can use
|
|
160
|
+
* the unranked discovery for browsing).
|
|
161
|
+
*/
|
|
162
|
+
export const searchSkills = (
|
|
163
|
+
skills: Skill[],
|
|
164
|
+
query: string,
|
|
165
|
+
keywords?: string[]
|
|
166
|
+
): Skill[] => {
|
|
167
|
+
let candidates: Skill[] = skills;
|
|
168
|
+
|
|
169
|
+
if (keywords && keywords.length > 0) {
|
|
170
|
+
candidates = candidates.filter((s) => keywordMatch(s, keywords!));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!query || query.trim() === "") {
|
|
174
|
+
return candidates;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const tokens = tokenize(query);
|
|
178
|
+
if (tokens.length === 0) {
|
|
179
|
+
return candidates;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const scored = candidates
|
|
183
|
+
.map((skill) => ({ skill, score: scoreSkill(skill, tokens) }))
|
|
184
|
+
.filter(({ score }) => score > 0)
|
|
185
|
+
.sort((a, b) => b.score - a.score);
|
|
186
|
+
|
|
187
|
+
return scored.map(({ skill }) => skill);
|
|
188
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure types for the skills core engine.
|
|
3
|
+
*
|
|
4
|
+
* The core has zero runtime dependency on any host SDK. Host-specific types
|
|
5
|
+
* (the OpenCode client, session context, etc.) live in the host adapter and
|
|
6
|
+
* are re-exported by the consumer entrypoints.
|
|
7
|
+
*
|
|
8
|
+
* Boundary contracts `SkillHostClient` and `SkillHostSession` declare the
|
|
9
|
+
* surface the core expects from any AI harness host. They are forward-looking
|
|
10
|
+
* for PR2 (the OpenCode adapter) and are not yet consumed by core code.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Skill label indicating the source/location of a skill.
|
|
14
|
+
* - project: .opencode/skills/ in project directory
|
|
15
|
+
* - user: ~/.config/opencode/skills/
|
|
16
|
+
* - claude-project: .claude/skills/ in project directory
|
|
17
|
+
* - claude-user: ~/.claude/skills/
|
|
18
|
+
*/
|
|
19
|
+
export type SkillLabel = "project" | "user" | "claude-project" | "claude-user";
|
|
20
|
+
/**
|
|
21
|
+
* Result from finding a file in a directory.
|
|
22
|
+
*/
|
|
23
|
+
export interface FileDiscoveryResult {
|
|
24
|
+
filePath: string;
|
|
25
|
+
relativePath: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Script metadata with both relative and absolute paths.
|
|
29
|
+
*/
|
|
30
|
+
export interface Script {
|
|
31
|
+
relativePath: string;
|
|
32
|
+
absolutePath: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Complete metadata for a discovered skill.
|
|
36
|
+
*/
|
|
37
|
+
export interface Skill {
|
|
38
|
+
name: string;
|
|
39
|
+
description: string;
|
|
40
|
+
/**
|
|
41
|
+
* Free-form trigger phrase(s) parsed from the `trigger` frontmatter key.
|
|
42
|
+
* Surfaced in targeted outputs (matched-skill injection, `get_available_skills`)
|
|
43
|
+
* so the model knows which user phrases should activate the skill. Absent
|
|
44
|
+
* when the frontmatter has no `trigger` key.
|
|
45
|
+
*/
|
|
46
|
+
trigger?: string;
|
|
47
|
+
path: string;
|
|
48
|
+
relativePath: string;
|
|
49
|
+
namespace?: string;
|
|
50
|
+
label: SkillLabel;
|
|
51
|
+
scripts: Script[];
|
|
52
|
+
template: string;
|
|
53
|
+
/**
|
|
54
|
+
* Free-form tags parsed from `metadata.tags` in the skill frontmatter.
|
|
55
|
+
* Defaults to an empty array when the skill has no `metadata` block or
|
|
56
|
+
* the block has no `tags` key. Consumers (e.g. the search layer) use
|
|
57
|
+
* this list to filter skills by user-supplied keywords.
|
|
58
|
+
*/
|
|
59
|
+
tags: string[];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Skill summary for preflight evaluation.
|
|
63
|
+
*/
|
|
64
|
+
export interface SkillSummary {
|
|
65
|
+
name: string;
|
|
66
|
+
description: string;
|
|
67
|
+
/**
|
|
68
|
+
* Optional trigger phrase(s) parsed from the `trigger` frontmatter key.
|
|
69
|
+
* Mirrors `Skill.trigger` so summaries carry the same discovery metadata.
|
|
70
|
+
*/
|
|
71
|
+
trigger?: string;
|
|
72
|
+
}
|
|
73
|
+
/** Discovery result with label attached. */
|
|
74
|
+
export type LabeledDiscoveryResult = FileDiscoveryResult & {
|
|
75
|
+
label: SkillLabel;
|
|
76
|
+
};
|
|
77
|
+
/** Configuration for a skill discovery path. */
|
|
78
|
+
export interface DiscoveryPath {
|
|
79
|
+
path: string;
|
|
80
|
+
label: SkillLabel;
|
|
81
|
+
maxDepth: number;
|
|
82
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure types for the skills core engine.
|
|
3
|
+
*
|
|
4
|
+
* The core has zero runtime dependency on any host SDK. Host-specific types
|
|
5
|
+
* (the OpenCode client, etc.) live in the host adapter and are re-exported
|
|
6
|
+
* by the consumer entrypoints.
|
|
7
|
+
*
|
|
8
|
+
* Boundary contracts `SkillHostClient` and `SkillHostSession` (and the
|
|
9
|
+
* minimal `SkillHostContext` they reference) declare the surface the core
|
|
10
|
+
* expects from any AI harness host. The concrete OpenCode implementation
|
|
11
|
+
* lives in the plugin package; other harnesses implement the same contracts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Skill label indicating the source/location of a skill.
|
|
16
|
+
* - project: .opencode/skills/ in project directory
|
|
17
|
+
* - user: ~/.config/opencode/skills/
|
|
18
|
+
* - claude-project: .claude/skills/ in project directory
|
|
19
|
+
* - claude-user: ~/.claude/skills/
|
|
20
|
+
*/
|
|
21
|
+
export type SkillLabel = "project" | "user" | "claude-project" | "claude-user";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Result from finding a file in a directory.
|
|
25
|
+
*/
|
|
26
|
+
export interface FileDiscoveryResult {
|
|
27
|
+
filePath: string;
|
|
28
|
+
relativePath: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Script metadata with both relative and absolute paths.
|
|
33
|
+
*/
|
|
34
|
+
export interface Script {
|
|
35
|
+
relativePath: string;
|
|
36
|
+
absolutePath: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Complete metadata for a discovered skill.
|
|
41
|
+
*/
|
|
42
|
+
export interface Skill {
|
|
43
|
+
name: string;
|
|
44
|
+
description: string;
|
|
45
|
+
/**
|
|
46
|
+
* Free-form trigger phrase(s) parsed from the `trigger` frontmatter key.
|
|
47
|
+
* Surfaced in targeted outputs (matched-skill injection, `get_available_skills`)
|
|
48
|
+
* so the model knows which user phrases should activate the skill. Absent
|
|
49
|
+
* when the frontmatter has no `trigger` key.
|
|
50
|
+
*/
|
|
51
|
+
trigger?: string;
|
|
52
|
+
path: string;
|
|
53
|
+
relativePath: string;
|
|
54
|
+
namespace?: string;
|
|
55
|
+
label: SkillLabel;
|
|
56
|
+
scripts: Script[];
|
|
57
|
+
template: string;
|
|
58
|
+
/**
|
|
59
|
+
* Free-form tags parsed from `metadata.tags` in the skill frontmatter.
|
|
60
|
+
* Defaults to an empty array when the skill has no `metadata` block or
|
|
61
|
+
* the block has no `tags` key. Consumers (e.g. the search layer) use
|
|
62
|
+
* this list to filter skills by user-supplied keywords.
|
|
63
|
+
*/
|
|
64
|
+
tags: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Skill summary for preflight evaluation.
|
|
69
|
+
*/
|
|
70
|
+
export interface SkillSummary {
|
|
71
|
+
name: string;
|
|
72
|
+
description: string;
|
|
73
|
+
/**
|
|
74
|
+
* Optional trigger phrase(s) parsed from the `trigger` frontmatter key.
|
|
75
|
+
* Mirrors `Skill.trigger` so summaries carry the same discovery metadata.
|
|
76
|
+
*/
|
|
77
|
+
trigger?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Discovery result with label attached. */
|
|
81
|
+
export type LabeledDiscoveryResult = FileDiscoveryResult & { label: SkillLabel };
|
|
82
|
+
|
|
83
|
+
/** Configuration for a skill discovery path. */
|
|
84
|
+
export interface DiscoveryPath {
|
|
85
|
+
path: string;
|
|
86
|
+
label: SkillLabel;
|
|
87
|
+
maxDepth: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Minimal per-call context the host carries alongside an injected message.
|
|
92
|
+
*
|
|
93
|
+
* The core never needs more than the model + agent hint that the host can
|
|
94
|
+
* resolve from a session. Declaring the shape here (instead of in the
|
|
95
|
+
* adapter) keeps the boundary contract self-contained: the plugin's
|
|
96
|
+
* OpenCode implementation supplies the values; future harnesses do the
|
|
97
|
+
* same without changing core.
|
|
98
|
+
*/
|
|
99
|
+
export interface SkillHostContext {
|
|
100
|
+
model?: { providerID: string; modelID: string };
|
|
101
|
+
agent?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Bounded client surface the core expects from any AI harness host.
|
|
106
|
+
*
|
|
107
|
+
* The interface is intentionally structural — a host adapter may add more
|
|
108
|
+
* methods, but it MUST provide these four to satisfy the boundary. The
|
|
109
|
+
* concrete OpenCode implementation (`createOpencodeSkillHost` in the plugin
|
|
110
|
+
* package) supplies them over the OpenCode SDK client plus `node:fs`.
|
|
111
|
+
*
|
|
112
|
+
* - `injectContent` push a synthetic message into a session
|
|
113
|
+
* - `getSessionContext` resolve model/agent hint for a session id
|
|
114
|
+
* - `readFile` / `readdir` bounded filesystem access for skill loading
|
|
115
|
+
*/
|
|
116
|
+
export interface SkillHostClient {
|
|
117
|
+
injectContent(sessionID: string, text: string, context?: SkillHostContext): Promise<void>;
|
|
118
|
+
getSessionContext(sessionID: string): Promise<SkillHostContext | undefined>;
|
|
119
|
+
readFile(path: string): Promise<string>;
|
|
120
|
+
readdir(path: string): Promise<string[]>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Host-side session handle. The core only needs to carry the session id
|
|
125
|
+
* through calls back into the host (e.g. when injecting matched-skill
|
|
126
|
+
* content). Hosts are free to attach additional state internally; the
|
|
127
|
+
* boundary contract here is the minimum the core relies on.
|
|
128
|
+
*/
|
|
129
|
+
export interface SkillHostSession {
|
|
130
|
+
id: string;
|
|
131
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal shared directory walker.
|
|
3
|
+
*
|
|
4
|
+
* Walks `baseDir` recursively up to `maxDepth` and invokes `visitor` for each
|
|
5
|
+
* non-skipped entry. The walker owns the traversal rules shared by skill
|
|
6
|
+
* discovery (`findSkillsRecursive`) and script enumeration (`findScripts`):
|
|
7
|
+
*
|
|
8
|
+
* - hidden directories (names starting with `.`) are skipped
|
|
9
|
+
* - `node_modules` and `.git` are skipped unconditionally
|
|
10
|
+
* - per-entry errors (read, stat, or visitor throw) are isolated so a
|
|
11
|
+
* single broken symlink or throwing visitor does not abort the walk
|
|
12
|
+
*
|
|
13
|
+
* Callers that need extra skip sets (e.g. `__pycache__`, `.venv` for the
|
|
14
|
+
* scripts layer) pass them via {@link WalkOptions.skipDirs}. The walker does
|
|
15
|
+
* NOT re-export its skip rules — each caller decides what extra paths are
|
|
16
|
+
* not its business to enter.
|
|
17
|
+
*
|
|
18
|
+
* The walker is internal to the core package: it is intentionally NOT
|
|
19
|
+
* re-exported from `packages/core/src/index.ts`. Callers import it
|
|
20
|
+
* directly from `./walk`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import * as fs from "node:fs/promises";
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
import type { Dirent } from "node:fs";
|
|
26
|
+
|
|
27
|
+
/** Directories the walker skips on every invocation, regardless of caller. */
|
|
28
|
+
const ALWAYS_SKIP = new Set(["node_modules", ".git"]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Optional walker configuration.
|
|
32
|
+
*
|
|
33
|
+
* Kept narrow on purpose: only fields both callers need belong here.
|
|
34
|
+
* Anything caller-specific (e.g. script executable-bit filter) lives in
|
|
35
|
+
* the visitor, not in the walker.
|
|
36
|
+
*/
|
|
37
|
+
export interface WalkOptions {
|
|
38
|
+
/**
|
|
39
|
+
* Extra directory names the walker should skip in addition to the
|
|
40
|
+
* unconditional `node_modules` / `.git` and the hidden-dir rule.
|
|
41
|
+
* Use this for caller-specific skip sets such as Python cache dirs
|
|
42
|
+
* or virtualenvs in the scripts layer.
|
|
43
|
+
*/
|
|
44
|
+
skipDirs?: ReadonlySet<string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Walk `baseDir` recursively, invoking `visitor` for each non-skipped entry.
|
|
49
|
+
*
|
|
50
|
+
* The visitor is called depth-first with the entry's `parentPath` already
|
|
51
|
+
* populated (Node 20.12+), so callers can build absolute paths via
|
|
52
|
+
* `path.join(entry.parentPath, entry.name)` without restating the parent.
|
|
53
|
+
*
|
|
54
|
+
* `currentDepth` is the depth of the directory containing the entry:
|
|
55
|
+
* `0` for entries inside `baseDir`, `1` for entries inside its subdirs,
|
|
56
|
+
* and so on. Entries beyond `maxDepth` are never visited.
|
|
57
|
+
*
|
|
58
|
+
* The visitor may be sync or async; the walker awaits it so any state
|
|
59
|
+
* the visitor records (e.g. "skip this subtree") is visible to the
|
|
60
|
+
* subsequent recursive step.
|
|
61
|
+
*
|
|
62
|
+
* A missing or unreadable `baseDir` is not an error: the walker simply
|
|
63
|
+
* yields nothing. Per-entry failures (read, stat, or a throwing visitor)
|
|
64
|
+
* are likewise isolated to the offending entry.
|
|
65
|
+
*/
|
|
66
|
+
export const walkDir = async (
|
|
67
|
+
baseDir: string,
|
|
68
|
+
maxDepth: number,
|
|
69
|
+
visitor: (entry: Dirent, currentDepth: number) => void | Promise<void>,
|
|
70
|
+
options: WalkOptions = {}
|
|
71
|
+
): Promise<void> => {
|
|
72
|
+
const skipDirs = options.skipDirs;
|
|
73
|
+
await walk(baseDir, 0, maxDepth, visitor, skipDirs);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const walk = async (
|
|
77
|
+
dir: string,
|
|
78
|
+
depth: number,
|
|
79
|
+
maxDepth: number,
|
|
80
|
+
visitor: (entry: Dirent, currentDepth: number) => void | Promise<void>,
|
|
81
|
+
skipDirs: ReadonlySet<string> | undefined
|
|
82
|
+
): Promise<void> => {
|
|
83
|
+
if (depth > maxDepth) return;
|
|
84
|
+
|
|
85
|
+
let entries: Dirent[];
|
|
86
|
+
try {
|
|
87
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
88
|
+
} catch {
|
|
89
|
+
// baseDir missing or unreadable: stay graceful, the walker is a utility.
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
if (entry.name.startsWith(".")) continue;
|
|
95
|
+
if (ALWAYS_SKIP.has(entry.name)) continue;
|
|
96
|
+
if (skipDirs?.has(entry.name)) continue;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
await visitor(entry, depth);
|
|
100
|
+
} catch {
|
|
101
|
+
// Per-entry error isolation: a throwing visitor must not abort the walk.
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (entry.isDirectory()) {
|
|
106
|
+
await walk(path.join(dir, entry.name), depth + 1, maxDepth, visitor, skipDirs);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|