Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Changelog

All notable changes to ClaudePortable are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.0] - 2026-05-10

### Added

- **Schedule sidebar section** that enumerates every Windows scheduled task
via `schtasks.exe /Query /FO CSV /V` and flags Claude relevance with a
three-tier classifier (green = ClaudePortable-managed, orange =
foreign-but-Claude-related, gray = unrelated). Use it to spot a legacy
`\Claude-Desktop-Backup`-style PowerShell task that writes loose-file
backups into a long-path OneDrive folder and breaks sync.
- Per-row Run / Disable / Enable / Delete / View XML buttons in the new
Schedule view, with confirmation dialogs on destructive actions and
clipboard copy of the raw Task Scheduler XML.
- CLI: `claudeportable schedule list [--all|--managed|--relevant]
[--json]` to enumerate tasks from the terminal, plus
`schedule disable|enable|run <name>` for symmetry with the new GUI
buttons.
- `ScheduledTaskClassifier` in `ClaudePortable.Scheduler` with a stable,
testable marker list (`.claude`, `Claude_pzs8sxrjxfjjc`, `Cowork`,
`CoWork\Backup`, `local-agent-mode-sessions`, `claude-desktop`,
`anthropic`, etc.).
- `TaskSchedulerInstaller.EnumerateAsync` / `DisableAsync` /
`EnableAsync` / `RunNowAsync` / `GetTaskXmlAsync`, all going through a
single internal `Func<>` seam so unit tests can assert exact `schtasks`
argv without invoking the executable.
- Fixture-driven CSV-parser tests covering German `schtasks` headers,
quoted-path executables (`"C:\Program Files\..."`), unquoted paths with
spaces, subfolder task names, disabled state, and `ManagedBy`
classification.

### Changed

- README upgraded from "Five sections" to "Six sections" GUI overview and
refreshed test count (61 -> 98 xUnit cases).
- `scripts/build-exe.ps1` and `src/ClaudePortable.Installer/build-msi.ps1`
default version bumped from `0.1.x` to `0.2.0`.

### Not changed (out of scope, intentionally)

- ClaudePortable does NOT auto-disable or auto-delete any task it finds.
The user explicitly clicks each action; confirmation dialogs gate
Disable and Delete.
- The existing backup engine, restore engine, and archive format are
untouched. A 0.1.x backup ZIP restores identically on 0.2.0.

## [0.1.x]

Initial alpha releases focused on backup, restore, retention rotation,
sync-client discovery, and a single-task scheduler install command. See
git history and the GitHub Releases page for per-version detail.
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,14 @@ Optional integrity check:

## GUI flows

Launch with no arguments (or `--gui`). Warm-dark UI in the Claude Desktop style, WCAG 2.1 AA contrast throughout, visible keyboard focus rings. Five sections:
Launch with no arguments (or `--gui`). Warm-dark UI in the Claude Desktop style, WCAG 2.1 AA contrast throughout, visible keyboard focus rings. Six sections:

- **Status** - summary cards for backups / targets / discovered paths, plus a grid of existing snapshots per target.
- **Targets** - folder list. Auto-discovers `<SyncClient>\ClaudePortable` on every recognised sync client (OneDrive Personal / Business, Dropbox, Google Drive Desktop), so a restore on a second machine picks up the first machine's backups without configuration. Manual add/remove available.
- **Discovery** - read-only view of detected Claude paths + sync clients.
- **Restore** - backup grid with per-row `STATUS` (`Synced` / `Cloud-only` / `Unreadable`), `Restore from file...` escape hatch for a ZIP that is not in any configured target, and an **Advanced options** panel for overriding the target user profile (e.g. restoring a `sascha` backup onto a laptop with `sasch` as the user) and for the version-gate override.
- **Logs** - last 500 log lines from the current session, rendered mono.
- **Schedule** - enumerates every Windows scheduled task on this machine via `schtasks.exe /Query /FO CSV /V`. ClaudePortable-managed entries are flagged green (name starts with `ClaudePortable-` or author contains `ClaudePortable`). Tasks that aren't managed but touch a Claude/Cowork/`.claude` path - including hand-written backup PowerShell scripts that compete with ClaudePortable - are flagged orange. Per-row buttons run/disable/enable/delete the task and copy its raw XML to the clipboard. Use this to spot legacy `\Claude-Desktop-Backup`-style tasks that write loose-file backups into a long-path OneDrive folder and break sync.

