cli-to-tools 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. cli_to_tools-0.2.1/PKG-INFO +246 -0
  2. cli_to_tools-0.2.1/README.md +227 -0
  3. cli_to_tools-0.2.1/pyproject.toml +47 -0
  4. cli_to_tools-0.2.1/setup.cfg +4 -0
  5. cli_to_tools-0.2.1/src/cli_to_tools/__init__.py +33 -0
  6. cli_to_tools-0.2.1/src/cli_to_tools/_effects.py +241 -0
  7. cli_to_tools-0.2.1/src/cli_to_tools/_model.py +66 -0
  8. cli_to_tools-0.2.1/src/cli_to_tools/_shell.py +710 -0
  9. cli_to_tools-0.2.1/src/cli_to_tools/_spec.py +543 -0
  10. cli_to_tools-0.2.1/src/cli_to_tools/_translate.py +95 -0
  11. cli_to_tools-0.2.1/src/cli_to_tools/agentltl.py +198 -0
  12. cli_to_tools-0.2.1/src/cli_to_tools/packs/cloud.yaml +416 -0
  13. cli_to_tools-0.2.1/src/cli_to_tools/packs/docker.yaml +182 -0
  14. cli_to_tools-0.2.1/src/cli_to_tools/packs/files.yaml +136 -0
  15. cli_to_tools-0.2.1/src/cli_to_tools/packs/git.yaml +233 -0
  16. cli_to_tools-0.2.1/src/cli_to_tools/packs/network.yaml +63 -0
  17. cli_to_tools-0.2.1/src/cli_to_tools/packs/python.yaml +51 -0
  18. cli_to_tools-0.2.1/src/cli_to_tools/packs/secrets.yaml +156 -0
  19. cli_to_tools-0.2.1/src/cli_to_tools/packs/shell.yaml +344 -0
  20. cli_to_tools-0.2.1/src/cli_to_tools.egg-info/PKG-INFO +246 -0
  21. cli_to_tools-0.2.1/src/cli_to_tools.egg-info/SOURCES.txt +28 -0
  22. cli_to_tools-0.2.1/src/cli_to_tools.egg-info/dependency_links.txt +1 -0
  23. cli_to_tools-0.2.1/src/cli_to_tools.egg-info/requires.txt +10 -0
  24. cli_to_tools-0.2.1/src/cli_to_tools.egg-info/top_level.txt +1 -0
  25. cli_to_tools-0.2.1/tests/test_agentltl.py +138 -0
  26. cli_to_tools-0.2.1/tests/test_effects.py +101 -0
  27. cli_to_tools-0.2.1/tests/test_secrets.py +84 -0
  28. cli_to_tools-0.2.1/tests/test_shell.py +199 -0
  29. cli_to_tools-0.2.1/tests/test_translate.py +171 -0
  30. cli_to_tools-0.2.1/tests/test_writes.py +187 -0
