Amuru means "command" in Swahili
TimeWarp.Amuru is a powerful fluent API library for elegant command-line execution in C#. It transforms shell scripting into a type-safe, IntelliSense-friendly experience with a simple static Builder() method, async operations, and proper error handling.
Designed for modern C# developers, TimeWarp.Amuru brings the power of shell scripting directly into your C# code. Whether you're building automation tools, DevOps scripts, or integrating command-line tools into your applications, TimeWarp.Amuru provides the elegant, type-safe API you need.
- Zero Learning Curve: If you know C#, you already know how to use TimeWarp.Amuru
- IntelliSense Everything: Full IDE support with autocomplete, parameter hints, and documentation
- Type Safety: Catch errors at compile-time, not runtime
- No String Escaping Hell: Use C# arrays and parameters naturally
- Built for .NET 10: Modern C# features and performance optimizations
- Script or Library: Use it in quick scripts or production applications
If you find this project useful, please give it a star. Thanks!
# Core library for shell scripting and process execution
dotnet add package TimeWarp.AmuruOr reference in your C# runfile:
#:package TimeWarp.Amuru@<latest-version># Global CLI tool with additional utilities (private package)
dotnet tool install --global TimeWarp.Ganda --source https://nuget.pkg.github.com/TimeWarpEngineering/index.jsonThe CLI tool includes various utilities like timestamp conversion, color generation, and more. See the Ganda repository for details.
#!/usr/bin/dotnet --
#:package TimeWarp.Amuru
using TimeWarp.Amuru;
using static System.Console;
// Default behavior - stream to console (like bash/PowerShell)
await Shell.Builder("npm", "install").RunAsync();
// Capture output when needed
var result = await Shell.Builder("git", "status").CaptureAsync();
if (result.Success)
{
WriteLine($"Git says: {result.Stdout}");
}
// Stream large files without memory issues
await foreach (var line in Shell.Builder("tail", "-f", "/var/log/app.log").StreamStdoutAsync())
{
WriteLine($"Log: {line}");
}
// Chain commands with pipelines
var result = await Shell.Builder("find", ".", "-name", "*.cs")
.Pipe("grep", "async")
.CaptureAsync();
WriteLine($"Found {result.Lines.Length} async files");
// Work with CommandOutput
var output = await Shell.Builder("docker", "ps").CaptureAsync();
WriteLine($"Exit code: {output.ExitCode}");
WriteLine($"Success: {output.Success}");
WriteLine($"Stdout: {output.Stdout}");
WriteLine($"Stderr: {output.Stderr}");
WriteLine($"Combined: {output.Combined}");
// Use the fluent builder API for complex commands
var result = await Shell.Builder("git")
.WithArguments("log", "--oneline", "-n", "10")
.WithWorkingDirectory("/my/repo")
.WithCancellationToken(cancellationToken)
.CaptureAsync();
// Provide standard input to commands
var grepResult = await Shell.Builder("grep")
.WithArguments("pattern")
.WithStandardInput("line1\nline2 with pattern\nline3")
.CaptureAsync();
// Interactive selection with Fzf
var selectedFile = await Fzf.Builder()
.FromInput("file1.txt", "file2.txt", "file3.txt")
.WithPreview("cat {}")
.SelectAsync();
// Interactive pipeline - find and select files
var chosenFile = await Shell.Builder("find")
.WithArguments(".", "-name", "*.cs")
.Pipe("fzf", "--preview", "head -20 {}")
.SelectAsync();
// Full interactive mode for stream-based tools (fzf, REPLs)
await Shell.Builder("fzf")
.PassthroughAsync();
// TUI applications (vim, nano, edit) need true TTY passthrough
await Shell.Builder("vim")
.WithArguments("myfile.txt")
.TtyPassthroughAsync();// Global dotnet options
var sdks = await DotNet.WithListSdks().CaptureAsync();
var runtimes = await DotNet.WithListRuntimes().CaptureAsync();
var version = await DotNet.WithVersion().CaptureAsync();
// Base builder for custom arguments
var result = await DotNet.Builder()
.WithArguments("--list-sdks")
.CaptureAsync();
// Build and test with streaming output
await DotNet.Build()
.WithConfiguration("Release")
.RunAsync();
await DotNet.Test()
.WithFilter("Category=Unit")
.RunAsync();The When() extension method allows you to apply configuration conditionally without breaking the fluent chain:
// Without When() - breaks the fluent chain
DotNetAddPackageBuilder builder = DotNet.AddPackage(packageName);
if (version != null)
{
builder = builder.WithVersion(version);
}
else
{
builder = builder.WithPrerelease();
}
await builder.CaptureAsync();
// With When() - keeps the fluent chain intact
await DotNet.AddPackage(packageName)
.WithProject(projectFile)
.When(version != null, b => b.WithVersion(version!))
.When(version == null, b => b.WithPrerelease())
.CaptureAsync();
// Works with all builders
await Shell.Builder("git")
.WithArguments("push")
.When(force, b => b.WithArguments("--force"))
.When(dryRun, b => b.WithArguments("--dry-run"))
.When(workDir != null, b => b.WithWorkingDirectory(workDir!))
.RunAsync();All extension methods work on any builder that implements ICommandBuilder<T>:
When(condition, configure) - Apply configuration when condition is true
.When(version != null, b => b.WithVersion(version!))WhenNotNull(value, configure) - Apply configuration when value is not null, passing the value
.WhenNotNull(version, (b, v) => b.WithVersion(v)) // Cleaner than When!Unless(condition, configure) - Apply configuration when condition is false
.Unless(isProduction, b => b.WithVerbose())Apply(configure) - Extract and reuse configuration logic
static DotNetBuildBuilder AddProductionSettings(DotNetBuildBuilder b) =>
b.WithConfiguration("Release").WithNoRestore();
await DotNet.Build()
.Apply(AddProductionSettings)
.RunAsync();ForEach(items, configure) - Apply configuration for each item
.ForEach(sources, (b, source) => b.WithSource(source))Tap(action) - Side effects without modifying the builder (logging, debugging)
.Tap(b => Console.WriteLine($"Building with config: {b}"))These extensions:
- Maintain type safety and IntelliSense support
- Keep method chains fluent and readable
- Work with all command builders (Shell, DotNet, Fzf, etc.)
- Enable functional programming patterns
- Shell-Like Default:
RunAsync()streams to console just like bash/PowerShell - Explicit Capture:
CaptureAsync()for when you need to process output - Memory-Efficient Streaming:
IAsyncEnumerablefor large data without buffering - Complete Output Access: CommandOutput with Stdout, Stderr, Combined, and ExitCode
- Fluent Interface: Chain operations naturally with
.Pipe()and builder methods - Conditional Configuration:
When()extension for fluent conditional logic - Async-First Design: All operations support modern async/await patterns
- Smart Error Handling: Commands throw on errors by default, with opt-in graceful degradation
- Pipeline Support: Chain commands with Unix-like pipe semantics
- Standard Input Support: Provide stdin to commands with
.WithStandardInput() - NO CACHING Philosophy: Like shells, commands run fresh every time
- Configuration Options: Working directory, environment variables, and more
- Cancellation Support: Full CancellationToken support for timeouts and manual cancellation
- Cross-Platform: Works on Windows, Linux, and macOS
- Command Builders: Fluent builders for complex commands (DotNet, Fzf, Ghq, Gwq)
- Interactive Commands:
PassthroughAsync()for stream-based tools,TtyPassthroughAsync()for TUI apps (vim, nano),SelectAsync()for selection tools - .NET 10 Script Support: AppContext extensions and ScriptContext for file-based apps
TimeWarp.Amuru provides clear, purpose-built methods for different scenarios:
// RunAsync() - Default shell behavior, streams to console
await Shell.Builder("npm", "install").RunAsync();
// Returns: exit code (int)
// Console output: real-time streaming
// CaptureAsync() - Silent execution with full output capture
var result = await Shell.Builder("git", "status").CaptureAsync();
// Returns: CommandOutput with all streams
// Console output: none (silent)
// PassthroughAsync() - Stream-based interactive tools (fzf, REPLs)
await Shell.Builder("fzf").PassthroughAsync();
// Returns: ExecutionResult
// Console output: piped through Console streams
// TtyPassthroughAsync() - True TTY for TUI applications (vim, nano, edit)
await Shell.Builder("vim", "file.txt").TtyPassthroughAsync();
// Returns: ExecutionResult
// Console output: inherits parent TTY (required for TUI apps)
// SelectAsync() - Selection tools (shows UI, captures selection)
var selected = await Fzf.Builder()
.FromInput("option1", "option2")
.SelectAsync();
// Returns: selected string
// Console output: UI on stderr, selection captured from stdoutvar output = await Shell.Builder("docker", "ps").CaptureAsync();
// Access individual streams
Console.WriteLine($"Stdout: {output.Stdout}");
Console.WriteLine($"Stderr: {output.Stderr}");
Console.WriteLine($"Combined: {output.Combined}"); // Both in chronological order
// Check status
Console.WriteLine($"Exit code: {output.ExitCode}");
Console.WriteLine($"Success: {output.Success}"); // ExitCode == 0
// Convenience properties for line processing
foreach (var line in output.Lines) // Combined.Split('\n')
{
ProcessLine(line);
}For commands that produce large amounts of data:
// Stream lines as they arrive (no buffering)
await foreach (var line in Shell.Builder("tail", "-f", "/var/log/app.log")
.StreamStdoutAsync(cancellationToken))
{
Console.WriteLine($"Log: {line}");
}
// Stream with LINQ-style processing
var errorLines = Shell.Builder("cat", "huge.log")
.StreamStdoutAsync()
.Where(line => line.Contains("ERROR"))
.Take(100);
await foreach (var error in errorLines)
{
LogError(error);
}| Method | Console Output | Captures | Returns | Primary Use Case |
|---|---|---|---|---|
RunAsync() |
✅ Real-time | ❌ | Exit code | Default scripting (80%) |
CaptureAsync() |
❌ Silent | ✅ All streams | CommandOutput | Process output (15%) |
PassthroughAsync() |
✅ Piped | ❌ | ExecutionResult | Stream-based interactive (3%) |
TtyPassthroughAsync() |
✅ TTY | ❌ | ExecutionResult | TUI apps (vim, nano) (1%) |
SelectAsync() |
✅ UI only | ✅ Selection | string | Selection tools (1%) |
StreamStdoutAsync() |
❌ | ✅ As stream | IAsyncEnumerable | Large data |
TimeWarp.Amuru intentionally does NOT cache command results:
// Shells don't cache - neither do we
await Shell.Builder("date").RunAsync(); // Shows current time
await Shell.Builder("date").RunAsync(); // Shows NEW current time
// If you need caching, it's trivial in C#:
private static CommandOutput? cachedResult;
var result = cachedResult ??= await Shell.Builder("expensive-command").CaptureAsync();Why no caching?
- Commands can have side effects
- Results change over time
- Shells don't cache
- Users can trivially cache in C# if needed
TimeWarp.Amuru provides intelligent error handling that distinguishes between different failure types:
// Throws CommandExecutionException on non-zero exit code
await Shell.Builder("ls", "/nonexistent").RunAsync();
// CaptureAsync also throws on failure by default
var result = await Shell.Builder("git", "invalid-command").CaptureAsync();// Disable validation for graceful degradation
var result = await Shell.Builder("ls", "/nonexistent")
.WithValidation(CommandResultValidation.None)
.CaptureAsync();
if (!result.Success)
{
Console.WriteLine($"Command failed with exit code: {result.ExitCode}");
Console.WriteLine($"Error: {result.Stderr}");
}// With explicit cancellation token
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await Shell.Builder("long-running-command")
.RunAsync(cts.Token);
// With timeout via builder
await Shell.Builder("slow-command")
.WithTimeout(TimeSpan.FromSeconds(10))
.RunAsync();
// Timeout and external token are combined
await Shell.Builder("another-command")
.WithTimeout(TimeSpan.FromSeconds(5))
.RunAsync(userCancellationToken);TimeWarp.Amuru provides built-in support for mocking commands during testing through the CliConfiguration class:
// Set up mock commands for testing
CliConfiguration.SetCommandPath("fzf", "/path/to/mock/fzf");
CliConfiguration.SetCommandPath("git", "/path/to/mock/git");
// Your code using these commands will now use the mocks
var selected = await Fzf.Builder()
.FromInput("option1", "option2", "option3")
.SelectAsync(); // Uses mock fzf
var status = await Shell.Builder("git", "status")
.CaptureAsync(); // Uses mock git
// Clean up after tests
CliConfiguration.Reset();// Create a simple mock script
File.WriteAllText("/tmp/mock-fzf", "#!/bin/bash\necho 'mock-selection'");
await Shell.Builder("chmod", "+x", "/tmp/mock-fzf").RunAsync();
// Configure TimeWarp.Amuru to use it
CliConfiguration.SetCommandPath("fzf", "/tmp/mock-fzf");
// Now SelectAsync will use the mock
var selected = await Fzf.Builder()
.FromInput("a", "b", "c")
.SelectAsync(); // Returns "mock-selection"For commands like fzf that are normally interactive, you can either:
- Use mock executables as shown above
- Use non-interactive modes (e.g.,
fzf --filter)
CliConfiguration.SetCommandPath(command, path)- Set custom executable pathCliConfiguration.ClearCommandPath(command)- Remove custom path for a commandCliConfiguration.Reset()- Clear all custom pathsCliConfiguration.HasCustomPath(command)- Check if command has custom pathCliConfiguration.AllCommandPaths- Get all configured paths
TimeWarp.Amuru provides specialized support for .NET 10's new file-based apps (single-file C# scripts) with AppContext extensions and ScriptContext for directory management.
- AppContext Extensions - Clean access to script metadata without magic strings
- ScriptContext - Automatic working directory management with cleanup guarantees
- ProcessExit Handling - Cleanup runs even with
Environment.Exit()
📖 See the documentation for detailed usage guides and examples.
TimeWarp.Amuru is built on several key architectural principles:
- Static Entry Point: Minimal ceremony with global
Builder()method - Immutable Design: Thread-safe, readonly objects throughout
- Integration Testing: Real command validation over mocking
- Predictable Error Handling: Clear distinction between failure types
- Opt-in Complexity: Advanced features available when needed
See our Architectural Decision Records for detailed design rationale.
- Migration Guide - Guide for migrating from older versions
- CommandExtensions.md - Static API documentation
- CommandResult.md - Fluent interface documentation
- Architectural Decisions - Design rationale and decisions
See Spikes/CsScripts/ for example scripts demonstrating TimeWarp.Amuru usage patterns.
This project is licensed under the Unlicense.
- TimeWarp.Multiavatar - Avatar generation library (see documentation)
- TimeWarp.Ganda - Shell toolkit CLI (private, moved to separate repository)
- TimeWarp.Zana - Private utilities library (in timewarp-ganda repo)
Your contributions are welcome! Before starting any work, please open a discussion.
See our Kanban board for current development tasks and priorities.
If you have an issue and don't receive a timely response, feel free to reach out on our Discord server.