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", {})