A ProgressBar on the status bar appears for the duration of any backup or restore, showing the current phase (`Extracting archive`, `Writing cowork-projects/<hash>`, etc.) with file-level percentage. Both commands run on the thread pool so the window stays responsive during multi-GB operations.

Expand All @@ -74,6 +75,8 @@ claudeportable list --in <folder> [--json] # list backups
claudeportable restore --from <zip> --yes [--target-user <path>] [--ignore-version-mismatch]
claudeportable rotate --in <folder> [--daily 7] [--weekly 3] [--monthly 2]
claudeportable schedule install|show|remove|emit # Windows Task Scheduler integration
claudeportable schedule list [--all|--managed|--relevant] [--json] # enumerate all scheduled tasks, flag Claude relevance
claudeportable schedule disable|enable|run <name> # toggle / trigger a scheduled task by full name
```

Exit codes: `0` ok, `1` usage error, `2` precondition fail (destination unwritable, Claude Desktop running), `3` runtime error (I/O, invalid backup, version block).
Expand Down Expand Up @@ -178,22 +181,22 @@ dotnet build
dotnet test
```

61 xUnit cases cover exclusion globs (incl. Claude Extensions paths that must NOT be excluded), manifest (de)serialisation, path rewriter across escaped / single-backslash / forward-slash and arbitrary home-relative paths, retention rotation simulated over 10 weeks with a fake clock, FolderTarget atomic I/O, end-to-end backup roundtrip on synthetic data, Task Scheduler XML, and version gating.
98 xUnit cases cover exclusion globs (incl. Claude Extensions paths that must NOT be excluded), manifest (de)serialisation, path rewriter across escaped / single-backslash / forward-slash and arbitrary home-relative paths, retention rotation simulated over 10 weeks with a fake clock, FolderTarget atomic I/O, end-to-end backup roundtrip on synthetic data, Task Scheduler XML emission, version gating, and the scheduled-task enumerator (CSV parser for German-locale `schtasks.exe` output, Claude-relevance classifier, and command-shape assertions for the installer wrapper).

CI runs the same commands on `windows-latest` via `.github/workflows/ci.yml`. The release pipeline at `.github/workflows/release.yml` builds the MSI + portable exe + SHA-256 on `v*` tag push and attaches them to the GitHub Release.

Local portable-exe build:

```powershell
pwsh scripts/build-exe.ps1 -Version 0.1.13
pwsh scripts/build-exe.ps1 -Version 0.2.0
```

Local MSI build (needs the WiX dotnet tool):

```powershell
dotnet tool install --global wix --version 7.0.0
wix eula accept wix7
pwsh src/ClaudePortable.Installer/build-msi.ps1 -Version 0.1.13
pwsh src/ClaudePortable.Installer/build-msi.ps1 -Version 0.2.0
```

## Known limitations
Expand Down
4 changes: 2 additions & 2 deletions scripts/build-exe.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@
Debug or Release (default Release).

.EXAMPLE
pwsh .\build-exe.ps1 -Version 0.1.1
pwsh .\build-exe.ps1 -Version 0.2.0
#>

param(
[string] $Version = "0.1.1",
[string] $Version = "0.2.0",
[string] $Configuration = "Release"
)

Expand Down
170 changes: 169 additions & 1 deletion src/ClaudePortable.App/Commands/ScheduleCommand.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.CommandLine;
using System.Globalization;
using System.Runtime.Versioning;
using System.Text.Json;
using ClaudePortable.Scheduler.Scheduling;

