Date: Sun, 15 Mar 2026 13:43:43 -0400
Subject: [PATCH 17/31] 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.map((option) => (
+
+ {option.label} ({option.profileType})
+
+ ))}
+
+
+
+ {
+ optionsQuery.data?.profiles.find((option) => option.name === selectedProfile)
+ ?.description
+ }
+
+
+
+
IDE host
+
+
+
+
+
+ {optionsQuery.data?.hosts.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ {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) => (
+
+ ))}
+
+
+ ) : 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 18/31] 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 19/31] 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 20/31] 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.'}
+
+
+
+
+ {busy ? : null}
+ {applyLabel}
+
+
+ {resetLabel}
+
+
+
+
+ );
+}
+
+function BindingListItem({
+ binding,
+ isSelected,
+ onSelect,
+}: {
+ binding: ClaudeExtensionBinding;
+ isSelected: boolean;
+ onSelect: () => void;
+}) {
+ return (
+
+
+
+
{binding.name}
+
+ {binding.profile} · {binding.host}
+
+
+
+ {binding.usesDefaultIdeSettingsPath ? 'Default path' : 'Custom path'}
+
+
+
+ );
+}
+
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}
+
+
+
+
+ New
+
-
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
+
updateDraft('profile', value)}
+ >
+
+
+
+
+ {profiles.map((profile) => (
+
+ {profile.label} ({profile.profileType})
+
+ ))}
+
+
+
+ {selectedProfile?.description ||
+ 'Choose which CCS profile the IDE should inherit.'}
+
+
+
+
+
IDE host
+
updateDraft('host', value as BindingDraft['host'])}
+ >
+
+
+
+
+ {hosts.map((host) => (
+
+ {host.label}
+
+ ))}
+
+
+
+
+
+
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"
+ />
+
+
+
+ void handleSave()}
+ disabled={!canPersist || isSaving}
+ >
+ {isSaving ? (
+
+ ) : (
+
+ )}
+ {creating ? 'Create' : 'Save'}
+
+
+ Reset form
+
+
+
+ {!creating ? (
+ void handleDelete()}
+ disabled={deleteBinding.isPending}
+ >
+
+ Delete binding
+
+ ) : 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.map((option) => (
-
- {option.label} ({option.profileType})
-
- ))}
-
-
-
- {
- optionsQuery.data?.profiles.find((option) => option.name === selectedProfile)
- ?.description
- }
-
-
-
-
IDE host
-
-
-
-
-
- {optionsQuery.data?.hosts.map((option) => (
-
- {option.label}
-
- ))}
-
-
-
- {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) => (
-
- ))}
-
-
- ) : 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}
+
+
+ void statusQuery.refetch()}
+ disabled={creating || statusQuery.isFetching}
+ >
+ {statusQuery.isFetching ? (
+
+ ) : (
+
+ )}
+ Verify
+
+ {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 ? (
+
+ runBindingAction('all', 'apply')}
+ disabled={applyBinding.isPending}
+ >
+ {applyBinding.isPending &&
+ applyBinding.variables?.target === 'all' ? (
+
+ ) : null}
+ Apply both targets
+
+ runBindingAction('all', 'reset')}
+ disabled={resetBinding.isPending}
+ >
+ Reset both targets
+
+
+ ) : (
+
+ 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) => (
+
+ ))
+ ) : (
+
+ 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 21/31] 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 22/31] 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)
);
}
From dffc153423724488d37302587ca126c9e5fdb559 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Sun, 15 Mar 2026 20:08:07 +0000
Subject: [PATCH 23/31] chore(release): 7.53.0-dev.4 [skip ci]
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 01cdbbca..41b77512 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
- "version": "7.53.0-dev.3",
+ "version": "7.53.0-dev.4",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
From f50c9625de6cc4d93c5db439af9349cb5e36f14b Mon Sep 17 00:00:00 2001
From: Tam Nhu Tran
Date: Mon, 16 Mar 2026 06:58:01 -0400
Subject: [PATCH 24/31] fix: harden global fetch proxy handling
---
README.md | 20 +++
bun.lock | 8 +-
package.json | 2 +-
src/cliproxy/binary/downloader.ts | 46 +----
src/utils/fetch-proxy-setup.ts | 162 +++++++++++++++++-
src/utils/proxy-env.ts | 150 ++++++++++++++++
.../cliproxy/binary-downloader-proxy.test.ts | 17 ++
tests/unit/utils/fetch-proxy-setup.test.ts | 89 ++++++++++
8 files changed, 434 insertions(+), 60 deletions(-)
create mode 100644 src/utils/proxy-env.ts
create mode 100644 tests/unit/utils/fetch-proxy-setup.test.ts
diff --git a/README.md b/README.md
index 366d4df2..da771632 100644
--- a/README.md
+++ b/README.md
@@ -533,6 +533,26 @@ See [Remote Proxy documentation](https://docs.ccs.kaitran.ca/features/remote-pro
+## Standard Fetch Proxy
+
+CCS also respects standard proxy environment variables for fetch-based quota, dashboard,
+and provider management requests:
+
+```bash
+export HTTPS_PROXY=http://proxy.example.com:8080
+export HTTP_PROXY=http://proxy.example.com:8080
+export ALL_PROXY=http://proxy.example.com:8080
+export NO_PROXY=localhost,127.0.0.1,.internal.corp
+```
+
+Notes:
+- CCS automatically bypasses loopback addresses (`localhost`, `127.0.0.1`, `::1`) for its own local services.
+- If `HTTPS_PROXY` is unset, CCS falls back to `HTTP_PROXY` for HTTPS fetches.
+- `ALL_PROXY` is used when protocol-specific proxy variables are not configured.
+- Proxy URLs must use `http://` or `https://`.
+
+
+
## Documentation
| Topic | Link |
diff --git a/bun.lock b/bun.lock
index 3ae74a56..6ba390ab 100644
--- a/bun.lock
+++ b/bun.lock
@@ -22,7 +22,7 @@
"open": "^8.4.2",
"ora": "^5.4.1",
"proper-lockfile": "^4.1.2",
- "undici": "^6.21.1",
+ "undici": "^5.29.0",
"ws": "^8.16.0",
},
"devDependencies": {
@@ -1282,7 +1282,7 @@
"uid-safe": ["uid-safe@2.1.5", "", { "dependencies": { "random-bytes": "~1.0.0" } }, "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA=="],
- "undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
+ "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
@@ -1350,8 +1350,6 @@
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
- "@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
-
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@@ -2062,8 +2060,6 @@
"semantic-release/@semantic-release/npm/@actions/core/@actions/exec/@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
- "semantic-release/@semantic-release/npm/@actions/core/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
-
"semantic-release/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"signale/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
diff --git a/package.json b/package.json
index 2d6d5f7d..2272a925 100644
--- a/package.json
+++ b/package.json
@@ -103,7 +103,7 @@
"open": "^8.4.2",
"ora": "^5.4.1",
"proper-lockfile": "^4.1.2",
- "undici": "^6.21.1",
+ "undici": "^5.29.0",
"ws": "^8.16.0"
},
"devDependencies": {
diff --git a/src/cliproxy/binary/downloader.ts b/src/cliproxy/binary/downloader.ts
index c7e1ea71..9bfc3bad 100644
--- a/src/cliproxy/binary/downloader.ts
+++ b/src/cliproxy/binary/downloader.ts
@@ -11,51 +11,7 @@ import * as http from 'http';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { HttpProxyAgent } from 'http-proxy-agent';
import { DownloadResult, ProgressCallback } from '../types';
-
-/**
- * Get proxy URL from environment variables.
- * Checks: https_proxy, HTTPS_PROXY, http_proxy, HTTP_PROXY, all_proxy, ALL_PROXY
- * @param isHttps Whether the target URL is HTTPS
- * @returns Proxy URL or undefined if no proxy configured
- */
-function getProxyUrl(isHttps: boolean): string | undefined {
- if (isHttps) {
- return (
- process.env.https_proxy ||
- process.env.HTTPS_PROXY ||
- process.env.all_proxy ||
- process.env.ALL_PROXY
- );
- }
- return (
- process.env.http_proxy ||
- process.env.HTTP_PROXY ||
- process.env.all_proxy ||
- process.env.ALL_PROXY
- );
-}
-
-/**
- * Check if a hostname should bypass the proxy based on NO_PROXY/no_proxy env var.
- * Supports: exact match, wildcard (*), and domain suffix (.example.com)
- * @param hostname The hostname to check
- * @returns true if the hostname should bypass the proxy
- */
-function shouldBypassProxy(hostname: string): boolean {
- const noProxy = process.env.no_proxy || process.env.NO_PROXY;
- if (!noProxy) return false;
-
- const noProxyList = noProxy.split(',').map((s) => s.trim().toLowerCase());
- const host = hostname.toLowerCase();
-
- return noProxyList.some((pattern) => {
- if (pattern === '*') return true;
- if (pattern.startsWith('.')) {
- return host.endsWith(pattern) || host === pattern.slice(1);
- }
- return host === pattern || host.endsWith('.' + pattern);
- });
-}
+import { getProxyUrl, shouldBypassProxy } from '../../utils/proxy-env';
/**
* Extract hostname from URL.
diff --git a/src/utils/fetch-proxy-setup.ts b/src/utils/fetch-proxy-setup.ts
index bb2917cb..0622114f 100644
--- a/src/utils/fetch-proxy-setup.ts
+++ b/src/utils/fetch-proxy-setup.ts
@@ -1,11 +1,157 @@
-import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
+/**
+ * Global Fetch Proxy Setup
+ *
+ * Configures undici's global dispatcher to respect standard proxy environment
+ * variables without routing CCS loopback traffic back through the proxy.
+ */
-const proxyUrl =
- process.env.HTTPS_PROXY ||
- process.env.https_proxy ||
- process.env.HTTP_PROXY ||
- process.env.http_proxy;
+import { Agent, Dispatcher, ProxyAgent, setGlobalDispatcher } from 'undici';
+import { getProxyResolution, shouldBypassProxy } from './proxy-env';
-if (proxyUrl) {
- setGlobalDispatcher(new EnvHttpProxyAgent());
+type GlobalFetchProxyConfig = {
+ httpProxyUrl?: string;
+ httpsProxyUrl?: string;
+ error?: string;
+};
+
+class RoutingProxyDispatcher extends Dispatcher {
+ private readonly directDispatcher = new Agent();
+ private readonly httpProxyDispatcher: ProxyAgent | null;
+ private readonly httpsProxyDispatcher: ProxyAgent | null;
+
+ constructor(httpProxyUrl: string | undefined, httpsProxyUrl: string | undefined) {
+ super();
+ this.httpProxyDispatcher = httpProxyUrl ? new ProxyAgent(httpProxyUrl) : null;
+ this.httpsProxyDispatcher = httpsProxyUrl ? new ProxyAgent(httpsProxyUrl) : null;
+ }
+
+ dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers): boolean {
+ return this.resolveDispatcher(options.origin).dispatch(options, handler);
+ }
+
+ close(): Promise;
+ close(callback: () => void): void;
+ close(callback?: () => void): Promise | void {
+ const promise = Promise.all(this.getDispatchers().map((dispatcher) => dispatcher.close())).then(
+ () => undefined
+ );
+
+ if (callback) {
+ promise.then(
+ () => callback(),
+ () => callback()
+ );
+ return;
+ }
+
+ return promise;
+ }
+
+ destroy(): Promise;
+ destroy(err: Error | null): Promise;
+ destroy(callback: () => void): void;
+ destroy(err: Error | null, callback: () => void): void;
+ destroy(
+ errOrCallback?: Error | null | (() => void),
+ callback?: () => void
+ ): Promise | void {
+ const error = typeof errOrCallback === 'function' ? null : errOrCallback;
+ const done = typeof errOrCallback === 'function' ? errOrCallback : callback;
+ const promise = Promise.all(
+ this.getDispatchers().map((dispatcher) => dispatcher.destroy(error ?? null))
+ ).then(() => undefined);
+
+ if (done) {
+ promise.then(
+ () => done(),
+ () => done()
+ );
+ return;
+ }
+
+ return promise;
+ }
+
+ private resolveDispatcher(origin: string | URL | undefined): Dispatcher {
+ if (!origin) {
+ return this.directDispatcher;
+ }
+
+ let url: URL;
+ try {
+ url = origin instanceof URL ? origin : new URL(origin);
+ } catch {
+ return this.directDispatcher;
+ }
+
+ if (shouldBypassProxy(url.hostname)) {
+ return this.directDispatcher;
+ }
+
+ if (url.protocol === 'https:' && this.httpsProxyDispatcher) {
+ return this.httpsProxyDispatcher;
+ }
+
+ if (url.protocol === 'http:' && this.httpProxyDispatcher) {
+ return this.httpProxyDispatcher;
+ }
+
+ return this.directDispatcher;
+ }
+
+ private getDispatchers(): Dispatcher[] {
+ return Array.from(
+ new Set(
+ [this.directDispatcher, this.httpProxyDispatcher, this.httpsProxyDispatcher].filter(
+ (dispatcher): dispatcher is Dispatcher => dispatcher !== null
+ )
+ )
+ );
+ }
+}
+
+export function createGlobalFetchProxyDispatcher(): Dispatcher | null {
+ const { httpProxyUrl, httpsProxyUrl } = resolveGlobalFetchProxyConfig();
+
+ if (!httpProxyUrl && !httpsProxyUrl) {
+ return null;
+ }
+
+ return new RoutingProxyDispatcher(httpProxyUrl, httpsProxyUrl);
+}
+
+export function applyGlobalFetchProxy(): { enabled: boolean; error?: string } {
+ try {
+ const config = resolveGlobalFetchProxyConfig();
+ if (!config.httpProxyUrl && !config.httpsProxyUrl) {
+ return config.error ? { enabled: false, error: config.error } : { enabled: false };
+ }
+
+ const dispatcher = createGlobalFetchProxyDispatcher();
+ if (!dispatcher) {
+ return { enabled: false };
+ }
+
+ setGlobalDispatcher(dispatcher);
+ return { enabled: true };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown proxy configuration error';
+ return { enabled: false, error: message };
+ }
+}
+
+const setupResult = applyGlobalFetchProxy();
+if (setupResult.error) {
+ console.error(`[!] Skipping global fetch proxy setup: ${setupResult.error}`);
+}
+
+function resolveGlobalFetchProxyConfig(): GlobalFetchProxyConfig {
+ const httpProxy = getProxyResolution(false);
+ const httpsProxy = getProxyResolution(true);
+
+ return {
+ httpProxyUrl: httpProxy.url,
+ httpsProxyUrl: httpsProxy.url,
+ error: httpProxy.error ?? httpsProxy.error,
+ };
}
diff --git a/src/utils/proxy-env.ts b/src/utils/proxy-env.ts
new file mode 100644
index 00000000..f2876e0c
--- /dev/null
+++ b/src/utils/proxy-env.ts
@@ -0,0 +1,150 @@
+type ProxyEnv = Record;
+type ProxyResolution = { url?: string; error?: string };
+
+const SUPPORTED_PROXY_PROTOCOLS = new Set(['http:', 'https:']);
+
+function getEnvValue(env: ProxyEnv, keys: string[]): string | undefined {
+ for (const key of keys) {
+ const value = env[key];
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ if (trimmed.length > 0) {
+ return trimmed;
+ }
+ }
+ }
+
+ return undefined;
+}
+
+function normalizeHostname(hostname: string): string {
+ const trimmed = hostname.trim().toLowerCase().replace(/\.$/, '');
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
+ return trimmed.slice(1, -1);
+ }
+ return trimmed;
+}
+
+function isIpLikeHost(hostname: string): boolean {
+ return /^[\d.:]+$/.test(hostname);
+}
+
+function validateProxyUrl(proxyUrl: string): string {
+ let parsedUrl: URL;
+ try {
+ parsedUrl = new URL(proxyUrl);
+ } catch {
+ throw new Error('Invalid URL');
+ }
+
+ if (!SUPPORTED_PROXY_PROTOCOLS.has(parsedUrl.protocol)) {
+ throw new Error(`Unsupported proxy protocol: ${parsedUrl.protocol}`);
+ }
+
+ return proxyUrl;
+}
+
+function getProxyKeys(isHttps: boolean): string[] {
+ if (isHttps) {
+ return ['https_proxy', 'HTTPS_PROXY', 'http_proxy', 'HTTP_PROXY', 'all_proxy', 'ALL_PROXY'];
+ }
+
+ return ['http_proxy', 'HTTP_PROXY', 'all_proxy', 'ALL_PROXY'];
+}
+
+export function getProxyResolution(isHttps: boolean, env: ProxyEnv = process.env): ProxyResolution {
+ let firstError: string | undefined;
+
+ for (const key of getProxyKeys(isHttps)) {
+ const proxyUrl = getEnvValue(env, [key]);
+ if (!proxyUrl) {
+ continue;
+ }
+
+ try {
+ return { url: validateProxyUrl(proxyUrl) };
+ } catch (error) {
+ if (!firstError) {
+ firstError = error instanceof Error ? error.message : 'Unknown proxy configuration error';
+ }
+ }
+ }
+
+ return { error: firstError };
+}
+
+export function getProxyUrl(isHttps: boolean, env: ProxyEnv = process.env): string | undefined {
+ const { url } = getProxyResolution(isHttps, env);
+ return url;
+}
+
+export function isLoopbackHost(hostname: string): boolean {
+ const normalizedHost = normalizeHostname(hostname);
+ if (!normalizedHost) {
+ return false;
+ }
+
+ if (
+ normalizedHost === 'localhost' ||
+ normalizedHost.endsWith('.localhost') ||
+ normalizedHost === '0.0.0.0' ||
+ normalizedHost === '::1'
+ ) {
+ return true;
+ }
+
+ if (/^127(?:\.\d{1,3}){3}$/.test(normalizedHost)) {
+ return true;
+ }
+
+ return normalizedHost.startsWith('::ffff:127.') || normalizedHost.startsWith('::ffff:7f');
+}
+
+export function getNoProxyValue(env: ProxyEnv = process.env): string | undefined {
+ return getEnvValue(env, ['no_proxy', 'NO_PROXY']);
+}
+
+export function shouldBypassProxy(hostname: string, env: ProxyEnv = process.env): boolean {
+ const normalizedHost = normalizeHostname(hostname);
+ if (!normalizedHost) {
+ return false;
+ }
+
+ if (isLoopbackHost(normalizedHost)) {
+ return true;
+ }
+
+ const noProxy = getNoProxyValue(env);
+ if (!noProxy) {
+ return false;
+ }
+
+ const patterns = noProxy
+ .split(/[,\s]+/)
+ .map((pattern) => normalizeHostname(pattern))
+ .filter(Boolean);
+
+ for (const pattern of patterns) {
+ if (pattern === '*') {
+ return true;
+ }
+
+ const canonicalPattern = pattern.startsWith('.')
+ ? pattern.slice(1)
+ : pattern.replace(/^\*/, '');
+
+ if (!canonicalPattern) {
+ continue;
+ }
+
+ if (normalizedHost === canonicalPattern) {
+ return true;
+ }
+
+ if (!isIpLikeHost(canonicalPattern) && normalizedHost.endsWith(`.${canonicalPattern}`)) {
+ return true;
+ }
+ }
+
+ return false;
+}
diff --git a/tests/unit/cliproxy/binary-downloader-proxy.test.ts b/tests/unit/cliproxy/binary-downloader-proxy.test.ts
index a92926f5..ad1cee11 100644
--- a/tests/unit/cliproxy/binary-downloader-proxy.test.ts
+++ b/tests/unit/cliproxy/binary-downloader-proxy.test.ts
@@ -68,6 +68,12 @@ describe('Binary Downloader Proxy Support', () => {
process.env.ALL_PROXY = 'http://allproxy:8080';
expect(getProxyUrl(true)).toBe('http://allproxy:8080');
});
+
+ it('should skip invalid HTTP_PROXY and fall back to ALL_PROXY', () => {
+ process.env.HTTP_PROXY = 'not-a-valid-url';
+ process.env.ALL_PROXY = 'http://allproxy:8080';
+ expect(getProxyUrl(true)).toBe('http://allproxy:8080');
+ });
});
describe('HTTP requests', () => {
@@ -91,6 +97,12 @@ describe('Binary Downloader Proxy Support', () => {
expect(getProxyUrl(false)).toBe('http://allproxy:8080');
});
+ it('should skip invalid HTTP_PROXY and use ALL_PROXY for HTTP', () => {
+ process.env.HTTP_PROXY = 'not-a-valid-url';
+ process.env.ALL_PROXY = 'http://allproxy:8080';
+ expect(getProxyUrl(false)).toBe('http://allproxy:8080');
+ });
+
it('should not use https_proxy for HTTP requests', () => {
process.env.https_proxy = 'http://httpsproxy:8080';
expect(getProxyUrl(false)).toBeUndefined();
@@ -251,6 +263,11 @@ describe('Binary Downloader Proxy Support', () => {
expect(getProxyAgent('https://example.com')).toBe(false);
});
+ it('should reject unsupported proxy protocols', () => {
+ process.env.https_proxy = 'socks5://proxy:1080';
+ expect(getProxyAgent('https://example.com')).toBe(false);
+ });
+
it('should return false for invalid target URL', () => {
process.env.https_proxy = 'http://proxy:8080';
expect(getProxyAgent('not-a-url')).toBe(false);
diff --git a/tests/unit/utils/fetch-proxy-setup.test.ts b/tests/unit/utils/fetch-proxy-setup.test.ts
new file mode 100644
index 00000000..a460216b
--- /dev/null
+++ b/tests/unit/utils/fetch-proxy-setup.test.ts
@@ -0,0 +1,89 @@
+import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
+import * as http from 'node:http';
+import { fetch, getGlobalDispatcher, setGlobalDispatcher } from 'undici';
+import { applyGlobalFetchProxy } from '../../../src/utils/fetch-proxy-setup';
+
+const PROXY_ENV_KEYS = [
+ 'http_proxy',
+ 'HTTP_PROXY',
+ 'https_proxy',
+ 'HTTPS_PROXY',
+ 'all_proxy',
+ 'ALL_PROXY',
+ 'no_proxy',
+ 'NO_PROXY',
+] as const;
+
+describe('global fetch proxy setup', () => {
+ const originalEnv = new Map();
+ const originalDispatcher = getGlobalDispatcher();
+
+ beforeEach(() => {
+ for (const key of PROXY_ENV_KEYS) {
+ originalEnv.set(key, process.env[key]);
+ delete process.env[key];
+ }
+ setGlobalDispatcher(originalDispatcher);
+ });
+
+ afterEach(() => {
+ setGlobalDispatcher(originalDispatcher);
+ for (const key of PROXY_ENV_KEYS) {
+ const value = originalEnv.get(key);
+ if (value === undefined) {
+ delete process.env[key];
+ } else {
+ process.env[key] = value;
+ }
+ }
+ });
+
+ it('does not fail when proxy configuration is invalid', () => {
+ process.env.HTTP_PROXY = 'not-a-valid-url';
+
+ expect(applyGlobalFetchProxy()).toEqual({
+ enabled: false,
+ error: 'Invalid URL',
+ });
+ });
+
+ it('bypasses loopback fetches even when HTTP_PROXY is set', async () => {
+ process.env.HTTP_PROXY = 'http://127.0.0.1:9';
+
+ const server = http.createServer((_req, res) => {
+ res.writeHead(200, { 'content-type': 'text/plain' });
+ res.end('ok');
+ });
+
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ const address = server.address();
+ if (!address || typeof address === 'string') {
+ throw new Error('Failed to bind test server');
+ }
+
+ try {
+ expect(applyGlobalFetchProxy()).toEqual({ enabled: true });
+
+ const response = await fetch(`http://127.0.0.1:${address.port}/health`);
+ expect(response.status).toBe(200);
+ expect(await response.text()).toBe('ok');
+ } finally {
+ await new Promise((resolve, reject) => {
+ server.close((error) => (error ? reject(error) : resolve()));
+ });
+ }
+ });
+
+ it('supports ALL_PROXY as a fetch proxy fallback', () => {
+ process.env.ALL_PROXY = 'http://127.0.0.1:8080';
+
+ expect(applyGlobalFetchProxy()).toEqual({ enabled: true });
+ });
+
+ it('falls back to ALL_PROXY when HTTP_PROXY is invalid', () => {
+ process.env.HTTP_PROXY = 'not-a-valid-url';
+ process.env.ALL_PROXY = 'http://127.0.0.1:8080';
+
+ expect(applyGlobalFetchProxy()).toEqual({ enabled: true });
+ });
+});
From fd5d16f3f3cebe9b965d30bbc5a05d73011f500e Mon Sep 17 00:00:00 2001
From: Tam Nhu Tran