feat(config): add get and unset commands
parent
5604d1852e
commit
53adb3fd97
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue