diff --git a/api/routes.py b/api/routes.py index a62bc24f..efc76b06 100644 --- a/api/routes.py +++ b/api/routes.py @@ -231,7 +231,7 @@ def _skills_list_from_dir(skills_dir: Path, category: str | None = None) -> dict if not skill_matches_platform(frontmatter): continue name = frontmatter.get("name", skill_dir.name)[:64] - if name in seen_names or name in disabled: + if name in seen_names: continue description = frontmatter.get("description", "") if not description: @@ -248,6 +248,7 @@ def _skills_list_from_dir(skills_dir: Path, category: str | None = None) -> dict "name": name, "description": description, "category": _skill_category_from_path(skill_md, search_dirs), + "disabled": name in disabled, } ) except (UnicodeDecodeError, PermissionError) as e: @@ -939,6 +940,11 @@ from api.config import ( get_webui_session_save_mode, STREAM_GOAL_RELATED, PENDING_GOAL_CONTINUATION, + _get_config_path, + _load_yaml_config_file, + _save_yaml_config_file, + reload_config, + _cfg_lock, ) from api.helpers import ( require, @@ -5485,6 +5491,9 @@ def handle_post(handler, parsed) -> bool: if parsed.path == "/api/skills/delete": return _handle_skill_delete(handler, body) + if parsed.path == "/api/skills/toggle": + return _handle_skill_toggle(handler, body) + # ── Memory (POST) ── if parsed.path == "/api/memory/write": return _handle_memory_write(handler, body) @@ -10951,6 +10960,81 @@ def _handle_skill_delete(handler, body): return j(handler, {"ok": True, "name": body["name"]}) +def _normalize_names_list(names) -> list[str]: + """Normalize a config value (None/str/list) into a deduplicated str list.""" + if names is None: + return [] + if isinstance(names, str): + names = [names] + elif not isinstance(names, list): + names = list(names) if names else [] + return list(dict.fromkeys(str(d).strip() for d in names if str(d).strip())) + + +def _toggle_name_in_list(names, name: str, enabled: bool) -> list[str]: + """Add or remove *name* from *names*, returning a new list.""" + names = _normalize_names_list(names) + if enabled: + return [d for d in names if d != name] + if name not in names: + names.append(name) + return names + + +def _handle_skill_toggle(handler, body): + """Toggle a skill's enabled/disabled state in the active profile's config.yaml. + + Writes through to ``skills.platform_disabled.webui`` when that key exists + so the toggle takes effect for WebUI sessions (the agent's + ``get_disabled_skill_names`` checks platform-specific lists first when + ``HERMES_SESSION_PLATFORM`` is set). + """ + try: + require(body, "name", "enabled") + except ValueError as e: + return bad(handler, str(e)) + + name = body["name"].strip() + enabled = bool(body["enabled"]) + + # Validate the skill exists in the filesystem + skills_dir = _active_skills_dir() + search_dirs = _active_skill_search_dirs(skills_dir) + skill_dir, skill_md = _find_skill_in_dirs(name, search_dirs) + if not skill_md: + return bad(handler, f"Skill '{name}' not found", 404) + + config_path = _get_config_path() + with _cfg_lock: + cfg = _load_yaml_config_file(config_path) + + # Ensure skills section exists as a dict + if "skills" not in cfg or not isinstance(cfg["skills"], dict): + cfg["skills"] = {} + skills_cfg = cfg["skills"] + + # Always update the global disabled list + skills_cfg["disabled"] = _toggle_name_in_list( + skills_cfg.get("disabled"), name, enabled + ) + + # Write-through to platform_disabled.webui if it exists so that the + # toggle takes effect for WebUI sessions (the agent checks the + # platform-specific list first when HERMES_SESSION_PLATFORM=webui). + platform_disabled = skills_cfg.get("platform_disabled") + if isinstance(platform_disabled, dict) and "webui" in platform_disabled: + platform_disabled["webui"] = _toggle_name_in_list( + platform_disabled["webui"], name, enabled + ) + + cfg["skills"] = skills_cfg + _save_yaml_config_file(config_path, cfg) + + reload_config() # outside with block — reload_config() acquires the lock itself + + return j(handler, {"ok": True, "name": name, "enabled": enabled}) + + def _handle_memory_write(handler, body): try: require(body, "section", "content") diff --git a/static/i18n.js b/static/i18n.js index ddf6f689..dcda7f35 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1050,6 +1050,9 @@ const LOCALES = { clear_conversation_message: 'Clear all messages? This cannot be undone.', clear_failed: 'Clear failed: ', skills_no_match: 'No skills match.', + skill_enabled: 'Enabled', + skill_disabled: 'Disabled', + skill_toggle_failed: 'Failed to toggle skill: ', linked_files: 'Linked Files', skill_load_failed: 'Could not load skill: ', skill_file_load_failed: 'Could not load file: ', @@ -2273,6 +2276,9 @@ const LOCALES = { clear_conversation_message: 'Cancellare tutti i messaggi? Questa azione non può essere annullata.', clear_failed: 'Cancellazione fallita: ', skills_no_match: 'Nessuna skill corrispondente.', + skill_enabled: 'Abilitato', + skill_disabled: 'Disabilitato', + skill_toggle_failed: 'Errore nell\'attivare la skill: ', linked_files: 'File Collegati', skill_load_failed: 'Impossibile caricare la skill: ', skill_file_load_failed: 'Impossibile caricare il file: ', @@ -3501,6 +3507,9 @@ const LOCALES = { clear_conversation_message: 'すべてのメッセージをクリアしますか? この操作は取り消せません。', clear_failed: 'クリア失敗: ', skills_no_match: '一致するスキルがありません。', + skill_enabled: '有効', + skill_disabled: '無効', + skill_toggle_failed: 'スキルの切り替えに失敗しました: ', linked_files: 'リンクされたファイル', skill_load_failed: 'スキルを読み込めませんでした: ', skill_file_load_failed: 'ファイルを読み込めませんでした: ', @@ -4475,6 +4484,9 @@ const LOCALES = { clear_conversation_message: 'Очистить все сообщения? Это действие нельзя отменить.', clear_failed: 'Не удалось очистить: ', skills_no_match: 'Подходящих навыков не найдено.', + skill_enabled: 'Включено', + skill_disabled: 'Отключено', + skill_toggle_failed: 'Не удалось переключить навык: ', linked_files: 'Связанные файлы', skill_load_failed: 'Не удалось загрузить навык: ', skill_file_load_failed: 'Не удалось загрузить файл: ', @@ -5642,6 +5654,9 @@ const LOCALES = { clear_conversation_message: 'Clear all messages? This cannot be undone.', clear_failed: 'Clear failed: ', skills_no_match: 'No skills match.', + skill_enabled: 'Habilitado', + skill_disabled: 'Deshabilitado', + skill_toggle_failed: 'Error al cambiar skill: ', linked_files: 'Linked Files', skill_load_failed: 'Could not load skill: ', skill_file_load_failed: 'Could not load file: ', @@ -6994,6 +7009,9 @@ const LOCALES = { clear_conversation_message: 'Alle Nachrichten werden gelöscht.', clear_failed: 'Löschen fehlgeschlagen.', skills_no_match: 'Keine passende Fähigkeit.', + skill_enabled: 'Aktiviert', + skill_disabled: 'Deaktiviert', + skill_toggle_failed: 'Fehler beim Umschalten der Fähigkeit: ', linked_files: 'Verknüpfte Dateien', skill_load_failed: 'Fähigkeit konnte nicht geladen werden.', skill_file_load_failed: 'Datei konnte nicht geladen werden.', @@ -7973,6 +7991,9 @@ const LOCALES = { clear_conversation_message: '要清空所有消息吗?此操作无法撤销。', clear_failed: '清空失败:', skills_no_match: '没有匹配的技能。', + skill_enabled: '已启用', + skill_disabled: '已禁用', + skill_toggle_failed: '切换技能失败:', linked_files: '关联文件', skill_load_failed: '加载技能失败:', skill_file_load_failed: '加载文件失败:', @@ -10458,6 +10479,9 @@ const LOCALES = { clear_conversation_message: 'Limpar todas mensagens? Isso não pode ser desfeito.', clear_failed: 'Falha ao limpar: ', skills_no_match: 'Nenhuma skill corresponde.', + skill_enabled: 'Habilitado', + skill_disabled: 'Desabilitado', + skill_toggle_failed: 'Falha ao alternar skill: ', linked_files: 'Arquivos vinculados', skill_load_failed: 'Não foi possível carregar skill: ', skill_file_load_failed: 'Não foi possível carregar arquivo: ', @@ -11585,6 +11609,9 @@ const LOCALES = { clear_conversation_message: 'Clear all messages? This cannot be undone.', clear_failed: 'Clear failed: ', skills_no_match: 'No skills match.', + skill_enabled: '활성화됨', + skill_disabled: '비활성화됨', + skill_toggle_failed: '스킬 전환 실패: ', linked_files: 'Linked Files', skill_load_failed: 'Could not load skill: ', skill_file_load_failed: 'Could not load file: ', @@ -12730,6 +12757,9 @@ const LOCALES = { clear_conversation_message: 'Effacer tous les messages ? Cela ne peut pas être annulé.', clear_failed: 'Échec de la suppression :', skills_no_match: 'Aucune compétence ne correspond.', + skill_enabled: 'Activé', + skill_disabled: 'Désactivé', + skill_toggle_failed: 'Échec du basculement de compétence: ', linked_files: 'Fichiers liés', skill_load_failed: 'Impossible de charger la compétence :', skill_file_load_failed: 'Impossible de charger le fichier :', diff --git a/static/panels.js b/static/panels.js index e19731e7..bff597ce 100644 --- a/static/panels.js +++ b/static/panels.js @@ -3315,9 +3315,23 @@ function renderSkills(skills) { sec.appendChild(hdr); for (const skill of items.sort((a,b) => a.name.localeCompare(b.name))) { const el = document.createElement('div'); - el.className = 'skill-item'; + el.className = 'skill-item' + (skill.disabled ? ' disabled' : ''); el.style.display = collapsed ? 'none' : ''; - el.innerHTML = `${esc(skill.name)}${esc(skill.description||'')}`; + const isDisabled = skill.disabled || false; + const toggle = document.createElement('span'); + toggle.className = 'skill-toggle' + (isDisabled ? '' : ' enabled'); + toggle.title = isDisabled ? t('skill_disabled') : t('skill_enabled'); + toggle.addEventListener('click', (ev) => { + ev.stopPropagation(); + toggleSkill(skill.name, !isDisabled); + }); + const nameEl = document.createElement('span'); + nameEl.className = 'skill-name'; + nameEl.textContent = skill.name; + const descEl = document.createElement('span'); + descEl.className = 'skill-desc'; + descEl.textContent = skill.description || ''; + el.append(toggle, nameEl, descEl); el.onclick = () => openSkill(skill.name, el); sec.appendChild(el); } @@ -3329,6 +3343,28 @@ function filterSkills() { if (_skillsData) renderSkills(_skillsData); } + +async function toggleSkill(name, currentlyEnabled) { + const newEnabled = !currentlyEnabled; + try { + const result = await api('/api/skills/toggle', { + method: 'POST', + body: JSON.stringify({ name, enabled: newEnabled }) + }); + if (result && result.ok) { + if (_skillsData) { + const skill = _skillsData.find(s => s.name === name); + if (skill) skill.disabled = !newEnabled; + } + renderSkills(_skillsData || []); + } else { + setStatus((result && result.error) || t('skill_toggle_failed')); + } + } catch(e) { + setStatus(t('skill_toggle_failed') + e.message); + } +} + // Currently selected skill detail — kept across panel switches so re-entering // the Skills view shows the last-viewed skill. let _currentSkillDetail = null; // { name, category, content } diff --git a/static/style.css b/static/style.css index e34506e1..037be9ff 100644 --- a/static/style.css +++ b/static/style.css @@ -1090,6 +1090,12 @@ .skill-item.active{background:var(--accent-bg);color:var(--accent-text);} .skill-name{font-weight:500;flex-shrink:0;} .skill-desc{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;font-size:11px;opacity:.7;} + .skill-item.disabled{opacity:.5;} + .skill-item.disabled .skill-name{opacity:.45;} + .skill-toggle{flex-shrink:0;width:28px;height:14px;border-radius:7px;border:1px solid var(--border2);background:var(--border);cursor:pointer;position:relative;transition:all .15s;margin-top:3px;} + .skill-toggle.enabled{background:var(--accent-bg-strong);border-color:var(--accent);} + .skill-toggle::after{content:'';position:absolute;top:1px;left:1px;width:10px;height:10px;border-radius:50%;background:var(--muted);transition:transform .15s;} + .skill-toggle.enabled::after{transform:translateX(14px);background:var(--accent-text);} /* Memory panel */ .memory-panel{flex:1;overflow-y:auto;padding:12px;} .memory-section{margin-bottom:16px;} diff --git a/tests/test_skills_toggle.py b/tests/test_skills_toggle.py new file mode 100644 index 00000000..bace8d1f --- /dev/null +++ b/tests/test_skills_toggle.py @@ -0,0 +1,199 @@ +"""Tests for skill toggle (enable/disable) API and frontend.""" +from pathlib import Path + + +PANELS_JS = (Path(__file__).resolve().parent.parent / "static" / "panels.js").read_text("utf-8") +I18N_JS = (Path(__file__).resolve().parent.parent / "static" / "i18n.js").read_text("utf-8") +STYLE_CSS = (Path(__file__).resolve().parent.parent / "static" / "style.css").read_text("utf-8") + + +def test_toggle_endpoint_signature_in_routes(): + """Verify the toggle endpoint code exists in routes.py.""" + from api.routes import _handle_skill_toggle + assert callable(_handle_skill_toggle) + + +def test_toggle_path_registered(): + """Verify /api/skills/toggle path is registered in POST routing.""" + routes_source = (Path(__file__).resolve().parent.parent / "api" / "routes.py").read_text("utf-8") + assert '/api/skills/toggle' in routes_source + + +def test_skills_list_includes_disabled_flag(): + """Each skill dict in the API response must have a 'disabled' boolean field.""" + routes_source = (Path(__file__).resolve().parent.parent / "api" / "routes.py").read_text("utf-8") + assert '"disabled": name in disabled' in routes_source + + +def test_i18n_keys_added(): + """The three new i18n keys must exist in the English locale.""" + assert "skill_enabled" in I18N_JS + assert "skill_disabled" in I18N_JS + assert "skill_toggle_failed" in I18N_JS + + +def test_toggle_css_classes_exist(): + """Toggle switch CSS classes must be in style.css.""" + assert ".skill-toggle" in STYLE_CSS + assert ".skill-toggle.enabled" in STYLE_CSS + assert ".skill-item.disabled" in STYLE_CSS + + +def test_render_skills_produces_toggle_buttons(): + """renderSkills() must include toggleSkill and skill-toggle.""" + assert "toggleSkill(" in PANELS_JS + assert "skill-toggle" in PANELS_JS + + +def test_toggle_skill_function_defined(): + """toggleSkill() async function must be defined.""" + assert "async function toggleSkill(" in PANELS_JS + + +def test_disabled_list_round_trip(tmp_path): + """Verify that writing and reading the disabled list through the config + module's YAML functions preserves values correctly, including normalization + of None/str/list shapes.""" + from api.config import _load_yaml_config_file, _save_yaml_config_file + + config_path = tmp_path / "config.yaml" + + # Write initial config + _save_yaml_config_file(config_path, {"skills": {"disabled": []}}) + + # Read, add skill, write + cfg = _load_yaml_config_file(config_path) + cfg.setdefault("skills", {}) + disabled = cfg["skills"].get("disabled", []) + disabled.append("skill-a") + disabled.append("skill-b") + cfg["skills"]["disabled"] = disabled + _save_yaml_config_file(config_path, cfg) + + # Read back and verify + cfg2 = _load_yaml_config_file(config_path) + assert cfg2["skills"]["disabled"] == ["skill-a", "skill-b"] + + # Remove one skill, write, verify + cfg2["skills"]["disabled"] = [d for d in cfg2["skills"]["disabled"] if d != "skill-a"] + _save_yaml_config_file(config_path, cfg2) + + cfg3 = _load_yaml_config_file(config_path) + assert cfg3["skills"]["disabled"] == ["skill-b"] + + +def test_normalize_names_list(): + """_normalize_names_list handles None, str, list, and deduplicates.""" + from api.routes import _normalize_names_list + + assert _normalize_names_list(None) == [] + assert _normalize_names_list("foo") == ["foo"] + assert _normalize_names_list(["a", "b"]) == ["a", "b"] + assert _normalize_names_list(["a", "a"]) == ["a"] + assert _normalize_names_list([" a ", "b"]) == ["a", "b"] + assert _normalize_names_list("") == [] + assert _normalize_names_list([]) == [] + + +def test_toggle_name_in_list(): + """_toggle_name_in_list adds when enabled=False, removes when enabled=True.""" + from api.routes import _toggle_name_in_list + + # Add to empty list + assert _toggle_name_in_list([], "foo", enabled=False) == ["foo"] + # Add to existing list + assert _toggle_name_in_list(["bar"], "foo", enabled=False) == ["bar", "foo"] + # Idempotent add + assert _toggle_name_in_list(["foo"], "foo", enabled=False) == ["foo"] + # Remove from list + assert _toggle_name_in_list(["foo", "bar"], "foo", enabled=True) == ["bar"] + # Remove non-existent is a no-op + assert _toggle_name_in_list(["bar"], "foo", enabled=True) == ["bar"] + # Remove from empty is a no-op + assert _toggle_name_in_list([], "foo", enabled=True) == [] + # Handles str input (normalization) + assert _toggle_name_in_list("foo", "bar", enabled=False) == ["foo", "bar"] + assert _toggle_name_in_list("foo", "foo", enabled=True) == [] + + +def test_platform_disabled_write_through(tmp_path, monkeypatch): + """Toggle writes through to platform_disabled.webui when that key exists.""" + from unittest.mock import MagicMock + from api.routes import _handle_skill_toggle + from api.config import _load_yaml_config_file + + config_path = tmp_path / "config.yaml" + monkeypatch.setattr("api.routes._get_config_path", lambda: config_path) + + import yaml + + config = { + "skills": { + "disabled": ["skill-a"], + "platform_disabled": { + "webui": ["skill-a", "skill-b"], + "telegram": ["skill-c"], + }, + } + } + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(yaml.dump(config), encoding="utf-8") + + # Mock _find_skill_in_dirs to avoid agent.skill_utils import in CI + fake_dir = tmp_path / "skills" / "skill-a" + fake_dir.mkdir(parents=True, exist_ok=True) + fake_md = fake_dir / "SKILL.md" + fake_md.write_text("---\nname: skill-a\n---\nA skill", encoding="utf-8") + monkeypatch.setattr( + "api.routes._find_skill_in_dirs", + lambda name, dirs: (fake_dir, fake_md), + ) + + handler = MagicMock() + + # Toggle skill-a OFF — it's already disabled, verify idempotency + _handle_skill_toggle(handler, {"name": "skill-a", "enabled": False}) + cfg_after = _load_yaml_config_file(config_path) + assert "skill-a" in cfg_after["skills"]["disabled"] + assert "skill-a" in cfg_after["skills"]["platform_disabled"]["webui"] + + # Toggle skill-a ON + _handle_skill_toggle(handler, {"name": "skill-a", "enabled": True}) + cfg_after2 = _load_yaml_config_file(config_path) + assert "skill-a" not in cfg_after2["skills"]["disabled"] + assert "skill-a" not in cfg_after2["skills"]["platform_disabled"]["webui"] + # Other platform keys are untouched + assert cfg_after2["skills"]["platform_disabled"]["telegram"] == ["skill-c"] + + +def test_platform_disabled_no_write_through_when_key_absent(tmp_path, monkeypatch): + """Toggle does NOT create platform_disabled.webui when it doesn't exist.""" + from unittest.mock import MagicMock + from api.routes import _handle_skill_toggle + from api.config import _load_yaml_config_file + + config_path = tmp_path / "config.yaml" + monkeypatch.setattr("api.routes._get_config_path", lambda: config_path) + + import yaml + + config = {"skills": {"disabled": []}} + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(yaml.dump(config), encoding="utf-8") + + # Mock _find_skill_in_dirs to avoid agent.skill_utils import in CI + fake_dir = tmp_path / "skills" / "skill-b" + fake_dir.mkdir(parents=True, exist_ok=True) + fake_md = fake_dir / "SKILL.md" + fake_md.write_text("---\nname: skill-b\n---\nB skill", encoding="utf-8") + monkeypatch.setattr( + "api.routes._find_skill_in_dirs", + lambda name, dirs: (fake_dir, fake_md), + ) + + handler = MagicMock() + _handle_skill_toggle(handler, {"name": "skill-b", "enabled": False}) + cfg_after = _load_yaml_config_file(config_path) + assert "skill-b" in cfg_after["skills"]["disabled"] + # platform_disabled was never created + assert "platform_disabled" not in cfg_after.get("skills", {})