From 67fe6d9c7f832b561e7ab62ef00729b020e33a56 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Tue, 19 May 2026 15:23:37 -0400 Subject: [PATCH] fix(persist): print recovery receipt after settings write (#1302) --- docs/codex-auth.md | 4 + src/commands/persist-command.ts | 129 +++++++++++++++- .../commands/persist-command-handler.test.ts | 143 +++++++++++++++++- 3 files changed, 272 insertions(+), 4 deletions(-) diff --git a/docs/codex-auth.md b/docs/codex-auth.md index 0705535f..020a609b 100644 --- a/docs/codex-auth.md +++ b/docs/codex-auth.md @@ -76,6 +76,10 @@ persisted, clear them with: ccs persist default --yes ``` +The command prints a config receipt after writing settings: cleared managed keys, +written managed keys, whether and where any `/api/provider/codex` translator URL +remains, and the native Codex targets to use next. + Shell syntax for `use`: ```bash diff --git a/src/commands/persist-command.ts b/src/commands/persist-command.ts index f016ba5f..0b1f2f82 100644 --- a/src/commands/persist-command.ts +++ b/src/commands/persist-command.ts @@ -37,6 +37,14 @@ interface ResolvedEnv { notes?: string[]; } +interface PersistReceipt { + clearedKeys: string[]; + clearedCodexTranslatorUrlKeys: string[]; + writtenKeys: string[]; + unchangedWrittenKeys: string[]; + codexTranslatorUrlPaths: string[]; +} + const PERSIST_KNOWN_FLAGS = [ '--yes', '-y', @@ -54,6 +62,8 @@ const PERSIST_LOCK_STALE_MS = 10000; const PERSIST_LOCK_RETRIES = 5; const PERSIST_LOCK_RETRY_MIN_MS = 100; const PERSIST_LOCK_RETRY_MAX_MS = 500; +const CODEX_TRANSLATOR_URL_MARKER = '/api/provider/codex'; +const NATIVE_CODEX_TARGETS = ['ccsxp', 'ccs codex --target codex']; type PermissionMode = (typeof VALID_PERMISSION_MODES)[number]; @@ -509,6 +519,112 @@ function isSensitiveEnvKey(key: string): boolean { ); } +function formatSettingsPathSegment(basePath: string, segment: string | number): string { + if (typeof segment === 'number') { + return `${basePath}[${segment}]`; + } + + if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(segment)) { + return basePath ? `${basePath}.${segment}` : segment; + } + + return `${basePath}[${JSON.stringify(segment)}]`; +} + +function findCodexTranslatorUrlPaths(value: unknown, path = ''): string[] { + if (typeof value === 'string') { + return value.includes(CODEX_TRANSLATOR_URL_MARKER) ? [path || '(root)'] : []; + } + + if (Array.isArray(value)) { + return value.flatMap((item, index) => + findCodexTranslatorUrlPaths(item, formatSettingsPathSegment(path, index)) + ); + } + + if (typeof value === 'object' && value !== null) { + return Object.entries(value).flatMap(([key, item]) => + findCodexTranslatorUrlPaths(item, formatSettingsPathSegment(path, key)) + ); + } + + return []; +} + +function buildPersistReceipt( + existingEnv: Record, + mergedSettings: Record, + resolved: ResolvedEnv +): PersistReceipt { + const mergedEnv = + typeof mergedSettings.env === 'object' && + mergedSettings.env !== null && + !Array.isArray(mergedSettings.env) + ? (mergedSettings.env as Record) + : {}; + + const clearedKeys = resolved.clearEnvKeys.filter( + (key) => Object.prototype.hasOwnProperty.call(existingEnv, key) && mergedEnv[key] === undefined + ); + const clearedCodexTranslatorUrlKeys = clearedKeys.filter( + (key) => findCodexTranslatorUrlPaths(existingEnv[key]).length > 0 + ); + const writtenKeys = Object.entries(resolved.env) + .filter(([key, value]) => existingEnv[key] !== value) + .map(([key]) => key) + .sort((left, right) => left.localeCompare(right)); + const unchangedWrittenKeys = Object.entries(resolved.env) + .filter(([key, value]) => existingEnv[key] === value) + .map(([key]) => key) + .sort((left, right) => left.localeCompare(right)); + + return { + clearedKeys, + clearedCodexTranslatorUrlKeys, + writtenKeys, + unchangedWrittenKeys, + codexTranslatorUrlPaths: findCodexTranslatorUrlPaths(mergedSettings), + }; +} + +function formatKeyList(keys: string[]): string { + return keys.length > 0 ? keys.join(', ') : 'none'; +} + +function formatSettingsPathList(paths: string[]): string { + const visiblePaths = paths.slice(0, 5); + const remainingCount = paths.length - visiblePaths.length; + return remainingCount > 0 + ? `${visiblePaths.join(', ')} (+${remainingCount} more)` + : visiblePaths.join(', '); +} + +function printPersistReceipt(receipt: PersistReceipt): void { + console.log(subheader('Config Receipt')); + console.log(` Settings: ${getClaudeSettingsDisplayPath()}`); + console.log(` Cleared managed keys: ${formatKeyList(receipt.clearedKeys)}`); + console.log(` Written/rewritten managed keys: ${formatKeyList(receipt.writtenKeys)}`); + if (receipt.unchangedWrittenKeys.length > 0) { + console.log(` Already current keys: ${formatKeyList(receipt.unchangedWrittenKeys)}`); + } + + const hadCodexTranslatorCleanup = receipt.clearedCodexTranslatorUrlKeys.length > 0; + if (receipt.codexTranslatorUrlPaths.length > 0) { + console.log( + warn( + ` Codex translator URL: still found at ${formatSettingsPathList( + receipt.codexTranslatorUrlPaths + )} (${CODEX_TRANSLATOR_URL_MARKER})` + ) + ); + } else { + console.log(ok(' Codex translator URL: not found')); + } + if (hadCodexTranslatorCleanup || receipt.codexTranslatorUrlPaths.length > 0) { + console.log(` Native Codex target: ${NATIVE_CODEX_TARGETS.join(' or ')}`); + } +} + /** Resolve shared Claude settings payload for a profile */ async function resolveProfileEnvVars(profileName: string): Promise { const setup = await resolveClaudeExtensionSetup(profileName); @@ -927,6 +1043,16 @@ export async function handlePersistCommand(args: string[]): Promise { } await writeClaudeSettings(mergedSettings); + const persistedSettings = await readClaudeSettings(); + const receipt = buildPersistReceipt(existingEnv, persistedSettings, resolved); + + console.log(''); + console.log( + ok(`Profile '${parsedArgs.profile}' written to ${getClaudeSettingsDisplayPath()}`) + ); + console.log(''); + printPersistReceipt(receipt); + console.log(''); }); } catch (error) { const message = (error as Error).message; @@ -943,9 +1069,6 @@ export async function handlePersistCommand(args: string[]): Promise { } process.exit(1); } - console.log(''); - console.log(ok(`Profile '${parsedArgs.profile}' written to ${getClaudeSettingsDisplayPath()}`)); - console.log(''); console.log(info('Claude Code will now use this profile by default.')); console.log(dim(' To revert, restore the backup or edit settings.json manually.')); console.log(''); diff --git a/tests/unit/commands/persist-command-handler.test.ts b/tests/unit/commands/persist-command-handler.test.ts index 89808811..99be6605 100644 --- a/tests/unit/commands/persist-command-handler.test.ts +++ b/tests/unit/commands/persist-command-handler.test.ts @@ -371,6 +371,7 @@ describe('persist command Claude extension parity', () => { { env: { ANTHROPIC_AUTH_TOKEN: 'stale-token', + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', ANTHROPIC_MODEL: 'stale-model', KEEP_ME: 'still-here', }, @@ -381,7 +382,17 @@ describe('persist command Claude extension parity', () => { 'utf8' ); - await withScopedHome(() => handlePersistCommand(['default', '--yes'])); + const originalConsoleLog = console.log; + const capturedLogs: string[] = []; + console.log = (...args: unknown[]) => { + capturedLogs.push(args.map((arg) => String(arg)).join(' ')); + }; + + try { + await withScopedHome(() => handlePersistCommand(['default', '--yes'])); + } finally { + console.log = originalConsoleLog; + } const persisted = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as { env: Record; @@ -389,9 +400,139 @@ describe('persist command Claude extension parity', () => { expect(persisted.env.KEEP_ME).toBe('still-here'); expect(persisted.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(persisted.env.ANTHROPIC_BASE_URL).toBeUndefined(); expect(persisted.env.ANTHROPIC_MODEL).toBeUndefined(); expect(persisted.env.CLAUDE_CONFIG_DIR).toBe(path.join(tempRoot, '.ccs', 'instances', 'work')); expect(fs.existsSync(persisted.env.CLAUDE_CONFIG_DIR)).toBe(true); + + const renderedLogs = capturedLogs.join('\n'); + expect(renderedLogs).toContain('Config Receipt'); + expect(renderedLogs).toContain( + 'Cleared managed keys: ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, ANTHROPIC_MODEL' + ); + expect(renderedLogs).toContain('Written/rewritten managed keys: CLAUDE_CONFIG_DIR'); + expect(renderedLogs).toContain('Codex translator URL: not found'); + expect(renderedLogs).toContain('Native Codex target: ccsxp or ccs codex --target codex'); + }); + + it('warns in the persist receipt when a Codex translator URL remains in settings', async () => { + await writeUnifiedConfig(); + + const settingsPath = path.join(tempRoot, '.claude', 'settings.json'); + await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true }); + await fs.promises.writeFile( + settingsPath, + JSON.stringify( + { + env: { + KEEP_ME: 'still-here', + }, + custom: { + staleUrl: 'http://127.0.0.1:8317/api/provider/codex', + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + const originalConsoleLog = console.log; + const capturedLogs: string[] = []; + console.log = (...args: unknown[]) => { + capturedLogs.push(args.map((arg) => String(arg)).join(' ')); + }; + + try { + await withScopedHome(() => handlePersistCommand(['default', '--yes'])); + } finally { + console.log = originalConsoleLog; + } + + const renderedLogs = capturedLogs.join('\n'); + expect(renderedLogs).toContain('Config Receipt'); + expect(renderedLogs).toContain( + 'Codex translator URL: still found at custom.staleUrl (/api/provider/codex)' + ); + expect(renderedLogs).toContain('Native Codex target: ccsxp or ccs codex --target codex'); + }); + + it('reports already-current account continuity without Codex target guidance', async () => { + await writeUnifiedConfig(); + + const settingsPath = path.join(tempRoot, '.claude', 'settings.json'); + const instancePath = path.join(tempRoot, '.ccs', 'instances', 'work'); + await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true }); + await fs.promises.writeFile( + settingsPath, + JSON.stringify( + { + env: { + CLAUDE_CONFIG_DIR: instancePath, + KEEP_ME: 'still-here', + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + const originalConsoleLog = console.log; + const capturedLogs: string[] = []; + console.log = (...args: unknown[]) => { + capturedLogs.push(args.map((arg) => String(arg)).join(' ')); + }; + + try { + await withScopedHome(() => handlePersistCommand(['default', '--yes'])); + } finally { + console.log = originalConsoleLog; + } + + const renderedLogs = capturedLogs.join('\n'); + expect(renderedLogs).toContain('Cleared managed keys: none'); + expect(renderedLogs).toContain('Written/rewritten managed keys: none'); + expect(renderedLogs).toContain('Already current keys: CLAUDE_CONFIG_DIR'); + expect(renderedLogs).toContain('Codex translator URL: not found'); + expect(renderedLogs).not.toContain('Native Codex target:'); + }); + + it('does not print native Codex target guidance for non-Codex profile persistence', async () => { + await writeUnifiedConfig(); + + const settingsPath = path.join(tempRoot, '.claude', 'settings.json'); + await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true }); + await fs.promises.writeFile( + settingsPath, + JSON.stringify( + { + env: { + KEEP_ME: 'still-here', + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + const originalConsoleLog = console.log; + const capturedLogs: string[] = []; + console.log = (...args: unknown[]) => { + capturedLogs.push(args.map((arg) => String(arg)).join(' ')); + }; + + try { + await withScopedHome(() => handlePersistCommand(['glm', '--yes'])); + } finally { + console.log = originalConsoleLog; + } + + const renderedLogs = capturedLogs.join('\n'); + expect(renderedLogs).toContain('Config Receipt'); + expect(renderedLogs).toContain('Codex translator URL: not found'); + expect(renderedLogs).not.toContain('Native Codex target:'); }); it('blocks Codex CLIProxy profiles from Claude settings persistence', async () => {