feat(auth): add --bare flag and MCP server sync for profiles

- Add --bare flag to `ccs auth create` to skip shared symlinks
  (commands/, skills/, agents/, settings.json). Bare profiles are
  clean instances without ClaudeKit or other global extensions.
  `ccs sync` respects bare flag and skips re-linking.

- Add MCP server sync from global ~/.claude.json to instances.
  Claude Code stores MCP config in ~/.claude.json (not settings.json),
  which was invisible to CCS profiles using CLAUDE_CONFIG_DIR isolation.
  Selective copy of mcpServers key only (not OAuth sessions/caches).
  `ccs sync` refreshes MCP servers across all non-bare instances.

Closes #691
Closes #692
This commit is contained in:
Tam Nhu Tran
2026-03-05 11:42:41 +07:00
parent 0c4d5244c8
commit bc9b04444e
9 changed files with 115 additions and 31 deletions
+6
View File
@@ -91,6 +91,9 @@ class AuthCommands {
` ${color('ccs auth create backup --context-group sprint-a --deeper-continuity', 'command')}` ` ${color('ccs auth create backup --context-group sprint-a --deeper-continuity', 'command')}`
); );
console.log(''); 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(` ${dim('# Set work as default')}`);
console.log(` ${color('ccs auth default work', 'command')}`); console.log(` ${color('ccs auth default work', 'command')}`);
console.log(''); console.log('');
@@ -116,6 +119,9 @@ class AuthCommands {
console.log( console.log(
` ${color('--deeper-continuity', 'command')} Advanced shared mode: sync additional continuity artifacts` ` ${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( console.log(
` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)` ` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)`
); );
+15 -6
View File
@@ -31,7 +31,7 @@ function sanitizeProfileNameForInstance(name: string): string {
*/ */
export async function handleCreate(ctx: CommandContext, args: string[]): Promise<void> { export async function handleCreate(ctx: CommandContext, args: string[]): Promise<void> {
await initUI(); await initUI();
const { profileName, force, shareContext, contextGroup, deeperContinuity, unknownFlags } = const { profileName, force, shareContext, contextGroup, deeperContinuity, bare, unknownFlags } =
parseArgs(args); parseArgs(args);
if (unknownFlags && unknownFlags.length > 0) { 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(fail(`Unknown option(s): ${unknownList}`));
console.log(''); console.log('');
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(`Help: ${color('ccs auth --help', 'command')}`);
console.log(''); console.log('');
@@ -50,7 +50,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
console.log(fail('Profile name is required')); console.log(fail('Profile name is required'));
console.log(''); console.log('');
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('');
console.log('Example:'); console.log('Example:');
@@ -179,7 +179,9 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
try { try {
// Create instance directory // Create instance directory
console.log(info(`Creating profile: ${profileName}`)); console.log(info(`Creating profile: ${profileName}`));
const instancePath = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy); const instancePath = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, {
bare: !!bare,
});
// Create/update profile entry based on config mode // Create/update profile entry based on config mode
if (useUnifiedConfig) { if (useUnifiedConfig) {
@@ -188,10 +190,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
ctx.registry.updateAccountUnified(profileName, { ctx.registry.updateAccountUnified(profileName, {
context_mode: contextMetadata.context_mode, context_mode: contextMetadata.context_mode,
context_group: contextMetadata.context_group, context_group: contextMetadata.context_group,
...(bare ? { bare: true } : {}),
}); });
ctx.registry.touchAccountUnified(profileName); ctx.registry.touchAccountUnified(profileName);
} else { } else {
ctx.registry.createAccountUnified(profileName, contextMetadata); ctx.registry.createAccountUnified(profileName, {
...contextMetadata,
...(bare ? { bare: true } : {}),
});
} }
} else { } else {
// Use legacy profiles.json // Use legacy profiles.json
@@ -200,12 +206,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
type: 'account', type: 'account',
context_mode: contextMetadata.context_mode, context_mode: contextMetadata.context_mode,
context_group: contextMetadata.context_group, context_group: contextMetadata.context_group,
...(bare ? { bare: true } : {}),
}); });
} else { } else {
ctx.registry.createProfile(profileName, { ctx.registry.createProfile(profileName, {
type: 'account', type: 'account',
context_mode: contextMetadata.context_mode, context_mode: contextMetadata.context_mode,
context_group: contextMetadata.context_group, context_group: contextMetadata.context_group,
...(bare ? { bare: true } : {}),
}); });
} }
} }
@@ -262,7 +270,8 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
`Profile: ${profileName}\n` + `Profile: ${profileName}\n` +
`Instance: ${instancePath}\n` + `Instance: ${instancePath}\n` +
`Type: account\n` + `Type: account\n` +
`Context: ${formatAccountContextPolicy(contextPolicy)}`, `Context: ${formatAccountContextPolicy(contextPolicy)}` +
(bare ? '\nMode: bare (no shared symlinks)' : ''),
'Profile Created' '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, continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
instance_path: instancePath, instance_path: instancePath,
session_count: sessionCount, session_count: sessionCount,
...(profile.bare ? { bare: true } : {}),
}; };
console.log(JSON.stringify(output, null, 2)); console.log(JSON.stringify(output, null, 2));
return; return;
@@ -80,6 +81,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
['Created', new Date(profile.created).toLocaleString()], ['Created', new Date(profile.created).toLocaleString()],
['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'], ['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'],
['Context', formatAccountContextPolicy(contextPolicy)], ['Context', formatAccountContextPolicy(contextPolicy)],
...(profile.bare ? [['Bare', 'yes (no shared symlinks)']] : []),
['Sessions', `${sessionCount}`], ['Sessions', `${sessionCount}`],
]; ];
+4
View File
@@ -22,6 +22,7 @@ export interface AuthCommandArgs {
shareContext?: boolean; shareContext?: boolean;
contextGroup?: string; contextGroup?: string;
deeperContinuity?: boolean; deeperContinuity?: boolean;
bare?: boolean;
unknownFlags?: string[]; unknownFlags?: string[];
} }
@@ -39,6 +40,7 @@ export interface ProfileOutput {
continuity_mode?: 'standard' | 'deeper' | null; continuity_mode?: 'standard' | 'deeper' | null;
instance_path?: string; instance_path?: string;
session_count?: number; session_count?: number;
bare?: boolean;
} }
/** /**
@@ -73,6 +75,7 @@ export function parseArgs(args: string[]): AuthCommandArgs {
'-y', '-y',
'--share-context', '--share-context',
'--deeper-continuity', '--deeper-continuity',
'--bare',
]); ]);
const knownValueFlags = new Set(['--context-group']); const knownValueFlags = new Set(['--context-group']);
@@ -125,6 +128,7 @@ export function parseArgs(args: string[]): AuthCommandArgs {
yes: args.includes('--yes') || args.includes('-y'), yes: args.includes('--yes') || args.includes('-y'),
shareContext: args.includes('--share-context'), shareContext: args.includes('--share-context'),
deeperContinuity: args.includes('--deeper-continuity'), deeperContinuity: args.includes('--deeper-continuity'),
bare: args.includes('--bare'),
contextGroup, contextGroup,
unknownFlags: [...unknownFlags], unknownFlags: [...unknownFlags],
}; };
+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 --help', 'Show account management commands'],
[ [
'ccs auth create <name>', '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'], ['ccs config', 'Dashboard: Accounts table can edit context mode/group/continuity depth'],
[ [
+26 -1
View File
@@ -4,7 +4,7 @@
* Handle sync command for CCS. * Handle sync command for CCS.
*/ */
import { initUI, header, ok } from '../utils/ui'; import { initUI, header, ok, info } from '../utils/ui';
/** /**
* Handle sync command * Handle sync command
@@ -41,6 +41,31 @@ export async function handleSyncCommand(): Promise<void> {
sharedManager.ensureSharedDirectories(); sharedManager.ensureSharedDirectories();
console.log(ok('Shared symlinks verified')); 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
}
const instancePath = instanceMgr.getInstancePath(name);
if (instancePath) {
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('');
console.log(ok('Sync complete!')); console.log(ok('Sync complete!'));
console.log(''); console.log('');
+2
View File
@@ -46,6 +46,8 @@ export interface AccountConfig {
context_group?: string; context_group?: string;
/** Shared continuity depth when context_mode='shared' */ /** Shared continuity depth when context_mode='shared' */
continuity_mode?: 'standard' | 'deeper'; continuity_mode?: 'standard' | 'deeper';
/** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */
bare?: boolean;
} }
/** /**
+57 -23
View File
@@ -11,7 +11,13 @@ import * as path from 'path';
import SharedManager from './shared-manager'; import SharedManager from './shared-manager';
import ProfileContextSyncLock from './profile-context-sync-lock'; import ProfileContextSyncLock from './profile-context-sync-lock';
import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context'; 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 */
interface InstanceOptions {
/** Skip shared symlinks (commands, skills, agents, settings.json) */
bare?: boolean;
}
/** /**
* Instance Manager Class * Instance Manager Class
@@ -32,7 +38,8 @@ class InstanceManager {
*/ */
async ensureInstance( async ensureInstance(
profileName: string, profileName: string,
contextPolicy: AccountContextPolicy = { mode: DEFAULT_ACCOUNT_CONTEXT_MODE } contextPolicy: AccountContextPolicy = { mode: DEFAULT_ACCOUNT_CONTEXT_MODE },
options: InstanceOptions = {}
): Promise<string> { ): Promise<string> {
const instancePath = this.getInstancePath(profileName); const instancePath = this.getInstancePath(profileName);
@@ -40,7 +47,7 @@ class InstanceManager {
await this.contextSyncLock.withLock(profileName, async () => { await this.contextSyncLock.withLock(profileName, async () => {
// Lazy initialization // Lazy initialization
if (!fs.existsSync(instancePath)) { if (!fs.existsSync(instancePath)) {
this.initializeInstance(profileName, instancePath); this.initializeInstance(profileName, instancePath, options);
} }
// Validate structure (auto-fix missing dirs) // Validate structure (auto-fix missing dirs)
@@ -51,6 +58,11 @@ class InstanceManager {
await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy); await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy);
}); });
// Sync MCP servers from global ~/.claude.json (unless bare)
if (!options.bare) {
this.syncMcpServers(instancePath);
}
return instancePath; return instancePath;
} }
@@ -65,7 +77,11 @@ class InstanceManager {
/** /**
* Initialize new instance directory * Initialize new instance directory
*/ */
private initializeInstance(profileName: string, instancePath: string): void { private initializeInstance(
profileName: string,
instancePath: string,
options: InstanceOptions = {}
): void {
try { try {
// Create base directory // Create base directory
fs.mkdirSync(instancePath, { recursive: true, mode: 0o700 }); fs.mkdirSync(instancePath, { recursive: true, mode: 0o700 });
@@ -88,8 +104,10 @@ class InstanceManager {
} }
}); });
// Symlink shared directories (Phase 1: commands, skills) // Bare profiles skip shared symlinks (commands, skills, agents, settings.json)
this.sharedManager.linkSharedDirectories(instancePath); if (!options.bare) {
this.sharedManager.linkSharedDirectories(instancePath);
}
// Copy global configs if exist (settings.json only) // Copy global configs if exist (settings.json only)
this.copyGlobalConfigs(instancePath); this.copyGlobalConfigs(instancePath);
@@ -167,33 +185,49 @@ class InstanceManager {
*/ */
private copyGlobalConfigs(_instancePath: string): void { private copyGlobalConfigs(_instancePath: string): void {
// No longer needed - settings.json now symlinked via SharedManager // No longer needed - settings.json now symlinked via SharedManager
// Keeping method for backward compatibility (empty implementation)
// Can be removed in future major version
} }
/** /**
* Copy directory recursively - Currently unused * 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 {
private copyDirectory(src: string, dest: string): void { const homeDir = getCcsHome();
if (!fs.existsSync(dest)) { const globalClaudeJson = path.join(homeDir, '.claude.json');
fs.mkdirSync(dest, { recursive: true, mode: 0o700 });
if (!fs.existsSync(globalClaudeJson)) {
return;
} }
const entries = fs.readdirSync(src, { withFileTypes: true }); try {
const globalContent = JSON.parse(fs.readFileSync(globalClaudeJson, 'utf8'));
const mcpServers = globalContent.mcpServers;
for (const entry of entries) { if (!mcpServers || Object.keys(mcpServers).length === 0) {
const srcPath = path.join(src, entry.name); return;
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
this.copyDirectory(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
} }
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 existingMcp = (instanceContent.mcpServers as Record<string, unknown> | undefined) || {};
instanceContent.mcpServers = { ...mcpServers, ...existingMcp };
fs.writeFileSync(instanceClaudeJson, JSON.stringify(instanceContent, null, 2), 'utf8');
} catch {
// Best-effort: don't fail instance creation if MCP sync fails
} }
} }
*/
/** /**
* Sanitize profile name for filesystem * Sanitize profile name for filesystem
+2
View File
@@ -100,6 +100,8 @@ export interface ProfileMetadata {
context_group?: string; context_group?: string;
/** Shared continuity depth when context_mode='shared' */ /** Shared continuity depth when context_mode='shared' */
continuity_mode?: 'standard' | 'deeper'; continuity_mode?: 'standard' | 'deeper';
/** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */
bare?: boolean;
} }
export interface ProfilesRegistry { export interface ProfilesRegistry {