Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 178 additions & 1 deletion hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2657,11 +2657,26 @@ def _profile_attr(info, name: str, default: Any = None) -> Any:
return default


def _active_profile_path_str() -> str:
"""Resolved HERMES_HOME of the running dashboard process, for is_active comparison."""
from hermes_constants import get_hermes_home
try:
return str(get_hermes_home().resolve())
except Exception:
return ""


def _profile_to_dict(info) -> Dict[str, Any]:
path_str = str(_profile_attr(info, "path", ""))
try:
resolved = str(Path(path_str).resolve()) if path_str else ""
except Exception:
resolved = path_str
return {
"name": _profile_attr(info, "name", ""),
"path": str(_profile_attr(info, "path", "")),
"path": path_str,
"is_default": bool(_profile_attr(info, "is_default", False)),
"is_active": bool(resolved) and resolved == _active_profile_path_str(),
"model": _profile_attr(info, "model"),
"provider": _profile_attr(info, "provider"),
"has_env": bool(_profile_attr(info, "has_env", False)),
Expand All @@ -2676,6 +2691,14 @@ def _safe(callable_, default):
except Exception:
return default

active_path_str = _active_profile_path_str()

def _is_active(path: Path) -> bool:
try:
return bool(active_path_str) and str(path.resolve()) == active_path_str
except Exception:
return False

profiles: List[Dict[str, Any]] = []
default_home = profiles_mod._get_default_hermes_home()
if default_home.is_dir():
Expand All @@ -2684,6 +2707,7 @@ def _safe(callable_, default):
"name": "default",
"path": str(default_home),
"is_default": True,
"is_active": _is_active(default_home),
"model": model,
"provider": provider,
"has_env": (default_home / ".env").exists(),
Expand All @@ -2700,6 +2724,7 @@ def _safe(callable_, default):
"name": entry.name,
"path": str(entry),
"is_default": False,
"is_active": _is_active(entry),
"model": model,
"provider": provider,
"has_env": (entry / ".env").exists(),
Expand Down Expand Up @@ -2917,6 +2942,158 @@ async def toggle_skill(body: SkillToggle):
return {"ok": True, "name": body.name, "enabled": body.enabled}


# ---------------------------------------------------------------------------
# Per-profile skills endpoints
#
# The legacy /api/skills routes above operate on the active profile
# (whichever HERMES_HOME the dashboard process was launched under). The
# routes below let the UI list and toggle skills for any installed profile
# without spawning a second dashboard daemon per profile.
#
# Reads/writes go directly against the profile's config.yaml (skills.disabled
# key) rather than through load_config / save_config — those helpers are
# bound to the process-level HERMES_HOME via get_config_path().
# ---------------------------------------------------------------------------


def _profile_config_path(profile_dir: Path) -> Path:
return profile_dir / "config.yaml"


def _load_profile_raw_config(profile_dir: Path) -> Dict[str, Any]:
"""Read a profile's config.yaml as raw YAML. Returns {} if missing/empty."""
config_path = _profile_config_path(profile_dir)
if not config_path.exists():
return {}
try:
import yaml as _yaml
with open(config_path, encoding="utf-8") as f:
data = _yaml.safe_load(f)
return data if isinstance(data, dict) else {}
except OSError as e:
raise HTTPException(status_code=500, detail=f"Could not read config.yaml: {e}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Could not parse config.yaml: {e}")


def _save_profile_raw_config(profile_dir: Path, config: Dict[str, Any]) -> None:
from utils import atomic_yaml_write
try:
atomic_yaml_write(_profile_config_path(profile_dir), config)
except OSError as e:
raise HTTPException(status_code=500, detail=f"Could not write config.yaml: {e}")


def _find_skills_in_profile(profile_dir: Path) -> List[Dict[str, Any]]:
"""Scan profile_dir/skills/ for SKILL.md files and return the same shape
as ``tools.skills_tool._find_all_skills`` (without external_dirs).

External-dir scanning is intentionally omitted for v1 — the dropdown is
aimed at toggling profile-installed skills. Skills loaded via
``skills.external_dirs`` are still respected at gateway runtime.

Category is derived from the directory layout under the profile's own
skills/ root (matching the convention used by
``tools.skills_tool._get_category_from_path``, which is hardcoded to the
process-level SKILLS_DIR and therefore can't be reused here).
"""
from tools.skills_tool import (
MAX_DESCRIPTION_LENGTH,
MAX_NAME_LENGTH,
_EXCLUDED_SKILL_DIRS,
_parse_frontmatter,
skill_matches_platform,
)
from agent.skill_utils import iter_skill_index_files

skills_dir = profile_dir / "skills"
if not skills_dir.is_dir():
return []

def _category(skill_md_path: Path) -> Optional[str]:
try:
rel = skill_md_path.relative_to(skills_dir)
except ValueError:
return None
# rel like "mlops/axolotl/SKILL.md" → "mlops"; "airtable/SKILL.md" → None
return rel.parts[0] if len(rel.parts) >= 3 else None

out: List[Dict[str, Any]] = []
seen: set = set()
for skill_md in iter_skill_index_files(skills_dir, "SKILL.md"):
if any(part in _EXCLUDED_SKILL_DIRS for part in skill_md.parts):
continue
try:
content = skill_md.read_text(encoding="utf-8")[:4000]
frontmatter, body = _parse_frontmatter(content)
if not skill_matches_platform(frontmatter):
continue
name = frontmatter.get("name", skill_md.parent.name)[:MAX_NAME_LENGTH]
if name in seen:
continue
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] + "..."
seen.add(name)
out.append({
"name": name,
"description": description,
"category": _category(skill_md),
"path": str(skill_md.parent),
})
except (OSError, UnicodeDecodeError):
continue
out.sort(key=lambda s: s["name"].lower())
return out


