Skip to main content
Glama

behave

Record how any CLI behaves. Prove your rewrite behaves the same.

Proof: running it against uutils coreutils (the Rust rewrite of GNU coreutils) has so far led to 22 upstream bug reports and 29 fix PRs, 12 of them merged (as of Oct 31, 2026). See what it found.

behave treats a command-line tool as a black box. It reads --help (and the man page), generates hundreds of test cases (every flag, flag pairs, every value the help lists for a flag, edge-case stdin, file arguments, bad values, missing files), records what the tool actually does (stdout, stderr, exit code), and replays those cases against another implementation.

behave recording jq and checking gojq against it

Use it when you are:

  • porting a tool to Rust or Go and want proof that the port is compatible,

  • refactoring or upgrading a CLI and want a regression net in a few seconds,

  • comparing two implementations of the same tool.

Porting a CLI?

If you are rewriting an existing command-line tool in Rust or Go, behave gives you a compatibility suite without reading the original source. Three commands:

behave probe sort                      # record what the original does, case by case
behave run behave-sort                 # make sure the recording is stable
behave diff behave-sort --impl ./target/release/sort   # every place your port differs

This is how it led to 22 bug reports and 29 fix PRs in uutils coreutils (12 merged so far). A second, ready to run pair: behave diff examples/jq --impl gojq passes 29 of 33 jq 1.7 cases, and the 4 differences are all error wording on stderr (see examples/jq).

Status: early (v0.1). CLI and MCP server work; feedback welcome.

Related MCP server: Evidrift

Quick start

Install with Homebrew (macOS and Linux), download a binary from Releases, or use go install:

brew install emirmrkaljevic/tap/behave
# or: go install github.com/emirmrkaljevic/behave/cmd/behave@latest

# 1. record how GNU sort behaves (writes ./behave-sort/)
behave probe sort

# 2. check that the recording is stable
behave run behave-sort

# 3. compare another implementation against it
behave diff behave-sort --impl ./target/release/sort --skip-tag meta

Or try the committed examples first: behave run examples/cut (27 GNU cut cases), behave run examples/paste (18 GNU paste cases), behave run examples/join (26 GNU join cases), behave run examples/nl (26 GNU nl cases), behave run examples/fold (28 GNU fold cases), behave run examples/head (24 GNU head cases), behave run examples/expand (21 GNU expand cases), behave run examples/unexpand (26 GNU unexpand cases), behave run examples/tr (29 GNU tr cases), behave run examples/comm (30 GNU comm cases), behave run examples/uniq (31 GNU uniq cases), behave run examples/sort (35 GNU sort cases), behave run examples/cat (39 GNU cat cases), behave run examples/wc (38 GNU wc cases), behave run examples/tac (33 GNU tac cases), behave run examples/numfmt (41 GNU numfmt cases), behave run examples/od (36 GNU od cases), behave run examples/basenc (32 GNU basenc cases), or behave run examples/jq (33 jq 1.7 cases); see examples/.

For tools that need a positional argument (a filter, a pattern, a field list), give samples:

behave probe jq --arg . --arg '.a' --arg '.[]' --arg 'map(.+1)' --arg keys
behave diff behave-jq --impl gojq --ignore-stderr

When the help text lists the values a flag accepts, probe adds one case per value next to the invalid-value cases. It understands GNU style (WHEN can be: auto, always, never, --sort=WORD ... to WORD: none (-U), size (-S), ..., The WHEN argument defaults to 'always' and can also be 'auto' or 'never'.), clap's [possible values: ...], argparse's {json,yaml} and <fast|safe>. It only keeps plain lowercase lists it can tie to that flag. --dry-run shows what it found:

$ behave probe ls --dry-run
       --color                      WHEN  {always,auto,never}
       --sort                       WORD  {none,size,time,version,extension,name,width}

Example output (abridged):

✗ flag --output-delimiter, value -1, arg -f1  0135-flag-output-delimiter-value-1-arg-f1.yaml
  $ cut --output-delimiter -1 -f1
  exit:
    exit: want 0, got 1
  stdout:
    - hello

flags with differences:
  --output-delimiter             2/16  cases differ
flags fully compatible: 9 of 10

cut vs ./uu/cut: 172/175 passed, 3 failed, 0 skipped  (differs in: exit 2, stdout 3)  [0.1s]

What it found on day one

Run on this machine against real tools, generated cases only, --help/--version excluded:

original

candidate

cases

pass

notable differences

GNU wc 9.7

uutils wc 0.12.0

63

63

none

GNU head

uutils head

51

51

none

