@codebend3r/che 1.0.0

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +424 -0
  3. package/bin/che.py +869 -0
  4. package/bin/commands.py +872 -0
  5. package/bin/drives/eject-all-drives.py +203 -0
  6. package/bin/drives/mount-all-drives.py +140 -0
  7. package/bin/drives/ping-nas.py +242 -0
  8. package/bin/files/compress-folders.py +188 -0
  9. package/bin/files/delete-by-ext.py +108 -0
  10. package/bin/files/delete-empty-folders.py +136 -0
  11. package/bin/files/delete-smb-files.py +105 -0
  12. package/bin/files/files-under-size.py +132 -0
  13. package/bin/files/find-largest-files.py +102 -0
  14. package/bin/files/make-alpha-dir.py +63 -0
  15. package/bin/git/all-actions.py +545 -0
  16. package/bin/git/checkout-my-branches.py +147 -0
  17. package/bin/git/clean-stale-branches.py +161 -0
  18. package/bin/git/prune-worktrees.py +143 -0
  19. package/bin/git/sync-all-branches.py +368 -0
  20. package/bin/git/update-local-branches.py +195 -0
  21. package/bin/install.py +1515 -0
  22. package/bin/shellgen.py +782 -0
  23. package/bin/system/btop-launch.py +114 -0
  24. package/bin/system/update-brew.py +146 -0
  25. package/bin/tui.py +648 -0
  26. package/bin/utils.py +836 -0
  27. package/bin/video/delete-duplicate-videos.py +313 -0
  28. package/bin/video/detect-green-magenta-videos.py +268 -0
  29. package/bin/video/find-movie-by-year.py +84 -0
  30. package/bin/video/find-video-mkv-issues.py +242 -0
  31. package/bin/video/fix-codecs.py +209 -0
  32. package/bin/video/largest-tv-shows.py +175 -0
  33. package/bin/video/remove-metadata.py +126 -0
  34. package/bin/video/rename-video-file.py +329 -0
  35. package/bin/video/scan-videos-audio-language.py +110 -0
  36. package/bin/video/show-codecs.py +111 -0
  37. package/bin/video/validate-video-files.py +114 -0
  38. package/bin/video/video-list.py +120 -0
  39. package/package.json +54 -0
  40. package/shell/che.bash +382 -0
  41. package/shell/che.fish +282 -0
  42. package/shell/che.zsh +389 -0
  43. package/shell/completions/_che +146 -0
  44. package/shell/completions/che.bash +107 -0
  45. package/shell/completions/che.fish +371 -0