def _profile_disabled_skills(config: Dict[str, Any]) -> set:
skills_cfg = config.get("skills") if isinstance(config, dict) else None
if not isinstance(skills_cfg, dict):
return set()
disabled = skills_cfg.get("disabled", [])
if not isinstance(disabled, list):
return set()
return {str(x) for x in disabled}


@app.get("/api/profiles/{name}/skills")
async def get_profile_skills(name: str):
profile_dir = _resolve_profile_dir(name)
config = _load_profile_raw_config(profile_dir)
disabled = _profile_disabled_skills(config)
skills = _find_skills_in_profile(profile_dir)
for s in skills:
s["enabled"] = s["name"] not in disabled
return skills


@app.put("/api/profiles/{name}/skills/toggle")
async def toggle_profile_skill(name: str, body: SkillToggle):
profile_dir = _resolve_profile_dir(name)
config = _load_profile_raw_config(profile_dir)
skills_cfg = config.setdefault("skills", {}) if isinstance(config, dict) else None
if not isinstance(skills_cfg, dict):
# The skills key existed but wasn't a dict — overwrite with a fresh mapping.
config["skills"] = {}
skills_cfg = config["skills"]
raw_disabled = skills_cfg.get("disabled", [])
disabled = {str(x) for x in raw_disabled} if isinstance(raw_disabled, list) else set()
if body.enabled:
disabled.discard(body.name)
else:
disabled.add(body.name)
skills_cfg["disabled"] = sorted(disabled)
_save_profile_raw_config(profile_dir, config)
return {"ok": True, "name": body.name, "enabled": body.enabled, "profile": name}


@app.get("/api/tools/toolsets")
async def get_toolsets():
from hermes_cli.tools_config import (
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,7 @@
"openclaw@agent.local": "29206394", # PR #22194 salvage (sudo -S brute-force guard, #9590)
"freedemon@gmail.com": "fr33d3m0n", # PR #21128 salvage (sudo stdin/askpass DANGEROUS, #17873 cat 4)
"zhaowh3613@outlook.com": "VinceZcrikl", # PR #23647 salvage (npm UTF-8 decode on GBK Windows)
"cypres0099@users.noreply.github.com": "cypres0099",
}


