@codebend3r/che 1.1.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.
Files changed (53) hide show
  1. package/README.md +38 -24
  2. package/bin/art.py +22 -20
  3. package/bin/che.py +208 -587
  4. package/bin/commands.py +220 -231
  5. package/bin/doctor.py +220 -0
  6. package/bin/drives/eject-all-drives.py +21 -38
  7. package/bin/drives/mount-all-drives.py +18 -62
  8. package/bin/drives/ping-nas.py +77 -105
  9. package/bin/files/compress-folders.py +7 -11
  10. package/bin/files/delete-by-ext.py +10 -14
  11. package/bin/files/delete-empty-folders.py +33 -37
  12. package/bin/files/delete-smb-files.py +12 -21
  13. package/bin/files/files-under-size.py +5 -11
  14. package/bin/files/find-largest-files.py +21 -25
  15. package/bin/files/list-permission.py +40 -0
  16. package/bin/files/make-alpha-dir.py +2 -0
  17. package/bin/git/all-actions.py +350 -253
  18. package/bin/git/checkout-my-branches.py +42 -63
  19. package/bin/git/clean-stale-branches.py +23 -48
  20. package/bin/git/prune-worktrees.py +12 -51
  21. package/bin/git/sync-all-branches.py +54 -283
  22. package/bin/git/update-local-branches.py +67 -73
  23. package/bin/gitutils.py +263 -0
  24. package/bin/install.py +393 -1182
  25. package/bin/machine.py +345 -0
  26. package/bin/media.py +127 -0
  27. package/bin/menu.py +437 -0
  28. package/bin/nas.py +102 -0
  29. package/bin/rcfile.py +380 -0
  30. package/bin/shellgen.py +503 -431
  31. package/bin/system/btop-launch.py +17 -13
  32. package/bin/system/update-brew.py +57 -68
  33. package/bin/tui.py +156 -153
  34. package/bin/utils.py +165 -237
  35. package/bin/video/delete-duplicate-videos.py +64 -62
  36. package/bin/video/detect-green-magenta-videos.py +105 -95
  37. package/bin/video/find-movie-by-year.py +3 -0
  38. package/bin/video/find-video-mkv-issues.py +48 -65
  39. package/bin/video/fix-codecs.py +25 -30
  40. package/bin/video/largest-tv-shows.py +60 -57
  41. package/bin/video/remove-metadata.py +3 -6
  42. package/bin/video/rename-video-file.py +74 -84
  43. package/bin/video/scan-videos-audio-language.py +16 -33
  44. package/bin/video/show-codecs.py +20 -24
  45. package/bin/video/validate-video-files.py +5 -8
  46. package/bin/video/video-list.py +17 -20
  47. package/package.json +4 -10
  48. package/shell/che.bash +33 -33
  49. package/shell/che.fish +7 -7
  50. package/shell/che.zsh +33 -33
  51. package/shell/completions/_che +19 -19
  52. package/shell/completions/che.bash +15 -15
  53. package/shell/completions/che.fish +34 -50
package/bin/shellgen.py CHANGED
@@ -1,49 +1,47 @@
1
1
  """Render the shell wrappers and completions from :mod:`commands`.
2
2
 
3
- Everything under ``shell/`` is produced here, and ``che install`` writes the
4
- result to disk. Two rules keep the generated files honest:
3
+ Everything under ``shell/`` is produced here, along with the README's
4
+ generated islands, and :func:`sync_generated` writes them to disk for both
5
+ ``che install`` and ``bun run generate`` (which runs this module). Two rules
6
+ keep the generated files honest:
5
7
 
6
8
  * **No machine-specific data.** These files are committed, so they must work
7
9
  from any clone path. The one machine-specific thing - where the repo lives
8
10
  and which ``python3`` to use - goes in the small block the installer writes
9
11
  into ``~/.zshrc``, not here. As a fallback the wrappers resolve their own
10
12
  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.
13
+ * **Deterministic.** A test re-renders and diffs against the files on disk,
14
+ and ``bun run ci`` runs it, so generation may not depend on the environment
15
+ it runs in.
14
16
 
15
- Library module: no shebang, not executable.
17
+ Library module: no shebang, not executable. ``python3 bin/shellgen.py``
18
+ rewrites every generated file.
16
19
  """
17
20
 
18
21
  from __future__ import annotations
19
22
 
23
+ import re
20
24
  import shlex
25
+ from collections.abc import Callable, Iterable, Sequence
26
+ from dataclasses import dataclass
21
27
  from pathlib import Path
22
28
 
23
29
  import art
24
30
  from commands import (
25
31
  BUILTINS,
26
- CATEGORIES,
27
32
  COMMANDS,
28
33
  MENU_KEYS,
34
+ REPO,
29
35
  VERSION,
30
36
  Command,
37
+ Resolved,
38
+ all_commands,
39
+ grouped,
31
40
  required_binaries,
41
+ variants,
42
+ wrapper_names,
32
43
  )
33
-
34
- SHELLS = ("zsh", "bash", "fish")
35
-
36
- # Where each shell's generated file lives, relative to the repo root.
37
- WRAPPER_FILES = {
38
- "zsh": Path("shell/che.zsh"),
39
- "bash": Path("shell/che.bash"),
40
- "fish": Path("shell/che.fish"),
41
- }
42
- COMPLETION_FILES = {
43
- "zsh": Path("shell/completions/_che"),
44
- "bash": Path("shell/completions/che.bash"),
45
- "fish": Path("shell/completions/che.fish"),
46
- }
44
+ from utils import atomic_write_text
47
45
 
48
46
  # The rc-file block is delimited by these. Anything between them belongs to
49
47
  # `che install` and is rewritten wholesale on every install; anything outside
