@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/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,37 @@ 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="Delete merged branches, rebase the rest, check out new ones; branches only",
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", "prune"),
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, spelled the way sync-all-branches tells its own children;
287
+ # a `--dry-run` typed after it still wins.
288
+ args=("--dry-run=false",),
270
289
  sound=7,
290
+ destructive=True,
271
291
  needs=("git",),
272
- tags=("pull", "fetch", "sync", "worktree"),
292
+ tags=("pull", "fetch", "sync"),
273
293
  ),
274
- _cmd(
294
+ Command(
275
295
  name="update-local-branches",
276
- flags=("--limit", "--dry-run"),
296
+ flags=("--limit=", "--dry-run"),
277
297
  category="git",
278
298
  icon="⬆️",
279
299
  summary="Rebase every local branch that has an upstream onto origin",
@@ -282,14 +302,14 @@ COMMANDS: tuple[Command, ...] = (
282
302
  needs=("git",),
283
303
  tags=("rebase", "branch"),
284
304
  prompts=(
285
- Prompt("--limit", "Only the N most recent branches", kind="int"),
305
+ Prompt("--limit", "Only the N most recent branches"),
286
306
  DRY_PROMPT,
287
307
  ),
288
308
  ),
289
309
  # -- video --------------------------------------------------------------
290
- _cmd(
310
+ Command(
291
311
  name="show-codecs",
292
- flags=("--path", "--verbose"),
312
+ flags=("--path=", "--verbose"),
293
313
  category="video",
294
314
  icon="🎥",
295
315
  summary="Report media files outside the Direct Play codec/container set",
@@ -299,15 +319,14 @@ COMMANDS: tuple[Command, ...] = (
299
319
  tags=("plex", "codec", "audit"),
300
320
  prompts=(PATH_PROMPT, Prompt("--verbose", "Also print files that pass", kind="bool")),
301
321
  ),
302
- _cmd(
322
+ Command(
303
323
  name="fix-codecs",
304
- flags=("--path", "--delete-original", "--dry-run"),
324
+ flags=("--path=", "--delete-original", "--dry-run"),
305
325
  category="video",
306
326
  icon="🔧",
307
327
  summary="Re-encode media to h265/aac mp4 so it Direct Plays",
308
328
  script="video/fix-codecs.py",
309
329
  sound=7,
310
- dry_sound=7,
311
330
  dry_run="env",
312
331
  destructive=True,
313
332
  needs=("ffmpeg",),
@@ -317,9 +336,9 @@ COMMANDS: tuple[Command, ...] = (
317
336
  Prompt("--delete-original", "Delete each source after converting", kind="bool"),
318
337
  ),
319
338
  ),
320
- _cmd(
339
+ Command(
321
340
  name="find-video-mkv-issues",
322
- flags=("--path", "--recursive"),
341
+ flags=("--path=", "--recursive"),
323
342
  category="video",
324
343
  icon="🎬",
325
344
  summary="Scan MKV files and estimate Plex direct-play compatibility",
@@ -329,9 +348,9 @@ COMMANDS: tuple[Command, ...] = (
329
348
  tags=("plex", "mkv", "audit"),
330
349
  prompts=(PATH_PROMPT, Prompt("--recursive", "Recurse into subfolders", kind="bool")),
331
350
  ),
332
- _cmd(
351
+ Command(
333
352
  name="validate-video-files",
334
- flags=("--path", "--verbose"),
353
+ flags=("--path=", "--verbose"),
335
354
  category="video",
336
355
  icon="🩺",
337
356
  summary="Check .mp4/.mkv files decode by playing one frame with mpv",
@@ -341,9 +360,9 @@ COMMANDS: tuple[Command, ...] = (
341
360
  tags=("corrupt", "health", "audit"),
342
361
  prompts=(PATH_PROMPT, Prompt("--verbose", "Print each file as it is checked", kind="bool")),
343
362
  ),
344
- _cmd(
363
+ Command(
345
364
  name="scan-videos-audio-language",
346
- flags=("--path"),
365
+ flags=("--path=",),
347
366
  category="video",
348
367
  icon="🔊",
349
368
  summary="Print the audio language tags of every video under a path",
@@ -353,9 +372,9 @@ COMMANDS: tuple[Command, ...] = (
353
372
  tags=("audio", "language", "dub"),
354
373
  prompts=(PATH_PROMPT,),
355
374
  ),
356
- _cmd(
375
+ Command(
357
376
  name="remove-metadata",
358
- flags=("--path", "--exts"),
377
+ flags=("--path=", "--exts="),
359
378
  category="video",
360
379
  icon="🧼",
361
380
  summary="Strip all metadata from video files recursively",
@@ -367,15 +386,15 @@ COMMANDS: tuple[Command, ...] = (
367
386
  tags=("metadata", "tags", "plex"),
368
387
  prompts=(PATH_PROMPT, Prompt("--exts", "Extensions", help="Comma separated")),
369
388
  ),
370
- _cmd(
389
+ Command(
371
390
  name="rename-video-file",
372
391
  flags=(
373
- "--path",
392
+ "--path=",
374
393
  "--recursive",
375
394
  "--rename-folders",
376
395
  "--capitalize-preps",
377
396
  "--dry-run",
378
- "--ignore-words",
397
+ "--ignore-words=",
379
398
  ),
380
399
  category="video",
381
400
  icon="📝",
@@ -391,14 +410,14 @@ COMMANDS: tuple[Command, ...] = (
391
410
  prompts=(
392
411
  PATH_PROMPT,
393
412
  Prompt("--recursive", "Recurse into subdirectories", kind="bool", default="true"),
394
- Prompt("--rename-folders", "Rename folders too", kind="bool"),
413
+ Prompt("--rename-folders", "Rename folders too", kind="bool", default="true"),
395
414
  Prompt("--dry-run", "Preview only", kind="bool", default="true"),
396
415
  Prompt("--ignore-words", "Words to leave as written", help="Comma separated"),
397
416
  ),
398
417
  ),
399
- _cmd(
418
+ Command(
400
419
  name="delete-duplicate-videos",
401
- flags=("--path", "--strategy", "--dry-run", "--verbose"),
420
+ flags=("--path=", "--strategy=", "--dry-run", "--verbose"),
402
421
  category="video",
403
422
  icon="🗑️",
404
423
  summary="Delete duplicate MKV/MP4 files under a root directory",
@@ -413,15 +432,14 @@ COMMANDS: tuple[Command, ...] = (
413
432
  Prompt(
414
433
  "--strategy",
415
434
  "Match strategy",
416
- kind="choice",
417
435
  choices=("episode", "filename", "size", "hash", "all"),
418
436
  default="episode",
419
437
  ),
420
438
  ),
421
439
  ),
422
- _cmd(
440
+ Command(
423
441
  name="video-list",
424
- flags=("--path", "--recursive", "--with-folder", "--sort"),
442
+ flags=("--path=", "--recursive", "--with-folder", "--sort="),
425
443
  category="video",
426
444
  icon="📋",
427
445
  summary="List .mp4/.mkv files under a path with human-readable sizes",
@@ -434,15 +452,14 @@ COMMANDS: tuple[Command, ...] = (
434
452
  Prompt(
435
453
  "--sort",
436
454
  "Sort order",
437
- kind="choice",
438
455
  choices=("alpha", "fileSizeAsc", "fileSizeDesc"),
439
456
  default="alpha",
440
457
  ),
441
458
  ),
442
459
  ),
443
- _cmd(
460
+ Command(
444
461
  name="detect-green-magenta-videos",
445
- flags=("--samples", "--threshold", "--verbose"),
462
+ flags=("--samples=", "--threshold=", "--verbose"),
446
463
  category="video",
447
464
  icon="🟢",
448
465
  summary="Detect videos with the green/magenta chroma artifact",
@@ -455,9 +472,9 @@ COMMANDS: tuple[Command, ...] = (
455
472
  tags=("corrupt", "artifact", "opencv"),
456
473
  prompts=(Prompt("", "Files or folders to scan", kind="path", required=True),),
457
474
  ),
458
- _cmd(
475
+ Command(
459
476
  name="find-movie-by-year",
460
- flags=("--year", "--path"),
477
+ flags=("--year=", "--path="),
461
478
  category="video",
462
479
  icon="🎞️",
463
480
  summary='Find movie folders whose name ends with "(YYYY)"',
@@ -466,21 +483,21 @@ COMMANDS: tuple[Command, ...] = (
466
483
  tags=("movie", "year", "search"),
467
484
  prompts=(Prompt("--year", "Year", required=True), PATH_PROMPT_CWD),
468
485
  ),
469
- _cmd(
486
+ Command(
470
487
  name="largest-tv-shows",
471
- flags=("--path", "--limit", "--full-path", "--debug"),
488
+ flags=("--path=", "--limit=", "--full-path", "--debug"),
472
489
  category="video",
473
490
  icon="📺",
474
491
  summary="Rank TV show folders in a library by total size on disk",
475
492
  script="video/largest-tv-shows.py",
476
493
  sound=2,
477
494
  tags=("size", "tv", "report"),
478
- prompts=(PATH_PROMPT, Prompt("--limit", "How many to list", kind="int", default="20")),
495
+ prompts=(PATH_PROMPT, Prompt("--limit", "How many to list", default="20")),
479
496
  ),
480
497
  # -- files --------------------------------------------------------------
481
- _cmd(
498
+ Command(
482
499
  name="delete-by-ext",
483
- flags=("--path", "--ext", "--dry-run", "--verbose"),
500
+ flags=("--path=", "--ext=", "--dry-run", "--verbose"),
484
501
  category="files",
485
502
  icon="🗑️",
486
503
  summary="Delete files under a path matching a set of extensions",
@@ -491,9 +508,9 @@ COMMANDS: tuple[Command, ...] = (
491
508
  tags=("delete", "extension", "cleanup"),
492
509
  prompts=(PATH_PROMPT, Prompt("--ext", "Extensions", help="Comma separated")),
493
510
  ),
494
- _cmd(
511
+ Command(
495
512
  name="delete-empty-folders",
496
- flags=("--path", "--dry-run", "--verbose"),
513
+ flags=("--path=", "--dry-run", "--verbose"),
497
514
  category="files",
498
515
  icon="🧹",
499
516
  summary="Delete truly-empty directories under a path, cascading upwards",
@@ -504,9 +521,9 @@ COMMANDS: tuple[Command, ...] = (
504
521
  tags=("delete", "empty", "cleanup"),
505
522
  prompts=(PATH_PROMPT,),
506
523
  ),
507
- _cmd(
524
+ Command(
508
525
  name="delete-smb-files",
509
- flags=("--path", "--dry-run"),
526
+ flags=("--path=", "--dry-run"),
510
527
  category="files",
511
528
  icon="🧽",
512
529
  summary="Delete .smbdelete* files left behind by an SMB share",
@@ -517,9 +534,9 @@ COMMANDS: tuple[Command, ...] = (
517
534
  tags=("delete", "smb", "nas", "cleanup"),
518
535
  prompts=(PATH_PROMPT,),
519
536
  ),
520
- _cmd(
537
+ Command(
521
538
  name="files-under-size",
522
- flags=("--path", "--size", "--dry-run"),
539
+ flags=("--path=", "--size=", "--dry-run"),
523
540
  category="files",
524
541
  icon="📉",
525
542
  summary="Find video files at or under a size threshold",
@@ -528,11 +545,11 @@ COMMANDS: tuple[Command, ...] = (
528
545
  dry_run="env",
529
546
  destructive=True,
530
547
  tags=("size", "delete", "small"),
531
- prompts=(PATH_PROMPT, Prompt("--size", "Size threshold", kind="size", required=True)),
548
+ prompts=(PATH_PROMPT, Prompt("--size", "Size threshold", required=True)),
532
549
  ),
533
- _cmd(
550
+ Command(
534
551
  name="find-largest-files",
535
- flags=("--path", "--length", "--full-path"),
552
+ flags=("--path=", "--length=", "--full-path"),
536
553
  category="files",
537
554
  icon="📊",
538
555
  summary="List the largest files under a path, biggest first",
@@ -541,13 +558,13 @@ COMMANDS: tuple[Command, ...] = (
541
558
  tags=("size", "report", "disk"),
542
559
  prompts=(
543
560
  PATH_PROMPT_CWD,
544
- Prompt("--length", "How many to list", kind="int", default="10"),
561
+ Prompt("--length", "How many to list", default="10"),
545
562
  Prompt("--full-path", "Print the whole path", kind="bool"),
546
563
  ),
547
564
  ),
548
- _cmd(
565
+ Command(
549
566
  name="make-alpha-dir",
550
- flags=("--path"),
567
+ flags=("--path=",),
551
568
  category="files",
552
569
  icon="🔤",
553
570
  summary="Create '#' and A-Z bucket folders under a parent directory",
@@ -556,9 +573,9 @@ COMMANDS: tuple[Command, ...] = (
556
573
  tags=("organize", "folders"),
557
574
  prompts=(PATH_PROMPT_CWD,),
558
575
  ),
559
- _cmd(
576
+ Command(
560
577
  name="compress-folders",
561
- flags=("--path", "--dry-run", "--verbose"),
578
+ flags=("--path=", "--dry-run", "--verbose"),
562
579
  category="files",
563
580
  icon="📦",
564
581
  summary="Zip every immediate subfolder of a path at max compression",
@@ -567,26 +584,22 @@ COMMANDS: tuple[Command, ...] = (
567
584
  tags=("zip", "archive", "compress"),
568
585
  prompts=(PATH_PROMPT, DRY_PROMPT),
569
586
  ),
570
- _cmd(
587
+ Command(
571
588
  name="list-permission",
572
589
  flags=(),
573
590
  category="files",
574
591
  icon="🔐",
575
592
  summary="Show ownership and mode of a volume under /Volumes",
593
+ script="files/list-permission.py",
576
594
  sound=7,
577
595
  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
596
  prompts=(Prompt("", "Volume name", required=True),),
584
597
  macos_only=True,
585
598
  ),
586
599
  # -- drives -------------------------------------------------------------
587
- _cmd(
600
+ Command(
588
601
  name="mount-all-drives",
589
- flags=("--only", "--use-ip", "--quiet"),
602
+ flags=("--only=", "--use-ip", "--quiet"),
590
603
  category="drives",
591
604
  icon="💿",
592
605
  summary="Mount all NAS drives over SMB via AppleScript",
@@ -597,15 +610,14 @@ COMMANDS: tuple[Command, ...] = (
597
610
  tags=("nas", "smb", "mount"),
598
611
  prompts=(Prompt("--only", "Only this drive", help="Case-insensitive name"),),
599
612
  ),
600
- _cmd(
613
+ Command(
601
614
  name="eject-all-drives",
602
- flags=("--dry-run", "--only", "--no-force", "--no-clear-favorites", "--quiet"),
615
+ flags=("--dry-run", "--only=", "--no-force", "--no-clear-favorites", "--quiet"),
603
616
  category="drives",
604
617
  icon="📤",
605
618
  summary="Eject all NAS volumes from /Volumes",
606
619
  script="drives/eject-all-drives.py",
607
620
  sound=7,
608
- dry_sound=7,
609
621
  dry_run="flag",
610
622
  destructive=True,
611
623
  needs=("diskutil",),
@@ -613,12 +625,12 @@ COMMANDS: tuple[Command, ...] = (
613
625
  tags=("nas", "smb", "eject", "unmount"),
614
626
  prompts=(Prompt("--only", "Only this drive", help="Case-insensitive name"),),
615
627
  ),
616
- _cmd(
628
+ Command(
617
629
  name="ping-nas",
618
630
  flags=(
619
- "--interval",
620
- "--ping-timeout",
621
- "--only",
631
+ "--interval=",
632
+ "--ping-timeout=",
633
+ "--only=",
622
634
  "--no-remount",
623
635
  "--use-ip",
624
636
  "--once",
@@ -632,15 +644,14 @@ COMMANDS: tuple[Command, ...] = (
632
644
  tags=("nas", "keepalive", "network"),
633
645
  ),
634
646
  # -- system -------------------------------------------------------------
635
- _cmd(
647
+ Command(
636
648
  name="update-brew",
637
- flags=("--dry-run", "--no-cask", "--no-cleanup"),
649
+ flags=("--dry-run", "--no-cask", "--no-cleanup", "--no-autoremove", "--greedy", "--doctor"),
638
650
  category="system",
639
651
  icon="🍺",
640
652
  summary="Update Homebrew, upgrade formulae and casks, then clean up",
641
653
  script="system/update-brew.py",
642
654
  sound=7,
643
- dry_sound=7,
644
655
  dry_run="flag",
645
656
  needs=("brew",),
646
657
  tags=("homebrew", "update", "packages"),
@@ -649,7 +660,7 @@ COMMANDS: tuple[Command, ...] = (
649
660
  Prompt("--no-cleanup", "Skip brew cleanup", kind="bool"),
650
661
  ),
651
662
  ),
652
- _cmd(
663
+ Command(
653
664
  name="btop",
654
665
  flags=(),
655
666
  category="system",
@@ -664,72 +675,74 @@ COMMANDS: tuple[Command, ...] = (
664
675
 
665
676
  # ---------------------------------------------------------------------------
666
677
  # 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.
678
+ # `che --help`, the completions and the menu alongside everything else. Their
679
+ # flags are checked against each one's parser, like a script's against its
680
+ # --help.
668
681
  # ---------------------------------------------------------------------------
669
682
 
670
683
  BUILTINS: tuple[Command, ...] = (
671
- _cmd(
684
+ Command(
672
685
  name="install",
673
686
  flags=(
674
- "--shells",
687
+ "--shells=",
675
688
  "--all-shells",
676
689
  "--yes",
677
690
  "--dry-run",
678
- "--no-completions",
679
691
  "--no-path-shim",
692
+ "--no-sounds",
680
693
  "--replace-legacy",
694
+ "--install-deps",
681
695
  "--print",
682
696
  ),
683
697
  category="che",
684
698
  icon="📥",
685
699
  summary="Install the shell wrappers into your shell startup files",
686
- builtin=True,
687
700
  tags=("setup", "wizard", "zshrc", "bashrc"),
688
701
  ),
689
- _cmd(
702
+ Command(
690
703
  name="update",
691
- flags=("--check", "--yes"),
704
+ flags=("--check", "--dry-run"),
692
705
  category="che",
693
706
  icon="⬆️",
694
707
  summary="Pull the latest scripts and refresh the installed wrappers",
695
- builtin=True,
696
708
  needs=("git",),
697
709
  tags=("upgrade", "self-update", "pull"),
698
710
  ),
699
- _cmd(
711
+ Command(
700
712
  name="doctor",
701
713
  flags=("--json", "--verbose"),
702
714
  category="che",
703
715
  icon="🩺",
704
716
  summary="Check the install, the interpreter and every external tool",
705
- builtin=True,
706
717
  tags=("diagnose", "health", "check"),
707
718
  ),
708
- _cmd(
719
+ Command(
709
720
  name="uninstall",
710
- flags=("--yes", "--dry-run", "--purge"),
721
+ flags=("--dry-run", "--purge"),
711
722
  category="che",
712
723
  icon="🧽",
713
724
  summary="Remove the wrappers, the shim and the completions",
714
- builtin=True,
725
+ destructive=True,
715
726
  tags=("remove", "clean"),
727
+ prompts=(
728
+ Prompt("--dry-run", "Preview only", kind="bool", default="true"),
729
+ Prompt("--purge", "Also delete the config and menu history", kind="bool"),
730
+ ),
716
731
  ),
717
- _cmd(
732
+ Command(
718
733
  name="list",
719
- flags=("--category", "--json", "--dry"),
734
+ flags=("--category=", "--json", "--no-dry"),
720
735
  category="che",
721
736
  icon="📋",
722
737
  summary="List every command, one per line",
723
- builtin=True,
724
738
  tags=("ls", "commands"),
725
739
  ),
726
- _cmd(
740
+ Command(
727
741
  name="completions",
728
- flags=("--shell"),
742
+ flags=("--shell=",),
729
743
  category="che",
730
744
  icon="⌨️",
731
745
  summary="Print the completion script for a shell",
732
- builtin=True,
733
746
  tags=("tab", "complete"),
734
747
  ),
735
748
  )
@@ -765,18 +778,57 @@ MENU_KEYS: tuple[tuple[str, str], ...] = (
765
778
 
766
779
  @dataclass(frozen=True)
767
780
  class Resolved:
768
- """A command name resolved to the command plus whether it was the ``-dr`` twin."""
781
+ """One name a user can call: a command, or its ``-dr`` preview twin."""
769
782
 
770
783
  command: Command
771
784
  dry: bool = False
772
- matched: str = field(default="")
773
785
 
786
+ @property
787
+ def name(self) -> str:
788
+ return self.command.dry_name if self.dry else self.command.name
774
789
 
775
- _BY_NAME: dict[str, Command] = {command.name: command for command in (*COMMANDS, *BUILTINS)}
790
+ @property
791
+ def sound(self) -> int | None:
792
+ return self.command.dry_sound if self.dry else self.command.sound
793
+
794
+ def summary(self, preview: str) -> str:
795
+ """The command's summary, with ``(preview)`` appended on the twin."""
796
+ return f"{self.command.summary} ({preview})" if self.dry else self.command.summary
797
+
798
+ @property
799
+ def env(self) -> dict[str, str]:
800
+ """Environment overrides under this name: ``DRY_RUN`` for a command
801
+ that previews through it, nothing for any other."""
802
+ if self.command.dry_run != "env":
803
+ return {}
804
+ return {"DRY_RUN": "true" if self.dry else "false"}
805
+
806
+ @property
807
+ def args(self) -> tuple[str, ...]:
808
+ """Fixed arguments under this name, before anything the user typed: the
809
+ command's own, plus ``--dry-run`` on the twin of a command that previews
810
+ through the flag."""
811
+ preview = ("--dry-run",) if self.dry and self.command.dry_run == "flag" else ()
812
+ return (*self.command.args, *preview)
813
+
814
+
815
+ def all_commands() -> tuple[Command, ...]:
816
+ return (*COMMANDS, *BUILTINS)
817
+
818
+
819
+ def variants(commands: Iterable[Command]) -> list[Resolved]:
820
+ """Every callable name ``commands`` define, each twin right after its command."""
821
+ return [variant for command in commands for variant in command.variants()]
822
+
823
+
824
+ def grouped(commands: Iterable[Command]) -> list[tuple[Category, list[Command]]]:
825
+ """``commands`` by category, in category order, leaving out empty categories."""
826
+ commands = tuple(commands)
827
+ groups = [(cat, [c for c in commands if c.category == cat.key]) for cat in CATEGORIES]
828
+ return [(category, members) for category, members in groups if members]
776
829
 
777
830
 
778
- def all_commands(*, include_builtins: bool = True) -> tuple[Command, ...]:
779
- return (*COMMANDS, *BUILTINS) if include_builtins else COMMANDS
831
+ _BY_NAME: dict[str, Resolved] = {variant.name: variant for variant in variants(all_commands())}
780
832
 
781
833
 
782
834
  def resolve(name: str) -> Resolved | None:
@@ -787,37 +839,17 @@ def resolve(name: str) -> Resolved | None:
787
839
  a "did you mean" message rather than an exception, because the name usually
788
840
  came from a typo at a shell prompt.
789
841
  """
790
- command = _BY_NAME.get(name)
791
- if command is not None:
792
- return Resolved(command, dry=False, matched=name)
842
+ return _BY_NAME.get(name)
793
843
 
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
844
 
799
- return None
800
-
801
-
802
- def wrapper_names() -> Iterator[str]:
845
+ def wrapper_names() -> list[str]:
803
846
  """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
-
847
+ return [variant.name for variant in variants(COMMANDS)]
809
848
 
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
849
 
818
-
819
- def in_category(key: str) -> tuple[Command, ...]:
820
- return tuple(command for command in all_commands() if command.category == key)
850
+ def command_names() -> list[str]:
851
+ """Every name ``che`` accepts, the built-ins and the ``-dr`` twins included."""
852
+ return list(_BY_NAME)
821
853
 
822
854
 
823
855
  def required_binaries() -> dict[str, tuple[str, ...]]:
@@ -827,46 +859,3 @@ def required_binaries() -> dict[str, tuple[str, ...]]:
827
859
  for binary in command.needs:
828
860
  index.setdefault(binary, []).append(command.name)
829
861
  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