diff --git a/ClaudeDo.slnx b/ClaudeDo.slnx
index 785aab8..4fbdd50 100644
--- a/ClaudeDo.slnx
+++ b/ClaudeDo.slnx
@@ -4,6 +4,7 @@
+
diff --git a/src/ClaudeDo.Installer/App.xaml b/src/ClaudeDo.Installer/App.xaml
new file mode 100644
index 0000000..c6c0cae
--- /dev/null
+++ b/src/ClaudeDo.Installer/App.xaml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/App.xaml.cs b/src/ClaudeDo.Installer/App.xaml.cs
new file mode 100644
index 0000000..11376fa
--- /dev/null
+++ b/src/ClaudeDo.Installer/App.xaml.cs
@@ -0,0 +1,79 @@
+using System.Windows;
+using ClaudeDo.Installer.Core;
+using ClaudeDo.Installer.Pages.InstallPage;
+using ClaudeDo.Installer.Pages.PathsPage;
+using ClaudeDo.Installer.Pages.ServicePage;
+using ClaudeDo.Installer.Pages.UiSettingsPage;
+using ClaudeDo.Installer.Pages.WelcomePage;
+using ClaudeDo.Installer.Steps;
+using ClaudeDo.Installer.Views;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ClaudeDo.Installer;
+
+public partial class App : Application
+{
+ private ServiceProvider? _services;
+
+ protected override void OnStartup(StartupEventArgs e)
+ {
+ base.OnStartup(e);
+
+ var mode = ModeDetector.Detect();
+ _services = BuildServices();
+
+ Window mainWindow = mode switch
+ {
+ InstallerMode.Wizard => new WizardWindow
+ {
+ DataContext = _services.GetRequiredService()
+ },
+ InstallerMode.Settings => new SettingsWindow
+ {
+ DataContext = _services.GetRequiredService()
+ },
+ _ => throw new InvalidOperationException($"Unknown installer mode: {mode}")
+ };
+
+ DarkTitleBar.Apply(mainWindow);
+ mainWindow.Show();
+ }
+
+ protected override void OnExit(ExitEventArgs e)
+ {
+ _services?.Dispose();
+ base.OnExit(e);
+ }
+
+ private static ServiceProvider BuildServices()
+ {
+ var sc = new ServiceCollection();
+
+ // Core
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+
+ // Pages
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+
+ // Steps (registration order = execution order)
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+ sc.AddSingleton();
+
+ // ViewModels
+ sc.AddSingleton();
+ sc.AddSingleton();
+
+ return sc.BuildServiceProvider();
+ }
+}
diff --git a/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj b/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
new file mode 100644
index 0000000..49cd431
--- /dev/null
+++ b/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
@@ -0,0 +1,30 @@
+
+
+
+ WinExe
+ net8.0-windows
+ true
+ enable
+ enable
+
+
+
+
+ app.debug.manifest
+
+
+
+
+ app.manifest
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/Core/ConfigModels.cs b/src/ClaudeDo.Installer/Core/ConfigModels.cs
new file mode 100644
index 0000000..124c546
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/ConfigModels.cs
@@ -0,0 +1,109 @@
+using System.IO;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ClaudeDo.Data;
+
+namespace ClaudeDo.Installer.Core;
+
+///
+/// Mirrors ClaudeDo.Worker.Config.WorkerConfig JSON shape.
+/// Keep in sync with src/ClaudeDo.Worker/Config/WorkerConfig.cs.
+///
+public sealed class InstallerWorkerConfig
+{
+ [JsonPropertyName("db_path")]
+ public string DbPath { get; set; } = "~/.todo-app/todo.db";
+
+ [JsonPropertyName("sandbox_root")]
+ public string SandboxRoot { get; set; } = "~/.todo-app/sandbox";
+
+ [JsonPropertyName("log_root")]
+ public string LogRoot { get; set; } = "~/.todo-app/logs";
+
+ [JsonPropertyName("worktree_root_strategy")]
+ public string WorktreeRootStrategy { get; set; } = "sibling";
+
+ [JsonPropertyName("central_worktree_root")]
+ public string CentralWorktreeRoot { get; set; } = "~/.todo-app/worktrees";
+
+ [JsonPropertyName("queue_backstop_interval_ms")]
+ public int QueueBackstopIntervalMs { get; set; } = 30_000;
+
+ [JsonPropertyName("signalr_port")]
+ public int SignalRPort { get; set; } = 47_821;
+
+ [JsonPropertyName("claude_bin")]
+ public string ClaudeBin { get; set; } = "claude";
+
+ private static readonly JsonSerializerOptions ReadOpts = new()
+ {
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ };
+
+ private static readonly JsonSerializerOptions WriteOpts = new()
+ {
+ WriteIndented = true,
+ };
+
+ public static InstallerWorkerConfig Load()
+ {
+ var path = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
+ if (!File.Exists(path)) return new();
+ var json = File.ReadAllText(path);
+ return JsonSerializer.Deserialize(json, ReadOpts) ?? new();
+ }
+
+ public void Save()
+ {
+ var dir = Paths.AppDataRoot();
+ Directory.CreateDirectory(dir);
+ var path = Path.Combine(dir, "worker.config.json");
+ var json = JsonSerializer.Serialize(this, WriteOpts);
+ File.WriteAllText(path, json);
+ }
+}
+
+///
+/// Mirrors ClaudeDo.Ui.AppSettings JSON shape.
+/// Keep in sync with src/ClaudeDo.Ui/AppSettings.cs.
+///
+public sealed class InstallerAppSettings
+{
+ public string DbPath { get; set; } = "~/.todo-app/todo.db";
+ public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub";
+
+ private static readonly JsonSerializerOptions ReadOpts = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ };
+
+ private static readonly JsonSerializerOptions WriteOpts = new()
+ {
+ WriteIndented = true,
+ };
+
+ public static InstallerAppSettings Load()
+ {
+ var path = Path.Combine(Paths.AppDataRoot(), "ui.config.json");
+ if (!File.Exists(path)) return new();
+ try
+ {
+ var json = File.ReadAllText(path);
+ return JsonSerializer.Deserialize(json, ReadOpts) ?? new();
+ }
+ catch
+ {
+ return new();
+ }
+ }
+
+ public void Save()
+ {
+ var dir = Paths.AppDataRoot();
+ Directory.CreateDirectory(dir);
+ var path = Path.Combine(dir, "ui.config.json");
+ var json = JsonSerializer.Serialize(this, WriteOpts);
+ File.WriteAllText(path, json);
+ }
+}
diff --git a/src/ClaudeDo.Installer/Core/DarkTitleBar.cs b/src/ClaudeDo.Installer/Core/DarkTitleBar.cs
new file mode 100644
index 0000000..e13cf41
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/DarkTitleBar.cs
@@ -0,0 +1,29 @@
+using System.Runtime.InteropServices;
+using System.Windows;
+using System.Windows.Interop;
+
+namespace ClaudeDo.Installer.Core;
+
+public static class DarkTitleBar
+{
+ [DllImport("dwmapi.dll", PreserveSig = true)]
+ private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int attrValue, int attrSize);
+
+ private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
+
+ public static void Apply(Window window)
+ {
+ if (window.IsLoaded)
+ SetDarkMode(window);
+ else
+ window.SourceInitialized += (_, _) => SetDarkMode(window);
+ }
+
+ private static void SetDarkMode(Window window)
+ {
+ var hwnd = new WindowInteropHelper(window).Handle;
+ if (hwnd == IntPtr.Zero) return;
+ int value = 1;
+ DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ref value, sizeof(int));
+ }
+}
diff --git a/src/ClaudeDo.Installer/Core/IInstallStep.cs b/src/ClaudeDo.Installer/Core/IInstallStep.cs
new file mode 100644
index 0000000..67c22cd
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/IInstallStep.cs
@@ -0,0 +1,20 @@
+namespace ClaudeDo.Installer.Core;
+
+public interface IInstallStep
+{
+ string Name { get; }
+ Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct);
+}
+
+public sealed class StepResult
+{
+ public bool Success { get; init; }
+ public string? ErrorMessage { get; init; }
+
+ public static StepResult Ok() => new() { Success = true };
+ public static StepResult Fail(string error) => new() { Success = false, ErrorMessage = error };
+}
+
+public enum StepStatus { Pending, Running, Done, Failed, Skipped }
+
+public sealed record StepProgress(string StepName, StepStatus Status, string? Message = null);
diff --git a/src/ClaudeDo.Installer/Core/IInstallerPage.cs b/src/ClaudeDo.Installer/Core/IInstallerPage.cs
new file mode 100644
index 0000000..ef4db1c
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/IInstallerPage.cs
@@ -0,0 +1,16 @@
+using System.Windows.Controls;
+
+namespace ClaudeDo.Installer.Core;
+
+public interface IInstallerPage
+{
+ string Title { get; }
+ string Icon { get; }
+ int Order { get; }
+ bool ShowInWizard { get; }
+ bool ShowInSettings { get; }
+ UserControl View { get; }
+ Task LoadAsync();
+ Task ApplyAsync();
+ bool Validate();
+}
diff --git a/src/ClaudeDo.Installer/Core/InstallContext.cs b/src/ClaudeDo.Installer/Core/InstallContext.cs
new file mode 100644
index 0000000..eafded5
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/InstallContext.cs
@@ -0,0 +1,30 @@
+namespace ClaudeDo.Installer.Core;
+
+public sealed class InstallContext
+{
+ // WelcomePage
+ public string SourceDirectory { get; set; } = "";
+ public string InstallDirectory { get; set; } = @"C:\Program Files\ClaudeDo";
+
+ // PathsPage
+ public string DbPath { get; set; } = "~/.todo-app/todo.db";
+ public string LogRoot { get; set; } = "~/.todo-app/logs";
+ public string SandboxRoot { get; set; } = "~/.todo-app/sandbox";
+ public string WorktreeRootStrategy { get; set; } = "sibling";
+ public string CentralWorktreeRoot { get; set; } = "~/.todo-app/worktrees";
+
+ // ServicePage
+ public int SignalRPort { get; set; } = 47_821;
+ public int QueueBackstopIntervalMs { get; set; } = 30_000;
+ public string ClaudeBin { get; set; } = "claude";
+ public string ServiceAccount { get; set; } = "LocalSystem";
+ public bool AutoStart { get; set; } = true;
+ public int RestartDelayMs { get; set; } = 5000;
+
+ // UiSettingsPage
+ public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub";
+ public string UiDbPath { get; set; } = "~/.todo-app/todo.db";
+
+ // InstallPage
+ public bool CreateDesktopShortcut { get; set; } = true;
+}
diff --git a/src/ClaudeDo.Installer/Core/InstallerMode.cs b/src/ClaudeDo.Installer/Core/InstallerMode.cs
new file mode 100644
index 0000000..03a9eb2
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/InstallerMode.cs
@@ -0,0 +1,19 @@
+using System.IO;
+using ClaudeDo.Data;
+
+namespace ClaudeDo.Installer.Core;
+
+public enum InstallerMode { Wizard, Settings }
+
+public static class ModeDetector
+{
+ public static InstallerMode Detect()
+ {
+ var root = Paths.AppDataRoot();
+ var workerConfig = Path.Combine(root, "worker.config.json");
+ var uiConfig = Path.Combine(root, "ui.config.json");
+ return File.Exists(workerConfig) && File.Exists(uiConfig)
+ ? InstallerMode.Settings
+ : InstallerMode.Wizard;
+ }
+}
diff --git a/src/ClaudeDo.Installer/Core/InstallerService.cs b/src/ClaudeDo.Installer/Core/InstallerService.cs
new file mode 100644
index 0000000..4d04dc9
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/InstallerService.cs
@@ -0,0 +1,49 @@
+namespace ClaudeDo.Installer.Core;
+
+public sealed class InstallerService
+{
+ private readonly IEnumerable _steps;
+
+ public InstallerService(IEnumerable steps) => _steps = steps;
+
+ public async Task> ExecuteAsync(
+ InstallContext ctx,
+ IProgress progress,
+ CancellationToken ct)
+ {
+ var results = new List<(IInstallStep, StepResult)>();
+
+ foreach (var step in _steps)
+ {
+ ct.ThrowIfCancellationRequested();
+ progress.Report(new StepProgress(step.Name, StepStatus.Running));
+
+ var lineProgress = new Progress(msg =>
+ progress.Report(new StepProgress(step.Name, StepStatus.Running, msg)));
+
+ try
+ {
+ var result = await step.ExecuteAsync(ctx, lineProgress, ct);
+ var status = result.Success ? StepStatus.Done : StepStatus.Failed;
+ progress.Report(new StepProgress(step.Name, status, result.ErrorMessage));
+ results.Add((step, result));
+
+ if (!result.Success) break;
+ }
+ catch (OperationCanceledException)
+ {
+ progress.Report(new StepProgress(step.Name, StepStatus.Failed, "Cancelled"));
+ results.Add((step, StepResult.Fail("Cancelled")));
+ break;
+ }
+ catch (Exception ex)
+ {
+ progress.Report(new StepProgress(step.Name, StepStatus.Failed, ex.Message));
+ results.Add((step, StepResult.Fail(ex.Message)));
+ break;
+ }
+ }
+
+ return results;
+ }
+}
diff --git a/src/ClaudeDo.Installer/Core/NullToCollapsedConverter.cs b/src/ClaudeDo.Installer/Core/NullToCollapsedConverter.cs
new file mode 100644
index 0000000..935c731
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/NullToCollapsedConverter.cs
@@ -0,0 +1,14 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+
+namespace ClaudeDo.Installer.Core;
+
+public sealed class NullToCollapsedConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => value is null or "" ? Visibility.Collapsed : Visibility.Visible;
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
diff --git a/src/ClaudeDo.Installer/Core/PageResolver.cs b/src/ClaudeDo.Installer/Core/PageResolver.cs
new file mode 100644
index 0000000..b26c5ff
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/PageResolver.cs
@@ -0,0 +1,17 @@
+namespace ClaudeDo.Installer.Core;
+
+public sealed class PageResolver
+{
+ private readonly IReadOnlyList _allPages;
+
+ public PageResolver(IEnumerable pages)
+ {
+ _allPages = pages.OrderBy(p => p.Order).ToList();
+ }
+
+ public IReadOnlyList WizardPages =>
+ _allPages.Where(p => p.ShowInWizard).ToList();
+
+ public IReadOnlyList SettingsPages =>
+ _allPages.Where(p => p.ShowInSettings).ToList();
+}
diff --git a/src/ClaudeDo.Installer/Core/ProcessRunner.cs b/src/ClaudeDo.Installer/Core/ProcessRunner.cs
new file mode 100644
index 0000000..cbe94e0
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/ProcessRunner.cs
@@ -0,0 +1,60 @@
+using System.Diagnostics;
+using System.IO;
+using System.Text;
+
+namespace ClaudeDo.Installer.Core;
+
+public static class ProcessRunner
+{
+ public static async Task<(int ExitCode, string Output)> RunAsync(
+ string fileName,
+ string arguments,
+ string? workingDirectory,
+ IProgress? progress,
+ CancellationToken ct)
+ {
+ var output = new StringBuilder();
+ var outputLock = new object();
+
+ using var process = new Process();
+ process.StartInfo = new ProcessStartInfo
+ {
+ FileName = fileName,
+ Arguments = arguments,
+ WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+
+ if (!process.Start())
+ return (-1, "Failed to start process");
+
+ var stdoutTask = ReadStreamAsync(process.StandardOutput, output, outputLock, progress);
+ var stderrTask = ReadStreamAsync(process.StandardError, output, outputLock, progress);
+
+ using var reg = ct.Register(() =>
+ {
+ try { process.Kill(entireProcessTree: true); } catch { }
+ });
+
+ await Task.WhenAll(stdoutTask, stderrTask);
+ await process.WaitForExitAsync(ct);
+
+ return (process.ExitCode, output.ToString());
+ }
+
+ private static async Task ReadStreamAsync(
+ StreamReader reader,
+ StringBuilder output,
+ object outputLock,
+ IProgress? progress)
+ {
+ while (await reader.ReadLineAsync() is { } line)
+ {
+ lock (outputLock) { output.AppendLine(line); }
+ progress?.Report(line);
+ }
+ }
+}
diff --git a/src/ClaudeDo.Installer/Core/StepIndicatorConverter.cs b/src/ClaudeDo.Installer/Core/StepIndicatorConverter.cs
new file mode 100644
index 0000000..14e06c8
--- /dev/null
+++ b/src/ClaudeDo.Installer/Core/StepIndicatorConverter.cs
@@ -0,0 +1,37 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+using System.Windows.Media;
+
+namespace ClaudeDo.Installer.Core;
+
+///
+/// Multi-value converter: compares the page's index with the current page index
+/// to determine step indicator styling.
+///
+public sealed class StepActiveConverter : IMultiValueConverter
+{
+ public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (values.Length < 2 ||
+ values[0] is not IInstallerPage page ||
+ values[1] is not IInstallerPage currentPage)
+ return DependencyProperty.UnsetValue;
+
+ var isActive = ReferenceEquals(page, currentPage);
+
+ var key = parameter?.ToString() switch
+ {
+ "Background" => isActive ? "AccentBrush" : "WindowBgBrush",
+ "Foreground" => isActive ? "TextPrimaryBrush" : "TextMutedBrush",
+ "BorderBrush" => isActive ? "AccentBrush" : "BorderSubtleBrush",
+ _ => null
+ };
+
+ if (key is null) return DependencyProperty.UnsetValue;
+ return Application.Current.Resources[key] as SolidColorBrush ?? DependencyProperty.UnsetValue;
+ }
+
+ public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
diff --git a/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageView.xaml b/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageView.xaml
new file mode 100644
index 0000000..94781c8
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageView.xaml
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageView.xaml.cs b/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageView.xaml.cs
new file mode 100644
index 0000000..81d0e5c
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageView.xaml.cs
@@ -0,0 +1,8 @@
+using System.Windows.Controls;
+
+namespace ClaudeDo.Installer.Pages.InstallPage;
+
+public partial class InstallPageView : UserControl
+{
+ public InstallPageView() => InitializeComponent();
+}
diff --git a/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageViewModel.cs b/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageViewModel.cs
new file mode 100644
index 0000000..1993391
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageViewModel.cs
@@ -0,0 +1,120 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Windows;
+using System.Windows.Controls;
+using ClaudeDo.Installer.Core;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace ClaudeDo.Installer.Pages.InstallPage;
+
+public partial class InstallPageViewModel : ObservableObject, IInstallerPage
+{
+ private readonly InstallContext _context;
+ private readonly InstallerService _installerService;
+ private InstallPageView? _view;
+ private CancellationTokenSource? _cts;
+
+ public string Title => "Install";
+ public string Icon => "\uE896";
+ public int Order => 99;
+ public bool ShowInWizard => true;
+ public bool ShowInSettings => false;
+ public UserControl View => _view ??= new InstallPageView { DataContext = this };
+
+ public ObservableCollection Steps { get; } = [];
+
+ [ObservableProperty] private bool _isInstalling;
+ [ObservableProperty] private bool _isComplete;
+ [ObservableProperty] private bool _hasErrors;
+ [ObservableProperty] private double _overallProgress;
+
+ public InstallPageViewModel(InstallContext context, InstallerService installerService)
+ {
+ _context = context;
+ _installerService = installerService;
+ }
+
+ public Task LoadAsync()
+ {
+ Steps.Clear();
+ Steps.Add(new StepViewModel("Publish ClaudeDo.App"));
+ Steps.Add(new StepViewModel("Publish ClaudeDo.Worker"));
+ Steps.Add(new StepViewModel("Deploy Binaries"));
+ Steps.Add(new StepViewModel("Write Configuration"));
+ Steps.Add(new StepViewModel("Initialize Database"));
+ Steps.Add(new StepViewModel("Register Windows Service"));
+ Steps.Add(new StepViewModel("Create Shortcuts"));
+ return Task.CompletedTask;
+ }
+
+ public Task ApplyAsync() => RunInstallAsync();
+
+ public bool Validate() => true;
+
+ [RelayCommand]
+ private async Task RunInstallAsync()
+ {
+ if (IsInstalling) return;
+
+ IsInstalling = true;
+ IsComplete = false;
+ HasErrors = false;
+ OverallProgress = 0;
+
+ _cts = new CancellationTokenSource();
+
+ var progress = new Progress(p =>
+ {
+ var step = Steps.FirstOrDefault(s => s.Name == p.StepName);
+ if (step is null) return;
+
+ step.Status = p.Status;
+ if (p.Message is not null)
+ step.Messages.Add(p.Message);
+
+ if (p.Status is StepStatus.Running && !step.IsExpanded)
+ step.IsExpanded = true;
+
+ if (p.Status is StepStatus.Done or StepStatus.Failed)
+ {
+ var completed = Steps.Count(s => s.Status is StepStatus.Done or StepStatus.Failed);
+ OverallProgress = (double)completed / Steps.Count * 100;
+ }
+ });
+
+ try
+ {
+ var results = await _installerService.ExecuteAsync(_context, progress, _cts.Token);
+ HasErrors = results.Any(r => !r.Result.Success);
+ }
+ catch (OperationCanceledException)
+ {
+ HasErrors = true;
+ }
+ finally
+ {
+ IsInstalling = false;
+ IsComplete = true;
+ _cts.Dispose();
+ _cts = null;
+ }
+ }
+
+ [RelayCommand]
+ private void CancelInstall()
+ {
+ _cts?.Cancel();
+ }
+
+ [RelayCommand]
+ private void LaunchApp()
+ {
+ var appExe = System.IO.Path.Combine(_context.InstallDirectory, "app", "ClaudeDo.App.exe");
+ if (System.IO.File.Exists(appExe))
+ {
+ Process.Start(new ProcessStartInfo(appExe) { UseShellExecute = true });
+ Application.Current.Shutdown();
+ }
+ }
+}
diff --git a/src/ClaudeDo.Installer/Pages/InstallPage/StepViewModel.cs b/src/ClaudeDo.Installer/Pages/InstallPage/StepViewModel.cs
new file mode 100644
index 0000000..176948a
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/InstallPage/StepViewModel.cs
@@ -0,0 +1,17 @@
+using System.Collections.ObjectModel;
+using ClaudeDo.Installer.Core;
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace ClaudeDo.Installer.Pages.InstallPage;
+
+public partial class StepViewModel : ObservableObject
+{
+ public string Name { get; }
+
+ [ObservableProperty] private StepStatus _status = StepStatus.Pending;
+ [ObservableProperty] private bool _isExpanded;
+
+ public ObservableCollection Messages { get; } = [];
+
+ public StepViewModel(string name) => Name = name;
+}
diff --git a/src/ClaudeDo.Installer/Pages/PathsPage/PathsPageView.xaml b/src/ClaudeDo.Installer/Pages/PathsPage/PathsPageView.xaml
new file mode 100644
index 0000000..80af2aa
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/PathsPage/PathsPageView.xaml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ sibling
+ central
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/Pages/PathsPage/PathsPageView.xaml.cs b/src/ClaudeDo.Installer/Pages/PathsPage/PathsPageView.xaml.cs
new file mode 100644
index 0000000..e100d64
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/PathsPage/PathsPageView.xaml.cs
@@ -0,0 +1,8 @@
+using System.Windows.Controls;
+
+namespace ClaudeDo.Installer.Pages.PathsPage;
+
+public partial class PathsPageView : UserControl
+{
+ public PathsPageView() => InitializeComponent();
+}
diff --git a/src/ClaudeDo.Installer/Pages/PathsPage/PathsPageViewModel.cs b/src/ClaudeDo.Installer/Pages/PathsPage/PathsPageViewModel.cs
new file mode 100644
index 0000000..6e9c215
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/PathsPage/PathsPageViewModel.cs
@@ -0,0 +1,74 @@
+using System.Windows.Controls;
+using ClaudeDo.Installer.Core;
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace ClaudeDo.Installer.Pages.PathsPage;
+
+public partial class PathsPageViewModel : ObservableObject, IInstallerPage
+{
+ private readonly InstallContext _context;
+ private PathsPageView? _view;
+
+ public string Title => "Paths";
+ public string Icon => "\uE8B7";
+ public int Order => 1;
+ public bool ShowInWizard => true;
+ public bool ShowInSettings => true;
+ public UserControl View => _view ??= new PathsPageView { DataContext = this };
+
+ [ObservableProperty] private string _dbPath = "~/.todo-app/todo.db";
+ [ObservableProperty] private string _logRoot = "~/.todo-app/logs";
+ [ObservableProperty] private string _sandboxRoot = "~/.todo-app/sandbox";
+ [ObservableProperty] private string _worktreeRootStrategy = "sibling";
+ [ObservableProperty] private string _centralWorktreeRoot = "~/.todo-app/worktrees";
+ [ObservableProperty] private string? _validationError;
+
+ public bool IsCentralVisible => WorktreeRootStrategy == "central";
+
+ public PathsPageViewModel(InstallContext context) => _context = context;
+
+ partial void OnWorktreeRootStrategyChanged(string value) =>
+ OnPropertyChanged(nameof(IsCentralVisible));
+
+ public Task LoadAsync()
+ {
+ var cfg = InstallerWorkerConfig.Load();
+ DbPath = cfg.DbPath;
+ LogRoot = cfg.LogRoot;
+ SandboxRoot = cfg.SandboxRoot;
+ WorktreeRootStrategy = cfg.WorktreeRootStrategy;
+ CentralWorktreeRoot = cfg.CentralWorktreeRoot;
+ return Task.CompletedTask;
+ }
+
+ public Task ApplyAsync()
+ {
+ _context.DbPath = DbPath;
+ _context.UiDbPath = DbPath;
+ _context.LogRoot = LogRoot;
+ _context.SandboxRoot = SandboxRoot;
+ _context.WorktreeRootStrategy = WorktreeRootStrategy;
+ _context.CentralWorktreeRoot = CentralWorktreeRoot;
+ return Task.CompletedTask;
+ }
+
+ public bool Validate()
+ {
+ if (string.IsNullOrWhiteSpace(DbPath) ||
+ string.IsNullOrWhiteSpace(LogRoot) ||
+ string.IsNullOrWhiteSpace(SandboxRoot))
+ {
+ ValidationError = "All path fields are required.";
+ return false;
+ }
+
+ if (WorktreeRootStrategy == "central" && string.IsNullOrWhiteSpace(CentralWorktreeRoot))
+ {
+ ValidationError = "Central worktree root is required when using central strategy.";
+ return false;
+ }
+
+ ValidationError = null;
+ return true;
+ }
+}
diff --git a/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageView.xaml b/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageView.xaml
new file mode 100644
index 0000000..4edd763
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageView.xaml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageView.xaml.cs b/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageView.xaml.cs
new file mode 100644
index 0000000..d0a1887
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageView.xaml.cs
@@ -0,0 +1,8 @@
+using System.Windows.Controls;
+
+namespace ClaudeDo.Installer.Pages.ServicePage;
+
+public partial class ServicePageView : UserControl
+{
+ public ServicePageView() => InitializeComponent();
+}
diff --git a/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageViewModel.cs b/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageViewModel.cs
new file mode 100644
index 0000000..e111f02
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageViewModel.cs
@@ -0,0 +1,88 @@
+using System.Windows.Controls;
+using ClaudeDo.Installer.Core;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Win32;
+
+namespace ClaudeDo.Installer.Pages.ServicePage;
+
+public partial class ServicePageViewModel : ObservableObject, IInstallerPage
+{
+ private readonly InstallContext _context;
+ private ServicePageView? _view;
+
+ public string Title => "Service";
+ public string Icon => "\uE912";
+ public int Order => 2;
+ public bool ShowInWizard => true;
+ public bool ShowInSettings => true;
+ public UserControl View => _view ??= new ServicePageView { DataContext = this };
+
+ [ObservableProperty] private int _signalRPort = 47_821;
+ [ObservableProperty] private int _queueBackstopIntervalMs = 30_000;
+ [ObservableProperty] private string _claudeBin = "claude";
+ [ObservableProperty] private bool _isLocalSystem = true;
+ [ObservableProperty] private bool _isCurrentUser;
+ [ObservableProperty] private bool _autoStart = true;
+ [ObservableProperty] private int _restartDelayMs = 5000;
+ [ObservableProperty] private string? _validationError;
+
+ public ServicePageViewModel(InstallContext context) => _context = context;
+
+ public Task LoadAsync()
+ {
+ var cfg = InstallerWorkerConfig.Load();
+ SignalRPort = cfg.SignalRPort;
+ QueueBackstopIntervalMs = cfg.QueueBackstopIntervalMs;
+ ClaudeBin = cfg.ClaudeBin;
+ return Task.CompletedTask;
+ }
+
+ public Task ApplyAsync()
+ {
+ _context.SignalRPort = SignalRPort;
+ _context.QueueBackstopIntervalMs = QueueBackstopIntervalMs;
+ _context.ClaudeBin = ClaudeBin;
+ _context.ServiceAccount = IsCurrentUser ? "CurrentUser" : "LocalSystem";
+ _context.AutoStart = AutoStart;
+ _context.RestartDelayMs = RestartDelayMs;
+ _context.SignalRUrl = $"http://127.0.0.1:{SignalRPort}/hub";
+ return Task.CompletedTask;
+ }
+
+ public bool Validate()
+ {
+ if (SignalRPort < 1024 || SignalRPort > 65535)
+ {
+ ValidationError = "Port must be between 1024 and 65535.";
+ return false;
+ }
+
+ if (QueueBackstopIntervalMs <= 0)
+ {
+ ValidationError = "Queue backstop interval must be greater than 0.";
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(ClaudeBin))
+ {
+ ValidationError = "Claude CLI path is required.";
+ return false;
+ }
+
+ ValidationError = null;
+ return true;
+ }
+
+ [RelayCommand]
+ private void BrowseClaude()
+ {
+ var dialog = new OpenFileDialog
+ {
+ Title = "Select Claude CLI executable",
+ Filter = "Executables (*.exe)|*.exe|All files (*.*)|*.*",
+ };
+ if (dialog.ShowDialog() == true)
+ ClaudeBin = dialog.FileName;
+ }
+}
diff --git a/src/ClaudeDo.Installer/Pages/UiSettingsPage/UiSettingsPageView.xaml b/src/ClaudeDo.Installer/Pages/UiSettingsPage/UiSettingsPageView.xaml
new file mode 100644
index 0000000..8b0f016
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/UiSettingsPage/UiSettingsPageView.xaml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/Pages/UiSettingsPage/UiSettingsPageView.xaml.cs b/src/ClaudeDo.Installer/Pages/UiSettingsPage/UiSettingsPageView.xaml.cs
new file mode 100644
index 0000000..a1b777c
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/UiSettingsPage/UiSettingsPageView.xaml.cs
@@ -0,0 +1,8 @@
+using System.Windows.Controls;
+
+namespace ClaudeDo.Installer.Pages.UiSettingsPage;
+
+public partial class UiSettingsPageView : UserControl
+{
+ public UiSettingsPageView() => InitializeComponent();
+}
diff --git a/src/ClaudeDo.Installer/Pages/UiSettingsPage/UiSettingsPageViewModel.cs b/src/ClaudeDo.Installer/Pages/UiSettingsPage/UiSettingsPageViewModel.cs
new file mode 100644
index 0000000..55236e8
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/UiSettingsPage/UiSettingsPageViewModel.cs
@@ -0,0 +1,83 @@
+using System.Windows.Controls;
+using ClaudeDo.Installer.Core;
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace ClaudeDo.Installer.Pages.UiSettingsPage;
+
+public partial class UiSettingsPageViewModel : ObservableObject, IInstallerPage
+{
+ private readonly InstallContext _context;
+ private UiSettingsPageView? _view;
+
+ public string Title => "UI Settings";
+ public string Icon => "\uE771";
+ public int Order => 3;
+ public bool ShowInWizard => true;
+ public bool ShowInSettings => true;
+ public UserControl View => _view ??= new UiSettingsPageView { DataContext = this };
+
+ [ObservableProperty] private string _signalRUrl = "http://127.0.0.1:47821/hub";
+ [ObservableProperty] private string _uiDbPath = "~/.todo-app/todo.db";
+ [ObservableProperty] private bool _isSynced = true;
+ [ObservableProperty] private string? _validationError;
+
+ public UiSettingsPageViewModel(InstallContext context) => _context = context;
+
+ partial void OnIsSyncedChanged(bool value)
+ {
+ if (value) SyncFromContext();
+ }
+
+ private void SyncFromContext()
+ {
+ SignalRUrl = $"http://127.0.0.1:{_context.SignalRPort}/hub";
+ UiDbPath = _context.DbPath;
+ }
+
+ public Task LoadAsync()
+ {
+ if (IsSynced)
+ {
+ SyncFromContext();
+ }
+ else
+ {
+ var cfg = InstallerAppSettings.Load();
+ SignalRUrl = cfg.SignalRUrl;
+ UiDbPath = cfg.DbPath;
+ }
+ return Task.CompletedTask;
+ }
+
+ public Task ApplyAsync()
+ {
+ if (IsSynced) SyncFromContext();
+ _context.SignalRUrl = SignalRUrl;
+ _context.UiDbPath = UiDbPath;
+ return Task.CompletedTask;
+ }
+
+ public bool Validate()
+ {
+ if (string.IsNullOrWhiteSpace(SignalRUrl))
+ {
+ ValidationError = "SignalR URL is required.";
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(UiDbPath))
+ {
+ ValidationError = "Database path is required.";
+ return false;
+ }
+
+ if (!Uri.TryCreate(SignalRUrl, UriKind.Absolute, out _))
+ {
+ ValidationError = "SignalR URL must be a valid URL.";
+ return false;
+ }
+
+ ValidationError = null;
+ return true;
+ }
+}
diff --git a/src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageView.xaml b/src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageView.xaml
new file mode 100644
index 0000000..80c94c7
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageView.xaml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageView.xaml.cs b/src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageView.xaml.cs
new file mode 100644
index 0000000..e2f60a3
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageView.xaml.cs
@@ -0,0 +1,8 @@
+using System.Windows.Controls;
+
+namespace ClaudeDo.Installer.Pages.WelcomePage;
+
+public partial class WelcomePageView : UserControl
+{
+ public WelcomePageView() => InitializeComponent();
+}
diff --git a/src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageViewModel.cs b/src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageViewModel.cs
new file mode 100644
index 0000000..5cc4eb3
--- /dev/null
+++ b/src/ClaudeDo.Installer/Pages/WelcomePage/WelcomePageViewModel.cs
@@ -0,0 +1,106 @@
+using System.IO;
+using System.Windows.Controls;
+using ClaudeDo.Installer.Core;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Win32;
+
+namespace ClaudeDo.Installer.Pages.WelcomePage;
+
+public partial class WelcomePageViewModel : ObservableObject, IInstallerPage
+{
+ private readonly InstallContext _context;
+ private WelcomePageView? _view;
+
+ public string Title => "Welcome";
+ public string Icon => "\uE80F";
+ public int Order => 0;
+ public bool ShowInWizard => true;
+ public bool ShowInSettings => false;
+ public UserControl View => _view ??= new WelcomePageView { DataContext = this };
+
+ [ObservableProperty] private string _sourceDirectory = "";
+ [ObservableProperty] private string _installDirectory = @"C:\Program Files\ClaudeDo";
+ [ObservableProperty] private string? _sourceError;
+ [ObservableProperty] private string? _installError;
+
+ public WelcomePageViewModel(InstallContext context)
+ {
+ _context = context;
+ _sourceDirectory = DetectSourceDirectory();
+ }
+
+ public Task LoadAsync()
+ {
+ if (!string.IsNullOrEmpty(_context.SourceDirectory))
+ SourceDirectory = _context.SourceDirectory;
+ if (!string.IsNullOrEmpty(_context.InstallDirectory))
+ InstallDirectory = _context.InstallDirectory;
+ return Task.CompletedTask;
+ }
+
+ public Task ApplyAsync()
+ {
+ _context.SourceDirectory = SourceDirectory;
+ _context.InstallDirectory = InstallDirectory;
+ return Task.CompletedTask;
+ }
+
+ public bool Validate()
+ {
+ var valid = true;
+
+ if (string.IsNullOrWhiteSpace(SourceDirectory) ||
+ !File.Exists(Path.Combine(SourceDirectory, "ClaudeDo.slnx")))
+ {
+ SourceError = "Source directory must contain ClaudeDo.slnx";
+ valid = false;
+ }
+ else
+ {
+ SourceError = null;
+ }
+
+ if (string.IsNullOrWhiteSpace(InstallDirectory))
+ {
+ InstallError = "Install directory is required";
+ valid = false;
+ }
+ else
+ {
+ InstallError = null;
+ }
+
+ return valid;
+ }
+
+ [RelayCommand]
+ private void BrowseSource()
+ {
+ var dialog = new OpenFolderDialog { Title = "Select ClaudeDo source directory" };
+ if (dialog.ShowDialog() == true)
+ SourceDirectory = dialog.FolderName;
+ }
+
+ [RelayCommand]
+ private void BrowseInstall()
+ {
+ var dialog = new OpenFolderDialog { Title = "Select installation directory" };
+ if (dialog.ShowDialog() == true)
+ InstallDirectory = dialog.FolderName;
+ }
+
+ private static string DetectSourceDirectory()
+ {
+ var dir = AppContext.BaseDirectory;
+ for (var i = 0; i < 8; i++)
+ {
+ if (File.Exists(Path.Combine(dir, "ClaudeDo.slnx")))
+ return dir;
+ var parent = Directory.GetParent(dir)?.FullName;
+ if (parent is null) break;
+ dir = parent;
+ }
+ return "";
+ }
+}
diff --git a/src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs b/src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs
new file mode 100644
index 0000000..86774cd
--- /dev/null
+++ b/src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs
@@ -0,0 +1,91 @@
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Runtime.InteropServices.ComTypes;
+using System.Text;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Steps;
+
+public sealed class CreateShortcutsStep : IInstallStep
+{
+ public string Name => "Create Shortcuts";
+
+ public Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct)
+ {
+ try
+ {
+ var appExe = Path.Combine(ctx.InstallDirectory, "app", "ClaudeDo.App.exe");
+ var workingDir = Path.Combine(ctx.InstallDirectory, "app");
+
+ // Start Menu shortcut
+ var startMenuDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
+ "Programs");
+ Directory.CreateDirectory(startMenuDir);
+ var startMenuPath = Path.Combine(startMenuDir, "ClaudeDo.lnk");
+ CreateShortcut(startMenuPath, appExe, workingDir, "ClaudeDo Task Manager");
+ progress.Report($"Created Start Menu shortcut: {startMenuPath}");
+
+ // Desktop shortcut (optional)
+ if (ctx.CreateDesktopShortcut)
+ {
+ var desktopPath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory),
+ "ClaudeDo.lnk");
+ CreateShortcut(desktopPath, appExe, workingDir, "ClaudeDo Task Manager");
+ progress.Report($"Created Desktop shortcut: {desktopPath}");
+ }
+
+ return Task.FromResult(StepResult.Ok());
+ }
+ catch (Exception ex)
+ {
+ return Task.FromResult(StepResult.Fail(ex.Message));
+ }
+ }
+
+ private static void CreateShortcut(string shortcutPath, string targetPath, string workingDir, string description)
+ {
+ var link = (IShellLink)new ShellLink();
+ link.SetPath(targetPath);
+ link.SetWorkingDirectory(workingDir);
+ link.SetDescription(description);
+ link.SetIconLocation(targetPath, 0);
+
+ var file = (IPersistFile)link;
+ file.Save(shortcutPath, false);
+ }
+
+ #region COM Interop for IShellLink
+
+ [ComImport]
+ [Guid("00021401-0000-0000-C000-000000000046")]
+ private class ShellLink { }
+
+ [ComImport]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ [Guid("000214F9-0000-0000-C000-000000000046")]
+ private interface IShellLink
+ {
+ void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, IntPtr pfd, int fFlags);
+ void GetIDList(out IntPtr ppidl);
+ void SetIDList(IntPtr pidl);
+ void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName);
+ void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName);
+ void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath);
+ void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir);
+ void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath);
+ void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs);
+ void GetHotkey(out short pwHotkey);
+ void SetHotkey(short wHotkey);
+ void GetShowCmd(out int piShowCmd);
+ void SetShowCmd(int iShowCmd);
+ void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int piIcon);
+ void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon);
+ void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, int dwReserved);
+ void Resolve(IntPtr hwnd, int fFlags);
+ void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile);
+ }
+
+ #endregion
+}
diff --git a/src/ClaudeDo.Installer/Steps/DeployBinariesStep.cs b/src/ClaudeDo.Installer/Steps/DeployBinariesStep.cs
new file mode 100644
index 0000000..da65934
--- /dev/null
+++ b/src/ClaudeDo.Installer/Steps/DeployBinariesStep.cs
@@ -0,0 +1,62 @@
+using System.IO;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Steps;
+
+public sealed class DeployBinariesStep : IInstallStep
+{
+ public string Name => "Deploy Binaries";
+
+ public Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct)
+ {
+ try
+ {
+ var appPublish = Path.Combine(ctx.SourceDirectory, "src", "ClaudeDo.App", "bin", "Release", "net8.0", "win-x64", "publish");
+ var workerPublish = Path.Combine(ctx.SourceDirectory, "src", "ClaudeDo.Worker", "bin", "Release", "net8.0", "win-x64", "publish");
+
+ var appDest = Path.Combine(ctx.InstallDirectory, "app");
+ var workerDest = Path.Combine(ctx.InstallDirectory, "worker");
+
+ if (!Directory.Exists(appPublish))
+ return Task.FromResult(StepResult.Fail($"App publish directory not found: {appPublish}"));
+ if (!Directory.Exists(workerPublish))
+ return Task.FromResult(StepResult.Fail($"Worker publish directory not found: {workerPublish}"));
+
+ var appCount = CopyDirectory(appPublish, appDest, progress, ct);
+ progress.Report($"Copied {appCount} files to {appDest}");
+
+ var workerCount = CopyDirectory(workerPublish, workerDest, progress, ct);
+ progress.Report($"Copied {workerCount} files to {workerDest}");
+
+ return Task.FromResult(StepResult.Ok());
+ }
+ catch (Exception ex)
+ {
+ return Task.FromResult(StepResult.Fail(ex.Message));
+ }
+ }
+
+ private static int CopyDirectory(string source, string dest, IProgress progress, CancellationToken ct)
+ {
+ Directory.CreateDirectory(dest);
+ var count = 0;
+
+ foreach (var dir in Directory.GetDirectories(source, "*", SearchOption.AllDirectories))
+ {
+ ct.ThrowIfCancellationRequested();
+ var relative = Path.GetRelativePath(source, dir);
+ Directory.CreateDirectory(Path.Combine(dest, relative));
+ }
+
+ foreach (var file in Directory.GetFiles(source, "*", SearchOption.AllDirectories))
+ {
+ ct.ThrowIfCancellationRequested();
+ var relative = Path.GetRelativePath(source, file);
+ var destFile = Path.Combine(dest, relative);
+ File.Copy(file, destFile, overwrite: true);
+ count++;
+ }
+
+ return count;
+ }
+}
diff --git a/src/ClaudeDo.Installer/Steps/InitDatabaseStep.cs b/src/ClaudeDo.Installer/Steps/InitDatabaseStep.cs
new file mode 100644
index 0000000..dc4814c
--- /dev/null
+++ b/src/ClaudeDo.Installer/Steps/InitDatabaseStep.cs
@@ -0,0 +1,28 @@
+using ClaudeDo.Data;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Steps;
+
+public sealed class InitDatabaseStep : IInstallStep
+{
+ public string Name => "Initialize Database";
+
+ public Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct)
+ {
+ try
+ {
+ var expandedPath = Paths.Expand(ctx.DbPath);
+ progress.Report($"Initializing database at {expandedPath}");
+
+ var factory = new SqliteConnectionFactory(expandedPath);
+ SchemaInitializer.Apply(factory);
+
+ progress.Report("Schema applied successfully");
+ return Task.FromResult(StepResult.Ok());
+ }
+ catch (Exception ex)
+ {
+ return Task.FromResult(StepResult.Fail(ex.Message));
+ }
+ }
+}
diff --git a/src/ClaudeDo.Installer/Steps/PublishAppStep.cs b/src/ClaudeDo.Installer/Steps/PublishAppStep.cs
new file mode 100644
index 0000000..91eb1da
--- /dev/null
+++ b/src/ClaudeDo.Installer/Steps/PublishAppStep.cs
@@ -0,0 +1,20 @@
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Steps;
+
+public sealed class PublishAppStep : IInstallStep
+{
+ public string Name => "Publish ClaudeDo.App";
+
+ public async Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct)
+ {
+ progress.Report("Publishing ClaudeDo.App...");
+
+ var args = "publish src/ClaudeDo.App/ClaudeDo.App.csproj -c Release -r win-x64 --self-contained false";
+ var (exitCode, output) = await ProcessRunner.RunAsync("dotnet", args, ctx.SourceDirectory, progress, ct);
+
+ return exitCode == 0
+ ? StepResult.Ok()
+ : StepResult.Fail($"dotnet publish failed with exit code {exitCode}");
+ }
+}
diff --git a/src/ClaudeDo.Installer/Steps/PublishWorkerStep.cs b/src/ClaudeDo.Installer/Steps/PublishWorkerStep.cs
new file mode 100644
index 0000000..0906e60
--- /dev/null
+++ b/src/ClaudeDo.Installer/Steps/PublishWorkerStep.cs
@@ -0,0 +1,20 @@
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Steps;
+
+public sealed class PublishWorkerStep : IInstallStep
+{
+ public string Name => "Publish ClaudeDo.Worker";
+
+ public async Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct)
+ {
+ progress.Report("Publishing ClaudeDo.Worker...");
+
+ var args = "publish src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release -r win-x64 --self-contained false";
+ var (exitCode, output) = await ProcessRunner.RunAsync("dotnet", args, ctx.SourceDirectory, progress, ct);
+
+ return exitCode == 0
+ ? StepResult.Ok()
+ : StepResult.Fail($"dotnet publish failed with exit code {exitCode}");
+ }
+}
diff --git a/src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs b/src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs
new file mode 100644
index 0000000..ef29c15
--- /dev/null
+++ b/src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs
@@ -0,0 +1,68 @@
+using System.IO;
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Steps;
+
+public sealed class RegisterServiceStep : IInstallStep
+{
+ private const string ServiceName = "ClaudeDoWorker";
+
+ public string Name => "Register Windows Service";
+
+ public async Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct)
+ {
+ var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
+ if (!File.Exists(workerExe))
+ return StepResult.Fail($"Worker executable not found: {workerExe}");
+
+ // Stop existing service (ignore errors — may not exist)
+ progress.Report("Stopping existing service (if any)...");
+ await RunSc($"stop {ServiceName}", ctx, progress, ct, ignoreErrors: true);
+
+ // Delete existing service (ignore errors)
+ progress.Report("Removing existing service registration (if any)...");
+ await RunSc($"delete {ServiceName}", ctx, progress, ct, ignoreErrors: true);
+
+ // Create service
+ var startType = ctx.AutoStart ? "auto" : "demand";
+ var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
+
+ if (ctx.ServiceAccount == "CurrentUser")
+ {
+ var username = Environment.UserName;
+ createArgs += $" obj= \".\\{username}\"";
+ }
+
+ progress.Report("Creating service...");
+ var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
+ if (exitCode != 0)
+ return StepResult.Fail($"sc.exe create failed (exit {exitCode}): {output}");
+
+ // Configure restart policy
+ var delay = ctx.RestartDelayMs;
+ var failureArgs = $"failure {ServiceName} reset= 86400 actions= restart/{delay}/restart/{delay}/restart/{delay}";
+ progress.Report("Configuring restart policy...");
+ var (failExit, failOutput) = await RunSc(failureArgs, ctx, progress, ct);
+ if (failExit != 0)
+ progress.Report($"Warning: failed to set restart policy (exit {failExit})");
+
+ // Start service if auto-start
+ if (ctx.AutoStart)
+ {
+ progress.Report("Starting service...");
+ var (startExit, _) = await RunSc($"start {ServiceName}", ctx, progress, ct);
+ if (startExit != 0)
+ progress.Report("Warning: service created but failed to start. You may need to start it manually.");
+ }
+
+ return StepResult.Ok();
+ }
+
+ private static async Task<(int ExitCode, string Output)> RunSc(
+ string arguments, InstallContext ctx, IProgress progress,
+ CancellationToken ct, bool ignoreErrors = false)
+ {
+ var result = await ProcessRunner.RunAsync("sc.exe", arguments, null, progress, ct);
+ return result;
+ }
+}
diff --git a/src/ClaudeDo.Installer/Steps/WriteConfigStep.cs b/src/ClaudeDo.Installer/Steps/WriteConfigStep.cs
new file mode 100644
index 0000000..7401e4c
--- /dev/null
+++ b/src/ClaudeDo.Installer/Steps/WriteConfigStep.cs
@@ -0,0 +1,42 @@
+using ClaudeDo.Installer.Core;
+
+namespace ClaudeDo.Installer.Steps;
+
+public sealed class WriteConfigStep : IInstallStep
+{
+ public string Name => "Write Configuration";
+
+ public Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct)
+ {
+ try
+ {
+ var workerCfg = new InstallerWorkerConfig
+ {
+ DbPath = ctx.DbPath,
+ SandboxRoot = ctx.SandboxRoot,
+ LogRoot = ctx.LogRoot,
+ WorktreeRootStrategy = ctx.WorktreeRootStrategy,
+ CentralWorktreeRoot = ctx.CentralWorktreeRoot,
+ QueueBackstopIntervalMs = ctx.QueueBackstopIntervalMs,
+ SignalRPort = ctx.SignalRPort,
+ ClaudeBin = ctx.ClaudeBin,
+ };
+ workerCfg.Save();
+ progress.Report("Written worker.config.json");
+
+ var uiCfg = new InstallerAppSettings
+ {
+ DbPath = ctx.UiDbPath,
+ SignalRUrl = ctx.SignalRUrl,
+ };
+ uiCfg.Save();
+ progress.Report("Written ui.config.json");
+
+ return Task.FromResult(StepResult.Ok());
+ }
+ catch (Exception ex)
+ {
+ return Task.FromResult(StepResult.Fail(ex.Message));
+ }
+ }
+}
diff --git a/src/ClaudeDo.Installer/Theme/DarkTheme.xaml b/src/ClaudeDo.Installer/Theme/DarkTheme.xaml
new file mode 100644
index 0000000..0987946
--- /dev/null
+++ b/src/ClaudeDo.Installer/Theme/DarkTheme.xaml
@@ -0,0 +1,280 @@
+
+
+
+
+
+ #3d9474
+ #6bb89e
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/Views/SettingsViewModel.cs b/src/ClaudeDo.Installer/Views/SettingsViewModel.cs
new file mode 100644
index 0000000..9ec9aca
--- /dev/null
+++ b/src/ClaudeDo.Installer/Views/SettingsViewModel.cs
@@ -0,0 +1,87 @@
+using System.Windows;
+using ClaudeDo.Installer.Core;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace ClaudeDo.Installer.Views;
+
+public partial class SettingsViewModel : ObservableObject
+{
+ private readonly InstallContext _context;
+
+ public IReadOnlyList Pages { get; }
+
+ [ObservableProperty]
+ private IInstallerPage? _selectedPage;
+
+ [ObservableProperty]
+ private string? _statusMessage;
+
+ [ObservableProperty]
+ private bool _isStatusError;
+
+ public SettingsViewModel(PageResolver resolver, InstallContext context)
+ {
+ Pages = resolver.SettingsPages;
+ _context = context;
+ _selectedPage = Pages.FirstOrDefault();
+
+ _ = LoadAllAsync();
+ }
+
+ private async Task LoadAllAsync()
+ {
+ foreach (var page in Pages)
+ await page.LoadAsync();
+ }
+
+ [RelayCommand]
+ private async Task Apply()
+ {
+ // Validate all pages
+ foreach (var page in Pages)
+ {
+ if (!page.Validate())
+ {
+ SelectedPage = page;
+ StatusMessage = $"Validation failed on {page.Title}. Please fix the errors.";
+ IsStatusError = true;
+ return;
+ }
+ }
+
+ // Apply all pages (writes to InstallContext)
+ foreach (var page in Pages)
+ await page.ApplyAsync();
+
+ // Write config files directly
+ var workerCfg = new InstallerWorkerConfig
+ {
+ DbPath = _context.DbPath,
+ SandboxRoot = _context.SandboxRoot,
+ LogRoot = _context.LogRoot,
+ WorktreeRootStrategy = _context.WorktreeRootStrategy,
+ CentralWorktreeRoot = _context.CentralWorktreeRoot,
+ QueueBackstopIntervalMs = _context.QueueBackstopIntervalMs,
+ SignalRPort = _context.SignalRPort,
+ ClaudeBin = _context.ClaudeBin,
+ };
+ workerCfg.Save();
+
+ var uiCfg = new InstallerAppSettings
+ {
+ DbPath = _context.UiDbPath,
+ SignalRUrl = _context.SignalRUrl,
+ };
+ uiCfg.Save();
+
+ StatusMessage = "Settings saved successfully.";
+ IsStatusError = false;
+ }
+
+ [RelayCommand]
+ private void Close()
+ {
+ Application.Current.Shutdown();
+ }
+}
diff --git a/src/ClaudeDo.Installer/Views/SettingsWindow.xaml b/src/ClaudeDo.Installer/Views/SettingsWindow.xaml
new file mode 100644
index 0000000..fbd25c7
--- /dev/null
+++ b/src/ClaudeDo.Installer/Views/SettingsWindow.xaml
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/Views/SettingsWindow.xaml.cs b/src/ClaudeDo.Installer/Views/SettingsWindow.xaml.cs
new file mode 100644
index 0000000..d3dd915
--- /dev/null
+++ b/src/ClaudeDo.Installer/Views/SettingsWindow.xaml.cs
@@ -0,0 +1,11 @@
+using System.Windows;
+
+namespace ClaudeDo.Installer.Views;
+
+public partial class SettingsWindow : Window
+{
+ public SettingsWindow()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/ClaudeDo.Installer/Views/WizardViewModel.cs b/src/ClaudeDo.Installer/Views/WizardViewModel.cs
new file mode 100644
index 0000000..45f6d66
--- /dev/null
+++ b/src/ClaudeDo.Installer/Views/WizardViewModel.cs
@@ -0,0 +1,77 @@
+using System.Windows;
+using ClaudeDo.Installer.Core;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace ClaudeDo.Installer.Views;
+
+public partial class WizardViewModel : ObservableObject
+{
+ private readonly InstallContext _context;
+
+ public IReadOnlyList Pages { get; }
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(CanGoBack))]
+ [NotifyPropertyChangedFor(nameof(IsLastPage))]
+ [NotifyPropertyChangedFor(nameof(NextButtonText))]
+ [NotifyPropertyChangedFor(nameof(CurrentPage))]
+ private int _currentPageIndex;
+
+ public IInstallerPage CurrentPage => Pages[CurrentPageIndex];
+ public bool CanGoBack => CurrentPageIndex > 0;
+ public bool IsLastPage => CurrentPageIndex == Pages.Count - 1;
+ public string NextButtonText => IsLastPage ? "Install" : "Next \u2192";
+
+ [ObservableProperty]
+ private string? _validationError;
+
+ public WizardViewModel(PageResolver resolver, InstallContext context)
+ {
+ Pages = resolver.WizardPages;
+ _context = context;
+
+ if (Pages.Count > 0)
+ _ = InitAsync();
+ }
+
+ private async Task InitAsync()
+ {
+ try { await Pages[0].LoadAsync(); }
+ catch { /* first page loads with defaults on error */ }
+ }
+
+ [RelayCommand]
+ private async Task GoBack()
+ {
+ if (!CanGoBack) return;
+ CurrentPageIndex--;
+ await CurrentPage.LoadAsync();
+ ValidationError = null;
+ }
+
+ [RelayCommand]
+ private async Task GoNext()
+ {
+ if (!CurrentPage.Validate())
+ {
+ ValidationError = "Please fix the highlighted errors before continuing.";
+ return;
+ }
+
+ ValidationError = null;
+ await CurrentPage.ApplyAsync();
+
+ if (CurrentPageIndex < Pages.Count - 1)
+ {
+ CurrentPageIndex++;
+ await CurrentPage.LoadAsync();
+ }
+ }
+
+ [RelayCommand]
+ private void Close()
+ {
+ Application.Current.Shutdown();
+ }
+}
diff --git a/src/ClaudeDo.Installer/Views/WizardWindow.xaml b/src/ClaudeDo.Installer/Views/WizardWindow.xaml
new file mode 100644
index 0000000..91d0a31
--- /dev/null
+++ b/src/ClaudeDo.Installer/Views/WizardWindow.xaml
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/Views/WizardWindow.xaml.cs b/src/ClaudeDo.Installer/Views/WizardWindow.xaml.cs
new file mode 100644
index 0000000..73c9b83
--- /dev/null
+++ b/src/ClaudeDo.Installer/Views/WizardWindow.xaml.cs
@@ -0,0 +1,11 @@
+using System.Windows;
+
+namespace ClaudeDo.Installer.Views;
+
+public partial class WizardWindow : Window
+{
+ public WizardWindow()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/ClaudeDo.Installer/app.debug.manifest b/src/ClaudeDo.Installer/app.debug.manifest
new file mode 100644
index 0000000..ba24c2e
--- /dev/null
+++ b/src/ClaudeDo.Installer/app.debug.manifest
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/app.manifest b/src/ClaudeDo.Installer/app.manifest
new file mode 100644
index 0000000..67abdbd
--- /dev/null
+++ b/src/ClaudeDo.Installer/app.manifest
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+