2026-02-02 19:01:51 -08:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""
|
|
|
|
|
|
Hermes CLI - Main entry point.
|
|
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
|
hermes # Interactive chat (default)
|
|
|
|
|
|
hermes chat # Interactive chat
|
|
|
|
|
|
hermes gateway # Run gateway in foreground
|
|
|
|
|
|
hermes gateway start # Start gateway as service
|
|
|
|
|
|
hermes gateway stop # Stop gateway service
|
|
|
|
|
|
hermes gateway status # Show gateway status
|
|
|
|
|
|
hermes gateway install # Install gateway service
|
|
|
|
|
|
hermes gateway uninstall # Uninstall gateway service
|
|
|
|
|
|
hermes setup # Interactive setup wizard
|
2026-02-20 17:24:00 -08:00
|
|
|
|
hermes logout # Clear stored authentication
|
2026-02-02 19:01:51 -08:00
|
|
|
|
hermes status # Show status of all components
|
|
|
|
|
|
hermes cron # Manage cron jobs
|
|
|
|
|
|
hermes cron list # List cron jobs
|
2026-02-21 16:21:19 -08:00
|
|
|
|
hermes cron status # Check if cron scheduler is running
|
2026-02-02 19:01:51 -08:00
|
|
|
|
hermes doctor # Check configuration and dependencies
|
|
|
|
|
|
hermes version # Show version
|
2026-02-02 22:18:18 -08:00
|
|
|
|
hermes update # Update to latest version
|
|
|
|
|
|
hermes uninstall # Uninstall Hermes Agent
|
feat: interactive session browser with search filtering (#718)
Add `hermes sessions browse` — a curses-based interactive session picker
with live type-to-search filtering, arrow key navigation, and seamless
session resume via Enter.
Features:
- Arrow keys to navigate, Enter to select and resume, Esc/q to quit
- Type characters to live-filter sessions by title, preview, source, or ID
- Backspace to edit filter, first Esc clears filter, second Esc exits
- Adaptive column layout (title/preview, last active, source, ID)
- Scrolling support for long session lists
- --source flag to filter by platform (cli, telegram, discord, etc.)
- --limit flag to control how many sessions to load (default: 50)
- Windows fallback: numbered list with input prompt
- After selection, seamlessly execs into `hermes --resume <id>`
Design decisions:
- Separate subcommand (not a flag on -c) — preserves `hermes -c` as-is
for instant most-recent-session resume
- Uses curses (not simple_term_menu) per Known Pitfalls to avoid the
arrow-key ghost-duplication rendering bug in tmux/iTerm
- Follows existing curses pattern from hermes_cli/tools_config.py
Also fixes: removed redundant `import os` inside cmd_sessions stats
block that shadowed the module-level import (would cause UnboundLocalError
if browse action was taken in the same function).
Tests: 33 new tests covering curses picker, fallback mode, filtering,
navigation, edge cases, and argument parser registration.
2026-03-08 17:42:50 -07:00
|
|
|
|
hermes sessions browse # Interactive session picker with search
|
feat: add 'hermes claw migrate' command + migration docs
- Add hermes_cli/claw.py with full CLI migration handler:
- hermes claw migrate (interactive migration with confirmation)
- --dry-run, --preset, --overwrite, --skill-conflict flags
- --source for custom OpenClaw path
- --yes to skip confirmation
- Clean formatted output matching setup wizard style
- Fix Python 3.11+ @dataclass compatibility bug in dynamic module loading:
- Register module in sys.modules before exec_module()
- Fixes both setup.py (PR #981) and new claw.py
- Add 16 tests in tests/hermes_cli/test_claw.py covering:
- Script discovery (project root, installed, missing)
- Command routing
- Dry-run, execute, cancellation, error handling
- Preset/secrets behavior, report formatting
- Documentation updates:
- README.md: Add 'hermes claw migrate' to Getting Started, new Migration section
- docs/migration/openclaw.md: Full migration guide with all options
- SKILL.md: Add CLI Command section at top of openclaw-migration skill
2026-03-12 08:20:12 -07:00
|
|
|
|
hermes claw migrate # Migrate from OpenClaw to Hermes
|
|
|
|
|
|
hermes claw migrate --dry-run # Preview migration without changes
|
2026-02-02 19:01:51 -08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
import os
|
|
|
|
|
|
import sys
|
|
|
|
|
|
from pathlib import Path
|
2026-02-25 23:00:10 -08:00
|
|
|
|
from typing import Optional
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
|
|
# Add project root to path
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
|
|
|
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
|
|
|
2026-02-26 16:49:14 +11:00
|
|
|
|
# Load .env from ~/.hermes/.env first, then project root as dev fallback
|
2026-02-02 19:01:51 -08:00
|
|
|
|
from dotenv import load_dotenv
|
2026-02-26 16:49:14 +11:00
|
|
|
|
from hermes_cli.config import get_env_path, get_hermes_home
|
|
|
|
|
|
_user_env = get_env_path()
|
|
|
|
|
|
if _user_env.exists():
|
2026-02-25 15:20:42 -08:00
|
|
|
|
try:
|
2026-02-26 16:49:14 +11:00
|
|
|
|
load_dotenv(dotenv_path=_user_env, encoding="utf-8")
|
2026-02-25 15:20:42 -08:00
|
|
|
|
except UnicodeDecodeError:
|
2026-02-26 16:49:14 +11:00
|
|
|
|
load_dotenv(dotenv_path=_user_env, encoding="latin-1")
|
|
|
|
|
|
load_dotenv(dotenv_path=PROJECT_ROOT / '.env', override=False)
|
|
|
|
|
|
|
|
|
|
|
|
# Point mini-swe-agent at ~/.hermes/ so it shares our config
|
|
|
|
|
|
os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(get_hermes_home()))
|
|
|
|
|
|
os.environ.setdefault("MSWEA_SILENT_STARTUP", "1")
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
2026-02-21 03:32:11 -08:00
|
|
|
|
import logging
|
|
|
|
|
|
|
2026-03-12 05:51:31 -07:00
|
|
|
|
from hermes_cli import __version__, __release_date__
|
2026-02-20 23:23:32 -08:00
|
|
|
|
from hermes_constants import OPENROUTER_BASE_URL
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
2026-02-21 03:32:11 -08:00
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
2026-02-22 02:16:11 -08:00
|
|
|
|
def _has_any_provider_configured() -> bool:
|
|
|
|
|
|
"""Check if at least one inference provider is usable."""
|
|
|
|
|
|
from hermes_cli.config import get_env_path, get_hermes_home
|
2026-02-25 18:20:38 -08:00
|
|
|
|
from hermes_cli.auth import get_auth_status
|
2026-02-22 02:16:11 -08:00
|
|
|
|
|
2026-02-26 19:56:24 -08:00
|
|
|
|
# Check env vars (may be set by .env or shell).
|
|
|
|
|
|
# OPENAI_BASE_URL alone counts — local models (vLLM, llama.cpp, etc.)
|
|
|
|
|
|
# often don't require an API key.
|
feat: add z.ai/GLM, Kimi/Moonshot, MiniMax as first-class providers
Adds 4 new direct API-key providers (zai, kimi-coding, minimax, minimax-cn)
to the inference provider system. All use standard OpenAI-compatible
chat/completions endpoints with Bearer token auth.
Core changes:
- auth.py: Extended ProviderConfig with api_key_env_vars and base_url_env_var
fields. Added providers to PROVIDER_REGISTRY. Added provider aliases
(glm, z-ai, zhipu, kimi, moonshot). Added auto-detection of API-key
providers in resolve_provider(). Added resolve_api_key_provider_credentials()
and get_api_key_provider_status() helpers.
- runtime_provider.py: Added generic API-key provider branch in
resolve_runtime_provider() — any provider with auth_type='api_key'
is automatically handled.
- main.py: Added providers to hermes model menu with generic
_model_flow_api_key_provider() flow. Updated _has_any_provider_configured()
to check all provider env vars. Updated argparse --provider choices.
- setup.py: Added providers to setup wizard with API key prompts and
curated model lists.
- config.py: Added env vars (GLM_API_KEY, KIMI_API_KEY, MINIMAX_API_KEY,
etc.) to OPTIONAL_ENV_VARS.
- status.py: Added API key display and provider status section.
- doctor.py: Added connectivity checks for each provider endpoint.
- cli.py: Updated provider docstrings.
Docs: Updated README.md, .env.example, cli-config.yaml.example,
cli-commands.md, environment-variables.md, configuration.md.
Tests: 50 new tests covering registry, aliases, resolution, auto-detection,
credential resolution, and runtime provider dispatch.
Inspired by PR #33 (numman-ali) which proposed a provider registry approach.
Credit to tars90percent (PR #473) and manuelschipper (PR #420) for related
provider improvements merged earlier in this changeset.
2026-03-06 18:55:12 -08:00
|
|
|
|
from hermes_cli.auth import PROVIDER_REGISTRY
|
|
|
|
|
|
|
|
|
|
|
|
# Collect all provider env vars
|
|
|
|
|
|
provider_env_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_BASE_URL"}
|
|
|
|
|
|
for pconfig in PROVIDER_REGISTRY.values():
|
|
|
|
|
|
if pconfig.auth_type == "api_key":
|
|
|
|
|
|
provider_env_vars.update(pconfig.api_key_env_vars)
|
2026-02-26 19:56:24 -08:00
|
|
|
|
if any(os.getenv(v) for v in provider_env_vars):
|
2026-02-22 02:16:11 -08:00
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
# Check .env file for keys
|
|
|
|
|
|
env_file = get_env_path()
|
|
|
|
|
|
if env_file.exists():
|
|
|
|
|
|
try:
|
|
|
|
|
|
for line in env_file.read_text().splitlines():
|
|
|
|
|
|
line = line.strip()
|
|
|
|
|
|
if line.startswith("#") or "=" not in line:
|
|
|
|
|
|
continue
|
|
|
|
|
|
key, _, val = line.partition("=")
|
|
|
|
|
|
val = val.strip().strip("'\"")
|
2026-02-26 19:56:24 -08:00
|
|
|
|
if key.strip() in provider_env_vars and val:
|
2026-02-22 02:16:11 -08:00
|
|
|
|
return True
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# Check for Nous Portal OAuth credentials
|
|
|
|
|
|
auth_file = get_hermes_home() / "auth.json"
|
|
|
|
|
|
if auth_file.exists():
|
|
|
|
|
|
try:
|
|
|
|
|
|
import json
|
|
|
|
|
|
auth = json.loads(auth_file.read_text())
|
|
|
|
|
|
active = auth.get("active_provider")
|
|
|
|
|
|
if active:
|
2026-02-25 18:20:38 -08:00
|
|
|
|
status = get_auth_status(active)
|
|
|
|
|
|
if status.get("logged_in"):
|
2026-02-22 02:16:11 -08:00
|
|
|
|
return True
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: interactive session browser with search filtering (#718)
Add `hermes sessions browse` — a curses-based interactive session picker
with live type-to-search filtering, arrow key navigation, and seamless
session resume via Enter.
Features:
- Arrow keys to navigate, Enter to select and resume, Esc/q to quit
- Type characters to live-filter sessions by title, preview, source, or ID
- Backspace to edit filter, first Esc clears filter, second Esc exits
- Adaptive column layout (title/preview, last active, source, ID)
- Scrolling support for long session lists
- --source flag to filter by platform (cli, telegram, discord, etc.)
- --limit flag to control how many sessions to load (default: 50)
- Windows fallback: numbered list with input prompt
- After selection, seamlessly execs into `hermes --resume <id>`
Design decisions:
- Separate subcommand (not a flag on -c) — preserves `hermes -c` as-is
for instant most-recent-session resume
- Uses curses (not simple_term_menu) per Known Pitfalls to avoid the
arrow-key ghost-duplication rendering bug in tmux/iTerm
- Follows existing curses pattern from hermes_cli/tools_config.py
Also fixes: removed redundant `import os` inside cmd_sessions stats
block that shadowed the module-level import (would cause UnboundLocalError
if browse action was taken in the same function).
Tests: 33 new tests covering curses picker, fallback mode, filtering,
navigation, edge cases, and argument parser registration.
2026-03-08 17:42:50 -07:00
|
|
|
|
def _session_browse_picker(sessions: list) -> Optional[str]:
|
|
|
|
|
|
"""Interactive curses-based session browser with live search filtering.
|
|
|
|
|
|
|
|
|
|
|
|
Returns the selected session ID, or None if cancelled.
|
|
|
|
|
|
Uses curses (not simple_term_menu) to avoid the ghost-duplication rendering
|
|
|
|
|
|
bug in tmux/iTerm when arrow keys are used.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not sessions:
|
|
|
|
|
|
print("No sessions found.")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
# Try curses-based picker first
|
|
|
|
|
|
try:
|
|
|
|
|
|
import curses
|
|
|
|
|
|
import time as _time
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
|
|
result_holder = [None]
|
|
|
|
|
|
|
|
|
|
|
|
def _relative_time(ts):
|
|
|
|
|
|
if not ts:
|
|
|
|
|
|
return "?"
|
|
|
|
|
|
delta = _time.time() - ts
|
|
|
|
|
|
if delta < 60:
|
|
|
|
|
|
return "just now"
|
|
|
|
|
|
elif delta < 3600:
|
|
|
|
|
|
return f"{int(delta / 60)}m ago"
|
|
|
|
|
|
elif delta < 86400:
|
|
|
|
|
|
return f"{int(delta / 3600)}h ago"
|
|
|
|
|
|
elif delta < 172800:
|
|
|
|
|
|
return "yesterday"
|
|
|
|
|
|
elif delta < 604800:
|
|
|
|
|
|
return f"{int(delta / 86400)}d ago"
|
|
|
|
|
|
else:
|
|
|
|
|
|
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
|
|
|
|
|
|
|
|
|
|
|
|
def _format_row(s, max_x):
|
|
|
|
|
|
"""Format a session row for display."""
|
|
|
|
|
|
title = (s.get("title") or "").strip()
|
|
|
|
|
|
preview = (s.get("preview") or "").strip()
|
|
|
|
|
|
source = s.get("source", "")[:6]
|
|
|
|
|
|
last_active = _relative_time(s.get("last_active"))
|
|
|
|
|
|
sid = s["id"][:18]
|
|
|
|
|
|
|
|
|
|
|
|
# Adaptive column widths based on terminal width
|
|
|
|
|
|
# Layout: [arrow 3] [title/preview flexible] [active 12] [src 6] [id 18]
|
|
|
|
|
|
fixed_cols = 3 + 12 + 6 + 18 + 6 # arrow + active + src + id + padding
|
|
|
|
|
|
name_width = max(20, max_x - fixed_cols)
|
|
|
|
|
|
|
|
|
|
|
|
if title:
|
|
|
|
|
|
name = title[:name_width]
|
|
|
|
|
|
elif preview:
|
|
|
|
|
|
name = preview[:name_width]
|
|
|
|
|
|
else:
|
|
|
|
|
|
name = sid
|
|
|
|
|
|
|
|
|
|
|
|
return f"{name:<{name_width}} {last_active:<10} {source:<5} {sid}"
|
|
|
|
|
|
|
|
|
|
|
|
def _match(s, query):
|
|
|
|
|
|
"""Check if a session matches the search query (case-insensitive)."""
|
|
|
|
|
|
q = query.lower()
|
|
|
|
|
|
return (
|
|
|
|
|
|
q in (s.get("title") or "").lower()
|
|
|
|
|
|
or q in (s.get("preview") or "").lower()
|
|
|
|
|
|
or q in s.get("id", "").lower()
|
|
|
|
|
|
or q in (s.get("source") or "").lower()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _curses_browse(stdscr):
|
|
|
|
|
|
curses.curs_set(0)
|
|
|
|
|
|
if curses.has_colors():
|
|
|
|
|
|
curses.start_color()
|
|
|
|
|
|
curses.use_default_colors()
|
|
|
|
|
|
curses.init_pair(1, curses.COLOR_GREEN, -1) # selected
|
|
|
|
|
|
curses.init_pair(2, curses.COLOR_YELLOW, -1) # header
|
|
|
|
|
|
curses.init_pair(3, curses.COLOR_CYAN, -1) # search
|
|
|
|
|
|
curses.init_pair(4, 8, -1) # dim
|
|
|
|
|
|
|
|
|
|
|
|
cursor = 0
|
|
|
|
|
|
scroll_offset = 0
|
|
|
|
|
|
search_text = ""
|
|
|
|
|
|
filtered = list(sessions)
|
|
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
stdscr.clear()
|
|
|
|
|
|
max_y, max_x = stdscr.getmaxyx()
|
|
|
|
|
|
if max_y < 5 or max_x < 40:
|
|
|
|
|
|
# Terminal too small
|
|
|
|
|
|
try:
|
|
|
|
|
|
stdscr.addstr(0, 0, "Terminal too small")
|
|
|
|
|
|
except curses.error:
|
|
|
|
|
|
pass
|
|
|
|
|
|
stdscr.refresh()
|
|
|
|
|
|
stdscr.getch()
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# Header line
|
|
|
|
|
|
if search_text:
|
|
|
|
|
|
header = f" Browse sessions — filter: {search_text}█"
|
|
|
|
|
|
header_attr = curses.A_BOLD
|
|
|
|
|
|
if curses.has_colors():
|
|
|
|
|
|
header_attr |= curses.color_pair(3)
|
|
|
|
|
|
else:
|
|
|
|
|
|
header = " Browse sessions — ↑↓ navigate Enter select Type to filter Esc quit"
|
|
|
|
|
|
header_attr = curses.A_BOLD
|
|
|
|
|
|
if curses.has_colors():
|
|
|
|
|
|
header_attr |= curses.color_pair(2)
|
|
|
|
|
|
try:
|
|
|
|
|
|
stdscr.addnstr(0, 0, header, max_x - 1, header_attr)
|
|
|
|
|
|
except curses.error:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# Column header line
|
|
|
|
|
|
fixed_cols = 3 + 12 + 6 + 18 + 6
|
|
|
|
|
|
name_width = max(20, max_x - fixed_cols)
|
|
|
|
|
|
col_header = f" {'Title / Preview':<{name_width}} {'Active':<10} {'Src':<5} {'ID'}"
|
|
|
|
|
|
try:
|
|
|
|
|
|
dim_attr = curses.color_pair(4) if curses.has_colors() else curses.A_DIM
|
|
|
|
|
|
stdscr.addnstr(1, 0, col_header, max_x - 1, dim_attr)
|
|
|
|
|
|
except curses.error:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# Compute visible area
|
|
|
|
|
|
visible_rows = max_y - 4 # header + col header + blank + footer
|
|
|
|
|
|
if visible_rows < 1:
|
|
|
|
|
|
visible_rows = 1
|
|
|
|
|
|
|
|
|
|
|
|
# Clamp cursor and scroll
|
|
|
|
|
|
if not filtered:
|
|
|
|
|
|
try:
|
|
|
|
|
|
msg = " No sessions match the filter."
|
|
|
|
|
|
stdscr.addnstr(3, 0, msg, max_x - 1, curses.A_DIM)
|
|
|
|
|
|
except curses.error:
|
|
|
|
|
|
pass
|
|
|
|
|
|
else:
|
|
|
|
|
|
if cursor >= len(filtered):
|
|
|
|
|
|
cursor = len(filtered) - 1
|
|
|
|
|
|
if cursor < 0:
|
|
|
|
|
|
cursor = 0
|
|
|
|
|
|
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(filtered), scroll_offset + visible_rows)
|
|
|
|
|
|
)):
|
|
|
|
|
|
y = draw_i + 3
|
|
|
|
|
|
if y >= max_y - 1:
|
|
|
|
|
|
break
|
|
|
|
|
|
s = filtered[i]
|
|
|
|
|
|
arrow = " → " if i == cursor else " "
|
|
|
|
|
|
row = arrow + _format_row(s, max_x - 3)
|
|
|
|
|
|
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, row, max_x - 1, attr)
|
|
|
|
|
|
except curses.error:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# Footer
|
|
|
|
|
|
footer_y = max_y - 1
|
|
|
|
|
|
if filtered:
|
|
|
|
|
|
footer = f" {cursor + 1}/{len(filtered)} sessions"
|
|
|
|
|
|
if len(filtered) < len(sessions):
|
|
|
|
|
|
footer += f" (filtered from {len(sessions)})"
|
|
|
|
|
|
else:
|
|
|
|
|
|
footer = f" 0/{len(sessions)} sessions"
|
|
|
|
|
|
try:
|
|
|
|
|
|
stdscr.addnstr(footer_y, 0, footer, max_x - 1,
|
|
|
|
|
|
curses.color_pair(4) if curses.has_colors() else curses.A_DIM)
|
|
|
|
|
|
except curses.error:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
stdscr.refresh()
|
|
|
|
|
|
key = stdscr.getch()
|
|
|
|
|
|
|
|
|
|
|
|
if key in (curses.KEY_UP, ):
|
|
|
|
|
|
if filtered:
|
|
|
|
|
|
cursor = (cursor - 1) % len(filtered)
|
|
|
|
|
|
elif key in (curses.KEY_DOWN, ):
|
|
|
|
|
|
if filtered:
|
|
|
|
|
|
cursor = (cursor + 1) % len(filtered)
|
|
|
|
|
|
elif key in (curses.KEY_ENTER, 10, 13):
|
|
|
|
|
|
if filtered:
|
|
|
|
|
|
result_holder[0] = filtered[cursor]["id"]
|
|
|
|
|
|
return
|
|
|
|
|
|
elif key == 27: # Esc
|
|
|
|
|
|
if search_text:
|
|
|
|
|
|
# First Esc clears the search
|
|
|
|
|
|
search_text = ""
|
|
|
|
|
|
filtered = list(sessions)
|
|
|
|
|
|
cursor = 0
|
|
|
|
|
|
scroll_offset = 0
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Second Esc exits
|
|
|
|
|
|
return
|
|
|
|
|
|
elif key in (curses.KEY_BACKSPACE, 127, 8):
|
|
|
|
|
|
if search_text:
|
|
|
|
|
|
search_text = search_text[:-1]
|
|
|
|
|
|
if search_text:
|
|
|
|
|
|
filtered = [s for s in sessions if _match(s, search_text)]
|
|
|
|
|
|
else:
|
|
|
|
|
|
filtered = list(sessions)
|
|
|
|
|
|
cursor = 0
|
|
|
|
|
|
scroll_offset = 0
|
|
|
|
|
|
elif key == ord('q') and not search_text:
|
|
|
|
|
|
return
|
|
|
|
|
|
elif 32 <= key <= 126:
|
|
|
|
|
|
# Printable character → add to search filter
|
|
|
|
|
|
search_text += chr(key)
|
|
|
|
|
|
filtered = [s for s in sessions if _match(s, search_text)]
|
|
|
|
|
|
cursor = 0
|
|
|
|
|
|
scroll_offset = 0
|
|
|
|
|
|
|
|
|
|
|
|
curses.wrapper(_curses_browse)
|
|
|
|
|
|
return result_holder[0]
|
|
|
|
|
|
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# Fallback: numbered list (Windows without curses, etc.)
|
|
|
|
|
|
import time as _time
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
|
|
def _relative_time_fb(ts):
|
|
|
|
|
|
if not ts:
|
|
|
|
|
|
return "?"
|
|
|
|
|
|
delta = _time.time() - ts
|
|
|
|
|
|
if delta < 60:
|
|
|
|
|
|
return "just now"
|
|
|
|
|
|
elif delta < 3600:
|
|
|
|
|
|
return f"{int(delta / 60)}m ago"
|
|
|
|
|
|
elif delta < 86400:
|
|
|
|
|
|
return f"{int(delta / 3600)}h ago"
|
|
|
|
|
|
elif delta < 172800:
|
|
|
|
|
|
return "yesterday"
|
|
|
|
|
|
elif delta < 604800:
|
|
|
|
|
|
return f"{int(delta / 86400)}d ago"
|
|
|
|
|
|
else:
|
|
|
|
|
|
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
|
|
|
|
|
|
|
|
|
|
|
|
print("\n Browse sessions (enter number to resume, q to cancel)\n")
|
|
|
|
|
|
for i, s in enumerate(sessions):
|
|
|
|
|
|
title = (s.get("title") or "").strip()
|
|
|
|
|
|
preview = (s.get("preview") or "").strip()
|
|
|
|
|
|
label = title or preview or s["id"]
|
|
|
|
|
|
if len(label) > 50:
|
|
|
|
|
|
label = label[:47] + "..."
|
|
|
|
|
|
last_active = _relative_time_fb(s.get("last_active"))
|
|
|
|
|
|
src = s.get("source", "")[:6]
|
|
|
|
|
|
print(f" {i + 1:>3}. {label:<50} {last_active:<10} {src}")
|
|
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
|
|
|
|
|
val = input(f"\n Select [1-{len(sessions)}]: ").strip()
|
|
|
|
|
|
if not val or val.lower() in ("q", "quit", "exit"):
|
|
|
|
|
|
return None
|
|
|
|
|
|
idx = int(val) - 1
|
|
|
|
|
|
if 0 <= idx < len(sessions):
|
|
|
|
|
|
return sessions[idx]["id"]
|
|
|
|
|
|
print(f" Invalid selection. Enter 1-{len(sessions)} or q to cancel.")
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
print(f" Invalid input. Enter a number or q to cancel.")
|
|
|
|
|
|
except (KeyboardInterrupt, EOFError):
|
|
|
|
|
|
print()
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-25 23:00:10 -08:00
|
|
|
|
def _resolve_last_cli_session() -> Optional[str]:
|
|
|
|
|
|
"""Look up the most recent CLI session ID from SQLite. Returns None if unavailable."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from hermes_state import SessionDB
|
|
|
|
|
|
db = SessionDB()
|
|
|
|
|
|
sessions = db.search_sessions(source="cli", limit=1)
|
|
|
|
|
|
db.close()
|
|
|
|
|
|
if sessions:
|
|
|
|
|
|
return sessions[0]["id"]
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-08 15:20:29 -07:00
|
|
|
|
def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]:
|
|
|
|
|
|
"""Resolve a session name (title) or ID to a session ID.
|
|
|
|
|
|
|
|
|
|
|
|
- If it looks like a session ID (contains underscore + hex), try direct lookup first.
|
|
|
|
|
|
- Otherwise, treat it as a title and use resolve_session_by_title (auto-latest).
|
|
|
|
|
|
- Falls back to the other method if the first doesn't match.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from hermes_state import SessionDB
|
|
|
|
|
|
db = SessionDB()
|
|
|
|
|
|
|
|
|
|
|
|
# Try as exact session ID first
|
|
|
|
|
|
session = db.get_session(name_or_id)
|
|
|
|
|
|
if session:
|
|
|
|
|
|
db.close()
|
|
|
|
|
|
return session["id"]
|
|
|
|
|
|
|
|
|
|
|
|
# Try as title (with auto-latest for lineage)
|
|
|
|
|
|
session_id = db.resolve_session_by_title(name_or_id)
|
|
|
|
|
|
db.close()
|
|
|
|
|
|
return session_id
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
def cmd_chat(args):
|
|
|
|
|
|
"""Run interactive chat CLI."""
|
2026-03-08 15:20:29 -07:00
|
|
|
|
# Resolve --continue into --resume with the latest CLI session or by name
|
|
|
|
|
|
continue_val = getattr(args, "continue_last", None)
|
|
|
|
|
|
if continue_val and not getattr(args, "resume", None):
|
|
|
|
|
|
if isinstance(continue_val, str):
|
|
|
|
|
|
# -c "session name" — resolve by title or ID
|
|
|
|
|
|
resolved = _resolve_session_by_name_or_id(continue_val)
|
|
|
|
|
|
if resolved:
|
|
|
|
|
|
args.resume = resolved
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(f"No session found matching '{continue_val}'.")
|
|
|
|
|
|
print("Use 'hermes sessions list' to see available sessions.")
|
|
|
|
|
|
sys.exit(1)
|
2026-02-25 23:00:10 -08:00
|
|
|
|
else:
|
2026-03-08 15:20:29 -07:00
|
|
|
|
# -c with no argument — continue the most recent session
|
|
|
|
|
|
last_id = _resolve_last_cli_session()
|
|
|
|
|
|
if last_id:
|
|
|
|
|
|
args.resume = last_id
|
|
|
|
|
|
else:
|
|
|
|
|
|
print("No previous CLI session found to continue.")
|
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
# Resolve --resume by title if it's not a direct session ID
|
|
|
|
|
|
resume_val = getattr(args, "resume", None)
|
|
|
|
|
|
if resume_val:
|
|
|
|
|
|
resolved = _resolve_session_by_name_or_id(resume_val)
|
|
|
|
|
|
if resolved:
|
|
|
|
|
|
args.resume = resolved
|
|
|
|
|
|
# If resolution fails, keep the original value — _init_agent will
|
|
|
|
|
|
# report "Session not found" with the original input
|
2026-02-25 23:00:10 -08:00
|
|
|
|
|
2026-02-22 02:16:11 -08:00
|
|
|
|
# First-run guard: check if any provider is configured before launching
|
|
|
|
|
|
if not _has_any_provider_configured():
|
|
|
|
|
|
print()
|
|
|
|
|
|
print("It looks like Hermes isn't configured yet -- no API keys or providers found.")
|
|
|
|
|
|
print()
|
|
|
|
|
|
print(" Run: hermes setup")
|
|
|
|
|
|
print()
|
|
|
|
|
|
try:
|
|
|
|
|
|
reply = input("Run setup now? [Y/n] ").strip().lower()
|
|
|
|
|
|
except (EOFError, KeyboardInterrupt):
|
|
|
|
|
|
reply = "n"
|
|
|
|
|
|
if reply in ("", "y", "yes"):
|
|
|
|
|
|
cmd_setup(args)
|
|
|
|
|
|
return
|
|
|
|
|
|
print()
|
|
|
|
|
|
print("You can run 'hermes setup' at any time to configure.")
|
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
fix: restore all removed bundled skills + fix skills sync system
- Restored 21 skills removed in commits 757d012 and 740dd92:
accelerate, audiocraft, code-review, faiss, flash-attention, gguf,
grpo-rl-training, guidance, llava, nemo-curator, obliteratus, peft,
pytorch-fsdp, pytorch-lightning, simpo, slime, stable-diffusion,
tensorrt-llm, torchtitan, trl-fine-tuning, whisper
- Rewrote sync_skills() with proper update semantics:
* New skills (not in manifest): copied to user dir
* Existing skills (in manifest + on disk): updated via hash comparison
* User-deleted skills (in manifest, not on disk): respected, not re-added
* Stale manifest entries (removed from bundled): cleaned from manifest
- Added sync_skills() to CLI startup (cmd_chat) and gateway startup
(start_gateway) — previously only ran during 'hermes update'
- Updated cmd_update output to show new/updated/cleaned counts
- Rewrote tests: 20 tests covering manifest CRUD, dir hashing, fresh
install, user deletion respect, update detection, stale cleanup, and
name collision handling
75 bundled skills total. 2002 tests pass.
2026-03-06 15:57:12 -08:00
|
|
|
|
# Sync bundled skills on every CLI launch (fast -- skips unchanged skills)
|
|
|
|
|
|
try:
|
|
|
|
|
|
from tools.skills_sync import sync_skills
|
|
|
|
|
|
sync_skills(quiet=True)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2026-03-10 20:56:30 -07:00
|
|
|
|
# --yolo: bypass all dangerous command approvals
|
|
|
|
|
|
if getattr(args, "yolo", False):
|
2026-03-08 18:36:37 -05:00
|
|
|
|
os.environ["HERMES_YOLO_MODE"] = "1"
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
# Import and run the CLI
|
|
|
|
|
|
from cli import main as cli_main
|
|
|
|
|
|
|
|
|
|
|
|
# Build kwargs from args
|
|
|
|
|
|
kwargs = {
|
|
|
|
|
|
"model": args.model,
|
2026-02-20 17:24:00 -08:00
|
|
|
|
"provider": getattr(args, "provider", None),
|
2026-02-02 19:01:51 -08:00
|
|
|
|
"toolsets": args.toolsets,
|
|
|
|
|
|
"verbose": args.verbose,
|
2026-03-10 20:45:18 -07:00
|
|
|
|
"quiet": getattr(args, "quiet", False),
|
2026-02-02 19:01:51 -08:00
|
|
|
|
"query": args.query,
|
2026-02-25 22:56:12 -08:00
|
|
|
|
"resume": getattr(args, "resume", None),
|
2026-03-07 21:05:40 -08:00
|
|
|
|
"worktree": getattr(args, "worktree", False),
|
feat: filesystem checkpoints and /rollback command
Automatic filesystem snapshots before destructive file operations,
with user-facing rollback. Inspired by PR #559 (by @alireza78a).
Architecture:
- Shadow git repos at ~/.hermes/checkpoints/{hash}/ via GIT_DIR
- CheckpointManager: take/list/restore, turn-scoped dedup, pruning
- Transparent — the LLM never sees it, no tool schema, no tokens
- Once per turn — only first write_file/patch triggers a snapshot
Integration:
- Config: checkpoints.enabled + checkpoints.max_snapshots
- CLI flag: hermes --checkpoints
- Trigger: run_agent.py _execute_tool_calls() before write_file/patch
- /rollback slash command in CLI + gateway (list, restore by number)
- Pre-rollback snapshot auto-created on restore (undo the undo)
Safety:
- Never blocks file operations — all errors silently logged
- Skips root dir, home dir, dirs >50K files
- Disables gracefully when git not installed
- Shadow repo completely isolated from project git
Tests: 35 new tests, all passing (2798 total suite)
Docs: feature page, config reference, CLI commands reference
2026-03-10 00:49:15 -07:00
|
|
|
|
"checkpoints": getattr(args, "checkpoints", False),
|
2026-03-12 05:51:31 -07:00
|
|
|
|
"pass_session_id": getattr(args, "pass_session_id", False),
|
2026-02-02 19:01:51 -08:00
|
|
|
|
}
|
|
|
|
|
|
# Filter out None values
|
|
|
|
|
|
kwargs = {k: v for k, v in kwargs.items() if v is not None}
|
|
|
|
|
|
|
|
|
|
|
|
cli_main(**kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_gateway(args):
|
|
|
|
|
|
"""Gateway management commands."""
|
|
|
|
|
|
from hermes_cli.gateway import gateway_command
|
|
|
|
|
|
gateway_command(args)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-25 21:04:36 -08:00
|
|
|
|
def cmd_whatsapp(args):
|
2026-03-02 17:51:33 -08:00
|
|
|
|
"""Set up WhatsApp: choose mode, configure, install bridge, pair via QR."""
|
2026-02-25 21:04:36 -08:00
|
|
|
|
import os
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from hermes_cli.config import get_env_value, save_env_value
|
|
|
|
|
|
|
|
|
|
|
|
print()
|
|
|
|
|
|
print("⚕ WhatsApp Setup")
|
|
|
|
|
|
print("=" * 50)
|
|
|
|
|
|
|
2026-03-02 17:51:33 -08:00
|
|
|
|
# ── Step 1: Choose mode ──────────────────────────────────────────────
|
|
|
|
|
|
current_mode = get_env_value("WHATSAPP_MODE") or ""
|
|
|
|
|
|
if not current_mode:
|
|
|
|
|
|
print()
|
|
|
|
|
|
print("How will you use WhatsApp with Hermes?")
|
|
|
|
|
|
print()
|
|
|
|
|
|
print(" 1. Separate bot number (recommended)")
|
|
|
|
|
|
print(" People message the bot's number directly — cleanest experience.")
|
|
|
|
|
|
print(" Requires a second phone number with WhatsApp installed on a device.")
|
|
|
|
|
|
print()
|
|
|
|
|
|
print(" 2. Personal number (self-chat)")
|
|
|
|
|
|
print(" You message yourself to talk to the agent.")
|
|
|
|
|
|
print(" Quick to set up, but the UX is less intuitive.")
|
|
|
|
|
|
print()
|
|
|
|
|
|
try:
|
|
|
|
|
|
choice = input(" Choose [1/2]: ").strip()
|
|
|
|
|
|
except (EOFError, KeyboardInterrupt):
|
|
|
|
|
|
print("\nSetup cancelled.")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
if choice == "1":
|
|
|
|
|
|
save_env_value("WHATSAPP_MODE", "bot")
|
|
|
|
|
|
wa_mode = "bot"
|
|
|
|
|
|
print(" ✓ Mode: separate bot number")
|
|
|
|
|
|
print()
|
|
|
|
|
|
print(" ┌─────────────────────────────────────────────────┐")
|
|
|
|
|
|
print(" │ Getting a second number for the bot: │")
|
|
|
|
|
|
print(" │ │")
|
|
|
|
|
|
print(" │ Easiest: Install WhatsApp Business (free app) │")
|
|
|
|
|
|
print(" │ on your phone with a second number: │")
|
|
|
|
|
|
print(" │ • Dual-SIM: use your 2nd SIM slot │")
|
|
|
|
|
|
print(" │ • Google Voice: free US number (voice.google) │")
|
|
|
|
|
|
print(" │ • Prepaid SIM: $3-10, verify once │")
|
|
|
|
|
|
print(" │ │")
|
|
|
|
|
|
print(" │ WhatsApp Business runs alongside your personal │")
|
|
|
|
|
|
print(" │ WhatsApp — no second phone needed. │")
|
|
|
|
|
|
print(" └─────────────────────────────────────────────────┘")
|
|
|
|
|
|
else:
|
|
|
|
|
|
save_env_value("WHATSAPP_MODE", "self-chat")
|
|
|
|
|
|
wa_mode = "self-chat"
|
|
|
|
|
|
print(" ✓ Mode: personal number (self-chat)")
|
|
|
|
|
|
else:
|
|
|
|
|
|
wa_mode = current_mode
|
|
|
|
|
|
mode_label = "separate bot number" if wa_mode == "bot" else "personal number (self-chat)"
|
|
|
|
|
|
print(f"\n✓ Mode: {mode_label}")
|
|
|
|
|
|
|
|
|
|
|
|
# ── Step 2: Enable WhatsApp ──────────────────────────────────────────
|
|
|
|
|
|
print()
|
2026-02-25 21:04:36 -08:00
|
|
|
|
current = get_env_value("WHATSAPP_ENABLED")
|
|
|
|
|
|
if current and current.lower() == "true":
|
|
|
|
|
|
print("✓ WhatsApp is already enabled")
|
|
|
|
|
|
else:
|
|
|
|
|
|
save_env_value("WHATSAPP_ENABLED", "true")
|
|
|
|
|
|
print("✓ WhatsApp enabled")
|
|
|
|
|
|
|
2026-03-02 17:51:33 -08:00
|
|
|
|
# ── Step 3: Allowed users ────────────────────────────────────────────
|
2026-02-25 21:04:36 -08:00
|
|
|
|
current_users = get_env_value("WHATSAPP_ALLOWED_USERS") or ""
|
|
|
|
|
|
if current_users:
|
|
|
|
|
|
print(f"✓ Allowed users: {current_users}")
|
2026-03-02 17:51:33 -08:00
|
|
|
|
try:
|
|
|
|
|
|
response = input("\n Update allowed users? [y/N] ").strip()
|
|
|
|
|
|
except (EOFError, KeyboardInterrupt):
|
|
|
|
|
|
response = "n"
|
2026-02-25 21:04:36 -08:00
|
|
|
|
if response.lower() in ("y", "yes"):
|
2026-03-02 17:51:33 -08:00
|
|
|
|
if wa_mode == "bot":
|
|
|
|
|
|
phone = input(" Phone numbers that can message the bot (comma-separated): ").strip()
|
|
|
|
|
|
else:
|
|
|
|
|
|
phone = input(" Your phone number (e.g. 15551234567): ").strip()
|
2026-02-25 21:04:36 -08:00
|
|
|
|
if phone:
|
|
|
|
|
|
save_env_value("WHATSAPP_ALLOWED_USERS", phone.replace(" ", ""))
|
|
|
|
|
|
print(f" ✓ Updated to: {phone}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print()
|
2026-03-02 17:51:33 -08:00
|
|
|
|
if wa_mode == "bot":
|
|
|
|
|
|
print(" Who should be allowed to message the bot?")
|
|
|
|
|
|
phone = input(" Phone numbers (comma-separated, or * for anyone): ").strip()
|
|
|
|
|
|
else:
|
|
|
|
|
|
phone = input(" Your phone number (e.g. 15551234567): ").strip()
|
2026-02-25 21:04:36 -08:00
|
|
|
|
if phone:
|
|
|
|
|
|
save_env_value("WHATSAPP_ALLOWED_USERS", phone.replace(" ", ""))
|
|
|
|
|
|
print(f" ✓ Allowed users set: {phone}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(" ⚠ No allowlist — the agent will respond to ALL incoming messages")
|
|
|
|
|
|
|
2026-03-02 17:51:33 -08:00
|
|
|
|
# ── Step 4: Install bridge dependencies ──────────────────────────────
|
2026-02-25 21:04:36 -08:00
|
|
|
|
project_root = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
bridge_dir = project_root / "scripts" / "whatsapp-bridge"
|
|
|
|
|
|
bridge_script = bridge_dir / "bridge.js"
|
|
|
|
|
|
|
|
|
|
|
|
if not bridge_script.exists():
|
|
|
|
|
|
print(f"\n✗ Bridge script not found at {bridge_script}")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
if not (bridge_dir / "node_modules").exists():
|
|
|
|
|
|
print("\n→ Installing WhatsApp bridge dependencies...")
|
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
|
["npm", "install"],
|
|
|
|
|
|
cwd=str(bridge_dir),
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
timeout=120,
|
|
|
|
|
|
)
|
|
|
|
|
|
if result.returncode != 0:
|
|
|
|
|
|
print(f" ✗ npm install failed: {result.stderr}")
|
|
|
|
|
|
return
|
|
|
|
|
|
print(" ✓ Dependencies installed")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print("✓ Bridge dependencies already installed")
|
|
|
|
|
|
|
2026-03-02 17:51:33 -08:00
|
|
|
|
# ── Step 5: Check for existing session ───────────────────────────────
|
2026-02-25 21:04:36 -08:00
|
|
|
|
session_dir = Path.home() / ".hermes" / "whatsapp" / "session"
|
|
|
|
|
|
session_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
if (session_dir / "creds.json").exists():
|
|
|
|
|
|
print("✓ Existing WhatsApp session found")
|
2026-03-02 17:51:33 -08:00
|
|
|
|
try:
|
|
|
|
|
|
response = input("\n Re-pair? This will clear the existing session. [y/N] ").strip()
|
|
|
|
|
|
except (EOFError, KeyboardInterrupt):
|
|
|
|
|
|
response = "n"
|
2026-02-25 21:04:36 -08:00
|
|
|
|
if response.lower() in ("y", "yes"):
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
shutil.rmtree(session_dir, ignore_errors=True)
|
|
|
|
|
|
session_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
print(" ✓ Session cleared")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print("\n✓ WhatsApp is configured and paired!")
|
|
|
|
|
|
print(" Start the gateway with: hermes gateway")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-03-02 17:51:33 -08:00
|
|
|
|
# ── Step 6: QR code pairing ──────────────────────────────────────────
|
2026-02-25 21:04:36 -08:00
|
|
|
|
print()
|
|
|
|
|
|
print("─" * 50)
|
2026-03-02 17:51:33 -08:00
|
|
|
|
if wa_mode == "bot":
|
|
|
|
|
|
print("📱 Open WhatsApp (or WhatsApp Business) on the")
|
|
|
|
|
|
print(" phone with the BOT's number, then scan:")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print("📱 Open WhatsApp on your phone, then scan:")
|
|
|
|
|
|
print()
|
|
|
|
|
|
print(" Settings → Linked Devices → Link a Device")
|
2026-02-25 21:04:36 -08:00
|
|
|
|
print("─" * 50)
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
subprocess.run(
|
|
|
|
|
|
["node", str(bridge_script), "--pair-only", "--session", str(session_dir)],
|
|
|
|
|
|
cwd=str(bridge_dir),
|
|
|
|
|
|
)
|
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2026-03-02 17:51:33 -08:00
|
|
|
|
# ── Step 7: Post-pairing ─────────────────────────────────────────────
|
2026-02-25 21:04:36 -08:00
|
|
|
|
print()
|
|
|
|
|
|
if (session_dir / "creds.json").exists():
|
|
|
|
|
|
print("✓ WhatsApp paired successfully!")
|
|
|
|
|
|
print()
|
2026-03-02 17:51:33 -08:00
|
|
|
|
if wa_mode == "bot":
|
|
|
|
|
|
print(" Next steps:")
|
|
|
|
|
|
print(" 1. Start the gateway: hermes gateway")
|
|
|
|
|
|
print(" 2. Send a message to the bot's WhatsApp number")
|
|
|
|
|
|
print(" 3. The agent will reply automatically")
|
|
|
|
|
|
print()
|
|
|
|
|
|
print(" Tip: Agent responses are prefixed with '⚕ Hermes Agent'")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(" Next steps:")
|
|
|
|
|
|
print(" 1. Start the gateway: hermes gateway")
|
|
|
|
|
|
print(" 2. Open WhatsApp → Message Yourself")
|
|
|
|
|
|
print(" 3. Type a message — the agent will reply")
|
|
|
|
|
|
print()
|
|
|
|
|
|
print(" Tip: Agent responses are prefixed with '⚕ Hermes Agent'")
|
|
|
|
|
|
print(" so you can tell them apart from your own messages.")
|
|
|
|
|
|
print()
|
|
|
|
|
|
print(" Or install as a service: hermes gateway install")
|
2026-02-25 21:04:36 -08:00
|
|
|
|
else:
|
|
|
|
|
|
print("⚠ Pairing may not have completed. Run 'hermes whatsapp' to try again.")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
def cmd_setup(args):
|
|
|
|
|
|
"""Interactive setup wizard."""
|
|
|
|
|
|
from hermes_cli.setup import run_setup_wizard
|
|
|
|
|
|
run_setup_wizard(args)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-20 17:52:46 -08:00
|
|
|
|
def cmd_model(args):
|
|
|
|
|
|
"""Select default model — starts with provider selection, then model picker."""
|
|
|
|
|
|
from hermes_cli.auth import (
|
|
|
|
|
|
resolve_provider, get_provider_auth_state, PROVIDER_REGISTRY,
|
|
|
|
|
|
_prompt_model_selection, _save_model_choice, _update_config_for_provider,
|
|
|
|
|
|
resolve_nous_runtime_credentials, fetch_nous_models, AuthError, format_auth_error,
|
2026-02-25 18:20:38 -08:00
|
|
|
|
_login_nous,
|
2026-02-20 17:52:46 -08:00
|
|
|
|
)
|
|
|
|
|
|
from hermes_cli.config import load_config, save_config, get_env_value, save_env_value
|
|
|
|
|
|
|
|
|
|
|
|
config = load_config()
|
|
|
|
|
|
current_model = config.get("model")
|
|
|
|
|
|
if isinstance(current_model, dict):
|
|
|
|
|
|
current_model = current_model.get("default", "")
|
|
|
|
|
|
current_model = current_model or "(not set)"
|
|
|
|
|
|
|
2026-02-20 18:17:55 -08:00
|
|
|
|
# Read effective provider the same way the CLI does at startup:
|
|
|
|
|
|
# config.yaml model.provider > env var > auto-detect
|
|
|
|
|
|
import os
|
|
|
|
|
|
config_provider = None
|
|
|
|
|
|
model_cfg = config.get("model")
|
|
|
|
|
|
if isinstance(model_cfg, dict):
|
|
|
|
|
|
config_provider = model_cfg.get("provider")
|
|
|
|
|
|
|
|
|
|
|
|
effective_provider = (
|
|
|
|
|
|
os.getenv("HERMES_INFERENCE_PROVIDER")
|
|
|
|
|
|
or config_provider
|
|
|
|
|
|
or "auto"
|
|
|
|
|
|
)
|
2026-02-25 18:20:38 -08:00
|
|
|
|
try:
|
|
|
|
|
|
active = resolve_provider(effective_provider)
|
|
|
|
|
|
except AuthError as exc:
|
|
|
|
|
|
warning = format_auth_error(exc)
|
|
|
|
|
|
print(f"Warning: {warning} Falling back to auto provider detection.")
|
|
|
|
|
|
active = resolve_provider("auto")
|
2026-02-20 18:17:55 -08:00
|
|
|
|
|
|
|
|
|
|
# Detect custom endpoint
|
|
|
|
|
|
if active == "openrouter" and get_env_value("OPENAI_BASE_URL"):
|
|
|
|
|
|
active = "custom"
|
2026-02-20 17:52:46 -08:00
|
|
|
|
|
|
|
|
|
|
provider_labels = {
|
|
|
|
|
|
"openrouter": "OpenRouter",
|
|
|
|
|
|
"nous": "Nous Portal",
|
2026-02-25 18:20:38 -08:00
|
|
|
|
"openai-codex": "OpenAI Codex",
|
feat: add z.ai/GLM, Kimi/Moonshot, MiniMax as first-class providers
Adds 4 new direct API-key providers (zai, kimi-coding, minimax, minimax-cn)
to the inference provider system. All use standard OpenAI-compatible
chat/completions endpoints with Bearer token auth.
Core changes:
- auth.py: Extended ProviderConfig with api_key_env_vars and base_url_env_var
fields. Added providers to PROVIDER_REGISTRY. Added provider aliases
(glm, z-ai, zhipu, kimi, moonshot). Added auto-detection of API-key
providers in resolve_provider(). Added resolve_api_key_provider_credentials()
and get_api_key_provider_status() helpers.
- runtime_provider.py: Added generic API-key provider branch in
resolve_runtime_provider() — any provider with auth_type='api_key'
is automatically handled.
- main.py: Added providers to hermes model menu with generic
_model_flow_api_key_provider() flow. Updated _has_any_provider_configured()
to check all provider env vars. Updated argparse --provider choices.
- setup.py: Added providers to setup wizard with API key prompts and
curated model lists.
- config.py: Added env vars (GLM_API_KEY, KIMI_API_KEY, MINIMAX_API_KEY,
etc.) to OPTIONAL_ENV_VARS.
- status.py: Added API key display and provider status section.
- doctor.py: Added connectivity checks for each provider endpoint.
- cli.py: Updated provider docstrings.
Docs: Updated README.md, .env.example, cli-config.yaml.example,
cli-commands.md, environment-variables.md, configuration.md.
Tests: 50 new tests covering registry, aliases, resolution, auto-detection,
credential resolution, and runtime provider dispatch.
Inspired by PR #33 (numman-ali) which proposed a provider registry approach.
Credit to tars90percent (PR #473) and manuelschipper (PR #420) for related
provider improvements merged earlier in this changeset.
2026-03-06 18:55:12 -08:00
|
|
|
|
"zai": "Z.AI / GLM",
|
|
|
|
|
|
"kimi-coding": "Kimi / Moonshot",
|
|
|
|
|
|
"minimax": "MiniMax",
|
|
|
|
|
|
"minimax-cn": "MiniMax (China)",
|
2026-02-20 18:17:55 -08:00
|
|
|
|
"custom": "Custom endpoint",
|
2026-02-20 17:52:46 -08:00
|
|
|
|
}
|
2026-02-20 18:17:55 -08:00
|
|
|
|
active_label = provider_labels.get(active, active)
|
2026-02-20 17:52:46 -08:00
|
|
|
|
|
|
|
|
|
|
print()
|
|
|
|
|
|
print(f" Current model: {current_model}")
|
|
|
|
|
|
print(f" Active provider: {active_label}")
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
# Step 1: Provider selection — put active provider first with marker
|
|
|
|
|
|
providers = [
|
|
|
|
|
|
("openrouter", "OpenRouter (100+ models, pay-per-use)"),
|
|
|
|
|
|
("nous", "Nous Portal (Nous Research subscription)"),
|
2026-02-25 18:25:15 -08:00
|
|
|
|
("openai-codex", "OpenAI Codex"),
|
feat: add z.ai/GLM, Kimi/Moonshot, MiniMax as first-class providers
Adds 4 new direct API-key providers (zai, kimi-coding, minimax, minimax-cn)
to the inference provider system. All use standard OpenAI-compatible
chat/completions endpoints with Bearer token auth.
Core changes:
- auth.py: Extended ProviderConfig with api_key_env_vars and base_url_env_var
fields. Added providers to PROVIDER_REGISTRY. Added provider aliases
(glm, z-ai, zhipu, kimi, moonshot). Added auto-detection of API-key
providers in resolve_provider(). Added resolve_api_key_provider_credentials()
and get_api_key_provider_status() helpers.
- runtime_provider.py: Added generic API-key provider branch in
resolve_runtime_provider() — any provider with auth_type='api_key'
is automatically handled.
- main.py: Added providers to hermes model menu with generic
_model_flow_api_key_provider() flow. Updated _has_any_provider_configured()
to check all provider env vars. Updated argparse --provider choices.
- setup.py: Added providers to setup wizard with API key prompts and
curated model lists.
- config.py: Added env vars (GLM_API_KEY, KIMI_API_KEY, MINIMAX_API_KEY,
etc.) to OPTIONAL_ENV_VARS.
- status.py: Added API key display and provider status section.
- doctor.py: Added connectivity checks for each provider endpoint.
- cli.py: Updated provider docstrings.
Docs: Updated README.md, .env.example, cli-config.yaml.example,
cli-commands.md, environment-variables.md, configuration.md.
Tests: 50 new tests covering registry, aliases, resolution, auto-detection,
credential resolution, and runtime provider dispatch.
Inspired by PR #33 (numman-ali) which proposed a provider registry approach.
Credit to tars90percent (PR #473) and manuelschipper (PR #420) for related
provider improvements merged earlier in this changeset.
2026-03-06 18:55:12 -08:00
|
|
|
|
("zai", "Z.AI / GLM (Zhipu AI direct API)"),
|
|
|
|
|
|
("kimi-coding", "Kimi / Moonshot (Moonshot AI direct API)"),
|
|
|
|
|
|
("minimax", "MiniMax (global direct API)"),
|
|
|
|
|
|
("minimax-cn", "MiniMax China (domestic direct API)"),
|
2026-02-20 17:52:46 -08:00
|
|
|
|
]
|
|
|
|
|
|
|
2026-03-09 03:45:17 -07:00
|
|
|
|
# Add user-defined custom providers from config.yaml
|
|
|
|
|
|
custom_providers_cfg = config.get("custom_providers") or []
|
|
|
|
|
|
_custom_provider_map = {} # key → {name, base_url, api_key}
|
|
|
|
|
|
if isinstance(custom_providers_cfg, list):
|
|
|
|
|
|
for entry in custom_providers_cfg:
|
|
|
|
|
|
if not isinstance(entry, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
name = entry.get("name", "").strip()
|
|
|
|
|
|
base_url = entry.get("base_url", "").strip()
|
|
|
|
|
|
if not name or not base_url:
|
|
|
|
|
|
continue
|
|
|
|
|
|
# Generate a stable key from the name
|
|
|
|
|
|
key = "custom:" + name.lower().replace(" ", "-")
|
|
|
|
|
|
short_url = base_url.replace("https://", "").replace("http://", "").rstrip("/")
|
2026-03-09 05:07:53 -07:00
|
|
|
|
saved_model = entry.get("model", "")
|
|
|
|
|
|
model_hint = f" — {saved_model}" if saved_model else ""
|
|
|
|
|
|
providers.append((key, f"{name} ({short_url}){model_hint}"))
|
2026-03-09 03:45:17 -07:00
|
|
|
|
_custom_provider_map[key] = {
|
|
|
|
|
|
"name": name,
|
|
|
|
|
|
"base_url": base_url,
|
|
|
|
|
|
"api_key": entry.get("api_key", ""),
|
2026-03-09 05:07:53 -07:00
|
|
|
|
"model": saved_model,
|
2026-03-09 03:45:17 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Always add the manual custom endpoint option last
|
|
|
|
|
|
providers.append(("custom", "Custom endpoint (enter URL manually)"))
|
|
|
|
|
|
|
2026-03-09 04:58:20 -07:00
|
|
|
|
# Add removal option if there are saved custom providers
|
|
|
|
|
|
if _custom_provider_map:
|
|
|
|
|
|
providers.append(("remove-custom", "Remove a saved custom provider"))
|
|
|
|
|
|
|
2026-02-20 17:52:46 -08:00
|
|
|
|
# Reorder so the active provider is at the top
|
feat: add z.ai/GLM, Kimi/Moonshot, MiniMax as first-class providers
Adds 4 new direct API-key providers (zai, kimi-coding, minimax, minimax-cn)
to the inference provider system. All use standard OpenAI-compatible
chat/completions endpoints with Bearer token auth.
Core changes:
- auth.py: Extended ProviderConfig with api_key_env_vars and base_url_env_var
fields. Added providers to PROVIDER_REGISTRY. Added provider aliases
(glm, z-ai, zhipu, kimi, moonshot). Added auto-detection of API-key
providers in resolve_provider(). Added resolve_api_key_provider_credentials()
and get_api_key_provider_status() helpers.
- runtime_provider.py: Added generic API-key provider branch in
resolve_runtime_provider() — any provider with auth_type='api_key'
is automatically handled.
- main.py: Added providers to hermes model menu with generic
_model_flow_api_key_provider() flow. Updated _has_any_provider_configured()
to check all provider env vars. Updated argparse --provider choices.
- setup.py: Added providers to setup wizard with API key prompts and
curated model lists.
- config.py: Added env vars (GLM_API_KEY, KIMI_API_KEY, MINIMAX_API_KEY,
etc.) to OPTIONAL_ENV_VARS.
- status.py: Added API key display and provider status section.
- doctor.py: Added connectivity checks for each provider endpoint.
- cli.py: Updated provider docstrings.
Docs: Updated README.md, .env.example, cli-config.yaml.example,
cli-commands.md, environment-variables.md, configuration.md.
Tests: 50 new tests covering registry, aliases, resolution, auto-detection,
credential resolution, and runtime provider dispatch.
Inspired by PR #33 (numman-ali) which proposed a provider registry approach.
Credit to tars90percent (PR #473) and manuelschipper (PR #420) for related
provider improvements merged earlier in this changeset.
2026-03-06 18:55:12 -08:00
|
|
|
|
known_keys = {k for k, _ in providers}
|
|
|
|
|
|
active_key = active if active in known_keys else "custom"
|
2026-02-20 17:52:46 -08:00
|
|
|
|
ordered = []
|
|
|
|
|
|
for key, label in providers:
|
|
|
|
|
|
if key == active_key:
|
|
|
|
|
|
ordered.insert(0, (key, f"{label} ← currently active"))
|
|
|
|
|
|
else:
|
|
|
|
|
|
ordered.append((key, label))
|
|
|
|
|
|
ordered.append(("cancel", "Cancel"))
|
|
|
|
|
|
|
|
|
|
|
|
provider_idx = _prompt_provider_choice([label for _, label in ordered])
|
|
|
|
|
|
if provider_idx is None or ordered[provider_idx][0] == "cancel":
|
|
|
|
|
|
print("No change.")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
selected_provider = ordered[provider_idx][0]
|
|
|
|
|
|
|
|
|
|
|
|
# Step 2: Provider-specific setup + model selection
|
|
|
|
|
|
if selected_provider == "openrouter":
|
|
|
|
|
|
_model_flow_openrouter(config, current_model)
|
|
|
|
|
|
elif selected_provider == "nous":
|
|
|
|
|
|
_model_flow_nous(config, current_model)
|
2026-02-25 18:20:38 -08:00
|
|
|
|
elif selected_provider == "openai-codex":
|
|
|
|
|
|
_model_flow_openai_codex(config, current_model)
|
2026-02-20 17:52:46 -08:00
|
|
|
|
elif selected_provider == "custom":
|
|
|
|
|
|
_model_flow_custom(config)
|
2026-03-09 03:45:17 -07:00
|
|
|
|
elif selected_provider.startswith("custom:") and selected_provider in _custom_provider_map:
|
|
|
|
|
|
_model_flow_named_custom(config, _custom_provider_map[selected_provider])
|
2026-03-09 04:58:20 -07:00
|
|
|
|
elif selected_provider == "remove-custom":
|
|
|
|
|
|
_remove_custom_provider(config)
|
fix: improve Kimi model selection — auto-detect endpoint, add missing models (#1039)
* fix: /reasoning command output ordering, display, and inline think extraction
Three issues with the /reasoning command:
1. Output interleaving: The command echo used print() while feedback
used _cprint(), causing them to render out-of-order under
prompt_toolkit's patch_stdout. Changed echo to use _cprint() so
all output renders through the same path in correct order.
2. Reasoning display not working: /reasoning show toggled a flag
but reasoning never appeared for models that embed thinking in
inline <think> blocks rather than structured API fields. Added
fallback extraction in _build_assistant_message to capture
<think> block content as reasoning when no structured reasoning
fields (reasoning, reasoning_content, reasoning_details) are
present. This feeds into both the reasoning callback (during
tool loops) and the post-response reasoning box display.
3. Feedback clarity: Added checkmarks to confirm actions, persisted
show/hide to config (was session-only before), and aligned the
status display for readability.
Tests: 7 new tests for inline think block extraction (41 total).
* feat: add /reasoning command to gateway (Telegram/Discord/etc)
The /reasoning command only existed in the CLI — messaging platforms
had no way to view or change reasoning settings. This adds:
1. /reasoning command handler in the gateway:
- No args: shows current effort level and display state
- /reasoning <level>: sets reasoning effort (none/low/medium/high/xhigh)
- /reasoning show|hide: toggles reasoning display in responses
- All changes saved to config.yaml immediately
2. Reasoning display in gateway responses:
- When show_reasoning is enabled, prepends a 'Reasoning' block
with the model's last_reasoning content before the response
- Collapses long reasoning (>15 lines) to keep messages readable
- Uses last_reasoning from run_conversation result dict
3. Plumbing:
- Added _show_reasoning attribute loaded from config at startup
- Propagated last_reasoning through _run_agent return dict
- Added /reasoning to help text and known_commands set
- Uses getattr for _show_reasoning to handle test stubs
* fix: improve Kimi model selection — auto-detect endpoint, add missing models
Kimi Coding Plan setup:
- New dedicated _model_flow_kimi() replaces the generic API-key flow
for kimi-coding. Removes the confusing 'Base URL' prompt entirely —
the endpoint is auto-detected from the API key prefix:
sk-kimi-* → api.kimi.com/coding/v1 (Kimi Coding Plan)
other → api.moonshot.ai/v1 (legacy Moonshot)
- Shows appropriate models for each endpoint:
Coding Plan: kimi-for-coding, kimi-k2.5, kimi-k2-thinking, kimi-k2-thinking-turbo
Moonshot: full model catalog
- Clears any stale KIMI_BASE_URL override so runtime auto-detection
via _resolve_kimi_base_url() works correctly.
Model catalog updates:
- Added kimi-for-coding (primary Coding Plan model) and kimi-k2-thinking-turbo
to models.py, main.py _PROVIDER_MODELS, and model_metadata.py context windows.
- Updated User-Agent from KimiCLI/1.0 to KimiCLI/1.3 (Kimi's coding
endpoint whitelists known coding agents via User-Agent sniffing).
2026-03-12 05:58:48 -07:00
|
|
|
|
elif selected_provider == "kimi-coding":
|
|
|
|
|
|
_model_flow_kimi(config, current_model)
|
|
|
|
|
|
elif selected_provider in ("zai", "minimax", "minimax-cn"):
|
feat: add z.ai/GLM, Kimi/Moonshot, MiniMax as first-class providers
Adds 4 new direct API-key providers (zai, kimi-coding, minimax, minimax-cn)
to the inference provider system. All use standard OpenAI-compatible
chat/completions endpoints with Bearer token auth.
Core changes:
- auth.py: Extended ProviderConfig with api_key_env_vars and base_url_env_var
fields. Added providers to PROVIDER_REGISTRY. Added provider aliases
(glm, z-ai, zhipu, kimi, moonshot). Added auto-detection of API-key
providers in resolve_provider(). Added resolve_api_key_provider_credentials()
and get_api_key_provider_status() helpers.
- runtime_provider.py: Added generic API-key provider branch in
resolve_runtime_provider() — any provider with auth_type='api_key'
is automatically handled.
- main.py: Added providers to hermes model menu with generic
_model_flow_api_key_provider() flow. Updated _has_any_provider_configured()
to check all provider env vars. Updated argparse --provider choices.
- setup.py: Added providers to setup wizard with API key prompts and
curated model lists.
- config.py: Added env vars (GLM_API_KEY, KIMI_API_KEY, MINIMAX_API_KEY,
etc.) to OPTIONAL_ENV_VARS.
- status.py: Added API key display and provider status section.
- doctor.py: Added connectivity checks for each provider endpoint.
- cli.py: Updated provider docstrings.
Docs: Updated README.md, .env.example, cli-config.yaml.example,
cli-commands.md, environment-variables.md, configuration.md.
Tests: 50 new tests covering registry, aliases, resolution, auto-detection,
credential resolution, and runtime provider dispatch.
Inspired by PR #33 (numman-ali) which proposed a provider registry approach.
Credit to tars90percent (PR #473) and manuelschipper (PR #420) for related
provider improvements merged earlier in this changeset.
2026-03-06 18:55:12 -08:00
|
|
|
|
_model_flow_api_key_provider(config, selected_provider, current_model)
|
2026-02-20 17:52:46 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _prompt_provider_choice(choices):
|
|
|
|
|
|
"""Show provider selection menu. Returns index or None."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from simple_term_menu import TerminalMenu
|
|
|
|
|
|
menu_items = [f" {c}" for c in choices]
|
|
|
|
|
|
menu = TerminalMenu(
|
|
|
|
|
|
menu_items, cursor_index=0,
|
|
|
|
|
|
menu_cursor="-> ", menu_cursor_style=("fg_green", "bold"),
|
|
|
|
|
|
menu_highlight_style=("fg_green",),
|
|
|
|
|
|
cycle_cursor=True, clear_screen=False,
|
|
|
|
|
|
title="Select provider:",
|
|
|
|
|
|
)
|
|
|
|
|
|
idx = menu.show()
|
|
|
|
|
|
print()
|
|
|
|
|
|
return idx
|
2026-02-25 14:10:54 -08:00
|
|
|
|
except (ImportError, NotImplementedError):
|
2026-02-20 17:52:46 -08:00
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# Fallback: numbered list
|
|
|
|
|
|
print("Select provider:")
|
|
|
|
|
|
for i, c in enumerate(choices, 1):
|
|
|
|
|
|
print(f" {i}. {c}")
|
|
|
|
|
|
print()
|
|
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
|
|
|
|
|
val = input(f"Choice [1-{len(choices)}]: ").strip()
|
|
|
|
|
|
if not val:
|
|
|
|
|
|
return None
|
|
|
|
|
|
idx = int(val) - 1
|
|
|
|
|
|
if 0 <= idx < len(choices):
|
|
|
|
|
|
return idx
|
|
|
|
|
|
print(f"Please enter 1-{len(choices)}")
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
print("Please enter a number")
|
|
|
|
|
|
except (KeyboardInterrupt, EOFError):
|
|
|
|
|
|
print()
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _model_flow_openrouter(config, current_model=""):
|
|
|
|
|
|
"""OpenRouter provider: ensure API key, then pick model."""
|
2026-02-20 18:17:55 -08:00
|
|
|
|
from hermes_cli.auth import _prompt_model_selection, _save_model_choice, deactivate_provider
|
2026-02-20 17:52:46 -08:00
|
|
|
|
from hermes_cli.config import get_env_value, save_env_value
|
|
|
|
|
|
|
|
|
|
|
|
api_key = get_env_value("OPENROUTER_API_KEY")
|
|
|
|
|
|
if not api_key:
|
|
|
|
|
|
print("No OpenRouter API key configured.")
|
|
|
|
|
|
print("Get one at: https://openrouter.ai/keys")
|
|
|
|
|
|
print()
|
|
|
|
|
|
try:
|
|
|
|
|
|
key = input("OpenRouter API key (or Enter to cancel): ").strip()
|
|
|
|
|
|
except (KeyboardInterrupt, EOFError):
|
|
|
|
|
|
print()
|
|
|
|
|
|
return
|
|
|
|
|
|
if not key:
|
|
|
|
|
|
print("Cancelled.")
|
|
|
|
|
|
return
|
|
|
|
|
|
save_env_value("OPENROUTER_API_KEY", key)
|
|
|
|
|
|
print("API key saved.")
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
2026-02-22 02:16:11 -08:00
|
|
|
|
from hermes_cli.models import model_ids
|
|
|
|
|
|
openrouter_models = model_ids()
|
2026-02-20 17:52:46 -08:00
|
|
|
|
|
2026-02-22 02:16:11 -08:00
|
|
|
|
selected = _prompt_model_selection(openrouter_models, current_model=current_model)
|
2026-02-20 17:52:46 -08:00
|
|
|
|
if selected:
|
|
|
|
|
|
# Clear any custom endpoint and set provider to openrouter
|
|
|
|
|
|
if get_env_value("OPENAI_BASE_URL"):
|
|
|
|
|
|
save_env_value("OPENAI_BASE_URL", "")
|
|
|
|
|
|
save_env_value("OPENAI_API_KEY", "")
|
|
|
|
|
|
_save_model_choice(selected)
|
2026-02-20 18:17:55 -08:00
|
|
|
|
|
|
|
|
|
|
# Update config provider and deactivate any OAuth provider
|
2026-02-20 17:52:46 -08:00
|
|
|
|
from hermes_cli.config import load_config, save_config
|
|
|
|
|
|
cfg = load_config()
|
|
|
|
|
|
model = cfg.get("model")
|
2026-03-10 17:12:34 -07:00
|
|
|
|
if not isinstance(model, dict):
|
|
|
|
|
|
model = {"default": model} if model else {}
|
|
|
|
|
|
cfg["model"] = model
|
|
|
|
|
|
model["provider"] = "openrouter"
|
|
|
|
|
|
model["base_url"] = OPENROUTER_BASE_URL
|
2026-02-20 17:52:46 -08:00
|
|
|
|
save_config(cfg)
|
2026-02-20 18:17:55 -08:00
|
|
|
|
deactivate_provider()
|
2026-02-20 17:52:46 -08:00
|
|
|
|
print(f"Default model set to: {selected} (via OpenRouter)")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print("No change.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _model_flow_nous(config, current_model=""):
|
|
|
|
|
|
"""Nous Portal provider: ensure logged in, then pick model."""
|
|
|
|
|
|
from hermes_cli.auth import (
|
|
|
|
|
|
get_provider_auth_state, _prompt_model_selection, _save_model_choice,
|
2026-02-20 18:17:55 -08:00
|
|
|
|
_update_config_for_provider, resolve_nous_runtime_credentials,
|
|
|
|
|
|
fetch_nous_models, AuthError, format_auth_error,
|
|
|
|
|
|
_login_nous, PROVIDER_REGISTRY,
|
2026-02-20 17:52:46 -08:00
|
|
|
|
)
|
2026-02-20 18:17:55 -08:00
|
|
|
|
from hermes_cli.config import get_env_value, save_env_value
|
2026-02-20 17:52:46 -08:00
|
|
|
|
import argparse
|
|
|
|
|
|
|
|
|
|
|
|
state = get_provider_auth_state("nous")
|
|
|
|
|
|
if not state or not state.get("access_token"):
|
|
|
|
|
|
print("Not logged into Nous Portal. Starting login...")
|
|
|
|
|
|
print()
|
|
|
|
|
|
try:
|
|
|
|
|
|
mock_args = argparse.Namespace(
|
|
|
|
|
|
portal_url=None, inference_url=None, client_id=None,
|
|
|
|
|
|
scope=None, no_browser=False, timeout=15.0,
|
|
|
|
|
|
ca_bundle=None, insecure=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
_login_nous(mock_args, PROVIDER_REGISTRY["nous"])
|
|
|
|
|
|
except SystemExit:
|
|
|
|
|
|
print("Login cancelled or failed.")
|
|
|
|
|
|
return
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
print(f"Login failed: {exc}")
|
|
|
|
|
|
return
|
2026-02-20 18:17:55 -08:00
|
|
|
|
# login_nous already handles model selection + config update
|
2026-02-20 17:52:46 -08:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# Already logged in — fetch models and select
|
|
|
|
|
|
print("Fetching models from Nous Portal...")
|
|
|
|
|
|
try:
|
|
|
|
|
|
creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=5 * 60)
|
|
|
|
|
|
model_ids = fetch_nous_models(
|
|
|
|
|
|
inference_base_url=creds.get("base_url", ""),
|
|
|
|
|
|
api_key=creds.get("api_key", ""),
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
2026-03-01 20:20:30 -08:00
|
|
|
|
relogin = isinstance(exc, AuthError) and exc.relogin_required
|
2026-02-20 17:52:46 -08:00
|
|
|
|
msg = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc)
|
2026-03-01 20:20:30 -08:00
|
|
|
|
if relogin:
|
|
|
|
|
|
print(f"Session expired: {msg}")
|
|
|
|
|
|
print("Re-authenticating with Nous Portal...\n")
|
|
|
|
|
|
try:
|
|
|
|
|
|
mock_args = argparse.Namespace(
|
|
|
|
|
|
portal_url=None, inference_url=None, client_id=None,
|
|
|
|
|
|
scope=None, no_browser=False, timeout=15.0,
|
|
|
|
|
|
ca_bundle=None, insecure=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
_login_nous(mock_args, PROVIDER_REGISTRY["nous"])
|
|
|
|
|
|
except Exception as login_exc:
|
|
|
|
|
|
print(f"Re-login failed: {login_exc}")
|
|
|
|
|
|
return
|
2026-02-20 17:52:46 -08:00
|
|
|
|
print(f"Could not fetch models: {msg}")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
if not model_ids:
|
|
|
|
|
|
print("No models returned by the inference API.")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
selected = _prompt_model_selection(model_ids, current_model=current_model)
|
|
|
|
|
|
if selected:
|
|
|
|
|
|
_save_model_choice(selected)
|
2026-02-20 18:17:55 -08:00
|
|
|
|
# Reactivate Nous as the provider and update config
|
|
|
|
|
|
inference_url = creds.get("base_url", "")
|
|
|
|
|
|
_update_config_for_provider("nous", inference_url)
|
|
|
|
|
|
# Clear any custom endpoint that might conflict
|
|
|
|
|
|
if get_env_value("OPENAI_BASE_URL"):
|
|
|
|
|
|
save_env_value("OPENAI_BASE_URL", "")
|
|
|
|
|
|
save_env_value("OPENAI_API_KEY", "")
|
2026-02-20 17:52:46 -08:00
|
|
|
|
print(f"Default model set to: {selected} (via Nous Portal)")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print("No change.")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-25 18:20:38 -08:00
|
|
|
|
def _model_flow_openai_codex(config, current_model=""):
|
|
|
|
|
|
"""OpenAI Codex provider: ensure logged in, then pick model."""
|
|
|
|
|
|
from hermes_cli.auth import (
|
|
|
|
|
|
get_codex_auth_status, _prompt_model_selection, _save_model_choice,
|
|
|
|
|
|
_update_config_for_provider, _login_openai_codex,
|
|
|
|
|
|
PROVIDER_REGISTRY, DEFAULT_CODEX_BASE_URL,
|
|
|
|
|
|
)
|
2026-02-25 19:27:54 -08:00
|
|
|
|
from hermes_cli.codex_models import get_codex_model_ids
|
2026-02-25 18:20:38 -08:00
|
|
|
|
from hermes_cli.config import get_env_value, save_env_value
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
|
|
|
|
|
|
status = get_codex_auth_status()
|
|
|
|
|
|
if not status.get("logged_in"):
|
|
|
|
|
|
print("Not logged into OpenAI Codex. Starting login...")
|
|
|
|
|
|
print()
|
|
|
|
|
|
try:
|
|
|
|
|
|
mock_args = argparse.Namespace()
|
|
|
|
|
|
_login_openai_codex(mock_args, PROVIDER_REGISTRY["openai-codex"])
|
|
|
|
|
|
except SystemExit:
|
|
|
|
|
|
print("Login cancelled or failed.")
|
|
|
|
|
|
return
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
print(f"Login failed: {exc}")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-02-28 21:47:51 -08:00
|
|
|
|
_codex_token = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
from hermes_cli.auth import resolve_codex_runtime_credentials
|
|
|
|
|
|
_codex_creds = resolve_codex_runtime_credentials()
|
|
|
|
|
|
_codex_token = _codex_creds.get("api_key")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
codex_models = get_codex_model_ids(access_token=_codex_token)
|
2026-02-25 18:20:38 -08:00
|
|
|
|
|
|
|
|
|
|
selected = _prompt_model_selection(codex_models, current_model=current_model)
|
|
|
|
|
|
if selected:
|
|
|
|
|
|
_save_model_choice(selected)
|
|
|
|
|
|
_update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL)
|
|
|
|
|
|
# Clear custom endpoint env vars that would otherwise override Codex.
|
|
|
|
|
|
if get_env_value("OPENAI_BASE_URL"):
|
|
|
|
|
|
save_env_value("OPENAI_BASE_URL", "")
|
|
|
|
|
|
save_env_value("OPENAI_API_KEY", "")
|
|
|
|
|
|
print(f"Default model set to: {selected} (via OpenAI Codex)")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print("No change.")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-20 17:52:46 -08:00
|
|
|
|
def _model_flow_custom(config):
|
2026-03-09 04:58:20 -07:00
|
|
|
|
"""Custom endpoint: collect URL, API key, and model name.
|
|
|
|
|
|
|
|
|
|
|
|
Automatically saves the endpoint to ``custom_providers`` in config.yaml
|
|
|
|
|
|
so it appears in the provider menu on subsequent runs.
|
|
|
|
|
|
"""
|
2026-02-20 18:17:55 -08:00
|
|
|
|
from hermes_cli.auth import _save_model_choice, deactivate_provider
|
2026-02-20 17:52:46 -08:00
|
|
|
|
from hermes_cli.config import get_env_value, save_env_value, load_config, save_config
|
|
|
|
|
|
|
|
|
|
|
|
current_url = get_env_value("OPENAI_BASE_URL") or ""
|
|
|
|
|
|
current_key = get_env_value("OPENAI_API_KEY") or ""
|
|
|
|
|
|
|
|
|
|
|
|
print("Custom OpenAI-compatible endpoint configuration:")
|
|
|
|
|
|
if current_url:
|
|
|
|
|
|
print(f" Current URL: {current_url}")
|
|
|
|
|
|
if current_key:
|
|
|
|
|
|
print(f" Current key: {current_key[:8]}...")
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
base_url = input(f"API base URL [{current_url or 'e.g. https://api.example.com/v1'}]: ").strip()
|
|
|
|
|
|
api_key = input(f"API key [{current_key[:8] + '...' if current_key else 'optional'}]: ").strip()
|
|
|
|
|
|
model_name = input("Model name (e.g. gpt-4, llama-3-70b): ").strip()
|
|
|
|
|
|
except (KeyboardInterrupt, EOFError):
|
|
|
|
|
|
print("\nCancelled.")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
if not base_url and not current_url:
|
|
|
|
|
|
print("No URL provided. Cancelled.")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# Validate URL format
|
|
|
|
|
|
effective_url = base_url or current_url
|
|
|
|
|
|
if not effective_url.startswith(("http://", "https://")):
|
|
|
|
|
|
print(f"Invalid URL: {effective_url} (must start with http:// or https://)")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-03-09 04:58:20 -07:00
|
|
|
|
effective_key = api_key or current_key
|
|
|
|
|
|
|
2026-02-20 17:52:46 -08:00
|
|
|
|
if base_url:
|
|
|
|
|
|
save_env_value("OPENAI_BASE_URL", base_url)
|
|
|
|
|
|
if api_key:
|
|
|
|
|
|
save_env_value("OPENAI_API_KEY", api_key)
|
|
|
|
|
|
|
|
|
|
|
|
if model_name:
|
|
|
|
|
|
_save_model_choice(model_name)
|
|
|
|
|
|
|
2026-02-20 18:17:55 -08:00
|
|
|
|
# Update config and deactivate any OAuth provider
|
2026-02-20 17:52:46 -08:00
|
|
|
|
cfg = load_config()
|
|
|
|
|
|
model = cfg.get("model")
|
2026-03-10 17:12:34 -07:00
|
|
|
|
if not isinstance(model, dict):
|
|
|
|
|
|
model = {"default": model} if model else {}
|
|
|
|
|
|
cfg["model"] = model
|
|
|
|
|
|
model["provider"] = "custom"
|
|
|
|
|
|
model["base_url"] = effective_url
|
2026-02-20 17:52:46 -08:00
|
|
|
|
save_config(cfg)
|
2026-02-20 18:17:55 -08:00
|
|
|
|
deactivate_provider()
|
2026-02-20 17:52:46 -08:00
|
|
|
|
|
|
|
|
|
|
print(f"Default model set to: {model_name} (via {effective_url})")
|
|
|
|
|
|
else:
|
2026-02-20 18:17:55 -08:00
|
|
|
|
if base_url or api_key:
|
|
|
|
|
|
deactivate_provider()
|
2026-02-20 17:52:46 -08:00
|
|
|
|
print("Endpoint saved. Use `/model` in chat or `hermes model` to set a model.")
|
|
|
|
|
|
|
2026-03-09 04:58:20 -07:00
|
|
|
|
# Auto-save to custom_providers so it appears in the menu next time
|
2026-03-09 05:07:53 -07:00
|
|
|
|
_save_custom_provider(effective_url, effective_key, model_name or "")
|
2026-03-09 04:58:20 -07:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-09 05:07:53 -07:00
|
|
|
|
def _save_custom_provider(base_url, api_key="", model=""):
|
2026-03-09 04:58:20 -07:00
|
|
|
|
"""Save a custom endpoint to custom_providers in config.yaml.
|
|
|
|
|
|
|
2026-03-09 05:07:53 -07:00
|
|
|
|
Deduplicates by base_url — if the URL already exists, updates the
|
|
|
|
|
|
model name but doesn't add a duplicate entry.
|
|
|
|
|
|
Auto-generates a display name from the URL hostname.
|
2026-03-09 04:58:20 -07:00
|
|
|
|
"""
|
|
|
|
|
|
from hermes_cli.config import load_config, save_config
|
|
|
|
|
|
|
|
|
|
|
|
cfg = load_config()
|
|
|
|
|
|
providers = cfg.get("custom_providers") or []
|
|
|
|
|
|
if not isinstance(providers, list):
|
|
|
|
|
|
providers = []
|
|
|
|
|
|
|
2026-03-09 05:07:53 -07:00
|
|
|
|
# Check if this URL is already saved — update model if so
|
2026-03-09 04:58:20 -07:00
|
|
|
|
for entry in providers:
|
|
|
|
|
|
if isinstance(entry, dict) and entry.get("base_url", "").rstrip("/") == base_url.rstrip("/"):
|
2026-03-09 05:07:53 -07:00
|
|
|
|
if model and entry.get("model") != model:
|
|
|
|
|
|
entry["model"] = model
|
|
|
|
|
|
cfg["custom_providers"] = providers
|
|
|
|
|
|
save_config(cfg)
|
|
|
|
|
|
return # already saved, updated model if needed
|
2026-03-09 04:58:20 -07:00
|
|
|
|
|
|
|
|
|
|
# Auto-generate a name from the URL
|
|
|
|
|
|
import re
|
|
|
|
|
|
clean = base_url.replace("https://", "").replace("http://", "").rstrip("/")
|
|
|
|
|
|
# Remove /v1 suffix for cleaner names
|
|
|
|
|
|
clean = re.sub(r"/v1/?$", "", clean)
|
|
|
|
|
|
# Use hostname:port as the name
|
|
|
|
|
|
name = clean.split("/")[0]
|
|
|
|
|
|
# Capitalize for readability
|
|
|
|
|
|
if "localhost" in name or "127.0.0.1" in name:
|
|
|
|
|
|
name = f"Local ({name})"
|
|
|
|
|
|
elif "runpod" in name.lower():
|
|
|
|
|
|
name = f"RunPod ({name})"
|
|
|
|
|
|
else:
|
|
|
|
|
|
name = name.capitalize()
|
|
|
|
|
|
|
|
|
|
|
|
entry = {"name": name, "base_url": base_url}
|
|
|
|
|
|
if api_key:
|
|
|
|
|
|
entry["api_key"] = api_key
|
2026-03-09 05:07:53 -07:00
|
|
|
|
if model:
|
|
|
|
|
|
entry["model"] = model
|
2026-03-09 04:58:20 -07:00
|
|
|
|
|
|
|
|
|
|
providers.append(entry)
|
|
|
|
|
|
cfg["custom_providers"] = providers
|
|
|
|
|
|
save_config(cfg)
|
|
|
|
|
|
print(f" 💾 Saved to custom providers as \"{name}\" (edit in config.yaml)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _remove_custom_provider(config):
|
|
|
|
|
|
"""Let the user remove a saved custom provider from config.yaml."""
|
|
|
|
|
|
from hermes_cli.config import load_config, save_config
|
|
|
|
|
|
|
|
|
|
|
|
cfg = load_config()
|
|
|
|
|
|
providers = cfg.get("custom_providers") or []
|
|
|
|
|
|
if not isinstance(providers, list) or not providers:
|
|
|
|
|
|
print("No custom providers configured.")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
print("Remove a custom provider:\n")
|
|
|
|
|
|
|
|
|
|
|
|
choices = []
|
|
|
|
|
|
for entry in providers:
|
|
|
|
|
|
if isinstance(entry, dict):
|
|
|
|
|
|
name = entry.get("name", "unnamed")
|
|
|
|
|
|
url = entry.get("base_url", "")
|
|
|
|
|
|
short_url = url.replace("https://", "").replace("http://", "").rstrip("/")
|
|
|
|
|
|
choices.append(f"{name} ({short_url})")
|
|
|
|
|
|
else:
|
|
|
|
|
|
choices.append(str(entry))
|
|
|
|
|
|
choices.append("Cancel")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from simple_term_menu import TerminalMenu
|
|
|
|
|
|
menu = TerminalMenu(
|
|
|
|
|
|
[f" {c}" for c in choices], cursor_index=0,
|
|
|
|
|
|
menu_cursor="-> ", menu_cursor_style=("fg_red", "bold"),
|
|
|
|
|
|
menu_highlight_style=("fg_red",),
|
|
|
|
|
|
cycle_cursor=True, clear_screen=False,
|
|
|
|
|
|
title="Select provider to remove:",
|
|
|
|
|
|
)
|
|
|
|
|
|
idx = menu.show()
|
|
|
|
|
|
print()
|
|
|
|
|
|
except (ImportError, NotImplementedError):
|
|
|
|
|
|
for i, c in enumerate(choices, 1):
|
|
|
|
|
|
print(f" {i}. {c}")
|
|
|
|
|
|
print()
|
|
|
|
|
|
try:
|
|
|
|
|
|
val = input(f"Choice [1-{len(choices)}]: ").strip()
|
|
|
|
|
|
idx = int(val) - 1 if val else None
|
|
|
|
|
|
except (ValueError, KeyboardInterrupt, EOFError):
|
|
|
|
|
|
idx = None
|
|
|
|
|
|
|
|
|
|
|
|
if idx is None or idx >= len(providers):
|
|
|
|
|
|
print("No change.")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
removed = providers.pop(idx)
|
|
|
|
|
|
cfg["custom_providers"] = providers
|
|
|
|
|
|
save_config(cfg)
|
|
|
|
|
|
removed_name = removed.get("name", "unnamed") if isinstance(removed, dict) else str(removed)
|
|
|
|
|
|
print(f"✅ Removed \"{removed_name}\" from custom providers.")
|
|
|
|
|
|
|
2026-02-20 17:52:46 -08:00
|
|
|
|
|
2026-03-09 03:45:17 -07:00
|
|
|
|
def _model_flow_named_custom(config, provider_info):
|
2026-03-09 05:07:53 -07:00
|
|
|
|
"""Handle a named custom provider from config.yaml custom_providers list.
|
|
|
|
|
|
|
|
|
|
|
|
If the entry has a saved model name, activates it immediately.
|
|
|
|
|
|
Otherwise probes the endpoint's /models API to let the user pick one.
|
|
|
|
|
|
"""
|
2026-03-09 03:45:17 -07:00
|
|
|
|
from hermes_cli.auth import _save_model_choice, deactivate_provider
|
|
|
|
|
|
from hermes_cli.config import save_env_value, load_config, save_config
|
|
|
|
|
|
from hermes_cli.models import fetch_api_models
|
|
|
|
|
|
|
|
|
|
|
|
name = provider_info["name"]
|
|
|
|
|
|
base_url = provider_info["base_url"]
|
|
|
|
|
|
api_key = provider_info.get("api_key", "")
|
2026-03-09 05:07:53 -07:00
|
|
|
|
saved_model = provider_info.get("model", "")
|
|
|
|
|
|
|
|
|
|
|
|
# If a model is saved, just activate immediately — no probing needed
|
|
|
|
|
|
if saved_model:
|
|
|
|
|
|
save_env_value("OPENAI_BASE_URL", base_url)
|
|
|
|
|
|
if api_key:
|
|
|
|
|
|
save_env_value("OPENAI_API_KEY", api_key)
|
|
|
|
|
|
_save_model_choice(saved_model)
|
2026-03-09 03:45:17 -07:00
|
|
|
|
|
2026-03-09 05:07:53 -07:00
|
|
|
|
cfg = load_config()
|
|
|
|
|
|
model = cfg.get("model")
|
2026-03-10 17:12:34 -07:00
|
|
|
|
if not isinstance(model, dict):
|
|
|
|
|
|
model = {"default": model} if model else {}
|
|
|
|
|
|
cfg["model"] = model
|
|
|
|
|
|
model["provider"] = "custom"
|
|
|
|
|
|
model["base_url"] = base_url
|
2026-03-09 05:07:53 -07:00
|
|
|
|
save_config(cfg)
|
|
|
|
|
|
deactivate_provider()
|
|
|
|
|
|
|
|
|
|
|
|
print(f"✅ Switched to: {saved_model}")
|
|
|
|
|
|
print(f" Provider: {name} ({base_url})")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# No saved model — probe endpoint and let user pick
|
2026-03-09 03:45:17 -07:00
|
|
|
|
print(f" Provider: {name}")
|
|
|
|
|
|
print(f" URL: {base_url}")
|
|
|
|
|
|
print()
|
2026-03-09 05:07:53 -07:00
|
|
|
|
print("No model saved for this provider. Fetching available models...")
|
2026-03-09 03:45:17 -07:00
|
|
|
|
models = fetch_api_models(api_key, base_url, timeout=8.0)
|
|
|
|
|
|
|
|
|
|
|
|
if models:
|
|
|
|
|
|
print(f"Found {len(models)} model(s):\n")
|
|
|
|
|
|
try:
|
|
|
|
|
|
from simple_term_menu import TerminalMenu
|
|
|
|
|
|
menu_items = [f" {m}" for m in models] + [" Cancel"]
|
|
|
|
|
|
menu = TerminalMenu(
|
|
|
|
|
|
menu_items, cursor_index=0,
|
|
|
|
|
|
menu_cursor="-> ", menu_cursor_style=("fg_green", "bold"),
|
|
|
|
|
|
menu_highlight_style=("fg_green",),
|
|
|
|
|
|
cycle_cursor=True, clear_screen=False,
|
|
|
|
|
|
title=f"Select model from {name}:",
|
|
|
|
|
|
)
|
|
|
|
|
|
idx = menu.show()
|
|
|
|
|
|
print()
|
|
|
|
|
|
if idx is None or idx >= len(models):
|
|
|
|
|
|
print("Cancelled.")
|
|
|
|
|
|
return
|
|
|
|
|
|
model_name = models[idx]
|
|
|
|
|
|
except (ImportError, NotImplementedError):
|
|
|
|
|
|
for i, m in enumerate(models, 1):
|
|
|
|
|
|
print(f" {i}. {m}")
|
|
|
|
|
|
print(f" {len(models) + 1}. Cancel")
|
|
|
|
|
|
print()
|
|
|
|
|
|
try:
|
|
|
|
|
|
val = input(f"Choice [1-{len(models) + 1}]: ").strip()
|
|
|
|
|
|
if not val:
|
|
|
|
|
|
print("Cancelled.")
|
|
|
|
|
|
return
|
|
|
|
|
|
idx = int(val) - 1
|
|
|
|
|
|
if idx < 0 or idx >= len(models):
|
|
|
|
|
|
print("Cancelled.")
|
|
|
|
|
|
return
|
|
|
|
|
|
model_name = models[idx]
|
|
|
|
|
|
except (ValueError, KeyboardInterrupt, EOFError):
|
|
|
|
|
|
print("\nCancelled.")
|
|
|
|
|
|
return
|
|
|
|
|
|
else:
|
|
|
|
|
|
print("Could not fetch models from endpoint. Enter model name manually.")
|
|
|
|
|
|
try:
|
|
|
|
|
|
model_name = input("Model name: ").strip()
|
|
|
|
|
|
except (KeyboardInterrupt, EOFError):
|
|
|
|
|
|
print("\nCancelled.")
|
|
|
|
|
|
return
|
|
|
|
|
|
if not model_name:
|
|
|
|
|
|
print("No model specified. Cancelled.")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-03-09 05:07:53 -07:00
|
|
|
|
# Activate and save the model to the custom_providers entry
|
2026-03-09 03:45:17 -07:00
|
|
|
|
save_env_value("OPENAI_BASE_URL", base_url)
|
|
|
|
|
|
if api_key:
|
|
|
|
|
|
save_env_value("OPENAI_API_KEY", api_key)
|
|
|
|
|
|
_save_model_choice(model_name)
|
|
|
|
|
|
|
|
|
|
|
|
cfg = load_config()
|
|
|
|
|
|
model = cfg.get("model")
|
2026-03-10 17:12:34 -07:00
|
|
|
|
if not isinstance(model, dict):
|
|
|
|
|
|
model = {"default": model} if model else {}
|
|
|
|
|
|
cfg["model"] = model
|
|
|
|
|
|
model["provider"] = "custom"
|
|
|
|
|
|
model["base_url"] = base_url
|
2026-03-09 03:45:17 -07:00
|
|
|
|
save_config(cfg)
|
|
|
|
|
|
deactivate_provider()
|
|
|
|
|
|
|
2026-03-09 05:07:53 -07:00
|
|
|
|
# Save model name to the custom_providers entry for next time
|
|
|
|
|
|
_save_custom_provider(base_url, api_key, model_name)
|
|
|
|
|
|
|
2026-03-09 03:45:17 -07:00
|
|
|
|
print(f"\n✅ Model set to: {model_name}")
|
|
|
|
|
|
print(f" Provider: {name} ({base_url})")
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: add z.ai/GLM, Kimi/Moonshot, MiniMax as first-class providers
Adds 4 new direct API-key providers (zai, kimi-coding, minimax, minimax-cn)
to the inference provider system. All use standard OpenAI-compatible
chat/completions endpoints with Bearer token auth.
Core changes:
- auth.py: Extended ProviderConfig with api_key_env_vars and base_url_env_var
fields. Added providers to PROVIDER_REGISTRY. Added provider aliases
(glm, z-ai, zhipu, kimi, moonshot). Added auto-detection of API-key
providers in resolve_provider(). Added resolve_api_key_provider_credentials()
and get_api_key_provider_status() helpers.
- runtime_provider.py: Added generic API-key provider branch in
resolve_runtime_provider() — any provider with auth_type='api_key'
is automatically handled.
- main.py: Added providers to hermes model menu with generic
_model_flow_api_key_provider() flow. Updated _has_any_provider_configured()
to check all provider env vars. Updated argparse --provider choices.
- setup.py: Added providers to setup wizard with API key prompts and
curated model lists.
- config.py: Added env vars (GLM_API_KEY, KIMI_API_KEY, MINIMAX_API_KEY,
etc.) to OPTIONAL_ENV_VARS.
- status.py: Added API key display and provider status section.
- doctor.py: Added connectivity checks for each provider endpoint.
- cli.py: Updated provider docstrings.
Docs: Updated README.md, .env.example, cli-config.yaml.example,
cli-commands.md, environment-variables.md, configuration.md.
Tests: 50 new tests covering registry, aliases, resolution, auto-detection,
credential resolution, and runtime provider dispatch.
Inspired by PR #33 (numman-ali) which proposed a provider registry approach.
Credit to tars90percent (PR #473) and manuelschipper (PR #420) for related
provider improvements merged earlier in this changeset.
2026-03-06 18:55:12 -08:00
|
|
|
|
# Curated model lists for direct API-key providers
|
|
|
|
|
|
_PROVIDER_MODELS = {
|
|
|
|
|
|
"zai": [
|
|
|
|
|
|
"glm-5",
|
|
|
|
|
|
"glm-4.7",
|
|
|
|
|
|
"glm-4.5",
|
|
|
|
|
|
"glm-4.5-flash",
|
|
|
|
|
|
],
|
|
|
|
|
|
"kimi-coding": [
|
fix: improve Kimi model selection — auto-detect endpoint, add missing models (#1039)
* fix: /reasoning command output ordering, display, and inline think extraction
Three issues with the /reasoning command:
1. Output interleaving: The command echo used print() while feedback
used _cprint(), causing them to render out-of-order under
prompt_toolkit's patch_stdout. Changed echo to use _cprint() so
all output renders through the same path in correct order.
2. Reasoning display not working: /reasoning show toggled a flag
but reasoning never appeared for models that embed thinking in
inline <think> blocks rather than structured API fields. Added
fallback extraction in _build_assistant_message to capture
<think> block content as reasoning when no structured reasoning
fields (reasoning, reasoning_content, reasoning_details) are
present. This feeds into both the reasoning callback (during
tool loops) and the post-response reasoning box display.
3. Feedback clarity: Added checkmarks to confirm actions, persisted
show/hide to config (was session-only before), and aligned the
status display for readability.
Tests: 7 new tests for inline think block extraction (41 total).
* feat: add /reasoning command to gateway (Telegram/Discord/etc)
The /reasoning command only existed in the CLI — messaging platforms
had no way to view or change reasoning settings. This adds:
1. /reasoning command handler in the gateway:
- No args: shows current effort level and display state
- /reasoning <level>: sets reasoning effort (none/low/medium/high/xhigh)
- /reasoning show|hide: toggles reasoning display in responses
- All changes saved to config.yaml immediately
2. Reasoning display in gateway responses:
- When show_reasoning is enabled, prepends a 'Reasoning' block
with the model's last_reasoning content before the response
- Collapses long reasoning (>15 lines) to keep messages readable
- Uses last_reasoning from run_conversation result dict
3. Plumbing:
- Added _show_reasoning attribute loaded from config at startup
- Propagated last_reasoning through _run_agent return dict
- Added /reasoning to help text and known_commands set
- Uses getattr for _show_reasoning to handle test stubs
* fix: improve Kimi model selection — auto-detect endpoint, add missing models
Kimi Coding Plan setup:
- New dedicated _model_flow_kimi() replaces the generic API-key flow
for kimi-coding. Removes the confusing 'Base URL' prompt entirely —
the endpoint is auto-detected from the API key prefix:
sk-kimi-* → api.kimi.com/coding/v1 (Kimi Coding Plan)
other → api.moonshot.ai/v1 (legacy Moonshot)
- Shows appropriate models for each endpoint:
Coding Plan: kimi-for-coding, kimi-k2.5, kimi-k2-thinking, kimi-k2-thinking-turbo
Moonshot: full model catalog
- Clears any stale KIMI_BASE_URL override so runtime auto-detection
via _resolve_kimi_base_url() works correctly.
Model catalog updates:
- Added kimi-for-coding (primary Coding Plan model) and kimi-k2-thinking-turbo
to models.py, main.py _PROVIDER_MODELS, and model_metadata.py context windows.
- Updated User-Agent from KimiCLI/1.0 to KimiCLI/1.3 (Kimi's coding
endpoint whitelists known coding agents via User-Agent sniffing).
2026-03-12 05:58:48 -07:00
|
|
|
|
"kimi-for-coding",
|
feat: add z.ai/GLM, Kimi/Moonshot, MiniMax as first-class providers
Adds 4 new direct API-key providers (zai, kimi-coding, minimax, minimax-cn)
to the inference provider system. All use standard OpenAI-compatible
chat/completions endpoints with Bearer token auth.
Core changes:
- auth.py: Extended ProviderConfig with api_key_env_vars and base_url_env_var
fields. Added providers to PROVIDER_REGISTRY. Added provider aliases
(glm, z-ai, zhipu, kimi, moonshot). Added auto-detection of API-key
providers in resolve_provider(). Added resolve_api_key_provider_credentials()
and get_api_key_provider_status() helpers.
- runtime_provider.py: Added generic API-key provider branch in
resolve_runtime_provider() — any provider with auth_type='api_key'
is automatically handled.
- main.py: Added providers to hermes model menu with generic
_model_flow_api_key_provider() flow. Updated _has_any_provider_configured()
to check all provider env vars. Updated argparse --provider choices.
- setup.py: Added providers to setup wizard with API key prompts and
curated model lists.
- config.py: Added env vars (GLM_API_KEY, KIMI_API_KEY, MINIMAX_API_KEY,
etc.) to OPTIONAL_ENV_VARS.
- status.py: Added API key display and provider status section.
- doctor.py: Added connectivity checks for each provider endpoint.
- cli.py: Updated provider docstrings.
Docs: Updated README.md, .env.example, cli-config.yaml.example,
cli-commands.md, environment-variables.md, configuration.md.
Tests: 50 new tests covering registry, aliases, resolution, auto-detection,
credential resolution, and runtime provider dispatch.
Inspired by PR #33 (numman-ali) which proposed a provider registry approach.
Credit to tars90percent (PR #473) and manuelschipper (PR #420) for related
provider improvements merged earlier in this changeset.
2026-03-06 18:55:12 -08:00
|
|
|
|
"kimi-k2.5",
|
|
|
|
|
|
"kimi-k2-thinking",
|
fix: improve Kimi model selection — auto-detect endpoint, add missing models (#1039)
* fix: /reasoning command output ordering, display, and inline think extraction
Three issues with the /reasoning command:
1. Output interleaving: The command echo used print() while feedback
used _cprint(), causing them to render out-of-order under
prompt_toolkit's patch_stdout. Changed echo to use _cprint() so
all output renders through the same path in correct order.
2. Reasoning display not working: /reasoning show toggled a flag
but reasoning never appeared for models that embed thinking in
inline <think> blocks rather than structured API fields. Added
fallback extraction in _build_assistant_message to capture
<think> block content as reasoning when no structured reasoning
fields (reasoning, reasoning_content, reasoning_details) are
present. This feeds into both the reasoning callback (during
tool loops) and the post-response reasoning box display.
3. Feedback clarity: Added checkmarks to confirm actions, persisted
show/hide to config (was session-only before), and aligned the
status display for readability.
Tests: 7 new tests for inline think block extraction (41 total).
* feat: add /reasoning command to gateway (Telegram/Discord/etc)
The /reasoning command only existed in the CLI — messaging platforms
had no way to view or change reasoning settings. This adds:
1. /reasoning command handler in the gateway:
- No args: shows current effort level and display state
- /reasoning <level>: sets reasoning effort (none/low/medium/high/xhigh)
- /reasoning show|hide: toggles reasoning display in responses
- All changes saved to config.yaml immediately
2. Reasoning display in gateway responses:
- When show_reasoning is enabled, prepends a 'Reasoning' block
with the model's last_reasoning content before the response
- Collapses long reasoning (>15 lines) to keep messages readable
- Uses last_reasoning from run_conversation result dict
3. Plumbing:
- Added _show_reasoning attribute loaded from config at startup
- Propagated last_reasoning through _run_agent return dict
- Added /reasoning to help text and known_commands set
- Uses getattr for _show_reasoning to handle test stubs
* fix: improve Kimi model selection — auto-detect endpoint, add missing models
Kimi Coding Plan setup:
- New dedicated _model_flow_kimi() replaces the generic API-key flow
for kimi-coding. Removes the confusing 'Base URL' prompt entirely —
the endpoint is auto-detected from the API key prefix:
sk-kimi-* → api.kimi.com/coding/v1 (Kimi Coding Plan)
other → api.moonshot.ai/v1 (legacy Moonshot)
- Shows appropriate models for each endpoint:
Coding Plan: kimi-for-coding, kimi-k2.5, kimi-k2-thinking, kimi-k2-thinking-turbo
Moonshot: full model catalog
- Clears any stale KIMI_BASE_URL override so runtime auto-detection
via _resolve_kimi_base_url() works correctly.
Model catalog updates:
- Added kimi-for-coding (primary Coding Plan model) and kimi-k2-thinking-turbo
to models.py, main.py _PROVIDER_MODELS, and model_metadata.py context windows.
- Updated User-Agent from KimiCLI/1.0 to KimiCLI/1.3 (Kimi's coding
endpoint whitelists known coding agents via User-Agent sniffing).
2026-03-12 05:58:48 -07:00
|
|
|
|
"kimi-k2-thinking-turbo",
|
feat: add z.ai/GLM, Kimi/Moonshot, MiniMax as first-class providers
Adds 4 new direct API-key providers (zai, kimi-coding, minimax, minimax-cn)
to the inference provider system. All use standard OpenAI-compatible
chat/completions endpoints with Bearer token auth.
Core changes:
- auth.py: Extended ProviderConfig with api_key_env_vars and base_url_env_var
fields. Added providers to PROVIDER_REGISTRY. Added provider aliases
(glm, z-ai, zhipu, kimi, moonshot). Added auto-detection of API-key
providers in resolve_provider(). Added resolve_api_key_provider_credentials()
and get_api_key_provider_status() helpers.
- runtime_provider.py: Added generic API-key provider branch in
resolve_runtime_provider() — any provider with auth_type='api_key'
is automatically handled.
- main.py: Added providers to hermes model menu with generic
_model_flow_api_key_provider() flow. Updated _has_any_provider_configured()
to check all provider env vars. Updated argparse --provider choices.
- setup.py: Added providers to setup wizard with API key prompts and
curated model lists.
- config.py: Added env vars (GLM_API_KEY, KIMI_API_KEY, MINIMAX_API_KEY,
etc.) to OPTIONAL_ENV_VARS.
- status.py: Added API key display and provider status section.
- doctor.py: Added connectivity checks for each provider endpoint.
- cli.py: Updated provider docstrings.
Docs: Updated README.md, .env.example, cli-config.yaml.example,
cli-commands.md, environment-variables.md, configuration.md.
Tests: 50 new tests covering registry, aliases, resolution, auto-detection,
credential resolution, and runtime provider dispatch.
Inspired by PR #33 (numman-ali) which proposed a provider registry approach.
Credit to tars90percent (PR #473) and manuelschipper (PR #420) for related
provider improvements merged earlier in this changeset.
2026-03-06 18:55:12 -08:00
|
|
|
|
"kimi-k2-turbo-preview",
|
|
|
|
|
|
"kimi-k2-0905-preview",
|
|
|
|
|
|
],
|
|
|
|
|
|
"minimax": [
|
|
|
|
|
|
"MiniMax-M2.5",
|
|
|
|
|
|
"MiniMax-M2.5-highspeed",
|
|
|
|
|
|
"MiniMax-M2.1",
|
|
|
|
|
|
],
|
|
|
|
|
|
"minimax-cn": [
|
|
|
|
|
|
"MiniMax-M2.5",
|
|
|
|
|
|
"MiniMax-M2.5-highspeed",
|
|
|
|
|
|
"MiniMax-M2.1",
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
fix: improve Kimi model selection — auto-detect endpoint, add missing models (#1039)
* fix: /reasoning command output ordering, display, and inline think extraction
Three issues with the /reasoning command:
1. Output interleaving: The command echo used print() while feedback
used _cprint(), causing them to render out-of-order under
prompt_toolkit's patch_stdout. Changed echo to use _cprint() so
all output renders through the same path in correct order.
2. Reasoning display not working: /reasoning show toggled a flag
but reasoning never appeared for models that embed thinking in
inline <think> blocks rather than structured API fields. Added
fallback extraction in _build_assistant_message to capture
<think> block content as reasoning when no structured reasoning
fields (reasoning, reasoning_content, reasoning_details) are
present. This feeds into both the reasoning callback (during
tool loops) and the post-response reasoning box display.
3. Feedback clarity: Added checkmarks to confirm actions, persisted
show/hide to config (was session-only before), and aligned the
status display for readability.
Tests: 7 new tests for inline think block extraction (41 total).
* feat: add /reasoning command to gateway (Telegram/Discord/etc)
The /reasoning command only existed in the CLI — messaging platforms
had no way to view or change reasoning settings. This adds:
1. /reasoning command handler in the gateway:
- No args: shows current effort level and display state
- /reasoning <level>: sets reasoning effort (none/low/medium/high/xhigh)
- /reasoning show|hide: toggles reasoning display in responses
- All changes saved to config.yaml immediately
2. Reasoning display in gateway responses:
- When show_reasoning is enabled, prepends a 'Reasoning' block
with the model's last_reasoning content before the response
- Collapses long reasoning (>15 lines) to keep messages readable
- Uses last_reasoning from run_conversation result dict
3. Plumbing:
- Added _show_reasoning attribute loaded from config at startup
- Propagated last_reasoning through _run_agent return dict
- Added /reasoning to help text and known_commands set
- Uses getattr for _show_reasoning to handle test stubs
* fix: improve Kimi model selection — auto-detect endpoint, add missing models
Kimi Coding Plan setup:
- New dedicated _model_flow_kimi() replaces the generic API-key flow
for kimi-coding. Removes the confusing 'Base URL' prompt entirely —
the endpoint is auto-detected from the API key prefix:
sk-kimi-* → api.kimi.com/coding/v1 (Kimi Coding Plan)
other → api.moonshot.ai/v1 (legacy Moonshot)
- Shows appropriate models for each endpoint:
Coding Plan: kimi-for-coding, kimi-k2.5, kimi-k2-thinking, kimi-k2-thinking-turbo
Moonshot: full model catalog
- Clears any stale KIMI_BASE_URL override so runtime auto-detection
via _resolve_kimi_base_url() works correctly.
Model catalog updates:
- Added kimi-for-coding (primary Coding Plan model) and kimi-k2-thinking-turbo
to models.py, main.py _PROVIDER_MODELS, and model_metadata.py context windows.
- Updated User-Agent from KimiCLI/1.0 to KimiCLI/1.3 (Kimi's coding
endpoint whitelists known coding agents via User-Agent sniffing).
2026-03-12 05:58:48 -07:00
|
|
|
|
def _model_flow_kimi(config, current_model=""):
|
|
|
|
|
|
"""Kimi / Moonshot model selection with automatic endpoint routing.
|
|
|
|
|
|
|
|
|
|
|
|
- sk-kimi-* keys → api.kimi.com/coding/v1 (Kimi Coding Plan)
|
|
|
|
|
|
- Other keys → api.moonshot.ai/v1 (legacy Moonshot)
|
|
|
|
|
|
|
|
|
|
|
|
No manual base URL prompt — endpoint is determined by key prefix.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from hermes_cli.auth import (
|
|
|
|
|
|
PROVIDER_REGISTRY, KIMI_CODE_BASE_URL, _prompt_model_selection,
|
|
|
|
|
|
_save_model_choice, deactivate_provider,
|
|
|
|
|
|
)
|
|
|
|
|
|
from hermes_cli.config import get_env_value, save_env_value, load_config, save_config
|
|
|
|
|
|
|
|
|
|
|
|
provider_id = "kimi-coding"
|
|
|
|
|
|
pconfig = PROVIDER_REGISTRY[provider_id]
|
|
|
|
|
|
key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else ""
|
|
|
|
|
|
base_url_env = pconfig.base_url_env_var or ""
|
|
|
|
|
|
|
|
|
|
|
|
# Step 1: Check / prompt for API key
|
|
|
|
|
|
existing_key = ""
|
|
|
|
|
|
for ev in pconfig.api_key_env_vars:
|
|
|
|
|
|
existing_key = get_env_value(ev) or os.getenv(ev, "")
|
|
|
|
|
|
if existing_key:
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
if not existing_key:
|
|
|
|
|
|
print(f"No {pconfig.name} API key configured.")
|
|
|
|
|
|
if key_env:
|
|
|
|
|
|
try:
|
|
|
|
|
|
new_key = input(f"{key_env} (or Enter to cancel): ").strip()
|
|
|
|
|
|
except (KeyboardInterrupt, EOFError):
|
|
|
|
|
|
print()
|
|
|
|
|
|
return
|
|
|
|
|
|
if not new_key:
|
|
|
|
|
|
print("Cancelled.")
|
|
|
|
|
|
return
|
|
|
|
|
|
save_env_value(key_env, new_key)
|
|
|
|
|
|
existing_key = new_key
|
|
|
|
|
|
print("API key saved.")
|
|
|
|
|
|
print()
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(f" {pconfig.name} API key: {existing_key[:8]}... ✓")
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
# Step 2: Auto-detect endpoint from key prefix
|
|
|
|
|
|
is_coding_plan = existing_key.startswith("sk-kimi-")
|
|
|
|
|
|
if is_coding_plan:
|
|
|
|
|
|
effective_base = KIMI_CODE_BASE_URL
|
|
|
|
|
|
print(f" Detected Kimi Coding Plan key → {effective_base}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
effective_base = pconfig.inference_base_url
|
|
|
|
|
|
print(f" Using Moonshot endpoint → {effective_base}")
|
|
|
|
|
|
# Clear any manual base URL override so auto-detection works at runtime
|
|
|
|
|
|
if base_url_env and get_env_value(base_url_env):
|
|
|
|
|
|
save_env_value(base_url_env, "")
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
# Step 3: Model selection — show appropriate models for the endpoint
|
|
|
|
|
|
if is_coding_plan:
|
|
|
|
|
|
# Coding Plan models (kimi-for-coding first)
|
|
|
|
|
|
model_list = [
|
|
|
|
|
|
"kimi-for-coding",
|
|
|
|
|
|
"kimi-k2.5",
|
|
|
|
|
|
"kimi-k2-thinking",
|
|
|
|
|
|
"kimi-k2-thinking-turbo",
|
|
|
|
|
|
]
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Legacy Moonshot models
|
|
|
|
|
|
model_list = _PROVIDER_MODELS.get(provider_id, [])
|
|
|
|
|
|
|
|
|
|
|
|
if model_list:
|
|
|
|
|
|
selected = _prompt_model_selection(model_list, current_model=current_model)
|
|
|
|
|
|
else:
|
|
|
|
|
|
try:
|
|
|
|
|
|
selected = input("Enter model name: ").strip()
|
|
|
|
|
|
except (KeyboardInterrupt, EOFError):
|
|
|
|
|
|
selected = None
|
|
|
|
|
|
|
|
|
|
|
|
if selected:
|
|
|
|
|
|
# Clear custom endpoint if set (avoid confusion)
|
|
|
|
|
|
if get_env_value("OPENAI_BASE_URL"):
|
|
|
|
|
|
save_env_value("OPENAI_BASE_URL", "")
|
|
|
|
|
|
save_env_value("OPENAI_API_KEY", "")
|
|
|
|
|
|
|
|
|
|
|
|
_save_model_choice(selected)
|
|
|
|
|
|
|
|
|
|
|
|
# Update config with provider and base URL
|
|
|
|
|
|
cfg = load_config()
|
|
|
|
|
|
model = cfg.get("model")
|
|
|
|
|
|
if not isinstance(model, dict):
|
|
|
|
|
|
model = {"default": model} if model else {}
|
|
|
|
|
|
cfg["model"] = model
|
|
|
|
|
|
model["provider"] = provider_id
|
|
|
|
|
|
model["base_url"] = effective_base
|
|
|
|
|
|
save_config(cfg)
|
|
|
|
|
|
deactivate_provider()
|
|
|
|
|
|
|
|
|
|
|
|
endpoint_label = "Kimi Coding" if is_coding_plan else "Moonshot"
|
|
|
|
|
|
print(f"Default model set to: {selected} (via {endpoint_label})")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print("No change.")
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: add z.ai/GLM, Kimi/Moonshot, MiniMax as first-class providers
Adds 4 new direct API-key providers (zai, kimi-coding, minimax, minimax-cn)
to the inference provider system. All use standard OpenAI-compatible
chat/completions endpoints with Bearer token auth.
Core changes:
- auth.py: Extended ProviderConfig with api_key_env_vars and base_url_env_var
fields. Added providers to PROVIDER_REGISTRY. Added provider aliases
(glm, z-ai, zhipu, kimi, moonshot). Added auto-detection of API-key
providers in resolve_provider(). Added resolve_api_key_provider_credentials()
and get_api_key_provider_status() helpers.
- runtime_provider.py: Added generic API-key provider branch in
resolve_runtime_provider() — any provider with auth_type='api_key'
is automatically handled.
- main.py: Added providers to hermes model menu with generic
_model_flow_api_key_provider() flow. Updated _has_any_provider_configured()
to check all provider env vars. Updated argparse --provider choices.
- setup.py: Added providers to setup wizard with API key prompts and
curated model lists.
- config.py: Added env vars (GLM_API_KEY, KIMI_API_KEY, MINIMAX_API_KEY,
etc.) to OPTIONAL_ENV_VARS.
- status.py: Added API key display and provider status section.
- doctor.py: Added connectivity checks for each provider endpoint.
- cli.py: Updated provider docstrings.
Docs: Updated README.md, .env.example, cli-config.yaml.example,
cli-commands.md, environment-variables.md, configuration.md.
Tests: 50 new tests covering registry, aliases, resolution, auto-detection,
credential resolution, and runtime provider dispatch.
Inspired by PR #33 (numman-ali) which proposed a provider registry approach.
Credit to tars90percent (PR #473) and manuelschipper (PR #420) for related
provider improvements merged earlier in this changeset.
2026-03-06 18:55:12 -08:00
|
|
|
|
def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
fix: improve Kimi model selection — auto-detect endpoint, add missing models (#1039)
* fix: /reasoning command output ordering, display, and inline think extraction
Three issues with the /reasoning command:
1. Output interleaving: The command echo used print() while feedback
used _cprint(), causing them to render out-of-order under
prompt_toolkit's patch_stdout. Changed echo to use _cprint() so
all output renders through the same path in correct order.
2. Reasoning display not working: /reasoning show toggled a flag
but reasoning never appeared for models that embed thinking in
inline <think> blocks rather than structured API fields. Added
fallback extraction in _build_assistant_message to capture
<think> block content as reasoning when no structured reasoning
fields (reasoning, reasoning_content, reasoning_details) are
present. This feeds into both the reasoning callback (during
tool loops) and the post-response reasoning box display.
3. Feedback clarity: Added checkmarks to confirm actions, persisted
show/hide to config (was session-only before), and aligned the
status display for readability.
Tests: 7 new tests for inline think block extraction (41 total).
* feat: add /reasoning command to gateway (Telegram/Discord/etc)
The /reasoning command only existed in the CLI — messaging platforms
had no way to view or change reasoning settings. This adds:
1. /reasoning command handler in the gateway:
- No args: shows current effort level and display state
- /reasoning <level>: sets reasoning effort (none/low/medium/high/xhigh)
- /reasoning show|hide: toggles reasoning display in responses
- All changes saved to config.yaml immediately
2. Reasoning display in gateway responses:
- When show_reasoning is enabled, prepends a 'Reasoning' block
with the model's last_reasoning content before the response
- Collapses long reasoning (>15 lines) to keep messages readable
- Uses last_reasoning from run_conversation result dict
3. Plumbing:
- Added _show_reasoning attribute loaded from config at startup
- Propagated last_reasoning through _run_agent return dict
- Added /reasoning to help text and known_commands set
- Uses getattr for _show_reasoning to handle test stubs
* fix: improve Kimi model selection — auto-detect endpoint, add missing models
Kimi Coding Plan setup:
- New dedicated _model_flow_kimi() replaces the generic API-key flow
for kimi-coding. Removes the confusing 'Base URL' prompt entirely —
the endpoint is auto-detected from the API key prefix:
sk-kimi-* → api.kimi.com/coding/v1 (Kimi Coding Plan)
other → api.moonshot.ai/v1 (legacy Moonshot)
- Shows appropriate models for each endpoint:
Coding Plan: kimi-for-coding, kimi-k2.5, kimi-k2-thinking, kimi-k2-thinking-turbo
Moonshot: full model catalog
- Clears any stale KIMI_BASE_URL override so runtime auto-detection
via _resolve_kimi_base_url() works correctly.
Model catalog updates:
- Added kimi-for-coding (primary Coding Plan model) and kimi-k2-thinking-turbo
to models.py, main.py _PROVIDER_MODELS, and model_metadata.py context windows.
- Updated User-Agent from KimiCLI/1.0 to KimiCLI/1.3 (Kimi's coding
endpoint whitelists known coding agents via User-Agent sniffing).
2026-03-12 05:58:48 -07:00
|
|
|
|
"""Generic flow for API-key providers (z.ai, MiniMax)."""
|
feat: add z.ai/GLM, Kimi/Moonshot, MiniMax as first-class providers
Adds 4 new direct API-key providers (zai, kimi-coding, minimax, minimax-cn)
to the inference provider system. All use standard OpenAI-compatible
chat/completions endpoints with Bearer token auth.
Core changes:
- auth.py: Extended ProviderConfig with api_key_env_vars and base_url_env_var
fields. Added providers to PROVIDER_REGISTRY. Added provider aliases
(glm, z-ai, zhipu, kimi, moonshot). Added auto-detection of API-key
providers in resolve_provider(). Added resolve_api_key_provider_credentials()
and get_api_key_provider_status() helpers.
- runtime_provider.py: Added generic API-key provider branch in
resolve_runtime_provider() — any provider with auth_type='api_key'
is automatically handled.
- main.py: Added providers to hermes model menu with generic
_model_flow_api_key_provider() flow. Updated _has_any_provider_configured()
to check all provider env vars. Updated argparse --provider choices.
- setup.py: Added providers to setup wizard with API key prompts and
curated model lists.
- config.py: Added env vars (GLM_API_KEY, KIMI_API_KEY, MINIMAX_API_KEY,
etc.) to OPTIONAL_ENV_VARS.
- status.py: Added API key display and provider status section.
- doctor.py: Added connectivity checks for each provider endpoint.
- cli.py: Updated provider docstrings.
Docs: Updated README.md, .env.example, cli-config.yaml.example,
cli-commands.md, environment-variables.md, configuration.md.
Tests: 50 new tests covering registry, aliases, resolution, auto-detection,
credential resolution, and runtime provider dispatch.
Inspired by PR #33 (numman-ali) which proposed a provider registry approach.
Credit to tars90percent (PR #473) and manuelschipper (PR #420) for related
provider improvements merged earlier in this changeset.
2026-03-06 18:55:12 -08:00
|
|
|
|
from hermes_cli.auth import (
|
|
|
|
|
|
PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice,
|
|
|
|
|
|
_update_config_for_provider, deactivate_provider,
|
|
|
|
|
|
)
|
|
|
|
|
|
from hermes_cli.config import get_env_value, save_env_value, load_config, save_config
|
|
|
|
|
|
|
|
|
|
|
|
pconfig = PROVIDER_REGISTRY[provider_id]
|
|
|
|
|
|
key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else ""
|
|
|
|
|
|
base_url_env = pconfig.base_url_env_var or ""
|
|
|
|
|
|
|
|
|
|
|
|
# Check / prompt for API key
|
|
|
|
|
|
existing_key = ""
|
|
|
|
|
|
for ev in pconfig.api_key_env_vars:
|
|
|
|
|
|
existing_key = get_env_value(ev) or os.getenv(ev, "")
|
|
|
|
|
|
if existing_key:
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
if not existing_key:
|
|
|
|
|
|
print(f"No {pconfig.name} API key configured.")
|
|
|
|
|
|
if key_env:
|
|
|
|
|
|
try:
|
|
|
|
|
|
new_key = input(f"{key_env} (or Enter to cancel): ").strip()
|
|
|
|
|
|
except (KeyboardInterrupt, EOFError):
|
|
|
|
|
|
print()
|
|
|
|
|
|
return
|
|
|
|
|
|
if not new_key:
|
|
|
|
|
|
print("Cancelled.")
|
|
|
|
|
|
return
|
|
|
|
|
|
save_env_value(key_env, new_key)
|
|
|
|
|
|
print("API key saved.")
|
|
|
|
|
|
print()
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(f" {pconfig.name} API key: {existing_key[:8]}... ✓")
|
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
|
|
# Optional base URL override
|
|
|
|
|
|
current_base = ""
|
|
|
|
|
|
if base_url_env:
|
|
|
|
|
|
current_base = get_env_value(base_url_env) or os.getenv(base_url_env, "")
|
|
|
|
|
|
effective_base = current_base or pconfig.inference_base_url
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
override = input(f"Base URL [{effective_base}]: ").strip()
|
|
|
|
|
|
except (KeyboardInterrupt, EOFError):
|
|
|
|
|
|
print()
|
|
|
|
|
|
override = ""
|
|
|
|
|
|
if override and base_url_env:
|
|
|
|
|
|
save_env_value(base_url_env, override)
|
|
|
|
|
|
effective_base = override
|
|
|
|
|
|
|
|
|
|
|
|
# Model selection
|
|
|
|
|
|
model_list = _PROVIDER_MODELS.get(provider_id, [])
|
|
|
|
|
|
if model_list:
|
|
|
|
|
|
selected = _prompt_model_selection(model_list, current_model=current_model)
|
|
|
|
|
|
else:
|
|
|
|
|
|
try:
|
|
|
|
|
|
selected = input("Model name: ").strip()
|
|
|
|
|
|
except (KeyboardInterrupt, EOFError):
|
|
|
|
|
|
selected = None
|
|
|
|
|
|
|
|
|
|
|
|
if selected:
|
|
|
|
|
|
# Clear custom endpoint if set (avoid confusion)
|
|
|
|
|
|
if get_env_value("OPENAI_BASE_URL"):
|
|
|
|
|
|
save_env_value("OPENAI_BASE_URL", "")
|
|
|
|
|
|
save_env_value("OPENAI_API_KEY", "")
|
|
|
|
|
|
|
|
|
|
|
|
_save_model_choice(selected)
|
|
|
|
|
|
|
|
|
|
|
|
# Update config with provider and base URL
|
|
|
|
|
|
cfg = load_config()
|
|
|
|
|
|
model = cfg.get("model")
|
2026-03-10 17:12:34 -07:00
|
|
|
|
if not isinstance(model, dict):
|
|
|
|
|
|
model = {"default": model} if model else {}
|
|
|
|
|
|
cfg["model"] = model
|
|
|
|
|
|
model["provider"] = provider_id
|
|
|
|
|
|
model["base_url"] = effective_base
|
feat: add z.ai/GLM, Kimi/Moonshot, MiniMax as first-class providers
Adds 4 new direct API-key providers (zai, kimi-coding, minimax, minimax-cn)
to the inference provider system. All use standard OpenAI-compatible
chat/completions endpoints with Bearer token auth.
Core changes:
- auth.py: Extended ProviderConfig with api_key_env_vars and base_url_env_var
fields. Added providers to PROVIDER_REGISTRY. Added provider aliases
(glm, z-ai, zhipu, kimi, moonshot). Added auto-detection of API-key
providers in resolve_provider(). Added resolve_api_key_provider_credentials()
and get_api_key_provider_status() helpers.
- runtime_provider.py: Added generic API-key provider branch in
resolve_runtime_provider() — any provider with auth_type='api_key'
is automatically handled.
- main.py: Added providers to hermes model menu with generic
_model_flow_api_key_provider() flow. Updated _has_any_provider_configured()
to check all provider env vars. Updated argparse --provider choices.
- setup.py: Added providers to setup wizard with API key prompts and
curated model lists.
- config.py: Added env vars (GLM_API_KEY, KIMI_API_KEY, MINIMAX_API_KEY,
etc.) to OPTIONAL_ENV_VARS.
- status.py: Added API key display and provider status section.
- doctor.py: Added connectivity checks for each provider endpoint.
- cli.py: Updated provider docstrings.
Docs: Updated README.md, .env.example, cli-config.yaml.example,
cli-commands.md, environment-variables.md, configuration.md.
Tests: 50 new tests covering registry, aliases, resolution, auto-detection,
credential resolution, and runtime provider dispatch.
Inspired by PR #33 (numman-ali) which proposed a provider registry approach.
Credit to tars90percent (PR #473) and manuelschipper (PR #420) for related
provider improvements merged earlier in this changeset.
2026-03-06 18:55:12 -08:00
|
|
|
|
save_config(cfg)
|
|
|
|
|
|
deactivate_provider()
|
|
|
|
|
|
|
|
|
|
|
|
print(f"Default model set to: {selected} (via {pconfig.name})")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print("No change.")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-20 17:24:00 -08:00
|
|
|
|
def cmd_login(args):
|
|
|
|
|
|
"""Authenticate Hermes CLI with a provider."""
|
|
|
|
|
|
from hermes_cli.auth import login_command
|
|
|
|
|
|
login_command(args)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_logout(args):
|
|
|
|
|
|
"""Clear provider authentication."""
|
|
|
|
|
|
from hermes_cli.auth import logout_command
|
|
|
|
|
|
logout_command(args)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
def cmd_status(args):
|
|
|
|
|
|
"""Show status of all components."""
|
|
|
|
|
|
from hermes_cli.status import show_status
|
|
|
|
|
|
show_status(args)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_cron(args):
|
|
|
|
|
|
"""Cron job management."""
|
|
|
|
|
|
from hermes_cli.cron import cron_command
|
|
|
|
|
|
cron_command(args)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_doctor(args):
|
|
|
|
|
|
"""Check configuration and dependencies."""
|
|
|
|
|
|
from hermes_cli.doctor import run_doctor
|
|
|
|
|
|
run_doctor(args)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_config(args):
|
|
|
|
|
|
"""Configuration management."""
|
|
|
|
|
|
from hermes_cli.config import config_command
|
|
|
|
|
|
config_command(args)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_version(args):
|
|
|
|
|
|
"""Show version."""
|
2026-03-12 05:51:31 -07:00
|
|
|
|
print(f"Hermes Agent v{__version__} ({__release_date__})")
|
2026-02-02 19:01:51 -08:00
|
|
|
|
print(f"Project: {PROJECT_ROOT}")
|
|
|
|
|
|
|
|
|
|
|
|
# Show Python version
|
|
|
|
|
|
print(f"Python: {sys.version.split()[0]}")
|
|
|
|
|
|
|
|
|
|
|
|
# Check for key dependencies
|
|
|
|
|
|
try:
|
|
|
|
|
|
import openai
|
|
|
|
|
|
print(f"OpenAI SDK: {openai.__version__}")
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
print("OpenAI SDK: Not installed")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-02 22:18:18 -08:00
|
|
|
|
def cmd_uninstall(args):
|
|
|
|
|
|
"""Uninstall Hermes Agent."""
|
|
|
|
|
|
from hermes_cli.uninstall import run_uninstall
|
|
|
|
|
|
run_uninstall(args)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-02 23:00:22 -08:00
|
|
|
|
def _update_via_zip(args):
|
|
|
|
|
|
"""Update Hermes Agent by downloading a ZIP archive.
|
|
|
|
|
|
|
|
|
|
|
|
Used on Windows when git file I/O is broken (antivirus, NTFS filter
|
|
|
|
|
|
drivers causing 'Invalid argument' errors on file creation).
|
|
|
|
|
|
"""
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
import tempfile
|
|
|
|
|
|
import zipfile
|
|
|
|
|
|
from urllib.request import urlretrieve
|
|
|
|
|
|
|
|
|
|
|
|
branch = "main"
|
|
|
|
|
|
zip_url = f"https://github.com/NousResearch/hermes-agent/archive/refs/heads/{branch}.zip"
|
|
|
|
|
|
|
|
|
|
|
|
print("→ Downloading latest version...")
|
|
|
|
|
|
try:
|
|
|
|
|
|
tmp_dir = tempfile.mkdtemp(prefix="hermes-update-")
|
|
|
|
|
|
zip_path = os.path.join(tmp_dir, f"hermes-agent-{branch}.zip")
|
|
|
|
|
|
urlretrieve(zip_url, zip_path)
|
|
|
|
|
|
|
|
|
|
|
|
print("→ Extracting...")
|
|
|
|
|
|
with zipfile.ZipFile(zip_path, 'r') as zf:
|
|
|
|
|
|
zf.extractall(tmp_dir)
|
|
|
|
|
|
|
|
|
|
|
|
# GitHub ZIPs extract to hermes-agent-<branch>/
|
|
|
|
|
|
extracted = os.path.join(tmp_dir, f"hermes-agent-{branch}")
|
|
|
|
|
|
if not os.path.isdir(extracted):
|
|
|
|
|
|
# Try to find it
|
|
|
|
|
|
for d in os.listdir(tmp_dir):
|
|
|
|
|
|
candidate = os.path.join(tmp_dir, d)
|
|
|
|
|
|
if os.path.isdir(candidate) and d != "__MACOSX":
|
|
|
|
|
|
extracted = candidate
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
# Copy updated files over existing installation, preserving venv/node_modules/.git
|
|
|
|
|
|
preserve = {'venv', 'node_modules', '.git', '__pycache__', '.env'}
|
|
|
|
|
|
update_count = 0
|
|
|
|
|
|
for item in os.listdir(extracted):
|
|
|
|
|
|
if item in preserve:
|
|
|
|
|
|
continue
|
|
|
|
|
|
src = os.path.join(extracted, item)
|
|
|
|
|
|
dst = os.path.join(str(PROJECT_ROOT), item)
|
|
|
|
|
|
if os.path.isdir(src):
|
|
|
|
|
|
if os.path.exists(dst):
|
|
|
|
|
|
shutil.rmtree(dst)
|
|
|
|
|
|
shutil.copytree(src, dst)
|
|
|
|
|
|
else:
|
|
|
|
|
|
shutil.copy2(src, dst)
|
|
|
|
|
|
update_count += 1
|
|
|
|
|
|
|
|
|
|
|
|
print(f"✓ Updated {update_count} items from ZIP")
|
|
|
|
|
|
|
|
|
|
|
|
# Cleanup
|
|
|
|
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"✗ ZIP update failed: {e}")
|
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
# Reinstall Python dependencies
|
|
|
|
|
|
print("→ Updating Python dependencies...")
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
uv_bin = shutil.which("uv")
|
|
|
|
|
|
if uv_bin:
|
|
|
|
|
|
subprocess.run(
|
|
|
|
|
|
[uv_bin, "pip", "install", "-e", ".", "--quiet"],
|
|
|
|
|
|
cwd=PROJECT_ROOT, check=True,
|
|
|
|
|
|
env={**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
venv_pip = PROJECT_ROOT / "venv" / ("Scripts" if sys.platform == "win32" else "bin") / "pip"
|
|
|
|
|
|
if venv_pip.exists():
|
|
|
|
|
|
subprocess.run([str(venv_pip), "install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True)
|
|
|
|
|
|
|
|
|
|
|
|
# Sync skills
|
|
|
|
|
|
try:
|
|
|
|
|
|
from tools.skills_sync import sync_skills
|
fix: restore all removed bundled skills + fix skills sync system
- Restored 21 skills removed in commits 757d012 and 740dd92:
accelerate, audiocraft, code-review, faiss, flash-attention, gguf,
grpo-rl-training, guidance, llava, nemo-curator, obliteratus, peft,
pytorch-fsdp, pytorch-lightning, simpo, slime, stable-diffusion,
tensorrt-llm, torchtitan, trl-fine-tuning, whisper
- Rewrote sync_skills() with proper update semantics:
* New skills (not in manifest): copied to user dir
* Existing skills (in manifest + on disk): updated via hash comparison
* User-deleted skills (in manifest, not on disk): respected, not re-added
* Stale manifest entries (removed from bundled): cleaned from manifest
- Added sync_skills() to CLI startup (cmd_chat) and gateway startup
(start_gateway) — previously only ran during 'hermes update'
- Updated cmd_update output to show new/updated/cleaned counts
- Rewrote tests: 20 tests covering manifest CRUD, dir hashing, fresh
install, user deletion respect, update detection, stale cleanup, and
name collision handling
75 bundled skills total. 2002 tests pass.
2026-03-06 15:57:12 -08:00
|
|
|
|
print("→ Syncing bundled skills...")
|
2026-03-02 23:00:22 -08:00
|
|
|
|
result = sync_skills(quiet=True)
|
|
|
|
|
|
if result["copied"]:
|
fix: restore all removed bundled skills + fix skills sync system
- Restored 21 skills removed in commits 757d012 and 740dd92:
accelerate, audiocraft, code-review, faiss, flash-attention, gguf,
grpo-rl-training, guidance, llava, nemo-curator, obliteratus, peft,
pytorch-fsdp, pytorch-lightning, simpo, slime, stable-diffusion,
tensorrt-llm, torchtitan, trl-fine-tuning, whisper
- Rewrote sync_skills() with proper update semantics:
* New skills (not in manifest): copied to user dir
* Existing skills (in manifest + on disk): updated via hash comparison
* User-deleted skills (in manifest, not on disk): respected, not re-added
* Stale manifest entries (removed from bundled): cleaned from manifest
- Added sync_skills() to CLI startup (cmd_chat) and gateway startup
(start_gateway) — previously only ran during 'hermes update'
- Updated cmd_update output to show new/updated/cleaned counts
- Rewrote tests: 20 tests covering manifest CRUD, dir hashing, fresh
install, user deletion respect, update detection, stale cleanup, and
name collision handling
75 bundled skills total. 2002 tests pass.
2026-03-06 15:57:12 -08:00
|
|
|
|
print(f" + {len(result['copied'])} new: {', '.join(result['copied'])}")
|
|
|
|
|
|
if result.get("updated"):
|
|
|
|
|
|
print(f" ↑ {len(result['updated'])} updated: {', '.join(result['updated'])}")
|
2026-03-06 16:14:43 -08:00
|
|
|
|
if result.get("user_modified"):
|
|
|
|
|
|
print(f" ~ {len(result['user_modified'])} user-modified (kept)")
|
fix: restore all removed bundled skills + fix skills sync system
- Restored 21 skills removed in commits 757d012 and 740dd92:
accelerate, audiocraft, code-review, faiss, flash-attention, gguf,
grpo-rl-training, guidance, llava, nemo-curator, obliteratus, peft,
pytorch-fsdp, pytorch-lightning, simpo, slime, stable-diffusion,
tensorrt-llm, torchtitan, trl-fine-tuning, whisper
- Rewrote sync_skills() with proper update semantics:
* New skills (not in manifest): copied to user dir
* Existing skills (in manifest + on disk): updated via hash comparison
* User-deleted skills (in manifest, not on disk): respected, not re-added
* Stale manifest entries (removed from bundled): cleaned from manifest
- Added sync_skills() to CLI startup (cmd_chat) and gateway startup
(start_gateway) — previously only ran during 'hermes update'
- Updated cmd_update output to show new/updated/cleaned counts
- Rewrote tests: 20 tests covering manifest CRUD, dir hashing, fresh
install, user deletion respect, update detection, stale cleanup, and
name collision handling
75 bundled skills total. 2002 tests pass.
2026-03-06 15:57:12 -08:00
|
|
|
|
if result.get("cleaned"):
|
|
|
|
|
|
print(f" − {len(result['cleaned'])} removed from manifest")
|
|
|
|
|
|
if not result["copied"] and not result.get("updated"):
|
2026-03-02 23:00:22 -08:00
|
|
|
|
print(" ✓ Skills are up to date")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
print()
|
|
|
|
|
|
print("✓ Update complete!")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
def cmd_update(args):
|
|
|
|
|
|
"""Update Hermes Agent to the latest version."""
|
|
|
|
|
|
import subprocess
|
2026-02-07 23:54:53 +00:00
|
|
|
|
import shutil
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
2026-02-20 21:25:04 -08:00
|
|
|
|
print("⚕ Updating Hermes Agent...")
|
2026-02-02 19:01:51 -08:00
|
|
|
|
print()
|
|
|
|
|
|
|
2026-03-02 23:00:22 -08:00
|
|
|
|
# Try git-based update first, fall back to ZIP download on Windows
|
|
|
|
|
|
# when git file I/O is broken (antivirus, NTFS filter drivers, etc.)
|
|
|
|
|
|
use_zip_update = False
|
2026-02-02 19:01:51 -08:00
|
|
|
|
git_dir = PROJECT_ROOT / '.git'
|
2026-03-02 23:00:22 -08:00
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
if not git_dir.exists():
|
2026-03-02 23:00:22 -08:00
|
|
|
|
if sys.platform == "win32":
|
|
|
|
|
|
use_zip_update = True
|
|
|
|
|
|
else:
|
|
|
|
|
|
print("✗ Not a git repository. Please reinstall:")
|
|
|
|
|
|
print(" curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash")
|
|
|
|
|
|
sys.exit(1)
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
2026-03-02 22:31:42 -08:00
|
|
|
|
# On Windows, git can fail with "unable to write loose object file: Invalid argument"
|
|
|
|
|
|
# due to filesystem atomicity issues. Set the recommended workaround.
|
2026-03-02 23:00:22 -08:00
|
|
|
|
if sys.platform == "win32" and git_dir.exists():
|
2026-03-02 22:31:42 -08:00
|
|
|
|
subprocess.run(
|
2026-03-02 23:00:22 -08:00
|
|
|
|
["git", "-c", "windows.appendAtomically=false", "config", "windows.appendAtomically", "false"],
|
2026-03-02 22:31:42 -08:00
|
|
|
|
cwd=PROJECT_ROOT, check=False, capture_output=True
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-02 23:00:22 -08:00
|
|
|
|
if use_zip_update:
|
|
|
|
|
|
# ZIP-based update for Windows when git is broken
|
|
|
|
|
|
_update_via_zip(args)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
# Fetch and pull
|
|
|
|
|
|
try:
|
|
|
|
|
|
print("→ Fetching updates...")
|
2026-03-02 23:00:22 -08:00
|
|
|
|
git_cmd = ["git"]
|
|
|
|
|
|
if sys.platform == "win32":
|
|
|
|
|
|
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
|
|
|
|
|
|
|
|
|
|
|
|
subprocess.run(git_cmd + ["fetch", "origin"], cwd=PROJECT_ROOT, check=True)
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
|
|
# Get current branch
|
|
|
|
|
|
result = subprocess.run(
|
2026-03-02 23:00:22 -08:00
|
|
|
|
git_cmd + ["rev-parse", "--abbrev-ref", "HEAD"],
|
2026-02-02 19:01:51 -08:00
|
|
|
|
cwd=PROJECT_ROOT,
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
check=True
|
|
|
|
|
|
)
|
|
|
|
|
|
branch = result.stdout.strip()
|
|
|
|
|
|
|
|
|
|
|
|
# Check if there are updates
|
|
|
|
|
|
result = subprocess.run(
|
2026-03-02 23:00:22 -08:00
|
|
|
|
git_cmd + ["rev-list", f"HEAD..origin/{branch}", "--count"],
|
2026-02-02 19:01:51 -08:00
|
|
|
|
cwd=PROJECT_ROOT,
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
check=True
|
|
|
|
|
|
)
|
|
|
|
|
|
commit_count = int(result.stdout.strip())
|
|
|
|
|
|
|
|
|
|
|
|
if commit_count == 0:
|
|
|
|
|
|
print("✓ Already up to date!")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
print(f"→ Found {commit_count} new commit(s)")
|
|
|
|
|
|
print("→ Pulling updates...")
|
2026-03-02 23:00:22 -08:00
|
|
|
|
subprocess.run(git_cmd + ["pull", "origin", branch], cwd=PROJECT_ROOT, check=True)
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
2026-02-07 23:54:53 +00:00
|
|
|
|
# Reinstall Python dependencies (prefer uv for speed, fall back to pip)
|
2026-02-02 19:01:51 -08:00
|
|
|
|
print("→ Updating Python dependencies...")
|
2026-02-07 23:54:53 +00:00
|
|
|
|
uv_bin = shutil.which("uv")
|
|
|
|
|
|
if uv_bin:
|
|
|
|
|
|
subprocess.run(
|
|
|
|
|
|
[uv_bin, "pip", "install", "-e", ".", "--quiet"],
|
|
|
|
|
|
cwd=PROJECT_ROOT, check=True,
|
|
|
|
|
|
env={**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
|
|
|
|
|
|
)
|
2026-02-02 19:01:51 -08:00
|
|
|
|
else:
|
2026-03-02 22:31:42 -08:00
|
|
|
|
venv_pip = PROJECT_ROOT / "venv" / ("Scripts" if sys.platform == "win32" else "bin") / "pip"
|
2026-02-07 23:54:53 +00:00
|
|
|
|
if venv_pip.exists():
|
|
|
|
|
|
subprocess.run([str(venv_pip), "install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True)
|
|
|
|
|
|
else:
|
|
|
|
|
|
subprocess.run(["pip", "install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True)
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
|
|
# Check for Node.js deps
|
|
|
|
|
|
if (PROJECT_ROOT / "package.json").exists():
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
if shutil.which("npm"):
|
|
|
|
|
|
print("→ Updating Node.js dependencies...")
|
|
|
|
|
|
subprocess.run(["npm", "install", "--silent"], cwd=PROJECT_ROOT, check=False)
|
|
|
|
|
|
|
2026-02-02 19:39:23 -08:00
|
|
|
|
print()
|
|
|
|
|
|
print("✓ Code updated!")
|
|
|
|
|
|
|
fix: restore all removed bundled skills + fix skills sync system
- Restored 21 skills removed in commits 757d012 and 740dd92:
accelerate, audiocraft, code-review, faiss, flash-attention, gguf,
grpo-rl-training, guidance, llava, nemo-curator, obliteratus, peft,
pytorch-fsdp, pytorch-lightning, simpo, slime, stable-diffusion,
tensorrt-llm, torchtitan, trl-fine-tuning, whisper
- Rewrote sync_skills() with proper update semantics:
* New skills (not in manifest): copied to user dir
* Existing skills (in manifest + on disk): updated via hash comparison
* User-deleted skills (in manifest, not on disk): respected, not re-added
* Stale manifest entries (removed from bundled): cleaned from manifest
- Added sync_skills() to CLI startup (cmd_chat) and gateway startup
(start_gateway) — previously only ran during 'hermes update'
- Updated cmd_update output to show new/updated/cleaned counts
- Rewrote tests: 20 tests covering manifest CRUD, dir hashing, fresh
install, user deletion respect, update detection, stale cleanup, and
name collision handling
75 bundled skills total. 2002 tests pass.
2026-03-06 15:57:12 -08:00
|
|
|
|
# Sync bundled skills (copies new, updates changed, respects user deletions)
|
2026-02-19 18:25:53 -08:00
|
|
|
|
try:
|
|
|
|
|
|
from tools.skills_sync import sync_skills
|
|
|
|
|
|
print()
|
fix: restore all removed bundled skills + fix skills sync system
- Restored 21 skills removed in commits 757d012 and 740dd92:
accelerate, audiocraft, code-review, faiss, flash-attention, gguf,
grpo-rl-training, guidance, llava, nemo-curator, obliteratus, peft,
pytorch-fsdp, pytorch-lightning, simpo, slime, stable-diffusion,
tensorrt-llm, torchtitan, trl-fine-tuning, whisper
- Rewrote sync_skills() with proper update semantics:
* New skills (not in manifest): copied to user dir
* Existing skills (in manifest + on disk): updated via hash comparison
* User-deleted skills (in manifest, not on disk): respected, not re-added
* Stale manifest entries (removed from bundled): cleaned from manifest
- Added sync_skills() to CLI startup (cmd_chat) and gateway startup
(start_gateway) — previously only ran during 'hermes update'
- Updated cmd_update output to show new/updated/cleaned counts
- Rewrote tests: 20 tests covering manifest CRUD, dir hashing, fresh
install, user deletion respect, update detection, stale cleanup, and
name collision handling
75 bundled skills total. 2002 tests pass.
2026-03-06 15:57:12 -08:00
|
|
|
|
print("→ Syncing bundled skills...")
|
2026-02-19 18:25:53 -08:00
|
|
|
|
result = sync_skills(quiet=True)
|
|
|
|
|
|
if result["copied"]:
|
fix: restore all removed bundled skills + fix skills sync system
- Restored 21 skills removed in commits 757d012 and 740dd92:
accelerate, audiocraft, code-review, faiss, flash-attention, gguf,
grpo-rl-training, guidance, llava, nemo-curator, obliteratus, peft,
pytorch-fsdp, pytorch-lightning, simpo, slime, stable-diffusion,
tensorrt-llm, torchtitan, trl-fine-tuning, whisper
- Rewrote sync_skills() with proper update semantics:
* New skills (not in manifest): copied to user dir
* Existing skills (in manifest + on disk): updated via hash comparison
* User-deleted skills (in manifest, not on disk): respected, not re-added
* Stale manifest entries (removed from bundled): cleaned from manifest
- Added sync_skills() to CLI startup (cmd_chat) and gateway startup
(start_gateway) — previously only ran during 'hermes update'
- Updated cmd_update output to show new/updated/cleaned counts
- Rewrote tests: 20 tests covering manifest CRUD, dir hashing, fresh
install, user deletion respect, update detection, stale cleanup, and
name collision handling
75 bundled skills total. 2002 tests pass.
2026-03-06 15:57:12 -08:00
|
|
|
|
print(f" + {len(result['copied'])} new: {', '.join(result['copied'])}")
|
|
|
|
|
|
if result.get("updated"):
|
|
|
|
|
|
print(f" ↑ {len(result['updated'])} updated: {', '.join(result['updated'])}")
|
2026-03-06 16:14:43 -08:00
|
|
|
|
if result.get("user_modified"):
|
|
|
|
|
|
print(f" ~ {len(result['user_modified'])} user-modified (kept)")
|
fix: restore all removed bundled skills + fix skills sync system
- Restored 21 skills removed in commits 757d012 and 740dd92:
accelerate, audiocraft, code-review, faiss, flash-attention, gguf,
grpo-rl-training, guidance, llava, nemo-curator, obliteratus, peft,
pytorch-fsdp, pytorch-lightning, simpo, slime, stable-diffusion,
tensorrt-llm, torchtitan, trl-fine-tuning, whisper
- Rewrote sync_skills() with proper update semantics:
* New skills (not in manifest): copied to user dir
* Existing skills (in manifest + on disk): updated via hash comparison
* User-deleted skills (in manifest, not on disk): respected, not re-added
* Stale manifest entries (removed from bundled): cleaned from manifest
- Added sync_skills() to CLI startup (cmd_chat) and gateway startup
(start_gateway) — previously only ran during 'hermes update'
- Updated cmd_update output to show new/updated/cleaned counts
- Rewrote tests: 20 tests covering manifest CRUD, dir hashing, fresh
install, user deletion respect, update detection, stale cleanup, and
name collision handling
75 bundled skills total. 2002 tests pass.
2026-03-06 15:57:12 -08:00
|
|
|
|
if result.get("cleaned"):
|
|
|
|
|
|
print(f" − {len(result['cleaned'])} removed from manifest")
|
|
|
|
|
|
if not result["copied"] and not result.get("updated"):
|
2026-02-19 18:25:53 -08:00
|
|
|
|
print(" ✓ Skills are up to date")
|
2026-02-21 03:32:11 -08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.debug("Skills sync during update failed: %s", e)
|
2026-02-19 18:25:53 -08:00
|
|
|
|
|
2026-02-02 19:39:23 -08:00
|
|
|
|
# Check for config migrations
|
|
|
|
|
|
print()
|
|
|
|
|
|
print("→ Checking configuration for new options...")
|
|
|
|
|
|
|
|
|
|
|
|
from hermes_cli.config import (
|
|
|
|
|
|
get_missing_env_vars, get_missing_config_fields,
|
|
|
|
|
|
check_config_version, migrate_config
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
missing_env = get_missing_env_vars(required_only=True)
|
|
|
|
|
|
missing_config = get_missing_config_fields()
|
|
|
|
|
|
current_ver, latest_ver = check_config_version()
|
|
|
|
|
|
|
|
|
|
|
|
needs_migration = missing_env or missing_config or current_ver < latest_ver
|
|
|
|
|
|
|
|
|
|
|
|
if needs_migration:
|
|
|
|
|
|
print()
|
|
|
|
|
|
if missing_env:
|
|
|
|
|
|
print(f" ⚠️ {len(missing_env)} new required setting(s) need configuration")
|
|
|
|
|
|
if missing_config:
|
|
|
|
|
|
print(f" ℹ️ {len(missing_config)} new config option(s) available")
|
|
|
|
|
|
|
|
|
|
|
|
print()
|
|
|
|
|
|
response = input("Would you like to configure them now? [Y/n]: ").strip().lower()
|
|
|
|
|
|
|
|
|
|
|
|
if response in ('', 'y', 'yes'):
|
|
|
|
|
|
print()
|
|
|
|
|
|
results = migrate_config(interactive=True, quiet=False)
|
|
|
|
|
|
|
|
|
|
|
|
if results["env_added"] or results["config_added"]:
|
|
|
|
|
|
print()
|
|
|
|
|
|
print("✓ Configuration updated!")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print()
|
|
|
|
|
|
print("Skipped. Run 'hermes config migrate' later to configure.")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(" ✓ Configuration is up to date")
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
print()
|
|
|
|
|
|
print("✓ Update complete!")
|
2026-02-26 20:26:05 -08:00
|
|
|
|
|
|
|
|
|
|
# Auto-restart gateway if it's running as a systemd service
|
|
|
|
|
|
try:
|
|
|
|
|
|
check = subprocess.run(
|
|
|
|
|
|
["systemctl", "--user", "is-active", "hermes-gateway"],
|
|
|
|
|
|
capture_output=True, text=True, timeout=5,
|
|
|
|
|
|
)
|
|
|
|
|
|
if check.stdout.strip() == "active":
|
|
|
|
|
|
print()
|
|
|
|
|
|
print("→ Gateway service is running — restarting to pick up changes...")
|
|
|
|
|
|
restart = subprocess.run(
|
|
|
|
|
|
["systemctl", "--user", "restart", "hermes-gateway"],
|
|
|
|
|
|
capture_output=True, text=True, timeout=15,
|
|
|
|
|
|
)
|
|
|
|
|
|
if restart.returncode == 0:
|
|
|
|
|
|
print("✓ Gateway restarted.")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(f"⚠ Gateway restart failed: {restart.stderr.strip()}")
|
|
|
|
|
|
print(" Try manually: hermes gateway restart")
|
|
|
|
|
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
|
|
|
|
pass # No systemd (macOS, WSL1, etc.) — skip silently
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
print()
|
2026-02-28 21:47:51 -08:00
|
|
|
|
print("Tip: You can now select a provider and model:")
|
|
|
|
|
|
print(" hermes model # Select provider and model")
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
|
|
except subprocess.CalledProcessError as e:
|
2026-03-02 23:00:22 -08:00
|
|
|
|
if sys.platform == "win32":
|
|
|
|
|
|
print(f"⚠ Git update failed: {e}")
|
|
|
|
|
|
print("→ Falling back to ZIP download...")
|
|
|
|
|
|
print()
|
|
|
|
|
|
_update_via_zip(args)
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(f"✗ Update failed: {e}")
|
|
|
|
|
|
sys.exit(1)
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-09 21:36:29 -07:00
|
|
|
|
def _coalesce_session_name_args(argv: list) -> list:
|
|
|
|
|
|
"""Join unquoted multi-word session names after -c/--continue and -r/--resume.
|
|
|
|
|
|
|
|
|
|
|
|
When a user types ``hermes -c Pokemon Agent Dev`` without quoting the
|
|
|
|
|
|
session name, argparse sees three separate tokens. This function merges
|
|
|
|
|
|
them into a single argument so argparse receives
|
|
|
|
|
|
``['-c', 'Pokemon Agent Dev']`` instead.
|
|
|
|
|
|
|
|
|
|
|
|
Tokens are collected after the flag until we hit another flag (``-*``)
|
|
|
|
|
|
or a known top-level subcommand.
|
|
|
|
|
|
"""
|
|
|
|
|
|
_SUBCOMMANDS = {
|
|
|
|
|
|
"chat", "model", "gateway", "setup", "whatsapp", "login", "logout",
|
|
|
|
|
|
"status", "cron", "doctor", "config", "pairing", "skills", "tools",
|
|
|
|
|
|
"sessions", "insights", "version", "update", "uninstall",
|
|
|
|
|
|
}
|
|
|
|
|
|
_SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"}
|
|
|
|
|
|
|
|
|
|
|
|
result = []
|
|
|
|
|
|
i = 0
|
|
|
|
|
|
while i < len(argv):
|
|
|
|
|
|
token = argv[i]
|
|
|
|
|
|
if token in _SESSION_FLAGS:
|
|
|
|
|
|
result.append(token)
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
# Collect subsequent non-flag, non-subcommand tokens as one name
|
|
|
|
|
|
parts: list = []
|
|
|
|
|
|
while i < len(argv) and not argv[i].startswith("-") and argv[i] not in _SUBCOMMANDS:
|
|
|
|
|
|
parts.append(argv[i])
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
if parts:
|
|
|
|
|
|
result.append(" ".join(parts))
|
|
|
|
|
|
else:
|
|
|
|
|
|
result.append(token)
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
def main():
|
|
|
|
|
|
"""Main entry point for hermes CLI."""
|
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
|
prog="hermes",
|
|
|
|
|
|
description="Hermes Agent - AI assistant with tool-calling capabilities",
|
|
|
|
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
|
|
|
|
epilog="""
|
|
|
|
|
|
Examples:
|
|
|
|
|
|
hermes Start interactive chat
|
|
|
|
|
|
hermes chat -q "Hello" Single query mode
|
2026-03-08 15:20:29 -07:00
|
|
|
|
hermes -c Resume the most recent session
|
|
|
|
|
|
hermes -c "my project" Resume a session by name (latest in lineage)
|
|
|
|
|
|
hermes --resume <session_id> Resume a specific session by ID
|
2026-02-02 19:01:51 -08:00
|
|
|
|
hermes setup Run setup wizard
|
2026-02-20 17:24:00 -08:00
|
|
|
|
hermes logout Clear stored authentication
|
2026-02-20 17:52:46 -08:00
|
|
|
|
hermes model Select default model
|
2026-02-02 19:01:51 -08:00
|
|
|
|
hermes config View configuration
|
|
|
|
|
|
hermes config edit Edit config in $EDITOR
|
|
|
|
|
|
hermes config set model gpt-4 Set a config value
|
|
|
|
|
|
hermes gateway Run messaging gateway
|
2026-03-07 21:05:40 -08:00
|
|
|
|
hermes -w Start in isolated git worktree
|
2026-02-02 19:01:51 -08:00
|
|
|
|
hermes gateway install Install as system service
|
2026-02-25 23:04:08 -08:00
|
|
|
|
hermes sessions list List past sessions
|
feat: interactive session browser with search filtering (#718)
Add `hermes sessions browse` — a curses-based interactive session picker
with live type-to-search filtering, arrow key navigation, and seamless
session resume via Enter.
Features:
- Arrow keys to navigate, Enter to select and resume, Esc/q to quit
- Type characters to live-filter sessions by title, preview, source, or ID
- Backspace to edit filter, first Esc clears filter, second Esc exits
- Adaptive column layout (title/preview, last active, source, ID)
- Scrolling support for long session lists
- --source flag to filter by platform (cli, telegram, discord, etc.)
- --limit flag to control how many sessions to load (default: 50)
- Windows fallback: numbered list with input prompt
- After selection, seamlessly execs into `hermes --resume <id>`
Design decisions:
- Separate subcommand (not a flag on -c) — preserves `hermes -c` as-is
for instant most-recent-session resume
- Uses curses (not simple_term_menu) per Known Pitfalls to avoid the
arrow-key ghost-duplication rendering bug in tmux/iTerm
- Follows existing curses pattern from hermes_cli/tools_config.py
Also fixes: removed redundant `import os` inside cmd_sessions stats
block that shadowed the module-level import (would cause UnboundLocalError
if browse action was taken in the same function).
Tests: 33 new tests covering curses picker, fallback mode, filtering,
navigation, edge cases, and argument parser registration.
2026-03-08 17:42:50 -07:00
|
|
|
|
hermes sessions browse Interactive session picker
|
2026-03-08 15:20:29 -07:00
|
|
|
|
hermes sessions rename ID T Rename/title a session
|
2026-02-02 19:01:51 -08:00
|
|
|
|
hermes update Update to latest version
|
|
|
|
|
|
|
|
|
|
|
|
For more help on a command:
|
|
|
|
|
|
hermes <command> --help
|
|
|
|
|
|
"""
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--version", "-V",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="Show version and exit"
|
|
|
|
|
|
)
|
2026-02-25 22:56:12 -08:00
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--resume", "-r",
|
2026-03-08 15:20:29 -07:00
|
|
|
|
metavar="SESSION",
|
2026-02-25 22:56:12 -08:00
|
|
|
|
default=None,
|
2026-03-08 15:20:29 -07:00
|
|
|
|
help="Resume a previous session by ID or title"
|
2026-02-25 22:56:12 -08:00
|
|
|
|
)
|
2026-02-25 23:00:10 -08:00
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--continue", "-c",
|
|
|
|
|
|
dest="continue_last",
|
2026-03-08 15:20:29 -07:00
|
|
|
|
nargs="?",
|
|
|
|
|
|
const=True,
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
metavar="SESSION_NAME",
|
|
|
|
|
|
help="Resume a session by name, or the most recent if no name given"
|
2026-02-25 23:00:10 -08:00
|
|
|
|
)
|
2026-03-07 21:05:40 -08:00
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--worktree", "-w",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
default=False,
|
|
|
|
|
|
help="Run in an isolated git worktree (for parallel agents)"
|
|
|
|
|
|
)
|
2026-03-08 18:36:37 -05:00
|
|
|
|
parser.add_argument(
|
2026-03-10 20:56:30 -07:00
|
|
|
|
"--yolo",
|
2026-03-08 18:36:37 -05:00
|
|
|
|
action="store_true",
|
|
|
|
|
|
default=False,
|
|
|
|
|
|
help="Bypass all dangerous command approval prompts (use at your own risk)"
|
|
|
|
|
|
)
|
2026-03-12 05:51:31 -07:00
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--pass-session-id",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
default=False,
|
|
|
|
|
|
help="Include the session ID in the agent's system prompt"
|
|
|
|
|
|
)
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
|
|
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
# chat command
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
chat_parser = subparsers.add_parser(
|
|
|
|
|
|
"chat",
|
|
|
|
|
|
help="Interactive chat with the agent",
|
|
|
|
|
|
description="Start an interactive chat session with Hermes Agent"
|
|
|
|
|
|
)
|
|
|
|
|
|
chat_parser.add_argument(
|
|
|
|
|
|
"-q", "--query",
|
|
|
|
|
|
help="Single query (non-interactive mode)"
|
|
|
|
|
|
)
|
|
|
|
|
|
chat_parser.add_argument(
|
|
|
|
|
|
"-m", "--model",
|
|
|
|
|
|
help="Model to use (e.g., anthropic/claude-sonnet-4)"
|
|
|
|
|
|
)
|
|
|
|
|
|
chat_parser.add_argument(
|
|
|
|
|
|
"-t", "--toolsets",
|
|
|
|
|
|
help="Comma-separated toolsets to enable"
|
|
|
|
|
|
)
|
2026-02-20 17:24:00 -08:00
|
|
|
|
chat_parser.add_argument(
|
|
|
|
|
|
"--provider",
|
feat: add z.ai/GLM, Kimi/Moonshot, MiniMax as first-class providers
Adds 4 new direct API-key providers (zai, kimi-coding, minimax, minimax-cn)
to the inference provider system. All use standard OpenAI-compatible
chat/completions endpoints with Bearer token auth.
Core changes:
- auth.py: Extended ProviderConfig with api_key_env_vars and base_url_env_var
fields. Added providers to PROVIDER_REGISTRY. Added provider aliases
(glm, z-ai, zhipu, kimi, moonshot). Added auto-detection of API-key
providers in resolve_provider(). Added resolve_api_key_provider_credentials()
and get_api_key_provider_status() helpers.
- runtime_provider.py: Added generic API-key provider branch in
resolve_runtime_provider() — any provider with auth_type='api_key'
is automatically handled.
- main.py: Added providers to hermes model menu with generic
_model_flow_api_key_provider() flow. Updated _has_any_provider_configured()
to check all provider env vars. Updated argparse --provider choices.
- setup.py: Added providers to setup wizard with API key prompts and
curated model lists.
- config.py: Added env vars (GLM_API_KEY, KIMI_API_KEY, MINIMAX_API_KEY,
etc.) to OPTIONAL_ENV_VARS.
- status.py: Added API key display and provider status section.
- doctor.py: Added connectivity checks for each provider endpoint.
- cli.py: Updated provider docstrings.
Docs: Updated README.md, .env.example, cli-config.yaml.example,
cli-commands.md, environment-variables.md, configuration.md.
Tests: 50 new tests covering registry, aliases, resolution, auto-detection,
credential resolution, and runtime provider dispatch.
Inspired by PR #33 (numman-ali) which proposed a provider registry approach.
Credit to tars90percent (PR #473) and manuelschipper (PR #420) for related
provider improvements merged earlier in this changeset.
2026-03-06 18:55:12 -08:00
|
|
|
|
choices=["auto", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax", "minimax-cn"],
|
2026-02-20 17:24:00 -08:00
|
|
|
|
default=None,
|
|
|
|
|
|
help="Inference provider (default: auto)"
|
|
|
|
|
|
)
|
2026-02-02 19:01:51 -08:00
|
|
|
|
chat_parser.add_argument(
|
|
|
|
|
|
"-v", "--verbose",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="Verbose output"
|
|
|
|
|
|
)
|
2026-02-25 22:56:12 -08:00
|
|
|
|
chat_parser.add_argument(
|
2026-03-10 20:45:18 -07:00
|
|
|
|
"-Q", "--quiet",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="Quiet mode for programmatic use: suppress banner, spinner, and tool previews. Only output the final response and session info."
|
|
|
|
|
|
)
|
|
|
|
|
|
chat_parser.add_argument(
|
2026-02-25 22:56:12 -08:00
|
|
|
|
"--resume", "-r",
|
|
|
|
|
|
metavar="SESSION_ID",
|
|
|
|
|
|
help="Resume a previous session by ID (shown on exit)"
|
|
|
|
|
|
)
|
2026-02-25 23:00:10 -08:00
|
|
|
|
chat_parser.add_argument(
|
|
|
|
|
|
"--continue", "-c",
|
|
|
|
|
|
dest="continue_last",
|
2026-03-08 15:20:29 -07:00
|
|
|
|
nargs="?",
|
|
|
|
|
|
const=True,
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
metavar="SESSION_NAME",
|
|
|
|
|
|
help="Resume a session by name, or the most recent if no name given"
|
2026-02-25 23:00:10 -08:00
|
|
|
|
)
|
2026-03-07 21:05:40 -08:00
|
|
|
|
chat_parser.add_argument(
|
|
|
|
|
|
"--worktree", "-w",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
default=False,
|
|
|
|
|
|
help="Run in an isolated git worktree (for parallel agents on the same repo)"
|
|
|
|
|
|
)
|
feat: filesystem checkpoints and /rollback command
Automatic filesystem snapshots before destructive file operations,
with user-facing rollback. Inspired by PR #559 (by @alireza78a).
Architecture:
- Shadow git repos at ~/.hermes/checkpoints/{hash}/ via GIT_DIR
- CheckpointManager: take/list/restore, turn-scoped dedup, pruning
- Transparent — the LLM never sees it, no tool schema, no tokens
- Once per turn — only first write_file/patch triggers a snapshot
Integration:
- Config: checkpoints.enabled + checkpoints.max_snapshots
- CLI flag: hermes --checkpoints
- Trigger: run_agent.py _execute_tool_calls() before write_file/patch
- /rollback slash command in CLI + gateway (list, restore by number)
- Pre-rollback snapshot auto-created on restore (undo the undo)
Safety:
- Never blocks file operations — all errors silently logged
- Skips root dir, home dir, dirs >50K files
- Disables gracefully when git not installed
- Shadow repo completely isolated from project git
Tests: 35 new tests, all passing (2798 total suite)
Docs: feature page, config reference, CLI commands reference
2026-03-10 00:49:15 -07:00
|
|
|
|
chat_parser.add_argument(
|
|
|
|
|
|
"--checkpoints",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
default=False,
|
|
|
|
|
|
help="Enable filesystem checkpoints before destructive file operations (use /rollback to restore)"
|
|
|
|
|
|
)
|
2026-03-10 20:56:30 -07:00
|
|
|
|
chat_parser.add_argument(
|
|
|
|
|
|
"--yolo",
|
2026-03-08 18:36:37 -05:00
|
|
|
|
action="store_true",
|
|
|
|
|
|
default=False,
|
|
|
|
|
|
help="Bypass all dangerous command approval prompts (use at your own risk)"
|
|
|
|
|
|
)
|
2026-03-12 05:51:31 -07:00
|
|
|
|
chat_parser.add_argument(
|
|
|
|
|
|
"--pass-session-id",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
default=False,
|
|
|
|
|
|
help="Include the session ID in the agent's system prompt"
|
|
|
|
|
|
)
|
2026-02-02 19:01:51 -08:00
|
|
|
|
chat_parser.set_defaults(func=cmd_chat)
|
2026-02-20 17:52:46 -08:00
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
# model command
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
model_parser = subparsers.add_parser(
|
|
|
|
|
|
"model",
|
|
|
|
|
|
help="Select default model and provider",
|
|
|
|
|
|
description="Interactively select your inference provider and default model"
|
|
|
|
|
|
)
|
|
|
|
|
|
model_parser.set_defaults(func=cmd_model)
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
# =========================================================================
|
|
|
|
|
|
# gateway command
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
gateway_parser = subparsers.add_parser(
|
|
|
|
|
|
"gateway",
|
|
|
|
|
|
help="Messaging gateway management",
|
|
|
|
|
|
description="Manage the messaging gateway (Telegram, Discord, WhatsApp)"
|
|
|
|
|
|
)
|
|
|
|
|
|
gateway_subparsers = gateway_parser.add_subparsers(dest="gateway_command")
|
|
|
|
|
|
|
|
|
|
|
|
# gateway run (default)
|
|
|
|
|
|
gateway_run = gateway_subparsers.add_parser("run", help="Run gateway in foreground")
|
|
|
|
|
|
gateway_run.add_argument("-v", "--verbose", action="store_true")
|
2026-03-07 18:08:12 +00:00
|
|
|
|
gateway_run.add_argument("--replace", action="store_true",
|
|
|
|
|
|
help="Replace any existing gateway instance (useful for systemd)")
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
|
|
# gateway start
|
|
|
|
|
|
gateway_start = gateway_subparsers.add_parser("start", help="Start gateway service")
|
|
|
|
|
|
|
|
|
|
|
|
# gateway stop
|
|
|
|
|
|
gateway_stop = gateway_subparsers.add_parser("stop", help="Stop gateway service")
|
|
|
|
|
|
|
|
|
|
|
|
# gateway restart
|
|
|
|
|
|
gateway_restart = gateway_subparsers.add_parser("restart", help="Restart gateway service")
|
|
|
|
|
|
|
|
|
|
|
|
# gateway status
|
|
|
|
|
|
gateway_status = gateway_subparsers.add_parser("status", help="Show gateway status")
|
|
|
|
|
|
gateway_status.add_argument("--deep", action="store_true", help="Deep status check")
|
|
|
|
|
|
|
|
|
|
|
|
# gateway install
|
|
|
|
|
|
gateway_install = gateway_subparsers.add_parser("install", help="Install gateway as service")
|
|
|
|
|
|
gateway_install.add_argument("--force", action="store_true", help="Force reinstall")
|
|
|
|
|
|
|
|
|
|
|
|
# gateway uninstall
|
|
|
|
|
|
gateway_uninstall = gateway_subparsers.add_parser("uninstall", help="Uninstall gateway service")
|
2026-03-03 18:57:33 -08:00
|
|
|
|
|
|
|
|
|
|
# gateway setup
|
|
|
|
|
|
gateway_setup = gateway_subparsers.add_parser("setup", help="Configure messaging platforms")
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
gateway_parser.set_defaults(func=cmd_gateway)
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
# setup command
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
setup_parser = subparsers.add_parser(
|
|
|
|
|
|
"setup",
|
|
|
|
|
|
help="Interactive setup wizard",
|
feat: modular setup wizard with section subcommands and tool-first UX
Restructure the monolithic hermes setup wizard into independently-runnable
sections with a category-first tool configuration experience.
Changes:
- Break setup into 5 sections: model, terminal, gateway, tools, agent
- Each section is a standalone function, runnable individually via
'hermes setup model', 'hermes setup terminal', etc.
- Returning users get a menu: Quick Setup / Full Setup / individual sections
- First-time users get a guided walkthrough of all sections
Tool Configuration UX overhaul:
- Replace flat API key checklist with category-first approach
- Show tool types (TTS, Web Search, Image Gen, etc.) as top-level items
- Within each category, let users pick a provider:
- TTS: Microsoft Edge (Free), OpenAI, ElevenLabs
- Web: Firecrawl Cloud, Firecrawl Self-Hosted
- Image Gen: FAL.ai
- Browser: Browserbase
- Smart Home: Home Assistant
- RL Training: Tinker/Atropos
- GitHub: Personal Access Token
- Shows configured status on each tool and provider
- Only prompts for API keys after provider selection
Also:
- Add section argument to setup argparse parser in main.py
- Update summary to show new section commands
- Add self-hosted Firecrawl and Home Assistant to tool setup
- All 2013 tests pass
2026-03-06 17:46:31 -08:00
|
|
|
|
description="Configure Hermes Agent with an interactive wizard. "
|
|
|
|
|
|
"Run a specific section: hermes setup model|terminal|gateway|tools|agent"
|
|
|
|
|
|
)
|
|
|
|
|
|
setup_parser.add_argument(
|
|
|
|
|
|
"section",
|
|
|
|
|
|
nargs="?",
|
|
|
|
|
|
choices=["model", "terminal", "gateway", "tools", "agent"],
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
help="Run a specific setup section instead of the full wizard"
|
2026-02-02 19:01:51 -08:00
|
|
|
|
)
|
|
|
|
|
|
setup_parser.add_argument(
|
|
|
|
|
|
"--non-interactive",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="Non-interactive mode (use defaults/env vars)"
|
|
|
|
|
|
)
|
|
|
|
|
|
setup_parser.add_argument(
|
|
|
|
|
|
"--reset",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="Reset configuration to defaults"
|
|
|
|
|
|
)
|
|
|
|
|
|
setup_parser.set_defaults(func=cmd_setup)
|
2026-02-20 17:24:00 -08:00
|
|
|
|
|
2026-02-25 21:04:36 -08:00
|
|
|
|
# =========================================================================
|
|
|
|
|
|
# whatsapp command
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
whatsapp_parser = subparsers.add_parser(
|
|
|
|
|
|
"whatsapp",
|
|
|
|
|
|
help="Set up WhatsApp integration",
|
|
|
|
|
|
description="Configure WhatsApp and pair via QR code"
|
|
|
|
|
|
)
|
|
|
|
|
|
whatsapp_parser.set_defaults(func=cmd_whatsapp)
|
|
|
|
|
|
|
2026-02-20 17:24:00 -08:00
|
|
|
|
# =========================================================================
|
|
|
|
|
|
# login command
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
login_parser = subparsers.add_parser(
|
|
|
|
|
|
"login",
|
|
|
|
|
|
help="Authenticate with an inference provider",
|
|
|
|
|
|
description="Run OAuth device authorization flow for Hermes CLI"
|
|
|
|
|
|
)
|
|
|
|
|
|
login_parser.add_argument(
|
|
|
|
|
|
"--provider",
|
2026-02-25 18:20:38 -08:00
|
|
|
|
choices=["nous", "openai-codex"],
|
2026-02-20 17:24:00 -08:00
|
|
|
|
default=None,
|
2026-02-25 18:20:38 -08:00
|
|
|
|
help="Provider to authenticate with (default: nous)"
|
2026-02-20 17:24:00 -08:00
|
|
|
|
)
|
|
|
|
|
|
login_parser.add_argument(
|
|
|
|
|
|
"--portal-url",
|
|
|
|
|
|
help="Portal base URL (default: production portal)"
|
|
|
|
|
|
)
|
|
|
|
|
|
login_parser.add_argument(
|
|
|
|
|
|
"--inference-url",
|
|
|
|
|
|
help="Inference API base URL (default: production inference API)"
|
|
|
|
|
|
)
|
|
|
|
|
|
login_parser.add_argument(
|
|
|
|
|
|
"--client-id",
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
help="OAuth client id to use (default: hermes-cli)"
|
|
|
|
|
|
)
|
|
|
|
|
|
login_parser.add_argument(
|
|
|
|
|
|
"--scope",
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
help="OAuth scope to request"
|
|
|
|
|
|
)
|
|
|
|
|
|
login_parser.add_argument(
|
|
|
|
|
|
"--no-browser",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="Do not attempt to open the browser automatically"
|
|
|
|
|
|
)
|
|
|
|
|
|
login_parser.add_argument(
|
|
|
|
|
|
"--timeout",
|
|
|
|
|
|
type=float,
|
|
|
|
|
|
default=15.0,
|
|
|
|
|
|
help="HTTP request timeout in seconds (default: 15)"
|
|
|
|
|
|
)
|
|
|
|
|
|
login_parser.add_argument(
|
|
|
|
|
|
"--ca-bundle",
|
|
|
|
|
|
help="Path to CA bundle PEM file for TLS verification"
|
|
|
|
|
|
)
|
|
|
|
|
|
login_parser.add_argument(
|
|
|
|
|
|
"--insecure",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="Disable TLS verification (testing only)"
|
|
|
|
|
|
)
|
|
|
|
|
|
login_parser.set_defaults(func=cmd_login)
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
# logout command
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
logout_parser = subparsers.add_parser(
|
|
|
|
|
|
"logout",
|
|
|
|
|
|
help="Clear authentication for an inference provider",
|
|
|
|
|
|
description="Remove stored credentials and reset provider config"
|
|
|
|
|
|
)
|
|
|
|
|
|
logout_parser.add_argument(
|
|
|
|
|
|
"--provider",
|
2026-02-25 18:20:38 -08:00
|
|
|
|
choices=["nous", "openai-codex"],
|
2026-02-20 17:24:00 -08:00
|
|
|
|
default=None,
|
|
|
|
|
|
help="Provider to log out from (default: active provider)"
|
|
|
|
|
|
)
|
|
|
|
|
|
logout_parser.set_defaults(func=cmd_logout)
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
# =========================================================================
|
|
|
|
|
|
# status command
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
status_parser = subparsers.add_parser(
|
|
|
|
|
|
"status",
|
|
|
|
|
|
help="Show status of all components",
|
|
|
|
|
|
description="Display status of Hermes Agent components"
|
|
|
|
|
|
)
|
|
|
|
|
|
status_parser.add_argument(
|
|
|
|
|
|
"--all",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="Show all details (redacted for sharing)"
|
|
|
|
|
|
)
|
|
|
|
|
|
status_parser.add_argument(
|
|
|
|
|
|
"--deep",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="Run deep checks (may take longer)"
|
|
|
|
|
|
)
|
|
|
|
|
|
status_parser.set_defaults(func=cmd_status)
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
# cron command
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
cron_parser = subparsers.add_parser(
|
|
|
|
|
|
"cron",
|
|
|
|
|
|
help="Cron job management",
|
|
|
|
|
|
description="Manage scheduled tasks"
|
|
|
|
|
|
)
|
|
|
|
|
|
cron_subparsers = cron_parser.add_subparsers(dest="cron_command")
|
|
|
|
|
|
|
|
|
|
|
|
# cron list
|
|
|
|
|
|
cron_list = cron_subparsers.add_parser("list", help="List scheduled jobs")
|
|
|
|
|
|
cron_list.add_argument("--all", action="store_true", help="Include disabled jobs")
|
|
|
|
|
|
|
2026-02-21 16:21:19 -08:00
|
|
|
|
# cron status
|
|
|
|
|
|
cron_subparsers.add_parser("status", help="Check if cron scheduler is running")
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
2026-02-21 16:21:19 -08:00
|
|
|
|
# cron tick (mostly for debugging)
|
|
|
|
|
|
cron_subparsers.add_parser("tick", help="Run due jobs once and exit")
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
|
|
cron_parser.set_defaults(func=cmd_cron)
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
# doctor command
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
doctor_parser = subparsers.add_parser(
|
|
|
|
|
|
"doctor",
|
|
|
|
|
|
help="Check configuration and dependencies",
|
|
|
|
|
|
description="Diagnose issues with Hermes Agent setup"
|
|
|
|
|
|
)
|
|
|
|
|
|
doctor_parser.add_argument(
|
|
|
|
|
|
"--fix",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="Attempt to fix issues automatically"
|
|
|
|
|
|
)
|
|
|
|
|
|
doctor_parser.set_defaults(func=cmd_doctor)
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
# config command
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
config_parser = subparsers.add_parser(
|
|
|
|
|
|
"config",
|
|
|
|
|
|
help="View and edit configuration",
|
|
|
|
|
|
description="Manage Hermes Agent configuration"
|
|
|
|
|
|
)
|
|
|
|
|
|
config_subparsers = config_parser.add_subparsers(dest="config_command")
|
|
|
|
|
|
|
|
|
|
|
|
# config show (default)
|
|
|
|
|
|
config_show = config_subparsers.add_parser("show", help="Show current configuration")
|
|
|
|
|
|
|
|
|
|
|
|
# config edit
|
|
|
|
|
|
config_edit = config_subparsers.add_parser("edit", help="Open config file in editor")
|
|
|
|
|
|
|
|
|
|
|
|
# config set
|
|
|
|
|
|
config_set = config_subparsers.add_parser("set", help="Set a configuration value")
|
|
|
|
|
|
config_set.add_argument("key", nargs="?", help="Configuration key (e.g., model, terminal.backend)")
|
|
|
|
|
|
config_set.add_argument("value", nargs="?", help="Value to set")
|
|
|
|
|
|
|
|
|
|
|
|
# config path
|
|
|
|
|
|
config_path = config_subparsers.add_parser("path", help="Print config file path")
|
|
|
|
|
|
|
|
|
|
|
|
# config env-path
|
|
|
|
|
|
config_env = config_subparsers.add_parser("env-path", help="Print .env file path")
|
|
|
|
|
|
|
2026-02-02 19:39:23 -08:00
|
|
|
|
# config check
|
|
|
|
|
|
config_check = config_subparsers.add_parser("check", help="Check for missing/outdated config")
|
|
|
|
|
|
|
|
|
|
|
|
# config migrate
|
|
|
|
|
|
config_migrate = config_subparsers.add_parser("migrate", help="Update config with new options")
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
config_parser.set_defaults(func=cmd_config)
|
|
|
|
|
|
|
Add messaging platform enhancements: STT, stickers, Discord UX, Slack, pairing, hooks
Major feature additions inspired by OpenClaw/ClawdBot integration analysis:
Voice Message Transcription (STT):
- Auto-transcribe voice/audio messages via OpenAI Whisper API
- Download voice to ~/.hermes/audio_cache/ on Telegram/Discord/WhatsApp
- Inject transcript as text so all models can understand voice input
- Configurable model (whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe)
Telegram Sticker Understanding:
- Describe static stickers via vision tool with JSON-backed cache
- Cache keyed by file_unique_id avoids redundant API calls
- Animated/video stickers get emoji-based fallback description
Discord Rich UX:
- Native slash commands (/ask, /reset, /status, /stop) via app_commands
- Button-based exec approvals (Allow Once / Always Allow / Deny)
- ExecApprovalView with user authorization and timeout handling
Slack Integration:
- Full SlackAdapter using slack-bolt with Socket Mode
- DMs, channel messages (mention-gated), /hermes slash command
- File attachment handling with bot-token-authenticated downloads
DM Pairing System:
- Code-based user authorization as alternative to static allowlists
- 8-char codes from unambiguous alphabet, 1-hour expiry
- Rate limiting, lockout after failed attempts, chmod 0600 on data
- CLI: hermes pairing list/approve/revoke/clear-pending
Event Hook System:
- File-based hook discovery from ~/.hermes/hooks/
- HOOK.yaml + handler.py per hook, sync/async handler support
- Events: gateway:startup, session:start/reset, agent:start/step/end
- Wildcard matching (command:* catches all command events)
Cross-Channel Messaging:
- send_message agent tool for delivering to any connected platform
- Enables cron job delivery and cross-platform notifications
Human-Like Response Pacing:
- Configurable delays between message chunks (off/natural/custom)
- HERMES_HUMAN_DELAY_MODE env var with min/max ms settings
Warm Injection Message Style:
- Retrofitted image vision messages with friendly kawaii-consistent tone
- All new injection messages (STT, stickers, errors) use warm style
Also: updated config migration to prompt for optional keys interactively,
bumped config version, updated README, AGENTS.md, .env.example,
cli-config.yaml.example, install scripts, pyproject.toml, and toolsets.
2026-02-15 21:38:59 -08:00
|
|
|
|
# =========================================================================
|
|
|
|
|
|
# pairing command
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
pairing_parser = subparsers.add_parser(
|
|
|
|
|
|
"pairing",
|
|
|
|
|
|
help="Manage DM pairing codes for user authorization",
|
|
|
|
|
|
description="Approve or revoke user access via pairing codes"
|
|
|
|
|
|
)
|
|
|
|
|
|
pairing_sub = pairing_parser.add_subparsers(dest="pairing_action")
|
|
|
|
|
|
|
|
|
|
|
|
pairing_list_parser = pairing_sub.add_parser("list", help="Show pending + approved users")
|
|
|
|
|
|
|
|
|
|
|
|
pairing_approve_parser = pairing_sub.add_parser("approve", help="Approve a pairing code")
|
|
|
|
|
|
pairing_approve_parser.add_argument("platform", help="Platform name (telegram, discord, slack, whatsapp)")
|
|
|
|
|
|
pairing_approve_parser.add_argument("code", help="Pairing code to approve")
|
|
|
|
|
|
|
|
|
|
|
|
pairing_revoke_parser = pairing_sub.add_parser("revoke", help="Revoke user access")
|
|
|
|
|
|
pairing_revoke_parser.add_argument("platform", help="Platform name")
|
|
|
|
|
|
pairing_revoke_parser.add_argument("user_id", help="User ID to revoke")
|
|
|
|
|
|
|
|
|
|
|
|
pairing_clear_parser = pairing_sub.add_parser("clear-pending", help="Clear all pending codes")
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_pairing(args):
|
|
|
|
|
|
from hermes_cli.pairing import pairing_command
|
|
|
|
|
|
pairing_command(args)
|
|
|
|
|
|
|
|
|
|
|
|
pairing_parser.set_defaults(func=cmd_pairing)
|
|
|
|
|
|
|
Add Skills Hub — universal skill search, install, and management from online registries
Implements the Hermes Skills Hub with agentskills.io spec compliance,
multi-registry skill discovery, security scanning, and user-driven
management via CLI and /skills slash command.
Core features:
- Security scanner (tools/skills_guard.py): 120 threat patterns across
12 categories, trust-aware install policy (builtin/trusted/community),
structural checks, unicode injection detection, LLM audit pass
- Hub client (tools/skills_hub.py): GitHub, ClawHub, Claude Code
marketplace, and LobeHub source adapters with shared GitHubAuth
(PAT + gh CLI + GitHub App), lock file provenance tracking, quarantine
flow, and unified search across all sources
- CLI interface (hermes_cli/skills_hub.py): search, install, inspect,
list, audit, uninstall, publish (GitHub PR), snapshot export/import,
and tap management — powers both `hermes skills` and `/skills`
Spec conformance (Phase 0):
- Upgraded frontmatter parser to yaml.safe_load with fallback
- Migrated 39 SKILL.md files: tags/related_skills to metadata.hermes.*
- Added assets/ directory support and compatibility/metadata fields
- Excluded .hub/ from skill discovery in skills_tool.py
Updated 13 config/doc files including README, AGENTS.md, .env.example,
setup wizard, doctor, status, pyproject.toml, and docs.
2026-02-18 16:09:05 -08:00
|
|
|
|
# =========================================================================
|
|
|
|
|
|
# skills command
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
skills_parser = subparsers.add_parser(
|
|
|
|
|
|
"skills",
|
2026-03-11 03:33:27 -04:00
|
|
|
|
help="Search, install, configure, and manage skills",
|
|
|
|
|
|
description="Search, install, inspect, audit, configure, and manage skills from GitHub, ClawHub, and other registries."
|
Add Skills Hub — universal skill search, install, and management from online registries
Implements the Hermes Skills Hub with agentskills.io spec compliance,
multi-registry skill discovery, security scanning, and user-driven
management via CLI and /skills slash command.
Core features:
- Security scanner (tools/skills_guard.py): 120 threat patterns across
12 categories, trust-aware install policy (builtin/trusted/community),
structural checks, unicode injection detection, LLM audit pass
- Hub client (tools/skills_hub.py): GitHub, ClawHub, Claude Code
marketplace, and LobeHub source adapters with shared GitHubAuth
(PAT + gh CLI + GitHub App), lock file provenance tracking, quarantine
flow, and unified search across all sources
- CLI interface (hermes_cli/skills_hub.py): search, install, inspect,
list, audit, uninstall, publish (GitHub PR), snapshot export/import,
and tap management — powers both `hermes skills` and `/skills`
Spec conformance (Phase 0):
- Upgraded frontmatter parser to yaml.safe_load with fallback
- Migrated 39 SKILL.md files: tags/related_skills to metadata.hermes.*
- Added assets/ directory support and compatibility/metadata fields
- Excluded .hub/ from skill discovery in skills_tool.py
Updated 13 config/doc files including README, AGENTS.md, .env.example,
setup wizard, doctor, status, pyproject.toml, and docs.
2026-02-18 16:09:05 -08:00
|
|
|
|
)
|
|
|
|
|
|
skills_subparsers = skills_parser.add_subparsers(dest="skills_action")
|
|
|
|
|
|
|
2026-03-06 01:29:45 -08:00
|
|
|
|
skills_browse = skills_subparsers.add_parser("browse", help="Browse all available skills (paginated)")
|
|
|
|
|
|
skills_browse.add_argument("--page", type=int, default=1, help="Page number (default: 1)")
|
|
|
|
|
|
skills_browse.add_argument("--size", type=int, default=20, help="Results per page (default: 20)")
|
|
|
|
|
|
skills_browse.add_argument("--source", default="all",
|
|
|
|
|
|
choices=["all", "official", "github", "clawhub", "lobehub"],
|
|
|
|
|
|
help="Filter by source (default: all)")
|
|
|
|
|
|
|
Add Skills Hub — universal skill search, install, and management from online registries
Implements the Hermes Skills Hub with agentskills.io spec compliance,
multi-registry skill discovery, security scanning, and user-driven
management via CLI and /skills slash command.
Core features:
- Security scanner (tools/skills_guard.py): 120 threat patterns across
12 categories, trust-aware install policy (builtin/trusted/community),
structural checks, unicode injection detection, LLM audit pass
- Hub client (tools/skills_hub.py): GitHub, ClawHub, Claude Code
marketplace, and LobeHub source adapters with shared GitHubAuth
(PAT + gh CLI + GitHub App), lock file provenance tracking, quarantine
flow, and unified search across all sources
- CLI interface (hermes_cli/skills_hub.py): search, install, inspect,
list, audit, uninstall, publish (GitHub PR), snapshot export/import,
and tap management — powers both `hermes skills` and `/skills`
Spec conformance (Phase 0):
- Upgraded frontmatter parser to yaml.safe_load with fallback
- Migrated 39 SKILL.md files: tags/related_skills to metadata.hermes.*
- Added assets/ directory support and compatibility/metadata fields
- Excluded .hub/ from skill discovery in skills_tool.py
Updated 13 config/doc files including README, AGENTS.md, .env.example,
setup wizard, doctor, status, pyproject.toml, and docs.
2026-02-18 16:09:05 -08:00
|
|
|
|
skills_search = skills_subparsers.add_parser("search", help="Search skill registries")
|
|
|
|
|
|
skills_search.add_argument("query", help="Search query")
|
2026-03-06 01:29:45 -08:00
|
|
|
|
skills_search.add_argument("--source", default="all", choices=["all", "official", "github", "clawhub", "lobehub"])
|
Add Skills Hub — universal skill search, install, and management from online registries
Implements the Hermes Skills Hub with agentskills.io spec compliance,
multi-registry skill discovery, security scanning, and user-driven
management via CLI and /skills slash command.
Core features:
- Security scanner (tools/skills_guard.py): 120 threat patterns across
12 categories, trust-aware install policy (builtin/trusted/community),
structural checks, unicode injection detection, LLM audit pass
- Hub client (tools/skills_hub.py): GitHub, ClawHub, Claude Code
marketplace, and LobeHub source adapters with shared GitHubAuth
(PAT + gh CLI + GitHub App), lock file provenance tracking, quarantine
flow, and unified search across all sources
- CLI interface (hermes_cli/skills_hub.py): search, install, inspect,
list, audit, uninstall, publish (GitHub PR), snapshot export/import,
and tap management — powers both `hermes skills` and `/skills`
Spec conformance (Phase 0):
- Upgraded frontmatter parser to yaml.safe_load with fallback
- Migrated 39 SKILL.md files: tags/related_skills to metadata.hermes.*
- Added assets/ directory support and compatibility/metadata fields
- Excluded .hub/ from skill discovery in skills_tool.py
Updated 13 config/doc files including README, AGENTS.md, .env.example,
setup wizard, doctor, status, pyproject.toml, and docs.
2026-02-18 16:09:05 -08:00
|
|
|
|
skills_search.add_argument("--limit", type=int, default=10, help="Max results")
|
|
|
|
|
|
|
|
|
|
|
|
skills_install = skills_subparsers.add_parser("install", help="Install a skill")
|
|
|
|
|
|
skills_install.add_argument("identifier", help="Skill identifier (e.g. openai/skills/skill-creator)")
|
|
|
|
|
|
skills_install.add_argument("--category", default="", help="Category folder to install into")
|
|
|
|
|
|
skills_install.add_argument("--force", action="store_true", help="Install despite caution verdict")
|
|
|
|
|
|
|
|
|
|
|
|
skills_inspect = skills_subparsers.add_parser("inspect", help="Preview a skill without installing")
|
|
|
|
|
|
skills_inspect.add_argument("identifier", help="Skill identifier")
|
|
|
|
|
|
|
|
|
|
|
|
skills_list = skills_subparsers.add_parser("list", help="List installed skills")
|
2026-03-10 17:22:36 -04:00
|
|
|
|
skills_list.add_argument("--source", default="all", choices=["all", "hub", "builtin", "local"])
|
Add Skills Hub — universal skill search, install, and management from online registries
Implements the Hermes Skills Hub with agentskills.io spec compliance,
multi-registry skill discovery, security scanning, and user-driven
management via CLI and /skills slash command.
Core features:
- Security scanner (tools/skills_guard.py): 120 threat patterns across
12 categories, trust-aware install policy (builtin/trusted/community),
structural checks, unicode injection detection, LLM audit pass
- Hub client (tools/skills_hub.py): GitHub, ClawHub, Claude Code
marketplace, and LobeHub source adapters with shared GitHubAuth
(PAT + gh CLI + GitHub App), lock file provenance tracking, quarantine
flow, and unified search across all sources
- CLI interface (hermes_cli/skills_hub.py): search, install, inspect,
list, audit, uninstall, publish (GitHub PR), snapshot export/import,
and tap management — powers both `hermes skills` and `/skills`
Spec conformance (Phase 0):
- Upgraded frontmatter parser to yaml.safe_load with fallback
- Migrated 39 SKILL.md files: tags/related_skills to metadata.hermes.*
- Added assets/ directory support and compatibility/metadata fields
- Excluded .hub/ from skill discovery in skills_tool.py
Updated 13 config/doc files including README, AGENTS.md, .env.example,
setup wizard, doctor, status, pyproject.toml, and docs.
2026-02-18 16:09:05 -08:00
|
|
|
|
|
|
|
|
|
|
skills_audit = skills_subparsers.add_parser("audit", help="Re-scan installed hub skills")
|
|
|
|
|
|
skills_audit.add_argument("name", nargs="?", help="Specific skill to audit (default: all)")
|
|
|
|
|
|
|
|
|
|
|
|
skills_uninstall = skills_subparsers.add_parser("uninstall", help="Remove a hub-installed skill")
|
|
|
|
|
|
skills_uninstall.add_argument("name", help="Skill name to remove")
|
|
|
|
|
|
|
|
|
|
|
|
skills_publish = skills_subparsers.add_parser("publish", help="Publish a skill to a registry")
|
|
|
|
|
|
skills_publish.add_argument("skill_path", help="Path to skill directory")
|
|
|
|
|
|
skills_publish.add_argument("--to", default="github", choices=["github", "clawhub"], help="Target registry")
|
|
|
|
|
|
skills_publish.add_argument("--repo", default="", help="Target GitHub repo (e.g. openai/skills)")
|
|
|
|
|
|
|
|
|
|
|
|
skills_snapshot = skills_subparsers.add_parser("snapshot", help="Export/import skill configurations")
|
|
|
|
|
|
snapshot_subparsers = skills_snapshot.add_subparsers(dest="snapshot_action")
|
|
|
|
|
|
snap_export = snapshot_subparsers.add_parser("export", help="Export installed skills to a file")
|
|
|
|
|
|
snap_export.add_argument("output", help="Output JSON file path")
|
|
|
|
|
|
snap_import = snapshot_subparsers.add_parser("import", help="Import and install skills from a file")
|
|
|
|
|
|
snap_import.add_argument("input", help="Input JSON file path")
|
|
|
|
|
|
snap_import.add_argument("--force", action="store_true", help="Force install despite caution verdict")
|
|
|
|
|
|
|
|
|
|
|
|
skills_tap = skills_subparsers.add_parser("tap", help="Manage skill sources")
|
|
|
|
|
|
tap_subparsers = skills_tap.add_subparsers(dest="tap_action")
|
|
|
|
|
|
tap_subparsers.add_parser("list", help="List configured taps")
|
|
|
|
|
|
tap_add = tap_subparsers.add_parser("add", help="Add a GitHub repo as skill source")
|
|
|
|
|
|
tap_add.add_argument("repo", help="GitHub repo (e.g. owner/repo)")
|
|
|
|
|
|
tap_rm = tap_subparsers.add_parser("remove", help="Remove a tap")
|
|
|
|
|
|
tap_rm.add_argument("name", help="Tap name to remove")
|
|
|
|
|
|
|
2026-03-11 03:33:27 -04:00
|
|
|
|
# config sub-action: interactive enable/disable
|
|
|
|
|
|
skills_subparsers.add_parser("config", help="Interactive skill configuration — enable/disable individual skills")
|
|
|
|
|
|
|
Add Skills Hub — universal skill search, install, and management from online registries
Implements the Hermes Skills Hub with agentskills.io spec compliance,
multi-registry skill discovery, security scanning, and user-driven
management via CLI and /skills slash command.
Core features:
- Security scanner (tools/skills_guard.py): 120 threat patterns across
12 categories, trust-aware install policy (builtin/trusted/community),
structural checks, unicode injection detection, LLM audit pass
- Hub client (tools/skills_hub.py): GitHub, ClawHub, Claude Code
marketplace, and LobeHub source adapters with shared GitHubAuth
(PAT + gh CLI + GitHub App), lock file provenance tracking, quarantine
flow, and unified search across all sources
- CLI interface (hermes_cli/skills_hub.py): search, install, inspect,
list, audit, uninstall, publish (GitHub PR), snapshot export/import,
and tap management — powers both `hermes skills` and `/skills`
Spec conformance (Phase 0):
- Upgraded frontmatter parser to yaml.safe_load with fallback
- Migrated 39 SKILL.md files: tags/related_skills to metadata.hermes.*
- Added assets/ directory support and compatibility/metadata fields
- Excluded .hub/ from skill discovery in skills_tool.py
Updated 13 config/doc files including README, AGENTS.md, .env.example,
setup wizard, doctor, status, pyproject.toml, and docs.
2026-02-18 16:09:05 -08:00
|
|
|
|
def cmd_skills(args):
|
2026-03-11 03:33:27 -04:00
|
|
|
|
# Route 'config' action to skills_config module
|
|
|
|
|
|
if getattr(args, 'skills_action', None) == 'config':
|
|
|
|
|
|
from hermes_cli.skills_config import skills_command as skills_config_command
|
|
|
|
|
|
skills_config_command(args)
|
|
|
|
|
|
else:
|
|
|
|
|
|
from hermes_cli.skills_hub import skills_command
|
|
|
|
|
|
skills_command(args)
|
Add Skills Hub — universal skill search, install, and management from online registries
Implements the Hermes Skills Hub with agentskills.io spec compliance,
multi-registry skill discovery, security scanning, and user-driven
management via CLI and /skills slash command.
Core features:
- Security scanner (tools/skills_guard.py): 120 threat patterns across
12 categories, trust-aware install policy (builtin/trusted/community),
structural checks, unicode injection detection, LLM audit pass
- Hub client (tools/skills_hub.py): GitHub, ClawHub, Claude Code
marketplace, and LobeHub source adapters with shared GitHubAuth
(PAT + gh CLI + GitHub App), lock file provenance tracking, quarantine
flow, and unified search across all sources
- CLI interface (hermes_cli/skills_hub.py): search, install, inspect,
list, audit, uninstall, publish (GitHub PR), snapshot export/import,
and tap management — powers both `hermes skills` and `/skills`
Spec conformance (Phase 0):
- Upgraded frontmatter parser to yaml.safe_load with fallback
- Migrated 39 SKILL.md files: tags/related_skills to metadata.hermes.*
- Added assets/ directory support and compatibility/metadata fields
- Excluded .hub/ from skill discovery in skills_tool.py
Updated 13 config/doc files including README, AGENTS.md, .env.example,
setup wizard, doctor, status, pyproject.toml, and docs.
2026-02-18 16:09:05 -08:00
|
|
|
|
|
|
|
|
|
|
skills_parser.set_defaults(func=cmd_skills)
|
|
|
|
|
|
|
2026-02-23 23:52:07 +00:00
|
|
|
|
# =========================================================================
|
|
|
|
|
|
# tools command
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
tools_parser = subparsers.add_parser(
|
|
|
|
|
|
"tools",
|
|
|
|
|
|
help="Configure which tools are enabled per platform",
|
|
|
|
|
|
description="Interactive tool configuration — enable/disable tools for CLI, Telegram, Discord, etc."
|
|
|
|
|
|
)
|
2026-03-09 16:50:53 +03:00
|
|
|
|
tools_parser.add_argument(
|
|
|
|
|
|
"--summary",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="Print a summary of enabled tools per platform and exit"
|
|
|
|
|
|
)
|
2026-02-23 23:52:07 +00:00
|
|
|
|
|
|
|
|
|
|
def cmd_tools(args):
|
|
|
|
|
|
from hermes_cli.tools_config import tools_command
|
|
|
|
|
|
tools_command(args)
|
|
|
|
|
|
|
|
|
|
|
|
tools_parser.set_defaults(func=cmd_tools)
|
2026-02-19 00:57:31 -08:00
|
|
|
|
# =========================================================================
|
|
|
|
|
|
# sessions command
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
sessions_parser = subparsers.add_parser(
|
|
|
|
|
|
"sessions",
|
docs: add session naming documentation across all doc files
- website/docs/user-guide/sessions.md: New 'Session Naming' section
with /title usage, title rules, auto-lineage, gateway support.
Updated 'Resume by Name' section, 'Rename a Session' subsection,
updated sessions list output format, updated DB schema description.
- website/docs/reference/cli-commands.md: Added -c "name" and
--resume by title to Core Commands, sessions rename to Sessions
table, /title to slash commands.
- website/docs/user-guide/cli.md: Added -c "name" and --resume by
title to resume options.
- AGENTS.md: Added -c, --resume, sessions list/rename to CLI commands
table. Added hermes_state.py to project structure.
- CONTRIBUTING.md: Updated hermes_state.py and session persistence
descriptions to mention titles.
- hermes_cli/main.py: Fixed sessions help string to include 'rename'.
2026-03-08 16:09:31 -07:00
|
|
|
|
help="Manage session history (list, rename, export, prune, delete)",
|
2026-02-19 00:57:31 -08:00
|
|
|
|
description="View and manage the SQLite session store"
|
|
|
|
|
|
)
|
|
|
|
|
|
sessions_subparsers = sessions_parser.add_subparsers(dest="sessions_action")
|
|
|
|
|
|
|
|
|
|
|
|
sessions_list = sessions_subparsers.add_parser("list", help="List recent sessions")
|
|
|
|
|
|
sessions_list.add_argument("--source", help="Filter by source (cli, telegram, discord, etc.)")
|
|
|
|
|
|
sessions_list.add_argument("--limit", type=int, default=20, help="Max sessions to show")
|
|
|
|
|
|
|
|
|
|
|
|
sessions_export = sessions_subparsers.add_parser("export", help="Export sessions to a JSONL file")
|
|
|
|
|
|
sessions_export.add_argument("output", help="Output JSONL file path")
|
|
|
|
|
|
sessions_export.add_argument("--source", help="Filter by source")
|
|
|
|
|
|
sessions_export.add_argument("--session-id", help="Export a specific session")
|
|
|
|
|
|
|
|
|
|
|
|
sessions_delete = sessions_subparsers.add_parser("delete", help="Delete a specific session")
|
|
|
|
|
|
sessions_delete.add_argument("session_id", help="Session ID to delete")
|
|
|
|
|
|
sessions_delete.add_argument("--yes", "-y", action="store_true", help="Skip confirmation")
|
|
|
|
|
|
|
|
|
|
|
|
sessions_prune = sessions_subparsers.add_parser("prune", help="Delete old sessions")
|
|
|
|
|
|
sessions_prune.add_argument("--older-than", type=int, default=90, help="Delete sessions older than N days (default: 90)")
|
|
|
|
|
|
sessions_prune.add_argument("--source", help="Only prune sessions from this source")
|
|
|
|
|
|
sessions_prune.add_argument("--yes", "-y", action="store_true", help="Skip confirmation")
|
|
|
|
|
|
|
|
|
|
|
|
sessions_stats = sessions_subparsers.add_parser("stats", help="Show session store statistics")
|
|
|
|
|
|
|
2026-03-08 15:20:29 -07:00
|
|
|
|
sessions_rename = sessions_subparsers.add_parser("rename", help="Set or change a session's title")
|
|
|
|
|
|
sessions_rename.add_argument("session_id", help="Session ID to rename")
|
|
|
|
|
|
sessions_rename.add_argument("title", nargs="+", help="New title for the session")
|
|
|
|
|
|
|
feat: interactive session browser with search filtering (#718)
Add `hermes sessions browse` — a curses-based interactive session picker
with live type-to-search filtering, arrow key navigation, and seamless
session resume via Enter.
Features:
- Arrow keys to navigate, Enter to select and resume, Esc/q to quit
- Type characters to live-filter sessions by title, preview, source, or ID
- Backspace to edit filter, first Esc clears filter, second Esc exits
- Adaptive column layout (title/preview, last active, source, ID)
- Scrolling support for long session lists
- --source flag to filter by platform (cli, telegram, discord, etc.)
- --limit flag to control how many sessions to load (default: 50)
- Windows fallback: numbered list with input prompt
- After selection, seamlessly execs into `hermes --resume <id>`
Design decisions:
- Separate subcommand (not a flag on -c) — preserves `hermes -c` as-is
for instant most-recent-session resume
- Uses curses (not simple_term_menu) per Known Pitfalls to avoid the
arrow-key ghost-duplication rendering bug in tmux/iTerm
- Follows existing curses pattern from hermes_cli/tools_config.py
Also fixes: removed redundant `import os` inside cmd_sessions stats
block that shadowed the module-level import (would cause UnboundLocalError
if browse action was taken in the same function).
Tests: 33 new tests covering curses picker, fallback mode, filtering,
navigation, edge cases, and argument parser registration.
2026-03-08 17:42:50 -07:00
|
|
|
|
sessions_browse = sessions_subparsers.add_parser(
|
|
|
|
|
|
"browse",
|
|
|
|
|
|
help="Interactive session picker — browse, search, and resume sessions",
|
|
|
|
|
|
)
|
|
|
|
|
|
sessions_browse.add_argument("--source", help="Filter by source (cli, telegram, discord, etc.)")
|
|
|
|
|
|
sessions_browse.add_argument("--limit", type=int, default=50, help="Max sessions to load (default: 50)")
|
|
|
|
|
|
|
2026-02-19 00:57:31 -08:00
|
|
|
|
def cmd_sessions(args):
|
|
|
|
|
|
import json as _json
|
|
|
|
|
|
try:
|
|
|
|
|
|
from hermes_state import SessionDB
|
|
|
|
|
|
db = SessionDB()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"Error: Could not open session database: {e}")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
action = args.sessions_action
|
|
|
|
|
|
|
|
|
|
|
|
if action == "list":
|
2026-03-08 15:20:29 -07:00
|
|
|
|
sessions = db.list_sessions_rich(source=args.source, limit=args.limit)
|
2026-02-19 00:57:31 -08:00
|
|
|
|
if not sessions:
|
|
|
|
|
|
print("No sessions found.")
|
|
|
|
|
|
return
|
|
|
|
|
|
from datetime import datetime
|
2026-03-08 15:20:29 -07:00
|
|
|
|
import time as _time
|
|
|
|
|
|
|
|
|
|
|
|
def _relative_time(ts):
|
|
|
|
|
|
"""Format a timestamp as relative time (e.g., '2h ago', 'yesterday')."""
|
|
|
|
|
|
if not ts:
|
|
|
|
|
|
return "?"
|
|
|
|
|
|
delta = _time.time() - ts
|
|
|
|
|
|
if delta < 60:
|
|
|
|
|
|
return "just now"
|
|
|
|
|
|
elif delta < 3600:
|
|
|
|
|
|
mins = int(delta / 60)
|
|
|
|
|
|
return f"{mins}m ago"
|
|
|
|
|
|
elif delta < 86400:
|
|
|
|
|
|
hours = int(delta / 3600)
|
|
|
|
|
|
return f"{hours}h ago"
|
|
|
|
|
|
elif delta < 172800:
|
|
|
|
|
|
return "yesterday"
|
|
|
|
|
|
elif delta < 604800:
|
|
|
|
|
|
days = int(delta / 86400)
|
|
|
|
|
|
return f"{days}d ago"
|
|
|
|
|
|
else:
|
|
|
|
|
|
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
|
|
|
|
|
|
|
|
|
|
|
|
has_titles = any(s.get("title") for s in sessions)
|
|
|
|
|
|
if has_titles:
|
|
|
|
|
|
print(f"{'Title':<22} {'Preview':<40} {'Last Active':<13} {'ID'}")
|
|
|
|
|
|
print("─" * 100)
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(f"{'Preview':<50} {'Last Active':<13} {'Src':<6} {'ID'}")
|
|
|
|
|
|
print("─" * 90)
|
2026-02-19 00:57:31 -08:00
|
|
|
|
for s in sessions:
|
2026-03-08 15:20:29 -07:00
|
|
|
|
last_active = _relative_time(s.get("last_active"))
|
|
|
|
|
|
preview = s.get("preview", "")[:38] if has_titles else s.get("preview", "")[:48]
|
|
|
|
|
|
if has_titles:
|
|
|
|
|
|
title = (s.get("title") or "—")[:20]
|
|
|
|
|
|
sid = s["id"][:20]
|
|
|
|
|
|
print(f"{title:<22} {preview:<40} {last_active:<13} {sid}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
sid = s["id"][:20]
|
|
|
|
|
|
print(f"{preview:<50} {last_active:<13} {s['source']:<6} {sid}")
|
2026-02-19 00:57:31 -08:00
|
|
|
|
|
|
|
|
|
|
elif action == "export":
|
|
|
|
|
|
if args.session_id:
|
|
|
|
|
|
data = db.export_session(args.session_id)
|
|
|
|
|
|
if not data:
|
|
|
|
|
|
print(f"Session '{args.session_id}' not found.")
|
|
|
|
|
|
return
|
2026-03-09 21:36:29 -07:00
|
|
|
|
with open(args.output, "w", encoding="utf-8") as f:
|
2026-02-19 00:57:31 -08:00
|
|
|
|
f.write(_json.dumps(data, ensure_ascii=False) + "\n")
|
|
|
|
|
|
print(f"Exported 1 session to {args.output}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
sessions = db.export_all(source=args.source)
|
2026-03-09 21:36:29 -07:00
|
|
|
|
with open(args.output, "w", encoding="utf-8") as f:
|
2026-02-19 00:57:31 -08:00
|
|
|
|
for s in sessions:
|
|
|
|
|
|
f.write(_json.dumps(s, ensure_ascii=False) + "\n")
|
|
|
|
|
|
print(f"Exported {len(sessions)} sessions to {args.output}")
|
|
|
|
|
|
|
|
|
|
|
|
elif action == "delete":
|
|
|
|
|
|
if not args.yes:
|
|
|
|
|
|
confirm = input(f"Delete session '{args.session_id}' and all its messages? [y/N] ")
|
|
|
|
|
|
if confirm.lower() not in ("y", "yes"):
|
|
|
|
|
|
print("Cancelled.")
|
|
|
|
|
|
return
|
|
|
|
|
|
if db.delete_session(args.session_id):
|
|
|
|
|
|
print(f"Deleted session '{args.session_id}'.")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(f"Session '{args.session_id}' not found.")
|
|
|
|
|
|
|
|
|
|
|
|
elif action == "prune":
|
|
|
|
|
|
days = args.older_than
|
|
|
|
|
|
source_msg = f" from '{args.source}'" if args.source else ""
|
|
|
|
|
|
if not args.yes:
|
|
|
|
|
|
confirm = input(f"Delete all ended sessions older than {days} days{source_msg}? [y/N] ")
|
|
|
|
|
|
if confirm.lower() not in ("y", "yes"):
|
|
|
|
|
|
print("Cancelled.")
|
|
|
|
|
|
return
|
|
|
|
|
|
count = db.prune_sessions(older_than_days=days, source=args.source)
|
|
|
|
|
|
print(f"Pruned {count} session(s).")
|
|
|
|
|
|
|
2026-03-08 15:20:29 -07:00
|
|
|
|
elif action == "rename":
|
|
|
|
|
|
title = " ".join(args.title)
|
|
|
|
|
|
try:
|
|
|
|
|
|
if db.set_session_title(args.session_id, title):
|
|
|
|
|
|
print(f"Session '{args.session_id}' renamed to: {title}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(f"Session '{args.session_id}' not found.")
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
print(f"Error: {e}")
|
|
|
|
|
|
|
feat: interactive session browser with search filtering (#718)
Add `hermes sessions browse` — a curses-based interactive session picker
with live type-to-search filtering, arrow key navigation, and seamless
session resume via Enter.
Features:
- Arrow keys to navigate, Enter to select and resume, Esc/q to quit
- Type characters to live-filter sessions by title, preview, source, or ID
- Backspace to edit filter, first Esc clears filter, second Esc exits
- Adaptive column layout (title/preview, last active, source, ID)
- Scrolling support for long session lists
- --source flag to filter by platform (cli, telegram, discord, etc.)
- --limit flag to control how many sessions to load (default: 50)
- Windows fallback: numbered list with input prompt
- After selection, seamlessly execs into `hermes --resume <id>`
Design decisions:
- Separate subcommand (not a flag on -c) — preserves `hermes -c` as-is
for instant most-recent-session resume
- Uses curses (not simple_term_menu) per Known Pitfalls to avoid the
arrow-key ghost-duplication rendering bug in tmux/iTerm
- Follows existing curses pattern from hermes_cli/tools_config.py
Also fixes: removed redundant `import os` inside cmd_sessions stats
block that shadowed the module-level import (would cause UnboundLocalError
if browse action was taken in the same function).
Tests: 33 new tests covering curses picker, fallback mode, filtering,
navigation, edge cases, and argument parser registration.
2026-03-08 17:42:50 -07:00
|
|
|
|
elif action == "browse":
|
|
|
|
|
|
limit = getattr(args, "limit", 50) or 50
|
|
|
|
|
|
source = getattr(args, "source", None)
|
|
|
|
|
|
sessions = db.list_sessions_rich(source=source, limit=limit)
|
|
|
|
|
|
db.close()
|
|
|
|
|
|
if not sessions:
|
|
|
|
|
|
print("No sessions found.")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
selected_id = _session_browse_picker(sessions)
|
|
|
|
|
|
if not selected_id:
|
|
|
|
|
|
print("Cancelled.")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# Launch hermes --resume <id> by replacing the current process
|
|
|
|
|
|
print(f"Resuming session: {selected_id}")
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
hermes_bin = shutil.which("hermes")
|
|
|
|
|
|
if hermes_bin:
|
|
|
|
|
|
os.execvp(hermes_bin, ["hermes", "--resume", selected_id])
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Fallback: re-invoke via python -m
|
|
|
|
|
|
os.execvp(
|
|
|
|
|
|
sys.executable,
|
|
|
|
|
|
[sys.executable, "-m", "hermes_cli.main", "--resume", selected_id],
|
|
|
|
|
|
)
|
|
|
|
|
|
return # won't reach here after execvp
|
|
|
|
|
|
|
2026-02-19 00:57:31 -08:00
|
|
|
|
elif action == "stats":
|
|
|
|
|
|
total = db.session_count()
|
|
|
|
|
|
msgs = db.message_count()
|
|
|
|
|
|
print(f"Total sessions: {total}")
|
|
|
|
|
|
print(f"Total messages: {msgs}")
|
|
|
|
|
|
for src in ["cli", "telegram", "discord", "whatsapp", "slack"]:
|
|
|
|
|
|
c = db.session_count(source=src)
|
|
|
|
|
|
if c > 0:
|
|
|
|
|
|
print(f" {src}: {c} sessions")
|
|
|
|
|
|
db_path = db.db_path
|
|
|
|
|
|
if db_path.exists():
|
|
|
|
|
|
size_mb = os.path.getsize(db_path) / (1024 * 1024)
|
|
|
|
|
|
print(f"Database size: {size_mb:.1f} MB")
|
|
|
|
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
sessions_parser.print_help()
|
|
|
|
|
|
|
|
|
|
|
|
db.close()
|
|
|
|
|
|
|
|
|
|
|
|
sessions_parser.set_defaults(func=cmd_sessions)
|
|
|
|
|
|
|
feat: add /insights command with usage analytics and cost estimation
Inspired by Claude Code's /insights, adapted for Hermes Agent's multi-platform
architecture. Analyzes session history from state.db to produce comprehensive
usage insights.
Features:
- Overview stats: sessions, messages, tokens, estimated cost, active time
- Model breakdown: per-model sessions, tokens, and cost estimation
- Platform breakdown: CLI vs Telegram vs Discord etc. (unique to Hermes)
- Tool usage ranking: most-used tools with percentages
- Activity patterns: day-of-week chart, peak hours, streaks
- Notable sessions: longest, most messages, most tokens, most tool calls
- Cost estimation: real pricing data for 25+ models (OpenAI, Anthropic,
DeepSeek, Google, Meta) with fuzzy model name matching
- Configurable time window: --days flag (default 30)
- Source filtering: --source flag to filter by platform
Three entry points:
- /insights slash command in CLI (supports --days and --source flags)
- /insights slash command in gateway (compact markdown format)
- hermes insights CLI subcommand (standalone)
Includes 56 tests covering pricing helpers, format helpers, empty DB,
populated DB with multi-platform data, filtering, formatting, and edge cases.
2026-03-06 14:04:59 -08:00
|
|
|
|
# =========================================================================
|
|
|
|
|
|
# insights command
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
insights_parser = subparsers.add_parser(
|
|
|
|
|
|
"insights",
|
|
|
|
|
|
help="Show usage insights and analytics",
|
|
|
|
|
|
description="Analyze session history to show token usage, costs, tool patterns, and activity trends"
|
|
|
|
|
|
)
|
|
|
|
|
|
insights_parser.add_argument("--days", type=int, default=30, help="Number of days to analyze (default: 30)")
|
|
|
|
|
|
insights_parser.add_argument("--source", help="Filter by platform (cli, telegram, discord, etc.)")
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_insights(args):
|
|
|
|
|
|
try:
|
|
|
|
|
|
from hermes_state import SessionDB
|
|
|
|
|
|
from agent.insights import InsightsEngine
|
|
|
|
|
|
|
|
|
|
|
|
db = SessionDB()
|
|
|
|
|
|
engine = InsightsEngine(db)
|
|
|
|
|
|
report = engine.generate(days=args.days, source=args.source)
|
|
|
|
|
|
print(engine.format_terminal(report))
|
|
|
|
|
|
db.close()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"Error generating insights: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
insights_parser.set_defaults(func=cmd_insights)
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
# =========================================================================
|
feat: add 'hermes claw migrate' command + migration docs
- Add hermes_cli/claw.py with full CLI migration handler:
- hermes claw migrate (interactive migration with confirmation)
- --dry-run, --preset, --overwrite, --skill-conflict flags
- --source for custom OpenClaw path
- --yes to skip confirmation
- Clean formatted output matching setup wizard style
- Fix Python 3.11+ @dataclass compatibility bug in dynamic module loading:
- Register module in sys.modules before exec_module()
- Fixes both setup.py (PR #981) and new claw.py
- Add 16 tests in tests/hermes_cli/test_claw.py covering:
- Script discovery (project root, installed, missing)
- Command routing
- Dry-run, execute, cancellation, error handling
- Preset/secrets behavior, report formatting
- Documentation updates:
- README.md: Add 'hermes claw migrate' to Getting Started, new Migration section
- docs/migration/openclaw.md: Full migration guide with all options
- SKILL.md: Add CLI Command section at top of openclaw-migration skill
2026-03-12 08:20:12 -07:00
|
|
|
|
# claw command (OpenClaw migration)
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
claw_parser = subparsers.add_parser(
|
|
|
|
|
|
"claw",
|
|
|
|
|
|
help="OpenClaw migration tools",
|
|
|
|
|
|
description="Migrate settings, memories, skills, and API keys from OpenClaw to Hermes"
|
|
|
|
|
|
)
|
|
|
|
|
|
claw_subparsers = claw_parser.add_subparsers(dest="claw_action")
|
|
|
|
|
|
|
|
|
|
|
|
# claw migrate
|
|
|
|
|
|
claw_migrate = claw_subparsers.add_parser(
|
|
|
|
|
|
"migrate",
|
|
|
|
|
|
help="Migrate from OpenClaw to Hermes",
|
|
|
|
|
|
description="Import settings, memories, skills, and API keys from an OpenClaw installation"
|
|
|
|
|
|
)
|
|
|
|
|
|
claw_migrate.add_argument(
|
|
|
|
|
|
"--source",
|
|
|
|
|
|
help="Path to OpenClaw directory (default: ~/.openclaw)"
|
|
|
|
|
|
)
|
|
|
|
|
|
claw_migrate.add_argument(
|
|
|
|
|
|
"--dry-run",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="Preview what would be migrated without making changes"
|
|
|
|
|
|
)
|
|
|
|
|
|
claw_migrate.add_argument(
|
|
|
|
|
|
"--preset",
|
|
|
|
|
|
choices=["user-data", "full"],
|
|
|
|
|
|
default="full",
|
|
|
|
|
|
help="Migration preset (default: full). 'user-data' excludes secrets"
|
|
|
|
|
|
)
|
|
|
|
|
|
claw_migrate.add_argument(
|
|
|
|
|
|
"--overwrite",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="Overwrite existing files (default: skip conflicts)"
|
|
|
|
|
|
)
|
|
|
|
|
|
claw_migrate.add_argument(
|
|
|
|
|
|
"--migrate-secrets",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="Include allowlisted secrets (TELEGRAM_BOT_TOKEN, API keys, etc.)"
|
|
|
|
|
|
)
|
|
|
|
|
|
claw_migrate.add_argument(
|
|
|
|
|
|
"--workspace-target",
|
|
|
|
|
|
help="Absolute path to copy workspace instructions into"
|
|
|
|
|
|
)
|
|
|
|
|
|
claw_migrate.add_argument(
|
|
|
|
|
|
"--skill-conflict",
|
|
|
|
|
|
choices=["skip", "overwrite", "rename"],
|
|
|
|
|
|
default="skip",
|
|
|
|
|
|
help="How to handle skill name conflicts (default: skip)"
|
|
|
|
|
|
)
|
|
|
|
|
|
claw_migrate.add_argument(
|
|
|
|
|
|
"--yes", "-y",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="Skip confirmation prompts"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_claw(args):
|
|
|
|
|
|
from hermes_cli.claw import claw_command
|
|
|
|
|
|
claw_command(args)
|
|
|
|
|
|
|
|
|
|
|
|
claw_parser.set_defaults(func=cmd_claw)
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
2026-02-02 19:01:51 -08:00
|
|
|
|
# version command
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
version_parser = subparsers.add_parser(
|
|
|
|
|
|
"version",
|
|
|
|
|
|
help="Show version information"
|
|
|
|
|
|
)
|
|
|
|
|
|
version_parser.set_defaults(func=cmd_version)
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
# update command
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
update_parser = subparsers.add_parser(
|
|
|
|
|
|
"update",
|
|
|
|
|
|
help="Update Hermes Agent to the latest version",
|
|
|
|
|
|
description="Pull the latest changes from git and reinstall dependencies"
|
|
|
|
|
|
)
|
|
|
|
|
|
update_parser.set_defaults(func=cmd_update)
|
|
|
|
|
|
|
2026-02-02 22:18:18 -08:00
|
|
|
|
# =========================================================================
|
|
|
|
|
|
# uninstall command
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
uninstall_parser = subparsers.add_parser(
|
|
|
|
|
|
"uninstall",
|
|
|
|
|
|
help="Uninstall Hermes Agent",
|
|
|
|
|
|
description="Remove Hermes Agent from your system. Can keep configs/data for reinstall."
|
|
|
|
|
|
)
|
|
|
|
|
|
uninstall_parser.add_argument(
|
|
|
|
|
|
"--full",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="Full uninstall - remove everything including configs and data"
|
|
|
|
|
|
)
|
|
|
|
|
|
uninstall_parser.add_argument(
|
|
|
|
|
|
"--yes", "-y",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="Skip confirmation prompts"
|
|
|
|
|
|
)
|
|
|
|
|
|
uninstall_parser.set_defaults(func=cmd_uninstall)
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
# =========================================================================
|
|
|
|
|
|
# Parse and execute
|
|
|
|
|
|
# =========================================================================
|
2026-03-09 21:36:29 -07:00
|
|
|
|
# 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'``
|
|
|
|
|
|
_processed_argv = _coalesce_session_name_args(sys.argv[1:])
|
|
|
|
|
|
args = parser.parse_args(_processed_argv)
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
|
|
# Handle --version flag
|
|
|
|
|
|
if args.version:
|
|
|
|
|
|
cmd_version(args)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-02-25 23:00:10 -08:00
|
|
|
|
# Handle top-level --resume / --continue as shortcut to chat
|
|
|
|
|
|
if (args.resume or args.continue_last) and args.command is None:
|
2026-02-25 22:56:12 -08:00
|
|
|
|
args.command = "chat"
|
|
|
|
|
|
args.query = None
|
|
|
|
|
|
args.model = None
|
|
|
|
|
|
args.provider = None
|
|
|
|
|
|
args.toolsets = None
|
|
|
|
|
|
args.verbose = False
|
2026-03-07 21:05:40 -08:00
|
|
|
|
if not hasattr(args, "worktree"):
|
|
|
|
|
|
args.worktree = False
|
2026-02-25 22:56:12 -08:00
|
|
|
|
cmd_chat(args)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
# Default to chat if no command specified
|
|
|
|
|
|
if args.command is None:
|
|
|
|
|
|
args.query = None
|
|
|
|
|
|
args.model = None
|
2026-02-20 17:24:00 -08:00
|
|
|
|
args.provider = None
|
2026-02-02 19:01:51 -08:00
|
|
|
|
args.toolsets = None
|
|
|
|
|
|
args.verbose = False
|
2026-02-25 22:56:12 -08:00
|
|
|
|
args.resume = None
|
2026-03-08 15:20:29 -07:00
|
|
|
|
args.continue_last = None
|
2026-03-07 21:05:40 -08:00
|
|
|
|
if not hasattr(args, "worktree"):
|
|
|
|
|
|
args.worktree = False
|
2026-02-02 19:01:51 -08:00
|
|
|
|
cmd_chat(args)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# Execute the command
|
|
|
|
|
|
if hasattr(args, 'func'):
|
|
|
|
|
|
args.func(args)
|
|
|
|
|
|
else:
|
|
|
|
|
|
parser.print_help()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|