fiber_shell
Typed shell command builder for Dart and Flutter desktop: CLI tools wrapped as classes, pipes and chaining run in Dart.
Running a system command from Dart usually ends up as string concatenation
handed to sh -c: you escape every argument by hand, you lose autocompletion,
and Windows is off the table.
This package takes the other route. Every tool is a class where one method is one option, and the chain stays typed end to end:
import 'package:fiber_shell/fiber_shell.dart';
await Rm.recursive().force().path('/tmp/build').execute();
final ShellResult result = await (Curl.silent().url(endpoint) | Jq.rawOutput().filter('.version')).output();
if (result.failed) print(result.error);
Shell operators are reimplemented in Dart rather than delegated to an
interpreter: | wires the streams between processes, and &&, ||, ;
become and(), or(), then(). No argument is ever interpolated into a
string, so there is nothing to escape and no injection surface.
Pure Dart, zero external dependencies. Works from a Dart CLI and from a Flutter desktop app on Linux, macOS and Windows.
Install
dependencies:
fiber_shell: ^1.0.0
import 'package:fiber_shell/fiber_shell.dart';
One import gives you every wrapper, the runners and the result type.
The shape of it
A command is built, then run. Building never touches the machine.
final MkdirCmd command = Mkdir.parents().path('build/artifacts');
command.line; // 'mkdir -p build/artifacts', rendered but not run
await command.execute();
Mkdir is a getter that hands back a fresh builder, and every option returns
that same builder, so a chain collects arguments and stops there.
Four ways to run
| Call | What it does |
|---|---|
execute() |
Runs with stdio inherited, so output appears live. Throws ShellException on a non-zero status. |
output() |
Captures both streams and returns a ShellResult. Never throws on a non-zero status. |
writeTo(File) |
Sends stdout straight into a file, the way > does. |
background() |
Starts the job and returns a BackgroundJob you can wait() on or kill(). |
All four take cwd and env; output() also takes input for stdin.
await Find.path('lib').type('f').name('*.dart').writeTo(File('inventory.txt'));
final ShellResult framed = await Sed.expression('s/.*/[&]/').output(input: 'alpha\n');
final ShellResult scoped = await Git.status().porcelain().output(
cwd: '/some/repo',
env: <String, String>{'GIT_TERMINAL_PROMPT': '0'},
);
A failure is a value
output() returns a ShellResult whatever happened. A command that fails is
something to inspect, not something to catch:
final ShellResult result = await Git.status().porcelain().output();
result.success; // exit code was zero
result.failed; // it was not
result.exitCode;
result.text; // stdout, trailing newline dropped
result.lines; // the non-blank lines, in order
result.error; // stderr, trimmed
result.bytes; // stdout undecoded: `openssl pkey -outform DER` writes a key, not a string
result.duration;
result.textOrNull; // the text, or null if it failed or printed nothing
result.orThrow(); // the text, or a ShellException carrying the stderr
Output is kept as bytes and decoded on demand, so the same type carries text and binary alike.
Pipes
| starts every stage at once and wires each stdout into the next stdin.
final ShellResult errors = await (Grep.pattern('ERROR').file('server.log') |
Sed.expression('s/^[0-9:]* ERROR //') |
Grep.invertMatch().pattern('timeout'))
.output();
The status is the one set -o pipefail gives: the rightmost stage that failed,
or zero when they all succeeded. Plain shell semantics, where only the last
stage counts, hide a broken first command behind a grep that happily matched
nothing.
Chaining
Dart cannot overload && and ||: they short-circuit, which makes them syntax
rather than operators. So they are named instead:
await Mkdir.parents().path(release).and(Cp.recursive().source(src).destination(release)).execute();
await Grep.quiet().pattern('token').file(config)
.or(Grep.quiet().pattern('token').file(fallback))
.output();
await Rm.path(stale).then(Mkdir.parents().path(fresh)).execute();
A chain is deliberately not a pipe stage: a shell needs a subshell to pipe out of one, and this package will not pretend otherwise. Pipe the stages, chain the outcomes.
Elevation
asRoot() marks the command rather than the run, so it survives being piped or
chained. Only the stage that needs the rights takes them.
await Systemctl.restart().unit('nginx').asRoot().execute();
await (Grep.pattern('Failed password').file('/var/log/auth.log').asRoot() | Grep.count().pattern('.')).output();
It adds sudo on Linux and leaves the command untouched elsewhere, because
that is where sudo is the answer.
Rendering without running
line renders a command, a pipeline or a whole chain as a terminal would show
it, which is all a --dry-run flag has to be:
Ufw.allow().arg('443/tcp').asRoot().and(Ufw.reload().asRoot()).line;
// 'sudo ufw allow 443/tcp && sudo ufw reload'
One caveat: it joins the executable and its arguments with spaces, for a human or a log file to read. It is not shell-quoted, so an argument containing a space renders as two words. Read it, log it, show it behind a flag, but do not build a shell command out of it.
The catalogue
Wrappers are grouped by where the tool exists. It grows as needs come up.
| Group | Tools |
|---|---|
common/ |
curl, deno, direnv, docker, docker compose, git, jq, npm, openssl, pg_dump, pg_restore, psql, python3, scp, ssh, ssh-keygen, tar |
unix/ |
awk, bash, chmod, chown, cp, du, find, grep, install, kill, ln, lsof, mkdir, ps, rm, sed, sh, tee, xargs |
linux/ |
apt-get, ip, iptables, journalctl, secret-tool, ss, sysctl, systemctl, ufw, usermod, wg |
macos/ |
caffeinate, defaults, diskutil, dscl, launchctl, networksetup, plutil, pmset, scutil, security, softwareupdate |
windows/ |
cmd, icacls, netsh, powershell, reg, robocopy, sc, schtasks, taskkill, tasklist |
Every wrapper carries its own documentation: the traps of the tool, which
platform it exists on, and which options bite in automation. Read the class
comment before reaching for a flag: security, dscl, netsh and launchctl
in particular have surprises worth knowing about first.
Two helpers for the tools that may not be there at all:
if (await commandExists('docker')) { ... }
await waitUntil(<String>['pg_isready', '-h', 'localhost'], interval: 1, timeout: 30);
Writing your own wrapper
The catalogue will never cover everything, and a wrapper is about twenty lines. A class names itself as its own type argument, which is what keeps a chain typed to the end:
class WcCmd extends CommandBuilder<WcCmd> {
@override
final String executable = 'wc';
/// Counts lines (`-l`).
WcCmd lines() => token('-l');
/// Adds a path to count.
WcCmd file(String path) => token(path);
}
/// `wc`, ready to take its first option.
// ignore: non_constant_identifier_names
WcCmd get Wc => WcCmd();
That is all. Pipes, chaining, elevation, background jobs and line come from
the base class:
final ShellResult count = await (Grep.pattern('ERROR').file(log) | Wc.lines()).output();
Four helpers cover the argument shapes tools use:
| Helper | Emits |
|---|---|
token('-l') |
a bare flag, or a subcommand, or a path |
pair('-C', path) |
two separate arguments |
joined('--max-count', '5') |
--max-count=5 |
joinedAll('--allow-read', paths) |
--allow-read=a,b |
One thing to watch when naming options: execute, output, writeTo, line,
asRoot, stages, token, pair, joined and joinedAll belong to the base
class. Colliding with one is an invalid override, so it fails to compile rather
than misbehaving at runtime, which is how curl's -o ended up as
outputFile() here.
Flutter desktop
The package is pure Dart and uses dart:io, so it works in a Flutter desktop
app as it does in a CLI. Two things to know:
macOS sandboxing. A Flutter macOS app is sandboxed by default, and a
sandboxed process cannot spawn arbitrary binaries. Process will fail on most
of them until com.apple.security.app-sandbox is removed from the
.entitlements files, which also means giving up Mac App Store distribution.
Keep it off the UI isolate for long jobs. execute() and output() are
async and do not block, but a job that produces a lot of output is still work on
the isolate that draws your frames. Reach for background(), or an isolate, when
that matters.
There is nothing here for Flutter web or mobile: dart:io process spawning does
not exist on the web, and neither Android nor iOS lets an app run arbitrary
binaries.
Example
example/ is a runnable tour in eight chapters, each a single file:
cd example
dart pub get
dart run lib/example.dart # the whole tour
dart run lib/example.dart 3 8 # just piping and custom wrappers
It runs in a throwaway temp directory, elevates nothing for real, and asks for no password. See example/README.md for what each chapter covers.
Tests
dart test
The suite covers the builders, ShellResult, the runners, pipelines, chaining
and background jobs. Chaining is tested with mockito: whether or() actually
skips its right-hand side is an assertion (verifyNever) rather than a guess at
what a second process left behind.
test/catalogue_test.dart reads the sources and guards the structural rules:
that every wrapper is exported from the barrel, names itself as its own type
argument, declares a usable executable, and offers a getter facade. It is what
catches a wrapper added without its export line, which compiles fine and
leaves the tool invisible to everyone importing the package.
Mocks are generated and committed, so dart test runs without a build step.
After changing a @GenerateNiceMocks annotation:
dart run build_runner build
Contributing a wrapper
- Add
lib/src/commands/<group>/<tool>.dart, following the shape above. - Document the class: what the tool is, where it exists, and what bites in
automation. That comment is the reason the wrapper is worth more than a raw
Process.start. - Add the
exportline tolib/fiber_shell.dart, in alphabetical order. - Run
dart test.catalogue_test.dartfails if step 3 was missed.