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