From b82f10e63952db82ebe2e66fc584c5da231efb1d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 15 Mar 2026 13:43:43 -0400 Subject: [PATCH 1/6] 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'; From 56ba37911a2fc1465c1f793614ac19e4d7bcf004 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 15 Mar 2026 13:46:15 -0400 Subject: [PATCH 2/6] fix(config): guard Claude extension metadata --- src/commands/env-command.ts | 17 ++-- src/commands/persist-command.ts | 8 +- src/shared/claude-extension-hosts.ts | 4 +- src/shared/claude-extension-setup.ts | 96 +++++++++++++++---- .../routes/claude-extension-routes.ts | 4 +- 5 files changed, 98 insertions(+), 31 deletions(-) diff --git a/src/commands/env-command.ts b/src/commands/env-command.ts index 6a1407a4..888f075d 100644 --- a/src/commands/env-command.ts +++ b/src/commands/env-command.ts @@ -7,10 +7,7 @@ import { initUI, header, dim, color, subheader, fail, warn } from '../utils/ui'; import { getCcsDir } from '../utils/config-manager'; -import { - CLAUDE_EXTENSION_HOSTS, - type ClaudeExtensionHost, -} from '../shared/claude-extension-hosts'; +import { CLAUDE_EXTENSION_HOSTS, type ClaudeExtensionHost } from '../shared/claude-extension-hosts'; import { renderClaudeExtensionSettingsJson, resolveClaudeExtensionSetup, @@ -157,8 +154,12 @@ function showHelp(): void { ); 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( + ` ${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(''); } @@ -178,7 +179,9 @@ export async function handleEnvCommand(args: string[]): Promise { const flagsWithValues = ['format', 'shell', 'ide']; const profile = findProfile(args, flagsWithValues); if (!profile) { - console.error(fail('Usage: ccs env [--format openai|anthropic|raw|claude-extension]')); + console.error( + fail('Usage: ccs env [--format openai|anthropic|raw|claude-extension]') + ); process.exit(1); } diff --git a/src/commands/persist-command.ts b/src/commands/persist-command.ts index 014f7f79..a1d061ff 100644 --- a/src/commands/persist-command.ts +++ b/src/commands/persist-command.ts @@ -720,7 +720,9 @@ 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] 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' ); @@ -832,7 +834,9 @@ export async function handlePersistCommand(args: string[]): Promise { // 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( + 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(); diff --git a/src/shared/claude-extension-hosts.ts b/src/shared/claude-extension-hosts.ts index ea8e21b5..e9f3aca1 100644 --- a/src/shared/claude-extension-hosts.ts +++ b/src/shared/claude-extension-hosts.ts @@ -38,5 +38,7 @@ export const CLAUDE_EXTENSION_HOSTS: ClaudeExtensionHostDefinition[] = [ export function getClaudeExtensionHostDefinition( host: ClaudeExtensionHost = 'vscode' ): ClaudeExtensionHostDefinition { - return CLAUDE_EXTENSION_HOSTS.find((candidate) => candidate.id === host) ?? CLAUDE_EXTENSION_HOSTS[0]; + 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 index c248ca7f..fd504e26 100644 --- a/src/shared/claude-extension-setup.ts +++ b/src/shared/claude-extension-setup.ts @@ -3,7 +3,11 @@ 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 { + 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'; @@ -70,14 +74,19 @@ function describeProfile(profileName: string, result: ProfileDetectionResult): s ? '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 === '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 === '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 { +function createProfileOption( + profileName: string, + result: ProfileDetectionResult +): ClaudeExtensionProfileOption { return { name: profileName, profileType: result.type, @@ -89,7 +98,13 @@ function createProfileOption(profileName: string, result: ProfileDetectionResult export function listClaudeExtensionProfiles(): ClaudeExtensionProfileOption[] { const detector = new ProfileDetector(); const all = detector.getAllProfiles(); - const orderedNames = ['default', ...all.accounts, ...all.settings, ...all.cliproxy, ...all.cliproxyVariants]; + const orderedNames = [ + 'default', + ...all.accounts, + ...all.settings, + ...all.cliproxy, + ...all.cliproxyVariants, + ]; const deduped = [...new Set(orderedNames)]; try { detector.detectProfileType('copilot'); @@ -106,7 +121,9 @@ export function listClaudeExtensionProfiles(): ClaudeExtensionProfileOption[] { async function resolveExtensionEnv( requestedProfile: string, result: ProfileDetectionResult -): Promise> { +): Promise< + Pick +> { const warnings: string[] = []; const notes: string[] = []; const requestedIsDefault = requestedProfile === 'default'; @@ -144,7 +161,9 @@ async function resolveExtensionEnv( }; } - notes.push('Default profile clears CCS-managed transport overrides and uses native Claude defaults.'); + notes.push( + 'Default profile clears CCS-managed transport overrides and uses native Claude defaults.' + ); return { extensionEnv: {}, warnings, notes, disableLoginPrompt: false }; } @@ -155,30 +174,60 @@ async function resolveExtensionEnv( }); const env = result.type === 'settings' - ? result.env ?? (result.settingsPath ? loadSettingsFromFile(expandPath(result.settingsPath)) : {}) + ? (result.env ?? + (result.settingsPath ? loadSettingsFromFile(expandPath(result.settingsPath)) : {})) : result.type === 'copilot' - ? generateCopilotEnv(result.copilotConfig!, continuity.claudeConfigDir) + ? (() => { + if (!result.copilotConfig) { + throw new Error(`Profile "${requestedProfile}" is missing copilot configuration.`); + } + return generateCopilotEnv(result.copilotConfig, continuity.claudeConfigDir); + })() : (() => { + if (!result.provider) { + throw new Error( + `Profile "${requestedProfile}" is missing CLIProxy provider metadata.` + ); + } 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}.`); + 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); + ? 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.'); + 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); + ? 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}".`); + 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.'); + 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.`); @@ -187,7 +236,9 @@ async function resolveExtensionEnv( return { extensionEnv: sortEnvRecord(env), warnings, notes, disableLoginPrompt: true }; } -export async function resolveClaudeExtensionSetup(requestedProfile: string): Promise { +export async function resolveClaudeExtensionSetup( + requestedProfile: string +): Promise { const detector = new ProfileDetector(); const result = detector.detectProfileType(requestedProfile); const resolved = await resolveExtensionEnv(requestedProfile, result); @@ -212,7 +263,10 @@ export function renderClaudeExtensionSettingsJson( ): string { const definition = getClaudeExtensionHostDefinition(host); const payload: Record = { - [definition.settingsKey]: Object.entries(setup.extensionEnv).map(([name, value]) => ({ name, value })), + [definition.settingsKey]: Object.entries(setup.extensionEnv).map(([name, value]) => ({ + name, + value, + })), }; if (definition.disableLoginPromptKey && setup.disableLoginPrompt) { payload[definition.disableLoginPromptKey] = true; @@ -224,7 +278,9 @@ export function renderSharedClaudeSettingsJson(setup: ClaudeExtensionSetup): str return JSON.stringify({ env: setup.extensionEnv }, null, 2); } -export function getClaudeExtensionHostMetadata(host: ClaudeExtensionHost): ClaudeExtensionHostDefinition { +export function getClaudeExtensionHostMetadata( + host: ClaudeExtensionHost +): ClaudeExtensionHostDefinition { return getClaudeExtensionHostDefinition(host); } diff --git a/src/web-server/routes/claude-extension-routes.ts b/src/web-server/routes/claude-extension-routes.ts index d67fe465..41e081dd 100644 --- a/src/web-server/routes/claude-extension-routes.ts +++ b/src/web-server/routes/claude-extension-routes.ts @@ -18,7 +18,9 @@ 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(', ')}`); + throw new Error( + `Invalid host "${rawHost}". Use: ${CLAUDE_EXTENSION_HOSTS.map((host) => host.id).join(', ')}` + ); } return rawHost as ClaudeExtensionHost; } From 56db6035f51058a6ff7e3e245448b8e6fbf35f91 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 15 Mar 2026 13:46:56 -0400 Subject: [PATCH 3/6] docs(readme): document Claude IDE extension setup --- README.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/README.md b/README.md index 6744c337..f9aa41e2 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,44 @@ Defaults: Detailed guide: [`docs/cursor-integration.md`](./docs/cursor-integration.md) +### Claude IDE Extension Setup + +CCS now has a native setup flow for the Anthropic Claude extension in VS Code and compatible hosts. +Use the same resolver in both the CLI and dashboard, so API profiles, CCS auth accounts, +CLIProxy-backed profiles, Copilot, and default-profile continuity all map to the correct env shape. + +Preferred shared-settings path: + +```bash +ccs persist glm +ccs persist work +ccs persist default +``` + +This writes the resolved setup to `~/.claude/settings.json`, which is the best option when you want +the Claude CLI and the IDE extension to share one CCS profile. + +IDE-local snippet path: + +```bash +ccs env glm --format claude-extension --ide vscode +ccs env work --format claude-extension --ide cursor +ccs env default --format claude-extension --ide windsurf +``` + +This prints a copy-ready `settings.json` snippet for the installed Claude extension host: + +- `vscode` / `cursor`: `claudeCode.environmentVariables` plus `claudeCode.disableLoginPrompt` +- `windsurf`: `claude-code.environmentVariables` + +Account and continuity-aware flows use `CLAUDE_CONFIG_DIR` instead of Anthropic transport env vars. +CLIProxy and Copilot flows emit the required `ANTHROPIC_*` variables and still depend on their local +proxy/daemon being reachable. + +Dashboard parity: +- `ccs config` -> `Claude Extension` +- Select a CCS profile and IDE host to copy either the shared `~/.claude/settings.json` payload or the IDE-local extension snippet + ### Parallel Workflows Run multiple terminals with different providers: From a2f531016d29093c957c1000860fc83e2dc55cb4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 15 Mar 2026 15:57:03 -0400 Subject: [PATCH 4/6] feat(claude-extension): add binding workflow --- src/shared/claude-extension-setup.ts | 36 +- .../routes/claude-extension-routes.ts | 122 +- .../claude-extension-binding-service.ts | 275 ++++ .../claude-extension-settings-service.ts | 608 +++++++++ .../claude-extension-routes.test.ts | 343 ++++- ui/src/hooks/use-claude-extension.ts | 160 ++- ui/src/pages/claude-extension.tsx | 1128 ++++++++++++++--- 7 files changed, 2464 insertions(+), 208 deletions(-) create mode 100644 src/web-server/services/claude-extension-binding-service.ts create mode 100644 src/web-server/services/claude-extension-settings-service.ts diff --git a/src/shared/claude-extension-setup.ts b/src/shared/claude-extension-setup.ts index fd504e26..dddf3efd 100644 --- a/src/shared/claude-extension-setup.ts +++ b/src/shared/claude-extension-setup.ts @@ -45,15 +45,25 @@ export const CLAUDE_EXTENSION_MANAGED_ENV_KEYS = [ 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', + 'ANTHROPIC_MAX_TOKENS', + 'ANTHROPIC_SAFE_MODE', + 'ANTHROPIC_TEMPERATURE', 'ANTHROPIC_SMALL_FAST_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'API_TIMEOUT_MS', 'CLAUDE_CONFIG_DIR', 'DISABLE_NON_ESSENTIAL_MODEL_CALLS', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', + 'ENABLE_STREAMING', + 'MAX_THINKING_TOKENS', ] as const; +function sortUniqueEnvKeys(keys: Iterable): string[] { + return [...new Set(keys)].sort((left, right) => left.localeCompare(right)); +} + function sortEnvRecord(env: NodeJS.ProcessEnv | Record): Record { const normalized: Record = {}; @@ -250,17 +260,20 @@ export async function resolveClaudeExtensionSetup( profileLabel: requestedProfile === 'default' ? 'default' : result.name, profileDescription: describeProfile(requestedProfile, result), extensionEnv: resolved.extensionEnv, - removeEnvKeys: [...CLAUDE_EXTENSION_MANAGED_ENV_KEYS], + removeEnvKeys: sortUniqueEnvKeys([ + ...CLAUDE_EXTENSION_MANAGED_ENV_KEYS, + ...Object.keys(resolved.extensionEnv), + ]), warnings: resolved.warnings, notes: resolved.notes, disableLoginPrompt: resolved.disableLoginPrompt, }; } -export function renderClaudeExtensionSettingsJson( +export function buildClaudeExtensionSettingsObject( setup: ClaudeExtensionSetup, host: ClaudeExtensionHost -): string { +): Record { const definition = getClaudeExtensionHostDefinition(host); const payload: Record = { [definition.settingsKey]: Object.entries(setup.extensionEnv).map(([name, value]) => ({ @@ -271,11 +284,24 @@ export function renderClaudeExtensionSettingsJson( if (definition.disableLoginPromptKey && setup.disableLoginPrompt) { payload[definition.disableLoginPromptKey] = true; } - return JSON.stringify(payload, null, 2); + return payload; +} + +export function buildSharedClaudeSettingsObject( + setup: ClaudeExtensionSetup +): Record> { + return { env: setup.extensionEnv }; +} + +export function renderClaudeExtensionSettingsJson( + setup: ClaudeExtensionSetup, + host: ClaudeExtensionHost +): string { + return JSON.stringify(buildClaudeExtensionSettingsObject(setup, host), null, 2); } export function renderSharedClaudeSettingsJson(setup: ClaudeExtensionSetup): string { - return JSON.stringify({ env: setup.extensionEnv }, null, 2); + return JSON.stringify(buildSharedClaudeSettingsObject(setup), null, 2); } export function getClaudeExtensionHostMetadata( diff --git a/src/web-server/routes/claude-extension-routes.ts b/src/web-server/routes/claude-extension-routes.ts index 41e081dd..76c66225 100644 --- a/src/web-server/routes/claude-extension-routes.ts +++ b/src/web-server/routes/claude-extension-routes.ts @@ -11,9 +11,25 @@ import { renderSharedClaudeSettingsJson, resolveClaudeExtensionSetup, } from '../../shared/claude-extension-setup'; +import { + createClaudeExtensionBinding, + deleteClaudeExtensionBinding, + getClaudeExtensionBinding, + listClaudeExtensionBindings, + updateClaudeExtensionBinding, +} from '../services/claude-extension-binding-service'; +import { + applyClaudeExtensionBinding, + getDefaultClaudeExtensionIdeSettingsPath, + resetClaudeExtensionBinding, + resolveClaudeExtensionIdeSettingsPath, + type ClaudeExtensionActionTarget, + verifyClaudeExtensionBinding, +} from '../services/claude-extension-settings-service'; const router = Router(); const VALID_HOSTS = new Set(CLAUDE_EXTENSION_HOSTS.map((host) => host.id)); +const VALID_TARGETS = new Set(['shared', 'ide', 'all']); function getHostFromRequest(req: Request): ClaudeExtensionHost { const rawHost = String(req.query.host || 'vscode'); @@ -25,10 +41,40 @@ function getHostFromRequest(req: Request): ClaudeExtensionHost { return rawHost as ClaudeExtensionHost; } +function getActionTarget(req: Request): ClaudeExtensionActionTarget { + const rawTarget = + req.body && typeof req.body.target === 'string' ? req.body.target.trim().toLowerCase() : 'all'; + if (!VALID_TARGETS.has(rawTarget as ClaudeExtensionActionTarget)) { + throw new Error('Invalid target. Use: shared, ide, or all'); + } + return rawTarget as ClaudeExtensionActionTarget; +} + +function serializeBinding(id: string) { + const binding = getClaudeExtensionBinding(id); + return { + ...binding, + effectiveIdeSettingsPath: resolveClaudeExtensionIdeSettingsPath(binding), + usesDefaultIdeSettingsPath: !binding.ideSettingsPath, + }; +} + +function handleRouteError(res: Response, error: unknown): void { + const message = (error as Error).message; + if (message.startsWith('Binding not found')) { + res.status(404).json({ error: message }); + return; + } + res.status(400).json({ error: message }); +} + router.get('/profiles', (_req: Request, res: Response): void => { res.json({ profiles: listClaudeExtensionProfiles(), - hosts: CLAUDE_EXTENSION_HOSTS, + hosts: CLAUDE_EXTENSION_HOSTS.map((host) => ({ + ...host, + defaultSettingsPath: getDefaultClaudeExtensionIdeSettingsPath(host.id), + })), }); }); @@ -63,12 +109,84 @@ router.get('/setup', async (req: Request, res: Response): Promise => { json: renderSharedClaudeSettingsJson(setup), }, ideSettings: { + path: getDefaultClaudeExtensionIdeSettingsPath(host), targetLabel: hostDefinition.settingsTargetLabel, json: renderClaudeExtensionSettingsJson(setup, host), }, }); } catch (error) { - res.status(400).json({ error: (error as Error).message }); + handleRouteError(res, error); + } +}); + +router.get('/bindings', (_req: Request, res: Response): void => { + try { + res.json({ + bindings: listClaudeExtensionBindings().map((binding) => ({ + ...binding, + effectiveIdeSettingsPath: resolveClaudeExtensionIdeSettingsPath(binding), + usesDefaultIdeSettingsPath: !binding.ideSettingsPath, + })), + }); + } catch (error) { + handleRouteError(res, error); + } +}); + +router.post('/bindings', (req: Request, res: Response): void => { + try { + const binding = createClaudeExtensionBinding(req.body); + res.status(201).json({ binding: serializeBinding(binding.id) }); + } catch (error) { + handleRouteError(res, error); + } +}); + +router.put('/bindings/:id', (req: Request, res: Response): void => { + try { + const binding = updateClaudeExtensionBinding(req.params.id, req.body); + res.json({ binding: serializeBinding(binding.id) }); + } catch (error) { + handleRouteError(res, error); + } +}); + +router.delete('/bindings/:id', (req: Request, res: Response): void => { + try { + deleteClaudeExtensionBinding(req.params.id); + res.status(204).end(); + } catch (error) { + handleRouteError(res, error); + } +}); + +router.get('/bindings/:id/verify', async (req: Request, res: Response): Promise => { + try { + const binding = getClaudeExtensionBinding(req.params.id); + const status = await verifyClaudeExtensionBinding(binding); + res.json({ binding: serializeBinding(binding.id), ...status }); + } catch (error) { + handleRouteError(res, error); + } +}); + +router.post('/bindings/:id/apply', async (req: Request, res: Response): Promise => { + try { + const binding = getClaudeExtensionBinding(req.params.id); + const status = await applyClaudeExtensionBinding(binding, getActionTarget(req)); + res.json({ binding: serializeBinding(binding.id), ...status }); + } catch (error) { + handleRouteError(res, error); + } +}); + +router.post('/bindings/:id/reset', async (req: Request, res: Response): Promise => { + try { + const binding = getClaudeExtensionBinding(req.params.id); + const status = await resetClaudeExtensionBinding(binding, getActionTarget(req)); + res.json({ binding: serializeBinding(binding.id), ...status }); + } catch (error) { + handleRouteError(res, error); } }); diff --git a/src/web-server/services/claude-extension-binding-service.ts b/src/web-server/services/claude-extension-binding-service.ts new file mode 100644 index 00000000..93053e93 --- /dev/null +++ b/src/web-server/services/claude-extension-binding-service.ts @@ -0,0 +1,275 @@ +import { randomUUID } from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import ProfileDetector from '../../auth/profile-detector'; +import type { ClaudeExtensionHost } from '../../shared/claude-extension-hosts'; +import { expandPath } from '../../utils/helpers'; +import { getCcsDir } from '../../utils/config-manager'; + +export interface ClaudeExtensionBinding { + id: string; + name: string; + profile: string; + host: ClaudeExtensionHost; + ideSettingsPath?: string; + notes?: string; + createdAt: string; + updatedAt: string; +} + +export interface ClaudeExtensionBindingInput { + name: string; + profile: string; + host: ClaudeExtensionHost; + ideSettingsPath?: string; + notes?: string; +} + +export interface ClaudeExtensionManagedEnvManifest { + shared: string[]; + ide: string[]; +} + +interface ClaudeExtensionStoredBinding extends ClaudeExtensionBinding { + managedEnvManifest: ClaudeExtensionManagedEnvManifest; +} + +interface ClaudeExtensionBindingStore { + bindings: ClaudeExtensionStoredBinding[]; +} + +const VALID_HOSTS = new Set(['vscode', 'cursor', 'windsurf']); + +function getBindingsFilePath(): string { + return path.join(getCcsDir(), 'claude-extension-bindings.json'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function normalizeEnvKeyList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + + return [...new Set(value.filter((entry): entry is string => typeof entry === 'string'))] + .map((entry) => entry.trim()) + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)); +} + +function normalizeManagedEnvManifest(value: unknown): ClaudeExtensionManagedEnvManifest { + if (!isRecord(value)) { + return { shared: [], ide: [] }; + } + + return { + shared: normalizeEnvKeyList(value.shared), + ide: normalizeEnvKeyList(value.ide), + }; +} + +function toPublicBinding(binding: ClaudeExtensionStoredBinding): ClaudeExtensionBinding { + const { managedEnvManifest: _managedEnvManifest, ...publicBinding } = binding; + return publicBinding; +} + +function normalizeBindingInput(input: ClaudeExtensionBindingInput): ClaudeExtensionBindingInput { + const name = input.name?.trim(); + const profile = input.profile?.trim(); + const ideSettingsPath = input.ideSettingsPath?.trim(); + const notes = input.notes?.trim(); + + if (!name) throw new Error('Binding name is required'); + if (!profile) throw new Error('Profile is required'); + if (!VALID_HOSTS.has(input.host)) throw new Error(`Unsupported IDE host "${input.host}"`); + + try { + new ProfileDetector().detectProfileType(profile); + } catch { + throw new Error(`Unknown profile "${profile}"`); + } + + return { + name, + profile, + host: input.host, + ideSettingsPath: ideSettingsPath ? expandPath(ideSettingsPath) : undefined, + notes: notes || undefined, + }; +} + +function normalizeStoredBinding(value: unknown): ClaudeExtensionStoredBinding | null { + if (!isRecord(value)) return null; + const id = typeof value.id === 'string' ? value.id.trim() : ''; + const name = typeof value.name === 'string' ? value.name.trim() : ''; + const profile = typeof value.profile === 'string' ? value.profile.trim() : ''; + const host = typeof value.host === 'string' ? value.host.trim() : ''; + const createdAt = typeof value.createdAt === 'string' ? value.createdAt : ''; + const updatedAt = typeof value.updatedAt === 'string' ? value.updatedAt : ''; + + if (!id || !name || !profile || !VALID_HOSTS.has(host as ClaudeExtensionHost)) { + return null; + } + + return { + id, + name, + profile, + host: host as ClaudeExtensionHost, + ideSettingsPath: + typeof value.ideSettingsPath === 'string' && value.ideSettingsPath.trim().length > 0 + ? value.ideSettingsPath.trim() + : undefined, + notes: + typeof value.notes === 'string' && value.notes.trim().length > 0 + ? value.notes.trim() + : undefined, + createdAt: createdAt || new Date().toISOString(), + updatedAt: updatedAt || new Date().toISOString(), + managedEnvManifest: normalizeManagedEnvManifest(value.managedEnvManifest), + }; +} + +function readBindingsStore(): ClaudeExtensionBindingStore { + const filePath = getBindingsFilePath(); + if (!fs.existsSync(filePath)) { + return { bindings: [] }; + } + + const raw = fs.readFileSync(filePath, 'utf8'); + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch (error) { + throw new Error( + `Failed to parse Claude extension bindings store at ${filePath}: ${(error as Error).message}` + ); + } + const bindings = Array.isArray(parsed) + ? parsed + : isRecord(parsed) && Array.isArray(parsed.bindings) + ? parsed.bindings + : []; + + return { + bindings: bindings + .map((entry) => normalizeStoredBinding(entry)) + .filter((entry): entry is ClaudeExtensionStoredBinding => entry !== null) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)), + }; +} + +function writeBindingsStore(store: ClaudeExtensionBindingStore): void { + const filePath = getBindingsFilePath(); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.tmp.${process.pid}-${Date.now()}-${randomUUID()}`; + + try { + fs.writeFileSync(tempPath, JSON.stringify(store, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, filePath); + } catch (error) { + if (fs.existsSync(tempPath)) { + fs.rmSync(tempPath, { force: true }); + } + throw error; + } +} + +export function listClaudeExtensionBindings(): ClaudeExtensionBinding[] { + return readBindingsStore().bindings.map((binding) => toPublicBinding(binding)); +} + +export function getClaudeExtensionBinding(id: string): ClaudeExtensionBinding { + const binding = readBindingsStore().bindings.find((entry) => entry.id === id); + if (!binding) { + throw new Error(`Binding not found: ${id}`); + } + return toPublicBinding(binding); +} + +export function getClaudeExtensionManagedEnvManifest(id: string): ClaudeExtensionManagedEnvManifest { + const binding = readBindingsStore().bindings.find((entry) => entry.id === id); + if (!binding) { + throw new Error(`Binding not found: ${id}`); + } + return binding.managedEnvManifest; +} + +export function updateClaudeExtensionManagedEnvManifest( + id: string, + updates: Partial +): void { + const store = readBindingsStore(); + const index = store.bindings.findIndex((entry) => entry.id === id); + if (index === -1) { + throw new Error(`Binding not found: ${id}`); + } + + const current = store.bindings[index]; + store.bindings[index] = { + ...current, + managedEnvManifest: { + shared: + updates.shared !== undefined + ? normalizeEnvKeyList(updates.shared) + : current.managedEnvManifest.shared, + ide: + updates.ide !== undefined + ? normalizeEnvKeyList(updates.ide) + : current.managedEnvManifest.ide, + }, + }; + + writeBindingsStore(store); +} + +export function createClaudeExtensionBinding( + input: ClaudeExtensionBindingInput +): ClaudeExtensionBinding { + const normalized = normalizeBindingInput(input); + const store = readBindingsStore(); + const timestamp = new Date().toISOString(); + const binding: ClaudeExtensionStoredBinding = { + managedEnvManifest: { shared: [], ide: [] }, + id: randomUUID(), + createdAt: timestamp, + updatedAt: timestamp, + ...normalized, + }; + + store.bindings.unshift(binding); + writeBindingsStore(store); + return toPublicBinding(binding); +} + +export function updateClaudeExtensionBinding( + id: string, + input: ClaudeExtensionBindingInput +): ClaudeExtensionBinding { + const normalized = normalizeBindingInput(input); + const store = readBindingsStore(); + const index = store.bindings.findIndex((entry) => entry.id === id); + if (index === -1) { + throw new Error(`Binding not found: ${id}`); + } + + const updated: ClaudeExtensionStoredBinding = { + ...store.bindings[index], + ...normalized, + updatedAt: new Date().toISOString(), + }; + + store.bindings[index] = updated; + writeBindingsStore(store); + return toPublicBinding(updated); +} + +export function deleteClaudeExtensionBinding(id: string): void { + const store = readBindingsStore(); + const nextBindings = store.bindings.filter((entry) => entry.id !== id); + if (nextBindings.length === store.bindings.length) { + throw new Error(`Binding not found: ${id}`); + } + + writeBindingsStore({ bindings: nextBindings }); +} diff --git a/src/web-server/services/claude-extension-settings-service.ts b/src/web-server/services/claude-extension-settings-service.ts new file mode 100644 index 00000000..88bcb6e8 --- /dev/null +++ b/src/web-server/services/claude-extension-settings-service.ts @@ -0,0 +1,608 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { randomUUID } from 'crypto'; +import { + buildClaudeExtensionSettingsObject, + resolveClaudeExtensionSetup, +} from '../../shared/claude-extension-setup'; +import { + type ClaudeExtensionHost, + getClaudeExtensionHostDefinition, +} from '../../shared/claude-extension-hosts'; +import { getClaudeSettingsPath } from '../../utils/claude-config-path'; +import { expandPath } from '../../utils/helpers'; +import { + getClaudeExtensionManagedEnvManifest, + updateClaudeExtensionManagedEnvManifest, + type ClaudeExtensionBinding, +} from './claude-extension-binding-service'; + +export type ClaudeExtensionActionTarget = 'shared' | 'ide' | 'all'; +export type ClaudeExtensionFileState = 'applied' | 'drifted' | 'missing' | 'unconfigured'; + +export interface ClaudeExtensionTargetStatus { + target: 'shared' | 'ide'; + path: string; + exists: boolean; + mtime: number | null; + state: ClaudeExtensionFileState; + message: string; +} + +export interface ClaudeExtensionBindingStatus { + bindingId: string; + sharedSettings: ClaudeExtensionTargetStatus; + ideSettings: ClaudeExtensionTargetStatus; +} + +interface JsonDocument { + exists: boolean; + data: Record; + mtime: number | null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function sortStringRecord(record: Record): Record { + return Object.fromEntries( + Object.entries(record).sort(([left], [right]) => left.localeCompare(right)) + ); +} + +function toStringRecord(record: Record): Record { + const normalized: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (typeof value === 'string') { + normalized[key] = value; + } + } + return normalized; +} + +function uniqueFileNonce(): string { + return `${process.pid}-${Date.now()}-${randomUUID()}`; +} + +function stripJsonComments(input: string): string { + let output = ''; + let inString = false; + let escaping = false; + let inLineComment = false; + let inBlockComment = false; + + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + const nextChar = input[index + 1]; + + if (inLineComment) { + if (char === '\n') { + inLineComment = false; + output += char; + } + continue; + } + + if (inBlockComment) { + if (char === '*' && nextChar === '/') { + inBlockComment = false; + index += 1; + continue; + } + if (char === '\n') { + output += char; + } + continue; + } + + if (inString) { + output += char; + if (escaping) { + escaping = false; + } else if (char === '\\') { + escaping = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + output += char; + continue; + } + + if (char === '/' && nextChar === '/') { + inLineComment = true; + index += 1; + continue; + } + + if (char === '/' && nextChar === '*') { + inBlockComment = true; + index += 1; + continue; + } + + output += char; + } + + return output; +} + +function stripTrailingCommas(input: string): string { + let output = ''; + let inString = false; + let escaping = false; + + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + + if (inString) { + output += char; + if (escaping) { + escaping = false; + } else if (char === '\\') { + escaping = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + output += char; + continue; + } + + if (char === ',') { + let lookahead = index + 1; + while (lookahead < input.length && /\s/.test(input[lookahead])) { + lookahead += 1; + } + + if (input[lookahead] === '}' || input[lookahead] === ']') { + continue; + } + } + + output += char; + } + + return output; +} + +function parseJsonDocumentObject(raw: string, filePath: string): Record { + const normalized = stripTrailingCommas(stripJsonComments(raw)); + + let parsed: unknown; + try { + parsed = JSON.parse(normalized) as unknown; + } catch (error) { + throw new Error(`Failed to parse ${filePath}: ${(error as Error).message}`); + } + + if (!isRecord(parsed)) { + throw new Error(`Expected a JSON object in ${filePath}`); + } + + return parsed; +} + +function readJsonDocument(filePath: string): JsonDocument { + if (!fs.existsSync(filePath)) { + return { exists: false, data: {}, mtime: null }; + } + + if (fs.lstatSync(filePath).isSymbolicLink()) { + throw new Error(`Refusing to manage symlinked file: ${filePath}`); + } + + const raw = fs.readFileSync(filePath, 'utf8'); + + return { + exists: true, + data: parseJsonDocumentObject(raw, filePath), + mtime: fs.statSync(filePath).mtimeMs, + }; +} + +function backupIfPresent(filePath: string, suffix: string): void { + if (!fs.existsSync(filePath)) return; + const backupPath = `${filePath}.${suffix}.${uniqueFileNonce()}`; + fs.copyFileSync(filePath, backupPath, fs.constants.COPYFILE_EXCL); +} + +function writeJsonDocument( + filePath: string, + data: Record, + backupSuffix: string +): void { + if (fs.existsSync(filePath) && fs.lstatSync(filePath).isSymbolicLink()) { + throw new Error(`Refusing to manage symlinked file: ${filePath}`); + } + + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + backupIfPresent(filePath, backupSuffix); + const tempPath = `${filePath}.tmp.${uniqueFileNonce()}`; + + try { + fs.writeFileSync(tempPath, JSON.stringify(data, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, filePath); + } catch (error) { + if (fs.existsSync(tempPath)) { + fs.rmSync(tempPath, { force: true }); + } + throw error; + } +} + +function getManagedSharedEnv( + data: Record, + managedKeys: ReadonlySet +): Record { + const rawEnv = isRecord(data.env) ? toStringRecord(data.env) : {}; + const managed: Record = {}; + for (const key of managedKeys) { + if (typeof rawEnv[key] === 'string') { + managed[key] = rawEnv[key]; + } + } + return sortStringRecord(managed); +} + +interface ExtensionEnvEntry { + name: string; + value: string; +} + +function getExtensionEnvEntries(value: unknown): ExtensionEnvEntry[] { + if (!Array.isArray(value)) return []; + return value + .map((entry) => { + if (!isRecord(entry) || typeof entry.name !== 'string' || typeof entry.value !== 'string') { + return null; + } + return { name: entry.name, value: entry.value }; + }) + .filter((entry): entry is ExtensionEnvEntry => entry !== null); +} + +function getExtensionEnvMap( + value: unknown, + managedKeys?: ReadonlySet +): Record { + const entries = getExtensionEnvEntries(value) + .filter((entry) => !managedKeys || managedKeys.has(entry.name)) + .map((entry) => [entry.name, entry.value] as const); + return sortStringRecord(Object.fromEntries(entries)); +} + +function mergeManagedExtensionEnvEntries( + value: unknown, + managedKeys: ReadonlySet, + nextManagedEnv: Record +): ExtensionEnvEntry[] { + const preservedEntries = getExtensionEnvEntries(value).filter( + (entry) => !managedKeys.has(entry.name) + ); + const managedEntries = Object.entries(sortStringRecord(nextManagedEnv)).map( + ([name, entryValue]) => ({ + name, + value: entryValue, + }) + ); + + return [...preservedEntries, ...managedEntries]; +} + +function recordsMatch(left: Record, right: Record): boolean { + return JSON.stringify(sortStringRecord(left)) === JSON.stringify(sortStringRecord(right)); +} + +function booleansMatch(left: boolean | undefined, right: boolean | undefined): boolean { + return left === right; +} + +function targetEnabled(target: ClaudeExtensionActionTarget, candidate: 'shared' | 'ide'): boolean { + return target === 'all' || target === candidate; +} + +function getManagedKeysForTarget( + binding: ClaudeExtensionBinding, + target: 'shared' | 'ide', + currentResolvedKeys: string[] +): Set { + const manifest = getClaudeExtensionManagedEnvManifest(binding.id); + const manifestKeys = target === 'shared' ? manifest.shared : manifest.ide; + return new Set([...currentResolvedKeys, ...manifestKeys]); +} + +function getCurrentResolvedManagedKeys(setup: Awaited>): string[] { + return [...new Set([...setup.removeEnvKeys, ...Object.keys(setup.extensionEnv)])].sort((left, right) => + left.localeCompare(right) + ); +} + +export function getDefaultClaudeExtensionIdeSettingsPath(host: ClaudeExtensionHost): string { + if (process.platform === 'darwin') { + if (host === 'vscode') { + return path.join( + os.homedir(), + 'Library', + 'Application Support', + 'Code', + 'User', + 'settings.json' + ); + } + if (host === 'cursor') { + return path.join( + os.homedir(), + 'Library', + 'Application Support', + 'Cursor', + 'User', + 'settings.json' + ); + } + return path.join( + os.homedir(), + 'Library', + 'Application Support', + 'Windsurf', + 'User', + 'settings.json' + ); + } + + if (process.platform === 'win32') { + const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); + if (host === 'vscode') return path.join(appData, 'Code', 'User', 'settings.json'); + if (host === 'cursor') return path.join(appData, 'Cursor', 'User', 'settings.json'); + return path.join(appData, 'Windsurf', 'User', 'settings.json'); + } + + if (host === 'vscode') return path.join(os.homedir(), '.config', 'Code', 'User', 'settings.json'); + if (host === 'cursor') + return path.join(os.homedir(), '.config', 'Cursor', 'User', 'settings.json'); + return path.join(os.homedir(), '.config', 'Windsurf', 'User', 'settings.json'); +} + +export function resolveClaudeExtensionIdeSettingsPath(binding: ClaudeExtensionBinding): string { + return binding.ideSettingsPath + ? expandPath(binding.ideSettingsPath) + : getDefaultClaudeExtensionIdeSettingsPath(binding.host); +} + +export async function verifyClaudeExtensionBinding( + binding: ClaudeExtensionBinding +): Promise { + const setup = await resolveClaudeExtensionSetup(binding.profile); + const currentResolvedKeys = getCurrentResolvedManagedKeys(setup); + const sharedManagedKeys = getManagedKeysForTarget(binding, 'shared', currentResolvedKeys); + const ideManagedKeys = getManagedKeysForTarget(binding, 'ide', currentResolvedKeys); + const sharedPath = getClaudeSettingsPath(); + const idePath = resolveClaudeExtensionIdeSettingsPath(binding); + const sharedDoc = readJsonDocument(sharedPath); + const ideDoc = readJsonDocument(idePath); + const hostDefinition = getClaudeExtensionHostDefinition(binding.host); + const expectedIde = buildClaudeExtensionSettingsObject(setup, binding.host); + const expectedIdeEnv = getExtensionEnvMap( + expectedIde[hostDefinition.settingsKey], + ideManagedKeys + ); + const expectedDisablePrompt = hostDefinition.disableLoginPromptKey + ? (expectedIde[hostDefinition.disableLoginPromptKey] as boolean | undefined) + : undefined; + const actualIdeEnv = getExtensionEnvMap(ideDoc.data[hostDefinition.settingsKey], ideManagedKeys); + const actualDisablePrompt = hostDefinition.disableLoginPromptKey + ? (ideDoc.data[hostDefinition.disableLoginPromptKey] as boolean | undefined) + : undefined; + const expectedShared = sortStringRecord(setup.extensionEnv); + const actualShared = getManagedSharedEnv(sharedDoc.data, sharedManagedKeys); + const missingSharedState: ClaudeExtensionFileState = + Object.keys(expectedShared).length > 0 ? 'missing' : 'applied'; + const missingIdeState: ClaudeExtensionFileState = + Object.keys(expectedIdeEnv).length > 0 || expectedDisablePrompt === true + ? 'missing' + : 'applied'; + + const sharedSettings = !sharedDoc.exists + ? { + target: 'shared' as const, + path: sharedPath, + exists: false, + mtime: null, + state: missingSharedState, + message: + Object.keys(expectedShared).length > 0 + ? 'Shared Claude settings file does not exist yet.' + : 'No shared CCS-managed values are required.', + } + : recordsMatch(actualShared, expectedShared) + ? { + target: 'shared' as const, + path: sharedPath, + exists: true, + mtime: sharedDoc.mtime, + state: 'applied' as const, + message: 'Shared Claude settings match this binding.', + } + : Object.keys(actualShared).length === 0 + ? { + target: 'shared' as const, + path: sharedPath, + exists: true, + mtime: sharedDoc.mtime, + state: 'unconfigured' as const, + message: 'Shared Claude settings are not configured for this binding.', + } + : { + target: 'shared' as const, + path: sharedPath, + exists: true, + mtime: sharedDoc.mtime, + state: 'drifted' as const, + message: 'Shared Claude settings differ from the expected managed values.', + }; + + const ideSettings = !ideDoc.exists + ? { + target: 'ide' as const, + path: idePath, + exists: false, + mtime: null, + state: missingIdeState, + message: + Object.keys(expectedIdeEnv).length > 0 || expectedDisablePrompt === true + ? `${hostDefinition.label} settings file does not exist yet.` + : 'No IDE-local CCS-managed values are required.', + } + : recordsMatch(actualIdeEnv, expectedIdeEnv) && + booleansMatch(actualDisablePrompt, expectedDisablePrompt) + ? { + target: 'ide' as const, + path: idePath, + exists: true, + mtime: ideDoc.mtime, + state: 'applied' as const, + message: `${hostDefinition.label} settings match this binding.`, + } + : Object.keys(actualIdeEnv).length === 0 && actualDisablePrompt === undefined + ? { + target: 'ide' as const, + path: idePath, + exists: true, + mtime: ideDoc.mtime, + state: 'unconfigured' as const, + message: `${hostDefinition.label} settings are not configured for this binding.`, + } + : { + target: 'ide' as const, + path: idePath, + exists: true, + mtime: ideDoc.mtime, + state: 'drifted' as const, + message: `${hostDefinition.label} settings differ from the expected managed values.`, + }; + + return { bindingId: binding.id, sharedSettings, ideSettings }; +} + +export async function applyClaudeExtensionBinding( + binding: ClaudeExtensionBinding, + target: ClaudeExtensionActionTarget = 'all' +): Promise { + const setup = await resolveClaudeExtensionSetup(binding.profile); + const currentResolvedKeys = getCurrentResolvedManagedKeys(setup); + if (targetEnabled(target, 'shared')) { + const managedKeys = getManagedKeysForTarget(binding, 'shared', currentResolvedKeys); + const filePath = getClaudeSettingsPath(); + const document = readJsonDocument(filePath); + const env = isRecord(document.data.env) ? { ...document.data.env } : {}; + for (const key of managedKeys) delete env[key]; + for (const [key, value] of Object.entries(setup.extensionEnv)) env[key] = value; + const nextData = { ...document.data }; + const nextEnv = toStringRecord(env); + if (Object.keys(nextEnv).length > 0) nextData.env = sortStringRecord(nextEnv); + else delete nextData.env; + if (document.exists || Object.keys(setup.extensionEnv).length > 0) { + writeJsonDocument(filePath, nextData, 'backup'); + } + updateClaudeExtensionManagedEnvManifest(binding.id, { + shared: Object.keys(setup.extensionEnv), + }); + } + + if (targetEnabled(target, 'ide')) { + const managedKeys = getManagedKeysForTarget(binding, 'ide', currentResolvedKeys); + const hostDefinition = getClaudeExtensionHostDefinition(binding.host); + const filePath = resolveClaudeExtensionIdeSettingsPath(binding); + const document = readJsonDocument(filePath); + const nextData = { ...document.data }; + const payload = buildClaudeExtensionSettingsObject(setup, binding.host); + const mergedEnvEntries = mergeManagedExtensionEnvEntries( + document.data[hostDefinition.settingsKey], + managedKeys, + setup.extensionEnv + ); + if (mergedEnvEntries.length > 0) { + nextData[hostDefinition.settingsKey] = mergedEnvEntries; + } else { + delete nextData[hostDefinition.settingsKey]; + } + if (hostDefinition.disableLoginPromptKey) { + if (payload[hostDefinition.disableLoginPromptKey] === true) { + nextData[hostDefinition.disableLoginPromptKey] = true; + } else { + delete nextData[hostDefinition.disableLoginPromptKey]; + } + } + if ( + document.exists || + Object.keys(setup.extensionEnv).length > 0 || + payload[hostDefinition.disableLoginPromptKey ?? ''] === true + ) { + writeJsonDocument(filePath, nextData, 'ccs-backup'); + } + updateClaudeExtensionManagedEnvManifest(binding.id, { + ide: Object.keys(setup.extensionEnv), + }); + } + + return verifyClaudeExtensionBinding(binding); +} + +export async function resetClaudeExtensionBinding( + binding: ClaudeExtensionBinding, + target: ClaudeExtensionActionTarget = 'all' +): Promise { + const setup = await resolveClaudeExtensionSetup(binding.profile); + const currentResolvedKeys = getCurrentResolvedManagedKeys(setup); + + if (targetEnabled(target, 'shared')) { + const managedKeys = getManagedKeysForTarget(binding, 'shared', currentResolvedKeys); + const filePath = getClaudeSettingsPath(); + const document = readJsonDocument(filePath); + if (document.exists) { + const env = isRecord(document.data.env) ? { ...document.data.env } : {}; + for (const key of managedKeys) delete env[key]; + const nextData = { ...document.data }; + const nextEnv = toStringRecord(env); + if (Object.keys(nextEnv).length > 0) nextData.env = sortStringRecord(nextEnv); + else delete nextData.env; + writeJsonDocument(filePath, nextData, 'backup'); + } + updateClaudeExtensionManagedEnvManifest(binding.id, { shared: [] }); + } + + if (targetEnabled(target, 'ide')) { + const managedKeys = getManagedKeysForTarget(binding, 'ide', currentResolvedKeys); + const hostDefinition = getClaudeExtensionHostDefinition(binding.host); + const filePath = resolveClaudeExtensionIdeSettingsPath(binding); + const document = readJsonDocument(filePath); + if (document.exists) { + const nextData = { ...document.data }; + const preservedEntries = getExtensionEnvEntries( + document.data[hostDefinition.settingsKey] + ).filter((entry) => !managedKeys.has(entry.name)); + if (preservedEntries.length > 0) { + nextData[hostDefinition.settingsKey] = preservedEntries; + } else { + delete nextData[hostDefinition.settingsKey]; + } + if (hostDefinition.disableLoginPromptKey) { + delete nextData[hostDefinition.disableLoginPromptKey]; + } + writeJsonDocument(filePath, nextData, 'ccs-backup'); + } + updateClaudeExtensionManagedEnvManifest(binding.id, { ide: [] }); + } + + return verifyClaudeExtensionBinding(binding); +} diff --git a/tests/unit/web-server/claude-extension-routes.test.ts b/tests/unit/web-server/claude-extension-routes.test.ts index d30627e8..84e27c77 100644 --- a/tests/unit/web-server/claude-extension-routes.test.ts +++ b/tests/unit/web-server/claude-extension-routes.test.ts @@ -61,12 +61,33 @@ describe('web-server claude-extension-routes', () => { 2 ) + '\n' ); + fs.writeFileSync( + path.join(ccsDir, 'rich.settings.json'), + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://rich.example.test', + ANTHROPIC_API_KEY: 'sk-ant-rich-123456', + ANTHROPIC_MODEL: 'claude-opus-4-1', + ANTHROPIC_MAX_TOKENS: '65536', + API_TIMEOUT_MS: '120000', + EXPERIMENTAL_ROUTER_HEADER: 'tenant-alpha', + }, + }, + null, + 2 + ) + '\n' + ); const config = createEmptyUnifiedConfig(); config.profiles.glm = { type: 'api', settings: path.join(ccsDir, 'glm.settings.json'), }; + config.profiles.rich = { + type: 'api', + settings: path.join(ccsDir, 'rich.settings.json'), + }; config.accounts.work = { created: '2026-03-15T00:00:00.000Z', last_used: null, @@ -91,7 +112,7 @@ describe('web-server claude-extension-routes', () => { const payload = (await response.json()) as { profiles: Array<{ name: string }>; - hosts: Array<{ id: string }>; + hosts: Array<{ id: string; defaultSettingsPath: string }>; }; expect(payload.profiles.some((profile) => profile.name === 'default')).toBe(true); @@ -99,6 +120,9 @@ describe('web-server claude-extension-routes', () => { 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']); + expect(payload.hosts.every((host) => host.defaultSettingsPath.endsWith('settings.json'))).toBe( + true + ); }); it('renders VS Code setup for API profiles with disableLoginPrompt', async () => { @@ -139,4 +163,321 @@ describe('web-server claude-extension-routes', () => { expect(payload.ideSettings.json).toContain('"CLAUDE_CONFIG_DIR"'); expect(payload.sharedSettings.command).toBe('ccs persist default'); }); + + it('creates a binding and applies managed settings to shared + IDE targets', async () => { + const ideSettingsPath = path.join(tempHome, 'ide', 'vscode', 'settings.json'); + const createResponse = await fetch(`${baseUrl}/api/claude-extension/bindings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'GLM in VS Code', + profile: 'glm', + host: 'vscode', + ideSettingsPath, + }), + }); + expect(createResponse.status).toBe(201); + + const created = (await createResponse.json()) as { + binding: { id: string; effectiveIdeSettingsPath: string; usesDefaultIdeSettingsPath: boolean }; + }; + expect(created.binding.effectiveIdeSettingsPath).toBe(ideSettingsPath); + expect(created.binding.usesDefaultIdeSettingsPath).toBe(false); + + const applyResponse = await fetch( + `${baseUrl}/api/claude-extension/bindings/${created.binding.id}/apply`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target: 'all' }), + } + ); + expect(applyResponse.status).toBe(200); + + const applied = (await applyResponse.json()) as { + sharedSettings: { state: string }; + ideSettings: { state: string }; + }; + expect(applied.sharedSettings.state).toBe('applied'); + expect(applied.ideSettings.state).toBe('applied'); + + const sharedSettingsPath = path.join(tempHome, '.claude', 'settings.json'); + const sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf8')) as { + env?: Record; + }; + const ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record< + string, + unknown + >; + + expect(sharedSettings.env?.ANTHROPIC_API_KEY).toBe('sk-ant-test-123456'); + expect(sharedSettings.env?.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5'); + expect( + Array.isArray(ideSettings['claudeCode.environmentVariables']) && + (ideSettings['claudeCode.environmentVariables'] as Array<{ name: string }>).some( + (entry) => entry.name === 'ANTHROPIC_API_KEY' + ) + ).toBe(true); + expect(ideSettings['claudeCode.disableLoginPrompt']).toBe(true); + }); + + it('resets only managed keys and preserves unrelated shared + IDE settings', async () => { + const ideSettingsPath = path.join(tempHome, 'ide', 'cursor', 'settings.json'); + fs.mkdirSync(path.dirname(ideSettingsPath), { recursive: true }); + fs.mkdirSync(path.join(tempHome, '.claude'), { recursive: true }); + + fs.writeFileSync( + path.join(tempHome, '.claude', 'settings.json'), + JSON.stringify( + { + theme: 'dark', + env: { + KEEP_ME: '1', + ANTHROPIC_API_KEY: 'stale', + }, + }, + null, + 2 + ) + '\n' + ); + fs.writeFileSync( + ideSettingsPath, + JSON.stringify( + { + 'editor.fontSize': 14, + 'claudeCode.environmentVariables': [{ name: 'ANTHROPIC_API_KEY', value: 'stale' }], + 'claudeCode.disableLoginPrompt': true, + }, + null, + 2 + ) + '\n' + ); + + const createResponse = await fetch(`${baseUrl}/api/claude-extension/bindings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Cursor reset', + profile: 'glm', + host: 'cursor', + ideSettingsPath, + }), + }); + const created = (await createResponse.json()) as { binding: { id: string } }; + + const resetResponse = await fetch( + `${baseUrl}/api/claude-extension/bindings/${created.binding.id}/reset`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target: 'all' }), + } + ); + expect(resetResponse.status).toBe(200); + + const resetPayload = (await resetResponse.json()) as { + sharedSettings: { state: string }; + ideSettings: { state: string }; + }; + expect(resetPayload.sharedSettings.state).toBe('unconfigured'); + expect(resetPayload.ideSettings.state).toBe('unconfigured'); + + const sharedSettings = JSON.parse( + fs.readFileSync(path.join(tempHome, '.claude', 'settings.json'), 'utf8') + ) as { + theme?: string; + env?: Record; + }; + const ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record< + string, + unknown + >; + + expect(sharedSettings.theme).toBe('dark'); + expect(sharedSettings.env?.KEEP_ME).toBe('1'); + expect(sharedSettings.env?.ANTHROPIC_API_KEY).toBeUndefined(); + expect(ideSettings['editor.fontSize']).toBe(14); + expect(ideSettings['claudeCode.environmentVariables']).toBeUndefined(); + expect(ideSettings['claudeCode.disableLoginPrompt']).toBeUndefined(); + }); + + it('removes optional shared env keys even after the profile payload shrinks', async () => { + const ideSettingsPath = path.join(tempHome, 'ide', 'vscode', 'settings.json'); + const createResponse = await fetch(`${baseUrl}/api/claude-extension/bindings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Rich env binding', + profile: 'rich', + host: 'vscode', + ideSettingsPath, + }), + }); + const created = (await createResponse.json()) as { binding: { id: string } }; + + const applyResponse = await fetch( + `${baseUrl}/api/claude-extension/bindings/${created.binding.id}/apply`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target: 'shared' }), + } + ); + expect(applyResponse.status).toBe(200); + + const sharedSettingsPath = path.join(tempHome, '.claude', 'settings.json'); + let sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf8')) as { + env?: Record; + }; + + expect(sharedSettings.env?.ANTHROPIC_API_KEY).toBe('sk-ant-rich-123456'); + expect(sharedSettings.env?.ANTHROPIC_MAX_TOKENS).toBe('65536'); + expect(sharedSettings.env?.API_TIMEOUT_MS).toBe('120000'); + expect(sharedSettings.env?.EXPERIMENTAL_ROUTER_HEADER).toBe('tenant-alpha'); + + fs.writeFileSync( + path.join(tempHome, '.ccs', 'rich.settings.json'), + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://rich.example.test', + ANTHROPIC_API_KEY: 'sk-ant-rich-123456', + ANTHROPIC_MODEL: 'claude-opus-4-1', + }, + }, + null, + 2 + ) + '\n' + ); + + const resetResponse = await fetch( + `${baseUrl}/api/claude-extension/bindings/${created.binding.id}/reset`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target: 'shared' }), + } + ); + expect(resetResponse.status).toBe(200); + + const verifyResponse = await fetch( + `${baseUrl}/api/claude-extension/bindings/${created.binding.id}/verify` + ); + expect(verifyResponse.status).toBe(200); + + const verified = (await verifyResponse.json()) as { + sharedSettings: { state: string }; + }; + expect(verified.sharedSettings.state).toBe('unconfigured'); + + sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf8')) as { + env?: Record; + }; + expect(sharedSettings.env?.ANTHROPIC_API_KEY).toBeUndefined(); + expect(sharedSettings.env?.ANTHROPIC_MAX_TOKENS).toBeUndefined(); + expect(sharedSettings.env?.API_TIMEOUT_MS).toBeUndefined(); + expect(sharedSettings.env?.EXPERIMENTAL_ROUTER_HEADER).toBeUndefined(); + }); + + it('preserves unrelated IDE env entries while applying and resetting managed values', async () => { + const ideSettingsPath = path.join(tempHome, 'ide', 'vscode', 'settings.json'); + fs.mkdirSync(path.dirname(ideSettingsPath), { recursive: true }); + fs.writeFileSync( + ideSettingsPath, + `{ + // VS Code stores JSONC here, not strict JSON + "editor.tabSize": 2, + "claudeCode.environmentVariables": [ + { "name": "KEEP_ME", "value": "1" }, + { "name": "ANTHROPIC_API_KEY", "value": "stale" }, + ], + "claudeCode.disableLoginPrompt": false, +} +` + ); + + const createResponse = await fetch(`${baseUrl}/api/claude-extension/bindings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'VS Code preserved env', + profile: 'glm', + host: 'vscode', + ideSettingsPath, + }), + }); + const created = (await createResponse.json()) as { binding: { id: string } }; + + const applyResponse = await fetch( + `${baseUrl}/api/claude-extension/bindings/${created.binding.id}/apply`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target: 'ide' }), + } + ); + expect(applyResponse.status).toBe(200); + + let ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record; + const appliedEnv = ideSettings['claudeCode.environmentVariables'] as Array<{ + name: string; + value: string; + }>; + + expect(appliedEnv.some((entry) => entry.name === 'KEEP_ME' && entry.value === '1')).toBe(true); + expect( + appliedEnv.some((entry) => entry.name === 'ANTHROPIC_API_KEY' && entry.value === 'sk-ant-test-123456') + ).toBe(true); + + const verifyAppliedResponse = await fetch( + `${baseUrl}/api/claude-extension/bindings/${created.binding.id}/verify` + ); + expect(verifyAppliedResponse.status).toBe(200); + + const verifiedApplied = (await verifyAppliedResponse.json()) as { + ideSettings: { state: string }; + }; + expect(verifiedApplied.ideSettings.state).toBe('applied'); + + const resetResponse = await fetch( + `${baseUrl}/api/claude-extension/bindings/${created.binding.id}/reset`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target: 'ide' }), + } + ); + expect(resetResponse.status).toBe(200); + + ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record; + expect(ideSettings['editor.tabSize']).toBe(2); + expect(ideSettings['claudeCode.disableLoginPrompt']).toBeUndefined(); + expect(ideSettings['claudeCode.environmentVariables']).toEqual([{ name: 'KEEP_ME', value: '1' }]); + + const verifyResetResponse = await fetch( + `${baseUrl}/api/claude-extension/bindings/${created.binding.id}/verify` + ); + expect(verifyResetResponse.status).toBe(200); + + const verifiedReset = (await verifyResetResponse.json()) as { + ideSettings: { state: string }; + }; + expect(verifiedReset.ideSettings.state).toBe('unconfigured'); + }); + + it('rejects bindings for profiles that do not exist', async () => { + const response = await fetch(`${baseUrl}/api/claude-extension/bindings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Broken profile binding', + profile: 'does-not-exist', + host: 'vscode', + }), + }); + + expect(response.status).toBe(400); + const payload = (await response.json()) as { error: string }; + expect(payload.error).toContain('Unknown profile'); + }); }); diff --git a/ui/src/hooks/use-claude-extension.ts b/ui/src/hooks/use-claude-extension.ts index a7e34b50..058e2e0b 100644 --- a/ui/src/hooks/use-claude-extension.ts +++ b/ui/src/hooks/use-claude-extension.ts @@ -1,4 +1,5 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; import { withApiBase } from '@/lib/api-client'; export interface ClaudeExtensionProfileOption { @@ -15,6 +16,7 @@ export interface ClaudeExtensionHostOption { disableLoginPromptKey?: string; settingsTargetLabel: string; description: string; + defaultSettingsPath: string; } export interface ClaudeExtensionSetupPayload { @@ -36,18 +38,64 @@ export interface ClaudeExtensionSetupPayload { json: string; }; ideSettings: { + path: string; targetLabel: string; json: string; }; } -async function requestJson(url: string): Promise { - const res = await fetch(withApiBase(url)); +export interface ClaudeExtensionBinding { + id: string; + name: string; + profile: string; + host: ClaudeExtensionHostOption['id']; + ideSettingsPath?: string; + effectiveIdeSettingsPath: string; + usesDefaultIdeSettingsPath: boolean; + notes?: string; + createdAt: string; + updatedAt: string; +} + +export interface ClaudeExtensionBindingInput { + name: string; + profile: string; + host: ClaudeExtensionHostOption['id']; + ideSettingsPath?: string; + notes?: string; +} + +export type ClaudeExtensionActionTarget = 'shared' | 'ide' | 'all'; +export type ClaudeExtensionFileState = 'applied' | 'drifted' | 'missing' | 'unconfigured'; + +export interface ClaudeExtensionTargetStatus { + target: 'shared' | 'ide'; + path: string; + exists: boolean; + mtime: number | null; + state: ClaudeExtensionFileState; + message: string; +} + +export interface ClaudeExtensionBindingStatus { + binding: ClaudeExtensionBinding; + bindingId: string; + sharedSettings: ClaudeExtensionTargetStatus; + ideSettings: ClaudeExtensionTargetStatus; +} + +const bindingsQueryKey = ['claude-extension-bindings'] as const; + +async function requestJson(url: string, options?: RequestInit): Promise { + const res = await fetch(withApiBase(url), { + headers: { 'Content-Type': 'application/json' }, + ...options, + }); 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(); + return res.status === 204 ? (undefined as T) : ((await res.json()) as T); } export function useClaudeExtensionOptions() { @@ -70,3 +118,107 @@ export function useClaudeExtensionSetup(profile?: string, host: string = 'vscode ), }); } + +export function useClaudeExtensionBindings() { + return useQuery({ + queryKey: bindingsQueryKey, + queryFn: () => + requestJson<{ bindings: ClaudeExtensionBinding[] }>('/claude-extension/bindings'), + }); +} + +export function useClaudeExtensionBindingStatus(bindingId?: string) { + return useQuery({ + queryKey: ['claude-extension-binding-status', bindingId], + enabled: Boolean(bindingId), + queryFn: () => + requestJson( + `/claude-extension/bindings/${encodeURIComponent(bindingId || '')}/verify` + ), + }); +} + +export function useCreateClaudeExtensionBinding() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (binding: ClaudeExtensionBindingInput) => + requestJson<{ binding: ClaudeExtensionBinding }>('/claude-extension/bindings', { + method: 'POST', + body: JSON.stringify(binding), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: bindingsQueryKey }); + toast.success('Binding created'); + }, + onError: (error: Error) => toast.error(error.message), + }); +} + +export function useUpdateClaudeExtensionBinding() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, binding }: { id: string; binding: ClaudeExtensionBindingInput }) => + requestJson<{ binding: ClaudeExtensionBinding }>( + `/claude-extension/bindings/${encodeURIComponent(id)}`, + { + method: 'PUT', + body: JSON.stringify(binding), + } + ), + onSuccess: (_result, variables) => { + queryClient.invalidateQueries({ queryKey: bindingsQueryKey }); + queryClient.invalidateQueries({ + queryKey: ['claude-extension-binding-status', variables.id], + }); + toast.success('Binding saved'); + }, + onError: (error: Error) => toast.error(error.message), + }); +} + +export function useDeleteClaudeExtensionBinding() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => + requestJson(`/claude-extension/bindings/${encodeURIComponent(id)}`, { + method: 'DELETE', + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: bindingsQueryKey }); + toast.success('Binding deleted'); + }, + onError: (error: Error) => toast.error(error.message), + }); +} + +function useClaudeExtensionActionMutation(action: 'apply' | 'reset', successMessage: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, target }: { id: string; target: ClaudeExtensionActionTarget }) => + requestJson( + `/claude-extension/bindings/${encodeURIComponent(id)}/${action}`, + { + method: 'POST', + body: JSON.stringify({ target }), + } + ), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: bindingsQueryKey }); + queryClient.setQueryData(['claude-extension-binding-status', result.bindingId], result); + toast.success(successMessage); + }, + onError: (error: Error) => toast.error(error.message), + }); +} + +export function useApplyClaudeExtensionBinding() { + return useClaudeExtensionActionMutation('apply', 'Binding applied'); +} + +export function useResetClaudeExtensionBinding() { + return useClaudeExtensionActionMutation('reset', 'Managed values removed'); +} diff --git a/ui/src/pages/claude-extension.tsx b/ui/src/pages/claude-extension.tsx index 3eb91aa6..00bed2c0 100644 --- a/ui/src/pages/claude-extension.tsx +++ b/ui/src/pages/claude-extension.tsx @@ -1,8 +1,21 @@ import { useState } from 'react'; -import { AlertTriangle, Code2, Copy, FolderCog, Loader2, Sparkles } from 'lucide-react'; +import { + AlertTriangle, + Loader2, + Plus, + RefreshCw, + Save, + Settings2, + ShieldCheck, + Sparkles, + Trash2, +} from 'lucide-react'; import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { CopyButton } from '@/components/ui/copy-button'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; import { Select, SelectContent, @@ -10,7 +23,132 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { useClaudeExtensionOptions, useClaudeExtensionSetup } from '@/hooks/use-claude-extension'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + type ClaudeExtensionActionTarget, + type ClaudeExtensionBinding, + type ClaudeExtensionBindingInput, + type ClaudeExtensionFileState, + type ClaudeExtensionTargetStatus, + useApplyClaudeExtensionBinding, + useClaudeExtensionBindingStatus, + useClaudeExtensionBindings, + useClaudeExtensionOptions, + useClaudeExtensionSetup, + useCreateClaudeExtensionBinding, + useDeleteClaudeExtensionBinding, + useResetClaudeExtensionBinding, + useUpdateClaudeExtensionBinding, +} from '@/hooks/use-claude-extension'; +import { cn } from '@/lib/utils'; + +const EMPTY_BINDINGS: ClaudeExtensionBinding[] = []; + +interface BindingDraft { + name: string; + profile: string; + host: 'vscode' | 'cursor' | 'windsurf'; + ideSettingsPath: string; + notes: string; +} + +function createEmptyDraft(profile: string): BindingDraft { + return { + name: '', + profile, + host: 'vscode', + ideSettingsPath: '', + notes: '', + }; +} + +function bindingToDraft(binding: ClaudeExtensionBinding): BindingDraft { + return { + name: binding.name, + profile: binding.profile, + host: binding.host, + ideSettingsPath: binding.ideSettingsPath || '', + notes: binding.notes || '', + }; +} + +function normalizeBindingDraft(draft: BindingDraft): ClaudeExtensionBindingInput { + return { + name: draft.name.trim(), + profile: draft.profile.trim(), + host: draft.host, + ideSettingsPath: draft.ideSettingsPath.trim() || undefined, + notes: draft.notes.trim() || undefined, + }; +} + +function isPlainStatusActive(status?: ClaudeExtensionTargetStatus): boolean { + return status?.state === 'applied'; +} + +function StatusBadge({ state }: { state: ClaudeExtensionFileState }) { + const classes = + state === 'applied' + ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' + : state === 'drifted' + ? 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300' + : state === 'missing' + ? 'border-destructive/30 bg-destructive/10 text-destructive' + : 'border-border bg-muted text-muted-foreground'; + + return ( + + {state} + + ); +} + +function formatPathForDisplay(value: string): string { + return value.replace(/[\\/]/g, '$&\u200b'); +} + +function DetailRow({ + label, + value, + mono = false, + copyValue, +}: { + label: string; + value: string; + mono?: boolean; + copyValue?: string; +}) { + const isPathRow = typeof copyValue === 'string' && copyValue.trim().length > 0; + + return ( +
+ {label} + {isPathRow ? ( +
+
+ + {formatPathForDisplay(value)} + +
+ +
+ ) : ( + + {value} + + )} +
+ ); +} function CodeBlockCard({ title, @@ -22,7 +160,7 @@ function CodeBlockCard({ value: string; }) { return ( - +
@@ -33,7 +171,7 @@ function CodeBlockCard({
-
+        
           {value}
         
@@ -41,209 +179,807 @@ function CodeBlockCard({ ); } +function TargetStatusCard({ + title, + description, + status, + applyLabel, + resetLabel, + onApply, + onReset, + disabled, + busy, +}: { + title: string; + description: string; + status?: ClaudeExtensionTargetStatus; + applyLabel: string; + resetLabel: string; + onApply: () => void; + onReset: () => void; + disabled: boolean; + busy: boolean; +}) { + return ( + + +
+
+ {title} + {description} +
+ {status ? : null} +
+
+ +
+ + +
+ +
+ {status?.message || 'Verify the binding after saving to inspect the current file state.'} +
+ +
+ + +
+
+
+ ); +} + +function BindingListItem({ + binding, + isSelected, + onSelect, +}: { + binding: ClaudeExtensionBinding; + isSelected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + 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); + const bindingsQuery = useClaudeExtensionBindings(); + const createBinding = useCreateClaudeExtensionBinding(); + const updateBinding = useUpdateClaudeExtensionBinding(); + const deleteBinding = useDeleteClaudeExtensionBinding(); + const applyBinding = useApplyClaudeExtensionBinding(); + const resetBinding = useResetClaudeExtensionBinding(); + + const profiles = optionsQuery.data?.profiles ?? []; + const hosts = optionsQuery.data?.hosts ?? []; + const bindings = bindingsQuery.data?.bindings ?? EMPTY_BINDINGS; + const defaultProfile = profiles[0]?.name ?? 'default'; + + const [isCreating, setIsCreating] = useState(false); + const [selectedBindingId, setSelectedBindingId] = useState(null); + const [draft, setDraft] = useState(() => createEmptyDraft('default')); + + const creating = isCreating || bindings.length === 0; + const selectedBinding = + !creating && bindings.length > 0 + ? (bindings.find((binding) => binding.id === selectedBindingId) ?? + (selectedBindingId ? null : bindings[0])) + : null; + const effectiveSelectedBindingId = selectedBinding?.id ?? null; + const currentDraft = + creating || !selectedBinding + ? draft + : selectedBindingId + ? draft + : bindingToDraft(selectedBinding); + + const setupQuery = useClaudeExtensionSetup(currentDraft.profile, currentDraft.host); + const statusQuery = useClaudeExtensionBindingStatus( + creating ? undefined : effectiveSelectedBindingId || undefined + ); + + const selectedHost = hosts.find((host) => host.id === currentDraft.host); + const selectedProfile = profiles.find((profile) => profile.name === currentDraft.profile); + const activeError = + (optionsQuery.error as Error | null) || + (bindingsQuery.error as Error | null) || + (setupQuery.error as Error | null) || + (statusQuery.error as Error | null); + + const bindingCountLabel = `${bindings.length} saved`; + const isSaving = createBinding.isPending || updateBinding.isPending; + const isBusyShared = + (applyBinding.isPending && applyBinding.variables?.target === 'shared') || + (resetBinding.isPending && resetBinding.variables?.target === 'shared'); + const isBusyIde = + (applyBinding.isPending && applyBinding.variables?.target === 'ide') || + (resetBinding.isPending && resetBinding.variables?.target === 'ide'); + const canPersist = currentDraft.name.trim().length > 0 && currentDraft.profile.trim().length > 0; + + const setup = setupQuery.data; + const status = statusQuery.data; + const hiddenEnvCount = Math.max((setup?.env.length ?? 0) - 6, 0); + const envPreview = setup?.env.slice(0, 6) ?? []; + + function startCreateMode(): void { + setIsCreating(true); + setSelectedBindingId(null); + setDraft(createEmptyDraft(defaultProfile)); + } + + async function handleSave(): Promise { + if (!canPersist) return; + + const payload = normalizeBindingDraft(currentDraft); + if (!creating && effectiveSelectedBindingId) { + const result = await updateBinding.mutateAsync({ + id: effectiveSelectedBindingId, + binding: payload, + }); + setIsCreating(false); + setSelectedBindingId(result.binding.id); + setDraft(bindingToDraft(result.binding)); + return; + } + + const result = await createBinding.mutateAsync(payload); + setIsCreating(false); + setSelectedBindingId(result.binding.id); + setDraft(bindingToDraft(result.binding)); + } + + async function handleDelete(): Promise { + if (!effectiveSelectedBindingId || !selectedBinding) return; + if (!window.confirm(`Delete binding "${selectedBinding.name}"?`)) return; + + await deleteBinding.mutateAsync(effectiveSelectedBindingId); + const remaining = bindings.filter((binding) => binding.id !== effectiveSelectedBindingId); + if (remaining.length > 0) { + setSelectedBindingId(remaining[0].id); + setIsCreating(false); + setDraft(bindingToDraft(remaining[0])); + } else { + startCreateMode(); + } + } + + function updateDraft(key: K, value: BindingDraft[K]): void { + if (!creating && selectedBinding && !selectedBindingId) { + setSelectedBindingId(selectedBinding.id); + setDraft({ ...bindingToDraft(selectedBinding), [key]: value }); + setIsCreating(false); + return; + } + setDraft((current) => ({ ...current, [key]: value })); + } + + function runBindingAction(target: ClaudeExtensionActionTarget, action: 'apply' | 'reset'): void { + if (!effectiveSelectedBindingId) return; + if (action === 'apply') { + applyBinding.mutate({ id: effectiveSelectedBindingId, target }); + return; + } + resetBinding.mutate({ id: effectiveSelectedBindingId, target }); + } return ( -
-
-
-
- - - Native setup - - VS Code first +
+
+
+
+
+
+
+ +
+
+

Claude Extension

+

+ Saved IDE bindings for CCS profiles +

+
+
+
+ {bindingCountLabel} + {selectedHost ? {selectedHost.label} : null} +
+
+
-

Claude IDE Extension

-

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

+ + +
+ + + + {creating ? 'Create binding' : 'Binding editor'} + + + Save a profile + IDE path once, then apply or reset it from the dashboard. + + + +
+
Binding name
+ updateDraft('name', event.target.value)} + placeholder="VS Code · work profile" + /> +
+ +
+
CCS profile
+ +

+ {selectedProfile?.description || + 'Choose which CCS profile the IDE should inherit.'} +

+
+ +
+
IDE host
+ +
+ +
+
IDE settings path
+ updateDraft('ideSettingsPath', event.target.value)} + placeholder={ + selectedHost?.defaultSettingsPath || + 'Leave blank for the default user settings path' + } + /> +

+ Leave blank to use the default user settings path for{' '} + {selectedHost?.label || 'this IDE'}. +

+
+ +
+
Notes
+ updateDraft('notes', event.target.value)} + placeholder="Optional reminder for this machine or workspace" + /> +
+ +
+ + +
+ + {!creating ? ( + + ) : null} +
+
+ +
+
+ Saved bindings +
+
+ {bindings.length > 0 ? ( + bindings.map((binding) => ( + { + setIsCreating(false); + setSelectedBindingId(binding.id); + setDraft(bindingToDraft(binding)); + }} + /> + )) + ) : ( + + + No saved bindings yet. Create one to manage apply, reset, and drift checks + from the dashboard. + + + )} +
+
+
+
- - - 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} +
+ +
+
+
+
+ {selectedProfile ? ( + {selectedProfile.label} + ) : null} + {selectedHost ? {selectedHost.label} : null} + {creating ? Draft : null} + {status?.sharedSettings && + isPlainStatusActive(status.sharedSettings) && + isPlainStatusActive(status.ideSettings) ? ( + In sync + ) : null}
-
- Preferred when you want Claude Code CLI and the IDE extension to share one CCS - profile. +
+

+ {selectedBinding?.name || 'Claude extension binding'} +

+

+ Manage the shared Claude settings file and the IDE-local settings file as two + scoped targets. +

- - - - - - - 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} + +
+ + {setup ? ( + + ) : null} +
+
+ + {activeError ? ( + + + +
{activeError.message}
+
+
+ ) : null} + + {!activeError ? ( + + + Overview + Advanced + + + +
+ runBindingAction('shared', 'apply')} + onReset={() => runBindingAction('shared', 'reset')} + disabled={creating} + busy={isBusyShared} + /> + runBindingAction('ide', 'apply')} + onReset={() => runBindingAction('ide', 'reset')} + disabled={creating} + busy={isBusyIde} + /> +
+ +
+ + + Resolved binding + + The binding uses the same profile resolution as `ccs persist` and `ccs + env`. + + + + + + + + + + {currentDraft.notes.trim() ? ( + + ) : null} + + + + + + Managed payload + + Keep the main view short. The full JSON stays in the Advanced tab. + + + +
+ {envPreview.map((entry) => ( + + {entry.name} + + ))} + {hiddenEnvCount > 0 ? ( + +{hiddenEnvCount} more + ) : null} +
+ +
+ {setup?.env.length ? ( +
+
+ CCS will inject {setup.env.length} environment values. +
+
+ The IDE-local target receives the extension schema. The shared + target receives the same env block through Claude settings. +
+
+ ) : ( +
+ This profile resolves to native Claude defaults, so apply/reset mainly + clears existing CCS-managed overrides. +
+ )} +
+ + {!creating ? ( +
+ + +
+ ) : ( +
+ Save this draft to unlock apply, reset, and verify actions. +
+ )} +
+
+
+ + {setup && (setup.warnings.length > 0 || setup.notes.length > 0) ? ( +
+ + + Warnings + + Operational details that can break the binding even when JSON is + correct. + + + + {setup.warnings.length > 0 ? ( + setup.warnings.map((warning) => ( +
+ + {warning} +
+ )) + ) : ( +
+ No runtime warnings for this binding. +
+ )} +
+
+ + + + Notes + + Short context from CCS about account continuity and host-specific + behavior. + + + + {setup.notes.length > 0 ? ( + setup.notes.map((note) => ( +
+ + {note} +
+ )) + ) : ( +
+ No extra notes for this binding. +
+ )} +
+
+
+ ) : null} +
+ + + {setup ? ( + <> +
+ + +
+ + + +
+
+ + Resolved environment payload + + + Exact environment values that the extension receives after CCS + expands this profile. + +
+ +
+
+ + {setup.env.length > 0 ? ( +
+                              {JSON.stringify(setup.env, null, 2)}
+                            
+ ) : ( +
+ No env payload. This binding resolves to native Claude defaults. +
+ )} +
+
+ +
+ + + Shared target metadata + + Useful when debugging drift or comparing with manual edits. + + + + + + + + + + + + IDE target metadata + + Current file path plus the extension setting key used for this host. + + + + + + + + +
+ + ) : ( + + + + Choose a profile and IDE host to preview the generated payload. + + + )} +
+
+ ) : null} +
+ +
); } From 337dd188cc240e74ea2e434ca99280360c3f9ce1 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 15 Mar 2026 15:57:27 -0400 Subject: [PATCH 5/6] fix(sidebar): move claude extension to compatible --- ui/src/components/layout/app-sidebar.tsx | 6 ++++-- ui/src/lib/i18n.ts | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/ui/src/components/layout/app-sidebar.tsx b/ui/src/components/layout/app-sidebar.tsx index f4543e1a..a393ba06 100644 --- a/ui/src/components/layout/app-sidebar.tsx +++ b/ui/src/components/layout/app-sidebar.tsx @@ -102,7 +102,6 @@ function buildNavGroups(t: (key: string) => 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, @@ -117,7 +116,10 @@ function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] { }, { title: t('nav.compatibleClis'), - items: [{ path: '/droid', icon: TerminalSquare, label: t('nav.factoryDroid') }], + items: [ + { path: '/claude-extension', icon: Puzzle, label: t('nav.claudeExtension') }, + { path: '/droid', icon: TerminalSquare, label: t('nav.factoryDroid') }, + ], }, { title: t('nav.system'), diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 893a5207..31f6d592 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -29,7 +29,7 @@ const resources = { accounts: 'Accounts', allAccounts: 'All Accounts', sharedData: 'Shared Data', - compatibleClis: 'Compatible CLIs', + compatibleClis: 'Compatible', factoryDroid: 'Factory Droid', system: 'System', health: 'Health', @@ -1200,7 +1200,7 @@ const resources = { accounts: '账号', allAccounts: '全部账号', sharedData: '共享数据', - compatibleClis: '兼容 CLI', + compatibleClis: '兼容', factoryDroid: 'Factory Droid', system: '系统', health: '健康', @@ -2330,7 +2330,7 @@ const resources = { accounts: 'Tài khoản', allAccounts: 'Tất cả tài khoản', sharedData: 'Dữ liệu dùng chung', - compatibleClis: 'CLI tương thích', + compatibleClis: 'Tương thích', factoryDroid: 'Factory Droid', system: 'Hệ thống', health: 'Sức khỏe', @@ -3520,7 +3520,7 @@ const resources = { accounts: 'アカウント', allAccounts: 'すべてのアカウント', sharedData: '共有データ', - compatibleClis: '対応 CLI', + compatibleClis: '互換', factoryDroid: 'Factory Droid', system: 'システム', health: 'ヘルス', From 6724ea52c2cb41ca3d71e663fafce00e040b5425 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 15 Mar 2026 15:58:25 -0400 Subject: [PATCH 6/6] style(claude-extension): apply formatter output --- .../services/claude-extension-binding-service.ts | 4 +++- .../services/claude-extension-settings-service.ts | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/web-server/services/claude-extension-binding-service.ts b/src/web-server/services/claude-extension-binding-service.ts index 93053e93..ded0d77d 100644 --- a/src/web-server/services/claude-extension-binding-service.ts +++ b/src/web-server/services/claude-extension-binding-service.ts @@ -187,7 +187,9 @@ export function getClaudeExtensionBinding(id: string): ClaudeExtensionBinding { return toPublicBinding(binding); } -export function getClaudeExtensionManagedEnvManifest(id: string): ClaudeExtensionManagedEnvManifest { +export function getClaudeExtensionManagedEnvManifest( + id: string +): ClaudeExtensionManagedEnvManifest { const binding = readBindingsStore().bindings.find((entry) => entry.id === id); if (!binding) { throw new Error(`Binding not found: ${id}`); diff --git a/src/web-server/services/claude-extension-settings-service.ts b/src/web-server/services/claude-extension-settings-service.ts index 88bcb6e8..aad9fe24 100644 --- a/src/web-server/services/claude-extension-settings-service.ts +++ b/src/web-server/services/claude-extension-settings-service.ts @@ -322,9 +322,11 @@ function getManagedKeysForTarget( return new Set([...currentResolvedKeys, ...manifestKeys]); } -function getCurrentResolvedManagedKeys(setup: Awaited>): string[] { - return [...new Set([...setup.removeEnvKeys, ...Object.keys(setup.extensionEnv)])].sort((left, right) => - left.localeCompare(right) +function getCurrentResolvedManagedKeys( + setup: Awaited> +): string[] { + return [...new Set([...setup.removeEnvKeys, ...Object.keys(setup.extensionEnv)])].sort( + (left, right) => left.localeCompare(right) ); }