From b82f10e63952db82ebe2e66fc584c5da231efb1d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 15 Mar 2026 13:43:43 -0400 Subject: [PATCH] feat(config): add Claude IDE extension setup flow --- scripts/completion/ccs.bash | 15 +- src/commands/config-command.ts | 7 + src/commands/env-command.ts | 138 ++++------ src/commands/help-command.ts | 12 +- src/commands/persist-command.ts | 181 ++++++------- src/shared/claude-extension-hosts.ts | 42 +++ src/shared/claude-extension-setup.ts | 233 ++++++++++++++++ .../routes/claude-extension-routes.ts | 73 +++++ src/web-server/routes/index.ts | 2 + .../unit/commands/help-command-parity.test.ts | 18 ++ .../commands/persist-command-handler.test.ts | 113 ++++++++ .../claude-extension-routes.test.ts | 142 ++++++++++ ui/src/App.tsx | 11 + ui/src/components/layout/app-sidebar.tsx | 2 + ui/src/hooks/use-claude-extension.ts | 72 +++++ ui/src/lib/i18n.ts | 4 + ui/src/pages/claude-extension.tsx | 249 ++++++++++++++++++ ui/src/pages/index.tsx | 2 + 18 files changed, 1120 insertions(+), 196 deletions(-) create mode 100644 src/shared/claude-extension-hosts.ts create mode 100644 src/shared/claude-extension-setup.ts create mode 100644 src/web-server/routes/claude-extension-routes.ts create mode 100644 tests/unit/web-server/claude-extension-routes.test.ts create mode 100644 ui/src/hooks/use-claude-extension.ts create mode 100644 ui/src/pages/claude-extension.tsx diff --git a/scripts/completion/ccs.bash b/scripts/completion/ccs.bash index b9eb965e..985e8806 100644 --- a/scripts/completion/ccs.bash +++ b/scripts/completion/ccs.bash @@ -18,7 +18,7 @@ _ccs_completion() { # Top-level completion (first argument) if [[ ${COMP_CWORD} -eq 1 ]]; then - local commands="auth api cliproxy doctor env sync update" + local commands="auth api cliproxy config doctor env persist sync update" local flags="--help --version --shell-completion -h -v -sc" local cliproxy_profiles="gemini codex agy qwen" local profiles="" @@ -156,23 +156,30 @@ _ccs_completion() { case "${prev}" in env) # Complete with profile names and flags (inline profiles since $cliproxy_profiles is out of scope) - local env_opts="--format --shell --help -h gemini codex agy qwen iflow kiro ghcp claude" + local env_opts="--format --shell --ide --help -h gemini codex agy qwen iflow kiro ghcp claude default" if [[ -f ~/.ccs/config.json ]]; then env_opts="$env_opts $(jq -r '.profiles | keys[]' ~/.ccs/config.json 2>/dev/null || true)" fi + if [[ -f ~/.ccs/profiles.json ]]; then + env_opts="$env_opts $(jq -r '.profiles | keys[]' ~/.ccs/profiles.json 2>/dev/null || true)" + fi COMPREPLY=( $(compgen -W "${env_opts}" -- ${cur}) ) return 0 ;; --format) - COMPREPLY=( $(compgen -W "openai anthropic raw" -- ${cur}) ) + COMPREPLY=( $(compgen -W "openai anthropic raw claude-extension" -- ${cur}) ) return 0 ;; --shell) COMPREPLY=( $(compgen -W "auto bash zsh fish powershell" -- ${cur}) ) return 0 ;; + --ide) + COMPREPLY=( $(compgen -W "vscode cursor windsurf" -- ${cur}) ) + return 0 + ;; *) - COMPREPLY=( $(compgen -W "--format --shell --help -h" -- ${cur}) ) + COMPREPLY=( $(compgen -W "--format --shell --ide --help -h" -- ${cur}) ) return 0 ;; esac diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index 611b553c..7a7f5f4c 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -59,6 +59,7 @@ function showHelp(): void { console.log('Usage: ccs config [command] [options]'); console.log(''); console.log('Open web-based configuration dashboard'); + console.log('Includes a dedicated Claude IDE Extension page for VS Code-compatible hosts.'); console.log(''); console.log('Commands:'); console.log(' auth Manage dashboard authentication'); @@ -72,6 +73,11 @@ function showHelp(): void { console.log(' --timeout Set analysis timeout (seconds)'); console.log(' --set-model

Set model for provider'); console.log(''); + console.log(' Claude IDE Extension'); + console.log(' Dashboard page Generate copy-ready setup for VS Code, Cursor, Windsurf'); + console.log(' Shared settings Shows preferred ~/.claude/settings.json setup'); + console.log(' IDE-local JSON Shows extension-specific environmentVariables snippets'); + console.log(''); console.log(' thinking Manage thinking/reasoning settings'); console.log(' --mode Set mode (auto, off, manual)'); console.log(' --override Set persistent override level'); @@ -90,6 +96,7 @@ function showHelp(): void { console.log(' ccs config --port 3000 Use specific port'); console.log(' ccs config --dev Development mode with hot reload'); console.log(' ccs config auth setup Configure dashboard login'); + console.log(' ccs config Open dashboard, then choose Claude IDE Extension'); console.log(' ccs config image-analysis Show image settings'); console.log(' ccs config image-analysis --enable Enable feature'); console.log(' ccs config thinking Show thinking settings'); diff --git a/src/commands/env-command.ts b/src/commands/env-command.ts index a2f1aee5..6a1407a4 100644 --- a/src/commands/env-command.ts +++ b/src/commands/env-command.ts @@ -6,22 +6,23 @@ */ import { initUI, header, dim, color, subheader, fail, warn } from '../utils/ui'; -import { CLIProxyProvider } from '../cliproxy/types'; -import { CLIPROXY_PROFILES, loadSettingsFromFile } from '../auth/profile-detector'; -import { getEffectiveEnvVars } from '../cliproxy/config/env-builder'; -import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; -import { isUnifiedMode, loadUnifiedConfig } from '../config/unified-config-loader'; -import { expandPath } from '../utils/helpers'; import { getCcsDir } from '../utils/config-manager'; -import { ProfileRegistry } from '../auth/profile-registry'; -import { getProfileLookupCandidates } from '../utils/profile-compat'; +import { + CLAUDE_EXTENSION_HOSTS, + type ClaudeExtensionHost, +} from '../shared/claude-extension-hosts'; +import { + renderClaudeExtensionSettingsJson, + resolveClaudeExtensionSetup, +} from '../shared/claude-extension-setup'; type ShellType = 'bash' | 'fish' | 'powershell'; -type OutputFormat = 'openai' | 'anthropic' | 'raw'; +type OutputFormat = 'openai' | 'anthropic' | 'raw' | 'claude-extension'; -const VALID_FORMATS: OutputFormat[] = ['openai', 'anthropic', 'raw']; +const VALID_FORMATS: OutputFormat[] = ['openai', 'anthropic', 'raw', 'claude-extension']; const VALID_SHELLS: ShellType[] = ['bash', 'fish', 'powershell']; const VALID_SHELL_INPUTS = ['auto', 'bash', 'zsh', 'fish', 'powershell'] as const; +const VALID_EXTENSION_HOSTS = CLAUDE_EXTENSION_HOSTS.map((host) => host.id); const VALID_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; /** Auto-detect shell from environment */ @@ -97,46 +98,6 @@ export function findProfile(args: string[], flagsWithValues: string[]): string | return undefined; } -/** Check if a profile is a CLIProxy profile */ -function isCLIProxyProfile(name: string): boolean { - return (CLIPROXY_PROFILES as readonly string[]).includes(name); -} - -/** Resolve env vars for settings-based profiles (glm, km, custom API profiles) */ -function resolveSettingsProfile(profileName: string): Record | null { - if (!isUnifiedMode()) return null; - - const config = loadUnifiedConfig(); - if (!config) return null; - - // Check unified config profiles section (supports compatibility aliases, e.g. km -> kimi) - let profileConfig: { type?: string; settings?: string } | undefined; - for (const candidate of getProfileLookupCandidates(profileName)) { - const candidateConfig = config.profiles?.[candidate]; - if (candidateConfig) { - profileConfig = candidateConfig; - break; - } - } - if (!profileConfig) return null; - - if (profileConfig.type !== 'api') { - console.error( - fail( - `Profile '${profileName}' is type '${profileConfig.type}', not a settings-based API profile.` - ) - ); - process.exit(1); - } - - if (profileConfig.settings) { - const settingsPath = expandPath(profileConfig.settings); - return loadSettingsFromFile(settingsPath); - } - - return {}; -} - /** Show help for env command */ function showHelp(): void { console.log(''); @@ -151,11 +112,14 @@ function showHelp(): void { console.log(subheader('Options:')); console.log( - ` ${color('--format', 'command')} Output format: openai, anthropic, raw ${dim('(default: anthropic)')}` + ` ${color('--format', 'command')} Output format: openai, anthropic, raw, claude-extension ${dim('(default: anthropic)')}` ); console.log( ` ${color('--shell', 'command')} Shell syntax: auto, bash/zsh, fish, powershell ${dim('(default: auto)')}` ); + console.log( + ` ${color('--ide', 'command')} Claude extension host: ${VALID_EXTENSION_HOSTS.join(', ')} ${dim('(default: vscode)')}` + ); console.log(` ${color('--help, -h', 'command')} Show this help message`); console.log(''); @@ -167,6 +131,9 @@ function showHelp(): void { ` ${color('anthropic', 'command')} ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_MODEL` ); console.log(` ${color('raw', 'command')} All effective env vars as-is`); + console.log( + ` ${color('claude-extension', 'command')} Settings JSON snippet for the Claude IDE extension` + ); console.log(''); console.log(subheader('Examples:')); @@ -182,6 +149,16 @@ function showHelp(): void { console.log( ` $ ${color('ccs env agy --format openai --shell fish', 'command')} ${dim('# Fish shell syntax')}` ); + console.log( + ` $ ${color('ccs env work --format claude-extension --ide vscode', 'command')} ${dim('# VS Code/Cursor snippet')}` + ); + console.log( + ` $ ${color('ccs env default --format claude-extension --ide windsurf', 'command')} ${dim('# Clear/replace Windsurf env overrides')}` + ); + console.log(''); + console.log(subheader('Notes:')); + console.log(` ${dim('- Use ccs persist for shared ~/.claude/settings.json setup when possible.')}`); + console.log(` ${dim('- claude-extension output prints JSON only; replace the full environmentVariables setting.')}`); console.log(''); } @@ -198,10 +175,10 @@ export async function handleEnvCommand(args: string[]): Promise { } // Parse profile (first positional argument, skipping flag values) - const flagsWithValues = ['format', 'shell']; + const flagsWithValues = ['format', 'shell', 'ide']; const profile = findProfile(args, flagsWithValues); if (!profile) { - console.error(fail('Usage: ccs env [--format openai|anthropic|raw]')); + console.error(fail('Usage: ccs env [--format openai|anthropic|raw|claude-extension]')); process.exit(1); } @@ -220,47 +197,24 @@ export async function handleEnvCommand(args: string[]): Promise { } // zsh uses the same syntax as bash const shell = detectShell(shellStr === 'zsh' ? 'bash' : shellStr); + const ide = (parseFlag(args, 'ide') || 'vscode') as ClaudeExtensionHost; + if (!VALID_EXTENSION_HOSTS.includes(ide)) { + console.error(fail(`Invalid IDE host: ${ide}. Use: ${VALID_EXTENSION_HOSTS.join(', ')}`)); + process.exit(1); + } - // Resolve env vars based on profile type - let envVars: Record = {}; - - if (isCLIProxyProfile(profile)) { - // CLIProxy profile (gemini, codex, agy, etc.) - const provider = profile as CLIProxyProvider; - const resolved = getEffectiveEnvVars(provider, CLIPROXY_DEFAULT_PORT); - // Convert NodeJS.ProcessEnv to Record - for (const [k, v] of Object.entries(resolved)) { - if (v !== undefined) envVars[k] = v; + let envVars: Record; + try { + const resolved = await resolveClaudeExtensionSetup(profile); + envVars = resolved.extensionEnv; + if (format === 'claude-extension') { + console.log(renderClaudeExtensionSettingsJson(resolved, ide)); + return; } - } else { - // Settings-based profile (glm, km, custom API) - const resolved = resolveSettingsProfile(profile); - if (!resolved) { - // Check if it's an account-based profile - const registry = new ProfileRegistry(); - const allProfiles = registry.getAllProfiles(); - if (allProfiles[profile]) { - console.error( - fail( - `'${profile}' is an account-based profile. ` + - '`ccs env` only supports CLIProxy and settings profiles.' - ) - ); - process.exit(1); - } - - console.error(fail(`Profile '${profile}' not found.`)); - console.error(dim(' Available CLIProxy profiles: ' + CLIPROXY_PROFILES.join(', '))); - if (!isUnifiedMode()) { - console.error( - dim(' Settings profiles require unified config. Run `ccs migrate` to upgrade.') - ); - } else { - console.error(dim(` Check ${getCcsDir()}/config.yaml for custom profiles.`)); - } - process.exit(1); - } - envVars = resolved; + } catch (error) { + console.error(fail((error as Error).message)); + console.error(dim(` Check ${getCcsDir()}/config.yaml or run ccs config for profile setup.`)); + process.exit(1); } if (Object.keys(envVars).length === 0) { diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index cbe44ebb..6eb794c0 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -305,7 +305,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs setup', 'First-time setup wizard'], ['ccs doctor', 'Run health check and diagnostics'], ['ccs cleanup', 'Remove old CLIProxy logs'], - ['ccs config', 'Open web configuration dashboard'], + ['ccs config', 'Open web dashboard (includes Claude IDE Extension setup page)'], ['ccs config auth setup', 'Configure dashboard login'], ['ccs config auth show', 'Show dashboard auth status'], ['ccs config image-analysis', 'Show image analysis settings'], @@ -314,7 +314,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs config thinking --mode auto', 'Set thinking mode'], ['ccs config thinking --clear-provider-override codex', 'Clear provider overrides'], ['ccs config --port 3000', 'Use specific port'], - ['ccs persist ', 'Write profile env to ~/.claude/settings.json'], + ['ccs persist ', 'Write profile setup to ~/.claude/settings.json'], ['ccs persist --list-backups', 'List available settings.json backups'], ['ccs persist --restore', 'Restore settings.json from latest backup'], ['ccs sync', 'Sync delegation commands and skills'], @@ -329,6 +329,14 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs env --format openai', 'OpenAI-compatible vars (OpenCode/Cursor)'], ['ccs env --format anthropic', 'Anthropic vars (default)'], ['ccs env --format raw', 'All effective env vars'], + [ + 'ccs env --format claude-extension --ide vscode', + 'VS Code/Cursor Claude extension settings JSON', + ], + [ + 'ccs env --format claude-extension --ide windsurf', + 'Windsurf Claude extension settings JSON', + ], ['ccs env --shell fish', 'Fish shell syntax'], ]); diff --git a/src/commands/persist-command.ts b/src/commands/persist-command.ts index 1f157a80..014f7f79 100644 --- a/src/commands/persist-command.ts +++ b/src/commands/persist-command.ts @@ -1,11 +1,11 @@ /** * Persist Command Handler * - * Writes a profile's environment variables to ~/.claude/settings.json - * for native Claude Code usage (IDEs, extensions, etc.). + * Writes a profile's Claude setup to ~/.claude/settings.json + * for native Claude Code usage across the CLI and IDE extension. * - * Supports all profile types: API, CLIProxy, Copilot. - * Account-based profiles are not supported (use CLAUDE_CONFIG_DIR). + * Supports API, CLIProxy, Copilot, account, and default flows + * through the shared Claude extension setup resolver. */ import * as fs from 'fs'; @@ -14,16 +14,10 @@ import * as os from 'os'; import * as lockfile from 'proper-lockfile'; import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../utils/ui'; import { InteractivePrompt } from '../utils/prompt'; -import ProfileDetector, { - ProfileDetectionResult, - loadSettingsFromFile, - CLIPROXY_PROFILES, -} from '../auth/profile-detector'; -import { getEffectiveEnvVars, CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator'; -import { generateCopilotEnv } from '../copilot/copilot-executor'; -import { expandPath } from '../utils/helpers'; +import ProfileDetector from '../auth/profile-detector'; import { getClaudeConfigDir, getClaudeSettingsPath } from '../utils/claude-config-path'; import { extractOption, hasAnyFlag } from './arg-extractor'; +import { resolveClaudeExtensionSetup } from '../shared/claude-extension-setup'; interface PersistCommandArgs { profile?: string; @@ -37,8 +31,10 @@ interface PersistCommandArgs { interface ResolvedEnv { env: Record; + clearEnvKeys: string[]; profileType: string; - warning?: string; + warnings?: string[]; + notes?: string[]; } const PERSIST_KNOWN_FLAGS = [ @@ -513,68 +509,24 @@ function isSensitiveEnvKey(key: string): boolean { ); } -/** Resolve env vars for a profile */ -async function resolveProfileEnvVars( - profileName: string, - profileResult: ProfileDetectionResult -): Promise { - switch (profileResult.type) { - case 'settings': { - // API profile - load from settings file - let env: Record = {}; - if (profileResult.env) { - env = profileResult.env; - } else if (profileResult.settingsPath) { - env = loadSettingsFromFile(expandPath(profileResult.settingsPath)); - } - if (Object.keys(env).length === 0) { - throw new Error(`Profile '${profileName}' has no env vars configured`); - } - return { env, profileType: 'API' }; - } - case 'cliproxy': { - // CLIProxy profile - generate env vars - const provider = - profileResult.provider || (profileName as (typeof CLIPROXY_PROFILES)[number]); - const port = profileResult.port || CLIPROXY_DEFAULT_PORT; - const env = getEffectiveEnvVars(provider, port, profileResult.settingsPath) as Record< - string, - string - >; - return { - env, - profileType: 'CLIProxy', - warning: 'CLIProxy must be running for this profile to work', - }; - } - case 'copilot': { - // Copilot profile - generate env vars - if (!profileResult.copilotConfig) { - throw new Error('Copilot configuration not found'); - } - const env = generateCopilotEnv(profileResult.copilotConfig); - return { - env, - profileType: 'Copilot', - warning: 'copilot-api daemon must be running for this profile to work', - }; - } - case 'account': { - throw new Error( - `Account profiles use CLAUDE_CONFIG_DIR isolation, not env vars.\n` + - `Use 'ccs ${profileName}' to run with this profile instead.` - ); - } - case 'default': { - throw new Error( - 'Default profile has no env vars to persist.\n' + - 'Specify a profile name: ccs persist ' - ); - } - default: { - throw new Error(`Unknown profile type: ${profileResult.type}`); - } - } +/** Resolve shared Claude settings payload for a profile */ +async function resolveProfileEnvVars(profileName: string): Promise { + const setup = await resolveClaudeExtensionSetup(profileName); + const typeLabel: Record = { + settings: 'API', + cliproxy: 'CLIProxy', + copilot: 'Copilot', + account: 'Account', + default: 'Default', + }; + + return { + env: setup.extensionEnv, + clearEnvKeys: setup.removeEnvKeys, + profileType: typeLabel[setup.profileType] ?? setup.profileType, + warnings: setup.warnings, + notes: setup.notes, + }; } /** Handle --list-backups flag */ @@ -702,11 +654,11 @@ async function showHelp(): Promise { console.log(` ${color('ccs persist', 'command')} --restore [timestamp]`); console.log(''); console.log(subheader('Description')); - console.log(" Writes a profile's environment variables directly to"); + console.log(" Writes a profile's Claude setup directly to"); console.log(` ${getClaudeSettingsDisplayPath()} for native Claude Code usage.`); console.log(''); - console.log(' This allows Claude Code to use the profile without CCS,'); - console.log(' enabling compatibility with IDEs and extensions.'); + console.log(' This is the preferred shared-settings path for Claude Code'); + console.log(' and the Claude IDE extension when you want one profile everywhere.'); console.log(''); console.log(subheader('Options')); console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts (auto-backup)`); @@ -730,7 +682,12 @@ async function showHelp(): Promise { console.log(` ${color('API profiles', 'command')} glm, glmt, km, custom API profiles`); console.log(` ${color('CLIProxy', 'command')} gemini, codex, agy, qwen, kiro, ghcp`); console.log(` ${color('Copilot', 'command')} copilot (requires copilot-api daemon)`); - console.log(` ${dim('Account-based')} Not supported (uses CLAUDE_CONFIG_DIR)`); + console.log( + ` ${color('Account profiles', 'command')} work, personal, client (persists CLAUDE_CONFIG_DIR)` + ); + console.log( + ` ${color('default', 'command')} Clears CCS-managed overrides or inherits mapped continuity` + ); console.log(''); console.log(subheader('Examples')); console.log(` ${dim('# Persist GLM profile')}`); @@ -745,6 +702,12 @@ async function showHelp(): Promise { console.log(` ${dim('# Persist with auto-approve enabled')}`); console.log(` ${color('ccs persist codex --dangerously-skip-permissions', 'command')}`); console.log(''); + console.log(` ${dim('# Persist an account profile for IDE/native Claude use')}`); + console.log(` ${color('ccs persist work --yes', 'command')}`); + console.log(''); + console.log(` ${dim('# Reset to native Claude defaults (clear CCS-managed overrides)')}`); + console.log(` ${color('ccs persist default --yes', 'command')}`); + console.log(''); console.log(` ${dim('# List all backups')}`); console.log(` ${color('ccs persist --list-backups', 'command')}`); console.log(''); @@ -757,6 +720,10 @@ async function showHelp(): Promise { console.log(subheader('Notes')); console.log(' [i] CLIProxy profiles require the proxy to be running.'); console.log(' [i] Copilot profiles require copilot-api daemon.'); + console.log(' [i] Account/default flows remove stale ANTHROPIC_* overrides before applying new setup.'); + console.log( + ' [i] For IDE-local settings.json snippets, use: ccs env --format claude-extension' + ); console.log( ` [i] Backups are saved as ${getClaudeSettingsDisplayPath()}.backup.YYYYMMDD_HHMMSS` ); @@ -798,9 +765,8 @@ export async function handlePersistCommand(args: string[]): Promise { } // Detect profile const detector = new ProfileDetector(); - let profileResult: ProfileDetectionResult; try { - profileResult = detector.detectProfileType(parsedArgs.profile); + detector.detectProfileType(parsedArgs.profile); } catch (error) { const err = error as Error & { availableProfiles?: string }; console.log(fail(`Profile not found: ${parsedArgs.profile}`)); @@ -813,7 +779,7 @@ export async function handlePersistCommand(args: string[]): Promise { // Resolve env vars let resolved: ResolvedEnv; try { - resolved = await resolveProfileEnvVars(parsedArgs.profile, profileResult); + resolved = await resolveProfileEnvVars(parsedArgs.profile); } catch (error) { console.log(fail((error as Error).message)); process.exit(1); @@ -823,21 +789,27 @@ export async function handlePersistCommand(args: string[]): Promise { console.log(''); console.log(`Profile type: ${color(resolved.profileType, 'command')}`); console.log(''); - console.log(`The following env vars will be written to ${getClaudeSettingsDisplayPath()}:`); - console.log(''); - // Display env vars (mask sensitive values) const envKeys = Object.keys(resolved.env); - if (envKeys.length === 0) { - console.log(fail('Profile has no environment variables to persist')); - process.exit(1); + if (envKeys.length > 0) { + console.log(`The following env vars will be written to ${getClaudeSettingsDisplayPath()}:`); + console.log(''); + const maxKeyLen = Math.max(...envKeys.map((k) => k.length)); + for (const [key, value] of Object.entries(resolved.env)) { + const paddedKey = key.padEnd(maxKeyLen + 2); + const displayValue = isSensitiveEnvKey(key) ? maskApiKey(value) : value; + console.log(` ${color(paddedKey, 'command')} = ${displayValue}`); + } + console.log(''); + } else { + console.log(info('No new env vars will be added.')); + console.log(dim(' CCS-managed transport overrides will be removed if present.')); + console.log(''); } - const maxKeyLen = Math.max(...envKeys.map((k) => k.length)); - for (const [key, value] of Object.entries(resolved.env)) { - const paddedKey = key.padEnd(maxKeyLen + 2); - const displayValue = isSensitiveEnvKey(key) ? maskApiKey(value) : value; - console.log(` ${color(paddedKey, 'command')} = ${displayValue}`); + if (resolved.clearEnvKeys.length > 0) { + console.log('Managed env keys replaced/cleared on write:'); + console.log(` ${dim(resolved.clearEnvKeys.join(', '))}`); + console.log(''); } - console.log(''); if (resolvedPermissionMode) { console.log(`Default permission mode: ${color(resolvedPermissionMode, 'command')}`); if (resolvedPermissionMode === 'bypassPermissions') { @@ -845,14 +817,22 @@ export async function handlePersistCommand(args: string[]): Promise { } console.log(''); } - // Show warning if applicable - if (resolved.warning) { - console.log(warn(resolved.warning)); + if (resolved.warnings?.length) { + for (const message of resolved.warnings) { + console.log(warn(message)); + } + console.log(''); + } + if (resolved.notes?.length) { + for (const note of resolved.notes) { + console.log(info(note)); + } console.log(''); } // Warning about modification console.log(warn(`This will modify ${getClaudeSettingsDisplayPath()}`)); console.log(dim(' Existing hooks and other settings will be preserved.')); + console.log(dim(' Existing managed profile env keys will be replaced to avoid stale routing.')); console.log(''); // Check if settings.json exists for backup const settingsPath = getClaudeSettingsPath(); @@ -904,10 +884,15 @@ export async function handlePersistCommand(args: string[]): Promise { } } + const preservedEnv = { ...existingEnv }; + for (const key of resolved.clearEnvKeys) { + delete preservedEnv[key]; + } + const mergedSettings: Record = { ...existingSettings, env: { - ...existingEnv, + ...preservedEnv, ...resolved.env, }, }; diff --git a/src/shared/claude-extension-hosts.ts b/src/shared/claude-extension-hosts.ts new file mode 100644 index 00000000..ea8e21b5 --- /dev/null +++ b/src/shared/claude-extension-hosts.ts @@ -0,0 +1,42 @@ +export type ClaudeExtensionHost = 'vscode' | 'cursor' | 'windsurf'; + +export interface ClaudeExtensionHostDefinition { + id: ClaudeExtensionHost; + label: string; + settingsKey: string; + disableLoginPromptKey?: string; + settingsTargetLabel: string; + description: string; +} + +export const CLAUDE_EXTENSION_HOSTS: ClaudeExtensionHostDefinition[] = [ + { + id: 'vscode', + label: 'VS Code', + settingsKey: 'claudeCode.environmentVariables', + disableLoginPromptKey: 'claudeCode.disableLoginPrompt', + settingsTargetLabel: 'VS Code user or workspace settings.json', + description: 'Official Anthropic VS Code extension with camelCase settings keys.', + }, + { + id: 'cursor', + label: 'Cursor', + settingsKey: 'claudeCode.environmentVariables', + disableLoginPromptKey: 'claudeCode.disableLoginPrompt', + settingsTargetLabel: 'Cursor user or workspace settings.json', + description: 'VS Code-compatible host using the Anthropic extension schema.', + }, + { + id: 'windsurf', + label: 'Windsurf', + settingsKey: 'claude-code.environmentVariables', + settingsTargetLabel: 'Windsurf user settings.json', + description: 'Current Windsurf Anthropic extension build uses legacy kebab-case keys.', + }, +]; + +export function getClaudeExtensionHostDefinition( + host: ClaudeExtensionHost = 'vscode' +): ClaudeExtensionHostDefinition { + return CLAUDE_EXTENSION_HOSTS.find((candidate) => candidate.id === host) ?? CLAUDE_EXTENSION_HOSTS[0]; +} diff --git a/src/shared/claude-extension-setup.ts b/src/shared/claude-extension-setup.ts new file mode 100644 index 00000000..c248ca7f --- /dev/null +++ b/src/shared/claude-extension-setup.ts @@ -0,0 +1,233 @@ +import { loadSettingsFromFile, type ProfileType } from '../auth/profile-detector'; +import ProfileDetector from '../auth/profile-detector'; +import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance'; +import { resolveAccountContextPolicy, isAccountContextMetadata } from '../auth/account-context'; +import type { ProfileDetectionResult } from '../auth/profile-detector'; +import { getEffectiveEnvVars, getRemoteEnvVars, getCompositeEnvVars } from '../cliproxy/config/env-builder'; +import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; +import { getProxyTarget } from '../cliproxy/proxy-target-resolver'; +import { generateCopilotEnv } from '../copilot/copilot-executor'; +import InstanceManager from '../management/instance-manager'; +import { expandPath } from '../utils/helpers'; +import { getClaudeSettingsPath } from '../utils/claude-config-path'; +import { + type ClaudeExtensionHost, + type ClaudeExtensionHostDefinition, + getClaudeExtensionHostDefinition, +} from './claude-extension-hosts'; + +export interface ClaudeExtensionProfileOption { + name: string; + profileType: ProfileType; + label: string; + description: string; +} + +export interface ClaudeExtensionSetup { + requestedProfile: string; + resolvedProfileName: string; + profileType: ProfileType; + profileLabel: string; + profileDescription: string; + extensionEnv: Record; + removeEnvKeys: string[]; + warnings: string[]; + notes: string[]; + disableLoginPrompt: boolean; +} + +export const CLAUDE_EXTENSION_MANAGED_ENV_KEYS = [ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_MODEL', + 'ANTHROPIC_SMALL_FAST_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'CLAUDE_CONFIG_DIR', + 'DISABLE_NON_ESSENTIAL_MODEL_CALLS', + 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', +] as const; + +function sortEnvRecord(env: NodeJS.ProcessEnv | Record): Record { + const normalized: Record = {}; + + for (const [key, value] of Object.entries(env)) { + if (typeof value === 'string') { + normalized[key] = value; + } + } + + return Object.fromEntries( + Object.entries(normalized).sort(([left], [right]) => left.localeCompare(right)) + ); +} + +function describeProfile(profileName: string, result: ProfileDetectionResult): string { + if (profileName === 'default') { + return result.name === 'default' + ? 'Use Claude Code defaults with no CCS-specific transport override.' + : `Use the current default profile resolution for "${result.name}".`; + } + if (result.type === 'cliproxy') return 'OAuth or CLIProxy-backed profile for Anthropic-compatible routing.'; + if (result.type === 'settings') return 'API profile backed by a CCS settings file.'; + if (result.type === 'account') return 'Claude account instance isolated through CLAUDE_CONFIG_DIR.'; + if (result.type === 'copilot') return 'GitHub Copilot profile routed through copilot-api.'; + return 'Native Claude profile resolution.'; +} + +function createProfileOption(profileName: string, result: ProfileDetectionResult): ClaudeExtensionProfileOption { + return { + name: profileName, + profileType: result.type, + label: profileName === 'default' ? 'default' : result.name, + description: describeProfile(profileName, result), + }; +} + +export function listClaudeExtensionProfiles(): ClaudeExtensionProfileOption[] { + const detector = new ProfileDetector(); + const all = detector.getAllProfiles(); + const orderedNames = ['default', ...all.accounts, ...all.settings, ...all.cliproxy, ...all.cliproxyVariants]; + const deduped = [...new Set(orderedNames)]; + try { + detector.detectProfileType('copilot'); + deduped.push('copilot'); + } catch { + // Copilot disabled; skip from setup UI. + } + + return deduped + .map((profileName) => createProfileOption(profileName, detector.detectProfileType(profileName))) + .sort((left, right) => deduped.indexOf(left.name) - deduped.indexOf(right.name)); +} + +async function resolveExtensionEnv( + requestedProfile: string, + result: ProfileDetectionResult +): Promise> { + const warnings: string[] = []; + const notes: string[] = []; + const requestedIsDefault = requestedProfile === 'default'; + + if (result.type === 'account') { + const instanceManager = new InstanceManager(); + const policy = resolveAccountContextPolicy( + isAccountContextMetadata(result.profile) ? result.profile : undefined + ); + const instancePath = await instanceManager.ensureInstance(result.name, policy, { + bare: result.profile?.bare === true, + }); + notes.push('Account profiles authenticate through the isolated Claude config directory.'); + return { + extensionEnv: { CLAUDE_CONFIG_DIR: instancePath }, + warnings, + notes, + disableLoginPrompt: false, + }; + } + + if (result.type === 'default') { + const continuity = await resolveProfileContinuityInheritance({ + profileName: requestedProfile, + profileType: result.type, + target: 'claude', + }); + if (continuity.claudeConfigDir) { + notes.push(`Default profile inherits continuity from account "${continuity.sourceAccount}".`); + return { + extensionEnv: { CLAUDE_CONFIG_DIR: continuity.claudeConfigDir }, + warnings, + notes, + disableLoginPrompt: false, + }; + } + + notes.push('Default profile clears CCS-managed transport overrides and uses native Claude defaults.'); + return { extensionEnv: {}, warnings, notes, disableLoginPrompt: false }; + } + + const continuity = await resolveProfileContinuityInheritance({ + profileName: requestedProfile, + profileType: result.type, + target: 'claude', + }); + const env = + result.type === 'settings' + ? result.env ?? (result.settingsPath ? loadSettingsFromFile(expandPath(result.settingsPath)) : {}) + : result.type === 'copilot' + ? generateCopilotEnv(result.copilotConfig!, continuity.claudeConfigDir) + : (() => { + const proxyTarget = getProxyTarget(); + const port = result.port || CLIPROXY_DEFAULT_PORT; + if (proxyTarget.isRemote) { + warnings.push(`CLIProxy is configured for remote routing via ${proxyTarget.protocol}://${proxyTarget.host}:${proxyTarget.port}.`); + return result.isComposite && result.compositeTiers && result.compositeDefaultTier + ? getCompositeEnvVars(result.compositeTiers, result.compositeDefaultTier, port, result.settingsPath, proxyTarget) + : getRemoteEnvVars(result.provider!, proxyTarget, result.settingsPath); + } + warnings.push('CLIProxy-backed profiles require the local or remote proxy endpoint to be reachable.'); + return result.isComposite && result.compositeTiers && result.compositeDefaultTier + ? getCompositeEnvVars(result.compositeTiers, result.compositeDefaultTier, port, result.settingsPath) + : getEffectiveEnvVars(result.provider!, port, result.settingsPath); + })(); + + if (!requestedIsDefault && continuity.claudeConfigDir && !env.CLAUDE_CONFIG_DIR) { + env.CLAUDE_CONFIG_DIR = continuity.claudeConfigDir; + notes.push(`Continuity inheritance adds CLAUDE_CONFIG_DIR from account "${continuity.sourceAccount}".`); + } + if (result.type === 'copilot') { + warnings.push('copilot-api must stay reachable for this profile to work inside the IDE extension.'); + } + if (Object.keys(env).length === 0) { + throw new Error(`Profile "${requestedProfile}" has no extension environment to export.`); + } + + return { extensionEnv: sortEnvRecord(env), warnings, notes, disableLoginPrompt: true }; +} + +export async function resolveClaudeExtensionSetup(requestedProfile: string): Promise { + const detector = new ProfileDetector(); + const result = detector.detectProfileType(requestedProfile); + const resolved = await resolveExtensionEnv(requestedProfile, result); + + return { + requestedProfile, + resolvedProfileName: result.name, + profileType: result.type, + profileLabel: requestedProfile === 'default' ? 'default' : result.name, + profileDescription: describeProfile(requestedProfile, result), + extensionEnv: resolved.extensionEnv, + removeEnvKeys: [...CLAUDE_EXTENSION_MANAGED_ENV_KEYS], + warnings: resolved.warnings, + notes: resolved.notes, + disableLoginPrompt: resolved.disableLoginPrompt, + }; +} + +export function renderClaudeExtensionSettingsJson( + setup: ClaudeExtensionSetup, + host: ClaudeExtensionHost +): string { + const definition = getClaudeExtensionHostDefinition(host); + const payload: Record = { + [definition.settingsKey]: Object.entries(setup.extensionEnv).map(([name, value]) => ({ name, value })), + }; + if (definition.disableLoginPromptKey && setup.disableLoginPrompt) { + payload[definition.disableLoginPromptKey] = true; + } + return JSON.stringify(payload, null, 2); +} + +export function renderSharedClaudeSettingsJson(setup: ClaudeExtensionSetup): string { + return JSON.stringify({ env: setup.extensionEnv }, null, 2); +} + +export function getClaudeExtensionHostMetadata(host: ClaudeExtensionHost): ClaudeExtensionHostDefinition { + return getClaudeExtensionHostDefinition(host); +} + +export function getClaudeSharedSettingsPath(): string { + return getClaudeSettingsPath(); +} diff --git a/src/web-server/routes/claude-extension-routes.ts b/src/web-server/routes/claude-extension-routes.ts new file mode 100644 index 00000000..d67fe465 --- /dev/null +++ b/src/web-server/routes/claude-extension-routes.ts @@ -0,0 +1,73 @@ +import { Router, Request, Response } from 'express'; +import { + CLAUDE_EXTENSION_HOSTS, + type ClaudeExtensionHost, + getClaudeExtensionHostDefinition, +} from '../../shared/claude-extension-hosts'; +import { + getClaudeSharedSettingsPath, + listClaudeExtensionProfiles, + renderClaudeExtensionSettingsJson, + renderSharedClaudeSettingsJson, + resolveClaudeExtensionSetup, +} from '../../shared/claude-extension-setup'; + +const router = Router(); +const VALID_HOSTS = new Set(CLAUDE_EXTENSION_HOSTS.map((host) => host.id)); + +function getHostFromRequest(req: Request): ClaudeExtensionHost { + const rawHost = String(req.query.host || 'vscode'); + if (!VALID_HOSTS.has(rawHost as ClaudeExtensionHost)) { + throw new Error(`Invalid host "${rawHost}". Use: ${CLAUDE_EXTENSION_HOSTS.map((host) => host.id).join(', ')}`); + } + return rawHost as ClaudeExtensionHost; +} + +router.get('/profiles', (_req: Request, res: Response): void => { + res.json({ + profiles: listClaudeExtensionProfiles(), + hosts: CLAUDE_EXTENSION_HOSTS, + }); +}); + +router.get('/setup', async (req: Request, res: Response): Promise => { + const rawProfile = typeof req.query.profile === 'string' ? req.query.profile.trim() : ''; + if (!rawProfile) { + res.status(400).json({ error: 'Missing required query parameter: profile' }); + return; + } + + try { + const host = getHostFromRequest(req); + const setup = await resolveClaudeExtensionSetup(rawProfile); + const hostDefinition = getClaudeExtensionHostDefinition(host); + + res.json({ + profile: { + requestedProfile: setup.requestedProfile, + resolvedProfileName: setup.resolvedProfileName, + profileType: setup.profileType, + label: setup.profileLabel, + description: setup.profileDescription, + }, + host: hostDefinition, + env: Object.entries(setup.extensionEnv).map(([name, value]) => ({ name, value })), + warnings: setup.warnings, + notes: setup.notes, + removeEnvKeys: setup.removeEnvKeys, + sharedSettings: { + path: getClaudeSharedSettingsPath(), + command: `ccs persist ${rawProfile}`, + json: renderSharedClaudeSettingsJson(setup), + }, + ideSettings: { + targetLabel: hostDefinition.settingsTargetLabel, + json: renderClaudeExtensionSettingsJson(setup, host), + }, + }); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 09097156..9fafe3f7 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -27,6 +27,7 @@ import cliproxyServerRoutes from './proxy-routes'; import authRoutes from './auth-routes'; import persistRoutes from './persist-routes'; import catalogRoutes from './catalog-routes'; +import claudeExtensionRoutes from './claude-extension-routes'; // Create the main API router export const apiRoutes = Router(); @@ -49,6 +50,7 @@ apiRoutes.use('/auth', authRoutes); // ==================== Persist (Backup Management) ==================== apiRoutes.use('/persist', persistRoutes); +apiRoutes.use('/claude-extension', claudeExtensionRoutes); // ==================== CLIProxy ==================== // Variants, auth, accounts, stats, status, models, error logs diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 285e6585..27cd4de6 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -40,4 +40,22 @@ describe('help command parity', () => { expect(rendered.includes('ccs llamacpp')).toBe(true); expect(rendered.includes('http://127.0.0.1:8080')).toBe(true); }); + + test('root help documents Claude IDE extension setup surfaces', async () => { + const lines: string[] = []; + console.log = (...args: unknown[]) => { + lines.push(args.map((arg) => String(arg)).join(' ')); + }; + + await handleHelpCommand(); + + const rendered = stripAnsi(lines.join('\n')); + expect(rendered.includes('Claude IDE Extension setup page')).toBe(true); + expect(rendered.includes('ccs env --format claude-extension --ide vscode')).toBe( + true + ); + expect(rendered.includes('ccs env --format claude-extension --ide windsurf')).toBe( + true + ); + }); }); diff --git a/tests/unit/commands/persist-command-handler.test.ts b/tests/unit/commands/persist-command-handler.test.ts index d285a59b..a298c09d 100644 --- a/tests/unit/commands/persist-command-handler.test.ts +++ b/tests/unit/commands/persist-command-handler.test.ts @@ -4,6 +4,8 @@ import * as os from 'os'; import * as path from 'path'; import * as lockfile from 'proper-lockfile'; import { handlePersistCommand } from '../../../src/commands/persist-command'; +import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types'; +import { saveUnifiedConfig } from '../../../src/config/unified-config-loader'; interface RestoreFixture { claudeDir: string; @@ -16,6 +18,7 @@ interface RestoreFixture { let tempRoot: string; let originalClaudeConfigDir: string | undefined; +let originalCcsHome: string | undefined; let originalProcessExit: typeof process.exit; let originalFsOpen: typeof fs.promises.open; let originalFsRename: typeof fs.promises.rename; @@ -66,9 +69,11 @@ function stubProcessExit(): void { beforeEach(async () => { tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ccs-persist-handler-test-')); originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + originalCcsHome = process.env.CCS_HOME; originalProcessExit = process.exit; originalFsOpen = fs.promises.open; originalFsRename = fs.promises.rename; + process.env.CCS_HOME = tempRoot; }); afterEach(async () => { @@ -82,6 +87,12 @@ afterEach(async () => { process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; } + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + if (tempRoot) { await fs.promises.rm(tempRoot, { recursive: true, force: true }); } @@ -271,3 +282,105 @@ describe('persist command restore failure handling', () => { } }); }); + +describe('persist command Claude extension parity', () => { + async function writeUnifiedConfig(): Promise { + const config = createEmptyUnifiedConfig(); + config.accounts.work = { + created: '2026-03-15T00:00:00.000Z', + last_used: null, + context_mode: 'isolated', + }; + config.default = 'work'; + config.profiles.glm = { + type: 'api', + settings: path.join(tempRoot, '.ccs', 'glm.settings.json'), + }; + + await fs.promises.mkdir(path.join(tempRoot, '.ccs'), { recursive: true }); + await fs.promises.writeFile( + path.join(tempRoot, '.ccs', 'glm.settings.json'), + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.example.test', + ANTHROPIC_API_KEY: 'sk-ant-test-123456', + ANTHROPIC_MODEL: 'claude-sonnet-4-5', + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + saveUnifiedConfig(config); + } + + it('persists account profiles via CLAUDE_CONFIG_DIR and clears stale managed env keys', async () => { + await writeUnifiedConfig(); + + const settingsPath = path.join(tempRoot, '.claude', 'settings.json'); + await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true }); + await fs.promises.writeFile( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_API_KEY: 'stale-key', + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', + KEEP_ME: 'still-here', + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + await handlePersistCommand(['work', '--yes']); + + const persisted = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as { + env: Record; + }; + + expect(persisted.env.KEEP_ME).toBe('still-here'); + expect(persisted.env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(persisted.env.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(persisted.env.CLAUDE_CONFIG_DIR).toBe(path.join(tempRoot, '.ccs', 'instances', 'work')); + expect(fs.existsSync(persisted.env.CLAUDE_CONFIG_DIR)).toBe(true); + }); + + it('persists default profile using mapped account continuity and preserves unrelated env', async () => { + await writeUnifiedConfig(); + + const settingsPath = path.join(tempRoot, '.claude', 'settings.json'); + await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true }); + await fs.promises.writeFile( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_AUTH_TOKEN: 'stale-token', + ANTHROPIC_MODEL: 'stale-model', + KEEP_ME: 'still-here', + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + await handlePersistCommand(['default', '--yes']); + + const persisted = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as { + env: Record; + }; + + expect(persisted.env.KEEP_ME).toBe('still-here'); + expect(persisted.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(persisted.env.ANTHROPIC_MODEL).toBeUndefined(); + expect(persisted.env.CLAUDE_CONFIG_DIR).toBe(path.join(tempRoot, '.ccs', 'instances', 'work')); + expect(fs.existsSync(persisted.env.CLAUDE_CONFIG_DIR)).toBe(true); + }); +}); diff --git a/tests/unit/web-server/claude-extension-routes.test.ts b/tests/unit/web-server/claude-extension-routes.test.ts new file mode 100644 index 00000000..d30627e8 --- /dev/null +++ b/tests/unit/web-server/claude-extension-routes.test.ts @@ -0,0 +1,142 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Server } from 'http'; +import claudeExtensionRoutes from '../../../src/web-server/routes/claude-extension-routes'; +import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types'; +import { saveUnifiedConfig } from '../../../src/config/unified-config-loader'; + +describe('web-server claude-extension-routes', () => { + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/claude-extension', claudeExtensionRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const handleError = (error: Error) => reject(error); + server.once('error', handleError); + server.once('listening', () => { + server.off('error', handleError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-claude-extension-routes-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'glm.settings.json'), + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.example.test', + ANTHROPIC_API_KEY: 'sk-ant-test-123456', + ANTHROPIC_MODEL: 'claude-sonnet-4-5', + }, + }, + null, + 2 + ) + '\n' + ); + + const config = createEmptyUnifiedConfig(); + config.profiles.glm = { + type: 'api', + settings: path.join(ccsDir, 'glm.settings.json'), + }; + config.accounts.work = { + created: '2026-03-15T00:00:00.000Z', + last_used: null, + context_mode: 'isolated', + }; + config.default = 'work'; + saveUnifiedConfig(config); + }); + + afterEach(() => { + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('lists profile options and IDE host targets', async () => { + const response = await fetch(`${baseUrl}/api/claude-extension/profiles`); + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + profiles: Array<{ name: string }>; + hosts: Array<{ id: string }>; + }; + + expect(payload.profiles.some((profile) => profile.name === 'default')).toBe(true); + expect(payload.profiles.some((profile) => profile.name === 'glm')).toBe(true); + expect(payload.profiles.some((profile) => profile.name === 'work')).toBe(true); + expect(payload.profiles.some((profile) => profile.name === 'gemini')).toBe(true); + expect(payload.hosts.map((host) => host.id)).toEqual(['vscode', 'cursor', 'windsurf']); + }); + + it('renders VS Code setup for API profiles with disableLoginPrompt', async () => { + const response = await fetch(`${baseUrl}/api/claude-extension/setup?profile=glm&host=vscode`); + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + host: { settingsKey: string; disableLoginPromptKey?: string }; + ideSettings: { json: string }; + sharedSettings: { command: string; json: string }; + }; + + expect(payload.host.settingsKey).toBe('claudeCode.environmentVariables'); + expect(payload.host.disableLoginPromptKey).toBe('claudeCode.disableLoginPrompt'); + expect(payload.ideSettings.json).toContain('"claudeCode.disableLoginPrompt": true'); + expect(payload.ideSettings.json).toContain('"ANTHROPIC_API_KEY"'); + expect(payload.sharedSettings.command).toBe('ccs persist glm'); + expect(payload.sharedSettings.json).toContain('"env"'); + }); + + it('renders Windsurf setup for default account resolution via CLAUDE_CONFIG_DIR', async () => { + const response = await fetch( + `${baseUrl}/api/claude-extension/setup?profile=default&host=windsurf` + ); + expect(response.status).toBe(200); + + const payload = (await response.json()) as { + profile: { profileType: string; resolvedProfileName: string }; + host: { settingsKey: string }; + ideSettings: { json: string }; + sharedSettings: { command: string }; + }; + + expect(payload.profile.profileType).toBe('account'); + expect(payload.profile.resolvedProfileName).toBe('work'); + expect(payload.host.settingsKey).toBe('claude-code.environmentVariables'); + expect(payload.ideSettings.json).toContain('"claude-code.environmentVariables"'); + expect(payload.ideSettings.json).toContain('"CLAUDE_CONFIG_DIR"'); + expect(payload.sharedSettings.command).toBe('ccs persist default'); + }); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 7b70e49b..31b3f343 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -28,6 +28,9 @@ const CliproxyControlPanelPage = lazy(() => ); const CopilotPage = lazy(() => import('@/pages/copilot').then((m) => ({ default: m.CopilotPage }))); const CursorPage = lazy(() => import('@/pages/cursor').then((m) => ({ default: m.CursorPage }))); +const ClaudeExtensionPage = lazy(() => + import('@/pages/claude-extension').then((m) => ({ default: m.ClaudeExtensionPage })) +); const DroidPage = lazy(() => import('@/pages/droid').then((m) => ({ default: m.DroidPage }))); const AccountsPage = lazy(() => import('@/pages/accounts').then((m) => ({ default: m.AccountsPage })) @@ -119,6 +122,14 @@ export default function App() { } /> + }> + + + } + /> string): SidebarGroupDef[] { }, { path: '/copilot', icon: Github, label: t('nav.githubCopilot') }, { path: '/cursor', iconSrc: '/assets/sidebar/cursor.svg', label: t('nav.cursorIde') }, + { path: '/claude-extension', icon: Puzzle, label: t('nav.claudeExtension') }, { path: '/accounts', icon: Users, diff --git a/ui/src/hooks/use-claude-extension.ts b/ui/src/hooks/use-claude-extension.ts new file mode 100644 index 00000000..a7e34b50 --- /dev/null +++ b/ui/src/hooks/use-claude-extension.ts @@ -0,0 +1,72 @@ +import { useQuery } from '@tanstack/react-query'; +import { withApiBase } from '@/lib/api-client'; + +export interface ClaudeExtensionProfileOption { + name: string; + profileType: string; + label: string; + description: string; +} + +export interface ClaudeExtensionHostOption { + id: 'vscode' | 'cursor' | 'windsurf'; + label: string; + settingsKey: string; + disableLoginPromptKey?: string; + settingsTargetLabel: string; + description: string; +} + +export interface ClaudeExtensionSetupPayload { + profile: { + requestedProfile: string; + resolvedProfileName: string; + profileType: string; + label: string; + description: string; + }; + host: ClaudeExtensionHostOption; + env: Array<{ name: string; value: string }>; + warnings: string[]; + notes: string[]; + removeEnvKeys: string[]; + sharedSettings: { + path: string; + command: string; + json: string; + }; + ideSettings: { + targetLabel: string; + json: string; + }; +} + +async function requestJson(url: string): Promise { + const res = await fetch(withApiBase(url)); + if (!res.ok) { + const payload = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(payload?.error || `Request failed (${res.status})`); + } + return res.json(); +} + +export function useClaudeExtensionOptions() { + return useQuery({ + queryKey: ['claude-extension-options'], + queryFn: () => + requestJson<{ profiles: ClaudeExtensionProfileOption[]; hosts: ClaudeExtensionHostOption[] }>( + '/claude-extension/profiles' + ), + }); +} + +export function useClaudeExtensionSetup(profile?: string, host: string = 'vscode') { + return useQuery({ + queryKey: ['claude-extension-setup', profile, host], + enabled: Boolean(profile), + queryFn: () => + requestJson( + `/claude-extension/setup?profile=${encodeURIComponent(profile || '')}&host=${encodeURIComponent(host)}` + ), + }); +} diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index c70780cd..893a5207 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -25,6 +25,7 @@ const resources = { controlPanel: 'Control Panel', githubCopilot: 'GitHub Copilot', cursorIde: 'Cursor IDE', + claudeExtension: 'Claude Extension', accounts: 'Accounts', allAccounts: 'All Accounts', sharedData: 'Shared Data', @@ -1195,6 +1196,7 @@ const resources = { controlPanel: '控制面板', githubCopilot: 'GitHub Copilot', cursorIde: 'Cursor IDE', + claudeExtension: 'Claude Extension', accounts: '账号', allAccounts: '全部账号', sharedData: '共享数据', @@ -2324,6 +2326,7 @@ const resources = { controlPanel: 'Bảng điều khiển', githubCopilot: 'GitHub Copilot', cursorIde: 'Cursor IDE', + claudeExtension: 'Claude Extension', accounts: 'Tài khoản', allAccounts: 'Tất cả tài khoản', sharedData: 'Dữ liệu dùng chung', @@ -3513,6 +3516,7 @@ const resources = { controlPanel: 'コントロールパネル', githubCopilot: 'GitHub Copilot', cursorIde: 'Cursor IDE', + claudeExtension: 'Claude Extension', accounts: 'アカウント', allAccounts: 'すべてのアカウント', sharedData: '共有データ', diff --git a/ui/src/pages/claude-extension.tsx b/ui/src/pages/claude-extension.tsx new file mode 100644 index 00000000..3eb91aa6 --- /dev/null +++ b/ui/src/pages/claude-extension.tsx @@ -0,0 +1,249 @@ +import { useState } from 'react'; +import { AlertTriangle, Code2, Copy, FolderCog, Loader2, Sparkles } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { CopyButton } from '@/components/ui/copy-button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useClaudeExtensionOptions, useClaudeExtensionSetup } from '@/hooks/use-claude-extension'; + +function CodeBlockCard({ + title, + description, + value, +}: { + title: string; + description: string; + value: string; +}) { + return ( + + +

+
+ {title} + {description} +
+ +
+ + +
+          {value}
+        
+
+ + ); +} + +export function ClaudeExtensionPage() { + const optionsQuery = useClaudeExtensionOptions(); + const [profile, setProfile] = useState(''); + const [host, setHost] = useState('vscode'); + const selectedProfile = profile || optionsQuery.data?.profiles?.[0]?.name || ''; + const setupQuery = useClaudeExtensionSetup(selectedProfile, host); + const activeError = (optionsQuery.error as Error | null) ?? (setupQuery.error as Error | null); + + return ( +
+
+
+
+ + + Native setup + + VS Code first +
+

Claude IDE Extension

+

+ Generate the correct Claude extension configuration for CCS API profiles, account + instances, CLIProxy modes, Copilot, and default/native Claude flows. +

+
+
+ + + + Target selection + + Choose the CCS profile and IDE host you want to configure. + + + +
+
CCS profile
+ +

+ { + optionsQuery.data?.profiles.find((option) => option.name === selectedProfile) + ?.description + } +

+
+
+
IDE host
+ +

+ {optionsQuery.data?.hosts.find((option) => option.id === host)?.description} +

+
+
+
+ + {optionsQuery.isLoading || (selectedProfile && setupQuery.isLoading) ? ( +
+ + Resolving extension setup... +
+ ) : null} + + {activeError ? ( + + + +
{activeError.message}
+
+
+ ) : null} + + {setupQuery.data ? ( + <> +
+ + + + + Resolved profile + + + +
{setupQuery.data.profile.label}
+
{setupQuery.data.profile.description}
+ {setupQuery.data.profile.profileType} +
+
+ + + + + Shared settings path + + + +
+ {setupQuery.data.sharedSettings.path} +
+
+ Preferred when you want Claude Code CLI and the IDE extension to share one CCS + profile. +
+
+
+ + + + + IDE target + + + +
{setupQuery.data.host.label}
+
{setupQuery.data.host.settingsKey}
+
+ {setupQuery.data.ideSettings.targetLabel} +
+
+
+
+ + {setupQuery.data.warnings.length > 0 ? ( + + + {setupQuery.data.warnings.map((warning) => ( +
+ + {warning} +
+ ))} +
+
+ ) : null} + +
+ + +
+ + + +
+
+ CLI shortcut + + Use the native shared-settings path directly from the terminal. + +
+ +
+
+ +
+                {setupQuery.data.sharedSettings.command}
+              
+ {setupQuery.data.notes.length > 0 ? ( +
+ {setupQuery.data.notes.map((note) => ( +
- {note}
+ ))} +
+ ) : null} +
+
+ + ) : null} +
+ ); +} diff --git a/ui/src/pages/index.tsx b/ui/src/pages/index.tsx index b4ef665f..46d59ace 100644 --- a/ui/src/pages/index.tsx +++ b/ui/src/pages/index.tsx @@ -16,6 +16,8 @@ export { AnalyticsPage } from './analytics'; export { CursorPage } from './cursor'; +export { ClaudeExtensionPage } from './claude-extension'; + export { UpdatesPage } from './updates'; export { DroidPage } from './droid';