feat(auth): add opt-in shared context groups across accounts

This commit is contained in:
Tam Nhu Tran
2026-02-24 23:54:03 +07:00
parent 1dae52014b
commit 4fed74c64b
21 changed files with 787 additions and 25 deletions
+12
View File
@@ -221,6 +221,18 @@ ccs work "implement feature" # Terminal 1
ccs "review code" # Terminal 2 (personal account) 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.
<br> <br>
## Maintenance ## Maintenance
+164
View File
@@ -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<string, unknown>;
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';
}
+15
View File
@@ -79,6 +79,12 @@ class AuthCommands {
console.log(` ${dim('# Create & login to work profile')}`); console.log(` ${dim('# Create & login to work profile')}`);
console.log(` ${color('ccs auth create work', 'command')}`); console.log(` ${color('ccs auth create work', 'command')}`);
console.log(''); 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(` ${dim('# Set work as default')}`);
console.log(` ${color('ccs auth default work', 'command')}`); console.log(` ${color('ccs auth default work', 'command')}`);
console.log(''); console.log('');
@@ -95,6 +101,12 @@ class AuthCommands {
console.log( console.log(
` ${color('--force', 'command')} Allow overwriting existing profile (create)` ` ${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 <name>', 'command')} Share context only within a named group`
);
console.log( console.log(
` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)` ` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)`
); );
@@ -112,6 +124,9 @@ class AuthCommands {
console.log( console.log(
` Use ${color('ccs auth default <profile>', 'command')} to change the default profile.` ` Use ${color('ccs auth default <profile>', 'command')} to change the default profile.`
); );
console.log(
` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.`
);
console.log(''); console.log('');
} }
+37 -5
View File
@@ -9,6 +9,11 @@ import { initUI, header, color, fail, warn, info, infoBox, warnBox } from '../..
import { getClaudeCliInfo } from '../../utils/claude-detector'; import { getClaudeCliInfo } from '../../utils/claude-detector';
import { escapeShellArg, stripClaudeCodeEnv } from '../../utils/shell-executor'; import { escapeShellArg, stripClaudeCodeEnv } from '../../utils/shell-executor';
import { isUnifiedMode } from '../../config/unified-config-loader'; import { isUnifiedMode } from '../../config/unified-config-loader';
import {
resolveCreateAccountContext,
policyToAccountContextMetadata,
formatAccountContextPolicy,
} from '../account-context';
import { exitWithError } from '../../errors'; import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes'; import { ExitCode } from '../../errors/exit-codes';
import { CommandContext, parseArgs } from './types'; import { CommandContext, parseArgs } from './types';
@@ -18,12 +23,14 @@ import { CommandContext, parseArgs } from './types';
*/ */
export async function handleCreate(ctx: CommandContext, args: string[]): Promise<void> { export async function handleCreate(ctx: CommandContext, args: string[]): Promise<void> {
await initUI(); await initUI();
const { profileName, force } = parseArgs(args); const { profileName, force, shareContext, contextGroup } = parseArgs(args);
if (!profileName) { if (!profileName) {
console.log(fail('Profile name is required')); console.log(fail('Profile name is required'));
console.log(''); console.log('');
console.log(`Usage: ${color('ccs auth create <profile> [--force]', 'command')}`); console.log(
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>]', 'command')}`
);
console.log(''); console.log('');
console.log('Example:'); console.log('Example:');
console.log(` ${color('ccs auth create work', 'command')}`); 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); 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 { try {
// Create instance directory // Create instance directory
console.log(info(`Creating profile: ${profileName}`)); console.log(info(`Creating profile: ${profileName}`));
const instancePath = await ctx.instanceMgr.ensureInstance(profileName); const instancePath = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy);
// Create/update profile entry based on config mode // Create/update profile entry based on config mode
if (isUnifiedMode()) { if (isUnifiedMode()) {
// Use unified config (config.yaml) // Use unified config (config.yaml)
if (existsUnified) { if (existsUnified) {
ctx.registry.updateAccountUnified(profileName, {
context_mode: contextMetadata.context_mode,
context_group: contextMetadata.context_group,
});
ctx.registry.touchAccountUnified(profileName); ctx.registry.touchAccountUnified(profileName);
} else { } else {
ctx.registry.createAccountUnified(profileName); ctx.registry.createAccountUnified(profileName, contextMetadata);
} }
} else { } else {
// Use legacy profiles.json // Use legacy profiles.json
if (existsLegacy) { if (existsLegacy) {
ctx.registry.updateProfile(profileName, { ctx.registry.updateProfile(profileName, {
type: 'account', type: 'account',
context_mode: contextMetadata.context_mode,
context_group: contextMetadata.context_group,
}); });
} else { } else {
ctx.registry.createProfile(profileName, { ctx.registry.createProfile(profileName, {
type: 'account', 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('');
console.log( console.log(
infoBox( infoBox(
`Profile: ${profileName}\n` + `Instance: ${instancePath}\n` + `Type: account`, `Profile: ${profileName}\n` +
`Instance: ${instancePath}\n` +
`Type: account\n` +
`Context: ${formatAccountContextPolicy(contextPolicy)}`,
'Profile Created' 'Profile Created'
) )
); );
+8 -2
View File
@@ -6,6 +6,7 @@
import { ProfileMetadata } from '../../types'; import { ProfileMetadata } from '../../types';
import { initUI, header, color, dim, warn, table } from '../../utils/ui'; import { initUI, header, color, dim, warn, table } from '../../utils/ui';
import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context';
import { exitWithError } from '../../errors'; import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes'; import { ExitCode } from '../../errors/exit-codes';
import { CommandContext, ListOutput, parseArgs, formatRelativeTime } from './types'; import { CommandContext, ListOutput, parseArgs, formatRelativeTime } from './types';
@@ -41,6 +42,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
version: ctx.version, version: ctx.version,
profiles: profileNames.map((name) => { profiles: profileNames.map((name) => {
const profile = profiles[name]; const profile = profiles[name];
const contextPolicy = resolveAccountContextPolicy(profile);
const isDefault = name === defaultProfile; const isDefault = name === defaultProfile;
const instancePath = ctx.instanceMgr.getInstancePath(name); const instancePath = ctx.instanceMgr.getInstancePath(name);
@@ -50,6 +52,8 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
is_default: isDefault, is_default: isDefault,
created: profile.created, created: profile.created,
last_used: profile.last_used || null, last_used: profile.last_used || null,
context_mode: contextPolicy.mode,
context_group: contextPolicy.group || null,
instance_path: instancePath, instance_path: instancePath,
}; };
}), }),
@@ -98,6 +102,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
const rows: string[][] = sorted.map((name) => { const rows: string[][] = sorted.map((name) => {
const profile = profiles[name]; const profile = profiles[name];
const isDefault = name === defaultProfile; const isDefault = name === defaultProfile;
const contextPolicy = resolveAccountContextPolicy(profile);
// Status column // Status column
const status = isDefault ? color('[OK] default', 'success') : color('[OK]', 'success'); const status = isDefault ? color('[OK] default', 'success') : color('[OK]', 'success');
@@ -112,6 +117,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
if (verbose) { if (verbose) {
row.push(lastUsed); row.push(lastUsed);
row.push(formatAccountContextPolicy(contextPolicy));
} }
return row; return row;
@@ -119,14 +125,14 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
// Headers // Headers
const headers = verbose const headers = verbose
? ['Profile', 'Type', 'Status', 'Last Used'] ? ['Profile', 'Type', 'Status', 'Last Used', 'Context']
: ['Profile', 'Type', 'Status']; : ['Profile', 'Type', 'Status'];
// Print table // Print table
console.log( console.log(
table(rows, { table(rows, {
head: headers, head: headers,
colWidths: verbose ? [15, 12, 15, 12] : [15, 12, 15], colWidths: verbose ? [15, 12, 15, 12, 18] : [15, 12, 15],
}) })
); );
console.log(''); console.log('');
+5
View File
@@ -7,6 +7,7 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { initUI, header, color, fail, table } from '../../utils/ui'; import { initUI, header, color, fail, table } from '../../utils/ui';
import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context';
import { exitWithError } from '../../errors'; import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes'; import { ExitCode } from '../../errors/exit-codes';
import { CommandContext, ProfileOutput, parseArgs } from './types'; import { CommandContext, ProfileOutput, parseArgs } from './types';
@@ -35,6 +36,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
const defaultProfile = ctx.registry.getDefaultResolved(); const defaultProfile = ctx.registry.getDefaultResolved();
const isDefault = profileName === defaultProfile; const isDefault = profileName === defaultProfile;
const instancePath = ctx.instanceMgr.getInstancePath(profileName); const instancePath = ctx.instanceMgr.getInstancePath(profileName);
const contextPolicy = resolveAccountContextPolicy(profile);
// Count sessions // Count sessions
let sessionCount = 0; let sessionCount = 0;
@@ -56,6 +58,8 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
is_default: isDefault, is_default: isDefault,
created: profile.created, created: profile.created,
last_used: profile.last_used || null, last_used: profile.last_used || null,
context_mode: contextPolicy.mode,
context_group: contextPolicy.group || null,
instance_path: instancePath, instance_path: instancePath,
session_count: sessionCount, session_count: sessionCount,
}; };
@@ -74,6 +78,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
['Instance', instancePath], ['Instance', instancePath],
['Created', new Date(profile.created).toLocaleString()], ['Created', new Date(profile.created).toLocaleString()],
['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'], ['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'],
['Context', formatAccountContextPolicy(contextPolicy)],
['Sessions', `${sessionCount}`], ['Sessions', `${sessionCount}`],
]; ];
+38 -1
View File
@@ -19,6 +19,8 @@ export interface AuthCommandArgs {
verbose?: boolean; verbose?: boolean;
json?: boolean; json?: boolean;
yes?: boolean; yes?: boolean;
shareContext?: boolean;
contextGroup?: string;
} }
/** /**
@@ -30,6 +32,8 @@ export interface ProfileOutput {
is_default: boolean; is_default: boolean;
created: string; created: string;
last_used: string | null; last_used: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string | null;
instance_path?: string; instance_path?: string;
session_count?: number; session_count?: number;
} }
@@ -55,12 +59,45 @@ export interface CommandContext {
* Parse command arguments from raw args array * Parse command arguments from raw args array
*/ */
export function parseArgs(args: string[]): AuthCommandArgs { export function parseArgs(args: string[]): AuthCommandArgs {
const profileName = args.find((arg) => !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 { return {
profileName, profileName,
force: args.includes('--force'), force: args.includes('--force'),
verbose: args.includes('--verbose'), verbose: args.includes('--verbose'),
json: args.includes('--json'), json: args.includes('--json'),
yes: args.includes('--yes') || args.includes('-y'), yes: args.includes('--yes') || args.includes('-y'),
shareContext: args.includes('--share-context'),
contextGroup,
}; };
} }
+2
View File
@@ -200,6 +200,8 @@ class ProfileDetector {
type: 'account', type: 'account',
created: account.created, created: account.created,
last_used: account.last_used, last_used: account.last_used,
context_mode: account.context_mode,
context_group: account.context_group,
}, },
}; };
} }
+59 -8
View File
@@ -6,6 +6,7 @@ import {
saveUnifiedConfig, saveUnifiedConfig,
isUnifiedMode, isUnifiedMode,
} from '../config/unified-config-loader'; } from '../config/unified-config-loader';
import type { AccountConfig } from '../config/unified-config-types';
import { getCcsDir } from '../utils/config-manager'; import { getCcsDir } from '../utils/config-manager';
/** /**
@@ -19,6 +20,8 @@ import { getCcsDir } from '../utils/config-manager';
* type: 'account', // Profile type * type: 'account', // Profile type
* created: <ISO timestamp>, // Creation time * created: <ISO timestamp>, // Creation time
* last_used: <ISO timestamp or null> // Last usage time * last_used: <ISO timestamp or null> // Last usage time
* context_mode?: 'isolated' | 'shared' // Workspace context policy
* context_group?: <string> // Shared context group when mode=shared
* } * }
* *
* Removed fields from v2.x: * Removed fields from v2.x:
@@ -37,6 +40,8 @@ interface CreateMetadata {
type?: string; type?: string;
created?: string; created?: string;
last_used?: string | null; last_used?: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string;
} }
export class ProfileRegistry { export class ProfileRegistry {
@@ -46,6 +51,30 @@ export class ProfileRegistry {
this.profilesPath = path.join(getCcsDir(), 'profiles.json'); 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 * Read profiles from disk
*/ */
@@ -105,11 +134,13 @@ export class ProfileRegistry {
} }
// v3.0 minimal schema: only essential fields // v3.0 minimal schema: only essential fields
data.profiles[name] = { data.profiles[name] = this.normalizeLegacyProfileMetadata({
type: metadata.type || 'account', type: metadata.type || 'account',
created: metadata.created || new Date().toISOString(), created: metadata.created || new Date().toISOString(),
last_used: metadata.last_used || null, last_used: metadata.last_used || null,
}; context_mode: metadata.context_mode,
context_group: metadata.context_group,
});
// Note: No longer auto-set as default // Note: No longer auto-set as default
// Users must explicitly run: ccs auth default <profile> // Users must explicitly run: ccs auth default <profile>
@@ -141,10 +172,10 @@ export class ProfileRegistry {
throw new Error(`Profile not found: ${name}`); throw new Error(`Profile not found: ${name}`);
} }
data.profiles[name] = { data.profiles[name] = this.normalizeLegacyProfileMetadata({
...data.profiles[name], ...data.profiles[name],
...updates, ...updates,
}; });
this._write(data); this._write(data);
} }
@@ -242,15 +273,32 @@ export class ProfileRegistry {
/** /**
* Create account in unified config (config.yaml) * Create account in unified config (config.yaml)
*/ */
createAccountUnified(name: string): void { createAccountUnified(name: string, metadata: CreateMetadata = {}): void {
const config = loadOrCreateUnifiedConfig(); const config = loadOrCreateUnifiedConfig();
if (config.accounts[name]) { if (config.accounts[name]) {
throw new Error(`Account already exists: ${name}`); throw new Error(`Account already exists: ${name}`);
} }
config.accounts[name] = { config.accounts[name] = this.normalizeUnifiedAccountConfig({
created: new Date().toISOString(), created: new Date().toISOString(),
last_used: null, 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<AccountConfig>): 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); saveUnifiedConfig(config);
} }
@@ -306,7 +354,7 @@ export class ProfileRegistry {
/** /**
* Get all accounts from unified config * Get all accounts from unified config
*/ */
getAllAccountsUnified(): Record<string, { created: string; last_used: string | null }> { getAllAccountsUnified(): Record<string, AccountConfig> {
if (!isUnifiedMode()) return {}; if (!isUnifiedMode()) return {};
const config = loadOrCreateUnifiedConfig(); const config = loadOrCreateUnifiedConfig();
return config.accounts; return config.accounts;
@@ -330,6 +378,7 @@ export class ProfileRegistry {
throw new Error(`Account not found: ${name}`); throw new Error(`Account not found: ${name}`);
} }
config.accounts[name].last_used = new Date().toISOString(); config.accounts[name].last_used = new Date().toISOString();
config.accounts[name] = this.normalizeUnifiedAccountConfig(config.accounts[name]);
saveUnifiedConfig(config); saveUnifiedConfig(config);
} }
@@ -355,6 +404,8 @@ export class ProfileRegistry {
type: 'account', type: 'account',
created: account.created, created: account.created,
last_used: account.last_used, last_used: account.last_used,
context_mode: account.context_mode,
context_group: account.context_group,
}; };
} }
+7 -1
View File
@@ -589,6 +589,8 @@ async function main(): Promise<void> {
const InstanceManager = InstanceManagerModule.default; const InstanceManager = InstanceManagerModule.default;
const ProfileRegistryModule = await import('./auth/profile-registry'); const ProfileRegistryModule = await import('./auth/profile-registry');
const ProfileRegistry = ProfileRegistryModule.default; const ProfileRegistry = ProfileRegistryModule.default;
const AccountContextModule = await import('./auth/account-context');
const { resolveAccountContextPolicy, isAccountContextMetadata } = AccountContextModule;
const detector = new ProfileDetector(); const detector = new ProfileDetector();
@@ -882,9 +884,13 @@ async function main(): Promise<void> {
// All platforms: Use instance isolation with CLAUDE_CONFIG_DIR // All platforms: Use instance isolation with CLAUDE_CONFIG_DIR
const registry = new ProfileRegistry(); const registry = new ProfileRegistry();
const instanceMgr = new InstanceManager(); const instanceMgr = new InstanceManager();
const accountMetadata = isAccountContextMetadata(profileInfo.profile)
? profileInfo.profile
: undefined;
const contextPolicy = resolveAccountContextPolicy(accountMetadata);
// Ensure instance exists (lazy init if needed) // 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) // Update last_used timestamp (check unified config first, fallback to legacy)
if (registry.hasAccountUnified(profileInfo.name)) { if (registry.hasAccountUnified(profileInfo.name)) {
+1 -1
View File
@@ -150,7 +150,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['Run multiple Claude accounts concurrently'], ['Run multiple Claude accounts concurrently'],
[ [
['ccs auth --help', 'Show account management commands'], ['ccs auth --help', 'Show account management commands'],
['ccs auth create <name>', 'Create new account profile'], ['ccs auth create <name>', 'Create account profile (supports context sharing flags)'],
['ccs auth list', 'List all account profiles'], ['ccs auth list', 'List all account profiles'],
['ccs auth default <name>', 'Set default profile'], ['ccs auth default <name>', 'Set default profile'],
['ccs auth reset-default', 'Restore original CCS default'], ['ccs auth reset-default', 'Restore original CCS default'],
+4
View File
@@ -40,6 +40,10 @@ export interface AccountConfig {
created: string; created: string;
/** ISO timestamp of last usage, null if never used */ /** ISO timestamp of last usage, null if never used */
last_used: string | null; last_used: string | null;
/** Context mode for project workspace data */
context_mode?: 'isolated' | 'shared';
/** Context-sharing group when context_mode='shared' */
context_group?: string;
} }
/** /**
+7 -3
View File
@@ -9,6 +9,7 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import SharedManager from './shared-manager'; import SharedManager from './shared-manager';
import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context';
import { getCcsDir } from '../utils/config-manager'; import { getCcsDir } from '../utils/config-manager';
/** /**
@@ -26,7 +27,10 @@ class InstanceManager {
/** /**
* Ensure instance exists for profile (lazy init only) * Ensure instance exists for profile (lazy init only)
*/ */
async ensureInstance(profileName: string): Promise<string> { async ensureInstance(
profileName: string,
contextPolicy: AccountContextPolicy = { mode: DEFAULT_ACCOUNT_CONTEXT_MODE }
): Promise<string> {
const instancePath = this.getInstancePath(profileName); const instancePath = this.getInstancePath(profileName);
// Lazy initialization // Lazy initialization
@@ -37,8 +41,8 @@ class InstanceManager {
// Validate structure (auto-fix missing dirs) // Validate structure (auto-fix missing dirs)
this.validateInstance(instancePath); this.validateInstance(instancePath);
// Keep project memory shared across instances. // Apply context policy (isolated by default, optional shared group).
await this.sharedManager.syncProjectMemories(instancePath); await this.sharedManager.syncProjectContext(instancePath, contextPolicy);
return instancePath; return instancePath;
} }
+179
View File
@@ -10,6 +10,7 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
import { ok, info, warn } from '../utils/ui'; import { ok, info, warn } from '../utils/ui';
import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_GROUP } from '../auth/account-context';
import { getCcsDir } from '../utils/config-manager'; import { getCcsDir } from '../utils/config-manager';
interface SharedItem { interface SharedItem {
@@ -201,6 +202,102 @@ class SharedManager {
this.normalizePluginRegistryPaths(); 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<void> {
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. * 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<string | null> {
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<void> {
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<void> {
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. * Move directory, with cross-device fallback.
*/ */
+4
View File
@@ -87,6 +87,10 @@ export interface ProfileMetadata {
type?: string; // Profile type (e.g., 'account') type?: string; // Profile type (e.g., 'account')
created: string; // Creation time created: string; // Creation time
last_used?: string | null; // Last usage 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 { export interface ProfilesRegistry {
+6
View File
@@ -53,6 +53,8 @@ router.get('/', (_req: Request, res: Response): void => {
type: string; type: string;
created: string; created: string;
last_used: string | null; last_used: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string;
provider?: string; provider?: string;
displayName?: string; displayName?: string;
} }
@@ -64,6 +66,8 @@ router.get('/', (_req: Request, res: Response): void => {
type: meta.type || 'account', type: meta.type || 'account',
created: meta.created, created: meta.created,
last_used: meta.last_used || null, 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', type: 'account',
created: account.created, created: account.created,
last_used: account.last_used, last_used: account.last_used,
context_mode: account.context_mode,
context_group: account.context_group,
}; };
} }
+34
View File
@@ -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('');
});
});
+121
View File
@@ -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');
});
});
@@ -87,6 +87,24 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
return <span className="text-muted-foreground">{date.toLocaleDateString()}</span>; return <span className="text-muted-foreground">{date.toLocaleDateString()}</span>;
}, },
}, },
{
id: 'context',
header: 'Context',
size: 170,
cell: ({ row }) => {
if (row.original.type === 'cliproxy') {
return <span className="text-muted-foreground/50">-</span>;
}
const mode = row.original.context_mode || 'isolated';
if (mode === 'shared') {
const group = row.original.context_group || 'default';
return <span className="text-muted-foreground">shared ({group})</span>;
}
return <span className="text-muted-foreground">isolated</span>;
},
},
{ {
id: 'actions', id: 'actions',
header: 'Actions', header: 'Actions',
@@ -154,6 +172,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
type: 'w-[100px]', type: 'w-[100px]',
created: 'w-[150px]', created: 'w-[150px]',
last_used: 'w-[150px]', last_used: 'w-[150px]',
context: 'w-[170px]',
actions: 'w-[180px]', actions: 'w-[180px]',
}[header.id] || 'w-auto'; }[header.id] || 'w-auto';
@@ -14,6 +14,7 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Copy, Check, Terminal } from 'lucide-react'; import { Copy, Check, Terminal } from 'lucide-react';
interface CreateAuthProfileDialogProps { interface CreateAuthProfileDialogProps {
@@ -23,14 +24,32 @@ interface CreateAuthProfileDialogProps {
export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDialogProps) { export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDialogProps) {
const [profileName, setProfileName] = useState(''); const [profileName, setProfileName] = useState('');
const [shareContext, setShareContext] = useState(false);
const [contextGroup, setContextGroup] = useState('');
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
// Validate profile name: alphanumeric, dash, underscore only // Validate profile name: alphanumeric, dash, underscore only
const isValidName = /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(profileName); const isValidName = /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(profileName);
const command = profileName ? `ccs auth create ${profileName}` : 'ccs auth create <name>'; 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 <name>';
const handleCopy = async () => { const handleCopy = async () => {
if (!isValidName) return; if (!isValidName || (shareContext && !isValidContextGroup)) return;
await navigator.clipboard.writeText(command); await navigator.clipboard.writeText(command);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
@@ -38,6 +57,8 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
const handleClose = () => { const handleClose = () => {
setProfileName(''); setProfileName('');
setShareContext(false);
setContextGroup('');
setCopied(false); setCopied(false);
onClose(); onClose();
}; };
@@ -70,6 +91,41 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
)} )}
</div> </div>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Checkbox
id="share-context"
checked={shareContext}
onCheckedChange={(checked) => setShareContext(checked === true)}
/>
<Label htmlFor="share-context" className="cursor-pointer">
Share project context with other accounts
</Label>
</div>
{shareContext && (
<div className="space-y-2 pl-6">
<Label htmlFor="context-group">Context Group (optional)</Label>
<Input
id="context-group"
value={contextGroup}
onChange={(e) => setContextGroup(e.target.value)}
placeholder="default, sprint-a, client-x"
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
Leave empty to use the default shared group.
</p>
{contextGroup.trim().length > 0 && !isValidContextGroup && (
<p className="text-xs text-destructive">
Group must start with a letter and use only letters, numbers, dashes, or
underscores.
</p>
)}
</div>
)}
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Command</Label> <Label>Command</Label>
<div className="flex items-center gap-2 p-3 bg-muted rounded-md font-mono text-sm"> <div className="flex items-center gap-2 p-3 bg-muted rounded-md font-mono text-sm">
@@ -80,7 +136,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
size="sm" size="sm"
className="shrink-0 h-8 px-2" className="shrink-0 h-8 px-2"
onClick={handleCopy} onClick={handleCopy}
disabled={!isValidName} disabled={!isValidName || (shareContext && !isValidContextGroup)}
> >
{copied ? ( {copied ? (
<Check className="w-4 h-4 text-green-500" /> <Check className="w-4 h-4 text-green-500" />
@@ -103,7 +159,10 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
<Button variant="ghost" onClick={handleClose}> <Button variant="ghost" onClick={handleClose}>
Close Close
</Button> </Button>
<Button onClick={handleCopy} disabled={!isValidName}> <Button
onClick={handleCopy}
disabled={!isValidName || (shareContext && !isValidContextGroup)}
>
{copied ? ( {copied ? (
<> <>
<Check className="w-4 h-4 mr-2" /> <Check className="w-4 h-4 mr-2" />
+2
View File
@@ -445,6 +445,8 @@ export interface Account {
type?: string; type?: string;
created: string; created: string;
last_used?: string | null; last_used?: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string;
} }
// Unified config types // Unified config types