opencode-agent-skills-md 1.0.1 → 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.
Files changed (129) hide show
  1. package/dist/cli.mjs +770 -0
  2. package/dist/plugin.mjs +1138 -0
  3. package/dist/src/cli/config.d.ts +144 -0
  4. package/dist/src/cli/install.d.ts +33 -0
  5. package/dist/src/cli/main.d.ts +11 -0
  6. package/dist/src/cli/real-fs.d.ts +6 -0
  7. package/dist/src/cli/status.d.ts +34 -0
  8. package/dist/src/cli/uninstall.d.ts +22 -0
  9. package/dist/src/host.d.ts +51 -0
  10. package/dist/src/index.d.ts +17 -0
  11. package/dist/src/plugin.d.ts +35 -0
  12. package/dist/src/sdk.d.ts +51 -0
  13. package/dist/src/tools.d.ts +86 -0
  14. package/package.json +48 -18
  15. package/.beads/.local_version +0 -1
  16. package/.beads/README.md +0 -81
  17. package/.beads/config.yaml +0 -61
  18. package/.beads/deletions.jsonl +0 -1
  19. package/.beads/issues.jsonl +0 -64
  20. package/.beads/metadata.json +0 -4
  21. package/.gitattributes +0 -3
  22. package/.github/CODEOWNERS +0 -1
  23. package/.github/copilot-instructions.md +0 -78
  24. package/.github/dependabot.yml +0 -13
  25. package/.github/workflows/release.yml +0 -51
  26. package/.opencode/command/test-compaction.md +0 -9
  27. package/.opencode/command/test-find-skills.md +0 -7
  28. package/.opencode/command/test-read-skill-file.md +0 -14
  29. package/.opencode/command/test-run-skill-script.md +0 -13
  30. package/.opencode/command/test-skills.md +0 -14
  31. package/.opencode/command/test-use-skill.md +0 -10
  32. package/.opencode/skills/git-helper/SKILL.md +0 -65
  33. package/.opencode/skills/test-skill/SKILL.md +0 -43
  34. package/.opencode/skills/test-skill/example-config.json +0 -16
  35. package/.opencode/skills/test-skill/helper-docs.md +0 -29
  36. package/.opencode/skills/test-skill/scripts/echo-args +0 -14
  37. package/.opencode/skills/test-skill/scripts/greet +0 -6
  38. package/AGENTS.md +0 -43
  39. package/CHANGELOG.md +0 -178
  40. package/Justfile +0 -39
  41. package/README.md +0 -220
  42. package/openspec/changes/archive/2026-06-14-skills-core-decouple/specs/core-decoupling/spec.md +0 -74
  43. package/openspec/changes/archive/2026-06-14-skills-core-decouple/tasks.md +0 -64
  44. package/openspec/changes/archive/2026-06-14-skills-core-decouple/verify-report.md +0 -75
  45. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/apply-progress.md +0 -136
  46. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/archive-report.md +0 -77
  47. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/design.md +0 -89
  48. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/proposal.md +0 -65
  49. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/specs/core-decoupling/spec.md +0 -77
  50. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/tasks.md +0 -65
  51. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/verify-report.md +0 -165
  52. package/openspec/specs/core-decoupling/spec.md +0 -110
  53. package/packages/core/package.json +0 -30
  54. package/packages/core/src/content.d.ts +0 -16
  55. package/packages/core/src/content.ts +0 -30
  56. package/packages/core/src/debug.ts +0 -16
  57. package/packages/core/src/discovery.d.ts +0 -86
  58. package/packages/core/src/discovery.ts +0 -257
  59. package/packages/core/src/index.d.ts +0 -20
  60. package/packages/core/src/index.ts +0 -55
  61. package/packages/core/src/match.d.ts +0 -19
  62. package/packages/core/src/match.ts +0 -75
  63. package/packages/core/src/parse.d.ts +0 -26
  64. package/packages/core/src/parse.ts +0 -141
  65. package/packages/core/src/scripts.d.ts +0 -17
  66. package/packages/core/src/scripts.ts +0 -79
  67. package/packages/core/src/search.d.ts +0 -83
  68. package/packages/core/src/search.ts +0 -188
  69. package/packages/core/src/types.d.ts +0 -82
  70. package/packages/core/src/types.ts +0 -131
  71. package/packages/core/src/walk.ts +0 -109
  72. package/packages/core/tests/agnostic.test.ts +0 -346
  73. package/packages/core/tests/content.test.ts +0 -65
  74. package/packages/core/tests/discovery.test.ts +0 -370
  75. package/packages/core/tests/package-boundary.test.ts +0 -310
  76. package/packages/core/tests/parse-trigger.test.ts +0 -282
  77. package/packages/core/tests/search.test.ts +0 -374
  78. package/packages/core/tests/subpath.test.ts +0 -87
  79. package/packages/core/tsconfig.json +0 -10
  80. package/packages/opencode-agent-skills-md/package.json +0 -66
  81. package/packages/opencode-agent-skills-md/rolldown.config.js +0 -47
  82. package/packages/opencode-agent-skills-md/tests/cli-commands.test.ts +0 -1423
  83. package/packages/opencode-agent-skills-md/tests/e2e/startup-smoke.test.ts +0 -66
  84. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.claude/skills/claude-user-only-skill/SKILL.md +0 -8
  85. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/shared-skill/SKILL.md +0 -8
  86. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/user-only-skill/SKILL.md +0 -8
  87. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.claude/skills/claude-project-only-skill/SKILL.md +0 -8
  88. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/go-tester/SKILL.md +0 -12
  89. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/nested/team/nested-skill/SKILL.md +0 -8
  90. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/rust-tester/SKILL.md +0 -11
  91. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/SKILL.md +0 -8
  92. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/bin/echo.sh +0 -2
  93. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/docs/reference.md +0 -1
  94. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/shared-skill/SKILL.md +0 -8
  95. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/using-superpowers/SKILL.md +0 -8
  96. package/packages/opencode-agent-skills-md/tests/integration/helpers/mock-opencode.ts +0 -114
  97. package/packages/opencode-agent-skills-md/tests/integration/plugin.test.ts +0 -316
  98. package/packages/opencode-agent-skills-md/tests/integration/skill-discovery.test.ts +0 -315
  99. package/packages/opencode-agent-skills-md/tests/opencode/host.test.ts +0 -179
  100. package/packages/opencode-agent-skills-md/tests/opencode/plugin.test.ts +0 -551
  101. package/packages/opencode-agent-skills-md/tests/opencode/subpath.test.ts +0 -66
  102. package/packages/opencode-agent-skills-md/tests/opencode/tools.test.ts +0 -213
  103. package/packages/opencode-agent-skills-md/tests/package-boundary.test.ts +0 -345
  104. package/packages/opencode-agent-skills-md/tests/tools-security.test.ts +0 -72
  105. package/packages/opencode-agent-skills-md/tsconfig.build.json +0 -11
  106. package/packages/opencode-agent-skills-md/tsconfig.json +0 -10
  107. package/plans/001-ci-gate.md +0 -177
  108. package/plans/002-is-path-safe.md +0 -243
  109. package/plans/003-escape-prompts.md +0 -310
  110. package/plans/004-test-security-paths.md +0 -228
  111. package/plans/005-stop-swallowing-errors.md +0 -246
  112. package/plans/006-preserve-jsonc-commas.md +0 -144
  113. package/plans/007-write-before-purge.md +0 -144
  114. package/plans/008-reuse-walkdir-for-list-skill-files.md +0 -164
  115. package/plans/README.md +0 -43
  116. package/pnpm-workspace.yaml +0 -6
  117. package/tests/workspace.test.ts +0 -367
  118. package/tsconfig.json +0 -15
  119. /package/{packages/opencode-agent-skills-md/src → src}/cli/config.ts +0 -0
  120. /package/{packages/opencode-agent-skills-md/src → src}/cli/install.ts +0 -0
  121. /package/{packages/opencode-agent-skills-md/src → src}/cli/main.ts +0 -0
  122. /package/{packages/opencode-agent-skills-md/src → src}/cli/real-fs.ts +0 -0
  123. /package/{packages/opencode-agent-skills-md/src → src}/cli/status.ts +0 -0
  124. /package/{packages/opencode-agent-skills-md/src → src}/cli/uninstall.ts +0 -0
  125. /package/{packages/opencode-agent-skills-md/src → src}/host.ts +0 -0
  126. /package/{packages/opencode-agent-skills-md/src → src}/index.ts +0 -0
  127. /package/{packages/opencode-agent-skills-md/src → src}/plugin.ts +0 -0
  128. /package/{packages/opencode-agent-skills-md/src → src}/sdk.ts +0 -0
  129. /package/{packages/opencode-agent-skills-md/src → src}/tools.ts +0 -0
