feat(settings): claude_bin im Dateien-Tab setzbar, mit Auflösungs-Check
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.
This commit is contained in:
@@ -105,6 +105,11 @@ public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPa
|
|||||||
|
|
||||||
public record SeedResultDto(int Copied, int Skipped);
|
public record SeedResultDto(int Copied, int Skipped);
|
||||||
|
|
||||||
|
/// <param name="Value">What worker.config.json holds (a command name or a full path).</param>
|
||||||
|
/// <param name="ResolvedPath">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.</param>
|
||||||
|
public record ClaudeBinDto(string Value, string? ResolvedPath);
|
||||||
|
|
||||||
public record OnlineInboxStateDto(
|
public record OnlineInboxStateDto(
|
||||||
bool Enabled,
|
bool Enabled,
|
||||||
string ApiBaseUrl,
|
string ApiBaseUrl,
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
Task<(bool Ok, string? Error)> SetWorktreeStateAsync(string taskId, WorktreeState newState);
|
Task<(bool Ok, string? Error)> SetWorktreeStateAsync(string taskId, WorktreeState newState);
|
||||||
Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId);
|
Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId);
|
||||||
|
|
||||||
|
Task<ClaudeBinDto?> GetClaudeBinAsync();
|
||||||
|
Task<ClaudeBinDto?> SetClaudeBinAsync(string? value);
|
||||||
|
|
||||||
Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync();
|
Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync();
|
||||||
Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input);
|
Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input);
|
||||||
Task SetOnlineInboxAuthAsync(string refreshToken);
|
Task SetOnlineInboxAuthAsync(string refreshToken);
|
||||||
|
|||||||
@@ -664,6 +664,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
await InvokeTimedAsync("QueuePlanningSubtasksAsync", () => _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct));
|
await InvokeTimedAsync("QueuePlanningSubtasksAsync", () => _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<ClaudeBinDto?> GetClaudeBinAsync()
|
||||||
|
=> TryInvokeAsync<ClaudeBinDto>("GetClaudeBin");
|
||||||
|
|
||||||
|
public Task<ClaudeBinDto?> SetClaudeBinAsync(string? value)
|
||||||
|
=> TryInvokeAsync<ClaudeBinDto>("SetClaudeBin", value);
|
||||||
|
|
||||||
public Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync()
|
public Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync()
|
||||||
=> TryInvokeAsync<OnlineInboxStateDto>("GetOnlineInboxState");
|
=> TryInvokeAsync<OnlineInboxStateDto>("GetOnlineInboxState");
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
|
|||||||
private readonly IWorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
|
|
||||||
public OperationStatus RestoreOp { get; } = new();
|
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 string _statusMessage = "";
|
||||||
[ObservableProperty] private bool _hasCustomizedPrompts;
|
[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;
|
private bool CanRestoreDefaultAgents() => !RestoreOp.IsRunning;
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanRestoreDefaultAgents))]
|
[RelayCommand(CanExecute = nameof(CanRestoreDefaultAgents))]
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ public sealed class WorkerConfig
|
|||||||
/// Reads the existing JSON, replaces the <c>online_inbox</c> node, and writes back indented.
|
/// Reads the existing JSON, replaces the <c>online_inbox</c> node, and writes back indented.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void SaveOnlineInbox(string? path = null)
|
public void SaveOnlineInbox(string? path = null)
|
||||||
|
=> SaveKey("online_inbox", JsonSerializer.SerializeToNode(OnlineInbox, InboxSerializerOpts), path);
|
||||||
|
|
||||||
|
/// <summary>Persists ONLY <c>claude_bin</c>, same read-modify-write as
|
||||||
|
/// <see cref="SaveOnlineInbox"/> — every other field in the file stays untouched.</summary>
|
||||||
|
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;
|
path ??= DefaultConfigPath;
|
||||||
|
|
||||||
@@ -103,7 +111,7 @@ public sealed class WorkerConfig
|
|||||||
? JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? new JsonObject()
|
? JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? new JsonObject()
|
||||||
: new JsonObject();
|
: new JsonObject();
|
||||||
|
|
||||||
root["online_inbox"] = JsonSerializer.SerializeToNode(OnlineInbox, InboxSerializerOpts);
|
root[key] = value;
|
||||||
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
File.WriteAllText(path, root.ToJsonString(WriteOpts));
|
File.WriteAllText(path, root.ToJsonString(WriteOpts));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Environment;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Worker.Agents;
|
using ClaudeDo.Worker.Agents;
|
||||||
@@ -1000,6 +1001,22 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
return ids.Count;
|
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.
|
#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI calls are safe here.
|
||||||
public OnlineInboxStateDto GetOnlineInboxState()
|
public OnlineInboxStateDto GetOnlineInboxState()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -172,6 +172,8 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
public virtual Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync() => Task.FromResult(WorkerBuildInfo);
|
public virtual Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync() => Task.FromResult(WorkerBuildInfo);
|
||||||
public virtual Task RefineTaskAsync(string taskId) => Task.CompletedTask;
|
public virtual Task RefineTaskAsync(string taskId) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public virtual Task<ClaudeBinDto?> GetClaudeBinAsync() => Task.FromResult<ClaudeBinDto?>(null);
|
||||||
|
public virtual Task<ClaudeBinDto?> SetClaudeBinAsync(string? value) => Task.FromResult<ClaudeBinDto?>(null);
|
||||||
public virtual Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync() => Task.FromResult<OnlineInboxStateDto?>(null);
|
public virtual Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync() => Task.FromResult<OnlineInboxStateDto?>(null);
|
||||||
public virtual Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask;
|
public virtual Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask;
|
||||||
public virtual Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask;
|
public virtual Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,6 +159,8 @@ sealed class FakeWorkerClient : IWorkerClient
|
|||||||
public Task<string> GetLastPrepLogAsync() => Task.FromResult(string.Empty);
|
public Task<string> GetLastPrepLogAsync() => Task.FromResult(string.Empty);
|
||||||
public Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync() => Task.FromResult<WorkerBuildInfoDto?>(null);
|
public Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync() => Task.FromResult<WorkerBuildInfoDto?>(null);
|
||||||
public Task RefineTaskAsync(string taskId) => Task.CompletedTask;
|
public Task RefineTaskAsync(string taskId) => Task.CompletedTask;
|
||||||
|
public Task<ClaudeBinDto?> GetClaudeBinAsync() => Task.FromResult<ClaudeBinDto?>(null);
|
||||||
|
public Task<ClaudeBinDto?> SetClaudeBinAsync(string? value) => Task.FromResult<ClaudeBinDto?>(null);
|
||||||
public Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync() => Task.FromResult<OnlineInboxStateDto?>(null);
|
public Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync() => Task.FromResult<OnlineInboxStateDto?>(null);
|
||||||
public Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask;
|
public Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask;
|
||||||
public Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask;
|
public Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask;
|
||||||
|
|||||||
Reference in New Issue
Block a user