namespace ClaudePortable.App.Commands;
Expand All @@ -10,11 +11,15 @@ public static class ScheduleCommand
{
public static Command Build()
{
var cmd = new Command("schedule", "Install, inspect, or remove a Windows Task Scheduler entry that runs daily backups.");
var cmd = new Command("schedule", "Install, inspect, list, or remove Windows Task Scheduler entries.");
cmd.AddCommand(BuildInstall());
cmd.AddCommand(BuildShow());
cmd.AddCommand(BuildRemove());
cmd.AddCommand(BuildEmit());
cmd.AddCommand(BuildList());
cmd.AddCommand(BuildDisable());
cmd.AddCommand(BuildEnable());
cmd.AddCommand(BuildRun());
return cmd;
}

Expand Down Expand Up @@ -117,6 +122,169 @@ private static Command BuildRemove()
return remove;
}

private static Command BuildList()
{
var allOption = new Option<bool>(new[] { "--all" }, () => false, "Include foreign tasks unrelated to Claude (default: hide them).");
var managedOption = new Option<bool>(new[] { "--managed" }, () => false, "Only list ClaudePortable-managed tasks.");
var relevantOption = new Option<bool>(new[] { "--relevant" }, () => false, "Only list ClaudePortable + Claude-related tasks (default).");
var jsonOption = new Option<bool>(new[] { "--json" }, () => false, "Emit JSON instead of an aligned table.");
var list = new Command("list", "List Windows scheduled tasks and flag Claude relevance.")
{
allOption, managedOption, relevantOption, jsonOption,
};
list.SetHandler(async (all, managed, relevant, json) =>
{
var installer = new TaskSchedulerInstaller();
var tasks = await installer.EnumerateAsync().ConfigureAwait(false);

IEnumerable<ScheduledTaskInfo> filtered = tasks;
if (managed)
{
filtered = filtered.Where(t => t.ManagedBy == ManagedBy.ClaudePortable);
}
else if (all)
{
// no filter
}
else
{
filtered = filtered.Where(t => t.ManagedBy is ManagedBy.ClaudePortable or ManagedBy.ForeignRelevant);
}

_ = relevant;

var ordered = filtered
.OrderBy(t => t.ManagedBy)
.ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.ToList();

if (json)
{
var payload = new
{
tasks = ordered.Select(t => new
{
name = t.Name,
fullName = t.FullName,
folderPath = t.FolderPath,
managedBy = t.ManagedBy.ToString(),
state = t.State,
author = t.Author,
nextRun = t.NextRunTime?.ToString("o", CultureInfo.InvariantCulture),
lastRun = t.LastRunTime?.ToString("o", CultureInfo.InvariantCulture),
lastResult = t.LastResult,
action = new
{
executable = t.Action.Executable,
arguments = t.Action.Arguments,
workingDirectory = t.Action.WorkingDirectory,
},
trigger = t.TriggerSummary,
}),
};
Console.WriteLine(JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }));
return;
}

if (ordered.Count == 0)
{
Console.WriteLine("(no tasks matched the filter)");
return;
}

var headers = new[] { "NAME", "MANAGED-BY", "STATE", "NEXT RUN", "ACTION" };
var rows = ordered.Select(t => new[]
{
t.FullName,
t.ManagedBy.ToString(),
t.State,
t.NextRunTime?.LocalDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? "-",
Truncate(string.IsNullOrEmpty(t.Action.Arguments) ? t.Action.Executable : $"{t.Action.Executable} {t.Action.Arguments}", 80),
}).ToList();

var widths = headers
.Select((h, i) => Math.Max(h.Length, rows.Count == 0 ? 0 : rows.Max(r => r[i]?.Length ?? 0)))
.ToArray();

Console.WriteLine(string.Join(" ", headers.Select((h, i) => h.PadRight(widths[i]))));
Console.WriteLine(string.Join(" ", widths.Select(w => new string('-', w))));
foreach (var row in rows)
{
Console.WriteLine(string.Join(" ", row.Select((c, i) => (c ?? string.Empty).PadRight(widths[i]))));
}
}, allOption, managedOption, relevantOption, jsonOption);
return list;
}

