Skip to content

TimeWarpEngineering/timewarp-amuru

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

750 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Stars workflow Forks License Issues Open OpenSSF Scorecard

nuget nuget

Twitter Dotnet

Discord Twitter Twitter

logo

TimeWarp.Amuru

Amuru means "command" in Swahili

TimeWarp.Amuru is a 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 shell-like 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.

Why TimeWarp.Amuru?

  • 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
  • Native AOT Ready: Both packages declare and validate AOT/trimming compatibility
  • Built for .NET 10: Modern C# features and first-class file-based app (runfile) support
  • Script or Library: Use it in quick scripts or production applications

Give a Star! ⭐

If you find this project useful, please give it a star. Thanks!

Installation

# Core library: process execution, mocking, native file operations
dotnet add package TimeWarp.Amuru

# Optional: fluent builders for dotnet/git/fzf plus repo services
dotnet add package TimeWarp.Amuru.Tools --prerelease

Or reference in your C# runfile:

#:package TimeWarp.Amuru@<latest-version>

Both packages share the TimeWarp.Amuru namespace — adding the Tools reference lights up DotNet.*, Git.*, and Fzf.* with no code changes.

Optional: CLI Tools

# Global CLI tool with additional utilities (private package)
dotnet tool install --global TimeWarp.Ganda --source https://nuget.pkg.github.com/TimeWarpEngineering/index.json

See the Ganda repository for details.

Quick Start

#!/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").WithArguments("install").RunAsync();

// Capture output when needed
CommandOutput result = await Shell.Builder("git").WithArguments("status").CaptureAsync();
if (result.Success)
{
    WriteLine($"Git says: {result.Stdout}");
}

// Stream large files without memory issues
await foreach (string line in Shell.Builder("tail").WithArguments("-f", "/var/log/app.log").StreamStdoutAsync())
{
    WriteLine($"Log: {line}");
}

// Chain commands with pipelines
CommandOutput found = await Shell.Builder("find")
    .WithArguments(".", "-name", "*.cs")
    .Pipe("grep", "async")
    .CaptureAsync();
WriteLine($"Found {found.GetLines().Length} async lines");

