toolrack-cli 0.1.0__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.
toolrack/completion.py ADDED
@@ -0,0 +1,351 @@
1
+ """Shell-completion fast path that answers from the metadata cache.
2
+
3
+ Importing Click and building the dynamic command tree dominates the latency of
4
+ a <TAB> press. When the command-metadata cache is current, the answer can be
5
+ computed from the cached entries alone, using only the standard library.
6
+
7
+ Design rules:
8
+
9
+ * The fast path never *guesses*. Anything it does not model exactly (unknown
10
+ tokens, option bundles, hidden alias groups, a stale or missing cache, ...)
11
+ returns ``None`` and the request is handled by Click as before.
12
+ * Repository discovery and cache validation come from ``toolrack.runtime`` so
13
+ they cannot drift from the CLI.
14
+ * ``tests/test_completion.py`` runs both implementations side by side and
15
+ requires byte-identical output, so Click upgrades that change completion
16
+ behaviour fail loudly instead of silently diverging.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import shlex
23
+ import sys
24
+ from collections.abc import Mapping
25
+
26
+ from toolrack import runtime
27
+
28
+ Completion = tuple[str, str, str] # (type, value, help)
29
+
30
+ _MODES = {"bash_complete", "zsh_complete", "fish_complete"}
31
+ _HELP = ("--help", "Show this message and exit.")
32
+ _VERSION = ("--version", "Show the version and exit.")
33
+
34
+ # Mirrors the `core` group in toolrack.cli; tests compare it with the real
35
+ # Click group so the two cannot diverge unnoticed.
36
+ CORE_COMMANDS: dict[str, dict] = {
37
+ "register": {
38
+ "description": "Register a script as a CLI subcommand.",
39
+ "args": [{"name": "script_path", "positional": True, "required": True}],
40
+ },
41
+ "auto-register": {
42
+ "description": "Scan scripts/ for sidecar+script pairs and register unregistered ones.",
43
+ "args": [
44
+ {
45
+ "name": "dry_run",
46
+ "flag": True,
47
+ "option": "--dry-run",
48
+ "help": "Show what would be registered without writing.",
49
+ }
50
+ ],
51
+ },
52
+ "unregister": {
53
+ "description": "Remove a script from the registry.",
54
+ "args": [{"name": "script_path", "positional": True, "required": True}],
55
+ },
56
+ "reregister": {"description": "Re-validate all registered commands."},
57
+ "refresh-cache": {"description": "Rebuild the command metadata cache next to the registry."},
58
+ "list": {"description": "List all registered scripts."},
59
+ "install-completion": {
60
+ "description": "Print the shell completion script to stdout.",
61
+ "args": [{"name": "shell", "positional": True, "choices": ["bash", "zsh", "fish"]}],
62
+ },
63
+ }
64
+ CORE_GROUP_HELP = "Registry management and shell-completion commands."
65
+
66
+
67
+ # --------------------------------------------------------------------------
68
+ # Request parsing
69
+ # --------------------------------------------------------------------------
70
+
71
+
72
+ def _split_words(value: str) -> list[str]:
73
+ lexer = shlex.shlex(value, posix=True)
74
+ lexer.whitespace_split = True
75
+ lexer.commenters = ""
76
+ try:
77
+ return list(lexer)
78
+ except ValueError:
79
+ return value.split()
80
+
81
+
82
+ def _request(shell: str, environ: Mapping[str, str]) -> tuple[list[str], str] | None:
83
+ """Return (args, incomplete) the way Click's shell classes derive them."""
84
+ try:
85
+ words = _split_words(environ["COMP_WORDS"])
86
+ if shell == "fish":
87
+ incomplete = environ.get("COMP_CWORD", "")
88
+ args = words[1:]
89
+ if incomplete and args and args[-1] == incomplete:
90
+ args.pop()
91
+ return args, incomplete
92
+ cword = int(environ["COMP_CWORD"])
93
+ except (KeyError, ValueError):
94
+ return None
95
+ return words[1:cword], words[cword] if cword < len(words) else ""
96
+
97
+
98
+ # --------------------------------------------------------------------------
99
+ # Command tree
100
+ # --------------------------------------------------------------------------
101
+
102
+
103
+ def _short_help(text: object, limit: int = 45) -> str:
104
+ """Equivalent of ``click.utils.make_default_short_help``."""
105
+ if not isinstance(text, str):
106
+ return ""
107
+ paragraph_end = text.find("\n\n")
108
+ if paragraph_end != -1:
109
+ text = text[:paragraph_end]
110
+ words = text.split()
111
+ if not words:
112
+ return ""
113
+ if words[0] == "\b":
114
+ words = words[1:]
115
+
116
+ total = 0
117
+ last = len(words) - 1
118
+ index = 0
119
+ for index, word in enumerate(words):
120
+ total += len(word) + (index > 0)
121
+ if total > limit:
122
+ break
123
+ if word[-1] == ".":
124
+ return " ".join(words[: index + 1])
125
+ if total == limit and index != last:
126
+ break
127
+ else:
128
+ return " ".join(words)
129
+
130
+ total += len("...")
131
+ while index > 0:
132
+ total -= len(words[index]) + (index > 0)
133
+ if total <= limit:
134
+ break
135
+ index -= 1
136
+ return " ".join(words[:index]) + "..."
137
+
138
+
139
+ def _new_node(help_text: str = "") -> dict:
140
+ return {"help": help_text, "groups": {}, "commands": {}}
141
+
142
+
143
+ def _cli_name(name: str) -> str:
144
+ return name.replace("_", "-")
145
+
146
+
147
+ def _group_help(entry_path: str, group_parts: list[str], depth: int) -> str:
148
+ """Reproduce the help text cli._build_cli_tree gives a generated group."""
149
+ name = group_parts[depth]
150
+ if depth == 0:
151
+ parts = entry_path.replace("\\", "/").split("/")
152
+ if parts[0] == "scripts":
153
+ parts = parts[1:]
154
+ original = parts[0] if len(parts) > 1 else name
155
+ folder = original if _cli_name(original) != name else name
156
+ return f"Commands from scripts/{folder}/"
157
+ return f"Commands from scripts/{'/'.join(group_parts[: depth + 1])}/"
158
+
159
+
160
+ def _build_tree(entries: list[dict]) -> dict | None:
161
+ root = _new_node()
162
+ root["groups"]["core"] = core = _new_node(CORE_GROUP_HELP)
163
+ core["commands"].update(CORE_COMMANDS)
164
+
165
+ for entry in entries:
166
+ if not isinstance(entry, dict):
167
+ return None
168
+ group_parts = entry.get("group_parts")
169
+ command_name = entry.get("cmd_name")
170
+ sidecar = entry.get("sidecar")
171
+ entry_path = entry.get("entry")
172
+ if not (
173
+ isinstance(group_parts, list)
174
+ and all(isinstance(part, str) for part in group_parts)
175
+ and isinstance(command_name, str)
176
+ and isinstance(sidecar, dict)
177
+ and isinstance(entry_path, str)
178
+ ):
179
+ return None
180
+
181
+ node = root
182
+ for depth, part in enumerate(group_parts):
183
+ if part not in node["groups"]:
184
+ node["groups"][part] = _new_node(_group_help(entry_path, group_parts, depth))
185
+ node = node["groups"][part]
186
+ node["commands"][command_name] = sidecar
187
+ return root
188
+
189
+
190
+ # --------------------------------------------------------------------------
191
+ # Completion
192
+ # --------------------------------------------------------------------------
193
+
194
+
195
+ def _arg_specs(sidecar: dict) -> list[dict] | None:
196
+ raw = sidecar.get("args") or []
197
+ if not isinstance(raw, list):
198
+ return None
199
+ specs = [arg for arg in raw if isinstance(arg, dict) and isinstance(arg.get("name"), str)]
200
+ return specs if len(specs) == len(raw) else None
201
+
202
+
203
+ def _option_name(arg: dict) -> str:
204
+ return arg.get("option") or "--" + arg["name"].replace("_", "-")
205
+
206
+
207
+ def _value_completions(arg: dict, incomplete: str) -> list[Completion]:
208
+ choices = arg.get("choices")
209
+ if choices:
210
+ return [("plain", str(c), "") for c in choices if str(c).startswith(incomplete)]
211
+ if arg.get("type") == "path":
212
+ return [("file", incomplete, "")]
213
+ return []
214
+
215
+
216
+ def _command_completions(
217
+ sidecar: dict, args: list[str], incomplete: str
218
+ ) -> list[Completion] | None:
219
+ specs = _arg_specs(sidecar)
220
+ if specs is None:
221
+ return None
222
+ options = {_option_name(a): a for a in specs if not a.get("positional")}
223
+ positionals = [a for a in specs if a.get("positional")]
224
+ used: set[str] = set()
225
+ positional_count = 0
226
+ pending: dict | None = None
227
+ parse_options = True
228
+
229
+ for token in args:
230
+ if pending is not None:
231
+ pending = None
232
+ continue
233
+ if parse_options and token == "--":
234
+ parse_options = False
235
+ elif parse_options and token.startswith("-") and token != "-":
236
+ name, separator, _value = token.partition("=")
237
+ option = options.get(name)
238
+ if option is None:
239
+ return None # --help, short-option bundles, unknown: let Click decide
240
+ used.add(name)
241
+ if not option.get("flag") and not separator:
242
+ pending = option
243
+ else:
244
+ positional_count += 1
245
+
246
+ if pending is not None:
247
+ return _value_completions(pending, incomplete)
248
+
249
+ if parse_options and incomplete.startswith("-"):
250
+ name, separator, value = incomplete.partition("=")
251
+ if separator:
252
+ option = options.get(name)
253
+ return None if option is None else _value_completions(option, value)
254
+ matches = [
255
+ ("plain", flag, str(spec.get("help") or ""))
256
+ for flag, spec in options.items()
257
+ if flag.startswith(incomplete)
258
+ and (spec.get("multiple") or flag not in used)
259
+ ]
260
+ if _HELP[0].startswith(incomplete) and _HELP[0] not in used:
261
+ matches.append(("plain", *_HELP))
262
+ return matches
263
+
264
+ if positional_count < len(positionals):
265
+ return _value_completions(positionals[positional_count], incomplete)
266
+ if positionals and positionals[-1].get("variadic"):
267
+ return _value_completions(positionals[-1], incomplete)
268
+ return []
269
+
270
+
271
+ def _node_completions(node: dict, incomplete: str, *, root: bool) -> list[Completion]:
272
+ if incomplete.startswith("-"):
273
+ flags = [_VERSION, _HELP] if root else [_HELP]
274
+ return [("plain", *flag) for flag in flags if flag[0].startswith(incomplete)]
275
+
276
+ items = [
277
+ ("plain", name, _short_help(group["help"])) for name, group in node["groups"].items()
278
+ ]
279
+ items += [
280
+ ("plain", name, _short_help(sidecar.get("description")))
281
+ for name, sidecar in node["commands"].items()
282
+ ]
283
+ return sorted(
284
+ (item for item in items if item[1].startswith(incomplete)), key=lambda item: item[1]
285
+ )
286
+
287
+
288
+ def complete(entries: list[dict], args: list[str], incomplete: str) -> list[Completion] | None:
289
+ """Return completions, or None when Click must answer instead."""
290
+ node = _build_tree(entries)
291
+ if node is None:
292
+ return None
293
+
294
+ root = True
295
+ for index, token in enumerate(args):
296
+ if token.startswith("-"):
297
+ return None
298
+ if token in node["groups"]:
299
+ node = node["groups"][token]
300
+ root = False
301
+ elif token in node["commands"]:
302
+ return _command_completions(node["commands"][token], args[index + 1 :], incomplete)
303
+ else:
304
+ return None # hidden alias group, typo, ...
305
+ return _node_completions(node, incomplete, root=root)
306
+
307
+
308
+ # --------------------------------------------------------------------------
309
+ # Output
310
+ # --------------------------------------------------------------------------
311
+
312
+
313
+ def _format(shell: str, item: Completion) -> str:
314
+ kind, value, help_text = item
315
+ if shell == "zsh":
316
+ if help_text:
317
+ value = value.replace(":", r"\:")
318
+ return f"{kind}\n{value}\n{help_text or '_'}"
319
+ if shell == "fish" and help_text:
320
+ return f"{kind},{value}\t{help_text}"
321
+ return f"{kind},{value}"
322
+
323
+
324
+ def try_fast_completion(environ: Mapping[str, str] = os.environ) -> bool:
325
+ """Answer a completion request from the cache. Return False to defer to Click."""
326
+ mode = environ.get(runtime.completion_var(runtime.cli_name(environ)))
327
+ if mode not in _MODES:
328
+ return False
329
+ shell = mode.removesuffix("_complete")
330
+
331
+ request = _request(shell, environ)
332
+ if request is None:
333
+ return False
334
+
335
+ repo = runtime.resolve_repository(environ)
336
+ try:
337
+ entries = runtime.read_cache(runtime.read_registry(repo), repo)
338
+ except (OSError, ValueError):
339
+ return False
340
+ if entries is None:
341
+ return False
342
+
343
+ items = complete(entries, *request)
344
+ if items is None:
345
+ return False
346
+
347
+ if os.name == "nt" and hasattr(sys.stdout, "reconfigure"):
348
+ sys.stdout.reconfigure(newline="", errors="replace")
349
+ sys.stdout.write("\n".join(_format(shell, item) for item in items) + "\n")
350
+ sys.stdout.flush()
351
+ return True
toolrack/entrypoint.py ADDED
@@ -0,0 +1,14 @@
1
+ """Process entry point with a lightweight shell-completion fast path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from toolrack.completion import try_fast_completion
6
+
7
+
8
+ def main() -> None:
9
+ if try_fast_completion():
10
+ return
11
+
12
+ from toolrack.cli import main as click_main
13
+
14
+ click_main()
toolrack/runtime.py ADDED
@@ -0,0 +1,178 @@
1
+ """Click-free runtime primitives shared by the CLI and the completion fast path.
2
+
3
+ Everything here must stay importable without Click or PyYAML: the shell
4
+ completion fast path (``toolrack.completion``) imports this module on every
5
+ <TAB> press, and the CLI imports it so both sides agree on how the repository
6
+ is located and how the command-metadata cache is validated.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import ntpath
13
+ import os
14
+ import sys
15
+ from collections import namedtuple
16
+ from collections.abc import Mapping
17
+
18
+ LEGACY_REGISTRY_BASENAME = ".attic"
19
+ DEFAULT_REGISTRY_BASENAME = ".toolrack"
20
+
21
+
22
+ # A namedtuple rather than a dataclass: importing `dataclasses` costs more than
23
+ # the rest of this module put together, and this runs on every <TAB>.
24
+ RepositoryContext = namedtuple(
25
+ "RepositoryContext",
26
+ ["repo_root", "scripts_root", "registry_file", "cache_file", "aliases_file"],
27
+ )
28
+
29
+
30
+ def default_cli_name() -> str:
31
+ prog = os.path.basename(sys.argv[0] or "").strip()
32
+ if prog:
33
+ prog = os.path.splitext(prog)[0]
34
+ if prog not in {"", "__main__", "-m"}:
35
+ return prog
36
+ return "toolrack"
37
+
38
+
39
+ def cli_name(environ: Mapping[str, str] = os.environ) -> str:
40
+ return environ.get("TOOLRACK_CLI_NAME") or default_cli_name()
41
+
42
+
43
+ def completion_var(name: str) -> str:
44
+ return "_" + name.replace("-", "_").upper() + "_COMPLETE"
45
+
46
+
47
+ def registry_basename(environ: Mapping[str, str] = os.environ) -> str:
48
+ return environ.get("TOOLRACK_REGISTRY_BASENAME") or DEFAULT_REGISTRY_BASENAME
49
+
50
+
51
+ def is_windows() -> bool:
52
+ return os.name == "nt"
53
+
54
+
55
+ def normalize_env_path(value: str | None) -> str | None:
56
+ """Map Git Bash / Cygwin Windows mount paths back to native Windows paths."""
57
+ if not value or not is_windows():
58
+ return value
59
+
60
+ normalized = value.replace("\\", "/")
61
+ if normalized.startswith("/cygdrive/") and len(normalized) > len("/cygdrive/x/"):
62
+ drive = normalized[len("/cygdrive/")]
63
+ remainder = normalized[len("/cygdrive/x") :]
64
+ return ntpath.normpath(f"{drive.upper()}:{remainder}")
65
+
66
+ if normalized.startswith("/") and len(normalized) > 3 and normalized[2] == "/":
67
+ drive = normalized[1]
68
+ if drive.isalpha():
69
+ return ntpath.normpath(f"{drive.upper()}:{normalized[2:]}")
70
+
71
+ return value
72
+
73
+
74
+ def find_repo_root(environ: Mapping[str, str] = os.environ) -> str:
75
+ """Walk up from cwd until we find the registry file or .git."""
76
+ candidates = [
77
+ environ.get("TOOLRACK_REGISTRY_FILE"),
78
+ registry_basename(environ),
79
+ LEGACY_REGISTRY_BASENAME,
80
+ ]
81
+ names = [name for name in candidates if name and not os.path.isabs(name)]
82
+
83
+ here = os.path.abspath(os.getcwd())
84
+ candidate = here
85
+ while True:
86
+ if any(os.path.isfile(os.path.join(candidate, name)) for name in names):
87
+ return candidate
88
+ if os.path.isdir(os.path.join(candidate, ".git")):
89
+ return candidate
90
+ parent = os.path.dirname(candidate)
91
+ if parent == candidate:
92
+ return here
93
+ candidate = parent
94
+
95
+
96
+ def resolve_repository(environ: Mapping[str, str] = os.environ) -> RepositoryContext:
97
+ """Resolve repository locations from the TOOLRACK_* environment."""
98
+
99
+ def path_from_env(name: str) -> str | None:
100
+ return normalize_env_path(environ.get(name))
101
+
102
+ repo_root = path_from_env("TOOLRACK_REPO_ROOT") or find_repo_root(environ)
103
+ registry_file = path_from_env("TOOLRACK_REGISTRY_FILE") or os.path.join(
104
+ repo_root, registry_basename(environ)
105
+ )
106
+ return RepositoryContext(
107
+ repo_root=repo_root,
108
+ scripts_root=path_from_env("TOOLRACK_SCRIPTS_ROOT") or os.path.join(repo_root, "scripts"),
109
+ registry_file=registry_file,
110
+ cache_file=path_from_env("TOOLRACK_CACHE_FILE") or registry_file + ".cache.json",
111
+ aliases_file=path_from_env("TOOLRACK_ALIASES_FILE")
112
+ or os.path.join(repo_root, "aliases.cfg"),
113
+ )
114
+
115
+
116
+ def read_registry(repo: RepositoryContext) -> list[str]:
117
+ if not os.path.isfile(repo.registry_file):
118
+ return []
119
+ with open(repo.registry_file, encoding="utf-8") as handle:
120
+ return [
121
+ line.strip()
122
+ for line in handle
123
+ if line.strip() and not line.strip().startswith("#")
124
+ ]
125
+
126
+
127
+ def abs_script_path(registry_entry: str, repo: RepositoryContext) -> str:
128
+ return os.path.normpath(os.path.join(repo.repo_root, registry_entry))
129
+
130
+
131
+ def sidecar_path(script_path: str) -> str:
132
+ return os.path.splitext(script_path)[0] + ".yml"
133
+
134
+
135
+ def file_state(path: str) -> dict[str, int | bool]:
136
+ try:
137
+ stat = os.stat(path)
138
+ except FileNotFoundError:
139
+ return {"exists": False, "size": 0, "mtime_ns": 0}
140
+ return {"exists": True, "size": stat.st_size, "mtime_ns": stat.st_mtime_ns}
141
+
142
+
143
+ def cache_signature(registry: list[str], repo: RepositoryContext) -> str:
144
+ payload = {
145
+ "repo_root": repo.repo_root,
146
+ "scripts_root": repo.scripts_root,
147
+ "registry_file": repo.registry_file,
148
+ "aliases_file": repo.aliases_file,
149
+ "registry": registry,
150
+ "registry_state": file_state(repo.registry_file),
151
+ "aliases_state": file_state(repo.aliases_file),
152
+ "entries": [
153
+ {
154
+ "registry_entry": entry,
155
+ "script_state": file_state(abs_script_path(entry, repo)),
156
+ "sidecar_state": file_state(sidecar_path(abs_script_path(entry, repo))),
157
+ }
158
+ for entry in registry
159
+ ],
160
+ }
161
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"))
162
+
163
+
164
+ def read_cache(registry: list[str], repo: RepositoryContext) -> list[dict] | None:
165
+ """Return cached command entries, or None when missing or stale."""
166
+ if not os.path.isfile(repo.cache_file):
167
+ return None
168
+ try:
169
+ with open(repo.cache_file, encoding="utf-8") as handle:
170
+ payload = json.load(handle)
171
+ except (OSError, json.JSONDecodeError):
172
+ return None
173
+ if not isinstance(payload, dict) or payload.get("signature") != cache_signature(
174
+ registry, repo
175
+ ):
176
+ return None
177
+ entries = payload.get("entries")
178
+ return entries if isinstance(entries, list) else None