SPB Git

Repository Public

branding.ts 5.2 KB 110 lines typescript

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
Files main
Menu

Theme