// Work with CommandOutput
CommandOutput output = await Shell.Builder("docker").WithArguments("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
CommandOutput log = await Shell.Builder("git")
    .WithArguments("log", "--oneline", "-n", "10")
    .WithWorkingDirectory("/my/repo")
    .CaptureAsync(cancellationToken);

// Provide standard input to commands
CommandOutput grepResult = await Shell.Builder("grep")
    .WithArguments("pattern")
    .WithStandardInput("line1\nline2 with pattern\nline3")
    .CaptureAsync();

// 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();

Tool Builders (TimeWarp.Amuru.Tools)

// Global dotnet options
CommandOutput sdks = await DotNet.WithListSdks().CaptureAsync();
CommandOutput version = await DotNet.WithVersion().CaptureAsync();

// Base builder for custom arguments
CommandOutput custom = await DotNet.Builder()
    .WithArguments("--list-runtimes")
    .CaptureAsync();

// Build and test with streaming output
await DotNet.Build()
    .WithConfiguration("Release")
    .RunAsync();

await DotNet.Test()
    .WithFilter("Category=Unit")
    .RunAsync();

// Git operations with typed results
string? repoRoot = Git.FindRoot();
string porcelain = await Git.WorktreeListPorcelainAsync("/my/repo");
IReadOnlyList<WorktreeEntry> worktrees = Git.ParseWorktreeList(porcelain);

// Interactive selection with Fzf
string selectedFile = await Fzf.Builder()
    .FromInput("file1.txt", "file2.txt", "file3.txt")
    .WithPreview("cat {}")
    .SelectAsync();

Key Features

  • Shell-Like Default: RunAsync() streams to console just like bash/PowerShell
  • Explicit Capture: CaptureAsync() for when you need to process output
  • Memory-Efficient Streaming: IAsyncEnumerable for large data without buffering
  • One Result Type: CommandOutput with Stdout, Stderr, Combined, ExitCode, Success, and RunTime — from every execution mode
  • Shell-Like Error Handling: non-zero exit codes are values, not exceptions; strict validation is one opt-in call away
  • Built-In Command Mocking: CommandMock with strict-by-default matching — tests can never silently run real commands
  • 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
  • Cancellation Support: Full CancellationToken support throughout
  • Cross-Platform: Works on Windows, Linux, and macOS (including .cs script execution on Windows via the dotnet host)
  • 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

Output Handling

Core API Methods

// RunAsync() - Default shell behavior, streams to console
await Shell.Builder("npm").WithArguments("install").RunAsync();
// Returns: exit code (int)

// CaptureAsync() - Silent execution with full output capture
CommandOutput result = await Shell.Builder("git").WithArguments("status").CaptureAsync();
// Returns: CommandOutput; no console output

// RunAndCaptureAsync() - Stream to console AND capture
CommandOutput logged = await Shell.Builder("dotnet").WithArguments("build").RunAndCaptureAsync();

// PassthroughAsync() - Stream-based interactive tools (fzf, REPLs)
CommandOutput fzfResult = await Shell.Builder("fzf").PassthroughAsync();

// TtyPassthroughAsync() - True TTY for TUI applications (vim, nano, edit)
CommandOutput vimResult = await Shell.Builder("vim").WithArguments("file.txt").TtyPassthroughAsync();

// SelectAsync() - Selection tools (shows UI on stderr, captures stdout selection)
string selected = await Shell.Builder("fzf").SelectAsync();

The CommandOutput Type

CommandOutput output = await Shell.Builder("docker").WithArguments("ps").CaptureAsync();

// Access individual streams
Console.WriteLine($"Stdout: {output.Stdout}");
Console.WriteLine($"Stderr: {output.Stderr}");
Console.WriteLine($"Combined: {output.Combined}"); // Captured in arrival order

// Check status
Console.WriteLine($"Exit code: {output.ExitCode}");
Console.WriteLine($"Success: {output.Success}"); // ExitCode == 0
Console.WriteLine($"Runtime: {output.RunTime}");

// Line processing (interior blank lines preserved; no trailing empty entry)
foreach (string line in output.GetLines())
{
    ProcessLine(line);
}

// Or line-level access with source-stream metadata
foreach (OutputLine line in output.OutputLines)
{
    Console.WriteLine($"{(line.IsError ? "ERR" : "OUT")}: {line.Text}");
}

// Pretty-print a result with status coloring
output.WriteToConsole();

Streaming Large Data

// Stream lines as they arrive (no buffering)
await foreach (string line in Shell.Builder("tail")
    .WithArguments("-f", "/var/log/app.log")
    .StreamStdoutAsync(cancellationToken))
{
    Console.WriteLine($"Log: {line}");
}

Method Comparison

Method Console Output Captures Returns Primary Use Case
RunAsync() ✅ Real-time Exit code Default scripting
CaptureAsync() ❌ Silent ✅ All streams CommandOutput Process output
RunAndCaptureAsync() ✅ Real-time ✅ All streams CommandOutput Logging + capture
PassthroughAsync() ✅ Piped CommandOutput Stream-based interactive
TtyPassthroughAsync() ✅ TTY CommandOutput TUI apps (vim, nano)
SelectAsync() ✅ UI only ✅ Selection string Selection tools
StreamStdoutAsync() ✅ As stream IAsyncEnumerable Large data

Design Philosophy: NO CACHING

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;
CommandOutput result = cachedResult ??= await Shell.Builder("expensive-command").CaptureAsync();

Error Handling

TimeWarp.Amuru handles failure the way shells do: a non-zero exit code is a value you inspect, not an exception.

Default Behavior (Never Throws on Exit Codes)

CommandOutput result = await Shell.Builder("ls").WithArguments("/nonexistent").CaptureAsync();

if (!result.Success)
{
    Console.WriteLine($"Command failed with exit code: {result.ExitCode}");
    Console.WriteLine($"Error: {result.Stderr}");
}

Strict Validation (Opt-in Throwing)

// Throws on any non-zero exit code
await Shell.Builder("git")
    .WithArguments("push")
    .WithZeroExitCodeValidation()
    .RunAsync();

Commands That Never Ran

An empty/invalid command or a failed pipeline composition never throws, but it is never mistaken for success either — it reports CommandResult.NeverRanExitCode (-1):

CommandOutput result = await Shell.Builder("").CaptureAsync();
// result.Success == false, result.ExitCode == CommandResult.NeverRanExitCode

Note: a missing executable (e.g. a typo'd command name) still throws at execution time — that is an environment error, not an exit code.

Cancellation and Timeouts

// With explicit cancellation token
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30));
await Shell.Builder("long-running-command").RunAsync(cts.Token);

