Skip to main content

cli-to-tools

Translate shell command lines into ordered, structured tool calls, so that an agent working through a single bash(command=...) tool can be constrained by a tool-call harness such as AgentLTL.

📖 Documentation: agentltl.github.io/shell, including every supported command with its tools and arguments.

from cli_to_tools import Translator

for call in Translator().translate('git add . && git commit -am "fix" | tee log; echo $(cat VERSION)'):
    print(call.name, call.args)
# git_add     {"paths": ["."], ...}
# git_commit  {"message": ["fix"], "all": True, ...}
# tee         {"paths": ["log"], ...}
# cat         {"paths": ["VERSION"], ...}     <- runs before the echo that consumes it
# echo        {"words": ["$(cat VERSION)"], ...}

The library only translates. Executing the command line stays with the caller.

Install

Paste this into your coding agent (Claude Code, Codex, Copilot CLI, Mistral Vibe...):

Install cli-to-tools from https://github.com/AgentLTL/cli-to-tools in a virtual environment (pip install "git+https://github.com/AgentLTL/cli-to-tools.git"), then use cli_to_tools.Translator to translate a shell command line of mine into tool calls and show me the result.

Or install manually:

pip install -e ".[dev]"
pip install -e /path/to/AgentLTL      # only for cli_to_tools.agentltl
pytest

How it works

  1. Shell layer – the command line is parsed with tree-sitter-bash and the parse tree is walked in execution order: ;, &&, || and | left to right; commands inside $(...), backticks and <(...) before the command that consumes them; subshells and { ...; } groups flattened.
  2. Specs – each simple command is matched against a command spec, which gives it a tool name (git_commit, docker_compose_up) and flat named arguments.
  3. Fallback – a command without a spec becomes tool_name=<executable>, arguments={"argv": [...]}.

Each ToolCall has name, args, id and meta. to_dict() returns AgentLTL's trace format, {"tool_name", "arguments", "id", "cli": meta}, where meta holds:

key meaning
command, source, index the full command line, this command's text, its position
operator connector to the previous call: ; && || | $() wrap or None
conditional True if the call may be skipped at run time (after && / ||, inside if / case, a loop body that may run zero times)
repeated True if the call sits in a loop that could not be unrolled: listed once, may run any number of times
pipeline, depth pipeline id; nesting depth (subshell, substitution, wrapper)
wrapper the wrapper this call runs under (sudo, xargs, bash, find, python -m)
redirects, env [{op, target, fd?}] and VAR=value prefixes
dynamic_args arguments the shell would expand ($X, globs, $(...), ~)
spec_matched False if there was no spec or the spec did not fully cover the argv

Conditional chains are over-approximated: every command that could run is emitted.

Control flow

construct translation
for x in a b c, {1..5}, x{a,b} (literal list, up to 64 values) unrolled exactly: the body is emitted once per value with $x / ${x} substituted
for x in *.py, in $(ls), in $LIST, for ((...)), while, until body listed once, flagged conditional and repeated; the loop variable stays a dynamic argument
if / elif / else, case conditions, then every branch, flagged conditional

The second and third rows are over-approximations. They are sound for constraints on names and order ("never call X", Before), but not for counts (CalledNTimes) or exact argument values, because the number of iterations and the run-time values are unknown. Translator(strict=True) rejects those constructs instead; literal for loops are exact and stay allowed.

Wrappers

Commands that run other commands are unwrapped, so they cannot hide a call: sudo, doas, env, time, timeout, nohup, setsid, nice, stdbuf, watch, exec, command, builtin, xargs, find -exec, python -m <module>, and bash -c '<script>' (the script is parsed recursively). The wrapper is emitted first, then the wrapped call.

Not unwrapped: commands that run a command string elsewhere or in another language (ssh host '<cmd>', docker exec ... <cmd>, python -c, make, npx, scripts such as bash script.sh). These appear as one call with the command visible in its arguments; forbid or restrict them in your constraints if that matters.

