refactor(hermes_cli): config.py AST-neutral layout pass (hug closers, reflow string literals)
This commit is contained in:
+62
-119
@@ -81,12 +81,10 @@ _PARSE_FAILURE_FALLBACK_MSG = {
|
||||
"edits to config.yaml are being IGNORED until the YAML is fixed."),
|
||||
"refuse-write": (
|
||||
"REFUSING to write config.yaml so the existing file is preserved. "
|
||||
"Fix the YAML (hermes config edit) and retry."),
|
||||
}
|
||||
"Fix the YAML (hermes config edit) and retry.")}
|
||||
_PARSE_FAILURE_DEFAULTS_MSG = (
|
||||
"Falling back to default config — every user override "
|
||||
"(auxiliary providers, fallback chain, model settings) is being IGNORED. "
|
||||
"Fix the YAML and restart.")
|
||||
"Falling back to default config — every user override (auxiliary providers, fallback chain, "
|
||||
"model settings) is being IGNORED. Fix the YAML and restart.")
|
||||
|
||||
|
||||
def _warn_config_parse_failure(
|
||||
@@ -169,8 +167,7 @@ _ENV_VAR_NAME_DENYLIST: frozenset[str] = frozenset({
|
||||
"HERMES_YOLO_MODE", "HERMES_ACCEPT_HOOKS", "HERMES_REDACT_SECRETS",
|
||||
"HERMES_INTERACTIVE", "HERMES_EXEC_ASK", "HERMES_GATEWAY_SESSION",
|
||||
"HERMES_CRON_SESSION", "HERMES_SINGLE_QUERY_SESSION",
|
||||
"HERMES_SESSION_KEY", "HERMES_SESSION_PLATFORM",
|
||||
})
|
||||
"HERMES_SESSION_KEY", "HERMES_SESSION_PLATFORM"})
|
||||
|
||||
|
||||
def _env_var_policy_name(key: str, *, is_windows: Optional[bool] = None) -> str:
|
||||
@@ -187,12 +184,10 @@ def validate_env_var_name_for_write(key: str) -> None:
|
||||
if _env_var_policy_name(key) in _ENV_VAR_NAME_DENYLIST:
|
||||
raise ValueError(
|
||||
f"Environment variable {key!r} is on the writer denylist. "
|
||||
"Names that influence subprocess execution (LD_PRELOAD, "
|
||||
"PYTHONPATH, PATH, EDITOR, ...) or Hermes runtime location "
|
||||
"and security policy (HERMES_HOME, HERMES_YOLO_MODE, ...) "
|
||||
"cannot be persisted via "
|
||||
"the env writer. If you really need this, edit "
|
||||
"~/.hermes/.env directly.")
|
||||
"Names that influence subprocess execution (LD_PRELOAD, PYTHONPATH, PATH, EDITOR, ...) "
|
||||
"or Hermes runtime location and security policy (HERMES_HOME, HERMES_YOLO_MODE, ...) "
|
||||
"cannot be persisted via the env writer. If you really need this, edit ~/.hermes/.env "
|
||||
"directly.")
|
||||
|
||||
|
||||
# Serializes all config read/write paths and guards the module-level caches below. libyaml's
|
||||
@@ -254,8 +249,7 @@ _EXTRA_ENV_KEYS = frozenset({
|
||||
"LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_BASE_URL",
|
||||
# ACP (Agent Client Protocol) keys — profile-isolable so profiles can use different backends.
|
||||
"HERMES_ACP_AUTH_METHOD", "HERMES_ACP_AUTO_APPROVE", "HERMES_COPILOT_ACP_COMMAND",
|
||||
"HERMES_COPILOT_ACP_ARGS", "COPILOT_CLI_PATH", "COPILOT_ACP_BASE_URL",
|
||||
})
|
||||
"HERMES_COPILOT_ACP_ARGS", "COPILOT_CLI_PATH", "COPILOT_ACP_BASE_URL"})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -479,8 +473,7 @@ def get_container_exec_info() -> Optional[dict]:
|
||||
"backend": info.get("backend", "docker"),
|
||||
"container_name": info.get("container_name", "hermes-agent"),
|
||||
"exec_user": info.get("exec_user", "hermes"),
|
||||
"hermes_bin": info.get("hermes_bin", "/data/current-package/bin/hermes"),
|
||||
}
|
||||
"hermes_bin": info.get("hermes_bin", "/data/current-package/bin/hermes")}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -627,8 +620,7 @@ def _ensure_default_soul_md(home: Path) -> None:
|
||||
_HERMES_HOME_ENSURED: set = set()
|
||||
_HERMES_HOME_SUBDIRS = (
|
||||
"cron", "sessions", "logs", "logs/curator", "memories",
|
||||
"pairing", "hooks", "image_cache", "audio_cache", "skills",
|
||||
)
|
||||
"pairing", "hooks", "image_cache", "audio_cache", "skills")
|
||||
|
||||
|
||||
def ensure_hermes_home():
|
||||
@@ -685,15 +677,13 @@ from hermes_cli.config_providers import ( # noqa: E402,F401 (re-exported; call
|
||||
get_compatible_custom_providers, get_custom_provider_context_length,
|
||||
get_custom_provider_extra_headers, get_custom_provider_model_capability,
|
||||
get_custom_provider_tls_settings, is_provider_enabled, normalize_extra_headers,
|
||||
providers_dict_to_custom_providers, stringify_provider_map,
|
||||
)
|
||||
providers_dict_to_custom_providers, stringify_provider_map)
|
||||
# Back-compat re-exports — :mod:`hermes_cli.personality` owns personality/overlay semantics.
|
||||
from hermes_cli.personality import ( # noqa: E402,F401
|
||||
NEUTRAL_PERSONALITY_NAMES as _NEUTRAL_PERSONALITY_NAMES,
|
||||
prompt_text as _prompt_text,
|
||||
render_personality_prompt,
|
||||
resolve_ephemeral_system_prompt as resolve_ephemeral_system_prompt_from_config,
|
||||
)
|
||||
resolve_ephemeral_system_prompt as resolve_ephemeral_system_prompt_from_config)
|
||||
|
||||
# =============================================================================
|
||||
# Config Migration System
|
||||
@@ -707,8 +697,7 @@ ENV_VARS_BY_VERSION: Dict[int, List[str]] = {
|
||||
5: ["WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS",
|
||||
"SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"],
|
||||
10: ["TAVILY_API_KEY"],
|
||||
11: ["TERMINAL_MODAL_MODE"],
|
||||
}
|
||||
11: ["TERMINAL_MODAL_MODE"]}
|
||||
|
||||
# Intentionally empty: the LLM provider is required but handled by the setup wizard's provider
|
||||
# selection step, so no single env var is universally required.
|
||||
@@ -724,8 +713,7 @@ def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:
|
||||
{"name": var_name, **info, "is_required": is_required}
|
||||
for table, is_required in groups
|
||||
for var_name, info in table.items()
|
||||
if not get_env_value(var_name)
|
||||
]
|
||||
if not get_env_value(var_name)]
|
||||
|
||||
|
||||
def _split_key_path(key: str) -> list[str]:
|
||||
@@ -812,10 +800,9 @@ def _set_nested(config, dotted_key: str, value):
|
||||
if shadowed is not None:
|
||||
escaped = shadowed.replace(".", "\\.")
|
||||
raise ValueError(
|
||||
f"Refusing to create nested key {part!r} in {dotted_key!r}: "
|
||||
f"the mapping already contains a literal key {shadowed!r} "
|
||||
f"that contains a dot. If you meant that key, escape its "
|
||||
f"dots with a backslash (e.g. {escaped}).")
|
||||
f"Refusing to create nested key {part!r} in {dotted_key!r}: the mapping "
|
||||
f"already contains a literal key {shadowed!r} that contains a dot. If you "
|
||||
f"meant that key, escape its dots with a backslash (e.g. {escaped}).")
|
||||
current = current.setdefault(part, {})
|
||||
i += 1
|
||||
else:
|
||||
@@ -911,8 +898,7 @@ _ENV_CONFIG_KEYS = frozenset({
|
||||
'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',
|
||||
})
|
||||
'GITHUB_TOKEN', 'HONCHO_API_KEY'})
|
||||
|
||||
|
||||
def _is_env_config_key(key: str) -> bool:
|
||||
@@ -953,8 +939,7 @@ def get_missing_config_fields() -> List[Dict[str, Any]]:
|
||||
missing.append({
|
||||
"key": full_key,
|
||||
"default": default_value,
|
||||
"description": f"New config option: {full_key}",
|
||||
})
|
||||
"description": f"New config option: {full_key}"})
|
||||
elif isinstance(default_value, dict) and isinstance(current.get(key), dict):
|
||||
_check(default_value, current[key], full_key)
|
||||
|
||||
@@ -1049,8 +1034,7 @@ _KNOWN_ROOT_KEYS = frozenset(DEFAULT_CONFIG.keys()) | _EXTRA_KNOWN_ROOT_KEYS
|
||||
_VALID_CUSTOM_PROVIDER_FIELDS = {
|
||||
"name", "base_url", "api_key", "api_mode", "model", "models",
|
||||
"context_length", "rate_limit_delay", "extra_body",
|
||||
"ssl_ca_cert", "ssl_verify", "key_env",
|
||||
}
|
||||
"ssl_ca_cert", "ssl_verify", "key_env"}
|
||||
|
||||
# Fields that look like they should be inside custom_providers, not at root
|
||||
_CUSTOM_PROVIDER_LIKE_FIELDS = {"base_url", "api_key", "rate_limit_delay", "api_mode"}
|
||||
@@ -1079,16 +1063,13 @@ def _require_fields(
|
||||
|
||||
_CP_REQUIRED_FIELDS = (
|
||||
("name", "Add a name, e.g.: name: my-provider"),
|
||||
("base_url", "Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1"),
|
||||
)
|
||||
("base_url", "Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1"))
|
||||
_FB_REQUIRED_FIELDS = (
|
||||
("provider", "Add: provider: openrouter (or another provider)"),
|
||||
("model", "Add: model: <model-name>"),
|
||||
)
|
||||
("model", "Add: model: <model-name>"))
|
||||
_FB_SINGLE_REQUIRED_FIELDS = (
|
||||
("provider", "Add: provider: openrouter (or another provider)"),
|
||||
("model", "Add: model: anthropic/claude-sonnet-4 (or another model)"),
|
||||
)
|
||||
("model", "Add: model: anthropic/claude-sonnet-4 (or another model)"))
|
||||
|
||||
|
||||
def _validate_voice(config: Dict[str, Any], issues: List[ConfigIssue]) -> None:
|
||||
@@ -1428,13 +1409,11 @@ def _offer_new_optional_env_vars(current_ver: int, latest_ver: int, results: Dic
|
||||
new_and_unset = [
|
||||
(name, OPTIONAL_ENV_VARS[name])
|
||||
for name in sorted(new_var_names)
|
||||
if not get_env_value(name) and name in OPTIONAL_ENV_VARS
|
||||
]
|
||||
if not get_env_value(name) and name in OPTIONAL_ENV_VARS]
|
||||
if not new_and_unset or not _offer_list(
|
||||
f"\n {len(new_and_unset)} new optional key(s) in this update:",
|
||||
[f"{name} — {info.get('description', '')}" for name, info in new_and_unset],
|
||||
" Configure new keys? [y/N]: ",
|
||||
):
|
||||
" Configure new keys? [y/N]: "):
|
||||
return
|
||||
for name, info in new_and_unset:
|
||||
print(f" {info.get('description', name)}")
|
||||
@@ -1449,8 +1428,7 @@ def _offer_skill_config_vars(missing_skill_config: List[Dict[str, Any]], results
|
||||
if not _offer_list(
|
||||
f"\n {len(missing_skill_config)} skill setting(s) not configured:",
|
||||
[f"{v['key']} — {v['description']} (from skill: {v.get('skill', 'unknown')})" for v in missing_skill_config],
|
||||
" Configure skill settings? [y/N]: ",
|
||||
):
|
||||
" Configure skill settings? [y/N]: "):
|
||||
return
|
||||
config = read_raw_config()
|
||||
try:
|
||||
@@ -1630,8 +1608,7 @@ def _preserve_env_ref_templates(current, raw, loaded_expanded=None):
|
||||
key: _preserve_env_ref_templates(
|
||||
value, raw.get(key),
|
||||
loaded_expanded.get(key) if isinstance(loaded_expanded, dict) else None)
|
||||
for key, value in current.items()
|
||||
}
|
||||
for key, value in current.items()}
|
||||
|
||||
if isinstance(current, list) and isinstance(raw, list):
|
||||
# Match named objects (e.g. custom_providers) by name so reordering keeps templates;
|
||||
@@ -1644,8 +1621,7 @@ def _preserve_env_ref_templates(current, raw, loaded_expanded=None):
|
||||
_preserve_env_ref_templates(
|
||||
item, raw_by_name.get(item.get("name")),
|
||||
loaded_by_name.get(item.get("name")) if loaded_by_name is not None else None)
|
||||
for item in current
|
||||
]
|
||||
for item in current]
|
||||
return [
|
||||
_preserve_env_ref_templates(
|
||||
item,
|
||||
@@ -1653,8 +1629,7 @@ def _preserve_env_ref_templates(current, raw, loaded_expanded=None):
|
||||
loaded_expanded[index]
|
||||
if isinstance(loaded_expanded, list) and index < len(loaded_expanded)
|
||||
else None)
|
||||
for index, item in enumerate(current)
|
||||
]
|
||||
for index, item in enumerate(current)]
|
||||
|
||||
return current
|
||||
|
||||
@@ -1802,8 +1777,7 @@ TURN_LIMIT_UNLIMITED = sys.maxsize
|
||||
|
||||
# Spellings that mean "no limit" (compared lowercased, whitespace-stripped).
|
||||
_UNLIMITED_SPELLINGS = frozenset({
|
||||
"none", "null", "unlimited", "infinite", "infinity", "inf", "∞", "-1", "0"
|
||||
})
|
||||
"none", "null", "unlimited", "infinite", "infinity", "inf", "∞", "-1", "0"})
|
||||
|
||||
|
||||
def resolve_turn_limit(raw: Any, default: int = TURN_LIMIT_UNLIMITED) -> int:
|
||||
@@ -1913,8 +1887,7 @@ _FIX_PERMS = "Fix the file permissions or move it aside first."
|
||||
_FIX_YAML = "Fix the file or restore from a .corrupt.*.bak backup first."
|
||||
|
||||
|
||||
def require_readable_config_before_write(
|
||||
config_path: Optional[Path] = None) -> Dict[str, Any]:
|
||||
def require_readable_config_before_write(config_path: Optional[Path] = None) -> Dict[str, Any]:
|
||||
"""Refuse to replace an existing config.yaml that cannot be read or parsed; return the mapping.
|
||||
Guards two collapse-to-empty failure modes that would let a read-then-write caller silently
|
||||
wipe user overrides: an unreadable file (permissions / broken mount) and an unparseable or
|
||||
@@ -1943,9 +1916,8 @@ def require_readable_config_before_write(
|
||||
exc = TypeError(f"top-level YAML must be a mapping, got {type(loaded).__name__}")
|
||||
_warn_config_parse_failure(config_path, exc, fallback="refuse-write")
|
||||
raise RuntimeError(
|
||||
f"Refusing to overwrite {config_path}: top-level YAML must be a mapping, "
|
||||
f"got {type(loaded).__name__}. Fix the file or restore from a "
|
||||
f".corrupt.*.bak backup first."
|
||||
f"Refusing to overwrite {config_path}: top-level YAML must be a mapping, got "
|
||||
f"{type(loaded).__name__}. Fix the file or restore from a .corrupt.*.bak backup first."
|
||||
) from exc
|
||||
return loaded
|
||||
|
||||
@@ -2004,10 +1976,7 @@ TERMINAL_CONFIG_ENV_MAP = {
|
||||
"docker_volumes", "docker_env", "docker_mount_cwd_to_workspace", "docker_network",
|
||||
"docker_extra_args", "docker_shm_size", "docker_run_as_host_user",
|
||||
"docker_persist_across_processes", "docker_shared_container_key",
|
||||
"docker_orphan_reaper", "sandbox_dir", "persistent_shell",
|
||||
)
|
||||
},
|
||||
}
|
||||
"docker_orphan_reaper", "sandbox_dir", "persistent_shell")}}
|
||||
|
||||
|
||||
def _terminal_env_value(value: Any) -> str:
|
||||
@@ -2027,8 +1996,7 @@ def terminal_config_owned_env_vars(terminal_config: Any) -> Set[str]:
|
||||
env_var
|
||||
for key, env_var in TERMINAL_CONFIG_ENV_MAP.items()
|
||||
if key in terminal_config
|
||||
and _terminal_config_value_is_bridgeable(key, terminal_config[key])
|
||||
}
|
||||
and _terminal_config_value_is_bridgeable(key, terminal_config[key])}
|
||||
|
||||
|
||||
def terminal_config_env_var_for_key(key: str) -> Optional[str]:
|
||||
@@ -2469,8 +2437,7 @@ def _check_non_ascii_credential(key: str, value: str) -> str:
|
||||
print(
|
||||
f"\n Warning: {key} contains non-ASCII characters that will break API requests.\n"
|
||||
f" This usually happens when copy-pasting from a PDF, rich-text editor,\n"
|
||||
f" or web page that substitutes lookalike Unicode glyphs for ASCII letters.\n"
|
||||
f"\n"
|
||||
f" or web page that substitutes lookalike Unicode glyphs for ASCII letters.\n\n"
|
||||
+ "\n".join(f" {line}" for line in bad_chars[:5])
|
||||
+ ("\n ... and more" if len(bad_chars) > 5 else "")
|
||||
+ "\n\n The non-ASCII characters have been stripped automatically.\n"
|
||||
@@ -2490,8 +2457,7 @@ def _quote_env_value(value: str) -> str:
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def _env_line_defines_key(
|
||||
line: str, key: str, *, is_windows: Optional[bool] = None) -> bool:
|
||||
def _env_line_defines_key(line: str, key: str, *, is_windows: Optional[bool] = None) -> bool:
|
||||
"""True when a .env line assigns ``key`` — plain, ``export``-prefixed, or ``KEY = value``.
|
||||
Must match exactly the shapes ``load_env()`` parses; otherwise a hand-added line is invisible
|
||||
to save (duplicate appended) and remove (line survives -> the value resurrects on next load)."""
|
||||
@@ -2699,8 +2665,7 @@ def redact_key(key: str) -> str:
|
||||
_SECRET_CONFIG_KEYS = frozenset({
|
||||
"api_key", "apikey", "key", "token", "access_token", "refresh_token", "id_token",
|
||||
"secret", "client_secret", "password", "passwd", "auth", "authorization",
|
||||
"private_key", "bearer", "jwt",
|
||||
})
|
||||
"private_key", "bearer", "jwt"})
|
||||
|
||||
|
||||
def redact_config_value(value: Any, _depth: int = 0) -> Any:
|
||||
@@ -2753,8 +2718,7 @@ _SHOW_CONFIG_API_KEYS = (
|
||||
("TAVILY_API_KEY", "Tavily"),
|
||||
("BROWSERBASE_API_KEY", "Browserbase"),
|
||||
("BROWSER_USE_API_KEY", "Browser Use"),
|
||||
("FAL_KEY", "FAL"),
|
||||
)
|
||||
("FAL_KEY", "FAL"))
|
||||
|
||||
|
||||
def _show_model_section(config: Dict[str, Any]) -> None:
|
||||
@@ -2808,21 +2772,17 @@ def _show_terminal_section(config: Dict[str, Any]) -> None:
|
||||
'singularity': lambda: [f" Image: {terminal.get('singularity_image', 'docker://' + default_img)}"],
|
||||
'modal': lambda: [
|
||||
f" Modal image: {terminal.get('modal_image', default_img)}",
|
||||
f" Modal token: {configured('MODAL_TOKEN_ID')}",
|
||||
],
|
||||
f" Modal token: {configured('MODAL_TOKEN_ID')}"],
|
||||
'daytona': lambda: [
|
||||
f" Daytona image: {terminal.get('daytona_image', default_img)}",
|
||||
f" API key: {configured('DAYTONA_API_KEY')}",
|
||||
],
|
||||
f" API key: {configured('DAYTONA_API_KEY')}"],
|
||||
'vercel_sandbox': lambda: [
|
||||
f" Vercel runtime: {terminal.get('vercel_runtime', 'node24')}",
|
||||
f" Vercel auth: {'configured' if get_env_value('VERCEL_OIDC_TOKEN') or (get_env_value('VERCEL_TOKEN') and get_env_value('VERCEL_PROJECT_ID') and get_env_value('VERCEL_TEAM_ID')) else '(not set)'}",
|
||||
],
|
||||
'ssh': lambda: [
|
||||
f" SSH host: {get_env_value('TERMINAL_SSH_HOST') or '(not set)'}",
|
||||
f" SSH user: {get_env_value('TERMINAL_SSH_USER') or '(not set)'}",
|
||||
],
|
||||
}
|
||||
f" SSH user: {get_env_value('TERMINAL_SSH_USER') or '(not set)'}"]}
|
||||
for line in backend_lines.get(terminal.get('backend'), list)():
|
||||
print(line)
|
||||
|
||||
@@ -2856,8 +2816,7 @@ def _show_aux_overrides(config: Dict[str, Any]) -> None:
|
||||
overrides = {
|
||||
label: (t.get('provider', 'auto'), t.get('model', ''))
|
||||
for label, t in aux_tasks.items()
|
||||
if t.get('provider', 'auto') != 'auto' or t.get('model', '')
|
||||
}
|
||||
if t.get('provider', 'auto') != 'auto' or t.get('model', '')}
|
||||
if not overrides:
|
||||
return
|
||||
_section("Auxiliary Models (overrides)")
|
||||
@@ -2960,8 +2919,7 @@ def edit_config():
|
||||
|
||||
_CRON_DRIFT_AXIS_BY_KEY = {
|
||||
"model": "model", "model.default": "model", "model.model": "model", "model.name": "model",
|
||||
"model.provider": "provider", "provider": "provider",
|
||||
}
|
||||
"model.provider": "provider", "provider": "provider"}
|
||||
|
||||
|
||||
def _cron_model_drift_axis_for_config_key(key: str) -> Optional[str]:
|
||||
@@ -2980,8 +2938,7 @@ def _cron_section(config: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
return cron_config if isinstance(cron_config, dict) else None
|
||||
|
||||
|
||||
def cron_model_drift_guard_enabled(
|
||||
config: Optional[Dict[str, Any]] = None) -> bool:
|
||||
def cron_model_drift_guard_enabled(config: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""Whether cron must fail closed on unpinned inference drift.
|
||||
Only the literal YAML boolean ``false`` disables this spend-safety guard; missing, malformed,
|
||||
or non-boolean values stay fail-closed. With *config* omitted the merged config is loaded so
|
||||
@@ -3025,8 +2982,7 @@ def cron_model_drift_axes(
|
||||
|
||||
current = {
|
||||
"provider": _model_assignment_text(current_provider).lower(),
|
||||
"model": _model_assignment_text(current_model).lower(),
|
||||
}
|
||||
"model": _model_assignment_text(current_model).lower()}
|
||||
# A cron.model / cron.model_provider fleet default covers its axis: that axis no longer follows
|
||||
# the global assignment at fire time, so the guard never engages and a warning would be false.
|
||||
fleet = _cron_section(config) or {}
|
||||
@@ -3066,8 +3022,7 @@ def _cron_model_impact_result(available: bool, guard_enabled: bool) -> Dict[str,
|
||||
"guard_enabled": guard_enabled,
|
||||
"affected_count": 0,
|
||||
"truncated": False,
|
||||
"jobs": [],
|
||||
}
|
||||
"jobs": []}
|
||||
|
||||
|
||||
def build_cron_model_impact(
|
||||
@@ -3110,8 +3065,7 @@ def build_cron_model_impact(
|
||||
result["jobs"].append({
|
||||
"id": job_id,
|
||||
"name": _cron_impact_job_name(job.get("name"), job_id),
|
||||
"drifted_axes": axes,
|
||||
})
|
||||
"drifted_axes": axes})
|
||||
|
||||
result["truncated"] = result["affected_count"] > len(result["jobs"])
|
||||
return result
|
||||
@@ -3138,10 +3092,9 @@ def warn_unpinned_cron_jobs_after_model_config_change(
|
||||
print(
|
||||
f"⚠️ {affected} enabled unpinned cron {noun} {verb} stored "
|
||||
f"{axis}_snapshot values that differ from the new global {axis}. "
|
||||
"They will fail closed on their next run instead of silently using the "
|
||||
"changed model/provider. Inspect with `hermes cron list`, then pin the "
|
||||
"intended values with `hermes cron edit <job_id> --provider <provider> "
|
||||
"--model <model>`.")
|
||||
"They will fail closed on their next run instead of silently using the changed "
|
||||
"model/provider. Inspect with `hermes cron list`, then pin the intended values with "
|
||||
"`hermes cron edit <job_id> --provider <provider> --model <model>`.")
|
||||
|
||||
|
||||
def _default_value_for_key(dotted_key: str):
|
||||
@@ -3155,8 +3108,7 @@ def _default_value_for_key(dotted_key: str):
|
||||
_OPEN_DICT_TOP_LEVEL_KEYS = frozenset({
|
||||
"providers", "credential_pool_strategies", "mcp_servers", "hooks", "quick_commands",
|
||||
"personalities", "command_allowlist", "model_catalog", "channel_prompts", "server_actions",
|
||||
"secrets", "goals", "loops",
|
||||
})
|
||||
"secrets", "goals", "loops"})
|
||||
|
||||
# Top-level keys whose sub-keys are partially schema-defined (e.g. a PlatformConfig dataclass) but
|
||||
# where users may add fields DEFAULT_CONFIG doesn't enumerate: validate the FIRST segment only.
|
||||
@@ -3168,8 +3120,7 @@ _SCHEMA_DEFINED_DICT_KEYS = frozenset({
|
||||
# MCP server template / dynamic auth dicts
|
||||
"sessions", "checkpoints",
|
||||
# Plugin enable/disable lists + index_url override; absent from DEFAULT_CONFIG.
|
||||
"plugins",
|
||||
})
|
||||
"plugins"})
|
||||
|
||||
# Top-level keys that can be ANY user-supplied name.
|
||||
_DYNAMIC_TOP_LEVEL_KEYS = frozenset({
|
||||
@@ -3281,8 +3232,7 @@ _SCALAR_WORDS = {
|
||||
'false': False, 'no': False, 'off': False,
|
||||
# YAML null. Many DEFAULT_CONFIG leaves are "null/absent = off"; without this,
|
||||
# ``config set X null`` stored the truthy string "null" and the feature could never be cleared.
|
||||
'null': None, 'none': None, '~': None,
|
||||
}
|
||||
'null': None, 'none': None, '~': None}
|
||||
|
||||
|
||||
def _coerce_config_set_value(key: str, value: str) -> Any:
|
||||
@@ -3370,8 +3320,7 @@ def _guard_section_overwrite(key: str, value: Any, user_config: Dict[str, Any],
|
||||
sub = [k for k in existing if isinstance(k, str)]
|
||||
err = [
|
||||
f"✗ Cannot set '{key}' to a scalar — '{key}' is a "
|
||||
f"configuration section with {len(sub)} sub-key(s).",
|
||||
]
|
||||
f"configuration section with {len(sub)} sub-key(s)."]
|
||||
if sub:
|
||||
err.append(f" Sub-keys: {', '.join(sub[:8])}")
|
||||
if len(sub) > 8:
|
||||
@@ -3380,8 +3329,7 @@ def _guard_section_overwrite(key: str, value: Any, user_config: Dict[str, Any],
|
||||
" Use a dotted path to set a specific leaf key:",
|
||||
f" hermes config set {key}.<sub-key> <value>",
|
||||
" Or use --force to replace the entire section:",
|
||||
f" hermes config set --force {key} {value!r}",
|
||||
]
|
||||
f" hermes config set --force {key} {value!r}"]
|
||||
print("\n".join(err), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -3638,8 +3586,7 @@ def _cmd_config_migrate(args):
|
||||
optional_missing = [v for v in missing_env if not v.get("is_required") and not v.get("advanced")]
|
||||
for heading, group, suffix in (
|
||||
("⚠️ {} required API key(s) missing:", required_missing, ""),
|
||||
("ℹ️ {} optional API key(s) not configured:", optional_missing, " (enables: {})"),
|
||||
):
|
||||
("ℹ️ {} optional API key(s) not configured:", optional_missing, " (enables: {})")):
|
||||
if group:
|
||||
print(f"\n {heading.format(len(group))}")
|
||||
for var in group:
|
||||
@@ -3673,8 +3620,7 @@ def _cmd_config_check(args):
|
||||
groups = (
|
||||
("Required", REQUIRED_ENV_VARS, lambda n, i: color(f" ✗ {n} (missing)", Colors.RED)),
|
||||
("Optional", OPTIONAL_ENV_VARS,
|
||||
lambda n, i: color(f" ○ {n}{_tools_suffix(i, ' → {}')}", Colors.DIM)),
|
||||
)
|
||||
lambda n, i: color(f" ○ {n}{_tools_suffix(i, ' → {}')}", Colors.DIM)))
|
||||
for title, table, missing_line in groups:
|
||||
print()
|
||||
print(color(f" {title}:", Colors.BOLD))
|
||||
@@ -3700,8 +3646,7 @@ _CONFIG_SUBCOMMANDS = {
|
||||
"path": lambda args: print(get_config_path()),
|
||||
"env-path": lambda args: print(get_env_path()),
|
||||
"migrate": _cmd_config_migrate,
|
||||
"check": _cmd_config_check,
|
||||
}
|
||||
"check": _cmd_config_check}
|
||||
|
||||
_CONFIG_USAGE = """Available commands:
|
||||
hermes config Show current configuration
|
||||
@@ -3751,8 +3696,7 @@ def _inject_profile_env_vars() -> None:
|
||||
"url": _pp.signup_url or None,
|
||||
"password": _is_key,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
}
|
||||
"advanced": True}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -3803,8 +3747,7 @@ def _inject_platform_plugin_env_vars() -> None:
|
||||
"prompt": meta.get("prompt") or name,
|
||||
"url": meta.get("url") or None,
|
||||
"password": is_secret,
|
||||
"category": meta.get("category") or "messaging",
|
||||
}
|
||||
"category": meta.get("category") or "messaging"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
Reference in New Issue
Block a user