Testing and Mocking

CommandMock (Recommended)

CommandMock intercepts command execution in-process — no mock executables needed. It is strict by default: a command with no matching setup throws instead of silently running the real thing.

using TimeWarp.Amuru.Testing;

using (CommandMock.Enable())
{
    CommandMock.Setup("git", "status")
        .Returns("On branch main\nnothing to commit");

    CommandOutput output = await Shell.Builder("git").WithArguments("status").CaptureAsync();
    // output.Stdout == "On branch main\nnothing to commit" — no real git ran

    CommandMock.VerifyCalled("git", "status");
}

// Simulate failures and exceptions
using (CommandMock.Enable())
{
    CommandMock.Setup("git", "push").ReturnsError("remote: Permission denied", 128);
    CommandMock.Setup("flaky-tool").Throws(new TimeoutException("simulated"));
    CommandMock.Setup("slow-tool").Delays(TimeSpan.FromSeconds(2));
    // ...
}

// Mixed mocked + real commands (opt out of strict mode)
using (CommandMock.Enable(MockBehavior.Loose))
{
    CommandMock.Setup("deploy-tool").Returns("deployed");
    // unmocked commands fall through to real execution
}

Mocking is scoped per async context (parallel tests stay isolated) and covers every execution mode — run, capture, streaming, select, and passthrough. Pipe compositions are the documented exception (use loose mode).

CliConfiguration (Path Overrides)

For cases where you want a real replacement executable, redirect a command process-wide:

CliConfiguration.SetCommandPath("fzf", "/tmp/mock-bin/fzf");
// ... code using fzf now runs the replacement ...
CliConfiguration.Reset();

API: SetCommandPath, ClearCommandPath, Reset, HasCustomPath, AllCommandPaths. Overrides are process-global by design — use CommandMock for per-test isolation.

.NET 10 File-Based App Support

TimeWarp.Amuru provides specialized support for .NET 10's file-based apps (single-file C# scripts):

  • AppContext ExtensionsAppContext.EntryPointFilePath() / EntryPointFileDirectoryPath() without magic strings
  • ScriptContext — scoped working-directory management with cleanup on dispose or process exit (contexts nest safely)
  • .cs as a commandShell.Builder("script.cs") runs another runfile (shebang on Unix, dotnet host on Windows)

📖 See the documentation for detailed usage guides and examples.

Architecture

  • Static Entry Point: Minimal ceremony with Shell.Builder() / Shell.Run()
  • Two Packages, One Namespace: TimeWarp.Amuru (stable core) and TimeWarp.Amuru.Tools (tool builders, own release cadence)
  • Shell Semantics: exit codes are values; composition never throws; nothing is cached
  • Predictable Error Handling: never-ran, non-zero-exit, and environment failures are all distinguishable
  • Opt-in Complexity: Advanced features available when needed

See our Architectural Decision Records for detailed design rationale.

Documentation

Unlicense

License
This project is licensed under the Unlicense.

Related Packages

Contributing

Your contributions are welcome! Before starting any work, please open a discussion.

See our Kanban board for current development tasks and priorities.

Contact

If you have an issue and don't receive a timely response, feel free to reach out on our Discord server.

Discord

About

No description, website, or topics provided.

Resources

License

Stars

18 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages