@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/che.py
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
Three ways in, all landing on the same command table in ``bin/commands.py``:
|
|
5
5
|
|
|
6
6
|
* ``che`` on its own opens a full-screen menu: type to filter, arrows to move,
|
|
7
|
-
Enter to run. Commands that can preview default to preview.
|
|
7
|
+
Enter to run. Commands that can preview default to preview. What the menu
|
|
8
|
+
shows and how keys move it is ``bin/menu.py``; running what it picks is here.
|
|
8
9
|
* ``che <command> [args…]`` runs one directly, with the same environment the
|
|
9
10
|
shell wrapper would have used, and ``exec``s so signals and exit codes pass
|
|
10
11
|
straight through.
|
|
11
12
|
* ``che install|update|doctor|uninstall|list|completions`` manage the install
|
|
12
|
-
itself - see ``bin/install.py``.
|
|
13
|
+
itself - see ``bin/install.py`` and ``bin/doctor.py``.
|
|
13
14
|
|
|
14
15
|
The shell wrappers (``delete-by-ext``, ``sync-all-branches``, …) are generated
|
|
15
16
|
from the same table, so anything reachable here is reachable by name too.
|
|
@@ -23,43 +24,36 @@ import shlex
|
|
|
23
24
|
import shutil
|
|
24
25
|
import subprocess
|
|
25
26
|
import sys
|
|
26
|
-
import
|
|
27
|
-
from
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from contextlib import suppress
|
|
29
|
+
from difflib import get_close_matches
|
|
28
30
|
from functools import lru_cache
|
|
29
31
|
from pathlib import Path
|
|
30
32
|
|
|
31
33
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
32
34
|
|
|
33
35
|
import art
|
|
34
|
-
import
|
|
36
|
+
import doctor
|
|
37
|
+
import install
|
|
38
|
+
import machine
|
|
39
|
+
import menu
|
|
35
40
|
import shellgen
|
|
36
41
|
import tui
|
|
37
42
|
from commands import (
|
|
38
|
-
BUILTIN_NAMES,
|
|
39
43
|
CATEGORIES,
|
|
40
|
-
MENU_KEYS,
|
|
41
|
-
REPO,
|
|
42
44
|
VERSION,
|
|
43
45
|
Command,
|
|
44
46
|
Resolved,
|
|
45
47
|
all_commands,
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
command_names,
|
|
49
|
+
grouped,
|
|
48
50
|
resolve,
|
|
49
|
-
|
|
51
|
+
variants,
|
|
50
52
|
)
|
|
51
|
-
from utils import note, run_cli, success, warning
|
|
53
|
+
from utils import add_bool_flag, build_parser, note, parse_bool, run, run_cli, success, warning
|
|
52
54
|
|
|
53
55
|
__version__ = VERSION
|
|
54
56
|
|
|
55
|
-
BIN = REPO / "bin"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
@lru_cache(maxsize=64)
|
|
59
|
-
def _which(binary: str) -> str | None:
|
|
60
|
-
"""PATH lookup, cached: the menu asks about the same binaries every frame."""
|
|
61
|
-
return shutil.which(binary)
|
|
62
|
-
|
|
63
57
|
|
|
64
58
|
@lru_cache(maxsize=1)
|
|
65
59
|
def _interpreter() -> str:
|
|
@@ -71,7 +65,7 @@ def _interpreter() -> str:
|
|
|
71
65
|
Command Line Tools build. The dispatcher itself tolerates that; the scripts
|
|
72
66
|
do not, so hand them the newest suitable interpreter instead.
|
|
73
67
|
"""
|
|
74
|
-
return
|
|
68
|
+
return machine.find_python()[0]
|
|
75
69
|
|
|
76
70
|
|
|
77
71
|
# ---------------------------------------------------------------------------
|
|
@@ -79,14 +73,23 @@ def _interpreter() -> str:
|
|
|
79
73
|
# ---------------------------------------------------------------------------
|
|
80
74
|
|
|
81
75
|
|
|
82
|
-
def
|
|
83
|
-
|
|
84
|
-
|
|
76
|
+
def _argv_env(resolved: Resolved, extra: list[str]) -> tuple[list[str], dict[str, str]]:
|
|
77
|
+
"""The argv and environment overrides that run ``resolved`` as its wrapper would.
|
|
78
|
+
|
|
79
|
+
A built-in runs as ``che <name>`` in a child, so the menu reaches it the
|
|
80
|
+
same way it reaches a script: prompts, the destructive confirm, ``--help``.
|
|
81
|
+
"""
|
|
82
|
+
command = resolved.command
|
|
83
|
+
if command.builtin:
|
|
84
|
+
return [_interpreter(), str(Path(__file__).resolve()), command.name, *extra], {}
|
|
85
|
+
return [_interpreter(), str(command.path), *resolved.args, *extra], resolved.env
|
|
85
86
|
|
|
86
87
|
|
|
87
|
-
def
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
def _missing_script(command: Command) -> bool:
|
|
89
|
+
if command.script and not command.path.exists():
|
|
90
|
+
warning(f"❌ Missing script: {command.path}")
|
|
91
|
+
return True
|
|
92
|
+
return False
|
|
90
93
|
|
|
91
94
|
|
|
92
95
|
def exec_command(resolved: Resolved, extra: list[str]) -> int:
|
|
@@ -97,61 +100,42 @@ def exec_command(resolved: Resolved, extra: list[str]) -> int:
|
|
|
97
100
|
for the long-running ones (``ping-nas``) and the full-screen ones
|
|
98
101
|
(``btop``).
|
|
99
102
|
"""
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if command.body:
|
|
103
|
-
# A shell-only wrapper (`list-permission`). Nothing to exec but sh.
|
|
104
|
-
return subprocess.run( # noqa: S603 - fixed body from the manifest, args via $@
|
|
105
|
-
["sh", "-c", command.body + ' "$@"', command.name, *extra], # noqa: S607
|
|
106
|
-
check=False,
|
|
107
|
-
).returncode
|
|
108
|
-
|
|
109
|
-
script = BIN / command.script
|
|
110
|
-
if not script.exists():
|
|
111
|
-
warning(f"❌ Missing script: {script}")
|
|
103
|
+
if _missing_script(resolved.command):
|
|
112
104
|
return 127
|
|
113
|
-
|
|
114
|
-
argv
|
|
115
|
-
|
|
116
|
-
return 127 # unreachable: execve only returns by raising
|
|
105
|
+
argv, env = _argv_env(resolved, extra)
|
|
106
|
+
os.execvpe(argv[0], argv, {**os.environ, **env}) # noqa: S606
|
|
107
|
+
return 127 # unreachable: execvpe only returns by raising
|
|
117
108
|
|
|
118
109
|
|
|
119
110
|
def run_command(resolved: Resolved, extra: list[str]) -> int:
|
|
120
|
-
"""Run a command as a child, for the menu (which needs to come back).
|
|
121
|
-
command = resolved.command
|
|
122
|
-
|
|
123
|
-
if command.body:
|
|
124
|
-
return subprocess.run( # noqa: S603 - fixed body from the manifest, args via $@
|
|
125
|
-
["sh", "-c", command.body + ' "$@"', command.name, *extra], # noqa: S607
|
|
126
|
-
check=False,
|
|
127
|
-
).returncode
|
|
111
|
+
"""Run a command as a child, for the menu (which needs to come back).
|
|
128
112
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
)
|
|
113
|
+
Ctrl-C reaches the child through the terminal, and is the only way to stop
|
|
114
|
+
``ping-nas`` or ``all-actions --watch``. It must not take the menu down
|
|
115
|
+
too, so the menu ignores it and waits for the child to decide what it means.
|
|
116
|
+
"""
|
|
117
|
+
if _missing_script(resolved.command):
|
|
118
|
+
return 127
|
|
119
|
+
argv, env = _argv_env(resolved, extra)
|
|
120
|
+
with subprocess.Popen(argv, env={**os.environ, **env}) as child: # noqa: S603
|
|
121
|
+
while child.returncode is None:
|
|
122
|
+
with suppress(KeyboardInterrupt):
|
|
123
|
+
child.wait()
|
|
124
|
+
# A child killed by signal N reports -N; a shell would say 128+N.
|
|
125
|
+
return 128 - child.returncode if child.returncode < 0 else child.returncode
|
|
134
126
|
|
|
135
127
|
|
|
136
128
|
def script_help(command: Command) -> str:
|
|
137
|
-
|
|
138
|
-
return command.summary
|
|
129
|
+
argv, env = _argv_env(Resolved(command), ["--help"])
|
|
139
130
|
try:
|
|
140
|
-
result =
|
|
141
|
-
[_interpreter(), str(BIN / command.script), "--help"],
|
|
142
|
-
capture_output=True,
|
|
143
|
-
text=True,
|
|
144
|
-
timeout=20,
|
|
145
|
-
env={**os.environ, "NO_COLOR": "1"},
|
|
146
|
-
check=False,
|
|
147
|
-
)
|
|
131
|
+
result = run(argv, capture=True, check=False, env={**env, "NO_COLOR": "1"}, timeout=20)
|
|
148
132
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
149
133
|
return f"(could not read help: {exc})"
|
|
150
134
|
return (result.stdout or result.stderr).strip()
|
|
151
135
|
|
|
152
136
|
|
|
153
137
|
# ---------------------------------------------------------------------------
|
|
154
|
-
# Help
|
|
138
|
+
# Help and listing
|
|
155
139
|
# ---------------------------------------------------------------------------
|
|
156
140
|
|
|
157
141
|
|
|
@@ -165,10 +149,7 @@ def usage() -> None:
|
|
|
165
149
|
print(" che help <command> show that command's own --help")
|
|
166
150
|
print()
|
|
167
151
|
|
|
168
|
-
for category in
|
|
169
|
-
members = in_category(category.key)
|
|
170
|
-
if not members:
|
|
171
|
-
continue
|
|
152
|
+
for category, members in grouped(all_commands()):
|
|
172
153
|
print(
|
|
173
154
|
f"{colors.bold}{category.title.upper()}{colors.reset} {colors.grey}{category.blurb}{colors.reset}"
|
|
174
155
|
)
|
|
@@ -185,11 +166,25 @@ def usage() -> None:
|
|
|
185
166
|
print(" -v, --version show the version")
|
|
186
167
|
print()
|
|
187
168
|
print(
|
|
188
|
-
f"{colors.grey}Every command
|
|
169
|
+
f"{colors.grey}Every command outside CHE is also a shell function of the same name."
|
|
170
|
+
f"{colors.reset}"
|
|
189
171
|
)
|
|
190
172
|
|
|
191
173
|
|
|
192
174
|
def list_commands(category: str = "", *, as_json: bool = False, include_dry: bool = True) -> int:
|
|
175
|
+
known = [item.key for item in CATEGORIES]
|
|
176
|
+
if category and category not in known:
|
|
177
|
+
warning(f"❌ Unknown category: {category}")
|
|
178
|
+
note(f" Known: {', '.join(known)}")
|
|
179
|
+
return 1
|
|
180
|
+
|
|
181
|
+
chosen = [
|
|
182
|
+
command
|
|
183
|
+
for group, members in grouped(all_commands())
|
|
184
|
+
if not category or group.key == category
|
|
185
|
+
for command in members
|
|
186
|
+
]
|
|
187
|
+
|
|
193
188
|
if as_json:
|
|
194
189
|
payload = [
|
|
195
190
|
{
|
|
@@ -198,511 +193,155 @@ def list_commands(category: str = "", *, as_json: bool = False, include_dry: boo
|
|
|
198
193
|
"summary": command.summary,
|
|
199
194
|
"script": command.script,
|
|
200
195
|
"needs": list(command.needs),
|
|
201
|
-
"flags": list(command.
|
|
196
|
+
"flags": list(command.flag_names),
|
|
202
197
|
"destructive": command.destructive,
|
|
203
198
|
"dry_run": command.dry_run,
|
|
204
199
|
}
|
|
205
|
-
for command in
|
|
206
|
-
if not category or command.category == category
|
|
200
|
+
for command in chosen
|
|
207
201
|
]
|
|
208
202
|
print(json.dumps(payload, indent=2))
|
|
209
203
|
return 0
|
|
210
204
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
return 1
|
|
215
|
-
|
|
216
|
-
for command in all_commands():
|
|
217
|
-
if category and command.category != category:
|
|
218
|
-
continue
|
|
219
|
-
print(command.name)
|
|
220
|
-
if include_dry and command.has_dry_twin:
|
|
221
|
-
print(command.dry_name)
|
|
205
|
+
for variant in variants(chosen):
|
|
206
|
+
if include_dry or not variant.dry:
|
|
207
|
+
print(variant.name)
|
|
222
208
|
return 0
|
|
223
209
|
|
|
224
210
|
|
|
211
|
+
def list_main(argv: list[str] | None = None) -> int:
|
|
212
|
+
parser = build_parser(prog="che list", description="📋 List every command.")
|
|
213
|
+
parser.add_argument("--category", default="", metavar="NAME", help="Only this category")
|
|
214
|
+
add_bool_flag(parser, "--json", help="Machine-readable output")
|
|
215
|
+
add_bool_flag(parser, "--no-dry", allow_value=False, help="Hide the -dr twins")
|
|
216
|
+
args = parser.parse_args(argv)
|
|
217
|
+
return list_commands(args.category, as_json=args.json, include_dry=not args.no_dry)
|
|
218
|
+
|
|
219
|
+
|
|
225
220
|
# ---------------------------------------------------------------------------
|
|
226
221
|
# The interactive menu
|
|
227
222
|
# ---------------------------------------------------------------------------
|
|
228
223
|
|
|
229
224
|
|
|
230
|
-
|
|
231
|
-
class Row:
|
|
232
|
-
"""One line in the menu list: either a category header or a command."""
|
|
233
|
-
|
|
234
|
-
kind: str # "header" | "item"
|
|
235
|
-
text: str
|
|
236
|
-
command: Command | None = None
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
@dataclass
|
|
240
|
-
class Menu:
|
|
241
|
-
query: str = ""
|
|
242
|
-
searching: bool = False
|
|
243
|
-
cursor: int = 0
|
|
244
|
-
offset: int = 0
|
|
245
|
-
dry: dict[str, bool] = field(default_factory=dict)
|
|
246
|
-
show_help: bool = False
|
|
247
|
-
status: str = ""
|
|
248
|
-
|
|
249
|
-
def matches(self) -> list[Command]:
|
|
250
|
-
scored = []
|
|
251
|
-
for command in all_commands():
|
|
252
|
-
score = fuzzy_match(self.query, command)
|
|
253
|
-
if score is None:
|
|
254
|
-
continue
|
|
255
|
-
scored.append((score, command))
|
|
256
|
-
scored.sort(key=lambda pair: (pair[0], pair[1].category, pair[1].name))
|
|
257
|
-
return [command for _, command in scored]
|
|
258
|
-
|
|
259
|
-
def rows(self) -> list[Row]:
|
|
260
|
-
commands = self.matches()
|
|
261
|
-
rows: list[Row] = []
|
|
262
|
-
if self.query:
|
|
263
|
-
rows.extend(Row("item", command.name, command) for command in commands)
|
|
264
|
-
return rows
|
|
265
|
-
|
|
266
|
-
for category in CATEGORIES:
|
|
267
|
-
members = [command for command in commands if command.category == category.key]
|
|
268
|
-
if not members:
|
|
269
|
-
continue
|
|
270
|
-
rows.append(Row("header", f"{category.title} {category.blurb}"))
|
|
271
|
-
rows.extend(Row("item", command.name, command) for command in members)
|
|
272
|
-
return rows
|
|
273
|
-
|
|
274
|
-
def items(self) -> list[int]:
|
|
275
|
-
return [index for index, row in enumerate(self.rows()) if row.kind == "item"]
|
|
276
|
-
|
|
277
|
-
def selected(self) -> Command | None:
|
|
278
|
-
rows = self.rows()
|
|
279
|
-
if not rows:
|
|
280
|
-
return None
|
|
281
|
-
self.cursor = max(0, min(self.cursor, len(rows) - 1))
|
|
282
|
-
row = rows[self.cursor]
|
|
283
|
-
return row.command
|
|
284
|
-
|
|
285
|
-
def move(self, delta: int) -> None:
|
|
286
|
-
rows = self.rows()
|
|
287
|
-
items = [index for index, row in enumerate(rows) if row.kind == "item"]
|
|
288
|
-
if not items:
|
|
289
|
-
return
|
|
290
|
-
if self.cursor not in items:
|
|
291
|
-
self.cursor = items[0]
|
|
292
|
-
return
|
|
293
|
-
position = items.index(self.cursor)
|
|
294
|
-
self.cursor = items[(position + delta) % len(items)]
|
|
295
|
-
|
|
296
|
-
def first(self) -> None:
|
|
297
|
-
items = self.items()
|
|
298
|
-
self.cursor = items[0] if items else 0
|
|
299
|
-
|
|
300
|
-
def last(self) -> None:
|
|
301
|
-
items = self.items()
|
|
302
|
-
self.cursor = items[-1] if items else 0
|
|
303
|
-
|
|
304
|
-
def is_dry(self, command: Command) -> bool:
|
|
305
|
-
# Anything that can preview previews first: the menu is a browsing UI,
|
|
306
|
-
# and "I pressed Enter to see what it does" should never delete files.
|
|
307
|
-
return self.dry.get(command.name, command.has_dry_twin)
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
HELP_OVERLAY = MENU_KEYS
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
def menu() -> int:
|
|
225
|
+
def run_menu() -> int:
|
|
314
226
|
"""The full-screen browser. Returns the exit status of the last command."""
|
|
315
227
|
if not tui.is_interactive():
|
|
316
228
|
warning("❌ The menu needs a terminal. Try `che <command>` or `che --help`.")
|
|
317
229
|
return 1
|
|
318
230
|
|
|
319
|
-
state = Menu()
|
|
320
|
-
state.first()
|
|
231
|
+
state = menu.Menu()
|
|
321
232
|
last_status = 0
|
|
322
233
|
screen = tui.Screen()
|
|
323
234
|
|
|
324
235
|
with tui.Term() as term:
|
|
325
236
|
while True:
|
|
326
237
|
screen.start()
|
|
327
|
-
draw(screen, state)
|
|
238
|
+
menu.draw(screen, state)
|
|
328
239
|
screen.flush()
|
|
329
240
|
|
|
330
241
|
key = tui.read_key(timeout=0.5)
|
|
331
242
|
if key is None:
|
|
332
243
|
continue # timeout: loop so a resize repaints
|
|
333
244
|
|
|
334
|
-
|
|
335
|
-
state.show_help = False
|
|
336
|
-
continue
|
|
337
|
-
|
|
338
|
-
action = handle_key(state, key)
|
|
339
|
-
|
|
245
|
+
action = menu.handle_key(state, key)
|
|
340
246
|
if action == "quit":
|
|
341
247
|
break
|
|
342
|
-
if action
|
|
343
|
-
command = state.selected()
|
|
344
|
-
if command is None:
|
|
345
|
-
continue
|
|
346
|
-
term.suspend()
|
|
347
|
-
last_status = launch(command, state, prompt=(action == "run"))
|
|
348
|
-
term.resume()
|
|
349
|
-
elif action == "help":
|
|
350
|
-
command = state.selected()
|
|
351
|
-
if command is None:
|
|
352
|
-
continue
|
|
353
|
-
term.suspend()
|
|
354
|
-
show_help(command)
|
|
355
|
-
term.resume()
|
|
356
|
-
|
|
357
|
-
return last_status
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
def handle_key(state: Menu, key: str) -> str:
|
|
361
|
-
"""Fold one keypress into the menu state. Returns an action for the caller."""
|
|
362
|
-
if state.searching:
|
|
363
|
-
if key == "enter":
|
|
364
|
-
state.searching = False
|
|
365
|
-
elif key == "esc":
|
|
366
|
-
state.searching = False
|
|
367
|
-
state.query = ""
|
|
368
|
-
state.first()
|
|
369
|
-
elif key == "backspace":
|
|
370
|
-
state.query = state.query[:-1]
|
|
371
|
-
state.first()
|
|
372
|
-
elif key == "ctrl-u":
|
|
373
|
-
state.query = ""
|
|
374
|
-
state.first()
|
|
375
|
-
elif key in ("up", "down"):
|
|
376
|
-
state.move(-1 if key == "up" else 1)
|
|
377
|
-
elif key == "ctrl-c":
|
|
378
|
-
return "quit"
|
|
379
|
-
elif len(key) == 1 and key.isprintable():
|
|
380
|
-
state.query += key
|
|
381
|
-
state.first()
|
|
382
|
-
return ""
|
|
383
|
-
|
|
384
|
-
if key in ("q", "ctrl-c", "eof"):
|
|
385
|
-
return "quit"
|
|
386
|
-
if key == "esc":
|
|
387
|
-
if state.query:
|
|
388
|
-
state.query = ""
|
|
389
|
-
state.first()
|
|
390
|
-
return ""
|
|
391
|
-
return "quit"
|
|
392
|
-
if key in ("up", "k"):
|
|
393
|
-
state.move(-1)
|
|
394
|
-
elif key in ("down", "j"):
|
|
395
|
-
state.move(1)
|
|
396
|
-
elif key == "pgup":
|
|
397
|
-
for _ in range(10):
|
|
398
|
-
state.move(-1)
|
|
399
|
-
elif key == "pgdn":
|
|
400
|
-
for _ in range(10):
|
|
401
|
-
state.move(1)
|
|
402
|
-
elif key in ("home", "g"):
|
|
403
|
-
state.first()
|
|
404
|
-
elif key in ("end", "G"):
|
|
405
|
-
state.last()
|
|
406
|
-
elif key == "/":
|
|
407
|
-
state.searching = True
|
|
408
|
-
elif key == "?":
|
|
409
|
-
state.show_help = True
|
|
410
|
-
elif key == "d":
|
|
411
|
-
command = state.selected()
|
|
412
|
-
if command and command.has_dry_twin:
|
|
413
|
-
state.dry[command.name] = not state.is_dry(command)
|
|
414
|
-
state.status = f"dry-run {'on' if state.is_dry(command) else 'OFF'} for {command.name}"
|
|
415
|
-
elif command:
|
|
416
|
-
state.status = f"{command.name} has no preview mode"
|
|
417
|
-
elif key == "h":
|
|
418
|
-
return "help"
|
|
419
|
-
elif key == "x":
|
|
420
|
-
return "run-raw"
|
|
421
|
-
elif key == "enter":
|
|
422
|
-
return "run"
|
|
423
|
-
elif len(key) == 1 and key.isprintable():
|
|
424
|
-
# Typing anything else drops into search, the way a file picker does.
|
|
425
|
-
state.searching = True
|
|
426
|
-
state.query += key
|
|
427
|
-
state.first()
|
|
428
|
-
return ""
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
# The face heads the menu only while the list under it keeps at least this
|
|
432
|
-
# many rows. Below that the one-line header takes over, as it always did.
|
|
433
|
-
MIN_LIST_ROWS = 8
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
def banner_fits(screen: tui.Screen, right: list[str]) -> bool:
|
|
437
|
-
"""Whether the face and wordmark can head the menu and still leave a list."""
|
|
438
|
-
spare = screen.rows - (art.HEIGHT + 1) - 4
|
|
439
|
-
return spare >= MIN_LIST_ROWS and art.fits(right, screen.columns)
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
def draw(screen: tui.Screen, state: Menu) -> None:
|
|
443
|
-
colors = tui.palette()
|
|
444
|
-
width = screen.columns
|
|
445
|
-
|
|
446
|
-
total = len(all_commands())
|
|
447
|
-
shown = len(state.matches())
|
|
448
|
-
counter = f"{shown}/{total}" if state.query else str(total)
|
|
449
|
-
if state.searching or state.query:
|
|
450
|
-
caret = f"{colors.cyan}▏{colors.reset}" if state.searching else " "
|
|
451
|
-
prompt = f" {colors.grey}search{colors.reset} {caret}{state.query}"
|
|
452
|
-
else:
|
|
453
|
-
prompt = f" {colors.grey}type to search ↑↓ move ⏎ run ? keys{colors.reset}"
|
|
454
|
-
|
|
455
|
-
right = art.wordmark(colors)
|
|
456
|
-
right += ["", f" {colors.grey}v{VERSION} {counter} commands{colors.reset}", prompt]
|
|
457
|
-
if banner_fits(screen, right) and not state.show_help:
|
|
458
|
-
# The face beside the wordmark, with the version, the count and the
|
|
459
|
-
# search line tucked under the letters.
|
|
460
|
-
screen.lines(art.banner(right, columns=width, colors=colors))
|
|
461
|
-
else:
|
|
462
|
-
title = f"{colors.bright_magenta}{colors.bold}che{colors.reset}"
|
|
463
|
-
screen.line()
|
|
464
|
-
screen.line(
|
|
465
|
-
f" {title} {colors.grey}v{VERSION}{colors.reset} "
|
|
466
|
-
f"{colors.grey}{counter} commands{colors.reset}"
|
|
467
|
-
)
|
|
468
|
-
screen.line(prompt)
|
|
469
|
-
screen.line(f" {colors.grey}{'─' * max(0, width - 4)}{colors.reset}")
|
|
470
|
-
header_rows = screen.used
|
|
471
|
-
|
|
472
|
-
if state.show_help:
|
|
473
|
-
draw_help(screen)
|
|
474
|
-
return
|
|
475
|
-
|
|
476
|
-
body_rows = max(3, screen.rows - header_rows - 4)
|
|
477
|
-
rows = state.rows()
|
|
478
|
-
if not rows:
|
|
479
|
-
screen.line()
|
|
480
|
-
screen.line(f" {colors.yellow}No command matches {state.query!r}{colors.reset}")
|
|
481
|
-
draw_footer(screen, state)
|
|
482
|
-
return
|
|
483
|
-
|
|
484
|
-
# Keep the cursor inside the viewport, with a line of context either side.
|
|
485
|
-
if state.cursor < state.offset + 1:
|
|
486
|
-
state.offset = max(0, state.cursor - 1)
|
|
487
|
-
if state.cursor >= state.offset + body_rows - 1:
|
|
488
|
-
state.offset = min(max(0, len(rows) - body_rows), state.cursor - body_rows + 2)
|
|
489
|
-
|
|
490
|
-
detail_width = 0
|
|
491
|
-
if width >= 92:
|
|
492
|
-
detail_width = max(34, width // 2 - 6)
|
|
493
|
-
list_width = width - detail_width - (4 if detail_width else 2)
|
|
494
|
-
|
|
495
|
-
detail_lines = detail_pane(state, detail_width) if detail_width else []
|
|
496
|
-
|
|
497
|
-
for index in range(state.offset, min(len(rows), state.offset + body_rows)):
|
|
498
|
-
row = rows[index]
|
|
499
|
-
if row.kind == "header":
|
|
500
|
-
left = tui.fit(f" {colors.bold}{colors.blue}{row.text}{colors.reset}", list_width)
|
|
501
|
-
else:
|
|
502
|
-
command = row.command
|
|
503
|
-
if command is None:
|
|
248
|
+
if not action or (target := state.resolved()) is None:
|
|
504
249
|
continue
|
|
505
|
-
selected = index == state.cursor
|
|
506
|
-
pointer = f"{colors.cyan}▸{colors.reset}" if selected else " "
|
|
507
|
-
name = command.name
|
|
508
|
-
if selected:
|
|
509
|
-
name = f"{colors.bold}{colors.cyan}{name}{colors.reset}"
|
|
510
|
-
elif command.destructive:
|
|
511
|
-
name = f"{colors.yellow}{name}{colors.reset}"
|
|
512
|
-
|
|
513
|
-
badge = ""
|
|
514
|
-
if command.has_dry_twin and state.is_dry(command):
|
|
515
|
-
badge = f" {colors.grey}·dry{colors.reset}"
|
|
516
|
-
elif command.destructive:
|
|
517
|
-
badge = f" {colors.red}·live{colors.reset}"
|
|
518
|
-
|
|
519
|
-
left = f" {pointer} {command.icon} {name}{badge}"
|
|
520
|
-
if detail_width:
|
|
521
|
-
left += " " * max(0, list_width - tui.display_width(left))
|
|
522
|
-
else:
|
|
523
|
-
room = max(0, list_width - tui.display_width(left) - 2)
|
|
524
|
-
summary = tui.truncate(command.summary, room)
|
|
525
|
-
left += f" {colors.grey}{summary}{colors.reset}"
|
|
526
|
-
|
|
527
|
-
detail_index = index - state.offset
|
|
528
|
-
right = detail_lines[detail_index] if detail_index < len(detail_lines) else ""
|
|
529
|
-
if detail_width:
|
|
530
|
-
screen.line(f"{left}{colors.grey}│{colors.reset} {right}")
|
|
531
|
-
else:
|
|
532
|
-
screen.line(left)
|
|
533
|
-
|
|
534
|
-
draw_footer(screen, state)
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
def detail_pane(state: Menu, width: int) -> list[str]:
|
|
538
|
-
"""The right-hand column: what the selected command is and what it needs."""
|
|
539
|
-
colors = tui.palette()
|
|
540
|
-
command = state.selected()
|
|
541
|
-
if command is None:
|
|
542
|
-
return []
|
|
543
|
-
|
|
544
|
-
lines: list[str] = [f"{colors.bold}{command.name}{colors.reset}", ""]
|
|
545
|
-
lines += wrap(command.summary, width, colors.grey)
|
|
546
|
-
lines.append("")
|
|
547
|
-
|
|
548
|
-
def field_line(label: str, value: str) -> str:
|
|
549
|
-
return f"{colors.grey}{label:<8}{colors.reset}{value}"
|
|
550
|
-
|
|
551
|
-
if command.script:
|
|
552
|
-
lines.append(field_line("script", f"bin/{command.script}"))
|
|
553
|
-
elif command.builtin:
|
|
554
|
-
lines.append(field_line("script", "built in to che"))
|
|
555
|
-
|
|
556
|
-
if command.needs:
|
|
557
|
-
parts = []
|
|
558
|
-
for binary in command.needs:
|
|
559
|
-
found = _which(binary)
|
|
560
|
-
mark = f"{colors.green}✓{colors.reset}" if found else f"{colors.red}✗{colors.reset}"
|
|
561
|
-
parts.append(f"{mark} {binary}")
|
|
562
|
-
lines.append(field_line("needs", " ".join(parts)))
|
|
563
|
-
|
|
564
|
-
if command.flags:
|
|
565
|
-
lines += wrap(" ".join(command.flags), width - 8, colors.grey, indent="flags ")
|
|
566
|
-
|
|
567
|
-
if command.has_dry_twin:
|
|
568
|
-
state_text = (
|
|
569
|
-
f"{colors.green}on{colors.reset} - previews only"
|
|
570
|
-
if state.is_dry(command)
|
|
571
|
-
else f"{colors.red}OFF{colors.reset} - changes files"
|
|
572
|
-
)
|
|
573
|
-
lines.append(field_line("dry-run", f"{state_text} {colors.grey}(d){colors.reset}"))
|
|
574
|
-
elif command.destructive:
|
|
575
|
-
reason = command.no_preview_reason or "changes files"
|
|
576
|
-
lines += wrap(reason, width - 8, colors.yellow, indent="warning ")
|
|
577
|
-
|
|
578
|
-
if command.setup:
|
|
579
|
-
lines += wrap(command.setup, width - 8, colors.yellow, indent="setup ")
|
|
580
|
-
|
|
581
|
-
if command.macos_only:
|
|
582
|
-
lines.append(field_line("os", "macOS only"))
|
|
583
|
-
|
|
584
|
-
lines.append("")
|
|
585
|
-
lines.append(f"{colors.grey}⏎ run x run bare h help{colors.reset}")
|
|
586
|
-
return [tui.truncate(line, width) for line in lines]
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
def wrap(text: str, width: int, color: str = "", indent: str = "") -> list[str]:
|
|
590
|
-
colors = tui.palette()
|
|
591
|
-
body = textwrap.wrap(text, max(10, width - len(indent))) or [""]
|
|
592
|
-
out = []
|
|
593
|
-
for index, line in enumerate(body):
|
|
594
|
-
prefix = indent if index == 0 else " " * len(indent)
|
|
595
|
-
out.append(
|
|
596
|
-
f"{colors.grey}{prefix}{colors.reset}{color}{line}{colors.reset if color else ''}"
|
|
597
|
-
)
|
|
598
|
-
return out
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
def draw_help(screen: tui.Screen) -> None:
|
|
602
|
-
colors = tui.palette()
|
|
603
|
-
screen.line()
|
|
604
|
-
screen.line(f" {colors.bold}Keys{colors.reset}")
|
|
605
|
-
screen.line()
|
|
606
|
-
width = max(len(key) for key, _ in HELP_OVERLAY) + 3
|
|
607
|
-
for key, description in HELP_OVERLAY:
|
|
608
|
-
screen.line(
|
|
609
|
-
f" {colors.cyan}{key:<{width}}{colors.reset}{colors.grey}{description}{colors.reset}"
|
|
610
|
-
)
|
|
611
|
-
screen.line()
|
|
612
|
-
screen.line(f" {colors.grey}Press any key to go back.{colors.reset}")
|
|
613
250
|
|
|
251
|
+
term.suspend()
|
|
252
|
+
if action == "help":
|
|
253
|
+
show_help(target.command)
|
|
254
|
+
else:
|
|
255
|
+
last_status = launch(target, prompt=(action == "run"))
|
|
256
|
+
tui.pause("Press any key to go back")
|
|
257
|
+
term.resume()
|
|
614
258
|
|
|
615
|
-
|
|
616
|
-
colors = tui.palette()
|
|
617
|
-
screen.pad_to(screen.rows - 2)
|
|
618
|
-
screen.line(f" {colors.grey}{'─' * max(0, screen.columns - 4)}{colors.reset}")
|
|
619
|
-
if state.status:
|
|
620
|
-
screen.line(f" {colors.yellow}{state.status}{colors.reset}")
|
|
621
|
-
state.status = ""
|
|
622
|
-
else:
|
|
623
|
-
screen.line(
|
|
624
|
-
f" {colors.grey}⏎ run d dry-run h help / search ? keys q quit{colors.reset}"
|
|
625
|
-
)
|
|
259
|
+
return last_status
|
|
626
260
|
|
|
627
261
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
# ---------------------------------------------------------------------------
|
|
262
|
+
class Cancelled(Exception):
|
|
263
|
+
"""The user backed out before the command ran. The message says why, if anything."""
|
|
631
264
|
|
|
632
265
|
|
|
633
|
-
def launch(
|
|
634
|
-
"""Collect arguments for ``
|
|
266
|
+
def launch(target: Resolved, *, prompt: bool) -> int:
|
|
267
|
+
"""Collect arguments for ``target``, confirm a real run, and run it."""
|
|
268
|
+
command = target.command
|
|
635
269
|
colors = tui.palette()
|
|
636
|
-
dry = state.is_dry(command)
|
|
637
270
|
|
|
638
271
|
print()
|
|
639
272
|
print(
|
|
640
|
-
f"{colors.bright_magenta}{colors.bold}{
|
|
273
|
+
f"{colors.bright_magenta}{colors.bold}{target.name}{colors.reset} "
|
|
274
|
+
f"{colors.grey}{command.summary}{colors.reset}"
|
|
641
275
|
)
|
|
642
276
|
print()
|
|
643
277
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
extra: list[str] = []
|
|
650
|
-
if prompt and command.prompts:
|
|
651
|
-
collected = collect_arguments(command)
|
|
652
|
-
if collected is None:
|
|
653
|
-
print(f"{colors.yellow}Cancelled.{colors.reset}")
|
|
654
|
-
tui.pause("Press any key to go back")
|
|
655
|
-
return 130
|
|
656
|
-
extra = collected
|
|
657
|
-
|
|
658
|
-
free = tui.ask("Extra arguments", default="", history_file=history_file("extra"))
|
|
659
|
-
if free.strip():
|
|
660
|
-
extra += shlex.split(free)
|
|
661
|
-
|
|
662
|
-
resolved = Resolved(command, dry=dry, matched=command.name)
|
|
663
|
-
preview = " ".join([command.name, *extra])
|
|
664
|
-
env, _ = command.invocation(dry=dry)
|
|
665
|
-
env_text = " ".join(f"{key}={value}" for key, value in sorted(env.items()))
|
|
666
|
-
|
|
667
|
-
print()
|
|
668
|
-
print(f"{colors.grey}$ {env_text} {preview}{colors.reset}".replace("$ ", "$ "))
|
|
669
|
-
|
|
670
|
-
previewing = dry or any(
|
|
671
|
-
arg.startswith("--dry-run") and not arg.endswith("=false") for arg in extra
|
|
672
|
-
)
|
|
673
|
-
if command.destructive and not previewing:
|
|
278
|
+
try:
|
|
279
|
+
extra = collect_arguments(command) if prompt else []
|
|
280
|
+
# What a person would type to repeat this exactly, so it is safe to
|
|
281
|
+
# copy: the -dr twin carries its own preview, whichever style it uses.
|
|
674
282
|
print()
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
283
|
+
print(f"{colors.grey}$ {shlex.join(['che', target.name, *extra])}{colors.reset}")
|
|
284
|
+
|
|
285
|
+
if command.destructive and not effective_dry(target.dry, extra):
|
|
286
|
+
print()
|
|
287
|
+
if not tui.confirm(f"{command.name} changes files for real. Continue?", default=False):
|
|
288
|
+
raise Cancelled
|
|
289
|
+
except (Cancelled, KeyboardInterrupt) as exc:
|
|
290
|
+
if str(exc):
|
|
291
|
+
warning(f"❌ {exc}")
|
|
292
|
+
print(f"\n{colors.yellow}Cancelled.{colors.reset}")
|
|
293
|
+
return 130
|
|
679
294
|
|
|
680
295
|
print()
|
|
681
|
-
status = run_command(
|
|
296
|
+
status = run_command(target, extra)
|
|
682
297
|
print()
|
|
683
298
|
if status == 0:
|
|
684
|
-
success(f"✓ {
|
|
299
|
+
success(f"✓ {target.name} finished")
|
|
685
300
|
else:
|
|
686
|
-
warning(f"✗ {
|
|
687
|
-
|
|
688
|
-
tui.pause("Press any key to go back")
|
|
301
|
+
warning(f"✗ {target.name} exited {status}")
|
|
689
302
|
return status
|
|
690
303
|
|
|
691
304
|
|
|
692
|
-
def
|
|
693
|
-
"""
|
|
694
|
-
extra: list[str] = []
|
|
305
|
+
def effective_dry(dry: bool, extra: list[str]) -> bool:
|
|
306
|
+
"""Whether a run only previews, once a ``--dry-run`` typed by hand is counted.
|
|
695
307
|
|
|
308
|
+
Read the way the script's own parser reads it - ``utils.parse_bool``, the
|
|
309
|
+
last one winning, nothing after ``--`` - so ``--dry-run=0`` gets the
|
|
310
|
+
"changes files for real" question, and so does a ``--dry-run=false`` that
|
|
311
|
+
overrides the ``DRY_RUN=true`` of a command left on preview.
|
|
312
|
+
"""
|
|
313
|
+
for arg in extra:
|
|
314
|
+
if arg == "--":
|
|
315
|
+
break
|
|
316
|
+
flag, sep, value = arg.partition("=")
|
|
317
|
+
if flag == "--dry-run":
|
|
318
|
+
try:
|
|
319
|
+
dry = parse_bool(value) if sep else True
|
|
320
|
+
except ValueError:
|
|
321
|
+
dry = False # the script will reject it; ask anyway
|
|
322
|
+
return dry
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def collect_arguments(command: Command) -> list[str]:
|
|
326
|
+
"""Ask the curated questions for ``command``, then for anything extra.
|
|
327
|
+
|
|
328
|
+
Raises :class:`Cancelled` for a required answer left empty, or extra
|
|
329
|
+
arguments that do not split (an unclosed quote).
|
|
330
|
+
"""
|
|
331
|
+
if not command.prompts:
|
|
332
|
+
return []
|
|
333
|
+
|
|
334
|
+
extra: list[str] = []
|
|
696
335
|
for prompt in command.prompts:
|
|
697
336
|
if prompt.kind == "bool":
|
|
698
337
|
default = prompt.default == "true"
|
|
699
|
-
|
|
700
|
-
#
|
|
701
|
-
#
|
|
702
|
-
#
|
|
703
|
-
if
|
|
338
|
+
# A yes always passes the flag, and a no passes `=false` when the
|
|
339
|
+
# question defaulted to yes. The prompt's default is the question's,
|
|
340
|
+
# not necessarily the script's: `--dry-run` defaults to yes here on
|
|
341
|
+
# scripts that run for real without it.
|
|
342
|
+
if tui.confirm(prompt.label, default=default):
|
|
704
343
|
extra.append(prompt.flag)
|
|
705
|
-
elif
|
|
344
|
+
elif default:
|
|
706
345
|
extra.append(f"{prompt.flag}=false")
|
|
707
346
|
continue
|
|
708
347
|
|
|
@@ -715,33 +354,32 @@ def collect_arguments(command: Command) -> list[str] | None:
|
|
|
715
354
|
)
|
|
716
355
|
if not answer:
|
|
717
356
|
if prompt.required:
|
|
718
|
-
|
|
719
|
-
return None
|
|
357
|
+
raise Cancelled(f"{prompt.label} is required.")
|
|
720
358
|
continue
|
|
721
359
|
|
|
722
360
|
extra.append(answer if not prompt.flag else f"{prompt.flag}={answer}")
|
|
723
361
|
|
|
362
|
+
free = tui.ask("Extra arguments", default="", history_file=history_file("extra"))
|
|
363
|
+
try:
|
|
364
|
+
extra += shlex.split(free)
|
|
365
|
+
except ValueError as exc:
|
|
366
|
+
raise Cancelled(f"Extra arguments: {exc}") from None
|
|
724
367
|
return extra
|
|
725
368
|
|
|
726
369
|
|
|
727
370
|
def history_file(key: str) -> Path:
|
|
728
|
-
return
|
|
371
|
+
return machine.state_dir() / f"history-{key}"
|
|
729
372
|
|
|
730
373
|
|
|
731
374
|
def show_help(command: Command) -> None:
|
|
732
375
|
colors = tui.palette()
|
|
733
376
|
print()
|
|
734
|
-
|
|
735
|
-
print(text)
|
|
377
|
+
print(script_help(command))
|
|
736
378
|
print()
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
warning(
|
|
741
|
-
f"missing: {binary} - {installer.INSTALL_HINTS.get(binary, 'not installed')}"
|
|
742
|
-
)
|
|
379
|
+
for binary in command.needs:
|
|
380
|
+
if not shutil.which(binary):
|
|
381
|
+
warning(f"missing: {binary} - {machine.INSTALL_HINTS.get(binary, 'not installed')}")
|
|
743
382
|
print(f"{colors.grey}(from `{command.name} --help`){colors.reset}")
|
|
744
|
-
tui.pause("Press any key to go back")
|
|
745
383
|
|
|
746
384
|
|
|
747
385
|
# ---------------------------------------------------------------------------
|
|
@@ -749,62 +387,35 @@ def show_help(command: Command) -> None:
|
|
|
749
387
|
# ---------------------------------------------------------------------------
|
|
750
388
|
|
|
751
389
|
|
|
752
|
-
def
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
return installer.uninstall(dry_run=args.dry_run, purge=args.purge)
|
|
767
|
-
if name == "list":
|
|
768
|
-
parser = _simple_parser("che list", "📋 List every command.")
|
|
769
|
-
args = parser.parse_args(argv)
|
|
770
|
-
return list_commands(args.category, as_json=args.json, include_dry=not args.no_dry)
|
|
771
|
-
if name == "completions":
|
|
772
|
-
return completions_main(argv)
|
|
773
|
-
raise AssertionError(f"unhandled builtin: {name}")
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
def _simple_parser(prog: str, description: str):
|
|
777
|
-
from utils import add_bool_flag, build_parser
|
|
778
|
-
|
|
779
|
-
parser = build_parser(prog=prog, description=description)
|
|
780
|
-
add_bool_flag(parser, "--dry-run", help="Report what would change, change nothing")
|
|
781
|
-
add_bool_flag(parser, "--check", help="Report whether an update is available, apply nothing")
|
|
782
|
-
add_bool_flag(parser, "--json", help="Machine-readable output")
|
|
783
|
-
add_bool_flag(parser, "--verbose", help="Include checks that passed")
|
|
784
|
-
add_bool_flag(parser, "--purge", help="Also delete ~/.config/che")
|
|
785
|
-
add_bool_flag(parser, "--no-dry", dest="no_dry", allow_value=False, help="Hide the -dr twins")
|
|
786
|
-
parser.add_argument("--category", default="", metavar="NAME", help="Only this category")
|
|
787
|
-
return parser
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
def completions_main(argv: list[str]) -> int:
|
|
791
|
-
shells = ", ".join(shellgen.SHELLS)
|
|
792
|
-
if not argv or argv[0] in ("-h", "--help"):
|
|
793
|
-
print(f"Usage: che completions <{shells.replace(', ', '|')}>")
|
|
794
|
-
print()
|
|
795
|
-
print("Print the completion script for a shell. `che install` already")
|
|
796
|
-
print("wires these up; this is for sourcing one by hand.")
|
|
797
|
-
return 0 if argv else 1
|
|
798
|
-
|
|
799
|
-
shell = argv[0].removeprefix("--shell=")
|
|
800
|
-
if shell not in shellgen.SHELLS:
|
|
801
|
-
warning(f"❌ Unknown shell: {shell}")
|
|
802
|
-
note(f" Known: {shells}")
|
|
803
|
-
return 1
|
|
390
|
+
def completions_main(argv: list[str] | None = None) -> int:
|
|
391
|
+
parser = build_parser(
|
|
392
|
+
prog="che completions",
|
|
393
|
+
description="⌨️ Print the completion script for a shell.",
|
|
394
|
+
epilog="`che install` already wires these up; this is for sourcing one by hand.",
|
|
395
|
+
)
|
|
396
|
+
parser.add_argument("shell", nargs="?", choices=shellgen.FLAVORS, help="The shell")
|
|
397
|
+
parser.add_argument(
|
|
398
|
+
"--shell", dest="shell_flag", choices=shellgen.FLAVORS, metavar="NAME", help="The same"
|
|
399
|
+
)
|
|
400
|
+
args = parser.parse_args(argv)
|
|
401
|
+
shell = args.shell_flag or args.shell
|
|
402
|
+
if not shell:
|
|
403
|
+
parser.error("a shell is required")
|
|
804
404
|
print(shellgen.completions(shell), end="")
|
|
805
405
|
return 0
|
|
806
406
|
|
|
807
407
|
|
|
408
|
+
# Each built-in's CLI sits beside what it runs and parses only its own flags.
|
|
409
|
+
BUILTIN_MAIN: dict[str, Callable[[list[str]], int]] = {
|
|
410
|
+
"install": install.install_main,
|
|
411
|
+
"update": install.update_main,
|
|
412
|
+
"doctor": doctor.doctor_main,
|
|
413
|
+
"uninstall": install.uninstall_main,
|
|
414
|
+
"list": list_main,
|
|
415
|
+
"completions": completions_main,
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
|
|
808
419
|
# ---------------------------------------------------------------------------
|
|
809
420
|
# Entry point
|
|
810
421
|
# ---------------------------------------------------------------------------
|
|
@@ -813,7 +424,7 @@ def completions_main(argv: list[str]) -> int:
|
|
|
813
424
|
def first_run() -> int:
|
|
814
425
|
"""What ``che`` does on a machine where it has never been installed."""
|
|
815
426
|
colors = tui.palette()
|
|
816
|
-
|
|
427
|
+
art.print_logo(f"shell helpers, v{VERSION}")
|
|
817
428
|
print(f" {colors.grey}che is not installed in this shell yet.{colors.reset}")
|
|
818
429
|
print()
|
|
819
430
|
print(
|
|
@@ -828,9 +439,9 @@ def first_run() -> int:
|
|
|
828
439
|
|
|
829
440
|
print()
|
|
830
441
|
if key in ("i", "enter"):
|
|
831
|
-
return
|
|
442
|
+
return install.install_main([])
|
|
832
443
|
if key == "m":
|
|
833
|
-
return
|
|
444
|
+
return run_menu()
|
|
834
445
|
return 0
|
|
835
446
|
|
|
836
447
|
|
|
@@ -841,9 +452,9 @@ def main() -> int:
|
|
|
841
452
|
if not tui.is_interactive():
|
|
842
453
|
usage()
|
|
843
454
|
return 0
|
|
844
|
-
if
|
|
455
|
+
if machine.load_record() is None:
|
|
845
456
|
return first_run()
|
|
846
|
-
return
|
|
457
|
+
return run_menu()
|
|
847
458
|
|
|
848
459
|
first = argv[0]
|
|
849
460
|
rest = argv[1:]
|
|
@@ -864,10 +475,10 @@ def main() -> int:
|
|
|
864
475
|
print(script_help(found.command))
|
|
865
476
|
return 0
|
|
866
477
|
if first == "menu":
|
|
867
|
-
return
|
|
478
|
+
return run_menu()
|
|
868
479
|
|
|
869
|
-
if first in
|
|
870
|
-
return
|
|
480
|
+
if first in BUILTIN_MAIN:
|
|
481
|
+
return BUILTIN_MAIN[first](rest)
|
|
871
482
|
|
|
872
483
|
found = resolve(first)
|
|
873
484
|
if found is None:
|
|
@@ -876,6 +487,16 @@ def main() -> int:
|
|
|
876
487
|
return exec_command(found, rest)
|
|
877
488
|
|
|
878
489
|
|
|
490
|
+
def suggest(name: str, *, limit: int = 3) -> list[str]:
|
|
491
|
+
"""Closest known command names, for the "did you mean" line."""
|
|
492
|
+
pool = command_names()
|
|
493
|
+
close = get_close_matches(name, pool, n=limit, cutoff=0.6)
|
|
494
|
+
if close:
|
|
495
|
+
return close
|
|
496
|
+
# get_close_matches misses substring typos like "codecs" for "show-codecs".
|
|
497
|
+
return [candidate for candidate in pool if name and name in candidate][:limit]
|
|
498
|
+
|
|
499
|
+
|
|
879
500
|
def unknown(name: str) -> int:
|
|
880
501
|
warning(f"❌ Unknown command: {name}")
|
|
881
502
|
close = suggest(name)
|