Repository Public
1/**2 * Run-time branding. ONE static build serves both products ("Claude Remote" / "Codex Remote"):3 * the instance says which agent it runs in `GET /auth/status` (`branding`, public, before login).4 *5 * Resolution order:6 * 1. before the server answered: the value cached in localStorage (`cr-branding`) by the last7 * visit — also read by /theme-init.js, which sets <html data-agent> and the title before paint;8 * 2. once /auth/status answered: the server value, which always wins and rewrites the cache;9 * 3. absent / invalid anywhere: the Claude default.10 * Pure functions only (unit-tested); the DOM side lives in stores/branding.ts.11 */12import { AGENT_PROFILES, Branding, DEFAULT_AGENT, brandingOf, type AgentCapabilities, type AgentId, type AgentProfile } from '@cr/shared';1314export const BRANDING_KEY = 'cr-branding';15export const DEFAULT_BRANDING: Branding = brandingOf(DEFAULT_AGENT);16const MAX_NAME = 40;1718type ReadStore = Pick<Storage, 'getItem'>;19type WriteStore = Pick<Storage, 'setItem'>;2021/** A well-formed branding object, or null. Names are bounded (they end up in the document title). */22export function parseBranding(raw: unknown): Branding | null {23 const r = Branding.safeParse(raw);24 if (!r.success) return null;25 const productName = r.data.productName.trim();26 const agentName = r.data.agentName.trim();27 if (!productName || !agentName || productName.length > MAX_NAME || agentName.length > MAX_NAME) return null;28 return { agent: r.data.agent, productName, agentName };29}3031/** Cached branding of the previous visit; null when missing, corrupted or storage is unavailable. */32export function readCachedBranding(storage: ReadStore | null | undefined): Branding | null {33 try {34 const text = storage?.getItem(BRANDING_KEY);35 return text ? parseBranding(JSON.parse(text)) : null;36 } catch {37 return null;38 }39}4041export function writeCachedBranding(storage: WriteStore | null | undefined, branding: Branding): void {42 try {43 storage?.setItem(BRANDING_KEY, JSON.stringify(branding));44 } catch {45 /* storage unavailable: the next load starts from the default */46 }47}4849/** Branding to paint with before the server answered. */50export function initialBranding(storage: ReadStore | null | undefined): Branding {51 return readCachedBranding(storage) ?? DEFAULT_BRANDING;52}5354/** Branding announced by the server (`AuthStatus.branding`); absent or invalid ⇒ the Claude default. */55export function serverBranding(raw: unknown): Branding {56 return parseBranding(raw) ?? DEFAULT_BRANDING;57}5859/** The server answered: its value wins over the cache, and the cache is rewritten with it. */60export function confirmBranding(storage: WriteStore | null | undefined, raw: unknown): Branding {61 const branding = serverBranding(raw);62 writeCachedBranding(storage, branding);63 return branding;64}6566/** Everything the UI needs to word itself for the instance's agent. */67export interface Brand {68 agent: AgentId;69 /** Name of this web application: "Claude Remote" / "Codex Remote". */70 product: string;71 /** Name of the CLI: "Claude Code" / "Codex". */72 agentName: string;73 /** Short name used in sentences: "Claude" / "Codex". */74 short: string;75 /** Binary typed in a shell: "claude" / "codex". */76 binary: string;77 profile: AgentProfile;78 caps: AgentCapabilities;79}80export function brandOf(branding: Branding): Brand {81 const profile = AGENT_PROFILES[branding.agent];82 return { agent: branding.agent, product: branding.productName, agentName: branding.agentName, short: profile.shortName, binary: profile.defaultBinary, profile, caps: profile.capabilities };83}84export const sameBranding = (a: Branding, b: Branding): boolean => a.agent === b.agent && a.productName === b.productName && a.agentName === b.agentName;8586/** What to tell the user when the CLI is not authenticated (system page). */87export function loginHint(brand: Brand): { before: string; code: string; after: string } {88 return brand.agent === 'claude'89 ? { before: `Open a ${brand.short} session and run `, code: '/login', after: '.' }90 : { before: 'Run ', code: brand.profile.loginCommand, after: ' in a shell session.' };91}9293/** "claude --continue" / "codex resume --last": what "Continue last" runs, shown as a hint. */94export const continueCommand = (brand: Brand): string => (brand.agent === 'claude' ? 'claude --continue' : 'codex resume --last');9596/** Where the agent keeps user-level skills (display only). */97export const configDirLabel = (brand: Brand): string => `~/.${brand.binary}`;9899const KIND_PLURAL = { skill: 'skills', command: 'slash commands', agent: 'sub-agents' } as const;100/** "Skills, slash commands and sub-agents" — only the kinds this agent has. */101export function skillKindsPhrase(kinds: readonly (keyof typeof KIND_PLURAL)[]): string {102 const words = kinds.map((k) => KIND_PLURAL[k]);103 const text = words.length > 1 ? `${words.slice(0, -1).join(', ')} and ${words[words.length - 1]}` : (words[0] ?? 'items');104 return text.charAt(0).toUpperCase() + text.slice(1);105}106export function skillsEmptyHint(brand: Brand): string {107 const rest = brand.caps.skillKinds.filter((k) => k !== 'skill').map((k) => (k === 'command' ? 'commands' : 'agents'));108 return `Skills in ${configDirLabel(brand)}/skills${rest.length ? `${rest.length > 1 ? ', ' : ' and '}${rest.join(' and ')}` : ''} appear here.`;109}110