diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ab145c38116..4316bc1f535 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -11676,7 +11676,8 @@ def cmd_profile(args): ) print(f"Skills: {p.skill_count} installed") if p.alias_path: - print(f"Alias: {p.name} → hermes -p {p.name}") + alias_display = p.alias_name or p.name + print(f"Alias: {alias_display} → hermes -p {p.name}") break print() return @@ -11708,7 +11709,7 @@ def cmd_profile(args): name = p.name model = (p.model or "—")[:26] gw = "running" if p.gateway_running else "stopped" - alias = p.name if p.alias_path else "—" + alias = (p.alias_name or p.name) if p.alias_path else "—" if p.is_default: alias = "—" if p.distribution_name: @@ -11958,6 +11959,8 @@ def cmd_profile(args): _check_gateway_running, _count_skills, _read_distribution_meta, + _get_wrapper_dir, + find_alias_for_profile, ) if not profile_exists(name): @@ -11968,7 +11971,7 @@ def cmd_profile(args): gw = _check_gateway_running(profile_dir) skills = _count_skills(profile_dir) dist_name, dist_version, dist_source = _read_distribution_meta(profile_dir) - wrapper = _get_wrapper_dir() / name + alias_name = find_alias_for_profile(name) print(f"\nProfile: {name}") print(f"Path: {profile_dir}") @@ -11987,8 +11990,10 @@ def cmd_profile(args): if dist_source: print(f"Installed from: {dist_source}") print(f" (run `hermes profile info {name}` for full manifest)") - if wrapper.exists(): - print(f"Alias: {wrapper}") + if alias_name: + is_windows = sys.platform == "win32" + wrapper = _get_wrapper_dir() / (f"{alias_name}.bat" if is_windows else alias_name) + print(f"Alias: {alias_name} → hermes -p {name} ({wrapper})") print() elif action == "alias": diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 31dbf8dfb4a..f2fc0112be3 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -422,6 +422,50 @@ def remove_wrapper_script(name: str) -> bool: return False +def find_alias_for_profile(profile_name: str) -> Optional[str]: + """Return the alias name of the wrapper that activates *profile_name*, or None. + + A wrapper created by :func:`create_wrapper_script` is a file named after the + alias whose body invokes ``hermes -p ``. When the alias name equals + the profile name this is trivial, but a custom alias (``hermes profile alias + --name ``) produces a differently-named file — so the + display side cannot assume ``wrapper == profile`` and must reverse-look-up. + + A custom alias (name != profile) is preferred over the profile-named wrapper + so ``profile list``/``show`` surface the command the user actually typed. + Results are sorted for deterministic output when several aliases match. + """ + wrapper_dir = _get_wrapper_dir() + if not wrapper_dir.is_dir(): + return None + canon = normalize_profile_name(profile_name) + is_windows = sys.platform == "win32" + needle = f"hermes -p {canon}" + + custom: Optional[str] = None + profile_named: Optional[str] = None + for entry in sorted(wrapper_dir.iterdir()): + if not entry.is_file(): + continue + # Only our own wrappers are named with the alias and (on Windows) .bat. + if is_windows and entry.suffix != ".bat": + continue + if not is_windows and entry.suffix: + continue + try: + content = entry.read_text() + except (OSError, UnicodeDecodeError): + continue + if needle not in content: + continue + alias = entry.stem if is_windows else entry.name + if alias == canon: + profile_named = alias + elif custom is None: + custom = alias + return custom if custom is not None else profile_named + + # --------------------------------------------------------------------------- # ProfileInfo # --------------------------------------------------------------------------- @@ -438,6 +482,10 @@ class ProfileInfo: has_env: bool = False skill_count: int = 0 alias_path: Optional[Path] = None + # Custom alias name (the wrapper file name) when it differs from ``name``; + # falls back to ``name`` when a profile-named wrapper exists. None if no + # wrapper points at this profile. See ``find_alias_for_profile``. + alias_name: Optional[str] = None # Distribution metadata (None if the profile wasn't installed from a distribution). distribution_name: Optional[str] = None distribution_version: Optional[str] = None @@ -638,7 +686,12 @@ def list_profiles() -> List[ProfileInfo]: if not _PROFILE_ID_RE.match(name): continue model, provider = _read_config_model(entry) - alias_path = wrapper_dir / name + alias_name = find_alias_for_profile(name) + if alias_name: + is_windows = sys.platform == "win32" + alias_path = wrapper_dir / (f"{alias_name}.bat" if is_windows else alias_name) + else: + alias_path = None dist_name, dist_version, dist_source = _read_distribution_meta(entry) meta = read_profile_meta(entry) profiles.append(ProfileInfo( @@ -650,7 +703,8 @@ def list_profiles() -> List[ProfileInfo]: provider=provider, has_env=(entry / ".env").exists(), skill_count=_count_skills(entry), - alias_path=alias_path if alias_path.exists() else None, + alias_path=alias_path if (alias_path and alias_path.exists()) else None, + alias_name=alias_name, distribution_name=dist_name, distribution_version=dist_version, distribution_source=dist_source, diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index dd336030928..a1060e5e95b 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -709,6 +709,63 @@ class TestWrapperScript: assert "#!/bin/sh" not in content +# =================================================================== +# TestFindAliasForProfile — display-side reverse lookup +# =================================================================== + +class TestFindAliasForProfile: + """Tests for find_alias_for_profile() and alias display in list/show.""" + + def test_profile_named_alias(self, profile_env, monkeypatch): + monkeypatch.setattr("sys.platform", "darwin") + from hermes_cli.profiles import create_wrapper_script, find_alias_for_profile + create_wrapper_script("steve") + assert find_alias_for_profile("steve") == "steve" + + def test_custom_alias_name_preferred(self, profile_env, monkeypatch): + # qiaobusi -> steve-jobs: the custom alias name must surface, not the + # profile name, because that's the command the user actually typed. + monkeypatch.setattr("sys.platform", "darwin") + from hermes_cli.profiles import create_wrapper_script, find_alias_for_profile + create_wrapper_script("qiaobusi", target="steve") + assert find_alias_for_profile("steve") == "qiaobusi" + + def test_no_alias_returns_none(self, profile_env, monkeypatch): + monkeypatch.setattr("sys.platform", "darwin") + from hermes_cli.profiles import find_alias_for_profile + assert find_alias_for_profile("steve") is None + + def test_ignores_unrelated_files(self, profile_env, monkeypatch): + # ~/.local/bin commonly holds unrelated binaries; they must not match. + monkeypatch.setattr("sys.platform", "darwin") + from hermes_cli.profiles import _get_wrapper_dir, find_alias_for_profile + wrapper_dir = _get_wrapper_dir() + wrapper_dir.mkdir(parents=True, exist_ok=True) + (wrapper_dir / "pip").write_text("#!/bin/sh\nexec python -m pip \"$@\"\n") + assert find_alias_for_profile("steve") is None + + def test_custom_alias_on_windows(self, profile_env, monkeypatch): + monkeypatch.setattr("sys.platform", "win32") + from hermes_cli.profiles import create_wrapper_script, find_alias_for_profile + create_wrapper_script("qiaobusi", target="steve") + # The .bat extension must be stripped from the returned alias name. + assert find_alias_for_profile("steve") == "qiaobusi" + + def test_list_profiles_surfaces_custom_alias(self, profile_env, monkeypatch): + monkeypatch.setattr("sys.platform", "darwin") + from hermes_cli.profiles import ( + create_profile, + create_wrapper_script, + list_profiles, + ) + create_profile("steve", no_alias=True) + create_wrapper_script("qiaobusi", target="steve") + info = next(p for p in list_profiles() if p.name == "steve") + assert info.alias_name == "qiaobusi" + assert info.alias_path is not None + assert info.alias_path.name == "qiaobusi" + + # =================================================================== # TestRenameProfile # ===================================================================