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