diff --git a/README.md b/README.md index 95ad1e20..9c173863 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,18 @@ ccs work "implement feature" # Terminal 1 ccs "review code" # Terminal 2 (personal account) ``` +Need continuity between two accounts for the same project? Opt in to shared context: + +```bash +# Share context with default group +ccs auth create backup --share-context + +# Or isolate by named group (only accounts in this group share context) +ccs auth create backup2 --context-group sprint-a +``` + +Isolation remains the default. Shared context only links project workspace data; credentials stay per-account. +
## Maintenance diff --git a/src/auth/account-context.ts b/src/auth/account-context.ts new file mode 100644 index 00000000..93cb29be --- /dev/null +++ b/src/auth/account-context.ts @@ -0,0 +1,164 @@ +/** + * Account context policy helpers. + * + * Controls whether account instances keep project context isolated, or share + * project workspace context with other accounts in the same context group. + */ + +export type AccountContextMode = 'isolated' | 'shared'; + +export interface AccountContextMetadata { + context_mode?: AccountContextMode; + context_group?: string; +} + +export interface AccountContextPolicy { + mode: AccountContextMode; + group?: string; +} + +export interface CreateAccountContextInput { + shareContext: boolean; + contextGroup?: string; +} + +export interface ResolvedCreateAccountContext { + policy: AccountContextPolicy; + error?: string; +} + +export const DEFAULT_ACCOUNT_CONTEXT_MODE: AccountContextMode = 'isolated'; +export const DEFAULT_ACCOUNT_CONTEXT_GROUP = 'default'; + +const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; + +/** + * Normalize context group names so paths and config stay consistent. + */ +export function normalizeContextGroupName(value: string): string { + return value.trim().toLowerCase(); +} + +/** + * Validate context group naming constraints. + */ +export function isValidContextGroupName(value: string): boolean { + return CONTEXT_GROUP_PATTERN.test(value); +} + +/** + * Runtime type guard for account context metadata payloads. + */ +export function isAccountContextMetadata(value: unknown): value is AccountContextMetadata { + if (typeof value !== 'object' || value === null) { + return false; + } + + const candidate = value as Record; + const mode = candidate['context_mode']; + const group = candidate['context_group']; + + const modeValid = mode === undefined || mode === 'isolated' || mode === 'shared'; + const groupValid = group === undefined || typeof group === 'string'; + + return modeValid && groupValid; +} + +/** + * Resolve create-command flags into a valid context policy. + */ +export function resolveCreateAccountContext( + input: CreateAccountContextInput +): ResolvedCreateAccountContext { + const hasGroupFlag = input.contextGroup !== undefined; + + if (hasGroupFlag) { + if (!input.contextGroup || input.contextGroup.trim().length === 0) { + return { + policy: { mode: 'isolated' }, + error: 'Context group name is required after --context-group', + }; + } + + const normalizedGroup = normalizeContextGroupName(input.contextGroup); + if (!isValidContextGroupName(normalizedGroup)) { + return { + policy: { mode: 'isolated' }, + error: + 'Invalid context group. Use letters/numbers/dash/underscore and start with a letter.', + }; + } + + return { + policy: { + mode: 'shared', + group: normalizedGroup, + }, + }; + } + + if (input.shareContext) { + return { + policy: { + mode: 'shared', + group: DEFAULT_ACCOUNT_CONTEXT_GROUP, + }, + }; + } + + return { + policy: { mode: DEFAULT_ACCOUNT_CONTEXT_MODE }, + }; +} + +/** + * Resolve persisted metadata into runtime policy with safe defaults. + */ +export function resolveAccountContextPolicy( + metadata?: AccountContextMetadata | null +): AccountContextPolicy { + const mode: AccountContextMode = metadata?.context_mode === 'shared' ? 'shared' : 'isolated'; + + if (mode === 'shared') { + const rawGroup = metadata?.context_group; + if (rawGroup && rawGroup.trim().length > 0) { + const normalized = normalizeContextGroupName(rawGroup); + if (isValidContextGroupName(normalized)) { + return { mode: 'shared', group: normalized }; + } + } + + return { mode: 'shared', group: DEFAULT_ACCOUNT_CONTEXT_GROUP }; + } + + return { mode: 'isolated' }; +} + +/** + * Convert runtime policy back to persisted metadata. + */ +export function policyToAccountContextMetadata( + policy: AccountContextPolicy +): AccountContextMetadata { + if (policy.mode === 'shared') { + return { + context_mode: 'shared', + context_group: policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP, + }; + } + + return { + context_mode: 'isolated', + }; +} + +/** + * User-facing summary for display/help output. + */ +export function formatAccountContextPolicy(policy: AccountContextPolicy): string { + if (policy.mode === 'shared') { + return `shared (${policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP})`; + } + + return 'isolated'; +} diff --git a/src/auth/auth-commands.ts b/src/auth/auth-commands.ts index 23efc106..e94c47f8 100644 --- a/src/auth/auth-commands.ts +++ b/src/auth/auth-commands.ts @@ -79,6 +79,12 @@ class AuthCommands { console.log(` ${dim('# Create & login to work profile')}`); console.log(` ${color('ccs auth create work', 'command')}`); console.log(''); + console.log(` ${dim('# Create account with shared project context (default group)')}`); + console.log(` ${color('ccs auth create work2 --share-context', 'command')}`); + console.log(''); + console.log(` ${dim('# Share context only within a specific group')}`); + console.log(` ${color('ccs auth create backup --context-group sprint-a', 'command')}`); + console.log(''); console.log(` ${dim('# Set work as default')}`); console.log(` ${color('ccs auth default work', 'command')}`); console.log(''); @@ -95,6 +101,12 @@ class AuthCommands { console.log( ` ${color('--force', 'command')} Allow overwriting existing profile (create)` ); + console.log( + ` ${color('--share-context', 'command')} Share project workspace context across accounts` + ); + console.log( + ` ${color('--context-group ', 'command')} Share context only within a named group` + ); console.log( ` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)` ); @@ -112,6 +124,9 @@ class AuthCommands { console.log( ` Use ${color('ccs auth default ', 'command')} to change the default profile.` ); + console.log( + ` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.` + ); console.log(''); } diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index 03668b47..84c04518 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -9,6 +9,11 @@ import { initUI, header, color, fail, warn, info, infoBox, warnBox } from '../.. import { getClaudeCliInfo } from '../../utils/claude-detector'; import { escapeShellArg, stripClaudeCodeEnv } from '../../utils/shell-executor'; import { isUnifiedMode } from '../../config/unified-config-loader'; +import { + resolveCreateAccountContext, + policyToAccountContextMetadata, + formatAccountContextPolicy, +} from '../account-context'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; import { CommandContext, parseArgs } from './types'; @@ -18,12 +23,14 @@ import { CommandContext, parseArgs } from './types'; */ export async function handleCreate(ctx: CommandContext, args: string[]): Promise { await initUI(); - const { profileName, force } = parseArgs(args); + const { profileName, force, shareContext, contextGroup } = parseArgs(args); if (!profileName) { console.log(fail('Profile name is required')); console.log(''); - console.log(`Usage: ${color('ccs auth create [--force]', 'command')}`); + console.log( + `Usage: ${color('ccs auth create [--force] [--share-context] [--context-group ]', 'command')}` + ); console.log(''); console.log('Example:'); console.log(` ${color('ccs auth create work', 'command')}`); @@ -39,28 +46,50 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise exitWithError(`Profile already exists: ${profileName}`, ExitCode.PROFILE_ERROR); } + const resolvedContext = resolveCreateAccountContext({ + shareContext: !!shareContext, + contextGroup, + }); + + if (resolvedContext.error) { + console.log(fail(resolvedContext.error)); + console.log(''); + exitWithError(resolvedContext.error, ExitCode.PROFILE_ERROR); + } + + const contextPolicy = resolvedContext.policy; + const contextMetadata = policyToAccountContextMetadata(contextPolicy); + try { // Create instance directory console.log(info(`Creating profile: ${profileName}`)); - const instancePath = await ctx.instanceMgr.ensureInstance(profileName); + const instancePath = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy); // Create/update profile entry based on config mode if (isUnifiedMode()) { // Use unified config (config.yaml) if (existsUnified) { + ctx.registry.updateAccountUnified(profileName, { + context_mode: contextMetadata.context_mode, + context_group: contextMetadata.context_group, + }); ctx.registry.touchAccountUnified(profileName); } else { - ctx.registry.createAccountUnified(profileName); + ctx.registry.createAccountUnified(profileName, contextMetadata); } } else { // Use legacy profiles.json if (existsLegacy) { ctx.registry.updateProfile(profileName, { type: 'account', + context_mode: contextMetadata.context_mode, + context_group: contextMetadata.context_group, }); } else { ctx.registry.createProfile(profileName, { type: 'account', + context_mode: contextMetadata.context_mode, + context_group: contextMetadata.context_group, }); } } @@ -108,7 +137,10 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise console.log(''); console.log( infoBox( - `Profile: ${profileName}\n` + `Instance: ${instancePath}\n` + `Type: account`, + `Profile: ${profileName}\n` + + `Instance: ${instancePath}\n` + + `Type: account\n` + + `Context: ${formatAccountContextPolicy(contextPolicy)}`, 'Profile Created' ) ); diff --git a/src/auth/commands/list-command.ts b/src/auth/commands/list-command.ts index 90444c5e..a60dbff3 100644 --- a/src/auth/commands/list-command.ts +++ b/src/auth/commands/list-command.ts @@ -6,6 +6,7 @@ import { ProfileMetadata } from '../../types'; import { initUI, header, color, dim, warn, table } from '../../utils/ui'; +import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; import { CommandContext, ListOutput, parseArgs, formatRelativeTime } from './types'; @@ -41,6 +42,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise { const profile = profiles[name]; + const contextPolicy = resolveAccountContextPolicy(profile); const isDefault = name === defaultProfile; const instancePath = ctx.instanceMgr.getInstancePath(name); @@ -50,6 +52,8 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise { const profile = profiles[name]; const isDefault = name === defaultProfile; + const contextPolicy = resolveAccountContextPolicy(profile); // Status column const status = isDefault ? color('[OK] default', 'success') : color('[OK]', 'success'); @@ -112,6 +117,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise !arg.startsWith('--')); + let profileName: string | undefined; + let contextGroup: string | undefined; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--context-group') { + const next = args[i + 1]; + if (!next || next.startsWith('-')) { + contextGroup = ''; + continue; + } + + contextGroup = next; + i++; + continue; + } + + if (arg.startsWith('--context-group=')) { + contextGroup = arg.slice('--context-group='.length); + continue; + } + + if (arg.startsWith('-')) { + continue; + } + + if (!profileName) { + profileName = arg; + } + } + return { profileName, force: args.includes('--force'), verbose: args.includes('--verbose'), json: args.includes('--json'), yes: args.includes('--yes') || args.includes('-y'), + shareContext: args.includes('--share-context'), + contextGroup, }; } diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index c86668ca..a705200b 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -200,6 +200,8 @@ class ProfileDetector { type: 'account', created: account.created, last_used: account.last_used, + context_mode: account.context_mode, + context_group: account.context_group, }, }; } diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts index e076e8eb..e6d7beda 100644 --- a/src/auth/profile-registry.ts +++ b/src/auth/profile-registry.ts @@ -6,6 +6,7 @@ import { saveUnifiedConfig, isUnifiedMode, } from '../config/unified-config-loader'; +import type { AccountConfig } from '../config/unified-config-types'; import { getCcsDir } from '../utils/config-manager'; /** @@ -19,6 +20,8 @@ import { getCcsDir } from '../utils/config-manager'; * type: 'account', // Profile type * created: , // Creation time * last_used: // Last usage time + * context_mode?: 'isolated' | 'shared' // Workspace context policy + * context_group?: // Shared context group when mode=shared * } * * Removed fields from v2.x: @@ -37,6 +40,8 @@ interface CreateMetadata { type?: string; created?: string; last_used?: string | null; + context_mode?: 'isolated' | 'shared'; + context_group?: string; } export class ProfileRegistry { @@ -46,6 +51,30 @@ export class ProfileRegistry { this.profilesPath = path.join(getCcsDir(), 'profiles.json'); } + private normalizeLegacyProfileMetadata(metadata: ProfileMetadata): ProfileMetadata { + const normalized: ProfileMetadata = { ...metadata }; + + if (normalized.context_mode !== 'shared') { + delete normalized.context_group; + } else if (!normalized.context_group || normalized.context_group.trim().length === 0) { + delete normalized.context_group; + } + + return normalized; + } + + private normalizeUnifiedAccountConfig(account: AccountConfig): AccountConfig { + const normalized: AccountConfig = { ...account }; + + if (normalized.context_mode !== 'shared') { + delete normalized.context_group; + } else if (!normalized.context_group || normalized.context_group.trim().length === 0) { + delete normalized.context_group; + } + + return normalized; + } + /** * Read profiles from disk */ @@ -105,11 +134,13 @@ export class ProfileRegistry { } // v3.0 minimal schema: only essential fields - data.profiles[name] = { + data.profiles[name] = this.normalizeLegacyProfileMetadata({ type: metadata.type || 'account', created: metadata.created || new Date().toISOString(), last_used: metadata.last_used || null, - }; + context_mode: metadata.context_mode, + context_group: metadata.context_group, + }); // Note: No longer auto-set as default // Users must explicitly run: ccs auth default @@ -141,10 +172,10 @@ export class ProfileRegistry { throw new Error(`Profile not found: ${name}`); } - data.profiles[name] = { + data.profiles[name] = this.normalizeLegacyProfileMetadata({ ...data.profiles[name], ...updates, - }; + }); this._write(data); } @@ -242,15 +273,32 @@ export class ProfileRegistry { /** * Create account in unified config (config.yaml) */ - createAccountUnified(name: string): void { + createAccountUnified(name: string, metadata: CreateMetadata = {}): void { const config = loadOrCreateUnifiedConfig(); if (config.accounts[name]) { throw new Error(`Account already exists: ${name}`); } - config.accounts[name] = { + config.accounts[name] = this.normalizeUnifiedAccountConfig({ created: new Date().toISOString(), last_used: null, - }; + context_mode: metadata.context_mode, + context_group: metadata.context_group, + }); + saveUnifiedConfig(config); + } + + /** + * Update account metadata in unified config + */ + updateAccountUnified(name: string, updates: Partial): void { + const config = loadOrCreateUnifiedConfig(); + if (!config.accounts[name]) { + throw new Error(`Account not found: ${name}`); + } + config.accounts[name] = this.normalizeUnifiedAccountConfig({ + ...config.accounts[name], + ...updates, + }); saveUnifiedConfig(config); } @@ -306,7 +354,7 @@ export class ProfileRegistry { /** * Get all accounts from unified config */ - getAllAccountsUnified(): Record { + getAllAccountsUnified(): Record { if (!isUnifiedMode()) return {}; const config = loadOrCreateUnifiedConfig(); return config.accounts; @@ -330,6 +378,7 @@ export class ProfileRegistry { throw new Error(`Account not found: ${name}`); } config.accounts[name].last_used = new Date().toISOString(); + config.accounts[name] = this.normalizeUnifiedAccountConfig(config.accounts[name]); saveUnifiedConfig(config); } @@ -355,6 +404,8 @@ export class ProfileRegistry { type: 'account', created: account.created, last_used: account.last_used, + context_mode: account.context_mode, + context_group: account.context_group, }; } diff --git a/src/ccs.ts b/src/ccs.ts index dc673635..20f69d54 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -589,6 +589,8 @@ async function main(): Promise { const InstanceManager = InstanceManagerModule.default; const ProfileRegistryModule = await import('./auth/profile-registry'); const ProfileRegistry = ProfileRegistryModule.default; + const AccountContextModule = await import('./auth/account-context'); + const { resolveAccountContextPolicy, isAccountContextMetadata } = AccountContextModule; const detector = new ProfileDetector(); @@ -882,9 +884,13 @@ async function main(): Promise { // All platforms: Use instance isolation with CLAUDE_CONFIG_DIR const registry = new ProfileRegistry(); const instanceMgr = new InstanceManager(); + const accountMetadata = isAccountContextMetadata(profileInfo.profile) + ? profileInfo.profile + : undefined; + const contextPolicy = resolveAccountContextPolicy(accountMetadata); // Ensure instance exists (lazy init if needed) - const instancePath = await instanceMgr.ensureInstance(profileInfo.name); + const instancePath = await instanceMgr.ensureInstance(profileInfo.name, contextPolicy); // Update last_used timestamp (check unified config first, fallback to legacy) if (registry.hasAccountUnified(profileInfo.name)) { diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index c7b52ae7..1fdc3d63 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -150,7 +150,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['Run multiple Claude accounts concurrently'], [ ['ccs auth --help', 'Show account management commands'], - ['ccs auth create ', 'Create new account profile'], + ['ccs auth create ', 'Create account profile (supports context sharing flags)'], ['ccs auth list', 'List all account profiles'], ['ccs auth default ', 'Set default profile'], ['ccs auth reset-default', 'Restore original CCS default'], diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 1b1d1d9c..95924ea7 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -40,6 +40,10 @@ export interface AccountConfig { created: string; /** ISO timestamp of last usage, null if never used */ last_used: string | null; + /** Context mode for project workspace data */ + context_mode?: 'isolated' | 'shared'; + /** Context-sharing group when context_mode='shared' */ + context_group?: string; } /** diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index a9b273bd..ab3889de 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -9,6 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; import SharedManager from './shared-manager'; +import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context'; import { getCcsDir } from '../utils/config-manager'; /** @@ -26,7 +27,10 @@ class InstanceManager { /** * Ensure instance exists for profile (lazy init only) */ - async ensureInstance(profileName: string): Promise { + async ensureInstance( + profileName: string, + contextPolicy: AccountContextPolicy = { mode: DEFAULT_ACCOUNT_CONTEXT_MODE } + ): Promise { const instancePath = this.getInstancePath(profileName); // Lazy initialization @@ -37,8 +41,8 @@ class InstanceManager { // Validate structure (auto-fix missing dirs) this.validateInstance(instancePath); - // Keep project memory shared across instances. - await this.sharedManager.syncProjectMemories(instancePath); + // Apply context policy (isolated by default, optional shared group). + await this.sharedManager.syncProjectContext(instancePath, contextPolicy); return instancePath; } diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 7871e95d..b48aeb54 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -10,6 +10,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { ok, info, warn } from '../utils/ui'; +import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_GROUP } from '../auth/account-context'; import { getCcsDir } from '../utils/config-manager'; interface SharedItem { @@ -201,6 +202,102 @@ class SharedManager { this.normalizePluginRegistryPaths(); } + /** + * Sync project workspace context based on account policy. + * + * - isolated (default): each profile keeps its own ./projects directory. + * - shared: profile ./projects becomes symlink to shared context group root. + */ + async syncProjectContext(instancePath: string, policy: AccountContextPolicy): Promise { + const projectsPath = path.join(instancePath, 'projects'); + const instanceName = path.basename(instancePath); + const mode = policy.mode === 'shared' ? 'shared' : 'isolated'; + + if (mode === 'shared') { + const contextGroup = policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP; + const sharedProjectsPath = path.join( + this.sharedDir, + 'context-groups', + contextGroup, + 'projects' + ); + + await this.ensureDirectory(sharedProjectsPath); + await this.ensureDirectory(path.dirname(projectsPath)); + + const currentStats = await this.getLstat(projectsPath); + if (!currentStats) { + await this.linkDirectoryWithFallback(sharedProjectsPath, projectsPath); + return; + } + + if (currentStats.isSymbolicLink()) { + if (await this.isSymlinkTarget(projectsPath, sharedProjectsPath)) { + return; + } + + const currentTarget = await this.resolveSymlinkTargetPath(projectsPath); + if ( + currentTarget && + path.resolve(currentTarget) !== path.resolve(sharedProjectsPath) && + (await this.pathExists(currentTarget)) + ) { + await this.mergeDirectoryWithConflictCopies( + currentTarget, + sharedProjectsPath, + instanceName + ); + } + + await fs.promises.unlink(projectsPath); + await this.linkDirectoryWithFallback(sharedProjectsPath, projectsPath); + return; + } + + if (currentStats.isDirectory()) { + await this.detachLegacySharedMemoryLinks(projectsPath, instanceName); + await this.mergeDirectoryWithConflictCopies(projectsPath, sharedProjectsPath, instanceName); + await fs.promises.rm(projectsPath, { recursive: true, force: true }); + await this.linkDirectoryWithFallback(sharedProjectsPath, projectsPath); + return; + } + + await fs.promises.rm(projectsPath, { force: true }); + await this.linkDirectoryWithFallback(sharedProjectsPath, projectsPath); + return; + } + + const currentStats = await this.getLstat(projectsPath); + if (!currentStats) { + await this.ensureDirectory(projectsPath); + return; + } + + if (currentStats.isDirectory()) { + await this.detachLegacySharedMemoryLinks(projectsPath, instanceName); + return; + } + + if (currentStats.isSymbolicLink()) { + const currentTarget = await this.resolveSymlinkTargetPath(projectsPath); + await fs.promises.unlink(projectsPath); + await this.ensureDirectory(projectsPath); + + if ( + currentTarget && + path.resolve(currentTarget) !== path.resolve(projectsPath) && + (await this.pathExists(currentTarget)) + ) { + await this.mergeDirectoryWithConflictCopies(currentTarget, projectsPath, instanceName); + } + + return; + } + + await fs.promises.rm(projectsPath, { force: true }); + await this.ensureDirectory(projectsPath); + } + /** * Ensure all project memory directories for an instance are shared. * @@ -569,6 +666,88 @@ class SharedManager { } } + /** + * Resolve symlink target to absolute path. + */ + private async resolveSymlinkTargetPath(linkPath: string): Promise { + try { + const currentTarget = await fs.promises.readlink(linkPath); + return path.resolve(path.dirname(linkPath), currentTarget); + } catch (_err) { + return null; + } + } + + /** + * Link directory with Windows fallback to recursive copy. + */ + private async linkDirectoryWithFallback(targetPath: string, linkPath: string): Promise { + const symlinkType: 'dir' | 'junction' = process.platform === 'win32' ? 'junction' : 'dir'; + const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath; + + try { + await fs.promises.symlink(linkTarget, linkPath, symlinkType); + } catch (_err) { + if (process.platform === 'win32') { + this.copyDirectoryFallback(targetPath, linkPath); + console.log( + warn(`Symlink failed for context projects, copied instead (enable Developer Mode)`) + ); + return; + } + + throw _err; + } + } + + /** + * Migrate legacy per-project memory symlinks that point to ~/.ccs/shared/memory. + * This preserves data while restoring true profile isolation. + */ + private async detachLegacySharedMemoryLinks( + projectsPath: string, + instanceName: string + ): Promise { + const sharedMemoryRoot = path.resolve(path.join(this.sharedDir, 'memory')); + + let projectEntries: fs.Dirent[] = []; + try { + projectEntries = await fs.promises.readdir(projectsPath, { withFileTypes: true }); + } catch (_err) { + return; + } + + for (const entry of projectEntries) { + if (!entry.isDirectory()) { + continue; + } + + const projectPath = path.join(projectsPath, entry.name); + const memoryPath = path.join(projectPath, 'memory'); + const memoryStats = await this.getLstat(memoryPath); + + if (!memoryStats?.isSymbolicLink()) { + continue; + } + + const memoryTarget = await this.resolveSymlinkTargetPath(memoryPath); + if (!memoryTarget) { + continue; + } + + if (!path.resolve(memoryTarget).startsWith(sharedMemoryRoot)) { + continue; + } + + await fs.promises.unlink(memoryPath); + await this.ensureDirectory(memoryPath); + + if (await this.pathExists(memoryTarget)) { + await this.mergeDirectoryWithConflictCopies(memoryTarget, memoryPath, instanceName); + } + } + } + /** * Move directory, with cross-device fallback. */ diff --git a/src/types/config.ts b/src/types/config.ts index 952355a9..2565bd79 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -87,6 +87,10 @@ export interface ProfileMetadata { type?: string; // Profile type (e.g., 'account') created: string; // Creation time last_used?: string | null; // Last usage time + /** Context mode for project workspace data */ + context_mode?: 'isolated' | 'shared'; + /** Context-sharing group when context_mode='shared' */ + context_group?: string; } export interface ProfilesRegistry { diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index 20362ff6..8c35128a 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -53,6 +53,8 @@ router.get('/', (_req: Request, res: Response): void => { type: string; created: string; last_used: string | null; + context_mode?: 'isolated' | 'shared'; + context_group?: string; provider?: string; displayName?: string; } @@ -64,6 +66,8 @@ router.get('/', (_req: Request, res: Response): void => { type: meta.type || 'account', created: meta.created, last_used: meta.last_used || null, + context_mode: meta.context_mode, + context_group: meta.context_group, }; } @@ -73,6 +77,8 @@ router.get('/', (_req: Request, res: Response): void => { type: 'account', created: account.created, last_used: account.last_used, + context_mode: account.context_mode, + context_group: account.context_group, }; } diff --git a/tests/unit/auth-command-args.test.ts b/tests/unit/auth-command-args.test.ts new file mode 100644 index 00000000..e91afc9c --- /dev/null +++ b/tests/unit/auth-command-args.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'bun:test'; +import { parseArgs } from '../../src/auth/commands/types'; + +describe('auth command args parsing', () => { + it('parses create with explicit shared context', () => { + const parsed = parseArgs(['work', '--share-context']); + + expect(parsed.profileName).toBe('work'); + expect(parsed.shareContext).toBe(true); + expect(parsed.contextGroup).toBeUndefined(); + }); + + it('parses context group with separate value', () => { + const parsed = parseArgs(['work', '--context-group', 'sprint-a']); + + expect(parsed.profileName).toBe('work'); + expect(parsed.shareContext).toBe(false); + expect(parsed.contextGroup).toBe('sprint-a'); + }); + + it('parses context group with equals form', () => { + const parsed = parseArgs(['--context-group=sprint-a', 'work']); + + expect(parsed.profileName).toBe('work'); + expect(parsed.contextGroup).toBe('sprint-a'); + }); + + it('flags missing context group value as empty string', () => { + const parsed = parseArgs(['work', '--context-group']); + + expect(parsed.profileName).toBe('work'); + expect(parsed.contextGroup).toBe(''); + }); +}); diff --git a/tests/unit/shared-context-policy.test.ts b/tests/unit/shared-context-policy.test.ts new file mode 100644 index 00000000..5cf76b09 --- /dev/null +++ b/tests/unit/shared-context-policy.test.ts @@ -0,0 +1,121 @@ +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 SharedManager from '../../src/management/shared-manager'; +import type { AccountContextPolicy } from '../../src/auth/account-context'; + +function getTestCcsDir(): string { + if (!process.env.CCS_HOME) { + throw new Error('CCS_HOME must be set in tests'); + } + return path.join(path.resolve(process.env.CCS_HOME), '.ccs'); +} + +describe('SharedManager context policy', () => { + let tempRoot = ''; + let originalHome: string | undefined; + let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-context-policy-test-')); + originalHome = process.env.HOME; + originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; + + const isolatedHome = path.join(tempRoot, 'home'); + fs.mkdirSync(isolatedHome, { recursive: true }); + process.env.HOME = isolatedHome; + process.env.CCS_HOME = tempRoot; + delete process.env.CCS_DIR; + }); + + afterEach(() => { + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + 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 }); + } + }); + + async function applyPolicy(policy: AccountContextPolicy): Promise<{ instancePath: string; ccsDir: string }> { + const ccsDir = getTestCcsDir(); + const instancePath = path.join(ccsDir, 'instances', 'work'); + fs.mkdirSync(instancePath, { recursive: true }); + + const manager = new SharedManager(); + await manager.syncProjectContext(instancePath, policy); + + return { instancePath, ccsDir }; + } + + it('keeps projects isolated by default', async () => { + const { instancePath } = await applyPolicy({ mode: 'isolated' }); + const projectsPath = path.join(instancePath, 'projects'); + + expect(fs.existsSync(projectsPath)).toBe(true); + expect(fs.lstatSync(projectsPath).isDirectory()).toBe(true); + }); + + it('migrates local projects into shared context group', async () => { + const ccsDir = getTestCcsDir(); + const instancePath = path.join(ccsDir, 'instances', 'work'); + const localProjectsPath = path.join(instancePath, 'projects'); + const localFile = path.join(localProjectsPath, '-tmp-project', 'notes.md'); + + fs.mkdirSync(path.dirname(localFile), { recursive: true }); + fs.writeFileSync(localFile, 'local context', 'utf8'); + + const manager = new SharedManager(); + await manager.syncProjectContext(instancePath, { mode: 'shared', group: 'sprint-a' }); + + const linkStats = fs.lstatSync(localProjectsPath); + expect(linkStats.isSymbolicLink()).toBe(true); + + const sharedFile = path.join( + ccsDir, + 'shared', + 'context-groups', + 'sprint-a', + 'projects', + '-tmp-project', + 'notes.md' + ); + expect(fs.existsSync(sharedFile)).toBe(true); + expect(fs.readFileSync(sharedFile, 'utf8')).toBe('local context'); + }); + + it('switches from shared mode back to isolated without data loss', async () => { + const { instancePath, ccsDir } = await applyPolicy({ mode: 'shared', group: 'sprint-a' }); + const sharedFile = path.join( + ccsDir, + 'shared', + 'context-groups', + 'sprint-a', + 'projects', + '-tmp-project', + 'history.md' + ); + + fs.mkdirSync(path.dirname(sharedFile), { recursive: true }); + fs.writeFileSync(sharedFile, 'shared history', 'utf8'); + + const manager = new SharedManager(); + await manager.syncProjectContext(instancePath, { mode: 'isolated' }); + + const projectsPath = path.join(instancePath, 'projects'); + const projectFile = path.join(projectsPath, '-tmp-project', 'history.md'); + + expect(fs.lstatSync(projectsPath).isDirectory()).toBe(true); + expect(fs.existsSync(projectFile)).toBe(true); + expect(fs.readFileSync(projectFile, 'utf8')).toBe('shared history'); + }); +}); diff --git a/ui/src/components/account/accounts-table.tsx b/ui/src/components/account/accounts-table.tsx index d90969c9..baaddc25 100644 --- a/ui/src/components/account/accounts-table.tsx +++ b/ui/src/components/account/accounts-table.tsx @@ -87,6 +87,24 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { return {date.toLocaleDateString()}; }, }, + { + id: 'context', + header: 'Context', + size: 170, + cell: ({ row }) => { + if (row.original.type === 'cliproxy') { + return -; + } + + const mode = row.original.context_mode || 'isolated'; + if (mode === 'shared') { + const group = row.original.context_group || 'default'; + return shared ({group}); + } + + return isolated; + }, + }, { id: 'actions', header: 'Actions', @@ -154,6 +172,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { type: 'w-[100px]', created: 'w-[150px]', last_used: 'w-[150px]', + context: 'w-[170px]', actions: 'w-[180px]', }[header.id] || 'w-auto'; diff --git a/ui/src/components/account/create-auth-profile-dialog.tsx b/ui/src/components/account/create-auth-profile-dialog.tsx index 2da65f82..a82dc72f 100644 --- a/ui/src/components/account/create-auth-profile-dialog.tsx +++ b/ui/src/components/account/create-auth-profile-dialog.tsx @@ -14,6 +14,7 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; import { Copy, Check, Terminal } from 'lucide-react'; interface CreateAuthProfileDialogProps { @@ -23,14 +24,32 @@ interface CreateAuthProfileDialogProps { export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDialogProps) { const [profileName, setProfileName] = useState(''); + const [shareContext, setShareContext] = useState(false); + const [contextGroup, setContextGroup] = useState(''); const [copied, setCopied] = useState(false); // Validate profile name: alphanumeric, dash, underscore only const isValidName = /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(profileName); - const command = profileName ? `ccs auth create ${profileName}` : 'ccs auth create '; + const normalizedGroup = contextGroup.trim().toLowerCase(); + const isValidContextGroup = + normalizedGroup.length === 0 || /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(normalizedGroup); + + const command = + profileName && isValidName + ? [ + `ccs auth create ${profileName}`, + shareContext + ? normalizedGroup.length > 0 + ? `--context-group ${normalizedGroup}` + : '--share-context' + : '', + ] + .filter(Boolean) + .join(' ') + : 'ccs auth create '; const handleCopy = async () => { - if (!isValidName) return; + if (!isValidName || (shareContext && !isValidContextGroup)) return; await navigator.clipboard.writeText(command); setCopied(true); setTimeout(() => setCopied(false), 2000); @@ -38,6 +57,8 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial const handleClose = () => { setProfileName(''); + setShareContext(false); + setContextGroup(''); setCopied(false); onClose(); }; @@ -70,6 +91,41 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial )} +
+
+ setShareContext(checked === true)} + /> + +
+ + {shareContext && ( +
+ + setContextGroup(e.target.value)} + placeholder="default, sprint-a, client-x" + autoComplete="off" + /> +

+ Leave empty to use the default shared group. +

+ {contextGroup.trim().length > 0 && !isValidContextGroup && ( +

+ Group must start with a letter and use only letters, numbers, dashes, or + underscores. +

+ )} +
+ )} +
+
@@ -80,7 +136,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial size="sm" className="shrink-0 h-8 px-2" onClick={handleCopy} - disabled={!isValidName} + disabled={!isValidName || (shareContext && !isValidContextGroup)} > {copied ? ( @@ -103,7 +159,10 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial -