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.
- 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/.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 -220
- 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 -66
- package/packages/opencode-agent-skills-md/rolldown.config.js +0 -47
- 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 -345
- 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/main.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
package/dist/plugin.mjs
ADDED
|
@@ -0,0 +1,1138 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { tool } from "@opencode-ai/plugin";
|
|
6
|
+
//#region ../core/src/debug.ts
|
|
7
|
+
/**
|
|
8
|
+
* Debug-gated logging for bare `catch {}` blocks.
|
|
9
|
+
*
|
|
10
|
+
* Bare catches stay silent by default so malformed payloads and parse
|
|
11
|
+
* errors never surface as user-visible noise. Setting
|
|
12
|
+
* `OPENCODE_AGENT_SKILLS_DEBUG=1` makes the diagnostic context appear on
|
|
13
|
+
* stderr so developers can trace why a fallback fired.
|
|
14
|
+
*
|
|
15
|
+
* The env var is checked on every call (not cached at module load) so
|
|
16
|
+
* tests can toggle it without re-importing the module.
|
|
17
|
+
*/
|
|
18
|
+
const debugLog = (...args) => {
|
|
19
|
+
if (!process.env.OPENCODE_AGENT_SKILLS_DEBUG) return;
|
|
20
|
+
console.error("[opencode-agent-skills-md]", ...args);
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
//#region ../core/src/walk.ts
|
|
24
|
+
/**
|
|
25
|
+
* Internal shared directory walker.
|
|
26
|
+
*
|
|
27
|
+
* Walks `baseDir` recursively up to `maxDepth` and invokes `visitor` for each
|
|
28
|
+
* non-skipped entry. The walker owns the traversal rules shared by skill
|
|
29
|
+
* discovery (`findSkillsRecursive`) and script enumeration (`findScripts`):
|
|
30
|
+
*
|
|
31
|
+
* - hidden directories (names starting with `.`) are skipped
|
|
32
|
+
* - `node_modules` and `.git` are skipped unconditionally
|
|
33
|
+
* - per-entry errors (read, stat, or visitor throw) are isolated so a
|
|
34
|
+
* single broken symlink or throwing visitor does not abort the walk
|
|
35
|
+
*
|
|
36
|
+
* Callers that need extra skip sets (e.g. `__pycache__`, `.venv` for the
|
|
37
|
+
* scripts layer) pass them via {@link WalkOptions.skipDirs}. The walker does
|
|
38
|
+
* NOT re-export its skip rules — each caller decides what extra paths are
|
|
39
|
+
* not its business to enter.
|
|
40
|
+
*
|
|
41
|
+
* The walker is internal to the core package: it is intentionally NOT
|
|
42
|
+
* re-exported from `packages/core/src/index.ts`. Callers import it
|
|
43
|
+
* directly from `./walk`.
|
|
44
|
+
*/
|
|
45
|
+
/** Directories the walker skips on every invocation, regardless of caller. */
|
|
46
|
+
const ALWAYS_SKIP = new Set(["node_modules", ".git"]);
|
|
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
|
+
const walkDir = async (baseDir, maxDepth, visitor, options = {}) => {
|
|
67
|
+
const skipDirs = options.skipDirs;
|
|
68
|
+
await walk(baseDir, 0, maxDepth, visitor, skipDirs);
|
|
69
|
+
};
|
|
70
|
+
const walk = async (dir, depth, maxDepth, visitor, skipDirs) => {
|
|
71
|
+
if (depth > maxDepth) return;
|
|
72
|
+
let entries;
|
|
73
|
+
try {
|
|
74
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
75
|
+
} catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (entry.name.startsWith(".")) continue;
|
|
80
|
+
if (ALWAYS_SKIP.has(entry.name)) continue;
|
|
81
|
+
if (skipDirs?.has(entry.name)) continue;
|
|
82
|
+
try {
|
|
83
|
+
await visitor(entry, depth);
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (entry.isDirectory()) await walk(path.join(dir, entry.name), depth + 1, maxDepth, visitor, skipDirs);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region ../core/src/scripts.ts
|
|
92
|
+
/**
|
|
93
|
+
* Script discovery and path-safety helpers.
|
|
94
|
+
*
|
|
95
|
+
* Pure functions: filesystem reads only, no host dependencies.
|
|
96
|
+
*/
|
|
97
|
+
/**
|
|
98
|
+
* Directory names the script walker skips on top of the unconditional
|
|
99
|
+
* `node_modules` / `.git` / hidden-dir rules owned by {@link walkDir}.
|
|
100
|
+
* These are common dependency / cache directories that never host skill
|
|
101
|
+
* scripts and would otherwise inflate the file scan.
|
|
102
|
+
*/
|
|
103
|
+
const SCRIPT_SKIP_DIRS = new Set([
|
|
104
|
+
"__pycache__",
|
|
105
|
+
".venv",
|
|
106
|
+
"venv",
|
|
107
|
+
".tox",
|
|
108
|
+
".nox"
|
|
109
|
+
]);
|
|
110
|
+
/**
|
|
111
|
+
* Recursively find executable scripts in a skill's directory.
|
|
112
|
+
*
|
|
113
|
+
* Traversal is delegated to the shared {@link walkDir} utility, which owns
|
|
114
|
+
* hidden-dir / `node_modules` / `.git` skip rules and per-entry error
|
|
115
|
+
* isolation. The visitor checks each file entry's executable bit (the
|
|
116
|
+
* `0o111` mode mask) and pushes a `Script` record only for files that
|
|
117
|
+
* qualify.
|
|
118
|
+
*
|
|
119
|
+
* Output is sorted by `relativePath` so callers see a stable order
|
|
120
|
+
* regardless of the underlying `readdir` enumeration order.
|
|
121
|
+
*/
|
|
122
|
+
const findScripts = async (skillPath, maxDepth = 10) => {
|
|
123
|
+
const scripts = [];
|
|
124
|
+
await walkDir(skillPath, maxDepth, async (entry) => {
|
|
125
|
+
if (!entry.isFile()) return;
|
|
126
|
+
const fullPath = path.join(entry.parentPath, entry.name);
|
|
127
|
+
const relPath = path.relative(skillPath, fullPath);
|
|
128
|
+
let stats;
|
|
129
|
+
try {
|
|
130
|
+
stats = await fs.stat(fullPath);
|
|
131
|
+
} catch {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (stats.mode & 73) scripts.push({
|
|
135
|
+
relativePath: relPath,
|
|
136
|
+
absolutePath: fullPath
|
|
137
|
+
});
|
|
138
|
+
}, { skipDirs: SCRIPT_SKIP_DIRS });
|
|
139
|
+
return scripts.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* Check if a path is safely within a base directory (no escape via .. or symlink).
|
|
143
|
+
*
|
|
144
|
+
* Uses fs.realpath to canonicalize both paths before comparing, which closes
|
|
145
|
+
* the symlink-escape attack: a symlink inside the skill directory that points
|
|
146
|
+
* outside will have its real path resolved and fail the prefix check.
|
|
147
|
+
*
|
|
148
|
+
* @returns Promise<boolean> — true if the resolved real path is within basePath
|
|
149
|
+
*/
|
|
150
|
+
const isPathSafe = async (basePath, requestedPath) => {
|
|
151
|
+
const resolved = path.resolve(basePath, requestedPath);
|
|
152
|
+
try {
|
|
153
|
+
const resolvedReal = await fs.realpath(resolved);
|
|
154
|
+
const baseReal = await fs.realpath(basePath);
|
|
155
|
+
return resolvedReal.startsWith(baseReal + path.sep) || resolvedReal === baseReal;
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region ../core/src/parse.ts
|
|
162
|
+
/**
|
|
163
|
+
* YAML frontmatter parsing and skill frontmatter validation.
|
|
164
|
+
*
|
|
165
|
+
* Pure functions: no I/O, no host dependencies. The script-discovery step
|
|
166
|
+
* that follows parsing is delegated to `core/scripts.ts`.
|
|
167
|
+
*/
|
|
168
|
+
/**
|
|
169
|
+
* Parse YAML frontmatter using the yaml library with safe options.
|
|
170
|
+
* Uses strict schema to prevent code execution from malicious YAML.
|
|
171
|
+
* Handles all YAML 1.2 features including multi-line strings (| and >).
|
|
172
|
+
*
|
|
173
|
+
* Two distinct failure modes:
|
|
174
|
+
* - Empty frontmatter (blank / whitespace-only input) returns `{}`
|
|
175
|
+
* without touching the parser. This is a valid zero-field case.
|
|
176
|
+
* - Malformed YAML (real syntax error) is caught and logged via the
|
|
177
|
+
* `debugLog` helper; the function still returns `{}` so callers see
|
|
178
|
+
* the same graceful fallback as before.
|
|
179
|
+
*/
|
|
180
|
+
const parseYamlFrontmatter = (text) => {
|
|
181
|
+
if (text.trim().length === 0) return {};
|
|
182
|
+
try {
|
|
183
|
+
const result = YAML.parse(text, {
|
|
184
|
+
schema: "core",
|
|
185
|
+
maxAliasCount: 100
|
|
186
|
+
});
|
|
187
|
+
return typeof result === "object" && result !== null ? result : {};
|
|
188
|
+
} catch (error) {
|
|
189
|
+
debugLog("parseYamlFrontmatter: malformed YAML", error);
|
|
190
|
+
return {};
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
const NAME_REGEX = /^[\p{Ll}\p{N}-]+$/u;
|
|
194
|
+
const validateFrontmatter = (obj) => {
|
|
195
|
+
if (typeof obj !== "object" || obj === null) return null;
|
|
196
|
+
const o = obj;
|
|
197
|
+
if (typeof o.name !== "string" || !NAME_REGEX.test(o.name) || o.name.length === 0) return null;
|
|
198
|
+
if (typeof o.description !== "string" || o.description.length === 0) return null;
|
|
199
|
+
if (o.trigger !== void 0 && typeof o.trigger !== "string") return null;
|
|
200
|
+
if (o.license !== void 0 && typeof o.license !== "string") return null;
|
|
201
|
+
if (o["allowed-tools"] !== void 0 && !Array.isArray(o["allowed-tools"])) return null;
|
|
202
|
+
if (o.metadata !== void 0 && typeof o.metadata !== "object") return null;
|
|
203
|
+
const frontmatter = {
|
|
204
|
+
name: o.name,
|
|
205
|
+
description: o.description
|
|
206
|
+
};
|
|
207
|
+
if (o.trigger !== void 0) frontmatter.trigger = o.trigger;
|
|
208
|
+
if (o.license !== void 0) frontmatter.license = o.license;
|
|
209
|
+
if (o["allowed-tools"] !== void 0) frontmatter["allowed-tools"] = o["allowed-tools"];
|
|
210
|
+
if (o.metadata !== void 0) frontmatter.metadata = o.metadata;
|
|
211
|
+
return frontmatter;
|
|
212
|
+
};
|
|
213
|
+
/**
|
|
214
|
+
* Parse a SKILL.md file and validate its frontmatter.
|
|
215
|
+
* Returns null if parsing fails (with error logging).
|
|
216
|
+
*/
|
|
217
|
+
const parseSkillFile = async (skillPath, relativePath, label) => {
|
|
218
|
+
const content = await fs.readFile(skillPath, "utf-8").catch((error) => {
|
|
219
|
+
debugLog("parseSkillFile: cannot read", skillPath, error);
|
|
220
|
+
return null;
|
|
221
|
+
});
|
|
222
|
+
if (!content) return null;
|
|
223
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
224
|
+
if (!frontmatterMatch?.[1] || !frontmatterMatch[2]) return null;
|
|
225
|
+
const frontmatterText = frontmatterMatch[1];
|
|
226
|
+
const skillContent = frontmatterMatch[2].trim();
|
|
227
|
+
let frontmatterObj;
|
|
228
|
+
try {
|
|
229
|
+
frontmatterObj = parseYamlFrontmatter(frontmatterText);
|
|
230
|
+
} catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
const frontmatter = validateFrontmatter(frontmatterObj);
|
|
234
|
+
if (!frontmatter) return null;
|
|
235
|
+
const skillDirPath = path.dirname(skillPath);
|
|
236
|
+
const scripts = await findScripts(skillDirPath);
|
|
237
|
+
const rawNamespace = frontmatter.metadata?.namespace;
|
|
238
|
+
const namespace = typeof rawNamespace === "string" ? rawNamespace : void 0;
|
|
239
|
+
const rawTags = frontmatter.metadata?.tags;
|
|
240
|
+
const tags = Array.isArray(rawTags) ? rawTags.filter((t) => typeof t === "string") : [];
|
|
241
|
+
return {
|
|
242
|
+
name: frontmatter.name,
|
|
243
|
+
description: frontmatter.description,
|
|
244
|
+
trigger: frontmatter.trigger,
|
|
245
|
+
path: skillDirPath,
|
|
246
|
+
relativePath,
|
|
247
|
+
namespace,
|
|
248
|
+
tags,
|
|
249
|
+
label,
|
|
250
|
+
scripts,
|
|
251
|
+
template: skillContent
|
|
252
|
+
};
|
|
253
|
+
};
|
|
254
|
+
//#endregion
|
|
255
|
+
//#region ../core/src/discovery.ts
|
|
256
|
+
/**
|
|
257
|
+
* Skill discovery across filesystem roots.
|
|
258
|
+
*
|
|
259
|
+
* The core never hard-codes a host's directory layout. Callers pass the list
|
|
260
|
+
* of discovery roots; the default `getDefaultOpencodeRoots` reproduces the
|
|
261
|
+
* legacy OpenCode priority order. PR2 will call `discoverAllSkills` from the
|
|
262
|
+
* OpenCode host adapter with the same default.
|
|
263
|
+
*/
|
|
264
|
+
/**
|
|
265
|
+
* Check if a file exists in a directory and return path info.
|
|
266
|
+
*
|
|
267
|
+
* @param directory - Directory to check
|
|
268
|
+
* @param relativePath - Relative path to use in result (caller-specific)
|
|
269
|
+
* @param filename - Name of file to look for (e.g., 'SKILL.md')
|
|
270
|
+
* @returns Path info if file exists, null otherwise
|
|
271
|
+
*/
|
|
272
|
+
const findFile = async (directory, relativePath, filename) => {
|
|
273
|
+
const filePath = path.join(directory, filename);
|
|
274
|
+
try {
|
|
275
|
+
await fs.stat(filePath);
|
|
276
|
+
return {
|
|
277
|
+
filePath,
|
|
278
|
+
relativePath
|
|
279
|
+
};
|
|
280
|
+
} catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
/**
|
|
285
|
+
* Recursively find SKILL.md files in a directory.
|
|
286
|
+
*
|
|
287
|
+
* The base directory itself is checked first: a SKILL.md placed at the root
|
|
288
|
+
* of a discovery root is returned with `relativePath = ""` and wins the
|
|
289
|
+
* shadowing tie-break over same-name skills in subdirectories (first found
|
|
290
|
+
* wins in `discoverAllSkills`).
|
|
291
|
+
*
|
|
292
|
+
* The traversal is delegated to the shared {@link walkDir} utility, which
|
|
293
|
+
* owns hidden-dir / `node_modules` / `.git` skip rules and per-entry error
|
|
294
|
+
* isolation. The visitor only checks each directory entry for SKILL.md and
|
|
295
|
+
* records the labeled result; recursion and skip semantics are the walker's
|
|
296
|
+
* job, not this function's.
|
|
297
|
+
*
|
|
298
|
+
* Output is sorted by `relativePath` so callers see a stable order across
|
|
299
|
+
* runs regardless of the underlying `readdir` enumeration order.
|
|
300
|
+
*/
|
|
301
|
+
const findSkillsRecursive = async (baseDir, label, maxDepth = 3) => {
|
|
302
|
+
const results = [];
|
|
303
|
+
try {
|
|
304
|
+
await fs.access(baseDir);
|
|
305
|
+
const rootFile = await findFile(baseDir, "", "SKILL.md");
|
|
306
|
+
if (rootFile) results.push({
|
|
307
|
+
...rootFile,
|
|
308
|
+
label
|
|
309
|
+
});
|
|
310
|
+
await walkDir(baseDir, maxDepth, async (entry) => {
|
|
311
|
+
if (!entry.isDirectory()) return;
|
|
312
|
+
const fullPath = path.join(entry.parentPath, entry.name);
|
|
313
|
+
const found = await findFile(fullPath, path.relative(baseDir, fullPath), "SKILL.md");
|
|
314
|
+
if (found) results.push({
|
|
315
|
+
...found,
|
|
316
|
+
label
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
} catch (error) {
|
|
320
|
+
debugLog("findSkillsRecursive: cannot access baseDir", baseDir, error);
|
|
321
|
+
}
|
|
322
|
+
return results.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
323
|
+
};
|
|
324
|
+
/**
|
|
325
|
+
* Default recursion depth for the four priority discovery roots.
|
|
326
|
+
*
|
|
327
|
+
* Pre-refactor commit `c2d8e74` used `maxDepth: 1` for the Claude-side
|
|
328
|
+
* roots; commit `12de52a` ("fix(core): unify maxDepth to 3 across all
|
|
329
|
+
* discovery roots") widened them deliberately so deeply-nested Claude
|
|
330
|
+
* skills surface. The regression net in
|
|
331
|
+
* `tests/integration/skill-discovery.test.ts` pins this value so a
|
|
332
|
+
* future narrowing breaks loudly.
|
|
333
|
+
*/
|
|
334
|
+
const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
|
|
335
|
+
/**
|
|
336
|
+
* Default discovery roots matching the pre-refactor OpenCode priority order
|
|
337
|
+
* (see commit `c2d8e74`, `src/skills.ts#discoverAllSkills`):
|
|
338
|
+
* 1. .opencode/skills/ (project - OpenCode)
|
|
339
|
+
* 2. .claude/skills/ (project - Claude)
|
|
340
|
+
* 3. ~/.config/opencode/skills/ (user - OpenCode)
|
|
341
|
+
* 4. ~/.claude/skills/ (user - Claude)
|
|
342
|
+
*
|
|
343
|
+
* No shadowing - unique names only. First match wins, duplicates are warned.
|
|
344
|
+
*/
|
|
345
|
+
const getDefaultOpencodeRoots = (directory) => {
|
|
346
|
+
return [
|
|
347
|
+
{
|
|
348
|
+
path: path.join(directory, ".opencode", "skills"),
|
|
349
|
+
label: "project",
|
|
350
|
+
maxDepth: DEFAULT_DISCOVERY_MAX_DEPTH
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
path: path.join(directory, ".claude", "skills"),
|
|
354
|
+
label: "claude-project",
|
|
355
|
+
maxDepth: DEFAULT_DISCOVERY_MAX_DEPTH
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
path: path.join(homedir(), ".config", "opencode", "skills"),
|
|
359
|
+
label: "user",
|
|
360
|
+
maxDepth: DEFAULT_DISCOVERY_MAX_DEPTH
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
path: path.join(homedir(), ".claude", "skills"),
|
|
364
|
+
label: "claude-user",
|
|
365
|
+
maxDepth: DEFAULT_DISCOVERY_MAX_DEPTH
|
|
366
|
+
}
|
|
367
|
+
];
|
|
368
|
+
};
|
|
369
|
+
/**
|
|
370
|
+
* Default callback for shadowed skill names. Emits a `console.warn` that
|
|
371
|
+
* identifies the surviving (existing) skill and the duplicate that was
|
|
372
|
+
* skipped. Hosts can override by passing `onDuplicate` to `discoverAllSkills`.
|
|
373
|
+
*
|
|
374
|
+
* @internal - exported for testing
|
|
375
|
+
*/
|
|
376
|
+
const defaultOnDuplicate = (existing, duplicate) => {
|
|
377
|
+
console.warn(`Skill name conflict: '${existing.name}' at ${existing.path} shadows duplicate at ${duplicate.path}`);
|
|
378
|
+
};
|
|
379
|
+
/**
|
|
380
|
+
* Discover all skills from the provided roots.
|
|
381
|
+
*
|
|
382
|
+
* @param directory - Project directory (used to build the default roots).
|
|
383
|
+
* @param roots - Discovery roots. Defaults to the OpenCode priority order
|
|
384
|
+
* via `getDefaultOpencodeRoots(directory)`. Hosts pass an explicit list to
|
|
385
|
+
* override the layout.
|
|
386
|
+
* @param onDuplicate - Optional callback invoked when two roots produce a
|
|
387
|
+
* skill with the same `name`. Defaults to `console.warn` via
|
|
388
|
+
* `defaultOnDuplicate`. The first-discovered skill wins; the duplicate
|
|
389
|
+
* (second one encountered) is passed to the callback but never stored.
|
|
390
|
+
*/
|
|
391
|
+
const discoverAllSkills = async (directory, roots = getDefaultOpencodeRoots(directory), onDuplicate = defaultOnDuplicate) => {
|
|
392
|
+
const allResults = [];
|
|
393
|
+
for (const { path: baseDir, label, maxDepth } of roots) allResults.push(...await findSkillsRecursive(baseDir, label, maxDepth));
|
|
394
|
+
const skillsByName = /* @__PURE__ */ new Map();
|
|
395
|
+
for (const { filePath, relativePath, label } of allResults) {
|
|
396
|
+
const skill = await parseSkillFile(filePath, relativePath, label);
|
|
397
|
+
if (!skill) continue;
|
|
398
|
+
if (skillsByName.has(skill.name)) {
|
|
399
|
+
onDuplicate(skillsByName.get(skill.name), skill);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
skillsByName.set(skill.name, skill);
|
|
403
|
+
}
|
|
404
|
+
return skillsByName;
|
|
405
|
+
};
|
|
406
|
+
/**
|
|
407
|
+
* Resolve a skill by name, handling namespace prefixes.
|
|
408
|
+
* Supports: "skill-name", "project:skill-name", "user:skill-name", etc.
|
|
409
|
+
*/
|
|
410
|
+
const resolveSkill = (skillName, skillsByName) => {
|
|
411
|
+
if (skillName.includes(":")) {
|
|
412
|
+
const [namespace, name] = skillName.split(":");
|
|
413
|
+
for (const skill of skillsByName.values()) if (skill.name === name && (skill.label === namespace || skill.namespace === namespace)) return skill;
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
return skillsByName.get(skillName) || null;
|
|
417
|
+
};
|
|
418
|
+
/**
|
|
419
|
+
* Recursively list all files in a directory, returning relative paths.
|
|
420
|
+
* Excludes SKILL.md since it's already loaded as the main content.
|
|
421
|
+
* Applies the same skip rules as walkDir (hidden dirs, node_modules, .git).
|
|
422
|
+
*/
|
|
423
|
+
const listSkillFiles = async (skillPath, maxDepth = 3) => {
|
|
424
|
+
const files = [];
|
|
425
|
+
const walk = async (dir, depth) => {
|
|
426
|
+
let entries;
|
|
427
|
+
try {
|
|
428
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
429
|
+
} catch {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
for (const entry of entries) {
|
|
433
|
+
if (entry.name.startsWith(".")) continue;
|
|
434
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
435
|
+
const fullPath = path.join(dir, entry.name);
|
|
436
|
+
const relPath = path.relative(skillPath, fullPath);
|
|
437
|
+
if (entry.name === "SKILL.md") continue;
|
|
438
|
+
if (entry.isDirectory()) {
|
|
439
|
+
if (depth < maxDepth) await walk(fullPath, depth + 1);
|
|
440
|
+
} else files.push(relPath);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
await walk(skillPath, 0);
|
|
444
|
+
return files.sort();
|
|
445
|
+
};
|
|
446
|
+
//#endregion
|
|
447
|
+
//#region ../core/src/match.ts
|
|
448
|
+
/**
|
|
449
|
+
* Fuzzy string matching helpers used to suggest the closest skill or script
|
|
450
|
+
* name when a user request does not match exactly.
|
|
451
|
+
*
|
|
452
|
+
* Pure functions: no I/O, no host dependencies.
|
|
453
|
+
*/
|
|
454
|
+
/**
|
|
455
|
+
* Calculate Levenshtein edit distance between two strings.
|
|
456
|
+
* Used for fuzzy matching suggestions when skill/script names are not found.
|
|
457
|
+
* @internal - exported for testing
|
|
458
|
+
*/
|
|
459
|
+
const levenshtein = (a, b) => {
|
|
460
|
+
const m = a.length;
|
|
461
|
+
const n = b.length;
|
|
462
|
+
const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_, j) => i || j));
|
|
463
|
+
for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++) dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] !== b[j - 1] ? 1 : 0));
|
|
464
|
+
return dp[m][n];
|
|
465
|
+
};
|
|
466
|
+
/**
|
|
467
|
+
* Find the closest matching string from a list of candidates.
|
|
468
|
+
* Uses combined scoring: prefix match (strongest), substring match, then Levenshtein distance.
|
|
469
|
+
* Returns the best match if similarity is above 0.4 threshold, otherwise null.
|
|
470
|
+
* @internal - exported for testing
|
|
471
|
+
*/
|
|
472
|
+
const findClosestMatch = (input, candidates) => {
|
|
473
|
+
if (candidates.length === 0) return null;
|
|
474
|
+
const inputLower = input.toLowerCase();
|
|
475
|
+
let bestMatch = null;
|
|
476
|
+
let bestScore = 0;
|
|
477
|
+
for (const candidate of candidates) {
|
|
478
|
+
const candidateLower = candidate.toLowerCase();
|
|
479
|
+
let score = 0;
|
|
480
|
+
if (candidateLower.startsWith(inputLower)) {
|
|
481
|
+
score = .9 + inputLower.length / candidateLower.length * .1;
|
|
482
|
+
const nextChar = candidateLower[inputLower.length];
|
|
483
|
+
if (nextChar && /[-_/.]/.test(nextChar)) score += .05;
|
|
484
|
+
} else if (inputLower.startsWith(candidateLower)) score = .8;
|
|
485
|
+
else if (candidateLower.includes(inputLower) || inputLower.includes(candidateLower)) score = .7;
|
|
486
|
+
else score = 1 - levenshtein(inputLower, candidateLower) / Math.max(inputLower.length, candidateLower.length);
|
|
487
|
+
if (score > bestScore) {
|
|
488
|
+
bestScore = score;
|
|
489
|
+
bestMatch = candidate;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return bestScore >= .4 ? bestMatch : null;
|
|
493
|
+
};
|
|
494
|
+
//#endregion
|
|
495
|
+
//#region ../core/src/content.ts
|
|
496
|
+
/**
|
|
497
|
+
* Format a list of skills as the inner bullet block used inside the
|
|
498
|
+
* `<available-skills>` synthetic injection.
|
|
499
|
+
*/
|
|
500
|
+
const formatSkillListing = (skills) => {
|
|
501
|
+
return skills.map((s) => `- ${s.name}: ${s.description}`).join("\n");
|
|
502
|
+
};
|
|
503
|
+
/**
|
|
504
|
+
* Render the full `<available-skills>...</available-skills>` block that the
|
|
505
|
+
* host injects into a session on startup and after compaction.
|
|
506
|
+
*/
|
|
507
|
+
const renderAvailableSkillsBlock = (skills) => {
|
|
508
|
+
return `<available-skills>
|
|
509
|
+
Use the use_skill, read_skill_file, run_skill_script, and get_available_skills tools to work with skills.
|
|
510
|
+
|
|
511
|
+
${formatSkillListing(skills)}
|
|
512
|
+
</available-skills>`;
|
|
513
|
+
};
|
|
514
|
+
//#endregion
|
|
515
|
+
//#region ../core/src/search.ts
|
|
516
|
+
/**
|
|
517
|
+
* Tokenize a free-text query into lowercase, non-empty tokens.
|
|
518
|
+
*
|
|
519
|
+
* Whitespace is the only separator. Empty tokens (from leading or
|
|
520
|
+
* trailing whitespace) are dropped so the caller never has to filter
|
|
521
|
+
* them out before scoring.
|
|
522
|
+
*/
|
|
523
|
+
const tokenize = (query) => {
|
|
524
|
+
return query.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
|
|
525
|
+
};
|
|
526
|
+
/**
|
|
527
|
+
* Check whether a skill matches at least one of the supplied keywords
|
|
528
|
+
* against its `metadata.tags`. OR semantics: a single tag hit is enough
|
|
529
|
+
* to keep the skill in the result set. An empty keyword list is a no-op
|
|
530
|
+
* (every skill passes).
|
|
531
|
+
*/
|
|
532
|
+
const keywordMatch = (skill, keywords) => {
|
|
533
|
+
if (keywords.length === 0) return true;
|
|
534
|
+
const tags = skill.tags ?? [];
|
|
535
|
+
return keywords.some((kw) => tags.includes(kw));
|
|
536
|
+
};
|
|
537
|
+
/** Compute a Levenshtein-derived similarity in the 0..1 range. */
|
|
538
|
+
const similarity = (a, b) => {
|
|
539
|
+
if (a.length === 0 && b.length === 0) return 1;
|
|
540
|
+
return 1 - levenshtein(a, b) / Math.max(a.length, b.length);
|
|
541
|
+
};
|
|
542
|
+
/** Best description-token fuzzy similarity for a single query token. */
|
|
543
|
+
const bestDescriptionTokenSim = (descLower, token) => {
|
|
544
|
+
let best = 0;
|
|
545
|
+
for (const dt of descLower.split(/\s+/)) {
|
|
546
|
+
if (dt.length === 0) continue;
|
|
547
|
+
const sim = similarity(dt, token);
|
|
548
|
+
if (sim > best) best = sim;
|
|
549
|
+
}
|
|
550
|
+
return best;
|
|
551
|
+
};
|
|
552
|
+
/**
|
|
553
|
+
* Score a skill against a list of pre-tokenized, lowercase query
|
|
554
|
+
* tokens. Returns 0 when the skill has no chance of matching (used to
|
|
555
|
+
* drop it from the result). Positive scores compare higher = more
|
|
556
|
+
* relevant.
|
|
557
|
+
*
|
|
558
|
+
* The "description contains ALL tokens" tier (50) is applied as a
|
|
559
|
+
* per-token lift, not a flat bonus, so the ordering
|
|
560
|
+
* name exact > name prefix > name fuzzy > trigger > desc-all > desc-any
|
|
561
|
+
* is preserved even after the `max + 0.1 * sum` multi-token formula.
|
|
562
|
+
*
|
|
563
|
+
* The `trigger` tier (60) is a flat per-token contribution: any token
|
|
564
|
+
* that appears as a case-insensitive substring of `skill.trigger` adds
|
|
565
|
+
* 60 to that token's contribution. Trigger is sandwiched between the
|
|
566
|
+
* name tiers (≥70) and the description tiers (≤50) so the invariant
|
|
567
|
+
* name > trigger > description
|
|
568
|
+
* holds for single-token queries.
|
|
569
|
+
*/
|
|
570
|
+
const scoreSkill = (skill, tokens) => {
|
|
571
|
+
if (tokens.length === 0) return 0;
|
|
572
|
+
const name = skill.name.toLowerCase();
|
|
573
|
+
const desc = skill.description.toLowerCase();
|
|
574
|
+
const trigger = skill.trigger?.toLowerCase() ?? "";
|
|
575
|
+
const descTier = tokens.every((t) => desc.includes(t)) ? 50 : 30;
|
|
576
|
+
const perToken = [];
|
|
577
|
+
for (const token of tokens) {
|
|
578
|
+
let s = 0;
|
|
579
|
+
if (name === token) s = Math.max(s, 100);
|
|
580
|
+
else if (name.startsWith(token)) s = Math.max(s, 90);
|
|
581
|
+
else {
|
|
582
|
+
const nameSim = similarity(name, token);
|
|
583
|
+
if (nameSim > .4) s = Math.max(s, 70 * nameSim);
|
|
584
|
+
}
|
|
585
|
+
if (trigger.length > 0 && trigger.includes(token)) s = Math.max(s, 60);
|
|
586
|
+
if (desc.includes(token)) s = Math.max(s, descTier);
|
|
587
|
+
else {
|
|
588
|
+
const descSim = bestDescriptionTokenSim(desc, token);
|
|
589
|
+
if (descSim > .4) s = Math.max(s, Math.min(60, 60 * descSim));
|
|
590
|
+
}
|
|
591
|
+
if (s === 0) return 0;
|
|
592
|
+
perToken.push(s);
|
|
593
|
+
}
|
|
594
|
+
return Math.max(...perToken) + .1 * perToken.reduce((a, b) => a + b, 0);
|
|
595
|
+
};
|
|
596
|
+
/**
|
|
597
|
+
* Filter, score, and rank skills against a free-text query and an
|
|
598
|
+
* optional tag-keyword filter. The keyword filter applies first
|
|
599
|
+
* (it is the cheaper predicate and can only narrow the candidate
|
|
600
|
+
* set), then the query is tokenized and scored.
|
|
601
|
+
*
|
|
602
|
+
* Returns a new array sorted by score descending. Skills with a score
|
|
603
|
+
* of 0 are dropped. When the query is empty AND no keywords are
|
|
604
|
+
* supplied, the input list is returned unchanged (the caller can use
|
|
605
|
+
* the unranked discovery for browsing).
|
|
606
|
+
*/
|
|
607
|
+
const searchSkills = (skills, query, keywords) => {
|
|
608
|
+
let candidates = skills;
|
|
609
|
+
if (keywords && keywords.length > 0) candidates = candidates.filter((s) => keywordMatch(s, keywords));
|
|
610
|
+
if (!query || query.trim() === "") return candidates;
|
|
611
|
+
const tokens = tokenize(query);
|
|
612
|
+
if (tokens.length === 0) return candidates;
|
|
613
|
+
return candidates.map((skill) => ({
|
|
614
|
+
skill,
|
|
615
|
+
score: scoreSkill(skill, tokens)
|
|
616
|
+
})).filter(({ score }) => score > 0).sort((a, b) => b.score - a.score).map(({ skill }) => skill);
|
|
617
|
+
};
|
|
618
|
+
//#endregion
|
|
619
|
+
//#region src/host.ts
|
|
620
|
+
/**
|
|
621
|
+
* OpenCode host adapter.
|
|
622
|
+
*
|
|
623
|
+
* Wraps the OpenCode SDK client (`PluginInput["client"]`) and provides
|
|
624
|
+
* a bounded surface for content injection, session context, and filesystem
|
|
625
|
+
* access consumed by the plugin and skill tools.
|
|
626
|
+
*
|
|
627
|
+
* The boundary contracts (`SkillHostClient`, `SkillHostSession`,
|
|
628
|
+
* `SkillHostContext`) are declared in the `opencode-agent-skills-md-core`
|
|
629
|
+
* package per spec R2; this module IMPLEMENTS them over the OpenCode SDK
|
|
630
|
+
* client plus `node:fs/promises`. No other package may declare a concrete
|
|
631
|
+
* implementation — the plugin package owns exactly one.
|
|
632
|
+
*/
|
|
633
|
+
/**
|
|
634
|
+
* Build an `OpencodeSkillHost` over the supplied OpenCode SDK client.
|
|
635
|
+
*
|
|
636
|
+
* The host is the only place in the codebase that touches the SDK's
|
|
637
|
+
* `client.session.prompt` and `client.session.messages` methods.
|
|
638
|
+
*/
|
|
639
|
+
const createOpencodeSkillHost = (client) => {
|
|
640
|
+
const skillClient = {
|
|
641
|
+
async injectContent(sessionID, text, context) {
|
|
642
|
+
await client.session.prompt({
|
|
643
|
+
path: { id: sessionID },
|
|
644
|
+
body: {
|
|
645
|
+
noReply: true,
|
|
646
|
+
model: context?.model,
|
|
647
|
+
agent: context?.agent,
|
|
648
|
+
parts: [{
|
|
649
|
+
type: "text",
|
|
650
|
+
text,
|
|
651
|
+
synthetic: true
|
|
652
|
+
}]
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
},
|
|
656
|
+
async getSessionContext(sessionID) {
|
|
657
|
+
try {
|
|
658
|
+
const response = await client.session.messages({
|
|
659
|
+
path: { id: sessionID },
|
|
660
|
+
query: { limit: 50 }
|
|
661
|
+
});
|
|
662
|
+
if (response.data) {
|
|
663
|
+
for (const msg of response.data) if (msg.info.role === "user" && "model" in msg.info && msg.info.model) return {
|
|
664
|
+
model: msg.info.model,
|
|
665
|
+
agent: msg.info.agent
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
} catch (error) {
|
|
669
|
+
debugLog("getSessionContext: session lookup failed", sessionID, error?.name);
|
|
670
|
+
}
|
|
671
|
+
},
|
|
672
|
+
async readFile(filePath) {
|
|
673
|
+
return fs.readFile(filePath, "utf-8");
|
|
674
|
+
},
|
|
675
|
+
async readdir(dirPath) {
|
|
676
|
+
return fs.readdir(dirPath);
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
const session = (id) => ({ id });
|
|
680
|
+
return {
|
|
681
|
+
client: skillClient,
|
|
682
|
+
session
|
|
683
|
+
};
|
|
684
|
+
};
|
|
685
|
+
//#endregion
|
|
686
|
+
//#region src/tools.ts
|
|
687
|
+
/** Escape XML special characters to prevent wrapper breakout. */
|
|
688
|
+
const escapeXml = (s) => {
|
|
689
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
690
|
+
};
|
|
691
|
+
/** Wrap a shell argument in single quotes and escape embedded single quotes (Bourne-shell pattern). */
|
|
692
|
+
const escapeShellArg = (arg) => {
|
|
693
|
+
return "'" + arg.replace(/'/g, "'\\''") + "'";
|
|
694
|
+
};
|
|
695
|
+
/**
|
|
696
|
+
* Tool translation guide for skills written for Claude Code.
|
|
697
|
+
* Injected into skill content to help the AI use OpenCode equivalents.
|
|
698
|
+
*/
|
|
699
|
+
const toolTranslation = `<tool-translation>
|
|
700
|
+
This skill may reference Claude Code tools. Use OpenCode equivalents:
|
|
701
|
+
- TodoWrite/TodoRead -> todowrite/todoread
|
|
702
|
+
- Task (subagents) -> task tool with subagent_type parameter
|
|
703
|
+
- Skill tool -> use_skill tool
|
|
704
|
+
- Read/Write/Edit/Bash/Glob/Grep/WebFetch -> lowercase (read/write/edit/bash/glob/grep/webfetch)
|
|
705
|
+
</tool-translation>`;
|
|
706
|
+
/**
|
|
707
|
+
* Build the four skill tool factories bound to the host, shell, and
|
|
708
|
+
* project directory. The returned object is what the plugin registers
|
|
709
|
+
* under its `tool` hook.
|
|
710
|
+
*
|
|
711
|
+
* The optional `onSkillLoaded` callback is threaded through to `UseSkill`
|
|
712
|
+
* so a successful load can update host session state (e.g., the loaded-
|
|
713
|
+
* skill set used to suppress duplicate match injection in `chat.message`).
|
|
714
|
+
*/
|
|
715
|
+
const createSkillTools = (host, $, directory, onSkillLoaded) => {
|
|
716
|
+
return {
|
|
717
|
+
GetAvailableSkills: GetAvailableSkills(directory),
|
|
718
|
+
ReadSkillFile: ReadSkillFile(directory, host),
|
|
719
|
+
RunSkillScript: RunSkillScript(directory, $),
|
|
720
|
+
UseSkill: UseSkill(directory, host, onSkillLoaded)
|
|
721
|
+
};
|
|
722
|
+
};
|
|
723
|
+
/**
|
|
724
|
+
* Resolve a skill by name, or return a "not found" message with a
|
|
725
|
+
* close-match suggestion.
|
|
726
|
+
*
|
|
727
|
+
* Centralizes the duplicated resolve-then-suggest pattern that
|
|
728
|
+
* `use_skill`, `read_skill_file`, and `run_skill_script` all need.
|
|
729
|
+
* Returning a single `string` keeps the call site trivial:
|
|
730
|
+
*
|
|
731
|
+
* - skill found → returns `skill.name`
|
|
732
|
+
* - skill missing, suggestion → `Skill "<name>" not found. Did you mean "<suggestion>"?`
|
|
733
|
+
* - skill missing, no hint → `Skill "<name>" not found. Use get_available_skills to list available skills.`
|
|
734
|
+
*
|
|
735
|
+
* Not-found messages always start with the literal `Skill "` so callers
|
|
736
|
+
* can detect them with `result.startsWith('Skill "')`. The skill-name
|
|
737
|
+
* regex (`/^[\p{Ll}\p{N}-]+$/u`) forbids uppercase initial characters,
|
|
738
|
+
* so a legitimate skill name can never collide with that prefix.
|
|
739
|
+
*
|
|
740
|
+
* The helper does its own discovery; callers that need the `Skill` object
|
|
741
|
+
* (rather than just its name) re-resolve via a second `discoverAllSkills`
|
|
742
|
+
* call. Discovery is cheap (file-listing only) and the OS-level metadata
|
|
743
|
+
* cache absorbs most of the cost.
|
|
744
|
+
*/
|
|
745
|
+
const resolveSkillOrSuggest = async (directory, skillName) => {
|
|
746
|
+
const skillsByName = await discoverAllSkills(directory);
|
|
747
|
+
const skill = resolveSkill(skillName, skillsByName);
|
|
748
|
+
if (skill) return skill.name;
|
|
749
|
+
const suggestion = findClosestMatch(skillName, Array.from(skillsByName.values()).map((s) => s.name));
|
|
750
|
+
if (suggestion) return `Skill "${skillName}" not found. Did you mean "${suggestion}"?`;
|
|
751
|
+
return `Skill "${skillName}" not found. Use get_available_skills to list available skills.`;
|
|
752
|
+
};
|
|
753
|
+
const GetAvailableSkills = (directory) => {
|
|
754
|
+
return tool({
|
|
755
|
+
description: "Get available skills with their descriptions. Optionally filter by free-text query and/or tag keywords.",
|
|
756
|
+
args: {
|
|
757
|
+
query: tool.schema.string().optional().describe("Free-text search query. Matched against skill name and description; relevance-ranked."),
|
|
758
|
+
keywords: tool.schema.array(tool.schema.string()).optional().describe("Optional list of tag keywords. Only skills whose metadata.tags include at least one entry are returned.")
|
|
759
|
+
},
|
|
760
|
+
async execute(args) {
|
|
761
|
+
const skillsByName = await discoverAllSkills(directory);
|
|
762
|
+
const allSkills = Array.from(skillsByName.values());
|
|
763
|
+
const matched = searchSkills(allSkills, args.query ?? "", args.keywords);
|
|
764
|
+
if (matched.length === 0) {
|
|
765
|
+
if (args.query) {
|
|
766
|
+
const allSkillNames = allSkills.map((s) => s.name);
|
|
767
|
+
const suggestion = findClosestMatch(args.query, allSkillNames);
|
|
768
|
+
if (suggestion) return `No skills found matching "${args.query}". Did you mean "${suggestion}"?`;
|
|
769
|
+
}
|
|
770
|
+
return "No skills found matching your query.";
|
|
771
|
+
}
|
|
772
|
+
return matched.map((s) => {
|
|
773
|
+
const scripts = s.scripts.length > 0 ? ` [scripts: ${s.scripts.map((sc) => sc.relativePath).join(", ")}]` : "";
|
|
774
|
+
const trigger = s.trigger && s.trigger.length > 0 ? `\n trigger: ${s.trigger}` : "";
|
|
775
|
+
return `${s.name} (${s.label})\n ${s.description}${trigger}${scripts}`;
|
|
776
|
+
}).join("\n\n");
|
|
777
|
+
}
|
|
778
|
+
});
|
|
779
|
+
};
|
|
780
|
+
const ReadSkillFile = (directory, host) => {
|
|
781
|
+
return tool({
|
|
782
|
+
description: "Read a supporting file from a skill's directory (docs, examples, configs).",
|
|
783
|
+
args: {
|
|
784
|
+
skill: tool.schema.string().describe("Name of the skill"),
|
|
785
|
+
filename: tool.schema.string().describe("File to read, relative to skill directory (e.g., 'anthropic-best-practices.md', 'scripts/helper.sh')")
|
|
786
|
+
},
|
|
787
|
+
async execute(args, ctx) {
|
|
788
|
+
const resolved = await resolveSkillOrSuggest(directory, args.skill);
|
|
789
|
+
if (resolved.startsWith("Skill \"")) return resolved;
|
|
790
|
+
const skill = (await discoverAllSkills(directory)).get(resolved);
|
|
791
|
+
if (!skill) return `Skill "${args.skill}" not found. Use get_available_skills to list available skills.`;
|
|
792
|
+
if (!await isPathSafe(skill.path, args.filename)) return `Invalid path: cannot access files outside skill directory.`;
|
|
793
|
+
const filePath = path.join(skill.path, args.filename);
|
|
794
|
+
try {
|
|
795
|
+
const content = await host.client.readFile(filePath);
|
|
796
|
+
const wrappedContent = `<skill-file skill="${escapeXml(skill.name)}" file="${escapeXml(args.filename)}">
|
|
797
|
+
<metadata>
|
|
798
|
+
<directory>${escapeXml(skill.path)}</directory>
|
|
799
|
+
</metadata>
|
|
800
|
+
|
|
801
|
+
<content>
|
|
802
|
+
${content}
|
|
803
|
+
</content>
|
|
804
|
+
</skill-file>`;
|
|
805
|
+
const context = await host.client.getSessionContext(ctx.sessionID);
|
|
806
|
+
await host.client.injectContent(ctx.sessionID, wrappedContent, context);
|
|
807
|
+
return `File "${args.filename}" from skill "${skill.name}" loaded.`;
|
|
808
|
+
} catch {
|
|
809
|
+
try {
|
|
810
|
+
const files = await host.client.readdir(skill.path);
|
|
811
|
+
return `File "${args.filename}" not found. Available files: ${files.join(", ")}`;
|
|
812
|
+
} catch {
|
|
813
|
+
return `File "${args.filename}" not found in skill "${skill.name}".`;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
};
|
|
819
|
+
const RunSkillScript = (directory, $) => {
|
|
820
|
+
return tool({
|
|
821
|
+
description: "Execute a script from a skill's directory. Scripts are run with the skill directory as CWD.",
|
|
822
|
+
args: {
|
|
823
|
+
skill: tool.schema.string().describe("Name of the skill"),
|
|
824
|
+
script: tool.schema.string().describe("Relative path to the script (e.g., 'build.sh', 'tools/deploy.sh')"),
|
|
825
|
+
arguments: tool.schema.array(tool.schema.string()).optional().describe("Arguments to pass to the script")
|
|
826
|
+
},
|
|
827
|
+
async execute(args) {
|
|
828
|
+
const resolved = await resolveSkillOrSuggest(directory, args.skill);
|
|
829
|
+
if (resolved.startsWith("Skill \"")) return resolved;
|
|
830
|
+
const skill = (await discoverAllSkills(directory)).get(resolved);
|
|
831
|
+
if (!skill) return `Skill "${args.skill}" not found. Use get_available_skills to list available skills.`;
|
|
832
|
+
const script = skill.scripts.find((s) => s.relativePath === args.script);
|
|
833
|
+
if (!script) {
|
|
834
|
+
const scriptPaths = skill.scripts.map((s) => s.relativePath);
|
|
835
|
+
const suggestion = findClosestMatch(args.script, scriptPaths);
|
|
836
|
+
if (suggestion) return `Script "${args.script}" not found in skill "${skill.name}". Did you mean "${suggestion}"?`;
|
|
837
|
+
const available = scriptPaths.join(", ") || "none";
|
|
838
|
+
return `Script "${args.script}" not found in skill "${skill.name}". Available scripts: ${available}`;
|
|
839
|
+
}
|
|
840
|
+
try {
|
|
841
|
+
$.cwd(skill.path);
|
|
842
|
+
const scriptArgs = (args.arguments || []).map(escapeShellArg).join(" ");
|
|
843
|
+
return await $`${script.absolutePath} ${scriptArgs}`.text();
|
|
844
|
+
} catch (error) {
|
|
845
|
+
if (error instanceof Error && "exitCode" in error) {
|
|
846
|
+
const shellError = error;
|
|
847
|
+
const stderr = shellError.stderr?.toString() || "";
|
|
848
|
+
const stdout = shellError.stdout?.toString() || "";
|
|
849
|
+
return `Script failed (exit ${shellError.exitCode}): ${stderr || stdout || shellError.message}`;
|
|
850
|
+
}
|
|
851
|
+
if (error instanceof Error) return `Script failed: ${error.message}`;
|
|
852
|
+
return `Script failed: ${String(error)}`;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
};
|
|
857
|
+
const UseSkill = (directory, host, onSkillLoaded) => {
|
|
858
|
+
return tool({
|
|
859
|
+
description: "Load a skill's SKILL.md content into context. Skills contain proven workflows, techniques, and patterns.",
|
|
860
|
+
args: { skill: tool.schema.string().describe("Name of the skill (e.g., 'brainstorming', 'project:my-skill', 'user:my-skill')") },
|
|
861
|
+
async execute(args, ctx) {
|
|
862
|
+
const resolved = await resolveSkillOrSuggest(directory, args.skill);
|
|
863
|
+
if (resolved.startsWith("Skill \"")) return resolved;
|
|
864
|
+
const skill = (await discoverAllSkills(directory)).get(resolved);
|
|
865
|
+
if (!skill) return `Skill "${args.skill}" not found. Use get_available_skills to list available skills.`;
|
|
866
|
+
const skillFiles = await listSkillFiles(skill.path);
|
|
867
|
+
const scriptsXml = skill.scripts.length > 0 ? `\n <scripts>\n${skill.scripts.map((s) => ` <script>${escapeXml(s.relativePath)}<\/script>`).join("\n")}\n <\/scripts>` : "";
|
|
868
|
+
const filesXml = skillFiles.length > 0 ? `\n <files>\n${skillFiles.map((f) => ` <file>${escapeXml(f)}</file>`).join("\n")}\n </files>` : "";
|
|
869
|
+
const skillContent = `<skill name="${escapeXml(skill.name)}">
|
|
870
|
+
<metadata>
|
|
871
|
+
<source>${escapeXml(skill.label)}</source>
|
|
872
|
+
<directory>${escapeXml(skill.path)}</directory>${scriptsXml}${filesXml}
|
|
873
|
+
</metadata>
|
|
874
|
+
|
|
875
|
+
${toolTranslation}
|
|
876
|
+
|
|
877
|
+
<content>
|
|
878
|
+
${skill.template}
|
|
879
|
+
</content>
|
|
880
|
+
</skill>`;
|
|
881
|
+
const context = await host.client.getSessionContext(ctx.sessionID);
|
|
882
|
+
await host.client.injectContent(ctx.sessionID, skillContent, context);
|
|
883
|
+
onSkillLoaded?.(ctx.sessionID, skill.name);
|
|
884
|
+
const scriptInfo = skill.scripts.length > 0 ? `\nAvailable scripts: ${skill.scripts.map((s) => s.relativePath).join(", ")}` : "";
|
|
885
|
+
const filesInfo = skillFiles.length > 0 ? `\nAvailable files: ${skillFiles.join(", ")}` : "";
|
|
886
|
+
return `Skill "${skill.name}" loaded.${scriptInfo}${filesInfo}`;
|
|
887
|
+
}
|
|
888
|
+
});
|
|
889
|
+
};
|
|
890
|
+
//#endregion
|
|
891
|
+
//#region src/sdk.ts
|
|
892
|
+
/** Type guard: narrows `unknown` to `ChatTextPart` when `part.type === "text"`. */
|
|
893
|
+
const isChatTextPart = (part) => {
|
|
894
|
+
return typeof part === "object" && part !== null && part.type === "text";
|
|
895
|
+
};
|
|
896
|
+
/** Type guard: narrows `unknown` to `SessionCompactedEvent`. */
|
|
897
|
+
const isSessionCompactedEvent = (event) => {
|
|
898
|
+
return typeof event === "object" && event !== null && event.type === "session.compacted";
|
|
899
|
+
};
|
|
900
|
+
/** Type guard: narrows `unknown` to `SessionDeletedEvent`. */
|
|
901
|
+
const isSessionDeletedEvent = (event) => {
|
|
902
|
+
return typeof event === "object" && event !== null && event.type === "session.deleted";
|
|
903
|
+
};
|
|
904
|
+
//#endregion
|
|
905
|
+
//#region src/plugin.ts
|
|
906
|
+
const injectSkillsList = async (directory, host, sessionID, context, precomputed) => {
|
|
907
|
+
const skillsByName = precomputed ?? await discoverAllSkills(directory);
|
|
908
|
+
const skills = Array.from(skillsByName.values());
|
|
909
|
+
if (skills.length === 0) return;
|
|
910
|
+
await host.client.injectContent(sessionID, renderAvailableSkillsBlock(skills), context);
|
|
911
|
+
};
|
|
912
|
+
const maybeInjectSuperpowersBootstrap = async (directory, host, sessionID, context, precomputed) => {
|
|
913
|
+
if (process.env.OPENCODE_AGENT_SKILLS_SUPERPOWERS_MODE !== "true") return;
|
|
914
|
+
const usingSuperpowersSkill = (precomputed ?? await discoverAllSkills(directory)).get("using-superpowers");
|
|
915
|
+
if (!usingSuperpowersSkill) return;
|
|
916
|
+
const ctx = context ?? await host.client.getSessionContext(sessionID);
|
|
917
|
+
const content = `<EXTREMELY_IMPORTANT>
|
|
918
|
+
You have superpowers.
|
|
919
|
+
|
|
920
|
+
**IMPORTANT: The using-superpowers skill content is included below. It is ALREADY LOADED - do not call use_skill for it again. Use use_skill only for OTHER skills.**
|
|
921
|
+
|
|
922
|
+
${usingSuperpowersSkill.template}
|
|
923
|
+
|
|
924
|
+
${toolMapping}
|
|
925
|
+
|
|
926
|
+
${skillsNamespace}
|
|
927
|
+
</EXTREMELY_IMPORTANT>`;
|
|
928
|
+
await host.client.injectContent(sessionID, content, ctx);
|
|
929
|
+
};
|
|
930
|
+
const toolMapping = `**Tool Mapping for OpenCode:**
|
|
931
|
+
- \`TodoWrite\` → \`todowrite\`
|
|
932
|
+
- \`Task\` tool with subagents → Use the \`task\` tool with \`subagent_type\`
|
|
933
|
+
- \`Skill\` tool → \`use_skill\`
|
|
934
|
+
- \`Read\`, \`Write\`, \`Edit\`, \`Bash\`, \`Glob\`, \`Grep\`, \`WebFetch\` → Use the native lowercase OpenCode tools`;
|
|
935
|
+
const skillsNamespace = `**Skill namespace priority:**
|
|
936
|
+
1. Project: \`project:skill-name\`
|
|
937
|
+
2. Claude project: \`claude-project:skill-name\`
|
|
938
|
+
3. User: \`skill-name\`
|
|
939
|
+
4. Claude user: \`claude-user:skill-name\`
|
|
940
|
+
5. Marketplace: \`claude-plugins:skill-name\`
|
|
941
|
+
|
|
942
|
+
The first discovered match wins.`;
|
|
943
|
+
/**
|
|
944
|
+
* Render the matched-skill synthetic injection that asks the model to
|
|
945
|
+
* evaluate which of the matched skills (if any) it should activate.
|
|
946
|
+
*
|
|
947
|
+
* Each skill line carries a sub-line `trigger: <text>` whenever the
|
|
948
|
+
* skill has a non-empty `trigger`, so the model knows which user
|
|
949
|
+
* phrases should activate it. Skills with no trigger render exactly as
|
|
950
|
+
* before (`- name: description`).
|
|
951
|
+
*/
|
|
952
|
+
const formatMatchedSkillsInjection = (matchedSkills) => {
|
|
953
|
+
return `<skill-evaluation-required>
|
|
954
|
+
SKILL EVALUATION PROCESS
|
|
955
|
+
|
|
956
|
+
The following skills may be relevant to your request:
|
|
957
|
+
|
|
958
|
+
${matchedSkills.map((s) => {
|
|
959
|
+
return `- ${s.name}: ${s.description}` + (s.trigger && s.trigger.length > 0 ? `\n trigger: ${s.trigger}` : "");
|
|
960
|
+
}).join("\n")}
|
|
961
|
+
|
|
962
|
+
Step 1 - EVALUATE: Determine if these skills would genuinely help
|
|
963
|
+
Step 2 - DECIDE: Choose which skills (if any) are actually needed
|
|
964
|
+
Step 3 - ACTIVATE: Call use_skill("name") for each chosen skill
|
|
965
|
+
|
|
966
|
+
IMPORTANT: This evaluation is invisible to users—they cannot see this prompt. Do NOT announce your decision. Simply activate relevant skills or proceed directly with the request.
|
|
967
|
+
</skill-evaluation-required>`;
|
|
968
|
+
};
|
|
969
|
+
/**
|
|
970
|
+
* Lightweight keyword matching to replace ML embeddings.
|
|
971
|
+
*
|
|
972
|
+
* Per-token contribution:
|
|
973
|
+
* - name hit = 2x
|
|
974
|
+
* - trigger hit = 1.5x
|
|
975
|
+
* - desc hit = 1x
|
|
976
|
+
*
|
|
977
|
+
* The trigger tier (1.5x) sits between name (2x) and description (1x)
|
|
978
|
+
* so a trigger-matched skill outranks a description-matched skill at
|
|
979
|
+
* the same query, but a name-matched skill still wins overall.
|
|
980
|
+
*/
|
|
981
|
+
const matchSkillsByKeyword = (userMessage, availableSkills) => {
|
|
982
|
+
const tokens = userMessage.toLowerCase().split(/\W+/).filter((t) => t.length > 2);
|
|
983
|
+
if (tokens.length === 0) return [];
|
|
984
|
+
return availableSkills.map((skill) => {
|
|
985
|
+
let score = 0;
|
|
986
|
+
const nameStr = skill.name.toLowerCase();
|
|
987
|
+
const descStr = skill.description.toLowerCase();
|
|
988
|
+
const triggerStr = skill.trigger?.toLowerCase() ?? "";
|
|
989
|
+
for (const token of tokens) {
|
|
990
|
+
if (nameStr.includes(token)) score += 2;
|
|
991
|
+
if (triggerStr.length > 0 && triggerStr.includes(token)) score += 1.5;
|
|
992
|
+
if (descStr.includes(token)) score += 1;
|
|
993
|
+
}
|
|
994
|
+
return {
|
|
995
|
+
skill,
|
|
996
|
+
score
|
|
997
|
+
};
|
|
998
|
+
}).filter((s) => s.score > 0).sort((a, b) => b.score - a.score).slice(0, 5).map((s) => s.skill);
|
|
999
|
+
};
|
|
1000
|
+
const SkillsPlugin = async ({ client, $, directory }) => {
|
|
1001
|
+
const host = createOpencodeSkillHost(client);
|
|
1002
|
+
const setupCompleteSessions = /* @__PURE__ */ new Set();
|
|
1003
|
+
const loadedSkillsPerSession = /* @__PURE__ */ new Map();
|
|
1004
|
+
const getLoadedSkills = (sessionID) => {
|
|
1005
|
+
let set = loadedSkillsPerSession.get(sessionID);
|
|
1006
|
+
if (!set) {
|
|
1007
|
+
set = /* @__PURE__ */ new Set();
|
|
1008
|
+
loadedSkillsPerSession.set(sessionID, set);
|
|
1009
|
+
}
|
|
1010
|
+
return set;
|
|
1011
|
+
};
|
|
1012
|
+
/**
|
|
1013
|
+
* Returns true when this chat.message is the first one for the session
|
|
1014
|
+
* AND no prior message in this session already injected the available-
|
|
1015
|
+
* skills block (which would mean the session was bootstrapped before
|
|
1016
|
+
* this plugin instance attached).
|
|
1017
|
+
*/
|
|
1018
|
+
const isFirstMessageSetup = async (sessionID) => {
|
|
1019
|
+
if (setupCompleteSessions.has(sessionID)) return false;
|
|
1020
|
+
try {
|
|
1021
|
+
const existing = await client.session.messages({ path: { id: sessionID } });
|
|
1022
|
+
if (existing.data) {
|
|
1023
|
+
if (existing.data.some((msg) => {
|
|
1024
|
+
const m = msg;
|
|
1025
|
+
const parts = Array.isArray(m.parts) ? m.parts : Array.isArray(m.info?.parts) ? m.info.parts : null;
|
|
1026
|
+
if (!parts) return false;
|
|
1027
|
+
return parts.some((part) => {
|
|
1028
|
+
if (!isChatTextPart(part)) return false;
|
|
1029
|
+
return typeof part.text === "string" && part.text.includes("<available-skills>");
|
|
1030
|
+
});
|
|
1031
|
+
})) setupCompleteSessions.add(sessionID);
|
|
1032
|
+
}
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
debugLog("isFirstMessageSetup: failed to read existing messages", error);
|
|
1035
|
+
}
|
|
1036
|
+
return !setupCompleteSessions.has(sessionID);
|
|
1037
|
+
};
|
|
1038
|
+
/** Mark the session as bootstrapped and inject the available-skills block. */
|
|
1039
|
+
const injectBootstrapSkills = async (sessionID, skillsByName, context) => {
|
|
1040
|
+
setupCompleteSessions.add(sessionID);
|
|
1041
|
+
await maybeInjectSuperpowersBootstrap(directory, host, sessionID, context, skillsByName);
|
|
1042
|
+
await injectSkillsList(directory, host, sessionID, context, skillsByName);
|
|
1043
|
+
};
|
|
1044
|
+
/** Run keyword matching on the user message and inject the matched-skill prompt. */
|
|
1045
|
+
const handleKeywordMatch = async (userText, sessionID, summaries, context) => {
|
|
1046
|
+
if (!userText) return;
|
|
1047
|
+
if (summaries.length === 0) return;
|
|
1048
|
+
const matchedSkills = matchSkillsByKeyword(userText, summaries);
|
|
1049
|
+
const loadedSkills = getLoadedSkills(sessionID);
|
|
1050
|
+
const newSkills = matchedSkills.filter((s) => !loadedSkills.has(s.name));
|
|
1051
|
+
if (newSkills.length === 0) return;
|
|
1052
|
+
const injectionText = formatMatchedSkillsInjection(newSkills);
|
|
1053
|
+
await host.client.injectContent(sessionID, injectionText, context);
|
|
1054
|
+
};
|
|
1055
|
+
const tools = createSkillTools(host, $, directory, (sessionID, skillName) => {
|
|
1056
|
+
getLoadedSkills(sessionID).add(skillName);
|
|
1057
|
+
});
|
|
1058
|
+
return {
|
|
1059
|
+
"chat.message": async (input, output) => {
|
|
1060
|
+
const rawOutput = output;
|
|
1061
|
+
if (!rawOutput || typeof rawOutput !== "object") {
|
|
1062
|
+
debugLog("chat.message: missing or non-object output", output);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
const safeOutput = rawOutput;
|
|
1066
|
+
if (typeof safeOutput.message?.sessionID !== "string") {
|
|
1067
|
+
debugLog("chat.message: missing sessionID on output", safeOutput);
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
const sessionID = safeOutput.message.sessionID;
|
|
1071
|
+
const skillsByName = await discoverAllSkills(directory);
|
|
1072
|
+
const summaries = Array.from(skillsByName.values()).map((skill) => ({
|
|
1073
|
+
name: skill.name,
|
|
1074
|
+
description: skill.description,
|
|
1075
|
+
trigger: skill.trigger
|
|
1076
|
+
}));
|
|
1077
|
+
const context = {
|
|
1078
|
+
model: safeOutput.message.model,
|
|
1079
|
+
agent: safeOutput.message.agent
|
|
1080
|
+
};
|
|
1081
|
+
if (await isFirstMessageSetup(sessionID)) {
|
|
1082
|
+
await injectBootstrapSkills(sessionID, skillsByName, context);
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
await handleKeywordMatch((Array.isArray(safeOutput.parts) ? safeOutput.parts : []).flatMap((part) => {
|
|
1086
|
+
if (!isChatTextPart(part)) return [];
|
|
1087
|
+
if (part.synthetic === true) return [];
|
|
1088
|
+
return typeof part.text === "string" ? [part.text] : [];
|
|
1089
|
+
}).join("\n").trim(), sessionID, summaries, context);
|
|
1090
|
+
},
|
|
1091
|
+
event: async ({ event }) => {
|
|
1092
|
+
if (isSessionCompactedEvent(event)) {
|
|
1093
|
+
const sessionID = event.properties.sessionID;
|
|
1094
|
+
if (typeof sessionID !== "string") {
|
|
1095
|
+
debugLog("event: session.compacted missing sessionID", event);
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
const context = await host.client.getSessionContext(sessionID);
|
|
1099
|
+
await maybeInjectSuperpowersBootstrap(directory, host, sessionID, context);
|
|
1100
|
+
await injectSkillsList(directory, host, sessionID, context);
|
|
1101
|
+
loadedSkillsPerSession.delete(sessionID);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
if (isSessionDeletedEvent(event)) {
|
|
1105
|
+
const sessionID = event.properties.info?.id;
|
|
1106
|
+
if (typeof sessionID !== "string") {
|
|
1107
|
+
debugLog("event: session.deleted missing info.id", event);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
setupCompleteSessions.delete(sessionID);
|
|
1111
|
+
loadedSkillsPerSession.delete(sessionID);
|
|
1112
|
+
}
|
|
1113
|
+
},
|
|
1114
|
+
tool: {
|
|
1115
|
+
get_available_skills: tools.GetAvailableSkills,
|
|
1116
|
+
read_skill_file: tools.ReadSkillFile,
|
|
1117
|
+
run_skill_script: tools.RunSkillScript,
|
|
1118
|
+
use_skill: tools.UseSkill
|
|
1119
|
+
}
|
|
1120
|
+
};
|
|
1121
|
+
};
|
|
1122
|
+
//#endregion
|
|
1123
|
+
//#region src/index.ts
|
|
1124
|
+
/**
|
|
1125
|
+
* OpenCode host adapter — root entrypoint.
|
|
1126
|
+
*
|
|
1127
|
+
* Re-exports the plugin factory as the package's default export so the
|
|
1128
|
+
* `rolldown` build can target this file directly. The root `src/plugin.ts`
|
|
1129
|
+
* shim forwards to this module to preserve the legacy import path while
|
|
1130
|
+
* `package.json` still resolves `dist/plugin.mjs` to the package main.
|
|
1131
|
+
*
|
|
1132
|
+
* Public surface:
|
|
1133
|
+
* - default export: SkillsPlugin (the @opencode-ai/plugin Plugin factory)
|
|
1134
|
+
* - named exports: SkillsPlugin, createOpencodeSkillHost
|
|
1135
|
+
*/
|
|
1136
|
+
var src_default = SkillsPlugin;
|
|
1137
|
+
//#endregion
|
|
1138
|
+
export { SkillsPlugin, createOpencodeSkillHost, src_default as default };
|