refactor: extract shared curses checklist, fix skill discovery perf

Four cleanups to code merged today:

1. New hermes_cli/curses_ui.py — shared curses_checklist() used by both
   hermes tools and hermes skills. Eliminates ~140 lines of near-identical
   curses code (scrolling, key handling, color setup, numbered fallback).

2. Fix _find_all_skills() perf — was calling load_config() per skill
   (~100+ YAML parses). Now loads disabled set once via
   _get_disabled_skill_names() and does a set lookup.

3. Eliminate _list_all_skills_unfiltered() duplication — _find_all_skills()
   now accepts skip_disabled=True for the config UI, removing 30 lines
   of copy-pasted discovery logic from skills_config.py.

4. Fix fragile label round-trip in skills_command — was building label
   strings, passing to checklist, then mapping labels back to skill names
   (collision-prone). Now works with indices directly, like tools_config.
This commit is contained in:
teknium1 2026-03-11 03:06:15 -07:00
parent f1510ec33e
commit 4864a5684a
5 changed files with 257 additions and 319 deletions

140
hermes_cli/curses_ui.py Normal file
View file

@ -0,0 +1,140 @@
"""Shared curses-based UI components for Hermes CLI.
Used by `hermes tools` and `hermes skills` for interactive checklists.
Provides a curses multi-select with keyboard navigation, plus a
text-based numbered fallback for terminals without curses support.
"""
from typing import List, Set
from hermes_cli.colors import Colors, color
def curses_checklist(
title: str,
items: List[str],
selected: Set[int],
*,
cancel_returns: Set[int] | None = None,
) -> Set[int]:
"""Curses multi-select checklist. Returns set of selected indices.
Args:
title: Header line displayed above the checklist.
items: Display labels for each row.
selected: Indices that start checked (pre-selected).
cancel_returns: Returned on ESC/q. Defaults to the original *selected*.
"""
if cancel_returns is None:
cancel_returns = set(selected)
try:
import curses
chosen = set(selected)
result_holder: list = [None]
def _draw(stdscr):
curses.curs_set(0)
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
curses.init_pair(3, 8, -1) # dim gray
cursor = 0
scroll_offset = 0
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
# Header
try:
hattr = curses.A_BOLD
if curses.has_colors():
hattr |= curses.color_pair(2)
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
stdscr.addnstr(
1, 0,
" ↑↓ navigate SPACE toggle ENTER confirm ESC cancel",
max_x - 1, curses.A_DIM,
)
except curses.error:
pass
# Scrollable item list
visible_rows = max_y - 3
if cursor < scroll_offset:
scroll_offset = cursor
elif cursor >= scroll_offset + visible_rows:
scroll_offset = cursor - visible_rows + 1
for draw_i, i in enumerate(
range(scroll_offset, min(len(items), scroll_offset + visible_rows))
):
y = draw_i + 3
if y >= max_y - 1:
break
check = "" if i in chosen else " "
arrow = "" if i == cursor else " "
line = f" {arrow} [{check}] {items[i]}"
attr = curses.A_NORMAL
if i == cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
try:
stdscr.addnstr(y, 0, line, max_x - 1, attr)
except curses.error:
pass
stdscr.refresh()
key = stdscr.getch()
if key in (curses.KEY_UP, ord("k")):
cursor = (cursor - 1) % len(items)
elif key in (curses.KEY_DOWN, ord("j")):
cursor = (cursor + 1) % len(items)
elif key == ord(" "):
chosen.symmetric_difference_update({cursor})
elif key in (curses.KEY_ENTER, 10, 13):
result_holder[0] = set(chosen)
return
elif key in (27, ord("q")):
result_holder[0] = cancel_returns
return
curses.wrapper(_draw)
return result_holder[0] if result_holder[0] is not None else cancel_returns
except Exception:
return _numbered_fallback(title, items, selected, cancel_returns)
def _numbered_fallback(
title: str,
items: List[str],
selected: Set[int],
cancel_returns: Set[int],
) -> Set[int]:
"""Text-based toggle fallback for terminals without curses."""
chosen = set(selected)
print(color(f"\n {title}", Colors.YELLOW))
print(color(" Toggle by number, Enter to confirm.\n", Colors.DIM))
while True:
for i, label in enumerate(items):
marker = color("[✓]", Colors.GREEN) if i in chosen else "[ ]"
print(f" {marker} {i + 1:>2}. {label}")
print()
try:
val = input(color(" Toggle # (or Enter to confirm): ", Colors.DIM)).strip()
if not val:
break
idx = int(val) - 1
if 0 <= idx < len(items):
chosen.symmetric_difference_update({idx})
except (ValueError, KeyboardInterrupt, EOFError):
return cancel_returns
print()
return chosen

