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