Merge pull request #730 from kaitranntt/kai/feat/727-claude-extension-ide-setup

feat: add native Claude IDE extension setup
This commit is contained in:
Kai (Tam Nhu) Tran
2026-03-15 16:06:46 -04:00
committed by GitHub
21 changed files with 3492 additions and 201 deletions
+38
View File
@@ -260,6 +260,44 @@ Defaults:
Detailed guide: [`docs/cursor-integration.md`](./docs/cursor-integration.md)
### Claude IDE Extension Setup
CCS now has a native setup flow for the Anthropic Claude extension in VS Code and compatible hosts.
Use the same resolver in both the CLI and dashboard, so API profiles, CCS auth accounts,
CLIProxy-backed profiles, Copilot, and default-profile continuity all map to the correct env shape.
Preferred shared-settings path:
```bash
ccs persist glm
ccs persist work
ccs persist default
```
This writes the resolved setup to `~/.claude/settings.json`, which is the best option when you want
the Claude CLI and the IDE extension to share one CCS profile.
IDE-local snippet path:
```bash
ccs env glm --format claude-extension --ide vscode
ccs env work --format claude-extension --ide cursor
ccs env default --format claude-extension --ide windsurf
```
This prints a copy-ready `settings.json` snippet for the installed Claude extension host:
- `vscode` / `cursor`: `claudeCode.environmentVariables` plus `claudeCode.disableLoginPrompt`
- `windsurf`: `claude-code.environmentVariables`
Account and continuity-aware flows use `CLAUDE_CONFIG_DIR` instead of Anthropic transport env vars.
CLIProxy and Copilot flows emit the required `ANTHROPIC_*` variables and still depend on their local
proxy/daemon being reachable.
Dashboard parity:
- `ccs config` -> `Claude Extension`
- Select a CCS profile and IDE host to copy either the shared `~/.claude/settings.json` payload or the IDE-local extension snippet
### Parallel Workflows
Run multiple terminals with different providers:
+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');
+49 -92
View File
@@ -6,22 +6,20 @@
*/
import { initUI, header, dim, color, subheader, fail, warn } from '../utils/ui';
import { CLIProxyProvider } from '../cliproxy/types';
import { CLIPROXY_PROFILES, loadSettingsFromFile } from '../auth/profile-detector';
import { getEffectiveEnvVars } from '../cliproxy/config/env-builder';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import { isUnifiedMode, loadUnifiedConfig } from '../config/unified-config-loader';
import { expandPath } from '../utils/helpers';
import { getCcsDir } from '../utils/config-manager';
import { ProfileRegistry } from '../auth/profile-registry';
import { getProfileLookupCandidates } from '../utils/profile-compat';
import { CLAUDE_EXTENSION_HOSTS, type ClaudeExtensionHost } from '../shared/claude-extension-hosts';
import {
renderClaudeExtensionSettingsJson,
resolveClaudeExtensionSetup,
} from '../shared/claude-extension-setup';
type ShellType = 'bash' | 'fish' | 'powershell';
type OutputFormat = 'openai' | 'anthropic' | 'raw';
type OutputFormat = 'openai' | 'anthropic' | 'raw' | 'claude-extension';
const VALID_FORMATS: OutputFormat[] = ['openai', 'anthropic', 'raw'];
const VALID_FORMATS: OutputFormat[] = ['openai', 'anthropic', 'raw', 'claude-extension'];
const VALID_SHELLS: ShellType[] = ['bash', 'fish', 'powershell'];
const VALID_SHELL_INPUTS = ['auto', 'bash', 'zsh', 'fish', 'powershell'] as const;
const VALID_EXTENSION_HOSTS = CLAUDE_EXTENSION_HOSTS.map((host) => host.id);
const VALID_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
/** Auto-detect shell from environment */
@@ -97,46 +95,6 @@ export function findProfile(args: string[], flagsWithValues: string[]): string |
return undefined;
}
/** Check if a profile is a CLIProxy profile */
function isCLIProxyProfile(name: string): boolean {
return (CLIPROXY_PROFILES as readonly string[]).includes(name);
}
/** Resolve env vars for settings-based profiles (glm, km, custom API profiles) */
function resolveSettingsProfile(profileName: string): Record<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 +109,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 +128,9 @@ function showHelp(): void {
` ${color('anthropic', 'command')} ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_MODEL`
);
console.log(` ${color('raw', 'command')} All effective env vars as-is`);
console.log(
` ${color('claude-extension', 'command')} Settings JSON snippet for the Claude IDE extension`
);
console.log('');
console.log(subheader('Examples:'));
@@ -182,6 +146,20 @@ function showHelp(): void {
console.log(
` $ ${color('ccs env agy --format openai --shell fish', 'command')} ${dim('# Fish shell syntax')}`
);
console.log(
` $ ${color('ccs env work --format claude-extension --ide vscode', 'command')} ${dim('# VS Code/Cursor snippet')}`
);
console.log(
` $ ${color('ccs env default --format claude-extension --ide windsurf', 'command')} ${dim('# Clear/replace Windsurf env overrides')}`
);
console.log('');
console.log(subheader('Notes:'));
console.log(
` ${dim('- Use ccs persist <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 +176,12 @@ 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 +200,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'],
]);
+87 -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,12 @@ 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 +767,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 +781,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 +791,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 +819,24 @@ 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 +888,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,
},
};
+44
View File
@@ -0,0 +1,44 @@
export type ClaudeExtensionHost = 'vscode' | 'cursor' | 'windsurf';
export interface ClaudeExtensionHostDefinition {
id: ClaudeExtensionHost;
label: string;
settingsKey: string;
disableLoginPromptKey?: string;
settingsTargetLabel: string;
description: string;
}
export const CLAUDE_EXTENSION_HOSTS: ClaudeExtensionHostDefinition[] = [
{
id: 'vscode',
label: 'VS Code',
settingsKey: 'claudeCode.environmentVariables',
disableLoginPromptKey: 'claudeCode.disableLoginPrompt',
settingsTargetLabel: 'VS Code user or workspace settings.json',
description: 'Official Anthropic VS Code extension with camelCase settings keys.',
},
{
id: 'cursor',
label: 'Cursor',
settingsKey: 'claudeCode.environmentVariables',
disableLoginPromptKey: 'claudeCode.disableLoginPrompt',
settingsTargetLabel: 'Cursor user or workspace settings.json',
description: 'VS Code-compatible host using the Anthropic extension schema.',
},
{
id: 'windsurf',
label: 'Windsurf',
settingsKey: 'claude-code.environmentVariables',
settingsTargetLabel: 'Windsurf user settings.json',
description: 'Current Windsurf Anthropic extension build uses legacy kebab-case keys.',
},
];
export function getClaudeExtensionHostDefinition(
host: ClaudeExtensionHost = 'vscode'
): ClaudeExtensionHostDefinition {
return (
CLAUDE_EXTENSION_HOSTS.find((candidate) => candidate.id === host) ?? CLAUDE_EXTENSION_HOSTS[0]
);
}
+315
View File
@@ -0,0 +1,315 @@
import { loadSettingsFromFile, type ProfileType } from '../auth/profile-detector';
import ProfileDetector from '../auth/profile-detector';
import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance';
import { resolveAccountContextPolicy, isAccountContextMetadata } from '../auth/account-context';
import type { ProfileDetectionResult } from '../auth/profile-detector';
import {
getEffectiveEnvVars,
getRemoteEnvVars,
getCompositeEnvVars,
} from '../cliproxy/config/env-builder';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import { getProxyTarget } from '../cliproxy/proxy-target-resolver';
import { generateCopilotEnv } from '../copilot/copilot-executor';
import InstanceManager from '../management/instance-manager';
import { expandPath } from '../utils/helpers';
import { getClaudeSettingsPath } from '../utils/claude-config-path';
import {
type ClaudeExtensionHost,
type ClaudeExtensionHostDefinition,
getClaudeExtensionHostDefinition,
} from './claude-extension-hosts';
export interface ClaudeExtensionProfileOption {
name: string;
profileType: ProfileType;
label: string;
description: string;
}
export interface ClaudeExtensionSetup {
requestedProfile: string;
resolvedProfileName: string;
profileType: ProfileType;
profileLabel: string;
profileDescription: string;
extensionEnv: Record<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_MAX_TOKENS',
'ANTHROPIC_SAFE_MODE',
'ANTHROPIC_TEMPERATURE',
'ANTHROPIC_SMALL_FAST_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'API_TIMEOUT_MS',
'CLAUDE_CONFIG_DIR',
'DISABLE_NON_ESSENTIAL_MODEL_CALLS',
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
'ENABLE_STREAMING',
'MAX_THINKING_TOKENS',
] as const;
function sortUniqueEnvKeys(keys: Iterable<string>): string[] {
return [...new Set(keys)].sort((left, right) => left.localeCompare(right));
}
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'
? (() => {
if (!result.copilotConfig) {
throw new Error(`Profile "${requestedProfile}" is missing copilot configuration.`);
}
return generateCopilotEnv(result.copilotConfig, continuity.claudeConfigDir);
})()
: (() => {
if (!result.provider) {
throw new Error(
`Profile "${requestedProfile}" is missing CLIProxy provider metadata.`
);
}
const proxyTarget = getProxyTarget();
const port = result.port || CLIPROXY_DEFAULT_PORT;
if (proxyTarget.isRemote) {
warnings.push(
`CLIProxy is configured for remote routing via ${proxyTarget.protocol}://${proxyTarget.host}:${proxyTarget.port}.`
);
return result.isComposite && result.compositeTiers && result.compositeDefaultTier
? getCompositeEnvVars(
result.compositeTiers,
result.compositeDefaultTier,
port,
result.settingsPath,
proxyTarget
)
: getRemoteEnvVars(result.provider, proxyTarget, result.settingsPath);
}
warnings.push(
'CLIProxy-backed profiles require the local or remote proxy endpoint to be reachable.'
);
return result.isComposite && result.compositeTiers && result.compositeDefaultTier
? getCompositeEnvVars(
result.compositeTiers,
result.compositeDefaultTier,
port,
result.settingsPath
)
: getEffectiveEnvVars(result.provider, port, result.settingsPath);
})();
if (!requestedIsDefault && continuity.claudeConfigDir && !env.CLAUDE_CONFIG_DIR) {
env.CLAUDE_CONFIG_DIR = continuity.claudeConfigDir;
notes.push(
`Continuity inheritance adds CLAUDE_CONFIG_DIR from account "${continuity.sourceAccount}".`
);
}
if (result.type === 'copilot') {
warnings.push(
'copilot-api must stay reachable for this profile to work inside the IDE extension.'
);
}
if (Object.keys(env).length === 0) {
throw new Error(`Profile "${requestedProfile}" has no extension environment to export.`);
}
return { extensionEnv: sortEnvRecord(env), warnings, notes, disableLoginPrompt: true };
}
export async function resolveClaudeExtensionSetup(
requestedProfile: string
): Promise<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: sortUniqueEnvKeys([
...CLAUDE_EXTENSION_MANAGED_ENV_KEYS,
...Object.keys(resolved.extensionEnv),
]),
warnings: resolved.warnings,
notes: resolved.notes,
disableLoginPrompt: resolved.disableLoginPrompt,
};
}
export function buildClaudeExtensionSettingsObject(
setup: ClaudeExtensionSetup,
host: ClaudeExtensionHost
): Record<string, unknown> {
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 payload;
}
export function buildSharedClaudeSettingsObject(
setup: ClaudeExtensionSetup
): Record<string, Record<string, string>> {
return { env: setup.extensionEnv };
}
export function renderClaudeExtensionSettingsJson(
setup: ClaudeExtensionSetup,
host: ClaudeExtensionHost
): string {
return JSON.stringify(buildClaudeExtensionSettingsObject(setup, host), null, 2);
}
export function renderSharedClaudeSettingsJson(setup: ClaudeExtensionSetup): string {
return JSON.stringify(buildSharedClaudeSettingsObject(setup), null, 2);
}
export function getClaudeExtensionHostMetadata(
host: ClaudeExtensionHost
): ClaudeExtensionHostDefinition {
return getClaudeExtensionHostDefinition(host);
}
export function getClaudeSharedSettingsPath(): string {
return getClaudeSettingsPath();
}
@@ -0,0 +1,193 @@
import { Router, Request, Response } from 'express';
import {
CLAUDE_EXTENSION_HOSTS,
type ClaudeExtensionHost,
getClaudeExtensionHostDefinition,
} from '../../shared/claude-extension-hosts';
import {
getClaudeSharedSettingsPath,
listClaudeExtensionProfiles,
renderClaudeExtensionSettingsJson,
renderSharedClaudeSettingsJson,
resolveClaudeExtensionSetup,
} from '../../shared/claude-extension-setup';
import {
createClaudeExtensionBinding,
deleteClaudeExtensionBinding,
getClaudeExtensionBinding,
listClaudeExtensionBindings,
updateClaudeExtensionBinding,
} from '../services/claude-extension-binding-service';
import {
applyClaudeExtensionBinding,
getDefaultClaudeExtensionIdeSettingsPath,
resetClaudeExtensionBinding,
resolveClaudeExtensionIdeSettingsPath,
type ClaudeExtensionActionTarget,
verifyClaudeExtensionBinding,
} from '../services/claude-extension-settings-service';
const router = Router();
const VALID_HOSTS = new Set(CLAUDE_EXTENSION_HOSTS.map((host) => host.id));
const VALID_TARGETS = new Set<ClaudeExtensionActionTarget>(['shared', 'ide', 'all']);
function getHostFromRequest(req: Request): ClaudeExtensionHost {
const rawHost = String(req.query.host || 'vscode');
if (!VALID_HOSTS.has(rawHost as ClaudeExtensionHost)) {
throw new Error(
`Invalid host "${rawHost}". Use: ${CLAUDE_EXTENSION_HOSTS.map((host) => host.id).join(', ')}`
);
}
return rawHost as ClaudeExtensionHost;
}
function getActionTarget(req: Request): ClaudeExtensionActionTarget {
const rawTarget =
req.body && typeof req.body.target === 'string' ? req.body.target.trim().toLowerCase() : 'all';
if (!VALID_TARGETS.has(rawTarget as ClaudeExtensionActionTarget)) {
throw new Error('Invalid target. Use: shared, ide, or all');
}
return rawTarget as ClaudeExtensionActionTarget;
}
function serializeBinding(id: string) {
const binding = getClaudeExtensionBinding(id);
return {
...binding,
effectiveIdeSettingsPath: resolveClaudeExtensionIdeSettingsPath(binding),
usesDefaultIdeSettingsPath: !binding.ideSettingsPath,
};
}
function handleRouteError(res: Response, error: unknown): void {
const message = (error as Error).message;
if (message.startsWith('Binding not found')) {
res.status(404).json({ error: message });
return;
}
res.status(400).json({ error: message });
}
router.get('/profiles', (_req: Request, res: Response): void => {
res.json({
profiles: listClaudeExtensionProfiles(),
hosts: CLAUDE_EXTENSION_HOSTS.map((host) => ({
...host,
defaultSettingsPath: getDefaultClaudeExtensionIdeSettingsPath(host.id),
})),
});
});
router.get('/setup', async (req: Request, res: Response): Promise<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: {
path: getDefaultClaudeExtensionIdeSettingsPath(host),
targetLabel: hostDefinition.settingsTargetLabel,
json: renderClaudeExtensionSettingsJson(setup, host),
},
});
} catch (error) {
handleRouteError(res, error);
}
});
router.get('/bindings', (_req: Request, res: Response): void => {
try {
res.json({
bindings: listClaudeExtensionBindings().map((binding) => ({
...binding,
effectiveIdeSettingsPath: resolveClaudeExtensionIdeSettingsPath(binding),
usesDefaultIdeSettingsPath: !binding.ideSettingsPath,
})),
});
} catch (error) {
handleRouteError(res, error);
}
});
router.post('/bindings', (req: Request, res: Response): void => {
try {
const binding = createClaudeExtensionBinding(req.body);
res.status(201).json({ binding: serializeBinding(binding.id) });
} catch (error) {
handleRouteError(res, error);
}
});
router.put('/bindings/:id', (req: Request, res: Response): void => {
try {
const binding = updateClaudeExtensionBinding(req.params.id, req.body);
res.json({ binding: serializeBinding(binding.id) });
} catch (error) {
handleRouteError(res, error);
}
});
router.delete('/bindings/:id', (req: Request, res: Response): void => {
try {
deleteClaudeExtensionBinding(req.params.id);
res.status(204).end();
} catch (error) {
handleRouteError(res, error);
}
});
router.get('/bindings/:id/verify', async (req: Request, res: Response): Promise<void> => {
try {
const binding = getClaudeExtensionBinding(req.params.id);
const status = await verifyClaudeExtensionBinding(binding);
res.json({ binding: serializeBinding(binding.id), ...status });
} catch (error) {
handleRouteError(res, error);
}
});
router.post('/bindings/:id/apply', async (req: Request, res: Response): Promise<void> => {
try {
const binding = getClaudeExtensionBinding(req.params.id);
const status = await applyClaudeExtensionBinding(binding, getActionTarget(req));
res.json({ binding: serializeBinding(binding.id), ...status });
} catch (error) {
handleRouteError(res, error);
}
});
router.post('/bindings/:id/reset', async (req: Request, res: Response): Promise<void> => {
try {
const binding = getClaudeExtensionBinding(req.params.id);
const status = await resetClaudeExtensionBinding(binding, getActionTarget(req));
res.json({ binding: serializeBinding(binding.id), ...status });
} catch (error) {
handleRouteError(res, error);
}
});
export default router;
+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
@@ -0,0 +1,277 @@
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import ProfileDetector from '../../auth/profile-detector';
import type { ClaudeExtensionHost } from '../../shared/claude-extension-hosts';
import { expandPath } from '../../utils/helpers';
import { getCcsDir } from '../../utils/config-manager';
export interface ClaudeExtensionBinding {
id: string;
name: string;
profile: string;
host: ClaudeExtensionHost;
ideSettingsPath?: string;
notes?: string;
createdAt: string;
updatedAt: string;
}
export interface ClaudeExtensionBindingInput {
name: string;
profile: string;
host: ClaudeExtensionHost;
ideSettingsPath?: string;
notes?: string;
}
export interface ClaudeExtensionManagedEnvManifest {
shared: string[];
ide: string[];
}
interface ClaudeExtensionStoredBinding extends ClaudeExtensionBinding {
managedEnvManifest: ClaudeExtensionManagedEnvManifest;
}
interface ClaudeExtensionBindingStore {
bindings: ClaudeExtensionStoredBinding[];
}
const VALID_HOSTS = new Set<ClaudeExtensionHost>(['vscode', 'cursor', 'windsurf']);
function getBindingsFilePath(): string {
return path.join(getCcsDir(), 'claude-extension-bindings.json');
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function normalizeEnvKeyList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value.filter((entry): entry is string => typeof entry === 'string'))]
.map((entry) => entry.trim())
.filter(Boolean)
.sort((left, right) => left.localeCompare(right));
}
function normalizeManagedEnvManifest(value: unknown): ClaudeExtensionManagedEnvManifest {
if (!isRecord(value)) {
return { shared: [], ide: [] };
}
return {
shared: normalizeEnvKeyList(value.shared),
ide: normalizeEnvKeyList(value.ide),
};
}
function toPublicBinding(binding: ClaudeExtensionStoredBinding): ClaudeExtensionBinding {
const { managedEnvManifest: _managedEnvManifest, ...publicBinding } = binding;
return publicBinding;
}
function normalizeBindingInput(input: ClaudeExtensionBindingInput): ClaudeExtensionBindingInput {
const name = input.name?.trim();
const profile = input.profile?.trim();
const ideSettingsPath = input.ideSettingsPath?.trim();
const notes = input.notes?.trim();
if (!name) throw new Error('Binding name is required');
if (!profile) throw new Error('Profile is required');
if (!VALID_HOSTS.has(input.host)) throw new Error(`Unsupported IDE host "${input.host}"`);
try {
new ProfileDetector().detectProfileType(profile);
} catch {
throw new Error(`Unknown profile "${profile}"`);
}
return {
name,
profile,
host: input.host,
ideSettingsPath: ideSettingsPath ? expandPath(ideSettingsPath) : undefined,
notes: notes || undefined,
};
}
function normalizeStoredBinding(value: unknown): ClaudeExtensionStoredBinding | null {
if (!isRecord(value)) return null;
const id = typeof value.id === 'string' ? value.id.trim() : '';
const name = typeof value.name === 'string' ? value.name.trim() : '';
const profile = typeof value.profile === 'string' ? value.profile.trim() : '';
const host = typeof value.host === 'string' ? value.host.trim() : '';
const createdAt = typeof value.createdAt === 'string' ? value.createdAt : '';
const updatedAt = typeof value.updatedAt === 'string' ? value.updatedAt : '';
if (!id || !name || !profile || !VALID_HOSTS.has(host as ClaudeExtensionHost)) {
return null;
}
return {
id,
name,
profile,
host: host as ClaudeExtensionHost,
ideSettingsPath:
typeof value.ideSettingsPath === 'string' && value.ideSettingsPath.trim().length > 0
? value.ideSettingsPath.trim()
: undefined,
notes:
typeof value.notes === 'string' && value.notes.trim().length > 0
? value.notes.trim()
: undefined,
createdAt: createdAt || new Date().toISOString(),
updatedAt: updatedAt || new Date().toISOString(),
managedEnvManifest: normalizeManagedEnvManifest(value.managedEnvManifest),
};
}
function readBindingsStore(): ClaudeExtensionBindingStore {
const filePath = getBindingsFilePath();
if (!fs.existsSync(filePath)) {
return { bindings: [] };
}
const raw = fs.readFileSync(filePath, 'utf8');
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch (error) {
throw new Error(
`Failed to parse Claude extension bindings store at ${filePath}: ${(error as Error).message}`
);
}
const bindings = Array.isArray(parsed)
? parsed
: isRecord(parsed) && Array.isArray(parsed.bindings)
? parsed.bindings
: [];
return {
bindings: bindings
.map((entry) => normalizeStoredBinding(entry))
.filter((entry): entry is ClaudeExtensionStoredBinding => entry !== null)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)),
};
}
function writeBindingsStore(store: ClaudeExtensionBindingStore): void {
const filePath = getBindingsFilePath();
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.tmp.${process.pid}-${Date.now()}-${randomUUID()}`;
try {
fs.writeFileSync(tempPath, JSON.stringify(store, null, 2) + '\n', 'utf8');
fs.renameSync(tempPath, filePath);
} catch (error) {
if (fs.existsSync(tempPath)) {
fs.rmSync(tempPath, { force: true });
}
throw error;
}
}
export function listClaudeExtensionBindings(): ClaudeExtensionBinding[] {
return readBindingsStore().bindings.map((binding) => toPublicBinding(binding));
}
export function getClaudeExtensionBinding(id: string): ClaudeExtensionBinding {
const binding = readBindingsStore().bindings.find((entry) => entry.id === id);
if (!binding) {
throw new Error(`Binding not found: ${id}`);
}
return toPublicBinding(binding);
}
export function getClaudeExtensionManagedEnvManifest(
id: string
): ClaudeExtensionManagedEnvManifest {
const binding = readBindingsStore().bindings.find((entry) => entry.id === id);
if (!binding) {
throw new Error(`Binding not found: ${id}`);
}
return binding.managedEnvManifest;
}
export function updateClaudeExtensionManagedEnvManifest(
id: string,
updates: Partial<ClaudeExtensionManagedEnvManifest>
): void {
const store = readBindingsStore();
const index = store.bindings.findIndex((entry) => entry.id === id);
if (index === -1) {
throw new Error(`Binding not found: ${id}`);
}
const current = store.bindings[index];
store.bindings[index] = {
...current,
managedEnvManifest: {
shared:
updates.shared !== undefined
? normalizeEnvKeyList(updates.shared)
: current.managedEnvManifest.shared,
ide:
updates.ide !== undefined
? normalizeEnvKeyList(updates.ide)
: current.managedEnvManifest.ide,
},
};
writeBindingsStore(store);
}
export function createClaudeExtensionBinding(
input: ClaudeExtensionBindingInput
): ClaudeExtensionBinding {
const normalized = normalizeBindingInput(input);
const store = readBindingsStore();
const timestamp = new Date().toISOString();
const binding: ClaudeExtensionStoredBinding = {
managedEnvManifest: { shared: [], ide: [] },
id: randomUUID(),
createdAt: timestamp,
updatedAt: timestamp,
...normalized,
};
store.bindings.unshift(binding);
writeBindingsStore(store);
return toPublicBinding(binding);
}
export function updateClaudeExtensionBinding(
id: string,
input: ClaudeExtensionBindingInput
): ClaudeExtensionBinding {
const normalized = normalizeBindingInput(input);
const store = readBindingsStore();
const index = store.bindings.findIndex((entry) => entry.id === id);
if (index === -1) {
throw new Error(`Binding not found: ${id}`);
}
const updated: ClaudeExtensionStoredBinding = {
...store.bindings[index],
...normalized,
updatedAt: new Date().toISOString(),
};
store.bindings[index] = updated;
writeBindingsStore(store);
return toPublicBinding(updated);
}
export function deleteClaudeExtensionBinding(id: string): void {
const store = readBindingsStore();
const nextBindings = store.bindings.filter((entry) => entry.id !== id);
if (nextBindings.length === store.bindings.length) {
throw new Error(`Binding not found: ${id}`);
}
writeBindingsStore({ bindings: nextBindings });
}
@@ -0,0 +1,610 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { randomUUID } from 'crypto';
import {
buildClaudeExtensionSettingsObject,
resolveClaudeExtensionSetup,
} from '../../shared/claude-extension-setup';
import {
type ClaudeExtensionHost,
getClaudeExtensionHostDefinition,
} from '../../shared/claude-extension-hosts';
import { getClaudeSettingsPath } from '../../utils/claude-config-path';
import { expandPath } from '../../utils/helpers';
import {
getClaudeExtensionManagedEnvManifest,
updateClaudeExtensionManagedEnvManifest,
type ClaudeExtensionBinding,
} from './claude-extension-binding-service';
export type ClaudeExtensionActionTarget = 'shared' | 'ide' | 'all';
export type ClaudeExtensionFileState = 'applied' | 'drifted' | 'missing' | 'unconfigured';
export interface ClaudeExtensionTargetStatus {
target: 'shared' | 'ide';
path: string;
exists: boolean;
mtime: number | null;
state: ClaudeExtensionFileState;
message: string;
}
export interface ClaudeExtensionBindingStatus {
bindingId: string;
sharedSettings: ClaudeExtensionTargetStatus;
ideSettings: ClaudeExtensionTargetStatus;
}
interface JsonDocument {
exists: boolean;
data: Record<string, unknown>;
mtime: number | null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function sortStringRecord(record: Record<string, string>): Record<string, string> {
return Object.fromEntries(
Object.entries(record).sort(([left], [right]) => left.localeCompare(right))
);
}
function toStringRecord(record: Record<string, unknown>): Record<string, string> {
const normalized: Record<string, string> = {};
for (const [key, value] of Object.entries(record)) {
if (typeof value === 'string') {
normalized[key] = value;
}
}
return normalized;
}
function uniqueFileNonce(): string {
return `${process.pid}-${Date.now()}-${randomUUID()}`;
}
function stripJsonComments(input: string): string {
let output = '';
let inString = false;
let escaping = false;
let inLineComment = false;
let inBlockComment = false;
for (let index = 0; index < input.length; index += 1) {
const char = input[index];
const nextChar = input[index + 1];
if (inLineComment) {
if (char === '\n') {
inLineComment = false;
output += char;
}
continue;
}
if (inBlockComment) {
if (char === '*' && nextChar === '/') {
inBlockComment = false;
index += 1;
continue;
}
if (char === '\n') {
output += char;
}
continue;
}
if (inString) {
output += char;
if (escaping) {
escaping = false;
} else if (char === '\\') {
escaping = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
output += char;
continue;
}
if (char === '/' && nextChar === '/') {
inLineComment = true;
index += 1;
continue;
}
if (char === '/' && nextChar === '*') {
inBlockComment = true;
index += 1;
continue;
}
output += char;
}
return output;
}
function stripTrailingCommas(input: string): string {
let output = '';
let inString = false;
let escaping = false;
for (let index = 0; index < input.length; index += 1) {
const char = input[index];
if (inString) {
output += char;
if (escaping) {
escaping = false;
} else if (char === '\\') {
escaping = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
output += char;
continue;
}
if (char === ',') {
let lookahead = index + 1;
while (lookahead < input.length && /\s/.test(input[lookahead])) {
lookahead += 1;
}
if (input[lookahead] === '}' || input[lookahead] === ']') {
continue;
}
}
output += char;
}
return output;
}
function parseJsonDocumentObject(raw: string, filePath: string): Record<string, unknown> {
const normalized = stripTrailingCommas(stripJsonComments(raw));
let parsed: unknown;
try {
parsed = JSON.parse(normalized) as unknown;
} catch (error) {
throw new Error(`Failed to parse ${filePath}: ${(error as Error).message}`);
}
if (!isRecord(parsed)) {
throw new Error(`Expected a JSON object in ${filePath}`);
}
return parsed;
}
function readJsonDocument(filePath: string): JsonDocument {
if (!fs.existsSync(filePath)) {
return { exists: false, data: {}, mtime: null };
}
if (fs.lstatSync(filePath).isSymbolicLink()) {
throw new Error(`Refusing to manage symlinked file: ${filePath}`);
}
const raw = fs.readFileSync(filePath, 'utf8');
return {
exists: true,
data: parseJsonDocumentObject(raw, filePath),
mtime: fs.statSync(filePath).mtimeMs,
};
}
function backupIfPresent(filePath: string, suffix: string): void {
if (!fs.existsSync(filePath)) return;
const backupPath = `${filePath}.${suffix}.${uniqueFileNonce()}`;
fs.copyFileSync(filePath, backupPath, fs.constants.COPYFILE_EXCL);
}
function writeJsonDocument(
filePath: string,
data: Record<string, unknown>,
backupSuffix: string
): void {
if (fs.existsSync(filePath) && fs.lstatSync(filePath).isSymbolicLink()) {
throw new Error(`Refusing to manage symlinked file: ${filePath}`);
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
backupIfPresent(filePath, backupSuffix);
const tempPath = `${filePath}.tmp.${uniqueFileNonce()}`;
try {
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
fs.renameSync(tempPath, filePath);
} catch (error) {
if (fs.existsSync(tempPath)) {
fs.rmSync(tempPath, { force: true });
}
throw error;
}
}
function getManagedSharedEnv(
data: Record<string, unknown>,
managedKeys: ReadonlySet<string>
): Record<string, string> {
const rawEnv = isRecord(data.env) ? toStringRecord(data.env) : {};
const managed: Record<string, string> = {};
for (const key of managedKeys) {
if (typeof rawEnv[key] === 'string') {
managed[key] = rawEnv[key];
}
}
return sortStringRecord(managed);
}
interface ExtensionEnvEntry {
name: string;
value: string;
}
function getExtensionEnvEntries(value: unknown): ExtensionEnvEntry[] {
if (!Array.isArray(value)) return [];
return value
.map((entry) => {
if (!isRecord(entry) || typeof entry.name !== 'string' || typeof entry.value !== 'string') {
return null;
}
return { name: entry.name, value: entry.value };
})
.filter((entry): entry is ExtensionEnvEntry => entry !== null);
}
function getExtensionEnvMap(
value: unknown,
managedKeys?: ReadonlySet<string>
): Record<string, string> {
const entries = getExtensionEnvEntries(value)
.filter((entry) => !managedKeys || managedKeys.has(entry.name))
.map((entry) => [entry.name, entry.value] as const);
return sortStringRecord(Object.fromEntries(entries));
}
function mergeManagedExtensionEnvEntries(
value: unknown,
managedKeys: ReadonlySet<string>,
nextManagedEnv: Record<string, string>
): ExtensionEnvEntry[] {
const preservedEntries = getExtensionEnvEntries(value).filter(
(entry) => !managedKeys.has(entry.name)
);
const managedEntries = Object.entries(sortStringRecord(nextManagedEnv)).map(
([name, entryValue]) => ({
name,
value: entryValue,
})
);
return [...preservedEntries, ...managedEntries];
}
function recordsMatch(left: Record<string, string>, right: Record<string, string>): boolean {
return JSON.stringify(sortStringRecord(left)) === JSON.stringify(sortStringRecord(right));
}
function booleansMatch(left: boolean | undefined, right: boolean | undefined): boolean {
return left === right;
}
function targetEnabled(target: ClaudeExtensionActionTarget, candidate: 'shared' | 'ide'): boolean {
return target === 'all' || target === candidate;
}
function getManagedKeysForTarget(
binding: ClaudeExtensionBinding,
target: 'shared' | 'ide',
currentResolvedKeys: string[]
): Set<string> {
const manifest = getClaudeExtensionManagedEnvManifest(binding.id);
const manifestKeys = target === 'shared' ? manifest.shared : manifest.ide;
return new Set([...currentResolvedKeys, ...manifestKeys]);
}
function getCurrentResolvedManagedKeys(
setup: Awaited<ReturnType<typeof resolveClaudeExtensionSetup>>
): string[] {
return [...new Set([...setup.removeEnvKeys, ...Object.keys(setup.extensionEnv)])].sort(
(left, right) => left.localeCompare(right)
);
}
export function getDefaultClaudeExtensionIdeSettingsPath(host: ClaudeExtensionHost): string {
if (process.platform === 'darwin') {
if (host === 'vscode') {
return path.join(
os.homedir(),
'Library',
'Application Support',
'Code',
'User',
'settings.json'
);
}
if (host === 'cursor') {
return path.join(
os.homedir(),
'Library',
'Application Support',
'Cursor',
'User',
'settings.json'
);
}
return path.join(
os.homedir(),
'Library',
'Application Support',
'Windsurf',
'User',
'settings.json'
);
}
if (process.platform === 'win32') {
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
if (host === 'vscode') return path.join(appData, 'Code', 'User', 'settings.json');
if (host === 'cursor') return path.join(appData, 'Cursor', 'User', 'settings.json');
return path.join(appData, 'Windsurf', 'User', 'settings.json');
}
if (host === 'vscode') return path.join(os.homedir(), '.config', 'Code', 'User', 'settings.json');
if (host === 'cursor')
return path.join(os.homedir(), '.config', 'Cursor', 'User', 'settings.json');
return path.join(os.homedir(), '.config', 'Windsurf', 'User', 'settings.json');
}
export function resolveClaudeExtensionIdeSettingsPath(binding: ClaudeExtensionBinding): string {
return binding.ideSettingsPath
? expandPath(binding.ideSettingsPath)
: getDefaultClaudeExtensionIdeSettingsPath(binding.host);
}
export async function verifyClaudeExtensionBinding(
binding: ClaudeExtensionBinding
): Promise<ClaudeExtensionBindingStatus> {
const setup = await resolveClaudeExtensionSetup(binding.profile);
const currentResolvedKeys = getCurrentResolvedManagedKeys(setup);
const sharedManagedKeys = getManagedKeysForTarget(binding, 'shared', currentResolvedKeys);
const ideManagedKeys = getManagedKeysForTarget(binding, 'ide', currentResolvedKeys);
const sharedPath = getClaudeSettingsPath();
const idePath = resolveClaudeExtensionIdeSettingsPath(binding);
const sharedDoc = readJsonDocument(sharedPath);
const ideDoc = readJsonDocument(idePath);
const hostDefinition = getClaudeExtensionHostDefinition(binding.host);
const expectedIde = buildClaudeExtensionSettingsObject(setup, binding.host);
const expectedIdeEnv = getExtensionEnvMap(
expectedIde[hostDefinition.settingsKey],
ideManagedKeys
);
const expectedDisablePrompt = hostDefinition.disableLoginPromptKey
? (expectedIde[hostDefinition.disableLoginPromptKey] as boolean | undefined)
: undefined;
const actualIdeEnv = getExtensionEnvMap(ideDoc.data[hostDefinition.settingsKey], ideManagedKeys);
const actualDisablePrompt = hostDefinition.disableLoginPromptKey
? (ideDoc.data[hostDefinition.disableLoginPromptKey] as boolean | undefined)
: undefined;
const expectedShared = sortStringRecord(setup.extensionEnv);
const actualShared = getManagedSharedEnv(sharedDoc.data, sharedManagedKeys);
const missingSharedState: ClaudeExtensionFileState =
Object.keys(expectedShared).length > 0 ? 'missing' : 'applied';
const missingIdeState: ClaudeExtensionFileState =
Object.keys(expectedIdeEnv).length > 0 || expectedDisablePrompt === true
? 'missing'
: 'applied';
const sharedSettings = !sharedDoc.exists
? {
target: 'shared' as const,
path: sharedPath,
exists: false,
mtime: null,
state: missingSharedState,
message:
Object.keys(expectedShared).length > 0
? 'Shared Claude settings file does not exist yet.'
: 'No shared CCS-managed values are required.',
}
: recordsMatch(actualShared, expectedShared)
? {
target: 'shared' as const,
path: sharedPath,
exists: true,
mtime: sharedDoc.mtime,
state: 'applied' as const,
message: 'Shared Claude settings match this binding.',
}
: Object.keys(actualShared).length === 0
? {
target: 'shared' as const,
path: sharedPath,
exists: true,
mtime: sharedDoc.mtime,
state: 'unconfigured' as const,
message: 'Shared Claude settings are not configured for this binding.',
}
: {
target: 'shared' as const,
path: sharedPath,
exists: true,
mtime: sharedDoc.mtime,
state: 'drifted' as const,
message: 'Shared Claude settings differ from the expected managed values.',
};
const ideSettings = !ideDoc.exists
? {
target: 'ide' as const,
path: idePath,
exists: false,
mtime: null,
state: missingIdeState,
message:
Object.keys(expectedIdeEnv).length > 0 || expectedDisablePrompt === true
? `${hostDefinition.label} settings file does not exist yet.`
: 'No IDE-local CCS-managed values are required.',
}
: recordsMatch(actualIdeEnv, expectedIdeEnv) &&
booleansMatch(actualDisablePrompt, expectedDisablePrompt)
? {
target: 'ide' as const,
path: idePath,
exists: true,
mtime: ideDoc.mtime,
state: 'applied' as const,
message: `${hostDefinition.label} settings match this binding.`,
}
: Object.keys(actualIdeEnv).length === 0 && actualDisablePrompt === undefined
? {
target: 'ide' as const,
path: idePath,
exists: true,
mtime: ideDoc.mtime,
state: 'unconfigured' as const,
message: `${hostDefinition.label} settings are not configured for this binding.`,
}
: {
target: 'ide' as const,
path: idePath,
exists: true,
mtime: ideDoc.mtime,
state: 'drifted' as const,
message: `${hostDefinition.label} settings differ from the expected managed values.`,
};
return { bindingId: binding.id, sharedSettings, ideSettings };
}
export async function applyClaudeExtensionBinding(
binding: ClaudeExtensionBinding,
target: ClaudeExtensionActionTarget = 'all'
): Promise<ClaudeExtensionBindingStatus> {
const setup = await resolveClaudeExtensionSetup(binding.profile);
const currentResolvedKeys = getCurrentResolvedManagedKeys(setup);
if (targetEnabled(target, 'shared')) {
const managedKeys = getManagedKeysForTarget(binding, 'shared', currentResolvedKeys);
const filePath = getClaudeSettingsPath();
const document = readJsonDocument(filePath);
const env = isRecord(document.data.env) ? { ...document.data.env } : {};
for (const key of managedKeys) delete env[key];
for (const [key, value] of Object.entries(setup.extensionEnv)) env[key] = value;
const nextData = { ...document.data };
const nextEnv = toStringRecord(env);
if (Object.keys(nextEnv).length > 0) nextData.env = sortStringRecord(nextEnv);
else delete nextData.env;
if (document.exists || Object.keys(setup.extensionEnv).length > 0) {
writeJsonDocument(filePath, nextData, 'backup');
}
updateClaudeExtensionManagedEnvManifest(binding.id, {
shared: Object.keys(setup.extensionEnv),
});
}
if (targetEnabled(target, 'ide')) {
const managedKeys = getManagedKeysForTarget(binding, 'ide', currentResolvedKeys);
const hostDefinition = getClaudeExtensionHostDefinition(binding.host);
const filePath = resolveClaudeExtensionIdeSettingsPath(binding);
const document = readJsonDocument(filePath);
const nextData = { ...document.data };
const payload = buildClaudeExtensionSettingsObject(setup, binding.host);
const mergedEnvEntries = mergeManagedExtensionEnvEntries(
document.data[hostDefinition.settingsKey],
managedKeys,
setup.extensionEnv
);
if (mergedEnvEntries.length > 0) {
nextData[hostDefinition.settingsKey] = mergedEnvEntries;
} else {
delete nextData[hostDefinition.settingsKey];
}
if (hostDefinition.disableLoginPromptKey) {
if (payload[hostDefinition.disableLoginPromptKey] === true) {
nextData[hostDefinition.disableLoginPromptKey] = true;
} else {
delete nextData[hostDefinition.disableLoginPromptKey];
}
}
if (
document.exists ||
Object.keys(setup.extensionEnv).length > 0 ||
payload[hostDefinition.disableLoginPromptKey ?? ''] === true
) {
writeJsonDocument(filePath, nextData, 'ccs-backup');
}
updateClaudeExtensionManagedEnvManifest(binding.id, {
ide: Object.keys(setup.extensionEnv),
});
}
return verifyClaudeExtensionBinding(binding);
}
export async function resetClaudeExtensionBinding(
binding: ClaudeExtensionBinding,
target: ClaudeExtensionActionTarget = 'all'
): Promise<ClaudeExtensionBindingStatus> {
const setup = await resolveClaudeExtensionSetup(binding.profile);
const currentResolvedKeys = getCurrentResolvedManagedKeys(setup);
if (targetEnabled(target, 'shared')) {
const managedKeys = getManagedKeysForTarget(binding, 'shared', currentResolvedKeys);
const filePath = getClaudeSettingsPath();
const document = readJsonDocument(filePath);
if (document.exists) {
const env = isRecord(document.data.env) ? { ...document.data.env } : {};
for (const key of managedKeys) delete env[key];
const nextData = { ...document.data };
const nextEnv = toStringRecord(env);
if (Object.keys(nextEnv).length > 0) nextData.env = sortStringRecord(nextEnv);
else delete nextData.env;
writeJsonDocument(filePath, nextData, 'backup');
}
updateClaudeExtensionManagedEnvManifest(binding.id, { shared: [] });
}
if (targetEnabled(target, 'ide')) {
const managedKeys = getManagedKeysForTarget(binding, 'ide', currentResolvedKeys);
const hostDefinition = getClaudeExtensionHostDefinition(binding.host);
const filePath = resolveClaudeExtensionIdeSettingsPath(binding);
const document = readJsonDocument(filePath);
if (document.exists) {
const nextData = { ...document.data };
const preservedEntries = getExtensionEnvEntries(
document.data[hostDefinition.settingsKey]
).filter((entry) => !managedKeys.has(entry.name));
if (preservedEntries.length > 0) {
nextData[hostDefinition.settingsKey] = preservedEntries;
} else {
delete nextData[hostDefinition.settingsKey];
}
if (hostDefinition.disableLoginPromptKey) {
delete nextData[hostDefinition.disableLoginPromptKey];
}
writeJsonDocument(filePath, nextData, 'ccs-backup');
}
updateClaudeExtensionManagedEnvManifest(binding.id, { ide: [] });
}
return verifyClaudeExtensionBinding(binding);
}
@@ -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,483 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import express from 'express';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { Server } from 'http';
import claudeExtensionRoutes from '../../../src/web-server/routes/claude-extension-routes';
import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types';
import { saveUnifiedConfig } from '../../../src/config/unified-config-loader';
describe('web-server claude-extension-routes', () => {
let server: Server;
let baseUrl = '';
let tempHome = '';
let originalCcsHome: string | undefined;
beforeAll(async () => {
const app = express();
app.use(express.json());
app.use('/api/claude-extension', claudeExtensionRoutes);
await new Promise<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'
);
fs.writeFileSync(
path.join(ccsDir, 'rich.settings.json'),
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://rich.example.test',
ANTHROPIC_API_KEY: 'sk-ant-rich-123456',
ANTHROPIC_MODEL: 'claude-opus-4-1',
ANTHROPIC_MAX_TOKENS: '65536',
API_TIMEOUT_MS: '120000',
EXPERIMENTAL_ROUTER_HEADER: 'tenant-alpha',
},
},
null,
2
) + '\n'
);
const config = createEmptyUnifiedConfig();
config.profiles.glm = {
type: 'api',
settings: path.join(ccsDir, 'glm.settings.json'),
};
config.profiles.rich = {
type: 'api',
settings: path.join(ccsDir, 'rich.settings.json'),
};
config.accounts.work = {
created: '2026-03-15T00:00:00.000Z',
last_used: null,
context_mode: 'isolated',
};
config.default = 'work';
saveUnifiedConfig(config);
});
afterEach(() => {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('lists profile options and IDE host targets', async () => {
const response = await fetch(`${baseUrl}/api/claude-extension/profiles`);
expect(response.status).toBe(200);
const payload = (await response.json()) as {
profiles: Array<{ name: string }>;
hosts: Array<{ id: string; defaultSettingsPath: string }>;
};
expect(payload.profiles.some((profile) => profile.name === 'default')).toBe(true);
expect(payload.profiles.some((profile) => profile.name === 'glm')).toBe(true);
expect(payload.profiles.some((profile) => profile.name === 'work')).toBe(true);
expect(payload.profiles.some((profile) => profile.name === 'gemini')).toBe(true);
expect(payload.hosts.map((host) => host.id)).toEqual(['vscode', 'cursor', 'windsurf']);
expect(payload.hosts.every((host) => host.defaultSettingsPath.endsWith('settings.json'))).toBe(
true
);
});
it('renders VS Code setup for API profiles with disableLoginPrompt', async () => {
const response = await fetch(`${baseUrl}/api/claude-extension/setup?profile=glm&host=vscode`);
expect(response.status).toBe(200);
const payload = (await response.json()) as {
host: { settingsKey: string; disableLoginPromptKey?: string };
ideSettings: { json: string };
sharedSettings: { command: string; json: string };
};
expect(payload.host.settingsKey).toBe('claudeCode.environmentVariables');
expect(payload.host.disableLoginPromptKey).toBe('claudeCode.disableLoginPrompt');
expect(payload.ideSettings.json).toContain('"claudeCode.disableLoginPrompt": true');
expect(payload.ideSettings.json).toContain('"ANTHROPIC_API_KEY"');
expect(payload.sharedSettings.command).toBe('ccs persist glm');
expect(payload.sharedSettings.json).toContain('"env"');
});
it('renders Windsurf setup for default account resolution via CLAUDE_CONFIG_DIR', async () => {
const response = await fetch(
`${baseUrl}/api/claude-extension/setup?profile=default&host=windsurf`
);
expect(response.status).toBe(200);
const payload = (await response.json()) as {
profile: { profileType: string; resolvedProfileName: string };
host: { settingsKey: string };
ideSettings: { json: string };
sharedSettings: { command: string };
};
expect(payload.profile.profileType).toBe('account');
expect(payload.profile.resolvedProfileName).toBe('work');
expect(payload.host.settingsKey).toBe('claude-code.environmentVariables');
expect(payload.ideSettings.json).toContain('"claude-code.environmentVariables"');
expect(payload.ideSettings.json).toContain('"CLAUDE_CONFIG_DIR"');
expect(payload.sharedSettings.command).toBe('ccs persist default');
});
it('creates a binding and applies managed settings to shared + IDE targets', async () => {
const ideSettingsPath = path.join(tempHome, 'ide', 'vscode', 'settings.json');
const createResponse = await fetch(`${baseUrl}/api/claude-extension/bindings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'GLM in VS Code',
profile: 'glm',
host: 'vscode',
ideSettingsPath,
}),
});
expect(createResponse.status).toBe(201);
const created = (await createResponse.json()) as {
binding: { id: string; effectiveIdeSettingsPath: string; usesDefaultIdeSettingsPath: boolean };
};
expect(created.binding.effectiveIdeSettingsPath).toBe(ideSettingsPath);
expect(created.binding.usesDefaultIdeSettingsPath).toBe(false);
const applyResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/apply`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'all' }),
}
);
expect(applyResponse.status).toBe(200);
const applied = (await applyResponse.json()) as {
sharedSettings: { state: string };
ideSettings: { state: string };
};
expect(applied.sharedSettings.state).toBe('applied');
expect(applied.ideSettings.state).toBe('applied');
const sharedSettingsPath = path.join(tempHome, '.claude', 'settings.json');
const sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf8')) as {
env?: Record<string, string>;
};
const ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record<
string,
unknown
>;
expect(sharedSettings.env?.ANTHROPIC_API_KEY).toBe('sk-ant-test-123456');
expect(sharedSettings.env?.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5');
expect(
Array.isArray(ideSettings['claudeCode.environmentVariables']) &&
(ideSettings['claudeCode.environmentVariables'] as Array<{ name: string }>).some(
(entry) => entry.name === 'ANTHROPIC_API_KEY'
)
).toBe(true);
expect(ideSettings['claudeCode.disableLoginPrompt']).toBe(true);
});
it('resets only managed keys and preserves unrelated shared + IDE settings', async () => {
const ideSettingsPath = path.join(tempHome, 'ide', 'cursor', 'settings.json');
fs.mkdirSync(path.dirname(ideSettingsPath), { recursive: true });
fs.mkdirSync(path.join(tempHome, '.claude'), { recursive: true });
fs.writeFileSync(
path.join(tempHome, '.claude', 'settings.json'),
JSON.stringify(
{
theme: 'dark',
env: {
KEEP_ME: '1',
ANTHROPIC_API_KEY: 'stale',
},
},
null,
2
) + '\n'
);
fs.writeFileSync(
ideSettingsPath,
JSON.stringify(
{
'editor.fontSize': 14,
'claudeCode.environmentVariables': [{ name: 'ANTHROPIC_API_KEY', value: 'stale' }],
'claudeCode.disableLoginPrompt': true,
},
null,
2
) + '\n'
);
const createResponse = await fetch(`${baseUrl}/api/claude-extension/bindings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Cursor reset',
profile: 'glm',
host: 'cursor',
ideSettingsPath,
}),
});
const created = (await createResponse.json()) as { binding: { id: string } };
const resetResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/reset`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'all' }),
}
);
expect(resetResponse.status).toBe(200);
const resetPayload = (await resetResponse.json()) as {
sharedSettings: { state: string };
ideSettings: { state: string };
};
expect(resetPayload.sharedSettings.state).toBe('unconfigured');
expect(resetPayload.ideSettings.state).toBe('unconfigured');
const sharedSettings = JSON.parse(
fs.readFileSync(path.join(tempHome, '.claude', 'settings.json'), 'utf8')
) as {
theme?: string;
env?: Record<string, string>;
};
const ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record<
string,
unknown
>;
expect(sharedSettings.theme).toBe('dark');
expect(sharedSettings.env?.KEEP_ME).toBe('1');
expect(sharedSettings.env?.ANTHROPIC_API_KEY).toBeUndefined();
expect(ideSettings['editor.fontSize']).toBe(14);
expect(ideSettings['claudeCode.environmentVariables']).toBeUndefined();
expect(ideSettings['claudeCode.disableLoginPrompt']).toBeUndefined();
});
it('removes optional shared env keys even after the profile payload shrinks', async () => {
const ideSettingsPath = path.join(tempHome, 'ide', 'vscode', 'settings.json');
const createResponse = await fetch(`${baseUrl}/api/claude-extension/bindings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Rich env binding',
profile: 'rich',
host: 'vscode',
ideSettingsPath,
}),
});
const created = (await createResponse.json()) as { binding: { id: string } };
const applyResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/apply`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'shared' }),
}
);
expect(applyResponse.status).toBe(200);
const sharedSettingsPath = path.join(tempHome, '.claude', 'settings.json');
let sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf8')) as {
env?: Record<string, string>;
};
expect(sharedSettings.env?.ANTHROPIC_API_KEY).toBe('sk-ant-rich-123456');
expect(sharedSettings.env?.ANTHROPIC_MAX_TOKENS).toBe('65536');
expect(sharedSettings.env?.API_TIMEOUT_MS).toBe('120000');
expect(sharedSettings.env?.EXPERIMENTAL_ROUTER_HEADER).toBe('tenant-alpha');
fs.writeFileSync(
path.join(tempHome, '.ccs', 'rich.settings.json'),
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://rich.example.test',
ANTHROPIC_API_KEY: 'sk-ant-rich-123456',
ANTHROPIC_MODEL: 'claude-opus-4-1',
},
},
null,
2
) + '\n'
);
const resetResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/reset`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'shared' }),
}
);
expect(resetResponse.status).toBe(200);
const verifyResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/verify`
);
expect(verifyResponse.status).toBe(200);
const verified = (await verifyResponse.json()) as {
sharedSettings: { state: string };
};
expect(verified.sharedSettings.state).toBe('unconfigured');
sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf8')) as {
env?: Record<string, string>;
};
expect(sharedSettings.env?.ANTHROPIC_API_KEY).toBeUndefined();
expect(sharedSettings.env?.ANTHROPIC_MAX_TOKENS).toBeUndefined();
expect(sharedSettings.env?.API_TIMEOUT_MS).toBeUndefined();
expect(sharedSettings.env?.EXPERIMENTAL_ROUTER_HEADER).toBeUndefined();
});
it('preserves unrelated IDE env entries while applying and resetting managed values', async () => {
const ideSettingsPath = path.join(tempHome, 'ide', 'vscode', 'settings.json');
fs.mkdirSync(path.dirname(ideSettingsPath), { recursive: true });
fs.writeFileSync(
ideSettingsPath,
`{
// VS Code stores JSONC here, not strict JSON
"editor.tabSize": 2,
"claudeCode.environmentVariables": [
{ "name": "KEEP_ME", "value": "1" },
{ "name": "ANTHROPIC_API_KEY", "value": "stale" },
],
"claudeCode.disableLoginPrompt": false,
}
`
);
const createResponse = await fetch(`${baseUrl}/api/claude-extension/bindings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'VS Code preserved env',
profile: 'glm',
host: 'vscode',
ideSettingsPath,
}),
});
const created = (await createResponse.json()) as { binding: { id: string } };
const applyResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/apply`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'ide' }),
}
);
expect(applyResponse.status).toBe(200);
let ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record<string, unknown>;
const appliedEnv = ideSettings['claudeCode.environmentVariables'] as Array<{
name: string;
value: string;
}>;
expect(appliedEnv.some((entry) => entry.name === 'KEEP_ME' && entry.value === '1')).toBe(true);
expect(
appliedEnv.some((entry) => entry.name === 'ANTHROPIC_API_KEY' && entry.value === 'sk-ant-test-123456')
).toBe(true);
const verifyAppliedResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/verify`
);
expect(verifyAppliedResponse.status).toBe(200);
const verifiedApplied = (await verifyAppliedResponse.json()) as {
ideSettings: { state: string };
};
expect(verifiedApplied.ideSettings.state).toBe('applied');
const resetResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/reset`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'ide' }),
}
);
expect(resetResponse.status).toBe(200);
ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record<string, unknown>;
expect(ideSettings['editor.tabSize']).toBe(2);
expect(ideSettings['claudeCode.disableLoginPrompt']).toBeUndefined();
expect(ideSettings['claudeCode.environmentVariables']).toEqual([{ name: 'KEEP_ME', value: '1' }]);
const verifyResetResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/verify`
);
expect(verifyResetResponse.status).toBe(200);
const verifiedReset = (await verifyResetResponse.json()) as {
ideSettings: { state: string };
};
expect(verifiedReset.ideSettings.state).toBe('unconfigured');
});
it('rejects bindings for profiles that do not exist', async () => {
const response = await fetch(`${baseUrl}/api/claude-extension/bindings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Broken profile binding',
profile: 'does-not-exist',
host: 'vscode',
}),
});
expect(response.status).toBe(400);
const payload = (await response.json()) as { error: string };
expect(payload.error).toContain('Unknown profile');
});
});
+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={
+5 -1
View File
@@ -11,6 +11,7 @@ import {
BarChart3,
Gauge,
Github,
Puzzle,
TerminalSquare,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
@@ -115,7 +116,10 @@ function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] {
},
{
title: t('nav.compatibleClis'),
items: [{ path: '/droid', icon: TerminalSquare, label: t('nav.factoryDroid') }],
items: [
{ path: '/claude-extension', icon: Puzzle, label: t('nav.claudeExtension') },
{ path: '/droid', icon: TerminalSquare, label: t('nav.factoryDroid') },
],
},
{
title: t('nav.system'),
+224
View File
@@ -0,0 +1,224 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { withApiBase } from '@/lib/api-client';
export interface ClaudeExtensionProfileOption {
name: string;
profileType: string;
label: string;
description: string;
}
export interface ClaudeExtensionHostOption {
id: 'vscode' | 'cursor' | 'windsurf';
label: string;
settingsKey: string;
disableLoginPromptKey?: string;
settingsTargetLabel: string;
description: string;
defaultSettingsPath: string;
}
export interface ClaudeExtensionSetupPayload {
profile: {
requestedProfile: string;
resolvedProfileName: string;
profileType: string;
label: string;
description: string;
};
host: ClaudeExtensionHostOption;
env: Array<{ name: string; value: string }>;
warnings: string[];
notes: string[];
removeEnvKeys: string[];
sharedSettings: {
path: string;
command: string;
json: string;
};
ideSettings: {
path: string;
targetLabel: string;
json: string;
};
}
export interface ClaudeExtensionBinding {
id: string;
name: string;
profile: string;
host: ClaudeExtensionHostOption['id'];
ideSettingsPath?: string;
effectiveIdeSettingsPath: string;
usesDefaultIdeSettingsPath: boolean;
notes?: string;
createdAt: string;
updatedAt: string;
}
export interface ClaudeExtensionBindingInput {
name: string;
profile: string;
host: ClaudeExtensionHostOption['id'];
ideSettingsPath?: string;
notes?: string;
}
export type ClaudeExtensionActionTarget = 'shared' | 'ide' | 'all';
export type ClaudeExtensionFileState = 'applied' | 'drifted' | 'missing' | 'unconfigured';
export interface ClaudeExtensionTargetStatus {
target: 'shared' | 'ide';
path: string;
exists: boolean;
mtime: number | null;
state: ClaudeExtensionFileState;
message: string;
}
export interface ClaudeExtensionBindingStatus {
binding: ClaudeExtensionBinding;
bindingId: string;
sharedSettings: ClaudeExtensionTargetStatus;
ideSettings: ClaudeExtensionTargetStatus;
}
const bindingsQueryKey = ['claude-extension-bindings'] as const;
async function requestJson<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(withApiBase(url), {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!res.ok) {
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(payload?.error || `Request failed (${res.status})`);
}
return res.status === 204 ? (undefined as T) : ((await res.json()) as T);
}
export function useClaudeExtensionOptions() {
return useQuery({
queryKey: ['claude-extension-options'],
queryFn: () =>
requestJson<{ profiles: ClaudeExtensionProfileOption[]; hosts: ClaudeExtensionHostOption[] }>(
'/claude-extension/profiles'
),
});
}
export function useClaudeExtensionSetup(profile?: string, host: string = 'vscode') {
return useQuery({
queryKey: ['claude-extension-setup', profile, host],
enabled: Boolean(profile),
queryFn: () =>
requestJson<ClaudeExtensionSetupPayload>(
`/claude-extension/setup?profile=${encodeURIComponent(profile || '')}&host=${encodeURIComponent(host)}`
),
});
}
export function useClaudeExtensionBindings() {
return useQuery({
queryKey: bindingsQueryKey,
queryFn: () =>
requestJson<{ bindings: ClaudeExtensionBinding[] }>('/claude-extension/bindings'),
});
}
export function useClaudeExtensionBindingStatus(bindingId?: string) {
return useQuery({
queryKey: ['claude-extension-binding-status', bindingId],
enabled: Boolean(bindingId),
queryFn: () =>
requestJson<ClaudeExtensionBindingStatus>(
`/claude-extension/bindings/${encodeURIComponent(bindingId || '')}/verify`
),
});
}
export function useCreateClaudeExtensionBinding() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (binding: ClaudeExtensionBindingInput) =>
requestJson<{ binding: ClaudeExtensionBinding }>('/claude-extension/bindings', {
method: 'POST',
body: JSON.stringify(binding),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: bindingsQueryKey });
toast.success('Binding created');
},
onError: (error: Error) => toast.error(error.message),
});
}
export function useUpdateClaudeExtensionBinding() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, binding }: { id: string; binding: ClaudeExtensionBindingInput }) =>
requestJson<{ binding: ClaudeExtensionBinding }>(
`/claude-extension/bindings/${encodeURIComponent(id)}`,
{
method: 'PUT',
body: JSON.stringify(binding),
}
),
onSuccess: (_result, variables) => {
queryClient.invalidateQueries({ queryKey: bindingsQueryKey });
queryClient.invalidateQueries({
queryKey: ['claude-extension-binding-status', variables.id],
});
toast.success('Binding saved');
},
onError: (error: Error) => toast.error(error.message),
});
}
export function useDeleteClaudeExtensionBinding() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
requestJson<void>(`/claude-extension/bindings/${encodeURIComponent(id)}`, {
method: 'DELETE',
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: bindingsQueryKey });
toast.success('Binding deleted');
},
onError: (error: Error) => toast.error(error.message),
});
}
function useClaudeExtensionActionMutation(action: 'apply' | 'reset', successMessage: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, target }: { id: string; target: ClaudeExtensionActionTarget }) =>
requestJson<ClaudeExtensionBindingStatus>(
`/claude-extension/bindings/${encodeURIComponent(id)}/${action}`,
{
method: 'POST',
body: JSON.stringify({ target }),
}
),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: bindingsQueryKey });
queryClient.setQueryData(['claude-extension-binding-status', result.bindingId], result);
toast.success(successMessage);
},
onError: (error: Error) => toast.error(error.message),
});
}
export function useApplyClaudeExtensionBinding() {
return useClaudeExtensionActionMutation('apply', 'Binding applied');
}
export function useResetClaudeExtensionBinding() {
return useClaudeExtensionActionMutation('reset', 'Managed values removed');
}
+8 -4
View File
@@ -25,10 +25,11 @@ const resources = {
controlPanel: 'Control Panel',
githubCopilot: 'GitHub Copilot',
cursorIde: 'Cursor IDE',
claudeExtension: 'Claude Extension',
accounts: 'Accounts',
allAccounts: 'All Accounts',
sharedData: 'Shared Data',
compatibleClis: 'Compatible CLIs',
compatibleClis: 'Compatible',
factoryDroid: 'Factory Droid',
system: 'System',
health: 'Health',
@@ -1195,10 +1196,11 @@ const resources = {
controlPanel: '控制面板',
githubCopilot: 'GitHub Copilot',
cursorIde: 'Cursor IDE',
claudeExtension: 'Claude Extension',
accounts: '账号',
allAccounts: '全部账号',
sharedData: '共享数据',
compatibleClis: '兼容 CLI',
compatibleClis: '兼容',
factoryDroid: 'Factory Droid',
system: '系统',
health: '健康',
@@ -2324,10 +2326,11 @@ const resources = {
controlPanel: 'Bảng điều khiển',
githubCopilot: 'GitHub Copilot',
cursorIde: 'Cursor IDE',
claudeExtension: 'Claude Extension',
accounts: 'Tài khoản',
allAccounts: 'Tất cả tài khoản',
sharedData: 'Dữ liệu dùng chung',
compatibleClis: 'CLI tương thích',
compatibleClis: 'Tương thích',
factoryDroid: 'Factory Droid',
system: 'Hệ thống',
health: 'Sức khỏe',
@@ -3513,10 +3516,11 @@ const resources = {
controlPanel: 'コントロールパネル',
githubCopilot: 'GitHub Copilot',
cursorIde: 'Cursor IDE',
claudeExtension: 'Claude Extension',
accounts: 'アカウント',
allAccounts: 'すべてのアカウント',
sharedData: '共有データ',
compatibleClis: '対応 CLI',
compatibleClis: '互換',
factoryDroid: 'Factory Droid',
system: 'システム',
health: 'ヘルス',
+985
View File
@@ -0,0 +1,985 @@
import { useState } from 'react';
import {
AlertTriangle,
Loader2,
Plus,
RefreshCw,
Save,
Settings2,
ShieldCheck,
Sparkles,
Trash2,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { CopyButton } from '@/components/ui/copy-button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
type ClaudeExtensionActionTarget,
type ClaudeExtensionBinding,
type ClaudeExtensionBindingInput,
type ClaudeExtensionFileState,
type ClaudeExtensionTargetStatus,
useApplyClaudeExtensionBinding,
useClaudeExtensionBindingStatus,
useClaudeExtensionBindings,
useClaudeExtensionOptions,
useClaudeExtensionSetup,
useCreateClaudeExtensionBinding,
useDeleteClaudeExtensionBinding,
useResetClaudeExtensionBinding,
useUpdateClaudeExtensionBinding,
} from '@/hooks/use-claude-extension';
import { cn } from '@/lib/utils';
const EMPTY_BINDINGS: ClaudeExtensionBinding[] = [];
interface BindingDraft {
name: string;
profile: string;
host: 'vscode' | 'cursor' | 'windsurf';
ideSettingsPath: string;
notes: string;
}
function createEmptyDraft(profile: string): BindingDraft {
return {
name: '',
profile,
host: 'vscode',
ideSettingsPath: '',
notes: '',
};
}
function bindingToDraft(binding: ClaudeExtensionBinding): BindingDraft {
return {
name: binding.name,
profile: binding.profile,
host: binding.host,
ideSettingsPath: binding.ideSettingsPath || '',
notes: binding.notes || '',
};
}
function normalizeBindingDraft(draft: BindingDraft): ClaudeExtensionBindingInput {
return {
name: draft.name.trim(),
profile: draft.profile.trim(),
host: draft.host,
ideSettingsPath: draft.ideSettingsPath.trim() || undefined,
notes: draft.notes.trim() || undefined,
};
}
function isPlainStatusActive(status?: ClaudeExtensionTargetStatus): boolean {
return status?.state === 'applied';
}
function StatusBadge({ state }: { state: ClaudeExtensionFileState }) {
const classes =
state === 'applied'
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
: state === 'drifted'
? 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300'
: state === 'missing'
? 'border-destructive/30 bg-destructive/10 text-destructive'
: 'border-border bg-muted text-muted-foreground';
return (
<Badge variant="outline" className={classes}>
{state}
</Badge>
);
}
function formatPathForDisplay(value: string): string {
return value.replace(/[\\/]/g, '$&\u200b');
}
function DetailRow({
label,
value,
mono = false,
copyValue,
}: {
label: string;
value: string;
mono?: boolean;
copyValue?: string;
}) {
const isPathRow = typeof copyValue === 'string' && copyValue.trim().length > 0;
return (
<div className="grid gap-2 text-sm sm:grid-cols-[112px_minmax(0,1fr)] sm:items-start">
<span className="text-muted-foreground">{label}</span>
{isPathRow ? (
<div className="flex min-w-0 items-start gap-2">
<div className="min-w-0 flex-1 rounded-md border bg-muted/25 px-3 py-2">
<span className="block text-left font-mono text-xs leading-5 [overflow-wrap:anywhere]">
{formatPathForDisplay(value)}
</span>
</div>
<CopyButton
value={copyValue}
label={`Copy ${label.toLowerCase()}`}
className="shrink-0"
/>
</div>
) : (
<span
className={cn(
'text-left sm:text-right',
mono && 'font-mono text-xs leading-5 [overflow-wrap:anywhere]'
)}
>
{value}
</span>
)}
</div>
);
}
function CodeBlockCard({
title,
description,
value,
}: {
title: string;
description: string;
value: string;
}) {
return (
<Card className="border-border/60 bg-card/80">
<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="max-h-[360px] overflow-auto rounded-lg border bg-muted/30 p-4 text-xs leading-6">
{value}
</pre>
</CardContent>
</Card>
);
}
function TargetStatusCard({
title,
description,
status,
applyLabel,
resetLabel,
onApply,
onReset,
disabled,
busy,
}: {
title: string;
description: string;
status?: ClaudeExtensionTargetStatus;
applyLabel: string;
resetLabel: string;
onApply: () => void;
onReset: () => void;
disabled: boolean;
busy: boolean;
}) {
return (
<Card className="border-border/60 bg-card/80">
<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>
{status ? <StatusBadge state={status.state} /> : null}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<DetailRow
label="Path"
value={status?.path || 'Save a binding first'}
mono
copyValue={status?.path}
/>
<DetailRow
label="File"
value={status ? (status.exists ? 'Present' : 'Not created yet') : 'Unavailable'}
/>
</div>
<div className="rounded-lg border bg-muted/25 p-3 text-sm text-muted-foreground">
{status?.message || 'Verify the binding after saving to inspect the current file state.'}
</div>
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={onApply} disabled={disabled || busy}>
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
{applyLabel}
</Button>
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={onReset}
disabled={disabled || busy}
>
{resetLabel}
</Button>
</div>
</CardContent>
</Card>
);
}
function BindingListItem({
binding,
isSelected,
onSelect,
}: {
binding: ClaudeExtensionBinding;
isSelected: boolean;
onSelect: () => void;
}) {
return (
<button
onClick={onSelect}
className={cn(
'w-full rounded-lg border px-3 py-3 text-left transition-colors',
isSelected
? 'border-primary/40 bg-primary/10'
: 'border-border/60 bg-card hover:bg-muted/40'
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{binding.name}</div>
<div className="mt-1 text-xs text-muted-foreground">
{binding.profile} · {binding.host}
</div>
</div>
<Badge variant="outline" className="shrink-0">
{binding.usesDefaultIdeSettingsPath ? 'Default path' : 'Custom path'}
</Badge>
</div>
</button>
);
}
export function ClaudeExtensionPage() {
const optionsQuery = useClaudeExtensionOptions();
const bindingsQuery = useClaudeExtensionBindings();
const createBinding = useCreateClaudeExtensionBinding();
const updateBinding = useUpdateClaudeExtensionBinding();
const deleteBinding = useDeleteClaudeExtensionBinding();
const applyBinding = useApplyClaudeExtensionBinding();
const resetBinding = useResetClaudeExtensionBinding();
const profiles = optionsQuery.data?.profiles ?? [];
const hosts = optionsQuery.data?.hosts ?? [];
const bindings = bindingsQuery.data?.bindings ?? EMPTY_BINDINGS;
const defaultProfile = profiles[0]?.name ?? 'default';
const [isCreating, setIsCreating] = useState(false);
const [selectedBindingId, setSelectedBindingId] = useState<string | null>(null);
const [draft, setDraft] = useState<BindingDraft>(() => createEmptyDraft('default'));
const creating = isCreating || bindings.length === 0;
const selectedBinding =
!creating && bindings.length > 0
? (bindings.find((binding) => binding.id === selectedBindingId) ??
(selectedBindingId ? null : bindings[0]))
: null;
const effectiveSelectedBindingId = selectedBinding?.id ?? null;
const currentDraft =
creating || !selectedBinding
? draft
: selectedBindingId
? draft
: bindingToDraft(selectedBinding);
const setupQuery = useClaudeExtensionSetup(currentDraft.profile, currentDraft.host);
const statusQuery = useClaudeExtensionBindingStatus(
creating ? undefined : effectiveSelectedBindingId || undefined
);
const selectedHost = hosts.find((host) => host.id === currentDraft.host);
const selectedProfile = profiles.find((profile) => profile.name === currentDraft.profile);
const activeError =
(optionsQuery.error as Error | null) ||
(bindingsQuery.error as Error | null) ||
(setupQuery.error as Error | null) ||
(statusQuery.error as Error | null);
const bindingCountLabel = `${bindings.length} saved`;
const isSaving = createBinding.isPending || updateBinding.isPending;
const isBusyShared =
(applyBinding.isPending && applyBinding.variables?.target === 'shared') ||
(resetBinding.isPending && resetBinding.variables?.target === 'shared');
const isBusyIde =
(applyBinding.isPending && applyBinding.variables?.target === 'ide') ||
(resetBinding.isPending && resetBinding.variables?.target === 'ide');
const canPersist = currentDraft.name.trim().length > 0 && currentDraft.profile.trim().length > 0;
const setup = setupQuery.data;
const status = statusQuery.data;
const hiddenEnvCount = Math.max((setup?.env.length ?? 0) - 6, 0);
const envPreview = setup?.env.slice(0, 6) ?? [];
function startCreateMode(): void {
setIsCreating(true);
setSelectedBindingId(null);
setDraft(createEmptyDraft(defaultProfile));
}
async function handleSave(): Promise<void> {
if (!canPersist) return;
const payload = normalizeBindingDraft(currentDraft);
if (!creating && effectiveSelectedBindingId) {
const result = await updateBinding.mutateAsync({
id: effectiveSelectedBindingId,
binding: payload,
});
setIsCreating(false);
setSelectedBindingId(result.binding.id);
setDraft(bindingToDraft(result.binding));
return;
}
const result = await createBinding.mutateAsync(payload);
setIsCreating(false);
setSelectedBindingId(result.binding.id);
setDraft(bindingToDraft(result.binding));
}
async function handleDelete(): Promise<void> {
if (!effectiveSelectedBindingId || !selectedBinding) return;
if (!window.confirm(`Delete binding "${selectedBinding.name}"?`)) return;
await deleteBinding.mutateAsync(effectiveSelectedBindingId);
const remaining = bindings.filter((binding) => binding.id !== effectiveSelectedBindingId);
if (remaining.length > 0) {
setSelectedBindingId(remaining[0].id);
setIsCreating(false);
setDraft(bindingToDraft(remaining[0]));
} else {
startCreateMode();
}
}
function updateDraft<K extends keyof BindingDraft>(key: K, value: BindingDraft[K]): void {
if (!creating && selectedBinding && !selectedBindingId) {
setSelectedBindingId(selectedBinding.id);
setDraft({ ...bindingToDraft(selectedBinding), [key]: value });
setIsCreating(false);
return;
}
setDraft((current) => ({ ...current, [key]: value }));
}
function runBindingAction(target: ClaudeExtensionActionTarget, action: 'apply' | 'reset'): void {
if (!effectiveSelectedBindingId) return;
if (action === 'apply') {
applyBinding.mutate({ id: effectiveSelectedBindingId, target });
return;
}
resetBinding.mutate({ id: effectiveSelectedBindingId, target });
}
return (
<div className="flex h-[calc(100vh-100px)] min-h-0">
<div className="flex w-[348px] shrink-0 flex-col border-r bg-muted/30 xl:w-[372px]">
<div className="border-b bg-background p-4">
<div className="flex items-start justify-between gap-3">
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="rounded-lg border bg-muted/40 p-2">
<Sparkles className="h-5 w-5 text-primary" />
</div>
<div>
<h1 className="font-semibold">Claude Extension</h1>
<p className="text-xs text-muted-foreground">
Saved IDE bindings for CCS profiles
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary">{bindingCountLabel}</Badge>
{selectedHost ? <Badge variant="outline">{selectedHost.label}</Badge> : null}
</div>
</div>
<Button size="sm" onClick={startCreateMode} className="gap-1.5">
<Plus className="h-3.5 w-3.5" />
New
</Button>
</div>
</div>
<ScrollArea className="flex-1">
<div className="space-y-4 p-5">
<Card className="border-border/60 bg-card/80">
<CardHeader>
<CardTitle className="text-base">
{creating ? 'Create binding' : 'Binding editor'}
</CardTitle>
<CardDescription>
Save a profile + IDE path once, then apply or reset it from the dashboard.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Binding name</div>
<Input
value={currentDraft.name}
onChange={(event) => updateDraft('name', event.target.value)}
placeholder="VS Code · work profile"
/>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">CCS profile</div>
<Select
value={currentDraft.profile}
onValueChange={(value) => updateDraft('profile', value)}
>
<SelectTrigger>
<SelectValue placeholder="Select a profile" />
</SelectTrigger>
<SelectContent>
{profiles.map((profile) => (
<SelectItem key={profile.name} value={profile.name}>
{profile.label} ({profile.profileType})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{selectedProfile?.description ||
'Choose which CCS profile the IDE should inherit.'}
</p>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">IDE host</div>
<Select
value={currentDraft.host}
onValueChange={(value) => updateDraft('host', value as BindingDraft['host'])}
>
<SelectTrigger>
<SelectValue placeholder="Select a host" />
</SelectTrigger>
<SelectContent>
{hosts.map((host) => (
<SelectItem key={host.id} value={host.id}>
{host.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">IDE settings path</div>
<Input
value={currentDraft.ideSettingsPath}
onChange={(event) => updateDraft('ideSettingsPath', event.target.value)}
placeholder={
selectedHost?.defaultSettingsPath ||
'Leave blank for the default user settings path'
}
/>
<p className="text-xs text-muted-foreground">
Leave blank to use the default user settings path for{' '}
{selectedHost?.label || 'this IDE'}.
</p>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Notes</div>
<Input
value={currentDraft.notes}
onChange={(event) => updateDraft('notes', event.target.value)}
placeholder="Optional reminder for this machine or workspace"
/>
</div>
<div className="flex gap-2">
<Button
className="flex-1 gap-1.5"
onClick={() => void handleSave()}
disabled={!canPersist || isSaving}
>
{isSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{creating ? 'Create' : 'Save'}
</Button>
<Button variant="outline" onClick={startCreateMode}>
Reset form
</Button>
</div>
{!creating ? (
<Button
variant="outline"
className="w-full gap-1.5 text-destructive hover:text-destructive"
onClick={() => void handleDelete()}
disabled={deleteBinding.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
Delete binding
</Button>
) : null}
</CardContent>
</Card>
<div className="space-y-2">
<div className="px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Saved bindings
</div>
<div className="space-y-2">
{bindings.length > 0 ? (
bindings.map((binding) => (
<BindingListItem
key={binding.id}
binding={binding}
isSelected={binding.id === effectiveSelectedBindingId && !creating}
onSelect={() => {
setIsCreating(false);
setSelectedBindingId(binding.id);
setDraft(bindingToDraft(binding));
}}
/>
))
) : (
<Card className="border-dashed border-border/60 bg-card/60">
<CardContent className="pt-6 text-sm text-muted-foreground">
No saved bindings yet. Create one to manage apply, reset, and drift checks
from the dashboard.
</CardContent>
</Card>
)}
</div>
</div>
</div>
</ScrollArea>
</div>
<div className="min-w-0 flex-1">
<ScrollArea className="h-full">
<div className="w-full space-y-6 p-6 xl:p-7 2xl:p-8">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
{selectedProfile ? (
<Badge variant="outline">{selectedProfile.label}</Badge>
) : null}
{selectedHost ? <Badge variant="outline">{selectedHost.label}</Badge> : null}
{creating ? <Badge variant="secondary">Draft</Badge> : null}
{status?.sharedSettings &&
isPlainStatusActive(status.sharedSettings) &&
isPlainStatusActive(status.ideSettings) ? (
<Badge className="bg-emerald-600 hover:bg-emerald-600">In sync</Badge>
) : null}
</div>
<div className="max-w-5xl">
<h2 className="text-2xl font-semibold tracking-tight">
{selectedBinding?.name || 'Claude extension binding'}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Manage the shared Claude settings file and the IDE-local settings file as two
scoped targets.
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => void statusQuery.refetch()}
disabled={creating || statusQuery.isFetching}
>
{statusQuery.isFetching ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
)}
Verify
</Button>
{setup ? (
<CopyButton value={setup.sharedSettings.command} label="Copy persist command" />
) : null}
</div>
</div>
{activeError ? (
<Card className="border-destructive/40 bg-destructive/5">
<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}
{!activeError ? (
<Tabs defaultValue="overview" className="flex flex-col gap-6">
<TabsList className="w-full justify-start">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-0 space-y-6">
<div className="grid gap-6 xl:grid-cols-2">
<TargetStatusCard
title="Shared Claude settings"
description="Writes the managed env block inside ~/.claude/settings.json so CLI and IDE behavior stay aligned."
status={status?.sharedSettings}
applyLabel="Apply shared"
resetLabel="Reset shared"
onApply={() => runBindingAction('shared', 'apply')}
onReset={() => runBindingAction('shared', 'reset')}
disabled={creating}
busy={isBusyShared}
/>
<TargetStatusCard
title={`${selectedHost?.label || 'IDE'} settings.json`}
description="Writes only the Anthropic extension keys so unrelated editor preferences stay untouched."
status={status?.ideSettings}
applyLabel="Apply IDE"
resetLabel="Reset IDE"
onApply={() => runBindingAction('ide', 'apply')}
onReset={() => runBindingAction('ide', 'reset')}
disabled={creating}
busy={isBusyIde}
/>
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]">
<Card className="border-border/60 bg-card/80">
<CardHeader>
<CardTitle className="text-base">Resolved binding</CardTitle>
<CardDescription>
The binding uses the same profile resolution as `ccs persist` and `ccs
env`.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<DetailRow
label="Profile"
value={setup?.profile.label || currentDraft.profile || 'Not selected'}
/>
<DetailRow
label="Profile type"
value={setup?.profile.profileType || 'Unknown'}
/>
<DetailRow label="IDE host" value={selectedHost?.label || 'Not selected'} />
<DetailRow
label="IDE path mode"
value={
currentDraft.ideSettingsPath.trim()
? 'Custom path'
: 'Default user path'
}
/>
<DetailRow
label="Effective IDE path"
value={
status?.ideSettings.path ||
currentDraft.ideSettingsPath.trim() ||
selectedHost?.defaultSettingsPath ||
'Unavailable'
}
mono
copyValue={
status?.ideSettings.path ||
currentDraft.ideSettingsPath.trim() ||
selectedHost?.defaultSettingsPath
}
/>
<DetailRow
label="Persist command"
value={setup?.sharedSettings.command || 'Save a valid binding first'}
mono
/>
{currentDraft.notes.trim() ? (
<DetailRow label="Notes" value={currentDraft.notes.trim()} />
) : null}
</CardContent>
</Card>
<Card className="border-border/60 bg-card/80">
<CardHeader>
<CardTitle className="text-base">Managed payload</CardTitle>
<CardDescription>
Keep the main view short. The full JSON stays in the Advanced tab.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
{envPreview.map((entry) => (
<Badge
key={entry.name}
variant="secondary"
className="font-mono text-[10px]"
>
{entry.name}
</Badge>
))}
{hiddenEnvCount > 0 ? (
<Badge variant="outline">+{hiddenEnvCount} more</Badge>
) : null}
</div>
<div className="rounded-lg border bg-muted/25 p-4 text-sm">
{setup?.env.length ? (
<div className="space-y-2">
<div className="font-medium">
CCS will inject {setup.env.length} environment values.
</div>
<div className="text-muted-foreground">
The IDE-local target receives the extension schema. The shared
target receives the same env block through Claude settings.
</div>
</div>
) : (
<div className="text-muted-foreground">
This profile resolves to native Claude defaults, so apply/reset mainly
clears existing CCS-managed overrides.
</div>
)}
</div>
{!creating ? (
<div className="flex gap-2">
<Button
className="flex-1"
onClick={() => runBindingAction('all', 'apply')}
disabled={applyBinding.isPending}
>
{applyBinding.isPending &&
applyBinding.variables?.target === 'all' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : null}
Apply both targets
</Button>
<Button
variant="outline"
className="flex-1"
onClick={() => runBindingAction('all', 'reset')}
disabled={resetBinding.isPending}
>
Reset both targets
</Button>
</div>
) : (
<div className="rounded-lg border border-dashed bg-muted/15 p-4 text-sm text-muted-foreground">
Save this draft to unlock apply, reset, and verify actions.
</div>
)}
</CardContent>
</Card>
</div>
{setup && (setup.warnings.length > 0 || setup.notes.length > 0) ? (
<div className="grid gap-6 xl:grid-cols-2">
<Card className="border-border/60 bg-card/80">
<CardHeader>
<CardTitle className="text-base">Warnings</CardTitle>
<CardDescription>
Operational details that can break the binding even when JSON is
correct.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{setup.warnings.length > 0 ? (
setup.warnings.map((warning) => (
<div
key={warning}
className="flex items-start gap-3 rounded-lg border border-amber-400/40 bg-amber-50/60 p-3 text-sm dark:bg-amber-950/10"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<span>{warning}</span>
</div>
))
) : (
<div className="rounded-lg border bg-muted/20 p-3 text-sm text-muted-foreground">
No runtime warnings for this binding.
</div>
)}
</CardContent>
</Card>
<Card className="border-border/60 bg-card/80">
<CardHeader>
<CardTitle className="text-base">Notes</CardTitle>
<CardDescription>
Short context from CCS about account continuity and host-specific
behavior.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{setup.notes.length > 0 ? (
setup.notes.map((note) => (
<div
key={note}
className="flex items-start gap-3 rounded-lg border bg-muted/30 p-3 text-sm"
>
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<span>{note}</span>
</div>
))
) : (
<div className="rounded-lg border bg-muted/20 p-3 text-sm text-muted-foreground">
No extra notes for this binding.
</div>
)}
</CardContent>
</Card>
</div>
) : null}
</TabsContent>
<TabsContent value="advanced" className="mt-0 space-y-6">
{setup ? (
<>
<div className="grid gap-6 xl:grid-cols-2">
<CodeBlockCard
title="Shared Claude settings JSON"
description="Managed env block for ~/.claude/settings.json."
value={setup.sharedSettings.json}
/>
<CodeBlockCard
title={`${selectedHost?.label || 'IDE'} settings JSON`}
description={`Anthropic extension snippet for ${selectedHost?.settingsTargetLabel || 'settings.json'}.`}
value={setup.ideSettings.json}
/>
</div>
<Card className="border-border/60 bg-card/80">
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-base">
Resolved environment payload
</CardTitle>
<CardDescription>
Exact environment values that the extension receives after CCS
expands this profile.
</CardDescription>
</div>
<CopyButton
value={JSON.stringify(setup.env, null, 2)}
label="Copy environment payload"
/>
</div>
</CardHeader>
<CardContent>
{setup.env.length > 0 ? (
<pre className="max-h-[420px] overflow-auto rounded-lg border bg-muted/30 p-4 text-xs leading-6">
{JSON.stringify(setup.env, null, 2)}
</pre>
) : (
<div className="rounded-lg border bg-muted/20 p-4 text-sm text-muted-foreground">
No env payload. This binding resolves to native Claude defaults.
</div>
)}
</CardContent>
</Card>
<div className="grid gap-6 xl:grid-cols-2">
<Card className="border-border/60 bg-card/80">
<CardHeader>
<CardTitle className="text-base">Shared target metadata</CardTitle>
<CardDescription>
Useful when debugging drift or comparing with manual edits.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<DetailRow
label="Target path"
value={status?.sharedSettings.path || setup.sharedSettings.path}
mono
copyValue={status?.sharedSettings.path || setup.sharedSettings.path}
/>
<DetailRow label="Command" value={setup.sharedSettings.command} mono />
<DetailRow
label="Current state"
value={status?.sharedSettings.state || 'Not verified'}
/>
</CardContent>
</Card>
<Card className="border-border/60 bg-card/80">
<CardHeader>
<CardTitle className="text-base">IDE target metadata</CardTitle>
<CardDescription>
Current file path plus the extension setting key used for this host.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<DetailRow
label="Target path"
value={
status?.ideSettings.path ||
currentDraft.ideSettingsPath.trim() ||
selectedHost?.defaultSettingsPath ||
setup.ideSettings.path
}
mono
copyValue={
status?.ideSettings.path ||
currentDraft.ideSettingsPath.trim() ||
selectedHost?.defaultSettingsPath ||
setup.ideSettings.path
}
/>
<DetailRow
label="Settings key"
value={selectedHost?.settingsKey || 'Unknown'}
mono
/>
<DetailRow
label="Current state"
value={status?.ideSettings.state || 'Not verified'}
/>
</CardContent>
</Card>
</div>
</>
) : (
<Card className="border-border/60 bg-card/80">
<CardContent className="flex min-h-[240px] items-center justify-center gap-3 text-sm text-muted-foreground">
<Settings2 className="h-5 w-5" />
Choose a profile and IDE host to preview the generated payload.
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
) : null}
</div>
</ScrollArea>
</div>
</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';