Rust

CLI Arguments Generator in Rust

Agents

Build a universal utility that transforms natural language tasks into precise command-line arguments.

⏱️ 1h 50min
📦 11 modules
CLI Arguments Generator in Rust cover

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 → flags pairs 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 choicerig speaks to several backends; make MODEL and 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.