feat(config): add Claude IDE extension setup flow

This commit is contained in:
Tam Nhu Tran
2026-03-15 15:58:37 -04:00
parent 437b040f4c
commit b82f10e639
18 changed files with 1120 additions and 196 deletions
+11 -4
View File
@@ -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
+7
View File
@@ -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 <s> Set analysis timeout (seconds)');
console.log(' --set-model <p> <m> 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 <mode> Set mode (auto, off, manual)');
console.log(' --override <l> 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');
+46 -92
View File
@@ -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<string, string> | 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')} <fmt> Output format: openai, anthropic, raw ${dim('(default: anthropic)')}`
` ${color('--format', 'command')} <fmt> Output format: openai, anthropic, raw, claude-extension ${dim('(default: anthropic)')}`
);
console.log(
` ${color('--shell', 'command')} <sh> Shell syntax: auto, bash/zsh, fish, powershell ${dim('(default: auto)')}`
);
console.log(
` ${color('--ide', 'command')} <host> 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 <profile> 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<void> {
}
// 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 <profile> [--format openai|anthropic|raw]'));
console.error(fail('Usage: ccs env <profile> [--format openai|anthropic|raw|claude-extension]'));
process.exit(1);
}
@@ -220,47 +197,24 @@ export async function handleEnvCommand(args: string[]): Promise<void> {
}
// 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<string, string> = {};
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<string, string>
for (const [k, v] of Object.entries(resolved)) {
if (v !== undefined) envVars[k] = v;
let envVars: Record<string, string>;
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) {
+10 -2
View File
@@ -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 <profile>', 'Write profile env to ~/.claude/settings.json'],
['ccs persist <profile>', '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 <profile> --format openai', 'OpenAI-compatible vars (OpenCode/Cursor)'],
['ccs env <profile> --format anthropic', 'Anthropic vars (default)'],
['ccs env <profile> --format raw', 'All effective env vars'],
[
'ccs env <profile> --format claude-extension --ide vscode',
'VS Code/Cursor Claude extension settings JSON',
],
[
'ccs env <profile> --format claude-extension --ide windsurf',
'Windsurf Claude extension settings JSON',
],
['ccs env <profile> --shell fish', 'Fish shell syntax'],
]);
+83 -98
View File
@@ -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<string, string>;
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<ResolvedEnv> {
switch (profileResult.type) {
case 'settings': {
// API profile - load from settings file
let env: Record<string, string> = {};
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 <profile>'
);
}
default: {
throw new Error(`Unknown profile type: ${profileResult.type}`);
}
}
/** Resolve shared Claude settings payload for a profile */
async function resolveProfileEnvVars(profileName: string): Promise<ResolvedEnv> {
const setup = await resolveClaudeExtensionSetup(profileName);
const typeLabel: Record<string, string> = {
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<void> {
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<void> {
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<void> {
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<void> {
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 <profile> --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<void> {
}
// 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<void> {
// 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<void> {
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<void> {
}
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<void> {
}
}
const preservedEnv = { ...existingEnv };
for (const key of resolved.clearEnvKeys) {
delete preservedEnv[key];
}
const mergedSettings: Record<string, unknown> = {
...existingSettings,
env: {
...existingEnv,
...preservedEnv,
...resolved.env,
},
};
+42
View File
@@ -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];
}
+233
View File
@@ -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<string, string>;
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<string, string>): Record<string, string> {
const normalized: Record<string, string> = {};
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<ClaudeExtensionSetup, 'extensionEnv' | 'warnings' | 'notes' | 'disableLoginPrompt'>> {
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<ClaudeExtensionSetup> {
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<string, unknown> = {
[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();
}
@@ -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<void> => {
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;
+2
View File
@@ -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
@@ -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 <profile> --format claude-extension --ide vscode')).toBe(
true
);
expect(rendered.includes('ccs env <profile> --format claude-extension --ide windsurf')).toBe(
true
);
});
});
@@ -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<void> {
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<string, string>;
};
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<string, string>;
};
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);
});
});
@@ -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<void>((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<void>((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');
});
});
+11
View File
@@ -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() {
</Suspense>
}
/>
<Route
path="/claude-extension"
element={
<Suspense fallback={<PageLoader />}>
<ClaudeExtensionPage />
</Suspense>
}
/>
<Route
path="/droid"
element={
+2
View File
@@ -11,6 +11,7 @@ import {
BarChart3,
Gauge,
Github,
Puzzle,
TerminalSquare,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
@@ -101,6 +102,7 @@ function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] {
},
{ path: '/copilot', icon: Github, label: t('nav.githubCopilot') },
{ path: '/cursor', iconSrc: '/assets/sidebar/cursor.svg', label: t('nav.cursorIde') },
{ path: '/claude-extension', icon: Puzzle, label: t('nav.claudeExtension') },
{
path: '/accounts',
icon: Users,
+72
View File
@@ -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<T>(url: string): Promise<T> {
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<ClaudeExtensionSetupPayload>(
`/claude-extension/setup?profile=${encodeURIComponent(profile || '')}&host=${encodeURIComponent(host)}`
),
});
}
+4
View File
@@ -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: '共有データ',
+249
View File
@@ -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 (
<Card className="border-border/60">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription className="mt-1">{description}</CardDescription>
</div>
<CopyButton value={value} label={`Copy ${title}`} />
</div>
</CardHeader>
<CardContent>
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-4 text-xs leading-6 text-foreground">
{value}
</pre>
</CardContent>
</Card>
);
}
export function ClaudeExtensionPage() {
const optionsQuery = useClaudeExtensionOptions();
const [profile, setProfile] = useState<string>('');
const [host, setHost] = useState<string>('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 (
<div className="space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="gap-1">
<Sparkles className="h-3.5 w-3.5" />
Native setup
</Badge>
<Badge variant="outline">VS Code first</Badge>
</div>
<h1 className="text-3xl font-semibold tracking-tight">Claude IDE Extension</h1>
<p className="max-w-3xl text-sm text-muted-foreground">
Generate the correct Claude extension configuration for CCS API profiles, account
instances, CLIProxy modes, Copilot, and default/native Claude flows.
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Target selection</CardTitle>
<CardDescription>
Choose the CCS profile and IDE host you want to configure.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<div className="text-sm font-medium">CCS profile</div>
<Select
value={selectedProfile}
onValueChange={setProfile}
disabled={optionsQuery.isLoading}
>
<SelectTrigger>
<SelectValue placeholder="Select a profile" />
</SelectTrigger>
<SelectContent>
{optionsQuery.data?.profiles.map((option) => (
<SelectItem key={option.name} value={option.name}>
{option.label} ({option.profileType})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{
optionsQuery.data?.profiles.find((option) => option.name === selectedProfile)
?.description
}
</p>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">IDE host</div>
<Select value={host} onValueChange={setHost} disabled={optionsQuery.isLoading}>
<SelectTrigger>
<SelectValue placeholder="Select a host" />
</SelectTrigger>
<SelectContent>
{optionsQuery.data?.hosts.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{optionsQuery.data?.hosts.find((option) => option.id === host)?.description}
</p>
</div>
</CardContent>
</Card>
{optionsQuery.isLoading || (selectedProfile && setupQuery.isLoading) ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Resolving extension setup...
</div>
) : null}
{activeError ? (
<Card className="border-destructive/40">
<CardContent className="flex items-start gap-3 pt-6 text-sm text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>{activeError.message}</div>
</CardContent>
</Card>
) : null}
{setupQuery.data ? (
<>
<div className="grid gap-4 lg:grid-cols-3">
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Code2 className="h-4 w-4" />
Resolved profile
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="font-medium">{setupQuery.data.profile.label}</div>
<div className="text-muted-foreground">{setupQuery.data.profile.description}</div>
<Badge variant="outline">{setupQuery.data.profile.profileType}</Badge>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<FolderCog className="h-4 w-4" />
Shared settings path
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="font-mono text-xs break-all">
{setupQuery.data.sharedSettings.path}
</div>
<div className="text-muted-foreground">
Preferred when you want Claude Code CLI and the IDE extension to share one CCS
profile.
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Copy className="h-4 w-4" />
IDE target
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="font-medium">{setupQuery.data.host.label}</div>
<div className="font-mono text-xs">{setupQuery.data.host.settingsKey}</div>
<div className="text-muted-foreground">
{setupQuery.data.ideSettings.targetLabel}
</div>
</CardContent>
</Card>
</div>
{setupQuery.data.warnings.length > 0 ? (
<Card className="border-amber-400/40 bg-amber-50/50 dark:bg-amber-950/10">
<CardContent className="space-y-2 pt-6 text-sm">
{setupQuery.data.warnings.map((warning) => (
<div key={warning} className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<span>{warning}</span>
</div>
))}
</CardContent>
</Card>
) : null}
<div className="grid gap-6 xl:grid-cols-2">
<CodeBlockCard
title="Preferred shared Claude settings"
description={`Run ${setupQuery.data.sharedSettings.command}, or copy the JSON shape shown here for manual edits.`}
value={setupQuery.data.sharedSettings.json}
/>
<CodeBlockCard
title="IDE-local settings.json snippet"
description={`Replace the full ${setupQuery.data.host.settingsKey} value in ${setupQuery.data.ideSettings.targetLabel}.`}
value={setupQuery.data.ideSettings.json}
/>
</div>
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-base">CLI shortcut</CardTitle>
<CardDescription>
Use the native shared-settings path directly from the terminal.
</CardDescription>
</div>
<CopyButton
value={setupQuery.data.sharedSettings.command}
label="Copy persist command"
/>
</div>
</CardHeader>
<CardContent className="space-y-4">
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-4 text-xs">
{setupQuery.data.sharedSettings.command}
</pre>
{setupQuery.data.notes.length > 0 ? (
<div className="space-y-2 text-sm text-muted-foreground">
{setupQuery.data.notes.map((note) => (
<div key={note}>- {note}</div>
))}
</div>
) : null}
</CardContent>
</Card>
</>
) : null}
</div>
);
}
+2
View File
@@ -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';