cancho-tools

cancho-tools

Ten small tools that AI agents and scripts can rely on, and one opt-in. seek, list, peek, jsonq, tally, hash, write, replace, move and archive do the everyday work of grep, ls/find, sed -n, jq, sort | uniq -c, sha256sum, a careful cp/mv and tar/gzip, and run starts a program from a directory you chose, with explicit semantics, bounded resources, tagged refusals that often carry a repair, and a checkable authority row per binary. Every answer is JSON against a published schema, and every tool describes itself (introspect, skill); an MCP server serves them to an agent runtime from that same description. Written in cancho. The project page has one page per tool.

Status: early, ten tools and one opt-in built and tested. Not claimed: that agents do better with these tools than with the incumbents. That needs an agent-in-the-loop evaluation (cancho#228) that has not been run. What is claimed is what the conformance gates check. What is missing is listed under "What it cannot do".

ToolInstead ofDoes
seekgrep -F -n -bliteral search over files, a stream of matches
listls, find, treethe entries beneath a directory, sorted, depth-bounded, links never followed
peeksed -n, head, tail, wc -la range of lines or bytes, with numbers and a cursor
jsonqjq -cone value from one strict JSON document, by RFC 6901 pointer
tallysort | uniq -c | sort -rndistinct lines or fields, counted and ranked
hashsha256sum, sha512sumfile digests of any size, --verify
writecp, >, teereplace a file atomically, only if it holds what the caller says
replacesed -ireplace exact text an expected number of times, atomically
movemvrename a file or directory in place, never over an existing name
archivetar, gzip, unzip, zstdlist, extract and create tar archives, gzip- or zstd-compressed or not, and zips; read bzip2 and xz; checked whole before any file is written (docs/archive.md)
runexec, timeoutopt-in: start one program from the deployer's directory with exact arguments, a fixed environment, a deadline and an output cap (docs/next-tools.md section 3)

The tools at a glance

seek

Search files for a literal string; NDJSON records with tagged errors, bounded memory, and an authority with no write, network or foreign code.

write

Replace a file atomically, only if it holds what the caller says it holds (--if-sha256) or does not exist (--create); idempotent, locked, with --dry-run.

replace

Replace exact text in a file atomically, only when it occurs --expect times (and, with --if-sha256, only when the file is what the caller read); idempotent, locked, with --dry-run.

peek

Read a range of a file -- lines with numbers and offsets, or bytes -- with its size, a binary flag and a cursor to the rest; head, tail, sed -n, cat -n and wc -l in one call.

jsonq

Look up one JSON Pointer (RFC 6901) in one strictly parsed JSON document; the value byte for byte, its kind, keys or length, and a parse error's position as data.

tally

Count distinct lines or fields and report the most frequent, ordered by count then bytewise key; one bounded pass, the same answer on every machine.

hash

SHA-256 or SHA-512 of files of any size, as NDJSON records, with --verify; the hash write --if-sha256 wants, from a tool that cannot write.

list

The entries beneath a directory, sorted bytewise, depth-bounded and capped, as NDJSON records; links listed and never followed, and no verb but reading.

move

Rename a file or directory in place, never over an existing name (the kernel refuses it, even if another process creates it mid-move); locked, atomic, idempotent with --if-sha256, with --dry-run. One directory: a move to another is not offered. With --remove and --if-sha256, a file is removed by renaming it to a tombstone, and nothing is deleted.

archive

List, extract and create tar archives, gzip- or zstd-compressed or not (and read bzip2- and xz-compressed ones), and zips, and compress one file with gzip or zstd; refuses links, absolute names, .. and overwriting by rule, checks the whole archive before writing any of it, and writes the same bytes for the same files.

run

Start one program from the deployer's programs directory with exact arguments, no shell, a fixed environment, a deadline and an output cap; answers its exit status and output. Opt-in: it can run whatever the directory holds, and it does not sandbox it.

One complete flow: two agents, one file

Two agents read notes.txt and each adds a line. With plain writes the second write wins and agent A's line is gone, and nobody is told:

$ printf 'shopping list\n- milk (agent A)\n'  > notes.txt   # agent A
$ printf 'shopping list\n- bread (agent B)\n' > notes.txt   # agent B
$ cat notes.txt
shopping list
- bread (agent B)

With guarded writes (write --if-sha256) the stale write is refused, and the refusal carries what the agent needs to retry (selected fields of each JSON line):

$ hash --root . notes.txt                 # A and B both read it: H1 is its hash
{"type":"hash","path":"notes.txt","algo":"sha256","hex":"936bb8ff57a0…","bytes":14}
$ ... | write --root . --if-sha256 H1 --stdin notes.txt   # agent A adds its line
{"path":"notes.txt","changed":true,"bytes":31,"after_sha256":"7bde7db5f30a…"}
$ ... | write --root . --if-sha256 H1 --stdin notes.txt   # agent B, stale
{"rule":"precondition.hash-mismatch","hint":"re-read the file, then decide","repair":{"kind":"none","reason":"the file changed since it was read; re-read, then decide"},"detail":{"path":"notes.txt","expected_sha256":"936bb8ff57a0…","actual_sha256":"7bde7db5f30a…","size":31}}
# exit status 5
$ ... | write --root . --if-sha256 H2 --stdin notes.txt   # agent B re-reads (H2 came with the refusal), re-applies, retries
{"path":"notes.txt","changed":true,"bytes":49,"after_sha256":"60e7843a980c…"}
$ cat notes.txt
shopping list
- milk (agent A)
- bread (agent B)

The full transcript, with every JSON line as printed, is in docs/reference.md.

Why these tools

grep, sed and jq were made for a person at a terminal, who reads the prose and retries. An agent has to parse text, often cannot see stderr, and decides its next step from what came back. Each bullet is a property the gates check, with where to see it.

Quick start

You need git, Rust and clang. On a 16-core Mac with Rust installed and its dependencies already downloaded, the compiler built in 17 seconds and the first nine tools in 7; the clones and a first-time dependency download are extra, so allow a few minutes the first time.

git clone https://github.com/alpibrusl/cancho
git clone https://github.com/alpibrusl/cancho-tools && cd cancho-tools
REV=$(sed -n 's/^cancho *= *"\([0-9a-f]*\)".*/\1/p' cancho.toml)           # the compiler these sources need
(cd ../cancho && git fetch -q origin && git checkout "$REV" && cargo build --release -p cancho)
export PATH=$PWD/../cancho/target/release:$PWD/build:$PATH
cancho build                                                             # every tool into build/
seek --root . --format text 'fn main' tools/seek/seek.cho                # try one
seek introspect                                                          # flags, rules, limits, schema, authority (JSON)

What you can do now

One real command per tool, on a small project (src/main.rs, package.json, access.log). Every tool takes --root .: paths are relative to it and nothing outside can be reached. The output is what the built tools print, with long hashes shortened.

**seek**: search files for literal text.

$ seek --root . add src/main.rs
{"type":"match","path":"src/main.rs","line":2,"offset":12,"text":"    let total = add(2, 3);"}
{"type":"match","path":"src/main.rs","line":6,"offset":69,"text":"fn add(a: i32, b: i32) -> i32 {"}
{"type":"file","path":"src/main.rs","matches":2,"bytes":113,"binary":false,"complete":true}
{"type":"end","ok":true,"command":"seek","schema":"seek.v1","complete":true,"files":1,"matches":2,"errors":0,"truncated":false,"next":null}

**list**: the entries beneath a directory.

$ list --root . --depth 2 --format text
access.log
package.json
src/
src/main.rs

**peek**: a range of lines, with numbers.

$ peek --root . --lines 2:3 --format text src/main.rs
2	    let total = add(2, 3);
3	    println!("{}", total);

**jsonq**: one value from a JSON document.

$ jsonq --root . -p /deps/serde package.json
{"ok":true,"command":"jsonq","schema":"jsonq.v1","data":{"pointer":"/deps/serde","kind":"string","value":"1.0"},"meta":{"version":"0.1.0"}}

**tally**: count and rank a field.

$ tally --root . --field 3 --delim ' ' --format text access.log
3	200
1	404
1	500

**hash**: a file's SHA-256.

$ hash --root . package.json
{"type":"hash","path":"package.json","algo":"sha256","hex":"2c313334b372…","bytes":78}
{"type":"end","ok":true,"command":"hash","schema":"hash.v1","complete":true,"files":1,"errors":0}

**write**: create a file, or replace it only if it is what you think.

$ printf 'hello\n' | write --root . --create --stdin NOTES.md
{"ok":true,"command":"write","schema":"write.v1","data":{"path":"NOTES.md","changed":true,"created":true,"bytes":6,"before_sha256":null,"after_sha256":"5891b5b522d5…","diff":[{"old_start":1,"new_start":1,"removed_count":0,"added_count":1,"removed":[],"added":["hello\n"]}],"diff_truncated":false},"meta":{"version":"0.1.0"}}

**replace**: change exact text, here as a dry run.

$ replace --root . --old 'a + b' --new 'a.wrapping_add(b)' --dry-run src/main.rs
{"ok":true,"command":"replace","schema":"replace.v1","data":{"path":"src/main.rs","changed":false,"replacements":1,"bytes":125,"before_sha256":"98ace4483c48…","after_sha256":"63261420d975…","diff":[{"old_start":7,"new_start":7,"removed_count":1,"added_count":1,"removed":["    a + b\n"],"added":["    a.wrapping_add(b)\n"]}],"diff_truncated":false},"dry_run":true,"planned_actions":[{"op":"replace","path":"src/main.rs","replacements":1,"before_sha256":"98ace4483c48…","after_sha256":"63261420d975…","bytes":125,"diff":[{"old_start":7,"new_start":7,"removed_count":1,"added_count":1,"removed":["    a + b\n"],"added":["    a.wrapping_add(b)\n"]}],"diff_truncated":false}],"meta":{"version":"0.1.0"}}
# exit status 9

**move**: rename in place, never over an existing name.

$ move --root . --dry-run NOTES.md NOTES-2026.md
{"ok":true,"command":"move","schema":"move.v1","data":{"path":"NOTES.md","to":"NOTES-2026.md","changed":false,"kind":"file","sha256":null},"dry_run":true,"planned_actions":[{"op":"move","path":"NOTES.md","to":"NOTES-2026.md","kind":"file","sha256":null}],"meta":{"version":"0.1.0"}}
# exit status 9

**archive**: a tarball, byte for byte the same every time, here as a dry run.

$ archive --root . --op create --dry-run project.tgz src package.json
{"type":"dir","path":"src"}
{"type":"file","path":"src/main.rs","bytes":113,"sha256":"98ace4483c48…"}
{"type":"file","path":"package.json","bytes":78,"sha256":"2c313334b372…"}
{"type":"end","ok":true,"command":"archive","schema":"archive.v1","complete":true,"op":"create","format":"tgz","entries":3,"bytes":191,"errors":0,"dry_run":true,"archive_sha256":"372e8efb591b…"}
# exit status 9

Flags, more examples, and each tool's rules are in docs/reference.md.

As an MCP server

server/mcp.cho serves the ten default tools over MCP (run only if the server is built with --with run) (stdio, protocol 2025-06-18). tools/list is generated from each tool's introspect, and a tools/call runs the binary and answers with its output byte for byte. It may start the binaries in the one directory it was built with, and nothing else; --root is the server's, never the model's.

python3 scripts/mcp.py build --bin "$PWD/build" --out build/mcp
build/mcp --root /path/to/workspace

Design and measurements: docs/mcp.md.

What "designed for agents" means here, what a test checks on every binary, and what is not checked: docs/agent-first.md.

What it cannot do

The list of open work is in issues.

How fast

Supporting evidence: these tools are not built to win on speed, and mostly do not lose. A 64 MiB text file, through a pipe, minimum of 7 interleaved rounds, Linux x86-64 on a shared machine; speed is reported, not gated.

Commandcancho-toolsIncumbent
seek gamma (about a million matches)0.49 sgrep -F -n -b 0.51 s, rg 0.39 s
seek with no match0.04 sgrep -F -n -b 0.04 s
hash (SHA-256)0.52 ssha256sum 0.17 s
peek --count-lines --lines 1:1000.08 ssed -n 1,100p; wc -l 0.13 s
tally --field 10.16 scut | sort | uniq -c | sort | head 0.68 s
jsonq -p /0, 16 MiB array0.14 sjq -c .[0] 0.73 s
list, one directory of 100,000 files0.144 sfind -printf 0.146 s, ls -1 0.154 s

sha256sum's lead is OpenSSL's hand-written assembly, which cancho has no way to express. More rows (seek -i is 0.58 s against 0.52 s), startup and --root cost: docs/reference.md. Measure on your machine: python3 scripts/toolbench.py --tool seek --size 64MiB.

Learn more

project pageone page per tool, generated from the tool itself: flags, rules, exit codes, authority, JSON schemas and a ready-made SKILL.md
docs/reference.mdevery tool with its examples, the error protocol, confinement, authority, memory, performance, the package, development
docs/mcp.mdthe MCP server: design and measurements
docs/history.mdhow this was built, what it found in the compiler, the performance rounds
cancho's docs/agent-toolbox.mdthe design (epic cancho#214)
cancho-tablea tenth tool, built outside this repository on its contract package

Contributing

Every change goes through what CI runs: cancho fmt --check contract tools tests generated server, cancho build, cancho test, scripts/schemas.py --check, scripts/manifest.py --check, scripts/demo_race.py --check, scripts/package.py --check, scripts/mcp.py --check, scripts/cli_mutants.py --check (the mutants of the table scans), the conformance tests. A change that needs a new effect fails until tools.toml allows it. After changing a tool, run python3 scripts/manifest.py and cancho build; after changing what a tool prints, python3 scripts/demo_race.py --update-readme rewrites the generated sections of this file. Layout, the gates and how to build more tools on the contract package: docs/reference.md.

Licence

EUPL-1.2.