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/__init__.py +3 -0
- toolrack/__main__.py +4 -0
- toolrack/cli.py +946 -0
- toolrack/completion.py +351 -0
- toolrack/entrypoint.py +14 -0
- toolrack/runtime.py +178 -0
- toolrack_cli-0.1.0.dist-info/METADATA +201 -0
- toolrack_cli-0.1.0.dist-info/RECORD +12 -0
- toolrack_cli-0.1.0.dist-info/WHEEL +5 -0
- toolrack_cli-0.1.0.dist-info/entry_points.txt +2 -0
- toolrack_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- toolrack_cli-0.1.0.dist-info/top_level.txt +1 -0
toolrack/cli.py
ADDED
|
@@ -0,0 +1,946 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""toolrack - typed dispatcher for a user-owned scripts repository."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import configparser
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from toolrack import __version__, runtime
|
|
16
|
+
from toolrack.runtime import RepositoryContext
|
|
17
|
+
|
|
18
|
+
CLI_NAME = runtime.cli_name()
|
|
19
|
+
COMPLETION_VAR = runtime.completion_var(CLI_NAME)
|
|
20
|
+
DEFAULT_REGISTRY_BASENAME = runtime.registry_basename()
|
|
21
|
+
# TODO(#12): Move environment-derived settings into an explicit runtime config object.
|
|
22
|
+
# Import-time globals make packaging harder, complicate tests, and will get in
|
|
23
|
+
# the way once toolrack is used as an installed library as well as a checkout.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_windows() -> bool:
|
|
27
|
+
return os.name == "nt"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _fix_windows_completion_crlf() -> None:
|
|
31
|
+
if not _is_windows():
|
|
32
|
+
return
|
|
33
|
+
if not os.environ.get(COMPLETION_VAR):
|
|
34
|
+
return
|
|
35
|
+
import io
|
|
36
|
+
|
|
37
|
+
sys.stdout = io.TextIOWrapper(
|
|
38
|
+
sys.stdout.buffer,
|
|
39
|
+
encoding=sys.stdout.encoding or "utf-8",
|
|
40
|
+
errors="replace",
|
|
41
|
+
newline="",
|
|
42
|
+
line_buffering=False,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_fix_windows_completion_crlf()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_find_repo_root = runtime.find_repo_root
|
|
50
|
+
_normalize_env_path = runtime.normalize_env_path
|
|
51
|
+
_file_state = runtime.file_state
|
|
52
|
+
|
|
53
|
+
_REPOSITORY = runtime.resolve_repository()
|
|
54
|
+
REPO_ROOT = _REPOSITORY.repo_root
|
|
55
|
+
SCRIPTS_ROOT = _REPOSITORY.scripts_root
|
|
56
|
+
REGISTRY_FILE = _REPOSITORY.registry_file
|
|
57
|
+
CACHE_FILE = _REPOSITORY.cache_file
|
|
58
|
+
ALIASES_FILE = _REPOSITORY.aliases_file
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class CommandArgSpec:
|
|
63
|
+
name: str
|
|
64
|
+
positional: bool
|
|
65
|
+
flag: bool
|
|
66
|
+
multiple: bool
|
|
67
|
+
variadic: bool
|
|
68
|
+
required: bool
|
|
69
|
+
default: object
|
|
70
|
+
choices: tuple[str, ...]
|
|
71
|
+
help: str
|
|
72
|
+
option: str | None
|
|
73
|
+
type_name: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class CommandEnvSpec:
|
|
78
|
+
name: str
|
|
79
|
+
help: str
|
|
80
|
+
default: object
|
|
81
|
+
required: bool
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class CommandSpec:
|
|
86
|
+
script_path: str
|
|
87
|
+
command_name: str
|
|
88
|
+
interpreter: tuple[str, ...]
|
|
89
|
+
description: str
|
|
90
|
+
args: tuple[CommandArgSpec, ...]
|
|
91
|
+
env: tuple[CommandEnvSpec, ...]
|
|
92
|
+
epilog: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _current_repo() -> RepositoryContext:
|
|
96
|
+
return RepositoryContext(
|
|
97
|
+
repo_root=REPO_ROOT,
|
|
98
|
+
scripts_root=SCRIPTS_ROOT,
|
|
99
|
+
registry_file=REGISTRY_FILE,
|
|
100
|
+
cache_file=CACHE_FILE,
|
|
101
|
+
aliases_file=ALIASES_FILE,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
_SKIP_DIRS = {"__pycache__", ".git", "node_modules"}
|
|
105
|
+
_RUNNABLE_EXTS = {".py", ".sh", ".bash", ".sql"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _read_registry(repo: RepositoryContext | None = None) -> list[str]:
|
|
109
|
+
return runtime.read_registry(repo or _current_repo())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _write_registry(paths: list[str], repo: RepositoryContext | None = None) -> None:
|
|
113
|
+
repo = repo or _current_repo()
|
|
114
|
+
with open(repo.registry_file, "w", encoding="utf-8") as handle:
|
|
115
|
+
for path in paths:
|
|
116
|
+
handle.write(path + "\n")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _resolve_script_path(raw: str, repo: RepositoryContext | None = None) -> str:
|
|
120
|
+
repo = repo or _current_repo()
|
|
121
|
+
if os.path.isabs(raw):
|
|
122
|
+
p = os.path.abspath(raw)
|
|
123
|
+
else:
|
|
124
|
+
repo_candidate = os.path.join(repo.repo_root, raw)
|
|
125
|
+
p = os.path.abspath(repo_candidate if os.path.exists(repo_candidate) else raw)
|
|
126
|
+
try:
|
|
127
|
+
return os.path.relpath(p, repo.repo_root).replace(os.sep, "/")
|
|
128
|
+
except ValueError:
|
|
129
|
+
return p.replace(os.sep, "/")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _abs_script_path(registry_entry: str, repo: RepositoryContext | None = None) -> str:
|
|
133
|
+
return runtime.abs_script_path(registry_entry, repo or _current_repo())
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# TODO(#3): Promote cached entry payloads to typed models instead of raw dicts.
|
|
137
|
+
# The JSON contract (signature + entries) lives in toolrack.runtime so the
|
|
138
|
+
# completion fast path validates the cache exactly as the CLI does.
|
|
139
|
+
def _cache_signature(registry: list[str], repo: RepositoryContext | None = None) -> str:
|
|
140
|
+
return runtime.cache_signature(registry, repo or _current_repo())
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _read_cache(registry: list[str], repo: RepositoryContext | None = None) -> list[dict] | None:
|
|
144
|
+
return runtime.read_cache(registry, repo or _current_repo())
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _write_cache(
|
|
148
|
+
registry: list[str], entries: list[dict], repo: RepositoryContext | None = None
|
|
149
|
+
) -> None:
|
|
150
|
+
repo = repo or _current_repo()
|
|
151
|
+
cache_dir = os.path.dirname(repo.cache_file)
|
|
152
|
+
if cache_dir:
|
|
153
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
154
|
+
payload = {
|
|
155
|
+
"signature": _cache_signature(registry, repo),
|
|
156
|
+
"entries": entries,
|
|
157
|
+
}
|
|
158
|
+
with open(repo.cache_file, "w", encoding="utf-8") as handle:
|
|
159
|
+
json.dump(payload, handle, indent=2, sort_keys=True)
|
|
160
|
+
handle.write("\n")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _load_aliases(repo: RepositoryContext | None = None) -> dict[str, str]:
|
|
164
|
+
repo = repo or _current_repo()
|
|
165
|
+
cfg = configparser.ConfigParser()
|
|
166
|
+
# TODO(#4): Merge registry/cache/aliases metadata into one top-level repo
|
|
167
|
+
# manifest object. These files are now conceptually related, but the code
|
|
168
|
+
# still treats them as separate incidental globals.
|
|
169
|
+
if not os.path.isfile(repo.aliases_file):
|
|
170
|
+
return {}
|
|
171
|
+
cfg.read(repo.aliases_file)
|
|
172
|
+
if not cfg.has_section("groups"):
|
|
173
|
+
return {}
|
|
174
|
+
|
|
175
|
+
aliases: dict[str, str] = {}
|
|
176
|
+
seen: dict[str, str] = {}
|
|
177
|
+
for original, alias in cfg.items("groups"):
|
|
178
|
+
if alias in seen:
|
|
179
|
+
raise RuntimeError(
|
|
180
|
+
f"aliases.cfg collision: '{original}' and '{seen[alias]}' both map to '{alias}'"
|
|
181
|
+
)
|
|
182
|
+
aliases[original] = alias
|
|
183
|
+
seen[alias] = original
|
|
184
|
+
return aliases
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
_sidecar_path = runtime.sidecar_path
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _generate_sidecar(script_path: str) -> dict:
|
|
191
|
+
import yaml
|
|
192
|
+
|
|
193
|
+
name = os.path.basename(script_path)
|
|
194
|
+
sidecar = {
|
|
195
|
+
"# autogenerated": (
|
|
196
|
+
f"This sidecar was created automatically by `{CLI_NAME} core register`.\n"
|
|
197
|
+
"Arguments are passed through verbatim. Replace it with a typed sidecar."
|
|
198
|
+
),
|
|
199
|
+
"description": f"Run {name} (autogenerated; edit to add proper description).",
|
|
200
|
+
"args": [
|
|
201
|
+
{
|
|
202
|
+
"name": "args",
|
|
203
|
+
"positional": True,
|
|
204
|
+
"variadic": True,
|
|
205
|
+
"required": False,
|
|
206
|
+
"help": "All arguments passed through verbatim to the script.",
|
|
207
|
+
}
|
|
208
|
+
],
|
|
209
|
+
}
|
|
210
|
+
with open(_sidecar_path(script_path), "w", encoding="utf-8") as handle:
|
|
211
|
+
yaml.dump(sidecar, handle, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
212
|
+
return sidecar
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _load_sidecar(script_path: str) -> dict | None:
|
|
216
|
+
yml_path = _sidecar_path(script_path)
|
|
217
|
+
if not os.path.isfile(yml_path):
|
|
218
|
+
return None
|
|
219
|
+
import yaml
|
|
220
|
+
|
|
221
|
+
with open(yml_path, encoding="utf-8") as handle:
|
|
222
|
+
data = yaml.safe_load(handle) or {}
|
|
223
|
+
if not isinstance(data, dict):
|
|
224
|
+
raise RuntimeError(f"{yml_path}: sidecar must be a YAML mapping, got {type(data).__name__}")
|
|
225
|
+
return data
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _validate_sidecar(sidecar: dict, script_path: str) -> None:
|
|
229
|
+
ext = os.path.splitext(script_path)[1].lower()
|
|
230
|
+
args = sidecar.get("args") or []
|
|
231
|
+
name = os.path.basename(script_path)
|
|
232
|
+
|
|
233
|
+
if ext == ".sql" and args:
|
|
234
|
+
raise RuntimeError(f"{name}: SQL scripts must not declare 'args'.")
|
|
235
|
+
|
|
236
|
+
seen_names: set[str] = set()
|
|
237
|
+
variadic_seen = False
|
|
238
|
+
for arg in args:
|
|
239
|
+
arg_name = arg.get("name", "")
|
|
240
|
+
if arg_name in seen_names:
|
|
241
|
+
raise RuntimeError(f"{name}: duplicate arg name '{arg_name}'.")
|
|
242
|
+
seen_names.add(arg_name)
|
|
243
|
+
if variadic_seen:
|
|
244
|
+
raise RuntimeError(f"{name}: '{arg_name}' appears after variadic; variadic must be last.")
|
|
245
|
+
if arg.get("variadic"):
|
|
246
|
+
if not arg.get("positional"):
|
|
247
|
+
raise RuntimeError(f"{name}: '{arg_name}' is variadic but not positional.")
|
|
248
|
+
variadic_seen = True
|
|
249
|
+
if arg.get("multiple") and arg.get("positional"):
|
|
250
|
+
raise RuntimeError(f"{name}: '{arg_name}' cannot be both multiple and positional.")
|
|
251
|
+
if arg.get("flag") and arg.get("type") not in (None, "bool"):
|
|
252
|
+
raise RuntimeError(f"{name}: '{arg_name}' is a flag but declares type '{arg['type']}'.")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _to_bash_path(windows_path: str, bash_exe: str = "") -> str:
|
|
256
|
+
# TODO(#5): Isolate shell/path translation behind a shell adapter layer.
|
|
257
|
+
# Windows, Git Bash, and Cygwin compatibility logic is mixed into the core
|
|
258
|
+
# dispatcher, which makes the module harder to reason about than it should be.
|
|
259
|
+
if not _is_windows():
|
|
260
|
+
return windows_path
|
|
261
|
+
|
|
262
|
+
cygpath_candidates: list[str] = []
|
|
263
|
+
if bash_exe:
|
|
264
|
+
bash_dir = os.path.dirname(os.path.abspath(bash_exe))
|
|
265
|
+
same_dir = os.path.join(bash_dir, "cygpath.exe")
|
|
266
|
+
parent_usr_bin = os.path.join(os.path.dirname(bash_dir), "usr", "bin", "cygpath.exe")
|
|
267
|
+
for candidate in (same_dir, parent_usr_bin):
|
|
268
|
+
if os.path.isfile(candidate):
|
|
269
|
+
cygpath_candidates.append(candidate)
|
|
270
|
+
cygpath_candidates.append("cygpath")
|
|
271
|
+
|
|
272
|
+
for cygpath_exe in cygpath_candidates:
|
|
273
|
+
try:
|
|
274
|
+
result = subprocess.run(
|
|
275
|
+
[cygpath_exe, "-u", windows_path],
|
|
276
|
+
capture_output=True,
|
|
277
|
+
text=True,
|
|
278
|
+
timeout=5,
|
|
279
|
+
)
|
|
280
|
+
if result.returncode == 0:
|
|
281
|
+
return result.stdout.strip()
|
|
282
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
283
|
+
continue
|
|
284
|
+
|
|
285
|
+
path = windows_path.replace("\\", "/")
|
|
286
|
+
if len(path) >= 2 and path[1] == ":":
|
|
287
|
+
path = "/" + path[0].lower() + path[2:]
|
|
288
|
+
return path
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _resolve_bash_exe() -> str:
|
|
292
|
+
if not _is_windows():
|
|
293
|
+
return "bash"
|
|
294
|
+
|
|
295
|
+
cygwin_bash = r"C:\cygwin64\bin\bash.exe"
|
|
296
|
+
git_bash = r"C:\Program Files\Git\bin\bash.exe"
|
|
297
|
+
|
|
298
|
+
if not os.environ.get("MSYSTEM"):
|
|
299
|
+
path_dirs = os.environ.get("PATH", "").split(os.pathsep)
|
|
300
|
+
cygwin_bin = r"C:\cygwin64\bin"
|
|
301
|
+
gitbash_bin = r"C:\Program Files\Git\bin"
|
|
302
|
+
|
|
303
|
+
def _norm(value: str) -> str:
|
|
304
|
+
return os.path.normcase(os.path.normpath(value))
|
|
305
|
+
|
|
306
|
+
normed = [_norm(entry) for entry in path_dirs]
|
|
307
|
+
cygwin_idx = next((idx for idx, entry in enumerate(normed) if entry == _norm(cygwin_bin)), 999)
|
|
308
|
+
gitbash_idx = next((idx for idx, entry in enumerate(normed) if entry == _norm(gitbash_bin)), 999)
|
|
309
|
+
if cygwin_idx < gitbash_idx and os.path.isfile(cygwin_bash):
|
|
310
|
+
return cygwin_bash
|
|
311
|
+
|
|
312
|
+
if os.environ.get("MSYSTEM") and os.path.isfile(git_bash):
|
|
313
|
+
return git_bash
|
|
314
|
+
|
|
315
|
+
return "bash"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _interpreter(script_path: str) -> list[str]:
|
|
319
|
+
ext = os.path.splitext(script_path)[1].lower()
|
|
320
|
+
if ext == ".py":
|
|
321
|
+
return [sys.executable]
|
|
322
|
+
if ext in (".sh", ".bash"):
|
|
323
|
+
return [_resolve_bash_exe()]
|
|
324
|
+
if ext == ".sql":
|
|
325
|
+
return ["psql", "-f"]
|
|
326
|
+
return []
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _click_type(type_str: str):
|
|
330
|
+
return {
|
|
331
|
+
"int": click.INT,
|
|
332
|
+
"float": click.FLOAT,
|
|
333
|
+
"bool": click.BOOL,
|
|
334
|
+
"path": click.Path(),
|
|
335
|
+
}.get(type_str, click.STRING)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _help_epilog(env_specs: tuple[CommandEnvSpec, ...], epilog_text: str) -> str | None:
|
|
339
|
+
epilog_lines: list[str] = []
|
|
340
|
+
if env_specs:
|
|
341
|
+
epilog_lines.append("Environment variables:")
|
|
342
|
+
for env_spec in env_specs:
|
|
343
|
+
line = f" {env_spec.name}"
|
|
344
|
+
if env_spec.help:
|
|
345
|
+
line += f" {env_spec.help}"
|
|
346
|
+
if env_spec.default is not None:
|
|
347
|
+
line += f" [default: {env_spec.default}]"
|
|
348
|
+
if env_spec.required:
|
|
349
|
+
line += " [required]"
|
|
350
|
+
epilog_lines.append(line)
|
|
351
|
+
if epilog_text:
|
|
352
|
+
if epilog_lines:
|
|
353
|
+
epilog_lines.append("")
|
|
354
|
+
epilog_lines.append(epilog_text)
|
|
355
|
+
return "\n".join(epilog_lines) or None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _command_arg_spec(arg: dict) -> CommandArgSpec:
|
|
359
|
+
positional = bool(arg.get("positional"))
|
|
360
|
+
return CommandArgSpec(
|
|
361
|
+
name=arg["name"],
|
|
362
|
+
positional=positional,
|
|
363
|
+
flag=bool(arg.get("flag")),
|
|
364
|
+
multiple=bool(arg.get("multiple")),
|
|
365
|
+
variadic=bool(arg.get("variadic")),
|
|
366
|
+
required=arg.get("required", True if positional else False),
|
|
367
|
+
default=arg.get("default"),
|
|
368
|
+
choices=tuple(str(choice) for choice in (arg.get("choices") or [])),
|
|
369
|
+
help=arg.get("help", ""),
|
|
370
|
+
option=arg.get("option"),
|
|
371
|
+
type_name=arg.get("type", "string"),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _command_env_spec(env_spec: dict) -> CommandEnvSpec:
|
|
376
|
+
return CommandEnvSpec(
|
|
377
|
+
name=env_spec["name"],
|
|
378
|
+
help=env_spec.get("help", ""),
|
|
379
|
+
default=env_spec.get("default"),
|
|
380
|
+
required=bool(env_spec.get("required")),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _command_spec(script_path: str, cmd_name: str, sidecar: dict) -> CommandSpec:
|
|
385
|
+
args = tuple(_command_arg_spec(arg) for arg in (sidecar.get("args") or []))
|
|
386
|
+
env = tuple(_command_env_spec(env_spec) for env_spec in (sidecar.get("env") or []))
|
|
387
|
+
return CommandSpec(
|
|
388
|
+
script_path=script_path,
|
|
389
|
+
command_name=cmd_name,
|
|
390
|
+
interpreter=tuple(_interpreter(script_path)),
|
|
391
|
+
description=sidecar.get("description", "").strip(),
|
|
392
|
+
args=args,
|
|
393
|
+
env=env,
|
|
394
|
+
epilog=sidecar.get("epilog", "").strip(),
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _click_param(spec: CommandArgSpec):
|
|
399
|
+
click_type = _click_type(spec.type_name)
|
|
400
|
+
if spec.choices:
|
|
401
|
+
click_type = click.Choice(list(spec.choices))
|
|
402
|
+
if spec.flag:
|
|
403
|
+
click_type = click.BOOL
|
|
404
|
+
|
|
405
|
+
if spec.positional:
|
|
406
|
+
return click.Argument(
|
|
407
|
+
[spec.name],
|
|
408
|
+
required=spec.required,
|
|
409
|
+
type=click_type,
|
|
410
|
+
nargs=-1 if spec.variadic else 1,
|
|
411
|
+
)
|
|
412
|
+
flag_name = spec.option or f"--{spec.name.replace('_', '-')}"
|
|
413
|
+
if spec.flag:
|
|
414
|
+
return click.Option([flag_name], is_flag=True, default=False, help=spec.help)
|
|
415
|
+
return click.Option(
|
|
416
|
+
[flag_name],
|
|
417
|
+
required=spec.required,
|
|
418
|
+
default=spec.default,
|
|
419
|
+
type=click_type,
|
|
420
|
+
multiple=spec.multiple,
|
|
421
|
+
help=spec.help,
|
|
422
|
+
show_default=spec.default is not None,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _run_dynamic_command(spec: CommandSpec, kwargs: dict) -> int:
|
|
427
|
+
"""Execute a generated command and return the subprocess exit status."""
|
|
428
|
+
bash_exe = spec.interpreter[0] if spec.interpreter else ""
|
|
429
|
+
exec_path = (
|
|
430
|
+
_to_bash_path(spec.script_path, bash_exe)
|
|
431
|
+
if bash_exe == "bash" or bash_exe.endswith("bash.exe")
|
|
432
|
+
else spec.script_path
|
|
433
|
+
)
|
|
434
|
+
cmd = list(spec.interpreter) + [exec_path]
|
|
435
|
+
for arg_spec in spec.args:
|
|
436
|
+
value = kwargs[arg_spec.name]
|
|
437
|
+
flag_name = arg_spec.option or f"--{arg_spec.name.replace('_', '-')}"
|
|
438
|
+
|
|
439
|
+
if value is None or value == () or value is False:
|
|
440
|
+
continue
|
|
441
|
+
if arg_spec.positional:
|
|
442
|
+
if arg_spec.variadic:
|
|
443
|
+
for item in value:
|
|
444
|
+
cmd.append(str(item))
|
|
445
|
+
else:
|
|
446
|
+
cmd.append(str(value))
|
|
447
|
+
elif arg_spec.flag:
|
|
448
|
+
cmd.append(flag_name)
|
|
449
|
+
elif arg_spec.multiple:
|
|
450
|
+
for item in value:
|
|
451
|
+
cmd.extend([flag_name, str(item)])
|
|
452
|
+
else:
|
|
453
|
+
cmd.extend([flag_name, str(value)])
|
|
454
|
+
|
|
455
|
+
result = subprocess.run(cmd, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr)
|
|
456
|
+
return result.returncode
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _command_callback(spec: CommandSpec):
|
|
460
|
+
def callback(**kwargs):
|
|
461
|
+
return _run_dynamic_command(spec, kwargs)
|
|
462
|
+
|
|
463
|
+
return callback
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _make_command(script_path: str, cmd_name: str, sidecar: dict) -> click.Command:
|
|
467
|
+
spec = _command_spec(script_path, cmd_name, sidecar)
|
|
468
|
+
|
|
469
|
+
return click.Command(
|
|
470
|
+
name=spec.command_name,
|
|
471
|
+
callback=_command_callback(spec),
|
|
472
|
+
params=[_click_param(arg_spec) for arg_spec in spec.args],
|
|
473
|
+
help=spec.description,
|
|
474
|
+
epilog=_help_epilog(spec.env, spec.epilog),
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _to_cli_name(name: str) -> str:
|
|
479
|
+
return name.replace("_", "-")
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _script_cli_name(filename: str) -> str:
|
|
483
|
+
return _to_cli_name(os.path.splitext(filename)[0])
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _group_path_and_cmd(
|
|
487
|
+
registry_entry: str, aliases: dict[str, str], repo: RepositoryContext | None = None
|
|
488
|
+
) -> tuple[list[str], str, str]:
|
|
489
|
+
abs_path = _abs_script_path(registry_entry, repo)
|
|
490
|
+
parts = registry_entry.replace("\\", "/").split("/")
|
|
491
|
+
if parts[0] == "scripts":
|
|
492
|
+
parts = parts[1:]
|
|
493
|
+
group_parts = parts[:-1]
|
|
494
|
+
filename = parts[-1]
|
|
495
|
+
cmd_name = _script_cli_name(filename)
|
|
496
|
+
resolved = [_to_cli_name(aliases.get(part, part)) for part in group_parts]
|
|
497
|
+
return resolved, cmd_name, abs_path
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _discover_cli_entries(
|
|
501
|
+
registry: list[str], aliases: dict[str, str], repo: RepositoryContext | None = None
|
|
502
|
+
) -> list[dict]:
|
|
503
|
+
repo = repo or _current_repo()
|
|
504
|
+
# TODO(#8): This should become a pure discovery phase that reports structured
|
|
505
|
+
# diagnostics instead of printing via Click while traversing the repo.
|
|
506
|
+
# Mixing discovery, validation, and user I/O makes caching and reuse clumsy.
|
|
507
|
+
discovered: list[dict] = []
|
|
508
|
+
|
|
509
|
+
for entry in registry:
|
|
510
|
+
abs_path = _abs_script_path(entry, repo)
|
|
511
|
+
if not os.path.isfile(abs_path):
|
|
512
|
+
click.echo(f"[{CLI_NAME}] warning: registered script not found: {entry}", err=True)
|
|
513
|
+
continue
|
|
514
|
+
|
|
515
|
+
try:
|
|
516
|
+
sidecar = _load_sidecar(abs_path)
|
|
517
|
+
if sidecar is None:
|
|
518
|
+
click.echo(
|
|
519
|
+
f"[{CLI_NAME}] warning: sidecar missing for registered script: {entry}\n"
|
|
520
|
+
f" Run `{CLI_NAME} core register {entry}` to regenerate it.",
|
|
521
|
+
err=True,
|
|
522
|
+
)
|
|
523
|
+
continue
|
|
524
|
+
_validate_sidecar(sidecar, abs_path)
|
|
525
|
+
except RuntimeError as exc:
|
|
526
|
+
click.echo(f"[{CLI_NAME}] skipping {entry}: {exc}", err=True)
|
|
527
|
+
continue
|
|
528
|
+
|
|
529
|
+
group_parts, cmd_name, _ = _group_path_and_cmd(entry, aliases, repo)
|
|
530
|
+
discovered.append(
|
|
531
|
+
{
|
|
532
|
+
"entry": entry,
|
|
533
|
+
"abs_path": abs_path,
|
|
534
|
+
"group_parts": group_parts,
|
|
535
|
+
"cmd_name": cmd_name,
|
|
536
|
+
"sidecar": sidecar,
|
|
537
|
+
}
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
return discovered
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _load_cli_entries(
|
|
544
|
+
registry: list[str], aliases: dict[str, str], repo: RepositoryContext | None = None
|
|
545
|
+
) -> list[dict]:
|
|
546
|
+
repo = repo or _current_repo()
|
|
547
|
+
cached = _read_cache(registry, repo)
|
|
548
|
+
if cached is not None:
|
|
549
|
+
return cached
|
|
550
|
+
discovered = _discover_cli_entries(registry, aliases, repo)
|
|
551
|
+
try:
|
|
552
|
+
_write_cache(registry, discovered, repo)
|
|
553
|
+
except OSError:
|
|
554
|
+
pass
|
|
555
|
+
return discovered
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _refresh_cache(repo: RepositoryContext | None = None) -> None:
|
|
559
|
+
repo = repo or _current_repo()
|
|
560
|
+
try:
|
|
561
|
+
registry = _read_registry(repo)
|
|
562
|
+
aliases = _load_aliases(repo)
|
|
563
|
+
_write_cache(registry, _discover_cli_entries(registry, aliases, repo), repo)
|
|
564
|
+
except (OSError, RuntimeError):
|
|
565
|
+
return
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _build_cli_tree(entries: list[dict], aliases: dict[str, str]) -> None:
|
|
569
|
+
# TODO(#9): Build a declarative command tree first, then render it into Click.
|
|
570
|
+
# Right now the tree is mutated directly on the global Click group, which is
|
|
571
|
+
# serviceable for one process but awkward for caching and introspection.
|
|
572
|
+
groups: dict[tuple[str, ...], click.Group] = {}
|
|
573
|
+
|
|
574
|
+
def get_or_create_group(name_parts: list[str]) -> click.Group:
|
|
575
|
+
key = tuple(name_parts)
|
|
576
|
+
if key in groups:
|
|
577
|
+
return groups[key]
|
|
578
|
+
|
|
579
|
+
if len(name_parts) == 1:
|
|
580
|
+
name = name_parts[0]
|
|
581
|
+
folder = name
|
|
582
|
+
for original, alias in aliases.items():
|
|
583
|
+
if _to_cli_name(alias) == name:
|
|
584
|
+
folder = original
|
|
585
|
+
break
|
|
586
|
+
group = click.Group(name=name, help=f"Commands from scripts/{folder}/")
|
|
587
|
+
cli.add_command(group)
|
|
588
|
+
|
|
589
|
+
for original, alias in aliases.items():
|
|
590
|
+
if _to_cli_name(alias) == name and _to_cli_name(original) != name:
|
|
591
|
+
shadow = click.Group(
|
|
592
|
+
name=_to_cli_name(original),
|
|
593
|
+
help=f"Alias; use '{name}' instead.",
|
|
594
|
+
hidden=True,
|
|
595
|
+
)
|
|
596
|
+
cli.add_command(shadow)
|
|
597
|
+
groups[(_to_cli_name(original),)] = shadow
|
|
598
|
+
break
|
|
599
|
+
|
|
600
|
+
groups[key] = group
|
|
601
|
+
return group
|
|
602
|
+
|
|
603
|
+
parent = get_or_create_group(name_parts[:-1])
|
|
604
|
+
name = name_parts[-1]
|
|
605
|
+
if name in parent.commands:
|
|
606
|
+
return parent.commands[name]
|
|
607
|
+
folder = name
|
|
608
|
+
for original, alias in aliases.items():
|
|
609
|
+
if _to_cli_name(alias) == name:
|
|
610
|
+
folder = original
|
|
611
|
+
break
|
|
612
|
+
group = click.Group(name=name, help=f"Commands from scripts/{'/'.join(name_parts)}/")
|
|
613
|
+
parent.add_command(group)
|
|
614
|
+
groups[key] = group
|
|
615
|
+
return group
|
|
616
|
+
|
|
617
|
+
for entry in entries:
|
|
618
|
+
group_parts = entry["group_parts"]
|
|
619
|
+
cmd_name = entry["cmd_name"]
|
|
620
|
+
abs_path = entry["abs_path"]
|
|
621
|
+
sidecar = entry["sidecar"]
|
|
622
|
+
try:
|
|
623
|
+
cmd = _make_command(abs_path, cmd_name, sidecar)
|
|
624
|
+
except RuntimeError as exc:
|
|
625
|
+
click.echo(f"[{CLI_NAME}] error building command for {entry['entry']}: {exc}", err=True)
|
|
626
|
+
continue
|
|
627
|
+
|
|
628
|
+
if group_parts:
|
|
629
|
+
get_or_create_group(group_parts).add_command(cmd)
|
|
630
|
+
else:
|
|
631
|
+
cli.add_command(cmd)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
_STATIC_COMMANDS = {"core"}
|
|
635
|
+
_DYNAMIC_CLI_SIGNATURE: str | None = None
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _clear_dynamic_cli_tree() -> None:
|
|
639
|
+
for name in list(cli.commands.keys()):
|
|
640
|
+
if name not in _STATIC_COMMANDS:
|
|
641
|
+
cli.commands.pop(name)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _ensure_cli_tree() -> None:
|
|
645
|
+
global _DYNAMIC_CLI_SIGNATURE
|
|
646
|
+
|
|
647
|
+
repo = _current_repo()
|
|
648
|
+
registry = _read_registry(repo)
|
|
649
|
+
signature = _cache_signature(registry, repo)
|
|
650
|
+
if _DYNAMIC_CLI_SIGNATURE == signature:
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
_clear_dynamic_cli_tree()
|
|
654
|
+
aliases = _load_aliases(repo)
|
|
655
|
+
_build_cli_tree(_load_cli_entries(registry, aliases, repo), aliases)
|
|
656
|
+
_DYNAMIC_CLI_SIGNATURE = signature
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
class RuntimeCLIGroup(click.Group):
|
|
660
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
661
|
+
if cmd_name not in self.commands:
|
|
662
|
+
_ensure_cli_tree()
|
|
663
|
+
return super().get_command(ctx, cmd_name)
|
|
664
|
+
|
|
665
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
666
|
+
_ensure_cli_tree()
|
|
667
|
+
return super().list_commands(ctx)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
@click.group(cls=RuntimeCLIGroup)
|
|
671
|
+
@click.version_option(version=__version__, prog_name=CLI_NAME)
|
|
672
|
+
def cli():
|
|
673
|
+
"""Typed dispatcher for a user-owned scripts repository."""
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
@cli.result_callback()
|
|
677
|
+
def _propagate_command_exit_status(result, **_kwargs):
|
|
678
|
+
if type(result) is int:
|
|
679
|
+
raise click.exceptions.Exit(result)
|
|
680
|
+
return result
|
|
681
|
+
# TODO(#10): Avoid import-time CLI tree construction. It speeds up the "single file
|
|
682
|
+
# script" model, but it also means import has side effects and front-loads work
|
|
683
|
+
# even for commands that only need core maintenance operations.
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
@cli.group("core")
|
|
687
|
+
def core():
|
|
688
|
+
"""Registry management and shell-completion commands."""
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
@core.command("register")
|
|
692
|
+
@click.argument("script_path")
|
|
693
|
+
def cmd_register(script_path: str):
|
|
694
|
+
"""Register a script as a CLI subcommand."""
|
|
695
|
+
repo = _current_repo()
|
|
696
|
+
canonical = _resolve_script_path(script_path, repo)
|
|
697
|
+
abs_path = _abs_script_path(canonical, repo)
|
|
698
|
+
|
|
699
|
+
if not os.path.isfile(abs_path):
|
|
700
|
+
raise click.ClickException(f"Script not found: {abs_path}")
|
|
701
|
+
|
|
702
|
+
ext = os.path.splitext(abs_path)[1].lower()
|
|
703
|
+
if ext not in _RUNNABLE_EXTS:
|
|
704
|
+
raise click.ClickException(
|
|
705
|
+
f"Unsupported extension '{ext}'. Supported: {', '.join(sorted(_RUNNABLE_EXTS))}"
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
try:
|
|
709
|
+
sidecar = _load_sidecar(abs_path)
|
|
710
|
+
except RuntimeError as exc:
|
|
711
|
+
raise click.ClickException(str(exc))
|
|
712
|
+
|
|
713
|
+
if sidecar is None:
|
|
714
|
+
sidecar = _generate_sidecar(abs_path)
|
|
715
|
+
click.echo(
|
|
716
|
+
f"No sidecar found; generated: {_sidecar_path(abs_path)}\n"
|
|
717
|
+
" Arguments are passed through verbatim. Edit the sidecar to add\n"
|
|
718
|
+
" typed options and proper help."
|
|
719
|
+
)
|
|
720
|
+
else:
|
|
721
|
+
try:
|
|
722
|
+
_validate_sidecar(sidecar, abs_path)
|
|
723
|
+
except RuntimeError as exc:
|
|
724
|
+
raise click.ClickException(str(exc))
|
|
725
|
+
|
|
726
|
+
registry = _read_registry(repo)
|
|
727
|
+
if canonical in registry:
|
|
728
|
+
click.echo(f"Already registered: {canonical}")
|
|
729
|
+
return
|
|
730
|
+
|
|
731
|
+
registry.append(canonical)
|
|
732
|
+
_write_registry(registry, repo)
|
|
733
|
+
_refresh_cache(repo)
|
|
734
|
+
click.echo(f"Registered: {canonical}")
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
@core.command("auto-register")
|
|
738
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be registered without writing.")
|
|
739
|
+
def cmd_auto_register(dry_run: bool):
|
|
740
|
+
"""Scan scripts/ for sidecar+script pairs and register unregistered ones."""
|
|
741
|
+
repo = _current_repo()
|
|
742
|
+
registry = _read_registry(repo)
|
|
743
|
+
registry_set = set(registry)
|
|
744
|
+
added: list[str] = []
|
|
745
|
+
skipped: list[str] = []
|
|
746
|
+
failures: list[tuple[str, str]] = []
|
|
747
|
+
|
|
748
|
+
for dirpath, dirnames, filenames in os.walk(repo.scripts_root):
|
|
749
|
+
dirnames[:] = sorted(
|
|
750
|
+
dirname for dirname in dirnames if dirname not in _SKIP_DIRS and not dirname.startswith(".")
|
|
751
|
+
)
|
|
752
|
+
for filename in sorted(filenames):
|
|
753
|
+
if not filename.endswith(".yml"):
|
|
754
|
+
continue
|
|
755
|
+
stem = os.path.splitext(filename)[0]
|
|
756
|
+
script_path = None
|
|
757
|
+
for ext in _RUNNABLE_EXTS:
|
|
758
|
+
candidate = os.path.join(dirpath, stem + ext)
|
|
759
|
+
if os.path.isfile(candidate):
|
|
760
|
+
script_path = candidate
|
|
761
|
+
break
|
|
762
|
+
if script_path is None:
|
|
763
|
+
continue
|
|
764
|
+
|
|
765
|
+
canonical = _resolve_script_path(script_path, repo)
|
|
766
|
+
if canonical in registry_set:
|
|
767
|
+
skipped.append(canonical)
|
|
768
|
+
continue
|
|
769
|
+
|
|
770
|
+
try:
|
|
771
|
+
sidecar = _load_sidecar(script_path)
|
|
772
|
+
if sidecar is None:
|
|
773
|
+
failures.append((canonical, "sidecar could not be loaded"))
|
|
774
|
+
continue
|
|
775
|
+
_validate_sidecar(sidecar, script_path)
|
|
776
|
+
except RuntimeError as exc:
|
|
777
|
+
failures.append((canonical, str(exc)))
|
|
778
|
+
continue
|
|
779
|
+
|
|
780
|
+
added.append(canonical)
|
|
781
|
+
if not dry_run:
|
|
782
|
+
registry.append(canonical)
|
|
783
|
+
registry_set.add(canonical)
|
|
784
|
+
|
|
785
|
+
if not dry_run:
|
|
786
|
+
_write_registry(registry, repo)
|
|
787
|
+
_refresh_cache(repo)
|
|
788
|
+
|
|
789
|
+
prefix = "[dry-run] " if dry_run else ""
|
|
790
|
+
for canonical in skipped:
|
|
791
|
+
click.echo(f" SKIP {canonical} (already registered)")
|
|
792
|
+
for canonical in added:
|
|
793
|
+
click.echo(f" {prefix}ADD {canonical}")
|
|
794
|
+
for canonical, reason in failures:
|
|
795
|
+
click.echo(f" ERROR {canonical}: {reason}", err=True)
|
|
796
|
+
|
|
797
|
+
action = "Would add" if dry_run else "Added"
|
|
798
|
+
click.echo(f"\n{action} {len(added)}, skipped {len(skipped)}, {len(failures)} errors.")
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
@core.command("unregister")
|
|
802
|
+
@click.argument("script_path")
|
|
803
|
+
def cmd_unregister(script_path: str):
|
|
804
|
+
"""Remove a script from the registry."""
|
|
805
|
+
repo = _current_repo()
|
|
806
|
+
canonical = _resolve_script_path(script_path, repo)
|
|
807
|
+
registry = _read_registry(repo)
|
|
808
|
+
|
|
809
|
+
if canonical not in registry:
|
|
810
|
+
raise click.ClickException(f"Not registered: {canonical}")
|
|
811
|
+
|
|
812
|
+
registry.remove(canonical)
|
|
813
|
+
_write_registry(registry, repo)
|
|
814
|
+
_refresh_cache(repo)
|
|
815
|
+
click.echo(f"Unregistered: {canonical}")
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
@core.command("reregister")
|
|
819
|
+
def cmd_reregister():
|
|
820
|
+
"""Re-validate all registered commands."""
|
|
821
|
+
repo = _current_repo()
|
|
822
|
+
registry = _read_registry(repo)
|
|
823
|
+
if not registry:
|
|
824
|
+
click.echo("Nothing registered.")
|
|
825
|
+
return
|
|
826
|
+
|
|
827
|
+
_load_aliases(repo)
|
|
828
|
+
ok = 0
|
|
829
|
+
errors = 0
|
|
830
|
+
for entry in registry:
|
|
831
|
+
abs_path = _abs_script_path(entry, repo)
|
|
832
|
+
if not os.path.isfile(abs_path):
|
|
833
|
+
click.echo(f" MISSING {entry}", err=True)
|
|
834
|
+
errors += 1
|
|
835
|
+
continue
|
|
836
|
+
try:
|
|
837
|
+
sidecar = _load_sidecar(abs_path)
|
|
838
|
+
if sidecar is None:
|
|
839
|
+
click.echo(
|
|
840
|
+
f" NO SIDECAR {entry} (run: {CLI_NAME} core register {entry})",
|
|
841
|
+
err=True,
|
|
842
|
+
)
|
|
843
|
+
errors += 1
|
|
844
|
+
continue
|
|
845
|
+
_validate_sidecar(sidecar, abs_path)
|
|
846
|
+
click.echo(f" OK {entry}")
|
|
847
|
+
ok += 1
|
|
848
|
+
except RuntimeError as exc:
|
|
849
|
+
click.echo(f" ERROR {entry}: {exc}", err=True)
|
|
850
|
+
errors += 1
|
|
851
|
+
|
|
852
|
+
_refresh_cache(repo)
|
|
853
|
+
click.echo(f"\n{ok} ok, {errors} errors.")
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
@core.command("refresh-cache")
|
|
857
|
+
def cmd_refresh_cache():
|
|
858
|
+
"""Rebuild the command metadata cache next to the registry."""
|
|
859
|
+
repo = _current_repo()
|
|
860
|
+
_refresh_cache(repo)
|
|
861
|
+
click.echo(f"Refreshed cache: {repo.cache_file}")
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
@core.command("list")
|
|
865
|
+
def cmd_list():
|
|
866
|
+
"""List all registered scripts."""
|
|
867
|
+
repo = _current_repo()
|
|
868
|
+
registry = _read_registry(repo)
|
|
869
|
+
if not registry:
|
|
870
|
+
click.echo(f"No scripts registered. Use: {CLI_NAME} core register <script_path>")
|
|
871
|
+
return
|
|
872
|
+
for entry in registry:
|
|
873
|
+
abs_path = _abs_script_path(entry, repo)
|
|
874
|
+
status = "ok" if os.path.isfile(abs_path) else "MISSING"
|
|
875
|
+
sidecar_exists = os.path.isfile(_sidecar_path(abs_path))
|
|
876
|
+
sidecar_tag = "" if sidecar_exists else " [no sidecar]"
|
|
877
|
+
click.echo(f" [{status}] {entry}{sidecar_tag}")
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _bypass_wrapper_in_bash_completion(source: str) -> str:
|
|
881
|
+
"""Make Bash completion call this interpreter directly instead of the wrapper.
|
|
882
|
+
|
|
883
|
+
Click's script runs ``$1`` (the wrapper on PATH) for every <TAB>; on Windows
|
|
884
|
+
that starts a whole extra bash process. The generated helper runs
|
|
885
|
+
``python -m toolrack`` with the same environment the wrapper would export,
|
|
886
|
+
and falls back to ``$1`` if that interpreter no longer exists (venv moved or
|
|
887
|
+
recreated), so a stale script degrades to the old behaviour, not to nothing.
|
|
888
|
+
"""
|
|
889
|
+
import shlex
|
|
890
|
+
|
|
891
|
+
call = " $1)"
|
|
892
|
+
if call not in source:
|
|
893
|
+
return source
|
|
894
|
+
|
|
895
|
+
repo = _current_repo()
|
|
896
|
+
python = _to_bash_path(sys.executable, _resolve_bash_exe())
|
|
897
|
+
env = {
|
|
898
|
+
"TOOLRACK_CLI_NAME": CLI_NAME,
|
|
899
|
+
"TOOLRACK_REPO_ROOT": repo.repo_root,
|
|
900
|
+
"TOOLRACK_SCRIPTS_ROOT": repo.scripts_root,
|
|
901
|
+
"TOOLRACK_REGISTRY_FILE": repo.registry_file,
|
|
902
|
+
"TOOLRACK_CACHE_FILE": repo.cache_file,
|
|
903
|
+
"TOOLRACK_ALIASES_FILE": repo.aliases_file,
|
|
904
|
+
}
|
|
905
|
+
assignments = " ".join(f"{name}={shlex.quote(value)}" for name, value in env.items())
|
|
906
|
+
ident = "".join(ch if ch.isalnum() else "_" for ch in CLI_NAME)
|
|
907
|
+
helper = f"_{ident}_completion_direct"
|
|
908
|
+
definition = (
|
|
909
|
+
f"{helper}() {{\n"
|
|
910
|
+
f" if [ -x {shlex.quote(python)} ]; then\n"
|
|
911
|
+
f" {assignments} {shlex.quote(python)} -m toolrack\n"
|
|
912
|
+
f" else\n"
|
|
913
|
+
f' "$1"\n'
|
|
914
|
+
f" fi\n"
|
|
915
|
+
f"}}\n\n"
|
|
916
|
+
)
|
|
917
|
+
return definition + source.replace(call, f' {helper} "$1")', 1)
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
@core.command("install-completion")
|
|
921
|
+
@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]), default="bash")
|
|
922
|
+
def install_completion(shell: str):
|
|
923
|
+
"""Print the shell completion script to stdout."""
|
|
924
|
+
from click.shell_completion import BashComplete, FishComplete, ShellComplete, ZshComplete
|
|
925
|
+
|
|
926
|
+
shell_cls = {"bash": BashComplete, "zsh": ZshComplete, "fish": FishComplete}[shell]
|
|
927
|
+
comp = shell_cls(cli, {}, CLI_NAME, COMPLETION_VAR)
|
|
928
|
+
# Use the base formatter directly so we don't shell out to probe the
|
|
929
|
+
# user's Bash installation when merely printing the completion script.
|
|
930
|
+
source = ShellComplete.source(comp).replace("\r\n", "\n").replace("\r", "\n")
|
|
931
|
+
if shell == "bash":
|
|
932
|
+
# TODO(#11): Stop patching upstream Click output with string replacement.
|
|
933
|
+
# This is pragmatic for now, but a custom completion emitter would be
|
|
934
|
+
# more explicit and less brittle than editing generated shell text.
|
|
935
|
+
source = source.replace("$(env ", "$(")
|
|
936
|
+
source = _bypass_wrapper_in_bash_completion(source)
|
|
937
|
+
sys.stdout.write(source)
|
|
938
|
+
sys.stdout.flush()
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def main() -> None:
|
|
942
|
+
cli(prog_name=CLI_NAME)
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
if __name__ == "__main__":
|
|
946
|
+
main()
|