diff --git a/README.md b/README.md index e7490324..aa3bbcff 100644 --- a/README.md +++ b/README.md @@ -522,6 +522,29 @@ accounts: Shared context with `standard` depth links project workspace data. `deeper` depth links additional continuity artifacts. Credentials remain isolated per account. +Resume is lane-scoped: + +- plain `ccs -r` resumes the lane that plain `ccs` currently uses +- `ccs -r` resumes that account's lane only +- those lanes can differ, even when an account is `shared + deeper` + +If you do most of your work with plain `ccs` and want future resumes to line up with an auth account: + +```bash +ccs auth default work +``` + +If you need to protect local continuity files before changing account sync settings: + +```bash +ccs auth backup work +ccs auth backup default +``` + +- `ccs auth backup work` backs up the local continuity lane for that auth account +- `ccs auth backup default` backs up the lane plain `ccs` would use right now +- this backs up local continuity artifacts only; Claude-hosted resume behavior still depends on upstream state + #### 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: @@ -532,6 +555,7 @@ continuity: glm: pro gemini: pro copilot: pro + default: 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. diff --git a/docs/session-sharing-technical-analysis.md b/docs/session-sharing-technical-analysis.md index a42b7dff..551f6b42 100644 --- a/docs/session-sharing-technical-analysis.md +++ b/docs/session-sharing-technical-analysis.md @@ -1,6 +1,6 @@ # Session Sharing Technical Analysis -Last Updated: 2026-02-26 +Last Updated: 2026-04-04 ## Summary @@ -69,6 +69,30 @@ Behavior: - Reuses `CLAUDE_CONFIG_DIR` from mapped account profile after normal account context policy resolution - Invalid/missing mapped accounts are skipped safely +### Resume Lane Note + +Resume follows the active `CLAUDE_CONFIG_DIR`, not just the continuity group: + +- plain `ccs -r` resumes the lane plain `ccs` is using right now +- `ccs -r` resumes only that account lane +- those two commands can point at different continuity inventories + +That means `shared + deeper` on an account does **not** automatically make old plain-`ccs` resume history appear inside `ccs -r`. + +If you want future plain `ccs` sessions to use an account lane, either: + +```bash +ccs auth default work +``` + +or map the default profile explicitly: + +```yaml +continuity: + inherit_from_account: + default: work +``` + ## User Workflows ### New account with shared context @@ -88,6 +112,19 @@ ccs auth create backup2 --context-group sprint-a --deeper-continuity No account recreation required for this workflow. +### Backup Before Changing Sync + +CCS can back up local continuity artifacts before you change settings: + +```bash +ccs auth backup work +ccs auth backup default +``` + +- `ccs auth backup work` backs up the selected account lane +- `ccs auth backup default` backs up the lane plain `ccs` would use right now +- this is a local continuity backup, not a guaranteed export of all upstream Claude-hosted resume state + ## Current Limitations - Shared context is local filesystem sharing. It does not bypass remote provider permission models. diff --git a/src/auth/auth-commands.ts b/src/auth/auth-commands.ts index 85458288..6f757e1a 100644 --- a/src/auth/auth-commands.ts +++ b/src/auth/auth-commands.ts @@ -21,6 +21,7 @@ import packageJson from '../../package.json'; import { type CommandContext, handleCreate, + handleBackup, handleList, handleShow, handleRemove, @@ -68,6 +69,9 @@ class AuthCommands { console.log(''); console.log(subheader('Commands')); console.log(` ${color('create ', 'command')} Create new profile and login`); + console.log( + ` ${color('backup ', 'command')} Backup local continuity for an account` + ); console.log(` ${color('list', 'command')} List all saved profiles`); console.log(` ${color('show ', 'command')} Show profile details`); console.log(` ${color('remove ', 'command')} Remove saved profile`); @@ -97,6 +101,12 @@ class AuthCommands { console.log(` ${dim('# Set work as default')}`); console.log(` ${color('ccs auth default work', 'command')}`); console.log(''); + console.log(` ${dim('# Backup the local continuity lane for an account or plain ccs')}`); + console.log(` ${color('ccs auth backup work', 'command')}`); + console.log( + ` ${color('ccs auth backup default', 'command')} ${dim('# backup plain ccs lane')}` + ); + console.log(''); console.log(` ${dim('# Restore original CCS behavior')}`); console.log(` ${color('ccs auth reset-default', 'command')}`); console.log(''); @@ -162,6 +172,10 @@ class AuthCommands { return handleCreate(this.getContext(), args); } + async handleBackup(args: string[]): Promise { + return handleBackup(this.getContext(), args); + } + /** * List all profiles - delegates to list-command.ts */ @@ -227,6 +241,10 @@ class AuthCommands { await this.handleList(commandArgs); break; + case 'backup': + await this.handleBackup(commandArgs); + break; + case 'show': await this.handleShow(commandArgs); break; diff --git a/src/auth/commands/backup-command.ts b/src/auth/commands/backup-command.ts new file mode 100644 index 00000000..4a682367 --- /dev/null +++ b/src/auth/commands/backup-command.ts @@ -0,0 +1,139 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { initUI, color, dim, fail, info, ok } from '../../utils/ui'; +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import { + createTimestampStamp, + getAuthBackupRoot, + getContinuityArtifactNames, + resolveRuntimePlainCcsResumeLane, +} from '../resume-lane-diagnostics'; +import { isAccountContextMetadata, resolveAccountContextPolicy } from '../account-context'; +import { CommandContext, parseArgs } from './types'; + +interface BackupManifest { + target: string; + source_config_dir: string; + created_at: string; + copied: string[]; + skipped: string[]; +} + +function copyDirectoryIfPresent(sourcePath: string, targetPath: string): boolean { + if (!fs.existsSync(sourcePath)) { + return false; + } + + fs.cpSync(sourcePath, targetPath, { + recursive: true, + dereference: true, + force: false, + errorOnExist: false, + }); + return true; +} + +export async function handleBackup(ctx: CommandContext, args: string[]): Promise { + await initUI(); + const { profileName, json } = parseArgs(args); + + if (!profileName) { + console.log(fail('Profile name is required')); + console.log(''); + console.log(`Usage: ${color('ccs auth backup [--json]', 'command')}`); + exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); + } + + try { + let sourceConfigDir = ''; + let backupLabel = profileName; + let artifactNames: string[] = []; + + if (profileName === 'default') { + const lane = await resolveRuntimePlainCcsResumeLane(); + sourceConfigDir = lane.configDir; + backupLabel = lane.accountName ? `default-${lane.accountName}` : 'default'; + artifactNames = getContinuityArtifactNames('default'); + } else { + const profiles = ctx.registry.getAllProfilesMerged(); + const profile = profiles[profileName]; + if (!profile || profile.type !== 'account') { + exitWithError( + `Backup supports auth accounts or "default". Not found: ${profileName}`, + ExitCode.PROFILE_ERROR + ); + } + + const contextPolicy = resolveAccountContextPolicy( + isAccountContextMetadata(profile) ? profile : undefined + ); + sourceConfigDir = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, { + bare: profile.bare === true, + }); + artifactNames = getContinuityArtifactNames('account'); + } + + const backupRoot = path.join(getAuthBackupRoot(), backupLabel, createTimestampStamp()); + fs.mkdirSync(backupRoot, { recursive: true, mode: 0o700 }); + + const copied: string[] = []; + const skipped: string[] = []; + + for (const artifactName of artifactNames) { + const sourcePath = path.join(sourceConfigDir, artifactName); + const targetPath = path.join(backupRoot, artifactName); + if (copyDirectoryIfPresent(sourcePath, targetPath)) { + copied.push(artifactName); + } else { + skipped.push(artifactName); + } + } + + const manifest: BackupManifest = { + target: profileName, + source_config_dir: sourceConfigDir, + created_at: new Date().toISOString(), + copied, + skipped, + }; + fs.writeFileSync( + path.join(backupRoot, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + { + encoding: 'utf8', + mode: 0o600, + } + ); + + if (json) { + console.log( + JSON.stringify( + { + target: profileName, + backup_path: backupRoot, + copied, + skipped, + }, + null, + 2 + ) + ); + return; + } + + console.log(ok(`Continuity backup created: ${backupRoot}`)); + console.log(''); + console.log(info(`Source lane: ${sourceConfigDir}`)); + console.log(` ${dim(`Copied: ${copied.length > 0 ? copied.join(', ') : 'none'}`)}`); + if (skipped.length > 0) { + console.log(` ${dim(`Skipped: ${skipped.join(', ')}`)}`); + } + console.log(''); + } catch (error) { + exitWithError( + `Failed to create continuity backup: ${(error as Error).message}`, + ExitCode.GENERAL_ERROR + ); + } +} diff --git a/src/auth/commands/index.ts b/src/auth/commands/index.ts index 67a11be5..81d55688 100644 --- a/src/auth/commands/index.ts +++ b/src/auth/commands/index.ts @@ -16,6 +16,7 @@ export { // Command handlers export { handleCreate } from './create-command'; +export { handleBackup } from './backup-command'; export { handleList } from './list-command'; export { handleShow } from './show-command'; export { handleRemove } from './remove-command'; diff --git a/src/auth/profile-continuity-inheritance.ts b/src/auth/profile-continuity-inheritance.ts index 7ccded76..b2a775b3 100644 --- a/src/auth/profile-continuity-inheritance.ts +++ b/src/auth/profile-continuity-inheritance.ts @@ -100,6 +100,19 @@ function resolveMappedAccount( return undefined; } +export function resolveConfiguredContinuitySourceAccount( + profileName: string, + profileType: ProfileType +): string | undefined { + const inheritFromAccount = getContinuityInheritanceMap(); + return ( + resolveMappedAccount(profileName, profileType, inheritFromAccount) ?? + (!isUnifiedMode() + ? resolveMappedAccount(profileName, profileType, loadLegacyContinuityInheritanceMap()) + : undefined) + ); +} + /** * Resolve optional cross-profile continuity inheritance. * @@ -115,16 +128,10 @@ export async function resolveProfileContinuityInheritance( return {}; } - const inheritFromAccount = getContinuityInheritanceMap(); - const sourceAccount = - resolveMappedAccount(input.profileName, input.profileType, inheritFromAccount) ?? - (!isUnifiedMode() - ? resolveMappedAccount( - input.profileName, - input.profileType, - loadLegacyContinuityInheritanceMap() - ) - : undefined); + const sourceAccount = resolveConfiguredContinuitySourceAccount( + input.profileName, + input.profileType + ); if (!sourceAccount) { return {}; } diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index a67f8d98..bc7fbab1 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -462,6 +462,14 @@ class ProfileDetector { }; } + /** + * Public wrapper for diagnostics and tooling that need to inspect how + * plain `ccs` resolves without duplicating the default-profile logic. + */ + resolveDefaultProfileResult(): ProfileDetectionResult { + return this.resolveDefaultProfile(); + } + /** * List available profiles (for error messages) */ diff --git a/src/auth/resume-lane-diagnostics.ts b/src/auth/resume-lane-diagnostics.ts new file mode 100644 index 00000000..4919f157 --- /dev/null +++ b/src/auth/resume-lane-diagnostics.ts @@ -0,0 +1,163 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { getDefaultClaudeConfigDir } from '../utils/claude-config-path'; +import { getCcsDir } from '../utils/config-manager'; +import InstanceManager from '../management/instance-manager'; +import ProfileDetector from './profile-detector'; +import { resolveConfiguredContinuitySourceAccount } from './profile-continuity-inheritance'; +import type { ProfileType } from '../types/profile'; + +export type ResumeLaneKind = + | 'native' + | 'account-default' + | 'account-inherited' + | 'profile-default' + | 'ambient'; + +export interface ResumeLaneSummary { + kind: ResumeLaneKind; + label: string; + configDir: string; + accountName?: string; + profileName?: string; + projectCount: number; +} + +export interface ResumeFlagIntent { + implicit: boolean; + explicitSessionId?: string; +} + +function countTopLevelProjectDirs(projectsDir: string): number { + try { + return fs + .readdirSync(projectsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()).length; + } catch { + return 0; + } +} + +function resolveNativeLaneSummary( + kind: ResumeLaneKind = 'native', + profileName?: string +): ResumeLaneSummary { + const configDir = getDefaultClaudeConfigDir(); + const label = + kind === 'profile-default' && profileName + ? `profile "${profileName}" via native Claude lane` + : 'native Claude lane'; + + return { + kind, + label, + configDir, + profileName, + projectCount: countTopLevelProjectDirs(path.join(configDir, 'projects')), + }; +} + +function resolveAccountLaneSummary( + kind: Extract, + accountName: string +): ResumeLaneSummary { + const instanceMgr = new InstanceManager(); + const configDir = instanceMgr.getInstancePath(accountName); + + return { + kind, + label: + kind === 'account-inherited' + ? `plain ccs inherits from account "${accountName}"` + : `plain ccs defaults to account "${accountName}"`, + configDir, + accountName, + projectCount: countTopLevelProjectDirs(path.join(configDir, 'projects')), + }; +} + +export async function resolveConfiguredPlainCcsResumeLane(): Promise { + const detector = new ProfileDetector(); + const defaultProfile = detector.resolveDefaultProfileResult(); + + if (defaultProfile.type === 'account') { + return resolveAccountLaneSummary('account-default', defaultProfile.name); + } + + const inheritedAccount = resolveConfiguredContinuitySourceAccount( + defaultProfile.name, + defaultProfile.type + ); + if (inheritedAccount) { + return resolveAccountLaneSummary('account-inherited', inheritedAccount); + } + + if (defaultProfile.type !== 'default') { + return resolveNativeLaneSummary('profile-default', defaultProfile.name); + } + + return resolveNativeLaneSummary(); +} + +export async function resolveRuntimePlainCcsResumeLane(): Promise { + if (process.env.CLAUDE_CONFIG_DIR) { + const configDir = path.resolve(process.env.CLAUDE_CONFIG_DIR); + return { + kind: 'ambient', + label: 'current shell CLAUDE_CONFIG_DIR', + configDir, + projectCount: countTopLevelProjectDirs(path.join(configDir, 'projects')), + }; + } + + return resolveConfiguredPlainCcsResumeLane(); +} + +export function parseResumeFlagIntent(args: string[]): ResumeFlagIntent | null { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '-r') { + return { implicit: true }; + } + + if (arg === '--resume') { + const next = args[index + 1]; + if (next && !next.startsWith('-')) { + return { implicit: false, explicitSessionId: next }; + } + return { implicit: true }; + } + + if (arg.startsWith('--resume=')) { + const sessionId = arg.slice('--resume='.length).trim(); + return sessionId ? { implicit: false, explicitSessionId: sessionId } : { implicit: true }; + } + } + + return null; +} + +export function getAuthBackupRoot(): string { + return path.join(getCcsDir(), 'backups', 'auth-continuity'); +} + +export function createTimestampStamp(date = new Date()): string { + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + const hh = String(date.getHours()).padStart(2, '0'); + const min = String(date.getMinutes()).padStart(2, '0'); + const ss = String(date.getSeconds()).padStart(2, '0'); + return `${yyyy}${mm}${dd}_${hh}${min}${ss}`; +} + +export function getContinuityArtifactNames(profileType: ProfileType): string[] { + const sharedItems = ['projects']; + const deeperItems = ['session-env', 'file-history', 'shell-snapshots', 'todos']; + + if (profileType === 'default' || profileType === 'account') { + return [...sharedItems, ...deeperItems]; + } + + return sharedItems; +} diff --git a/src/ccs.ts b/src/ccs.ts index bdd8487e..9618da71 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -1,6 +1,7 @@ import './utils/fetch-proxy-setup'; import * as fs from 'fs'; +import * as path from 'path'; import { detectClaudeCli } from './utils/claude-detector'; import { getSettingsPath, @@ -64,6 +65,10 @@ import { tryHandleRootCommand } from './commands/root-command-router'; // Import extracted utility functions import { execClaude } from './utils/shell-executor'; import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation'; +import { + parseResumeFlagIntent, + resolveRuntimePlainCcsResumeLane, +} from './auth/resume-lane-diagnostics'; // Import target adapter system import { @@ -259,6 +264,43 @@ function shouldPassthroughNativeCodexFlagCommand(args: string[]): boolean { return getNativeCodexPassthroughArgs(args) !== null; } +async function maybeWarnAboutResumeLaneMismatch( + profileName: string, + accountConfigDir: string, + args: string[] +): Promise { + const resumeIntent = parseResumeFlagIntent(args); + if (!resumeIntent) { + return; + } + + const plainLane = await resolveRuntimePlainCcsResumeLane(); + if (path.resolve(plainLane.configDir) === path.resolve(accountConfigDir)) { + return; + } + + console.error( + warn( + `Resume for account "${profileName}" will search that account lane, not the current plain ccs lane.` + ) + ); + console.error(info(` Account lane: ${accountConfigDir}`)); + console.error(info(` Plain ccs lane: ${plainLane.label} (${plainLane.configDir})`)); + if (resumeIntent.explicitSessionId) { + console.error( + info( + ` This explicit session ID may have been created in a different lane, so Claude may not find it here.` + ) + ); + } + console.error(info(` Recover the original lane first: ccs -r`)); + console.error(info(` Back it up before changing setup: ccs auth backup default`)); + console.error( + info(` For future work, align plain ccs with this account: ccs auth default ${profileName}`) + ); + console.error(''); +} + function getNativeCodexPassthroughArgs(args: string[]): string[] | null { const targetArgs = stripTargetFlag(args); if (resolveTargetType(args) !== 'codex' || targetArgs.length === 0) { @@ -1269,6 +1311,7 @@ async function main(): Promise { CCS_WEBSEARCH_SKIP: '1', CCS_IMAGE_ANALYSIS_SKIP: '1', }; + await maybeWarnAboutResumeLaneMismatch(profileInfo.name, instancePath, remainingArgs); const launchArgs = resolveNativeClaudeLaunchArgs(remainingArgs, 'account', instancePath); execClaude(claudeCli, launchArgs, envVars); } else { diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index 1ca8d7f1..28bf315d 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -215,6 +215,7 @@ export const ROOT_COMMAND_FLAGS = [ ] as const; export const AUTH_SUBCOMMANDS = [ 'create', + 'backup', 'list', 'show', 'remove', diff --git a/src/commands/completion-backend.ts b/src/commands/completion-backend.ts index 04cbdc83..4399d9c0 100644 --- a/src/commands/completion-backend.ts +++ b/src/commands/completion-backend.ts @@ -94,6 +94,8 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg return completeSubcommands(ROOT_HELP_TOPICS.map((topic) => topic.name)); case 'auth': if (!subcommand) return completeSubcommands(AUTH_SUBCOMMANDS); + if (subcommand === 'backup') + return completeSubcommands(['default', ...getProfileNames('accounts')], ['--json']); if (subcommand === 'show') return completeSubcommands(getProfileNames('accounts'), ['--json']); if (subcommand === 'remove') diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index cd98a259..07137831 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -32,6 +32,7 @@ import { type MergedAccountEntry, } from './account-route-helpers'; import type { AccountConfig } from '../../config/unified-config-types'; +import { resolveConfiguredPlainCcsResumeLane } from '../../auth/resume-lane-diagnostics'; const router = Router(); @@ -59,7 +60,7 @@ function hasAuthAccount(name: string): boolean { /** * GET /api/accounts - List accounts from both profiles.json and config.yaml */ -router.get('/', (_req: Request, res: Response): void => { +router.get('/', async (_req: Request, res: Response): Promise => { try { const registry = createProfileRegistry(); @@ -147,8 +148,19 @@ router.get('/', (_req: Request, res: Response): void => { // Get default from unified config first, fallback to legacy const defaultProfile = registry.getDefaultUnified() ?? registry.getDefaultProfile() ?? null; + const plainCcsLane = await resolveConfiguredPlainCcsResumeLane(); - res.json({ accounts, default: defaultProfile }); + res.json({ + accounts, + default: defaultProfile, + plain_ccs_lane: { + kind: plainCcsLane.kind, + label: plainCcsLane.label, + account_name: plainCcsLane.accountName ?? null, + profile_name: plainCcsLane.profileName ?? null, + project_count: plainCcsLane.projectCount, + }, + }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } diff --git a/tests/unit/auth/backup-command.test.ts b/tests/unit/auth/backup-command.test.ts new file mode 100644 index 00000000..476b7509 --- /dev/null +++ b/tests/unit/auth/backup-command.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import ProfileRegistry from '../../../src/auth/profile-registry'; +import { InstanceManager } from '../../../src/management/instance-manager'; +import { handleBackup } from '../../../src/auth/commands/backup-command'; + +describe('auth backup command', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalUnified: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auth-backup-')); + originalCcsHome = process.env.CCS_HOME; + originalUnified = process.env.CCS_UNIFIED_CONFIG; + process.env.CCS_HOME = tempHome; + process.env.CCS_UNIFIED_CONFIG = '1'; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalUnified; + else delete process.env.CCS_UNIFIED_CONFIG; + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + function writeConfig(): void { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'accounts:', + ' work:', + ' created: "2026-04-04T00:00:00.000Z"', + ' context_mode: shared', + ' context_group: default', + ' continuity_mode: deeper', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + } + + it('creates a JSON backup for an auth account continuity lane', async () => { + writeConfig(); + const instanceMgr = new InstanceManager(); + const registry = new ProfileRegistry(); + const instancePath = await instanceMgr.ensureInstance('work', { + mode: 'shared', + group: 'default', + continuityMode: 'deeper', + }); + + fs.mkdirSync(path.join(instancePath, 'projects', 'demo-project'), { recursive: true }); + fs.writeFileSync( + path.join(instancePath, 'projects', 'demo-project', 'history.jsonl'), + '{}\n', + 'utf8' + ); + fs.writeFileSync(path.join(instancePath, 'todos', 'todo.md'), '- keep this\n', 'utf8'); + + const consoleSpy = spyOn(console, 'log').mockImplementation(() => {}); + try { + await handleBackup( + { + registry, + instanceMgr, + version: 'test', + }, + ['work', '--json'] + ); + } finally { + consoleSpy.mockRestore(); + } + + const backupBase = path.join(tempHome, '.ccs', 'backups', 'auth-continuity', 'work'); + const [latestBackupDir] = fs.readdirSync(backupBase).sort().reverse(); + expect(latestBackupDir).toBeTruthy(); + const backupPath = path.join(backupBase, latestBackupDir as string); + expect(fs.existsSync(path.join(backupPath, 'manifest.json'))).toBe(true); + expect( + fs.existsSync(path.join(backupPath, 'projects', 'demo-project', 'history.jsonl')) + ).toBe(true); + expect(fs.existsSync(path.join(backupPath, 'todos', 'todo.md'))).toBe(true); + }); +}); diff --git a/tests/unit/auth/resume-lane-diagnostics.test.ts b/tests/unit/auth/resume-lane-diagnostics.test.ts new file mode 100644 index 00000000..938455b8 --- /dev/null +++ b/tests/unit/auth/resume-lane-diagnostics.test.ts @@ -0,0 +1,143 @@ +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 { + parseResumeFlagIntent, + resolveConfiguredPlainCcsResumeLane, +} from '../../../src/auth/resume-lane-diagnostics'; + +describe('resume lane diagnostics', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalUnified: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-resume-lane-')); + originalCcsHome = process.env.CCS_HOME; + originalUnified = process.env.CCS_UNIFIED_CONFIG; + process.env.CCS_HOME = tempHome; + process.env.CCS_UNIFIED_CONFIG = '1'; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalUnified; + else delete process.env.CCS_UNIFIED_CONFIG; + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + function writeConfig(lines: string[]): void { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync(path.join(ccsDir, 'config.yaml'), `${lines.join('\n')}\n`, 'utf8'); + } + + it('parses implicit and explicit resume flags', () => { + expect(parseResumeFlagIntent(['-r'])).toEqual({ implicit: true }); + expect(parseResumeFlagIntent(['--resume'])).toEqual({ implicit: true }); + expect(parseResumeFlagIntent(['--resume', 'abc123'])).toEqual({ + implicit: false, + explicitSessionId: 'abc123', + }); + expect(parseResumeFlagIntent(['--resume=xyz'])).toEqual({ + implicit: false, + explicitSessionId: 'xyz', + }); + expect(parseResumeFlagIntent(['hello'])).toBeNull(); + }); + + it('defaults to the native lane when no account/default mapping exists', async () => { + writeConfig([ + 'version: 12', + 'accounts: {}', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ]); + + const lane = await resolveConfiguredPlainCcsResumeLane(); + expect(lane.kind).toBe('native'); + expect(lane.configDir).toBe(path.join(tempHome, '.claude')); + }); + + it('resolves the plain ccs lane to a default account when configured', async () => { + writeConfig([ + 'version: 12', + 'default: work', + 'accounts:', + ' work:', + ' created: "2026-04-04T00:00:00.000Z"', + ' context_mode: shared', + ' context_group: default', + ' continuity_mode: deeper', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ]); + + const lane = await resolveConfiguredPlainCcsResumeLane(); + expect(lane.kind).toBe('account-default'); + expect(lane.accountName).toBe('work'); + expect(lane.configDir).toBe(path.join(tempHome, '.ccs', 'instances', 'work')); + }); + + it('resolves the plain ccs lane to inherited account continuity when configured', async () => { + writeConfig([ + 'version: 12', + 'accounts:', + ' work:', + ' created: "2026-04-04T00:00:00.000Z"', + ' context_mode: shared', + ' context_group: default', + ' continuity_mode: deeper', + 'profiles: {}', + 'continuity:', + ' inherit_from_account:', + ' default: work', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ]); + + const lane = await resolveConfiguredPlainCcsResumeLane(); + expect(lane.kind).toBe('account-inherited'); + expect(lane.accountName).toBe('work'); + expect(lane.configDir).toBe(path.join(tempHome, '.ccs', 'instances', 'work')); + }); + + it('resolves a non-account default profile through continuity inheritance', async () => { + writeConfig([ + 'version: 12', + 'default: glm', + 'accounts:', + ' work:', + ' created: "2026-04-04T00:00:00.000Z"', + 'profiles:', + ' glm:', + ' type: api', + ' settings: ~/.ccs/glm.settings.json', + 'continuity:', + ' inherit_from_account:', + ' glm: work', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ]); + + const lane = await resolveConfiguredPlainCcsResumeLane(); + expect(lane.kind).toBe('account-inherited'); + expect(lane.accountName).toBe('work'); + }); +}); diff --git a/tests/unit/commands/completion-backend.test.ts b/tests/unit/commands/completion-backend.test.ts index a864c290..5bb12d0a 100644 --- a/tests/unit/commands/completion-backend.test.ts +++ b/tests/unit/commands/completion-backend.test.ts @@ -15,7 +15,9 @@ function suggestionValues( current = '', shell: 'bash' | 'fish' | 'powershell' | 'zsh' = 'bash' ): string[] { - return getCompletionSuggestions({ tokensBeforeCurrent, current, shell }).map((entry) => entry.value); + return getCompletionSuggestions({ tokensBeforeCurrent, current, shell }).map( + (entry) => entry.value + ); } beforeEach(() => { @@ -100,7 +102,9 @@ describe('completion backend', () => { test('suggests lifecycle subcommands for api', () => { const values = suggestionValues(['api']); - expect(values).toEqual(expect.arrayContaining(['create', 'discover', 'copy', 'export', 'import', 'remove'])); + expect(values).toEqual( + expect.arrayContaining(['create', 'discover', 'copy', 'export', 'import', 'remove']) + ); }); test('suggests account profiles for auth show', () => { @@ -109,6 +113,13 @@ describe('completion backend', () => { expect(values).toContain('--json'); }); + test('suggests default lane and account profiles for auth backup', () => { + const values = suggestionValues(['auth', 'backup']); + expect(values).toContain('default'); + expect(values).toContain('work'); + expect(values).toContain('--json'); + }); + test('suggests cliproxy variants for variant-scoped commands', () => { const values = suggestionValues(['cliproxy', 'edit']); expect(values).toContain('my-codex'); @@ -116,7 +127,9 @@ describe('completion backend', () => { test('suggests env format values after the format flag', () => { const values = suggestionValues(['env', '--format']); - expect(values).toEqual(expect.arrayContaining(['openai', 'anthropic', 'raw', 'claude-extension'])); + expect(values).toEqual( + expect.arrayContaining(['openai', 'anthropic', 'raw', 'claude-extension']) + ); }); test('includes live doctor and cliproxy flags from the shared catalog', () => { diff --git a/tests/unit/web-server/account-routes-context.test.ts b/tests/unit/web-server/account-routes-context.test.ts index 8e35607c..093a6b14 100644 --- a/tests/unit/web-server/account-routes-context.test.ts +++ b/tests/unit/web-server/account-routes-context.test.ts @@ -164,6 +164,104 @@ describe('web-server account-routes context normalization', () => { expect(work?.continuity_inferred).toBe(true); }); + it('reports the plain ccs lane as native when no account default or inheritance exists', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const payload = await getJson<{ + plain_ccs_lane?: { + kind: string; + account_name?: string | null; + }; + }>(baseUrl, '/api/accounts'); + + expect(payload.plain_ccs_lane?.kind).toBe('native'); + expect(payload.plain_ccs_lane?.account_name).toBeNull(); + }); + + it('reports the plain ccs lane as an account when default profile is an auth account', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'default: work', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' context_mode: shared', + ' context_group: default', + ' continuity_mode: deeper', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const payload = await getJson<{ + plain_ccs_lane?: { + kind: string; + account_name?: string | null; + }; + }>(baseUrl, '/api/accounts'); + + expect(payload.plain_ccs_lane?.kind).toBe('account-default'); + expect(payload.plain_ccs_lane?.account_name).toBe('work'); + }); + + it('does not initialize an account instance as a side effect of listing accounts', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'default: work', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' context_mode: shared', + ' context_group: default', + ' continuity_mode: deeper', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const instancePath = path.join(ccsDir, 'instances', 'work'); + expect(fs.existsSync(instancePath)).toBe(false); + + await getJson(baseUrl, '/api/accounts'); + + expect(fs.existsSync(instancePath)).toBe(false); + }); + it('does not delete metadata when instance deletion fails', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); diff --git a/ui/src/components/account/accounts-table.tsx b/ui/src/components/account/accounts-table.tsx index 42eeb02a..0a409a6f 100644 --- a/ui/src/components/account/accounts-table.tsx +++ b/ui/src/components/account/accounts-table.tsx @@ -35,14 +35,21 @@ import { useUpdateAccountContext, } from '@/hooks/use-accounts'; import type { AuthAccountRow, SharedGroupSummary } from '@/lib/account-continuity'; +import type { PlainCcsLane } from '@/lib/api-client'; interface AccountsTableProps { data: AuthAccountRow[]; defaultAccount: string | null; groupSummaries: SharedGroupSummary[]; + plainCcsLane: PlainCcsLane | null; } -export function AccountsTable({ data, defaultAccount, groupSummaries }: AccountsTableProps) { +export function AccountsTable({ + data, + defaultAccount, + groupSummaries, + plainCcsLane, +}: AccountsTableProps) { const { t } = useTranslation(); const setDefaultMutation = useSetDefaultAccount(); const deleteMutation = useDeleteAccount(); @@ -114,7 +121,7 @@ export function AccountsTable({ data, defaultAccount, groupSummaries }: Accounts variant="outline" className={`font-mono text-[10px] uppercase px-1.5 py-0 border ${isDeeper ? 'text-indigo-700 border-indigo-300/60 bg-indigo-50/50 dark:text-indigo-300 dark:border-indigo-900/40 dark:bg-indigo-900/20' : 'text-emerald-700 border-emerald-300/60 bg-emerald-50/50 dark:text-emerald-300 dark:border-emerald-900/40 dark:bg-emerald-900/20'}`} > - {isDeeper ? 'Deeper' : 'Shared'} + {isDeeper ? t('accountsTable.badges.deeper') : t('accountsTable.badges.shared')} {group} @@ -136,7 +143,7 @@ export function AccountsTable({ data, defaultAccount, groupSummaries }: Accounts variant="outline" className="text-amber-700 border-amber-300/60 bg-amber-50/50 dark:text-amber-400 dark:border-amber-900/40 dark:bg-amber-900/20 font-mono text-[10px] uppercase px-1.5 py-0" > - Legacy + {t('accountsTable.badges.legacy')}

