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