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