View file

@ -11,7 +11,8 @@ Config stored in ~/.hermes/config.yaml under:
telegram: [skill-c] telegram: [skill-c]
cli: [] cli: []
""" """
from typing import Dict, List, Set, Optional from typing import Dict, List, Optional, Set
from hermes_cli.config import load_config, save_config from hermes_cli.config import load_config, save_config
from hermes_cli.colors import Colors, color from hermes_cli.colors import Colors, color
@ -48,163 +49,23 @@ def save_disabled_skills(config: dict, disabled: Set[str], platform: Optional[st
save_config(config) save_config(config)
# ─── Skill Discovery ───────────────────────────────────────────────────────── # ─── Skill Discovery ─────────────────────────────────────────────────────────
def _list_all_skills_unfiltered() -> List[dict]: def _list_all_skills() -> List[dict]:
"""Return all installed skills ignoring disabled state.""" """Return all installed skills (ignoring disabled state)."""
try: try:
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, _get_category_from_path, MAX_NAME_LENGTH, MAX_DESCRIPTION_LENGTH from tools.skills_tool import _find_all_skills
skills = [] return _find_all_skills(skip_disabled=True)
if not SKILLS_DIR.exists():
return skills
for skill_md in SKILLS_DIR.rglob("SKILL.md"):
if any(part in ('.git', '.github', '.hub') for part in skill_md.parts):
continue
skill_dir = skill_md.parent
try:
content = skill_md.read_text(encoding='utf-8')
frontmatter, body = _parse_frontmatter(content)
if not skill_matches_platform(frontmatter):
continue
name = frontmatter.get('name', skill_dir.name)[:MAX_NAME_LENGTH]
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)
skills.append({"name": name, "description": description, "category": category})
except Exception:
continue
return skills
except Exception: except Exception:
return [] return []
def _get_categories(skills: List[dict]) -> List[str]: def _get_categories(skills: List[dict]) -> List[str]:
"""Return sorted unique category names (None -> 'uncategorized').""" """Return sorted unique category names (None -> 'uncategorized')."""
cats = set() return sorted({s["category"] or "uncategorized" for s in skills})
for s in skills:
cats.add(s["category"] or "uncategorized")
return sorted(cats)
# ─── Checklist UI ───────────────────────────────────────────────────────────── # ─── Platform Selection ──────────────────────────────────────────────────────
def _prompt_checklist(title: str, items: List[str], disabled_items: Set[str]) -> Set[str]:
"""Generic curses multi-select. Returns set of DISABLED item names."""
pre_disabled = {i for i, item in enumerate(items) if item in disabled_items}
try:
import curses
selected = set(pre_disabled)
result_holder = [None]
def _curses_ui(stdscr):
curses.curs_set(0)
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
curses.init_pair(3, 8, -1) # dim gray
cursor = 0
scroll_offset = 0
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
try:
hattr = curses.A_BOLD | (curses.color_pair(2) if curses.has_colors() else 0)
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
stdscr.addnstr(1, 0, " ↑↓ navigate SPACE toggle ENTER confirm ESC cancel", max_x - 1,
curses.A_DIM)
except curses.error:
pass
visible_rows = max_y - 3
if cursor < scroll_offset:
scroll_offset = cursor
elif cursor >= scroll_offset + visible_rows:
scroll_offset = cursor - visible_rows + 1
for draw_i, i in enumerate(range(scroll_offset, min(len(items), scroll_offset + visible_rows))):
y = draw_i + 3
if y >= max_y - 1:
break
is_disabled = i in selected
check = " " if is_disabled else ""
arrow = "" if i == cursor else " "
line = f" {arrow} [{check}] {items[i]}"
attr = curses.A_NORMAL
if i == cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
try:
stdscr.addnstr(y, 0, line, max_x - 1, attr)
except curses.error:
pass
stdscr.refresh()
key = stdscr.getch()
if key in (curses.KEY_UP, ord('k')):
cursor = (cursor - 1) % len(items)
elif key in (curses.KEY_DOWN, ord('j')):
cursor = (cursor + 1) % len(items)
elif key == ord(' '):
if cursor in selected:
selected.discard(cursor)
else:
selected.add(cursor)
elif key in (curses.KEY_ENTER, 10, 13):
result_holder[0] = {items[i] for i in selected}
return
elif key in (27, ord('q')):
result_holder[0] = disabled_items
return
curses.wrapper(_curses_ui)
return result_holder[0] if result_holder[0] is not None else disabled_items
except Exception:
return _numbered_toggle(title, items, disabled_items)
def _numbered_toggle(title: str, items: List[str], disabled: Set[str]) -> Set[str]:
"""Fallback text-based toggle."""
current = set(disabled)
while True:
print()
print(color(f"{title}", Colors.BOLD))
for i, item in enumerate(items, 1):
mark = "" if item not in current else " "
print(f" {i:3}. [{mark}] {item}")
print()
print(color(" Number to toggle, 's' save, 'q' cancel:", Colors.DIM))
try:
raw = input("> ").strip()
except (KeyboardInterrupt, EOFError):
return disabled
if raw.lower() == 's':
return current
if raw.lower() == 'q':
return disabled
try:
idx = int(raw) - 1
if 0 <= idx < len(items):
name = items[idx]
if name in current:
current.discard(name)
print(color(f"{name} enabled", Colors.GREEN))
else:
current.add(name)
print(color(f"{name} disabled", Colors.DIM))
except ValueError:
print(color(" Invalid input", Colors.DIM))
# ─── Platform Selection ───────────────────────────────────────────────────────
def _select_platform() -> Optional[str]: def _select_platform() -> Optional[str]:
"""Ask user which platform to configure, or global.""" """Ask user which platform to configure, or global."""
@ -230,29 +91,34 @@ def _select_platform() -> Optional[str]:
return None return None
# ─── Category Toggle ───────────────────────────────────────────────────────── # ─── Category Toggle ─────────────────────────────────────────────────────────
def _toggle_by_category(skills: List[dict], disabled: Set[str]) -> Set[str]: def _toggle_by_category(skills: List[dict], disabled: Set[str]) -> Set[str]:
"""Toggle all skills in a category at once.""" """Toggle all skills in a category at once."""
categories = _get_categories(skills) from hermes_cli.curses_ui import curses_checklist
cat_items = []
cat_disabled = set()
for cat in categories:
cat_skills = [s["name"] for s in skills if (s["category"] or "uncategorized") == cat]
cat_items.append(f"{cat} ({len(cat_skills)} skills)")
if all(s in disabled for s in cat_skills):
cat_disabled.add(f"{cat} ({len(cat_skills)} skills)")
new_cat_disabled = _prompt_checklist("Categories — disable entire categories", cat_items, cat_disabled) categories = _get_categories(skills)
cat_labels = []
# A category is "enabled" (checked) when NOT all its skills are disabled
pre_selected = set()
for i, cat in enumerate(categories):
cat_skills = [s["name"] for s in skills if (s["category"] or "uncategorized") == cat]
cat_labels.append(f"{cat} ({len(cat_skills)} skills)")
if not all(s in disabled for s in cat_skills):
pre_selected.add(i)
chosen = curses_checklist(
"Categories — toggle entire categories",
cat_labels, pre_selected, cancel_returns=pre_selected,
)
new_disabled = set(disabled) new_disabled = set(disabled)
for i, cat in enumerate(categories): for i, cat in enumerate(categories):
label = cat_items[i] cat_skills = {s["name"] for s in skills if (s["category"] or "uncategorized") == cat}
cat_skills = [s["name"] for s in skills if (s["category"] or "uncategorized") == cat] if i in chosen:
if label in new_cat_disabled: new_disabled -= cat_skills # category enabled → remove from disabled
new_disabled.update(cat_skills)
else: else:
new_disabled -= set(cat_skills) new_disabled |= cat_skills # category disabled → add to disabled
return new_disabled return new_disabled
@ -260,8 +126,10 @@ def _toggle_by_category(skills: List[dict], disabled: Set[str]) -> Set[str]:
def skills_command(args=None): def skills_command(args=None):
"""Entry point for `hermes skills`.""" """Entry point for `hermes skills`."""
from hermes_cli.curses_ui import curses_checklist
config = load_config() config = load_config()
skills = _list_all_skills_unfiltered() skills = _list_all_skills()
if not skills: if not skills:
print(color(" No skills installed.", Colors.DIM)) print(color(" No skills installed.", Colors.DIM))
@ -288,25 +156,19 @@ def skills_command(args=None):
if mode == "2": if mode == "2":
new_disabled = _toggle_by_category(skills, disabled) new_disabled = _toggle_by_category(skills, disabled)
else: else:
skill_items = [ # Build labels and map indices → skill names
labels = [
f"{s['name']} ({s['category'] or 'uncategorized'}) — {s['description'][:55]}" f"{s['name']} ({s['category'] or 'uncategorized'}) — {s['description'][:55]}"
for s in skills for s in skills
] ]
disabled_labels = { # "selected" = enabled (not disabled) — matches the [✓] convention
f"{s['name']} ({s['category'] or 'uncategorized'}) — {s['description'][:55]}" pre_selected = {i for i, s in enumerate(skills) if s["name"] not in disabled}
for s in skills if s["name"] in disabled chosen = curses_checklist(
} f"Skills for {platform_label}",
new_disabled_labels = _prompt_checklist( labels, pre_selected, cancel_returns=pre_selected,
f"Skills for {platform_label} — space=toggle, enter=confirm",
skill_items,
disabled_labels
) )
# Map labels back to skill names # Anything NOT chosen is disabled
label_to_name = { new_disabled = {skills[i]["name"] for i in range(len(skills)) if i not in chosen}
f"{s['name']} ({s['category'] or 'uncategorized'}) — {s['description'][:55]}": s["name"]
for s in skills
}
new_disabled = {label_to_name[l] for l in new_disabled_labels if l in label_to_name}
if new_disabled == disabled: if new_disabled == disabled:
print(color(" No changes.", Colors.DIM)) print(color(" No changes.", Colors.DIM))

View file

@ -463,6 +463,7 @@ def _prompt_choice(question: str, choices: list, default: int = 0) -> int:
def _prompt_toolset_checklist(platform_label: str, enabled: Set[str]) -> Set[str]: def _prompt_toolset_checklist(platform_label: str, enabled: Set[str]) -> Set[str]:
"""Multi-select checklist of toolsets. Returns set of selected toolset keys.""" """Multi-select checklist of toolsets. Returns set of selected toolset keys."""
from hermes_cli.curses_ui import curses_checklist
labels = [] labels = []
for ts_key, ts_label, ts_desc in CONFIGURABLE_TOOLSETS: for ts_key, ts_label, ts_desc in CONFIGURABLE_TOOLSETS:
@ -471,112 +472,18 @@ def _prompt_toolset_checklist(platform_label: str, enabled: Set[str]) -> Set[str
suffix = " [no API key]" suffix = " [no API key]"
labels.append(f"{ts_label} ({ts_desc}){suffix}") labels.append(f"{ts_label} ({ts_desc}){suffix}")
pre_selected_indices = [ pre_selected = {
i for i, (ts_key, _, _) in enumerate(CONFIGURABLE_TOOLSETS) i for i, (ts_key, _, _) in enumerate(CONFIGURABLE_TOOLSETS)
if ts_key in enabled if ts_key in enabled
] }
# Curses-based multi-select — arrow keys + space to toggle + enter to confirm. chosen = curses_checklist(
# simple_term_menu has rendering bugs in tmux, iTerm, and other terminals. f"Tools for {platform_label}",
try: labels,
import curses pre_selected,
selected = set(pre_selected_indices) cancel_returns=pre_selected,
result_holder = [None] )
return {CONFIGURABLE_TOOLSETS[i][0] for i in chosen}
def _curses_checklist(stdscr):
curses.curs_set(0)
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
curses.init_pair(3, 8, -1) # dim gray
cursor = 0
scroll_offset = 0
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
header = f"Tools for {platform_label} — ↑↓ navigate, SPACE toggle, ENTER confirm, ESC cancel"
try:
stdscr.addnstr(0, 0, header, max_x - 1, curses.A_BOLD | curses.color_pair(2) if curses.has_colors() else curses.A_BOLD)
except curses.error:
pass
visible_rows = max_y - 3
if cursor < scroll_offset:
scroll_offset = cursor
elif cursor >= scroll_offset + visible_rows:
scroll_offset = cursor - visible_rows + 1
for draw_i, i in enumerate(range(scroll_offset, min(len(labels), scroll_offset + visible_rows))):
y = draw_i + 2
if y >= max_y - 1:
break
check = "" if i in selected else " "
arrow = "" if i == cursor else " "
line = f" {arrow} [{check}] {labels[i]}"
attr = curses.A_NORMAL
if i == cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
try:
stdscr.addnstr(y, 0, line, max_x - 1, attr)
except curses.error:
pass
stdscr.refresh()
key = stdscr.getch()
if key in (curses.KEY_UP, ord('k')):
cursor = (cursor - 1) % len(labels)
elif key in (curses.KEY_DOWN, ord('j')):
cursor = (cursor + 1) % len(labels)
elif key == ord(' '):
if cursor in selected:
selected.discard(cursor)
else:
selected.add(cursor)
elif key in (curses.KEY_ENTER, 10, 13):
result_holder[0] = {CONFIGURABLE_TOOLSETS[i][0] for i in selected}
return
elif key in (27, ord('q')): # ESC or q
result_holder[0] = enabled
return
curses.wrapper(_curses_checklist)
return result_holder[0] if result_holder[0] is not None else enabled
except Exception:
pass # fall through to numbered toggle
# Final fallback: numbered toggle (Windows without curses, etc.)
selected = set(pre_selected_indices)
print(color(f"\n Tools for {platform_label}", Colors.YELLOW))
print(color(" Toggle by number, Enter to confirm.\n", Colors.DIM))
while True:
for i, label in enumerate(labels):
marker = color("[✓]", Colors.GREEN) if i in selected else "[ ]"
print(f" {marker} {i + 1:>2}. {label}")
print()
try:
val = input(color(" Toggle # (or Enter to confirm): ", Colors.DIM)).strip()
if not val:
break
idx = int(val) - 1
if 0 <= idx < len(labels):
if idx in selected:
selected.discard(idx)
else:
selected.add(idx)
except (ValueError, KeyboardInterrupt, EOFError):
return enabled
print()
return {CONFIGURABLE_TOOLSETS[i][0] for i in selected}
# ─── Provider-Aware Configuration ──────────────────────────────────────────── # ─── Provider-Aware Configuration ────────────────────────────────────────────

View file

@ -146,8 +146,8 @@ class TestIsSkillDisabled:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestFindAllSkillsFiltering: class TestFindAllSkillsFiltering:
@patch("tools.skills_tool._is_skill_disabled") @patch("tools.skills_tool._get_disabled_skill_names", return_value={"my-skill"})
@patch("tools.skills_tool.skill_matches_platform") @patch("tools.skills_tool.skill_matches_platform", return_value=True)
@patch("tools.skills_tool.SKILLS_DIR") @patch("tools.skills_tool.SKILLS_DIR")
def test_disabled_skill_excluded(self, mock_dir, mock_platform, mock_disabled, tmp_path): def test_disabled_skill_excluded(self, mock_dir, mock_platform, mock_disabled, tmp_path):
skill_dir = tmp_path / "my-skill" skill_dir = tmp_path / "my-skill"
@ -156,14 +156,12 @@ class TestFindAllSkillsFiltering:
skill_md.write_text("---\nname: my-skill\ndescription: A test skill\n---\nContent") skill_md.write_text("---\nname: my-skill\ndescription: A test skill\n---\nContent")
mock_dir.exists.return_value = True mock_dir.exists.return_value = True
mock_dir.rglob.return_value = [skill_md] mock_dir.rglob.return_value = [skill_md]
mock_platform.return_value = True
mock_disabled.return_value = True
from tools.skills_tool import _find_all_skills from tools.skills_tool import _find_all_skills
skills = _find_all_skills() skills = _find_all_skills()
assert not any(s["name"] == "my-skill" for s in skills) assert not any(s["name"] == "my-skill" for s in skills)
@patch("tools.skills_tool._is_skill_disabled") @patch("tools.skills_tool._get_disabled_skill_names", return_value=set())
@patch("tools.skills_tool.skill_matches_platform") @patch("tools.skills_tool.skill_matches_platform", return_value=True)
@patch("tools.skills_tool.SKILLS_DIR") @patch("tools.skills_tool.SKILLS_DIR")
def test_enabled_skill_included(self, mock_dir, mock_platform, mock_disabled, tmp_path): def test_enabled_skill_included(self, mock_dir, mock_platform, mock_disabled, tmp_path):
skill_dir = tmp_path / "my-skill" skill_dir = tmp_path / "my-skill"
@ -172,12 +170,25 @@ class TestFindAllSkillsFiltering:
skill_md.write_text("---\nname: my-skill\ndescription: A test skill\n---\nContent") skill_md.write_text("---\nname: my-skill\ndescription: A test skill\n---\nContent")
mock_dir.exists.return_value = True mock_dir.exists.return_value = True
mock_dir.rglob.return_value = [skill_md] mock_dir.rglob.return_value = [skill_md]
mock_platform.return_value = True
mock_disabled.return_value = False
from tools.skills_tool import _find_all_skills from tools.skills_tool import _find_all_skills
skills = _find_all_skills() skills = _find_all_skills()
assert any(s["name"] == "my-skill" for s in skills) assert any(s["name"] == "my-skill" for s in skills)
@patch("tools.skills_tool._get_disabled_skill_names", return_value={"my-skill"})
@patch("tools.skills_tool.skill_matches_platform", return_value=True)
@patch("tools.skills_tool.SKILLS_DIR")
def test_skip_disabled_returns_all(self, mock_dir, mock_platform, mock_disabled, tmp_path):
"""skip_disabled=True ignores the disabled set (for config UI)."""
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
skill_md = skill_dir / "SKILL.md"
skill_md.write_text("---\nname: my-skill\ndescription: A test skill\n---\nContent")
mock_dir.exists.return_value = True
mock_dir.rglob.return_value = [skill_md]
from tools.skills_tool import _find_all_skills
skills = _find_all_skills(skip_disabled=True)
assert any(s["name"] == "my-skill" for s in skills)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _get_categories # _get_categories

View file

@ -68,7 +68,7 @@ import os
import re import re
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple from typing import Dict, Any, List, Optional, Set, Tuple
import yaml import yaml
@ -223,62 +223,80 @@ def _parse_tags(tags_value) -> List[str]:
def _is_skill_disabled(name: str, platform: str = None) -> bool: def _get_disabled_skill_names() -> Set[str]:
"""Check if a skill is disabled in config, globally or for a specific platform. """Load disabled skill names from config (once per call).
Platform is resolved from the ``platform`` argument, then the Resolves platform from ``HERMES_PLATFORM`` env var, falls back to
``HERMES_PLATFORM`` env var, then falls back to the global disabled list. the global disabled list.
""" """
import os import os
try: try:
from hermes_cli.config import load_config from hermes_cli.config import load_config
config = load_config() config = load_config()
skills_cfg = config.get("skills", {}) skills_cfg = config.get("skills", {})
# Resolve platform resolved_platform = os.getenv("HERMES_PLATFORM")
if resolved_platform:
platform_disabled = skills_cfg.get("platform_disabled", {}).get(resolved_platform)
if platform_disabled is not None:
return set(platform_disabled)
return set(skills_cfg.get("disabled", []))
except Exception:
return set()
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") resolved_platform = platform or os.getenv("HERMES_PLATFORM")
if resolved_platform: if resolved_platform:
platform_disabled = skills_cfg.get("platform_disabled", {}).get(resolved_platform) platform_disabled = skills_cfg.get("platform_disabled", {}).get(resolved_platform)
if platform_disabled is not None: if platform_disabled is not None:
return name in platform_disabled return name in platform_disabled
# Fall back to global disabled list
return name in skills_cfg.get("disabled", []) return name in skills_cfg.get("disabled", [])
except Exception: except Exception:
return False return False
def _find_all_skills() -> List[Dict[str, Any]]:
""" def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]:
Recursively find all skills in ~/.hermes/skills/. """Recursively find all skills in ~/.hermes/skills/.
Returns metadata for progressive disclosure (tier 1): Args:
- name, description, category skip_disabled: If True, return ALL skills regardless of disabled
state (used by ``hermes skills`` config UI). Default False
filters out disabled skills.
Returns: Returns:
List of skill metadata dicts List of skill metadata dicts (name, description, category).
""" """
skills = [] skills = []
if not SKILLS_DIR.exists(): if not SKILLS_DIR.exists():
return skills return skills
# Load disabled set once (not per-skill)
disabled = set() if skip_disabled else _get_disabled_skill_names()
for skill_md in SKILLS_DIR.rglob("SKILL.md"): for skill_md in SKILLS_DIR.rglob("SKILL.md"):
if any(part in ('.git', '.github', '.hub') for part in skill_md.parts): if any(part in ('.git', '.github', '.hub') for part in skill_md.parts):
continue continue
skill_dir = skill_md.parent skill_dir = skill_md.parent
try: try:
content = skill_md.read_text(encoding='utf-8') content = skill_md.read_text(encoding='utf-8')
frontmatter, body = _parse_frontmatter(content) frontmatter, body = _parse_frontmatter(content)
# Skip skills incompatible with the current OS platform
if not skill_matches_platform(frontmatter): if not skill_matches_platform(frontmatter):
continue continue
name = frontmatter.get('name', skill_dir.name)[:MAX_NAME_LENGTH] name = frontmatter.get('name', skill_dir.name)[:MAX_NAME_LENGTH]
# Skip disabled skills if name in disabled:
if _is_skill_disabled(name):
continue continue
description = frontmatter.get('description', '') description = frontmatter.get('description', '')
if not description: if not description:
for line in body.strip().split('\n'): for line in body.strip().split('\n'):
@ -286,25 +304,25 @@ def _find_all_skills() -> List[Dict[str, Any]]:
if line and not line.startswith('#'): if line and not line.startswith('#'):
description = line description = line
break break
if len(description) > MAX_DESCRIPTION_LENGTH: if len(description) > MAX_DESCRIPTION_LENGTH:
description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..." description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..."
category = _get_category_from_path(skill_md) category = _get_category_from_path(skill_md)
skills.append({ skills.append({
"name": name, "name": name,
"description": description, "description": description,
"category": category, "category": category,
}) })
except (UnicodeDecodeError, PermissionError) as e: except (UnicodeDecodeError, PermissionError) as e:
logger.warning("Failed to read skill file %s: %s", skill_md, e) logger.warning("Failed to read skill file %s: %s", skill_md, e)
continue continue
except Exception as e: except Exception as e:
logger.warning("Error parsing skill %s: %s", skill_md, e, exc_info=True) logger.warning("Error parsing skill %s: %s", skill_md, e, exc_info=True)
continue continue
return skills return skills