diff --git a/README.md b/README.md index 794a9bb7..ac2a750f 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,11 @@ Native release artifacts are published on GitHub Releases for macOS ARM64/x64 an OpenCode
Docs + + Hermes Agent
+ Hermes Agent
+ Docs + OpenClaw
OpenClaw
@@ -174,6 +179,7 @@ Native release artifacts are published on GitHub Releases for macOS ARM64/x64 an | Claude Code | Yes | Yes | Yes | | Cursor | Yes | Yes | Yes | | OpenCode | Yes | Yes | Yes | +| Hermes Agent | Yes | Yes | Not Yet | | OpenClaw | Yes | Not Yet | Not Yet | ## Local-first safety @@ -202,6 +208,10 @@ App-owned files live under `~/Library/Application Support/skill-manager` on macO Before adoption, each harness points at its own local skill folder. After adoption, Skill Manager keeps one canonical package in its shared local store and exposes it to selected harnesses with local links. Disabling a harness removes that harness binding without deleting the package. +Skill Manager treats managed Skills as portable by default: once a Skill is adopted into the shared store, it can be enabled for any supported harness. `originHarness` is retained only as provenance. + +Hermes Agent Skills use the categorized Hermes layout under `~/.hermes/skills///SKILL.md`. Shared Skills enabled for Hermes are linked under the `skill-manager` category by default. Skill Manager only imports Hermes Skills that Hermes itself installed from external hub provenance (`.hub/lock.json` entries that are not official/builtin/optional). Hermes self-learned/local Skills, bundled Skills tracked by `.bundled_manifest`, and official optional Skills recorded in Hermes hub provenance are excluded from Skill Manager inventory and bulk actions; Skill Manager leaves those folders untouched so `hermes update` and Hermes-owned Skill sync keep their normal ownership. + ![skill-market-overview](./assets/skill-manager-skill-unification.svg) ### Skill scans @@ -219,6 +229,7 @@ MCP servers are stored as normalized Skill Manager records, then translated into - Codex uses TOML under `mcp_servers`. - Claude Code and Cursor use `mcpServers` JSON entries. - OpenCode uses typed local/remote MCP entries. +- Hermes Agent uses YAML under `mcp_servers` in `~/.hermes/config.yaml` (or `$HERMES_HOME/config.yaml`). - OpenClaw MCP writes are not yet supported. When Skill Manager finds different configs for the same MCP server, it asks you to resolve the source of truth first. @@ -233,6 +244,7 @@ Slash commands are stored as TOML records under Skill Manager app storage, then - Claude Code writes Markdown command files under `~/.claude/commands` and invokes them with `/`. - Cursor writes plain text command files under `~/.cursor/commands` and invokes them with `/`. - Codex writes prompt files under `~/.codex/prompts` and invokes them with `/prompts:`. +- Hermes Agent slash command writes are not yet supported; reusable Hermes workflows are managed through Skills. - OpenClaw slash command writes are not yet supported. Skill Manager tracks target ownership with sync state and content hashes. It will not overwrite an untracked command file automatically, and it reports managed files as changed or missing when the target no longer matches the last synced hash. Review actions let you adopt unmanaged commands, restore managed content, adopt a changed harness command as the new source, or remove a broken binding while leaving the harness file untouched. @@ -273,9 +285,10 @@ Most users do not need to change these locations. If you manage skills in a cust | Claude | `SKILL_MANAGER_CLAUDE_ROOT` | `~/.claude/skills` | | Cursor | `SKILL_MANAGER_CURSOR_ROOT` | `~/.cursor/skills` | | OpenCode | `SKILL_MANAGER_OPENCODE_ROOT` | `~/.config/opencode/skills` | +| Hermes Agent | `SKILL_MANAGER_HERMES_ROOT` | `${HERMES_HOME:-~/.hermes}/skills` | | OpenClaw | `n/a` | `~/.openclaw/skills` | -MCP config locations are harness-owned. Skill Manager writes only to verified config paths and skips unsupported harness writes. +MCP config locations are harness-owned. Skill Manager writes only to verified config paths and skips unsupported harness writes. Hermes Agent config discovery honors `SKILL_MANAGER_HERMES_HOME` first, then `HERMES_HOME`, then `~/.hermes`. ## From source diff --git a/README.zh-CN.md b/README.zh-CN.md index b4229feb..2530f1b9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -144,6 +144,7 @@ GitHub Releases 会发布 macOS ARM64/x64 和 Linux x64/ARM64 的原生 release | Claude Code | 支持 | 支持 | 支持 | | Cursor | 支持 | 支持 | 支持 | | OpenCode | 支持 | 支持 | 支持 | +| Hermes Agent | 支持 | 支持 | 暂不支持 | | OpenClaw | 支持 | 暂不支持 | 暂不支持 | ## 本地优先安全模型 @@ -172,6 +173,10 @@ Skill Manager 是本地配置管理工具。它在你的机器上运行,并读 采用之前,各 harness 指向各自的本地 Skill 文件夹。采用之后,Skill Manager 会在共享本地存储中保留一个规范包,并通过本地链接暴露给选定 harness。停用某个 harness 会移除该 harness 绑定,但不会删除包本身。 +Skill Manager 默认把已管理 Skill 视为可迁移:Skill 一旦进入 shared store,就可以启用到任何受支持 harness。`originHarness` 只保留作来源记录。 + +Hermes Agent Skill 使用 Hermes 分类目录:`~/.hermes/skills///SKILL.md`。共享 Skill 启用到 Hermes 时,默认会链接到 `skill-manager` 分类下。Skill Manager 只导入 Hermes 自己从外部 hub provenance 安装的 Skill(`.hub/lock.json` 中非 official/builtin/optional 的条目)。Hermes 自学习/local Skill、`.bundled_manifest` 跟踪的内置打包 Skill,以及 Hermes hub provenance 中记录的官方 optional Skill,都会从 Skill Manager 库存和批量操作中排除;Skill Manager 不会修改、链接或删除这些文件夹,让 `hermes update` 和 Hermes 自有 Skill 同步继续保持原有所有权。 + ![skill-market-overview](./assets/skill-manager-skill-unification.svg) ### Skill 扫描 @@ -189,6 +194,7 @@ MCP 服务器以规范化 Skill Manager 记录保存,再转换为每个 harnes - Codex 使用 `mcp_servers` 下的 TOML。 - Claude Code 和 Cursor 使用 `mcpServers` JSON 条目。 - OpenCode 使用类型化的本地或远程 MCP 条目。 +- Hermes Agent 使用 `~/.hermes/config.yaml`(或 `$HERMES_HOME/config.yaml`)中 `mcp_servers` 下的 YAML 配置。 - OpenClaw 暂不支持 MCP 写入。 当 Skill Manager 发现同一个 MCP 服务器存在不同配置时,会先要求你选择事实来源。 @@ -203,6 +209,7 @@ Slash command 以 TOML 记录保存在 Skill Manager 应用存储中,再渲染 - Claude Code 写入 `~/.claude/commands` 下的 Markdown command 文件,并通过 `/` 调用。 - Cursor 写入 `~/.cursor/commands` 下的纯文本 command 文件,并通过 `/` 调用。 - Codex 写入 `~/.codex/prompts` 下的 prompt 文件,并通过 `/prompts:` 调用。 +- Hermes Agent 暂不支持 slash command 写入;Hermes 的可复用工作流优先通过 Skill 管理。 - OpenClaw 暂不支持 slash command 写入。 Skill Manager 使用同步状态和内容哈希跟踪目标所有权。它不会自动覆盖未跟踪的 command 文件;当目标不再匹配上次同步哈希时,会报告托管文件已变更或缺失。确认操作可用于采用未托管 command、恢复托管内容、将已变更的 harness command 采用为新来源,或移除损坏绑定且保留 harness 文件。 @@ -243,9 +250,10 @@ CLI marketplace 条目仅用于预览。 | Claude | `SKILL_MANAGER_CLAUDE_ROOT` | `~/.claude/skills` | | Cursor | `SKILL_MANAGER_CURSOR_ROOT` | `~/.cursor/skills` | | OpenCode | `SKILL_MANAGER_OPENCODE_ROOT` | `~/.config/opencode/skills` | +| Hermes Agent | `SKILL_MANAGER_HERMES_ROOT` | `${HERMES_HOME:-~/.hermes}/skills` | | OpenClaw | `n/a` | `~/.openclaw/skills` | -MCP 配置位置由 harness 拥有。Skill Manager 只写入经过验证的配置路径,并跳过不支持的 harness 写入。 +MCP 配置位置由 harness 拥有。Skill Manager 只写入经过验证的配置路径,并跳过不支持的 harness 写入。Hermes Agent 配置发现会优先使用 `SKILL_MANAGER_HERMES_HOME`,然后是 `HERMES_HOME`,最后回退到 `~/.hermes`。 ## 从源码运行 diff --git a/assets/harness-logos/hermes-logo.png b/assets/harness-logos/hermes-logo.png new file mode 100644 index 00000000..2d629d2f Binary files /dev/null and b/assets/harness-logos/hermes-logo.png differ diff --git a/frontend/src/assets/harness-logos/hermes-logo.png b/frontend/src/assets/harness-logos/hermes-logo.png new file mode 100644 index 00000000..2d629d2f Binary files /dev/null and b/frontend/src/assets/harness-logos/hermes-logo.png differ diff --git a/frontend/src/components/harness/harnessPresentation.ts b/frontend/src/components/harness/harnessPresentation.ts index 7abbca70..b0dfb433 100644 --- a/frontend/src/components/harness/harnessPresentation.ts +++ b/frontend/src/components/harness/harnessPresentation.ts @@ -1,10 +1,11 @@ import claudeLogo from "../../assets/harness-logos/claude-code-logo.svg"; import codexLogo from "../../assets/harness-logos/codex-logo.svg"; import cursorLogo from "../../assets/harness-logos/cursor-logo.svg"; +import hermesLogo from "../../assets/harness-logos/hermes-logo.png"; import openclawLogo from "../../assets/harness-logos/openclaw-logo.svg"; import opencodeLogo from "../../assets/harness-logos/opencode-logo.svg"; -export type HarnessLogoKey = "claude" | "codex" | "cursor" | "opencode" | "openclaw"; +export type HarnessLogoKey = "claude" | "codex" | "cursor" | "hermes" | "opencode" | "openclaw"; interface HarnessPresentation { logoSrc: string; @@ -24,6 +25,10 @@ const HARNESS_LOGO_ASSETS: Record = { logoSrc: cursorLogo, variant: "cursor", }, + hermes: { + logoSrc: hermesLogo, + variant: "hermes", + }, opencode: { logoSrc: opencodeLogo, variant: "opencode", diff --git a/pyproject.toml b/pyproject.toml index 85c2adc6..e4040d1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "certifi>=2024.0.0", "fastapi>=0.135,<0.136", "tomli-w>=1.2,<2", + "ruamel.yaml>=0.18,<0.19", "uvicorn>=0.44,<0.45", "certifi>=2024.8.30,<2026", ] diff --git a/requirements.txt b/requirements.txt index 10623e24..464b6e5d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ fastapi>=0.135,<0.136 tomli-w>=1.2,<2 uvicorn>=0.44,<0.45 certifi>=2024.8.30,<2026 +ruamel.yaml>=0.18,<0.19 diff --git a/skill_manager/application/mcp/adapters.py b/skill_manager/application/mcp/adapters.py index f7e25d47..94255c1e 100644 --- a/skill_manager/application/mcp/adapters.py +++ b/skill_manager/application/mcp/adapters.py @@ -2,14 +2,18 @@ import json import re +from io import StringIO import shutil import subprocess import tomllib from dataclasses import dataclass from pathlib import Path +from collections.abc import MutableMapping from typing import Mapping import tomli_w +from ruamel.yaml import YAML +from ruamel.yaml.error import YAMLError from skill_manager.errors import MutationError from skill_manager.atomic_files import atomic_write_text, file_lock @@ -175,9 +179,8 @@ def enable_server(self, spec: McpServerSpec) -> None: self._require_mcp_writable() with file_lock(self._lock_path(self.config_path)): document = self._load_document(self.config_path) - subtree = dict(self._read_subtree(document, self._write_subtree_path)) + subtree = self._ensure_subtree(document, self._write_subtree_path) subtree[spec.name] = self._mapper.spec_to_dict(spec) - self._write_subtree(document, subtree, self._write_subtree_path) for subtree_path in self._read_subtree_paths: if subtree_path != self._write_subtree_path: self._remove_from_subtree(document, subtree_path, spec.name) @@ -283,7 +286,16 @@ def _load_document(self, config_path: Path) -> dict[str, object]: f"{self.harness} config file is not valid {self._file_format.upper()}: {error}", status=409, ) from error - return payload if isinstance(payload, dict) else {} + return payload if isinstance(payload, MutableMapping) else {} + if self._file_format == "yaml": + try: + payload = _yaml().load(text) if text.strip() else {} + except YAMLError as error: + raise MutationError( + f"{self.harness} config file is not valid YAML: {error}", + status=409, + ) from error + return payload if isinstance(payload, MutableMapping) else {} try: payload = tomllib.loads(text) except tomllib.TOMLDecodeError as error: @@ -296,6 +308,10 @@ def _load_document(self, config_path: Path) -> dict[str, object]: def _dump_document(self, document: dict[str, object]) -> str: if self._file_format in {"json", "jsonc"}: return json.dumps(document, ensure_ascii=False, indent=2) + "\n" + if self._file_format == "yaml": + stream = StringIO() + _yaml().dump(document, stream) + return stream.getvalue() return tomli_w.dumps(document) def _read_subtree( @@ -312,24 +328,20 @@ def _read_subtree( return cursor return {} - def _write_subtree( + def _ensure_subtree( self, - document: dict[str, object], - subtree: dict[str, object], + document: MutableMapping[str, object], subtree_path: SubtreePath, - ) -> None: - cursor: dict[str, object] = document - for segment in subtree_path[:-1]: + ) -> MutableMapping[str, object]: + cursor: MutableMapping[str, object] = document + yaml = _yaml() if self._file_format == "yaml" else None + for segment in subtree_path: existing = cursor.get(segment) - if not isinstance(existing, dict): - existing = {} + if not isinstance(existing, MutableMapping): + existing = yaml.map() if yaml is not None else {} cursor[segment] = existing - cursor = existing # type: ignore[assignment] - leaf_key = subtree_path[-1] - if subtree: - cursor[leaf_key] = subtree - else: - cursor.pop(leaf_key, None) + cursor = existing + return cursor def _remove_from_subtree( self, @@ -337,15 +349,15 @@ def _remove_from_subtree( subtree_path: SubtreePath, name: str, ) -> bool: - cursor: dict[str, object] = document + cursor: MutableMapping[str, object] = document for segment in subtree_path[:-1]: existing = cursor.get(segment) - if not isinstance(existing, dict): + if not isinstance(existing, MutableMapping): return False cursor = existing leaf_key = subtree_path[-1] subtree = cursor.get(leaf_key) - if not isinstance(subtree, dict) or name not in subtree: + if not isinstance(subtree, MutableMapping) or name not in subtree: return False del subtree[name] if not subtree: @@ -367,6 +379,14 @@ def build_mcp_adapters( ) +def _yaml() -> YAML: + yaml = YAML(typ="rt") + yaml.default_flow_style = False + yaml.preserve_quotes = True + yaml.indent(mapping=2, sequence=4, offset=2) + return yaml + + def _normalize_payload(value: object) -> object: if isinstance(value, dict): normalized = { diff --git a/skill_manager/application/mcp/mappers.py b/skill_manager/application/mcp/mappers.py index 77ba29c4..2fea0f83 100644 --- a/skill_manager/application/mcp/mappers.py +++ b/skill_manager/application/mcp/mappers.py @@ -216,6 +216,61 @@ def dict_to_spec( ) +# Hermes ------------------------------------------------------------------- + +class HermesMapper: + # Hermes config.yaml mcp_servers entry shape: + # stdio: {command, args, env}; http: {url, headers}; sse: {url, transport: sse, headers} + + def spec_to_dict(self, spec: McpServerSpec) -> dict[str, object]: + if spec.transport == "stdio": + payload: dict[str, object] = {} + if spec.command is not None: + payload["command"] = spec.command + if spec.args: + payload["args"] = list(spec.args) + if spec.env: + payload["env"] = dict(spec.env) + return payload + payload = {} + if spec.url is not None: + payload["url"] = spec.url + if spec.transport == "sse": + payload["transport"] = "sse" + if spec.headers: + payload["headers"] = dict(spec.headers) + return payload + + def dict_to_spec( + self, name: str, raw: Mapping[str, object], *, source: McpSource | None = None + ) -> McpServerSpec: + if "command" in raw or "args" in raw: + return McpServerSpec( + name=name, + display_name=name, + source=source or McpSource.adopted("hermes", name), + transport="stdio", + command=_str_or_none(raw.get("command")), + args=_str_tuple(raw.get("args")), + env=_str_pairs(raw.get("env")), + ) + if "url" in raw: + transport_raw = _str_or_none(raw.get("transport")) + transport = "sse" if transport_raw == "sse" else "http" + return McpServerSpec( + name=name, + display_name=name, + source=source or McpSource.adopted("hermes", name), + transport=transport, + url=_str_or_none(raw.get("url")), + headers=_str_pairs(raw.get("headers")), + ) + raise MutationError( + f"unsupported hermes mcp entry '{name}': missing 'command' and 'url'", + status=400, + ) + + # OpenClaw ----------------------------------------------------------------- @@ -297,6 +352,7 @@ def _str_pairs(value: object) -> tuple[tuple[str, str], ...] | None: "cursor": CursorMapper(), "opencode": OpenCodeMapper(), "codex": CodexMapper(), + "hermes": HermesMapper(), "openclaw": OpenClawMapper(), } @@ -311,6 +367,7 @@ def get_mapper(kind: str) -> TransportMapper: "ClaudeCodeMapper", "CodexMapper", "CursorMapper", + "HermesMapper", "OpenClawMapper", "OpenCodeMapper", "TransportMapper", diff --git a/skill_manager/application/skills/adapters.py b/skill_manager/application/skills/adapters.py index 0edf4f4b..228c84ec 100644 --- a/skill_manager/application/skills/adapters.py +++ b/skill_manager/application/skills/adapters.py @@ -2,6 +2,7 @@ from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass +import json from pathlib import Path import shutil from uuid import uuid4 @@ -10,6 +11,7 @@ from skill_manager.harness import ( FileTreeAvailability, FileTreeBindingProfile, + FileTreeLayout, HarnessKernelService, ) @@ -32,6 +34,8 @@ def __init__( discovery_roots: tuple["_ResolvedRoot", ...], availability: FileTreeAvailability, app_probe_paths: tuple[Path, ...], + layout: FileTreeLayout = "flat", + default_category: str | None = None, ) -> None: self.harness = harness self.label = label @@ -42,6 +46,8 @@ def __init__( self._discovery_roots = self._dedupe_roots(discovery_roots) self._availability = availability self._app_probe_paths = app_probe_paths + self._layout = layout + self._default_category = default_category or "skill-manager" def status(self) -> SkillsHarnessStatus: return SkillsHarnessStatus( @@ -53,22 +59,34 @@ def status(self) -> SkillsHarnessStatus: ) def scan(self) -> SkillsHarnessScan: - observations = _scan_skill_roots( + hermes_policy = ( + _hermes_scan_policy(self.managed_root) if self.harness == "hermes" else None + ) + observations, skipped_skill_names = _scan_skill_roots( harness=self.harness, label=self.label, roots=self._discovery_roots, + excluded_skill_names=( + hermes_policy.excluded_skill_names if hermes_policy is not None else frozenset() + ), + hermes_policy=hermes_policy, + managed_category=self._default_category, ) + excluded_skill_names = set(skipped_skill_names) + if hermes_policy is not None: + excluded_skill_names.update(hermes_policy.excluded_skill_names) return SkillsHarnessScan( harness=self.harness, label=self.label, logo_key=self.logo_key, installed=self._is_installed(), skills=tuple(observations), + excluded_skill_names=tuple(sorted(excluded_skill_names)), ) def enable_shared_package(self, package_path: Path) -> None: resolved_target = package_path.resolve() - link = self.managed_root / package_path.name + link = self._binding_path(package_path.name) if link.is_symlink(): if link.resolve() == resolved_target: return @@ -77,11 +95,11 @@ def enable_shared_package(self, package_path: Path) -> None: ) if link.exists(): raise MutationError(f"real directory exists at {link}; will not overwrite") - self.managed_root.mkdir(parents=True, exist_ok=True) + link.parent.mkdir(parents=True, exist_ok=True) link.symlink_to(resolved_target) def disable_shared_package(self, package_dir: str) -> None: - link = self.managed_root / package_dir + link = self._binding_path(package_dir) if not link.exists() and not link.is_symlink(): return if not link.is_symlink(): @@ -102,11 +120,11 @@ def adopt_local_copy(self, existing_dir: Path, package_path: Path) -> None: existing_dir.symlink_to(resolved_target) def has_binding(self, package_dir: str) -> bool: - candidate = self.managed_root / package_dir + candidate = self._binding_path(package_dir) return candidate.exists() or candidate.is_symlink() def prepare_materialize(self, package_dir: str, expected_target: Path) -> None: - existing_link = self.managed_root / package_dir + existing_link = self._binding_path(package_dir) if not existing_link.exists() and not existing_link.is_symlink(): raise MutationError(f"directory does not exist: {existing_link}") if not existing_link.is_symlink(): @@ -118,7 +136,7 @@ def prepare_materialize(self, package_dir: str, expected_target: Path) -> None: ) def materialize_binding(self, package_dir: str, source_path: Path) -> None: - existing_link = self.managed_root / package_dir + existing_link = self._binding_path(package_dir) resolved_target = source_path.resolve() self.prepare_materialize(package_dir=package_dir, expected_target=resolved_target) @@ -140,7 +158,7 @@ def materialize_binding(self, package_dir: str, source_path: Path) -> None: backup_link.unlink() def prepare_remove(self, package_dir: str) -> None: - link = self.managed_root / package_dir + link = self._binding_path(package_dir) if not link.exists() and not link.is_symlink(): return if not link.is_symlink(): @@ -149,6 +167,25 @@ def prepare_remove(self, package_dir: str) -> None: def remove_binding(self, package_dir: str) -> None: self.disable_shared_package(package_dir) + def _binding_path(self, package_dir: str) -> Path: + default = self._default_binding_path(package_dir) + if default.exists() or default.is_symlink(): + return default + if self._layout != "categorized" or not self.managed_root.is_dir(): + return default + for category_dir in sorted(self.managed_root.iterdir(), key=lambda path: path.name): + if not category_dir.is_dir() or category_dir.name.startswith("."): + continue + candidate = category_dir / package_dir + if candidate.is_symlink(): + return candidate + return default + + def _default_binding_path(self, package_dir: str) -> Path: + if self._layout == "categorized": + return self.managed_root / self._default_category / package_dir + return self.managed_root / package_dir + def invalidate(self) -> None: return None @@ -181,6 +218,30 @@ class _ResolvedRoot: scope: str label: str path: Path + layout: FileTreeLayout = "flat" + + +@dataclass(frozen=True) +class _HermesScanPolicy: + # Non-official Hermes hub installs are the only real Hermes directories + # Skill Manager should adopt. Local/self-learned Hermes skills are too + # runtime-specific; bundled and official optional skills are Hermes-owned. + external_sources: dict[str, SourceDescriptor] + excluded_skill_names: frozenset[str] + + +def _iter_skill_roots(root: _ResolvedRoot): + if root.layout == "flat": + for skill_root in find_skill_roots(root.path): + yield skill_root, skill_root.name + return + if not root.path.is_dir(): + return + for category_dir in sorted(root.path.iterdir(), key=lambda path: path.name): + if not category_dir.is_dir() or category_dir.name.startswith("."): + continue + for skill_root in find_skill_roots(category_dir): + yield skill_root, f"{category_dir.name}/{skill_root.name}" def build_skills_adapters(kernel: HarnessKernelService) -> tuple[FileTreeSkillsAdapter, ...]: @@ -197,6 +258,7 @@ def build_skills_adapters(kernel: HarnessKernelService) -> tuple[FileTreeSkillsA scope="canonical", label="Managed skills root", path=managed_root, + layout=profile.layout, ), *tuple( _ResolvedRoot( @@ -204,6 +266,7 @@ def build_skills_adapters(kernel: HarnessKernelService) -> tuple[FileTreeSkillsA scope=root.scope, label=root.label, path=root.path_resolver(kernel.context), + layout=profile.layout, ) for root in profile.discovery_roots ), @@ -221,6 +284,8 @@ def build_skills_adapters(kernel: HarnessKernelService) -> tuple[FileTreeSkillsA app_probe_paths=tuple( resolver(kernel.context) for resolver in profile.app_probe_paths ), + layout=profile.layout, + default_category=profile.default_category, ) ) return tuple(adapters) @@ -238,20 +303,75 @@ def _scan_skill_roots( harness: str, label: str, roots: tuple[_ResolvedRoot, ...], -) -> list[SkillObservation]: + excluded_skill_names: frozenset[str] = frozenset(), + hermes_policy: _HermesScanPolicy | None = None, + managed_category: str = "skill-manager", +) -> tuple[list[SkillObservation], set[str]]: observations: list[SkillObservation] = [] + skipped_skill_names: set[str] = set() for root in roots: - for skill_root in find_skill_roots(root.path): - try: - package = parse_skill_package( - skill_root, - default_source=SourceDescriptor( - kind="harness-local", - locator=f"{harness}:{root.scope}:{skill_root.name}", - ), + for skill_root, locator_name in _iter_skill_roots(root): + hermes_source = _hermes_external_source( + hermes_policy, + package_name=None, + package_dir=skill_root.name, + locator_name=locator_name, + ) + is_skill_manager_binding = ( + hermes_policy is not None + and _is_skill_manager_hermes_binding( + skill_root=skill_root, + locator_name=locator_name, + managed_category=managed_category, + ) + ) + if hermes_policy is not None and hermes_source is None and not is_skill_manager_binding: + _record_excluded_skill( + skipped_skill_names, + package_name=None, + package_dir=skill_root.name, + locator_name=locator_name, ) + continue + + default_source = SourceDescriptor( + kind="harness-local", + locator=f"{harness}:{root.scope}:{locator_name}", + ) + if hermes_source is not None: + default_source = hermes_source + try: + package = parse_skill_package(skill_root, default_source=default_source) except SkillParseError: continue + + if hermes_policy is not None: + if not is_skill_manager_binding and _is_excluded_skill( + package_name=package.declared_name, + package_dir=skill_root.name, + locator_name=locator_name, + excluded_skill_names=excluded_skill_names, + ): + _record_excluded_skill( + skipped_skill_names, + package_name=package.declared_name, + package_dir=skill_root.name, + locator_name=locator_name, + ) + continue + + hermes_source = hermes_source or _hermes_external_source( + hermes_policy, + package_name=package.declared_name, + package_dir=skill_root.name, + locator_name=locator_name, + ) + if hermes_source is not None and package.source.kind == "harness-local": + try: + package = parse_skill_package(skill_root, default_source=hermes_source) + except SkillParseError: + continue + observations.append( SkillObservation( harness=harness, @@ -260,7 +380,141 @@ def _scan_skill_roots( package=package, ) ) - return observations + return observations, skipped_skill_names + + +def _hermes_scan_policy(skills_root: Path) -> _HermesScanPolicy: + excluded_names: set[str] = set() + external_sources: dict[str, SourceDescriptor] = {} + _read_hermes_bundled_manifest(skills_root / ".bundled_manifest", excluded_names) + _read_hermes_hub_lock( + skills_root / ".hub" / "lock.json", + excluded_names=excluded_names, + external_sources=external_sources, + ) + return _HermesScanPolicy( + external_sources=external_sources, + excluded_skill_names=frozenset(name for name in excluded_names if name), + ) + + +def _read_hermes_bundled_manifest(path: Path, names: set[str]) -> None: + if not path.is_file(): + return + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError: + return + for line in lines: + name = line.strip().partition(":")[0].strip() + if name: + names.add(name) + + +def _read_hermes_hub_lock( + path: Path, + *, + excluded_names: set[str], + external_sources: dict[str, SourceDescriptor], +) -> None: + if not path.is_file(): + return + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return + installed = payload.get("installed", {}) + if not isinstance(installed, dict): + return + for lock_name, raw_entry in installed.items(): + if not isinstance(raw_entry, dict): + continue + install_path = raw_entry.get("install_path") + names = _hermes_lock_names(str(lock_name), install_path) + if _is_hermes_official_lock_entry(raw_entry): + excluded_names.update(names) + continue + source = str(raw_entry.get("source", "")).strip() or "hermes-hub" + identifier = str(raw_entry.get("identifier", "")).strip() or str(lock_name) + descriptor = SourceDescriptor(kind=source, locator=identifier) + for name in names: + external_sources[name] = descriptor + + +def _is_hermes_official_lock_entry(raw_entry: dict[str, object]) -> bool: + metadata = raw_entry.get("metadata", {}) + source = str(raw_entry.get("source", "")) + identifier = str(raw_entry.get("identifier", "")) + trust_level = str(raw_entry.get("trust_level", "")) + return ( + source == "official" + or identifier.startswith("official/") + or trust_level == "builtin" + or (isinstance(metadata, dict) and metadata.get("backfilled_from") == "optional-skills") + ) + + +def _hermes_lock_names(lock_name: str, install_path: object) -> set[str]: + names = {lock_name} if lock_name else set() + if isinstance(install_path, str) and install_path: + names.add(install_path) + names.add(Path(install_path).name) + return {name for name in names if name} + + +def _hermes_external_source( + hermes_policy: _HermesScanPolicy | None, + *, + package_name: str | None, + package_dir: str, + locator_name: str, +) -> SourceDescriptor | None: + if hermes_policy is None: + return None + locator_leaf = Path(locator_name).name + for candidate in (package_name, locator_name, package_dir, locator_leaf): + if candidate and candidate in hermes_policy.external_sources: + return hermes_policy.external_sources[candidate] + return None + + +def _is_skill_manager_hermes_binding( + *, + skill_root: Path, + locator_name: str, + managed_category: str, +) -> bool: + return skill_root.is_symlink() and locator_name.startswith(f"{managed_category}/") + + +def _record_excluded_skill( + names: set[str], + *, + package_name: str | None, + package_dir: str, + locator_name: str, +) -> None: + locator_leaf = Path(locator_name).name + for candidate in (package_name, package_dir, locator_name, locator_leaf): + if candidate: + names.add(candidate) + + +def _is_excluded_skill( + *, + package_name: str | None, + package_dir: str, + locator_name: str, + excluded_skill_names: frozenset[str], +) -> bool: + if not excluded_skill_names: + return False + locator_leaf = Path(locator_name).name + return any( + candidate in excluded_skill_names + for candidate in (package_name, package_dir, locator_name, locator_leaf) + if candidate + ) __all__ = ["FileTreeSkillsAdapter", "build_skills_adapters", "scan_all_adapters"] diff --git a/skill_manager/application/skills/inventory.py b/skill_manager/application/skills/inventory.py index 58ff691a..87ab385b 100644 --- a/skill_manager/application/skills/inventory.py +++ b/skill_manager/application/skills/inventory.py @@ -44,6 +44,7 @@ class InventoryEntry: source_path: str | None = None package_dir: str | None = None package_path: Path | None = None + origin_harness: str | None = None sightings: list[InventorySighting] = field(default_factory=list) def add_sighting(self, sighting: InventorySighting) -> None: @@ -106,9 +107,18 @@ def from_snapshot( entries: list[InventoryEntry] = [] shared_path_index: dict[Path, InventoryEntry] = {} shared_match_index: dict[str, InventoryEntry] = {} + excluded_hermes_names = _excluded_hermes_names(harness_scans) for store_package in store_scan.packages: package = store_package.package + if _is_excluded_hermes_store_package( + name=package.declared_name, + package_dir=package.root_path.name, + origin_harness=store_package.origin_harness, + source_kind=package.source.kind, + excluded_hermes_names=excluded_hermes_names, + ): + continue entry = InventoryEntry( skill_ref=f"shared:{package.root_path.name}", name=package.declared_name, @@ -121,6 +131,7 @@ def from_snapshot( source_path=store_package.recorded_source_path, package_dir=package.root_path.name, package_path=package.root_path, + origin_harness=store_package.origin_harness, ) entry.add_sighting( InventorySighting( @@ -193,6 +204,32 @@ def entries_by_kind(self, kind: EntryKind) -> tuple[InventoryEntry, ...]: return tuple(entry for entry in self.entries if entry.kind == kind) +def _excluded_hermes_names(harness_scans: tuple[SkillsHarnessScan, ...]) -> set[str]: + names: set[str] = set() + for scan in harness_scans: + if scan.harness == "hermes": + names.update(scan.excluded_skill_names) + return names + + +def _is_excluded_hermes_store_package( + *, + name: str, + package_dir: str, + origin_harness: str | None, + source_kind: str, + excluded_hermes_names: set[str], +) -> bool: + if origin_harness != "hermes": + return False + if name in excluded_hermes_names or package_dir in excluded_hermes_names: + return True + # Legacy pre-policy Hermes self-learned skills were centralized when + # managed. Keep them out; only non-official Hermes hub provenance should + # be portable through Skill Manager. + return source_kind == "centralized" + + def _unmanaged_entry_key(declared_name: str, source: SourceDescriptor, revision: str) -> str: if source.is_source_backed: return stable_id("unmanaged", source.kind, source.locator, declared_name, revision) diff --git a/skill_manager/application/skills/manifest.py b/skill_manager/application/skills/manifest.py index d0e7df4d..4b22d0d5 100644 --- a/skill_manager/application/skills/manifest.py +++ b/skill_manager/application/skills/manifest.py @@ -16,9 +16,10 @@ class SkillStoreEntry: revision: str source_ref: str | None = None source_path: str | None = None + origin_harness: str | None = None - def to_dict(self) -> dict[str, str]: - payload = { + def to_dict(self) -> dict[str, object]: + payload: dict[str, object] = { "packageDir": self.package_dir, "declaredName": self.declared_name, "sourceKind": self.source_kind, @@ -29,6 +30,8 @@ def to_dict(self) -> dict[str, str]: payload["sourceRef"] = self.source_ref if self.source_path is not None: payload["sourcePath"] = self.source_path + if self.origin_harness is not None: + payload["originHarness"] = self.origin_harness return payload @@ -53,6 +56,7 @@ def load_skill_store_manifest(path: Path) -> SkillStoreManifest: revision=item["revision"], source_ref=item.get("sourceRef") if isinstance(item.get("sourceRef"), str) else None, source_path=item.get("sourcePath") if isinstance(item.get("sourcePath"), str) else None, + origin_harness=item.get("originHarness") if isinstance(item.get("originHarness"), str) else None, ) for item in payload.get("entries", []) ) diff --git a/skill_manager/application/skills/mutations.py b/skill_manager/application/skills/mutations.py index 8414b9f7..c0863266 100644 --- a/skill_manager/application/skills/mutations.py +++ b/skill_manager/application/skills/mutations.py @@ -9,7 +9,14 @@ from .identity import SourceDescriptor from .inventory import InventoryEntry from .package import parse_skill_package -from .policy import can_delete, can_manage, can_stop_managing, can_update, display_status, has_local_changes +from .policy import ( + can_delete, + can_manage, + can_stop_managing, + can_update, + display_status, + has_local_changes, +) from .queries import SkillsQueryService from .read_models import SkillsReadModelService from .source_fetch import SourceFetchService @@ -270,6 +277,7 @@ def _manage_entry(self, entry: InventoryEntry) -> None: declared_name=entry.name, source_kind=source_kind, source_locator=source_locator, + origin_harness=_origin_harness_for_entry(harness_sightings), ) except ValueError as error: raise MutationError(str(error), status=409) from error @@ -304,3 +312,10 @@ def _partition_bound_adapters( def _describe_harnesses(self, bindings: list[tuple[str, SkillsHarnessAdapter]]) -> str: return ", ".join(adapter.label for _harness, adapter in bindings) + + +def _origin_harness_for_entry(harness_sightings) -> str | None: + for sighting in harness_sightings: + if sighting.scope == "canonical": + return sighting.harness + return harness_sightings[0].harness if harness_sightings else None diff --git a/skill_manager/application/skills/observations.py b/skill_manager/application/skills/observations.py index 892e2c1e..2c6881ab 100644 --- a/skill_manager/application/skills/observations.py +++ b/skill_manager/application/skills/observations.py @@ -20,6 +20,7 @@ class StorePackageObservation: recorded_revision: str | None = None recorded_source_ref: str | None = None recorded_source_path: str | None = None + origin_harness: str | None = None @dataclass(frozen=True) @@ -29,6 +30,7 @@ class SkillsHarnessScan: logo_key: str | None installed: bool skills: tuple[SkillObservation, ...] = () + excluded_skill_names: tuple[str, ...] = () @dataclass(frozen=True) diff --git a/skill_manager/application/skills/store.py b/skill_manager/application/skills/store.py index e3810d8e..303f7d6e 100644 --- a/skill_manager/application/skills/store.py +++ b/skill_manager/application/skills/store.py @@ -42,6 +42,7 @@ def scan(self) -> SkillStoreScan: recorded_revision=entry.revision if entry else None, recorded_source_ref=entry.source_ref if entry else None, recorded_source_path=entry.source_path if entry else None, + origin_harness=entry.origin_harness if entry else None, ) ) return SkillStoreScan( @@ -58,6 +59,7 @@ def ingest( source_locator: str, source_ref: str | None = None, source_path_hint: str | None = None, + origin_harness: str | None = None, ) -> Path: self.root.mkdir(parents=True, exist_ok=True) with file_lock(self.lock_path): @@ -75,6 +77,7 @@ def ingest( revision=fingerprint, source_ref=source_ref, source_path=source_path_hint, + origin_harness=origin_harness, ) write_skill_store_manifest( self.manifest_path, @@ -110,6 +113,7 @@ def update( new_fp, e.source_ref if source_ref is None else source_ref, e.source_path if source_path_hint is None else source_path_hint, + e.origin_harness, ) if e.package_dir == package_dir else e diff --git a/skill_manager/harness/__init__.py b/skill_manager/harness/__init__.py index 15c5216c..1c4ff8d9 100644 --- a/skill_manager/harness/__init__.py +++ b/skill_manager/harness/__init__.py @@ -7,6 +7,7 @@ ConfigSubtreeBindingProfile, FamilyKey, FileTreeAvailability, + FileTreeLayout, FileTreeBindingProfile, FileTreeDiscoveryRoot, HarnessDefinition, @@ -28,6 +29,7 @@ "FamilyBinding", "FamilyKey", "FileTreeAvailability", + "FileTreeLayout", "FileTreeBindingProfile", "FileTreeDiscoveryRoot", "HarnessDefinition", diff --git a/skill_manager/harness/catalog.py b/skill_manager/harness/catalog.py index a546e294..bea9aa40 100644 --- a/skill_manager/harness/catalog.py +++ b/skill_manager/harness/catalog.py @@ -12,6 +12,19 @@ ) +def _hermes_home(context) -> Path: + override = context.env.get("SKILL_MANAGER_HERMES_HOME") or context.env.get("HERMES_HOME") + return Path(override) if override else context.home / ".hermes" + + +def _hermes_skills_root(context) -> Path: + return _hermes_home(context) / "skills" + + +def _hermes_config_path(context) -> Path: + return _hermes_home(context) / "config.yaml" + + def supported_harness_definitions() -> tuple[HarnessDefinition, ...]: return SUPPORTED_HARNESS_DEFINITIONS @@ -186,6 +199,26 @@ def harness_definitions_for_family(family: FamilyKey) -> tuple[HarnessDefinition ), }, ), + HarnessDefinition( + harness="hermes", + label="Hermes", + logo_key="hermes", + install_probe="hermes", + bindings={ + "skills": FileTreeBindingProfile( + managed_env="SKILL_MANAGER_HERMES_ROOT", + managed_default=_hermes_skills_root, + layout="categorized", + default_category="skill-manager", + ), + "mcp": ConfigSubtreeBindingProfile( + config_path_resolver=_hermes_config_path, + file_format="yaml", + subtree_path=("mcp_servers",), + codec="hermes", + ), + }, + ), HarnessDefinition( harness="openclaw", label="OpenClaw", diff --git a/skill_manager/harness/contracts.py b/skill_manager/harness/contracts.py index e9b7c464..3cb00db9 100644 --- a/skill_manager/harness/contracts.py +++ b/skill_manager/harness/contracts.py @@ -11,6 +11,7 @@ CommandFileRenderFormat = Literal["frontmatter_markdown", "cursor_plaintext"] CommandFileScope = Literal["global", "project"] FileTreeAvailability = Literal["cli", "cli_or_app"] +FileTreeLayout = Literal["flat", "categorized"] PathResolver = Callable[[ResolutionContext], Path] SubtreePath: TypeAlias = tuple[str, ...] SubtreePathResolver = Callable[[ResolutionContext], SubtreePath] @@ -32,6 +33,8 @@ class FileTreeBindingProfile: discovery_roots: tuple[FileTreeDiscoveryRoot, ...] = () availability: FileTreeAvailability = "cli" app_probe_paths: tuple[PathResolver, ...] = () + layout: FileTreeLayout = "flat" + default_category: str | None = None def resolve_managed_root(self, context: ResolutionContext) -> Path: if self.managed_default is None: @@ -49,7 +52,7 @@ class ConfigSubtreeBindingProfile: config_path_resolver: PathResolver | None = None discovery_config_path_resolvers: tuple[PathResolver, ...] = () source_install_config_path_resolvers: tuple[PathResolver, ...] = () - file_format: Literal["json", "jsonc", "toml"] = "json" + file_format: Literal["json", "jsonc", "toml", "yaml"] = "json" subtree_path: SubtreePath = () discovery_subtree_path_resolvers: tuple[SubtreePathResolver, ...] = () codec: str = "default" @@ -156,6 +159,7 @@ class HarnessStatus: "ConfigSubtreeBindingProfile", "FamilyKey", "FileTreeAvailability", + "FileTreeLayout", "FileTreeBindingProfile", "FileTreeDiscoveryRoot", "HarnessDefinition", diff --git a/tests/integration/test_http_api.py b/tests/integration/test_http_api.py index 7f50ded5..8f803efa 100644 --- a/tests/integration/test_http_api.py +++ b/tests/integration/test_http_api.py @@ -28,7 +28,7 @@ def test_empty_fixture_returns_skills_settings_and_health(self) -> None: settings["storage"]["settingsPath"], str(harness.spec.xdg_config_home / "skill-manager" / "settings.json"), ) - self.assertEqual(len(settings["harnesses"]), 5) + self.assertEqual(len(settings["harnesses"]), 6) openclaw = next(item for item in settings["harnesses"] if item["harness"] == "openclaw") self.assertTrue(openclaw["installed"]) self.assertTrue(openclaw["supportEnabled"]) @@ -84,7 +84,7 @@ def test_mixed_fixture_returns_skills_page_and_detail(self) -> None: self.assertEqual(detail["displayStatus"], "Managed") self.assertEqual( [cell["label"] for cell in detail["harnessCells"]], - ["Codex", "Claude", "Cursor", "OpenCode", "OpenClaw"], + ["Codex", "Claude", "Cursor", "OpenCode", "Hermes", "OpenClaw"], ) self.assertNotIn("updateStatus", detail["actions"]) self.assertEqual(source_status["updateStatus"], "no_update_available") diff --git a/tests/integration/test_mcp_routes.py b/tests/integration/test_mcp_routes.py index 74108163..445654ba 100644 --- a/tests/integration/test_mcp_routes.py +++ b/tests/integration/test_mcp_routes.py @@ -160,7 +160,7 @@ def test_list_servers_starts_empty(self) -> None: payload = harness.get_json("/api/mcp/servers") assert isinstance(payload, dict) self.assertEqual(payload.get("entries"), []) - # Columns reflect enabled harnesses (codex, claude, cursor, opencode, openclaw) + # Columns reflect enabled harnesses (codex, claude, cursor, opencode, hermes, openclaw) cols = [col["harness"] for col in payload["columns"]] self.assertIn("codex", cols) self.assertIn("claude", cols) @@ -404,7 +404,7 @@ def test_set_harnesses_fan_out(self) -> None: "/api/mcp/servers/exa/set-harnesses", {"target": "enabled"} ) self.assertTrue(response["ok"]) - self.assertEqual(set(response["succeeded"]), {"codex", "claude", "cursor", "opencode", "openclaw"}) + self.assertEqual(set(response["succeeded"]), {"codex", "claude", "cursor", "opencode", "hermes", "openclaw"}) # Verify each config file self.assertTrue((harness.spec.home / ".cursor" / "mcp.json").is_file()) @@ -412,6 +412,7 @@ def test_set_harnesses_fan_out(self) -> None: self.assertTrue((harness.spec.home / ".codex" / "config.toml").is_file()) self.assertTrue((harness.spec.home / ".opencode" / "opencode.jsonc").is_file()) self.assertTrue((harness.spec.home / ".openclaw" / "openclaw.json").is_file()) + self.assertTrue(harness.spec.hermes_config_path.is_file()) def test_uninstall_cleans_all_harnesses_and_central(self) -> None: with AppTestHarness() as harness: diff --git a/tests/integration/test_skills_mutations.py b/tests/integration/test_skills_mutations.py index 460e3831..08a063e6 100644 --- a/tests/integration/test_skills_mutations.py +++ b/tests/integration/test_skills_mutations.py @@ -34,6 +34,29 @@ def seed_local_changes_fixture(spec): ) +def seed_origin_shared_fixture(spec): + package_root = seed_skill_package( + spec.skills_store_root, + "policy-kit", + "Policy Kit", + body="Opencode originated policy.", + ) + revision, _ = fingerprint_package(package_root) + seed_store_manifest( + spec, + [ + SkillStoreEntry( + package_dir="policy-kit", + declared_name="Policy Kit", + source_kind="centralized", + source_locator="centralized:Policy Kit", + revision=revision, + origin_harness="opencode", + ) + ], + ) + + def seed_delete_fixture(spec): seed_shared_only_fixture(spec) target = spec.skills_store_root / "shared-audit" @@ -66,8 +89,168 @@ def seed_unmanage_fixture(spec): def seed_cursor_owned_skill_fixture(spec): seed_skill_package(spec.cursor_owned_root, "cursor-built", "Cursor Built") +def seed_hermes_bundled_exclusion_fixture(spec): + bundled = seed_skill_package( + spec.hermes_skills_root / "builtin", + "bundled-core", + "Bundled Core", + ) + seed_skill_package( + spec.hermes_skills_root / "local", + "user-helper", + "User Helper", + ) + (spec.hermes_skills_root / ".bundled_manifest").write_text( + "Bundled Core:0123456789abcdef\n", + encoding="utf-8", + ) + managed_copy = seed_skill_package( + spec.skills_store_root, + "bundled-core", + "Bundled Core", + ) + revision, _ = fingerprint_package(managed_copy) + seed_store_manifest( + spec, + [ + SkillStoreEntry( + package_dir="bundled-core", + declared_name="Bundled Core", + source_kind="centralized", + source_locator="centralized:Bundled Core", + revision=revision, + origin_harness="hermes", + ) + ], + ) + return bundled + + +def seed_hermes_external_hub_fixture(spec): + seed_skill_package( + spec.hermes_skills_root / "community", + "external-helper", + "External Helper", + ) + hub_lock = spec.hermes_skills_root / ".hub" / "lock.json" + hub_lock.parent.mkdir(parents=True, exist_ok=True) + hub_lock.write_text( + """{ + "version": 1, + "installed": { + "external-helper": { + "source": "github", + "identifier": "github/example/external-helper", + "trust_level": "community", + "install_path": "community/external-helper" + } + } +} +""", + encoding="utf-8", + ) + class SkillsMutationTests(unittest.TestCase): + + + def test_hermes_external_hub_skill_can_be_managed_and_enabled_everywhere(self) -> None: + with AppTestHarness(fixture_factory=seed_hermes_external_hub_fixture) as harness: + skills = harness.get_json("/api/skills") + external = next(row for row in skills["rows"] if row["name"] == "External Helper") + + harness.post_json(f"/api/skills/{external['skillRef']}/manage") + manifest = harness.container.skills_read_models.store.scan() + managed = next(item for item in manifest.packages if item.package.declared_name == "External Helper") + + self.assertEqual(managed.origin_harness, "hermes") + self.assertEqual(managed.package.source.kind, "github") + self.assertEqual(managed.package.source.locator, "github/example/external-helper") + + refreshed = harness.get_json("/api/skills") + external = next(row for row in refreshed["rows"] if row["name"] == "External Helper") + result = harness.post_json( + f"/api/skills/{external['skillRef']}/enable", + {"harness": "codex"}, + ) + + self.assertTrue(result["ok"]) + self.assertTrue((harness.spec.codex_root / "external-helper").is_symlink()) + + def test_hermes_owned_skills_are_hidden_and_manage_all_leaves_files_alone(self) -> None: + with AppTestHarness(fixture_factory=seed_hermes_bundled_exclusion_fixture) as harness: + skills = harness.get_json("/api/skills") + + self.assertNotIn("Bundled Core", [row["name"] for row in skills["rows"]]) + self.assertNotIn("User Helper", [row["name"] for row in skills["rows"]]) + + result = harness.post_json("/api/skills/manage-all") + refreshed = harness.get_json("/api/skills") + + self.assertTrue(result["ok"]) + self.assertNotIn("Bundled Core", [row["name"] for row in refreshed["rows"]]) + self.assertNotIn("User Helper", [row["name"] for row in refreshed["rows"]]) + bundled_dir = harness.spec.hermes_skills_root / "builtin" / "bundled-core" + self.assertTrue((bundled_dir / "SKILL.md").is_file()) + self.assertFalse(bundled_dir.is_symlink()) + + def test_origin_metadata_exposes_every_harness_cell(self) -> None: + with AppTestHarness(fixture_factory=seed_origin_shared_fixture) as harness: + skills = harness.get_json("/api/skills") + policy = next(row for row in skills["rows"] if row["name"] == "Policy Kit") + detail = harness.get_json(f"/api/skills/{policy['skillRef']}") + + self.assertEqual(len(policy["cells"]), len(skills["harnessColumns"])) + self.assertEqual(len(detail["harnessCells"]), len(skills["harnessColumns"])) + self.assertIn("codex", [cell["harness"] for cell in policy["cells"]]) + self.assertEqual(len(skills["harnessColumns"]), 6) + + def test_enable_allows_harnesses_other_than_origin(self) -> None: + with AppTestHarness(fixture_factory=seed_origin_shared_fixture) as harness: + skills = harness.get_json("/api/skills") + policy = next(row for row in skills["rows"] if row["name"] == "Policy Kit") + + result = harness.post_json( + f"/api/skills/{policy['skillRef']}/enable", + {"harness": "codex"}, + ) + + self.assertTrue(result["ok"]) + self.assertTrue((harness.spec.codex_root / "policy-kit").is_symlink()) + + def test_set_harnesses_targets_all_enabled_installed_harnesses(self) -> None: + with AppTestHarness(fixture_factory=seed_origin_shared_fixture) as harness: + skills = harness.get_json("/api/skills") + policy = next(row for row in skills["rows"] if row["name"] == "Policy Kit") + + result = harness.post_json( + f"/api/skills/{policy['skillRef']}/set-harnesses", + {"target": "enabled"}, + ) + + self.assertTrue(result["ok"]) + self.assertEqual( + set(result["succeeded"]), + {"codex", "claude", "cursor", "opencode", "openclaw", "hermes"}, + ) + self.assertTrue((harness.spec.codex_root / "policy-kit").is_symlink()) + self.assertTrue((harness.spec.claude_root / "policy-kit").is_symlink()) + self.assertTrue((harness.spec.cursor_root / "policy-kit").is_symlink()) + self.assertTrue((harness.spec.opencode_root / "policy-kit").is_symlink()) + self.assertTrue((harness.spec.openclaw_managed_root / "policy-kit").is_symlink()) + self.assertTrue((harness.spec.hermes_skills_root / "skill-manager" / "policy-kit").is_symlink()) + + def test_manage_records_origin_only(self) -> None: + with AppTestHarness(mixed=True) as harness: + skills = harness.get_json("/api/skills") + trace_lens = next(row for row in skills["rows"] if row["name"] == "Trace Lens") + + harness.post_json(f"/api/skills/{trace_lens['skillRef']}/manage") + manifest = harness.container.skills_read_models.store.scan() + managed = next(item for item in manifest.packages if item.package.declared_name == "Trace Lens") + + self.assertEqual(managed.origin_harness, "claude") + def test_enable_managed_skill_creates_symlink(self) -> None: with AppTestHarness(fixture_factory=seed_shared_only_fixture) as harness: skills = harness.get_json("/api/skills") @@ -163,7 +346,7 @@ def test_set_skill_harnesses_only_targets_available_harnesses(self) -> None: with AppTestHarness(fixture_factory=seed_shared_only_fixture) as harness: # Simulate missing non-core CLIs by removing their stubs from the # fake PATH. Cursor may still be available through its app probe. - for cli in ("cursor-agent", "opencode", "openclaw"): + for cli in ("cursor-agent", "opencode", "hermes", "openclaw"): stub = harness.spec.bin_dir / cli if stub.exists(): stub.unlink() @@ -178,6 +361,7 @@ def test_set_skill_harnesses_only_targets_available_harnesses(self) -> None: self.assertTrue(installed_by_harness["codex"]) self.assertTrue(installed_by_harness["claude"]) self.assertFalse(installed_by_harness["opencode"]) + self.assertFalse(installed_by_harness["hermes"]) self.assertFalse(installed_by_harness["openclaw"]) result = harness.post_json( @@ -200,6 +384,7 @@ def test_set_skill_harnesses_only_targets_available_harnesses(self) -> None: self.assertFalse((harness.spec.cursor_root / "shared-audit").exists()) # Unavailable harness folders remain untouched. self.assertFalse((harness.spec.opencode_root / "shared-audit").exists()) + self.assertFalse((harness.spec.hermes_skills_root / "skill-manager" / "shared-audit").exists()) self.assertFalse((harness.spec.openclaw_managed_root / "shared-audit").exists()) def test_manage_skill_replaces_found_local_copy_with_managed_links(self) -> None: @@ -338,6 +523,7 @@ def test_delete_managed_skill_removes_shared_package_and_all_links(self) -> None self.assertFalse((harness.spec.codex_root / "shared-audit").exists()) self.assertFalse((harness.spec.claude_root / "shared-audit").exists()) self.assertFalse((harness.spec.opencode_root / "shared-audit").exists()) + self.assertFalse((harness.spec.hermes_skills_root / "skill-manager" / "shared-audit").exists()) self.assertFalse((harness.spec.openclaw_managed_root / "shared-audit").exists()) refreshed = harness.get_json("/api/skills") diff --git a/tests/support/app_harness.py b/tests/support/app_harness.py index 10e620e2..b022a045 100644 --- a/tests/support/app_harness.py +++ b/tests/support/app_harness.py @@ -3,6 +3,7 @@ from collections.abc import Callable from contextlib import AbstractContextManager import json +from http.client import IncompleteRead from pathlib import Path from tempfile import TemporaryDirectory from urllib.error import HTTPError @@ -43,6 +44,18 @@ def probe(self, _spec) -> McpAvailabilityResult: return McpAvailabilityResult("unavailable", "not checked in tests") +def _read_http_error_payload(error: HTTPError) -> str: + try: + return error.read().decode("utf-8") + except IncompleteRead as exc: + if exc.partial: + return exc.partial.decode("utf-8") + reason = getattr(error, "reason", None) or str(error) + return json.dumps({"error": str(reason)}) + finally: + error.close() + + class AppTestHarness(AbstractContextManager["AppTestHarness"]): def __init__( self, @@ -102,8 +115,7 @@ def get_json(self, path: str, *, expected_status: int = 200) -> object: payload = response.read().decode("utf-8") except HTTPError as error: status = error.code - payload = error.read().decode("utf-8") - error.close() + payload = _read_http_error_payload(error) if status != expected_status: raise AssertionError(f"expected {expected_status} for {path}, got {status}: {payload}") return json.loads(payload) @@ -134,8 +146,7 @@ def _send_json(self, method: str, path: str, body: object = None, *, expected_st payload = response.read().decode("utf-8") except HTTPError as error: status = error.code - payload = error.read().decode("utf-8") - error.close() + payload = _read_http_error_payload(error) if status != expected_status: raise AssertionError(f"expected {expected_status} for {method} {path}, got {status}: {payload}") return json.loads(payload) diff --git a/tests/support/fake_home.py b/tests/support/fake_home.py index 1b155aed..540bad05 100644 --- a/tests/support/fake_home.py +++ b/tests/support/fake_home.py @@ -55,6 +55,18 @@ def openclaw_home(self) -> Path: def openclaw_managed_root(self) -> Path: return self.openclaw_home / "skills" + @property + def hermes_home(self) -> Path: + return self.home / ".hermes" + + @property + def hermes_skills_root(self) -> Path: + return self.hermes_home / "skills" + + @property + def hermes_config_path(self) -> Path: + return self.hermes_home / "config.yaml" + @property def bin_dir(self) -> Path: return self.root / "bin" @@ -85,12 +97,13 @@ def create_fake_home_spec(root: Path, *, seed_openclaw_state: bool = True) -> Fa spec.cursor_root, spec.opencode_root, spec.openclaw_managed_root, + spec.hermes_skills_root, spec.xdg_state_home, spec.bin_dir, ): path.mkdir(parents=True, exist_ok=True) - for executable in ("codex", "claude", "cursor-agent", "opencode"): + for executable in ("codex", "claude", "cursor-agent", "opencode", "hermes"): write_cli_stub(spec.bin_dir / executable, executable) if seed_openclaw_state: write_cli_stub(spec.bin_dir / "openclaw", "openclaw") diff --git a/tests/unit/test_backend_container.py b/tests/unit/test_backend_container.py index 64f289a1..e4373d05 100644 --- a/tests/unit/test_backend_container.py +++ b/tests/unit/test_backend_container.py @@ -143,7 +143,7 @@ def test_settings_surface_store_issues(self) -> None: settings["storage"]["marketplaceCachePath"], str(spec.xdg_data_home / "skill-manager" / "marketplace"), ) - self.assertEqual(len(settings["harnesses"]), 5) + self.assertEqual(len(settings["harnesses"]), 6) codex = next(item for item in settings["harnesses"] if item["harness"] == "codex") self.assertIn("managedLocation", codex) self.assertIn("installed", codex) diff --git a/tests/unit/test_mcp_adapters.py b/tests/unit/test_mcp_adapters.py index 97b24fc8..e018e267 100644 --- a/tests/unit/test_mcp_adapters.py +++ b/tests/unit/test_mcp_adapters.py @@ -10,6 +10,7 @@ from skill_manager.application.mcp.store import McpServerSpec, McpServerStore, McpSource from skill_manager.errors import MutationError from skill_manager.harness import HarnessKernelService, HarnessSupportStore +from ruamel.yaml import YAML def _spec(name: str = "exa") -> McpServerSpec: @@ -23,6 +24,10 @@ def _spec(name: str = "exa") -> McpServerSpec: env=(("KEY", "value"),), ) +def _load_yaml(path: Path) -> dict[str, object]: + payload = YAML(typ="safe").load(path.read_text(encoding="utf-8")) + return payload if isinstance(payload, dict) else {} + def _adapter( harness: str, @@ -189,6 +194,97 @@ def test_enable_and_disable_round_trip_for_toml(self) -> None: payload = tomllib.loads(adapter.config_path.read_text(encoding="utf-8")) self.assertEqual(payload.get("mcp_servers", {}), {}) + def test_enable_and_disable_round_trip_for_hermes_yaml(self) -> None: + with TemporaryDirectory() as tmp: + home = Path(tmp) + adapter = _adapter("hermes", home=home) + adapter.config_path.parent.mkdir(parents=True, exist_ok=True) + adapter.config_path.write_text( + "model: test-model\nmcp_servers:\n existing:\n command: ls\n", + encoding="utf-8", + ) + + adapter.enable_server(_spec()) + payload = _load_yaml(adapter.config_path) + self.assertEqual(payload["model"], "test-model") + self.assertEqual(payload["mcp_servers"]["existing"]["command"], "ls") + self.assertEqual(payload["mcp_servers"]["exa"]["command"], "npx") + self.assertEqual(payload["mcp_servers"]["exa"]["env"], {"KEY": "value"}) + + adapter.disable_server("exa") + payload = _load_yaml(adapter.config_path) + self.assertIn("existing", payload["mcp_servers"]) + self.assertNotIn("exa", payload["mcp_servers"]) + + def test_hermes_yaml_round_trip_preserves_comments_and_existing_format(self) -> None: + with TemporaryDirectory() as tmp: + home = Path(tmp) + adapter = _adapter("hermes", home=home) + adapter.config_path.parent.mkdir(parents=True, exist_ok=True) + adapter.config_path.write_text( + """# top-level comment +model: "test-model" # inline model comment + +# keep this block comment +mcp_servers: + # existing server comment + existing: + command: ls # command comment + args: ["-la"] # flow-style args comment + +# unrelated block comment +profiles: + default: true # profile comment +""", + encoding="utf-8", + ) + + adapter.enable_server(_spec()) + enabled_text = adapter.config_path.read_text(encoding="utf-8") + + self.assertIn("# top-level comment", enabled_text) + self.assertIn("# inline model comment", enabled_text) + self.assertIn("# keep this block comment", enabled_text) + self.assertIn("# existing server comment", enabled_text) + self.assertIn("# command comment", enabled_text) + self.assertIn("# flow-style args comment", enabled_text) + self.assertIn("# unrelated block comment", enabled_text) + self.assertIn("# profile comment", enabled_text) + self.assertIn("exa:", enabled_text) + self.assertIn('model: "test-model"', enabled_text) + + adapter.disable_server("exa") + disabled_text = adapter.config_path.read_text(encoding="utf-8") + + self.assertNotIn(" exa:", disabled_text) + self.assertIn("# top-level comment", disabled_text) + self.assertIn("# existing server comment", disabled_text) + self.assertIn("# command comment", disabled_text) + self.assertIn("# unrelated block comment", disabled_text) + self.assertIn("existing:", disabled_text) + + def test_hermes_yaml_http_uses_headers_and_sse_transport(self) -> None: + with TemporaryDirectory() as tmp: + home = Path(tmp) + adapter = _adapter("hermes", home=home) + + adapter.enable_server( + McpServerSpec( + name="remote", + display_name="Remote", + source=McpSource.marketplace("@remote/server"), + transport="sse", + url="https://mcp.example.com/sse", + headers=(("Authorization", "Bearer token"),), + ) + ) + + payload = _load_yaml(adapter.config_path) + remote = payload["mcp_servers"]["remote"] + self.assertEqual(remote["url"], "https://mcp.example.com/sse") + self.assertEqual(remote["transport"], "sse") + self.assertEqual(remote["headers"], {"Authorization": "Bearer token"}) + def test_cursor_writes_explicit_type_for_stdio_and_http(self) -> None: with TemporaryDirectory() as tmp: home = Path(tmp) diff --git a/tests/unit/test_skill_manifest.py b/tests/unit/test_skill_manifest.py index bac4fd2b..92ad47eb 100644 --- a/tests/unit/test_skill_manifest.py +++ b/tests/unit/test_skill_manifest.py @@ -33,6 +33,43 @@ def test_manifest_round_trip(self) -> None: loaded = load_manifest(manifest_path) self.assertEqual(loaded, manifest) + def test_manifest_round_trip_with_origin(self) -> None: + with TemporaryDirectory() as temp_dir: + manifest_path = Path(temp_dir) / "manifest.json" + manifest = SkillStoreManifest( + entries=( + SkillStoreEntry( + package_dir="policy-kit", + declared_name="Policy Kit", + source_kind="centralized", + source_locator="centralized:Policy Kit", + revision="def456", + origin_harness="opencode", + ), + ) + ) + + write_manifest(manifest_path, manifest) + loaded = load_manifest(manifest_path) + + self.assertEqual(loaded, manifest) + payload = manifest_path.read_text(encoding="utf-8") + self.assertIn('"originHarness": "opencode"', payload) + + def test_load_legacy_manifest_defaults_to_no_origin(self) -> None: + with TemporaryDirectory() as temp_dir: + manifest_path = Path(temp_dir) / "manifest.json" + manifest_path.write_text( + '{"entries":[{"packageDir":"legacy","declaredName":"Legacy","sourceKind":"github",' + '"sourceLocator":"github:mode-io/legacy","revision":"abc"}]}', + encoding="utf-8", + ) + + loaded = load_manifest(manifest_path) + entry = loaded.entries[0] + + self.assertIsNone(entry.origin_harness) + def test_load_manifest_handles_missing_file(self) -> None: with TemporaryDirectory() as temp_dir: loaded = load_manifest(Path(temp_dir) / "missing.json") diff --git a/tests/unit/test_skills_adapters.py b/tests/unit/test_skills_adapters.py index 0db902dd..517890a8 100644 --- a/tests/unit/test_skills_adapters.py +++ b/tests/unit/test_skills_adapters.py @@ -25,14 +25,34 @@ def test_adapter_scans_discovery_roots_and_reports_installation(self) -> None: spec = create_fake_home_spec(Path(temp_dir)) seed_skill_package(spec.codex_legacy_root, "trace-lens", "Trace Lens") seed_skill_package(spec.openclaw_managed_root, "watch", "Workspace Watch") + seed_skill_package(spec.hermes_skills_root / "debugging", "trace", "Hermes Trace") + hub_lock = spec.hermes_skills_root / ".hub" / "lock.json" + hub_lock.parent.mkdir(parents=True, exist_ok=True) + hub_lock.write_text( + """{ + \"version\": 1, + \"installed\": { + \"trace\": { + \"source\": \"github\", + \"identifier\": \"github/example/hermes-trace\", + \"trust_level\": \"community\", + \"install_path\": \"debugging/trace\" + } + } +} +""", + encoding="utf-8", + ) codex = _adapter("codex", spec) claude = _adapter("claude", spec) openclaw = _adapter("openclaw", spec) + hermes = _adapter("hermes", spec) codex_scan = codex.scan() claude_scan = claude.scan() openclaw_scan = openclaw.scan() + hermes_scan = hermes.scan() self.assertTrue(codex_scan.installed) self.assertEqual(codex_scan.skills[0].package.declared_name, "Trace Lens") @@ -43,6 +63,90 @@ def test_adapter_scans_discovery_roots_and_reports_installation(self) -> None: [skill.package.declared_name for skill in openclaw_scan.skills], ["Workspace Watch"], ) + self.assertTrue(hermes_scan.installed) + self.assertEqual( + [skill.package.declared_name for skill in hermes_scan.skills], + ["Hermes Trace"], + ) + self.assertEqual(hermes_scan.skills[0].package.source.kind, "github") + self.assertEqual( + hermes_scan.skills[0].package.source.locator, + "github/example/hermes-trace", + ) + + def test_hermes_scan_only_includes_external_hub_skills_without_touching_files(self) -> None: + with TemporaryDirectory() as temp_dir: + spec = create_fake_home_spec(Path(temp_dir)) + bundled = seed_skill_package( + spec.hermes_skills_root / "builtin", + "bundled-core", + "Bundled Core", + ) + official = seed_skill_package( + spec.hermes_skills_root / "optional", + "official-helper", + "Official Helper", + ) + local = seed_skill_package( + spec.hermes_skills_root / "local", + "user-helper", + "User Helper", + ) + community_hub = seed_skill_package( + spec.hermes_skills_root / "hub", + "community-helper", + "Community Helper", + ) + (bundled / "SKILL.md").write_bytes(b"\xff") + (official / "SKILL.md").write_bytes(b"\xff") + (local / "SKILL.md").write_bytes(b"\xff") + (spec.hermes_skills_root / ".bundled_manifest").write_text( + "Bundled Core:0123456789abcdef\n", + encoding="utf-8", + ) + hub_lock = spec.hermes_skills_root / ".hub" / "lock.json" + hub_lock.parent.mkdir(parents=True, exist_ok=True) + hub_lock.write_text( + """{ + "version": 1, + "installed": { + "official-helper": { + "source": "official", + "identifier": "official/optional/official-helper", + "trust_level": "builtin", + "install_path": "optional/official-helper", + "metadata": {"backfilled_from": "optional-skills"} + }, + "community-helper": { + "source": "github", + "identifier": "github/example/community-helper", + "trust_level": "community", + "install_path": "hub/community-helper" + } + } +} +""", + encoding="utf-8", + ) + hermes = _adapter("hermes", spec) + + scan = hermes.scan() + + self.assertEqual( + [skill.package.declared_name for skill in scan.skills], + ["Community Helper"], + ) + self.assertEqual(scan.skills[0].package.source.kind, "github") + self.assertEqual(scan.skills[0].package.source.locator, "github/example/community-helper") + self.assertIn("Bundled Core", scan.excluded_skill_names) + self.assertIn("official-helper", scan.excluded_skill_names) + self.assertIn("user-helper", scan.excluded_skill_names) + self.assertTrue((bundled / "SKILL.md").is_file()) + self.assertTrue((official / "SKILL.md").is_file()) + self.assertFalse(bundled.is_symlink()) + self.assertFalse(official.is_symlink()) + self.assertTrue((local / "SKILL.md").is_file()) + self.assertTrue((community_hub / "SKILL.md").is_file()) def test_adapter_reports_missing_cli_as_not_installed(self) -> None: with TemporaryDirectory() as temp_dir: @@ -142,6 +246,54 @@ def test_materialize_binding_restores_real_directory(self) -> None: self.assertFalse(link.is_symlink()) self.assertIn("shared version", (link / "SKILL.md").read_text(encoding="utf-8")) + def test_hermes_enable_creates_symlink_under_default_category(self) -> None: + with TemporaryDirectory() as temp_dir: + spec = create_fake_home_spec(Path(temp_dir)) + package = seed_skill_package(spec.skills_store_root, "audit", "Audit") + hermes = _adapter("hermes", spec) + + hermes.enable_shared_package(package) + + link = spec.hermes_skills_root / "skill-manager" / "audit" + self.assertTrue(link.is_symlink()) + self.assertEqual(link.resolve(), package.resolve()) + self.assertTrue(hermes.has_binding("audit")) + + def test_hermes_enable_ignores_real_directory_in_existing_category(self) -> None: + with TemporaryDirectory() as temp_dir: + spec = create_fake_home_spec(Path(temp_dir)) + package = seed_skill_package(spec.skills_store_root, "audit", "Audit") + hermes_owned = seed_skill_package( + spec.hermes_skills_root / "local", + "audit", + "Hermes Audit", + ) + hermes = _adapter("hermes", spec) + + self.assertFalse(hermes.has_binding("audit")) + + hermes.enable_shared_package(package) + + link = spec.hermes_skills_root / "skill-manager" / "audit" + self.assertTrue(link.is_symlink()) + self.assertEqual(link.resolve(), package.resolve()) + self.assertTrue((hermes_owned / "SKILL.md").is_file()) + self.assertFalse(hermes_owned.is_symlink()) + + def test_hermes_disable_finds_symlink_in_existing_category(self) -> None: + with TemporaryDirectory() as temp_dir: + spec = create_fake_home_spec(Path(temp_dir)) + package = seed_skill_package(spec.skills_store_root, "audit", "Audit") + link = spec.hermes_skills_root / "custom" / "audit" + link.parent.mkdir(parents=True, exist_ok=True) + link.symlink_to(package) + hermes = _adapter("hermes", spec) + + hermes.disable_shared_package("audit") + + self.assertFalse(link.exists()) + self.assertFalse(link.is_symlink()) + if __name__ == "__main__": unittest.main()