toolrack-cli 0.1.0__py3-none-any.whl

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