@codebend3r/che 1.0.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 +65 -25
  2. package/bin/art.py +123 -0
  3. package/bin/che.py +209 -568
  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 -1188
  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 +508 -428
  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/menu.py ADDED
@@ -0,0 +1,437 @@
1
+ """The full-screen menu's model and view: what is listed, how a key moves
2
+ through it, and how a frame is drawn.
3
+
4
+ Running anything is left to ``che.py``, whose menu loop suspends the screen,
5
+ launches the selected command and comes back. That keeps the dependency one
6
+ way - the dispatcher uses the menu, the menu never calls the dispatcher.
7
+
8
+ Library module: no shebang, not executable.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import shutil
14
+ import textwrap
15
+ from dataclasses import dataclass, field
16
+ from functools import lru_cache
17
+ from typing import Literal
18
+
19
+ import art
20
+ import tui
21
+ from commands import MENU_KEYS, VERSION, Category, Command, Resolved, all_commands, grouped
22
+
23
+
24
+ @lru_cache(maxsize=64)
25
+ def _which(binary: str) -> str | None:
26
+ """PATH lookup, cached: the menu asks about the same binaries every frame."""
27
+ return shutil.which(binary)
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Filtering
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ def fuzzy_match(query: str, command: Command) -> int | None:
36
+ """Score ``command`` against ``query`` for the menu's type-to-filter.
37
+
38
+ Returns ``None`` when it does not match at all, otherwise a score where
39
+ lower is better: an exact name prefix beats a name substring, which beats a
40
+ match that only landed in the summary or tags, which beats a subsequence
41
+ match ("dbe" -> "delete-by-ext").
42
+ """
43
+ if not query:
44
+ return 0
45
+
46
+ needle = query.lower().strip()
47
+ name = command.name.lower()
48
+
49
+ if name.startswith(needle):
50
+ return 1
51
+ if needle in name:
52
+ return 2
53
+ words = (command.name, command.summary, command.category, *command.tags)
54
+ if needle in " ".join(words).lower():
55
+ return 3
56
+
57
+ # Subsequence: every character of the query appears in order in the name.
58
+ position = 0
59
+ for char in needle:
60
+ found = name.find(char, position)
61
+ if found < 0:
62
+ return None
63
+ position = found + 1
64
+ return 4
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # State
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ # What a keypress asks the caller to do; "" is nothing beyond the redraw.
73
+ Action = Literal["", "quit", "run", "run-raw", "help"]
74
+
75
+ # How far PgUp and PgDn move the cursor, in commands.
76
+ PAGE = 10
77
+
78
+
79
+ @dataclass
80
+ class Menu:
81
+ query: str = ""
82
+ searching: bool = False
83
+ cursor: int = 0
84
+ offset: int = 0
85
+ dry: dict[str, bool] = field(default_factory=dict)
86
+ show_help: bool = False
87
+ status: str = ""
88
+
89
+ def __post_init__(self) -> None:
90
+ self.first()
91
+
92
+ def matches(self) -> list[Command]:
93
+ scored = []
94
+ for command in all_commands():
95
+ score = fuzzy_match(self.query, command)
96
+ if score is None:
97
+ continue
98
+ scored.append((score, command))
99
+ scored.sort(key=lambda pair: (pair[0], pair[1].category, pair[1].name))
100
+ return [command for _, command in scored]
101
+
102
+ def rows(self) -> list[Category | Command]:
103
+ """The list as drawn: commands, under their category headers unless searching."""
104
+ commands = self.matches()
105
+ if self.query:
106
+ return list(commands)
107
+ return [row for category, members in grouped(commands) for row in (category, *members)]
108
+
109
+ def items(self) -> list[int]:
110
+ """The rows the cursor can land on: the commands, not the headers."""
111
+ return [index for index, row in enumerate(self.rows()) if isinstance(row, Command)]
112
+
113
+ def selected(self) -> Command | None:
114
+ rows = self.rows()
115
+ if not rows:
116
+ return None
117
+ self.cursor = max(0, min(self.cursor, len(rows) - 1))
118
+ row = rows[self.cursor]
119
+ return row if isinstance(row, Command) else None
120
+
121
+ def resolved(self) -> Resolved | None:
122
+ """The selected command as it would run: its ``-dr`` twin while on preview."""
123
+ command = self.selected()
124
+ return None if command is None else Resolved(command, dry=self.is_dry(command))
125
+
126
+ def move(self, delta: int) -> None:
127
+ """One step up or down, wrapping round at either end."""
128
+ items = self.items()
129
+ if not items:
130
+ return
131
+ if self.cursor not in items:
132
+ self.cursor = items[0]
133
+ return
134
+ position = items.index(self.cursor)
135
+ self.cursor = items[(position + delta) % len(items)]
136
+
137
+ def jump(self, delta: int) -> None:
138
+ """A page up or down, stopping at either end rather than wrapping."""
139
+ items = self.items()
140
+ if not items:
141
+ return
142
+ position = items.index(self.cursor) if self.cursor in items else 0
143
+ self.cursor = items[max(0, min(len(items) - 1, position + delta))]
144
+
145
+ def set_query(self, query: str) -> None:
146
+ self.query = query
147
+ self.first()
148
+
149
+ def first(self) -> None:
150
+ items = self.items()
151
+ self.cursor = items[0] if items else 0
152
+
153
+ def last(self) -> None:
154
+ items = self.items()
155
+ self.cursor = items[-1] if items else 0
156
+
157
+ def is_dry(self, command: Command) -> bool:
158
+ # Anything that can preview previews first: the menu is a browsing UI,
159
+ # and "I pressed Enter to see what it does" should never delete files.
160
+ return self.dry.get(command.name, command.has_dry_twin)
161
+
162
+
163
+ def handle_key(state: Menu, key: str) -> Action:
164
+ """Fold one keypress into the menu state. Returns an action for the caller."""
165
+ # A status line lasts until the next key, and any key dismisses the help.
166
+ state.status = ""
167
+ if state.show_help:
168
+ state.show_help = False
169
+ return ""
170
+
171
+ if state.searching:
172
+ if key == "enter":
173
+ state.searching = False
174
+ elif key == "esc":
175
+ state.searching = False
176
+ state.set_query("")
177
+ elif key == "backspace":
178
+ state.set_query(state.query[:-1])
179
+ elif key == "ctrl-u":
180
+ state.set_query("")
181
+ elif key in ("up", "down"):
182
+ state.move(-1 if key == "up" else 1)
183
+ elif key == "ctrl-c":
184
+ return "quit"
185
+ elif len(key) == 1 and key.isprintable():
186
+ state.set_query(state.query + key)
187
+ return ""
188
+
189
+ if key in ("q", "ctrl-c", "eof"):
190
+ return "quit"
191
+ if key == "esc":
192
+ if state.query:
193
+ state.set_query("")
194
+ return ""
195
+ return "quit"
196
+ if key in ("up", "k"):
197
+ state.move(-1)
198
+ elif key in ("down", "j"):
199
+ state.move(1)
200
+ elif key == "pgup":
201
+ state.jump(-PAGE)
202
+ elif key == "pgdn":
203
+ state.jump(PAGE)
204
+ elif key in ("home", "g"):
205
+ state.first()
206
+ elif key in ("end", "G"):
207
+ state.last()
208
+ elif key == "/":
209
+ state.searching = True
210
+ elif key == "?":
211
+ state.show_help = True
212
+ elif key == "d":
213
+ command = state.selected()
214
+ if command and command.has_dry_twin:
215
+ state.dry[command.name] = not state.is_dry(command)
216
+ state.status = f"dry-run {'on' if state.is_dry(command) else 'OFF'} for {command.name}"
217
+ elif command and command.preview:
218
+ state.status = f"{command.name} previews with {command.preview}, asked on ⏎"
219
+ elif command:
220
+ state.status = f"{command.name} has no preview mode"
221
+ elif key == "h":
222
+ return "help"
223
+ elif key == "x":
224
+ return "run-raw"
225
+ elif key == "enter":
226
+ return "run"
227
+ elif len(key) == 1 and key.isprintable():
228
+ # Typing anything else drops into search, the way a file picker does.
229
+ state.searching = True
230
+ state.set_query(state.query + key)
231
+ return ""
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # Drawing
236
+ # ---------------------------------------------------------------------------
237
+
238
+ # The face heads the menu only while the list under it keeps at least this
239
+ # many rows. Below that the one-line header takes over, as it always did.
240
+ MIN_LIST_ROWS = 8
241
+
242
+
243
+ def banner_fits(screen: tui.Screen, right: list[str]) -> bool:
244
+ """Whether the face and wordmark can head the menu and still leave a list."""
245
+ spare = screen.rows - (art.HEIGHT + 1) - 4
246
+ return spare >= MIN_LIST_ROWS and art.fits(right, screen.columns)
247
+
248
+
249
+ def draw(screen: tui.Screen, state: Menu) -> None:
250
+ colors = tui.palette()
251
+ width = screen.columns
252
+
253
+ total = len(all_commands())
254
+ shown = len(state.matches())
255
+ counter = f"{shown}/{total}" if state.query else str(total)
256
+ if state.searching or state.query:
257
+ caret = f"{colors.cyan}▏{colors.reset}" if state.searching else " "
258
+ prompt = f" {colors.grey}search{colors.reset} {caret}{state.query}"
259
+ else:
260
+ prompt = f" {colors.grey}type to search ↑↓ move ⏎ run ? keys{colors.reset}"
261
+
262
+ right = art.wordmark(colors)
263
+ right += ["", f" {colors.grey}v{VERSION} {counter} commands{colors.reset}", prompt]
264
+ if banner_fits(screen, right) and not state.show_help:
265
+ # The face beside the wordmark, with the version, the count and the
266
+ # search line tucked under the letters.
267
+ screen.lines(art.banner(right, columns=width, colors=colors))
268
+ else:
269
+ title = f"{colors.bright_magenta}{colors.bold}che{colors.reset}"
270
+ screen.line()
271
+ screen.line(
272
+ f" {title} {colors.grey}v{VERSION}{colors.reset} "
273
+ f"{colors.grey}{counter} commands{colors.reset}"
274
+ )
275
+ screen.line(prompt)
276
+ screen.line(f" {colors.grey}{'─' * max(0, width - 4)}{colors.reset}")
277
+ header_rows = screen.used
278
+
279
+ if state.show_help:
280
+ draw_help(screen)
281
+ return
282
+
283
+ body_rows = max(3, screen.rows - header_rows - 4)
284
+ rows = state.rows()
285
+ if not rows:
286
+ screen.line()
287
+ screen.line(f" {colors.yellow}No command matches {state.query!r}{colors.reset}")
288
+ draw_footer(screen, state)
289
+ return
290
+
291
+ # Keep the cursor inside the viewport, with a line of context either side.
292
+ if state.cursor < state.offset + 1:
293
+ state.offset = max(0, state.cursor - 1)
294
+ if state.cursor >= state.offset + body_rows - 1:
295
+ state.offset = min(max(0, len(rows) - body_rows), state.cursor - body_rows + 2)
296
+
297
+ detail_width = 0
298
+ if width >= 92:
299
+ detail_width = max(34, width // 2 - 6)
300
+ list_width = width - detail_width - (4 if detail_width else 2)
301
+
302
+ detail_lines = detail_pane(state, detail_width) if detail_width else []
303
+
304
+ for index in range(state.offset, min(len(rows), state.offset + body_rows)):
305
+ row = rows[index]
306
+ if isinstance(row, Category):
307
+ header = f"{row.title} {row.blurb}"
308
+ left = tui.fit(f" {colors.bold}{colors.blue}{header}{colors.reset}", list_width)
309
+ else:
310
+ command = row
311
+ selected = index == state.cursor
312
+ pointer = f"{colors.cyan}▸{colors.reset}" if selected else " "
313
+ name = command.name
314
+ if selected:
315
+ name = f"{colors.bold}{colors.cyan}{name}{colors.reset}"
316
+ elif command.destructive:
317
+ name = f"{colors.yellow}{name}{colors.reset}"
318
+
319
+ badge = ""
320
+ if command.has_dry_twin and state.is_dry(command):
321
+ badge = f" {colors.grey}·dry{colors.reset}"
322
+ elif command.destructive:
323
+ badge = f" {colors.red}·live{colors.reset}"
324
+
325
+ left = f" {pointer} {command.icon} {name}{badge}"
326
+ if detail_width:
327
+ left = tui.fit(left, list_width)
328
+ else:
329
+ room = max(0, list_width - tui.display_width(left) - 2)
330
+ summary = tui.truncate(command.summary, room)
331
+ left += f" {colors.grey}{summary}{colors.reset}"
332
+
333
+ detail_index = index - state.offset
334
+ right = detail_lines[detail_index] if detail_index < len(detail_lines) else ""
335
+ if detail_width:
336
+ screen.line(f"{left}{colors.grey}│{colors.reset} {right}")
337
+ else:
338
+ screen.line(left)
339
+
340
+ draw_footer(screen, state)
341
+
342
+
343
+ # The detail pane's label column: "script ", "needs ", "warning ".
344
+ LABEL_WIDTH = 8
345
+
346
+
347
+ def detail_pane(state: Menu, width: int) -> list[str]:
348
+ """The right-hand column: what the selected command is and what it needs."""
349
+ colors = tui.palette()
350
+ command = state.selected()
351
+ if command is None:
352
+ return []
353
+
354
+ lines: list[str] = [f"{colors.bold}{command.name}{colors.reset}", ""]
355
+ lines += wrap(command.summary, width, colors.grey)
356
+ lines.append("")
357
+
358
+ def field_line(label: str, value: str) -> str:
359
+ return f"{colors.grey}{label:<{LABEL_WIDTH}}{colors.reset}{value}"
360
+
361
+ def field_wrap(label: str, text: str, color: str) -> list[str]:
362
+ return wrap(text, width, color, indent=f"{label:<{LABEL_WIDTH}}")
363
+
364
+ if command.script:
365
+ lines.append(field_line("script", f"bin/{command.script}"))
366
+ elif command.builtin:
367
+ lines.append(field_line("script", "built in to che"))
368
+
369
+ if command.needs:
370
+ parts = []
371
+ for binary in command.needs:
372
+ found = _which(binary)
373
+ mark = f"{colors.green}✓{colors.reset}" if found else f"{colors.red}✗{colors.reset}"
374
+ parts.append(f"{mark} {binary}")
375
+ lines.append(field_line("needs", " ".join(parts)))
376
+
377
+ if command.flags:
378
+ lines += field_wrap("flags", " ".join(command.flag_names), colors.grey)
379
+
380
+ if command.has_dry_twin:
381
+ state_text = (
382
+ f"{colors.green}on{colors.reset} - previews only"
383
+ if state.is_dry(command)
384
+ else f"{colors.red}OFF{colors.reset} - changes files"
385
+ )
386
+ lines.append(field_line("dry-run", f"{state_text} {colors.grey}(d){colors.reset}"))
387
+ elif command.destructive:
388
+ reason = command.no_preview_reason or "changes files"
389
+ lines += field_wrap("warning", reason, colors.yellow)
390
+
391
+ if command.setup:
392
+ lines += field_wrap("setup", command.setup, colors.yellow)
393
+
394
+ if command.macos_only:
395
+ lines.append(field_line("os", "macOS only"))
396
+
397
+ lines.append("")
398
+ lines.append(f"{colors.grey}⏎ run x run bare h help{colors.reset}")
399
+ return [tui.truncate(line, width) for line in lines]
400
+
401
+
402
+ def wrap(text: str, width: int, color: str = "", indent: str = "") -> list[str]:
403
+ colors = tui.palette()
404
+ body = textwrap.wrap(text, max(10, width - len(indent))) or [""]
405
+ out = []
406
+ for index, line in enumerate(body):
407
+ prefix = indent if index == 0 else " " * len(indent)
408
+ out.append(
409
+ f"{colors.grey}{prefix}{colors.reset}{color}{line}{colors.reset if color else ''}"
410
+ )
411
+ return out
412
+
413
+
414
+ def draw_help(screen: tui.Screen) -> None:
415
+ colors = tui.palette()
416
+ screen.line()
417
+ screen.line(f" {colors.bold}Keys{colors.reset}")
418
+ screen.line()
419
+ width = max(len(key) for key, _ in MENU_KEYS) + 3
420
+ for key, description in MENU_KEYS:
421
+ screen.line(
422
+ f" {colors.cyan}{key:<{width}}{colors.reset}{colors.grey}{description}{colors.reset}"
423
+ )
424
+ screen.line()
425
+ screen.line(f" {colors.grey}Press any key to go back.{colors.reset}")
426
+
427
+
428
+ def draw_footer(screen: tui.Screen, state: Menu) -> None:
429
+ colors = tui.palette()
430
+ screen.pad_to(screen.rows - 2)
431
+ screen.line(f" {colors.grey}{'─' * max(0, screen.columns - 4)}{colors.reset}")
432
+ if state.status:
433
+ screen.line(f" {colors.yellow}{state.status}{colors.reset}")
434
+ else:
435
+ screen.line(
436
+ f" {colors.grey}⏎ run d dry-run h help / search ? keys q quit{colors.reset}"
437
+ )
package/bin/nas.py ADDED
@@ -0,0 +1,102 @@
1
+ """The NAS drive table and SMB mount helpers for the scripts under ``bin/drives/``.
2
+
3
+ Split out of the three drive scripts, which each carried their own copy of the
4
+ table, the mount check and the AppleScript mount call. Scripts pick it up the
5
+ same way as ``utils.py``::
6
+
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
11
+ from nas import mount_smb, select_drives
12
+
13
+ Standard library only, like everything else at the ``bin/`` root. ``osascript``
14
+ is macOS-only; callers check for it with ``require_binary``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from typing import NamedTuple
21
+
22
+ from utils import run, warning
23
+
24
+ SMB_USER = "crivas"
25
+
26
+
27
+ class NasDrive(NamedTuple):
28
+ """One NAS. The share name is assumed to match the drive name, so
29
+ //crivas@Meleys/Meleys mounts at /Volumes/Meleys."""
30
+
31
+ name: str
32
+ ip: str
33
+
34
+ def host(self, *, use_ip: bool) -> str:
35
+ """How to reach it in a share URL: by IP under ``--use-ip``, else by name."""
36
+ return self.ip if use_ip else self.name
37
+
38
+
39
+ NAS_DRIVES: tuple[NasDrive, ...] = (
40
+ NasDrive("Meleys", "192.168.50.2"),
41
+ NasDrive("Vermithor", "192.168.50.3"),
42
+ NasDrive("Caraxes", "192.168.50.4"),
43
+ NasDrive("Syrax", "192.168.50.5"),
44
+ NasDrive("Vhagar", "192.168.50.6"),
45
+ )
46
+
47
+
48
+ def select_drives(only: str) -> list[NasDrive]:
49
+ """The drives an ``--only`` value picks: all of them when it is empty.
50
+
51
+ The match is case-insensitive. An unknown name selects nothing; see
52
+ :func:`warn_unknown_drive`.
53
+ """
54
+ return [drive for drive in NAS_DRIVES if not only or drive.name.lower() == only.lower()]
55
+
56
+
57
+ def warn_unknown_drive(only: str) -> None:
58
+ """Warn that ``--only`` named no drive, for the scripts that carry on.
59
+
60
+ Only a warning, keeping the shell versions' exit code: an unknown --only
61
+ printed a 0/0 summary and still exited 0. ``ping-nas`` alone refuses one.
62
+ """
63
+ known = ", ".join(drive.name for drive in NAS_DRIVES)
64
+ warning(f"⚠️ No drive named '{only}'. Known: {known}")
65
+
66
+
67
+ def is_mounted(name: str) -> bool:
68
+ """True when something is actually mounted at ``/Volumes/<name>``.
69
+
70
+ The shell version grepped ``mount`` output for `` on /Volumes/<name> (``;
71
+ ``os.path.ismount`` asks the kernel directly. The two agree for every
72
+ healthy mount. The one real difference: ``ismount`` lstats the mount root,
73
+ so on a wedged SMB mount it can block for the server timeout where reading
74
+ the mount table cannot.
75
+ """
76
+ return os.path.ismount(f"/Volumes/{name}")
77
+
78
+
79
+ def smb_url(name: str, host: str) -> str:
80
+ """The share URL for drive ``name``, reached at ``host`` (its name or its IP)."""
81
+ return f"smb://{SMB_USER}@{host}/{name}"
82
+
83
+
84
+ def mount_smb(name: str, host: str) -> bool:
85
+ """Mount one share via AppleScript's ``mount volume``. True on success.
86
+
87
+ AppleScript rather than ``mount_smbfs`` takes the same NetAuth path Finder
88
+ does: ``/Volumes/<name>`` is created automatically and Keychain credentials
89
+ are used silently, with no Finder modals. On failure this prints why; the
90
+ caller prints its own progress and success lines.
91
+ """
92
+ url = smb_url(name, host)
93
+ completed = run(["osascript", "-e", f'mount volume "{url}"'], check=False, capture=True)
94
+ if completed.returncode == 0:
95
+ return True
96
+
97
+ warning(f" ❌ mount failed for {url}")
98
+ stderr = (completed.stderr or "").strip()
99
+ if stderr:
100
+ warning(f" {stderr}")
101
+ warning(f" Check that Keychain has a password for '{SMB_USER}@{host}'.")
102
+ return False