`git+`: Talking to Commands in Plain Language
Build a universal utility that transforms natural language tasks into precise command-line arguments.
Every serious tool has its own dictionary of flags. To undo
the last commit you must recall git reset --soft HEAD^; to
compress a video, a dozen ffmpeg keys. What if, next to your
usual command, you placed its "plus version" and spoke to it
like a human? You type git+ drop the last commit — and get
a ready git reset --soft HEAD^, with a preview and a "run
it?" prompt. Let's build such a wrapper in Rust: one binary
that knows from its own name which command it stands in for,
asks an LLM for the right parameters, and carefully runs the
real tool.
The Binary's Name Is Already the Command
The central trick of the whole idea is that our binary doesn't
know up front which command it works for. It learns that from
its own name. We build a single executable (call it
add-plus) and place symbolic aliases like git+ or ffmpeg+
next to it. When the system is asked to run git+, that very
string arrives as our first argument.
let mut args = env::args();
let alias_name = args.next()
.context("No name of the app provided")?;
env::args() yields an iterator over the command-line
arguments, and the very first of them is, by convention, the
name of the launched file. The next() method returns an
Option, since the iterator may be empty. Instead of
unwrap() we take context() from the anyhow crate: it
turns None into a meaningful error, and the error type is
already wired in through the Result from that same anyhow.
There's no reason to panic in a utility — a clear message is
better.
The name from argv can be a full path (/usr/bin/git+), so we
pull out just the file name from it.
let binary_name = Path::new(&alias_name)
.file_name()
.context("Invalid path")?
.to_str()
.context("Invalid UTF-8 in filename")?;
let real_name = binary_name.trim_end_matches('+');
Path::new parses the string as a path, file_name() strips
the directories. It returns an Option, because a path can be
empty or a root — context() again. Then to_str(): a file
name arrives as an OsStr, since file systems need not be
Unicode, and the conversion may fail. The final touch —
trim_end_matches('+') removes the plus suffix. So git+
becomes a clean git — the real command for which we'll be
choosing arguments.
Arguments as Natural Language
We pulled the command name as the first argument. Everything
the user typed after git+ is their request in words. We
gather the remaining arguments into a single string.
let natural_args = args.collect::<Vec<_>>()
.join(" ");
We already advanced the args iterator with next(), so
collect() gathers exactly the "tail" — everything but the
file name. And join(" ") glues the pieces with a space:
drop the last commit becomes the phrase drop the last commit again. The resulting String is the input for the
model. There's no flag parsing at this stage — on the
contrary, we deliberately keep the request as plain text, so
the LLM itself decides what to translate it into.
A Prompt That Keeps the Model in Check
The prompt text is the most valuable part of the project, and it's better kept in a separate file than as a string literal in code. Rust can embed a file straight into the binary at compile time.
static PROMPT: &str = include_str!("prompt.md");
include_str! is a macro: it reads prompt.md at build time
and inserts its contents as a &'static str. At runtime the
file is no longer needed — it lives inside the binary. We
build the prompt itself in layers. First we set the role and
the output format:
You are given a natural language instruction
from a user that describes how they want to
use the command `{COMMAND}`.
Only output the parameters in a single line.
Output example:
"subcommand -f --flag --parameter=value arg1"
The role tells the model who it is; the example shows exactly how the answer should look — a single line of subcommands, flags, and arguments, without the command itself. Models hold a format noticeably more reliably when they see a live sample, not just a worded description. Next come the constraints, to keep the output clean:
Do not include explanations, extra text,
or the command name itself.
If some parameters are missing, omit them
rather than inventing values.
The first ban silences "chattiness": otherwise the model
appends an explanation that we'd have to clean out. The second
matters more: it forbids inventing missing data. Better to
omit a parameter than to substitute a made-up path or hash. At
the very end we place the placeholders for substitution:
{COMMAND} for the command and {INPUT} for the user's
request.
let prompt = PROMPT
.replace("{COMMAND}", real_name)
.replace("{INPUT}", &natural_args);
The replace() method substitutes real_name (git) and
natural_args (drop the last commit) into the template. So
one prompt file serves any command: only the two holes change,
while the instruction's frame stays constant.
A Single Model Call via rig
There's no need to implement OpenAI's HTTP protocol by hand —
we take the rig-core crate. It provides a client, an agent
builder, and a single method to send the request.
let client = openai::Client::from_env();
let agent = client.agent("gpt-5").build();
let real_args = agent.prompt(prompt).await?;
Client::from_env() assembles a client from environment
variables — the key is read from OPENAI_API_KEY, and there's
no secret in the code. The agent("gpt-5") method starts a
builder for a specific model; if no fine-tuning is needed,
build() right away. In rig terms the interlocutor is an
Agent, and its prompt() sends a chat-completion request
and returns the answer as a string — those are the ready
command arguments.
The prompt() method is async, which means we need an async
runtime. The most common runtime in Rust is Tokio, and it's
hooked in with a single attribute.
#[tokio::main]
async fn main() -> Result<ExitCode> {
// ...
let real_args = agent.prompt(prompt).await?;
}
#[tokio::main] wraps the ordinary main in a runtime
launch, so inside you can write await. The ? operator
after await propagates the error upward: if the network drops
or the key is wrong, the program honestly exits with an error
rather than continuing with an empty answer.
Preview, Confirm, Run
The model returned a string of arguments — but running someone else's command blindly is dangerous. So first we show the person exactly what we intend to execute, and wait for a "yes".
let prompt = format!("{real_name} {real_args}");
let run = Confirm::new()
.with_prompt(prompt)
.report(false)
.interact()?;
The format! macro glues the whole command together — git reset --soft HEAD^ — to show it as-is. The dialoguer crate
provides a Confirm struct: with_prompt() puts the text in,
report(false) mutes the extra report about the choice (we
already see the command in the prompt), and interact() blocks
the terminal and returns a bool. The method is synchronous on
purpose: a terminal dialog doesn't mix with async. true — the
user agreed; false — they changed their mind.
If consent is given, we assemble the real process.
let mut cmd = Command::new(real_name);
let args_list: Vec<&str> = real_args
.split_whitespace()
.collect();
cmd.args(&args_list);
Command from the process module of the tokio crate
builds a child process. Earlier we glued arguments into a
phrase — now we do the reverse: split_whitespace() cuts the
model's answer back into a list of arguments by spaces. The
approach is simple and works for our purposes, but let's note
its weak spot honestly: quoted phrases (-m "fix bug") it will
split incorrectly — a good reason for a future improvement.
cmd.stdin(Stdio::inherit());
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
let status = cmd.status().await?;
Stdio::inherit() hands the child process the same I/O streams
we have — so git log draws its output straight into our
terminal, and interactive commands work as usual. status() .await launches the command and waits for it to finish. An
important subtlety: ? here catches a launch error (no such
command, no permission), not "the command ran unsuccessfully".
The command's own success lives in its exit code — we'll handle
that next.
An Exit Code Like a Real Command
The wrapper should behave like the tool itself — including returning the same exit code, so it can be dropped into scripts and pipelines.
async fn main() -> Result<ExitCode> {
// ...
if run {
let status = cmd.status().await?;
let code: u8 = status
.code()
.context("Cannot get the exit code")?
.try_into()?;
Ok(ExitCode::from(code))
} else {
Ok(ExitCode::SUCCESS)
}
}
status() yields an ExitStatus, and its code() an
Option<i32> (there may be no code if the process was killed
by a signal), hence context() again. ExitCode is built only
from a u8, so try_into() narrows the i32 to a u8, and
? catches any out-of-range value. The else branch, for the
"user declined" case, returns ExitCode::SUCCESS: we ran
nothing, and that's not an error. The return type of main is
Result<ExitCode>, so both branches are wrapped in Ok.
Where to Go From Here
The utility is ready: build a release with cargo build --release, drop the binary into a directory on your PATH
(say, ~/.local/bin), and create a git+ alias next to it.
Before running, export OPENAI_API_KEY — and git+ show a compact list of commits will propose git log --oneline.
Obvious directions to grow: a proper argument parse that honors
quotes instead of split_whitespace(); a response cache so
repeated requests don't hit the model; a --yes flag that
skips confirmation in trusted scenarios; support for local
models — rig speaks more than just OpenAI.
Why build this at all? Because several practical Rust skills
meet here: working with env::args and paths, embedding
resources via include_str!, careful error handling on
anyhow, async on Tokio, and spawning child processes with
proxied I/O and exit code. And the result isn't a toy but a
real helper: a thin, safe layer that turns "I remember the
exact flag" into "just say it in words".