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.
Files changed (29) hide show
  1. {toolrack_cli-0.2.0/src/toolrack_cli.egg-info → toolrack_cli-0.3.0}/PKG-INFO +12 -2
  2. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/README.md +11 -1
  3. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack/__init__.py +1 -1
  4. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack/cli.py +139 -139
  5. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack/completion.py +13 -0
  6. toolrack_cli-0.3.0/src/toolrack/doctor.py +126 -0
  7. toolrack_cli-0.3.0/src/toolrack/scaffold.py +93 -0
  8. toolrack_cli-0.3.0/src/toolrack/shells.py +73 -0
  9. toolrack_cli-0.3.0/src/toolrack/sidecar.py +75 -0
  10. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0/src/toolrack_cli.egg-info}/PKG-INFO +12 -2
  11. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack_cli.egg-info/SOURCES.txt +5 -0
  12. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/tests/test_cli.py +77 -0
  13. toolrack_cli-0.3.0/tests/test_init_doctor.py +109 -0
  14. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/LICENSE +0 -0
  15. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/pyproject.toml +0 -0
  16. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/setup.cfg +0 -0
  17. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack/__main__.py +0 -0
  18. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack/entrypoint.py +0 -0
  19. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack/pytest_plugin.py +0 -0
  20. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack/runtime.py +0 -0
  21. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack/testing.py +0 -0
  22. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack_cli.egg-info/dependency_links.txt +0 -0
  23. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack_cli.egg-info/entry_points.txt +0 -0
  24. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack_cli.egg-info/requires.txt +0 -0
  25. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/src/toolrack_cli.egg-info/top_level.txt +0 -0
  26. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/tests/test_completion.py +0 -0
  27. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/tests/test_cross_shell.py +0 -0
  28. {toolrack_cli-0.2.0 → toolrack_cli-0.3.0}/tests/test_testing.py +0 -0
  29. {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.2.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 bash` prints a script that calls the Python
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 bash` prints a script that calls the Python
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
 
@@ -1,3 +1,3 @@
1
1
  """toolrack - typed dispatcher for a user-owned scripts repository."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -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
- 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"
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
- repo = _current_repo()
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
- ident = "".join(ch if ch.isalnum() else "_" for ch in CLI_NAME)
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.2.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 bash` prints a script that calls the Python
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