mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-20 00:14:58 +00:00
feat(claude-extension): add binding workflow
This commit is contained in:
1 parent
56db6035f5
commit
a2f531016d
7 files changed
+2464
-208
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user