Skip to content

fix: report Uninstall and Refresh Interval failures when Claude's settings.json can't be changed - #805

Merged
sirmalloc merged 6 commits into
sirmalloc:mainfrom
eric-engberg:fix/claude-settings-write-failures
Nov 6, 2026
Merged

sirmalloc merged 6 commits into
sirmalloc:mainfrom
eric-engberg:fix/claude-settings-write-failures

Conversation

@eric-engberg

Copy link
Copy Markdown
Contributor

What

When the TUI can't change Claude Code's settings.json, it now reports a failure instead of claiming success:

  • Uninstall. If settings.json can't be read or parsed, Uninstall fails with "✗ Uninstall failed". The TUI stays installed and the global npm/bun package is left alone. Before this change, npm uninstall -g / bun remove -g ran anyway and the TUI flashed "✓ Uninstalled from Claude Code and removed global package", while statusLine, the managed --hook hooks and the installation metadata were all still there. Uninstall also fails now when statusLine is already gone but the managed --hook hooks are still in a settings.json that can't be written, for example a read-only one. That failure used to be ignored, so the package was removed and the hooks were left running it. A hook entry in a shape Claude Code doesn't expect, such as one written by hand, is left as it is and no longer stops the cleanup.
  • Refresh Interval. If settings.json can't be read or parsed, or no longer has a statusLine, Configure Status Line shows "✗ Failed to save refresh interval" and keeps the value that is still in effect. Before, it flashed "✓ Refresh interval updated" and showed the new value although nothing was written.

Why (evidence)

Both writers caught the failure and returned normally:

  • uninstallStatusLine printed "Warning: Could not read existing Claude settings." and returned. It also ignored any failure to remove the managed hooks, which is the only write to settings.json when statusLine is already gone.
  • setRefreshInterval returned without a word when the read failed and when there was no statusLine.

The TUI treats a normal return as success. It only takes its existing failure paths ("✗ Uninstall failed", "✗ Failed to save refresh interval") when the call rejects, so these cases never reached them.

The TUI reads settings.json once at startup, and only offers Uninstall and Configure Status Line if the file parsed and had our statusLine. So hitting this takes the file changing while the TUI is open, for example a hand edit saved with a trailing comma in another window. Reproduced on main, as an Ink test and in the demo below:

  • Uninstall (pinned npm install, trailing comma added with the confirm dialog open):
    • The warning lands on top of the TUI and npm uninstall -g ccstatusline runs.
    • The flash reads "✓ Uninstalled from Claude Code and removed global package" and the menu switches to "📦 Install to Claude Code".
    • settings.json is unchanged. Once its JSON is fixed, statusLine.command is still ccstatusline, the binary that was just removed, so Claude Code shows a blank status line. The --hook hooks point at it too.
    • The settings-only options said "✓ Uninstalled from Claude Code" while removing nothing.
  • Refresh Interval (refreshInterval: 10; the file given a trailing comma, or rewritten without statusLine): "✓ Refresh interval updated" and "(15s)", while settings.json still says 10.