package/bin/che.py ADDED
@@ -0,0 +1,869 @@
1
+ #!/usr/bin/env python3
2
+ """``che`` - the dispatcher every helper script in this repo hangs off.
3
+
4
+ Three ways in, all landing on the same command table in ``bin/commands.py``:
5
+
6
+ * ``che`` on its own opens a full-screen menu: type to filter, arrows to move,
7
+ Enter to run. Commands that can preview default to preview.
8
+ * ``che <command> [args…]`` runs one directly, with the same environment the
9
+ shell wrapper would have used, and ``exec``s so signals and exit codes pass
10
+ straight through.
11
+ * ``che install|update|doctor|uninstall|list|completions`` manage the install
12
+ itself - see ``bin/install.py``.
13
+
14
+ The shell wrappers (``delete-by-ext``, ``sync-all-branches``, …) are generated
15
+ from the same table, so anything reachable here is reachable by name too.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import shlex
23
+ import shutil
24
+ import subprocess
25
+ import sys
26
+ import textwrap
27
+ from dataclasses import dataclass, field
28
+ from functools import lru_cache
29
+ from pathlib import Path
30
+
31
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
32
+
33
+ import install as installer
34
+ import shellgen
35
+ import tui
36
+ from commands import (
37
+ BUILTIN_NAMES,
38
+ CATEGORIES,
39
+ MENU_KEYS,
40
+ REPO,
41
+ VERSION,
42
+ Command,
43
+ Resolved,
44
+ all_commands,
45
+ fuzzy_match,
46
+ in_category,
47
+ resolve,
48
+ suggest,
49
+ )
50
+ from utils import note, run_cli, success, warning
51
+
52
+ __version__ = VERSION
53
+
54
+ BIN = REPO / "bin"
55
+
56
+
57
+ @lru_cache(maxsize=64)
58
+ def _which(binary: str) -> str | None:
59
+ """PATH lookup, cached: the menu asks about the same binaries every frame."""
60
+ return shutil.which(binary)
61
+
62
+
63
+ @lru_cache(maxsize=1)
64
+ def _interpreter() -> str:
65
+ """The python the scripts run under.
66
+
67
+ Normally this very interpreter: the shell wrapper already chose
68
+ ``$CHE_PYTHON``. Through the npm ``bin`` link, though, ``che`` starts under
69
+ whatever ``python3`` is first on PATH, which on a stock Mac is the years-old
70
+ Command Line Tools build. The dispatcher itself tolerates that; the scripts
71
+ do not, so hand them the newest suitable interpreter instead.
72
+ """
73
+ return installer.find_python()[0]
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Running a command
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ def build_argv(command: Command, extra: list[str], *, dry: bool = False) -> list[str]:
82
+ _, fixed = command.invocation(dry=dry)
83
+ return [_interpreter(), str(BIN / command.script), *fixed, *extra]
84
+
85
+
86
+ def command_env(command: Command, *, dry: bool = False) -> dict[str, str]:
87
+ env, _ = command.invocation(dry=dry)
88
+ return {**os.environ, **env}
89
+
90
+
91
+ def exec_command(resolved: Resolved, extra: list[str]) -> int:
92
+ """Replace this process with the script, the way the wrapper would.
93
+
94
+ ``exec`` rather than a subprocess so Ctrl-C, job control and the exit code
95
+ behave exactly as if the script had been called directly - which matters
96
+ for the long-running ones (``ping-nas``) and the full-screen ones
97
+ (``btop``).
98
+ """
99
+ command = resolved.command
100
+
101
+ if command.body:
102
+ # A shell-only wrapper (`list-permission`). Nothing to exec but sh.
103
+ return subprocess.run( # noqa: S603 - fixed body from the manifest, args via $@
104
+ ["sh", "-c", command.body + ' "$@"', command.name, *extra], # noqa: S607
105
+ check=False,
106
+ ).returncode
107
+
108
+ script = BIN / command.script
109
+ if not script.exists():
110
+ warning(f"❌ Missing script: {script}")
111
+ return 127
112
+
113
+ argv = build_argv(command, extra, dry=resolved.dry)
114
+ os.execve(argv[0], argv, command_env(command, dry=resolved.dry)) # noqa: S606
115
+ return 127 # unreachable: execve only returns by raising
116
+
117
+
118
+ def run_command(resolved: Resolved, extra: list[str]) -> int:
119
+ """Run a command as a child, for the menu (which needs to come back)."""
120
+ command = resolved.command
121
+
122
+ if command.body:
123
+ return subprocess.run( # noqa: S603 - fixed body from the manifest, args via $@
124
+ ["sh", "-c", command.body + ' "$@"', command.name, *extra], # noqa: S607
125
+ check=False,
126
+ ).returncode
127
+
128
+ return subprocess.run( # noqa: S603 - argv list, shell=False
129
+ build_argv(command, extra, dry=resolved.dry),
130
+ env=command_env(command, dry=resolved.dry),
131
+ check=False,
132
+ ).returncode
133
+
134
+
135
+ def script_help(command: Command) -> str:
136
+ if not command.script:
137
+ return command.summary
138
+ try:
139
+ result = subprocess.run( # noqa: S603
140
+ [_interpreter(), str(BIN / command.script), "--help"],
141
+ capture_output=True,
142
+ text=True,
143
+ timeout=20,
144
+ env={**os.environ, "NO_COLOR": "1"},
145
+ check=False,
146
+ )
147
+ except (OSError, subprocess.SubprocessError) as exc:
148
+ return f"(could not read help: {exc})"
149
+ return (result.stdout or result.stderr).strip()
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Help
154
+ # ---------------------------------------------------------------------------
155
+
156
+
157
+ def usage() -> None:
158
+ colors = tui.palette()
159
+ print(f"{colors.bold}che{colors.reset} {colors.grey}v{VERSION}{colors.reset} - shell helpers")
160
+ print()
161
+ print(f"{colors.bold}USAGE{colors.reset}")
162
+ print(" che open the interactive menu")
163
+ print(" che <command> [args…] run one command")
164
+ print(" che help <command> show that command's own --help")
165
+ print()
166
+
167
+ for category in CATEGORIES:
168
+ members = in_category(category.key)
169
+ if not members:
170
+ continue
171
+ print(
172
+ f"{colors.bold}{category.title.upper()}{colors.reset} {colors.grey}{category.blurb}{colors.reset}"
173
+ )
174
+ width = max(len(command.name) for command in members) + 2
175
+ for command in members:
176
+ marks = ""
177
+ if command.has_dry_twin:
178
+ marks = f" {colors.grey}(+{command.dry_name}){colors.reset}"
179
+ print(f" {colors.cyan}{command.name:<{width}}{colors.reset}{command.summary}{marks}")
180
+ print()
181
+
182
+ print(f"{colors.bold}OPTIONS{colors.reset}")
183
+ print(" -h, --help show this help")
184
+ print(" -v, --version show the version")
185
+ print()
186
+ print(
187
+ f"{colors.grey}Every command above is also a shell function of the same name.{colors.reset}"
188
+ )
189
+
190
+
191
+ def list_commands(category: str = "", *, as_json: bool = False, include_dry: bool = True) -> int:
192
+ if as_json:
193
+ payload = [
194
+ {
195
+ "name": command.name,
196
+ "category": command.category,
197
+ "summary": command.summary,
198
+ "script": command.script,
199
+ "needs": list(command.needs),
200
+ "flags": list(command.flags),
201
+ "destructive": command.destructive,
202
+ "dry_run": command.dry_run,
203
+ }
204
+ for command in all_commands()
205
+ if not category or command.category == category
206
+ ]
207
+ print(json.dumps(payload, indent=2))
208
+ return 0
209
+
210
+ if category and not any(command.category == category for command in all_commands()):
211
+ warning(f"❌ Unknown category: {category}")
212
+ note(f" Known: {', '.join(item.key for item in CATEGORIES)}")
213
+ return 1
214
+
215
+ for command in all_commands():
216
+ if category and command.category != category:
217
+ continue
218
+ print(command.name)
219
+ if include_dry and command.has_dry_twin:
220
+ print(command.dry_name)
221
+ return 0
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # The interactive menu
226
+ # ---------------------------------------------------------------------------
227
+
228
+
229
+ @dataclass
230
+ class Row:
231
+ """One line in the menu list: either a category header or a command."""
232
+
233
+ kind: str # "header" | "item"
234
+ text: str
235
+ command: Command | None = None
236
+
237
+
238
+ @dataclass
239
+ class Menu:
240
+ query: str = ""
241
+ searching: bool = False
242
+ cursor: int = 0
243
+ offset: int = 0
244
+ dry: dict[str, bool] = field(default_factory=dict)
245
+ show_help: bool = False
246
+ status: str = ""
247
+
248
+ def matches(self) -> list[Command]:
249
+ scored = []
250
+ for command in all_commands():
251
+ score = fuzzy_match(self.query, command)
252
+ if score is None:
253
+ continue
254
+ scored.append((score, command))
255
+ scored.sort(key=lambda pair: (pair[0], pair[1].category, pair[1].name))
256
+ return [command for _, command in scored]
257
+
258
+ def rows(self) -> list[Row]:
259
+ commands = self.matches()
260
+ rows: list[Row] = []
261
+ if self.query:
262
+ rows.extend(Row("item", command.name, command) for command in commands)
263
+ return rows
264
+
265
+ for category in CATEGORIES:
266
+ members = [command for command in commands if command.category == category.key]
267
+ if not members:
268
+ continue
269
+ rows.append(Row("header", f"{category.title} {category.blurb}"))
270
+ rows.extend(Row("item", command.name, command) for command in members)
271
+ return rows
272
+
273
+ def items(self) -> list[int]:
274
+ return [index for index, row in enumerate(self.rows()) if row.kind == "item"]
275
+
276
+ def selected(self) -> Command | None:
277
+ rows = self.rows()
278
+ if not rows:
279
+ return None
280
+ self.cursor = max(0, min(self.cursor, len(rows) - 1))
281
+ row = rows[self.cursor]
282
+ return row.command
283
+
284
+ def move(self, delta: int) -> None:
285
+ rows = self.rows()
286
+ items = [index for index, row in enumerate(rows) if row.kind == "item"]
287
+ if not items:
288
+ return
289
+ if self.cursor not in items:
290
+ self.cursor = items[0]
291
+ return
292
+ position = items.index(self.cursor)
293
+ self.cursor = items[(position + delta) % len(items)]
294
+
295
+ def first(self) -> None:
296
+ items = self.items()
297
+ self.cursor = items[0] if items else 0
298
+
299
+ def last(self) -> None:
300
+ items = self.items()
301
+ self.cursor = items[-1] if items else 0
302
+
303
+ def is_dry(self, command: Command) -> bool:
304
+ # Anything that can preview previews first: the menu is a browsing UI,
305
+ # and "I pressed Enter to see what it does" should never delete files.
306
+ return self.dry.get(command.name, command.has_dry_twin)
307
+
308
+
309
+ HELP_OVERLAY = MENU_KEYS
310
+
311
+
312
+ def menu() -> int:
313
+ """The full-screen browser. Returns the exit status of the last command."""
314
+ if not tui.is_interactive():
315
+ warning("❌ The menu needs a terminal. Try `che <command>` or `che --help`.")
316
+ return 1
317
+
318
+ state = Menu()
319
+ state.first()
320
+ last_status = 0
321
+ screen = tui.Screen()
322
+
323
+ with tui.Term() as term:
324
+ while True:
325
+ screen.start()
326
+ draw(screen, state)
327
+ screen.flush()
328
+
329
+ key = tui.read_key(timeout=0.5)
330
+ if key is None:
331
+ continue # timeout: loop so a resize repaints
332
+
333
+ if state.show_help:
334
+ state.show_help = False
335
+ continue
336
+
337
+ action = handle_key(state, key)
338
+
339
+ if action == "quit":
340
+ break
341
+ if action == "run" or action == "run-raw":
342
+ command = state.selected()
343
+ if command is None:
344
+ continue
345
+ term.suspend()
346
+ last_status = launch(command, state, prompt=(action == "run"))
347
+ term.resume()
348
+ elif action == "help":
349
+ command = state.selected()
350
+ if command is None:
351
+ continue
352
+ term.suspend()
353
+ show_help(command)
354
+ term.resume()
355
+
356
+ return last_status
357
+
358
+
359
+ def handle_key(state: Menu, key: str) -> str:
360
+ """Fold one keypress into the menu state. Returns an action for the caller."""
361
+ if state.searching:
362
+ if key == "enter":
363
+ state.searching = False
364
+ elif key == "esc":
365
+ state.searching = False
366
+ state.query = ""
367
+ state.first()
368
+ elif key == "backspace":
369
+ state.query = state.query[:-1]
370
+ state.first()
371
+ elif key == "ctrl-u":
372
+ state.query = ""
373
+ state.first()
374
+ elif key in ("up", "down"):
375
+ state.move(-1 if key == "up" else 1)
376
+ elif key == "ctrl-c":
377
+ return "quit"
378
+ elif len(key) == 1 and key.isprintable():
379
+ state.query += key
380
+ state.first()
381
+ return ""
382
+
383
+ if key in ("q", "ctrl-c", "eof"):
384
+ return "quit"
385
+ if key == "esc":
386
+ if state.query:
387
+ state.query = ""
388
+ state.first()
389
+ return ""
390
+ return "quit"
391
+ if key in ("up", "k"):
392
+ state.move(-1)
393
+ elif key in ("down", "j"):
394
+ state.move(1)
395
+ elif key == "pgup":
396
+ for _ in range(10):
397
+ state.move(-1)
398
+ elif key == "pgdn":
399
+ for _ in range(10):
400
+ state.move(1)
401
+ elif key in ("home", "g"):
402
+ state.first()
403
+ elif key in ("end", "G"):
404
+ state.last()
405
+ elif key == "/":
406
+ state.searching = True
407
+ elif key == "?":
408
+ state.show_help = True
409
+ elif key == "d":
410
+ command = state.selected()
411
+ if command and command.has_dry_twin:
412
+ state.dry[command.name] = not state.is_dry(command)
413
+ state.status = f"dry-run {'on' if state.is_dry(command) else 'OFF'} for {command.name}"
414
+ elif command:
415
+ state.status = f"{command.name} has no preview mode"
416
+ elif key == "h":
417
+ return "help"
418
+ elif key == "x":
419
+ return "run-raw"
420
+ elif key == "enter":
421
+ return "run"
422
+ elif len(key) == 1 and key.isprintable():
423
+ # Typing anything else drops into search, the way a file picker does.
424
+ state.searching = True
425
+ state.query += key
426
+ state.first()
427
+ return ""
428
+
429
+
430
+ def draw(screen: tui.Screen, state: Menu) -> None:
431
+ colors = tui.palette()
432
+ width = screen.columns
433
+
434
+ total = len(all_commands())
435
+ shown = len(state.matches())
436
+ title = f"{colors.bright_magenta}{colors.bold}che{colors.reset}"
437
+ counter = f"{shown}/{total}" if state.query else str(total)
438
+ header = (
439
+ f" {title} {colors.grey}v{VERSION}{colors.reset} "
440
+ f"{colors.grey}{counter} commands{colors.reset}"
441
+ )
442
+ screen.line()
443
+ screen.line(header)
444
+
445
+ if state.searching or state.query:
446
+ caret = f"{colors.cyan}▏{colors.reset}" if state.searching else " "
447
+ screen.line(f" {colors.grey}search{colors.reset} {caret}{state.query}")
448
+ else:
449
+ screen.line(f" {colors.grey}type to search ↑↓ move ⏎ run ? keys{colors.reset}")
450
+ screen.line(f" {colors.grey}{'─' * max(0, width - 4)}{colors.reset}")
451
+
452
+ if state.show_help:
453
+ draw_help(screen)
454
+ return
455
+
456
+ body_rows = max(3, screen.rows - 8)
457
+ rows = state.rows()
458
+ if not rows:
459
+ screen.line()
460
+ screen.line(f" {colors.yellow}No command matches {state.query!r}{colors.reset}")
461
+ draw_footer(screen, state)
462
+ return
463
+
464
+ # Keep the cursor inside the viewport, with a line of context either side.
465
+ if state.cursor < state.offset + 1:
466
+ state.offset = max(0, state.cursor - 1)
467
+ if state.cursor >= state.offset + body_rows - 1:
468
+ state.offset = min(max(0, len(rows) - body_rows), state.cursor - body_rows + 2)
469
+
470
+ detail_width = 0
471
+ if width >= 92:
472
+ detail_width = max(34, width // 2 - 6)
473
+ list_width = width - detail_width - (4 if detail_width else 2)
474
+
475
+ detail_lines = detail_pane(state, detail_width) if detail_width else []
476
+
477
+ for index in range(state.offset, min(len(rows), state.offset + body_rows)):
478
+ row = rows[index]
479
+ if row.kind == "header":
480
+ left = tui.fit(f" {colors.bold}{colors.blue}{row.text}{colors.reset}", list_width)
481
+ else:
482
+ command = row.command
483
+ if command is None:
484
+ continue
485
+ selected = index == state.cursor
486
+ pointer = f"{colors.cyan}▸{colors.reset}" if selected else " "
487
+ name = command.name
488
+ if selected:
489
+ name = f"{colors.bold}{colors.cyan}{name}{colors.reset}"
490
+ elif command.destructive:
491
+ name = f"{colors.yellow}{name}{colors.reset}"
492
+
493
+ badge = ""
494
+ if command.has_dry_twin and state.is_dry(command):
495
+ badge = f" {colors.grey}·dry{colors.reset}"
496
+ elif command.destructive:
497
+ badge = f" {colors.red}·live{colors.reset}"
498
+
499
+ left = f" {pointer} {command.icon} {name}{badge}"
500
+ if detail_width:
501
+ left += " " * max(0, list_width - tui.display_width(left))
502
+ else:
503
+ room = max(0, list_width - tui.display_width(left) - 2)
504
+ summary = tui.truncate(command.summary, room)
505
+ left += f" {colors.grey}{summary}{colors.reset}"
506
+
507
+ detail_index = index - state.offset
508
+ right = detail_lines[detail_index] if detail_index < len(detail_lines) else ""
509
+ if detail_width:
510
+ screen.line(f"{left}{colors.grey}│{colors.reset} {right}")
511
+ else:
512
+ screen.line(left)
513
+
514
+ draw_footer(screen, state)
515
+
516
+
517
+ def detail_pane(state: Menu, width: int) -> list[str]:
518
+ """The right-hand column: what the selected command is and what it needs."""
519
+ colors = tui.palette()
520
+ command = state.selected()
521
+ if command is None:
522
+ return []
523
+
524
+ lines: list[str] = [f"{colors.bold}{command.name}{colors.reset}", ""]
525
+ lines += wrap(command.summary, width, colors.grey)
526
+ lines.append("")
527
+
528
+ def field_line(label: str, value: str) -> str:
529
+ return f"{colors.grey}{label:<8}{colors.reset}{value}"
530
+
531
+ if command.script:
532
+ lines.append(field_line("script", f"bin/{command.script}"))
533
+ elif command.builtin:
534
+ lines.append(field_line("script", "built in to che"))
535
+
536
+ if command.needs:
537
+ parts = []
538
+ for binary in command.needs:
539
+ found = _which(binary)
540
+ mark = f"{colors.green}✓{colors.reset}" if found else f"{colors.red}✗{colors.reset}"
541
+ parts.append(f"{mark} {binary}")
542
+ lines.append(field_line("needs", " ".join(parts)))
543
+
544
+ if command.flags:
545
+ lines += wrap(" ".join(command.flags), width - 8, colors.grey, indent="flags ")
546
+
547
+ if command.has_dry_twin:
548
+ state_text = (
549
+ f"{colors.green}on{colors.reset} - previews only"
550
+ if state.is_dry(command)
551
+ else f"{colors.red}OFF{colors.reset} - changes files"
552
+ )
553
+ lines.append(field_line("dry-run", f"{state_text} {colors.grey}(d){colors.reset}"))
554
+ elif command.destructive:
555
+ reason = command.no_preview_reason or "changes files"
556
+ lines += wrap(reason, width - 8, colors.yellow, indent="warning ")
557
+
558
+ if command.setup:
559
+ lines += wrap(command.setup, width - 8, colors.yellow, indent="setup ")
560
+
561
+ if command.macos_only:
562
+ lines.append(field_line("os", "macOS only"))
563
+
564
+ lines.append("")
565
+ lines.append(f"{colors.grey}⏎ run x run bare h help{colors.reset}")
566
+ return [tui.truncate(line, width) for line in lines]
567
+
568
+
569
+ def wrap(text: str, width: int, color: str = "", indent: str = "") -> list[str]:
570
+ colors = tui.palette()
571
+ body = textwrap.wrap(text, max(10, width - len(indent))) or [""]
572
+ out = []
573
+ for index, line in enumerate(body):
574
+ prefix = indent if index == 0 else " " * len(indent)
575
+ out.append(
576
+ f"{colors.grey}{prefix}{colors.reset}{color}{line}{colors.reset if color else ''}"
577
+ )
578
+ return out
579
+
580
+
581
+ def draw_help(screen: tui.Screen) -> None:
582
+ colors = tui.palette()
583
+ screen.line()
584
+ screen.line(f" {colors.bold}Keys{colors.reset}")
585
+ screen.line()
586
+ width = max(len(key) for key, _ in HELP_OVERLAY) + 3
587
+ for key, description in HELP_OVERLAY:
588
+ screen.line(
589
+ f" {colors.cyan}{key:<{width}}{colors.reset}{colors.grey}{description}{colors.reset}"
590
+ )
591
+ screen.line()
592
+ screen.line(f" {colors.grey}Press any key to go back.{colors.reset}")
593
+
594
+
595
+ def draw_footer(screen: tui.Screen, state: Menu) -> None:
596
+ colors = tui.palette()
597
+ screen.pad_to(screen.rows - 2)
598
+ screen.line(f" {colors.grey}{'─' * max(0, screen.columns - 4)}{colors.reset}")
599
+ if state.status:
600
+ screen.line(f" {colors.yellow}{state.status}{colors.reset}")
601
+ state.status = ""
602
+ else:
603
+ screen.line(
604
+ f" {colors.grey}⏎ run d dry-run h help / search ? keys q quit{colors.reset}"
605
+ )
606
+
607
+
608
+ # ---------------------------------------------------------------------------
609
+ # Running from the menu
610
+ # ---------------------------------------------------------------------------
611
+
612
+
613
+ def launch(command: Command, state: Menu, *, prompt: bool = True) -> int:
614
+ """Collect arguments for ``command``, run it, and wait for a keypress."""
615
+ colors = tui.palette()
616
+ dry = state.is_dry(command)
617
+
618
+ print()
619
+ print(
620
+ f"{colors.bright_magenta}{colors.bold}{command.name}{colors.reset} {colors.grey}{command.summary}{colors.reset}"
621
+ )
622
+ print()
623
+
624
+ if command.builtin:
625
+ status = run_builtin(command.name, [])
626
+ tui.pause("Press any key to go back")
627
+ return status
628
+
629
+ extra: list[str] = []
630
+ if prompt and command.prompts:
631
+ collected = collect_arguments(command)
632
+ if collected is None:
633
+ print(f"{colors.yellow}Cancelled.{colors.reset}")
634
+ tui.pause("Press any key to go back")
635
+ return 130
636
+ extra = collected
637
+
638
+ free = tui.ask("Extra arguments", default="", history_file=history_file("extra"))
639
+ if free.strip():
640
+ extra += shlex.split(free)
641
+
642
+ resolved = Resolved(command, dry=dry, matched=command.name)
643
+ preview = " ".join([command.name, *extra])
644
+ env, _ = command.invocation(dry=dry)
645
+ env_text = " ".join(f"{key}={value}" for key, value in sorted(env.items()))
646
+
647
+ print()
648
+ print(f"{colors.grey}$ {env_text} {preview}{colors.reset}".replace("$ ", "$ "))
649
+
650
+ previewing = dry or any(
651
+ arg.startswith("--dry-run") and not arg.endswith("=false") for arg in extra
652
+ )
653
+ if command.destructive and not previewing:
654
+ print()
655
+ if not tui.confirm(f"{command.name} changes files for real. Continue?", default=False):
656
+ print(f"{colors.yellow}Cancelled.{colors.reset}")
657
+ tui.pause("Press any key to go back")
658
+ return 130
659
+
660
+ print()
661
+ status = run_command(resolved, extra)
662
+ print()
663
+ if status == 0:
664
+ success(f"✓ {command.name} finished")
665
+ else:
666
+ warning(f"✗ {command.name} exited {status}")
667
+
668
+ tui.pause("Press any key to go back")
669
+ return status
670
+
671
+
672
+ def collect_arguments(command: Command) -> list[str] | None:
673
+ """Ask the curated questions for ``command``. ``None`` means cancelled."""
674
+ extra: list[str] = []
675
+
676
+ for prompt in command.prompts:
677
+ if prompt.kind == "bool":
678
+ default = prompt.default == "true"
679
+ answer = tui.confirm(prompt.label, default=default)
680
+ # Only pass the flag when the answer differs from what the script
681
+ # already does, so the command line stays as short as what a person
682
+ # would have typed.
683
+ if answer and not default:
684
+ extra.append(prompt.flag)
685
+ elif not answer and default:
686
+ extra.append(f"{prompt.flag}=false")
687
+ continue
688
+
689
+ answer = tui.ask(
690
+ prompt.label + (f" ({prompt.help})" if prompt.help else ""),
691
+ default=prompt.default,
692
+ kind=prompt.kind,
693
+ choices=prompt.choices,
694
+ history_file=history_file(prompt.key),
695
+ )
696
+ if not answer:
697
+ if prompt.required:
698
+ warning(f"❌ {prompt.label} is required.")
699
+ return None
700
+ continue
701
+
702
+ extra.append(answer if not prompt.flag else f"{prompt.flag}={answer}")
703
+
704
+ return extra
705
+
706
+
707
+ def history_file(key: str) -> Path:
708
+ return installer.state_dir() / f"history-{key}"
709
+
710
+
711
+ def show_help(command: Command) -> None:
712
+ colors = tui.palette()
713
+ print()
714
+ text = script_help(command)
715
+ print(text)
716
+ print()
717
+ if command.needs:
718
+ for binary in command.needs:
719
+ if not _which(binary):
720
+ warning(
721
+ f"missing: {binary} - {installer.INSTALL_HINTS.get(binary, 'not installed')}"
722
+ )
723
+ print(f"{colors.grey}(from `{command.name} --help`){colors.reset}")
724
+ tui.pause("Press any key to go back")
725
+
726
+
727
+ # ---------------------------------------------------------------------------
728
+ # Built-in subcommands
729
+ # ---------------------------------------------------------------------------
730
+
731
+
732
+ def run_builtin(name: str, argv: list[str]) -> int:
733
+ if name == "install":
734
+ return installer.install_main(argv)
735
+ if name == "update":
736
+ parser = _simple_parser("che update", "⬆️ Update the scripts, then refresh the wrappers.")
737
+ args = parser.parse_args(argv)
738
+ return installer.update(check_only=args.check, dry_run=args.dry_run)
739
+ if name == "doctor":
740
+ parser = _simple_parser("che doctor", "🩺 Check the install and everything it depends on.")
741
+ args = parser.parse_args(argv)
742
+ return installer.doctor(as_json=args.json, verbose=args.verbose)
743
+ if name == "uninstall":
744
+ parser = _simple_parser("che uninstall", "🧽 Remove the wrappers, shim and completions.")
745
+ args = parser.parse_args(argv)
746
+ return installer.uninstall(dry_run=args.dry_run, purge=args.purge)
747
+ if name == "list":
748
+ parser = _simple_parser("che list", "📋 List every command.")
749
+ args = parser.parse_args(argv)
750
+ return list_commands(args.category, as_json=args.json, include_dry=not args.no_dry)
751
+ if name == "completions":
752
+ return completions_main(argv)
753
+ raise AssertionError(f"unhandled builtin: {name}")
754
+
755
+
756
+ def _simple_parser(prog: str, description: str):
757
+ from utils import add_bool_flag, build_parser
758
+
759
+ parser = build_parser(prog=prog, description=description)
760
+ add_bool_flag(parser, "--dry-run", help="Report what would change, change nothing")
761
+ add_bool_flag(parser, "--check", help="Report whether an update is available, apply nothing")
762
+ add_bool_flag(parser, "--json", help="Machine-readable output")
763
+ add_bool_flag(parser, "--verbose", help="Include checks that passed")
764
+ add_bool_flag(parser, "--purge", help="Also delete ~/.config/che")
765
+ add_bool_flag(parser, "--no-dry", dest="no_dry", allow_value=False, help="Hide the -dr twins")
766
+ parser.add_argument("--category", default="", metavar="NAME", help="Only this category")
767
+ return parser
768
+
769
+
770
+ def completions_main(argv: list[str]) -> int:
771
+ shells = ", ".join(shellgen.SHELLS)
772
+ if not argv or argv[0] in ("-h", "--help"):
773
+ print(f"Usage: che completions <{shells.replace(', ', '|')}>")
774
+ print()
775
+ print("Print the completion script for a shell. `che install` already")
776
+ print("wires these up; this is for sourcing one by hand.")
777
+ return 0 if argv else 1
778
+
779
+ shell = argv[0].removeprefix("--shell=")
780
+ if shell not in shellgen.SHELLS:
781
+ warning(f"❌ Unknown shell: {shell}")
782
+ note(f" Known: {shells}")
783
+ return 1
784
+ print(shellgen.completions(shell), end="")
785
+ return 0
786
+
787
+
788
+ # ---------------------------------------------------------------------------
789
+ # Entry point
790
+ # ---------------------------------------------------------------------------
791
+
792
+
793
+ def first_run() -> int:
794
+ """What ``che`` does on a machine where it has never been installed."""
795
+ colors = tui.palette()
796
+ installer.print_logo(f"shell helpers, v{VERSION}")
797
+ print(f" {colors.grey}che is not installed in this shell yet.{colors.reset}")
798
+ print()
799
+ print(
800
+ f" {colors.bold}i{colors.reset} install it now (writes a block into your shell rc file)"
801
+ )
802
+ print(f" {colors.bold}m{colors.reset} just open the menu")
803
+ print(f" {colors.bold}q{colors.reset} quit")
804
+ print()
805
+
806
+ with tui.Term(alt_screen=False):
807
+ key = tui.read_key()
808
+
809
+ print()
810
+ if key in ("i", "enter"):
811
+ return installer.wizard()
812
+ if key == "m":
813
+ return menu()
814
+ return 0
815
+
816
+
817
+ def main() -> int:
818
+ argv = sys.argv[1:]
819
+
820
+ if not argv:
821
+ if not tui.is_interactive():
822
+ usage()
823
+ return 0
824
+ if not installer.load_config():
825
+ return first_run()
826
+ return menu()
827
+
828
+ first = argv[0]
829
+ rest = argv[1:]
830
+
831
+ if first in ("-h", "--help"):
832
+ usage()
833
+ return 0
834
+ if first in ("-v", "--version", "version"):
835
+ print(f"che {VERSION}")
836
+ return 0
837
+ if first == "help":
838
+ if not rest:
839
+ usage()
840
+ return 0
841
+ found = resolve(rest[0])
842
+ if found is None:
843
+ return unknown(rest[0])
844
+ print(script_help(found.command))
845
+ return 0
846
+ if first == "menu":
847
+ return menu()
848
+
849
+ if first in BUILTIN_NAMES:
850
+ return run_builtin(first, rest)
851
+
852
+ found = resolve(first)
853
+ if found is None:
854
+ return unknown(first)
855
+
856
+ return exec_command(found, rest)
857
+
858
+
859
+ def unknown(name: str) -> int:
860
+ warning(f"❌ Unknown command: {name}")
861
+ close = suggest(name)
862
+ if close:
863
+ note(" Did you mean: " + ", ".join(close))
864
+ note(" `che --help` lists everything, `che` opens the menu.")
865
+ return 1
866
+
867
+
868
+ if __name__ == "__main__":
869
+ run_cli(main)