feat(claude-do): „Claude Help Me"-Button: Claude-Session zur Setup-Fehlersuch

Der Button, der aus „Problem erkannt" ein „Problem gelöst" macht: startet eine interaktive Claude-Session, die dem Nutzer beim Einrichten hilft.

## Warum externes Terminal
Der ConPTY-Stack (`PtyTerminalSession`, `ConPtyPaneView`) liegt in `ClaudeDo.Ui` und ist Avalonia — der Installer ist WPF und referenziert nur Data/Releases/Localization. Beim Fresh Install sind `app\`/`worker\` außerdem noch n

ClaudeDo-Task: 4e196058-38a3-404f-9862-4cb0e90195da
This commit is contained in:
mika kuns
2026-08-06 08:25:36 +02:00
parent b3a8373c70
commit 09e0772673
16 changed files with 630 additions and 35 deletions
+10 -9
View File
@@ -149,16 +149,17 @@ beider Branches, nicht aus `main` selbst. Details → `installer-preflight` in
`docs/explore-notes/README.md` und den neuen Abschnitt „Environment Checks" in `docs/explore-notes/README.md` und den neuen Abschnitt „Environment Checks" in
`src/ClaudeDo.Installer/CLAUDE.md`. `src/ClaudeDo.Installer/CLAUDE.md`.
**Zusätzliche Lücke, unabhängig vom Merge:** die zwei Folge-Tasks „Claude Help Me"-Button **Update 2026-08-06:** der „Claude Help Me"-Button ist jetzt implementiert
und Diagnose-Sektion (Config-Modus/`SettingsWindow`) sind **nicht implementiert** — beide (`Core/ClaudeHelpLauncher.cs` + `SystemCheckPageViewModel`/-View), gebaut + unit-getestet
liefen ins selbe Merge-Problem und wurden ohne jede Code-Änderung als `Blocked` beendet. Die (`ClaudeDo.Installer.Tests`/`ClaudeDo.Localization.Tests` grün). Die Diagnose-Sektion
folgenden Punkte, die diese zwei Features beträfen, können also noch nicht geprüft werden und (Config-Modus/`SettingsWindow`) bleibt **nicht implementiert** — eigener Folge-Task.
brauchen zuerst eine neue Umsetzungsrunde:
- [ ] „Claude Help Me" öffnet ein Terminal mit laufender Claude-Session, und die Session hat - [x] Help-Me-Button ist deaktiviert, wenn `claude-cli` nicht `Ok` ist oder `claude-auth`
den Diagnose-Report tatsächlich gelesen — **nicht umsetzbar, Feature existiert nicht.** `Failed` ist (bleibt aktiv bei `Unknown`), mit erklärendem Tooltip — unit-getestet.
- [ ] Der Help-Me-Button ist korrekt deaktiviert, wenn `claude` nicht im PATH ist, mit - [ ] „Claude Help Me" öffnet tatsächlich ein Terminal mit laufender Claude-Session, und die
verständlichem Tooltip — **nicht umsetzbar, Feature existiert nicht.** Session hat den Diagnose-Report gelesen — **nicht verifiziert** (der eigentliche
Terminal-Start/`wt.exe`-Zusammenspiel und die Session-Qualität sind nur über die
injizierte `IProcessLauncher`-Fake getestet, nie mit einem echten Terminal/CLI).
- [ ] Diagnose-Sektion im Config-Modus zeigt die echten installierten Pfade/Ports, und der - [ ] Diagnose-Sektion im Config-Modus zeigt die echten installierten Pfade/Ports, und der
laufende Worker auf 47821 gilt nicht als Konflikt — **nicht umsetzbar, Feature existiert laufende Worker auf 47821 gilt nicht als Konflikt — **nicht umsetzbar, Feature existiert
nicht.** nicht.**
+2
View File
@@ -128,7 +128,9 @@ public partial class App : Application
// Environment checks — stateless, so their infrastructure is shared; ClaudeCliLookup is // Environment checks — stateless, so their infrastructure is shared; ClaudeCliLookup is
// rebuilt per EnvironmentCheckService instance so a re-check doesn't reuse a stale result. // rebuilt per EnvironmentCheckService instance so a re-check doesn't reuse a stale result.
sc.AddSingleton<IProcessRunner, ProcessRunnerAdapter>(); sc.AddSingleton<IProcessRunner, ProcessRunnerAdapter>();
sc.AddSingleton<IProcessLauncher, ProcessLauncher>();
sc.AddSingleton<IPortOwnerResolver, NetstatPortOwnerResolver>(); sc.AddSingleton<IPortOwnerResolver, NetstatPortOwnerResolver>();
sc.AddSingleton<ClaudeHelpLauncher>();
sc.AddTransient<Func<EnvironmentCheckService>>(sp => () => sc.AddTransient<Func<EnvironmentCheckService>>(sp => () =>
{ {
var processRunner = sp.GetRequiredService<IProcessRunner>(); var processRunner = sp.GetRequiredService<IProcessRunner>();
+20 -15
View File
@@ -128,16 +128,11 @@ The Apps & Features uninstall string and "Rerun Installer" both point at `<Insta
## Environment Checks ## Environment Checks
> **Merge status (2026-08-05): not yet on `main`.** The `Checks/` folder, `SystemCheckPage`, > **Merge status (2026-08-06): `Checks/`, `SystemCheckPage`, `ExecutableResolver`, and the
> and `ExecutableResolver` described below exist only on unmerged task branches > "Claude Help Me" button below are on this branch.** The 2026-08-05 note about two unmerged
> (`claudedo/06aca9b3afec4b939f59b627bfe21737` for the Installer side, > task branches applied to an earlier state; the "Claude Help Me" button follow-up has since
> `claudedo/40272c0bb3b14562b59c022d09c382b6` for the `ClaudeDo.Worker` wiring). Build/test > landed (see below). The Config-mode Diagnose section in `SettingsWindow` is still **not**
> verification for this section was done against a local scratch integration of both, not > implemented — see `docs/open.md`.
> against this repo's actual `main`. Merge them (or re-derive equivalent commits) before trusting
> this section against the checked-out code. See `docs/open.md` for the outstanding gap this
> leaves (`Checks/` and `SystemCheckPage` are real, but the "Claude Help Me" button and the
> Config-mode Diagnose section described as follow-ups were never implemented — both follow-up
> tasks blocked on this same missing merge and shipped no code).
`Checks/` holds one `IEnvironmentCheck` per concern, run in parallel by `EnvironmentCheckService.RunAllAsync`: `Checks/` holds one `IEnvironmentCheck` per concern, run in parallel by `EnvironmentCheckService.RunAllAsync`:
@@ -169,8 +164,18 @@ on page entry (`LoadAsync`, guarded against double-entry). "Next" is disabled vi
subscribes to `PropertyChanged` on the current page so a live recheck can flip it back. A "Recheck" subscribes to `PropertyChanged` on the current page so a live recheck can flip it back. A "Recheck"
button re-runs `EnvironmentCheckService.RunAllAsync` (disabled while already running). button re-runs `EnvironmentCheckService.RunAllAsync` (disabled while already running).
**Not implemented (see merge-status note above):** a "Claude Help Me" button that launches an **"Claude Help Me" button** (`Core/ClaudeHelpLauncher.cs`) — a second footer button next to
external terminal with a live `claude` session for setup troubleshooting, and a Diagnose section "Recheck", enabled only when `claude-cli` is `Ok` and `claude-auth` is not `Failed` (`Unknown`
in `SettingsWindow` (Config mode) that re-runs the same checks against the installed configuration. stays enabled — an indeterminate login state shouldn't block the one feature that could help
Both were speced as follow-up tasks; both blocked before writing any code because their prerequisite diagnose it). `BuildReportAsync` renders all check results (Id/Severity/Status/Message table,
(this section) wasn't on `main` yet. plus the full `Detail` of any `Failed` check) and system info (OS, `dotnet --list-runtimes`,
resolved `git`/`claude` messages, planned install dir/ports) into
`%TEMP%\claudedo-setup-diagnose.md` — English and hardcoded (an AI assistant reads it, not the
user) and deliberately excludes credentials/tokens/env-var dumps. `LaunchTerminal` then opens
`wt.exe -d %TEMP% cmd.exe /k <claude> <prompt>` (or `cmd.exe /k <claude> <prompt>` if `wt.exe`
isn't resolvable) via the injectable `IProcessLauncher`, pointing the initial prompt at that
report file. Errors from either step surface as `ClaudeHelpError` on the page, never an
exception.
**Not implemented:** a Diagnose section in `SettingsWindow` (Config mode) that re-runs the same
checks against the installed configuration — still speced as a follow-up, no code yet.
@@ -0,0 +1,182 @@
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using ClaudeDo.Data.Environment;
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Core.Interfaces;
namespace ClaudeDo.Installer.Core;
public sealed record ClaudeHelpLaunchResult(bool Success, string? ErrorMessage);
/// <summary>
/// Builds a setup-diagnose report from the environment check results and opens an interactive
/// claude session pointed at it, so a stuck user can get live help finishing setup.
/// </summary>
public sealed class ClaudeHelpLauncher
{
public const string ReportFileName = "claudedo-setup-diagnose.md";
private readonly IProcessRunner _processRunner;
private readonly IProcessLauncher _processLauncher;
private readonly string? _pathOverride;
private readonly string? _pathExtOverride;
public ClaudeHelpLauncher(
IProcessRunner processRunner,
IProcessLauncher processLauncher,
string? pathOverride = null,
string? pathExtOverride = null)
{
_processRunner = processRunner;
_processLauncher = processLauncher;
_pathOverride = pathOverride;
_pathExtOverride = pathExtOverride;
}
public async Task<string> BuildReportAsync(EnvironmentCheckReport report, InstallContext ctx, CancellationToken ct)
{
var sb = new StringBuilder();
sb.AppendLine("# ClaudeDo setup diagnose");
sb.AppendLine();
sb.AppendLine(
"ClaudeDo is a desktop task manager that runs the `claude` CLI autonomously in git " +
"worktrees. The person you're talking to is stuck partway through the setup wizard. " +
"Explain the likely cause of each failing check below in plain language, propose the " +
"concrete command(s) to fix it, and ask a clarifying question if something here is " +
"ambiguous.");
sb.AppendLine();
sb.AppendLine("## Check results");
sb.AppendLine();
sb.AppendLine("| Id | Severity | Status | Message |");
sb.AppendLine("|---|---|---|---|");
foreach (var result in report.Results)
{
sb.AppendLine($"| {result.Id} | {result.Severity} | {result.Status} | {EscapeCell(result.Message)} |");
}
sb.AppendLine();
var failedDetails = report.Results
.Where(r => r.Status == CheckStatus.Failed && !string.IsNullOrWhiteSpace(r.Detail))
.ToList();
if (failedDetails.Count > 0)
{
sb.AppendLine("## Details of failed checks");
foreach (var result in failedDetails)
{
sb.AppendLine();
sb.AppendLine($"### {result.Id}");
sb.AppendLine("```");
sb.AppendLine(result.Detail);
sb.AppendLine("```");
}
sb.AppendLine();
}
sb.AppendLine("## System info");
sb.AppendLine();
sb.AppendLine($"- Windows: {RuntimeInformation.OSDescription}");
sb.AppendLine($"- Architecture: {RuntimeInformation.OSArchitecture}");
sb.AppendLine($"- git: {FindMessage(report, GitCheck.CheckId)}");
sb.AppendLine($"- claude: {FindMessage(report, ClaudeCliCheck.CheckId)}");
sb.AppendLine($"- Planned install directory: {ctx.InstallDirectory}");
sb.AppendLine($"- Planned ports: SignalR {ctx.SignalRPort}, MCP {ctx.ExternalMcpPort}");
sb.AppendLine("- `dotnet --list-runtimes`:");
sb.AppendLine("```");
sb.AppendLine(await RunDotnetListRuntimesAsync(ct));
sb.AppendLine("```");
sb.AppendLine();
// No secrets: never include credentials.json content, tokens, or a raw environment
// variable dump here — only resolved paths and version numbers, since this file exists
// to be read by an AI assistant, not to stay private.
sb.AppendLine(
"No secrets are included above (no credentials file contents, no tokens, no raw " +
"environment variable dump) — only resolved paths and version numbers.");
sb.AppendLine();
sb.AppendLine("Please start with the blocking errors above.");
return sb.ToString();
}
public async Task<ClaudeHelpLaunchResult> LaunchAsync(EnvironmentCheckReport report, InstallContext ctx, CancellationToken ct)
{
string reportPath;
try
{
var content = await BuildReportAsync(report, ctx, ct);
reportPath = Path.Combine(Path.GetTempPath(), ReportFileName);
await File.WriteAllTextAsync(reportPath, content, ct);
}
catch (Exception ex)
{
return new ClaudeHelpLaunchResult(false, ex.Message);
}
return LaunchTerminal(reportPath, ctx);
}
public ClaudeHelpLaunchResult LaunchTerminal(string reportPath, InstallContext ctx)
{
var claude = ExecutableResolver.Resolve(ctx.ClaudeBin, _pathOverride, _pathExtOverride);
if (claude is null)
{
return new ClaudeHelpLaunchResult(false, $"'{ctx.ClaudeBin}' was not found on PATH.");
}
var tempDir = Path.GetTempPath();
var promptText = $"Lies {reportPath} und hilf mir, mein ClaudeDo-Setup zum Laufen zu bringen.";
var claudeCommand = $"{Quote(claude.Path)} {Quote(promptText)}";
var wt = ExecutableResolver.Resolve("wt", _pathOverride, _pathExtOverride);
var startInfo = wt is not null
? new ProcessStartInfo(wt.Path)
{
Arguments = $"-d {QuoteDirectory(tempDir)} cmd.exe /k {claudeCommand}",
WorkingDirectory = tempDir,
UseShellExecute = false,
}
: new ProcessStartInfo("cmd.exe")
{
Arguments = $"/k {claudeCommand}",
WorkingDirectory = tempDir,
UseShellExecute = false,
};
try
{
_processLauncher.Start(startInfo);
return new ClaudeHelpLaunchResult(true, null);
}
catch (Exception ex)
{
return new ClaudeHelpLaunchResult(false, ex.Message);
}
}
private async Task<string> RunDotnetListRuntimesAsync(CancellationToken ct)
{
try
{
var (_, output) = await _processRunner.RunAsync("dotnet", "--list-runtimes", null, ct);
return output.Trim();
}
catch (Exception ex)
{
return $"(could not run 'dotnet --list-runtimes': {ex.Message})";
}
}
private static string? FindMessage(EnvironmentCheckReport report, string checkId) =>
report.Results.FirstOrDefault(r => r.Id == checkId)?.Message;
private static string EscapeCell(string value) =>
value.Replace("|", "\\|").Replace("\r", " ").Replace("\n", " ");
private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value;
private static string QuoteDirectory(string directory) => Quote(directory.TrimEnd('\\', '/'));
}
@@ -0,0 +1,9 @@
using System.Diagnostics;
namespace ClaudeDo.Installer.Core.Interfaces;
/// <summary>Starts a detached process without waiting for it to exit or capturing its output.</summary>
public interface IProcessLauncher
{
void Start(ProcessStartInfo startInfo);
}
@@ -0,0 +1,9 @@
using System.Diagnostics;
using ClaudeDo.Installer.Core.Interfaces;
namespace ClaudeDo.Installer.Core;
public sealed class ProcessLauncher : IProcessLauncher
{
public void Start(ProcessStartInfo startInfo) => Process.Start(startInfo);
}
@@ -90,10 +90,18 @@
</StackPanel> </StackPanel>
<!-- Footer --> <!-- Footer -->
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0"> <StackPanel Grid.Row="3" Margin="0,12,0,0">
<!-- Reserved space: a "Claude Help Me" button lands here in a follow-up task. --> <TextBlock Text="{Binding ClaudeHelpError}" FontSize="12" TextWrapping="Wrap"
<Button Content="{loc:Tr installer.systemCheck.recheck}" Foreground="{StaticResource ErrorBrush}" Margin="0,0,0,8"
Command="{Binding RunChecksCommand}"/> Visibility="{Binding ClaudeHelpError, Converter={StaticResource NullToCollapsedConverter}}"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="{loc:Tr installer.systemCheck.claudeHelp.button}"
ToolTip="{Binding ClaudeHelpTooltip}"
Command="{Binding StartClaudeHelpCommand}"
Margin="0,0,8,0"/>
<Button Content="{loc:Tr installer.systemCheck.recheck}"
Command="{Binding RunChecksCommand}"/>
</StackPanel>
</StackPanel> </StackPanel>
</Grid> </Grid>
</UserControl> </UserControl>
@@ -51,8 +51,10 @@ public partial class SystemCheckPageViewModel : ObservableObject, IInstallerPage
{ {
private readonly InstallContext _context; private readonly InstallContext _context;
private readonly Func<EnvironmentCheckService> _checkServiceFactory; private readonly Func<EnvironmentCheckService> _checkServiceFactory;
private readonly ClaudeHelpLauncher _claudeHelpLauncher;
private SystemCheckPageView? _view; private SystemCheckPageView? _view;
private bool _hasStarted; private bool _hasStarted;
private EnvironmentCheckReport? _lastReport;
public string Title => TrExtension.Localizer?["installer.systemCheck.title"] ?? "System Check"; public string Title => TrExtension.Localizer?["installer.systemCheck.title"] ?? "System Check";
public string Icon => ""; public string Icon => "";
@@ -67,13 +69,40 @@ public partial class SystemCheckPageViewModel : ObservableObject, IInstallerPage
[ObservableProperty] private bool _hasRun; [ObservableProperty] private bool _hasRun;
[ObservableProperty] private bool _hasBlockingError; [ObservableProperty] private bool _hasBlockingError;
[ObservableProperty] private string _summary = string.Empty; [ObservableProperty] private string _summary = string.Empty;
[ObservableProperty] private string? _claudeHelpError;
public bool BlocksNavigation => IsRunning || HasBlockingError; public bool BlocksNavigation => IsRunning || HasBlockingError;
public SystemCheckPageViewModel(InstallContext context, Func<EnvironmentCheckService> checkServiceFactory) public bool ClaudeCliOk =>
_lastReport?.Results.FirstOrDefault(r => r.Id == ClaudeCliCheck.CheckId)?.Status == CheckStatus.Ok;
public bool ClaudeAuthFailed =>
_lastReport?.Results.FirstOrDefault(r => r.Id == ClaudeAuthCheck.CheckId)?.Status == CheckStatus.Failed;
public bool CanStartClaudeHelp => ClaudeCliOk && !ClaudeAuthFailed;
public string ClaudeHelpTooltip
{
get
{
var loc = TrExtension.Localizer;
if (!ClaudeCliOk)
return loc?["installer.systemCheck.claudeHelp.tooltip.cliMissing"] ?? "The Claude CLI was not found.";
if (ClaudeAuthFailed)
return loc?["installer.systemCheck.claudeHelp.tooltip.notLoggedIn"] ?? "Claude is not logged in.";
return loc?["installer.systemCheck.claudeHelp.tooltip.ready"]
?? "Start an interactive Claude session to help troubleshoot your setup.";
}
}
public SystemCheckPageViewModel(
InstallContext context,
Func<EnvironmentCheckService> checkServiceFactory,
ClaudeHelpLauncher claudeHelpLauncher)
{ {
_context = context; _context = context;
_checkServiceFactory = checkServiceFactory; _checkServiceFactory = checkServiceFactory;
_claudeHelpLauncher = claudeHelpLauncher;
} }
partial void OnIsRunningChanged(bool value) partial void OnIsRunningChanged(bool value)
@@ -115,9 +144,14 @@ public partial class SystemCheckPageViewModel : ObservableObject, IInstallerPage
foreach (var result in report.Results) foreach (var result in report.Results)
Rows.Add(new CheckRowViewModel(result)); Rows.Add(new CheckRowViewModel(result));
_lastReport = report;
HasBlockingError = report.HasBlockingError; HasBlockingError = report.HasBlockingError;
Summary = BuildSummary(report); Summary = BuildSummary(report);
HasRun = true; HasRun = true;
OnPropertyChanged(nameof(CanStartClaudeHelp));
OnPropertyChanged(nameof(ClaudeHelpTooltip));
StartClaudeHelpCommand.NotifyCanExecuteChanged();
} }
finally finally
{ {
@@ -127,6 +161,20 @@ public partial class SystemCheckPageViewModel : ObservableObject, IInstallerPage
private bool CanRunChecks() => !IsRunning; private bool CanRunChecks() => !IsRunning;
[RelayCommand(CanExecute = nameof(CanStartClaudeHelp))]
private async Task StartClaudeHelpAsync()
{
if (_lastReport is null) return;
ClaudeHelpError = null;
var result = await _claudeHelpLauncher.LaunchAsync(_lastReport, _context, CancellationToken.None);
if (!result.Success)
{
ClaudeHelpError = TrExtension.Localizer?.Get("installer.systemCheck.claudeHelp.error", result.ErrorMessage ?? "")
?? $"Could not start the Claude session: {result.ErrorMessage}";
}
}
private static string BuildSummary(EnvironmentCheckReport report) private static string BuildSummary(EnvironmentCheckReport report)
{ {
var loc = TrExtension.Localizer; var loc = TrExtension.Localizer;
@@ -498,6 +498,15 @@
"ok": "Alles in Ordnung.", "ok": "Alles in Ordnung.",
"warnings": "{0} Warnung(en) gefunden.", "warnings": "{0} Warnung(en) gefunden.",
"blocking": "{0} Problem(e) müssen behoben werden: {1}" "blocking": "{0} Problem(e) müssen behoben werden: {1}"
},
"claudeHelp": {
"button": "Claude Help Me",
"error": "Claude-Session konnte nicht gestartet werden: {0}",
"tooltip": {
"cliMissing": "Claude CLI wurde nicht gefunden — installiere sie zuerst (siehe Hinweis zur Claude CLI oben).",
"notLoggedIn": "Claude ist nicht eingeloggt — führe einmal 'claude' aus und schließe den Login-Vorgang ab.",
"ready": "Öffnet eine interaktive Claude-Session, die dir beim Einrichten hilft."
}
} }
}, },
"paths": { "paths": {
@@ -498,6 +498,15 @@
"ok": "Everything looks good.", "ok": "Everything looks good.",
"warnings": "{0} warning(s) found.", "warnings": "{0} warning(s) found.",
"blocking": "{0} problem(s) must be fixed: {1}" "blocking": "{0} problem(s) must be fixed: {1}"
},
"claudeHelp": {
"button": "Claude Help Me",
"error": "Could not start the Claude session: {0}",
"tooltip": {
"cliMissing": "The Claude CLI was not found — install it first (see the Claude CLI hint above).",
"notLoggedIn": "Claude is not logged in — run 'claude' once and complete the login flow.",
"ready": "Open an interactive Claude session to help fix your setup."
}
} }
}, },
"paths": { "paths": {
@@ -0,0 +1,215 @@
using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Tests.Checks;
namespace ClaudeDo.Installer.Tests.Core;
public sealed class ClaudeHelpLauncherTests : IDisposable
{
private readonly string _dir;
public ClaudeHelpLauncherTests()
{
_dir = Path.Combine(Path.GetTempPath(), $"cdclaudehelp_{Guid.NewGuid():N}");
Directory.CreateDirectory(_dir);
}
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
private ClaudeHelpLauncher MakeLauncher(FakeProcessRunner? runner = null, FakeProcessLauncher? launcher = null, string? pathExtOverride = ".exe") =>
new(runner ?? new FakeProcessRunner(), launcher ?? new FakeProcessLauncher(), pathOverride: _dir, pathExtOverride: pathExtOverride);
private static EnvironmentCheckReport MakeReport() => new(new[]
{
CheckResult.Ok(GitCheck.CheckId, CheckSeverity.Error, "checks.git.title", "C:\\Program Files\\Git\\git.exe — 2.43.0"),
CheckResult.Ok(ClaudeCliCheck.CheckId, CheckSeverity.Error, "checks.claudeCli.title", "C:\\npm\\claude.cmd — 2.1.230"),
CheckResult.Fail(ClaudeAuthCheck.CheckId, CheckSeverity.Error, "checks.claudeAuth.title", "Not logged in.",
"checks.claudeAuth.hint", detail: "exit code 1: some raw CLI output"),
});
// ----- BuildReportAsync -----
[Fact]
public async Task Report_includes_all_check_ids_severities_and_failed_details()
{
var launcher = MakeLauncher();
var report = MakeReport();
var text = await launcher.BuildReportAsync(report, new InstallContext(), CancellationToken.None);
Assert.Contains(GitCheck.CheckId, text);
Assert.Contains(ClaudeCliCheck.CheckId, text);
Assert.Contains(ClaudeAuthCheck.CheckId, text);
Assert.Contains(CheckSeverity.Error.ToString(), text);
Assert.Contains(CheckStatus.Failed.ToString(), text);
Assert.Contains("exit code 1: some raw CLI output", text);
}
[Fact]
public async Task Report_does_not_include_environment_variable_values()
{
const string marker = "sk-super-secret-token-12345";
Environment.SetEnvironmentVariable("CLAUDEDO_TEST_SECRET", marker);
try
{
var launcher = MakeLauncher();
var text = await launcher.BuildReportAsync(MakeReport(), new InstallContext(), CancellationToken.None);
Assert.DoesNotContain(marker, text);
Assert.DoesNotContain(".credentials.json", text);
}
finally
{
Environment.SetEnvironmentVariable("CLAUDEDO_TEST_SECRET", null);
}
}
[Fact]
public async Task Report_includes_planned_install_directory_and_ports()
{
var launcher = MakeLauncher();
var ctx = new InstallContext { InstallDirectory = @"C:\Somewhere\ClaudeDo", SignalRPort = 11111, ExternalMcpPort = 22222 };
var text = await launcher.BuildReportAsync(MakeReport(), ctx, CancellationToken.None);
Assert.Contains(@"C:\Somewhere\ClaudeDo", text);
Assert.Contains("11111", text);
Assert.Contains("22222", text);
}
// ----- LaunchTerminal -----
private string MakeClaudeExe()
{
var path = Path.Combine(_dir, "claude.exe");
File.WriteAllText(path, "");
return path;
}
[Fact]
public void Uses_wt_when_available()
{
MakeClaudeExe();
File.WriteAllText(Path.Combine(_dir, "wt.exe"), "");
var fakeLauncher = new FakeProcessLauncher();
var launcher = MakeLauncher(launcher: fakeLauncher);
var result = launcher.LaunchTerminal(Path.Combine(_dir, "report.md"), new InstallContext());
Assert.True(result.Success);
Assert.Contains("wt.exe", fakeLauncher.LastStartInfo!.FileName);
}
[Fact]
public void Falls_back_to_cmd_when_wt_is_not_available()
{
MakeClaudeExe();
var fakeLauncher = new FakeProcessLauncher();
var launcher = MakeLauncher(launcher: fakeLauncher);
var result = launcher.LaunchTerminal(Path.Combine(_dir, "report.md"), new InstallContext());
Assert.True(result.Success);
Assert.Equal("cmd.exe", fakeLauncher.LastStartInfo!.FileName);
}
[Fact]
public void Working_directory_is_temp_not_install_directory()
{
MakeClaudeExe();
var fakeLauncher = new FakeProcessLauncher();
var launcher = MakeLauncher(launcher: fakeLauncher);
var ctx = new InstallContext { InstallDirectory = @"C:\Program Files\ClaudeDo" };
launcher.LaunchTerminal(Path.Combine(_dir, "report.md"), ctx);
Assert.Equal(Path.GetTempPath(), fakeLauncher.LastStartInfo!.WorkingDirectory);
Assert.DoesNotContain(ctx.InstallDirectory, fakeLauncher.LastStartInfo!.WorkingDirectory);
}
[Fact]
public void Quotes_the_claude_path_when_it_contains_spaces()
{
var spacedDir = Path.Combine(_dir, "path with spaces");
Directory.CreateDirectory(spacedDir);
var claudePath = Path.Combine(spacedDir, "claude.exe");
File.WriteAllText(claudePath, "");
var fakeLauncher = new FakeProcessLauncher();
var runner = new FakeProcessRunner();
var launcher = new ClaudeHelpLauncher(runner, fakeLauncher, pathOverride: spacedDir, pathExtOverride: ".exe");
var result = launcher.LaunchTerminal(Path.Combine(_dir, "report.md"), new InstallContext());
Assert.True(result.Success);
Assert.Contains($"\"{claudePath}\"", fakeLauncher.LastStartInfo!.Arguments);
}
[Fact]
public void Cmd_shim_as_claude_works()
{
var claudePath = Path.Combine(_dir, "claude.cmd");
File.WriteAllText(claudePath, "");
var fakeLauncher = new FakeProcessLauncher();
var launcher = MakeLauncher(launcher: fakeLauncher, pathExtOverride: ".cmd");
var result = launcher.LaunchTerminal(Path.Combine(_dir, "report.md"), new InstallContext());
Assert.True(result.Success);
Assert.Contains(claudePath, fakeLauncher.LastStartInfo!.Arguments);
}
[Fact]
public void Claude_not_found_returns_error_result_not_exception()
{
var fakeLauncher = new FakeProcessLauncher();
var launcher = MakeLauncher(launcher: fakeLauncher);
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
var result = launcher.LaunchTerminal(Path.Combine(_dir, "report.md"), ctx);
Assert.False(result.Success);
Assert.NotNull(result.ErrorMessage);
Assert.Null(fakeLauncher.LastStartInfo);
}
[Fact]
public void Failed_process_start_returns_error_result_not_exception()
{
MakeClaudeExe();
var fakeLauncher = new FakeProcessLauncher(throwOnStart: new InvalidOperationException("boom"));
var launcher = MakeLauncher(launcher: fakeLauncher);
var result = launcher.LaunchTerminal(Path.Combine(_dir, "report.md"), new InstallContext());
Assert.False(result.Success);
Assert.Equal("boom", result.ErrorMessage);
}
// ----- LaunchAsync (report + terminal wiring) -----
[Fact]
public async Task LaunchAsync_writes_report_and_points_the_prompt_at_it()
{
MakeClaudeExe();
var fakeLauncher = new FakeProcessLauncher();
var launcher = MakeLauncher(launcher: fakeLauncher);
var result = await launcher.LaunchAsync(MakeReport(), new InstallContext(), CancellationToken.None);
var reportPath = Path.Combine(Path.GetTempPath(), ClaudeHelpLauncher.ReportFileName);
try
{
Assert.True(result.Success);
Assert.True(File.Exists(reportPath));
Assert.Contains(GitCheck.CheckId, await File.ReadAllTextAsync(reportPath));
Assert.Contains(reportPath, fakeLauncher.LastStartInfo!.Arguments);
}
finally
{
try { File.Delete(reportPath); } catch { }
}
}
}
@@ -0,0 +1,22 @@
using System.Diagnostics;
using ClaudeDo.Installer.Core.Interfaces;
namespace ClaudeDo.Installer.Tests.Core;
internal sealed class FakeProcessLauncher : IProcessLauncher
{
private readonly Exception? _throwOnStart;
public FakeProcessLauncher(Exception? throwOnStart = null)
{
_throwOnStart = throwOnStart;
}
public ProcessStartInfo? LastStartInfo { get; private set; }
public void Start(ProcessStartInfo startInfo)
{
LastStartInfo = startInfo;
if (_throwOnStart is not null) throw _throwOnStart;
}
}
@@ -0,0 +1,11 @@
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Tests.Checks;
namespace ClaudeDo.Installer.Tests.Core;
/// <summary>A ClaudeHelpLauncher for tests that don't exercise it, just need a valid instance.</summary>
internal static class TestClaudeHelpLauncher
{
public static ClaudeHelpLauncher Create() =>
new(new FakeProcessRunner(), new FakeProcessLauncher());
}
@@ -4,6 +4,7 @@ using ClaudeDo.Installer.Pages.ServicePage;
using ClaudeDo.Installer.Pages.SystemCheckPage; using ClaudeDo.Installer.Pages.SystemCheckPage;
using ClaudeDo.Installer.Pages.UiSettingsPage; using ClaudeDo.Installer.Pages.UiSettingsPage;
using ClaudeDo.Installer.Pages.WelcomePage; using ClaudeDo.Installer.Pages.WelcomePage;
using ClaudeDo.Installer.Tests.Core;
namespace ClaudeDo.Installer.Tests; namespace ClaudeDo.Installer.Tests;
@@ -16,7 +17,7 @@ public class PageResolverTests
var pages = new IInstallerPage[] var pages = new IInstallerPage[]
{ {
new WelcomePageViewModel(context), new WelcomePageViewModel(context),
new SystemCheckPageViewModel(context, () => throw new InvalidOperationException()), new SystemCheckPageViewModel(context, () => throw new InvalidOperationException(), TestClaudeHelpLauncher.Create()),
new PathsPageViewModel(context), new PathsPageViewModel(context),
new ServicePageViewModel(context), new ServicePageViewModel(context),
new UiSettingsPageViewModel(context), new UiSettingsPageViewModel(context),
@@ -36,7 +37,7 @@ public class PageResolverTests
public void SystemCheckPage_is_not_shown_in_settings() public void SystemCheckPage_is_not_shown_in_settings()
{ {
var context = new InstallContext(); var context = new InstallContext();
var page = new SystemCheckPageViewModel(context, () => throw new InvalidOperationException()); var page = new SystemCheckPageViewModel(context, () => throw new InvalidOperationException(), TestClaudeHelpLauncher.Create());
Assert.False(page.ShowInSettings); Assert.False(page.ShowInSettings);
} }
@@ -1,13 +1,14 @@
using ClaudeDo.Installer.Checks; using ClaudeDo.Installer.Checks;
using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Tests.Core;
using Vm = ClaudeDo.Installer.Pages.SystemCheckPage.SystemCheckPageViewModel; using Vm = ClaudeDo.Installer.Pages.SystemCheckPage.SystemCheckPageViewModel;
namespace ClaudeDo.Installer.Tests.Pages.SystemCheckPage; namespace ClaudeDo.Installer.Tests.Pages.SystemCheckPage;
public sealed class SystemCheckPageViewModelTests public sealed class SystemCheckPageViewModelTests
{ {
private static Vm CreateViewModel(Func<EnvironmentCheckService> factory) => private static Vm CreateViewModel(Func<EnvironmentCheckService> factory, ClaudeHelpLauncher? claudeHelpLauncher = null, InstallContext? context = null) =>
new(new InstallContext(), factory); new(context ?? new InstallContext(), factory, claudeHelpLauncher ?? TestClaudeHelpLauncher.Create());
private static Func<EnvironmentCheckService> FactoryFor(params IEnvironmentCheck[] checks) => private static Func<EnvironmentCheckService> FactoryFor(params IEnvironmentCheck[] checks) =>
() => new EnvironmentCheckService(checks); () => new EnvironmentCheckService(checks);
@@ -169,4 +170,66 @@ public sealed class SystemCheckPageViewModelTests
gate.SetResult(); gate.SetResult();
await run; await run;
} }
[Fact]
public async Task ClaudeHelp_disabled_when_claude_cli_check_failed()
{
var vm = CreateViewModel(FactoryFor(
FakeEnvironmentCheck.Fail(ClaudeCliCheck.CheckId, CheckSeverity.Error),
FakeEnvironmentCheck.Ok(ClaudeAuthCheck.CheckId)));
await vm.LoadAsync();
Assert.False(vm.CanStartClaudeHelp);
}
[Fact]
public async Task ClaudeHelp_disabled_when_claude_auth_check_failed()
{
var vm = CreateViewModel(FactoryFor(
FakeEnvironmentCheck.Ok(ClaudeCliCheck.CheckId),
FakeEnvironmentCheck.Fail(ClaudeAuthCheck.CheckId, CheckSeverity.Error)));
await vm.LoadAsync();
Assert.False(vm.CanStartClaudeHelp);
}
[Fact]
public async Task ClaudeHelp_enabled_when_both_checks_ok()
{
var vm = CreateViewModel(FactoryFor(
FakeEnvironmentCheck.Ok(ClaudeCliCheck.CheckId),
FakeEnvironmentCheck.Ok(ClaudeAuthCheck.CheckId)));
await vm.LoadAsync();
Assert.True(vm.CanStartClaudeHelp);
}
[Fact]
public async Task ClaudeHelp_stays_enabled_when_auth_check_is_unknown()
{
var vm = CreateViewModel(FactoryFor(
FakeEnvironmentCheck.Ok(ClaudeCliCheck.CheckId),
FakeEnvironmentCheck.Unknown(ClaudeAuthCheck.CheckId)));
await vm.LoadAsync();
Assert.True(vm.CanStartClaudeHelp);
}
[Fact]
public async Task ClaudeHelp_start_failure_sets_error_message_without_throwing()
{
var ctx = new InstallContext { ClaudeBin = "claudedo_totally_missing_cmd_9f3a1" };
var vm = CreateViewModel(
FactoryFor(FakeEnvironmentCheck.Ok(ClaudeCliCheck.CheckId), FakeEnvironmentCheck.Ok(ClaudeAuthCheck.CheckId)),
context: ctx);
await vm.LoadAsync();
await vm.StartClaudeHelpCommand.ExecuteAsync(null);
Assert.NotNull(vm.ClaudeHelpError);
}
} }
@@ -6,6 +6,7 @@ using ClaudeDo.Installer.Pages.ServicePage;
using ClaudeDo.Installer.Pages.SystemCheckPage; using ClaudeDo.Installer.Pages.SystemCheckPage;
using ClaudeDo.Installer.Pages.UiSettingsPage; using ClaudeDo.Installer.Pages.UiSettingsPage;
using ClaudeDo.Installer.Pages.WelcomePage; using ClaudeDo.Installer.Pages.WelcomePage;
using ClaudeDo.Installer.Tests.Core;
using ClaudeDo.Installer.Tests.Pages.SystemCheckPage; using ClaudeDo.Installer.Tests.Pages.SystemCheckPage;
using ClaudeDo.Installer.Views; using ClaudeDo.Installer.Views;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -20,7 +21,7 @@ public class WizardViewModelTests
return new IInstallerPage[] return new IInstallerPage[]
{ {
new WelcomePageViewModel(context), new WelcomePageViewModel(context),
new SystemCheckPageViewModel(context, () => throw new InvalidOperationException("not needed for this test")), new SystemCheckPageViewModel(context, () => throw new InvalidOperationException("not needed for this test"), TestClaudeHelpLauncher.Create()),
new PathsPageViewModel(context), new PathsPageViewModel(context),
new ServicePageViewModel(context), new ServicePageViewModel(context),
new UiSettingsPageViewModel(context), new UiSettingsPageViewModel(context),
@@ -65,7 +66,7 @@ public class WizardViewModelTests
? FakeEnvironmentCheck.Fail("git", CheckSeverity.Error) ? FakeEnvironmentCheck.Fail("git", CheckSeverity.Error)
: FakeEnvironmentCheck.Ok("git"); : FakeEnvironmentCheck.Ok("git");
return new EnvironmentCheckService(new[] { check }); return new EnvironmentCheckService(new[] { check });
}); }, TestClaudeHelpLauncher.Create());
var resolver = new PageResolver(new IInstallerPage[] var resolver = new PageResolver(new IInstallerPage[]
{ {