Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ hermes # Interactive CLI β€” start a conversation
hermes model # Choose your LLM provider and model
hermes tools # Configure which tools are enabled
hermes config set # Set individual config values
hermes config get # Print individual config values
hermes gateway # Start the messaging gateway (Telegram, Discord, etc.)
hermes setup # Run the full setup wizard (configures everything at once)
hermes claw migrate # Migrate from OpenClaw (if coming from OpenClaw)
Expand Down
266 changes: 228 additions & 38 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
This module provides:
- hermes config - Show current configuration
- hermes config edit - Open config in editor
- hermes config get - Print a resolved configuration value
- hermes config set - Set a specific value
- hermes config unset - Remove a user configuration value
- hermes config wizard - Re-run setup wizard
"""

Expand Down Expand Up @@ -3323,6 +3325,149 @@ def _set_nested(config, dotted_key: str, value):
current[last] = value


_MISSING = object()

_CONFIG_TO_ENV_SYNC = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not introduce another terminal mapping here. Current main has the authoritative TERMINAL_CONFIG_ENV_MAP and terminal_config_env_var_for_key() helper; this subset misses newer mappings such as docker_network, so unset would leave their mirrored .env values stale.

"terminal.backend": "TERMINAL_ENV",
"terminal.modal_mode": "TERMINAL_MODAL_MODE",
"terminal.docker_image": "TERMINAL_DOCKER_IMAGE",
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
"terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
"terminal.docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
"terminal.docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
"terminal.docker_env": "TERMINAL_DOCKER_ENV",
# terminal.cwd intentionally excluded β€” CLI resolves at runtime,
# gateway bridges it in gateway/run.py. Persisting to .env causes
# stale values to poison child processes.
"terminal.timeout": "TERMINAL_TIMEOUT",
"terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR",
"terminal.persistent_shell": "TERMINAL_PERSISTENT_SHELL",
"terminal.container_cpu": "TERMINAL_CONTAINER_CPU",
"terminal.container_memory": "TERMINAL_CONTAINER_MEMORY",
"terminal.container_disk": "TERMINAL_CONTAINER_DISK",
"terminal.container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
}


def _get_nested(config, dotted_key: str):
"""Return a dotted-path value from nested dict/list config data."""
current = config
for part in dotted_key.split("."):
if isinstance(current, list):
try:
current = current[int(part)]
except (TypeError, ValueError, IndexError):
return _MISSING
elif isinstance(current, dict):
if part not in current:
return _MISSING
current = current[part]
else:
return _MISSING
return current


def _unset_nested(config, dotted_key: str) -> bool:
"""Remove a dotted-path value from nested dict/list config data."""
parts = dotted_key.split(".")
if not parts:
return False

parents = []
current = config
for part in parts[:-1]:
parents.append((current, part))
if isinstance(current, list):
try:
current = current[int(part)]
except (TypeError, ValueError, IndexError):
return False
elif isinstance(current, dict):
if part not in current:
return False
current = current[part]
else:
return False

last = parts[-1]
removed = False
if isinstance(current, list):
try:
current.pop(int(last))
removed = True
except (TypeError, ValueError, IndexError):
return False
elif isinstance(current, dict):
if last not in current:
return False
del current[last]
removed = True
else:
return False

# Drop empty dict containers left behind by the deletion while preserving
# user-authored empty lists and non-empty sibling branches.
for parent, part in reversed(parents):
if current != {}:
break
if isinstance(parent, list):
try:
idx = int(part)
except (TypeError, ValueError):
break
if 0 <= idx < len(parent) and parent[idx] == {}:
parent.pop(idx)
current = parent
continue
elif isinstance(parent, dict) and parent.get(part) == {}:
del parent[part]
current = parent
continue
break

return removed


def _is_env_config_key(key: str) -> bool:
"""Return whether `hermes config set` routes this key to .env."""
if "." in key:
return False
key_upper = key.upper()
api_keys = [
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL',
'FIRECRAWL_GATEWAY_URL', 'TOOL_GATEWAY_DOMAIN', 'TOOL_GATEWAY_SCHEME',
'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_API_KEY',
'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY',
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
'SUDO_PASSWORD', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN',
'GITHUB_TOKEN', 'HONCHO_API_KEY',
]
return (
key_upper in api_keys
or key_upper.endswith(('_API_KEY', '_TOKEN'))
or key_upper.startswith('TERMINAL_SSH')
)


def _format_config_get_value(value, *, as_json: bool) -> str:
"""Format a config value for command-line output."""
if as_json:
import json
return json.dumps(value, ensure_ascii=False)
if isinstance(value, bool):
return "true" if value else "false"
if value is None:
return "null"
if isinstance(value, (dict, list)):
return yaml.safe_dump(value, sort_keys=False).rstrip()
return str(value)


def get_missing_config_fields() -> List[Dict[str, Any]]:
"""
Check which config fields are missing or outdated (recursive).
Expand Down Expand Up @@ -5623,19 +5768,7 @@ def set_config_value(key: str, value: str):
managed_error("set configuration values")
return
# Check if it's an API key (goes to .env)
api_keys = [
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL',
'FIRECRAWL_GATEWAY_URL', 'TOOL_GATEWAY_DOMAIN', 'TOOL_GATEWAY_SCHEME',
'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_API_KEY',
'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY',
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
'SUDO_PASSWORD', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN',
'GITHUB_TOKEN', 'HONCHO_API_KEY',
]

