Skip to content

Commit 475e401

Browse files
elhoimclaude
andauthored
fix(install): keep unreadable Claude settings instead of overwriting them (#627)
installStatusLine caught any error from loadClaudeSettings (a JSON parse failure such as a trailing comma, EACCES/EBUSY, or a read racing Claude Code's own write), printed a warning, fell back to {} and then saved a settings.json containing only statusLine. Permissions, hooks, env, model and every other key were lost: the .bak written by saveClaudeSettings is immediately overwritten by the widget-hook sync, and the .orig backup is overwritten by the next install. loadClaudeSettings already returns {} when the file does not exist, so any error it throws means the file exists but cannot be read or parsed. In that case install now aborts with an error naming the file and the reason, leaving it (and any previous .orig) untouched; the .orig backup is taken only after a successful read. This matches uninstallStatusLine, which already refuses to modify unreadable settings. The TUI shows the error text in an "Install Failed" notice instead of a 2-second flash. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent baf37e1 commit 475e401

3 files changed

Lines changed: 23 additions & 26 deletions

File tree

‎src/tui/App.tsx‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -717,12 +717,15 @@ export const App: React.FC = () => {
717717
color: 'green'
718718
});
719719
}
720-
} catch {
721-
setFlashMessage({
722-
text: '✗ Install failed',
723-
color: 'red'
720+
} catch (err) {
721+
setFlashMessage(null);
722+
setFlowNotice({
723+
title: 'Install Failed',
724+
message: err instanceof Error ? err.message : String(err),
725+
color: 'red',
726+
continueScreen: 'install'
724727
});
725-
setScreen('install');
728+
setScreen('flowNotice');
726729
}
727730
setConfirmDialog(null);
728731
}

‎src/utils/__tests__/claude-settings.test.ts‎

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -518,25 +518,16 @@ describe('backup and error handling behavior', () => {
518518
}
519519
});
520520

521-
it('installStatusLine should warn and recover when existing settings are invalid', async () => {
521+
it('installStatusLine should abort without touching existing settings that cannot be parsed', async () => {
522522
writeRawClaudeSettings('{ invalid json');
523-
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
524-
try {
525-
await installStatusLine({ commandMode: 'auto-npx' });
523+
const settingsPath = getClaudeSettingsPath();
526524

527-
const settingsPath = getClaudeSettingsPath();
528-
const installed = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { statusLine?: { command?: string; padding?: number } };
529-
expect(installed.statusLine?.command).toBe(buildStatusLineCommand('auto-npx'));
530-
expect(installed.statusLine?.padding).toBe(0);
531-
expect(fs.existsSync(`${settingsPath}.orig`)).toBe(true);
532-
expect(fs.readFileSync(`${settingsPath}.orig`, 'utf-8')).toBe('{ invalid json');
525+
const installPromise = installStatusLine({ commandMode: 'auto-npx' });
526+
await expect(installPromise).rejects.toThrow(settingsPath);
533527

534-
expect(consoleErrorSpy).toHaveBeenCalledWith(
535-
`Warning: Could not read existing Claude settings. A backup exists at ${settingsPath}.orig.`
536-
);
537-
} finally {
538-
consoleErrorSpy.mockRestore();
539-
}
528+
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe('{ invalid json');
529+
expect(fs.existsSync(`${settingsPath}.bak`)).toBe(false);
530+
expect(fs.existsSync(`${settingsPath}.orig`)).toBe(false);
540531
});
541532

542533
it('uninstallStatusLine should warn and return without modifying invalid settings', async () => {

‎src/utils/claude-settings.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -405,15 +405,18 @@ export async function installStatusLine({
405405
}: InstallStatusLineOptions): Promise<void> {
406406
let settings: ClaudeSettings;
407407

408-
const backupPath = await backupClaudeSettings('.orig');
408+
// A missing settings file loads as {}; any error here means the file exists
409+
// but could not be read or parsed. Abort rather than overwrite it with only
410+
// our statusLine, which would discard the user's other Claude Code settings.
409411
try {
410412
settings = await loadClaudeSettings({ logErrors: false });
411-
} catch {
412-
const fallbackBackupPath = `${getClaudeSettingsPath()}.orig`;
413-
console.error(`Warning: Could not read existing Claude settings. A backup exists at ${backupPath ?? fallbackBackupPath}.`);
414-
settings = {};
413+
} catch (error) {
414+
const reason = error instanceof Error ? error.message : String(error);
415+
throw new Error(`Could not read existing Claude settings at ${getClaudeSettingsPath()} (${reason}). The file was left unchanged; fix or remove it, then install again.`, { cause: error });
415416
}
416417

418+
await backupClaudeSettings('.orig');
419+
417420
// Update settings with our status line (confirmation already handled in TUI)
418421
const existingRefreshInterval = settings.statusLine?.refreshInterval;
419422
settings.statusLine = {

0 commit comments

Comments
 (0)