Skip to content
Merged
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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ Native release artifacts are published on GitHub Releases for macOS ARM64/x64 an
<strong>OpenCode</strong><br />
<a href="https://opencode.ai/docs">Docs</a>
</td>
<td align="center" valign="middle">
<img src="assets/harness-logos/hermes-logo.png" alt="Hermes Agent" height="56" /><br />
<strong>Hermes Agent</strong><br />
<a href="https://hermes-agent.nousresearch.com/docs">Docs</a>
</td>
<td align="center" valign="middle">
<img src="assets/harness-logos/openclaw-logo.svg" alt="OpenClaw" height="56" /><br />
<strong>OpenClaw</strong><br />
Expand All @@ -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
Expand Down Expand Up @@ -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/<category>/<skill>/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
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down
10 changes: 9 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ GitHub Releases 会发布 macOS ARM64/x64 和 Linux x64/ARM64 的原生 release
| Claude Code | 支持 | 支持 | 支持 |
| Cursor | 支持 | 支持 | 支持 |
| OpenCode | 支持 | 支持 | 支持 |
| Hermes Agent | 支持 | 支持 | 暂不支持 |
| OpenClaw | 支持 | 暂不支持 | 暂不支持 |

## 本地优先安全模型
Expand Down Expand Up @@ -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/<category>/<skill>/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 扫描
Expand All @@ -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 服务器存在不同配置时,会先要求你选择事实来源。
Expand All @@ -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 文件。
Expand Down Expand Up @@ -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`。

## 从源码运行

Expand Down
Binary file added assets/harness-logos/hermes-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 6 additions & 1 deletion frontend/src/components/harness/harnessPresentation.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -24,6 +25,10 @@ const HARNESS_LOGO_ASSETS: Record<HarnessLogoKey, HarnessPresentation> = {
logoSrc: cursorLogo,
variant: "cursor",
},
hermes: {
logoSrc: hermesLogo,
variant: "hermes",
},
opencode: {
logoSrc: opencodeLogo,
variant: "opencode",
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
60 changes: 40 additions & 20 deletions skill_manager/application/mcp/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand All @@ -312,40 +328,36 @@ 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,
document: dict[str, object],
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:
Expand All @@ -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 = {
Expand Down
57 changes: 57 additions & 0 deletions skill_manager/application/mcp/mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 -----------------------------------------------------------------


Expand Down Expand Up @@ -297,6 +352,7 @@ def _str_pairs(value: object) -> tuple[tuple[str, str], ...] | None:
"cursor": CursorMapper(),
"opencode": OpenCodeMapper(),
"codex": CodexMapper(),
"hermes": HermesMapper(),
"openclaw": OpenClawMapper(),
}

Expand All @@ -311,6 +367,7 @@ def get_mapper(kind: str) -> TransportMapper:
"ClaudeCodeMapper",
"CodexMapper",
"CursorMapper",
"HermesMapper",
"OpenClawMapper",
"OpenCodeMapper",
"TransportMapper",
Expand Down
Loading