Expand Down
34 changes: 29 additions & 5 deletions skills/devops/kanban-orchestrator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,42 @@ metadata:

Hermes setups vary widely. Some users run a single profile that does everything; some run a small fleet (`docker-worker`, `cron-worker`); some run a curated specialist team they've named themselves. There is **no default specialist roster** — the orchestrator skill does not know what profiles exist on this machine.

Before fanning out, you must ground the decomposition in the profiles that actually exist. The dispatcher silently fails to spawn unknown assignee names — it doesn't autocorrect, doesn't suggest, doesn't fall back. So a card assigned to `researcher` on a setup that only has `docker-worker` just sits in `ready` forever.
Before fanning out, you must ground the decomposition in the profiles that actually exist *and* in the skills each one knows. The dispatcher silently fails to spawn unknown assignee names — it doesn't autocorrect, doesn't suggest, doesn't fall back. So a card assigned to `researcher` on a setup that only has `docker-worker` just sits in `ready` forever. And a `kanban_create(skills=["live-fully"])` aimed at a profile that doesn't have that skill installed loads no extra context.

**Step 0: discover available profiles before planning.**
**Step 0: call `capabilities_list` before every routing decision.**

Use one of these:
If your toolset includes `capabilities_list`, that is the canonical discovery primitive. It returns a flat list of `{profile, name, description, category}` for every enabled skill on every profile on this host, with disabled skills filtered out and symlink-reached SKILL.md entries dropped. Read the descriptions, match the work to a profile, and pass the matching skill names into `kanban_create(skills=[...])` so the worker spawns with the right specialist context preloaded.

**Do not cache the result across turns.** Sibling profiles can install or remove skills between tasks, and a stale cache routes work to a profile that no longer has the skill loaded. Call `capabilities_list` again at the start of every routing decision; it is cheap (single filesystem walk, in-process).

Worked example — the orchestrator decides who handles a "two Live Fully illustrations of cows" request:

```python
caps = capabilities_list()
# caps is JSON like:
# [
# {"profile": "creative", "name": "live-fully",
# "description": "Brand voice + visual identity for Live Fully…",
# "category": "brand"},
# {"profile": "creative", "name": "image-gen-prompt-engineer", ...},
# {"profile": "researcher", "name": "deep-web-research", ...},
# ]

# Match: "Live Fully illustrations" → profile=creative with skills=[live-fully, image-gen-prompt-engineer]
t1 = kanban_create(
title="illustration 1: Live Fully cow",
assignee="creative",
skills=["live-fully", "image-gen-prompt-engineer"],
body="One illustration of a cow in the Live Fully brand voice. See live-fully skill.",
)["task_id"]
```

If `capabilities_list` is not in your toolset (older Hermes profile, or operator hasn't enabled the `capabilities` toolset), fall back to:

- `hermes profile list` — prints the table of profiles configured on this machine. Run it through your terminal tool if you have one; otherwise ask the user.
- `kanban_list(assignee="<some-name>")` — sanity-check a single name. Returns an empty list (rather than an error) for an unknown assignee, so this only confirms a name you're already considering.
- **Just ask the user.** "What profiles do you have set up?" is a fine first turn when the goal needs more than one specialist.

Cache the result in your working memory for the rest of the conversation. Re-asking every turn wastes a tool call.

## When to use the board (vs. just doing the work)

Create Kanban tasks when any of these are true:
Expand Down
104 changes: 104 additions & 0 deletions tests/hermes_cli/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,110 @@ def _fake_find_all_skills(*, skip_disabled=False):
},
]

