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:
@@ -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()
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -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
@@ -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
@@ -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.")
|
||||
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user