@yeaft/webchat-agent 0.1.456 → 0.1.460

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.456",
3
+ "version": "0.1.460",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/skills.js CHANGED
@@ -1,24 +1,51 @@
1
1
  /**
2
2
  * skills.js — Skill loading and management
3
3
  *
4
- * Skills are markdown files in ~/.yeaft/skills/ that define
5
- * specialized behaviors or workflows. They are loaded at startup
6
- * and injected into the system prompt when relevant.
4
+ * Skills can be:
5
+ * 1. Single .md files: skills/my-skill.md (legacy, still supported)
6
+ * 2. Directories: skills/my-skill/SKILL.md + references/ + templates/
7
7
  *
8
- * Skill format (skills/my-skill.md):
9
- * ---
10
- * name: my-skill
11
- * description: Does something useful
12
- * trigger: "when user asks about X"
13
- * mode: chat | work | both
14
- * ---
15
- * # Skill instructions here...
8
+ * Directory-based skills support progressive disclosure:
9
+ * - list() → metadata only (name, description, trigger, mode, category, platforms)
10
+ * - view() → full SKILL.md content + linked files from references/ and templates/
11
+ *
12
+ * Categories are derived from nested directories:
13
+ * skills/coding/review/SKILL.md → category = "coding/review"
14
+ *
15
+ * Frontmatter fields:
16
+ * name, description, trigger, mode, platforms, keywords
17
+ *
18
+ * - trigger: string (keyword list) OR /regex/ pattern
19
+ * - keywords: array of match keywords (alternative to trigger)
20
+ * - platforms: array of ["macos", "linux", "windows"]
16
21
  *
17
22
  * Reference: yeaft-unify-design.md §8, yeaft-unify-core-systems.md
18
23
  */
19
24
 
