mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-25 11:23:51 +08:00
Compare commits
12 Commits
feat/gatew
...
skill/gith
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d814cd115 | ||
|
|
c52f6348b6 | ||
|
|
3162472674 | ||
|
|
8b9d22a74b | ||
|
|
fee0e0d35e | ||
|
|
81ac62c0e9 | ||
|
|
f53a5a7fe1 | ||
|
|
fdf55e0fe9 | ||
|
|
36f57dbc51 | ||
|
|
1871227198 | ||
|
|
eb2a49f95a | ||
|
|
73f970fa4d |
29
Dockerfile
29
Dockerfile
@@ -1,27 +1,44 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
|
||||
FROM tianon/gosu:1.19-trixie@sha256:3b176695959c71e123eb390d427efc665eeb561b1540e82679c15e992006b8b9 AS gosu_source
|
||||
FROM debian:13.4
|
||||
|
||||
# Disable Python stdout buffering to ensure logs are printed immediately
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Store Playwright browsers outside the volume mount so the build-time
|
||||
# install survives the /opt/data volume overlay at runtime.
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
|
||||
# Install system dependencies in one layer, clear APT cache
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential nodejs npm python3 python3-pip ripgrep ffmpeg gcc python3-dev libffi-dev procps && \
|
||||
build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
|
||||
RUN useradd -u 10000 -m -d /opt/data hermes
|
||||
|
||||
COPY --chmod=0755 --from=gosu_source /gosu /usr/local/bin/
|
||||
COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/
|
||||
|
||||
COPY . /opt/hermes
|
||||
WORKDIR /opt/hermes
|
||||
|
||||
# Install Python and Node dependencies in one layer, no cache
|
||||
RUN pip install --no-cache-dir uv --break-system-packages && \
|
||||
uv pip install --system --break-system-packages --no-cache -e ".[all]" && \
|
||||
npm install --prefer-offline --no-audit && \
|
||||
# Install Node dependencies and Playwright as root (--with-deps needs apt)
|
||||
RUN npm install --prefer-offline --no-audit && \
|
||||
npx playwright install --with-deps chromium --only-shell && \
|
||||
cd /opt/hermes/scripts/whatsapp-bridge && \
|
||||
npm install --prefer-offline --no-audit && \
|
||||
npm cache clean --force
|
||||
|
||||
WORKDIR /opt/hermes
|
||||
# Hand ownership to hermes user, then install Python deps in a virtualenv
|
||||
RUN chown -R hermes:hermes /opt/hermes
|
||||
USER hermes
|
||||
|
||||
RUN uv venv && \
|
||||
uv pip install --no-cache-dir -e ".[all]"
|
||||
|
||||
USER root
|
||||
RUN chmod +x /opt/hermes/docker/entrypoint.sh
|
||||
|
||||
ENV HERMES_HOME=/opt/data
|
||||
|
||||
@@ -1128,6 +1128,23 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
elif provider == "openai-codex":
|
||||
state = _load_provider_state(auth_store, "openai-codex")
|
||||
tokens = state.get("tokens") if isinstance(state, dict) else None
|
||||
# Fallback: import from Codex CLI (~/.codex/auth.json) if Hermes auth
|
||||
# store has no tokens. This mirrors resolve_codex_runtime_credentials()
|
||||
# so that load_pool() and list_authenticated_providers() detect tokens
|
||||
# that only exist in the Codex CLI shared file.
|
||||
if not (isinstance(tokens, dict) and tokens.get("access_token")):
|
||||
try:
|
||||
from hermes_cli.auth import _import_codex_cli_tokens, _save_codex_tokens
|
||||
cli_tokens = _import_codex_cli_tokens()
|
||||
if cli_tokens:
|
||||
logger.info("Importing Codex CLI tokens into Hermes auth store.")
|
||||
_save_codex_tokens(cli_tokens)
|
||||
# Re-read state after import
|
||||
auth_store = _load_auth_store()
|
||||
state = _load_provider_state(auth_store, "openai-codex")
|
||||
tokens = state.get("tokens") if isinstance(state, dict) else None
|
||||
except Exception as exc:
|
||||
logger.debug("Codex CLI token import failed: %s", exc)
|
||||
if isinstance(tokens, dict) and tokens.get("access_token"):
|
||||
active_sources.add("device_code")
|
||||
changed |= _upsert_entry(
|
||||
|
||||
50
cli.py
50
cli.py
@@ -5244,9 +5244,33 @@ class HermesCLI:
|
||||
context_length=ctx_len,
|
||||
)
|
||||
_cprint(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n")
|
||||
# Show a random tip on new session
|
||||
try:
|
||||
from hermes_cli.tips import get_random_tip
|
||||
_tip = get_random_tip()
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
_tip_color = get_active_skin().get_color("banner_dim", "#B8860B")
|
||||
except Exception:
|
||||
_tip_color = "#B8860B"
|
||||
cc.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
self.show_banner()
|
||||
print(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n")
|
||||
# Show a random tip on new session
|
||||
try:
|
||||
from hermes_cli.tips import get_random_tip
|
||||
_tip = get_random_tip()
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
_tip_color = get_active_skin().get_color("banner_dim", "#B8860B")
|
||||
except Exception:
|
||||
_tip_color = "#B8860B"
|
||||
self.console.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
|
||||
except Exception:
|
||||
pass
|
||||
elif canonical == "history":
|
||||
self.show_history()
|
||||
elif canonical == "title":
|
||||
@@ -8075,6 +8099,17 @@ class HermesCLI:
|
||||
_welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands."
|
||||
_welcome_color = "#FFF8DC"
|
||||
self.console.print(f"[{_welcome_color}]{_welcome_text}[/]")
|
||||
# Show a random tip to help users discover features
|
||||
try:
|
||||
from hermes_cli.tips import get_random_tip
|
||||
_tip = get_random_tip()
|
||||
try:
|
||||
_tip_color = _welcome_skin.get_color("banner_dim", "#B8860B")
|
||||
except Exception:
|
||||
_tip_color = "#B8860B"
|
||||
self.console.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
|
||||
except Exception:
|
||||
pass # Tips are non-critical — never break startup
|
||||
if self.preloaded_skills and not self._startup_skills_line_shown:
|
||||
skills_label = ", ".join(self.preloaded_skills)
|
||||
self.console.print(
|
||||
@@ -9350,9 +9385,14 @@ class HermesCLI:
|
||||
from tools.process_registry import process_registry
|
||||
if not process_registry.completion_queue.empty():
|
||||
evt = process_registry.completion_queue.get_nowait()
|
||||
_synth = _format_process_notification(evt)
|
||||
if _synth:
|
||||
self._pending_input.put(_synth)
|
||||
# Skip if the agent already consumed this via wait/poll/log
|
||||
_evt_sid = evt.get("session_id", "")
|
||||
if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid):
|
||||
pass # already delivered via tool result
|
||||
else:
|
||||
_synth = _format_process_notification(evt)
|
||||
if _synth:
|
||||
self._pending_input.put(_synth)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
@@ -9478,6 +9518,10 @@ class HermesCLI:
|
||||
from tools.process_registry import process_registry
|
||||
while not process_registry.completion_queue.empty():
|
||||
evt = process_registry.completion_queue.get_nowait()
|
||||
# Skip if the agent already consumed this via wait/poll/log
|
||||
_evt_sid = evt.get("session_id", "")
|
||||
if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid):
|
||||
continue # already delivered via tool result
|
||||
_synth = _format_process_notification(evt)
|
||||
if _synth:
|
||||
self._pending_input.put(_synth)
|
||||
|
||||
@@ -5,6 +5,33 @@ set -e
|
||||
HERMES_HOME="/opt/data"
|
||||
INSTALL_DIR="/opt/hermes"
|
||||
|
||||
# --- Privilege dropping via gosu ---
|
||||
# When started as root (the default), optionally remap the hermes user/group
|
||||
# to match host-side ownership, fix volume permissions, then re-exec as hermes.
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
if [ -n "$HERMES_UID" ] && [ "$HERMES_UID" != "$(id -u hermes)" ]; then
|
||||
echo "Changing hermes UID to $HERMES_UID"
|
||||
usermod -u "$HERMES_UID" hermes
|
||||
fi
|
||||
|
||||
if [ -n "$HERMES_GID" ] && [ "$HERMES_GID" != "$(id -g hermes)" ]; then
|
||||
echo "Changing hermes GID to $HERMES_GID"
|
||||
groupmod -g "$HERMES_GID" hermes
|
||||
fi
|
||||
|
||||
actual_hermes_uid=$(id -u hermes)
|
||||
if [ "$(stat -c %u "$HERMES_HOME" 2>/dev/null)" != "$actual_hermes_uid" ]; then
|
||||
echo "$HERMES_HOME is not owned by $actual_hermes_uid, fixing"
|
||||
chown -R hermes:hermes "$HERMES_HOME"
|
||||
fi
|
||||
|
||||
echo "Dropping root privileges"
|
||||
exec gosu hermes "$0" "$@"
|
||||
fi
|
||||
|
||||
# --- Running as hermes from here ---
|
||||
source "${INSTALL_DIR}/.venv/bin/activate"
|
||||
|
||||
# Create essential directory structure. Cache and platform directories
|
||||
# (cache/images, cache/audio, platforms/whatsapp, etc.) are created on
|
||||
# demand by the application — don't pre-create them here so new installs
|
||||
|
||||
@@ -118,7 +118,7 @@ For executed migrations, the full report is saved to `~/.hermes/migration/opencl
|
||||
## Troubleshooting
|
||||
|
||||
### "OpenClaw directory not found"
|
||||
The migration looks for `~/.openclaw` by default, then tries `~/.clawdbot` and `~/.moldbot`. If your OpenClaw is installed elsewhere, use `--source`:
|
||||
The migration looks for `~/.openclaw` by default, then tries `~/.clawdbot` and `~/.moltbot`. If your OpenClaw is installed elsewhere, use `--source`:
|
||||
```bash
|
||||
hermes claw migrate --source /path/to/.openclaw
|
||||
```
|
||||
|
||||
@@ -734,6 +734,42 @@ def _split_delivery_units_for_weixin(content: str) -> List[str]:
|
||||
return [unit for unit in units if unit]
|
||||
|
||||
|
||||
def _looks_like_chatty_line_for_weixin(line: str) -> bool:
|
||||
"""Return True when a line looks like a standalone chat utterance."""
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
return False
|
||||
if len(stripped) > 48:
|
||||
return False
|
||||
if line.startswith((" ", "\t")):
|
||||
return False
|
||||
if stripped.startswith((">", "-", "*", "【")):
|
||||
return False
|
||||
if re.match(r"^\*\*[^*]+\*\*$", stripped):
|
||||
return False
|
||||
if re.match(r"^\d+\.\s", stripped):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _looks_like_heading_line_for_weixin(line: str) -> bool:
|
||||
"""Return True when a short line behaves like a plain-text heading."""
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
return False
|
||||
return len(stripped) <= 24 and stripped.endswith((":", ":"))
|
||||
|
||||
|
||||
def _should_split_short_chat_block_for_weixin(block: str) -> bool:
|
||||
"""Split only chat-like multiline blocks into separate bubbles."""
|
||||
lines = [line for line in block.splitlines() if line.strip()]
|
||||
if not 2 <= len(lines) <= 6:
|
||||
return False
|
||||
if _looks_like_heading_line_for_weixin(lines[0]):
|
||||
return False
|
||||
return all(_looks_like_chatty_line_for_weixin(line) for line in lines)
|
||||
|
||||
|
||||
def _pack_markdown_blocks_for_weixin(content: str, max_length: int) -> List[str]:
|
||||
if len(content) <= max_length:
|
||||
return [content]
|
||||
@@ -787,9 +823,15 @@ def _split_text_for_weixin_delivery(
|
||||
chunks.extend(_pack_markdown_blocks_for_weixin(unit, max_length))
|
||||
return chunks or [content]
|
||||
|
||||
# Compact (default): single message when under the limit.
|
||||
# Compact (default): single message when under the limit — unless the
|
||||
# content looks like a short chatty exchange, in which case split into
|
||||
# separate bubbles for a more natural chat feel.
|
||||
if len(content) <= max_length:
|
||||
return [content]
|
||||
return (
|
||||
_split_delivery_units_for_weixin(content)
|
||||
if _should_split_short_chat_block_for_weixin(content)
|
||||
else [content]
|
||||
)
|
||||
return _pack_markdown_blocks_for_weixin(content, max_length) or [content]
|
||||
|
||||
|
||||
|
||||
225
gateway/run.py
225
gateway/run.py
@@ -631,18 +631,7 @@ class GatewayRunner:
|
||||
# Track background tasks to prevent garbage collection mid-execution
|
||||
self._background_tasks: set = set()
|
||||
|
||||
# MCP config watcher state — detect header changes (e.g. OAuth token refresh)
|
||||
self._mcp_config_mtime: float = 0.0
|
||||
self._mcp_config_servers: dict = {}
|
||||
try:
|
||||
from hermes_cli.config import get_config_path, load_config
|
||||
cfg_path = get_config_path()
|
||||
if cfg_path.exists():
|
||||
self._mcp_config_mtime = cfg_path.stat().st_mtime
|
||||
cfg = load_config()
|
||||
self._mcp_config_servers = cfg.get("mcp_servers") or {}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
# -- Setup skill availability ----------------------------------------
|
||||
@@ -1701,111 +1690,10 @@ class GatewayRunner:
|
||||
)
|
||||
asyncio.create_task(self._platform_reconnect_watcher())
|
||||
|
||||
# Start background MCP config watcher for auto-reloading on token refresh
|
||||
asyncio.create_task(self._mcp_config_watcher())
|
||||
|
||||
logger.info("Press Ctrl+C to stop")
|
||||
|
||||
return True
|
||||
|
||||
async def _mcp_config_watcher(self, interval: int = 30, _initial_delay: int = 30) -> None:
|
||||
"""Background task that detects MCP config changes and auto-reloads connections.
|
||||
|
||||
Polls config.yaml every ``interval`` seconds. When the ``mcp_servers``
|
||||
section changes (e.g. OAuth token refresh updates the Authorization
|
||||
header), triggers a full MCP shutdown + reconnect so the running
|
||||
gateway picks up new credentials without a restart.
|
||||
|
||||
Mirrors the CLI's ``_check_config_mcp_changes`` but adapted for the
|
||||
async gateway event loop.
|
||||
"""
|
||||
# Initial delay — let startup finish. Sleep in 1s increments for quick shutdown.
|
||||
for _ in range(_initial_delay):
|
||||
if not self._running:
|
||||
return
|
||||
await asyncio.sleep(1)
|
||||
logger.info("MCP config watcher started (checking every %ds)", interval)
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
from hermes_cli.config import get_config_path
|
||||
import yaml as _yaml
|
||||
|
||||
cfg_path = get_config_path()
|
||||
if not cfg_path.exists():
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
|
||||
try:
|
||||
mtime = cfg_path.stat().st_mtime
|
||||
except OSError:
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
|
||||
if mtime == self._mcp_config_mtime:
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
|
||||
# File changed — read and compare mcp_servers section
|
||||
self._mcp_config_mtime = mtime
|
||||
try:
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
new_cfg = _yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
|
||||
new_mcp = new_cfg.get("mcp_servers") or {}
|
||||
if new_mcp == self._mcp_config_servers:
|
||||
# Some other config section changed, not MCP
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
|
||||
self._mcp_config_servers = new_mcp
|
||||
logger.info("MCP config change detected — reloading connections...")
|
||||
|
||||
# Perform the reload in a thread to avoid blocking the event loop
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _servers, _lock
|
||||
|
||||
with _lock:
|
||||
old_servers = set(_servers.keys())
|
||||
|
||||
await loop.run_in_executor(None, shutdown_mcp_servers)
|
||||
new_tools = await loop.run_in_executor(None, discover_mcp_tools)
|
||||
|
||||
with _lock:
|
||||
connected_servers = set(_servers.keys())
|
||||
|
||||
added = connected_servers - old_servers
|
||||
removed = old_servers - connected_servers
|
||||
reconnected = connected_servers & old_servers
|
||||
|
||||
parts = []
|
||||
if reconnected:
|
||||
parts.append(f"♻️ Reconnected: {', '.join(sorted(reconnected))}")
|
||||
if added:
|
||||
parts.append(f"➕ Added: {', '.join(sorted(added))}")
|
||||
if removed:
|
||||
parts.append(f"➖ Removed: {', '.join(sorted(removed))}")
|
||||
parts.append(
|
||||
f"🔧 {len(new_tools)} tool(s) from {len(connected_servers)} server(s)"
|
||||
)
|
||||
logger.info("MCP auto-reload complete: %s", "; ".join(parts))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("MCP auto-reload failed: %s", e)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("MCP config watcher error: %s", e)
|
||||
|
||||
# Sleep in 1-second increments so we respond quickly to shutdown
|
||||
for _ in range(interval):
|
||||
if not self._running:
|
||||
return
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def _session_expiry_watcher(self, interval: int = 300):
|
||||
"""Background task that proactively flushes memories for expired sessions.
|
||||
|
||||
@@ -4077,9 +3965,16 @@ class GatewayRunner:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Append a random tip to the reset message
|
||||
try:
|
||||
from hermes_cli.tips import get_random_tip
|
||||
_tip_line = f"\n✦ Tip: {get_random_tip()}"
|
||||
except Exception:
|
||||
_tip_line = ""
|
||||
|
||||
if session_info:
|
||||
return f"{header}\n\n{session_info}"
|
||||
return header
|
||||
return f"{header}\n\n{session_info}{_tip_line}"
|
||||
return f"{header}{_tip_line}"
|
||||
|
||||
async def _handle_profile_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /profile — show active profile name and home directory."""
|
||||
@@ -7114,7 +7009,9 @@ class GatewayRunner:
|
||||
|
||||
if session.exited:
|
||||
# --- Agent-triggered completion: inject synthetic message ---
|
||||
if agent_notify:
|
||||
# Skip if the agent already consumed the result via wait/poll/log
|
||||
from tools.process_registry import process_registry as _pr_check
|
||||
if agent_notify and not _pr_check.is_completion_consumed(session_id):
|
||||
from tools.ansi_strip import strip_ansi
|
||||
_out = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else ""
|
||||
synth_text = (
|
||||
@@ -8122,26 +8019,43 @@ class GatewayRunner:
|
||||
|
||||
tracking_task = asyncio.create_task(track_agent())
|
||||
|
||||
# Monitor for interrupts from the adapter (new messages arriving)
|
||||
# Monitor for interrupts from the adapter (new messages arriving).
|
||||
# This is the PRIMARY interrupt path for regular text messages —
|
||||
# Level 1 (base.py) catches them before _handle_message() is reached,
|
||||
# so the Level 2 running_agent.interrupt() path never fires.
|
||||
# The inactivity poll loop below has a BACKUP check in case this
|
||||
# task dies (no error handling = silent death = lost interrupts).
|
||||
_interrupt_detected = asyncio.Event() # shared with backup check
|
||||
|
||||
async def monitor_for_interrupt():
|
||||
adapter = self.adapters.get(source.platform)
|
||||
if not adapter or not session_key:
|
||||
if not session_key:
|
||||
return
|
||||
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(0.2) # Check every 200ms
|
||||
# Check if adapter has a pending interrupt for this session.
|
||||
# Must use session_key (build_session_key output) — NOT
|
||||
# source.chat_id — because the adapter stores interrupt events
|
||||
# under the full session key.
|
||||
if hasattr(adapter, 'has_pending_interrupt') and adapter.has_pending_interrupt(session_key):
|
||||
agent = agent_holder[0]
|
||||
if agent:
|
||||
pending_event = adapter.get_pending_message(session_key)
|
||||
pending_text = pending_event.text if pending_event else None
|
||||
logger.debug("Interrupt detected from adapter, signaling agent...")
|
||||
agent.interrupt(pending_text)
|
||||
break
|
||||
try:
|
||||
# Re-resolve adapter each iteration so reconnects don't
|
||||
# leave us holding a stale reference.
|
||||
_adapter = self.adapters.get(source.platform)
|
||||
if not _adapter:
|
||||
continue
|
||||
# Check if adapter has a pending interrupt for this session.
|
||||
# Must use session_key (build_session_key output) — NOT
|
||||
# source.chat_id — because the adapter stores interrupt events
|
||||
# under the full session key.
|
||||
if hasattr(_adapter, 'has_pending_interrupt') and _adapter.has_pending_interrupt(session_key):
|
||||
agent = agent_holder[0]
|
||||
if agent:
|
||||
pending_event = _adapter.get_pending_message(session_key)
|
||||
pending_text = pending_event.text if pending_event else None
|
||||
logger.debug("Interrupt detected from adapter, signaling agent...")
|
||||
agent.interrupt(pending_text)
|
||||
_interrupt_detected.set()
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as _mon_err:
|
||||
logger.debug("monitor_for_interrupt error (will retry): %s", _mon_err)
|
||||
|
||||
interrupt_monitor = asyncio.create_task(monitor_for_interrupt())
|
||||
|
||||
@@ -8206,8 +8120,34 @@ class GatewayRunner:
|
||||
_POLL_INTERVAL = 5.0
|
||||
|
||||
if _agent_timeout is None:
|
||||
# Unlimited — just await the result.
|
||||
response = await _executor_task
|
||||
# Unlimited — still poll periodically for backup interrupt
|
||||
# detection in case monitor_for_interrupt() silently died.
|
||||
response = None
|
||||
while True:
|
||||
done, _ = await asyncio.wait(
|
||||
{_executor_task}, timeout=_POLL_INTERVAL
|
||||
)
|
||||
if done:
|
||||
response = _executor_task.result()
|
||||
break
|
||||
# Backup interrupt check: if the monitor task died or
|
||||
# missed the interrupt, catch it here.
|
||||
if not _interrupt_detected.is_set() and session_key:
|
||||
_backup_adapter = self.adapters.get(source.platform)
|
||||
_backup_agent = agent_holder[0]
|
||||
if (_backup_adapter and _backup_agent
|
||||
and hasattr(_backup_adapter, 'has_pending_interrupt')
|
||||
and _backup_adapter.has_pending_interrupt(session_key)):
|
||||
_bp_event = _backup_adapter.get_pending_message(session_key)
|
||||
_bp_text = _bp_event.text if _bp_event else None
|
||||
logger.info(
|
||||
"Backup interrupt detected for session %s "
|
||||
"(monitor task state: %s)",
|
||||
session_key[:20],
|
||||
"done" if interrupt_monitor.done() else "running",
|
||||
)
|
||||
_backup_agent.interrupt(_bp_text)
|
||||
_interrupt_detected.set()
|
||||
else:
|
||||
# Poll loop: check the agent's built-in activity tracker
|
||||
# (updated by _touch_activity() on every tool call, API
|
||||
@@ -8251,6 +8191,23 @@ class GatewayRunner:
|
||||
if _idle_secs >= _agent_timeout:
|
||||
_inactivity_timeout = True
|
||||
break
|
||||
# Backup interrupt check (same as unlimited path).
|
||||
if not _interrupt_detected.is_set() and session_key:
|
||||
_backup_adapter = self.adapters.get(source.platform)
|
||||
_backup_agent = agent_holder[0]
|
||||
if (_backup_adapter and _backup_agent
|
||||
and hasattr(_backup_adapter, 'has_pending_interrupt')
|
||||
and _backup_adapter.has_pending_interrupt(session_key)):
|
||||
_bp_event = _backup_adapter.get_pending_message(session_key)
|
||||
_bp_text = _bp_event.text if _bp_event else None
|
||||
logger.info(
|
||||
"Backup interrupt detected for session %s "
|
||||
"(monitor task state: %s)",
|
||||
session_key[:20],
|
||||
"done" if interrupt_monitor.done() else "running",
|
||||
)
|
||||
_backup_agent.interrupt(_bp_text)
|
||||
_interrupt_detected.set()
|
||||
|
||||
if _inactivity_timeout:
|
||||
# Build a diagnostic summary from the agent's activity tracker.
|
||||
|
||||
@@ -50,7 +50,7 @@ _OPENCLAW_SCRIPT_INSTALLED = (
|
||||
)
|
||||
|
||||
# Known OpenClaw directory names (current + legacy)
|
||||
_OPENCLAW_DIR_NAMES = (".openclaw", ".clawdbot", ".moldbot")
|
||||
_OPENCLAW_DIR_NAMES = (".openclaw", ".clawdbot", ".moltbot")
|
||||
|
||||
def _warn_if_gateway_running(auto_yes: bool) -> None:
|
||||
"""Check if a Hermes gateway is running with connected platforms.
|
||||
@@ -87,8 +87,8 @@ def _warn_if_gateway_running(auto_yes: bool) -> None:
|
||||
print_info("Migration cancelled. Stop the gateway and try again.")
|
||||
sys.exit(0)
|
||||
|
||||
# State files commonly found in OpenClaw workspace directories that cause
|
||||
# confusion after migration (the agent discovers them and writes to them)
|
||||
# State files commonly found in OpenClaw workspace directories — listed
|
||||
# during cleanup to help the user decide whether to archive
|
||||
_WORKSPACE_STATE_GLOBS = (
|
||||
"*/todo.json",
|
||||
"*/sessions/*",
|
||||
@@ -133,7 +133,7 @@ def _find_openclaw_dirs() -> list[Path]:
|
||||
|
||||
|
||||
def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]:
|
||||
"""Scan an OpenClaw directory for workspace state files that cause confusion.
|
||||
"""Scan an OpenClaw directory for workspace state files.
|
||||
|
||||
Returns a list of (path, description) tuples.
|
||||
"""
|
||||
@@ -216,7 +216,7 @@ def _cmd_migrate(args):
|
||||
source_dir = Path.home() / ".openclaw"
|
||||
if not source_dir.is_dir():
|
||||
# Try legacy directory names
|
||||
for legacy in (".clawdbot", ".moldbot"):
|
||||
for legacy in (".clawdbot", ".moltbot"):
|
||||
candidate = Path.home() / legacy
|
||||
if candidate.is_dir():
|
||||
source_dir = candidate
|
||||
@@ -384,65 +384,16 @@ def _cmd_migrate(args):
|
||||
# Print results
|
||||
_print_migration_report(report, dry_run=False)
|
||||
|
||||
# After successful migration, offer to archive the source directory
|
||||
if report.get("summary", {}).get("migrated", 0) > 0:
|
||||
_offer_source_archival(source_dir, auto_yes)
|
||||
|
||||
|
||||
def _offer_source_archival(source_dir: Path, auto_yes: bool = False):
|
||||
"""After migration, offer to rename the source directory to prevent state fragmentation.
|
||||
|
||||
OpenClaw workspace directories contain state files (todo.json, sessions, etc.)
|
||||
that the agent may discover and write to, causing confusion. Renaming the
|
||||
directory prevents this.
|
||||
"""
|
||||
if not source_dir.is_dir():
|
||||
return
|
||||
|
||||
# Scan for state files that could cause problems
|
||||
state_files = _scan_workspace_state(source_dir)
|
||||
|
||||
print()
|
||||
print_header("Post-Migration Cleanup")
|
||||
print_info("The OpenClaw directory still exists and contains workspace state files")
|
||||
print_info("that can confuse the agent (todo lists, sessions, logs).")
|
||||
if state_files:
|
||||
print()
|
||||
print(color(" Found state files:", Colors.YELLOW))
|
||||
# Show up to 10 most relevant findings
|
||||
for path, desc in state_files[:10]:
|
||||
print(f" {desc}")
|
||||
if len(state_files) > 10:
|
||||
print(f" ... and {len(state_files) - 10} more")
|
||||
print()
|
||||
print_info(f"Recommend: rename {source_dir.name}/ to {source_dir.name}.pre-migration/")
|
||||
print_info("This prevents the agent from discovering old workspace directories.")
|
||||
print_info("You can always rename it back if needed.")
|
||||
print()
|
||||
|
||||
if not auto_yes and not sys.stdin.isatty():
|
||||
print_info("Non-interactive session — skipping archival.")
|
||||
print_info("Run later with: hermes claw cleanup")
|
||||
return
|
||||
|
||||
if auto_yes or prompt_yes_no(f"Archive {source_dir} now?", default=True):
|
||||
try:
|
||||
archive_path = _archive_directory(source_dir)
|
||||
print_success(f"Archived: {source_dir} → {archive_path}")
|
||||
print_info("The original directory has been renamed, not deleted.")
|
||||
print_info(f"To undo: mv {archive_path} {source_dir}")
|
||||
except OSError as e:
|
||||
print_error(f"Could not archive: {e}")
|
||||
print_info(f"You can do it manually: mv {source_dir} {source_dir}.pre-migration")
|
||||
else:
|
||||
print_info("Skipped. You can archive later with: hermes claw cleanup")
|
||||
# Source directory is left untouched — archiving is not the migration
|
||||
# tool's responsibility. Users who want to clean up can run
|
||||
# 'hermes claw cleanup' separately.
|
||||
|
||||
|
||||
def _cmd_cleanup(args):
|
||||
"""Archive leftover OpenClaw directories after migration.
|
||||
|
||||
Scans for OpenClaw directories that still exist after migration and offers
|
||||
to rename them to .pre-migration to prevent state fragmentation.
|
||||
to rename them to .pre-migration to free disk space.
|
||||
"""
|
||||
dry_run = getattr(args, "dry_run", False)
|
||||
auto_yes = getattr(args, "yes", False)
|
||||
@@ -517,7 +468,7 @@ def _cmd_cleanup(args):
|
||||
|
||||
if state_files:
|
||||
print()
|
||||
print(color(f" {len(state_files)} state file(s) that could cause confusion:", Colors.YELLOW))
|
||||
print(color(f" {len(state_files)} state file(s) found:", Colors.YELLOW))
|
||||
for path, desc in state_files[:8]:
|
||||
print(f" {desc}")
|
||||
if len(state_files) > 8:
|
||||
|
||||
@@ -839,8 +839,11 @@ def list_authenticated_providers(
|
||||
if any(os.environ.get(ev) for ev in pcfg.api_key_env_vars):
|
||||
has_creds = True
|
||||
break
|
||||
if not has_creds and overlay.auth_type in ("oauth_device_code", "oauth_external", "external_process"):
|
||||
# These use auth stores, not env vars — check for auth.json entries
|
||||
# Check auth store and credential pool for non-env-var credentials.
|
||||
# This applies to OAuth providers AND api_key providers that also
|
||||
# support OAuth (e.g. anthropic supports both API key and Claude Code
|
||||
# OAuth via external credential files).
|
||||
if not has_creds:
|
||||
try:
|
||||
from hermes_cli.auth import _load_auth_store
|
||||
store = _load_auth_store()
|
||||
@@ -853,6 +856,38 @@ def list_authenticated_providers(
|
||||
has_creds = True
|
||||
except Exception as exc:
|
||||
logger.debug("Auth store check failed for %s: %s", pid, exc)
|
||||
# Fallback: check the credential pool with full auto-seeding.
|
||||
# This catches credentials that exist in external stores (e.g.
|
||||
# Codex CLI ~/.codex/auth.json) which _seed_from_singletons()
|
||||
# imports on demand but aren't in the raw auth.json yet.
|
||||
if not has_creds:
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
pool = load_pool(hermes_slug)
|
||||
if pool.has_credentials():
|
||||
has_creds = True
|
||||
except Exception as exc:
|
||||
logger.debug("Credential pool check failed for %s: %s", hermes_slug, exc)
|
||||
# Fallback: check external credential files directly.
|
||||
# The credential pool gates anthropic behind
|
||||
# is_provider_explicitly_configured() to prevent auxiliary tasks
|
||||
# from silently consuming Claude Code tokens (PR #4210).
|
||||
# But the /model picker is discovery-oriented — we WANT to show
|
||||
# providers the user can switch to, even if they aren't currently
|
||||
# configured.
|
||||
if not has_creds and hermes_slug == "anthropic":
|
||||
try:
|
||||
from agent.anthropic_adapter import (
|
||||
read_claude_code_credentials,
|
||||
read_hermes_oauth_credentials,
|
||||
)
|
||||
hermes_creds = read_hermes_oauth_credentials()
|
||||
cc_creds = read_claude_code_credentials()
|
||||
if (hermes_creds and hermes_creds.get("accessToken")) or \
|
||||
(cc_creds and cc_creds.get("accessToken")):
|
||||
has_creds = True
|
||||
except Exception as exc:
|
||||
logger.debug("Anthropic external creds check failed: %s", exc)
|
||||
if not has_creds:
|
||||
continue
|
||||
|
||||
|
||||
351
hermes_cli/tips.py
Normal file
351
hermes_cli/tips.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""Random tips shown at CLI session start to help users discover features."""
|
||||
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tip corpus — one-liners covering slash commands, CLI flags, config,
|
||||
# keybindings, tools, gateway, skills, profiles, and workflow tricks.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TIPS = [
|
||||
# --- Slash Commands ---
|
||||
"/btw <question> asks a quick side question without tools or history — great for clarifications.",
|
||||
"/background <prompt> runs a task in a separate session while your current one stays free.",
|
||||
"/branch forks the current session so you can explore a different direction without losing progress.",
|
||||
"/compress manually compresses conversation context when things get long.",
|
||||
"/rollback lists filesystem checkpoints — restore files the agent modified to any prior state.",
|
||||
"/rollback diff 2 previews what changed since checkpoint 2 without restoring anything.",
|
||||
"/rollback 2 src/file.py restores a single file from a specific checkpoint.",
|
||||
"/title \"my project\" names your session — resume it later with /resume or hermes -c.",
|
||||
"/resume picks up where you left off in a previously named session.",
|
||||
"/queue <prompt> queues a message for the next turn without interrupting the current one.",
|
||||
"/undo removes the last user/assistant exchange from the conversation.",
|
||||
"/retry resends your last message — useful when the agent's response wasn't quite right.",
|
||||
"/verbose cycles tool progress display: off → new → all → verbose.",
|
||||
"/reasoning high increases the model's thinking depth. /reasoning show displays the reasoning.",
|
||||
"/fast toggles priority processing for faster API responses (provider-dependent).",
|
||||
"/yolo skips all dangerous command approval prompts for the rest of the session.",
|
||||
"/model lets you switch models mid-session — try /model sonnet or /model gpt-5.",
|
||||
"/model --global changes your default model permanently.",
|
||||
"/personality pirate sets a fun personality — 14 built-in options from kawaii to shakespeare.",
|
||||
"/skin changes the CLI theme — try ares, mono, slate, poseidon, or charizard.",
|
||||
"/statusbar toggles a persistent bar showing model, tokens, context fill %, cost, and duration.",
|
||||
"/tools disable browser temporarily removes browser tools for the current session.",
|
||||
"/browser connect attaches browser tools to your running Chrome instance via CDP.",
|
||||
"/plugins lists installed plugins and their status.",
|
||||
"/cron manages scheduled tasks — set up recurring prompts with delivery to any platform.",
|
||||
"/reload-mcp hot-reloads MCP server configuration without restarting.",
|
||||
"/usage shows token usage, cost breakdown, and session duration.",
|
||||
"/insights shows usage analytics for the last 30 days.",
|
||||
"/paste checks your clipboard for an image and attaches it to your next message.",
|
||||
"/profile shows which profile is active and its home directory.",
|
||||
"/config shows your current configuration at a glance.",
|
||||
"/stop kills all running background processes spawned by the agent.",
|
||||
|
||||
# --- @ Context References ---
|
||||
"@file:path/to/file.py injects file contents directly into your message.",
|
||||
"@file:main.py:10-50 injects only lines 10-50 of a file.",
|
||||
"@folder:src/ injects a directory tree listing.",
|
||||
"@diff injects your unstaged git changes into the message.",
|
||||
"@staged injects your staged git changes (git diff --staged).",
|
||||
"@git:5 injects the last 5 commits with full patches.",
|
||||
"@url:https://example.com fetches and injects a web page's content.",
|
||||
"Typing @ triggers filesystem path completion — navigate to any file interactively.",
|
||||
"Combine multiple references: \"Review @file:main.py and @file:test.py for consistency.\"",
|
||||
|
||||
# --- Keybindings ---
|
||||
"Alt+Enter (or Ctrl+J) inserts a newline for multi-line input.",
|
||||
"Ctrl+C interrupts the agent. Double-press within 2 seconds to force exit.",
|
||||
"Ctrl+Z suspends Hermes to the background — run fg in your shell to resume.",
|
||||
"Tab accepts auto-suggestion ghost text or autocompletes slash commands.",
|
||||
"Type a new message while the agent is working to interrupt and redirect it.",
|
||||
"Alt+V pastes an image from your clipboard into the conversation.",
|
||||
"Pasting 5+ lines auto-saves to a file and inserts a compact reference instead.",
|
||||
|
||||
# --- CLI Flags ---
|
||||
"hermes -c resumes your most recent CLI session. hermes -c \"project name\" resumes by title.",
|
||||
"hermes -w creates an isolated git worktree — perfect for parallel agent workflows.",
|
||||
"hermes -w -q \"Fix issue #42\" combines worktree isolation with a one-shot query.",
|
||||
"hermes chat -t web,terminal enables only specific toolsets for a focused session.",
|
||||
"hermes chat -s github-pr-workflow preloads a skill at launch.",
|
||||
"hermes chat -q \"query\" runs a single non-interactive query and exits.",
|
||||
"hermes chat --max-turns 200 overrides the default 90-iteration limit per turn.",
|
||||
"hermes chat --checkpoints enables filesystem snapshots before every destructive file change.",
|
||||
"hermes --yolo bypasses all dangerous command approval prompts for the entire session.",
|
||||
"hermes chat --source telegram tags the session for filtering in hermes sessions list.",
|
||||
"hermes -p work chat runs under a specific profile without changing your default.",
|
||||
|
||||
# --- CLI Subcommands ---
|
||||
"hermes doctor --fix diagnoses and auto-repairs config and dependency issues.",
|
||||
"hermes dump outputs a compact setup summary — great for bug reports.",
|
||||
"hermes config set KEY VALUE auto-routes secrets to .env and everything else to config.yaml.",
|
||||
"hermes config edit opens config.yaml in your default editor.",
|
||||
"hermes config check scans for missing or stale configuration options.",
|
||||
"hermes sessions browse opens an interactive session picker with search.",
|
||||
"hermes sessions stats shows session counts by platform and database size.",
|
||||
"hermes sessions prune --older-than 30 cleans up old sessions.",
|
||||
"hermes skills search react --source skills-sh searches the skills.sh public directory.",
|
||||
"hermes skills check scans installed hub skills for upstream updates.",
|
||||
"hermes skills tap add myorg/skills-repo adds a custom GitHub skill source.",
|
||||
"hermes skills snapshot export setup.json exports your skill configuration for backup or sharing.",
|
||||
"hermes mcp add github --command npx adds MCP servers from the command line.",
|
||||
"hermes mcp serve runs Hermes itself as an MCP server for other agents.",
|
||||
"hermes auth add lets you add multiple API keys for credential pool rotation.",
|
||||
"hermes completion bash >> ~/.bashrc enables tab completion for all commands and profiles.",
|
||||
"hermes logs -f follows agent.log in real time. --level WARNING --since 1h filters output.",
|
||||
"hermes backup creates a zip backup of your entire Hermes home directory.",
|
||||
"hermes profile create coder creates an isolated profile that becomes its own command.",
|
||||
"hermes profile create work --clone copies your current config and keys to a new profile.",
|
||||
"hermes update syncs new bundled skills to ALL profiles automatically.",
|
||||
"hermes gateway install sets up Hermes as a system service (systemd/launchd).",
|
||||
"hermes memory setup lets you configure an external memory provider (Honcho, Mem0, etc.).",
|
||||
"hermes webhook subscribe creates event-driven webhook routes with HMAC validation.",
|
||||
|
||||
# --- Configuration ---
|
||||
"Set display.bell_on_complete: true in config.yaml to hear a bell when long tasks finish.",
|
||||
"Set display.streaming: true to see tokens appear in real time as the model generates.",
|
||||
"Set display.show_reasoning: true to watch the model's chain-of-thought reasoning.",
|
||||
"Set display.compact: true to reduce whitespace in output for denser information.",
|
||||
"Set display.busy_input_mode: queue to queue messages instead of interrupting the agent.",
|
||||
"Set display.resume_display: minimal to skip the full conversation recap on session resume.",
|
||||
"Set compression.threshold: 0.50 to control when auto-compression fires (default: 50% of context).",
|
||||
"Set agent.max_turns: 200 to let the agent take more tool-calling steps per turn.",
|
||||
"Set file_read_max_chars: 200000 to increase the max content per read_file call.",
|
||||
"Set approvals.mode: smart to let an LLM auto-approve safe commands and auto-deny dangerous ones.",
|
||||
"Set fallback_model in config.yaml to automatically fail over to a backup provider.",
|
||||
"Set privacy.redact_pii: true to hash user IDs and phone numbers before sending to the LLM.",
|
||||
"Set browser.record_sessions: true to auto-record browser sessions as WebM videos.",
|
||||
"Set worktree: true in config.yaml to always create a git worktree (same as hermes -w).",
|
||||
"Set security.website_blocklist.enabled: true to block specific domains from web tools.",
|
||||
"Set cron.wrap_response: false to deliver raw agent output without the cron header/footer.",
|
||||
"HERMES_TIMEZONE overrides the server timezone with any IANA timezone string.",
|
||||
"Environment variable substitution works in config.yaml: use ${VAR_NAME} syntax.",
|
||||
"Quick commands in config.yaml run shell commands instantly with zero token usage.",
|
||||
"Custom personalities can be defined in config.yaml under agent.personalities.",
|
||||
"provider_routing controls OpenRouter provider sorting, whitelisting, and blacklisting.",
|
||||
|
||||
# --- Tools & Capabilities ---
|
||||
"execute_code runs Python scripts that call Hermes tools programmatically — results stay out of context.",
|
||||
"delegate_task spawns up to 3 concurrent sub-agents with isolated contexts for parallel work.",
|
||||
"web_extract works on PDF URLs — pass any PDF link and it converts to markdown.",
|
||||
"search_files is ripgrep-backed and faster than grep — use it instead of terminal grep.",
|
||||
"patch uses 9 fuzzy matching strategies so minor whitespace differences won't break edits.",
|
||||
"patch supports V4A format for bulk multi-file edits in a single call.",
|
||||
"read_file suggests similar filenames when a file isn't found.",
|
||||
"read_file auto-deduplicates — re-reading an unchanged file returns a lightweight stub.",
|
||||
"browser_vision takes a screenshot and analyzes it with AI — works for CAPTCHAs and visual content.",
|
||||
"browser_console can evaluate JavaScript expressions in the page context.",
|
||||
"image_generate creates images with FLUX 2 Pro and automatic 2x upscaling.",
|
||||
"text_to_speech converts text to audio — plays as voice bubbles on Telegram.",
|
||||
"send_message can reach any connected messaging platform from within a session.",
|
||||
"The todo tool helps the agent track complex multi-step tasks during a session.",
|
||||
"session_search performs full-text search across ALL past conversations.",
|
||||
"The agent automatically saves preferences, corrections, and environment facts to memory.",
|
||||
"mixture_of_agents routes hard problems through 4 frontier LLMs collaboratively.",
|
||||
"Terminal commands support background mode with notify_on_complete for long-running tasks.",
|
||||
"Terminal background processes support watch_patterns to alert on specific output lines.",
|
||||
"The terminal tool supports 6 backends: local, Docker, SSH, Modal, Daytona, and Singularity.",
|
||||
|
||||
# --- Profiles ---
|
||||
"Each profile gets its own config, API keys, memory, sessions, skills, and cron jobs.",
|
||||
"Profile names become shell commands — 'hermes profile create coder' creates the 'coder' command.",
|
||||
"hermes profile export coder -o backup.tar.gz creates a portable profile archive.",
|
||||
"If two profiles accidentally share a bot token, the second gateway is blocked with a clear error.",
|
||||
|
||||
# --- Sessions ---
|
||||
"Sessions auto-generate descriptive titles after the first exchange — no manual naming needed.",
|
||||
"Session titles support lineage: \"my project\" → \"my project #2\" → \"my project #3\".",
|
||||
"When exiting, Hermes prints a resume command with session ID and stats.",
|
||||
"hermes sessions export backup.jsonl exports all sessions for backup or analysis.",
|
||||
"hermes -r SESSION_ID resumes any specific past session by its ID.",
|
||||
|
||||
# --- Memory ---
|
||||
"Memory is a frozen snapshot — changes appear in the system prompt only at next session start.",
|
||||
"Memory entries are automatically scanned for prompt injection and exfiltration patterns.",
|
||||
"The agent has two memory stores: personal notes (~2200 chars) and user profile (~1375 chars).",
|
||||
"Corrections you give the agent (\"no, do it this way\") are often auto-saved to memory.",
|
||||
|
||||
# --- Skills ---
|
||||
"Over 80 bundled skills covering github, creative, mlops, productivity, research, and more.",
|
||||
"Every installed skill automatically becomes a slash command — type / to see them all.",
|
||||
"hermes skills install official/security/1password installs optional skills from the repo.",
|
||||
"Skills can restrict to specific OS platforms — some only load on macOS or Linux.",
|
||||
"skills.external_dirs in config.yaml lets you load skills from custom directories.",
|
||||
"The agent can create its own skills as procedural memory using skill_manage.",
|
||||
"The plan skill saves markdown plans under .hermes/plans/ in the active workspace.",
|
||||
|
||||
# --- Cron & Scheduling ---
|
||||
"Cron jobs can attach skills: hermes cron add --skill blogwatcher \"Check for new posts\".",
|
||||
"Cron delivery targets include telegram, discord, slack, email, sms, and 12+ more platforms.",
|
||||
"If a cron response starts with [SILENT], delivery is suppressed — useful for monitoring-only jobs.",
|
||||
"Cron supports relative delays (30m), intervals (every 2h), cron expressions, and ISO timestamps.",
|
||||
"Cron jobs run in completely fresh agent sessions — prompts must be self-contained.",
|
||||
|
||||
# --- Voice ---
|
||||
"Voice mode works with zero API keys if faster-whisper is installed (free local speech-to-text).",
|
||||
"Five TTS providers available: Edge TTS (free), ElevenLabs, OpenAI, NeuTTS (free local), MiniMax.",
|
||||
"/voice on enables voice mode in the CLI. Ctrl+B toggles push-to-talk recording.",
|
||||
"Streaming TTS plays sentences as they generate — you don't wait for the full response.",
|
||||
"Voice messages on Telegram, Discord, WhatsApp, and Slack are auto-transcribed.",
|
||||
|
||||
# --- Gateway & Messaging ---
|
||||
"Hermes runs on 18 platforms: Telegram, Discord, Slack, WhatsApp, Signal, Matrix, email, and more.",
|
||||
"hermes gateway install sets it up as a system service that starts on boot.",
|
||||
"DingTalk uses Stream Mode — no webhooks or public URL needed.",
|
||||
"BlueBubbles brings iMessage to Hermes via a local macOS server.",
|
||||
"Webhook routes support HMAC validation, rate limiting, and event filtering.",
|
||||
"The API server exposes an OpenAI-compatible endpoint compatible with Open WebUI and LibreChat.",
|
||||
"Discord voice channel mode: the bot joins VC, transcribes speech, and talks back.",
|
||||
"group_sessions_per_user: true gives each person their own session in group chats.",
|
||||
"/sethome marks a chat as the home channel for cron job deliveries.",
|
||||
"The gateway supports inactivity-based timeouts — active agents can run indefinitely.",
|
||||
|
||||
# --- Security ---
|
||||
"Dangerous command approval has 4 tiers: once, session, always (permanent allowlist), deny.",
|
||||
"Smart approval mode uses an LLM to auto-approve safe commands and flag dangerous ones.",
|
||||
"SSRF protection blocks private networks, loopback, link-local, and cloud metadata addresses.",
|
||||
"Tirith pre-exec scanning detects homograph URL spoofing and pipe-to-interpreter patterns.",
|
||||
"MCP subprocesses receive a filtered environment — only safe system vars pass through.",
|
||||
"Context files (.hermes.md, AGENTS.md) are security-scanned for prompt injection before loading.",
|
||||
"command_allowlist in config.yaml permanently approves specific shell command patterns.",
|
||||
|
||||
# --- Context & Compression ---
|
||||
"Context auto-compresses when it reaches the threshold — memories are flushed and history summarized.",
|
||||
"The status bar turns yellow, then orange, then red as context fills up.",
|
||||
"SOUL.md at ~/.hermes/SOUL.md is the agent's primary identity — customize it to shape behavior.",
|
||||
"Hermes loads project context from .hermes.md, AGENTS.md, CLAUDE.md, or .cursorrules (first match).",
|
||||
"Subdirectory AGENTS.md files are discovered progressively as the agent navigates into folders.",
|
||||
"Context files are capped at 20,000 characters with smart head/tail truncation.",
|
||||
|
||||
# --- Browser ---
|
||||
"Five browser providers: local Chromium, Browserbase, Browser Use, Camofox, and Firecrawl.",
|
||||
"Camofox is an anti-detection browser — Firefox fork with C++ fingerprint spoofing.",
|
||||
"browser_navigate returns a page snapshot automatically — no need to call browser_snapshot after.",
|
||||
"browser_vision with annotate=true overlays numbered labels on interactive elements.",
|
||||
|
||||
# --- MCP ---
|
||||
"MCP servers are configured in config.yaml — both stdio and HTTP transports supported.",
|
||||
"Per-server tool filtering: tools.include whitelists and tools.exclude blacklists specific tools.",
|
||||
"MCP servers auto-generate toolsets at runtime — hermes tools can toggle them per platform.",
|
||||
"MCP OAuth support: auth: oauth enables browser-based authorization with PKCE.",
|
||||
|
||||
# --- Checkpoints & Rollback ---
|
||||
"Checkpoints have zero overhead when no files are modified — enabled by default.",
|
||||
"A pre-rollback snapshot is saved automatically so you can undo the undo.",
|
||||
"/rollback also undoes the conversation turn, so the agent doesn't remember rolled-back changes.",
|
||||
"Checkpoints use shadow repos in ~/.hermes/checkpoints/ — your project's .git is never touched.",
|
||||
|
||||
# --- Batch & Data ---
|
||||
"batch_runner.py processes hundreds of prompts in parallel for training data generation.",
|
||||
"hermes chat -Q enables quiet mode for programmatic use — suppresses banner and spinner.",
|
||||
"Trajectory saving (--save-trajectories) captures full tool-use traces for model training.",
|
||||
|
||||
# --- Plugins ---
|
||||
"Three plugin types: general (tools/hooks), memory providers, and context engines.",
|
||||
"hermes plugins install owner/repo installs plugins directly from GitHub.",
|
||||
"8 external memory providers available: Honcho, OpenViking, Mem0, Hindsight, and more.",
|
||||
"Plugin hooks include pre_tool_call, post_tool_call, pre_llm_call, and post_llm_call.",
|
||||
|
||||
# --- Miscellaneous ---
|
||||
"Prompt caching (Anthropic) reduces costs by reusing cached system prompt prefixes.",
|
||||
"The agent auto-generates session titles in a background thread — zero latency impact.",
|
||||
"Smart model routing can auto-route simple queries to a cheaper model.",
|
||||
"Slash commands support prefix matching: /h resolves to /help, /mod to /model.",
|
||||
"Dragging a file path into the terminal auto-attaches images or sends as context.",
|
||||
".worktreeinclude in your repo root lists gitignored files to copy into worktrees.",
|
||||
"hermes acp runs Hermes as an ACP server for VS Code, Zed, and JetBrains integration.",
|
||||
"Custom providers: save named endpoints in config.yaml under custom_providers.",
|
||||
"HERMES_EPHEMERAL_SYSTEM_PROMPT injects a system prompt that's never persisted to history.",
|
||||
"credential_pool_strategies supports fill_first, round_robin, least_used, and random rotation.",
|
||||
"hermes login supports OAuth-based auth for Nous and OpenAI Codex providers.",
|
||||
"The API server supports both Chat Completions and Responses API with server-side state.",
|
||||
"tool_preview_length: 0 in config shows full file paths in the spinner's activity feed.",
|
||||
"hermes status --deep runs deeper diagnostic checks across all components.",
|
||||
|
||||
# --- Hidden Gems & Power-User Tricks ---
|
||||
"BOOT.md at ~/.hermes/BOOT.md runs automatically on every gateway start — use it for startup checks.",
|
||||
"Cron jobs can attach a Python script (--script) whose stdout is injected into the prompt as context.",
|
||||
"Cron scripts live in ~/.hermes/scripts/ and run before the agent — perfect for data collection pipelines.",
|
||||
"prefill_messages_file in config.yaml injects few-shot examples into every API call, never saved to history.",
|
||||
"SOUL.md completely replaces the agent's default identity — rewrite it to make Hermes your own.",
|
||||
"SOUL.md is auto-seeded with a default personality on first run. Edit ~/.hermes/SOUL.md to customize.",
|
||||
"/compress <focus topic> allocates 60-70% of the summary budget to your topic and aggressively trims the rest.",
|
||||
"On second+ compression, the compressor updates the previous summary instead of starting from scratch.",
|
||||
"Before a gateway session reset, Hermes auto-flushes important facts to memory in the background.",
|
||||
"network.force_ipv4: true in config.yaml fixes hangs on servers with broken IPv6 — monkey-patches socket.",
|
||||
"The terminal tool annotates common exit codes: grep returning 1 = 'No matches found (not an error)'.",
|
||||
"Failed foreground terminal commands auto-retry up to 3 times with exponential backoff (2s, 4s, 8s).",
|
||||
"Bare sudo commands are auto-rewritten to pipe SUDO_PASSWORD from .env — no interactive prompt needed.",
|
||||
"execute_code has built-in helpers: json_parse() for tolerant parsing, shell_quote(), and retry() with backoff.",
|
||||
"execute_code's 7 sandbox tools (web_search, terminal, read/write/search/patch) use RPC — never enter context.",
|
||||
"Reading the same file region 3+ times triggers a warning. At 4+, it's hard-blocked to prevent loops.",
|
||||
"write_file and patch detect if a file was externally modified since the last read and warn about staleness.",
|
||||
"V4A patch format supports Add File, Delete File, and Move File directives — not just Update.",
|
||||
"MCP servers can request LLM completions back via sampling — the agent becomes a tool for the server.",
|
||||
"MCP servers send notifications/tools/list_changed to trigger automatic tool re-registration without restart.",
|
||||
"delegate_task with acp_command: 'claude' spawns Claude Code as a child agent from any platform.",
|
||||
"Delegation has a heartbeat thread — child activity propagates to the parent, preventing gateway timeouts.",
|
||||
"When a provider returns HTTP 402 (payment required), the auxiliary client auto-falls back to the next one.",
|
||||
"agent.tool_use_enforcement steers models that describe actions instead of calling tools — auto for GPT/Codex.",
|
||||
"agent.restart_drain_timeout (default 60s) lets running agents finish before a gateway restart takes effect.",
|
||||
"The gateway caches AIAgent instances per session — destroying this cache breaks Anthropic prompt caching.",
|
||||
"Any website can expose skills via /.well-known/skills/index.json — the skills hub discovers them automatically.",
|
||||
"The skills audit log at ~/.hermes/skills/.hub/audit.log tracks every install and removal operation.",
|
||||
"Stale git worktrees are auto-cleaned: 24-72h old with no unpushed commits get pruned on startup.",
|
||||
"Each profile gets its own subprocess HOME at HERMES_HOME/home/ — isolated git, ssh, npm, gh configs.",
|
||||
"HERMES_HOME_MODE env var (octal, e.g. 0701) sets custom directory permissions for web server traversal.",
|
||||
"Container mode: place .container-mode in HERMES_HOME and the host CLI auto-execs into the container.",
|
||||
"Ctrl+C has 5 priority tiers: cancel recording → cancel prompts → cancel picker → interrupt agent → exit.",
|
||||
"Every interrupt during an agent run is logged to ~/.hermes/interrupt_debug.log with timestamps.",
|
||||
"BROWSER_CDP_URL connects browser tools to any running Chrome — accepts WebSocket, HTTP, or host:port.",
|
||||
"BROWSERBASE_ADVANCED_STEALTH=true enables advanced anti-detection with custom Chromium (Scale Plan).",
|
||||
"The CLI auto-switches to compact mode in terminals narrower than 80 columns.",
|
||||
"Quick commands support two types: exec (run shell command directly) and alias (redirect to another command).",
|
||||
"Per-task delegation model: delegation.model and delegation.provider in config route subagents to cheaper models.",
|
||||
"delegation.reasoning_effort independently controls thinking depth for subagents.",
|
||||
"display.platforms in config.yaml allows per-platform display overrides: {telegram: {tool_progress: all}}.",
|
||||
"human_delay.mode in config simulates human typing speed — configurable min_ms/max_ms range.",
|
||||
"Config version migrations run automatically on load — new config keys appear without manual intervention.",
|
||||
"GPT and Codex models get special system prompt guidance for tool discipline and mandatory tool use.",
|
||||
"Gemini models get tailored directives for absolute paths, parallel tool calls, and non-interactive commands.",
|
||||
"context.engine in config.yaml can be set to a plugin name for alternative context management strategies.",
|
||||
"Browser pages over 8000 tokens are auto-summarized by the auxiliary LLM before returning to the agent.",
|
||||
"The compressor does a cheap pre-pass: tool outputs over 200 chars are replaced with placeholders before the LLM runs.",
|
||||
"When compression fails, further attempts are paused for 10 minutes to avoid API hammering.",
|
||||
"Long dangerous commands (>70 chars) get a 'view' option in the approval prompt to see the full text first.",
|
||||
"Audio level visualization shows ▁▂▃▄▅▆▇ bars during voice recording based on microphone RMS levels.",
|
||||
"Profile names cannot collide with existing PATH binaries — 'hermes profile create ls' would be rejected.",
|
||||
"hermes profile create backup --clone-all copies everything (config, keys, SOUL.md, memories, skills, sessions).",
|
||||
"The voice record key is configurable via voice.record_key in config.yaml — not just Ctrl+B.",
|
||||
".cursorrules and .cursor/rules/*.mdc files are auto-detected and loaded as project context.",
|
||||
"Context files support 10+ prompt injection patterns — invisible Unicode, 'ignore instructions', exfil attempts.",
|
||||
"GPT-5 and Codex use 'developer' role instead of 'system' in the message format.",
|
||||
"Per-task auxiliary overrides: auxiliary.vision.provider, auxiliary.compression.model, etc. in config.yaml.",
|
||||
"The auxiliary client treats 'main' as a provider alias — resolves to your actual primary provider + model.",
|
||||
"Smart routing can auto-route simple queries to a cheaper model — set smart_model_routing.enabled: true.",
|
||||
"hermes claw migrate --dry-run previews OpenClaw migration without writing anything.",
|
||||
"File paths pasted with quotes or escaped spaces are handled automatically — no manual cleanup needed.",
|
||||
"Slash commands never trigger the large-paste collapse — /command with big arguments works correctly.",
|
||||
"In interrupt mode, slash commands typed during agent execution bypass interrupt logic and run immediately.",
|
||||
"HERMES_DEV=1 bypasses container mode detection for local development.",
|
||||
"Each MCP server gets its own toolset (mcp-servername) that can be toggled independently via hermes tools.",
|
||||
"MCP ${ENV_VAR} placeholders in config are resolved at server spawn — including vars from ~/.hermes/.env.",
|
||||
"Skills from trusted repos (NousResearch) get a 'trusted' security level; community skills get extra scanning.",
|
||||
"The skills quarantine at ~/.hermes/skills/.hub/quarantine/ holds skills pending security review.",
|
||||
]
|
||||
|
||||
|
||||
def get_random_tip(exclude_recent: int = 0) -> str:
|
||||
"""Return a random tip string.
|
||||
|
||||
Args:
|
||||
exclude_recent: not used currently; reserved for future
|
||||
deduplication across sessions.
|
||||
"""
|
||||
return random.choice(TIPS)
|
||||
|
||||
|
||||
def get_tip_count() -> int:
|
||||
"""Return the total number of tips available."""
|
||||
return len(TIPS)
|
||||
@@ -376,6 +376,24 @@ def backup_existing(path: Path, backup_root: Path) -> Optional[Path]:
|
||||
return dest
|
||||
|
||||
|
||||
# ── Brand rewriting ─────────────────────────────────────────
|
||||
# Replace OpenClaw brand names with Hermes in migrated text so that
|
||||
# memory entries, user profiles, SOUL.md, and workspace instructions
|
||||
# read as self-referential to the new agent identity.
|
||||
_REBRAND_PATTERNS: List[Tuple[re.Pattern, str]] = [
|
||||
(re.compile(r'\bOpen[\s-]?Claw\b', re.IGNORECASE), 'Hermes'),
|
||||
(re.compile(r'\bClawdBot\b', re.IGNORECASE), 'Hermes'),
|
||||
(re.compile(r'\bMoltBot\b', re.IGNORECASE), 'Hermes'),
|
||||
]
|
||||
|
||||
|
||||
def rebrand_text(text: str) -> str:
|
||||
"""Replace OpenClaw / ClawdBot / MoltBot brand names with Hermes."""
|
||||
for pattern, replacement in _REBRAND_PATTERNS:
|
||||
text = pattern.sub(replacement, text)
|
||||
return text
|
||||
|
||||
|
||||
def parse_existing_memory_entries(path: Path) -> List[str]:
|
||||
if not path.exists():
|
||||
return []
|
||||
@@ -782,12 +800,13 @@ class Migrator:
|
||||
path.write_text("\n".join(entries) + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
def copy_file(self, source: Path, destination: Path, kind: str) -> None:
|
||||
def copy_file(self, source: Path, destination: Path, kind: str,
|
||||
transform: Optional[Any] = None) -> None:
|
||||
if not source or not source.exists():
|
||||
return
|
||||
|
||||
if destination.exists():
|
||||
if sha256_file(source) == sha256_file(destination):
|
||||
if not transform and sha256_file(source) == sha256_file(destination):
|
||||
self.record(kind, source, destination, "skipped", "Target already matches source")
|
||||
return
|
||||
if not self.overwrite:
|
||||
@@ -797,7 +816,13 @@ class Migrator:
|
||||
if self.execute:
|
||||
backup_path = self.maybe_backup(destination)
|
||||
ensure_parent(destination)
|
||||
shutil.copy2(source, destination)
|
||||
if transform:
|
||||
content = read_text(source)
|
||||
content = transform(content)
|
||||
destination.write_text(content, encoding="utf-8")
|
||||
shutil.copystat(source, destination)
|
||||
else:
|
||||
shutil.copy2(source, destination)
|
||||
self.record(kind, source, destination, "migrated", backup=str(backup_path) if backup_path else None)
|
||||
else:
|
||||
self.record(kind, source, destination, "migrated", "Would copy")
|
||||
@@ -807,7 +832,7 @@ class Migrator:
|
||||
if not source:
|
||||
self.record("soul", None, self.target_root / "SOUL.md", "skipped", "No OpenClaw SOUL.md found")
|
||||
return
|
||||
self.copy_file(source, self.target_root / "SOUL.md", kind="soul")
|
||||
self.copy_file(source, self.target_root / "SOUL.md", kind="soul", transform=rebrand_text)
|
||||
|
||||
def migrate_workspace_agents(self) -> None:
|
||||
source = self.source_candidate(
|
||||
@@ -821,7 +846,7 @@ class Migrator:
|
||||
self.record("workspace-agents", source, None, "skipped", "No workspace target was provided")
|
||||
return
|
||||
destination = self.workspace_target / WORKSPACE_INSTRUCTIONS_FILENAME
|
||||
self.copy_file(source, destination, kind="workspace-agents")
|
||||
self.copy_file(source, destination, kind="workspace-agents", transform=rebrand_text)
|
||||
|
||||
def migrate_memory(self, source: Optional[Path], destination: Path, limit: int, kind: str) -> None:
|
||||
if not source or not source.exists():
|
||||
@@ -832,6 +857,7 @@ class Migrator:
|
||||
if not incoming:
|
||||
self.record(kind, source, destination, "skipped", "No importable entries found")
|
||||
return
|
||||
incoming = [rebrand_text(entry) for entry in incoming]
|
||||
|
||||
existing = parse_existing_memory_entries(destination)
|
||||
merged, stats, overflowed = merge_entries(existing, incoming, limit)
|
||||
@@ -927,7 +953,7 @@ class Migrator:
|
||||
|
||||
def load_openclaw_config(self) -> Dict[str, Any]:
|
||||
# Check current name and legacy config filenames
|
||||
for name in ("openclaw.json", "clawdbot.json", "moldbot.json"):
|
||||
for name in ("openclaw.json", "clawdbot.json", "moltbot.json"):
|
||||
config_path = self.source_root / name
|
||||
if config_path.exists():
|
||||
try:
|
||||
@@ -997,7 +1023,17 @@ class Migrator:
|
||||
.get("workspace")
|
||||
)
|
||||
if isinstance(workspace, str) and workspace.strip():
|
||||
additions["MESSAGING_CWD"] = workspace.strip()
|
||||
ws_path = workspace.strip()
|
||||
# Skip if the workspace points inside the OpenClaw source directory —
|
||||
# that path will be stale after migration and would cause the Hermes
|
||||
# gateway to use the old OpenClaw workspace as its cwd, picking up
|
||||
# OpenClaw's AGENTS.md, MEMORY.md, etc.
|
||||
try:
|
||||
inside_source = Path(ws_path).resolve().is_relative_to(self.source_root.resolve())
|
||||
except (ValueError, OSError):
|
||||
inside_source = False
|
||||
if not inside_source:
|
||||
additions["MESSAGING_CWD"] = ws_path
|
||||
|
||||
allowlist_path = self.source_root / "credentials" / "telegram-default-allowFrom.json"
|
||||
if allowlist_path.exists():
|
||||
@@ -1543,6 +1579,7 @@ class Migrator:
|
||||
if not all_incoming:
|
||||
self.record("daily-memory", source_dir, destination, "skipped", "No importable entries found in daily memory files")
|
||||
return
|
||||
all_incoming = [rebrand_text(entry) for entry in all_incoming]
|
||||
|
||||
existing = parse_existing_memory_entries(destination)
|
||||
merged, stats, overflowed = merge_entries(existing, all_incoming, self.memory_limit)
|
||||
|
||||
@@ -1,51 +1,29 @@
|
||||
---
|
||||
name: github-code-review
|
||||
description: Review code changes by analyzing git diffs, leaving inline comments on PRs, and performing thorough pre-push review. Works with gh CLI or falls back to git + GitHub REST API via curl.
|
||||
version: 1.1.0
|
||||
description: Review code changes by analyzing git diffs, leaving inline comments on PRs, and performing thorough pre-push review. Uses GitHub MCP tools (mcp_github_*) as the primary interface, with git CLI for local diff operations.
|
||||
version: 2.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [GitHub, Code-Review, Pull-Requests, Git, Quality]
|
||||
tags: [GitHub, Code-Review, Pull-Requests, Git, Quality, MCP]
|
||||
related_skills: [github-auth, github-pr-workflow]
|
||||
---
|
||||
|
||||
# GitHub Code Review
|
||||
|
||||
Perform code reviews on local changes before pushing, or review open PRs on GitHub. Most of this skill uses plain `git` — the `gh`/`curl` split only matters for PR-level interactions.
|
||||
Perform code reviews on local changes before pushing, or review open PRs on GitHub. This skill uses **GitHub MCP tools** (`mcp_github_*`) as the primary interface for all GitHub API interactions, with plain `git` for local diff operations.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Authenticated with GitHub (see `github-auth` skill)
|
||||
- Inside a git repository
|
||||
|
||||
### Setup (for PR interactions)
|
||||
|
||||
```bash
|
||||
if command -v gh &>/dev/null && gh auth status &>/dev/null; then
|
||||
AUTH="gh"
|
||||
else
|
||||
AUTH="git"
|
||||
if [ -z "$GITHUB_TOKEN" ]; then
|
||||
if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then
|
||||
GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r')
|
||||
elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then
|
||||
GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|')
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
REMOTE_URL=$(git remote get-url origin)
|
||||
OWNER_REPO=$(echo "$REMOTE_URL" | sed -E 's|.*github\.com[:/]||; s|\.git$||')
|
||||
OWNER=$(echo "$OWNER_REPO" | cut -d/ -f1)
|
||||
REPO=$(echo "$OWNER_REPO" | cut -d/ -f2)
|
||||
```
|
||||
- GitHub MCP server configured (provides `mcp_github_*` tools)
|
||||
- Inside a git repository (for local diff operations)
|
||||
|
||||
---
|
||||
|
||||
## 1. Reviewing Local Changes (Pre-Push)
|
||||
|
||||
This is pure `git` — works everywhere, no API needed.
|
||||
Local diffs use plain `git` — no API needed.
|
||||
|
||||
### Get the Diff
|
||||
|
||||
@@ -122,158 +100,206 @@ When reviewing local changes, present findings in this structure:
|
||||
|
||||
---
|
||||
|
||||
## 2. Reviewing a Pull Request on GitHub
|
||||
## 2. Reviewing a Pull Request on GitHub (MCP Tools)
|
||||
|
||||
### View PR Details
|
||||
### Step 1: Gather PR Context
|
||||
|
||||
**With gh:**
|
||||
Use MCP tools to get PR metadata, description, and changed files:
|
||||
|
||||
```bash
|
||||
gh pr view 123
|
||||
gh pr diff 123
|
||||
gh pr diff 123 --name-only
|
||||
```
|
||||
# Get PR details (title, author, description, branch, status)
|
||||
mcp_github_pull_request_read(method="get", owner=OWNER, repo=REPO, pullNumber=PR_NUMBER)
|
||||
|
||||
# Get the diff
|
||||
mcp_github_pull_request_read(method="get_diff", owner=OWNER, repo=REPO, pullNumber=PR_NUMBER)
|
||||
|
||||
# Get list of changed files with additions/deletions
|
||||
mcp_github_pull_request_read(method="get_files", owner=OWNER, repo=REPO, pullNumber=PR_NUMBER)
|
||||
|
||||
# Get CI/CD status
|
||||
mcp_github_pull_request_read(method="get_status", owner=OWNER, repo=REPO, pullNumber=PR_NUMBER)
|
||||
|
||||
# Get check runs (individual CI jobs)
|
||||
mcp_github_pull_request_read(method="get_check_runs", owner=OWNER, repo=REPO, pullNumber=PR_NUMBER)
|
||||
```
|
||||
|
||||
**With git + curl:**
|
||||
### Step 2: Read File Contents for Context
|
||||
|
||||
```bash
|
||||
PR_NUMBER=123
|
||||
For each changed file, read the full file to understand the surrounding context:
|
||||
|
||||
# Get PR details
|
||||
curl -s \
|
||||
-H "Authorization: token $GITHUB_TOKEN" \
|
||||
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER \
|
||||
| python3 -c "
|
||||
import sys, json
|
||||
pr = json.load(sys.stdin)
|
||||
print(f\"Title: {pr['title']}\")
|
||||
print(f\"Author: {pr['user']['login']}\")
|
||||
print(f\"Branch: {pr['head']['ref']} -> {pr['base']['ref']}\")
|
||||
print(f\"State: {pr['state']}\")
|
||||
print(f\"Body:\n{pr['body']}\")"
|
||||
|
||||
# List changed files
|
||||
curl -s \
|
||||
-H "Authorization: token $GITHUB_TOKEN" \
|
||||
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER/files \
|
||||
| python3 -c "
|
||||
import sys, json
|
||||
for f in json.load(sys.stdin):
|
||||
print(f\"{f['status']:10} +{f['additions']:-4} -{f['deletions']:-4} {f['filename']}\")"
|
||||
```
|
||||
# Read specific files from the PR branch
|
||||
mcp_github_get_file_contents(owner=OWNER, repo=REPO, path="src/auth/login.py", ref="refs/pull/PR_NUMBER/head")
|
||||
```
|
||||
|
||||
### Check Out PR Locally for Full Review
|
||||
### Step 3: Check Out Locally (Optional — for running tests)
|
||||
|
||||
This works with plain `git` — no `gh` needed:
|
||||
If you need to run tests or linters locally:
|
||||
|
||||
```bash
|
||||
# Fetch the PR branch and check it out
|
||||
git fetch origin pull/123/head:pr-123
|
||||
git checkout pr-123
|
||||
git fetch origin pull/PR_NUMBER/head:pr-PR_NUMBER
|
||||
git checkout pr-PR_NUMBER
|
||||
|
||||
# Now you can use read_file, search_files, run tests, etc.
|
||||
# Run tests
|
||||
python -m pytest 2>&1 | tail -20
|
||||
|
||||
# View diff against the base branch
|
||||
git diff main...pr-123
|
||||
# Run linter
|
||||
ruff check . 2>&1 | head -30
|
||||
```
|
||||
|
||||
**With gh (shortcut):**
|
||||
### Step 4: Get Existing Review Comments
|
||||
|
||||
Check what's already been discussed:
|
||||
|
||||
```
|
||||
# Get review threads (grouped comments on code locations)
|
||||
mcp_github_pull_request_read(method="get_review_comments", owner=OWNER, repo=REPO, pullNumber=PR_NUMBER)
|
||||
|
||||
# Get general PR comments
|
||||
mcp_github_pull_request_read(method="get_comments", owner=OWNER, repo=REPO, pullNumber=PR_NUMBER)
|
||||
|
||||
# Get formal reviews (approvals, change requests)
|
||||
mcp_github_pull_request_read(method="get_reviews", owner=OWNER, repo=REPO, pullNumber=PR_NUMBER)
|
||||
```
|
||||
|
||||
### Step 5: Apply the Review Checklist (Section 3)
|
||||
|
||||
Go through each category systematically.
|
||||
|
||||
### Step 6: Submit a Formal Review with Inline Comments
|
||||
|
||||
Use the MCP review tools to submit findings:
|
||||
|
||||
**Create a pending review, add inline comments, then submit:**
|
||||
|
||||
```
|
||||
# Step A: Create a pending review (omit "event" to keep it pending)
|
||||
mcp_github_pull_request_review_write(
|
||||
method="create",
|
||||
owner=OWNER,
|
||||
repo=REPO,
|
||||
pullNumber=PR_NUMBER
|
||||
)
|
||||
|
||||
# Step B: Add inline comments to the pending review
|
||||
mcp_github_add_comment_to_pending_review(
|
||||
owner=OWNER,
|
||||
repo=REPO,
|
||||
pullNumber=PR_NUMBER,
|
||||
path="src/auth.py",
|
||||
line=45,
|
||||
body="🔴 **Critical:** User input passed directly to SQL query — use parameterized queries.",
|
||||
subjectType="LINE",
|
||||
side="RIGHT"
|
||||
)
|
||||
|
||||
mcp_github_add_comment_to_pending_review(
|
||||
owner=OWNER,
|
||||
repo=REPO,
|
||||
pullNumber=PR_NUMBER,
|
||||
path="src/models/user.py",
|
||||
line=23,
|
||||
body="⚠️ **Warning:** Password stored without hashing. Use bcrypt or argon2.",
|
||||
subjectType="LINE",
|
||||
side="RIGHT"
|
||||
)
|
||||
|
||||
# Step C: Submit the pending review
|
||||
mcp_github_pull_request_review_write(
|
||||
method="submit_pending",
|
||||
owner=OWNER,
|
||||
repo=REPO,
|
||||
pullNumber=PR_NUMBER,
|
||||
event="REQUEST_CHANGES", # or "APPROVE" or "COMMENT"
|
||||
body="## Hermes Agent Review\n\nFound 2 issues. See inline comments."
|
||||
)
|
||||
```
|
||||
|
||||
**Or submit a review directly (no pending step):**
|
||||
|
||||
```
|
||||
# Approve
|
||||
mcp_github_pull_request_review_write(
|
||||
method="create",
|
||||
owner=OWNER,
|
||||
repo=REPO,
|
||||
pullNumber=PR_NUMBER,
|
||||
event="APPROVE",
|
||||
body="LGTM! Code looks clean — good test coverage, no security concerns."
|
||||
)
|
||||
|
||||
# Request changes
|
||||
mcp_github_pull_request_review_write(
|
||||
method="create",
|
||||
owner=OWNER,
|
||||
repo=REPO,
|
||||
pullNumber=PR_NUMBER,
|
||||
event="REQUEST_CHANGES",
|
||||
body="Found a few issues — see inline comments."
|
||||
)
|
||||
```
|
||||
|
||||
### Step 7: Post a Summary Comment
|
||||
|
||||
Leave a top-level summary so the PR author gets the full picture:
|
||||
|
||||
```
|
||||
mcp_github_add_issue_comment(
|
||||
owner=OWNER,
|
||||
repo=REPO,
|
||||
issue_number=PR_NUMBER,
|
||||
body="""## Code Review Summary
|
||||
|
||||
**Verdict: Changes Requested** (2 issues, 1 suggestion)
|
||||
|
||||
### 🔴 Critical
|
||||
- **src/auth.py:45** — SQL injection vulnerability
|
||||
|
||||
### ⚠️ Warnings
|
||||
- **src/models.py:23** — Plaintext password storage
|
||||
|
||||
### 💡 Suggestions
|
||||
- **src/utils.py:8** — Duplicated logic, consider consolidating
|
||||
|
||||
### ✅ Looks Good
|
||||
- Clean API design
|
||||
- Good error handling in the middleware layer
|
||||
|
||||
---
|
||||
*Reviewed by Hermes Agent*"""
|
||||
)
|
||||
```
|
||||
|
||||
### Step 8: Reply to Existing Comments
|
||||
|
||||
If the PR author responds to your review:
|
||||
|
||||
```
|
||||
# Reply to a specific review comment
|
||||
mcp_github_add_reply_to_pull_request_comment(
|
||||
owner=OWNER,
|
||||
repo=REPO,
|
||||
pullNumber=PR_NUMBER,
|
||||
commentId=COMMENT_ID,
|
||||
body="Good point! That approach works too."
|
||||
)
|
||||
```
|
||||
|
||||
### Step 9: Request Copilot Review (Optional)
|
||||
|
||||
For automated AI feedback before your review:
|
||||
|
||||
```
|
||||
mcp_github_request_copilot_review(owner=OWNER, repo=REPO, pullNumber=PR_NUMBER)
|
||||
```
|
||||
|
||||
### Step 10: Clean Up (if checked out locally)
|
||||
|
||||
```bash
|
||||
gh pr checkout 123
|
||||
git checkout main
|
||||
git branch -D pr-PR_NUMBER
|
||||
```
|
||||
|
||||
### Leave Comments on a PR
|
||||
|
||||
**General PR comment — with gh:**
|
||||
|
||||
```bash
|
||||
gh pr comment 123 --body "Overall looks good, a few suggestions below."
|
||||
```
|
||||
|
||||
**General PR comment — with curl:**
|
||||
|
||||
```bash
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token $GITHUB_TOKEN" \
|
||||
https://api.github.com/repos/$OWNER/$REPO/issues/$PR_NUMBER/comments \
|
||||
-d '{"body": "Overall looks good, a few suggestions below."}'
|
||||
```
|
||||
|
||||
### Leave Inline Review Comments
|
||||
|
||||
**Single inline comment — with gh (via API):**
|
||||
|
||||
```bash
|
||||
HEAD_SHA=$(gh pr view 123 --json headRefOid --jq '.headRefOid')
|
||||
|
||||
gh api repos/$OWNER/$REPO/pulls/123/comments \
|
||||
--method POST \
|
||||
-f body="This could be simplified with a list comprehension." \
|
||||
-f path="src/auth/login.py" \
|
||||
-f commit_id="$HEAD_SHA" \
|
||||
-f line=45 \
|
||||
-f side="RIGHT"
|
||||
```
|
||||
|
||||
**Single inline comment — with curl:**
|
||||
|
||||
```bash
|
||||
# Get the head commit SHA
|
||||
HEAD_SHA=$(curl -s \
|
||||
-H "Authorization: token $GITHUB_TOKEN" \
|
||||
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['head']['sha'])")
|
||||
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token $GITHUB_TOKEN" \
|
||||
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER/comments \
|
||||
-d "{
|
||||
\"body\": \"This could be simplified with a list comprehension.\",
|
||||
\"path\": \"src/auth/login.py\",
|
||||
\"commit_id\": \"$HEAD_SHA\",
|
||||
\"line\": 45,
|
||||
\"side\": \"RIGHT\"
|
||||
}"
|
||||
```
|
||||
|
||||
### Submit a Formal Review (Approve / Request Changes)
|
||||
|
||||
**With gh:**
|
||||
|
||||
```bash
|
||||
gh pr review 123 --approve --body "LGTM!"
|
||||
gh pr review 123 --request-changes --body "See inline comments."
|
||||
gh pr review 123 --comment --body "Some suggestions, nothing blocking."
|
||||
```
|
||||
|
||||
**With curl — multi-comment review submitted atomically:**
|
||||
|
||||
```bash
|
||||
HEAD_SHA=$(curl -s \
|
||||
-H "Authorization: token $GITHUB_TOKEN" \
|
||||
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['head']['sha'])")
|
||||
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token $GITHUB_TOKEN" \
|
||||
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER/reviews \
|
||||
-d "{
|
||||
\"commit_id\": \"$HEAD_SHA\",
|
||||
\"event\": \"COMMENT\",
|
||||
\"body\": \"Code review from Hermes Agent\",
|
||||
\"comments\": [
|
||||
{\"path\": \"src/auth.py\", \"line\": 45, \"body\": \"Use parameterized queries to prevent SQL injection.\"},
|
||||
{\"path\": \"src/models/user.py\", \"line\": 23, \"body\": \"Hash passwords with bcrypt before storing.\"},
|
||||
{\"path\": \"tests/test_auth.py\", \"line\": 1, \"body\": \"Add test for expired token edge case.\"}
|
||||
]
|
||||
}"
|
||||
```
|
||||
|
||||
Event values: `"APPROVE"`, `"REQUEST_CHANGES"`, `"COMMENT"`
|
||||
|
||||
The `line` field refers to the line number in the *new* version of the file. For deleted lines, use `"side": "LEFT"`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Review Checklist
|
||||
@@ -290,6 +316,7 @@ When performing a code review (local or PR), systematically check:
|
||||
- Input validation on user-facing inputs
|
||||
- No SQL injection, XSS, or path traversal
|
||||
- Auth/authz checks where needed
|
||||
- Use `mcp_github_run_secret_scanning` on changed files for automated secret detection
|
||||
|
||||
### Code Quality
|
||||
- Clear naming (variables, functions, classes)
|
||||
@@ -327,151 +354,30 @@ When the user asks you to "review the code" or "check before pushing":
|
||||
|
||||
---
|
||||
|
||||
## 5. PR Review Workflow (End-to-End)
|
||||
## 5. PR Review Workflow (End-to-End with MCP Tools)
|
||||
|
||||
When the user asks you to "review PR #N", "look at this PR", or gives you a PR URL, follow this recipe:
|
||||
When the user asks you to "review PR #N", "look at this PR", or gives you a PR URL:
|
||||
|
||||
### Step 1: Set up environment
|
||||
### Quick Reference
|
||||
|
||||
```bash
|
||||
source ~/.hermes/skills/github/github-auth/scripts/gh-env.sh
|
||||
# Or run the inline setup block from the top of this skill
|
||||
```
|
||||
|
||||
### Step 2: Gather PR context
|
||||
|
||||
Get the PR metadata, description, and list of changed files to understand scope before diving into code.
|
||||
|
||||
**With gh:**
|
||||
```bash
|
||||
gh pr view 123
|
||||
gh pr diff 123 --name-only
|
||||
gh pr checks 123
|
||||
```
|
||||
|
||||
**With curl:**
|
||||
```bash
|
||||
PR_NUMBER=123
|
||||
|
||||
# PR details (title, author, description, branch)
|
||||
curl -s -H "Authorization: token $GITHUB_TOKEN" \
|
||||
https://api.github.com/repos/$GH_OWNER/$GH_REPO/pulls/$PR_NUMBER
|
||||
|
||||
# Changed files with line counts
|
||||
curl -s -H "Authorization: token $GITHUB_TOKEN" \
|
||||
https://api.github.com/repos/$GH_OWNER/$GH_REPO/pulls/$PR_NUMBER/files
|
||||
```
|
||||
|
||||
### Step 3: Check out the PR locally
|
||||
|
||||
This gives you full access to `read_file`, `search_files`, and the ability to run tests.
|
||||
|
||||
```bash
|
||||
git fetch origin pull/$PR_NUMBER/head:pr-$PR_NUMBER
|
||||
git checkout pr-$PR_NUMBER
|
||||
```
|
||||
|
||||
### Step 4: Read the diff and understand changes
|
||||
|
||||
```bash
|
||||
# Full diff against the base branch
|
||||
git diff main...HEAD
|
||||
|
||||
# Or file-by-file for large PRs
|
||||
git diff main...HEAD --name-only
|
||||
# Then for each file:
|
||||
git diff main...HEAD -- path/to/file.py
|
||||
```
|
||||
|
||||
For each changed file, use `read_file` to see full context around the changes — diffs alone can miss issues visible only with surrounding code.
|
||||
|
||||
### Step 5: Run automated checks locally (if applicable)
|
||||
|
||||
```bash
|
||||
# Run tests if there's a test suite
|
||||
python -m pytest 2>&1 | tail -20
|
||||
# or: npm test, cargo test, go test ./..., etc.
|
||||
|
||||
# Run linter if configured
|
||||
ruff check . 2>&1 | head -30
|
||||
# or: eslint, clippy, etc.
|
||||
```
|
||||
|
||||
### Step 6: Apply the review checklist (Section 3)
|
||||
|
||||
Go through each category: Correctness, Security, Code Quality, Testing, Performance, Documentation.
|
||||
|
||||
### Step 7: Post the review to GitHub
|
||||
|
||||
Collect your findings and submit them as a formal review with inline comments.
|
||||
|
||||
**With gh:**
|
||||
```bash
|
||||
# If no issues — approve
|
||||
gh pr review $PR_NUMBER --approve --body "Reviewed by Hermes Agent. Code looks clean — good test coverage, no security concerns."
|
||||
|
||||
# If issues found — request changes with inline comments
|
||||
gh pr review $PR_NUMBER --request-changes --body "Found a few issues — see inline comments."
|
||||
```
|
||||
|
||||
**With curl — atomic review with multiple inline comments:**
|
||||
```bash
|
||||
HEAD_SHA=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \
|
||||
https://api.github.com/repos/$GH_OWNER/$GH_REPO/pulls/$PR_NUMBER \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['head']['sha'])")
|
||||
|
||||
# Build the review JSON — event is APPROVE, REQUEST_CHANGES, or COMMENT
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token $GITHUB_TOKEN" \
|
||||
https://api.github.com/repos/$GH_OWNER/$GH_REPO/pulls/$PR_NUMBER/reviews \
|
||||
-d "{
|
||||
\"commit_id\": \"$HEAD_SHA\",
|
||||
\"event\": \"REQUEST_CHANGES\",
|
||||
\"body\": \"## Hermes Agent Review\n\nFound 2 issues, 1 suggestion. See inline comments.\",
|
||||
\"comments\": [
|
||||
{\"path\": \"src/auth.py\", \"line\": 45, \"body\": \"🔴 **Critical:** User input passed directly to SQL query — use parameterized queries.\"},
|
||||
{\"path\": \"src/models.py\", \"line\": 23, \"body\": \"⚠️ **Warning:** Password stored without hashing.\"},
|
||||
{\"path\": \"src/utils.py\", \"line\": 8, \"body\": \"💡 **Suggestion:** This duplicates logic in core/utils.py:34.\"}
|
||||
]
|
||||
}"
|
||||
```
|
||||
|
||||
### Step 8: Also post a summary comment
|
||||
|
||||
In addition to inline comments, leave a top-level summary so the PR author gets the full picture at a glance. Use the review output format from `references/review-output-template.md`.
|
||||
|
||||
**With gh:**
|
||||
```bash
|
||||
gh pr comment $PR_NUMBER --body "$(cat <<'EOF'
|
||||
## Code Review Summary
|
||||
|
||||
**Verdict: Changes Requested** (2 issues, 1 suggestion)
|
||||
|
||||
### 🔴 Critical
|
||||
- **src/auth.py:45** — SQL injection vulnerability
|
||||
|
||||
### ⚠️ Warnings
|
||||
- **src/models.py:23** — Plaintext password storage
|
||||
|
||||
### 💡 Suggestions
|
||||
- **src/utils.py:8** — Duplicated logic, consider consolidating
|
||||
|
||||
### ✅ Looks Good
|
||||
- Clean API design
|
||||
- Good error handling in the middleware layer
|
||||
|
||||
---
|
||||
*Reviewed by Hermes Agent*
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### Step 9: Clean up
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git branch -D pr-$PR_NUMBER
|
||||
```
|
||||
| Task | MCP Tool |
|
||||
|------|----------|
|
||||
| Get PR details | `mcp_github_pull_request_read(method="get")` |
|
||||
| Get PR diff | `mcp_github_pull_request_read(method="get_diff")` |
|
||||
| Get changed files | `mcp_github_pull_request_read(method="get_files")` |
|
||||
| Get CI status | `mcp_github_pull_request_read(method="get_status")` |
|
||||
| Get check runs | `mcp_github_pull_request_read(method="get_check_runs")` |
|
||||
| Read file contents | `mcp_github_get_file_contents(ref="refs/pull/N/head")` |
|
||||
| Get review threads | `mcp_github_pull_request_read(method="get_review_comments")` |
|
||||
| Get PR comments | `mcp_github_pull_request_read(method="get_comments")` |
|
||||
| Get reviews | `mcp_github_pull_request_read(method="get_reviews")` |
|
||||
| Create pending review | `mcp_github_pull_request_review_write(method="create")` |
|
||||
| Add inline comment | `mcp_github_add_comment_to_pending_review()` |
|
||||
| Submit review | `mcp_github_pull_request_review_write(method="submit_pending")` |
|
||||
| Add PR comment | `mcp_github_add_issue_comment()` |
|
||||
| Reply to comment | `mcp_github_add_reply_to_pull_request_comment()` |
|
||||
| Scan for secrets | `mcp_github_run_secret_scanning()` |
|
||||
| Request Copilot review | `mcp_github_request_copilot_review()` |
|
||||
|
||||
### Decision: Approve vs Request Changes vs Comment
|
||||
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
"""Tests for gateway MCP config watcher — auto-reload on mcp_servers changes."""
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
|
||||
def _make_runner(tmp_path, mcp_servers=None):
|
||||
"""Create a minimal GatewayRunner with mocked MCP config watcher state."""
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._running = True
|
||||
runner._mcp_config_servers = mcp_servers or {}
|
||||
|
||||
cfg_file = tmp_path / "config.yaml"
|
||||
cfg_file.write_text(yaml.dump({"mcp_servers": mcp_servers or {}}))
|
||||
runner._mcp_config_mtime = cfg_file.stat().st_mtime
|
||||
|
||||
return runner, cfg_file
|
||||
|
||||
|
||||
class TestMCPConfigWatcher:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_change_does_not_reload(self, tmp_path):
|
||||
"""If config file hasn't changed, no MCP reload should happen."""
|
||||
runner, cfg_file = _make_runner(tmp_path, mcp_servers={
|
||||
"betterstack": {"url": "https://mcp.betterstack.com", "headers": {"Authorization": "Bearer old"}}
|
||||
})
|
||||
|
||||
reload_called = False
|
||||
|
||||
async def fake_watcher_iteration():
|
||||
nonlocal reload_called
|
||||
from hermes_cli.config import get_config_path
|
||||
import yaml as _yaml
|
||||
|
||||
cfg_path = cfg_file
|
||||
mtime = cfg_path.stat().st_mtime
|
||||
|
||||
if mtime == runner._mcp_config_mtime:
|
||||
return # No change — fast path
|
||||
|
||||
runner._mcp_config_mtime = mtime
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
new_cfg = _yaml.safe_load(f) or {}
|
||||
|
||||
new_mcp = new_cfg.get("mcp_servers") or {}
|
||||
if new_mcp == runner._mcp_config_servers:
|
||||
return
|
||||
|
||||
reload_called = True
|
||||
|
||||
await fake_watcher_iteration()
|
||||
assert not reload_called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_header_change_triggers_reload(self, tmp_path):
|
||||
"""When Authorization header changes, reload should be triggered."""
|
||||
old_servers = {
|
||||
"betterstack": {"url": "https://mcp.betterstack.com", "headers": {"Authorization": "Bearer old_token"}}
|
||||
}
|
||||
runner, cfg_file = _make_runner(tmp_path, mcp_servers=old_servers)
|
||||
|
||||
# Simulate token refresh updating the config
|
||||
new_servers = {
|
||||
"betterstack": {"url": "https://mcp.betterstack.com", "headers": {"Authorization": "Bearer new_token"}}
|
||||
}
|
||||
cfg_file.write_text(yaml.dump({"mcp_servers": new_servers}))
|
||||
|
||||
# Force mtime to look different
|
||||
runner._mcp_config_mtime = 0.0
|
||||
|
||||
reload_triggered = False
|
||||
|
||||
# Simulate one iteration of the watcher's core logic
|
||||
mtime = cfg_file.stat().st_mtime
|
||||
assert mtime != runner._mcp_config_mtime
|
||||
|
||||
runner._mcp_config_mtime = mtime
|
||||
with open(cfg_file, encoding="utf-8") as f:
|
||||
new_cfg = yaml.safe_load(f) or {}
|
||||
|
||||
new_mcp = new_cfg.get("mcp_servers") or {}
|
||||
if new_mcp != runner._mcp_config_servers:
|
||||
reload_triggered = True
|
||||
runner._mcp_config_servers = new_mcp
|
||||
|
||||
assert reload_triggered
|
||||
assert runner._mcp_config_servers == new_servers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_mcp_change_does_not_reload(self, tmp_path):
|
||||
"""If a non-MCP section changes but mcp_servers stays the same, no reload."""
|
||||
servers = {
|
||||
"betterstack": {"url": "https://mcp.betterstack.com", "headers": {"Authorization": "Bearer tok"}}
|
||||
}
|
||||
runner, cfg_file = _make_runner(tmp_path, mcp_servers=servers)
|
||||
|
||||
# Write same mcp_servers but change something else
|
||||
cfg_file.write_text(yaml.dump({
|
||||
"mcp_servers": servers,
|
||||
"some_other_setting": "changed"
|
||||
}))
|
||||
runner._mcp_config_mtime = 0.0 # force stale mtime
|
||||
|
||||
mtime = cfg_file.stat().st_mtime
|
||||
runner._mcp_config_mtime = mtime
|
||||
with open(cfg_file, encoding="utf-8") as f:
|
||||
new_cfg = yaml.safe_load(f) or {}
|
||||
|
||||
new_mcp = new_cfg.get("mcp_servers") or {}
|
||||
assert new_mcp == runner._mcp_config_servers # Should be unchanged
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_added_triggers_reload(self, tmp_path):
|
||||
"""Adding a new MCP server to config triggers reload."""
|
||||
runner, cfg_file = _make_runner(tmp_path, mcp_servers={})
|
||||
|
||||
new_servers = {"github": {"url": "https://api.github.com/mcp"}}
|
||||
cfg_file.write_text(yaml.dump({"mcp_servers": new_servers}))
|
||||
runner._mcp_config_mtime = 0.0
|
||||
|
||||
mtime = cfg_file.stat().st_mtime
|
||||
runner._mcp_config_mtime = mtime
|
||||
with open(cfg_file, encoding="utf-8") as f:
|
||||
new_cfg = yaml.safe_load(f) or {}
|
||||
|
||||
new_mcp = new_cfg.get("mcp_servers") or {}
|
||||
assert new_mcp != runner._mcp_config_servers
|
||||
runner._mcp_config_servers = new_mcp
|
||||
assert runner._mcp_config_servers == new_servers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_removed_triggers_reload(self, tmp_path):
|
||||
"""Removing an MCP server from config triggers reload."""
|
||||
runner, cfg_file = _make_runner(tmp_path, mcp_servers={
|
||||
"github": {"url": "https://api.github.com/mcp"}
|
||||
})
|
||||
|
||||
cfg_file.write_text(yaml.dump({"mcp_servers": {}}))
|
||||
runner._mcp_config_mtime = 0.0
|
||||
|
||||
mtime = cfg_file.stat().st_mtime
|
||||
runner._mcp_config_mtime = mtime
|
||||
with open(cfg_file, encoding="utf-8") as f:
|
||||
new_cfg = yaml.safe_load(f) or {}
|
||||
|
||||
new_mcp = new_cfg.get("mcp_servers") or {}
|
||||
assert new_mcp != runner._mcp_config_servers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watcher_stops_on_shutdown(self, tmp_path):
|
||||
"""Watcher loop exits when _running is set to False."""
|
||||
runner, cfg_file = _make_runner(tmp_path)
|
||||
runner._running = False
|
||||
|
||||
# The watcher should return almost immediately
|
||||
# We test it doesn't hang by using a timeout
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
runner._mcp_config_watcher(interval=1, _initial_delay=0),
|
||||
timeout=5.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
pytest.fail("_mcp_config_watcher did not exit after _running=False")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_watcher_detects_change_and_reloads(self, tmp_path):
|
||||
"""Integration test: watcher detects a header change and calls MCP reload."""
|
||||
old_servers = {
|
||||
"betterstack": {"url": "https://mcp.betterstack.com", "headers": {"Authorization": "Bearer old"}}
|
||||
}
|
||||
runner, cfg_file = _make_runner(tmp_path, mcp_servers=old_servers)
|
||||
|
||||
# Prepare the config change that will happen during the watcher run
|
||||
new_servers = {
|
||||
"betterstack": {"url": "https://mcp.betterstack.com", "headers": {"Authorization": "Bearer new"}}
|
||||
}
|
||||
|
||||
shutdown_mock = MagicMock()
|
||||
discover_mock = MagicMock(return_value=[{"function": {"name": "test_tool"}}])
|
||||
servers_dict = {"betterstack": MagicMock()}
|
||||
lock_mock = MagicMock()
|
||||
|
||||
async def stop_after_reload():
|
||||
"""Write the config change, wait for the watcher to pick it up, then stop."""
|
||||
await asyncio.sleep(0.5)
|
||||
cfg_file.write_text(yaml.dump({"mcp_servers": new_servers}))
|
||||
# Wait enough time for the watcher to detect + reload
|
||||
await asyncio.sleep(4)
|
||||
runner._running = False
|
||||
|
||||
with patch("hermes_cli.config.get_config_path", return_value=cfg_file), \
|
||||
patch("tools.mcp_tool.shutdown_mcp_servers", shutdown_mock), \
|
||||
patch("tools.mcp_tool.discover_mcp_tools", discover_mock), \
|
||||
patch("tools.mcp_tool._servers", servers_dict), \
|
||||
patch("tools.mcp_tool._lock", lock_mock):
|
||||
|
||||
stop_task = asyncio.create_task(stop_after_reload())
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
runner._mcp_config_watcher(interval=1, _initial_delay=0),
|
||||
timeout=10.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
runner._running = False
|
||||
|
||||
await stop_task
|
||||
|
||||
shutdown_mock.assert_called_once()
|
||||
discover_mock.assert_called_once()
|
||||
assert runner._mcp_config_servers == new_servers
|
||||
@@ -28,12 +28,16 @@ class _FakeRegistry:
|
||||
|
||||
def __init__(self, sessions):
|
||||
self._sessions = list(sessions)
|
||||
self._completion_consumed: set = set()
|
||||
|
||||
def get(self, session_id):
|
||||
if self._sessions:
|
||||
return self._sessions.pop(0)
|
||||
return None
|
||||
|
||||
def is_completion_consumed(self, session_id):
|
||||
return session_id in self._completion_consumed
|
||||
|
||||
|
||||
def _build_runner(monkeypatch, tmp_path) -> GatewayRunner:
|
||||
"""Create a GatewayRunner with notifications set to 'all'."""
|
||||
|
||||
@@ -64,13 +64,44 @@ class TestWeixinFormatting:
|
||||
|
||||
|
||||
class TestWeixinChunking:
|
||||
def test_split_text_keeps_short_multiline_message_in_single_chunk(self):
|
||||
def test_split_text_splits_short_chatty_replies_into_separate_bubbles(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
content = adapter.format_message("第一行\n第二行\n第三行")
|
||||
chunks = adapter._split_text(content)
|
||||
|
||||
assert chunks == ["第一行\n第二行\n第三行"]
|
||||
assert chunks == ["第一行", "第二行", "第三行"]
|
||||
|
||||
def test_split_text_keeps_structured_table_block_together(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
content = adapter.format_message(
|
||||
"- Setting: Timeout\n Value: 30s\n- Setting: Retries\n Value: 3"
|
||||
)
|
||||
chunks = adapter._split_text(content)
|
||||
|
||||
assert chunks == ["- Setting: Timeout\n Value: 30s\n- Setting: Retries\n Value: 3"]
|
||||
|
||||
def test_split_text_keeps_four_line_structured_blocks_together(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
content = adapter.format_message(
|
||||
"今天结论:\n"
|
||||
"- 留存下降 3%\n"
|
||||
"- 转化上涨 8%\n"
|
||||
"- 主要问题在首日激活"
|
||||
)
|
||||
chunks = adapter._split_text(content)
|
||||
|
||||
assert chunks == ["今天结论:\n- 留存下降 3%\n- 转化上涨 8%\n- 主要问题在首日激活"]
|
||||
|
||||
def test_split_text_keeps_heading_with_body_together(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
content = adapter.format_message("## 结论\n这是正文")
|
||||
chunks = adapter._split_text(content)
|
||||
|
||||
assert chunks == ["**结论**\n这是正文"]
|
||||
|
||||
def test_split_text_keeps_short_reformatted_table_in_single_chunk(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
@@ -58,13 +58,13 @@ class TestFindOpenclawDirs:
|
||||
def test_finds_legacy_dirs(self, tmp_path):
|
||||
clawdbot = tmp_path / ".clawdbot"
|
||||
clawdbot.mkdir()
|
||||
moldbot = tmp_path / ".moldbot"
|
||||
moldbot.mkdir()
|
||||
moltbot = tmp_path / ".moltbot"
|
||||
moltbot.mkdir()
|
||||
with patch("pathlib.Path.home", return_value=tmp_path):
|
||||
found = claw_mod._find_openclaw_dirs()
|
||||
assert len(found) == 2
|
||||
assert clawdbot in found
|
||||
assert moldbot in found
|
||||
assert moltbot in found
|
||||
|
||||
def test_returns_empty_when_none_exist(self, tmp_path):
|
||||
with patch("pathlib.Path.home", return_value=tmp_path):
|
||||
@@ -297,7 +297,6 @@ class TestCmdMigrate:
|
||||
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
||||
patch.object(claw_mod, "get_config_path", return_value=config_path),
|
||||
patch.object(claw_mod, "prompt_yes_no", return_value=True),
|
||||
patch.object(claw_mod, "_offer_source_archival"),
|
||||
patch("sys.stdin", mock_stdin),
|
||||
):
|
||||
claw_mod._cmd_migrate(args)
|
||||
@@ -306,43 +305,8 @@ class TestCmdMigrate:
|
||||
assert "Migration Results" in captured.out
|
||||
assert "Migration complete!" in captured.out
|
||||
|
||||
def test_execute_offers_archival_on_success(self, tmp_path, capsys):
|
||||
"""After successful migration, _offer_source_archival should be called."""
|
||||
openclaw_dir = tmp_path / ".openclaw"
|
||||
openclaw_dir.mkdir()
|
||||
|
||||
fake_mod = ModuleType("openclaw_to_hermes")
|
||||
fake_mod.resolve_selected_options = MagicMock(return_value={"soul"})
|
||||
fake_migrator = MagicMock()
|
||||
fake_migrator.migrate.return_value = {
|
||||
"summary": {"migrated": 3, "skipped": 0, "conflict": 0, "error": 0},
|
||||
"items": [
|
||||
{"kind": "soul", "status": "migrated", "destination": str(tmp_path / "SOUL.md")},
|
||||
],
|
||||
}
|
||||
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
||||
|
||||
args = Namespace(
|
||||
source=str(openclaw_dir),
|
||||
dry_run=False, preset="full", overwrite=False,
|
||||
migrate_secrets=False, workspace_target=None,
|
||||
skill_conflict="skip", yes=True,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
||||
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
||||
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
|
||||
patch.object(claw_mod, "save_config"),
|
||||
patch.object(claw_mod, "load_config", return_value={}),
|
||||
patch.object(claw_mod, "_offer_source_archival") as mock_archival,
|
||||
):
|
||||
claw_mod._cmd_migrate(args)
|
||||
|
||||
mock_archival.assert_called_once_with(openclaw_dir, True)
|
||||
|
||||
def test_dry_run_skips_archival(self, tmp_path, capsys):
|
||||
"""Dry run should not offer archival."""
|
||||
def test_dry_run_does_not_touch_source(self, tmp_path, capsys):
|
||||
"""Dry run should not modify the source directory."""
|
||||
openclaw_dir = tmp_path / ".openclaw"
|
||||
openclaw_dir.mkdir()
|
||||
|
||||
@@ -369,11 +333,10 @@ class TestCmdMigrate:
|
||||
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
|
||||
patch.object(claw_mod, "save_config"),
|
||||
patch.object(claw_mod, "load_config", return_value={}),
|
||||
patch.object(claw_mod, "_offer_source_archival") as mock_archival,
|
||||
):
|
||||
claw_mod._cmd_migrate(args)
|
||||
|
||||
mock_archival.assert_not_called()
|
||||
assert openclaw_dir.is_dir() # Source untouched
|
||||
|
||||
def test_execute_cancelled_by_user(self, tmp_path, capsys):
|
||||
openclaw_dir = tmp_path / ".openclaw"
|
||||
@@ -506,73 +469,6 @@ class TestCmdMigrate:
|
||||
assert call_kwargs["migrate_secrets"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _offer_source_archival
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOfferSourceArchival:
|
||||
"""Test the post-migration archival offer."""
|
||||
|
||||
def test_archives_with_auto_yes(self, tmp_path, capsys):
|
||||
source = tmp_path / ".openclaw"
|
||||
source.mkdir()
|
||||
(source / "workspace").mkdir()
|
||||
(source / "workspace" / "todo.json").write_text("{}")
|
||||
|
||||
claw_mod._offer_source_archival(source, auto_yes=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Archived" in captured.out
|
||||
assert not source.exists()
|
||||
assert (tmp_path / ".openclaw.pre-migration").is_dir()
|
||||
|
||||
def test_skips_when_user_declines(self, tmp_path, capsys):
|
||||
source = tmp_path / ".openclaw"
|
||||
source.mkdir()
|
||||
|
||||
mock_stdin = MagicMock()
|
||||
mock_stdin.isatty.return_value = True
|
||||
|
||||
with (
|
||||
patch.object(claw_mod, "prompt_yes_no", return_value=False),
|
||||
patch("sys.stdin", mock_stdin),
|
||||
):
|
||||
claw_mod._offer_source_archival(source, auto_yes=False)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Skipped" in captured.out
|
||||
assert source.is_dir() # Still exists
|
||||
|
||||
def test_noop_when_source_missing(self, tmp_path, capsys):
|
||||
claw_mod._offer_source_archival(tmp_path / "nonexistent", auto_yes=True)
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == "" # No output
|
||||
|
||||
def test_shows_state_files(self, tmp_path, capsys):
|
||||
source = tmp_path / ".openclaw"
|
||||
source.mkdir()
|
||||
ws = source / "workspace"
|
||||
ws.mkdir()
|
||||
(ws / "todo.json").write_text("{}")
|
||||
|
||||
with patch.object(claw_mod, "prompt_yes_no", return_value=False):
|
||||
claw_mod._offer_source_archival(source, auto_yes=False)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "todo.json" in captured.out
|
||||
|
||||
def test_handles_archive_error(self, tmp_path, capsys):
|
||||
source = tmp_path / ".openclaw"
|
||||
source.mkdir()
|
||||
|
||||
with patch.object(claw_mod, "_archive_directory", side_effect=OSError("permission denied")):
|
||||
claw_mod._offer_source_archival(source, auto_yes=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Could not archive" in captured.out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _cmd_cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
241
tests/hermes_cli/test_codex_cli_model_picker.py
Normal file
241
tests/hermes_cli/test_codex_cli_model_picker.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Regression test: openai-codex must appear in /model picker when
|
||||
credentials are only in the Codex CLI shared file (~/.codex/auth.json)
|
||||
and haven't been migrated to the Hermes auth store yet.
|
||||
|
||||
Root cause: list_authenticated_providers() checked the raw Hermes auth
|
||||
store but didn't know about the Codex CLI fallback import path.
|
||||
|
||||
Fix: _seed_from_singletons() now imports from the Codex CLI when the
|
||||
Hermes auth store has no openai-codex tokens, and
|
||||
list_authenticated_providers() falls back to load_pool() for OAuth
|
||||
providers.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_fake_jwt(expiry_offset: int = 3600) -> str:
|
||||
"""Build a fake JWT with a future expiry."""
|
||||
header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=").decode()
|
||||
exp = int(time.time()) + expiry_offset
|
||||
payload_bytes = json.dumps({"exp": exp, "sub": "test"}).encode()
|
||||
payload = base64.urlsafe_b64encode(payload_bytes).rstrip(b"=").decode()
|
||||
return f"{header}.{payload}.fakesig"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def codex_cli_only_env(tmp_path, monkeypatch):
|
||||
"""Set up an environment where Codex tokens exist only in ~/.codex/auth.json,
|
||||
NOT in the Hermes auth store."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
codex_home = tmp_path / ".codex"
|
||||
codex_home.mkdir()
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
|
||||
# Empty Hermes auth store
|
||||
(hermes_home / "auth.json").write_text(
|
||||
json.dumps({"version": 2, "providers": {}})
|
||||
)
|
||||
|
||||
# Valid Codex CLI tokens
|
||||
fake_jwt = _make_fake_jwt()
|
||||
(codex_home / "auth.json").write_text(
|
||||
json.dumps({
|
||||
"tokens": {
|
||||
"access_token": fake_jwt,
|
||||
"refresh_token": "fake-refresh-token",
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
# Clear provider env vars so only OAuth is a detection path
|
||||
for var in [
|
||||
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"NOUS_API_KEY", "DEEPSEEK_API_KEY", "COPILOT_GITHUB_TOKEN",
|
||||
"GH_TOKEN", "GEMINI_API_KEY",
|
||||
]:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
return hermes_home
|
||||
|
||||
|
||||
def test_codex_cli_tokens_detected_by_model_picker(codex_cli_only_env):
|
||||
"""openai-codex should appear when tokens only exist in ~/.codex/auth.json."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="openai-codex",
|
||||
max_models=10,
|
||||
)
|
||||
slugs = [p["slug"] for p in providers]
|
||||
assert "openai-codex" in slugs, (
|
||||
f"openai-codex not found in /model picker providers: {slugs}"
|
||||
)
|
||||
|
||||
codex = next(p for p in providers if p["slug"] == "openai-codex")
|
||||
assert codex["is_current"] is True
|
||||
assert codex["total_models"] > 0
|
||||
|
||||
|
||||
def test_codex_cli_tokens_migrated_after_detection(codex_cli_only_env):
|
||||
"""After the /model picker detects Codex CLI tokens, they should be
|
||||
migrated into the Hermes auth store for subsequent fast lookups."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
# First call triggers migration
|
||||
list_authenticated_providers(current_provider="openai-codex")
|
||||
|
||||
# Verify tokens are now in Hermes auth store
|
||||
auth_path = codex_cli_only_env / "auth.json"
|
||||
store = json.loads(auth_path.read_text())
|
||||
providers = store.get("providers", {})
|
||||
assert "openai-codex" in providers, (
|
||||
f"openai-codex not migrated to Hermes auth store: {list(providers.keys())}"
|
||||
)
|
||||
tokens = providers["openai-codex"].get("tokens", {})
|
||||
assert tokens.get("access_token"), "access_token missing after migration"
|
||||
assert tokens.get("refresh_token"), "refresh_token missing after migration"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def hermes_auth_only_env(tmp_path, monkeypatch):
|
||||
"""Tokens already in Hermes auth store (no Codex CLI needed)."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
# Point CODEX_HOME to nonexistent dir to prove it's not needed
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex"))
|
||||
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 2,
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"tokens": {
|
||||
"access_token": _make_fake_jwt(),
|
||||
"refresh_token": "fake-refresh",
|
||||
},
|
||||
"last_refresh": "2026-04-12T00:00:00Z",
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
for var in [
|
||||
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"NOUS_API_KEY", "DEEPSEEK_API_KEY",
|
||||
]:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
return hermes_home
|
||||
|
||||
|
||||
def test_normal_path_still_works(hermes_auth_only_env):
|
||||
"""openai-codex appears when tokens are already in Hermes auth store."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="openai-codex",
|
||||
max_models=10,
|
||||
)
|
||||
slugs = [p["slug"] for p in providers]
|
||||
assert "openai-codex" in slugs
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def claude_code_only_env(tmp_path, monkeypatch):
|
||||
"""Set up an environment where Anthropic credentials only exist in
|
||||
~/.claude/.credentials.json (Claude Code) — not in env vars or Hermes
|
||||
auth store."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
# No Codex CLI
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex"))
|
||||
|
||||
(hermes_home / "auth.json").write_text(
|
||||
json.dumps({"version": 2, "providers": {}})
|
||||
)
|
||||
|
||||
# Claude Code credentials in the correct format
|
||||
claude_dir = tmp_path / ".claude"
|
||||
claude_dir.mkdir()
|
||||
(claude_dir / ".credentials.json").write_text(json.dumps({
|
||||
"claudeAiOauth": {
|
||||
"accessToken": _make_fake_jwt(),
|
||||
"refreshToken": "fake-refresh",
|
||||
"expiresAt": int(time.time() * 1000) + 3_600_000,
|
||||
}
|
||||
}))
|
||||
|
||||
# Patch Path.home() so the adapter finds the file
|
||||
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
|
||||
|
||||
for var in [
|
||||
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"NOUS_API_KEY", "DEEPSEEK_API_KEY",
|
||||
]:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
return hermes_home
|
||||
|
||||
|
||||
def test_claude_code_file_detected_by_model_picker(claude_code_only_env):
|
||||
"""anthropic should appear when credentials only exist in ~/.claude/.credentials.json."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="anthropic",
|
||||
max_models=10,
|
||||
)
|
||||
slugs = [p["slug"] for p in providers]
|
||||
assert "anthropic" in slugs, (
|
||||
f"anthropic not found in /model picker providers: {slugs}"
|
||||
)
|
||||
|
||||
anthropic = next(p for p in providers if p["slug"] == "anthropic")
|
||||
assert anthropic["is_current"] is True
|
||||
assert anthropic["total_models"] > 0
|
||||
|
||||
|
||||
def test_no_codex_when_no_credentials(tmp_path, monkeypatch):
|
||||
"""openai-codex should NOT appear when no credentials exist anywhere."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex"))
|
||||
|
||||
(hermes_home / "auth.json").write_text(
|
||||
json.dumps({"version": 2, "providers": {}})
|
||||
)
|
||||
|
||||
for var in [
|
||||
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"NOUS_API_KEY", "DEEPSEEK_API_KEY", "COPILOT_GITHUB_TOKEN",
|
||||
"GH_TOKEN", "GEMINI_API_KEY",
|
||||
]:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="openrouter",
|
||||
max_models=10,
|
||||
)
|
||||
slugs = [p["slug"] for p in providers]
|
||||
assert "openai-codex" not in slugs, (
|
||||
"openai-codex should not appear without any credentials"
|
||||
)
|
||||
77
tests/hermes_cli/test_tips.py
Normal file
77
tests/hermes_cli/test_tips.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Tests for hermes_cli/tips.py — random tip display at session start."""
|
||||
|
||||
import pytest
|
||||
from hermes_cli.tips import TIPS, get_random_tip, get_tip_count
|
||||
|
||||
|
||||
class TestTipsCorpus:
|
||||
"""Validate the tip corpus itself."""
|
||||
|
||||
def test_has_at_least_200_tips(self):
|
||||
assert len(TIPS) >= 200, f"Expected 200+ tips, got {len(TIPS)}"
|
||||
|
||||
def test_no_duplicates(self):
|
||||
assert len(TIPS) == len(set(TIPS)), "Duplicate tips found"
|
||||
|
||||
def test_all_tips_are_strings(self):
|
||||
for i, tip in enumerate(TIPS):
|
||||
assert isinstance(tip, str), f"Tip {i} is not a string: {type(tip)}"
|
||||
|
||||
def test_no_empty_tips(self):
|
||||
for i, tip in enumerate(TIPS):
|
||||
assert tip.strip(), f"Tip {i} is empty or whitespace-only"
|
||||
|
||||
def test_max_length_reasonable(self):
|
||||
"""Tips should fit on a single terminal line (~120 chars max)."""
|
||||
for i, tip in enumerate(TIPS):
|
||||
assert len(tip) <= 150, (
|
||||
f"Tip {i} too long ({len(tip)} chars): {tip[:60]}..."
|
||||
)
|
||||
|
||||
def test_no_leading_trailing_whitespace(self):
|
||||
for i, tip in enumerate(TIPS):
|
||||
assert tip == tip.strip(), f"Tip {i} has leading/trailing whitespace"
|
||||
|
||||
|
||||
class TestGetRandomTip:
|
||||
"""Validate the get_random_tip() function."""
|
||||
|
||||
def test_returns_string(self):
|
||||
tip = get_random_tip()
|
||||
assert isinstance(tip, str)
|
||||
assert len(tip) > 0
|
||||
|
||||
def test_returns_tip_from_corpus(self):
|
||||
tip = get_random_tip()
|
||||
assert tip in TIPS
|
||||
|
||||
def test_randomness(self):
|
||||
"""Multiple calls should eventually return different tips."""
|
||||
seen = set()
|
||||
for _ in range(50):
|
||||
seen.add(get_random_tip())
|
||||
# With 200+ tips and 50 draws, we should see at least 10 unique
|
||||
assert len(seen) >= 10, f"Only got {len(seen)} unique tips in 50 draws"
|
||||
|
||||
|
||||
class TestGetTipCount:
|
||||
def test_matches_corpus_length(self):
|
||||
assert get_tip_count() == len(TIPS)
|
||||
|
||||
|
||||
class TestTipIntegrationInCLI:
|
||||
"""Test that the tip display code in cli.py works correctly."""
|
||||
|
||||
def test_tip_import_works(self):
|
||||
"""The import used in cli.py must succeed."""
|
||||
from hermes_cli.tips import get_random_tip
|
||||
assert callable(get_random_tip)
|
||||
|
||||
def test_tip_display_format(self):
|
||||
"""Verify the Rich markup format doesn't break."""
|
||||
tip = get_random_tip()
|
||||
color = "#B8860B"
|
||||
markup = f"[dim {color}]✦ Tip: {tip}[/]"
|
||||
# Should not contain nested/broken Rich tags
|
||||
assert markup.count("[/]") == 1
|
||||
assert "[dim #B8860B]" in markup
|
||||
@@ -185,6 +185,38 @@ def test_migrator_optionally_imports_supported_secrets_and_messaging_settings(tm
|
||||
assert "TELEGRAM_BOT_TOKEN=123:abc" in env_text
|
||||
|
||||
|
||||
def test_messaging_cwd_skipped_when_inside_source(tmp_path: Path):
|
||||
"""MESSAGING_CWD pointing inside the OpenClaw source dir should be skipped."""
|
||||
mod = load_module()
|
||||
source = tmp_path / ".openclaw"
|
||||
target = tmp_path / ".hermes"
|
||||
target.mkdir()
|
||||
|
||||
# Workspace path is inside the source directory
|
||||
ws_path = str(source / "workspace")
|
||||
(source / "credentials").mkdir(parents=True)
|
||||
(source / "openclaw.json").write_text(
|
||||
json.dumps({"agents": {"defaults": {"workspace": ws_path}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
migrator = mod.Migrator(
|
||||
source_root=source,
|
||||
target_root=target,
|
||||
execute=True,
|
||||
workspace_target=None,
|
||||
overwrite=False,
|
||||
migrate_secrets=True,
|
||||
output_dir=target / "migration-report",
|
||||
selected_options={"messaging-settings"},
|
||||
)
|
||||
migrator.migrate()
|
||||
|
||||
env_path = target / ".env"
|
||||
if env_path.exists():
|
||||
assert "MESSAGING_CWD" not in env_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_migrator_can_execute_only_selected_categories(tmp_path: Path):
|
||||
mod = load_module()
|
||||
source = tmp_path / ".openclaw"
|
||||
@@ -722,3 +754,98 @@ def test_skill_installs_cleanly_under_skills_guard():
|
||||
KNOWN_FALSE_POSITIVES = {"agent_config_mod", "python_os_environ", "hermes_config_mod"}
|
||||
for f in result.findings:
|
||||
assert f.pattern_id in KNOWN_FALSE_POSITIVES, f"Unexpected finding: {f}"
|
||||
|
||||
|
||||
# ── rebrand_text tests ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_rebrand_text_replaces_openclaw_variants():
|
||||
mod = load_module()
|
||||
assert mod.rebrand_text("OpenClaw prefers Python 3.11") == "Hermes prefers Python 3.11"
|
||||
assert mod.rebrand_text("I told Open Claw to use dark mode") == "I told Hermes to use dark mode"
|
||||
assert mod.rebrand_text("Open-Claw config is great") == "Hermes config is great"
|
||||
assert mod.rebrand_text("openclaw should always respond concisely") == "Hermes should always respond concisely"
|
||||
assert mod.rebrand_text("OPENCLAW uses tools well") == "Hermes uses tools well"
|
||||
|
||||
|
||||
def test_rebrand_text_replaces_legacy_bot_names():
|
||||
mod = load_module()
|
||||
assert mod.rebrand_text("ClawdBot remembers my timezone") == "Hermes remembers my timezone"
|
||||
assert mod.rebrand_text("clawdbot prefers tabs") == "Hermes prefers tabs"
|
||||
assert mod.rebrand_text("MoltBot was configured for Spanish") == "Hermes was configured for Spanish"
|
||||
assert mod.rebrand_text("moltbot uses Python") == "Hermes uses Python"
|
||||
|
||||
|
||||
def test_rebrand_text_preserves_unrelated_content():
|
||||
mod = load_module()
|
||||
text = "User prefers dark mode and lives in Las Vegas"
|
||||
assert mod.rebrand_text(text) == text
|
||||
|
||||
|
||||
def test_rebrand_text_handles_multiple_replacements():
|
||||
mod = load_module()
|
||||
text = "OpenClaw said to ask ClawdBot about MoltBot settings"
|
||||
assert mod.rebrand_text(text) == "Hermes said to ask Hermes about Hermes settings"
|
||||
|
||||
|
||||
def test_migrate_memory_rebrands_entries(tmp_path):
|
||||
mod = load_module()
|
||||
source_root = tmp_path / "openclaw"
|
||||
source_root.mkdir()
|
||||
workspace = source_root / "workspace"
|
||||
workspace.mkdir()
|
||||
memory_md = workspace / "MEMORY.md"
|
||||
memory_md.write_text(
|
||||
"# Memory\n\n- OpenClaw should use Python 3.11\n- ClawdBot prefers dark mode\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
target_root = tmp_path / "hermes"
|
||||
target_root.mkdir()
|
||||
(target_root / "memories").mkdir()
|
||||
|
||||
migrator = mod.Migrator(
|
||||
source_root=source_root,
|
||||
target_root=target_root,
|
||||
execute=True,
|
||||
workspace_target=None,
|
||||
overwrite=False,
|
||||
migrate_secrets=False,
|
||||
output_dir=tmp_path / "report",
|
||||
selected_options={"memory"},
|
||||
)
|
||||
migrator.migrate()
|
||||
|
||||
result = (target_root / "memories" / "MEMORY.md").read_text(encoding="utf-8")
|
||||
assert "OpenClaw" not in result
|
||||
assert "ClawdBot" not in result
|
||||
assert "Hermes" in result
|
||||
|
||||
|
||||
def test_migrate_soul_rebrands_content(tmp_path):
|
||||
mod = load_module()
|
||||
source_root = tmp_path / "openclaw"
|
||||
source_root.mkdir()
|
||||
workspace = source_root / "workspace"
|
||||
workspace.mkdir()
|
||||
soul_md = workspace / "SOUL.md"
|
||||
soul_md.write_text("You are OpenClaw, an AI assistant made by SparkLab.", encoding="utf-8")
|
||||
|
||||
target_root = tmp_path / "hermes"
|
||||
target_root.mkdir()
|
||||
|
||||
migrator = mod.Migrator(
|
||||
source_root=source_root,
|
||||
target_root=target_root,
|
||||
execute=True,
|
||||
workspace_target=None,
|
||||
overwrite=False,
|
||||
migrate_secrets=False,
|
||||
output_dir=tmp_path / "report",
|
||||
selected_options={"soul"},
|
||||
)
|
||||
migrator.migrate()
|
||||
|
||||
result = (target_root / "SOUL.md").read_text(encoding="utf-8")
|
||||
assert "OpenClaw" not in result
|
||||
assert "You are Hermes" in result
|
||||
|
||||
@@ -289,3 +289,62 @@ class TestCodeExecutionBlocked:
|
||||
def test_notify_on_complete_blocked_in_sandbox(self):
|
||||
from tools.code_execution_tool import _TERMINAL_BLOCKED_PARAMS
|
||||
assert "notify_on_complete" in _TERMINAL_BLOCKED_PARAMS
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Completion consumed suppression
|
||||
# =========================================================================
|
||||
|
||||
class TestCompletionConsumed:
|
||||
"""Test that wait/poll/log suppress redundant completion notifications."""
|
||||
|
||||
def test_wait_marks_completion_consumed(self, registry):
|
||||
"""wait() returning exited status marks session as consumed."""
|
||||
s = _make_session(sid="proc_wait", notify_on_complete=True, output="done")
|
||||
s.exited = True
|
||||
s.exit_code = 0
|
||||
registry._running[s.id] = s
|
||||
with patch.object(registry, "_write_checkpoint"):
|
||||
registry._move_to_finished(s)
|
||||
|
||||
# Notification is in the queue
|
||||
assert not registry.completion_queue.empty()
|
||||
assert not registry.is_completion_consumed("proc_wait")
|
||||
|
||||
# Agent calls wait() — gets the result directly
|
||||
result = registry.wait("proc_wait", timeout=1)
|
||||
assert result["status"] == "exited"
|
||||
|
||||
# Now the completion is marked as consumed
|
||||
assert registry.is_completion_consumed("proc_wait")
|
||||
|
||||
def test_poll_marks_completion_consumed(self, registry):
|
||||
"""poll() returning exited status marks session as consumed."""
|
||||
s = _make_session(sid="proc_poll", notify_on_complete=True, output="done")
|
||||
s.exited = True
|
||||
s.exit_code = 0
|
||||
registry._finished[s.id] = s
|
||||
|
||||
result = registry.poll("proc_poll")
|
||||
assert result["status"] == "exited"
|
||||
assert registry.is_completion_consumed("proc_poll")
|
||||
|
||||
def test_log_marks_completion_consumed(self, registry):
|
||||
"""read_log() on exited session marks as consumed."""
|
||||
s = _make_session(sid="proc_log", notify_on_complete=True, output="line1\nline2")
|
||||
s.exited = True
|
||||
s.exit_code = 0
|
||||
registry._finished[s.id] = s
|
||||
|
||||
result = registry.read_log("proc_log")
|
||||
assert result["status"] == "exited"
|
||||
assert registry.is_completion_consumed("proc_log")
|
||||
|
||||
def test_running_process_not_consumed(self, registry):
|
||||
"""poll() on a still-running process does not mark as consumed."""
|
||||
s = _make_session(sid="proc_running", notify_on_complete=True, output="partial")
|
||||
registry._running[s.id] = s
|
||||
|
||||
result = registry.poll("proc_running")
|
||||
assert result["status"] == "running"
|
||||
assert not registry.is_completion_consumed("proc_running")
|
||||
|
||||
@@ -25,6 +25,8 @@ import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from toolsets import TOOLSETS
|
||||
|
||||
|
||||
# Tools that children must never have access to
|
||||
DELEGATE_BLOCKED_TOOLS = frozenset([
|
||||
@@ -35,6 +37,18 @@ DELEGATE_BLOCKED_TOOLS = frozenset([
|
||||
"execute_code", # children should reason step-by-step, not write scripts
|
||||
])
|
||||
|
||||
# Build a description fragment listing toolsets available for subagents.
|
||||
# Excludes toolsets where ALL tools are blocked, composite/platform toolsets
|
||||
# (hermes-* prefixed), and scenario toolsets.
|
||||
_EXCLUDED_TOOLSET_NAMES = frozenset({"debugging", "safe", "delegation", "moa", "rl"})
|
||||
_SUBAGENT_TOOLSETS = sorted(
|
||||
name for name, defn in TOOLSETS.items()
|
||||
if name not in _EXCLUDED_TOOLSET_NAMES
|
||||
and not name.startswith("hermes-")
|
||||
and not all(t in DELEGATE_BLOCKED_TOOLS for t in defn.get("tools", []))
|
||||
)
|
||||
_TOOLSET_LIST_STR = ", ".join(f"'{n}'" for n in _SUBAGENT_TOOLSETS)
|
||||
|
||||
_DEFAULT_MAX_CONCURRENT_CHILDREN = 3
|
||||
MAX_DEPTH = 2 # parent (0) -> child (1) -> grandchild rejected (2)
|
||||
|
||||
@@ -999,9 +1013,10 @@ DELEGATE_TASK_SCHEMA = {
|
||||
"description": (
|
||||
"Toolsets to enable for this subagent. "
|
||||
"Default: inherits your enabled toolsets. "
|
||||
f"Available toolsets: {_TOOLSET_LIST_STR}. "
|
||||
"Common patterns: ['terminal', 'file'] for code work, "
|
||||
"['web'] for research, ['terminal', 'file', 'web'] for "
|
||||
"full-stack tasks."
|
||||
"['web'] for research, ['browser'] for web interaction, "
|
||||
"['terminal', 'file', 'web'] for full-stack tasks."
|
||||
),
|
||||
},
|
||||
"tasks": {
|
||||
@@ -1014,7 +1029,7 @@ DELEGATE_TASK_SCHEMA = {
|
||||
"toolsets": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Toolsets for this specific task. Use 'web' for network access, 'terminal' for shell.",
|
||||
"description": f"Toolsets for this specific task. Available: {_TOOLSET_LIST_STR}. Use 'web' for network access, 'terminal' for shell, 'browser' for web interaction.",
|
||||
},
|
||||
"acp_command": {
|
||||
"type": "string",
|
||||
|
||||
@@ -136,6 +136,10 @@ class ProcessRegistry:
|
||||
import queue as _queue_mod
|
||||
self.completion_queue: _queue_mod.Queue = _queue_mod.Queue()
|
||||
|
||||
# Track sessions whose completion was already consumed by the agent
|
||||
# via wait/poll/log. Drain loops skip notifications for these.
|
||||
self._completion_consumed: set = set()
|
||||
|
||||
@staticmethod
|
||||
def _clean_shell_noise(text: str) -> str:
|
||||
"""Strip shell startup warnings from the beginning of output."""
|
||||
@@ -613,6 +617,10 @@ class ProcessRegistry:
|
||||
|
||||
# ----- Query Methods -----
|
||||
|
||||
def is_completion_consumed(self, session_id: str) -> bool:
|
||||
"""Check if a completion notification was already consumed via wait/poll/log."""
|
||||
return session_id in self._completion_consumed
|
||||
|
||||
def get(self, session_id: str) -> Optional[ProcessSession]:
|
||||
"""Get a session by ID (running or finished)."""
|
||||
with self._lock:
|
||||
@@ -640,6 +648,7 @@ class ProcessRegistry:
|
||||
}
|
||||
if session.exited:
|
||||
result["exit_code"] = session.exit_code
|
||||
self._completion_consumed.add(session_id)
|
||||
if session.detached:
|
||||
result["detached"] = True
|
||||
result["note"] = "Process recovered after restart -- output history unavailable"
|
||||
@@ -665,13 +674,16 @@ class ProcessRegistry:
|
||||
else:
|
||||
selected = lines[offset:offset + limit]
|
||||
|
||||
return {
|
||||
result = {
|
||||
"session_id": session.id,
|
||||
"status": "exited" if session.exited else "running",
|
||||
"output": "\n".join(selected),
|
||||
"total_lines": total_lines,
|
||||
"showing": f"{len(selected)} lines",
|
||||
}
|
||||
if session.exited:
|
||||
self._completion_consumed.add(session_id)
|
||||
return result
|
||||
|
||||
def wait(self, session_id: str, timeout: int = None) -> dict:
|
||||
"""
|
||||
@@ -714,6 +726,7 @@ class ProcessRegistry:
|
||||
while time.monotonic() < deadline:
|
||||
session = self._refresh_detached_session(session)
|
||||
if session.exited:
|
||||
self._completion_consumed.add(session_id)
|
||||
result = {
|
||||
"status": "exited",
|
||||
"exit_code": session.exit_code,
|
||||
|
||||
@@ -23,7 +23,7 @@ hermes claw migrate --preset full --yes
|
||||
|
||||
The migration always shows a full preview of what will be imported before making any changes. Review the list, then confirm to proceed.
|
||||
|
||||
Reads from `~/.openclaw/` by default. Legacy `~/.clawdbot/` or `~/.moldbot/` directories are detected automatically. Same for legacy config filenames (`clawdbot.json`, `moldbot.json`).
|
||||
Reads from `~/.openclaw/` by default. Legacy `~/.clawdbot/` or `~/.moltbot/` directories are detected automatically. Same for legacy config filenames (`clawdbot.json`, `moltbot.json`).
|
||||
|
||||
## Options
|
||||
|
||||
@@ -234,7 +234,7 @@ The migration resolves all three formats. For env templates and SecretRef object
|
||||
|
||||
### "OpenClaw directory not found"
|
||||
|
||||
The migration checks `~/.openclaw/`, then `~/.clawdbot/`, then `~/.moldbot/`. If your installation is elsewhere, use `--source /path/to/your/openclaw`.
|
||||
The migration checks `~/.openclaw/`, then `~/.clawdbot/`, then `~/.moltbot/`. If your installation is elsewhere, use `--source /path/to/your/openclaw`.
|
||||
|
||||
### "No provider API keys found"
|
||||
|
||||
|
||||
@@ -660,7 +660,7 @@ hermes insights [--days N] [--source platform]
|
||||
hermes claw migrate [options]
|
||||
```
|
||||
|
||||
Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom path) and writes to `~/.hermes`. Automatically detects legacy directory names (`~/.clawdbot`, `~/.moldbot`) and config filenames (`clawdbot.json`, `moldbot.json`).
|
||||
Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom path) and writes to `~/.hermes`. Automatically detects legacy directory names (`~/.clawdbot`, `~/.moltbot`) and config filenames (`clawdbot.json`, `moltbot.json`).
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
|
||||
Reference in New Issue
Block a user