From dbee4a0de915cce57992c8be02f4a306fb5c54bb Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 10 Sep 2026 13:10:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Worker-Men=C3=BC=20"Update=20Claude?= =?UTF-8?q?=20CLI"=20f=C3=BChrt=20claude=20update=20aus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Eintrag im Worker-Menü der Titelleiste, der die Claude CLI über das konfigurierte claude_bin (ExecutableResolver, inkl. Shim-Handling) mit `claude update` aktualisiert. Ausgabe/Fehler landen im Footer-Strip (FlashFooterSuccess/FlashFooterError), Menüpunkt ist während des Laufs deaktiviert. 5-Minuten-Timeout tötet den Prozessbaum. --- src/ClaudeDo.Localization/locales/de.json | 1 + src/ClaudeDo.Localization/locales/en.json | 1 + .../ViewModels/IslandsShellViewModel.cs | 90 +++++++++++++++++++ src/ClaudeDo.Ui/Views/MainWindow.axaml | 2 + ...landsShellViewModelUpdateClaudeCliTests.cs | 46 ++++++++++ 5 files changed, 140 insertions(+) create mode 100644 tests/ClaudeDo.Ui.Tests/IslandsShellViewModelUpdateClaudeCliTests.cs 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}"/> +