Merge task branch for: „Claude Help Me"-Button: Claude-Session zur Setup-Fehlersuche starten
This commit is contained in:
+25
-20
@@ -140,33 +140,38 @@ verifiziert**:
|
||||
|
||||
## Offene Verifikation (2026-08-05, Environment Checks / SystemCheckPage)
|
||||
|
||||
**Voraussetzung, bevor irgendeiner der Punkte unten geprüft werden kann:** die Branches
|
||||
`claudedo/06aca9b3afec4b939f59b627bfe21737` (Checks + SystemCheckPage) und
|
||||
`claudedo/40272c0bb3b14562b59c022d09c382b6` (ExecutableResolver-Wiring in
|
||||
`ClaudeDo.Worker`) müssen erst gemerged werden — sie lagen bei dieser Abschluss-Session
|
||||
noch nicht auf `main`. Build/Test-Nachweis unten stammt aus einer lokalen Scratch-Integration
|
||||
beider Branches, nicht aus `main` selbst. Details → `installer-preflight` in
|
||||
`docs/explore-notes/README.md` und den neuen Abschnitt „Environment Checks" in
|
||||
`src/ClaudeDo.Installer/CLAUDE.md`.
|
||||
Checks + SystemCheckPage (`claudedo/06aca9b3…`) und das ExecutableResolver-Wiring im Worker
|
||||
(`claudedo/40272c0b…`) sind seit 2026-08-06 auf `main` gemerged; die frühere „erst mergen"-
|
||||
Voraussetzung ist erledigt. Details → `installer-preflight` in `docs/explore-notes/README.md`
|
||||
und der Abschnitt „Environment Checks" in `src/ClaudeDo.Installer/CLAUDE.md`.
|
||||
|
||||
**Update (2026-08-06):** die Diagnose-Sektion (Config-Modus/`SettingsWindow`) ist implementiert
|
||||
— `Pages/DiagnosePage/` + geteilte `Checks/CheckListViewModel.cs`/`Checks/CheckListView.xaml`
|
||||
(auch von `SystemCheckPage` genutzt, keine zweite Implementierung). Unit-getestet
|
||||
(`tests/ClaudeDo.Installer.Tests/Pages/DiagnosePage/DiagnosePageViewModelTests.cs`), **aber
|
||||
nicht visuell verifiziert** — siehe Punkt unten. Der „Claude Help Me"-Button bleibt offen (sein
|
||||
Footer-Slot ist in `CheckListView.xaml` reserviert):
|
||||
**Update (2026-08-06):** beide Folge-Features sind jetzt implementiert und auf `main`.
|
||||
|
||||
- [ ] „Claude Help Me" öffnet ein Terminal mit laufender Claude-Session, und die Session hat
|
||||
den Diagnose-Report tatsächlich gelesen — **nicht umsetzbar, Feature existiert nicht.**
|
||||
- [ ] Der Help-Me-Button ist korrekt deaktiviert, wenn `claude` nicht im PATH ist, mit
|
||||
verständlichem Tooltip — **nicht umsetzbar, Feature existiert nicht.**
|
||||
- Diagnose-Sektion (Config-Modus/`SettingsWindow`): `Pages/DiagnosePage/` + geteilte
|
||||
`Checks/CheckListViewModel.cs`/`Checks/CheckListView.xaml` (auch von `SystemCheckPage`
|
||||
genutzt, keine zweite Implementierung). Unit-getestet
|
||||
(`tests/ClaudeDo.Installer.Tests/Pages/DiagnosePage/DiagnosePageViewModelTests.cs`).
|
||||
- „Claude Help Me"-Button: `Core/ClaudeHelpLauncher.cs` + `SystemCheckPageViewModel`/-View,
|
||||
unit-getestet (`tests/ClaudeDo.Installer.Tests/Core/ClaudeHelpLauncherTests.cs`).
|
||||
|
||||
Beides **nicht visuell verifiziert**:
|
||||
|
||||
- [x] Help-Me-Button ist deaktiviert, wenn `claude-cli` nicht `Ok` ist oder `claude-auth`
|
||||
`Failed` ist (bleibt aktiv bei `Unknown`), mit erklärendem Tooltip — unit-getestet.
|
||||
- [ ] „Claude Help Me" öffnet tatsächlich ein Terminal mit laufender Claude-Session, und die
|
||||
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).
|
||||
- [ ] Platzierung des Help-Me-Buttons: er sitzt seit dem Merge der Diagnose-Sektion in einer
|
||||
**eigenen Zeile unter** dem geteilten Check-Listen-Footer (der reservierte Slot *im*
|
||||
Footer entfiel mit der Extraktion nach `CheckListView.xaml`). Optisch prüfen, ob das
|
||||
so bleiben soll oder ob der Button in den geteilten Footer gehört.
|
||||
- [ ] Diagnose-Sektion im Config-Modus: Öffnen von SettingsWindow löst keinen Prüflauf aus, Klick
|
||||
auf „Erneut prüfen" schon; zeigt die echten installierten Pfade/Ports (nicht die
|
||||
InstallContext-Defaults), und der laufende Worker auf dem konfigurierten SignalR-Port gilt
|
||||
nicht als Konflikt — **unit-verifiziert, visueller Durchlauf noch offen.**
|
||||
|
||||
Sobald die beiden Branches oben gemerged sind, sind folgende Punkte real prüfbar (gebaut +
|
||||
unit-getestet gegen die Scratch-Integration, aber **nicht visuell verifiziert**):
|
||||
Weitere Punkte, gebaut + unit-getestet auf `main`, aber **nicht visuell verifiziert**:
|
||||
|
||||
- [ ] SystemCheckPage: Layout, Icon-/Farbwirkung der vier Status (Ok grün / Warnung orange /
|
||||
Fehler rot / Unbekannt grau — `StatusGreenBrush`/`StatusOrangeBrush`/`StatusRedBrush`/
|
||||
|
||||
@@ -129,7 +129,9 @@ public partial class App : Application
|
||||
// Environment checks — stateless, so their infrastructure is shared; ClaudeCliLookup is
|
||||
// rebuilt per EnvironmentCheckService instance so a re-check doesn't reuse a stale result.
|
||||
sc.AddSingleton<IProcessRunner, ProcessRunnerAdapter>();
|
||||
sc.AddSingleton<IProcessLauncher, ProcessLauncher>();
|
||||
sc.AddSingleton<IPortOwnerResolver, NetstatPortOwnerResolver>();
|
||||
sc.AddSingleton<ClaudeHelpLauncher>();
|
||||
sc.AddTransient<Func<EnvironmentCheckService>>(sp => () =>
|
||||
{
|
||||
var processRunner = sp.GetRequiredService<IProcessRunner>();
|
||||
|
||||
@@ -128,17 +128,6 @@ The Apps & Features uninstall string and "Rerun Installer" both point at `<Insta
|
||||
|
||||
## Environment Checks
|
||||
|
||||
> **Merge status (2026-08-05): not yet on `main`.** The `Checks/` folder, `SystemCheckPage`,
|
||||
> and `ExecutableResolver` described below exist only on unmerged task branches
|
||||
> (`claudedo/06aca9b3afec4b939f59b627bfe21737` for the Installer side,
|
||||
> `claudedo/40272c0bb3b14562b59c022d09c382b6` for the `ClaudeDo.Worker` wiring). Build/test
|
||||
> verification for this section was done against a local scratch integration of both, not
|
||||
> 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`:
|
||||
|
||||
| Check | Severity | What it verifies |
|
||||
@@ -173,6 +162,20 @@ 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"
|
||||
button re-runs `EnvironmentCheckService.RunAllAsync` (disabled while already running).
|
||||
|
||||
**"Claude Help Me" button** (`Core/ClaudeHelpLauncher.cs`) — a second button below the shared
|
||||
check-list footer, enabled only when `claude-cli` is `Ok` and `claude-auth` is not `Failed` (`Unknown`
|
||||
stays enabled — an indeterminate login state shouldn't block the one feature that could help
|
||||
diagnose it). `BuildReportAsync` renders all check results (Id/Severity/Status/Message table,
|
||||
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. It reads the last report via `CheckListViewModel.LastReport`, so it re-evaluates on
|
||||
every check run.
|
||||
|
||||
`DiagnosePage` (`Pages/DiagnosePage/`) hosts the same `CheckListView` in `SettingsWindow` (Config
|
||||
mode only, `ShowInSettings = true` / `ShowInWizard = false`, `Order = 5` — after UiSettings).
|
||||
Nothing here blocks navigation and checks do **not** auto-run on load — only on a "Recheck" click.
|
||||
@@ -180,8 +183,5 @@ Unlike the wizard, its `InstallContext` is built from the **installed** configur
|
||||
(`InstallerWorkerConfig.Load()` for `ClaudeBin`/`SignalRPort`, the shared `InstallContext` for
|
||||
`InstallDirectory`/`ExternalMcpPort`), refreshed on every `LoadAsync()` — not the wizard-default
|
||||
`InstallContext` the DI container hands out, which is only populated once a page's `ApplyAsync`
|
||||
(i.e. Save) runs.
|
||||
|
||||
**Not implemented (see merge-status note above):** a "Claude Help Me" button that launches an
|
||||
external terminal with a live `claude` session for setup troubleshooting. Its footer slot is
|
||||
reserved in `CheckListView.xaml`; still blocked on the same follow-up as before.
|
||||
(i.e. Save) runs. `DiagnosePage` intentionally has **no** "Claude Help Me" button — that one is
|
||||
wizard-only.
|
||||
|
||||
@@ -58,6 +58,10 @@ public partial class CheckListViewModel : ObservableObject
|
||||
|
||||
public ObservableCollection<CheckRowViewModel> Rows { get; } = [];
|
||||
|
||||
/// <summary>Report of the most recent run, null before the first one. Hosts that need the raw
|
||||
/// results (the wizard's "Claude Help Me" button) read it instead of running the checks again.</summary>
|
||||
public EnvironmentCheckReport? LastReport { get; private set; }
|
||||
|
||||
[ObservableProperty] private bool _isRunning;
|
||||
[ObservableProperty] private bool _hasRun;
|
||||
[ObservableProperty] private bool _hasBlockingError;
|
||||
@@ -88,6 +92,8 @@ public partial class CheckListViewModel : ObservableObject
|
||||
foreach (var result in report.Results)
|
||||
Rows.Add(new CheckRowViewModel(result));
|
||||
|
||||
LastReport = report;
|
||||
OnPropertyChanged(nameof(LastReport));
|
||||
HasBlockingError = report.HasBlockingError;
|
||||
Summary = BuildSummary(report);
|
||||
HasRun = true;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Header -->
|
||||
@@ -23,5 +24,16 @@
|
||||
</StackPanel>
|
||||
|
||||
<checks:CheckListView Grid.Row="1" DataContext="{Binding Checks}"/>
|
||||
|
||||
<!-- Wizard-only footer: "Claude Help Me" (the Recheck button lives in the shared list) -->
|
||||
<StackPanel Grid.Row="2" Margin="0,8,0,0">
|
||||
<TextBlock Text="{Binding ClaudeHelpError}" FontSize="12" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource ErrorBrush}" Margin="0,0,0,8"
|
||||
Visibility="{Binding ClaudeHelpError, Converter={StaticResource NullToCollapsedConverter}}"/>
|
||||
<Button Content="{loc:Tr installer.systemCheck.claudeHelp.button}"
|
||||
HorizontalAlignment="Right"
|
||||
ToolTip="{Binding ClaudeHelpTooltip}"
|
||||
Command="{Binding StartClaudeHelpCommand}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -10,6 +10,8 @@ namespace ClaudeDo.Installer.Pages.SystemCheckPage;
|
||||
|
||||
public partial class SystemCheckPageViewModel : ObservableObject, IInstallerPage
|
||||
{
|
||||
private readonly InstallContext _context;
|
||||
private readonly ClaudeHelpLauncher _claudeHelpLauncher;
|
||||
private SystemCheckPageView? _view;
|
||||
private bool _hasStarted;
|
||||
|
||||
@@ -33,12 +35,50 @@ public partial class SystemCheckPageViewModel : ObservableObject, IInstallerPage
|
||||
|
||||
public bool BlocksNavigation => Checks.IsRunning || Checks.HasBlockingError;
|
||||
|
||||
public SystemCheckPageViewModel(InstallContext context, Func<EnvironmentCheckService> checkServiceFactory)
|
||||
[ObservableProperty] private string? _claudeHelpError;
|
||||
|
||||
public bool ClaudeCliOk =>
|
||||
Checks.LastReport?.Results.FirstOrDefault(r => r.Id == ClaudeCliCheck.CheckId)?.Status == CheckStatus.Ok;
|
||||
|
||||
public bool ClaudeAuthFailed =>
|
||||
Checks.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;
|
||||
_claudeHelpLauncher = claudeHelpLauncher;
|
||||
Checks = new CheckListViewModel(context, checkServiceFactory);
|
||||
// The wizard listens for PropertyChanged on this page to re-evaluate "Next" — bubble
|
||||
// any change from the composed check list up so a live recheck can flip it back.
|
||||
Checks.PropertyChanged += (_, _) => OnPropertyChanged(nameof(BlocksNavigation));
|
||||
// any change from the composed check list up so a live recheck can flip it back, and so
|
||||
// the Claude-Help gating re-reads the fresh report.
|
||||
Checks.PropertyChanged += (_, _) =>
|
||||
{
|
||||
OnPropertyChanged(nameof(BlocksNavigation));
|
||||
OnPropertyChanged(nameof(ClaudeCliOk));
|
||||
OnPropertyChanged(nameof(ClaudeAuthFailed));
|
||||
OnPropertyChanged(nameof(CanStartClaudeHelp));
|
||||
OnPropertyChanged(nameof(ClaudeHelpTooltip));
|
||||
StartClaudeHelpCommand.NotifyCanExecuteChanged();
|
||||
};
|
||||
}
|
||||
|
||||
public Task LoadAsync()
|
||||
@@ -54,4 +94,18 @@ public partial class SystemCheckPageViewModel : ObservableObject, IInstallerPage
|
||||
public Task ApplyAsync() => Task.CompletedTask;
|
||||
|
||||
public bool Validate() => !HasBlockingError;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanStartClaudeHelp))]
|
||||
private async Task StartClaudeHelpAsync()
|
||||
{
|
||||
if (Checks.LastReport is null) return;
|
||||
|
||||
ClaudeHelpError = null;
|
||||
var result = await _claudeHelpLauncher.LaunchAsync(Checks.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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,6 +498,15 @@
|
||||
"ok": "Alles in Ordnung.",
|
||||
"warnings": "{0} Warnung(en) gefunden.",
|
||||
"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": {
|
||||
|
||||
@@ -498,6 +498,15 @@
|
||||
"ok": "Everything looks good.",
|
||||
"warnings": "{0} warning(s) found.",
|
||||
"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": {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using ClaudeDo.Installer.Pages.ServicePage;
|
||||
using ClaudeDo.Installer.Pages.SystemCheckPage;
|
||||
using ClaudeDo.Installer.Pages.UiSettingsPage;
|
||||
using ClaudeDo.Installer.Pages.WelcomePage;
|
||||
using ClaudeDo.Installer.Tests.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
@@ -18,7 +19,7 @@ public class PageResolverTests
|
||||
var pages = new IInstallerPage[]
|
||||
{
|
||||
new WelcomePageViewModel(context),
|
||||
new SystemCheckPageViewModel(context, () => throw new InvalidOperationException()),
|
||||
new SystemCheckPageViewModel(context, () => throw new InvalidOperationException(), TestClaudeHelpLauncher.Create()),
|
||||
new PathsPageViewModel(context),
|
||||
new ServicePageViewModel(context),
|
||||
new UiSettingsPageViewModel(context),
|
||||
@@ -38,7 +39,7 @@ public class PageResolverTests
|
||||
public void SystemCheckPage_is_not_shown_in_settings()
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
+65
-2
@@ -1,13 +1,14 @@
|
||||
using ClaudeDo.Installer.Checks;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Tests.Core;
|
||||
using Vm = ClaudeDo.Installer.Pages.SystemCheckPage.SystemCheckPageViewModel;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests.Pages.SystemCheckPage;
|
||||
|
||||
public sealed class SystemCheckPageViewModelTests
|
||||
{
|
||||
private static Vm CreateViewModel(Func<EnvironmentCheckService> factory) =>
|
||||
new(new InstallContext(), factory);
|
||||
private static Vm CreateViewModel(Func<EnvironmentCheckService> factory, ClaudeHelpLauncher? claudeHelpLauncher = null, InstallContext? context = null) =>
|
||||
new(context ?? new InstallContext(), factory, claudeHelpLauncher ?? TestClaudeHelpLauncher.Create());
|
||||
|
||||
private static Func<EnvironmentCheckService> FactoryFor(params IEnvironmentCheck[] checks) =>
|
||||
() => new EnvironmentCheckService(checks);
|
||||
@@ -169,4 +170,66 @@ public sealed class SystemCheckPageViewModelTests
|
||||
gate.SetResult();
|
||||
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.UiSettingsPage;
|
||||
using ClaudeDo.Installer.Pages.WelcomePage;
|
||||
using ClaudeDo.Installer.Tests.Core;
|
||||
using ClaudeDo.Installer.Tests.Pages.SystemCheckPage;
|
||||
using ClaudeDo.Installer.Views;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -20,7 +21,7 @@ public class WizardViewModelTests
|
||||
return new IInstallerPage[]
|
||||
{
|
||||
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 ServicePageViewModel(context),
|
||||
new UiSettingsPageViewModel(context),
|
||||
@@ -65,7 +66,7 @@ public class WizardViewModelTests
|
||||
? FakeEnvironmentCheck.Fail("git", CheckSeverity.Error)
|
||||
: FakeEnvironmentCheck.Ok("git");
|
||||
return new EnvironmentCheckService(new[] { check });
|
||||
});
|
||||
}, TestClaudeHelpLauncher.Create());
|
||||
|
||||
var resolver = new PageResolver(new IInstallerPage[]
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user