feat(claude-extension): add binding workflow

This commit is contained in:
Tam Nhu Tran
2026-03-15 15:58:37 -04:00
parent 56db6035f5
commit a2f531016d
7 changed files with 2464 additions and 208 deletions
+31 -5
View File
@@ -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>): string[] {
return [...new Set(keys)].sort((left, right) => left.localeCompare(right));
}
function sortEnvRecord(env: NodeJS.ProcessEnv | Record<string, string>): Record<string, string> {
const normalized: Record<string, string> = {};
@@ -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<string, unknown> {
const definition = getClaudeExtensionHostDefinition(host);
const payload: Record<string, unknown> = {
[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<string, Record<string, string>> {
return { env: setup.extensionEnv };
}
export function renderClaudeExtensionSettingsJson(
setup: ClaudeExtensionSetup,
host: ClaudeExtensionHost
): string {
return JSON.stringify(buildClaudeExtensionSettingsObject(setup, host), null, 2);
}
export function renderSharedClaudeSettingsJson(setup: ClaudeExtensionSetup): string {
return JSON.stringify({ env: setup.extensionEnv }, null, 2);
return JSON.stringify(buildSharedClaudeSettingsObject(setup), null, 2);
}
export function getClaudeExtensionHostMetadata(
@@ -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<ClaudeExtensionActionTarget>(['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<void> => {
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<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
) + '\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<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');
});
});
+156 -4
View File
@@ -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<T>(url: string): Promise<T> {
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<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(withApiBase(url), {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!res.ok) {
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(payload?.error || `Request failed (${res.status})`);
}
return res.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<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');
}
File diff suppressed because it is too large Load Diff