@codebend3r/che 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +65 -25
  2. package/bin/art.py +123 -0
  3. package/bin/che.py +209 -568
  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 -1188
  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 +508 -428
  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,48 +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
 
29
+ import art
23
30
  from commands import (
24
31
  BUILTINS,
25
- CATEGORIES,
26
32
  COMMANDS,
27
33
  MENU_KEYS,
34
+ REPO,
28
35
  VERSION,
29
36
  Command,
37
+ Resolved,
38
+ all_commands,
39
+ grouped,
30
40
  required_binaries,
41
+ variants,
42
+ wrapper_names,
31
43
  )
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
- }
44
+ from utils import atomic_write_text
46
45
 
47
46
  # The rc-file block is delimited by these. Anything between them belongs to
48
47
  # `che install` and is rewritten wholesale on every install; anything outside
@@ -50,42 +49,10 @@ COMPLETION_FILES = {
50
49
  BEGIN_MARKER = "# >>> che shell functions >>>"
51
50
  END_MARKER = "# <<< che shell functions <<<"
52
51
 
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
52
 
86
- def _banner(comment: str = "#") -> str:
53
+ def _banner() -> str:
87
54
  return "\n".join(
88
- f"{comment} {line}".rstrip()
55
+ f"# {line}".rstrip()
89
56
  for line in (
90
57
  f"che {VERSION} - generated file, do not edit.",
91
58
  "",
@@ -97,66 +64,215 @@ def _banner(comment: str = "#") -> str:
97
64
 
98
65
  def _flag_words(command: Command) -> list[str]:
99
66
  """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
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("'", "\\'") + "'"
103
199
 
200
+ def header(self, name: str, summary: str) -> list[str]:
201
+ return [f"function {name} --description {self.quote(summary)}"]
104
202
 
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
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()
110
223
 
111
224
 
112
225
  # ---------------------------------------------------------------------------
113
- # POSIX-family wrappers (zsh, bash, and anything else that speaks sh)
226
+ # The wrapper files
114
227
  # ---------------------------------------------------------------------------
115
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"
116
238
 
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
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
143
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
+ """
144
248
 
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
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
+ }
159
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
+ """
160
276
 
161
277
  ZSH_ALIAS_GUARD = """
162
278
  # An alias sharing a name with a wrapper below is fatal, not cosmetic: zsh
@@ -216,19 +332,60 @@ _che_restore_aliases() {
216
332
  }
217
333
  """
218
334
 
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()]
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()]
225
382
 
226
383
 
227
384
  def _stash_call() -> str:
228
- """`_che_stash_aliases` invocation, wrapped so the line stays readable."""
385
+ """`_che_stash_aliases` for every name the file defines, wrapped to stay readable."""
229
386
  lines: list[str] = []
230
387
  current = "_che_stash_aliases"
231
- for name in _guarded_names():
388
+ for name in defined_names():
232
389
  candidate = f"{current} {name}"
233
390
  if len(candidate) > 76:
234
391
  lines.append(f"{current} \\")
@@ -239,197 +396,23 @@ def _stash_call() -> str:
239
396
  return "\n".join(lines)
240
397
 
241
398
 
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
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):
393
403
  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)
404
+ for variant in variants(members):
405
+ lines += ["", *dialect.wrapper(variant)]
406
+ return lines
424
407
 
425
408
 
426
409
  def wrappers(shell: str) -> str:
427
410
  """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}")
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
+ )
433
416
 
434
417
 
435
418
  # ---------------------------------------------------------------------------
@@ -437,25 +420,14 @@ def wrappers(shell: str) -> str:
437
420
  # ---------------------------------------------------------------------------
438
421
 
439
422
 
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
423
  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)
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
+ ]
459
431
 
460
432
  return f"""#compdef che
461
433
  {_banner()}
@@ -501,17 +473,16 @@ _che_wrapper() {{
501
473
  }}
502
474
 
503
475
  compdef _che che
504
- compdef _che_wrapper {wrapper_names}
476
+ compdef _che_wrapper {" ".join(wrapper_names())}
505
477
  """
506
478
 
507
479
 
508
480
  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)
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()))
515
486
 
516
487
  return f"""{_banner()}
517
488
  # shellcheck shell=bash
@@ -567,52 +538,122 @@ _che_complete_wrapper() {{
567
538
  }}
568
539
 
569
540
  complete -o nospace -F _che_complete che
570
- complete -o nospace -F _che_complete_wrapper {wrapper_names}
541
+ complete -o nospace -F _che_complete_wrapper {" ".join(wrapper_names())}
571
542
  """
