@codebend3r/che 1.1.0 → 1.2.1

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 (54) hide show
  1. package/README.md +58 -26
  2. package/bin/art.py +22 -20
  3. package/bin/branchsync.py +324 -0
  4. package/bin/che.py +208 -587
  5. package/bin/commands.py +219 -231
  6. package/bin/doctor.py +220 -0
  7. package/bin/drives/eject-all-drives.py +21 -38
  8. package/bin/drives/mount-all-drives.py +18 -62
  9. package/bin/drives/ping-nas.py +77 -105
  10. package/bin/files/compress-folders.py +7 -11
  11. package/bin/files/delete-by-ext.py +10 -14
  12. package/bin/files/delete-empty-folders.py +33 -37
  13. package/bin/files/delete-smb-files.py +12 -21
  14. package/bin/files/files-under-size.py +5 -11
  15. package/bin/files/find-largest-files.py +21 -25
  16. package/bin/files/list-permission.py +40 -0
  17. package/bin/files/make-alpha-dir.py +2 -0
  18. package/bin/git/all-actions.py +350 -253
  19. package/bin/git/checkout-my-branches.py +42 -63
  20. package/bin/git/clean-stale-branches.py +23 -48
  21. package/bin/git/prune-worktrees.py +12 -51
  22. package/bin/git/sync-all-branches.py +75 -297
  23. package/bin/git/update-local-branches.py +67 -73
  24. package/bin/gitutils.py +263 -0
  25. package/bin/install.py +393 -1182
  26. package/bin/machine.py +345 -0
  27. package/bin/media.py +127 -0
  28. package/bin/menu.py +437 -0
  29. package/bin/nas.py +102 -0
  30. package/bin/rcfile.py +380 -0
  31. package/bin/shellgen.py +503 -431
  32. package/bin/system/btop-launch.py +17 -13
  33. package/bin/system/update-brew.py +57 -68
  34. package/bin/tui.py +156 -153
  35. package/bin/utils.py +165 -237
  36. package/bin/video/delete-duplicate-videos.py +64 -62
  37. package/bin/video/detect-green-magenta-videos.py +105 -95
  38. package/bin/video/find-movie-by-year.py +3 -0
  39. package/bin/video/find-video-mkv-issues.py +48 -65
  40. package/bin/video/fix-codecs.py +25 -30
  41. package/bin/video/largest-tv-shows.py +60 -57
  42. package/bin/video/remove-metadata.py +3 -6
  43. package/bin/video/rename-video-file.py +74 -84
  44. package/bin/video/scan-videos-audio-language.py +16 -33
  45. package/bin/video/show-codecs.py +20 -24
  46. package/bin/video/validate-video-files.py +5 -8
  47. package/bin/video/video-list.py +17 -20
  48. package/package.json +4 -10
  49. package/shell/che.bash +33 -33
  50. package/shell/che.fish +7 -7
  51. package/shell/che.zsh +33 -33
  52. package/shell/completions/_che +19 -19
  53. package/shell/completions/che.bash +15 -15
  54. package/shell/completions/che.fish +34 -50
package/bin/commands.py CHANGED
@@ -4,9 +4,9 @@ This module is the single source of truth for the user-facing command surface.
4
4
  Four things are generated from it, so they can never drift apart:
5
5
 
6
6
  * the shell wrappers in ``shell/che.zsh``, ``shell/che.bash`` and
7
- ``shell/che.fish`` (written by ``bin/install.py``),
7
+ ``shell/che.fish`` (rendered by ``bin/shellgen.py``),
8
8
  * the tab completions under ``shell/completions/``,
9
- * the interactive menu in ``bin/che.py``,
9
+ * the interactive menu in ``bin/menu.py``,
10
10
  * the dependency report printed by ``che doctor``.
11
11
 
12
12
  Before this file existed, ``.zshrc`` was the source of truth and every new
@@ -24,9 +24,13 @@ Library module: no shebang, not executable, imported the same way as
24
24
  from __future__ import annotations
25
25
 
26
26
  import json
27
- from collections.abc import Iterator
28
- from dataclasses import dataclass, field
27
+ from collections.abc import Iterable
28
+ from dataclasses import dataclass
29
29
  from pathlib import Path
30
+ from typing import Literal
31
+
32
+ # How a command previews: "" (it cannot), "env" (DRY_RUN=) or "flag" (--dry-run).
33
+ DryRunStyle = Literal["", "env", "flag"]
30
34
 
31
35
  BIN = Path(__file__).resolve().parent
32
36
  REPO = BIN.parent
@@ -35,7 +39,8 @@ REPO = BIN.parent
35
39
  # generated shell files together. It is read from package.json so that one
36
40
  # `npm version` bump moves the npm release, `che --version` and the header of
37
41
  # every generated file at once. `che doctor` compares it against the version
38
- # recorded in the installed rc block to decide whether a re-install is needed.
42
+ # `che install` recorded in ~/.config/che/config.json to decide whether a
43
+ # re-install is needed.
39
44
  _PACKAGE = json.loads((REPO / "package.json").read_text(encoding="utf-8"))
40
45
  PACKAGE_NAME: str = _PACKAGE["name"]
41
46
  VERSION: str = _PACKAGE["version"]
@@ -53,21 +58,18 @@ DRY_SUFFIX = "-dr"
53
58
  class Category:
54
59
  key: str
55
60
  title: str
56
- icon: str
57
61
  blurb: str
58
62
 
59
63
 
