opencode-agent-skills-md 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/.beads/.local_version +1 -0
  2. package/.beads/README.md +81 -0
  3. package/.beads/config.yaml +61 -0
  4. package/.beads/deletions.jsonl +1 -0
  5. package/.beads/issues.jsonl +64 -0
  6. package/.beads/metadata.json +4 -0
  7. package/.gitattributes +3 -0
  8. package/.github/CODEOWNERS +1 -0
  9. package/.github/copilot-instructions.md +78 -0
  10. package/.github/dependabot.yml +13 -0
  11. package/.github/workflows/release.yml +51 -0
  12. package/.opencode/command/test-compaction.md +9 -0
  13. package/.opencode/command/test-find-skills.md +7 -0
  14. package/.opencode/command/test-read-skill-file.md +14 -0
  15. package/.opencode/command/test-run-skill-script.md +13 -0
  16. package/.opencode/command/test-skills.md +14 -0
  17. package/.opencode/command/test-use-skill.md +10 -0
  18. package/.opencode/skills/git-helper/SKILL.md +65 -0
  19. package/.opencode/skills/test-skill/SKILL.md +43 -0
  20. package/.opencode/skills/test-skill/example-config.json +16 -0
  21. package/.opencode/skills/test-skill/helper-docs.md +29 -0
  22. package/.opencode/skills/test-skill/scripts/echo-args +14 -0
  23. package/.opencode/skills/test-skill/scripts/greet +6 -0
  24. package/AGENTS.md +43 -0
  25. package/CHANGELOG.md +178 -0
  26. package/Justfile +39 -0
  27. package/LICENSE +9 -0
  28. package/README.md +189 -0
  29. package/openspec/changes/archive/2026-06-14-skills-core-decouple/specs/core-decoupling/spec.md +74 -0
  30. package/openspec/changes/archive/2026-06-14-skills-core-decouple/tasks.md +64 -0
  31. package/openspec/changes/archive/2026-06-14-skills-core-decouple/verify-report.md +75 -0
  32. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/apply-progress.md +136 -0
  33. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/archive-report.md +77 -0
  34. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/design.md +89 -0
  35. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/proposal.md +65 -0
  36. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/specs/core-decoupling/spec.md +77 -0
  37. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/tasks.md +65 -0
  38. package/openspec/changes/archive/2026-06-17-fix-skill-loading-regression/verify-report.md +165 -0
  39. package/openspec/specs/core-decoupling/spec.md +110 -0
  40. package/package.json +35 -0
  41. package/packages/core/package.json +30 -0
  42. package/packages/core/src/content.d.ts +16 -0
  43. package/packages/core/src/content.ts +30 -0
  44. package/packages/core/src/debug.ts +16 -0
  45. package/packages/core/src/discovery.d.ts +86 -0
  46. package/packages/core/src/discovery.ts +257 -0
  47. package/packages/core/src/index.d.ts +20 -0
  48. package/packages/core/src/index.ts +55 -0
  49. package/packages/core/src/match.d.ts +19 -0
  50. package/packages/core/src/match.ts +75 -0
  51. package/packages/core/src/parse.d.ts +26 -0
  52. package/packages/core/src/parse.ts +141 -0
  53. package/packages/core/src/scripts.d.ts +17 -0
  54. package/packages/core/src/scripts.ts +79 -0
  55. package/packages/core/src/search.d.ts +83 -0
  56. package/packages/core/src/search.ts +188 -0
  57. package/packages/core/src/types.d.ts +82 -0
  58. package/packages/core/src/types.ts +131 -0
  59. package/packages/core/src/walk.ts +109 -0
  60. package/packages/core/tests/agnostic.test.ts +346 -0
  61. package/packages/core/tests/content.test.ts +65 -0
  62. package/packages/core/tests/discovery.test.ts +370 -0
  63. package/packages/core/tests/package-boundary.test.ts +310 -0
  64. package/packages/core/tests/parse-trigger.test.ts +282 -0
  65. package/packages/core/tests/search.test.ts +374 -0
  66. package/packages/core/tests/subpath.test.ts +87 -0
  67. package/packages/core/tsconfig.json +10 -0
  68. package/packages/opencode-agent-skills-md/package.json +42 -0
  69. package/packages/opencode-agent-skills-md/rolldown.config.js +48 -0
  70. package/packages/opencode-agent-skills-md/src/cli/config.ts +522 -0
  71. package/packages/opencode-agent-skills-md/src/cli/install.ts +111 -0
  72. package/packages/opencode-agent-skills-md/src/cli/main.ts +201 -0
  73. package/packages/opencode-agent-skills-md/src/cli/real-fs.ts +51 -0
  74. package/packages/opencode-agent-skills-md/src/cli/status.ts +183 -0
  75. package/packages/opencode-agent-skills-md/src/cli/uninstall.ts +157 -0
  76. package/packages/opencode-agent-skills-md/src/host.ts +119 -0
  77. package/packages/opencode-agent-skills-md/src/index.ts +25 -0
  78. package/packages/opencode-agent-skills-md/src/plugin.ts +343 -0
  79. package/packages/opencode-agent-skills-md/src/sdk.ts +71 -0
  80. package/packages/opencode-agent-skills-md/src/tools.ts +373 -0
  81. package/packages/opencode-agent-skills-md/tests/cli-commands.test.ts +1423 -0
  82. package/packages/opencode-agent-skills-md/tests/e2e/startup-smoke.test.ts +66 -0
  83. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.claude/skills/claude-user-only-skill/SKILL.md +8 -0
  84. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/shared-skill/SKILL.md +8 -0
  85. package/packages/opencode-agent-skills-md/tests/fixtures/skills/home/.config/opencode/skills/user-only-skill/SKILL.md +8 -0
  86. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.claude/skills/claude-project-only-skill/SKILL.md +8 -0
  87. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/go-tester/SKILL.md +12 -0
  88. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/nested/team/nested-skill/SKILL.md +8 -0
  89. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/rust-tester/SKILL.md +11 -0
  90. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/SKILL.md +8 -0
  91. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/bin/echo.sh +2 -0
  92. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/scripted-skill/docs/reference.md +1 -0
  93. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/shared-skill/SKILL.md +8 -0
  94. package/packages/opencode-agent-skills-md/tests/fixtures/skills/project/.opencode/skills/using-superpowers/SKILL.md +8 -0
  95. package/packages/opencode-agent-skills-md/tests/integration/helpers/mock-opencode.ts +114 -0
  96. package/packages/opencode-agent-skills-md/tests/integration/plugin.test.ts +316 -0
  97. package/packages/opencode-agent-skills-md/tests/integration/skill-discovery.test.ts +315 -0
  98. package/packages/opencode-agent-skills-md/tests/opencode/host.test.ts +179 -0
  99. package/packages/opencode-agent-skills-md/tests/opencode/plugin.test.ts +551 -0
  100. package/packages/opencode-agent-skills-md/tests/opencode/subpath.test.ts +66 -0
  101. package/packages/opencode-agent-skills-md/tests/opencode/tools.test.ts +213 -0
  102. package/packages/opencode-agent-skills-md/tests/package-boundary.test.ts +346 -0
  103. package/packages/opencode-agent-skills-md/tests/tools-security.test.ts +72 -0
  104. package/packages/opencode-agent-skills-md/tsconfig.build.json +11 -0
  105. package/packages/opencode-agent-skills-md/tsconfig.json +10 -0
  106. package/plans/001-ci-gate.md +177 -0
  107. package/plans/002-is-path-safe.md +243 -0
  108. package/plans/003-escape-prompts.md +310 -0
  109. package/plans/004-test-security-paths.md +228 -0
  110. package/plans/005-stop-swallowing-errors.md +246 -0
  111. package/plans/006-preserve-jsonc-commas.md +144 -0
  112. package/plans/007-write-before-purge.md +144 -0
  113. package/plans/008-reuse-walkdir-for-list-skill-files.md +164 -0
  114. package/plans/README.md +43 -0
  115. package/pnpm-workspace.yaml +6 -0
  116. package/tests/workspace.test.ts +367 -0
  117. package/tsconfig.json +15 -0
