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:
140
hermes_cli/curses_ui.py
Normal file
140
hermes_cli/curses_ui.py
Normal 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
|
||||
@@ -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))
|
||||
|
||||
@@ -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]:
|
||||
"""Multi-select checklist of toolsets. Returns set of selected toolset keys."""
|
||||
from hermes_cli.curses_ui import curses_checklist
|
||||
|
||||
labels = []
|
||||
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]"
|
||||
labels.append(f"{ts_label} ({ts_desc}){suffix}")
|
||||
|
||||
pre_selected_indices = [
|
||||
pre_selected = {
|
||||
i for i, (ts_key, _, _) in enumerate(CONFIGURABLE_TOOLSETS)
|
||||
if ts_key in enabled
|
||||
]
|
||||
}
|
||||
|
||||
# Curses-based multi-select — arrow keys + space to toggle + enter to confirm.
|
||||
# simple_term_menu has rendering bugs in tmux, iTerm, and other terminals.
|
||||
try:
|
||||
import curses
|
||||
selected = set(pre_selected_indices)
|
||||
result_holder = [None]
|
||||
|
||||
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}
|
||||
chosen = curses_checklist(
|
||||
f"Tools for {platform_label}",
|
||||
labels,
|
||||
pre_selected,
|
||||
cancel_returns=pre_selected,
|
||||
)
|
||||
return {CONFIGURABLE_TOOLSETS[i][0] for i in chosen}
|
||||
|
||||
|
||||
# ─── Provider-Aware Configuration ────────────────────────────────────────────
|
||||
|
||||
@@ -146,8 +146,8 @@ class TestIsSkillDisabled:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFindAllSkillsFiltering:
|
||||
@patch("tools.skills_tool._is_skill_disabled")
|
||||
@patch("tools.skills_tool.skill_matches_platform")
|
||||
@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_disabled_skill_excluded(self, mock_dir, mock_platform, mock_disabled, tmp_path):
|
||||
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")
|
||||
mock_dir.exists.return_value = True
|
||||
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
|
||||
skills = _find_all_skills()
|
||||
assert not any(s["name"] == "my-skill" for s in skills)
|
||||
|
||||
@patch("tools.skills_tool._is_skill_disabled")
|
||||
@patch("tools.skills_tool.skill_matches_platform")
|
||||
@patch("tools.skills_tool._get_disabled_skill_names", return_value=set())
|
||||
@patch("tools.skills_tool.skill_matches_platform", return_value=True)
|
||||
@patch("tools.skills_tool.SKILLS_DIR")
|
||||
def test_enabled_skill_included(self, mock_dir, mock_platform, mock_disabled, tmp_path):
|
||||
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")
|
||||
mock_dir.exists.return_value = True
|
||||
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
|
||||
skills = _find_all_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
|
||||
|
||||
@@ -68,7 +68,7 @@ import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from typing import Dict, Any, List, Optional, Set, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -223,62 +223,80 @@ def _parse_tags(tags_value) -> List[str]:
|
||||
|
||||
|
||||
|
||||
def _is_skill_disabled(name: str, platform: str = None) -> bool:
|
||||
"""Check if a skill is disabled in config, globally or for a specific platform.
|
||||
def _get_disabled_skill_names() -> Set[str]:
|
||||
"""Load disabled skill names from config (once per call).
|
||||
|
||||
Platform is resolved from the ``platform`` argument, then the
|
||||
``HERMES_PLATFORM`` env var, then falls back to the global disabled list.
|
||||
Resolves platform from ``HERMES_PLATFORM`` env var, falls back to
|
||||
the global disabled list.
|
||||
"""
|
||||
import os
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
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")
|
||||
if resolved_platform:
|
||||
platform_disabled = skills_cfg.get("platform_disabled", {}).get(resolved_platform)
|
||||
if platform_disabled is not None:
|
||||
return name in platform_disabled
|
||||
# Fall back to global disabled list
|
||||
return name in skills_cfg.get("disabled", [])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _find_all_skills() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Recursively find all skills in ~/.hermes/skills/.
|
||||
|
||||
Returns metadata for progressive disclosure (tier 1):
|
||||
- name, description, category
|
||||
|
||||
|
||||
def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]:
|
||||
"""Recursively find all skills in ~/.hermes/skills/.
|
||||
|
||||
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
|
||||
List of skill metadata dicts (name, description, category).
|
||||
"""
|
||||
skills = []
|
||||
|
||||
|
||||
if not SKILLS_DIR.exists():
|
||||
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"):
|
||||
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)
|
||||
|
||||
# Skip skills incompatible with the current OS platform
|
||||
if not skill_matches_platform(frontmatter):
|
||||
continue
|
||||
|
||||
|
||||
name = frontmatter.get('name', skill_dir.name)[:MAX_NAME_LENGTH]
|
||||
# Skip disabled skills
|
||||
if _is_skill_disabled(name):
|
||||
if name in disabled:
|
||||
continue
|
||||
|
||||
|
||||
description = frontmatter.get('description', '')
|
||||
if not description:
|
||||
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('#'):
|
||||
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 (UnicodeDecodeError, PermissionError) as e:
|
||||
logger.warning("Failed to read skill file %s: %s", skill_md, e)
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning("Error parsing skill %s: %s", skill_md, e, exc_info=True)
|
||||
continue
|
||||
|
||||
|
||||
return skills
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user