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:
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..888f075d 100644
--- a/src/commands/env-command.ts
+++ b/src/commands/env-command.ts
@@ -6,22 +6,20 @@
*/
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 +95,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 +109,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 +128,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 +146,20 @@ 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 +176,12 @@ 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 +200,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..a1d061ff 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,12 @@ 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 +767,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 +781,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 +791,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 +819,24 @@ 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 +888,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..e9f3aca1
--- /dev/null
+++ b/src/shared/claude-extension-hosts.ts
@@ -0,0 +1,44 @@
+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..dddf3efd
--- /dev/null
+++ b/src/shared/claude-extension-setup.ts
@@ -0,0 +1,315 @@
+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_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 = {};
+
+ 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<
+ Pick
+> {
+ 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'
+ ? (() => {
+ 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}.`
+ );
+ 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: sortUniqueEnvKeys([
+ ...CLAUDE_EXTENSION_MANAGED_ENV_KEYS,
+ ...Object.keys(resolved.extensionEnv),
+ ]),
+ warnings: resolved.warnings,
+ notes: resolved.notes,
+ disableLoginPrompt: resolved.disableLoginPrompt,
+ };
+}
+
+export function buildClaudeExtensionSettingsObject(
+ setup: ClaudeExtensionSetup,
+ host: ClaudeExtensionHost
+): Record {
+ 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 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(buildSharedClaudeSettingsObject(setup), 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..76c66225
--- /dev/null
+++ b/src/web-server/routes/claude-extension-routes.ts
@@ -0,0 +1,193 @@
+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';
+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');
+ 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;
+}
+
+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.map((host) => ({
+ ...host,
+ defaultSettingsPath: getDefaultClaudeExtensionIdeSettingsPath(host.id),
+ })),
+ });
+});
+
+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: {
+ path: getDefaultClaudeExtensionIdeSettingsPath(host),
+ targetLabel: hostDefinition.settingsTargetLabel,
+ json: renderClaudeExtensionSettingsJson(setup, host),
+ },
+ });
+ } catch (error) {
+ 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);
+ }
+});
+
+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/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..ded0d77d
--- /dev/null
+++ b/src/web-server/services/claude-extension-binding-service.ts
@@ -0,0 +1,277 @@
+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..aad9fe24
--- /dev/null
+++ b/src/web-server/services/claude-extension-settings-service.ts
@@ -0,0 +1,610 @@
+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/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..84e27c77
--- /dev/null
+++ b/tests/unit/web-server/claude-extension-routes.test.ts
@@ -0,0 +1,483 @@
+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'
+ );
+ 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,
+ 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; defaultSettingsPath: 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']);
+ expect(payload.hosts.every((host) => host.defaultSettingsPath.endsWith('settings.json'))).toBe(
+ true
+ );
+ });
+
+ 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');
+ });
+
+ 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/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[] {
},
{
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/hooks/use-claude-extension.ts b/ui/src/hooks/use-claude-extension.ts
new file mode 100644
index 00000000..058e2e0b
--- /dev/null
+++ b/ui/src/hooks/use-claude-extension.ts
@@ -0,0 +1,224 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { toast } from 'sonner';
+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;
+ defaultSettingsPath: 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: {
+ path: string;
+ targetLabel: string;
+ json: string;
+ };
+}
+
+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.status === 204 ? (undefined as T) : ((await res.json()) as T);
+}
+
+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)}`
+ ),
+ });
+}
+
+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/lib/i18n.ts b/ui/src/lib/i18n.ts
index c70780cd..31f6d592 100644
--- a/ui/src/lib/i18n.ts
+++ b/ui/src/lib/i18n.ts
@@ -25,10 +25,11 @@ const resources = {
controlPanel: 'Control Panel',
githubCopilot: 'GitHub Copilot',
cursorIde: 'Cursor IDE',
+ claudeExtension: 'Claude Extension',
accounts: 'Accounts',
allAccounts: 'All Accounts',
sharedData: 'Shared Data',
- compatibleClis: 'Compatible CLIs',
+ compatibleClis: 'Compatible',
factoryDroid: 'Factory Droid',
system: 'System',
health: 'Health',
@@ -1195,10 +1196,11 @@ const resources = {
controlPanel: '控制面板',
githubCopilot: 'GitHub Copilot',
cursorIde: 'Cursor IDE',
+ claudeExtension: 'Claude Extension',
accounts: '账号',
allAccounts: '全部账号',
sharedData: '共享数据',
- compatibleClis: '兼容 CLI',
+ compatibleClis: '兼容',
factoryDroid: 'Factory Droid',
system: '系统',
health: '健康',
@@ -2324,10 +2326,11 @@ 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',
- compatibleClis: 'CLI tương thích',
+ compatibleClis: 'Tương thích',
factoryDroid: 'Factory Droid',
system: 'Hệ thống',
health: 'Sức khỏe',
@@ -3513,10 +3516,11 @@ const resources = {
controlPanel: 'コントロールパネル',
githubCopilot: 'GitHub Copilot',
cursorIde: 'Cursor IDE',
+ claudeExtension: 'Claude Extension',
accounts: 'アカウント',
allAccounts: 'すべてのアカウント',
sharedData: '共有データ',
- compatibleClis: '対応 CLI',
+ compatibleClis: '互換',
factoryDroid: 'Factory Droid',
system: 'システム',
health: 'ヘルス',
diff --git a/ui/src/pages/claude-extension.tsx b/ui/src/pages/claude-extension.tsx
new file mode 100644
index 00000000..00bed2c0
--- /dev/null
+++ b/ui/src/pages/claude-extension.tsx
@@ -0,0 +1,985 @@
+import { useState } from '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,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+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,
+ description,
+ value,
+}: {
+ title: string;
+ description: string;
+ value: string;
+}) {
+ return (
+
+
+
+
+ {title}
+ {description}
+
+
+
+
+
+
+ {value}
+
+
+
+ );
+}
+
+function TargetStatusCard({
+ title,
+ description,
+ status,
+ applyLabel,
+ resetLabel,
+ onApply,
+ onReset,
+ disabled,
+ busy,
+}: {
+ title: string;
+ description: string;
+ status?: ClaudeExtensionTargetStatus;
+ applyLabel: string;
+ resetLabel: string;
+ onApply: () => void;
+ onReset: () => void;
+ disabled: boolean;
+ busy: boolean;
+}) {
+ return (
+
+
+
+
+ {title}
+ {description}
+
+ {status ?
: null}
+
+
+
+
+
+
+
+
+
+ {status?.message || 'Verify the binding after saving to inspect the current file state.'}
+
+
+
+
+
+
+
+
+ );
+}
+
+function BindingListItem({
+ binding,
+ isSelected,
+ onSelect,
+}: {
+ binding: ClaudeExtensionBinding;
+ isSelected: boolean;
+ onSelect: () => void;
+}) {
+ return (
+
+ );
+}
+
+export function ClaudeExtensionPage() {
+ const optionsQuery = useClaudeExtensionOptions();
+ const 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 (
+
+
+
+
+
+
+
+
+
+
+
Claude Extension
+
+ Saved IDE bindings for CCS profiles
+
+
+
+
+ {bindingCountLabel}
+ {selectedHost ? {selectedHost.label} : null}
+
+
+
+
+
+
+
+
+
+
+
+ {creating ? 'Create binding' : 'Binding editor'}
+
+
+ Save a profile + IDE path once, then apply or reset it from the dashboard.
+
+
+
+
+
Binding name
+
updateDraft('name', event.target.value)}
+ placeholder="VS Code · work profile"
+ />
+
+
+
+
CCS profile
+
+
+ {selectedProfile?.description ||
+ 'Choose which CCS profile the IDE should inherit.'}
+
+
+
+
+
IDE host
+
+
+
+
+
IDE settings path
+
updateDraft('ideSettingsPath', event.target.value)}
+ placeholder={
+ selectedHost?.defaultSettingsPath ||
+ 'Leave blank for the default user settings path'
+ }
+ />
+
+ Leave blank to use the default user settings path for{' '}
+ {selectedHost?.label || 'this IDE'}.
+
+
+
+
+
Notes
+
updateDraft('notes', event.target.value)}
+ placeholder="Optional reminder for this machine or workspace"
+ />
+
+
+
+
+
+
+
+ {!creating ? (
+
+ ) : null}
+
+
+
+
+
+ Saved bindings
+
+
+ {bindings.length > 0 ? (
+ bindings.map((binding) => (
+ {
+ setIsCreating(false);
+ setSelectedBindingId(binding.id);
+ setDraft(bindingToDraft(binding));
+ }}
+ />
+ ))
+ ) : (
+
+
+ No saved bindings yet. Create one to manage apply, reset, and drift checks
+ from the dashboard.
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {selectedProfile ? (
+ {selectedProfile.label}
+ ) : null}
+ {selectedHost ? {selectedHost.label} : null}
+ {creating ? Draft : null}
+ {status?.sharedSettings &&
+ isPlainStatusActive(status.sharedSettings) &&
+ isPlainStatusActive(status.ideSettings) ? (
+ In sync
+ ) : null}
+
+
+
+ {selectedBinding?.name || 'Claude extension binding'}
+
+
+ Manage the shared Claude settings file and the IDE-local settings file as two
+ scoped targets.
+
+
+
+
+
+
+ {setup ? (
+
+ ) : null}
+
+
+
+ {activeError ? (
+
+
+
+ {activeError.message}
+
+
+ ) : null}
+
+ {!activeError ? (
+
+
+ Overview
+ Advanced
+
+
+
+
+ runBindingAction('shared', 'apply')}
+ onReset={() => runBindingAction('shared', 'reset')}
+ disabled={creating}
+ busy={isBusyShared}
+ />
+ runBindingAction('ide', 'apply')}
+ onReset={() => runBindingAction('ide', 'reset')}
+ disabled={creating}
+ busy={isBusyIde}
+ />
+
+
+
+
+
+ Resolved binding
+
+ The binding uses the same profile resolution as `ccs persist` and `ccs
+ env`.
+
+
+
+
+
+
+
+
+
+ {currentDraft.notes.trim() ? (
+
+ ) : null}
+
+
+
+
+
+ Managed payload
+
+ Keep the main view short. The full JSON stays in the Advanced tab.
+
+
+
+
+ {envPreview.map((entry) => (
+
+ {entry.name}
+
+ ))}
+ {hiddenEnvCount > 0 ? (
+ +{hiddenEnvCount} more
+ ) : null}
+
+
+
+ {setup?.env.length ? (
+
+
+ CCS will inject {setup.env.length} environment values.
+
+
+ The IDE-local target receives the extension schema. The shared
+ target receives the same env block through Claude settings.
+
+
+ ) : (
+
+ This profile resolves to native Claude defaults, so apply/reset mainly
+ clears existing CCS-managed overrides.
+
+ )}
+
+
+ {!creating ? (
+
+
+
+
+ ) : (
+
+ Save this draft to unlock apply, reset, and verify actions.
+
+ )}
+
+
+
+
+ {setup && (setup.warnings.length > 0 || setup.notes.length > 0) ? (
+
+
+
+ Warnings
+
+ Operational details that can break the binding even when JSON is
+ correct.
+
+
+
+ {setup.warnings.length > 0 ? (
+ setup.warnings.map((warning) => (
+
+ ))
+ ) : (
+
+ 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}
+
+
+
+
+ );
+}
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';