@codebend3r/che 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +424 -0
- package/bin/che.py +869 -0
- package/bin/commands.py +872 -0
- package/bin/drives/eject-all-drives.py +203 -0
- package/bin/drives/mount-all-drives.py +140 -0
- package/bin/drives/ping-nas.py +242 -0
- package/bin/files/compress-folders.py +188 -0
- package/bin/files/delete-by-ext.py +108 -0
- package/bin/files/delete-empty-folders.py +136 -0
- package/bin/files/delete-smb-files.py +105 -0
- package/bin/files/files-under-size.py +132 -0
- package/bin/files/find-largest-files.py +102 -0
- package/bin/files/make-alpha-dir.py +63 -0
- package/bin/git/all-actions.py +545 -0
- package/bin/git/checkout-my-branches.py +147 -0
- package/bin/git/clean-stale-branches.py +161 -0
- package/bin/git/prune-worktrees.py +143 -0
- package/bin/git/sync-all-branches.py +368 -0
- package/bin/git/update-local-branches.py +195 -0
- package/bin/install.py +1515 -0
- package/bin/shellgen.py +782 -0
- package/bin/system/btop-launch.py +114 -0
- package/bin/system/update-brew.py +146 -0
- package/bin/tui.py +648 -0
- package/bin/utils.py +836 -0
- package/bin/video/delete-duplicate-videos.py +313 -0
- package/bin/video/detect-green-magenta-videos.py +268 -0
- package/bin/video/find-movie-by-year.py +84 -0
- package/bin/video/find-video-mkv-issues.py +242 -0
- package/bin/video/fix-codecs.py +209 -0
- package/bin/video/largest-tv-shows.py +175 -0
- package/bin/video/remove-metadata.py +126 -0
- package/bin/video/rename-video-file.py +329 -0
- package/bin/video/scan-videos-audio-language.py +110 -0
- package/bin/video/show-codecs.py +111 -0
- package/bin/video/validate-video-files.py +114 -0
- package/bin/video/video-list.py +120 -0
- package/package.json +54 -0
- package/shell/che.bash +382 -0
- package/shell/che.fish +282 -0
- package/shell/che.zsh +389 -0
- package/shell/completions/_che +146 -0
- package/shell/completions/che.bash +107 -0
- package/shell/completions/che.fish +371 -0
package/bin/commands.py
ADDED
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
"""Canonical registry of every command ``che`` exposes.
|
|
2
|
+
|
|
3
|
+
This module is the single source of truth for the user-facing command surface.
|
|
4
|
+
Four things are generated from it, so they can never drift apart:
|
|
5
|
+
|
|
6
|
+
* the shell wrappers in ``shell/che.zsh``, ``shell/che.bash`` and
|
|
7
|
+
``shell/che.fish`` (written by ``bin/install.py``),
|
|
8
|
+
* the tab completions under ``shell/completions/``,
|
|
9
|
+
* the interactive menu in ``bin/che.py``,
|
|
10
|
+
* the dependency report printed by ``che doctor``.
|
|
11
|
+
|
|
12
|
+
Before this file existed, ``.zshrc`` was the source of truth and every new
|
|
13
|
+
script meant hand-editing a wrapper into it - and hand-mirroring that edit into
|
|
14
|
+
``~/.zshrc``. Adding a script now means adding one :class:`Command` here and
|
|
15
|
+
running ``che install``.
|
|
16
|
+
|
|
17
|
+
Library module: no shebang, not executable, imported the same way as
|
|
18
|
+
``utils.py``::
|
|
19
|
+
|
|
20
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
21
|
+
from commands import COMMANDS, resolve
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
from collections.abc import Iterator
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
BIN = Path(__file__).resolve().parent
|
|
32
|
+
REPO = BIN.parent
|
|
33
|
+
|
|
34
|
+
# Version of the `che` app itself - the dispatcher, the installer and the
|
|
35
|
+
# generated shell files together. It is read from package.json so that one
|
|
36
|
+
# `npm version` bump moves the npm release, `che --version` and the header of
|
|
37
|
+
# 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.
|
|
39
|
+
_PACKAGE = json.loads((REPO / "package.json").read_text(encoding="utf-8"))
|
|
40
|
+
PACKAGE_NAME: str = _PACKAGE["name"]
|
|
41
|
+
VERSION: str = _PACKAGE["version"]
|
|
42
|
+
|
|
43
|
+
# Suffix appended to a command name to get its preview-only twin.
|
|
44
|
+
DRY_SUFFIX = "-dr"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Categories
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Category:
|
|
54
|
+
key: str
|
|
55
|
+
title: str
|
|
56
|
+
icon: str
|
|
57
|
+
blurb: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
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"),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
CATEGORY_KEYS = tuple(category.key for category in CATEGORIES)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Prompts
|
|
74
|
+
#
|
|
75
|
+
# The interactive menu asks for these before running a command. They are
|
|
76
|
+
# curated rather than derived from `--help`, because "the two flags a human
|
|
77
|
+
# actually sets" is a smaller and better-ordered list than "every flag the
|
|
78
|
+
# script accepts" - anything else can still be typed at the extra-args line.
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Prompt:
|
|
84
|
+
"""One question the interactive menu asks before running a command."""
|
|
85
|
+
|
|
86
|
+
flag: str # "--path"; empty string means a positional argument
|
|
87
|
+
label: str
|
|
88
|
+
kind: str = "text" # text | path | size | int | bool | choice
|
|
89
|
+
required: bool = False
|
|
90
|
+
default: str = ""
|
|
91
|
+
choices: tuple[str, ...] = ()
|
|
92
|
+
help: str = ""
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def key(self) -> str:
|
|
96
|
+
"""Stable identity for per-prompt input history."""
|
|
97
|
+
return self.flag.removeprefix("--") or "args"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
PATH_PROMPT = Prompt("--path", "Path to scan", kind="path", required=True)
|
|
101
|
+
PATH_PROMPT_CWD = Prompt("--path", "Path to scan", kind="path", default=".")
|
|
102
|
+
DRY_PROMPT = Prompt("--dry-run", "Dry run (preview only)", kind="bool", default="true")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Commands
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class Command:
|
|
112
|
+
"""One user-facing command: a name, a script and how to invoke it."""
|
|
113
|
+
|
|
114
|
+
name: str
|
|
115
|
+
category: str
|
|
116
|
+
summary: str
|
|
117
|
+
script: str = "" # relative to bin/; empty for a shell-only wrapper
|
|
118
|
+
icon: str = "•"
|
|
119
|
+
sound: int | None = None
|
|
120
|
+
dry_sound: int | None = 7
|
|
121
|
+
env: tuple[tuple[str, str], ...] = ()
|
|
122
|
+
args: tuple[str, ...] = () # fixed arguments, always passed first
|
|
123
|
+
flags: tuple[str, ...] = () # every flag the script accepts, for completions
|
|
124
|
+
prompts: tuple[Prompt, ...] = ()
|
|
125
|
+
needs: tuple[str, ...] = () # external binaries the script shells out to
|
|
126
|
+
dry_run: str = "" # "" | "env" (DRY_RUN=) | "flag" (--dry-run)
|
|
127
|
+
destructive: bool = False
|
|
128
|
+
# Set only where a destructive command genuinely cannot preview, with the
|
|
129
|
+
# reason. The menu shows it, and the manifest test demands one rather than
|
|
130
|
+
# letting a missing dry-run pass unnoticed.
|
|
131
|
+
no_preview_reason: str = ""
|
|
132
|
+
# One-off setup a command needs that `needs` cannot express, because it is
|
|
133
|
+
# not a binary on PATH (a venv, a credential, a mounted volume).
|
|
134
|
+
setup: str = ""
|
|
135
|
+
macos_only: bool = False
|
|
136
|
+
body: str = "" # shell-only wrapper body, for commands with no script
|
|
137
|
+
tags: tuple[str, ...] = ()
|
|
138
|
+
builtin: bool = False # handled by che.py itself, not a script
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def has_dry_twin(self) -> bool:
|
|
142
|
+
"""Whether a ``-dr`` wrapper is generated alongside this command."""
|
|
143
|
+
return bool(self.dry_run)
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def dry_name(self) -> str:
|
|
147
|
+
return f"{self.name}{DRY_SUFFIX}"
|
|
148
|
+
|
|
149
|
+
@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")
|
|
169
|
+
|
|
170
|
+
return env, args
|
|
171
|
+
|
|
172
|
+
|
|
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]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
COMMANDS: tuple[Command, ...] = (
|
|
179
|
+
# -- git ----------------------------------------------------------------
|
|
180
|
+
_cmd(
|
|
181
|
+
name="all-actions",
|
|
182
|
+
flags=("--owner", "--author", "--pr-limit", "--interval", "--watch"),
|
|
183
|
+
category="git",
|
|
184
|
+
icon="🎬",
|
|
185
|
+
summary="GitHub Actions status for every open PR you authored",
|
|
186
|
+
script="git/all-actions.py",
|
|
187
|
+
needs=("gh", "git"),
|
|
188
|
+
tags=("ci", "pr", "github", "workflow"),
|
|
189
|
+
prompts=(
|
|
190
|
+
Prompt("--owner", "Only this owner", help="Defaults to every repo you own"),
|
|
191
|
+
Prompt("--pr-limit", "PRs per repo", kind="int"),
|
|
192
|
+
),
|
|
193
|
+
),
|
|
194
|
+
_cmd(
|
|
195
|
+
name="all-actions-watch",
|
|
196
|
+
flags=("--owner", "--author", "--pr-limit", "--interval"),
|
|
197
|
+
category="git",
|
|
198
|
+
icon="👀",
|
|
199
|
+
summary="Same as all-actions, refreshed on an interval",
|
|
200
|
+
script="git/all-actions.py",
|
|
201
|
+
args=("--watch",),
|
|
202
|
+
needs=("gh", "git"),
|
|
203
|
+
tags=("ci", "pr", "github", "watch"),
|
|
204
|
+
),
|
|
205
|
+
_cmd(
|
|
206
|
+
name="checkout-my-branches",
|
|
207
|
+
flags=("--author", "--limit"),
|
|
208
|
+
category="git",
|
|
209
|
+
icon="🌿",
|
|
210
|
+
summary="Check out recent remote branches you authored that aren't local yet",
|
|
211
|
+
script="git/checkout-my-branches.py",
|
|
212
|
+
sound=7,
|
|
213
|
+
needs=("git",),
|
|
214
|
+
tags=("branch", "remote"),
|
|
215
|
+
prompts=(
|
|
216
|
+
Prompt("--author", "Author email", help="Defaults to git config user.email"),
|
|
217
|
+
Prompt("--limit", "Remote branches to scan", kind="int"),
|
|
218
|
+
),
|
|
219
|
+
),
|
|
220
|
+
_cmd(
|
|
221
|
+
name="clean-stale-branches",
|
|
222
|
+
flags=("--dry-run", "--protect"),
|
|
223
|
+
category="git",
|
|
224
|
+
icon="🧹",
|
|
225
|
+
summary="Delete local branches whose upstream is gone",
|
|
226
|
+
script="git/clean-stale-branches.py",
|
|
227
|
+
sound=4,
|
|
228
|
+
dry_run="env",
|
|
229
|
+
destructive=True,
|
|
230
|
+
needs=("git",),
|
|
231
|
+
tags=("branch", "delete", "prune"),
|
|
232
|
+
prompts=(Prompt("--protect", "Extra branches to protect", help="Comma separated"),),
|
|
233
|
+
),
|
|
234
|
+
_cmd(
|
|
235
|
+
name="prune-worktrees",
|
|
236
|
+
flags=("--dry-run", "--force"),
|
|
237
|
+
category="git",
|
|
238
|
+
icon="🌳",
|
|
239
|
+
summary="Remove every linked worktree, then prune stale admin records",
|
|
240
|
+
script="git/prune-worktrees.py",
|
|
241
|
+
sound=4,
|
|
242
|
+
dry_run="env",
|
|
243
|
+
destructive=True,
|
|
244
|
+
needs=("git",),
|
|
245
|
+
tags=("worktree", "prune", "delete"),
|
|
246
|
+
),
|
|
247
|
+
_cmd(
|
|
248
|
+
name="sync-all-branches",
|
|
249
|
+
flags=("--dry-run", "--no-push", "--keep-locked", "--author", "--limit"),
|
|
250
|
+
category="git",
|
|
251
|
+
icon="🔄",
|
|
252
|
+
summary="Push every worktree, collapse the pushed ones, then tidy branches",
|
|
253
|
+
script="git/sync-all-branches.py",
|
|
254
|
+
sound=7,
|
|
255
|
+
dry_sound=7,
|
|
256
|
+
dry_run="env",
|
|
257
|
+
destructive=True,
|
|
258
|
+
needs=("git",),
|
|
259
|
+
tags=("push", "worktree", "branch", "sync"),
|
|
260
|
+
),
|
|
261
|
+
_cmd(
|
|
262
|
+
name="update-from-origin",
|
|
263
|
+
flags=("--dry-run", "--keep-locked", "--author", "--limit"),
|
|
264
|
+
category="git",
|
|
265
|
+
icon="⬇️",
|
|
266
|
+
summary="Same as sync-all-branches but never pushes: fetch, collapse, rebase",
|
|
267
|
+
script="git/sync-all-branches.py",
|
|
268
|
+
args=("--no-push",),
|
|
269
|
+
env=(("DRY_RUN", "false"),),
|
|
270
|
+
sound=7,
|
|
271
|
+
needs=("git",),
|
|
272
|
+
tags=("pull", "fetch", "sync", "worktree"),
|
|
273
|
+
),
|
|
274
|
+
_cmd(
|
|
275
|
+
name="update-local-branches",
|
|
276
|
+
flags=("--limit", "--dry-run"),
|
|
277
|
+
category="git",
|
|
278
|
+
icon="⬆️",
|
|
279
|
+
summary="Rebase every local branch that has an upstream onto origin",
|
|
280
|
+
script="git/update-local-branches.py",
|
|
281
|
+
sound=5,
|
|
282
|
+
needs=("git",),
|
|
283
|
+
tags=("rebase", "branch"),
|
|
284
|
+
prompts=(
|
|
285
|
+
Prompt("--limit", "Only the N most recent branches", kind="int"),
|
|
286
|
+
DRY_PROMPT,
|
|
287
|
+
),
|
|
288
|
+
),
|
|
289
|
+
# -- video --------------------------------------------------------------
|
|
290
|
+
_cmd(
|
|
291
|
+
name="show-codecs",
|
|
292
|
+
flags=("--path", "--verbose"),
|
|
293
|
+
category="video",
|
|
294
|
+
icon="🎥",
|
|
295
|
+
summary="Report media files outside the Direct Play codec/container set",
|
|
296
|
+
script="video/show-codecs.py",
|
|
297
|
+
sound=6,
|
|
298
|
+
needs=("ffprobe",),
|
|
299
|
+
tags=("plex", "codec", "audit"),
|
|
300
|
+
prompts=(PATH_PROMPT, Prompt("--verbose", "Also print files that pass", kind="bool")),
|
|
301
|
+
),
|
|
302
|
+
_cmd(
|
|
303
|
+
name="fix-codecs",
|
|
304
|
+
flags=("--path", "--delete-original", "--dry-run"),
|
|
305
|
+
category="video",
|
|
306
|
+
icon="🔧",
|
|
307
|
+
summary="Re-encode media to h265/aac mp4 so it Direct Plays",
|
|
308
|
+
script="video/fix-codecs.py",
|
|
309
|
+
sound=7,
|
|
310
|
+
dry_sound=7,
|
|
311
|
+
dry_run="env",
|
|
312
|
+
destructive=True,
|
|
313
|
+
needs=("ffmpeg",),
|
|
314
|
+
tags=("plex", "encode", "transcode"),
|
|
315
|
+
prompts=(
|
|
316
|
+
PATH_PROMPT,
|
|
317
|
+
Prompt("--delete-original", "Delete each source after converting", kind="bool"),
|
|
318
|
+
),
|
|
319
|
+
),
|
|
320
|
+
_cmd(
|
|
321
|
+
name="find-video-mkv-issues",
|
|
322
|
+
flags=("--path", "--recursive"),
|
|
323
|
+
category="video",
|
|
324
|
+
icon="🎬",
|
|
325
|
+
summary="Scan MKV files and estimate Plex direct-play compatibility",
|
|
326
|
+
script="video/find-video-mkv-issues.py",
|
|
327
|
+
sound=4,
|
|
328
|
+
needs=("ffprobe",),
|
|
329
|
+
tags=("plex", "mkv", "audit"),
|
|
330
|
+
prompts=(PATH_PROMPT, Prompt("--recursive", "Recurse into subfolders", kind="bool")),
|
|
331
|
+
),
|
|
332
|
+
_cmd(
|
|
333
|
+
name="validate-video-files",
|
|
334
|
+
flags=("--path", "--verbose"),
|
|
335
|
+
category="video",
|
|
336
|
+
icon="🩺",
|
|
337
|
+
summary="Check .mp4/.mkv files decode by playing one frame with mpv",
|
|
338
|
+
script="video/validate-video-files.py",
|
|
339
|
+
sound=2,
|
|
340
|
+
needs=("mpv",),
|
|
341
|
+
tags=("corrupt", "health", "audit"),
|
|
342
|
+
prompts=(PATH_PROMPT, Prompt("--verbose", "Print each file as it is checked", kind="bool")),
|
|
343
|
+
),
|
|
344
|
+
_cmd(
|
|
345
|
+
name="scan-videos-audio-language",
|
|
346
|
+
flags=("--path"),
|
|
347
|
+
category="video",
|
|
348
|
+
icon="🔊",
|
|
349
|
+
summary="Print the audio language tags of every video under a path",
|
|
350
|
+
script="video/scan-videos-audio-language.py",
|
|
351
|
+
sound=3,
|
|
352
|
+
needs=("ffprobe",),
|
|
353
|
+
tags=("audio", "language", "dub"),
|
|
354
|
+
prompts=(PATH_PROMPT,),
|
|
355
|
+
),
|
|
356
|
+
_cmd(
|
|
357
|
+
name="remove-metadata",
|
|
358
|
+
flags=("--path", "--exts"),
|
|
359
|
+
category="video",
|
|
360
|
+
icon="🧼",
|
|
361
|
+
summary="Strip all metadata from video files recursively",
|
|
362
|
+
script="video/remove-metadata.py",
|
|
363
|
+
sound=4,
|
|
364
|
+
destructive=True,
|
|
365
|
+
no_preview_reason="exiftool rewrites in place; the script has no preview mode",
|
|
366
|
+
needs=("exiftool",),
|
|
367
|
+
tags=("metadata", "tags", "plex"),
|
|
368
|
+
prompts=(PATH_PROMPT, Prompt("--exts", "Extensions", help="Comma separated")),
|
|
369
|
+
),
|
|
370
|
+
_cmd(
|
|
371
|
+
name="rename-video-file",
|
|
372
|
+
flags=(
|
|
373
|
+
"--path",
|
|
374
|
+
"--recursive",
|
|
375
|
+
"--rename-folders",
|
|
376
|
+
"--capitalize-preps",
|
|
377
|
+
"--dry-run",
|
|
378
|
+
"--ignore-words",
|
|
379
|
+
),
|
|
380
|
+
category="video",
|
|
381
|
+
icon="📝",
|
|
382
|
+
summary="Title-case video filenames (and optionally their folders)",
|
|
383
|
+
script="video/rename-video-file.py",
|
|
384
|
+
sound=5,
|
|
385
|
+
# No -dr twin: this script already defaults to --dry-run=true on its
|
|
386
|
+
# own (it never read DRY_RUN, so an exported DRY_RUN=false in the
|
|
387
|
+
# user's shell could not turn a preview into a mass rename). The
|
|
388
|
+
# preview twin would be identical to the plain wrapper.
|
|
389
|
+
destructive=True,
|
|
390
|
+
tags=("rename", "title", "case"),
|
|
391
|
+
prompts=(
|
|
392
|
+
PATH_PROMPT,
|
|
393
|
+
Prompt("--recursive", "Recurse into subdirectories", kind="bool", default="true"),
|
|
394
|
+
Prompt("--rename-folders", "Rename folders too", kind="bool"),
|
|
395
|
+
Prompt("--dry-run", "Preview only", kind="bool", default="true"),
|
|
396
|
+
Prompt("--ignore-words", "Words to leave as written", help="Comma separated"),
|
|
397
|
+
),
|
|
398
|
+
),
|
|
399
|
+
_cmd(
|
|
400
|
+
name="delete-duplicate-videos",
|
|
401
|
+
flags=("--path", "--strategy", "--dry-run", "--verbose"),
|
|
402
|
+
category="video",
|
|
403
|
+
icon="🗑️",
|
|
404
|
+
summary="Delete duplicate MKV/MP4 files under a root directory",
|
|
405
|
+
script="video/delete-duplicate-videos.py",
|
|
406
|
+
sound=6,
|
|
407
|
+
dry_sound=6,
|
|
408
|
+
dry_run="env",
|
|
409
|
+
destructive=True,
|
|
410
|
+
tags=("duplicate", "delete", "dedupe"),
|
|
411
|
+
prompts=(
|
|
412
|
+
PATH_PROMPT,
|
|
413
|
+
Prompt(
|
|
414
|
+
"--strategy",
|
|
415
|
+
"Match strategy",
|
|
416
|
+
kind="choice",
|
|
417
|
+
choices=("episode", "filename", "size", "hash", "all"),
|
|
418
|
+
default="episode",
|
|
419
|
+
),
|
|
420
|
+
),
|
|
421
|
+
),
|
|
422
|
+
_cmd(
|
|
423
|
+
name="video-list",
|
|
424
|
+
flags=("--path", "--recursive", "--with-folder", "--sort"),
|
|
425
|
+
category="video",
|
|
426
|
+
icon="📋",
|
|
427
|
+
summary="List .mp4/.mkv files under a path with human-readable sizes",
|
|
428
|
+
script="video/video-list.py",
|
|
429
|
+
sound=5,
|
|
430
|
+
tags=("list", "size"),
|
|
431
|
+
prompts=(
|
|
432
|
+
PATH_PROMPT,
|
|
433
|
+
Prompt("--recursive", "Recurse into subdirectories", kind="bool"),
|
|
434
|
+
Prompt(
|
|
435
|
+
"--sort",
|
|
436
|
+
"Sort order",
|
|
437
|
+
kind="choice",
|
|
438
|
+
choices=("alpha", "fileSizeAsc", "fileSizeDesc"),
|
|
439
|
+
default="alpha",
|
|
440
|
+
),
|
|
441
|
+
),
|
|
442
|
+
),
|
|
443
|
+
_cmd(
|
|
444
|
+
name="detect-green-magenta-videos",
|
|
445
|
+
flags=("--samples", "--threshold", "--verbose"),
|
|
446
|
+
category="video",
|
|
447
|
+
icon="🟢",
|
|
448
|
+
summary="Detect videos with the green/magenta chroma artifact",
|
|
449
|
+
script="video/detect-green-magenta-videos.py",
|
|
450
|
+
sound=3,
|
|
451
|
+
# The only command here that is not standard-library-only: it re-execs
|
|
452
|
+
# itself under a venv holding OpenCV. Not a binary on PATH, so `needs`
|
|
453
|
+
# cannot express it and doctor must not claim to have checked it.
|
|
454
|
+
setup="needs OpenCV in ~/.venvs/green-magenta (override with $DETECT_GM_PYTHON)",
|
|
455
|
+
tags=("corrupt", "artifact", "opencv"),
|
|
456
|
+
prompts=(Prompt("", "Files or folders to scan", kind="path", required=True),),
|
|
457
|
+
),
|
|
458
|
+
_cmd(
|
|
459
|
+
name="find-movie-by-year",
|
|
460
|
+
flags=("--year", "--path"),
|
|
461
|
+
category="video",
|
|
462
|
+
icon="🎞️",
|
|
463
|
+
summary='Find movie folders whose name ends with "(YYYY)"',
|
|
464
|
+
script="video/find-movie-by-year.py",
|
|
465
|
+
sound=2,
|
|
466
|
+
tags=("movie", "year", "search"),
|
|
467
|
+
prompts=(Prompt("--year", "Year", required=True), PATH_PROMPT_CWD),
|
|
468
|
+
),
|
|
469
|
+
_cmd(
|
|
470
|
+
name="largest-tv-shows",
|
|
471
|
+
flags=("--path", "--limit", "--full-path", "--debug"),
|
|
472
|
+
category="video",
|
|
473
|
+
icon="📺",
|
|
474
|
+
summary="Rank TV show folders in a library by total size on disk",
|
|
475
|
+
script="video/largest-tv-shows.py",
|
|
476
|
+
sound=2,
|
|
477
|
+
tags=("size", "tv", "report"),
|
|
478
|
+
prompts=(PATH_PROMPT, Prompt("--limit", "How many to list", kind="int", default="20")),
|
|
479
|
+
),
|
|
480
|
+
# -- files --------------------------------------------------------------
|
|
481
|
+
_cmd(
|
|
482
|
+
name="delete-by-ext",
|
|
483
|
+
flags=("--path", "--ext", "--dry-run", "--verbose"),
|
|
484
|
+
category="files",
|
|
485
|
+
icon="🗑️",
|
|
486
|
+
summary="Delete files under a path matching a set of extensions",
|
|
487
|
+
script="files/delete-by-ext.py",
|
|
488
|
+
sound=6,
|
|
489
|
+
dry_run="env",
|
|
490
|
+
destructive=True,
|
|
491
|
+
tags=("delete", "extension", "cleanup"),
|
|
492
|
+
prompts=(PATH_PROMPT, Prompt("--ext", "Extensions", help="Comma separated")),
|
|
493
|
+
),
|
|
494
|
+
_cmd(
|
|
495
|
+
name="delete-empty-folders",
|
|
496
|
+
flags=("--path", "--dry-run", "--verbose"),
|
|
497
|
+
category="files",
|
|
498
|
+
icon="🧹",
|
|
499
|
+
summary="Delete truly-empty directories under a path, cascading upwards",
|
|
500
|
+
script="files/delete-empty-folders.py",
|
|
501
|
+
sound=6,
|
|
502
|
+
dry_run="env",
|
|
503
|
+
destructive=True,
|
|
504
|
+
tags=("delete", "empty", "cleanup"),
|
|
505
|
+
prompts=(PATH_PROMPT,),
|
|
506
|
+
),
|
|
507
|
+
_cmd(
|
|
508
|
+
name="delete-smb-files",
|
|
509
|
+
flags=("--path", "--dry-run"),
|
|
510
|
+
category="files",
|
|
511
|
+
icon="🧽",
|
|
512
|
+
summary="Delete .smbdelete* files left behind by an SMB share",
|
|
513
|
+
script="files/delete-smb-files.py",
|
|
514
|
+
sound=6,
|
|
515
|
+
dry_run="env",
|
|
516
|
+
destructive=True,
|
|
517
|
+
tags=("delete", "smb", "nas", "cleanup"),
|
|
518
|
+
prompts=(PATH_PROMPT,),
|
|
519
|
+
),
|
|
520
|
+
_cmd(
|
|
521
|
+
name="files-under-size",
|
|
522
|
+
flags=("--path", "--size", "--dry-run"),
|
|
523
|
+
category="files",
|
|
524
|
+
icon="📉",
|
|
525
|
+
summary="Find video files at or under a size threshold",
|
|
526
|
+
script="files/files-under-size.py",
|
|
527
|
+
sound=4,
|
|
528
|
+
dry_run="env",
|
|
529
|
+
destructive=True,
|
|
530
|
+
tags=("size", "delete", "small"),
|
|
531
|
+
prompts=(PATH_PROMPT, Prompt("--size", "Size threshold", kind="size", required=True)),
|
|
532
|
+
),
|
|
533
|
+
_cmd(
|
|
534
|
+
name="find-largest-files",
|
|
535
|
+
flags=("--path", "--length", "--full-path"),
|
|
536
|
+
category="files",
|
|
537
|
+
icon="📊",
|
|
538
|
+
summary="List the largest files under a path, biggest first",
|
|
539
|
+
script="files/find-largest-files.py",
|
|
540
|
+
sound=2,
|
|
541
|
+
tags=("size", "report", "disk"),
|
|
542
|
+
prompts=(
|
|
543
|
+
PATH_PROMPT_CWD,
|
|
544
|
+
Prompt("--length", "How many to list", kind="int", default="10"),
|
|
545
|
+
Prompt("--full-path", "Print the whole path", kind="bool"),
|
|
546
|
+
),
|
|
547
|
+
),
|
|
548
|
+
_cmd(
|
|
549
|
+
name="make-alpha-dir",
|
|
550
|
+
flags=("--path"),
|
|
551
|
+
category="files",
|
|
552
|
+
icon="🔤",
|
|
553
|
+
summary="Create '#' and A-Z bucket folders under a parent directory",
|
|
554
|
+
script="files/make-alpha-dir.py",
|
|
555
|
+
sound=4,
|
|
556
|
+
tags=("organize", "folders"),
|
|
557
|
+
prompts=(PATH_PROMPT_CWD,),
|
|
558
|
+
),
|
|
559
|
+
_cmd(
|
|
560
|
+
name="compress-folders",
|
|
561
|
+
flags=("--path", "--dry-run", "--verbose"),
|
|
562
|
+
category="files",
|
|
563
|
+
icon="📦",
|
|
564
|
+
summary="Zip every immediate subfolder of a path at max compression",
|
|
565
|
+
script="files/compress-folders.py",
|
|
566
|
+
sound=7,
|
|
567
|
+
tags=("zip", "archive", "compress"),
|
|
568
|
+
prompts=(PATH_PROMPT, DRY_PROMPT),
|
|
569
|
+
),
|
|
570
|
+
_cmd(
|
|
571
|
+
name="list-permission",
|
|
572
|
+
flags=(),
|
|
573
|
+
category="files",
|
|
574
|
+
icon="🔐",
|
|
575
|
+
summary="Show ownership and mode of a volume under /Volumes",
|
|
576
|
+
sound=7,
|
|
577
|
+
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
|
+
prompts=(Prompt("", "Volume name", required=True),),
|
|
584
|
+
macos_only=True,
|
|
585
|
+
),
|
|
586
|
+
# -- drives -------------------------------------------------------------
|
|
587
|
+
_cmd(
|
|
588
|
+
name="mount-all-drives",
|
|
589
|
+
flags=("--only", "--use-ip", "--quiet"),
|
|
590
|
+
category="drives",
|
|
591
|
+
icon="💿",
|
|
592
|
+
summary="Mount all NAS drives over SMB via AppleScript",
|
|
593
|
+
script="drives/mount-all-drives.py",
|
|
594
|
+
sound=7,
|
|
595
|
+
needs=("osascript",),
|
|
596
|
+
macos_only=True,
|
|
597
|
+
tags=("nas", "smb", "mount"),
|
|
598
|
+
prompts=(Prompt("--only", "Only this drive", help="Case-insensitive name"),),
|
|
599
|
+
),
|
|
600
|
+
_cmd(
|
|
601
|
+
name="eject-all-drives",
|
|
602
|
+
flags=("--dry-run", "--only", "--no-force", "--no-clear-favorites", "--quiet"),
|
|
603
|
+
category="drives",
|
|
604
|
+
icon="📤",
|
|
605
|
+
summary="Eject all NAS volumes from /Volumes",
|
|
606
|
+
script="drives/eject-all-drives.py",
|
|
607
|
+
sound=7,
|
|
608
|
+
dry_sound=7,
|
|
609
|
+
dry_run="flag",
|
|
610
|
+
destructive=True,
|
|
611
|
+
needs=("diskutil",),
|
|
612
|
+
macos_only=True,
|
|
613
|
+
tags=("nas", "smb", "eject", "unmount"),
|
|
614
|
+
prompts=(Prompt("--only", "Only this drive", help="Case-insensitive name"),),
|
|
615
|
+
),
|
|
616
|
+
_cmd(
|
|
617
|
+
name="ping-nas",
|
|
618
|
+
flags=(
|
|
619
|
+
"--interval",
|
|
620
|
+
"--ping-timeout",
|
|
621
|
+
"--only",
|
|
622
|
+
"--no-remount",
|
|
623
|
+
"--use-ip",
|
|
624
|
+
"--once",
|
|
625
|
+
"--quiet",
|
|
626
|
+
),
|
|
627
|
+
category="drives",
|
|
628
|
+
icon="📡",
|
|
629
|
+
summary="Keep-alive pinger that remounts a NAS drive that dropped off",
|
|
630
|
+
script="drives/ping-nas.py",
|
|
631
|
+
macos_only=True,
|
|
632
|
+
tags=("nas", "keepalive", "network"),
|
|
633
|
+
),
|
|
634
|
+
# -- system -------------------------------------------------------------
|
|
635
|
+
_cmd(
|
|
636
|
+
name="update-brew",
|
|
637
|
+
flags=("--dry-run", "--no-cask", "--no-cleanup"),
|
|
638
|
+
category="system",
|
|
639
|
+
icon="🍺",
|
|
640
|
+
summary="Update Homebrew, upgrade formulae and casks, then clean up",
|
|
641
|
+
script="system/update-brew.py",
|
|
642
|
+
sound=7,
|
|
643
|
+
dry_sound=7,
|
|
644
|
+
dry_run="flag",
|
|
645
|
+
needs=("brew",),
|
|
646
|
+
tags=("homebrew", "update", "packages"),
|
|
647
|
+
prompts=(
|
|
648
|
+
Prompt("--no-cask", "Skip casks", kind="bool"),
|
|
649
|
+
Prompt("--no-cleanup", "Skip brew cleanup", kind="bool"),
|
|
650
|
+
),
|
|
651
|
+
),
|
|
652
|
+
_cmd(
|
|
653
|
+
name="btop",
|
|
654
|
+
flags=(),
|
|
655
|
+
category="system",
|
|
656
|
+
icon="📈",
|
|
657
|
+
summary="Launch btop with a gruvbox theme matching the macOS appearance",
|
|
658
|
+
script="system/btop-launch.py",
|
|
659
|
+
needs=("btop",),
|
|
660
|
+
tags=("monitor", "process", "cpu"),
|
|
661
|
+
),
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
# ---------------------------------------------------------------------------
|
|
666
|
+
# 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.
|
|
668
|
+
# ---------------------------------------------------------------------------
|
|
669
|
+
|
|
670
|
+
BUILTINS: tuple[Command, ...] = (
|
|
671
|
+
_cmd(
|
|
672
|
+
name="install",
|
|
673
|
+
flags=(
|
|
674
|
+
"--shells",
|
|
675
|
+
"--all-shells",
|
|
676
|
+
"--yes",
|
|
677
|
+
"--dry-run",
|
|
678
|
+
"--no-completions",
|
|
679
|
+
"--no-path-shim",
|
|
680
|
+
"--replace-legacy",
|
|
681
|
+
"--print",
|
|
682
|
+
),
|
|
683
|
+
category="che",
|
|
684
|
+
icon="📥",
|
|
685
|
+
summary="Install the shell wrappers into your shell startup files",
|
|
686
|
+
builtin=True,
|
|
687
|
+
tags=("setup", "wizard", "zshrc", "bashrc"),
|
|
688
|
+
),
|
|
689
|
+
_cmd(
|
|
690
|
+
name="update",
|
|
691
|
+
flags=("--check", "--yes"),
|
|
692
|
+
category="che",
|
|
693
|
+
icon="⬆️",
|
|
694
|
+
summary="Pull the latest scripts and refresh the installed wrappers",
|
|
695
|
+
builtin=True,
|
|
696
|
+
needs=("git",),
|
|
697
|
+
tags=("upgrade", "self-update", "pull"),
|
|
698
|
+
),
|
|
699
|
+
_cmd(
|
|
700
|
+
name="doctor",
|
|
701
|
+
flags=("--json", "--verbose"),
|
|
702
|
+
category="che",
|
|
703
|
+
icon="🩺",
|
|
704
|
+
summary="Check the install, the interpreter and every external tool",
|
|
705
|
+
builtin=True,
|
|
706
|
+
tags=("diagnose", "health", "check"),
|
|
707
|
+
),
|
|
708
|
+
_cmd(
|
|
709
|
+
name="uninstall",
|
|
710
|
+
flags=("--yes", "--dry-run", "--purge"),
|
|
711
|
+
category="che",
|
|
712
|
+
icon="🧽",
|
|
713
|
+
summary="Remove the wrappers, the shim and the completions",
|
|
714
|
+
builtin=True,
|
|
715
|
+
tags=("remove", "clean"),
|
|
716
|
+
),
|
|
717
|
+
_cmd(
|
|
718
|
+
name="list",
|
|
719
|
+
flags=("--category", "--json", "--dry"),
|
|
720
|
+
category="che",
|
|
721
|
+
icon="📋",
|
|
722
|
+
summary="List every command, one per line",
|
|
723
|
+
builtin=True,
|
|
724
|
+
tags=("ls", "commands"),
|
|
725
|
+
),
|
|
726
|
+
_cmd(
|
|
727
|
+
name="completions",
|
|
728
|
+
flags=("--shell"),
|
|
729
|
+
category="che",
|
|
730
|
+
icon="⌨️",
|
|
731
|
+
summary="Print the completion script for a shell",
|
|
732
|
+
builtin=True,
|
|
733
|
+
tags=("tab", "complete"),
|
|
734
|
+
),
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
BUILTIN_NAMES = frozenset(command.name for command in BUILTINS)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
# ---------------------------------------------------------------------------
|
|
741
|
+
# Menu keys
|
|
742
|
+
#
|
|
743
|
+
# Lives here rather than in che.py so the README can be generated from it: a
|
|
744
|
+
# keybinding documented in two hand-written places is a keybinding that will
|
|
745
|
+
# eventually be wrong in one of them.
|
|
746
|
+
# ---------------------------------------------------------------------------
|
|
747
|
+
|
|
748
|
+
MENU_KEYS: tuple[tuple[str, str], ...] = (
|
|
749
|
+
("↑ ↓ / j k", "move"),
|
|
750
|
+
("PgUp PgDn / g G", "jump"),
|
|
751
|
+
("⏎", "run (asks for arguments)"),
|
|
752
|
+
("x", "run with no arguments"),
|
|
753
|
+
("d", "toggle dry-run for this command"),
|
|
754
|
+
("h", "show the command's own --help"),
|
|
755
|
+
("/", "search, Esc to clear"),
|
|
756
|
+
("?", "this list"),
|
|
757
|
+
("q", "quit"),
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
# ---------------------------------------------------------------------------
|
|
762
|
+
# Lookups
|
|
763
|
+
# ---------------------------------------------------------------------------
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
@dataclass(frozen=True)
|
|
767
|
+
class Resolved:
|
|
768
|
+
"""A command name resolved to the command plus whether it was the ``-dr`` twin."""
|
|
769
|
+
|
|
770
|
+
command: Command
|
|
771
|
+
dry: bool = False
|
|
772
|
+
matched: str = field(default="")
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
_BY_NAME: dict[str, Command] = {command.name: command for command in (*COMMANDS, *BUILTINS)}
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def all_commands(*, include_builtins: bool = True) -> tuple[Command, ...]:
|
|
779
|
+
return (*COMMANDS, *BUILTINS) if include_builtins else COMMANDS
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def resolve(name: str) -> Resolved | None:
|
|
783
|
+
"""Look up ``name``, honouring the ``-dr`` preview suffix.
|
|
784
|
+
|
|
785
|
+
``resolve("delete-by-ext-dr")`` returns the ``delete-by-ext`` command with
|
|
786
|
+
``dry=True``. Returns ``None`` for anything unknown; callers turn that into
|
|
787
|
+
a "did you mean" message rather than an exception, because the name usually
|
|
788
|
+
came from a typo at a shell prompt.
|
|
789
|
+
"""
|
|
790
|
+
command = _BY_NAME.get(name)
|
|
791
|
+
if command is not None:
|
|
792
|
+
return Resolved(command, dry=False, matched=name)
|
|
793
|
+
|
|
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
|
+
|
|
799
|
+
return None
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def wrapper_names() -> Iterator[str]:
|
|
803
|
+
"""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
|
+
|
|
809
|
+
|
|
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
|
+
|
|
818
|
+
|
|
819
|
+
def in_category(key: str) -> tuple[Command, ...]:
|
|
820
|
+
return tuple(command for command in all_commands() if command.category == key)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def required_binaries() -> dict[str, tuple[str, ...]]:
|
|
824
|
+
"""Map each external binary to the commands that need it, sorted by name."""
|
|
825
|
+
index: dict[str, list[str]] = {}
|
|
826
|
+
for command in all_commands():
|
|
827
|
+
for binary in command.needs:
|
|
828
|
+
index.setdefault(binary, []).append(command.name)
|
|
829
|
+
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
|