Merge upstream/main and address Copilot review feedback
Merge resolved conflicts in web/src/{i18n/{en,zh,types}.ts,lib/api.ts}
by keeping both this branch's `profiles` additions and upstream's new
`models` page additions.
Copilot review feedback:
- Implement POST /api/profiles/{name}/open-terminal endpoint (already
present); align Windows branch to `cmd.exe /c start "" <cmd>` so it
matches the new test and spawns a fresh window instead of /k reusing
the parent console.
- Move backslash escaping out of the macOS AppleScript f-string
expression (Python <3.12 disallows backslashes inside f-string
expression parts).
- Patch `_get_wrapper_dir` via monkeypatch in
test_profiles_create_creates_wrapper_alias_when_safe so the test no
longer writes to the real `~/.local/bin`.
- Extend test_dashboard_browser_safe_imports to scan `.ts` files in
addition to `.tsx`.
- Switch upstream's new ModelsPage.tsx away from the `@nous-research/ui`
root barrel onto per-component subpaths to satisfy the stricter scan.
- Fix NouiTypography `leading-1.4` -> `leading-[1.4]` so Tailwind
actually emits the line-height for the `sm` variant.
- Guard ProfilesPage.openSoulEditor against out-of-order responses by
tracking the latest requested profile via a ref.
- Replace ProfilesPage's hand-rolled setup command with a fetch to
`/api/profiles/{name}/setup-command` so the copied command always
matches what the backend would actually run (handles wrapper-alias
collisions and reserved names correctly).
- Wire SOUL.md textarea label `htmlFor` -> textarea `id` so screen
readers and clicking the label work as expected.
This commit is contained in:
373
hermes_cli/_parser.py
Normal file
373
hermes_cli/_parser.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Top-level argparse construction for the hermes CLI.
|
||||
|
||||
Lives in its own module so other modules (e.g. ``relaunch.py``) can
|
||||
introspect the parser to discover which flags exist without running the
|
||||
``main`` fn.
|
||||
|
||||
Only the top-level parser and the ``chat`` subparser live here. Every other
|
||||
subparser (model, gateway, sessions, …) is built inline in ``main.py``
|
||||
because its dispatch is tightly coupled to module-level ``cmd_*`` functions.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
||||
|
||||
# `--profile` / `-p` is consumed by ``main._apply_profile_override`` before
|
||||
# argparse runs (it sets ``HERMES_HOME`` and strips itself from ``sys.argv``),
|
||||
# so it isn't on the parser. Listed here so all "carry over on relaunch"
|
||||
# metadata lives in one file.
|
||||
PRE_ARGPARSE_INHERITED_FLAGS: list[tuple[str, bool]] = [
|
||||
("--profile", True),
|
||||
("-p", True),
|
||||
]
|
||||
|
||||
|
||||
def _inherited_flag(parser, *args, **kwargs):
|
||||
"""Register a flag that ``hermes_cli.relaunch`` should carry over when
|
||||
the CLI re-execs itself (e.g. after ``sessions browse`` picks a session,
|
||||
or after the setup wizard launches chat).
|
||||
|
||||
Equivalent to ``parser.add_argument(...)`` plus tagging the resulting
|
||||
Action with ``inherit_on_relaunch = True`` so the relaunch table builder
|
||||
can find it via introspection.
|
||||
"""
|
||||
action = parser.add_argument(*args, **kwargs)
|
||||
action.inherit_on_relaunch = True
|
||||
return action
|
||||
|
||||
|
||||
_EPILOGUE = """
|
||||
Examples:
|
||||
hermes Start interactive chat
|
||||
hermes chat -q "Hello" Single query mode
|
||||
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
|
||||
hermes setup Run setup wizard
|
||||
hermes logout Clear stored authentication
|
||||
hermes auth add <provider> Add a pooled credential
|
||||
hermes auth list List pooled credentials
|
||||
hermes auth remove <p> <t> Remove pooled credential by index, id, or label
|
||||
hermes auth reset <provider> Clear exhaustion status for a provider
|
||||
hermes model Select default model
|
||||
hermes fallback [list] Show fallback provider chain
|
||||
hermes fallback add Add a fallback provider (same picker as `hermes model`)
|
||||
hermes fallback remove Remove a fallback provider from the chain
|
||||
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
|
||||
hermes -s hermes-agent-dev,github-auth
|
||||
hermes -w Start in isolated git worktree
|
||||
hermes gateway install Install gateway background service
|
||||
hermes sessions list List past sessions
|
||||
hermes sessions browse Interactive session picker
|
||||
hermes sessions rename ID T Rename/title a session
|
||||
hermes logs View agent.log (last 50 lines)
|
||||
hermes logs -f Follow agent.log in real time
|
||||
hermes logs errors View errors.log
|
||||
hermes logs --since 1h Lines from the last hour
|
||||
hermes debug share Upload debug report for support
|
||||
hermes update Update to latest version
|
||||
|
||||
For more help on a command:
|
||||
hermes <command> --help
|
||||
"""
|
||||
|
||||
|
||||
def build_top_level_parser():
|
||||
"""Build the top-level parser, the subparsers action, and the ``chat`` subparser.
|
||||
|
||||
Returns ``(parser, subparsers, chat_parser)``. The caller wires
|
||||
``chat_parser.set_defaults(func=cmd_chat)`` and continues registering
|
||||
other subparsers via ``subparsers.add_parser(...)``.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="hermes",
|
||||
description="Hermes Agent - AI assistant with tool-calling capabilities",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=_EPILOGUE,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--version", "-V", action="store_true", help="Show version and exit"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-z",
|
||||
"--oneshot",
|
||||
metavar="PROMPT",
|
||||
default=None,
|
||||
help=(
|
||||
"One-shot mode: send a single prompt and print ONLY the final "
|
||||
"response text to stdout. No banner, no spinner, no tool "
|
||||
"previews, no session_id line. Tools, memory, rules, and "
|
||||
"AGENTS.md in the CWD are loaded as normal; approvals are "
|
||||
"auto-bypassed. Intended for scripts / pipes."
|
||||
),
|
||||
)
|
||||
# --model / --provider are accepted at the top level so they can pair
|
||||
# with -z without needing the `chat` subcommand. If neither -z nor a
|
||||
# subcommand consumes them, they fall through harmlessly as None.
|
||||
# Mirrors `hermes chat --model ... --provider ...` semantics.
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"-m",
|
||||
"--model",
|
||||
default=None,
|
||||
help=(
|
||||
"Model override for this invocation (e.g. anthropic/claude-sonnet-4.6). "
|
||||
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_MODEL env var."
|
||||
),
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--provider",
|
||||
default=None,
|
||||
help=(
|
||||
"Provider override for this invocation (e.g. openrouter, anthropic). "
|
||||
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_PROVIDER env var."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--toolsets",
|
||||
default=None,
|
||||
help="Comma-separated toolsets to enable for this invocation. Applies to -z/--oneshot and --tui.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION",
|
||||
default=None,
|
||||
help="Resume a previous session by ID or title",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--continue",
|
||||
"-c",
|
||||
dest="continue_last",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=None,
|
||||
metavar="SESSION_NAME",
|
||||
help="Resume a session by name, or the most recent if no name given",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--worktree",
|
||||
"-w",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Run in an isolated git worktree (for parallel agents)",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--accept-hooks",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Auto-approve any unseen shell hooks declared in config.yaml "
|
||||
"without a TTY prompt. Equivalent to HERMES_ACCEPT_HOOKS=1 or "
|
||||
"hooks_auto_accept: true in config.yaml. Use on CI / headless "
|
||||
"runs that can't prompt."
|
||||
),
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--skills",
|
||||
"-s",
|
||||
action="append",
|
||||
default=None,
|
||||
help="Preload one or more skills for the session (repeat flag or comma-separate)",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--yolo",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Bypass all dangerous command approval prompts (use at your own risk)",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--pass-session-id",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Include the session ID in the agent's system prompt",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--ignore-user-config",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded)",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--ignore-rules",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--tui",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--dev",
|
||||
dest="tui_dev",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="With --tui: run TypeScript sources via tsx (skip dist build)",
|
||||
)
|
||||
|
||||
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(
|
||||
"--image", help="Optional local image path to attach to a single query"
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"-m", "--model", help="Model to use (e.g., anthropic/claude-sonnet-4)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-t", "--toolsets", help="Comma-separated toolsets to enable"
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"-s",
|
||||
"--skills",
|
||||
action="append",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Preload one or more skills for the session (repeat flag or comma-separate)",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--provider",
|
||||
# No `choices=` here: user-defined providers from config.yaml `providers:`
|
||||
# are also valid values, and runtime resolution (resolve_runtime_provider)
|
||||
# handles validation/error reporting consistently with the top-level
|
||||
# `--provider` flag.
|
||||
default=None,
|
||||
help="Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-v", "--verbose", action="store_true", help="Verbose output"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-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(
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION_ID",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Resume a previous session by ID (shown on exit)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--continue",
|
||||
"-c",
|
||||
dest="continue_last",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=argparse.SUPPRESS,
|
||||
metavar="SESSION_NAME",
|
||||
help="Resume a session by name, or the most recent if no name given",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--worktree",
|
||||
"-w",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Run in an isolated git worktree (for parallel agents on the same repo)",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--accept-hooks",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help=(
|
||||
"Auto-approve any unseen shell hooks declared in config.yaml "
|
||||
"without a TTY prompt (see also HERMES_ACCEPT_HOOKS env var and "
|
||||
"hooks_auto_accept: in config.yaml)."
|
||||
),
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--checkpoints",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable filesystem checkpoints before destructive file operations (use /rollback to restore)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--max-turns",
|
||||
type=int,
|
||||
default=None,
|
||||
metavar="N",
|
||||
help="Maximum tool-calling iterations per conversation turn (default: 90, or agent.max_turns in config)",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--yolo",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Bypass all dangerous command approval prompts (use at your own risk)",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--pass-session-id",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Include the session ID in the agent's system prompt",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--ignore-user-config",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded). Useful for isolated CI runs, reproduction, and third-party integrations.",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--ignore-rules",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills. Combine with --ignore-user-config for a fully isolated run.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--source",
|
||||
default=None,
|
||||
help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists.",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--tui",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--dev",
|
||||
dest="tui_dev",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="With --tui: run TypeScript sources via tsx (skip dist build)",
|
||||
)
|
||||
|
||||
return parser, subparsers, chat_parser
|
||||
@@ -72,6 +72,14 @@ DEFAULT_AGENT_KEY_MIN_TTL_SECONDS = 30 * 60 # 30 minutes
|
||||
ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry
|
||||
DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1 # poll at most every 1s
|
||||
DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
MINIMAX_OAUTH_CLIENT_ID = "78257093-7e40-4613-99e0-527b14b39113"
|
||||
MINIMAX_OAUTH_SCOPE = "group_id profile model.completion"
|
||||
MINIMAX_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:user_code"
|
||||
MINIMAX_OAUTH_GLOBAL_BASE = "https://api.minimax.io"
|
||||
MINIMAX_OAUTH_CN_BASE = "https://api.minimaxi.com"
|
||||
MINIMAX_OAUTH_GLOBAL_INFERENCE = "https://api.minimax.io/anthropic"
|
||||
MINIMAX_OAUTH_CN_INFERENCE = "https://api.minimaxi.com/anthropic"
|
||||
MINIMAX_OAUTH_REFRESH_SKEW_SECONDS = 60
|
||||
DEFAULT_QWEN_BASE_URL = "https://portal.qwen.ai/v1"
|
||||
DEFAULT_GITHUB_MODELS_BASE_URL = "https://api.githubcopilot.com"
|
||||
DEFAULT_COPILOT_ACP_BASE_URL = "acp://copilot"
|
||||
@@ -126,7 +134,7 @@ class ProviderConfig:
|
||||
"""Describes a known inference provider."""
|
||||
id: str
|
||||
name: str
|
||||
auth_type: str # "oauth_device_code", "oauth_external", or "api_key"
|
||||
auth_type: str # "oauth_device_code", "oauth_external", "oauth_minimax", or "api_key"
|
||||
portal_base_url: str = ""
|
||||
inference_base_url: str = ""
|
||||
client_id: str = ""
|
||||
@@ -255,6 +263,17 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
api_key_env_vars=("MINIMAX_API_KEY",),
|
||||
base_url_env_var="MINIMAX_BASE_URL",
|
||||
),
|
||||
"minimax-oauth": ProviderConfig(
|
||||
id="minimax-oauth",
|
||||
name="MiniMax (OAuth \u00b7 minimax.io)",
|
||||
auth_type="oauth_minimax",
|
||||
portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE,
|
||||
inference_base_url=MINIMAX_OAUTH_GLOBAL_INFERENCE,
|
||||
client_id=MINIMAX_OAUTH_CLIENT_ID,
|
||||
scope=MINIMAX_OAUTH_SCOPE,
|
||||
extra={"region": "global", "cn_portal_base_url": MINIMAX_OAUTH_CN_BASE,
|
||||
"cn_inference_base_url": MINIMAX_OAUTH_CN_INFERENCE},
|
||||
),
|
||||
"anthropic": ProviderConfig(
|
||||
id="anthropic",
|
||||
name="Anthropic",
|
||||
@@ -1153,6 +1172,7 @@ def resolve_provider(
|
||||
"arcee-ai": "arcee", "arceeai": "arcee",
|
||||
"gmi-cloud": "gmi", "gmicloud": "gmi",
|
||||
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
|
||||
"minimax-portal": "minimax-oauth", "minimax-global": "minimax-oauth", "minimax_oauth": "minimax-oauth",
|
||||
"alibaba_coding": "alibaba-coding-plan", "alibaba-coding": "alibaba-coding-plan",
|
||||
"alibaba_coding_plan": "alibaba-coding-plan",
|
||||
"claude": "anthropic", "claude-code": "anthropic",
|
||||
@@ -4116,6 +4136,326 @@ def _codex_device_code_login() -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# ==================== MiniMax Portal OAuth ====================
|
||||
|
||||
def _minimax_pkce_pair() -> tuple:
|
||||
"""Generate (code_verifier, code_challenge_S256, state) for MiniMax OAuth."""
|
||||
import secrets
|
||||
verifier = secrets.token_urlsafe(64)[:96]
|
||||
challenge = base64.urlsafe_b64encode(
|
||||
hashlib.sha256(verifier.encode()).digest()
|
||||
).decode().rstrip("=")
|
||||
state = secrets.token_urlsafe(16)
|
||||
return verifier, challenge, state
|
||||
|
||||
|
||||
def _minimax_request_user_code(
|
||||
client: httpx.Client, *, portal_base_url: str, client_id: str,
|
||||
code_challenge: str, state: str,
|
||||
) -> Dict[str, Any]:
|
||||
response = client.post(
|
||||
f"{portal_base_url}/oauth/code",
|
||||
data={
|
||||
"response_type": "code",
|
||||
"client_id": client_id,
|
||||
"scope": MINIMAX_OAUTH_SCOPE,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": state,
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
"x-request-id": str(uuid.uuid4()),
|
||||
},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise AuthError(
|
||||
f"MiniMax OAuth authorization failed: {response.text or response.reason_phrase}",
|
||||
provider="minimax-oauth", code="authorization_failed",
|
||||
)
|
||||
payload = response.json()
|
||||
for field in ("user_code", "verification_uri", "expired_in"):
|
||||
if field not in payload:
|
||||
raise AuthError(
|
||||
f"MiniMax OAuth response missing field: {field}",
|
||||
provider="minimax-oauth", code="authorization_incomplete",
|
||||
)
|
||||
if payload.get("state") != state:
|
||||
raise AuthError(
|
||||
"MiniMax OAuth state mismatch (possible CSRF).",
|
||||
provider="minimax-oauth", code="state_mismatch",
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _minimax_poll_token(
|
||||
client: httpx.Client, *, portal_base_url: str, client_id: str,
|
||||
user_code: str, code_verifier: str, expired_in: int, interval_ms: Optional[int],
|
||||
) -> Dict[str, Any]:
|
||||
# OpenClaw treats expired_in as a unix-ms timestamp (Date.now() < expireTimeMs).
|
||||
# Defensive parsing: if it's small enough to be a duration, treat as seconds.
|
||||
import time as _time
|
||||
now_ms = int(_time.time() * 1000)
|
||||
if expired_in > now_ms // 2:
|
||||
# Looks like a unix-ms timestamp.
|
||||
deadline = expired_in / 1000.0
|
||||
else:
|
||||
# Treat as duration in seconds from now.
|
||||
deadline = _time.time() + max(1, expired_in)
|
||||
interval = max(2.0, (interval_ms or 2000) / 1000.0)
|
||||
|
||||
while _time.time() < deadline:
|
||||
response = client.post(
|
||||
f"{portal_base_url}/oauth/token",
|
||||
data={
|
||||
"grant_type": MINIMAX_OAUTH_GRANT_TYPE,
|
||||
"client_id": client_id,
|
||||
"user_code": user_code,
|
||||
"code_verifier": code_verifier,
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
payload = response.json() if response.text else {}
|
||||
except Exception:
|
||||
payload = {}
|
||||
|
||||
if response.status_code != 200:
|
||||
msg = (payload.get("base_resp", {}) or {}).get("status_msg") or response.text
|
||||
raise AuthError(
|
||||
f"MiniMax OAuth error: {msg or 'unknown'}",
|
||||
provider="minimax-oauth", code="token_exchange_failed",
|
||||
)
|
||||
|
||||
status = payload.get("status")
|
||||
if status == "error":
|
||||
raise AuthError(
|
||||
"MiniMax OAuth reported an error. Please try again later.",
|
||||
provider="minimax-oauth", code="authorization_denied",
|
||||
)
|
||||
if status == "success":
|
||||
if not all(payload.get(k) for k in ("access_token", "refresh_token", "expired_in")):
|
||||
raise AuthError(
|
||||
"MiniMax OAuth success payload missing required token fields.",
|
||||
provider="minimax-oauth", code="token_incomplete",
|
||||
)
|
||||
return payload
|
||||
# "pending" or any other status -> keep polling
|
||||
_time.sleep(interval)
|
||||
|
||||
raise AuthError(
|
||||
"MiniMax OAuth timed out before authorization completed.",
|
||||
provider="minimax-oauth", code="timeout",
|
||||
)
|
||||
|
||||
|
||||
def _minimax_save_auth_state(auth_state: Dict[str, Any]) -> None:
|
||||
"""Persist MiniMax OAuth state to Hermes auth store (~/.hermes/auth.json)."""
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
_save_provider_state(auth_store, "minimax-oauth", auth_state)
|
||||
_save_auth_store(auth_store)
|
||||
|
||||
|
||||
def _minimax_oauth_login(
|
||||
*, region: str = "global", open_browser: bool = True,
|
||||
timeout_seconds: float = 15.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run MiniMax OAuth flow, persist tokens, return auth state dict."""
|
||||
pconfig = PROVIDER_REGISTRY["minimax-oauth"]
|
||||
if region == "cn":
|
||||
portal_base_url = pconfig.extra["cn_portal_base_url"]
|
||||
inference_base_url = pconfig.extra["cn_inference_base_url"]
|
||||
else:
|
||||
portal_base_url = pconfig.portal_base_url
|
||||
inference_base_url = pconfig.inference_base_url
|
||||
|
||||
verifier, challenge, state = _minimax_pkce_pair()
|
||||
|
||||
if _is_remote_session():
|
||||
open_browser = False
|
||||
|
||||
print(f"Starting Hermes login via MiniMax ({region}) OAuth...")
|
||||
print(f"Portal: {portal_base_url}")
|
||||
|
||||
with httpx.Client(timeout=httpx.Timeout(timeout_seconds),
|
||||
headers={"Accept": "application/json"}) as client:
|
||||
code_data = _minimax_request_user_code(
|
||||
client, portal_base_url=portal_base_url,
|
||||
client_id=pconfig.client_id,
|
||||
code_challenge=challenge, state=state,
|
||||
)
|
||||
verification_url = str(code_data["verification_uri"])
|
||||
user_code = str(code_data["user_code"])
|
||||
|
||||
print()
|
||||
print("To continue:")
|
||||
print(f" 1. Open: {verification_url}")
|
||||
print(f" 2. If prompted, enter code: {user_code}")
|
||||
if open_browser:
|
||||
if webbrowser.open(verification_url):
|
||||
print(" (Opened browser for verification)")
|
||||
else:
|
||||
print(" Could not open browser automatically -- use the URL above.")
|
||||
|
||||
interval_raw = code_data.get("interval")
|
||||
interval_ms = int(interval_raw) if interval_raw is not None else None
|
||||
print("Waiting for approval...")
|
||||
|
||||
token_data = _minimax_poll_token(
|
||||
client, portal_base_url=portal_base_url,
|
||||
client_id=pconfig.client_id,
|
||||
user_code=user_code, code_verifier=verifier,
|
||||
expired_in=int(code_data["expired_in"]),
|
||||
interval_ms=interval_ms,
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_in_s = int(token_data["expired_in"])
|
||||
expires_at = now.timestamp() + expires_in_s
|
||||
|
||||
auth_state = {
|
||||
"provider": "minimax-oauth",
|
||||
"region": region,
|
||||
"portal_base_url": portal_base_url,
|
||||
"inference_base_url": inference_base_url,
|
||||
"client_id": pconfig.client_id,
|
||||
"scope": MINIMAX_OAUTH_SCOPE,
|
||||
"token_type": token_data.get("token_type", "Bearer"),
|
||||
"access_token": token_data["access_token"],
|
||||
"refresh_token": token_data["refresh_token"],
|
||||
"resource_url": token_data.get("resource_url"),
|
||||
"obtained_at": now.isoformat(),
|
||||
"expires_at": datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat(),
|
||||
"expires_in": expires_in_s,
|
||||
}
|
||||
|
||||
_minimax_save_auth_state(auth_state)
|
||||
print("\u2713 MiniMax OAuth login successful.")
|
||||
if msg := token_data.get("notification_message"):
|
||||
print(f"Note from MiniMax: {msg}")
|
||||
return auth_state
|
||||
|
||||
|
||||
def _refresh_minimax_oauth_state(
|
||||
state: Dict[str, Any], *, timeout_seconds: float = 15.0,
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Refresh MiniMax OAuth access token if close to expiry (or forced)."""
|
||||
if not state.get("refresh_token"):
|
||||
raise AuthError(
|
||||
"MiniMax OAuth state has no refresh_token; please re-login.",
|
||||
provider="minimax-oauth", code="no_refresh_token", relogin_required=True,
|
||||
)
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(state.get("expires_at", "")).timestamp()
|
||||
except Exception:
|
||||
expires_at = 0.0
|
||||
now = time.time()
|
||||
if not force and (expires_at - now) > MINIMAX_OAUTH_REFRESH_SKEW_SECONDS:
|
||||
return state
|
||||
|
||||
portal_base_url = state["portal_base_url"]
|
||||
with httpx.Client(timeout=httpx.Timeout(timeout_seconds)) as client:
|
||||
response = client.post(
|
||||
f"{portal_base_url}/oauth/token",
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": state["client_id"],
|
||||
"refresh_token": state["refresh_token"],
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
body = response.text.lower()
|
||||
relogin = any(m in body for m in
|
||||
("invalid_grant", "refresh_token_reused", "invalid_refresh_token"))
|
||||
raise AuthError(
|
||||
f"MiniMax OAuth refresh failed: {response.text or response.reason_phrase}",
|
||||
provider="minimax-oauth", code="refresh_failed",
|
||||
relogin_required=relogin,
|
||||
)
|
||||
payload = response.json()
|
||||
if payload.get("status") != "success":
|
||||
raise AuthError(
|
||||
"MiniMax OAuth refresh did not return success.",
|
||||
provider="minimax-oauth", code="refresh_failed",
|
||||
relogin_required=True,
|
||||
)
|
||||
now_dt = datetime.now(timezone.utc)
|
||||
expires_in_s = int(payload["expired_in"])
|
||||
new_state = dict(state)
|
||||
new_state.update({
|
||||
"access_token": payload["access_token"],
|
||||
"refresh_token": payload.get("refresh_token", state["refresh_token"]),
|
||||
"obtained_at": now_dt.isoformat(),
|
||||
"expires_at": datetime.fromtimestamp(now_dt.timestamp() + expires_in_s,
|
||||
tz=timezone.utc).isoformat(),
|
||||
"expires_in": expires_in_s,
|
||||
})
|
||||
_minimax_save_auth_state(new_state)
|
||||
return new_state
|
||||
|
||||
|
||||
def resolve_minimax_oauth_runtime_credentials(
|
||||
*, min_token_ttl_seconds: int = MINIMAX_OAUTH_REFRESH_SKEW_SECONDS,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return {provider, api_key, base_url, source} for minimax-oauth."""
|
||||
state = get_provider_auth_state("minimax-oauth")
|
||||
if not state or not state.get("access_token"):
|
||||
raise AuthError(
|
||||
"Not logged into MiniMax OAuth. Run `hermes model` and select "
|
||||
"MiniMax (OAuth).",
|
||||
provider="minimax-oauth", code="not_logged_in", relogin_required=True,
|
||||
)
|
||||
state = _refresh_minimax_oauth_state(state)
|
||||
return {
|
||||
"provider": "minimax-oauth",
|
||||
"api_key": state["access_token"],
|
||||
"base_url": state["inference_base_url"].rstrip("/"),
|
||||
"source": "oauth",
|
||||
}
|
||||
|
||||
|
||||
def get_minimax_oauth_auth_status() -> Dict[str, Any]:
|
||||
"""Return auth status dict for MiniMax OAuth provider."""
|
||||
state = get_provider_auth_state("minimax-oauth")
|
||||
if not state or not state.get("access_token"):
|
||||
return {"logged_in": False, "provider": "minimax-oauth"}
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(state.get("expires_at", "")).timestamp()
|
||||
token_valid = (expires_at - time.time()) > 0
|
||||
except Exception:
|
||||
token_valid = bool(state.get("access_token"))
|
||||
return {
|
||||
"logged_in": token_valid,
|
||||
"provider": "minimax-oauth",
|
||||
"region": state.get("region", "global"),
|
||||
"expires_at": state.get("expires_at"),
|
||||
}
|
||||
|
||||
|
||||
def _login_minimax_oauth(args, pconfig: ProviderConfig) -> None:
|
||||
"""CLI entry for MiniMax OAuth login."""
|
||||
region = getattr(args, "region", None) or "global"
|
||||
open_browser = not getattr(args, "no_browser", False)
|
||||
timeout = getattr(args, "timeout", None) or 15.0
|
||||
try:
|
||||
_minimax_oauth_login(
|
||||
region=region, open_browser=open_browser, timeout_seconds=timeout,
|
||||
)
|
||||
except AuthError as exc:
|
||||
print(format_auth_error(exc))
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _nous_device_code_login(
|
||||
*,
|
||||
portal_base_url: Optional[str] = None,
|
||||
|
||||
@@ -33,7 +33,7 @@ from hermes_constants import OPENROUTER_BASE_URL
|
||||
|
||||
|
||||
# Providers that support OAuth login in addition to API keys.
|
||||
_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli"}
|
||||
_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli", "minimax-oauth"}
|
||||
|
||||
|
||||
def _get_custom_provider_names() -> list:
|
||||
@@ -170,7 +170,7 @@ def auth_add_command(args) -> None:
|
||||
if provider.startswith(CUSTOM_POOL_PREFIX):
|
||||
requested_type = AUTH_TYPE_API_KEY
|
||||
else:
|
||||
requested_type = AUTH_TYPE_OAUTH if provider in {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli"} else AUTH_TYPE_API_KEY
|
||||
requested_type = AUTH_TYPE_OAUTH if provider in {"anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli", "minimax-oauth"} else AUTH_TYPE_API_KEY
|
||||
|
||||
pool = load_pool(provider)
|
||||
|
||||
@@ -333,6 +333,27 @@ def auth_add_command(args) -> None:
|
||||
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
||||
return
|
||||
|
||||
if provider == "minimax-oauth":
|
||||
from hermes_cli.auth import resolve_minimax_oauth_runtime_credentials
|
||||
creds = resolve_minimax_oauth_runtime_credentials()
|
||||
label = (getattr(args, "label", None) or "").strip() or label_from_token(
|
||||
creds["api_key"],
|
||||
_oauth_default_label(provider, len(pool.entries()) + 1),
|
||||
)
|
||||
entry = PooledCredential(
|
||||
provider=provider,
|
||||
id=uuid.uuid4().hex[:6],
|
||||
label=label,
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source=f"{SOURCE_MANUAL}:minimax_oauth",
|
||||
access_token=creds["api_key"],
|
||||
base_url=creds.get("base_url"),
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
||||
return
|
||||
|
||||
raise SystemExit(f"`hermes auth add {provider}` is not implemented for auth type {requested_type} yet.")
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ Pure display functions with no HermesCLI state dependency.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -122,35 +123,36 @@ def get_available_skills() -> Dict[str, List[str]]:
|
||||
# Cache update check results for 6 hours to avoid repeated git fetches
|
||||
_UPDATE_CHECK_CACHE_SECONDS = 6 * 3600
|
||||
|
||||
# Sentinel returned when we know an update exists but can't count commits
|
||||
# (e.g. nix-built hermes — no local git history to count against).
|
||||
UPDATE_AVAILABLE_NO_COUNT = -1
|
||||
|
||||
def check_for_updates() -> Optional[int]:
|
||||
"""Check how many commits behind origin/main the local repo is.
|
||||
_UPSTREAM_REPO_URL = "https://github.com/NousResearch/hermes-agent.git"
|
||||
|
||||
Does a ``git fetch`` at most once every 6 hours (cached to
|
||||
``~/.hermes/.update_check``). Returns the number of commits behind,
|
||||
or ``None`` if the check fails or isn't applicable.
|
||||
|
||||
def _check_via_rev(local_rev: str) -> Optional[int]:
|
||||
"""Compare an embedded git revision to upstream main via ls-remote.
|
||||
|
||||
Returns 0 if up-to-date, ``UPDATE_AVAILABLE_NO_COUNT`` if behind,
|
||||
or ``None`` on failure.
|
||||
"""
|
||||
hermes_home = get_hermes_home()
|
||||
repo_dir = hermes_home / "hermes-agent"
|
||||
cache_file = hermes_home / ".update_check"
|
||||
|
||||
# Must be a git repo — fall back to project root for dev installs
|
||||
if not (repo_dir / ".git").exists():
|
||||
repo_dir = Path(__file__).parent.parent.resolve()
|
||||
if not (repo_dir / ".git").exists():
|
||||
return None
|
||||
|
||||
# Read cache
|
||||
now = time.time()
|
||||
try:
|
||||
if cache_file.exists():
|
||||
cached = json.loads(cache_file.read_text())
|
||||
if now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS:
|
||||
return cached.get("behind")
|
||||
result = subprocess.run(
|
||||
["git", "ls-remote", _UPSTREAM_REPO_URL, "refs/heads/main"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
if result.returncode != 0 or not result.stdout:
|
||||
return None
|
||||
upstream_rev = result.stdout.split()[0]
|
||||
if not upstream_rev:
|
||||
return None
|
||||
return 0 if upstream_rev == local_rev else UPDATE_AVAILABLE_NO_COUNT
|
||||
|
||||
# Fetch latest refs (fast — only downloads ref metadata, no files)
|
||||
|
||||
def _check_via_local_git(repo_dir: Path) -> Optional[int]:
|
||||
"""Count commits behind origin/main in a local checkout."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "fetch", "origin", "--quiet"],
|
||||
@@ -160,7 +162,6 @@ def check_for_updates() -> Optional[int]:
|
||||
except Exception:
|
||||
pass # Offline or timeout — use stale refs, that's fine
|
||||
|
||||
# Count commits behind
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-list", "--count", "HEAD..origin/main"],
|
||||
@@ -168,15 +169,52 @@ def check_for_updates() -> Optional[int]:
|
||||
cwd=str(repo_dir),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
behind = int(result.stdout.strip())
|
||||
else:
|
||||
behind = None
|
||||
return int(result.stdout.strip())
|
||||
except Exception:
|
||||
behind = None
|
||||
pass
|
||||
return None
|
||||
|
||||
# Write cache
|
||||
|
||||
def check_for_updates() -> Optional[int]:
|
||||
"""Check whether a Hermes update is available.
|
||||
|
||||
Two paths: if ``HERMES_REVISION`` is set (nix builds embed it), compare
|
||||
it to upstream main via ``git ls-remote``. Otherwise look for a local
|
||||
git checkout and count commits behind ``origin/main``.
|
||||
|
||||
Returns the number of commits behind, ``UPDATE_AVAILABLE_NO_COUNT`` (-1)
|
||||
if behind but the count is unknown, ``0`` if up-to-date, or ``None`` if
|
||||
the check failed or doesn't apply. Cached for 6 hours.
|
||||
"""
|
||||
hermes_home = get_hermes_home()
|
||||
cache_file = hermes_home / ".update_check"
|
||||
embedded_rev = os.environ.get("HERMES_REVISION") or None
|
||||
|
||||
# Read cache — invalidate if the embedded rev has changed since last check
|
||||
now = time.time()
|
||||
try:
|
||||
cache_file.write_text(json.dumps({"ts": now, "behind": behind}))
|
||||
if cache_file.exists():
|
||||
cached = json.loads(cache_file.read_text())
|
||||
if (
|
||||
now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS
|
||||
and cached.get("rev") == embedded_rev
|
||||
):
|
||||
return cached.get("behind")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if embedded_rev:
|
||||
behind = _check_via_rev(embedded_rev)
|
||||
else:
|
||||
repo_dir = hermes_home / "hermes-agent"
|
||||
if not (repo_dir / ".git").exists():
|
||||
repo_dir = Path(__file__).parent.parent.resolve()
|
||||
if not (repo_dir / ".git").exists():
|
||||
return None
|
||||
behind = _check_via_local_git(repo_dir)
|
||||
|
||||
try:
|
||||
cache_file.write_text(json.dumps({"ts": now, "behind": behind, "rev": embedded_rev}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -549,13 +587,23 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||
# Update check — use prefetched result if available
|
||||
try:
|
||||
behind = get_update_result(timeout=0.5)
|
||||
if behind and behind > 0:
|
||||
from hermes_cli.config import recommended_update_command
|
||||
commits_word = "commit" if behind == 1 else "commits"
|
||||
right_lines.append(
|
||||
f"[bold yellow]⚠ {behind} {commits_word} behind[/]"
|
||||
f"[dim yellow] — run [bold]{recommended_update_command()}[/bold] to update[/]"
|
||||
)
|
||||
if behind is not None and behind != 0:
|
||||
from hermes_cli.config import get_managed_update_command, recommended_update_command
|
||||
if behind > 0:
|
||||
commits_word = "commit" if behind == 1 else "commits"
|
||||
right_lines.append(
|
||||
f"[bold yellow]⚠ {behind} {commits_word} behind[/]"
|
||||
f"[dim yellow] — run [bold]{recommended_update_command()}[/bold] to update[/]"
|
||||
)
|
||||
else:
|
||||
# UPDATE_AVAILABLE_NO_COUNT: nix-built hermes; we know an update
|
||||
# exists but not by how much, and we don't know how the user
|
||||
# installed it (nix run, profile, system flake, home-manager).
|
||||
managed_cmd = get_managed_update_command()
|
||||
line = "[bold yellow]⚠ update available[/]"
|
||||
if managed_cmd:
|
||||
line += f"[dim yellow] — run [bold]{managed_cmd}[/bold][/]"
|
||||
right_lines.append(line)
|
||||
except Exception:
|
||||
pass # Never break the banner over an update check
|
||||
|
||||
|
||||
@@ -155,6 +155,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
cli_only=True),
|
||||
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
|
||||
aliases=("reload_mcp",)),
|
||||
CommandDef("reload-skills", "Re-scan ~/.hermes/skills/ for newly installed or removed skills",
|
||||
"Tools & Skills", aliases=("reload_skills",)),
|
||||
CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",
|
||||
cli_only=True, args_hint="[connect|disconnect|status]",
|
||||
subcommands=("connect", "disconnect", "status")),
|
||||
|
||||
@@ -73,6 +73,8 @@ _EXTRA_ENV_KEYS = frozenset({
|
||||
"QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME", # legacy aliases (pre-rename, still read for back-compat)
|
||||
"QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", "QQ_MARKDOWN_SUPPORT",
|
||||
"QQ_STT_API_KEY", "QQ_STT_BASE_URL", "QQ_STT_MODEL",
|
||||
"IRC_SERVER", "IRC_PORT", "IRC_NICKNAME", "IRC_CHANNEL",
|
||||
"IRC_USE_TLS", "IRC_SERVER_PASSWORD", "IRC_NICKSERV_PASSWORD",
|
||||
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
|
||||
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
|
||||
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_HOME_CHANNEL_NAME", "MATTERMOST_REPLY_MODE",
|
||||
@@ -499,7 +501,8 @@ DEFAULT_CONFIG = {
|
||||
"singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20",
|
||||
"modal_image": "nikolaik/python-nodejs:python3.11-nodejs20",
|
||||
"daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20",
|
||||
# Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)
|
||||
"vercel_runtime": "node24",
|
||||
# Container resource limits (docker, singularity, modal, daytona, vercel_sandbox — ignored for local/ssh)
|
||||
"container_cpu": 1,
|
||||
"container_memory": 5120, # MB (default 5GB)
|
||||
"container_disk": 51200, # MB (default 50GB)
|
||||
@@ -515,6 +518,16 @@ DEFAULT_CONFIG = {
|
||||
# Explicit opt-in: mount the host cwd into /workspace for Docker sessions.
|
||||
# Default off because passing host directories into a sandbox weakens isolation.
|
||||
"docker_mount_cwd_to_workspace": False,
|
||||
# Explicit opt-in: run the Docker container as the host user's uid:gid
|
||||
# (via `--user`). When enabled, files written into bind-mounted dirs
|
||||
# (docker_volumes, the persistent workspace, or the auto-mounted cwd)
|
||||
# are owned by your host user instead of root, which avoids needing
|
||||
# `sudo chown` after container runs. Default off to preserve behavior
|
||||
# for images whose entrypoints expect to start as root (e.g. the
|
||||
# bundled Hermes image, which drops to the `hermes` user via gosu).
|
||||
# When on, SETUID/SETGID caps are omitted from the container since
|
||||
# no privilege drop is needed.
|
||||
"docker_run_as_host_user": False,
|
||||
# Persistent shell — keep a long-lived bash shell across execute() calls
|
||||
# so cwd/env vars/shell variables survive between commands.
|
||||
# Enabled by default for non-local backends (SSH); local is always opt-in
|
||||
@@ -696,6 +709,19 @@ DEFAULT_CONFIG = {
|
||||
"timeout": 30,
|
||||
"extra_body": {},
|
||||
},
|
||||
# Curator — skill-usage review fork. Timeout is generous because the
|
||||
# review pass can take several minutes on reasoning models (umbrella
|
||||
# building over hundreds of candidate skills). "auto" = use main chat
|
||||
# model; override via `hermes model` → auxiliary → Curator to route
|
||||
# to a cheaper aux model (e.g. openrouter google/gemini-3-flash-preview).
|
||||
"curator": {
|
||||
"provider": "auto",
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 600,
|
||||
"extra_body": {},
|
||||
},
|
||||
},
|
||||
|
||||
"display": {
|
||||
@@ -753,7 +779,7 @@ DEFAULT_CONFIG = {
|
||||
# limit (OpenAI 4096, xAI 15000, MiniMax 10000, ElevenLabs 5k-40k model-aware,
|
||||
# Gemini 5000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
|
||||
"tts": {
|
||||
"provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "neutts" (local)
|
||||
"provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "gemini" | "neutts" (local) | "kittentts" (local) | "piper" (local)
|
||||
"edge": {
|
||||
"voice": "en-US-AriaNeural",
|
||||
# Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural
|
||||
@@ -783,6 +809,19 @@ DEFAULT_CONFIG = {
|
||||
"model": "neuphonic/neutts-air-q4-gguf", # HuggingFace model repo
|
||||
"device": "cpu", # cpu, cuda, or mps
|
||||
},
|
||||
"piper": {
|
||||
# Voice name (e.g. "en_US-lessac-medium") downloaded on first
|
||||
# use, OR an absolute path to a pre-downloaded .onnx file.
|
||||
# Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md
|
||||
"voice": "en_US-lessac-medium",
|
||||
# "voices_dir": "", # Override voice cache dir; default = ~/.hermes/cache/piper-voices/
|
||||
# "use_cuda": False, # Requires onnxruntime-gpu
|
||||
# "length_scale": 1.0, # 2.0 = twice as slow
|
||||
# "noise_scale": 0.667,
|
||||
# "noise_w_scale": 0.8,
|
||||
# "volume": 1.0,
|
||||
# "normalize_audio": True,
|
||||
},
|
||||
},
|
||||
|
||||
"stt": {
|
||||
@@ -936,12 +975,6 @@ DEFAULT_CONFIG = {
|
||||
# Archive a skill (move to skills/.archive/) after this many days
|
||||
# without use. Archived skills are recoverable — no auto-deletion.
|
||||
"archive_after_days": 90,
|
||||
# Optional per-task override for the curator's aux model. Leave null
|
||||
# to use Hermes' main auxiliary client resolution.
|
||||
"auxiliary": {
|
||||
"provider": None,
|
||||
"model": None,
|
||||
},
|
||||
},
|
||||
|
||||
# Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.
|
||||
@@ -1007,6 +1040,14 @@ DEFAULT_CONFIG = {
|
||||
"mode": "manual",
|
||||
"timeout": 60,
|
||||
"cron_mode": "deny",
|
||||
# When true, /reload-mcp asks the user to confirm before rebuilding
|
||||
# the MCP tool set for the active session. Reloading invalidates
|
||||
# the provider prompt cache (tool schemas are baked into the system
|
||||
# prompt), so the next message re-sends full input tokens — this can
|
||||
# be expensive on long-context or high-reasoning models. Users click
|
||||
# "Always Approve" to silence the prompt permanently; that flips
|
||||
# this key to false.
|
||||
"mcp_reload_confirm": True,
|
||||
},
|
||||
|
||||
# Permanently allowed dangerous command patterns (added via "always" approval)
|
||||
@@ -2065,6 +2106,43 @@ OPTIONAL_ENV_VARS = {
|
||||
"prompt": "QQ Sandbox Mode",
|
||||
"category": "messaging",
|
||||
},
|
||||
"IRC_SERVER": {
|
||||
"description": "IRC server hostname (e.g. irc.libera.chat)",
|
||||
"prompt": "IRC server",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
},
|
||||
"IRC_CHANNEL": {
|
||||
"description": "IRC channel to join (e.g. #hermes)",
|
||||
"prompt": "IRC channel",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
},
|
||||
"IRC_NICKNAME": {
|
||||
"description": "Bot nickname on IRC (default: hermes-bot)",
|
||||
"prompt": "IRC nickname",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
},
|
||||
"IRC_SERVER_PASSWORD": {
|
||||
"description": "IRC server password (if required)",
|
||||
"prompt": "IRC server password",
|
||||
"url": None,
|
||||
"password": True,
|
||||
"category": "messaging",
|
||||
"advanced": True,
|
||||
},
|
||||
"IRC_NICKSERV_PASSWORD": {
|
||||
"description": "NickServ password for nick identification",
|
||||
"prompt": "NickServ password",
|
||||
"url": None,
|
||||
"password": True,
|
||||
"category": "messaging",
|
||||
"advanced": True,
|
||||
},
|
||||
"GATEWAY_ALLOW_ALL_USERS": {
|
||||
"description": "Allow all users to interact with messaging bots (true/false). Default: false.",
|
||||
"prompt": "Allow all users (true/false)",
|
||||
@@ -3477,6 +3555,52 @@ def _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return config
|
||||
|
||||
|
||||
def cfg_get(cfg: Optional[Dict[str, Any]], *keys: str, default: Any = None) -> Any:
|
||||
"""Traverse nested dict keys safely, returning ``default`` on any miss.
|
||||
|
||||
Canonical helper for the ``cfg.get("X", {}).get("Y", default)`` pattern
|
||||
that appears 50+ times across the codebase. Handles three common gotchas
|
||||
in one place:
|
||||
|
||||
1. Missing intermediate keys (returns ``default``, no KeyError).
|
||||
2. An intermediate value that's not a dict (e.g. a user wrote a string
|
||||
where a section was expected). Returns ``default`` instead of
|
||||
AttributeError on ``.get()``.
|
||||
3. ``cfg is None`` (callers sometimes pass ``load_config() or None``).
|
||||
|
||||
Named ``cfg_get`` rather than ``cfg_path`` to avoid shadowing the
|
||||
ubiquitous ``cfg_path = _hermes_home / "config.yaml"`` local variable
|
||||
that appears in gateway/run.py, cron/scheduler.py, main.py, etc.
|
||||
|
||||
Explicit ``None`` values are returned as-is (matches ``dict.get(key,
|
||||
default)`` semantics — ``default`` is only returned when the key is
|
||||
*absent*, not when it's present but set to ``None``).
|
||||
|
||||
Examples:
|
||||
>>> cfg_get({"agent": {"reasoning_effort": "high"}}, "agent", "reasoning_effort")
|
||||
'high'
|
||||
>>> cfg_get({}, "agent", "reasoning_effort", default="medium")
|
||||
'medium'
|
||||
>>> cfg_get({"agent": "oops_a_string"}, "agent", "reasoning_effort", default="low")
|
||||
'low'
|
||||
>>> cfg_get(None, "anything", default=42)
|
||||
42
|
||||
>>> cfg_get({"a": {"b": None}}, "a", "b", default="def") # explicit None preserved
|
||||
>>> cfg_get({"a": {"b": False}}, "a", "b", default=True) # falsy values preserved
|
||||
False
|
||||
"""
|
||||
if not isinstance(cfg, dict):
|
||||
return default
|
||||
node: Any = cfg
|
||||
for key in keys:
|
||||
if not isinstance(node, dict):
|
||||
return default
|
||||
if key not in node:
|
||||
return default
|
||||
node = node[key]
|
||||
return node
|
||||
|
||||
|
||||
|
||||
def read_raw_config() -> Dict[str, Any]:
|
||||
"""Read ~/.hermes/config.yaml as-is, without merging defaults or migrating.
|
||||
@@ -4137,6 +4261,9 @@ def show_config():
|
||||
print(f" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
|
||||
daytona_key = get_env_value('DAYTONA_API_KEY')
|
||||
print(f" API key: {'configured' if daytona_key else '(not set)'}")
|
||||
elif terminal.get('backend') == 'vercel_sandbox':
|
||||
print(f" Vercel runtime: {terminal.get('vercel_runtime', 'node24')}")
|
||||
print(f" Vercel auth: {'configured' if get_env_value('VERCEL_OIDC_TOKEN') or (get_env_value('VERCEL_TOKEN') and get_env_value('VERCEL_PROJECT_ID') and get_env_value('VERCEL_TEAM_ID')) else '(not set)'}")
|
||||
elif terminal.get('backend') == 'ssh':
|
||||
ssh_host = get_env_value('TERMINAL_SSH_HOST')
|
||||
ssh_user = get_env_value('TERMINAL_SSH_USER')
|
||||
@@ -4329,7 +4456,9 @@ def set_config_value(key: str, value: str):
|
||||
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
|
||||
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
|
||||
"terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
|
||||
"terminal.vercel_runtime": "TERMINAL_VERCEL_RUNTIME",
|
||||
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
|
||||
"terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
|
||||
"terminal.cwd": "TERMINAL_CWD",
|
||||
"terminal.timeout": "TERMINAL_TIMEOUT",
|
||||
"terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR",
|
||||
|
||||
@@ -55,6 +55,9 @@ def _cmd_status(args) -> int:
|
||||
print(f" runs: {runs}")
|
||||
print(f" last run: {_fmt_ts(last_run)}")
|
||||
print(f" last summary: {summary}")
|
||||
_report = state.get("last_report_path")
|
||||
if _report:
|
||||
print(f" last report: {_report}")
|
||||
_ih = curator.get_interval_hours()
|
||||
_interval_label = (
|
||||
f"{_ih // 24}d" if _ih % 24 == 0 and _ih >= 24
|
||||
|
||||
@@ -8,6 +8,7 @@ import os
|
||||
import sys
|
||||
import subprocess
|
||||
import shutil
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.config import get_project_root, get_hermes_home, get_env_path
|
||||
@@ -30,6 +31,7 @@ load_dotenv(PROJECT_ROOT / ".env", override=False, encoding="utf-8")
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.models import _HERMES_USER_AGENT
|
||||
from hermes_cli.vercel_auth import describe_vercel_auth
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
from utils import base_url_host_matches
|
||||
|
||||
@@ -76,6 +78,14 @@ def _system_package_install_cmd(pkg: str) -> str:
|
||||
return f"sudo apt install {pkg}"
|
||||
|
||||
|
||||
def _safe_which(cmd: str) -> str | None:
|
||||
"""shutil.which wrapper resilient to platform monkeypatching in tests."""
|
||||
try:
|
||||
return shutil.which(cmd)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _termux_browser_setup_steps(node_installed: bool) -> list[str]:
|
||||
steps: list[str] = []
|
||||
step = 1
|
||||
@@ -537,6 +547,7 @@ def run_doctor(args):
|
||||
get_nous_auth_status,
|
||||
get_codex_auth_status,
|
||||
get_gemini_oauth_auth_status,
|
||||
get_minimax_oauth_auth_status,
|
||||
)
|
||||
|
||||
nous_status = get_nous_auth_status()
|
||||
@@ -566,10 +577,17 @@ def run_doctor(args):
|
||||
check_ok("Google Gemini OAuth", f"(logged in{suffix})")
|
||||
else:
|
||||
check_warn("Google Gemini OAuth", "(not logged in)")
|
||||
|
||||
minimax_status = get_minimax_oauth_auth_status()
|
||||
if minimax_status.get("logged_in"):
|
||||
region = minimax_status.get("region", "global")
|
||||
check_ok("MiniMax OAuth", f"(logged in, region={region})")
|
||||
else:
|
||||
check_warn("MiniMax OAuth", "(not logged in)")
|
||||
except Exception as e:
|
||||
check_warn("Auth provider status", f"(could not check: {e})")
|
||||
|
||||
if shutil.which("codex"):
|
||||
if _safe_which("codex"):
|
||||
check_ok("codex CLI")
|
||||
else:
|
||||
# Native OAuth uses Hermes' own device-code flow — the Codex CLI is
|
||||
@@ -787,13 +805,13 @@ def run_doctor(args):
|
||||
print(color("◆ External Tools", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
# Git
|
||||
if shutil.which("git"):
|
||||
if _safe_which("git"):
|
||||
check_ok("git")
|
||||
else:
|
||||
check_warn("git not found", "(optional)")
|
||||
|
||||
# ripgrep (optional, for faster file search)
|
||||
if shutil.which("rg"):
|
||||
if _safe_which("rg"):
|
||||
check_ok("ripgrep (rg)", "(faster file search)")
|
||||
else:
|
||||
check_warn("ripgrep (rg) not found", "(file search uses grep fallback)")
|
||||
@@ -802,7 +820,7 @@ def run_doctor(args):
|
||||
# Docker (optional)
|
||||
terminal_env = os.getenv("TERMINAL_ENV", "local")
|
||||
if terminal_env == "docker":
|
||||
if shutil.which("docker"):
|
||||
if _safe_which("docker"):
|
||||
# Check if docker daemon is running
|
||||
try:
|
||||
result = subprocess.run(["docker", "info"], capture_output=True, timeout=10)
|
||||
@@ -817,7 +835,7 @@ def run_doctor(args):
|
||||
check_fail("docker not found", "(required for TERMINAL_ENV=docker)")
|
||||
issues.append("Install Docker or change TERMINAL_ENV")
|
||||
else:
|
||||
if shutil.which("docker"):
|
||||
if _safe_which("docker"):
|
||||
check_ok("docker", "(optional)")
|
||||
else:
|
||||
if _is_termux():
|
||||
@@ -863,8 +881,52 @@ def run_doctor(args):
|
||||
check_fail("daytona SDK not installed", "(pip install daytona)")
|
||||
issues.append("Install daytona SDK: pip install daytona")
|
||||
|
||||
# Vercel Sandbox (if using vercel_sandbox backend)
|
||||
if terminal_env == "vercel_sandbox":
|
||||
runtime = os.getenv("TERMINAL_VERCEL_RUNTIME", "node24").strip() or "node24"
|
||||
from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES
|
||||
if runtime in _SUPPORTED_VERCEL_RUNTIMES:
|
||||
check_ok("Vercel runtime", f"({runtime})")
|
||||
else:
|
||||
supported = ", ".join(_SUPPORTED_VERCEL_RUNTIMES)
|
||||
check_fail("Vercel runtime unsupported", f"({runtime}; use {supported})")
|
||||
issues.append(f"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}")
|
||||
|
||||
disk = os.getenv("TERMINAL_CONTAINER_DISK", "51200").strip()
|
||||
if disk in ("", "0", "51200"):
|
||||
check_ok("Vercel disk setting", "(uses platform default)")
|
||||
else:
|
||||
check_fail("Vercel custom disk unsupported", "(reset terminal.container_disk to 51200)")
|
||||
issues.append("Vercel Sandbox does not support custom container_disk; use the shared default 51200")
|
||||
|
||||
if importlib.util.find_spec("vercel") is not None:
|
||||
check_ok("vercel SDK", "(installed)")
|
||||
else:
|
||||
check_fail("vercel SDK not installed", "(pip install 'hermes-agent[vercel]')")
|
||||
issues.append("Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'")
|
||||
|
||||
auth_status = describe_vercel_auth()
|
||||
if auth_status.ok:
|
||||
check_ok("Vercel auth", f"({auth_status.label})")
|
||||
elif auth_status.label.startswith("partial"):
|
||||
check_fail("Vercel auth incomplete", f"({auth_status.label})")
|
||||
issues.append("Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together")
|
||||
else:
|
||||
check_fail("Vercel auth not configured", f"({auth_status.label})")
|
||||
issues.append(
|
||||
"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID"
|
||||
)
|
||||
for line in auth_status.detail_lines:
|
||||
check_info(f"Vercel auth {line}")
|
||||
|
||||
persistent = os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in ("1", "true", "yes", "on")
|
||||
if persistent:
|
||||
check_info("Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation")
|
||||
else:
|
||||
check_info("Vercel persistence: ephemeral filesystem")
|
||||
|
||||
# Node.js + agent-browser (for browser automation tools)
|
||||
if shutil.which("node"):
|
||||
if _safe_which("node"):
|
||||
check_ok("Node.js")
|
||||
# Check if agent-browser is installed
|
||||
agent_browser_path = PROJECT_ROOT / "node_modules" / "agent-browser"
|
||||
@@ -890,7 +952,7 @@ def run_doctor(args):
|
||||
check_warn("Node.js not found", "(optional, needed for browser tools)")
|
||||
|
||||
# npm audit for all Node.js packages
|
||||
if shutil.which("npm"):
|
||||
if _safe_which("npm"):
|
||||
npm_dirs = [
|
||||
(PROJECT_ROOT, "Browser tools (agent-browser)"),
|
||||
(PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge"),
|
||||
@@ -969,10 +1031,16 @@ def run_doctor(args):
|
||||
print(" Checking Anthropic API...", end="", flush=True)
|
||||
try:
|
||||
import httpx
|
||||
from agent.anthropic_adapter import _is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS
|
||||
from agent.anthropic_adapter import (
|
||||
_is_oauth_token,
|
||||
_COMMON_BETAS,
|
||||
_OAUTH_ONLY_BETAS,
|
||||
_CONTEXT_1M_BETA,
|
||||
)
|
||||
|
||||
headers = {"anthropic-version": "2023-06-01"}
|
||||
if _is_oauth_token(anthropic_key):
|
||||
is_oauth = _is_oauth_token(anthropic_key)
|
||||
if is_oauth:
|
||||
headers["Authorization"] = f"Bearer {anthropic_key}"
|
||||
headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
|
||||
else:
|
||||
@@ -982,6 +1050,25 @@ def run_doctor(args):
|
||||
headers=headers,
|
||||
timeout=10
|
||||
)
|
||||
# Reactive recovery: OAuth subscriptions that don't include 1M
|
||||
# context reject the request with 400 "long context beta is not
|
||||
# yet available for this subscription". Retry once with that
|
||||
# beta stripped so the doctor check doesn't falsely report the
|
||||
# Anthropic API as unreachable for those users.
|
||||
if (
|
||||
is_oauth
|
||||
and response.status_code == 400
|
||||
and "long context beta" in response.text.lower()
|
||||
and "not yet available" in response.text.lower()
|
||||
):
|
||||
headers["anthropic-beta"] = ",".join(
|
||||
[b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)
|
||||
)
|
||||
response = httpx.get(
|
||||
"https://api.anthropic.com/v1/models",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
print(f"\r {color('✓', Colors.GREEN)} Anthropic API ")
|
||||
elif response.status_code == 401:
|
||||
|
||||
@@ -279,9 +279,11 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li
|
||||
["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="ignore",
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
if result.returncode != 0 or result.stdout is None:
|
||||
return []
|
||||
current_cmd = ""
|
||||
for line in result.stdout.split("\n"):
|
||||
@@ -830,6 +832,22 @@ def _user_dbus_socket_path() -> Path:
|
||||
return Path(xdg) / "bus"
|
||||
|
||||
|
||||
def _user_systemd_private_socket_path() -> Path:
|
||||
"""Return the per-user systemd private socket path (regardless of existence)."""
|
||||
xdg = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
|
||||
return Path(xdg) / "systemd" / "private"
|
||||
|
||||
|
||||
def _user_systemd_socket_ready() -> bool:
|
||||
"""Return True when user-scope systemd has a reachable control socket.
|
||||
|
||||
Some distros expose only the per-user systemd private socket even when the
|
||||
D-Bus session bus socket is absent. ``systemctl --user`` can still work in
|
||||
that configuration, so preflight checks must treat either socket as valid.
|
||||
"""
|
||||
return _user_dbus_socket_path().exists() or _user_systemd_private_socket_path().exists()
|
||||
|
||||
|
||||
def _ensure_user_systemd_env() -> None:
|
||||
"""Ensure DBUS_SESSION_BUS_ADDRESS and XDG_RUNTIME_DIR are set for systemctl --user.
|
||||
|
||||
@@ -853,28 +871,29 @@ def _ensure_user_systemd_env() -> None:
|
||||
|
||||
|
||||
def _wait_for_user_dbus_socket(timeout: float = 3.0) -> bool:
|
||||
"""Poll for the user D-Bus socket to appear, up to ``timeout`` seconds.
|
||||
"""Poll for the user systemd runtime socket(s), up to ``timeout`` seconds.
|
||||
|
||||
Linger-enabled user@.service can take a second or two to spawn the socket
|
||||
after ``loginctl enable-linger`` runs. Returns True once the socket exists.
|
||||
Linger-enabled user@.service can take a second or two to spawn its control
|
||||
socket(s) after ``loginctl enable-linger`` runs. Returns True once either
|
||||
the user D-Bus socket or the per-user systemd private socket exists.
|
||||
"""
|
||||
import time
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if _user_dbus_socket_path().exists():
|
||||
if _user_systemd_socket_ready():
|
||||
_ensure_user_systemd_env()
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return _user_dbus_socket_path().exists()
|
||||
return _user_systemd_socket_ready()
|
||||
|
||||
|
||||
def _preflight_user_systemd(*, auto_enable_linger: bool = True) -> None:
|
||||
"""Ensure ``systemctl --user`` will reach the user D-Bus session bus.
|
||||
"""Ensure ``systemctl --user`` will reach the user-scope systemd instance.
|
||||
|
||||
No-op when the bus socket is already there (the common case on desktops
|
||||
and linger-enabled servers). On fresh SSH sessions where the socket is
|
||||
missing:
|
||||
No-op when the user D-Bus socket or per-user systemd private socket is
|
||||
already there (the common case on desktops and linger-enabled servers). On
|
||||
fresh SSH sessions where both are missing:
|
||||
|
||||
* If linger is already enabled, wait briefly for user@.service to spawn
|
||||
the socket.
|
||||
@@ -888,8 +907,7 @@ def _preflight_user_systemd(*, auto_enable_linger: bool = True) -> None:
|
||||
systemd operations and surface the message to the user.
|
||||
"""
|
||||
_ensure_user_systemd_env()
|
||||
bus_path = _user_dbus_socket_path()
|
||||
if bus_path.exists():
|
||||
if _user_systemd_socket_ready():
|
||||
return
|
||||
|
||||
import getpass
|
||||
@@ -903,7 +921,7 @@ def _preflight_user_systemd(*, auto_enable_linger: bool = True) -> None:
|
||||
# Linger is on but socket still missing — unusual; fall through to error.
|
||||
_raise_user_systemd_unavailable(
|
||||
username,
|
||||
reason="User D-Bus socket is missing even though linger is enabled.",
|
||||
reason="User systemd control sockets are missing even though linger is enabled.",
|
||||
fix_hint=(
|
||||
f" systemctl start user@{os.getuid()}.service\n"
|
||||
" (may require sudo; try again after the command succeeds)"
|
||||
@@ -2743,15 +2761,77 @@ _PLATFORMS = [
|
||||
],
|
||||
},
|
||||
]
|
||||
def _all_platforms() -> list[dict]:
|
||||
"""Return the full list of platforms for setup menus.
|
||||
|
||||
Combines the built-in ``_PLATFORMS`` with plugin platforms registered via
|
||||
``platform_registry``. Plugins are discovered on first call so bundled
|
||||
platforms (like IRC, which auto-load via ``kind: platform``) appear in
|
||||
``hermes setup gateway`` without needing the gateway to be running.
|
||||
Built-ins keep their dict shape; plugin entries are adapted to the same
|
||||
shape with ``_registry_entry`` holding the source.
|
||||
"""
|
||||
# Populate the registry so plugin platforms are visible. Idempotent.
|
||||
# Bundled platform plugins (``kind: platform``) auto-load unconditionally,
|
||||
# so every shipped messaging channel appears in the setup menu by default.
|
||||
# User-installed platform plugins under ~/.hermes/plugins/ still require
|
||||
# opt-in via ``plugins.enabled`` (untrusted code).
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
discover_plugins()
|
||||
except Exception as e:
|
||||
logger.debug("plugin discovery failed during platform enumeration: %s", e)
|
||||
|
||||
platforms = [dict(p) for p in _PLATFORMS]
|
||||
by_key = {p["key"]: p for p in platforms}
|
||||
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
except Exception:
|
||||
return platforms
|
||||
|
||||
for entry in platform_registry.all_entries():
|
||||
if entry.name in by_key:
|
||||
continue # built-in already covers it
|
||||
platforms.append({
|
||||
"key": entry.name,
|
||||
"label": entry.label,
|
||||
"emoji": entry.emoji,
|
||||
"token_var": entry.required_env[0] if entry.required_env else "",
|
||||
"install_hint": entry.install_hint,
|
||||
"_registry_entry": entry,
|
||||
})
|
||||
return platforms
|
||||
|
||||
|
||||
def _platform_status(platform: dict) -> str:
|
||||
"""Return a plain-text status string for a platform.
|
||||
|
||||
Returns uncolored text so it can safely be embedded in
|
||||
simple_term_menu items (ANSI codes break width calculation).
|
||||
curses menu items (ANSI codes break width calculation).
|
||||
"""
|
||||
token_var = platform["token_var"]
|
||||
entry = platform.get("_registry_entry")
|
||||
if entry is not None:
|
||||
configured = False
|
||||
# Prefer is_connected (checks both env and config.yaml) over
|
||||
# check_fn (typically just dependency / env presence).
|
||||
if entry.is_connected is not None:
|
||||
try:
|
||||
from gateway.config import PlatformConfig
|
||||
synthetic = PlatformConfig(enabled=True)
|
||||
configured = bool(entry.is_connected(synthetic))
|
||||
except Exception:
|
||||
configured = False
|
||||
if not configured:
|
||||
try:
|
||||
configured = bool(entry.check_fn())
|
||||
except Exception:
|
||||
configured = False
|
||||
return "configured" if configured else "not configured"
|
||||
|
||||
token_var = platform.get("token_var", "")
|
||||
if not token_var:
|
||||
return "not configured"
|
||||
val = get_env_value(token_var)
|
||||
if token_var == "WHATSAPP_ENABLED":
|
||||
if val and val.lower() == "true":
|
||||
@@ -3277,6 +3357,12 @@ def _setup_weixin():
|
||||
print_warning(" Direct messages disabled.")
|
||||
|
||||
print()
|
||||
print_info(" Note: QR login connects an iLink bot identity (e.g. ...@im.bot), not a")
|
||||
print_info(" scriptable personal WeChat account. Ordinary WeChat groups typically cannot")
|
||||
print_info(" invite an @im.bot identity, and iLink does not deliver ordinary-group events")
|
||||
print_info(" to most bot accounts. The settings below only apply when iLink actually")
|
||||
print_info(" delivers group events for your account type — otherwise DM remains the only")
|
||||
print_info(" working channel regardless of this choice.")
|
||||
group_choices = [
|
||||
"Disable group chats (recommended)",
|
||||
"Allow all group chats",
|
||||
@@ -3290,12 +3376,12 @@ def _setup_weixin():
|
||||
elif group_idx == 1:
|
||||
save_env_value("WEIXIN_GROUP_POLICY", "open")
|
||||
save_env_value("WEIXIN_GROUP_ALLOWED_USERS", "")
|
||||
print_warning(" All group chats enabled.")
|
||||
print_warning(" All group chats enabled (only takes effect if iLink delivers group events).")
|
||||
else:
|
||||
allow_groups = prompt(" Allowed group chat IDs (comma-separated)", "", password=False).replace(" ", "")
|
||||
allow_groups = prompt(" Allowed group chat IDs (comma-separated, not member user IDs)", "", password=False).replace(" ", "")
|
||||
save_env_value("WEIXIN_GROUP_POLICY", "allowlist")
|
||||
save_env_value("WEIXIN_GROUP_ALLOWED_USERS", allow_groups)
|
||||
print_success(" Group allowlist saved.")
|
||||
print_success(" Group allowlist saved (only takes effect if iLink delivers group events).")
|
||||
|
||||
if user_id:
|
||||
print()
|
||||
@@ -3703,6 +3789,71 @@ def _setup_signal():
|
||||
print_info(f" Groups: {'enabled' if get_env_value('SIGNAL_GROUP_ALLOWED_USERS') else 'disabled'}")
|
||||
|
||||
|
||||
def _builtin_setup_fn(key: str):
|
||||
"""Resolve the interactive setup function for a built-in platform key.
|
||||
|
||||
Late-bound to avoid a circular import with ``hermes_cli.setup`` (which
|
||||
imports from this module for the remaining bespoke flows).
|
||||
"""
|
||||
from hermes_cli import setup as _s
|
||||
return {
|
||||
"telegram": _s._setup_telegram,
|
||||
"discord": _s._setup_discord,
|
||||
"slack": _s._setup_slack,
|
||||
"matrix": _s._setup_matrix,
|
||||
"mattermost": _s._setup_mattermost,
|
||||
"bluebubbles": _s._setup_bluebubbles,
|
||||
"webhooks": _s._setup_webhooks,
|
||||
"signal": _setup_signal,
|
||||
"whatsapp": _setup_whatsapp,
|
||||
"weixin": _setup_weixin,
|
||||
"dingtalk": _setup_dingtalk,
|
||||
"feishu": _setup_feishu,
|
||||
"wecom": _setup_wecom,
|
||||
"qqbot": _setup_qqbot,
|
||||
}.get(key)
|
||||
def _configure_platform(platform: dict) -> None:
|
||||
"""Run the interactive setup flow for a single platform.
|
||||
|
||||
Dispatch order:
|
||||
1. Plugin-provided ``setup_fn`` on the registry entry.
|
||||
2. Built-in setup function matched by platform key.
|
||||
3. ``_setup_standard_platform`` when the entry has a ``vars`` schema.
|
||||
4. Env-var hint fallback for plugins that offer no setup helper.
|
||||
|
||||
Bundled platform plugins (e.g. IRC) auto-load, so no plugin enable step
|
||||
is needed here. User-installed platform plugins under ~/.hermes/plugins/
|
||||
must already be in ``plugins.enabled`` before they appear in this menu.
|
||||
"""
|
||||
entry = platform.get("_registry_entry")
|
||||
|
||||
if entry is not None and entry.setup_fn is not None:
|
||||
entry.setup_fn()
|
||||
return
|
||||
|
||||
fn = _builtin_setup_fn(platform["key"])
|
||||
if fn is not None:
|
||||
fn()
|
||||
return
|
||||
|
||||
if platform.get("vars"):
|
||||
_setup_standard_platform(platform)
|
||||
return
|
||||
|
||||
# Plugin with no setup helper — show env-var instructions.
|
||||
label = platform.get("label", platform["key"])
|
||||
emoji = platform.get("emoji", "🔌")
|
||||
print()
|
||||
print(color(f" ─── {emoji} {label} Setup ───", Colors.CYAN))
|
||||
required = entry.required_env if entry else []
|
||||
if required:
|
||||
print_info(f" Set these env vars in ~/.hermes/.env: {', '.join(required)}")
|
||||
else:
|
||||
print_info(f" Configure {label} in config.yaml under gateway.platforms.{platform['key']}")
|
||||
if platform.get("install_hint"):
|
||||
print_info(f" {platform['install_hint']}")
|
||||
|
||||
|
||||
def gateway_setup():
|
||||
"""Interactive setup for messaging platforms + gateway service."""
|
||||
if is_managed():
|
||||
@@ -3755,42 +3906,36 @@ def gateway_setup():
|
||||
print()
|
||||
print_header("Messaging Platforms")
|
||||
|
||||
menu_items = []
|
||||
for plat in _PLATFORMS:
|
||||
status = _platform_status(plat)
|
||||
menu_items.append(f"{plat['label']} ({status})")
|
||||
platforms = _all_platforms()
|
||||
|
||||
menu_items = [
|
||||
f"{p['emoji']} {p['label']} ({_platform_status(p)})"
|
||||
for p in platforms
|
||||
]
|
||||
menu_items.append("Done")
|
||||
|
||||
choice = prompt_choice("Select a platform to configure:", menu_items, len(menu_items) - 1)
|
||||
|
||||
if choice == len(_PLATFORMS):
|
||||
if choice == len(platforms):
|
||||
break
|
||||
|
||||
platform = _PLATFORMS[choice]
|
||||
|
||||
if platform["key"] == "whatsapp":
|
||||
_setup_whatsapp()
|
||||
elif platform["key"] == "signal":
|
||||
_setup_signal()
|
||||
elif platform["key"] == "weixin":
|
||||
_setup_weixin()
|
||||
elif platform["key"] == "dingtalk":
|
||||
_setup_dingtalk()
|
||||
elif platform["key"] == "feishu":
|
||||
_setup_feishu()
|
||||
elif platform["key"] == "qqbot":
|
||||
_setup_qqbot()
|
||||
elif platform["key"] == "wecom":
|
||||
_setup_wecom()
|
||||
else:
|
||||
_setup_standard_platform(platform)
|
||||
_configure_platform(platforms[choice])
|
||||
|
||||
# ── Post-setup: offer to install/restart gateway ──
|
||||
# Consider any platform (built-in or plugin) where the user has made
|
||||
# meaningful progress. ``_platform_status`` already handles plugin
|
||||
# entries via their check_fn and per-platform dual-states like
|
||||
# WhatsApp's "enabled, not paired".
|
||||
def _is_progress(status: str) -> bool:
|
||||
s = status.lower()
|
||||
return not (
|
||||
s == "not configured"
|
||||
or s.startswith("partially")
|
||||
or s.startswith("plugin disabled")
|
||||
)
|
||||
|
||||
any_configured = any(
|
||||
bool(get_env_value(p["token_var"]))
|
||||
for p in _PLATFORMS
|
||||
if p["key"] != "whatsapp"
|
||||
) or (get_env_value("WHATSAPP_ENABLED") or "").lower() == "true"
|
||||
_is_progress(_platform_status(p)) for p in _all_platforms()
|
||||
)
|
||||
|
||||
if any_configured:
|
||||
print()
|
||||
@@ -4228,4 +4373,4 @@ def _gateway_command_inner(args):
|
||||
if not supports_systemd_services() and not is_macos():
|
||||
print("Legacy unit migration only applies to systemd-based Linux hosts.")
|
||||
return
|
||||
remove_legacy_hermes_units(interactive=not yes, dry_run=dry_run)
|
||||
remove_legacy_hermes_units(interactive=not yes, dry_run=dry_run)
|
||||
@@ -114,6 +114,12 @@ def _apply_profile_override() -> None:
|
||||
consume = 1
|
||||
break
|
||||
|
||||
# 1.5 If HERMES_HOME is already set and no explicit flag was given, trust it.
|
||||
# This lets child processes (relaunch, subprocess) inherit the parent's
|
||||
# profile choice without having to pass --profile again.
|
||||
if profile_name is None and os.environ.get("HERMES_HOME"):
|
||||
return
|
||||
|
||||
# 2. If no flag, check active_profile in the hermes root
|
||||
if profile_name is None:
|
||||
try:
|
||||
@@ -1094,11 +1100,36 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
return [node, str(root / "dist" / "entry.js")], root
|
||||
|
||||
|
||||
def _normalize_tui_toolsets(toolsets: object) -> list[str]:
|
||||
"""Normalize argparse/Fire-style toolset input for the TUI subprocess."""
|
||||
try:
|
||||
from hermes_cli.oneshot import _normalize_toolsets
|
||||
|
||||
return _normalize_toolsets(toolsets) or []
|
||||
except (AttributeError, ImportError):
|
||||
if not toolsets:
|
||||
return []
|
||||
|
||||
raw_items = [toolsets] if isinstance(toolsets, str) else toolsets
|
||||
if not isinstance(raw_items, (list, tuple)):
|
||||
raw_items = [raw_items]
|
||||
|
||||
normalized: list[str] = []
|
||||
for item in raw_items:
|
||||
if isinstance(item, str):
|
||||
normalized.extend(part.strip() for part in item.split(","))
|
||||
else:
|
||||
normalized.append(str(item).strip())
|
||||
|
||||
return [item for item in normalized if item]
|
||||
|
||||
|
||||
def _launch_tui(
|
||||
resume_session_id: Optional[str] = None,
|
||||
tui_dev: bool = False,
|
||||
model: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
toolsets: object = None,
|
||||
):
|
||||
"""Replace current process with the TUI."""
|
||||
tui_dir = PROJECT_ROOT / "ui-tui"
|
||||
@@ -1123,6 +1154,9 @@ def _launch_tui(
|
||||
if provider:
|
||||
env["HERMES_TUI_PROVIDER"] = provider
|
||||
env["HERMES_INFERENCE_PROVIDER"] = provider
|
||||
tui_toolsets = _normalize_tui_toolsets(toolsets)
|
||||
if tui_toolsets:
|
||||
env["HERMES_TUI_TOOLSETS"] = ",".join(tui_toolsets)
|
||||
# Guarantee an 8GB V8 heap + exposed GC for the TUI. Default node cap is
|
||||
# ~1.5–4GB depending on version and can fatal-OOM on long sessions with
|
||||
# large transcripts / reasoning blobs. Token-level merge: respect any
|
||||
@@ -1270,6 +1304,7 @@ def cmd_chat(args):
|
||||
tui_dev=getattr(args, "tui_dev", False),
|
||||
model=getattr(args, "model", None),
|
||||
provider=getattr(args, "provider", None),
|
||||
toolsets=getattr(args, "toolsets", None),
|
||||
)
|
||||
|
||||
# Import and run the CLI
|
||||
@@ -1770,6 +1805,8 @@ def select_provider_and_model(args=None):
|
||||
_model_flow_openai_codex(config, current_model)
|
||||
elif selected_provider == "qwen-oauth":
|
||||
_model_flow_qwen_oauth(config, current_model)
|
||||
elif selected_provider == "minimax-oauth":
|
||||
_model_flow_minimax_oauth(config, current_model, args=args)
|
||||
elif selected_provider == "google-gemini-cli":
|
||||
_model_flow_google_gemini_cli(config, current_model)
|
||||
elif selected_provider == "copilot-acp":
|
||||
@@ -1890,6 +1927,7 @@ _AUX_TASKS: list[tuple[str, str, str]] = [
|
||||
("mcp", "MCP", "MCP tool reasoning"),
|
||||
("title_generation", "Title generation", "session titles"),
|
||||
("skills_hub", "Skills hub", "skills search/install"),
|
||||
("curator", "Curator", "skill-usage review pass"),
|
||||
]
|
||||
|
||||
|
||||
@@ -2658,6 +2696,53 @@ def _model_flow_qwen_oauth(_config, current_model=""):
|
||||
print("No change.")
|
||||
|
||||
|
||||
def _model_flow_minimax_oauth(config, current_model="", args=None):
|
||||
"""MiniMax OAuth provider: ensure logged in, then pick model."""
|
||||
from hermes_cli.auth import (
|
||||
get_provider_auth_state,
|
||||
_prompt_model_selection,
|
||||
_save_model_choice,
|
||||
_update_config_for_provider,
|
||||
resolve_minimax_oauth_runtime_credentials,
|
||||
AuthError,
|
||||
format_auth_error,
|
||||
_login_minimax_oauth,
|
||||
PROVIDER_REGISTRY,
|
||||
)
|
||||
state = get_provider_auth_state("minimax-oauth")
|
||||
if not state or not state.get("access_token"):
|
||||
print("Not logged into MiniMax. Starting OAuth login...")
|
||||
print()
|
||||
try:
|
||||
mock_args = argparse.Namespace(
|
||||
region=getattr(args, "region", None) or "global",
|
||||
no_browser=bool(getattr(args, "no_browser", False)),
|
||||
timeout=getattr(args, "timeout", None) or 15.0,
|
||||
)
|
||||
_login_minimax_oauth(mock_args, PROVIDER_REGISTRY["minimax-oauth"])
|
||||
except SystemExit:
|
||||
print("Login cancelled or failed.")
|
||||
return
|
||||
except Exception as exc:
|
||||
print(f"Login failed: {exc}")
|
||||
return
|
||||
|
||||
try:
|
||||
creds = resolve_minimax_oauth_runtime_credentials()
|
||||
except AuthError as exc:
|
||||
print(format_auth_error(exc))
|
||||
return
|
||||
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
model_ids = _PROVIDER_MODELS.get("minimax-oauth", [])
|
||||
selected = _prompt_model_selection(model_ids, current_model)
|
||||
if not selected:
|
||||
return
|
||||
_save_model_choice(selected)
|
||||
_update_config_for_provider("minimax-oauth", creds["base_url"])
|
||||
print(f"\u2713 Using MiniMax model: {selected}")
|
||||
|
||||
|
||||
def _model_flow_google_gemini_cli(_config, current_model=""):
|
||||
"""Google Gemini OAuth (PKCE) via Cloud Code Assist — supports free AND paid tiers.
|
||||
|
||||
@@ -5251,8 +5336,8 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _warn_stale_dashboard_processes() -> None:
|
||||
"""Warn about running dashboard processes that still hold pre-update code.
|
||||
def _find_stale_dashboard_pids() -> list[int]:
|
||||
"""Return PIDs of ``hermes dashboard`` processes other than ourselves.
|
||||
|
||||
``hermes dashboard`` is a long-lived server process commonly started and
|
||||
forgotten. When ``hermes update`` replaces files on disk, the running
|
||||
@@ -5260,9 +5345,13 @@ def _warn_stale_dashboard_processes() -> None:
|
||||
disk is updated, causing a silent frontend/backend mismatch (e.g. new
|
||||
auth headers the old backend doesn't recognise → every API call 401s).
|
||||
|
||||
Unlike the gateway, the dashboard has no service manager (systemd /
|
||||
launchd), so we can only warn — we don't auto-kill user-managed
|
||||
background processes.
|
||||
The dashboard has no service manager (systemd / launchd), no PID file,
|
||||
and we can't know the original launch args — so the only sane action
|
||||
after an update is to kill the stale process and let the user restart
|
||||
it. This helper is just the detection step; see
|
||||
``_kill_stale_dashboard_processes`` for the kill.
|
||||
|
||||
Returns an empty list on any scan error (missing ps/wmic, timeout, etc.).
|
||||
"""
|
||||
patterns = [
|
||||
"hermes dashboard",
|
||||
@@ -5274,13 +5363,21 @@ def _warn_stale_dashboard_processes() -> None:
|
||||
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
# wmic may emit text in the system code page (for example cp936
|
||||
# on zh-CN systems), not UTF-8. In text mode, subprocess output
|
||||
# decoding depends on Python's configuration (locale-dependent
|
||||
# by default, or UTF-8 in UTF-8 mode). The important protection
|
||||
# here is errors="ignore": it prevents a reader-thread
|
||||
# UnicodeDecodeError from leaving result.stdout=None and turning
|
||||
# the later .split() into an AttributeError (#17049).
|
||||
result = subprocess.run(
|
||||
["wmic", "process", "get", "ProcessId,CommandLine",
|
||||
"/FORMAT:LIST"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
encoding="utf-8", errors="ignore",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return
|
||||
if result.returncode != 0 or result.stdout is None:
|
||||
return []
|
||||
current_cmd = ""
|
||||
for line in result.stdout.split("\n"):
|
||||
line = line.strip()
|
||||
@@ -5306,7 +5403,7 @@ def _warn_stale_dashboard_processes() -> None:
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.split("\n"):
|
||||
for line in getattr(result, "stdout", "").split("\n"):
|
||||
stripped = line.strip()
|
||||
if not stripped or "grep" in stripped:
|
||||
continue
|
||||
@@ -5322,20 +5419,112 @@ def _warn_stale_dashboard_processes() -> None:
|
||||
and pid != self_pid):
|
||||
dashboard_pids.append(pid)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
return
|
||||
return []
|
||||
|
||||
if not dashboard_pids:
|
||||
return dashboard_pids
|
||||
|
||||
|
||||
def _kill_stale_dashboard_processes(
|
||||
reason: str = "the running backend no longer matches the updated frontend",
|
||||
) -> None:
|
||||
"""Kill running ``hermes dashboard`` processes.
|
||||
|
||||
Called at the end of ``hermes update`` (default ``reason``) and also
|
||||
from ``hermes dashboard --stop`` (which overrides ``reason``). The
|
||||
dashboard has no service manager, so after a code update the running
|
||||
process is guaranteed to be serving stale Python against a
|
||||
freshly-updated JS bundle. Leaving it alive produces silent
|
||||
frontend/backend mismatches (new auth headers the old backend doesn't
|
||||
recognise → every API call 401s).
|
||||
|
||||
POSIX: SIGTERM, wait up to ~3s for graceful exit, SIGKILL any survivors.
|
||||
Windows: ``taskkill /PID <pid> /F`` since there's no clean SIGTERM
|
||||
equivalent for background console apps.
|
||||
|
||||
The dashboard isn't auto-restarted because we don't know the original
|
||||
launch args (--host, --port, --insecure, --tui, --no-open). The user
|
||||
restarts it manually; a hint is printed.
|
||||
"""
|
||||
pids = _find_stale_dashboard_pids()
|
||||
if not pids:
|
||||
return
|
||||
|
||||
print()
|
||||
print(f"⚠ {len(dashboard_pids)} dashboard process(es) still running "
|
||||
f"with the previous version:")
|
||||
for pid in dashboard_pids:
|
||||
print(f" PID {pid}")
|
||||
print(" The running backend may not match the updated frontend,")
|
||||
print(" causing silent auth failures or empty data.")
|
||||
print(" Restart them to pick up the changes:")
|
||||
print(" kill <pid> && hermes dashboard --port <port> ...")
|
||||
print(f"⟲ Stopping {len(pids)} dashboard process(es) ({reason})")
|
||||
|
||||
killed: list[int] = []
|
||||
failed: list[tuple[int, str]] = []
|
||||
|
||||
if sys.platform == "win32":
|
||||
for pid in pids:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["taskkill", "/PID", str(pid), "/F"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
killed.append(pid)
|
||||
else:
|
||||
failed.append((pid, (result.stderr or result.stdout or "").strip()))
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as e:
|
||||
failed.append((pid, str(e)))
|
||||
else:
|
||||
import signal as _signal
|
||||
import time as _time
|
||||
|
||||
# SIGTERM first — give each process a chance to shut down cleanly
|
||||
# (uvicorn closes its socket, flushes logs, etc.).
|
||||
for pid in pids:
|
||||
try:
|
||||
os.kill(pid, _signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
# Already gone — count as killed.
|
||||
killed.append(pid)
|
||||
except (PermissionError, OSError) as e:
|
||||
failed.append((pid, str(e)))
|
||||
|
||||
# Poll for exit up to ~3s total.
|
||||
deadline = _time.monotonic() + 3.0
|
||||
pending = [p for p in pids if p not in killed
|
||||
and p not in {f[0] for f in failed}]
|
||||
while pending and _time.monotonic() < deadline:
|
||||
_time.sleep(0.1)
|
||||
still_pending = []
|
||||
for pid in pending:
|
||||
try:
|
||||
os.kill(pid, 0) # probe
|
||||
except ProcessLookupError:
|
||||
killed.append(pid)
|
||||
except (PermissionError, OSError):
|
||||
# Can't probe — assume still there.
|
||||
still_pending.append(pid)
|
||||
else:
|
||||
still_pending.append(pid)
|
||||
pending = still_pending
|
||||
|
||||
# SIGKILL any survivors.
|
||||
for pid in pending:
|
||||
try:
|
||||
os.kill(pid, _signal.SIGKILL)
|
||||
killed.append(pid)
|
||||
except ProcessLookupError:
|
||||
killed.append(pid)
|
||||
except (PermissionError, OSError) as e:
|
||||
failed.append((pid, str(e)))
|
||||
|
||||
for pid in killed:
|
||||
print(f" ✓ stopped PID {pid}")
|
||||
for pid, reason in failed:
|
||||
print(f" ✗ failed to stop PID {pid}: {reason}")
|
||||
|
||||
if killed:
|
||||
print(" Restart the dashboard when you're ready:")
|
||||
print(" hermes dashboard --port <port>")
|
||||
|
||||
|
||||
# Back-compat alias: some tests and any external callers may import the old
|
||||
# warn-only name. The new behaviour (kill stale processes) replaces it.
|
||||
_warn_stale_dashboard_processes = _kill_stale_dashboard_processes
|
||||
|
||||
|
||||
def _update_via_zip(args):
|
||||
@@ -5472,7 +5661,7 @@ def _update_via_zip(args):
|
||||
|
||||
print()
|
||||
print("✓ Update complete!")
|
||||
_warn_stale_dashboard_processes()
|
||||
_kill_stale_dashboard_processes()
|
||||
|
||||
|
||||
def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[str]:
|
||||
@@ -7289,9 +7478,12 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
except Exception as e:
|
||||
logger.debug("Legacy unit check during update failed: %s", e)
|
||||
|
||||
# Warn about stale dashboard processes — the dashboard has no
|
||||
# service manager, so we can only tell the user to restart them.
|
||||
_warn_stale_dashboard_processes()
|
||||
# Kill stale dashboard processes — the dashboard has no service
|
||||
# manager, so leaving it alive after a code update produces a
|
||||
# silent frontend/backend mismatch. We can't auto-restart it
|
||||
# (no saved launch args) but we can stop it, and a hint is
|
||||
# printed for the user to re-launch.
|
||||
_kill_stale_dashboard_processes()
|
||||
|
||||
print()
|
||||
print("Tip: You can now select a provider and model:")
|
||||
@@ -7682,8 +7874,59 @@ def cmd_profile(args):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _report_dashboard_status() -> int:
|
||||
"""Print ``hermes dashboard`` PIDs and return the count.
|
||||
|
||||
Uses the same detection logic as ``_find_stale_dashboard_pids`` (the
|
||||
current process is excluded, but since ``hermes dashboard --status``
|
||||
runs in a short-lived CLI process that never matches the pattern,
|
||||
the exclusion is irrelevant here).
|
||||
"""
|
||||
pids = _find_stale_dashboard_pids()
|
||||
if not pids:
|
||||
print("No hermes dashboard processes running.")
|
||||
return 0
|
||||
|
||||
print(f"{len(pids)} hermes dashboard process(es) running:")
|
||||
for pid in pids:
|
||||
# Best-effort: show the full cmdline so users can tell profiles apart.
|
||||
cmdline = ""
|
||||
try:
|
||||
if sys.platform != "win32":
|
||||
cmdline_path = f"/proc/{pid}/cmdline"
|
||||
if os.path.exists(cmdline_path):
|
||||
with open(cmdline_path, "rb") as f:
|
||||
cmdline = f.read().replace(b"\x00", b" ").decode(
|
||||
"utf-8", errors="replace").strip()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if cmdline:
|
||||
print(f" PID {pid}: {cmdline}")
|
||||
else:
|
||||
print(f" PID {pid}")
|
||||
return len(pids)
|
||||
|
||||
|
||||
def cmd_dashboard(args):
|
||||
"""Start the web UI server."""
|
||||
"""Start the web UI server, or (with --stop/--status) manage running ones."""
|
||||
# --status: report running dashboards and exit, no deps needed.
|
||||
if getattr(args, "status", False):
|
||||
count = _report_dashboard_status()
|
||||
sys.exit(0 if count == 0 else 0) # status is informational, always 0
|
||||
|
||||
# --stop: kill any running dashboards and exit, no deps needed.
|
||||
if getattr(args, "stop", False):
|
||||
pids = _find_stale_dashboard_pids()
|
||||
if not pids:
|
||||
print("No hermes dashboard processes running.")
|
||||
sys.exit(0)
|
||||
# Reuse the same SIGTERM-grace-SIGKILL path used after `hermes update`.
|
||||
_kill_stale_dashboard_processes(reason="requested via --stop")
|
||||
# _kill_stale_dashboard_processes prints outcomes itself. Exit 0 if
|
||||
# we killed at least one, 1 if they were all unkillable.
|
||||
remaining = _find_stale_dashboard_pids()
|
||||
sys.exit(1 if remaining else 0)
|
||||
|
||||
try:
|
||||
import fastapi # noqa: F401
|
||||
import uvicorn # noqa: F401
|
||||
@@ -7750,302 +7993,9 @@ def cmd_logs(args):
|
||||
|
||||
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
|
||||
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
|
||||
hermes setup Run setup wizard
|
||||
hermes logout Clear stored authentication
|
||||
hermes auth add <provider> Add a pooled credential
|
||||
hermes auth list List pooled credentials
|
||||
hermes auth remove <p> <t> Remove pooled credential by index, id, or label
|
||||
hermes auth reset <provider> Clear exhaustion status for a provider
|
||||
hermes model Select default model
|
||||
hermes fallback [list] Show fallback provider chain
|
||||
hermes fallback add Add a fallback provider (same picker as `hermes model`)
|
||||
hermes fallback remove Remove a fallback provider from the chain
|
||||
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
|
||||
hermes -s hermes-agent-dev,github-auth
|
||||
hermes -w Start in isolated git worktree
|
||||
hermes gateway install Install gateway background service
|
||||
hermes sessions list List past sessions
|
||||
hermes sessions browse Interactive session picker
|
||||
hermes sessions rename ID T Rename/title a session
|
||||
hermes logs View agent.log (last 50 lines)
|
||||
hermes logs -f Follow agent.log in real time
|
||||
hermes logs errors View errors.log
|
||||
hermes logs --since 1h Lines from the last hour
|
||||
hermes debug share Upload debug report for support
|
||||
hermes update Update to latest version
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
For more help on a command:
|
||||
hermes <command> --help
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--version", "-V", action="store_true", help="Show version and exit"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-z",
|
||||
"--oneshot",
|
||||
metavar="PROMPT",
|
||||
default=None,
|
||||
help=(
|
||||
"One-shot mode: send a single prompt and print ONLY the final "
|
||||
"response text to stdout. No banner, no spinner, no tool "
|
||||
"previews, no session_id line. Tools, memory, rules, and "
|
||||
"AGENTS.md in the CWD are loaded as normal; approvals are "
|
||||
"auto-bypassed. Intended for scripts / pipes."
|
||||
),
|
||||
)
|
||||
# --model / --provider are accepted at the top level so they can pair
|
||||
# with -z without needing the `chat` subcommand. If neither -z nor a
|
||||
# subcommand consumes them, they fall through harmlessly as None.
|
||||
# Mirrors `hermes chat --model ... --provider ...` semantics.
|
||||
parser.add_argument(
|
||||
"-m",
|
||||
"--model",
|
||||
default=None,
|
||||
help=(
|
||||
"Model override for this invocation (e.g. anthropic/claude-sonnet-4.6). "
|
||||
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_MODEL env var."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--provider",
|
||||
default=None,
|
||||
help=(
|
||||
"Provider override for this invocation (e.g. openrouter, anthropic). "
|
||||
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_PROVIDER env var."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION",
|
||||
default=None,
|
||||
help="Resume a previous session by ID or title",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--continue",
|
||||
"-c",
|
||||
dest="continue_last",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=None,
|
||||
metavar="SESSION_NAME",
|
||||
help="Resume a session by name, or the most recent if no name given",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--worktree",
|
||||
"-w",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Run in an isolated git worktree (for parallel agents)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--accept-hooks",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Auto-approve any unseen shell hooks declared in config.yaml "
|
||||
"without a TTY prompt. Equivalent to HERMES_ACCEPT_HOOKS=1 or "
|
||||
"hooks_auto_accept: true in config.yaml. Use on CI / headless "
|
||||
"runs that can't prompt."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skills",
|
||||
"-s",
|
||||
action="append",
|
||||
default=None,
|
||||
help="Preload one or more skills for the session (repeat flag or comma-separate)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--yolo",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Bypass all dangerous command approval prompts (use at your own risk)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pass-session-id",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Include the session ID in the agent's system prompt",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ignore-user-config",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ignore-rules",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tui",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dev",
|
||||
dest="tui_dev",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="With --tui: run TypeScript sources via tsx (skip dist build)",
|
||||
)
|
||||
|
||||
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(
|
||||
"--image", help="Optional local image path to attach to a single query"
|
||||
)
|
||||
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"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-s",
|
||||
"--skills",
|
||||
action="append",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Preload one or more skills for the session (repeat flag or comma-separate)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--provider",
|
||||
# No `choices=` here: user-defined providers from config.yaml `providers:`
|
||||
# are also valid values, and runtime resolution (resolve_runtime_provider)
|
||||
# handles validation/error reporting consistently with the top-level
|
||||
# `--provider` flag.
|
||||
default=None,
|
||||
help="Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-v", "--verbose", action="store_true", help="Verbose output"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-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(
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION_ID",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Resume a previous session by ID (shown on exit)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--continue",
|
||||
"-c",
|
||||
dest="continue_last",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=argparse.SUPPRESS,
|
||||
metavar="SESSION_NAME",
|
||||
help="Resume a session by name, or the most recent if no name given",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--worktree",
|
||||
"-w",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Run in an isolated git worktree (for parallel agents on the same repo)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--accept-hooks",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help=(
|
||||
"Auto-approve any unseen shell hooks declared in config.yaml "
|
||||
"without a TTY prompt (see also HERMES_ACCEPT_HOOKS env var and "
|
||||
"hooks_auto_accept: in config.yaml)."
|
||||
),
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--checkpoints",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable filesystem checkpoints before destructive file operations (use /rollback to restore)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--max-turns",
|
||||
type=int,
|
||||
default=None,
|
||||
metavar="N",
|
||||
help="Maximum tool-calling iterations per conversation turn (default: 90, or agent.max_turns in config)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--yolo",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Bypass all dangerous command approval prompts (use at your own risk)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--pass-session-id",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Include the session ID in the agent's system prompt",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--ignore-user-config",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded). Useful for isolated CI runs, reproduction, and third-party integrations.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--ignore-rules",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills. Combine with --ignore-user-config for a fully isolated run.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--source",
|
||||
default=None,
|
||||
help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--tui",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--dev",
|
||||
dest="tui_dev",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="With --tui: run TypeScript sources via tsx (skip dist build)",
|
||||
)
|
||||
parser, subparsers, chat_parser = build_top_level_parser()
|
||||
chat_parser.set_defaults(func=cmd_chat)
|
||||
|
||||
# =========================================================================
|
||||
@@ -9715,15 +9665,8 @@ Examples:
|
||||
|
||||
# Launch hermes --resume <id> by replacing the current process
|
||||
print(f"Resuming session: {selected_id}")
|
||||
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],
|
||||
)
|
||||
from hermes_cli.relaunch import relaunch
|
||||
relaunch(["--resume", selected_id])
|
||||
return # won't reach here after execvp
|
||||
|
||||
elif action == "stats":
|
||||
@@ -10081,6 +10024,22 @@ Examples:
|
||||
"Alternatively set HERMES_DASHBOARD_TUI=1."
|
||||
),
|
||||
)
|
||||
# Lifecycle flags — mutually exclusive with each other and with the
|
||||
# start-a-server flags above (if both are passed, --stop / --status win
|
||||
# because they exit before the server is started). The dashboard has
|
||||
# no service manager and no PID file, so these scan the process table
|
||||
# for `hermes dashboard` cmdlines and SIGTERM them directly — the same
|
||||
# path `hermes update` uses to clean up stale dashboards.
|
||||
dashboard_parser.add_argument(
|
||||
"--stop",
|
||||
action="store_true",
|
||||
help="Stop all running hermes dashboard processes and exit",
|
||||
)
|
||||
dashboard_parser.add_argument(
|
||||
"--status",
|
||||
action="store_true",
|
||||
help="List running hermes dashboard processes and exit",
|
||||
)
|
||||
dashboard_parser.set_defaults(func=cmd_dashboard)
|
||||
|
||||
# =========================================================================
|
||||
@@ -10270,6 +10229,7 @@ Examples:
|
||||
args.oneshot,
|
||||
model=getattr(args, "model", None),
|
||||
provider=getattr(args, "provider", None),
|
||||
toolsets=getattr(args, "toolsets", None),
|
||||
))
|
||||
|
||||
# Handle top-level --resume / --continue as shortcut to chat
|
||||
|
||||
@@ -16,6 +16,7 @@ import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from hermes_cli.config import (
|
||||
cfg_get,
|
||||
load_config,
|
||||
save_config,
|
||||
get_env_value,
|
||||
@@ -716,7 +717,7 @@ def cmd_mcp_configure(args):
|
||||
|
||||
# Update config
|
||||
config = load_config()
|
||||
server_entry = config.get("mcp_servers", {}).get(name, {})
|
||||
server_entry = cfg_get(config, "mcp_servers", name, default={})
|
||||
|
||||
if len(chosen) == total:
|
||||
# All selected → remove include/exclude (register all)
|
||||
|
||||
@@ -96,6 +96,7 @@ _MATCHING_PREFIX_STRIP_PROVIDERS: frozenset[str] = frozenset({
|
||||
"kimi-coding",
|
||||
"kimi-coding-cn",
|
||||
"minimax",
|
||||
"minimax-oauth",
|
||||
"minimax-cn",
|
||||
"alibaba",
|
||||
"qwen-oauth",
|
||||
|
||||
@@ -1018,6 +1018,37 @@ def list_authenticated_providers(
|
||||
results: List[dict] = []
|
||||
seen_slugs: set = set() # lowercase-normalized to catch case variants (#9545)
|
||||
seen_mdev_ids: set = set() # prevent duplicate entries for aliases (e.g. kimi-coding + kimi-coding-cn)
|
||||
# Effective base URLs of every built-in row we emit (normalized lower+rstrip).
|
||||
# Section 4 uses this to hide ``custom_providers`` entries that point at the
|
||||
# same endpoint as a built-in (e.g. a user-defined "my-dashscope" on
|
||||
# https://coding-intl.dashscope.aliyuncs.com/v1 collides with the built-in
|
||||
# alibaba-coding-plan row when DASHSCOPE_API_KEY is present). Fixes #16970.
|
||||
_builtin_endpoints: set = set()
|
||||
|
||||
def _norm_url(url: str) -> str:
|
||||
return str(url or "").strip().rstrip("/").lower()
|
||||
|
||||
def _record_builtin_endpoint(slug: str) -> None:
|
||||
"""Record the effective base URL for a built-in provider row.
|
||||
|
||||
Prefers the live env-override (e.g. DASHSCOPE_BASE_URL) over the
|
||||
static inference_base_url so the dedup matches what a user typing
|
||||
that URL into custom_providers would actually hit."""
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY as _reg
|
||||
except Exception:
|
||||
return
|
||||
pcfg = _reg.get(slug)
|
||||
if not pcfg:
|
||||
return
|
||||
url = ""
|
||||
if getattr(pcfg, "base_url_env_var", ""):
|
||||
url = os.environ.get(pcfg.base_url_env_var, "") or ""
|
||||
if not url:
|
||||
url = getattr(pcfg, "inference_base_url", "") or ""
|
||||
normed = _norm_url(url)
|
||||
if normed:
|
||||
_builtin_endpoints.add(normed)
|
||||
|
||||
data = fetch_models_dev()
|
||||
|
||||
@@ -1124,6 +1155,7 @@ def list_authenticated_providers(
|
||||
})
|
||||
seen_slugs.add(slug.lower())
|
||||
seen_mdev_ids.add(mdev_id)
|
||||
_record_builtin_endpoint(slug)
|
||||
|
||||
# --- 2. Check Hermes-only providers (nous, openai-codex, copilot, opencode-go) ---
|
||||
from hermes_cli.providers import HERMES_OVERLAYS
|
||||
@@ -1238,6 +1270,7 @@ def list_authenticated_providers(
|
||||
})
|
||||
seen_slugs.add(pid.lower())
|
||||
seen_slugs.add(hermes_slug.lower())
|
||||
_record_builtin_endpoint(hermes_slug)
|
||||
|
||||
# --- 2b. Cross-check canonical provider list ---
|
||||
# Catches providers that are in CANONICAL_PROVIDERS but weren't found
|
||||
@@ -1317,6 +1350,7 @@ def list_authenticated_providers(
|
||||
"source": "canonical",
|
||||
})
|
||||
seen_slugs.add(_cp.slug.lower())
|
||||
_record_builtin_endpoint(_cp.slug)
|
||||
|
||||
# --- 3. User-defined endpoints from config ---
|
||||
# Track (name, base_url) of what section 3 emits so section 4 can skip
|
||||
@@ -1526,6 +1560,15 @@ def list_authenticated_providers(
|
||||
)
|
||||
if _pair_key[0] and _pair_key[1] and _pair_key in _section3_emitted_pairs:
|
||||
continue
|
||||
# Skip if a built-in row (sections 1/2/2b) already represents this
|
||||
# endpoint. Fixes #16970: a user-defined "my-dashscope" pointing at
|
||||
# https://coding-intl.dashscope.aliyuncs.com/v1 duplicates the
|
||||
# built-in alibaba-coding-plan row whenever DASHSCOPE_API_KEY is
|
||||
# set. The built-in row carries the curated model list, correct
|
||||
# auth wiring, and canonical slug — keep it and hide the shadow.
|
||||
_grp_url_norm = _pair_key[1]
|
||||
if _grp_url_norm and _grp_url_norm in _builtin_endpoints:
|
||||
continue
|
||||
results.append({
|
||||
"slug": slug,
|
||||
"name": grp["name"],
|
||||
|
||||
@@ -288,6 +288,10 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"MiniMax-M2.1",
|
||||
"MiniMax-M2",
|
||||
],
|
||||
"minimax-oauth": [
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.7-highspeed",
|
||||
],
|
||||
"minimax-cn": [
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.5",
|
||||
@@ -788,6 +792,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
||||
ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "Kimi / Moonshot China (Moonshot CN direct API)"),
|
||||
ProviderEntry("stepfun", "StepFun Step Plan", "StepFun Step Plan (agent/coding models via Step Plan API)"),
|
||||
ProviderEntry("minimax", "MiniMax", "MiniMax (global direct API)"),
|
||||
ProviderEntry("minimax-oauth", "MiniMax (OAuth)", "MiniMax via OAuth browser login (Coding Plan, minimax.io)"),
|
||||
ProviderEntry("minimax-cn", "MiniMax (China)", "MiniMax China (domestic direct API)"),
|
||||
ProviderEntry("alibaba", "Alibaba Cloud (DashScope)","Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"),
|
||||
ProviderEntry("ollama-cloud", "Ollama Cloud", "Ollama Cloud (cloud-hosted open models — ollama.com)"),
|
||||
@@ -831,6 +836,9 @@ _PROVIDER_ALIASES = {
|
||||
"gmicloud": "gmi",
|
||||
"minimax-china": "minimax-cn",
|
||||
"minimax_cn": "minimax-cn",
|
||||
"minimax-portal": "minimax-oauth",
|
||||
"minimax-global": "minimax-oauth",
|
||||
"minimax_oauth": "minimax-oauth",
|
||||
"claude": "anthropic",
|
||||
"claude-code": "anthropic",
|
||||
"deep-seek": "deepseek",
|
||||
@@ -2026,28 +2034,56 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
|
||||
return None
|
||||
|
||||
headers: dict[str, str] = {"anthropic-version": "2023-06-01"}
|
||||
if _is_oauth_token(token):
|
||||
is_oauth = _is_oauth_token(token)
|
||||
if is_oauth:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
from agent.anthropic_adapter import _COMMON_BETAS, _OAUTH_ONLY_BETAS
|
||||
from agent.anthropic_adapter import _COMMON_BETAS, _OAUTH_ONLY_BETAS, _CONTEXT_1M_BETA
|
||||
headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
|
||||
else:
|
||||
headers["x-api-key"] = token
|
||||
|
||||
req = urllib.request.Request(
|
||||
"https://api.anthropic.com/v1/models",
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
def _do_request(h: dict[str, str]):
|
||||
req = urllib.request.Request(
|
||||
"https://api.anthropic.com/v1/models",
|
||||
headers=h,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
models = [m["id"] for m in data.get("data", []) if m.get("id")]
|
||||
# Sort: latest/largest first (opus > sonnet > haiku, higher version first)
|
||||
return sorted(models, key=lambda m: (
|
||||
"opus" not in m, # opus first
|
||||
"sonnet" not in m, # then sonnet
|
||||
"haiku" not in m, # then haiku
|
||||
m, # alphabetical within tier
|
||||
))
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
try:
|
||||
try:
|
||||
data = _do_request(headers)
|
||||
except urllib.error.HTTPError as http_err:
|
||||
# Reactive recovery for OAuth subscriptions that reject the 1M
|
||||
# context beta with 400 "long context beta is not yet available
|
||||
# for this subscription". Retry once without the beta; re-raise
|
||||
# anything else so the outer except logs it.
|
||||
if (
|
||||
is_oauth
|
||||
and http_err.code == 400
|
||||
):
|
||||
try:
|
||||
body_text = http_err.read().decode(errors="ignore").lower()
|
||||
except Exception:
|
||||
body_text = ""
|
||||
if "long context beta" in body_text and "not yet available" in body_text:
|
||||
headers["anthropic-beta"] = ",".join(
|
||||
[b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]
|
||||
+ list(_OAUTH_ONLY_BETAS)
|
||||
)
|
||||
data = _do_request(headers)
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
raise
|
||||
models = [m["id"] for m in data.get("data", []) if m.get("id")]
|
||||
# Sort: latest/largest first (opus > sonnet > haiku, higher version first)
|
||||
return sorted(models, key=lambda m: (
|
||||
"opus" not in m, # opus first
|
||||
"sonnet" not in m, # then sonnet
|
||||
"haiku" not in m, # then haiku
|
||||
m, # alphabetical within tier
|
||||
))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).debug("Failed to fetch Anthropic models: %s", e)
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
Bypasses cli.py entirely. No banner, no spinner, no session_id line,
|
||||
no stderr chatter. Just the agent's final text to stdout.
|
||||
|
||||
Toolsets = whatever the user has configured for "cli" in `hermes tools`.
|
||||
Toolsets = explicit --toolsets when provided, otherwise whatever the user has
|
||||
configured for "cli" in `hermes tools`.
|
||||
Rules / memory / AGENTS.md / preloaded skills = same as a normal chat turn.
|
||||
Approvals = auto-bypassed (HERMES_YOLO_MODE=1 is set for the call).
|
||||
Working directory = the user's CWD (AGENTS.md etc. resolve from there as usual).
|
||||
@@ -28,10 +29,103 @@ from contextlib import redirect_stderr, redirect_stdout
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _normalize_toolsets(toolsets: object = None) -> list[str] | None:
|
||||
if not toolsets:
|
||||
return None
|
||||
|
||||
raw_items = [toolsets] if isinstance(toolsets, str) else toolsets
|
||||
if not isinstance(raw_items, (list, tuple)):
|
||||
raw_items = [raw_items]
|
||||
|
||||
normalized: list[str] = []
|
||||
for item in raw_items:
|
||||
if isinstance(item, str):
|
||||
normalized.extend(part.strip() for part in item.split(","))
|
||||
else:
|
||||
normalized.append(str(item).strip())
|
||||
|
||||
return [item for item in normalized if item] or None
|
||||
|
||||
|
||||
def _validate_explicit_toolsets(toolsets: object = None) -> tuple[list[str] | None, str | None]:
|
||||
normalized = _normalize_toolsets(toolsets)
|
||||
if normalized is None:
|
||||
return None, None
|
||||
|
||||
try:
|
||||
from toolsets import validate_toolset
|
||||
except Exception as exc:
|
||||
return None, f"hermes -z: failed to validate --toolsets: {exc}\n"
|
||||
|
||||
built_in = [name for name in normalized if validate_toolset(name)]
|
||||
unresolved = [name for name in normalized if name not in built_in]
|
||||
|
||||
if unresolved:
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
|
||||
discover_plugins()
|
||||
plugin_valid = [name for name in unresolved if validate_toolset(name)]
|
||||
except Exception:
|
||||
plugin_valid = []
|
||||
|
||||
if plugin_valid:
|
||||
built_in.extend(plugin_valid)
|
||||
unresolved = [name for name in unresolved if name not in plugin_valid]
|
||||
|
||||
if any(name in {"all", "*"} for name in built_in):
|
||||
ignored = [name for name in normalized if name not in {"all", "*"}]
|
||||
if ignored:
|
||||
sys.stderr.write(
|
||||
"hermes -z: --toolsets all enables every toolset; "
|
||||
f"ignoring additional entries: {', '.join(ignored)}\n"
|
||||
)
|
||||
return None, None
|
||||
|
||||
mcp_names: set[str] = set()
|
||||
mcp_disabled: set[str] = set()
|
||||
if unresolved:
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
from hermes_cli.tools_config import _parse_enabled_flag
|
||||
|
||||
cfg = read_raw_config()
|
||||
mcp_servers = cfg.get("mcp_servers") if isinstance(cfg.get("mcp_servers"), dict) else {}
|
||||
for name, server_cfg in mcp_servers.items():
|
||||
if not isinstance(server_cfg, dict):
|
||||
continue
|
||||
if _parse_enabled_flag(server_cfg.get("enabled", True), default=True):
|
||||
mcp_names.add(str(name))
|
||||
else:
|
||||
mcp_disabled.add(str(name))
|
||||
except Exception:
|
||||
mcp_names = set()
|
||||
mcp_disabled = set()
|
||||
|
||||
mcp_valid = [name for name in unresolved if name in mcp_names]
|
||||
disabled = [name for name in unresolved if name in mcp_disabled]
|
||||
unknown = [name for name in unresolved if name not in mcp_names and name not in mcp_disabled]
|
||||
valid = built_in + mcp_valid
|
||||
|
||||
if unknown:
|
||||
sys.stderr.write(f"hermes -z: ignoring unknown --toolsets entries: {', '.join(unknown)}\n")
|
||||
if disabled:
|
||||
sys.stderr.write(
|
||||
"hermes -z: ignoring disabled MCP servers (set enabled: true in config.yaml to use): "
|
||||
f"{', '.join(disabled)}\n"
|
||||
)
|
||||
|
||||
if not valid:
|
||||
return None, "hermes -z: --toolsets did not contain any valid toolsets.\n"
|
||||
|
||||
return valid, None
|
||||
|
||||
|
||||
def run_oneshot(
|
||||
prompt: str,
|
||||
model: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
toolsets: object = None,
|
||||
) -> int:
|
||||
"""Execute a single prompt and print only the final content block.
|
||||
|
||||
@@ -42,6 +136,7 @@ def run_oneshot(
|
||||
provider: Optional provider override. Falls back to
|
||||
HERMES_INFERENCE_PROVIDER env var, then config.yaml's model.provider,
|
||||
then "auto".
|
||||
toolsets: Optional comma-separated string or iterable of toolsets.
|
||||
|
||||
Returns the exit code. Caller should sys.exit() with the return.
|
||||
"""
|
||||
@@ -65,6 +160,12 @@ def run_oneshot(
|
||||
)
|
||||
return 2
|
||||
|
||||
explicit_toolsets, toolsets_error = _validate_explicit_toolsets(toolsets)
|
||||
if toolsets_error:
|
||||
sys.stderr.write(toolsets_error)
|
||||
return 2
|
||||
use_config_toolsets = _normalize_toolsets(toolsets) is None
|
||||
|
||||
# Auto-approve any shell / tool approvals. Non-interactive by
|
||||
# definition — a prompt would hang forever.
|
||||
os.environ["HERMES_YOLO_MODE"] = "1"
|
||||
@@ -77,7 +178,13 @@ def run_oneshot(
|
||||
|
||||
try:
|
||||
with redirect_stdout(devnull), redirect_stderr(devnull):
|
||||
response = _run_agent(prompt, model=model, provider=provider)
|
||||
response = _run_agent(
|
||||
prompt,
|
||||
model=model,
|
||||
provider=provider,
|
||||
toolsets=explicit_toolsets,
|
||||
use_config_toolsets=use_config_toolsets,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
devnull.close()
|
||||
@@ -96,6 +203,8 @@ def _run_agent(
|
||||
prompt: str,
|
||||
model: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
toolsets: object = None,
|
||||
use_config_toolsets: bool = True,
|
||||
) -> str:
|
||||
"""Build an AIAgent exactly like a normal CLI chat turn would, then
|
||||
run a single conversation. Returns the final response string."""
|
||||
@@ -168,9 +277,12 @@ def _run_agent(
|
||||
explicit_base_url=explicit_base_url_from_alias,
|
||||
)
|
||||
|
||||
# Pull in whatever toolsets the user has enabled for "cli".
|
||||
# sorted() gives stable ordering; set→list for AIAgent's signature.
|
||||
toolsets_list = sorted(_get_platform_tools(cfg, "cli"))
|
||||
# Pull in explicit toolsets when provided; otherwise use whatever the user
|
||||
# has enabled for "cli". sorted() gives stable ordering for config-derived
|
||||
# sets; explicit values preserve user order.
|
||||
toolsets_list = _normalize_toolsets(toolsets)
|
||||
if toolsets_list is None and use_config_toolsets:
|
||||
toolsets_list = sorted(_get_platform_tools(cfg, "cli"))
|
||||
|
||||
agent = AIAgent(
|
||||
api_key=runtime.get("api_key"),
|
||||
|
||||
@@ -44,6 +44,40 @@ PLATFORMS: OrderedDict[str, PlatformInfo] = OrderedDict([
|
||||
|
||||
|
||||
def platform_label(key: str, default: str = "") -> str:
|
||||
"""Return the display label for a platform key, or *default*."""
|
||||
"""Return the display label for a platform key, or *default*.
|
||||
|
||||
Checks the static PLATFORMS dict first, then the plugin platform
|
||||
registry for dynamically registered platforms.
|
||||
"""
|
||||
info = PLATFORMS.get(key)
|
||||
return info.label if info is not None else default
|
||||
if info is not None:
|
||||
return info.label
|
||||
# Check plugin registry
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
entry = platform_registry.get(key)
|
||||
if entry:
|
||||
return f"{entry.emoji} {entry.label}" if entry.emoji else entry.label
|
||||
except Exception:
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def get_all_platforms() -> "OrderedDict[str, PlatformInfo]":
|
||||
"""Return PLATFORMS merged with any plugin-registered platforms.
|
||||
|
||||
Plugin platforms are appended after builtins. This is the function
|
||||
that tools_config and skills_config should use for platform menus.
|
||||
"""
|
||||
merged = OrderedDict(PLATFORMS)
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
for entry in platform_registry.plugin_entries():
|
||||
if entry.name not in merged:
|
||||
merged[entry.name] = PlatformInfo(
|
||||
label=f"{entry.emoji} {entry.label}" if entry.emoji else entry.label,
|
||||
default_toolset=f"hermes-{entry.name}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return merged
|
||||
|
||||
@@ -37,6 +37,7 @@ import importlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass, field
|
||||
@@ -45,6 +46,20 @@ from typing import Any, Callable, Dict, List, Optional, Set, Union
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from utils import env_var_enabled
|
||||
from hermes_cli.config import cfg_get
|
||||
|
||||
|
||||
def get_bundled_plugins_dir() -> Path:
|
||||
"""Locate the bundled ``plugins/`` directory.
|
||||
|
||||
Honours ``HERMES_BUNDLED_PLUGINS`` (set by the Nix wrapper / packaged
|
||||
installs) so read-only store paths are consulted first. Falls back to
|
||||
the in-repo path used during development.
|
||||
"""
|
||||
env_override = os.getenv("HERMES_BUNDLED_PLUGINS")
|
||||
if env_override:
|
||||
return Path(env_override)
|
||||
return Path(__file__).resolve().parent.parent / "plugins"
|
||||
|
||||
try:
|
||||
import yaml
|
||||
@@ -115,7 +130,7 @@ def _get_disabled_plugins() -> set:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
disabled = config.get("plugins", {}).get("disabled", [])
|
||||
disabled = cfg_get(config, "plugins", "disabled", default=[])
|
||||
return set(disabled) if isinstance(disabled, list) else set()
|
||||
except Exception:
|
||||
return set()
|
||||
@@ -155,7 +170,7 @@ def _get_enabled_plugins() -> Optional[set]:
|
||||
# Data classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive"}
|
||||
_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform"}
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -181,6 +196,11 @@ class PluginManifest:
|
||||
# Selection via ``<category>.provider`` config key; the
|
||||
# category's own discovery system handles loading and the
|
||||
# general scanner skips these.
|
||||
# ``platform``: gateway messaging platform adapter (e.g. IRC). Bundled
|
||||
# platform plugins auto-load so every shipped platform is
|
||||
# available out of the box; user-installed platform plugins
|
||||
# in ~/.hermes/plugins/ still gated by ``plugins.enabled``
|
||||
# (untrusted code).
|
||||
kind: str = "standalone"
|
||||
# Registry key — path-derived, used by ``plugins.enabled``/``disabled``
|
||||
# lookups and by ``hermes plugins list``. For a flat plugin at
|
||||
@@ -444,6 +464,62 @@ class PluginContext:
|
||||
self.manifest.name, provider.name,
|
||||
)
|
||||
|
||||
# -- platform adapter registration ---------------------------------------
|
||||
|
||||
def register_platform(
|
||||
self,
|
||||
name: str,
|
||||
label: str,
|
||||
adapter_factory: Callable,
|
||||
check_fn: Callable,
|
||||
validate_config: Callable | None = None,
|
||||
required_env: list | None = None,
|
||||
install_hint: str = "",
|
||||
**entry_kwargs: Any,
|
||||
) -> None:
|
||||
"""Register a gateway platform adapter.
|
||||
|
||||
The adapter_factory receives a ``PlatformConfig`` and returns a
|
||||
``BasePlatformAdapter`` subclass instance. The gateway calls
|
||||
``check_fn()`` before instantiation to verify dependencies.
|
||||
|
||||
Extra keyword arguments are forwarded to ``PlatformEntry`` (e.g.
|
||||
``setup_fn``, ``emoji``, ``allowed_users_env``, ``platform_hint``).
|
||||
Unknown keys raise TypeError from the dataclass constructor.
|
||||
|
||||
Example::
|
||||
|
||||
ctx.register_platform(
|
||||
name="irc",
|
||||
label="IRC",
|
||||
adapter_factory=lambda cfg: IRCAdapter(cfg),
|
||||
check_fn=lambda: True,
|
||||
emoji="💬",
|
||||
setup_fn=irc_interactive_setup,
|
||||
)
|
||||
"""
|
||||
from gateway.platform_registry import platform_registry, PlatformEntry
|
||||
|
||||
entry_kwargs.setdefault("plugin_name", self.manifest.name)
|
||||
entry = PlatformEntry(
|
||||
name=name,
|
||||
label=label,
|
||||
adapter_factory=adapter_factory,
|
||||
check_fn=check_fn,
|
||||
validate_config=validate_config,
|
||||
required_env=required_env or [],
|
||||
install_hint=install_hint,
|
||||
source="plugin",
|
||||
**entry_kwargs,
|
||||
)
|
||||
platform_registry.register(entry)
|
||||
self._manager._plugin_platform_names.add(name)
|
||||
logger.debug(
|
||||
"Plugin %s registered platform: %s",
|
||||
self.manifest.name,
|
||||
name,
|
||||
)
|
||||
|
||||
# -- hook registration --------------------------------------------------
|
||||
|
||||
def register_hook(self, hook_name: str, callback: Callable) -> None:
|
||||
@@ -522,6 +598,7 @@ class PluginManager:
|
||||
self._plugins: Dict[str, LoadedPlugin] = {}
|
||||
self._hooks: Dict[str, List[Callable]] = {}
|
||||
self._plugin_tool_names: Set[str] = set()
|
||||
self._plugin_platform_names: Set[str] = set()
|
||||
self._cli_commands: Dict[str, dict] = {}
|
||||
self._context_engine = None # Set by a plugin via register_context_engine()
|
||||
self._plugin_commands: Dict[str, dict] = {} # Slash commands registered by plugins
|
||||
@@ -564,16 +641,19 @@ class PluginManager:
|
||||
# - category: ``plugins/image_gen/openai/plugin.yaml`` (backend)
|
||||
#
|
||||
# ``memory/`` and ``context_engine/`` are skipped at the top level —
|
||||
# they have their own discovery systems. Porting those to the
|
||||
# category-namespace ``kind: exclusive`` model is a future PR.
|
||||
repo_plugins = Path(__file__).resolve().parent.parent / "plugins"
|
||||
# they have their own discovery systems. ``platforms/`` is a category
|
||||
# holding platform adapters (scanned one level deeper below).
|
||||
repo_plugins = get_bundled_plugins_dir()
|
||||
manifests.extend(
|
||||
self._scan_directory(
|
||||
repo_plugins,
|
||||
source="bundled",
|
||||
skip_names={"memory", "context_engine"},
|
||||
skip_names={"memory", "context_engine", "platforms"},
|
||||
)
|
||||
)
|
||||
manifests.extend(
|
||||
self._scan_directory(repo_plugins / "platforms", source="bundled")
|
||||
)
|
||||
|
||||
# 2. User plugins (~/.hermes/plugins/)
|
||||
user_dir = get_hermes_home() / "plugins"
|
||||
@@ -630,7 +710,11 @@ class PluginManager:
|
||||
# just work. Selection among them (e.g. which image_gen backend
|
||||
# services calls) is driven by ``<category>.provider`` config,
|
||||
# enforced by the tool wrapper.
|
||||
if manifest.kind == "backend" and manifest.source == "bundled":
|
||||
#
|
||||
# Bundled platform plugins (gateway adapters like IRC) auto-load
|
||||
# for the same reason: every platform Hermes ships must be
|
||||
# available out of the box without the user having to opt in.
|
||||
if manifest.source == "bundled" and manifest.kind in ("backend", "platform"):
|
||||
self._load_plugin(manifest)
|
||||
continue
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.config import cfg_get
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -519,7 +520,7 @@ def _get_disabled_set() -> set:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
disabled = config.get("plugins", {}).get("disabled", [])
|
||||
disabled = cfg_get(config, "plugins", "disabled", default=[])
|
||||
return set(disabled) if isinstance(disabled, list) else set()
|
||||
except Exception:
|
||||
return set()
|
||||
@@ -629,10 +630,9 @@ def _plugin_exists(name: str) -> bool:
|
||||
manifest = _read_manifest(child)
|
||||
if manifest.get("name") == name:
|
||||
return True
|
||||
# Bundled: <repo>/plugins/<name>/
|
||||
from pathlib import Path as _P
|
||||
import hermes_cli
|
||||
repo_plugins = _P(hermes_cli.__file__).resolve().parent.parent / "plugins"
|
||||
# Bundled: <repo>/plugins/<name>/ (or HERMES_BUNDLED_PLUGINS on Nix).
|
||||
from hermes_cli.plugins import get_bundled_plugins_dir
|
||||
repo_plugins = get_bundled_plugins_dir()
|
||||
if repo_plugins.is_dir():
|
||||
candidate = repo_plugins / name
|
||||
if candidate.is_dir() and (
|
||||
@@ -659,8 +659,8 @@ def _discover_all_plugins() -> list:
|
||||
seen: dict = {} # name -> (name, version, description, source, path)
|
||||
|
||||
# Bundled (<repo>/plugins/<name>/), excluding memory/ and context_engine/
|
||||
import hermes_cli
|
||||
repo_plugins = Path(hermes_cli.__file__).resolve().parent.parent / "plugins"
|
||||
from hermes_cli.plugins import get_bundled_plugins_dir
|
||||
repo_plugins = get_bundled_plugins_dir()
|
||||
for base, source in ((repo_plugins, "bundled"), (_plugins_dir(), "user")):
|
||||
if not base.is_dir():
|
||||
continue
|
||||
@@ -763,7 +763,7 @@ def _get_current_memory_provider() -> str:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
return config.get("memory", {}).get("provider", "") or ""
|
||||
return cfg_get(config, "memory", "provider", default="") or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
@@ -773,7 +773,7 @@ def _get_current_context_engine() -> str:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
return config.get("context", {}).get("engine", "compressor") or "compressor"
|
||||
return cfg_get(config, "context", "engine", default="compressor") or "compressor"
|
||||
except Exception:
|
||||
return "compressor"
|
||||
|
||||
|
||||
@@ -71,6 +71,29 @@ _CLONE_ALL_STRIP = [
|
||||
"processes.json",
|
||||
]
|
||||
|
||||
|
||||
def _clone_all_copytree_ignore(source_dir: Path):
|
||||
"""Ignore ``profiles/`` at the root of *source_dir* only.
|
||||
|
||||
``~/.hermes`` contains ``profiles/<name>/`` for sibling named profiles.
|
||||
``shutil.copytree`` would otherwise duplicate that entire tree inside the
|
||||
new profile (recursive ``.../profiles/.../profiles/...``). Export already
|
||||
excludes ``profiles`` via ``_DEFAULT_EXPORT_EXCLUDE_ROOT`` — match that
|
||||
behavior for ``--clone-all``.
|
||||
"""
|
||||
source_resolved = source_dir.resolve()
|
||||
|
||||
def _ignore(directory: str, names: List[str]) -> List[str]:
|
||||
try:
|
||||
if Path(directory).resolve() == source_resolved:
|
||||
return [n for n in names if n == "profiles"]
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return []
|
||||
|
||||
return _ignore
|
||||
|
||||
|
||||
# Directories/files to exclude when exporting the default (~/.hermes) profile.
|
||||
# The default profile contains infrastructure (repo checkout, worktrees, DBs,
|
||||
# caches, binaries) that named profiles don't have. We exclude those so the
|
||||
@@ -425,8 +448,12 @@ def create_profile(
|
||||
)
|
||||
|
||||
if clone_all and source_dir:
|
||||
# Full copy of source profile
|
||||
shutil.copytree(source_dir, profile_dir)
|
||||
# Full copy of source profile (exclude sibling ~/.hermes/profiles/)
|
||||
shutil.copytree(
|
||||
source_dir,
|
||||
profile_dir,
|
||||
ignore=_clone_all_copytree_ignore(source_dir),
|
||||
)
|
||||
# Strip runtime files
|
||||
for stale in _CLONE_ALL_STRIP:
|
||||
(profile_dir / stale).unlink(missing_ok=True)
|
||||
|
||||
@@ -111,6 +111,11 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
||||
transport="anthropic_messages",
|
||||
base_url_env_var="MINIMAX_BASE_URL",
|
||||
),
|
||||
"minimax-oauth": HermesOverlay(
|
||||
transport="anthropic_messages",
|
||||
auth_type="oauth_external",
|
||||
base_url_override="https://api.minimax.io/anthropic",
|
||||
),
|
||||
"minimax-cn": HermesOverlay(
|
||||
transport="anthropic_messages",
|
||||
base_url_env_var="MINIMAX_CN_BASE_URL",
|
||||
|
||||
149
hermes_cli/relaunch.py
Normal file
149
hermes_cli/relaunch.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Unified self-relaunch for Hermes CLI.
|
||||
|
||||
Preserves critical flags (--tui, --dev, --profile, --model, etc.) across
|
||||
process replacement so that ``hermes sessions browse`` or post-setup relaunch
|
||||
doesn't silently drop the user's UI mode or other preferences.
|
||||
|
||||
Also works when ``hermes`` is not on PATH (e.g. ``nix run`` or ``python -m``).
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from hermes_cli._parser import (
|
||||
PRE_ARGPARSE_INHERITED_FLAGS,
|
||||
build_top_level_parser,
|
||||
)
|
||||
|
||||
|
||||
def _build_inherited_flag_table() -> list[tuple[str, bool]]:
|
||||
"""Build the ``(option_string, takes_value)`` table of flags that must
|
||||
survive a self-relaunch, by introspecting the real parser used by
|
||||
``hermes`` itself.
|
||||
|
||||
A flag participates if its argparse Action carries
|
||||
``inherit_on_relaunch = True`` — set by ``_parser._inherited_flag``.
|
||||
"""
|
||||
parser, _subparsers, chat_parser = build_top_level_parser()
|
||||
|
||||
table: list[tuple[str, bool]] = []
|
||||
seen: set[tuple[str, bool]] = set()
|
||||
for p in (parser, chat_parser):
|
||||
for action in p._actions:
|
||||
if not action.option_strings:
|
||||
continue # positional / no flag form
|
||||
if not getattr(action, "inherit_on_relaunch", False):
|
||||
continue
|
||||
takes_value = action.nargs != 0 # store_true/false set nargs=0
|
||||
for opt in action.option_strings:
|
||||
key = (opt, takes_value)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
table.append(key)
|
||||
|
||||
table.extend(PRE_ARGPARSE_INHERITED_FLAGS)
|
||||
return table
|
||||
|
||||
|
||||
_INHERITED_FLAGS_TABLE = _build_inherited_flag_table()
|
||||
|
||||
|
||||
def _extract_inherited_flags(argv: Sequence[str]) -> list[str]:
|
||||
"""Pull out flags that should carry over into a self-relaunched hermes."""
|
||||
flags: list[str] = []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
arg = argv[i]
|
||||
if "=" in arg:
|
||||
key = arg.split("=", 1)[0]
|
||||
for flag, _ in _INHERITED_FLAGS_TABLE:
|
||||
if key == flag:
|
||||
flags.append(arg)
|
||||
break
|
||||
i += 1
|
||||
continue
|
||||
|
||||
for flag, takes_value in _INHERITED_FLAGS_TABLE:
|
||||
if arg == flag:
|
||||
flags.append(arg)
|
||||
if takes_value and i + 1 < len(argv) and not argv[i + 1].startswith("-"):
|
||||
flags.append(argv[i + 1])
|
||||
i += 1
|
||||
break
|
||||
i += 1
|
||||
return flags
|
||||
|
||||
|
||||
def resolve_hermes_bin() -> Optional[str]:
|
||||
"""Find the hermes entry point.
|
||||
|
||||
Priority:
|
||||
1. ``sys.argv[0]`` if it resolves to a real executable.
|
||||
2. ``shutil.which("hermes")`` on PATH.
|
||||
3. ``None`` → caller should fall back to ``python -m hermes_cli.main``.
|
||||
"""
|
||||
argv0 = sys.argv[0]
|
||||
|
||||
# Absolute path to an executable (covers nix store, venv wrappers, etc.)
|
||||
if os.path.isabs(argv0) and os.path.isfile(argv0) and os.access(argv0, os.X_OK):
|
||||
return argv0
|
||||
|
||||
# Relative path — resolve against CWD
|
||||
if not argv0.startswith("-") and os.path.isfile(argv0):
|
||||
abs_path = os.path.abspath(argv0)
|
||||
if os.access(abs_path, os.X_OK):
|
||||
return abs_path
|
||||
|
||||
# PATH lookup
|
||||
path_bin = shutil.which("hermes")
|
||||
if path_bin:
|
||||
return path_bin
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def build_relaunch_argv(
|
||||
extra_args: Sequence[str],
|
||||
*,
|
||||
preserve_inherited: bool = True,
|
||||
original_argv: Optional[Sequence[str]] = None,
|
||||
) -> list[str]:
|
||||
"""Construct an argv list for replacing the current process with hermes.
|
||||
|
||||
Args:
|
||||
extra_args: Arguments to append (e.g. ``["--resume", id]``).
|
||||
preserve_inherited: Whether to carry over UI / behaviour flags
|
||||
tagged with ``inherit_on_relaunch`` in the parser.
|
||||
original_argv: The original argv to scan for flags (defaults to
|
||||
``sys.argv[1:]``).
|
||||
"""
|
||||
bin_path = resolve_hermes_bin()
|
||||
|
||||
if bin_path:
|
||||
argv = [bin_path]
|
||||
else:
|
||||
argv = [sys.executable, "-m", "hermes_cli.main"]
|
||||
|
||||
src = list(original_argv) if original_argv is not None else list(sys.argv[1:])
|
||||
|
||||
if preserve_inherited:
|
||||
argv.extend(_extract_inherited_flags(src))
|
||||
|
||||
argv.extend(extra_args)
|
||||
return argv
|
||||
|
||||
|
||||
def relaunch(
|
||||
extra_args: Sequence[str],
|
||||
*,
|
||||
preserve_inherited: bool = True,
|
||||
original_argv: Optional[Sequence[str]] = None,
|
||||
) -> None:
|
||||
"""Replace the current process with a fresh hermes invocation."""
|
||||
new_argv = build_relaunch_argv(
|
||||
extra_args, preserve_inherited=preserve_inherited, original_argv=original_argv
|
||||
)
|
||||
os.execvp(new_argv[0], new_argv)
|
||||
@@ -1070,6 +1070,20 @@ def resolve_runtime_provider(
|
||||
logger.info("Qwen OAuth credentials failed; "
|
||||
"falling through to next provider.")
|
||||
|
||||
if provider == "minimax-oauth":
|
||||
pconfig = PROVIDER_REGISTRY.get(provider)
|
||||
if pconfig and pconfig.auth_type == "oauth_minimax":
|
||||
from hermes_cli.auth import resolve_minimax_oauth_runtime_credentials
|
||||
creds = resolve_minimax_oauth_runtime_credentials()
|
||||
return {
|
||||
"provider": provider,
|
||||
"api_mode": "anthropic_messages",
|
||||
"base_url": creds["base_url"],
|
||||
"api_key": creds["api_key"],
|
||||
"source": creds.get("source", "oauth"),
|
||||
"requested_provider": requested_provider,
|
||||
}
|
||||
|
||||
if provider == "google-gemini-cli":
|
||||
try:
|
||||
creds = resolve_gemini_oauth_runtime_credentials()
|
||||
|
||||
@@ -12,6 +12,7 @@ Config files are stored in ~/.hermes/ for easy access.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
@@ -131,6 +132,7 @@ def _set_reasoning_effort(config: Dict[str, Any], effort: str) -> None:
|
||||
|
||||
# Import config helpers
|
||||
from hermes_cli.config import (
|
||||
cfg_get,
|
||||
DEFAULT_CONFIG,
|
||||
get_hermes_home,
|
||||
get_config_path,
|
||||
@@ -138,6 +140,7 @@ from hermes_cli.config import (
|
||||
load_config,
|
||||
save_config,
|
||||
save_env_value,
|
||||
remove_env_value,
|
||||
get_env_value,
|
||||
ensure_hermes_home,
|
||||
)
|
||||
@@ -441,7 +444,7 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
tool_status.append(("Image Generation", False, "FAL_KEY or OPENAI_API_KEY"))
|
||||
|
||||
# TTS — show configured provider
|
||||
tts_provider = config.get("tts", {}).get("provider", "edge")
|
||||
tts_provider = cfg_get(config, "tts", "provider", default="edge")
|
||||
if subscription_features.tts.managed_by_nous:
|
||||
tool_status.append(("Text-to-Speech (OpenAI via Nous subscription)", True, None))
|
||||
elif tts_provider == "elevenlabs" and get_env_value("ELEVENLABS_API_KEY"):
|
||||
@@ -480,7 +483,7 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
|
||||
if subscription_features.modal.managed_by_nous:
|
||||
tool_status.append(("Modal Execution (Nous subscription)", True, None))
|
||||
elif config.get("terminal", {}).get("backend") == "modal":
|
||||
elif cfg_get(config, "terminal", "backend") == "modal":
|
||||
if subscription_features.modal.direct_override:
|
||||
tool_status.append(("Modal Execution (direct Modal)", True, None))
|
||||
else:
|
||||
@@ -654,6 +657,102 @@ def _prompt_container_resources(config: dict):
|
||||
pass
|
||||
|
||||
|
||||
def _prompt_vercel_sandbox_settings(config: dict):
|
||||
"""Prompt for Vercel Sandbox settings without exposing unsupported disk sizing."""
|
||||
terminal = config.setdefault("terminal", {})
|
||||
|
||||
print()
|
||||
print_info("Vercel Sandbox settings:")
|
||||
print_info(" Filesystem persistence uses Vercel snapshots.")
|
||||
print_info(" Snapshots restore files only; live processes do not continue after sandbox recreation.")
|
||||
|
||||
from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES
|
||||
|
||||
current_runtime = terminal.get("vercel_runtime") or "node24"
|
||||
supported_label = ", ".join(_SUPPORTED_VERCEL_RUNTIMES)
|
||||
runtime = prompt(f" Runtime ({supported_label})", current_runtime).strip() or current_runtime
|
||||
if runtime not in _SUPPORTED_VERCEL_RUNTIMES:
|
||||
print_warning(f"Unsupported Vercel runtime '{runtime}', keeping {current_runtime}.")
|
||||
runtime = current_runtime if current_runtime in _SUPPORTED_VERCEL_RUNTIMES else "node24"
|
||||
terminal["vercel_runtime"] = runtime
|
||||
save_env_value("TERMINAL_VERCEL_RUNTIME", runtime)
|
||||
|
||||
current_persist = terminal.get("container_persistent", True)
|
||||
persist_label = "yes" if current_persist else "no"
|
||||
terminal["container_persistent"] = prompt(
|
||||
" Persist filesystem with snapshots? (yes/no)", persist_label
|
||||
).lower() in ("yes", "true", "y", "1")
|
||||
|
||||
current_cpu = terminal.get("container_cpu", 1)
|
||||
cpu_str = prompt(" CPU cores", str(current_cpu))
|
||||
try:
|
||||
terminal["container_cpu"] = float(cpu_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
current_mem = terminal.get("container_memory", 5120)
|
||||
mem_str = prompt(" Memory in MB (5120 = 5GB)", str(current_mem))
|
||||
try:
|
||||
terminal["container_memory"] = int(mem_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if terminal.get("container_disk", 51200) not in (0, 51200):
|
||||
print_warning("Vercel Sandbox does not support custom disk sizing; resetting container_disk to 51200.")
|
||||
terminal["container_disk"] = 51200
|
||||
|
||||
print()
|
||||
print_info("Vercel authentication:")
|
||||
print_info(" Use a long-lived Vercel access token plus project/team IDs.")
|
||||
linked_project = _read_nearest_vercel_project()
|
||||
if linked_project:
|
||||
print_info(" Found defaults in nearest .vercel/project.json.")
|
||||
|
||||
remove_env_value("VERCEL_OIDC_TOKEN")
|
||||
token = prompt(" Vercel access token", get_env_value("VERCEL_TOKEN") or "", password=True)
|
||||
project = prompt(
|
||||
" Vercel project ID",
|
||||
get_env_value("VERCEL_PROJECT_ID") or linked_project.get("projectId", ""),
|
||||
)
|
||||
team = prompt(
|
||||
" Vercel team ID",
|
||||
get_env_value("VERCEL_TEAM_ID") or linked_project.get("orgId", ""),
|
||||
)
|
||||
if token:
|
||||
save_env_value("VERCEL_TOKEN", token)
|
||||
if project:
|
||||
save_env_value("VERCEL_PROJECT_ID", project)
|
||||
if team:
|
||||
save_env_value("VERCEL_TEAM_ID", team)
|
||||
|
||||
|
||||
def _read_nearest_vercel_project(start: Path | None = None) -> dict[str, str]:
|
||||
"""Read project/team defaults from the nearest Vercel link file."""
|
||||
current = (start or Path.cwd()).resolve()
|
||||
if current.is_file():
|
||||
current = current.parent
|
||||
|
||||
for directory in (current, *current.parents):
|
||||
project_file = directory / ".vercel" / "project.json"
|
||||
if not project_file.exists():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(project_file.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return {
|
||||
key: value
|
||||
for key, value in {
|
||||
"projectId": data.get("projectId"),
|
||||
"orgId": data.get("orgId"),
|
||||
}.items()
|
||||
if isinstance(value, str) and value.strip()
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
# Tool categories and provider config are now in tools_config.py (shared
|
||||
# between `hermes tools` and `hermes setup tools`).
|
||||
|
||||
@@ -1179,7 +1278,7 @@ def setup_terminal_backend(config: dict):
|
||||
print_info(f" Guide: {_DOCS_BASE}/developer-guide/environments")
|
||||
print()
|
||||
|
||||
current_backend = config.get("terminal", {}).get("backend", "local")
|
||||
current_backend = cfg_get(config, "terminal", "backend", default="local")
|
||||
is_linux = _platform.system() == "Linux"
|
||||
|
||||
# Build backend choices with descriptions
|
||||
@@ -1189,11 +1288,12 @@ def setup_terminal_backend(config: dict):
|
||||
"Modal - serverless cloud sandbox",
|
||||
"SSH - run on a remote machine",
|
||||
"Daytona - persistent cloud development environment",
|
||||
"Vercel Sandbox - cloud microVM with snapshot filesystem persistence",
|
||||
]
|
||||
idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona"}
|
||||
backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4}
|
||||
idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona", 5: "vercel_sandbox"}
|
||||
backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4, "vercel_sandbox": 5}
|
||||
|
||||
next_idx = 5
|
||||
next_idx = 6
|
||||
if is_linux:
|
||||
terminal_choices.append("Singularity/Apptainer - HPC-friendly container")
|
||||
idx_to_backend[next_idx] = "singularity"
|
||||
@@ -1228,7 +1328,7 @@ def setup_terminal_backend(config: dict):
|
||||
print_info(
|
||||
" the agent starts. CLI mode always starts in the current directory."
|
||||
)
|
||||
current_cwd = config.get("terminal", {}).get("cwd", "")
|
||||
current_cwd = cfg_get(config, "terminal", "cwd", default="")
|
||||
cwd = prompt(" Messaging working directory", current_cwd or str(Path.home()))
|
||||
if cwd:
|
||||
config["terminal"]["cwd"] = cwd
|
||||
@@ -1259,9 +1359,7 @@ def setup_terminal_backend(config: dict):
|
||||
print_info(f"Docker found: {docker_bin}")
|
||||
|
||||
# Docker image
|
||||
current_image = config.get("terminal", {}).get(
|
||||
"docker_image", "nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
)
|
||||
current_image = cfg_get(config, "terminal", "docker_image", default="nikolaik/python-nodejs:python3.11-nodejs20")
|
||||
image = prompt(" Docker image", current_image)
|
||||
config["terminal"]["docker_image"] = image
|
||||
save_env_value("TERMINAL_DOCKER_IMAGE", image)
|
||||
@@ -1281,9 +1379,7 @@ def setup_terminal_backend(config: dict):
|
||||
else:
|
||||
print_info(f"Found: {sing_bin}")
|
||||
|
||||
current_image = config.get("terminal", {}).get(
|
||||
"singularity_image", "docker://nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
)
|
||||
current_image = cfg_get(config, "terminal", "singularity_image", default="docker://nikolaik/python-nodejs:python3.11-nodejs20")
|
||||
image = prompt(" Container image", current_image)
|
||||
config["terminal"]["singularity_image"] = image
|
||||
save_env_value("TERMINAL_SINGULARITY_IMAGE", image)
|
||||
@@ -1302,7 +1398,7 @@ def setup_terminal_backend(config: dict):
|
||||
get_nous_subscription_features(config).nous_auth_present
|
||||
and is_managed_tool_gateway_ready("modal")
|
||||
)
|
||||
modal_mode = normalize_modal_mode(config.get("terminal", {}).get("modal_mode"))
|
||||
modal_mode = normalize_modal_mode(cfg_get(config, "terminal", "modal_mode"))
|
||||
use_managed_modal = False
|
||||
if managed_modal_available:
|
||||
modal_choices = [
|
||||
@@ -1439,15 +1535,46 @@ def setup_terminal_backend(config: dict):
|
||||
print_success(" Configured")
|
||||
|
||||
# Daytona image
|
||||
current_image = config.get("terminal", {}).get(
|
||||
"daytona_image", "nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
)
|
||||
current_image = cfg_get(config, "terminal", "daytona_image", default="nikolaik/python-nodejs:python3.11-nodejs20")
|
||||
image = prompt(" Sandbox image", current_image)
|
||||
config["terminal"]["daytona_image"] = image
|
||||
save_env_value("TERMINAL_DAYTONA_IMAGE", image)
|
||||
|
||||
_prompt_container_resources(config)
|
||||
|
||||
elif selected_backend == "vercel_sandbox":
|
||||
print_success("Terminal backend: Vercel Sandbox")
|
||||
print_info("Cloud microVM sandboxes with snapshot-backed filesystem persistence.")
|
||||
print_info("Requires the optional SDK: pip install 'hermes-agent[vercel]'")
|
||||
|
||||
try:
|
||||
__import__("vercel")
|
||||
except ImportError:
|
||||
print_info("Installing vercel SDK...")
|
||||
import subprocess
|
||||
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
result = subprocess.run(
|
||||
[uv_bin, "pip", "install", "--python", sys.executable, "vercel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "vercel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print_success("vercel SDK installed")
|
||||
else:
|
||||
print_warning("Install failed — run manually: pip install 'hermes-agent[vercel]'")
|
||||
if result.stderr:
|
||||
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
|
||||
|
||||
_prompt_vercel_sandbox_settings(config)
|
||||
|
||||
elif selected_backend == "ssh":
|
||||
print_success("Terminal backend: SSH")
|
||||
print_info("Run commands on a remote machine via SSH.")
|
||||
@@ -1501,6 +1628,8 @@ def setup_terminal_backend(config: dict):
|
||||
save_env_value("TERMINAL_ENV", selected_backend)
|
||||
if selected_backend == "modal":
|
||||
save_env_value("TERMINAL_MODAL_MODE", config["terminal"].get("modal_mode", "auto"))
|
||||
if selected_backend == "vercel_sandbox":
|
||||
save_env_value("TERMINAL_VERCEL_RUNTIME", config["terminal"].get("vercel_runtime", "node24"))
|
||||
save_config(config)
|
||||
print()
|
||||
print_success(f"Terminal backend set to: {selected_backend}")
|
||||
@@ -1545,7 +1674,7 @@ def setup_agent_settings(config: dict):
|
||||
|
||||
# ── Max Iterations ──
|
||||
current_max = get_env_value("HERMES_MAX_ITERATIONS") or str(
|
||||
config.get("agent", {}).get("max_turns", 90)
|
||||
cfg_get(config, "agent", "max_turns", default=90)
|
||||
)
|
||||
print_info("Maximum tool-calling iterations per conversation.")
|
||||
print_info("Higher = more complex tasks, but costs more tokens.")
|
||||
@@ -1573,7 +1702,7 @@ def setup_agent_settings(config: dict):
|
||||
print_info(" all — Show every tool call with a short preview")
|
||||
print_info(" verbose — Full args, results, and debug logs")
|
||||
|
||||
current_mode = config.get("display", {}).get("tool_progress", "all")
|
||||
current_mode = cfg_get(config, "display", "tool_progress", default="all")
|
||||
mode = prompt("Tool progress mode", current_mode)
|
||||
if mode.lower() in ("off", "new", "all", "verbose"):
|
||||
if "display" not in config:
|
||||
@@ -1593,7 +1722,7 @@ def setup_agent_settings(config: dict):
|
||||
|
||||
config.setdefault("compression", {})["enabled"] = True
|
||||
|
||||
current_threshold = config.get("compression", {}).get("threshold", 0.50)
|
||||
current_threshold = cfg_get(config, "compression", "threshold", default=0.50)
|
||||
threshold_str = prompt("Compression threshold (0.5-0.95)", str(current_threshold))
|
||||
try:
|
||||
threshold = float(threshold_str)
|
||||
@@ -2075,80 +2204,7 @@ def _setup_mattermost():
|
||||
home_channel = prompt("Home channel ID (leave empty to set later with /set-home)")
|
||||
if home_channel:
|
||||
save_env_value("MATTERMOST_HOME_CHANNEL", home_channel)
|
||||
|
||||
|
||||
def _setup_whatsapp():
|
||||
"""Configure WhatsApp bridge."""
|
||||
print_header("WhatsApp")
|
||||
existing = get_env_value("WHATSAPP_ENABLED")
|
||||
if existing:
|
||||
print_info("WhatsApp: already enabled")
|
||||
return
|
||||
|
||||
print_info("WhatsApp connects via a built-in bridge (Baileys).")
|
||||
print_info("Requires Node.js. Run 'hermes whatsapp' for guided setup.")
|
||||
print()
|
||||
if prompt_yes_no("Enable WhatsApp now?", True):
|
||||
save_env_value("WHATSAPP_ENABLED", "true")
|
||||
print_success("WhatsApp enabled")
|
||||
print_info("Run 'hermes whatsapp' to choose your mode (separate bot number")
|
||||
print_info("or personal self-chat) and pair via QR code.")
|
||||
|
||||
|
||||
def _setup_weixin():
|
||||
"""Configure Weixin (personal WeChat) via iLink Bot API QR login."""
|
||||
from hermes_cli.gateway import _setup_weixin as _gateway_setup_weixin
|
||||
_gateway_setup_weixin()
|
||||
|
||||
|
||||
def _setup_signal():
|
||||
"""Configure Signal via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_signal as _gateway_setup_signal
|
||||
_gateway_setup_signal()
|
||||
|
||||
|
||||
def _setup_email():
|
||||
"""Configure Email via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_email as _gateway_setup_email
|
||||
_gateway_setup_email()
|
||||
|
||||
|
||||
def _setup_sms():
|
||||
"""Configure SMS (Twilio) via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_sms as _gateway_setup_sms
|
||||
_gateway_setup_sms()
|
||||
|
||||
|
||||
def _setup_dingtalk():
|
||||
"""Configure DingTalk via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_dingtalk as _gateway_setup_dingtalk
|
||||
_gateway_setup_dingtalk()
|
||||
|
||||
|
||||
def _setup_feishu():
|
||||
"""Configure Feishu / Lark via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_feishu as _gateway_setup_feishu
|
||||
_gateway_setup_feishu()
|
||||
|
||||
|
||||
def _setup_yuanbao():
|
||||
"""Configure Yuanbao via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_yuanbao as _gateway_setup_yuanbao
|
||||
_gateway_setup_yuanbao()
|
||||
|
||||
|
||||
def _setup_wecom():
|
||||
"""Configure WeCom (Enterprise WeChat) via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_wecom as _gateway_setup_wecom
|
||||
_gateway_setup_wecom()
|
||||
|
||||
|
||||
def _setup_wecom_callback():
|
||||
"""Configure WeCom Callback (self-built app) via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_wecom_callback as _gw_setup
|
||||
_gw_setup()
|
||||
|
||||
|
||||
print_info(" Open config in your editor: hermes config edit")
|
||||
|
||||
|
||||
def _setup_bluebubbles():
|
||||
@@ -2266,49 +2322,27 @@ def _setup_webhooks():
|
||||
print_info(" https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks/#configuring-routes")
|
||||
print()
|
||||
print_info(" Open config in your editor: hermes config edit")
|
||||
|
||||
|
||||
# Platform registry for the gateway checklist
|
||||
_GATEWAY_PLATFORMS = [
|
||||
("Telegram", "TELEGRAM_BOT_TOKEN", _setup_telegram),
|
||||
("Discord", "DISCORD_BOT_TOKEN", _setup_discord),
|
||||
("Slack", "SLACK_BOT_TOKEN", _setup_slack),
|
||||
("Signal", "SIGNAL_HTTP_URL", _setup_signal),
|
||||
("Email", "EMAIL_ADDRESS", _setup_email),
|
||||
("SMS (Twilio)", "TWILIO_ACCOUNT_SID", _setup_sms),
|
||||
("Matrix", "MATRIX_ACCESS_TOKEN", _setup_matrix),
|
||||
("Mattermost", "MATTERMOST_TOKEN", _setup_mattermost),
|
||||
("WhatsApp", "WHATSAPP_ENABLED", _setup_whatsapp),
|
||||
("DingTalk", "DINGTALK_CLIENT_ID", _setup_dingtalk),
|
||||
("Feishu / Lark", "FEISHU_APP_ID", _setup_feishu),
|
||||
("Yuanbao", "YUANBAO_APP_ID", _setup_yuanbao),
|
||||
("WeCom (Enterprise WeChat)", "WECOM_BOT_ID", _setup_wecom),
|
||||
("WeCom Callback (Self-Built App)", "WECOM_CALLBACK_CORP_ID", _setup_wecom_callback),
|
||||
("Weixin (WeChat)", "WEIXIN_ACCOUNT_ID", _setup_weixin),
|
||||
("BlueBubbles (iMessage)", "BLUEBUBBLES_SERVER_URL", _setup_bluebubbles),
|
||||
("QQ Bot", "QQ_APP_ID", _setup_qqbot),
|
||||
("Webhooks (GitHub, GitLab, etc.)", "WEBHOOK_ENABLED", _setup_webhooks),
|
||||
]
|
||||
print_info(" Open config in your editor: hermes config edit")
|
||||
|
||||
|
||||
def setup_gateway(config: dict):
|
||||
"""Configure messaging platform integrations."""
|
||||
from hermes_cli.gateway import _all_platforms, _platform_status, _configure_platform
|
||||
|
||||
print_header("Messaging Platforms")
|
||||
print_info("Connect to messaging platforms to chat with Hermes from anywhere.")
|
||||
print_info("Toggle with Space, confirm with Enter.")
|
||||
print()
|
||||
|
||||
# Build checklist items, pre-selecting already-configured platforms
|
||||
platforms = _all_platforms()
|
||||
|
||||
# Build checklist, pre-selecting already-configured platforms.
|
||||
items = []
|
||||
pre_selected = []
|
||||
for i, (name, env_var, _func) in enumerate(_GATEWAY_PLATFORMS):
|
||||
# Matrix has two possible env vars
|
||||
is_configured = bool(get_env_value(env_var))
|
||||
if name == "Matrix" and not is_configured:
|
||||
is_configured = bool(get_env_value("MATRIX_PASSWORD"))
|
||||
label = f"{name} (configured)" if is_configured else name
|
||||
items.append(label)
|
||||
if is_configured:
|
||||
for i, plat in enumerate(platforms):
|
||||
status = _platform_status(plat)
|
||||
items.append(f"{plat['emoji']} {plat['label']} ({status})")
|
||||
if status == "configured":
|
||||
pre_selected.append(i)
|
||||
|
||||
selected = prompt_checklist("Select platforms to configure:", items, pre_selected)
|
||||
@@ -2318,28 +2352,22 @@ def setup_gateway(config: dict):
|
||||
return
|
||||
|
||||
for idx in selected:
|
||||
name, _env_var, setup_func = _GATEWAY_PLATFORMS[idx]
|
||||
setup_func()
|
||||
_configure_platform(platforms[idx])
|
||||
|
||||
# ── Gateway Service Setup ──
|
||||
any_messaging = (
|
||||
get_env_value("TELEGRAM_BOT_TOKEN")
|
||||
or get_env_value("DISCORD_BOT_TOKEN")
|
||||
or get_env_value("SLACK_BOT_TOKEN")
|
||||
or get_env_value("SIGNAL_HTTP_URL")
|
||||
or get_env_value("EMAIL_ADDRESS")
|
||||
or get_env_value("TWILIO_ACCOUNT_SID")
|
||||
or get_env_value("MATTERMOST_TOKEN")
|
||||
or get_env_value("MATRIX_ACCESS_TOKEN")
|
||||
or get_env_value("MATRIX_PASSWORD")
|
||||
or get_env_value("WHATSAPP_ENABLED")
|
||||
or get_env_value("DINGTALK_CLIENT_ID")
|
||||
or get_env_value("FEISHU_APP_ID")
|
||||
or get_env_value("WECOM_BOT_ID")
|
||||
or get_env_value("WEIXIN_ACCOUNT_ID")
|
||||
or get_env_value("BLUEBUBBLES_SERVER_URL")
|
||||
or get_env_value("QQ_APP_ID")
|
||||
or get_env_value("WEBHOOK_ENABLED")
|
||||
# Count any platform (built-in or plugin) the user configured during this
|
||||
# setup pass — reuses ``_platform_status`` so plugin platforms like IRC
|
||||
# are picked up without another hard-coded env-var list.
|
||||
def _is_progress(status: str) -> bool:
|
||||
s = status.lower()
|
||||
return not (
|
||||
s == "not configured"
|
||||
or s.startswith("partially")
|
||||
or s.startswith("plugin disabled")
|
||||
)
|
||||
|
||||
any_messaging = any(
|
||||
_is_progress(_platform_status(p)) for p in _all_platforms()
|
||||
)
|
||||
if any_messaging:
|
||||
print()
|
||||
@@ -2601,21 +2629,26 @@ def _get_section_config_summary(config: dict, section_key: str) -> Optional[str]
|
||||
return "configured"
|
||||
|
||||
elif section_key == "terminal":
|
||||
backend = config.get("terminal", {}).get("backend", "local")
|
||||
backend = cfg_get(config, "terminal", "backend", default="local")
|
||||
return f"backend: {backend}"
|
||||
|
||||
elif section_key == "agent":
|
||||
max_turns = config.get("agent", {}).get("max_turns", 90)
|
||||
max_turns = cfg_get(config, "agent", "max_turns", default=90)
|
||||
return f"max turns: {max_turns}"
|
||||
|
||||
elif section_key == "gateway":
|
||||
platforms = [
|
||||
_gateway_platform_short_label(label)
|
||||
for label, env_var, _ in _GATEWAY_PLATFORMS
|
||||
if get_env_value(env_var)
|
||||
from hermes_cli.gateway import _all_platforms, _platform_status
|
||||
# Count any non-empty status other than the "not configured" sentinel —
|
||||
# platforms like WhatsApp ("enabled, not paired"), Matrix ("configured
|
||||
# + E2EE"), and Signal ("partially configured") all indicate the user
|
||||
# has already started setup and we shouldn't force the section to rerun.
|
||||
configured = [
|
||||
_gateway_platform_short_label(plat["label"])
|
||||
for plat in _all_platforms()
|
||||
if _platform_status(plat) and _platform_status(plat) != "not configured"
|
||||
]
|
||||
if platforms:
|
||||
return ", ".join(platforms)
|
||||
if configured:
|
||||
return ", ".join(configured)
|
||||
return None # No platforms configured — section must run
|
||||
|
||||
elif section_key == "tools":
|
||||
@@ -3120,33 +3153,14 @@ def run_setup_wizard(args):
|
||||
_offer_launch_chat()
|
||||
|
||||
|
||||
def _resolve_hermes_chat_argv() -> Optional[list[str]]:
|
||||
"""Resolve argv for launching ``hermes chat`` in a fresh process."""
|
||||
hermes_bin = shutil.which("hermes")
|
||||
if hermes_bin:
|
||||
return [hermes_bin, "chat"]
|
||||
|
||||
try:
|
||||
if importlib.util.find_spec("hermes_cli") is not None:
|
||||
return [sys.executable, "-m", "hermes_cli.main", "chat"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _offer_launch_chat():
|
||||
"""Prompt the user to jump straight into chat after setup."""
|
||||
print()
|
||||
if not prompt_yes_no("Launch hermes chat now?", True):
|
||||
return
|
||||
|
||||
chat_argv = _resolve_hermes_chat_argv()
|
||||
if not chat_argv:
|
||||
print_info("Could not relaunch Hermes automatically. Run 'hermes chat' manually.")
|
||||
return
|
||||
|
||||
os.execvp(chat_argv[0], chat_argv)
|
||||
from hermes_cli.relaunch import relaunch
|
||||
relaunch(["chat"])
|
||||
|
||||
|
||||
def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
|
||||
|
||||
@@ -13,7 +13,7 @@ Config stored in ~/.hermes/config.yaml under:
|
||||
"""
|
||||
from typing import List, Optional, Set
|
||||
|
||||
from hermes_cli.config import load_config, save_config
|
||||
from hermes_cli.config import cfg_get, load_config, save_config
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.platforms import PLATFORMS as _PLATFORMS
|
||||
|
||||
@@ -30,7 +30,7 @@ def get_disabled_skills(config: dict, platform: Optional[str] = None) -> Set[str
|
||||
global_disabled = set(skills_cfg.get("disabled", []))
|
||||
if platform is None:
|
||||
return global_disabled
|
||||
platform_disabled = skills_cfg.get("platform_disabled", {}).get(platform)
|
||||
platform_disabled = cfg_get(skills_cfg, "platform_disabled", platform)
|
||||
if platform_disabled is None:
|
||||
return global_disabled
|
||||
return set(platform_disabled)
|
||||
|
||||
@@ -7,6 +7,7 @@ Shows the status of all Hermes Agent components.
|
||||
import os
|
||||
import sys
|
||||
import subprocess # noqa: F401 — re-exported for tests that monkeypatch status.subprocess to guard against regressions
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
@@ -17,6 +18,7 @@ from hermes_cli.config import get_env_path, get_env_value, get_hermes_home, load
|
||||
from hermes_cli.models import provider_label
|
||||
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||
from hermes_cli.runtime_provider import resolve_requested_provider
|
||||
from hermes_cli.vercel_auth import describe_vercel_auth
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||
|
||||
@@ -89,12 +91,12 @@ def show_status(args):
|
||||
"""Show status of all Hermes Agent components."""
|
||||
show_all = getattr(args, 'all', False)
|
||||
deep = getattr(args, 'deep', False)
|
||||
|
||||
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
|
||||
print(color("│ ⚕ Hermes Agent Status │", Colors.CYAN))
|
||||
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Environment
|
||||
# =========================================================================
|
||||
@@ -102,7 +104,7 @@ def show_status(args):
|
||||
print(color("◆ Environment", Colors.CYAN, Colors.BOLD))
|
||||
print(f" Project: {PROJECT_ROOT}")
|
||||
print(f" Python: {sys.version.split()[0]}")
|
||||
|
||||
|
||||
env_path = get_env_path()
|
||||
print(f" .env file: {check_mark(env_path.exists())} {'exists' if env_path.exists() else 'not found'}")
|
||||
|
||||
@@ -113,13 +115,13 @@ def show_status(args):
|
||||
|
||||
print(f" Model: {_configured_model_label(config)}")
|
||||
print(f" Provider: {_effective_provider_label()}")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# API Keys
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ API Keys", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
keys = {
|
||||
"OpenRouter": "OPENROUTER_API_KEY",
|
||||
"OpenAI": "OPENAI_API_KEY",
|
||||
@@ -138,7 +140,7 @@ def show_status(args):
|
||||
"ElevenLabs": "ELEVENLABS_API_KEY",
|
||||
"GitHub": "GITHUB_TOKEN",
|
||||
}
|
||||
|
||||
|
||||
for name, env_var in keys.items():
|
||||
value = get_env_value(env_var) or ""
|
||||
has_key = bool(value)
|
||||
@@ -157,14 +159,21 @@ def show_status(args):
|
||||
print(color("◆ Auth Providers", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import get_nous_auth_status, get_codex_auth_status, get_qwen_auth_status
|
||||
from hermes_cli.auth import (
|
||||
get_nous_auth_status,
|
||||
get_codex_auth_status,
|
||||
get_qwen_auth_status,
|
||||
get_minimax_oauth_auth_status,
|
||||
)
|
||||
nous_status = get_nous_auth_status()
|
||||
codex_status = get_codex_auth_status()
|
||||
qwen_status = get_qwen_auth_status()
|
||||
minimax_status = get_minimax_oauth_auth_status()
|
||||
except Exception:
|
||||
nous_status = {}
|
||||
codex_status = {}
|
||||
qwen_status = {}
|
||||
minimax_status = {}
|
||||
|
||||
nous_logged_in = bool(nous_status.get("logged_in"))
|
||||
nous_error = nous_status.get("error")
|
||||
@@ -217,6 +226,20 @@ def show_status(args):
|
||||
if qwen_status.get("error") and not qwen_logged_in:
|
||||
print(f" Error: {qwen_status.get('error')}")
|
||||
|
||||
minimax_logged_in = bool(minimax_status.get("logged_in"))
|
||||
print(
|
||||
f" {'MiniMax OAuth':<12} {check_mark(minimax_logged_in)} "
|
||||
f"{'logged in' if minimax_logged_in else 'not logged in (run: hermes auth add minimax-oauth)'}"
|
||||
)
|
||||
minimax_region = minimax_status.get("region")
|
||||
if minimax_logged_in and minimax_region:
|
||||
print(f" Region: {minimax_region}")
|
||||
minimax_exp = minimax_status.get("expires_at")
|
||||
if minimax_exp:
|
||||
print(f" Access exp: {minimax_exp}")
|
||||
if minimax_status.get("error") and not minimax_logged_in:
|
||||
print(f" Error: {minimax_status.get('error')}")
|
||||
|
||||
# =========================================================================
|
||||
# Nous Subscription Features
|
||||
# =========================================================================
|
||||
@@ -299,18 +322,13 @@ def show_status(args):
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Terminal Backend", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
terminal_cfg = config.get("terminal", {}) if isinstance(config.get("terminal"), dict) else {}
|
||||
terminal_env = os.getenv("TERMINAL_ENV", "")
|
||||
if not terminal_env:
|
||||
# Fall back to config file value when env var isn't set
|
||||
# (hermes status doesn't go through cli.py's config loading)
|
||||
try:
|
||||
_cfg = load_config()
|
||||
terminal_env = _cfg.get("terminal", {}).get("backend", "local")
|
||||
except Exception:
|
||||
terminal_env = "local"
|
||||
terminal_env = terminal_cfg.get("backend", "local")
|
||||
print(f" Backend: {terminal_env}")
|
||||
|
||||
|
||||
if terminal_env == "ssh":
|
||||
ssh_host = os.getenv("TERMINAL_SSH_HOST", "")
|
||||
ssh_user = os.getenv("TERMINAL_SSH_USER", "")
|
||||
@@ -322,16 +340,33 @@ def show_status(args):
|
||||
elif terminal_env == "daytona":
|
||||
daytona_image = os.getenv("TERMINAL_DAYTONA_IMAGE", "nikolaik/python-nodejs:python3.11-nodejs20")
|
||||
print(f" Daytona Image: {daytona_image}")
|
||||
|
||||
elif terminal_env == "vercel_sandbox":
|
||||
runtime = os.getenv("TERMINAL_VERCEL_RUNTIME") or terminal_cfg.get("vercel_runtime") or "node24"
|
||||
persist = os.getenv("TERMINAL_CONTAINER_PERSISTENT")
|
||||
if persist is None:
|
||||
persist_enabled = bool(terminal_cfg.get("container_persistent", True))
|
||||
else:
|
||||
persist_enabled = persist.lower() in ("1", "true", "yes", "on")
|
||||
auth_status = describe_vercel_auth()
|
||||
sdk_ok = importlib.util.find_spec("vercel") is not None
|
||||
sdk_label = "installed" if sdk_ok else "missing (install: pip install 'hermes-agent[vercel]')"
|
||||
print(f" Runtime: {runtime}")
|
||||
print(f" SDK: {check_mark(sdk_ok)} {sdk_label}")
|
||||
print(f" Auth: {check_mark(auth_status.ok)} {auth_status.label}")
|
||||
for line in auth_status.detail_lines:
|
||||
print(f" Auth detail: {line}")
|
||||
print(f" Persistence: {'snapshot filesystem' if persist_enabled else 'ephemeral filesystem'}")
|
||||
print(" Processes: live processes do not survive cleanup, snapshots, or sandbox recreation")
|
||||
|
||||
sudo_password = os.getenv("SUDO_PASSWORD", "")
|
||||
print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Messaging Platforms
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Messaging Platforms", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
platforms = {
|
||||
"Telegram": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL"),
|
||||
"Discord": ("DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL"),
|
||||
@@ -349,7 +384,7 @@ def show_status(args):
|
||||
"QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"),
|
||||
"Yuanbao": ("YUANBAO_APP_ID", "YUANBAO_HOME_CHANNEL"),
|
||||
}
|
||||
|
||||
|
||||
for name, (token_var, home_var) in platforms.items():
|
||||
token = os.getenv(token_var, "")
|
||||
has_token = bool(token)
|
||||
@@ -366,7 +401,18 @@ def show_status(args):
|
||||
status += f" (home: {home_channel})"
|
||||
|
||||
print(f" {name:<12} {check_mark(has_token)} {status}")
|
||||
|
||||
|
||||
# Plugin-registered platforms
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
for entry in platform_registry.plugin_entries():
|
||||
configured = entry.check_fn()
|
||||
status_str = "configured" if configured else "not configured"
|
||||
label = entry.label
|
||||
print(f" {label:<12} {check_mark(configured)} {status_str} (plugin)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# =========================================================================
|
||||
# Gateway Status
|
||||
# =========================================================================
|
||||
@@ -402,13 +448,13 @@ def show_status(args):
|
||||
else:
|
||||
print(f" Status: {color('N/A', Colors.DIM)}")
|
||||
print(" Manager: (not supported on this platform)")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Cron Jobs
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Scheduled Jobs", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
jobs_file = get_hermes_home() / "cron" / "jobs.json"
|
||||
if jobs_file.exists():
|
||||
import json
|
||||
@@ -422,13 +468,13 @@ def show_status(args):
|
||||
print(" Jobs: (error reading jobs file)")
|
||||
else:
|
||||
print(" Jobs: 0")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Sessions
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Sessions", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
sessions_file = get_hermes_home() / "sessions" / "sessions.json"
|
||||
if sessions_file.exists():
|
||||
import json
|
||||
@@ -440,7 +486,7 @@ def show_status(args):
|
||||
print(" Active: (error reading sessions file)")
|
||||
else:
|
||||
print(" Active: 0")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Deep checks
|
||||
# =========================================================================
|
||||
@@ -476,7 +522,7 @@ def show_status(args):
|
||||
print(f" Port 18789: {'in use' if port_in_use else 'available'}")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
print()
|
||||
print(color("─" * 60, Colors.DIM))
|
||||
print(color(" Run 'hermes doctor' for detailed diagnostics", Colors.DIM))
|
||||
|
||||
@@ -100,6 +100,9 @@ TIPS = [
|
||||
"hermes gateway install sets up Hermes as a system service (systemd/launchd).",
|
||||
"hermes memory setup lets you configure an external memory provider (Honcho, Mem0, etc.).",
|
||||
"hermes webhook subscribe creates event-driven webhook routes with HMAC validation.",
|
||||
"Save money: hermes tools disables unused tools, hermes skills config trims skills down.",
|
||||
"/reasoning low or /reasoning minimal cuts thinking depth below the default (medium) — faster, cheaper responses.",
|
||||
"hermes models routes vision, compression, and aux tasks to cheaper models — cuts background token cost 85%+ without downgrading your main chat model.",
|
||||
|
||||
# --- Configuration ---
|
||||
"Set display.bell_on_complete: true in config.yaml to hear a bell when long tasks finish.",
|
||||
|
||||
@@ -18,6 +18,7 @@ from typing import Dict, List, Optional, Set
|
||||
|
||||
|
||||
from hermes_cli.config import (
|
||||
cfg_get,
|
||||
load_config, save_config, get_env_value, save_env_value,
|
||||
)
|
||||
from hermes_cli.colors import Colors, color
|
||||
@@ -226,6 +227,14 @@ TOOL_CATEGORIES = {
|
||||
"tts_provider": "kittentts",
|
||||
"post_setup": "kittentts",
|
||||
},
|
||||
{
|
||||
"name": "Piper",
|
||||
"badge": "local · free",
|
||||
"tag": "Local neural TTS, 44 languages (voices ~20-90MB)",
|
||||
"env_vars": [],
|
||||
"tts_provider": "piper",
|
||||
"post_setup": "piper",
|
||||
},
|
||||
],
|
||||
},
|
||||
"web": {
|
||||
@@ -623,6 +632,33 @@ def _run_post_setup(post_setup_key: str):
|
||||
_print_warning(" kittentts install timed out (>5min)")
|
||||
_print_info(f" Run manually: python -m pip install -U '{wheel_url}' soundfile")
|
||||
|
||||
elif post_setup_key == "piper":
|
||||
try:
|
||||
__import__("piper")
|
||||
_print_success(" piper-tts is already installed")
|
||||
except ImportError:
|
||||
import subprocess
|
||||
_print_info(" Installing piper-tts (~14MB wheel, voices downloaded on first use)...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "-U", "piper-tts", "--quiet"],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
_print_success(" piper-tts installed")
|
||||
else:
|
||||
_print_warning(" piper-tts install failed:")
|
||||
_print_info(f" {result.stderr.strip()[:300]}")
|
||||
_print_info(" Run manually: python -m pip install -U piper-tts")
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
_print_warning(" piper-tts install timed out (>5min)")
|
||||
_print_info(" Run manually: python -m pip install -U piper-tts")
|
||||
return
|
||||
_print_info(" Default voice: en_US-lessac-medium (downloaded on first TTS call)")
|
||||
_print_info(" Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md")
|
||||
_print_info(" Switch voices by setting tts.piper.voice in ~/.hermes/config.yaml")
|
||||
|
||||
elif post_setup_key == "spotify":
|
||||
# Run the full `hermes auth spotify` flow — if the user has no
|
||||
# client_id yet, this drops them into the interactive wizard
|
||||
@@ -780,7 +816,12 @@ def _get_platform_tools(
|
||||
toolset_names = platform_toolsets.get(platform)
|
||||
|
||||
if toolset_names is None or not isinstance(toolset_names, list):
|
||||
default_ts = PLATFORMS[platform]["default_toolset"]
|
||||
plat_info = PLATFORMS.get(platform)
|
||||
if plat_info:
|
||||
default_ts = plat_info["default_toolset"]
|
||||
else:
|
||||
# Plugin platform — derive toolset name from platform key
|
||||
default_ts = f"hermes-{platform}"
|
||||
toolset_names = [default_ts]
|
||||
|
||||
# YAML may parse bare numeric names (e.g. ``12306:``) as int.
|
||||
@@ -843,7 +884,9 @@ def _get_platform_tools(
|
||||
# checklist or in a user-saved config. Must run in BOTH branches —
|
||||
# otherwise saving via `hermes tools` (which flips has_explicit_config
|
||||
# to True) silently drops them.
|
||||
platform_tool_universe = set(resolve_toolset(PLATFORMS[platform]["default_toolset"]))
|
||||
_plat_info = PLATFORMS.get(platform)
|
||||
_default_ts = _plat_info["default_toolset"] if _plat_info else f"hermes-{platform}"
|
||||
platform_tool_universe = set(resolve_toolset(_default_ts))
|
||||
configurable_tool_universe = set()
|
||||
for ck in configurable_keys:
|
||||
configurable_tool_universe.update(resolve_toolset(ck))
|
||||
@@ -965,7 +1008,7 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[
|
||||
platform_default_keys = {p["default_toolset"] for p in PLATFORMS.values()}
|
||||
|
||||
# Get existing toolsets for this platform
|
||||
existing_toolsets = config.get("platform_toolsets", {}).get(platform, [])
|
||||
existing_toolsets = cfg_get(config, "platform_toolsets", platform, default=[])
|
||||
if not isinstance(existing_toolsets, list):
|
||||
existing_toolsets = []
|
||||
existing_toolsets = [str(ts) for ts in existing_toolsets]
|
||||
@@ -1352,23 +1395,23 @@ def _is_provider_active(provider: dict, config: dict) -> bool:
|
||||
if provider.get("tts_provider"):
|
||||
return (
|
||||
feature.managed_by_nous
|
||||
and config.get("tts", {}).get("provider") == provider["tts_provider"]
|
||||
and cfg_get(config, "tts", "provider") == provider["tts_provider"]
|
||||
)
|
||||
if "browser_provider" in provider:
|
||||
current = config.get("browser", {}).get("cloud_provider")
|
||||
current = cfg_get(config, "browser", "cloud_provider")
|
||||
return feature.managed_by_nous and provider["browser_provider"] == current
|
||||
if provider.get("web_backend"):
|
||||
current = config.get("web", {}).get("backend")
|
||||
current = cfg_get(config, "web", "backend")
|
||||
return feature.managed_by_nous and current == provider["web_backend"]
|
||||
return feature.managed_by_nous
|
||||
|
||||
if provider.get("tts_provider"):
|
||||
return config.get("tts", {}).get("provider") == provider["tts_provider"]
|
||||
return cfg_get(config, "tts", "provider") == provider["tts_provider"]
|
||||
if "browser_provider" in provider:
|
||||
current = config.get("browser", {}).get("cloud_provider")
|
||||
current = cfg_get(config, "browser", "cloud_provider")
|
||||
return provider["browser_provider"] == current
|
||||
if provider.get("web_backend"):
|
||||
current = config.get("web", {}).get("backend")
|
||||
current = cfg_get(config, "web", "backend")
|
||||
return current == provider["web_backend"]
|
||||
if provider.get("imagegen_backend"):
|
||||
image_cfg = config.get("image_gen", {})
|
||||
|
||||
70
hermes_cli/vercel_auth.py
Normal file
70
hermes_cli/vercel_auth.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Helpers for reporting Vercel Sandbox authentication state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
_TOKEN_TUPLE_VARS = ("VERCEL_TOKEN", "VERCEL_PROJECT_ID", "VERCEL_TEAM_ID")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VercelAuthStatus:
|
||||
ok: bool
|
||||
label: str
|
||||
detail_lines: tuple[str, ...]
|
||||
|
||||
|
||||
def _present(name: str) -> bool:
|
||||
return bool(os.getenv(name))
|
||||
|
||||
|
||||
def describe_vercel_auth() -> VercelAuthStatus:
|
||||
"""Return Vercel auth status without exposing secret values."""
|
||||
|
||||
has_oidc = _present("VERCEL_OIDC_TOKEN")
|
||||
token_states = {name: _present(name) for name in _TOKEN_TUPLE_VARS}
|
||||
present_token_vars = tuple(name for name, present in token_states.items() if present)
|
||||
missing_token_vars = tuple(name for name, present in token_states.items() if not present)
|
||||
|
||||
if has_oidc:
|
||||
details = [
|
||||
"mode: OIDC",
|
||||
"active env: VERCEL_OIDC_TOKEN",
|
||||
"note: OIDC tokens are development-only; use access-token auth for deployments and long-running processes",
|
||||
]
|
||||
if present_token_vars:
|
||||
details.append(f"also present: {', '.join(present_token_vars)}")
|
||||
return VercelAuthStatus(True, "OIDC token via VERCEL_OIDC_TOKEN", tuple(details))
|
||||
|
||||
if not missing_token_vars:
|
||||
return VercelAuthStatus(
|
||||
True,
|
||||
"access token + project/team via VERCEL_TOKEN, VERCEL_PROJECT_ID, VERCEL_TEAM_ID",
|
||||
(
|
||||
"mode: access token",
|
||||
"active env: VERCEL_TOKEN, VERCEL_PROJECT_ID, VERCEL_TEAM_ID",
|
||||
),
|
||||
)
|
||||
|
||||
if present_token_vars:
|
||||
return VercelAuthStatus(
|
||||
False,
|
||||
f"partial access-token auth (missing {', '.join(missing_token_vars)})",
|
||||
(
|
||||
"mode: incomplete access token",
|
||||
f"present env: {', '.join(present_token_vars)}",
|
||||
f"missing env: {', '.join(missing_token_vars)}",
|
||||
"recommended: set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together",
|
||||
),
|
||||
)
|
||||
|
||||
return VercelAuthStatus(
|
||||
False,
|
||||
"not configured",
|
||||
(
|
||||
"recommended: set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID",
|
||||
"development-only alternative: set VERCEL_OIDC_TOKEN",
|
||||
),
|
||||
)
|
||||
@@ -23,7 +23,7 @@ import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -33,6 +33,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
|
||||
from hermes_cli import __version__, __release_date__
|
||||
from hermes_cli.config import (
|
||||
cfg_get,
|
||||
DEFAULT_CONFIG,
|
||||
OPTIONAL_ENV_VARS,
|
||||
get_config_path,
|
||||
@@ -252,7 +253,12 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
|
||||
"terminal.backend": {
|
||||
"type": "select",
|
||||
"description": "Terminal execution backend",
|
||||
"options": ["local", "docker", "ssh", "modal", "daytona", "singularity"],
|
||||
"options": ["local", "docker", "ssh", "modal", "daytona", "vercel_sandbox", "singularity"],
|
||||
},
|
||||
"terminal.vercel_runtime": {
|
||||
"type": "select",
|
||||
"description": "Vercel Sandbox runtime",
|
||||
"options": ["node24", "node22", "python3.13"], # sync with _SUPPORTED_VERCEL_RUNTIMES in terminal_tool.py
|
||||
},
|
||||
"terminal.modal_mode": {
|
||||
"type": "select",
|
||||
@@ -338,6 +344,11 @@ _CATEGORY_MERGE: Dict[str, str] = {
|
||||
"human_delay": "display",
|
||||
"dashboard": "display",
|
||||
"code_execution": "agent",
|
||||
"prompt_caching": "agent",
|
||||
# Only `telegram.reactions` currently lives under telegram — fold it in
|
||||
# with the other messaging-platform config (discord) so it isn't an
|
||||
# orphan tab of one field.
|
||||
"telegram": "discord",
|
||||
}
|
||||
|
||||
# Display order for tabs — unlisted categories sort alphabetically after these.
|
||||
@@ -434,6 +445,20 @@ class EnvVarReveal(BaseModel):
|
||||
key: str
|
||||
|
||||
|
||||
class ModelAssignment(BaseModel):
|
||||
"""Payload for POST /api/model/set — assign a provider/model to a slot.
|
||||
|
||||
scope="main" → writes model.provider + model.default
|
||||
scope="auxiliary" → writes auxiliary.<task>.provider + auxiliary.<task>.model
|
||||
scope="auxiliary" with task="" → applied to every auxiliary.* slot
|
||||
scope="auxiliary" with task="__reset__" → resets every slot to provider="auto"
|
||||
"""
|
||||
scope: str
|
||||
provider: str
|
||||
model: str
|
||||
task: str = ""
|
||||
|
||||
|
||||
_GATEWAY_HEALTH_URL = os.getenv("GATEWAY_HEALTH_URL")
|
||||
try:
|
||||
_GATEWAY_HEALTH_TIMEOUT = float(os.getenv("GATEWAY_HEALTH_TIMEOUT", "3"))
|
||||
@@ -910,6 +935,207 @@ def get_model_info():
|
||||
return dict(_EMPTY_MODEL_INFO)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model assignment — pick provider+model for main slot or auxiliary slots.
|
||||
# Mirrors the model.options JSON-RPC from tui_gateway but uses REST so the
|
||||
# Models page (which has no chat PTY open) can drive it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Canonical auxiliary task slots. Keep in sync with DEFAULT_CONFIG["auxiliary"]
|
||||
# in hermes_cli/config.py — listed here for deterministic ordering in the UI.
|
||||
_AUX_TASK_SLOTS: Tuple[str, ...] = (
|
||||
"vision",
|
||||
"web_extract",
|
||||
"compression",
|
||||
"session_search",
|
||||
"skills_hub",
|
||||
"approval",
|
||||
"mcp",
|
||||
"title_generation",
|
||||
"curator",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/model/options")
|
||||
def get_model_options():
|
||||
"""Return authenticated providers + their curated model lists.
|
||||
|
||||
REST equivalent of the ``model.options`` JSON-RPC on tui_gateway, so the
|
||||
dashboard Models page can render the picker without a live chat session.
|
||||
The response shape matches ``model.options`` 1:1 so ``ModelPickerDialog``
|
||||
can share the same types.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
current_model = model_cfg.get("default", model_cfg.get("name", "")) or ""
|
||||
current_provider = model_cfg.get("provider", "") or ""
|
||||
current_base_url = model_cfg.get("base_url", "") or ""
|
||||
else:
|
||||
current_model = str(model_cfg) if model_cfg else ""
|
||||
current_provider = ""
|
||||
current_base_url = ""
|
||||
|
||||
user_providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
|
||||
custom_providers = (
|
||||
cfg.get("custom_providers")
|
||||
if isinstance(cfg.get("custom_providers"), list)
|
||||
else []
|
||||
)
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider=current_provider,
|
||||
current_base_url=current_base_url,
|
||||
current_model=current_model,
|
||||
user_providers=user_providers,
|
||||
custom_providers=custom_providers,
|
||||
max_models=50,
|
||||
)
|
||||
return {
|
||||
"providers": providers,
|
||||
"model": current_model,
|
||||
"provider": current_provider,
|
||||
}
|
||||
except Exception:
|
||||
_log.exception("GET /api/model/options failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to list model options")
|
||||
|
||||
|
||||
@app.get("/api/model/auxiliary")
|
||||
def get_auxiliary_models():
|
||||
"""Return current auxiliary task assignments.
|
||||
|
||||
Shape:
|
||||
{
|
||||
"tasks": [
|
||||
{"task": "vision", "provider": "auto", "model": "", "base_url": ""},
|
||||
...
|
||||
],
|
||||
"main": {"provider": "openrouter", "model": "anthropic/claude-opus-4.7"},
|
||||
}
|
||||
"""
|
||||
try:
|
||||
cfg = load_config()
|
||||
aux_cfg = cfg.get("auxiliary", {})
|
||||
if not isinstance(aux_cfg, dict):
|
||||
aux_cfg = {}
|
||||
|
||||
tasks = []
|
||||
for slot in _AUX_TASK_SLOTS:
|
||||
slot_cfg = aux_cfg.get(slot, {}) if isinstance(aux_cfg.get(slot), dict) else {}
|
||||
tasks.append({
|
||||
"task": slot,
|
||||
"provider": str(slot_cfg.get("provider", "auto") or "auto"),
|
||||
"model": str(slot_cfg.get("model", "") or ""),
|
||||
"base_url": str(slot_cfg.get("base_url", "") or ""),
|
||||
})
|
||||
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
main = {
|
||||
"provider": str(model_cfg.get("provider", "") or ""),
|
||||
"model": str(model_cfg.get("default", model_cfg.get("name", "")) or ""),
|
||||
}
|
||||
else:
|
||||
main = {"provider": "", "model": str(model_cfg) if model_cfg else ""}
|
||||
|
||||
return {"tasks": tasks, "main": main}
|
||||
except Exception:
|
||||
_log.exception("GET /api/model/auxiliary failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to read auxiliary config")
|
||||
|
||||
|
||||
@app.post("/api/model/set")
|
||||
async def set_model_assignment(body: ModelAssignment):
|
||||
"""Assign a model to the main slot or an auxiliary task slot.
|
||||
|
||||
Writes to ``~/.hermes/config.yaml`` — applies to **new** sessions only.
|
||||
The currently running chat PTY (if any) is not affected; use the
|
||||
``/model`` slash command inside a chat to hot-swap that specific session.
|
||||
"""
|
||||
scope = (body.scope or "").strip().lower()
|
||||
provider = (body.provider or "").strip()
|
||||
model = (body.model or "").strip()
|
||||
task = (body.task or "").strip().lower()
|
||||
|
||||
if scope not in ("main", "auxiliary"):
|
||||
raise HTTPException(status_code=400, detail="scope must be 'main' or 'auxiliary'")
|
||||
|
||||
try:
|
||||
cfg = load_config()
|
||||
|
||||
if scope == "main":
|
||||
if not provider or not model:
|
||||
raise HTTPException(status_code=400, detail="provider and model required for main")
|
||||
model_cfg = cfg.get("model", {})
|
||||
if not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
model_cfg["provider"] = provider
|
||||
model_cfg["default"] = model
|
||||
# Clear stale base_url so the resolver picks the provider's own default.
|
||||
if "base_url" in model_cfg and model_cfg.get("base_url"):
|
||||
model_cfg["base_url"] = ""
|
||||
# Also clear hardcoded context_length override — new model may have
|
||||
# a different context window.
|
||||
if "context_length" in model_cfg:
|
||||
model_cfg.pop("context_length", None)
|
||||
cfg["model"] = model_cfg
|
||||
save_config(cfg)
|
||||
return {"ok": True, "scope": "main", "provider": provider, "model": model}
|
||||
|
||||
# scope == "auxiliary"
|
||||
aux = cfg.get("auxiliary")
|
||||
if not isinstance(aux, dict):
|
||||
aux = {}
|
||||
|
||||
if task == "__reset__":
|
||||
# Reset every slot to provider="auto", model="" — keeps other fields intact.
|
||||
for slot in _AUX_TASK_SLOTS:
|
||||
slot_cfg = aux.get(slot)
|
||||
if not isinstance(slot_cfg, dict):
|
||||
slot_cfg = {}
|
||||
slot_cfg["provider"] = "auto"
|
||||
slot_cfg["model"] = ""
|
||||
aux[slot] = slot_cfg
|
||||
cfg["auxiliary"] = aux
|
||||
save_config(cfg)
|
||||
return {"ok": True, "scope": "auxiliary", "reset": True}
|
||||
|
||||
if not provider:
|
||||
raise HTTPException(status_code=400, detail="provider required for auxiliary")
|
||||
|
||||
targets = [task] if task else list(_AUX_TASK_SLOTS)
|
||||
for slot in targets:
|
||||
if slot not in _AUX_TASK_SLOTS:
|
||||
raise HTTPException(status_code=400, detail=f"unknown auxiliary task: {slot}")
|
||||
slot_cfg = aux.get(slot)
|
||||
if not isinstance(slot_cfg, dict):
|
||||
slot_cfg = {}
|
||||
slot_cfg["provider"] = provider
|
||||
slot_cfg["model"] = model
|
||||
aux[slot] = slot_cfg
|
||||
|
||||
cfg["auxiliary"] = aux
|
||||
save_config(cfg)
|
||||
return {
|
||||
"ok": True,
|
||||
"scope": "auxiliary",
|
||||
"tasks": targets,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
_log.exception("POST /api/model/set failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to save model assignment")
|
||||
|
||||
|
||||
|
||||
|
||||
def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Reverse _normalize_config_for_web before saving.
|
||||
|
||||
@@ -1214,6 +1440,14 @@ _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
|
||||
"docs_url": "https://github.com/QwenLM/qwen-code",
|
||||
"status_fn": None, # dispatched via auth.get_qwen_auth_status
|
||||
},
|
||||
{
|
||||
"id": "minimax-oauth",
|
||||
"name": "MiniMax (OAuth)",
|
||||
"flow": "pkce",
|
||||
"cli_command": "hermes auth add minimax-oauth",
|
||||
"docs_url": "https://www.minimax.io",
|
||||
"status_fn": None, # dispatched via auth.get_minimax_oauth_auth_status
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1257,6 +1491,16 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
|
||||
"expires_at": raw.get("expires_at"),
|
||||
"has_refresh_token": bool(raw.get("has_refresh_token")),
|
||||
}
|
||||
if provider_id == "minimax-oauth":
|
||||
raw = hauth.get_minimax_oauth_auth_status()
|
||||
return {
|
||||
"logged_in": bool(raw.get("logged_in")),
|
||||
"source": "minimax_oauth",
|
||||
"source_label": f"MiniMax ({raw.get('region', 'global')})",
|
||||
"token_preview": None,
|
||||
"expires_at": raw.get("expires_at"),
|
||||
"has_refresh_token": True,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"logged_in": False, "error": str(e)}
|
||||
return {"logged_in": False}
|
||||
@@ -2245,12 +2489,13 @@ async def open_profile_terminal_endpoint(name: str):
|
||||
command = _profile_setup_command(name)
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
subprocess.Popen(["cmd.exe", "/k", command])
|
||||
subprocess.Popen(["cmd.exe", "/c", "start", "", command])
|
||||
elif sys.platform == "darwin":
|
||||
escaped = command.replace("\\", "\\\\").replace('"', '\\"')
|
||||
applescript = (
|
||||
'tell application "Terminal"\n'
|
||||
"activate\n"
|
||||
f'do script "{command.replace("\\\\", "\\\\\\\\").replace(\'"\', \'\\\\"\')}"\n'
|
||||
f'do script "{escaped}"\n'
|
||||
"end tell"
|
||||
)
|
||||
subprocess.Popen(["osascript", "-e", applescript])
|
||||
@@ -2517,6 +2762,99 @@ async def get_usage_analytics(days: int = 30):
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/api/analytics/models")
|
||||
async def get_models_analytics(days: int = 30):
|
||||
"""Rich per-model analytics for the Models dashboard page.
|
||||
|
||||
Returns token/cost/session breakdown per model plus capability metadata
|
||||
from models.dev (context window, vision, tools, reasoning, etc.).
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
cutoff = time.time() - (days * 86400)
|
||||
|
||||
cur = db._conn.execute("""
|
||||
SELECT model,
|
||||
billing_provider,
|
||||
SUM(input_tokens) as input_tokens,
|
||||
SUM(output_tokens) as output_tokens,
|
||||
SUM(cache_read_tokens) as cache_read_tokens,
|
||||
SUM(reasoning_tokens) as reasoning_tokens,
|
||||
COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost,
|
||||
COALESCE(SUM(actual_cost_usd), 0) as actual_cost,
|
||||
COUNT(*) as sessions,
|
||||
SUM(COALESCE(api_call_count, 0)) as api_calls,
|
||||
SUM(tool_call_count) as tool_calls,
|
||||
MAX(started_at) as last_used_at,
|
||||
AVG(input_tokens + output_tokens) as avg_tokens_per_session
|
||||
FROM sessions WHERE started_at > ? AND model IS NOT NULL AND model != ''
|
||||
GROUP BY model, billing_provider
|
||||
ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC
|
||||
""", (cutoff,))
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
models = []
|
||||
for row in rows:
|
||||
provider = row.get("billing_provider") or ""
|
||||
model_name = row["model"]
|
||||
caps = {}
|
||||
try:
|
||||
from agent.models_dev import get_model_capabilities
|
||||
mc = get_model_capabilities(provider=provider, model=model_name)
|
||||
if mc is not None:
|
||||
caps = {
|
||||
"supports_tools": mc.supports_tools,
|
||||
"supports_vision": mc.supports_vision,
|
||||
"supports_reasoning": mc.supports_reasoning,
|
||||
"context_window": mc.context_window,
|
||||
"max_output_tokens": mc.max_output_tokens,
|
||||
"model_family": mc.model_family,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
models.append({
|
||||
"model": model_name,
|
||||
"provider": provider,
|
||||
"input_tokens": row["input_tokens"],
|
||||
"output_tokens": row["output_tokens"],
|
||||
"cache_read_tokens": row["cache_read_tokens"],
|
||||
"reasoning_tokens": row["reasoning_tokens"],
|
||||
"estimated_cost": row["estimated_cost"],
|
||||
"actual_cost": row["actual_cost"],
|
||||
"sessions": row["sessions"],
|
||||
"api_calls": row["api_calls"],
|
||||
"tool_calls": row["tool_calls"],
|
||||
"last_used_at": row["last_used_at"],
|
||||
"avg_tokens_per_session": row["avg_tokens_per_session"],
|
||||
"capabilities": caps,
|
||||
})
|
||||
|
||||
totals_cur = db._conn.execute("""
|
||||
SELECT COUNT(DISTINCT model) as distinct_models,
|
||||
SUM(input_tokens) as total_input,
|
||||
SUM(output_tokens) as total_output,
|
||||
SUM(cache_read_tokens) as total_cache_read,
|
||||
SUM(reasoning_tokens) as total_reasoning,
|
||||
COALESCE(SUM(estimated_cost_usd), 0) as total_estimated_cost,
|
||||
COALESCE(SUM(actual_cost_usd), 0) as total_actual_cost,
|
||||
COUNT(*) as total_sessions,
|
||||
SUM(COALESCE(api_call_count, 0)) as total_api_calls
|
||||
FROM sessions WHERE started_at > ? AND model IS NOT NULL AND model != ''
|
||||
""", (cutoff,))
|
||||
totals = dict(totals_cur.fetchone())
|
||||
|
||||
return {
|
||||
"models": models,
|
||||
"totals": totals,
|
||||
"period_days": days,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/pty — PTY-over-WebSocket bridge for the dashboard "Chat" tab.
|
||||
#
|
||||
@@ -3149,7 +3487,7 @@ async def get_dashboard_themes():
|
||||
them without a stub.
|
||||
"""
|
||||
config = load_config()
|
||||
active = config.get("dashboard", {}).get("theme", "default")
|
||||
active = cfg_get(config, "dashboard", "theme", default="default")
|
||||
user_themes = _discover_user_themes()
|
||||
seen = set()
|
||||
themes = []
|
||||
@@ -3199,10 +3537,12 @@ def _discover_dashboard_plugins() -> list:
|
||||
plugins = []
|
||||
seen_names: set = set()
|
||||
|
||||
from hermes_cli.plugins import get_bundled_plugins_dir
|
||||
bundled_root = get_bundled_plugins_dir()
|
||||
search_dirs = [
|
||||
(get_hermes_home() / "plugins", "user"),
|
||||
(PROJECT_ROOT / "plugins" / "memory", "bundled"),
|
||||
(PROJECT_ROOT / "plugins", "bundled"),
|
||||
(bundled_root / "memory", "bundled"),
|
||||
(bundled_root, "bundled"),
|
||||
]
|
||||
if os.environ.get("HERMES_ENABLE_PROJECT_PLUGINS"):
|
||||
search_dirs.append((Path.cwd() / ".hermes" / "plugins", "project"))
|
||||
@@ -3347,13 +3687,23 @@ def _mount_plugin_api_routes():
|
||||
_log.warning("Plugin %s declares api=%s but file not found", plugin["name"], api_file_name)
|
||||
continue
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"hermes_dashboard_plugin_{plugin['name']}", api_path,
|
||||
)
|
||||
module_name = f"hermes_dashboard_plugin_{plugin['name']}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, api_path)
|
||||
if spec is None or spec.loader is None:
|
||||
continue
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
# Register in sys.modules BEFORE exec_module so pydantic/FastAPI
|
||||
# can resolve forward references (e.g. models defined in a file
|
||||
# that uses `from __future__ import annotations`). Without this,
|
||||
# TypeAdapter lazy-build fails at first request with
|
||||
# "is not fully defined" because the module namespace isn't
|
||||
# reachable by name for string-annotation resolution.
|
||||
sys.modules[module_name] = mod
|
||||
try:
|
||||
spec.loader.exec_module(mod)
|
||||
except Exception:
|
||||
sys.modules.pop(module_name, None)
|
||||
raise
|
||||
router = getattr(mod, "router", None)
|
||||
if router is None:
|
||||
_log.warning("Plugin %s api file has no 'router' attribute", plugin["name"])
|
||||
|
||||
@@ -19,6 +19,7 @@ from typing import Dict
|
||||
|
||||
from hermes_constants import display_hermes_home
|
||||
from utils import atomic_replace
|
||||
from hermes_cli.config import cfg_get
|
||||
|
||||
|
||||
_SUBSCRIPTIONS_FILENAME = "webhook_subscriptions.json"
|
||||
@@ -60,7 +61,7 @@ def _get_webhook_config() -> dict:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
return cfg.get("platforms", {}).get("webhook", {})
|
||||
return cfg_get(cfg, "platforms", "webhook", default={})
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user