Merge pull request #694 from kaitranntt/kai/feat/691-692-mcp-bare-profile

feat(auth): add --bare flag and MCP server sync for profiles
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-03-05 00:47:34 -05:00
commit 48cbdbe9b9
20 files changed
+452 -47

No files matched your search

+6
View File
@@ -91,6 +91,9 @@ class AuthCommands {
` ${color('ccs auth create backup --context-group sprint-a --deeper-continuity', 'command')}`
);
console.log('');
console.log(` ${dim('# Create clean profile without shared commands/skills/agents')}`);
console.log(` ${color('ccs auth create sandbox --bare', 'command')}`);
console.log('');
console.log(` ${dim('# Set work as default')}`);
console.log(` ${color('ccs auth default work', 'command')}`);
console.log('');
@@ -116,6 +119,9 @@ class AuthCommands {
console.log(
` ${color('--deeper-continuity', 'command')} Advanced shared mode: sync additional continuity artifacts`
);
console.log(
` ${color('--bare', 'command')} Create clean profile without shared symlinks (no CK/commands/skills)`
);
console.log(
` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)`
);
+21 -7
View File
@@ -31,7 +31,7 @@ function sanitizeProfileNameForInstance(name: string): string {
*/
export async function handleCreate(ctx: CommandContext, args: string[]): Promise<void> {
await initUI();
const { profileName, force, shareContext, contextGroup, deeperContinuity, unknownFlags } =
const { profileName, force, shareContext, contextGroup, deeperContinuity, bare, unknownFlags } =
parseArgs(args);
if (unknownFlags && unknownFlags.length > 0) {
@@ -39,7 +39,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
console.log(fail(`Unknown option(s): ${unknownList}`));
console.log('');
console.log(
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>] [--deeper-continuity]', 'command')}`
`Usage: ${color('ccs auth create <profile> [--force] [--bare] [--share-context] [--context-group <name>] [--deeper-continuity]', 'command')}`
);
console.log(`Help: ${color('ccs auth --help', 'command')}`);
console.log('');
@@ -50,7 +50,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
console.log(fail('Profile name is required'));
console.log('');
console.log(
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>] [--deeper-continuity]', 'command')}`
`Usage: ${color('ccs auth create <profile> [--force] [--bare] [--share-context] [--context-group <name>] [--deeper-continuity]', 'command')}`
);
console.log('');
console.log('Example:');
@@ -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.
}
@@ -179,7 +184,9 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
try {
// Create instance directory
console.log(info(`Creating profile: ${profileName}`));
const instancePath = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy);
const instancePath = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, {
bare: effectiveBare,
});
// Create/update profile entry based on config mode
if (useUnifiedConfig) {
@@ -188,10 +195,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
ctx.registry.updateAccountUnified(profileName, {
context_mode: contextMetadata.context_mode,
context_group: contextMetadata.context_group,
...(effectiveBare ? { bare: true } : {}),
});
ctx.registry.touchAccountUnified(profileName);
} else {
ctx.registry.createAccountUnified(profileName, contextMetadata);
ctx.registry.createAccountUnified(profileName, {
...contextMetadata,
...(effectiveBare ? { bare: true } : {}),
});
}
} else {
// Use legacy profiles.json
@@ -200,12 +211,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
type: 'account',
context_mode: contextMetadata.context_mode,
context_group: contextMetadata.context_group,
...(effectiveBare ? { bare: true } : {}),
});
} else {
ctx.registry.createProfile(profileName, {
type: 'account',
context_mode: contextMetadata.context_mode,
context_group: contextMetadata.context_group,
...(effectiveBare ? { bare: true } : {}),
});
}
}
@@ -262,7 +275,8 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
`Profile: ${profileName}\n` +
`Instance: ${instancePath}\n` +
`Type: account\n` +
`Context: ${formatAccountContextPolicy(contextPolicy)}`,
`Context: ${formatAccountContextPolicy(contextPolicy)}` +
(effectiveBare ? '\nMode: bare (no shared symlinks)' : ''),
'Profile Created'
)
);
+2
View File
@@ -63,6 +63,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
instance_path: instancePath,
session_count: sessionCount,
...(profile.bare ? { bare: true } : {}),
};
console.log(JSON.stringify(output, null, 2));
return;
@@ -80,6 +81,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
['Created', new Date(profile.created).toLocaleString()],
['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'],
['Context', formatAccountContextPolicy(contextPolicy)],
...(profile.bare ? [['Bare', 'yes (no shared symlinks)']] : []),
['Sessions', `${sessionCount}`],
];
+4
View File
@@ -22,6 +22,7 @@ export interface AuthCommandArgs {
shareContext?: boolean;
contextGroup?: string;
deeperContinuity?: boolean;
bare?: boolean;
unknownFlags?: string[];
}
@@ -39,6 +40,7 @@ export interface ProfileOutput {
continuity_mode?: 'standard' | 'deeper' | null;
instance_path?: string;
session_count?: number;
bare?: boolean;
}
/**
@@ -73,6 +75,7 @@ export function parseArgs(args: string[]): AuthCommandArgs {
'-y',
'--share-context',
'--deeper-continuity',
'--bare',
]);
const knownValueFlags = new Set(['--context-group']);
@@ -125,6 +128,7 @@ export function parseArgs(args: string[]): AuthCommandArgs {
yes: args.includes('--yes') || args.includes('-y'),
shareContext: args.includes('--share-context'),
deeperContinuity: args.includes('--deeper-continuity'),
bare: args.includes('--bare'),
contextGroup,
unknownFlags: [...unknownFlags],
};
+3 -1
View File
@@ -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,
+1
View File
@@ -203,6 +203,7 @@ class ProfileDetector {
context_mode: account.context_mode,
context_group: account.context_group,
continuity_mode: account.continuity_mode,
bare: account.bare,
},
};
}
+4
View File
@@ -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,
};
}
+7 -1
View File
@@ -1132,10 +1132,16 @@ async function main(): Promise<void> {
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)) {
+1 -1
View File
@@ -156,7 +156,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs auth --help', 'Show account management commands'],
[
'ccs auth create <name>',
'Create account profile (supports shared groups + --deeper-continuity)',
'Create account profile (supports --bare, shared groups, --deeper-continuity)',
],
['ccs config', 'Dashboard: Accounts table can edit context mode/group/continuity depth'],
[
+30 -1
View File
@@ -4,7 +4,7 @@
* Handle sync command for CCS.
*/
import { initUI, header, ok } from '../utils/ui';
import { initUI, header, ok, info } from '../utils/ui';
/**
* Handle sync command
@@ -41,6 +41,35 @@ export async function handleSyncCommand(): Promise<void> {
sharedManager.ensureSharedDirectories();
console.log(ok('Shared symlinks verified'));
// Sync MCP servers from global ~/.claude.json to all non-bare instances
const { InstanceManager } = await import('../management/instance-manager');
const instanceMgr = new InstanceManager();
const ProfileRegistry = (await import('../auth/profile-registry')).default;
const registry = new ProfileRegistry();
const allProfiles = registry.getAllProfilesMerged();
let mcpSynced = 0;
for (const [name, profile] of Object.entries(allProfiles)) {
if (profile.bare) {
continue; // Skip bare profiles
}
if (!instanceMgr.hasInstance(name)) {
continue;
}
const instancePath = instanceMgr.getInstancePath(name);
if (instanceMgr.syncMcpServers(instancePath)) {
mcpSynced++;
}
}
if (mcpSynced > 0) {
console.log(ok(`MCP servers synced to ${mcpSynced} instance(s)`));
} else {
console.log(info('No instances to sync MCP servers'));
}
console.log('');
console.log(ok('Sync complete!'));
console.log('');
+2
View File
@@ -46,6 +46,8 @@ export interface AccountConfig {
context_group?: string;
/** Shared continuity depth when context_mode='shared' */
continuity_mode?: 'standard' | 'deeper';
/** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */
bare?: boolean;
}
/**
+72 -33
View File
@@ -11,7 +11,13 @@ import * as path from 'path';
import SharedManager from './shared-manager';
import ProfileContextSyncLock from './profile-context-sync-lock';
import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context';
import { getCcsDir } from '../utils/config-manager';
import { getCcsDir, getCcsHome } from '../utils/config-manager';
/** Options for instance creation */
export interface InstanceOptions {
/** Skip shared symlinks (commands, skills, agents, settings.json) */
bare?: boolean;
}
/**
* Instance Manager Class
@@ -32,7 +38,8 @@ class InstanceManager {
*/
async ensureInstance(
profileName: string,
contextPolicy: AccountContextPolicy = { mode: DEFAULT_ACCOUNT_CONTEXT_MODE }
contextPolicy: AccountContextPolicy = { mode: DEFAULT_ACCOUNT_CONTEXT_MODE },
options: InstanceOptions = {}
): Promise<string> {
const instancePath = this.getInstancePath(profileName);
@@ -40,7 +47,7 @@ class InstanceManager {
await this.contextSyncLock.withLock(profileName, async () => {
// Lazy initialization
if (!fs.existsSync(instancePath)) {
this.initializeInstance(profileName, instancePath);
this.initializeInstance(profileName, instancePath, options);
}
// Validate structure (auto-fix missing dirs)
@@ -51,6 +58,11 @@ class InstanceManager {
await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy);
});
// Sync MCP servers from global ~/.claude.json (unless bare)
if (!options.bare) {
this.syncMcpServers(instancePath);
}
return instancePath;
}
@@ -65,7 +77,11 @@ class InstanceManager {
/**
* Initialize new instance directory
*/
private initializeInstance(profileName: string, instancePath: string): void {
private initializeInstance(
profileName: string,
instancePath: string,
options: InstanceOptions = {}
): void {
try {
// Create base directory
fs.mkdirSync(instancePath, { recursive: true, mode: 0o700 });
@@ -88,11 +104,10 @@ class InstanceManager {
}
});
// Symlink shared directories (Phase 1: commands, skills)
this.sharedManager.linkSharedDirectories(instancePath);
// Copy global configs if exist (settings.json only)
this.copyGlobalConfigs(instancePath);
// Bare profiles skip shared symlinks (commands, skills, agents, settings.json)
if (!options.bare) {
this.sharedManager.linkSharedDirectories(instancePath);
}
} catch (error) {
throw new Error(
`Failed to initialize instance for ${profileName}: ${(error as Error).message}`
@@ -163,37 +178,61 @@ class InstanceManager {
}
/**
* Copy global configs to instance (optional)
* Sync MCP servers from global ~/.claude.json to instance .claude.json.
* Selectively copies only mcpServers key (not OAuth sessions or caches).
*/
private copyGlobalConfigs(_instancePath: string): void {
// No longer needed - settings.json now symlinked via SharedManager
// Keeping method for backward compatibility (empty implementation)
// Can be removed in future major version
}
syncMcpServers(instancePath: string): boolean {
const homeDir = getCcsHome();
const globalClaudeJson = path.join(homeDir, '.claude.json');
/**
* Copy directory recursively - Currently unused
*/
/*
private copyDirectory(src: string, dest: string): void {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true, mode: 0o700 });
if (!fs.existsSync(globalClaudeJson)) {
return false;
}
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
this.copyDirectory(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
try {
const globalContent = JSON.parse(fs.readFileSync(globalClaudeJson, 'utf8'));
const rawMcpServers = globalContent.mcpServers;
if (
!rawMcpServers ||
typeof rawMcpServers !== 'object' ||
Array.isArray(rawMcpServers) ||
Object.keys(rawMcpServers).length === 0
) {
return false;
}
const mcpServers = rawMcpServers as Record<string, unknown>;
const instanceClaudeJson = path.join(instancePath, '.claude.json');
let instanceContent: Record<string, unknown> = {};
if (fs.existsSync(instanceClaudeJson)) {
try {
instanceContent = JSON.parse(fs.readFileSync(instanceClaudeJson, 'utf8'));
} catch {
// Corrupted file, start fresh
instanceContent = {};
}
}
// Merge: global MCP servers as base, instance-specific overrides on top
const rawExistingMcp = instanceContent.mcpServers;
const existingMcp =
rawExistingMcp && typeof rawExistingMcp === 'object' && !Array.isArray(rawExistingMcp)
? (rawExistingMcp as Record<string, unknown>)
: {};
instanceContent.mcpServers = { ...mcpServers, ...existingMcp };
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;
}
}
*/
/**
* Sanitize profile name for filesystem
+2
View File
@@ -100,6 +100,8 @@ export interface ProfileMetadata {
context_group?: string;
/** Shared continuity depth when context_mode='shared' */
continuity_mode?: 'standard' | 'deeper';
/** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */
bare?: boolean;
}
export interface ProfilesRegistry {
+2 -1
View File
@@ -268,6 +268,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
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<void>
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);
+16
View File
@@ -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']);
@@ -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<typeof configLoader.loadOrCreateUnifiedConfig>);
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 () => {
+2 -1
View File
@@ -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();
@@ -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);
});
});
+84
View File
@@ -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> = {}): 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();
});
});
@@ -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();
});
});