mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
`hermes dump` and the startup banner both call `git rev-parse HEAD` to
report the running commit, but `.dockerignore` line 2 excludes `.git` —
so inside the published image `hermes dump` shows
`version: ... [(unknown)]` and the banner drops its `· upstream <sha>`
suffix entirely. That makes support triage from container bug reports
impossible: we can't tell which commit the user is actually running.
Fix: thread the build-time SHA through as a Docker build-arg, write it
to `/opt/hermes/.hermes_build_sha` in the image, and have a new
`hermes_cli/build_info.get_build_sha()` read it as a fallback after the
existing live-git lookup fails. Output format is unchanged in both
callsites — same 8-char short SHA whether resolved live or baked.
Wiring:
- Dockerfile: `ARG HERMES_GIT_SHA=` + write-file step after the source
copy. Empty/missing arg → no file written → callers fall through to
live git (so local `docker build` without --build-arg is unchanged).
- docker-publish.yml: passes `HERMES_GIT_SHA=${{ github.sha }}` on all
four build-push-action steps (amd64/arm64, smoke-test + final push).
- dump.py:_get_git_commit() / banner.py:get_git_banner_state(): try
live git first, fall back to baked SHA, then to legacy `(unknown)`
/ None. Banner returns `upstream == local, ahead=0` because a built
image is by definition pinned to one commit.
Coverage:
- Unit tests cover build_info (file present/absent/empty/error,
truncation, whitespace), dump (live-git wins, both fallbacks,
identical output-format regression guard), and banner (no-repo +
baked, no-repo + no-sha, shallow-clone fallback).
- tests/docker/test_dump_build_sha.py is an integration regression
guard that runs against the real image, reads
`/opt/hermes/.hermes_build_sha`, and asserts `hermes dump` surfaces
its content (or stays at `(unknown)` if no file).
- Verified end-to-end: `docker build --build-arg HERMES_GIT_SHA=abc...`
→ `docker run ... dump` reports `[abc12345]`; without the build-arg
it reports `[(unknown)]` as before.
349 lines
11 KiB
Python
349 lines
11 KiB
Python
"""
|
|
Dump command for hermes CLI.
|
|
|
|
Outputs a compact, plain-text summary of the user's Hermes setup
|
|
that can be copy-pasted into Discord/GitHub/Telegram for support context.
|
|
No ANSI colors, no checkmarks — just data.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import platform
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from hermes_cli.config import get_hermes_home, get_env_path, get_project_root, load_config
|
|
from hermes_cli.env_loader import load_hermes_dotenv
|
|
from hermes_constants import display_hermes_home
|
|
from agent.skill_utils import is_excluded_skill_path
|
|
|
|
|
|
def _get_git_commit(project_root: Path) -> str:
|
|
"""Return short git commit hash, or '(unknown)'.
|
|
|
|
Source installs and dev images resolve this live via ``git rev-parse``.
|
|
The published Docker image excludes ``.git`` from the build context, so
|
|
that lookup always fails — we fall back to the baked-in build SHA written
|
|
to ``<project_root>/.hermes_build_sha`` by the Dockerfile's
|
|
``HERMES_GIT_SHA`` build-arg (see ``hermes_cli/build_info.py``).
|
|
The output format is identical regardless of source.
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "rev-parse", "--short=8", "HEAD"],
|
|
capture_output=True, text=True, timeout=5,
|
|
cwd=str(project_root),
|
|
)
|
|
if result.returncode == 0:
|
|
value = result.stdout.strip()
|
|
if value:
|
|
return value
|
|
except Exception:
|
|
pass
|
|
|
|
# Fall back to the build-time baked SHA (populated in published Docker
|
|
# images, absent otherwise). Defers the import so the dump module
|
|
# stays cheap on non-dump code paths.
|
|
try:
|
|
from hermes_cli.build_info import get_build_sha
|
|
baked = get_build_sha(short=8)
|
|
if baked:
|
|
return baked
|
|
except Exception:
|
|
pass
|
|
|
|
return "(unknown)"
|
|
|
|
|
|
def _redact(value: str) -> str:
|
|
"""Redact all but first 4 and last 4 chars.
|
|
|
|
Thin wrapper over :func:`agent.redact.mask_secret`. Returns ``""`` for
|
|
an empty value (matches the historical behavior of this helper —
|
|
``hermes dump`` formats empty values as blank, not as ``"(not set)"``).
|
|
"""
|
|
from agent.redact import mask_secret
|
|
return mask_secret(value)
|
|
|
|
|
|
def _gateway_status() -> str:
|
|
"""Return a short gateway status string."""
|
|
try:
|
|
from hermes_cli.gateway import get_gateway_runtime_snapshot
|
|
|
|
snapshot = get_gateway_runtime_snapshot()
|
|
if snapshot.running:
|
|
mode = snapshot.manager
|
|
if snapshot.has_process_service_mismatch:
|
|
mode = "manual"
|
|
return f"running ({mode}, pid {snapshot.gateway_pids[0]})"
|
|
if snapshot.service_installed and not snapshot.service_running:
|
|
return f"stopped ({snapshot.manager})"
|
|
return f"stopped ({snapshot.manager})"
|
|
except Exception:
|
|
return "unknown" if sys.platform.startswith(("linux", "darwin")) else "N/A"
|
|
|
|
|
|
def _count_skills(hermes_home: Path) -> int:
|
|
"""Count installed skills."""
|
|
skills_dir = hermes_home / "skills"
|
|
if not skills_dir.is_dir():
|
|
return 0
|
|
count = 0
|
|
for item in skills_dir.rglob("SKILL.md"):
|
|
if is_excluded_skill_path(item):
|
|
continue
|
|
count += 1
|
|
return count
|
|
|
|
|
|
def _count_mcp_servers(config: dict) -> int:
|
|
"""Count configured MCP servers."""
|
|
mcp = config.get("mcp", {})
|
|
servers = mcp.get("servers", {})
|
|
return len(servers)
|
|
|
|
|
|
def _cron_summary(hermes_home: Path) -> str:
|
|
"""Return cron jobs summary."""
|
|
jobs_file = hermes_home / "cron" / "jobs.json"
|
|
if not jobs_file.exists():
|
|
return "0"
|
|
try:
|
|
with open(jobs_file, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
jobs = data.get("jobs", [])
|
|
active = sum(1 for j in jobs if j.get("enabled", True))
|
|
return f"{active} active / {len(jobs)} total"
|
|
except Exception:
|
|
return "(error reading)"
|
|
|
|
|
|
def _configured_platforms() -> list[str]:
|
|
"""Return list of configured messaging platform names."""
|
|
checks = {
|
|
"telegram": "TELEGRAM_BOT_TOKEN",
|
|
"discord": "DISCORD_BOT_TOKEN",
|
|
"slack": "SLACK_BOT_TOKEN",
|
|
"whatsapp": "WHATSAPP_ENABLED",
|
|
"signal": "SIGNAL_HTTP_URL",
|
|
"email": "EMAIL_ADDRESS",
|
|
"sms": "TWILIO_ACCOUNT_SID",
|
|
"matrix": "MATRIX_HOMESERVER_URL",
|
|
"mattermost": "MATTERMOST_URL",
|
|
"homeassistant": "HASS_TOKEN",
|
|
"dingtalk": "DINGTALK_CLIENT_ID",
|
|
"feishu": "FEISHU_APP_ID",
|
|
"wecom": "WECOM_BOT_ID",
|
|
"wecom_callback": "WECOM_CALLBACK_CORP_ID",
|
|
"weixin": "WEIXIN_ACCOUNT_ID",
|
|
"qqbot": "QQ_APP_ID",
|
|
}
|
|
return [name for name, env in checks.items() if os.getenv(env)]
|
|
|
|
|
|
def _memory_provider(config: dict) -> str:
|
|
"""Return the active memory provider name."""
|
|
mem = config.get("memory", {})
|
|
provider = mem.get("provider", "")
|
|
return provider if provider else "built-in"
|
|
|
|
|
|
def _get_model_and_provider(config: dict) -> tuple[str, str]:
|
|
"""Extract model and provider from config."""
|
|
model_cfg = config.get("model", "")
|
|
if isinstance(model_cfg, dict):
|
|
model = model_cfg.get("default") or model_cfg.get("model") or model_cfg.get("name") or "(not set)"
|
|
provider = model_cfg.get("provider") or "(auto)"
|
|
elif isinstance(model_cfg, str):
|
|
model = model_cfg or "(not set)"
|
|
provider = "(auto)"
|
|
else:
|
|
model = "(not set)"
|
|
provider = "(auto)"
|
|
return model, provider
|
|
|
|
|
|
def _config_overrides(config: dict) -> dict[str, str]:
|
|
"""Find non-default config values worth reporting.
|
|
|
|
Returns a flat dict of dotpath -> value for interesting overrides.
|
|
"""
|
|
from hermes_cli.config import DEFAULT_CONFIG
|
|
|
|
overrides = {}
|
|
|
|
# Sections with interesting user-facing overrides
|
|
interesting_paths = [
|
|
("agent", "max_turns"),
|
|
("agent", "gateway_timeout"),
|
|
("agent", "tool_use_enforcement"),
|
|
("terminal", "backend"),
|
|
("terminal", "docker_image"),
|
|
("terminal", "persistent_shell"),
|
|
("browser", "allow_private_urls"),
|
|
("compression", "enabled"),
|
|
("compression", "threshold"),
|
|
("display", "streaming"),
|
|
("display", "skin"),
|
|
("display", "show_reasoning"),
|
|
("privacy", "redact_pii"),
|
|
("tts", "provider"),
|
|
]
|
|
|
|
for section, key in interesting_paths:
|
|
default_section = DEFAULT_CONFIG.get(section, {})
|
|
user_section = config.get(section, {})
|
|
if not isinstance(default_section, dict) or not isinstance(user_section, dict):
|
|
continue
|
|
default_val = default_section.get(key)
|
|
user_val = user_section.get(key)
|
|
if user_val is not None and user_val != default_val:
|
|
overrides[f"{section}.{key}"] = str(user_val)
|
|
|
|
# Toolsets (if different from default)
|
|
default_toolsets = DEFAULT_CONFIG.get("toolsets", [])
|
|
user_toolsets = config.get("toolsets", [])
|
|
if user_toolsets != default_toolsets:
|
|
overrides["toolsets"] = str(user_toolsets)
|
|
|
|
# Fallback providers
|
|
fallbacks = config.get("fallback_providers", [])
|
|
if fallbacks:
|
|
overrides["fallback_providers"] = str(fallbacks)
|
|
|
|
return overrides
|
|
|
|
|
|
def run_dump(args):
|
|
"""Output a compact, copy-pasteable setup summary."""
|
|
show_keys = getattr(args, "show_keys", False)
|
|
|
|
# Load env from .env file so key checks work
|
|
env_path = get_env_path()
|
|
load_hermes_dotenv(
|
|
hermes_home=env_path.parent,
|
|
project_env=get_project_root() / ".env",
|
|
)
|
|
|
|
project_root = get_project_root()
|
|
hermes_home = get_hermes_home()
|
|
|
|
try:
|
|
from hermes_cli import __version__, __release_date__
|
|
except ImportError:
|
|
__version__ = "(unknown)"
|
|
__release_date__ = ""
|
|
|
|
commit = _get_git_commit(project_root)
|
|
|
|
try:
|
|
config = load_config()
|
|
except Exception:
|
|
config = {}
|
|
|
|
model, provider = _get_model_and_provider(config)
|
|
|
|
# Profile
|
|
try:
|
|
from hermes_cli.profiles import get_active_profile_name
|
|
profile = get_active_profile_name() or "(default)"
|
|
except Exception:
|
|
profile = "(default)"
|
|
|
|
# Terminal backend
|
|
terminal_cfg = config.get("terminal", {})
|
|
backend = terminal_cfg.get("backend", "local")
|
|
|
|
# OpenAI SDK version
|
|
try:
|
|
import openai
|
|
openai_ver = openai.__version__
|
|
except ImportError:
|
|
openai_ver = "not installed"
|
|
|
|
# OS info
|
|
os_info = f"{platform.system()} {platform.release()} {platform.machine()}"
|
|
|
|
lines = []
|
|
lines.append("--- hermes dump ---")
|
|
ver_str = f"{__version__}"
|
|
if __release_date__:
|
|
ver_str += f" ({__release_date__})"
|
|
ver_str += f" [{commit}]"
|
|
lines.append(f"version: {ver_str}")
|
|
lines.append(f"os: {os_info}")
|
|
lines.append(f"python: {sys.version.split()[0]}")
|
|
lines.append(f"openai_sdk: {openai_ver}")
|
|
lines.append(f"profile: {profile}")
|
|
lines.append(f"hermes_home: {display_hermes_home()}")
|
|
lines.append(f"model: {model}")
|
|
lines.append(f"provider: {provider}")
|
|
lines.append(f"terminal: {backend}")
|
|
|
|
# API keys
|
|
lines.append("")
|
|
lines.append("api_keys:")
|
|
api_keys = [
|
|
("OPENROUTER_API_KEY", "openrouter"),
|
|
("OPENAI_API_KEY", "openai"),
|
|
("ANTHROPIC_API_KEY", "anthropic"),
|
|
("ANTHROPIC_TOKEN", "anthropic_token"),
|
|
("NOUS_API_KEY", "nous"),
|
|
("GOOGLE_API_KEY", "google/gemini"),
|
|
("GEMINI_API_KEY", "gemini"),
|
|
("GLM_API_KEY", "glm/zai"),
|
|
("ZAI_API_KEY", "zai"),
|
|
("KIMI_API_KEY", "kimi"),
|
|
("MINIMAX_API_KEY", "minimax"),
|
|
("DEEPSEEK_API_KEY", "deepseek"),
|
|
("DASHSCOPE_API_KEY", "dashscope"),
|
|
("HF_TOKEN", "huggingface"),
|
|
("NVIDIA_API_KEY", "nvidia"),
|
|
("OPENCODE_ZEN_API_KEY", "opencode_zen"),
|
|
("OPENCODE_GO_API_KEY", "opencode_go"),
|
|
("KILOCODE_API_KEY", "kilocode"),
|
|
("FIRECRAWL_API_KEY", "firecrawl"),
|
|
("TAVILY_API_KEY", "tavily"),
|
|
("BROWSERBASE_API_KEY", "browserbase"),
|
|
("FAL_KEY", "fal"),
|
|
("ELEVENLABS_API_KEY", "elevenlabs"),
|
|
("GITHUB_TOKEN", "github"),
|
|
]
|
|
|
|
for env_var, label in api_keys:
|
|
val = os.getenv(env_var, "")
|
|
if show_keys and val:
|
|
display = _redact(val)
|
|
else:
|
|
display = "set" if val else "not set"
|
|
lines.append(f" {label:<20} {display}")
|
|
|
|
# Features summary
|
|
lines.append("")
|
|
lines.append("features:")
|
|
|
|
toolsets = config.get("toolsets", ["hermes-cli"])
|
|
lines.append(f" toolsets: {', '.join(toolsets) if toolsets else '(default)'}")
|
|
lines.append(f" mcp_servers: {_count_mcp_servers(config)}")
|
|
lines.append(f" memory_provider: {_memory_provider(config)}")
|
|
lines.append(f" gateway: {_gateway_status()}")
|
|
|
|
platforms = _configured_platforms()
|
|
lines.append(f" platforms: {', '.join(platforms) if platforms else 'none'}")
|
|
lines.append(f" cron_jobs: {_cron_summary(hermes_home)}")
|
|
lines.append(f" skills: {_count_skills(hermes_home)}")
|
|
|
|
# Config overrides (non-default values)
|
|
overrides = _config_overrides(config)
|
|
if overrides:
|
|
lines.append("")
|
|
lines.append("config_overrides:")
|
|
for key, val in overrides.items():
|
|
lines.append(f" {key}: {val}")
|
|
|
|
lines.append("--- end dump ---")
|
|
|
|
output = "\n".join(lines)
|
|
print(output)
|