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".