-
Notifications
You must be signed in to change notification settings - Fork 52.2k
feat(config): add get and unset commands #36067
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please also apply the per-key managed-scope guard used by current |
||
| 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: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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 <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) | ||
|
|
@@ -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()) | ||
|
|
@@ -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") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Current main no longer builds config subcommands here: |
||
| 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") | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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_MAPandterminal_config_env_var_for_key()helper; this subset misses newer mappings such asdocker_network, so unset would leave their mirrored .env values stale.