diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index 1fe60c94..7cdbdc97 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -58,9 +58,10 @@ 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.sharedManager.normalizeSharedPluginMetadataPaths(); this.syncMcpServers(instancePath); } diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index af5e15ef..cc617d3b 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -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).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,7 +254,7 @@ class SharedManager { } } - this.normalizeSharedPluginMetadataPaths(); + this.normalizeSharedPluginMetadataPaths(instancePath); } /** @@ -541,9 +585,9 @@ class SharedManager { /** * Normalize shared plugin metadata files to canonical ~/.claude/ paths. */ - normalizeSharedPluginMetadataPaths(): void { - this.normalizePluginRegistryPaths(); - this.normalizeMarketplaceRegistryPaths(); + normalizeSharedPluginMetadataPaths(configDir?: string): void { + this.normalizePluginRegistryPaths(configDir); + this.normalizeMarketplaceRegistryPaths(configDir); } /** @@ -553,9 +597,10 @@ class SharedManager { * This ensures installed_plugins.json is consistent regardless of * which CCS instance installed the plugin. */ - normalizePluginRegistryPaths(): void { - this.normalizePluginMetadataFile( - path.join(this.claudeDir, 'plugins', 'installed_plugins.json'), + normalizePluginRegistryPaths(configDir?: string): void { + this.normalizePluginMetadataFiles( + 'installed_plugins.json', + configDir, 'Normalized plugin registry paths', 'plugin registry' ); @@ -568,14 +613,47 @@ class SharedManager { * This ensures known_marketplaces.json is consistent regardless of * which CCS instance added the marketplace. */ - normalizeMarketplaceRegistryPaths(): void { - this.normalizePluginMetadataFile( - path.join(this.claudeDir, 'plugins', 'known_marketplaces.json'), + 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(); + + 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([ + 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, @@ -587,12 +665,9 @@ class SharedManager { try { const original = fs.readFileSync(registryPath, 'utf8'); - - // Pattern: /.ccs/instances// -> /.claude/ - const normalized = original.replace(/\/\.ccs\/instances\/[^/]+\//g, '/.claude/'); + const normalized = normalizePluginMetadataContent(original); if (normalized !== original) { - JSON.parse(normalized); fs.writeFileSync(registryPath, normalized, 'utf8'); console.log(ok(successMessage)); } diff --git a/src/shared/claude-extension-setup.ts b/src/shared/claude-extension-setup.ts index dddf3efd..5aaaa409 100644 --- a/src/shared/claude-extension-setup.ts +++ b/src/shared/claude-extension-setup.ts @@ -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.' diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 0d9cbd45..16861f00 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -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) { diff --git a/tests/unit/instance-manager-mcp-sync.test.ts b/tests/unit/instance-manager-mcp-sync.test.ts index 29dd11af..1a39e803 100644 --- a/tests/unit/instance-manager-mcp-sync.test.ts +++ b/tests/unit/instance-manager-mcp-sync.test.ts @@ -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,45 +122,118 @@ 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 normalizeSharedPluginMetadataSpy = spyOn( - SharedManager.prototype, - 'normalizeSharedPluginMetadataPaths' - ).mockImplementation(() => {}); const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( () => false ); 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(normalizeSharedPluginMetadataSpy).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 normalizeSharedPluginMetadataSpy = spyOn( - SharedManager.prototype, - 'normalizeSharedPluginMetadataPaths' - ).mockImplementation(() => {}); const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( () => false ); const manager = new InstanceManager(); const instancePath = manager.getInstancePath('work'); - fs.mkdirSync(instancePath, { recursive: true }); + 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' }); - expect(normalizeSharedPluginMetadataSpy).toHaveBeenCalledTimes(1); + 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); }); }); diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 02446cb8..96ee5eee 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -5,11 +5,13 @@ import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:te import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import SharedManager from '../../src/management/shared-manager'; +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', () => { @@ -17,12 +19,14 @@ describe('SharedManager', () => { 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; @@ -42,6 +46,10 @@ describe('SharedManager', () => { 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 }); } @@ -149,9 +157,8 @@ 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'); }); }); }); @@ -185,5 +192,81 @@ describe('SharedManager', () => { '/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); + }); }); }); diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index 64a2323a..0d10ebe0 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -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'; diff --git a/tests/unit/web-server/claude-extension-routes.test.ts b/tests/unit/web-server/claude-extension-routes.test.ts index 84e27c77..8dbc458a 100644 --- a/tests/unit/web-server/claude-extension-routes.test.ts +++ b/tests/unit/web-server/claude-extension-routes.test.ts @@ -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; + 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; 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`