/ hermes_cli / dump.py
dump.py
1 """ 2 Dump command for hermes CLI. 3 4 Outputs a compact, plain-text summary of the user's Hermes setup 5 that can be copy-pasted into Discord/GitHub/Telegram for support context. 6 No ANSI colors, no checkmarks — just data. 7 """ 8 9 import json 10 import os 11 import platform 12 import subprocess 13 import sys 14 from pathlib import Path 15 16 from hermes_cli.config import get_hermes_home, get_env_path, get_project_root, load_config 17 from hermes_constants import display_hermes_home 18 19 20 def _get_git_commit(project_root: Path) -> str: 21 """Return short git commit hash, or '(unknown)'.""" 22 try: 23 result = subprocess.run( 24 ["git", "rev-parse", "--short=8", "HEAD"], 25 capture_output=True, text=True, timeout=5, 26 cwd=str(project_root), 27 ) 28 if result.returncode == 0: 29 return result.stdout.strip() 30 except Exception: 31 pass 32 return "(unknown)" 33 34 35 def _redact(value: str) -> str: 36 """Redact all but first 4 and last 4 chars. 37 38 Thin wrapper over :func:`agent.redact.mask_secret`. Returns ``""`` for 39 an empty value (matches the historical behavior of this helper — 40 ``hermes dump`` formats empty values as blank, not as ``"(not set)"``). 41 """ 42 from agent.redact import mask_secret 43 return mask_secret(value) 44 45 46 def _gateway_status() -> str: 47 """Return a short gateway status string.""" 48 try: 49 from hermes_cli.gateway import get_gateway_runtime_snapshot 50 51 snapshot = get_gateway_runtime_snapshot() 52 if snapshot.running: 53 mode = snapshot.manager 54 if snapshot.has_process_service_mismatch: 55 mode = "manual" 56 return f"running ({mode}, pid {snapshot.gateway_pids[0]})" 57 if snapshot.service_installed and not snapshot.service_running: 58 return f"stopped ({snapshot.manager})" 59 return f"stopped ({snapshot.manager})" 60 except Exception: 61 return "unknown" if sys.platform.startswith(("linux", "darwin")) else "N/A" 62 63 64 def _count_skills(hermes_home: Path) -> int: 65 """Count installed skills.""" 66 skills_dir = hermes_home / "skills" 67 if not skills_dir.is_dir(): 68 return 0 69 count = 0 70 for item in skills_dir.rglob("SKILL.md"): 71 count += 1 72 return count 73 74 75 def _count_mcp_servers(config: dict) -> int: 76 """Count configured MCP servers.""" 77 mcp = config.get("mcp", {}) 78 servers = mcp.get("servers", {}) 79 return len(servers) 80 81 82 def _cron_summary(hermes_home: Path) -> str: 83 """Return cron jobs summary.""" 84 jobs_file = hermes_home / "cron" / "jobs.json" 85 if not jobs_file.exists(): 86 return "0" 87 try: 88 with open(jobs_file, encoding="utf-8") as f: 89 data = json.load(f) 90 jobs = data.get("jobs", []) 91 active = sum(1 for j in jobs if j.get("enabled", True)) 92 return f"{active} active / {len(jobs)} total" 93 except Exception: 94 return "(error reading)" 95 96 97 def _configured_platforms() -> list[str]: 98 """Return list of configured messaging platform names.""" 99 checks = { 100 "telegram": "TELEGRAM_BOT_TOKEN", 101 "discord": "DISCORD_BOT_TOKEN", 102 "slack": "SLACK_BOT_TOKEN", 103 "whatsapp": "WHATSAPP_ENABLED", 104 "signal": "SIGNAL_HTTP_URL", 105 "email": "EMAIL_ADDRESS", 106 "sms": "TWILIO_ACCOUNT_SID", 107 "matrix": "MATRIX_HOMESERVER_URL", 108 "mattermost": "MATTERMOST_URL", 109 "homeassistant": "HASS_TOKEN", 110 "dingtalk": "DINGTALK_CLIENT_ID", 111 "feishu": "FEISHU_APP_ID", 112 "wecom": "WECOM_BOT_ID", 113 "wecom_callback": "WECOM_CALLBACK_CORP_ID", 114 "weixin": "WEIXIN_ACCOUNT_ID", 115 "qqbot": "QQ_APP_ID", 116 } 117 return [name for name, env in checks.items() if os.getenv(env)] 118 119 120 def _memory_provider(config: dict) -> str: 121 """Return the active memory provider name.""" 122 mem = config.get("memory", {}) 123 provider = mem.get("provider", "") 124 return provider if provider else "built-in" 125 126 127 def _get_model_and_provider(config: dict) -> tuple[str, str]: 128 """Extract model and provider from config.""" 129 model_cfg = config.get("model", "") 130 if isinstance(model_cfg, dict): 131 model = model_cfg.get("default") or model_cfg.get("model") or model_cfg.get("name") or "(not set)" 132 provider = model_cfg.get("provider") or "(auto)" 133 elif isinstance(model_cfg, str): 134 model = model_cfg or "(not set)" 135 provider = "(auto)" 136 else: 137 model = "(not set)" 138 provider = "(auto)" 139 return model, provider 140 141 142 def _config_overrides(config: dict) -> dict[str, str]: 143 """Find non-default config values worth reporting. 144 145 Returns a flat dict of dotpath -> value for interesting overrides. 146 """ 147 from hermes_cli.config import DEFAULT_CONFIG 148 149 overrides = {} 150 151 # Sections with interesting user-facing overrides 152 interesting_paths = [ 153 ("agent", "max_turns"), 154 ("agent", "gateway_timeout"), 155 ("agent", "tool_use_enforcement"), 156 ("terminal", "backend"), 157 ("terminal", "docker_image"), 158 ("terminal", "persistent_shell"), 159 ("browser", "allow_private_urls"), 160 ("compression", "enabled"), 161 ("compression", "threshold"), 162 ("display", "streaming"), 163 ("display", "skin"), 164 ("display", "show_reasoning"), 165 ("privacy", "redact_pii"), 166 ("tts", "provider"), 167 ] 168 169 for section, key in interesting_paths: 170 default_section = DEFAULT_CONFIG.get(section, {}) 171 user_section = config.get(section, {}) 172 if not isinstance(default_section, dict) or not isinstance(user_section, dict): 173 continue 174 default_val = default_section.get(key) 175 user_val = user_section.get(key) 176 if user_val is not None and user_val != default_val: 177 overrides[f"{section}.{key}"] = str(user_val) 178 179 # Toolsets (if different from default) 180 default_toolsets = DEFAULT_CONFIG.get("toolsets", []) 181 user_toolsets = config.get("toolsets", []) 182 if user_toolsets != default_toolsets: 183 overrides["toolsets"] = str(user_toolsets) 184 185 # Fallback providers 186 fallbacks = config.get("fallback_providers", []) 187 if fallbacks: 188 overrides["fallback_providers"] = str(fallbacks) 189 190 return overrides 191 192 193 def run_dump(args): 194 """Output a compact, copy-pasteable setup summary.""" 195 show_keys = getattr(args, "show_keys", False) 196 197 # Load env from .env file so key checks work 198 from dotenv import load_dotenv 199 env_path = get_env_path() 200 if env_path.exists(): 201 try: 202 load_dotenv(env_path, encoding="utf-8") 203 except UnicodeDecodeError: 204 load_dotenv(env_path, encoding="latin-1") 205 # Also try project .env as dev fallback 206 load_dotenv(get_project_root() / ".env", override=False, encoding="utf-8") 207 208 project_root = get_project_root() 209 hermes_home = get_hermes_home() 210 211 try: 212 from hermes_cli import __version__, __release_date__ 213 except ImportError: 214 __version__ = "(unknown)" 215 __release_date__ = "" 216 217 commit = _get_git_commit(project_root) 218 219 try: 220 config = load_config() 221 except Exception: 222 config = {} 223 224 model, provider = _get_model_and_provider(config) 225 226 # Profile 227 try: 228 from hermes_cli.profiles import get_active_profile_name 229 profile = get_active_profile_name() or "(default)" 230 except Exception: 231 profile = "(default)" 232 233 # Terminal backend 234 terminal_cfg = config.get("terminal", {}) 235 backend = terminal_cfg.get("backend", "local") 236 237 # OpenAI SDK version 238 try: 239 import openai 240 openai_ver = openai.__version__ 241 except ImportError: 242 openai_ver = "not installed" 243 244 # OS info 245 os_info = f"{platform.system()} {platform.release()} {platform.machine()}" 246 247 lines = [] 248 lines.append("--- hermes dump ---") 249 ver_str = f"{__version__}" 250 if __release_date__: 251 ver_str += f" ({__release_date__})" 252 ver_str += f" [{commit}]" 253 lines.append(f"version: {ver_str}") 254 lines.append(f"os: {os_info}") 255 lines.append(f"python: {sys.version.split()[0]}") 256 lines.append(f"openai_sdk: {openai_ver}") 257 lines.append(f"profile: {profile}") 258 lines.append(f"hermes_home: {display_hermes_home()}") 259 lines.append(f"model: {model}") 260 lines.append(f"provider: {provider}") 261 lines.append(f"terminal: {backend}") 262 263 # API keys 264 lines.append("") 265 lines.append("api_keys:") 266 api_keys = [ 267 ("OPENROUTER_API_KEY", "openrouter"), 268 ("OPENAI_API_KEY", "openai"), 269 ("ANTHROPIC_API_KEY", "anthropic"), 270 ("ANTHROPIC_TOKEN", "anthropic_token"), 271 ("NOUS_API_KEY", "nous"), 272 ("GOOGLE_API_KEY", "google/gemini"), 273 ("GEMINI_API_KEY", "gemini"), 274 ("GLM_API_KEY", "glm/zai"), 275 ("ZAI_API_KEY", "zai"), 276 ("KIMI_API_KEY", "kimi"), 277 ("MINIMAX_API_KEY", "minimax"), 278 ("DEEPSEEK_API_KEY", "deepseek"), 279 ("DASHSCOPE_API_KEY", "dashscope"), 280 ("HF_TOKEN", "huggingface"), 281 ("NVIDIA_API_KEY", "nvidia"), 282 ("AI_GATEWAY_API_KEY", "ai_gateway"), 283 ("OPENCODE_ZEN_API_KEY", "opencode_zen"), 284 ("OPENCODE_GO_API_KEY", "opencode_go"), 285 ("KILOCODE_API_KEY", "kilocode"), 286 ("FIRECRAWL_API_KEY", "firecrawl"), 287 ("TAVILY_API_KEY", "tavily"), 288 ("BROWSERBASE_API_KEY", "browserbase"), 289 ("FAL_KEY", "fal"), 290 ("ELEVENLABS_API_KEY", "elevenlabs"), 291 ("GITHUB_TOKEN", "github"), 292 ] 293 294 for env_var, label in api_keys: 295 val = os.getenv(env_var, "") 296 if show_keys and val: 297 display = _redact(val) 298 else: 299 display = "set" if val else "not set" 300 lines.append(f" {label:<20} {display}") 301 302 # Features summary 303 lines.append("") 304 lines.append("features:") 305 306 toolsets = config.get("toolsets", ["hermes-cli"]) 307 lines.append(f" toolsets: {', '.join(toolsets) if toolsets else '(default)'}") 308 lines.append(f" mcp_servers: {_count_mcp_servers(config)}") 309 lines.append(f" memory_provider: {_memory_provider(config)}") 310 lines.append(f" gateway: {_gateway_status()}") 311 312 platforms = _configured_platforms() 313 lines.append(f" platforms: {', '.join(platforms) if platforms else 'none'}") 314 lines.append(f" cron_jobs: {_cron_summary(hermes_home)}") 315 lines.append(f" skills: {_count_skills(hermes_home)}") 316 317 # Config overrides (non-default values) 318 overrides = _config_overrides(config) 319 if overrides: 320 lines.append("") 321 lines.append("config_overrides:") 322 for key, val in overrides.items(): 323 lines.append(f" {key}: {val}") 324 325 lines.append("--- end dump ---") 326 327 output = "\n".join(lines) 328 print(output)