diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json
index ba68ab2f..38c41343 100644
--- a/src/ClaudeDo.Localization/locales/de.json
+++ b/src/ClaudeDo.Localization/locales/de.json
@@ -745,6 +745,7 @@
"repositories": "Repositories",
"checkForUpdates": "Nach Updates suchen",
"restartWorker": "Worker neu starten",
+ "updateClaudeCli": "Claude CLI aktualisieren",
"worktrees": "Worktrees…",
"weeklyReport": "Wochenbericht…",
"usageMonitor": "Usage Monitor…",
diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json
index 8affc2b4..8d05a8ad 100644
--- a/src/ClaudeDo.Localization/locales/en.json
+++ b/src/ClaudeDo.Localization/locales/en.json
@@ -745,6 +745,7 @@
"repositories": "Repositories",
"checkForUpdates": "Check for updates",
"restartWorker": "Restart worker",
+ "updateClaudeCli": "Update Claude CLI",
"worktrees": "Worktrees…",
"weeklyReport": "Weekly Report…",
"usageMonitor": "Usage Monitor…",
diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
index 8229ca2e..864e2a33 100644
--- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
@@ -680,6 +680,96 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
if (RestartWorkerStatus == text) RestartWorkerStatus = null;
}
+ private bool _updatingClaudeCli;
+
+ private bool CanUpdateClaudeCli() => !_updatingClaudeCli;
+
+ [RelayCommand(CanExecute = nameof(CanUpdateClaudeCli))]
+ private async Task UpdateClaudeCliAsync()
+ {
+ _updatingClaudeCli = true;
+ UpdateClaudeCliCommand.NotifyCanExecuteChanged();
+ try
+ {
+ var dto = Worker is null ? null : await Worker.GetClaudeBinAsync();
+ if (dto is null)
+ {
+ FlashFooterError("Update failed: worker offline.");
+ return;
+ }
+
+ var (success, message) = await RunClaudeUpdateAsync(dto);
+ if (success) FlashFooterSuccess(message);
+ else FlashFooterError(message);
+ }
+ finally
+ {
+ _updatingClaudeCli = false;
+ UpdateClaudeCliCommand.NotifyCanExecuteChanged();
+ }
+ }
+
+ private static async Task<(bool Success, string Message)> RunClaudeUpdateAsync(ClaudeBinDto dto)
+ {
+ var resolved = ClaudeDo.Data.Environment.ExecutableResolver.Resolve(dto.Value);
+ if (resolved is null) return (false, $"Update failed: '{dto.Value}' not found.");
+
+ var psi = resolved.IsShim
+ ? BuildShimUpdatePsi(resolved.Path)
+ : new System.Diagnostics.ProcessStartInfo { FileName = resolved.Path, ArgumentList = { "update" } };
+ psi.UseShellExecute = false;
+ psi.RedirectStandardOutput = true;
+ psi.RedirectStandardError = true;
+ psi.CreateNoWindow = true;
+
+ using var process = new System.Diagnostics.Process { StartInfo = psi };
+ var stdout = new System.Text.StringBuilder();
+ var stderr = new System.Text.StringBuilder();
+ process.OutputDataReceived += (_, e) => { if (e.Data is not null) stdout.AppendLine(e.Data); };
+ process.ErrorDataReceived += (_, e) => { if (e.Data is not null) stderr.AppendLine(e.Data); };
+
+ try { process.Start(); }
+ catch (Exception ex) { return (false, $"Update failed: {ex.Message}"); }
+
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
+ var timedOut = false;
+ // On timeout: kill the tree, which unblocks WaitForExitAsync below and lets the
+ // async output readers drain/complete naturally (mirrors VerifyCommandRunner).
+ await using var ctr = cts.Token.Register(() =>
+ {
+ timedOut = true;
+ try { process.Kill(entireProcessTree: true); } catch { /* already exited */ }
+ });
+
+ await process.WaitForExitAsync(CancellationToken.None);
+
+ return timedOut
+ ? (false, "Update failed: timed out after 5 minutes.")
+ : SummarizeClaudeUpdateOutcome(process.ExitCode, stdout.ToString(), stderr.ToString());
+ }
+
+ private static System.Diagnostics.ProcessStartInfo BuildShimUpdatePsi(string shimPath)
+ {
+ var shim = ClaudeDo.Data.Environment.ExecutableResolver.BuildShimStartInfo(shimPath, new[] { "update" });
+ return new System.Diagnostics.ProcessStartInfo { FileName = shim.FileName, Arguments = shim.Arguments };
+ }
+
+ // Pure so a test can drive it with fake process output instead of spawning a real `claude`.
+ internal static (bool Success, string Message) SummarizeClaudeUpdateOutcome(int exitCode, string stdout, string stderr)
+ {
+ if (exitCode == 0)
+ return (true, LastNonEmptyLine(stdout) ?? "Claude CLI is already up to date.");
+
+ var detail = LastNonEmptyLine(stderr) ?? LastNonEmptyLine(stdout) ?? "";
+ return (false, $"Update failed (exit {exitCode}): {detail}");
+ }
+
+ private static string? LastNonEmptyLine(string text) =>
+ text.Split('\n').Select(l => l.Trim()).LastOrDefault(l => l.Length > 0);
+
[RelayCommand]
private void DismissBanner()
{
diff --git a/src/ClaudeDo.Ui/Views/MainWindow.axaml b/src/ClaudeDo.Ui/Views/MainWindow.axaml
index dc9c3458..e5d54798 100644
--- a/src/ClaudeDo.Ui/Views/MainWindow.axaml
+++ b/src/ClaudeDo.Ui/Views/MainWindow.axaml
@@ -66,6 +66,8 @@
Command="{Binding RestartWorkerCommand}"/>
+