File size: 49,152 Bytes
b5b9c2e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 | #!/usr/bin/env python3
"""
Skills Tool Module
This module provides tools for listing and viewing skill documents.
Skills are organized as directories containing a SKILL.md file (the main instructions)
and optional supporting files like references, templates, and examples.
Inspired by Anthropic's Claude Skills system with progressive disclosure architecture:
- Metadata (name ≤64 chars, description ≤1024 chars) - shown in skills_list
- Full Instructions - loaded via skill_view when needed
- Linked Files (references, templates) - loaded on demand
Directory Structure:
skills/
├── my-skill/
│ ├── SKILL.md # Main instructions (required)
│ ├── references/ # Supporting documentation
│ │ ├── api.md
│ │ └── examples.md
│ ├── templates/ # Templates for output
│ │ └── template.md
│ └── assets/ # Supplementary files (agentskills.io standard)
└── category/ # Category folder for organization
└── another-skill/
└── SKILL.md
SKILL.md Format (YAML Frontmatter, agentskills.io compatible):
---
name: skill-name # Required, max 64 chars
description: Brief description # Required, max 1024 chars
version: 1.0.0 # Optional
license: MIT # Optional (agentskills.io)
platforms: [macos] # Optional — restrict to specific OS platforms
# Valid: macos, linux, windows
# Omit to load on all platforms (default)
prerequisites: # Optional — legacy runtime requirements
env_vars: [API_KEY] # Legacy env var names are normalized into
# required_environment_variables on load.
commands: [curl, jq] # Command checks remain advisory only.
compatibility: Requires X # Optional (agentskills.io)
metadata: # Optional, arbitrary key-value (agentskills.io)
hermes:
tags: [fine-tuning, llm]
related_skills: [peft, lora]
---
# Skill Title
Full instructions and content here...
Available tools:
- skills_list: List skills with metadata (progressive disclosure tier 1)
- skill_view: Load full skill content (progressive disclosure tier 2-3)
Usage:
from tools.skills_tool import skills_list, skill_view, check_skills_requirements
# List all skills (returns metadata only - token efficient)
result = skills_list()
# View a skill's main content (loads full instructions)
content = skill_view("axolotl")
# View a reference file within a skill (loads linked file)
content = skill_view("axolotl", "references/dataset-formats.md")
"""
import json
import logging
from hermes_constants import get_hermes_home
import os
import re
from enum import Enum
from pathlib import Path
from typing import Dict, Any, List, Optional, Set, Tuple
from tools.registry import registry, tool_error
logger = logging.getLogger(__name__)
# All skills live in ~/.hermes/skills/ (seeded from bundled skills/ on install).
# This is the single source of truth -- agent edits, hub installs, and bundled
# skills all coexist here without polluting the git repo.
HERMES_HOME = get_hermes_home()
SKILLS_DIR = HERMES_HOME / "skills"
# Anthropic-recommended limits for progressive disclosure efficiency
MAX_NAME_LENGTH = 64
MAX_DESCRIPTION_LENGTH = 1024
# Platform identifiers for the 'platforms' frontmatter field.
# Maps user-friendly names to sys.platform prefixes.
_PLATFORM_MAP = {
"macos": "darwin",
"linux": "linux",
"windows": "win32",
}
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_EXCLUDED_SKILL_DIRS = frozenset((".git", ".github", ".hub"))
_REMOTE_ENV_BACKENDS = frozenset({"docker", "singularity", "modal", "ssh", "daytona"})
_secret_capture_callback = None
def load_env() -> Dict[str, str]:
"""Load profile-scoped environment variables from HERMES_HOME/.env."""
env_path = get_hermes_home() / ".env"
env_vars: Dict[str, str] = {}
if not env_path.exists():
return env_vars
with env_path.open() as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, _, value = line.partition("=")
env_vars[key.strip()] = value.strip().strip("\"'")
return env_vars
class SkillReadinessStatus(str, Enum):
AVAILABLE = "available"
SETUP_NEEDED = "setup_needed"
UNSUPPORTED = "unsupported"
def set_secret_capture_callback(callback) -> None:
global _secret_capture_callback
_secret_capture_callback = callback
def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
"""Check if a skill is compatible with the current OS platform.
Delegates to ``agent.skill_utils.skill_matches_platform`` — kept here
as a public re-export so existing callers don't need updating.
"""
from agent.skill_utils import skill_matches_platform as _impl
return _impl(frontmatter)
def _normalize_prerequisite_values(value: Any) -> List[str]:
if not value:
return []
if isinstance(value, str):
value = [value]
return [str(item) for item in value if str(item).strip()]
def _collect_prerequisite_values(
frontmatter: Dict[str, Any],
) -> Tuple[List[str], List[str]]:
prereqs = frontmatter.get("prerequisites")
if not prereqs or not isinstance(prereqs, dict):
return [], []
return (
_normalize_prerequisite_values(prereqs.get("env_vars")),
_normalize_prerequisite_values(prereqs.get("commands")),
)
def _normalize_setup_metadata(frontmatter: Dict[str, Any]) -> Dict[str, Any]:
setup = frontmatter.get("setup")
if not isinstance(setup, dict):
return {"help": None, "collect_secrets": []}
help_text = setup.get("help")
normalized_help = (
str(help_text).strip()
if isinstance(help_text, str) and help_text.strip()
else None
)
collect_secrets_raw = setup.get("collect_secrets")
if isinstance(collect_secrets_raw, dict):
collect_secrets_raw = [collect_secrets_raw]
if not isinstance(collect_secrets_raw, list):
collect_secrets_raw = []
collect_secrets: List[Dict[str, Any]] = []
for item in collect_secrets_raw:
if not isinstance(item, dict):
continue
env_var = str(item.get("env_var") or "").strip()
if not env_var:
continue
prompt = str(item.get("prompt") or f"Enter value for {env_var}").strip()
provider_url = str(item.get("provider_url") or item.get("url") or "").strip()
entry: Dict[str, Any] = {
"env_var": env_var,
"prompt": prompt,
"secret": bool(item.get("secret", True)),
}
if provider_url:
entry["provider_url"] = provider_url
collect_secrets.append(entry)
return {
"help": normalized_help,
"collect_secrets": collect_secrets,
}
def _get_required_environment_variables(
frontmatter: Dict[str, Any],
legacy_env_vars: List[str] | None = None,
) -> List[Dict[str, Any]]:
setup = _normalize_setup_metadata(frontmatter)
required_raw = frontmatter.get("required_environment_variables")
if isinstance(required_raw, dict):
required_raw = [required_raw]
if not isinstance(required_raw, list):
required_raw = []
required: List[Dict[str, Any]] = []
seen: set[str] = set()
def _append_required(entry: Dict[str, Any]) -> None:
env_name = str(entry.get("name") or entry.get("env_var") or "").strip()
if not env_name or env_name in seen:
return
if not _ENV_VAR_NAME_RE.match(env_name):
return
normalized: Dict[str, Any] = {
"name": env_name,
"prompt": str(entry.get("prompt") or f"Enter value for {env_name}").strip(),
}
help_text = (
entry.get("help")
or entry.get("provider_url")
or entry.get("url")
or setup.get("help")
)
if isinstance(help_text, str) and help_text.strip():
normalized["help"] = help_text.strip()
required_for = entry.get("required_for")
if isinstance(required_for, str) and required_for.strip():
normalized["required_for"] = required_for.strip()
seen.add(env_name)
required.append(normalized)
for item in required_raw:
if isinstance(item, str):
_append_required({"name": item})
continue
if isinstance(item, dict):
_append_required(item)
for item in setup["collect_secrets"]:
_append_required(
{
"name": item.get("env_var"),
"prompt": item.get("prompt"),
"help": item.get("provider_url") or setup.get("help"),
}
)
if legacy_env_vars is None:
legacy_env_vars, _ = _collect_prerequisite_values(frontmatter)
for env_var in legacy_env_vars:
_append_required({"name": env_var})
return required
def _capture_required_environment_variables(
skill_name: str,
missing_entries: List[Dict[str, Any]],
) -> Dict[str, Any]:
if not missing_entries:
return {
"missing_names": [],
"setup_skipped": False,
"gateway_setup_hint": None,
}
missing_names = [entry["name"] for entry in missing_entries]
if _is_gateway_surface():
return {
"missing_names": missing_names,
"setup_skipped": False,
"gateway_setup_hint": _gateway_setup_hint(),
}
if _secret_capture_callback is None:
return {
"missing_names": missing_names,
"setup_skipped": False,
"gateway_setup_hint": None,
}
setup_skipped = False
remaining_names: List[str] = []
for entry in missing_entries:
metadata = {"skill_name": skill_name}
if entry.get("help"):
metadata["help"] = entry["help"]
if entry.get("required_for"):
metadata["required_for"] = entry["required_for"]
try:
callback_result = _secret_capture_callback(
entry["name"],
entry["prompt"],
metadata,
)
except Exception:
logger.warning(
f"Secret capture callback failed for {entry['name']}", exc_info=True
)
callback_result = {
"success": False,
"stored_as": entry["name"],
"validated": False,
"skipped": True,
}
success = isinstance(callback_result, dict) and bool(
callback_result.get("success")
)
skipped = isinstance(callback_result, dict) and bool(
callback_result.get("skipped")
)
if success and not skipped:
continue
setup_skipped = True
remaining_names.append(entry["name"])
return {
"missing_names": remaining_names,
"setup_skipped": setup_skipped,
"gateway_setup_hint": None,
}
def _is_gateway_surface() -> bool:
if os.getenv("HERMES_GATEWAY_SESSION"):
return True
from gateway.session_context import get_session_env
return bool(get_session_env("HERMES_SESSION_PLATFORM"))
def _get_terminal_backend_name() -> str:
return str(os.getenv("TERMINAL_ENV", "local")).strip().lower() or "local"
def _is_env_var_persisted(
var_name: str, env_snapshot: Dict[str, str] | None = None
) -> bool:
if env_snapshot is None:
env_snapshot = load_env()
if var_name in env_snapshot:
return bool(env_snapshot.get(var_name))
return bool(os.getenv(var_name))
def _remaining_required_environment_names(
required_env_vars: List[Dict[str, Any]],
capture_result: Dict[str, Any],
*,
env_snapshot: Dict[str, str] | None = None,
) -> List[str]:
missing_names = set(capture_result["missing_names"])
if env_snapshot is None:
env_snapshot = load_env()
remaining = []
for entry in required_env_vars:
name = entry["name"]
if name in missing_names or not _is_env_var_persisted(name, env_snapshot):
remaining.append(name)
return remaining
def _gateway_setup_hint() -> str:
try:
from gateway.platforms.base import GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE
return GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE
except Exception:
return "Secure secret entry is not available. Load this skill in the local CLI to be prompted, or add the key to ~/.hermes/.env manually."
def _build_setup_note(
readiness_status: SkillReadinessStatus,
missing: List[str],
setup_help: str | None = None,
) -> str | None:
if readiness_status == SkillReadinessStatus.SETUP_NEEDED:
missing_str = ", ".join(missing) if missing else "required prerequisites"
note = f"Setup needed before using this skill: missing {missing_str}."
if setup_help:
return f"{note} {setup_help}"
return note
return None
def check_skills_requirements() -> bool:
"""Skills are always available -- the directory is created on first use if needed."""
return True
def _parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]:
"""Parse YAML frontmatter from markdown content.
Delegates to ``agent.skill_utils.parse_frontmatter`` — kept here
as a public re-export so existing callers don't need updating.
"""
from agent.skill_utils import parse_frontmatter
return parse_frontmatter(content)
def _get_category_from_path(skill_path: Path) -> Optional[str]:
"""
Extract category from skill path based on directory structure.
For paths like: ~/.hermes/skills/mlops/axolotl/SKILL.md -> "mlops"
Also works for external skill dirs configured via skills.external_dirs.
"""
# Try the module-level SKILLS_DIR first (respects monkeypatching in tests),
# then fall back to external dirs from config.
dirs_to_check = [SKILLS_DIR]
try:
from agent.skill_utils import get_external_skills_dirs
dirs_to_check.extend(get_external_skills_dirs())
except Exception:
pass
for skills_dir in dirs_to_check:
try:
rel_path = skill_path.relative_to(skills_dir)
parts = rel_path.parts
if len(parts) >= 3:
return parts[0]
except ValueError:
continue
return None
# Token estimation — use the shared implementation from model_metadata.
from agent.model_metadata import estimate_tokens_rough as _estimate_tokens
def _parse_tags(tags_value) -> List[str]:
"""
Parse tags from frontmatter value.
Handles:
- Already-parsed list (from yaml.safe_load): [tag1, tag2]
- String with brackets: "[tag1, tag2]"
- Comma-separated string: "tag1, tag2"
Args:
tags_value: Raw tags value — may be a list or string
Returns:
List of tag strings
"""
if not tags_value:
return []
# yaml.safe_load already returns a list for [tag1, tag2]
if isinstance(tags_value, list):
return [str(t).strip() for t in tags_value if t]
# String fallback — handle bracket-wrapped or comma-separated
tags_value = str(tags_value).strip()
if tags_value.startswith("[") and tags_value.endswith("]"):
tags_value = tags_value[1:-1]
return [t.strip().strip("\"'") for t in tags_value.split(",") if t.strip()]
def _get_disabled_skill_names() -> Set[str]:
"""Load disabled skill names from config.
Delegates to ``agent.skill_utils.get_disabled_skill_names`` — kept here
as a public re-export so existing callers don't need updating.
"""
from agent.skill_utils import get_disabled_skill_names
return get_disabled_skill_names()
def _is_skill_disabled(name: str, platform: str = None) -> bool:
"""Check if a skill is disabled in config."""
import os
try:
from hermes_cli.config import load_config
config = load_config()
skills_cfg = config.get("skills", {})
resolved_platform = platform or os.getenv("HERMES_PLATFORM")
if resolved_platform:
platform_disabled = skills_cfg.get("platform_disabled", {}).get(resolved_platform)
if platform_disabled is not None:
return name in platform_disabled
return name in skills_cfg.get("disabled", [])
except Exception:
return False
def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]:
"""Recursively find all skills in ~/.hermes/skills/ and external dirs.
Args:
skip_disabled: If True, return ALL skills regardless of disabled
state (used by ``hermes skills`` config UI). Default False
filters out disabled skills.
Returns:
List of skill metadata dicts (name, description, category).
"""
from agent.skill_utils import get_external_skills_dirs
skills = []
seen_names: set = set()
# Load disabled set once (not per-skill)
disabled = set() if skip_disabled else _get_disabled_skill_names()
# Scan local dir first, then external dirs (local takes precedence)
dirs_to_scan = []
if SKILLS_DIR.exists():
dirs_to_scan.append(SKILLS_DIR)
dirs_to_scan.extend(get_external_skills_dirs())
for scan_dir in dirs_to_scan:
for skill_md in scan_dir.rglob("SKILL.md"):
if any(part in _EXCLUDED_SKILL_DIRS for part in skill_md.parts):
continue
skill_dir = skill_md.parent
try:
content = skill_md.read_text(encoding="utf-8")[:4000]
frontmatter, body = _parse_frontmatter(content)
if not skill_matches_platform(frontmatter):
continue
name = frontmatter.get("name", skill_dir.name)[:MAX_NAME_LENGTH]
if name in seen_names:
continue
if name in disabled:
continue
description = frontmatter.get("description", "")
if not description:
for line in body.strip().split("\n"):
line = line.strip()
if line and not line.startswith("#"):
description = line
break
if len(description) > MAX_DESCRIPTION_LENGTH:
description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..."
category = _get_category_from_path(skill_md)
seen_names.add(name)
skills.append({
"name": name,
"description": description,
"category": category,
})
except (UnicodeDecodeError, PermissionError) as e:
logger.debug("Failed to read skill file %s: %s", skill_md, e)
continue
except Exception as e:
logger.debug(
"Skipping skill at %s: failed to parse: %s", skill_md, e, exc_info=True
)
continue
return skills
def _load_category_description(category_dir: Path) -> Optional[str]:
"""
Load category description from DESCRIPTION.md if it exists.
Args:
category_dir: Path to the category directory
Returns:
Description string or None if not found
"""
desc_file = category_dir / "DESCRIPTION.md"
if not desc_file.exists():
return None
try:
content = desc_file.read_text(encoding="utf-8")
# Parse frontmatter if present
frontmatter, body = _parse_frontmatter(content)
# Prefer frontmatter description, fall back to first non-header line
description = frontmatter.get("description", "")
if not description:
for line in body.strip().split("\n"):
line = line.strip()
if line and not line.startswith("#"):
description = line
break
# Truncate to reasonable length
if len(description) > MAX_DESCRIPTION_LENGTH:
description = description[: MAX_DESCRIPTION_LENGTH - 3] + "..."
return description if description else None
except (UnicodeDecodeError, PermissionError) as e:
logger.debug("Failed to read category description %s: %s", desc_file, e)
return None
except Exception as e:
logger.warning(
"Error parsing category description %s: %s", desc_file, e, exc_info=True
)
return None
def skills_categories(verbose: bool = False, task_id: str = None) -> str:
"""
List available skill categories with descriptions (progressive disclosure tier 0).
Returns category names and descriptions for efficient discovery before drilling down.
Categories can have a DESCRIPTION.md file with a description frontmatter field
or first paragraph to explain what skills are in that category.
Args:
verbose: If True, include skill counts per category (default: False, but currently always included)
task_id: Optional task identifier used to probe the active backend
Returns:
JSON string with list of categories and their descriptions
"""
try:
# Use module-level SKILLS_DIR (respects monkeypatching) + external dirs
all_dirs = [SKILLS_DIR] if SKILLS_DIR.exists() else []
try:
from agent.skill_utils import get_external_skills_dirs
all_dirs.extend(d for d in get_external_skills_dirs() if d.exists())
except Exception:
pass
if not all_dirs:
return json.dumps(
{
"success": True,
"categories": [],
"message": "No skills directory found.",
},
ensure_ascii=False,
)
category_dirs = {}
category_counts: Dict[str, int] = {}
for scan_dir in all_dirs:
for skill_md in scan_dir.rglob("SKILL.md"):
if any(part in _EXCLUDED_SKILL_DIRS for part in skill_md.parts):
continue
try:
frontmatter, _ = _parse_frontmatter(
skill_md.read_text(encoding="utf-8")[:4000]
)
except Exception:
frontmatter = {}
if not skill_matches_platform(frontmatter):
continue
category = _get_category_from_path(skill_md)
if category:
category_counts[category] = category_counts.get(category, 0) + 1
if category not in category_dirs:
category_dirs[category] = skill_md.parent.parent
categories = []
for name in sorted(category_dirs.keys()):
category_dir = category_dirs[name]
description = _load_category_description(category_dir)
cat_entry = {"name": name, "skill_count": category_counts[name]}
if description:
cat_entry["description"] = description
categories.append(cat_entry)
return json.dumps(
{
"success": True,
"categories": categories,
"hint": "If a category is relevant to your task, use skills_list with that category to see available skills",
},
ensure_ascii=False,
)
except Exception as e:
return tool_error(str(e), success=False)
def skills_list(category: str = None, task_id: str = None) -> str:
"""
List all available skills (progressive disclosure tier 1 - minimal metadata).
Returns only name + description to minimize token usage. Use skill_view() to
load full content, tags, related files, etc.
Args:
category: Optional category filter (e.g., "mlops")
task_id: Optional task identifier used to probe the active backend
Returns:
JSON string with minimal skill info: name, description, category
"""
try:
if not SKILLS_DIR.exists():
SKILLS_DIR.mkdir(parents=True, exist_ok=True)
return json.dumps(
{
"success": True,
"skills": [],
"categories": [],
"message": "No skills found. Skills directory created at ~/.hermes/skills/",
},
ensure_ascii=False,
)
# Find all skills
all_skills = _find_all_skills()
if not all_skills:
return json.dumps(
{
"success": True,
"skills": [],
"categories": [],
"message": "No skills found in skills/ directory.",
},
ensure_ascii=False,
)
# Filter by category if specified
if category:
all_skills = [s for s in all_skills if s.get("category") == category]
# Sort by category then name
all_skills.sort(key=lambda s: (s.get("category") or "", s["name"]))
# Extract unique categories
categories = sorted(
set(s.get("category") for s in all_skills if s.get("category"))
)
return json.dumps(
{
"success": True,
"skills": all_skills,
"categories": categories,
"count": len(all_skills),
"hint": "Use skill_view(name) to see full content, tags, and linked files",
},
ensure_ascii=False,
)
except Exception as e:
return tool_error(str(e), success=False)
def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
"""
View the content of a skill or a specific file within a skill directory.
Args:
name: Name or path of the skill (e.g., "axolotl" or "03-fine-tuning/axolotl")
file_path: Optional path to a specific file within the skill (e.g., "references/api.md")
task_id: Optional task identifier used to probe the active backend
Returns:
JSON string with skill content or error message
"""
try:
from agent.skill_utils import get_external_skills_dirs
# Build list of all skill directories to search
all_dirs = []
if SKILLS_DIR.exists():
all_dirs.append(SKILLS_DIR)
all_dirs.extend(get_external_skills_dirs())
if not all_dirs:
return json.dumps(
{
"success": False,
"error": "Skills directory does not exist yet. It will be created on first install.",
},
ensure_ascii=False,
)
skill_dir = None
skill_md = None
# Search all dirs: local first, then external (first match wins)
for search_dir in all_dirs:
# Try direct path first (e.g., "mlops/axolotl")
direct_path = search_dir / name
if direct_path.is_dir() and (direct_path / "SKILL.md").exists():
skill_dir = direct_path
skill_md = direct_path / "SKILL.md"
break
elif direct_path.with_suffix(".md").exists():
skill_md = direct_path.with_suffix(".md")
break
# Search by directory name across all dirs
if not skill_md:
for search_dir in all_dirs:
for found_skill_md in search_dir.rglob("SKILL.md"):
if found_skill_md.parent.name == name:
skill_dir = found_skill_md.parent
skill_md = found_skill_md
break
if skill_md:
break
# Legacy: flat .md files
if not skill_md:
for search_dir in all_dirs:
for found_md in search_dir.rglob(f"{name}.md"):
if found_md.name != "SKILL.md":
skill_md = found_md
break
if skill_md:
break
if not skill_md or not skill_md.exists():
available = [s["name"] for s in _find_all_skills()[:20]]
return json.dumps(
{
"success": False,
"error": f"Skill '{name}' not found.",
"available_skills": available,
"hint": "Use skills_list to see all available skills",
},
ensure_ascii=False,
)
# Read the file once — reused for platform check and main content below
try:
content = skill_md.read_text(encoding="utf-8")
except Exception as e:
return json.dumps(
{
"success": False,
"error": f"Failed to read skill '{name}': {e}",
},
ensure_ascii=False,
)
# Security: warn if skill is loaded from outside trusted directories
# (local skills dir + configured external_dirs are all trusted)
_outside_skills_dir = True
_trusted_dirs = [SKILLS_DIR.resolve()]
try:
_trusted_dirs.extend(d.resolve() for d in all_dirs[1:])
except Exception:
pass
for _td in _trusted_dirs:
try:
skill_md.resolve().relative_to(_td)
_outside_skills_dir = False
break
except ValueError:
continue
# Security: detect common prompt injection patterns
_INJECTION_PATTERNS = [
"ignore previous instructions",
"ignore all previous",
"you are now",
"disregard your",
"forget your instructions",
"new instructions:",
"system prompt:",
"<system>",
"]]>",
]
_content_lower = content.lower()
_injection_detected = any(p in _content_lower for p in _INJECTION_PATTERNS)
if _outside_skills_dir or _injection_detected:
_warnings = []
if _outside_skills_dir:
_warnings.append(f"skill file is outside the trusted skills directory (~/.hermes/skills/): {skill_md}")
if _injection_detected:
_warnings.append("skill content contains patterns that may indicate prompt injection")
import logging as _logging
_logging.getLogger(__name__).warning("Skill security warning for '%s': %s", name, "; ".join(_warnings))
parsed_frontmatter: Dict[str, Any] = {}
try:
parsed_frontmatter, _ = _parse_frontmatter(content)
except Exception:
parsed_frontmatter = {}
if not skill_matches_platform(parsed_frontmatter):
return json.dumps(
{
"success": False,
"error": f"Skill '{name}' is not supported on this platform.",
"readiness_status": SkillReadinessStatus.UNSUPPORTED.value,
},
ensure_ascii=False,
)
# Check if the skill is disabled by the user
resolved_name = parsed_frontmatter.get("name", skill_md.parent.name)
if _is_skill_disabled(resolved_name):
return json.dumps(
{
"success": False,
"error": (
f"Skill '{resolved_name}' is disabled. "
"Enable it with `hermes skills` or inspect the files directly on disk."
),
},
ensure_ascii=False,
)
# If a specific file path is requested, read that instead
if file_path and skill_dir:
from tools.path_security import validate_within_dir, has_traversal_component
# Security: Prevent path traversal attacks
if has_traversal_component(file_path):
return json.dumps(
{
"success": False,
"error": "Path traversal ('..') is not allowed.",
"hint": "Use a relative path within the skill directory",
},
ensure_ascii=False,
)
target_file = skill_dir / file_path
# Security: Verify resolved path is still within skill directory
traversal_error = validate_within_dir(target_file, skill_dir)
if traversal_error:
return json.dumps(
{
"success": False,
"error": traversal_error,
"hint": "Use a relative path within the skill directory",
},
ensure_ascii=False,
)
if not target_file.exists():
# List available files in the skill directory, organized by type
available_files = {
"references": [],
"templates": [],
"assets": [],
"scripts": [],
"other": [],
}
# Scan for all readable files
for f in skill_dir.rglob("*"):
if f.is_file() and f.name != "SKILL.md":
rel = str(f.relative_to(skill_dir))
if rel.startswith("references/"):
available_files["references"].append(rel)
elif rel.startswith("templates/"):
available_files["templates"].append(rel)
elif rel.startswith("assets/"):
available_files["assets"].append(rel)
elif rel.startswith("scripts/"):
available_files["scripts"].append(rel)
elif f.suffix in [
".md",
".py",
".yaml",
".yml",
".json",
".tex",
".sh",
]:
available_files["other"].append(rel)
# Remove empty categories
available_files = {k: v for k, v in available_files.items() if v}
return json.dumps(
{
"success": False,
"error": f"File '{file_path}' not found in skill '{name}'.",
"available_files": available_files,
"hint": "Use one of the available file paths listed above",
},
ensure_ascii=False,
)
# Read the file content
try:
content = target_file.read_text(encoding="utf-8")
except UnicodeDecodeError:
# Binary file - return info about it instead
return json.dumps(
{
"success": True,
"name": name,
"file": file_path,
"content": f"[Binary file: {target_file.name}, size: {target_file.stat().st_size} bytes]",
"is_binary": True,
},
ensure_ascii=False,
)
return json.dumps(
{
"success": True,
"name": name,
"file": file_path,
"content": content,
"file_type": target_file.suffix,
},
ensure_ascii=False,
)
# Reuse the parse from the platform check above
frontmatter = parsed_frontmatter
# Get reference, template, asset, and script files if this is a directory-based skill
reference_files = []
template_files = []
asset_files = []
script_files = []
if skill_dir:
references_dir = skill_dir / "references"
if references_dir.exists():
reference_files = [
str(f.relative_to(skill_dir)) for f in references_dir.glob("*.md")
]
templates_dir = skill_dir / "templates"
if templates_dir.exists():
for ext in [
"*.md",
"*.py",
"*.yaml",
"*.yml",
"*.json",
"*.tex",
"*.sh",
]:
template_files.extend(
[
str(f.relative_to(skill_dir))
for f in templates_dir.rglob(ext)
]
)
# assets/ — agentskills.io standard directory for supplementary files
assets_dir = skill_dir / "assets"
if assets_dir.exists():
for f in assets_dir.rglob("*"):
if f.is_file():
asset_files.append(str(f.relative_to(skill_dir)))
scripts_dir = skill_dir / "scripts"
if scripts_dir.exists():
for ext in ["*.py", "*.sh", "*.bash", "*.js", "*.ts", "*.rb"]:
script_files.extend(
[str(f.relative_to(skill_dir)) for f in scripts_dir.glob(ext)]
)
# Read tags/related_skills with backward compat:
# Check metadata.hermes.* first (agentskills.io convention), fall back to top-level
hermes_meta = {}
metadata = frontmatter.get("metadata")
if isinstance(metadata, dict):
hermes_meta = metadata.get("hermes", {}) or {}
tags = _parse_tags(hermes_meta.get("tags") or frontmatter.get("tags", ""))
related_skills = _parse_tags(
hermes_meta.get("related_skills") or frontmatter.get("related_skills", "")
)
# Build linked files structure for clear discovery
linked_files = {}
if reference_files:
linked_files["references"] = reference_files
if template_files:
linked_files["templates"] = template_files
if asset_files:
linked_files["assets"] = asset_files
if script_files:
linked_files["scripts"] = script_files
try:
rel_path = str(skill_md.relative_to(SKILLS_DIR))
except ValueError:
# External skill — use path relative to the skill's own parent dir
rel_path = str(skill_md.relative_to(skill_md.parent.parent)) if skill_md.parent.parent else skill_md.name
skill_name = frontmatter.get(
"name", skill_md.stem if not skill_dir else skill_dir.name
)
legacy_env_vars, _ = _collect_prerequisite_values(frontmatter)
required_env_vars = _get_required_environment_variables(
frontmatter, legacy_env_vars
)
backend = _get_terminal_backend_name()
env_snapshot = load_env()
missing_required_env_vars = [
e
for e in required_env_vars
if not _is_env_var_persisted(e["name"], env_snapshot)
]
capture_result = _capture_required_environment_variables(
skill_name,
missing_required_env_vars,
)
if missing_required_env_vars:
env_snapshot = load_env()
remaining_missing_required_envs = _remaining_required_environment_names(
required_env_vars,
capture_result,
env_snapshot=env_snapshot,
)
setup_needed = bool(remaining_missing_required_envs)
# Register available skill env vars so they pass through to sandboxed
# execution environments (execute_code, terminal). Only vars that are
# actually set get registered — missing ones are reported as setup_needed.
available_env_names = [
e["name"]
for e in required_env_vars
if e["name"] not in remaining_missing_required_envs
]
if available_env_names:
try:
from tools.env_passthrough import register_env_passthrough
register_env_passthrough(available_env_names)
except Exception:
logger.debug(
"Could not register env passthrough for skill %s",
skill_name,
exc_info=True,
)
# Register credential files for mounting into remote sandboxes
# (Modal, Docker). Files that exist on the host are registered;
# missing ones are added to the setup_needed indicators.
required_cred_files_raw = frontmatter.get("required_credential_files", [])
if not isinstance(required_cred_files_raw, list):
required_cred_files_raw = []
missing_cred_files: list = []
if required_cred_files_raw:
try:
from tools.credential_files import register_credential_files
missing_cred_files = register_credential_files(required_cred_files_raw)
if missing_cred_files:
setup_needed = True
except Exception:
logger.debug(
"Could not register credential files for skill %s",
skill_name,
exc_info=True,
)
result = {
"success": True,
"name": skill_name,
"description": frontmatter.get("description", ""),
"tags": tags,
"related_skills": related_skills,
"content": content,
"path": rel_path,
"linked_files": linked_files if linked_files else None,
"usage_hint": "To view linked files, call skill_view(name, file_path) where file_path is e.g. 'references/api.md' or 'assets/config.yaml'"
if linked_files
else None,
"required_environment_variables": required_env_vars,
"required_commands": [],
"missing_required_environment_variables": remaining_missing_required_envs,
"missing_credential_files": missing_cred_files,
"missing_required_commands": [],
"setup_needed": setup_needed,
"setup_skipped": capture_result["setup_skipped"],
"readiness_status": SkillReadinessStatus.SETUP_NEEDED.value
if setup_needed
else SkillReadinessStatus.AVAILABLE.value,
}
setup_help = next((e["help"] for e in required_env_vars if e.get("help")), None)
if setup_help:
result["setup_help"] = setup_help
if capture_result["gateway_setup_hint"]:
result["gateway_setup_hint"] = capture_result["gateway_setup_hint"]
if setup_needed:
missing_items = [
f"env ${env_name}" for env_name in remaining_missing_required_envs
] + [
f"file {path}" for path in missing_cred_files
]
setup_note = _build_setup_note(
SkillReadinessStatus.SETUP_NEEDED,
missing_items,
setup_help,
)
if backend in _REMOTE_ENV_BACKENDS and setup_note:
setup_note = f"{setup_note} {backend.upper()}-backed skills need these requirements available inside the remote environment as well."
if setup_note:
result["setup_note"] = setup_note
# Surface agentskills.io optional fields when present
if frontmatter.get("compatibility"):
result["compatibility"] = frontmatter["compatibility"]
if isinstance(metadata, dict):
result["metadata"] = metadata
return json.dumps(result, ensure_ascii=False)
except Exception as e:
return tool_error(str(e), success=False)
# Tool description for model_tools.py
SKILLS_TOOL_DESCRIPTION = """Access skill documents providing specialized instructions, guidelines, and executable knowledge.
Progressive disclosure workflow:
1. skills_list() - Returns metadata (name, description, tags, linked_file_count) for all skills
2. skill_view(name) - Loads full SKILL.md content + shows available linked_files
3. skill_view(name, file_path) - Loads specific linked file (e.g., 'references/api.md', 'scripts/train.py')
Skills may include:
- references/: Additional documentation, API specs, examples
- templates/: Output formats, config files, boilerplate code
- assets/: Supplementary files (agentskills.io standard)
- scripts/: Executable helpers (Python, shell scripts)"""
if __name__ == "__main__":
"""Test the skills tool"""
print("🎯 Skills Tool Test")
print("=" * 60)
# Test listing skills
print("\n📋 Listing all skills:")
result = json.loads(skills_list())
if result["success"]:
print(
f"Found {result['count']} skills in {len(result.get('categories', []))} categories"
)
print(f"Categories: {result.get('categories', [])}")
print("\nFirst 10 skills:")
for skill in result["skills"][:10]:
cat = f"[{skill['category']}] " if skill.get("category") else ""
print(f" • {cat}{skill['name']}: {skill['description'][:60]}...")
else:
print(f"Error: {result['error']}")
# Test viewing a skill
print("\n📖 Viewing skill 'axolotl':")
result = json.loads(skill_view("axolotl"))
if result["success"]:
print(f"Name: {result['name']}")
print(f"Description: {result.get('description', 'N/A')[:100]}...")
print(f"Content length: {len(result['content'])} chars")
if result.get("linked_files"):
print(f"Linked files: {result['linked_files']}")
else:
print(f"Error: {result['error']}")
# Test viewing a reference file
print("\n📄 Viewing reference file 'axolotl/references/dataset-formats.md':")
result = json.loads(skill_view("axolotl", "references/dataset-formats.md"))
if result["success"]:
print(f"File: {result['file']}")
print(f"Content length: {len(result['content'])} chars")
print(f"Preview: {result['content'][:150]}...")
else:
print(f"Error: {result['error']}")
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
SKILLS_LIST_SCHEMA = {
"name": "skills_list",
"description": "List available skills (name + description). Use skill_view(name) to load full content.",
"parameters": {
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "Optional category filter to narrow results",
}
},
"required": [],
},
}
SKILL_VIEW_SCHEMA = {
"name": "skill_view",
"description": "Skills allow for loading information about specific tasks and workflows, as well as scripts and templates. Load a skill's full content or access its linked files (references, templates, scripts). First call returns SKILL.md content plus a 'linked_files' dict showing available references/templates/scripts. To access those, call again with file_path parameter.",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The skill name (use skills_list to see available skills)",
},
"file_path": {
"type": "string",
"description": "OPTIONAL: Path to a linked file within the skill (e.g., 'references/api.md', 'templates/config.yaml', 'scripts/validate.py'). Omit to get the main SKILL.md content.",
},
},
"required": ["name"],
},
}
registry.register(
name="skills_list",
toolset="skills",
schema=SKILLS_LIST_SCHEMA,
handler=lambda args, **kw: skills_list(
category=args.get("category"), task_id=kw.get("task_id")
),
check_fn=check_skills_requirements,
emoji="📚",
)
registry.register(
name="skill_view",
toolset="skills",
schema=SKILL_VIEW_SCHEMA,
handler=lambda args, **kw: skill_view(
args.get("name", ""), file_path=args.get("file_path"), task_id=kw.get("task_id")
),
check_fn=check_skills_requirements,
emoji="📚",
)
|