@@ -0,0 +1,246 @@
1
+ Metadata-Version: 2.4
2
+ Name: cli-to-tools
3
+ Version: 0.2.1
4
+ Summary: Translate shell command lines into ordered, structured tool calls.
5
+ License: MIT
6
+ Project-URL: Homepage, https://agentltl.github.io/shell/
7
+ Project-URL: Source, https://github.com/AgentLTL/cli-to-tools
8
+ Project-URL: Issues, https://github.com/AgentLTL/cli-to-tools/issues
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: tree-sitter>=0.23
12
+ Requires-Dist: tree-sitter-bash>=0.23
13
+ Requires-Dist: pyyaml>=6.0
14
+ Provides-Extra: agentltl
15
+ Requires-Dist: agentltl>=0.2; extra == "agentltl"
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=7.0; extra == "dev"
18
+ Requires-Dist: ruff>=0.4; extra == "dev"
19
+
20
+ # cli-to-tools
21
+
22
+ Translate shell command lines into ordered, structured tool calls, so that an agent working
23
+ through a single `bash(command=...)` tool can be constrained by a tool-call harness such as
24
+ [AgentLTL](https://github.com/AgentLTL/AgentLTL).
25
+
26
+ 📖 **Documentation: [agentltl.github.io/shell](https://agentltl.github.io/shell/)**, including
27
+ every [supported command](https://agentltl.github.io/shell/commands/) with its tools and
28
+ arguments.
29
+
30
+ ```python
31
+ from cli_to_tools import Translator
32
+
33
+ for call in Translator().translate('git add . && git commit -am "fix" | tee log; echo $(cat VERSION)'):
34
+ print(call.name, call.args)
35
+ # git_add {"paths": ["."], ...}
36
+ # git_commit {"message": ["fix"], "all": True, ...}
37
+ # tee {"paths": ["log"], ...}
38
+ # cat {"paths": ["VERSION"], ...} <- runs before the echo that consumes it
39
+ # echo {"words": ["$(cat VERSION)"], ...}
40
+ ```
41
+
42
+ The library only translates. Executing the command line stays with the caller.
43
+
44
+ ## Install
45
+
46
+ Paste this into your coding agent (Claude Code, Codex, Copilot CLI, Mistral Vibe...):
47
+
48
+ ```
49
+ 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.
50
+ ```
51
+
52
+ Or install manually:
53
+
54
+ ```bash
55
+ pip install -e ".[dev]"
56
+ pip install -e /path/to/AgentLTL # only for cli_to_tools.agentltl
57
+ pytest
58
+ ```
59
+
60
+ ## How it works
61
+
62
+ 1. **Shell layer** – the command line is parsed with tree-sitter-bash and the parse tree is
63
+ walked in execution order: `;`, `&&`, `||` and `|` left to right; commands inside `$(...)`,
64
+ backticks and `<(...)` before the command that consumes them; subshells and `{ ...; }` groups
65
+ flattened.
66
+ 2. **Specs** – each simple command is matched against a command spec, which gives it a tool name
67
+ (`git_commit`, `docker_compose_up`) and flat named arguments.
68
+ 3. **Fallback** – a command without a spec becomes `tool_name=<executable>`,
69
+ `arguments={"argv": [...]}`.
70
+
71
+ Each `ToolCall` has `name`, `args`, `id` and `meta`. `to_dict()` returns AgentLTL's trace format,
72
+ `{"tool_name", "arguments", "id", "cli": meta}`, where `meta` holds:
73
+
74
+ | key | meaning |
75
+ |---|---|
76
+ | `command`, `source`, `index` | the full command line, this command's text, its position |
77
+ | `operator` | connector to the previous call: `;` `&&` `\|\|` `\|` `$()` `wrap` or `None` |
78
+ | `conditional` | `True` if the call may be skipped at run time (after `&&` / `\|\|`, inside `if` / `case`, a loop body that may run zero times) |
79
+ | `repeated` | `True` if the call sits in a loop that could not be unrolled: listed once, may run any number of times |
80
+ | `pipeline`, `depth` | pipeline id; nesting depth (subshell, substitution, wrapper) |
81
+ | `wrapper` | the wrapper this call runs under (`sudo`, `xargs`, `bash`, `find`, `python -m`) |
82
+ | `redirects`, `env` | `[{op, target, fd?}]` and `VAR=value` prefixes |
83
+ | `dynamic_args` | arguments the shell would expand (`$X`, globs, `$(...)`, `~`) |
84
+ | `spec_matched` | `False` if there was no spec or the spec did not fully cover the argv |
85
+
86
+ Conditional chains are over-approximated: every command that *could* run is emitted.
87
+
88
+ ### Control flow
89
+
90
+ | construct | translation |
91
+ |---|---|
92
+ | `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 |
93
+ | `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 |
94
+ | `if` / `elif` / `else`, `case` | conditions, then **every** branch, flagged `conditional` |
95
+
96
+ The second and third rows are over-approximations. They are sound for constraints on names and
97
+ order ("never call X", `Before`), but not for counts (`CalledNTimes`) or exact argument values,
98
+ because the number of iterations and the run-time values are unknown. `Translator(strict=True)`
99
+ rejects those constructs instead; literal `for` loops are exact and stay allowed.
100
+
101
+ ### Wrappers
102
+
103
+ Commands that run other commands are unwrapped, so they cannot hide a call: `sudo`, `doas`,
104
+ `env`, `time`, `timeout`, `nohup`, `setsid`, `nice`, `stdbuf`, `watch`, `exec`, `command`,
105
+ `builtin`, `xargs`, `find -exec`, `python -m <module>`, and `bash -c '<script>'` (the script is
106
+ parsed recursively). The wrapper is emitted first, then the wrapped call.
107
+
108
+ Not unwrapped: commands that run a command string elsewhere or in another language
109
+ (`ssh host '<cmd>'`, `docker exec ... <cmd>`, `python -c`, `make`, `npx`, scripts such as
110
+ `bash script.sh`). These appear as one call with the command visible in its arguments; forbid or
111
+ restrict them in your constraints if that matters.
112
+
113
+ ### What commands do to files
114
+
115
+ `Translator(shell_effects=True, paths=Paths(cwd, root))` adds what a guard needs on top of the
116
+ arguments themselves:
117
+
118
+ | Argument | What it holds |
119
+ |---|---|
120
+ | `redirect_to`, `redirect_from` | files a redirection writes (`> f`, `>> f`, `2> f`, `cat <<EOF > f`) or reads (`< f`) |
121
+ | `overwrite_to` | the redirections that truncate (`>`, `&>`; not `>>`, nor `/dev/null`) |
122
+ | `unknown_paths` | path arguments only known at run time: `True` under `xargs`, else a list (`rm $UNSET` → `["paths"]`) |
123
+ | `paths` of `patch` / `git apply` | the files the diff modifies |
124
+ | `output` of `curl -O` | the file it writes |
125
+
126
+ Path arguments are made absolute, resolved from the directory the line has moved to: in
127
+ `cd sub && rm a`, `a` is `sub/a`. A `cd` lasts until the end of its subshell, one in a pipeline
128
+ changes nothing, and after `cd $D` relative paths are unknown. Which arguments hold paths:
129
+ those named in `PATH_NAMES` (`path`, `paths`, `file`, `destination`, ...), plus any a spec
130
+ marks `path: true` (and minus those it marks `path: false`). Tool schemas show them with
131
+ `"format": "path"`.
132
+
133
+ ### Fail closed
134
+
135
+ `translate()` raises `TranslationError` instead of guessing on: syntax errors, function
136
+ definitions, background jobs (`&`), `eval` / `source` / `.`, a non-static command name
137
+ (`$CMD args`, including a loop variable that cannot be resolved), a dynamic `bash -c "$X"`, and a
138
+ shell reading its script from stdin (`curl ... | sh`).
139
+
140
+ ## Specs
141
+
142
+ These packs are bundled and loaded by default:
143
+
144
+ | Pack | Commands |
145
+ |---|---|
146
+ | `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`) |
147
+ | `files` | other file writers: `perl`, `ed`, `vim`, `nano`, `code`, `dd`, `install`, `truncate`, `shred`, `split`, `sponge`, `patch`, archives (`unzip`, `zip`, `7z`) |
148
+ | `git` | `git`, including `git credential` and `git submodule` |
149
+ | `docker` | `docker` (including `inspect`, `login`), with `docker compose` / `docker-compose` (including `config`) |
150
+ | `python` | `python`, `pip`, `pytest` |
151
+ | `network` | `curl`, `wget`, `ssh`, `scp`, `rsync` |
152
+ | `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` |
153
+ | `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`) |
154
+
155
+ They cover the commonly used subcommands and flags, not complete man pages:
156
+ - **Unknown flags** are kept in `extra_args`.
157
+ - **An undeclared subcommand** still gets a predictable name: `git frob x` becomes `git_frob`,
158
+ with `{"argv": [...]}`.
159
+ - **Global flags:** options a CLI accepts anywhere are marked `global: true`, so
160
+ `kubectl delete pod x -n prod` records `namespace: prod`. That applies to kubectl's `-n`
161
+ and `--context`, gh's `-R`, and aws's `--profile` and `--region`.
162
+
163
+ ```python
164
+ from cli_to_tools import CommandSpec, SpecRegistry, Translator
165
+
166
+ registry = SpecRegistry() # all packs; SpecRegistry(packs=["shell", "git"]) to pick
167
+ registry.load_yaml("my_specs.yaml") # add or override from YAML
168
+
169
+ deploy = registry.register(CommandSpec("deploy")) # or in Python: plain argparse arguments
170
+ deploy.add_argument("-e", "--env", dest="environment")
171
+ deploy.add_argument("--force", action="store_true")
172
+ deploy.add_argument("services", nargs="*")
173
+
174
+ translator = Translator(registry)
175
+ ```
176
+
177
+ ```yaml
178
+ # my_specs.yaml
179
+ deploy:
180
+ tool: deploy # optional tool name override
181
+ aliases: [dpl]
182
+ posix: false # true: options stop at the first positional (docker run IMAGE CMD...)
183
+ options:
184
+ - {flags: [-e, --env], dest: environment}
185
+ - {flags: [--force], type: bool} # str (default) | bool | count | list | int
186
+ - {flags: [--mode], choices: [fast, safe], help: "How to deploy."} # optional, for tool schemas
187
+ - {flags: [-n, --namespace], global: true} # also accepted after a subcommand
188
+ - {flags: [--manifest], path: true} # holds a file path (see shell_effects)
189
+ positionals:
190
+ - {name: services, nargs: "*"}
191
+ subcommands: {} # nested specs, same schema -> tool "deploy_<name>"
192
+
193
+ kubectl:
194
+ extend: true # add to the bundled kubectl spec instead of replacing it
195
+ subcommands:
196
+ rollout: {positionals: [{name: action}, {name: resource}]}
197
+ ```
198
+
199
+ A later spec for the same command replaces the earlier one, unless it says `extend: true`:
200
+ then its options are merged by flag and its subcommands one by one.
201
+ `registry.load_dict(specs, extend=True)` extends by default.
202
+
203
+ Boolean flags are always present in the arguments (`False` when absent), so both
204
+ `CalledWith("rm", {"recursive": True})` and `CalledWith("git_push", {"force": False})` work.
205
+
206
+ ## AgentLTL
207
+
208
+ `ShellEnforcer` is an `agentltl.Enforcer` (AgentLTL 0.2 or later). Calls to a shell tool are
209
+ expanded into the structured calls of the command line and checked with
210
+ `Enforcer.check_chain`; every other tool is checked unchanged.
211
+
212
+ ```python
213
+ from agentltl import Before, CalledWith, Constraint, Globally, Not
214
+ from cli_to_tools.agentltl import ShellEnforcer
215
+
216
+ enforcer = ShellEnforcer(
217
+ [
218
+ Constraint("commit_before_push", Before("git_commit", "git_push")),
219
+ Constraint("no_force_push", Globally(Not(CalledWith("git_push", {"force": True})))),
220
+ ],
221
+ shell_tools={"bash": "command"}, # tool name -> argument holding the command line
222
+ )
223
+
224
+ decision = enforcer.check("bash", {"command": "git push && git commit -m x"})
225
+ decision.action, decision.index # ("stop", 0): the first command, git push
226
+ ```
227
+
228
+ `CliConstraintEnforcer` is the same with AgentLTL's original `ConstraintEnforcer` interface
229
+ (`"allow"` or `(kind, feedback)`, raising on a stop).
230
+
231
+ - **All or nothing.** The calls of a chain are checked in order, each against a trace that
232
+ already contains the earlier calls of the same chain. If any call is blocked, the whole command
233
+ line is rejected and nothing is recorded, so nothing should be executed.
234
+ - **Untranslatable** command lines are blocked with feedback telling the model how to rephrase,
235
+ and listed in `enforcer.untranslatable`.
236
+ - **`record_completed`** stores one trace entry per structured call (with the `cli` metadata, so
237
+ `Predicate`s can read `call.raw["cli"]`). The tool result and exit status (`status=`) are
238
+ attached to the last call.
239
+ - **Native backend:** with AgentLTL's `cli` extra, pass `shell_tools={"bash": "command"}` to
240
+ `AgentWithConstraints(backend="native")`, `MultiTurnAgent` or `NativeOpenAIAgent`; the agent
241
+ then uses this enforcer and emits the structured calls in `metrics["tool_calls"]`. On an
242
+ AgentLTL without that option: `agent._enforcer = CliConstraintEnforcer.from_enforcer(agent._enforcer)`.
243
+ - **Post hoc:** `expand_tool_calls(metrics["tool_calls"])` rewrites a recorded trace before
244
+ `verify_trace`.
245
+
246
+ See `examples/02_agentltl_guard.py` for a runnable walk-through.
@@ -0,0 +1,227 @@
1
+ # cli-to-tools
2
+
3
+ Translate shell command lines into ordered, structured tool calls, so that an agent working
4
+ through a single `bash(command=...)` tool can be constrained by a tool-call harness such as
5
+ [AgentLTL](https://github.com/AgentLTL/AgentLTL).
6
+
7
+ 📖 **Documentation: [agentltl.github.io/shell](https://agentltl.github.io/shell/)**, including
8
+ every [supported command](https://agentltl.github.io/shell/commands/) with its tools and
9
+ arguments.
10
+
11
+ ```python
12
+ from cli_to_tools import Translator
13
+
14
+ for call in Translator().translate('git add . && git commit -am "fix" | tee log; echo $(cat VERSION)'):
15
+ print(call.name, call.args)
16
+ # git_add {"paths": ["."], ...}
17
+ # git_commit {"message": ["fix"], "all": True, ...}
18
+ # tee {"paths": ["log"], ...}
19
+ # cat {"paths": ["VERSION"], ...} <- runs before the echo that consumes it
20
+ # echo {"words": ["$(cat VERSION)"], ...}
21
+ ```
22
+
23
+ The library only translates. Executing the command line stays with the caller.
24
+
25
+ ## Install
26
+
27
+ Paste this into your coding agent (Claude Code, Codex, Copilot CLI, Mistral Vibe...):
28
+
29
+ ```
30
+ 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.
31
+ ```
32
+
33
+ Or install manually:
34
+
35
+ ```bash
36
+ pip install -e ".[dev]"
37
+ pip install -e /path/to/AgentLTL # only for cli_to_tools.agentltl
38
+ pytest
39
+ ```
40
+
41
+ ## How it works
42
+
43
+ 1. **Shell layer** – the command line is parsed with tree-sitter-bash and the parse tree is
44
+ walked in execution order: `;`, `&&`, `||` and `|` left to right; commands inside `$(...)`,
45
+ backticks and `<(...)` before the command that consumes them; subshells and `{ ...; }` groups
46
+ flattened.
47
+ 2. **Specs** – each simple command is matched against a command spec, which gives it a tool name
48
+ (`git_commit`, `docker_compose_up`) and flat named arguments.
49
+ 3. **Fallback** – a command without a spec becomes `tool_name=<executable>`,
50
+ `arguments={"argv": [...]}`.
51
+
52
+ Each `ToolCall` has `name`, `args`, `id` and `meta`. `to_dict()` returns AgentLTL's trace format,
53
+ `{"tool_name", "arguments", "id", "cli": meta}`, where `meta` holds:
54
+
55
+ | key | meaning |
56
+ |---|---|
57
+ | `command`, `source`, `index` | the full command line, this command's text, its position |
58
+ | `operator` | connector to the previous call: `;` `&&` `\|\|` `\|` `$()` `wrap` or `None` |
59
+ | `conditional` | `True` if the call may be skipped at run time (after `&&` / `\|\|`, inside `if` / `case`, a loop body that may run zero times) |
60
+ | `repeated` | `True` if the call sits in a loop that could not be unrolled: listed once, may run any number of times |
61
+ | `pipeline`, `depth` | pipeline id; nesting depth (subshell, substitution, wrapper) |
62
+ | `wrapper` | the wrapper this call runs under (`sudo`, `xargs`, `bash`, `find`, `python -m`) |
63
+ | `redirects`, `env` | `[{op, target, fd?}]` and `VAR=value` prefixes |
64
+ | `dynamic_args` | arguments the shell would expand (`$X`, globs, `$(...)`, `~`) |
65
+ | `spec_matched` | `False` if there was no spec or the spec did not fully cover the argv |
66
+
67
+ Conditional chains are over-approximated: every command that *could* run is emitted.
68
+
69
+ ### Control flow
70
+
71
+ | construct | translation |
72
+ |---|---|
73
+ | `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 |
74
+ | `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 |
75
+ | `if` / `elif` / `else`, `case` | conditions, then **every** branch, flagged `conditional` |
76
+
77
+ The second and third rows are over-approximations. They are sound for constraints on names and
78
+ order ("never call X", `Before`), but not for counts (`CalledNTimes`) or exact argument values,
79
+ because the number of iterations and the run-time values are unknown. `Translator(strict=True)`
80
+ rejects those constructs instead; literal `for` loops are exact and stay allowed.
81
+
82
+ ### Wrappers
83
+
84
+ Commands that run other commands are unwrapped, so they cannot hide a call: `sudo`, `doas`,
85
+ `env`, `time`, `timeout`, `nohup`, `setsid`, `nice`, `stdbuf`, `watch`, `exec`, `command`,
86
+ `builtin`, `xargs`, `find -exec`, `python -m <module>`, and `bash -c '<script>'` (the script is
87
+ parsed recursively). The wrapper is emitted first, then the wrapped call.
88
+
89
+ Not unwrapped: commands that run a command string elsewhere or in another language
90
+ (`ssh host '<cmd>'`, `docker exec ... <cmd>`, `python -c`, `make`, `npx`, scripts such as
91
+ `bash script.sh`). These appear as one call with the command visible in its arguments; forbid or
92
+ restrict them in your constraints if that matters.
93
+
94
+ ### What commands do to files
95
+
96
+ `Translator(shell_effects=True, paths=Paths(cwd, root))` adds what a guard needs on top of the
97
+ arguments themselves:
98
+
99
+ | Argument | What it holds |
100
+ |---|---|
101
+ | `redirect_to`, `redirect_from` | files a redirection writes (`> f`, `>> f`, `2> f`, `cat <<EOF > f`) or reads (`< f`) |
102
+ | `overwrite_to` | the redirections that truncate (`>`, `&>`; not `>>`, nor `/dev/null`) |
103
+ | `unknown_paths` | path arguments only known at run time: `True` under `xargs`, else a list (`rm $UNSET` → `["paths"]`) |
104
+ | `paths` of `patch` / `git apply` | the files the diff modifies |
105
+ | `output` of `curl -O` | the file it writes |
106
+
107
+ Path arguments are made absolute, resolved from the directory the line has moved to: in
108
+ `cd sub && rm a`, `a` is `sub/a`. A `cd` lasts until the end of its subshell, one in a pipeline
109
+ changes nothing, and after `cd $D` relative paths are unknown. Which arguments hold paths:
110
+ those named in `PATH_NAMES` (`path`, `paths`, `file`, `destination`, ...), plus any a spec
111
+ marks `path: true` (and minus those it marks `path: false`). Tool schemas show them with
112
+ `"format": "path"`.
113
+
114
+ ### Fail closed
115
+
116
+ `translate()` raises `TranslationError` instead of guessing on: syntax errors, function
117
+ definitions, background jobs (`&`), `eval` / `source` / `.`, a non-static command name
118
+ (`$CMD args`, including a loop variable that cannot be resolved), a dynamic `bash -c "$X"`, and a
119
+ shell reading its script from stdin (`curl ... | sh`).
120
+
121
+ ## Specs
122
+
123
+ These packs are bundled and loaded by default:
124
+
125
+ | Pack | Commands |
126
+ |---|---|
127
+ | `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`) |
128
+ | `files` | other file writers: `perl`, `ed`, `vim`, `nano`, `code`, `dd`, `install`, `truncate`, `shred`, `split`, `sponge`, `patch`, archives (`unzip`, `zip`, `7z`) |
129
+ | `git` | `git`, including `git credential` and `git submodule` |
130
+ | `docker` | `docker` (including `inspect`, `login`), with `docker compose` / `docker-compose` (including `config`) |
131
+ | `python` | `python`, `pip`, `pytest` |
132
+ | `network` | `curl`, `wget`, `ssh`, `scp`, `rsync` |
133
+ | `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` |
134
+ | `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`) |
135
+
136
+ They cover the commonly used subcommands and flags, not complete man pages:
137
+ - **Unknown flags** are kept in `extra_args`.
138
+ - **An undeclared subcommand** still gets a predictable name: `git frob x` becomes `git_frob`,
139
+ with `{"argv": [...]}`.
140
+ - **Global flags:** options a CLI accepts anywhere are marked `global: true`, so
141
+ `kubectl delete pod x -n prod` records `namespace: prod`. That applies to kubectl's `-n`
142
+ and `--context`, gh's `-R`, and aws's `--profile` and `--region`.
143
+
144
+ ```python
145
+ from cli_to_tools import CommandSpec, SpecRegistry, Translator
146
+
147
+ registry = SpecRegistry() # all packs; SpecRegistry(packs=["shell", "git"]) to pick
148
+ registry.load_yaml("my_specs.yaml") # add or override from YAML
149
+
150
+ deploy = registry.register(CommandSpec("deploy")) # or in Python: plain argparse arguments
151
+ deploy.add_argument("-e", "--env", dest="environment")
152
+ deploy.add_argument("--force", action="store_true")
153
+ deploy.add_argument("services", nargs="*")
154
+
155
+ translator = Translator(registry)
156
+ ```
157
+
158
+ ```yaml
159
+ # my_specs.yaml
160
+ deploy:
161
+ tool: deploy # optional tool name override
162
+ aliases: [dpl]
163
+ posix: false # true: options stop at the first positional (docker run IMAGE CMD...)
164
+ options:
165
+ - {flags: [-e, --env], dest: environment}
166
+ - {flags: [--force], type: bool} # str (default) | bool | count | list | int
167
+ - {flags: [--mode], choices: [fast, safe], help: "How to deploy."} # optional, for tool schemas
168
+ - {flags: [-n, --namespace], global: true} # also accepted after a subcommand
169
+ - {flags: [--manifest], path: true} # holds a file path (see shell_effects)
170
+ positionals:
171
+ - {name: services, nargs: "*"}
172
+ subcommands: {} # nested specs, same schema -> tool "deploy_<name>"
173
+
174
+ kubectl:
175
+ extend: true # add to the bundled kubectl spec instead of replacing it
176
+ subcommands:
177
+ rollout: {positionals: [{name: action}, {name: resource}]}
178
+ ```
179
+
180
+ A later spec for the same command replaces the earlier one, unless it says `extend: true`:
181
+ then its options are merged by flag and its subcommands one by one.
182
+ `registry.load_dict(specs, extend=True)` extends by default.
183
+
184
+ Boolean flags are always present in the arguments (`False` when absent), so both
185
+ `CalledWith("rm", {"recursive": True})` and `CalledWith("git_push", {"force": False})` work.
186
+
187
+ ## AgentLTL
188
+
189
+ `ShellEnforcer` is an `agentltl.Enforcer` (AgentLTL 0.2 or later). Calls to a shell tool are
190
+ expanded into the structured calls of the command line and checked with
191
+ `Enforcer.check_chain`; every other tool is checked unchanged.
192
+
193
+ ```python
194
+ from agentltl import Before, CalledWith, Constraint, Globally, Not
195
+ from cli_to_tools.agentltl import ShellEnforcer
196
+
197
+ enforcer = ShellEnforcer(
198
+ [
199
+ Constraint("commit_before_push", Before("git_commit", "git_push")),
200
+ Constraint("no_force_push", Globally(Not(CalledWith("git_push", {"force": True})))),
201
+ ],
202
+ shell_tools={"bash": "command"}, # tool name -> argument holding the command line
203
+ )
204
+
205
+ decision = enforcer.check("bash", {"command": "git push && git commit -m x"})
206
+ decision.action, decision.index # ("stop", 0): the first command, git push
207
+ ```
208
+
209
+ `CliConstraintEnforcer` is the same with AgentLTL's original `ConstraintEnforcer` interface
210
+ (`"allow"` or `(kind, feedback)`, raising on a stop).
211
+
212
+ - **All or nothing.** The calls of a chain are checked in order, each against a trace that
213
+ already contains the earlier calls of the same chain. If any call is blocked, the whole command
214
+ line is rejected and nothing is recorded, so nothing should be executed.
215
+ - **Untranslatable** command lines are blocked with feedback telling the model how to rephrase,
216
+ and listed in `enforcer.untranslatable`.
217
+ - **`record_completed`** stores one trace entry per structured call (with the `cli` metadata, so
218
+ `Predicate`s can read `call.raw["cli"]`). The tool result and exit status (`status=`) are
219
+ attached to the last call.
220
+ - **Native backend:** with AgentLTL's `cli` extra, pass `shell_tools={"bash": "command"}` to
221
+ `AgentWithConstraints(backend="native")`, `MultiTurnAgent` or `NativeOpenAIAgent`; the agent
222
+ then uses this enforcer and emits the structured calls in `metrics["tool_calls"]`. On an
223
+ AgentLTL without that option: `agent._enforcer = CliConstraintEnforcer.from_enforcer(agent._enforcer)`.
224
+ - **Post hoc:** `expand_tool_calls(metrics["tool_calls"])` rewrites a recorded trace before
225
+ `verify_trace`.
226
+
227
+ See `examples/02_agentltl_guard.py` for a runnable walk-through.
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cli-to-tools"
7
+ version = "0.2.1"
8
+ description = "Translate shell command lines into ordered, structured tool calls."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ dependencies = [
13
+ "tree-sitter>=0.23",
14
+ "tree-sitter-bash>=0.23",
15
+ "pyyaml>=6.0",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ agentltl = [
20
+ "agentltl>=0.2",
21
+ ]
22
+ dev = [
23
+ "pytest>=7.0",
24
+ "ruff>=0.4",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://agentltl.github.io/shell/"
29
+ Source = "https://github.com/AgentLTL/cli-to-tools"
30
+ Issues = "https://github.com/AgentLTL/cli-to-tools/issues"
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
34
+
35
+ [tool.setuptools.package-data]
36
+ cli_to_tools = ["packs/*.yaml"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
40
+
41
+ [tool.ruff]
42
+ line-length = 100
43
+ target-version = "py310"
44
+
45
+ [tool.ruff.lint]
46
+ select = ["E", "F", "W", "I"]
47
+ ignore = ["E501"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,33 @@
1
+ """
2
+ cli_to_tools – translate shell command lines into ordered, structured tool calls.
3
+
4
+ Example::
5
+
6
+ from cli_to_tools import Translator
7
+
8
+ for call in Translator().translate("git add . && git commit -m 'x' | tee log"):
9
+ print(call.name, call.args)
10
+ """
11
+
12
+ from ._effects import PATH_NAMES, UNKNOWN_PATHS, Paths, normalize_paths
13
+ from ._model import CommandNode, ToolCall, TranslationError, Word
14
+ from ._shell import parse_command_line
15
+ from ._spec import CommandSpec, SpecRegistry, available_packs, normalize_tool_name
16
+ from ._translate import Translator
17
+
18
+ __all__ = [
19
+ "PATH_NAMES",
20
+ "Paths",
21
+ "UNKNOWN_PATHS",
22
+ "normalize_paths",
23
+ "CommandNode",
24
+ "CommandSpec",
25
+ "SpecRegistry",
26
+ "ToolCall",
27
+ "TranslationError",
28
+ "Translator",
29
+ "Word",
30
+ "available_packs",
31
+ "normalize_tool_name",
32
+ "parse_command_line",
33
+ ]