installStatusLine already refuses to touch an unreadable settings.json (#627), and the TUI reports that as "Install Failed". Uninstall and Refresh Interval now do the same.

How

  • In claude-settings.ts, the read guard installStatusLine had inline moves into loadClaudeSettingsForUpdate(retry). It throws "Could not read existing Claude settings at (). The file was left unchanged; fix or remove it, then ." Install, uninstall and setRefreshInterval use it; install's message is unchanged.
  • uninstallStatusLine throws before removing anything, so the metadata and hooks stay in place. It now removes statusLine and the managed hooks in one write, before it clears the metadata, so a failure to save rejects with both still in the file and the metadata kept. With neither in the file it doesn't write at all, so Uninstall still goes ahead when statusLine is already gone and no managed hooks are left, even if the file can't be written, since nothing would be left pointing at the binary. The .bak copy now holds the file as it was before the uninstall.
  • The hook cleanup leaves alone what isn't ours: an event whose value isn't a list, an entry that isn't an object, or an entry whose hooks isn't a list. Before, such an entry threw a TypeError, which uninstall ignored.
  • setRefreshInterval also throws when there is no statusLine, including when clearing the interval: the TUI's idea that ccstatusline is installed is out of date in that state.
  • The TUI code is unchanged; its existing failure paths handle both cases. For Uninstall the catch runs before any settings were removed, so it skips the global package removal, shows "✗ Uninstall failed" and stays on the uninstall screen. For Refresh Interval the .catch puts the previous value back.
  • The stderr warning that used to land on top of the TUI is gone.

Demo

The TUI runs in the top of a tmux split. The shell below adds a trailing comma to ~/.claude/settings.json mid-session. npm is a stub that only records npm uninstall -g ccstatusline.

Uninstall. Before: the warning bleeds in, "✓ Uninstalled from Claude Code and removed global package", and the stub log shows npm ran. After: "✗ Uninstall failed", it stays on the uninstall screen, and npm never runs.

Powerline

Before:

Uninstall with a broken settings.json before the fix, Powerline

After:

Uninstall with a broken settings.json after the fix, Powerline

Plain

Before:

Uninstall with a broken settings.json before the fix, Plain

After:

Uninstall with a broken settings.json after the fix, Plain

Refresh Interval. Before: "✓ Refresh interval updated" and "(15s)" on reopening, while the file still says 10. After: "✗ Failed to save refresh interval", "(10s)" on reopening, and the file is unchanged.

Powerline

Before:

Refresh Interval with a broken settings.json before the fix, Powerline

After:

Refresh Interval with a broken settings.json after the fix, Powerline

Plain

Before:

Refresh Interval with a broken settings.json before the fix, Plain

After:

Refresh Interval with a broken settings.json after the fix, Plain

Testing

  • On main as of fix(powerline): keep the Custom theme when Powerline is enabled again #793 (a0bedc0): it merges cleanly. bun run lint is clean, and bun test gives 4155 pass, 0 fail (4147 on main). The built CLI under Bun 1.4.4 and Node 26.13.0 agrees across runtimes and matches main in plain and Powerline mode, and the TUI opens and exits cleanly under both. It adds no JavaScript or Node API newer than Node 14, the build's target.
  • 11 tests fail on main and pass here:
    • claude-settings.test.ts: 3 changed, 4 new. Uninstall rejects and leaves the file and metadata alone, both when the file can't be parsed and when managed hooks are left in a read-only file. With neither statusLine nor managed hooks in a read-only file, Uninstall resolves, clears the metadata and doesn't try to rewrite the file. Uninstall keeps hook entries it doesn't recognize, and its .bak copy is the file from before the uninstall. setRefreshInterval rejects on an unreadable file and on a missing statusLine.
    • App.test.ts: 4 new Ink tests. Uninstall shows "✗ Uninstall failed", never calls the global uninstall, and leaves the file, the metadata and the installed state alone. That holds with a settings.json that no longer parses, and with a read-only one that has lost its statusLine but kept the managed hooks. Refresh Interval with an unparseable file or no statusLine shows the failure flash, keeps "(10s)" and leaves the file unchanged.
  • Node vitest on the two changed files: 135 pass / 11 fail here, 127 / 11 on main. The same 11 tests fail on both (spying on node:child_process execSync isn't allowed under Node ESM). hooks.test.ts, whose source changed, passes 7 / 7 on both.

eric-engberg and others added 6 commits October 16, 2026 00:50
uninstallStatusLine caught a load failure (invalid JSON such as a
trailing comma, or a permission error), printed a warning and returned
normally. The TUI's uninstall action treats a normal return as "Claude
settings removed", so it went on to run `npm uninstall -g` or
`bun remove -g`, switched the menu to not installed and flashed
"✓ Uninstalled from Claude Code and removed global package". Nothing
had been removed from settings.json: once the file was fixed,
statusLine still ran the global binary that had just been uninstalled,
which leaves a blank status line, and the managed --hook hooks and the
installation metadata were still there. The settings-only options
reported "✓ Uninstalled from Claude Code" the same way.

The TUI only offers Uninstall when settings.json was readable at
startup, so this takes the file breaking while the TUI is open, for
example a hand edit saved with a syntax error in another window.

uninstallStatusLine now throws on that failure, as installStatusLine
already does, with the same message naming the file and the reason
from a loader the two share. The TUI's existing failure branch then
runs: it shows "✗ Uninstall failed", keeps the installed state and
skips the global package removal. settings.json, the metadata and the
hooks are left as they were.
setRefreshInterval returned normally when Claude's settings.json could
not be read or parsed, and when it had no statusLine, without writing
anything. Configure Status Line sets the new value before the save and
only puts the old one back if the save rejects, so in both cases it
flashed "✓ Refresh interval updated" and kept showing the new value,
while Claude Code went on using the old one.

As with Uninstall, the menu is only enabled when settings.json was
readable and had our statusLine at startup, so this takes the file
changing while the TUI is open: a hand edit that leaves invalid JSON,
or another tool replacing the file.

setRefreshInterval now throws in both cases, the read failure with the
message install and uninstall use. The TUI's existing failure branch
then shows "✗ Failed to save refresh interval" and keeps the value
that is still in effect. The file is left unchanged.
uninstallStatusLine ignored any failure to remove the managed --hook
hooks. When statusLine was already gone from Claude's settings.json,
so nothing was written before that step, and the file couldn't be
written (read-only, for example), the hooks stayed in place and the
uninstall returned normally. The TUI then ran `npm uninstall -g` or
`bun remove -g` and flashed success, leaving hooks that run the binary
it had just removed. The installation metadata was cleared before the
hooks were tried, so it was gone as well.

The hook cleanup failure now rejects the uninstall, and the hooks are
removed before the metadata is cleared, so the TUI's existing failure
branch shows "✗ Uninstall failed", skips the global package removal
and leaves settings.json and the metadata as they were.

removeManagedHooks no longer rewrites settings.json when it holds none
of our hooks. An uninstall with neither statusLine nor managed hooks in
the file still goes ahead even when the file can't be written, since
nothing in it would be left pointing at the binary. It also stops
reformatting the file and refreshing its .bak copy for no change.
… read

Removing the managed hooks assumed every hook event held a list of
objects, each with a list of hooks. A hand-edited settings.json can
break that, for example a Stop hook written as an object, a null entry,
or "hooks" set to a string. The cleanup then threw a TypeError, even when
none of the hooks were ccstatusline's, and since the previous change
that error rejects the uninstall. statusLine had already been removed in
an earlier write, so the TUI showed "Uninstall failed" with statusLine
gone, and every retry failed the same way.

Entries without the expected shape are now left as they are, since they
can't be ours. Uninstall also removes statusLine and the managed hooks
in one write, so a failure to save leaves both in place, and the .bak
copy holds the file as it was before the uninstall rather than the
halfway state between the two writes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants