A feature-rich, cross-platform CLI task/to-do manager written in C# (.NET 10).
Rich terminal UI powered by Spectre.Console — coloured tables, progress bars, status badges, interactive prompts, and more.
todo list
Tasks
╭──────┬──────────────────────┬──────────────────┬────────────┬──────────┬─────────╮
│ # │ Title │ Status │ Priority │ Due │ Tags │
├──────┼──────────────────────┼──────────────────┼────────────┼──────────┼─────────┤
│ 1 │ Write project README │ ⟳ In Progress │ ● High │ in 2d │ #docs │
│ 2 │ Fix login bug │ ⚠ Overdue │ ● Critical │ 17d ago │ #work │
│ 3 │ Buy groceries │ ✓ Done │ ● Low │ — │ #home │
╰──────┴──────────────────────┴──────────────────┴────────────┴──────────┴─────────╯
Showing 3 of 3 task(s) · ✓ 1 done · ⚠ 1 overdue
- Features
- Project Structure
- Architecture
- Prerequisites
- Build & Run
- Commands Reference
- Task Statuses
- Priority Levels
- Data Storage
- Portable Usage
- Third-party Packages
- Key Design Decisions
- Contributing
- License
| Feature | Details |
|---|---|
| Rich task table | Rounded borders, colour-coded status & priority, relative due-date labels |
| Overdue detection | Computed at runtime from DueDate; never a stale value in the JSON file |
| Progress bars | Per-status ASCII fill bars + a segment breakdown chart in todo stats |
| Interactive prompts | todo add prompts for the title if none is supplied on the command line |
| Safe file writes | Atomic write-to-temp-then-rename prevents data corruption on crash/power loss |
| Portable JSON store | Human-readable, git-diffable JSON array; path overridable via flag or env var |
| Confirmation guards | remove and purge ask for confirmation; bypassable with --yes |
| State-machine guards | Invalid transitions (e.g. "done → start") are rejected with a clear error |
| Full DI | Commands receive services via constructor injection (no static state) |
| .NET 10 | Targets the latest framework; primary-constructor syntax throughout |
cli-todo-sharp/
├── src/
│ └── CliTodoSharp/
│ ├── CliTodoSharp.csproj # SDK-style project; NuGet refs
│ ├── Program.cs # Entry point: DI setup + Spectre app config
│ │
│ ├── Models/
│ │ ├── TodoTask.cs # Core domain entity
│ │ ├── TodoStatus.cs # Enum: Pending | InProgress | Done | Canceled
│ │ └── TaskPriority.cs # Enum: None | Low | Medium | High | Critical
│ │
│ ├── Services/
│ │ ├── ITaskStorageService.cs # Persistence abstraction
│ │ ├── JsonTaskStorageService.cs# JSON-file implementation (atomic writes)
│ │ └── TaskManager.cs # Business logic layer + TaskStats record
│ │
│ ├── Commands/
│ │ ├── BaseCommandSettings.cs # Shared --storage option
│ │ ├── AddCommand.cs # todo add
│ │ ├── ListCommand.cs # todo list / ls
│ │ ├── ShowCommand.cs # todo show <index>
│ │ ├── StartCommand.cs # todo start <index>
│ │ ├── DoneCommand.cs # todo done <index>
│ │ ├── CancelCommand.cs # todo cancel <index>
│ │ ├── ReopenCommand.cs # todo reopen <index>
│ │ ├── RemoveCommand.cs # todo remove / rm <index>
│ │ ├── EditCommand.cs # todo edit <index>
│ │ ├── StatsCommand.cs # todo stats
│ │ └── PurgeCommand.cs # todo purge
│ │
│ ├── Rendering/
│ │ └── TaskRenderer.cs # All Spectre.Console output helpers
│ │
│ └── Infrastructure/
│ └── TypeRegistrar.cs # ITypeRegistrar/ITypeResolver for DI bridge
│
├── tests/
│ └── CliTodoSharp.Tests/
│ ├── CliTodoSharp.Tests.csproj # xUnit + FluentAssertions test project
│ ├── InMemoryTaskStorage.cs # ITaskStorageService stub for unit tests
│ ├── TaskManagerTests.cs # 18 TaskManager unit tests
│ └── JsonTaskStorageServiceTests.cs # 7 JSON storage integration tests
│
├── .github/
│ ├── workflows/
│ │ └── ci.yml # CI: build + test on Ubuntu/Windows/macOS
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.yml
│ │ └── feature_request.yml
│ └── PULL_REQUEST_TEMPLATE.md
│
├── .vscode/
│ ├── launch.json # 24 debug configurations
│ └── tasks.json # Build (Debug/Release) + publish tasks
│
├── cli-todo-sharp.sln # Solution file
├── global.json # Pins .NET SDK version
├── Directory.Build.props # Shared MSBuild properties
├── .editorconfig # C# code-style rules
├── LICENSE # MIT
├── CONTRIBUTING.md
├── CODE_OF_CONDUCT.md
└── README.md
┌─────────────────────────────────────────────────────────┐
│ CLI Layer (Spectre.Console.Cli) │
│ Commands: Add / List / Show / Start / Done / Cancel … │
│ Settings classes carry parsed CLI arguments │
└─────────────────────┬───────────────────────────────────┘
│ constructor injection
┌─────────────────────▼───────────────────────────────────┐
│ Business Logic (TaskManager) │
│ State-machine transitions · Filtering · Stats │
└─────────────────────┬───────────────────────────────────┘
│ ITaskStorageService
┌─────────────────────▼───────────────────────────────────┐
│ Persistence (JsonTaskStorageService) │
│ Atomic JSON read/write · Path resolution chain │
└────────────────────────────────────────────────────────-─┘
Rendering (TaskRenderer) is called directly by commands –
it has no state and only depends on domain objects.
Spectre.Console.Cli instantiates commands through its own ITypeResolver interface.
Infrastructure/TypeRegistrar.cs implements that interface by delegating to a standard Microsoft.Extensions.DependencyInjection container, so commands can use ordinary constructor injection:
// Program.cs – DI wiring
services.AddSingleton<ITaskStorageService>(_ => new JsonTaskStorageService(storagePath));
services.AddSingleton<TaskManager>();
services.AddTransient<AddCommand>();
// …
var registrar = new TypeRegistrar(services);
var app = new CommandApp(registrar);| Requirement | Version |
|---|---|
| .NET SDK | 10.0 or later |
| OS | Windows / macOS / Linux |
Verify your SDK version:
dotnet --version
# 10.0.xgit clone https://github.com/jacshuo/cli-todo-sharp.git
cd cli-todo-sharp/src/CliTodoSharp
dotnet run -- <command> [options]dotnet build -c Release
./bin/Release/net10.0/todo --helpdotnet test cli-todo-sharp.sln
# or with verbosity:
dotnet test cli-todo-sharp.sln --verbosity normal# Windows x64
dotnet publish -c Release -r win-x64 --self-contained false -o ./publish/win
# Linux x64
dotnet publish -c Release -r linux-x64 --self-contained false -o ./publish/linux
# macOS Apple Silicon
dotnet publish -c Release -r osx-arm64 --self-contained false -o ./publish/macAfter publishing, copy the todo (or todo.exe) binary to any directory on your PATH.
Create a new task. If TITLE is omitted, an interactive prompt appears.
| Option | Short | Description | Default |
|---|---|---|---|
--priority <LEVEL> |
-p |
none | low | medium | high | critical |
medium |
--due <DATE> |
yyyy-MM-dd or yyyy-MM-ddTHH:mm |
— | |
--description <TEXT> |
-d |
Multi-line detail text | — |
--tags <LIST> |
-t |
Comma-separated tags | — |
--storage <PATH> |
Override the JSON file path | see Data Storage |
todo add "Refactor auth module" -p high --due 2026-03-15 -t work,backend
todo add # interactive title promptShow a rich coloured table of tasks.
| Option | Short | Description | Default |
|---|---|---|---|
--status <STATUS> |
-s |
all | pending | inprogress | done | canceled | overdue |
all |
--tag <TAG> |
-t |
Show only tasks with this tag | — |
--sort <FIELD> |
created | due | priority | title | status |
created |
|
--detail |
Render a full panel per task instead of a table row | false |
todo list
todo ls --status overdue
todo list --tag work --sort priority
todo list --detailPrint the full detail panel for a single task (all fields including description and timestamps).
todo show 2Transition a Pending task to In Progress. Records StartedAt timestamp.
todo start 3Mark a Pending or In Progress task as Done. Records CompletedAt timestamp.
todo done 3Abandon a Pending or In Progress task (sets status to Canceled).
todo cancel 5Reset a Done or Canceled task back to Pending. Clears StartedAt and CompletedAt.
todo reopen 5Permanently delete a task. Prompts for confirmation unless --yes / -y is supplied.
todo remove 4
todo rm 4 --yes # skip confirmationPatch any subset of a task's fields. Only the options you provide are changed.
| Option | Description |
|---|---|
--title <TEXT> |
New title |
--description <TEXT> |
New description |
--priority <LEVEL> -p |
New priority |
--due <DATE> |
New due date |
--clear-due |
Remove the due date |
--tags <LIST> -t |
Replace all tags |
todo edit 1 --title "Updated title" --priority critical
todo edit 2 --clear-due
todo edit 3 --tags work,urgentDisplay a statistics dashboard with:
- Proportional segment chart (each status coloured differently)
- Progress bars per status (count / total)
- Overall completion rate bar
- Current storage file path
todo statsBulk-delete all Done and Canceled tasks. Prompts for confirmation unless --yes is used.
todo purge
todo purge --yes| Status | Icon | Stored | Description |
|---|---|---|---|
| Pending | ◯ |
✔ | Created, not yet started |
| In Progress | ⟳ |
✔ | Work active |
| Done | ✓ |
✔ | Completed |
| Canceled | ✕ |
✔ | Abandoned |
| Overdue | ⚠ |
✘ | Derived – Pending/InProgress with DueDate < now |
Why is "Overdue" not stored?
Storing it would create stale values: a task saved as "Overdue" on Monday would still claim to be overdue after being completed on Tuesday. Instead,TodoTask.IsOverdueis a computed property evaluated at render time, and the JSON only ever contains the four real states above.
Pending ──────► InProgress ──────► Done
│ │
└──────────────────┴──────────► Canceled
│
Done ◄────────────── Reopen ◄──────────┘
| Level | Symbol | Colour |
|---|---|---|
| Critical | ● |
Bold Red |
| High | ● |
Orange |
| Medium | ● |
Yellow |
| Low | ● |
Steel Blue |
| None | ○ |
Grey |
Tasks are persisted as a pretty-printed JSON array at:
| Platform | Default path |
|---|---|
| Windows | %USERPROFILE%\.todo-sharp\tasks.json |
| macOS / Linux | ~/.todo-sharp/tasks.json |
The path is resolved in this order (first wins):
--storage <path>CLI flagTODO_STORAGE_PATHenvironment variable- Platform home-directory default above
[
{
"id": "e2759fd7-84da-420c-8146-a472612bfc8f",
"title": "Fix login bug",
"description": null,
"tags": ["work", "backend"],
"status": "pending",
"priority": "critical",
"createdAt": "2026-02-27T21:21:00.000Z",
"dueDate": "2026-02-10T00:00:00.000Z",
"startedAt": null,
"completedAt":null,
"updatedAt": "2026-02-27T21:21:00.000Z"
}
]All DateTime values are stored as UTC ISO-8601. This means the file is
timezone-independent and will display correctly when opened on a machine in a different
time zone.
Atomic writes — the storage service writes to tasks.json.tmp first and then
renames over the target file. This ensures the JSON is never left in a half-written
state if the process is killed mid-save.
Because the storage file is plain JSON, you can:
- Place
tasks.jsonon a USB drive and pass its path with--storage:todo list --storage /mnt/usb/mytasks.json
- Set the env variable once in your shell profile:
# ~/.bashrc or ~/.zshrc export TODO_STORAGE_PATH="$HOME/Dropbox/todo/tasks.json"
- Commit it to a git repo for a simple, auditable history of every change.
- Copy the file between Windows, macOS, and Linux — all timestamps are UTC and
all enum values are human-readable strings (
"pending","high", …).
| Package | Version | Purpose |
|---|---|---|
Spectre.Console |
0.49.x | Rich terminal UI — tables, panels, progress bars, prompts, markup |
Spectre.Console.Cli |
0.49.x | Strongly-typed command-line argument parsing on top of Spectre.Console |
Microsoft.Extensions.DependencyInjection |
10.x | Standard .NET DI container; bridges Spectre command resolution with services |
All other functionality (JSON, file I/O, date parsing) uses the .NET BCL — no extra packages needed.
It's the most complete rich-terminal library in the .NET ecosystem. A single package provides markup colouring, tables, panels, live progress, prompts, and the Cli sub-library — no need to stitch together multiple tools.
See Task Statuses. Short version: stale status values are a correctness bug waiting to happen.
Short integers (1, 2, 3…) are fine for display but collide when two separate JSON files are merged (e.g. from a phone and a laptop). GUIDs are unique across machines. We expose short sequential display indices to the user while using GUIDs internally.
If the process is killed between File.WriteAllText and the data being fully flushed, the JSON is truncated and unreadable. Writing to .tmp first and then File.Move(..., overwrite: true) is an atomic rename on all supported operating systems — the old file is only replaced once the new one is fully written.
C# 12 / .NET 8+ primary constructors reduce boilerplate DI plumbing without any runtime cost. Every service and command class in this project uses them.
Spectre.Console.Cli has its own resolver interface but no built-in IOC container. Implementing the two-interface bridge (TypeRegistrar / TypeResolver) against the standard .NET DI container means all normal patterns (scoped services, factory registrations, etc.) work without coupling to a third-party container.
Contributions are welcome! Please read CONTRIBUTING.md for:
- Setting up the development environment
- Branch and commit conventions (Conventional Commits)
- How to add tests and run them locally
- The pull-request process
Please also review our Code of Conduct before participating.
Distributed under the MIT License. See LICENSE for details.