From 54ea36fd18955778a8c15bd825df22618593d9ed Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Mar 2026 07:08:09 -0400 Subject: [PATCH 01/19] fix(management): localize marketplace registry per instance --- src/management/instance-manager.ts | 17 +- src/management/profile-context-sync-lock.ts | 10 +- src/management/shared-manager.ts | 343 +++++++++++++++++-- tests/unit/instance-manager-mcp-sync.test.ts | 111 +++--- tests/unit/shared-manager.test.ts | 336 ++++++++---------- 5 files changed, 552 insertions(+), 265 deletions(-) diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index 7cdbdc97..7f3af249 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -26,11 +26,13 @@ class InstanceManager { private readonly instancesDir: string; private readonly sharedManager: SharedManager; private readonly contextSyncLock: ProfileContextSyncLock; + private readonly pluginLayoutLock: ProfileContextSyncLock; constructor() { this.instancesDir = path.join(getCcsDir(), 'instances'); this.sharedManager = new SharedManager(); this.contextSyncLock = new ProfileContextSyncLock(this.instancesDir); + this.pluginLayoutLock = new ProfileContextSyncLock(this.instancesDir); } /** @@ -56,9 +58,15 @@ class InstanceManager { // Apply context policy (isolated by default, optional shared group). await this.sharedManager.syncProjectContext(instancePath, contextPolicy); await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy); + + if (!options.bare) { + await this.pluginLayoutLock.withNamedLock('__plugin-layout__', async () => { + this.sharedManager.linkSharedDirectories(instancePath); + }); + } }); - this.sharedManager.normalizeSharedPluginMetadataPaths(instancePath); + this.sharedManager.normalizeSharedPluginMetadataPaths(options.bare ? undefined : instancePath); // Sync MCP servers from global ~/.claude.json (unless bare) if (!options.bare) { @@ -82,7 +90,7 @@ class InstanceManager { private initializeInstance( profileName: string, instancePath: string, - options: InstanceOptions = {} + _options: InstanceOptions = {} ): void { try { // Create base directory @@ -106,10 +114,7 @@ class InstanceManager { } }); - // Bare profiles skip shared symlinks (commands, skills, agents, settings.json) - if (!options.bare) { - this.sharedManager.linkSharedDirectories(instancePath); - } + // Shared links are created during ensureInstance() under the plugin layout lock. } catch (error) { throw new Error( `Failed to initialize instance for ${profileName}: ${(error as Error).message}` diff --git a/src/management/profile-context-sync-lock.ts b/src/management/profile-context-sync-lock.ts index c8158bc3..01072c6e 100644 --- a/src/management/profile-context-sync-lock.ts +++ b/src/management/profile-context-sync-lock.ts @@ -106,8 +106,8 @@ class ProfileContextSyncLock { return this.tryRemoveLockIfUnchanged(lockPath, snapshot.raw); } - async withLock(profileName: string, callback: () => Promise): Promise { - const lockPath = this.getLockPath(profileName); + async withNamedLock(lockName: string, callback: () => Promise): Promise { + const lockPath = this.getLockPath(lockName); const retryDelayMs = 50; const staleLockMs = 30000; const timeoutMs = staleLockMs + 5000; @@ -159,7 +159,7 @@ class ProfileContextSyncLock { } if (Date.now() - start > timeoutMs) { - throw new Error(`Timed out waiting for profile context lock: ${profileName}`); + throw new Error(`Timed out waiting for profile context lock: ${lockName}`); } await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); @@ -172,6 +172,10 @@ class ProfileContextSyncLock { this.tryRemoveLockIfUnchanged(lockPath, ownerPayloadRaw); } } + + async withLock(profileName: string, callback: () => Promise): Promise { + return this.withNamedLock(profileName, callback); + } } export default ProfileContextSyncLock; diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index cc617d3b..8df6e3e9 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -18,23 +18,64 @@ 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}` +const DEFAULT_INSTALLED_PLUGIN_REGISTRY = JSON.stringify( + { + version: 2, + plugins: {}, + }, + null, + 2 +); + +function getPluginPathModule( + targetConfigDir: string, + input: string +): typeof path.posix | typeof path.win32 { + return targetConfigDir.includes('\\') || input.includes('\\') ? path.win32 : path.posix; +} + +function normalizeTargetConfigDir(targetConfigDir: string, input: string): string { + const pathModule = getPluginPathModule(targetConfigDir, input); + return pathModule.normalize( + pathModule === path.win32 + ? targetConfigDir.replace(/\//g, '\\') + : targetConfigDir.replace(/\\/g, '/') ); } -function normalizePluginMetadataValue(value: unknown): { normalized: unknown; changed: boolean } { +export function normalizePluginMetadataPathString( + input: string, + targetConfigDir = path.join(os.homedir(), '.claude') +): string { + const match = input.match( + /^(.*?)([\\/])(?:\.claude|\.ccs\2shared|\.ccs\2instances\2[^\\/]+)\2plugins(?:(\2.*))?$/ + ); + + if (!match) { + return input; + } + + const pathModule = getPluginPathModule(targetConfigDir, input); + const normalizedTargetConfigDir = normalizeTargetConfigDir(targetConfigDir, input); + const suffix = match[3] ?? ''; + const suffixSegments = suffix.split(/[\\/]+/).filter(Boolean); + + return pathModule.join(normalizedTargetConfigDir, 'plugins', ...suffixSegments); +} + +function normalizePluginMetadataValue( + value: unknown, + targetConfigDir: string +): { normalized: unknown; changed: boolean } { if (typeof value === 'string') { - const normalized = normalizePluginMetadataPathString(value); + const normalized = normalizePluginMetadataPathString(value, targetConfigDir); return { normalized, changed: normalized !== value }; } if (Array.isArray(value)) { let changed = false; const normalized = value.map((item) => { - const result = normalizePluginMetadataValue(item); + const result = normalizePluginMetadataValue(item, targetConfigDir); changed = changed || result.changed; return result.normalized; }); @@ -45,7 +86,7 @@ function normalizePluginMetadataValue(value: unknown): { normalized: unknown; ch let changed = false; const normalized = Object.fromEntries( Object.entries(value as Record).map(([key, item]) => { - const result = normalizePluginMetadataValue(item); + const result = normalizePluginMetadataValue(item, targetConfigDir); changed = changed || result.changed; return [key, result.normalized]; }) @@ -56,9 +97,12 @@ function normalizePluginMetadataValue(value: unknown): { normalized: unknown; ch return { normalized: value, changed: false }; } -export function normalizePluginMetadataContent(original: string): string { +export function normalizePluginMetadataContent( + original: string, + targetConfigDir = path.join(os.homedir(), '.claude') +): string { const parsed = JSON.parse(original) as unknown; - const result = normalizePluginMetadataValue(parsed); + const result = normalizePluginMetadataValue(parsed, targetConfigDir); return result.changed ? JSON.stringify(result.normalized, null, 2) : original; } @@ -71,6 +115,12 @@ class SharedManager { private readonly claudeDir: string; private readonly instancesDir: string; private readonly sharedItems: SharedItem[]; + private readonly sharedPluginEntries: readonly SharedItem[] = [ + { name: 'cache', type: 'directory' }, + { name: 'marketplaces', type: 'directory' }, + { name: 'installed_plugins.json', type: 'file' }, + ]; + private readonly instanceLocalPluginMetadataFiles = new Set(['known_marketplaces.json']); private readonly advancedContinuityItems: readonly string[] = [ 'session-env', 'file-history', @@ -148,6 +198,8 @@ class SharedManager { fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o700 }); } + this.ensureSharedPluginLayoutDefaults(); + // Create symlinks ~/.ccs/shared/* → ~/.claude/* for (const item of this.sharedItems) { const claudePath = path.join(this.claudeDir, item.name); @@ -221,17 +273,15 @@ class SharedManager { this.ensureSharedDirectories(); for (const item of this.sharedItems) { + if (item.name === 'plugins') { + this.linkInstancePlugins(instancePath); + continue; + } + const linkPath = path.join(instancePath, item.name); const targetPath = path.join(this.sharedDir, item.name); - // Remove existing file/directory/link - if (fs.existsSync(linkPath)) { - if (item.type === 'directory') { - fs.rmSync(linkPath, { recursive: true, force: true }); - } else { - fs.unlinkSync(linkPath); - } - } + this.removeExistingPath(linkPath, item.type); // Create symlink try { @@ -257,6 +307,126 @@ class SharedManager { this.normalizeSharedPluginMetadataPaths(instancePath); } + private ensureSharedPluginLayoutDefaults(): void { + const pluginsDir = path.join(this.claudeDir, 'plugins'); + fs.mkdirSync(pluginsDir, { recursive: true, mode: 0o700 }); + + for (const entry of this.sharedPluginEntries) { + const entryPath = path.join(pluginsDir, entry.name); + if (fs.existsSync(entryPath)) { + continue; + } + + if (entry.type === 'directory') { + fs.mkdirSync(entryPath, { recursive: true, mode: 0o700 }); + continue; + } + + fs.writeFileSync(entryPath, DEFAULT_INSTALLED_PLUGIN_REGISTRY, 'utf8'); + } + + const marketplaceRegistryPath = path.join(pluginsDir, 'known_marketplaces.json'); + if (!fs.existsSync(marketplaceRegistryPath)) { + fs.writeFileSync(marketplaceRegistryPath, JSON.stringify({}, null, 2), 'utf8'); + } + } + + private linkInstancePlugins(instancePath: string): void { + const linkPath = path.join(instancePath, 'plugins'); + const targetPath = path.join(this.sharedDir, 'plugins'); + let linkStats: fs.Stats | null = null; + + try { + linkStats = fs.lstatSync(linkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + + if (linkStats?.isSymbolicLink() || (linkStats && !linkStats.isDirectory())) { + this.removeExistingPath(linkPath, linkStats.isDirectory() ? 'directory' : 'file'); + } + + if (!linkStats || !linkStats.isDirectory()) { + fs.mkdirSync(linkPath, { recursive: true, mode: 0o700 }); + } + + for (const item of this.getSharedPluginLinkItems()) { + const targetEntryPath = path.join(targetPath, item.name); + const linkEntryPath = path.join(linkPath, item.name); + + this.removeExistingPath(linkEntryPath, item.type); + + try { + const symlinkType = item.type === 'directory' ? 'dir' : 'file'; + fs.symlinkSync(targetEntryPath, linkEntryPath, symlinkType); + } catch (_err) { + if (process.platform === 'win32') { + if (item.type === 'directory') { + this.copyDirectoryFallback(targetEntryPath, linkEntryPath); + } else { + fs.copyFileSync(targetEntryPath, linkEntryPath); + } + console.log( + warn(`Symlink failed for plugins/${item.name}, copied instead (enable Developer Mode)`) + ); + } else { + throw _err; + } + } + } + } + + private getSharedPluginLinkItems(): SharedItem[] { + const sharedPluginsPath = path.join(this.sharedDir, 'plugins'); + const items = new Map( + this.sharedPluginEntries.map((entry) => [entry.name, { ...entry }]) + ); + + for (const entry of fs.readdirSync(sharedPluginsPath, { withFileTypes: true })) { + if (items.has(entry.name) || this.instanceLocalPluginMetadataFiles.has(entry.name)) { + continue; + } + + const entryPath = path.join(sharedPluginsPath, entry.name); + const stats = fs.statSync(entryPath); + items.set(entry.name, { + name: entry.name, + type: stats.isDirectory() ? 'directory' : 'file', + }); + } + + return [...items.values()]; + } + + private removeExistingPath(targetPath: string, typeHint: SharedItem['type']): void { + try { + const stats = fs.lstatSync(targetPath); + if (stats.isDirectory() && !stats.isSymbolicLink()) { + fs.rmSync(targetPath, { recursive: true, force: true }); + return; + } + + if (stats.isSymbolicLink() || typeHint === 'file') { + fs.unlinkSync(targetPath); + return; + } + + fs.rmSync(targetPath, { recursive: true, force: true }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return; + } + + if (typeHint === 'directory') { + fs.rmSync(targetPath, { recursive: true, force: true }); + } else { + fs.rmSync(targetPath, { force: true }); + } + } + } + /** * Sync project workspace context based on account policy. * @@ -583,7 +753,7 @@ class SharedManager { } /** - * Normalize shared plugin metadata files to canonical ~/.claude/ paths. + * Normalize plugin metadata and reconcile marketplace metadata for the active config dir. */ normalizeSharedPluginMetadataPaths(configDir?: string): void { this.normalizePluginRegistryPaths(configDir); @@ -607,19 +777,31 @@ class SharedManager { } /** - * Normalize marketplace registry paths to use canonical ~/.claude/ paths - * instead of instance-specific ~/.ccs/instances// paths. - * - * This ensures known_marketplaces.json is consistent regardless of - * which CCS instance added the marketplace. + * Reconcile marketplace registry content into the active config dir while + * keeping the global ~/.claude copy up to date for non-instance flows. */ normalizeMarketplaceRegistryPaths(configDir?: string): void { - this.normalizePluginMetadataFiles( - 'known_marketplaces.json', - configDir, - 'Normalized marketplace registry paths', - 'marketplace registry' - ); + const successMessage = 'Synchronized marketplace registry paths'; + const warningLabel = 'marketplace registry'; + + try { + const sourcePaths = this.getMarketplaceRegistrySourcePaths(configDir); + this.writePluginMetadataFile( + path.join(this.claudeDir, 'plugins', 'known_marketplaces.json'), + this.buildMarketplaceRegistryContent(sourcePaths, this.claudeDir), + successMessage + ); + + if (configDir && path.resolve(configDir) !== path.resolve(this.claudeDir)) { + this.writePluginMetadataFile( + path.join(configDir, 'plugins', 'known_marketplaces.json'), + this.buildMarketplaceRegistryContent(sourcePaths, configDir), + successMessage + ); + } + } catch (err) { + console.log(warn(`Could not synchronize ${warningLabel}: ${(err as Error).message}`)); + } } private normalizePluginMetadataFiles( @@ -676,6 +858,107 @@ class SharedManager { } } + private getMarketplaceRegistrySourcePaths(configDir?: string): string[] { + const sourcePaths = new Set([ + path.join(this.claudeDir, 'plugins', 'known_marketplaces.json'), + ]); + + if (fs.existsSync(this.instancesDir)) { + for (const entry of fs.readdirSync(this.instancesDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + sourcePaths.add( + path.join(this.instancesDir, entry.name, 'plugins', 'known_marketplaces.json') + ); + } + } + + if (configDir && path.resolve(configDir) !== path.resolve(this.claudeDir)) { + sourcePaths.add(path.join(configDir, 'plugins', 'known_marketplaces.json')); + } + + return [...sourcePaths]; + } + + private buildMarketplaceRegistryContent(sourcePaths: string[], targetConfigDir: string): string { + const merged: Record = {}; + + for (const registryPath of sourcePaths) { + if (!fs.existsSync(registryPath)) { + continue; + } + + try { + const parsed = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + continue; + } + + for (const [name, value] of Object.entries(parsed as Record)) { + merged[name] = normalizePluginMetadataValue(value, targetConfigDir).normalized; + } + } catch (_err) { + // Best-effort merge: malformed sources should not block self-heal. + } + } + + for (const [name, value] of Object.entries(this.discoverMarketplaceEntries(targetConfigDir))) { + const existing = merged[name]; + if (existing && typeof existing === 'object' && !Array.isArray(existing)) { + merged[name] = { + ...(existing as Record), + installLocation: value.installLocation, + }; + continue; + } + + merged[name] = value; + } + + return JSON.stringify(merged, null, 2); + } + + private discoverMarketplaceEntries( + targetConfigDir: string + ): Record { + const marketplacesDir = path.join(targetConfigDir, 'plugins', 'marketplaces'); + if (!fs.existsSync(marketplacesDir)) { + return {}; + } + + const discovered: Record = {}; + + for (const entry of fs.readdirSync(marketplacesDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + discovered[entry.name] = { + installLocation: path.join(targetConfigDir, 'plugins', 'marketplaces', entry.name), + }; + } + + return discovered; + } + + private writePluginMetadataFile( + registryPath: string, + content: string, + successMessage: string + ): void { + fs.mkdirSync(path.dirname(registryPath), { recursive: true, mode: 0o700 }); + const current = fs.existsSync(registryPath) ? fs.readFileSync(registryPath, 'utf8') : null; + + if (current === content) { + return; + } + + fs.writeFileSync(registryPath, content, 'utf8'); + console.log(ok(successMessage)); + } + /** * Migrate from v3.1.1 (copied data in ~/.ccs/shared/) to v3.2.0 (symlinks to ~/.claude/) * Runs once on upgrade diff --git a/tests/unit/instance-manager-mcp-sync.test.ts b/tests/unit/instance-manager-mcp-sync.test.ts index 1a39e803..847b37b3 100644 --- a/tests/unit/instance-manager-mcp-sync.test.ts +++ b/tests/unit/instance-manager-mcp-sync.test.ts @@ -11,6 +11,12 @@ describe('InstanceManager MCP sync', () => { let originalCcsHome: string | undefined; let originalCcsDir: string | undefined; + const claudeDir = () => path.join(tempRoot, '.claude'); + const marketplacePath = (configDir: string, name = 'claude-code-plugins') => + path.join(configDir, 'plugins', 'marketplaces', name); + const readJson = (filePath: string) => + JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record; + function writeMarketplaceRegistry(registryPath: string, installLocation: string): void { fs.mkdirSync(path.dirname(registryPath), { recursive: true }); fs.writeFileSync( @@ -28,6 +34,11 @@ describe('InstanceManager MCP sync', () => { ); } + function expectMarketplaceLocation(registryPath: string, expectedLocation: string): void { + const parsed = readJson(registryPath) as Record; + expect(parsed['claude-code-plugins']?.installLocation).toBe(expectedLocation); + } + beforeEach(() => { tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-mcp-test-')); originalHome = process.env.HOME; @@ -95,9 +106,10 @@ 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 = readJson(path.join(instancePath, '.claude.json')) as { + otherKey: string; + mcpServers: Record; + }; expect(instanceContent.otherKey).toBe('keep-me'); expect(instanceContent.mcpServers).toEqual({ globalOnly: { command: 'global-cmd' }, @@ -134,9 +146,9 @@ describe('InstanceManager MCP sync', () => { const manager = new InstanceManager(); const instancePath = manager.getInstancePath('sandbox'); - const sharedRegistryPath = path.join(tempRoot, '.claude', 'plugins', 'known_marketplaces.json'); + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); writeMarketplaceRegistry( - sharedRegistryPath, + globalRegistryPath, path.join( tempRoot, '.ccs', @@ -150,20 +162,16 @@ describe('InstanceManager MCP sync', () => { 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') - ); + expectMarketplaceLocation(globalRegistryPath, marketplacePath(claudeDir())); 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 () => { + it('rewrites existing non-bare instance marketplace metadata to the instance-local plugin dir', async () => { spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( @@ -174,38 +182,28 @@ describe('InstanceManager MCP sync', () => { const instancePath = manager.getInstancePath('work'); writeMarketplaceRegistry( path.join(instancePath, 'plugins', 'known_marketplaces.json'), - path.join( - tempRoot, - '.ccs', - 'instances', - 'work', - 'plugins', - 'marketplaces', - 'claude-code-plugins' - ) + path.join(tempRoot, '.claude', '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') + expectMarketplaceLocation( + path.join(instancePath, 'plugins', 'known_marketplaces.json'), + marketplacePath(instancePath) ); expect(syncMcpSpy).toHaveBeenCalledWith(instancePath); }); - it('normalizes shared plugin metadata during new non-bare instance creation', async () => { + it('writes new non-bare instance marketplace metadata without clobbering the global copy', 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'); + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); writeMarketplaceRegistry( - registryPath, + globalRegistryPath, path.join( tempRoot, '.ccs', @@ -220,20 +218,51 @@ describe('InstanceManager MCP sync', () => { const manager = new InstanceManager(); const instancePath = await manager.ensureInstance('work', { mode: 'isolated' }); - const expected = path.join( - tempRoot, - '.claude', - 'plugins', - 'marketplaces', - 'claude-code-plugins' + expectMarketplaceLocation(globalRegistryPath, marketplacePath(claudeDir())); + expectMarketplaceLocation( + path.join(instancePath, 'plugins', 'known_marketplaces.json'), + marketplacePath(instancePath) ); - 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); }); + + it('keeps alternating instances independently valid for Claude Code validation', async () => { + spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); + spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); + spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(() => false); + + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + writeMarketplaceRegistry( + globalRegistryPath, + path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins') + ); + + const manager = new InstanceManager(); + const workPath = await manager.ensureInstance('work', { mode: 'isolated' }); + const personalPath = await manager.ensureInstance('personal', { mode: 'isolated' }); + await manager.ensureInstance('work', { mode: 'isolated' }); + + const workInstallLocation = ( + readJson(path.join(workPath, 'plugins', 'known_marketplaces.json')) as Record< + string, + { installLocation?: string } + > + )['claude-code-plugins']?.installLocation; + const personalInstallLocation = ( + readJson(path.join(personalPath, 'plugins', 'known_marketplaces.json')) as Record< + string, + { installLocation?: string } + > + )['claude-code-plugins']?.installLocation; + + expect(workInstallLocation).toBe(marketplacePath(workPath)); + expect(personalInstallLocation).toBe(marketplacePath(personalPath)); + expect(path.resolve(workInstallLocation ?? '')).toStartWith( + path.resolve(path.join(workPath, 'plugins', 'marketplaces')) + ); + expect(path.resolve(personalInstallLocation ?? '')).toStartWith( + path.resolve(path.join(personalPath, 'plugins', 'marketplaces')) + ); + expectMarketplaceLocation(globalRegistryPath, marketplacePath(claudeDir())); + }); }); diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 96ee5eee..9bffc9b4 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -1,19 +1,12 @@ -/** - * Unit tests for SharedManager - plugin registry path normalization - */ -import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; import * as fs from 'fs'; -import * as path from 'path'; import * as os from 'os'; +import * as path from 'path'; import SharedManager, { + normalizePluginMetadataContent, normalizePluginMetadataPathString, } from '../../src/management/shared-manager'; -// Test the normalization regex pattern directly -const normalizePluginPaths = (content: string): string => { - return normalizePluginMetadataPathString(content); -}; - describe('SharedManager', () => { let tempRoot = ''; let originalHome: string | undefined; @@ -21,6 +14,24 @@ describe('SharedManager', () => { let originalCcsDir: string | undefined; let originalPlatform: PropertyDescriptor | undefined; + const claudeDir = () => path.join(tempRoot, '.claude'); + const ccsDir = () => path.join(tempRoot, '.ccs'); + const instanceDir = (name: string) => path.join(ccsDir(), 'instances', name); + const marketplacePath = (configDir: string, name = 'claude-code-plugins') => + path.join(configDir, 'plugins', 'marketplaces', name); + const readJson = (filePath: string) => + JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record; + + function writeJson(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8'); + } + + function readMarketplaceLocation(filePath: string, name = 'claude-code-plugins'): string { + const parsed = readJson(filePath) as Record; + return parsed[name]?.installLocation ?? ''; + } + beforeEach(() => { tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-manager-test-')); originalHome = process.env.HOME; @@ -55,218 +66,173 @@ describe('SharedManager', () => { } }); - describe('normalizePluginRegistryPaths', () => { - describe('regex pattern', () => { - it('should replace instance paths with canonical claude path', () => { - const input = '/home/user/.ccs/instances/ck/plugins/cache/plugin/0.0.2'; - const expected = '/home/user/.claude/plugins/cache/plugin/0.0.2'; - expect(normalizePluginPaths(input)).toBe(expected); - }); + describe('plugin metadata path normalization', () => { + it('rewrites instance plugin paths to the requested target config dir', () => { + const targetConfigDir = path.join('/home/user', '.claude'); + const input = '/home/user/.ccs/instances/work/plugins/cache/plugin/0.0.2'; - it('should handle different instance names', () => { - const inputs = [ - '/home/user/.ccs/instances/work/plugins/cache/plugin/1.0.0', - '/home/user/.ccs/instances/personal/plugins/cache/plugin/1.0.0', - '/home/user/.ccs/instances/test-account/plugins/cache/plugin/1.0.0', - ]; - for (const input of inputs) { - expect(normalizePluginPaths(input)).toContain('/.claude/'); - expect(normalizePluginPaths(input)).not.toContain('/.ccs/instances/'); - } - }); + expect(normalizePluginMetadataPathString(input, targetConfigDir)).toBe( + '/home/user/.claude/plugins/cache/plugin/0.0.2' + ); + }); - it('should handle multiple occurrences', () => { - const input = JSON.stringify({ + it('rewrites shared plugin paths to an instance-local target config dir', () => { + const targetConfigDir = instanceDir('personal'); + const input = path.join(tempRoot, '.ccs', 'shared', 'plugins', 'marketplaces', 'official'); + + expect(normalizePluginMetadataPathString(input, targetConfigDir)).toBe( + marketplacePath(targetConfigDir, 'official') + ); + }); + + it('normalizes all matching JSON string values without changing the structure', () => { + const targetConfigDir = instanceDir('work'); + const input = JSON.stringify( + { plugins: { - 'plugin-a': [{ installPath: '/home/user/.ccs/instances/ck/plugins/a' }], - 'plugin-b': [{ installPath: '/home/user/.ccs/instances/work/plugins/b' }], - }, - }); - const result = normalizePluginPaths(input); - expect(result).not.toContain('/.ccs/instances/'); - expect(result.match(/\.claude/g)?.length).toBe(2); - }); - - it('should not modify already-canonical paths', () => { - const input = '/home/user/.claude/plugins/cache/plugin/0.0.2'; - expect(normalizePluginPaths(input)).toBe(input); - }); - - it('should be idempotent', () => { - const input = '/home/user/.ccs/instances/ck/plugins/cache/plugin/0.0.2'; - const first = normalizePluginPaths(input); - const second = normalizePluginPaths(first); - expect(first).toBe(second); - }); - - it('should preserve JSON structure', () => { - const original = { - version: 2, - plugins: { - 'claude-hud@claude-hud': [ + 'plugin-a': [ { - scope: 'user', - installPath: - '/home/kai/.ccs/instances/ck/plugins/cache/claude-hud/claude-hud/0.0.2', - version: '0.0.2', + installPath: path.join( + tempRoot, + '.ccs', + 'instances', + 'old', + 'plugins', + 'cache', + 'plugin-a' + ), }, ], }, - }; - const input = JSON.stringify(original, null, 2); - const result = normalizePluginPaths(input); - - // Should be valid JSON - expect(() => JSON.parse(result)).not.toThrow(); - - // Should have normalized path - const parsed = JSON.parse(result); - expect(parsed.plugins['claude-hud@claude-hud'][0].installPath).toBe( - '/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', + marketplaces: { + official: { + installLocation: path.join( + tempRoot, + '.claude', + 'plugins', + 'marketplaces', + 'official' + ), + }, }, - }; - const input = JSON.stringify(original, null, 2); - const result = normalizePluginPaths(input); + }, + null, + 2 + ); - expect(() => JSON.parse(result)).not.toThrow(); + const normalized = JSON.parse(normalizePluginMetadataContent(input, targetConfigDir)) as { + plugins: { 'plugin-a': [{ installPath: string }] }; + marketplaces: { official: { installLocation: string } }; + }; - const parsed = JSON.parse(result); - expect(parsed['claude-code-plugins'].installLocation).toBe( - '/home/kai/.claude/plugins/marketplaces/claude-code-plugins' - ); - }); + expect(normalized.plugins['plugin-a'][0].installPath).toBe( + path.join(targetConfigDir, 'plugins', 'cache', 'plugin-a') + ); + expect(normalized.marketplaces.official.installLocation).toBe( + marketplacePath(targetConfigDir, 'official') + ); }); - describe('edge cases', () => { - it('should handle empty object', () => { - const input = JSON.stringify({}); - expect(normalizePluginPaths(input)).toBe(input); - }); + it('preserves paths already rooted at the target config dir', () => { + const targetConfigDir = instanceDir('work'); + const input = path.join(targetConfigDir, 'plugins', 'cache', 'plugin-a'); - it('should handle plugins without installPath', () => { - const input = JSON.stringify({ plugins: {} }); - expect(normalizePluginPaths(input)).toBe(input); - }); + expect(normalizePluginMetadataPathString(input, targetConfigDir)).toBe(input); + }); - it('should handle Windows-style paths (backslash)', () => { - const input = 'C:\\Users\\user\\.ccs\\instances\\ck\\plugins\\cache'; - expect(normalizePluginPaths(input)).toBe('C:\\Users\\user\\.claude\\plugins\\cache'); - }); + it('handles Windows path separators', () => { + const targetConfigDir = 'C:\\Users\\user\\.claude'; + const input = 'C:\\Users\\user\\.ccs\\instances\\work\\plugins\\marketplaces\\official'; + + expect(normalizePluginMetadataPathString(input, targetConfigDir)).toBe( + 'C:\\Users\\user\\.claude\\plugins\\marketplaces\\official' + ); }); }); - describe('normalizeMarketplaceRegistryPaths', () => { - it('rewrites known_marketplaces.json on disk', () => { - const pluginsDir = path.join(tempRoot, '.claude', 'plugins'); - fs.mkdirSync(pluginsDir, { recursive: true }); + describe('marketplace registry ownership', () => { + it('writes global and instance registries with different authoritative install locations', () => { + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + writeJson(globalRegistryPath, { + 'claude-code-plugins': { + installLocation: path.join( + tempRoot, + '.ccs', + 'instances', + 'work', + 'plugins', + 'marketplaces', + 'claude-code-plugins' + ), + }, + }); - 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 instancePath = instanceDir('personal'); + fs.mkdirSync(instancePath, { recursive: true }); const manager = new SharedManager(); - manager.normalizeMarketplaceRegistryPaths(); + manager.linkSharedDirectories(instancePath); - const normalized = JSON.parse(fs.readFileSync(registryPath, 'utf8')); - expect(normalized['claude-code-plugins'].installLocation).toBe( - '/home/kai/.claude/plugins/marketplaces/claude-code-plugins' - ); + const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json'); + expect(readMarketplaceLocation(globalRegistryPath)).toBe(marketplacePath(claudeDir())); + expect(readMarketplaceLocation(instanceRegistryPath)).toBe(marketplacePath(instancePath)); + expect(fs.lstatSync(path.join(instancePath, 'plugins')).isSymbolicLink()).toBe(false); + expect(fs.lstatSync(instanceRegistryPath).isSymbolicLink()).toBe(false); }); - 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' - ); - + it('self-heals missing installLocation from discovered marketplace payloads', () => { const manager = new SharedManager(); - manager.normalizeMarketplaceRegistryPaths(); + const instancePath = instanceDir('work'); + fs.mkdirSync(instancePath, { recursive: true }); + manager.linkSharedDirectories(instancePath); - const normalized = JSON.parse(fs.readFileSync(registryPath, 'utf8')); - expect(normalized['claude-code-plugins'].installLocation).toBe( - 'C:\\Users\\kai\\.claude\\plugins\\marketplaces\\claude-code-plugins' - ); + fs.mkdirSync(marketplacePath(claudeDir()), { recursive: true }); + writeJson(path.join(instancePath, 'plugins', 'known_marketplaces.json'), { + 'claude-code-plugins': { + label: 'Official marketplace', + }, + }); + + manager.normalizeMarketplaceRegistryPaths(instancePath); + + const repaired = readJson( + path.join(instancePath, 'plugins', 'known_marketplaces.json') + ) as Record; + expect(repaired['claude-code-plugins']).toEqual({ + label: 'Official marketplace', + installLocation: marketplacePath(instancePath), + }); }); - it('normalizes copied shared and instance metadata under Windows fallback', () => { + it('keeps the instance-local registry valid under Windows copy 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 globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + writeJson(globalRegistryPath, { + 'claude-code-plugins': { + installLocation: path.join( + tempRoot, + '.claude', + 'plugins', + 'marketplaces', + 'claude-code-plugins' + ), + }, + }); - 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 instancePath = instanceDir('personal'); + fs.mkdirSync(instancePath, { recursive: true }); 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); + const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json'); + expect(readMarketplaceLocation(globalRegistryPath)).toBe(marketplacePath(claudeDir())); + expect(readMarketplaceLocation(instanceRegistryPath)).toBe(marketplacePath(instancePath)); + expect(fs.existsSync(path.join(instancePath, 'plugins', 'marketplaces'))).toBe(true); }); }); }); From 242a095edb9e32a51fa3f487e9c1fd98dc127e1f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Mar 2026 07:08:24 -0400 Subject: [PATCH 02/19] docs(architecture): document marketplace registry ownership --- docs/codebase-summary.md | 9 ++++++++- docs/project-roadmap.md | 3 ++- docs/system-architecture/index.md | 17 ++++++++++++++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index df29f733..17ae9fca 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,6 +1,6 @@ # CCS Codebase Summary -Last Updated: 2026-03-17 +Last Updated: 2026-03-18 Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, and account-context validation hardening. @@ -222,6 +222,13 @@ src/ - API route rejects `context_group`/`continuity_mode` when mode is not `shared` - registry normalization drops malformed persisted `context_group` values +### Shared Plugin Layout + +- Shared payload owner: `src/management/shared-manager.ts`. +- Profile entry point: `src/management/instance-manager.ts`. +- `plugins/marketplaces/`, `plugins/cache/`, and `installed_plugins.json` stay shared through the `~/.ccs/shared/` topology. +- `known_marketplaces.json` is now instance-local under `~/.ccs/instances//plugins/` so Claude Code validates `installLocation` against the active `CLAUDE_CONFIG_DIR` instead of a last-writer-wins shared file. + ### Target Adapter Module The targets module provides an extensible interface for dispatching profiles to different CLI implementations. diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 347851b2..ebaef666 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-03-17 +Last Updated: 2026-03-18 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-03-18**: **#755** Marketplace refresh no longer reuses one shared `known_marketplaces.json` across isolated instances. CCS now keeps marketplace payload directories shared while reconciling per-instance marketplace metadata so Claude Code validation succeeds for alternating or concurrent profiles, including Windows copy fallback. - **2026-03-17**: Deprecated user-facing GLMT discovery across CLI help, completions, presets, and docs. Existing `glmt` profiles now run through a compatibility path that normalizes legacy proxy settings to the direct GLM endpoint. - **#748**: API profile creation now keeps provider selection compact by collapsing advanced presets behind an explicit toggle, shrinking chooser cards so the form fields stay visually primary, and giving `llama.cpp` a dedicated provider logo. - **#744**: API profile creation now keeps featured providers in a horizontal rail with scroll fallback, moves Anthropic Direct API to the end, reuses the shared Claude logo, and separates the custom-endpoint entry point from advanced template discovery. diff --git a/docs/system-architecture/index.md b/docs/system-architecture/index.md index a13cd68e..7de060eb 100644 --- a/docs/system-architecture/index.md +++ b/docs/system-architecture/index.md @@ -1,6 +1,6 @@ # CCS System Architecture -Last Updated: 2026-03-02 +Last Updated: 2026-03-18 High-level architecture overview for the CCS (Claude Code Switch) system. @@ -233,12 +233,27 @@ For detailed provider flows (CLIProxyAPI, legacy GLMT compatibility, quota manag +---> commands/ # Claude Code commands +---> skills/ # Custom skills +---> agents/ # Agent configurations + +---> plugins/ + | + +---> cache/ # Shared plugin payload/cache data + +---> marketplaces/ # Shared marketplace payload directories + +---> installed_plugins.json + + ~/.ccs/instances// + | + +---> plugins/ + | + +---> known_marketplaces.json # Instance-local registry for active CLAUDE_CONFIG_DIR validation ~/.factory/ (Droid CLI) | +---> settings.json # Droid config (custom models) ``` +Plugin ownership note: +- `commands/`, `skills/`, `agents/`, and `settings.json` remain shared through the existing symlink/copy flow. +- Marketplace payload directories stay shared, but `known_marketplaces.json` is reconciled per instance so Claude Code can validate `installLocation` against that instance's `CLAUDE_CONFIG_DIR/plugins/marketplaces`. + ### Config Loading Order ``` From 68a5d17327e4fc5e3bd5c9fcb48e1bcd96dd92c4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Mar 2026 07:34:05 -0400 Subject: [PATCH 03/19] fix(management): serialize marketplace registry reconciliation - keep bare and non-bare marketplace normalization inside the plugin-layout lock - warn when malformed registry sources are skipped during reconciliation - add regression coverage for cross-instance refresh metadata and legacy layout upgrade --- src/management/instance-manager.ts | 13 +- src/management/shared-manager.ts | 6 +- tests/unit/instance-manager-mcp-sync.test.ts | 135 +++++++++++++++---- tests/unit/shared-manager.test.ts | 38 ++++++ 4 files changed, 156 insertions(+), 36 deletions(-) diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index 7f3af249..d6e2f141 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -59,14 +59,15 @@ class InstanceManager { await this.sharedManager.syncProjectContext(instancePath, contextPolicy); await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy); - if (!options.bare) { - await this.pluginLayoutLock.withNamedLock('__plugin-layout__', async () => { + await this.pluginLayoutLock.withNamedLock('__plugin-layout__', async () => { + if (!options.bare) { this.sharedManager.linkSharedDirectories(instancePath); - }); - } - }); + return; + } - this.sharedManager.normalizeSharedPluginMetadataPaths(options.bare ? undefined : instancePath); + this.sharedManager.normalizeSharedPluginMetadataPaths(); + }); + }); // Sync MCP servers from global ~/.claude.json (unless bare) if (!options.bare) { diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 8df6e3e9..7e87c198 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -899,8 +899,10 @@ class SharedManager { for (const [name, value] of Object.entries(parsed as Record)) { merged[name] = normalizePluginMetadataValue(value, targetConfigDir).normalized; } - } catch (_err) { - // Best-effort merge: malformed sources should not block self-heal. + } catch (err) { + console.log( + warn(`Skipping malformed marketplace registry ${registryPath}: ${(err as Error).message}`) + ); } } diff --git a/tests/unit/instance-manager-mcp-sync.test.ts b/tests/unit/instance-manager-mcp-sync.test.ts index 847b37b3..fea00791 100644 --- a/tests/unit/instance-manager-mcp-sync.test.ts +++ b/tests/unit/instance-manager-mcp-sync.test.ts @@ -34,6 +34,28 @@ describe('InstanceManager MCP sync', () => { ); } + function writeMarketplaceRegistryWithMetadata( + registryPath: string, + installLocation: string, + metadata: Record + ): void { + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + fs.writeFileSync( + registryPath, + JSON.stringify( + { + 'claude-code-plugins': { + installLocation, + ...metadata, + }, + }, + null, + 2 + ), + 'utf8' + ); + } + function expectMarketplaceLocation(registryPath: string, expectedLocation: string): void { const parsed = readJson(registryPath) as Record; expect(parsed['claude-code-plugins']?.installLocation).toBe(expectedLocation); @@ -226,43 +248,100 @@ describe('InstanceManager MCP sync', () => { expect(syncMcpSpy).toHaveBeenCalledWith(instancePath); }); - it('keeps alternating instances independently valid for Claude Code validation', async () => { + it('reconciles marketplace metadata across isolated instances without losing refresh fields', async () => { spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); - spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(() => false); - - const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); - writeMarketplaceRegistry( - globalRegistryPath, - path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins') - ); const manager = new InstanceManager(); const workPath = await manager.ensureInstance('work', { mode: 'isolated' }); + const workRegistryPath = path.join(workPath, 'plugins', 'known_marketplaces.json'); + writeMarketplaceRegistryWithMetadata(workRegistryPath, marketplacePath(workPath), { + label: 'Official marketplace', + refreshToken: 'refresh-token', + metadata: { + source: 'refresh-flow', + lastSyncedAt: '2026-03-18T00:00:00Z', + }, + }); + const personalPath = await manager.ensureInstance('personal', { mode: 'isolated' }); - await manager.ensureInstance('work', { mode: 'isolated' }); + const workRegistry = readJson(workRegistryPath) as Record< + string, + { + installLocation?: string; + label?: string; + refreshToken?: string; + metadata?: Record; + } + >; + const personalRegistry = readJson(path.join(personalPath, 'plugins', 'known_marketplaces.json')) as Record< + string, + { + installLocation?: string; + label?: string; + refreshToken?: string; + metadata?: Record; + } + >; - const workInstallLocation = ( - readJson(path.join(workPath, 'plugins', 'known_marketplaces.json')) as Record< - string, - { installLocation?: string } - > - )['claude-code-plugins']?.installLocation; - const personalInstallLocation = ( - readJson(path.join(personalPath, 'plugins', 'known_marketplaces.json')) as Record< - string, - { installLocation?: string } - > - )['claude-code-plugins']?.installLocation; + expect(workRegistry['claude-code-plugins']).toMatchObject({ + installLocation: marketplacePath(workPath), + label: 'Official marketplace', + refreshToken: 'refresh-token', + metadata: { + source: 'refresh-flow', + lastSyncedAt: '2026-03-18T00:00:00Z', + }, + }); + expect(personalRegistry['claude-code-plugins']).toMatchObject({ + installLocation: marketplacePath(personalPath), + label: 'Official marketplace', + refreshToken: 'refresh-token', + metadata: { + source: 'refresh-flow', + lastSyncedAt: '2026-03-18T00:00:00Z', + }, + }); + }); - expect(workInstallLocation).toBe(marketplacePath(workPath)); - expect(personalInstallLocation).toBe(marketplacePath(personalPath)); - expect(path.resolve(workInstallLocation ?? '')).toStartWith( - path.resolve(path.join(workPath, 'plugins', 'marketplaces')) + it('upgrades a legacy shared plugins symlink to an instance-local layout', async () => { + spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); + spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); + + const manager = new InstanceManager(); + const legacyPath = manager.getInstancePath('legacy'); + const sharedPluginsPath = path.join(tempRoot, '.ccs', 'shared', 'plugins'); + fs.mkdirSync(sharedPluginsPath, { recursive: true }); + fs.mkdirSync(legacyPath, { recursive: true }); + fs.symlinkSync(sharedPluginsPath, path.join(legacyPath, 'plugins'), 'dir'); + + writeMarketplaceRegistryWithMetadata( + path.join(claudeDir(), 'plugins', 'known_marketplaces.json'), + path.join(tempRoot, '.ccs', 'shared', 'plugins', 'marketplaces', 'claude-code-plugins'), + { + label: 'Legacy marketplace', + refreshToken: 'legacy-refresh-token', + } ); - expect(path.resolve(personalInstallLocation ?? '')).toStartWith( - path.resolve(path.join(personalPath, 'plugins', 'marketplaces')) + + await manager.ensureInstance('legacy', { mode: 'isolated' }); + + expect(fs.lstatSync(path.join(legacyPath, 'plugins')).isSymbolicLink()).toBe(false); + expectMarketplaceLocation( + path.join(legacyPath, 'plugins', 'known_marketplaces.json'), + marketplacePath(legacyPath) ); - expectMarketplaceLocation(globalRegistryPath, marketplacePath(claudeDir())); + + const legacyRegistry = readJson( + path.join(legacyPath, 'plugins', 'known_marketplaces.json') + ) as Record< + string, + { installLocation?: string; label?: string; refreshToken?: string } + >; + expect(legacyRegistry['claude-code-plugins']).toMatchObject({ + installLocation: marketplacePath(legacyPath), + label: 'Legacy marketplace', + refreshToken: 'legacy-refresh-token', + }); }); }); diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 9bffc9b4..88caf1f2 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -204,6 +204,44 @@ describe('SharedManager', () => { }); }); + it('warns and skips malformed marketplace registries while keeping valid sources', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + fs.mkdirSync(instancePath, { recursive: true }); + manager.linkSharedDirectories(instancePath); + + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + writeJson(globalRegistryPath, { + 'claude-code-plugins': { + installLocation: path.join( + tempRoot, + '.ccs', + 'instances', + 'work', + 'plugins', + 'marketplaces', + 'claude-code-plugins' + ), + label: 'Official marketplace', + }, + }); + + const malformedRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json'); + fs.writeFileSync(malformedRegistryPath, '{invalid-json', 'utf8'); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + manager.normalizeMarketplaceRegistryPaths(instancePath); + + expect(readMarketplaceLocation(malformedRegistryPath)).toBe(marketplacePath(instancePath)); + expect( + logSpy.mock.calls.some( + ([message]) => + String(message).includes('Skipping malformed marketplace registry') && + String(message).includes(malformedRegistryPath) + ) + ).toBe(true); + }); + it('keeps the instance-local registry valid under Windows copy fallback', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); spyOn(fs, 'symlinkSync').mockImplementation(() => { From fc02c4b9682af6205735bdfd221dfb668f1821ae Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Mar 2026 07:55:09 -0400 Subject: [PATCH 04/19] fix(management): harden marketplace state transitions - detach verified shared symlinks when a profile is reopened as bare - prune stale marketplace entries whose payload directories no longer exist - add regressions for bare/non-bare transitions and stale registry recovery --- src/management/instance-manager.ts | 1 + src/management/shared-manager.ts | 147 ++++++++++++++++++- tests/unit/instance-manager-mcp-sync.test.ts | 93 ++++++++++++ tests/unit/shared-manager.test.ts | 37 +++++ 4 files changed, 277 insertions(+), 1 deletion(-) diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index d6e2f141..54302ada 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -65,6 +65,7 @@ class InstanceManager { return; } + this.sharedManager.detachSharedDirectories(instancePath); this.sharedManager.normalizeSharedPluginMetadataPaths(); }); }); diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 7e87c198..a5318dcc 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -307,6 +307,31 @@ class SharedManager { this.normalizeSharedPluginMetadataPaths(instancePath); } + detachSharedDirectories(instancePath: string): void { + this.ensureSharedDirectories(); + + for (const item of this.sharedItems) { + const managedPath = path.join(instancePath, item.name); + if (!fs.existsSync(managedPath)) { + continue; + } + + if (item.name === 'plugins') { + this.detachManagedPluginLayout(instancePath); + continue; + } + + const stats = fs.lstatSync(managedPath); + if (!stats.isSymbolicLink()) { + continue; + } + + if (this.symlinkPointsTo(managedPath, path.join(this.sharedDir, item.name))) { + this.removeExistingPath(managedPath, item.type); + } + } + } + private ensureSharedPluginLayoutDefaults(): void { const pluginsDir = path.join(this.claudeDir, 'plugins'); fs.mkdirSync(pluginsDir, { recursive: true, mode: 0o700 }); @@ -906,7 +931,9 @@ class SharedManager { } } - for (const [name, value] of Object.entries(this.discoverMarketplaceEntries(targetConfigDir))) { + const discoveredEntries = this.discoverMarketplaceEntries(targetConfigDir); + + for (const [name, value] of Object.entries(discoveredEntries)) { const existing = merged[name]; if (existing && typeof existing === 'object' && !Array.isArray(existing)) { merged[name] = { @@ -919,6 +946,12 @@ class SharedManager { merged[name] = value; } + for (const name of Object.keys(merged)) { + if (!(name in discoveredEntries)) { + delete merged[name]; + } + } + return JSON.stringify(merged, null, 2); } @@ -1442,6 +1475,118 @@ class SharedManager { return candidate; } + private symlinkPointsTo(linkPath: string, expectedTarget: string): boolean { + try { + const currentTarget = fs.readlinkSync(linkPath); + const resolvedCurrentTarget = path.resolve(path.dirname(linkPath), currentTarget); + return ( + this.resolveCanonicalPath(resolvedCurrentTarget) === + this.resolveCanonicalPath(expectedTarget) + ); + } catch { + return false; + } + } + + private detachManagedPluginLayout(instancePath: string): void { + const pluginsPath = path.join(instancePath, 'plugins'); + if (!fs.existsSync(pluginsPath)) { + return; + } + + const stats = fs.lstatSync(pluginsPath); + const sharedPluginsPath = path.join(this.sharedDir, 'plugins'); + + if (stats.isSymbolicLink()) { + if (this.symlinkPointsTo(pluginsPath, sharedPluginsPath)) { + this.removeExistingPath(pluginsPath, 'directory'); + } + return; + } + + if (!stats.isDirectory()) { + return; + } + + let removedManagedEntries = false; + + for (const item of this.getSharedPluginLinkItems()) { + const pluginEntryPath = path.join(pluginsPath, item.name); + if (!fs.existsSync(pluginEntryPath)) { + continue; + } + + const entryStats = fs.lstatSync(pluginEntryPath); + if (!entryStats.isSymbolicLink()) { + continue; + } + + if (this.symlinkPointsTo(pluginEntryPath, path.join(sharedPluginsPath, item.name))) { + this.removeExistingPath(pluginEntryPath, item.type); + removedManagedEntries = true; + } + } + + if (!removedManagedEntries) { + return; + } + + this.reconcileLocalMarketplaceRegistry(instancePath); + + if (fs.readdirSync(pluginsPath).length === 0) { + fs.rmSync(pluginsPath, { recursive: true, force: true }); + } + } + + private reconcileLocalMarketplaceRegistry(configDir: string): void { + const registryPath = path.join(configDir, 'plugins', 'known_marketplaces.json'); + if (!fs.existsSync(registryPath)) { + return; + } + + const discoveredEntries = this.discoverMarketplaceEntries(configDir); + if (Object.keys(discoveredEntries).length === 0) { + this.removeExistingPath(registryPath, 'file'); + return; + } + + let parsed: Record = {}; + try { + const raw = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as unknown; + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + parsed = raw as Record; + } + } catch { + parsed = {}; + } + + const reconciled = Object.fromEntries( + Object.entries(discoveredEntries).map(([name, value]) => { + const existing = parsed[name]; + if (existing && typeof existing === 'object' && !Array.isArray(existing)) { + return [ + name, + { + ...(normalizePluginMetadataValue(existing, configDir).normalized as Record< + string, + unknown + >), + installLocation: value.installLocation, + }, + ]; + } + + return [name, value]; + }) + ); + + this.writePluginMetadataFile( + registryPath, + JSON.stringify(reconciled, null, 2), + 'Synchronized marketplace registry paths' + ); + } + private resolveCanonicalPath(targetPath: string): string { try { return fs.realpathSync.native(targetPath); diff --git a/tests/unit/instance-manager-mcp-sync.test.ts b/tests/unit/instance-manager-mcp-sync.test.ts index fea00791..748eea2a 100644 --- a/tests/unit/instance-manager-mcp-sync.test.ts +++ b/tests/unit/instance-manager-mcp-sync.test.ts @@ -17,6 +17,10 @@ describe('InstanceManager MCP sync', () => { const readJson = (filePath: string) => JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record; + function ensureMarketplacePayload(configDir: string, name = 'claude-code-plugins'): void { + fs.mkdirSync(marketplacePath(configDir, name), { recursive: true }); + } + function writeMarketplaceRegistry(registryPath: string, installLocation: string): void { fs.mkdirSync(path.dirname(registryPath), { recursive: true }); fs.writeFileSync( @@ -169,6 +173,7 @@ describe('InstanceManager MCP sync', () => { const manager = new InstanceManager(); const instancePath = manager.getInstancePath('sandbox'); const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + ensureMarketplacePayload(claudeDir()); writeMarketplaceRegistry( globalRegistryPath, path.join( @@ -193,6 +198,90 @@ describe('InstanceManager MCP sync', () => { expect(syncMcpSpy).not.toHaveBeenCalled(); }); + it('detaches existing shared layout when an instance is reopened as bare', 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 = await manager.ensureInstance('work', { mode: 'isolated' }); + syncMcpSpy.mockClear(); + + await manager.ensureInstance('work', { mode: 'isolated' }, { bare: true }); + + expect(fs.existsSync(path.join(instancePath, 'settings.json'))).toBe(false); + expect(fs.existsSync(path.join(instancePath, 'commands'))).toBe(false); + expect(fs.existsSync(path.join(instancePath, 'skills'))).toBe(false); + expect(fs.existsSync(path.join(instancePath, 'agents'))).toBe(false); + expect(fs.existsSync(path.join(instancePath, 'plugins'))).toBe(false); + expect(syncMcpSpy).not.toHaveBeenCalled(); + }); + + it('restores the shared layout when a bare-reopened instance is switched back to non-bare', async () => { + spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); + spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); + const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( + () => false + ); + + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + ensureMarketplacePayload(claudeDir()); + writeMarketplaceRegistry(globalRegistryPath, marketplacePath(claudeDir())); + + const manager = new InstanceManager(); + const instancePath = await manager.ensureInstance('work', { mode: 'isolated' }); + await manager.ensureInstance('work', { mode: 'isolated' }, { bare: true }); + syncMcpSpy.mockClear(); + + await manager.ensureInstance('work', { mode: 'isolated' }); + + expect(fs.lstatSync(path.join(instancePath, 'settings.json')).isSymbolicLink()).toBe(true); + expectMarketplaceLocation( + path.join(instancePath, 'plugins', 'known_marketplaces.json'), + marketplacePath(instancePath) + ); + expect(syncMcpSpy).toHaveBeenCalledWith(instancePath); + }); + + it('preserves genuine bare-local content when re-ensuring a bare instance', 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('sandbox'); + fs.mkdirSync(path.join(instancePath, 'plugins', 'marketplaces', 'custom-market'), { + recursive: true, + }); + fs.mkdirSync(path.join(instancePath, 'commands'), { recursive: true }); + fs.writeFileSync( + path.join(instancePath, 'settings.json'), + JSON.stringify({ local: true }, null, 2), + 'utf8' + ); + fs.writeFileSync(path.join(instancePath, 'commands', 'local.md'), '# local', 'utf8'); + writeMarketplaceRegistry( + path.join(instancePath, 'plugins', 'known_marketplaces.json'), + marketplacePath(instancePath, 'custom-market') + ); + + await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true }); + + expect(readJson(path.join(instancePath, 'settings.json'))).toEqual({ local: true }); + expect(fs.readFileSync(path.join(instancePath, 'commands', 'local.md'), 'utf8')).toBe( + '# local' + ); + expectMarketplaceLocation( + path.join(instancePath, 'plugins', 'known_marketplaces.json'), + marketplacePath(instancePath, 'custom-market') + ); + expect(syncMcpSpy).not.toHaveBeenCalled(); + }); + it('rewrites existing non-bare instance marketplace metadata to the instance-local plugin dir', async () => { spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); @@ -202,6 +291,7 @@ describe('InstanceManager MCP sync', () => { const manager = new InstanceManager(); const instancePath = manager.getInstancePath('work'); + ensureMarketplacePayload(claudeDir()); writeMarketplaceRegistry( path.join(instancePath, 'plugins', 'known_marketplaces.json'), path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins') @@ -224,6 +314,7 @@ describe('InstanceManager MCP sync', () => { ); const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + ensureMarketplacePayload(claudeDir()); writeMarketplaceRegistry( globalRegistryPath, path.join( @@ -253,6 +344,7 @@ describe('InstanceManager MCP sync', () => { spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); const manager = new InstanceManager(); + ensureMarketplacePayload(claudeDir()); const workPath = await manager.ensureInstance('work', { mode: 'isolated' }); const workRegistryPath = path.join(workPath, 'plugins', 'known_marketplaces.json'); writeMarketplaceRegistryWithMetadata(workRegistryPath, marketplacePath(workPath), { @@ -315,6 +407,7 @@ describe('InstanceManager MCP sync', () => { fs.mkdirSync(legacyPath, { recursive: true }); fs.symlinkSync(sharedPluginsPath, path.join(legacyPath, 'plugins'), 'dir'); + ensureMarketplacePayload(claudeDir()); writeMarketplaceRegistryWithMetadata( path.join(claudeDir(), 'plugins', 'known_marketplaces.json'), path.join(tempRoot, '.ccs', 'shared', 'plugins', 'marketplaces', 'claude-code-plugins'), diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 88caf1f2..3cd142f8 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -22,6 +22,10 @@ describe('SharedManager', () => { const readJson = (filePath: string) => JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record; + function ensureMarketplacePayload(configDir: string, name = 'claude-code-plugins'): void { + fs.mkdirSync(marketplacePath(configDir, name), { recursive: true }); + } + function writeJson(filePath: string, value: unknown): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8'); @@ -153,6 +157,7 @@ describe('SharedManager', () => { describe('marketplace registry ownership', () => { it('writes global and instance registries with different authoritative install locations', () => { const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + ensureMarketplacePayload(claudeDir()); writeJson(globalRegistryPath, { 'claude-code-plugins': { installLocation: path.join( @@ -204,6 +209,36 @@ describe('SharedManager', () => { }); }); + it('prunes stale marketplace entries whose payload directories no longer exist', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + fs.mkdirSync(instancePath, { recursive: true }); + manager.linkSharedDirectories(instancePath); + + fs.mkdirSync(marketplacePath(claudeDir(), 'claude-code-plugins'), { recursive: true }); + writeJson(path.join(instancePath, 'plugins', 'known_marketplaces.json'), { + 'claude-code-plugins': { + installLocation: marketplacePath(instancePath, 'claude-code-plugins'), + label: 'Official marketplace', + }, + stale: { + installLocation: marketplacePath(instancePath, 'stale'), + label: 'Stale marketplace', + }, + }); + + manager.normalizeMarketplaceRegistryPaths(instancePath); + + const reconciled = readJson( + path.join(instancePath, 'plugins', 'known_marketplaces.json') + ) as Record; + expect(reconciled['claude-code-plugins']).toEqual({ + installLocation: marketplacePath(instancePath, 'claude-code-plugins'), + label: 'Official marketplace', + }); + expect(reconciled.stale).toBeUndefined(); + }); + it('warns and skips malformed marketplace registries while keeping valid sources', () => { const manager = new SharedManager(); const instancePath = instanceDir('work'); @@ -211,6 +246,7 @@ describe('SharedManager', () => { manager.linkSharedDirectories(instancePath); const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + ensureMarketplacePayload(claudeDir()); writeJson(globalRegistryPath, { 'claude-code-plugins': { installLocation: path.join( @@ -249,6 +285,7 @@ describe('SharedManager', () => { }); const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + ensureMarketplacePayload(claudeDir()); writeJson(globalRegistryPath, { 'claude-code-plugins': { installLocation: path.join( From 36e8ed5d878be13b1dfd7ea1a6e890d575a09360 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Mar 2026 08:04:48 -0400 Subject: [PATCH 05/19] fix(management): serialize lifecycle maintenance paths - serialize deleteInstance with the same profile and plugin-layout locks as ensure - lock non-account marketplace normalization paths and ignore .locks as an instance source --- src/auth/commands/create-command.ts | 2 +- src/auth/commands/remove-command.ts | 2 +- src/management/instance-manager.ts | 17 ++++- src/management/profile-context-sync-lock.ts | 73 ++++++++++++++++++++ src/management/shared-manager.ts | 11 ++- src/shared/claude-extension-setup.ts | 4 +- src/utils/shell-executor.ts | 2 +- src/web-server/routes/account-routes.ts | 4 +- tests/unit/instance-manager-mcp-sync.test.ts | 11 +++ 9 files changed, 115 insertions(+), 11 deletions(-) diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index f9edd2dc..ef59fb60 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -163,7 +163,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise if (!profileExistedBeforeCreate) { try { - ctx.instanceMgr.deleteInstance(profileName); + await ctx.instanceMgr.deleteInstance(profileName); } catch { // Best-effort cleanup. } diff --git a/src/auth/commands/remove-command.ts b/src/auth/commands/remove-command.ts index cfef25cd..b7df32fa 100644 --- a/src/auth/commands/remove-command.ts +++ b/src/auth/commands/remove-command.ts @@ -68,7 +68,7 @@ export async function handleRemove(ctx: CommandContext, args: string[]): Promise } // Delete instance - ctx.instanceMgr.deleteInstance(profileName); + await ctx.instanceMgr.deleteInstance(profileName); // Delete profile from appropriate config if (isUnifiedMode() && existsUnified) { diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index 54302ada..576bc828 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -153,15 +153,22 @@ class InstanceManager { /** * Delete instance for profile */ - deleteInstance(profileName: string): void { + async deleteInstance(profileName: string): Promise { const instancePath = this.getInstancePath(profileName); if (!fs.existsSync(instancePath)) { return; } - // Recursive delete - fs.rmSync(instancePath, { recursive: true, force: true }); + await this.contextSyncLock.withLock(profileName, async () => { + await this.pluginLayoutLock.withNamedLock('__plugin-layout__', async () => { + if (!fs.existsSync(instancePath)) { + return; + } + + fs.rmSync(instancePath, { recursive: true, force: true }); + }); + }); } /** @@ -173,6 +180,10 @@ class InstanceManager { } return fs.readdirSync(this.instancesDir).filter((name) => { + if (name.startsWith('.')) { + return false; + } + const instancePath = path.join(this.instancesDir, name); return fs.statSync(instancePath).isDirectory(); }); diff --git a/src/management/profile-context-sync-lock.ts b/src/management/profile-context-sync-lock.ts index 01072c6e..92ff5f31 100644 --- a/src/management/profile-context-sync-lock.ts +++ b/src/management/profile-context-sync-lock.ts @@ -176,6 +176,79 @@ class ProfileContextSyncLock { async withLock(profileName: string, callback: () => Promise): Promise { return this.withNamedLock(profileName, callback); } + + withNamedLockSync(lockName: string, callback: () => T): T { + const lockPath = this.getLockPath(lockName); + const retryDelayMs = 50; + const staleLockMs = 30000; + const timeoutMs = staleLockMs + 5000; + const start = Date.now(); + const ownerPayload: ContextSyncLockPayload = { + version: 1, + pid: process.pid, + nonce: createHash('sha1') + .update(`${process.pid}:${Date.now()}:${Math.random()}`) + .digest('hex') + .slice(0, 16), + acquiredAtMs: Date.now(), + }; + const ownerPayloadRaw = JSON.stringify(ownerPayload); + + fs.mkdirSync(this.locksDir, { recursive: true, mode: 0o700 }); + + while (true) { + try { + const fd = fs.openSync(lockPath, 'wx', 0o600); + fs.writeFileSync(fd, ownerPayloadRaw, 'utf8'); + fs.closeSync(fd); + break; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== 'EEXIST') { + throw error; + } + + const lockSnapshot = this.readContextSyncLockSnapshot(lockPath); + if (lockSnapshot) { + if (this.tryRemoveDeadOwnerLock(lockPath, lockSnapshot)) { + continue; + } + + if (!lockSnapshot.owner) { + try { + const lockStats = fs.statSync(lockPath); + if (Date.now() - lockStats.mtimeMs > staleLockMs) { + if (this.tryRemoveLockIfUnchanged(lockPath, lockSnapshot.raw)) { + continue; + } + } + } catch { + // Best-effort stale lock cleanup. + } + } + } + + if (Date.now() - start > timeoutMs) { + throw new Error(`Timed out waiting for profile context lock: ${lockName}`); + } + + const until = Date.now() + retryDelayMs; + while (Date.now() < until) { + // Busy wait only during rare lock contention. + } + } + } + + try { + return callback(); + } finally { + this.tryRemoveLockIfUnchanged(lockPath, ownerPayloadRaw); + } + } + + withLockSync(profileName: string, callback: () => T): T { + return this.withNamedLockSync(profileName, callback); + } } export default ProfileContextSyncLock; diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index a5318dcc..b4054c22 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -9,6 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import ProfileContextSyncLock from './profile-context-sync-lock'; import { ok, info, warn } from '../utils/ui'; import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_GROUP } from '../auth/account-context'; import { getCcsDir } from '../utils/config-manager'; @@ -114,6 +115,7 @@ class SharedManager { private readonly sharedDir: string; private readonly claudeDir: string; private readonly instancesDir: string; + private readonly pluginLayoutLock: ProfileContextSyncLock; private readonly sharedItems: SharedItem[]; private readonly sharedPluginEntries: readonly SharedItem[] = [ { name: 'cache', type: 'directory' }, @@ -134,6 +136,7 @@ class SharedManager { this.sharedDir = path.join(ccsDir, 'shared'); this.claudeDir = path.join(this.homeDir, '.claude'); this.instancesDir = path.join(ccsDir, 'instances'); + this.pluginLayoutLock = new ProfileContextSyncLock(this.instancesDir); this.sharedItems = [ { name: 'commands', type: 'directory' }, { name: 'skills', type: 'directory' }, @@ -785,6 +788,12 @@ class SharedManager { this.normalizeMarketplaceRegistryPaths(configDir); } + normalizeSharedPluginMetadataPathsLocked(configDir?: string): void { + this.pluginLayoutLock.withNamedLockSync('__plugin-layout__', () => { + this.normalizeSharedPluginMetadataPaths(configDir); + }); + } + /** * Normalize plugin registry paths to use canonical ~/.claude/ paths * instead of instance-specific ~/.ccs/instances// paths. @@ -890,7 +899,7 @@ class SharedManager { if (fs.existsSync(this.instancesDir)) { for (const entry of fs.readdirSync(this.instancesDir, { withFileTypes: true })) { - if (!entry.isDirectory()) { + if (!entry.isDirectory() || entry.name.startsWith('.')) { continue; } diff --git a/src/shared/claude-extension-setup.ts b/src/shared/claude-extension-setup.ts index 784c8560..9f6a9385 100644 --- a/src/shared/claude-extension-setup.ts +++ b/src/shared/claude-extension-setup.ts @@ -168,7 +168,7 @@ async function resolveExtensionEnv( profileType: result.type, target: 'claude', }); - new SharedManager().normalizeSharedPluginMetadataPaths(continuity.claudeConfigDir); + new SharedManager().normalizeSharedPluginMetadataPathsLocked(continuity.claudeConfigDir); if (continuity.claudeConfigDir) { notes.push(`Default profile inherits continuity from account "${continuity.sourceAccount}".`); return { @@ -250,7 +250,7 @@ async function resolveExtensionEnv( ); } - new SharedManager().normalizeSharedPluginMetadataPaths(env.CLAUDE_CONFIG_DIR); + new SharedManager().normalizeSharedPluginMetadataPathsLocked(env.CLAUDE_CONFIG_DIR); if (result.type === 'copilot') { warnings.push( diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 16861f00..ff96458e 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -125,7 +125,7 @@ export function execClaude( if (profileType !== 'account') { try { - new SharedManager().normalizeSharedPluginMetadataPaths(env.CLAUDE_CONFIG_DIR); + new SharedManager().normalizeSharedPluginMetadataPathsLocked(env.CLAUDE_CONFIG_DIR); } catch { // Best-effort normalization should never block Claude launch. } diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index daa9c71a..147de4c8 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -324,7 +324,7 @@ router.delete('/reset-default', (_req: Request, res: Response): void => { /** * DELETE /api/accounts/:name - Delete an account */ -router.delete('/:name', (req: Request, res: Response): void => { +router.delete('/:name', async (req: Request, res: Response): Promise => { try { const { name } = req.params; @@ -371,7 +371,7 @@ router.delete('/:name', (req: Request, res: Response): void => { } // Match CLI remove ordering: delete instance first, metadata second. - instanceMgr.deleteInstance(name); + await instanceMgr.deleteInstance(name); if (existsUnified) { registry.removeAccountUnified(name); diff --git a/tests/unit/instance-manager-mcp-sync.test.ts b/tests/unit/instance-manager-mcp-sync.test.ts index 748eea2a..8eec45a7 100644 --- a/tests/unit/instance-manager-mcp-sync.test.ts +++ b/tests/unit/instance-manager-mcp-sync.test.ts @@ -159,6 +159,17 @@ describe('InstanceManager MCP sync', () => { expect(String(warnSpy.mock.calls[0]?.[0] || '')).toContain('MCP sync skipped'); }); + it('does not list lock housekeeping as an instance', async () => { + spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); + spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); + spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(() => false); + + const manager = new InstanceManager(); + await manager.ensureInstance('work', { mode: 'isolated' }); + + expect(manager.listInstances()).toEqual(['work']); + }); + it('skips shared symlinks and MCP sync for bare instance creation', async () => { const linkSharedSpy = spyOn( SharedManager.prototype, From fab05011f19f5059a09292d2fbb09e8b5cc62f24 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Mar 2026 08:22:03 -0400 Subject: [PATCH 06/19] test(management): cover plugin layout sync lock --- src/management/profile-context-sync-lock.ts | 4 +- tests/unit/profile-context-sync-lock.test.ts | 94 ++++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 tests/unit/profile-context-sync-lock.test.ts diff --git a/src/management/profile-context-sync-lock.ts b/src/management/profile-context-sync-lock.ts index 92ff5f31..01727405 100644 --- a/src/management/profile-context-sync-lock.ts +++ b/src/management/profile-context-sync-lock.ts @@ -234,7 +234,9 @@ class ProfileContextSyncLock { const until = Date.now() + retryDelayMs; while (Date.now() < until) { - // Busy wait only during rare lock contention. + // Sync callers need a synchronous retry path here. + // This lock only guards short local filesystem normalization work, so + // contention should be brief and limited to profile/bootstrap edges. } } } diff --git a/tests/unit/profile-context-sync-lock.test.ts b/tests/unit/profile-context-sync-lock.test.ts new file mode 100644 index 00000000..323810e7 --- /dev/null +++ b/tests/unit/profile-context-sync-lock.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { createHash } from 'crypto'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import ProfileContextSyncLock from '../../src/management/profile-context-sync-lock'; + +describe('ProfileContextSyncLock', () => { + let tempRoot = ''; + let instancesDir = ''; + + const getLockPath = (lockName: string): string => { + const safeName = lockName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); + const profileHash = createHash('sha1').update(lockName).digest('hex').slice(0, 8); + return path.join(instancesDir, '.locks', `${safeName}-${profileHash}.lock`); + }; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-context-lock-test-')); + instancesDir = path.join(tempRoot, 'instances'); + fs.mkdirSync(instancesDir, { recursive: true }); + }); + + afterEach(() => { + if (tempRoot && fs.existsSync(tempRoot)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('acquires and releases synchronous named locks', () => { + const lock = new ProfileContextSyncLock(instancesDir); + const lockPath = getLockPath('__plugin-layout__'); + + let sawLockInsideCallback = false; + const result = lock.withNamedLockSync('__plugin-layout__', () => { + sawLockInsideCallback = fs.existsSync(lockPath); + expect(fs.readFileSync(lockPath, 'utf8')).toContain(`"pid":${process.pid}`); + return 'ok'; + }); + + expect(result).toBe('ok'); + expect(sawLockInsideCallback).toBe(true); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it('releases synchronous named locks when the callback throws', () => { + const lock = new ProfileContextSyncLock(instancesDir); + const lockPath = getLockPath('__plugin-layout__'); + + expect(() => + lock.withNamedLockSync('__plugin-layout__', () => { + throw new Error('boom'); + }) + ).toThrow('boom'); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it('reclaims dead-owner locks before entering the callback', () => { + const lock = new ProfileContextSyncLock(instancesDir); + const lockPath = getLockPath('__plugin-layout__'); + + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync( + lockPath, + JSON.stringify({ + version: 1, + pid: 999999, + nonce: 'dead-owner', + acquiredAtMs: Date.now() - 1000, + }), + 'utf8' + ); + + const result = lock.withNamedLockSync('__plugin-layout__', () => 'reclaimed'); + + expect(result).toBe('reclaimed'); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it('reclaims malformed stale locks before entering the callback', () => { + const lock = new ProfileContextSyncLock(instancesDir); + const lockPath = getLockPath('__plugin-layout__'); + const staleDate = new Date(Date.now() - 60_000); + + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync(lockPath, 'not-json', 'utf8'); + fs.utimesSync(lockPath, staleDate, staleDate); + + const result = lock.withNamedLockSync('__plugin-layout__', () => 'stale-reclaimed'); + + expect(result).toBe('stale-reclaimed'); + expect(fs.existsSync(lockPath)).toBe(false); + }); +}); From 7eb75edd5627fb5375f315654135ad04ec9e6afe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 18 Mar 2026 15:27:28 +0000 Subject: [PATCH 07/19] chore(release): 7.55.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6ac7b844..c86f437d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.55.0", + "version": "7.55.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 9fac214051a2e30fd58ea7341ebd7f9de112f426 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Mar 2026 11:56:39 -0400 Subject: [PATCH 08/19] fix(codex): recover unsupported live model switches --- src/cliproxy/codex-plan-compatibility.ts | 96 +++++++++- src/cliproxy/codex-reasoning-proxy.ts | 179 +++++++++++++++--- .../cliproxy/codex-plan-compatibility.test.ts | 46 +++++ ...x-reasoning-proxy-extended-context.test.ts | 84 ++++++++ 4 files changed, 375 insertions(+), 30 deletions(-) diff --git a/src/cliproxy/codex-plan-compatibility.ts b/src/cliproxy/codex-plan-compatibility.ts index 41177e00..459d31a4 100644 --- a/src/cliproxy/codex-plan-compatibility.ts +++ b/src/cliproxy/codex-plan-compatibility.ts @@ -1,4 +1,5 @@ import { getDefaultAccount } from './account-manager'; +import { getProviderCatalog } from './model-catalog'; import { fetchCodexQuota } from './quota-fetcher-codex'; import { getCachedQuota, setCachedQuota } from './quota-response-cache'; import type { CodexQuotaResult } from './quota-types'; @@ -12,6 +13,9 @@ const FREE_SAFE_FAST_MODEL = 'gpt-5-codex-mini'; const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; const CODEX_PAREN_SUFFIX_REGEX = /\((xhigh|high|medium)\)$/i; const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i; +const KNOWN_CODEX_MODELS = new Set( + (getProviderCatalog('codex')?.models ?? []).map((model) => model.id.toLowerCase()) +); const FREE_PLAN_FALLBACKS = new Map([ ['gpt-5.3-codex', FREE_SAFE_DEFAULT_MODEL], @@ -19,7 +23,29 @@ const FREE_PLAN_FALLBACKS = new Map([ ['gpt-5.4', FREE_SAFE_DEFAULT_MODEL], ]); -function normalizeCodexModelId(model: string): string { +export interface CodexRuntimeFallbackModelMap { + defaultModel?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; +} + +export interface CodexUnsupportedModelError { + message: string | null; + code: 'model_not_supported'; + param: string | null; + type: string | null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isKnownCodexModel(model: string): boolean { + return KNOWN_CODEX_MODELS.has(model); +} + +export function normalizeCodexModelId(model: string): string { return model .trim() .replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '') @@ -37,6 +63,74 @@ export function getFreePlanFallbackCodexModel(model: string): string | null { return FREE_PLAN_FALLBACKS.get(normalizeCodexModelId(model)) ?? null; } +export function parseCodexUnsupportedModelError( + statusCode: number | undefined, + responseBody: string +): CodexUnsupportedModelError | null { + if (statusCode !== 400 || !responseBody.trim()) { + return null; + } + + try { + const parsed = JSON.parse(responseBody); + if ( + !isRecord(parsed) || + !isRecord(parsed.error) || + parsed.error.code !== 'model_not_supported' + ) { + return null; + } + + return { + message: typeof parsed.error.message === 'string' ? parsed.error.message : null, + code: 'model_not_supported', + param: typeof parsed.error.param === 'string' ? parsed.error.param : null, + type: typeof parsed.error.type === 'string' ? parsed.error.type : null, + }; + } catch { + return null; + } +} + +export function resolveRuntimeCodexFallbackModel(options: { + requestedModel: string; + modelMap: CodexRuntimeFallbackModelMap; + excludeModels?: string[]; +}): string | null { + const requestedModel = normalizeCodexModelId(options.requestedModel); + if (!requestedModel) { + return null; + } + + const excludedModels = new Set( + (options.excludeModels ?? []).map((model) => normalizeCodexModelId(model)).filter(Boolean) + ); + const candidates = [ + options.modelMap.defaultModel, + getFreePlanFallbackCodexModel(requestedModel), + options.modelMap.opusModel, + options.modelMap.sonnetModel, + options.modelMap.haikuModel, + getDefaultCodexModel(), + ]; + + for (const candidate of candidates) { + if (!candidate) continue; + const normalizedCandidate = normalizeCodexModelId(candidate); + if ( + !normalizedCandidate || + normalizedCandidate === requestedModel || + excludedModels.has(normalizedCandidate) || + !isKnownCodexModel(normalizedCandidate) + ) { + continue; + } + return normalizedCandidate; + } + + return null; +} + export async function reconcileCodexModelForActivePlan(options: { settingsPath: string; currentModel: string | undefined; diff --git a/src/cliproxy/codex-reasoning-proxy.ts b/src/cliproxy/codex-reasoning-proxy.ts index 57eb7e39..b24508e4 100644 --- a/src/cliproxy/codex-reasoning-proxy.ts +++ b/src/cliproxy/codex-reasoning-proxy.ts @@ -1,6 +1,11 @@ import * as http from 'http'; import * as https from 'https'; import { URL } from 'url'; +import { + normalizeCodexModelId, + parseCodexUnsupportedModelError, + resolveRuntimeCodexFallbackModel, +} from './codex-plan-compatibility'; import { getModelMaxLevel } from './model-catalog'; export type CodexReasoningEffort = 'medium' | 'high' | 'xhigh'; @@ -29,6 +34,14 @@ export interface CodexReasoningProxyConfig { disableEffort?: boolean; } +interface ForwardJsonContext { + requestPath: string; + requestedModel: string | null; + attemptedUpstreamModel: string | null; + effort: CodexReasoningEffort | null; + retryCount: number; +} + const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i; function stripExtendedContextSuffix(model: string): string { @@ -170,6 +183,7 @@ export class CodexReasoningProxy { > & Pick; private readonly modelEffort: Map; + private readonly sessionFallbackByModel = new Map(); private readonly recent: Array<{ at: string; model: string | null; @@ -193,6 +207,41 @@ export class CodexReasoningProxy { this.modelEffort = buildCodexModelEffortMap(this.config.modelMap, this.config.defaultEffort); } + private getRememberedFallback(model: string | null): string | null { + if (!model) return null; + return this.sessionFallbackByModel.get(normalizeCodexModelId(model)) ?? null; + } + + private rememberFallback(requestedModel: string, fallbackModel: string): void { + const normalizedRequestedModel = normalizeCodexModelId(requestedModel); + const normalizedFallbackModel = normalizeCodexModelId(fallbackModel); + if (!normalizedRequestedModel || !normalizedFallbackModel) return; + this.sessionFallbackByModel.set(normalizedRequestedModel, normalizedFallbackModel); + } + + private buildForwardBody( + body: unknown, + upstreamModel: string | null, + effort: CodexReasoningEffort | null + ): unknown { + const withUpstreamModel = + upstreamModel && isRecord(body) ? { ...body, model: upstreamModel } : body; + if (this.config.disableEffort || !effort) { + return withUpstreamModel; + } + return injectReasoningEffortIntoBody(withUpstreamModel, effort); + } + + private sendBufferedResponse( + clientRes: http.ServerResponse, + statusCode: number, + headers: http.IncomingHttpHeaders, + responseBody: string + ): void { + clientRes.writeHead(statusCode, headers); + clientRes.end(responseBody); + } + /** * Treat trailing "-high/-medium/-xhigh" as an effort alias only for known codex models. * Prevents stripping legitimate upstream model IDs that happen to end with those tokens. @@ -365,41 +414,48 @@ export class CodexReasoningProxy { ? stripExtendedContextSuffix(originalModel) : null; - // When effort is disabled (thinking mode: off), strip model suffix but don't inject reasoning - if (this.config.disableEffort) { - const suffixParsed = this.parseEffortAlias(normalizedRequestModel); - const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel; - const forwarded = - upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed; - - this.log(`[disabled] model=${originalModel ?? 'null'} -> passthrough (no reasoning)`); - await this.forwardJson(req, res, fullUpstreamUrl, forwarded); - return; - } - // Support "model aliases" like `gpt-5.2-codex-xhigh` by translating to: // - upstream model: `gpt-5.2-codex` // - reasoning.effort: `xhigh` // // This allows tier→effort mapping without inventing upstream model IDs. const suffixParsed = this.parseEffortAlias(normalizedRequestModel); - const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel; - const effort = + const requestedUpstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel; + const rememberedFallback = this.getRememberedFallback(requestedUpstreamModel); + const upstreamModel = rememberedFallback ?? requestedUpstreamModel; + const requestedEffort = suffixParsed?.effort ?? getEffortForModel(normalizedRequestModel, this.modelEffort, this.config.defaultEffort); + const effort = + !this.config.disableEffort && upstreamModel + ? capEffortAtModelMax(upstreamModel, requestedEffort) + : !this.config.disableEffort + ? requestedEffort + : null; + const rewritten = this.buildForwardBody(parsed, upstreamModel, effort); - const withUpstreamModel = - upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed; - const rewritten = injectReasoningEffortIntoBody(withUpstreamModel, effort); + if (effort) { + this.record(originalModel, upstreamModel, effort, requestPath); + this.trace( + `[${new Date().toISOString()}] model=${originalModel ?? 'null'} upstreamModel=${ + upstreamModel ?? 'null' + } effort=${effort} path=${requestPath}` + ); + } else { + this.log(`[disabled] model=${originalModel ?? 'null'} -> passthrough (no reasoning)`); + } - this.record(originalModel, upstreamModel, effort, requestPath); - this.trace( - `[${new Date().toISOString()}] model=${originalModel ?? 'null'} upstreamModel=${ - upstreamModel ?? 'null' - } effort=${effort} path=${requestPath}` - ); + if (rememberedFallback && rememberedFallback !== requestedUpstreamModel) { + this.log(`Using remembered fallback ${requestedUpstreamModel} -> ${rememberedFallback}`); + } - await this.forwardJson(req, res, fullUpstreamUrl, rewritten); + await this.forwardJson(req, res, fullUpstreamUrl, rewritten, { + requestPath, + requestedModel: requestedUpstreamModel, + attemptedUpstreamModel: upstreamModel, + effort, + retryCount: 0, + }); } catch (error) { const err = error as Error; if (!res.headersSent) { @@ -487,8 +543,9 @@ export class CodexReasoningProxy { originalReq: http.IncomingMessage, clientRes: http.ServerResponse, upstreamUrl: URL, - body: unknown - ): Promise { + body: unknown, + context: ForwardJsonContext + ): Promise { return new Promise((resolve, reject) => { const bodyString = JSON.stringify(body); const requestFn = this.getRequestFn(upstreamUrl); @@ -503,9 +560,73 @@ export class CodexReasoningProxy { headers: this.buildForwardHeaders(originalReq.headers, bodyString), }, (upstreamRes) => { - clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers); - upstreamRes.pipe(clientRes); - upstreamRes.on('end', () => resolve()); + const statusCode = upstreamRes.statusCode || 200; + if (statusCode >= 200 && statusCode < 300) { + clientRes.writeHead(statusCode, upstreamRes.headers); + upstreamRes.pipe(clientRes); + upstreamRes.on('end', () => resolve(statusCode)); + upstreamRes.on('error', reject); + return; + } + + const chunks: Buffer[] = []; + upstreamRes.on('data', (chunk: Buffer) => chunks.push(chunk)); + upstreamRes.on('end', async () => { + try { + const responseBody = Buffer.concat(chunks).toString('utf8'); + const unsupportedError = + context.retryCount === 0 + ? parseCodexUnsupportedModelError(statusCode, responseBody) + : null; + const fallbackModel = + unsupportedError && context.requestedModel + ? resolveRuntimeCodexFallbackModel({ + requestedModel: context.requestedModel, + modelMap: this.config.modelMap, + excludeModels: context.attemptedUpstreamModel + ? [context.attemptedUpstreamModel] + : undefined, + }) + : null; + + if (unsupportedError && fallbackModel && context.requestedModel) { + const retryEffort = + !this.config.disableEffort && context.effort + ? capEffortAtModelMax(fallbackModel, context.effort) + : null; + const retryBody = this.buildForwardBody(body, fallbackModel, retryEffort); + + this.log( + `Upstream rejected model "${context.attemptedUpstreamModel}". Retrying ${context.requestPath} with "${fallbackModel}".` + ); + + const retryStatusCode = await this.forwardJson( + originalReq, + clientRes, + upstreamUrl, + retryBody, + { + ...context, + attemptedUpstreamModel: fallbackModel, + effort: retryEffort, + retryCount: context.retryCount + 1, + } + ); + + if (retryStatusCode >= 200 && retryStatusCode < 300) { + this.rememberFallback(context.requestedModel, fallbackModel); + } + + resolve(retryStatusCode); + return; + } + + this.sendBufferedResponse(clientRes, statusCode, upstreamRes.headers, responseBody); + resolve(statusCode); + } catch (error) { + reject(error); + } + }); upstreamRes.on('error', reject); } ); diff --git a/tests/unit/cliproxy/codex-plan-compatibility.test.ts b/tests/unit/cliproxy/codex-plan-compatibility.test.ts index cf84fd28..23bb7bc9 100644 --- a/tests/unit/cliproxy/codex-plan-compatibility.test.ts +++ b/tests/unit/cliproxy/codex-plan-compatibility.test.ts @@ -3,6 +3,8 @@ import { getProviderCatalog, getModelMaxLevel } from '../../../src/cliproxy/mode import { getDefaultCodexModel, getFreePlanFallbackCodexModel, + parseCodexUnsupportedModelError, + resolveRuntimeCodexFallbackModel, } from '../../../src/cliproxy/codex-plan-compatibility'; describe('codex plan compatibility', () => { @@ -25,6 +27,50 @@ describe('codex plan compatibility', () => { expect(getFreePlanFallbackCodexModel('gpt-5.1-codex-mini')).toBeNull(); }); + it('detects upstream Codex model_not_supported responses', () => { + expect( + parseCodexUnsupportedModelError( + 400, + JSON.stringify({ + error: { + message: 'The requested model is not supported.', + code: 'model_not_supported', + param: 'model', + type: 'invalid_request_error', + }, + }) + ) + ).toEqual({ + message: 'The requested model is not supported.', + code: 'model_not_supported', + param: 'model', + type: 'invalid_request_error', + }); + expect( + parseCodexUnsupportedModelError(500, '{"error":{"code":"model_not_supported"}}') + ).toBeNull(); + }); + + it('resolves runtime fallbacks without retrying the rejected model again', () => { + expect( + resolveRuntimeCodexFallbackModel({ + requestedModel: 'gpt-5.4', + modelMap: { defaultModel: 'gpt-5-codex' }, + }) + ).toBe('gpt-5-codex'); + + expect( + resolveRuntimeCodexFallbackModel({ + requestedModel: 'gpt-5.4', + modelMap: { + defaultModel: 'gpt-5.4', + haikuModel: 'gpt-5-codex-mini', + }, + excludeModels: ['gpt-5-codex'], + }) + ).toBe('gpt-5-codex-mini'); + }); + it('tracks Codex thinking caps for current safe defaults and paid models', () => { expect(getModelMaxLevel('codex', 'gpt-5-codex')).toBe('high'); expect(getModelMaxLevel('codex', 'gpt-5-codex-mini')).toBe('high'); diff --git a/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts b/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts index 1e6f0cd3..5a6dc3a2 100644 --- a/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts +++ b/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts @@ -230,6 +230,90 @@ describe('CodexReasoningProxy extended-context compatibility', () => { expect(capturedBody?.model).toBe('enterprise-internal-high'); }); + it('retries unsupported live-session models once and remembers the fallback', async () => { + const capturedModels: string[] = []; + const capturedEfforts: Array = []; + + const upstream = http.createServer((req, res) => { + let rawBody = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + rawBody += chunk; + }); + req.on('end', () => { + const requestBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {}; + const reasoning = requestBody.reasoning as JsonRecord | undefined; + const model = String(requestBody.model ?? ''); + const effort = typeof reasoning?.effort === 'string' ? reasoning.effort : undefined; + + capturedModels.push(model); + capturedEfforts.push(effort); + + if (model === 'gpt-5.4') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: { + message: 'The requested model is not supported.', + code: 'model_not_supported', + param: 'model', + type: 'invalid_request_error', + }, + }) + ); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + ok: true, + model, + effort: effort ?? null, + }) + ); + }); + }); + cleanupServers.push(upstream); + + const upstreamPort = await listenOnRandomPort(upstream); + const proxy = new CodexReasoningProxy({ + upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`, + modelMap: { + defaultModel: 'gpt-5.4', + haikuModel: 'gpt-5-codex-mini', + }, + defaultEffort: 'medium', + }); + + const proxyPort = await proxy.start(); + const firstResponse = await postJson( + `http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`, + { + model: 'gpt-5.4-xhigh', + messages: [], + } + ); + const secondResponse = await postJson( + `http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`, + { + model: 'gpt-5.4-xhigh', + messages: [], + } + ); + + proxy.stop(); + + expect(firstResponse.statusCode).toBe(200); + expect(secondResponse.statusCode).toBe(200); + expect(firstResponse.body.model).toBe('gpt-5-codex'); + expect(firstResponse.body.effort).toBe('high'); + expect(secondResponse.body.model).toBe('gpt-5-codex'); + expect(secondResponse.body.effort).toBe('high'); + expect(capturedModels).toEqual(['gpt-5.4', 'gpt-5-codex', 'gpt-5-codex']); + expect(capturedEfforts).toEqual(['xhigh', 'high', 'high']); + }); + it('keeps reasoning enabled when CCS_THINKING=high overrides config off', async () => { let capturedBody: JsonRecord | null = null; From 2114a4b96e1b78e8e4f5a00bd29a866cd147348e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Mar 2026 11:56:54 -0400 Subject: [PATCH 09/19] fix(ui): sync codex model catalog defaults --- ui/src/lib/model-catalogs.ts | 100 +++++++++++++++++++++++++---------- 1 file changed, 71 insertions(+), 29 deletions(-) diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 9ecabf72..9e711d72 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -111,45 +111,56 @@ export const MODEL_CATALOGS: Record = { codex: { provider: 'codex', displayName: 'Codex', - defaultModel: 'gpt-5.3-codex', + defaultModel: 'gpt-5-codex', models: [ { - id: 'gpt-5.3-codex', - name: 'GPT-5.3 Codex', - description: 'Supports up to xhigh effort', + id: 'gpt-5-codex', + name: 'GPT-5 Codex', + description: 'Cross-plan safe Codex default', presetMapping: { - default: 'gpt-5.3-codex', - opus: 'gpt-5.3-codex', - sonnet: 'gpt-5.3-codex', - haiku: 'gpt-5.1-codex-mini', + default: 'gpt-5-codex', + opus: 'gpt-5-codex', + sonnet: 'gpt-5-codex', + haiku: 'gpt-5-codex-mini', }, }, { - id: 'gpt-5.2-codex', - name: 'GPT-5.2 Codex', - description: 'Previous stable Codex model', + id: 'gpt-5-codex-mini', + name: 'GPT-5 Codex Mini', + description: 'Faster and cheaper Codex option', presetMapping: { - default: 'gpt-5.2-codex', - opus: 'gpt-5.2-codex', - sonnet: 'gpt-5.2-codex', - haiku: 'gpt-5.1-codex-mini', + default: 'gpt-5-codex-mini', + opus: 'gpt-5-codex', + sonnet: 'gpt-5-codex', + haiku: 'gpt-5-codex-mini', }, }, { id: 'gpt-5-mini', name: 'GPT-5 Mini', - description: 'Fast, capped at high effort (no xhigh)', + description: 'Legacy mini model ID kept for backwards compatibility', presetMapping: { default: 'gpt-5-mini', - opus: 'gpt-5.3-codex', + opus: 'gpt-5-codex', sonnet: 'gpt-5-mini', haiku: 'gpt-5-mini', }, }, + { + id: 'gpt-5.1-codex-mini', + name: 'GPT-5.1 Codex Mini', + description: 'Legacy fast Codex mini model', + presetMapping: { + default: 'gpt-5.1-codex-mini', + opus: 'gpt-5.1-codex-max', + sonnet: 'gpt-5.1-codex-max', + haiku: 'gpt-5.1-codex-mini', + }, + }, { id: 'gpt-5.1-codex-max', - name: 'Codex Max (5.1)', - description: 'Legacy most capable Codex model', + name: 'GPT-5.1 Codex Max', + description: 'Higher-effort Codex model with xhigh support', presetMapping: { default: 'gpt-5.1-codex-max', opus: 'gpt-5.1-codex-max', @@ -158,20 +169,51 @@ export const MODEL_CATALOGS: Record = { }, }, { - id: 'gpt-5.2', - name: 'GPT 5.2', - description: 'Latest GPT model', + id: 'gpt-5.2-codex', + name: 'GPT-5.2 Codex', + description: 'Cross-plan Codex model with xhigh support', presetMapping: { - default: 'gpt-5.2', - opus: 'gpt-5.2', - sonnet: 'gpt-5.2', - haiku: 'gpt-5.2', + default: 'gpt-5.2-codex', + opus: 'gpt-5.2-codex', + sonnet: 'gpt-5.2-codex', + haiku: 'gpt-5-codex-mini', }, }, { - id: 'gpt-5.1-codex-mini', - name: 'Codex Mini', - description: 'Fast and efficient Codex model', + id: 'gpt-5.3-codex', + name: 'GPT-5.3 Codex', + tier: 'paid', + description: 'Paid Codex plans only', + presetMapping: { + default: 'gpt-5.3-codex', + opus: 'gpt-5.3-codex', + sonnet: 'gpt-5.3-codex', + haiku: 'gpt-5-codex-mini', + }, + }, + { + id: 'gpt-5.3-codex-spark', + name: 'GPT-5.3 Codex Spark', + tier: 'paid', + description: 'Paid Codex plans only, ultra-fast coding model', + presetMapping: { + default: 'gpt-5.3-codex-spark', + opus: 'gpt-5.3-codex', + sonnet: 'gpt-5.3-codex', + haiku: 'gpt-5-codex-mini', + }, + }, + { + id: 'gpt-5.4', + name: 'GPT-5.4', + tier: 'paid', + description: 'Paid Codex plans only, latest GPT-5 family model', + presetMapping: { + default: 'gpt-5.4', + opus: 'gpt-5.4', + sonnet: 'gpt-5.4', + haiku: 'gpt-5-codex-mini', + }, }, ], }, From ef36ad4600282aae7680316a084ec1eb2d74ab63 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Mar 2026 12:15:30 -0400 Subject: [PATCH 10/19] fix(ui): reflect cliproxy preset plan tiers --- .../provider-editor/model-config-section.tsx | 192 +++++++++++------- .../model-config-section.test.tsx | 73 +++++++ .../unit/ui/lib/model-catalogs-codex.test.ts | 6 +- 3 files changed, 198 insertions(+), 73 deletions(-) create mode 100644 ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx diff --git a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx index b9b92c0a..d22a9632 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -5,6 +5,7 @@ import { useMemo } from 'react'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import { Separator } from '@/components/ui/separator'; import { Sparkles, Zap, Star, X, Plus } from 'lucide-react'; import { FlexibleModelSelector } from '../provider-model-selector'; @@ -12,6 +13,24 @@ import { ExtendedContextToggle } from '../extended-context-toggle'; import { stripExtendedContextSuffix } from '@/lib/extended-context-utils'; import type { ModelConfigSectionProps } from './types'; +type CatalogPresetModel = NonNullable['models'][number]; + +function getPresetUpdates(model: CatalogPresetModel): Record { + const mapping = model.presetMapping || { + default: model.id, + opus: model.id, + sonnet: model.id, + haiku: model.id, + }; + + return { + ANTHROPIC_MODEL: mapping.default, + ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus, + ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet, + ANTHROPIC_DEFAULT_HAIKU_MODEL: mapping.haiku, + }; +} + export function ModelConfigSection({ catalog, savedPresets, @@ -29,8 +48,6 @@ export function ModelConfigSection({ onDeletePreset, isDeletePending, }: ModelConfigSectionProps) { - const showPresets = (catalog && catalog.models.length > 0) || savedPresets.length > 0; - // Find current model entry to check for extended context support // Strip [1m] suffix when looking up in catalog since catalog IDs don't have suffix const currentModelEntry = useMemo(() => { @@ -39,6 +56,37 @@ export function ModelConfigSection({ return catalog.models.find((m) => m.id === baseModelId); }, [catalog, currentModel]); + const presetGroups = useMemo(() => { + const presetModels = (catalog?.models ?? []).filter((model) => model.presetMapping); + if (presetModels.length === 0) return []; + + const hasPaidPresets = presetModels.some((model) => model.tier === 'paid'); + if (!hasPaidPresets) { + return [{ key: 'default', models: presetModels.slice(0, 4) }]; + } + + return [ + { + key: 'free', + label: 'Free Tier', + description: 'Available on free or paid plans', + badgeClassName: 'text-[10px] bg-green-100 text-green-700 border-green-200', + iconClassName: 'text-green-600', + models: presetModels.filter((model) => model.tier !== 'paid'), + }, + { + key: 'paid', + label: 'Paid Tier', + description: 'Requires paid access', + badgeClassName: 'text-[10px] bg-amber-100 text-amber-700 border-amber-200', + iconClassName: 'text-amber-700', + models: presetModels.filter((model) => model.tier === 'paid'), + }, + ].filter((group) => group.models.length > 0); + }, [catalog]); + + const showPresets = presetGroups.length > 0 || savedPresets.length > 0; + return ( <> {/* Quick Presets */} @@ -49,77 +97,81 @@ export function ModelConfigSection({ Presets

Apply pre-configured model mappings

-
- {/* Recommended presets from catalog */} - {catalog?.models.slice(0, 4).map((model) => ( - - ))} - - {/* User saved presets */} - {savedPresets.map((preset) => ( -
- - +
+ {presetGroups.map((group) => ( +
+ {'label' in group && group.label && ( +
+ + {group.label} + + {group.description} +
+ )} +
+ {group.models.map((model) => ( + + ))} +
))} - +
+ {/* User saved presets */} + {savedPresets.map((preset) => ( +
+ + +
+ ))} + + +
)} diff --git a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx new file mode 100644 index 00000000..9fb5d916 --- /dev/null +++ b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, userEvent } from '@tests/setup/test-utils'; + +vi.mock('@/components/cliproxy/provider-model-selector', () => ({ + FlexibleModelSelector: () =>
, +})); + +vi.mock('@/components/cliproxy/extended-context-toggle', () => ({ + ExtendedContextToggle: () =>
, +})); + +import { ModelConfigSection } from '@/components/cliproxy/provider-editor/model-config-section'; +import { MODEL_CATALOGS } from '@/lib/model-catalogs'; + +describe('ModelConfigSection presets', () => { + it('groups codex presets by free and paid tiers', async () => { + const onApplyPreset = vi.fn(); + + render( + + ); + + expect(screen.getByText('Free Tier')).toBeInTheDocument(); + expect(screen.getByText('Paid Tier')).toBeInTheDocument(); + expect(screen.getByText('Available on free or paid plans')).toBeInTheDocument(); + expect(screen.getByText('Requires paid access')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'GPT-5.4' })); + + expect(onApplyPreset).toHaveBeenCalledWith({ + ANTHROPIC_MODEL: 'gpt-5.4', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.4', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-codex-mini', + }); + }); + + it('keeps non-tiered provider presets ungrouped', () => { + render( + + ); + + expect(screen.queryByText('Free Tier')).not.toBeInTheDocument(); + expect(screen.queryByText('Paid Tier')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Claude Opus 4.6 Thinking' })).toBeInTheDocument(); + }); +}); diff --git a/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts b/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts index ed57cc8a..8e302458 100644 --- a/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts +++ b/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest'; import { MODEL_CATALOGS } from '@/lib/model-catalogs'; describe('codex model catalog defaults', () => { - it('uses gpt-5.1-codex-mini as the haiku mapping for codex presets', () => { + it('uses gpt-5-codex-mini as the haiku mapping for cross-plan codex presets', () => { const codexCatalog = MODEL_CATALOGS.codex; const codex53 = codexCatalog.models.find((model) => model.id === 'gpt-5.3-codex'); const codex52 = codexCatalog.models.find((model) => model.id === 'gpt-5.2-codex'); - expect(codex53?.presetMapping?.haiku).toBe('gpt-5.1-codex-mini'); - expect(codex52?.presetMapping?.haiku).toBe('gpt-5.1-codex-mini'); + expect(codex53?.presetMapping?.haiku).toBe('gpt-5-codex-mini'); + expect(codex52?.presetMapping?.haiku).toBe('gpt-5-codex-mini'); }); }); From 1f284132ce55f921f733e7825f9ce9f8daeea3c7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 18 Mar 2026 16:26:46 +0000 Subject: [PATCH 11/19] chore(release): 7.55.0-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c86f437d..4932b383 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.55.0-dev.1", + "version": "7.55.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 287691fa04d3aba136650c55fa13644fe31ef76c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 19 Mar 2026 10:24:28 -0400 Subject: [PATCH 12/19] feat(profiles): add cliproxy api profile bridge - add backend and CLI flows for creating routed API profiles from CLIProxy providers - add dashboard bridge CTAs, metadata, and guided create dialog mode - cover bridge routes and dialog behavior with focused tests Refs #649 --- src/api/services/cliproxy-profile-bridge.ts | 153 ++++ src/api/services/index.ts | 12 + src/api/services/profile-reader.ts | 63 +- src/api/services/profile-types.ts | 39 + src/api/services/profile-writer.ts | 65 ++ src/commands/api-command/create-command.ts | 131 +++ src/commands/api-command/help.ts | 11 + src/commands/api-command/shared.ts | 14 + src/commands/help-command.ts | 4 + src/web-server/routes/profile-routes.ts | 53 ++ src/web-server/routes/settings-routes.ts | 3 + .../unit/api/cliproxy-profile-bridge.test.ts | 63 ++ tests/unit/commands/api-command-args.test.ts | 8 + .../profile-routes-cliproxy-bridge.test.ts | 99 +++ .../cliproxy/api-profile-bridge-callout.tsx | 61 ++ .../cliproxy/control-panel-embed.tsx | 123 +-- .../profiles/cliproxy-bridge-create-panel.tsx | 114 +++ .../profiles/editor/info-section.tsx | 75 +- ui/src/components/profiles/editor/types.ts | 3 +- .../profiles/openrouter-quick-start.tsx | 51 +- .../profiles/profile-create-dialog.tsx | 801 +++++++++++------- ui/src/hooks/use-profiles.ts | 17 + ui/src/lib/api-client.ts | 29 + ui/src/pages/api.tsx | 78 +- ui/src/pages/cliproxy.tsx | 189 +++-- .../profiles/profile-create-dialog.test.tsx | 10 + 26 files changed, 1804 insertions(+), 465 deletions(-) create mode 100644 src/api/services/cliproxy-profile-bridge.ts create mode 100644 tests/unit/api/cliproxy-profile-bridge.test.ts create mode 100644 tests/unit/web-server/profile-routes-cliproxy-bridge.test.ts create mode 100644 ui/src/components/cliproxy/api-profile-bridge-callout.tsx create mode 100644 ui/src/components/profiles/cliproxy-bridge-create-panel.tsx diff --git a/src/api/services/cliproxy-profile-bridge.ts b/src/api/services/cliproxy-profile-bridge.ts new file mode 100644 index 00000000..e903d222 --- /dev/null +++ b/src/api/services/cliproxy-profile-bridge.ts @@ -0,0 +1,153 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir, loadConfigSafe } from '../../utils/config-manager'; +import { buildProxyUrl, getProxyTarget } from '../../cliproxy/proxy-target-resolver'; +import { getEffectiveApiKey } from '../../cliproxy/auth-token-manager'; +import { getModelMappingFromConfig } from '../../cliproxy/base-config-loader'; +import { + CLIPROXY_PROVIDER_IDS, + getProviderDescription, + getProviderDisplayName, + mapExternalProviderName, +} from '../../cliproxy/provider-capabilities'; +import { extractProviderFromPathname } from '../../cliproxy/model-id-normalizer'; +import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import type { TargetType } from '../../targets/target-adapter'; +import type { Settings } from '../../types/config'; +import type { CLIProxyProvider } from '../../cliproxy/types'; +import type { + CliproxyBridgeMetadata, + CliproxyBridgeProviderInfo, + ModelMapping, + ResolvedCliproxyBridgeProfile, +} from './profile-types'; + +const DEFAULT_PROFILE_SUFFIX = '-api'; + +function normalizeBridgeUrl(value: string): string { + try { + const parsed = new URL(value); + const hostname = parsed.hostname === 'localhost' ? '127.0.0.1' : parsed.hostname; + const pathname = parsed.pathname.replace(/\/+$/, '') || '/'; + return `${parsed.protocol}//${hostname}:${parsed.port}${pathname}`; + } catch { + return value.trim().replace(/\/+$/, ''); + } +} + +function resolveProviderFromBaseUrl(baseUrl: unknown): CLIProxyProvider | null { + if (typeof baseUrl !== 'string' || baseUrl.trim().length === 0) { + return null; + } + + try { + const parsed = new URL(baseUrl); + const extracted = extractProviderFromPathname(parsed.pathname); + return extracted ? mapExternalProviderName(extracted) : null; + } catch { + const extracted = extractProviderFromPathname(baseUrl); + return extracted ? mapExternalProviderName(extracted) : null; + } +} + +function hasConfiguredProfile(name: string): boolean { + if (isUnifiedMode()) { + const config = loadOrCreateUnifiedConfig(); + return name in config.profiles; + } + + const config = loadConfigSafe(); + return name in config.profiles; +} + +function hasSettingsFile(name: string): boolean { + return fs.existsSync(path.join(getCcsDir(), `${name}.settings.json`)); +} + +export function getDefaultCliproxyBridgeName(provider: CLIProxyProvider): string { + return `${provider}${DEFAULT_PROFILE_SUFFIX}`; +} + +export function suggestCliproxyBridgeName(provider: CLIProxyProvider): string { + const baseName = getDefaultCliproxyBridgeName(provider); + if (!hasConfiguredProfile(baseName) && !hasSettingsFile(baseName)) { + return baseName; + } + + for (let index = 2; index < 1000; index += 1) { + const candidate = `${baseName}-${index}`; + if (!hasConfiguredProfile(candidate) && !hasSettingsFile(candidate)) { + return candidate; + } + } + + return `${baseName}-${Date.now()}`; +} + +function resolveBridgeModelMapping(provider: CLIProxyProvider): ModelMapping { + const mapping = getModelMappingFromConfig(provider); + return { + default: mapping.defaultModel, + opus: mapping.opusModel || mapping.defaultModel, + sonnet: mapping.sonnetModel || mapping.defaultModel, + haiku: mapping.haikuModel || mapping.defaultModel, + }; +} + +export function listCliproxyBridgeProviders(): CliproxyBridgeProviderInfo[] { + return CLIPROXY_PROVIDER_IDS.map((provider) => ({ + provider, + displayName: getProviderDisplayName(provider), + description: getProviderDescription(provider), + defaultProfileName: getDefaultCliproxyBridgeName(provider), + routePath: `/api/provider/${provider}`, + })); +} + +export function resolveCliproxyBridgeProfile( + provider: CLIProxyProvider, + options: { + name?: string; + target?: TargetType; + } = {} +): ResolvedCliproxyBridgeProfile { + const target = getProxyTarget(); + const profileName = options.name?.trim() || suggestCliproxyBridgeName(provider); + const baseUrl = buildProxyUrl(target, `/api/provider/${provider}`); + const apiKey = target.authToken ?? getEffectiveApiKey(); + + return { + name: profileName, + provider, + providerDisplayName: getProviderDisplayName(provider), + baseUrl, + apiKey, + models: resolveBridgeModelMapping(provider), + target: options.target || 'claude', + routePath: `/api/provider/${provider}`, + source: target.isRemote ? 'remote' : 'local', + }; +} + +export function resolveCliproxyBridgeMetadata( + settings: Pick | null | undefined +): CliproxyBridgeMetadata | null { + const provider = resolveProviderFromBaseUrl(settings?.env?.ANTHROPIC_BASE_URL); + if (!provider) { + return null; + } + + const resolved = resolveCliproxyBridgeProfile(provider); + const actualBaseUrl = settings?.env?.ANTHROPIC_BASE_URL?.trim() || ''; + const actualAuthToken = settings?.env?.ANTHROPIC_AUTH_TOKEN?.trim() || ''; + + return { + provider, + providerDisplayName: resolved.providerDisplayName, + routePath: resolved.routePath, + currentBaseUrl: resolved.baseUrl, + source: resolved.source, + usesCurrentTarget: normalizeBridgeUrl(actualBaseUrl) === normalizeBridgeUrl(resolved.baseUrl), + usesCurrentAuthToken: actualAuthToken.length > 0 && actualAuthToken === resolved.apiKey, + }; +} diff --git a/src/api/services/index.ts b/src/api/services/index.ts index ae519d48..fb6ad360 100644 --- a/src/api/services/index.ts +++ b/src/api/services/index.ts @@ -14,8 +14,12 @@ export { type CliproxyVariantInfo, type ApiListResult, type CreateApiProfileResult, + type CreateCliproxyBridgeProfileResult, type RemoveApiProfileResult, type UpdateApiProfileTargetResult, + type CliproxyBridgeProviderInfo, + type CliproxyBridgeMetadata, + type ResolvedCliproxyBridgeProfile, type ProfileValidationIssue, type ProfileValidationSummary, type ApiProfileOrphanCandidate, @@ -38,6 +42,14 @@ export { // Profile write operations export { createApiProfile, removeApiProfile, updateApiProfileTarget } from './profile-writer'; +export { createCliproxyBridgeProfile } from './profile-writer'; +export { + getDefaultCliproxyBridgeName, + listCliproxyBridgeProviders, + resolveCliproxyBridgeMetadata, + resolveCliproxyBridgeProfile, + suggestCliproxyBridgeName, +} from './cliproxy-profile-bridge'; // Lifecycle validation and operations export { validateApiProfileSettingsPayload } from './profile-lifecycle-validation'; diff --git a/src/api/services/profile-reader.ts b/src/api/services/profile-reader.ts index ee0efc2f..4bb99b31 100644 --- a/src/api/services/profile-reader.ts +++ b/src/api/services/profile-reader.ts @@ -6,11 +6,13 @@ */ import * as fs from 'fs'; -import * as path from 'path'; -import { getCcsDir, loadConfigSafe } from '../../utils/config-manager'; +import { loadConfigSafe } from '../../utils/config-manager'; import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader'; +import { expandPath } from '../../utils/helpers'; import type { TargetType } from '../../targets/target-adapter'; +import type { Settings } from '../../types/config'; import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types'; +import { resolveCliproxyBridgeMetadata } from './cliproxy-profile-bridge'; const VALID_TARGETS: ReadonlySet = new Set(['claude', 'droid']); @@ -37,23 +39,43 @@ export function apiProfileExists(name: string): boolean { } } +/** + * Load settings file from a config reference such as ~/.ccs/name.settings.json. + */ +function loadProfileSettings(settingsReference: string | undefined): Settings | null { + if (!settingsReference || settingsReference === 'config.yaml') { + return null; + } + + try { + const settingsPath = expandPath(settingsReference); + if (!fs.existsSync(settingsPath)) return null; + return JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as Settings; + } catch { + return null; + } +} + +function isConfiguredFromSettings(settings: Settings | null): boolean { + const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || settings?.env?.ANTHROPIC_API_KEY || ''; + return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-'); +} + +function resolveProfileSettingsReference(name: string): string | undefined { + if (isUnifiedMode()) { + const config = loadOrCreateUnifiedConfig(); + return config.profiles[name]?.settings; + } + + const config = loadConfigSafe(); + return config.profiles[name]; +} + /** * Check if API profile has real API key (not placeholder) */ export function isApiProfileConfigured(apiName: string): boolean { - try { - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${apiName}.settings.json`); - - // Check settings.json file for API key - if (!fs.existsSync(settingsPath)) return false; - - const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); - const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || settings?.env?.ANTHROPIC_API_KEY || ''; - return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-'); - } catch { - return false; - } + return isConfiguredFromSettings(loadProfileSettings(resolveProfileSettingsReference(apiName))); } /** @@ -73,12 +95,15 @@ export function listApiProfiles(): ApiListResult { if (name === 'default' && profile.settings?.includes('.claude/settings.json')) { continue; } + const settingsPath = profile.settings || 'config.yaml'; + const settings = loadProfileSettings(settingsPath); profiles.push({ name, - settingsPath: profile.settings || 'config.yaml', - isConfigured: isApiProfileConfigured(name), + settingsPath, + isConfigured: isConfiguredFromSettings(settings), configSource: 'unified', target: sanitizeTarget(profile.target), + cliproxyBridge: resolveCliproxyBridgeMetadata(settings), }); } // CLIProxy variants @@ -103,12 +128,14 @@ export function listApiProfiles(): ApiListResult { if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) { continue; } + const settings = loadProfileSettings(settingsPath as string); profiles.push({ name, settingsPath: settingsPath as string, - isConfigured: isApiProfileConfigured(name), + isConfigured: isConfiguredFromSettings(settings), configSource: 'legacy', target: sanitizeTarget(legacyTargetMap?.[name]), + cliproxyBridge: resolveCliproxyBridgeMetadata(settings), }); } // CLIProxy variants diff --git a/src/api/services/profile-types.ts b/src/api/services/profile-types.ts index 54d63ebe..2f8bd40a 100644 --- a/src/api/services/profile-types.ts +++ b/src/api/services/profile-types.ts @@ -5,6 +5,7 @@ */ import type { TargetType } from '../../targets/target-adapter'; +import type { CLIProxyProvider } from '../../cliproxy/types'; /** Model mapping for API profiles */ export interface ModelMapping { @@ -21,6 +22,7 @@ export interface ApiProfileInfo { isConfigured: boolean; configSource: 'unified' | 'legacy'; target: TargetType; + cliproxyBridge?: CliproxyBridgeMetadata | null; } /** CLIProxy variant info */ @@ -44,6 +46,43 @@ export interface CreateApiProfileResult { error?: string; } +export interface CliproxyBridgeProviderInfo { + provider: CLIProxyProvider; + displayName: string; + description: string; + defaultProfileName: string; + routePath: string; +} + +export interface CliproxyBridgeMetadata { + provider: CLIProxyProvider; + providerDisplayName: string; + routePath: string; + currentBaseUrl: string; + source: 'local' | 'remote'; + usesCurrentTarget: boolean; + usesCurrentAuthToken: boolean; +} + +export interface ResolvedCliproxyBridgeProfile { + name: string; + provider: CLIProxyProvider; + providerDisplayName: string; + baseUrl: string; + apiKey: string; + models: ModelMapping; + target: TargetType; + routePath: string; + source: 'local' | 'remote'; +} + +export interface CreateCliproxyBridgeProfileResult extends CreateApiProfileResult { + name?: string; + provider?: CLIProxyProvider; + target?: TargetType; + cliproxyBridge?: CliproxyBridgeMetadata | null; +} + /** Result from remove operation */ export interface RemoveApiProfileResult { success: boolean; diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index 2404b202..ae77908c 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -6,6 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager'; import { expandPath } from '../../utils/helpers'; +import { validateApiName } from './validation-service'; import { loadOrCreateUnifiedConfig, saveUnifiedConfig, @@ -14,6 +15,7 @@ import { import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector'; import type { TargetType } from '../../targets/target-adapter'; import { resolveDroidProvider } from '../../targets/droid-provider'; +import { isReservedName } from '../../config/reserved-names'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; import { extractProviderFromPathname, @@ -23,9 +25,15 @@ import type { CLIProxyProvider } from '../../cliproxy/types'; import type { ModelMapping, CreateApiProfileResult, + CreateCliproxyBridgeProfileResult, RemoveApiProfileResult, UpdateApiProfileTargetResult, } from './profile-types'; +import { apiProfileExists } from './profile-reader'; +import { + resolveCliproxyBridgeMetadata, + resolveCliproxyBridgeProfile, +} from './cliproxy-profile-bridge'; /** Check if URL is an OpenRouter endpoint */ function isOpenRouterUrl(baseUrl: string): boolean { @@ -233,6 +241,63 @@ export function createApiProfile( } } +export function createCliproxyBridgeProfile( + provider: CLIProxyProvider, + options: { + name?: string; + force?: boolean; + target?: TargetType; + } = {} +): CreateCliproxyBridgeProfileResult { + const providedName = options.name?.trim(); + if (providedName) { + const nameError = validateApiName(providedName); + if (nameError) { + return { success: false, settingsFile: '', error: nameError }; + } + if (isReservedName(providedName)) { + return { + success: false, + settingsFile: '', + error: `Profile name '${providedName}' is reserved`, + }; + } + } + + const resolved = resolveCliproxyBridgeProfile(provider, options); + const settingsPath = path.join(getCcsDir(), `${resolved.name}.settings.json`); + if (!options.force && (apiProfileExists(resolved.name) || fs.existsSync(settingsPath))) { + return { + success: false, + settingsFile: '', + error: `Profile already exists: ${resolved.name}`, + }; + } + + const result = createApiProfile( + resolved.name, + resolved.baseUrl, + resolved.apiKey, + resolved.models, + resolved.target, + provider + ); + + return { + ...result, + name: resolved.name, + provider, + target: resolved.target, + cliproxyBridge: + resolveCliproxyBridgeMetadata({ + env: { + ANTHROPIC_BASE_URL: resolved.baseUrl, + ANTHROPIC_AUTH_TOKEN: resolved.apiKey, + }, + }) ?? null, + }; +} + /** * Update API profile target (claude/droid). * Persists to config.yaml in unified mode and config.json profile_targets in legacy mode. diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index 5941e9d1..0bcce273 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -1,6 +1,8 @@ import { apiProfileExists, + createCliproxyBridgeProfile, createApiProfile, + getDefaultCliproxyBridgeName, getPresetById, getPresetIds, getUrlWarning, @@ -8,11 +10,17 @@ import { isUsingUnifiedConfig, pickOpenRouterModel, sanitizeBaseUrl, + suggestCliproxyBridgeName, validateApiName, validateUrl, type ModelMapping, type ProviderPreset, } from '../../api/services'; +import { + CLIPROXY_PROVIDER_IDS, + getProviderDisplayName, + isCLIProxyProvider, +} from '../../cliproxy/provider-capabilities'; import { syncToLocalConfig } from '../../cliproxy/sync/local-config-sync'; import type { TargetType } from '../../targets/target-adapter'; import { color, dim, fail, header, info, infoBox, initUI, warn } from '../../utils/ui'; @@ -66,6 +74,34 @@ async function resolveProfileName( return name; } +async function resolveCliproxyProfileName( + provider: string, + providedName: string | undefined, + yes: boolean | undefined +): Promise { + if (providedName) { + const error = validateApiName(providedName); + if (error) { + console.log(fail(error)); + process.exit(1); + } + return providedName; + } + + const suggestedName = isCLIProxyProvider(provider) + ? suggestCliproxyBridgeName(provider) + : getDefaultCliproxyBridgeName('gemini'); + + if (yes) { + return suggestedName; + } + + return InteractivePrompt.input('API name', { + default: suggestedName, + validate: validateApiName, + }); +} + async function resolveBaseUrl( providedBaseUrl: string | undefined, preset: ProviderPreset | null @@ -248,6 +284,101 @@ export async function handleApiCreateCommand(args: string[]): Promise { console.log(header('Create API Profile')); console.log(''); + if (parsedArgs.cliproxyProvider) { + const cliproxyProvider = parsedArgs.cliproxyProvider.trim().toLowerCase(); + if (!isCLIProxyProvider(cliproxyProvider)) { + console.log(fail(`Unknown CLIProxy provider: ${cliproxyProvider}`)); + console.log(''); + console.log(`Available providers: ${CLIPROXY_PROVIDER_IDS.join(', ')}`); + process.exit(1); + } + + const incompatibleFlags = [ + parsedArgs.baseUrl && '--base-url', + parsedArgs.apiKey && '--api-key', + parsedArgs.model && '--model', + parsedArgs.preset && '--preset', + ].filter(Boolean); + + if (incompatibleFlags.length > 0) { + console.log( + fail(`--cliproxy-provider cannot be combined with ${incompatibleFlags.join(', ')}`) + ); + process.exit(1); + } + + const name = await resolveCliproxyProfileName( + cliproxyProvider, + parsedArgs.name, + parsedArgs.yes + ); + const target = await resolveDefaultTarget(parsedArgs.target, parsedArgs.yes); + + if (name && apiProfileExists(name) && !parsedArgs.force) { + console.log(fail(`API '${name}' already exists`)); + console.log(` Use ${color('--force', 'command')} to overwrite`); + process.exit(1); + } + + console.log( + info( + `Using CLIProxy provider: ${getProviderDisplayName(cliproxyProvider)} (${cliproxyProvider})` + ) + ); + console.log( + dim(' CCS will create a routed API profile. Provider credentials stay managed by CLIProxy.') + ); + console.log(''); + console.log(info('Creating API profile...')); + + const result = createCliproxyBridgeProfile(cliproxyProvider, { + force: parsedArgs.force === true, + name, + target, + }); + if (!result.success || !result.name || !result.cliproxyBridge) { + console.log(fail(`Failed to create CLIProxy bridge profile: ${result.error}`)); + process.exit(1); + } + + try { + syncToLocalConfig(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.log(`[i] Auto-sync to CLIProxy config skipped: ${message}`); + } + + const details = + `API: ${result.name}\n` + + `Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` + + `Settings: ${result.settingsFile}\n` + + `Provider: ${result.cliproxyBridge.providerDisplayName}\n` + + `Route: ${result.cliproxyBridge.routePath}\n` + + `Proxy: ${result.cliproxyBridge.currentBaseUrl}\n` + + `Target: ${target}`; + + console.log(''); + console.log(infoBox(details, 'CLIProxy Bridge Created')); + console.log(''); + console.log(header('Usage')); + if (target === 'droid') { + console.log( + ` ${color(`ccs ${result.name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` + ); + console.log( + ` ${color(`ccsd ${result.name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ); + } else { + console.log(` ${color(`ccs ${result.name} "your prompt"`, 'command')}`); + console.log( + ` ${color(`ccs ${result.name} --target droid "your prompt"`, 'command')} ${dim('# optional target override')}` + ); + } + console.log(''); + console.log(dim('Manage provider accounts, keys, and models in: ccs cliproxy')); + return; + } + showPresetDeprecationNotice(parsedArgs.preset); const preset = resolvePresetOrExit(parsedArgs.preset); const name = await resolveProfileName(parsedArgs.name, preset); diff --git a/src/commands/api-command/help.ts b/src/commands/api-command/help.ts index e9c5f26a..c9b3fb99 100644 --- a/src/commands/api-command/help.ts +++ b/src/commands/api-command/help.ts @@ -1,5 +1,6 @@ import { PROVIDER_PRESETS, + listCliproxyBridgeProviders, getPresetAliases, getPresetIds, type ProviderPreset, @@ -20,6 +21,7 @@ export async function showApiCommandHelp(): Promise { const presetIds = getPresetIds() .map((id) => sanitizeHelpText(id)) .filter(Boolean); + const cliproxyProviderIds = listCliproxyBridgeProviders().map((provider) => provider.provider); const presetAliases = getPresetAliases(); const presetIdWidth = Math.max(0, ...presetIds.map((id) => id.length)) + 2; @@ -47,6 +49,9 @@ export async function showApiCommandHelp(): Promise { console.log( ` ${color('--preset ', 'command')} Use provider preset (${presetIds.join(', ')})` ); + console.log( + ` ${color('--cliproxy-provider ', 'command')} Use routed CLIProxy provider (${cliproxyProviderIds.join(', ')})` + ); console.log(` ${color('--base-url ', 'command')} API base URL (create)`); console.log(` ${color('--api-key ', 'command')} API key (create)`); console.log(` ${color('--model ', 'command')} Default model (create)`); @@ -84,6 +89,12 @@ export async function showApiCommandHelp(): Promise { console.log(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`); console.log(` ${color('ccs api create --preset glm', 'command')}`); console.log(''); + console.log(` ${dim('# Create routed profile from existing CLIProxy provider config')}`); + console.log(` ${color('ccs api create --cliproxy-provider gemini', 'command')}`); + console.log( + ` ${color('ccs api create gemini-droid --cliproxy-provider gemini --target droid', 'command')}` + ); + console.log(''); console.log(` ${dim('# Create with name')}`); console.log(` ${color('ccs api create myapi', 'command')}`); console.log(` ${color('ccs api create mydroid --preset glm --target droid', 'command')}`); diff --git a/src/commands/api-command/shared.ts b/src/commands/api-command/shared.ts index d01a3107..de42e70b 100644 --- a/src/commands/api-command/shared.ts +++ b/src/commands/api-command/shared.ts @@ -9,6 +9,7 @@ export interface ApiCommandArgs { apiKey?: string; model?: string; preset?: string; + cliproxyProvider?: string; target?: TargetType; force?: boolean; yes?: boolean; @@ -21,6 +22,7 @@ export const API_VALUE_FLAGS = [ '--api-key', '--model', '--preset', + '--cliproxy-provider', '--target', ] as const; export const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_VALUE_FLAGS]; @@ -210,6 +212,18 @@ export function parseApiCommandArgs( false ); + remaining = applyRepeatedOption( + remaining, + ['--cliproxy-provider'], + (value) => { + result.cliproxyProvider = value.trim().toLowerCase(); + }, + () => { + result.errors.push('Missing value for --cliproxy-provider'); + }, + false + ); + remaining = applyRepeatedOption( remaining, ['--target'], diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 97e2af09..41d365aa 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -141,6 +141,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs ollama-cloud', 'Ollama Cloud (API key required)'], ['', ''], // Spacer ['ccs api create --preset anthropic', 'Anthropic direct API key (sk-ant-...)'], + [ + 'ccs api create --cliproxy-provider gemini', + 'Create routed API profile from CLIProxy Gemini', + ], ['ccs api create', 'Create custom API profile'], ['ccs api discover --register', 'Discover/register orphan settings files'], ['ccs api copy ', 'Duplicate API profile'], diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index 81a63055..800ca5c1 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -9,6 +9,7 @@ import { Router, Request, Response } from 'express'; import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names'; import { createApiProfile, + createCliproxyBridgeProfile, removeApiProfile, updateApiProfileTarget, discoverApiProfileOrphans, @@ -17,10 +18,12 @@ import { exportApiProfile, importApiProfileBundle, apiProfileExists, + listCliproxyBridgeProviders, listApiProfiles, validateApiName, } from '../../api/services'; import { normalizeDroidProvider } from '../../targets/droid-provider'; +import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; import { isAnthropicDirectProfile, updateSettingsFile, parseTarget } from './route-helpers'; const router = Router(); @@ -66,6 +69,7 @@ router.get('/', (_req: Request, res: Response): void => { settingsPath: p.settingsPath, configured: p.isConfigured, target: p.target, + cliproxyBridge: p.cliproxyBridge ?? null, })); res.json({ profiles }); } catch (error) { @@ -73,6 +77,54 @@ router.get('/', (_req: Request, res: Response): void => { } }); +router.get('/cliproxy-bridge/providers', (_req: Request, res: Response): void => { + try { + res.json({ providers: listCliproxyBridgeProviders() }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +router.post('/cliproxy-bridge', (req: Request, res: Response): void => { + const shape = validatePayloadShape(req.body, ['provider', 'name', 'target']); + if (!shape.ok) { + res.status(400).json({ error: shape.error }); + return; + } + + const provider = typeof shape.payload.provider === 'string' ? shape.payload.provider.trim() : ''; + if (!isCLIProxyProvider(provider)) { + res.status(400).json({ error: 'Invalid provider. Expected a supported CLIProxy provider ID.' }); + return; + } + + const target = parseTarget(shape.payload.target); + if (shape.payload.target !== undefined && target === null) { + res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + return; + } + + const result = createCliproxyBridgeProfile(provider, { + name: typeof shape.payload.name === 'string' ? shape.payload.name : undefined, + target: target || 'claude', + }); + + if (!result.success || !result.name) { + const errorMessage = result.error || 'Failed to create CLIProxy bridge profile'; + res.status(errorMessage.toLowerCase().includes('already exists') ? 409 : 400).json({ + error: errorMessage, + }); + return; + } + + res.status(201).json({ + name: result.name, + settingsPath: result.settingsFile, + target: result.target || 'claude', + cliproxyBridge: result.cliproxyBridge ?? null, + }); +}); + /** * POST /api/profiles - Create new profile */ @@ -160,6 +212,7 @@ router.post('/', (req: Request, res: Response): void => { name, settingsPath: result.settingsFile, target: parsedTarget || 'claude', + cliproxyBridge: null, }); }); diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 4f293f7b..9f2eec1d 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -19,6 +19,7 @@ import { } from '../../cliproxy'; import { regenerateConfig } from '../../cliproxy/config-generator'; import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils'; +import { resolveCliproxyBridgeMetadata } from '../../api/services'; import { getDashboardAuthConfig, loadOrCreateUnifiedConfig, @@ -327,6 +328,7 @@ router.get('/:profile', (req: Request, res: Response): void => { settings: masked, mtime: stat.mtime.getTime(), path: settingsPath, + cliproxyBridge: resolveCliproxyBridgeMetadata(settings), }); } catch (error) { respondInternalError(res, error, 'Internal server error.'); @@ -354,6 +356,7 @@ router.get('/:profile/raw', (req: Request, res: Response): void => { settings, mtime: stat.mtime.getTime(), path: settingsPath, + cliproxyBridge: resolveCliproxyBridgeMetadata(settings), }); } catch (error) { respondInternalError(res, error, 'Internal server error.'); diff --git a/tests/unit/api/cliproxy-profile-bridge.test.ts b/tests/unit/api/cliproxy-profile-bridge.test.ts new file mode 100644 index 00000000..07c3be60 --- /dev/null +++ b/tests/unit/api/cliproxy-profile-bridge.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { getEffectiveApiKey } from '../../../src/cliproxy/auth-token-manager'; +import { + resolveCliproxyBridgeMetadata, + resolveCliproxyBridgeProfile, + suggestCliproxyBridgeName, +} from '../../../src/api/services/cliproxy-profile-bridge'; + +describe('cliproxy-profile-bridge', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-bridge-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('resolves routed profile payload for a local CLIProxy provider', () => { + const bridge = resolveCliproxyBridgeProfile('gemini'); + + expect(bridge.name).toBe('gemini-api'); + expect(bridge.baseUrl).toBe('http://127.0.0.1:8317/api/provider/gemini'); + expect(bridge.routePath).toBe('/api/provider/gemini'); + expect(bridge.models.default.length).toBeGreaterThan(0); + }); + + it('suggests a unique name when the default bridge settings file already exists', () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync(path.join(ccsDir, 'gemini-api.settings.json'), '{}\n'); + + expect(suggestCliproxyBridgeName('gemini')).toBe('gemini-api-2'); + }); + + it('detects CLIProxy-backed profile metadata and normalizes localhost loopback URLs', () => { + const metadata = resolveCliproxyBridgeMetadata({ + env: { + ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/gemini', + ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(), + }, + }); + + expect(metadata?.provider).toBe('gemini'); + expect(metadata?.usesCurrentTarget).toBe(true); + expect(metadata?.usesCurrentAuthToken).toBe(true); + }); +}); diff --git a/tests/unit/commands/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts index 3ee6916b..94032699 100644 --- a/tests/unit/commands/api-command-args.test.ts +++ b/tests/unit/commands/api-command-args.test.ts @@ -55,6 +55,14 @@ describe('api-command arg parser', () => { expect(parsed.errors).toEqual([]); }); + test('parses --cliproxy-provider for routed API profile creation', () => { + const parsed = parseApiCommandArgs(['my-api', '--cliproxy-provider', 'Gemini']); + + expect(parsed.name).toBe('my-api'); + expect(parsed.cliproxyProvider).toBe('gemini'); + expect(parsed.errors).toEqual([]); + }); + test('validates invalid --target values', () => { const parsed = parseApiCommandArgs(['my-api', '--target', 'invalid-target']); diff --git a/tests/unit/web-server/profile-routes-cliproxy-bridge.test.ts b/tests/unit/web-server/profile-routes-cliproxy-bridge.test.ts new file mode 100644 index 00000000..d12a28d6 --- /dev/null +++ b/tests/unit/web-server/profile-routes-cliproxy-bridge.test.ts @@ -0,0 +1,99 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } 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 profileRoutes from '../../../src/web-server/routes/profile-routes'; + +describe('profile-routes cliproxy bridge', () => { + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/profiles', profileRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-routes-cliproxy-bridge-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('creates a routed CLIProxy-backed API profile and returns bridge metadata', async () => { + const response = await fetch(`${baseUrl}/api/profiles/cliproxy-bridge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: 'gemini' }), + }); + + expect(response.status).toBe(201); + const body = (await response.json()) as { + name: string; + settingsPath: string; + cliproxyBridge: { provider: string; usesCurrentTarget: boolean }; + }; + expect(body.name).toBe('gemini-api'); + expect(body.settingsPath).toBe('~/.ccs/gemini-api.settings.json'); + expect(body.cliproxyBridge.provider).toBe('gemini'); + expect(body.cliproxyBridge.usesCurrentTarget).toBe(true); + + const settingsPath = path.join(tempHome, '.ccs', 'gemini-api.settings.json'); + expect(fs.existsSync(settingsPath)).toBe(true); + }); + + it('auto-suggests the next routed profile name when the default bridge name is taken', async () => { + const firstResponse = await fetch(`${baseUrl}/api/profiles/cliproxy-bridge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: 'gemini' }), + }); + expect(firstResponse.status).toBe(201); + + const secondResponse = await fetch(`${baseUrl}/api/profiles/cliproxy-bridge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: 'gemini' }), + }); + + expect(secondResponse.status).toBe(201); + const body = (await secondResponse.json()) as { name: string }; + expect(body.name).toBe('gemini-api-2'); + }); +}); diff --git a/ui/src/components/cliproxy/api-profile-bridge-callout.tsx b/ui/src/components/cliproxy/api-profile-bridge-callout.tsx new file mode 100644 index 00000000..2cf7d20a --- /dev/null +++ b/ui/src/components/cliproxy/api-profile-bridge-callout.tsx @@ -0,0 +1,61 @@ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { ProviderLogo } from '@/components/cliproxy/provider-logo'; +import { cn } from '@/lib/utils'; +import type { CLIProxyProvider } from '@/lib/provider-config'; +import { ArrowRight, Link2, ShieldCheck } from 'lucide-react'; +import { Link } from 'react-router-dom'; + +interface ApiProfileBridgeCalloutProps { + provider?: CLIProxyProvider; + className?: string; + compact?: boolean; +} + +export function ApiProfileBridgeCallout({ + provider, + className, + compact = false, +}: ApiProfileBridgeCalloutProps) { + const providerQuery = provider ? `&cliproxyProvider=${provider}` : ''; + + return ( +
+
+
+ {provider ? ( + + ) : ( + + )} +
+
+
+

Use this provider in API Profiles

+ + + No manual token copy + +
+

+ Configure keys, OAuth accounts, or models here, then create a routed API Profile in CCS. + The profile will point at the provider route automatically. +

+
+ + +
+
+
+
+ ); +} diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx index a92125b0..113a6cbe 100644 --- a/ui/src/components/cliproxy/control-panel-embed.tsx +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -12,6 +12,7 @@ import { useQuery } from '@tanstack/react-query'; import { api, withApiBase } from '@/lib/api-client'; import type { CliproxyServerConfig } from '@/lib/api-client'; import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; +import { ApiProfileBridgeCallout } from './api-profile-bridge-callout'; interface AuthTokensResponse { apiKey: { value: string; isCustom: boolean }; @@ -200,6 +201,9 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel Retry
+
+ +
@@ -219,67 +223,72 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel } return ( -
- {/* Remote indicator and login hint banner */} - {showLoginHint && !isLoading && ( -
-
- {isRemote && ( - <> - - Remote - | - - )} - - - Key:{' '} - - {authToken && authToken.length > 4 - ? `***${authToken.slice(-4)}` - : authToken || 'ccs'} - - - - - - +
+
+ +
+
+ {/* Remote indicator and login hint banner */} + {showLoginHint && !isLoading && ( +
+
+ {isRemote && ( + <> + + Remote + | + + )} + + + Key:{' '} + + {authToken && authToken.length > 4 + ? `***${authToken.slice(-4)}` + : authToken || 'ccs'} + + + + + + +
-
- )} + )} - {/* Loading overlay */} - {isLoading && ( -
-
- -

- {isRemote - ? `Loading Control Panel from ${displayHost}...` - : 'Loading Control Panel...'} -

+ {/* Loading overlay */} + {isLoading && ( +
+
+ +

+ {isRemote + ? `Loading Control Panel from ${displayHost}...` + : 'Loading Control Panel...'} +

+
-
- )} + )} - {/* Iframe */} -