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.
@@ -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
+ ]
@@ -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}