mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
feat(continuity): support cross-profile continuity inheritance from account profiles
This commit is contained in:
@@ -331,6 +331,20 @@ accounts:
|
||||
|
||||
Shared context with `standard` depth links project workspace data. `deeper` depth links additional continuity artifacts. Credentials remain isolated per account.
|
||||
|
||||
#### Cross-Profile Continuity Inheritance (Claude Target)
|
||||
|
||||
You can map non-account profiles (API, CLIProxy, Copilot, or `default`) to reuse continuity artifacts from an account profile:
|
||||
|
||||
```yaml
|
||||
continuity:
|
||||
inherit_from_account:
|
||||
glm: pro
|
||||
gemini: pro
|
||||
copilot: pro
|
||||
```
|
||||
|
||||
With this config, `ccs glm`, `ccs gemini`, and `ccs copilot` run with `pro`'s `CLAUDE_CONFIG_DIR` continuity context while keeping each profile's own provider credentials/settings.
|
||||
|
||||
Alternative path for lower manual switching:
|
||||
|
||||
- Use CLIProxy Claude pool (`ccs cliproxy auth claude`) and manage pool behavior in `ccs config` -> `CLIProxy Plus`.
|
||||
|
||||
@@ -50,6 +50,25 @@ Deeper continuity links these directories per context group:
|
||||
|
||||
`.anthropic` and account credentials remain isolated.
|
||||
|
||||
## Cross-Profile Inheritance (API / CLIProxy / Copilot)
|
||||
|
||||
You can explicitly map non-account profiles (including `default`) to reuse continuity artifacts from an account profile:
|
||||
|
||||
```yaml
|
||||
continuity:
|
||||
inherit_from_account:
|
||||
glm: pro
|
||||
gemini: pro
|
||||
copilot: pro
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- Applies only when running Claude target (`ccs <profile>` or `--target claude`)
|
||||
- Does not change provider credentials or API routing
|
||||
- Reuses `CLAUDE_CONFIG_DIR` from mapped account profile after normal account context policy resolution
|
||||
- Invalid/missing mapped accounts are skipped safely
|
||||
|
||||
## User Workflows
|
||||
|
||||
### New account with shared context
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import * as fs from 'fs';
|
||||
import {
|
||||
getConfigJsonPath,
|
||||
getContinuityInheritanceMap,
|
||||
isUnifiedMode,
|
||||
} from '../config/unified-config-loader';
|
||||
import { warn } from '../utils/ui';
|
||||
import InstanceManager from '../management/instance-manager';
|
||||
import ProfileRegistry from './profile-registry';
|
||||
import { isAccountContextMetadata, resolveAccountContextPolicy } from './account-context';
|
||||
import type { ProfileType } from '../types/profile';
|
||||
|
||||
export interface ProfileContinuityInheritanceInput {
|
||||
profileName: string;
|
||||
profileType: ProfileType;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface ProfileContinuityInheritanceResult {
|
||||
sourceAccount?: string;
|
||||
claudeConfigDir?: string;
|
||||
}
|
||||
|
||||
function loadLegacyContinuityInheritanceMap(): Record<string, string> {
|
||||
const configJsonPath = getConfigJsonPath();
|
||||
if (!fs.existsSync(configJsonPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(configJsonPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as { continuity_inherit_from_account?: unknown };
|
||||
if (
|
||||
typeof parsed.continuity_inherit_from_account !== 'object' ||
|
||||
parsed.continuity_inherit_from_account === null ||
|
||||
Array.isArray(parsed.continuity_inherit_from_account)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [profileName, accountName] of Object.entries(
|
||||
parsed.continuity_inherit_from_account as Record<string, unknown>
|
||||
)) {
|
||||
if (typeof accountName !== 'string') continue;
|
||||
const normalizedProfile = profileName.trim();
|
||||
const normalizedAccount = accountName.trim();
|
||||
if (!normalizedProfile || !normalizedAccount) continue;
|
||||
normalized[normalizedProfile] = normalizedAccount;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMappedAccount(
|
||||
profileName: string,
|
||||
inheritFromAccount: Record<string, string>
|
||||
): string | undefined {
|
||||
const mapped = inheritFromAccount[profileName];
|
||||
if (typeof mapped !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = mapped.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve optional cross-profile continuity inheritance.
|
||||
*
|
||||
* Rules:
|
||||
* - Claude target only.
|
||||
* - Never applies to account profiles.
|
||||
* - Mapping source must be an account profile.
|
||||
*/
|
||||
export async function resolveProfileContinuityInheritance(
|
||||
input: ProfileContinuityInheritanceInput
|
||||
): Promise<ProfileContinuityInheritanceResult> {
|
||||
if (input.target !== 'claude' || input.profileType === 'account') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const inheritFromAccount = getContinuityInheritanceMap();
|
||||
const sourceAccount =
|
||||
resolveMappedAccount(input.profileName, inheritFromAccount) ??
|
||||
(!isUnifiedMode()
|
||||
? resolveMappedAccount(input.profileName, loadLegacyContinuityInheritanceMap())
|
||||
: undefined);
|
||||
if (!sourceAccount) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
const profiles = registry.getAllProfilesMerged();
|
||||
const mappedProfile = profiles[sourceAccount];
|
||||
if (!mappedProfile || mappedProfile.type !== 'account') {
|
||||
console.error(
|
||||
warn(
|
||||
`Continuity inheritance skipped for "${input.profileName}": source account "${sourceAccount}" not found`
|
||||
)
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
const contextPolicy = resolveAccountContextPolicy(
|
||||
isAccountContextMetadata(mappedProfile) ? mappedProfile : undefined
|
||||
);
|
||||
const instanceMgr = new InstanceManager();
|
||||
const instancePath = await instanceMgr.ensureInstance(sourceAccount, contextPolicy);
|
||||
|
||||
// Best-effort touch only; execution must continue even if touch fails.
|
||||
try {
|
||||
if (registry.hasAccountUnified(sourceAccount)) {
|
||||
registry.touchAccountUnified(sourceAccount);
|
||||
} else if (registry.hasProfile(sourceAccount)) {
|
||||
registry.touchProfile(sourceAccount);
|
||||
}
|
||||
} catch {
|
||||
// Ignore metadata touch failure.
|
||||
}
|
||||
|
||||
return {
|
||||
sourceAccount,
|
||||
claudeConfigDir: instancePath,
|
||||
};
|
||||
}
|
||||
+64
-3
@@ -107,7 +107,8 @@ function detectProfile(args: string[]): DetectedProfile {
|
||||
async function execClaudeWithProxy(
|
||||
claudeCli: string,
|
||||
profileName: string,
|
||||
args: string[]
|
||||
args: string[],
|
||||
claudeConfigDir?: string
|
||||
): Promise<void> {
|
||||
// 1. Read settings to get API key
|
||||
const settingsPath = getSettingsPath(profileName);
|
||||
@@ -208,6 +209,7 @@ async function execClaudeWithProxy(
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||
ANTHROPIC_MODEL: configuredModel,
|
||||
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
|
||||
};
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
@@ -611,6 +613,8 @@ async function main(): Promise<void> {
|
||||
const ProfileRegistry = ProfileRegistryModule.default;
|
||||
const AccountContextModule = await import('./auth/account-context');
|
||||
const { resolveAccountContextPolicy, isAccountContextMetadata } = AccountContextModule;
|
||||
const ProfileContinuityModule = await import('./auth/profile-continuity-inheritance');
|
||||
const { resolveProfileContinuityInheritance } = ProfileContinuityModule;
|
||||
|
||||
const detector = new ProfileDetector();
|
||||
|
||||
@@ -948,7 +952,23 @@ async function main(): Promise<void> {
|
||||
console.error(fail('Copilot configuration not found'));
|
||||
process.exit(1);
|
||||
}
|
||||
const exitCode = await executeCopilotProfile(copilotConfig, remainingArgs);
|
||||
const continuityInheritance = await resolveProfileContinuityInheritance({
|
||||
profileName: profileInfo.name,
|
||||
profileType: profileInfo.type,
|
||||
target: resolvedTarget,
|
||||
});
|
||||
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
|
||||
console.error(
|
||||
info(
|
||||
`Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"`
|
||||
)
|
||||
);
|
||||
}
|
||||
const exitCode = await executeCopilotProfile(
|
||||
copilotConfig,
|
||||
remainingArgs,
|
||||
continuityInheritance.claudeConfigDir
|
||||
);
|
||||
process.exit(exitCode);
|
||||
} else if (profileInfo.type === 'settings') {
|
||||
// Settings-based profiles (glm, glmt) are third-party providers
|
||||
@@ -963,6 +983,23 @@ async function main(): Promise<void> {
|
||||
// Display WebSearch status (single line, equilibrium UX)
|
||||
displayWebSearchStatus();
|
||||
|
||||
const continuityInheritance =
|
||||
resolvedTarget === 'claude'
|
||||
? await resolveProfileContinuityInheritance({
|
||||
profileName: profileInfo.name,
|
||||
profileType: profileInfo.type,
|
||||
target: resolvedTarget,
|
||||
})
|
||||
: {};
|
||||
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
|
||||
console.error(
|
||||
info(
|
||||
`Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"`
|
||||
)
|
||||
);
|
||||
}
|
||||
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
|
||||
|
||||
// Pre-flight validation for GLM/GLMT/MiniMax profiles
|
||||
if (profileInfo.name === 'glm' || profileInfo.name === 'glmt') {
|
||||
const preflightSettingsPath = getSettingsPath(profileInfo.name);
|
||||
@@ -1026,7 +1063,12 @@ async function main(): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
// GLMT FLOW: Settings-based with embedded proxy for thinking support
|
||||
await execClaudeWithProxy(claudeCli, profileInfo.name, remainingArgs);
|
||||
await execClaudeWithProxy(
|
||||
claudeCli,
|
||||
profileInfo.name,
|
||||
remainingArgs,
|
||||
inheritedClaudeConfigDir
|
||||
);
|
||||
} else {
|
||||
// EXISTING FLOW: Settings-based profile (glm)
|
||||
// Use --settings flag (backward compatible)
|
||||
@@ -1055,6 +1097,7 @@ async function main(): Promise<void> {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
...globalEnv,
|
||||
...settingsEnv, // Explicitly inject all settings env vars
|
||||
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
|
||||
...webSearchEnv,
|
||||
...imageAnalysisEnv,
|
||||
CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider
|
||||
@@ -1128,6 +1171,24 @@ async function main(): Promise<void> {
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||
};
|
||||
|
||||
if (resolvedTarget === 'claude') {
|
||||
const defaultContinuityInheritance = await resolveProfileContinuityInheritance({
|
||||
profileName: profileInfo.name,
|
||||
profileType: profileInfo.type,
|
||||
target: resolvedTarget,
|
||||
});
|
||||
if (defaultContinuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
|
||||
console.error(
|
||||
info(
|
||||
`Continuity inheritance active: profile "${profileInfo.name}" -> account "${defaultContinuityInheritance.sourceAccount}"`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (defaultContinuityInheritance.claudeConfigDir) {
|
||||
envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir;
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch through target adapter for non-claude targets
|
||||
if (resolvedTarget !== 'claude') {
|
||||
const adapter = targetAdapter;
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface ProxyChainConfig {
|
||||
};
|
||||
/** Composite variant: which tier is the default */
|
||||
compositeDefaultTier?: 'opus' | 'sonnet' | 'haiku';
|
||||
/** Optional inherited continuity directory from mapped account profile */
|
||||
claudeConfigDir?: string;
|
||||
}
|
||||
|
||||
const CODEX_EFFORT_SUFFIX_REGEX = /^(.*)-(xhigh|high|medium)$/i;
|
||||
@@ -113,6 +115,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
|
||||
isComposite,
|
||||
compositeTiers,
|
||||
compositeDefaultTier,
|
||||
claudeConfigDir,
|
||||
} = config;
|
||||
|
||||
// Build base env vars - check remote mode first
|
||||
@@ -264,6 +267,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
|
||||
const mergedEnv = {
|
||||
...baseEnv,
|
||||
...effectiveEnvVarsFiltered,
|
||||
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
|
||||
...webSearchEnv,
|
||||
...imageAnalysisEnv,
|
||||
CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider
|
||||
|
||||
@@ -53,6 +53,7 @@ import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unifi
|
||||
import { installImageAnalyzerHook } from '../../utils/hooks';
|
||||
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
||||
import { isKiroAuthMethod, KiroAuthMethod, normalizeKiroAuthMethod } from '../auth/auth-types';
|
||||
import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance';
|
||||
|
||||
// Import modular components
|
||||
import { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager';
|
||||
@@ -795,6 +796,21 @@ export async function execClaudeWithCLIProxy(
|
||||
// 9. Setup tool sanitization proxy
|
||||
let toolSanitizationProxy: ToolSanitizationProxy | null = null;
|
||||
let toolSanitizationPort: number | null = null;
|
||||
let inheritedClaudeConfigDir = cfg.claudeConfigDir;
|
||||
|
||||
if (!inheritedClaudeConfigDir && cfg.profileName) {
|
||||
const continuityInheritance = await resolveProfileContinuityInheritance({
|
||||
profileName: cfg.profileName,
|
||||
profileType: 'cliproxy',
|
||||
target: 'claude',
|
||||
});
|
||||
inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
|
||||
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
|
||||
log(
|
||||
`Continuity inheritance active: profile "${cfg.profileName}" -> account "${continuityInheritance.sourceAccount}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build initial env vars to get ANTHROPIC_BASE_URL
|
||||
const initialEnvVars = buildClaudeEnvironment({
|
||||
@@ -818,6 +834,7 @@ export async function execClaudeWithCLIProxy(
|
||||
isComposite: cfg.isComposite,
|
||||
compositeTiers: cfg.compositeTiers,
|
||||
compositeDefaultTier: cfg.compositeDefaultTier,
|
||||
claudeConfigDir: inheritedClaudeConfigDir,
|
||||
});
|
||||
|
||||
if (initialEnvVars.ANTHROPIC_BASE_URL) {
|
||||
@@ -913,6 +930,7 @@ export async function execClaudeWithCLIProxy(
|
||||
isComposite: cfg.isComposite,
|
||||
compositeTiers: cfg.compositeTiers,
|
||||
compositeDefaultTier: cfg.compositeDefaultTier,
|
||||
claudeConfigDir: inheritedClaudeConfigDir,
|
||||
});
|
||||
|
||||
if (cfg.isComposite && cfg.compositeTiers && cfg.compositeDefaultTier) {
|
||||
|
||||
@@ -197,6 +197,8 @@ export interface ExecutorConfig {
|
||||
compositeDefaultTier?: 'opus' | 'sonnet' | 'haiku';
|
||||
/** Original profile/variant name (e.g., "my-mix" for composite variants) */
|
||||
profileName?: string;
|
||||
/** Optional inherited continuity directory from mapped account profile */
|
||||
claudeConfigDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -155,6 +155,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
'Create account profile (supports shared groups + --deeper-continuity)',
|
||||
],
|
||||
['ccs config', 'Dashboard: Accounts table can edit context mode/group/continuity depth'],
|
||||
[
|
||||
'~/.ccs/config.yaml',
|
||||
'Optional: continuity.inherit_from_account maps API/CLIProxy/copilot/default profiles to an account context',
|
||||
],
|
||||
['ccs auth list', 'List all account profiles'],
|
||||
['ccs auth default <name>', 'Set default profile'],
|
||||
['ccs auth reset-default', 'Restore original CCS default'],
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
DashboardAuthConfig,
|
||||
ImageAnalysisConfig,
|
||||
CursorConfig,
|
||||
ContinuityConfig,
|
||||
} from './unified-config-types';
|
||||
import { validateCompositeTiers } from '../cliproxy/composite-validator';
|
||||
import { isUnifiedConfigEnabled } from './feature-flags';
|
||||
@@ -230,12 +231,58 @@ function validateCompositeVariants(config: UnifiedConfig): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize continuity inheritance mapping payload.
|
||||
* Keeps only non-empty string keys and values.
|
||||
*/
|
||||
function normalizeContinuityInheritanceMap(value: unknown): Record<string, string> | undefined {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [profileName, accountName] of Object.entries(value as Record<string, unknown>)) {
|
||||
const normalizedProfile = profileName.trim();
|
||||
const normalizedAccount = typeof accountName === 'string' ? accountName.trim() : '';
|
||||
|
||||
if (!normalizedProfile || !normalizedAccount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized[normalizedProfile] = normalizedAccount;
|
||||
}
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize continuity section.
|
||||
* Supports legacy root key: continuity_inherit_from_account.
|
||||
*/
|
||||
function normalizeContinuityConfig(partial: Partial<UnifiedConfig>): ContinuityConfig | undefined {
|
||||
const continuityMap = normalizeContinuityInheritanceMap(partial.continuity?.inherit_from_account);
|
||||
if (continuityMap) {
|
||||
return { inherit_from_account: continuityMap };
|
||||
}
|
||||
|
||||
const legacyMap = normalizeContinuityInheritanceMap(
|
||||
(partial as Partial<UnifiedConfig> & { continuity_inherit_from_account?: unknown })
|
||||
.continuity_inherit_from_account
|
||||
);
|
||||
if (legacyMap) {
|
||||
return { inherit_from_account: legacyMap };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge partial config with defaults.
|
||||
* Preserves existing data while filling in missing sections.
|
||||
*/
|
||||
function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
const defaults = createEmptyUnifiedConfig();
|
||||
const continuity = normalizeContinuityConfig(partial);
|
||||
return {
|
||||
version: partial.version ?? defaults.version,
|
||||
setup_completed: partial.setup_completed,
|
||||
@@ -328,6 +375,7 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
enabled: partial.global_env?.enabled ?? true,
|
||||
env: partial.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV },
|
||||
},
|
||||
continuity,
|
||||
// CLIProxy server config - remote/local CLIProxyAPI settings
|
||||
cliproxy_server: {
|
||||
remote: {
|
||||
@@ -650,6 +698,22 @@ function generateYamlWithComments(config: UnifiedConfig): string {
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Continuity inheritance section
|
||||
if (config.continuity?.inherit_from_account) {
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
lines.push('# Continuity Inheritance: Reuse account continuity artifacts across profiles');
|
||||
lines.push('# Map execution profile names to source account profiles (CLAUDE_CONFIG_DIR).');
|
||||
lines.push('# Applies to Claude target only; credentials remain profile-specific.');
|
||||
lines.push('# Example: continuity.inherit_from_account.glm: pro');
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
lines.push(
|
||||
yaml
|
||||
.dump({ continuity: config.continuity }, { indent: 2, lineWidth: -1, quotingType: '"' })
|
||||
.trim()
|
||||
);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Thinking section (extended thinking/reasoning configuration)
|
||||
if (config.thinking) {
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
@@ -958,6 +1022,15 @@ export function getGlobalEnvConfig(): GlobalEnvConfig {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get continuity inheritance mapping.
|
||||
* Returns empty mapping when not configured.
|
||||
*/
|
||||
export function getContinuityInheritanceMap(): Record<string, string> {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return config.continuity?.inherit_from_account ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cliproxy safety configuration.
|
||||
* Returns defaults if not configured.
|
||||
|
||||
@@ -418,6 +418,15 @@ export interface GlobalEnvConfig {
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-profile continuity inheritance configuration.
|
||||
* Maps execution profile names to source account profiles for CLAUDE_CONFIG_DIR reuse.
|
||||
*/
|
||||
export interface ContinuityConfig {
|
||||
/** Profile name -> source account profile name */
|
||||
inherit_from_account?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default global env vars for third-party profiles.
|
||||
* These disable Claude Code telemetry/reporting since we're using proxy.
|
||||
@@ -717,6 +726,8 @@ export interface UnifiedConfig {
|
||||
websearch?: WebSearchConfig;
|
||||
/** Global environment variables for all non-Claude subscription profiles */
|
||||
global_env?: GlobalEnvConfig;
|
||||
/** Cross-profile continuity inheritance mapping */
|
||||
continuity?: ContinuityConfig;
|
||||
/** Copilot API configuration (GitHub Copilot proxy) */
|
||||
copilot?: CopilotConfig;
|
||||
/** Cursor IDE configuration (Cursor proxy daemon) */
|
||||
|
||||
@@ -39,7 +39,10 @@ export async function getCopilotStatus(config: CopilotConfig): Promise<CopilotSt
|
||||
* Generate environment variables for Claude Code to use copilot-api.
|
||||
* Uses model mapping for opus/sonnet/haiku tiers if configured.
|
||||
*/
|
||||
export function generateCopilotEnv(config: CopilotConfig): Record<string, string> {
|
||||
export function generateCopilotEnv(
|
||||
config: CopilotConfig,
|
||||
claudeConfigDir?: string
|
||||
): Record<string, string> {
|
||||
// Use mapped models if configured, otherwise fall back to default model
|
||||
const opusModel = config.opus_model || config.model;
|
||||
const sonnetModel = config.sonnet_model || config.model;
|
||||
@@ -59,6 +62,7 @@ export function generateCopilotEnv(config: CopilotConfig): Record<string, string
|
||||
// Disable non-essential traffic to avoid rate limiting
|
||||
DISABLE_NON_ESSENTIAL_MODEL_CALLS: '1',
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
||||
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,7 +75,8 @@ export function generateCopilotEnv(config: CopilotConfig): Record<string, string
|
||||
*/
|
||||
export async function executeCopilotProfile(
|
||||
config: CopilotConfig,
|
||||
claudeArgs: string[]
|
||||
claudeArgs: string[],
|
||||
claudeConfigDir?: string
|
||||
): Promise<number> {
|
||||
// Ensure copilot-api is installed (auto-install if missing, auto-update if outdated)
|
||||
try {
|
||||
@@ -130,7 +135,7 @@ export async function executeCopilotProfile(
|
||||
}
|
||||
|
||||
// Generate environment for Claude
|
||||
const copilotEnv = generateCopilotEnv(config);
|
||||
const copilotEnv = generateCopilotEnv(config, claudeConfigDir);
|
||||
|
||||
// Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
|
||||
const globalEnvConfig = getGlobalEnvConfig();
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface Config {
|
||||
profile_targets?: Record<string, TargetType>;
|
||||
/** User-defined CLIProxy profile variants (optional) */
|
||||
cliproxy?: CLIProxyVariantsConfig;
|
||||
/** Legacy continuity inheritance mapping (profile -> source account) */
|
||||
continuity_inherit_from_account?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as configLoader from '../../../src/config/unified-config-loader';
|
||||
import ProfileRegistry from '../../../src/auth/profile-registry';
|
||||
import InstanceManager from '../../../src/management/instance-manager';
|
||||
import { resolveProfileContinuityInheritance } from '../../../src/auth/profile-continuity-inheritance';
|
||||
|
||||
describe('resolveProfileContinuityInheritance', () => {
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('returns no inheritance for non-claude targets', async () => {
|
||||
const result = await resolveProfileContinuityInheritance({
|
||||
profileName: 'glm',
|
||||
profileType: 'settings',
|
||||
target: 'droid',
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('resolves mapped account and ensures inherited instance path', async () => {
|
||||
spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({
|
||||
version: 8,
|
||||
continuity: {
|
||||
inherit_from_account: {
|
||||
glm: 'pro',
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof configLoader.loadOrCreateUnifiedConfig>);
|
||||
|
||||
const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue(
|
||||
'/tmp/.ccs/instances/pro'
|
||||
);
|
||||
const getProfilesSpy = spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue(
|
||||
{
|
||||
pro: {
|
||||
type: 'account',
|
||||
created: '2026-03-01T00:00:00.000Z',
|
||||
last_used: null,
|
||||
context_mode: 'shared',
|
||||
context_group: 'Team Alpha',
|
||||
continuity_mode: 'deeper',
|
||||
},
|
||||
}
|
||||
);
|
||||
const hasUnifiedSpy = spyOn(ProfileRegistry.prototype, 'hasAccountUnified').mockReturnValue(true);
|
||||
const touchUnifiedSpy = spyOn(ProfileRegistry.prototype, 'touchAccountUnified').mockImplementation(
|
||||
() => undefined
|
||||
);
|
||||
const touchLegacySpy = spyOn(ProfileRegistry.prototype, 'touchProfile').mockImplementation(
|
||||
() => undefined
|
||||
);
|
||||
|
||||
const result = await resolveProfileContinuityInheritance({
|
||||
profileName: 'glm',
|
||||
profileType: 'settings',
|
||||
target: 'claude',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceAccount: 'pro',
|
||||
claudeConfigDir: '/tmp/.ccs/instances/pro',
|
||||
});
|
||||
expect(getProfilesSpy).toHaveBeenCalledTimes(1);
|
||||
expect(hasUnifiedSpy).toHaveBeenCalledWith('pro');
|
||||
expect(touchUnifiedSpy).toHaveBeenCalledWith('pro');
|
||||
expect(touchLegacySpy).not.toHaveBeenCalled();
|
||||
expect(ensureInstanceSpy).toHaveBeenCalledWith('pro', {
|
||||
mode: 'shared',
|
||||
group: 'team-alpha',
|
||||
continuityMode: 'deeper',
|
||||
});
|
||||
});
|
||||
|
||||
it('supports legacy continuity_inherit_from_account fallback', async () => {
|
||||
spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({
|
||||
version: 8,
|
||||
} as ReturnType<typeof configLoader.loadOrCreateUnifiedConfig>);
|
||||
spyOn(configLoader, 'isUnifiedMode').mockReturnValue(false);
|
||||
spyOn(configLoader, 'getConfigJsonPath').mockReturnValue('/tmp/ccs-test-config.json');
|
||||
spyOn(fs, 'existsSync').mockImplementation((filePath) => {
|
||||
return filePath === '/tmp/ccs-test-config.json';
|
||||
});
|
||||
spyOn(fs, 'readFileSync').mockImplementation((filePath) => {
|
||||
if (filePath === '/tmp/ccs-test-config.json') {
|
||||
return JSON.stringify({
|
||||
continuity_inherit_from_account: {
|
||||
copilot: 'work',
|
||||
},
|
||||
});
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
|
||||
work: {
|
||||
type: 'account',
|
||||
created: '2026-03-01T00:00:00.000Z',
|
||||
last_used: null,
|
||||
},
|
||||
});
|
||||
spyOn(ProfileRegistry.prototype, 'hasAccountUnified').mockReturnValue(false);
|
||||
spyOn(ProfileRegistry.prototype, 'hasProfile').mockReturnValue(true);
|
||||
const touchLegacySpy = spyOn(ProfileRegistry.prototype, 'touchProfile').mockImplementation(
|
||||
() => undefined
|
||||
);
|
||||
spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue('/tmp/.ccs/instances/work');
|
||||
|
||||
const result = await resolveProfileContinuityInheritance({
|
||||
profileName: 'copilot',
|
||||
profileType: 'copilot',
|
||||
target: 'claude',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceAccount: 'work',
|
||||
claudeConfigDir: '/tmp/.ccs/instances/work',
|
||||
});
|
||||
expect(touchLegacySpy).toHaveBeenCalledWith('work');
|
||||
});
|
||||
|
||||
it('returns empty result when mapped source account does not exist', async () => {
|
||||
spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({
|
||||
version: 8,
|
||||
continuity: {
|
||||
inherit_from_account: {
|
||||
glm: 'missing',
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof configLoader.loadOrCreateUnifiedConfig>);
|
||||
|
||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({});
|
||||
const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance');
|
||||
|
||||
const result = await resolveProfileContinuityInheritance({
|
||||
profileName: 'glm',
|
||||
profileType: 'settings',
|
||||
target: 'claude',
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(ensureInstanceSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not read legacy config fallback when unified mode is active', async () => {
|
||||
spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({
|
||||
version: 8,
|
||||
} as ReturnType<typeof configLoader.loadOrCreateUnifiedConfig>);
|
||||
spyOn(configLoader, 'isUnifiedMode').mockReturnValue(true);
|
||||
spyOn(configLoader, 'getConfigJsonPath').mockReturnValue('/tmp/ccs-test-config.json');
|
||||
const existsSpy = spyOn(fs, 'existsSync').mockImplementation((filePath) => {
|
||||
return filePath === '/tmp/ccs-test-config.json';
|
||||
});
|
||||
const readSpy = spyOn(fs, 'readFileSync').mockImplementation((filePath) => {
|
||||
if (filePath === '/tmp/ccs-test-config.json') {
|
||||
return JSON.stringify({
|
||||
continuity_inherit_from_account: {
|
||||
glm: 'pro',
|
||||
},
|
||||
});
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({});
|
||||
const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance');
|
||||
|
||||
const result = await resolveProfileContinuityInheritance({
|
||||
profileName: 'glm',
|
||||
profileType: 'settings',
|
||||
target: 'claude',
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(ensureInstanceSpy).not.toHaveBeenCalled();
|
||||
expect(existsSpy).not.toHaveBeenCalled();
|
||||
expect(readSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('supports default profile inheritance when explicitly mapped', async () => {
|
||||
spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({
|
||||
version: 8,
|
||||
continuity: {
|
||||
inherit_from_account: {
|
||||
default: 'pro',
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof configLoader.loadOrCreateUnifiedConfig>);
|
||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
|
||||
pro: {
|
||||
type: 'account',
|
||||
created: '2026-03-01T00:00:00.000Z',
|
||||
last_used: null,
|
||||
},
|
||||
});
|
||||
spyOn(ProfileRegistry.prototype, 'hasAccountUnified').mockReturnValue(true);
|
||||
spyOn(ProfileRegistry.prototype, 'touchAccountUnified').mockImplementation(() => undefined);
|
||||
spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue('/tmp/.ccs/instances/pro');
|
||||
|
||||
const result = await resolveProfileContinuityInheritance({
|
||||
profileName: 'default',
|
||||
profileType: 'default',
|
||||
target: 'claude',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceAccount: 'pro',
|
||||
claudeConfigDir: '/tmp/.ccs/instances/pro',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -92,4 +92,24 @@ describe('buildClaudeEnvironment codex fallback normalization', () => {
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini-medium');
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9444/api/provider/codex');
|
||||
});
|
||||
|
||||
it('propagates inherited CLAUDE_CONFIG_DIR when provided', () => {
|
||||
const settingsPath = createCodexSettingsFile({
|
||||
defaultModel: 'gpt-5.3-codex',
|
||||
opusModel: 'gpt-5.3-codex',
|
||||
sonnetModel: 'gpt-5.3-codex',
|
||||
haikuModel: 'gpt-5-mini',
|
||||
});
|
||||
|
||||
const env = buildClaudeEnvironment({
|
||||
provider: 'codex',
|
||||
useRemoteProxy: false,
|
||||
localPort: 8317,
|
||||
customSettingsPath: settingsPath,
|
||||
claudeConfigDir: '/tmp/.ccs/instances/pro',
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
expect(env.CLAUDE_CONFIG_DIR).toBe('/tmp/.ccs/instances/pro');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { generateCopilotEnv } from '../../../src/copilot/copilot-executor';
|
||||
import type { CopilotConfig } from '../../../src/config/unified-config-types';
|
||||
|
||||
const baseConfig: CopilotConfig = {
|
||||
enabled: true,
|
||||
auto_start: false,
|
||||
port: 4141,
|
||||
account_type: 'individual',
|
||||
rate_limit: null,
|
||||
wait_on_limit: true,
|
||||
model: 'gpt-4.1',
|
||||
};
|
||||
|
||||
describe('generateCopilotEnv', () => {
|
||||
it('includes inherited CLAUDE_CONFIG_DIR when provided', () => {
|
||||
const env = generateCopilotEnv(baseConfig, '/tmp/.ccs/instances/pro');
|
||||
expect(env.CLAUDE_CONFIG_DIR).toBe('/tmp/.ccs/instances/pro');
|
||||
});
|
||||
|
||||
it('omits CLAUDE_CONFIG_DIR when inheritance is not configured', () => {
|
||||
const env = generateCopilotEnv(baseConfig);
|
||||
expect(env.CLAUDE_CONFIG_DIR).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
* Unit tests for unified config module
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
// Import only modules without transitive dependencies on config-manager
|
||||
import {
|
||||
@@ -15,6 +18,7 @@ import {
|
||||
UNIFIED_CONFIG_VERSION,
|
||||
} from '../../src/config/unified-config-types';
|
||||
import { isUnifiedConfigEnabled } from '../../src/config/feature-flags';
|
||||
import { loadOrCreateUnifiedConfig } from '../../src/config/unified-config-loader';
|
||||
|
||||
// Inline helper to test secret key detection (utility kept for potential reuse)
|
||||
function isSecretKey(key: string): boolean {
|
||||
@@ -188,3 +192,38 @@ describe('feature-flags', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('continuity-inheritance-config', () => {
|
||||
it('normalizes legacy continuity_inherit_from_account into continuity.inherit_from_account', () => {
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-continuity-home-'));
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'continuity_inherit_from_account:',
|
||||
' glm: pro',
|
||||
' empty: ""',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
process.env.CCS_HOME = tempHome;
|
||||
try {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
expect(config.continuity?.inherit_from_account).toEqual({
|
||||
glm: 'pro',
|
||||
});
|
||||
} finally {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user