From a2f531016d29093c957c1000860fc83e2dc55cb4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 15 Mar 2026 15:57:03 -0400 Subject: [PATCH] feat(claude-extension): add binding workflow --- src/shared/claude-extension-setup.ts | 36 +- .../routes/claude-extension-routes.ts | 122 +- .../claude-extension-binding-service.ts | 275 ++++ .../claude-extension-settings-service.ts | 608 +++++++++ .../claude-extension-routes.test.ts | 343 ++++- ui/src/hooks/use-claude-extension.ts | 160 ++- ui/src/pages/claude-extension.tsx | 1128 ++++++++++++++--- 7 files changed, 2464 insertions(+), 208 deletions(-) create mode 100644 src/web-server/services/claude-extension-binding-service.ts create mode 100644 src/web-server/services/claude-extension-settings-service.ts diff --git a/src/shared/claude-extension-setup.ts b/src/shared/claude-extension-setup.ts index fd504e26..dddf3efd 100644 --- a/src/shared/claude-extension-setup.ts +++ b/src/shared/claude-extension-setup.ts @@ -45,15 +45,25 @@ export const CLAUDE_EXTENSION_MANAGED_ENV_KEYS = [ '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[] { + return [...new Set(keys)].sort((left, right) => left.localeCompare(right)); +} + function sortEnvRecord(env: NodeJS.ProcessEnv | Record): Record { const normalized: Record = {}; @@ -250,17 +260,20 @@ export async function resolveClaudeExtensionSetup( profileLabel: requestedProfile === 'default' ? 'default' : result.name, profileDescription: describeProfile(requestedProfile, result), extensionEnv: resolved.extensionEnv, - removeEnvKeys: [...CLAUDE_EXTENSION_MANAGED_ENV_KEYS], + removeEnvKeys: sortUniqueEnvKeys([ + ...CLAUDE_EXTENSION_MANAGED_ENV_KEYS, + ...Object.keys(resolved.extensionEnv), + ]), warnings: resolved.warnings, notes: resolved.notes, disableLoginPrompt: resolved.disableLoginPrompt, }; } -export function renderClaudeExtensionSettingsJson( +export function buildClaudeExtensionSettingsObject( setup: ClaudeExtensionSetup, host: ClaudeExtensionHost -): string { +): Record { const definition = getClaudeExtensionHostDefinition(host); const payload: Record = { [definition.settingsKey]: Object.entries(setup.extensionEnv).map(([name, value]) => ({ @@ -271,11 +284,24 @@ export function renderClaudeExtensionSettingsJson( if (definition.disableLoginPromptKey && setup.disableLoginPrompt) { payload[definition.disableLoginPromptKey] = true; } - return JSON.stringify(payload, null, 2); + return payload; +} + +export function buildSharedClaudeSettingsObject( + setup: ClaudeExtensionSetup +): Record> { + 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({ env: setup.extensionEnv }, null, 2); + return JSON.stringify(buildSharedClaudeSettingsObject(setup), null, 2); } export function getClaudeExtensionHostMetadata( diff --git a/src/web-server/routes/claude-extension-routes.ts b/src/web-server/routes/claude-extension-routes.ts index 41e081dd..76c66225 100644 --- a/src/web-server/routes/claude-extension-routes.ts +++ b/src/web-server/routes/claude-extension-routes.ts @@ -11,9 +11,25 @@ import { 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(['shared', 'ide', 'all']); function getHostFromRequest(req: Request): ClaudeExtensionHost { const rawHost = String(req.query.host || 'vscode'); @@ -25,10 +41,40 @@ function getHostFromRequest(req: Request): ClaudeExtensionHost { 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, + hosts: CLAUDE_EXTENSION_HOSTS.map((host) => ({ + ...host, + defaultSettingsPath: getDefaultClaudeExtensionIdeSettingsPath(host.id), + })), }); }); @@ -63,12 +109,84 @@ router.get('/setup', async (req: Request, res: Response): Promise => { json: renderSharedClaudeSettingsJson(setup), }, ideSettings: { + path: getDefaultClaudeExtensionIdeSettingsPath(host), targetLabel: hostDefinition.settingsTargetLabel, json: renderClaudeExtensionSettingsJson(setup, host), }, }); } catch (error) { - res.status(400).json({ error: (error as Error).message }); + 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 => { + 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 => { + 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 => { + 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); } }); diff --git a/src/web-server/services/claude-extension-binding-service.ts b/src/web-server/services/claude-extension-binding-service.ts new file mode 100644 index 00000000..93053e93 --- /dev/null +++ b/src/web-server/services/claude-extension-binding-service.ts @@ -0,0 +1,275 @@ +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(['vscode', 'cursor', 'windsurf']); + +function getBindingsFilePath(): string { + return path.join(getCcsDir(), 'claude-extension-bindings.json'); +} + +function isRecord(value: unknown): value is Record { + 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 +): 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 }); +} diff --git a/src/web-server/services/claude-extension-settings-service.ts b/src/web-server/services/claude-extension-settings-service.ts new file mode 100644 index 00000000..88bcb6e8 --- /dev/null +++ b/src/web-server/services/claude-extension-settings-service.ts @@ -0,0 +1,608 @@ +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; + mtime: number | null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function sortStringRecord(record: Record): Record { + return Object.fromEntries( + Object.entries(record).sort(([left], [right]) => left.localeCompare(right)) + ); +} + +function toStringRecord(record: Record): Record { + const normalized: Record = {}; + 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 { + 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, + 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, + managedKeys: ReadonlySet +): Record { + const rawEnv = isRecord(data.env) ? toStringRecord(data.env) : {}; + const managed: Record = {}; + 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 +): Record { + 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, + nextManagedEnv: Record +): 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, right: Record): 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 { + const manifest = getClaudeExtensionManagedEnvManifest(binding.id); + const manifestKeys = target === 'shared' ? manifest.shared : manifest.ide; + return new Set([...currentResolvedKeys, ...manifestKeys]); +} + +function getCurrentResolvedManagedKeys(setup: Awaited>): 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 { + 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 { + 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 { + 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); +} diff --git a/tests/unit/web-server/claude-extension-routes.test.ts b/tests/unit/web-server/claude-extension-routes.test.ts index d30627e8..84e27c77 100644 --- a/tests/unit/web-server/claude-extension-routes.test.ts +++ b/tests/unit/web-server/claude-extension-routes.test.ts @@ -61,12 +61,33 @@ describe('web-server claude-extension-routes', () => { 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, @@ -91,7 +112,7 @@ describe('web-server claude-extension-routes', () => { const payload = (await response.json()) as { profiles: Array<{ name: string }>; - hosts: Array<{ id: string }>; + hosts: Array<{ id: string; defaultSettingsPath: string }>; }; expect(payload.profiles.some((profile) => profile.name === 'default')).toBe(true); @@ -99,6 +120,9 @@ describe('web-server claude-extension-routes', () => { 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 () => { @@ -139,4 +163,321 @@ describe('web-server claude-extension-routes', () => { 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; + }; + 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; + }; + 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; + }; + + 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; + }; + 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; + 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; + 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'); + }); }); diff --git a/ui/src/hooks/use-claude-extension.ts b/ui/src/hooks/use-claude-extension.ts index a7e34b50..058e2e0b 100644 --- a/ui/src/hooks/use-claude-extension.ts +++ b/ui/src/hooks/use-claude-extension.ts @@ -1,4 +1,5 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; import { withApiBase } from '@/lib/api-client'; export interface ClaudeExtensionProfileOption { @@ -15,6 +16,7 @@ export interface ClaudeExtensionHostOption { disableLoginPromptKey?: string; settingsTargetLabel: string; description: string; + defaultSettingsPath: string; } export interface ClaudeExtensionSetupPayload { @@ -36,18 +38,64 @@ export interface ClaudeExtensionSetupPayload { json: string; }; ideSettings: { + path: string; targetLabel: string; json: string; }; } -async function requestJson(url: string): Promise { - const res = await fetch(withApiBase(url)); +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(url: string, options?: RequestInit): Promise { + 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.json(); + return res.status === 204 ? (undefined as T) : ((await res.json()) as T); } export function useClaudeExtensionOptions() { @@ -70,3 +118,107 @@ export function useClaudeExtensionSetup(profile?: string, host: string = 'vscode ), }); } + +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( + `/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(`/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( + `/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'); +} diff --git a/ui/src/pages/claude-extension.tsx b/ui/src/pages/claude-extension.tsx index 3eb91aa6..00bed2c0 100644 --- a/ui/src/pages/claude-extension.tsx +++ b/ui/src/pages/claude-extension.tsx @@ -1,8 +1,21 @@ import { useState } from 'react'; -import { AlertTriangle, Code2, Copy, FolderCog, Loader2, Sparkles } from 'lucide-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, @@ -10,7 +23,132 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { useClaudeExtensionOptions, useClaudeExtensionSetup } from '@/hooks/use-claude-extension'; +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 ( + + {state} + + ); +} + +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 ( +
+ {label} + {isPathRow ? ( +
+
+ + {formatPathForDisplay(value)} + +
+ +
+ ) : ( + + {value} + + )} +
+ ); +} function CodeBlockCard({ title, @@ -22,7 +160,7 @@ function CodeBlockCard({ value: string; }) { return ( - +
@@ -33,7 +171,7 @@ function CodeBlockCard({
-
+        
           {value}
         
@@ -41,209 +179,807 @@ function CodeBlockCard({ ); } +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 ( + + +
+
+ {title} + {description} +
+ {status ? : null} +
+
+ +
+ + +
+ +
+ {status?.message || 'Verify the binding after saving to inspect the current file state.'} +
+ +
+ + +
+
+
+ ); +} + +function BindingListItem({ + binding, + isSelected, + onSelect, +}: { + binding: ClaudeExtensionBinding; + isSelected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + export function ClaudeExtensionPage() { const optionsQuery = useClaudeExtensionOptions(); - const [profile, setProfile] = useState(''); - const [host, setHost] = useState('vscode'); - const selectedProfile = profile || optionsQuery.data?.profiles?.[0]?.name || ''; - const setupQuery = useClaudeExtensionSetup(selectedProfile, host); - const activeError = (optionsQuery.error as Error | null) ?? (setupQuery.error as Error | null); + 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(null); + const [draft, setDraft] = useState(() => 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 { + 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 { + 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(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 ( -
-
-
-
- - - Native setup - - VS Code first +
+
+
+
+
+
+
+ +
+
+

Claude Extension

+

+ Saved IDE bindings for CCS profiles +

+
+
+
+ {bindingCountLabel} + {selectedHost ? {selectedHost.label} : null} +
+
+
-

Claude IDE Extension

-

- Generate the correct Claude extension configuration for CCS API profiles, account - instances, CLIProxy modes, Copilot, and default/native Claude flows. -

+ + +
+ + + + {creating ? 'Create binding' : 'Binding editor'} + + + Save a profile + IDE path once, then apply or reset it from the dashboard. + + + +
+
Binding name
+ updateDraft('name', event.target.value)} + placeholder="VS Code · work profile" + /> +
+ +
+
CCS profile
+ +

+ {selectedProfile?.description || + 'Choose which CCS profile the IDE should inherit.'} +

+
+ +
+
IDE host
+ +
+ +
+
IDE settings path
+ updateDraft('ideSettingsPath', event.target.value)} + placeholder={ + selectedHost?.defaultSettingsPath || + 'Leave blank for the default user settings path' + } + /> +

+ Leave blank to use the default user settings path for{' '} + {selectedHost?.label || 'this IDE'}. +

+
+ +
+
Notes
+ updateDraft('notes', event.target.value)} + placeholder="Optional reminder for this machine or workspace" + /> +
+ +
+ + +
+ + {!creating ? ( + + ) : null} +
+
+ +
+
+ Saved bindings +
+
+ {bindings.length > 0 ? ( + bindings.map((binding) => ( + { + setIsCreating(false); + setSelectedBindingId(binding.id); + setDraft(bindingToDraft(binding)); + }} + /> + )) + ) : ( + + + No saved bindings yet. Create one to manage apply, reset, and drift checks + from the dashboard. + + + )} +
+
+
+
- - - Target selection - - Choose the CCS profile and IDE host you want to configure. - - - -
-
CCS profile
- -

- { - optionsQuery.data?.profiles.find((option) => option.name === selectedProfile) - ?.description - } -

-
-
-
IDE host
- -

- {optionsQuery.data?.hosts.find((option) => option.id === host)?.description} -

-
-
-
- - {optionsQuery.isLoading || (selectedProfile && setupQuery.isLoading) ? ( -
- - Resolving extension setup... -
- ) : null} - - {activeError ? ( - - - -
{activeError.message}
-
-
- ) : null} - - {setupQuery.data ? ( - <> -
- - - - - Resolved profile - - - -
{setupQuery.data.profile.label}
-
{setupQuery.data.profile.description}
- {setupQuery.data.profile.profileType} -
-
- - - - - Shared settings path - - - -
- {setupQuery.data.sharedSettings.path} +
+ +
+
+
+
+ {selectedProfile ? ( + {selectedProfile.label} + ) : null} + {selectedHost ? {selectedHost.label} : null} + {creating ? Draft : null} + {status?.sharedSettings && + isPlainStatusActive(status.sharedSettings) && + isPlainStatusActive(status.ideSettings) ? ( + In sync + ) : null}
-
- Preferred when you want Claude Code CLI and the IDE extension to share one CCS - profile. +
+

+ {selectedBinding?.name || 'Claude extension binding'} +

+

+ Manage the shared Claude settings file and the IDE-local settings file as two + scoped targets. +

- - - - - - - IDE target - - - -
{setupQuery.data.host.label}
-
{setupQuery.data.host.settingsKey}
-
- {setupQuery.data.ideSettings.targetLabel} -
-
-
-
- - {setupQuery.data.warnings.length > 0 ? ( - - - {setupQuery.data.warnings.map((warning) => ( -
- - {warning} -
- ))} -
-
- ) : null} - -
- - -
- - - -
-
- CLI shortcut - - Use the native shared-settings path directly from the terminal. - -
-
-
- -
-                {setupQuery.data.sharedSettings.command}
-              
- {setupQuery.data.notes.length > 0 ? ( -
- {setupQuery.data.notes.map((note) => ( -
- {note}
- ))} -
- ) : null} -
-
- - ) : null} + +
+ + {setup ? ( + + ) : null} +
+
+ + {activeError ? ( + + + +
{activeError.message}
+
+
+ ) : null} + + {!activeError ? ( + + + Overview + Advanced + + + +
+ runBindingAction('shared', 'apply')} + onReset={() => runBindingAction('shared', 'reset')} + disabled={creating} + busy={isBusyShared} + /> + runBindingAction('ide', 'apply')} + onReset={() => runBindingAction('ide', 'reset')} + disabled={creating} + busy={isBusyIde} + /> +
+ +
+ + + Resolved binding + + The binding uses the same profile resolution as `ccs persist` and `ccs + env`. + + + + + + + + + + {currentDraft.notes.trim() ? ( + + ) : null} + + + + + + Managed payload + + Keep the main view short. The full JSON stays in the Advanced tab. + + + +
+ {envPreview.map((entry) => ( + + {entry.name} + + ))} + {hiddenEnvCount > 0 ? ( + +{hiddenEnvCount} more + ) : null} +
+ +
+ {setup?.env.length ? ( +
+
+ CCS will inject {setup.env.length} environment values. +
+
+ The IDE-local target receives the extension schema. The shared + target receives the same env block through Claude settings. +
+
+ ) : ( +
+ This profile resolves to native Claude defaults, so apply/reset mainly + clears existing CCS-managed overrides. +
+ )} +
+ + {!creating ? ( +
+ + +
+ ) : ( +
+ Save this draft to unlock apply, reset, and verify actions. +
+ )} +
+
+
+ + {setup && (setup.warnings.length > 0 || setup.notes.length > 0) ? ( +
+ + + Warnings + + Operational details that can break the binding even when JSON is + correct. + + + + {setup.warnings.length > 0 ? ( + setup.warnings.map((warning) => ( +
+ + {warning} +
+ )) + ) : ( +
+ No runtime warnings for this binding. +
+ )} +
+
+ + + + Notes + + Short context from CCS about account continuity and host-specific + behavior. + + + + {setup.notes.length > 0 ? ( + setup.notes.map((note) => ( +
+ + {note} +
+ )) + ) : ( +
+ No extra notes for this binding. +
+ )} +
+
+
+ ) : null} +
+ + + {setup ? ( + <> +
+ + +
+ + + +
+
+ + Resolved environment payload + + + Exact environment values that the extension receives after CCS + expands this profile. + +
+ +
+
+ + {setup.env.length > 0 ? ( +
+                              {JSON.stringify(setup.env, null, 2)}
+                            
+ ) : ( +
+ No env payload. This binding resolves to native Claude defaults. +
+ )} +
+
+ +
+ + + Shared target metadata + + Useful when debugging drift or comparing with manual edits. + + + + + + + + + + + + IDE target metadata + + Current file path plus the extension setting key used for this host. + + + + + + + + +
+ + ) : ( + + + + Choose a profile and IDE host to preview the generated payload. + + + )} +
+
+ ) : null} +
+ +
); }