feat(global-env): add global environment variables injection for third-party profiles

- add global_env config section to unified config types and loader

- inject global env vars at runtime for cliproxy and copilot profiles

- add GET/PUT /api/global-env endpoints for UI management

- add Global Env tab to Settings page with enable toggle and var management

- add GlobalEnvIndicator component showing injected vars in profile editors

- support ?tab=globalenv query param for direct navigation from indicators
This commit is contained in:
kaitranntt committed 2025-12-18 22:38:25 -05:00
1 parent 25cfb5b65e
commit 5d343260c7
11 files changed
+842 -104

No files matched your search

+5
View File
@@ -11,6 +11,7 @@ import {
displayWebSearchStatus, displayWebSearchStatus,
getWebSearchHookEnv, getWebSearchHookEnv,
} from './utils/websearch-manager'; } from './utils/websearch-manager';
import { getGlobalEnvConfig } from './config/unified-config-loader';
// Import extracted command handlers // Import extracted command handlers
import { handleVersionCommand } from './commands/version-command'; import { handleVersionCommand } from './commands/version-command';
@@ -494,7 +495,11 @@ async function main(): Promise<void> {
// Use --settings flag (backward compatible) // Use --settings flag (backward compatible)
const expandedSettingsPath = getSettingsPath(profileInfo.name); const expandedSettingsPath = getSettingsPath(profileInfo.name);
const webSearchEnv = getWebSearchHookEnv(); const webSearchEnv = getWebSearchHookEnv();
// Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
const globalEnvConfig = getGlobalEnvConfig();
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
const envVars: NodeJS.ProcessEnv = { const envVars: NodeJS.ProcessEnv = {
...globalEnv,
...webSearchEnv, ...webSearchEnv,
CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider
}; };
+23 -9
View File
@@ -14,7 +14,7 @@ import { getCcsDir } from '../utils/config-manager';
import { warn } from '../utils/ui'; import { warn } from '../utils/ui';
import { CLIProxyProvider, ProviderConfig, ProviderModelMapping } from './types'; import { CLIProxyProvider, ProviderConfig, ProviderModelMapping } from './types';
import { getModelMappingFromConfig, getEnvVarsFromConfig } from './base-config-loader'; import { getModelMappingFromConfig, getEnvVarsFromConfig } from './base-config-loader';
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; import { loadOrCreateUnifiedConfig, getGlobalEnvConfig } from '../config/unified-config-loader';
/** Settings file structure for user overrides */ /** Settings file structure for user overrides */
interface ProviderSettings { interface ProviderSettings {
@@ -380,6 +380,18 @@ export function getClaudeEnvVars(
}; };
} }
/**
* Get global env vars to inject into all third-party profiles.
* Returns empty object if disabled.
*/
function getGlobalEnvVars(): Record<string, string> {
const globalEnvConfig = getGlobalEnvConfig();
if (!globalEnvConfig.enabled) {
return {};
}
return globalEnvConfig.env;
}
/** /**
* Get effective environment variables for provider * Get effective environment variables for provider
* *
@@ -388,7 +400,7 @@ export function getClaudeEnvVars(
* 2. User settings file (~/.ccs/{provider}.settings.json) if exists * 2. User settings file (~/.ccs/{provider}.settings.json) if exists
* 3. Bundled defaults from PROVIDER_CONFIGS * 3. Bundled defaults from PROVIDER_CONFIGS
* *
* This allows users to customize model mappings without code changes. * All results are merged with global_env vars (telemetry/reporting disables).
* User takes full responsibility for custom settings. * User takes full responsibility for custom settings.
*/ */
export function getEffectiveEnvVars( export function getEffectiveEnvVars(
@@ -396,6 +408,9 @@ export function getEffectiveEnvVars(
port: number = CLIPROXY_DEFAULT_PORT, port: number = CLIPROXY_DEFAULT_PORT,
customSettingsPath?: string customSettingsPath?: string
): NodeJS.ProcessEnv { ): NodeJS.ProcessEnv {
// Get global env vars (DISABLE_TELEMETRY, etc.)
const globalEnv = getGlobalEnvVars();
// Priority 1: Custom settings path (for user-defined variants) // Priority 1: Custom settings path (for user-defined variants)
if (customSettingsPath) { if (customSettingsPath) {
const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir()); const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir());
@@ -405,8 +420,8 @@ export function getEffectiveEnvVars(
const settings: ProviderSettings = JSON.parse(content); const settings: ProviderSettings = JSON.parse(content);
if (settings.env && typeof settings.env === 'object') { if (settings.env && typeof settings.env === 'object') {
// Custom variant settings found - use them // Custom variant settings found - merge with global env
return settings.env; return { ...globalEnv, ...settings.env };
} }
} catch { } catch {
// Invalid JSON - fall through to provider defaults // Invalid JSON - fall through to provider defaults
@@ -427,9 +442,8 @@ export function getEffectiveEnvVars(
const settings: ProviderSettings = JSON.parse(content); const settings: ProviderSettings = JSON.parse(content);
if (settings.env && typeof settings.env === 'object') { if (settings.env && typeof settings.env === 'object') {
// User override found - use their settings // User override found - merge with global env
// Note: User is responsible for correctness return { ...globalEnv, ...settings.env };
return settings.env;
} }
} catch { } catch {
// Invalid JSON or structure - fall through to defaults // Invalid JSON or structure - fall through to defaults
@@ -437,8 +451,8 @@ export function getEffectiveEnvVars(
} }
} }
// No override or invalid - use bundled defaults // No override or invalid - use bundled defaults merged with global env
return getClaudeEnvVars(provider, port); return { ...globalEnv, ...getClaudeEnvVars(provider, port) };
} }
/** /**
+41
View File
@@ -15,6 +15,8 @@ import {
createEmptyUnifiedConfig, createEmptyUnifiedConfig,
UNIFIED_CONFIG_VERSION, UNIFIED_CONFIG_VERSION,
DEFAULT_COPILOT_CONFIG, DEFAULT_COPILOT_CONFIG,
DEFAULT_GLOBAL_ENV,
GlobalEnvConfig,
} from './unified-config-types'; } from './unified-config-types';
import { isUnifiedConfigEnabled } from './feature-flags'; import { isUnifiedConfigEnabled } from './feature-flags';
@@ -170,6 +172,11 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
wait_on_limit: partial.copilot?.wait_on_limit ?? DEFAULT_COPILOT_CONFIG.wait_on_limit, wait_on_limit: partial.copilot?.wait_on_limit ?? DEFAULT_COPILOT_CONFIG.wait_on_limit,
model: partial.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model, model: partial.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model,
}, },
// Global env - injected into all non-Claude subscription profiles
global_env: {
enabled: partial.global_env?.enabled ?? true,
env: partial.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV },
},
}; };
} }
@@ -336,6 +343,28 @@ function generateYamlWithComments(config: UnifiedConfig): string {
lines.push(''); lines.push('');
} }
// Global env section
if (config.global_env) {
lines.push('# ----------------------------------------------------------------------------');
lines.push(
'# Global Environment Variables: Injected into all non-Claude subscription profiles'
);
lines.push('# These env vars disable telemetry/reporting for third-party providers.');
lines.push('# Configure via Dashboard (`ccs config`) > Global Env tab.');
lines.push('#');
lines.push('# Default variables:');
lines.push('# DISABLE_BUG_COMMAND: Disables /bug command (not supported by proxy)');
lines.push('# DISABLE_ERROR_REPORTING: Disables error reporting to Anthropic');
lines.push('# DISABLE_TELEMETRY: Disables usage telemetry');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump({ global_env: config.global_env }, { indent: 2, lineWidth: -1, quotingType: '"' })
.trim()
);
lines.push('');
}
return lines.join('\n'); return lines.join('\n');
} }
@@ -462,3 +491,15 @@ export function getWebSearchConfig(): {
gemini: config.websearch?.gemini, gemini: config.websearch?.gemini,
}; };
} }
/**
* Get global_env configuration.
* Returns defaults if not configured.
*/
export function getGlobalEnvConfig(): GlobalEnvConfig {
const config = loadOrCreateUnifiedConfig();
return {
enabled: config.global_env?.enabled ?? true,
env: config.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV },
};
}
+28
View File
@@ -185,6 +185,28 @@ export interface CopilotConfig {
haiku_model?: string; haiku_model?: string;
} }
/**
* Global environment variables configuration.
* These env vars are injected into ALL non-Claude subscription profiles.
* Useful for disabling telemetry, bug commands, error reporting, etc.
*/
export interface GlobalEnvConfig {
/** Enable global env injection (default: true) */
enabled: boolean;
/** Environment variables to inject */
env: Record<string, string>;
}
/**
* Default global env vars for third-party profiles.
* These disable Claude Code telemetry/reporting since we're using proxy.
*/
export const DEFAULT_GLOBAL_ENV: Record<string, string> = {
DISABLE_BUG_COMMAND: '1',
DISABLE_ERROR_REPORTING: '1',
DISABLE_TELEMETRY: '1',
};
/** /**
* WebSearch configuration. * WebSearch configuration.
* Uses CLI tools (Gemini CLI, Grok CLI, OpenCode) for third-party profiles. * Uses CLI tools (Gemini CLI, Grok CLI, OpenCode) for third-party profiles.
@@ -234,6 +256,8 @@ export interface UnifiedConfig {
preferences: PreferencesConfig; preferences: PreferencesConfig;
/** WebSearch configuration */ /** WebSearch configuration */
websearch?: WebSearchConfig; websearch?: WebSearchConfig;
/** Global environment variables for all non-Claude subscription profiles */
global_env?: GlobalEnvConfig;
/** Copilot API configuration (GitHub Copilot proxy) */ /** Copilot API configuration (GitHub Copilot proxy) */
copilot?: CopilotConfig; copilot?: CopilotConfig;
} }
@@ -307,6 +331,10 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
}, },
}, },
}, },
global_env: {
enabled: true,
env: { ...DEFAULT_GLOBAL_ENV },
},
copilot: { ...DEFAULT_COPILOT_CONFIG }, copilot: { ...DEFAULT_COPILOT_CONFIG },
}; };
} }
+7 -1
View File
@@ -7,6 +7,7 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { CopilotConfig } from '../config/unified-config-types'; import { CopilotConfig } from '../config/unified-config-types';
import { getGlobalEnvConfig } from '../config/unified-config-loader';
import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth'; import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth';
import { isDaemonRunning, startDaemon } from './copilot-daemon'; import { isDaemonRunning, startDaemon } from './copilot-daemon';
import { ensureCopilotApi } from './copilot-package-manager'; import { ensureCopilotApi } from './copilot-package-manager';
@@ -127,9 +128,14 @@ export async function executeCopilotProfile(
// Generate environment for Claude // Generate environment for Claude
const copilotEnv = generateCopilotEnv(config); const copilotEnv = generateCopilotEnv(config);
// Merge with current environment // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
const globalEnvConfig = getGlobalEnvConfig();
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
// Merge with current environment (global env first, copilot overrides)
const env = { const env = {
...process.env, ...process.env,
...globalEnv,
...copilotEnv, ...copilotEnv,
}; };
+48 -1
View File
@@ -1789,7 +1789,7 @@ import {
getInstalledVersion as getCopilotInstalledVersion, getInstalledVersion as getCopilotInstalledVersion,
} from '../copilot'; } from '../copilot';
import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types'; import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types';
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; import { loadOrCreateUnifiedConfig, getGlobalEnvConfig } from '../config/unified-config-loader';
/** /**
* GET /api/copilot/status - Get Copilot status (auth + daemon + install info) * GET /api/copilot/status - Get Copilot status (auth + daemon + install info)
@@ -2081,3 +2081,50 @@ apiRoutes.put('/copilot/settings/raw', (req: Request, res: Response): void => {
res.status(500).json({ error: (error as Error).message }); res.status(500).json({ error: (error as Error).message });
} }
}); });
// ==================== Global Environment Variables ====================
/**
* GET /api/global-env - Get global environment variables configuration
* Returns the global_env section from config.yaml
*/
apiRoutes.get('/global-env', (_req: Request, res: Response): void => {
try {
const config = getGlobalEnvConfig();
res.json(config);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* PUT /api/global-env - Update global environment variables configuration
* Updates the global_env section in config.yaml
*/
apiRoutes.put('/global-env', (req: Request, res: Response): void => {
try {
const { enabled, env } = req.body;
const config = loadOrCreateUnifiedConfig();
// Validate env is an object with string values
if (env !== undefined && typeof env === 'object' && env !== null) {
for (const [key, value] of Object.entries(env)) {
if (typeof value !== 'string') {
res.status(400).json({ error: `Invalid value for ${key}: must be a string` });
return;
}
}
}
// Update global_env section
config.global_env = {
enabled: enabled ?? config.global_env?.enabled ?? true,
env: env ?? config.global_env?.env ?? {},
};
saveUnifiedConfig(config);
res.json({ success: true, config: config.global_env });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
@@ -57,6 +57,7 @@ import {
useDeletePreset, useDeletePreset,
} from '@/hooks/use-cliproxy'; } from '@/hooks/use-cliproxy';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { GlobalEnvIndicator } from '@/components/global-env-indicator';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
// Lazy load CodeEditor // Lazy load CodeEditor
@@ -543,7 +544,7 @@ export function ProviderEditor({
Invalid JSON syntax Invalid JSON syntax
</div> </div>
)} )}
<div className="flex-1 overflow-hidden px-6 pb-6 pt-4"> <div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
<div className="h-full border rounded-md overflow-hidden bg-background"> <div className="h-full border rounded-md overflow-hidden bg-background">
<CodeEditor <CodeEditor
value={rawJsonContent} value={rawJsonContent}
@@ -553,6 +554,12 @@ export function ProviderEditor({
/> />
</div> </div>
</div> </div>
{/* Global Env Indicator */}
<div className="mx-6 mb-4">
<div className="border rounded-md overflow-hidden">
<GlobalEnvIndicator profileEnv={settings?.env} />
</div>
</div>
</div> </div>
</Suspense> </Suspense>
); );
@@ -31,6 +31,7 @@ import { useCopilot, type CopilotModel, type CopilotPlanTier } from '@/hooks/use
import { Loader2, Save, Code2, X, Info, RefreshCw, Sparkles, Zap, Check } from 'lucide-react'; import { Loader2, Save, Code2, X, Info, RefreshCw, Sparkles, Zap, Check } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/confirm-dialog'; import { ConfirmDialog } from '@/components/confirm-dialog';
import { GlobalEnvIndicator } from '@/components/global-env-indicator';
// Lazy load CodeEditor // Lazy load CodeEditor
const CodeEditor = lazy(() => const CodeEditor = lazy(() =>
@@ -726,7 +727,7 @@ export function CopilotConfigForm() {
Invalid JSON syntax Invalid JSON syntax
</div> </div>
)} )}
<div className="flex-1 overflow-hidden px-6 pb-6 pt-4"> <div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
<div className="h-full border rounded-md overflow-hidden bg-background"> <div className="h-full border rounded-md overflow-hidden bg-background">
<CodeEditor <CodeEditor
value={rawJsonContent} value={rawJsonContent}
@@ -736,6 +737,12 @@ export function CopilotConfigForm() {
/> />
</div> </div>
</div> </div>
{/* Global Env Indicator */}
<div className="mx-6 mb-4">
<div className="border rounded-md overflow-hidden">
<GlobalEnvIndicator profileEnv={rawSettings?.settings?.env} />
</div>
</div>
</div> </div>
</Suspense> </Suspense>
); );
+132
View File
@@ -0,0 +1,132 @@
/**
* Global Environment Variables Indicator
*
* Shows which env vars from global_env will be injected at runtime.
* Displayed below the Raw Configuration (JSON) section in profile editors.
*/
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Settings2, ChevronDown, ChevronUp, ExternalLink, Info } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface GlobalEnvConfig {
enabled: boolean;
env: Record<string, string>;
}
interface GlobalEnvIndicatorProps {
/** Current profile's env vars (to show which are overridden) */
profileEnv?: Record<string, string>;
}
export function GlobalEnvIndicator({ profileEnv = {} }: GlobalEnvIndicatorProps) {
const [config, setConfig] = useState<GlobalEnvConfig | null>(null);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
fetchConfig();
}, []);
const fetchConfig = async () => {
try {
setLoading(true);
const res = await fetch('/api/global-env');
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
setConfig(data);
} catch {
setConfig(null);
} finally {
setLoading(false);
}
};
// Don't render if loading or disabled or no vars
if (loading) return null;
if (!config?.enabled) return null;
const envVars = config.env || {};
const envKeys = Object.keys(envVars);
if (envKeys.length === 0) return null;
// Check which keys are already in profile (won't be overridden)
const injectedKeys = envKeys.filter((key) => !(key in profileEnv));
const overriddenKeys = envKeys.filter((key) => key in profileEnv);
return (
<div className="border-t bg-muted/20">
{/* Header - clickable to expand */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full px-4 py-2 flex items-center gap-2 hover:bg-muted/30 transition-colors"
>
<Info className="w-4 h-4 text-blue-500" />
<span className="text-xs text-muted-foreground flex-1 text-left">
<span className="font-medium text-foreground">{injectedKeys.length}</span> global env var
{injectedKeys.length !== 1 ? 's' : ''} will be injected at runtime
{overriddenKeys.length > 0 && (
<span className="text-amber-600 dark:text-amber-400 ml-1">
({overriddenKeys.length} overridden by profile)
</span>
)}
</span>
{expanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</button>
{/* Expanded content */}
{expanded && (
<div className="px-4 pb-3 space-y-2">
{/* Injected vars */}
{injectedKeys.length > 0 && (
<div className="space-y-1">
{injectedKeys.map((key) => (
<div
key={key}
className="flex items-center gap-2 text-xs font-mono bg-green-500/10 text-green-700 dark:text-green-400 px-2 py-1 rounded"
>
<span className="text-green-500">+</span>
<span className="truncate">
{key}={envVars[key]}
</span>
</div>
))}
</div>
)}
{/* Overridden vars (profile takes precedence) */}
{overriddenKeys.length > 0 && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Skipped (profile already defines):</p>
{overriddenKeys.map((key) => (
<div
key={key}
className="flex items-center gap-2 text-xs font-mono bg-amber-500/10 text-amber-700 dark:text-amber-400 px-2 py-1 rounded"
>
<span className="text-amber-500">~</span>
<span className="truncate">{key}</span>
</div>
))}
</div>
)}
{/* Link to settings */}
<div className="pt-2 border-t border-border/50">
<Button variant="ghost" size="sm" asChild className="h-7 text-xs gap-1.5 -ml-2">
<Link to="/settings?tab=globalenv">
<Settings2 className="w-3.5 h-3.5" />
Configure in Settings
<ExternalLink className="w-3 h-3 opacity-50" />
</Link>
</Button>
</div>
</div>
)}
</div>
);
}
+8 -1
View File
@@ -16,6 +16,7 @@ import { Save, Loader2, Code2, Trash2, RefreshCw, Plus, X, Info } from 'lucide-r
import { toast } from 'sonner'; import { toast } from 'sonner';
import { CopyButton } from '@/components/ui/copy-button'; import { CopyButton } from '@/components/ui/copy-button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { GlobalEnvIndicator } from '@/components/global-env-indicator';
// Lazy load CodeEditor to reduce initial bundle size // Lazy load CodeEditor to reduce initial bundle size
const CodeEditor = lazy(() => const CodeEditor = lazy(() =>
@@ -412,7 +413,7 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
Invalid JSON syntax Invalid JSON syntax
</div> </div>
)} )}
<div className="flex-1 overflow-hidden px-6 pb-6 pt-4"> <div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
<div className="h-full border rounded-md overflow-hidden bg-background"> <div className="h-full border rounded-md overflow-hidden bg-background">
<CodeEditor <CodeEditor
value={rawJsonContent} value={rawJsonContent}
@@ -422,6 +423,12 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
/> />
</div> </div>
</div> </div>
{/* Global Env Indicator */}
<div className="mx-6 mb-4">
<div className="border rounded-md overflow-hidden">
<GlobalEnvIndicator profileEnv={settings?.env} />
</div>
</div>
</div> </div>
</Suspense> </Suspense>
); );
+534 -90
View File
@@ -1,15 +1,17 @@
/** /**
* Settings Page - WebSearch Configuration * Settings Page - WebSearch & Global Env Configuration
* Supports Gemini CLI and Grok CLI providers * Supports Gemini CLI and Grok CLI providers + Global Environment Variables
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { import {
Globe, Globe,
RefreshCw, RefreshCw,
@@ -23,6 +25,9 @@ import {
ExternalLink, ExternalLink,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Settings2,
Plus,
Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { CodeEditor } from '@/components/code-editor'; import { CodeEditor } from '@/components/code-editor';
@@ -59,7 +64,15 @@ interface WebSearchStatus {
}; };
} }
interface GlobalEnvConfig {
enabled: boolean;
env: Record<string, string>;
}
export function SettingsPage() { export function SettingsPage() {
const [searchParams] = useSearchParams();
const initialTab = searchParams.get('tab') === 'globalenv' ? 'globalenv' : 'websearch';
const [activeTab, setActiveTab] = useState<'websearch' | 'globalenv'>(initialTab);
const [config, setConfig] = useState<WebSearchConfig | null>(null); const [config, setConfig] = useState<WebSearchConfig | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -78,12 +91,22 @@ export function SettingsPage() {
const [showGeminiHint, setShowGeminiHint] = useState(false); const [showGeminiHint, setShowGeminiHint] = useState(false);
const [showOpencodeHint, setShowOpencodeHint] = useState(false); const [showOpencodeHint, setShowOpencodeHint] = useState(false);
const [showGrokHint, setShowGrokHint] = useState(false); const [showGrokHint, setShowGrokHint] = useState(false);
// Global Env state
const [globalEnvConfig, setGlobalEnvConfig] = useState<GlobalEnvConfig | null>(null);
const [globalEnvLoading, setGlobalEnvLoading] = useState(true);
const [globalEnvSaving, setGlobalEnvSaving] = useState(false);
const [globalEnvError, setGlobalEnvError] = useState<string | null>(null);
const [globalEnvSuccess, setGlobalEnvSuccess] = useState(false);
// New env var inputs
const [newEnvKey, setNewEnvKey] = useState('');
const [newEnvValue, setNewEnvValue] = useState('');
// Load config and status on mount // Load config and status on mount
useEffect(() => { useEffect(() => {
fetchConfig(); fetchConfig();
fetchStatus(); fetchStatus();
fetchRawConfig(); fetchRawConfig();
fetchGlobalEnvConfig();
}, []); }, []);
// Sync local model inputs when config changes // Sync local model inputs when config changes
@@ -141,6 +164,21 @@ export function SettingsPage() {
} }
}; };
const fetchGlobalEnvConfig = async () => {
try {
setGlobalEnvLoading(true);
setGlobalEnvError(null);
const res = await fetch('/api/global-env');
if (!res.ok) throw new Error('Failed to load Global Env config');
const data = await res.json();
setGlobalEnvConfig(data);
} catch (err) {
setGlobalEnvError((err as Error).message);
} finally {
setGlobalEnvLoading(false);
}
};
const copyToClipboard = async () => { const copyToClipboard = async () => {
if (!rawConfig) return; if (!rawConfig) return;
try { try {
@@ -284,6 +322,71 @@ export function SettingsPage() {
} }
}; };
// Global Env functions
const saveGlobalEnvConfig = async (updates: Partial<GlobalEnvConfig>) => {
if (!globalEnvConfig) return;
// Optimistic update
const optimisticConfig = { ...globalEnvConfig, ...updates };
setGlobalEnvConfig(optimisticConfig);
try {
setGlobalEnvSaving(true);
setGlobalEnvError(null);
const res = await fetch('/api/global-env', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(optimisticConfig),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to save');
}
const data = await res.json();
setGlobalEnvConfig(data.config);
setGlobalEnvSuccess(true);
setTimeout(() => setGlobalEnvSuccess(false), 1500);
// Silently refresh raw config
fetch('/api/config/raw')
.then((r) => (r.ok ? r.text() : null))
.then((text) => text && setRawConfig(text))
.catch(() => {});
} catch (err) {
setGlobalEnvConfig(globalEnvConfig);
setGlobalEnvError((err as Error).message);
} finally {
setGlobalEnvSaving(false);
}
};
const toggleGlobalEnv = () => {
saveGlobalEnvConfig({ enabled: !globalEnvConfig?.enabled });
};
const addEnvVar = () => {
if (!newEnvKey.trim() || !globalEnvConfig) return;
const newEnv = { ...globalEnvConfig.env, [newEnvKey.trim()]: newEnvValue };
saveGlobalEnvConfig({ env: newEnv });
setNewEnvKey('');
setNewEnvValue('');
};
const removeEnvVar = (key: string) => {
if (!globalEnvConfig) return;
const newEnv = { ...globalEnvConfig.env };
delete newEnv[key];
saveGlobalEnvConfig({ env: newEnv });
};
const updateEnvValue = (key: string, value: string) => {
if (!globalEnvConfig) return;
const newEnv = { ...globalEnvConfig.env, [key]: value };
saveGlobalEnvConfig({ env: newEnv });
};
if (loading) { if (loading) {
return ( return (
<div className="h-[calc(100vh-100px)] flex items-center justify-center"> <div className="h-[calc(100vh-100px)] flex items-center justify-center">
@@ -298,22 +401,222 @@ export function SettingsPage() {
return ( return (
<div className="h-[calc(100vh-100px)]"> <div className="h-[calc(100vh-100px)]">
<PanelGroup direction="horizontal" className="h-full"> <PanelGroup direction="horizontal" className="h-full">
{/* Left Panel - WebSearch Controls */} {/* Left Panel - Settings Controls */}
<Panel defaultSize={40} minSize={30} maxSize={55}> <Panel defaultSize={40} minSize={30} maxSize={55}>
<div className="h-full border-r flex flex-col bg-muted/30 relative"> <div className="h-full border-r flex flex-col bg-muted/30 relative">
{/* Header */} {/* Header with Tabs */}
<div className="p-5 border-b bg-background"> <div className="p-5 border-b bg-background">
<div className="flex items-center gap-3"> <Tabs
<Globe className="w-6 h-6 text-primary" /> value={activeTab}
<div> onValueChange={(v) => setActiveTab(v as 'websearch' | 'globalenv')}
<h1 className="text-lg font-semibold">WebSearch</h1> >
<p className="text-sm text-muted-foreground"> <TabsList className="w-full">
CLI-based web search for third-party profiles <TabsTrigger value="websearch" className="flex-1 gap-2">
</p> <Globe className="w-4 h-4" />
WebSearch
</TabsTrigger>
<TabsTrigger value="globalenv" className="flex-1 gap-2">
<Settings2 className="w-4 h-4" />
Global Env
</TabsTrigger>
</TabsList>
</Tabs>
</div> </div>
{/* Tab Content */}
{activeTab === 'websearch' ? (
<WebSearchContent
config={config}
status={status}
statusLoading={statusLoading}
saving={saving}
error={error}
success={success}
isGeminiEnabled={isGeminiEnabled}
isGrokEnabled={isGrokEnabled}
isOpenCodeEnabled={isOpenCodeEnabled}
geminiModelInput={geminiModelInput}
opencodeModelInput={opencodeModelInput}
showGeminiHint={showGeminiHint}
showOpencodeHint={showOpencodeHint}
showGrokHint={showGrokHint}
setGeminiModelInput={setGeminiModelInput}
setOpencodeModelInput={setOpencodeModelInput}
setShowGeminiHint={setShowGeminiHint}
setShowOpencodeHint={setShowOpencodeHint}
setShowGrokHint={setShowGrokHint}
toggleGemini={toggleGemini}
toggleGrok={toggleGrok}
toggleOpenCode={toggleOpenCode}
saveGeminiModel={saveGeminiModel}
saveOpencodeModel={saveOpencodeModel}
fetchStatus={fetchStatus}
fetchConfig={fetchConfig}
fetchRawConfig={fetchRawConfig}
loading={loading}
/>
) : (
<GlobalEnvContent
config={globalEnvConfig}
loading={globalEnvLoading}
saving={globalEnvSaving}
error={globalEnvError}
success={globalEnvSuccess}
newEnvKey={newEnvKey}
newEnvValue={newEnvValue}
setNewEnvKey={setNewEnvKey}
setNewEnvValue={setNewEnvValue}
toggleGlobalEnv={toggleGlobalEnv}
addEnvVar={addEnvVar}
removeEnvVar={removeEnvVar}
updateEnvValue={updateEnvValue}
fetchGlobalEnvConfig={fetchGlobalEnvConfig}
fetchRawConfig={fetchRawConfig}
/>
)}
</div>
</Panel>
{/* Resize Handle */}
<PanelResizeHandle className="w-2 bg-border hover:bg-primary/20 transition-colors cursor-col-resize flex items-center justify-center group">
<GripVertical className="w-3 h-3 text-muted-foreground group-hover:text-primary" />
</PanelResizeHandle>
{/* Right Panel - Config Viewer */}
<Panel defaultSize={60} minSize={35}>
<div className="h-full flex flex-col">
{/* Header */}
<div className="p-4 border-b bg-background flex items-center justify-between">
<div className="flex items-center gap-3">
<FileCode className="w-5 h-5 text-primary" />
<div>
<h2 className="font-semibold">config.yaml</h2>
<p className="text-sm text-muted-foreground">~/.ccs/config.yaml</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={copyToClipboard} disabled={!rawConfig}>
{copied ? (
<>
<Check className="w-4 h-4 mr-1" />
Copied
</>
) : (
<>
<Copy className="w-4 h-4 mr-1" />
Copy
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={fetchRawConfig}
disabled={rawConfigLoading}
>
<RefreshCw className={`w-4 h-4 ${rawConfigLoading ? 'animate-spin' : ''}`} />
</Button>
</div> </div>
</div> </div>
{/* Config Content - scrollable */}
<div className="flex-1 overflow-auto">
{rawConfigLoading ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
Loading...
</div>
) : rawConfig ? (
<CodeEditor
value={rawConfig}
onChange={() => {}}
language="yaml"
readonly
minHeight="auto"
className="min-h-full"
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">
<FileCode className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>Config file not found</p>
<code className="text-sm bg-muted px-2 py-1 rounded mt-2 inline-block">
ccs migrate
</code>
</div>
</div>
)}
</div>
</div>
</Panel>
</PanelGroup>
</div>
);
}
// WebSearch Tab Content Component
interface WebSearchContentProps {
config: WebSearchConfig | null;
status: WebSearchStatus | null;
statusLoading: boolean;
saving: boolean;
error: string | null;
success: boolean;
isGeminiEnabled: boolean;
isGrokEnabled: boolean;
isOpenCodeEnabled: boolean;
geminiModelInput: string;
opencodeModelInput: string;
showGeminiHint: boolean;
showOpencodeHint: boolean;
showGrokHint: boolean;
setGeminiModelInput: (v: string) => void;
setOpencodeModelInput: (v: string) => void;
setShowGeminiHint: (v: boolean) => void;
setShowOpencodeHint: (v: boolean) => void;
setShowGrokHint: (v: boolean) => void;
toggleGemini: () => void;
toggleGrok: () => void;
toggleOpenCode: () => void;
saveGeminiModel: () => void;
saveOpencodeModel: () => void;
fetchStatus: () => void;
fetchConfig: () => void;
fetchRawConfig: () => void;
loading: boolean;
}
function WebSearchContent({
status,
statusLoading,
saving,
error,
success,
isGeminiEnabled,
isGrokEnabled,
isOpenCodeEnabled,
geminiModelInput,
opencodeModelInput,
showGeminiHint,
showOpencodeHint,
showGrokHint,
setGeminiModelInput,
setOpencodeModelInput,
setShowGeminiHint,
setShowOpencodeHint,
setShowGrokHint,
toggleGemini,
toggleGrok,
toggleOpenCode,
saveGeminiModel,
saveOpencodeModel,
fetchStatus,
fetchConfig,
fetchRawConfig,
loading,
}: WebSearchContentProps) {
return (
<>
{/* Toast-style alerts - absolute positioned, no layout shift */} {/* Toast-style alerts - absolute positioned, no layout shift */}
<div <div
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${ className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
@@ -339,6 +642,11 @@ export function SettingsPage() {
{/* Scrollable Content */} {/* Scrollable Content */}
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="p-5 space-y-6"> <div className="p-5 space-y-6">
{/* Description */}
<p className="text-sm text-muted-foreground">
CLI-based web search for third-party profiles (gemini, codex, agy, etc.)
</p>
{/* Status Summary */} {/* Status Summary */}
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50"> <div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div> <div>
@@ -480,9 +788,7 @@ export function SettingsPage() {
</span> </span>
)} )}
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">OpenCode (web search via Zen)</p>
OpenCode (web search via Zen)
</p>
</div> </div>
</div> </div>
<Switch <Switch
@@ -573,9 +879,7 @@ export function SettingsPage() {
</span> </span>
)} )}
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">xAI Grok CLI (web + X search)</p>
xAI Grok CLI (web + X search)
</p>
</div> </div>
</div> </div>
<Switch <Switch
@@ -640,82 +944,222 @@ export function SettingsPage() {
Refresh Refresh
</Button> </Button>
</div> </div>
</div>
</Panel>
{/* Resize Handle */}
<PanelResizeHandle className="w-2 bg-border hover:bg-primary/20 transition-colors cursor-col-resize flex items-center justify-center group">
<GripVertical className="w-3 h-3 text-muted-foreground group-hover:text-primary" />
</PanelResizeHandle>
{/* Right Panel - Config Viewer */}
<Panel defaultSize={60} minSize={35}>
<div className="h-full flex flex-col">
{/* Header */}
<div className="p-4 border-b bg-background flex items-center justify-between">
<div className="flex items-center gap-3">
<FileCode className="w-5 h-5 text-primary" />
<div>
<h2 className="font-semibold">config.yaml</h2>
<p className="text-sm text-muted-foreground">~/.ccs/config.yaml</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={copyToClipboard} disabled={!rawConfig}>
{copied ? (
<>
<Check className="w-4 h-4 mr-1" />
Copied
</> </>
) : ( );
<> }
<Copy className="w-4 h-4 mr-1" />
Copy
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={fetchRawConfig}
disabled={rawConfigLoading}
>
<RefreshCw className={`w-4 h-4 ${rawConfigLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
{/* Config Content - scrollable */} // Global Env Tab Content Component
<div className="flex-1 overflow-auto"> interface GlobalEnvContentProps {
{rawConfigLoading ? ( config: GlobalEnvConfig | null;
<div className="flex items-center justify-center h-full text-muted-foreground"> loading: boolean;
<RefreshCw className="w-5 h-5 animate-spin mr-2" /> saving: boolean;
Loading... error: string | null;
success: boolean;
newEnvKey: string;
newEnvValue: string;
setNewEnvKey: (v: string) => void;
setNewEnvValue: (v: string) => void;
toggleGlobalEnv: () => void;
addEnvVar: () => void;
removeEnvVar: (key: string) => void;
updateEnvValue: (key: string, value: string) => void;
fetchGlobalEnvConfig: () => void;
fetchRawConfig: () => void;
}
function GlobalEnvContent({
config,
loading,
saving,
error,
success,
newEnvKey,
newEnvValue,
setNewEnvKey,
setNewEnvValue,
toggleGlobalEnv,
addEnvVar,
removeEnvVar,
fetchGlobalEnvConfig,
fetchRawConfig,
}: GlobalEnvContentProps) {
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" />
<span>Loading...</span>
</div> </div>
) : rawConfig ? (
<CodeEditor
value={rawConfig}
onChange={() => {}}
language="yaml"
readonly
minHeight="auto"
className="min-h-full"
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">
<FileCode className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>Config file not found</p>
<code className="text-sm bg-muted px-2 py-1 rounded mt-2 inline-block">
ccs migrate
</code>
</div>
</div>
)}
</div>
</div>
</Panel>
</PanelGroup>
</div> </div>
); );
} }
return (
<>
{/* Toast-style alerts */}
<div
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
error || success
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-2 pointer-events-none'
}`}
>
{error && (
<Alert variant="destructive" className="py-2 shadow-lg">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">Saved</span>
</div>
)}
</div>
{/* Scrollable Content */}
<ScrollArea className="flex-1">
<div className="p-5 space-y-6">
{/* Description */}
<p className="text-sm text-muted-foreground">
Environment variables injected into all non-Claude subscription profiles (gemini, codex,
agy, copilot, etc.)
</p>
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<p className="font-medium">
{config?.enabled ? 'Global Env enabled' : 'Global Env disabled'}
</p>
<p className="text-sm text-muted-foreground">
{config?.enabled
? 'Env vars will be injected into third-party profiles'
: 'Env vars will not be injected'}
</p>
</div>
<Switch checked={config?.enabled ?? true} onCheckedChange={toggleGlobalEnv} />
</div>
{/* Current Environment Variables */}
<div className="space-y-3">
<h3 className="text-base font-medium">Environment Variables</h3>
{config?.env && Object.keys(config.env).length > 0 ? (
<div className="space-y-2">
{Object.entries(config.env).map(([key, value]) => (
<div
key={key}
className="flex items-center gap-2 p-3 rounded-lg border bg-background"
>
<code className="flex-1 font-mono text-sm truncate">{key}</code>
<span className="text-muted-foreground">=</span>
<code className="font-mono text-sm px-2 py-1 bg-muted rounded">{value}</code>
<Button
variant="ghost"
size="sm"
onClick={() => removeEnvVar(key)}
disabled={saving}
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
</div>
) : (
<div className="p-4 rounded-lg border border-dashed text-center text-muted-foreground">
<p>No environment variables configured</p>
</div>
)}
{/* Add New Variable */}
<div className="p-4 rounded-lg border bg-muted/30">
<h4 className="text-sm font-medium mb-3">Add New Variable</h4>
<div className="flex gap-2">
<Input
value={newEnvKey}
onChange={(e) => setNewEnvKey(e.target.value.toUpperCase())}
placeholder="KEY_NAME"
className="flex-1 font-mono text-sm h-9"
disabled={saving}
/>
<span className="flex items-center text-muted-foreground">=</span>
<Input
value={newEnvValue}
onChange={(e) => setNewEnvValue(e.target.value)}
placeholder="value"
className="flex-1 font-mono text-sm h-9"
disabled={saving}
/>
<Button
size="sm"
onClick={addEnvVar}
disabled={saving || !newEnvKey.trim()}
className="h-9"
>
<Plus className="w-4 h-4 mr-1" />
Add
</Button>
</div>
</div>
{/* Common Variables Quick Add */}
<div className="p-4 rounded-lg border bg-muted/30">
<h4 className="text-sm font-medium mb-3">Quick Add Common Variables</h4>
<div className="flex flex-wrap gap-2">
{[
{ key: 'DISABLE_BUG_COMMAND', value: '1' },
{ key: 'DISABLE_ERROR_REPORTING', value: '1' },
{ key: 'DISABLE_TELEMETRY', value: '1' },
].map(
({ key, value }) =>
!config?.env?.[key] && (
<Button
key={key}
variant="outline"
size="sm"
onClick={() => {
setNewEnvKey(key);
setNewEnvValue(value);
}}
className="text-xs font-mono"
>
+ {key}
</Button>
)
)}
{config?.env &&
['DISABLE_BUG_COMMAND', 'DISABLE_ERROR_REPORTING', 'DISABLE_TELEMETRY'].every(
(k) => config.env[k]
) && (
<span className="text-sm text-muted-foreground">
All common variables are configured
</span>
)}
</div>
</div>
</div>
</div>
</ScrollArea>
{/* Footer */}
<div className="p-4 border-t bg-background">
<Button
variant="outline"
size="sm"
onClick={() => {
fetchGlobalEnvConfig();
fetchRawConfig();
}}
disabled={loading || saving}
className="w-full"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</>
);
}