/ 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)