@@ -1,257 +0,0 @@
1
- /**
2
- * Skill discovery across filesystem roots.
3
- *
4
- * The core never hard-codes a host's directory layout. Callers pass the list
5
- * of discovery roots; the default `getDefaultOpencodeRoots` reproduces the
6
- * legacy OpenCode priority order. PR2 will call `discoverAllSkills` from the
7
- * OpenCode host adapter with the same default.
8
- */
9
-
10
- import { homedir } from "node:os";
11
- import * as fs from "node:fs/promises";
12
- import * as path from "node:path";
13
- import type { Dirent } from "node:fs";
14
- import type {
15
- DiscoveryPath,
16
- FileDiscoveryResult,
17
- LabeledDiscoveryResult,
18
- Skill,
19
- SkillLabel,
20
- } from "./types";
21
- import { parseSkillFile } from "./parse";
22
- import { walkDir } from "./walk";
23
- import { debugLog } from "./debug";
24
-
25
- /**
26
- * Check if a file exists in a directory and return path info.
27
- *
28
- * @param directory - Directory to check
29
- * @param relativePath - Relative path to use in result (caller-specific)
30
- * @param filename - Name of file to look for (e.g., 'SKILL.md')
31
- * @returns Path info if file exists, null otherwise
32
- */
33
- export const findFile = async (
34
- directory: string,
35
- relativePath: string,
36
- filename: string
37
- ): Promise<FileDiscoveryResult | null> => {
38
- const filePath = path.join(directory, filename);
39
- try {
40
- await fs.stat(filePath);
41
- return { filePath, relativePath };
42
- } catch {
43
- return null;
44
- }
45
- };
46
-
47
- /**
48
- * Recursively find SKILL.md files in a directory.
49
- *
50
- * The base directory itself is checked first: a SKILL.md placed at the root
51
- * of a discovery root is returned with `relativePath = ""` and wins the
52
- * shadowing tie-break over same-name skills in subdirectories (first found
53
- * wins in `discoverAllSkills`).
54
- *
55
- * The traversal is delegated to the shared {@link walkDir} utility, which
56
- * owns hidden-dir / `node_modules` / `.git` skip rules and per-entry error
57
- * isolation. The visitor only checks each directory entry for SKILL.md and
58
- * records the labeled result; recursion and skip semantics are the walker's
59
- * job, not this function's.
60
- *
61
- * Output is sorted by `relativePath` so callers see a stable order across
62
- * runs regardless of the underlying `readdir` enumeration order.
63
- */
64
- export const findSkillsRecursive = async (
65
- baseDir: string,
66
- label: SkillLabel,
67
- maxDepth: number = 3
68
- ): Promise<LabeledDiscoveryResult[]> => {
69
- const results: LabeledDiscoveryResult[] = [];
70
-
71
- try {
72
- await fs.access(baseDir);
73
- // Check the baseDir itself before walking its entries so a root-level
74
- // SKILL.md is discovered and naturally wins the first-found-wins tie-break.
75
- const rootFile = await findFile(baseDir, '', 'SKILL.md');
76
- if (rootFile) {
77
- results.push({ ...rootFile, label });
78
- }
79
-
80
- await walkDir(baseDir, maxDepth, async (entry) => {
81
- if (!entry.isDirectory()) return;
82
- const fullPath = path.join(entry.parentPath, entry.name);
83
- const relPath = path.relative(baseDir, fullPath);
84
- const found = await findFile(fullPath, relPath, 'SKILL.md');
85
- if (found) {
86
- results.push({ ...found, label });
87
- }
88
- });
89
- } catch (error) {
90
- debugLog("findSkillsRecursive: cannot access baseDir", baseDir, error);
91
- }
92
-
93
- return results.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
94
- };
95
-
96
- /**
97
- * Default recursion depth for the four priority discovery roots.
98
- *
99
- * Pre-refactor commit `c2d8e74` used `maxDepth: 1` for the Claude-side
100
- * roots; commit `12de52a` ("fix(core): unify maxDepth to 3 across all
101
- * discovery roots") widened them deliberately so deeply-nested Claude
102
- * skills surface. The regression net in
103
- * `tests/integration/skill-discovery.test.ts` pins this value so a
104
- * future narrowing breaks loudly.
105
- */
106
- const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
107
-
108
- /**
109
- * Default discovery roots matching the pre-refactor OpenCode priority order
110
- * (see commit `c2d8e74`, `src/skills.ts#discoverAllSkills`):
111
- * 1. .opencode/skills/ (project - OpenCode)
112
- * 2. .claude/skills/ (project - Claude)
113
- * 3. ~/.config/opencode/skills/ (user - OpenCode)
114
- * 4. ~/.claude/skills/ (user - Claude)
115
- *
116
- * No shadowing - unique names only. First match wins, duplicates are warned.
117
- */
118
- export const getDefaultOpencodeRoots = (directory: string): DiscoveryPath[] => {
119
- return [
120
- { path: path.join(directory, '.opencode', 'skills'), label: 'project', maxDepth: DEFAULT_DISCOVERY_MAX_DEPTH },
121
- { path: path.join(directory, '.claude', 'skills'), label: 'claude-project', maxDepth: DEFAULT_DISCOVERY_MAX_DEPTH },
122
- { path: path.join(homedir(), '.config', 'opencode', 'skills'), label: 'user', maxDepth: DEFAULT_DISCOVERY_MAX_DEPTH },
123
- { path: path.join(homedir(), '.claude', 'skills'), label: 'claude-user', maxDepth: DEFAULT_DISCOVERY_MAX_DEPTH }
124
- ];
125
- };
126
-
127
- /**
128
- * Default callback for shadowed skill names. Emits a `console.warn` that
129
- * identifies the surviving (existing) skill and the duplicate that was
130
- * skipped. Hosts can override by passing `onDuplicate` to `discoverAllSkills`.
131
- *
132
- * @internal - exported for testing
133
- */
134
- export const defaultOnDuplicate = (
135
- existing: Skill,
136
- duplicate: Skill
137
- ): void => {
138
- console.warn(
139
- `Skill name conflict: '${existing.name}' at ${existing.path} shadows duplicate at ${duplicate.path}`
140
- );
141
- };
142
-
143
- /**
144
- * Discover all skills from the provided roots.
145
- *
146
- * @param directory - Project directory (used to build the default roots).
147
- * @param roots - Discovery roots. Defaults to the OpenCode priority order
148
- * via `getDefaultOpencodeRoots(directory)`. Hosts pass an explicit list to
149
- * override the layout.
150
- * @param onDuplicate - Optional callback invoked when two roots produce a
151
- * skill with the same `name`. Defaults to `console.warn` via
152
- * `defaultOnDuplicate`. The first-discovered skill wins; the duplicate
153
- * (second one encountered) is passed to the callback but never stored.
154
- */
155
- export const discoverAllSkills = async (
156
- directory: string,
157
- roots: DiscoveryPath[] = getDefaultOpencodeRoots(directory),
158
- onDuplicate: (existing: Skill, duplicate: Skill) => void = defaultOnDuplicate
159
- ): Promise<Map<string, Skill>> => {
160
- const allResults: LabeledDiscoveryResult[] = [];
161
- for (const { path: baseDir, label, maxDepth } of roots) {
162
- allResults.push(...await findSkillsRecursive(baseDir, label, maxDepth));
163
- }
164
-
165
- const skillsByName = new Map<string, Skill>();
166
- for (const { filePath, relativePath, label } of allResults) {
167
- const skill = await parseSkillFile(filePath, relativePath, label);
168
- if (!skill) continue;
169
- if (skillsByName.has(skill.name)) {
170
- onDuplicate(skillsByName.get(skill.name)!, skill);
171
- continue;
172
- }
173
- skillsByName.set(skill.name, skill);
174
- }
175
-
176
- return skillsByName;
177
- };
178
-
179
- /**
180
- * Resolve a skill by name, handling namespace prefixes.
181
- * Supports: "skill-name", "project:skill-name", "user:skill-name", etc.
182
- */
183
- export const resolveSkill = (
184
- skillName: string,
185
- skillsByName: Map<string, Skill>
186
- ): Skill | null => {
187
- if (skillName.includes(':')) {
188
- const [namespace, name] = skillName.split(':');
189
- for (const skill of skillsByName.values()) {
190
- if (skill.name === name && (skill.label === namespace || skill.namespace === namespace)) {
191
- return skill;
192
- }
193
- }
194
- return null;
195
- }
196
- return skillsByName.get(skillName) || null;
197
- };
198
-
199
- /**
200
- * Recursively list all files in a directory, returning relative paths.
201
- * Excludes SKILL.md since it's already loaded as the main content.
202
- * Applies the same skip rules as walkDir (hidden dirs, node_modules, .git).
203
- */
204
- export const listSkillFiles = async (skillPath: string, maxDepth: number = 3): Promise<string[]> => {
205
- const files: string[] = [];
206
-
207
- const walk = async (dir: string, depth: number): Promise<void> => {
208
- let entries: Dirent[];
209
- try {
210
- entries = await fs.readdir(dir, { withFileTypes: true });
211
- } catch {
212
- return;
213
- }
214
-
215
- for (const entry of entries) {
216
- if (entry.name.startsWith(".")) continue;
217
- if (entry.name === "node_modules" || entry.name === ".git") continue;
218
-
219
- const fullPath = path.join(dir, entry.name);
220
- const relPath = path.relative(skillPath, fullPath);
221
-
222
- if (entry.name === "SKILL.md") continue;
223
-
224
- if (entry.isDirectory()) {
225
- if (depth < maxDepth) {
226
- await walk(fullPath, depth + 1);
227
- }
228
- } else {
229
- files.push(relPath);
230
- }
231
- }
232
- };
233
-
234
- await walk(skillPath, 0);
235
- return files.sort();
236
- };
237
-
238
- /**
239
- * Get summaries of all available skills (name, description, trigger).
240
- * Used by preflight LLM call to evaluate which skills are relevant and
241
- * by the plugin's keyword matcher to rank matched skills.
242
- *
243
- * The `trigger` frontmatter key (PR 2 of `trigger-aware-skill-discovery`)
244
- * is threaded through so the keyword matcher can apply the 1.5x trigger
245
- * tier and the targeted outputs can render trigger text.
246
- *
247
- * @param directory - Project directory to discover skills from
248
- * @returns Array of skill summaries
249
- */
250
- export const getSkillSummaries = async (directory: string): Promise<Array<{ name: string; description: string; trigger?: string }>> => {
251
- const skillsByName = await discoverAllSkills(directory);
252
- return Array.from(skillsByName.values()).map(skill => ({
253
- name: skill.name,
254
- description: skill.description,
255
- trigger: skill.trigger,
256
- }));
257
- };
@@ -1,20 +0,0 @@
1
- /**
2
- * Public entrypoint for the portable skills core.
3
- *
4
- * Re-exports every type and function defined under `core/*` so consumers
5
- * (the host adapter, the test suite, and external harnesses) can
6
- * import everything from a single path:
7
- *
8
- * import { discoverAllSkills, resolveSkill, type Skill } from "opencode-agent-skills-md/core";
9
- *
10
- * The core has zero runtime dependency on any host SDK. Host adapters
11
- * supply the host-boundary types and client implementations.
12
- */
13
- export type { DiscoveryPath, FileDiscoveryResult, LabeledDiscoveryResult, Script, Skill, SkillLabel, SkillSummary, } from "./types";
14
- export type { SkillFrontmatter } from "./parse";
15
- export { parseSkillFile, parseYamlFrontmatter } from "./parse";
16
- export { defaultOnDuplicate, discoverAllSkills, findFile, findSkillsRecursive, getDefaultOpencodeRoots, getSkillSummaries, listSkillFiles, resolveSkill, } from "./discovery";
17
- export { findScripts, isPathSafe } from "./scripts";
18
- export { findClosestMatch, levenshtein } from "./match";
19
- export { formatSkillListing, renderAvailableSkillsBlock } from "./content";
20
- export { escapeRegex, keywordMatch, scoreSkill, searchSkills, tokenize, } from "./search";
@@ -1,55 +0,0 @@
1
- /**
2
- * Public entrypoint for the portable skills core.
3
- *
4
- * Re-exports every type and function defined under `core/*` so consumers
5
- * (the host adapter, the test suite, and external harnesses) can
6
- * import everything from a single path:
7
- *
8
- * import { discoverAllSkills, resolveSkill, type Skill } from "opencode-agent-skills-md/core";
9
- *
10
- * The core has zero runtime dependency on any host SDK. Host adapters
11
- * supply the host-boundary types and client implementations.
12
- */
13
-
14
- export type {
15
- DiscoveryPath,
16
- FileDiscoveryResult,
17
- LabeledDiscoveryResult,
18
- Script,
19
- Skill,
20
- SkillHostClient,
21
- SkillHostContext,
22
- SkillHostSession,
23
- SkillLabel,
24
- SkillSummary,
25
- } from "./types";
26
-
27
- export type { SkillFrontmatter } from "./parse";
28
- export { parseSkillFile, parseYamlFrontmatter } from "./parse";
29
-
30
- export {
31
- defaultOnDuplicate,
32
- discoverAllSkills,
33
- findFile,
34
- findSkillsRecursive,
35
- getDefaultOpencodeRoots,
36
- getSkillSummaries,
37
- listSkillFiles,
38
- resolveSkill,
39
- } from "./discovery";
40
-
41
- export { findScripts, isPathSafe } from "./scripts";
42
-
43
- export { debugLog } from "./debug";
44
-
45
- export { findClosestMatch, levenshtein } from "./match";
46
-
47
- export { formatSkillListing, renderAvailableSkillsBlock } from "./content";
48
-
49
- export {
50
- escapeRegex,
51
- keywordMatch,
52
- scoreSkill,
53
- searchSkills,
54
- tokenize,
55
- } from "./search";
@@ -1,19 +0,0 @@
1
- /**
2
- * Fuzzy string matching helpers used to suggest the closest skill or script
3
- * name when a user request does not match exactly.
4
- *
5
- * Pure functions: no I/O, no host dependencies.
6
- */
7
- /**
8
- * Calculate Levenshtein edit distance between two strings.
9
- * Used for fuzzy matching suggestions when skill/script names are not found.
10
- * @internal - exported for testing
11
- */
12
- export declare function levenshtein(a: string, b: string): number;
13
- /**
14
- * Find the closest matching string from a list of candidates.
15
- * Uses combined scoring: prefix match (strongest), substring match, then Levenshtein distance.
16
- * Returns the best match if similarity is above 0.4 threshold, otherwise null.
17
- * @internal - exported for testing
18
- */
19
- export declare function findClosestMatch(input: string, candidates: string[]): string | null;
@@ -1,75 +0,0 @@
1
- /**
2
- * Fuzzy string matching helpers used to suggest the closest skill or script
3
- * name when a user request does not match exactly.
4
- *
5
- * Pure functions: no I/O, no host dependencies.
6
- */
7
-
8
- /**
9
- * Calculate Levenshtein edit distance between two strings.
10
- * Used for fuzzy matching suggestions when skill/script names are not found.
11
- * @internal - exported for testing
12
- */
13
- export const levenshtein = (a: string, b: string): number => {
14
- const m = a.length;
15
- const n = b.length;
16
- const dp: number[][] = Array.from({ length: m + 1 }, (_, i) =>
17
- Array.from({ length: n + 1 }, (_, j) => i || j)
18
- );
19
- for (let i = 1; i <= m; i++) {
20
- for (let j = 1; j <= n; j++) {
21
- dp[i]![j] = Math.min(
22
- dp[i - 1]![j]! + 1,
23
- dp[i]![j - 1]! + 1,
24
- dp[i - 1]![j - 1]! + (a[i - 1] !== b[j - 1] ? 1 : 0)
25
- );
26
- }
27
- }
28
-
29
- return dp[m]![n]!;
30
- };
31
-
32
- /**
33
- * Find the closest matching string from a list of candidates.
34
- * Uses combined scoring: prefix match (strongest), substring match, then Levenshtein distance.
35
- * Returns the best match if similarity is above 0.4 threshold, otherwise null.
36
- * @internal - exported for testing
37
- */
38
- export const findClosestMatch = (input: string, candidates: string[]): string | null => {
39
- if (candidates.length === 0) return null;
40
-
41
- const inputLower = input.toLowerCase();
42
- let bestMatch: string | null = null;
43
- let bestScore = 0;
44
-
45
- for (const candidate of candidates) {
46
- const candidateLower = candidate.toLowerCase();
47
- let score = 0;
48
-
49
- if (candidateLower.startsWith(inputLower)) {
50
- score = 0.9 + (inputLower.length / candidateLower.length) * 0.1;
51
-
52
- const nextChar = candidateLower[inputLower.length];
53
- if (nextChar && /[-_/.]/.test(nextChar)) {
54
- score += 0.05;
55
- }
56
- } else if (inputLower.startsWith(candidateLower)) {
57
- score = 0.8;
58
- }
59
- else if (candidateLower.includes(inputLower) || inputLower.includes(candidateLower)) {
60
- score = 0.7;
61
- }
62
- else {
63
- const distance = levenshtein(inputLower, candidateLower);
64
- const maxLength = Math.max(inputLower.length, candidateLower.length);
65
- score = 1 - (distance / maxLength);
66
- }
67
-
68
- if (score > bestScore) {
69
- bestScore = score;
70
- bestMatch = candidate;
71
- }
72
- }
73
-
74
- return bestScore >= 0.4 ? bestMatch : null;
75
- };
@@ -1,26 +0,0 @@
1
- /**
2
- * YAML frontmatter parsing and skill frontmatter validation.
3
- *
4
- * Pure functions: no I/O, no host dependencies. The script-discovery step
5
- * that follows parsing is delegated to `core/scripts.ts`.
6
- */
7
- import type { Skill, SkillLabel } from "./types";
8
- /**
9
- * Parse YAML frontmatter using the yaml library with safe options.
10
- * Uses strict schema to prevent code execution from malicious YAML.
11
- * Handles all YAML 1.2 features including multi-line strings (| and >).
12
- */
13
- export declare function parseYamlFrontmatter(text: string): Record<string, unknown>;
14
- export interface SkillFrontmatter {
15
- name: string;
16
- description: string;
17
- trigger?: string;
18
- license?: string;
19
- "allowed-tools"?: string[];
20
- metadata?: Record<string, unknown>;
21
- }
22
- /**
23
- * Parse a SKILL.md file and validate its frontmatter.
24
- * Returns null if parsing fails (with error logging).
25
- */
26
- export declare function parseSkillFile(skillPath: string, relativePath: string, label: SkillLabel): Promise<Skill | null>;
@@ -1,141 +0,0 @@
1
- /**
2
- * YAML frontmatter parsing and skill frontmatter validation.
3
- *
4
- * Pure functions: no I/O, no host dependencies. The script-discovery step
5
- * that follows parsing is delegated to `core/scripts.ts`.
6
- */
7
-
8
- import * as fs from "node:fs/promises";
9
- import * as path from "node:path";
10
- import YAML from "yaml";
11
- import type { Skill, SkillLabel } from "./types";
12
- import { debugLog } from "./debug";
13
- import { findScripts } from "./scripts";
14
-
15
- /**
16
- * Parse YAML frontmatter using the yaml library with safe options.
17
- * Uses strict schema to prevent code execution from malicious YAML.
18
- * Handles all YAML 1.2 features including multi-line strings (| and >).
19
- *
20
- * Two distinct failure modes:
21
- * - Empty frontmatter (blank / whitespace-only input) returns `{}`
22
- * without touching the parser. This is a valid zero-field case.
23
- * - Malformed YAML (real syntax error) is caught and logged via the
24
- * `debugLog` helper; the function still returns `{}` so callers see
25
- * the same graceful fallback as before.
26
- */
27
- export const parseYamlFrontmatter = (text: string): Record<string, unknown> => {
28
- if (text.trim().length === 0) return {};
29
- try {
30
- const result = YAML.parse(text, {
31
- schema: "core",
32
- maxAliasCount: 100,
33
- });
34
- return typeof result === "object" && result !== null
35
- ? (result as Record<string, unknown>)
36
- : {};
37
- } catch (error) {
38
- debugLog("parseYamlFrontmatter: malformed YAML", error);
39
- return {};
40
- }
41
- };
42
-
43
- export interface SkillFrontmatter {
44
- name: string;
45
- description: string;
46
- trigger?: string;
47
- license?: string;
48
- "allowed-tools"?: string[];
49
- metadata?: Record<string, unknown>;
50
- }
51
-
52
- const NAME_REGEX = /^[\p{Ll}\p{N}-]+$/u;
53
-
54
- const validateFrontmatter = (obj: unknown): SkillFrontmatter | null => {
55
- if (typeof obj !== "object" || obj === null) return null;
56
- const o = obj as Record<string, unknown>;
57
- if (typeof o.name !== "string" || !NAME_REGEX.test(o.name) || o.name.length === 0) return null;
58
- if (typeof o.description !== "string" || o.description.length === 0) return null;
59
- if (o.trigger !== undefined && typeof o.trigger !== "string") return null;
60
- if (o.license !== undefined && typeof o.license !== "string") return null;
61
- if (o["allowed-tools"] !== undefined && !Array.isArray(o["allowed-tools"])) return null;
62
- if (o.metadata !== undefined && typeof o.metadata !== "object") return null;
63
-
64
- // Build SkillFrontmatter from validated fields. Avoids the previous
65
- // `as unknown as SkillFrontmatter` double cast so the resulting object
66
- // is structurally a SkillFrontmatter at every optional key.
67
- const frontmatter: SkillFrontmatter = {
68
- name: o.name,
69
- description: o.description,
70
- };
71
- if (o.trigger !== undefined) frontmatter.trigger = o.trigger;
72
- if (o.license !== undefined) frontmatter.license = o.license;
73
- if (o["allowed-tools"] !== undefined) {
74
- frontmatter["allowed-tools"] = o["allowed-tools"] as string[];
75
- }
76
- if (o.metadata !== undefined) {
77
- frontmatter.metadata = o.metadata as Record<string, unknown>;
78
- }
79
- return frontmatter;
80
- };
81
-
82
- /**
83
- * Parse a SKILL.md file and validate its frontmatter.
84
- * Returns null if parsing fails (with error logging).
85
- */
86
- export const parseSkillFile = async (
87
- skillPath: string,
88
- relativePath: string,
89
- label: SkillLabel
90
- ): Promise<Skill | null> => {
91
- const content = await fs.readFile(skillPath, 'utf-8').catch((error) => {
92
- debugLog("parseSkillFile: cannot read", skillPath, error);
93
- return null;
94
- });
95
- if (!content) {
96
- return null;
97
- }
98
-
99
- const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
100
- if (!frontmatterMatch?.[1] || !frontmatterMatch[2]) {
101
- return null;
102
- }
103
-
104
- const frontmatterText = frontmatterMatch[1];
105
- const skillContent = frontmatterMatch[2].trim();
106
-
107
- let frontmatterObj: unknown;
108
- try {
109
- frontmatterObj = parseYamlFrontmatter(frontmatterText);
110
- } catch {
111
- return null;
112
- }
113
-
114
- const frontmatter = validateFrontmatter(frontmatterObj);
115
- if (!frontmatter) {
116
- return null;
117
- }
118
-
119
- const skillDirPath = path.dirname(skillPath);
120
- const scripts = await findScripts(skillDirPath);
121
-
122
- const rawNamespace = frontmatter.metadata?.namespace;
123
- const namespace =
124
- typeof rawNamespace === "string" ? rawNamespace : undefined;
125
-
126
- const rawTags = frontmatter.metadata?.tags;
127
- const tags = Array.isArray(rawTags) ? rawTags.filter((t): t is string => typeof t === "string") : [];
128
-
129
- return {
130
- name: frontmatter.name,
131
- description: frontmatter.description,
132
- trigger: frontmatter.trigger,
133
- path: skillDirPath,
134
- relativePath,
135
- namespace,
136
- tags,
137
- label,
138
- scripts,
139
- template: skillContent
140
- };
141
- };
@@ -1,17 +0,0 @@
1
- /**
2
- * Script discovery and path-safety helpers.
3
- *
4
- * Pure functions: filesystem reads only, no host dependencies.
5
- */
6
- import type { Script } from "./types";
7
- /**
8
- * Recursively find executable scripts in a skill's directory.
9
- * Skips hidden directories (starting with .) and common dependency dirs.
10
- * Only files with executable bit set are returned.
11
- */
12
- export declare function findScripts(skillPath: string, maxDepth?: number): Promise<Script[]>;
13
- /**
14
- * Check if a path is safely within a base directory (no escape via .. or symlink).
15
- * Uses fs.realpath to canonicalize paths before comparing.
16
- */
17
- export declare function isPathSafe(basePath: string, requestedPath: string): Promise<boolean>;
@@ -1,79 +0,0 @@
1
- /**
2
- * Script discovery and path-safety helpers.
3
- *
4
- * Pure functions: filesystem reads only, no host dependencies.
5
- */
6
-
7
- import * as fs from "node:fs/promises";
8
- import * as path from "node:path";
9
- import type { Script } from "./types";
10
- import { walkDir } from "./walk";
11
-
12
- /**
13
- * Directory names the script walker skips on top of the unconditional
14
- * `node_modules` / `.git` / hidden-dir rules owned by {@link walkDir}.
15
- * These are common dependency / cache directories that never host skill
16
- * scripts and would otherwise inflate the file scan.
17
- */
18
- const SCRIPT_SKIP_DIRS: ReadonlySet<string> = new Set([
19
- '__pycache__',
20
- '.venv',
21
- 'venv',
22
- '.tox',
23
- '.nox',
24
- ]);
25
-
26
- /**
27
- * Recursively find executable scripts in a skill's directory.
28
- *
29
- * Traversal is delegated to the shared {@link walkDir} utility, which owns
30
- * hidden-dir / `node_modules` / `.git` skip rules and per-entry error
31
- * isolation. The visitor checks each file entry's executable bit (the
32
- * `0o111` mode mask) and pushes a `Script` record only for files that
33
- * qualify.
34
- *
35
- * Output is sorted by `relativePath` so callers see a stable order
36
- * regardless of the underlying `readdir` enumeration order.
37
- */
38
- export const findScripts = async (skillPath: string, maxDepth: number = 10): Promise<Script[]> => {
39
- const scripts: Script[] = [];
40
-
41
- await walkDir(skillPath, maxDepth, async (entry) => {
42
- if (!entry.isFile()) return;
43
- const fullPath = path.join(entry.parentPath, entry.name);
44
- const relPath = path.relative(skillPath, fullPath);
45
-
46
- let stats;
47
- try {
48
- stats = await fs.stat(fullPath);
49
- } catch {
50
- return;
51
- }
52
-
53
- if (stats.mode & 0o111) {
54
- scripts.push({ relativePath: relPath, absolutePath: fullPath });
55
- }
56
- }, { skipDirs: SCRIPT_SKIP_DIRS });
57
-
58
- return scripts.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
59
- };
60
-
61
- /**
62
- * Check if a path is safely within a base directory (no escape via .. or symlink).
63
- *
64
- * Uses fs.realpath to canonicalize both paths before comparing, which closes
65
- * the symlink-escape attack: a symlink inside the skill directory that points
66
- * outside will have its real path resolved and fail the prefix check.
67
- *
68
- * @returns Promise<boolean> — true if the resolved real path is within basePath
69
- */
70
- export const isPathSafe = async (basePath: string, requestedPath: string): Promise<boolean> => {
71
- const resolved = path.resolve(basePath, requestedPath);
72
- try {
73
- const resolvedReal = await fs.realpath(resolved);
74
- const baseReal = await fs.realpath(basePath);
75
- return resolvedReal.startsWith(baseReal + path.sep) || resolvedReal === baseReal;
76
- } catch {
77
- return false;
78
- }
79
- };