@@ -0,0 +1,373 @@
1
+ /**
2
+ * OpenCode tool factories.
3
+ *
4
+ * The four skill tools (get_available_skills, read_skill_file, run_skill_script,
5
+ * use_skill) compose the portable core engine with the OpenCode host. Tools
6
+ * consume the host's bounded client surface; they never reference the
7
+ * OpenCode SDK client or the `node:fs` module directly.
8
+ *
9
+ * `createSkillTools(host, $, directory)` returns the four tool factories
10
+ * pre-bound to the host, the shell runner, and the project directory. The
11
+ * plugin instantiates them at registration time.
12
+ */
13
+
14
+ import type { PluginInput } from "@opencode-ai/plugin";
15
+ import { tool } from "@opencode-ai/plugin";
16
+ import * as path from "node:path";
17
+ import {
18
+ discoverAllSkills,
19
+ findClosestMatch,
20
+ isPathSafe,
21
+ listSkillFiles,
22
+ resolveSkill,
23
+ searchSkills,
24
+ } from "opencode-agent-skills-md-core";
25
+ import type { OpencodeSkillHost } from "./host";
26
+
27
+ /** Escape XML special characters to prevent wrapper breakout. */
28
+ const escapeXml = (s: string): string => {
29
+ return s
30
+ .replace(/&/g, "&")
31
+ .replace(/</g, "&lt;")
32
+ .replace(/>/g, "&gt;")
33
+ .replace(/"/g, "&quot;")
34
+ .replace(/'/g, "&apos;");
35
+ };
36
+
37
+ /** Wrap a shell argument in single quotes and escape embedded single quotes (Bourne-shell pattern). */
38
+ const escapeShellArg = (arg: string): string => {
39
+ const escaped = arg.replace(/'/g, "'\\''");
40
+ return "'" + escaped + "'";
41
+ };
42
+
43
+ /** @internal - exported for testing */
44
+ export const _escapeXml = escapeXml;
45
+ /** @internal - exported for testing */
46
+ export const _escapeShellArg = escapeShellArg;
47
+
48
+ /**
49
+ * Portable return type for tool factory consts.
50
+ *
51
+ * The `tool()` helper captures the Zod shape of its `args` parameter in the
52
+ * generic parameter, so the inferred return type of each factory leaks Zod
53
+ * types. When TypeScript emits `.d.ts` files for the package, that generic
54
+ * instantiation cannot be named portably across zod versions. Annotating
55
+ * the return type as `ReturnType<typeof tool>` erases the per-call Zod
56
+ * shape and leaves a stable, portable declaration for downstream consumers.
57
+ */
58
+ type SkillTool = ReturnType<typeof tool>;
59
+
60
+ /**
61
+ * Tool translation guide for skills written for Claude Code.
62
+ * Injected into skill content to help the AI use OpenCode equivalents.
63
+ */
64
+ export const toolTranslation = `<tool-translation>
65
+ This skill may reference Claude Code tools. Use OpenCode equivalents:
66
+ - TodoWrite/TodoRead -> todowrite/todoread
67
+ - Task (subagents) -> task tool with subagent_type parameter
68
+ - Skill tool -> use_skill tool
69
+ - Read/Write/Edit/Bash/Glob/Grep/WebFetch -> lowercase (read/write/edit/bash/glob/grep/webfetch)
70
+ </tool-translation>`;
71
+
72
+ export interface SkillTools {
73
+ GetAvailableSkills: ReturnType<typeof GetAvailableSkills>;
74
+ ReadSkillFile: ReturnType<typeof ReadSkillFile>;
75
+ RunSkillScript: ReturnType<typeof RunSkillScript>;
76
+ UseSkill: ReturnType<typeof UseSkill>;
77
+ }
78
+
79
+ /**
80
+ * Callback fired by `UseSkill` after a successful load so the host can
81
+ * update its session-level bookkeeping (loaded-skill set, TUI icon, etc.).
82
+ * The core never assumes a callback is registered; missing it must not
83
+ * break the load.
84
+ */
85
+ export type OnSkillLoaded = (sessionID: string, skillName: string) => void;
86
+
87
+ /**
88
+ * Build the four skill tool factories bound to the host, shell, and
89
+ * project directory. The returned object is what the plugin registers
90
+ * under its `tool` hook.
91
+ *
92
+ * The optional `onSkillLoaded` callback is threaded through to `UseSkill`
93
+ * so a successful load can update host session state (e.g., the loaded-
94
+ * skill set used to suppress duplicate match injection in `chat.message`).
95
+ */
96
+ export const createSkillTools = (
97
+ host: OpencodeSkillHost,
98
+ $: PluginInput["$"],
99
+ directory: string,
100
+ onSkillLoaded?: OnSkillLoaded
101
+ ): SkillTools => {
102
+ return {
103
+ GetAvailableSkills: GetAvailableSkills(directory),
104
+ ReadSkillFile: ReadSkillFile(directory, host),
105
+ RunSkillScript: RunSkillScript(directory, $),
106
+ UseSkill: UseSkill(directory, host, onSkillLoaded),
107
+ };
108
+ };
109
+
110
+ /**
111
+ * Resolve a skill by name, or return a "not found" message with a
112
+ * close-match suggestion.
113
+ *
114
+ * Centralizes the duplicated resolve-then-suggest pattern that
115
+ * `use_skill`, `read_skill_file`, and `run_skill_script` all need.
116
+ * Returning a single `string` keeps the call site trivial:
117
+ *
118
+ * - skill found → returns `skill.name`
119
+ * - skill missing, suggestion → `Skill "<name>" not found. Did you mean "<suggestion>"?`
120
+ * - skill missing, no hint → `Skill "<name>" not found. Use get_available_skills to list available skills.`
121
+ *
122
+ * Not-found messages always start with the literal `Skill "` so callers
123
+ * can detect them with `result.startsWith('Skill "')`. The skill-name
124
+ * regex (`/^[\p{Ll}\p{N}-]+$/u`) forbids uppercase initial characters,
125
+ * so a legitimate skill name can never collide with that prefix.
126
+ *
127
+ * The helper does its own discovery; callers that need the `Skill` object
128
+ * (rather than just its name) re-resolve via a second `discoverAllSkills`
129
+ * call. Discovery is cheap (file-listing only) and the OS-level metadata
130
+ * cache absorbs most of the cost.
131
+ */
132
+ export const resolveSkillOrSuggest = async (
133
+ directory: string,
134
+ skillName: string
135
+ ): Promise<string> => {
136
+ const skillsByName = await discoverAllSkills(directory);
137
+ const skill = resolveSkill(skillName, skillsByName);
138
+ if (skill) return skill.name;
139
+
140
+ const allSkillNames = Array.from(skillsByName.values()).map(s => s.name);
141
+ const suggestion = findClosestMatch(skillName, allSkillNames);
142
+ if (suggestion) {
143
+ return `Skill "${skillName}" not found. Did you mean "${suggestion}"?`;
144
+ }
145
+ return `Skill "${skillName}" not found. Use get_available_skills to list available skills.`;
146
+ };
147
+
148
+ const GetAvailableSkills = (directory: string): SkillTool => {
149
+ return tool({
150
+ description:
151
+ "Get available skills with their descriptions. Optionally filter by free-text query and/or tag keywords.",
152
+ args: {
153
+ query: tool.schema.string().optional()
154
+ .describe("Free-text search query. Matched against skill name and description; relevance-ranked."),
155
+ keywords: tool.schema.array(tool.schema.string()).optional()
156
+ .describe("Optional list of tag keywords. Only skills whose metadata.tags include at least one entry are returned.")
157
+ },
158
+ async execute(args) {
159
+ const skillsByName = await discoverAllSkills(directory);
160
+ const allSkills = Array.from(skillsByName.values());
161
+
162
+ const matched = searchSkills(allSkills, args.query ?? "", args.keywords);
163
+
164
+ if (matched.length === 0) {
165
+ if (args.query) {
166
+ const allSkillNames = allSkills.map(s => s.name);
167
+ const suggestion = findClosestMatch(args.query, allSkillNames);
168
+
169
+ if (suggestion) {
170
+ return `No skills found matching "${args.query}". Did you mean "${suggestion}"?`;
171
+ }
172
+ }
173
+
174
+ return "No skills found matching your query.";
175
+ }
176
+
177
+ return matched
178
+ .map(s => {
179
+ const scripts = s.scripts.length > 0
180
+ ? ` [scripts: ${s.scripts.map(sc => sc.relativePath).join(', ')}]`
181
+ : '';
182
+ // PR 2: render `trigger: <text>` on its own line when set.
183
+ // The always-on `<available-skills>` block stays compact; only
184
+ // this targeted listing surfaces the trigger.
185
+ const trigger = s.trigger && s.trigger.length > 0
186
+ ? `\n trigger: ${s.trigger}`
187
+ : '';
188
+ return `${s.name} (${s.label})\n ${s.description}${trigger}${scripts}`;
189
+ })
190
+ .join('\n\n');
191
+ }
192
+ });
193
+ };
194
+
195
+ const ReadSkillFile = (directory: string, host: OpencodeSkillHost): SkillTool => {
196
+ return tool({
197
+ description: "Read a supporting file from a skill's directory (docs, examples, configs).",
198
+ args: {
199
+ skill: tool.schema.string()
200
+ .describe("Name of the skill"),
201
+ filename: tool.schema.string()
202
+ .describe("File to read, relative to skill directory (e.g., 'anthropic-best-practices.md', 'scripts/helper.sh')")
203
+ },
204
+ async execute(args, ctx) {
205
+ const resolved = await resolveSkillOrSuggest(directory, args.skill);
206
+ if (resolved.startsWith('Skill "')) return resolved;
207
+
208
+ // Helper confirmed existence; resolve to the full Skill object so we
209
+ // can read its path, scripts, and other metadata below.
210
+ const skillsByName = await discoverAllSkills(directory);
211
+ const skill = skillsByName.get(resolved);
212
+ if (!skill) {
213
+ return `Skill "${args.skill}" not found. Use get_available_skills to list available skills.`;
214
+ }
215
+
216
+ // Security: ensure path doesn't escape skill directory
217
+ if (!(await isPathSafe(skill.path, args.filename))) {
218
+ return `Invalid path: cannot access files outside skill directory.`;
219
+ }
220
+
221
+ const filePath = path.join(skill.path, args.filename);
222
+
223
+ try {
224
+ const content = await host.client.readFile(filePath);
225
+
226
+ // Inject via noReply for context persistence
227
+ const wrappedContent = `<skill-file skill="${escapeXml(skill.name)}" file="${escapeXml(args.filename)}">
228
+ <metadata>
229
+ <directory>${escapeXml(skill.path)}</directory>
230
+ </metadata>
231
+
232
+ <content>
233
+ ${content}
234
+ </content>
235
+ </skill-file>`;
236
+
237
+ const context = await host.client.getSessionContext(ctx.sessionID);
238
+ await host.client.injectContent(ctx.sessionID, wrappedContent, context);
239
+
240
+ return `File "${args.filename}" from skill "${skill.name}" loaded.`;
241
+ } catch {
242
+ try {
243
+ const files = await host.client.readdir(skill.path);
244
+ return `File "${args.filename}" not found. Available files: ${files.join(', ')}`;
245
+ } catch {
246
+ return `File "${args.filename}" not found in skill "${skill.name}".`;
247
+ }
248
+ }
249
+ }
250
+ });
251
+ };
252
+
253
+ const RunSkillScript = (directory: string, $: PluginInput["$"]): SkillTool => {
254
+ return tool({
255
+ description: "Execute a script from a skill's directory. Scripts are run with the skill directory as CWD.",
256
+ args: {
257
+ skill: tool.schema.string()
258
+ .describe("Name of the skill"),
259
+ script: tool.schema.string()
260
+ .describe("Relative path to the script (e.g., 'build.sh', 'tools/deploy.sh')"),
261
+ arguments: tool.schema.array(tool.schema.string()).optional()
262
+ .describe("Arguments to pass to the script")
263
+ },
264
+ async execute(args) {
265
+ const resolved = await resolveSkillOrSuggest(directory, args.skill);
266
+ if (resolved.startsWith('Skill "')) return resolved;
267
+
268
+ // Helper confirmed existence; resolve to the full Skill object so we
269
+ // can inspect its scripts and run them below.
270
+ const skillsByName = await discoverAllSkills(directory);
271
+ const skill = skillsByName.get(resolved);
272
+ if (!skill) {
273
+ return `Skill "${args.skill}" not found. Use get_available_skills to list available skills.`;
274
+ }
275
+
276
+ const script = skill.scripts.find(s => s.relativePath === args.script);
277
+
278
+ if (!script) {
279
+ const scriptPaths = skill.scripts.map(s => s.relativePath);
280
+ const suggestion = findClosestMatch(args.script, scriptPaths);
281
+
282
+ if (suggestion) {
283
+ return `Script "${args.script}" not found in skill "${skill.name}". Did you mean "${suggestion}"?`;
284
+ }
285
+
286
+ const available = scriptPaths.join(', ') || 'none';
287
+ return `Script "${args.script}" not found in skill "${skill.name}". Available scripts: ${available}`;
288
+ }
289
+
290
+ try {
291
+ $.cwd(skill.path);
292
+ const scriptArgs = (args.arguments || []).map(escapeShellArg).join(' ');
293
+ const result = await $`${script.absolutePath} ${scriptArgs}`.text();
294
+ return result;
295
+ } catch (error: unknown) {
296
+ if (error instanceof Error && 'exitCode' in error) {
297
+ const shellError = error as Error & { exitCode: number; stderr?: Buffer; stdout?: Buffer };
298
+ const stderr = shellError.stderr?.toString() || '';
299
+ const stdout = shellError.stdout?.toString() || '';
300
+ return `Script failed (exit ${shellError.exitCode}): ${stderr || stdout || shellError.message}`;
301
+ }
302
+ if (error instanceof Error) {
303
+ return `Script failed: ${error.message}`;
304
+ }
305
+ return `Script failed: ${String(error)}`;
306
+ }
307
+ }
308
+ });
309
+ };
310
+
311
+ const UseSkill = (
312
+ directory: string,
313
+ host: OpencodeSkillHost,
314
+ onSkillLoaded?: (sessionID: string, skillName: string) => void
315
+ ): SkillTool => {
316
+ return tool({
317
+ description: "Load a skill's SKILL.md content into context. Skills contain proven workflows, techniques, and patterns.",
318
+ args: {
319
+ skill: tool.schema.string()
320
+ .describe("Name of the skill (e.g., 'brainstorming', 'project:my-skill', 'user:my-skill')")
321
+ },
322
+ async execute(args, ctx) {
323
+ const resolved = await resolveSkillOrSuggest(directory, args.skill);
324
+ if (resolved.startsWith('Skill "')) return resolved;
325
+
326
+ // Helper confirmed existence; resolve to the full Skill object so we
327
+ // can read its template, scripts, and files for injection below.
328
+ const skillsByName = await discoverAllSkills(directory);
329
+ const skill = skillsByName.get(resolved);
330
+ if (!skill) {
331
+ return `Skill "${args.skill}" not found. Use get_available_skills to list available skills.`;
332
+ }
333
+
334
+ const skillFiles = await listSkillFiles(skill.path);
335
+
336
+ const scriptsXml = skill.scripts.length > 0
337
+ ? `\n <scripts>\n${skill.scripts.map(s => ` <script>${escapeXml(s.relativePath)}</script>`).join('\n')}\n </scripts>`
338
+ : '';
339
+
340
+ const filesXml = skillFiles.length > 0
341
+ ? `\n <files>\n${skillFiles.map(f => ` <file>${escapeXml(f)}</file>`).join('\n')}\n </files>`
342
+ : '';
343
+
344
+ const skillContent = `<skill name="${escapeXml(skill.name)}">
345
+ <metadata>
346
+ <source>${escapeXml(skill.label)}</source>
347
+ <directory>${escapeXml(skill.path)}</directory>${scriptsXml}${filesXml}
348
+ </metadata>
349
+
350
+ ${toolTranslation}
351
+
352
+ <content>
353
+ ${skill.template}
354
+ </content>
355
+ </skill>`;
356
+
357
+ const context = await host.client.getSessionContext(ctx.sessionID);
358
+ await host.client.injectContent(ctx.sessionID, skillContent, context);
359
+
360
+ onSkillLoaded?.(ctx.sessionID, skill.name);
361
+
362
+ const scriptInfo = skill.scripts.length > 0
363
+ ? `\nAvailable scripts: ${skill.scripts.map(s => s.relativePath).join(', ')}`
364
+ : '';
365
+
366
+ const filesInfo = skillFiles.length > 0
367
+ ? `\nAvailable files: ${skillFiles.join(', ')}`
368
+ : '';
369
+
370
+ return `Skill "${skill.name}" loaded.${scriptInfo}${filesInfo}`;
371
+ }
372
+ });
373
+ };