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

View file

@ -11,7 +11,8 @@ Config stored in ~/.hermes/config.yaml under:
telegram: [skill-c]
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.colors import Colors, color
@ -48,163 +49,23 @@ def save_disabled_skills(config: dict, disabled: Set[str], platform: Optional[st
save_config(config)
# ─── Skill Discovery ─────────────────────────────────────────────────────────
# ─── Skill Discovery ─────────────────────────────────────────────────────────
def _list_all_skills_unfiltered() -> List[dict]:
"""Return all installed skills ignoring disabled state."""
def _list_all_skills() -> List[dict]:
"""Return all installed skills (ignoring disabled state)."""
try:
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, _get_category_from_path, MAX_NAME_LENGTH, MAX_DESCRIPTION_LENGTH
skills = []
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
from tools.skills_tool import _find_all_skills
return _find_all_skills(skip_disabled=True)
except Exception:
return []
def _get_categories(skills: List[dict]) -> List[str]:
"""Return sorted unique category names (None -> 'uncategorized')."""
cats = set()
for s in skills:
cats.add(s["category"] or "uncategorized")
return sorted(cats)
return sorted({s["category"] or "uncategorized" for s in skills})
# ─── Checklist UI ─────────────────────────────────────────────────────────────
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 ───────────────────────────────────────────────────────
# ─── Platform Selection ──────────────────────────────────────────────────────
def _select_platform() -> Optional[str]:
"""Ask user which platform to configure, or global."""
@ -230,29 +91,34 @@ def _select_platform() -> Optional[str]:
return None
# ─── Category Toggle ─────────────────────────────────────────────────────────
# ─── Category Toggle ─────────────────────────────────────────────────────────
def _toggle_by_category(skills: List[dict], disabled: Set[str]) -> Set[str]:
"""Toggle all skills in a category at once."""
categories = _get_categories(skills)
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)")
from hermes_cli.curses_ui import curses_checklist
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)
for i, cat in enumerate(categories):
label = cat_items[i]
cat_skills = [s["name"] for s in skills if (s["category"] or "uncategorized") == cat]
if label in new_cat_disabled:
new_disabled.update(cat_skills)
cat_skills = {s["name"] for s in skills if (s["category"] or "uncategorized") == cat}
if i in chosen:
new_disabled -= cat_skills # category enabled → remove from disabled
else:
new_disabled -= set(cat_skills)
new_disabled |= cat_skills # category disabled → add to 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):
"""Entry point for `hermes skills`."""
from hermes_cli.curses_ui import curses_checklist
config = load_config()
skills = _list_all_skills_unfiltered()
skills = _list_all_skills()
if not skills:
print(color(" No skills installed.", Colors.DIM))
@ -288,25 +156,19 @@ def skills_command(args=None):
if mode == "2":
new_disabled = _toggle_by_category(skills, disabled)
else:
skill_items = [
# Build labels and map indices → skill names
labels = [
f"{s['name']} ({s['category'] or 'uncategorized'}) — {s['description'][:55]}"
for s in skills
]
disabled_labels = {
f"{s['name']} ({s['category'] or 'uncategorized'}) — {s['description'][:55]}"
for s in skills if s["name"] in disabled
}
new_disabled_labels = _prompt_checklist(
f"Skills for {platform_label} — space=toggle, enter=confirm",
skill_items,
disabled_labels
# "selected" = enabled (not disabled) — matches the [✓] convention
pre_selected = {i for i, s in enumerate(skills) if s["name"] not in disabled}
chosen = curses_checklist(
f"Skills for {platform_label}",
labels, pre_selected, cancel_returns=pre_selected,
)
# Map labels back to skill names
label_to_name = {
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}
# Anything NOT chosen is disabled
new_disabled = {skills[i]["name"] for i in range(len(skills)) if i not in chosen}
if new_disabled == disabled:
print(color(" No changes.", Colors.DIM))