Swift & .NET CLI Frameworks
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against Swift 6.4, swift-argument-parser 1.8.2, SwiftTUI 0.1.0 (its latest tag), .NET with System.CommandLine 2.0.12,
Spectre.Console and Spectre.Console.Cli 0.57.2, and the docsctl running example of this sub-section, as checked on 2026-09-30. The Swift
docsctl was built and run with Swift 6.4 on macOS, and the SwiftTUI view was compiled. The .NET SDK is not installed on the machine used to write this page,
so the C# snippets were not compiled; their API names follow Microsoft’s documentation. The fictional docsctl exists only on these pages, and its behaviour here is illustrative.
Both ecosystems produce well-typed command-line tools that compile ahead of time, and both have a first-party parser. The language references are Swift and C#; this page covers only the command-line specifics.
| Library | Role | Main strength for agent-friendly tools |
|---|---|---|
swift-argument-parser |
Type-driven parser from Apple |
Help, errors and completion scripts generated from the declarations; a machine-readable dump of the command tree. |
SwiftTUI |
SwiftUI-style terminal views |
Terminal interfaces written in a familiar declarative style; young and effectively dormant (see below). |
System.CommandLine |
Microsoft’s parser |
Parsing, help, completion and suggestions; trim-friendly and suited to AOT-compiled tools. |
Spectre.Console |
Rich terminal output |
Tables, progress and prompts; detects interactive capabilities. |
Spectre.Console.Cli |
Typed command framework on Spectre.Console |
Settings classes with attributes; dependency injection. |
Swift: swift-argument-parser
swift-argument-parser is Apple’s library for declaring a command line as Swift types. A type conforms to ParsableCommand (or AsyncParsableCommand), is marked @main,
and declares its inputs with the property wrappers @Argument, @Option, @Flag and @OptionGroup; subcommands are listed in a CommandConfiguration. The library generates --help, error messages with usage, a
--generate-completion-script flag, and --experimental-dump-help, which prints the whole command tree as JSON. The package manifest needs one dependency:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "docsctl",
platforms: [.macOS(.v13)],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.8.0"),
],
targets: [
.executableTarget(
name: "docsctl",
dependencies: [.product(name: "ArgumentParser", package: "swift-argument-parser")]
),
]
)
import ArgumentParser
import Foundation
@main
struct Docsctl: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "docsctl",
abstract: "Search and publish this site's documentation.",
version: "0.1.0",
subcommands: [Search.self, Publish.self]
)
// ArgumentParser reports usage errors with EX_USAGE (64); the docsctl contract says 2.
static func main() {
do {
var command = try parseAsRoot()
try command.run()
} catch {
let code = exitCode(for: error)
let message = fullMessage(for: error)
if !message.isEmpty {
if code == .success {
print(message) // --help and --version go to stdout
} else {
FileHandle.standardError.write(Data((message + "\n").utf8))
}
}
Foundation.exit(code == .validationFailure ? 2 : code.rawValue)
}
}
struct GlobalOptions: ParsableArguments {
@Flag(name: .long, help: "Emit JSON on stdout.")
var json = false
}
struct Search: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Search the documentation.",
discussion: "Example: docsctl search kalman --component irurueta-statistics --json"
)
@OptionGroup var global: GlobalOptions
@Argument(help: "Words to look for.")
var query: String
@Option(help: "Restrict to one component.")
var component: String?
func run() throws {
if global.json {
print(#"{"query":"\#(query)","results":[],"schemaVersion":1,"total":0}"#)
} else {
print("0 result(s) for \(query)")
}
}
}
struct Publish: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Publish a component (destructive unless --dry-run)."
)
@Option(help: "Component to publish.")
var component: String
@Flag(name: .customLong("dry-run"), help: "Show the plan only.")
var dryRun = false
@Flag(name: [.customLong("yes"), .short], help: "Assume yes for confirmations.")
var yes = false
func run() throws {
if dryRun {
print(#"{"dryRun":true,"schemaVersion":1}"#)
return
}
if !yes {
FileHandle.standardError.write(
Data("docsctl: publish needs confirmation; re-run with --yes or use --dry-run.\n".utf8))
throw ExitCode(5) // CONFIRMATION_REQUIRED
}
}
}
}
Two behaviours are worth knowing before you rely on it. First, the library reports usage errors with the sysexits value 64 (EX_USAGE) by default. The docsctl contract uses 2, and treats
sysexits as vocabulary only (see Streams & Exit Codes), so the example overrides main() to map that one code to 2 while
passing the others through. Second, throw ExitCode(5) from run() ends the command with a chosen status without printing a message; the message is written to stderr first.
Running the built binary gave exit 2 for a missing argument or unknown subcommand, 5 for an unconfirmed publish, and 0 for --dry-run, --yes, --help and --version.
docsctl --experimental-dump-help prints the command tree, its arguments and flags as JSON. The name says the format may change between releases, so treat it as an input for
generating the contract’s schema --json output, not as the contract itself (see Self-Describing Help).
Reference: swift-argument-parser.
Swift: SwiftTUI
SwiftTUI applies SwiftUI’s declarative syntax to the terminal: a View with a body, stacks, text, buttons, text fields, @State and @Binding, and Application(rootView:).start()
to run it. Its documentation lists macOS and Linux as supported platforms. The project is small and its latest tag (0.1.0) and last commit date from 2024, so treat it as a source
of ideas and a way to prototype, and check that it still builds against your toolchain before you depend on it. The example below compiled against Swift 6.4; it was not run interactively.
// swift-tools-version: 5.9
import PackageDescription
let package = Package(name: "tui", platforms: [.macOS(.v13)],
dependencies: [.package(url: "https://github.com/rensbreur/SwiftTUI", from: "0.1.0")],
targets: [.executableTarget(name: "tui", dependencies: [.product(name: "SwiftTUI", package: "SwiftTUI")])])
import Foundation
import SwiftTUI
struct ResultsView: View {
var body: some View {
VStack {
Text("docsctl browse")
Button("Quit") { exit(0) }
}
}
}
Application(rootView: ResultsView()).start()
As with every terminal interface in this sub-section, use it behind its own subcommand (docsctl browse) and keep the commands it wraps available in plain form.
Reference: SwiftTUI.
.NET: System.CommandLine
System.CommandLine is Microsoft’s command-line library, used by the .NET CLI itself. It parses input according to POSIX or Windows conventions, generates help, and supports tab completion and response files. It is documented as trim-friendly,
which makes it a good fit for small AOT-capable tools. In the 2.x API a RootCommand holds Option<T> and Argument<T> symbols and subcommands, SetAction attaches a delegate that receives the
ParseResult and returns an int exit code, and rootCommand.Parse(args).Invoke() runs it. When parsing fails, Invoke prints the errors to stderr and returns 1, so a tool with the docsctl contract handles parse errors itself:
using System.CommandLine;
const int ExitUsage = 2;
const int ExitConfirmationRequired = 5;
Option<bool> json = new("--json") { Description = "Emit JSON on stdout.", Recursive = true };
Argument<string> query = new("query") { Description = "Words to look for." };
Option<string?> component = new("--component") { Description = "Restrict to one component." };
Option<string> publishComponent = new("--component") { Description = "Component to publish.", Required = true };
Option<bool> dryRun = new("--dry-run") { Description = "Show the plan only." };
Option<bool> yes = new("--yes", "-y") { Description = "Assume yes for confirmations." };
Command search = new("search", "Search the documentation.") { query, component };
search.SetAction(parseResult =>
{
string q = parseResult.GetValue(query)!;
Console.Out.WriteLine(parseResult.GetValue(json)
? $"{{\"query\":\"{q}\",\"results\":[],\"schemaVersion\":1,\"total\":0}}"
: $"0 result(s) for {q}");
return 0;
});
Command publish = new("publish", "Publish a component (destructive unless --dry-run).")
{
publishComponent, dryRun, yes
};
publish.SetAction(parseResult =>
{
if (parseResult.GetValue(dryRun))
{
Console.Out.WriteLine("{\"dryRun\":true,\"schemaVersion\":1}");
return 0;
}
if (!parseResult.GetValue(yes))
{
Console.Error.WriteLine("docsctl: publish needs confirmation; re-run with --yes or use --dry-run.");
return ExitConfirmationRequired;
}
return 0;
});
RootCommand root = new("Search and publish this site's documentation.") { json, search, publish };
ParseResult result = root.Parse(args);
if (result.Errors.Count > 0)
{
foreach (var error in result.Errors)
{
Console.Error.WriteLine(error.Message);
}
return ExitUsage; // the library's own default would be 1
}
return result.Invoke();
Points to note: a Recursive option (here --json) is available on the command it is added to and on every subcommand beneath it; Required = true turns a missing option into a parse error; and the Errors
check runs before Invoke so the exit code follows the contract. RootCommand supplies --help, --version and a suggest directive by default. Check the option-constructor and property names against the
System.CommandLine release you install, because the 2.0 API differs from the long-running beta releases that older articles describe. Reference:
System.CommandLine overview and
System.CommandLine tutorial.
.NET: Spectre.Console and Spectre.Console.Cli
Spectre.Console renders rich output (tables, charts, progress displays, prompts, status spinners) through AnsiConsole, and exposes capability information such as
Profile.Capabilities.Interactive so a program can tell an interactive terminal from redirected output. Direct its writer to stderr for commentary, and keep data on stdout.
Everything in TTY, Colour & Interactivity applies: turn animation and prompts off when the output is not a terminal.
Spectre.Console.Cli is a separate package that builds typed commands on top of it. Each command derives from Command<TSettings> (or AsyncCommand<TSettings>),
its settings class derives from CommandSettings and declares inputs with [CommandArgument] and [CommandOption], and the Execute method returns an int exit code. A CommandApp wires it together and
supports dependency injection through Microsoft.Extensions.DependencyInjection.
using System.ComponentModel;
using Spectre.Console.Cli;
public sealed class SearchSettings : CommandSettings
{
[CommandArgument(0, "<QUERY>")]
[Description("Words to look for.")]
public string Query { get; init; } = "";
[CommandOption("--component <NAME>")]
[Description("Restrict to one component.")]
public string? Component { get; init; }
}
public sealed class SearchCommand : Command<SearchSettings>
{
protected override int Execute(CommandContext context, SearchSettings settings, CancellationToken cancellationToken)
{
Console.Out.WriteLine($"0 result(s) for {settings.Query}"); // data on stdout
return 0;
}
}
Reference: Spectre.Console. If you choose Spectre.Console.Cli, test what exit code a usage error produces and, where it differs from the contract’s 2, map it in one place, as for the other frameworks on these pages.
Distribution: .NET global tools
The other channels (and signing and checksums) are on Packaging & Distribution.
A .NET tool is a NuGet package containing a console application, installed with dotnet tool install. Turning a project into one takes two properties in the project file, and dotnet pack builds the package:
<PropertyGroup>
<PackAsTool>true</PackAsTool>
<ToolCommandName>docsctl</ToolCommandName>
<PackageOutputPath>./nupkg</PackageOutputPath>
</PropertyGroup>
dotnet tool install --global docsctl # per user, on the PATH
dotnet tool install docsctl --tool-path ~/bin # custom location, not added to PATH automatically
dotnet new tool-manifest && dotnet tool install docsctl # local tool, pinned per repository
Global tools live in $HOME/.dotnet/tools (Linux and macOS) or %USERPROFILE%\.dotnet\tools (Windows) and are available to the installing user only. A local tool is recorded in .config/dotnet-tools.json, and dotnet tool restore reinstalls
every tool it lists, which pins a tool version per repository — a good fit for a repository an agent works in. The Microsoft documentation warns that tools run in full trust, so install only what you trust,
and that the manifest determines what dotnet tool run executes, so keep it in a controlled location. Reference:
.NET tools and
Create a .NET tool.