Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
"""CLI subcommand parser builders for ``hermes <subcommand>``.
``hermes_cli/main.py:main()`` historically built the entire argparse tree
inline — 179 ``add_parser`` calls across ~26 subcommand groups, all wedged
into one 3,300-line function. This package breaks that tree apart: each
subcommand group owns a ``build_<group>_parser(subparsers, ...)`` function in
its own module, and ``main()`` calls those builders instead of inlining the
argument definitions.
Handlers (the ``cmd_*`` functions) still live in ``main.py`` for now and are
dependency-injected into the builders so these modules never import ``main``
(which would create a cycle). Shared parser helpers live in
``_shared.py``.
Part of the god-file decomposition plan (Phase 2).
"""
from __future__ import annotations
+29
View File
@@ -0,0 +1,29 @@
"""Shared parser helpers used across multiple CLI subcommand builders.
These were module-level helpers in ``hermes_cli/main.py``. They are pulled
into a neutral module so both ``main.py`` and every
``hermes_cli/subcommands/<group>.py`` builder can import them without an
import cycle. ``main.py`` re-exports them for backwards compatibility, so
existing references keep working.
"""
from __future__ import annotations
import argparse
def add_accept_hooks_flag(parser: argparse.ArgumentParser) -> None:
"""Attach the ``--accept-hooks`` flag.
Shared across every agent subparser so the flag works regardless of CLI
position.
"""
parser.add_argument(
"--accept-hooks",
action="store_true",
default=argparse.SUPPRESS,
help=(
"Auto-approve unseen shell hooks without a TTY prompt "
"(equivalent to HERMES_ACCEPT_HOOKS=1 / hooks_auto_accept: true)."
),
)
+52
View File
@@ -0,0 +1,52 @@
"""``hermes acp`` subcommand parser.
Extracted from ``hermes_cli/main.py:main()`` (god-file Phase 2 follow-up).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
from hermes_cli.subcommands._shared import add_accept_hooks_flag
def build_acp_parser(subparsers, *, cmd_acp: Callable) -> None:
"""Attach the ``acp`` subcommand to ``subparsers``."""
acp_parser = subparsers.add_parser(
"acp",
help="Run Hermes Agent as an ACP (Agent Client Protocol) server",
description="Start Hermes Agent in ACP mode for editor integration (VS Code, Zed, JetBrains)",
)
add_accept_hooks_flag(acp_parser)
acp_parser.add_argument(
"--version",
action="store_true",
dest="acp_version",
help="Print Hermes ACP version and exit",
)
acp_parser.add_argument(
"--check",
action="store_true",
help="Verify ACP dependencies and adapter imports, then exit",
)
acp_parser.add_argument(
"--setup",
action="store_true",
help="Run interactive Hermes provider/model setup for ACP terminal auth",
)
acp_parser.add_argument(
"--setup-browser",
action="store_true",
help="Install agent-browser + Playwright Chromium into ~/.hermes/node/ "
"for browser tool support (idempotent).",
)
acp_parser.add_argument(
"--yes",
"-y",
action="store_true",
dest="assume_yes",
help="Accept all prompts (used by --setup-browser to skip the "
"~400 MB Chromium download confirmation).",
)
acp_parser.set_defaults(func=cmd_acp)
+115
View File
@@ -0,0 +1,115 @@
"""``hermes approvals`` subcommand parser.
Follows the cron/security pattern: parser construction lives here, the
handler is injected by ``main.py`` so this module never imports ``main``
(cycle avoidance).
"""
from __future__ import annotations
import argparse
from typing import Callable
def build_approvals_parser(subparsers, *, cmd_approvals: Callable) -> None:
"""Attach the ``approvals`` subcommand to ``subparsers``."""
approvals_parser = subparsers.add_parser(
"approvals",
help="Approval-prompt tools (mine history into allowlist proposals)",
description=(
"Tools for the dangerous-command approval system. "
"`hermes approvals suggest` mines past approval decisions from "
"the session database and proposes command_allowlist entries so "
"repeatedly-approved commands stop prompting."
),
)
approvals_subparsers = approvals_parser.add_subparsers(
dest="approvals_command",
metavar="<subcommand>",
)
suggest_parser = approvals_subparsers.add_parser(
"suggest",
help="Propose command_allowlist entries from past approvals",
description=(
"Scan the session database for dangerous-classified commands "
"that ran with user approval, rank the recurring patterns, and "
"print a numbered allowlist proposal. Nothing is written unless "
"--apply is given. Destructive classes (recursive delete, sudo, "
"disk writes, credential edits, ...) are never proposed."
),
)
suggest_parser.add_argument(
"--apply",
dest="apply_indices",
metavar="N[,M...]",
help="Merge the numbered proposals (from a prior run) into "
"command_allowlist in config.yaml",
)
suggest_parser.add_argument(
"--json",
action="store_true",
help="Emit machine-readable JSON instead of human-readable text",
)
suggest_parser.add_argument(
"--days",
type=int,
default=90,
help="How far back to scan session history (default: 90; 0 = all)",
)
suggest_parser.add_argument(
"--min-count",
dest="min_count",
type=int,
default=2,
help="Minimum approval count for a pattern to be proposed (default: 2)",
)
suggest_parser.add_argument(
"--limit",
type=int,
default=20,
help="Maximum number of proposals to show (default: 20)",
)
suggest_parser.add_argument(
"--db",
help="Path to an alternate session database (default: ~/.hermes/state.db)",
)
suggest_parser.set_defaults(func=cmd_approvals)
test_parser = approvals_subparsers.add_parser(
"test",
help="Dry-run the approval verdict for a command (never executes it)",
description=(
"Evaluate a command against the REAL runtime approval guards — "
"hardline blocklist, user approvals.deny rules, dangerous-pattern "
"detection, allowlist, yolo/off bypass — and print the verdict, "
"the matching rule, and the normalized-command trace, without "
"executing the command, prompting anyone, or persisting anything. "
"Exit codes: 0 allow, 2 ask-approval, 3 deny (hardline or user "
"deny rule). Tip: use `--` before the command so its own flags "
"aren't parsed: hermes approvals test -- rm -rf /tmp/x"
),
)
test_parser.add_argument(
"--env-type",
dest="env_type",
default="local",
help="Terminal backend type to evaluate against (default: local; "
"isolated container backends like docker skip the guards)",
)
test_parser.add_argument(
"--json",
action="store_true",
help="Emit machine-readable JSON instead of human-readable text",
)
test_parser.add_argument(
"command_words",
nargs=argparse.REMAINDER,
metavar="command",
# NOTE: dest must NOT be "command" — main.py's startup path reads
# args.command as the top-level subcommand name ("approvals").
help="The command to evaluate (prefix with -- to protect its flags)",
)
test_parser.set_defaults(func=cmd_approvals)
approvals_parser.set_defaults(func=cmd_approvals)
+98
View File
@@ -0,0 +1,98 @@
"""``hermes auth`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_auth_parser(subparsers, *, cmd_auth: Callable) -> None:
"""Attach the ``auth`` subcommand to ``subparsers``."""
auth_parser = subparsers.add_parser(
"auth",
help="Manage pooled provider credentials",
)
auth_subparsers = auth_parser.add_subparsers(dest="auth_action")
auth_add = auth_subparsers.add_parser("add", help="Add a pooled credential")
auth_add.add_argument(
"provider",
help="Provider id (for example: anthropic, openai-codex, openrouter)",
)
auth_add.add_argument(
"--type",
dest="auth_type",
choices=["oauth", "api-key", "api_key"],
help="Credential type to add",
)
auth_add.add_argument("--label", help="Optional display label")
auth_add.add_argument(
"--api-key", help="API key value (otherwise prompted securely)"
)
auth_add.add_argument("--portal-url", help="Nous portal base URL")
auth_add.add_argument("--inference-url", help="Nous inference base URL")
auth_add.add_argument("--client-id", help="OAuth client id")
auth_add.add_argument("--scope", help="OAuth scope override")
auth_add.add_argument(
"--no-browser",
action="store_true",
help="Do not auto-open a browser for OAuth login",
)
auth_add.add_argument(
"--timeout", type=float, help="OAuth/network timeout in seconds"
)
auth_add.add_argument(
"--insecure",
action="store_true",
help="Disable TLS verification for OAuth login",
)
auth_add.add_argument("--ca-bundle", help="Custom CA bundle for OAuth login")
auth_list = auth_subparsers.add_parser("list", help="List pooled credentials")
auth_list.add_argument("provider", nargs="?", help="Optional provider filter")
auth_remove = auth_subparsers.add_parser(
"remove", help="Remove a pooled credential by index, id, or label"
)
auth_remove.add_argument("provider", help="Provider id")
auth_remove.add_argument(
"target", help="Credential index, entry id, or exact label"
)
auth_reset = auth_subparsers.add_parser(
"reset", help="Clear exhaustion status for all credentials for a provider"
)
auth_reset.add_argument("provider", help="Provider id")
auth_status = auth_subparsers.add_parser(
"status", help="Show auth status for a provider"
)
auth_status.add_argument("provider", help="Provider id")
auth_logout = auth_subparsers.add_parser(
"logout", help="Log out a provider and clear stored auth state"
)
auth_logout.add_argument("provider", help="Provider id")
auth_spotify = auth_subparsers.add_parser(
"spotify", help="Authenticate Hermes with Spotify via PKCE"
)
auth_spotify.add_argument(
"spotify_action",
nargs="?",
choices=["login", "status", "logout"],
default="login",
)
auth_spotify.add_argument(
"--client-id", help="Spotify app client_id (or set HERMES_SPOTIFY_CLIENT_ID)"
)
auth_spotify.add_argument(
"--redirect-uri",
help="Allow-listed localhost redirect URI for your Spotify app",
)
auth_spotify.add_argument("--scope", help="Override requested Spotify scopes")
auth_spotify.add_argument(
"--no-browser",
action="store_true",
help="Do not attempt to open the browser automatically",
)
auth_spotify.add_argument(
"--timeout", type=float, help="Callback/token exchange timeout in seconds"
)
auth_parser.set_defaults(func=cmd_auth)
+38
View File
@@ -0,0 +1,38 @@
"""``hermes backup`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_backup_parser(subparsers, *, cmd_backup: Callable) -> None:
"""Attach the ``backup`` subcommand to ``subparsers``."""
# =========================================================================
# backup command
# =========================================================================
backup_parser = subparsers.add_parser(
"backup",
help="Back up Hermes home directory to a zip file",
description="Create a zip archive of your entire Hermes configuration, "
"skills, sessions, and data (excludes the hermes-agent codebase). "
"Use --quick for a fast snapshot of just critical state files.",
)
backup_parser.add_argument(
"-o",
"--output",
help="Output path for the zip file (default: ~/hermes-backup-<timestamp>.zip)",
)
backup_parser.add_argument(
"-q",
"--quick",
action="store_true",
help="Quick snapshot: only critical state files (config, state.db, .env, auth, cron)",
)
backup_parser.add_argument(
"-l", "--label", help="Label for the snapshot (only used with --quick)"
)
backup_parser.set_defaults(func=cmd_backup)
+92
View File
@@ -0,0 +1,92 @@
"""``hermes claw`` subcommand parser.
Extracted from ``hermes_cli/main.py:main()`` (god-file Phase 2 follow-up).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_claw_parser(subparsers, *, cmd_claw: Callable) -> None:
"""Attach the ``claw`` subcommand to ``subparsers``."""
claw_parser = subparsers.add_parser(
"claw",
help="OpenClaw migration tools",
description="Migrate settings, memories, skills, and API keys from OpenClaw to Hermes",
)
claw_subparsers = claw_parser.add_subparsers(dest="claw_action")
# claw migrate
claw_migrate = claw_subparsers.add_parser(
"migrate",
help="Migrate from OpenClaw to Hermes",
description="Import settings, memories, skills, and API keys from an OpenClaw installation. "
"Always shows a preview before making changes.",
)
claw_migrate.add_argument(
"--source", help="Path to OpenClaw directory (default: ~/.openclaw)"
)
claw_migrate.add_argument(
"--dry-run",
action="store_true",
help="Preview only — stop after showing what would be migrated",
)
claw_migrate.add_argument(
"--preset",
choices=["user-data", "full"],
default="full",
help="Migration preset (default: full). Neither preset imports secrets — "
"pass --migrate-secrets to include API keys.",
)
claw_migrate.add_argument(
"--overwrite",
action="store_true",
help="Overwrite existing files (default: refuse to apply when the plan has conflicts)",
)
claw_migrate.add_argument(
"--migrate-secrets",
action="store_true",
help="Include allowlisted secrets (TELEGRAM_BOT_TOKEN, API keys, etc.). "
"Required even under --preset full.",
)
claw_migrate.add_argument(
"--no-backup",
action="store_true",
help="Skip the pre-migration zip snapshot of ~/.hermes/ (by default a "
"single restore-point archive is written to ~/.hermes/backups/ "
"before apply; restorable with 'hermes import').",
)
claw_migrate.add_argument(
"--workspace-target", help="Absolute path to copy workspace instructions into"
)
claw_migrate.add_argument(
"--skill-conflict",
choices=["skip", "overwrite", "rename"],
default="skip",
help="How to handle skill name conflicts (default: skip)",
)
claw_migrate.add_argument(
"--yes", "-y", action="store_true", help="Skip confirmation prompts"
)
# claw cleanup
claw_cleanup = claw_subparsers.add_parser(
"cleanup",
aliases=["clean"],
help="Archive leftover OpenClaw directories after migration",
description="Scan for and archive leftover OpenClaw directories to prevent state fragmentation",
)
claw_cleanup.add_argument(
"--source", help="Path to a specific OpenClaw directory to clean up"
)
claw_cleanup.add_argument(
"--dry-run",
action="store_true",
help="Preview what would be archived without making changes",
)
claw_cleanup.add_argument(
"--yes", "-y", action="store_true", help="Skip confirmation prompts"
)
claw_parser.set_defaults(func=cmd_claw)
+68
View File
@@ -0,0 +1,68 @@
"""``hermes config`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_config_parser(subparsers, *, cmd_config: Callable) -> None:
"""Attach the ``config`` subcommand to ``subparsers``."""
# =========================================================================
# config command
# =========================================================================
config_parser = subparsers.add_parser(
"config",
help="View and edit configuration",
description="Manage Hermes Agent configuration",
)
config_subparsers = config_parser.add_subparsers(dest="config_command")
# config show (default)
config_subparsers.add_parser("show", help="Show current configuration")
# config edit
config_subparsers.add_parser("edit", help="Open config file in editor")
# config get
config_get = config_subparsers.add_parser(
"get", help="Print a resolved configuration value"
)
config_get.add_argument("key", nargs="?", help="Configuration key (e.g., model)")
config_get.add_argument("--json", action="store_true", help="Print value as JSON")
# config set
config_set = config_subparsers.add_parser("set", help="Set a configuration value")
config_set.add_argument(
"key", nargs="?", help="Configuration key (e.g., model, terminal.backend)"
)
config_set.add_argument("value", nargs="?", help="Value to set")
config_set.add_argument(
"--force",
action="store_true",
help="Skip the unknown-key notice printed after writing a key the "
"running version doesn't recognize (the value is saved either way).",
)
# config unset
config_unset = config_subparsers.add_parser(
"unset", help="Remove a configuration value"
)
config_unset.add_argument("key", nargs="?", help="Configuration key to remove")
# config path
config_subparsers.add_parser("path", help="Print config file path")
# config env-path
config_subparsers.add_parser("env-path", help="Print .env file path")
# config check
config_subparsers.add_parser("check", help="Check for missing/outdated config")
# config migrate
config_subparsers.add_parser("migrate", help="Update config with new options")
config_parser.set_defaults(func=cmd_config)
+18
View File
@@ -0,0 +1,18 @@
"""``hermes console`` subcommand parser."""
from __future__ import annotations
from typing import Callable
def build_console_parser(subparsers, *, cmd_console: Callable) -> None:
"""Attach the safe Hermes Console REPL subcommand."""
console_parser = subparsers.add_parser(
"console",
help="Open the safe Hermes command console",
description=(
"Open a curated Hermes command REPL. This is not a raw shell and "
"does not expose the full Hermes CLI."
),
)
console_parser.set_defaults(func=cmd_console)
+349
View File
@@ -0,0 +1,349 @@
"""``hermes cron`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` — same arguments, same
``func=cmd_cron`` dispatch. The handler is injected so this module does not
import ``main`` (cycle avoidance).
"""
from __future__ import annotations
from typing import Callable
from hermes_cli.subcommands._shared import add_accept_hooks_flag
def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None:
"""Attach the ``cron`` subcommand (and its sub-actions) to ``subparsers``."""
cron_parser = subparsers.add_parser(
"cron", help="Cron job management", description="Manage scheduled tasks"
)
cron_subparsers = cron_parser.add_subparsers(dest="cron_command")
# cron list
cron_list = cron_subparsers.add_parser("list", help="List scheduled jobs")
cron_list.add_argument("--all", action="store_true", help="Include disabled jobs")
# cron create/add
cron_create = cron_subparsers.add_parser(
"create", aliases=["add"], help="Create a scheduled job"
)
cron_create.add_argument(
"schedule", help="Schedule like '30m', 'every 2h', or '0 9 * * *'"
)
cron_create.add_argument(
"prompt", nargs="?", help="Optional self-contained prompt or task instruction"
)
cron_create.add_argument("--name", help="Optional human-friendly job name")
cron_create.add_argument(
"--deliver",
help=(
"Delivery target: origin, local, telegram, discord, signal, "
"platform:chat_id, or bot-chat[:profile] (inject output into a "
"local profile's canonical Bot Chat as a message the bot responds to)"
),
)
cron_create.add_argument(
"--failure-deliver",
dest="failure_deliver",
help=(
"Override target for FAILURE notices only (same grammar as "
"--deliver). 'local' suppresses failure notices entirely; run "
"state stays visible in `hermes cron list`. Omit = failures "
"follow --deliver."
),
)
cron_create.add_argument("--repeat", type=int, help="Optional repeat count")
cron_create.add_argument(
"--skill",
dest="skills",
action="append",
help="Attach a skill. Repeat to add multiple skills.",
)
cron_create.add_argument(
"--script",
help=(
"Path to a script under ~/.hermes/scripts/. Default mode: "
"script stdout is injected into the agent's prompt each run. "
"With --no-agent: the script IS the job and its stdout is "
"delivered verbatim. .sh/.bash files run via bash, everything "
"else via Python."
),
)
cron_create.add_argument(
"--no-agent",
dest="no_agent",
action="store_true",
default=False,
help=(
"Skip the LLM entirely — run --script on schedule and deliver "
"its stdout directly. Empty stdout = silent. Classic watchdog "
"pattern (memory alerts, disk alerts, CI pings)."
),
)
cron_create.add_argument(
"--monitor-script",
dest="monitor_script",
help=(
"Monitor mode: path to a cheap source script under "
"~/.hermes/scripts/ that runs each tick BEFORE the agent. "
"Unchanged output (exact-bytes hash) suppresses the agent run "
"entirely; changed output injects a MONITOR CHANGE DETECTED "
"diff into the prompt. Script output must be stable (no "
"timestamps). Mutually exclusive with --monitor-url; "
"incompatible with --no-agent."
),
)
cron_create.add_argument(
"--monitor-url",
dest="monitor_url",
help=(
"Monitor mode: http(s) URL fetched with a bounded GET each tick "
"instead of a script. Same hash-suppression semantics as "
"--monitor-script."
),
)
cron_create.add_argument(
"--workdir",
help="Absolute path for the job to run from. Injects AGENTS.md / CLAUDE.md / .cursorrules from that directory and uses it as the cwd for terminal/file/code_exec tools. Omit to preserve old behaviour (no project context files).",
)
cron_create.add_argument(
"--model",
help=(
"Pin this job to a specific inference model (user-owned; the "
"agent's cronjob tool cannot set this). Omit to follow "
"cron.model / model.default from config.yaml."
),
)
cron_create.add_argument(
"--provider",
dest="model_provider",
help="Inference provider paired with --model (e.g. 'openrouter', 'nous').",
)
cron_create.add_argument(
"--reasoning-effort",
dest="reasoning_effort",
help=(
"Pin this job's reasoning (thinking) effort: none, minimal, low, "
"medium, high, xhigh, max, or ultra. Overrides agent.reasoning_effort "
"and agent.reasoning_overrides for this job; unsupported levels are "
"clamped by the provider at request time. Omit to follow config."
),
)
cron_create.add_argument(
"--continuity",
dest="continuity",
action="store_const",
const=True,
default=None,
help=(
"Each run wakes up with the job's own previous output injected "
"into its prompt, so it can dedupe against what was already "
"reported and continue where the last run left off (scouts, "
"monitors, incremental digests). First run is unchanged."
),
)
# cron edit
cron_edit = cron_subparsers.add_parser(
"edit", help="Edit an existing scheduled job"
)
cron_edit.add_argument("job_id", help="Job ID to edit")
cron_edit.add_argument("--schedule", help="New schedule")
cron_edit.add_argument("--prompt", help="New prompt/task instruction")
cron_edit.add_argument("--name", help="New job name")
cron_edit.add_argument("--deliver", help="New delivery target")
cron_edit.add_argument(
"--failure-deliver",
dest="failure_deliver",
help=(
"Override target for failure notices (same grammar as --deliver; "
"'local' suppresses; '' clears the override)"
),
)
cron_edit.add_argument("--repeat", type=int, help="New repeat count")
cron_edit.add_argument(
"--skill",
dest="skills",
action="append",
help="Replace the job's skills with this set. Repeat to attach multiple skills.",
)
cron_edit.add_argument(
"--add-skill",
dest="add_skills",
action="append",
help="Append a skill without replacing the existing list. Repeatable.",
)
cron_edit.add_argument(
"--remove-skill",
dest="remove_skills",
action="append",
help="Remove a specific attached skill. Repeatable.",
)
cron_edit.add_argument(
"--clear-skills",
action="store_true",
help="Remove all attached skills from the job",
)
cron_edit.add_argument(
"--script",
help=(
"Path to a script under ~/.hermes/scripts/. Pass empty string to clear. "
"With --no-agent the script IS the job; otherwise its stdout is "
"injected into the agent's prompt each run."
),
)
cron_edit.add_argument(
"--no-agent",
dest="no_agent",
action="store_const",
const=True,
default=None,
help=(
"Enable no-agent mode on this job (requires --script or an "
"existing script on the job)."
),
)
cron_edit.add_argument(
"--agent",
dest="no_agent",
action="store_const",
const=False,
help="Disable no-agent mode on this job (reverts to LLM-driven execution).",
)
cron_edit.add_argument(
"--continuity",
dest="continuity",
action="store_const",
const=True,
default=None,
help=(
"Turn on run-to-run continuity: each run sees the job's own "
"previous output (dedupe, continue where it left off)."
),
)
cron_edit.add_argument(
"--no-continuity",
dest="continuity",
action="store_const",
const=False,
help=(
"Turn off run-to-run continuity (other context_from job refs "
"are preserved)."
),
)
cron_edit.add_argument(
"--monitor-script",
dest="monitor_script",
help=(
"Set/replace the monitor source script (see `hermes cron create "
"--monitor-script`). Pass empty string to clear."
),
)
cron_edit.add_argument(
"--monitor-url",
dest="monitor_url",
help=(
"Set/replace the monitor source URL. Pass empty string to clear."
),
)
cron_edit.add_argument(
"--workdir",
help="Absolute path for the job to run from (injects AGENTS.md etc. and sets terminal cwd). Pass empty string to clear.",
)
cron_edit.add_argument(
"--model",
help=(
"Pin this job to a specific inference model (user-owned; the "
"agent's cronjob tool cannot set this). Pass empty string to "
"clear the pin and follow cron.model / model.default."
),
)
cron_edit.add_argument(
"--provider",
dest="model_provider",
help="Inference provider paired with --model. Pass empty string to clear.",
)
cron_edit.add_argument(
"--reasoning-effort",
dest="reasoning_effort",
help=(
"Pin this job's reasoning (thinking) effort: none, minimal, low, "
"medium, high, xhigh, max, or ultra. Pass empty string to clear "
"the pin and follow config resolution."
),
)
# lifecycle actions
cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job")
cron_pause.add_argument("job_id", help="Job ID to pause")
cron_resume = cron_subparsers.add_parser("resume", help="Resume a paused job")
cron_resume.add_argument("job_id", help="Job ID to resume")
cron_resume.add_argument("--at", dest="run_at", help="Re-arm at an ISO-8601 time")
cron_resume.add_argument("--run-now", action="store_true", help="Re-arm to run now")
cron_run = cron_subparsers.add_parser(
"run", help="Run a job on the next scheduler tick"
)
cron_run.add_argument("job_id", help="Job ID to trigger")
add_accept_hooks_flag(cron_run)
cron_remove = cron_subparsers.add_parser(
"remove", aliases=["rm", "delete"], help="Remove a scheduled job"
)
cron_remove.add_argument("job_id", help="Job ID to remove")
# cron status
cron_subparsers.add_parser("status", help="Check if cron scheduler is running")
cron_runs = cron_subparsers.add_parser(
"runs", aliases=["history"], help="Show durable execution attempts"
)
cron_runs.add_argument("job_id", nargs="?", help="Optional job ID filter")
cron_runs.add_argument("--limit", type=int, default=20, help="Rows to show (1-500)")
# cron incidents — durable failure incidents (list/ack)
cron_incidents = cron_subparsers.add_parser(
"incidents", help="List or acknowledge durable cron failure incidents"
)
cron_incidents.add_argument(
"--state",
choices=["detected", "alerted", "closed"],
help="Filter incidents by lifecycle state",
)
cron_incidents.add_argument(
"incident_action",
nargs="?",
default="list",
choices=["list", "ack"],
help="Action (default: list)",
)
cron_incidents.add_argument(
"incident_id", nargs="?", help="Incident ID to acknowledge (ack)"
)
# cron notepad — per-job durable KV scratchpad (injected into the job
# prompt each run; the running agent writes it via this CLI).
cron_notepad = cron_subparsers.add_parser(
"notepad",
help="Read/write a job's durable notepad (persistent KV across runs)",
)
cron_notepad.add_argument("job_id", help="Job ID the notepad belongs to")
cron_notepad.add_argument(
"notepad_action",
nargs="?",
default="list",
choices=["get", "set", "delete", "list"],
help="Action (default: list)",
)
cron_notepad.add_argument("key", nargs="?", help="Notepad key (get/set/delete)")
cron_notepad.add_argument("value", nargs="?", help="Value to store (set)")
# cron doctor
cron_subparsers.add_parser("doctor", help="Check scheduled jobs for common health issues")
# cron tick (mostly for debugging)
cron_tick = cron_subparsers.add_parser("tick", help="Run due jobs once and exit")
add_accept_hooks_flag(cron_tick)
add_accept_hooks_flag(cron_parser)
cron_parser.set_defaults(func=cmd_cron)
+243
View File
@@ -0,0 +1,243 @@
"""``hermes dashboard`` / ``hermes serve`` subcommand parsers.
``dashboard`` is the browser web UI; ``serve`` is the same gateway, headless —
what the desktop app and remote backends run. ``serve`` also skips the web UI
build (``headless_backend=True``): pure JSON-RPC/WS clients never load the SPA.
Both share one handler (``cmd_dashboard`` → ``start_server``). Extracted from
``hermes_cli/main.py:main()`` (god-file Phase 2); handler injected to avoid
importing ``main``.
"""
from __future__ import annotations
import argparse
from typing import Callable
def _add_server_runtime_args(parser) -> None:
"""Attach the runtime flags shared by ``dashboard`` and ``serve``.
Both subcommands boot the *same* ``web_server.start_server`` (the
JSON-RPC/WebSocket gateway). ``dashboard`` opens a browser UI on top of
it; ``serve`` is the headless backend the desktop app and remote clients
connect to. The shared server logic lives in one place — only the
browser-opening behavior and help framing differ.
"""
parser.add_argument(
"--port", type=int, default=9119, help="Port (default 9119, 0 for auto-assign by OS)"
)
parser.add_argument(
"--host", default="127.0.0.1", help="Host (default 127.0.0.1)"
)
parser.add_argument(
"--insecure",
action="store_true",
help=(
"DEPRECATED / NO-OP. Formerly bypassed auth on a non-loopback "
"bind. As of the June 2026 hardening it no longer disables "
"authentication — a public bind always requires an auth provider "
"(password or OAuth). Bind 127.0.0.1 + tunnel to keep it local."
),
)
parser.add_argument(
"--skip-build",
action="store_true",
help=(
"Skip the web UI build step and serve the existing dist directly. "
"Useful for non-interactive contexts (Windows Scheduled Tasks, CI) "
"where npm may not be available. Pre-build with: cd web && npm run build"
),
)
parser.add_argument(
"--isolated",
action="store_true",
help=(
"When launched from a named profile, run a dedicated server scoped "
"to that profile instead of routing to the machine-level server. "
"Default behavior is unified: profile launches attach to (or start) "
"ONE machine-level server and preselect the profile."
),
)
# Internal flag set by the unified-launch re-exec (cmd_dashboard) to
# preselect the launching profile in the SPA switcher. Hidden from --help.
parser.add_argument(
"--open-profile",
dest="open_profile",
default="",
help=argparse.SUPPRESS,
)
# 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 server has no
# service manager and no PID file, so these scan the process table for
# `hermes dashboard` / `hermes serve` cmdlines and SIGTERM them directly —
# the same path `hermes update` uses to clean up stale servers.
parser.add_argument(
"--stop",
action="store_true",
help="Stop all running Hermes web server processes and exit",
)
parser.add_argument(
"--status",
action="store_true",
help="List running Hermes web server processes and exit",
)
def _configure_serve_parser(parser, *, cmd_dashboard: Callable) -> None:
"""Attach the canonical ``serve`` arguments to *parser*.
Kept separate from the full subcommand tree so Desktop's hot path can parse
only the command it launches. Both callers use this exact function, keeping
the lean parser and normal CLI semantics in lockstep.
"""
_add_server_runtime_args(parser)
# Accepted but redundant: ``serve`` is always headless. Kept so callers
# using the legacy flag do not trip an argparse error.
parser.add_argument("--no-open", action="store_true", help=argparse.SUPPRESS)
parser.add_argument(
"--ssh-session-token-file",
dest="ssh_session_token_file",
metavar="PATH",
default=None,
help="Read a one-shot Desktop SSH session token from PATH",
)
parser.add_argument(
"--ssh-owner-nonce",
dest="ssh_owner_nonce",
metavar="NONCE",
default=None,
help="Identify a Desktop-owned SSH backend process",
)
parser.set_defaults(
func=cmd_dashboard,
no_open=True,
headless_backend=True,
command="serve",
)
def build_serve_parser(
*,
cmd_dashboard: Callable,
add_help: bool = True,
exit_on_error: bool = True,
) -> argparse.ArgumentParser:
"""Build the standalone parser used by the lean ``serve`` dispatch path."""
parser = argparse.ArgumentParser(
prog="hermes serve",
description=(
"Run the Hermes backend server - the JSON-RPC/WebSocket gateway the "
"desktop app and remote clients connect to. Headless: it never opens "
"a browser UI."
),
add_help=add_help,
exit_on_error=exit_on_error,
)
_configure_serve_parser(parser, cmd_dashboard=cmd_dashboard)
return parser
def build_dashboard_parser(
subparsers, *, cmd_dashboard: Callable, cmd_dashboard_register: Callable
) -> None:
"""Attach the ``dashboard`` and ``serve`` subcommands.
Both share the same backend (``cmd_dashboard`` → ``start_server``).
``dashboard`` is the browser UI; ``serve`` is the headless backend used by
the desktop app and remote clients. They are independent surfaces — neither
"launches" the other — so the desktop app spawns ``serve``, never
``dashboard``.
"""
# =========================================================================
# dashboard command — the browser web UI
# =========================================================================
dashboard_parser = subparsers.add_parser(
"dashboard",
help="Start the web UI dashboard",
description="Launch the Hermes Agent web dashboard for managing config, API keys, and sessions",
)
_add_server_runtime_args(dashboard_parser)
dashboard_parser.add_argument(
"--no-open", action="store_true", help="Don't open browser automatically"
)
# Backward-compat shim: older Hermes desktop app shells (<= 0.15.x) spawn the
# backend as `hermes dashboard --no-open --tui --host ... --port ...`. The
# `--tui` flag was removed from this subcommand in cae6b5486 (embedded chat is
# always on now). When a user's CLI updates past that commit but their desktop
# app binary has not, argparse used to hard-error with "unrecognized arguments:
# --tui" and exit(2) — the backend died before becoming ready and the GUI just
# showed "Hermes couldn't start" with no actionable cause. Accept and silently
# ignore the flag so an old app + new CLI degrades gracefully instead of
# bricking. Hidden from --help; safe to delete once the floor app version is
# well past 0.16.0.
dashboard_parser.add_argument(
"--tui",
action="store_true",
help=argparse.SUPPRESS,
)
dashboard_parser.set_defaults(func=cmd_dashboard)
# =========================================================================
# serve command — the headless backend server
#
# `serve` boots the exact same gateway as `dashboard` but never opens a
# browser. It exists so the Hermes Desktop app (and headless remote
# backends) can launch a backend WITHOUT invoking `dashboard`: the desktop
# app and the web dashboard are independent surfaces that merely share this
# server, and neither should appear to launch the other.
# =========================================================================
serve_parser = subparsers.add_parser(
"serve",
help="Start the Hermes backend server (headless; powers the desktop app and remote backends)",
description=(
"Run the Hermes backend server — the JSON-RPC/WebSocket gateway the "
"desktop app and remote clients connect to. Headless: it never opens "
"a browser UI."
),
)
_configure_serve_parser(serve_parser, cmd_dashboard=cmd_dashboard)
# `hermes dashboard register` — register a self-hosted dashboard OAuth
# client with Nous Portal and write the client_id into ~/.hermes/.env.
# Nested subparser so bare `hermes dashboard` keeps launching the server
# (set_defaults(func=cmd_dashboard) above remains the default).
dashboard_subparsers = dashboard_parser.add_subparsers(
dest="dashboard_subcommand"
)
dashboard_register_parser = dashboard_subparsers.add_parser(
"register",
help="Register a self-hosted dashboard with Nous Portal (writes the OAuth client ID to .env)",
description=(
"Register this install as a self-hosted dashboard with your Nous "
"Portal account. Creates an OAuth client, writes "
"HERMES_DASHBOARD_OAUTH_CLIENT_ID into ~/.hermes/.env, and prints "
"how to engage the login gate. Requires being logged in (hermes setup)."
),
)
dashboard_register_parser.add_argument(
"--name",
default=None,
help="Human-readable label for the dashboard (default: an auto-generated name)",
)
dashboard_register_parser.add_argument(
"--redirect-uri",
dest="redirect_uri",
default=None,
help=(
"Optional public HTTPS OAuth redirect URI for the dashboard, e.g. "
"https://hermes.example.com/auth/callback. Omit for localhost-only use."
),
)
dashboard_register_parser.add_argument(
"--portal-url",
dest="portal_url",
default=None,
help=(
"Override the Nous Portal base URL for registration (default: the "
"portal you logged into). The access token must be valid at this "
"portal. Also settable via HERMES_DASHBOARD_PORTAL_URL. Mainly for "
"testing against a staging/preview portal."
),
)
dashboard_register_parser.set_defaults(func=cmd_dashboard_register)
+100
View File
@@ -0,0 +1,100 @@
"""``hermes debug`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
import argparse
from typing import Callable
def build_debug_parser(subparsers, *, cmd_debug: Callable) -> None:
"""Attach the ``debug`` subcommand to ``subparsers``."""
# =========================================================================
# debug command
# =========================================================================
debug_parser = subparsers.add_parser(
"debug",
help="Debug tools — upload logs and system info for support",
description="Debug utilities for Hermes Agent. Use 'hermes debug share' to "
"upload a debug report (system info + recent logs) to a paste "
"service and get a shareable URL.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
Examples:
hermes debug share Upload debug report (asks for confirmation)
hermes debug share --yes Skip confirmation (for scripts/CI)
hermes debug share --lines 500 Include more log lines
hermes debug share --expire 30 Keep paste for 30 days
hermes debug share --local Print report locally (no upload)
hermes debug share --no-redact Disable upload-time secret redaction
hermes debug share --nous Upload to Nous-internal storage (private)
hermes debug delete <url> Delete a previously uploaded paste
""",
)
debug_sub = debug_parser.add_subparsers(dest="debug_command")
share_parser = debug_sub.add_parser(
"share",
help="Upload debug report to a paste service and print a shareable URL",
)
share_parser.add_argument(
"--lines",
type=int,
default=200,
help="Number of log lines to include per log file (default: 200)",
)
share_parser.add_argument(
"--expire",
type=int,
default=7,
help="Paste expiry in days (default: 7)",
)
share_parser.add_argument(
"--local",
action="store_true",
help="Print the report locally instead of uploading",
)
share_parser.add_argument(
"-y",
"--yes",
action="store_true",
help=(
"Skip the confirmation prompt and upload immediately. Required "
"in non-interactive contexts (scripts/CI); without it, and with "
"no TTY on stdin, the command refuses rather than upload silently."
),
)
share_parser.add_argument(
"--no-redact",
action="store_true",
help=(
"Disable upload-time secret redaction (default: redact). Logs "
"are normally run through agent.redact.redact_sensitive_text "
"with force=True before upload so credentials are not leaked "
"into the public paste service."
),
)
share_parser.add_argument(
"--nous",
action="store_true",
help=(
"Upload the debug bundle to Nous-internal storage (AWS S3) instead "
"of a public paste service. The bundle is private — viewable only "
"by Nous staff (and allowlisted Discord mods) via a Google-login-"
"gated viewer — and auto-deletes after 14 days. Still force-redacts "
"secrets unless --no-redact is also passed."
),
)
delete_parser = debug_sub.add_parser(
"delete",
help="Delete a paste uploaded by 'hermes debug share'",
)
delete_parser.add_argument(
"urls",
nargs="*",
default=[],
help="One or more paste URLs to delete (e.g. https://paste.rs/abc123)",
)
debug_parser.set_defaults(func=cmd_debug)
+44
View File
@@ -0,0 +1,44 @@
"""``hermes doctor`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_doctor_parser(subparsers, *, cmd_doctor: Callable) -> None:
"""Attach the ``doctor`` subcommand to ``subparsers``."""
# =========================================================================
# doctor command
# =========================================================================
doctor_parser = subparsers.add_parser(
"doctor",
help="Check configuration and dependencies",
description="Diagnose issues with Hermes Agent setup",
)
doctor_parser.add_argument(
"--fix", action="store_true", help="Attempt to fix issues automatically"
)
doctor_parser.add_argument(
"--live",
action="store_true",
help=(
"Opt-in: run one bounded, read-only real-call health probe per "
"configured tool backend (Firecrawl/FAL/browser/MCP/TTS/STT) "
"after the static checks. Makes real network calls."
),
)
doctor_parser.add_argument(
"--ack",
metavar="ADVISORY_ID",
default=None,
help=(
"Acknowledge a security advisory by ID and exit. After ack, the "
"advisory will no longer trigger startup banners. Run `hermes "
"doctor` first to see active advisories and their IDs."
),
)
doctor_parser.set_defaults(func=cmd_doctor)
+28
View File
@@ -0,0 +1,28 @@
"""``hermes dump`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_dump_parser(subparsers, *, cmd_dump: Callable) -> None:
"""Attach the ``dump`` subcommand to ``subparsers``."""
# =========================================================================
# dump command
# =========================================================================
dump_parser = subparsers.add_parser(
"dump",
help="Dump setup summary for support/debugging",
description="Output a compact, plain-text summary of your Hermes setup "
"that can be copy-pasted into Discord/GitHub for support context",
)
dump_parser.add_argument(
"--show-keys",
action="store_true",
help="Show redacted API key prefixes (first/last 4 chars) instead of just set/not set",
)
dump_parser.set_defaults(func=cmd_dump)
+355
View File
@@ -0,0 +1,355 @@
"""``hermes gateway`` and ``hermes proxy`` subcommand parsers.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Both parsers are built together because they shared one inline block (the
``gateway`` section also defined ``proxy``). Handlers injected to avoid
importing ``main``.
"""
from __future__ import annotations
import argparse
from typing import Callable
from hermes_cli.subcommands._shared import add_accept_hooks_flag
def _add_compat_platform_flag(parser: argparse.ArgumentParser) -> None:
"""Accept stale `gateway <verb> --platform X` docs without advertising it.
Gateway service lifecycle commands operate on the gateway process, not a
single messaging adapter. Photon briefly printed a per-platform start
command during setup; keep that command parseable so users following the
old hint don't get blocked by argparse before the gateway can start.
"""
parser.add_argument(
"--platform",
dest="platform",
help=argparse.SUPPRESS,
)
def build_gateway_parser(
subparsers, *, cmd_gateway: Callable, cmd_proxy: Callable, cmd_gateway_enroll: Callable
) -> None:
"""Attach the ``gateway`` and ``proxy`` subcommands to ``subparsers``."""
# =========================================================================
# gateway command
# =========================================================================
gateway_parser = subparsers.add_parser(
"gateway",
help="Messaging gateway management",
description="Manage the messaging gateway (Telegram, Discord, WhatsApp, Weixin, and more)",
)
gateway_subparsers = gateway_parser.add_subparsers(dest="gateway_command")
# gateway run (default)
gateway_run = gateway_subparsers.add_parser(
"run", help="Run gateway in foreground (recommended for WSL, Docker, Termux)"
)
gateway_run.add_argument(
"-v",
"--verbose",
action="count",
default=0,
help="Increase stderr log verbosity (-v=INFO, -vv=DEBUG)",
)
gateway_run.add_argument(
"-q", "--quiet", action="store_true", help="Suppress all stderr log output"
)
gateway_run.add_argument(
"--replace",
action="store_true",
help="Replace any existing gateway instance (useful for systemd)",
)
gateway_run.add_argument(
"--force",
action="store_true",
help=(
"Start a foreground gateway even when a systemd/launchd/s6 service "
"already supervises this profile. Without --force, the command "
"refuses because a second dispatcher escapes the service and can "
"corrupt shared gateway state."
),
)
gateway_run.add_argument(
"--no-supervise",
action="store_true",
help=(
"Inside the s6-overlay Docker image, normally `gateway run` is "
"automatically redirected to the supervised s6 service (so the "
"gateway gets auto-restart on crash, plus a supervised dashboard "
"if HERMES_DASHBOARD is set). Pass --no-supervise to opt out and "
"get the historical pre-s6 foreground behavior: the gateway is "
"the container's main process and the container exits with the "
"gateway's exit code. No effect outside an s6 container."
),
)
gateway_run.add_argument(
"--external-supervisor",
action="store_true",
help=(
"Declare that an external process manager owns this foreground "
"gateway. In-chat restarts and updates exit back to that manager "
"instead of spawning a detached replacement. Use this when a "
"launchd/systemd wrapper strips its native environment markers."
),
)
add_accept_hooks_flag(gateway_run)
add_accept_hooks_flag(gateway_parser)
# gateway start
gateway_start = gateway_subparsers.add_parser(
"start", help="Start the installed systemd/launchd background service"
)
gateway_start.add_argument(
"--system",
action="store_true",
help="Target the Linux system-level gateway service",
)
gateway_start.add_argument(
"--all",
action="store_true",
help="Kill ALL stale gateway processes across all profiles before starting",
)
_add_compat_platform_flag(gateway_start)
# gateway stop
gateway_stop = gateway_subparsers.add_parser("stop", help="Stop gateway service")
gateway_stop.add_argument(
"--system",
action="store_true",
help="Target the Linux system-level gateway service",
)
gateway_stop.add_argument(
"--all",
action="store_true",
help="Stop ALL gateway processes across all profiles",
)
# gateway restart
gateway_restart = gateway_subparsers.add_parser(
"restart", help="Restart gateway service"
)
gateway_restart.add_argument(
"--system",
action="store_true",
help="Target the Linux system-level gateway service",
)
gateway_restart.add_argument(
"--all",
action="store_true",
help="Kill ALL gateway processes across all profiles before restarting",
)
_add_compat_platform_flag(gateway_restart)
# gateway status
gateway_status = gateway_subparsers.add_parser("status", help="Show gateway status")
gateway_status.add_argument("--deep", action="store_true", help="Deep status check")
gateway_status.add_argument(
"-l",
"--full",
action="store_true",
help="Show full, untruncated service/log output where supported",
)
gateway_status.add_argument(
"--system",
action="store_true",
help="Target the Linux system-level gateway service",
)
_add_compat_platform_flag(gateway_status)
# gateway install
gateway_install = gateway_subparsers.add_parser(
"install", help="Install gateway as a systemd/launchd background service"
)
gateway_install.add_argument("--force", action="store_true", help="Force reinstall")
gateway_install.add_argument(
"--system",
action="store_true",
help="Install as a Linux system-level service (starts at boot)",
)
gateway_install.add_argument(
"--run-as-user",
dest="run_as_user",
help="User account the Linux system service should run as",
)
gateway_install.add_argument(
"--start-now",
dest="start_now",
action="store_true",
default=None,
help="Start the gateway service immediately after installing",
)
gateway_install.add_argument(
"--no-start-now",
dest="start_now",
action="store_false",
help="Do not start the gateway service after installing",
)
gateway_install.add_argument(
"--start-on-login",
dest="start_on_login",
action="store_true",
default=None,
help="Enable the service to start automatically on login/boot",
)
gateway_install.add_argument(
"--no-start-on-login",
dest="start_on_login",
action="store_false",
help="Do not enable the service to start on login/boot",
)
gateway_install.add_argument(
"--elevated-handoff",
dest="elevated_handoff",
action="store_true",
help=argparse.SUPPRESS,
)
# gateway uninstall
gateway_uninstall = gateway_subparsers.add_parser(
"uninstall", help="Uninstall gateway service"
)
gateway_uninstall.add_argument(
"--system",
action="store_true",
help="Target the Linux system-level gateway service",
)
# gateway list
gateway_subparsers.add_parser("list", help="List all profiles and their gateway status")
# gateway setup
gateway_subparsers.add_parser("setup", help="Configure messaging platforms")
# gateway migrate-legacy
gateway_migrate_legacy = gateway_subparsers.add_parser(
"migrate-legacy",
help="Remove legacy hermes.service units from pre-rename installs",
description=(
"Stop, disable, and remove legacy Hermes gateway unit files "
"(e.g. hermes.service) left over from older installs. Profile "
"units (hermes-gateway-<profile>.service) and unrelated "
"third-party services are never touched."
),
)
gateway_migrate_legacy.add_argument(
"--dry-run",
dest="dry_run",
action="store_true",
help="List what would be removed without doing it",
)
gateway_migrate_legacy.add_argument(
"-y",
"--yes",
dest="yes",
action="store_true",
help="Skip the confirmation prompt",
)
# gateway enroll — enroll a self-hosted gateway with a relay connector
# (connector⇄gateway auth). Redeems a single-use enrollment token for the
# per-gateway secret + per-tenant delivery key and writes them to .env.
# See docs/relay-connector-contract.md (and the connector repo's
# docs/connector-gateway-auth-design.md). EXPERIMENTAL.
gateway_enroll = gateway_subparsers.add_parser(
"enroll",
help="Enroll this gateway with a relay connector (writes relay auth creds to .env)",
description=(
"Redeem a single-use enrollment token with a relay connector. "
"Authenticates as your Nous Portal account (the connector derives the "
"authoritative tenant from it), mints this gateway's per-gateway secret "
"and per-tenant delivery key, and writes GATEWAY_RELAY_ID / "
"GATEWAY_RELAY_SECRET / GATEWAY_RELAY_DELIVERY_KEY into ~/.hermes/.env. "
"Requires being logged in (hermes setup). Not available in managed installs."
),
)
gateway_enroll.add_argument(
"--token",
default=None,
help=(
"The single-use enrollment token from the connector (delivered with "
"your gateway config). Also settable via GATEWAY_RELAY_ENROLL_TOKEN."
),
)
gateway_enroll.add_argument(
"--connector-url",
dest="connector_url",
default=None,
help=(
"The connector base/relay URL, e.g. wss://connector.example.com/relay "
"or https://connector.example.com. Also settable via GATEWAY_RELAY_URL "
"/ gateway.relay_url in config.yaml."
),
)
gateway_enroll.add_argument(
"--gateway-id",
dest="gateway_id",
default=None,
help=(
"A stable id for this gateway instance (kill-switch granularity). "
"Defaults to gw-<hostname>."
),
)
gateway_enroll.add_argument(
"--wake-url",
dest="wake_url",
default=None,
help=(
"Phase 5 §5.2 wake URL: a reachable URL the connector pokes "
"(payload-free GET) to wake this gateway when buffered work arrives "
"while it's idle/suspended, so it reconnects and drains. Persisted as "
"GATEWAY_RELAY_WAKE_URL in ~/.hermes/.env and forwarded at provision. "
"Optional — without it the gateway still drains whenever it next "
"reconnects on its own."
),
)
gateway_enroll.set_defaults(func=cmd_gateway_enroll)
# =========================================================================
# proxy command — local OpenAI-compatible proxy that attaches the user's
# OAuth-authenticated provider credentials to outbound requests. Lets
# external apps (OpenViking, Karakeep, Open WebUI, ...) ride a logged-in
# subscription without copy-pasting static API keys.
# =========================================================================
proxy_parser = subparsers.add_parser(
"proxy",
help="Local OpenAI-compatible proxy to OAuth providers",
description=(
"Run a local HTTP server that forwards OpenAI-compatible requests "
"to an OAuth-authenticated provider (e.g. Nous Portal). External "
"apps can point at the proxy with any bearer token; the proxy "
"attaches your real credentials."
),
)
proxy_subparsers = proxy_parser.add_subparsers(dest="proxy_command")
proxy_start = proxy_subparsers.add_parser(
"start", help="Run the proxy in the foreground"
)
proxy_start.add_argument(
"--provider",
default="nous",
help="Upstream provider: nous or xai (default: nous). See `hermes proxy providers`.",
)
proxy_start.add_argument(
"--host",
default=None,
help="Bind address (default: 127.0.0.1). Use 0.0.0.0 to expose on LAN.",
)
proxy_start.add_argument(
"--port",
type=int,
default=None,
help="Bind port (default: 8645)",
)
proxy_subparsers.add_parser(
"status", help="Show which proxy upstreams are ready"
)
proxy_subparsers.add_parser(
"providers", help="List available proxy upstream providers"
)
proxy_parser.set_defaults(func=cmd_proxy)
gateway_parser.set_defaults(func=cmd_gateway)
+85
View File
@@ -0,0 +1,85 @@
"""``hermes gui`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_gui_parser(subparsers, *, cmd_gui: Callable) -> None:
"""Attach the ``gui`` subcommand to ``subparsers``."""
# =========================================================================
gui_parser = subparsers.add_parser(
"desktop",
aliases=["gui"],
help="Build and launch the native desktop app",
description=(
"Launch the Hermes Electron desktop app. By default this installs "
"workspace Node dependencies, builds the current OS's unpacked "
"Electron app, then launches that packaged artifact."
),
)
gui_parser.add_argument(
"--source",
action="store_true",
help="Launch via `electron .` against apps/desktop/dist instead of the packaged app",
)
gui_parser.add_argument(
"--build-only",
action="store_true",
help="Build the desktop app but do not launch it (used by the installer's --update flow)",
)
gui_parser.add_argument(
"--fake-boot",
action="store_true",
help="Enable deterministic desktop boot delays for validating startup UI",
)
gui_parser.add_argument(
"--ignore-existing",
action="store_true",
help="Force Desktop to ignore any hermes CLI already on PATH during backend resolution",
)
gui_parser.add_argument(
"--hermes-root",
help="Override the Hermes source root used by Desktop (sets HERMES_DESKTOP_HERMES_ROOT)",
)
gui_parser.add_argument(
"--cwd",
help="Initial project directory for Desktop chat sessions (sets HERMES_DESKTOP_CWD)",
)
gui_parser.add_argument(
"--skip-build",
action="store_true",
help="Skip npm install/package and launch the existing unpacked app from apps/desktop/release",
)
gui_parser.add_argument(
"--local",
action="store_true",
help="Show the local-models UI in the desktop app (models pane, quickstart, picker rows)",
)
gui_parser.add_argument(
"--force-build",
action="store_true",
help="Force a full rebuild even if the content stamp matches",
)
gui_parser.add_argument(
"--setup-tcc-identity",
action="store_true",
help=(
"macOS only: create/import a self-signed code-signing certificate "
"in the login keychain and point desktop.macos_signing_identity at "
"it, then re-sign the packaged app. Makes macOS TCC grants (Full "
"Disk Access, Accessibility, Files and Folders, microphone) survive "
"rebuilds with a certificate-anchored identity. Idempotent — safe "
"to re-run after updates."
),
)
gui_parser.add_argument(
"--identity",
default="Hermes Local Signing",
help="Certificate name to create/use for --setup-tcc-identity (default: Hermes Local Signing)",
)
gui_parser.set_defaults(func=cmd_gui)
+77
View File
@@ -0,0 +1,77 @@
"""``hermes hooks`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_hooks_parser(subparsers, *, cmd_hooks: Callable) -> None:
"""Attach the ``hooks`` subcommand to ``subparsers``."""
# =========================================================================
hooks_parser = subparsers.add_parser(
"hooks",
help="Inspect and manage shell-script hooks",
description=(
"Inspect shell-script hooks declared in ~/.hermes/config.yaml, "
"test them against synthetic payloads, and manage the first-use "
"consent allowlist at ~/.hermes/shell-hooks-allowlist.json."
),
)
hooks_subparsers = hooks_parser.add_subparsers(dest="hooks_action")
hooks_subparsers.add_parser(
"list",
aliases=["ls"],
help="List configured hooks with matcher, timeout, and consent status",
)
_hk_test = hooks_subparsers.add_parser(
"test",
help="Fire every hook matching <event> against a synthetic payload",
)
_hk_test.add_argument(
"event",
help="Hook event name (e.g. pre_tool_call, pre_llm_call, subagent_stop)",
)
_hk_test.add_argument(
"--for-tool",
dest="for_tool",
default=None,
help=(
"Only fire hooks whose matcher matches this tool name "
"(used for pre_tool_call / post_tool_call)"
),
)
_hk_test.add_argument(
"--payload-file",
dest="payload_file",
default=None,
help=(
"Path to a JSON file whose contents are merged into the "
"synthetic payload before execution"
),
)
_hk_revoke = hooks_subparsers.add_parser(
"revoke",
aliases=["remove", "rm"],
help="Remove a command's allowlist entries (takes effect on next restart)",
)
_hk_revoke.add_argument(
"command",
help="The exact command string to revoke (as declared in config.yaml)",
)
hooks_subparsers.add_parser(
"doctor",
help=(
"Check each configured hook: exec bit, allowlist, mtime drift, "
"JSON validity, and synthetic run timing"
),
)
hooks_parser.set_defaults(func=cmd_hooks)
+49
View File
@@ -0,0 +1,49 @@
"""``hermes import-agent`` subcommand parser.
Follows the ``hermes claw`` pattern (see ``hermes_cli/subcommands/claw.py``):
parser building lives here, the handler is injected to avoid importing
``main``, and the import logic itself lives in ``hermes_cli/agent_import.py``.
"""
from __future__ import annotations
from typing import Callable
def build_import_agent_parser(subparsers, *, cmd_import_agent: Callable) -> None:
"""Attach the ``import-agent`` subcommand to ``subparsers``."""
parser = subparsers.add_parser(
"import-agent",
help="Import a Claude Code or Codex CLI setup into Hermes",
description=(
"One-command import of another coding agent's setup into Hermes. "
"Maps CLAUDE.md/AGENTS.md instructions, permission allowlists, MCP "
"servers, skills, and memories into their Hermes equivalents. "
"Always shows a preview before making changes. API keys and "
"credentials are never imported — run 'hermes setup' for those."
),
)
parser.add_argument(
"agent",
nargs="?",
choices=["claude-code", "codex"],
help="Which agent to import from (default: auto-detect ~/.claude or ~/.codex)",
)
parser.add_argument(
"--source",
help="Path to the agent's config directory (default: ~/.claude or ~/.codex)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview only — stop after showing what would be imported",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Overwrite existing Hermes items on name conflicts (default: skip)",
)
parser.add_argument(
"--yes", "-y", action="store_true", help="Skip confirmation prompts"
)
parser.set_defaults(func=cmd_import_agent)
+31
View File
@@ -0,0 +1,31 @@
"""``hermes import`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_import_cmd_parser(subparsers, *, cmd_import: Callable) -> None:
"""Attach the ``import`` subcommand to ``subparsers``."""
# =========================================================================
# import command
# =========================================================================
import_parser = subparsers.add_parser(
"import",
help="Restore a Hermes backup from a zip file",
description="Extract a previously created Hermes backup into your "
"Hermes home directory, restoring configuration, skills, "
"sessions, and data",
)
import_parser.add_argument("zipfile", help="Path to the backup zip file")
import_parser.add_argument(
"--force",
"-f",
action="store_true",
help="Overwrite existing files without confirmation",
)
import_parser.set_defaults(func=cmd_import)
+25
View File
@@ -0,0 +1,25 @@
"""``hermes insights`` subcommand parser.
Extracted from ``hermes_cli/main.py:main()`` (god-file Phase 2 follow-up).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_insights_parser(subparsers, *, cmd_insights: Callable) -> None:
"""Attach the ``insights`` subcommand to ``subparsers``."""
insights_parser = subparsers.add_parser(
"insights",
help="Show usage insights and analytics",
description="Analyze session history to show token usage, costs, tool patterns, and activity trends",
)
insights_parser.add_argument(
"--days", type=int, default=30, help="Number of days to analyze (default: 30)"
)
insights_parser.add_argument(
"--source", help="Filter by platform (cli, telegram, discord, etc.)"
)
insights_parser.set_defaults(func=cmd_insights)
+78
View File
@@ -0,0 +1,78 @@
"""``hermes login`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_login_parser(subparsers, *, cmd_login: Callable) -> None:
"""Attach the deprecated ``login`` subcommand to ``subparsers``.
``hermes login`` was removed in favor of ``hermes auth`` / ``hermes model``
(the runtime handler in ``hermes_cli/auth.py::login_command`` just prints a
deprecation message and exits). The subparser is kept registered so that
old scripts/aliases invoking ``hermes login [--flags]`` still receive the
actionable deprecation message rather than an argparse ``invalid choice:
'login'`` error — but:
- The subparser is registered WITHOUT a ``help=`` kwarg so the row is
omitted from ``hermes --help`` (argparse only lists subcommands that
have a help string). This hides a command that no longer works (#24756)
without the ``help=argparse.SUPPRESS`` ``==SUPPRESS==`` leak that
argparse emits for a top-level subparser on Python 3.12+.
- ``--provider`` accepts ANY value (no ``choices=``) so that, e.g.,
``hermes login --provider anthropic`` reaches the deprecation handler and
gets pointed at ``hermes model`` instead of crashing in argparse with
``invalid choice: 'anthropic'`` before the handler can run.
"""
login_parser = subparsers.add_parser(
"login",
description=(
"Deprecated. Use `hermes auth` to manage credentials, "
"`hermes model` to select a provider, or `hermes setup` for full setup."
),
)
# No ``choices=`` on purpose — the handler is a deprecation notice that
# ignores the value, and a restrictive list would reject providers the user
# legitimately wants (e.g. ``anthropic``) with an argparse error before the
# friendly redirect message is ever printed.
login_parser.add_argument(
"--provider",
default=None,
help="(deprecated) Provider name; ignored — see `hermes model`",
)
login_parser.add_argument(
"--portal-url", help="Portal base URL (default: production portal)"
)
login_parser.add_argument(
"--inference-url",
help="Inference API base URL (default: production inference API)",
)
login_parser.add_argument(
"--client-id", default=None, help="OAuth client id to use (default: hermes-cli)"
)
login_parser.add_argument("--scope", default=None, help="OAuth scope to request")
login_parser.add_argument(
"--no-browser",
action="store_true",
help="Do not attempt to open the browser automatically",
)
login_parser.add_argument(
"--timeout",
type=float,
default=15.0,
help="HTTP request timeout in seconds (default: 15)",
)
login_parser.add_argument(
"--ca-bundle", help="Path to CA bundle PEM file for TLS verification"
)
login_parser.add_argument(
"--insecure",
action="store_true",
help="Disable TLS verification (testing only)",
)
login_parser.set_defaults(func=cmd_login)
+28
View File
@@ -0,0 +1,28 @@
"""``hermes logout`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_logout_parser(subparsers, *, cmd_logout: Callable) -> None:
"""Attach the ``logout`` subcommand to ``subparsers``."""
# =========================================================================
# logout command
# =========================================================================
logout_parser = subparsers.add_parser(
"logout",
help="Clear authentication for an inference provider",
description="Remove stored credentials and reset provider config",
)
logout_parser.add_argument(
"--provider",
choices=["nous", "openai-codex", "xai-oauth", "spotify"],
default=None,
help="Provider to log out from (default: active provider)",
)
logout_parser.set_defaults(func=cmd_logout)
+78
View File
@@ -0,0 +1,78 @@
"""``hermes logs`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
import argparse
from typing import Callable
def build_logs_parser(subparsers, *, cmd_logs: Callable) -> None:
"""Attach the ``logs`` subcommand to ``subparsers``."""
# =========================================================================
# logs command
# =========================================================================
logs_parser = subparsers.add_parser(
"logs",
help="View and filter Hermes log files",
description="View, tail, and filter agent.log / errors.log / gateway.log / gui.log / desktop.log",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
Examples:
hermes logs Show last 50 lines of agent.log
hermes logs -f Follow agent.log in real time
hermes logs errors Show last 50 lines of errors.log
hermes logs gateway -n 100 Show last 100 lines of gateway.log
hermes logs gui -f Follow gui.log in real time
hermes logs desktop -f Follow desktop.log (Electron app boot/backend)
hermes logs --level WARNING Only show WARNING and above
hermes logs --session abc123 Filter by session ID
hermes logs --component tools Only show tool-related lines
hermes logs --since 1h Lines from the last hour
hermes logs --since 30m -f Follow, starting from 30 min ago
hermes logs list List available log files with sizes
""",
)
logs_parser.add_argument(
"log_name",
nargs="?",
default="agent",
help="Log to view: agent (default), errors, gateway, gui, or 'list' to show available files",
)
logs_parser.add_argument(
"-n",
"--lines",
type=int,
default=50,
help="Number of lines to show (default: 50)",
)
logs_parser.add_argument(
"-f",
"--follow",
action="store_true",
help="Follow the log in real time (like tail -f)",
)
logs_parser.add_argument(
"--level",
metavar="LEVEL",
help="Minimum log level to show (DEBUG, INFO, WARNING, ERROR)",
)
logs_parser.add_argument(
"--session",
metavar="ID",
help="Filter lines containing this session ID substring",
)
logs_parser.add_argument(
"--since",
metavar="TIME",
help="Show lines since TIME ago (e.g. 1h, 30m, 2d)",
)
logs_parser.add_argument(
"--component",
metavar="NAME",
help="Filter by component: gateway, agent, tools, cli, cron, gui",
)
logs_parser.set_defaults(func=cmd_logs)
+126
View File
@@ -0,0 +1,126 @@
"""``hermes mcp`` subcommand parser.
Extracted from ``hermes_cli/main.py:main()`` (god-file Phase 2 follow-up).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
import argparse
from typing import Callable
from hermes_cli.subcommands._shared import add_accept_hooks_flag
def build_mcp_parser(subparsers, *, cmd_mcp: Callable) -> None:
"""Attach the ``mcp`` subcommand to ``subparsers``."""
mcp_parser = subparsers.add_parser(
"mcp",
help="Manage MCP servers and run Hermes as an MCP server",
description=(
"Manage MCP server connections and run Hermes as an MCP server.\n\n"
"MCP servers provide additional tools via the Model Context Protocol.\n"
"Use 'hermes mcp add' to connect to a new server, or\n"
"'hermes mcp serve' to expose Hermes conversations over MCP."
),
)
mcp_sub = mcp_parser.add_subparsers(dest="mcp_action")
mcp_serve_p = mcp_sub.add_parser(
"serve",
help="Run Hermes as an MCP server (expose conversations to other agents)",
)
mcp_serve_p.add_argument(
"-v",
"--verbose",
action="store_true",
help="Enable verbose logging on stderr",
)
add_accept_hooks_flag(mcp_serve_p)
mcp_add_p = mcp_sub.add_parser(
"add", help="Add an MCP server (discovery-first install)"
)
mcp_add_p.add_argument("name", help="Server name (used as config key)")
mcp_add_p.add_argument("--url", help="HTTP/SSE endpoint URL")
# dest="mcp_command" so this flag does not clobber the top-level
# subparser's args.command attribute, which the dispatcher reads to
# route to cmd_mcp. Without an explicit dest, argparse derives
# dest="command" from the flag name and sets it to None when the
# flag is omitted, causing `hermes mcp add ...` to fall through to
# interactive chat.
mcp_add_p.add_argument(
"--command", dest="mcp_command", help="Stdio command (e.g. npx)"
)
mcp_add_p.add_argument(
"--args",
nargs=argparse.REMAINDER,
default=[],
help="Arguments for stdio command; must be the last option",
)
mcp_add_p.add_argument("--auth", choices=["oauth", "header"], help="Auth method")
mcp_add_p.add_argument("--preset", help="Known MCP preset name")
mcp_add_p.add_argument(
"--connect-timeout",
type=float,
help="Timeout in seconds for initial connection and tool discovery",
)
mcp_add_p.add_argument(
"--env",
nargs="*",
default=[],
help="Environment variables for stdio servers (KEY=VALUE)",
)
mcp_rm_p = mcp_sub.add_parser("remove", aliases=["rm"], help="Remove an MCP server")
mcp_rm_p.add_argument("name", help="Server name to remove")
mcp_sub.add_parser("list", aliases=["ls"], help="List configured MCP servers")
mcp_test_p = mcp_sub.add_parser("test", help="Test MCP server connection")
mcp_test_p.add_argument("name", help="Server name to test")
mcp_cfg_p = mcp_sub.add_parser(
"configure", aliases=["config"], help="Toggle tool selection"
)
mcp_cfg_p.add_argument("name", help="Server name to configure")
mcp_login_p = mcp_sub.add_parser(
"login",
help="Force re-authentication for an OAuth-based MCP server",
)
mcp_login_p.add_argument("name", help="Server name to re-authenticate")
mcp_reauth_p = mcp_sub.add_parser(
"reauth",
help="Re-authenticate one OAuth MCP server, or all of them (--all)",
)
mcp_reauth_p.add_argument(
"name", nargs="?", help="Server name to re-authenticate (omit with --all)"
)
mcp_reauth_p.add_argument(
"--all",
action="store_true",
help="Re-authenticate every OAuth server in config, one at a time",
)
# ── Catalog (Nous-approved MCPs shipped with the repo) ─────────────────
mcp_sub.add_parser(
"picker",
help="Interactive catalog picker (also the default for `hermes mcp`)",
)
mcp_sub.add_parser(
"catalog",
help="List Nous-approved MCPs available for one-click install",
)
mcp_install_p = mcp_sub.add_parser(
"install",
help="Install a catalog MCP by name (e.g. `hermes mcp install n8n`)",
)
mcp_install_p.add_argument(
"identifier",
help="Catalog entry name (or `official/<name>`)",
)
add_accept_hooks_flag(mcp_parser)
mcp_parser.set_defaults(func=cmd_mcp)
+53
View File
@@ -0,0 +1,53 @@
"""``hermes memory`` subcommand parser.
Extracted from ``hermes_cli/main.py:main()`` (god-file Phase 2 follow-up).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_memory_parser(subparsers, *, cmd_memory: Callable) -> None:
"""Attach the ``memory`` subcommand to ``subparsers``."""
memory_parser = subparsers.add_parser(
"memory",
help="Configure external memory provider",
description=(
"Set up and manage external memory provider plugins.\n\n"
"Available providers: honcho, openviking, mem0, hindsight,\n"
"holographic, retaindb, byterover.\n\n"
"Only one external provider can be active at a time.\n"
"Built-in memory (MEMORY.md/USER.md) is always active."
),
)
memory_sub = memory_parser.add_subparsers(dest="memory_command")
_setup_parser = memory_sub.add_parser(
"setup", help="Interactive provider selection and configuration"
)
_setup_parser.add_argument(
"provider",
nargs="?",
default=None,
help="Provider to configure directly (e.g. honcho), skipping the picker",
)
memory_sub.add_parser("status", help="Show current memory provider config")
memory_sub.add_parser("off", help="Disable external provider (built-in only)")
_reset_parser = memory_sub.add_parser(
"reset",
help="Erase all built-in memory (MEMORY.md and USER.md)",
)
_reset_parser.add_argument(
"--yes",
"-y",
action="store_true",
help="Skip confirmation prompt",
)
_reset_parser.add_argument(
"--target",
choices=["all", "memory", "user"],
default="all",
help="Which store to reset: 'all' (default), 'memory', or 'user'",
)
memory_parser.set_defaults(func=cmd_memory)
+62
View File
@@ -0,0 +1,62 @@
"""``hermes model`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_model_parser(subparsers, *, cmd_model: Callable) -> None:
"""Attach the ``model`` subcommand to ``subparsers``."""
# =========================================================================
# model command
# =========================================================================
model_parser = subparsers.add_parser(
"model",
help="Select default model and provider",
description="Interactively select your inference provider and default model",
)
model_parser.add_argument(
"--refresh",
action="store_true",
help="Wipe the model picker disk cache and re-fetch every provider's live /v1/models list.",
)
model_parser.add_argument(
"--portal-url",
help="Portal base URL for Nous login (default: production portal)",
)
model_parser.add_argument(
"--inference-url",
help="Inference API base URL for Nous login (default: production inference API)",
)
model_parser.add_argument(
"--client-id",
default=None,
help="OAuth client id to use for Nous login (default: hermes-cli)",
)
model_parser.add_argument(
"--scope", default=None, help="OAuth scope to request for Nous login"
)
model_parser.add_argument(
"--no-browser",
action="store_true",
help="Do not attempt to open the browser automatically during Nous login",
)
model_parser.add_argument(
"--timeout",
type=float,
default=15.0,
help="HTTP request timeout in seconds for Nous login (default: 15)",
)
model_parser.add_argument(
"--ca-bundle", help="Path to CA bundle PEM file for Nous TLS verification"
)
model_parser.add_argument(
"--insecure",
action="store_true",
help="Disable TLS verification for Nous login (testing only)",
)
model_parser.set_defaults(func=cmd_model)
+36
View File
@@ -0,0 +1,36 @@
"""``hermes monitoring`` subcommand parser.
Gateway monitoring control and inspection. ``status`` shows whether the
gateway health & diagnostics export is enabled, where it points, and the
redaction posture.
The handler is injected to avoid importing ``main`` (mirrors the insights
subcommand).
"""
from __future__ import annotations
from typing import Callable
def build_monitoring_parser(subparsers, *, cmd_monitoring: Callable) -> None:
"""Attach the ``monitoring`` subcommand (with actions) to ``subparsers``."""
p = subparsers.add_parser(
"monitoring",
help="Inspect gateway monitoring (health & diagnostics export)",
description=(
"Gateway monitoring: service health metrics plus redacted "
"diagnostics, exported over OTLP to an operator-configured "
"endpoint. Content-free by construction — no prompts, messages, "
"tool args/results, or usage analytics. Configure under "
"monitoring.* in config.yaml."
),
)
sub = p.add_subparsers(dest="monitoring_action")
sub.add_parser(
"status",
help="Show monitoring settings, export state, and redaction posture",
)
p.set_defaults(func=cmd_monitoring)
+40
View File
@@ -0,0 +1,40 @@
"""``hermes pairing`` subcommand parser.
Extracted from ``hermes_cli/main.py:main()`` (god-file Phase 2 follow-up).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_pairing_parser(subparsers, *, cmd_pairing: Callable) -> None:
"""Attach the ``pairing`` subcommand to ``subparsers``."""
pairing_parser = subparsers.add_parser(
"pairing",
help="Manage DM pairing codes for user authorization",
description="Approve or revoke user access via pairing codes",
)
pairing_sub = pairing_parser.add_subparsers(dest="pairing_action")
pairing_sub.add_parser("list", help="Show pending + approved users")
pairing_approve_parser = pairing_sub.add_parser(
"approve", help="Approve a pairing request"
)
pairing_approve_parser.add_argument(
"platform", help="Platform name (telegram, discord, slack, whatsapp)"
)
pairing_approve_parser.add_argument(
"code",
metavar="request-id|code",
help="Request ID from 'pairing list', or the code the bot DM'd the user",
)
pairing_revoke_parser = pairing_sub.add_parser("revoke", help="Revoke user access")
pairing_revoke_parser.add_argument("platform", help="Platform name")
pairing_revoke_parser.add_argument("user_id", help="User ID to revoke")
pairing_sub.add_parser("clear-pending", help="Clear all pending codes")
pairing_parser.set_defaults(func=cmd_pairing)
+70
View File
@@ -0,0 +1,70 @@
"""``hermes pause`` / ``hermes resume`` — the global emergency stop.
``hermes pause`` writes the ESTOP sentinel at ``$HERMES_HOME/ESTOP``, which
halts cron dispatch, kanban dispatch, and new gateway turns on their next
check. In-flight work is never killed. ``hermes resume`` removes the
sentinel and normal operation resumes on the next tick — no restart needed.
Ported from: gastownhall/gastown estop.go (MIT); related prior art:
#26778 (/panic — kill/exit semantics, different), #44617.
"""
from __future__ import annotations
import argparse
def cmd_pause(args: argparse.Namespace) -> int:
"""Engage the global emergency stop."""
from agent.estop import engage, get_state, is_engaged
reason = getattr(args, "reason", None)
already = is_engaged()
path = engage(reason=reason)
state = get_state() or {}
verb = "Still paused" if already else "Hermes paused"
detail = f" — reason: {state['reason']}" if state.get("reason") else ""
print(f"⏸️ {verb}{detail}")
print(f" sentinel: {path}")
print(
" Cron dispatch, kanban dispatch, and new gateway turns are on hold.\n"
" In-flight work keeps running. Run `hermes resume` to lift the pause."
)
return 0
def cmd_resume(args: argparse.Namespace) -> int:
"""Disengage the global emergency stop."""
from agent.estop import disengage, sentinel_path
if disengage():
print("▶️ Hermes resumed — dispatch picks up on the next tick.")
else:
print(f"Hermes is not paused (no sentinel at {sentinel_path()}).")
return 0
def build_pause_parser(subparsers) -> None:
"""Attach the ``pause`` and ``resume`` subcommands to ``subparsers``."""
pause_parser = subparsers.add_parser(
"pause",
help="Emergency stop: pause cron/kanban dispatch and new gateway turns",
description=(
"Engage the global emergency stop. Halts NEW work only — cron "
"dispatch, kanban dispatch, and new gateway turns — until "
"`hermes resume`. In-flight work is never killed."
),
)
pause_parser.add_argument(
"--reason",
default=None,
help="Optional reason stored in the sentinel and shown to users",
)
pause_parser.set_defaults(func=cmd_pause)
resume_parser = subparsers.add_parser(
"resume",
help="Lift the emergency stop set by `hermes pause`",
description="Remove the ESTOP sentinel; dispatch resumes on the next tick.",
)
resume_parser.set_defaults(func=cmd_resume)
+541
View File
@@ -0,0 +1,541 @@
"""``hermes peer`` — bot-to-bot DMs across machines/gateways.
A *peer* is another Hermes gateway (any machine: homelab, Spark, Hermes
Cloud) running the ``api_server`` platform. Registering it here gives every
bot on THIS machine a transport to message bots on THAT machine:
hermes peer add spark --url http://spark.lan:8377 --key <API_SERVER_KEY>
hermes peer dm spark "Message from 🤖 dixie (@dixie): disk status?"
hermes peer dm spark/researcher "..." # named profile (multiplexed peer)
hermes peer run spark --idempotency-key ticket-123 < /tmp/long-task.txt
hermes peer status spark run_abc123
``dm`` resolves the remote agent's canonical "Bot Chat" session (by title,
creating it when missing), runs ONE synchronous agent turn over the peer's
existing ``POST /api/sessions/{id}/chat`` endpoint, and prints the reply on
stdout — the exact cross-machine twin of the local
``hermes -p <bot> chat --in ~ -c "Bot Chat" ...`` bot-messaging command, so
the Bot Mode protocol composes over it unchanged.
``run`` starts the same canonical-session turn through the asynchronous Runs
API and returns a ``run_id`` immediately. ``status`` polls that handle without
holding the original HTTP connection open. Use this pair for long turns.
Design notes:
- No new server surface: the peer's stock api_server is the transport.
- Peer labels/URLs live in config.yaml (``bot_peers``); the peer's
API_SERVER_KEY is a credential and lives in ``~/.hermes/.env`` as
``HERMES_PEER_<NAME>_KEY``.
- Named-profile targets use the peer's ``/p/<profile>/`` multiplex mirror;
the bare target is the peer gateway's own (launch) profile.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid
BOT_CHAT_TITLE = "Bot Chat"
_PEER_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
_PROFILE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$")
# One synchronous agent turn can legitimately take minutes.
DM_TIMEOUT_S = 600
LIST_TIMEOUT_S = 30
def _peer_key_env(name: str) -> str:
return f"HERMES_PEER_{name.upper().replace('-', '_')}_KEY"
def _load_peers() -> dict:
from hermes_cli.config import load_config
cfg = load_config() or {}
peers = cfg.get("bot_peers")
return peers if isinstance(peers, dict) else {}
def _save_peers(peers: dict) -> None:
from hermes_cli.config import load_config, save_config
cfg = load_config() or {}
cfg["bot_peers"] = peers
save_config(cfg)
def _peer_secret(name: str) -> str:
"""The peer's API key: profile-scoped secret store first, raw env fallback."""
env_name = _peer_key_env(name)
try:
from agent.secret_scope import get_secret
return (get_secret(env_name, "") or "").strip()
except Exception:
import os
return (os.environ.get(env_name) or "").strip()
def _request(
url: str,
key: str,
*,
method: str = "GET",
body: dict | None = None,
timeout: int = LIST_TIMEOUT_S,
headers: dict[str, str] | None = None,
) -> dict:
from hermes_cli.urllib_security import open_credentialed_url
data = json.dumps(body).encode("utf-8") if body is not None else None
request_headers = {
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"User-Agent": "hermes-peer-dm",
}
if headers:
request_headers.update(headers)
req = urllib.request.Request(
url,
data=data,
method=method,
headers=request_headers,
)
# The peer URL is user-registered (``hermes peer add``); a redirect to a
# different origin must not carry the Authorization: Bearer key with it —
# a compromised/MITM'd peer could otherwise harvest it. open_credentialed_url
# strips non-safelisted headers across a cross-origin redirect.
with open_credentialed_url(req, timeout=timeout) as resp:
payload = resp.read().decode("utf-8", "replace")
try:
parsed = json.loads(payload)
except ValueError as exc:
raise RuntimeError(f"Peer returned non-JSON response: {payload[:200]}") from exc
if not isinstance(parsed, dict):
raise RuntimeError("Peer returned a non-object JSON response")
return parsed
def _base_url(peer: dict, profile: str | None) -> str:
url = str(peer.get("url") or "").rstrip("/")
if profile:
# Multiplex mirror: same handlers, scoped to the named profile.
return f"{url}/p/{urllib.parse.quote(profile, safe='')}"
return url
def _find_bot_chat(base: str, key: str) -> str | None:
"""The remote canonical Bot Chat's session id, or None.
Bot Mode always HIDES canonical chats, so the plain listing (which
excludes hidden sessions) misses an existing Bot Chat and the caller
would try to create a duplicate that the peer's UNIQUE(title) guard
rejects (issue #91583). Newer peers support an exact-title lookup with
``include_hidden=1``; older peers ignore the unknown query params and
return the ordinary visible listing, so this single request degrades
to exactly the previous behavior against them.
"""
query = urllib.parse.urlencode({"limit": 200, "title": BOT_CHAT_TITLE, "include_hidden": 1})
listing = _request(f"{base}/api/sessions?{query}", key)
for session in listing.get("data") or []:
if isinstance(session, dict) and (session.get("title") or "").strip() == BOT_CHAT_TITLE:
return str(session.get("id") or "") or None
return None
def _ensure_bot_chat(base: str, key: str) -> str:
existing = _find_bot_chat(base, key)
if existing:
return existing
try:
created = _request(
f"{base}/api/sessions",
key,
method="POST",
body={"title": BOT_CHAT_TITLE, "source": "bot_peer_dm"},
)
except urllib.error.HTTPError as exc:
detail = _http_error_detail(exc)
if exc.code == 400 and "title" in detail.lower():
# Older peer (no title/include_hidden lookup support): its
# canonical Bot Chat exists but is hidden, so we couldn't see it
# and the create collided with the UNIQUE(title) guard.
raise RuntimeError(
f"Peer already has a '{BOT_CHAT_TITLE}' session but it is hidden and the "
f"peer's gateway is too old to expose hidden sessions to this lookup "
f"(HTTP 400: {detail}). Update the peer's hermes-agent, or unhide the "
f"session there: PATCH /api/sessions/<id> {{\"hidden\": false}}."
) from exc
raise
# Real api_server wraps the row: {"object": "hermes.session", "session": {...}}.
session = created.get("session") if isinstance(created.get("session"), dict) else created
session_id = str(session.get("id") or session.get("session_id") or "")
if not session_id:
raise RuntimeError("Peer did not return a session id for the new Bot Chat")
return session_id
def _parse_target(target: str) -> tuple[str, str | None]:
"""``<peer>`` or ``<peer>/<profile>`` → (peer, profile|None)."""
raw = (target or "").strip()
peer, _, profile = raw.partition("/")
peer = peer.strip()
profile = profile.strip() or None
if not peer:
raise ValueError("Peer name required (hermes peer dm <peer>[/<agent>] ...)")
if profile and not _PROFILE_RE.match(profile):
raise ValueError(f"Invalid agent/profile name: {profile!r}")
return peer, profile
def _http_error_detail(exc: urllib.error.HTTPError) -> str:
try:
body = exc.read().decode("utf-8", "replace")
parsed = json.loads(body)
message = parsed.get("error", {}).get("message") if isinstance(parsed, dict) else None
return message or body[:200]
except Exception:
return str(exc)
def _resolve_peer_target(target: str) -> tuple[str, str | None, dict, str]:
"""Resolve a registered target to ``(name, profile, config, key)``."""
peer_name, profile = _parse_target(target)
peer = _load_peers().get(peer_name)
if not isinstance(peer, dict) or not peer.get("url"):
raise LookupError(f"No peer named '{peer_name}'. Run: hermes peer list")
key = _peer_secret(peer_name)
if not key:
raise PermissionError(
f"No API key for peer '{peer_name}'. Set it: hermes peer add {peer_name} "
f"--url <url> --key <key> (or add {_peer_key_env(peer_name)}=<key> to ~/.hermes/.env)"
)
return peer_name, profile, peer, key
def _message_from_args(args) -> str:
message = (getattr(args, "message", None) or "").strip()
if not message and not sys.stdin.isatty():
message = sys.stdin.read().strip()
return message
def _peer_run_durability(base: str, key: str) -> bool | None:
"""Return durable support, or None when an older peer cannot advertise it."""
try:
capabilities = _request(f"{base}/v1/capabilities", key)
except Exception:
return None
features = capabilities.get("features")
if not isinstance(features, dict):
return None
contract = features.get("runs_idempotency")
if not isinstance(contract, dict) or not contract.get("supported"):
return None
return bool(contract.get("durable"))
def cmd_peer(args) -> int:
action = getattr(args, "peer_action", None)
if action in ("add", "set"):
name = (args.name or "").strip().lower()
if not _PEER_NAME_RE.match(name):
print(f"Invalid peer name: {name!r} (lowercase, digits, -, _; max 64)", file=sys.stderr)
return 2
url = (args.url or "").strip()
if not url.lower().startswith(("http://", "https://")):
print("Peer --url must be an http(s) gateway base URL, e.g. http://spark.lan:8377", file=sys.stderr)
return 2
peers = _load_peers()
peers[name] = {"url": url.rstrip("/"), **({"note": args.note.strip()} if getattr(args, "note", "") else {})}
_save_peers(peers)
key = (getattr(args, "key", "") or "").strip()
if key:
from hermes_cli.config import save_env_value
save_env_value(_peer_key_env(name), key)
print(f"Peer '{name}' saved ({url}) — key stored as {_peer_key_env(name)} in ~/.hermes/.env")
else:
print(
f"Peer '{name}' saved ({url}). No key given — set the peer's API_SERVER_KEY with:\n"
f" hermes peer add {name} --url {url} --key <key>\n"
f" (or add {_peer_key_env(name)}=<key> to ~/.hermes/.env)"
)
return 0
if action in ("remove", "rm"):
name = (args.name or "").strip().lower()
peers = _load_peers()
if name not in peers:
print(f"No peer named '{name}'.", file=sys.stderr)
return 1
peers.pop(name)
_save_peers(peers)
print(f"Peer '{name}' removed (its {_peer_key_env(name)} entry in .env is kept; delete it manually if unused).")
return 0
if action in ("list", "ls", None):
peers = _load_peers()
if not peers:
print("No peers registered. Add one: hermes peer add <name> --url http://host:port --key <API_SERVER_KEY>")
return 0
for name in sorted(peers):
entry = peers[name] if isinstance(peers[name], dict) else {}
has_key = "key set" if _peer_secret(name) else f"NO KEY ({_peer_key_env(name)} unset)"
note = f"{entry.get('note')}" if entry.get("note") else ""
print(f"{name}\t{entry.get('url', '?')}\t[{has_key}]{note}")
return 0
if action in {"dm", "run", "status", "stop"}:
try:
peer_name, profile, peer, key = _resolve_peer_target(args.target)
except ValueError as exc:
print(str(exc), file=sys.stderr)
return 2
except (LookupError, PermissionError) as exc:
print(str(exc), file=sys.stderr)
return 1
base = _base_url(peer, profile)
if action in {"status", "stop"}:
run_id = (getattr(args, "run_id", None) or "").strip()
if not run_id:
print("Run ID required.", file=sys.stderr)
return 2
try:
result = _request(
f"{base}/v1/runs/{urllib.parse.quote(run_id, safe='')}"
+ ("/stop" if action == "stop" else ""),
key,
method="POST" if action == "stop" else "GET",
body={} if action == "stop" else None,
)
except urllib.error.HTTPError as exc:
print(
f"Peer '{peer_name}' rejected the request (HTTP {exc.code}): {_http_error_detail(exc)}",
file=sys.stderr,
)
return 1
except (urllib.error.URLError, TimeoutError, OSError, RuntimeError) as exc:
print(f"Could not reach peer '{peer_name}': {exc}", file=sys.stderr)
return 1
payload = {"peer": peer_name, "profile": profile, **result}
if getattr(args, "json", False):
print(json.dumps(payload))
else:
print(f"{run_id}: {result.get('status', 'unknown')}")
if action == "status" and result.get("output"):
print(result["output"])
elif action == "status" and result.get("error"):
print(result["error"], file=sys.stderr)
return 0
message = _message_from_args(args)
if not message:
print("Message required (argument or stdin).", file=sys.stderr)
return 2
if action == "run":
idempotency_key = (
getattr(args, "idempotency_key", None) or f"peer-{uuid.uuid4().hex}"
).strip()
if (
not idempotency_key
or len(idempotency_key) > 255
or re.search(r"[\r\n\x00]", idempotency_key)
):
print(
"Idempotency key must be 1-255 characters without control newlines.",
file=sys.stderr,
)
return 2
try:
durability = _peer_run_durability(base, key)
if durability is not True:
print(
"Warning: this peer does not advertise restart-durable "
"run replay; keep the run ID and avoid blind retries "
"after a gateway restart.",
file=sys.stderr,
)
session_id = _ensure_bot_chat(base, key)
result = _request(
f"{base}/v1/runs",
key,
method="POST",
body={"input": message, "session_id": session_id},
headers={"Idempotency-Key": idempotency_key},
)
except urllib.error.HTTPError as exc:
print(
f"Peer '{peer_name}' rejected the request (HTTP {exc.code}): {_http_error_detail(exc)}",
file=sys.stderr,
)
return 1
except (urllib.error.URLError, TimeoutError, OSError, RuntimeError) as exc:
print(f"Could not reach peer '{peer_name}': {exc}", file=sys.stderr)
return 1
run_id = str(result.get("run_id") or "")
if not run_id:
print(f"Peer '{peer_name}' did not return a run ID.", file=sys.stderr)
return 1
payload = {
"peer": peer_name,
"profile": profile,
"session_id": session_id,
"run_id": run_id,
"status": result.get("status") or "started",
"idempotency_key": idempotency_key,
"replayed": bool(result.get("replayed", False)),
}
if getattr(args, "json", False):
print(json.dumps(payload))
else:
replay = " (replayed)" if payload["replayed"] else ""
print(f"{run_id}: {payload['status']}{replay}")
print(f"session_id: {session_id}")
print(f"idempotency_key: {idempotency_key}")
return 0
try:
session_id = _ensure_bot_chat(base, key)
result = _request(
f"{base}/api/sessions/{urllib.parse.quote(session_id, safe='')}/chat",
key,
method="POST",
body={"message": message},
timeout=DM_TIMEOUT_S,
)
except urllib.error.HTTPError as exc:
print(f"Peer '{peer_name}' rejected the request (HTTP {exc.code}): {_http_error_detail(exc)}", file=sys.stderr)
return 1
except RuntimeError as exc:
print(f"Peer '{peer_name}': {exc}", file=sys.stderr)
return 1
except (urllib.error.URLError, TimeoutError, OSError) as exc:
print(f"Could not reach peer '{peer_name}': {exc}", file=sys.stderr)
return 1
reply = ""
msg = result.get("message")
if isinstance(msg, dict):
reply = str(msg.get("content") or "")
if getattr(args, "json", False):
print(json.dumps({"peer": peer_name, "profile": profile, "session_id": result.get("session_id") or session_id, "reply": reply}))
else:
print(reply or "(no reply)")
return 0
print("Unknown peer action. See: hermes peer --help", file=sys.stderr)
return 2
def build_peer_parser(subparsers) -> None:
"""Attach the ``peer`` subcommand to ``subparsers``."""
parser = subparsers.add_parser(
"peer",
help="Bot-to-bot DMs across machines (peer Hermes gateways)",
description=(
"Register other Hermes gateways as peers and message their agents. "
"'hermes peer dm <peer>[/<agent>] \"...\"' delivers into the remote "
"agent's canonical Bot Chat over the peer's API server and prints "
"the reply — the cross-machine twin of 'hermes -p <bot> chat'. "
"The peer must run the api_server platform; its API_SERVER_KEY is "
"stored locally as a credential in ~/.hermes/.env."
),
epilog=(
"Examples:\n"
" hermes peer add spark --url http://spark.lan:8377 --key <API_SERVER_KEY>\n"
" hermes peer list\n"
' hermes peer dm spark "Message from 🤖 dixie (@dixie): disk status?"\n'
' hermes peer dm spark/researcher "..." # named profile on a multiplexed peer\n'
" hermes peer run spark --idempotency-key ticket-123 < long-task.txt\n"
" hermes peer status spark run_abc123\n"
" hermes peer stop spark run_abc123\n"
" hermes peer remove spark\n"
"\n"
"Exit codes: 0 ok, 1 delivery/peer error, 2 usage error."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
peer_sub = parser.add_subparsers(dest="peer_action")
add_p = peer_sub.add_parser("add", aliases=["set"], help="Register (or update) a peer gateway")
add_p.add_argument("name", help="Peer name (lowercase slug, e.g. spark, homelab)")
add_p.add_argument("--url", required=True, help="Peer gateway base URL, e.g. http://spark.lan:8377")
add_p.add_argument("--key", default="", help="The peer's API_SERVER_KEY (stored in ~/.hermes/.env)")
add_p.add_argument("--note", default="", help="Optional description")
peer_sub.add_parser("list", aliases=["ls"], help="List registered peers")
rm_p = peer_sub.add_parser("remove", aliases=["rm"], help="Remove a peer")
rm_p.add_argument("name", help="Peer name")
dm_p = peer_sub.add_parser(
"dm",
help="Message an agent on a peer gateway and print its reply",
)
dm_p.add_argument(
"target", help="<peer> or <peer>/<agent> (named profile on a multiplexed peer)"
)
dm_p.add_argument(
"message", nargs="?", default=None, help="Message text (or stdin)"
)
dm_p.add_argument(
"--json", action="store_true", default=False, help="Emit a JSON result"
)
run_p = peer_sub.add_parser(
"run",
help="Start a long peer turn asynchronously and return its run ID",
)
run_p.add_argument(
"target", help="<peer> or <peer>/<agent> (named profile on a multiplexed peer)"
)
run_p.add_argument(
"message", nargs="?", default=None, help="Message text (or stdin)"
)
run_p.add_argument(
"--idempotency-key",
default=None,
help="Stable retry key (generated when omitted)",
)
run_p.add_argument(
"--json", action="store_true", default=False, help="Emit a JSON result"
)
status_p = peer_sub.add_parser(
"status",
help="Read the status and final output of an asynchronous peer run",
)
status_p.add_argument(
"target", help="<peer> or <peer>/<agent> (named profile on a multiplexed peer)"
)
status_p.add_argument("run_id", help="Run ID returned by 'hermes peer run'")
status_p.add_argument(
"--json", action="store_true", default=False, help="Emit a JSON result"
)
stop_p = peer_sub.add_parser(
"stop",
help="Stop one asynchronous peer run without affecting another turn",
)
stop_p.add_argument(
"target", help="<peer> or <peer>/<agent> (named profile on a multiplexed peer)"
)
stop_p.add_argument("run_id", help="Run ID returned by 'hermes peer run'")
stop_p.add_argument(
"--json", action="store_true", default=False, help="Emit a JSON result"
)
parser.set_defaults(func=cmd_peer)
+230
View File
@@ -0,0 +1,230 @@
"""``hermes plugins`` subcommand parser.
Extracted from ``hermes_cli/main.py:main()`` (god-file Phase 2 follow-up).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None:
"""Attach the ``plugins`` subcommand to ``subparsers``."""
plugins_parser = subparsers.add_parser(
"plugins",
help="Manage and validate plugins",
description=(
"Install, update, remove, list, or validate native Hermes plugins "
"and portable Agent Plugins v1 packages. Portable packages install disabled."
),
)
plugins_subparsers = plugins_parser.add_subparsers(dest="plugins_action")
plugins_install = plugins_subparsers.add_parser(
"install", help="Install a plugin from a Git URL, owner/repo, or index name"
)
plugins_install.add_argument(
"identifier",
help=(
"Git URL, owner/repo shorthand (e.g. anpicasso/hermes-plugin-chrome-profiles), "
"or a bare plugin name resolved through the community index "
"(see `hermes plugins search`)"
),
)
plugins_install.add_argument(
"--force",
"-f",
action="store_true",
help="Remove existing plugin and reinstall",
)
plugins_install.add_argument(
"--ref",
metavar="COMMIT_SHA",
help="Install exactly one immutable 40-character Git commit SHA",
)
_install_enable_group = plugins_install.add_mutually_exclusive_group()
_install_enable_group.add_argument(
"--enable",
action="store_true",
help="Auto-enable the plugin after install (skip confirmation prompt)",
)
_install_enable_group.add_argument(
"--no-enable",
action="store_true",
help="Install disabled (skip confirmation prompt); enable later with `hermes plugins enable <name>`",
)
plugins_search = plugins_subparsers.add_parser(
"search", help="Search the community plugin index"
)
plugins_search.add_argument(
"term",
nargs="?",
default="",
help="Search term matched fuzzily against name, description, and tags "
"(omit to browse the full index)",
)
plugins_search.add_argument(
"--json",
action="store_true",
help="Print machine-readable JSON",
)
plugins_search.add_argument(
"--capability",
metavar="CAP",
help="Filter by declared capability (e.g. tools, platform, commands)",
)
plugins_search.add_argument(
"--refresh",
action="store_true",
help="Bypass the local cache and re-fetch the index",
)
plugins_update = plugins_subparsers.add_parser(
"update", help="Pull latest changes for an installed plugin"
)
plugins_update.add_argument("name", help="Plugin name to update")
plugins_remove = plugins_subparsers.add_parser(
"remove", aliases=["rm", "uninstall"], help="Remove an installed plugin"
)
plugins_remove.add_argument("name", help="Plugin directory name to remove")
plugins_list = plugins_subparsers.add_parser(
"list", aliases=["ls"], help="List installed plugins"
)
plugins_list.add_argument(
"--enabled",
action="store_true",
help="Show only enabled plugins",
)
plugins_list.add_argument(
"--user",
action="store_true",
help="Show only user-installed plugins (including git plugins)",
)
plugins_list.add_argument(
"--no-bundled",
action="store_true",
help="Hide bundled plugins",
)
plugins_list.add_argument(
"--plain",
action="store_true",
help="Print compact plain-text output instead of a Rich table",
)
plugins_list.add_argument(
"--json",
action="store_true",
help="Print machine-readable JSON",
)
plugins_enable = plugins_subparsers.add_parser(
"enable", help="Enable a disabled plugin"
)
plugins_enable.add_argument("name", help="Plugin name to enable")
_enable_override_group = plugins_enable.add_mutually_exclusive_group()
_enable_override_group.add_argument(
"--allow-tool-override",
action="store_true",
help="Grant this plugin permission to replace built-in tools "
"(e.g. shell_exec, write_file). Skips the confirmation prompt.",
)
_enable_override_group.add_argument(
"--no-allow-tool-override",
action="store_true",
help="Enable without granting built-in tool override (skip prompt).",
)
plugins_disable = plugins_subparsers.add_parser(
"disable", help="Disable a plugin without removing it"
)
plugins_disable.add_argument("name", help="Plugin name to disable")
plugins_capabilities = plugins_subparsers.add_parser(
"capabilities",
help="Show declared vs granted capabilities per plugin",
description=(
"Show each plugin's declared capabilities (from plugin.yaml) "
"against what the user has granted. Capabilities are a consent "
"and audit layer over host API surfaces — NOT a sandbox."
),
)
plugins_capabilities.add_argument(
"name",
nargs="?",
default=None,
help="Plugin id to inspect (omit to list all plugins with capabilities)",
)
plugins_doctor = plugins_subparsers.add_parser(
"doctor", help="Validate a plugin with the real runtime contracts"
)
plugins_doctor.add_argument(
"target",
nargs="?",
default=".",
help="Plugin path or installed plugin id (default: current directory)",
)
plugins_doctor.add_argument(
"--ci",
action="store_true",
help="Exit non-zero when validation reports an error",
)
plugins_pack = plugins_subparsers.add_parser(
"pack",
help="Declarative, shareable plugin sets (hermes-pack.yaml)",
description=(
"Install, export, or inspect plugin packs — a single YAML file "
"pinning a set of plugins to exact commit SHAs, with optional "
"non-secret config seeds. Installing a pack fans out to ordinary "
"pinned installs; capability consent stays per-plugin."
),
)
pack_subparsers = plugins_pack.add_subparsers(dest="pack_action")
pack_install = pack_subparsers.add_parser(
"install", help="Review and install a pack from a file path or https URL"
)
pack_install.add_argument(
"source", help="Path to a hermes-pack.yaml file, or an https:// URL"
)
pack_install.add_argument(
"--force",
"-f",
action="store_true",
help="Reinstall plugins that already exist",
)
pack_export = pack_subparsers.add_parser(
"export",
help="Emit a pack YAML for the current install on stdout",
)
pack_export.add_argument(
"--enabled-only",
action="store_true",
help="Only include plugins currently in plugins.enabled",
)
pack_export.add_argument(
"--name",
default="my-hermes-pack",
help="Pack name to embed in the exported YAML",
)
pack_show = pack_subparsers.add_parser(
"show", help="Dry-run: parse and display a pack without installing"
)
pack_show.add_argument(
"source", help="Path to a hermes-pack.yaml file, or an https:// URL"
)
plugins_show = plugins_subparsers.add_parser(
"show",
aliases=["info"],
help="Show details for a single plugin (including emits/listens)",
)
plugins_show.add_argument("name", help="Plugin name or key to show")
plugins_parser.set_defaults(func=cmd_plugins)
+211
View File
@@ -0,0 +1,211 @@
"""``hermes profile`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_profile_parser(subparsers, *, cmd_profile: Callable) -> None:
"""Attach the ``profile`` subcommand to ``subparsers``."""
# =========================================================================
# profile command
# =========================================================================
profile_parser = subparsers.add_parser(
"profile",
help="Manage profiles — multiple isolated Hermes instances",
)
profile_subparsers = profile_parser.add_subparsers(dest="profile_action")
profile_subparsers.add_parser("list", help="List all profiles")
profile_use = profile_subparsers.add_parser(
"use", help="Set sticky default profile"
)
profile_use.add_argument("profile_name", help="Profile name (or 'default')")
profile_create = profile_subparsers.add_parser(
"create", help="Create a new profile"
)
profile_create.add_argument(
"profile_name", help="Profile name (lowercase, alphanumeric)"
)
profile_create.add_argument(
"--clone",
action="store_true",
help="Copy config.yaml, .env, SOUL.md, and skills from active profile",
)
profile_create.add_argument(
"--clone-all",
action="store_true",
help="Full copy of active profile (all state, excluding per-profile history)",
)
profile_create.add_argument(
"--clone-from",
metavar="SOURCE",
help="Source profile to clone from; implies --clone unless --clone-all is set",
)
profile_create.add_argument(
"--no-alias", action="store_true", help="Skip wrapper script creation"
)
profile_create.add_argument(
"--no-skills",
action="store_true",
help="Create an empty profile with no bundled skills (opts out of `hermes update` skill sync)",
)
profile_create.add_argument(
"--description",
default=None,
help="One- or two-sentence description of what this profile is good at. "
"Used by the kanban decomposer to route tasks based on role instead "
"of profile name alone. Skip and add later via `hermes profile describe`.",
)
profile_delete = profile_subparsers.add_parser("delete", help="Delete a profile")
profile_delete.add_argument("profile_name", help="Profile to delete")
profile_delete.add_argument(
"-y", "--yes", action="store_true", help="Skip confirmation prompt"
)
profile_describe = profile_subparsers.add_parser(
"describe",
help="Read or set a profile's description (used by the kanban orchestrator)",
)
profile_describe.add_argument(
"profile_name",
nargs="?",
default=None,
help="Profile to describe (omit + use --all --auto to sweep)",
)
profile_describe.add_argument(
"--text",
default=None,
help="Set description to this exact text (overwrites any existing description)",
)
profile_describe.add_argument(
"--auto",
action="store_true",
help="Auto-generate description via the auxiliary LLM "
"(uses auxiliary.profile_describer)",
)
profile_describe.add_argument(
"--overwrite",
action="store_true",
help="With --auto, replace user-authored descriptions too (default: only "
"fill in missing or previously-auto descriptions)",
)
profile_describe.add_argument(
"--all",
dest="all_missing",
action="store_true",
help="With --auto, run on every profile missing a description",
)
profile_show = profile_subparsers.add_parser("show", help="Show profile details")
profile_show.add_argument("profile_name", help="Profile to show")
profile_alias = profile_subparsers.add_parser(
"alias", help="Manage wrapper scripts"
)
profile_alias.add_argument("profile_name", help="Profile name")
profile_alias.add_argument(
"--remove", action="store_true", help="Remove the wrapper script"
)
profile_alias.add_argument(
"--name",
dest="alias_name",
metavar="NAME",
help="Custom alias name (default: profile name)",
)
profile_rename = profile_subparsers.add_parser(
"rename",
help="Rename a profile ('default': sets a display name; id unchanged)",
)
profile_rename.add_argument("old_name", help="Current profile name")
profile_rename.add_argument(
"new_name",
help="New profile name (for 'default': a display name — the canonical id stays 'default')",
)
profile_export = profile_subparsers.add_parser(
"export", help="Export a profile to archive"
)
profile_export.add_argument("profile_name", help="Profile to export")
profile_export.add_argument(
"-o", "--output", default=None,
help="Output file (default: a managed profile-exports/<name>-<timestamp>.tar.gz "
"under the default Hermes home)",
)
profile_import = profile_subparsers.add_parser(
"import", help="Import a profile from archive"
)
profile_import.add_argument("archive", help="Path to .tar.gz archive")
profile_import.add_argument(
"--name",
dest="import_name",
metavar="NAME",
help="Profile name (default: inferred from archive)",
)
# ---------- Distribution subcommands (issue #20456) ----------
profile_install = profile_subparsers.add_parser(
"install",
help="Install a profile distribution from a git URL or local directory",
description=(
"Install a Hermes profile distribution. SOURCE can be a git URL "
"(github.com/user/repo, https://..., git@...) or a local "
"directory containing distribution.yaml at its root."
),
)
profile_install.add_argument(
"source",
help="Distribution source (git URL or local directory)",
)
profile_install.add_argument(
"--name", dest="install_name", metavar="NAME",
help="Override profile name (default: read from manifest)",
)
profile_install.add_argument(
"--alias", action="store_true",
help="Create a shell wrapper alias for the installed profile",
)
profile_install.add_argument(
"--force", action="store_true",
help="Overwrite an existing profile of the same name (user data preserved)",
)
profile_install.add_argument(
"-y", "--yes", action="store_true",
help="Skip manifest preview confirmation",
)
profile_update = profile_subparsers.add_parser(
"update",
help="Re-pull a distribution and apply updates (user data preserved)",
description=(
"Fetch the distribution from its recorded source and overwrite "
"distribution-owned files (SOUL.md, skills/, cron/, mcp.json). "
"User data (memories, sessions, auth, .env) is never touched. "
"config.yaml is preserved unless --force-config is passed."
),
)
profile_update.add_argument("profile_name", help="Profile to update")
profile_update.add_argument(
"--force-config", action="store_true",
help="Also overwrite config.yaml (normally preserved to keep user overrides)",
)
profile_update.add_argument(
"-y", "--yes", action="store_true",
help="Skip confirmation",
)
profile_info = profile_subparsers.add_parser(
"info",
help="Show a profile's distribution manifest (version, requirements, source)",
)
profile_info.add_argument("profile_name", help="Profile to inspect")
profile_parser.set_defaults(func=cmd_profile)
+36
View File
@@ -0,0 +1,36 @@
"""``hermes prompt-size`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_prompt_size_parser(subparsers, *, cmd_prompt_size: Callable) -> None:
"""Attach the ``prompt-size`` subcommand to ``subparsers``."""
# =========================================================================
# prompt-size command
# =========================================================================
prompt_size_parser = subparsers.add_parser(
"prompt-size",
help="Show a byte breakdown of the system prompt + tool schemas",
description=(
"Report the fixed prompt budget for a fresh session: system "
"prompt total, skills index, memory, user profile, and tool-schema "
"JSON. Runs offline (no API call)."
),
)
prompt_size_parser.add_argument(
"--platform",
default="cli",
help="Platform to simulate (cli, telegram, discord, ...). Default: cli",
)
prompt_size_parser.add_argument(
"--json",
action="store_true",
help="Emit the breakdown as JSON",
)
prompt_size_parser.set_defaults(func=cmd_prompt_size)
+62
View File
@@ -0,0 +1,62 @@
"""``hermes security`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_security_parser(subparsers, *, cmd_security: Callable) -> None:
"""Attach the ``security`` subcommand to ``subparsers``."""
# =========================================================================
security_parser = subparsers.add_parser(
"security",
help="Supply-chain audit (OSV.dev) for venv, plugins, and MCP servers",
description=(
"On-demand vulnerability scan against OSV.dev. Covers the Hermes "
"venv (installed PyPI dists), Python deps declared by plugins under "
"~/.hermes/plugins/, and pinned npx/uvx MCP servers in config.yaml. "
"Does NOT scan globally-installed packages or editor/browser extensions."
),
)
security_subparsers = security_parser.add_subparsers(
dest="security_command",
metavar="<subcommand>",
)
audit_parser = security_subparsers.add_parser(
"audit",
help="Run a one-shot supply-chain audit",
description="Query OSV.dev for known vulnerabilities in installed components.",
)
audit_parser.add_argument(
"--json",
action="store_true",
help="Emit machine-readable JSON instead of human-readable text",
)
audit_parser.add_argument(
"--fail-on",
default="critical",
choices=["low", "moderate", "high", "critical"],
help="Exit non-zero when any finding meets this severity (default: critical)",
)
audit_parser.add_argument(
"--skip-venv",
action="store_true",
help="Skip scanning the Hermes Python venv",
)
audit_parser.add_argument(
"--skip-plugins",
action="store_true",
help="Skip scanning plugin requirements files",
)
audit_parser.add_argument(
"--skip-mcp",
action="store_true",
help="Skip scanning pinned MCP servers in config.yaml",
)
audit_parser.set_defaults(func=cmd_security)
security_parser.set_defaults(func=cmd_security)
+67
View File
@@ -0,0 +1,67 @@
"""``hermes setup`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_setup_parser(subparsers, *, cmd_setup: Callable) -> None:
"""Attach the ``setup`` subcommand to ``subparsers``."""
# =========================================================================
# setup command
# =========================================================================
setup_parser = subparsers.add_parser(
"setup",
help="Interactive setup wizard",
description="Configure Hermes Agent with an interactive wizard. "
"Run a specific section: "
"hermes setup model|tts|terminal|gateway|tools|telemetry|agent",
)
setup_parser.add_argument(
"section",
nargs="?",
choices=[
"model",
"tts",
"terminal",
"gateway",
"tools",
"telemetry",
"agent",
],
default=None,
help="Run a specific setup section instead of the full wizard",
)
setup_parser.add_argument(
"--non-interactive",
action="store_true",
help="Non-interactive mode (use defaults/env vars)",
)
setup_parser.add_argument(
"--reset", action="store_true", help="Reset configuration to defaults"
)
setup_parser.add_argument(
"--reconfigure",
action="store_true",
help="(Default on existing installs.) Re-run the full wizard, "
"showing current values as defaults. Kept for backwards "
"compatibility — a bare 'hermes setup' now does this.",
)
setup_parser.add_argument(
"--quick",
action="store_true",
help="On existing installs: only prompt for items that are missing "
"or unset, instead of running the full reconfigure wizard.",
)
setup_parser.add_argument(
"--portal",
action="store_true",
help="One-shot Nous Portal setup: log in via OAuth, pick a Nous "
"model, set Nous as the inference provider, and opt into the Tool "
"Gateway. Skips the rest of the wizard.",
)
setup_parser.set_defaults(func=cmd_setup)
+348
View File
@@ -0,0 +1,348 @@
"""``hermes skills`` subcommand parser.
Extracted from ``hermes_cli/main.py:main()`` (god-file Phase 2 follow-up).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_skills_parser(subparsers, *, cmd_skills: Callable) -> None:
"""Attach the ``skills`` subcommand to ``subparsers``."""
skills_parser = subparsers.add_parser(
"skills",
help="Search, install, configure, and manage skills",
description="Search, install, inspect, audit, configure, and manage skills from skills.sh, well-known agent skill endpoints, GitHub, ClawHub, and other registries.",
)
skills_subparsers = skills_parser.add_subparsers(dest="skills_action")
skills_trust = skills_subparsers.add_parser(
"trust",
help="Trust a project so its repo-local skills (./.hermes/skills, ./.agents/skills) load",
)
skills_trust.add_argument(
"path",
nargs="?",
default=None,
help="Project root to trust (default: enclosing git checkout of cwd)",
)
skills_untrust = skills_subparsers.add_parser(
"untrust", help="Revoke project-skill trust for a repo"
)
skills_untrust.add_argument(
"path",
nargs="?",
default=None,
help="Project root to untrust (default: enclosing git checkout of cwd)",
)
skills_browse = skills_subparsers.add_parser(
"browse", help="Browse all available skills (paginated)"
)
skills_browse.add_argument(
"--page", type=int, default=1, help="Page number (default: 1)"
)
skills_browse.add_argument(
"--size", type=int, default=20, help="Results per page (default: 20)"
)
skills_browse.add_argument(
"--source",
default="all",
choices=[
"all",
"official",
"skills-sh",
"well-known",
"github",
"clawhub",
"lobehub",
"browse-sh",
# Provider filters (GitHub taps stored under source="github"):
"nvidia",
"openai",
"anthropic",
"huggingface",
"voltagent",
"gstack",
"minimax",
],
help="Filter by source or provider (e.g. nvidia, openai) (default: all)",
)
skills_search = skills_subparsers.add_parser(
"search", help="Search skill registries"
)
skills_search.add_argument("query", help="Search query")
skills_search.add_argument(
"--source",
default="all",
choices=[
"all",
"official",
"skills-sh",
"well-known",
"github",
"clawhub",
"lobehub",
"browse-sh",
# Provider filters (GitHub taps stored under source="github"):
"nvidia",
"openai",
"anthropic",
"huggingface",
"voltagent",
"gstack",
"minimax",
],
help="Filter by source or provider (e.g. nvidia, openai)",
)
skills_search.add_argument("--limit", type=int, default=25, help="Max results")
skills_search.add_argument(
"--json",
action="store_true",
help="Output JSON instead of a table (full identifiers, scripting-friendly)",
)
skills_install = skills_subparsers.add_parser("install", help="Install a skill")
skills_install.add_argument(
"identifier",
help="Skill identifier (e.g. openai/skills/skill-creator) or a direct HTTP(S) URL to a SKILL.md file",
)
skills_install.add_argument(
"--category", default="", help="Category folder to install into"
)
skills_install.add_argument(
"--name",
default="",
help="Override the skill name (useful when installing from a URL whose SKILL.md has no `name:` frontmatter)",
)
skills_install.add_argument(
"--force", action="store_true", help="Install despite blocked scan verdict"
)
skills_install.add_argument(
"--yes",
"-y",
action="store_true",
help="Skip confirmation prompt (needed in TUI mode)",
)
skills_inspect = skills_subparsers.add_parser(
"inspect", help="Preview a skill without installing"
)
skills_inspect.add_argument("identifier", help="Skill identifier")
skills_list = skills_subparsers.add_parser("list", help="List installed skills")
skills_list.add_argument(
"--source", default="all", choices=["all", "hub", "builtin", "local"]
)
skills_list.add_argument(
"--enabled-only",
action="store_true",
help="Hide disabled skills. Use with -p <profile> to see exactly "
"which skills will load for that profile.",
)
skills_check = skills_subparsers.add_parser(
"check", help="Check installed hub skills for updates"
)
skills_check.add_argument(
"name", nargs="?", help="Specific skill to check (default: all)"
)
skills_update = skills_subparsers.add_parser(
"update", help="Update installed hub skills"
)
skills_update.add_argument(
"name",
nargs="?",
help="Specific skill to update (default: all outdated skills)",
)
skills_update.add_argument(
"--force",
action="store_true",
help="Overwrite skills you have edited locally (they are skipped by default)",
)
skills_audit = skills_subparsers.add_parser(
"audit", help="Re-scan installed hub skills"
)
skills_audit.add_argument(
"name", nargs="?", help="Specific skill to audit (default: all)"
)
skills_audit.add_argument(
"--deep",
action="store_true",
help="Run AST-level analysis on Python files (opt-in diagnostic)",
)
skills_uninstall = skills_subparsers.add_parser(
"uninstall", help="Remove a hub-installed skill"
)
skills_uninstall.add_argument("name", help="Skill name to remove")
skills_uninstall.add_argument(
"--yes",
"-y",
action="store_true",
help="Skip confirmation prompt",
)
skills_reset = skills_subparsers.add_parser(
"reset",
help="Reset a bundled skill — clears 'user-modified' tracking so updates work again",
description=(
"Clear a bundled skill's entry from the sync manifest (~/.hermes/skills/.bundled_manifest) "
"so future 'hermes update' runs stop marking it as user-modified. Pass --restore to also "
"replace the current copy with the bundled version."
),
)
skills_reset.add_argument(
"name", help="Skill name to reset (e.g. google-workspace)"
)
skills_reset.add_argument(
"--restore",
action="store_true",
help="Also delete the current copy and re-copy the bundled version",
)
skills_reset.add_argument(
"--yes",
"-y",
action="store_true",
help="Skip confirmation prompt when using --restore",
)
skills_list_modified = skills_subparsers.add_parser(
"list-modified",
help="List bundled skills you've edited (which `hermes update` keeps)",
description=(
"Show the bundled skills whose local copy differs from the version last "
"synced, i.e. the ones `hermes update` reports as user-modified and skips. "
"Use `hermes skills diff <name>` to see changes and `hermes skills reset "
"<name>` to resume updates."
),
)
skills_list_modified.add_argument(
"--json",
action="store_true",
help="Output the list as JSON",
)
skills_diff = skills_subparsers.add_parser(
"diff",
help="Show how your copy of a bundled skill differs from the stock version",
description=(
"Print a unified diff between your local copy of a bundled skill and the "
"current bundled (stock) version, so you can confirm what changed before "
"running `hermes skills reset`."
),
)
skills_diff.add_argument(
"name", help="Skill name to diff (e.g. google-workspace)"
)
skills_opt_out = skills_subparsers.add_parser(
"opt-out",
help="Stop bundled skills from being seeded into this profile",
description=(
"Write the .no-bundled-skills marker so the installer, "
"`hermes update`, and any direct sync stop seeding bundled skills "
"into the active profile. By default nothing already on disk is "
"touched. Pass --remove to ALSO delete bundled skills that are "
"unmodified (user-edited and hub/local skills are never removed)."
),
)
skills_opt_out.add_argument(
"--remove",
action="store_true",
help="Also delete already-present unmodified bundled skills",
)
skills_opt_out.add_argument(
"--yes",
"-y",
action="store_true",
help="Skip confirmation prompt when using --remove",
)
skills_opt_in = skills_subparsers.add_parser(
"opt-in",
help="Re-enable bundled-skill seeding (undo opt-out)",
description=(
"Remove the .no-bundled-skills marker so bundled skills are seeded "
"again on the next `hermes update`. Pass --sync to re-seed now."
),
)
skills_opt_in.add_argument(
"--sync",
action="store_true",
help="Re-seed bundled skills immediately instead of waiting for update",
)
skills_repair_official = skills_subparsers.add_parser(
"repair-official",
help="Backfill or restore official optional skills from repo source",
description=(
"Repair official optional skill provenance. By default, only backfills "
"hub metadata for exact matches. Pass --restore to replace missing or "
"mutated active copies from optional-skills/, moving existing copies to "
"a restore backup first. Use name 'all' to repair every optional skill."
),
)
skills_repair_official.add_argument(
"name", help="Official optional skill folder/frontmatter name, or 'all'"
)
skills_repair_official.add_argument(
"--restore",
action="store_true",
help="Restore from official optional source, backing up existing matching copies",
)
skills_repair_official.add_argument(
"--yes",
"-y",
action="store_true",
help="Skip confirmation prompt when using --restore",
)
skills_publish = skills_subparsers.add_parser(
"publish", help="Publish a skill to a registry"
)
skills_publish.add_argument("skill_path", help="Path to skill directory")
skills_publish.add_argument(
"--to", default="github", choices=["github", "clawhub"], help="Target registry"
)
skills_publish.add_argument(
"--repo", default="", help="Target GitHub repo (e.g. openai/skills)"
)
skills_snapshot = skills_subparsers.add_parser(
"snapshot", help="Export/import skill configurations"
)
snapshot_subparsers = skills_snapshot.add_subparsers(dest="snapshot_action")
snap_export = snapshot_subparsers.add_parser(
"export", help="Export installed skills to a file"
)
snap_export.add_argument("output", help="Output JSON file path (use - for stdout)")
snap_import = snapshot_subparsers.add_parser(
"import", help="Import and install skills from a file"
)
snap_import.add_argument("input", help="Input JSON file path")
snap_import.add_argument(
"--force", action="store_true", help="Force install despite caution verdict"
)
skills_tap = skills_subparsers.add_parser("tap", help="Manage skill sources")
tap_subparsers = skills_tap.add_subparsers(dest="tap_action")
tap_subparsers.add_parser("list", help="List configured taps")
tap_add = tap_subparsers.add_parser("add", help="Add a GitHub repo as skill source")
tap_add.add_argument("repo", help="GitHub repo (e.g. owner/repo)")
tap_rm = tap_subparsers.add_parser("remove", help="Remove a tap")
tap_rm.add_argument("name", help="Tap name to remove")
# config sub-action: interactive enable/disable
skills_subparsers.add_parser(
"config",
help="Interactive skill configuration — enable/disable individual skills",
)
skills_parser.set_defaults(func=cmd_skills)
+30
View File
@@ -0,0 +1,30 @@
"""``hermes skin`` subcommand parser."""
from __future__ import annotations
from typing import Callable
def build_skin_parser(subparsers, *, cmd_skin: Callable) -> None:
"""Attach the ``skin`` subcommand to ``subparsers``."""
skin_parser = subparsers.add_parser(
"skin",
help="List, switch, and tweak skins",
description="Manage Hermes skins. `set` tweaks one color of the active skin in place.",
)
skin_subparsers = skin_parser.add_subparsers(dest="skin_command")
skin_subparsers.add_parser("list", help="List available skins")
skin_use = skin_subparsers.add_parser("use", help="Switch the active skin")
skin_use.add_argument("name", help="Skin name")
# skin set — change ONE color of the active skin in place (bg untouched).
skin_set = skin_subparsers.add_parser(
"set", help="Set one color of the active skin (e.g. `skin set ui_tool '#00FFFF'`)"
)
skin_set.add_argument("key", help="Color key (e.g. ui_tool, ui_accent, background)")
skin_set.add_argument("value", help="Hex color (#rrggbb)")
skin_set.add_argument("--skin", help="Target a specific skin instead of the active one")
skin_parser.set_defaults(func=cmd_skin)
+93
View File
@@ -0,0 +1,93 @@
"""``hermes slack`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_slack_parser(subparsers, *, cmd_slack: Callable) -> None:
"""Attach the ``slack`` subcommand to ``subparsers``."""
# =========================================================================
# slack command
# =========================================================================
slack_parser = subparsers.add_parser(
"slack",
help="Slack integration helpers (manifest generation, etc.)",
description="Slack integration helpers for Hermes.",
)
slack_sub = slack_parser.add_subparsers(dest="slack_command")
slack_manifest = slack_sub.add_parser(
"manifest",
help="Print or write a Slack app manifest with every gateway command "
"registered as a native slash (/btw, /stop, /model, ...)",
description=(
"Generate a Slack app manifest that registers every gateway "
"command in COMMAND_REGISTRY as a first-class Slack slash "
"command (matching Discord and Telegram parity). Paste the "
"output into Slack app config → Features → App Manifest → "
"Edit, then Save. Reinstall the app if Slack prompts for it."
),
)
slack_manifest.add_argument(
"--write",
nargs="?",
const=True,
default=None,
metavar="PATH",
help="Write manifest to a file instead of stdout. With no PATH "
"writes to $HERMES_HOME/slack-manifest.json.",
)
slack_manifest.add_argument(
"--name",
default=None,
help='Bot display name (default: "Hermes")',
)
slack_manifest.add_argument(
"--description",
default=None,
help="Bot description shown in Slack's app directory.",
)
slack_long_description = slack_manifest.add_mutually_exclusive_group()
slack_long_description.add_argument(
"--long-description",
default=None,
metavar="TEXT",
help="Set Slack's long app description (175-4,000 characters).",
)
slack_long_description.add_argument(
"--long-description-file",
default=None,
metavar="PATH",
help=(
"Read Slack's long app description from a UTF-8 text file "
"(175-4,000 characters)."
),
)
slack_manifest.add_argument(
"--slashes-only",
action="store_true",
help="Emit only the features.slash_commands array (for merging "
"into an existing manifest manually).",
)
slack_messaging = slack_manifest.add_mutually_exclusive_group()
slack_messaging.add_argument(
"--no-assistant",
action="store_true",
help="Omit Slack AI Assistant mode (assistant_view, assistant:write "
"scope, assistant_thread_* events). DMs then render as a flat chat "
"where bare slash commands (/help, /new) work inline instead of "
"Slack's Assistant thread pane.",
)
slack_messaging.add_argument(
"--agent-view",
action="store_true",
help="Emit Slack's Agent messaging experience (agent_view, "
"app_home_opened + message.im) instead of the legacy assistant_view "
"experience. This changes Slack's app messaging surface and cannot "
"be reversed in Slack after applying the manifest.",
)
slack_parser.set_defaults(func=cmd_slack)
+28
View File
@@ -0,0 +1,28 @@
"""``hermes status`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_status_parser(subparsers, *, cmd_status: Callable) -> None:
"""Attach the ``status`` subcommand to ``subparsers``."""
# =========================================================================
# status command
# =========================================================================
status_parser = subparsers.add_parser(
"status",
help="Show status of all components",
description="Display status of Hermes Agent components",
)
status_parser.add_argument(
"--all", action="store_true", help="Show all details (redacted for sharing)"
)
status_parser.add_argument(
"--deep", action="store_true", help="Run deep checks (may take longer)"
)
status_parser.set_defaults(func=cmd_status)
+99
View File
@@ -0,0 +1,99 @@
"""``hermes sync`` subcommand parser — Skill Sync.
Cloned from ``hermes_cli/subcommands/cron.py`` — same injected-handler shape
(``func=cmd_sync``) so this module does not import ``main`` (cycle avoidance).
Skill Sync covers two surfaces, both under this one command for launch:
Personal — your own skills, across your own devices:
hermes sync status show gate/opt-in/head state
hermes sync pull pull and materialize opted-in skills
hermes sync push push opted-in skills
hermes sync now reconcile: pull then push
hermes sync enable <skill> opt a skill into sync
hermes sync disable <skill> opt a skill out of sync
hermes sync device [--name] show or set this device's label
Organisation — skills shared with your team:
hermes sync propose <skill> share a skill with your organisation
Sync is INERT unless the resolved Nous token carries the access-gate claim
AND a sync base URL is configured. The commands report that state rather than
failing opaquely.
"""
from __future__ import annotations
import argparse
from typing import Callable
def build_sync_parser(subparsers, *, cmd_sync: Callable) -> None:
"""Attach the ``sync`` subcommand (and its sub-actions) to ``subparsers``."""
sync_parser = subparsers.add_parser(
"sync",
help="Skill Sync — sync your skills across devices and with your team",
description=(
"Skill Sync keeps your skills with you. Personal sync moves your "
"own skills between your devices; if you belong to an "
"organisation, you also get its shared skills and can propose "
"your own back to the team."
),
epilog=(
"Examples:\n"
" hermes sync status what is synced, and from where\n"
" hermes sync enable my-skill include a skill in your sync\n"
" hermes sync now pull, then push\n"
" hermes sync propose my-skill share a skill with your team\n"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sync_sub = sync_parser.add_subparsers(dest="sync_command")
sync_sub.add_parser("status", help="Show what is synced, and from where")
sync_sub.add_parser(
"pull", help="Pull your synced skills (and your organisation's)"
)
sync_sub.add_parser("push", help="Push your opted-in skills")
sync_sub.add_parser("now", help="Reconcile now: pull then push")
enable = sync_sub.add_parser("enable", help="Include a skill in your sync")
enable.add_argument("skill", help="Skill name (frontmatter name / directory name)")
disable = sync_sub.add_parser("disable", help="Exclude a skill from your sync")
disable.add_argument("skill", help="Skill name (frontmatter name / directory name)")
device = sync_sub.add_parser(
"device",
help="Show or set this device's label (shown in the sync console)",
)
device.add_argument(
"--name",
dest="device_name",
default=None,
help="Set a human-friendly label for this device (e.g. \"Ben's Laptop\"). "
"Omit to print the current label.",
)
# Org-shared skills. A member's submission becomes a proposal an admin
# reviews; an admin's merges straight into the shared set. Accounts that
# aren't in a shared organisation are told so plainly.
propose = sync_sub.add_parser(
"propose",
help="Share a skill with your organisation",
description=(
"Submit one of your skills to your organisation's shared set. If "
"you are an admin it is added directly; otherwise it becomes a "
"proposal for an admin to review. Accounts that aren't part of a "
"shared organisation don't have this workflow."
),
)
propose.add_argument("name", help="Skill name to share")
propose.add_argument(
"-m",
"--message",
default=None,
help="Optional message describing the change",
)
sync_parser.set_defaults(func=cmd_sync)
+95
View File
@@ -0,0 +1,95 @@
"""``hermes tools`` subcommand parser.
Extracted from ``hermes_cli/main.py:main()`` (god-file Phase 2 follow-up).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_tools_parser(subparsers, *, cmd_tools: Callable) -> None:
"""Attach the ``tools`` subcommand to ``subparsers``."""
tools_parser = subparsers.add_parser(
"tools",
help="Configure which tools are enabled per platform",
description=(
"Enable, disable, or list tools for CLI, Telegram, Discord, etc.\n\n"
"Built-in toolsets use plain names (e.g. web, memory).\n"
"MCP tools use server:tool notation (e.g. github:create_issue).\n\n"
"Run 'hermes tools' with no subcommand for the interactive configuration UI."
),
)
tools_parser.add_argument(
"--summary",
action="store_true",
help="Print a summary of enabled tools per platform and exit",
)
tools_sub = tools_parser.add_subparsers(dest="tools_action")
# hermes tools list [--platform cli]
tools_list_p = tools_sub.add_parser(
"list",
help="Show all tools and their enabled/disabled status",
)
tools_list_p.add_argument(
"--platform",
default="cli",
help="Platform to show (default: cli)",
)
# hermes tools disable <name...> [--platform cli]
tools_disable_p = tools_sub.add_parser(
"disable",
help="Disable toolsets or MCP tools",
)
tools_disable_p.add_argument(
"names",
nargs="+",
metavar="NAME",
help="Toolset name (e.g. web) or MCP tool in server:tool form",
)
tools_disable_p.add_argument(
"--platform",
default="cli",
help="Platform to apply to (default: cli)",
)
# hermes tools enable <name...> [--platform cli]
tools_enable_p = tools_sub.add_parser(
"enable",
help="Enable toolsets or MCP tools",
)
tools_enable_p.add_argument(
"names",
nargs="+",
metavar="NAME",
help="Toolset name or MCP tool in server:tool form",
)
tools_enable_p.add_argument(
"--platform",
default="cli",
help="Platform to apply to (default: cli)",
)
# hermes tools post-setup <key>
tools_postsetup_p = tools_sub.add_parser(
"post-setup",
help="Run a provider's post-setup install hook (npm/pip/binary)",
description=(
"Run the install/bootstrap hook a tool backend declares — the\n"
"same step `hermes tools` runs after you pick a provider that\n"
"needs extra dependencies (browser Chromium, Camofox, cua-driver,\n"
"KittenTTS/Piper, ddgs, Spotify, Langfuse, xAI). Stable,\n"
"non-interactive target the dashboard spawns to drive backend\n"
"setup. Keys: agent_browser, camofox, cua_driver, kittentts,\n"
"piper, ddgs, spotify, langfuse, xai_grok."
),
)
tools_postsetup_p.add_argument(
"post_setup_key",
metavar="KEY",
help="Post-setup hook key (e.g. agent_browser, camofox, kittentts)",
)
tools_parser.set_defaults(func=cmd_tools)
+46
View File
@@ -0,0 +1,46 @@
"""``hermes uninstall`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_uninstall_parser(subparsers, *, cmd_uninstall: Callable) -> None:
"""Attach the ``uninstall`` subcommand to ``subparsers``."""
# =========================================================================
# uninstall command
# =========================================================================
uninstall_parser = subparsers.add_parser(
"uninstall",
help="Uninstall Hermes Agent",
description="Remove Hermes Agent from your system. Can keep configs/data for reinstall.",
)
uninstall_parser.add_argument(
"--full",
action="store_true",
help="Full uninstall - remove everything including configs and data",
)
uninstall_parser.add_argument(
"--gui",
action="store_true",
help="Uninstall only the desktop Chat GUI, leaving the agent intact",
)
uninstall_parser.add_argument(
"--gui-summary",
action="store_true",
help="Print a JSON summary of installed GUI/agent artifacts and exit "
"(used by the desktop app to gate uninstall options)",
)
uninstall_parser.add_argument(
"--yes", "-y", action="store_true", help="Skip confirmation prompts"
)
uninstall_parser.add_argument(
"--dry-run",
action="store_true",
help="Print what uninstall would remove without changing anything",
)
uninstall_parser.set_defaults(func=cmd_uninstall)
+114
View File
@@ -0,0 +1,114 @@
"""``hermes update`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_update_parser(subparsers, *, cmd_update: Callable) -> None:
"""Attach the ``update`` subcommand to ``subparsers``."""
# =========================================================================
# update command
# =========================================================================
update_parser = subparsers.add_parser(
"update",
help="Update Hermes Agent to the latest version",
description="Pull the latest changes from git and reinstall dependencies",
)
update_parser.add_argument(
"--gateway",
action="store_true",
default=False,
help="Gateway mode: use file-based IPC for prompts instead of stdin (used internally by /update)",
)
update_parser.add_argument(
"--check",
action="store_true",
default=False,
help="Check whether an update is available without installing anything",
)
update_parser.add_argument(
"--plan",
action="store_true",
default=False,
help=(
"Show the update plan and exit without changing anything: install "
"kind (git/docker/nix), every running Hermes service across all "
"profiles with its supervisor and running code version, and how "
"each will be restarted. Read-only; safe on a live fleet."
),
)
update_parser.add_argument(
"--no-backup",
action="store_true",
default=False,
help="Skip ALL pre-update backups for this run (both the quick state snapshot and the full zip; overrides updates.pre_update_backup)",
)
update_parser.add_argument(
"--backup",
action="store_true",
default=False,
help="Force a FULL pre-update backup (quick state snapshot + HERMES_HOME zip) for this run, regardless of updates.pre_update_backup",
)
update_parser.add_argument(
"--yes",
"-y",
action="store_true",
default=False,
help="Run without blocking on prompts: accepts the config-migration and stash-restore prompts, skips the fork-upstream prompt without adding a remote. API-key entry is skipped; run 'hermes config migrate' separately for those.",
)
update_parser.add_argument(
"--keep-stash",
action="store_true",
default=False,
help=(
"Do NOT re-apply local changes after the update. Uncommitted "
"changes are still stashed so the update can proceed, but they "
"stay parked in git stash instead of being restored onto the "
"updated code. Used by the desktop updater so local source edits "
"never silently ride along across updates."
),
)
update_parser.add_argument(
"--branch",
default=None,
metavar="NAME",
help=(
"Update against this branch instead of the default (main). "
"If the local checkout is on a different branch, hermes will "
"switch to the requested branch first (auto-stashing any "
"uncommitted changes)."
),
)
update_parser.add_argument(
"--switch-branch",
action="store_true",
default=False,
help=(
"With updates.parked_branch_strategy: update_in_place configured, "
"override it for this run: switch to the update target and update "
"THERE instead of merging the target into the checked-out branch. "
"The branch is left exactly as it was — no merge commit is written "
"into its history. Use on long-lived feature branches where an "
"update-driven merge commit would pollute the branch. No effect "
"under the default strategy (switch), which already switches. "
"Still refuses to touch a dirty tree."
),
)
update_parser.add_argument(
"--force",
action="store_true",
default=False,
help="Windows: proceed with the update even when another hermes.exe is detected. The concurrent process will likely cause WinError 32 warnings. Does NOT bypass the venv-process guard (see --force-venv).",
)
update_parser.add_argument(
"--force-venv",
action="store_true",
default=False,
help="Windows: mutate the venv even while other processes are running from its interpreter (desktop backend, gateway, terminals). Those processes keep native .pyd files locked, so the dependency sync will likely fail partway and strand the install half-updated. Use only if you know the detected holders are false positives.",
)
update_parser.set_defaults(func=cmd_update)
+80
View File
@@ -0,0 +1,80 @@
"""``hermes verify`` subcommand parser.
Follows the pattern of ``hermes_cli/subcommands/doctor.py``: parser built
here, handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
# Keep in sync with agent/verify/runner.py defaults; not imported here to
# avoid paying an extra module import on every `hermes` invocation.
DEFAULT_PHASE_TIMEOUT = 600.0
DEFAULT_READY_TIMEOUT = 60.0
def build_verify_parser(subparsers, *, cmd_verify: Callable) -> None:
"""Attach the ``verify`` subcommand to ``subparsers``."""
verify_parser = subparsers.add_parser(
"verify",
help="Detect a project's run recipe and smoke-test it",
description=(
"Detect how the current project is built, tested, and started "
"(or load the saved manifest at .hermes/environment.json), then "
"run a verification pass: bootstrap -> build -> test -> start in "
"background -> poll readiness -> teardown."
),
)
verify_parser.add_argument(
"path",
nargs="?",
default=None,
help="Project root to verify (default: current directory)",
)
verify_parser.add_argument(
"--detect-only",
action="store_true",
help="Only detect and print the recipe as JSON; run nothing",
)
verify_parser.add_argument(
"--save",
action="store_true",
help="Save the recipe as .hermes/environment.json in the project",
)
verify_parser.add_argument(
"--skip-start",
action="store_true",
help="Run command phases but skip starting the app / readiness poll",
)
verify_parser.add_argument(
"--phase",
action="append",
choices=["bootstrap", "build", "test", "start"],
default=None,
help="Run only the given phase(s); repeatable",
)
verify_parser.add_argument(
"--port",
type=int,
default=None,
help="Override the port used for the readiness poll",
)
verify_parser.add_argument(
"--timeout",
type=float,
default=DEFAULT_PHASE_TIMEOUT,
help=f"Per-phase timeout in seconds (default: {DEFAULT_PHASE_TIMEOUT:.0f})",
)
verify_parser.add_argument(
"--ready-timeout",
type=float,
default=DEFAULT_READY_TIMEOUT,
help=f"Readiness poll timeout in seconds (default: {DEFAULT_READY_TIMEOUT:.0f})",
)
verify_parser.add_argument(
"--json",
action="store_true",
help="Emit a machine-readable JSON result",
)
verify_parser.set_defaults(func=cmd_verify)
+83
View File
@@ -0,0 +1,83 @@
"""``hermes webhook`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_webhook_parser(subparsers, *, cmd_webhook: Callable) -> None:
"""Attach the ``webhook`` subcommand to ``subparsers``."""
# =========================================================================
# webhook command
# =========================================================================
webhook_parser = subparsers.add_parser(
"webhook",
help="Manage dynamic webhook subscriptions",
description="Create, list, and remove webhook subscriptions for event-driven agent activation",
)
webhook_subparsers = webhook_parser.add_subparsers(dest="webhook_action")
wh_sub = webhook_subparsers.add_parser(
"subscribe", aliases=["add"], help="Create a webhook subscription"
)
wh_sub.add_argument("name", help="Route name (used in URL: /webhooks/<name>)")
wh_sub.add_argument(
"--prompt", default="", help="Prompt template with {dot.notation} payload refs"
)
wh_sub.add_argument(
"--events", default="", help="Comma-separated event types to accept"
)
wh_sub.add_argument("--description", default="", help="What this subscription does")
wh_sub.add_argument(
"--skills", default="", help="Comma-separated skill names to load"
)
wh_sub.add_argument(
"--deliver",
default="log",
help="Delivery target: log, telegram, discord, slack, etc.",
)
wh_sub.add_argument(
"--deliver-chat-id",
default="",
help="Target chat ID for cross-platform delivery",
)
wh_sub.add_argument(
"--secret", default="", help="HMAC secret (auto-generated if omitted)"
)
wh_sub.add_argument(
"--deliver-only",
action="store_true",
help="Skip the agent — deliver the rendered prompt directly as the "
"message. Zero LLM cost. Requires --deliver to be a real target "
"(not 'log').",
)
wh_sub.add_argument(
"--script",
default="",
help="Filter/transform script under ~/.hermes/scripts/. The route "
"payload is passed as JSON on stdin; empty stdout, [SILENT], or a "
"nonzero exit code ignores the webhook.",
)
webhook_subparsers.add_parser(
"list", aliases=["ls"], help="List all dynamic subscriptions"
)
wh_rm = webhook_subparsers.add_parser(
"remove", aliases=["rm"], help="Remove a subscription"
)
wh_rm.add_argument("name", help="Subscription name to remove")
wh_test = webhook_subparsers.add_parser(
"test", help="Send a test POST to a webhook route"
)
wh_test.add_argument("name", help="Subscription name to test")
wh_test.add_argument(
"--payload", default="", help="JSON payload to send (default: test payload)"
)
webhook_parser.set_defaults(func=cmd_webhook)
+22
View File
@@ -0,0 +1,22 @@
"""``hermes whatsapp`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_whatsapp_parser(subparsers, *, cmd_whatsapp: Callable) -> None:
"""Attach the ``whatsapp`` subcommand to ``subparsers``."""
# =========================================================================
# whatsapp command
# =========================================================================
whatsapp_parser = subparsers.add_parser(
"whatsapp",
help="Set up WhatsApp integration",
description="Configure WhatsApp and pair via QR code",
)
whatsapp_parser.set_defaults(func=cmd_whatsapp)