feat(profiles): rename any agent — the default profile gets a display name (#45624)

`hermes profile rename default <name>` (and the Desktop/dashboard rename
flows) now set a presentation-only `display_name` in profile.yaml instead
of erroring. The canonical id stays "default"; resolution, comparison,
and spawn paths are untouched. Named profiles keep real renames and their
display_name survives the move.

Surfaces: profile list/show/status, /profile (text only — data.profile
stays canonical), dashboard ProfilesPage, TUI-gateway profiles.list, and
Desktop (rail, switcher, Manage page, and the Bot Mode roster via a
displayName fallback so a renamed default shows its name, not "default").

Slimmer redo of the direction in PR #87760 by @yxssxn — thanks; see PR
body for what changed vs that approach.
This commit is contained in:
Teknium
2026-08-18 01:25:38 -07:00
parent 6abe6edee6
commit a1682376ca
22 changed files with 365 additions and 58 deletions
@@ -49,6 +49,7 @@ vi.mock('@/store/profile', () => ({
$profileScope: atom('default'),
ALL_PROFILES: '*',
normalizeProfileKey: (name: string) => name,
profileLabel: (profile: { display_name?: string; name: string }) => (profile.display_name ?? '').trim() || profile.name,
refreshActiveProfile: vi.fn().mockResolvedValue(undefined),
selectProfile: vi.fn(),
setProfileColor: vi.fn(),
@@ -53,6 +53,7 @@ import {
$profileScope,
ALL_PROFILES,
normalizeProfileKey,
profileLabel,
refreshActiveProfile,
selectProfile,
setProfileColor,
@@ -240,7 +241,7 @@ export function ProfileRail() {
<ProfilePill
active={isAll || onDefault}
glyph={isAll ? 'layers' : 'home'}
label={onDefault ? p.showAllProfiles : p.switchToProfile(defaultProfile.name)}
label={onDefault ? p.showAllProfiles : p.switchToProfile(profileLabel(defaultProfile))}
onSelect={() => (onDefault ? setShowAllProfiles(true) : selectProfile(defaultProfile.name))}
/>
) : (
@@ -252,7 +253,7 @@ export function ProfileRail() {
<ProfilePill
active
glyph="home"
label={defaultProfile.name}
label={profileLabel(defaultProfile)}
onSelect={() => selectProfile(defaultProfile.name)}
/>
)}
@@ -294,7 +295,7 @@ export function ProfileRail() {
active={!isAll && normalizeProfileKey(profile.name) === activeKey}
color={resolveProfileColor(profile.name, colors)}
key={profile.name}
label={profile.name}
label={profileLabel(profile)}
onDelete={() => setPendingDelete(profile)}
onEditSoul={() => setPendingSoul(profile.name)}
onRecolor={color => setProfileColor(profile.name, color)}
@@ -345,6 +346,7 @@ export function ProfileRail() {
<RenameProfileDialog
currentName={pendingRename?.name ?? ''}
isDefault={pendingRename?.is_default ?? false}
onClose={() => setPendingRename(null)}
onRenamed={refreshActiveProfile}
open={pendingRename !== null}
@@ -502,6 +504,7 @@ function ProfileDropdown({
<ProfileDropdownItem
color={resolveProfileColor(profile.name, colors)}
key={profile.name}
label={profileLabel(profile)}
name={profile.name}
/>
))}
@@ -512,14 +515,14 @@ function ProfileDropdown({
// One dropdown row per profile — its own component so each row can own a
// hover-intent prewarm timer (see useProfilePrewarm).
function ProfileDropdownItem({ color, name }: { color: null | string; name: string }) {
function ProfileDropdownItem({ color, label, name }: { color: null | string; label: string; name: string }) {
const { cancelPrewarm, startPrewarm } = useProfilePrewarm(name)
return (
<SelectItem onPointerEnter={startPrewarm} onPointerLeave={cancelPrewarm} value={name}>
<span className="flex min-w-0 items-center gap-1.5">
<ProfileGlyph aria-hidden="true" color={color} isDefault={false} name={name} />
<span className="truncate">{name}</span>
<span className="truncate">{label}</span>
</span>
</SelectItem>
)
@@ -57,6 +57,7 @@ vi.mock('@/store/profile', () => ({
$activeGatewayProfile: activeGateway,
$profileColors,
normalizeProfileKey: (name: null | string | undefined) => (name ?? '').trim() || 'default',
profileLabel: (profile: { display_name?: string; name: string }) => (profile.display_name ?? '').trim() || profile.name,
refreshProfiles: vi.fn(async () => [] as ProfileInfo[]),
selectProfile: vi.fn(),
setActiveProfile: vi.fn()
+8 -5
View File
@@ -13,7 +13,7 @@ import { AlertTriangle, Save } from '@/lib/icons'
import { resolveProfileColor } from '@/lib/profile-color'
import { normalize } from '@/lib/text'
import { notify, notifyError } from '@/store/notifications'
import { $profileColors, refreshProfiles } from '@/store/profile'
import { $profileColors, profileLabel, refreshProfiles } from '@/store/profile'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import {
@@ -133,7 +133,9 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
key={profile.name}
menuItems={
profile.is_default
? []
? // Renaming the default profile sets a presentation-only
// display name (the canonical id stays "default").
[{ icon: 'edit', label: p.renameMenu, onSelect: () => setPendingRename(profile) }]
: [
{ icon: 'edit', label: p.renameMenu, onSelect: () => setPendingRename(profile) },
{
@@ -162,6 +164,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
<RenameProfileDialog
currentName={pendingRename?.name ?? ''}
isDefault={pendingRename?.is_default ?? false}
onClose={() => setPendingRename(null)}
onRenamed={selectAndRefresh}
open={pendingRename !== null}
@@ -212,10 +215,10 @@ function ProfileRow({
/>
}
menuItems={menuItems}
menuLabel={profile.name}
menuLabel={profileLabel(profile)}
onSelect={onSelect}
rowKey={profile.name}
title={profile.name}
title={profileLabel(profile)}
/>
)
}
@@ -229,7 +232,7 @@ function ProfileDetail({ profile }: { profile: ProfileInfo }) {
<header className="space-y-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-[0.95rem] font-semibold tracking-tight text-foreground">{profile.name}</h3>
<h3 className="text-[0.95rem] font-semibold tracking-tight text-foreground">{profileLabel(profile)}</h3>
{profile.is_default && <PanelPill tone="good">{p.defaultBadge}</PanelPill>}
{profile.has_env && <PanelPill tone="muted">.env</PanelPill>}
</div>
@@ -20,15 +20,22 @@ import { retireLocalProfileGateways } from '@/store/gateway'
import { isValidProfileName } from './create-profile-dialog'
// Display names are free text (Unicode fine) — no slug sanitizing.
const identity = (raw: string) => raw
// Self-contained rename (owns the renameProfile call) so every caller just
// reacts via onRenamed. Unchanged name is a no-op close.
export function RenameProfileDialog({
currentName,
isDefault = false,
onClose,
onRenamed,
open
}: {
currentName: string
/** Default profile: sets a presentation-only display name (Unicode ok);
* the canonical id stays "default" and no backend teardown is needed. */
isDefault?: boolean
onClose: () => void
onRenamed?: (name: string) => Promise<void> | void
open: boolean
@@ -44,14 +51,15 @@ export function RenameProfileDialog({
return
}
setName(currentName)
// Display-name mode starts blank — "default" is the id, not a name.
setName(isDefault ? '' : currentName)
setError(null)
setStatus('idle')
}, [currentName, open])
}, [currentName, isDefault, open])
const trimmed = name.trim()
const unchanged = trimmed === currentName
const invalid = trimmed !== '' && !unchanged && !isValidProfileName(trimmed)
const unchanged = !isDefault && trimmed === currentName
const invalid = trimmed !== '' && !unchanged && !isDefault && !isValidProfileName(trimmed)
const busy = status === 'saving' || status === 'done'
async function handleSubmit(event: React.FormEvent) {
@@ -77,7 +85,10 @@ export function RenameProfileDialog({
// backend teardown as a transient drop and redial, resurrecting the
// old-name backend whose ensure_hermes_home() recreates the directory
// the rename just moved (same class as the delete path, #88638).
retireLocalProfileGateways(currentName)
if (!isDefault) {
retireLocalProfileGateways(currentName)
}
await renameProfile(currentName, trimmed)
await onRenamed?.(trimmed)
setStatus('done')
@@ -92,25 +103,31 @@ export function RenameProfileDialog({
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{p.renameTitle}</DialogTitle>
<DialogTitle>{isDefault ? p.displayNameTitle : p.renameTitle}</DialogTitle>
<DialogDescription>
{p.renameDescPrefix}
<span className="font-mono">~/.local/bin</span>
{p.renameDescSuffix}
{isDefault ? (
p.displayNameDesc
) : (
<>
{p.renameDescPrefix}
<span className="font-mono">~/.local/bin</span>
{p.renameDescSuffix}
</>
)}
</DialogDescription>
</DialogHeader>
<form className="grid gap-4" onSubmit={handleSubmit}>
<Field htmlFor="rename-profile-name" label={p.newNameLabel}>
<Field htmlFor="rename-profile-name" label={isDefault ? p.displayNameLabel : p.newNameLabel}>
<SanitizedInput
aria-invalid={invalid}
autoFocus
id="rename-profile-name"
onValueChange={setName}
sanitize={slug}
sanitize={isDefault ? identity : slug}
value={name}
/>
<FieldHint error={invalid}>{p.nameHint}</FieldHint>
{!isDefault && <FieldHint error={invalid}>{p.nameHint}</FieldHint>}
</Field>
{error && (
+3
View File
@@ -1774,6 +1774,9 @@ export const en: Translations = {
renameTitle: 'Rename profile',
renameDescPrefix: 'Renaming updates the profile directory and any wrapper scripts in ',
renameDescSuffix: '.',
displayNameTitle: 'Name this agent',
displayNameDesc: 'Sets a display name shown across the app. The internal profile ID stays "default".',
displayNameLabel: 'Display name',
newNameLabel: 'New name',
renaming: 'Renaming...',
created: 'Profile created',
+3
View File
@@ -1499,6 +1499,9 @@ export interface Translations {
creating: string
createAction: string
renameTitle: string
displayNameTitle: string
displayNameDesc: string
displayNameLabel: string
renameDescPrefix: string
renameDescSuffix: string
newNameLabel: string
+3
View File
@@ -1959,6 +1959,9 @@ export const zh: Translations = {
creating: '创建中…',
createAction: '创建配置档案',
renameTitle: '重命名配置档案',
displayNameTitle: '为智能体命名',
displayNameDesc: '设置一个在整个应用中显示的名称。内部配置档案 ID 仍为 "default"。',
displayNameLabel: '显示名称',
renameDescPrefix: '重命名会更新配置档案目录以及 ',
renameDescSuffix: ' 中的所有包装脚本。',
newNameLabel: '新名称',
@@ -3103,6 +3103,13 @@ function displayName(bot, meta) {
return meta.title.trim()
}
// Core-profile display name (profile.yaml, set via `hermes profile rename
// default <name>` or the dashboard) — the CLI-level equivalent of a Bot
// Mode title. Rides the profiles.list row; presentation-only.
if (typeof bot?.display_name === 'string' && bot.display_name.trim()) {
return bot.display_name.trim()
}
// The primary profile is literally named "default" — as a bot identity
// that reads like nobody bothered. Present it as Hermes (the agent it is)
// unless the user gives it a real title.
+7
View File
@@ -25,6 +25,13 @@ export function normalizeProfileKey(name: string | null | undefined): string {
return value || 'default'
}
// Presentation-only label: the display_name from profile.yaml when set (e.g. a
// renamed default profile), else the canonical name. Never used for
// comparison or routing — canonical `name` remains the identity everywhere.
export function profileLabel(profile: Pick<ProfileInfo, 'display_name' | 'name'>): string {
return (profile.display_name ?? '').trim() || profile.name
}
// The profile the running local backend is actually scoped to (mirrors
// /api/profiles/active `current`). "default" is the root ~/.hermes. This is the
// display source of truth for the statusbar pill; the desktop's *stored*
+2
View File
@@ -920,6 +920,8 @@ export interface ProfileCreatePayload {
}
export interface ProfileInfo {
/** Presentation-only label override (profile.yaml display_name). */
display_name?: string
has_env: boolean
is_default: boolean
model: null | string
+40 -20
View File
@@ -10068,31 +10068,47 @@ def cmd_profile(args):
if action is None:
# Bare `hermes profile` — show current profile status
from hermes_cli.profiles import format_profile_label
profile_name = get_active_profile_name()
dhh = display_hermes_home()
print(f"\nActive profile: {profile_name}")
print(f"Path: {dhh}")
profiles = list_profiles()
for p in profiles:
if p.name == profile_name or (profile_name == "default" and p.is_default):
if p.model:
print(
f"Model: {p.model}"
+ (f" ({p.provider})" if p.provider else "")
)
current = next(
(
p
for p in profiles
if p.name == profile_name
or (profile_name == "default" and p.is_default)
),
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:
print(
f"Gateway: {'running' if p.gateway_running else 'stopped'}"
f"Model: {p.model}"
+ (f" ({p.provider})" if p.provider else "")
)
print(f"Skills: {p.skill_count} installed")
if p.alias_path:
alias_display = p.alias_name or p.name
print(f"Alias: {alias_display} → hermes -p {p.name}")
break
print(
f"Gateway: {'running' if p.gateway_running else 'stopped'}"
)
print(f"Skills: {p.skill_count} installed")
if p.alias_path:
alias_display = p.alias_name or p.name
print(f"Alias: {alias_display} → hermes -p {p.name}")
print()
return
if action == "list":
from hermes_cli.profiles import format_profile_label
profiles = list_profiles()
active = get_active_profile_name()
@@ -10116,7 +10132,7 @@ def cmd_profile(args):
if (p.name == active or (active == "default" and p.is_default))
else " "
)
name = p.name
name = format_profile_label(p.name, p.display_name)
model = (p.model or "—")[:26]
gw = "running" if p.gateway_running else "stopped"
alias = (p.alias_name or p.name) if p.alias_path else "—"
@@ -10375,6 +10391,8 @@ def cmd_profile(args):
_read_distribution_meta,
_get_wrapper_dir,
find_alias_for_profile,
format_profile_label,
read_profile_meta,
)
if not profile_exists(name):
@@ -10386,8 +10404,9 @@ def cmd_profile(args):
skills = _count_skills(profile_dir)
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: {name}")
print(f"\nProfile: {format_profile_label(name, display)}")
print(f"Path: {profile_dir}")
if model:
print(f"Model: {model}" + (f" ({provider})" if provider else ""))
@@ -10448,12 +10467,13 @@ def cmd_profile(args):
print(f"⚠ {_get_wrapper_dir()} is not in your PATH.")
elif action == "rename":
from hermes_cli.profiles import rename_profile
from hermes_cli.profiles import normalize_profile_name, rename_profile
try:
new_dir = rename_profile(args.old_name, args.new_name)
print(f"\nProfile renamed: {args.old_name} → {args.new_name}")
print(f"Path: {new_dir}\n")
if normalize_profile_name(args.old_name) != "default":
print(f"\nProfile renamed: {args.old_name} → {args.new_name}")
print(f"Path: {new_dir}\n")
except (ValueError, FileExistsError, FileNotFoundError) as e:
print(f"Error: {e}")
sys.exit(1)
+66 -11
View File
@@ -657,6 +657,9 @@ class ProfileInfo:
# surfaces a "review" badge in this case so the user can edit or
# accept.
description_auto: bool = False
# Optional user-facing display name from profile.yaml. Presentation
# only — resolution/comparison/spawn paths always use ``name``.
display_name: str = ""
def _read_distribution_meta(profile_dir: Path) -> tuple:
@@ -823,25 +826,27 @@ def _profile_yaml_path(profile_dir: Path) -> Path:
def read_profile_meta(profile_dir: Path) -> dict:
"""Read ``<profile_dir>/profile.yaml`` and return a dict.
Returns ``{"description": "", "description_auto": False}`` when the
file is missing or unreadable. Never raises — a corrupt
profile.yaml on an unrelated profile must not break
``hermes profile list``.
Returns ``{"description": "", "description_auto": False,
"display_name": ""}`` when the file is missing or unreadable. Never
raises — a corrupt profile.yaml on an unrelated profile must not
break ``hermes profile list``.
"""
empty = {"description": "", "description_auto": False, "display_name": ""}
path = _profile_yaml_path(profile_dir)
if not path.is_file():
return {"description": "", "description_auto": False}
return empty
try:
import yaml
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
except Exception:
return {"description": "", "description_auto": False}
return empty
if not isinstance(data, dict):
return {"description": "", "description_auto": False}
return empty
return {
"description": str(data.get("description") or "").strip(),
"description_auto": bool(data.get("description_auto", False)),
"display_name": str(data.get("display_name") or "").strip(),
}
@@ -850,6 +855,7 @@ def write_profile_meta(
*,
description: Optional[str] = None,
description_auto: Optional[bool] = None,
display_name: Optional[str] = None,
) -> None:
"""Update ``<profile_dir>/profile.yaml`` in place.
@@ -874,6 +880,12 @@ def write_profile_meta(
existing["description"] = description.strip()
if description_auto is not None:
existing["description_auto"] = bool(description_auto)
if display_name is not None:
# Empty string clears the key (falls back to the canonical id).
if display_name.strip():
existing["display_name"] = display_name.strip()
else:
existing.pop("display_name", None)
# Atomic write: bare open("w") truncates before the dump, and the read
# path above swallows parse errors as {}, so a crashed write would
# silently drop unspecified fields on the next call (#51356, #16743).
@@ -882,6 +894,36 @@ def write_profile_meta(
atomic_yaml_write(path, existing, sort_keys=False)
def format_profile_label(name: str, display_name: Optional[str]) -> str:
"""Render a profile for display: ``display_name (canonical_id)``.
Falls back to the bare canonical id when no display name is set (or it
equals the id) — byte-for-byte the pre-feature rendering. Display names
are presentation-only free text (Unicode fine); they are never a
directory name, wrapper filename, or argv token.
"""
dn = (display_name or "").strip()
return f"{dn} ({name})" if dn and dn != name else name
def set_profile_display_name(profile_name: str, display_name: str) -> str:
"""Set (or clear, with ``""``) a profile's user-facing display name.
Presentation-only: the canonical profile id is untouched. Returns the
stored value. Raises ``ValueError`` for names over 64 chars.
"""
canon = normalize_profile_name(profile_name)
validate_profile_name(canon)
profile_dir = get_profile_dir(canon)
if not profile_dir.is_dir():
raise FileNotFoundError(f"Profile '{canon}' does not exist.")
cleaned = (display_name or "").strip()
if len(cleaned) > 64:
raise ValueError(f"Display name too long ({len(cleaned)} chars, max 64).")
write_profile_meta(profile_dir, display_name=cleaned)
return cleaned
# ---------------------------------------------------------------------------
# CRUD operations
# ---------------------------------------------------------------------------
@@ -911,6 +953,7 @@ def list_profiles() -> List[ProfileInfo]:
distribution_source=dist_source,
description=meta.get("description", ""),
description_auto=meta.get("description_auto", False),
display_name=meta.get("display_name", ""),
))
# Named profiles
@@ -953,6 +996,7 @@ def list_profiles() -> List[ProfileInfo]:
distribution_source=dist_source,
description=meta.get("description", ""),
description_auto=meta.get("description_auto", False),
display_name=meta.get("display_name", ""),
))
return profiles
@@ -2344,15 +2388,26 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) ->
def rename_profile(old_name: str, new_name: str) -> Path:
"""Rename a profile: directory, wrapper script, service, active_profile.
Returns the new profile directory.
The default profile's home IS the installation root, so "renaming" it
sets a presentation-only ``display_name`` in profile.yaml instead —
the canonical id stays ``default`` and every resolution path is
untouched.
Returns the (new) profile directory.
"""
old_canon = normalize_profile_name(old_name)
new_canon = normalize_profile_name(new_name)
validate_profile_name(old_canon)
validate_profile_name(new_canon)
if old_canon == "default":
raise ValueError("Cannot rename the default profile.")
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 = normalize_profile_name(new_name)
validate_profile_name(new_canon)
if new_canon == "default":
raise ValueError("Cannot rename to 'default' — it is reserved.")
+16 -1
View File
@@ -100,8 +100,23 @@ def _exec_profile(ctx: CommandContext) -> CommandReply:
home_display = display_hermes_home()
# Presentation-only display name (profile.yaml). `data.profile` stays
# the canonical id — consumers route on it; only the text gets the label.
label = profile_name
try:
from hermes_cli.profiles import (
format_profile_label,
get_profile_dir,
read_profile_meta,
)
display = read_profile_meta(get_profile_dir(profile_name)).get("display_name", "")
label = format_profile_label(profile_name, display)
except Exception:
pass
return CommandReply(
f"Profile: {profile_name}\nHome: {home_display}",
f"Profile: {label}\nHome: {home_display}",
data={"profile": profile_name, "home": home_display},
)
+8 -2
View File
@@ -120,9 +120,15 @@ def build_profile_parser(subparsers, *, cmd_profile: Callable) -> None:
help="Custom alias name (default: profile name)",
)
profile_rename = profile_subparsers.add_parser("rename", help="Rename a profile")
profile_rename = profile_subparsers.add_parser(
"rename",
help="Rename a profile ('default': sets a display name; id unchanged)",
)
profile_rename.add_argument("old_name", help="Current profile name")
profile_rename.add_argument("new_name", help="New profile name")
profile_rename.add_argument(
"new_name",
help="New profile name (for 'default': a display name — the canonical id stays 'default')",
)
profile_export = profile_subparsers.add_parser(
"export", help="Export a profile to archive"
+19 -1
View File
@@ -1010,7 +1010,25 @@ async def rename_profile_endpoint(name: str, body: ProfileRename):
except Exception as e:
_log.exception("PATCH /api/profiles/%s failed", name)
raise HTTPException(status_code=500, detail=str(e))
return {"ok": True, "name": body.new_name, "path": str(path)}
# For the default profile the rename lands as a presentation-only
# display_name; the canonical id ("default") is unchanged. Always
# return the canonical id so callers keying on `name` stay correct.
try:
is_default = profiles_mod.normalize_profile_name(name) == "default"
except ValueError:
is_default = False
if is_default:
return {
"ok": True,
"name": "default",
"display_name": body.new_name.strip(),
"path": str(path),
}
return {
"ok": True,
"name": profiles_mod.normalize_profile_name(body.new_name),
"path": str(path),
}
@router.delete("/api/profiles/{name}")
+1
View File
@@ -14476,6 +14476,7 @@ def _profile_to_dict(info) -> Dict[str, Any]:
"gateway_running": bool(_profile_attr(info, "gateway_running", False)),
"description": _profile_attr(info, "description", "") or "",
"description_auto": bool(_profile_attr(info, "description_auto", False)),
"display_name": _profile_attr(info, "display_name", "") or "",
"distribution_name": _profile_attr(info, "distribution_name"),
"distribution_version": _profile_attr(info, "distribution_version"),
"distribution_source": _profile_attr(info, "distribution_source"),
@@ -0,0 +1,122 @@
"""Profile display_name (#45624): presentation-only label in profile.yaml.
The canonical profile id ("default" for ~/.hermes) is never touched —
resolution, comparison, and spawn paths must be provably unaffected.
"""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
from hermes_cli.profiles import (
create_profile,
format_profile_label,
get_profile_dir,
list_profiles,
profile_exists,
read_profile_meta,
rename_profile,
resolve_profile_env,
set_profile_display_name,
write_profile_meta,
)
@pytest.fixture()
def profile_env(tmp_path, monkeypatch):
"""Isolated environment: Path.home() and HERMES_HOME under tmp_path."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
default_home = tmp_path / ".hermes"
default_home.mkdir(exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(default_home))
return default_home
class TestMetaAndValidation:
def test_round_trip_preserves_other_fields(self, profile_env):
write_profile_meta(profile_env, description="ops agent")
write_profile_meta(profile_env, display_name="小助手")
meta = read_profile_meta(profile_env)
assert meta["display_name"] == "小助手"
assert meta["description"] == "ops agent"
def test_missing_file_defaults_empty(self, profile_env):
assert read_profile_meta(profile_env)["display_name"] == ""
def test_empty_clears_key_from_file(self, profile_env):
write_profile_meta(profile_env, display_name="Harumesu")
write_profile_meta(profile_env, display_name="")
data = yaml.safe_load((profile_env / "profile.yaml").read_text())
assert "display_name" not in data
def test_setter_strips_and_caps_length(self, profile_env):
assert set_profile_display_name("default", " Harumesu ") == "Harumesu"
with pytest.raises(ValueError):
set_profile_display_name("default", "x" * 65)
def test_setter_missing_profile_raises(self, profile_env):
with pytest.raises(FileNotFoundError):
set_profile_display_name("ghost", "Boo")
class TestFormatProfileLabel:
def test_shapes(self):
assert format_profile_label("default", "Harumesu") == "Harumesu (default)"
# Unset/None/id-equal → byte-for-byte the pre-feature rendering.
assert format_profile_label("default", "") == "default"
assert format_profile_label("default", None) == "default"
assert format_profile_label("worker", "worker") == "worker"
class TestRenameDefault:
def test_sets_display_name_only(self, profile_env, capsys):
assert rename_profile("default", "Harumesu") == profile_env
assert profile_env.is_dir() # directory untouched
assert read_profile_meta(profile_env)["display_name"] == "Harumesu"
assert "canonical id remains 'default'" in capsys.readouterr().out
def test_reflected_in_list_profiles(self, profile_env):
rename_profile("default", "Harumesu")
info = next(p for p in list_profiles() if p.is_default)
assert info.name == "default"
assert info.is_default is True
assert info.display_name == "Harumesu"
def test_rejects_empty_and_preserves_existing(self, profile_env):
rename_profile("default", "Harumesu")
with pytest.raises(ValueError):
rename_profile("default", " ")
# Failed rename must not clear the existing display name.
assert read_profile_meta(profile_env)["display_name"] == "Harumesu"
def test_rename_to_default_still_reserved(self, profile_env):
create_profile("worker", no_alias=True)
with pytest.raises(ValueError, match="reserved"):
rename_profile("worker", "default")
def test_named_rename_still_real_and_keeps_display_name(
self, profile_env, monkeypatch
):
monkeypatch.setattr(
"hermes_cli.profiles.check_alias_collision", lambda name: "skip"
)
create_profile("oldname", no_alias=True)
write_profile_meta(get_profile_dir("oldname"), display_name="Old Friend")
new_dir = rename_profile("oldname", "newname")
assert not (profile_env / "profiles" / "oldname").is_dir()
assert new_dir == profile_env / "profiles" / "newname"
assert read_profile_meta(new_dir)["display_name"] == "Old Friend"
class TestResolutionUnaffected:
def test_display_name_is_not_a_resolvable_id(self, profile_env):
rename_profile("default", "Harumesu")
assert get_profile_dir("default") == profile_env
assert profile_exists("default") is True
assert profile_exists("harumesu") is False
assert resolve_profile_env("default") == str(profile_env)
with pytest.raises(FileNotFoundError):
resolve_profile_env("harumesu")
+1
View File
@@ -200,6 +200,7 @@ def _(rid, params: dict) -> dict:
"model": p.model,
"provider": p.provider,
"description": getattr(p, "description", "") or "",
"display_name": getattr(p, "display_name", "") or "",
"skill_count": getattr(p, "skill_count", 0) or 0,
}
if include_sessions:
+1
View File
@@ -2180,6 +2180,7 @@ export interface ProfileInfo {
gateway_running: boolean;
description: string;
description_auto: boolean;
display_name?: string;
distribution_name: string | null;
distribution_version: string | null;
distribution_source: string | null;
+1 -1
View File
@@ -1092,7 +1092,7 @@ export default function ProfilesPage() {
<div className="flex items-start gap-2">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<span className="font-medium text-sm truncate">
{p.name}
{p.display_name?.trim() ? `${p.display_name.trim()} (${p.name})` : p.name}
</span>
{active && (
+18
View File
@@ -247,6 +247,24 @@ hermes profile import coder.tar.gz # install an archive as a new profile
In chat, the same two live as `/export` and `/import` — and in the desktop app as **⌘K → Export/Import profile…**. See [Sharing a profile](#sharing-a-profile).
### Naming the default profile
The default profile's internal ID is always `default` — it can't be truly
renamed because `~/.hermes` is the installation root. Renaming it instead
sets a **display name**, which UI surfaces show in place of the bare ID:
```bash
hermes profile rename default Harumesu # Unicode fine: 小助手
```
The display name appears in `hermes profile list`/`show`, the `/profile`
chat command, the dashboard, and the desktop app (including the Bot Mode
roster). It is presentation-only: `-p default`, service names, cron jobs,
and every other reference keep using the canonical `default` ID. It is
stored as `display_name` in `~/.hermes/profile.yaml`; remove that line to
revert. Named profiles can carry a `display_name` too (it survives a real
rename), but `rename` for them still renames the profile itself.
## Deleting a profile
```bash