Merge pull request #739 from kaitranntt/kai/fix/738-marketplace-install-location

fix: normalize shared marketplace install paths
This commit is contained in:
Kai (Tam Nhu) Tran
2026-03-17 11:35:04 -04:00
committed by GitHub
8 changed files with 558 additions and 29 deletions
+2
View File
@@ -58,6 +58,8 @@ class InstanceManager {
await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy);
});
this.sharedManager.normalizeSharedPluginMetadataPaths(instancePath);
// Sync MCP servers from global ~/.claude.json (unless bare)
if (!options.bare) {
this.syncMcpServers(instancePath);
+117 -15
View File
@@ -18,6 +18,50 @@ interface SharedItem {
type: 'directory' | 'file';
}
export function normalizePluginMetadataPathString(input: string): string {
return input.replace(
/([\\/])\.ccs\1instances\1[^\\/]+\1/g,
(_match, separator: string) => `${separator}.claude${separator}`
);
}
function normalizePluginMetadataValue(value: unknown): { normalized: unknown; changed: boolean } {
if (typeof value === 'string') {
const normalized = normalizePluginMetadataPathString(value);
return { normalized, changed: normalized !== value };
}
if (Array.isArray(value)) {
let changed = false;
const normalized = value.map((item) => {
const result = normalizePluginMetadataValue(item);
changed = changed || result.changed;
return result.normalized;
});
return { normalized, changed };
}
if (value && typeof value === 'object') {
let changed = false;
const normalized = Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => {
const result = normalizePluginMetadataValue(item);
changed = changed || result.changed;
return [key, result.normalized];
})
);
return { normalized, changed };
}
return { normalized: value, changed: false };
}
export function normalizePluginMetadataContent(original: string): string {
const parsed = JSON.parse(original) as unknown;
const result = normalizePluginMetadataValue(parsed);
return result.changed ? JSON.stringify(result.normalized, null, 2) : original;
}
/**
* SharedManager Class
*/
@@ -210,8 +254,7 @@ class SharedManager {
}
}
// Normalize plugin registry paths after linking
this.normalizePluginRegistryPaths();
this.normalizeSharedPluginMetadataPaths(instancePath);
}
/**
@@ -539,6 +582,14 @@ class SharedManager {
}
}
/**
* Normalize shared plugin metadata files to canonical ~/.claude/ paths.
*/
normalizeSharedPluginMetadataPaths(configDir?: string): void {
this.normalizePluginRegistryPaths(configDir);
this.normalizeMarketplaceRegistryPaths(configDir);
}
/**
* Normalize plugin registry paths to use canonical ~/.claude/ paths
* instead of instance-specific ~/.ccs/instances/<name>/ paths.
@@ -546,31 +597,82 @@ class SharedManager {
* This ensures installed_plugins.json is consistent regardless of
* which CCS instance installed the plugin.
*/
normalizePluginRegistryPaths(): void {
const registryPath = path.join(this.claudeDir, 'plugins', 'installed_plugins.json');
normalizePluginRegistryPaths(configDir?: string): void {
this.normalizePluginMetadataFiles(
'installed_plugins.json',
configDir,
'Normalized plugin registry paths',
'plugin registry'
);
}
// Skip if registry doesn't exist
/**
* Normalize marketplace registry paths to use canonical ~/.claude/ paths
* instead of instance-specific ~/.ccs/instances/<name>/ paths.
*
* This ensures known_marketplaces.json is consistent regardless of
* which CCS instance added the marketplace.
*/
normalizeMarketplaceRegistryPaths(configDir?: string): void {
this.normalizePluginMetadataFiles(
'known_marketplaces.json',
configDir,
'Normalized marketplace registry paths',
'marketplace registry'
);
}
private normalizePluginMetadataFiles(
fileName: string,
configDir: string | undefined,
successMessage: string,
warningLabel: string
): void {
const seen = new Set<string>();
for (const registryPath of this.getPluginMetadataFilePaths(fileName, configDir)) {
const dedupeKey = this.resolveCanonicalPath(registryPath);
if (seen.has(dedupeKey)) {
continue;
}
seen.add(dedupeKey);
this.normalizePluginMetadataFile(registryPath, successMessage, warningLabel);
}
}
private getPluginMetadataFilePaths(fileName: string, configDir?: string): string[] {
const pluginDirs = new Set<string>([
path.join(this.claudeDir, 'plugins'),
path.join(this.sharedDir, 'plugins'),
]);
if (configDir && path.resolve(configDir) !== path.resolve(this.claudeDir)) {
pluginDirs.add(path.join(configDir, 'plugins'));
}
return [...pluginDirs].map((pluginDir) => path.join(pluginDir, fileName));
}
private normalizePluginMetadataFile(
registryPath: string,
successMessage: string,
warningLabel: string
): void {
if (!fs.existsSync(registryPath)) {
return;
}
try {
const original = fs.readFileSync(registryPath, 'utf8');
const normalized = normalizePluginMetadataContent(original);
// Replace instance paths with canonical claude path
// Pattern: /.ccs/instances/<instance-name>/ -> /.claude/
const normalized = original.replace(/\/\.ccs\/instances\/[^/]+\//g, '/.claude/');
// Only write if changes were made
if (normalized !== original) {
// Validate JSON before writing
JSON.parse(normalized);
fs.writeFileSync(registryPath, normalized, 'utf8');
console.log(ok('Normalized plugin registry paths'));
console.log(ok(successMessage));
}
} catch (err) {
// Log warning but don't fail - registry may be malformed
console.log(warn(`Could not normalize plugin registry: ${(err as Error).message}`));
console.log(warn(`Could not normalize ${warningLabel}: ${(err as Error).message}`));
}
}
+5
View File
@@ -12,6 +12,7 @@ import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import { getProxyTarget } from '../cliproxy/proxy-target-resolver';
import { generateCopilotEnv } from '../copilot/copilot-executor';
import InstanceManager from '../management/instance-manager';
import SharedManager from '../management/shared-manager';
import { expandPath } from '../utils/helpers';
import { getClaudeSettingsPath } from '../utils/claude-config-path';
import {
@@ -161,6 +162,7 @@ async function resolveExtensionEnv(
profileType: result.type,
target: 'claude',
});
new SharedManager().normalizeSharedPluginMetadataPaths(continuity.claudeConfigDir);
if (continuity.claudeConfigDir) {
notes.push(`Default profile inherits continuity from account "${continuity.sourceAccount}".`);
return {
@@ -234,6 +236,9 @@ async function resolveExtensionEnv(
`Continuity inheritance adds CLAUDE_CONFIG_DIR from account "${continuity.sourceAccount}".`
);
}
new SharedManager().normalizeSharedPluginMetadataPaths(env.CLAUDE_CONFIG_DIR);
if (result.type === 'copilot') {
warnings.push(
'copilot-api must stay reachable for this profile to work inside the IDE extension.'
+9
View File
@@ -9,6 +9,7 @@ import { ErrorManager } from './error-manager';
import { getWebSearchHookEnv } from './websearch-manager';
import { wireChildProcessSignals } from './signal-forwarder';
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
import SharedManager from '../management/shared-manager';
/**
* Strip ANTHROPIC_* env vars from an environment object.
@@ -122,6 +123,14 @@ export function execClaude(
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions)
const env = stripClaudeCodeEnv(mergedEnv);
if (profileType !== 'account') {
try {
new SharedManager().normalizeSharedPluginMetadataPaths(env.CLAUDE_CONFIG_DIR);
} catch {
// Best-effort normalization should never block Claude launch.
}
}
// propagate key env vars to tmux session so agent team teammates
// (spawned via tmux split-window) inherit the correct config dir
if (process.env.TMUX && envVars) {
+129 -4
View File
@@ -7,14 +7,35 @@ import SharedManager from '../../src/management/shared-manager';
describe('InstanceManager MCP sync', () => {
let tempRoot = '';
let originalHome: string | undefined;
let originalCcsHome: string | undefined;
let originalCcsDir: string | undefined;
function writeMarketplaceRegistry(registryPath: string, installLocation: string): void {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(
registryPath,
JSON.stringify(
{
'claude-code-plugins': {
installLocation,
},
},
null,
2
),
'utf8'
);
}
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-mcp-test-'));
originalHome = process.env.HOME;
originalCcsHome = process.env.CCS_HOME;
originalCcsDir = process.env.CCS_DIR;
spyOn(os, 'homedir').mockReturnValue(tempRoot);
process.env.HOME = tempRoot;
process.env.CCS_HOME = tempRoot;
delete process.env.CCS_DIR;
});
@@ -22,6 +43,9 @@ describe('InstanceManager MCP sync', () => {
afterEach(() => {
mock.restore();
if (originalHome !== undefined) process.env.HOME = originalHome;
else delete process.env.HOME;
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
@@ -71,7 +95,9 @@ describe('InstanceManager MCP sync', () => {
const synced = manager.syncMcpServers(instancePath);
expect(synced).toBe(true);
const instanceContent = JSON.parse(fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8'));
const instanceContent = JSON.parse(
fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8')
);
expect(instanceContent.otherKey).toBe('keep-me');
expect(instanceContent.mcpServers).toEqual({
globalOnly: { command: 'global-cmd' },
@@ -96,9 +122,10 @@ describe('InstanceManager MCP sync', () => {
});
it('skips shared symlinks and MCP sync for bare instance creation', async () => {
const linkSharedSpy = spyOn(SharedManager.prototype, 'linkSharedDirectories').mockImplementation(
() => {}
);
const linkSharedSpy = spyOn(
SharedManager.prototype,
'linkSharedDirectories'
).mockImplementation(() => {});
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
@@ -106,9 +133,107 @@ describe('InstanceManager MCP sync', () => {
);
const manager = new InstanceManager();
const instancePath = manager.getInstancePath('sandbox');
const sharedRegistryPath = path.join(tempRoot, '.claude', 'plugins', 'known_marketplaces.json');
writeMarketplaceRegistry(
sharedRegistryPath,
path.join(
tempRoot,
'.ccs',
'instances',
'work',
'plugins',
'marketplaces',
'claude-code-plugins'
)
);
await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true });
const normalized = JSON.parse(fs.readFileSync(sharedRegistryPath, 'utf8'));
expect(linkSharedSpy).not.toHaveBeenCalled();
expect(fs.existsSync(instancePath)).toBe(true);
expect(normalized['claude-code-plugins'].installLocation).toBe(
path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins')
);
expect(fs.existsSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'))).toBe(
false
);
expect(syncMcpSpy).not.toHaveBeenCalled();
});
it('normalizes shared plugin metadata for existing non-bare instances', async () => {
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
() => false
);
const manager = new InstanceManager();
const instancePath = manager.getInstancePath('work');
writeMarketplaceRegistry(
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
path.join(
tempRoot,
'.ccs',
'instances',
'work',
'plugins',
'marketplaces',
'claude-code-plugins'
)
);
await manager.ensureInstance('work', { mode: 'isolated' });
const normalized = JSON.parse(
fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8')
);
expect(normalized['claude-code-plugins'].installLocation).toBe(
path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins')
);
expect(syncMcpSpy).toHaveBeenCalledWith(instancePath);
});
it('normalizes shared plugin metadata during new non-bare instance creation', async () => {
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
() => false
);
const registryPath = path.join(tempRoot, '.claude', 'plugins', 'known_marketplaces.json');
writeMarketplaceRegistry(
registryPath,
path.join(
tempRoot,
'.ccs',
'instances',
'work',
'plugins',
'marketplaces',
'claude-code-plugins'
)
);
const manager = new InstanceManager();
const instancePath = await manager.ensureInstance('work', { mode: 'isolated' });
const expected = path.join(
tempRoot,
'.claude',
'plugins',
'marketplaces',
'claude-code-plugins'
);
const normalizedShared = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
const normalizedInstance = JSON.parse(
fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8')
);
expect(normalizedShared['claude-code-plugins'].installLocation).toBe(expected);
expect(normalizedInstance['claude-code-plugins'].installLocation).toBe(expected);
expect(syncMcpSpy).toHaveBeenCalledWith(instancePath);
});
});
+171 -4
View File
@@ -1,17 +1,60 @@
/**
* Unit tests for SharedManager - plugin registry path normalization
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import SharedManager, {
normalizePluginMetadataPathString,
} from '../../src/management/shared-manager';
// Test the normalization regex pattern directly
const normalizePluginPaths = (content: string): string => {
return content.replace(/\/\.ccs\/instances\/[^/]+\//g, '/.claude/');
return normalizePluginMetadataPathString(content);
};
describe('SharedManager', () => {
let tempRoot = '';
let originalHome: string | undefined;
let originalCcsHome: string | undefined;
let originalCcsDir: string | undefined;
let originalPlatform: PropertyDescriptor | undefined;
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-manager-test-'));
originalHome = process.env.HOME;
originalCcsHome = process.env.CCS_HOME;
originalCcsDir = process.env.CCS_DIR;
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
spyOn(os, 'homedir').mockReturnValue(tempRoot);
process.env.HOME = tempRoot;
process.env.CCS_HOME = tempRoot;
delete process.env.CCS_DIR;
});
afterEach(() => {
mock.restore();
if (originalHome !== undefined) process.env.HOME = originalHome;
else delete process.env.HOME;
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir;
else delete process.env.CCS_DIR;
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
if (tempRoot && fs.existsSync(tempRoot)) {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
describe('normalizePluginRegistryPaths', () => {
describe('regex pattern', () => {
it('should replace instance paths with canonical claude path', () => {
@@ -82,6 +125,24 @@ describe('SharedManager', () => {
'/home/kai/.claude/plugins/cache/claude-hud/claude-hud/0.0.2'
);
});
it('should normalize marketplace installLocation values', () => {
const original = {
'claude-code-plugins': {
installLocation:
'/home/kai/.ccs/instances/work/plugins/marketplaces/claude-code-plugins',
},
};
const input = JSON.stringify(original, null, 2);
const result = normalizePluginPaths(input);
expect(() => JSON.parse(result)).not.toThrow();
const parsed = JSON.parse(result);
expect(parsed['claude-code-plugins'].installLocation).toBe(
'/home/kai/.claude/plugins/marketplaces/claude-code-plugins'
);
});
});
describe('edge cases', () => {
@@ -96,10 +157,116 @@ describe('SharedManager', () => {
});
it('should handle Windows-style paths (backslash)', () => {
// Windows paths use backslashes, regex should not match
const input = 'C:\\Users\\user\\.ccs\\instances\\ck\\plugins\\cache';
expect(normalizePluginPaths(input)).toBe(input);
expect(normalizePluginPaths(input)).toBe('C:\\Users\\user\\.claude\\plugins\\cache');
});
});
});
describe('normalizeMarketplaceRegistryPaths', () => {
it('rewrites known_marketplaces.json on disk', () => {
const pluginsDir = path.join(tempRoot, '.claude', 'plugins');
fs.mkdirSync(pluginsDir, { recursive: true });
const registryPath = path.join(pluginsDir, 'known_marketplaces.json');
fs.writeFileSync(
registryPath,
JSON.stringify(
{
'claude-code-plugins': {
installLocation:
'/home/kai/.ccs/instances/work/plugins/marketplaces/claude-code-plugins',
},
},
null,
2
),
'utf8'
);
const manager = new SharedManager();
manager.normalizeMarketplaceRegistryPaths();
const normalized = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
expect(normalized['claude-code-plugins'].installLocation).toBe(
'/home/kai/.claude/plugins/marketplaces/claude-code-plugins'
);
});
it('rewrites Windows-style known_marketplaces.json paths on disk', () => {
const pluginsDir = path.join(tempRoot, '.claude', 'plugins');
fs.mkdirSync(pluginsDir, { recursive: true });
const registryPath = path.join(pluginsDir, 'known_marketplaces.json');
fs.writeFileSync(
registryPath,
JSON.stringify(
{
'claude-code-plugins': {
installLocation:
'C:\\Users\\kai\\.ccs\\instances\\work\\plugins\\marketplaces\\claude-code-plugins',
},
},
null,
2
),
'utf8'
);
const manager = new SharedManager();
manager.normalizeMarketplaceRegistryPaths();
const normalized = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
expect(normalized['claude-code-plugins'].installLocation).toBe(
'C:\\Users\\kai\\.claude\\plugins\\marketplaces\\claude-code-plugins'
);
});
it('normalizes copied shared and instance metadata under Windows fallback', () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
spyOn(fs, 'symlinkSync').mockImplementation(() => {
throw Object.assign(new Error('simulated symlink failure'), { code: 'EPERM' });
});
const pluginsDir = path.join(tempRoot, '.claude', 'plugins');
fs.mkdirSync(pluginsDir, { recursive: true });
const registryPath = path.join(pluginsDir, 'known_marketplaces.json');
fs.writeFileSync(
registryPath,
JSON.stringify(
{
'claude-code-plugins': {
installLocation:
'/home/kai/.ccs/instances/work/plugins/marketplaces/claude-code-plugins',
},
},
null,
2
),
'utf8'
);
const manager = new SharedManager();
const instancePath = path.join(tempRoot, '.ccs', 'instances', 'personal');
fs.mkdirSync(instancePath, { recursive: true });
manager.linkSharedDirectories(instancePath);
const expected = '/home/kai/.claude/plugins/marketplaces/claude-code-plugins';
const claudeRegistry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
const sharedRegistry = JSON.parse(
fs.readFileSync(
path.join(tempRoot, '.ccs', 'shared', 'plugins', 'known_marketplaces.json'),
'utf8'
)
);
const instanceRegistry = JSON.parse(
fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8')
);
expect(claudeRegistry['claude-code-plugins'].installLocation).toBe(expected);
expect(sharedRegistry['claude-code-plugins'].installLocation).toBe(expected);
expect(instanceRegistry['claude-code-plugins'].installLocation).toBe(expected);
});
});
});
@@ -1,4 +1,14 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
mock,
spyOn,
} from 'bun:test';
import { EventEmitter } from 'events';
import * as childProcess from 'child_process';
import * as fs from 'fs';
@@ -115,6 +125,7 @@ preferences:
let execClaude: typeof import('../../../src/utils/shell-executor').execClaude;
let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv;
let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor;
let SharedManager: typeof import('../../../src/management/shared-manager').default;
beforeAll(async () => {
registerChildProcessMock();
@@ -123,6 +134,9 @@ beforeAll(async () => {
execClaude = shellExecutor.execClaude;
stripClaudeCodeEnv = shellExecutor.stripClaudeCodeEnv;
const sharedManagerModule = await import('../../../src/management/shared-manager');
SharedManager = sharedManagerModule.default;
const headless = await import('../../../src/delegation/headless-executor');
HeadlessExecutor = headless.HeadlessExecutor;
});
@@ -242,6 +256,32 @@ describe('CLAUDECODE environment stripping', () => {
expect(env.DISABLE_AUTOUPDATER).toBeUndefined();
});
it('execClaude normalizes shared plugin metadata before default-profile launch', () => {
const normalizeSpy = spyOn(
SharedManager.prototype,
'normalizeSharedPluginMetadataPaths'
).mockImplementation(() => {});
execClaude('claude', ['--help'], { CCS_PROFILE_TYPE: 'default' });
expect(normalizeSpy).toHaveBeenCalledWith(undefined);
});
it('execClaude normalizes shared plugin metadata using CLAUDE_CONFIG_DIR when provided', () => {
const normalizeSpy = spyOn(
SharedManager.prototype,
'normalizeSharedPluginMetadataPaths'
).mockImplementation(() => {});
const instancePath = path.join(os.tmpdir(), 'ccs-shell-executor-instance');
execClaude('claude', ['--help'], {
CCS_PROFILE_TYPE: 'settings',
CLAUDE_CONFIG_DIR: instancePath,
});
expect(normalizeSpy).toHaveBeenCalledWith(instancePath);
});
it('headless executor spawn path strips CLAUDECODE before spawn', async () => {
writeConfigWithAutoUpdatePreference(false);
process.env.CLAUDECODE = 'nested';
@@ -1,10 +1,21 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
mock,
spyOn,
} from 'bun:test';
import express from 'express';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { Server } from 'http';
import claudeExtensionRoutes from '../../../src/web-server/routes/claude-extension-routes';
import SharedManager from '../../../src/management/shared-manager';
import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types';
import { saveUnifiedConfig } from '../../../src/config/unified-config-loader';
@@ -98,6 +109,8 @@ describe('web-server claude-extension-routes', () => {
});
afterEach(() => {
mock.restore();
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
@@ -143,6 +156,61 @@ describe('web-server claude-extension-routes', () => {
expect(payload.sharedSettings.json).toContain('"env"');
});
it('normalizes the effective profile CLAUDE_CONFIG_DIR for extension setup', async () => {
const explicitConfigDir = path.join(tempHome, '.claude-profiles', 'glm');
const glmSettingsPath = path.join(tempHome, '.ccs', 'glm.settings.json');
const normalizeSpy = spyOn(
SharedManager.prototype,
'normalizeSharedPluginMetadataPaths'
).mockImplementation(() => {});
fs.writeFileSync(
glmSettingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://api.example.test',
ANTHROPIC_API_KEY: 'sk-ant-test-123456',
ANTHROPIC_MODEL: 'claude-sonnet-4-5',
CLAUDE_CONFIG_DIR: explicitConfigDir,
},
},
null,
2
) + '\n'
);
const config = createEmptyUnifiedConfig();
config.profiles.glm = {
type: 'api',
settings: glmSettingsPath,
};
config.accounts.work = {
created: '2026-03-15T00:00:00.000Z',
last_used: null,
context_mode: 'isolated',
};
config.default = 'work';
config.continuity = {
inherit_from_account: {
glm: 'work',
},
};
saveUnifiedConfig(config);
const response = await fetch(`${baseUrl}/api/claude-extension/setup?profile=glm&host=vscode`);
expect(response.status).toBe(200);
const payload = (await response.json()) as {
ideSettings: { json: string };
};
expect(payload.ideSettings.json).toContain(explicitConfigDir);
expect(normalizeSpy.mock.calls.some(([configDir]) => configDir === explicitConfigDir)).toBe(
true
);
});
it('renders Windsurf setup for default account resolution via CLAUDE_CONFIG_DIR', async () => {
const response = await fetch(
`${baseUrl}/api/claude-extension/setup?profile=default&host=windsurf`
@@ -179,7 +247,11 @@ describe('web-server claude-extension-routes', () => {
expect(createResponse.status).toBe(201);
const created = (await createResponse.json()) as {
binding: { id: string; effectiveIdeSettingsPath: string; usesDefaultIdeSettingsPath: boolean };
binding: {
id: string;
effectiveIdeSettingsPath: string;
usesDefaultIdeSettingsPath: boolean;
};
};
expect(created.binding.effectiveIdeSettingsPath).toBe(ideSettingsPath);
expect(created.binding.usesDefaultIdeSettingsPath).toBe(false);
@@ -418,7 +490,10 @@ describe('web-server claude-extension-routes', () => {
);
expect(applyResponse.status).toBe(200);
let ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record<string, unknown>;
let ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record<
string,
unknown
>;
const appliedEnv = ideSettings['claudeCode.environmentVariables'] as Array<{
name: string;
value: string;
@@ -426,7 +501,9 @@ describe('web-server claude-extension-routes', () => {
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')
appliedEnv.some(
(entry) => entry.name === 'ANTHROPIC_API_KEY' && entry.value === 'sk-ant-test-123456'
)
).toBe(true);
const verifyAppliedResponse = await fetch(
@@ -452,7 +529,9 @@ describe('web-server claude-extension-routes', () => {
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' }]);
expect(ideSettings['claudeCode.environmentVariables']).toEqual([
{ name: 'KEEP_ME', value: '1' },
]);
const verifyResetResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/verify`