@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/shellgen.py
ADDED
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
"""Render the shell wrappers and completions from :mod:`commands`.
|
|
2
|
+
|
|
3
|
+
Everything under ``shell/`` is produced here, and ``che install`` writes the
|
|
4
|
+
result to disk. Two rules keep the generated files honest:
|
|
5
|
+
|
|
6
|
+
* **No machine-specific data.** These files are committed, so they must work
|
|
7
|
+
from any clone path. The one machine-specific thing - where the repo lives
|
|
8
|
+
and which ``python3`` to use - goes in the small block the installer writes
|
|
9
|
+
into ``~/.zshrc``, not here. As a fallback the wrappers resolve their own
|
|
10
|
+
location, so sourcing ``shell/che.zsh`` directly works with no block at all.
|
|
11
|
+
* **Deterministic.** ``che install --check`` re-renders and diffs against the
|
|
12
|
+
files on disk, and CI runs that check, so generation may not depend on the
|
|
13
|
+
environment it runs in.
|
|
14
|
+
|
|
15
|
+
Library module: no shebang, not executable.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import shlex
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from commands import (
|
|
24
|
+
BUILTINS,
|
|
25
|
+
CATEGORIES,
|
|
26
|
+
COMMANDS,
|
|
27
|
+
MENU_KEYS,
|
|
28
|
+
VERSION,
|
|
29
|
+
Command,
|
|
30
|
+
required_binaries,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
SHELLS = ("zsh", "bash", "fish")
|
|
34
|
+
|
|
35
|
+
# Where each shell's generated file lives, relative to the repo root.
|
|
36
|
+
WRAPPER_FILES = {
|
|
37
|
+
"zsh": Path("shell/che.zsh"),
|
|
38
|
+
"bash": Path("shell/che.bash"),
|
|
39
|
+
"fish": Path("shell/che.fish"),
|
|
40
|
+
}
|
|
41
|
+
COMPLETION_FILES = {
|
|
42
|
+
"zsh": Path("shell/completions/_che"),
|
|
43
|
+
"bash": Path("shell/completions/che.bash"),
|
|
44
|
+
"fish": Path("shell/completions/che.fish"),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# The rc-file block is delimited by these. Anything between them belongs to
|
|
48
|
+
# `che install` and is rewritten wholesale on every install; anything outside
|
|
49
|
+
# is the user's and is never touched.
|
|
50
|
+
BEGIN_MARKER = "# >>> che shell functions >>>"
|
|
51
|
+
END_MARKER = "# <<< che shell functions <<<"
|
|
52
|
+
|
|
53
|
+
# Flags that take a value, so completion can offer `--flag=` without a
|
|
54
|
+
# trailing space and then complete filenames after the `=`.
|
|
55
|
+
VALUE_FLAGS = frozenset(
|
|
56
|
+
{
|
|
57
|
+
"--author",
|
|
58
|
+
"--category",
|
|
59
|
+
"--exts",
|
|
60
|
+
"--ext",
|
|
61
|
+
"--ignore-words",
|
|
62
|
+
"--interval",
|
|
63
|
+
"--length",
|
|
64
|
+
"--limit",
|
|
65
|
+
"--only",
|
|
66
|
+
"--owner",
|
|
67
|
+
"--path",
|
|
68
|
+
"--ping-timeout",
|
|
69
|
+
"--pr-limit",
|
|
70
|
+
"--protect",
|
|
71
|
+
"--samples",
|
|
72
|
+
"--shell",
|
|
73
|
+
"--shells",
|
|
74
|
+
"--size",
|
|
75
|
+
"--sort",
|
|
76
|
+
"--strategy",
|
|
77
|
+
"--threshold",
|
|
78
|
+
"--year",
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Flags whose value is a path, so completion offers files rather than nothing.
|
|
83
|
+
PATH_FLAGS = frozenset({"--path"})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _banner(comment: str = "#") -> str:
|
|
87
|
+
return "\n".join(
|
|
88
|
+
f"{comment} {line}".rstrip()
|
|
89
|
+
for line in (
|
|
90
|
+
f"che {VERSION} - generated file, do not edit.",
|
|
91
|
+
"",
|
|
92
|
+
"Generated from bin/commands.py by `che install`.",
|
|
93
|
+
"To change a wrapper, edit that manifest and run `che install` again.",
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _flag_words(command: Command) -> list[str]:
|
|
99
|
+
"""Completion words for one command: its flags plus the universal ones."""
|
|
100
|
+
words = [f"{flag}=" if flag in VALUE_FLAGS else flag for flag in command.flags]
|
|
101
|
+
words.append("--help")
|
|
102
|
+
return words
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _dry_variants(command: Command) -> list[tuple[str, bool]]:
|
|
106
|
+
variants = [(command.name, False)]
|
|
107
|
+
if command.has_dry_twin:
|
|
108
|
+
variants.append((command.dry_name, True))
|
|
109
|
+
return variants
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# POSIX-family wrappers (zsh, bash, and anything else that speaks sh)
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _posix_body(command: Command, *, dry: bool) -> list[str]:
|
|
118
|
+
"""The body of one wrapper.
|
|
119
|
+
|
|
120
|
+
DELIBERATE DIVERGENCE from the hand-written wrappers: every wrapper now
|
|
121
|
+
forwards ``"$@"``. A few of them (clean-stale-branches, prune-worktrees)
|
|
122
|
+
used to swallow their arguments, so ``clean-stale-branches --protect=x``
|
|
123
|
+
silently ignored a flag the script documents.
|
|
124
|
+
"""
|
|
125
|
+
env, args = command.invocation(dry=dry)
|
|
126
|
+
prefix = "".join(f"{name}={shlex.quote(value)} " for name, value in sorted(env.items()))
|
|
127
|
+
|
|
128
|
+
if command.body:
|
|
129
|
+
call = command.body
|
|
130
|
+
else:
|
|
131
|
+
parts = ['"$CHE_PYTHON"', f'"$CHE_BIN/{command.script}"']
|
|
132
|
+
parts += [shlex.quote(arg) for arg in args]
|
|
133
|
+
parts.append('"$@"')
|
|
134
|
+
call = prefix + " ".join(parts)
|
|
135
|
+
|
|
136
|
+
sound = command.dry_sound if dry else command.sound
|
|
137
|
+
lines = [f" {call}"]
|
|
138
|
+
if sound is not None:
|
|
139
|
+
# `$?` is expanded before che_notify runs, so the wrapper still returns
|
|
140
|
+
# the script's exit status. The old wrappers returned playsound's.
|
|
141
|
+
lines.append(f" che_notify {sound} $?")
|
|
142
|
+
return lines
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _posix_wrappers() -> list[str]:
|
|
146
|
+
lines: list[str] = []
|
|
147
|
+
for category in CATEGORIES:
|
|
148
|
+
members = [command for command in COMMANDS if command.category == category.key]
|
|
149
|
+
if not members:
|
|
150
|
+
continue
|
|
151
|
+
lines += ["", f"# {'-' * 74}", f"# {category.title} - {category.blurb}", f"# {'-' * 74}"]
|
|
152
|
+
for command in members:
|
|
153
|
+
for name, dry in _dry_variants(command):
|
|
154
|
+
summary = command.summary + (" (preview only)" if dry else "")
|
|
155
|
+
lines += ["", f"# {summary}", f"{name}() {{"]
|
|
156
|
+
lines += _posix_body(command, dry=dry)
|
|
157
|
+
lines += ["}"]
|
|
158
|
+
return lines
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
ZSH_ALIAS_GUARD = """
|
|
162
|
+
# An alias sharing a name with a wrapper below is fatal, not cosmetic: zsh
|
|
163
|
+
# expands aliases while *parsing*, so `remove-metadata() {` becomes
|
|
164
|
+
# `find ./ -type f …() {` and the parse error aborts the rest of this file -
|
|
165
|
+
# taking every wrapper after it with it.
|
|
166
|
+
#
|
|
167
|
+
# So stash any colliding alias, define the functions, then put it back.
|
|
168
|
+
# Clearing it outright would silently replace a command the user aliased on
|
|
169
|
+
# purpose; restoring it keeps their alias winning at the prompt, exactly as it
|
|
170
|
+
# did before che was installed. `che doctor` reports the collision, and
|
|
171
|
+
# `\\name` (backslash) reaches the wrapper without expanding the alias.
|
|
172
|
+
typeset -ga _che_stashed_aliases=()
|
|
173
|
+
|
|
174
|
+
_che_stash_aliases() {
|
|
175
|
+
local name
|
|
176
|
+
for name in "$@"; do
|
|
177
|
+
if [[ -n ${aliases[$name]-} ]]; then
|
|
178
|
+
_che_stashed_aliases+=("${name}=${aliases[$name]}")
|
|
179
|
+
unalias -- "$name"
|
|
180
|
+
fi
|
|
181
|
+
done
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
_che_restore_aliases() {
|
|
185
|
+
local entry
|
|
186
|
+
for entry in "${_che_stashed_aliases[@]}"; do
|
|
187
|
+
alias -- "$entry"
|
|
188
|
+
done
|
|
189
|
+
_che_stashed_aliases=()
|
|
190
|
+
}
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
BASH_ALIAS_GUARD = """
|
|
194
|
+
# Same trap as zsh: with expand_aliases on (every interactive bash), an alias
|
|
195
|
+
# sharing a name with a wrapper below turns its definition into a syntax error
|
|
196
|
+
# and the rest of this file never loads. Stash, define, restore - so the user's
|
|
197
|
+
# alias keeps winning at the prompt and `\\name` reaches the wrapper.
|
|
198
|
+
_che_stashed_aliases=()
|
|
199
|
+
|
|
200
|
+
_che_stash_aliases() {
|
|
201
|
+
local name
|
|
202
|
+
for name in "$@"; do
|
|
203
|
+
if alias "$name" >/dev/null 2>&1; then
|
|
204
|
+
_che_stashed_aliases+=("$(alias "$name")")
|
|
205
|
+
unalias "$name"
|
|
206
|
+
fi
|
|
207
|
+
done
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
_che_restore_aliases() {
|
|
211
|
+
local entry
|
|
212
|
+
for entry in "${_che_stashed_aliases[@]}"; do
|
|
213
|
+
eval "$entry"
|
|
214
|
+
done
|
|
215
|
+
_che_stashed_aliases=()
|
|
216
|
+
}
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _guarded_names() -> list[str]:
|
|
221
|
+
"""Every name the file defines, in the order the guard should clear them."""
|
|
222
|
+
from commands import wrapper_names
|
|
223
|
+
|
|
224
|
+
return ["che", *wrapper_names()]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _stash_call() -> str:
|
|
228
|
+
"""`_che_stash_aliases` invocation, wrapped so the line stays readable."""
|
|
229
|
+
lines: list[str] = []
|
|
230
|
+
current = "_che_stash_aliases"
|
|
231
|
+
for name in _guarded_names():
|
|
232
|
+
candidate = f"{current} {name}"
|
|
233
|
+
if len(candidate) > 76:
|
|
234
|
+
lines.append(f"{current} \\")
|
|
235
|
+
current = f" {name}"
|
|
236
|
+
continue
|
|
237
|
+
current = candidate
|
|
238
|
+
lines.append(current)
|
|
239
|
+
return "\n".join(lines)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _render_posix(shell: str) -> str:
|
|
243
|
+
resolve_self = {
|
|
244
|
+
"zsh": ' CHE_HOME="${${(%):-%x}:A:h:h}"',
|
|
245
|
+
"bash": ' CHE_HOME="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"',
|
|
246
|
+
}[shell]
|
|
247
|
+
|
|
248
|
+
head = f"""{_banner()}
|
|
249
|
+
#
|
|
250
|
+
# Source this file (or let the `che install` block in your rc file do it):
|
|
251
|
+
#
|
|
252
|
+
# . {"~/Developer/git/che/shell/che." + shell}
|
|
253
|
+
#
|
|
254
|
+
# Every wrapper is a shell function, so `type <name>` shows what it runs and
|
|
255
|
+
# `che doctor` can tell you when something it needs is missing.
|
|
256
|
+
|
|
257
|
+
# CHE_HOME is normally exported by the installed rc block. Fall back to this
|
|
258
|
+
# file's own location so a bare `source` of it also works.
|
|
259
|
+
if [ -z "${{CHE_HOME:-}}" ]; then
|
|
260
|
+
{resolve_self}
|
|
261
|
+
fi
|
|
262
|
+
|
|
263
|
+
# Exported, not just set: every wrapper expands "$CHE_BIN", and a shell that
|
|
264
|
+
# replays the *functions* without re-reading this file - a `typeset -f` dump, an
|
|
265
|
+
# environment snapshot, any tool that rebuilds a shell from its exported
|
|
266
|
+
# environment - keeps the functions and drops a plain variable, leaving the
|
|
267
|
+
# wrappers to run "/che.py". CHE_HOME comes in exported by the rc block; this
|
|
268
|
+
# keeps everything derived from it exported too.
|
|
269
|
+
export CHE_BIN="${{CHE_HOME}}/bin"
|
|
270
|
+
|
|
271
|
+
# Any python3 on PATH by default. `che install` records the interpreter it
|
|
272
|
+
# verified (3.12+) in the rc block, which wins over this.
|
|
273
|
+
: "${{CHE_PYTHON:=python3}}"
|
|
274
|
+
export CHE_PYTHON
|
|
275
|
+
|
|
276
|
+
# Legacy name from the hand-maintained wrappers, kept so an older snippet in
|
|
277
|
+
# ~/.zshrc that still refers to it keeps resolving.
|
|
278
|
+
export SHELL_FUNCTIONS_BIN="${{CHE_BIN}}"
|
|
279
|
+
|
|
280
|
+
# Play the completion sound for a wrapper, then return the status it was given.
|
|
281
|
+
#
|
|
282
|
+
# playsound-N is defined outside this repo. Every wrapper used to call it
|
|
283
|
+
# unconditionally, which printed "command not found" on a machine that never
|
|
284
|
+
# had it; here a missing playsound is simply skipped. Set CHE_SOUNDS=0 to
|
|
285
|
+
# silence them all.
|
|
286
|
+
che_notify() {{
|
|
287
|
+
if [ "${{CHE_SOUNDS:-1}}" = "1" ] && command -v "playsound-$1" >/dev/null 2>&1; then
|
|
288
|
+
"playsound-$1" >/dev/null 2>&1
|
|
289
|
+
fi
|
|
290
|
+
return "${{2:-0}}"
|
|
291
|
+
}}
|
|
292
|
+
|
|
293
|
+
# The dispatcher. `che` with no arguments opens the interactive menu.
|
|
294
|
+
che() {{
|
|
295
|
+
if [ "$#" -eq 0 ]; then
|
|
296
|
+
"$CHE_PYTHON" "$CHE_BIN/che.py"
|
|
297
|
+
else
|
|
298
|
+
"$CHE_PYTHON" "$CHE_BIN/che.py" "$@"
|
|
299
|
+
che_notify 7 $?
|
|
300
|
+
fi
|
|
301
|
+
}}"""
|
|
302
|
+
|
|
303
|
+
tail_by_shell = {
|
|
304
|
+
"zsh": """
|
|
305
|
+
# Completions. compdef only exists once compinit has run; when it has not,
|
|
306
|
+
# skip quietly rather than erroring at shell start.
|
|
307
|
+
if whence -w compdef >/dev/null 2>&1 && [ -r "${CHE_HOME}/shell/completions/_che" ]; then
|
|
308
|
+
. "${CHE_HOME}/shell/completions/_che"
|
|
309
|
+
fi""",
|
|
310
|
+
"bash": """
|
|
311
|
+
# Completions, when this is bash and the completion builtin is available.
|
|
312
|
+
if [ -n "${BASH_VERSION:-}" ] && [ -r "${CHE_HOME}/shell/completions/che.bash" ]; then
|
|
313
|
+
. "${CHE_HOME}/shell/completions/che.bash"
|
|
314
|
+
fi""",
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
guard = {"zsh": ZSH_ALIAS_GUARD, "bash": BASH_ALIAS_GUARD}[shell]
|
|
318
|
+
|
|
319
|
+
return "\n".join(
|
|
320
|
+
[
|
|
321
|
+
head,
|
|
322
|
+
guard.rstrip("\n"),
|
|
323
|
+
"",
|
|
324
|
+
_stash_call(),
|
|
325
|
+
*_posix_wrappers(),
|
|
326
|
+
"",
|
|
327
|
+
"_che_restore_aliases",
|
|
328
|
+
"",
|
|
329
|
+
tail_by_shell[shell].strip("\n"),
|
|
330
|
+
"",
|
|
331
|
+
]
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ---------------------------------------------------------------------------
|
|
336
|
+
# fish
|
|
337
|
+
# ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _fish_quote(text: str) -> str:
|
|
341
|
+
return "'" + text.replace("\\", "\\\\").replace("'", "\\'") + "'"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _render_fish() -> str:
|
|
345
|
+
lines = [
|
|
346
|
+
_banner(),
|
|
347
|
+
"",
|
|
348
|
+
# No `cd` here: fish runs command substitution in the current process,
|
|
349
|
+
# so `(cd ...; and pwd)` would move the user's shell as a side effect.
|
|
350
|
+
"if not set -q CHE_HOME",
|
|
351
|
+
" set -l __che_dir (dirname (status --current-filename))/..",
|
|
352
|
+
" if type -q path",
|
|
353
|
+
" set -gx CHE_HOME (path resolve $__che_dir)",
|
|
354
|
+
" else",
|
|
355
|
+
" set -gx CHE_HOME $__che_dir",
|
|
356
|
+
" end",
|
|
357
|
+
" set -e __che_dir",
|
|
358
|
+
"end",
|
|
359
|
+
'set -gx CHE_BIN "$CHE_HOME/bin"',
|
|
360
|
+
"if not set -q CHE_PYTHON",
|
|
361
|
+
" set -gx CHE_PYTHON python3",
|
|
362
|
+
"end",
|
|
363
|
+
'set -gx SHELL_FUNCTIONS_BIN "$CHE_BIN"',
|
|
364
|
+
"",
|
|
365
|
+
"# Play a wrapper's completion sound, then return the status it was given.",
|
|
366
|
+
"function che_notify --description 'che: completion sound'",
|
|
367
|
+
" set -l code 0",
|
|
368
|
+
" if set -q argv[2]",
|
|
369
|
+
" set code $argv[2]",
|
|
370
|
+
" end",
|
|
371
|
+
' if test "$CHE_SOUNDS" != "0"',
|
|
372
|
+
' if type -q "playsound-$argv[1]"',
|
|
373
|
+
' "playsound-$argv[1]" >/dev/null 2>&1',
|
|
374
|
+
" end",
|
|
375
|
+
" end",
|
|
376
|
+
" return $code",
|
|
377
|
+
"end",
|
|
378
|
+
"",
|
|
379
|
+
"function che --description 'che: shell helpers dispatcher'",
|
|
380
|
+
" if test (count $argv) -eq 0",
|
|
381
|
+
' "$CHE_PYTHON" "$CHE_BIN/che.py"',
|
|
382
|
+
" else",
|
|
383
|
+
' "$CHE_PYTHON" "$CHE_BIN/che.py" $argv',
|
|
384
|
+
" che_notify 7 $status",
|
|
385
|
+
" end",
|
|
386
|
+
"end",
|
|
387
|
+
]
|
|
388
|
+
|
|
389
|
+
for category in CATEGORIES:
|
|
390
|
+
members = [command for command in COMMANDS if command.category == category.key]
|
|
391
|
+
if not members:
|
|
392
|
+
continue
|
|
393
|
+
lines += ["", f"# {'-' * 74}", f"# {category.title} - {category.blurb}", f"# {'-' * 74}"]
|
|
394
|
+
for command in members:
|
|
395
|
+
for name, dry in _dry_variants(command):
|
|
396
|
+
env, args = command.invocation(dry=dry)
|
|
397
|
+
summary = command.summary + (" (preview only)" if dry else "")
|
|
398
|
+
lines += ["", f"function {name} --description {_fish_quote(summary)}"]
|
|
399
|
+
|
|
400
|
+
if command.body:
|
|
401
|
+
# fish has no `$1`; the sh body's positional becomes $argv[1].
|
|
402
|
+
lines.append(" " + command.body.replace("$1", "$argv[1]"))
|
|
403
|
+
else:
|
|
404
|
+
prefix = "".join(f"{key}={value} " for key, value in sorted(env.items()))
|
|
405
|
+
argv = "".join(f" {shlex.quote(arg)}" for arg in args)
|
|
406
|
+
runner = "env " + prefix if prefix else ""
|
|
407
|
+
lines.append(
|
|
408
|
+
f' {runner}"$CHE_PYTHON" "$CHE_BIN/{command.script}"{argv} $argv'
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
sound = command.dry_sound if dry else command.sound
|
|
412
|
+
if sound is not None:
|
|
413
|
+
lines.append(f" che_notify {sound} $status")
|
|
414
|
+
lines.append("end")
|
|
415
|
+
|
|
416
|
+
lines += [
|
|
417
|
+
"",
|
|
418
|
+
'if test -r "$CHE_HOME/shell/completions/che.fish"',
|
|
419
|
+
' source "$CHE_HOME/shell/completions/che.fish"',
|
|
420
|
+
"end",
|
|
421
|
+
"",
|
|
422
|
+
]
|
|
423
|
+
return "\n".join(lines)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def wrappers(shell: str) -> str:
|
|
427
|
+
"""The generated wrapper file for ``shell``."""
|
|
428
|
+
if shell == "fish":
|
|
429
|
+
return _render_fish()
|
|
430
|
+
if shell in ("zsh", "bash"):
|
|
431
|
+
return _render_posix(shell)
|
|
432
|
+
raise ValueError(f"unsupported shell: {shell}")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# ---------------------------------------------------------------------------
|
|
436
|
+
# Completions
|
|
437
|
+
# ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _all_completable() -> list[tuple[str, Command, bool]]:
|
|
441
|
+
entries: list[tuple[str, Command, bool]] = []
|
|
442
|
+
for command in (*COMMANDS, *BUILTINS):
|
|
443
|
+
for name, dry in _dry_variants(command):
|
|
444
|
+
entries.append((name, command, dry))
|
|
445
|
+
return entries
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _zsh_completions() -> str:
|
|
449
|
+
describe = []
|
|
450
|
+
for name, command, dry in _all_completable():
|
|
451
|
+
summary = command.summary + (" (preview)" if dry else "")
|
|
452
|
+
describe.append(f" {shlex.quote(f'{name}:{summary}')}")
|
|
453
|
+
|
|
454
|
+
cases = []
|
|
455
|
+
for name, command, _ in _all_completable():
|
|
456
|
+
cases.append(f" {name}) print -r -- {shlex.quote(' '.join(_flag_words(command)))} ;;")
|
|
457
|
+
|
|
458
|
+
wrapper_names = " ".join(name for name, command, _ in _all_completable() if not command.builtin)
|
|
459
|
+
|
|
460
|
+
return f"""#compdef che
|
|
461
|
+
{_banner()}
|
|
462
|
+
|
|
463
|
+
_che_flags_for() {{
|
|
464
|
+
case "$1" in
|
|
465
|
+
{chr(10).join(cases)}
|
|
466
|
+
*) print -r -- "--help" ;;
|
|
467
|
+
esac
|
|
468
|
+
}}
|
|
469
|
+
|
|
470
|
+
_che_flag_complete() {{
|
|
471
|
+
local cmd="$1" cur="${{words[CURRENT]}}"
|
|
472
|
+
|
|
473
|
+
if [[ "$cur" == *=* ]]; then
|
|
474
|
+
compset -P '*='
|
|
475
|
+
_files
|
|
476
|
+
return
|
|
477
|
+
fi
|
|
478
|
+
|
|
479
|
+
local -a flags
|
|
480
|
+
flags=( ${{=$(_che_flags_for "$cmd")}} )
|
|
481
|
+
compadd -S '' -- $flags
|
|
482
|
+
_files
|
|
483
|
+
}}
|
|
484
|
+
|
|
485
|
+
_che() {{
|
|
486
|
+
local -a subcommands
|
|
487
|
+
subcommands=(
|
|
488
|
+
{chr(10).join(describe)}
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
if (( CURRENT == 2 )); then
|
|
492
|
+
_describe -t commands 'che command' subcommands
|
|
493
|
+
return
|
|
494
|
+
fi
|
|
495
|
+
|
|
496
|
+
_che_flag_complete "${{words[2]}}"
|
|
497
|
+
}}
|
|
498
|
+
|
|
499
|
+
_che_wrapper() {{
|
|
500
|
+
_che_flag_complete "${{words[1]}}"
|
|
501
|
+
}}
|
|
502
|
+
|
|
503
|
+
compdef _che che
|
|
504
|
+
compdef _che_wrapper {wrapper_names}
|
|
505
|
+
"""
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _bash_completions() -> str:
|
|
509
|
+
cases = []
|
|
510
|
+
for name, command, _ in _all_completable():
|
|
511
|
+
cases.append(f' {name}) echo "{" ".join(_flag_words(command))}" ;;')
|
|
512
|
+
|
|
513
|
+
names = " ".join(name for name, _, _ in _all_completable())
|
|
514
|
+
wrapper_names = " ".join(name for name, command, _ in _all_completable() if not command.builtin)
|
|
515
|
+
|
|
516
|
+
return f"""{_banner()}
|
|
517
|
+
# shellcheck shell=bash
|
|
518
|
+
|
|
519
|
+
_che_flags_for() {{
|
|
520
|
+
case "$1" in
|
|
521
|
+
{chr(10).join(cases)}
|
|
522
|
+
*) echo "--help" ;;
|
|
523
|
+
esac
|
|
524
|
+
}}
|
|
525
|
+
|
|
526
|
+
# `mapfile` is bash 4+, and macOS still ships bash 3.2, so read the candidates
|
|
527
|
+
# a line at a time instead - which also keeps filenames with spaces intact.
|
|
528
|
+
_che_reply() {{
|
|
529
|
+
local prefix="$1" line
|
|
530
|
+
COMPREPLY=()
|
|
531
|
+
while IFS= read -r line; do
|
|
532
|
+
COMPREPLY+=( "$prefix$line" )
|
|
533
|
+
done
|
|
534
|
+
}}
|
|
535
|
+
|
|
536
|
+
_che_complete_for() {{
|
|
537
|
+
local cmd="$1" cur="$2" prefix value
|
|
538
|
+
|
|
539
|
+
case "$cur" in
|
|
540
|
+
*=*)
|
|
541
|
+
prefix="${{cur%%=*}}="
|
|
542
|
+
value="${{cur#*=}}"
|
|
543
|
+
_che_reply "$prefix" < <(compgen -f -- "$value")
|
|
544
|
+
;;
|
|
545
|
+
-*)
|
|
546
|
+
_che_reply "" < <(compgen -W "$(_che_flags_for "$cmd")" -- "$cur")
|
|
547
|
+
;;
|
|
548
|
+
*)
|
|
549
|
+
_che_reply "" < <(compgen -f -- "$cur")
|
|
550
|
+
;;
|
|
551
|
+
esac
|
|
552
|
+
}}
|
|
553
|
+
|
|
554
|
+
_che_complete() {{
|
|
555
|
+
local cur="${{COMP_WORDS[COMP_CWORD]}}"
|
|
556
|
+
|
|
557
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
558
|
+
_che_reply "" < <(compgen -W "{names}" -- "$cur")
|
|
559
|
+
return
|
|
560
|
+
fi
|
|
561
|
+
|
|
562
|
+
_che_complete_for "${{COMP_WORDS[1]}}" "$cur"
|
|
563
|
+
}}
|
|
564
|
+
|
|
565
|
+
_che_complete_wrapper() {{
|
|
566
|
+
_che_complete_for "${{COMP_WORDS[0]}}" "${{COMP_WORDS[COMP_CWORD]}}"
|
|
567
|
+
}}
|
|
568
|
+
|
|
569
|
+
complete -o nospace -F _che_complete che
|
|
570
|
+
complete -o nospace -F _che_complete_wrapper {wrapper_names}
|
|
571
|
+
"""
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _fish_completions() -> str:
|
|
575
|
+
lines = [_banner(), ""]
|
|
576
|
+
lines.append("complete -c che -f")
|
|
577
|
+
for name, command, dry in _all_completable():
|
|
578
|
+
summary = command.summary + (" (preview)" if dry else "")
|
|
579
|
+
lines.append(
|
|
580
|
+
f"complete -c che -n __fish_use_subcommand -a {name} -d {_fish_quote(summary)}"
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
lines.append("")
|
|
584
|
+
for name, command, _ in _all_completable():
|
|
585
|
+
for flag in command.flags:
|
|
586
|
+
option = flag.removeprefix("--")
|
|
587
|
+
takes_value = " -r" if flag in VALUE_FLAGS else ""
|
|
588
|
+
paths = " -F" if flag in PATH_FLAGS else ""
|
|
589
|
+
lines.append(
|
|
590
|
+
f'complete -c che -n "__fish_seen_subcommand_from {name}" '
|
|
591
|
+
f"-l {option}{takes_value}{paths}"
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
lines.append("")
|
|
595
|
+
for name, command, _ in _all_completable():
|
|
596
|
+
if command.builtin:
|
|
597
|
+
continue
|
|
598
|
+
for flag in command.flags:
|
|
599
|
+
option = flag.removeprefix("--")
|
|
600
|
+
takes_value = " -r" if flag in VALUE_FLAGS else ""
|
|
601
|
+
paths = " -F" if flag in PATH_FLAGS else ""
|
|
602
|
+
lines.append(f"complete -c {name} -l {option}{takes_value}{paths}")
|
|
603
|
+
|
|
604
|
+
lines.append("")
|
|
605
|
+
return "\n".join(lines)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def completions(shell: str) -> str:
|
|
609
|
+
if shell == "zsh":
|
|
610
|
+
return _zsh_completions()
|
|
611
|
+
if shell == "bash":
|
|
612
|
+
return _bash_completions()
|
|
613
|
+
if shell == "fish":
|
|
614
|
+
return _fish_completions()
|
|
615
|
+
raise ValueError(f"unsupported shell: {shell}")
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
# ---------------------------------------------------------------------------
|
|
619
|
+
# The rc-file block, and the repo's own .zshrc compatibility shim
|
|
620
|
+
# ---------------------------------------------------------------------------
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def rc_block(shell: str, *, home: Path, python: str, sounds: bool = True) -> str:
|
|
624
|
+
"""The machine-specific snippet ``che install`` writes into an rc file.
|
|
625
|
+
|
|
626
|
+
Deliberately tiny: a path, an interpreter and a source line. Everything
|
|
627
|
+
that can be generic lives in the committed ``shell/che.*`` file instead, so
|
|
628
|
+
upgrading che never means rewriting the user's rc file.
|
|
629
|
+
"""
|
|
630
|
+
wrapper = home / WRAPPER_FILES[shell]
|
|
631
|
+
|
|
632
|
+
if shell == "fish":
|
|
633
|
+
body = [
|
|
634
|
+
BEGIN_MARKER,
|
|
635
|
+
f"# Managed by `che install` (che {VERSION}). Edits here are overwritten.",
|
|
636
|
+
f"set -gx CHE_HOME {_fish_quote(str(home))}",
|
|
637
|
+
f"set -gx CHE_PYTHON {_fish_quote(python)}",
|
|
638
|
+
]
|
|
639
|
+
if not sounds:
|
|
640
|
+
body.append("set -gx CHE_SOUNDS 0")
|
|
641
|
+
body += [
|
|
642
|
+
f"if test -r {_fish_quote(str(wrapper))}",
|
|
643
|
+
f" source {_fish_quote(str(wrapper))}",
|
|
644
|
+
"end",
|
|
645
|
+
END_MARKER,
|
|
646
|
+
]
|
|
647
|
+
return "\n".join(body) + "\n"
|
|
648
|
+
|
|
649
|
+
body = [
|
|
650
|
+
BEGIN_MARKER,
|
|
651
|
+
f"# Managed by `che install` (che {VERSION}). Edits here are overwritten.",
|
|
652
|
+
f"export CHE_HOME={shlex.quote(str(home))}",
|
|
653
|
+
f"export CHE_PYTHON={shlex.quote(python)}",
|
|
654
|
+
]
|
|
655
|
+
if not sounds:
|
|
656
|
+
body.append("export CHE_SOUNDS=0")
|
|
657
|
+
body += [
|
|
658
|
+
f"[ -r {shlex.quote(str(wrapper))} ] && . {shlex.quote(str(wrapper))}",
|
|
659
|
+
END_MARKER,
|
|
660
|
+
]
|
|
661
|
+
return "\n".join(body) + "\n"
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def legacy_zshrc() -> str:
|
|
665
|
+
"""The repo's own ``.zshrc``, kept as a one-line shim.
|
|
666
|
+
|
|
667
|
+
It used to hold every wrapper by hand, and the documented workflow was to
|
|
668
|
+
copy them into ``~/.zshrc`` - which meant the two drifted. Anyone still
|
|
669
|
+
sourcing this file keeps working; the wrappers themselves now come from
|
|
670
|
+
``shell/che.zsh``.
|
|
671
|
+
"""
|
|
672
|
+
return f"""{_banner()}
|
|
673
|
+
#
|
|
674
|
+
# The wrappers moved to shell/che.zsh and are generated from bin/commands.py.
|
|
675
|
+
# `che install` writes a block into ~/.zshrc that sources them; this file
|
|
676
|
+
# stays so that an existing `source .../shell-functions/.zshrc` keeps working.
|
|
677
|
+
|
|
678
|
+
CHE_HOME="${{CHE_HOME:-${{${{(%):-%x}}:A:h}}}}"
|
|
679
|
+
. "${{CHE_HOME}}/shell/che.zsh"
|
|
680
|
+
"""
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def generated_files() -> dict[Path, str]:
|
|
684
|
+
"""Every generated path mapped to its expected contents, relative to the repo."""
|
|
685
|
+
files: dict[Path, str] = {}
|
|
686
|
+
for shell in SHELLS:
|
|
687
|
+
files[WRAPPER_FILES[shell]] = wrappers(shell)
|
|
688
|
+
files[COMPLETION_FILES[shell]] = completions(shell)
|
|
689
|
+
files[Path(".zshrc")] = legacy_zshrc()
|
|
690
|
+
return files
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
# ---------------------------------------------------------------------------
|
|
694
|
+
# README islands
|
|
695
|
+
#
|
|
696
|
+
# The parts of the README that restate the manifest - the command table, the
|
|
697
|
+
# preview twins, the menu keys, the external tools - are generated between
|
|
698
|
+
# markers and checked by `bun run ci`. Prose stays hand-written; facts that
|
|
699
|
+
# have a single source of truth are not retyped where they can go stale.
|
|
700
|
+
# ---------------------------------------------------------------------------
|
|
701
|
+
|
|
702
|
+
README = Path("README.md")
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _marker(name: str) -> tuple[str, str]:
|
|
706
|
+
return f"<!-- che:{name} -->", f"<!-- /che:{name} -->"
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _commands_table() -> str:
|
|
710
|
+
rows = ["| Command | Does | Preview |", "| --- | --- | --- |"]
|
|
711
|
+
for category in CATEGORIES:
|
|
712
|
+
members = [command for command in COMMANDS if command.category == category.key]
|
|
713
|
+
if not members:
|
|
714
|
+
continue
|
|
715
|
+
rows.append(f"| **{category.title}** | *{category.blurb}* | |")
|
|
716
|
+
for command in members:
|
|
717
|
+
preview = f"`{command.dry_name}`" if command.has_dry_twin else ""
|
|
718
|
+
if not preview and command.dry_run == "flag":
|
|
719
|
+
preview = "`--dry-run`"
|
|
720
|
+
if not preview and any(p.flag == "--dry-run" for p in command.prompts):
|
|
721
|
+
preview = "`--dry-run`"
|
|
722
|
+
if not preview and command.no_preview_reason:
|
|
723
|
+
preview = "none"
|
|
724
|
+
rows.append(f"| `{command.name}` | {command.summary} | {preview} |")
|
|
725
|
+
return "\n".join(rows)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _builtins_table() -> str:
|
|
729
|
+
rows = ["| Command | Does |", "| --- | --- |"]
|
|
730
|
+
for command in BUILTINS:
|
|
731
|
+
rows.append(f"| `che {command.name}` | {command.summary} |")
|
|
732
|
+
return "\n".join(rows)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _keys_table() -> str:
|
|
736
|
+
rows = ["| Key | Does |", "| --- | --- |"]
|
|
737
|
+
for key, description in MENU_KEYS:
|
|
738
|
+
rows.append(f"| `{key}` | {description} |")
|
|
739
|
+
return "\n".join(rows)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _tools_table() -> str:
|
|
743
|
+
rows = ["| Tool | Needed by |", "| --- | --- |"]
|
|
744
|
+
for binary, users in required_binaries().items():
|
|
745
|
+
names = ", ".join(f"`{name}`" for name in users)
|
|
746
|
+
rows.append(f"| `{binary}` | {names} |")
|
|
747
|
+
|
|
748
|
+
extra = [command for command in COMMANDS if command.setup]
|
|
749
|
+
for command in extra:
|
|
750
|
+
rows.append(f"| (not a binary) | `{command.name}` {command.setup} |")
|
|
751
|
+
return "\n".join(rows)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def readme_islands() -> dict[str, str]:
|
|
755
|
+
return {
|
|
756
|
+
"commands": _commands_table(),
|
|
757
|
+
"builtins": _builtins_table(),
|
|
758
|
+
"keys": _keys_table(),
|
|
759
|
+
"tools": _tools_table(),
|
|
760
|
+
"count": (
|
|
761
|
+
f"{len(COMMANDS)} commands across {len({c.category for c in COMMANDS})} categories, "
|
|
762
|
+
f"plus {len(BUILTINS)} built into `che` itself"
|
|
763
|
+
),
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def render_readme(current: str) -> str:
|
|
768
|
+
"""Replace every marked island in ``current`` with what the manifest says.
|
|
769
|
+
|
|
770
|
+
Unmarked prose is left exactly as written. A marker that is missing from
|
|
771
|
+
the README is reported rather than silently skipped, because a section that
|
|
772
|
+
quietly stopped being checked is worse than one that was never checked.
|
|
773
|
+
"""
|
|
774
|
+
updated = current
|
|
775
|
+
for name, body in readme_islands().items():
|
|
776
|
+
begin, end = _marker(name)
|
|
777
|
+
if begin not in updated or end not in updated:
|
|
778
|
+
raise ValueError(f"README.md is missing the {begin} … {end} markers")
|
|
779
|
+
head, _, rest = updated.partition(begin)
|
|
780
|
+
_, _, tail = rest.partition(end)
|
|
781
|
+
updated = f"{head}{begin}\n{body}\n{end}{tail}"
|
|
782
|
+
return updated
|