diff --git a/hermes_cli/dump.py b/hermes_cli/dump.py new file mode 100644 index 0000000000..4ad32ca2c1 --- /dev/null +++ b/hermes_cli/dump.py @@ -0,0 +1,337 @@ +""" +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_constants import display_hermes_home + + +def _get_git_commit(project_root: Path) -> str: + """Return short git commit hash, or '(unknown)'.""" + 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: + return result.stdout.strip() + except Exception: + pass + return "(unknown)" + + +def _key_present(name: str) -> str: + """Return 'set' or 'not set' for an env var.""" + return "set" if os.getenv(name) else "not set" + + +def _redact(value: str) -> str: + """Redact all but first 4 and last 4 chars.""" + if not value: + return "" + if len(value) < 12: + return "***" + return value[:4] + "..." + value[-4:] + + +def _gateway_status() -> str: + """Return a short gateway status string.""" + if sys.platform.startswith("linux"): + try: + from hermes_cli.gateway import get_service_name + svc = get_service_name() + except Exception: + svc = "hermes-gateway" + try: + r = subprocess.run( + ["systemctl", "--user", "is-active", svc], + capture_output=True, text=True, timeout=5, + ) + return "running (systemd)" if r.stdout.strip() == "active" else "stopped" + except Exception: + return "unknown" + elif sys.platform == "darwin": + try: + from hermes_cli.gateway import get_launchd_label + r = subprocess.run( + ["launchctl", "list", get_launchd_label()], + capture_output=True, text=True, timeout=5, + ) + return "loaded (launchd)" if r.returncode == 0 else "not loaded" + except Exception: + return "unknown" + return "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"): + 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", + } + 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"), + ("smart_model_routing", "enabled"), + ("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 + from dotenv import load_dotenv + env_path = get_env_path() + if env_path.exists(): + try: + load_dotenv(env_path, encoding="utf-8") + except UnicodeDecodeError: + load_dotenv(env_path, encoding="latin-1") + # Also try project .env as dev fallback + load_dotenv(get_project_root() / ".env", override=False, encoding="utf-8") + + 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"), + ("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"), + ("AI_GATEWAY_API_KEY", "ai_gateway"), + ("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) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 96345c4850..a6d616e68b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2643,6 +2643,12 @@ def cmd_doctor(args): run_doctor(args) +def cmd_dump(args): + """Dump setup summary for support/debugging.""" + from hermes_cli.dump import run_dump + run_dump(args) + + def cmd_config(args): """Configuration management.""" from hermes_cli.config import config_command @@ -4724,6 +4730,22 @@ For more help on a command: help="Attempt to fix issues automatically" ) doctor_parser.set_defaults(func=cmd_doctor) + + # ========================================================================= + # dump command + # ========================================================================= + dump_parser = subparsers.add_parser( + "dump", + help="Dump setup summary for support/debugging", + description="Output a compact, plain-text summary of your Hermes setup " + "that can be copy-pasted into Discord/GitHub for support context" + ) + dump_parser.add_argument( + "--show-keys", + action="store_true", + help="Show redacted API key prefixes (first/last 4 chars) instead of just set/not set" + ) + dump_parser.set_defaults(func=cmd_dump) # ========================================================================= # config command diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 48ecbc4ca4..9be25e1007 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -102,7 +102,7 @@ _RESERVED_NAMES = frozenset({ # Hermes subcommands that cannot be used as profile names/aliases _HERMES_SUBCOMMANDS = frozenset({ "chat", "model", "gateway", "setup", "whatsapp", "login", "logout", - "status", "cron", "doctor", "config", "pairing", "skills", "tools", + "status", "cron", "doctor", "dump", "config", "pairing", "skills", "tools", "mcp", "sessions", "insights", "version", "update", "uninstall", "profile", "plugins", "honcho", "acp", }) @@ -1007,7 +1007,7 @@ _hermes_completion() { # Top-level subcommands if [[ "$COMP_CWORD" == 1 ]]; then - local commands="chat model gateway setup status cron doctor config skills tools mcp sessions profile update version" + local commands="chat model gateway setup status cron doctor dump config skills tools mcp sessions profile update version" COMPREPLY=($(compgen -W "$commands" -- "$cur")) fi } @@ -1032,7 +1032,7 @@ _hermes() { _arguments \\ '-p[Profile name]:profile:($profiles)' \\ '--profile[Profile name]:profile:($profiles)' \\ - '1:command:(chat model gateway setup status cron doctor config skills tools mcp sessions profile update version)' \\ + '1:command:(chat model gateway setup status cron doctor dump config skills tools mcp sessions profile update version)' \\ '*::arg:->args' case $words[1] in