60
64
  CATEGORIES: tuple[Category, ...] = (
61
- Category("git", "Git", "", "Branch hygiene, worktrees and GitHub Actions"),
62
- Category("video", "Video", "", "Codecs, renaming, dedupe and metadata"),
63
- Category("files", "Files", "", "Delete, compress and inspect by size or extension"),
64
- Category("drives", "Drives", "", "Mount, eject and keep-alive for the NAS volumes"),
65
- Category("system", "System", "", "Homebrew, monitoring and the machine itself"),
66
- Category("che", "che", "", "Install, update and inspect che itself"),
65
+ Category("git", "Git", "Branch hygiene, worktrees and GitHub Actions"),
66
+ Category("video", "Video", "Codecs, renaming, dedupe and metadata"),
67
+ Category("files", "Files", "Delete, compress and inspect by size or extension"),
68
+ Category("drives", "Drives", "Mount, eject and keep-alive for the NAS volumes"),
69
+ Category("system", "System", "Homebrew, monitoring and the machine itself"),
70
+ Category("che", "che", "Install, update and inspect che itself"),
67
71
  )
68
72
 
69
- CATEGORY_KEYS = tuple(category.key for category in CATEGORIES)
70
-
71
73
 
72
74
  # ---------------------------------------------------------------------------
73
75
  # Prompts
@@ -83,9 +85,12 @@ CATEGORY_KEYS = tuple(category.key for category in CATEGORIES)
83
85
  class Prompt:
84
86
  """One question the interactive menu asks before running a command."""
85
87
 
86
- flag: str # "--path"; empty string means a positional argument
88
+ flag: str # "--path", without the "="; empty string means a positional argument
87
89
  label: str
88
- kind: str = "text" # text | path | size | int | bool | choice
90
+ # "path" completes filenames and expands ~; "bool" asks yes/no and passes
91
+ # the bare flag. Everything else is "text", re-asked until it is one of
92
+ # `choices` when there are any.
93
+ kind: Literal["text", "path", "bool"] = "text"
89
94
  required: bool = False
90
95
  default: str = ""
91
96
  choices: tuple[str, ...] = ()
