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