Teach your shell to speak English: git+ and friends
Type git+ undo my last commit but keep the files and watch it run git reset --soft HEAD~1. That's the whole pitch: a tiny Rust binary that sits between you and any command-line tool, turns your plain-English request into real flags via an LLM, shows you the command, and runs it. We'll build it one small piece at a time — and see why each piece is shaped the way it is.
The core trick: one binary, many names
The magic isn't in the code, it's in the invocation. You install the binary once, then symlink it as git+, ls+, ffmpeg+. When it starts, it inspects how it was called — the +-suffixed name is the configuration. No subcommands, no config file.
Start by grabbing the name the program was launched under:
let arg0 = env::args()
.next()
.context("No name of the app provided")?;
env::args() yields the binary's own path first — something like /usr/local/bin/git+. That first element is arg0, the invocation path. The .context(...) method comes from the anyhow crate; it turns a bare None from .next() into a readable error instead of an unwrap panic.
That path still has directories and the + on it, so peel it down to a filename:
let file = Path::new(&arg0)
.file_name().context("Invalid path")?
.to_str().context("Invalid UTF-8 in filename")?;
file_name() strips /usr/local/bin/ and leaves git+. Both steps are fallible — a path can end in .., and OS filenames aren't guaranteed UTF-8 — so each gets its own .context(...) message. Now file is a clean &str we can trim.
Finally, recover the real command:
let real_name =
file.trim_end_matches('+').to_string();
trim_end_matches('+') drops the trailing +, turning git+ back into git. The symlink name carried everything we needed: which tool to drive is encoded right in how you typed the command.
A struct that is the whole pipeline
Everything lives on one type, populated from the environment and then driven to completion. Giving the app a home struct up front means every later feature — prompt building, the LLM call, execution — is just another method on it.
struct PromptCmd {
real_name: String, // "git"
natural_args: String, // "undo my last commit"
real_args: Vec<String>,
}
real_name is the command we just recovered. natural_args is the plain-English request the user typed. real_args starts empty and fills up later with the actual flags the model produces — ["reset", "--soft", "HEAD~1"].
The constructor pulls all of that from the environment:
impl PromptCmd {
fn from_env() -> Result<Self> {
// parsing from the section above
}
}
Result here is anyhow's alias, so any error implementing the standard Error trait flows through ? without you defining error enums. The remaining user words become natural_args by joining the rest of env::args() with spaces — everything after arg0.
Bake the prompt into the binary
The instructions to the model are prose, not code, so they live in a separate file. But we don't want a loose file to ship alongside the binary, so we embed it at compile time:
static PROMPT: &str = include_str!("prompt.md");
include_str! is a macro that reads the file during compilation and inlines its contents as a &'static str. The prompt ends up baked inside the executable — there's no external file to lose or mis-path at runtime. The template itself stays dead simple:
You generate CLI parameters for
{COMMAND}. Output only the raw flags and arguments on one line — no command name, no explanation, no code fences. Request:{INPUT}
Two placeholders don't justify a template engine, so plain str::replace fills them. Start with the command:
let prompt = PROMPT
.replace("{COMMAND}", &self.real_name);
This swaps {COMMAND} for git (or whatever tool you're driving), telling the model which program's flag vocabulary to use. replace returns a fresh String, leaving the static template untouched for the next invocation.
Then chain the user's request into the second slot:
let prompt = prompt
.replace("{INPUT}", &self.natural_args);
Now {INPUT} becomes undo my last commit. The strictness in the template — "only the raw flags, one line" — is deliberate: it makes the next step, splitting the response, trivial. The model returns flags and nothing else to clean up.
Calling the model
Talking to an OpenAI-compatible API is one short builder chain with the rig crate (rig-core), which hides HTTP, auth, and response parsing. Pin the model first:
const MODEL: &str = "gpt-5.2";
A const keeps the model name in one obvious place instead of scattered through call sites. Swapping models later is a one-line change.
Now build a client and an agent:
let client = Client::from_env();
let agent = client.agent(MODEL).build();
from_env() reads OPENAI_API_KEY from the environment, never from source — credentials don't belong in your binary. client.agent(MODEL).build() configures a reusable agent bound to that model, ready to take prompts.
Then fire the request:
let answer = agent.prompt(prompt).await?;
agent.prompt(...) returns a future; the .await? is what actually sends the request and propagates any failure through anyhow. The result, answer, is the model's raw line of flags as a String.
For that .await to mean anything, main has to run on an async runtime:
#[tokio::main]
async fn main() -> Result<ExitCode> {
PromptCmd::from_env()?.run().await
}
#[tokio::main] (from tokio, built with features = ["full"]) rewrites this into a sync main that boots the executor and blocks on the future. Network calls are I/O-bound — they spend almost all their time waiting — so async is the natural fit. The full feature set also gives us the process-spawning we'll need shortly.
Splitting like a shell, not like split(' ')
The model hands back one string: --sort=time -la "My Documents". Splitting on spaces would shatter "My Documents" into two separate arguments. Shells don't do that, and neither should we:
let parsed = shell_words::split(answer.trim())?;
The shell-words crate implements POSIX word-splitting: it honors single quotes, double quotes, and escapes, producing a clean Vec<String> exactly as a real shell would parse the line. We trim() first to drop any stray newline the model tacked on, and the ? surfaces a parse error if the quoting is malformed.
Then fold those words into our struct:
self.real_args.extend(parsed);
extend appends every parsed token onto real_args, the field that started empty. Its companion shell_words::join does the reverse — we'll use that next to show the user a copy-pasteable preview.
Confirm, then become the command
Running LLM-generated commands blind is how you rm -rf your afternoon. So before executing anything, we render the full command back into one readable line:
let joined = shell_words::join(&self.real_args);
let preview =
format!("{} {}", self.real_name, joined);
shell_words::join re-quotes the args so the preview is something you could actually paste into a shell — "My Documents" stays intact. Prefixing real_name gives the complete command, git reset --soft HEAD~1, ready to display.
Now ask the user to approve it:
let ok = Confirm::new()
.with_prompt(format!("Run: {preview}?"))
.report(false)
.interact()?;
Confirm from the dialoguer crate handles the terminal rendering and keypress reading. .report(false) keeps it from echoing the choice back after you answer, and .interact()? blocks for a yes/no, returning a bool.
Handle a refusal cleanly:
if !ok {
return Ok(ExitCode::SUCCESS);
}
Note the guard clause: declining returns SUCCESS and bails early. The tool did its job — it generated the command — and the user simply chose not to run it, which is no kind of failure.
If they say yes, we hand control to the real process. Build and spawn it:
let status = Command::new(&self.real_name)
.args(&self.real_args)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status().await?;
Command here is tokio's async process spawner — using the blocking std version would stall the runtime. Inheriting all three I/O streams makes interactive and streaming commands (git log, a build with live output) behave exactly as if you'd typed them yourself. .status().await? runs the child to completion and gives back its exit status.
Finally, forward that exit code:
let code: u8 = status
.code()
.context("killed by signal")?
.try_into()?;
Ok(ExitCode::from(code))
status.code() is an Option — it's None when a signal killed the process rather than a normal exit — so .context(...) turns that case into a clear error. We narrow the i32 to u8 with try_into() and wrap it in ExitCode. Now scripts that pipe through git+ still see git's real exit status, not a flattened 0.
Where to go next
The toy works; production wants a little more:
- A dry-run flag — print the command and stop, for piping into other tools.
- Few-shot examples in the prompt — a couple of
request → flagspairs sharply improve accuracy on niche tools. - A deny-list or sandbox — refuse obviously destructive output (
rm -rf /,dd of=/dev/sda) even before the confirmation prompt. - Provider choice —
rigspeaks to several backends; makeMODELand the client configurable via env.
Why build it
The whole program is a clean tour of practical Rust: argument parsing, compile-time embedding, an async LLM call, shell-correct tokenizing, subprocess management, and honest exit codes — each a single focused method on one struct. Build it once and + becomes muscle memory. You stop memorizing ffmpeg incantations and start describing what you want, with a human-readable confirmation standing guard between intent and enter.