opencode-swarm-plugin 0.12.20 → 0.12.23

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/src/skills.ts ADDED
@@ -0,0 +1,1364 @@
1
+ /**
2
+ * Skills Module for OpenCode
3
+ *
4
+ * Implements Anthropic's Agent Skills specification for OpenCode.
5
+ * Skills are markdown files with YAML frontmatter that provide
6
+ * domain-specific instructions the model can activate when relevant.
7
+ *
8
+ * Discovery locations (in priority order):
9
+ * 1. {projectDir}/.opencode/skills/
10
+ * 2. {projectDir}/.claude/skills/ (compatibility)
11
+ * 3. {projectDir}/skills/ (simple projects)
12
+ *
13
+ * Skill format:
14
+ * ```markdown
15
+ * ---
16
+ * name: my-skill
17
+ * description: What it does. Use when X.
18
+ * ---
19
+ *
20
+ * # Skill Instructions
21
+ * ...
22
+ * ```
23
+ *
24
+ * @module skills
25
+ */
26
+
27
+ import { tool } from "@opencode-ai/plugin";
28
+ import { readdir, readFile, stat, mkdir, writeFile, rm } from "fs/promises";
29
+ import {
30
+ join,
31
+ basename,
32
+ dirname,
33
+ resolve,
34
+ relative,
35
+ isAbsolute,
36
+ sep,
37
+ } from "path";
38
+ import matter from "gray-matter";
39
+
40
+ // =============================================================================
41
+ // Types
42
+ // =============================================================================
43
+
44
+ /**
45
+ * Skill metadata from YAML frontmatter
46
+ */
47
+ export interface SkillMetadata {
48
+ /** Unique skill identifier (lowercase, hyphens) */
49
+ name: string;
50
+ /** Description of what the skill does and when to use it */
51
+ description: string;
52
+ /** Optional list of tools this skill works with */
53
+ tools?: string[];
54
+ /** Optional tags for categorization */
55
+ tags?: string[];
56
+ }
57
+
58
+ /**
59
+ * Full skill definition including content
60
+ */
61
+ export interface Skill {
62
+ /** Parsed frontmatter metadata */
63
+ metadata: SkillMetadata;
64
+ /** Raw markdown body (instructions) */
65
+ body: string;
66
+ /** Absolute path to the SKILL.md file */
67
+ path: string;
68
+ /** Directory containing the skill */
69
+ directory: string;
70
+ /** Whether this skill has executable scripts */
71
+ hasScripts: boolean;
72
+ /** List of script files in the skill directory */
73
+ scripts: string[];
74
+ }
75
+
76
+ /**
77
+ * Lightweight skill reference for listing
78
+ */
79
+ export interface SkillRef {
80
+ name: string;
81
+ description: string;
82
+ path: string;
83
+ hasScripts: boolean;
84
+ }
85
+
86
+ // =============================================================================
87
+ // State
88
+ // =============================================================================
89
+
90
+ /** Cached project directory for skill discovery */
91
+ let skillsProjectDirectory: string = process.cwd();
92
+
93
+ /** Cached discovered skills (lazy-loaded) */
94
+ let skillsCache: Map<string, Skill> | null = null;
95
+
96
+ /**
97
+ * Set the project directory for skill discovery
98
+ */
99
+ export function setSkillsProjectDirectory(dir: string): void {
100
+ skillsProjectDirectory = dir;
101
+ skillsCache = null; // Invalidate cache when directory changes
102
+ }
103
+
104
+ // =============================================================================
105
+ // YAML Frontmatter Parser
106
+ // =============================================================================
107
+
108
+ /**
109
+ * Parse YAML frontmatter from markdown content using gray-matter
110
+ *
111
+ * Handles the common frontmatter format:
112
+ * ```
113
+ * ---
114
+ * key: value
115
+ * ---
116
+ * body content
117
+ * ```
118
+ */
119
+ export function parseFrontmatter(content: string): {
120
+ metadata: Record<string, unknown>;
121
+ body: string;
122
+ } {
123
+ try {
124
+ const { data, content: body } = matter(content);
125
+ return { metadata: data, body: body.trim() };
126
+ } catch {
127
+ // If gray-matter fails, return empty metadata and full content as body
128
+ return { metadata: {}, body: content };
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Validate and extract skill metadata from parsed frontmatter
134
+ */
135
+ function validateSkillMetadata(
136
+ raw: Record<string, unknown>,
137
+ filePath: string,
138
+ ): SkillMetadata {
139
+ const name = raw.name;
140
+ const description = raw.description;
141
+
142
+ if (typeof name !== "string" || !name) {
143
+ throw new Error(`Skill at ${filePath} missing required 'name' field`);
144
+ }
145
+
146
+ if (typeof description !== "string" || !description) {
147
+ throw new Error(
148
+ `Skill at ${filePath} missing required 'description' field`,
149
+ );
150
+ }
151
+
152
+ // Validate name format
153
+ if (!/^[a-z0-9-]+$/.test(name)) {
154
+ throw new Error(`Skill name '${name}' must be lowercase with hyphens only`);
155
+ }
156
+
157
+ if (name.length > 64) {
158
+ throw new Error(`Skill name '${name}' exceeds 64 character limit`);
159
+ }
160
+
161
+ if (description.length > 1024) {
162
+ throw new Error(
163
+ `Skill description for '${name}' exceeds 1024 character limit`,
164
+ );
165
+ }
166
+
167
+ return {
168
+ name,
169
+ description,
170
+ tools: Array.isArray(raw.tools)
171
+ ? raw.tools.filter((t): t is string => typeof t === "string")
172
+ : undefined,
173
+ tags: Array.isArray(raw.tags)
174
+ ? raw.tags.filter((t): t is string => typeof t === "string")
175
+ : undefined,
176
+ };
177
+ }
178
+
179
+ // =============================================================================
180
+ // Discovery
181
+ // =============================================================================
182
+
183
+ /**
184
+ * Skill discovery locations relative to project root (checked first)
185
+ */
186
+ const PROJECT_SKILL_DIRECTORIES = [
187
+ ".opencode/skills",
188
+ ".claude/skills",
189
+ "skills",
190
+ ] as const;
191
+
192
+ /**
193
+ * Global skills directory (user-level, checked after project)
194
+ */
195
+ function getGlobalSkillsDir(): string {
196
+ const home = process.env.HOME || process.env.USERPROFILE || "~";
197
+ return join(home, ".config", "opencode", "skills");
198
+ }
199
+
200
+ /**
201
+ * Claude Code global skills directory (compatibility)
202
+ */
203
+ function getClaudeGlobalSkillsDir(): string {
204
+ const home = process.env.HOME || process.env.USERPROFILE || "~";
205
+ return join(home, ".claude", "skills");
206
+ }
207
+
208
+ /**
209
+ * Bundled skills from the package (lowest priority)
210
+ */
211
+ function getPackageSkillsDir(): string {
212
+ // ES module equivalent of __dirname - resolve relative to this file
213
+ const currentDir = new URL(".", import.meta.url).pathname;
214
+ return join(currentDir, "..", "global-skills");
215
+ }
216
+
217
+ /**
218
+ * Find all SKILL.md files in a directory
219
+ */
220
+ async function findSkillFiles(baseDir: string): Promise<string[]> {
221
+ const skillFiles: string[] = [];
222
+
223
+ try {
224
+ const entries = await readdir(baseDir, { withFileTypes: true });
225
+
226
+ for (const entry of entries) {
227
+ if (entry.isDirectory()) {
228
+ const skillPath = join(baseDir, entry.name, "SKILL.md");
229
+ try {
230
+ const s = await stat(skillPath);
231
+ if (s.isFile()) {
232
+ skillFiles.push(skillPath);
233
+ }
234
+ } catch {
235
+ // SKILL.md doesn't exist in this subdirectory
236
+ }
237
+ }
238
+ }
239
+ } catch {
240
+ // Directory doesn't exist
241
+ }
242
+
243
+ return skillFiles;
244
+ }
245
+
246
+ /**
247
+ * Find script files in a skill directory
248
+ */
249
+ async function findSkillScripts(skillDir: string): Promise<string[]> {
250
+ const scripts: string[] = [];
251
+ const scriptsDir = join(skillDir, "scripts");
252
+
253
+ try {
254
+ const entries = await readdir(scriptsDir, { withFileTypes: true });
255
+ for (const entry of entries) {
256
+ if (entry.isFile()) {
257
+ scripts.push(entry.name);
258
+ }
259
+ }
260
+ } catch {
261
+ // No scripts directory
262
+ }
263
+
264
+ return scripts;
265
+ }
266
+
267
+ /**
268
+ * Load a skill from its SKILL.md file
269
+ */
270
+ async function loadSkill(skillPath: string): Promise<Skill> {
271
+ const content = await readFile(skillPath, "utf-8");
272
+ const { metadata: rawMetadata, body } = parseFrontmatter(content);
273
+ const metadata = validateSkillMetadata(rawMetadata, skillPath);
274
+ const directory = dirname(skillPath);
275
+ const scripts = await findSkillScripts(directory);
276
+
277
+ return {
278
+ metadata,
279
+ body,
280
+ path: skillPath,
281
+ directory,
282
+ hasScripts: scripts.length > 0,
283
+ scripts,
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Discover all skills in the project and global directories
289
+ *
290
+ * Priority order (first match wins):
291
+ * 1. Project: .opencode/skills/
292
+ * 2. Project: .claude/skills/
293
+ * 3. Project: skills/
294
+ * 4. Global: ~/.config/opencode/skills/
295
+ * 5. Global: ~/.claude/skills/
296
+ */
297
+ export async function discoverSkills(
298
+ projectDir?: string,
299
+ ): Promise<Map<string, Skill>> {
300
+ const dir = projectDir || skillsProjectDirectory;
301
+
302
+ // Return cached skills if available
303
+ if (skillsCache && !projectDir) {
304
+ return skillsCache;
305
+ }
306
+
307
+ const skills = new Map<string, Skill>();
308
+ const seenNames = new Set<string>();
309
+
310
+ /**
311
+ * Helper to load skills from a directory
312
+ */
313
+ async function loadSkillsFromDir(skillsDir: string): Promise<void> {
314
+ const skillFiles = await findSkillFiles(skillsDir);
315
+
316
+ for (const skillPath of skillFiles) {
317
+ try {
318
+ const skill = await loadSkill(skillPath);
319
+
320
+ // First definition wins (project overrides global)
321
+ if (!seenNames.has(skill.metadata.name)) {
322
+ skills.set(skill.metadata.name, skill);
323
+ seenNames.add(skill.metadata.name);
324
+ }
325
+ } catch (error) {
326
+ // Log but don't fail on individual skill parse errors
327
+ console.warn(
328
+ `[skills] Failed to load ${skillPath}: ${error instanceof Error ? error.message : String(error)}`,
329
+ );
330
+ }
331
+ }
332
+ }
333
+
334
+ // 1. Check project skill directories first (highest priority)
335
+ for (const relPath of PROJECT_SKILL_DIRECTORIES) {
336
+ await loadSkillsFromDir(join(dir, relPath));
337
+ }
338
+
339
+ // 2. Check global OpenCode skills directory
340
+ await loadSkillsFromDir(getGlobalSkillsDir());
341
+
342
+ // 3. Check global Claude skills directory (compatibility)
343
+ await loadSkillsFromDir(getClaudeGlobalSkillsDir());
344
+
345
+ // 4. Check bundled package skills (lowest priority)
346
+ await loadSkillsFromDir(getPackageSkillsDir());
347
+
348
+ // Cache for future lookups
349
+ if (!projectDir) {
350
+ skillsCache = skills;
351
+ }
352
+
353
+ return skills;
354
+ }
355
+
356
+ /**
357
+ * Get a single skill by name
358
+ */
359
+ export async function getSkill(name: string): Promise<Skill | null> {
360
+ const skills = await discoverSkills();
361
+ return skills.get(name) || null;
362
+ }
363
+
364
+ /**
365
+ * List all available skills (lightweight refs only)
366
+ */
367
+ export async function listSkills(): Promise<SkillRef[]> {
368
+ const skills = await discoverSkills();
369
+ return Array.from(skills.values()).map((skill) => ({
370
+ name: skill.metadata.name,
371
+ description: skill.metadata.description,
372
+ path: skill.path,
373
+ hasScripts: skill.hasScripts,
374
+ }));
375
+ }
376
+
377
+ /**
378
+ * Invalidate the skills cache (call when skills may have changed)
379
+ */
380
+ export function invalidateSkillsCache(): void {
381
+ skillsCache = null;
382
+ }
383
+
384
+ // =============================================================================
385
+ // Tools
386
+ // =============================================================================
387
+
388
+ /**
389
+ * List available skills with metadata
390
+ *
391
+ * Returns lightweight skill references for the model to evaluate
392
+ * which skills are relevant to the current task.
393
+ */
394
+ export const skills_list = tool({
395
+ description: `List all available skills in the project.
396
+
397
+ Skills are specialized instructions that help with specific domains or tasks.
398
+ Use this tool to discover what skills are available, then use skills_use to
399
+ activate a relevant skill.
400
+
401
+ Returns skill names, descriptions, and whether they have executable scripts.`,
402
+ args: {
403
+ tag: tool.schema
404
+ .string()
405
+ .optional()
406
+ .describe("Optional tag to filter skills by"),
407
+ },
408
+ async execute(args) {
409
+ const skills = await discoverSkills();
410
+ let refs = Array.from(skills.values());
411
+
412
+ // Filter by tag if provided
413
+ if (args.tag) {
414
+ refs = refs.filter((s) => s.metadata.tags?.includes(args.tag as string));
415
+ }
416
+
417
+ if (refs.length === 0) {
418
+ return args.tag
419
+ ? `No skills found with tag '${args.tag}'. Try skills_list without a tag filter.`
420
+ : `No skills found. Skills should be in .opencode/skills/, .claude/skills/, or skills/ directories with SKILL.md files.`;
421
+ }
422
+
423
+ const formatted = refs
424
+ .map((s) => {
425
+ const scripts = s.hasScripts ? " [has scripts]" : "";
426
+ const tags = s.metadata.tags?.length
427
+ ? ` (${s.metadata.tags.join(", ")})`
428
+ : "";
429
+ return `• ${s.metadata.name}${tags}${scripts}\n ${s.metadata.description}`;
430
+ })
431
+ .join("\n\n");
432
+
433
+ return `Found ${refs.length} skill(s):\n\n${formatted}`;
434
+ },
435
+ });
436
+
437
+ /**
438
+ * Load and activate a skill by name
439
+ *
440
+ * Loads the full skill content for injection into context.
441
+ * The skill's instructions become available for the model to follow.
442
+ */
443
+ export const skills_use = tool({
444
+ description: `Activate a skill by loading its full instructions.
445
+
446
+ After calling this tool, follow the skill's instructions for the current task.
447
+ Skills provide domain-specific guidance and best practices.
448
+
449
+ If the skill has scripts, you can run them with skills_execute.`,
450
+ args: {
451
+ name: tool.schema.string().describe("Name of the skill to activate"),
452
+ include_scripts: tool.schema
453
+ .boolean()
454
+ .optional()
455
+ .describe("Also list available scripts (default: true)"),
456
+ },
457
+ async execute(args) {
458
+ const skill = await getSkill(args.name);
459
+
460
+ if (!skill) {
461
+ const available = await listSkills();
462
+ const names = available.map((s) => s.name).join(", ");
463
+ return `Skill '${args.name}' not found. Available skills: ${names || "none"}`;
464
+ }
465
+
466
+ const includeScripts = args.include_scripts !== false;
467
+ let output = `# Skill: ${skill.metadata.name}\n\n`;
468
+ output += `${skill.body}\n`;
469
+
470
+ if (includeScripts && skill.scripts.length > 0) {
471
+ output += `\n---\n\n## Available Scripts\n\n`;
472
+ output += `This skill includes the following scripts in ${skill.directory}/scripts/:\n\n`;
473
+ output += skill.scripts.map((s) => `• ${s}`).join("\n");
474
+ output += `\n\nRun scripts with skills_execute tool.`;
475
+ }
476
+
477
+ return output;
478
+ },
479
+ });
480
+
481
+ /**
482
+ * Execute a script from a skill
483
+ *
484
+ * Skills can include helper scripts in their scripts/ directory.
485
+ * This tool runs them with appropriate context.
486
+ */
487
+ export const skills_execute = tool({
488
+ description: `Execute a script from a skill's scripts/ directory.
489
+
490
+ Some skills include helper scripts for common operations.
491
+ Use skills_use first to see available scripts, then execute them here.
492
+
493
+ Scripts run in the skill's directory with the project directory as an argument.`,
494
+ args: {
495
+ skill: tool.schema.string().describe("Name of the skill"),
496
+ script: tool.schema.string().describe("Name of the script file to execute"),
497
+ args: tool.schema
498
+ .array(tool.schema.string())
499
+ .optional()
500
+ .describe("Additional arguments to pass to the script"),
501
+ },
502
+ async execute(args, ctx) {
503
+ const skill = await getSkill(args.skill);
504
+
505
+ if (!skill) {
506
+ return `Skill '${args.skill}' not found.`;
507
+ }
508
+
509
+ if (!skill.scripts.includes(args.script)) {
510
+ return `Script '${args.script}' not found in skill '${args.skill}'. Available: ${skill.scripts.join(", ") || "none"}`;
511
+ }
512
+
513
+ const scriptPath = join(skill.directory, "scripts", args.script);
514
+ const scriptArgs = args.args || [];
515
+
516
+ try {
517
+ // Execute script using Bun.spawn with timeout
518
+ const TIMEOUT_MS = 60_000; // 60 second timeout
519
+ const proc = Bun.spawn(
520
+ [scriptPath, skillsProjectDirectory, ...scriptArgs],
521
+ {
522
+ cwd: skill.directory,
523
+ stdout: "pipe",
524
+ stderr: "pipe",
525
+ },
526
+ );
527
+
528
+ // Race between script completion and timeout
529
+ const timeoutPromise = new Promise<{ timedOut: true }>((resolve) => {
530
+ setTimeout(() => resolve({ timedOut: true }), TIMEOUT_MS);
531
+ });
532
+
533
+ const resultPromise = (async () => {
534
+ const [stdout, stderr] = await Promise.all([
535
+ new Response(proc.stdout).text(),
536
+ new Response(proc.stderr).text(),
537
+ ]);
538
+ const exitCode = await proc.exited;
539
+ return { timedOut: false as const, stdout, stderr, exitCode };
540
+ })();
541
+
542
+ const result = await Promise.race([resultPromise, timeoutPromise]);
543
+
544
+ if (result.timedOut) {
545
+ proc.kill();
546
+ return `Script timed out after ${TIMEOUT_MS / 1000} seconds.`;
547
+ }
548
+
549
+ const output = result.stdout + result.stderr;
550
+ if (result.exitCode === 0) {
551
+ return output || "Script executed successfully.";
552
+ } else {
553
+ return `Script exited with code ${result.exitCode}:\n${output}`;
554
+ }
555
+ } catch (error) {
556
+ return `Failed to execute script: ${error instanceof Error ? error.message : String(error)}`;
557
+ }
558
+ },
559
+ });
560
+
561
+ /**
562
+ * Read a resource file from a skill directory
563
+ *
564
+ * Skills can include additional resources like examples, templates, or reference docs.
565
+ */
566
+ export const skills_read = tool({
567
+ description: `Read a resource file from a skill's directory.
568
+
569
+ Skills may include additional files like:
570
+ - examples.md - Example usage
571
+ - reference.md - Reference documentation
572
+ - templates/ - Template files
573
+
574
+ Use this to access supplementary skill resources.`,
575
+ args: {
576
+ skill: tool.schema.string().describe("Name of the skill"),
577
+ file: tool.schema
578
+ .string()
579
+ .describe("Relative path to the file within the skill directory"),
580
+ },
581
+ async execute(args) {
582
+ const skill = await getSkill(args.skill);
583
+
584
+ if (!skill) {
585
+ return `Skill '${args.skill}' not found.`;
586
+ }
587
+
588
+ // Security: prevent path traversal (cross-platform)
589
+ // Block absolute paths (Unix / and Windows C:\ or \\)
590
+ if (isAbsolute(args.file)) {
591
+ return "Invalid file path. Use a relative path.";
592
+ }
593
+
594
+ // Block path traversal attempts
595
+ if (args.file.includes("..")) {
596
+ return "Invalid file path. Path traversal not allowed.";
597
+ }
598
+
599
+ const filePath = resolve(skill.directory, args.file);
600
+ const relativePath = relative(skill.directory, filePath);
601
+
602
+ // Verify resolved path stays within skill directory
603
+ // Check for ".." at start or after separator (handles both Unix and Windows)
604
+ if (
605
+ relativePath === ".." ||
606
+ relativePath.startsWith(".." + sep) ||
607
+ relativePath.startsWith(".." + "/") ||
608
+ relativePath.startsWith(".." + "\\")
609
+ ) {
610
+ return "Invalid file path. Must stay within the skill directory.";
611
+ }
612
+
613
+ try {
614
+ const content = await readFile(filePath, "utf-8");
615
+ return content;
616
+ } catch (error) {
617
+ return `Failed to read '${args.file}' from skill '${args.skill}': ${error instanceof Error ? error.message : String(error)}`;
618
+ }
619
+ },
620
+ });
621
+
622
+ // =============================================================================
623
+ // Skill Creation & Maintenance Tools
624
+ // =============================================================================
625
+
626
+ /**
627
+ * Default skills directory for new skills
628
+ */
629
+ const DEFAULT_SKILLS_DIR = ".opencode/skills";
630
+
631
+ /**
632
+ * Quote a YAML scalar if it contains special characters
633
+ * Uses double quotes and escapes internal quotes/newlines
634
+ */
635
+ function quoteYamlScalar(value: string): string {
636
+ // Check if quoting is needed (contains :, #, newlines, quotes, or starts with special chars)
637
+ const needsQuoting =
638
+ /[:\n\r#"'`\[\]{}|>&*!?@]/.test(value) ||
639
+ value.startsWith(" ") ||
640
+ value.endsWith(" ") ||
641
+ value === "" ||
642
+ /^[0-9]/.test(value) ||
643
+ ["true", "false", "null", "yes", "no", "on", "off"].includes(
644
+ value.toLowerCase(),
645
+ );
646
+
647
+ if (!needsQuoting) {
648
+ return value;
649
+ }
650
+
651
+ // Escape backslashes and double quotes, then wrap in double quotes
652
+ const escaped = value
653
+ .replace(/\\/g, "\\\\")
654
+ .replace(/"/g, '\\"')
655
+ .replace(/\n/g, "\\n");
656
+ return `"${escaped}"`;
657
+ }
658
+
659
+ /**
660
+ * Generate SKILL.md content from metadata and body
661
+ */
662
+ function generateSkillContent(
663
+ name: string,
664
+ description: string,
665
+ body: string,
666
+ options?: { tags?: string[]; tools?: string[] },
667
+ ): string {
668
+ const frontmatter: string[] = [
669
+ "---",
670
+ `name: ${quoteYamlScalar(name)}`,
671
+ `description: ${quoteYamlScalar(description)}`,
672
+ ];
673
+
674
+ if (options?.tags && options.tags.length > 0) {
675
+ frontmatter.push("tags:");
676
+ for (const tag of options.tags) {
677
+ frontmatter.push(` - ${quoteYamlScalar(tag)}`);
678
+ }
679
+ }
680
+
681
+ if (options?.tools && options.tools.length > 0) {
682
+ frontmatter.push("tools:");
683
+ for (const t of options.tools) {
684
+ frontmatter.push(` - ${quoteYamlScalar(t)}`);
685
+ }
686
+ }
687
+
688
+ frontmatter.push("---");
689
+
690
+ return `${frontmatter.join("\n")}\n\n${body}`;
691
+ }
692
+
693
+ /**
694
+ * Create a new skill in the project
695
+ *
696
+ * Agents can use this to codify learned patterns, best practices,
697
+ * or domain-specific knowledge into reusable skills.
698
+ */
699
+ export const skills_create = tool({
700
+ description: `Create a new skill in the project.
701
+
702
+ Use this to codify learned patterns, best practices, or domain knowledge
703
+ into a reusable skill that future agents can discover and use.
704
+
705
+ Skills are stored in .opencode/skills/<name>/SKILL.md by default.
706
+
707
+ Good skills have:
708
+ - Clear, specific descriptions explaining WHEN to use them
709
+ - Actionable instructions with examples
710
+ - Tags for discoverability`,
711
+ args: {
712
+ name: tool.schema
713
+ .string()
714
+ .regex(/^[a-z0-9-]+$/)
715
+ .max(64)
716
+ .describe("Skill name (lowercase, hyphens only, max 64 chars)"),
717
+ description: tool.schema
718
+ .string()
719
+ .max(1024)
720
+ .describe("What the skill does and when to use it (max 1024 chars)"),
721
+ body: tool.schema
722
+ .string()
723
+ .describe("Markdown content with instructions, examples, guidelines"),
724
+ tags: tool.schema
725
+ .array(tool.schema.string())
726
+ .optional()
727
+ .describe("Tags for categorization (e.g., ['testing', 'frontend'])"),
728
+ tools: tool.schema
729
+ .array(tool.schema.string())
730
+ .optional()
731
+ .describe("Tools this skill commonly uses"),
732
+ directory: tool.schema
733
+ .enum([
734
+ ".opencode/skills",
735
+ ".claude/skills",
736
+ "skills",
737
+ "global",
738
+ "global-claude",
739
+ ])
740
+ .optional()
741
+ .describe(
742
+ "Where to create the skill (default: .opencode/skills). Use 'global' for ~/.config/opencode/skills/, 'global-claude' for ~/.claude/skills/",
743
+ ),
744
+ },
745
+ async execute(args) {
746
+ // Check if skill already exists
747
+ const existing = await getSkill(args.name);
748
+ if (existing) {
749
+ return `Skill '${args.name}' already exists at ${existing.path}. Use skills_update to modify it.`;
750
+ }
751
+
752
+ // Determine target directory
753
+ let skillDir: string;
754
+ if (args.directory === "global") {
755
+ skillDir = join(getGlobalSkillsDir(), args.name);
756
+ } else if (args.directory === "global-claude") {
757
+ skillDir = join(getClaudeGlobalSkillsDir(), args.name);
758
+ } else {
759
+ const baseDir = args.directory || DEFAULT_SKILLS_DIR;
760
+ skillDir = join(skillsProjectDirectory, baseDir, args.name);
761
+ }
762
+ const skillPath = join(skillDir, "SKILL.md");
763
+
764
+ try {
765
+ // Create skill directory
766
+ await mkdir(skillDir, { recursive: true });
767
+
768
+ // Generate and write SKILL.md
769
+ const content = generateSkillContent(
770
+ args.name,
771
+ args.description,
772
+ args.body,
773
+ { tags: args.tags, tools: args.tools },
774
+ );
775
+
776
+ await writeFile(skillPath, content, "utf-8");
777
+
778
+ // Invalidate cache so new skill is discoverable
779
+ invalidateSkillsCache();
780
+
781
+ return JSON.stringify(
782
+ {
783
+ success: true,
784
+ skill: args.name,
785
+ path: skillPath,
786
+ message: `Created skill '${args.name}'. It's now discoverable via skills_list.`,
787
+ next_steps: [
788
+ "Test with skills_use to verify instructions are clear",
789
+ "Add examples.md or reference.md for supplementary content",
790
+ "Add scripts/ directory for executable helpers",
791
+ ],
792
+ },
793
+ null,
794
+ 2,
795
+ );
796
+ } catch (error) {
797
+ return `Failed to create skill: ${error instanceof Error ? error.message : String(error)}`;
798
+ }
799
+ },
800
+ });
801
+
802
+ /**
803
+ * Update an existing skill
804
+ *
805
+ * Modify a skill's metadata or content based on learned improvements.
806
+ */
807
+ export const skills_update = tool({
808
+ description: `Update an existing skill's content or metadata.
809
+
810
+ Use this to refine skills based on experience:
811
+ - Clarify instructions that were confusing
812
+ - Add examples from successful usage
813
+ - Update descriptions for better discoverability
814
+ - Add new tags or tool references`,
815
+ args: {
816
+ name: tool.schema.string().describe("Name of the skill to update"),
817
+ description: tool.schema
818
+ .string()
819
+ .max(1024)
820
+ .optional()
821
+ .describe("New description (replaces existing)"),
822
+ body: tool.schema
823
+ .string()
824
+ .optional()
825
+ .describe("New body content (replaces existing)"),
826
+ append_body: tool.schema
827
+ .string()
828
+ .optional()
829
+ .describe("Content to append to existing body"),
830
+ tags: tool.schema
831
+ .array(tool.schema.string())
832
+ .optional()
833
+ .describe("New tags (replaces existing)"),
834
+ add_tags: tool.schema
835
+ .array(tool.schema.string())
836
+ .optional()
837
+ .describe("Tags to add to existing"),
838
+ tools: tool.schema
839
+ .array(tool.schema.string())
840
+ .optional()
841
+ .describe("New tools list (replaces existing)"),
842
+ },
843
+ async execute(args) {
844
+ const skill = await getSkill(args.name);
845
+ if (!skill) {
846
+ const available = await listSkills();
847
+ const names = available.map((s) => s.name).join(", ");
848
+ return `Skill '${args.name}' not found. Available: ${names || "none"}`;
849
+ }
850
+
851
+ // Build updated metadata
852
+ const newDescription = args.description ?? skill.metadata.description;
853
+
854
+ // Handle body updates
855
+ let newBody = skill.body;
856
+ if (args.body) {
857
+ newBody = args.body;
858
+ } else if (args.append_body) {
859
+ newBody = `${skill.body}\n\n${args.append_body}`;
860
+ }
861
+
862
+ // Handle tags
863
+ let newTags = skill.metadata.tags;
864
+ if (args.tags) {
865
+ newTags = args.tags;
866
+ } else if (args.add_tags) {
867
+ newTags = [...(skill.metadata.tags || []), ...args.add_tags];
868
+ // Deduplicate
869
+ newTags = [...new Set(newTags)];
870
+ }
871
+
872
+ // Handle tools
873
+ const newTools = args.tools ?? skill.metadata.tools;
874
+
875
+ try {
876
+ // Generate and write updated SKILL.md
877
+ const content = generateSkillContent(args.name, newDescription, newBody, {
878
+ tags: newTags,
879
+ tools: newTools,
880
+ });
881
+
882
+ await writeFile(skill.path, content, "utf-8");
883
+
884
+ // Invalidate cache
885
+ invalidateSkillsCache();
886
+
887
+ return JSON.stringify(
888
+ {
889
+ success: true,
890
+ skill: args.name,
891
+ path: skill.path,
892
+ updated: {
893
+ description: args.description ? true : false,
894
+ body: args.body || args.append_body ? true : false,
895
+ tags: args.tags || args.add_tags ? true : false,
896
+ tools: args.tools ? true : false,
897
+ },
898
+ message: `Updated skill '${args.name}'.`,
899
+ },
900
+ null,
901
+ 2,
902
+ );
903
+ } catch (error) {
904
+ return `Failed to update skill: ${error instanceof Error ? error.message : String(error)}`;
905
+ }
906
+ },
907
+ });
908
+
909
+ /**
910
+ * Delete a skill from the project
911
+ */
912
+ export const skills_delete = tool({
913
+ description: `Delete a skill from the project.
914
+
915
+ Use sparingly - only delete skills that are:
916
+ - Obsolete or superseded by better skills
917
+ - Incorrect or harmful
918
+ - Duplicates of other skills
919
+
920
+ Consider updating instead of deleting when possible.`,
921
+ args: {
922
+ name: tool.schema.string().describe("Name of the skill to delete"),
923
+ confirm: tool.schema.boolean().describe("Must be true to confirm deletion"),
924
+ },
925
+ async execute(args) {
926
+ if (!args.confirm) {
927
+ return "Deletion not confirmed. Set confirm=true to delete the skill.";
928
+ }
929
+
930
+ const skill = await getSkill(args.name);
931
+ if (!skill) {
932
+ return `Skill '${args.name}' not found.`;
933
+ }
934
+
935
+ try {
936
+ // Remove the entire skill directory
937
+ await rm(skill.directory, { recursive: true, force: true });
938
+
939
+ // Invalidate cache
940
+ invalidateSkillsCache();
941
+
942
+ return JSON.stringify(
943
+ {
944
+ success: true,
945
+ skill: args.name,
946
+ deleted_path: skill.directory,
947
+ message: `Deleted skill '${args.name}' and its directory.`,
948
+ },
949
+ null,
950
+ 2,
951
+ );
952
+ } catch (error) {
953
+ return `Failed to delete skill: ${error instanceof Error ? error.message : String(error)}`;
954
+ }
955
+ },
956
+ });
957
+
958
+ /**
959
+ * Add a script to a skill
960
+ *
961
+ * Skills can include helper scripts for automation.
962
+ */
963
+ export const skills_add_script = tool({
964
+ description: `Add a helper script to an existing skill.
965
+
966
+ Scripts are stored in the skill's scripts/ directory and can be
967
+ executed with skills_execute. Use for:
968
+ - Automation helpers
969
+ - Validation scripts
970
+ - Setup/teardown utilities`,
971
+ args: {
972
+ skill: tool.schema.string().describe("Name of the skill"),
973
+ script_name: tool.schema
974
+ .string()
975
+ .describe("Script filename (e.g., 'validate.sh', 'setup.py')"),
976
+ content: tool.schema.string().describe("Script content"),
977
+ executable: tool.schema
978
+ .boolean()
979
+ .default(true)
980
+ .describe("Make script executable (default: true)"),
981
+ },
982
+ async execute(args) {
983
+ const skill = await getSkill(args.skill);
984
+ if (!skill) {
985
+ return `Skill '${args.skill}' not found.`;
986
+ }
987
+
988
+ // Security: validate script name (cross-platform)
989
+ // Block absolute paths, path separators, and traversal
990
+ if (
991
+ isAbsolute(args.script_name) ||
992
+ args.script_name.includes("..") ||
993
+ args.script_name.includes("/") ||
994
+ args.script_name.includes("\\") ||
995
+ basename(args.script_name) !== args.script_name
996
+ ) {
997
+ return "Invalid script name. Use simple filenames without paths.";
998
+ }
999
+
1000
+ const scriptsDir = join(skill.directory, "scripts");
1001
+ const scriptPath = join(scriptsDir, args.script_name);
1002
+
1003
+ try {
1004
+ // Create scripts directory if needed
1005
+ await mkdir(scriptsDir, { recursive: true });
1006
+
1007
+ // Write script
1008
+ await writeFile(scriptPath, args.content, {
1009
+ mode: args.executable ? 0o755 : 0o644,
1010
+ });
1011
+
1012
+ // Invalidate cache to update hasScripts
1013
+ invalidateSkillsCache();
1014
+
1015
+ return JSON.stringify(
1016
+ {
1017
+ success: true,
1018
+ skill: args.skill,
1019
+ script: args.script_name,
1020
+ path: scriptPath,
1021
+ executable: args.executable,
1022
+ message: `Added script '${args.script_name}' to skill '${args.skill}'.`,
1023
+ usage: `Run with: skills_execute(skill: "${args.skill}", script: "${args.script_name}")`,
1024
+ },
1025
+ null,
1026
+ 2,
1027
+ );
1028
+ } catch (error) {
1029
+ return `Failed to add script: ${error instanceof Error ? error.message : String(error)}`;
1030
+ }
1031
+ },
1032
+ });
1033
+
1034
+ // =============================================================================
1035
+ // Skill Initialization
1036
+ // =============================================================================
1037
+
1038
+ /**
1039
+ * Generate a skill template with TODO placeholders
1040
+ */
1041
+ function generateSkillTemplate(name: string, description?: string): string {
1042
+ const title = name
1043
+ .split("-")
1044
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
1045
+ .join(" ");
1046
+
1047
+ return `---
1048
+ name: ${name}
1049
+ description: ${description || `[TODO: Complete description of what this skill does and WHEN to use it. Be specific about scenarios that trigger this skill.]`}
1050
+ tags:
1051
+ - [TODO: add tags]
1052
+ ---
1053
+
1054
+ # ${title}
1055
+
1056
+ ## Overview
1057
+
1058
+ [TODO: 1-2 sentences explaining what this skill enables]
1059
+
1060
+ ## When to Use This Skill
1061
+
1062
+ [TODO: List specific scenarios when this skill should be activated:
1063
+ - When working on X type of task
1064
+ - When files matching Y pattern are involved
1065
+ - When the user asks about Z topic]
1066
+
1067
+ ## Instructions
1068
+
1069
+ [TODO: Add actionable instructions for the agent. Use imperative form:
1070
+ - "Read the configuration file first"
1071
+ - "Check for existing patterns before creating new ones"
1072
+ - "Always validate output before completing"]
1073
+
1074
+ ## Examples
1075
+
1076
+ ### Example 1: [TODO: Realistic scenario]
1077
+
1078
+ **User**: "[TODO: Example user request]"
1079
+
1080
+ **Process**:
1081
+ 1. [TODO: Step-by-step process]
1082
+ 2. [TODO: Next step]
1083
+ 3. [TODO: Final step]
1084
+
1085
+ ## Resources
1086
+
1087
+ This skill may include additional resources:
1088
+
1089
+ ### scripts/
1090
+ Executable scripts for automation. Run with \`skills_execute\`.
1091
+
1092
+ ### references/
1093
+ Documentation loaded on-demand. Access with \`skills_read\`.
1094
+
1095
+ ---
1096
+ *Delete any unused sections and this line when skill is complete.*
1097
+ `;
1098
+ }
1099
+
1100
+ /**
1101
+ * Generate a reference template
1102
+ */
1103
+ function generateReferenceTemplate(skillName: string): string {
1104
+ const title = skillName
1105
+ .split("-")
1106
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
1107
+ .join(" ");
1108
+
1109
+ return `# Reference Documentation for ${title}
1110
+
1111
+ ## Overview
1112
+
1113
+ [TODO: Detailed reference material for this skill]
1114
+
1115
+ ## API Reference
1116
+
1117
+ [TODO: If applicable, document APIs, schemas, or interfaces]
1118
+
1119
+ ## Detailed Workflows
1120
+
1121
+ [TODO: Complex multi-step workflows that don't fit in SKILL.md]
1122
+
1123
+ ## Troubleshooting
1124
+
1125
+ [TODO: Common issues and solutions]
1126
+ `;
1127
+ }
1128
+
1129
+ /**
1130
+ * Initialize a new skill with full directory structure
1131
+ *
1132
+ * Creates a skill template following best practices from the
1133
+ * Anthropic Agent Skills specification and community patterns.
1134
+ */
1135
+ export const skills_init = tool({
1136
+ description: `Initialize a new skill with full directory structure and templates.
1137
+
1138
+ Creates a complete skill directory with:
1139
+ - SKILL.md with frontmatter and TODO placeholders
1140
+ - scripts/ directory for executable helpers
1141
+ - references/ directory for on-demand documentation
1142
+
1143
+ Use this instead of skills_create when you want the full template structure.
1144
+ Perfect for learning to create effective skills.`,
1145
+ args: {
1146
+ name: tool.schema
1147
+ .string()
1148
+ .regex(/^[a-z0-9-]+$/)
1149
+ .max(64)
1150
+ .describe("Skill name (lowercase, hyphens only)"),
1151
+ description: tool.schema
1152
+ .string()
1153
+ .optional()
1154
+ .describe("Initial description (can be a TODO placeholder)"),
1155
+ directory: tool.schema
1156
+ .enum([".opencode/skills", ".claude/skills", "skills", "global"])
1157
+ .optional()
1158
+ .describe("Where to create (default: .opencode/skills)"),
1159
+ include_example_script: tool.schema
1160
+ .boolean()
1161
+ .default(true)
1162
+ .describe("Include example script placeholder (default: true)"),
1163
+ include_reference: tool.schema
1164
+ .boolean()
1165
+ .default(true)
1166
+ .describe("Include reference doc placeholder (default: true)"),
1167
+ },
1168
+ async execute(args) {
1169
+ // Check if skill already exists
1170
+ const existing = await getSkill(args.name);
1171
+ if (existing) {
1172
+ return JSON.stringify(
1173
+ {
1174
+ success: false,
1175
+ error: `Skill '${args.name}' already exists`,
1176
+ existing_path: existing.path,
1177
+ },
1178
+ null,
1179
+ 2,
1180
+ );
1181
+ }
1182
+
1183
+ // Determine target directory
1184
+ let skillDir: string;
1185
+ if (args.directory === "global") {
1186
+ skillDir = join(getGlobalSkillsDir(), args.name);
1187
+ } else {
1188
+ const baseDir = args.directory || DEFAULT_SKILLS_DIR;
1189
+ skillDir = join(skillsProjectDirectory, baseDir, args.name);
1190
+ }
1191
+
1192
+ const createdFiles: string[] = [];
1193
+
1194
+ try {
1195
+ // Create skill directory
1196
+ await mkdir(skillDir, { recursive: true });
1197
+
1198
+ // Create SKILL.md
1199
+ const skillPath = join(skillDir, "SKILL.md");
1200
+ const skillContent = generateSkillTemplate(args.name, args.description);
1201
+ await writeFile(skillPath, skillContent, "utf-8");
1202
+ createdFiles.push("SKILL.md");
1203
+
1204
+ // Create scripts/ directory with example
1205
+ if (args.include_example_script !== false) {
1206
+ const scriptsDir = join(skillDir, "scripts");
1207
+ await mkdir(scriptsDir, { recursive: true });
1208
+
1209
+ const exampleScript = `#!/usr/bin/env bash
1210
+ # Example helper script for ${args.name}
1211
+ #
1212
+ # This is a placeholder. Replace with actual implementation or delete.
1213
+ #
1214
+ # Usage: skills_execute(skill: "${args.name}", script: "example.sh")
1215
+
1216
+ echo "Hello from ${args.name} skill!"
1217
+ echo "Project directory: \$1"
1218
+
1219
+ # TODO: Add actual script logic
1220
+ `;
1221
+ const scriptPath = join(scriptsDir, "example.sh");
1222
+ await writeFile(scriptPath, exampleScript, { mode: 0o755 });
1223
+ createdFiles.push("scripts/example.sh");
1224
+ }
1225
+
1226
+ // Create references/ directory with example
1227
+ if (args.include_reference !== false) {
1228
+ const refsDir = join(skillDir, "references");
1229
+ await mkdir(refsDir, { recursive: true });
1230
+
1231
+ const refContent = generateReferenceTemplate(args.name);
1232
+ const refPath = join(refsDir, "guide.md");
1233
+ await writeFile(refPath, refContent, "utf-8");
1234
+ createdFiles.push("references/guide.md");
1235
+ }
1236
+
1237
+ // Invalidate cache
1238
+ invalidateSkillsCache();
1239
+
1240
+ return JSON.stringify(
1241
+ {
1242
+ success: true,
1243
+ skill: args.name,
1244
+ path: skillDir,
1245
+ created_files: createdFiles,
1246
+ next_steps: [
1247
+ "Edit SKILL.md to complete TODO placeholders",
1248
+ "Update the description in frontmatter",
1249
+ "Add specific 'When to Use' scenarios",
1250
+ "Add actionable instructions",
1251
+ "Delete unused sections and placeholder files",
1252
+ "Test with skills_use to verify it works",
1253
+ ],
1254
+ tips: [
1255
+ "Good descriptions explain WHEN to use, not just WHAT it does",
1256
+ "Instructions should be imperative: 'Do X' not 'You should do X'",
1257
+ "Include realistic examples with user requests",
1258
+ "Progressive disclosure: keep SKILL.md lean, use references/ for details",
1259
+ ],
1260
+ },
1261
+ null,
1262
+ 2,
1263
+ );
1264
+ } catch (error) {
1265
+ return JSON.stringify(
1266
+ {
1267
+ success: false,
1268
+ error: `Failed to initialize skill: ${error instanceof Error ? error.message : String(error)}`,
1269
+ partial_files: createdFiles,
1270
+ },
1271
+ null,
1272
+ 2,
1273
+ );
1274
+ }
1275
+ },
1276
+ });
1277
+
1278
+ // =============================================================================
1279
+ // Tool Registry
1280
+ // =============================================================================
1281
+
1282
+ /**
1283
+ * All skills tools for plugin registration
1284
+ */
1285
+ export const skillsTools = {
1286
+ skills_list,
1287
+ skills_use,
1288
+ skills_execute,
1289
+ skills_read,
1290
+ skills_create,
1291
+ skills_update,
1292
+ skills_delete,
1293
+ skills_add_script,
1294
+ skills_init,
1295
+ };
1296
+
1297
+ // =============================================================================
1298
+ // Swarm Integration
1299
+ // =============================================================================
1300
+
1301
+ /**
1302
+ * Get skill context for swarm task decomposition
1303
+ *
1304
+ * Returns a summary of available skills that can be referenced
1305
+ * in subtask prompts for specialized handling.
1306
+ */
1307
+ export async function getSkillsContextForSwarm(): Promise<string> {
1308
+ const skills = await listSkills();
1309
+
1310
+ if (skills.length === 0) {
1311
+ return "";
1312
+ }
1313
+
1314
+ const skillsList = skills
1315
+ .map((s) => `- ${s.name}: ${s.description}`)
1316
+ .join("\n");
1317
+
1318
+ return `
1319
+ ## Available Skills
1320
+
1321
+ The following skills are available in this project and can be activated
1322
+ with \`skills_use\` when relevant to subtasks:
1323
+
1324
+ ${skillsList}
1325
+
1326
+ Consider which skills may be helpful for each subtask.`;
1327
+ }
1328
+
1329
+ /**
1330
+ * Find skills relevant to a task description
1331
+ *
1332
+ * Simple keyword matching to suggest skills for a task.
1333
+ * Returns skill names that may be relevant.
1334
+ */
1335
+ export async function findRelevantSkills(
1336
+ taskDescription: string,
1337
+ ): Promise<string[]> {
1338
+ const skills = await discoverSkills();
1339
+ const relevant: string[] = [];
1340
+ const taskLower = taskDescription.toLowerCase();
1341
+
1342
+ for (const [name, skill] of skills) {
1343
+ const descLower = skill.metadata.description.toLowerCase();
1344
+
1345
+ // Check if task matches skill description keywords
1346
+ const keywords = descLower.split(/\s+/).filter((w) => w.length > 4);
1347
+ const taskWords = taskLower.split(/\s+/);
1348
+
1349
+ const matches = keywords.filter((k) =>
1350
+ taskWords.some((w) => w.includes(k) || k.includes(w)),
1351
+ );
1352
+
1353
+ // Also check tags
1354
+ const tagMatches =
1355
+ skill.metadata.tags?.filter((t) => taskLower.includes(t.toLowerCase())) ||
1356
+ [];
1357
+
1358
+ if (matches.length >= 2 || tagMatches.length > 0) {
1359
+ relevant.push(name);
1360
+ }
1361
+ }
1362
+
1363
+ return relevant;
1364
+ }