From dff07f82817b9329b4d2cef44d74d7d5568e64bd Mon Sep 17 00:00:00 2001 From: nima20002000 Date: Mon, 1 Jun 2026 00:03:31 +0330 Subject: [PATCH] feat(config): add get and unset commands --- README.md | 1 + hermes_cli/config.py | 266 +++++++++++++++--- hermes_cli/main.py | 9 + tests/hermes_cli/test_set_config_value.py | 116 ++++++++ tests/tools/test_terminal_config_env_sync.py | 12 +- website/docs/getting-started/installation.md | 1 + website/docs/guides/work-with-skills.md | 2 +- website/docs/user-guide/configuration.md | 4 + website/docs/user-guide/configuring-models.md | 2 +- 9 files changed, 367 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index fa2795305059..11759d936a27 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index bb004d9445ad..501ec348b4aa 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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 """ @@ -3323,6 +3325,149 @@ def _set_nested(config, dotted_key: str, value): current[last] = value +_MISSING = object() + +_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", +} + + +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). @@ -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 @@ -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(): + 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: + 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 # ============================================================================= @@ -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 [--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) @@ -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 ") + 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()) @@ -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 Print a resolved config value") print(" hermes config set Set a config value") + print(" hermes config unset 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") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1941dc2af313..213829bd525d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12779,6 +12779,11 @@ def _dispatch_secrets(args): # noqa: ANN001 # 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( @@ -12786,6 +12791,10 @@ def _dispatch_secrets(args): # noqa: ANN001 ) 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") diff --git a/tests/hermes_cli/test_set_config_value.py b/tests/hermes_cli/test_set_config_value.py index d404549cf524..2097a7bd3b61 100644 --- a/tests/hermes_cli/test_set_config_value.py +++ b/tests/hermes_cli/test_set_config_value.py @@ -164,6 +164,122 @@ def test_config_command_accepts_empty_string(self, _isolated_hermes_home): 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 # --------------------------------------------------------------------------- diff --git a/tests/tools/test_terminal_config_env_sync.py b/tests/tools/test_terminal_config_env_sync.py index 161318434171..c9cca0b86799 100644 --- a/tests/tools/test_terminal_config_env_sync.py +++ b/tests/tools/test_terminal_config_env_sync.py @@ -7,8 +7,8 @@ 1. cli.py -> ``env_mappings`` dict (CLI / TUI startup) 2. gateway/run.py -> ``_terminal_env_map`` dict (gateway / messaging platforms) - 3. hermes_cli/config.py:save_config_value - -> ``_config_to_env_sync`` dict (one-shot when the + 3. hermes_cli/config.py + -> ``_CONFIG_TO_ENV_SYNC`` dict (one-shot when the user runs ``hermes config set …``) If any one of these is missing a key, the corresponding config.yaml setting @@ -89,8 +89,8 @@ def _gateway_env_map_keys() -> set[str]: def _save_config_env_sync_keys() -> set[str]: """terminal config keys bridged by ``hermes config set foo bar``.""" from hermes_cli import config as hc_config - source = inspect.getsource(hc_config.set_config_value) - keys = _extract_dict_keys(source, "_config_to_env_sync") + source = inspect.getsource(hc_config) + keys = _extract_dict_keys(source, "_CONFIG_TO_ENV_SYNC") # set_config_value uses fully-qualified ``terminal.foo`` keys; strip the # prefix so we can compare against the other two maps which use bare # leaf keys. @@ -179,8 +179,8 @@ def test_save_config_set_supports_critical_bridged_keys(): missing = required - save_keys assert not missing, ( f"`hermes config set terminal.X` doesn't sync these load-bearing " - f"keys to .env: {sorted(missing)}. Add them to _config_to_env_sync " - f"in hermes_cli/config.py:set_config_value." + f"keys to .env: {sorted(missing)}. Add them to _CONFIG_TO_ENV_SYNC " + f"in hermes_cli/config.py." ) diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index 4825d6422788..db7178081e78 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -106,6 +106,7 @@ hermes model # Choose your LLM provider and model hermes tools # Configure which tools are enabled hermes gateway setup # Set up messaging platforms 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 ``` diff --git a/website/docs/guides/work-with-skills.md b/website/docs/guides/work-with-skills.md index 331558924e02..c098fe47fec4 100644 --- a/website/docs/guides/work-with-skills.md +++ b/website/docs/guides/work-with-skills.md @@ -162,7 +162,7 @@ Manage skill config from the CLI: hermes skills config gif-search # View all skill config -hermes config show | grep '^skills\.config' +hermes config get skills.config --json ``` --- diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index d74587432d5b..7aa02aa36b98 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -32,13 +32,17 @@ Run `hermes setup --portal` — one OAuth gets you a model provider and all four ```bash hermes config # View current configuration 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 unset KEY # Remove a user-set value hermes config check # Check for missing options (after updates) hermes config migrate # Interactively add missing options # Examples: +hermes config get model hermes config set model anthropic/claude-opus-4 hermes config set terminal.backend docker +hermes config unset terminal.backend hermes config set OPENROUTER_API_KEY sk-or-... # Saves to .env ``` diff --git a/website/docs/user-guide/configuring-models.md b/website/docs/user-guide/configuring-models.md index 00ad11d43f17..cab5192b97a8 100644 --- a/website/docs/user-guide/configuring-models.md +++ b/website/docs/user-guide/configuring-models.md @@ -206,7 +206,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.model` 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