20
- import { existsSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from 'fs';
21
- import { join, basename } from 'path';
25
+ import { existsSync, readFileSync, readdirSync, writeFileSync, unlinkSync, mkdirSync, statSync } from 'fs';
26
+ import { join, basename, relative, dirname, sep } from 'path';
27
+ import { platform } from 'os';
28
+
29
+ // ─── Platform Matching ────────────────────────────────────
30
+
31
+ const PLATFORM_MAP = {
32
+ macos: 'darwin',
33
+ linux: 'linux',
34
+ windows: 'win32',
35
+ darwin: 'darwin',
36
+ win32: 'win32',
37
+ };
38
+
39
+ /**
40
+ * Check if a skill matches the current platform.
41
+ * @param {string[]} [platforms] — e.g. ['macos', 'linux']
42
+ * @returns {boolean}
43
+ */
44
+ export function matchesPlatform(platforms) {
45
+ if (!platforms || platforms.length === 0) return true;
46
+ const currentPlatform = platform();
47
+ return platforms.some(p => PLATFORM_MAP[p.toLowerCase()] === currentPlatform);
48
+ }
22
49
 
23
50
  // ─── Skill Parsing ─────────────────────────────────────────
24
51
 
@@ -26,17 +53,23 @@ import { join, basename } from 'path';
26
53
  * @typedef {Object} Skill
27
54
  * @property {string} name — unique skill name
28
55
  * @property {string} description — human-readable description
29
- * @property {string} trigger — when this skill should be invoked
56
+ * @property {string} trigger — when this skill should be invoked (keyword string or /regex/)
57
+ * @property {string[]} [keywords] — explicit match keywords
30
58
  * @property {string} mode — 'chat' | 'work' | 'both'
59
+ * @property {string[]} [platforms] — platform filter e.g. ['macos', 'linux']
60
+ * @property {string} [category] — derived from directory path
31
61
  * @property {string} content — full skill instructions (markdown body)
32
- * @property {string} _filenamesource filename
62
+ * @property {string} _source'file' | 'directory'
63
+ * @property {string} _path — full path to skill file or directory
64
+ * @property {string[]} [_references] — filenames in references/ dir
65
+ * @property {string[]} [_templates] — filenames in templates/ dir
33
66
  */
34
67
 
35
68
  /**
36
- * Parse a skill .md file.
69
+ * Parse YAML-like frontmatter from a skill .md file.
37
70
  *
38
71
  * @param {string} raw — raw file content
39
- * @param {string} filename — source filename
72
+ * @param {string} [filename] — source filename (for name fallback)
40
73
  * @returns {Skill|null}
41
74
  */
42
75
  export function parseSkill(raw, filename = '') {
@@ -50,8 +83,9 @@ export function parseSkill(raw, filename = '') {
50
83
 
51
84
  const skill = {
52
85
  content: body,
53
- _filename: filename,
54
86
  mode: 'both',
87
+ _source: 'file',
88
+ _path: '',
55
89
  };
56
90
 
57
91
  for (const line of frontmatter.split('\n')) {
@@ -65,6 +99,18 @@ export function parseSkill(raw, filename = '') {
65
99
  case 'description': skill.description = value; break;
66
100
  case 'trigger': skill.trigger = value; break;
67
101
  case 'mode': skill.mode = value; break;
102
+ case 'platforms': {
103
+ // Parse [macos, linux] or macos, linux
104
+ const cleaned = value.replace(/^\[|\]$/g, '');
105
+ skill.platforms = cleaned.split(',').map(t => t.trim()).filter(Boolean);
106
+ break;
107
+ }
108
+ case 'keywords': {
109
+ const cleaned = value.replace(/^\[|\]$/g, '');
110
+ skill.keywords = cleaned.split(',').map(t => t.trim()).filter(Boolean);
111
+ break;
112
+ }
113
+ case 'category': skill.category = value; break;
68
114
  }
69
115
  }
70
116
 
@@ -89,16 +135,187 @@ export function serializeSkill(skill) {
89
135
  `description: ${skill.description || ''}`,
90
136
  `trigger: ${skill.trigger || ''}`,
91
137
  `mode: ${skill.mode || 'both'}`,
92
- '---',
93
138
  ];
94
139
 
140
+ if (skill.platforms && skill.platforms.length > 0) {
141
+ fm.push(`platforms: [${skill.platforms.join(', ')}]`);
142
+ }
143
+
144
+ if (skill.keywords && skill.keywords.length > 0) {
145
+ fm.push(`keywords: [${skill.keywords.join(', ')}]`);
146
+ }
147
+
148
+ if (skill.category) {
149
+ fm.push(`category: ${skill.category}`);
150
+ }
151
+
152
+ fm.push('---');
153
+
95
154
  return fm.join('\n') + '\n\n' + (skill.content || '');
96
155
  }
97
156
 
157
+ // ─── Directory Scanning ───────────────────────────────────
158
+
159
+ /**
160
+ * List files in a subdirectory (non-recursive).
161
+ * @param {string} dir
162
+ * @returns {string[]}
163
+ */
164
+ function listSubdirFiles(dir) {
165
+ if (!existsSync(dir)) return [];
166
+ try {
167
+ return readdirSync(dir).filter(f => {
168
+ try { return statSync(join(dir, f)).isFile(); } catch { return false; }
169
+ });
170
+ } catch { return []; }
171
+ }
172
+
173
+ /**
174
+ * Recursively discover skills in a directory.
175
+ * Supports:
176
+ * - skills/foo.md (single-file skill)
177
+ * - skills/foo/SKILL.md (directory-based skill)
178
+ * - skills/category/foo/SKILL.md (nested category)
179
+ *
180
+ * @param {string} rootDir — skills root directory
181
+ * @param {string} [subPath] — relative path from root (for category derivation)
182
+ * @returns {{ skills: Skill[], errors: string[] }}
183
+ */
184
+ function discoverSkills(rootDir, subPath = '') {
185
+ const dir = subPath ? join(rootDir, subPath) : rootDir;
186
+ const skills = [];
187
+ const errors = [];
188
+
189
+ if (!existsSync(dir)) return { skills, errors };
190
+
191
+ let entries;
192
+ try {
193
+ entries = readdirSync(dir, { withFileTypes: true });
194
+ } catch (err) {
195
+ errors.push(`Cannot read directory ${dir}: ${err.message}`);
196
+ return { skills, errors };
197
+ }
198
+
199
+ for (const entry of entries) {
200
+ const entryPath = join(dir, entry.name);
201
+ const relPath = subPath ? join(subPath, entry.name) : entry.name;
202
+
203
+ if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'SKILL.md') {
204
+ // Single-file skill (legacy format)
205
+ try {
206
+ const raw = readFileSync(entryPath, 'utf8');
207
+ const skill = parseSkill(raw, entry.name);
208
+ if (skill && skill.name) {
209
+ skill._source = 'file';
210
+ skill._path = entryPath;
211
+ // Category from parent directory path
212
+ if (subPath) {
213
+ skill.category = skill.category || subPath.split(sep).join('/');
214
+ }
215
+ skills.push(skill);
216
+ } else {
217
+ errors.push(`Failed to parse skill: ${relPath}`);
218
+ }
219
+ } catch (err) {
220
+ errors.push(`Error loading ${relPath}: ${err.message}`);
221
+ }
222
+ } else if (entry.isDirectory()) {
223
+ // Check for SKILL.md inside this directory
224
+ const skillMdPath = join(entryPath, 'SKILL.md');
225
+ if (existsSync(skillMdPath)) {
226
+ // Directory-based skill
227
+ try {
228
+ const raw = readFileSync(skillMdPath, 'utf8');
229
+ const skill = parseSkill(raw, entry.name);
230
+ if (skill && skill.name) {
231
+ skill._source = 'directory';
232
+ skill._path = entryPath;
233
+ // Derive category from parent path
234
+ if (subPath) {
235
+ skill.category = skill.category || subPath.split(sep).join('/');
236
+ }
237
+ // Discover linked files
238
+ skill._references = listSubdirFiles(join(entryPath, 'references'));
239
+ skill._templates = listSubdirFiles(join(entryPath, 'templates'));
240
+ skills.push(skill);
241
+ } else {
242
+ errors.push(`Failed to parse skill: ${relPath}/SKILL.md`);
243
+ }
244
+ } catch (err) {
245
+ errors.push(`Error loading ${relPath}/SKILL.md: ${err.message}`);
246
+ }
247
+ } else {
248
+ // No SKILL.md — treat as category directory, recurse
249
+ const sub = discoverSkills(rootDir, relPath);
250
+ skills.push(...sub.skills);
251
+ errors.push(...sub.errors);
252
+ }
253
+ }
254
+ }
255
+
256
+ return { skills, errors };
257
+ }
258
+
259
+ // ─── Trigger Matching ─────────────────────────────────────
260
+
261
+ /**
262
+ * Test if a trigger string matches a prompt.
263
+ * Supports:
264
+ * - /regex/ patterns (trigger starts and ends with /)
265
+ * - keyword-based matching (word overlap with stem matching)
266
+ *
267
+ * @param {string} trigger
268
+ * @param {string} prompt — lowercase prompt
269
+ * @param {string[]} promptWords — cleaned prompt words
270
+ * @returns {boolean}
271
+ */
272
+ function matchTrigger(trigger, prompt, promptWords) {
273
+ // Regex trigger: /pattern/flags
274
+ const regexMatch = trigger.match(/^\/(.+)\/([gimsuy]*)$/);
275
+ if (regexMatch) {
276
+ try {
277
+ const re = new RegExp(regexMatch[1], regexMatch[2] || 'i');
278
+ return re.test(prompt);
279
+ } catch {
280
+ // Invalid regex, fall through to keyword matching
281
+ }
282
+ }
283
+
284
+ // Keyword-based matching
285
+ const triggerWords = trigger.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(w => w.length > 2);
286
+ if (triggerWords.length === 0) return false;
287
+
288
+ const cleanPrompt = prompt.replace(/[^\w\s]/g, '');
289
+ const matchCount = triggerWords.filter(tw => {
290
+ if (cleanPrompt.includes(tw)) return true;
291
+ const twStem = tw.slice(0, Math.min(tw.length, 4));
292
+ return promptWords.some(pw => {
293
+ if (pw.includes(tw) || tw.includes(pw)) return true;
294
+ const pwStem = pw.slice(0, Math.min(pw.length, 4));
295
+ return twStem.length >= 4 && pwStem.length >= 4 && twStem === pwStem;
296
+ });
297
+ }).length;
298
+
299
+ return matchCount >= 1 && matchCount >= Math.ceil(triggerWords.length * 0.3);
300
+ }
301
+
302
+ /**
303
+ * Test if keywords list matches a prompt.
304
+ * Any keyword found in prompt = match.
305
+ *
306
+ * @param {string[]} keywords
307
+ * @param {string} prompt — lowercase prompt
308
+ * @returns {boolean}
309
+ */
310
+ function matchKeywords(keywords, prompt) {
311
+ return keywords.some(kw => prompt.includes(kw.toLowerCase()));
312
+ }
313
+
98
314
  // ─── SkillManager ──────────────────────────────────────────
99
315
 
100
316
  /**
101
317
  * SkillManager — loads, indexes, and queries skills.
318
+ * Supports both single-file and directory-based skills.
102
319
  */
103
320
  export class SkillManager {
104
321
  /** @type {Map<string, Skill>} */
@@ -114,32 +331,29 @@ export class SkillManager {
114
331
  this.#skillsDir = join(yeaftDir, 'skills');
115
332
  }
116
333
 
334
+ /** The skills root directory path. */
335
+ get skillsDir() {
336
+ return this.#skillsDir;
337
+ }
338
+
117
339
  /**
118
- * Load all skills from the skills directory.
340
+ * Load all skills from the skills directory (recursive).
119
341
  *
120
342
  * @returns {{ loaded: number, errors: string[] }}
121
343
  */
122
344
  load() {
123
345
  this.#skills.clear();
124
- const errors = [];
125
346
 
126
347
  if (!existsSync(this.#skillsDir)) {
127
348
  return { loaded: 0, errors: [] };
128
349
  }
129
350
 
130
- const files = readdirSync(this.#skillsDir).filter(f => f.endsWith('.md'));
351
+ const { skills, errors } = discoverSkills(this.#skillsDir);
131
352
 
132
- for (const file of files) {
133
- try {
134
- const raw = readFileSync(join(this.#skillsDir, file), 'utf8');
135
- const skill = parseSkill(raw, file);
136
- if (skill && skill.name) {
137
- this.#skills.set(skill.name, skill);
138
- } else {
139
- errors.push(`Failed to parse skill: ${file}`);
140
- }
141
- } catch (err) {
142
- errors.push(`Error loading ${file}: ${err.message}`);
353
+ for (const skill of skills) {
354
+ // Platform filtering at load time
355
+ if (matchesPlatform(skill.platforms)) {
356
+ this.#skills.set(skill.name, skill);
143
357
  }
144
358
  }
145
359
 
@@ -167,20 +381,71 @@ export class SkillManager {
167
381
  }
168
382
 
169
383
  /**
170
- * List all skills, optionally filtered by mode.
384
+ * List all skills (metadata only — no content), optionally filtered by mode.
385
+ * This is the "progressive disclosure" list tier.
171
386
  *
172
387
  * @param {string} [mode] — 'chat' | 'work' | undefined (all)
173
- * @returns {Skill[]}
388
+ * @returns {Array<{ name: string, description: string, trigger: string, mode: string, category?: string, platforms?: string[], keywords?: string[], source: string, hasReferences: boolean, hasTemplates: boolean }>}
174
389
  */
175
390
  list(mode) {
176
391
  const skills = [...this.#skills.values()];
177
- if (!mode) return skills;
392
+ const filtered = mode ? skills.filter(s => s.mode === 'both' || s.mode === mode) : skills;
393
+
394
+ return filtered.map(s => ({
395
+ name: s.name,
396
+ description: s.description || '',
397
+ trigger: s.trigger || '',
398
+ mode: s.mode || 'both',
399
+ category: s.category || undefined,
400
+ platforms: s.platforms || undefined,
401
+ keywords: s.keywords || undefined,
402
+ source: s._source,
403
+ hasReferences: (s._references && s._references.length > 0) || false,
404
+ hasTemplates: (s._templates && s._templates.length > 0) || false,
405
+ }));
406
+ }
407
+
408
+ /**
409
+ * View a skill's full content + linked files (progressive disclosure view tier).
410
+ *
411
+ * @param {string} name — skill name
412
+ * @param {string} [filePath] — specific linked file to read (e.g. "references/style-guide.md")
413
+ * @returns {{ skill: Skill, references: string[], templates: string[], linkedContent?: string } | null}
414
+ */
415
+ view(name, filePath) {
416
+ const skill = this.#skills.get(name);
417
+ if (!skill) return null;
418
+
419
+ const result = {
420
+ skill,
421
+ references: skill._references || [],
422
+ templates: skill._templates || [],
423
+ };
424
+
425
+ // Read a specific linked file if requested
426
+ if (filePath && skill._source === 'directory') {
427
+ const fullPath = join(skill._path, filePath);
428
+ // Security: ensure path doesn't escape skill directory
429
+ const resolved = join(skill._path, filePath);
430
+ if (!resolved.startsWith(skill._path)) {
431
+ result.linkedContent = 'Error: path traversal not allowed';
432
+ } else if (existsSync(fullPath)) {
433
+ try {
434
+ result.linkedContent = readFileSync(fullPath, 'utf8');
435
+ } catch (err) {
436
+ result.linkedContent = `Error reading file: ${err.message}`;
437
+ }
438
+ } else {
439
+ result.linkedContent = `File not found: ${filePath}`;
440
+ }
441
+ }
178
442
 
179
- return skills.filter(s => s.mode === 'both' || s.mode === mode);
443
+ return result;
180
444
  }
181
445
 
182
446
  /**
183
- * Find skills relevant to a prompt (simple keyword matching).
447
+ * Find skills relevant to a prompt.
448
+ * Enhanced matching: regex triggers, keyword lists, name/description match.
184
449
  *
185
450
  * @param {string} prompt — user's prompt
186
451
  * @param {string} [mode] — filter by mode
@@ -190,38 +455,28 @@ export class SkillManager {
190
455
  if (!prompt) return [];
191
456
 
192
457
  const lowerPrompt = prompt.toLowerCase();
193
- // Strip punctuation and split on whitespace for clean word matching
194
458
  const cleanPrompt = lowerPrompt.replace(/[^\w\s]/g, '');
195
459
  const promptWords = cleanPrompt.split(/\s+/).filter(w => w.length > 2);
196
- const skills = this.list(mode);
197
-
198
- return skills.filter(skill => {
199
- // Check trigger match — any trigger keyword found in prompt
200
- if (skill.trigger) {
201
- const triggerWords = skill.trigger.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(w => w.length > 2);
202
- // Count matches: exact word, substring, or shared stem (first 4 chars)
203
- const matchCount = triggerWords.filter(tw => {
204
- if (cleanPrompt.includes(tw)) return true;
205
- // Stem matching: if trigger word and prompt word share a 4+ char prefix
206
- const twStem = tw.slice(0, Math.min(tw.length, 4));
207
- return promptWords.some(pw => {
208
- if (pw.includes(tw) || tw.includes(pw)) return true;
209
- const pwStem = pw.slice(0, Math.min(pw.length, 4));
210
- return twStem.length >= 4 && pwStem.length >= 4 && twStem === pwStem;
211
- });
212
- }).length;
213
- // At least 1 meaningful match and ≥30% of trigger words
214
- if (matchCount >= 1 && matchCount >= Math.ceil(triggerWords.length * 0.3)) {
215
- return true;
216
- }
460
+ const allSkills = [...this.#skills.values()];
461
+ const filtered = mode ? allSkills.filter(s => s.mode === 'both' || s.mode === mode) : allSkills;
462
+
463
+ return filtered.filter(skill => {
464
+ // 1. Regex or keyword trigger match
465
+ if (skill.trigger && matchTrigger(skill.trigger, lowerPrompt, promptWords)) {
466
+ return true;
467
+ }
468
+
469
+ // 2. Explicit keywords match
470
+ if (skill.keywords && skill.keywords.length > 0 && matchKeywords(skill.keywords, lowerPrompt)) {
471
+ return true;
217
472
  }
218
473
 
219
- // Check name match
474
+ // 3. Name match
220
475
  if (lowerPrompt.includes(skill.name.toLowerCase())) {
221
476
  return true;
222
477
  }
223
478
 
224
- // Check description match
479
+ // 4. Description match
225
480
  if (skill.description && lowerPrompt.includes(skill.description.toLowerCase())) {
226
481
  return true;
227
482
  }
@@ -231,7 +486,7 @@ export class SkillManager {
231
486
  }
232
487
 
233
488
  /**
234
- * Add or update a skill.
489
+ * Add or update a skill (single-file format).
235
490
  *
236
491
  * @param {Skill} skill
237
492
  * @returns {string} — filename
@@ -242,14 +497,18 @@ export class SkillManager {
242
497
  const filename = `${skill.name}.md`;
243
498
  const filePath = join(this.#skillsDir, filename);
244
499
 
500
+ if (!existsSync(this.#skillsDir)) {
501
+ mkdirSync(this.#skillsDir, { recursive: true });
502
+ }
503
+
245
504
  writeFileSync(filePath, serializeSkill(skill), 'utf8');
246
- this.#skills.set(skill.name, { ...skill, _filename: filename });
505
+ this.#skills.set(skill.name, { ...skill, _source: 'file', _path: filePath });
247
506
 
248
507
  return filename;
249
508
  }
250
509
 
251
510
  /**
252
- * Remove a skill.
511
+ * Remove a skill (supports both file and directory skills).
253
512
  *
254
513
  * @param {string} name
255
514
  * @returns {boolean}
@@ -258,11 +517,14 @@ export class SkillManager {
258
517
  const skill = this.#skills.get(name);
259
518
  if (!skill) return false;
260
519
 
261
- const filePath = join(this.#skillsDir, skill._filename || `${name}.md`);
262
- try {
263
- unlinkSync(filePath);
264
- } catch {
265
- // File might not exist
520
+ if (skill._source === 'directory' && skill._path) {
521
+ // For directory skills, we only delete the SKILL.md to "deactivate"
522
+ // Full directory removal is left to the user (too dangerous to rm -rf)
523
+ const skillMd = join(skill._path, 'SKILL.md');
524
+ try { unlinkSync(skillMd); } catch { /* noop */ }
525
+ } else {
526
+ const filePath = skill._path || join(this.#skillsDir, `${name}.md`);
527
+ try { unlinkSync(filePath); } catch { /* noop */ }
266
528
  }
267
529
 
268
530
  this.#skills.delete(name);
@@ -296,6 +558,19 @@ export class SkillManager {
296
558
  return relevant.map(s => this.getPromptContent(s.name)).join('\n\n');
297
559
  }
298
560
 
561
+ /**
562
+ * List unique categories across all loaded skills.
563
+ *
564
+ * @returns {string[]}
565
+ */
566
+ listCategories() {
567
+ const categories = new Set();
568
+ for (const skill of this.#skills.values()) {
569
+ if (skill.category) categories.add(skill.category);
570
+ }
571
+ return [...categories].sort();
572
+ }
573
+
299
574
  /** Number of loaded skills. */
300
575
  get size() {
301
576
  return this.#skills.size;
@@ -1,8 +1,14 @@
1
1
  /**
2
- * skill.js — Skill invocation tool
2
+ * skill.js — Skill invocation tool (progressive disclosure)
3
3
  *
4
- * Allows the LLM to load and activate skills from the skill library.
5
- * Skills are specialized behaviors defined in ~/.yeaft/skills/*.md.
4
+ * Actions:
5
+ * list → metadata only (name, description, category, source)
6
+ * view → full skill content + linked files listing
7
+ * search → find relevant skills for a query
8
+ * load → alias for view (backward compat)
9
+ *
10
+ * Directory-based skills (SKILL.md + references/ + templates/) support
11
+ * reading linked files via the view action's filePath parameter.
6
12
  *
7
13
  * Reference: yeaft-unify-design.md §8
8
14
  */
@@ -11,31 +17,42 @@ import { defineTool } from './types.js';
11
17
 
12
18
  export default defineTool({
13
19
  name: 'Skill',
14
- description: `Load and activate a skill from the Yeaft skill library.
20
+ description: `Load and query skills from the Yeaft skill library.
15
21
 
16
- Skills are specialized behaviors or workflows defined in ~/.yeaft/skills/.
17
- Use this tool to:
18
- - List available skills
19
- - Load a specific skill's instructions
20
- - Find relevant skills for the current context
22
+ Skills are specialized behaviors or workflows in ~/.yeaft/skills/.
23
+ Two formats supported:
24
+ - Single file: skills/my-skill.md
25
+ - Directory: skills/my-skill/SKILL.md + references/ + templates/
21
26
 
22
- Skills provide domain-specific guidance and workflows that enhance your capabilities.`,
27
+ Actions:
28
+ - "list" — list all skills (metadata only: name, description, category)
29
+ - "view" — view a skill's full content. For directory skills, also lists linked files. Pass filePath to read a specific reference/template.
30
+ - "search" — find relevant skills for a query string
31
+ - "load" — alias for "view" (backward compatible)`,
23
32
  parameters: {
24
33
  type: 'object',
25
34
  properties: {
26
35
  action: {
27
36
  type: 'string',
28
- enum: ['list', 'load', 'search'],
29
- description: '"list" lists all skills, "load" loads a specific skill, "search" finds relevant skills',
37
+ enum: ['list', 'view', 'load', 'search'],
38
+ description: '"list" lists all skills, "view"/"load" loads a specific skill, "search" finds relevant skills',
30
39
  },
31
40
  name: {
32
41
  type: 'string',
33
- description: 'Skill name (for "load" action)',
42
+ description: 'Skill name (for "view"/"load" action)',
34
43
  },
35
44
  query: {
36
45
  type: 'string',
37
46
  description: 'Search query (for "search" action)',
38
47
  },
48
+ filePath: {
49
+ type: 'string',
50
+ description: 'Read a linked file from a directory skill (e.g. "references/style-guide.md")',
51
+ },
52
+ category: {
53
+ type: 'string',
54
+ description: 'Filter by category (for "list" action)',
55
+ },
39
56
  },
40
57
  required: ['action'],
41
58
  },
@@ -53,36 +70,61 @@ Skills provide domain-specific guidance and workflows that enhance your capabili
53
70
 
54
71
  switch (input.action) {
55
72
  case 'list': {
56
- const skills = skillManager.list();
73
+ let skills = skillManager.list();
57
74
  if (skills.length === 0) {
58
75
  return JSON.stringify({
59
76
  skills: [],
60
- message: 'No skills found. Add .md files to ~/.yeaft/skills/ to create skills.',
77
+ categories: [],
78
+ message: 'No skills found. Add .md files or directories with SKILL.md to ~/.yeaft/skills/',
61
79
  });
62
80
  }
81
+ // Filter by category if specified
82
+ if (input.category) {
83
+ skills = skills.filter(s => s.category === input.category || (s.category && s.category.startsWith(input.category + '/')));
84
+ }
63
85
  return JSON.stringify({
64
- skills: skills.map(s => ({
65
- name: s.name,
66
- description: s.description || '',
67
- trigger: s.trigger || '',
68
- mode: s.mode || 'both',
69
- })),
86
+ skills,
87
+ categories: skillManager.listCategories(),
70
88
  totalCount: skills.length,
71
89
  }, null, 2);
72
90
  }
73
91
 
92
+ case 'view':
74
93
  case 'load': {
75
94
  if (!input.name) {
76
- return JSON.stringify({ error: 'Skill name is required for "load" action' });
95
+ return JSON.stringify({ error: 'Skill name is required for "view" action' });
77
96
  }
78
- const content = skillManager.getPromptContent(input.name);
79
- if (!content) {
97
+ const result = skillManager.view(input.name, input.filePath);
98
+ if (!result) {
80
99
  return JSON.stringify({
81
100
  error: `Skill "${input.name}" not found`,
82
101
  available: skillManager.list().map(s => s.name),
83
102
  });
84
103
  }
85
- return content;
104
+
105
+ // If reading a specific linked file, return just that content
106
+ if (input.filePath && result.linkedContent !== undefined) {
107
+ return result.linkedContent;
108
+ }
109
+
110
+ // Return full skill content + linked file listing
111
+ const output = {
112
+ name: result.skill.name,
113
+ description: result.skill.description || '',
114
+ mode: result.skill.mode,
115
+ category: result.skill.category || null,
116
+ source: result.skill._source,
117
+ content: result.skill.content,
118
+ };
119
+
120
+ if (result.references.length > 0) {
121
+ output.references = result.references;
122
+ }
123
+ if (result.templates.length > 0) {
124
+ output.templates = result.templates;
125
+ }
126
+
127
+ return JSON.stringify(output, null, 2);
86
128
  }
87
129
 
88
130
  case 'search': {
@@ -95,13 +137,15 @@ Skills provide domain-specific guidance and workflows that enhance your capabili
95
137
  name: s.name,
96
138
  description: s.description || '',
97
139
  trigger: s.trigger || '',
140
+ category: s.category || null,
141
+ source: s._source,
98
142
  })),
99
143
  totalResults: results.length,
100
144
  }, null, 2);
101
145
  }
102
146
 
103
147
  default:
104
- return JSON.stringify({ error: `Unknown action: ${input.action}. Use "list", "load", or "search".` });
148
+ return JSON.stringify({ error: `Unknown action: ${input.action}. Use "list", "view", or "search".` });
105
149
  }
106
150
  },
107
151
  });