fix(persist): print recovery receipt after settings write (#1302)

This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-19 15:23:37 -04:00
committed by GitHub
parent af23e971fa
commit 67fe6d9c7f
3 changed files with 272 additions and 4 deletions
+4
View File
@@ -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
+126 -3
View File
@@ -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<string, string>,
mergedSettings: Record<string, unknown>,
resolved: ResolvedEnv
): PersistReceipt {
const mergedEnv =
typeof mergedSettings.env === 'object' &&
mergedSettings.env !== null &&
!Array.isArray(mergedSettings.env)
? (mergedSettings.env as Record<string, string>)
: {};
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<ResolvedEnv> {
const setup = await resolveClaudeExtensionSetup(profileName);
@@ -927,6 +1043,16 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
}
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<void> {
}
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('');
@@ -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<string, string>;
@@ -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 () => {