diff --git a/README.md b/README.md
index 0989a7ae..f1693ba8 100644
--- a/README.md
+++ b/README.md
@@ -4,8 +4,8 @@

-### Manage multiple AI accounts from one dashboard.
-Run Claude, Gemini, GLM, and more - concurrently, without conflicts.
+### The universal AI profile manager for Claude Code.
+Run Claude, Gemini, GLM, and any Anthropic-compatible API - concurrently, without conflicts.
[](LICENSE)
[](https://www.npmjs.com/package/@kaitranntt/ccs)
@@ -23,7 +23,7 @@ Run Claude, Gemini, GLM, and more - concurrently, without conflicts.
|------------|--------------|------------|
| **Multiple Claude Accounts** | Run work + personal Claude subs simultaneously | Dashboard |
| **OAuth Providers** | Gemini, Codex, Antigravity - zero API keys needed | Dashboard |
-| **API Profiles** | GLM, Kimi with your own API keys | Dashboard |
+| **API Profiles** | GLM, Kimi, or any Anthropic-compatible API | Dashboard |
@@ -76,7 +76,7 @@ The dashboard provides visual management for all account types:
-## Supported Providers
+## Built-in Providers
| Provider | Auth Type | Command | Best For |
|----------|-----------|---------|----------|
@@ -89,6 +89,9 @@ The dashboard provides visual management for all account types:
> **OAuth providers** authenticate via browser on first run. Tokens are cached in `~/.ccs/cliproxy/auth/`.
+> [!TIP]
+> **Need more?** CCS supports **any Anthropic-compatible API**. Create custom profiles for self-hosted LLMs, enterprise gateways, or alternative providers. See [API Profiles documentation](https://docs.ccs.kaitran.ca/providers/api-profiles).
+
## Usage
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 6db0f20a..032a7cf8 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -1,15 +1,11 @@
-import { lazy, Suspense } from 'react';
-import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom';
+import { lazy } from 'react';
+import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query';
-import { SidebarProvider } from '@/components/ui/sidebar';
-import { AppSidebar } from '@/components/app-sidebar';
-import { ThemeToggle } from '@/components/theme-toggle';
-import { GitHubLink } from '@/components/github-link';
-import { ConnectionIndicator } from '@/components/connection-indicator';
-import { LocalhostDisclaimer } from '@/components/localhost-disclaimer';
import { Toaster } from 'sonner';
import { queryClient } from '@/lib/query-client';
-import { Skeleton } from '@/components/ui/skeleton';
+import { ThemeProvider } from '@/components/theme-provider';
+import { Layout } from '@/components/layout';
+
// Eager load: HomePage (initial route)
import { HomePage } from '@/pages';
@@ -33,58 +29,27 @@ const SettingsPage = lazy(() =>
const HealthPage = lazy(() => import('@/pages/health').then((m) => ({ default: m.HealthPage })));
const SharedPage = lazy(() => import('@/pages/shared').then((m) => ({ default: m.SharedPage })));
-// Loading fallback for lazy-loaded pages
-function PageLoader() {
- return (
-
-
-
-
- );
-}
-
-function Layout() {
- return (
-
-
-
-
-
- }>
-
-
-
-
-
-
- );
-}
-
export default function App() {
return (
-
-
- }>
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
-
-
-
+
+
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
);
}
diff --git a/ui/src/components/command-builder.tsx b/ui/src/components/command-builder.tsx
new file mode 100644
index 00000000..8e33dae3
--- /dev/null
+++ b/ui/src/components/command-builder.tsx
@@ -0,0 +1,148 @@
+import { useState } from 'react';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { CopyIcon, PlayIcon, TerminalIcon } from 'lucide-react';
+
+interface Command {
+ id: string;
+ command: string;
+ description: string;
+ category: string;
+}
+
+const commonCommands: Command[] = [
+ {
+ id: '1',
+ command: 'ccs config',
+ description: 'Open configuration interface',
+ category: 'Config',
+ },
+ {
+ id: '2',
+ command: 'ccs profile create --name my-profile',
+ description: 'Create a new profile',
+ category: 'Profile',
+ },
+ {
+ id: '3',
+ command: 'ccs profile switch --name my-profile',
+ description: 'Switch to a profile',
+ category: 'Profile',
+ },
+ {
+ id: '4',
+ command: 'ccs doctor',
+ description: 'Check system health',
+ category: 'Diagnostics',
+ },
+ {
+ id: '5',
+ command: 'ccs cliproxy list',
+ description: 'List available CLIProxy providers',
+ category: 'CLIProxy',
+ },
+ {
+ id: '6',
+ command: 'ccs cliproxy add --provider gemini --token YOUR_TOKEN',
+ description: 'Add CLIProxy provider',
+ category: 'CLIProxy',
+ },
+];
+
+export function CommandBuilder() {
+ const [command, setCommand] = useState('');
+ const [filteredCommands, setFilteredCommands] = useState(commonCommands);
+
+ const handleCommandChange = (value: string) => {
+ setCommand(value);
+ const filtered = commonCommands.filter(
+ (cmd) =>
+ cmd.command.toLowerCase().includes(value.toLowerCase()) ||
+ cmd.description.toLowerCase().includes(value.toLowerCase())
+ );
+ setFilteredCommands(filtered);
+ };
+
+ const handleCommandSelect = (cmd: string) => {
+ setCommand(cmd);
+ setFilteredCommands(commonCommands);
+ };
+
+ const handleCopy = () => {
+ navigator.clipboard.writeText(command);
+ };
+
+ const handleRun = () => {
+ console.log('Running command:', command);
+ };
+
+ const categories = Array.from(new Set(commonCommands.map((cmd) => cmd.category)));
+
+ return (
+
+
+
+
+ Command Builder
+
+
+
+
+
handleCommandChange(e.target.value)}
+ className="font-mono"
+ />
+
+
+
+
+
+
+
+ {categories.map((category) => (
+ {
+ const categoryCommands = commonCommands.filter((cmd) => cmd.category === category);
+ console.log(`${category} commands:`, categoryCommands);
+ }}
+ >
+ {category}
+
+ ))}
+
+
+
+
+ {filteredCommands.map((cmd) => (
+
handleCommandSelect(cmd.command)}
+ >
+
{cmd.command}
+
{cmd.description}
+
+ {cmd.category}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/ui/src/components/hub-footer.tsx b/ui/src/components/hub-footer.tsx
new file mode 100644
index 00000000..a81539f8
--- /dev/null
+++ b/ui/src/components/hub-footer.tsx
@@ -0,0 +1,71 @@
+import { Separator } from '@/components/ui/separator';
+import { Button } from '@/components/ui/button';
+import { FileTextIcon, SettingsIcon, GithubIcon, ExternalLinkIcon } from 'lucide-react';
+
+export function HubFooter() {
+ const currentYear = new Date().getFullYear();
+
+ const footerLinks = [
+ {
+ icon: ,
+ label: 'Logs',
+ href: '#logs',
+ onClick: () => console.log('Navigate to Logs'),
+ },
+ {
+ icon: ,
+ label: 'Settings',
+ href: '#settings',
+ onClick: () => console.log('Navigate to Settings'),
+ },
+ {
+ icon: ,
+ label: 'GitHub',
+ href: 'https://github.com/kaitranntt/ccs',
+ external: true,
+ },
+ ];
+
+ return (
+
+ );
+}
diff --git a/ui/src/components/layout.tsx b/ui/src/components/layout.tsx
new file mode 100644
index 00000000..fba044b2
--- /dev/null
+++ b/ui/src/components/layout.tsx
@@ -0,0 +1,42 @@
+import { Suspense } from 'react';
+import { Outlet } from 'react-router-dom';
+import { SidebarProvider } from '@/components/ui/sidebar';
+import { AppSidebar } from '@/components/app-sidebar';
+import { ThemeToggle } from '@/components/theme-toggle';
+import { GitHubLink } from '@/components/github-link';
+import { ConnectionIndicator } from '@/components/connection-indicator';
+import { LocalhostDisclaimer } from '@/components/localhost-disclaimer';
+import { Skeleton } from '@/components/ui/skeleton';
+
+function PageLoader() {
+ return (
+
+
+
+
+ );
+}
+
+export function Layout() {
+ return (
+
+
+
+
+
+ }>
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/profile-card.tsx b/ui/src/components/profile-card.tsx
new file mode 100644
index 00000000..b760c392
--- /dev/null
+++ b/ui/src/components/profile-card.tsx
@@ -0,0 +1,58 @@
+import { Card, CardContent, CardHeader } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { SettingsIcon, PlayIcon } from 'lucide-react';
+
+interface ProfileCardProps {
+ profile: {
+ name: string;
+ settingsPath: string;
+ configured: boolean;
+ isActive?: boolean;
+ lastUsed?: string;
+ model?: string;
+ };
+ onSwitch?: () => void;
+ onConfig?: () => void;
+ onTest?: () => void;
+}
+
+export function ProfileCard({ profile, onSwitch, onConfig, onTest }: ProfileCardProps) {
+ return (
+
+
+
+
+
{profile.name}
+ {profile.isActive && (
+
+ Active
+
+ )}
+
+
+
+
+
+ {profile.model && (
+ Model: {profile.model}
+ )}
+ {profile.lastUsed && (
+ Last used: {profile.lastUsed}
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/profile-deck.tsx b/ui/src/components/profile-deck.tsx
new file mode 100644
index 00000000..20523469
--- /dev/null
+++ b/ui/src/components/profile-deck.tsx
@@ -0,0 +1,59 @@
+import { useProfiles } from '@/hooks/use-profiles';
+import { ProfileCard } from './profile-card';
+import { Skeleton } from '@/components/ui/skeleton';
+
+export function ProfileDeck() {
+ const { data: response, isLoading, error } = useProfiles();
+
+ if (isLoading) {
+ return (
+
+ {[...Array(6)].map((_, i) => (
+
+
+
+ ))}
+
+ );
+ }
+
+ if (error) {
+ return Failed to load profiles: {error.message}
;
+ }
+
+ const profiles = response?.profiles || [];
+
+ if (!profiles || profiles.length === 0) {
+ return (
+
+ No profiles configured. Create your first profile to get started.
+
+ );
+ }
+
+ // Mock data for demonstration
+ const mockProfiles = profiles.map((profile, index: number) => ({
+ ...profile,
+ configured: profile.configured ?? false, // Ensure configured is always boolean
+ isActive: index === 0, // First profile is active
+ lastUsed: index === 0 ? '2 hours ago' : index === 1 ? '1 day ago' : undefined,
+ model: index === 0 ? 'claude-3.5-sonnet' : index === 1 ? 'claude-3-haiku' : undefined,
+ }));
+
+ return (
+
+
Profiles
+
+ {mockProfiles.map((profile) => (
+
console.log('Switch to', profile.name)}
+ onConfig={() => console.log('Config', profile.name)}
+ onTest={() => console.log('Test', profile.name)}
+ />
+ ))}
+
+
+ );
+}
diff --git a/ui/src/components/theme-provider.tsx b/ui/src/components/theme-provider.tsx
new file mode 100644
index 00000000..d113fe46
--- /dev/null
+++ b/ui/src/components/theme-provider.tsx
@@ -0,0 +1,61 @@
+import { useEffect, useState } from 'react';
+import { ThemeProviderContext } from '@/contexts/theme-context';
+import type { Theme } from '@/contexts/theme-context';
+
+type ThemeProviderProps = {
+ children: React.ReactNode;
+ defaultTheme?: Theme;
+ storageKey?: string;
+};
+
+export function ThemeProvider({
+ children,
+ defaultTheme = 'system',
+ storageKey = 'vite-ui-theme',
+}: ThemeProviderProps) {
+ const [theme, setTheme] = useState(
+ () => (localStorage.getItem(storageKey) as Theme) || defaultTheme
+ );
+
+ // Track system preference separately
+ // Initialize with window.matchMedia value if available
+ const [systemIsDark, setSystemIsDark] = useState(() => {
+ if (typeof window !== 'undefined') {
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
+ }
+ return false;
+ });
+
+ // Listen for system theme changes
+ useEffect(() => {
+ const media = window.matchMedia('(prefers-color-scheme: dark)');
+
+ const listener = (e: MediaQueryListEvent) => {
+ setSystemIsDark(e.matches);
+ };
+
+ media.addEventListener('change', listener);
+ return () => media.removeEventListener('change', listener);
+ }, []);
+
+ // Derive effective theme
+ const isDark = theme === 'dark' || (theme === 'system' && systemIsDark);
+
+ // Update DOM when effective theme changes
+ useEffect(() => {
+ const root = window.document.documentElement;
+ root.classList.remove('light', 'dark');
+ root.classList.add(isDark ? 'dark' : 'light');
+ }, [isDark]);
+
+ const value = {
+ theme,
+ setTheme: (theme: Theme) => {
+ localStorage.setItem(storageKey, theme);
+ setTheme(theme);
+ },
+ isDark,
+ };
+
+ return {children};
+}
diff --git a/ui/src/components/theme-toggle.tsx b/ui/src/components/theme-toggle.tsx
index 946aed90..619b3a11 100644
--- a/ui/src/components/theme-toggle.tsx
+++ b/ui/src/components/theme-toggle.tsx
@@ -3,11 +3,17 @@ import { Button } from '@/components/ui/button';
import { useTheme } from '@/hooks/use-theme';
export function ThemeToggle() {
- const { isDark, toggle } = useTheme();
+ const { theme, setTheme } = useTheme();
return (
-