GNU uniq

uutils uniq

100

100

none

GNU tr

uutils tr

63

63

none

GNU tac 9.12

uutils tac (main)

36

35

-s -1 rejected (value starting with -, #15497, now fixed)

GNU tail 9.12

uutils tail (main)

60

58

-c with no count accepted (#15528, now fixed); file names like 007 printed as 7 (#15533, now fixed in tail); a hand-added -cf FILE case is rejected (#15517)

GNU cut

uutils cut

175

172

--output-delimiter -1 rejected (value starting with -)

GNU base64

uutils base64

36

31

-d on invalid input: GNU prints the bytes decoded so far, uutils prints nothing

GNU sort

uutils sort

290

~260†

usage errors exit 1 instead of 2; --debug underline width; --check --merge

jq 1.7

gojq 0.12.19

596

331*

unsupported flags (--seq, --ascii-output, …), key order, big-number formatting

* with --ignore-stderr; error message wording differs almost everywhere. † varies by one or two: some --random-sort cases are only sometimes detected as flaky with tiny inputs.

Many of these are deliberate design choices, not bugs. behave just makes them visible. Record from the release the rewrite actually targets. uutils follows the newest GNU release, and GNU 9.12 prints ==> 'standard input' <== in tail -v headers where 9.7 prints no quotes, so a spec recorded from an older GNU reports differences that are really GNU changes. behave.yaml keeps the --version line of the recorded tool, and run/diff print it next to the version being replayed. Some were real bugs and are now fixed upstream:

Still open: a few error messages that lose the file name, mostly caught by the "file with trailing slash" probe case added in v0.1.7:

  • cp f/ dest prints Not a directory (os error 20) instead of cannot stat 'f/': Not a directory (#15691, fix in #15692)

  • paste open and read errors without the file name (#15254, fix in #15642)

  • shred dir says Not a file instead of failed to open for writing: Is a directory (#15630, fix in #15631)

  • mv missing/ g says Not a directory and mv d/f g with an unsearchable d says No such file or directory, where GNU gives the real reason (#15704, fix in #15705)

  • ln f/ link and ln missing link say failed to create hard link 'link' => ... instead of naming the source with failed to access 'f/' (#15713, fix in #15714)

  • mv a missing/b and mv a ro/b print only No such file or directory (os error 2) or Permission denied (os error 13), where GNU says cannot move 'a' to 'missing/b': ... (#15721, fix in #15722)

  • mv -T f d (file onto an existing directory) prints cannot overwrite directory 'd' with non-directory without the source, where GNU adds 'f' (#15724, fix in #15725)

  • mv -n z z (and --update=none) fails with 'z' and 'z' are the same file, where GNU skips the move and exits 0 (#15824, fix in #15825)

  • chown 1000 f/ and chgrp 1000 missing say cannot dereference 'f/', where GNU says cannot access and keeps cannot dereference for dangling symlinks (#15867, fix in #15868)

  • chmod -v 644 missing prints nothing on stdout, and for a dangling symlink a made-up from 0000 (---------) to 1500 (r-x-----T) line, where GNU prints 'missing' could not be accessed (#15883, fix in #15884)

  • fmt d a.txt stops at the directory with a bare read error and never formats a.txt (same for a missing file), where GNU says error reading 'd': Is a directory and goes on (#15922, fix in #15923)

  • tr a '[:alpha:]' prints the set2 class error on one line and says set2, where GNU splits it over two lines and says string2 (#15948, fix in #15949)

  • uniq -f x says Invalid argument for skip-fields: x, where GNU says x: invalid number of fields to skip (same for -s and -w), caught by the new examples/uniq spec (#15970, fix in #15971)

  • sort -t ab and sort -t '' word the bad separator errors differently from GNU, and sort -c a b adds a usage hint GNU doesn't print, caught by examples/sort (#15975, fix in #15976)

  • od -A q and od -t x3 word the invalid radix and type string errors differently from GNU, caught by examples/od (#16071, fix in #16072)

  • od -t x0 (and d0, u0, f0) is silently treated as the default size, where GNU rejects a 0-byte type (#16085, fix in #16086)

MCP server

behave mcp serves the same features over stdio, so a coding assistant can explore the original tool, grow the spec while it works, and check its port after every change.

{
  "mcpServers": {
    "behave": { "command": "behave", "args": ["mcp"] }
  }
}

tool

what it does

behave_exec

run a command once in a temp dir, return exit/stdout/stderr (exploration)

behave_probe

generate and record a spec from --help

behave_add_case

add a case and record the original's behavior

behave_list_cases

list cases, optionally by tag (flag:--sort, manual)

behave_diff

replay against an implementation, return failing cases with diffs

Spec format

A spec is a directory you can commit and review:

behave-cut/
  behave.yaml           # command, timeout, normalizers, recorded_from (--version line)
  cases/
    0001-help.yaml
    0135-flag-output-delimiter-value-1-arg-f1.yaml
    ...
name: flag --output-delimiter, value -1, arg -f1
args: [--output-delimiter, "-1", -f1]
stdin: |
  hello
tags: [flag-value, "flag:--output-delimiter"]
expect:
  exit: 0
  stdout: |
    hello
  stderr: ""

Cases can also set env, files (created in a fresh temp dir), and timeout_ms. Add your own with behave add behave-cut --stdin 'a,b' -- -d, -f2.

How it stays deterministic

  • every case runs in a new temp directory with a fixed environment (LC_ALL=C.UTF-8, TZ=UTC, NO_COLOR=1, HOME=temp dir)

  • input files get fixed modification times, and tools that read the current directory (ls, du, rg; help text mentions "current directory" or recursion) get a small sample tree (--sample-files auto|on|off) whose names, sizes and extensions sort differently, so ls --sort=size, ls -t, du --time and du -S produce output worth comparing

  • each case is recorded 3 times; anything that changes between runs is marked flaky and skipped (e.g. sort --random-sort)

  • normalizers remove noise before comparing: ansi, workdir, cmd (jq: vs gojq:), timestamp, hexaddr, duration, pid, plus your own regex rules in behave.yaml

  • timeouts kill the whole process group

Commands

command

what it does

behave probe <cmd>

discover flags, generate and record cases (--arg, --inputs text|json|both, --max, --skip-flag, --sample-files, --dry-run)

behave record <spec>

re-record expectations from the original

behave add <spec> -- ARGS

add one hand-written case

behave run <spec>

replay against the original

behave diff <spec> --impl <bin>

replay against another implementation (--ignore-stderr, --ignore-exit, --tag, --skip-tag, --json, --junit file, -v)

behave coverage <spec>

which documented flags (from --help/man) the cases exercise (--min PCT, --all, --json)

behave normalizers

list built-in normalizers

run and diff exit with 1 when any case fails, so they drop straight into CI. Pass --junit report.xml to write a JUnit XML report for CI dashboards (GitHub Actions, Jenkins, etc.).

coverage tells you what a spec doesn't test yet. On the hand-picked cut example:

$ behave coverage examples/cut
cut: 27 cases exercise 7 of 12 documented flags (58%)
not exercised (5): -n --zero-terminated --help --version -M
used but not in help: --no-such-flag

It reads flags the same way probe does, understands --long=value, unambiguous long prefixes and clustered short flags (-sf1), and exits 1 with --min 80 when coverage drops below 80%.

The repo ships a multi-stage Dockerfile that builds the binary and starts behave mcp on stdio (for Glama and other MCP registries that build from a Dockerfile).

Roadmap

  • more committed example specs (head, tail, wc, a grep subset vs ripgrep)

  • Homebrew tap

Safety

behave only runs the binaries you point it at, in a temp directory. Don't probe tools that change things outside their working directory (package managers, rm -rf-style tools) without --skip-flag and a sandbox.

  • MCP Registry name: io.github.emirmrkaljevic/behave

License

MIT

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    A
    maintenance
    MCP server that lets coding agents test AI agents. Create YAML test cases, snapshot golden baselines, check for regressions, and generate visual reports all from inside Claude Code or any MCP-compatible tool. Works with LangGraph, CrewAI, OpenAI, Claude, Mistral, and any HTTP API.
    10
    114 npm
    2,050 PyPI
    140
    Apache 2.0
  • A
    license
    A
    quality
    C
    maintenance
    Enables LLMs and agents to check their own work by calling tools instead of trusting assertions, covering symbolic math identities, sandboxed Python execution, JSON-Schema validation, dimensional analysis, and regex testing. Runs over stdio or streamable HTTP for use in Claude Desktop and remote gateway deployments.
    5
    MIT
  • A
    license
    A
    quality
    A
    maintenance
    Enables MCP clients to score retrieval quality with recall, precision, nDCG, hit rate and MRR, compare 2-3 configurations with seeded paired-bootstrap confidence intervals, audit agent tool calls against expected traces, and detect prompt regressions against golden sets. It also generates Markdown, JSON or JUnit reports with pass/fail gates, all deterministically and offline without paid API keys.
    6
    308 npm
    1
    MIT