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

This commit is contained in:
Tam Nhu Tran committed 2026-02-24 23:54:03 +07:00
1 parent 1dae52014b
commit 4fed74c64b
21 files changed
+787 -25

No files matched your search

+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(` ${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 <name>', '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 <profile>', 'command')} to change the default profile.`
);
console.log(
` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.`
);
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 { 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<void> {
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 <profile> [--force]', 'command')}`);
console.log(
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>]', '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'
)
);
+8 -2
View File
@@ -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<v
version: ctx.version,
profiles: profileNames.map((name) => {
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<v
is_default: isDefault,
created: profile.created,
last_used: profile.last_used || null,
context_mode: contextPolicy.mode,
context_group: contextPolicy.group || null,
instance_path: instancePath,
};
}),
@@ -98,6 +102,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
const rows: string[][] = sorted.map((name) => {
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<v
if (verbose) {
row.push(lastUsed);
row.push(formatAccountContextPolicy(contextPolicy));
}
return row;
@@ -119,14 +125,14 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
// Headers
const headers = verbose
? ['Profile', 'Type', 'Status', 'Last Used']
? ['Profile', 'Type', 'Status', 'Last Used', 'Context']
: ['Profile', 'Type', 'Status'];
// Print table
console.log(
table(rows, {
head: headers,
colWidths: verbose ? [15, 12, 15, 12] : [15, 12, 15],
colWidths: verbose ? [15, 12, 15, 12, 18] : [15, 12, 15],
})
);
console.log('');
+5
View File
@@ -7,6 +7,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { initUI, header, color, fail, table } from '../../utils/ui';
import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
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 isDefault = profileName === defaultProfile;
const instancePath = ctx.instanceMgr.getInstancePath(profileName);
const contextPolicy = resolveAccountContextPolicy(profile);
// Count sessions
let sessionCount = 0;
@@ -56,6 +58,8 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
is_default: isDefault,
created: profile.created,
last_used: profile.last_used || null,
context_mode: contextPolicy.mode,
context_group: contextPolicy.group || null,
instance_path: instancePath,
session_count: sessionCount,
};
@@ -74,6 +78,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
['Instance', instancePath],
['Created', new Date(profile.created).toLocaleString()],
['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'],
['Context', formatAccountContextPolicy(contextPolicy)],
['Sessions', `${sessionCount}`],
];
+38 -1
View File
@@ -19,6 +19,8 @@ export interface AuthCommandArgs {
verbose?: boolean;
json?: boolean;
yes?: boolean;
shareContext?: boolean;
contextGroup?: string;
}
/**
@@ -30,6 +32,8 @@ export interface ProfileOutput {
is_default: boolean;
created: string;
last_used: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string | null;
instance_path?: string;
session_count?: number;
}
@@ -55,12 +59,45 @@ export interface CommandContext {
* Parse command arguments from raw args array
*/
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 {
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,
};
}
+2
View File
@@ -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,
},
};
}
+59 -8
View File
@@ -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: <ISO timestamp>, // Creation 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:
@@ -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 <profile>
@@ -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<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);
}
@@ -306,7 +354,7 @@ export class ProfileRegistry {
/**
* Get all accounts from unified config
*/
getAllAccountsUnified(): Record<string, { created: string; last_used: string | null }> {
getAllAccountsUnified(): Record<string, AccountConfig> {
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,
};
}