mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +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_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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
+932
-196
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user