Strongly-typed, JSON-persisted settings and ready-made settings commands for CLI tools built on Spectre.Console.
Stop hand-rolling the same ~/.app/config.json + load/save boilerplate into every CLI you build. Declare a settings class, register it, and inject it into any command. Change a property and it's persisted — automatically and debounced, or explicitly when you say so — and my-cli settings list / reset just work.
- Strongly-typed settings — derive from
SettingsBase, back each property with a field, callOnPropertyChanged(). That's the whole contract. - Automatic or explicit persistence —
Automaticmode debounces a burst of changes into a single async disk write;Explicitmode persists only when you callSave()/SaveAsync(). settingscommand branch —listandresetwired into your existingCommandAppwith a single call.- Atomic writes — a crash mid-write never leaves a half-written settings file on disk.
- Tolerant deserialisation — added a property? It defaults. Removed one? The old key is ignored. No migration ceremony for ordinary schema evolution.
- Errors are never swallowed — a failed fire-and-forget write is routed to a configurable error handler (default: one line to stderr).
- Zero compiler warnings, fully documented public surface —
<GenerateDocumentationFile>on, analyzers on,TreatWarningsAsErrorson.
dotnet add package NextIteration.SpectreConsole.SettingsTargets net10.0.
Derive from SettingsBase, back each property with a field, and call OnPropertyChanged() from the setter:
using NextIteration.SpectreConsole.Settings;
public sealed class AppSettings : SettingsBase
{
private DataSourceMode _mode = DataSourceMode.File;
public DataSourceMode Mode
{
get => _mode;
set
{
_mode = value;
OnPropertyChanged();
}
}
}
public enum DataSourceMode { File, Api }One call per settings class. SettingsDirectory is required — there's no smart default:
using NextIteration.SpectreConsole.Settings;
services.AddSettings<AppSettings>(opts =>
{
opts.SettingsDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".my-cli", "settings");
});
// A second class — this one persists only on explicit Save():
services.AddSettings<CacheSettings>(opts =>
{
opts.SettingsDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".my-cli", "settings");
opts.PersistenceMode = PersistenceMode.Explicit;
});app.Configure(config =>
{
config.AddSettingsBranch();
// ... your other commands
});public sealed class SwitchModeCommand(AppSettings settings) : AsyncCommand
{
public override async Task<int> ExecuteAsync(CommandContext context)
{
settings.Mode = DataSourceMode.Api;
// No explicit Save() needed — persisted automatically (debounced).
return 0;
}
}That's it. Each settings class is serialised to its own JSON file in the directory, named after the class (e.g. AppSettings.json). On the next launch it's deserialised and handed to your commands.
| Command | Description |
|---|---|
settings list |
Table of every registered settings class and its current property values. |
settings reset <SettingsClassName> |
Reset one settings class to default values and persist. |
settings reset --all |
Reset every registered settings class to defaults and persist. |
Every command accepts -v / --verbose for full stack-trace output when something goes wrong.
$ my-cli settings list
AppSettings (Automatic)
/home/me/.my-cli/settings/AppSettings.json
┌──────────┬──────┐
│ Property │ Value│
├──────────┼──────┤
│ Mode │ Api │
└──────────┴──────┘
$ my-cli settings reset AppSettings
Reset 'AppSettings' to defaults? This overwrites the saved file and cannot be undone. [y/N]: y
Reset 'AppSettings' to defaults.reset prompts for confirmation (defaulting to "no") before overwriting. Pass -f / --force to skip the prompt in scripts or CI.
Every OnPropertyChanged() schedules a debounced asynchronous write. A burst of changes in the same synchronous call stack — or within the debounce window — coalesces into a single disk write, so setting five properties in a row touches the file once. The default window is 250 ms; tune it per class via SettingsOptions.DebounceInterval.
Writes are fire-and-forget, but failures are never silently swallowed — they're routed to SettingsOptions.ErrorHandler (default: a single line to stderr).
OnPropertyChanged() becomes a no-op; nothing is written until you call Save() or SaveAsync().
Save()— fire-and-forget. Convenient, but in a command that mutates-then-returns the process may exit before the write lands.SaveAsync()— awaitable. Use it when you need the write to be on disk before the command returns. Exceptions propagate to the awaiter rather than the error handler.
Both methods work in either mode — call SaveAsync() in Automatic mode too when you want to flush deterministically.
On startup each registered class is deserialised from its JSON file. If the file doesn't exist, a default-constructed instance is used — no exception. Property assignments performed by the deserializer during load never trigger a write; persistence only kicks in for changes you make afterwards.
Settings classes change over time. The default serializer is tolerant:
- Added a property? Files written by older versions simply lack the key, so it takes its constructed default.
- Removed a property? The now-unknown key in older files is ignored.
- Property matching is case-insensitive; enums round-trip as readable strings; comments and trailing commas are tolerated on read.
Supply your own SettingsOptions.SerializerOptions if you need different behaviour.
Persistence handles whatever System.Text.Json can serialise — nested objects, lists, and dictionaries all round-trip to and from the JSON file without extra work. Two things to know once you go beyond scalars:
-
Automatic save only fires on the top-level setter.
OnPropertyChanged()runs when you assign a property on the settings object — not when you mutate inside a nested object or collection:settings.Profile = new Profile { Name = "Ada" }; // ✅ persisted (the setter ran) settings.Profile.Name = "Ada"; // ❌ not detected in Automatic mode settings.RecentFiles.Add("notes.txt"); // ❌ not detected
To persist a nested change: reassign the whole property, model nested values as immutable
records and swap them (settings.Profile = settings.Profile with { Name = "Ada" };), or callsettings.Save()/await settings.SaveAsync()after mutating. -
settings listrenders complex values as compact JSON, so the table stays readable instead of printing a type name.
Keep your life simple — make settings properties scalars (string, bool, numbers, enum, DateTime, Guid, Uri, …) wherever you can. A flat scalar settings class never hits the in-place-mutation gotcha above, reads cleanly in settings list, and is trivial to reason about. Reach for a nested object or collection only when you're happy to assign it wholesale.
ISettingsStore is registered as a singleton and aggregates every class you registered. It powers settings reset, and you can use it directly:
public sealed class FactoryResetCommand(ISettingsStore store) : AsyncCommand
{
public override async Task<int> ExecuteAsync(CommandContext context)
{
await store.ResetAllAsync();
return 0;
}
}A reset restores defaults in place on the live instance and persists immediately — any command that injected the settings object directly observes the change without a restart.
AddSettings<T>(opts => …) accepts a SettingsOptions:
| Option | Default | Notes |
|---|---|---|
SettingsDirectory |
— (required) | Absolute path; throws at registration if unset. |
PersistenceMode |
Automatic |
Automatic (debounced) or Explicit. |
DebounceInterval |
250 ms |
Coalescing window for automatic writes. |
ErrorHandler |
stderr line | Invoked when a fire-and-forget write fails. |
SerializerOptions |
tolerant default | Override the JsonSerializerOptions. |
T must derive from SettingsBase and have a public parameterless constructor (used for defaults and by the deserializer).
- .NET 10.0 or later
- Spectre.Console 0.54+ and Spectre.Console.Cli 0.53+
- Microsoft.Extensions.DependencyInjection.Abstractions 10.0+
Everything else is transitive.
Issues and PRs welcome.
When contributing code, please keep the zero-warning, fully-documented public surface. TreatWarningsAsErrors is on for a reason.
See CHANGELOG.md for release notes.
MIT © Stuart Meeks
Built for — and unaffiliated with — the excellent Spectre.Console project.