@@ -107,23 +112,24 @@ DRY_PROMPT = Prompt("--dry-run", "Dry run (preview only)", kind="bool", default=
107
112
  # ---------------------------------------------------------------------------
108
113
 
109
114
 
110
- @dataclass(frozen=True)
115
+ @dataclass(frozen=True, kw_only=True)
111
116
  class Command:
112
117
  """One user-facing command: a name, a script and how to invoke it."""
113
118
 
114
119
  name: str
115
120
  category: str
116
121
  summary: str
117
- script: str = "" # relative to bin/; empty for a shell-only wrapper
122
+ script: str = "" # relative to bin/; empty only for a built-in
118
123
  icon: str = "•"
119
124
  sound: int | None = None
120
125
  dry_sound: int | None = 7
121
- env: tuple[tuple[str, str], ...] = ()
122
126
  args: tuple[str, ...] = () # fixed arguments, always passed first
123
- flags: tuple[str, ...] = () # every flag the script accepts, for completions
127
+ # Every flag the script accepts, for completions. One that takes a value is
128
+ # spelled with its "=" ("--path="), the way it is typed.
129
+ flags: tuple[str, ...] = ()
124
130
  prompts: tuple[Prompt, ...] = ()
125
131
  needs: tuple[str, ...] = () # external binaries the script shells out to
126
- dry_run: str = "" # "" | "env" (DRY_RUN=) | "flag" (--dry-run)
132
+ dry_run: DryRunStyle = ""
127
133
  destructive: bool = False
128
134
  # Set only where a destructive command genuinely cannot preview, with the
129
135
  # reason. The menu shows it, and the manifest test demands one rather than
@@ -133,9 +139,20 @@ class Command:
133
139
  # not a binary on PATH (a venv, a credential, a mounted volume).
134
140
  setup: str = ""
135
141
  macos_only: bool = False
136
- body: str = "" # shell-only wrapper body, for commands with no script
137
142
  tags: tuple[str, ...] = ()
138
- builtin: bool = False # handled by che.py itself, not a script
143
+
144
+ def __post_init__(self) -> None:
145
+ # `flags=("--path")` is a string, not a tuple, and everything that
146
+ # iterates it then offers `-`, `-`, `p`, `a`, `t`, `h` as flags.
147
+ for name in ("args", "flags", "prompts", "needs", "tags"):
148
+ value = getattr(self, name)
149
+ if not isinstance(value, tuple):
150
+ raise TypeError(f"{self.name}: {name} must be a tuple, got {value!r}")
151
+
152
+ @property
153
+ def builtin(self) -> bool:
154
+ """Handled by che.py itself rather than by a script."""
155
+ return self.name in BUILTIN_NAMES
139
156
 
140
157
  @property
141
158
  def has_dry_twin(self) -> bool:
@@ -147,53 +164,55 @@ class Command:
147
164
  return f"{self.name}{DRY_SUFFIX}"
148
165
 
149
166
  @property
150
- def path(self) -> Path:
151
- return BIN / self.script
152
-
153
- def search_text(self) -> str:
154
- return " ".join((self.name, self.summary, self.category, *self.tags)).lower()
155
-
156
- def invocation(self, *, dry: bool = False) -> tuple[dict[str, str], list[str]]:
157
- """Environment overrides and fixed arguments for one invocation.
158
-
159
- ``dry`` selects the preview form: either ``DRY_RUN=true`` or an added
160
- ``--dry-run``, depending on how the underlying script spells it.
161
- """
162
- env = dict(self.env)
163
- args = list(self.args)
164
-
165
- if self.dry_run == "env":
166
- env["DRY_RUN"] = "true" if dry else "false"
167
- elif self.dry_run == "flag" and dry:
168
- args.append("--dry-run")
167
+ def flag_names(self) -> tuple[str, ...]:
168
+ """``flags`` without the "=" that marks a flag taking a value."""
169
+ return tuple(flag.removesuffix("=") for flag in self.flags)
169
170
 
170
- return env, args
171
+ @property
172
+ def preview(self) -> str:
173
+ """How to look before running for real: the ``-dr`` twin, ``--dry-run``, or ""."""
174
+ if self.has_dry_twin:
175
+ return self.dry_name
176
+ return "--dry-run" if "--dry-run" in self.flags else ""
171
177
 
178
+ @property
179
+ def path(self) -> Path:
180
+ return BIN / self.script
172
181
 
173
- def _cmd(**kwargs: object) -> Command:
174
- """Keyword-only constructor, so a long table stays readable and greppable."""
175
- return Command(**kwargs) # type: ignore[arg-type]
182
+ def variants(self) -> tuple[Resolved, ...]:
183
+ """This command, then its ``-dr`` preview twin when it has one."""
184
+ plain = Resolved(self)
185
+ return (plain, Resolved(self, dry=True)) if self.has_dry_twin else (plain,)
176
186
 
177
187
 
178
188
  COMMANDS: tuple[Command, ...] = (
179
189
  # -- git ----------------------------------------------------------------
180
- _cmd(
190
+ Command(
181
191
  name="all-actions",
182
- flags=("--owner", "--author", "--pr-limit", "--interval", "--watch"),
192
+ flags=(
193
+ "--owner=",
194
+ "--author=",
195
+ "--pr-limit=",
196
+ "--repo-limit=",
197
+ "--no-main",
198
+ "--interval=",
199
+ "--watch",
200
+ ),
183
201
  category="git",
184
202
  icon="🎬",
185
- summary="GitHub Actions status for every open PR you authored",
203
+ summary="GitHub Actions status for every open PR and every default branch",
186
204
  script="git/all-actions.py",
187
205
  needs=("gh", "git"),
188
- tags=("ci", "pr", "github", "workflow"),
206
+ tags=("ci", "pr", "github", "workflow", "main"),
189
207
  prompts=(
190
208
  Prompt("--owner", "Only this owner", help="Defaults to every repo you own"),
191
- Prompt("--pr-limit", "PRs per repo", kind="int"),
209
+ Prompt("--pr-limit", "PRs per repo"),
210
+ Prompt("--no-main", "Skip the default-branch table", kind="bool"),
192
211
  ),
193
212
  ),
194
- _cmd(
213
+ Command(
195
214
  name="all-actions-watch",
196
- flags=("--owner", "--author", "--pr-limit", "--interval"),
215
+ flags=("--owner=", "--author=", "--pr-limit=", "--repo-limit=", "--no-main", "--interval="),
197
216
  category="git",
198
217
  icon="👀",
199
218
  summary="Same as all-actions, refreshed on an interval",
@@ -202,9 +221,9 @@ COMMANDS: tuple[Command, ...] = (
202
221
  needs=("gh", "git"),
203
222
  tags=("ci", "pr", "github", "watch"),
204
223
  ),
205
- _cmd(
224
+ Command(
206
225
  name="checkout-my-branches",
207
- flags=("--author", "--limit"),
226
+ flags=("--author=", "--limit="),
208
227
  category="git",
209
228
  icon="🌿",
210
229
  summary="Check out recent remote branches you authored that aren't local yet",
@@ -214,12 +233,12 @@ COMMANDS: tuple[Command, ...] = (
214
233
  tags=("branch", "remote"),
215
234
  prompts=(
216
235
  Prompt("--author", "Author email", help="Defaults to git config user.email"),
217
- Prompt("--limit", "Remote branches to scan", kind="int"),
236
+ Prompt("--limit", "Remote branches to scan"),
218
237
  ),
219
238
  ),
220
- _cmd(
239
+ Command(
221
240
  name="clean-stale-branches",
222
- flags=("--dry-run", "--protect"),
241
+ flags=("--dry-run", "--protect="),
223
242
  category="git",
224
243
  icon="🧹",
225
244
  summary="Delete local branches whose upstream is gone",
@@ -231,7 +250,7 @@ COMMANDS: tuple[Command, ...] = (
231
250
  tags=("branch", "delete", "prune"),
232
251
  prompts=(Prompt("--protect", "Extra branches to protect", help="Comma separated"),),
233
252
  ),
234
- _cmd(
253
+ Command(
235
254
  name="prune-worktrees",
236
255
  flags=("--dry-run", "--force"),
237
256
  category="git",
@@ -244,36 +263,36 @@ COMMANDS: tuple[Command, ...] = (
244
263
  needs=("git",),
245
264
  tags=("worktree", "prune", "delete"),
246
265
  ),
247
- _cmd(
266
+ Command(
248
267
  name="sync-all-branches",
249
- flags=("--dry-run", "--no-push", "--keep-locked", "--author", "--limit"),
268
+ flags=("--dry-run", "--author=", "--limit="),
250
269
  category="git",
251
270
  icon="🔄",
252
- summary="Push every worktree, collapse the pushed ones, then tidy branches",
271
+ summary="Update every branch and worktree, preserving locks and local work",
253
272
  script="git/sync-all-branches.py",
254
273
  sound=7,
255
- dry_sound=7,
256
274
  dry_run="env",
257
275
  destructive=True,
258
276
  needs=("git",),
259
- tags=("push", "worktree", "branch", "sync"),
277
+ tags=("branch", "sync", "pull", "worktree"),
260
278
  ),
261
- _cmd(
279
+ Command(
262
280
  name="update-from-origin",
263
- flags=("--dry-run", "--keep-locked", "--author", "--limit"),
281
+ flags=("--dry-run", "--author=", "--limit="),
264
282
  category="git",
265
283
  icon="⬇️",
266
- summary="Same as sync-all-branches but never pushes: fetch, collapse, rebase",
284
+ summary="Alias of sync-all-branches",
267
285
  script="git/sync-all-branches.py",
268
- args=("--no-push",),
269
- env=(("DRY_RUN", "false"),),
286
+ # A real run by default; a `--dry-run` typed after it still wins.
287
+ args=("--dry-run=false",),
270
288
  sound=7,
289
+ destructive=True,
271
290
  needs=("git",),
272
- tags=("pull", "fetch", "sync", "worktree"),
291
+ tags=("pull", "fetch", "sync"),
273
292
  ),
274
- _cmd(
293
+ Command(
275
294
  name="update-local-branches",
276
- flags=("--limit", "--dry-run"),
295
+ flags=("--limit=", "--dry-run"),
277
296
  category="git",
278
297
  icon="⬆️",
279
298
  summary="Rebase every local branch that has an upstream onto origin",
@@ -282,14 +301,14 @@ COMMANDS: tuple[Command, ...] = (
282
301
  needs=("git",),
283
302
  tags=("rebase", "branch"),
284
303
  prompts=(
285
- Prompt("--limit", "Only the N most recent branches", kind="int"),
304
+ Prompt("--limit", "Only the N most recent branches"),
286
305
  DRY_PROMPT,
287
306
  ),
288
307
  ),
289
308
  # -- video --------------------------------------------------------------
290
- _cmd(
309
+ Command(
291
310
  name="show-codecs",
292
- flags=("--path", "--verbose"),
311
+ flags=("--path=", "--verbose"),
293
312
  category="video",
294
313
  icon="🎥",
295
314
  summary="Report media files outside the Direct Play codec/container set",
@@ -299,15 +318,14 @@ COMMANDS: tuple[Command, ...] = (
299
318
  tags=("plex", "codec", "audit"),
300
319
  prompts=(PATH_PROMPT, Prompt("--verbose", "Also print files that pass", kind="bool")),
301
320
  ),
302
- _cmd(
321
+ Command(
303
322
  name="fix-codecs",
304
- flags=("--path", "--delete-original", "--dry-run"),
323
+ flags=("--path=", "--delete-original", "--dry-run"),
305
324
  category="video",
306
325
  icon="🔧",
307
326
  summary="Re-encode media to h265/aac mp4 so it Direct Plays",
308
327
  script="video/fix-codecs.py",
309
328
  sound=7,
310
- dry_sound=7,
311
329
  dry_run="env",
312
330
  destructive=True,
313
331
  needs=("ffmpeg",),
@@ -317,9 +335,9 @@ COMMANDS: tuple[Command, ...] = (
317
335
  Prompt("--delete-original", "Delete each source after converting", kind="bool"),
318
336
  ),
319
337
  ),
320
- _cmd(
338
+ Command(
321
339
  name="find-video-mkv-issues",
322
- flags=("--path", "--recursive"),
340
+ flags=("--path=", "--recursive"),
323
341
  category="video",
324
342
  icon="🎬",
325
343
  summary="Scan MKV files and estimate Plex direct-play compatibility",
@@ -329,9 +347,9 @@ COMMANDS: tuple[Command, ...] = (
329
347
  tags=("plex", "mkv", "audit"),
330
348
  prompts=(PATH_PROMPT, Prompt("--recursive", "Recurse into subfolders", kind="bool")),
331
349
  ),
332
- _cmd(
350
+ Command(
333
351
  name="validate-video-files",
334
- flags=("--path", "--verbose"),
352
+ flags=("--path=", "--verbose"),
335
353
  category="video",
336
354
  icon="🩺",
337
355
  summary="Check .mp4/.mkv files decode by playing one frame with mpv",
@@ -341,9 +359,9 @@ COMMANDS: tuple[Command, ...] = (
341
359
  tags=("corrupt", "health", "audit"),
342
360
  prompts=(PATH_PROMPT, Prompt("--verbose", "Print each file as it is checked", kind="bool")),
343
361
  ),
344
- _cmd(
362
+ Command(
345
363
  name="scan-videos-audio-language",
346
- flags=("--path"),
364
+ flags=("--path=",),
347
365
  category="video",
348
366
  icon="🔊",
349
367
  summary="Print the audio language tags of every video under a path",
@@ -353,9 +371,9 @@ COMMANDS: tuple[Command, ...] = (
353
371
  tags=("audio", "language", "dub"),
354
372
  prompts=(PATH_PROMPT,),
355
373
  ),
356
- _cmd(
374
+ Command(
357
375
  name="remove-metadata",
358
- flags=("--path", "--exts"),
376
+ flags=("--path=", "--exts="),
359
377
  category="video",
360
378
  icon="🧼",
361
379
  summary="Strip all metadata from video files recursively",
@@ -367,15 +385,15 @@ COMMANDS: tuple[Command, ...] = (
367
385
  tags=("metadata", "tags", "plex"),
368
386
  prompts=(PATH_PROMPT, Prompt("--exts", "Extensions", help="Comma separated")),
369
387
  ),
370
- _cmd(
388
+ Command(
371
389
  name="rename-video-file",
372
390
  flags=(
373
- "--path",
391
+ "--path=",
374
392
  "--recursive",
375
393
  "--rename-folders",
376
394
  "--capitalize-preps",
377
395
  "--dry-run",
378
- "--ignore-words",
396
+ "--ignore-words=",
379
397
  ),
380
398
  category="video",
381
399
  icon="📝",
@@ -391,14 +409,14 @@ COMMANDS: tuple[Command, ...] = (
391
409
  prompts=(
392
410
  PATH_PROMPT,
393
411
  Prompt("--recursive", "Recurse into subdirectories", kind="bool", default="true"),
394
- Prompt("--rename-folders", "Rename folders too", kind="bool"),
412
+ Prompt("--rename-folders", "Rename folders too", kind="bool", default="true"),
395
413
  Prompt("--dry-run", "Preview only", kind="bool", default="true"),
396
414
  Prompt("--ignore-words", "Words to leave as written", help="Comma separated"),
397
415
  ),
398
416
  ),
399
- _cmd(
417
+ Command(
400
418
  name="delete-duplicate-videos",
401
- flags=("--path", "--strategy", "--dry-run", "--verbose"),
419
+ flags=("--path=", "--strategy=", "--dry-run", "--verbose"),
402
420
  category="video",
403
421
  icon="🗑️",
404
422
  summary="Delete duplicate MKV/MP4 files under a root directory",
@@ -413,15 +431,14 @@ COMMANDS: tuple[Command, ...] = (
413
431
  Prompt(
414
432
  "--strategy",
415
433
  "Match strategy",
416
- kind="choice",
417
434
  choices=("episode", "filename", "size", "hash", "all"),
418
435
  default="episode",
419
436
  ),
420
437
  ),
421
438
  ),
422
- _cmd(
439
+ Command(
423
440
  name="video-list",
424
- flags=("--path", "--recursive", "--with-folder", "--sort"),
441
+ flags=("--path=", "--recursive", "--with-folder", "--sort="),
425
442
  category="video",
426
443
  icon="📋",
427
444
  summary="List .mp4/.mkv files under a path with human-readable sizes",
@@ -434,15 +451,14 @@ COMMANDS: tuple[Command, ...] = (
434
451
  Prompt(
435
452
  "--sort",
436
453
  "Sort order",
437
- kind="choice",
438
454
  choices=("alpha", "fileSizeAsc", "fileSizeDesc"),
439
455
  default="alpha",
440
456
  ),
441
457
  ),
442
458
  ),
443
- _cmd(
459
+ Command(
444
460
  name="detect-green-magenta-videos",
445
- flags=("--samples", "--threshold", "--verbose"),
461
+ flags=("--samples=", "--threshold=", "--verbose"),
446
462
  category="video",
447
463
  icon="🟢",
448
464
  summary="Detect videos with the green/magenta chroma artifact",
@@ -455,9 +471,9 @@ COMMANDS: tuple[Command, ...] = (
455
471
  tags=("corrupt", "artifact", "opencv"),
456
472
  prompts=(Prompt("", "Files or folders to scan", kind="path", required=True),),
457
473
  ),
458
- _cmd(
474
+ Command(
459
475
  name="find-movie-by-year",
460
- flags=("--year", "--path"),
476
+ flags=("--year=", "--path="),
461
477
  category="video",
462
478
  icon="🎞️",
463
479
  summary='Find movie folders whose name ends with "(YYYY)"',
@@ -466,21 +482,21 @@ COMMANDS: tuple[Command, ...] = (
466
482
  tags=("movie", "year", "search"),
467
483
  prompts=(Prompt("--year", "Year", required=True), PATH_PROMPT_CWD),
468
484
  ),
469
- _cmd(
485
+ Command(
470
486
  name="largest-tv-shows",
471
- flags=("--path", "--limit", "--full-path", "--debug"),
487
+ flags=("--path=", "--limit=", "--full-path", "--debug"),
472
488
  category="video",
473
489
  icon="📺",
474
490
  summary="Rank TV show folders in a library by total size on disk",
475
491
  script="video/largest-tv-shows.py",
476
492
  sound=2,
477
493
  tags=("size", "tv", "report"),
478
- prompts=(PATH_PROMPT, Prompt("--limit", "How many to list", kind="int", default="20")),
494
+ prompts=(PATH_PROMPT, Prompt("--limit", "How many to list", default="20")),
479
495
  ),
480
496
  # -- files --------------------------------------------------------------
481
- _cmd(
497
+ Command(
482
498
  name="delete-by-ext",
483
- flags=("--path", "--ext", "--dry-run", "--verbose"),
499
+ flags=("--path=", "--ext=", "--dry-run", "--verbose"),
484
500
  category="files",
485
501
  icon="🗑️",
486
502
  summary="Delete files under a path matching a set of extensions",
@@ -491,9 +507,9 @@ COMMANDS: tuple[Command, ...] = (
491
507
  tags=("delete", "extension", "cleanup"),
492
508
  prompts=(PATH_PROMPT, Prompt("--ext", "Extensions", help="Comma separated")),
493
509
  ),
494
- _cmd(
510
+ Command(
495
511
  name="delete-empty-folders",
496
- flags=("--path", "--dry-run", "--verbose"),
512
+ flags=("--path=", "--dry-run", "--verbose"),
497
513
  category="files",
498
514
  icon="🧹",
499
515
  summary="Delete truly-empty directories under a path, cascading upwards",
@@ -504,9 +520,9 @@ COMMANDS: tuple[Command, ...] = (
504
520
  tags=("delete", "empty", "cleanup"),
505
521
  prompts=(PATH_PROMPT,),
506
522
  ),
507
- _cmd(
523
+ Command(
508
524
  name="delete-smb-files",
509
- flags=("--path", "--dry-run"),
525
+ flags=("--path=", "--dry-run"),
510
526
  category="files",
511
527
  icon="🧽",
512
528
  summary="Delete .smbdelete* files left behind by an SMB share",
@@ -517,9 +533,9 @@ COMMANDS: tuple[Command, ...] = (
517
533
  tags=("delete", "smb", "nas", "cleanup"),
518
534
  prompts=(PATH_PROMPT,),
519
535
  ),
520
- _cmd(
536
+ Command(
521
537
  name="files-under-size",
522
- flags=("--path", "--size", "--dry-run"),
538
+ flags=("--path=", "--size=", "--dry-run"),
523
539
  category="files",
524
540
  icon="📉",
525
541
  summary="Find video files at or under a size threshold",
@@ -528,11 +544,11 @@ COMMANDS: tuple[Command, ...] = (
528
544
  dry_run="env",
529
545
  destructive=True,
530
546
  tags=("size", "delete", "small"),
531
- prompts=(PATH_PROMPT, Prompt("--size", "Size threshold", kind="size", required=True)),
547
+ prompts=(PATH_PROMPT, Prompt("--size", "Size threshold", required=True)),
532
548
  ),
533
- _cmd(
549
+ Command(
534
550
  name="find-largest-files",
535
- flags=("--path", "--length", "--full-path"),
551
+ flags=("--path=", "--length=", "--full-path"),
536
552
  category="files",
537
553
  icon="📊",
538
554
  summary="List the largest files under a path, biggest first",
@@ -541,13 +557,13 @@ COMMANDS: tuple[Command, ...] = (
541
557
  tags=("size", "report", "disk"),
542
558
  prompts=(
543
559
  PATH_PROMPT_CWD,
544
- Prompt("--length", "How many to list", kind="int", default="10"),
560
+ Prompt("--length", "How many to list", default="10"),
545
561
  Prompt("--full-path", "Print the whole path", kind="bool"),
546
562
  ),
547
563
  ),
548
- _cmd(
564
+ Command(
549
565
  name="make-alpha-dir",
550
- flags=("--path"),
566
+ flags=("--path=",),
551
567
  category="files",
552
568
  icon="🔤",
553
569
  summary="Create '#' and A-Z bucket folders under a parent directory",
@@ -556,9 +572,9 @@ COMMANDS: tuple[Command, ...] = (
556
572
  tags=("organize", "folders"),
557
573
  prompts=(PATH_PROMPT_CWD,),
558
574
  ),
559
- _cmd(
575
+ Command(
560
576
  name="compress-folders",
561
- flags=("--path", "--dry-run", "--verbose"),
577
+ flags=("--path=", "--dry-run", "--verbose"),
562
578
  category="files",
563
579
  icon="📦",
564
580
  summary="Zip every immediate subfolder of a path at max compression",
@@ -567,26 +583,22 @@ COMMANDS: tuple[Command, ...] = (
567
583
  tags=("zip", "archive", "compress"),
568
584
  prompts=(PATH_PROMPT, DRY_PROMPT),
569
585
  ),
570
- _cmd(
586
+ Command(
571
587
  name="list-permission",
572
588
  flags=(),
573
589
  category="files",
574
590
  icon="🔐",
575
591
  summary="Show ownership and mode of a volume under /Volumes",
592
+ script="files/list-permission.py",
576
593
  sound=7,
577
594
  tags=("permissions", "volume", "nas"),
578
- # `command` restores what the hand-written wrapper had: zsh expands
579
- # aliases when it *parses* a function body, and che.zsh is sourced after
580
- # the user's aliases, so a bare `ls` here bakes in an `alias ls=eza` and
581
- # the wrapper silently stops printing an `ls -l` listing.
582
- body='command ls -ld "/Volumes/$1"',
583
595
  prompts=(Prompt("", "Volume name", required=True),),
584
596
  macos_only=True,
585
597
  ),
586
598
  # -- drives -------------------------------------------------------------
587
- _cmd(
599
+ Command(
588
600
  name="mount-all-drives",
589
- flags=("--only", "--use-ip", "--quiet"),
601
+ flags=("--only=", "--use-ip", "--quiet"),
590
602
  category="drives",
591
603
  icon="💿",
592
604
  summary="Mount all NAS drives over SMB via AppleScript",
@@ -597,15 +609,14 @@ COMMANDS: tuple[Command, ...] = (
597
609
  tags=("nas", "smb", "mount"),
598
610
  prompts=(Prompt("--only", "Only this drive", help="Case-insensitive name"),),
599
611
  ),
600
- _cmd(
612
+ Command(
601
613
  name="eject-all-drives",
602
- flags=("--dry-run", "--only", "--no-force", "--no-clear-favorites", "--quiet"),
614
+ flags=("--dry-run", "--only=", "--no-force", "--no-clear-favorites", "--quiet"),
603
615
  category="drives",
604
616
  icon="📤",
605
617
  summary="Eject all NAS volumes from /Volumes",
606
618
  script="drives/eject-all-drives.py",
607
619
  sound=7,
608
- dry_sound=7,
609
620
  dry_run="flag",
610
621
  destructive=True,
611
622
  needs=("diskutil",),
@@ -613,12 +624,12 @@ COMMANDS: tuple[Command, ...] = (
613
624
  tags=("nas", "smb", "eject", "unmount"),
614
625
  prompts=(Prompt("--only", "Only this drive", help="Case-insensitive name"),),
615
626
  ),
616
- _cmd(
627
+ Command(
617
628
  name="ping-nas",
618
629
  flags=(
619
- "--interval",
620
- "--ping-timeout",
621
- "--only",
630
+ "--interval=",
631
+ "--ping-timeout=",
632
+ "--only=",
622
633
  "--no-remount",
623
634
  "--use-ip",
624
635
  "--once",
@@ -632,15 +643,14 @@ COMMANDS: tuple[Command, ...] = (
632
643
  tags=("nas", "keepalive", "network"),
633
644
  ),
634
645
  # -- system -------------------------------------------------------------
635
- _cmd(
646
+ Command(
636
647
  name="update-brew",
637
- flags=("--dry-run", "--no-cask", "--no-cleanup"),
648
+ flags=("--dry-run", "--no-cask", "--no-cleanup", "--no-autoremove", "--greedy", "--doctor"),
638
649
  category="system",
639
650
  icon="🍺",
640
651
  summary="Update Homebrew, upgrade formulae and casks, then clean up",
641
652
  script="system/update-brew.py",
642
653
  sound=7,
643
- dry_sound=7,
644
654
  dry_run="flag",
645
655
  needs=("brew",),
646
656
  tags=("homebrew", "update", "packages"),
@@ -649,7 +659,7 @@ COMMANDS: tuple[Command, ...] = (
649
659
  Prompt("--no-cleanup", "Skip brew cleanup", kind="bool"),
650
660
  ),
651
661
  ),
652
- _cmd(
662
+ Command(
653
663
  name="btop",
654
664
  flags=(),
655
665
  category="system",
@@ -664,72 +674,74 @@ COMMANDS: tuple[Command, ...] = (
664
674
 
665
675
  # ---------------------------------------------------------------------------
666
676
  # Built-in subcommands - handled inside che.py, listed here so they show up in
667
- # `che --help`, the completions and the menu alongside everything else.
677
+ # `che --help`, the completions and the menu alongside everything else. Their
678
+ # flags are checked against each one's parser, like a script's against its
679
+ # --help.
668
680
  # ---------------------------------------------------------------------------
669
681
 
670
682
  BUILTINS: tuple[Command, ...] = (
671
- _cmd(
683
+ Command(
672
684
  name="install",
673
685
  flags=(
674
- "--shells",
686
+ "--shells=",
675
687
  "--all-shells",
676
688
  "--yes",
677
689
  "--dry-run",
678
- "--no-completions",
679
690
  "--no-path-shim",
691
+ "--no-sounds",
680
692
  "--replace-legacy",
693
+ "--install-deps",
681
694
  "--print",
682
695
  ),
683
696
  category="che",
684
697
  icon="📥",
685
698
  summary="Install the shell wrappers into your shell startup files",
686
- builtin=True,
687
699
  tags=("setup", "wizard", "zshrc", "bashrc"),
688
700
  ),
689
- _cmd(
701
+ Command(
690
702
  name="update",
691
- flags=("--check", "--yes"),
703
+ flags=("--check", "--dry-run"),
692
704
  category="che",
693
705
  icon="⬆️",
694
706
  summary="Pull the latest scripts and refresh the installed wrappers",
695
- builtin=True,
696
707
  needs=("git",),
697
708
  tags=("upgrade", "self-update", "pull"),
698
709
  ),
699
- _cmd(
710
+ Command(
700
711
  name="doctor",
701
712
  flags=("--json", "--verbose"),
702
713
  category="che",
703
714
  icon="🩺",
704
715
  summary="Check the install, the interpreter and every external tool",
705
- builtin=True,
706
716
  tags=("diagnose", "health", "check"),
707
717
  ),
708
- _cmd(
718
+ Command(
709
719
  name="uninstall",
710
- flags=("--yes", "--dry-run", "--purge"),
720
+ flags=("--dry-run", "--purge"),
711
721
  category="che",
712
722
  icon="🧽",
713
723
  summary="Remove the wrappers, the shim and the completions",
714
- builtin=True,
724
+ destructive=True,
715
725
  tags=("remove", "clean"),
726
+ prompts=(
727
+ Prompt("--dry-run", "Preview only", kind="bool", default="true"),
728
+ Prompt("--purge", "Also delete the config and menu history", kind="bool"),
729
+ ),
716
730
  ),
717
- _cmd(
731
+ Command(
718
732
  name="list",
719
- flags=("--category", "--json", "--dry"),
733
+ flags=("--category=", "--json", "--no-dry"),
720
734
  category="che",
721
735
  icon="📋",
722
736
  summary="List every command, one per line",
723
- builtin=True,
724
737
  tags=("ls", "commands"),
725
738
  ),
726
- _cmd(
739
+ Command(
727
740
  name="completions",
728
- flags=("--shell"),
741
+ flags=("--shell=",),
729
742
  category="che",
730
743
  icon="⌨️",
731
744
  summary="Print the completion script for a shell",
732
- builtin=True,
733
745
  tags=("tab", "complete"),
734
746
  ),
735
747
  )
@@ -765,18 +777,57 @@ MENU_KEYS: tuple[tuple[str, str], ...] = (
765
777
 
766
778
  @dataclass(frozen=True)
767
779
  class Resolved:
768
- """A command name resolved to the command plus whether it was the ``-dr`` twin."""
780
+ """One name a user can call: a command, or its ``-dr`` preview twin."""
769
781
 
770
782
  command: Command
771
783
  dry: bool = False
772
- matched: str = field(default="")
773
784
 
785
+ @property
786
+ def name(self) -> str:
787
+ return self.command.dry_name if self.dry else self.command.name
774
788
 
775
- _BY_NAME: dict[str, Command] = {command.name: command for command in (*COMMANDS, *BUILTINS)}
789
+ @property
790
+ def sound(self) -> int | None:
791
+ return self.command.dry_sound if self.dry else self.command.sound
792
+
793
+ def summary(self, preview: str) -> str:
794
+ """The command's summary, with ``(preview)`` appended on the twin."""
795
+ return f"{self.command.summary} ({preview})" if self.dry else self.command.summary
796
+
797
+ @property
798
+ def env(self) -> dict[str, str]:
799
+ """Environment overrides under this name: ``DRY_RUN`` for a command
800
+ that previews through it, nothing for any other."""
801
+ if self.command.dry_run != "env":
802
+ return {}
803
+ return {"DRY_RUN": "true" if self.dry else "false"}
804
+
805
+ @property
806
+ def args(self) -> tuple[str, ...]:
807
+ """Fixed arguments under this name, before anything the user typed: the
808
+ command's own, plus ``--dry-run`` on the twin of a command that previews
809
+ through the flag."""
810
+ preview = ("--dry-run",) if self.dry and self.command.dry_run == "flag" else ()
811
+ return (*self.command.args, *preview)
812
+
813
+
814
+ def all_commands() -> tuple[Command, ...]:
815
+ return (*COMMANDS, *BUILTINS)
816
+
817
+
818
+ def variants(commands: Iterable[Command]) -> list[Resolved]:
819
+ """Every callable name ``commands`` define, each twin right after its command."""
820
+ return [variant for command in commands for variant in command.variants()]
821
+
822
+
823
+ def grouped(commands: Iterable[Command]) -> list[tuple[Category, list[Command]]]:
824
+ """``commands`` by category, in category order, leaving out empty categories."""
825
+ commands = tuple(commands)
826
+ groups = [(cat, [c for c in commands if c.category == cat.key]) for cat in CATEGORIES]
827
+ return [(category, members) for category, members in groups if members]
776
828
 
777
829
 
778
- def all_commands(*, include_builtins: bool = True) -> tuple[Command, ...]:
779
- return (*COMMANDS, *BUILTINS) if include_builtins else COMMANDS
830
+ _BY_NAME: dict[str, Resolved] = {variant.name: variant for variant in variants(all_commands())}
780
831
 
781
832
 
782
833
  def resolve(name: str) -> Resolved | None:
@@ -787,37 +838,17 @@ def resolve(name: str) -> Resolved | None:
787
838
  a "did you mean" message rather than an exception, because the name usually
788
839
  came from a typo at a shell prompt.
789
840
  """
790
- command = _BY_NAME.get(name)
791
- if command is not None:
792
- return Resolved(command, dry=False, matched=name)
841
+ return _BY_NAME.get(name)
793
842
 
794
- if name.endswith(DRY_SUFFIX):
795
- base = _BY_NAME.get(name[: -len(DRY_SUFFIX)])
796
- if base is not None and base.has_dry_twin:
797
- return Resolved(base, dry=True, matched=name)
798
843
 
799
- return None
800
-
801
-
802
- def wrapper_names() -> Iterator[str]:
844
+ def wrapper_names() -> list[str]:
803
845
  """Every name the generated shell files define, in menu order."""
804
- for command in COMMANDS:
805
- yield command.name
806
- if command.has_dry_twin:
807
- yield command.dry_name
808
-
846
+ return [variant.name for variant in variants(COMMANDS)]
809
847
 
810
- def command_names(*, include_dry: bool = True, include_builtins: bool = True) -> list[str]:
811
- names = []
812
- for command in all_commands(include_builtins=include_builtins):
813
- names.append(command.name)
814
- if include_dry and command.has_dry_twin:
815
- names.append(command.dry_name)
816
- return names
817
848
 
818
-
819
- def in_category(key: str) -> tuple[Command, ...]:
820
- return tuple(command for command in all_commands() if command.category == key)
849
+ def command_names() -> list[str]:
850
+ """Every name ``che`` accepts, the built-ins and the ``-dr`` twins included."""
851
+ return list(_BY_NAME)
821
852
 
822
853
 
823
854
  def required_binaries() -> dict[str, tuple[str, ...]]:
@@ -827,46 +858,3 @@ def required_binaries() -> dict[str, tuple[str, ...]]:
827
858
  for binary in command.needs:
828
859
  index.setdefault(binary, []).append(command.name)
829
860
  return {binary: tuple(names) for binary, names in sorted(index.items())}
830
-
831
-
832
- def suggest(name: str, *, limit: int = 3) -> list[str]:
833
- """Closest known command names, for the "did you mean" line."""
834
- from difflib import get_close_matches
835
-
836
- pool = command_names()
837
- close = get_close_matches(name, pool, n=limit, cutoff=0.6)
838
- if close:
839
- return close
840
- # get_close_matches misses substring typos like "codecs" for "show-codecs".
841
- return [candidate for candidate in pool if name and name in candidate][:limit]
842
-
843
-
844
- def fuzzy_match(query: str, command: Command) -> int | None:
845
- """Score ``command`` against ``query`` for the menu's type-to-filter.
846
-
847
- Returns ``None`` when it does not match at all, otherwise a score where
848
- lower is better: an exact name prefix beats a name substring, which beats a
849
- match that only landed in the summary or tags, which beats a subsequence
850
- match ("dbe" -> "delete-by-ext").
851
- """
852
- if not query:
853
- return 0
854
-
855
- needle = query.lower().strip()
856
- name = command.name.lower()
857
-
858
- if name.startswith(needle):
859
- return 1
860
- if needle in name:
861
- return 2
862
- if needle in command.search_text():
863
- return 3
864
-
865
- # Subsequence: every character of the query appears in order in the name.
866
- position = 0
867
- for char in needle:
868
- found = name.find(char, position)
869
- if found < 0:
870
- return None
871
- position = found + 1
872
- return 4