What commands do to files

Translator(shell_effects=True, paths=Paths(cwd, root)) adds what a guard needs on top of the arguments themselves:

Argument What it holds
redirect_to, redirect_from files a redirection writes (> f, >> f, 2> f, cat <<EOF > f) or reads (< f)
overwrite_to the redirections that truncate (>, &>; not >>, nor /dev/null)
unknown_paths path arguments only known at run time: True under xargs, else a list (rm $UNSET → ["paths"])
paths of patch / git apply the files the diff modifies
output of curl -O the file it writes

Path arguments are made absolute, resolved from the directory the line has moved to: in cd sub && rm a, a is sub/a. A cd lasts until the end of its subshell, one in a pipeline changes nothing, and after cd $D relative paths are unknown. Which arguments hold paths: those named in PATH_NAMES (path, paths, file, destination, ...), plus any a spec marks path: true (and minus those it marks path: false). Tool schemas show them with "format": "path".

Fail closed

translate() raises TranslationError instead of guessing on: syntax errors, function definitions, background jobs (&), eval / source / ., a non-static command name ($CMD args, including a loop variable that cannot be resolved), a dynamic bash -c "$X", and a shell reading its script from stdin (curl ... | sh).

Specs

These packs are bundled and loaded by default:

Pack Commands
shell coreutils and text tools: ls, cat, cp, mv, rm, find, sed, awk, grep, ...; environment (printenv, export, declare); binary viewers (base64, strings, xxd, hexdump, od)
files other file writers: perl, ed, vim, nano, code, dd, install, truncate, shred, split, sponge, patch, archives (unzip, zip, 7z)
git git, including git credential and git submodule
docker docker (including inspect, login), with docker compose / docker-compose (including config)
python python, pip, pytest
network curl, wget, ssh, scp, rsync
cloud kubectl (including config view, create secret), terraform (including output, show, state pull), helm, gh (including auth, secret, gist), aws (s3, secretsmanager, ssm, configure, iam, sts, kms, ecr), gcloud (secrets, auth, iam, container), az (keyvault, account, storage, acr, ad, aks), vault, doppler, heroku, vercel
secrets decryption (sops, ansible-vault, gpg, age, openssl), password managers and keychains (op, bw, pass, security, secret-tool), database clients that take passwords (mysql/mariadb, psql, redis-cli)

They cover the commonly used subcommands and flags, not complete man pages:

  • Unknown flags are kept in extra_args.
  • An undeclared subcommand still gets a predictable name: git frob x becomes git_frob, with {"argv": [...]}.
  • Global flags: options a CLI accepts anywhere are marked global: true, so kubectl delete pod x -n prod records namespace: prod. That applies to kubectl's -n and --context, gh's -R, and aws's --profile and --region.
from cli_to_tools import CommandSpec, SpecRegistry, Translator

registry = SpecRegistry()                      # all packs; SpecRegistry(packs=["shell", "git"]) to pick
registry.load_yaml("my_specs.yaml")            # add or override from YAML

deploy = registry.register(CommandSpec("deploy"))   # or in Python: plain argparse arguments
deploy.add_argument("-e", "--env", dest="environment")
deploy.add_argument("--force", action="store_true")
deploy.add_argument("services", nargs="*")

translator = Translator(registry)
# my_specs.yaml
deploy:
  tool: deploy                 # optional tool name override
  aliases: [dpl]
  posix: false                 # true: options stop at the first positional (docker run IMAGE CMD...)
  options:
    - {flags: [-e, --env], dest: environment}
    - {flags: [--force], type: bool}          # str (default) | bool | count | list | int
    - {flags: [--mode], choices: [fast, safe], help: "How to deploy."}   # optional, for tool schemas
    - {flags: [-n, --namespace], global: true}   # also accepted after a subcommand
    - {flags: [--manifest], path: true}          # holds a file path (see shell_effects)
  positionals:
    - {name: services, nargs: "*"}
  subcommands: {}              # nested specs, same schema -> tool "deploy_<name>"

