diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py new file mode 100644 index 00000000..f819b1ff --- /dev/null +++ b/hermes_cli/curses_ui.py @@ -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 diff --git a/hermes_cli/skills_config.py b/hermes_cli/skills_config.py index 256f7ba5..56abed8e 100644 --- a/hermes_cli/skills_config.py +++ b/hermes_cli/skills_config.py @@ -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)) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 5632327e..8b060016 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -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 ──────────────────────────────────────────── diff --git a/tests/hermes_cli/test_skills_config.py b/tests/hermes_cli/test_skills_config.py index 0cf57003..41329793 100644 --- a/tests/hermes_cli/test_skills_config.py +++ b/tests/hermes_cli/test_skills_config.py @@ -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 diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 27ae24af..3a78bdfb 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -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