572
543
 
573
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
+
574
555
  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
- )
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}")
582
560
 
583
561
  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 ""
562
+ for v in variants(all_commands()):
563
+ for flag in v.command.flags:
589
564
  lines.append(
590
- f'complete -c che -n "__fish_seen_subcommand_from {name}" '
591
- f"-l {option}{takes_value}{paths}"
565
+ f'complete -c che -n "__fish_seen_subcommand_from {v.name}" {_fish_option(flag)}'
592
566
  )
593
567
 
594
568
  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}")
569
+ for v in variants(COMMANDS):
570
+ for flag in v.command.flags:
571
+ lines.append(f"complete -c {v.name} {_fish_option(flag)}")
603
572
 
604
573
  lines.append("")
605
574
  return "\n".join(lines)
606
575
 
607
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
+
608
655
  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}")
656
+ return _shell(shell).completions()
616
657
 
617
658
 
618
659
  # ---------------------------------------------------------------------------
@@ -620,45 +661,31 @@ def completions(shell: str) -> str:
620
661
  # ---------------------------------------------------------------------------
621
662
 
622
663
 
623
- 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:
624
667
  """The machine-specific snippet ``che install`` writes into an rc file.
625
668
 
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.
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.
629
673
  """
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 = [
674
+ spec = _shell(shell)
675
+ dialect = spec.dialect
676
+ lines = [
650
677
  BEGIN_MARKER,
651
678
  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)}",
679
+ dialect.export("CHE_HOME", dialect.quote(str(home))),
680
+ dialect.export("CHE_PYTHON", dialect.quote(python)),
654
681
  ]
655
682
  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"
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"
662
689
 
663
690
 
664
691
  def legacy_zshrc() -> str:
@@ -669,25 +696,18 @@ def legacy_zshrc() -> str:
669
696
  sourcing this file keeps working; the wrappers themselves now come from
670
697
  ``shell/che.zsh``.
671
698
  """
672
- return f"""{_banner()}
699
+ return (
700
+ _banner()
701
+ + """
673
702
  #
674
703
  # The wrappers moved to shell/che.zsh and are generated from bin/commands.py.
675
704
  # `che install` writes a block into ~/.zshrc that sources them; this file
676
705
  # stays so that an existing `source .../shell-functions/.zshrc` keeps working.
677
706
 
678
- CHE_HOME="${{CHE_HOME:-${{${{(%):-%x}}:A:h}}}}"
679
- . "${{CHE_HOME}}/shell/che.zsh"
707
+ CHE_HOME="${CHE_HOME:-${${(%):-%x}:A:h}}"
708
+ . "${CHE_HOME}/shell/che.zsh"
680
709
  """
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
710
+ )
691
711
 
692
712
 
693
713
  # ---------------------------------------------------------------------------
@@ -706,53 +726,58 @@ def _marker(name: str) -> tuple[str, str]:
706
726
  return f"<!-- che:{name} -->", f"<!-- /che:{name} -->"
707
727
 
708
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
+
709
737
  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
738
+ rows = []
739
+ for category, members in grouped(COMMANDS):
715
740
  rows.append(f"| **{category.title}** | *{category.blurb}* | |")
716
741
  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)
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)
726
746
 
727
747
 
728
748
  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)
749
+ rows = (_md_row(f"`che {command.name}`", command.summary) for command in BUILTINS)
750
+ return _md_table(("Command", "Does"), rows)
733
751
 
734
752
 
735
753
  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)
754
+ rows = (_md_row(f"`{key}`", description) for key, description in MENU_KEYS)
755
+ return _md_table(("Key", "Does"), rows)
740
756
 
741
757
 
742
758
  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} |")
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)
747
769
 
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)
770
+
771
+ def _banner_island() -> str:
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)
775
+ return "```text\n" + "\n".join(rows) + "\n```"
752
776
 
753
777
 
754
778
  def readme_islands() -> dict[str, str]:
755
779
  return {
780
+ "banner": _banner_island(),
756
781
  "commands": _commands_table(),
757
782
  "builtins": _builtins_table(),
758
783
  "keys": _keys_table(),
@@ -780,3 +805,58 @@ def render_readme(current: str) -> str:
780
805
  _, _, tail = rest.partition(end)
781
806
  updated = f"{head}{begin}\n{body}\n{end}{tail}"
782
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")