feat(installer): add Claude CLI preflight checks (found, version, login, auto-mode)

Four IEnvironmentCheck implementations in src/ClaudeDo.Installer/Checks/:
- ClaudeCliCheck (Error) — resolves ctx.ClaudeBin via ExecutableResolver, runs
  --version; failure message lists searched PATH entries + fallback dirs, flags
  shim resolution (.cmd/.ps1) in Detail.
- ClaudeVersionCheck (Error) — tolerant version parsing (ignores surrounding
  text), numeric System.Version comparison against a named floor constant
  (2.1.220, see docs/explore-notes/installer-preflight.md §3). Unparseable or
  missing CLI -> Unknown, never Failed.
- ClaudeAuthCheck (Error) — `claude auth status --json`, never sends a prompt.
- PermissionModeAutoCheck (Warning) — per the note's §2 conclusion, real
  auto-mode eligibility (org/model/plan) has no cheap static signal, so this
  only confirms `claude --help` still lists "auto" as a --permission-mode
  choice. Kept as its own check rather than folded into ClaudeVersionCheck,
  since the note explicitly separates "flag recognized" from "mode eligible"
  and only the former is checkable at all.

All four share a new ClaudeCliLookup that resolves the CLI and runs
--version exactly once per install run (memoized, semaphore-guarded), so
none of the three version-dependent checks repeats that call.

Foundation prerequisites this task depends on (docs/explore-notes/
installer-preflight.md, ExecutableResolver, the IEnvironmentCheck/CheckResult/
EnvironmentCheckService abstraction, ProcessCommand, IProcessRunner) existed
only on an unmerged sibling branch, not on main. Rather than merging that
whole unreviewed branch, the specific prerequisite files were copied in
as-is (git history shows their origin). GitCheck/GitIdentityCheck/PortCheck/
WriteAccessCheck from that branch were intentionally NOT brought in — out of
scope for this task.

Deviations/decisions worth flagging:
- Added ExecutableResolver.FallbackDirectories() (public) so ClaudeCliCheck
  can name the checked fallback locations in its failure message; the prior
  branch kept that list private.
- Paths.cs now qualifies System.Environment explicitly. Adding the
  ClaudeDo.Data.Environment namespace makes bare `Environment.X` inside any
  ClaudeDo.Data.* namespace resolve to the sibling namespace instead of
  System.Environment (C# prefers nested/enclosing namespace members over
  usings) — this broke the build until qualified.