private static Command BuildDisable()
{
var nameArg = new Argument<string>("name", "Full task name (e.g. \\ClaudePortable-Daily).");
var disable = new Command("disable", "Disable a scheduled task via schtasks.exe /Change /Disable.")
{
nameArg,
};
disable.SetHandler(async taskName =>
{
var installer = new TaskSchedulerInstaller();
var exit = await installer.DisableAsync(taskName).ConfigureAwait(false);
if (exit != 0)
{
Console.Error.WriteLine($"error: schtasks.exe /Change /Disable exited with code {exit}.");
Environment.ExitCode = 3;
return;
}
Console.WriteLine($"disabled '{taskName}'.");
}, nameArg);
return disable;
}

private static Command BuildEnable()
{
var nameArg = new Argument<string>("name", "Full task name (e.g. \\ClaudePortable-Daily).");
var enable = new Command("enable", "Enable a scheduled task via schtasks.exe /Change /Enable.")
{
nameArg,
};
enable.SetHandler(async taskName =>
{
var installer = new TaskSchedulerInstaller();
var exit = await installer.EnableAsync(taskName).ConfigureAwait(false);
if (exit != 0)
{
Console.Error.WriteLine($"error: schtasks.exe /Change /Enable exited with code {exit}.");
Environment.ExitCode = 3;
return;
}
Console.WriteLine($"enabled '{taskName}'.");
}, nameArg);
return enable;
}

private static Command BuildRun()
{
var nameArg = new Argument<string>("name", "Full task name (e.g. \\ClaudePortable-Daily).");
var run = new Command("run", "Trigger a scheduled task immediately via schtasks.exe /Run.")
{
nameArg,
};
run.SetHandler(async taskName =>
{
var installer = new TaskSchedulerInstaller();
var exit = await installer.RunNowAsync(taskName).ConfigureAwait(false);
if (exit != 0)
{
Console.Error.WriteLine($"error: schtasks.exe /Run exited with code {exit}.");
Environment.ExitCode = 3;
return;
}
Console.WriteLine($"triggered '{taskName}'.");
}, nameArg);
return run;
}

private static string Truncate(string s, int max)
=> s.Length <= max ? s : s[..(max - 3)] + "...";

private static Command BuildEmit()
{
var folderOption = new Option<DirectoryInfo>(new[] { "--folder", "-f" }, "Destination folder for scheduled backups.") { IsRequired = true };
Expand Down
29 changes: 29 additions & 0 deletions src/ClaudePortable.App/Ui/Converters/ManagedByToBrushConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.Globalization;
using System.Windows.Data;
using ClaudePortable.Scheduler.Scheduling;

namespace ClaudePortable.App.Ui.Converters;

public sealed class ManagedByToBrushConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not ManagedBy managed)
{
return System.Windows.Application.Current?.TryFindResource("TextMutedBrush")
?? System.Windows.Media.Brushes.Gray;
}

var key = managed switch
{
ManagedBy.ClaudePortable => "SuccessBrush",
ManagedBy.ForeignRelevant => "WarnBrush",
_ => "TextMutedBrush",
};
return System.Windows.Application.Current?.TryFindResource(key)
?? System.Windows.Media.Brushes.Gray;
}

public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
2 changes: 2 additions & 0 deletions src/ClaudePortable.App/Ui/ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using ClaudePortable.Core.Manifest;
using ClaudePortable.Core.Restore;
using ClaudePortable.Scheduler.Retention;
using ClaudePortable.Scheduler.Scheduling;
using ClaudePortable.Targets;
using ClaudePortable.Targets.Models;

Expand All @@ -32,6 +33,7 @@ public sealed class MainViewModel : ViewModelBase
public ObservableCollection<BackupEntry> Backups { get; } = new();
public ObservableCollection<DiscoveredClaudePath> ClaudePaths { get; } = new();
public ObservableCollection<DiscoveredSyncClient> SyncClients { get; } = new();
public ScheduledTasksViewModel ScheduledTasks { get; } = new(new TaskSchedulerInstaller());

[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822", Justification = "Instance binding target for XAML.")]
public ObservableCollection<string> LogEntries => UiLogSink.Instance.Entries;
Expand Down
Loading
Loading