From 13b81cac682ec37d9c577188037771df4be639cc Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 27 Aug 2026 10:39:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(settings):=20claude=5Fbin=20im=20Dateien-T?= =?UTF-8?q?ab=20setzbar,=20mit=20Aufl=C3=B6sungs-Check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher nur per Hand in worker.config.json. Neuer Abschnitt CLAUDE CLI mit eigenem Save-Button (die Datei gehoert dem Worker, nicht app_settings) und einer Zeile, wohin der Wert per ExecutableResolver tatsaechlich aufloest — 'nicht im PATH gefunden' war bisher erst am fehlgeschlagenen Run zu sehen. WorkerConfig ist DI-Singleton und ClaudeProcess loest pro Spawn auf, also greift die Aenderung ab dem naechsten Run ohne Neustart. SaveOnlineInbox/SaveClaudeBin teilen jetzt ein SaveKey(), damit beide dieselbe read-modify-write-Semantik haben (alle anderen Keys bleiben unberuehrt). Die zugehoerigen Locale-Keys sind im vorigen Commit mitgelaufen. --- src/ClaudeDo.Data/Wire.cs | 5 ++ .../Services/Interfaces/IWorkerClient.cs | 3 ++ src/ClaudeDo.Ui/Services/WorkerClient.cs | 6 +++ .../Settings/FilesSettingsTabViewModel.cs | 34 ++++++++++++++ src/ClaudeDo.Worker/Config/WorkerConfig.cs | 10 +++- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 17 +++++++ tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 2 + .../Config/WorkerConfigSaveTests.cs | 46 +++++++++++++++++++ .../UiVm/TasksIslandViewModelPlanningTests.cs | 2 + 9 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/ClaudeDo.Worker.Tests/Config/WorkerConfigSaveTests.cs diff --git a/src/ClaudeDo.Data/Wire.cs b/src/ClaudeDo.Data/Wire.cs index 0cef71f3..3f8fe2f0 100644 --- a/src/ClaudeDo.Data/Wire.cs +++ b/src/ClaudeDo.Data/Wire.cs @@ -105,6 +105,11 @@ public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPa public record SeedResultDto(int Copied, int Skipped); +/// What worker.config.json holds (a command name or a full path). +/// Where it actually resolves to right now, or null if nothing resolves — +/// the whole point of surfacing this setting is telling those two cases apart. +public record ClaudeBinDto(string Value, string? ResolvedPath); + public record OnlineInboxStateDto( bool Enabled, string ApiBaseUrl, diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index bfbb19c8..8e67a927 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -194,6 +194,9 @@ public interface IWorkerClient : INotifyPropertyChanged Task<(bool Ok, string? Error)> SetWorktreeStateAsync(string taskId, WorktreeState newState); Task ForceRemoveWorktreeAsync(string taskId); + Task GetClaudeBinAsync(); + Task SetClaudeBinAsync(string? value); + Task GetOnlineInboxStateAsync(); Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input); Task SetOnlineInboxAuthAsync(string refreshToken); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index d83b5413..27077d3b 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -664,6 +664,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC await InvokeTimedAsync("QueuePlanningSubtasksAsync", () => _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct)); } + public Task GetClaudeBinAsync() + => TryInvokeAsync("GetClaudeBin"); + + public Task SetClaudeBinAsync(string? value) + => TryInvokeAsync("SetClaudeBin", value); + public Task GetOnlineInboxStateAsync() => TryInvokeAsync("GetOnlineInboxState"); diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs index 4013bd66..fcd050fe 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs @@ -12,6 +12,12 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase private readonly IWorkerClient _worker; public OperationStatus RestoreOp { get; } = new(); + public OperationStatus ClaudeBinOp { get; } = new(); + + // worker.config.json's claude_bin: a command name or a full path to the CLI. Saved on its own + // (not with the modal's Save button) because the worker owns the file, not app_settings. + [ObservableProperty] private string _claudeBin = ""; + [ObservableProperty] private string _claudeBinResolvedHint = ""; [ObservableProperty] private string _statusMessage = ""; [ObservableProperty] private bool _hasCustomizedPrompts; @@ -37,6 +43,34 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase }; } + public async Task LoadClaudeBinAsync() + { + var dto = await _worker.GetClaudeBinAsync(); + if (dto is null) return; + ClaudeBin = dto.Value; + ApplyResolvedHint(dto); + } + + [RelayCommand] + private async Task SaveClaudeBin() + { + StatusMessage = ""; + using var op = ClaudeBinOp.Begin(Loc.T("ops.claudeBin.saving")); + var dto = await _worker.SetClaudeBinAsync(ClaudeBin); + if (dto is null) + { + StatusMessage = Loc.T("vm.filesTab.workerOffline"); + return; + } + ClaudeBin = dto.Value; + ApplyResolvedHint(dto); + } + + private void ApplyResolvedHint(ClaudeBinDto dto) => + ClaudeBinResolvedHint = dto.ResolvedPath is null + ? Loc.T("settings.files.claudeBinUnresolved") + : Loc.T("settings.files.claudeBinResolved", dto.ResolvedPath); + private bool CanRestoreDefaultAgents() => !RestoreOp.IsRunning; [RelayCommand(CanExecute = nameof(CanRestoreDefaultAgents))] diff --git a/src/ClaudeDo.Worker/Config/WorkerConfig.cs b/src/ClaudeDo.Worker/Config/WorkerConfig.cs index 3045bdd8..5a8a2026 100644 --- a/src/ClaudeDo.Worker/Config/WorkerConfig.cs +++ b/src/ClaudeDo.Worker/Config/WorkerConfig.cs @@ -96,6 +96,14 @@ public sealed class WorkerConfig /// Reads the existing JSON, replaces the online_inbox node, and writes back indented. /// public void SaveOnlineInbox(string? path = null) + => SaveKey("online_inbox", JsonSerializer.SerializeToNode(OnlineInbox, InboxSerializerOpts), path); + + /// Persists ONLY claude_bin, same read-modify-write as + /// — every other field in the file stays untouched. + public void SaveClaudeBin(string? path = null) + => SaveKey("claude_bin", JsonValue.Create(ClaudeBin), path); + + private static void SaveKey(string key, JsonNode? value, string? path) { path ??= DefaultConfigPath; @@ -103,7 +111,7 @@ public sealed class WorkerConfig ? JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? new JsonObject() : new JsonObject(); - root["online_inbox"] = JsonSerializer.SerializeToNode(OnlineInbox, InboxSerializerOpts); + root[key] = value; Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, root.ToJsonString(WriteOpts)); diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 1dd28135..69818515 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Reflection; using ClaudeDo.Data; +using ClaudeDo.Data.Environment; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Agents; @@ -1000,6 +1001,22 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub return ids.Count; } + public ClaudeBinDto GetClaudeBin() => DescribeClaudeBin(); + + /// Blank resets to the plain "claude" command. WorkerConfig is a DI singleton and + /// ClaudeProcess resolves it per spawn, so the change applies to the next run without a + /// restart — but it is persisted too, since Load() re-reads the file on startup. + public ClaudeBinDto SetClaudeBin(string? value) + { + _cfg.ClaudeBin = string.IsNullOrWhiteSpace(value) ? "claude" : value.Trim(); + _cfg.SaveClaudeBin(); + _ = _broadcaster.WorkerLog($"claude_bin set to '{_cfg.ClaudeBin}'", WorkerLogLevel.Info, DateTime.UtcNow); + return DescribeClaudeBin(); + } + + private ClaudeBinDto DescribeClaudeBin() => + new(_cfg.ClaudeBin, ExecutableResolver.Resolve(_cfg.ClaudeBin)?.Path); + #pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI calls are safe here. public OnlineInboxStateDto GetOnlineInboxState() { diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index 69ff19a1..df0e4ee5 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -172,6 +172,8 @@ public abstract class StubWorkerClient : IWorkerClient public virtual Task GetWorkerBuildInfoAsync() => Task.FromResult(WorkerBuildInfo); public virtual Task RefineTaskAsync(string taskId) => Task.CompletedTask; + public virtual Task GetClaudeBinAsync() => Task.FromResult(null); + public virtual Task SetClaudeBinAsync(string? value) => Task.FromResult(null); public virtual Task GetOnlineInboxStateAsync() => Task.FromResult(null); public virtual Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask; public virtual Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask; diff --git a/tests/ClaudeDo.Worker.Tests/Config/WorkerConfigSaveTests.cs b/tests/ClaudeDo.Worker.Tests/Config/WorkerConfigSaveTests.cs new file mode 100644 index 00000000..4431ec24 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Config/WorkerConfigSaveTests.cs @@ -0,0 +1,46 @@ +using ClaudeDo.Worker.Config; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Config; + +/// SaveClaudeBin/SaveOnlineInbox are read-modify-write on a file the user also hand-edits, so the +/// one thing that must hold is: every other key survives. +public sealed class WorkerConfigSaveTests : IDisposable +{ + private readonly string _path = Path.Combine( + Path.GetTempPath(), $"cd_workercfg_{Guid.NewGuid():N}.json"); + + public void Dispose() { try { File.Delete(_path); } catch { } } + + [Fact] + public void SaveClaudeBin_keeps_every_other_key() + { + File.WriteAllText(_path, """ + { + "claude_bin": "claude", + "signalr_port": 12345, + "external_mcp_api_key": "secret", + "online_inbox": { "enabled": true } + } + """); + + var cfg = WorkerConfig.Load(_path); + cfg.ClaudeBin = @"C:\tools\claude.cmd"; + cfg.SaveClaudeBin(_path); + + var reloaded = WorkerConfig.Load(_path); + Assert.Equal(@"C:\tools\claude.cmd", reloaded.ClaudeBin); + Assert.Equal(12345, reloaded.SignalRPort); + Assert.Equal("secret", reloaded.ExternalMcpApiKey); + Assert.True(reloaded.OnlineInbox.Enabled); + } + + [Fact] + public void SaveClaudeBin_creates_the_file_when_missing() + { + var cfg = new WorkerConfig { ClaudeBin = "claude-next" }; + cfg.SaveClaudeBin(_path); + + Assert.Equal("claude-next", WorkerConfig.Load(_path).ClaudeBin); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index 6bd31701..27df187d 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -159,6 +159,8 @@ sealed class FakeWorkerClient : IWorkerClient public Task GetLastPrepLogAsync() => Task.FromResult(string.Empty); public Task GetWorkerBuildInfoAsync() => Task.FromResult(null); public Task RefineTaskAsync(string taskId) => Task.CompletedTask; + public Task GetClaudeBinAsync() => Task.FromResult(null); + public Task SetClaudeBinAsync(string? value) => Task.FromResult(null); public Task GetOnlineInboxStateAsync() => Task.FromResult(null); public Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask; public Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask;