refactor(hermes_cli): profiles cluster — contextlib.suppress for best-effort blocks, _existing_profile_dir helper, drop intra-function blanks
This commit is contained in:
@@ -96,15 +96,12 @@ def _profile_status(args):
|
||||
"""Bare ``hermes profile`` — show current profile status."""
|
||||
from hermes_constants import display_hermes_home
|
||||
from hermes_cli.profiles import format_profile_label, get_active_profile_name, list_profiles
|
||||
|
||||
profile_name = get_active_profile_name()
|
||||
dhh = display_hermes_home()
|
||||
|
||||
current = next((p for p in list_profiles() if _is_active(p, profile_name)), None)
|
||||
label = format_profile_label(profile_name, current.display_name if current else "")
|
||||
print(f"\nActive profile: {label}")
|
||||
print(f"Path: {dhh}")
|
||||
|
||||
if current is not None:
|
||||
p = current
|
||||
if p.model:
|
||||
@@ -118,21 +115,17 @@ def _profile_status(args):
|
||||
|
||||
def _profile_list(args):
|
||||
from hermes_cli.profiles import format_profile_label, get_active_profile_name, list_profiles
|
||||
|
||||
profiles = list_profiles()
|
||||
active = get_active_profile_name()
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
for p in profiles:
|
||||
marker = " ◆" if _is_active(p, active) else " "
|
||||
name = format_profile_label(p.name, p.display_name)
|
||||
@@ -146,7 +139,6 @@ def _profile_list(args):
|
||||
|
||||
def _profile_use(args):
|
||||
from hermes_cli.profiles import set_active_profile
|
||||
|
||||
name = args.profile_name
|
||||
try:
|
||||
set_active_profile(name)
|
||||
@@ -160,7 +152,6 @@ def _profile_create(args):
|
||||
_get_wrapper_dir, _is_wrapper_dir_in_path, check_alias_collision, create_profile,
|
||||
create_wrapper_script, get_active_profile_name, seed_profile_skills,
|
||||
)
|
||||
|
||||
name = args.profile_name
|
||||
clone = getattr(args, "clone", False)
|
||||
clone_all = getattr(args, "clone_all", False)
|
||||
@@ -169,7 +160,6 @@ def _profile_create(args):
|
||||
clone_from = getattr(args, "clone_from", None)
|
||||
clone_config = clone or clone_from is not None
|
||||
cloned = clone_config or clone_all
|
||||
|
||||
try:
|
||||
profile_dir = create_profile(
|
||||
name=name, clone_from=clone_from, clone_all=clone_all, clone_config=clone_config,
|
||||
@@ -178,7 +168,6 @@ def _profile_create(args):
|
||||
except (ValueError, FileExistsError, FileNotFoundError) as e:
|
||||
_die(f"Error: {e}")
|
||||
print(f"\nProfile '{name}' created at {profile_dir}")
|
||||
|
||||
if cloned:
|
||||
source_label = clone_from or get_active_profile_name()
|
||||
if clone_all:
|
||||
@@ -188,7 +177,6 @@ def _profile_create(args):
|
||||
# Auto-clone Honcho config for the new profile (only with clone operations)
|
||||
try:
|
||||
from plugins.memory.honcho.cli import clone_honcho_for_profile
|
||||
|
||||
if clone_honcho_for_profile(name):
|
||||
print(f"Honcho config cloned (peer: {name})")
|
||||
except Exception:
|
||||
@@ -204,7 +192,6 @@ def _profile_create(args):
|
||||
print(f"{len(result.get('copied', []))} bundled skills synced.")
|
||||
else:
|
||||
print(f"⚠ Skills could not be seeded. Run `{name} update` to retry.")
|
||||
|
||||
if not no_alias:
|
||||
collision = check_alias_collision(name)
|
||||
if collision:
|
||||
@@ -219,12 +206,10 @@ def _profile_create(args):
|
||||
print(f"\n⚠ {_get_wrapper_dir()} is not in your PATH.")
|
||||
print(" Add to your shell config (~/.bashrc or ~/.zshrc):")
|
||||
print(' export PATH="$HOME/.local/bin:$PATH"')
|
||||
|
||||
try:
|
||||
profile_dir_display = "~/" + profile_dir.relative_to(Path.home()).as_posix()
|
||||
except ValueError:
|
||||
profile_dir_display = str(profile_dir)
|
||||
|
||||
print("\nNext steps:")
|
||||
print(f" {name} setup Configure API keys and model")
|
||||
print(f" {name} chat Start chatting")
|
||||
@@ -241,7 +226,6 @@ def _profile_create(args):
|
||||
|
||||
def _profile_delete(args):
|
||||
from hermes_cli.profiles import delete_profile
|
||||
|
||||
try:
|
||||
delete_profile(args.profile_name, yes=getattr(args, "yes", False))
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
@@ -252,7 +236,6 @@ def _describe_target_dir(name: str) -> Path:
|
||||
"""Profile dir for ``describe``: ``default`` maps to the CURRENT home (get_hermes_home),
|
||||
everything else to its named directory."""
|
||||
from hermes_cli import profiles as _profiles_mod
|
||||
|
||||
if _profiles_mod.normalize_profile_name(name) == "default":
|
||||
from hermes_constants import get_hermes_home as _hh
|
||||
return Path(_hh())
|
||||
@@ -261,13 +244,11 @@ def _describe_target_dir(name: str) -> Path:
|
||||
|
||||
def _profile_describe(args):
|
||||
from hermes_cli import profiles as _profiles_mod
|
||||
|
||||
all_flag = bool(getattr(args, "all_missing", False))
|
||||
auto_flag = bool(getattr(args, "auto", False))
|
||||
overwrite_flag = bool(getattr(args, "overwrite", False))
|
||||
text_value = getattr(args, "text", None)
|
||||
name = getattr(args, "profile_name", None)
|
||||
|
||||
if all_flag and not auto_flag:
|
||||
_die("profile describe: --all requires --auto", 2, err=True)
|
||||
if all_flag and (text_value or name):
|
||||
@@ -307,14 +288,12 @@ def _profile_describe(args):
|
||||
|
||||
# --auto path: invoke the LLM describer.
|
||||
from hermes_cli import profile_describer as _pd
|
||||
|
||||
if all_flag:
|
||||
targets = _pd.list_describable_profiles(missing_only=True)
|
||||
if not targets:
|
||||
_die("All profiles already have descriptions.", 0)
|
||||
else:
|
||||
targets = [name]
|
||||
|
||||
ok_count = 0
|
||||
for tgt in targets:
|
||||
outcome = _pd.describe_profile(tgt, overwrite=overwrite_flag)
|
||||
@@ -333,7 +312,6 @@ def _profile_show(args):
|
||||
_served_by_running_multiplexer, _count_skills, _read_distribution_meta, _wrapper_path,
|
||||
find_alias_for_profile, format_profile_label, read_profile_meta,
|
||||
)
|
||||
|
||||
if not profile_exists(name):
|
||||
_die(f"Error: Profile '{name}' does not exist.")
|
||||
profile_dir = get_profile_dir(name)
|
||||
@@ -342,7 +320,6 @@ def _profile_show(args):
|
||||
dist_name, dist_version, dist_source = _read_distribution_meta(profile_dir)
|
||||
alias_name = find_alias_for_profile(name)
|
||||
display = read_profile_meta(profile_dir).get("display_name", "")
|
||||
|
||||
print(f"\nProfile: {format_profile_label(name, display)}")
|
||||
print(f"Path: {profile_dir}")
|
||||
if model:
|
||||
@@ -366,21 +343,16 @@ def _profile_alias(args):
|
||||
_get_wrapper_dir, _is_wrapper_dir_in_path, check_alias_collision, create_wrapper_script,
|
||||
profile_exists, remove_wrapper_script, validate_alias_name,
|
||||
)
|
||||
|
||||
name = args.profile_name
|
||||
remove = getattr(args, "remove", False)
|
||||
custom_name = getattr(args, "alias_name", None)
|
||||
|
||||
if not profile_exists(name):
|
||||
_die(f"Error: Profile '{name}' does not exist.")
|
||||
|
||||
alias_name = custom_name or name
|
||||
|
||||
try:
|
||||
validate_alias_name(alias_name)
|
||||
except ValueError as exc:
|
||||
_die(f"Error: {exc}")
|
||||
|
||||
if remove:
|
||||
if remove_wrapper_script(alias_name):
|
||||
print(f"✓ Removed alias '{alias_name}'")
|
||||
@@ -399,7 +371,6 @@ def _profile_alias(args):
|
||||
|
||||
def _profile_rename(args):
|
||||
from hermes_cli.profiles import normalize_profile_name, rename_profile
|
||||
|
||||
try:
|
||||
new_dir = rename_profile(args.old_name, args.new_name)
|
||||
if normalize_profile_name(args.old_name) != "default":
|
||||
@@ -411,7 +382,6 @@ def _profile_rename(args):
|
||||
|
||||
def _profile_export(args):
|
||||
from hermes_cli.profiles import export_profile, get_profile_export_path
|
||||
|
||||
name = args.profile_name
|
||||
try:
|
||||
output = args.output or str(get_profile_export_path(name))
|
||||
@@ -423,12 +393,10 @@ def _profile_export(args):
|
||||
|
||||
def _profile_import(args):
|
||||
from hermes_cli.profiles import check_alias_collision, create_wrapper_script, import_profile
|
||||
|
||||
try:
|
||||
profile_dir = import_profile(args.archive, name=getattr(args, "import_name", None))
|
||||
name = profile_dir.name
|
||||
print(f"✓ Imported profile '{name}' at {profile_dir}")
|
||||
|
||||
if not check_alias_collision(name):
|
||||
wrapper_path = create_wrapper_script(name)
|
||||
if wrapper_path:
|
||||
@@ -441,18 +409,15 @@ def _profile_import(args):
|
||||
def _profile_install(args):
|
||||
import tempfile
|
||||
from hermes_cli.profile_distribution import DistributionError, install_distribution, plan_install
|
||||
|
||||
try:
|
||||
# Preview: stage into a scratch dir, show the manifest, then do the real install.
|
||||
# The double-stage avoids any side-effects if the user declines.
|
||||
with tempfile.TemporaryDirectory(prefix="hermes_dist_preview_") as tmp:
|
||||
plan = plan_install(args.source, Path(tmp), override_name=getattr(args, "install_name", None))
|
||||
_render_distribution_plan(plan)
|
||||
|
||||
if not getattr(args, "yes", False) and not _confirm("\nProceed with install? [y/N] "):
|
||||
print("Install cancelled.")
|
||||
return
|
||||
|
||||
plan = install_distribution(
|
||||
args.source, name=getattr(args, "install_name", None), force=getattr(args, "force", False),
|
||||
create_alias=getattr(args, "alias", False),
|
||||
@@ -477,7 +442,6 @@ def _profile_install(args):
|
||||
def _profile_update(args):
|
||||
from hermes_cli.profile_distribution import DistributionError, read_manifest, update_distribution
|
||||
from hermes_cli.profiles import get_profile_dir, normalize_profile_name
|
||||
|
||||
try:
|
||||
canon = normalize_profile_name(args.profile_name)
|
||||
current = read_manifest(get_profile_dir(canon))
|
||||
@@ -486,7 +450,6 @@ def _profile_update(args):
|
||||
f"Error: Profile '{canon}' is not a distribution (no distribution.yaml). "
|
||||
"Only profiles installed via `hermes profile install` can be updated."
|
||||
)
|
||||
|
||||
force_config = getattr(args, "force_config", False)
|
||||
if not getattr(args, "yes", False):
|
||||
print(f"\nUpdate '{canon}' from: {current.source or '(no source)'}")
|
||||
@@ -499,7 +462,6 @@ def _profile_update(args):
|
||||
if not _confirm("\nProceed? [y/N] "):
|
||||
print("Update cancelled.")
|
||||
return
|
||||
|
||||
plan = update_distribution(canon, force_config=force_config)
|
||||
print(f"\n✓ Updated '{plan.manifest.name}' → v{plan.manifest.version}")
|
||||
if plan.has_cron:
|
||||
@@ -520,7 +482,6 @@ _INFO_FIELDS = (
|
||||
|
||||
def _profile_info(args):
|
||||
from hermes_cli.profile_distribution import describe_distribution, DistributionError
|
||||
|
||||
try:
|
||||
data = describe_distribution(args.profile_name)
|
||||
except (DistributionError, ValueError) as e:
|
||||
|
||||
@@ -106,7 +106,6 @@ def _sample_skills(names: list[str]) -> list[str]:
|
||||
|
||||
def _extract_json_blob(raw: str) -> Optional[dict]:
|
||||
from hermes_cli.kanban_specify import _extract_json_blob as _extract
|
||||
|
||||
return _extract(raw, _FENCE_RE)
|
||||
|
||||
|
||||
@@ -121,7 +120,6 @@ def describe_profile(
|
||||
canon = profiles_mod.normalize_profile_name(profile_name)
|
||||
if not profiles_mod.profile_exists(canon): # handles the virtual "default" name
|
||||
return DescribeOutcome(canon, False, "profile not found")
|
||||
|
||||
try:
|
||||
if canon == "default":
|
||||
from hermes_constants import get_hermes_home # type: ignore
|
||||
@@ -130,32 +128,26 @@ def describe_profile(
|
||||
profile_dir = profiles_mod.get_profile_dir(canon)
|
||||
except Exception as exc:
|
||||
return DescribeOutcome(canon, False, f"cannot resolve profile dir: {exc}")
|
||||
|
||||
existing = profiles_mod.read_profile_meta(profile_dir)
|
||||
if existing.get("description") and not existing.get("description_auto") and not overwrite:
|
||||
return DescribeOutcome(
|
||||
canon, False, "profile already has a user-authored description (use --overwrite to replace)"
|
||||
)
|
||||
|
||||
all_skills = _collect_skills(profile_dir)
|
||||
skill_list = "\n".join(f" - {n}" for n in _sample_skills(all_skills)) or " (no skills installed)"
|
||||
|
||||
try:
|
||||
model, provider = profiles_mod._read_config_model(profile_dir)
|
||||
except Exception:
|
||||
model, provider = None, None
|
||||
|
||||
try:
|
||||
from agent.auxiliary_client import call_llm # type: ignore
|
||||
except Exception as exc:
|
||||
logger.debug("describe: auxiliary client import failed: %s", exc)
|
||||
return DescribeOutcome(canon, False, "auxiliary client unavailable")
|
||||
|
||||
user_msg = _USER_TEMPLATE.format(
|
||||
name=canon, model=(model or "(unset)"), provider=(provider or "(unset)"), skill_count=len(all_skills),
|
||||
skill_cap=MAX_SKILLS_FOR_PROMPT, skill_list=skill_list,
|
||||
)
|
||||
|
||||
try:
|
||||
# call_llm applies auxiliary.profile_describer.* config (provider/model/base_url,
|
||||
# extra_body, reasoning_effort, retries); the direct-create path dropped extra_body.
|
||||
@@ -171,12 +163,10 @@ def describe_profile(
|
||||
except Exception as exc:
|
||||
logger.info("describe: API call failed for %s (%s)", canon, exc)
|
||||
return DescribeOutcome(canon, False, f"LLM error: {type(exc).__name__}")
|
||||
|
||||
try:
|
||||
raw = resp.choices[0].message.content or ""
|
||||
except Exception:
|
||||
raw = ""
|
||||
|
||||
parsed = _extract_json_blob(raw)
|
||||
if parsed is None:
|
||||
# Fall back: raw text trimmed to one paragraph.
|
||||
@@ -191,14 +181,12 @@ def describe_profile(
|
||||
canon, False, "LLM response missing 'description' field"
|
||||
)
|
||||
description = val.strip()[:280]
|
||||
|
||||
try:
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -260,7 +260,6 @@ def _stage_source(source: str, workdir: Path) -> Tuple[Path, str]:
|
||||
"""Resolve *source* to ``(staged_dir, provenance)``: git URLs are shallow-cloned into
|
||||
*workdir* (``.git`` removed); a local directory is used in place."""
|
||||
src_str = source.strip()
|
||||
|
||||
if _looks_like_git_url(src_str):
|
||||
staged, provenance = workdir / "clone", src_str
|
||||
_git_clone(src_str, staged)
|
||||
@@ -331,7 +330,6 @@ def plan_install(
|
||||
get_profile_dir, normalize_profile_name, validate_profile_name
|
||||
)
|
||||
from hermes_cli import __version__ as hermes_version
|
||||
|
||||
staged, provenance = _stage_source(source, workdir)
|
||||
_reject_distribution_symlinks(staged)
|
||||
manifest = read_manifest(staged)
|
||||
@@ -339,9 +337,7 @@ def plan_install(
|
||||
raise DistributionError(
|
||||
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)
|
||||
if canon == "default":
|
||||
@@ -353,7 +349,6 @@ def plan_install(
|
||||
manifest.source = provenance
|
||||
# Stamped once here so both fresh install and update propagate a fresh timestamp.
|
||||
manifest.installed_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
target_dir = get_profile_dir(canon)
|
||||
existing = target_dir.is_dir()
|
||||
return InstallPlan(
|
||||
@@ -430,7 +425,6 @@ def _copy_dist_payload(
|
||||
def _bootstrap_user_dirs(target: Path) -> None:
|
||||
"""Create the bootstrap dirs a fresh profile expects (same set as ``create_profile``)."""
|
||||
from hermes_cli.profiles import _PROFILE_DIRS
|
||||
|
||||
for d in _PROFILE_DIRS:
|
||||
(target / d).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -443,10 +437,8 @@ def install_distribution(
|
||||
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:
|
||||
raise DistributionError(
|
||||
f"Profile '{plan.manifest.name}' already exists at {plan.target_dir}. "
|
||||
@@ -456,10 +448,8 @@ def install_distribution(
|
||||
# Fresh install: config.yaml comes from the distribution.
|
||||
_bootstrap_user_dirs(plan.target_dir)
|
||||
_copy_dist_payload(plan.staged_dir, plan.target_dir, plan.manifest, preserve_config=False)
|
||||
|
||||
if create_alias and check_alias_collision(plan.manifest.name) is None:
|
||||
create_wrapper_script(plan.manifest.name)
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
@@ -468,7 +458,6 @@ def _existing_profile(profile_name: str) -> Tuple[str, Path]:
|
||||
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)
|
||||
@@ -494,7 +483,6 @@ def update_distribution(
|
||||
f"Profile '{canon}' has no recorded source. Re-install with "
|
||||
"`hermes profile install <source> --name {canon} --force`."
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="hermes_dist_update_") as tmp:
|
||||
plan = plan_install(existing_manifest.source, Path(tmp), override_name=canon)
|
||||
plan.preserves_config = not force_config
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Profile management for multiple isolated Hermes instances."""
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -126,9 +127,7 @@ _HERMES_SUBCOMMANDS = frozenset({
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_profiles_root() -> Path:
|
||||
"""Named-profiles root, anchored to the hermes root (NOT the current HERMES_HOME, which
|
||||
@@ -170,9 +169,7 @@ def _missing_profile_error(canon: str) -> FileNotFoundError:
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def normalize_profile_name(name: str) -> str:
|
||||
"""Canonical profile id used on disk and in ``-p`` argv: lowercase, ``default`` matched
|
||||
@@ -221,6 +218,15 @@ def _canon_valid(name: str) -> str:
|
||||
return canon
|
||||
|
||||
|
||||
def _existing_profile_dir(name: str) -> Tuple[str, Path]:
|
||||
"""``(canon, profile_dir)`` for an existing profile; FileNotFoundError otherwise."""
|
||||
canon = _canon_valid(name)
|
||||
profile_dir = get_profile_dir(canon)
|
||||
if not profile_dir.is_dir():
|
||||
raise FileNotFoundError(f"Profile '{canon}' does not exist.")
|
||||
return canon, profile_dir
|
||||
|
||||
|
||||
def get_profile_dir(name: str) -> Path:
|
||||
"""Resolve a profile name to its HERMES_HOME directory."""
|
||||
canon = normalize_profile_name(name)
|
||||
@@ -248,7 +254,6 @@ def profile_matches_home(name: str, home: "Path | None" = None) -> bool:
|
||||
target = get_profile_dir(name)
|
||||
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)
|
||||
@@ -275,16 +280,12 @@ def list_profile_names() -> List[str]:
|
||||
"""Cheap name-only listing (``default`` + profile dirs). Unlike :func:`list_profiles` this
|
||||
reads NO per-profile config — safe for hot paths (cron target listings, create validation)."""
|
||||
names = ["default"]
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
names.extend(entry.name for entry in _iter_named_profile_dirs(live_only=False))
|
||||
except OSError:
|
||||
pass
|
||||
return names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alias / wrapper script management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_alias_collision(name: str) -> Optional[str]:
|
||||
"""Return a human-readable collision message, or None if the name is safe."""
|
||||
@@ -297,7 +298,6 @@ def check_alias_collision(name: str) -> Optional[str]:
|
||||
return f"'{canon}' is a reserved name"
|
||||
if canon in _HERMES_SUBCOMMANDS:
|
||||
return f"'{canon}' conflicts with a hermes subcommand"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["where" if sys.platform == "win32" else "which", canon],
|
||||
@@ -311,7 +311,6 @@ def check_alias_collision(name: str) -> Optional[str]:
|
||||
return f"'{canon}' conflicts with an existing command ({existing_path})"
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
return None # safe
|
||||
|
||||
|
||||
@@ -331,7 +330,6 @@ def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[P
|
||||
except OSError as e:
|
||||
print(f"⚠ Could not create {wrapper_dir}: {e}")
|
||||
return None
|
||||
|
||||
wrapper_path = _wrapper_path(canon)
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
@@ -359,14 +357,11 @@ def remove_wrapper_script(name: str) -> bool:
|
||||
candidates = [_get_wrapper_dir() / canon]
|
||||
if sys.platform == "win32":
|
||||
candidates.insert(0, _get_wrapper_dir() / f"{canon}.bat")
|
||||
|
||||
for wrapper_path in candidates:
|
||||
if wrapper_path.exists() and _is_our_wrapper(wrapper_path):
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
wrapper_path.unlink()
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
@@ -375,11 +370,11 @@ def _migrate_profile_config_if_outdated(profile_dir: Path) -> None:
|
||||
profile); otherwise the first desktop/doctor view shows a scary ``v0 -> latest`` warning."""
|
||||
if not (profile_dir / "config.yaml").exists():
|
||||
return
|
||||
|
||||
try:
|
||||
# Creation must not fail over an unmigratable old config; `hermes doctor --fix` surfaces
|
||||
# the detailed error in the target profile.
|
||||
with contextlib.suppress(Exception):
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
from hermes_cli.config import check_config_version, migrate_config
|
||||
|
||||
token = set_hermes_home_override(str(profile_dir))
|
||||
try:
|
||||
current_ver, latest_ver = check_config_version()
|
||||
@@ -387,10 +382,6 @@ def _migrate_profile_config_if_outdated(profile_dir: Path) -> None:
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
except Exception:
|
||||
# Creation must not fail over an unmigratable old config; `hermes doctor --fix`
|
||||
# surfaces the detailed error in the target profile.
|
||||
pass
|
||||
|
||||
|
||||
def find_alias_for_profile(profile_name: str) -> Optional[str]:
|
||||
@@ -419,7 +410,6 @@ def build_alias_map() -> dict[str, str]:
|
||||
return result
|
||||
is_windows = sys.platform == "win32"
|
||||
prefix = "hermes -p "
|
||||
|
||||
for entry in sorted(wrapper_dir.iterdir()):
|
||||
if not entry.is_file():
|
||||
continue
|
||||
@@ -450,9 +440,7 @@ def build_alias_map() -> dict[str, str]:
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ProfileInfo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ProfileInfo:
|
||||
@@ -489,8 +477,7 @@ def _load_yaml_dict(path: Path) -> Optional[dict]:
|
||||
return None
|
||||
try:
|
||||
import yaml
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except Exception:
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
@@ -512,15 +499,14 @@ def _read_config_model(profile_dir: Path) -> tuple:
|
||||
try:
|
||||
# load_config() targets the ACTIVE profile's home; read THIS profile's file raw.
|
||||
from hermes_cli.config import read_user_config_raw
|
||||
cfg = read_user_config_raw(config_path)
|
||||
model_cfg = cfg.get("model", {})
|
||||
model_cfg = read_user_config_raw(config_path).get("model", {})
|
||||
if isinstance(model_cfg, str):
|
||||
return model_cfg, None
|
||||
if isinstance(model_cfg, dict):
|
||||
return model_cfg.get("default") or model_cfg.get("model"), model_cfg.get("provider")
|
||||
return None, None
|
||||
except Exception:
|
||||
return None, None
|
||||
pass
|
||||
return None, None
|
||||
|
||||
|
||||
def _seed_model_config(profile_dir: Path) -> None:
|
||||
@@ -529,22 +515,14 @@ def _seed_model_config(profile_dir: Path) -> None:
|
||||
config_path = profile_dir / "config.yaml"
|
||||
if config_path.exists():
|
||||
return
|
||||
try:
|
||||
with contextlib.suppress(Exception): # creation must not fail over this; `hermes model` sets it later
|
||||
import yaml
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.config import read_user_config_raw
|
||||
|
||||
source = get_hermes_home() / "config.yaml"
|
||||
if not source.is_file():
|
||||
return
|
||||
model_cfg = read_user_config_raw(source).get("model")
|
||||
if not model_cfg:
|
||||
return
|
||||
config_path.write_text(
|
||||
yaml.safe_dump({"model": model_cfg}, sort_keys=False), encoding="utf-8"
|
||||
)
|
||||
except Exception:
|
||||
pass # creation must not fail over this; `hermes model` sets it later
|
||||
model_cfg = read_user_config_raw(source).get("model") if source.is_file() else None
|
||||
if model_cfg:
|
||||
config_path.write_text(yaml.safe_dump({"model": model_cfg}, sort_keys=False), encoding="utf-8")
|
||||
|
||||
|
||||
def _check_gateway_running(profile_dir: Path) -> bool:
|
||||
@@ -574,7 +552,6 @@ def _served_by_running_multiplexer(profile_name: str) -> bool:
|
||||
gateway.pid of its own, so ``_check_gateway_running`` alone reports it stopped)."""
|
||||
try:
|
||||
from hermes_cli.gateway import named_profile_served_by_running_multiplexer
|
||||
|
||||
return named_profile_served_by_running_multiplexer(profile_name)
|
||||
except Exception:
|
||||
return False
|
||||
@@ -601,9 +578,7 @@ def _skills_dir_signature(skills_dir: Path) -> float:
|
||||
for entry in it:
|
||||
try:
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
m = entry.stat(follow_symlinks=False).st_mtime
|
||||
if m > sig:
|
||||
sig = m
|
||||
sig = max(sig, entry.stat(follow_symlinks=False).st_mtime)
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
@@ -616,7 +591,6 @@ def _count_skills(profile_dir: Path) -> int:
|
||||
skills_dir = profile_dir / "skills"
|
||||
if not skills_dir.is_dir():
|
||||
return 0
|
||||
|
||||
key = str(skills_dir)
|
||||
signature = _skills_dir_signature(skills_dir)
|
||||
now = time.time()
|
||||
@@ -625,15 +599,12 @@ def _count_skills(profile_dir: Path) -> int:
|
||||
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)
|
||||
return count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# profile.yaml — per-profile metadata (description, role, etc.)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deliberately tiny and separate from ``config.yaml`` (user-facing Hermes config, ~5000
|
||||
# lines of defaults): this is metadata ABOUT the profile. Missing file -> empty defaults,
|
||||
# never an error; the kanban decomposer falls back to the profile name.
|
||||
@@ -643,9 +614,7 @@ def read_profile_meta(profile_dir: Path) -> dict:
|
||||
"""Read ``profile.yaml`` -> ``{description, description_auto, display_name}`` (empty
|
||||
defaults when missing/unreadable). Never raises — a corrupt file on one profile must not
|
||||
break ``hermes profile list``."""
|
||||
data = _load_yaml_dict(profile_dir / "profile.yaml")
|
||||
if data is None:
|
||||
return {"description": "", "description_auto": False, "display_name": ""}
|
||||
data = _load_yaml_dict(profile_dir / "profile.yaml") or {}
|
||||
return {
|
||||
"description": str(data.get("description") or "").strip(),
|
||||
"description_auto": bool(data.get("description_auto", False)),
|
||||
@@ -676,7 +645,6 @@ def write_profile_meta(
|
||||
# Atomic write: bare open("w") truncates before the dump, and the read path swallows
|
||||
# parse errors as {}, so a crashed write would silently drop unspecified fields.
|
||||
from utils import atomic_yaml_write
|
||||
|
||||
atomic_yaml_write(path, existing, sort_keys=False)
|
||||
|
||||
|
||||
@@ -690,10 +658,7 @@ def format_profile_label(name: str, display_name: Optional[str]) -> str:
|
||||
def set_profile_display_name(profile_name: str, display_name: str) -> str:
|
||||
"""Set (or clear, with ``""``) a presentation-only display name. Returns the stored value;
|
||||
raises ``ValueError`` over 64 chars."""
|
||||
canon = _canon_valid(profile_name)
|
||||
profile_dir = get_profile_dir(canon)
|
||||
if not profile_dir.is_dir():
|
||||
raise FileNotFoundError(f"Profile '{canon}' does not exist.")
|
||||
canon, profile_dir = _existing_profile_dir(profile_name)
|
||||
cleaned = (display_name or "").strip()
|
||||
if len(cleaned) > 64:
|
||||
raise ValueError(f"Display name too long ({len(cleaned)} chars, max 64).")
|
||||
@@ -701,9 +666,7 @@ def set_profile_display_name(profile_name: str, display_name: str) -> str:
|
||||
return cleaned
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _profile_info(name: str, path: Path, *, is_default: bool, alias_name: Optional[str] = None) -> ProfileInfo:
|
||||
"""Build one :class:`ProfileInfo` from a profile directory."""
|
||||
@@ -721,8 +684,7 @@ def _profile_info(name: str, path: Path, *, is_default: bool, alias_name: Option
|
||||
provider=provider, has_env=(path / ".env").exists(), skill_count=_count_skills(path),
|
||||
alias_path=alias_path, alias_name=alias_name, distribution_name=dist_name,
|
||||
distribution_version=dist_version, distribution_source=dist_source,
|
||||
description=meta.get("description", ""), description_auto=meta.get("description_auto", False),
|
||||
display_name=meta.get("display_name", ""),
|
||||
**meta,
|
||||
)
|
||||
|
||||
|
||||
@@ -732,7 +694,6 @@ def list_profiles() -> List[ProfileInfo]:
|
||||
default_home = _get_default_hermes_home()
|
||||
if default_home.is_dir():
|
||||
profiles.append(_profile_info("default", default_home, is_default=True))
|
||||
|
||||
named = _iter_named_profile_dirs()
|
||||
if named:
|
||||
alias_map = build_alias_map() # ONCE, not per profile (was the dominant cost)
|
||||
@@ -755,7 +716,6 @@ def profiles_to_serve(
|
||||
active = get_active_profile_name() or "default"
|
||||
if not multiplex:
|
||||
return [(active, get_profile_dir(active))]
|
||||
|
||||
serve: List[Tuple[str, Path]] = [("default", _get_default_hermes_home())]
|
||||
allowed: Optional[set[str]] = None
|
||||
if profile_allowlist is not None:
|
||||
@@ -769,11 +729,9 @@ def profiles_to_serve(
|
||||
continue
|
||||
if name != "default":
|
||||
allowed.add(name)
|
||||
|
||||
for entry in _iter_named_profile_dirs():
|
||||
if allowed is None or entry.name in allowed:
|
||||
serve.append((entry.name, entry))
|
||||
|
||||
if allowed is not None:
|
||||
missing = tuple(sorted(allowed - {name for name, _ in serve}))
|
||||
if missing and missing not in _WARNED_MISSING_ALLOWLIST_ENTRIES:
|
||||
@@ -781,7 +739,6 @@ def profiles_to_serve(
|
||||
logger.warning(
|
||||
"Skipping missing gateway.multiplex_profile_allowlist profile(s): %s", ", ".join(missing)
|
||||
)
|
||||
|
||||
return serve
|
||||
|
||||
|
||||
@@ -804,12 +761,10 @@ def _seed_file_if_missing(path: Path, text: str, mode: Optional[int] = None) ->
|
||||
"""Best-effort: write *text* to *path* unless it already exists; never raises."""
|
||||
if path.exists():
|
||||
return
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
path.write_text(text, encoding="utf-8")
|
||||
if mode is not None:
|
||||
os.chmod(str(path), mode)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _clone_file(source_dir: Path, profile_dir: Path, relpath: str) -> None:
|
||||
@@ -822,10 +777,8 @@ def _clone_file(source_dir: Path, profile_dir: Path, relpath: str) -> None:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
if relpath == ".env":
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(str(dst), 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _clone_all_into(source_dir: Path, profile_dir: Path, canon: str) -> None:
|
||||
@@ -856,7 +809,6 @@ def _bootstrap_profile_dir(profile_dir: Path, source_dir: Optional[Path]) -> Non
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
for subdir in _PROFILE_DIRS:
|
||||
(profile_dir / subdir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if source_dir is None:
|
||||
_seed_model_config(profile_dir)
|
||||
return
|
||||
@@ -885,12 +837,10 @@ def create_profile(
|
||||
"(cloning explicitly copies skills from the source profile)."
|
||||
)
|
||||
canon = _canon_valid(name)
|
||||
|
||||
if canon == "default":
|
||||
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
|
||||
@@ -901,11 +851,9 @@ def create_profile(
|
||||
if profile_dir.exists():
|
||||
raise FileExistsError(f"Profile '{canon}' already exists at {profile_dir}")
|
||||
clear_named_profile_deleted(profile_dir)
|
||||
|
||||
source_dir = None
|
||||
if clone_from is not None or clone_all or clone_config:
|
||||
source_dir = _resolve_clone_source(clone_from)
|
||||
|
||||
if clone_all and source_dir:
|
||||
_clone_all_into(source_dir, profile_dir, canon)
|
||||
else:
|
||||
@@ -918,11 +866,9 @@ def create_profile(
|
||||
_seed_file_if_missing(profile_dir / ".env", _PLACEHOLDER_ENV, 0o600)
|
||||
|
||||
# Default SOUL.md to customize immediately (skipped when a clone already provided one).
|
||||
try:
|
||||
with contextlib.suppress(Exception): # best-effort — don't fail profile creation over this
|
||||
from hermes_cli.default_soul import DEFAULT_SOUL_MD
|
||||
_seed_file_if_missing(profile_dir / "SOUL.md", DEFAULT_SOUL_MD)
|
||||
except Exception:
|
||||
pass # best-effort — don't fail profile creation over this
|
||||
|
||||
# Opt-out marker read by seed_profile_skills() and `hermes update`'s all-profile sync
|
||||
# (the feature still works via the empty skills/ dir if this fails).
|
||||
@@ -941,18 +887,13 @@ def create_profile(
|
||||
|
||||
# Description last, so a partial-create failure doesn't strand a description file.
|
||||
if description and description.strip():
|
||||
try:
|
||||
write_profile_meta(
|
||||
profile_dir, description=description.strip(), description_auto=False
|
||||
)
|
||||
except Exception:
|
||||
pass # non-fatal — user can describe later with `hermes profile describe`
|
||||
with contextlib.suppress(Exception): # non-fatal — `hermes profile describe` works later
|
||||
write_profile_meta(profile_dir, description=description.strip(), description_auto=False)
|
||||
|
||||
# Inside a container under s6, register the gateway as a runtime s6 service so
|
||||
# `hermes -p <profile> gateway start` supervises via `s6-svc -u` instead of a bare
|
||||
# process. No-op on host (systemd/launchd/windows unit generation handles lifecycle).
|
||||
_maybe_register_gateway_service(canon)
|
||||
|
||||
return profile_dir
|
||||
|
||||
|
||||
@@ -992,7 +933,6 @@ def backfill_profile_envs(quiet: bool = False) -> List[str]:
|
||||
the placeholder header). Never overwrites an existing profile ``.env``."""
|
||||
backfilled: List[str] = []
|
||||
default_env = _get_default_hermes_home() / ".env"
|
||||
|
||||
for entry in _iter_named_profile_dirs():
|
||||
env_path = entry / ".env"
|
||||
if env_path.exists():
|
||||
@@ -1007,7 +947,6 @@ def backfill_profile_envs(quiet: bool = False) -> List[str]:
|
||||
except OSError as e:
|
||||
if not quiet:
|
||||
print(f"⚠ Could not seed .env for profile '{entry.name}': {e}")
|
||||
|
||||
return backfilled
|
||||
|
||||
|
||||
@@ -1055,7 +994,6 @@ def _profile_bound_backend_pids(canon: str, profile_dir: Path) -> list[int]:
|
||||
import psutil # type: ignore
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
try:
|
||||
resolved_dir = profile_dir.resolve()
|
||||
except OSError:
|
||||
@@ -1064,21 +1002,16 @@ def _profile_bound_backend_pids(canon: str, profile_dir: Path) -> list[int]:
|
||||
# Never terminate ourselves or a parent (`hermes -p <canon> profile delete` runs under
|
||||
# the very profile it's deleting).
|
||||
skip: set[int] = {os.getpid()}
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
parent = psutil.Process(os.getpid()).parent()
|
||||
while parent is not None:
|
||||
skip.add(parent.pid)
|
||||
parent = parent.parent()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
current_user = psutil.Process(os.getpid()).username()
|
||||
except Exception:
|
||||
current_user = None
|
||||
|
||||
pids: list[int] = []
|
||||
|
||||
for proc in psutil.process_iter(["pid", "name", "username", "cmdline"]):
|
||||
try:
|
||||
info = proc.info
|
||||
@@ -1087,7 +1020,6 @@ def _profile_bound_backend_pids(canon: str, profile_dir: Path) -> list[int]:
|
||||
continue
|
||||
if current_user is not None and info.get("username") != current_user:
|
||||
continue
|
||||
|
||||
argv = info.get("cmdline") or []
|
||||
if not argv or not _is_hermes_argv(argv):
|
||||
continue
|
||||
@@ -1099,18 +1031,13 @@ def _profile_bound_backend_pids(canon: str, profile_dir: Path) -> list[int]:
|
||||
normalize_profile_name(sel) == canon for sel in _argv_profile_selectors(argv)
|
||||
)
|
||||
if not bound:
|
||||
try:
|
||||
with contextlib.suppress(Exception): # environ() can raise AccessDenied even same-user
|
||||
env_home = (proc.environ() or {}).get("HERMES_HOME", "")
|
||||
if env_home and Path(env_home).resolve() == resolved_dir:
|
||||
bound = True
|
||||
except Exception:
|
||||
pass # environ() can raise AccessDenied even same-user; argv signal only
|
||||
|
||||
bound = bool(env_home) and Path(env_home).resolve() == resolved_dir
|
||||
if bound:
|
||||
pids.append(pid)
|
||||
except Exception:
|
||||
continue # NoSuchProcess / AccessDenied / ZombieProcess and anything else
|
||||
|
||||
return pids
|
||||
|
||||
|
||||
@@ -1119,19 +1046,14 @@ def _wait_then_force_kill(pids: List[int], start_times: dict, *, wait: float = 1
|
||||
to exit, then force-kill stragglers. True when every pid exited gracefully.
|
||||
``start_times`` pins each force kill to the same process incarnation (PID reuse guard)."""
|
||||
from gateway.status import _pid_exists, get_process_start_time, terminate_pid
|
||||
|
||||
for _ in range(int(wait / 0.5)):
|
||||
time.sleep(0.5)
|
||||
if not any(_pid_exists(pid) for pid in pids):
|
||||
return True
|
||||
for pid in pids:
|
||||
if _pid_exists(pid):
|
||||
try:
|
||||
terminate_pid(
|
||||
pid, force=True, expected_start_time=start_times.get(pid, get_process_start_time(pid))
|
||||
)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
||||
terminate_pid(pid, force=True, expected_start_time=start_times.get(pid, get_process_start_time(pid)))
|
||||
return False
|
||||
|
||||
|
||||
@@ -1146,14 +1068,12 @@ def _stop_profile_backends(canon: str, profile_dir: Path) -> None:
|
||||
from gateway.status import terminate_pid
|
||||
except Exception:
|
||||
return
|
||||
|
||||
for pid in pids:
|
||||
try:
|
||||
terminate_pid(pid) # graceful first
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
continue
|
||||
_wait_then_force_kill(pids, {})
|
||||
|
||||
print(f"✓ Stopped {len(pids)} profile backend process(es)")
|
||||
|
||||
|
||||
@@ -1167,10 +1087,8 @@ def _rmtree_make_writable(func, path, exc):
|
||||
raise
|
||||
for target in (path, os.path.dirname(path)): # parent needed for unlink/rmdir
|
||||
if target:
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.chmod(target, os.stat(target).st_mode | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass
|
||||
func(path)
|
||||
|
||||
|
||||
@@ -1202,7 +1120,6 @@ def _print_delete_summary(canon: str, profile_dir: Path, gw_running: bool, wrapp
|
||||
model, provider = _read_config_model(profile_dir)
|
||||
skill_count = _count_skills(profile_dir)
|
||||
dist_name, dist_version, dist_source = _read_distribution_meta(profile_dir)
|
||||
|
||||
print(f"\nProfile: {canon}")
|
||||
print(f"Path: {profile_dir}")
|
||||
if model:
|
||||
@@ -1213,7 +1130,6 @@ def _print_delete_summary(canon: str, profile_dir: Path, gw_running: bool, wrapp
|
||||
print(f"Distribution: {dist_name}@{dist_version or '?'}")
|
||||
if dist_source:
|
||||
print(f"Installed from: {dist_source}")
|
||||
|
||||
print("\nThis will permanently delete:")
|
||||
print(" • All config, API keys, memories, sessions, skills, cron jobs")
|
||||
if wrapper_path is not None:
|
||||
@@ -1225,22 +1141,16 @@ def _print_delete_summary(canon: str, profile_dir: Path, gw_running: bool, wrapp
|
||||
def delete_profile(name: str, yes: bool = False) -> Path:
|
||||
"""Delete a profile, its wrapper script, and its gateway service (service disabled first
|
||||
to prevent auto-restart, gateway stopped if running)."""
|
||||
canon = _canon_valid(name)
|
||||
|
||||
canon = normalize_profile_name(name)
|
||||
if canon == "default":
|
||||
raise ValueError(
|
||||
"Cannot delete the default profile (~/.hermes).\nTo remove everything, use: hermes uninstall"
|
||||
)
|
||||
|
||||
profile_dir = get_profile_dir(canon)
|
||||
if not profile_dir.is_dir():
|
||||
raise FileNotFoundError(f"Profile '{canon}' does not exist.")
|
||||
|
||||
canon, profile_dir = _existing_profile_dir(canon)
|
||||
gw_running = _check_gateway_running(profile_dir)
|
||||
wrapper_path = _get_wrapper_dir() / canon
|
||||
has_wrapper = wrapper_path.exists()
|
||||
_print_delete_summary(canon, profile_dir, gw_running, wrapper_path if has_wrapper else None)
|
||||
|
||||
if not yes:
|
||||
print()
|
||||
try:
|
||||
@@ -1270,14 +1180,11 @@ def delete_profile(name: str, yes: bool = False) -> Path:
|
||||
# Desktop's main serve process opens memory_store.db for every profile and is
|
||||
# deliberately not stopped above; on Windows its handles fail rmtree with WinError 32.
|
||||
# Inside serve (DELETE /api/profiles/<name>) the handles live here; from the CLI no-op.
|
||||
try:
|
||||
with contextlib.suppress(Exception): # best-effort: never block the delete on the release path
|
||||
from plugins.memory.holographic.store import MemoryStore as _MemoryStore
|
||||
|
||||
_released = _MemoryStore.release_all_under(profile_dir)
|
||||
if _released:
|
||||
print(f"✓ Released {_released} memory-store connection(s) held by this process")
|
||||
except Exception:
|
||||
pass # best-effort: never block the delete on the release path
|
||||
|
||||
# 3. Remove wrapper script
|
||||
if has_wrapper and remove_wrapper_script(canon):
|
||||
@@ -1294,10 +1201,8 @@ def delete_profile(name: str, yes: bool = False) -> Path:
|
||||
|
||||
# 5. Clear active_profile if it pointed to this profile
|
||||
_retarget_active_profile(canon, "default", "✓ Active profile reset to default")
|
||||
|
||||
if remove_error is not None:
|
||||
raise RuntimeError(f"Could not remove profile directory {profile_dir}: {remove_error}") from remove_error
|
||||
|
||||
print(f"\nProfile '{canon}' deleted.")
|
||||
return profile_dir
|
||||
|
||||
@@ -1372,10 +1277,9 @@ def _cleanup_gateway_service(name: str, profile_dir: Path) -> None:
|
||||
except Exception as e:
|
||||
print(f"⚠ Service cleanup: {e}")
|
||||
finally:
|
||||
os.environ.pop("HERMES_HOME", None)
|
||||
if old_home is not None:
|
||||
os.environ["HERMES_HOME"] = old_home
|
||||
else:
|
||||
os.environ.pop("HERMES_HOME", None)
|
||||
|
||||
|
||||
def _stop_gateway_process(profile_dir: Path) -> None:
|
||||
@@ -1383,7 +1287,6 @@ def _stop_gateway_process(profile_dir: Path) -> None:
|
||||
pid_file = profile_dir / "gateway.pid"
|
||||
if not pid_file.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
raw = pid_file.read_text(encoding="utf-8").strip()
|
||||
data = json.loads(raw) if raw.startswith("{") else {"pid": int(raw)}
|
||||
@@ -1394,7 +1297,6 @@ def _stop_gateway_process(profile_dir: Path) -> None:
|
||||
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 "
|
||||
@@ -1418,9 +1320,7 @@ def _stop_gateway_process(profile_dir: Path) -> None:
|
||||
print(f"⚠ Could not stop gateway: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Active profile (sticky default)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_active_profile() -> str:
|
||||
"""Read the sticky active profile name."""
|
||||
@@ -1436,7 +1336,6 @@ def set_active_profile(name: str) -> None:
|
||||
canon = _canon_valid(name)
|
||||
if canon != "default" and not profile_exists(canon):
|
||||
raise _missing_profile_error(canon)
|
||||
|
||||
path = _get_active_profile_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if canon == "default":
|
||||
@@ -1449,12 +1348,10 @@ def set_active_profile(name: str) -> None:
|
||||
|
||||
def _retarget_active_profile(old: str, new: str, message: str) -> None:
|
||||
"""If the sticky active profile is *old*, point it at *new* and print *message*. Never raises."""
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
if get_active_profile() == old:
|
||||
set_active_profile(new)
|
||||
print(message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_active_profile_name() -> str:
|
||||
@@ -1462,10 +1359,8 @@ def get_active_profile_name() -> str:
|
||||
name under ``~/.hermes/profiles/<name>``, ``"custom"`` for any other path."""
|
||||
from hermes_constants import get_hermes_home
|
||||
resolved = get_hermes_home().resolve()
|
||||
|
||||
if resolved == _get_default_hermes_home().resolve():
|
||||
return "default"
|
||||
|
||||
profiles_root = _get_profiles_root().resolve()
|
||||
try:
|
||||
parts = resolved.relative_to(profiles_root).parts
|
||||
@@ -1473,13 +1368,10 @@ def get_active_profile_name() -> str:
|
||||
return parts[0]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return "custom"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export / Import
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _inside_git_checkout(path: Path) -> bool:
|
||||
"""True when *path* lies inside a Git checkout. Walks the path's OWN resolved ancestry
|
||||
@@ -1497,7 +1389,6 @@ def _inside_git_checkout(path: Path) -> bool:
|
||||
def _profile_export_directory() -> Path:
|
||||
"""Choose an export directory that cannot become source-tree input."""
|
||||
import tempfile
|
||||
|
||||
export_dir = _get_default_hermes_home() / "profile-exports"
|
||||
if not _inside_git_checkout(export_dir):
|
||||
return export_dir
|
||||
@@ -1592,7 +1483,6 @@ def _scrub_export_secrets(staged: Path) -> None:
|
||||
sessions export --redact``). Runs on the staged copy only; symlinks to text files are
|
||||
materialized when content changes so redaction never follows a link back into the source."""
|
||||
from agent.redact import redact_sensitive_text
|
||||
|
||||
for path in staged.rglob("*"):
|
||||
try:
|
||||
is_link = path.is_symlink()
|
||||
@@ -1600,19 +1490,15 @@ def _scrub_export_secrets(staged: Path) -> None:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
if not _should_redact_export_file(path):
|
||||
continue
|
||||
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue
|
||||
|
||||
redacted = redact_sensitive_text(text, force=True)
|
||||
if redacted == text:
|
||||
continue
|
||||
|
||||
if is_link:
|
||||
path.unlink()
|
||||
path.write_text(redacted, encoding="utf-8")
|
||||
@@ -1622,12 +1508,7 @@ def export_profile(name: str, output_path: str, extra_files: Optional[Dict[str,
|
||||
"""Export a profile to a tar.gz archive; credential files are excluded and staged text is
|
||||
force-redacted first. Returns the output file path."""
|
||||
import tempfile
|
||||
|
||||
canon = _canon_valid(name)
|
||||
profile_dir = get_profile_dir(canon)
|
||||
if not profile_dir.is_dir():
|
||||
raise FileNotFoundError(f"Profile '{canon}' does not exist.")
|
||||
|
||||
canon, profile_dir = _existing_profile_dir(name)
|
||||
# Archive base name without extension (.tar.gz appended by the writer).
|
||||
base = str(Path(output_path)).removesuffix(".tar.gz").removesuffix(".tgz")
|
||||
|
||||
@@ -1652,11 +1533,9 @@ def export_profile(name: str, output_path: str, extra_files: Optional[Dict[str,
|
||||
def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
|
||||
"""Import a profile from a tar.gz archive."""
|
||||
import tempfile
|
||||
|
||||
archive = Path(archive_path)
|
||||
if not archive.exists():
|
||||
raise FileNotFoundError(f"Archive not found: {archive}")
|
||||
|
||||
top_dirs = archive_root_dirs(archive)
|
||||
archive_root = top_dirs.pop() if len(top_dirs) == 1 else None
|
||||
inferred_name = name or archive_root
|
||||
@@ -1678,36 +1557,27 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
|
||||
"Cannot import as 'default' — that is the built-in root profile (~/.hermes). "
|
||||
"Specify a different name: hermes profile import <archive> --name <name>"
|
||||
)
|
||||
|
||||
profile_dir = get_profile_dir(canon)
|
||||
if profile_dir.exists():
|
||||
raise FileExistsError(f"Profile '{canon}' already exists at {profile_dir}")
|
||||
|
||||
_get_profiles_root().mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="hermes_profile_import_") as tmpdir:
|
||||
staging_root = Path(tmpdir)
|
||||
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}"
|
||||
)
|
||||
|
||||
final_source = extracted
|
||||
if archive_root != canon:
|
||||
final_source = staging_root / canon
|
||||
extracted.rename(final_source)
|
||||
|
||||
shutil.move(str(final_source), str(profile_dir))
|
||||
|
||||
return profile_dir
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rename
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _atomic_write_json(path: Path, data: dict) -> bool:
|
||||
"""Write *data* to *path* via a sibling ``.tmp`` + rename. Returns False (tmp cleaned) on OSError."""
|
||||
@@ -1717,10 +1587,8 @@ def _atomic_write_json(path: Path, data: dict) -> bool:
|
||||
tmp.replace(path)
|
||||
return True
|
||||
except OSError:
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
tmp.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
@@ -1729,11 +1597,9 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) ->
|
||||
old_host = f"hermes_{old_name}"
|
||||
legacy_old_host = f"hermes.{old_name}"
|
||||
new_host = f"hermes_{new_name}"
|
||||
|
||||
candidates = [
|
||||
new_dir / "honcho.json", _get_default_hermes_home() / "honcho.json", Path.home() / ".honcho" / "config.json"
|
||||
]
|
||||
|
||||
seen: set[Path] = set()
|
||||
for path in candidates:
|
||||
try:
|
||||
@@ -1743,23 +1609,19 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) ->
|
||||
if resolved in seen or not path.is_file():
|
||||
continue
|
||||
seen.add(resolved)
|
||||
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
hosts = raw.get("hosts")
|
||||
if not isinstance(hosts, dict):
|
||||
continue
|
||||
source_host = old_host if old_host in hosts else legacy_old_host
|
||||
if source_host not in hosts:
|
||||
continue
|
||||
|
||||
if new_host in hosts:
|
||||
print(f"⚠ Honcho host block not migrated: {new_host} already exists in {path}")
|
||||
continue
|
||||
|
||||
block = hosts[source_host]
|
||||
if isinstance(block, dict) and "aiPeer" not in block:
|
||||
block["aiPeer"] = old_name # source_host is ``hermes_<old>`` or legacy ``hermes.<old>``
|
||||
@@ -1773,22 +1635,17 @@ def rename_profile(old_name: str, new_name: str) -> Path:
|
||||
profile's home IS the installation root, so "renaming" it sets a presentation-only
|
||||
``display_name`` instead — the canonical id stays ``default``."""
|
||||
old_canon = _canon_valid(old_name)
|
||||
|
||||
if old_canon == "default":
|
||||
if not (new_name or "").strip():
|
||||
raise ValueError("Display name cannot be empty.")
|
||||
cleaned = set_profile_display_name("default", new_name)
|
||||
print(f"✓ Display name set: {cleaned} (canonical id remains 'default')")
|
||||
return _get_default_hermes_home()
|
||||
|
||||
new_canon = _canon_valid(new_name)
|
||||
|
||||
if new_canon == "default":
|
||||
raise ValueError("Cannot rename to 'default' — it is reserved.")
|
||||
|
||||
old_dir = get_profile_dir(old_canon)
|
||||
new_dir = get_profile_dir(new_canon)
|
||||
|
||||
if not old_dir.is_dir():
|
||||
raise FileNotFoundError(f"Profile '{old_canon}' does not exist.")
|
||||
if new_dir.exists():
|
||||
@@ -1817,13 +1674,10 @@ def rename_profile(old_name: str, new_name: str) -> Path:
|
||||
|
||||
# 5. Update active_profile if it pointed to old name
|
||||
_retarget_active_profile(old_canon, new_canon, f"✓ Active profile updated: {new_canon}")
|
||||
|
||||
return new_dir
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profile env resolution (called from _apply_profile_override)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def resolve_profile_env(profile_name: str) -> str:
|
||||
"""Resolve a profile name to a HERMES_HOME path string. Called early in the CLI entry
|
||||
@@ -1840,8 +1694,6 @@ def resolve_profile_env(profile_name: str) -> str:
|
||||
if canon == "default":
|
||||
return str(root)
|
||||
profile_dir = root / "profiles" / canon
|
||||
|
||||
if not profile_dir.is_dir() or named_profile_is_deleted(profile_dir):
|
||||
raise _missing_profile_error(canon)
|
||||
|
||||
return str(profile_dir)
|
||||
|
||||
Reference in New Issue
Block a user