cli-to-tools 0.2.1__py3-none-any.whl
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.
- cli_to_tools/__init__.py +33 -0
- cli_to_tools/_effects.py +241 -0
- cli_to_tools/_model.py +66 -0
- cli_to_tools/_shell.py +710 -0
- cli_to_tools/_spec.py +543 -0
- cli_to_tools/_translate.py +95 -0
- cli_to_tools/agentltl.py +198 -0
- cli_to_tools/packs/cloud.yaml +416 -0
- cli_to_tools/packs/docker.yaml +182 -0
- cli_to_tools/packs/files.yaml +136 -0
- cli_to_tools/packs/git.yaml +233 -0
- cli_to_tools/packs/network.yaml +63 -0
- cli_to_tools/packs/python.yaml +51 -0
- cli_to_tools/packs/secrets.yaml +156 -0
- cli_to_tools/packs/shell.yaml +344 -0
- cli_to_tools-0.2.1.dist-info/METADATA +246 -0
- cli_to_tools-0.2.1.dist-info/RECORD +19 -0
- cli_to_tools-0.2.1.dist-info/WHEEL +5 -0
- cli_to_tools-0.2.1.dist-info/top_level.txt +1 -0
cli_to_tools/__init__.py
ADDED
|
@@ -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
|
+
]
|
cli_to_tools/_effects.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli_to_tools/_effects.py – what a command does to files, beyond its own arguments.
|
|
3
|
+
|
|
4
|
+
With ``Translator(shell_effects=True)`` each call also says:
|
|
5
|
+
|
|
6
|
+
- ``redirect_to`` / ``redirect_from``: files a redirection writes (``> f``, ``>> f``,
|
|
7
|
+
``2> f``, ``&> f``, ``cat <<EOF > f``) or reads (``< f``); ``overwrite_to``: those it
|
|
8
|
+
truncates first (``>``, ``&>``; not ``>>``, nor ``/dev/null`` and other devices);
|
|
9
|
+
- path arguments made absolute, resolved from the directory the command line has moved
|
|
10
|
+
to: in ``cd sub && rm a``, ``a`` is ``sub/a``. A ``cd`` lasts until the end of its
|
|
11
|
+
subshell (or ``bash -c``); one in a pipeline changes nothing (each part is a subshell);
|
|
12
|
+
- ``unknown_paths``: arguments whose files are only known at run time. ``True`` when any
|
|
13
|
+
path argument may be (``xargs rm``, ``find -exec rm {}``), else the list of those that
|
|
14
|
+
hold one (``rm $UNSET`` gives ``["paths"]``; after ``cd $D`` every relative path is);
|
|
15
|
+
- for ``patch`` / ``git apply``, the files the diff modifies, as ``paths``;
|
|
16
|
+
- for ``curl -O``, the file it writes, as ``output``.
|
|
17
|
+
|
|
18
|
+
Which arguments hold paths: those a spec marks ``path: true``, and by default those named
|
|
19
|
+
in :data:`PATH_NAMES` (a spec can say ``path: false``).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
27
|
+
|
|
28
|
+
# Argument names that hold file paths unless a spec says otherwise.
|
|
29
|
+
PATH_NAMES = ("path", "paths", "file", "files", "directory", "sources", "destination",
|
|
30
|
+
"destination_dir", "of", "if", "archive", "input", "patchfile", "patches",
|
|
31
|
+
"script_file", "program_file", "output", "file_path", "notebook_path")
|
|
32
|
+
# Added by the shell effects themselves.
|
|
33
|
+
REDIRECT_KEYS = ("redirect_to", "overwrite_to", "redirect_from")
|
|
34
|
+
UNKNOWN_PATHS = "unknown_paths"
|
|
35
|
+
_DEVICES = ("/dev/null", "/dev/stdout", "/dev/stderr", "/dev/tty")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Paths:
|
|
40
|
+
"""Where relative paths are resolved from (``cwd``), and the project root, if any."""
|
|
41
|
+
|
|
42
|
+
cwd: str = ""
|
|
43
|
+
root: str = ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_device(target: str) -> bool:
|
|
47
|
+
"""Writing to these discards or shows the output; it overwrites no file."""
|
|
48
|
+
return target in _DEVICES or target.startswith("/dev/fd/")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def redirect_files(redirects: Sequence[Dict[str, str]]) -> Tuple[List[str], List[str], List[str]]:
|
|
52
|
+
"""``(writes, reads, truncates)`` of a command's redirections."""
|
|
53
|
+
writes: List[str] = []
|
|
54
|
+
reads: List[str] = []
|
|
55
|
+
truncates: List[str] = []
|
|
56
|
+
for r in redirects:
|
|
57
|
+
op, target = r.get("op", ""), r.get("target", "")
|
|
58
|
+
if not target:
|
|
59
|
+
continue
|
|
60
|
+
if op in (">&", "<&") and (target.isdigit() or target == "-"):
|
|
61
|
+
continue # 2>&1: copies a descriptor, no file
|
|
62
|
+
if op in (">", ">>", ">|", "&>", "&>>", ">&"):
|
|
63
|
+
writes.append(target)
|
|
64
|
+
if op in (">", ">|", "&>", ">&") and not is_device(target):
|
|
65
|
+
truncates.append(target)
|
|
66
|
+
elif op == "<":
|
|
67
|
+
reads.append(target)
|
|
68
|
+
return writes, reads, truncates
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def absolute(value: str, paths: Paths) -> str:
|
|
72
|
+
return os.path.normpath(os.path.join(paths.cwd or os.getcwd(), os.path.expanduser(value)))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def canonical(value: str, paths: Paths) -> str:
|
|
76
|
+
"""A path argument made absolute; flags, URLs and unexpanded variables are left alone."""
|
|
77
|
+
home = os.path.expanduser("~")
|
|
78
|
+
for prefix in ("${HOME}", "$HOME"):
|
|
79
|
+
if value == prefix or value.startswith(prefix + "/"):
|
|
80
|
+
value = home + value[len(prefix):]
|
|
81
|
+
if not value or value.startswith(("-", "http://", "https://", "$")) or "://" in value:
|
|
82
|
+
return value
|
|
83
|
+
return absolute(value, paths)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def normalize_paths(args: Dict[str, Any], paths: Paths,
|
|
87
|
+
keys: Iterable[str] = PATH_NAMES + REDIRECT_KEYS) -> Dict[str, Any]:
|
|
88
|
+
"""*args* with the path arguments among *keys* made absolute."""
|
|
89
|
+
out = dict(args)
|
|
90
|
+
for key in keys:
|
|
91
|
+
value = out.get(key)
|
|
92
|
+
if isinstance(value, str):
|
|
93
|
+
out[key] = canonical(value, paths)
|
|
94
|
+
elif isinstance(value, list):
|
|
95
|
+
out[key] = [canonical(v, paths) if isinstance(v, str) else v for v in value]
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def strings(value: Any) -> List[str]:
|
|
100
|
+
if isinstance(value, str):
|
|
101
|
+
return [value]
|
|
102
|
+
if isinstance(value, (list, tuple)):
|
|
103
|
+
return [s for v in value for s in strings(v)]
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def unknown_path_keys(args: Dict[str, Any], keys: Iterable[str]) -> List[str]:
|
|
108
|
+
"""The path arguments among *keys* holding a value only known at run time."""
|
|
109
|
+
return [key for key in keys if any(
|
|
110
|
+
"$" in v or "`" in v or "{}" in v or "<(" in v for v in strings(args.get(key)))]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def relative_path_keys(args: Dict[str, Any], keys: Iterable[str]) -> List[str]:
|
|
114
|
+
"""The path arguments among *keys* holding a relative path."""
|
|
115
|
+
out = []
|
|
116
|
+
for key in keys:
|
|
117
|
+
if any(v and not v.startswith(("/", "~", "$", "-")) and "://" not in v
|
|
118
|
+
for v in strings(args.get(key))):
|
|
119
|
+
out.append(key)
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def cd_target(name: str, args: Dict[str, Any], here: Optional[str]) -> Optional[str]:
|
|
124
|
+
"""The directory ``cd`` / ``pushd`` / ``popd`` moves to; None when only known at run
|
|
125
|
+
time (``cd $D``, ``cd -``, ``popd``, or a relative move from an unknown directory)."""
|
|
126
|
+
if name == "popd":
|
|
127
|
+
return None
|
|
128
|
+
if name == "pushd":
|
|
129
|
+
words = [w for w in args.get("argv") or [] if not w.startswith("-")]
|
|
130
|
+
target = words[0] if words else None
|
|
131
|
+
if target is None or target.startswith("+"):
|
|
132
|
+
return None # swaps with the directory stack
|
|
133
|
+
else:
|
|
134
|
+
target = args.get("path")
|
|
135
|
+
if not target:
|
|
136
|
+
return os.path.expanduser("~")
|
|
137
|
+
if target == "-" or args.get(UNKNOWN_PATHS) or "$" in target or "`" in target:
|
|
138
|
+
return None
|
|
139
|
+
target = os.path.expanduser(target)
|
|
140
|
+
if os.path.isabs(target):
|
|
141
|
+
return os.path.normpath(target)
|
|
142
|
+
return None if here is None else os.path.normpath(os.path.join(here, target))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def patch_targets(name: str, args: Dict[str, Any], paths: Paths) -> None:
|
|
146
|
+
"""Read the diff a ``patch`` / ``git apply`` will apply and list the files it touches."""
|
|
147
|
+
diffs = [args.get("input"), args.get("patchfile"), *(args.get("patches") or []),
|
|
148
|
+
*(args.get("redirect_from") or [])]
|
|
149
|
+
diffs = [d for d in diffs if d]
|
|
150
|
+
strip = args.get("strip")
|
|
151
|
+
strips = [int(strip)] if str(strip or "").isdigit() else ([1] if name == "git_apply" else [0, 1])
|
|
152
|
+
found: List[str] = []
|
|
153
|
+
readable = bool(diffs)
|
|
154
|
+
for diff in diffs:
|
|
155
|
+
path = os.path.join(paths.cwd or os.getcwd(), os.path.expanduser(diff))
|
|
156
|
+
try:
|
|
157
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
158
|
+
text = fh.read(2_000_000)
|
|
159
|
+
except OSError:
|
|
160
|
+
readable = False
|
|
161
|
+
continue
|
|
162
|
+
for file in diff_files(text):
|
|
163
|
+
for n in strips:
|
|
164
|
+
parts = file.split("/")
|
|
165
|
+
if len(parts) > n:
|
|
166
|
+
found.append("/".join(parts[n:]))
|
|
167
|
+
if found:
|
|
168
|
+
args["paths"] = list(dict.fromkeys(found))
|
|
169
|
+
if not readable:
|
|
170
|
+
args[UNKNOWN_PATHS] = True
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def diff_files(text: str) -> List[str]:
|
|
174
|
+
"""The files a unified or git diff names."""
|
|
175
|
+
names: List[str] = []
|
|
176
|
+
for line in text.splitlines():
|
|
177
|
+
if line.startswith(("--- ", "+++ ")):
|
|
178
|
+
name = line[4:].split("\t")[0].strip()
|
|
179
|
+
elif line.startswith(("rename to ", "rename from ", "copy to ")):
|
|
180
|
+
name = line.split(" ", 2)[2].strip()
|
|
181
|
+
else:
|
|
182
|
+
continue
|
|
183
|
+
if name and name != "/dev/null":
|
|
184
|
+
names.append(name)
|
|
185
|
+
return list(dict.fromkeys(names))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def curl_output(args: Dict[str, Any]) -> None:
|
|
189
|
+
"""``curl -O URL`` writes the URL's basename (in ``--output-dir``, if given)."""
|
|
190
|
+
from urllib.parse import urlparse
|
|
191
|
+
names = [os.path.basename(urlparse(u).path) for u in args.get("urls") or []]
|
|
192
|
+
names = [n for n in names if n]
|
|
193
|
+
if names:
|
|
194
|
+
out_dir = args.get("output_dir") or ""
|
|
195
|
+
args["output"] = [os.path.join(out_dir, n) for n in names]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class Effects:
|
|
199
|
+
"""Applies the shell effects to the calls of one command line, in order."""
|
|
200
|
+
|
|
201
|
+
def __init__(self, paths: Optional[Paths]) -> None:
|
|
202
|
+
self.paths = paths
|
|
203
|
+
self._dirs: Dict[int, Optional[str]] = {} # subshell depth -> directory (None: unknown)
|
|
204
|
+
|
|
205
|
+
def apply(self, name: str, args: Dict[str, Any], meta: Dict[str, Any],
|
|
206
|
+
path_keys: Sequence[str]) -> Dict[str, Any]:
|
|
207
|
+
writes, reads, truncates = redirect_files(meta.get("redirects") or [])
|
|
208
|
+
if writes:
|
|
209
|
+
args["redirect_to"] = writes
|
|
210
|
+
if truncates:
|
|
211
|
+
args["overwrite_to"] = truncates
|
|
212
|
+
if reads:
|
|
213
|
+
args["redirect_from"] = reads
|
|
214
|
+
keys = tuple(dict.fromkeys([*path_keys, *REDIRECT_KEYS]))
|
|
215
|
+
here: Optional[str] = None
|
|
216
|
+
lost = False
|
|
217
|
+
if self.paths is not None:
|
|
218
|
+
depth = meta.get("depth") or 0
|
|
219
|
+
for d in [d for d in self._dirs if d > depth]:
|
|
220
|
+
del self._dirs[d] # left that subshell
|
|
221
|
+
here = self._dirs[max(self._dirs)] if self._dirs else self.paths.cwd
|
|
222
|
+
lost = here is None
|
|
223
|
+
paths = Paths(here or self.paths.cwd, self.paths.root)
|
|
224
|
+
relative = relative_path_keys(args, keys) if lost else []
|
|
225
|
+
if name in ("patch", "git_apply"):
|
|
226
|
+
patch_targets(name, args, paths)
|
|
227
|
+
if name == "curl" and args.get("remote_name") and not args.get("output"):
|
|
228
|
+
curl_output(args)
|
|
229
|
+
args = normalize_paths(args, paths, keys)
|
|
230
|
+
else:
|
|
231
|
+
relative = []
|
|
232
|
+
if meta.get("wrapper") == "xargs":
|
|
233
|
+
args[UNKNOWN_PATHS] = True
|
|
234
|
+
elif not args.get(UNKNOWN_PATHS):
|
|
235
|
+
unknown = list(dict.fromkeys(unknown_path_keys(args, keys) + relative))
|
|
236
|
+
if unknown:
|
|
237
|
+
args[UNKNOWN_PATHS] = unknown
|
|
238
|
+
if self.paths is not None and name in ("cd", "pushd", "popd") \
|
|
239
|
+
and not meta.get("pipeline"):
|
|
240
|
+
self._dirs[meta.get("depth") or 0] = cd_target(name, args, here)
|
|
241
|
+
return args
|
cli_to_tools/_model.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli_to_tools/_model.py – data types shared by the shell layer and the translator.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TranslationError(ValueError):
|
|
12
|
+
"""Raised when a command line cannot be fully translated (fail closed).
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
reason: Why the command line was rejected.
|
|
16
|
+
source: The offending fragment of the command line.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, reason: str, source: str = "") -> None:
|
|
20
|
+
self.reason = reason
|
|
21
|
+
self.source = source
|
|
22
|
+
super().__init__(f"{reason}: {source!r}" if source else reason)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Word:
|
|
27
|
+
"""One argv word after quote removal."""
|
|
28
|
+
|
|
29
|
+
text: str
|
|
30
|
+
static: bool = True
|
|
31
|
+
"""False when the shell would expand the word (``$VAR``, globs, ``$(...)``, ``~``)."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class CommandNode:
|
|
36
|
+
"""One simple command, in execution order, with its position in the chain."""
|
|
37
|
+
|
|
38
|
+
argv: List[Word]
|
|
39
|
+
source: str = ""
|
|
40
|
+
operator: Optional[str] = None
|
|
41
|
+
"""Connector to the previous command: ``;`` ``&&`` ``||`` ``|`` ``$()`` ``wrap``, or None."""
|
|
42
|
+
pipeline: Optional[int] = None
|
|
43
|
+
conditional: bool = False
|
|
44
|
+
"""True when the command may be skipped at run time (right-hand side of ``&&`` / ``||``)."""
|
|
45
|
+
depth: int = 0
|
|
46
|
+
redirects: List[Dict[str, str]] = field(default_factory=list)
|
|
47
|
+
env: Dict[str, str] = field(default_factory=dict)
|
|
48
|
+
wrapper: Optional[str] = None
|
|
49
|
+
"""Name of the wrapper command (``sudo``, ``xargs``, ``bash -c`` ...) this one runs under."""
|
|
50
|
+
repeated: bool = False
|
|
51
|
+
"""True when the command sits in a loop that could not be unrolled: it is listed once
|
|
52
|
+
but may run any number of times, with arguments only known at run time."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ToolCall:
|
|
57
|
+
"""A structured tool call derived from one simple command."""
|
|
58
|
+
|
|
59
|
+
name: str
|
|
60
|
+
args: Dict[str, Any] = field(default_factory=dict)
|
|
61
|
+
id: str = ""
|
|
62
|
+
meta: Dict[str, Any] = field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
65
|
+
"""Return the call in AgentLTL's trace dict format, with CLI metadata under ``cli``."""
|
|
66
|
+
return {"tool_name": self.name, "arguments": self.args, "id": self.id, "cli": self.meta}
|