agcli 0.7.0

A tiny, no-bloat foundation crate for building agentic CLIs in Rust.
Documentation
# agcli - Agent-Native CLI Framework for Rust

`agcli` is a no-bloat Rust crate for building CLIs that AI agents can reliably operate. It implements 5 principles:

1. **JSON always** - every command returns structured JSON envelopes, never plain text
2. **HATEOAS** - every response includes `next_actions` telling the agent what to do next
3. **Self-documenting tree** - root command returns the full command tree as JSON
4. **Context protection** - truncation helpers cap large outputs with file pointers
5. **Errors suggest fixes** - error envelopes include `fix` and `retryable` fields

## Add as dependency

```toml
[dependencies]
agcli = "0.5.0"
serde_json = "1"
```

## Minimal calculator example

A complete, runnable CLI with `add` and `sub` commands:

```rust
use agcli::{AgentCli, Command, CommandError, CommandOutput, NextAction, ActionParam};
use serde_json::json;

fn main() {
    let cli = AgentCli::new("calc", "Agent-native calculator")
        .version("1.0.0")
        .command(
            Command::new("add", "Add two numbers")
                .usage("calc add <a> <b>")
                .handler(|req, _ctx| {
                    let a: f64 = req.arg(0)
                        .ok_or_else(|| CommandError::new(
                            "missing argument <a>",
                            "MISSING_ARG",
                            "Provide two numbers: calc add <a> <b>",
                        ))?
                        .parse()
                        .map_err(|_| CommandError::new(
                            "argument <a> is not a number",
                            "INVALID_NUMBER",
                            "Pass a valid number for <a>",
                        ))?;
                    let b: f64 = req.arg(1)
                        .ok_or_else(|| CommandError::new(
                            "missing argument <b>",
                            "MISSING_ARG",
                            "Provide two numbers: calc add <a> <b>",
                        ))?
                        .parse()
                        .map_err(|_| CommandError::new(
                            "argument <b> is not a number",
                            "INVALID_NUMBER",
                            "Pass a valid number for <b>",
                        ))?;

                    let sum = a + b;
                    Ok(CommandOutput::new(json!({
                        "operation": "add",
                        "a": a,
                        "b": b,
                        "result": sum
                    }))
                    .next_action(
                        NextAction::new("calc add <a> <b>", "Add two more numbers")
                            .with_param("a", ActionParam::new()
                                .value(json!(sum))
                                .description("First number (pre-filled with previous result)"))
                            .with_param("b", ActionParam::new()
                                .required(true)
                                .description("Second number")),
                    )
                    .next_action(
                        NextAction::new("calc sub <a> <b>", "Subtract instead")
                            .with_param("a", ActionParam::new()
                                .value(json!(sum))
                                .description("First number (pre-filled with previous result)"))
                            .with_param("b", ActionParam::new()
                                .required(true)
                                .description("Number to subtract")),
                    ))
                }),
        )
        .command(
            Command::new("sub", "Subtract two numbers")
                .usage("calc sub <a> <b>")
                .handler(|req, _ctx| {
                    let a: f64 = req.arg(0)
                        .ok_or_else(|| CommandError::new(
                            "missing argument <a>",
                            "MISSING_ARG",
                            "Provide two numbers: calc sub <a> <b>",
                        ))?
                        .parse()
                        .map_err(|_| CommandError::new(
                            "argument <a> is not a number",
                            "INVALID_NUMBER",
                            "Pass a valid number for <a>",
                        ))?;
                    let b: f64 = req.arg(1)
                        .ok_or_else(|| CommandError::new(
                            "missing argument <b>",
                            "MISSING_ARG",
                            "Provide two numbers: calc sub <a> <b>",
                        ))?
                        .parse()
                        .map_err(|_| CommandError::new(
                            "argument <b> is not a number",
                            "INVALID_NUMBER",
                            "Pass a valid number for <b>",
                        ))?;

                    let diff = a - b;
                    Ok(CommandOutput::new(json!({
                        "operation": "sub",
                        "a": a,
                        "b": b,
                        "result": diff
                    }))
                    .next_action(
                        NextAction::new("calc sub <a> <b>", "Subtract two more numbers")
                            .with_param("a", ActionParam::new()
                                .value(json!(diff))
                                .description("First number (pre-filled with previous result)"))
                            .with_param("b", ActionParam::new()
                                .required(true)
                                .description("Number to subtract")),
                    ))
                }),
        );

    let run = cli.run_env();
    println!("{}", run.to_json());
    std::process::exit(run.exit_code());
}
```

## Build and run

```bash
cargo build --release
# Root command - self-documenting tree
./target/release/calc
# Add
./target/release/calc add 3 5
# Subtract
./target/release/calc sub 10 4
# Error case
./target/release/calc add foo bar
```

## Example JSON output

### Success (`calc add 3 5`)

```json
{
  "ok": true,
  "command": "calc add 3 5",
  "timestamp": 1740000000,
  "result": {
    "operation": "add",
    "a": 3.0,
    "b": 5.0,
    "result": 8.0
  },
  "next_actions": [
    {
      "command": "calc add <a> <b>",
      "description": "Add two more numbers",
      "params": {
        "a": { "value": 8.0, "description": "First number (pre-filled with previous result)" },
        "b": { "required": true, "description": "Second number" }
      }
    },
    {
      "command": "calc sub <a> <b>",
      "description": "Subtract instead",
      "params": {
        "a": { "value": 8.0, "description": "First number (pre-filled with previous result)" },
        "b": { "required": true, "description": "Number to subtract" }
      }
    }
  ]
}
```

### Error (`calc add foo bar`)

```json
{
  "ok": false,
  "command": "calc add foo bar",
  "timestamp": 1740000000,
  "error": {
    "message": "argument <a> is not a number",
    "code": "INVALID_NUMBER",
    "retryable": false
  },
  "fix": "Pass a valid number for <a>",
  "next_actions": [
    {
      "command": "calc add <a> <b>",
      "description": "Run this command template",
      "params": {
        "a": { "required": true },
        "b": { "required": true }
      }
    },
    {
      "command": "calc",
      "description": "Inspect the full command tree"
    }
  ]
}
```

## Key patterns

- **Envelope structure**: Every response has `ok`, `command`, `timestamp`, `result`/`error`, `next_actions`
- **Template vs literal next_actions**: When `params` is present, `command` is a template (agent fills placeholders). When absent, it's literal (run as-is).
- **Pre-filled values**: Use `ActionParam::new().value(json!(result))` to pre-fill context from the current operation
- **Error with fix**: `CommandError::new(message, code, fix)` - always tell the agent how to recover
- **Retryable errors**: Chain `.retryable(true)` on `CommandError` for transient failures
- **Truncation**: Use `truncate_lines_with_file()` to cap large outputs and write full content to a temp file
- **Streaming**: Use `NdjsonEmitter` for temporal operations; terminal `result`/`error` events carry `timestamp` and `schema_version`