def test_profiles_list_marks_default_profile_active(self):
"""The default profile (whose HERMES_HOME the dashboard process is bound
to) should report is_active=True; sub-profiles created during the test
should not."""
import hermes_cli.profiles as profiles_mod
# Sub-profile creation paths sometimes call into the gateway service
# cleanup helper — stub it out so the test stays hermetic.
# (mirrors test_profile_soul_round_trip)
try:
import pytest # noqa: F401
except ImportError:
pass
profiles_mod_orig = profiles_mod._cleanup_gateway_service
profiles_mod._cleanup_gateway_service = lambda *a, **kw: None
try:
self.client.post("/api/profiles", json={"name": "active-test-prof"})
data = self.client.get("/api/profiles").json()
finally:
self.client.delete("/api/profiles/active-test-prof")
profiles_mod._cleanup_gateway_service = profiles_mod_orig
by_name = {p["name"]: p for p in data["profiles"]}
assert by_name["default"]["is_active"] is True
if "active-test-prof" in by_name:
assert by_name["active-test-prof"]["is_active"] is False

def test_profile_skills_list_picks_up_profile_dir(self, monkeypatch):
"""GET /api/profiles/{name}/skills should scan the profile's own
skills/ directory, not the process-level HERMES_HOME."""
from pathlib import Path
import hermes_cli.profiles as profiles_mod
monkeypatch.setattr(profiles_mod, "_cleanup_gateway_service", lambda *a, **kw: None)

self.client.post("/api/profiles", json={"name": "skill-scope-prof"})
try:
profile_dir = Path(profiles_mod.get_profile_dir("skill-scope-prof"))
skill_dir = profile_dir / "skills" / "productivity" / "ben-only-skill"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"---\n"
"name: ben-only-skill\n"
"description: A skill that exists only in skill-scope-prof.\n"
"---\n"
"# Body\n",
encoding="utf-8",
)
resp = self.client.get("/api/profiles/skill-scope-prof/skills")
assert resp.status_code == 200
skills = resp.json()
found = [s for s in skills if s["name"] == "ben-only-skill"]
assert len(found) == 1, f"expected 1 ben-only-skill, got {skills}"
assert found[0]["enabled"] is True
assert found[0]["category"] == "productivity"
assert found[0]["description"].startswith("A skill that exists")
finally:
self.client.delete("/api/profiles/skill-scope-prof")

def test_profile_skill_toggle_persists_to_profile_config(self, monkeypatch):
"""PUT /api/profiles/{name}/skills/toggle should write to the profile's
own config.yaml, leaving the dashboard's active profile untouched."""
from pathlib import Path
import yaml
import hermes_cli.profiles as profiles_mod
monkeypatch.setattr(profiles_mod, "_cleanup_gateway_service", lambda *a, **kw: None)

self.client.post("/api/profiles", json={"name": "toggle-prof"})
try:
put = self.client.put(
"/api/profiles/toggle-prof/skills/toggle",
json={"name": "fake-skill", "enabled": False},
)
assert put.status_code == 200
assert put.json()["profile"] == "toggle-prof"
assert put.json()["enabled"] is False

profile_cfg = Path(profiles_mod.get_profile_dir("toggle-prof")) / "config.yaml"
assert profile_cfg.exists()
parsed = yaml.safe_load(profile_cfg.read_text(encoding="utf-8")) or {}
assert parsed.get("skills", {}).get("disabled") == ["fake-skill"]

# Re-enable removes the entry rather than leaving a stale flag.
put2 = self.client.put(
"/api/profiles/toggle-prof/skills/toggle",
json={"name": "fake-skill", "enabled": True},
)
assert put2.status_code == 200
parsed2 = yaml.safe_load(profile_cfg.read_text(encoding="utf-8")) or {}
assert parsed2.get("skills", {}).get("disabled") == []
finally:
self.client.delete("/api/profiles/toggle-prof")

def test_profile_skills_unknown_profile_404(self):
resp = self.client.get("/api/profiles/nonexistent/skills")
assert resp.status_code == 404
resp2 = self.client.put(
"/api/profiles/nonexistent/skills/toggle",
json={"name": "x", "enabled": False},
)
assert resp2.status_code == 404

def test_profile_skills_invalid_name_400(self):
resp = self.client.get("/api/profiles/Bad@Name/skills")
assert resp.status_code == 400
assert "Invalid profile name" in resp.json()["detail"]

def test_toolsets_list(self):
resp = self.client.get("/api/tools/toolsets")
assert resp.status_code == 200
Expand Down
Loading