Not done (explicitly out of scope): no DI wiring into the wizard UI, no
XAML — matches how the prerequisite Git/Port/WriteAccess checks were also
left unwired.
This commit is contained in:
mika kuns
2026-08-05 19:43:34 +02:00
parent bdee731376
commit d743a9d0e9
24 changed files with 1344 additions and 3 deletions
@@ -0,0 +1,116 @@
using SysEnvironment = System.Environment;
namespace ClaudeDo.Data.Environment;
public sealed record ResolvedExecutable(string Path, bool IsShim);
public sealed record ShimStartInfo(string FileName, string Arguments);
/// <summary>
/// Resolves a command the way Windows' CreateProcess/PATH search does, but also finds
/// non-.exe shims (.cmd/.bat/.ps1) that UseShellExecute=false alone would miss.
/// </summary>
public static class ExecutableResolver
{
private const string DefaultPathExt = ".COM;.EXE;.BAT;.CMD";
// Known npm/claude install locations to try when PATH search comes up empty.
private static readonly string[] FallbackDirectoryTemplates =
{
"%APPDATA%\\npm",
"%LOCALAPPDATA%\\Programs\\claude",
"%USERPROFILE%\\.local\\bin",
};
public static ResolvedExecutable? Resolve(string command, string? pathOverride = null, string? pathExtOverride = null)
{
var pathExts = ParsePathExt(pathExtOverride);
if (LooksLikePath(command))
{
return ResolveAsPath(command, pathExts);
}
var directories = ParsePath(pathOverride);
foreach (var dir in directories)
{
var resolved = ResolveInDirectory(dir, command, pathExts);
if (resolved is not null) return resolved;
}
foreach (var template in FallbackDirectoryTemplates)
{
var dir = SysEnvironment.ExpandEnvironmentVariables(template);
var resolved = ResolveInDirectory(dir, command, pathExts);
if (resolved is not null) return resolved;
}
return null;
}
/// <summary>Expanded fallback directories tried when PATH search comes up empty (for diagnostics).</summary>
public static IReadOnlyList<string> FallbackDirectories() =>
FallbackDirectoryTemplates.Select(SysEnvironment.ExpandEnvironmentVariables).ToList();
public static ShimStartInfo BuildShimStartInfo(string shimPath, IReadOnlyList<string> arguments)
{
var parts = new List<string> { "/c", Quote(shimPath) };
parts.AddRange(arguments.Select(Quote));
return new ShimStartInfo("cmd.exe", string.Join(' ', parts));
}
private static bool LooksLikePath(string command) =>
command.Contains(Path.DirectorySeparatorChar) || command.Contains(Path.AltDirectorySeparatorChar);
private static ResolvedExecutable? ResolveAsPath(string command, IReadOnlyList<string> pathExts)
{
if (File.Exists(command)) return new ResolvedExecutable(command, IsShimExtension(Path.GetExtension(command)));
if (Path.HasExtension(command)) return null;
foreach (var ext in pathExts)
{
var candidate = command + ext;
if (File.Exists(candidate)) return new ResolvedExecutable(candidate, IsShimExtension(ext));
}
return null;
}
private static ResolvedExecutable? ResolveInDirectory(string directory, string command, IReadOnlyList<string> pathExts)
{
if (!Directory.Exists(directory)) return null;
if (Path.HasExtension(command))
{
var candidate = Path.Combine(directory, command);
return File.Exists(candidate) ? new ResolvedExecutable(candidate, IsShimExtension(Path.GetExtension(candidate))) : null;
}
foreach (var ext in pathExts)
{
var candidate = Path.Combine(directory, command + ext);
if (File.Exists(candidate)) return new ResolvedExecutable(candidate, IsShimExtension(ext));
}
return null;
}
private static bool IsShimExtension(string extension) =>
!extension.Equals(".exe", StringComparison.OrdinalIgnoreCase)
&& !extension.Equals(".com", StringComparison.OrdinalIgnoreCase);
private static IReadOnlyList<string> ParsePathExt(string? pathExtOverride)
{
var raw = pathExtOverride ?? SysEnvironment.GetEnvironmentVariable("PATHEXT") ?? DefaultPathExt;
return raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static IReadOnlyList<string> ParsePath(string? pathOverride)
{
var raw = pathOverride ?? SysEnvironment.GetEnvironmentVariable("PATH") ?? "";
return raw.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value;
}
+3 -3
View File
@@ -11,16 +11,16 @@ public static class Paths
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Path must not be empty.", nameof(path));
var expanded = Environment.ExpandEnvironmentVariables(path);
var expanded = System.Environment.ExpandEnvironmentVariables(path);
if (expanded.StartsWith("~", StringComparison.Ordinal))
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile);
expanded = home + expanded[1..];
}
if (!Path.IsPathRooted(expanded))
expanded = Path.GetFullPath(expanded, baseDir ?? Environment.CurrentDirectory);
expanded = Path.GetFullPath(expanded, baseDir ?? System.Environment.CurrentDirectory);
return Path.GetFullPath(expanded);
}
@@ -0,0 +1,25 @@
namespace ClaudeDo.Installer.Checks;
public enum CheckSeverity { Warning, Error }
public enum CheckStatus { Ok, Failed, Unknown }
public sealed record CheckResult(
string Id,
CheckSeverity Severity,
CheckStatus Status,
string TitleKey,
string Message,
string? HintKey,
string? HelpUrl,
string? Detail)
{
public static CheckResult Ok(string id, CheckSeverity severity, string titleKey, string message, string? detail = null) =>
new(id, severity, CheckStatus.Ok, titleKey, message, HintKey: null, HelpUrl: null, detail);
public static CheckResult Fail(string id, CheckSeverity severity, string titleKey, string message, string? hintKey = null, string? helpUrl = null, string? detail = null) =>
new(id, severity, CheckStatus.Failed, titleKey, message, hintKey, helpUrl, detail);
public static CheckResult Unknown(string id, CheckSeverity severity, string titleKey, string message, string? detail = null) =>
new(id, severity, CheckStatus.Unknown, titleKey, message, HintKey: null, HelpUrl: null, detail);
}
@@ -0,0 +1,72 @@
using System.Text.Json;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Checks;
/// <summary>
/// Checks login via `claude auth status --json` — never sends a prompt (costs tokens, can hang
/// on a usage-limited account). See docs/explore-notes/installer-preflight.md §4.
/// </summary>
public sealed class ClaudeAuthCheck : IEnvironmentCheck
{
public const string CheckId = "claude-auth";
private readonly ClaudeCliLookup _lookup;
public ClaudeAuthCheck(ClaudeCliLookup lookup)
{
_lookup = lookup;
}
public string Id => CheckId;
public CheckSeverity Severity => CheckSeverity.Error;
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{
var lookup = await _lookup.ResolveAsync(ctx, ct);
if (lookup.Resolved is null)
{
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
"The claude CLI was not found — login could not be checked.");
}
var (fileName, arguments) = ProcessCommand.For(lookup.Resolved, "auth", "status", "--json");
var (exitCode, output) = await _lookup.ProcessRunner.RunAsync(fileName, arguments, null, ct);
if (exitCode != 0)
{
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
"Login status could not be determined.", output);
}
var loggedIn = TryParseLoggedIn(output);
if (loggedIn is null)
{
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
"Login status could not be determined.", output);
}
return loggedIn.Value
? CheckResult.Ok(Id, Severity, "checks.claudeAuth.title", "Logged in.")
: CheckResult.Fail(Id, Severity, "checks.claudeAuth.title", "Not logged in.", "checks.claudeAuth.hint");
}
private static bool? TryParseLoggedIn(string output)
{
try
{
using var doc = JsonDocument.Parse(output);
if (doc.RootElement.TryGetProperty("loggedIn", out var prop) &&
prop.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
return prop.GetBoolean();
}
}
catch (JsonException)
{
// fall through to null — treated as Unknown
}
return null;
}
}
@@ -0,0 +1,54 @@
using System.IO;
using ClaudeDo.Data.Environment;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Checks;
/// <summary>Without the claude CLI, no task can ever run — blocking.</summary>
public sealed class ClaudeCliCheck : IEnvironmentCheck
{
public const string CheckId = "claude-cli";
private readonly ClaudeCliLookup _lookup;
public ClaudeCliCheck(ClaudeCliLookup lookup)
{
_lookup = lookup;
}
public string Id => CheckId;
public CheckSeverity Severity => CheckSeverity.Error;
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{
var lookup = await _lookup.ResolveAsync(ctx, ct);
if (lookup.Resolved is null)
{
var searched = string.Join(", ", SearchedLocations());
return CheckResult.Fail(Id, Severity, "checks.claudeCli.title",
$"'{ctx.ClaudeBin}' was not found on PATH. Searched: {searched}.",
"checks.claudeCli.hint");
}
if (lookup.ExitCode != 0)
{
return CheckResult.Fail(Id, Severity, "checks.claudeCli.title",
$"'{lookup.Resolved.Path}' exited with code {lookup.ExitCode}.",
"checks.claudeCli.hint", detail: lookup.Output);
}
var version = lookup.ParsedVersion?.ToString() ?? lookup.Output.Trim();
var detail = lookup.Resolved.IsShim
? "Resolved via a shim (.cmd/.bat/.ps1), not a native .exe."
: null;
return CheckResult.Ok(Id, Severity, "checks.claudeCli.title", $"{lookup.Resolved.Path} — {version}", detail);
}
private IEnumerable<string> SearchedLocations()
{
var pathVar = _lookup.PathOverride ?? System.Environment.GetEnvironmentVariable("PATH") ?? "";
var pathEntries = pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return pathEntries.Concat(ExecutableResolver.FallbackDirectories());
}
}
@@ -0,0 +1,71 @@
using System.Text.RegularExpressions;
using ClaudeDo.Data.Environment;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Core.Interfaces;
namespace ClaudeDo.Installer.Checks;
public sealed record ClaudeCliLookupResult(
ResolvedExecutable? Resolved,
int ExitCode,
string Output,
Version? ParsedVersion);
/// <summary>
/// Resolves the claude CLI and runs `--version` at most once per install run, shared across
/// ClaudeCliCheck, ClaudeVersionCheck, ClaudeAuthCheck, and PermissionModeAutoCheck.
/// </summary>
public sealed class ClaudeCliLookup
{
private static readonly Regex VersionPattern = new(@"\d+(\.\d+){1,3}", RegexOptions.Compiled);
private readonly SemaphoreSlim _gate = new(1, 1);
private ClaudeCliLookupResult? _cached;
public ClaudeCliLookup(IProcessRunner processRunner, string? pathOverride = null, string? pathExtOverride = null)
{
ProcessRunner = processRunner;
PathOverride = pathOverride;
PathExtOverride = pathExtOverride;
}
public IProcessRunner ProcessRunner { get; }
public string? PathOverride { get; }
public string? PathExtOverride { get; }
public async Task<ClaudeCliLookupResult> ResolveAsync(InstallContext ctx, CancellationToken ct)
{
if (_cached is not null) return _cached;
await _gate.WaitAsync(ct);
try
{
_cached ??= await ResolveCoreAsync(ctx, ct);
return _cached;
}
finally
{
_gate.Release();
}
}
private async Task<ClaudeCliLookupResult> ResolveCoreAsync(InstallContext ctx, CancellationToken ct)
{
var resolved = ExecutableResolver.Resolve(ctx.ClaudeBin, PathOverride, PathExtOverride);
if (resolved is null)
{
return new ClaudeCliLookupResult(null, ExitCode: -1, Output: string.Empty, ParsedVersion: null);
}
var (fileName, arguments) = ProcessCommand.For(resolved, "--version");
var (exitCode, output) = await ProcessRunner.RunAsync(fileName, arguments, null, ct);
var parsedVersion = exitCode == 0 ? ParseVersion(output) : null;
return new ClaudeCliLookupResult(resolved, exitCode, output, parsedVersion);
}
private static Version? ParseVersion(string output) =>
VersionPattern.Match(output) is { Success: true } match && Version.TryParse(match.Value, out var version)
? version
: null;
}
@@ -0,0 +1,54 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Checks;
/// <summary>Gates the CLI flags ClaudeArgsBuilder relies on — blocking.</summary>
public sealed class ClaudeVersionCheck : IEnvironmentCheck
{
public const string CheckId = "claude-version";
/// <summary>
/// Newest version confirmed to work end-to-end with every flag ClaudeDo uses (--permission-mode
/// auto, --effort, --agents, --json-schema, --append-system-prompt, --output-format stream-json
/// --verbose, --resume, mcp add --transport http --scope user). A verified floor, not a proven
/// theoretical minimum — see docs/explore-notes/installer-preflight.md §3.
/// </summary>
public static readonly Version MinimumVersion = new(2, 1, 220);
private readonly ClaudeCliLookup _lookup;
public ClaudeVersionCheck(ClaudeCliLookup lookup)
{
_lookup = lookup;
}
public string Id => CheckId;
public CheckSeverity Severity => CheckSeverity.Error;
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{
var lookup = await _lookup.ResolveAsync(ctx, ct);
if (lookup.Resolved is null)
{
return CheckResult.Unknown(Id, Severity, "checks.claudeVersion.title",
"The claude CLI was not found — version could not be checked.");
}
if (lookup.ExitCode != 0 || lookup.ParsedVersion is null)
{
return CheckResult.Unknown(Id, Severity, "checks.claudeVersion.title",
$"Could not parse a version from '{lookup.Output.Trim()}'.");
}
if (lookup.ParsedVersion < MinimumVersion)
{
return CheckResult.Fail(Id, Severity, "checks.claudeVersion.title",
$"claude {lookup.ParsedVersion} is older than the required {MinimumVersion}.",
"checks.claudeVersion.hint");
}
return CheckResult.Ok(Id, Severity, "checks.claudeVersion.title",
$"claude {lookup.ParsedVersion} meets the minimum ({MinimumVersion}).");
}
}
@@ -0,0 +1,37 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Checks;
public sealed record EnvironmentCheckReport(IReadOnlyList<CheckResult> Results)
{
public bool HasBlockingError => Results.Any(r => r.Severity == CheckSeverity.Error && r.Status == CheckStatus.Failed);
}
public sealed class EnvironmentCheckService
{
private readonly IReadOnlyList<IEnvironmentCheck> _checks;
public EnvironmentCheckService(IEnumerable<IEnvironmentCheck> checks)
{
_checks = checks.ToList();
}
public async Task<EnvironmentCheckReport> RunAllAsync(InstallContext ctx, CancellationToken ct)
{
var tasks = _checks.Select(check => RunSafeAsync(check, ctx, ct)).ToArray();
var results = await Task.WhenAll(tasks);
return new EnvironmentCheckReport(results);
}
private static async Task<CheckResult> RunSafeAsync(IEnvironmentCheck check, InstallContext ctx, CancellationToken ct)
{
try
{
return await check.RunAsync(ctx, ct);
}
catch (Exception ex)
{
return CheckResult.Unknown(check.Id, check.Severity, string.Empty, ex.Message, ex.Message);
}
}
}
@@ -0,0 +1,10 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Checks;
public interface IEnvironmentCheck
{
string Id { get; }
CheckSeverity Severity { get; }
Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct);
}
@@ -0,0 +1,66 @@
using System.Text.RegularExpressions;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Checks;
/// <summary>
/// Static-only check per docs/explore-notes/installer-preflight.md §2: real auto-mode eligibility
/// (org/model/plan) has no cheap detectable signal, so this only confirms the CLI's argument
/// parser still recognizes "auto" as a --permission-mode choice. A warning, not a blocker — a
/// too-old CLI just degrades to acceptEdits/default with more prompts.
/// </summary>
public sealed class PermissionModeAutoCheck : IEnvironmentCheck
{
public const string CheckId = "claude-permission-mode-auto";
private static readonly Regex AutoWord = new(@"\bauto\b", RegexOptions.Compiled);
private const int SearchWindow = 300;
private readonly ClaudeCliLookup _lookup;
public PermissionModeAutoCheck(ClaudeCliLookup lookup)
{
_lookup = lookup;
}
public string Id => CheckId;
public CheckSeverity Severity => CheckSeverity.Warning;
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{
var lookup = await _lookup.ResolveAsync(ctx, ct);
if (lookup.Resolved is null)
{
return CheckResult.Unknown(Id, Severity, "checks.permissionModeAuto.title",
"The claude CLI was not found — permission-mode support could not be checked.");
}
var (fileName, arguments) = ProcessCommand.For(lookup.Resolved, "--help");
var (exitCode, output) = await _lookup.ProcessRunner.RunAsync(fileName, arguments, null, ct);
if (exitCode != 0 || string.IsNullOrWhiteSpace(output))
{
return CheckResult.Unknown(Id, Severity, "checks.permissionModeAuto.title",
"Could not determine whether --permission-mode auto is supported.");
}
if (AutoIsListed(output))
{
return CheckResult.Ok(Id, Severity, "checks.permissionModeAuto.title",
"--permission-mode auto is recognized by this CLI.");
}
return CheckResult.Fail(Id, Severity, "checks.permissionModeAuto.title",
"This claude CLI version does not list 'auto' as a --permission-mode choice.",
"checks.permissionModeAuto.hint");
}
private static bool AutoIsListed(string helpOutput)
{
var index = helpOutput.IndexOf("--permission-mode", StringComparison.Ordinal);
if (index < 0) return false;
var windowEnd = Math.Min(helpOutput.Length, index + SearchWindow);
return AutoWord.IsMatch(helpOutput[index..windowEnd]);
}
}
@@ -0,0 +1,20 @@
using ClaudeDo.Data.Environment;
namespace ClaudeDo.Installer.Checks;
/// <summary>Builds a (FileName, Arguments) pair for a resolved executable, routing shims through cmd.exe.</summary>
internal static class ProcessCommand
{
public static (string FileName, string Arguments) For(ResolvedExecutable resolved, params string[] args)
{
if (resolved.IsShim)
{
var shim = ExecutableResolver.BuildShimStartInfo(resolved.Path, args);
return (shim.FileName, shim.Arguments);
}
return (resolved.Path, string.Join(' ', args.Select(Quote)));
}
private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value;
}
@@ -0,0 +1,6 @@
namespace ClaudeDo.Installer.Core.Interfaces;
public interface IProcessRunner
{
Task<(int ExitCode, string Output)> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct);
}
@@ -0,0 +1,9 @@
using ClaudeDo.Installer.Core.Interfaces;
namespace ClaudeDo.Installer.Core;
public sealed class ProcessRunnerAdapter : IProcessRunner
{
public Task<(int ExitCode, string Output)> RunAsync(string fileName, string arguments, string? workingDirectory, CancellationToken ct) =>
ProcessRunner.RunAsync(fileName, arguments, workingDirectory, progress: null, ct);
}
+18
View File
@@ -628,5 +628,23 @@
"lastError": "Letzter Fehler: {0}",
"throttled": "Gedrosselt: {0}/{1} Slots ({2})"
}
},
"checks": {
"claudeCli": {
"title": "Claude CLI",
"hint": "Installiere die Claude CLI (npm install -g @anthropic-ai/claude-code) und starte danach dein Terminal/deine Anmeldesitzung neu, damit der aktualisierte PATH greift."
},
"claudeVersion": {
"title": "Claude-CLI-Version",
"hint": "Aktualisiere die CLI: npm update -g @anthropic-ai/claude-code"
},
"claudeAuth": {
"title": "Claude-CLI-Login",
"hint": "Starte 'claude' einmal und schließe den Login-Vorgang ab."
},
"permissionModeAuto": {
"title": "Unterstützung für --permission-mode auto",
"hint": "Aktualisiere die CLI: npm update -g @anthropic-ai/claude-code. ClaudeDo funktioniert auch im Modus acceptEdits/default, dann mit mehr Rückfragen."
}
}
}
+18
View File
@@ -628,5 +628,23 @@
"lastError": "Last error: {0}",
"throttled": "Throttled: {0}/{1} slots ({2})"
}
},
"checks": {
"claudeCli": {
"title": "Claude CLI",
"hint": "Install the Claude CLI (npm install -g @anthropic-ai/claude-code), then restart your terminal/login session so the updated PATH takes effect."
},
"claudeVersion": {
"title": "Claude CLI version",
"hint": "Update the CLI: npm update -g @anthropic-ai/claude-code"
},
"claudeAuth": {
"title": "Claude CLI login",
"hint": "Run 'claude' once and complete the login flow."
},
"permissionModeAuto": {
"title": "--permission-mode auto support",
"hint": "Update the CLI: npm update -g @anthropic-ai/claude-code. ClaudeDo still works in acceptEdits/default mode, with more prompts."
}
}
}