@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.
- package/README.md +65 -25
- package/bin/art.py +123 -0
- package/bin/che.py +209 -568
- package/bin/commands.py +220 -231
- package/bin/doctor.py +220 -0
- package/bin/drives/eject-all-drives.py +21 -38
- package/bin/drives/mount-all-drives.py +18 -62
- package/bin/drives/ping-nas.py +77 -105
- package/bin/files/compress-folders.py +7 -11
- package/bin/files/delete-by-ext.py +10 -14
- package/bin/files/delete-empty-folders.py +33 -37
- package/bin/files/delete-smb-files.py +12 -21
- package/bin/files/files-under-size.py +5 -11
- package/bin/files/find-largest-files.py +21 -25
- package/bin/files/list-permission.py +40 -0
- package/bin/files/make-alpha-dir.py +2 -0
- package/bin/git/all-actions.py +350 -253
- package/bin/git/checkout-my-branches.py +42 -63
- package/bin/git/clean-stale-branches.py +23 -48
- package/bin/git/prune-worktrees.py +12 -51
- package/bin/git/sync-all-branches.py +54 -283
- package/bin/git/update-local-branches.py +67 -73
- package/bin/gitutils.py +263 -0
- package/bin/install.py +393 -1188
- package/bin/machine.py +345 -0
- package/bin/media.py +127 -0
- package/bin/menu.py +437 -0
- package/bin/nas.py +102 -0
- package/bin/rcfile.py +380 -0
- package/bin/shellgen.py +508 -428
- package/bin/system/btop-launch.py +17 -13
- package/bin/system/update-brew.py +57 -68
- package/bin/tui.py +156 -153
- package/bin/utils.py +165 -237
- package/bin/video/delete-duplicate-videos.py +64 -62
- package/bin/video/detect-green-magenta-videos.py +105 -95
- package/bin/video/find-movie-by-year.py +3 -0
- package/bin/video/find-video-mkv-issues.py +48 -65
- package/bin/video/fix-codecs.py +25 -30
- package/bin/video/largest-tv-shows.py +60 -57
- package/bin/video/remove-metadata.py +3 -6
- package/bin/video/rename-video-file.py +74 -84
- package/bin/video/scan-videos-audio-language.py +16 -33
- package/bin/video/show-codecs.py +20 -24
- package/bin/video/validate-video-files.py +5 -8
- package/bin/video/video-list.py +17 -20
- package/package.json +4 -10
- package/shell/che.bash +33 -33
- package/shell/che.fish +7 -7
- package/shell/che.zsh +33 -33
- package/shell/completions/_che +19 -19
- package/shell/completions/che.bash +15 -15
- 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`` (
|
|
7
|
+
``shell/che.fish`` (rendered by ``bin/shellgen.py``),
|
|
8
8
|
* the tab completions under ``shell/completions/``,
|
|
9
|
-
* the interactive menu in ``bin/
|
|
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
|
|
28
|
-
from dataclasses import dataclass
|
|
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
|
|
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", "
|
|
62
|
-
Category("video", "Video", "
|
|
63
|
-
Category("files", "Files", "
|
|
64
|
-
Category("drives", "Drives", "
|
|
65
|
-
Category("system", "System", "
|
|
66
|
-
Category("che", "che", "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
190
|
+
Command(
|
|
181
191
|
name="all-actions",
|
|
182
|
-
flags=(
|
|
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
|
|
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"
|
|
209
|
+
Prompt("--pr-limit", "PRs per repo"),
|
|
210
|
+
Prompt("--no-main", "Skip the default-branch table", kind="bool"),
|
|
192
211
|
),
|
|
193
212
|
),
|
|
194
|
-
|
|
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
|
-
|
|
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"
|
|
236
|
+
Prompt("--limit", "Remote branches to scan"),
|
|
218
237
|
),
|
|
219
238
|
),
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
266
|
+
Command(
|
|
248
267
|
name="sync-all-branches",
|
|
249
|
-
flags=("--dry-run", "--
|
|
268
|
+
flags=("--dry-run", "--author=", "--limit="),
|
|
250
269
|
category="git",
|
|
251
270
|
icon="🔄",
|
|
252
|
-
summary="
|
|
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=("
|
|
277
|
+
tags=("branch", "sync", "pull", "prune"),
|
|
260
278
|
),
|
|
261
|
-
|
|
279
|
+
Command(
|
|
262
280
|
name="update-from-origin",
|
|
263
|
-
flags=("--dry-run", "--
|
|
281
|
+
flags=("--dry-run", "--author=", "--limit="),
|
|
264
282
|
category="git",
|
|
265
283
|
icon="⬇️",
|
|
266
|
-
summary="
|
|
284
|
+
summary="Alias of sync-all-branches",
|
|
267
285
|
script="git/sync-all-branches.py",
|
|
268
|
-
|
|
269
|
-
|
|
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"
|
|
292
|
+
tags=("pull", "fetch", "sync"),
|
|
273
293
|
),
|
|
274
|
-
|
|
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"
|
|
305
|
+
Prompt("--limit", "Only the N most recent branches"),
|
|
286
306
|
DRY_PROMPT,
|
|
287
307
|
),
|
|
288
308
|
),
|
|
289
309
|
# -- video --------------------------------------------------------------
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
495
|
+
prompts=(PATH_PROMPT, Prompt("--limit", "How many to list", default="20")),
|
|
479
496
|
),
|
|
480
497
|
# -- files --------------------------------------------------------------
|
|
481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
548
|
+
prompts=(PATH_PROMPT, Prompt("--size", "Size threshold", required=True)),
|
|
532
549
|
),
|
|
533
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
702
|
+
Command(
|
|
690
703
|
name="update",
|
|
691
|
-
flags=("--check", "--
|
|
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
|
-
|
|
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
|
-
|
|
719
|
+
Command(
|
|
709
720
|
name="uninstall",
|
|
710
|
-
flags=("--
|
|
721
|
+
flags=("--dry-run", "--purge"),
|
|
711
722
|
category="che",
|
|
712
723
|
icon="🧽",
|
|
713
724
|
summary="Remove the wrappers, the shim and the completions",
|
|
714
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
820
|
-
return
|
|
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
|