Task-shaped, not flag-shaped
psav shot demo.mp4 --at 1m30s — a verb and a want, not a filter graph.
Most CLI tools are excellent and unmemorable. You know ffmpeg can take a screenshot; you do not
know, today, whether -ss goes before or after -i, and you will look it up again next month.
This article builds the fix from scratch: a wrapper that speaks in tasks (“give me a screenshot
at 1:30”) instead of flags, and returns objects instead of text. The worked example is
psav — the ffmpeg wrapper ps-bash ships — but nothing here is about
ffmpeg. Substitute docker, kubectl, terraform, openssl, or whatever you look up every month.
Task-shaped, not flag-shaped
psav shot demo.mp4 --at 1m30s — a verb and a want, not a filter graph.
Objects out, not text
Every result is a typed object with a class, so it filters, sorts, and styles.
Plan, then run
Building the command and executing it are separate steps. That one split buys you --dry-run,
exact tests, and honest error messages.
Escape hatch
psav raw -- … passes through. Wrapping a tool must never take it away.
Before any code, write the command lines you wish existed. For a media tool the honest list was short:
psav probe FILE # what is this?psav shot FILE --at 1:30 # a screenshotpsav gif FILE --dur 6 # a demo GIFpsav clip FILE --from 1:05 --to 1:30psav record --seconds 20Five verbs cover the work; everything else is raw. Resist adding a subcommand for a flag you have
used once. The wrapper’s value is the subset, not the coverage.
Here is the trap that shapes the whole design. A ps-bash cmdlet collects its arguments with:
[Parameter(ValueFromRemainingArguments = true)]public string[]? Arguments { get; set; }and PowerShell’s binder gets first look at every token. A single-dash token is a parameter name:
-i collides with nothing and hard-crashes the binder as an ambiguous parameter,-o prefix-matches the common parameters -OutVariable / -OutBuffer and is silently
swallowed,-c, -d, -v behave the same way.A double-dash token is parsed as the parameter name -out — with the dash still in it. No real
parameter name contains a dash, so it matches nothing and lands in Arguments intact.
Rule: a wrapper cmdlet speaks long flags.
--at,--out,--fps. Not-i,-t,-o.
The parser itself is ordinary (Media/AvOptions.cs): first non-option token is the subcommand,
--name value and --name=value both work, a known switch never consumes the next token, and a
literal -- sends the rest to passthrough.
public static AvOptions Parse(IReadOnlyList<string> args){ var subcommand = args.Count > 0 && !args[0].StartsWith('-') ? args[0].ToLowerInvariant() : ""; // …then: "--" → passthrough, "--name=value" → split, switch → flag, else consume next token}Two decisions worth copying:
Switches are declared, not inferred. --dry-run clip.mp4 must not eat clip.mp4 as the value
of --dry-run. A HashSet of switch names is all it takes, and it is the difference between a flag
that works and one that silently steals your filename.
A value flag with no value is an error, not a shrug. --fps at the end of the line sets
UnknownOption, and the cmdlet fails with exit 2. Silently defaulting to 12 fps would be worse than
crashing.
This is the step that makes everything else possible.
A plan is a small record: the tool, the exact argv, what it will produce, and how long it may take.
internal sealed record AvPlan( string Tool, IReadOnlyList<string> Arguments, string Kind, string? OutputPath = null, double? At = null, TimeSpan? Timeout = null){ public string CommandLine => Tool + " " + string.Join(" ", Arguments.Select(Quote));}And every subcommand is a pure function from options to a plan. No process, no filesystem:
public static AvPlan Shot(string input, double at, string output, int width = 0){ var args = new List<string>(Common) { "-ss", AvTime.Format(at), "-i", input }; AddVideoFilter(args, ScaleFilter(width, evenHeight: false)); args.Add("-frames:v"); args.Add("1"); args.Add(output); return new AvPlan("ffmpeg", args, "screenshot", output, at);}You get three things for the price of one split:
--dry-run for free. Print plan.CommandLine and return. It is the same command, not a
reconstruction, so it cannot drift from what runs.FfmpegPlan.Shot(…) is a function call. The entire
ffmpeg-knowledge surface is assertable on a machine that has never had ffmpeg on it.Users type 90s, 1:30, 00:01:30.000, and 1m30s and mean the same thing. Tools accept a subset.
Put the conversion in one pure helper (Media/AvTime.cs) and normalise once, at the edge:
AvTime.TryParse("1m30s", out var seconds); // 90AvTime.Format(90); // "00:01:30.000" ← the only shape ffmpeg ever seesThen make it defensive where it matters: Format(-4) returns 00:00:00.000, because a negative
seek is an argv the tool rejects outright and there is no useful behaviour on the other side of that
error.
A wrapper that prints text has thrown away the thing PowerShell is good at. Every result gets a
type name, real properties, a class for styling, and a BashText line so it still reads like a
native tool in a pipe:
var o = new PSObject();o.TypeNames.Insert(0, "PsBash.MediaInfo");o.Properties.Add(new PSNoteProperty("Duration", 12.5));o.Properties.Add(new PSNoteProperty("Width", 1920));o.Properties.Add(new PSNoteProperty("class", "video")); // the styling hooko.Properties.Add(new PSNoteProperty("BashText", "demo.mp4 12.5s h264 1920x1080 1 MB"));SetColumns(o, "Name", "Length", "Video", "Width", "Height", "Fps", "SizeText");SetColumns declares a DefaultDisplayPropertySet, which is what stops a fifteen-property object
from vomiting fifteen columns at the terminal. Pick the six a person actually wants.
Most tools have a machine-readable mode hiding behind a flag. Find it before you write a parser:
ffprobe -print_format json, docker inspect --format '{{json .}}', kubectl -o json,
git status --porcelain. Then parse it in a pure function against captured fixtures:
public static MediaSummary? Parse(string path, string? json) { … }Two habits worth stealing from Media/FfprobeReport.cs:
null, never a row of zeroes. Unparseable input is a real error the caller should
report, not a media file with duration 0 that quietly poisons a Measure-Object.30000/1001. Parsing it as an int gives 30; dividing gives the true 29.97. Whatever your tool
does with rationals, durations, or “N/A”, handle it once, here.The host runspace is single-threaded. A child that hangs — or that fills a pipe buffer while you drain only one stream — wedges it forever. ps-bash has exactly one blessed spawn:
BashRuntime.RunChildProcess(startInfo, plan.Timeout);which drains stdout and stderr concurrently, bounds the wait, and on timeout kills the
entire process tree and returns exit 124. Never hand-roll Process.Start for a buffered
command.
The one place a wrapper must think about this is a subcommand that legitimately runs long:
// A screen grab runs for its full wall-clock duration; give the spawn budget real headroom// on top of it or the kill-tree timeout truncates the recording.return new AvPlan("ffmpeg", args, "recording", output, Timeout: TimeSpan.FromSeconds(seconds + 60));And the one error worth special-casing is a missing binary, because it is the only failure with an obvious fix:
catch (System.ComponentModel.Win32Exception){ Fail($"psav: {plan.Tool} is not on PATH. Install it: " + "winget install Gyan.FFmpeg | brew install ffmpeg | apt install ffmpeg", 127);}Here is the payoff of Step 3. These tests are fast, deterministic, and pass on a machine with no ffmpeg installed — including in CI:
[Fact]public void Shot_SeeksBeforeTheInputSoAGrabIsFastNotAFullDecode(){ var plan = FfmpegPlan.Shot("in.mp4", 5, "out.png");
var ss = plan.Arguments.ToList().IndexOf("-ss"); var i = plan.Arguments.ToList().IndexOf("-i"); Assert.True(ss >= 0 && i > ss, "the seek must precede -i (input seeking)");}
[Fact]public void Clip_ProducesABrowserPlayableMp4(){ var plan = FfmpegPlan.Clip("in.mov", null, 10, 1280, 22, mute: true, "out.mp4");
Assert.Contains("yuv420p", plan.Arguments); // plays in a browser, not just VLC Assert.Contains("+faststart", plan.Arguments); Assert.Equal("scale=1280:-2", …); // libx264 rejects an odd height}Each one encodes a reason, not a string. If someone “simplifies” -2 back to -1, the test that
fails tells them why it was -2.
Then test the cmdlet end to end through --dry-run, which still spawns nothing:
var rows = Run($"Invoke-BashFfmpeg gif '{file}' --from 2 --dur 5 --fps 20 --dry-run");Assert.Equal("planned", Prop(rows[0], "Status"));Assert.Contains("palettegen", Prop(rows[0], "Command"));That single test proves the option parser, the plan builder, the object projection, and that
PowerShell’s binder let --dry-run through — the one risk that no unit test can cover.
Six edits, all mechanical:
The cmdlet — src/PsBash.Cmdlets/InvokeBashFfmpegCommand.cs, [Cmdlet(VerbsLifecycle.Invoke, "BashFfmpeg")].
Export it — add 'Invoke-BashFfmpeg' to CmdletsToExport in PsBash.Cmdlets.psd1.
Alias it — in PsBash.psm1, next to the other non-bash tools:
Set-Alias -Name 'psav' -Value 'Invoke-BashFfmpeg' -Force -Scope Global -Option AllScopeDeclare the alias — add 'psav' to AliasesToExport in PsBash.psd1.
Do not touch the emitter. psav is not a bash command, so it needs no entry in
PsEmitter.TryEmitMappedCommand. Unmapped names pass through and the alias resolves them. Only
real coreutils get mapped.
Document it — a page under docs/src/content/docs/commands/ and a line in CODE_MAP.md.
Anything you wrap, in any language, should end up with:
raw passthrough.--dry-run printing that exact plan.class and a curated column set.Process.Start.null on unparseable input, never a fabricated row.The wrapper emits objects with a class. That is already everything the styling engine needs —
Part 2 turns it into colour and an interactive gallery in about eighty lines:
Part 2 — Style it and make it interactive