Skip to content

Instantly share code, notes, and snippets.

@JKamsker
Created March 12, 2026 11:36
Show Gist options
  • Select an option

  • Save JKamsker/6d14bc2eabc56b97f345d912af21f2cd to your computer and use it in GitHub Desktop.

Select an option

Save JKamsker/6d14bc2eabc56b97f345d912af21f2cd to your computer and use it in GitHub Desktop.
Dotnet-CLI-Spec.md

Here’s the spec I’d use.

For a real .NET API CLI with Spectre.Console, I would treat Spectre as the command surface and Host.CreateApplicationBuilder(args) as the runtime backbone. Spectre’s current docs explicitly support multi-command hierarchies with AddBranch(), shared settings inheritance, DI through a registrar bridge, interceptors, async commands, logging, and in-memory testing. Microsoft’s current .NET guidance recommends the Generic Host for apps that use configuration, DI, and logging. (spectreconsole.net)

1) Product shape

The CLI should be a task-oriented tool first, and only secondarily a “raw API caller.” Spectre’s branching model is a very good fit for that, because it gives you discoverable help at each level, aliases, descriptions, and examples. (spectreconsole.net)

Proposed command tree:

mycli
  auth
    login
    logout
    whoami
    token
  users
    list
    get
    create
    update
    delete
  projects
    list
    get
    create
    delete
  request
    send
  config
    get
    set
    list
    path
  cache
    clear
  doctor
  version

Why this shape:

  • auth, config, cache, and doctor are infrastructure branches.
  • resource branches (users, projects, etc.) are the main UX.
  • request send is the escape hatch for unsupported endpoints.
  • avoid a single giant flat command list.

2) Auth spec

For user login, the default flow should be system-browser OAuth authorization code flow with PKCE, not an embedded webview. RFC 8252 is explicit that native apps should use an external user-agent, typically the browser, and not embedded user-agents; RFC 7636 defines PKCE as the mitigation for authorization-code interception in public clients. For CLI/headless/SSH scenarios, support device code flow as the fallback. (RFC Editor)

Concrete auth behavior:

  • mycli auth login Opens browser, starts loopback callback listener on 127.0.0.1 or [::1] with an ephemeral port.
  • mycli auth login --device Prints verification URL + user code and polls until authorized.
  • mycli auth whoami Shows current profile, tenant/environment, scopes, token expiry.
  • mycli auth token --format json Hidden or advanced-only command for scripting/debugging.
  • mycli auth logout Deletes local token cache and optionally calls revoke endpoint if available.

Important guardrails:

  • do not embed a client secret in a widely distributed native CLI.
  • if you need machine-to-machine auth, make that a separate profile using client credentials only for controlled enterprise/service scenarios, not the default interactive user flow. Microsoft’s current guidance for client-credentials explicitly says this flow is for confidential clients/service-to-service and says never to publish such credentials in source or embed them in a widely distributed native app. (Microsoft Learn)

I would also make auth a cross-cutting concern through a Spectre interceptor: anonymous commands (auth login, doctor, config path, version) bypass auth, while protected commands validate the session before execution. Spectre’s interceptor model is designed exactly for cross-cutting concerns like authentication, logging, timing, and exit-code adjustment. (spectreconsole.net)

3) Token and secret storage

Do not store tokens in appsettings.json, repo files, or .NET user-secrets for production use. Microsoft’s Secret Manager docs explicitly say user-secrets are not encrypted and are for development only. (Microsoft Learn)

Use this contract instead:

  • ICredentialStore

    • SaveAsync(profile, tokenBundle)
    • LoadAsync(profile)
    • DeleteAsync(profile)
  • OS-backed secure store implementation per platform

  • encrypted file fallback only when no platform store exists, with an explicit warning banner

Also separate:

  • credentials/tokens → secure store
  • non-secret CLI settings → config file + env vars + command-line args
  • ephemeral cache → cache directory

4) Runtime architecture

Use the Generic Host as the composition root and Spectre as the command framework. Microsoft recommends Host.CreateApplicationBuilder for new projects, and its config guidance recommends the Generic Host approach when the app uses DI/logging/configuration. (Microsoft Learn)

Recommended project layout:

src/MyCli
  Program.cs
  Bootstrap/
    TypeRegistrar.cs
    HostingExtensions.cs
  Commands/
    Auth/
    Users/
    Projects/
    Request/
    Config/
    Cache/
    Doctor/
  Interceptors/
    AuthInterceptor.cs
    LoggingInterceptor.cs
  Settings/
    GlobalSettings.cs
    AuthenticatedSettings.cs
  Services/
    Auth/
    Api/
    Output/
    Profiles/
  Http/
    Handlers/
      AuthHeaderHandler.cs
      CorrelationIdHandler.cs
      UserAgentHandler.cs
  Configuration/
    CliOptions.cs
    ProfileOptions.cs
  Storage/
    ICredentialStore.cs
    IProfileStore.cs
    ICacheStore.cs

