mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 00:17:47 +00:00
fix(persist): print recovery receipt after settings write (#1302)
This commit is contained in:
@@ -76,6 +76,10 @@ persisted, clear them with:
|
|||||||
ccs persist default --yes
|
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`:
|
Shell syntax for `use`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ interface ResolvedEnv {
|
|||||||
notes?: string[];
|
notes?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PersistReceipt {
|
||||||
|
clearedKeys: string[];
|
||||||
|
clearedCodexTranslatorUrlKeys: string[];
|
||||||
|
writtenKeys: string[];
|
||||||
|
unchangedWrittenKeys: string[];
|
||||||
|
codexTranslatorUrlPaths: string[];
|
||||||
|
}
|
||||||
|
|
||||||
const PERSIST_KNOWN_FLAGS = [
|
const PERSIST_KNOWN_FLAGS = [
|
||||||
'--yes',
|
'--yes',
|
||||||
'-y',
|
'-y',
|
||||||
@@ -54,6 +62,8 @@ const PERSIST_LOCK_STALE_MS = 10000;
|
|||||||
const PERSIST_LOCK_RETRIES = 5;
|
const PERSIST_LOCK_RETRIES = 5;
|
||||||
const PERSIST_LOCK_RETRY_MIN_MS = 100;
|
const PERSIST_LOCK_RETRY_MIN_MS = 100;
|
||||||
const PERSIST_LOCK_RETRY_MAX_MS = 500;
|
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];
|
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 */
|
/** Resolve shared Claude settings payload for a profile */
|
||||||
async function resolveProfileEnvVars(profileName: string): Promise<ResolvedEnv> {
|
async function resolveProfileEnvVars(profileName: string): Promise<ResolvedEnv> {
|
||||||
const setup = await resolveClaudeExtensionSetup(profileName);
|
const setup = await resolveClaudeExtensionSetup(profileName);
|
||||||
@@ -927,6 +1043,16 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await writeClaudeSettings(mergedSettings);
|
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) {
|
} catch (error) {
|
||||||
const message = (error as Error).message;
|
const message = (error as Error).message;
|
||||||
@@ -943,9 +1069,6 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
process.exit(1);
|
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(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(dim(' To revert, restore the backup or edit settings.json manually.'));
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|||||||
@@ -371,6 +371,7 @@ describe('persist command Claude extension parity', () => {
|
|||||||
{
|
{
|
||||||
env: {
|
env: {
|
||||||
ANTHROPIC_AUTH_TOKEN: 'stale-token',
|
ANTHROPIC_AUTH_TOKEN: 'stale-token',
|
||||||
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
|
||||||
ANTHROPIC_MODEL: 'stale-model',
|
ANTHROPIC_MODEL: 'stale-model',
|
||||||
KEEP_ME: 'still-here',
|
KEEP_ME: 'still-here',
|
||||||
},
|
},
|
||||||
@@ -381,7 +382,17 @@ describe('persist command Claude extension parity', () => {
|
|||||||
'utf8'
|
'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 {
|
const persisted = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as {
|
||||||
env: Record<string, string>;
|
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.KEEP_ME).toBe('still-here');
|
||||||
expect(persisted.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
|
expect(persisted.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
|
||||||
|
expect(persisted.env.ANTHROPIC_BASE_URL).toBeUndefined();
|
||||||
expect(persisted.env.ANTHROPIC_MODEL).toBeUndefined();
|
expect(persisted.env.ANTHROPIC_MODEL).toBeUndefined();
|
||||||
expect(persisted.env.CLAUDE_CONFIG_DIR).toBe(path.join(tempRoot, '.ccs', 'instances', 'work'));
|
expect(persisted.env.CLAUDE_CONFIG_DIR).toBe(path.join(tempRoot, '.ccs', 'instances', 'work'));
|
||||||
expect(fs.existsSync(persisted.env.CLAUDE_CONFIG_DIR)).toBe(true);
|
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 () => {
|
it('blocks Codex CLIProxy profiles from Claude settings persistence', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user