toolrack-cli 0.2.0__tar.gz → 0.3.0__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.
- {toolrack_cli-0.2.0/src/toolrack_cli.egg-info → toolrack_cli-0.3.0}/PKG-INFO +12 -2
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/README.md +11 -1
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack/__init__.py +1 -1
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack/cli.py +139 -139
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack/completion.py +13 -0
- toolrack_cli-0.3.0/src/toolrack/doctor.py +126 -0
- toolrack_cli-0.3.0/src/toolrack/scaffold.py +93 -0
- toolrack_cli-0.3.0/src/toolrack/shells.py +73 -0
- toolrack_cli-0.3.0/src/toolrack/sidecar.py +75 -0
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0/src/toolrack_cli.egg-info}/PKG-INFO +12 -2
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack_cli.egg-info/SOURCES.txt +5 -0
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/tests/test_cli.py +77 -0
- toolrack_cli-0.3.0/tests/test_init_doctor.py +109 -0
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/LICENSE +0 -0
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/pyproject.toml +0 -0
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/setup.cfg +0 -0
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack/__main__.py +0 -0
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack/entrypoint.py +0 -0
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack/pytest_plugin.py +0 -0
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack/runtime.py +0 -0
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack/testing.py +0 -0
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack_cli.egg-info/dependency_links.txt +0 -0
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack_cli.egg-info/entry_points.txt +0 -0
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack_cli.egg-info/requires.txt +0 -0
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack_cli.egg-info/top_level.txt +0 -0
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/tests/test_completion.py +0 -0
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/tests/test_cross_shell.py +0 -0
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/tests/test_testing.py +0 -0
- {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/tests/test_validate.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: toolrack-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Turn a folder of small scripts plus YAML sidecars into one typed, completable CLI
|
|
5
5
|
Author: Jan Chimiak
|
|
6
6
|
License-Expression: MIT
|
|
@@ -222,6 +222,13 @@ Defaults:
|
|
|
222
222
|
- `core refresh-cache`: rebuild the cached command metadata next to `.toolrack`
|
|
223
223
|
- `core list`: show registry entries and sidecar status
|
|
224
224
|
- `core install-completion <shell>`: print shell completion source
|
|
225
|
+
- `core validate [--strict]`: read-only repository check for CI (see [Testing Your Tools](#testing-your-tools))
|
|
226
|
+
- `core init [path] [--no-example]`: create a minimal scripts repository; never overwrites existing files
|
|
227
|
+
- `core doctor`: diagnose the installation and setup (legacy `toolrack` install shadowing `toolrack-cli`,
|
|
228
|
+
missing `bash` or `psql` for registered scripts, command not on `PATH`, validation errors); exits 1 on failures
|
|
229
|
+
|
|
230
|
+
Starting without the template: `pip install toolrack-cli`, then
|
|
231
|
+
`python -m toolrack core init my-tools` and `cd my-tools`.
|
|
225
232
|
|
|
226
233
|
## Scripts Repo Contract
|
|
227
234
|
|
|
@@ -254,8 +261,11 @@ For a starter repository layout, use
|
|
|
254
261
|
|
|
255
262
|
Completion is answered from `.toolrack.cache.json` without importing Click, so
|
|
256
263
|
<TAB> stays fast; anything the fast path does not model exactly falls back to
|
|
257
|
-
Click. `core install-completion
|
|
264
|
+
Click. `core install-completion <shell>` prints a script that calls the Python
|
|
258
265
|
interpreter directly and falls back to the wrapper if that interpreter moves.
|
|
266
|
+
This applies to bash, zsh and fish; the zsh and fish scripts are covered by
|
|
267
|
+
string-level tests only, not exercised in a real zsh or fish, so report any
|
|
268
|
+
problems.
|
|
259
269
|
|
|
260
270
|
## Development
|
|
261
271
|
|
|
@@ -196,6 +196,13 @@ Defaults:
|
|
|
196
196
|
- `core refresh-cache`: rebuild the cached command metadata next to `.toolrack`
|
|
197
197
|
- `core list`: show registry entries and sidecar status
|
|
198
198
|
- `core install-completion <shell>`: print shell completion source
|
|
199
|
+
- `core validate [--strict]`: read-only repository check for CI (see [Testing Your Tools](#testing-your-tools))
|
|
200
|
+
- `core init [path] [--no-example]`: create a minimal scripts repository; never overwrites existing files
|
|
201
|
+
- `core doctor`: diagnose the installation and setup (legacy `toolrack` install shadowing `toolrack-cli`,
|
|
202
|
+
missing `bash` or `psql` for registered scripts, command not on `PATH`, validation errors); exits 1 on failures
|
|
203
|
+
|
|
204
|
+
Starting without the template: `pip install toolrack-cli`, then
|
|
205
|
+
`python -m toolrack core init my-tools` and `cd my-tools`.
|
|
199
206
|
|
|
200
207
|
## Scripts Repo Contract
|
|
201
208
|
|
|
@@ -228,8 +235,11 @@ For a starter repository layout, use
|
|
|
228
235
|
|
|
229
236
|
Completion is answered from `.toolrack.cache.json` without importing Click, so
|
|
230
237
|
<TAB> stays fast; anything the fast path does not model exactly falls back to
|
|
231
|
-
Click. `core install-completion
|
|
238
|
+
Click. `core install-completion <shell>` prints a script that calls the Python
|
|
232
239
|
interpreter directly and falls back to the wrapper if that interpreter moves.
|
|
240
|
+
This applies to bash, zsh and fish; the zsh and fish scripts are covered by
|
|
241
|
+
string-level tests only, not exercised in a real zsh or fish, so report any
|
|
242
|
+
problems.
|
|
233
243
|
|
|
234
244
|
## Development
|
|
235
245
|
|
|
@@ -12,9 +12,10 @@ from dataclasses import dataclass
|
|
|
12
12
|
|
|
13
13
|
import click
|
|
14
14
|
|
|
15
|
-
from toolrack import __version__, runtime
|
|
15
|
+
from toolrack import __version__, runtime, shells, sidecar as _sidecar
|
|
16
16
|
from toolrack.runtime import RepositoryContext
|
|
17
17
|
|
|
18
|
+
_is_windows = shells.is_windows
|
|
18
19
|
CLI_NAME = runtime.cli_name()
|
|
19
20
|
COMPLETION_VAR = runtime.completion_var(CLI_NAME)
|
|
20
21
|
DEFAULT_REGISTRY_BASENAME = runtime.registry_basename()
|
|
@@ -23,9 +24,6 @@ DEFAULT_REGISTRY_BASENAME = runtime.registry_basename()
|
|
|
23
24
|
# the way once toolrack is used as an installed library as well as a checkout.
|
|
24
25
|
|
|
25
26
|
|
|
26
|
-
def _is_windows() -> bool:
|
|
27
|
-
return os.name == "nt"
|
|
28
|
-
|
|
29
27
|
|
|
30
28
|
def _fix_windows_completion_crlf() -> None:
|
|
31
29
|
if not _is_windows():
|
|
@@ -185,134 +183,16 @@ def _load_aliases(repo: RepositoryContext | None = None) -> dict[str, str]:
|
|
|
185
183
|
|
|
186
184
|
|
|
187
185
|
_sidecar_path = runtime.sidecar_path
|
|
186
|
+
_to_bash_path = shells.to_bash_path
|
|
187
|
+
_resolve_bash_exe = shells.resolve_bash_exe
|
|
188
|
+
_load_sidecar = _sidecar.load_sidecar
|
|
189
|
+
_validate_sidecar = _sidecar.validate_sidecar
|
|
188
190
|
|
|
189
191
|
|
|
190
192
|
def _generate_sidecar(script_path: str) -> dict:
|
|
191
|
-
|
|
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"
|
|
193
|
+
return _sidecar.generate_sidecar(script_path, CLI_NAME)
|
|
297
194
|
|
|
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
195
|
|
|
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
196
|
|
|
317
197
|
|
|
318
198
|
def _interpreter(script_path: str) -> list[str]:
|
|
@@ -969,6 +849,46 @@ def cmd_validate(strict: bool):
|
|
|
969
849
|
raise click.exceptions.Exit(1)
|
|
970
850
|
|
|
971
851
|
|
|
852
|
+
@core.command("init")
|
|
853
|
+
@click.argument("path", default=".", required=False)
|
|
854
|
+
@click.option("--no-example", is_flag=True, help="Do not create the example script.")
|
|
855
|
+
def cmd_init(path: str, no_example: bool):
|
|
856
|
+
"""Create a minimal scripts repository (never overwrites existing files)."""
|
|
857
|
+
from toolrack import scaffold
|
|
858
|
+
|
|
859
|
+
root = os.path.abspath(path)
|
|
860
|
+
try:
|
|
861
|
+
results = scaffold.scaffold_repository(root, example=not no_example)
|
|
862
|
+
except OSError as exc:
|
|
863
|
+
raise click.ClickException(str(exc))
|
|
864
|
+
for rel, status in results:
|
|
865
|
+
click.echo(f" {status.upper():<8} {rel}")
|
|
866
|
+
repo = scaffold.repository_context(root)
|
|
867
|
+
_refresh_cache(repo)
|
|
868
|
+
click.echo(
|
|
869
|
+
f"\nRepository ready at {root}\n"
|
|
870
|
+
f"Next: cd there, then run `python -m toolrack --help`"
|
|
871
|
+
+ ("" if no_example else " and `python -m toolrack example hello --name you`")
|
|
872
|
+
+ ".\nAdd a script plus a .yml sidecar under scripts/, then `core auto-register`."
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
@core.command("doctor")
|
|
877
|
+
def cmd_doctor():
|
|
878
|
+
"""Diagnose the toolrack installation and repository setup."""
|
|
879
|
+
from toolrack import doctor
|
|
880
|
+
|
|
881
|
+
checks = doctor.run_checks(_current_repo(), CLI_NAME, _check_repository)
|
|
882
|
+
for check in checks:
|
|
883
|
+
tag = {"ok": "ok ", "warn": "WARN", "fail": "FAIL"}[check.level]
|
|
884
|
+
click.echo(f" [{tag}] {check.name}: {check.detail}", err=check.level != "ok")
|
|
885
|
+
failed = sum(1 for c in checks if c.level == doctor.FAIL)
|
|
886
|
+
warned = sum(1 for c in checks if c.level == doctor.WARN)
|
|
887
|
+
click.echo(f"\n{len(checks) - failed - warned} ok, {warned} warnings, {failed} failures.")
|
|
888
|
+
if failed:
|
|
889
|
+
raise click.exceptions.Exit(1)
|
|
890
|
+
|
|
891
|
+
|
|
972
892
|
@core.command("refresh-cache")
|
|
973
893
|
def cmd_refresh_cache():
|
|
974
894
|
"""Rebuild the command metadata cache next to the registry."""
|
|
@@ -993,6 +913,92 @@ def cmd_list():
|
|
|
993
913
|
click.echo(f" [{status}] {entry}{sidecar_tag}")
|
|
994
914
|
|
|
995
915
|
|
|
916
|
+
def _direct_completion_target() -> tuple[str, dict[str, str]]:
|
|
917
|
+
"""Interpreter and environment the wrapper would use, for direct completion."""
|
|
918
|
+
repo = _current_repo()
|
|
919
|
+
python = _to_bash_path(sys.executable, _resolve_bash_exe())
|
|
920
|
+
env = {
|
|
921
|
+
"TOOLRACK_CLI_NAME": CLI_NAME,
|
|
922
|
+
"TOOLRACK_REPO_ROOT": repo.repo_root,
|
|
923
|
+
"TOOLRACK_SCRIPTS_ROOT": repo.scripts_root,
|
|
924
|
+
"TOOLRACK_REGISTRY_FILE": repo.registry_file,
|
|
925
|
+
"TOOLRACK_CACHE_FILE": repo.cache_file,
|
|
926
|
+
"TOOLRACK_ALIASES_FILE": repo.aliases_file,
|
|
927
|
+
}
|
|
928
|
+
return python, env
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _direct_completion_helper_name() -> str:
|
|
932
|
+
ident = "".join(ch if ch.isalnum() else "_" for ch in CLI_NAME)
|
|
933
|
+
return f"_{ident}_completion_direct"
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _bypass_wrapper_in_zsh_completion(source: str) -> str:
|
|
937
|
+
"""Zsh counterpart of the Bash bypass; same fallback to the wrapper.
|
|
938
|
+
|
|
939
|
+
Only rewrites the exact invocation Click emits and otherwise returns the
|
|
940
|
+
script unchanged, so an unexpected Click version degrades to the old path.
|
|
941
|
+
"""
|
|
942
|
+
import shlex
|
|
943
|
+
|
|
944
|
+
call = (
|
|
945
|
+
f'env COMP_WORDS="${{words[*]}}" COMP_CWORD=$((CURRENT-1)) '
|
|
946
|
+
f"{COMPLETION_VAR}=zsh_complete {CLI_NAME}"
|
|
947
|
+
)
|
|
948
|
+
if call not in source:
|
|
949
|
+
return source
|
|
950
|
+
|
|
951
|
+
python, env = _direct_completion_target()
|
|
952
|
+
assignments = " ".join(f"{name}={shlex.quote(value)}" for name, value in env.items())
|
|
953
|
+
helper = _direct_completion_helper_name()
|
|
954
|
+
prefix = f'env COMP_WORDS="$1" COMP_CWORD="$2" {COMPLETION_VAR}=zsh_complete'
|
|
955
|
+
definition = (
|
|
956
|
+
f"{helper}() {{\n"
|
|
957
|
+
f" if [ -x {shlex.quote(python)} ]; then\n"
|
|
958
|
+
f" {prefix} {assignments} {shlex.quote(python)} -m toolrack\n"
|
|
959
|
+
f" else\n"
|
|
960
|
+
f" {prefix} {shlex.quote(CLI_NAME)}\n"
|
|
961
|
+
f" fi\n"
|
|
962
|
+
f"}}\n\n"
|
|
963
|
+
)
|
|
964
|
+
source = source.replace(call, f'{helper} "${{words[*]}}" $((CURRENT-1))', 1)
|
|
965
|
+
# `#compdef` must stay the first line of the file.
|
|
966
|
+
if source.startswith("#compdef"):
|
|
967
|
+
first, _, rest = source.partition("\n")
|
|
968
|
+
return f"{first}\n\n{definition}{rest}"
|
|
969
|
+
return definition + source
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _bypass_wrapper_in_fish_completion(source: str) -> str:
|
|
973
|
+
"""Fish counterpart of the Bash bypass; same fallback to the wrapper."""
|
|
974
|
+
call = (
|
|
975
|
+
f"env {COMPLETION_VAR}=fish_complete COMP_WORDS=(commandline -cp) "
|
|
976
|
+
f"COMP_CWORD=(commandline -t) {CLI_NAME}"
|
|
977
|
+
)
|
|
978
|
+
if call not in source:
|
|
979
|
+
return source
|
|
980
|
+
|
|
981
|
+
python, env = _direct_completion_target()
|
|
982
|
+
# Single-quoted fish strings cannot represent an embedded apostrophe
|
|
983
|
+
# portably; leave the script alone rather than emit a broken one.
|
|
984
|
+
if any("'" in value for value in (python, *env.values())):
|
|
985
|
+
return source
|
|
986
|
+
assignments = " ".join(f"{name}='{value}'" for name, value in env.items())
|
|
987
|
+
helper = _direct_completion_helper_name()
|
|
988
|
+
prefix = f'env COMP_WORDS="$argv[1]" COMP_CWORD="$argv[2]" {COMPLETION_VAR}=fish_complete'
|
|
989
|
+
definition = (
|
|
990
|
+
f"function {helper}\n"
|
|
991
|
+
f" if test -x '{python}'\n"
|
|
992
|
+
f" {prefix} {assignments} '{python}' -m toolrack\n"
|
|
993
|
+
f" else\n"
|
|
994
|
+
f" {prefix} {CLI_NAME}\n"
|
|
995
|
+
f" end\n"
|
|
996
|
+
f"end\n\n"
|
|
997
|
+
)
|
|
998
|
+
source = source.replace(call, f"{helper} (commandline -cp) (commandline -t)", 1)
|
|
999
|
+
return definition + source
|
|
1000
|
+
|
|
1001
|
+
|
|
996
1002
|
def _bypass_wrapper_in_bash_completion(source: str) -> str:
|
|
997
1003
|
"""Make Bash completion call this interpreter directly instead of the wrapper.
|
|
998
1004
|
|
|
@@ -1008,19 +1014,9 @@ def _bypass_wrapper_in_bash_completion(source: str) -> str:
|
|
|
1008
1014
|
if call not in source:
|
|
1009
1015
|
return source
|
|
1010
1016
|
|
|
1011
|
-
|
|
1012
|
-
python = _to_bash_path(sys.executable, _resolve_bash_exe())
|
|
1013
|
-
env = {
|
|
1014
|
-
"TOOLRACK_CLI_NAME": CLI_NAME,
|
|
1015
|
-
"TOOLRACK_REPO_ROOT": repo.repo_root,
|
|
1016
|
-
"TOOLRACK_SCRIPTS_ROOT": repo.scripts_root,
|
|
1017
|
-
"TOOLRACK_REGISTRY_FILE": repo.registry_file,
|
|
1018
|
-
"TOOLRACK_CACHE_FILE": repo.cache_file,
|
|
1019
|
-
"TOOLRACK_ALIASES_FILE": repo.aliases_file,
|
|
1020
|
-
}
|
|
1017
|
+
python, env = _direct_completion_target()
|
|
1021
1018
|
assignments = " ".join(f"{name}={shlex.quote(value)}" for name, value in env.items())
|
|
1022
|
-
|
|
1023
|
-
helper = f"_{ident}_completion_direct"
|
|
1019
|
+
helper = _direct_completion_helper_name()
|
|
1024
1020
|
definition = (
|
|
1025
1021
|
f"{helper}() {{\n"
|
|
1026
1022
|
f" if [ -x {shlex.quote(python)} ]; then\n"
|
|
@@ -1050,6 +1046,10 @@ def install_completion(shell: str):
|
|
|
1050
1046
|
# more explicit and less brittle than editing generated shell text.
|
|
1051
1047
|
source = source.replace("$(env ", "$(")
|
|
1052
1048
|
source = _bypass_wrapper_in_bash_completion(source)
|
|
1049
|
+
elif shell == "zsh":
|
|
1050
|
+
source = _bypass_wrapper_in_zsh_completion(source)
|
|
1051
|
+
elif shell == "fish":
|
|
1052
|
+
source = _bypass_wrapper_in_fish_completion(source)
|
|
1053
1053
|
sys.stdout.write(source)
|
|
1054
1054
|
sys.stdout.flush()
|
|
1055
1055
|
|
|
@@ -54,6 +54,19 @@ CORE_COMMANDS: dict[str, dict] = {
|
|
|
54
54
|
"args": [{"name": "script_path", "positional": True, "required": True}],
|
|
55
55
|
},
|
|
56
56
|
"reregister": {"description": "Re-validate all registered commands."},
|
|
57
|
+
"init": {
|
|
58
|
+
"description": "Create a minimal scripts repository (never overwrites existing files).",
|
|
59
|
+
"args": [
|
|
60
|
+
{"name": "path", "positional": True},
|
|
61
|
+
{
|
|
62
|
+
"name": "no_example",
|
|
63
|
+
"flag": True,
|
|
64
|
+
"option": "--no-example",
|
|
65
|
+
"help": "Do not create the example script.",
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
"doctor": {"description": "Diagnose the toolrack installation and repository setup."},
|
|
57
70
|
"validate": {
|
|
58
71
|
"description": "Check registry, sidecars and aliases without changing anything (for CI).",
|
|
59
72
|
"args": [
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Environment and installation diagnostics (`toolrack doctor`).
|
|
2
|
+
|
|
3
|
+
Complements `core validate`, which checks the repository's contents. Doctor
|
|
4
|
+
asks "is toolrack itself set up sanely on this machine?". Click-free; every
|
|
5
|
+
probe is a small function so it can be tested with injected inputs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Callable, NamedTuple
|
|
15
|
+
|
|
16
|
+
from toolrack import __version__, runtime
|
|
17
|
+
|
|
18
|
+
OK, WARN, FAIL = "ok", "warn", "fail"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Check(NamedTuple):
|
|
22
|
+
level: str
|
|
23
|
+
name: str
|
|
24
|
+
detail: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _install_description() -> str:
|
|
28
|
+
"""How this copy of toolrack was installed (editable checkout vs normal)."""
|
|
29
|
+
from importlib import metadata
|
|
30
|
+
|
|
31
|
+
location = os.path.dirname(os.path.abspath(__file__))
|
|
32
|
+
try:
|
|
33
|
+
dist = metadata.distribution("toolrack-cli")
|
|
34
|
+
raw = dist.read_text("direct_url.json")
|
|
35
|
+
if raw and json.loads(raw).get("dir_info", {}).get("editable"):
|
|
36
|
+
return f"{__version__}, editable install from {location}"
|
|
37
|
+
except (metadata.PackageNotFoundError, ValueError):
|
|
38
|
+
pass
|
|
39
|
+
return f"{__version__} at {location}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def legacy_distribution() -> str | None:
|
|
43
|
+
"""Version of a separately installed ``toolrack`` distribution, if any.
|
|
44
|
+
|
|
45
|
+
Before the PyPI release the engine was installed as ``toolrack``. Any
|
|
46
|
+
distribution of that name ships the same import package as ``toolrack-cli``
|
|
47
|
+
and can shadow it.
|
|
48
|
+
"""
|
|
49
|
+
from importlib import metadata
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
return metadata.version("toolrack")
|
|
53
|
+
except metadata.PackageNotFoundError:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run_checks(
|
|
58
|
+
repo: runtime.RepositoryContext,
|
|
59
|
+
cli_name: str,
|
|
60
|
+
check_repository: Callable[[runtime.RepositoryContext], tuple[list[str], list]],
|
|
61
|
+
registry_entries: list[str] | None = None,
|
|
62
|
+
) -> list[Check]:
|
|
63
|
+
checks: list[Check] = []
|
|
64
|
+
add = lambda level, name, detail: checks.append(Check(level, name, detail)) # noqa: E731
|
|
65
|
+
|
|
66
|
+
add(OK, "toolrack", _install_description())
|
|
67
|
+
add(OK, "python", f"{sys.version.split()[0]} at {sys.executable}")
|
|
68
|
+
|
|
69
|
+
legacy = legacy_distribution()
|
|
70
|
+
if legacy:
|
|
71
|
+
add(
|
|
72
|
+
WARN,
|
|
73
|
+
"legacy install",
|
|
74
|
+
f"a separate 'toolrack' distribution ({legacy}) is installed and can shadow toolrack-cli; "
|
|
75
|
+
"run: pip uninstall toolrack",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if not os.path.isfile(repo.registry_file):
|
|
79
|
+
add(FAIL, "repository", f"no registry at {repo.registry_file} (run: {cli_name} init)")
|
|
80
|
+
return checks
|
|
81
|
+
add(OK, "repository", repo.repo_root)
|
|
82
|
+
|
|
83
|
+
if not os.path.isdir(repo.scripts_root):
|
|
84
|
+
add(FAIL, "scripts dir", f"missing: {repo.scripts_root}")
|
|
85
|
+
legacy_registry = os.path.join(repo.repo_root, runtime.LEGACY_REGISTRY_BASENAME)
|
|
86
|
+
if os.path.isfile(legacy_registry) and os.path.basename(repo.registry_file) != runtime.LEGACY_REGISTRY_BASENAME:
|
|
87
|
+
add(WARN, "legacy registry", f"{legacy_registry} is ignored; rename it to {runtime.DEFAULT_REGISTRY_BASENAME}")
|
|
88
|
+
|
|
89
|
+
entries = registry_entries if registry_entries is not None else runtime.read_registry(repo)
|
|
90
|
+
add(OK, "registered", f"{len(entries)} script(s)")
|
|
91
|
+
|
|
92
|
+
exts = {os.path.splitext(entry)[1].lower() for entry in entries}
|
|
93
|
+
if exts & {".sh", ".bash"}:
|
|
94
|
+
from toolrack import shells
|
|
95
|
+
|
|
96
|
+
bash = _working_bash(shells.resolve_bash_exe())
|
|
97
|
+
add(OK if bash else FAIL, "bash", bash or "registered .sh/.bash scripts need a working bash")
|
|
98
|
+
if ".sql" in exts:
|
|
99
|
+
psql = shutil.which("psql")
|
|
100
|
+
add(OK if psql else FAIL, "psql", psql or "registered .sql scripts run through psql, which is not on PATH")
|
|
101
|
+
|
|
102
|
+
wrapper = shutil.which(cli_name)
|
|
103
|
+
add(OK if wrapper else WARN, "command on PATH", wrapper or f"'{cli_name}' not found on PATH (open a new shell?)")
|
|
104
|
+
|
|
105
|
+
ok, diagnostics = check_repository(repo)
|
|
106
|
+
errors = [d for d in diagnostics if d.level == "error"]
|
|
107
|
+
warnings = [d for d in diagnostics if d.level == "warning"]
|
|
108
|
+
if errors:
|
|
109
|
+
add(FAIL, "validate", f"{len(errors)} error(s); run: {cli_name} core validate")
|
|
110
|
+
elif warnings:
|
|
111
|
+
add(WARN, "validate", f"{len(warnings)} warning(s); run: {cli_name} core validate")
|
|
112
|
+
else:
|
|
113
|
+
add(OK, "validate", f"{len(ok)} script(s) valid")
|
|
114
|
+
return checks
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _working_bash(candidate: str) -> str | None:
|
|
118
|
+
import subprocess
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
done = subprocess.run(
|
|
122
|
+
[candidate, "-c", "echo toolrack"], capture_output=True, text=True, timeout=15, check=False
|
|
123
|
+
)
|
|
124
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
125
|
+
return None
|
|
126
|
+
return candidate if done.returncode == 0 and done.stdout.strip() == "toolrack" else None
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Create a minimal scripts repository (`toolrack init`). Click-free and non-destructive."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from toolrack import runtime
|
|
8
|
+
|
|
9
|
+
EXAMPLE_SCRIPT = '''\
|
|
10
|
+
import argparse
|
|
11
|
+
|
|
12
|
+
parser = argparse.ArgumentParser(description="Print a greeting.")
|
|
13
|
+
parser.add_argument("--name", required=True)
|
|
14
|
+
print(f"hello, {parser.parse_args().name}")
|
|
15
|
+
'''
|
|
16
|
+
|
|
17
|
+
EXAMPLE_SIDECAR = """\
|
|
18
|
+
description: Print a greeting.
|
|
19
|
+
args:
|
|
20
|
+
- name: name
|
|
21
|
+
required: true
|
|
22
|
+
help: Name to greet.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
EXAMPLE_ENTRY = "scripts/example/hello.py"
|
|
26
|
+
CACHE_IGNORE_LINE = ".toolrack.cache.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def scaffold_repository(root: str, example: bool = True) -> list[tuple[str, str]]:
|
|
30
|
+
"""Create the repository layout under ``root``; never overwrite anything.
|
|
31
|
+
|
|
32
|
+
Returns ``(relative_path, status)`` pairs where status is ``created``,
|
|
33
|
+
``updated`` or ``kept``.
|
|
34
|
+
"""
|
|
35
|
+
root = os.path.abspath(root)
|
|
36
|
+
registry_name = runtime.registry_basename()
|
|
37
|
+
results: list[tuple[str, str]] = []
|
|
38
|
+
|
|
39
|
+
def write_new(rel: str, content: str) -> None:
|
|
40
|
+
path = os.path.join(root, rel)
|
|
41
|
+
if os.path.exists(path):
|
|
42
|
+
results.append((rel, "kept"))
|
|
43
|
+
return
|
|
44
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
45
|
+
with open(path, "w", encoding="utf-8", newline="\n") as handle:
|
|
46
|
+
handle.write(content)
|
|
47
|
+
results.append((rel, "created"))
|
|
48
|
+
|
|
49
|
+
os.makedirs(os.path.join(root, "scripts"), exist_ok=True)
|
|
50
|
+
|
|
51
|
+
registry_path = os.path.join(root, registry_name)
|
|
52
|
+
if example:
|
|
53
|
+
write_new(EXAMPLE_ENTRY, EXAMPLE_SCRIPT)
|
|
54
|
+
write_new("scripts/example/hello.yml", EXAMPLE_SIDECAR)
|
|
55
|
+
if not os.path.exists(registry_path):
|
|
56
|
+
write_new(registry_name, (EXAMPLE_ENTRY + "\n") if example else "")
|
|
57
|
+
else:
|
|
58
|
+
existing = runtime.read_registry(_context(root))
|
|
59
|
+
if example and EXAMPLE_ENTRY not in existing and os.path.isfile(os.path.join(root, EXAMPLE_ENTRY)):
|
|
60
|
+
with open(registry_path, "a", encoding="utf-8", newline="\n") as handle:
|
|
61
|
+
handle.write(EXAMPLE_ENTRY + "\n")
|
|
62
|
+
results.append((registry_name, "updated"))
|
|
63
|
+
else:
|
|
64
|
+
results.append((registry_name, "kept"))
|
|
65
|
+
|
|
66
|
+
gitignore = os.path.join(root, ".gitignore")
|
|
67
|
+
if os.path.exists(gitignore):
|
|
68
|
+
with open(gitignore, encoding="utf-8") as handle:
|
|
69
|
+
text = handle.read()
|
|
70
|
+
if CACHE_IGNORE_LINE not in text.splitlines():
|
|
71
|
+
with open(gitignore, "a", encoding="utf-8", newline="\n") as handle:
|
|
72
|
+
handle.write(("" if text.endswith("\n") or not text else "\n") + CACHE_IGNORE_LINE + "\n")
|
|
73
|
+
results.append((".gitignore", "updated"))
|
|
74
|
+
else:
|
|
75
|
+
results.append((".gitignore", "kept"))
|
|
76
|
+
else:
|
|
77
|
+
write_new(".gitignore", CACHE_IGNORE_LINE + "\n")
|
|
78
|
+
return results
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _context(root: str) -> runtime.RepositoryContext:
|
|
82
|
+
registry = os.path.join(root, runtime.registry_basename())
|
|
83
|
+
return runtime.RepositoryContext(
|
|
84
|
+
repo_root=root,
|
|
85
|
+
scripts_root=os.path.join(root, "scripts"),
|
|
86
|
+
registry_file=registry,
|
|
87
|
+
cache_file=registry + ".cache.json",
|
|
88
|
+
aliases_file=os.path.join(root, "aliases.cfg"),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def repository_context(root: str) -> runtime.RepositoryContext:
|
|
93
|
+
return _context(os.path.abspath(root))
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Windows / Git Bash / Cygwin path and interpreter handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_windows() -> bool:
|
|
10
|
+
return os.name == "nt"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def to_bash_path(windows_path: str, bash_exe: str = "") -> str:
|
|
14
|
+
# TODO(#5): Isolate shell/path translation behind a shell adapter layer.
|
|
15
|
+
# Windows, Git Bash, and Cygwin compatibility logic is mixed into the core
|
|
16
|
+
# dispatcher, which makes the module harder to reason about than it should be.
|
|
17
|
+
if not is_windows():
|
|
18
|
+
return windows_path
|
|
19
|
+
|
|
20
|
+
cygpath_candidates: list[str] = []
|
|
21
|
+
if bash_exe:
|
|
22
|
+
bash_dir = os.path.dirname(os.path.abspath(bash_exe))
|
|
23
|
+
same_dir = os.path.join(bash_dir, "cygpath.exe")
|
|
24
|
+
parent_usr_bin = os.path.join(os.path.dirname(bash_dir), "usr", "bin", "cygpath.exe")
|
|
25
|
+
for candidate in (same_dir, parent_usr_bin):
|
|
26
|
+
if os.path.isfile(candidate):
|
|
27
|
+
cygpath_candidates.append(candidate)
|
|
28
|
+
cygpath_candidates.append("cygpath")
|
|
29
|
+
|
|
30
|
+
for cygpath_exe in cygpath_candidates:
|
|
31
|
+
try:
|
|
32
|
+
result = subprocess.run(
|
|
33
|
+
[cygpath_exe, "-u", windows_path],
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
timeout=5,
|
|
37
|
+
)
|
|
38
|
+
if result.returncode == 0:
|
|
39
|
+
return result.stdout.strip()
|
|
40
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
path = windows_path.replace("\\", "/")
|
|
44
|
+
if len(path) >= 2 and path[1] == ":":
|
|
45
|
+
path = "/" + path[0].lower() + path[2:]
|
|
46
|
+
return path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve_bash_exe() -> str:
|
|
50
|
+
if not is_windows():
|
|
51
|
+
return "bash"
|
|
52
|
+
|
|
53
|
+
cygwin_bash = r"C:\cygwin64\bin\bash.exe"
|
|
54
|
+
git_bash = r"C:\Program Files\Git\bin\bash.exe"
|
|
55
|
+
|
|
56
|
+
if not os.environ.get("MSYSTEM"):
|
|
57
|
+
path_dirs = os.environ.get("PATH", "").split(os.pathsep)
|
|
58
|
+
cygwin_bin = r"C:\cygwin64\bin"
|
|
59
|
+
gitbash_bin = r"C:\Program Files\Git\bin"
|
|
60
|
+
|
|
61
|
+
def _norm(value: str) -> str:
|
|
62
|
+
return os.path.normcase(os.path.normpath(value))
|
|
63
|
+
|
|
64
|
+
normed = [_norm(entry) for entry in path_dirs]
|
|
65
|
+
cygwin_idx = next((idx for idx, entry in enumerate(normed) if entry == _norm(cygwin_bin)), 999)
|
|
66
|
+
gitbash_idx = next((idx for idx, entry in enumerate(normed) if entry == _norm(gitbash_bin)), 999)
|
|
67
|
+
if cygwin_idx < gitbash_idx and os.path.isfile(cygwin_bash):
|
|
68
|
+
return cygwin_bash
|
|
69
|
+
|
|
70
|
+
if os.environ.get("MSYSTEM") and os.path.isfile(git_bash):
|
|
71
|
+
return git_bash
|
|
72
|
+
|
|
73
|
+
return "bash"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Sidecar (.yml) loading, generation and validation.
|
|
2
|
+
|
|
3
|
+
Click-free; PyYAML is imported lazily so importing this module stays cheap.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from toolrack import runtime
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def generate_sidecar(script_path: str, cli_name: str) -> dict:
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
name = os.path.basename(script_path)
|
|
17
|
+
sidecar = {
|
|
18
|
+
"# autogenerated": (
|
|
19
|
+
f"This sidecar was created automatically by `{cli_name} core register`.\n"
|
|
20
|
+
"Arguments are passed through verbatim. Replace it with a typed sidecar."
|
|
21
|
+
),
|
|
22
|
+
"description": f"Run {name} (autogenerated; edit to add proper description).",
|
|
23
|
+
"args": [
|
|
24
|
+
{
|
|
25
|
+
"name": "args",
|
|
26
|
+
"positional": True,
|
|
27
|
+
"variadic": True,
|
|
28
|
+
"required": False,
|
|
29
|
+
"help": "All arguments passed through verbatim to the script.",
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
}
|
|
33
|
+
with open(runtime.sidecar_path(script_path), "w", encoding="utf-8") as handle:
|
|
34
|
+
yaml.dump(sidecar, handle, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
35
|
+
return sidecar
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_sidecar(script_path: str) -> dict | None:
|
|
39
|
+
yml_path = runtime.sidecar_path(script_path)
|
|
40
|
+
if not os.path.isfile(yml_path):
|
|
41
|
+
return None
|
|
42
|
+
import yaml
|
|
43
|
+
|
|
44
|
+
with open(yml_path, encoding="utf-8") as handle:
|
|
45
|
+
data = yaml.safe_load(handle) or {}
|
|
46
|
+
if not isinstance(data, dict):
|
|
47
|
+
raise RuntimeError(f"{yml_path}: sidecar must be a YAML mapping, got {type(data).__name__}")
|
|
48
|
+
return data
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def validate_sidecar(sidecar: dict, script_path: str) -> None:
|
|
52
|
+
ext = os.path.splitext(script_path)[1].lower()
|
|
53
|
+
args = sidecar.get("args") or []
|
|
54
|
+
name = os.path.basename(script_path)
|
|
55
|
+
|
|
56
|
+
if ext == ".sql" and args:
|
|
57
|
+
raise RuntimeError(f"{name}: SQL scripts must not declare 'args'.")
|
|
58
|
+
|
|
59
|
+
seen_names: set[str] = set()
|
|
60
|
+
variadic_seen = False
|
|
61
|
+
for arg in args:
|
|
62
|
+
arg_name = arg.get("name", "")
|
|
63
|
+
if arg_name in seen_names:
|
|
64
|
+
raise RuntimeError(f"{name}: duplicate arg name '{arg_name}'.")
|
|
65
|
+
seen_names.add(arg_name)
|
|
66
|
+
if variadic_seen:
|
|
67
|
+
raise RuntimeError(f"{name}: '{arg_name}' appears after variadic; variadic must be last.")
|
|
68
|
+
if arg.get("variadic"):
|
|
69
|
+
if not arg.get("positional"):
|
|
70
|
+
raise RuntimeError(f"{name}: '{arg_name}' is variadic but not positional.")
|
|
71
|
+
variadic_seen = True
|
|
72
|
+
if arg.get("multiple") and arg.get("positional"):
|
|
73
|
+
raise RuntimeError(f"{name}: '{arg_name}' cannot be both multiple and positional.")
|
|
74
|
+
if arg.get("flag") and arg.get("type") not in (None, "bool"):
|
|
75
|
+
raise RuntimeError(f"{name}: '{arg_name}' is a flag but declares type '{arg['type']}'.")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: toolrack-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Turn a folder of small scripts plus YAML sidecars into one typed, completable CLI
|
|
5
5
|
Author: Jan Chimiak
|
|
6
6
|
License-Expression: MIT
|
|
@@ -222,6 +222,13 @@ Defaults:
|
|
|
222
222
|
- `core refresh-cache`: rebuild the cached command metadata next to `.toolrack`
|
|
223
223
|
- `core list`: show registry entries and sidecar status
|
|
224
224
|
- `core install-completion <shell>`: print shell completion source
|
|
225
|
+
- `core validate [--strict]`: read-only repository check for CI (see [Testing Your Tools](#testing-your-tools))
|
|
226
|
+
- `core init [path] [--no-example]`: create a minimal scripts repository; never overwrites existing files
|
|
227
|
+
- `core doctor`: diagnose the installation and setup (legacy `toolrack` install shadowing `toolrack-cli`,
|
|
228
|
+
missing `bash` or `psql` for registered scripts, command not on `PATH`, validation errors); exits 1 on failures
|
|
229
|
+
|
|
230
|
+
Starting without the template: `pip install toolrack-cli`, then
|
|
231
|
+
`python -m toolrack core init my-tools` and `cd my-tools`.
|
|
225
232
|
|
|
226
233
|
## Scripts Repo Contract
|
|
227
234
|
|
|
@@ -254,8 +261,11 @@ For a starter repository layout, use
|
|
|
254
261
|
|
|
255
262
|
Completion is answered from `.toolrack.cache.json` without importing Click, so
|
|
256
263
|
<TAB> stays fast; anything the fast path does not model exactly falls back to
|
|
257
|
-
Click. `core install-completion
|
|
264
|
+
Click. `core install-completion <shell>` prints a script that calls the Python
|
|
258
265
|
interpreter directly and falls back to the wrapper if that interpreter moves.
|
|
266
|
+
This applies to bash, zsh and fish; the zsh and fish scripts are covered by
|
|
267
|
+
string-level tests only, not exercised in a real zsh or fish, so report any
|
|
268
|
+
problems.
|
|
259
269
|
|
|
260
270
|
## Development
|
|
261
271
|
|
|
@@ -5,9 +5,13 @@ src/toolrack/__init__.py
|
|
|
5
5
|
src/toolrack/__main__.py
|
|
6
6
|
src/toolrack/cli.py
|
|
7
7
|
src/toolrack/completion.py
|
|
8
|
+
src/toolrack/doctor.py
|
|
8
9
|
src/toolrack/entrypoint.py
|
|
9
10
|
src/toolrack/pytest_plugin.py
|
|
10
11
|
src/toolrack/runtime.py
|
|
12
|
+
src/toolrack/scaffold.py
|
|
13
|
+
src/toolrack/shells.py
|
|
14
|
+
src/toolrack/sidecar.py
|
|
11
15
|
src/toolrack/testing.py
|
|
12
16
|
src/toolrack_cli.egg-info/PKG-INFO
|
|
13
17
|
src/toolrack_cli.egg-info/SOURCES.txt
|
|
@@ -18,5 +22,6 @@ src/toolrack_cli.egg-info/top_level.txt
|
|
|
18
22
|
tests/test_cli.py
|
|
19
23
|
tests/test_completion.py
|
|
20
24
|
tests/test_cross_shell.py
|
|
25
|
+
tests/test_init_doctor.py
|
|
21
26
|
tests/test_testing.py
|
|
22
27
|
tests/test_validate.py
|
|
@@ -1060,6 +1060,83 @@ class TestDeploymentConfig:
|
|
|
1060
1060
|
|
|
1061
1061
|
assert result.stdout.strip() == "used-wrapper"
|
|
1062
1062
|
|
|
1063
|
+
def _completion_source(self, shell, runner, monkeypatch, python="/venv/python"):
|
|
1064
|
+
monkeypatch.setattr(cli, "CLI_NAME", "my-tools")
|
|
1065
|
+
monkeypatch.setattr(cli, "COMPLETION_VAR", "_MY_TOOLS_COMPLETE")
|
|
1066
|
+
monkeypatch.setattr(cli, "_resolve_bash_exe", lambda: "bash")
|
|
1067
|
+
monkeypatch.setattr(cli, "_to_bash_path", lambda path, _bash: python)
|
|
1068
|
+
result = runner.invoke(cli.cli, ["core", "install-completion", shell])
|
|
1069
|
+
assert result.exit_code == 0
|
|
1070
|
+
return result.output
|
|
1071
|
+
|
|
1072
|
+
def test_install_completion_zsh_calls_interpreter_directly(self, repo, runner, monkeypatch):
|
|
1073
|
+
source = self._completion_source("zsh", runner, monkeypatch)
|
|
1074
|
+
|
|
1075
|
+
assert source.startswith("#compdef my-tools\n")
|
|
1076
|
+
assert "env COMP_WORDS" not in source.split("_my_tools_completion_direct() {", 1)[0]
|
|
1077
|
+
assert 'response=("${(@f)$(_my_tools_completion_direct "${words[*]}" $((CURRENT-1)))}")' in source
|
|
1078
|
+
assert "/venv/python -m toolrack" in source
|
|
1079
|
+
assert "TOOLRACK_CLI_NAME=my-tools" in source
|
|
1080
|
+
|
|
1081
|
+
def test_install_completion_fish_calls_interpreter_directly(self, repo, runner, monkeypatch):
|
|
1082
|
+
source = self._completion_source("fish", runner, monkeypatch)
|
|
1083
|
+
|
|
1084
|
+
assert "set -l response (_my_tools_completion_direct (commandline -cp) (commandline -t));" in source
|
|
1085
|
+
assert "function _my_tools_completion_direct" in source
|
|
1086
|
+
assert "test -x '/venv/python'" in source
|
|
1087
|
+
assert "'/venv/python' -m toolrack" in source
|
|
1088
|
+
assert "TOOLRACK_CLI_NAME='my-tools'" in source
|
|
1089
|
+
|
|
1090
|
+
def test_install_completion_fish_skips_bypass_for_apostrophe_paths(self, repo, runner, monkeypatch):
|
|
1091
|
+
source = self._completion_source("fish", runner, monkeypatch, python="/it's/python")
|
|
1092
|
+
assert "_completion_direct" not in source
|
|
1093
|
+
assert "env _MY_TOOLS_COMPLETE=fish_complete" in source
|
|
1094
|
+
|
|
1095
|
+
def test_zsh_bypass_leaves_unrecognised_scripts_alone(self):
|
|
1096
|
+
assert cli._bypass_wrapper_in_zsh_completion("something else\n") == "something else\n"
|
|
1097
|
+
assert cli._bypass_wrapper_in_fish_completion("something else\n") == "something else\n"
|
|
1098
|
+
|
|
1099
|
+
@pytest.mark.parametrize("use_python", [True, False])
|
|
1100
|
+
def test_zsh_helper_runs_python_or_falls_back_to_wrapper(
|
|
1101
|
+
self, repo, runner, monkeypatch, tmp_path, use_python
|
|
1102
|
+
):
|
|
1103
|
+
import shutil
|
|
1104
|
+
import subprocess
|
|
1105
|
+
|
|
1106
|
+
from toolrack.testing import bash_available
|
|
1107
|
+
|
|
1108
|
+
if shutil.which("bash") is None or not bash_available():
|
|
1109
|
+
pytest.skip("bash not available")
|
|
1110
|
+
fake_python = tmp_path / "python"
|
|
1111
|
+
fake_python.write_text(
|
|
1112
|
+
"#!/bin/sh\necho direct:$COMP_WORDS:$COMP_CWORD:$TOOLRACK_CLI_NAME\n", encoding="utf-8", newline="\n"
|
|
1113
|
+
)
|
|
1114
|
+
fake_python.chmod(0o755)
|
|
1115
|
+
wrapper_dir = tmp_path / "wrapper"
|
|
1116
|
+
wrapper_dir.mkdir()
|
|
1117
|
+
wrapper = wrapper_dir / "my-tools"
|
|
1118
|
+
wrapper.write_text(
|
|
1119
|
+
"#!/bin/sh\necho wrapper:$COMP_WORDS:$COMP_CWORD:$_MY_TOOLS_COMPLETE\n", encoding="utf-8", newline="\n"
|
|
1120
|
+
)
|
|
1121
|
+
wrapper.chmod(0o755)
|
|
1122
|
+
python = fake_python.as_posix() if use_python else (tmp_path / "missing").as_posix()
|
|
1123
|
+
source = self._completion_source("zsh", runner, monkeypatch, python=python)
|
|
1124
|
+
start = source.index("_my_tools_completion_direct() {")
|
|
1125
|
+
helper = source[start : source.index("\n}\n", start) + 3]
|
|
1126
|
+
script = tmp_path / "helper.sh"
|
|
1127
|
+
script.write_text(helper, encoding="utf-8", newline="\n")
|
|
1128
|
+
|
|
1129
|
+
done = subprocess.run(
|
|
1130
|
+
# cygpath: on Windows Git Bash a "C:/..." entry would be split at the colon.
|
|
1131
|
+
[shutil.which("bash"), "-c",
|
|
1132
|
+
f'd="{wrapper_dir.as_posix()}"; PATH="$(cygpath -u "$d" 2>/dev/null || echo "$d"):$PATH"; '
|
|
1133
|
+
f'. "{script.as_posix()}"; _my_tools_completion_direct "my-tools co" 1'],
|
|
1134
|
+
capture_output=True, text=True, check=False,
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
expected = "direct:my-tools co:1:my-tools" if use_python else "wrapper:my-tools co:1:zsh_complete"
|
|
1138
|
+
assert done.stdout.strip() == expected, done.stderr
|
|
1139
|
+
|
|
1063
1140
|
def test_completion_returns_choice_values_for_typed_option(
|
|
1064
1141
|
self, repo, make_script, make_sidecar, monkeypatch):
|
|
1065
1142
|
from click.shell_completion import BashComplete
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""`core init` (scaffolding) and `core doctor` (installation diagnostics)."""
|
|
2
|
+
|
|
3
|
+
from toolrack import cli, doctor, scaffold
|
|
4
|
+
from toolrack.testing import ToolrackRepo
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_init_creates_a_working_repository(tmp_path):
|
|
8
|
+
root = tmp_path / "fresh"
|
|
9
|
+
repo = ToolrackRepo(root)
|
|
10
|
+
root.mkdir()
|
|
11
|
+
|
|
12
|
+
result = repo.run("core", "init", str(root)).check()
|
|
13
|
+
|
|
14
|
+
assert (root / "scripts" / "example" / "hello.py").is_file()
|
|
15
|
+
assert (root / "scripts" / "example" / "hello.yml").is_file()
|
|
16
|
+
assert repo.registered() == ["scripts/example/hello.py"]
|
|
17
|
+
assert ".toolrack.cache.json" in (root / ".gitignore").read_text()
|
|
18
|
+
assert "CREATED" in result.stdout
|
|
19
|
+
assert repo.run("example", "hello", "--name", "Ada").check().stdout.strip() == "hello, Ada"
|
|
20
|
+
repo.validate()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_init_is_idempotent_and_never_overwrites(tmp_path):
|
|
24
|
+
root = tmp_path / "again"
|
|
25
|
+
root.mkdir()
|
|
26
|
+
repo = ToolrackRepo(root)
|
|
27
|
+
repo.run("core", "init", str(root)).check()
|
|
28
|
+
hello = root / "scripts" / "example" / "hello.py"
|
|
29
|
+
hello.write_text("print('mine')\n", encoding="utf-8")
|
|
30
|
+
|
|
31
|
+
second = repo.run("core", "init", str(root)).check()
|
|
32
|
+
|
|
33
|
+
assert hello.read_text() == "print('mine')\n"
|
|
34
|
+
assert "CREATED" not in second.stdout
|
|
35
|
+
assert repo.registered() == ["scripts/example/hello.py"]
|
|
36
|
+
assert (root / ".gitignore").read_text().count(".toolrack.cache.json") == 1
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_init_appends_to_existing_gitignore_and_registry(tmp_path):
|
|
40
|
+
(tmp_path / ".gitignore").write_text("dist/", encoding="utf-8")
|
|
41
|
+
(tmp_path / ".toolrack").write_text("scripts/mine.py\n", encoding="utf-8")
|
|
42
|
+
|
|
43
|
+
scaffold.scaffold_repository(str(tmp_path))
|
|
44
|
+
|
|
45
|
+
assert (tmp_path / ".gitignore").read_text() == "dist/\n.toolrack.cache.json\n"
|
|
46
|
+
assert (tmp_path / ".toolrack").read_text() == "scripts/mine.py\nscripts/example/hello.py\n"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_init_no_example(tmp_path):
|
|
50
|
+
scaffold.scaffold_repository(str(tmp_path), example=False)
|
|
51
|
+
assert (tmp_path / "scripts").is_dir()
|
|
52
|
+
assert (tmp_path / ".toolrack").read_text() == ""
|
|
53
|
+
assert not (tmp_path / "scripts" / "example").exists()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _checks(repo, monkeypatch, legacy=None):
|
|
57
|
+
monkeypatch.setattr(doctor, "legacy_distribution", lambda: legacy)
|
|
58
|
+
return {c.name: c for c in doctor.run_checks(cli._current_repo(), "toolrack", cli._check_repository)}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_doctor_reports_missing_repository(repo, monkeypatch):
|
|
62
|
+
checks = _checks(repo, monkeypatch)
|
|
63
|
+
assert checks["repository"].level == doctor.FAIL
|
|
64
|
+
assert "init" in checks["repository"].detail
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_doctor_healthy_repository(repo, make_script, make_sidecar, monkeypatch):
|
|
68
|
+
make_script("t/a.py")
|
|
69
|
+
make_sidecar("t/a.py", {"description": "a"})
|
|
70
|
+
repo["registry"].write_text("scripts/t/a.py\n", encoding="utf-8")
|
|
71
|
+
cli._refresh_cache()
|
|
72
|
+
|
|
73
|
+
checks = _checks(repo, monkeypatch)
|
|
74
|
+
|
|
75
|
+
assert checks["repository"].level == doctor.OK
|
|
76
|
+
assert checks["registered"].detail == "1 script(s)"
|
|
77
|
+
assert checks["validate"].level == doctor.OK
|
|
78
|
+
assert "legacy install" not in checks
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_doctor_flags_validation_errors(repo, monkeypatch):
|
|
82
|
+
repo["registry"].write_text("scripts/gone.py\n", encoding="utf-8")
|
|
83
|
+
assert _checks(repo, monkeypatch)["validate"].level == doctor.FAIL
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_doctor_warns_about_legacy_distribution(repo, monkeypatch):
|
|
87
|
+
repo["registry"].write_text("", encoding="utf-8")
|
|
88
|
+
check = _checks(repo, monkeypatch, legacy="0.0.1")["legacy install"]
|
|
89
|
+
assert check.level == doctor.WARN
|
|
90
|
+
assert "pip uninstall toolrack" in check.detail
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_doctor_requires_psql_for_sql_scripts(repo, make_script, make_sidecar, monkeypatch):
|
|
94
|
+
make_script("db/q.sql")
|
|
95
|
+
make_sidecar("db/q.sql", {"description": "q"})
|
|
96
|
+
repo["registry"].write_text("scripts/db/q.sql\n", encoding="utf-8")
|
|
97
|
+
monkeypatch.setattr(doctor.shutil, "which", lambda name: None)
|
|
98
|
+
assert _checks(repo, monkeypatch)["psql"].level == doctor.FAIL
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_doctor_command_exit_code(tmp_path):
|
|
102
|
+
root = tmp_path / "r"
|
|
103
|
+
root.mkdir()
|
|
104
|
+
repo = ToolrackRepo(root)
|
|
105
|
+
assert repo.run("core", "doctor").returncode == 1
|
|
106
|
+
repo.run("core", "init", str(root)).check()
|
|
107
|
+
result = repo.run("core", "doctor")
|
|
108
|
+
assert "[ok ] repository" in result.stdout
|
|
109
|
+
assert "failures" in result.stdout
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|