diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index 79b76885..f9edd2dc 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -111,6 +111,9 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise const previousUnifiedProfile = existsUnified ? ctx.registry.getAllAccountsUnified()[profileName] : undefined; + const previousBare = + previousLegacyProfile?.bare === true || previousUnifiedProfile?.bare === true; + const effectiveBare = bare === true || (profileExistedBeforeCreate && previousBare); const previousContextPolicy = profileExistedBeforeCreate && (previousUnifiedProfile || previousLegacyProfile) ? resolveAccountContextPolicy(previousUnifiedProfile || previousLegacyProfile) @@ -169,7 +172,9 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise if (previousContextPolicy) { try { - await ctx.instanceMgr.ensureInstance(profileName, previousContextPolicy); + await ctx.instanceMgr.ensureInstance(profileName, previousContextPolicy, { + bare: previousBare, + }); } catch { // Best-effort rollback for context mode/group. } @@ -180,7 +185,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise // Create instance directory console.log(info(`Creating profile: ${profileName}`)); const instancePath = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, { - bare: !!bare, + bare: effectiveBare, }); // Create/update profile entry based on config mode @@ -190,13 +195,13 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise ctx.registry.updateAccountUnified(profileName, { context_mode: contextMetadata.context_mode, context_group: contextMetadata.context_group, - ...(bare ? { bare: true } : {}), + ...(effectiveBare ? { bare: true } : {}), }); ctx.registry.touchAccountUnified(profileName); } else { ctx.registry.createAccountUnified(profileName, { ...contextMetadata, - ...(bare ? { bare: true } : {}), + ...(effectiveBare ? { bare: true } : {}), }); } } else { @@ -206,14 +211,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise type: 'account', context_mode: contextMetadata.context_mode, context_group: contextMetadata.context_group, - ...(bare ? { bare: true } : {}), + ...(effectiveBare ? { bare: true } : {}), }); } else { ctx.registry.createProfile(profileName, { type: 'account', context_mode: contextMetadata.context_mode, context_group: contextMetadata.context_group, - ...(bare ? { bare: true } : {}), + ...(effectiveBare ? { bare: true } : {}), }); } } @@ -271,7 +276,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise `Instance: ${instancePath}\n` + `Type: account\n` + `Context: ${formatAccountContextPolicy(contextPolicy)}` + - (bare ? '\nMode: bare (no shared symlinks)' : ''), + (effectiveBare ? '\nMode: bare (no shared symlinks)' : ''), 'Profile Created' ) ); diff --git a/src/auth/profile-continuity-inheritance.ts b/src/auth/profile-continuity-inheritance.ts index 7503c053..7ccded76 100644 --- a/src/auth/profile-continuity-inheritance.ts +++ b/src/auth/profile-continuity-inheritance.ts @@ -146,7 +146,9 @@ export async function resolveProfileContinuityInheritance( isAccountContextMetadata(mappedProfile) ? mappedProfile : undefined ); const instanceMgr = new InstanceManager(); - const instancePath = await instanceMgr.ensureInstance(sourceAccount, contextPolicy); + const instancePath = await instanceMgr.ensureInstance(sourceAccount, contextPolicy, { + bare: mappedProfile.bare === true, + }); return { sourceAccount, diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index bc745b0a..4c9d5930 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -203,6 +203,7 @@ class ProfileDetector { context_mode: account.context_mode, context_group: account.context_group, continuity_mode: account.continuity_mode, + bare: account.bare, }, }; } diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts index 7ee4db37..fb47da90 100644 --- a/src/auth/profile-registry.ts +++ b/src/auth/profile-registry.ts @@ -45,6 +45,7 @@ interface CreateMetadata { context_mode?: 'isolated' | 'shared'; context_group?: string; continuity_mode?: 'standard' | 'deeper'; + bare?: boolean; } export class ProfileRegistry { @@ -173,6 +174,7 @@ export class ProfileRegistry { context_mode: metadata.context_mode, context_group: metadata.context_group, continuity_mode: metadata.continuity_mode, + bare: metadata.bare, }); // Note: No longer auto-set as default @@ -321,6 +323,7 @@ export class ProfileRegistry { context_mode: metadata.context_mode, context_group: metadata.context_group, continuity_mode: metadata.continuity_mode, + bare: metadata.bare, }); saveUnifiedConfig(config); } @@ -449,6 +452,7 @@ export class ProfileRegistry { context_mode: account.context_mode, context_group: account.context_group, continuity_mode: account.continuity_mode, + bare: account.bare, }; } diff --git a/src/ccs.ts b/src/ccs.ts index 25c27c11..0c8ebded 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -1132,10 +1132,16 @@ async function main(): Promise { const accountMetadata = isAccountContextMetadata(profileInfo.profile) ? profileInfo.profile : undefined; + const isBareProfile = + typeof profileInfo.profile === 'object' && + profileInfo.profile !== null && + (profileInfo.profile as { bare?: unknown }).bare === true; const contextPolicy = resolveAccountContextPolicy(accountMetadata); // Ensure instance exists (lazy init if needed) - const instancePath = await instanceMgr.ensureInstance(profileInfo.name, contextPolicy); + const instancePath = await instanceMgr.ensureInstance(profileInfo.name, contextPolicy, { + bare: isBareProfile, + }); // Update last_used timestamp (check unified config first, fallback to legacy) if (registry.hasAccountUnified(profileInfo.name)) { diff --git a/src/commands/sync-command.ts b/src/commands/sync-command.ts index 86d58b6a..004c5903 100644 --- a/src/commands/sync-command.ts +++ b/src/commands/sync-command.ts @@ -53,9 +53,13 @@ export async function handleSyncCommand(): Promise { if (profile.bare) { continue; // Skip bare profiles } + + if (!instanceMgr.hasInstance(name)) { + continue; + } + const instancePath = instanceMgr.getInstancePath(name); - if (instancePath) { - instanceMgr.syncMcpServers(instancePath); + if (instanceMgr.syncMcpServers(instancePath)) { mcpSynced++; } } diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index 3ad89c16..b6559720 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -14,7 +14,7 @@ import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/acco import { getCcsDir, getCcsHome } from '../utils/config-manager'; /** Options for instance creation */ -interface InstanceOptions { +export interface InstanceOptions { /** Skip shared symlinks (commands, skills, agents, settings.json) */ bare?: boolean; } @@ -108,9 +108,6 @@ class InstanceManager { if (!options.bare) { this.sharedManager.linkSharedDirectories(instancePath); } - - // Copy global configs if exist (settings.json only) - this.copyGlobalConfigs(instancePath); } catch (error) { throw new Error( `Failed to initialize instance for ${profileName}: ${(error as Error).message}` @@ -180,33 +177,31 @@ class InstanceManager { return fs.existsSync(instancePath); } - /** - * Copy global configs to instance (optional) - */ - private copyGlobalConfigs(_instancePath: string): void { - // No longer needed - settings.json now symlinked via SharedManager - } - /** * Sync MCP servers from global ~/.claude.json to instance .claude.json. * Selectively copies only mcpServers key (not OAuth sessions or caches). */ - syncMcpServers(instancePath: string): void { + syncMcpServers(instancePath: string): boolean { const homeDir = getCcsHome(); const globalClaudeJson = path.join(homeDir, '.claude.json'); if (!fs.existsSync(globalClaudeJson)) { - return; + return false; } try { const globalContent = JSON.parse(fs.readFileSync(globalClaudeJson, 'utf8')); - const mcpServers = globalContent.mcpServers; - - if (!mcpServers || Object.keys(mcpServers).length === 0) { - return; + const rawMcpServers = globalContent.mcpServers; + if ( + !rawMcpServers || + typeof rawMcpServers !== 'object' || + Array.isArray(rawMcpServers) || + Object.keys(rawMcpServers).length === 0 + ) { + return false; } + const mcpServers = rawMcpServers as Record; const instanceClaudeJson = path.join(instancePath, '.claude.json'); let instanceContent: Record = {}; @@ -220,12 +215,22 @@ class InstanceManager { } // Merge: global MCP servers as base, instance-specific overrides on top - const existingMcp = (instanceContent.mcpServers as Record | undefined) || {}; + const rawExistingMcp = instanceContent.mcpServers; + const existingMcp = + rawExistingMcp && typeof rawExistingMcp === 'object' && !Array.isArray(rawExistingMcp) + ? (rawExistingMcp as Record) + : {}; instanceContent.mcpServers = { ...mcpServers, ...existingMcp }; - fs.writeFileSync(instanceClaudeJson, JSON.stringify(instanceContent, null, 2), 'utf8'); - } catch { + fs.writeFileSync(instanceClaudeJson, JSON.stringify(instanceContent, null, 2), { + encoding: 'utf8', + mode: 0o600, + }); + return true; + } catch (error) { // Best-effort: don't fail instance creation if MCP sync fails + console.warn(`[!] MCP sync skipped: ${(error as Error).message}`); + return false; } } diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index d96009d4..daa9c71a 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -268,6 +268,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise const previousUnified = existsUnified ? registry.getAllAccountsUnified()[name] : undefined; const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined; + const isBare = previousUnified?.bare === true || previousLegacy?.bare === true; try { if (existsUnified) { @@ -277,7 +278,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise registry.updateProfile(name, metadata); } - await instanceMgr.ensureInstance(name, policy); + await instanceMgr.ensureInstance(name, policy, { bare: isBare }); } catch (error) { if (existsUnified && previousUnified) { registry.updateAccountUnified(name, previousUnified); diff --git a/tests/unit/auth-command-args.test.ts b/tests/unit/auth-command-args.test.ts index 8811f4d6..5b5838cb 100644 --- a/tests/unit/auth-command-args.test.ts +++ b/tests/unit/auth-command-args.test.ts @@ -47,6 +47,22 @@ describe('auth command args parsing', () => { expect(parsed.deeperContinuity).toBe(true); }); + it('parses bare flag for create command', () => { + const parsed = parseArgs(['work', '--bare']); + + expect(parsed.profileName).toBe('work'); + expect(parsed.bare).toBe(true); + }); + + it('parses bare flag with shared context flags', () => { + const parsed = parseArgs(['work', '--bare', '--share-context', '--context-group', 'sprint-a']); + + expect(parsed.profileName).toBe('work'); + expect(parsed.bare).toBe(true); + expect(parsed.shareContext).toBe(true); + expect(parsed.contextGroup).toBe('sprint-a'); + }); + it('tracks unknown flags and keeps positional profile intact', () => { const parsed = parseArgs(['--foo', 'bar', 'work']); diff --git a/tests/unit/auth/profile-continuity-inheritance.test.ts b/tests/unit/auth/profile-continuity-inheritance.test.ts index acf322ec..eb133406 100644 --- a/tests/unit/auth/profile-continuity-inheritance.test.ts +++ b/tests/unit/auth/profile-continuity-inheritance.test.ts @@ -61,7 +61,7 @@ describe('resolveProfileContinuityInheritance', () => { mode: 'shared', group: 'team-alpha', continuityMode: 'deeper', - }); + }, { bare: false }); }); it('supports legacy continuity_inherit_from_account fallback', async () => { @@ -255,7 +255,46 @@ describe('resolveProfileContinuityInheritance', () => { }); expect(ensureInstanceSpy).toHaveBeenCalledWith('pro', { mode: 'isolated', + }, { bare: false }); + }); + + it('propagates bare source-account mode when inheriting continuity', async () => { + spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({ + version: 8, + continuity: { + inherit_from_account: { + glm: 'pro', + }, + }, + } as ReturnType); + + const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue( + '/tmp/.ccs/instances/pro' + ); + spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({ + pro: { + type: 'account', + created: '2026-03-01T00:00:00.000Z', + last_used: null, + bare: true, + }, }); + + const result = await resolveProfileContinuityInheritance({ + profileName: 'glm', + profileType: 'settings', + target: 'claude', + }); + + expect(result).toEqual({ + sourceAccount: 'pro', + claudeConfigDir: '/tmp/.ccs/instances/pro', + }); + expect(ensureInstanceSpy).toHaveBeenCalledWith( + 'pro', + { mode: 'isolated' }, + { bare: true } + ); }); it('does not apply km settings alias mapping to kimi cliproxy profile', async () => { diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts index b8533a7c..c8a2f324 100644 --- a/tests/unit/auth/profile-detector.test.ts +++ b/tests/unit/auth/profile-detector.test.ts @@ -161,7 +161,7 @@ describe('ProfileDetector', () => { const mockUnifiedConfig = { version: 2, accounts: { - work: { created: '2025-01-01', last_used: '2025-01-02' }, + work: { created: '2025-01-01', last_used: '2025-01-02', bare: true }, }, }; @@ -176,6 +176,7 @@ describe('ProfileDetector', () => { expect(result.name).toBe('work'); expect(result.profile).toBeDefined(); expect((result.profile as any).type).toBe('account'); + expect((result.profile as any).bare).toBe(true); } finally { isUnifiedModeSpy.mockRestore(); loadUnifiedConfigSpy.mockRestore(); diff --git a/tests/unit/auth/profile-registry-context-normalization.test.ts b/tests/unit/auth/profile-registry-context-normalization.test.ts index 8fb895aa..8ad59688 100644 --- a/tests/unit/auth/profile-registry-context-normalization.test.ts +++ b/tests/unit/auth/profile-registry-context-normalization.test.ts @@ -91,4 +91,43 @@ describe('profile-registry context normalization', () => { expect(accounts.work.context_group).toBeUndefined(); expect(accounts.work.continuity_mode).toBe('standard'); }); + + it('persists bare flag for legacy profiles', () => { + const registry = new ProfileRegistry(); + registry.createProfile('work', { type: 'account', bare: true }); + + const profile = registry.getProfile('work'); + expect(profile.bare).toBe(true); + }); + + it('persists bare flag for unified accounts and merged projection', () => { + process.env.CCS_UNIFIED_CONFIG = '1'; + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-03-05T00:00:00.000Z"', + ' last_used: null', + ' bare: true', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const registry = new ProfileRegistry(); + + const accounts = registry.getAllAccountsUnified(); + expect(accounts.work.bare).toBe(true); + + const merged = registry.getAllProfilesMerged(); + expect(merged.work.bare).toBe(true); + }); }); diff --git a/tests/unit/commands/sync-command.test.ts b/tests/unit/commands/sync-command.test.ts new file mode 100644 index 00000000..a0d94b98 --- /dev/null +++ b/tests/unit/commands/sync-command.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import { handleSyncCommand } from '../../../src/commands/sync-command'; +import { ClaudeDirInstaller } from '../../../src/utils/claude-dir-installer'; +import { ClaudeSymlinkManager } from '../../../src/utils/claude-symlink-manager'; +import SharedManager from '../../../src/management/shared-manager'; +import { InstanceManager } from '../../../src/management/instance-manager'; +import ProfileRegistry from '../../../src/auth/profile-registry'; +import type { ProfileMetadata } from '../../../src/types'; + +function profile(metadata: Partial = {}): ProfileMetadata { + return { + type: 'account', + created: '2026-03-05T00:00:00.000Z', + last_used: null, + ...metadata, + }; +} + +describe('sync command MCP sync behavior', () => { + let originalProcessExit: typeof process.exit; + + beforeEach(() => { + originalProcessExit = process.exit; + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as typeof process.exit; + }); + + afterEach(() => { + process.exit = originalProcessExit; + mock.restore(); + }); + + it('syncs MCP servers only to non-bare profiles', async () => { + spyOn(ClaudeDirInstaller.prototype, 'install').mockReturnValue(true); + spyOn(ClaudeDirInstaller.prototype, 'cleanupDeprecated').mockReturnValue({ + success: true, + cleanedFiles: [], + }); + spyOn(ClaudeSymlinkManager.prototype, 'install').mockImplementation(() => {}); + spyOn(SharedManager.prototype, 'ensureSharedDirectories').mockImplementation(() => {}); + spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({ + work: profile(), + sandbox: profile({ bare: true }), + personal: profile(), + }); + spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true); + + const getInstancePathSpy = spyOn(InstanceManager.prototype, 'getInstancePath').mockImplementation( + (name: string) => `/tmp/${name}` + ); + const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( + () => true + ); + + await expect(handleSyncCommand()).rejects.toThrow('process.exit(0)'); + + expect(getInstancePathSpy.mock.calls.map((call) => call[0])).toEqual(['work', 'personal']); + expect(syncMcpSpy.mock.calls.map((call) => call[0])).toEqual(['/tmp/work', '/tmp/personal']); + }); + + it('skips MCP sync when all profiles are bare', async () => { + spyOn(ClaudeDirInstaller.prototype, 'install').mockReturnValue(true); + spyOn(ClaudeDirInstaller.prototype, 'cleanupDeprecated').mockReturnValue({ + success: true, + cleanedFiles: [], + }); + spyOn(ClaudeSymlinkManager.prototype, 'install').mockImplementation(() => {}); + spyOn(SharedManager.prototype, 'ensureSharedDirectories').mockImplementation(() => {}); + spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({ + sandbox: profile({ bare: true }), + experiment: profile({ bare: true }), + }); + spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true); + + const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( + () => true + ); + + await expect(handleSyncCommand()).rejects.toThrow('process.exit(0)'); + + expect(syncMcpSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/instance-manager-mcp-sync.test.ts b/tests/unit/instance-manager-mcp-sync.test.ts new file mode 100644 index 00000000..1297069d --- /dev/null +++ b/tests/unit/instance-manager-mcp-sync.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { InstanceManager } from '../../src/management/instance-manager'; +import SharedManager from '../../src/management/shared-manager'; + +describe('InstanceManager MCP sync', () => { + let tempRoot = ''; + let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-mcp-test-')); + originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; + + process.env.CCS_HOME = tempRoot; + delete process.env.CCS_DIR; + }); + + afterEach(() => { + mock.restore(); + + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir; + else delete process.env.CCS_DIR; + + if (tempRoot && fs.existsSync(tempRoot)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('merges global MCP servers and preserves instance-specific overrides', () => { + fs.writeFileSync( + path.join(tempRoot, '.claude.json'), + JSON.stringify( + { + mcpServers: { + globalOnly: { command: 'global-cmd' }, + shared: { command: 'global-shared' }, + }, + }, + null, + 2 + ), + 'utf8' + ); + + const manager = new InstanceManager(); + const instancePath = manager.getInstancePath('work'); + fs.mkdirSync(instancePath, { recursive: true }); + fs.writeFileSync( + path.join(instancePath, '.claude.json'), + JSON.stringify( + { + mcpServers: { + shared: { command: 'instance-shared' }, + instanceOnly: { command: 'instance-only' }, + }, + otherKey: 'keep-me', + }, + null, + 2 + ), + 'utf8' + ); + + const synced = manager.syncMcpServers(instancePath); + expect(synced).toBe(true); + + const instanceContent = JSON.parse(fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8')); + expect(instanceContent.otherKey).toBe('keep-me'); + expect(instanceContent.mcpServers).toEqual({ + globalOnly: { command: 'global-cmd' }, + shared: { command: 'instance-shared' }, + instanceOnly: { command: 'instance-only' }, + }); + }); + + it('logs warning when global MCP sync fails', () => { + fs.writeFileSync(path.join(tempRoot, '.claude.json'), '{invalid-json', 'utf8'); + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); + + const manager = new InstanceManager(); + const instancePath = manager.getInstancePath('work'); + fs.mkdirSync(instancePath, { recursive: true }); + + const synced = manager.syncMcpServers(instancePath); + + expect(synced).toBe(false); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(String(warnSpy.mock.calls[0]?.[0] || '')).toContain('MCP sync skipped'); + }); + + it('skips shared symlinks and MCP sync for bare instance creation', async () => { + const linkSharedSpy = spyOn(SharedManager.prototype, 'linkSharedDirectories').mockImplementation( + () => {} + ); + spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); + spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); + const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( + () => false + ); + + const manager = new InstanceManager(); + await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true }); + + expect(linkSharedSpy).not.toHaveBeenCalled(); + expect(syncMcpSpy).not.toHaveBeenCalled(); + }); +});