mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 14:19:56 +00:00
feat(claude-extension): add binding workflow
This commit is contained in:
@@ -45,15 +45,25 @@ export const CLAUDE_EXTENSION_MANAGED_ENV_KEYS = [
|
|||||||
'ANTHROPIC_AUTH_TOKEN',
|
'ANTHROPIC_AUTH_TOKEN',
|
||||||
'ANTHROPIC_BASE_URL',
|
'ANTHROPIC_BASE_URL',
|
||||||
'ANTHROPIC_MODEL',
|
'ANTHROPIC_MODEL',
|
||||||
|
'ANTHROPIC_MAX_TOKENS',
|
||||||
|
'ANTHROPIC_SAFE_MODE',
|
||||||
|
'ANTHROPIC_TEMPERATURE',
|
||||||
'ANTHROPIC_SMALL_FAST_MODEL',
|
'ANTHROPIC_SMALL_FAST_MODEL',
|
||||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||||
|
'API_TIMEOUT_MS',
|
||||||
'CLAUDE_CONFIG_DIR',
|
'CLAUDE_CONFIG_DIR',
|
||||||
'DISABLE_NON_ESSENTIAL_MODEL_CALLS',
|
'DISABLE_NON_ESSENTIAL_MODEL_CALLS',
|
||||||
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
||||||
|
'ENABLE_STREAMING',
|
||||||
|
'MAX_THINKING_TOKENS',
|
||||||
] as const;
|
] 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> {
|
function sortEnvRecord(env: NodeJS.ProcessEnv | Record<string, string>): Record<string, string> {
|
||||||
const normalized: Record<string, string> = {};
|
const normalized: Record<string, string> = {};
|
||||||
|
|
||||||
@@ -250,17 +260,20 @@ export async function resolveClaudeExtensionSetup(
|
|||||||
profileLabel: requestedProfile === 'default' ? 'default' : result.name,
|
profileLabel: requestedProfile === 'default' ? 'default' : result.name,
|
||||||
profileDescription: describeProfile(requestedProfile, result),
|
profileDescription: describeProfile(requestedProfile, result),
|
||||||
extensionEnv: resolved.extensionEnv,
|
extensionEnv: resolved.extensionEnv,
|
||||||
removeEnvKeys: [...CLAUDE_EXTENSION_MANAGED_ENV_KEYS],
|
removeEnvKeys: sortUniqueEnvKeys([
|
||||||
|
...CLAUDE_EXTENSION_MANAGED_ENV_KEYS,
|
||||||
|
...Object.keys(resolved.extensionEnv),
|
||||||
|
]),
|
||||||
warnings: resolved.warnings,
|
warnings: resolved.warnings,
|
||||||
notes: resolved.notes,
|
notes: resolved.notes,
|
||||||
disableLoginPrompt: resolved.disableLoginPrompt,
|
disableLoginPrompt: resolved.disableLoginPrompt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderClaudeExtensionSettingsJson(
|
export function buildClaudeExtensionSettingsObject(
|
||||||
setup: ClaudeExtensionSetup,
|
setup: ClaudeExtensionSetup,
|
||||||
host: ClaudeExtensionHost
|
host: ClaudeExtensionHost
|
||||||
): string {
|
): Record<string, unknown> {
|
||||||
const definition = getClaudeExtensionHostDefinition(host);
|
const definition = getClaudeExtensionHostDefinition(host);
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
[definition.settingsKey]: Object.entries(setup.extensionEnv).map(([name, value]) => ({
|
[definition.settingsKey]: Object.entries(setup.extensionEnv).map(([name, value]) => ({
|
||||||
@@ -271,11 +284,24 @@ export function renderClaudeExtensionSettingsJson(
|
|||||||
if (definition.disableLoginPromptKey && setup.disableLoginPrompt) {
|
if (definition.disableLoginPromptKey && setup.disableLoginPrompt) {
|
||||||
payload[definition.disableLoginPromptKey] = true;
|
payload[definition.disableLoginPromptKey] = true;
|
||||||
}
|
}
|
||||||
return JSON.stringify(payload, null, 2);
|
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 {
|
export function renderSharedClaudeSettingsJson(setup: ClaudeExtensionSetup): string {
|
||||||
return JSON.stringify({ env: setup.extensionEnv }, null, 2);
|
return JSON.stringify(buildSharedClaudeSettingsObject(setup), null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getClaudeExtensionHostMetadata(
|
export function getClaudeExtensionHostMetadata(
|
||||||
|
|||||||
@@ -11,9 +11,25 @@ import {
|
|||||||
renderSharedClaudeSettingsJson,
|
renderSharedClaudeSettingsJson,
|
||||||
resolveClaudeExtensionSetup,
|
resolveClaudeExtensionSetup,
|
||||||
} from '../../shared/claude-extension-setup';
|
} 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 router = Router();
|
||||||
const VALID_HOSTS = new Set(CLAUDE_EXTENSION_HOSTS.map((host) => host.id));
|
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 {
|
function getHostFromRequest(req: Request): ClaudeExtensionHost {
|
||||||
const rawHost = String(req.query.host || 'vscode');
|
const rawHost = String(req.query.host || 'vscode');
|
||||||
@@ -25,10 +41,40 @@ function getHostFromRequest(req: Request): ClaudeExtensionHost {
|
|||||||
return rawHost as 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 => {
|
router.get('/profiles', (_req: Request, res: Response): void => {
|
||||||
res.json({
|
res.json({
|
||||||
profiles: listClaudeExtensionProfiles(),
|
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<void> => {
|
|||||||
json: renderSharedClaudeSettingsJson(setup),
|
json: renderSharedClaudeSettingsJson(setup),
|
||||||
},
|
},
|
||||||
ideSettings: {
|
ideSettings: {
|
||||||
|
path: getDefaultClaudeExtensionIdeSettingsPath(host),
|
||||||
targetLabel: hostDefinition.settingsTargetLabel,
|
targetLabel: hostDefinition.settingsTargetLabel,
|
||||||
json: renderClaudeExtensionSettingsJson(setup, host),
|
json: renderClaudeExtensionSettingsJson(setup, host),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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<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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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<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,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<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);
|
||||||
|
}
|
||||||
@@ -61,12 +61,33 @@ describe('web-server claude-extension-routes', () => {
|
|||||||
2
|
2
|
||||||
) + '\n'
|
) + '\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();
|
const config = createEmptyUnifiedConfig();
|
||||||
config.profiles.glm = {
|
config.profiles.glm = {
|
||||||
type: 'api',
|
type: 'api',
|
||||||
settings: path.join(ccsDir, 'glm.settings.json'),
|
settings: path.join(ccsDir, 'glm.settings.json'),
|
||||||
};
|
};
|
||||||
|
config.profiles.rich = {
|
||||||
|
type: 'api',
|
||||||
|
settings: path.join(ccsDir, 'rich.settings.json'),
|
||||||
|
};
|
||||||
config.accounts.work = {
|
config.accounts.work = {
|
||||||
created: '2026-03-15T00:00:00.000Z',
|
created: '2026-03-15T00:00:00.000Z',
|
||||||
last_used: null,
|
last_used: null,
|
||||||
@@ -91,7 +112,7 @@ describe('web-server claude-extension-routes', () => {
|
|||||||
|
|
||||||
const payload = (await response.json()) as {
|
const payload = (await response.json()) as {
|
||||||
profiles: Array<{ name: string }>;
|
profiles: Array<{ name: string }>;
|
||||||
hosts: Array<{ id: string }>;
|
hosts: Array<{ id: string; defaultSettingsPath: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(payload.profiles.some((profile) => profile.name === 'default')).toBe(true);
|
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 === 'work')).toBe(true);
|
||||||
expect(payload.profiles.some((profile) => profile.name === 'gemini')).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.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 () => {
|
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.ideSettings.json).toContain('"CLAUDE_CONFIG_DIR"');
|
||||||
expect(payload.sharedSettings.command).toBe('ccs persist default');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
import { withApiBase } from '@/lib/api-client';
|
||||||
|
|
||||||
export interface ClaudeExtensionProfileOption {
|
export interface ClaudeExtensionProfileOption {
|
||||||
@@ -15,6 +16,7 @@ export interface ClaudeExtensionHostOption {
|
|||||||
disableLoginPromptKey?: string;
|
disableLoginPromptKey?: string;
|
||||||
settingsTargetLabel: string;
|
settingsTargetLabel: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
defaultSettingsPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClaudeExtensionSetupPayload {
|
export interface ClaudeExtensionSetupPayload {
|
||||||
@@ -36,18 +38,64 @@ export interface ClaudeExtensionSetupPayload {
|
|||||||
json: string;
|
json: string;
|
||||||
};
|
};
|
||||||
ideSettings: {
|
ideSettings: {
|
||||||
|
path: string;
|
||||||
targetLabel: string;
|
targetLabel: string;
|
||||||
json: string;
|
json: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestJson<T>(url: string): Promise<T> {
|
export interface ClaudeExtensionBinding {
|
||||||
const res = await fetch(withApiBase(url));
|
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) {
|
if (!res.ok) {
|
||||||
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
|
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||||
throw new Error(payload?.error || `Request failed (${res.status})`);
|
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() {
|
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<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');
|
||||||
|
}
|
||||||
|
|||||||
+932
-196
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user