Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor
This commit is contained in:
@@ -1303,6 +1303,49 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _write_codex_cli_tokens(
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
*,
|
||||
last_refresh: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Write refreshed tokens back to ~/.codex/auth.json.
|
||||
|
||||
OpenAI OAuth refresh tokens are single-use and rotate on every refresh.
|
||||
When Hermes refreshes a token it consumes the old refresh_token; if we
|
||||
don't write the new pair back, the Codex CLI (or VS Code extension) will
|
||||
fail with ``refresh_token_reused`` on its next refresh attempt.
|
||||
|
||||
This mirrors the Anthropic write-back to ~/.claude/.credentials.json
|
||||
via ``_write_claude_code_credentials()``.
|
||||
"""
|
||||
codex_home = os.getenv("CODEX_HOME", "").strip()
|
||||
if not codex_home:
|
||||
codex_home = str(Path.home() / ".codex")
|
||||
auth_path = Path(codex_home).expanduser() / "auth.json"
|
||||
try:
|
||||
existing: Dict[str, Any] = {}
|
||||
if auth_path.is_file():
|
||||
existing = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
|
||||
tokens_dict = existing.get("tokens")
|
||||
if not isinstance(tokens_dict, dict):
|
||||
tokens_dict = {}
|
||||
tokens_dict["access_token"] = access_token
|
||||
tokens_dict["refresh_token"] = refresh_token
|
||||
existing["tokens"] = tokens_dict
|
||||
if last_refresh is not None:
|
||||
existing["last_refresh"] = last_refresh
|
||||
|
||||
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
auth_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
|
||||
auth_path.chmod(0o600)
|
||||
except (OSError, IOError) as exc:
|
||||
logger.debug("Failed to write refreshed tokens to %s: %s", auth_path, exc)
|
||||
|
||||
|
||||
def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None:
|
||||
"""Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json)."""
|
||||
if last_refresh is None:
|
||||
@@ -1425,6 +1468,12 @@ def _refresh_codex_auth_tokens(
|
||||
updated_tokens["refresh_token"] = refreshed["refresh_token"]
|
||||
|
||||
_save_codex_tokens(updated_tokens)
|
||||
# Write back to ~/.codex/auth.json so Codex CLI / VS Code stay in sync.
|
||||
_write_codex_cli_tokens(
|
||||
refreshed["access_token"],
|
||||
refreshed["refresh_token"],
|
||||
last_refresh=refreshed.get("last_refresh"),
|
||||
)
|
||||
return updated_tokens
|
||||
|
||||
|
||||
|
||||
399
hermes_cli/backup.py
Normal file
399
hermes_cli/backup.py
Normal file
@@ -0,0 +1,399 @@
|
||||
"""
|
||||
Backup and import commands for hermes CLI.
|
||||
|
||||
`hermes backup` creates a zip archive of the entire ~/.hermes/ directory
|
||||
(excluding the hermes-agent repo and transient files).
|
||||
|
||||
`hermes import` restores from a backup zip, overlaying onto the current
|
||||
HERMES_HOME root.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_default_hermes_root, display_hermes_home
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exclusion rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Directory names to skip entirely (matched against each path component)
|
||||
_EXCLUDED_DIRS = {
|
||||
"hermes-agent", # the codebase repo — re-clone instead
|
||||
"__pycache__", # bytecode caches — regenerated on import
|
||||
".git", # nested git dirs (profiles shouldn't have these, but safety)
|
||||
"node_modules", # js deps if website/ somehow leaks in
|
||||
}
|
||||
|
||||
# File-name suffixes to skip
|
||||
_EXCLUDED_SUFFIXES = (
|
||||
".pyc",
|
||||
".pyo",
|
||||
)
|
||||
|
||||
# File names to skip (runtime state that's meaningless on another machine)
|
||||
_EXCLUDED_NAMES = {
|
||||
"gateway.pid",
|
||||
"cron.pid",
|
||||
}
|
||||
|
||||
|
||||
def _should_exclude(rel_path: Path) -> bool:
|
||||
"""Return True if *rel_path* (relative to hermes root) should be skipped."""
|
||||
parts = rel_path.parts
|
||||
|
||||
# Any path component matches an excluded dir name
|
||||
for part in parts:
|
||||
if part in _EXCLUDED_DIRS:
|
||||
return True
|
||||
|
||||
name = rel_path.name
|
||||
|
||||
if name in _EXCLUDED_NAMES:
|
||||
return True
|
||||
|
||||
if name.endswith(_EXCLUDED_SUFFIXES):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _format_size(nbytes: int) -> str:
|
||||
"""Human-readable file size."""
|
||||
for unit in ("B", "KB", "MB", "GB"):
|
||||
if nbytes < 1024:
|
||||
return f"{nbytes:.1f} {unit}" if unit != "B" else f"{nbytes} {unit}"
|
||||
nbytes /= 1024
|
||||
return f"{nbytes:.1f} TB"
|
||||
|
||||
|
||||
def run_backup(args) -> None:
|
||||
"""Create a zip backup of the Hermes home directory."""
|
||||
hermes_root = get_default_hermes_root()
|
||||
|
||||
if not hermes_root.is_dir():
|
||||
print(f"Error: Hermes home directory not found at {hermes_root}")
|
||||
sys.exit(1)
|
||||
|
||||
# Determine output path
|
||||
if args.output:
|
||||
out_path = Path(args.output).expanduser().resolve()
|
||||
# If user gave a directory, put the zip inside it
|
||||
if out_path.is_dir():
|
||||
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
||||
out_path = out_path / f"hermes-backup-{stamp}.zip"
|
||||
else:
|
||||
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
||||
out_path = Path.home() / f"hermes-backup-{stamp}.zip"
|
||||
|
||||
# Ensure the suffix is .zip
|
||||
if out_path.suffix.lower() != ".zip":
|
||||
out_path = out_path.with_suffix(out_path.suffix + ".zip")
|
||||
|
||||
# Ensure parent directory exists
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Collect files
|
||||
print(f"Scanning {display_hermes_home()} ...")
|
||||
files_to_add: list[tuple[Path, Path]] = [] # (absolute, relative)
|
||||
skipped_dirs = set()
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(hermes_root, followlinks=False):
|
||||
dp = Path(dirpath)
|
||||
rel_dir = dp.relative_to(hermes_root)
|
||||
|
||||
# Prune excluded directories in-place so os.walk doesn't descend
|
||||
orig_dirnames = dirnames[:]
|
||||
dirnames[:] = [
|
||||
d for d in dirnames
|
||||
if d not in _EXCLUDED_DIRS
|
||||
]
|
||||
for removed in set(orig_dirnames) - set(dirnames):
|
||||
skipped_dirs.add(str(rel_dir / removed))
|
||||
|
||||
for fname in filenames:
|
||||
fpath = dp / fname
|
||||
rel = fpath.relative_to(hermes_root)
|
||||
|
||||
if _should_exclude(rel):
|
||||
continue
|
||||
|
||||
# Skip the output zip itself if it happens to be inside hermes root
|
||||
try:
|
||||
if fpath.resolve() == out_path.resolve():
|
||||
continue
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
files_to_add.append((fpath, rel))
|
||||
|
||||
if not files_to_add:
|
||||
print("No files to back up.")
|
||||
return
|
||||
|
||||
# Create the zip
|
||||
file_count = len(files_to_add)
|
||||
print(f"Backing up {file_count} files ...")
|
||||
|
||||
total_bytes = 0
|
||||
errors = []
|
||||
t0 = time.monotonic()
|
||||
|
||||
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
|
||||
for i, (abs_path, rel_path) in enumerate(files_to_add, 1):
|
||||
try:
|
||||
zf.write(abs_path, arcname=str(rel_path))
|
||||
total_bytes += abs_path.stat().st_size
|
||||
except (PermissionError, OSError) as exc:
|
||||
errors.append(f" {rel_path}: {exc}")
|
||||
continue
|
||||
|
||||
# Progress every 500 files
|
||||
if i % 500 == 0:
|
||||
print(f" {i}/{file_count} files ...")
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
zip_size = out_path.stat().st_size
|
||||
|
||||
# Summary
|
||||
print()
|
||||
print(f"Backup complete: {out_path}")
|
||||
print(f" Files: {file_count}")
|
||||
print(f" Original: {_format_size(total_bytes)}")
|
||||
print(f" Compressed: {_format_size(zip_size)}")
|
||||
print(f" Time: {elapsed:.1f}s")
|
||||
|
||||
if skipped_dirs:
|
||||
print(f"\n Excluded directories:")
|
||||
for d in sorted(skipped_dirs):
|
||||
print(f" {d}/")
|
||||
|
||||
if errors:
|
||||
print(f"\n Warnings ({len(errors)} files skipped):")
|
||||
for e in errors[:10]:
|
||||
print(e)
|
||||
if len(errors) > 10:
|
||||
print(f" ... and {len(errors) - 10} more")
|
||||
|
||||
print(f"\nRestore with: hermes import {out_path.name}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _validate_backup_zip(zf: zipfile.ZipFile) -> tuple[bool, str]:
|
||||
"""Check that a zip looks like a Hermes backup.
|
||||
|
||||
Returns (ok, reason).
|
||||
"""
|
||||
names = zf.namelist()
|
||||
if not names:
|
||||
return False, "zip archive is empty"
|
||||
|
||||
# Look for telltale files that a hermes home would have
|
||||
markers = {"config.yaml", ".env", "hermes_state.db", "memory_store.db"}
|
||||
found = set()
|
||||
for n in names:
|
||||
# Could be at the root or one level deep (if someone zipped the directory)
|
||||
basename = Path(n).name
|
||||
if basename in markers:
|
||||
found.add(basename)
|
||||
|
||||
if not found:
|
||||
return False, (
|
||||
"zip does not appear to be a Hermes backup "
|
||||
"(no config.yaml, .env, or state databases found)"
|
||||
)
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def _detect_prefix(zf: zipfile.ZipFile) -> str:
|
||||
"""Detect if the zip has a common directory prefix wrapping all entries.
|
||||
|
||||
Some tools zip as `.hermes/config.yaml` instead of `config.yaml`.
|
||||
Returns the prefix to strip (empty string if none).
|
||||
"""
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
if not names:
|
||||
return ""
|
||||
|
||||
# Find common prefix
|
||||
parts_list = [Path(n).parts for n in names]
|
||||
|
||||
# Check if all entries share a common first directory
|
||||
first_parts = {p[0] for p in parts_list if len(p) > 1}
|
||||
if len(first_parts) == 1:
|
||||
prefix = first_parts.pop()
|
||||
# Only strip if it looks like a hermes dir name
|
||||
if prefix in (".hermes", "hermes"):
|
||||
return prefix + "/"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def run_import(args) -> None:
|
||||
"""Restore a Hermes backup from a zip file."""
|
||||
zip_path = Path(args.zipfile).expanduser().resolve()
|
||||
|
||||
if not zip_path.is_file():
|
||||
print(f"Error: File not found: {zip_path}")
|
||||
sys.exit(1)
|
||||
|
||||
if not zipfile.is_zipfile(zip_path):
|
||||
print(f"Error: Not a valid zip file: {zip_path}")
|
||||
sys.exit(1)
|
||||
|
||||
hermes_root = get_default_hermes_root()
|
||||
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
# Validate
|
||||
ok, reason = _validate_backup_zip(zf)
|
||||
if not ok:
|
||||
print(f"Error: {reason}")
|
||||
sys.exit(1)
|
||||
|
||||
prefix = _detect_prefix(zf)
|
||||
members = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
file_count = len(members)
|
||||
|
||||
print(f"Backup contains {file_count} files")
|
||||
print(f"Target: {display_hermes_home()}")
|
||||
|
||||
if prefix:
|
||||
print(f"Detected archive prefix: {prefix!r} (will be stripped)")
|
||||
|
||||
# Check for existing installation
|
||||
has_config = (hermes_root / "config.yaml").exists()
|
||||
has_env = (hermes_root / ".env").exists()
|
||||
|
||||
if (has_config or has_env) and not args.force:
|
||||
print()
|
||||
print("Warning: Target directory already has Hermes configuration.")
|
||||
print("Importing will overwrite existing files with backup contents.")
|
||||
print()
|
||||
try:
|
||||
answer = input("Continue? [y/N] ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\nAborted.")
|
||||
sys.exit(1)
|
||||
if answer not in ("y", "yes"):
|
||||
print("Aborted.")
|
||||
return
|
||||
|
||||
# Extract
|
||||
print(f"\nImporting {file_count} files ...")
|
||||
hermes_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
errors = []
|
||||
restored = 0
|
||||
t0 = time.monotonic()
|
||||
|
||||
for member in members:
|
||||
# Strip prefix if detected
|
||||
if prefix and member.startswith(prefix):
|
||||
rel = member[len(prefix):]
|
||||
else:
|
||||
rel = member
|
||||
|
||||
if not rel:
|
||||
continue
|
||||
|
||||
target = hermes_root / rel
|
||||
|
||||
# Security: reject absolute paths and traversals
|
||||
try:
|
||||
target.resolve().relative_to(hermes_root.resolve())
|
||||
except ValueError:
|
||||
errors.append(f" {rel}: path traversal blocked")
|
||||
continue
|
||||
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(member) as src, open(target, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
restored += 1
|
||||
except (PermissionError, OSError) as exc:
|
||||
errors.append(f" {rel}: {exc}")
|
||||
|
||||
if restored % 500 == 0:
|
||||
print(f" {restored}/{file_count} files ...")
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
# Summary
|
||||
print()
|
||||
print(f"Import complete: {restored} files restored in {elapsed:.1f}s")
|
||||
print(f" Target: {display_hermes_home()}")
|
||||
|
||||
if errors:
|
||||
print(f"\n Warnings ({len(errors)} files skipped):")
|
||||
for e in errors[:10]:
|
||||
print(e)
|
||||
if len(errors) > 10:
|
||||
print(f" ... and {len(errors) - 10} more")
|
||||
|
||||
# Post-import: restore profile wrapper scripts
|
||||
profiles_dir = hermes_root / "profiles"
|
||||
restored_profiles = []
|
||||
if profiles_dir.is_dir():
|
||||
try:
|
||||
from hermes_cli.profiles import (
|
||||
create_wrapper_script, check_alias_collision,
|
||||
_is_wrapper_dir_in_path, _get_wrapper_dir,
|
||||
)
|
||||
for entry in sorted(profiles_dir.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
profile_name = entry.name
|
||||
# Only create wrappers for directories with config
|
||||
if not (entry / "config.yaml").exists() and not (entry / ".env").exists():
|
||||
continue
|
||||
collision = check_alias_collision(profile_name)
|
||||
if collision:
|
||||
print(f" Skipped alias '{profile_name}': {collision}")
|
||||
restored_profiles.append((profile_name, False))
|
||||
else:
|
||||
wrapper = create_wrapper_script(profile_name)
|
||||
restored_profiles.append((profile_name, wrapper is not None))
|
||||
|
||||
if restored_profiles:
|
||||
created = [n for n, ok in restored_profiles if ok]
|
||||
skipped = [n for n, ok in restored_profiles if not ok]
|
||||
if created:
|
||||
print(f"\n Profile aliases restored: {', '.join(created)}")
|
||||
if skipped:
|
||||
print(f" Profile aliases skipped: {', '.join(skipped)}")
|
||||
if not _is_wrapper_dir_in_path():
|
||||
print(f"\n Note: {_get_wrapper_dir()} is not in your PATH.")
|
||||
print(' Add to your shell config (~/.bashrc or ~/.zshrc):')
|
||||
print(' export PATH="$HOME/.local/bin:$PATH"')
|
||||
except ImportError:
|
||||
# hermes_cli.profiles might not be available (fresh install)
|
||||
if any(profiles_dir.iterdir()):
|
||||
print(f"\n Profiles detected but aliases could not be created.")
|
||||
print(f" Run: hermes profile list (after installing hermes)")
|
||||
|
||||
# Guidance
|
||||
print()
|
||||
if not (hermes_root / "hermes-agent").is_dir():
|
||||
print("Note: The hermes-agent codebase was not included in the backup.")
|
||||
print(" If this is a fresh install, run: hermes update")
|
||||
|
||||
if restored_profiles:
|
||||
gw_profiles = [n for n, _ in restored_profiles]
|
||||
print("\nTo re-enable gateway services for profiles:")
|
||||
for pname in gw_profiles:
|
||||
print(f" hermes -p {pname} gateway install")
|
||||
|
||||
print("Done. Your Hermes configuration has been restored.")
|
||||
@@ -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:
|
||||
|
||||
@@ -69,7 +69,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
args_hint="[name]"),
|
||||
CommandDef("branch", "Branch the current session (explore a different path)", "Session",
|
||||
aliases=("fork",), args_hint="[name]"),
|
||||
CommandDef("compress", "Manually compress conversation context", "Session"),
|
||||
CommandDef("compress", "Manually compress conversation context", "Session",
|
||||
args_hint="[focus topic]"),
|
||||
CommandDef("rollback", "List or restore filesystem checkpoints", "Session",
|
||||
args_hint="[number]"),
|
||||
CommandDef("stop", "Kill all running background processes", "Session"),
|
||||
|
||||
@@ -38,6 +38,9 @@ _EXTRA_ENV_KEYS = frozenset({
|
||||
"DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET",
|
||||
"FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN",
|
||||
"WECOM_BOT_ID", "WECOM_SECRET",
|
||||
"WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET", "WECOM_CALLBACK_AGENT_ID",
|
||||
"WECOM_CALLBACK_TOKEN", "WECOM_CALLBACK_ENCODING_AES_KEY",
|
||||
"WECOM_CALLBACK_HOST", "WECOM_CALLBACK_PORT",
|
||||
"WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL", "WEIXIN_CDN_BASE_URL",
|
||||
"WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY",
|
||||
"WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS",
|
||||
@@ -47,6 +50,7 @@ _EXTRA_ENV_KEYS = frozenset({
|
||||
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
|
||||
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM",
|
||||
"MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD",
|
||||
"MATRIX_RECOVERY_KEY",
|
||||
})
|
||||
import yaml
|
||||
|
||||
@@ -140,6 +144,73 @@ def managed_error(action: str = "modify configuration"):
|
||||
print(format_managed_message(action), file=sys.stderr)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Container-aware CLI (NixOS container mode)
|
||||
# =============================================================================
|
||||
|
||||
def _is_inside_container() -> bool:
|
||||
"""Detect if we're already running inside a Docker/Podman container."""
|
||||
# Standard Docker/Podman indicators
|
||||
if os.path.exists("/.dockerenv"):
|
||||
return True
|
||||
# Podman uses /run/.containerenv
|
||||
if os.path.exists("/run/.containerenv"):
|
||||
return True
|
||||
# Check cgroup for container runtime evidence (works for both Docker & Podman)
|
||||
try:
|
||||
with open("/proc/1/cgroup", "r") as f:
|
||||
cgroup = f.read()
|
||||
if "docker" in cgroup or "podman" in cgroup or "/lxc/" in cgroup:
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def get_container_exec_info() -> Optional[dict]:
|
||||
"""Read container mode metadata from HERMES_HOME/.container-mode.
|
||||
|
||||
Returns a dict with keys: backend, container_name, exec_user, hermes_bin
|
||||
or None if container mode is not active, we're already inside the
|
||||
container, or HERMES_DEV=1 is set.
|
||||
|
||||
The .container-mode file is written by the NixOS activation script when
|
||||
container.enable = true. It tells the host CLI to exec into the container
|
||||
instead of running locally.
|
||||
"""
|
||||
if os.environ.get("HERMES_DEV") == "1":
|
||||
return None
|
||||
|
||||
if _is_inside_container():
|
||||
return None
|
||||
|
||||
container_mode_file = get_hermes_home() / ".container-mode"
|
||||
|
||||
try:
|
||||
info = {}
|
||||
with open(container_mode_file, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if "=" in line and not line.startswith("#"):
|
||||
key, _, value = line.partition("=")
|
||||
info[key.strip()] = value.strip()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
# All other exceptions (PermissionError, malformed data, etc.) propagate
|
||||
|
||||
backend = info.get("backend", "docker")
|
||||
container_name = info.get("container_name", "hermes-agent")
|
||||
exec_user = info.get("exec_user", "hermes")
|
||||
hermes_bin = info.get("hermes_bin", "/data/current-package/bin/hermes")
|
||||
|
||||
return {
|
||||
"backend": backend,
|
||||
"container_name": container_name,
|
||||
"exec_user": exec_user,
|
||||
"hermes_bin": hermes_bin,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Config paths
|
||||
# =============================================================================
|
||||
@@ -445,9 +516,11 @@ DEFAULT_CONFIG = {
|
||||
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)
|
||||
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||||
"skin": "default",
|
||||
"interim_assistant_messages": True, # Gateway: show natural mid-turn assistant status messages
|
||||
"tool_progress_command": False, # Enable /verbose command in messaging gateway
|
||||
"tool_progress_overrides": {}, # Per-platform overrides: {"signal": "off", "telegram": "all"}
|
||||
"tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead
|
||||
"tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)
|
||||
"platforms": {}, # Per-platform display overrides: {"telegram": {"tool_progress": "all"}, "slack": {"tool_progress": "off"}}
|
||||
},
|
||||
|
||||
# Privacy settings
|
||||
@@ -634,8 +707,16 @@ DEFAULT_CONFIG = {
|
||||
"backup_count": 3, # Number of rotated backup files to keep
|
||||
},
|
||||
|
||||
# Network settings — workarounds for connectivity issues.
|
||||
"network": {
|
||||
# Force IPv4 connections. On servers with broken or unreachable IPv6,
|
||||
# Python tries AAAA records first and hangs for the full TCP timeout
|
||||
# before falling back to IPv4. Set to true to skip IPv6 entirely.
|
||||
"force_ipv4": False,
|
||||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 14,
|
||||
"_config_version": 16,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -1213,6 +1294,14 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "messaging",
|
||||
"advanced": True,
|
||||
},
|
||||
"MATRIX_RECOVERY_KEY": {
|
||||
"description": "Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)",
|
||||
"prompt": "Matrix recovery key",
|
||||
"url": None,
|
||||
"password": True,
|
||||
"category": "messaging",
|
||||
"advanced": True,
|
||||
},
|
||||
"BLUEBUBBLES_SERVER_URL": {
|
||||
"description": "BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)",
|
||||
"prompt": "BlueBubbles server URL",
|
||||
@@ -1862,6 +1951,44 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
if not quiet:
|
||||
print(f" ✓ Migrated legacy stt.model to provider-specific config")
|
||||
|
||||
# ── Version 14 → 15: add explicit gateway interim-message gate ──
|
||||
if current_ver < 15:
|
||||
config = read_raw_config()
|
||||
display = config.get("display", {})
|
||||
if not isinstance(display, dict):
|
||||
display = {}
|
||||
if "interim_assistant_messages" not in display:
|
||||
display["interim_assistant_messages"] = True
|
||||
config["display"] = display
|
||||
results["config_added"].append("display.interim_assistant_messages=true (default)")
|
||||
save_config(config)
|
||||
if not quiet:
|
||||
print(" ✓ Added display.interim_assistant_messages=true")
|
||||
|
||||
# ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──
|
||||
if current_ver < 16:
|
||||
config = read_raw_config()
|
||||
display = config.get("display", {})
|
||||
if not isinstance(display, dict):
|
||||
display = {}
|
||||
old_overrides = display.get("tool_progress_overrides")
|
||||
if isinstance(old_overrides, dict) and old_overrides:
|
||||
platforms = display.get("platforms", {})
|
||||
if not isinstance(platforms, dict):
|
||||
platforms = {}
|
||||
for plat, mode in old_overrides.items():
|
||||
if plat not in platforms:
|
||||
platforms[plat] = {}
|
||||
if "tool_progress" not in platforms[plat]:
|
||||
platforms[plat]["tool_progress"] = mode
|
||||
display["platforms"] = platforms
|
||||
config["display"] = display
|
||||
save_config(config)
|
||||
if not quiet:
|
||||
migrated = ", ".join(f"{p}={m}" for p, m in old_overrides.items())
|
||||
print(f" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}")
|
||||
results["config_added"].append("display.platforms (migrated from tool_progress_overrides)")
|
||||
|
||||
if current_ver < latest_ver and not quiet:
|
||||
print(f"Config version: {current_ver} → {latest_ver}")
|
||||
|
||||
|
||||
@@ -287,6 +287,129 @@ def _radio_numbered_fallback(
|
||||
return cancel_returns
|
||||
|
||||
|
||||
def curses_single_select(
|
||||
title: str,
|
||||
items: List[str],
|
||||
default_index: int = 0,
|
||||
*,
|
||||
cancel_label: str = "Cancel",
|
||||
) -> int | None:
|
||||
"""Curses single-select menu. Returns selected index or None on cancel.
|
||||
|
||||
Works inside prompt_toolkit because curses.wrapper() restores the terminal
|
||||
safely, unlike simple_term_menu which conflicts with /dev/tty.
|
||||
"""
|
||||
if not sys.stdin.isatty():
|
||||
return None
|
||||
|
||||
try:
|
||||
import curses
|
||||
result_holder: list = [None]
|
||||
|
||||
all_items = list(items) + [cancel_label]
|
||||
cancel_idx = len(items)
|
||||
|
||||
def _draw(stdscr):
|
||||
curses.curs_set(0)
|
||||
if curses.has_colors():
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
||||
cursor = min(default_index, len(all_items) - 1)
|
||||
scroll_offset = 0
|
||||
|
||||
while True:
|
||||
stdscr.clear()
|
||||
max_y, max_x = stdscr.getmaxyx()
|
||||
|
||||
try:
|
||||
hattr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
hattr |= curses.color_pair(2)
|
||||
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
|
||||
stdscr.addnstr(
|
||||
1, 0,
|
||||
" ↑↓ navigate ENTER confirm ESC/q cancel",
|
||||
max_x - 1, curses.A_DIM,
|
||||
)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
visible_rows = max_y - 3
|
||||
if cursor < scroll_offset:
|
||||
scroll_offset = cursor
|
||||
elif cursor >= scroll_offset + visible_rows:
|
||||
scroll_offset = cursor - visible_rows + 1
|
||||
|
||||
for draw_i, i in enumerate(
|
||||
range(scroll_offset, min(len(all_items), scroll_offset + visible_rows))
|
||||
):
|
||||
y = draw_i + 3
|
||||
if y >= max_y - 1:
|
||||
break
|
||||
arrow = "→" if i == cursor else " "
|
||||
line = f" {arrow} {all_items[i]}"
|
||||
attr = curses.A_NORMAL
|
||||
if i == cursor:
|
||||
attr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
attr |= curses.color_pair(1)
|
||||
try:
|
||||
stdscr.addnstr(y, 0, line, max_x - 1, attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
stdscr.refresh()
|
||||
key = stdscr.getch()
|
||||
|
||||
if key in (curses.KEY_UP, ord("k")):
|
||||
cursor = (cursor - 1) % len(all_items)
|
||||
elif key in (curses.KEY_DOWN, ord("j")):
|
||||
cursor = (cursor + 1) % len(all_items)
|
||||
elif key in (curses.KEY_ENTER, 10, 13):
|
||||
result_holder[0] = cursor
|
||||
return
|
||||
elif key in (27, ord("q")):
|
||||
result_holder[0] = None
|
||||
return
|
||||
|
||||
curses.wrapper(_draw)
|
||||
flush_stdin()
|
||||
if result_holder[0] is not None and result_holder[0] >= cancel_idx:
|
||||
return None
|
||||
return result_holder[0]
|
||||
|
||||
except Exception:
|
||||
all_items = list(items) + [cancel_label]
|
||||
cancel_idx = len(items)
|
||||
return _numbered_single_fallback(title, all_items, cancel_idx)
|
||||
|
||||
|
||||
def _numbered_single_fallback(
|
||||
title: str,
|
||||
items: List[str],
|
||||
cancel_idx: int,
|
||||
) -> int | None:
|
||||
"""Text-based numbered fallback for single-select."""
|
||||
print(f"\n {title}\n")
|
||||
for i, label in enumerate(items, 1):
|
||||
print(f" {i}. {label}")
|
||||
print()
|
||||
try:
|
||||
val = input(f" Choice [1-{len(items)}]: ").strip()
|
||||
if not val:
|
||||
return None
|
||||
idx = int(val) - 1
|
||||
if 0 <= idx < len(items) and idx < cancel_idx:
|
||||
return idx
|
||||
if idx == cancel_idx:
|
||||
return None
|
||||
except (ValueError, KeyboardInterrupt, EOFError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _numbered_fallback(
|
||||
title: str,
|
||||
items: List[str],
|
||||
|
||||
@@ -119,6 +119,7 @@ def _configured_platforms() -> list[str]:
|
||||
"dingtalk": "DINGTALK_CLIENT_ID",
|
||||
"feishu": "FEISHU_APP_ID",
|
||||
"wecom": "WECOM_BOT_ID",
|
||||
"wecom_callback": "WECOM_CALLBACK_CORP_ID",
|
||||
"weixin": "WEIXIN_ACCOUNT_ID",
|
||||
}
|
||||
return [name for name, env in checks.items() if os.getenv(env)]
|
||||
|
||||
@@ -1821,6 +1821,37 @@ _PLATFORMS = [
|
||||
"help": "Chat ID for scheduled results and notifications."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "wecom_callback",
|
||||
"label": "WeCom Callback (Self-Built App)",
|
||||
"emoji": "💬",
|
||||
"token_var": "WECOM_CALLBACK_CORP_ID",
|
||||
"setup_instructions": [
|
||||
"1. Go to WeCom Admin Console → Applications → Create Self-Built App",
|
||||
"2. Note the Corp ID (top of admin console) and create a Corp Secret",
|
||||
"3. Under Receive Messages, configure the callback URL to point to your server",
|
||||
"4. Copy the Token and EncodingAESKey from the callback configuration",
|
||||
"5. The adapter runs an HTTP server — ensure the port is reachable from WeCom",
|
||||
"6. Restrict access with WECOM_CALLBACK_ALLOWED_USERS for production use",
|
||||
],
|
||||
"vars": [
|
||||
{"name": "WECOM_CALLBACK_CORP_ID", "prompt": "Corp ID", "password": False,
|
||||
"help": "Your WeCom enterprise Corp ID."},
|
||||
{"name": "WECOM_CALLBACK_CORP_SECRET", "prompt": "Corp Secret", "password": True,
|
||||
"help": "The secret for your self-built application."},
|
||||
{"name": "WECOM_CALLBACK_AGENT_ID", "prompt": "Agent ID", "password": False,
|
||||
"help": "The Agent ID of your self-built application."},
|
||||
{"name": "WECOM_CALLBACK_TOKEN", "prompt": "Callback Token", "password": True,
|
||||
"help": "The Token from your WeCom callback configuration."},
|
||||
{"name": "WECOM_CALLBACK_ENCODING_AES_KEY", "prompt": "Encoding AES Key", "password": True,
|
||||
"help": "The EncodingAESKey from your WeCom callback configuration."},
|
||||
{"name": "WECOM_CALLBACK_PORT", "prompt": "Callback server port (default: 8645)", "password": False,
|
||||
"help": "Port for the HTTP callback server."},
|
||||
{"name": "WECOM_CALLBACK_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated, or empty)", "password": False,
|
||||
"is_allowlist": True,
|
||||
"help": "Restrict which WeCom users can interact with the app."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "weixin",
|
||||
"label": "Weixin / WeChat",
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
"""``hermes logs`` — view and filter Hermes log files.
|
||||
|
||||
Supports tailing, following, session filtering, level filtering, and
|
||||
relative time ranges. All log files live under ``~/.hermes/logs/``.
|
||||
Supports tailing, following, session filtering, level filtering,
|
||||
component filtering, and relative time ranges. All log files live
|
||||
under ``~/.hermes/logs/``.
|
||||
|
||||
Usage examples::
|
||||
|
||||
hermes logs # last 50 lines of agent.log
|
||||
hermes logs -f # follow agent.log in real time
|
||||
hermes logs errors # last 50 lines of errors.log
|
||||
hermes logs gateway -n 100 # last 100 lines of gateway.log
|
||||
hermes logs gateway -n 100 # last 100 lines of gateway.log
|
||||
hermes logs --level WARNING # only WARNING+ lines
|
||||
hermes logs --session abc123 # filter by session ID substring
|
||||
hermes logs --component tools # only tool-related lines
|
||||
hermes logs --since 1h # lines from the last hour
|
||||
hermes logs --since 30m -f # follow, starting 30 min ago
|
||||
"""
|
||||
@@ -20,7 +22,7 @@ import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from hermes_constants import get_hermes_home, display_hermes_home
|
||||
|
||||
@@ -38,6 +40,15 @@ _TS_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})")
|
||||
# Level extraction — matches " INFO ", " WARNING ", " ERROR ", " DEBUG ", " CRITICAL "
|
||||
_LEVEL_RE = re.compile(r"\s(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s")
|
||||
|
||||
# Logger name extraction — after level and optional session tag, the next
|
||||
# non-space token before ":" is the logger name.
|
||||
# Matches: "INFO gateway.run:" or "INFO [sess_abc] tools.terminal_tool:"
|
||||
_LOGGER_NAME_RE = re.compile(
|
||||
r"\s(?:DEBUG|INFO|WARNING|ERROR|CRITICAL)" # level
|
||||
r"(?:\s+\[.*?\])?" # optional session tag
|
||||
r"\s+(\S+):" # logger name
|
||||
)
|
||||
|
||||
# Level ordering for >= filtering
|
||||
_LEVEL_ORDER = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3, "CRITICAL": 4}
|
||||
|
||||
@@ -79,12 +90,27 @@ def _extract_level(line: str) -> Optional[str]:
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _extract_logger_name(line: str) -> Optional[str]:
|
||||
"""Extract the logger name from a log line."""
|
||||
m = _LOGGER_NAME_RE.search(line)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _line_matches_component(line: str, prefixes: Sequence[str]) -> bool:
|
||||
"""Check if a log line's logger name starts with any of *prefixes*."""
|
||||
name = _extract_logger_name(line)
|
||||
if name is None:
|
||||
return False
|
||||
return name.startswith(tuple(prefixes))
|
||||
|
||||
|
||||
def _matches_filters(
|
||||
line: str,
|
||||
*,
|
||||
min_level: Optional[str] = None,
|
||||
session_filter: Optional[str] = None,
|
||||
since: Optional[datetime] = None,
|
||||
component_prefixes: Optional[Sequence[str]] = None,
|
||||
) -> bool:
|
||||
"""Check if a log line passes all active filters."""
|
||||
if since is not None:
|
||||
@@ -102,6 +128,10 @@ def _matches_filters(
|
||||
if session_filter not in line:
|
||||
return False
|
||||
|
||||
if component_prefixes is not None:
|
||||
if not _line_matches_component(line, component_prefixes):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -113,6 +143,7 @@ def tail_log(
|
||||
level: Optional[str] = None,
|
||||
session: Optional[str] = None,
|
||||
since: Optional[str] = None,
|
||||
component: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Read and display log lines, optionally following in real time.
|
||||
|
||||
@@ -130,6 +161,8 @@ def tail_log(
|
||||
Session ID substring to filter on.
|
||||
since
|
||||
Relative time string (e.g. ``"1h"``, ``"30m"``).
|
||||
component
|
||||
Component name to filter by (e.g. ``"gateway"``, ``"tools"``).
|
||||
"""
|
||||
filename = LOG_FILES.get(log_name)
|
||||
if filename is None:
|
||||
@@ -155,13 +188,29 @@ def tail_log(
|
||||
print(f"Invalid --level: {level!r}. Use DEBUG, INFO, WARNING, ERROR, or CRITICAL.")
|
||||
sys.exit(1)
|
||||
|
||||
has_filters = min_level is not None or session is not None or since_dt is not None
|
||||
# Resolve component to logger name prefixes
|
||||
component_prefixes = None
|
||||
if component:
|
||||
from hermes_logging import COMPONENT_PREFIXES
|
||||
component_lower = component.lower()
|
||||
if component_lower not in COMPONENT_PREFIXES:
|
||||
available = ", ".join(sorted(COMPONENT_PREFIXES))
|
||||
print(f"Unknown component: {component!r}. Available: {available}")
|
||||
sys.exit(1)
|
||||
component_prefixes = COMPONENT_PREFIXES[component_lower]
|
||||
|
||||
has_filters = (
|
||||
min_level is not None
|
||||
or session is not None
|
||||
or since_dt is not None
|
||||
or component_prefixes is not None
|
||||
)
|
||||
|
||||
# Read and display the tail
|
||||
try:
|
||||
lines = _read_tail(log_path, num_lines, has_filters=has_filters,
|
||||
min_level=min_level, session_filter=session,
|
||||
since=since_dt)
|
||||
since=since_dt, component_prefixes=component_prefixes)
|
||||
except PermissionError:
|
||||
print(f"Permission denied: {log_path}")
|
||||
sys.exit(1)
|
||||
@@ -172,6 +221,8 @@ def tail_log(
|
||||
filter_parts.append(f"level>={min_level}")
|
||||
if session:
|
||||
filter_parts.append(f"session={session}")
|
||||
if component:
|
||||
filter_parts.append(f"component={component}")
|
||||
if since:
|
||||
filter_parts.append(f"since={since}")
|
||||
filter_desc = f" [{', '.join(filter_parts)}]" if filter_parts else ""
|
||||
@@ -190,7 +241,7 @@ def tail_log(
|
||||
# Follow mode — poll for new content
|
||||
try:
|
||||
_follow_log(log_path, min_level=min_level, session_filter=session,
|
||||
since=since_dt)
|
||||
since=since_dt, component_prefixes=component_prefixes)
|
||||
except KeyboardInterrupt:
|
||||
print("\n--- stopped ---")
|
||||
|
||||
@@ -203,6 +254,7 @@ def _read_tail(
|
||||
min_level: Optional[str] = None,
|
||||
session_filter: Optional[str] = None,
|
||||
since: Optional[datetime] = None,
|
||||
component_prefixes: Optional[Sequence[str]] = None,
|
||||
) -> list:
|
||||
"""Read the last *num_lines* matching lines from a log file.
|
||||
|
||||
@@ -215,7 +267,8 @@ def _read_tail(
|
||||
filtered = [
|
||||
l for l in raw_lines
|
||||
if _matches_filters(l, min_level=min_level,
|
||||
session_filter=session_filter, since=since)
|
||||
session_filter=session_filter, since=since,
|
||||
component_prefixes=component_prefixes)
|
||||
]
|
||||
return filtered[-num_lines:]
|
||||
else:
|
||||
@@ -284,6 +337,7 @@ def _follow_log(
|
||||
min_level: Optional[str] = None,
|
||||
session_filter: Optional[str] = None,
|
||||
since: Optional[datetime] = None,
|
||||
component_prefixes: Optional[Sequence[str]] = None,
|
||||
) -> None:
|
||||
"""Poll a log file for new content and print matching lines."""
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
@@ -293,7 +347,8 @@ def _follow_log(
|
||||
line = f.readline()
|
||||
if line:
|
||||
if _matches_filters(line, min_level=min_level,
|
||||
session_filter=session_filter, since=since):
|
||||
session_filter=session_filter, since=since,
|
||||
component_prefixes=component_prefixes):
|
||||
print(line, end="")
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
|
||||
@@ -152,6 +152,18 @@ try:
|
||||
except Exception:
|
||||
pass # best-effort — don't crash the CLI if logging setup fails
|
||||
|
||||
# Apply IPv4 preference early, before any HTTP clients are created.
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_config_early
|
||||
from hermes_constants import apply_ipv4_preference as _apply_ipv4
|
||||
_early_cfg = _load_config_early()
|
||||
_net = _early_cfg.get("network", {})
|
||||
if isinstance(_net, dict) and _net.get("force_ipv4"):
|
||||
_apply_ipv4(force=True)
|
||||
del _early_cfg, _net
|
||||
except Exception:
|
||||
pass # best-effort — don't crash if config isn't available yet
|
||||
|
||||
import logging
|
||||
import time as _time
|
||||
from datetime import datetime
|
||||
@@ -529,6 +541,113 @@ def _resolve_last_session(source: str = "cli") -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _probe_container(cmd: list, backend: str, via_sudo: bool = False):
|
||||
"""Run a container inspect probe, returning the CompletedProcess.
|
||||
|
||||
Catches TimeoutExpired specifically for a human-readable message;
|
||||
all other exceptions propagate naturally.
|
||||
"""
|
||||
try:
|
||||
return subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||
except subprocess.TimeoutExpired:
|
||||
label = f"sudo {backend}" if via_sudo else backend
|
||||
print(
|
||||
f"Error: timed out waiting for {label} to respond.\n"
|
||||
f"The {backend} daemon may be unresponsive or starting up.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _exec_in_container(container_info: dict, cli_args: list):
|
||||
"""Replace the current process with a command inside the managed container.
|
||||
|
||||
Probes whether sudo is needed (rootful containers), then os.execvp
|
||||
into the container. On success the Python process is replaced entirely
|
||||
and the container's exit code becomes the process exit code (OS semantics).
|
||||
On failure, OSError propagates naturally.
|
||||
|
||||
Args:
|
||||
container_info: dict with backend, container_name, exec_user, hermes_bin
|
||||
cli_args: the original CLI arguments (everything after 'hermes')
|
||||
"""
|
||||
import shutil
|
||||
|
||||
backend = container_info["backend"]
|
||||
container_name = container_info["container_name"]
|
||||
exec_user = container_info["exec_user"]
|
||||
hermes_bin = container_info["hermes_bin"]
|
||||
|
||||
runtime = shutil.which(backend)
|
||||
if not runtime:
|
||||
print(f"Error: {backend} not found on PATH. Cannot route to container.",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Rootful containers (NixOS systemd service) are invisible to unprivileged
|
||||
# users — Podman uses per-user namespaces, Docker needs group access.
|
||||
# Probe whether the runtime can see the container; if not, try via sudo.
|
||||
sudo_path = None
|
||||
probe = _probe_container(
|
||||
[runtime, "inspect", "--format", "ok", container_name], backend,
|
||||
)
|
||||
if probe.returncode != 0:
|
||||
sudo_path = shutil.which("sudo")
|
||||
if sudo_path:
|
||||
probe2 = _probe_container(
|
||||
[sudo_path, "-n", runtime, "inspect", "--format", "ok", container_name],
|
||||
backend, via_sudo=True,
|
||||
)
|
||||
if probe2.returncode != 0:
|
||||
print(
|
||||
f"Error: container '{container_name}' not found via {backend}.\n"
|
||||
f"\n"
|
||||
f"The container is likely running as root. Your user cannot see it\n"
|
||||
f"because {backend} uses per-user namespaces. Grant passwordless\n"
|
||||
f"sudo for {backend} — the -n (non-interactive) flag is required\n"
|
||||
f"because a password prompt would hang or break piped commands.\n"
|
||||
f"\n"
|
||||
f"On NixOS:\n"
|
||||
f"\n"
|
||||
f' security.sudo.extraRules = [{{\n'
|
||||
f' users = [ "{os.getenv("USER", "your-user")}" ];\n'
|
||||
f' commands = [{{ command = "{runtime}"; options = [ "NOPASSWD" ]; }}];\n'
|
||||
f' }}];\n'
|
||||
f"\n"
|
||||
f"Or run: sudo hermes {' '.join(cli_args)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(
|
||||
f"Error: container '{container_name}' not found via {backend}.\n"
|
||||
f"The container may be running under root. Try: sudo hermes {' '.join(cli_args)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
is_tty = sys.stdin.isatty()
|
||||
tty_flags = ["-it"] if is_tty else ["-i"]
|
||||
|
||||
env_flags = []
|
||||
for var in ("TERM", "COLORTERM", "LANG", "LC_ALL"):
|
||||
val = os.environ.get(var)
|
||||
if val:
|
||||
env_flags.extend(["-e", f"{var}={val}"])
|
||||
|
||||
cmd_prefix = [sudo_path, "-n", runtime] if sudo_path else [runtime]
|
||||
exec_cmd = (
|
||||
cmd_prefix + ["exec"]
|
||||
+ tty_flags
|
||||
+ ["-u", exec_user]
|
||||
+ env_flags
|
||||
+ [container_name, hermes_bin]
|
||||
+ cli_args
|
||||
)
|
||||
|
||||
os.execvp(exec_cmd[0], exec_cmd)
|
||||
|
||||
|
||||
def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]:
|
||||
"""Resolve a session name (title) or ID to a session ID.
|
||||
|
||||
@@ -1202,6 +1321,7 @@ def select_provider_and_model(args=None):
|
||||
"base_url": base_url,
|
||||
"api_key": entry.get("api_key", ""),
|
||||
"model": entry.get("model", ""),
|
||||
"api_mode": entry.get("api_mode", ""),
|
||||
}
|
||||
return custom_provider_map
|
||||
|
||||
@@ -2050,6 +2170,12 @@ def _model_flow_named_custom(config, provider_info):
|
||||
model["base_url"] = base_url
|
||||
if api_key:
|
||||
model["api_key"] = api_key
|
||||
# Apply api_mode from custom_providers entry, or clear stale value
|
||||
custom_api_mode = provider_info.get("api_mode", "")
|
||||
if custom_api_mode:
|
||||
model["api_mode"] = custom_api_mode
|
||||
else:
|
||||
model.pop("api_mode", None) # let runtime auto-detect from URL
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
|
||||
@@ -2587,8 +2713,11 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
print()
|
||||
override = ""
|
||||
if override and base_url_env:
|
||||
save_env_value(base_url_env, override)
|
||||
effective_base = override
|
||||
if not override.startswith(("http://", "https://")):
|
||||
print(" Invalid URL — must start with http:// or https://. Keeping current value.")
|
||||
else:
|
||||
save_env_value(base_url_env, override)
|
||||
effective_base = override
|
||||
|
||||
# Model selection — resolution order:
|
||||
# 1. models.dev registry (cached, filtered for agentic/tool-capable models)
|
||||
@@ -2925,6 +3054,18 @@ def cmd_config(args):
|
||||
config_command(args)
|
||||
|
||||
|
||||
def cmd_backup(args):
|
||||
"""Back up Hermes home directory to a zip file."""
|
||||
from hermes_cli.backup import run_backup
|
||||
run_backup(args)
|
||||
|
||||
|
||||
def cmd_import(args):
|
||||
"""Restore a Hermes backup from a zip file."""
|
||||
from hermes_cli.backup import run_import
|
||||
run_import(args)
|
||||
|
||||
|
||||
def cmd_version(args):
|
||||
"""Show version."""
|
||||
print(f"Hermes Agent v{__version__} ({__release_date__})")
|
||||
@@ -4042,6 +4183,26 @@ def cmd_update(args):
|
||||
print()
|
||||
print("✓ Update complete!")
|
||||
|
||||
# Write exit code *before* the gateway restart attempt.
|
||||
# When running as ``hermes update --gateway`` (spawned by the gateway's
|
||||
# /update command), this process lives inside the gateway's systemd
|
||||
# cgroup. ``systemctl restart hermes-gateway`` kills everything in the
|
||||
# cgroup (KillMode=mixed → SIGKILL to remaining processes), including
|
||||
# us and the wrapping bash shell. The shell never reaches its
|
||||
# ``printf $status > .update_exit_code`` epilogue, so the exit-code
|
||||
# marker file is never created. The new gateway's update watcher then
|
||||
# polls for 30 minutes and sends a spurious timeout message.
|
||||
#
|
||||
# Writing the marker here — after git pull + pip install succeed but
|
||||
# before we attempt the restart — ensures the new gateway sees it
|
||||
# regardless of how we die.
|
||||
if gateway_mode:
|
||||
_exit_code_path = get_hermes_home() / ".update_exit_code"
|
||||
try:
|
||||
_exit_code_path.write_text("0")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Auto-restart ALL gateways after update.
|
||||
# The code update (git pull) is shared across all profiles, so every
|
||||
# running gateway needs restarting to pick up the new code.
|
||||
@@ -4475,6 +4636,7 @@ def cmd_logs(args):
|
||||
level=getattr(args, "level", None),
|
||||
session=getattr(args, "session", None),
|
||||
since=getattr(args, "since", None),
|
||||
component=getattr(args, "component", None),
|
||||
)
|
||||
|
||||
|
||||
@@ -5066,7 +5228,43 @@ For more help on a command:
|
||||
help="Show redacted API key prefixes (first/last 4 chars) instead of just set/not set"
|
||||
)
|
||||
dump_parser.set_defaults(func=cmd_dump)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# backup command
|
||||
# =========================================================================
|
||||
backup_parser = subparsers.add_parser(
|
||||
"backup",
|
||||
help="Back up Hermes home directory to a zip file",
|
||||
description="Create a zip archive of your entire Hermes configuration, "
|
||||
"skills, sessions, and data (excludes the hermes-agent codebase)"
|
||||
)
|
||||
backup_parser.add_argument(
|
||||
"-o", "--output",
|
||||
help="Output path for the zip file (default: ~/hermes-backup-<timestamp>.zip)"
|
||||
)
|
||||
backup_parser.set_defaults(func=cmd_backup)
|
||||
|
||||
# =========================================================================
|
||||
# import command
|
||||
# =========================================================================
|
||||
import_parser = subparsers.add_parser(
|
||||
"import",
|
||||
help="Restore a Hermes backup from a zip file",
|
||||
description="Extract a previously created Hermes backup into your "
|
||||
"Hermes home directory, restoring configuration, skills, "
|
||||
"sessions, and data"
|
||||
)
|
||||
import_parser.add_argument(
|
||||
"zipfile",
|
||||
help="Path to the backup zip file"
|
||||
)
|
||||
import_parser.add_argument(
|
||||
"--force", "-f",
|
||||
action="store_true",
|
||||
help="Overwrite existing files without confirmation"
|
||||
)
|
||||
import_parser.set_defaults(func=cmd_import)
|
||||
|
||||
# =========================================================================
|
||||
# config command
|
||||
# =========================================================================
|
||||
@@ -5416,6 +5614,8 @@ For more help on a command:
|
||||
mcp_add_p.add_argument("--command", help="Stdio command (e.g. npx)")
|
||||
mcp_add_p.add_argument("--args", nargs="*", default=[], help="Arguments for stdio command")
|
||||
mcp_add_p.add_argument("--auth", choices=["oauth", "header"], help="Auth method")
|
||||
mcp_add_p.add_argument("--preset", help="Known MCP preset name")
|
||||
mcp_add_p.add_argument("--env", nargs="*", default=[], help="Environment variables for stdio servers (KEY=VALUE)")
|
||||
|
||||
mcp_rm_p = mcp_sub.add_parser("remove", aliases=["rm"], help="Remove an MCP server")
|
||||
mcp_rm_p.add_argument("name", help="Server name to remove")
|
||||
@@ -5898,6 +6098,7 @@ Examples:
|
||||
hermes logs gateway -n 100 Show last 100 lines of gateway.log
|
||||
hermes logs --level WARNING Only show WARNING and above
|
||||
hermes logs --session abc123 Filter by session ID
|
||||
hermes logs --component tools Only show tool-related lines
|
||||
hermes logs --since 1h Lines from the last hour
|
||||
hermes logs --since 30m -f Follow, starting from 30 min ago
|
||||
hermes logs list List available log files with sizes
|
||||
@@ -5927,6 +6128,10 @@ Examples:
|
||||
"--since", metavar="TIME",
|
||||
help="Show lines since TIME ago (e.g. 1h, 30m, 2d)",
|
||||
)
|
||||
logs_parser.add_argument(
|
||||
"--component", metavar="NAME",
|
||||
help="Filter by component: gateway, agent, tools, cli, cron",
|
||||
)
|
||||
logs_parser.set_defaults(func=cmd_logs)
|
||||
|
||||
# =========================================================================
|
||||
@@ -5935,9 +6140,22 @@ Examples:
|
||||
# Pre-process argv so unquoted multi-word session names after -c / -r
|
||||
# are merged into a single token before argparse sees them.
|
||||
# e.g. ``hermes -c Pokemon Agent Dev`` → ``hermes -c 'Pokemon Agent Dev'``
|
||||
# ── Container-aware routing ────────────────────────────────────────
|
||||
# When NixOS container mode is active, route ALL subcommands into
|
||||
# the managed container. This MUST run before parse_args() so that
|
||||
# --help, unrecognised flags, and every subcommand are forwarded
|
||||
# transparently instead of being intercepted by argparse on the host.
|
||||
from hermes_cli.config import get_container_exec_info
|
||||
container_info = get_container_exec_info()
|
||||
if container_info:
|
||||
_exec_in_container(container_info, sys.argv[1:])
|
||||
# Unreachable: os.execvp never returns on success (process is replaced)
|
||||
# and raises OSError on failure (which propagates as a traceback).
|
||||
sys.exit(1)
|
||||
|
||||
_processed_argv = _coalesce_session_name_args(sys.argv[1:])
|
||||
args = parser.parse_args(_processed_argv)
|
||||
|
||||
|
||||
# Handle --version flag
|
||||
if args.version:
|
||||
cmd_version(args)
|
||||
|
||||
@@ -9,7 +9,6 @@ configuration in ~/.hermes/config.yaml under the ``mcp_servers`` key.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import getpass
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -28,6 +27,11 @@ from hermes_constants import display_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
|
||||
_MCP_PRESETS: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
|
||||
# ─── UI Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -98,6 +102,59 @@ def _env_key_for_server(name: str) -> str:
|
||||
return f"MCP_{name.upper().replace('-', '_')}_API_KEY"
|
||||
|
||||
|
||||
def _parse_env_assignments(raw_env: Optional[List[str]]) -> Dict[str, str]:
|
||||
"""Parse ``KEY=VALUE`` strings from CLI args into an env dict."""
|
||||
parsed: Dict[str, str] = {}
|
||||
for item in raw_env or []:
|
||||
text = str(item or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
if "=" not in text:
|
||||
raise ValueError(f"Invalid --env value '{text}' (expected KEY=VALUE)")
|
||||
key, value = text.split("=", 1)
|
||||
key = key.strip()
|
||||
if not key:
|
||||
raise ValueError(f"Invalid --env value '{text}' (missing variable name)")
|
||||
if not _ENV_VAR_NAME_RE.match(key):
|
||||
raise ValueError(f"Invalid --env variable name '{key}'")
|
||||
parsed[key] = value
|
||||
return parsed
|
||||
|
||||
|
||||
def _apply_mcp_preset(
|
||||
name: str,
|
||||
*,
|
||||
preset_name: Optional[str],
|
||||
url: Optional[str],
|
||||
command: Optional[str],
|
||||
cmd_args: List[str],
|
||||
server_config: Dict[str, Any],
|
||||
) -> tuple[Optional[str], Optional[str], List[str], bool]:
|
||||
"""Apply a known MCP preset when transport details were omitted."""
|
||||
if not preset_name:
|
||||
return url, command, cmd_args, False
|
||||
|
||||
preset = _MCP_PRESETS.get(preset_name)
|
||||
if not preset:
|
||||
raise ValueError(f"Unknown MCP preset: {preset_name}")
|
||||
|
||||
if url or command:
|
||||
return url, command, cmd_args, False
|
||||
|
||||
url = preset.get("url")
|
||||
command = preset.get("command")
|
||||
cmd_args = list(preset.get("args") or [])
|
||||
|
||||
if url:
|
||||
server_config["url"] = url
|
||||
if command:
|
||||
server_config["command"] = command
|
||||
if cmd_args:
|
||||
server_config["args"] = cmd_args
|
||||
|
||||
return url, command, cmd_args, True
|
||||
|
||||
|
||||
# ─── Discovery (temporary connect) ───────────────────────────────────────────
|
||||
|
||||
def _probe_single_server(
|
||||
@@ -166,13 +223,35 @@ def cmd_mcp_add(args):
|
||||
command = getattr(args, "command", None)
|
||||
cmd_args = getattr(args, "args", None) or []
|
||||
auth_type = getattr(args, "auth", None)
|
||||
preset_name = getattr(args, "preset", None)
|
||||
raw_env = getattr(args, "env", None)
|
||||
|
||||
server_config: Dict[str, Any] = {}
|
||||
try:
|
||||
explicit_env = _parse_env_assignments(raw_env)
|
||||
url, command, cmd_args, _preset_applied = _apply_mcp_preset(
|
||||
name,
|
||||
preset_name=preset_name,
|
||||
url=url,
|
||||
command=command,
|
||||
cmd_args=list(cmd_args),
|
||||
server_config=server_config,
|
||||
)
|
||||
except ValueError as exc:
|
||||
_error(str(exc))
|
||||
return
|
||||
|
||||
if url and explicit_env:
|
||||
_error("--env is only supported for stdio MCP servers (--command or stdio presets)")
|
||||
return
|
||||
|
||||
# Validate transport
|
||||
if not url and not command:
|
||||
_error("Must specify --url <endpoint> or --command <cmd>")
|
||||
_error("Must specify --url <endpoint>, --command <cmd>, or --preset <name>")
|
||||
_info("Examples:")
|
||||
_info(' hermes mcp add ink --url "https://mcp.ml.ink/mcp"')
|
||||
_info(' hermes mcp add github --command npx --args @modelcontextprotocol/server-github')
|
||||
_info(' hermes mcp add myserver --preset mypreset')
|
||||
return
|
||||
|
||||
# Check if server already exists
|
||||
@@ -183,13 +262,15 @@ def cmd_mcp_add(args):
|
||||
return
|
||||
|
||||
# Build initial config
|
||||
server_config: Dict[str, Any] = {}
|
||||
if url:
|
||||
server_config["url"] = url
|
||||
else:
|
||||
server_config["command"] = command
|
||||
if cmd_args:
|
||||
server_config["args"] = cmd_args
|
||||
if explicit_env:
|
||||
server_config["env"] = explicit_env
|
||||
|
||||
|
||||
# ── Authentication ────────────────────────────────────────────────
|
||||
|
||||
@@ -627,6 +708,7 @@ def mcp_command(args):
|
||||
_info("hermes mcp serve Run as MCP server")
|
||||
_info("hermes mcp add <name> --url <endpoint> Add an MCP server")
|
||||
_info("hermes mcp add <name> --command <cmd> Add a stdio server")
|
||||
_info("hermes mcp add <name> --preset <preset> Add from a known preset")
|
||||
_info("hermes mcp remove <name> Remove a server")
|
||||
_info("hermes mcp list List servers")
|
||||
_info("hermes mcp test <name> Test connection")
|
||||
|
||||
@@ -74,13 +74,13 @@ _DOT_TO_HYPHEN_PROVIDERS: frozenset[str] = frozenset({
|
||||
_STRIP_VENDOR_ONLY_PROVIDERS: frozenset[str] = frozenset({
|
||||
"copilot",
|
||||
"copilot-acp",
|
||||
"openai-codex",
|
||||
})
|
||||
|
||||
# Providers whose native naming is authoritative -- pass through unchanged.
|
||||
_AUTHORITATIVE_NATIVE_PROVIDERS: frozenset[str] = frozenset({
|
||||
"gemini",
|
||||
"huggingface",
|
||||
"openai-codex",
|
||||
})
|
||||
|
||||
# Direct providers that accept bare native names but should repair a matching
|
||||
@@ -360,7 +360,11 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str:
|
||||
|
||||
# --- Copilot: strip matching provider prefix, keep dots ---
|
||||
if provider in _STRIP_VENDOR_ONLY_PROVIDERS:
|
||||
return _strip_matching_provider_prefix(name, provider)
|
||||
stripped = _strip_matching_provider_prefix(name, provider)
|
||||
if stripped == name and name.startswith("openai/"):
|
||||
# openai-codex maps openai/gpt-5.4 -> gpt-5.4
|
||||
return name.split("/", 1)[1]
|
||||
return stripped
|
||||
|
||||
# --- DeepSeek: map to one of two canonical names ---
|
||||
if provider == "deepseek":
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -546,6 +546,20 @@ _PROVIDER_ALIASES = {
|
||||
}
|
||||
|
||||
|
||||
def get_default_model_for_provider(provider: str) -> str:
|
||||
"""Return the default model for a provider, or empty string if unknown.
|
||||
|
||||
Uses the first entry in _PROVIDER_MODELS as the default. This is the
|
||||
model a user would be offered first in the ``hermes model`` picker.
|
||||
|
||||
Used as a fallback when the user has configured a provider but never
|
||||
selected a model (e.g. ``hermes auth add openai-codex`` without
|
||||
``hermes model``).
|
||||
"""
|
||||
models = _PROVIDER_MODELS.get(provider, [])
|
||||
return models[0] if models else ""
|
||||
|
||||
|
||||
def _openrouter_model_is_free(pricing: Any) -> bool:
|
||||
"""Return True when both prompt and completion pricing are zero."""
|
||||
if not isinstance(pricing, dict):
|
||||
@@ -1809,6 +1823,35 @@ def validate_requested_model(
|
||||
"message": message,
|
||||
}
|
||||
|
||||
# OpenAI Codex has its own catalog path; /v1/models probing is not the right validation path.
|
||||
if normalized == "openai-codex":
|
||||
try:
|
||||
codex_models = provider_model_ids("openai-codex")
|
||||
except Exception:
|
||||
codex_models = []
|
||||
if codex_models:
|
||||
if requested_for_lookup in set(codex_models):
|
||||
return {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"recognized": True,
|
||||
"message": None,
|
||||
}
|
||||
suggestions = get_close_matches(requested_for_lookup, codex_models, n=3, cutoff=0.5)
|
||||
suggestion_text = ""
|
||||
if suggestions:
|
||||
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
|
||||
return {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
"recognized": False,
|
||||
"message": (
|
||||
f"Note: `{requested}` was not found in the OpenAI Codex model listing. "
|
||||
f"It may still work if your account has access to it."
|
||||
f"{suggestion_text}"
|
||||
),
|
||||
}
|
||||
|
||||
# Probe the live API to check if the model actually exists
|
||||
api_models = fetch_api_models(api_key, base_url)
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ PLATFORMS: OrderedDict[str, PlatformInfo] = OrderedDict([
|
||||
("dingtalk", PlatformInfo(label="💬 DingTalk", default_toolset="hermes-dingtalk")),
|
||||
("feishu", PlatformInfo(label="🪽 Feishu", default_toolset="hermes-feishu")),
|
||||
("wecom", PlatformInfo(label="💬 WeCom", default_toolset="hermes-wecom")),
|
||||
("wecom_callback", PlatformInfo(label="💬 WeCom Callback", default_toolset="hermes-wecom-callback")),
|
||||
("weixin", PlatformInfo(label="💬 Weixin", default_toolset="hermes-weixin")),
|
||||
("webhook", PlatformInfo(label="🔗 Webhook", default_toolset="hermes-webhook")),
|
||||
("api_server", PlatformInfo(label="🌐 API Server", default_toolset="hermes-api-server")),
|
||||
|
||||
@@ -2005,6 +2005,12 @@ def _setup_wecom():
|
||||
_gateway_setup_wecom()
|
||||
|
||||
|
||||
def _setup_wecom_callback():
|
||||
"""Configure WeCom Callback (self-built app) via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_wecom_callback as _gw_setup
|
||||
_gw_setup()
|
||||
|
||||
|
||||
def _setup_bluebubbles():
|
||||
"""Configure BlueBubbles iMessage gateway."""
|
||||
print_header("BlueBubbles (iMessage)")
|
||||
@@ -2130,6 +2136,7 @@ _GATEWAY_PLATFORMS = [
|
||||
("DingTalk", "DINGTALK_CLIENT_ID", _setup_dingtalk),
|
||||
("Feishu / Lark", "FEISHU_APP_ID", _setup_feishu),
|
||||
("WeCom (Enterprise WeChat)", "WECOM_BOT_ID", _setup_wecom),
|
||||
("WeCom Callback (Self-Built App)", "WECOM_CALLBACK_CORP_ID", _setup_wecom_callback),
|
||||
("Weixin (WeChat)", "WEIXIN_ACCOUNT_ID", _setup_weixin),
|
||||
("BlueBubbles (iMessage)", "BLUEBUBBLES_SERVER_URL", _setup_bluebubbles),
|
||||
("Webhooks (GitHub, GitLab, etc.)", "WEBHOOK_ENABLED", _setup_webhooks),
|
||||
|
||||
@@ -302,6 +302,7 @@ def show_status(args):
|
||||
"DingTalk": ("DINGTALK_CLIENT_ID", None),
|
||||
"Feishu": ("FEISHU_APP_ID", "FEISHU_HOME_CHANNEL"),
|
||||
"WeCom": ("WECOM_BOT_ID", "WECOM_HOME_CHANNEL"),
|
||||
"WeCom Callback": ("WECOM_CALLBACK_CORP_ID", None),
|
||||
"Weixin": ("WEIXIN_ACCOUNT_ID", "WEIXIN_HOME_CHANNEL"),
|
||||
"BlueBubbles": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_HOME_CHANNEL"),
|
||||
}
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user