if key.upper() in api_keys or key.upper().endswith(('_API_KEY', '_TOKEN')) or key.upper().startswith('TERMINAL_SSH'):
if _is_env_config_key(key):
save_env_value(key.upper(), value)
print(f"βœ“ Set {key} in {get_env_path()}")
return
Expand Down Expand Up @@ -5676,35 +5809,66 @@ def set_config_value(key: str, value: str):

# Keep .env in sync for keys that terminal_tool reads directly from env vars.
# config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.
_config_to_env_sync = {
"terminal.backend": "TERMINAL_ENV",
"terminal.modal_mode": "TERMINAL_MODAL_MODE",
"terminal.docker_image": "TERMINAL_DOCKER_IMAGE",
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
"terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
"terminal.docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
"terminal.docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
"terminal.docker_env": "TERMINAL_DOCKER_ENV",
# terminal.cwd intentionally excluded β€” CLI resolves at runtime,
# gateway bridges it in gateway/run.py. Persisting to .env causes
# stale values to poison child processes.
"terminal.timeout": "TERMINAL_TIMEOUT",
"terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR",
"terminal.persistent_shell": "TERMINAL_PERSISTENT_SHELL",
"terminal.container_cpu": "TERMINAL_CONTAINER_CPU",
"terminal.container_memory": "TERMINAL_CONTAINER_MEMORY",
"terminal.container_disk": "TERMINAL_CONTAINER_DISK",
"terminal.container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
}
if key in _config_to_env_sync:
save_env_value(_config_to_env_sync[key], str(value))
if key in _CONFIG_TO_ENV_SYNC:
save_env_value(_CONFIG_TO_ENV_SYNC[key], str(value))

print(f"βœ“ Set {key} = {value} in {config_path}")


def get_config_value(key: str, *, as_json: bool = False):
"""Print a resolved configuration value."""
if _is_env_config_key(key):
env_value = get_env_value(key.upper())
value = _MISSING if env_value is None else env_value
else:
value = _get_nested(load_config(), key)

if value is _MISSING:
print(f"Config key not set: {key}", file=sys.stderr)
sys.exit(1)

print(_format_config_get_value(value, as_json=as_json))


def unset_config_value(key: str):
"""Remove a user-set configuration or .env value."""
if is_managed():

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please also apply the per-key managed-scope guard used by current set_config_value(). is_managed() covers the package-manager lock, but does not protect administrator-pinned individual config keys.

managed_error("unset configuration values")
return

if _is_env_config_key(key):
removed = remove_env_value(key.upper())
if not removed:
print(f"Config key not set: {key}", file=sys.stderr)
sys.exit(1)
print(f"βœ“ Unset {key} from {get_env_path()}")
return

config_path = get_config_path()
user_config = {}
if config_path.exists():
try:
with open(config_path, encoding="utf-8") as f:
user_config = yaml.safe_load(f) or {}
except Exception:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This must fail closed rather than treating an unreadable or malformed existing config as {}. Current main's atomic_config_write() protects this exact overwrite class; use that guard before any rewrite.

user_config = {}

removed = _unset_nested(user_config, key)

# Keep .env in sync for keys that terminal_tool reads directly from env vars.
if key in _CONFIG_TO_ENV_SYNC:
removed = remove_env_value(_CONFIG_TO_ENV_SYNC[key]) or removed

if not removed:
print(f"Config key not set: {key}", file=sys.stderr)
sys.exit(1)

ensure_hermes_home()
from utils import atomic_yaml_write
atomic_yaml_write(config_path, user_config, sort_keys=False)
print(f"βœ“ Unset {key} from {config_path}")


# =============================================================================
# Command handler
# =============================================================================
Expand All @@ -5719,6 +5883,18 @@ def config_command(args):
elif subcmd == "edit":
edit_config()

elif subcmd == "get":
key = getattr(args, 'key', None)
if not key:
print("Usage: hermes config get <key> [--json]")
print()
print("Examples:")
print(" hermes config get model")
print(" hermes config get terminal.backend")
print(" hermes config get skills.config --json")
sys.exit(1)
get_config_value(key, as_json=getattr(args, 'json', False))

elif subcmd == "set":
key = getattr(args, 'key', None)
value = getattr(args, 'value', None)
Expand All @@ -5731,6 +5907,18 @@ def config_command(args):
print(" hermes config set OPENROUTER_API_KEY sk-or-...")
sys.exit(1)
set_config_value(key, value)

elif subcmd == "unset":
key = getattr(args, 'key', None)
if not key:
print("Usage: hermes config unset <key>")
print()
print("Examples:")
print(" hermes config unset model")
print(" hermes config unset terminal.backend")
print(" hermes config unset OPENROUTER_API_KEY")
sys.exit(1)
unset_config_value(key)

elif subcmd == "path":
print(get_config_path())
Expand Down Expand Up @@ -5838,7 +6026,9 @@ def config_command(args):
print("Available commands:")
print(" hermes config Show current configuration")
print(" hermes config edit Open config in editor")
print(" hermes config get <key> Print a resolved config value")
print(" hermes config set <key> <value> Set a config value")
print(" hermes config unset <key> Remove a config value")
print(" hermes config check Check for missing/outdated config")
print(" hermes config migrate Update config with new options")
print(" hermes config path Show config file path")
Expand Down
9 changes: 9 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12779,13 +12779,22 @@ def _dispatch_secrets(args): # noqa: ANN001
# config edit
config_subparsers.add_parser("edit", help="Open config file in editor")

# config get

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main no longer builds config subcommands here: build_config_parser() in hermes_cli/subcommands/config.py is invoked from hermes_cli/main.py:13161. Port these argparse additions to that builder so the commands are reachable after salvage.

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 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")

Expand Down
Loading