{t('accountsTable.legacyReview')} @@ -151,7 +158,7 @@ export function AccountsTable({ data, defaultAccount, groupSummaries }: Accounts variant="secondary" className="font-mono text-[10px] uppercase px-1.5 py-0 text-muted-foreground bg-muted/60 border-transparent shadow-none" > - Isolated + {t('accountsTable.badges.isolated')} ); @@ -321,6 +328,7 @@ export function AccountsTable({ data, defaultAccount, groupSummaries }: Accounts setContextTarget(null)} /> )} diff --git a/ui/src/components/account/continuity-overview.tsx b/ui/src/components/account/continuity-overview.tsx index 89a1df93..539626bc 100644 --- a/ui/src/components/account/continuity-overview.tsx +++ b/ui/src/components/account/continuity-overview.tsx @@ -12,10 +12,13 @@ import { useTranslation } from 'react-i18next'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; +import { CopyButton } from '@/components/ui/copy-button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import type { PlainCcsLane } from '@/lib/api-client'; interface ContinuityOverviewProps { totalAccounts: number; + primaryAccountName?: string | null; isolatedCount: number; sharedStandardCount: number; deeperSharedCount: number; @@ -27,6 +30,7 @@ interface ContinuityOverviewProps { deeperReadyGroups: string[]; legacyTargetCount: number; cliproxyCount: number; + plainCcsLane: PlainCcsLane | null; } type ReadinessState = @@ -45,6 +49,7 @@ const InfoTooltip = ({ titleKey, descKey }: { titleKey: string; descKey: string