mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-06 00:22:23 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -18,7 +18,7 @@ _ccs_completion() {
|
||||
|
||||
# Top-level completion (first argument)
|
||||
if [[ ${COMP_CWORD} -eq 1 ]]; then
|
||||
local commands="auth api cliproxy doctor env sync update"
|
||||
local commands="auth api cliproxy config doctor env persist sync update"
|
||||
local flags="--help --version --shell-completion -h -v -sc"
|
||||
local cliproxy_profiles="gemini codex agy qwen"
|
||||
local profiles=""
|
||||
@@ -156,23 +156,30 @@ _ccs_completion() {
|
||||
case "${prev}" in
|
||||
env)
|
||||
# Complete with profile names and flags (inline profiles since $cliproxy_profiles is out of scope)
|
||||
local env_opts="--format --shell --help -h gemini codex agy qwen iflow kiro ghcp claude"
|
||||
local env_opts="--format --shell --ide --help -h gemini codex agy qwen iflow kiro ghcp claude default"
|
||||
if [[ -f ~/.ccs/config.json ]]; then
|
||||
env_opts="$env_opts $(jq -r '.profiles | keys[]' ~/.ccs/config.json 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -f ~/.ccs/profiles.json ]]; then
|
||||
env_opts="$env_opts $(jq -r '.profiles | keys[]' ~/.ccs/profiles.json 2>/dev/null || true)"
|
||||
fi
|
||||
COMPREPLY=( $(compgen -W "${env_opts}" -- ${cur}) )
|
||||
return 0
|
||||
;;
|
||||
--format)
|
||||
COMPREPLY=( $(compgen -W "openai anthropic raw" -- ${cur}) )
|
||||
COMPREPLY=( $(compgen -W "openai anthropic raw claude-extension" -- ${cur}) )
|
||||
return 0
|
||||
;;
|
||||
--shell)
|
||||
COMPREPLY=( $(compgen -W "auto bash zsh fish powershell" -- ${cur}) )
|
||||
return 0
|
||||
;;
|
||||
--ide)
|
||||
COMPREPLY=( $(compgen -W "vscode cursor windsurf" -- ${cur}) )
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
COMPREPLY=( $(compgen -W "--format --shell --help -h" -- ${cur}) )
|
||||
COMPREPLY=( $(compgen -W "--format --shell --ide --help -h" -- ${cur}) )
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -59,6 +59,7 @@ function showHelp(): void {
|
||||
console.log('Usage: ccs config [command] [options]');
|
||||
console.log('');
|
||||
console.log('Open web-based configuration dashboard');
|
||||
console.log('Includes a dedicated Claude IDE Extension page for VS Code-compatible hosts.');
|
||||
console.log('');
|
||||
console.log('Commands:');
|
||||
console.log(' auth Manage dashboard authentication');
|
||||
@@ -72,6 +73,11 @@ function showHelp(): void {
|
||||
console.log(' --timeout <s> Set analysis timeout (seconds)');
|
||||
console.log(' --set-model <p> <m> Set model for provider');
|
||||
console.log('');
|
||||
console.log(' Claude IDE Extension');
|
||||
console.log(' Dashboard page Generate copy-ready setup for VS Code, Cursor, Windsurf');
|
||||
console.log(' Shared settings Shows preferred ~/.claude/settings.json setup');
|
||||
console.log(' IDE-local JSON Shows extension-specific environmentVariables snippets');
|
||||
console.log('');
|
||||
console.log(' thinking Manage thinking/reasoning settings');
|
||||
console.log(' --mode <mode> Set mode (auto, off, manual)');
|
||||
console.log(' --override <l> Set persistent override level');
|
||||
@@ -90,6 +96,7 @@ function showHelp(): void {
|
||||
console.log(' ccs config --port 3000 Use specific port');
|
||||
console.log(' ccs config --dev Development mode with hot reload');
|
||||
console.log(' ccs config auth setup Configure dashboard login');
|
||||
console.log(' ccs config Open dashboard, then choose Claude IDE Extension');
|
||||
console.log(' ccs config image-analysis Show image settings');
|
||||
console.log(' ccs config image-analysis --enable Enable feature');
|
||||
console.log(' ccs config thinking Show thinking settings');
|
||||
|
||||
+49
-92
@@ -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) {
|
||||
|
||||
@@ -305,7 +305,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
['ccs setup', 'First-time setup wizard'],
|
||||
['ccs doctor', 'Run health check and diagnostics'],
|
||||
['ccs cleanup', 'Remove old CLIProxy logs'],
|
||||
['ccs config', 'Open web configuration dashboard'],
|
||||
['ccs config', 'Open web dashboard (includes Claude IDE Extension setup page)'],
|
||||
['ccs config auth setup', 'Configure dashboard login'],
|
||||
['ccs config auth show', 'Show dashboard auth status'],
|
||||
['ccs config image-analysis', 'Show image analysis settings'],
|
||||
@@ -314,7 +314,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
['ccs config thinking --mode auto', 'Set thinking mode'],
|
||||
['ccs config thinking --clear-provider-override codex', 'Clear provider overrides'],
|
||||
['ccs config --port 3000', 'Use specific port'],
|
||||
['ccs persist <profile>', 'Write profile env to ~/.claude/settings.json'],
|
||||
['ccs persist <profile>', 'Write profile setup to ~/.claude/settings.json'],
|
||||
['ccs persist --list-backups', 'List available settings.json backups'],
|
||||
['ccs persist --restore', 'Restore settings.json from latest backup'],
|
||||
['ccs sync', 'Sync delegation commands and skills'],
|
||||
@@ -329,6 +329,14 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
['ccs env <profile> --format openai', 'OpenAI-compatible vars (OpenCode/Cursor)'],
|
||||
['ccs env <profile> --format anthropic', 'Anthropic vars (default)'],
|
||||
['ccs env <profile> --format raw', 'All effective env vars'],
|
||||
[
|
||||
'ccs env <profile> --format claude-extension --ide vscode',
|
||||
'VS Code/Cursor Claude extension settings JSON',
|
||||
],
|
||||
[
|
||||
'ccs env <profile> --format claude-extension --ide windsurf',
|
||||
'Windsurf Claude extension settings JSON',
|
||||
],
|
||||
['ccs env <profile> --shell fish', 'Fish shell syntax'],
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Persist Command Handler
|
||||
*
|
||||
* Writes a profile's environment variables to ~/.claude/settings.json
|
||||
* for native Claude Code usage (IDEs, extensions, etc.).
|
||||
* Writes a profile's Claude setup to ~/.claude/settings.json
|
||||
* for native Claude Code usage across the CLI and IDE extension.
|
||||
*
|
||||
* Supports all profile types: API, CLIProxy, Copilot.
|
||||
* Account-based profiles are not supported (use CLAUDE_CONFIG_DIR).
|
||||
* Supports API, CLIProxy, Copilot, account, and default flows
|
||||
* through the shared Claude extension setup resolver.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
@@ -14,16 +14,10 @@ import * as os from 'os';
|
||||
import * as lockfile from 'proper-lockfile';
|
||||
import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../utils/ui';
|
||||
import { InteractivePrompt } from '../utils/prompt';
|
||||
import ProfileDetector, {
|
||||
ProfileDetectionResult,
|
||||
loadSettingsFromFile,
|
||||
CLIPROXY_PROFILES,
|
||||
} from '../auth/profile-detector';
|
||||
import { getEffectiveEnvVars, CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator';
|
||||
import { generateCopilotEnv } from '../copilot/copilot-executor';
|
||||
import { expandPath } from '../utils/helpers';
|
||||
import ProfileDetector from '../auth/profile-detector';
|
||||
import { getClaudeConfigDir, getClaudeSettingsPath } from '../utils/claude-config-path';
|
||||
import { extractOption, hasAnyFlag } from './arg-extractor';
|
||||
import { resolveClaudeExtensionSetup } from '../shared/claude-extension-setup';
|
||||
|
||||
interface PersistCommandArgs {
|
||||
profile?: string;
|
||||
@@ -37,8 +31,10 @@ interface PersistCommandArgs {
|
||||
|
||||
interface ResolvedEnv {
|
||||
env: Record<string, string>;
|
||||
clearEnvKeys: string[];
|
||||
profileType: string;
|
||||
warning?: string;
|
||||
warnings?: string[];
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
const PERSIST_KNOWN_FLAGS = [
|
||||
@@ -513,68 +509,24 @@ function isSensitiveEnvKey(key: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve env vars for a profile */
|
||||
async function resolveProfileEnvVars(
|
||||
profileName: string,
|
||||
profileResult: ProfileDetectionResult
|
||||
): Promise<ResolvedEnv> {
|
||||
switch (profileResult.type) {
|
||||
case 'settings': {
|
||||
// API profile - load from settings file
|
||||
let env: Record<string, string> = {};
|
||||
if (profileResult.env) {
|
||||
env = profileResult.env;
|
||||
} else if (profileResult.settingsPath) {
|
||||
env = loadSettingsFromFile(expandPath(profileResult.settingsPath));
|
||||
}
|
||||
if (Object.keys(env).length === 0) {
|
||||
throw new Error(`Profile '${profileName}' has no env vars configured`);
|
||||
}
|
||||
return { env, profileType: 'API' };
|
||||
}
|
||||
case 'cliproxy': {
|
||||
// CLIProxy profile - generate env vars
|
||||
const provider =
|
||||
profileResult.provider || (profileName as (typeof CLIPROXY_PROFILES)[number]);
|
||||
const port = profileResult.port || CLIPROXY_DEFAULT_PORT;
|
||||
const env = getEffectiveEnvVars(provider, port, profileResult.settingsPath) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
return {
|
||||
env,
|
||||
profileType: 'CLIProxy',
|
||||
warning: 'CLIProxy must be running for this profile to work',
|
||||
};
|
||||
}
|
||||
case 'copilot': {
|
||||
// Copilot profile - generate env vars
|
||||
if (!profileResult.copilotConfig) {
|
||||
throw new Error('Copilot configuration not found');
|
||||
}
|
||||
const env = generateCopilotEnv(profileResult.copilotConfig);
|
||||
return {
|
||||
env,
|
||||
profileType: 'Copilot',
|
||||
warning: 'copilot-api daemon must be running for this profile to work',
|
||||
};
|
||||
}
|
||||
case 'account': {
|
||||
throw new Error(
|
||||
`Account profiles use CLAUDE_CONFIG_DIR isolation, not env vars.\n` +
|
||||
`Use 'ccs ${profileName}' to run with this profile instead.`
|
||||
);
|
||||
}
|
||||
case 'default': {
|
||||
throw new Error(
|
||||
'Default profile has no env vars to persist.\n' +
|
||||
'Specify a profile name: ccs persist <profile>'
|
||||
);
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown profile type: ${profileResult.type}`);
|
||||
}
|
||||
}
|
||||
/** Resolve shared Claude settings payload for a profile */
|
||||
async function resolveProfileEnvVars(profileName: string): Promise<ResolvedEnv> {
|
||||
const setup = await resolveClaudeExtensionSetup(profileName);
|
||||
const typeLabel: Record<string, string> = {
|
||||
settings: 'API',
|
||||
cliproxy: 'CLIProxy',
|
||||
copilot: 'Copilot',
|
||||
account: 'Account',
|
||||
default: 'Default',
|
||||
};
|
||||
|
||||
return {
|
||||
env: setup.extensionEnv,
|
||||
clearEnvKeys: setup.removeEnvKeys,
|
||||
profileType: typeLabel[setup.profileType] ?? setup.profileType,
|
||||
warnings: setup.warnings,
|
||||
notes: setup.notes,
|
||||
};
|
||||
}
|
||||
|
||||
/** Handle --list-backups flag */
|
||||
@@ -702,11 +654,11 @@ async function showHelp(): Promise<void> {
|
||||
console.log(` ${color('ccs persist', 'command')} --restore [timestamp]`);
|
||||
console.log('');
|
||||
console.log(subheader('Description'));
|
||||
console.log(" Writes a profile's environment variables directly to");
|
||||
console.log(" Writes a profile's Claude setup directly to");
|
||||
console.log(` ${getClaudeSettingsDisplayPath()} for native Claude Code usage.`);
|
||||
console.log('');
|
||||
console.log(' This allows Claude Code to use the profile without CCS,');
|
||||
console.log(' enabling compatibility with IDEs and extensions.');
|
||||
console.log(' This is the preferred shared-settings path for Claude Code');
|
||||
console.log(' and the Claude IDE extension when you want one profile everywhere.');
|
||||
console.log('');
|
||||
console.log(subheader('Options'));
|
||||
console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts (auto-backup)`);
|
||||
@@ -730,7 +682,12 @@ async function showHelp(): Promise<void> {
|
||||
console.log(` ${color('API profiles', 'command')} glm, glmt, km, custom API profiles`);
|
||||
console.log(` ${color('CLIProxy', 'command')} gemini, codex, agy, qwen, kiro, ghcp`);
|
||||
console.log(` ${color('Copilot', 'command')} copilot (requires copilot-api daemon)`);
|
||||
console.log(` ${dim('Account-based')} Not supported (uses CLAUDE_CONFIG_DIR)`);
|
||||
console.log(
|
||||
` ${color('Account profiles', 'command')} work, personal, client (persists CLAUDE_CONFIG_DIR)`
|
||||
);
|
||||
console.log(
|
||||
` ${color('default', 'command')} Clears CCS-managed overrides or inherits mapped continuity`
|
||||
);
|
||||
console.log('');
|
||||
console.log(subheader('Examples'));
|
||||
console.log(` ${dim('# Persist GLM profile')}`);
|
||||
@@ -745,6 +702,12 @@ async function showHelp(): Promise<void> {
|
||||
console.log(` ${dim('# Persist with auto-approve enabled')}`);
|
||||
console.log(` ${color('ccs persist codex --dangerously-skip-permissions', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Persist an account profile for IDE/native Claude use')}`);
|
||||
console.log(` ${color('ccs persist work --yes', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Reset to native Claude defaults (clear CCS-managed overrides)')}`);
|
||||
console.log(` ${color('ccs persist default --yes', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# List all backups')}`);
|
||||
console.log(` ${color('ccs persist --list-backups', 'command')}`);
|
||||
console.log('');
|
||||
@@ -757,6 +720,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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,9 @@ const CliproxyControlPanelPage = lazy(() =>
|
||||
);
|
||||
const CopilotPage = lazy(() => import('@/pages/copilot').then((m) => ({ default: m.CopilotPage })));
|
||||
const CursorPage = lazy(() => import('@/pages/cursor').then((m) => ({ default: m.CursorPage })));
|
||||
const ClaudeExtensionPage = lazy(() =>
|
||||
import('@/pages/claude-extension').then((m) => ({ default: m.ClaudeExtensionPage }))
|
||||
);
|
||||
const DroidPage = lazy(() => import('@/pages/droid').then((m) => ({ default: m.DroidPage })));
|
||||
const AccountsPage = lazy(() =>
|
||||
import('@/pages/accounts').then((m) => ({ default: m.AccountsPage }))
|
||||
@@ -119,6 +122,14 @@ export default function App() {
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/claude-extension"
|
||||
element={
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<ClaudeExtensionPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/droid"
|
||||
element={
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
BarChart3,
|
||||
Gauge,
|
||||
Github,
|
||||
Puzzle,
|
||||
TerminalSquare,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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: 'ヘルス',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,8 @@ export { AnalyticsPage } from './analytics';
|
||||
|
||||
export { CursorPage } from './cursor';
|
||||
|
||||
export { ClaudeExtensionPage } from './claude-extension';
|
||||
|
||||
export { UpdatesPage } from './updates';
|
||||
|
||||
export { DroidPage } from './droid';
|
||||
|
||||
Reference in New Issue
Block a user