@@ -51,42 +49,10 @@ COMPLETION_FILES = {
51
49
  BEGIN_MARKER = "# >>> che shell functions >>>"
52
50
  END_MARKER = "# <<< che shell functions <<<"
53
51
 
54
- # Flags that take a value, so completion can offer `--flag=` without a
55
- # trailing space and then complete filenames after the `=`.
56
- VALUE_FLAGS = frozenset(
57
- {
58
- "--author",
59
- "--category",
60
- "--exts",
61
- "--ext",
62
- "--ignore-words",
63
- "--interval",
64
- "--length",
65
- "--limit",
66
- "--only",
67
- "--owner",
68
- "--path",
69
- "--ping-timeout",
70
- "--pr-limit",
71
- "--protect",
72
- "--samples",
73
- "--shell",
74
- "--shells",
75
- "--size",
76
- "--sort",
77
- "--strategy",
78
- "--threshold",
79
- "--year",
80
- }
81
- )
82
-
83
- # Flags whose value is a path, so completion offers files rather than nothing.
84
- PATH_FLAGS = frozenset({"--path"})
85
-
86
52
 
87
- def _banner(comment: str = "#") -> str:
53
+ def _banner() -> str:
88
54
  return "\n".join(
89
- f"{comment} {line}".rstrip()
55
+ f"# {line}".rstrip()
90
56
  for line in (
91
57
  f"che {VERSION} - generated file, do not edit.",
92
58
  "",
@@ -98,66 +64,215 @@ def _banner(comment: str = "#") -> str:
98
64
 
99
65
  def _flag_words(command: Command) -> list[str]:
100
66
  """Completion words for one command: its flags plus the universal ones."""
101
- words = [f"{flag}=" if flag in VALUE_FLAGS else flag for flag in command.flags]
102
- words.append("--help")
103
- return words
67
+ return [*command.flags, "--help"]
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Dialects: how each family of shells spells the same thing
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ class Dialect:
76
+ """The syntax one family of shells writes the generated code in."""
77
+
78
+ indent: str
79
+ all_args: str # every argument the function was called with
80
+ status: str # the last command's exit status
81
+ env_runner: str # what has to precede `NAME=value command`
82
+ end: str # closes a function
83
+
84
+ @staticmethod
85
+ def quote(text: str) -> str:
86
+ raise NotImplementedError
87
+
88
+ def header(self, name: str, summary: str) -> list[str]:
89
+ """The lines that open a function definition."""
90
+ raise NotImplementedError
91
+
92
+ def file_head(self, shell: Shell) -> str:
93
+ """Everything a wrapper file sets up before its first wrapper."""
94
+ raise NotImplementedError
95
+
96
+ def file_tail(self, shell: Shell) -> str:
97
+ """Everything a wrapper file does after its last wrapper."""
98
+ raise NotImplementedError
99
+
100
+ def export(self, name: str, word: str) -> str:
101
+ raise NotImplementedError
102
+
103
+ def source_if_readable(self, path: Path) -> str:
104
+ raise NotImplementedError
105
+
106
+ def add_to_path(self, directory: Path) -> str:
107
+ """Prepend ``directory`` to PATH unless it is already there."""
108
+ raise NotImplementedError
109
+
110
+ def wrapper(self, variant: Resolved) -> list[str]:
111
+ """One wrapper function.
112
+
113
+ DELIBERATE DIVERGENCE from the hand-written wrappers: every wrapper now
114
+ forwards ``"$@"``. A few of them (clean-stale-branches, prune-worktrees)
115
+ used to swallow their arguments, so ``clean-stale-branches --protect=x``
116
+ silently ignored a flag the script documents.
117
+ """
118
+ assignments = "".join(
119
+ f"{key}={self.quote(value)} " for key, value in sorted(variant.env.items())
120
+ )
121
+ runner = self.env_runner + assignments if assignments else ""
122
+ argv = "".join(f" {self.quote(arg)}" for arg in variant.args)
123
+ call = f'{runner}"$CHE_PYTHON" "$CHE_BIN/{variant.command.script}"{argv} {self.all_args}'
124
+
125
+ lines = [*self.header(variant.name, variant.summary("preview only")), self.indent + call]
126
+ if variant.sound is not None:
127
+ # `$?` is expanded before che_notify runs, so the wrapper still returns
128
+ # the script's exit status. The old wrappers returned playsound's.
129
+ lines.append(f"{self.indent}che_notify {variant.sound} {self.status}")
130
+ return [*lines, self.end]
131
+
132
+
133
+ class _Posix(Dialect):
134
+ indent = " "
135
+ all_args = '"$@"'
136
+ status = "$?"
137
+ env_runner = ""
138
+ end = "}"
139
+
140
+ @staticmethod
141
+ def quote(text: str) -> str:
142
+ return shlex.quote(text)
143
+
144
+ def header(self, name: str, summary: str) -> list[str]:
145
+ return [f"# {summary}", f"{name}() {{"]
146
+
147
+ def file_head(self, shell: Shell) -> str:
148
+ return f"""{_banner()}
149
+ #
150
+ # Source this file (or let the `che install` block in your rc file do it):
151
+ #
152
+ # . ~/Developer/git/che/{shell.wrapper_file}
153
+ #
154
+ # Every wrapper is a shell function, so `type <name>` shows what it runs and
155
+ # `che doctor` can tell you when something it needs is missing.
156
+
157
+ # CHE_HOME is normally exported by the installed rc block. Fall back to this
158
+ # file's own location so a bare `source` of it also works.
159
+ if [ -z "${{CHE_HOME:-}}" ]; then
160
+ {shell.find_home}
161
+ fi
162
+ {POSIX_EXPORTS}{shell.alias_guard}
163
+ {_stash_call()}
164
+ {POSIX_FUNCTIONS.rstrip()}"""
165
+
166
+ def file_tail(self, shell: Shell) -> str:
167
+ return f"_che_restore_aliases\n\n{shell.load_completions}"
168
+
169
+ def export(self, name: str, word: str) -> str:
170
+ return f"export {name}={word}"
171
+
172
+ def source_if_readable(self, path: Path) -> str:
173
+ quoted = self.quote(str(path))
174
+ return f"[ -r {quoted} ] && . {quoted}"
175
+
176
+ def add_to_path(self, directory: Path) -> str:
177
+ quoted = self.quote(str(directory))
178
+ return f'case ":$PATH:" in *:{quoted}:*) ;; *) PATH={quoted}:"$PATH" ;; esac'
179
+
180
+
181
+ # The characters shlex.quote leaves unquoted.
182
+ _SAFE_WORD = re.compile(r"[\w@%+=:,./-]+", re.ASCII)
183
+
184
+
185
+ class _Fish(Dialect):
186
+ indent = " "
187
+ all_args = "$argv"
188
+ status = "$status"
189
+ env_runner = "env "
190
+ end = "end"
191
+
192
+ @staticmethod
193
+ def quote(text: str) -> str:
194
+ # Bare when shlex would leave it bare too, so `--dry-run` reads the
195
+ # same in every shell's wrapper.
196
+ if _SAFE_WORD.fullmatch(text):
197
+ return text
198
+ return "'" + text.replace("\\", "\\\\").replace("'", "\\'") + "'"
104
199
 
200
+ def header(self, name: str, summary: str) -> list[str]:
201
+ return [f"function {name} --description {self.quote(summary)}"]
105
202
 
106
- def _dry_variants(command: Command) -> list[tuple[str, bool]]:
107
- variants = [(command.name, False)]
108
- if command.has_dry_twin:
109
- variants.append((command.dry_name, True))
110
- return variants
203
+ def file_head(self, shell: Shell) -> str:
204
+ return f"{_banner()}\n{FISH_PRELUDE}"
205
+
206
+ def file_tail(self, shell: Shell) -> str:
207
+ return shell.load_completions
208
+
209
+ def export(self, name: str, word: str) -> str:
210
+ return f"set -gx {name} {word}"
211
+
212
+ def source_if_readable(self, path: Path) -> str:
213
+ quoted = self.quote(str(path))
214
+ return f"if test -r {quoted}\n source {quoted}\nend"
215
+
216
+ def add_to_path(self, directory: Path) -> str:
217
+ quoted = self.quote(str(directory))
218
+ return f"if not contains {quoted} $PATH\n set -gx PATH {quoted} $PATH\nend"
219
+
220
+
221
+ POSIX = _Posix()
222
+ FISH = _Fish()
111
223
 
112
224
 
113
225
  # ---------------------------------------------------------------------------
114
- # POSIX-family wrappers (zsh, bash, and anything else that speaks sh)
226
+ # The wrapper files
115
227
  # ---------------------------------------------------------------------------
116
228
 
229
+ # What a POSIX wrapper file sets up once it has found CHE_HOME.
230
+ POSIX_EXPORTS = """
231
+ # Exported, not just set: every wrapper expands "$CHE_BIN", and a shell that
232
+ # replays the *functions* without re-reading this file - a `typeset -f` dump, an
233
+ # environment snapshot, any tool that rebuilds a shell from its exported
234
+ # environment - keeps the functions and drops a plain variable, leaving the
235
+ # wrappers to run "/che.py". CHE_HOME comes in exported by the rc block; this
236
+ # keeps everything derived from it exported too.
237
+ export CHE_BIN="${CHE_HOME}/bin"
117
238
 
118
- def _posix_body(command: Command, *, dry: bool) -> list[str]:
119
- """The body of one wrapper.
120
-
121
- DELIBERATE DIVERGENCE from the hand-written wrappers: every wrapper now
122
- forwards ``"$@"``. A few of them (clean-stale-branches, prune-worktrees)
123
- used to swallow their arguments, so ``clean-stale-branches --protect=x``
124
- silently ignored a flag the script documents.
125
- """
126
- env, args = command.invocation(dry=dry)
127
- prefix = "".join(f"{name}={shlex.quote(value)} " for name, value in sorted(env.items()))
128
-
129
- if command.body:
130
- call = command.body
131
- else:
132
- parts = ['"$CHE_PYTHON"', f'"$CHE_BIN/{command.script}"']
133
- parts += [shlex.quote(arg) for arg in args]
134
- parts.append('"$@"')
135
- call = prefix + " ".join(parts)
136
-
137
- sound = command.dry_sound if dry else command.sound
138
- lines = [f" {call}"]
139
- if sound is not None:
140
- # `$?` is expanded before che_notify runs, so the wrapper still returns
141
- # the script's exit status. The old wrappers returned playsound's.
142
- lines.append(f" che_notify {sound} $?")
143
- return lines
239
+ # Any python3 on PATH by default. `che install` records the interpreter it
240
+ # verified (3.12+) in the rc block, which wins over this.
241
+ : "${CHE_PYTHON:=python3}"
242
+ export CHE_PYTHON
144
243
 
244
+ # Legacy name from the hand-maintained wrappers, kept so an older snippet in
245
+ # ~/.zshrc that still refers to it keeps resolving.
246
+ export SHELL_FUNCTIONS_BIN="${CHE_BIN}"
247
+ """
145
248
 
146
- def _posix_wrappers() -> list[str]:
147
- lines: list[str] = []
148
- for category in CATEGORIES:
149
- members = [command for command in COMMANDS if command.category == category.key]
150
- if not members:
151
- continue
152
- lines += ["", f"# {'-' * 74}", f"# {category.title} - {category.blurb}", f"# {'-' * 74}"]
153
- for command in members:
154
- for name, dry in _dry_variants(command):
155
- summary = command.summary + (" (preview only)" if dry else "")
156
- lines += ["", f"# {summary}", f"{name}() {{"]
157
- lines += _posix_body(command, dry=dry)
158
- lines += ["}"]
159
- return lines
249
+ # The two functions every wrapper relies on. They come after the alias guard:
250
+ # an alias named `che` would otherwise break `che() {` exactly the way the
251
+ # guard describes, and take the rest of the file with it.
252
+ POSIX_FUNCTIONS = """
253
+ # Play the completion sound for a wrapper, then return the status it was given.
254
+ #
255
+ # playsound-N is defined outside this repo. Every wrapper used to call it
256
+ # unconditionally, which printed "command not found" on a machine that never
257
+ # had it; here a missing playsound is simply skipped. Set CHE_SOUNDS=0 to
258
+ # silence them all.
259
+ che_notify() {
260
+ if [ "${CHE_SOUNDS:-1}" != "0" ] && command -v "playsound-$1" >/dev/null 2>&1; then
261
+ "playsound-$1" >/dev/null 2>&1
262
+ fi
263
+ return "${2:-0}"
264
+ }
160
265
 
266
+ # The dispatcher. `che` with no arguments opens the interactive menu.
267
+ che() {
268
+ if [ "$#" -eq 0 ]; then
269
+ "$CHE_PYTHON" "$CHE_BIN/che.py"
270
+ else
271
+ "$CHE_PYTHON" "$CHE_BIN/che.py" "$@"
272
+ che_notify 7 $?
273
+ fi
274
+ }
275
+ """
161
276
 
162
277
  ZSH_ALIAS_GUARD = """
163
278
  # An alias sharing a name with a wrapper below is fatal, not cosmetic: zsh
@@ -217,19 +332,60 @@ _che_restore_aliases() {
217
332
  }
218
333
  """
219
334
 
220
-
221
- def _guarded_names() -> list[str]:
222
- """Every name the file defines, in the order the guard should clear them."""
223
- from commands import wrapper_names
224
-
225
- return ["che", *wrapper_names()]
335
+ # fish needs no alias guard, so its whole setup is one block. No `cd` in it:
336
+ # fish runs command substitution in the current process, so `(cd ...; and pwd)`
337
+ # would move the user's shell as a side effect.
338
+ FISH_PRELUDE = """
339
+ if not set -q CHE_HOME
340
+ set -l __che_dir (dirname (status --current-filename))/..
341
+ if type -q path
342
+ set -gx CHE_HOME (path resolve $__che_dir)
343
+ else
344
+ set -gx CHE_HOME $__che_dir
345
+ end
346
+ set -e __che_dir
347
+ end
348
+ set -gx CHE_BIN "$CHE_HOME/bin"
349
+ if not set -q CHE_PYTHON
350
+ set -gx CHE_PYTHON python3
351
+ end
352
+ set -gx SHELL_FUNCTIONS_BIN "$CHE_BIN"
353
+
354
+ # Play a wrapper's completion sound, then return the status it was given.
355
+ function che_notify --description 'che: completion sound'
356
+ set -l code 0
357
+ if set -q argv[2]
358
+ set code $argv[2]
359
+ end
360
+ if test "$CHE_SOUNDS" != "0"
361
+ if type -q "playsound-$argv[1]"
362
+ "playsound-$argv[1]" >/dev/null 2>&1
363
+ end
364
+ end
365
+ return $code
366
+ end
367
+
368
+ function che --description 'che: shell helpers dispatcher'
369
+ if test (count $argv) -eq 0
370
+ "$CHE_PYTHON" "$CHE_BIN/che.py"
371
+ else
372
+ "$CHE_PYTHON" "$CHE_BIN/che.py" $argv
373
+ che_notify 7 $status
374
+ end
375
+ end"""
376
+
377
+
378
+ def defined_names() -> list[str]:
379
+ """Every function name a wrapper file defines: the sound helper, the
380
+ dispatcher, then every wrapper."""
381
+ return ["che_notify", "che", *wrapper_names()]
226
382
 
227
383
 
228
384
  def _stash_call() -> str:
229
- """`_che_stash_aliases` invocation, wrapped so the line stays readable."""
385
+ """`_che_stash_aliases` for every name the file defines, wrapped to stay readable."""
230
386
  lines: list[str] = []
231
387
  current = "_che_stash_aliases"
232
- for name in _guarded_names():
388
+ for name in defined_names():
233
389
  candidate = f"{current} {name}"
234
390
  if len(candidate) > 76:
235
391
  lines.append(f"{current} \\")
@@ -240,197 +396,23 @@ def _stash_call() -> str:
240
396
  return "\n".join(lines)
241
397
 
242
398
 
243
- def _render_posix(shell: str) -> str:
244
- resolve_self = {
245
- "zsh": ' CHE_HOME="${${(%):-%x}:A:h:h}"',
246
- "bash": ' CHE_HOME="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"',
247
- }[shell]
248
-
249
- head = f"""{_banner()}
250
- #
251
- # Source this file (or let the `che install` block in your rc file do it):
252
- #
253
- # . {"~/Developer/git/che/shell/che." + shell}
254
- #
255
- # Every wrapper is a shell function, so `type <name>` shows what it runs and
256
- # `che doctor` can tell you when something it needs is missing.
257
-
258
- # CHE_HOME is normally exported by the installed rc block. Fall back to this
259
- # file's own location so a bare `source` of it also works.
260
- if [ -z "${{CHE_HOME:-}}" ]; then
261
- {resolve_self}
262
- fi
263
-
264
- # Exported, not just set: every wrapper expands "$CHE_BIN", and a shell that
265
- # replays the *functions* without re-reading this file - a `typeset -f` dump, an
266
- # environment snapshot, any tool that rebuilds a shell from its exported
267
- # environment - keeps the functions and drops a plain variable, leaving the
268
- # wrappers to run "/che.py". CHE_HOME comes in exported by the rc block; this
269
- # keeps everything derived from it exported too.
270
- export CHE_BIN="${{CHE_HOME}}/bin"
271
-
272
- # Any python3 on PATH by default. `che install` records the interpreter it
273
- # verified (3.12+) in the rc block, which wins over this.
274
- : "${{CHE_PYTHON:=python3}}"
275
- export CHE_PYTHON
276
-
277
- # Legacy name from the hand-maintained wrappers, kept so an older snippet in
278
- # ~/.zshrc that still refers to it keeps resolving.
279
- export SHELL_FUNCTIONS_BIN="${{CHE_BIN}}"
280
-
281
- # Play the completion sound for a wrapper, then return the status it was given.
282
- #
283
- # playsound-N is defined outside this repo. Every wrapper used to call it
284
- # unconditionally, which printed "command not found" on a machine that never
285
- # had it; here a missing playsound is simply skipped. Set CHE_SOUNDS=0 to
286
- # silence them all.
287
- che_notify() {{
288
- if [ "${{CHE_SOUNDS:-1}}" = "1" ] && command -v "playsound-$1" >/dev/null 2>&1; then
289
- "playsound-$1" >/dev/null 2>&1
290
- fi
291
- return "${{2:-0}}"
292
- }}
293
-
294
- # The dispatcher. `che` with no arguments opens the interactive menu.
295
- che() {{
296
- if [ "$#" -eq 0 ]; then
297
- "$CHE_PYTHON" "$CHE_BIN/che.py"
298
- else
299
- "$CHE_PYTHON" "$CHE_BIN/che.py" "$@"
300
- che_notify 7 $?
301
- fi
302
- }}"""
303
-
304
- tail_by_shell = {
305
- "zsh": """
306
- # Completions. compdef only exists once compinit has run; when it has not,
307
- # skip quietly rather than erroring at shell start.
308
- if whence -w compdef >/dev/null 2>&1 && [ -r "${CHE_HOME}/shell/completions/_che" ]; then
309
- . "${CHE_HOME}/shell/completions/_che"
310
- fi""",
311
- "bash": """
312
- # Completions, when this is bash and the completion builtin is available.
313
- if [ -n "${BASH_VERSION:-}" ] && [ -r "${CHE_HOME}/shell/completions/che.bash" ]; then
314
- . "${CHE_HOME}/shell/completions/che.bash"
315
- fi""",
316
- }
317
-
318
- guard = {"zsh": ZSH_ALIAS_GUARD, "bash": BASH_ALIAS_GUARD}[shell]
319
-
320
- return "\n".join(
321
- [
322
- head,
323
- guard.rstrip("\n"),
324
- "",
325
- _stash_call(),
326
- *_posix_wrappers(),
327
- "",
328
- "_che_restore_aliases",
329
- "",
330
- tail_by_shell[shell].strip("\n"),
331
- "",
332
- ]
333
- )
334
-
335
-
336
- # ---------------------------------------------------------------------------
337
- # fish
338
- # ---------------------------------------------------------------------------
339
-
340
-
341
- def _fish_quote(text: str) -> str:
342
- return "'" + text.replace("\\", "\\\\").replace("'", "\\'") + "'"
343
-
344
-
345
- def _render_fish() -> str:
346
- lines = [
347
- _banner(),
348
- "",
349
- # No `cd` here: fish runs command substitution in the current process,
350
- # so `(cd ...; and pwd)` would move the user's shell as a side effect.
351
- "if not set -q CHE_HOME",
352
- " set -l __che_dir (dirname (status --current-filename))/..",
353
- " if type -q path",
354
- " set -gx CHE_HOME (path resolve $__che_dir)",
355
- " else",
356
- " set -gx CHE_HOME $__che_dir",
357
- " end",
358
- " set -e __che_dir",
359
- "end",
360
- 'set -gx CHE_BIN "$CHE_HOME/bin"',
361
- "if not set -q CHE_PYTHON",
362
- " set -gx CHE_PYTHON python3",
363
- "end",
364
- 'set -gx SHELL_FUNCTIONS_BIN "$CHE_BIN"',
365
- "",
366
- "# Play a wrapper's completion sound, then return the status it was given.",
367
- "function che_notify --description 'che: completion sound'",
368
- " set -l code 0",
369
- " if set -q argv[2]",
370
- " set code $argv[2]",
371
- " end",
372
- ' if test "$CHE_SOUNDS" != "0"',
373
- ' if type -q "playsound-$argv[1]"',
374
- ' "playsound-$argv[1]" >/dev/null 2>&1',
375
- " end",
376
- " end",
377
- " return $code",
378
- "end",
379
- "",
380
- "function che --description 'che: shell helpers dispatcher'",
381
- " if test (count $argv) -eq 0",
382
- ' "$CHE_PYTHON" "$CHE_BIN/che.py"',
383
- " else",
384
- ' "$CHE_PYTHON" "$CHE_BIN/che.py" $argv',
385
- " che_notify 7 $status",
386
- " end",
387
- "end",
388
- ]
389
-
390
- for category in CATEGORIES:
391
- members = [command for command in COMMANDS if command.category == category.key]
392
- if not members:
393
- continue
399
+ def _wrapper_section(dialect: Dialect) -> list[str]:
400
+ """Every wrapper, grouped under a banner per category."""
401
+ lines: list[str] = []
402
+ for category, members in grouped(COMMANDS):
394
403
  lines += ["", f"# {'-' * 74}", f"# {category.title} - {category.blurb}", f"# {'-' * 74}"]
395
- for command in members:
396
- for name, dry in _dry_variants(command):
397
- env, args = command.invocation(dry=dry)
398
- summary = command.summary + (" (preview only)" if dry else "")
399
- lines += ["", f"function {name} --description {_fish_quote(summary)}"]
400
-
401
- if command.body:
402
- # fish has no `$1`; the sh body's positional becomes $argv[1].
403
- lines.append(" " + command.body.replace("$1", "$argv[1]"))
404
- else:
405
- prefix = "".join(f"{key}={value} " for key, value in sorted(env.items()))
406
- argv = "".join(f" {shlex.quote(arg)}" for arg in args)
407
- runner = "env " + prefix if prefix else ""
408
- lines.append(
409
- f' {runner}"$CHE_PYTHON" "$CHE_BIN/{command.script}"{argv} $argv'
410
- )
411
-
412
- sound = command.dry_sound if dry else command.sound
413
- if sound is not None:
414
- lines.append(f" che_notify {sound} $status")
415
- lines.append("end")
416
-
417
- lines += [
418
- "",
419
- 'if test -r "$CHE_HOME/shell/completions/che.fish"',
420
- ' source "$CHE_HOME/shell/completions/che.fish"',
421
- "end",
422
- "",
423
- ]
424
- return "\n".join(lines)
404
+ for variant in variants(members):
405
+ lines += ["", *dialect.wrapper(variant)]
406
+ return lines
425
407
 
426
408
 
427
409
  def wrappers(shell: str) -> str:
428
410
  """The generated wrapper file for ``shell``."""
429
- if shell == "fish":
430
- return _render_fish()
431
- if shell in ("zsh", "bash"):
432
- return _render_posix(shell)
433
- raise ValueError(f"unsupported shell: {shell}")
411
+ spec = _shell(shell)
412
+ dialect = spec.dialect
413
+ return "\n".join(
414
+ [dialect.file_head(spec), *_wrapper_section(dialect), "", dialect.file_tail(spec), ""]
415
+ )
434
416
 
435
417
 
436
418
  # ---------------------------------------------------------------------------
@@ -438,25 +420,14 @@ def wrappers(shell: str) -> str:
438
420
  # ---------------------------------------------------------------------------
439
421
 
440
422
 
441
- def _all_completable() -> list[tuple[str, Command, bool]]:
442
- entries: list[tuple[str, Command, bool]] = []
443
- for command in (*COMMANDS, *BUILTINS):
444
- for name, dry in _dry_variants(command):
445
- entries.append((name, command, dry))
446
- return entries
447
-
448
-
449
423
  def _zsh_completions() -> str:
450
- describe = []
451
- for name, command, dry in _all_completable():
452
- summary = command.summary + (" (preview)" if dry else "")
453
- describe.append(f" {shlex.quote(f'{name}:{summary}')}")
454
-
455
- cases = []
456
- for name, command, _ in _all_completable():
457
- cases.append(f" {name}) print -r -- {shlex.quote(' '.join(_flag_words(command)))} ;;")
458
-
459
- wrapper_names = " ".join(name for name, command, _ in _all_completable() if not command.builtin)
424
+ describe = [
425
+ " " + shlex.quote(f"{v.name}:{v.summary('preview')}") for v in variants(all_commands())
426
+ ]
427
+ cases = [
428
+ f" {v.name}) print -r -- {shlex.quote(' '.join(_flag_words(v.command)))} ;;"
429
+ for v in variants(all_commands())
430
+ ]
460
431
 
461
432
  return f"""#compdef che
462
433
  {_banner()}
@@ -502,17 +473,16 @@ _che_wrapper() {{
502
473
  }}
503
474
 
504
475
  compdef _che che
505
- compdef _che_wrapper {wrapper_names}
476
+ compdef _che_wrapper {" ".join(wrapper_names())}
506
477
  """
507
478
 
508
479
 
509
480
  def _bash_completions() -> str:
510
- cases = []
511
- for name, command, _ in _all_completable():
512
- cases.append(f' {name}) echo "{" ".join(_flag_words(command))}" ;;')
513
-
514
- names = " ".join(name for name, _, _ in _all_completable())
515
- wrapper_names = " ".join(name for name, command, _ in _all_completable() if not command.builtin)
481
+ cases = [
482
+ f' {v.name}) echo "{" ".join(_flag_words(v.command))}" ;;'
483
+ for v in variants(all_commands())
484
+ ]
485
+ names = " ".join(v.name for v in variants(all_commands()))
516
486
 
517
487
  return f"""{_banner()}
518
488
  # shellcheck shell=bash
@@ -568,52 +538,122 @@ _che_complete_wrapper() {{
568
538
  }}
569
539
 
570
540
  complete -o nospace -F _che_complete che
571
- complete -o nospace -F _che_complete_wrapper {wrapper_names}
541
+ complete -o nospace -F _che_complete_wrapper {" ".join(wrapper_names())}
572
542
  """
573
543
 
574
544
 
545
+ def _fish_option(flag: str) -> str:
546
+ """`-l name`, plus `-r` when the flag takes a value and `-F` when that value is a path."""
547
+ spec = f"-l {flag.removeprefix('--').removesuffix('=')}"
548
+ if flag.endswith("="):
549
+ spec += " -r"
550
+ if flag == "--path=":
551
+ spec += " -F"
552
+ return spec
553
+
554
+
575
555
  def _fish_completions() -> str:
576
- lines = [_banner(), ""]
577
- lines.append("complete -c che -f")
578
- for name, command, dry in _all_completable():
579
- summary = command.summary + (" (preview)" if dry else "")
580
- lines.append(
581
- f"complete -c che -n __fish_use_subcommand -a {name} -d {_fish_quote(summary)}"
582
- )
556
+ lines = [_banner(), "", "complete -c che -f"]
557
+ for v in variants(all_commands()):
558
+ description = FISH.quote(v.summary("preview"))
559
+ lines.append(f"complete -c che -n __fish_use_subcommand -a {v.name} -d {description}")
583
560
 
584
561
  lines.append("")
585
- for name, command, _ in _all_completable():
586
- for flag in command.flags:
587
- option = flag.removeprefix("--")
588
- takes_value = " -r" if flag in VALUE_FLAGS else ""
589
- paths = " -F" if flag in PATH_FLAGS else ""
562
+ for v in variants(all_commands()):
563
+ for flag in v.command.flags:
590
564
  lines.append(
591
- f'complete -c che -n "__fish_seen_subcommand_from {name}" '
592
- f"-l {option}{takes_value}{paths}"
565
+ f'complete -c che -n "__fish_seen_subcommand_from {v.name}" {_fish_option(flag)}'
593
566
  )
594
567
 
595
568
  lines.append("")
596
- for name, command, _ in _all_completable():
597
- if command.builtin:
598
- continue
599
- for flag in command.flags:
600
- option = flag.removeprefix("--")
601
- takes_value = " -r" if flag in VALUE_FLAGS else ""
602
- paths = " -F" if flag in PATH_FLAGS else ""
603
- lines.append(f"complete -c {name} -l {option}{takes_value}{paths}")
569
+ for v in variants(COMMANDS):
570
+ for flag in v.command.flags:
571
+ lines.append(f"complete -c {v.name} {_fish_option(flag)}")
604
572
 
605
573
  lines.append("")
606
574
  return "\n".join(lines)
607
575
 
608
576
 
577
+ # ---------------------------------------------------------------------------
578
+ # Shells
579
+ # ---------------------------------------------------------------------------
580
+
581
+
582
+ @dataclass(frozen=True)
583
+ class Shell:
584
+ """Everything that differs between the shells che generates files for."""
585
+
586
+ name: str
587
+ dialect: Dialect
588
+ wrapper_file: Path # relative to the repo root
589
+ completion_file: Path
590
+ completions: Callable[[], str]
591
+ load_completions: str # the end of the wrapper file
592
+ # POSIX only: how a sourced file finds the repo, and the alias guard
593
+ # around the definitions.
594
+ find_home: str = ""
595
+ alias_guard: str = ""
596
+
597
+
598
+ _SHELLS: dict[str, Shell] = {
599
+ shell.name: shell
600
+ for shell in (
601
+ Shell(
602
+ name="zsh",
603
+ dialect=POSIX,
604
+ wrapper_file=Path("shell/che.zsh"),
605
+ completion_file=Path("shell/completions/_che"),
606
+ completions=_zsh_completions,
607
+ find_home='CHE_HOME="${${(%):-%x}:A:h:h}"',
608
+ alias_guard=ZSH_ALIAS_GUARD,
609
+ load_completions="""\
610
+ # Completions. compdef only exists once compinit has run; when it has not,
611
+ # skip quietly rather than erroring at shell start.
612
+ if whence -w compdef >/dev/null 2>&1 && [ -r "${CHE_HOME}/shell/completions/_che" ]; then
613
+ . "${CHE_HOME}/shell/completions/_che"
614
+ fi""",
615
+ ),
616
+ Shell(
617
+ name="bash",
618
+ dialect=POSIX,
619
+ wrapper_file=Path("shell/che.bash"),
620
+ completion_file=Path("shell/completions/che.bash"),
621
+ completions=_bash_completions,
622
+ find_home='CHE_HOME="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"',
623
+ alias_guard=BASH_ALIAS_GUARD,
624
+ load_completions="""\
625
+ # Completions, when this is bash and the completion builtin is available.
626
+ if [ -n "${BASH_VERSION:-}" ] && [ -r "${CHE_HOME}/shell/completions/che.bash" ]; then
627
+ . "${CHE_HOME}/shell/completions/che.bash"
628
+ fi""",
629
+ ),
630
+ Shell(
631
+ name="fish",
632
+ dialect=FISH,
633
+ wrapper_file=Path("shell/che.fish"),
634
+ completion_file=Path("shell/completions/che.fish"),
635
+ completions=_fish_completions,
636
+ load_completions="""\
637
+ if test -r "$CHE_HOME/shell/completions/che.fish"
638
+ source "$CHE_HOME/shell/completions/che.fish"
639
+ end""",
640
+ ),
641
+ )
642
+ }
643
+
644
+ # The shell flavors there are generated files for: `machine.ShellSpec.flavor`.
645
+ FLAVORS = tuple(_SHELLS)
646
+
647
+
648
+ def _shell(name: str) -> Shell:
649
+ try:
650
+ return _SHELLS[name]
651
+ except KeyError:
652
+ raise ValueError(f"unsupported shell: {name}") from None
653
+
654
+
609
655
  def completions(shell: str) -> str:
610
- if shell == "zsh":
611
- return _zsh_completions()
612
- if shell == "bash":
613
- return _bash_completions()
614
- if shell == "fish":
615
- return _fish_completions()
616
- raise ValueError(f"unsupported shell: {shell}")
656
+ return _shell(shell).completions()
617
657
 
618
658
 
619
659
  # ---------------------------------------------------------------------------
@@ -621,45 +661,31 @@ def completions(shell: str) -> str:
621
661
  # ---------------------------------------------------------------------------
622
662
 
623
663
 
624
- def rc_block(shell: str, *, home: Path, python: str, sounds: bool = True) -> str:
664
+ def rc_block(
665
+ shell: str, *, home: Path, python: str, sounds: bool = True, path_dir: Path | None = None
666
+ ) -> str:
625
667
  """The machine-specific snippet ``che install`` writes into an rc file.
626
668
 
627
- Deliberately tiny: a path, an interpreter and a source line. Everything
628
- that can be generic lives in the committed ``shell/che.*`` file instead, so
629
- upgrading che never means rewriting the user's rc file.
669
+ Deliberately tiny: a path, an interpreter and a source line, plus the line
670
+ that puts ``path_dir`` (where the ``che`` shim lives) on PATH when given.
671
+ Everything that can be generic lives in the committed ``shell/che.*`` file
672
+ instead, so upgrading che never means rewriting the user's rc file.
630
673
  """
631
- wrapper = home / WRAPPER_FILES[shell]
632
-
633
- if shell == "fish":
634
- body = [
635
- BEGIN_MARKER,
636
- f"# Managed by `che install` (che {VERSION}). Edits here are overwritten.",
637
- f"set -gx CHE_HOME {_fish_quote(str(home))}",
638
- f"set -gx CHE_PYTHON {_fish_quote(python)}",
639
- ]
640
- if not sounds:
641
- body.append("set -gx CHE_SOUNDS 0")
642
- body += [
643
- f"if test -r {_fish_quote(str(wrapper))}",
644
- f" source {_fish_quote(str(wrapper))}",
645
- "end",
646
- END_MARKER,
647
- ]
648
- return "\n".join(body) + "\n"
649
-
650
- body = [
674
+ spec = _shell(shell)
675
+ dialect = spec.dialect
676
+ lines = [
651
677
  BEGIN_MARKER,
652
678
  f"# Managed by `che install` (che {VERSION}). Edits here are overwritten.",
653
- f"export CHE_HOME={shlex.quote(str(home))}",
654
- f"export CHE_PYTHON={shlex.quote(python)}",
679
+ dialect.export("CHE_HOME", dialect.quote(str(home))),
680
+ dialect.export("CHE_PYTHON", dialect.quote(python)),
655
681
  ]
656
682
  if not sounds:
657
- body.append("export CHE_SOUNDS=0")
658
- body += [
659
- f"[ -r {shlex.quote(str(wrapper))} ] && . {shlex.quote(str(wrapper))}",
660
- END_MARKER,
661
- ]
662
- return "\n".join(body) + "\n"
683
+ lines.append(dialect.export("CHE_SOUNDS", "0"))
684
+ lines.append(dialect.source_if_readable(home / spec.wrapper_file))
685
+ if path_dir is not None:
686
+ lines.append(dialect.add_to_path(path_dir))
687
+ lines.append(END_MARKER)
688
+ return "\n".join(lines) + "\n"
663
689
 
664
690
 
665
691
  def legacy_zshrc() -> str:
@@ -670,25 +696,18 @@ def legacy_zshrc() -> str:
670
696
  sourcing this file keeps working; the wrappers themselves now come from
671
697
  ``shell/che.zsh``.
672
698
  """
673
- return f"""{_banner()}
699
+ return (
700
+ _banner()
701
+ + """
674
702
  #
675
703
  # The wrappers moved to shell/che.zsh and are generated from bin/commands.py.
676
704
  # `che install` writes a block into ~/.zshrc that sources them; this file
677
705
  # stays so that an existing `source .../shell-functions/.zshrc` keeps working.
678
706
 
679
- CHE_HOME="${{CHE_HOME:-${{${{(%):-%x}}:A:h}}}}"
680
- . "${{CHE_HOME}}/shell/che.zsh"
707
+ CHE_HOME="${CHE_HOME:-${${(%):-%x}:A:h}}"
708
+ . "${CHE_HOME}/shell/che.zsh"
681
709
  """
682
-
683
-
684
- def generated_files() -> dict[Path, str]:
685
- """Every generated path mapped to its expected contents, relative to the repo."""
686
- files: dict[Path, str] = {}
687
- for shell in SHELLS:
688
- files[WRAPPER_FILES[shell]] = wrappers(shell)
689
- files[COMPLETION_FILES[shell]] = completions(shell)
690
- files[Path(".zshrc")] = legacy_zshrc()
691
- return files
710
+ )
692
711
 
693
712
 
694
713
  # ---------------------------------------------------------------------------
@@ -707,54 +726,52 @@ def _marker(name: str) -> tuple[str, str]:
707
726
  return f"<!-- che:{name} -->", f"<!-- /che:{name} -->"
708
727
 
709
728
 
729
+ def _md_row(*cells: str) -> str:
730
+ return f"| {' | '.join(cells)} |"
731
+
732
+
733
+ def _md_table(headers: Sequence[str], rows: Iterable[str]) -> str:
734
+ return "\n".join([_md_row(*headers), _md_row(*["---"] * len(headers)), *rows])
735
+
736
+
710
737
  def _commands_table() -> str:
711
- rows = ["| Command | Does | Preview |", "| --- | --- | --- |"]
712
- for category in CATEGORIES:
713
- members = [command for command in COMMANDS if command.category == category.key]
714
- if not members:
715
- continue
738
+ rows = []
739
+ for category, members in grouped(COMMANDS):
716
740
  rows.append(f"| **{category.title}** | *{category.blurb}* | |")
717
741
  for command in members:
718
- preview = f"`{command.dry_name}`" if command.has_dry_twin else ""
719
- if not preview and command.dry_run == "flag":
720
- preview = "`--dry-run`"
721
- if not preview and any(p.flag == "--dry-run" for p in command.prompts):
722
- preview = "`--dry-run`"
723
- if not preview and command.no_preview_reason:
724
- preview = "none"
725
- rows.append(f"| `{command.name}` | {command.summary} | {preview} |")
726
- return "\n".join(rows)
742
+ preview = command.preview
743
+ cell = f"`{preview}`" if preview else ("none" if command.no_preview_reason else "")
744
+ rows.append(_md_row(f"`{command.name}`", command.summary, cell))
745
+ return _md_table(("Command", "Does", "Preview"), rows)
727
746
 
728
747
 
729
748
  def _builtins_table() -> str:
730
- rows = ["| Command | Does |", "| --- | --- |"]
731
- for command in BUILTINS:
732
- rows.append(f"| `che {command.name}` | {command.summary} |")
733
- return "\n".join(rows)
749
+ rows = (_md_row(f"`che {command.name}`", command.summary) for command in BUILTINS)
750
+ return _md_table(("Command", "Does"), rows)
734
751
 
735
752
 
736
753
  def _keys_table() -> str:
737
- rows = ["| Key | Does |", "| --- | --- |"]
738
- for key, description in MENU_KEYS:
739
- rows.append(f"| `{key}` | {description} |")
740
- return "\n".join(rows)
754
+ rows = (_md_row(f"`{key}`", description) for key, description in MENU_KEYS)
755
+ return _md_table(("Key", "Does"), rows)
741
756
 
742
757
 
743
758
  def _tools_table() -> str:
744
- rows = ["| Tool | Needed by |", "| --- | --- |"]
745
- for binary, users in required_binaries().items():
746
- names = ", ".join(f"`{name}`" for name in users)
747
- rows.append(f"| `{binary}` | {names} |")
748
-
749
- extra = [command for command in COMMANDS if command.setup]
750
- for command in extra:
751
- rows.append(f"| (not a binary) | `{command.name}` {command.setup} |")
752
- return "\n".join(rows)
759
+ rows = [
760
+ _md_row(f"`{binary}`", ", ".join(f"`{name}`" for name in users))
761
+ for binary, users in required_binaries().items()
762
+ ]
763
+ rows += [
764
+ _md_row("(not a binary)", f"`{command.name}` {command.setup}")
765
+ for command in COMMANDS
766
+ if command.setup
767
+ ]
768
+ return _md_table(("Tool", "Needed by"), rows)
753
769
 
754
770
 
755
771
  def _banner_island() -> str:
756
- """The face beside the wordmark, exactly as ``che`` prints them."""
757
- rows = art.compose(art.face_lines(), art.logo_lines())
772
+ """The face beside the wordmark: the terminal banner without its colour,
773
+ indent and subtitle."""
774
+ rows = art.compose(art.FACE_LINES, art.LOGO_LINES)
758
775
  return "```text\n" + "\n".join(rows) + "\n```"
759
776
 
760
777
 
@@ -788,3 +805,58 @@ def render_readme(current: str) -> str:
788
805
  _, _, tail = rest.partition(end)
789
806
  updated = f"{head}{begin}\n{body}\n{end}{tail}"
790
807
  return updated
808
+
809
+
810
+ # ---------------------------------------------------------------------------
811
+ # Writing it all out
812
+ # ---------------------------------------------------------------------------
813
+
814
+
815
+ def expected_files() -> dict[Path, str]:
816
+ """Every generated path, relative to the repo, mapped to the contents the
817
+ manifest implies.
818
+
819
+ ``shell/`` is generated whole. The README is generated in place: only the
820
+ marked islands are replaced, so its prose survives.
821
+ """
822
+ expected: dict[Path, str] = {}
823
+ for spec in _SHELLS.values():
824
+ expected[spec.wrapper_file] = wrappers(spec.name)
825
+ expected[spec.completion_file] = spec.completions()
826
+ expected[Path(".zshrc")] = legacy_zshrc()
827
+
828
+ readme = REPO / README
829
+ if readme.exists():
830
+ expected[README] = render_readme(readme.read_text(encoding="utf-8"))
831
+
832
+ return expected
833
+
834
+
835
+ def sync_generated(*, dry_run: bool) -> list[Path]:
836
+ """Bring the generated files up to date; returns the ones that were stale.
837
+
838
+ With ``dry_run`` nothing is written, which makes it the check CI runs.
839
+ Raises ``ValueError`` when the README has lost one of its markers.
840
+
841
+ Each file is replaced atomically: ``che install`` rewrites ``shell/`` in
842
+ place, and a shell starting at that moment must not source half a file.
843
+ """
844
+ changed = []
845
+ for relative, contents in expected_files().items():
846
+ target = REPO / relative
847
+ if target.exists() and target.read_text(encoding="utf-8") == contents:
848
+ continue
849
+ changed.append(relative)
850
+ if not dry_run:
851
+ atomic_write_text(target, contents)
852
+ return changed
853
+
854
+
855
+ if __name__ == "__main__":
856
+ try:
857
+ written = sync_generated(dry_run=False)
858
+ except ValueError as exc:
859
+ raise SystemExit(f"❌ {exc}") from None
860
+ for path in written:
861
+ print(f"wrote {path}")
862
+ print(f"{len(written)} generated file(s) updated")