mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
feat(config): add Claude IDE extension setup flow
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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={
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -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: '共有データ',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user