From ca2675d9d67ddfabf0d4627d8ca4abc105a37170 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:06:03 -0700 Subject: [PATCH] =?UTF-8?q?refactor(hermes=5Fcli):=20profiles=20cluster=20?= =?UTF-8?q?=E2=80=94=20share=20=5Fcanon=5Fvalid/=5Fexisting=5Fprofile=5Fdi?= =?UTF-8?q?r=20with=20profile=5Fdistribution,=20fold=20single-item=20brack?= =?UTF-8?q?ets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/profile_cmd.py | 16 ++---- hermes_cli/profile_describer.py | 16 ++---- hermes_cli/profile_distribution.py | 76 ++++++++-------------------- hermes_cli/profiles.py | 80 ++++++++---------------------- 4 files changed, 48 insertions(+), 140 deletions(-) diff --git a/hermes_cli/profile_cmd.py b/hermes_cli/profile_cmd.py index a037a66e56..1157d95ae7 100644 --- a/hermes_cli/profile_cmd.py +++ b/hermes_cli/profile_cmd.py @@ -120,12 +120,8 @@ def _profile_list(args): if not profiles: print("No profiles found.") return - print( - f"\n {'Profile':<16} {'Model':<28} {'Gateway':<12} {'Alias':<12} {'Distribution'}" - ) - print( - f" {'─' * 15} {'─' * 27} {'─' * 11} {'─' * 11} {'─' * 20}" - ) + print(f"\n {'Profile':<16} {'Model':<28} {'Gateway':<12} {'Alias':<12} {'Distribution'}") + print(f" {'─' * 15} {'─' * 27} {'─' * 11} {'─' * 11} {'─' * 20}") for p in profiles: marker = " ◆" if _is_active(p, active) else " " name = format_profile_label(p.name, p.display_name) @@ -185,9 +181,7 @@ def _profile_create(args): # Fresh profiles only: clones already carry the source's (user-curated) skills. result = seed_profile_skills(profile_dir) if result and result.get("skipped_opt_out"): - print( - "No bundled skills seeded (--no-skills). Delete .no-bundled-skills in the profile to opt back in." - ) + print("No bundled skills seeded (--no-skills). Delete .no-bundled-skills in the profile to opt back in.") elif result: print(f"{len(result.get('copied', []))} bundled skills synced.") else: @@ -278,9 +272,7 @@ def _profile_describe(args): # --text path: just write the user-authored description. if text_value: try: - _profiles_mod.write_profile_meta( - _describe_target_dir(name), description=text_value, description_auto=False - ) + _profiles_mod.write_profile_meta(_describe_target_dir(name), description=text_value, description_auto=False) print(f"Description updated for '{name}'.") except Exception as exc: _die(f"Error: {exc}", err=True) diff --git a/hermes_cli/profile_describer.py b/hermes_cli/profile_describer.py index 3b5de354c4..f21e0a3854 100644 --- a/hermes_cli/profile_describer.py +++ b/hermes_cli/profile_describer.py @@ -109,9 +109,7 @@ def _extract_json_blob(raw: str) -> Optional[dict]: return _extract(raw, _FENCE_RE) -def describe_profile( - profile_name: str, *, overwrite: bool = False, timeout: Optional[int] = None -) -> DescribeOutcome: +def describe_profile(profile_name: str, *, overwrite: bool = False, timeout: Optional[int] = None) -> DescribeOutcome: """Auto-generate a description for one profile. Expected failures (profile missing, no aux client, API error, malformed response) return ``ok=False`` so a sweep continues. @@ -153,9 +151,7 @@ def describe_profile( # extra_body, reasoning_effort, retries); the direct-create path dropped extra_body. resp = call_llm( task="profile_describer", - messages=[ - {"role": "system", "content": _SYSTEM_PROMPT}, {"role": "user", "content": user_msg} - ], + messages=[{"role": "system", "content": _SYSTEM_PROMPT}, {"role": "user", "content": user_msg}], temperature=0.3, max_tokens=400, timeout=timeout or 60, @@ -177,14 +173,10 @@ def describe_profile( else: val = parsed.get("description") if not isinstance(val, str) or not val.strip(): - return DescribeOutcome( - canon, False, "LLM response missing 'description' field" - ) + return DescribeOutcome(canon, False, "LLM response missing 'description' field") description = val.strip()[:280] try: - profiles_mod.write_profile_meta( - profile_dir, description=description, description_auto=True - ) + profiles_mod.write_profile_meta(profile_dir, description=description, description_auto=True) except Exception as exc: return DescribeOutcome(canon, False, f"failed to write profile.yaml: {exc}") return DescribeOutcome(canon, True, "described", description=description) diff --git a/hermes_cli/profile_distribution.py b/hermes_cli/profile_distribution.py index af5bd528f8..3c1a8a7f13 100644 --- a/hermes_cli/profile_distribution.py +++ b/hermes_cli/profile_distribution.py @@ -58,9 +58,7 @@ class DistributionError(Exception): """Raised for distribution install/update failures.""" -# --------------------------------------------------------------------------- # Manifest -# --------------------------------------------------------------------------- def _str(data: dict, key: str, default: str = "") -> str: return str(data.get(key) or default) @@ -76,9 +74,7 @@ class EnvRequirement: @classmethod def from_dict(cls, data: Any) -> "EnvRequirement": if not isinstance(data, dict): - raise DistributionError( - f"env_requires entry must be a mapping, got {type(data).__name__}" - ) + raise DistributionError(f"env_requires entry must be a mapping, got {type(data).__name__}") name = _str(data, "name").strip() if not name: raise DistributionError("env_requires entry missing 'name'") @@ -114,9 +110,7 @@ class DistributionManifest: @classmethod def from_dict(cls, data: Any) -> "DistributionManifest": if not isinstance(data, dict): - raise DistributionError( - f"{MANIFEST_FILENAME} must be a mapping, got {type(data).__name__}" - ) + raise DistributionError(f"{MANIFEST_FILENAME} must be a mapping, got {type(data).__name__}") name = _str(data, "name").strip() if not name: raise DistributionError(f"{MANIFEST_FILENAME} missing 'name'") @@ -171,15 +165,11 @@ def write_manifest(profile_dir: Path, manifest: DistributionManifest) -> Path: # distribution.yaml, _copy_dist_payload reaches here with no manifest on disk. It is a # shareable descriptor, not a secret — don't leave it at mkstemp's 0600. An existing # file's mode is preserved. - atomic_yaml_write( - mf_path, manifest.to_dict(), sort_keys=False, default_flow_style=False, create_mode=0o644 - ) + atomic_yaml_write(mf_path, manifest.to_dict(), sort_keys=False, default_flow_style=False, create_mode=0o644) return mf_path -# --------------------------------------------------------------------------- # Version check -# --------------------------------------------------------------------------- _VERSION_OP_RE = re.compile(r"^\s*(>=|<=|==|!=|>|<)\s*(.+?)\s*$") _VERSION_OPS = {">=": operator.ge, "<=": operator.le, "==": operator.eq, "!=": operator.ne, ">": operator.gt, "<": operator.lt} @@ -190,7 +180,7 @@ def _parse_semver(v: str) -> Tuple[int, int, int]: parts = re.split(r"[-+]", str(v).strip().lstrip("v"), 1)[0].split(".") parts += ["0"] * (3 - len(parts)) try: - return (int(parts[0]), int(parts[1]), int(parts[2])) + return int(parts[0]), int(parts[1]), int(parts[2]) except ValueError as exc: raise DistributionError(f"Unparseable version: {v!r}") from exc @@ -202,9 +192,7 @@ def check_hermes_requires(spec: str, current_version: str) -> None: m = _VERSION_OP_RE.match(spec) op, target = m.groups() if m else (">=", spec.strip()) if not _VERSION_OPS[op](_parse_semver(current_version), _parse_semver(target)): - raise DistributionError( - f"This distribution requires Hermes {op}{target}, but you have {current_version}." - ) + raise DistributionError(f"This distribution requires Hermes {op}{target}, but you have {current_version}.") def _env_template_from_manifest(manifest: DistributionManifest) -> str: @@ -224,9 +212,7 @@ def _env_template_from_manifest(manifest: DistributionManifest) -> str: return "\n".join(lines).rstrip() + "\n" -# --------------------------------------------------------------------------- # Source staging — git clone or local directory -# --------------------------------------------------------------------------- _GITHUB_SHORTHAND_RE = re.compile(r"^github\.com/[\w.-]+/[\w.-]+/?$") @@ -294,14 +280,10 @@ def _reject_distribution_symlinks(staged: Path) -> None: rel = entry.relative_to(staged) except ValueError: rel = entry - raise DistributionError( - f"Profile distributions cannot contain symlinks: {rel}" - ) + raise DistributionError(f"Profile distributions cannot contain symlinks: {rel}") -# --------------------------------------------------------------------------- # Install -# --------------------------------------------------------------------------- @dataclass class InstallPlan: @@ -317,18 +299,12 @@ class InstallPlan: def _has_cron_jobs(staged: Path) -> bool: cron_dir = staged / "cron" - return cron_dir.is_dir() and ( - any(cron_dir.rglob("*.json")) or any(cron_dir.rglob("*.yaml")) - ) + return cron_dir.is_dir() and (any(cron_dir.rglob("*.json")) or any(cron_dir.rglob("*.yaml"))) -def plan_install( - source: str, workdir: Path, override_name: Optional[str] = None -) -> InstallPlan: +def plan_install(source: str, workdir: Path, override_name: Optional[str] = None) -> InstallPlan: """Stage *source* and produce a plan describing what install would do.""" - from hermes_cli.profiles import ( - get_profile_dir, normalize_profile_name, validate_profile_name - ) + from hermes_cli.profiles import _canon_valid, get_profile_dir from hermes_cli import __version__ as hermes_version staged, provenance = _stage_source(source, workdir) _reject_distribution_symlinks(staged) @@ -338,8 +314,7 @@ def plan_install( f"No {MANIFEST_FILENAME} found at the distribution root — this source is not a Hermes distribution." ) check_hermes_requires(manifest.hermes_requires, hermes_version) # fail fast - canon = normalize_profile_name(override_name or manifest.name) - validate_profile_name(canon) + canon = _canon_valid(override_name or manifest.name) if canon == "default": raise DistributionError( "Cannot install a distribution as 'default' — that is the built-in " @@ -380,9 +355,7 @@ def _owned_entries(staged: Path, manifest: DistributionManifest): yield src, rel_parts -def _copy_dist_payload( - staged: Path, target: Path, manifest: DistributionManifest, preserve_config: bool -) -> None: +def _copy_dist_payload(staged: Path, target: Path, manifest: DistributionManifest, preserve_config: bool) -> None: """Copy distribution-owned files (see ``_owned_entries``) from *staged* into *target*. User-owned paths are never touched. ``config.yaml`` is replaced only when @@ -414,9 +387,7 @@ def _copy_dist_payload( # Emit .env.EXAMPLE from manifest if the staged tree didn't ship one if manifest.env_requires and not (target / ENV_EXAMPLE_FILENAME).exists(): - (target / ENV_EXAMPLE_FILENAME).write_text( - _env_template_from_manifest(manifest), encoding="utf-8" - ) + (target / ENV_EXAMPLE_FILENAME).write_text(_env_template_from_manifest(manifest), encoding="utf-8") # Make sure the manifest on disk reflects resolved name + source write_manifest(target, manifest) @@ -434,9 +405,7 @@ def install_distribution( ) -> InstallPlan: """Install a distribution from *source* into a new profile; returns the resolved plan. Use :func:`plan_install` first to preview + prompt.""" - from hermes_cli.profiles import ( - check_alias_collision, create_wrapper_script - ) + from hermes_cli.profiles import check_alias_collision, create_wrapper_script with tempfile.TemporaryDirectory(prefix="hermes_dist_install_") as tmp: plan = plan_install(source, Path(tmp), override_name=name) if plan.existing and not force: @@ -455,20 +424,15 @@ def install_distribution( def _existing_profile(profile_name: str) -> Tuple[str, Path]: """Return ``(canonical_name, profile_dir)`` or raise if the profile doesn't exist.""" - from hermes_cli.profiles import ( - get_profile_dir, normalize_profile_name, validate_profile_name - ) - canon = normalize_profile_name(profile_name) - validate_profile_name(canon) - target = get_profile_dir(canon) - if not target.is_dir(): - raise DistributionError(f"Profile '{canon}' does not exist.") - return canon, target + from hermes_cli.profiles import _existing_profile_dir + + try: + return _existing_profile_dir(profile_name) + except FileNotFoundError as exc: + raise DistributionError(str(exc)) from exc -def update_distribution( - profile_name: str, force_config: bool = False -) -> InstallPlan: +def update_distribution(profile_name: str, force_config: bool = False) -> InstallPlan: """Re-pull from the installed manifest's ``source:`` and apply: dist-owned files overwritten, user data never touched, ``config.yaml`` preserved unless ``force_config``.""" canon, target = _existing_profile(profile_name) diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 80eebc148f..8e104449ff 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -16,12 +16,8 @@ from pathlib import Path from typing import Dict, List, Optional, Tuple from agent.skill_utils import is_excluded_skill_path -from hermes_cli.archive_safe import ( - archive_root_dirs, make_targz, normalize_archive_parts, safe_extract_targz -) -from hermes_constants import ( - clear_named_profile_deleted, mark_named_profile_deleted, named_profile_is_deleted -) +from hermes_cli.archive_safe import archive_root_dirs, make_targz, normalize_archive_parts, safe_extract_targz +from hermes_constants import clear_named_profile_deleted, mark_named_profile_deleted, named_profile_is_deleted logger = logging.getLogger(__name__) @@ -164,9 +160,7 @@ def _is_our_wrapper(path: Path) -> bool: def _missing_profile_error(canon: str) -> FileNotFoundError: - return FileNotFoundError( - f"Profile '{canon}' does not exist. Create it with: hermes profile create {canon}" - ) + return FileNotFoundError(f"Profile '{canon}' does not exist. Create it with: hermes profile create {canon}") # Validation @@ -191,9 +185,7 @@ def validate_profile_name(name: str) -> None: if name == "default": return # special alias for ~/.hermes if not _PROFILE_ID_RE.match(name): - raise ValueError( - f"Invalid profile name {name!r}. Must match [a-z0-9][a-z0-9_-]{{0,63}}" - ) + raise ValueError(f"Invalid profile name {name!r}. Must match [a-z0-9][a-z0-9_-]{{0,63}}") if name in _RESERVED_NAMES: raise ValueError( f"Profile name {name!r} is reserved — it collides with either " @@ -206,9 +198,7 @@ def validate_alias_name(name: str) -> None: """Raise ``ValueError`` unless *name* is a safe wrapper filename: it is used verbatim under ``~/.local/bin``, so ``../../.bashrc`` must never escape the wrapper dir.""" if not _PROFILE_ID_RE.match(name): - raise ValueError( - f"Invalid alias name {name!r}. Must match [a-z0-9][a-z0-9_-]{{0,63}}" - ) + raise ValueError(f"Invalid alias name {name!r}. Must match [a-z0-9][a-z0-9_-]{{0,63}}") def _canon_valid(name: str) -> str: @@ -255,9 +245,7 @@ def profile_matches_home(name: str, home: "Path | None" = None) -> bool: if home is None: from hermes_constants import get_hermes_home home = get_hermes_home() - return ( - Path(target).expanduser().resolve(strict=False) == Path(home).expanduser().resolve(strict=False) - ) + return Path(target).expanduser().resolve(strict=False) == Path(home).expanduser().resolve(strict=False) except Exception: return False @@ -533,9 +521,7 @@ def _check_gateway_running(profile_dir: Path) -> bool: gateways with no live PID file); fallback validates the PID in ``gateway_state.json`` against the process table, matching ``/api/status``.""" try: - from gateway.status import ( - get_running_pid, get_runtime_status_running_pid, read_runtime_status - ) + from gateway.status import get_running_pid, get_runtime_status_running_pid, read_runtime_status if get_running_pid(profile_dir / "gateway.pid", cleanup_stale=False) is not None: return True except Exception: @@ -595,9 +581,7 @@ def _count_skills(profile_dir: Path) -> int: signature = _skills_dir_signature(skills_dir) now = time.time() cached = _SKILL_COUNT_CACHE.get(key) - if ( - cached is not None and cached[0] == signature and (now - cached[1]) < _SKILL_COUNT_TTL_SECONDS - ): + if cached is not None and cached[0] == signature and (now - cached[1]) < _SKILL_COUNT_TTL_SECONDS: return cached[2] count = sum(1 for md in skills_dir.rglob("SKILL.md") if not is_excluded_skill_path(md)) _SKILL_COUNT_CACHE[key] = (signature, now, count) @@ -703,9 +687,7 @@ def list_profiles() -> List[ProfileInfo]: return profiles -def profiles_to_serve( - multiplex: bool, profile_allowlist: Optional[List[str]] = None -) -> List[Tuple[str, Path]]: +def profiles_to_serve(multiplex: bool, profile_allowlist: Optional[List[str]] = None) -> List[Tuple[str, Path]]: """``(profile_name, hermes_home)`` pairs a gateway should serve — the single chokepoint for "which profiles does the inbound gateway handle". @@ -736,9 +718,7 @@ def profiles_to_serve( missing = tuple(sorted(allowed - {name for name, _ in serve})) if missing and missing not in _WARNED_MISSING_ALLOWLIST_ENTRIES: _WARNED_MISSING_ALLOWLIST_ENTRIES.add(missing) - logger.warning( - "Skipping missing gateway.multiplex_profile_allowlist profile(s): %s", ", ".join(missing) - ) + logger.warning("Skipping missing gateway.multiplex_profile_allowlist profile(s): %s", ", ".join(missing)) return serve @@ -751,9 +731,7 @@ def _resolve_clone_source(clone_from: Optional[str]) -> Path: clone_from = _canon_valid(clone_from) source_dir = get_profile_dir(clone_from) if not source_dir.is_dir(): - raise FileNotFoundError( - f"Source profile '{clone_from or 'active'}' does not exist at {source_dir}" - ) + raise FileNotFoundError(f"Source profile '{clone_from or 'active'}' does not exist at {source_dir}") return source_dir @@ -784,9 +762,7 @@ def _clone_file(source_dir: Path, profile_dir: Path, relpath: str) -> None: def _clone_all_into(source_dir: Path, profile_dir: Path, canon: str) -> None: """--clone-all: full copytree minus infrastructure/history, then strip runtime files and cloned single-use OAuth grants.""" - shutil.copytree( - source_dir, profile_dir, symlinks=True, ignore=_clone_all_copytree_ignore(source_dir) - ) + shutil.copytree(source_dir, profile_dir, symlinks=True, ignore=_clone_all_copytree_ignore(source_dir)) for stale in _CLONE_ALL_STRIP: (profile_dir / stale).unlink(missing_ok=True) # auth.json / .anthropic_oauth.json copied verbatim fork single-use OAuth grants @@ -838,9 +814,7 @@ def create_profile( ) canon = _canon_valid(name) if canon == "default": - raise ValueError( - "Cannot create a profile named 'default' — it is the built-in profile (~/.hermes)." - ) + raise ValueError("Cannot create a profile named 'default' — it is the built-in profile (~/.hermes).") profile_dir = get_profile_dir(canon) if profile_dir.exists() and named_profile_is_deleted(profile_dir): # Empty shells left by post-delete mkdir may be replaced. Identity files mean the @@ -1027,9 +1001,7 @@ def _profile_bound_backend_pids(canon: str, profile_dir: Path) -> list[int]: continue # Bound to THIS profile by selector flag, or by HERMES_HOME pointing at its dir. - bound = any( - normalize_profile_name(sel) == canon for sel in _argv_profile_selectors(argv) - ) + bound = any(normalize_profile_name(sel) == canon for sel in _argv_profile_selectors(argv)) if not bound: with contextlib.suppress(Exception): # environ() can raise AccessDenied even same-user env_home = (proc.environ() or {}).get("HERMES_HOME", "") @@ -1143,9 +1115,7 @@ def delete_profile(name: str, yes: bool = False) -> Path: to prevent auto-restart, gateway stopped if running).""" canon = normalize_profile_name(name) if canon == "default": - raise ValueError( - "Cannot delete the default profile (~/.hermes).\nTo remove everything, use: hermes uninstall" - ) + raise ValueError("Cannot delete the default profile (~/.hermes).\nTo remove everything, use: hermes uninstall") canon, profile_dir = _existing_profile_dir(canon) gw_running = _check_gateway_running(profile_dir) wrapper_path = _get_wrapper_dir() / canon @@ -1294,9 +1264,7 @@ def _stop_gateway_process(profile_dir: Path) -> None: # Cross-profile kill refusal: the record's hermes_home stamp names the gateway's TRUE # owner. A poisoned gateway.pid in this dir can point at another profile's live # gateway — killing it starts a mutual SIGTERM restart loop. - from gateway.status import ( - get_process_start_time, recorded_gateway_home_conflicts, terminate_pid - ) + from gateway.status import get_process_start_time, recorded_gateway_home_conflicts, terminate_pid if recorded_gateway_home_conflicts(data, expected_home=profile_dir): print( f"✗ Refusing to stop PID {pid}: its recorded HERMES_HOME " @@ -1381,9 +1349,7 @@ def _inside_git_checkout(path: Path) -> bool: resolved = path.resolve() except (OSError, RuntimeError): # RuntimeError: symlink loops on Python <= 3.12 return True - return any( - (candidate / ".git").exists() for candidate in (resolved, *resolved.parents) - ) + return any((candidate / ".git").exists() for candidate in (resolved, *resolved.parents)) def _profile_export_directory() -> Path: @@ -1449,9 +1415,7 @@ def _default_export_ignore(root_dir: Path): or entry in {"package.json", "package-lock.json"} } if Path(directory) == root_dir: - ignored.update( - entry for entry in contents if entry not in _DEFAULT_EXPORT_INCLUDE_ROOT - ) + ignored.update(entry for entry in contents if entry not in _DEFAULT_EXPORT_INCLUDE_ROOT) return ignored return _ignore @@ -1545,9 +1509,7 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path: "Specify it explicitly: hermes profile import --name " ) if archive_root is None: - raise ValueError( - "Profile archive must contain exactly one top-level directory." - ) + raise ValueError("Profile archive must contain exactly one top-level directory.") # Default-profile archives have "default/" at top level; importing as "default" would # target ~/.hermes itself. @@ -1566,9 +1528,7 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path: safe_extract_targz(archive, staging_root) extracted = staging_root / archive_root if not extracted.is_dir(): - raise ValueError( - f"Profile archive root is missing or invalid: {archive_root}" - ) + raise ValueError(f"Profile archive root is missing or invalid: {archive_root}") final_source = extracted if archive_root != canon: final_source = staging_root / canon