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/package.json b/package.json index 3412c99a..f46ff265 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.49.1", + "version": "7.49.1-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", 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/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index 92933208..5347962d 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -4,6 +4,7 @@ */ import * as fs from 'fs'; +import * as path from 'path'; import { CLIProxyProvider, ProviderModelMapping } from '../types'; import { getModelMappingFromConfig, getEnvVarsFromConfig } from '../base-config-loader'; import { getGlobalEnvConfig } from '../../config/unified-config-loader'; @@ -37,6 +38,14 @@ const DEPRECATED_MODEL_PREFIX = 'gemini-claude-'; const UPSTREAM_MODEL_PREFIX = 'claude-'; const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; +const REQUIRED_PROVIDER_ENV_KEYS = [ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', +] as const; function stripCodexEffortSuffix(modelId: string): string { return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); @@ -405,23 +414,72 @@ export function getEffectiveEnvVars( */ export function ensureProviderSettings(provider: CLIProxyProvider): void { const settingsPath = getProviderSettingsPath(provider); + const defaultEnv = getClaudeEnvVars(provider); - // Only create if doesn't exist (preserve user edits) - if (fs.existsSync(settingsPath)) { + const writeSettings = (settings: Record): void => { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { + mode: 0o600, + }); + }; + + // Create initial file when missing. + if (!fs.existsSync(settingsPath)) { + writeSettings({ env: defaultEnv }); return; } - // Generate default settings from PROVIDER_CONFIGS - const envVars = getClaudeEnvVars(provider); - const settings: ProviderSettings = { env: envVars }; + // Existing file: repair missing/invalid core env keys without dropping user data. + let rawContent = ''; + try { + rawContent = fs.readFileSync(settingsPath, 'utf-8'); + } catch { + return; + } - // Ensure directory exists - fs.mkdirSync(require('path').dirname(settingsPath), { recursive: true }); + let parsed: Record; + try { + const value = JSON.parse(rawContent) as unknown; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('settings root must be an object'); + } + parsed = value as Record; + } catch { + // Preserve corrupt payload for manual inspection, then recover with defaults. + const backupPath = `${settingsPath}.corrupt-${Date.now()}`; + try { + fs.writeFileSync(backupPath, rawContent || '', { mode: 0o600 }); + } catch { + // Best effort only. + } + writeSettings({ env: defaultEnv }); + return; + } - // Write with restricted permissions - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { - mode: 0o600, - }); + const envCandidate = parsed.env; + const mergedEnv: Record = + envCandidate && typeof envCandidate === 'object' && !Array.isArray(envCandidate) + ? { ...(envCandidate as Record) } + : {}; + + let mutated = !(envCandidate && typeof envCandidate === 'object' && !Array.isArray(envCandidate)); + for (const key of REQUIRED_PROVIDER_ENV_KEYS) { + const current = mergedEnv[key]; + if (typeof current !== 'string' || current.trim().length === 0) { + mergedEnv[key] = defaultEnv[key] || ''; + mutated = true; + } + } + + if (!mutated) { + return; + } + + const repairedSettings: Record = { + ...parsed, + env: mergedEnv, + }; + writeSettings(repairedSettings); } /** diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index 8ea310f7..27e649d4 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -54,6 +54,8 @@ const DEFAULT_ANTIGRAVITY_ALIASES: OAuthModelAliasEntry[] = [ { name: 'gemini-3-pro-high', alias: 'gemini-3.1-pro-preview' }, { name: 'gemini-3-pro-high', alias: 'gemini-3.1-pro-preview-customtools' }, { name: 'gemini-3-flash', alias: 'gemini-3-flash-preview' }, + { name: 'claude-sonnet-4-6', alias: 'gemini-claude-sonnet-4-6', fork: true }, + { name: 'claude-sonnet-4-6-thinking', alias: 'gemini-claude-sonnet-4-6-thinking', fork: true }, { name: 'claude-sonnet-4-5', alias: 'gemini-claude-sonnet-4-5', fork: true }, { name: 'claude-sonnet-4-5-thinking', alias: 'gemini-claude-sonnet-4-5-thinking', fork: true }, { name: 'claude-opus-4-5-thinking', alias: 'gemini-claude-opus-4-5-thinking', fork: true }, diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 13b6c45c..eb6a87f5 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -103,6 +103,24 @@ export const MODEL_CATALOG: Partial> = dynamicAllowed: true, }, }, + { + id: 'claude-sonnet-4-6-thinking', + name: 'Claude Sonnet 4.6 Thinking', + description: 'Latest Sonnet with extended thinking', + thinking: { + type: 'budget', + min: 1024, + max: 128000, + zeroAllowed: true, + dynamicAllowed: true, + }, + }, + { + id: 'claude-sonnet-4-6', + name: 'Claude Sonnet 4.6', + description: 'Latest Sonnet baseline', + thinking: { type: 'none' }, + }, { id: 'claude-sonnet-4-5-thinking', name: 'Claude Sonnet 4.5 Thinking', diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index a9ab8a84..cb1ddb34 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -116,8 +116,9 @@ export async function configureProviderModel( ? customSettingsPath.replace(/^~/, os.homedir()) : getProviderSettingsPath(provider); - // Skip if already configured (unless --config flag) - if (!force && fs.existsSync(settingsPath)) { + // Skip if already configured with a model (unless --config flag). + // A settings file can exist without model env keys (e.g., hook-only writes). + if (!force && getCurrentModel(provider, customSettingsPath)?.trim()) { return false; } 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/utils/hooks/image-analyzer-profile-hook-injector.ts b/src/utils/hooks/image-analyzer-profile-hook-injector.ts index 94d414da..6c3c8316 100644 --- a/src/utils/hooks/image-analyzer-profile-hook-injector.ts +++ b/src/utils/hooks/image-analyzer-profile-hook-injector.ts @@ -133,7 +133,8 @@ export function ensureProfileHooks(profileName: string): boolean { warn(`Malformed ${profileName}.settings.json: ${(parseError as Error).message}`) ); } - // Continue with empty settings, will add hooks + // Never overwrite malformed settings files; avoid destructive data loss. + return false; } } diff --git a/src/utils/websearch/profile-hook-injector.ts b/src/utils/websearch/profile-hook-injector.ts index 8b2916de..698f00f6 100644 --- a/src/utils/websearch/profile-hook-injector.ts +++ b/src/utils/websearch/profile-hook-injector.ts @@ -114,7 +114,8 @@ export function ensureProfileHooks(profileName: string): boolean { warn(`Malformed ${profileName}.settings.json: ${(parseError as Error).message}`) ); } - // Continue with empty settings, will add hooks + // Never overwrite malformed settings files; avoid destructive data loss. + return false; } } 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/cliproxy/config-generator.test.js b/tests/unit/cliproxy/config-generator.test.js index 2b744bed..772d7a9b 100644 --- a/tests/unit/cliproxy/config-generator.test.js +++ b/tests/unit/cliproxy/config-generator.test.js @@ -59,10 +59,7 @@ describe('Config Generator', () => { assert(!configLine.includes('\\'), 'Should not contain backslashes'); // Verify it's valid YAML format - assert.strictEqual( - configLine, - 'auth-dir: "C:/Users/TestUser/.ccs/cliproxy/auth"' - ); + assert.strictEqual(configLine, 'auth-dir: "C:/Users/TestUser/.ccs/cliproxy/auth"'); }); it('handles multiple consecutive backslashes', () => { @@ -79,7 +76,10 @@ describe('Config Generator', () => { const complexPath = 'D:\\Projects\\ccs-2024\\test\\.ccs\\auth-tokens\\provider.json'; const normalizedPath = complexPath.replace(/\\/g, '/'); - assert.strictEqual(normalizedPath, 'D:/Projects/ccs-2024/test/.ccs/auth-tokens/provider.json'); + assert.strictEqual( + normalizedPath, + 'D:/Projects/ccs-2024/test/.ccs/auth-tokens/provider.json' + ); assert(normalizedPath.includes('Projects'), 'Should preserve directory names'); assert(normalizedPath.includes('auth-tokens'), 'Should preserve hyphens'); assert(normalizedPath.includes('provider.json'), 'Should preserve filenames'); @@ -348,7 +348,8 @@ auth-dir: "/test" }); it('handles tabs in indentation', () => { - const configContent = 'api-keys:\n\t- "ccs-internal-managed"\n\t- "user-key-with-tab"\n\nauth-dir: "/test"'; + const configContent = + 'api-keys:\n\t- "ccs-internal-managed"\n\t- "user-key-with-tab"\n\nauth-dir: "/test"'; const userKeys = parseUserApiKeys(configContent); @@ -544,11 +545,7 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" testPaths.forEach(({ input, expected }) => { const normalized = input.replace(/\\/g, '/'); - assert.strictEqual( - normalized, - expected, - `Should normalize "${input}" to "${expected}"` - ); + assert.strictEqual(normalized, expected, `Should normalize "${input}" to "${expected}"`); }); }); }); @@ -588,6 +585,7 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" // Claude aliases should have fork: true assert(config.includes('claude-sonnet-4-5'), 'Should include Claude sonnet model'); + assert(config.includes('claude-sonnet-4-6'), 'Should include Claude Sonnet 4.6 model'); assert(config.includes('fork: true'), 'Should include fork: true for Claude aliases'); // Verify fork: true appears after each Claude alias entry @@ -650,8 +648,14 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); const config = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); - assert(config.includes('alias: gemini-3.1-pro-preview'), 'Should include dotted version alias'); - assert(config.includes('alias: gemini-3-1-pro-preview'), 'Should include hyphen version alias'); + assert( + config.includes('alias: gemini-3.1-pro-preview'), + 'Should include dotted version alias' + ); + assert( + config.includes('alias: gemini-3-1-pro-preview'), + 'Should include hyphen version alias' + ); assert( config.includes('alias: gemini-3-1-pro-preview-customtools'), 'Should include hyphen customtools alias' diff --git a/tests/unit/cliproxy/env-builder-provider-url.test.ts b/tests/unit/cliproxy/env-builder-provider-url.test.ts index 38370495..63efe2c7 100644 --- a/tests/unit/cliproxy/env-builder-provider-url.test.ts +++ b/tests/unit/cliproxy/env-builder-provider-url.test.ts @@ -2,7 +2,10 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import { getEffectiveEnvVars } from '../../../src/cliproxy/config/env-builder'; +import { + ensureProviderSettings, + getEffectiveEnvVars, +} from '../../../src/cliproxy/config/env-builder'; interface EnvSettings { ANTHROPIC_BASE_URL: string; @@ -24,13 +27,16 @@ function writeSettings( describe('getEffectiveEnvVars local provider URL normalization', () => { let tempHome: string; let settingsPath: string; + let originalCcsHome: string | undefined; beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-env-url-')); settingsPath = path.join(tempHome, 'codex.settings.json'); }); afterEach(() => { + process.env.CCS_HOME = originalCcsHome; fs.rmSync(tempHome, { recursive: true, force: true }); }); @@ -139,4 +145,54 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex'); expect(persisted.presets[0]?.haiku).toBe('gpt-5-mini'); }); + + it('repairs existing provider settings files that are missing env keys', () => { + process.env.CCS_HOME = tempHome; + const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); + fs.mkdirSync(path.dirname(agySettingsPath), { recursive: true }); + fs.writeFileSync( + agySettingsPath, + JSON.stringify( + { + hooks: { + PreToolUse: [{ matcher: 'WebSearch', hooks: [] }], + }, + }, + null, + 2 + ) + ); + + ensureProviderSettings('agy'); + + const repaired = JSON.parse(fs.readFileSync(agySettingsPath, 'utf-8')) as { + env?: Record; + hooks?: Record; + }; + expect(repaired.hooks?.PreToolUse).toBeDefined(); + expect(repaired.env?.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/agy'); + expect(repaired.env?.ANTHROPIC_MODEL).toBeDefined(); + expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBeDefined(); + expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBeDefined(); + expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBeDefined(); + }); + + it('recovers malformed provider settings files by writing defaults and backup copy', () => { + process.env.CCS_HOME = tempHome; + const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); + fs.mkdirSync(path.dirname(agySettingsPath), { recursive: true }); + fs.writeFileSync(agySettingsPath, '{"env": {"ANTHROPIC_MODEL": "claude-sonnet-4-6-thinking",}'); + + ensureProviderSettings('agy'); + + const repaired = JSON.parse(fs.readFileSync(agySettingsPath, 'utf-8')) as { + env?: Record; + }; + expect(repaired.env?.ANTHROPIC_MODEL).toBeDefined(); + + const backups = fs + .readdirSync(path.dirname(agySettingsPath)) + .filter((file) => file.startsWith('agy.settings.json.corrupt-')); + expect(backups.length).toBe(1); + }); }); diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index b3fcfa68..3cd6d7e8 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -71,9 +71,7 @@ describe('Model Catalog', () => { it('includes Claude Opus 4.5 Thinking', () => { const { MODEL_CATALOG } = modelCatalog; - const opus = MODEL_CATALOG.agy.models.find( - (m) => m.id === 'claude-opus-4-5-thinking' - ); + const opus = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-opus-4-5-thinking'); assert(opus, 'Should include Claude Opus 4.5 Thinking'); assert.strictEqual(opus.name, 'Claude Opus 4.5 Thinking'); }); @@ -87,6 +85,22 @@ describe('Model Catalog', () => { assert.strictEqual(sonnetThinking.name, 'Claude Sonnet 4.5 Thinking'); }); + it('includes Claude Sonnet 4.6 Thinking', () => { + const { MODEL_CATALOG } = modelCatalog; + const sonnetThinking = MODEL_CATALOG.agy.models.find( + (m) => m.id === 'claude-sonnet-4-6-thinking' + ); + assert(sonnetThinking, 'Should include Claude Sonnet 4.6 Thinking'); + assert.strictEqual(sonnetThinking.name, 'Claude Sonnet 4.6 Thinking'); + }); + + it('includes Claude Sonnet 4.6', () => { + const { MODEL_CATALOG } = modelCatalog; + const sonnet = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-sonnet-4-6'); + assert(sonnet, 'Should include Claude Sonnet 4.6'); + assert.strictEqual(sonnet.name, 'Claude Sonnet 4.6'); + }); + it('includes Claude Sonnet 4.5', () => { const { MODEL_CATALOG } = modelCatalog; const sonnet = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-sonnet-4-5'); @@ -103,9 +117,9 @@ describe('Model Catalog', () => { assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier'); }); - it('has 5 models total', () => { + it('has 7 models total', () => { const { MODEL_CATALOG } = modelCatalog; - assert.strictEqual(MODEL_CATALOG.agy.models.length, 5); + assert.strictEqual(MODEL_CATALOG.agy.models.length, 7); }); }); @@ -266,9 +280,7 @@ describe('Model Catalog', () => { describe('Thinking models ordering', () => { it('Claude Opus 4.5 Thinking is not deprecated', () => { const { MODEL_CATALOG } = modelCatalog; - const opus = MODEL_CATALOG.agy.models.find( - (m) => m.id === 'claude-opus-4-5-thinking' - ); + const opus = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-opus-4-5-thinking'); assert(opus, 'Should include Claude Opus 4.5 Thinking'); assert.strictEqual(opus.deprecated, undefined, 'Should not be marked as deprecated'); }); @@ -279,7 +291,11 @@ describe('Model Catalog', () => { (m) => m.id === 'claude-sonnet-4-5-thinking' ); assert(sonnetThinking, 'Should include Claude Sonnet 4.5 Thinking'); - assert.strictEqual(sonnetThinking.deprecated, undefined, 'Should not be marked as deprecated'); + assert.strictEqual( + sonnetThinking.deprecated, + undefined, + 'Should not be marked as deprecated' + ); }); it('thinking models are at the top of the list', () => { @@ -288,9 +304,7 @@ describe('Model Catalog', () => { // Find indices of thinking models const opusIdx = models.findIndex((m) => m.id === 'claude-opus-4-5-thinking'); - const sonnetThinkingIdx = models.findIndex( - (m) => m.id === 'claude-sonnet-4-5-thinking' - ); + const sonnetThinkingIdx = models.findIndex((m) => m.id === 'claude-sonnet-4-5-thinking'); // Find indices of non-thinking models const sonnetIdx = models.findIndex((m) => m.id === 'claude-sonnet-4-5'); @@ -299,14 +313,8 @@ describe('Model Catalog', () => { // Thinking models should come before non-thinking models assert(opusIdx < sonnetIdx, 'Opus Thinking should be above non-thinking Sonnet'); assert(opusIdx < geminiIdx, 'Opus Thinking should be above non-thinking Gemini'); - assert( - sonnetThinkingIdx < sonnetIdx, - 'Sonnet Thinking should be above non-thinking Sonnet' - ); - assert( - sonnetThinkingIdx < geminiIdx, - 'Sonnet Thinking should be above non-thinking Gemini' - ); + assert(sonnetThinkingIdx < sonnetIdx, 'Sonnet Thinking should be above non-thinking Sonnet'); + assert(sonnetThinkingIdx < geminiIdx, 'Sonnet Thinking should be above non-thinking Gemini'); }); }); 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 -