feat(config): add get and unset commands

fix/verification-admin-route-recovery
nima20002000 2026-06-01 00:03:31 +03:30 committed by Teknium
parent 5604d1852e
commit 53adb3fd97
9 changed files with 373 additions and 15 deletions

View File

@ -109,6 +109,7 @@ hermes # Interactive CLI — start a conversation
hermes model # Choose your LLM provider and model hermes model # Choose your LLM provider and model
hermes tools # Configure which tools are enabled hermes tools # Configure which tools are enabled
hermes config set # Set individual config values hermes config set # Set individual config values
hermes config get # Print individual config values
hermes gateway # Start the messaging gateway (Telegram, Discord, etc.) hermes gateway # Start the messaging gateway (Telegram, Discord, etc.)
hermes setup # Run the full setup wizard (configures everything at once) hermes setup # Run the full setup wizard (configures everything at once)
hermes claw migrate # Migrate from OpenClaw (if coming from OpenClaw) hermes claw migrate # Migrate from OpenClaw (if coming from OpenClaw)

View File

@ -8,7 +8,9 @@ Config files are stored in ~/.hermes/ for easy access:
This module provides: This module provides:
- hermes config - Show current configuration - hermes config - Show current configuration
- hermes config edit - Open config in editor - hermes config edit - Open config in editor
- hermes config get - Print a resolved configuration value
- hermes config set - Set a specific value - hermes config set - Set a specific value
- hermes config unset - Remove a user configuration value
- hermes config wizard - Re-run setup wizard - hermes config wizard - Re-run setup wizard
""" """
@ -4647,6 +4649,125 @@ def clear_model_endpoint_credentials(
return model_cfg return model_cfg
_MISSING = object()
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]]: def get_missing_config_fields() -> List[Dict[str, Any]]:
""" """
Check which config fields are missing or outdated (recursive). Check which config fields are missing or outdated (recursive).
@ -8238,19 +8359,7 @@ def set_config_value(key: str, value: str):
) )
sys.exit(1) sys.exit(1)
# Check if it's an API key (goes to .env) # Check if it's an API key (goes to .env)
api_keys = [ if _is_env_config_key(key):
'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'):
save_env_value(key.upper(), value) save_env_value(key.upper(), value)
print(f"✓ Set {key} in {get_env_path()}") print(f"✓ Set {key} in {get_env_path()}")
return return
@ -8322,6 +8431,75 @@ def set_config_value(key: str, value: str):
print(f"✓ Set {key} = {_display_value} in {config_path}") print(f"✓ Set {key} = {_display_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():
managed_error("unset configuration values")
return
# Managed scope guard: a key pinned by the managed layer cannot be unset by
# the user — the next load would reinstate it anyway (mirrors set_config_value).
from hermes_cli import managed_scope
if managed_scope.is_key_managed(key):
managed_dir = managed_scope.get_managed_dir()
src = (managed_dir / "config.yaml") if managed_dir else "the managed scope"
print(
f"Cannot unset '{key}': it is managed by your administrator ({src}) "
f"and cannot be changed. Contact your administrator to modify it.",
file=sys.stderr,
)
sys.exit(1)
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()
require_readable_config_before_write(config_path)
user_config = {}
if config_path.exists():
try:
with open(config_path, encoding="utf-8") as f:
user_config = fast_safe_load(f) or {}
except Exception:
user_config = {}
removed = _unset_nested(user_config, key)
# Keep .env in sync for keys that terminal_tool reads directly from env vars.
env_var = terminal_config_env_var_for_key(key)
if env_var and key != "terminal.cwd":
removed = remove_env_value(env_var) 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 # Command handler
# ============================================================================= # =============================================================================
@ -8336,6 +8514,18 @@ def config_command(args):
elif subcmd == "edit": elif subcmd == "edit":
edit_config() 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": elif subcmd == "set":
key = getattr(args, 'key', None) key = getattr(args, 'key', None)
value = getattr(args, 'value', None) value = getattr(args, 'value', None)
@ -8348,6 +8538,18 @@ def config_command(args):
print(" hermes config set OPENROUTER_API_KEY sk-or-...") print(" hermes config set OPENROUTER_API_KEY sk-or-...")
sys.exit(1) sys.exit(1)
set_config_value(key, value) 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": elif subcmd == "path":
print(get_config_path()) print(get_config_path())
@ -8455,7 +8657,9 @@ def config_command(args):
print("Available commands:") print("Available commands:")
print(" hermes config Show current configuration") print(" hermes config Show current configuration")
print(" hermes config edit Open config in editor") 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 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 check Check for missing/outdated config")
print(" hermes config migrate Update config with new options") print(" hermes config migrate Update config with new options")
print(" hermes config path Show config file path") print(" hermes config path Show config file path")

View File

@ -27,6 +27,13 @@ def build_config_parser(subparsers, *, cmd_config: Callable) -> None:
# config edit # config edit
config_subparsers.add_parser("edit", help="Open config file in editor") 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_set = config_subparsers.add_parser("set", help="Set a configuration value") config_set = config_subparsers.add_parser("set", help="Set a configuration value")
config_set.add_argument( config_set.add_argument(
@ -34,6 +41,12 @@ def build_config_parser(subparsers, *, cmd_config: Callable) -> None:
) )
config_set.add_argument("value", nargs="?", help="Value to set") 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 path
config_subparsers.add_parser("path", help="Print config file path") config_subparsers.add_parser("path", help="Print config file path")

View File

@ -164,6 +164,122 @@ class TestFalsyValues:
assert "model" in config assert "model" in config
class TestConfigGetUnset:
"""config get/unset should mirror config set for scriptable workflows."""
def test_config_get_prints_resolved_nested_value(self, _isolated_hermes_home, capsys):
set_config_value("terminal.timeout", "120")
capsys.readouterr()
args = argparse.Namespace(config_command="get", key="terminal.timeout", json=False)
config_command(args)
assert capsys.readouterr().out.strip() == "120"
def test_config_get_prints_structured_json(self, _isolated_hermes_home, capsys):
set_config_value("terminal.backend", "docker")
capsys.readouterr()
args = argparse.Namespace(config_command="get", key="terminal", json=True)
config_command(args)
import json
assert json.loads(capsys.readouterr().out)["backend"] == "docker"
def test_config_get_prints_null_for_resolved_null_value(self, capsys):
args = argparse.Namespace(config_command="get", key="cron.max_parallel_jobs", json=False)
config_command(args)
assert capsys.readouterr().out.strip() == "null"
def test_config_get_missing_env_key_exits(self, capsys):
args = argparse.Namespace(config_command="get", key="OPENROUTER_API_KEY", json=False)
with pytest.raises(SystemExit) as exc:
config_command(args)
assert exc.value.code == 1
assert "Config key not set: OPENROUTER_API_KEY" in capsys.readouterr().err
def test_config_get_dotted_token_yaml_key(self, _isolated_hermes_home, capsys):
(_isolated_hermes_home / "config.yaml").write_text(
"platforms:\n"
" teams:\n"
" extra:\n"
" access_token: yaml-token\n"
)
args = argparse.Namespace(
config_command="get",
key="platforms.teams.extra.access_token",
json=False,
)
config_command(args)
assert capsys.readouterr().out.strip() == "yaml-token"
def test_config_get_missing_key_exits(self, capsys):
args = argparse.Namespace(config_command="get", key="not.a.real.key", json=False)
with pytest.raises(SystemExit) as exc:
config_command(args)
assert exc.value.code == 1
assert "Config key not set: not.a.real.key" in capsys.readouterr().err
def test_config_unset_removes_yaml_key_and_synced_env(self, _isolated_hermes_home, capsys):
set_config_value("terminal.backend", "docker")
assert "TERMINAL_ENV=docker" in _read_env(_isolated_hermes_home)
capsys.readouterr()
args = argparse.Namespace(config_command="unset", key="terminal.backend")
config_command(args)
import yaml
reloaded = yaml.safe_load(_read_config(_isolated_hermes_home)) or {}
assert reloaded == {}
assert "TERMINAL_ENV=" not in _read_env(_isolated_hermes_home)
assert "Unset terminal.backend" in capsys.readouterr().out
def test_config_unset_removes_env_key(self, _isolated_hermes_home, capsys):
set_config_value("OPENROUTER_API_KEY", "sk-test")
assert "OPENROUTER_API_KEY=sk-test" in _read_env(_isolated_hermes_home)
capsys.readouterr()
args = argparse.Namespace(config_command="unset", key="OPENROUTER_API_KEY")
config_command(args)
assert "OPENROUTER_API_KEY=" not in _read_env(_isolated_hermes_home)
assert "Unset OPENROUTER_API_KEY" in capsys.readouterr().out
def test_config_unset_removes_dotted_token_yaml_key(self, _isolated_hermes_home, capsys):
(_isolated_hermes_home / "config.yaml").write_text(
"platforms:\n"
" teams:\n"
" extra:\n"
" access_token: yaml-token\n"
" tenant_id: tenant\n"
)
args = argparse.Namespace(config_command="unset", key="platforms.teams.extra.access_token")
config_command(args)
import yaml
reloaded = yaml.safe_load(_read_config(_isolated_hermes_home))
assert "access_token" not in reloaded["platforms"]["teams"]["extra"]
assert reloaded["platforms"]["teams"]["extra"]["tenant_id"] == "tenant"
assert "Unset platforms.teams.extra.access_token" in capsys.readouterr().out
def test_config_unset_missing_key_exits(self, capsys):
args = argparse.Namespace(config_command="unset", key="not.a.real.key")
with pytest.raises(SystemExit) as exc:
config_command(args)
assert exc.value.code == 1
assert "Config key not set: not.a.real.key" in capsys.readouterr().err
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# List navigation — regression tests for #17876 # List navigation — regression tests for #17876
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -86,6 +86,25 @@ def test_single_handler_builders(name, builder, kw, argv):
assert ns.func is handler assert ns.func is handler
def test_config_get_unset_subcommands_parse():
"""`hermes config get/unset` parse key args (and --json for get)."""
parser = argparse.ArgumentParser(prog="hermes")
sub = parser.add_subparsers(dest="command")
handler = _h("config")
build_config_parser(sub, cmd_config=handler)
ns = parser.parse_args(["config", "get", "terminal.backend", "--json"])
assert ns.func is handler
assert ns.config_command == "get"
assert ns.key == "terminal.backend"
assert ns.json is True
ns = parser.parse_args(["config", "unset", "terminal.backend"])
assert ns.func is handler
assert ns.config_command == "unset"
assert ns.key == "terminal.backend"
def test_dashboard_builder_two_handlers(): def test_dashboard_builder_two_handlers():
parser = argparse.ArgumentParser(prog="hermes") parser = argparse.ArgumentParser(prog="hermes")
sub = parser.add_subparsers(dest="command") sub = parser.add_subparsers(dest="command")

View File

@ -68,6 +68,7 @@ hermes model # Choose your LLM provider and model
hermes tools # Configure which tools are enabled hermes tools # Configure which tools are enabled
hermes gateway setup # Set up messaging platforms hermes gateway setup # Set up messaging platforms
hermes config set # Set individual config values hermes config set # Set individual config values
hermes config get # Inspect individual config values
hermes setup # Or run the full setup wizard to configure everything at once hermes setup # Or run the full setup wizard to configure everything at once
``` ```

View File

@ -162,7 +162,7 @@ Manage skill config from the CLI:
hermes skills config gif-search hermes skills config gif-search
# View all skill config # View all skill config
hermes config show | grep '^skills\.config' hermes config get skills.config --json
``` ```
--- ---

View File

@ -32,13 +32,17 @@ Run `hermes setup --portal` — one OAuth gets you a model provider and all four
```bash ```bash
hermes config # View current configuration hermes config # View current configuration
hermes config edit # Open config.yaml in your editor hermes config edit # Open config.yaml in your editor
hermes config get KEY # Print a resolved value
hermes config set KEY VAL # Set a specific value hermes config set KEY VAL # Set a specific value
hermes config unset KEY # Remove a user-set value
hermes config check # Check for missing options (after updates) hermes config check # Check for missing options (after updates)
hermes config migrate # Interactively add missing options hermes config migrate # Interactively add missing options
# Examples: # Examples:
hermes config get model
hermes config set model anthropic/claude-opus-4 hermes config set model anthropic/claude-opus-4
hermes config set terminal.backend docker hermes config set terminal.backend docker
hermes config unset terminal.backend
hermes config set OPENROUTER_API_KEY sk-or-... # Saves to .env hermes config set OPENROUTER_API_KEY sk-or-... # Saves to .env
``` ```

View File

@ -232,7 +232,7 @@ hermes model # Interactive provider + model picker (the canonical way
`hermes model` walks you through picking a provider, authenticating (OAuth flows open a browser; API-key providers prompt for the key), and then choosing a specific model from that provider's curated catalog. The choice is written to `model.provider` and `model.default` in `~/.hermes/config.yaml`. `hermes model` walks you through picking a provider, authenticating (OAuth flows open a browser; API-key providers prompt for the key), and then choosing a specific model from that provider's curated catalog. The choice is written to `model.provider` and `model.default` in `~/.hermes/config.yaml`.
To list providers/models without launching the picker, use the dashboard or the REST endpoints below. To inspect what the CLI will actually use right now: `hermes config show | grep '^model\.'` and `hermes status`. To list providers/models without launching the picker, use the dashboard or the REST endpoints below. To inspect what the CLI will actually use right now: `hermes config get model --json` and `hermes status`.
### Direct config edit ### Direct config edit