kubectl:
  extend: true                 # add to the bundled kubectl spec instead of replacing it
  subcommands:
    rollout: {positionals: [{name: action}, {name: resource}]}

A later spec for the same command replaces the earlier one, unless it says extend: true: then its options are merged by flag and its subcommands one by one. registry.load_dict(specs, extend=True) extends by default.

Boolean flags are always present in the arguments (False when absent), so both CalledWith("rm", {"recursive": True}) and CalledWith("git_push", {"force": False}) work.

AgentLTL

ShellEnforcer is an agentltl.Enforcer (AgentLTL 0.2 or later). Calls to a shell tool are expanded into the structured calls of the command line and checked with Enforcer.check_chain; every other tool is checked unchanged.

from agentltl import Before, CalledWith, Constraint, Globally, Not
from cli_to_tools.agentltl import ShellEnforcer

enforcer = ShellEnforcer(
    [
        Constraint("commit_before_push", Before("git_commit", "git_push")),
        Constraint("no_force_push", Globally(Not(CalledWith("git_push", {"force": True})))),
    ],
    shell_tools={"bash": "command"},           # tool name -> argument holding the command line
)

decision = enforcer.check("bash", {"command": "git push && git commit -m x"})
decision.action, decision.index                # ("stop", 0): the first command, git push

CliConstraintEnforcer is the same with AgentLTL's original ConstraintEnforcer interface ("allow" or (kind, feedback), raising on a stop).

  • All or nothing. The calls of a chain are checked in order, each against a trace that already contains the earlier calls of the same chain. If any call is blocked, the whole command line is rejected and nothing is recorded, so nothing should be executed.
  • Untranslatable command lines are blocked with feedback telling the model how to rephrase, and listed in enforcer.untranslatable.
  • record_completed stores one trace entry per structured call (with the cli metadata, so Predicates can read call.raw["cli"]). The tool result and exit status (status=) are attached to the last call.
  • Native backend: with AgentLTL's cli extra, pass shell_tools={"bash": "command"} to AgentWithConstraints(backend="native"), MultiTurnAgent or NativeOpenAIAgent; the agent then uses this enforcer and emits the structured calls in metrics["tool_calls"]. On an AgentLTL without that option: agent._enforcer = CliConstraintEnforcer.from_enforcer(agent._enforcer).
  • Post hoc: expand_tool_calls(metrics["tool_calls"]) rewrites a recorded trace before verify_trace.

See examples/02_agentltl_guard.py for a runnable walk-through.

Metadata

Release files for cli-to-tools 0.2.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for cli-to-tools 0.2.1
File Size Uploaded
cli_to_tools-0.2.1.tar.gz 58.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for cli-to-tools 0.2.1
File Interpreter ABI Platform
cli_to_tools-0.2.1-py3-none-any.whl Python 3 none any Details

Total release size: 103.9 kB

Release files / cli_to_tools-0.2.1.tar.gz

Download URL cli_to_tools-0.2.1.tar.gz
Size 58.1 kB
Tags Source
SHA-256 checksum
How to use checksums
1d432543d428f6ff4d9e515e020fe39037abd76b2f5a9038a1ecb7288d22a294
BLAKE2b-256 checksum
How to use checksums
780a1f238e29cd4825670b64b84a456738296210c48285900b7d6a177b7c19e3
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 12, 2026.

Transparency log

Release files / cli_to_tools-0.2.1-py3-none-any.whl

Download URL cli_to_tools-0.2.1-py3-none-any.whl
Size 45.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ebec7ca0992b80c7e4a50c83fe1ed40ca2104dd30c1aa368fcaedc0aa562b8e8
BLAKE2b-256 checksum
How to use checksums
2dc036ea8f57653daa711a0e6845b451a2e9e55a60e06ca2bef47634e11539e8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 12, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.2.1 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page