Nymbo commited on
Commit
a684bf6
·
verified ·
1 Parent(s): b4f142c

ADDING PROPER SUPPORT FOR SKILLS, NEW AGENT_SKILLS TOOL ADDED

Browse files
Files changed (1) hide show
  1. Modules/Agent_Skills.py +792 -0
Modules/Agent_Skills.py ADDED
@@ -0,0 +1,792 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Agent Skills Module for Nymbo-Tools MCP Server.
5
+
6
+ Provides structured skill discovery, activation, validation, and resource access
7
+ following the Agent Skills specification (https://agentskills.io).
8
+
9
+ Skills are directories containing a SKILL.md file with YAML frontmatter (name, description)
10
+ and Markdown instructions. This tool enables agents to efficiently discover and use skills
11
+ through progressive disclosure: low-token metadata discovery, on-demand full activation,
12
+ and targeted resource access.
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import unicodedata
19
+ from pathlib import Path
20
+ from typing import Annotated, Optional
21
+
22
+ import gradio as gr
23
+
24
+ from app import _log_call_end, _log_call_start, _truncate_for_log
25
+ from ._docstrings import autodoc
26
+ from .File_System import ROOT_DIR, _display_path
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Constants
31
+ # ---------------------------------------------------------------------------
32
+
33
+ SKILLS_SUBDIR = "Skills" # Subdirectory under ROOT_DIR containing skills
34
+ MAX_SKILL_NAME_LENGTH = 64
35
+ MAX_DESCRIPTION_LENGTH = 1024
36
+ MAX_COMPATIBILITY_LENGTH = 500
37
+
38
+ ALLOWED_FRONTMATTER_FIELDS = {
39
+ "name",
40
+ "description",
41
+ "license",
42
+ "allowed-tools",
43
+ "metadata",
44
+ "compatibility",
45
+ }
46
+
47
+ TOOL_SUMMARY = (
48
+ "Discover, inspect, validate, and access Agent Skills. "
49
+ "Actions: discover (list all skills), info (get SKILL.md contents), "
50
+ "resources (list/read bundled files), validate (check format), search (find by keyword). "
51
+ "Skills provide structured instructions for specialized tasks. "
52
+ "Use in combination with the `Shell_Command` and `File_System` tools."
53
+ )
54
+
55
+ HELP_TEXT = """\
56
+ Agent Skills — actions and usage
57
+
58
+ Skills are directories containing a SKILL.md file with YAML frontmatter (name, description)
59
+ and Markdown instructions. They live under /Skills/ in the filesystem root.
60
+
61
+ Actions:
62
+ - discover: List all available skills with their metadata (name, description, location)
63
+ - info: Get the full contents of a specific skill's SKILL.md file
64
+ - resources: List or read files within a skill's bundled directories (scripts/, references/, assets/)
65
+ - validate: Check if a skill conforms to the Agent Skills specification
66
+ - search: Find skills by keyword in name or description
67
+ - help: Show this guide
68
+
69
+ Examples:
70
+ - Discover all skills: action="discover"
71
+ - Get skill info: action="info", skill_name="pdf"
72
+ - List skill resources: action="resources", skill_name="mcp-builder"
73
+ - Read a resource: action="resources", skill_name="pdf", resource_path="references/forms.md"
74
+ - Validate a skill: action="validate", skill_name="pdf"
75
+ - Search for skills: action="search", query="MCP"
76
+ """
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Skills Root Resolution
81
+ # ---------------------------------------------------------------------------
82
+
83
+ def _get_skills_root() -> Path:
84
+ """Get the absolute path to the skills directory."""
85
+ skills_root = os.getenv("NYMBO_SKILLS_ROOT")
86
+ if skills_root and skills_root.strip():
87
+ return Path(skills_root.strip()).resolve()
88
+ return Path(ROOT_DIR) / SKILLS_SUBDIR
89
+
90
+
91
+ def _fmt_size(num_bytes: int) -> str:
92
+ """Format byte size as human-readable string."""
93
+ units = ["B", "KB", "MB", "GB"]
94
+ size = float(num_bytes)
95
+ for unit in units:
96
+ if size < 1024.0:
97
+ return f"{size:.1f} {unit}"
98
+ size /= 1024.0
99
+ return f"{size:.1f} TB"
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # YAML Frontmatter Parsing (adapted from skills_ref/parser.py)
104
+ # ---------------------------------------------------------------------------
105
+
106
+ class ParseError(Exception):
107
+ """Raised when SKILL.md parsing fails."""
108
+ pass
109
+
110
+
111
+ class ValidationError(Exception):
112
+ """Raised when skill validation fails."""
113
+ def __init__(self, message: str, errors: list[str] | None = None):
114
+ super().__init__(message)
115
+ self.errors = errors if errors is not None else [message]
116
+
117
+
118
+ def _parse_frontmatter(content: str) -> tuple[dict, str]:
119
+ """
120
+ Parse YAML frontmatter from SKILL.md content.
121
+
122
+ Returns (metadata dict, markdown body).
123
+ Raises ParseError if frontmatter is missing or invalid.
124
+ """
125
+ if not content.startswith("---"):
126
+ raise ParseError("SKILL.md must start with YAML frontmatter (---)")
127
+
128
+ parts = content.split("---", 2)
129
+ if len(parts) < 3:
130
+ raise ParseError("SKILL.md frontmatter not properly closed with ---")
131
+
132
+ frontmatter_str = parts[1]
133
+ body = parts[2].strip()
134
+
135
+ # Simple YAML parsing without external dependency
136
+ metadata: dict = {}
137
+ in_metadata_block = False
138
+ metadata_dict: dict = {}
139
+
140
+ for line in frontmatter_str.strip().split("\n"):
141
+ if not line.strip():
142
+ continue
143
+
144
+ if line.strip() == "metadata:":
145
+ in_metadata_block = True
146
+ continue
147
+
148
+ if in_metadata_block:
149
+ if line.startswith(" "):
150
+ match = re.match(r"^\s+(\w+):\s*(.*)$", line)
151
+ if match:
152
+ key = match.group(1).strip()
153
+ value = match.group(2).strip().strip('"').strip("'")
154
+ metadata_dict[key] = value
155
+ continue
156
+ else:
157
+ in_metadata_block = False
158
+ if metadata_dict:
159
+ metadata["metadata"] = metadata_dict
160
+ metadata_dict = {}
161
+
162
+ match = re.match(r"^(\S+):\s*(.*)$", line)
163
+ if match:
164
+ key = match.group(1).strip()
165
+ value = match.group(2).strip()
166
+ if (value.startswith('"') and value.endswith('"')) or \
167
+ (value.startswith("'") and value.endswith("'")):
168
+ value = value[1:-1]
169
+ metadata[key] = value if value else ""
170
+
171
+ if in_metadata_block and metadata_dict:
172
+ metadata["metadata"] = metadata_dict
173
+
174
+ return metadata, body
175
+
176
+
177
+ def _find_skill_md(skill_dir: Path) -> Optional[Path]:
178
+ """Find the SKILL.md file in a skill directory (prefers uppercase)."""
179
+ for name in ("SKILL.md", "skill.md"):
180
+ path = skill_dir / name
181
+ if path.exists():
182
+ return path
183
+ return None
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # Skill Validation (adapted from skills_ref/validator.py)
188
+ # ---------------------------------------------------------------------------
189
+
190
+ def _validate_name(name: str, skill_dir: Path) -> list[str]:
191
+ """Validate skill name format and directory match."""
192
+ errors = []
193
+
194
+ if not name or not isinstance(name, str) or not name.strip():
195
+ errors.append("Field 'name' must be a non-empty string")
196
+ return errors
197
+
198
+ name = unicodedata.normalize("NFKC", name.strip())
199
+
200
+ if len(name) > MAX_SKILL_NAME_LENGTH:
201
+ errors.append(f"Skill name '{name}' exceeds {MAX_SKILL_NAME_LENGTH} character limit ({len(name)} chars)")
202
+
203
+ if name != name.lower():
204
+ errors.append(f"Skill name '{name}' must be lowercase")
205
+
206
+ if name.startswith("-") or name.endswith("-"):
207
+ errors.append("Skill name cannot start or end with a hyphen")
208
+
209
+ if "--" in name:
210
+ errors.append("Skill name cannot contain consecutive hyphens")
211
+
212
+ if not all(c.isalnum() or c == "-" for c in name):
213
+ errors.append(f"Skill name '{name}' contains invalid characters. Only letters, digits, and hyphens allowed.")
214
+
215
+ if skill_dir:
216
+ dir_name = unicodedata.normalize("NFKC", skill_dir.name)
217
+ if dir_name != name:
218
+ errors.append(f"Directory name '{skill_dir.name}' must match skill name '{name}'")
219
+
220
+ return errors
221
+
222
+
223
+ def _validate_description(description: str) -> list[str]:
224
+ """Validate description format."""
225
+ errors = []
226
+
227
+ if not description or not isinstance(description, str) or not description.strip():
228
+ errors.append("Field 'description' must be a non-empty string")
229
+ return errors
230
+
231
+ if len(description) > MAX_DESCRIPTION_LENGTH:
232
+ errors.append(f"Description exceeds {MAX_DESCRIPTION_LENGTH} character limit ({len(description)} chars)")
233
+
234
+ return errors
235
+
236
+
237
+ def _validate_compatibility(compatibility: str) -> list[str]:
238
+ """Validate compatibility format."""
239
+ errors = []
240
+
241
+ if not isinstance(compatibility, str):
242
+ errors.append("Field 'compatibility' must be a string")
243
+ return errors
244
+
245
+ if len(compatibility) > MAX_COMPATIBILITY_LENGTH:
246
+ errors.append(f"Compatibility exceeds {MAX_COMPATIBILITY_LENGTH} character limit ({len(compatibility)} chars)")
247
+
248
+ return errors
249
+
250
+
251
+ def _validate_skill(skill_dir: Path) -> list[str]:
252
+ """Validate a skill directory. Returns list of error messages (empty = valid)."""
253
+ if not skill_dir.exists():
254
+ return [f"Path does not exist: {skill_dir}"]
255
+
256
+ if not skill_dir.is_dir():
257
+ return [f"Not a directory: {skill_dir}"]
258
+
259
+ skill_md = _find_skill_md(skill_dir)
260
+ if skill_md is None:
261
+ return ["Missing required file: SKILL.md"]
262
+
263
+ try:
264
+ content = skill_md.read_text(encoding="utf-8")
265
+ metadata, _ = _parse_frontmatter(content)
266
+ except ParseError as e:
267
+ return [str(e)]
268
+ except Exception as e:
269
+ return [f"Failed to read SKILL.md: {e}"]
270
+
271
+ errors = []
272
+
273
+ extra_fields = set(metadata.keys()) - ALLOWED_FRONTMATTER_FIELDS
274
+ if extra_fields:
275
+ errors.append(f"Unexpected fields in frontmatter: {', '.join(sorted(extra_fields))}")
276
+
277
+ if "name" not in metadata:
278
+ errors.append("Missing required field: name")
279
+ else:
280
+ errors.extend(_validate_name(metadata["name"], skill_dir))
281
+
282
+ if "description" not in metadata:
283
+ errors.append("Missing required field: description")
284
+ else:
285
+ errors.extend(_validate_description(metadata["description"]))
286
+
287
+ if "compatibility" in metadata:
288
+ errors.extend(_validate_compatibility(metadata["compatibility"]))
289
+
290
+ return errors
291
+
292
+
293
+ # ---------------------------------------------------------------------------
294
+ # Skill Discovery and Info
295
+ # ---------------------------------------------------------------------------
296
+
297
+ def _read_skill_properties(skill_dir: Path) -> dict:
298
+ """Read skill properties from SKILL.md frontmatter. Returns dict with metadata."""
299
+ skill_md = _find_skill_md(skill_dir)
300
+ if skill_md is None:
301
+ raise ParseError(f"SKILL.md not found in {skill_dir}")
302
+
303
+ content = skill_md.read_text(encoding="utf-8")
304
+ metadata, body = _parse_frontmatter(content)
305
+
306
+ if "name" not in metadata:
307
+ raise ValidationError("Missing required field: name")
308
+ if "description" not in metadata:
309
+ raise ValidationError("Missing required field: description")
310
+
311
+ return {
312
+ "name": metadata.get("name", "").strip(),
313
+ "description": metadata.get("description", "").strip(),
314
+ "license": metadata.get("license"),
315
+ "compatibility": metadata.get("compatibility"),
316
+ "allowed_tools": metadata.get("allowed-tools"),
317
+ "metadata": metadata.get("metadata", {}),
318
+ "location": str(skill_md),
319
+ "body": body,
320
+ }
321
+
322
+
323
+ def _discover_skills() -> list[dict]:
324
+ """Discover all valid skills in the skills directory."""
325
+ skills_root = _get_skills_root()
326
+
327
+ if not skills_root.exists():
328
+ return []
329
+
330
+ skills = []
331
+ for item in sorted(skills_root.iterdir()):
332
+ if not item.is_dir():
333
+ continue
334
+
335
+ skill_md = _find_skill_md(item)
336
+ if skill_md is None:
337
+ continue
338
+
339
+ try:
340
+ props = _read_skill_properties(item)
341
+ skills.append({
342
+ "name": props["name"],
343
+ "description": props["description"],
344
+ "location": _display_path(str(skill_md)),
345
+ })
346
+ except Exception:
347
+ continue
348
+
349
+ return skills
350
+
351
+
352
+ def _get_skill_info(skill_name: str, offset: int = 0, max_chars: int = 0) -> dict:
353
+ """Get full information for a specific skill."""
354
+ skills_root = _get_skills_root()
355
+ skill_dir = skills_root / skill_name
356
+
357
+ if not skill_dir.exists():
358
+ raise FileNotFoundError(f"Skill not found: {skill_name}")
359
+
360
+ skill_md = _find_skill_md(skill_dir)
361
+ if skill_md is None:
362
+ raise FileNotFoundError(f"SKILL.md not found in skill: {skill_name}")
363
+
364
+ content = skill_md.read_text(encoding="utf-8")
365
+ metadata, body = _parse_frontmatter(content)
366
+
367
+ total_chars = len(body)
368
+ start = max(0, min(offset, total_chars))
369
+ if max_chars > 0:
370
+ end = min(total_chars, start + max_chars)
371
+ else:
372
+ end = total_chars
373
+
374
+ body_chunk = body[start:end]
375
+ truncated = end < total_chars
376
+ next_cursor = end if truncated else None
377
+
378
+ return {
379
+ "name": metadata.get("name", "").strip(),
380
+ "description": metadata.get("description", "").strip(),
381
+ "license": metadata.get("license"),
382
+ "compatibility": metadata.get("compatibility"),
383
+ "allowed_tools": metadata.get("allowed-tools"),
384
+ "metadata": metadata.get("metadata", {}),
385
+ "location": _display_path(str(skill_md)),
386
+ "body": body_chunk,
387
+ "offset": start,
388
+ "total_chars": total_chars,
389
+ "truncated": truncated,
390
+ "next_cursor": next_cursor,
391
+ }
392
+
393
+
394
+ def _list_skill_resources(skill_name: str) -> dict:
395
+ """List resources (scripts, references, assets) within a skill directory."""
396
+ skills_root = _get_skills_root()
397
+ skill_dir = skills_root / skill_name
398
+
399
+ if not skill_dir.exists():
400
+ raise FileNotFoundError(f"Skill not found: {skill_name}")
401
+
402
+ resources = {
403
+ "skill": skill_name,
404
+ "scripts": [],
405
+ "references": [],
406
+ "assets": [],
407
+ "other_files": [],
408
+ }
409
+
410
+ known_dirs = {"scripts", "references", "assets"}
411
+
412
+ for item in sorted(skill_dir.iterdir()):
413
+ if item.name.lower() in ("skill.md",):
414
+ continue
415
+
416
+ if item.is_dir():
417
+ category = item.name.lower()
418
+ if category in known_dirs:
419
+ files = []
420
+ for f in sorted(item.rglob("*")):
421
+ if f.is_file():
422
+ files.append({
423
+ "path": str(f.relative_to(skill_dir)),
424
+ "size": f.stat().st_size,
425
+ })
426
+ resources[category] = files
427
+ elif item.is_file():
428
+ resources["other_files"].append({
429
+ "path": item.name,
430
+ "size": item.stat().st_size,
431
+ })
432
+
433
+ return resources
434
+
435
+
436
+ def _read_skill_resource(skill_name: str, resource_path: str, offset: int = 0, max_chars: int = 3000) -> dict:
437
+ """Read a specific resource file from a skill."""
438
+ skills_root = _get_skills_root()
439
+ skill_dir = skills_root / skill_name
440
+
441
+ if not skill_dir.exists():
442
+ raise FileNotFoundError(f"Skill not found: {skill_name}")
443
+
444
+ resource_file = skill_dir / resource_path
445
+
446
+ try:
447
+ resource_file.resolve().relative_to(skill_dir.resolve())
448
+ except ValueError:
449
+ raise PermissionError(f"Resource path escapes skill directory: {resource_path}")
450
+
451
+ if not resource_file.exists():
452
+ raise FileNotFoundError(f"Resource not found: {resource_path}")
453
+
454
+ if resource_file.is_dir():
455
+ raise IsADirectoryError(f"Path is a directory: {resource_path}")
456
+
457
+ content = resource_file.read_text(encoding="utf-8", errors="replace")
458
+ total_chars = len(content)
459
+
460
+ start = max(0, min(offset, total_chars))
461
+ if max_chars > 0:
462
+ end = min(total_chars, start + max_chars)
463
+ else:
464
+ end = total_chars
465
+
466
+ chunk = content[start:end]
467
+ truncated = end < total_chars
468
+ next_cursor = end if truncated else None
469
+
470
+ return {
471
+ "skill": skill_name,
472
+ "resource": resource_path,
473
+ "content": chunk,
474
+ "size": resource_file.stat().st_size,
475
+ "offset": start,
476
+ "total_chars": total_chars,
477
+ "truncated": truncated,
478
+ "next_cursor": next_cursor,
479
+ }
480
+
481
+
482
+ def _search_skills(query: str) -> list[dict]:
483
+ """Search for skills by keyword in name or description."""
484
+ query_lower = query.lower()
485
+ all_skills = _discover_skills()
486
+
487
+ matches = []
488
+ for skill in all_skills:
489
+ name_match = query_lower in skill["name"].lower()
490
+ desc_match = query_lower in skill["description"].lower()
491
+
492
+ if name_match or desc_match:
493
+ matches.append({
494
+ **skill,
495
+ "match_in": "name" if name_match else "description",
496
+ })
497
+
498
+ return matches
499
+
500
+
501
+ # ---------------------------------------------------------------------------
502
+ # Human-Readable Output Formatters
503
+ # ---------------------------------------------------------------------------
504
+
505
+ def _format_discover(skills: list[dict]) -> str:
506
+ """Format skill discovery results as human-readable text."""
507
+ skills_root = _display_path(str(_get_skills_root()))
508
+ lines = [
509
+ f"Available Skills",
510
+ f"Root: {skills_root}",
511
+ f"Total: {len(skills)} skills",
512
+ "",
513
+ ]
514
+
515
+ if not skills:
516
+ lines.append("No skills found.")
517
+ else:
518
+ for i, skill in enumerate(skills, 1):
519
+ name = skill["name"]
520
+ desc = skill["description"]
521
+ # Truncate long descriptions
522
+ if len(desc) > 100:
523
+ desc = desc[:97] + "..."
524
+ lines.append(f"{i}. {name}")
525
+ lines.append(f" {desc}")
526
+ lines.append("")
527
+
528
+ return "\n".join(lines).strip()
529
+
530
+
531
+ def _format_skill_info(info: dict) -> str:
532
+ """Format skill info as human-readable text."""
533
+ lines = [
534
+ f"Skill: {info['name']}",
535
+ f"Location: {info['location']}",
536
+ "",
537
+ f"Description: {info['description']}",
538
+ ]
539
+
540
+ if info.get("license"):
541
+ lines.append(f"License: {info['license']}")
542
+ if info.get("compatibility"):
543
+ lines.append(f"Compatibility: {info['compatibility']}")
544
+ if info.get("allowed_tools"):
545
+ lines.append(f"Allowed Tools: {info['allowed_tools']}")
546
+ if info.get("metadata"):
547
+ meta_str = ", ".join(f"{k}={v}" for k, v in info["metadata"].items())
548
+ lines.append(f"Metadata: {meta_str}")
549
+
550
+ lines.append("")
551
+ lines.append("--- SKILL.md Body ---")
552
+ if info.get("offset", 0) > 0:
553
+ lines.append(f"(Showing content from offset {info['offset']})")
554
+ lines.append("")
555
+ lines.append(info["body"])
556
+
557
+ if info.get("truncated"):
558
+ lines.append("")
559
+ lines.append(f"… Truncated. Showing {len(info['body'])} chars (offset {info['offset']}). Total: {info['total_chars']}.")
560
+ lines.append(f"Next cursor: {info['next_cursor']}")
561
+
562
+ return "\n".join(lines)
563
+
564
+
565
+ def _format_resources_list(resources: dict) -> str:
566
+ """Format resource listing as human-readable text."""
567
+ skill = resources["skill"]
568
+ lines = [
569
+ f"Resources for skill: {skill}",
570
+ "",
571
+ ]
572
+
573
+ total_files = 0
574
+
575
+ for category in ["scripts", "references", "assets"]:
576
+ files = resources.get(category, [])
577
+ if files:
578
+ lines.append(f"📂 {category}/")
579
+ for f in files:
580
+ size_str = _fmt_size(f["size"])
581
+ lines.append(f" • {f['path']} ({size_str})")
582
+ total_files += 1
583
+ lines.append("")
584
+
585
+ other = resources.get("other_files", [])
586
+ if other:
587
+ lines.append("📂 (root)")
588
+ for f in other:
589
+ size_str = _fmt_size(f["size"])
590
+ lines.append(f" • {f['path']} ({size_str})")
591
+ total_files += 1
592
+ lines.append("")
593
+
594
+ if total_files == 0:
595
+ lines.append("No resource files found.")
596
+ else:
597
+ lines.append(f"Total: {total_files} files")
598
+
599
+ return "\n".join(lines).strip()
600
+
601
+
602
+ def _format_resource_content(data: dict) -> str:
603
+ """Format resource file content as human-readable text."""
604
+ lines = [
605
+ f"Resource: {data['resource']}",
606
+ f"Skill: {data['skill']}",
607
+ f"Size: {_fmt_size(data['size'])}",
608
+ ]
609
+
610
+ offset = data.get("offset", 0)
611
+ lines.append(f"Showing: {len(data['content'])} of {data['total_chars']} chars (offset {offset})")
612
+
613
+ lines.append("")
614
+ lines.append("--- Content ---")
615
+ lines.append("")
616
+ lines.append(data["content"])
617
+
618
+ if data.get("truncated"):
619
+ lines.append("")
620
+ lines.append(f"… Truncated. Next cursor: {data['next_cursor']}")
621
+
622
+ return "\n".join(lines)
623
+
624
+
625
+ def _format_validation(skill_name: str, errors: list[str]) -> str:
626
+ """Format validation results as human-readable text."""
627
+ if not errors:
628
+ return f"✓ Skill '{skill_name}' is valid."
629
+
630
+ lines = [
631
+ f"✗ Validation failed for skill '{skill_name}'",
632
+ f"Errors: {len(errors)}",
633
+ "",
634
+ ]
635
+
636
+ for i, err in enumerate(errors, 1):
637
+ lines.append(f" {i}. {err}")
638
+
639
+ return "\n".join(lines)
640
+
641
+
642
+ def _format_search(query: str, matches: list[dict]) -> str:
643
+ """Format search results as human-readable text."""
644
+ lines = [
645
+ f"Search results for: {query}",
646
+ f"Matches: {len(matches)}",
647
+ "",
648
+ ]
649
+
650
+ if not matches:
651
+ lines.append("No matching skills found.")
652
+ else:
653
+ for i, m in enumerate(matches, 1):
654
+ name = m["name"]
655
+ desc = m["description"]
656
+ match_in = m.get("match_in", "")
657
+ if len(desc) > 80:
658
+ desc = desc[:77] + "..."
659
+ lines.append(f"{i}. {name} (matched in {match_in})")
660
+ lines.append(f" {desc}")
661
+ lines.append("")
662
+
663
+ return "\n".join(lines).strip()
664
+
665
+
666
+ def _format_error(message: str, hint: str = "") -> str:
667
+ """Format error as human-readable text."""
668
+ lines = [f"Error: {message}"]
669
+ if hint:
670
+ lines.append(f"Hint: {hint}")
671
+ return "\n".join(lines)
672
+
673
+
674
+ # ---------------------------------------------------------------------------
675
+ # Main Tool Function
676
+ # ---------------------------------------------------------------------------
677
+
678
+ @autodoc(summary=TOOL_SUMMARY)
679
+ def Agent_Skills(
680
+ action: Annotated[str, "Operation: 'discover', 'info', 'resources', 'validate', 'search', 'help'."],
681
+ skill_name: Annotated[Optional[str], "Name of skill (required for info/resources/validate)."] = None,
682
+ resource_path: Annotated[Optional[str], "Path to resource file within skill (for resources action)."] = None,
683
+ query: Annotated[Optional[str], "Search query (for search action)."] = None,
684
+ max_chars: Annotated[int, "Max characters to return for skill body or resource content (0 = no limit)."] = 3000,
685
+ offset: Annotated[int, "Start offset for reading content (for info/resources)."] = 0,
686
+ ) -> str:
687
+ _log_call_start("Agent_Skills", action=action, skill_name=skill_name, resource_path=resource_path, query=query, max_chars=max_chars, offset=offset)
688
+
689
+ action = (action or "").strip().lower()
690
+
691
+ if action not in {"discover", "info", "resources", "validate", "search", "help"}:
692
+ result = _format_error(
693
+ f"Invalid action: {action}",
694
+ "Choose from: discover, info, resources, validate, search, help."
695
+ )
696
+ _log_call_end("Agent_Skills", _truncate_for_log(result))
697
+ return result
698
+
699
+ try:
700
+ if action == "help":
701
+ result = HELP_TEXT
702
+
703
+ elif action == "discover":
704
+ skills = _discover_skills()
705
+ result = _format_discover(skills)
706
+
707
+ elif action == "info":
708
+ if not skill_name:
709
+ result = _format_error("skill_name is required for 'info' action.")
710
+ else:
711
+ info = _get_skill_info(skill_name.strip(), offset=offset, max_chars=max_chars)
712
+ result = _format_skill_info(info)
713
+
714
+ elif action == "resources":
715
+ if not skill_name:
716
+ result = _format_error("skill_name is required for 'resources' action.")
717
+ elif resource_path:
718
+ resource_data = _read_skill_resource(skill_name.strip(), resource_path.strip(), offset=offset, max_chars=max_chars)
719
+ result = _format_resource_content(resource_data)
720
+ else:
721
+ resources = _list_skill_resources(skill_name.strip())
722
+ result = _format_resources_list(resources)
723
+
724
+ elif action == "validate":
725
+ if not skill_name:
726
+ result = _format_error("skill_name is required for 'validate' action.")
727
+ else:
728
+ skills_root = _get_skills_root()
729
+ skill_dir = skills_root / skill_name.strip()
730
+ errors = _validate_skill(skill_dir)
731
+ result = _format_validation(skill_name, errors)
732
+
733
+ elif action == "search":
734
+ if not query:
735
+ result = _format_error("query is required for 'search' action.")
736
+ else:
737
+ matches = _search_skills(query.strip())
738
+ result = _format_search(query, matches)
739
+
740
+ else:
741
+ result = _format_error(f"Action '{action}' not implemented.")
742
+
743
+ except FileNotFoundError as e:
744
+ result = _format_error(str(e))
745
+ except PermissionError as e:
746
+ result = _format_error(str(e))
747
+ except ParseError as e:
748
+ result = _format_error(str(e))
749
+ except ValidationError as e:
750
+ result = _format_error(str(e))
751
+ except Exception as e:
752
+ result = _format_error(f"Unexpected error: {e}")
753
+
754
+ _log_call_end("Agent_Skills", _truncate_for_log(result))
755
+ return result
756
+
757
+
758
+ # ---------------------------------------------------------------------------
759
+ # Gradio Interface
760
+ # ---------------------------------------------------------------------------
761
+
762
+ def build_interface() -> gr.Interface:
763
+ return gr.Interface(
764
+ fn=Agent_Skills,
765
+ inputs=[
766
+ gr.Radio(
767
+ label="Action",
768
+ choices=["discover", "info", "resources", "validate", "search", "help"],
769
+ value="help",
770
+ info="Operation to perform",
771
+ ),
772
+ gr.Textbox(label="Skill Name", placeholder="pdf", max_lines=1, info="Name of the skill"),
773
+ gr.Textbox(label="Resource Path", placeholder="references/forms.md", max_lines=1, info="Path to resource within skill"),
774
+ gr.Textbox(label="Search Query", placeholder="MCP", max_lines=1, info="Keyword to search for"),
775
+ gr.Slider(minimum=0, maximum=100000, step=500, value=3000, label="Max Chars", info="Max characters for content (0 = no limit)"),
776
+ gr.Slider(minimum=0, maximum=1_000_000, step=100, value=0, label="Offset", info="Start offset (Info/Resources)"),
777
+ ],
778
+ outputs=gr.Textbox(label="Result", lines=20),
779
+ title="Agent Skills",
780
+ description=(
781
+ "<div style=\"text-align:center; overflow:hidden;\">"
782
+ "Discover, inspect, and access Agent Skills. "
783
+ "Skills provide structured instructions and resources for specialized tasks."
784
+ "</div>"
785
+ ),
786
+ api_description=TOOL_SUMMARY,
787
+ flagging_mode="never",
788
+ submit_btn="Run",
789
+ )
790
+
791
+
792
+ __all__ = ["Agent_Skills", "build_interface"]