Command rules:

  • every command gets a small Settings type
  • common switches come from a shared base settings class
  • commands stay thin; service layer does the work
  • prefer AsyncCommand<TSettings> for anything that hits network or disk

Spectre’s docs recommend AsyncCommand<TSettings> for I/O-bound operations and show how to wire Ctrl+C cancellation into RunAsync. (spectreconsole.net)

5) HTTP client spec

Use IHttpClientFactory with typed clients for each API area, plus delegating handlers for auth, correlation ID, user-agent, and optional idempotency headers. Microsoft’s current guidance says IHttpClientFactory gives you central client configuration, outgoing middleware via delegating handlers, handler lifetime management, and built-in logging hooks. (Microsoft Learn)

Suggested registrations:

  • AuthApiClient
  • UsersApiClient
  • ProjectsApiClient
  • RawApiClient

For resilience, add one HTTP resilience handler per client using Microsoft.Extensions.Http.Resilience; Microsoft’s current docs explicitly recommend this package for HttpClient, note that standard handlers encode common best practices, and warn against stacking multiple resilience handlers. (Microsoft Learn)

Practical default:

  • connect timeout
  • request timeout
  • retry for transient 5xx / network faults
  • 429-aware backoff when API publishes rate limits
  • no retry on 4xx except explicit provider guidance

6) Output and UX contract

Use Spectre richly, but keep scripting stable:

  • default output: human-friendly tables/panels/spinners
  • --json: machine-readable output
  • --verbose / --log-level
  • --no-color
  • --profile
  • --base-url
  • --timeout

Also:

  • set application name/version explicitly
  • add aliases for common commands
  • add examples for the help screen
  • validate examples in debug builds

Spectre’s docs support SetApplicationName, SetApplicationVersion, aliases, examples, and ValidateExamples(). (spectreconsole.net)

Example global settings base:

public abstract class GlobalSettings : CommandSettings
{
    [CommandOption("-p|--profile <PROFILE>")]
    public string? Profile { get; init; }

    [CommandOption("--base-url <URL>")]
    public string? BaseUrl { get; init; }

    [CommandOption("--json")]
    public bool Json { get; init; }

    [CommandOption("-v|--verbose")]
    public bool Verbose { get; init; }

    [CommandOption("--log-level <LEVEL>")]
    public LogLevel? LogLevel { get; init; }

    [CommandOption("--no-color")]
    public bool NoColor { get; init; }
}

7) Error handling and exit codes

Use centralized error handling in production with SetExceptionHandler, and use PropagateExceptions() only in debug/dev/test runs. Spectre’s current error-handling docs recommend exactly those two models and note that SetExceptionHandler is the clean central path for exit-code mapping. (spectreconsole.net)

Recommended exit codes:

  • 0 success
  • 1 unexpected failure
  • 2 validation / usage error
  • 3 not authenticated
  • 4 authentication failed / token expired and refresh failed
  • 5 forbidden
  • 6 not found
  • 7 conflict
  • 8 rate limited
  • 9 network / timeout
  • 10 cancelled
  • 11 partial success
  • 12 config/profile error

Map HTTP errors into domain exceptions first, then into exit codes centrally.

8) Testing spec

Inject IAnsiConsole into commands instead of using static AnsiConsole, and test commands with CommandAppTester. Spectre’s docs explicitly recommend injecting IAnsiConsole for testability and using Spectre.Console.Cli.Testing to run commands in memory and assert on exit code, output, and parsed settings. (spectreconsole.net)

Test layers:

  • unit tests for auth/services/parsers
  • command tests for argument parsing and output
  • HTTP integration tests with mocked handlers
  • end-to-end smoke tests for auth, users, request send

9) Packaging and distribution

Ship it as a .NET tool. Microsoft’s docs define .NET tools as special NuGet-packaged console apps and support both global and local installation. I’d publish:

  • global tool for normal users
  • local tool manifest for repo-pinned/internal team use

Also note Microsoft’s warning: .NET tools run in full trust, so this is something users install only from a trusted publisher/feed. (Microsoft Learn)

10) My final recommendation

If this were my project, I would standardize on this combination:

  • Spectre.Console.Cli for commands, branches, help, and UX
  • Generic Host for DI/config/logging
  • IHttpClientFactory + typed clients
  • Microsoft.Extensions.Http.Resilience
  • browser login + PKCE by default
  • device code as headless fallback
  • auth interceptor
  • OS-backed credential store abstraction
  • JSON mode for automation
  • central exception-to-exit-code mapping

That gives you a CLI that feels polished for humans, but still behaves predictably in scripts and CI. It also lines up well with Spectre’s current model and Microsoft’s current hosting, HTTP, configuration, and packaging guidance. (spectreconsole.net)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment