@codebend3r/che 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +424 -0
- package/bin/che.py +869 -0
- package/bin/commands.py +872 -0
- package/bin/drives/eject-all-drives.py +203 -0
- package/bin/drives/mount-all-drives.py +140 -0
- package/bin/drives/ping-nas.py +242 -0
- package/bin/files/compress-folders.py +188 -0
- package/bin/files/delete-by-ext.py +108 -0
- package/bin/files/delete-empty-folders.py +136 -0
- package/bin/files/delete-smb-files.py +105 -0
- package/bin/files/files-under-size.py +132 -0
- package/bin/files/find-largest-files.py +102 -0
- package/bin/files/make-alpha-dir.py +63 -0
- package/bin/git/all-actions.py +545 -0
- package/bin/git/checkout-my-branches.py +147 -0
- package/bin/git/clean-stale-branches.py +161 -0
- package/bin/git/prune-worktrees.py +143 -0
- package/bin/git/sync-all-branches.py +368 -0
- package/bin/git/update-local-branches.py +195 -0
- package/bin/install.py +1515 -0
- package/bin/shellgen.py +782 -0
- package/bin/system/btop-launch.py +114 -0
- package/bin/system/update-brew.py +146 -0
- package/bin/tui.py +648 -0
- package/bin/utils.py +836 -0
- package/bin/video/delete-duplicate-videos.py +313 -0
- package/bin/video/detect-green-magenta-videos.py +268 -0
- package/bin/video/find-movie-by-year.py +84 -0
- package/bin/video/find-video-mkv-issues.py +242 -0
- package/bin/video/fix-codecs.py +209 -0
- package/bin/video/largest-tv-shows.py +175 -0
- package/bin/video/remove-metadata.py +126 -0
- package/bin/video/rename-video-file.py +329 -0
- package/bin/video/scan-videos-audio-language.py +110 -0
- package/bin/video/show-codecs.py +111 -0
- package/bin/video/validate-video-files.py +114 -0
- package/bin/video/video-list.py +120 -0
- package/package.json +54 -0
- package/shell/che.bash +382 -0
- package/shell/che.fish +282 -0
- package/shell/che.zsh +389 -0
- package/shell/completions/_che +146 -0
- package/shell/completions/che.bash +107 -0
- package/shell/completions/che.fish +371 -0
package/bin/che.py
ADDED
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""``che`` - the dispatcher every helper script in this repo hangs off.
|
|
3
|
+
|
|
4
|
+
Three ways in, all landing on the same command table in ``bin/commands.py``:
|
|
5
|
+
|
|
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.
|
|
8
|
+
* ``che <command> [args…]`` runs one directly, with the same environment the
|
|
9
|
+
shell wrapper would have used, and ``exec``s so signals and exit codes pass
|
|
10
|
+
straight through.
|
|
11
|
+
* ``che install|update|doctor|uninstall|list|completions`` manage the install
|
|
12
|
+
itself - see ``bin/install.py``.
|
|
13
|
+
|
|
14
|
+
The shell wrappers (``delete-by-ext``, ``sync-all-branches``, …) are generated
|
|
15
|
+
from the same table, so anything reachable here is reachable by name too.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import shlex
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import textwrap
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from functools import lru_cache
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
32
|
+
|
|
33
|
+
import install as installer
|
|
34
|
+
import shellgen
|
|
35
|
+
import tui
|
|
36
|
+
from commands import (
|
|
37
|
+
BUILTIN_NAMES,
|
|
38
|
+
CATEGORIES,
|
|
39
|
+
MENU_KEYS,
|
|
40
|
+
REPO,
|
|
41
|
+
VERSION,
|
|
42
|
+
Command,
|
|
43
|
+
Resolved,
|
|
44
|
+
all_commands,
|
|
45
|
+
fuzzy_match,
|
|
46
|
+
in_category,
|
|
47
|
+
resolve,
|
|
48
|
+
suggest,
|
|
49
|
+
)
|
|
50
|
+
from utils import note, run_cli, success, warning
|
|
51
|
+
|
|
52
|
+
__version__ = VERSION
|
|
53
|
+
|
|
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
|
+
|
|
63
|
+
@lru_cache(maxsize=1)
|
|
64
|
+
def _interpreter() -> str:
|
|
65
|
+
"""The python the scripts run under.
|
|
66
|
+
|
|
67
|
+
Normally this very interpreter: the shell wrapper already chose
|
|
68
|
+
``$CHE_PYTHON``. Through the npm ``bin`` link, though, ``che`` starts under
|
|
69
|
+
whatever ``python3`` is first on PATH, which on a stock Mac is the years-old
|
|
70
|
+
Command Line Tools build. The dispatcher itself tolerates that; the scripts
|
|
71
|
+
do not, so hand them the newest suitable interpreter instead.
|
|
72
|
+
"""
|
|
73
|
+
return installer.find_python()[0]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Running a command
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_argv(command: Command, extra: list[str], *, dry: bool = False) -> list[str]:
|
|
82
|
+
_, fixed = command.invocation(dry=dry)
|
|
83
|
+
return [_interpreter(), str(BIN / command.script), *fixed, *extra]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def command_env(command: Command, *, dry: bool = False) -> dict[str, str]:
|
|
87
|
+
env, _ = command.invocation(dry=dry)
|
|
88
|
+
return {**os.environ, **env}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def exec_command(resolved: Resolved, extra: list[str]) -> int:
|
|
92
|
+
"""Replace this process with the script, the way the wrapper would.
|
|
93
|
+
|
|
94
|
+
``exec`` rather than a subprocess so Ctrl-C, job control and the exit code
|
|
95
|
+
behave exactly as if the script had been called directly - which matters
|
|
96
|
+
for the long-running ones (``ping-nas``) and the full-screen ones
|
|
97
|
+
(``btop``).
|
|
98
|
+
"""
|
|
99
|
+
command = resolved.command
|
|
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}")
|
|
111
|
+
return 127
|
|
112
|
+
|
|
113
|
+
argv = build_argv(command, extra, dry=resolved.dry)
|
|
114
|
+
os.execve(argv[0], argv, command_env(command, dry=resolved.dry)) # noqa: S606
|
|
115
|
+
return 127 # unreachable: execve only returns by raising
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
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
|
|
127
|
+
|
|
128
|
+
return subprocess.run( # noqa: S603 - argv list, shell=False
|
|
129
|
+
build_argv(command, extra, dry=resolved.dry),
|
|
130
|
+
env=command_env(command, dry=resolved.dry),
|
|
131
|
+
check=False,
|
|
132
|
+
).returncode
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def script_help(command: Command) -> str:
|
|
136
|
+
if not command.script:
|
|
137
|
+
return command.summary
|
|
138
|
+
try:
|
|
139
|
+
result = subprocess.run( # noqa: S603
|
|
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
|
+
)
|
|
147
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
148
|
+
return f"(could not read help: {exc})"
|
|
149
|
+
return (result.stdout or result.stderr).strip()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Help
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def usage() -> None:
|
|
158
|
+
colors = tui.palette()
|
|
159
|
+
print(f"{colors.bold}che{colors.reset} {colors.grey}v{VERSION}{colors.reset} - shell helpers")
|
|
160
|
+
print()
|
|
161
|
+
print(f"{colors.bold}USAGE{colors.reset}")
|
|
162
|
+
print(" che open the interactive menu")
|
|
163
|
+
print(" che <command> [args…] run one command")
|
|
164
|
+
print(" che help <command> show that command's own --help")
|
|
165
|
+
print()
|
|
166
|
+
|
|
167
|
+
for category in CATEGORIES:
|
|
168
|
+
members = in_category(category.key)
|
|
169
|
+
if not members:
|
|
170
|
+
continue
|
|
171
|
+
print(
|
|
172
|
+
f"{colors.bold}{category.title.upper()}{colors.reset} {colors.grey}{category.blurb}{colors.reset}"
|
|
173
|
+
)
|
|
174
|
+
width = max(len(command.name) for command in members) + 2
|
|
175
|
+
for command in members:
|
|
176
|
+
marks = ""
|
|
177
|
+
if command.has_dry_twin:
|
|
178
|
+
marks = f" {colors.grey}(+{command.dry_name}){colors.reset}"
|
|
179
|
+
print(f" {colors.cyan}{command.name:<{width}}{colors.reset}{command.summary}{marks}")
|
|
180
|
+
print()
|
|
181
|
+
|
|
182
|
+
print(f"{colors.bold}OPTIONS{colors.reset}")
|
|
183
|
+
print(" -h, --help show this help")
|
|
184
|
+
print(" -v, --version show the version")
|
|
185
|
+
print()
|
|
186
|
+
print(
|
|
187
|
+
f"{colors.grey}Every command above is also a shell function of the same name.{colors.reset}"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def list_commands(category: str = "", *, as_json: bool = False, include_dry: bool = True) -> int:
|
|
192
|
+
if as_json:
|
|
193
|
+
payload = [
|
|
194
|
+
{
|
|
195
|
+
"name": command.name,
|
|
196
|
+
"category": command.category,
|
|
197
|
+
"summary": command.summary,
|
|
198
|
+
"script": command.script,
|
|
199
|
+
"needs": list(command.needs),
|
|
200
|
+
"flags": list(command.flags),
|
|
201
|
+
"destructive": command.destructive,
|
|
202
|
+
"dry_run": command.dry_run,
|
|
203
|
+
}
|
|
204
|
+
for command in all_commands()
|
|
205
|
+
if not category or command.category == category
|
|
206
|
+
]
|
|
207
|
+
print(json.dumps(payload, indent=2))
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
if category and not any(command.category == category for command in all_commands()):
|
|
211
|
+
warning(f"❌ Unknown category: {category}")
|
|
212
|
+
note(f" Known: {', '.join(item.key for item in CATEGORIES)}")
|
|
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)
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
# The interactive menu
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass
|
|
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:
|
|
313
|
+
"""The full-screen browser. Returns the exit status of the last command."""
|
|
314
|
+
if not tui.is_interactive():
|
|
315
|
+
warning("❌ The menu needs a terminal. Try `che <command>` or `che --help`.")
|
|
316
|
+
return 1
|
|
317
|
+
|
|
318
|
+
state = Menu()
|
|
319
|
+
state.first()
|
|
320
|
+
last_status = 0
|
|
321
|
+
screen = tui.Screen()
|
|
322
|
+
|
|
323
|
+
with tui.Term() as term:
|
|
324
|
+
while True:
|
|
325
|
+
screen.start()
|
|
326
|
+
draw(screen, state)
|
|
327
|
+
screen.flush()
|
|
328
|
+
|
|
329
|
+
key = tui.read_key(timeout=0.5)
|
|
330
|
+
if key is None:
|
|
331
|
+
continue # timeout: loop so a resize repaints
|
|
332
|
+
|
|
333
|
+
if state.show_help:
|
|
334
|
+
state.show_help = False
|
|
335
|
+
continue
|
|
336
|
+
|
|
337
|
+
action = handle_key(state, key)
|
|
338
|
+
|
|
339
|
+
if action == "quit":
|
|
340
|
+
break
|
|
341
|
+
if action == "run" or action == "run-raw":
|
|
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:
|
|
484
|
+
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
|
+
|
|
594
|
+
|
|
595
|
+
def draw_footer(screen: tui.Screen, state: Menu) -> None:
|
|
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
|
+
)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
# ---------------------------------------------------------------------------
|
|
609
|
+
# Running from the menu
|
|
610
|
+
# ---------------------------------------------------------------------------
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def launch(command: Command, state: Menu, *, prompt: bool = True) -> int:
|
|
614
|
+
"""Collect arguments for ``command``, run it, and wait for a keypress."""
|
|
615
|
+
colors = tui.palette()
|
|
616
|
+
dry = state.is_dry(command)
|
|
617
|
+
|
|
618
|
+
print()
|
|
619
|
+
print(
|
|
620
|
+
f"{colors.bright_magenta}{colors.bold}{command.name}{colors.reset} {colors.grey}{command.summary}{colors.reset}"
|
|
621
|
+
)
|
|
622
|
+
print()
|
|
623
|
+
|
|
624
|
+
if command.builtin:
|
|
625
|
+
status = run_builtin(command.name, [])
|
|
626
|
+
tui.pause("Press any key to go back")
|
|
627
|
+
return status
|
|
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:
|
|
654
|
+
print()
|
|
655
|
+
if not tui.confirm(f"{command.name} changes files for real. Continue?", default=False):
|
|
656
|
+
print(f"{colors.yellow}Cancelled.{colors.reset}")
|
|
657
|
+
tui.pause("Press any key to go back")
|
|
658
|
+
return 130
|
|
659
|
+
|
|
660
|
+
print()
|
|
661
|
+
status = run_command(resolved, extra)
|
|
662
|
+
print()
|
|
663
|
+
if status == 0:
|
|
664
|
+
success(f"✓ {command.name} finished")
|
|
665
|
+
else:
|
|
666
|
+
warning(f"✗ {command.name} exited {status}")
|
|
667
|
+
|
|
668
|
+
tui.pause("Press any key to go back")
|
|
669
|
+
return status
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def collect_arguments(command: Command) -> list[str] | None:
|
|
673
|
+
"""Ask the curated questions for ``command``. ``None`` means cancelled."""
|
|
674
|
+
extra: list[str] = []
|
|
675
|
+
|
|
676
|
+
for prompt in command.prompts:
|
|
677
|
+
if prompt.kind == "bool":
|
|
678
|
+
default = prompt.default == "true"
|
|
679
|
+
answer = tui.confirm(prompt.label, default=default)
|
|
680
|
+
# Only pass the flag when the answer differs from what the script
|
|
681
|
+
# already does, so the command line stays as short as what a person
|
|
682
|
+
# would have typed.
|
|
683
|
+
if answer and not default:
|
|
684
|
+
extra.append(prompt.flag)
|
|
685
|
+
elif not answer and default:
|
|
686
|
+
extra.append(f"{prompt.flag}=false")
|
|
687
|
+
continue
|
|
688
|
+
|
|
689
|
+
answer = tui.ask(
|
|
690
|
+
prompt.label + (f" ({prompt.help})" if prompt.help else ""),
|
|
691
|
+
default=prompt.default,
|
|
692
|
+
kind=prompt.kind,
|
|
693
|
+
choices=prompt.choices,
|
|
694
|
+
history_file=history_file(prompt.key),
|
|
695
|
+
)
|
|
696
|
+
if not answer:
|
|
697
|
+
if prompt.required:
|
|
698
|
+
warning(f"❌ {prompt.label} is required.")
|
|
699
|
+
return None
|
|
700
|
+
continue
|
|
701
|
+
|
|
702
|
+
extra.append(answer if not prompt.flag else f"{prompt.flag}={answer}")
|
|
703
|
+
|
|
704
|
+
return extra
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def history_file(key: str) -> Path:
|
|
708
|
+
return installer.state_dir() / f"history-{key}"
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def show_help(command: Command) -> None:
|
|
712
|
+
colors = tui.palette()
|
|
713
|
+
print()
|
|
714
|
+
text = script_help(command)
|
|
715
|
+
print(text)
|
|
716
|
+
print()
|
|
717
|
+
if command.needs:
|
|
718
|
+
for binary in command.needs:
|
|
719
|
+
if not _which(binary):
|
|
720
|
+
warning(
|
|
721
|
+
f"missing: {binary} - {installer.INSTALL_HINTS.get(binary, 'not installed')}"
|
|
722
|
+
)
|
|
723
|
+
print(f"{colors.grey}(from `{command.name} --help`){colors.reset}")
|
|
724
|
+
tui.pause("Press any key to go back")
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
# ---------------------------------------------------------------------------
|
|
728
|
+
# Built-in subcommands
|
|
729
|
+
# ---------------------------------------------------------------------------
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def run_builtin(name: str, argv: list[str]) -> int:
|
|
733
|
+
if name == "install":
|
|
734
|
+
return installer.install_main(argv)
|
|
735
|
+
if name == "update":
|
|
736
|
+
parser = _simple_parser("che update", "⬆️ Update the scripts, then refresh the wrappers.")
|
|
737
|
+
args = parser.parse_args(argv)
|
|
738
|
+
return installer.update(check_only=args.check, dry_run=args.dry_run)
|
|
739
|
+
if name == "doctor":
|
|
740
|
+
parser = _simple_parser("che doctor", "🩺 Check the install and everything it depends on.")
|
|
741
|
+
args = parser.parse_args(argv)
|
|
742
|
+
return installer.doctor(as_json=args.json, verbose=args.verbose)
|
|
743
|
+
if name == "uninstall":
|
|
744
|
+
parser = _simple_parser("che uninstall", "🧽 Remove the wrappers, shim and completions.")
|
|
745
|
+
args = parser.parse_args(argv)
|
|
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
|
|
784
|
+
print(shellgen.completions(shell), end="")
|
|
785
|
+
return 0
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
# ---------------------------------------------------------------------------
|
|
789
|
+
# Entry point
|
|
790
|
+
# ---------------------------------------------------------------------------
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def first_run() -> int:
|
|
794
|
+
"""What ``che`` does on a machine where it has never been installed."""
|
|
795
|
+
colors = tui.palette()
|
|
796
|
+
installer.print_logo(f"shell helpers, v{VERSION}")
|
|
797
|
+
print(f" {colors.grey}che is not installed in this shell yet.{colors.reset}")
|
|
798
|
+
print()
|
|
799
|
+
print(
|
|
800
|
+
f" {colors.bold}i{colors.reset} install it now (writes a block into your shell rc file)"
|
|
801
|
+
)
|
|
802
|
+
print(f" {colors.bold}m{colors.reset} just open the menu")
|
|
803
|
+
print(f" {colors.bold}q{colors.reset} quit")
|
|
804
|
+
print()
|
|
805
|
+
|
|
806
|
+
with tui.Term(alt_screen=False):
|
|
807
|
+
key = tui.read_key()
|
|
808
|
+
|
|
809
|
+
print()
|
|
810
|
+
if key in ("i", "enter"):
|
|
811
|
+
return installer.wizard()
|
|
812
|
+
if key == "m":
|
|
813
|
+
return menu()
|
|
814
|
+
return 0
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def main() -> int:
|
|
818
|
+
argv = sys.argv[1:]
|
|
819
|
+
|
|
820
|
+
if not argv:
|
|
821
|
+
if not tui.is_interactive():
|
|
822
|
+
usage()
|
|
823
|
+
return 0
|
|
824
|
+
if not installer.load_config():
|
|
825
|
+
return first_run()
|
|
826
|
+
return menu()
|
|
827
|
+
|
|
828
|
+
first = argv[0]
|
|
829
|
+
rest = argv[1:]
|
|
830
|
+
|
|
831
|
+
if first in ("-h", "--help"):
|
|
832
|
+
usage()
|
|
833
|
+
return 0
|
|
834
|
+
if first in ("-v", "--version", "version"):
|
|
835
|
+
print(f"che {VERSION}")
|
|
836
|
+
return 0
|
|
837
|
+
if first == "help":
|
|
838
|
+
if not rest:
|
|
839
|
+
usage()
|
|
840
|
+
return 0
|
|
841
|
+
found = resolve(rest[0])
|
|
842
|
+
if found is None:
|
|
843
|
+
return unknown(rest[0])
|
|
844
|
+
print(script_help(found.command))
|
|
845
|
+
return 0
|
|
846
|
+
if first == "menu":
|
|
847
|
+
return menu()
|
|
848
|
+
|
|
849
|
+
if first in BUILTIN_NAMES:
|
|
850
|
+
return run_builtin(first, rest)
|
|
851
|
+
|
|
852
|
+
found = resolve(first)
|
|
853
|
+
if found is None:
|
|
854
|
+
return unknown(first)
|
|
855
|
+
|
|
856
|
+
return exec_command(found, rest)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def unknown(name: str) -> int:
|
|
860
|
+
warning(f"❌ Unknown command: {name}")
|
|
861
|
+
close = suggest(name)
|
|
862
|
+
if close:
|
|
863
|
+
note(" Did you mean: " + ", ".join(close))
|
|
864
|
+
note(" `che --help` lists everything, `che` opens the menu.")
|
|
865
|
+
return 1
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
if __name__ == "__main__":
|
|
869
|
+
run_cli(main)
|