@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/install.py CHANGED
@@ -7,31 +7,31 @@ Run it directly on a fresh machine::
7
7
  python3 bin/install.py --yes # same, no questions asked
8
8
 
9
9
  or, once the wrappers exist, through the dispatcher: ``che install``,
10
- ``che doctor``, ``che update``, ``che uninstall``.
10
+ ``che update``, ``che uninstall``. The machine facts this reads are in
11
+ ``machine.py``, the rc-file edits in ``rcfile.py``, and ``che doctor``, which
12
+ checks the result, in ``doctor.py``.
11
13
 
12
- What "installed" means here is deliberately small. Three things go into the
13
- user's rc file - where the repo is, which python to use, and a line that
14
- sources the generated wrapper file - between two markers this script owns.
15
- Everything else (the wrappers themselves, the completions) lives in the repo
16
- and is regenerated from ``bin/commands.py``, so upgrading never rewrites the
17
- user's dotfiles.
14
+ What "installed" means here is deliberately small. A few lines go into the
15
+ user's rc file between two markers this script owns: where the repo is, which
16
+ python to use, whether to chime, the line that puts the ``che`` shim on PATH,
17
+ and the line that sources the generated wrapper file. Everything else (the
18
+ wrappers themselves, the completions) lives in the repo and is regenerated
19
+ from ``bin/commands.py``, so upgrading never rewrites the user's dotfiles.
18
20
 
19
21
  Unlike the destructive scripts under ``bin/<category>/``, this one does NOT
20
22
  default to dry-run: it is the entry point that creates the wrappers, so
21
23
  defaulting to "change nothing" would leave a first-time user with nothing
22
24
  installed and no obvious reason why. ``--dry-run`` is still there, and every
23
- file it touches is backed up first.
25
+ rc file it edits is backed up first.
24
26
  """
25
27
 
26
28
  from __future__ import annotations
27
29
 
30
+ import argparse
28
31
  import json
29
- import os
30
- import re
31
32
  import shutil
32
- import subprocess
33
33
  import sys
34
- from dataclasses import dataclass, field
34
+ from dataclasses import dataclass, field, replace
35
35
  from datetime import datetime
36
36
  from pathlib import Path
37
37
 
@@ -39,605 +39,60 @@ from pathlib import Path
39
39
  # `parent`, where the scripts under bin/<category>/ use `parent.parent`.
40
40
  sys.path.insert(0, str(Path(__file__).resolve().parent))
41
41
 
42
- import art
43
42
  import shellgen
44
- from commands import PACKAGE_NAME, REPO, VERSION, all_commands, required_binaries
43
+ from art import print_logo
44
+ from commands import REPO, VERSION, all_commands
45
+ from gitutils import has_tracked_changes
46
+ from machine import (
47
+ BREW_FORMULA,
48
+ INSTALL_HINTS,
49
+ MIN_PYTHON_TEXT,
50
+ PYTHON_HINT,
51
+ SHELLS,
52
+ InstallRecord,
53
+ config_dir,
54
+ config_file,
55
+ default_shell_selection,
56
+ find_python,
57
+ is_git_checkout,
58
+ load_record,
59
+ login_shell,
60
+ missing_binaries,
61
+ python_ok,
62
+ python_version,
63
+ save_record,
64
+ shim_on_path,
65
+ shim_path,
66
+ state_dir,
67
+ tildify,
68
+ update_command,
69
+ version_text,
70
+ )
71
+ from rcfile import commit_rc, find_legacy, read_rc, with_block, without_block, without_legacy
45
72
  from tui import choose, confirm, is_interactive, palette
46
- from utils import add_bool_flag, build_parser, info, note, run, success, warning
73
+ from utils import (
74
+ BoolFlagParser,
75
+ add_bool_flag,
76
+ atomic_write_text,
77
+ build_parser,
78
+ info,
79
+ note,
80
+ run,
81
+ run_cli,
82
+ run_output,
83
+ success,
84
+ warning,
85
+ )
47
86
 
48
87
  __version__ = "1.0.0"
49
88
 
50
- MIN_PYTHON = (3, 12)
51
-
52
- # Binary -> how to get it. Anything absent from here is either part of macOS or
53
- # has no obvious one-liner, and doctor says so rather than inventing a command.
54
- INSTALL_HINTS: dict[str, str] = {
55
- "ffmpeg": "brew install ffmpeg",
56
- "ffprobe": "brew install ffmpeg",
57
- "mpv": "brew install mpv",
58
- "exiftool": "brew install exiftool",
59
- "gh": "brew install gh",
60
- "btop": "brew install btop",
61
- "git": "brew install git",
62
- "brew": 'bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
63
- }
64
-
65
- # Binaries that ship with macOS: never suggest installing them, just report.
66
- SYSTEM_BINARIES = frozenset({"diskutil", "osascript", "sfltool"})
67
-
68
- BREW_FORMULA = {
69
- "ffprobe": "ffmpeg",
70
- "ffmpeg": "ffmpeg",
71
- "mpv": "mpv",
72
- "exiftool": "exiftool",
73
- "gh": "gh",
74
- "btop": "btop",
75
- "git": "git",
76
- }
77
-
78
89
 
79
90
  # ---------------------------------------------------------------------------
80
- # Locations
81
- #
82
- # Every path is resolved from the environment at call time, never at import
83
- # time, so the tests can point HOME at a temporary directory.
91
+ # The PATH shim and external tools
84
92
  # ---------------------------------------------------------------------------
85
93
 
86
94
 
87
- def home() -> Path:
88
- return Path(os.environ.get("HOME", str(Path.home())))
89
-
90
-
91
- def config_dir() -> Path:
92
- base = os.environ.get("XDG_CONFIG_HOME") or str(home() / ".config")
93
- return Path(base) / "che"
94
-
95
-
96
- def config_file() -> Path:
97
- return config_dir() / "config.json"
98
-
99
-
100
- def backup_dir() -> Path:
101
- return config_dir() / "backups"
102
-
103
-
104
- def state_dir() -> Path:
105
- base = os.environ.get("XDG_STATE_HOME") or str(home() / ".local" / "state")
106
- return Path(base) / "che"
107
-
108
-
109
- def shim_path() -> Path:
110
- return home() / ".local" / "bin" / "che"
111
-
112
-
113
- # ---------------------------------------------------------------------------
114
- # Shells
115
- # ---------------------------------------------------------------------------
116
-
117
-
118
- @dataclass
119
- class ShellTarget:
120
- """One shell che can install into, and the rc files it would touch."""
121
-
122
- name: str
123
- rc_files: list[Path] = field(default_factory=list)
124
- binary: str | None = None
125
- is_login_shell: bool = False
126
-
127
- @property
128
- def installed(self) -> bool:
129
- """Whether the shell itself exists on this machine."""
130
- return self.binary is not None
131
-
132
- @property
133
- def has_rc(self) -> bool:
134
- return any(path.exists() for path in self.rc_files)
135
-
136
-
137
- def login_shell() -> str:
138
- return Path(os.environ.get("SHELL", "")).name
139
-
140
-
141
- def _bash_rc_files() -> list[Path]:
142
- """Which bash startup files to write.
143
-
144
- macOS Terminal and iTerm start bash as a *login* shell, which reads
145
- ``.bash_profile`` and never ``.bashrc``; most Linux terminals do the
146
- opposite. Writing to whichever already exist (and to ``.bashrc`` when
147
- neither does) covers both without the usual "why isn't my alias there"
148
- hunt. The block is idempotent, so a ``.bash_profile`` that sources
149
- ``.bashrc`` just defines the same functions twice, harmlessly.
150
- """
151
- candidates = [home() / ".bashrc", home() / ".bash_profile"]
152
- existing = [path for path in candidates if path.exists()]
153
- return existing or [home() / ".bashrc"]
154
-
155
-
156
- def _zsh_rc_files() -> list[Path]:
157
- zdotdir = os.environ.get("ZDOTDIR")
158
- base = Path(zdotdir) if zdotdir else home()
159
- return [base / ".zshrc"]
160
-
161
-
162
- def _fish_rc_files() -> list[Path]:
163
- base = os.environ.get("XDG_CONFIG_HOME") or str(home() / ".config")
164
- return [Path(base) / "fish" / "config.fish"]
165
-
166
-
167
- def _sh_rc_files() -> list[Path]:
168
- return [home() / ".profile"]
169
-
170
-
171
- RC_RESOLVERS = {
172
- "zsh": _zsh_rc_files,
173
- "bash": _bash_rc_files,
174
- "fish": _fish_rc_files,
175
- "sh": _sh_rc_files,
176
- }
177
-
178
- # `sh` reuses the bash wrapper file: it is plain POSIX shell apart from the
179
- # completion tail, which is guarded by a $BASH_VERSION test.
180
- WRAPPER_FOR_SHELL = {"zsh": "zsh", "bash": "bash", "fish": "fish", "sh": "bash"}
181
-
182
-
183
- def detect_shells() -> list[ShellTarget]:
184
- """Every supported shell, whether or not it is present on this machine."""
185
- current = login_shell()
186
- targets = []
187
- for name, resolver in RC_RESOLVERS.items():
188
- targets.append(
189
- ShellTarget(
190
- name=name,
191
- rc_files=resolver(),
192
- binary=shutil.which(name),
193
- is_login_shell=(name == current),
194
- )
195
- )
196
- return targets
197
-
198
-
199
- def default_shell_selection(targets: list[ShellTarget]) -> list[str]:
200
- """What to install into when the user does not say.
201
-
202
- The login shell always, plus any other shell that is both installed and
203
- already has an rc file - those are shells the user demonstrably uses.
204
- ``sh``/``.profile`` is opt-in only; writing there affects every POSIX shell
205
- on the machine and is rarely what someone means.
206
- """
207
- chosen = [target.name for target in targets if target.is_login_shell and target.name != "sh"]
208
- for target in targets:
209
- if target.name in ("sh", *chosen):
210
- continue
211
- if target.installed and target.has_rc:
212
- chosen.append(target.name)
213
- return chosen or ["zsh"]
214
-
215
-
216
- # ---------------------------------------------------------------------------
217
- # Python interpreter
218
- # ---------------------------------------------------------------------------
219
-
220
-
221
- def python_version(executable: str) -> tuple[int, ...] | None:
222
- """Version of another interpreter, or None if it cannot be asked."""
223
- if executable == sys.executable:
224
- return sys.version_info[:3]
225
- try:
226
- output = run(
227
- [executable, "-c", "import sys; print('.'.join(str(p) for p in sys.version_info[:3]))"],
228
- capture=True,
229
- check=True,
230
- timeout=10,
231
- ).stdout
232
- except (OSError, subprocess.SubprocessError):
233
- return None
234
- try:
235
- return tuple(int(part) for part in output.strip().split("."))
236
- except ValueError:
237
- return None
238
-
239
-
240
- def find_python() -> tuple[str, tuple[int, ...] | None]:
241
- """Pick the interpreter the wrappers should use.
242
-
243
- The interpreter running this script wins when it is new enough - it is the
244
- one the user just proved works. Otherwise fall back to the newest suitable
245
- ``python3.X`` on PATH. macOS matters here: ``/usr/bin/python3`` is whatever
246
- the Command Line Tools shipped, often years behind the Homebrew python that
247
- is also installed.
248
- """
249
- if sys.version_info[:2] >= MIN_PYTHON:
250
- return sys.executable, sys.version_info[:3]
251
-
252
- candidates = ["python3.14", "python3.13", "python3.12", "python3"]
253
- for candidate in candidates:
254
- resolved = shutil.which(candidate)
255
- if not resolved:
256
- continue
257
- version = python_version(resolved)
258
- if version and version[:2] >= MIN_PYTHON:
259
- return resolved, version
260
-
261
- return sys.executable, sys.version_info[:3]
262
-
263
-
264
- # ---------------------------------------------------------------------------
265
- # rc-file surgery
266
- # ---------------------------------------------------------------------------
267
-
268
- BEGIN = shellgen.BEGIN_MARKER
269
- END = shellgen.END_MARKER
270
-
271
-
272
- def read_rc(path: Path) -> str:
273
- try:
274
- return path.read_text(encoding="utf-8")
275
- except (OSError, UnicodeDecodeError):
276
- return ""
277
-
278
-
279
- def find_block(text: str) -> tuple[int, int] | None:
280
- """Character span of the managed block, or None when it is not there."""
281
- start = text.find(BEGIN)
282
- if start < 0:
283
- return None
284
- end = text.find(END, start)
285
- if end < 0:
286
- # A truncated block (someone deleted the end marker): treat everything
287
- # from the start marker to end of file as ours rather than appending a
288
- # second copy that would never be found again.
289
- return start, len(text)
290
- return start, end + len(END)
291
-
292
-
293
- def backup_file(path: Path, *, dry_run: bool = False) -> Path | None:
294
- """Copy ``path`` into the backup directory before it is edited."""
295
- if not path.exists():
296
- return None
297
- stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
298
- target = backup_dir() / f"{path.name.lstrip('.')}.{stamp}.bak"
299
- if dry_run:
300
- return target
301
- target.parent.mkdir(parents=True, exist_ok=True)
302
- shutil.copy2(path, target)
303
- return target
304
-
305
-
306
- def apply_block(path: Path, block: str, *, dry_run: bool = False) -> str:
307
- """Insert or replace the managed block in ``path``.
308
-
309
- Returns one of ``created``, ``updated``, ``unchanged``. The block goes at
310
- the *end* of the file on a fresh install so that its definitions win over
311
- anything defined earlier, and stays exactly where it is on an update so a
312
- user who moved it keeps their placement.
313
- """
314
- original = read_rc(path)
315
- span = find_block(original)
316
-
317
- if span is None:
318
- separator = "" if not original or original.endswith("\n\n") else "\n"
319
- if original and not original.endswith("\n"):
320
- separator = "\n\n"
321
- updated = f"{original}{separator}{block}"
322
- action = "created"
323
- else:
324
- start, end = span
325
- if original[start:end].rstrip() == block.rstrip():
326
- return "unchanged"
327
- updated = original[:start] + block.rstrip("\n") + original[end:]
328
- action = "updated"
329
-
330
- if dry_run:
331
- return action
332
-
333
- backup_file(path)
334
- path.parent.mkdir(parents=True, exist_ok=True)
335
- path.write_text(updated, encoding="utf-8")
336
- return action
337
-
338
-
339
- def remove_block(path: Path, *, dry_run: bool = False) -> bool:
340
- """Take the managed block back out. Returns whether anything changed."""
341
- original = read_rc(path)
342
- span = find_block(original)
343
- if span is None:
344
- return False
345
- if dry_run:
346
- return True
347
-
348
- start, end = span
349
- updated = original[:start].rstrip("\n") + "\n" + original[end:].lstrip("\n")
350
- backup_file(path)
351
- path.write_text(updated, encoding="utf-8")
352
- return True
353
-
354
-
355
- # ---------------------------------------------------------------------------
356
- # Legacy hand-written wrappers
357
- # ---------------------------------------------------------------------------
358
-
359
- LEGACY_VARIABLE = "SHELL_FUNCTIONS_BIN"
360
-
361
- _ASSIGNMENT = re.compile(rf"^(?:export\s+)?{LEGACY_VARIABLE}=")
362
- _FUNCTION_HEAD = re.compile(r"^(?:function\s+)?([A-Za-z0-9_.-]+)\s*\(\)\s*\{$")
363
-
364
-
365
- @dataclass(frozen=True)
366
- class LegacyBlock:
367
- """One hand-written wrapper (or the helper behind it) found in an rc file."""
368
-
369
- start: int
370
- end: int
371
- name: str
372
- body: str
373
-
374
-
375
- def find_legacy(text: str) -> list[str]:
376
- """Names of hand-written wrappers in ``text`` that che now generates.
377
-
378
- Before the installer existed, the documented workflow was to copy the
379
- repo's ``.zshrc`` wrappers into ``~/.zshrc`` by hand, so most machines have
380
- a few dozen of them sitting there. They are harmless - the managed block is
381
- appended after, so its definitions win - but they go stale, which is why
382
- doctor reports them and ``--replace-legacy`` removes them.
383
- """
384
- spans, _ = _legacy_spans(text)
385
- return [name for _, _, name in spans if name]
386
-
387
-
388
- def _function_blocks(lines: list[str], managed: range) -> list[LegacyBlock]:
389
- """Every complete ``name() { … }`` outside the managed block."""
390
- blocks: list[LegacyBlock] = []
391
- index = 0
392
-
393
- while index < len(lines):
394
- if index in managed:
395
- index += 1
396
- continue
397
-
398
- head = _FUNCTION_HEAD.match(lines[index].strip())
399
- if not head:
400
- index += 1
401
- continue
402
-
403
- depth = 1
404
- cursor = index + 1
405
- body: list[str] = []
406
- while cursor < len(lines) and depth > 0:
407
- depth += lines[cursor].count("{") - lines[cursor].count("}")
408
- body.append(lines[cursor])
409
- cursor += 1
410
-
411
- if depth == 0:
412
- blocks.append(LegacyBlock(index, cursor, head.group(1), "\n".join(body)))
413
- index = cursor
414
- continue
415
-
416
- index += 1
417
-
418
- return blocks
419
-
420
-
421
- def _legacy_spans(text: str) -> tuple[list[tuple[int, int, str]], set[str]]:
422
- """Line spans holding legacy wrappers, plus the helper names among them.
423
-
424
- Seeded with everything that mentions ``SHELL_FUNCTIONS_BIN`` directly, then
425
- grown: a function that calls a legacy *helper* is legacy too. That second
426
- step is what handles a table-driven rc file, where the wrappers are built
427
- by an ``_sf`` helper that itself only calls ``_sf_run``.
428
-
429
- Growth deliberately stops at helpers. A function of the user's own that
430
- happens to call ``sync-all-branches`` is their automation, not a wrapper,
431
- and must survive untouched.
432
- """
433
- lines = text.splitlines()
434
- managed = _managed_line_range(lines)
435
- blocks = _function_blocks(lines, managed)
436
-
437
- from commands import wrapper_names
438
-
439
- generated = set(wrapper_names())
440
-
441
- marked = {block for block in blocks if LEGACY_VARIABLE in block.body}
442
- helpers = {block.name for block in marked if block.name not in generated}
443
-
444
- while True:
445
- grown = {
446
- block
447
- for block in blocks
448
- if block not in marked and any(helper in block.body for helper in helpers)
449
- }
450
- if not grown:
451
- break
452
- marked |= grown
453
- helpers |= {block.name for block in grown if block.name not in generated}
454
-
455
- spans = [(_extend_upwards(lines, block.start), block.end, block.name) for block in marked]
456
- spans += [
457
- (_extend_upwards(lines, index), index + 1, "")
458
- for index, line in enumerate(lines)
459
- if index not in managed and _ASSIGNMENT.match(line.strip())
460
- ]
461
- return sorted(spans), helpers
462
-
463
-
464
- def _extend_upwards(lines: list[str], start: int) -> int:
465
- """Pull the comment block that documents a wrapper into its span.
466
-
467
- Every wrapper in the old ``.zshrc`` had a banner or a sentence above it.
468
- Removing the function but leaving its comment behind produces a file full
469
- of headings with nothing under them, so the span starts at the first line
470
- of the attached comment instead. The walk stops at the first line that is
471
- neither a comment nor the single blank line between a banner and what it
472
- introduces, so a comment belonging to code that stays is never taken.
473
- """
474
- cursor = start
475
- if cursor > 0 and not lines[cursor - 1].strip():
476
- cursor -= 1
477
- while cursor > 0 and lines[cursor - 1].lstrip().startswith("#"):
478
- cursor -= 1
479
- return cursor if lines[cursor].lstrip().startswith("#") else start
480
-
481
-
482
- def find_shadowing(text: str) -> list[tuple[str, str]]:
483
- """Names in ``text`` that would win over a generated wrapper.
484
-
485
- Two ways that happens, both silent: an alias of the same name (in zsh and
486
- bash an alias is resolved before a function, so `alias remove-metadata=...`
487
- makes the wrapper dead code), and a function redefined *after* the managed
488
- block, since the last definition wins. Definitions before the block are
489
- fine, so they are not reported.
490
- """
491
- from commands import wrapper_names
492
-
493
- names = set(wrapper_names())
494
- span = find_block(text)
495
- block_end = span[1] if span else len(text)
496
-
497
- alias_pattern = re.compile(r"^alias\s+([A-Za-z0-9_.-]+)=")
498
- function_pattern = re.compile(r"^(?:function\s+)?([A-Za-z0-9_.-]+)\s*(?:\(\))?\s*\{")
499
-
500
- found: list[tuple[str, str]] = []
501
- offset = 0
502
- for line in text.splitlines(keepends=True):
503
- stripped = line.strip()
504
- start_of_line, offset = offset, offset + len(line)
505
-
506
- alias = alias_pattern.match(stripped)
507
- if alias and alias.group(1) in names:
508
- found.append((alias.group(1), "alias"))
509
- continue
510
-
511
- if start_of_line <= block_end:
512
- # Defined before or inside our block: ours is sourced later and
513
- # wins, so there is nothing to report.
514
- continue
515
-
516
- definition = function_pattern.match(stripped)
517
- if definition and definition.group(1) in names:
518
- found.append((definition.group(1), "redefined after the block"))
519
-
520
- return found
521
-
522
-
523
- def _managed_line_range(lines: list[str]) -> range:
524
- """Line indices covered by the managed block, so stripping never enters it."""
525
- begin = end = None
526
- for index, line in enumerate(lines):
527
- if line.startswith(BEGIN):
528
- begin = index
529
- elif line.startswith(END):
530
- end = index
531
- if begin is None:
532
- return range(0, 0)
533
- return range(begin, (end if end is not None else len(lines) - 1) + 1)
534
-
535
-
536
- def _consecutive_groups(indices: list[int]) -> list[list[int]]:
537
- groups: list[list[int]] = []
538
- for index in sorted(indices):
539
- if groups and index == groups[-1][-1] + 1:
540
- groups[-1].append(index)
541
- else:
542
- groups.append([index])
543
- return groups
544
-
545
-
546
- def strip_legacy(path: Path, *, dry_run: bool = False) -> list[str]:
547
- """Remove legacy wrappers from ``path``; returns the names removed.
548
-
549
- Two passes. The first drops the ``SHELL_FUNCTIONS_BIN`` assignment, every
550
- function that used it, and the helpers those functions were built from,
551
- along with the comment documenting each. The second drops the lines that
552
- *call* a removed helper - which is what makes a table-driven ``~/.zshrc``
553
- safe to clean: removing an ``_sf`` helper while leaving its forty
554
- ``_sf <name> …`` lines behind would turn every new shell into forty
555
- "command not found" errors.
556
-
557
- Calls to removed *wrappers* are never touched, only calls to helpers. A
558
- line of the user's own that runs ``mount-all-drives`` at login is theirs.
559
- """
560
- original = read_rc(path)
561
- spans, helpers = _legacy_spans(original)
562
- if not spans:
563
- return []
564
-
565
- removed = [name for _, _, name in spans if name]
566
- lines = original.splitlines()
567
- managed = _managed_line_range(lines)
568
-
569
- drop: set[int] = set()
570
- for span_start, span_end, _ in spans:
571
- drop.update(range(span_start, span_end))
572
-
573
- callers = [
574
- index
575
- for index, line in enumerate(lines)
576
- if index not in drop and index not in managed and line.strip().split(" ")[0] in helpers
577
- ]
578
- for group in _consecutive_groups(callers):
579
- drop.update(range(_extend_upwards(lines, group[0]), group[-1] + 1))
580
-
581
- if dry_run:
582
- return removed
583
-
584
- kept = [line for index, line in enumerate(lines) if index not in drop]
585
-
586
- # Collapse the runs of blank lines the removal leaves behind, so the rc
587
- # file does not end up with a hole in it.
588
- tidied: list[str] = []
589
- for line in kept:
590
- if not line.strip() and tidied and not tidied[-1].strip():
591
- continue
592
- tidied.append(line)
593
-
594
- backup_file(path)
595
- path.write_text("\n".join(tidied).rstrip("\n") + "\n", encoding="utf-8")
596
- return removed
597
-
598
-
599
- # ---------------------------------------------------------------------------
600
- # Generated files and the PATH shim
601
- # ---------------------------------------------------------------------------
602
-
603
-
604
- def _expected_files() -> dict[Path, str]:
605
- """Every generated path mapped to the contents the manifest implies.
606
-
607
- ``shell/`` is generated whole. The README is generated in place: only the
608
- marked islands are replaced, so its prose survives.
609
- """
610
- expected = dict(shellgen.generated_files())
611
-
612
- readme = REPO / shellgen.README
613
- if readme.exists():
614
- expected[shellgen.README] = shellgen.render_readme(readme.read_text(encoding="utf-8"))
615
-
616
- return expected
617
-
618
-
619
- def write_generated(*, dry_run: bool = False) -> list[Path]:
620
- """Refresh the generated files; returns the ones that changed."""
621
- changed = []
622
- for relative, contents in _expected_files().items():
623
- target = REPO / relative
624
- if target.exists() and target.read_text(encoding="utf-8") == contents:
625
- continue
626
- changed.append(relative)
627
- if not dry_run:
628
- target.parent.mkdir(parents=True, exist_ok=True)
629
- target.write_text(contents, encoding="utf-8")
630
- return changed
631
-
632
-
633
- def check_generated() -> list[Path]:
634
- """Generated files that differ from what the manifest would produce."""
635
- stale = []
636
- for relative, contents in _expected_files().items():
637
- target = REPO / relative
638
- if not target.exists() or target.read_text(encoding="utf-8") != contents:
639
- stale.append(relative)
640
- return stale
95
+ SHIM_HEADER = "# Generated by `che install`"
641
96
 
642
97
 
643
98
  def install_shim(python: str, *, dry_run: bool = False) -> str:
@@ -645,71 +100,44 @@ def install_shim(python: str, *, dry_run: bool = False) -> str:
645
100
 
646
101
  The shell function is what a human types; this shim is what a cron job, a
647
102
  Makefile or another script can call, none of which source an rc file.
103
+
104
+ A ``che`` that is a symlink is never written through. With an npm prefix of
105
+ ``~/.local``, ``npm install -g`` links it straight to ``bin/che.py``, and
106
+ writing through that link would replace the dispatcher with a shim that
107
+ execs itself. A link to this repo's dispatcher already does the shim's job;
108
+ any other link is replaced, not followed.
648
109
  """
649
110
  target = shim_path()
111
+ dispatcher = REPO / "bin" / "che.py"
650
112
  script = (
651
113
  "#!/bin/sh\n"
652
- f"# Generated by `che install` (che {VERSION}). Do not edit.\n"
653
- f'exec {json.dumps(python)} {json.dumps(str(REPO / "bin" / "che.py"))} "$@"\n'
114
+ f"{SHIM_HEADER} (che {VERSION}). Do not edit.\n"
115
+ f'exec {json.dumps(python)} {json.dumps(str(dispatcher))} "$@"\n'
654
116
  )
655
117
 
656
- if target.exists() and target.read_text(encoding="utf-8") == script:
118
+ if target.is_symlink():
119
+ if target.resolve() == dispatcher.resolve():
120
+ return "unchanged"
121
+ if not dry_run:
122
+ target.unlink()
123
+ elif target.exists() and target.read_text(encoding="utf-8", errors="replace") == script:
657
124
  return "unchanged"
658
- if dry_run:
659
- return "installed"
660
-
661
- target.parent.mkdir(parents=True, exist_ok=True)
662
- target.write_text(script, encoding="utf-8")
663
- target.chmod(0o755)
125
+ if not dry_run:
126
+ atomic_write_text(target, script, mode=0o755)
664
127
  return "installed"
665
128
 
666
129
 
667
- def shim_on_path() -> bool:
668
- entries = os.environ.get("PATH", "").split(os.pathsep)
669
- return str(shim_path().parent) in entries
670
-
671
-
672
- # ---------------------------------------------------------------------------
673
- # Config
674
- # ---------------------------------------------------------------------------
675
-
676
-
677
- def load_config() -> dict:
678
- try:
679
- return json.loads(config_file().read_text(encoding="utf-8"))
680
- except (OSError, ValueError):
681
- return {}
682
-
683
-
684
- def save_config(config: dict, *, dry_run: bool = False) -> None:
685
- if dry_run:
686
- return
687
- config_dir().mkdir(parents=True, exist_ok=True)
688
- config_file().write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
689
-
690
-
691
- # ---------------------------------------------------------------------------
692
- # Dependencies
693
- # ---------------------------------------------------------------------------
694
-
695
-
696
- def binary_status() -> dict[str, dict]:
697
- """Every external binary the commands need, and whether it is present."""
698
- status = {}
699
- for binary, users in required_binaries().items():
700
- resolved = shutil.which(binary)
701
- status[binary] = {
702
- "path": resolved,
703
- "present": bool(resolved),
704
- "commands": list(users),
705
- "hint": INSTALL_HINTS.get(binary, ""),
706
- "system": binary in SYSTEM_BINARIES,
707
- }
708
- return status
130
+ def is_che_shim(path: Path) -> bool:
131
+ """Whether ``path`` is a shim ``che install`` wrote, not a link or a stranger's file."""
132
+ return (
133
+ not path.is_symlink()
134
+ and path.is_file()
135
+ and SHIM_HEADER in path.read_text(encoding="utf-8", errors="replace")
136
+ )
709
137
 
710
138
 
711
139
  def install_missing_binaries(names: list[str], *, dry_run: bool = False) -> bool:
712
- """Install missing tools with Homebrew. Returns whether it ran cleanly."""
140
+ """Install missing tools with Homebrew. Returns whether every one went in."""
713
141
  formulae = sorted({BREW_FORMULA[name] for name in names if name in BREW_FORMULA})
714
142
  if not formulae:
715
143
  return True
@@ -736,218 +164,117 @@ class InstallPlan:
736
164
  shells: list[str]
737
165
  python: str
738
166
  sounds: bool = True
739
- completions: bool = True
740
167
  path_shim: bool = True
741
168
  replace_legacy: bool = False
742
169
  dry_run: bool = False
743
170
 
171
+ def to_record(self, rc_files: list[Path]) -> InstallRecord:
172
+ """What ``config.json`` keeps, so ``che update`` can repeat this install."""
173
+ return InstallRecord(
174
+ version=VERSION,
175
+ home=str(REPO),
176
+ python=self.python,
177
+ shells=self.shells,
178
+ rc_files=[str(path) for path in rc_files],
179
+ sounds=self.sounds,
180
+ path_shim=self.path_shim,
181
+ installed_at=datetime.now().isoformat(timespec="seconds"),
182
+ )
183
+
184
+ @classmethod
185
+ def from_record(cls, record: InstallRecord, *, python: str, dry_run: bool) -> InstallPlan:
186
+ """The plan that repeats a recorded install under ``python``."""
187
+ return cls(
188
+ shells=list(record.shells),
189
+ python=python,
190
+ sounds=record.sounds,
191
+ path_shim=record.path_shim,
192
+ dry_run=dry_run,
193
+ )
194
+
744
195
 
745
196
  @dataclass
746
197
  class InstallResult:
747
198
  rc_actions: list[tuple[Path, str]] = field(default_factory=list)
748
199
  generated: list[Path] = field(default_factory=list)
749
- legacy_removed: dict[str, list[str]] = field(default_factory=dict)
200
+ legacy_removed: dict[Path, list[str]] = field(default_factory=dict)
750
201
  shim: str = "skipped"
751
202
 
752
203
 
204
+ def block_for(shell: str, plan: InstallPlan) -> str:
205
+ """The rc block ``plan`` writes for ``shell``, and so what ``--print`` shows."""
206
+ return shellgen.rc_block(
207
+ SHELLS[shell].flavor,
208
+ home=REPO,
209
+ python=plan.python,
210
+ sounds=plan.sounds,
211
+ path_dir=shim_path().parent if plan.path_shim else None,
212
+ )
213
+
214
+
753
215
  def perform_install(plan: InstallPlan) -> InstallResult:
754
216
  """Do the install described by ``plan``. No prompting happens here."""
755
217
  result = InstallResult()
756
- result.generated = write_generated(dry_run=plan.dry_run)
757
-
758
- targets = {target.name: target for target in detect_shells()}
218
+ try:
219
+ result.generated = shellgen.sync_generated(dry_run=plan.dry_run)
220
+ except ValueError as exc:
221
+ # The README lost a marker. The committed shell/ files still work, so
222
+ # the rc blocks that point at them can still be written.
223
+ warning(f"❌ {exc}")
759
224
 
760
225
  for shell in plan.shells:
761
- target = targets.get(shell)
762
- if target is None:
226
+ spec = SHELLS.get(shell)
227
+ if spec is None:
763
228
  warning(f"❌ Unknown shell: {shell}")
764
229
  continue
765
230
 
766
- block = shellgen.rc_block(
767
- WRAPPER_FOR_SHELL[shell],
768
- home=REPO,
769
- python=plan.python,
770
- sounds=plan.sounds,
771
- )
772
- if plan.path_shim and shell != "fish":
773
- block = _with_path_line(block)
774
- elif plan.path_shim and shell == "fish":
775
- block = _with_fish_path_line(block)
776
-
777
- for rc_file in target.rc_files:
231
+ block = block_for(shell, plan)
232
+ for rc_file in spec.rc_files:
233
+ original = text = read_rc(rc_file)
778
234
  if plan.replace_legacy:
779
- removed = strip_legacy(rc_file, dry_run=plan.dry_run)
235
+ text, removed = without_legacy(text)
780
236
  if removed:
781
- result.legacy_removed[str(rc_file)] = removed
782
- action = apply_block(rc_file, block, dry_run=plan.dry_run)
237
+ result.legacy_removed[rc_file] = removed
238
+ updated, action = with_block(text, block)
239
+ commit_rc(rc_file, original, updated, dry_run=plan.dry_run)
783
240
  result.rc_actions.append((rc_file, action))
784
241
 
785
242
  if plan.path_shim:
786
243
  result.shim = install_shim(plan.python, dry_run=plan.dry_run)
787
244
 
788
- save_config(
789
- {
790
- "version": VERSION,
791
- "home": str(REPO),
792
- "python": plan.python,
793
- "shells": plan.shells,
794
- "rc_files": [str(path) for path, _ in result.rc_actions],
795
- "sounds": plan.sounds,
796
- "completions": plan.completions,
797
- "path_shim": plan.path_shim,
798
- "installed_at": datetime.now().isoformat(timespec="seconds"),
799
- },
800
- dry_run=plan.dry_run,
801
- )
245
+ save_record(plan.to_record([path for path, _ in result.rc_actions]), dry_run=plan.dry_run)
802
246
  return result
803
247
 
804
248
 
805
- def _with_path_line(block: str) -> str:
806
- """Add ``~/.local/bin`` to PATH inside the managed block, idempotently."""
807
- directory = str(shim_path().parent)
808
- line = f'case ":$PATH:" in *":{directory}:"*) ;; *) PATH="{directory}:$PATH" ;; esac'
809
- return block.replace(END, f"{line}\n{END}")
810
-
811
-
812
- def _with_fish_path_line(block: str) -> str:
813
- directory = str(shim_path().parent)
814
- line = f'if not contains "{directory}" $PATH\n set -gx PATH "{directory}" $PATH\nend'
815
- return block.replace(shellgen.END_MARKER, f"{line}\n{shellgen.END_MARKER}")
816
-
249
+ def run_install(plan: InstallPlan, *, install_deps: bool = False) -> int:
250
+ """Install, say what happened, then fetch the missing tools when asked to.
817
251
 
818
- # ---------------------------------------------------------------------------
819
- # The first-run wizard
820
- # ---------------------------------------------------------------------------
821
-
822
-
823
- def print_logo(subtitle: str = "") -> None:
824
- """The face beside the wordmark, with ``subtitle`` under the letters."""
825
- colors = palette()
826
- right = art.wordmark(colors)
827
- if subtitle:
828
- right.append(f" {colors.grey}{subtitle}{colors.reset}")
829
- for line in art.banner(right, colors=colors):
830
- print(line)
252
+ Returns the exit status: 1 when a tool the user asked for did not install.
253
+ """
254
+ report(plan, perform_install(plan))
255
+ missing = missing_binaries() if install_deps else []
256
+ if not missing:
257
+ return 0
831
258
  print()
259
+ return 0 if install_missing_binaries(missing, dry_run=plan.dry_run) else 1
832
260
 
833
261
 
834
- def wizard(*, dry_run: bool = False) -> int:
835
- """Walk a first-time user through the whole setup."""
836
- colors = palette()
837
- print_logo(f"shell helpers, v{VERSION} - first-run setup")
262
+ def require_python() -> str | None:
263
+ """The interpreter the wrappers will run, or None, said why, when none is new enough.
838
264
 
265
+ Writing a too-old one into every rc block would break every wrapper at once.
266
+ """
839
267
  python, version = find_python()
840
- version_text = ".".join(str(part) for part in (version or ()))
841
- if not version or version[:2] < MIN_PYTHON:
842
- warning(
843
- f"❌ Python {'.'.join(str(p) for p in MIN_PYTHON)}+ is required (found {version_text})."
844
- )
845
- note(" Install one with `brew install python@3.13`, then run this again.")
846
- return 1
847
-
848
- print(
849
- f" {colors.green}✓{colors.reset} python {version_text} {colors.grey}{python}{colors.reset}"
850
- )
851
- print(f" {colors.green}✓{colors.reset} repo {colors.grey}{REPO}{colors.reset}")
852
- print(f" {colors.green}✓{colors.reset} {len(all_commands())} commands ready to wrap")
853
- print()
854
-
855
- targets = detect_shells()
856
- selectable = [target for target in targets if target.installed or target.has_rc]
857
- if not selectable:
858
- selectable = [target for target in targets if target.name == "zsh"]
859
-
860
- preselected = default_shell_selection(targets)
861
- options = []
862
- for target in selectable:
863
- rc_names = ", ".join(str(path).replace(str(home()), "~") for path in target.rc_files)
864
- label = target.name + (" (your login shell)" if target.is_login_shell else "")
865
- options.append((label, rc_names))
866
-
867
- picked = choose(
868
- "Which shells should che install into?",
869
- options,
870
- selected=[index for index, target in enumerate(selectable) if target.name in preselected],
871
- )
872
- if not picked:
873
- warning("Nothing selected - install cancelled.")
874
- return 1
875
- shells = [selectable[index].name for index in picked]
268
+ if python_ok(version):
269
+ return python
270
+ warning(f"❌ Python {MIN_PYTHON_TEXT}+ is required (found {version_text(version)}).")
271
+ note(f" Install one with `{PYTHON_HINT}`, then run this again.")
272
+ return None
876
273
 
877
- print()
878
- path_shim = confirm(
879
- f"Add a `che` command to {str(shim_path()).replace(str(home()), '~')} "
880
- "so scripts and cron can call it?",
881
- default=True,
882
- )
883
- sounds = confirm("Play the playsound-N chime when a command finishes?", default=True)
884
-
885
- legacy_found = {
886
- rc_file: find_legacy(read_rc(rc_file))
887
- for target in targets
888
- if target.name in shells
889
- for rc_file in target.rc_files
890
- if find_legacy(read_rc(rc_file))
891
- }
892
- replace_legacy = False
893
- if legacy_found:
894
- print()
895
- total = sum(len(names) for names in legacy_found.values())
896
- note(f"Found {total} hand-written wrappers from before che managed them:")
897
- for rc_file, names in legacy_found.items():
898
- print(
899
- f" {colors.grey}{rc_file}: {', '.join(names[:6])}"
900
- f"{'…' if len(names) > 6 else ''}{colors.reset}"
901
- )
902
- replace_legacy = confirm(
903
- "Replace them with the generated ones? (a backup is written first)", default=True
904
- )
905
-
906
- missing = [
907
- name
908
- for name, entry in binary_status().items()
909
- if not entry["present"] and not entry["system"]
910
- ]
911
- install_deps = False
912
- if missing:
913
- print()
914
- note(f"Missing external tools: {', '.join(missing)}")
915
- for name in missing:
916
- hint = INSTALL_HINTS.get(name)
917
- if hint:
918
- print(f" {colors.grey}{name}: {hint}{colors.reset}")
919
- if shutil.which("brew"):
920
- install_deps = confirm("Install them now with Homebrew?", default=False)
921
-
922
- print()
923
- plan = InstallPlan(
924
- shells=shells,
925
- python=python,
926
- sounds=sounds,
927
- path_shim=path_shim,
928
- replace_legacy=replace_legacy,
929
- dry_run=dry_run,
930
- )
931
- result = perform_install(plan)
932
- report(plan, result)
933
-
934
- if install_deps:
935
- print()
936
- install_missing_binaries(missing, dry_run=dry_run)
937
-
938
- print()
939
- success("Done. Start using it with:")
940
- print(
941
- f" {colors.bold}exec {shells[0]}{colors.reset} {colors.grey}(reload your shell){colors.reset}"
942
- )
943
- print(
944
- f" {colors.bold}che{colors.reset} {colors.grey}(browse every command){colors.reset}"
945
- )
946
- print(
947
- f" {colors.bold}che doctor{colors.reset} {colors.grey}(check the install){colors.reset}"
948
- )
949
- return 0
950
274
 
275
+ # ---------------------------------------------------------------------------
276
+ # Reporting
277
+ # ---------------------------------------------------------------------------
951
278
 
952
279
  # Past tense for what happened, present tense for what a dry run would do.
953
280
  DRY_VERB = {
@@ -960,45 +287,47 @@ DRY_VERB = {
960
287
  }
961
288
 
962
289
 
963
- def report(plan: InstallPlan, result: InstallResult) -> None:
964
- """Print what an install did, in the same shape for wizard and --yes."""
290
+ def phrase(action: str, *, dry_run: bool) -> str:
291
+ """``action`` as done, or as what a dry run would do."""
292
+ if not dry_run:
293
+ return action
965
294
  colors = palette()
295
+ return f"{colors.yellow}would {DRY_VERB.get(action, action)}{colors.reset}"
296
+
966
297
 
967
- def phrase(action: str) -> str:
968
- if not plan.dry_run:
969
- return action
970
- return f"{colors.yellow}would {DRY_VERB.get(action, action)}{colors.reset}"
298
+ def mark(action: str = "") -> str:
299
+ """What starts a report line: a grey ``=`` for something left alone, else a ``✓``."""
300
+ colors = palette()
301
+ if action == "unchanged":
302
+ return f"{colors.grey}={colors.reset}"
303
+ return f"{colors.green}✓{colors.reset}"
971
304
 
972
- def short(path: Path) -> str:
973
- return str(path).replace(str(home()), "~")
305
+
306
+ def report(plan: InstallPlan, result: InstallResult) -> None:
307
+ """Print what an install did, in the same shape for wizard and --yes."""
308
+ colors = palette()
309
+ dry_run = plan.dry_run
974
310
 
975
311
  for rc_file, action in result.rc_actions:
976
- mark = (
977
- f"{colors.grey}={colors.reset}"
978
- if action == "unchanged"
979
- else f"{colors.green}✓{colors.reset}"
980
- )
981
- print(f" {mark} {phrase(action)} block in {short(rc_file)}")
312
+ print(f" {mark(action)} {phrase(action, dry_run=dry_run)} block in {tildify(rc_file)}")
982
313
 
983
314
  for rc_file, names in result.legacy_removed.items():
984
315
  print(
985
- f" {colors.green}✓{colors.reset} {phrase('removed')} "
986
- f"{len(names)} legacy wrappers from {short(Path(rc_file))}"
316
+ f" {mark()} {phrase('removed', dry_run=dry_run)} "
317
+ f"{len(names)} legacy wrappers from {tildify(rc_file)}"
987
318
  )
988
319
 
989
320
  if result.generated:
990
321
  print(
991
- f" {colors.green}✓{colors.reset} {phrase('regenerated')} "
322
+ f" {mark()} {phrase('regenerated', dry_run=dry_run)} "
992
323
  f"{len(result.generated)} files under shell/"
993
324
  )
994
325
 
995
326
  if plan.path_shim:
996
- mark = (
997
- f"{colors.grey}={colors.reset}"
998
- if result.shim == "unchanged"
999
- else f"{colors.green}✓{colors.reset}"
327
+ print(
328
+ f" {mark(result.shim)} che shim {phrase(result.shim, dry_run=dry_run)}: "
329
+ f"{tildify(shim_path())}"
1000
330
  )
1001
- print(f" {mark} che shim {phrase(result.shim)}: {short(shim_path())}")
1002
331
  if not shim_on_path():
1003
332
  print(
1004
333
  f" {colors.grey} ({shim_path().parent} is added to PATH by the block){colors.reset}"
@@ -1006,182 +335,119 @@ def report(plan: InstallPlan, result: InstallResult) -> None:
1006
335
 
1007
336
 
1008
337
  # ---------------------------------------------------------------------------
1009
- # Doctor
338
+ # The first-run wizard
1010
339
  # ---------------------------------------------------------------------------
1011
340
 
1012
- OK, WARN, FAIL = "ok", "warn", "fail"
1013
341
 
342
+ def wizard(plan: InstallPlan, *, install_deps: bool = False) -> int:
343
+ """Walk a first-time user through the whole setup.
1014
344
 
1015
- def diagnose() -> list[dict]:
1016
- """Collect every check as data, so both the table and --json use one source."""
1017
- checks: list[dict] = []
1018
- config = load_config()
345
+ ``plan`` is what is already decided. It preselects the shells, and a
346
+ question it already answers - ``--no-sounds``, ``--no-path-shim``,
347
+ ``--replace-legacy`` or ``install_deps`` - is not asked again.
348
+ """
349
+ colors = palette()
350
+ print_logo(f"shell helpers, v{VERSION} - first-run setup")
1019
351
 
1020
- python, version = find_python()
1021
- version_text = ".".join(str(part) for part in (version or ()))
1022
- checks.append(
1023
- {
1024
- "name": "python",
1025
- "status": OK if version and version[:2] >= MIN_PYTHON else FAIL,
1026
- "detail": f"{version_text} ({python})",
1027
- "fix": "brew install python@3.13",
1028
- }
1029
- )
352
+ version = python_version(plan.python) or ()
353
+ print(f" {mark()} python {version_text(version)} {colors.grey}{plan.python}{colors.reset}")
354
+ print(f" {mark()} repo {colors.grey}{REPO}{colors.reset}")
355
+ print(f" {mark()} {len(all_commands())} commands ready to wrap")
356
+ print()
1030
357
 
1031
- checks.append(
1032
- {
1033
- "name": "repo",
1034
- "status": OK if (REPO / "bin" / "che.py").exists() else FAIL,
1035
- "detail": str(REPO),
1036
- "fix": "re-clone the repo",
1037
- }
1038
- )
358
+ shells = _ask_shells(plan.shells)
359
+ if not shells:
360
+ warning("Nothing selected - install cancelled.")
361
+ return 1
1039
362
 
1040
- stale = check_generated()
1041
- checks.append(
1042
- {
1043
- "name": "generated files",
1044
- "status": OK if not stale else WARN,
1045
- "detail": "current"
1046
- if not stale
1047
- else f"{len(stale)} out of date: " + ", ".join(str(path) for path in stale),
1048
- "fix": "che install",
1049
- }
363
+ print()
364
+ path_shim = plan.path_shim and confirm(
365
+ f"Add a `che` command to {tildify(shim_path())} so scripts and cron can call it?",
366
+ default=True,
1050
367
  )
1051
-
1052
- installed_version = config.get("version")
1053
- checks.append(
1054
- {
1055
- "name": "install record",
1056
- "status": OK if installed_version == VERSION else (WARN if config else FAIL),
1057
- "detail": f"config {installed_version or 'missing'} vs che {VERSION}",
1058
- "fix": "che install",
1059
- }
368
+ sounds = plan.sounds and confirm(
369
+ "Play the playsound-N chime when a command finishes?", default=True
1060
370
  )
1061
-
1062
- # Only the shells this install actually targets. Reporting on every shell
1063
- # present would flag ~/.profile as "missing" on any machine with /bin/sh,
1064
- # which is every machine.
1065
- watched = set(config.get("shells") or default_shell_selection(detect_shells()))
1066
- watched.add(login_shell())
1067
-
1068
- for target in detect_shells():
1069
- if target.name not in watched:
1070
- continue
1071
- for rc_file in target.rc_files:
1072
- text = read_rc(rc_file)
1073
- has_block = find_block(text) is not None
1074
- legacy = find_legacy(text)
1075
- status = OK if has_block else (WARN if target.name == "sh" else FAIL)
1076
- detail = "block installed" if has_block else "no che block"
1077
- if legacy:
1078
- status = WARN if has_block else status
1079
- detail += f", {len(legacy)} legacy wrappers"
1080
-
1081
- shadowing = find_shadowing(text)
1082
- if shadowing:
1083
- status = WARN if status == OK else status
1084
- detail += ", shadowed: " + ", ".join(
1085
- f"{name} ({how})" for name, how in shadowing[:3]
1086
- )
1087
- shadow_fix = (
1088
- "the alias wins at the prompt; run `\\name` for the wrapper, "
1089
- "or remove the alias"
1090
- if any(how == "alias" for _, how in shadowing)
1091
- else "move your definition above the che block"
1092
- )
1093
- checks.append(
1094
- {
1095
- "name": f"{target.name}: {str(rc_file).replace(str(home()), '~')}",
1096
- "status": status,
1097
- "detail": detail,
1098
- "fix": (
1099
- "che install --replace-legacy"
1100
- if legacy
1101
- else (shadow_fix if shadowing else "che install")
1102
- ),
1103
- }
1104
- )
1105
-
1106
- shim = shim_path()
1107
- if shim.exists() and shim_on_path():
1108
- shim_status, shim_detail, shim_fix = OK, str(shim), ""
1109
- elif shim.exists():
1110
- shim_status = WARN
1111
- shim_detail = f"{shim} (PATH picks it up on your next shell)"
1112
- shim_fix = f"exec {login_shell() or 'zsh'}"
1113
- else:
1114
- shim_status, shim_detail, shim_fix = WARN, "not installed", "che install"
1115
- checks.append(
1116
- {"name": "che on PATH", "status": shim_status, "detail": shim_detail, "fix": shim_fix}
371
+ replace_legacy = plan.replace_legacy or _ask_legacy(shells)
372
+ install_deps = install_deps or _ask_tools()
373
+ plan = replace(
374
+ plan, shells=shells, path_shim=path_shim, sounds=sounds, replace_legacy=replace_legacy
1117
375
  )
1118
376
 
1119
- for binary, entry in binary_status().items():
1120
- used_by = ", ".join(entry["commands"][:3])
1121
- if len(entry["commands"]) > 3:
1122
- used_by += f" +{len(entry['commands']) - 3}"
1123
- checks.append(
1124
- {
1125
- "name": f"tool: {binary}",
1126
- "status": OK if entry["present"] else WARN,
1127
- "detail": entry["path"] or f"missing - needed by {used_by}",
1128
- "fix": entry["hint"],
1129
- }
1130
- )
377
+ print()
378
+ status = run_install(plan, install_deps=install_deps)
1131
379
 
1132
- missing_scripts = [
1133
- command.name
1134
- for command in all_commands()
1135
- if command.script and not (REPO / "bin" / command.script).exists()
1136
- ]
1137
- checks.append(
1138
- {
1139
- "name": "scripts",
1140
- "status": OK if not missing_scripts else FAIL,
1141
- "detail": f"{len(all_commands())} commands"
1142
- + ("" if not missing_scripts else f", missing: {', '.join(missing_scripts)}"),
1143
- "fix": update_command(),
1144
- }
380
+ print()
381
+ success("Done. Start using it with:")
382
+ print(
383
+ f" {colors.bold}exec {shells[0]}{colors.reset} {colors.grey}(reload your shell){colors.reset}"
384
+ )
385
+ print(
386
+ f" {colors.bold}che{colors.reset} {colors.grey}(browse every command){colors.reset}"
1145
387
  )
388
+ print(
389
+ f" {colors.bold}che doctor{colors.reset} {colors.grey}(check the install){colors.reset}"
390
+ )
391
+ return status
1146
392
 
1147
- return checks
1148
393
 
394
+ def _ask_shells(preselected: list[str]) -> list[str]:
395
+ """The shells to install into: those present on this machine, ``preselected`` ticked."""
396
+ specs = list(SHELLS.values())
397
+ selectable = [spec for spec in specs if spec.installed or spec.has_rc] or [SHELLS["zsh"]]
398
+ picked = choose(
399
+ "Which shells should che install into?",
400
+ [
401
+ (
402
+ spec.name + (" (your login shell)" if spec.is_login_shell else ""),
403
+ ", ".join(tildify(path) for path in spec.rc_files),
404
+ )
405
+ for spec in selectable
406
+ ],
407
+ selected=[index for index, spec in enumerate(selectable) if spec.name in preselected],
408
+ )
409
+ return [selectable[index].name for index in picked]
1149
410
 
1150
- def doctor(*, as_json: bool = False, verbose: bool = False) -> int:
1151
- checks = diagnose()
1152
411
 
1153
- if as_json:
1154
- print(json.dumps({"version": VERSION, "checks": checks}, indent=2))
1155
- return 1 if any(check["status"] == FAIL for check in checks) else 0
412
+ def _ask_legacy(shells: list[str]) -> bool:
413
+ """Whether to replace the hand-written wrappers in ``shells``' rc files, if any."""
414
+ found = {
415
+ rc_file: names
416
+ for shell in shells
417
+ for rc_file in SHELLS[shell].rc_files
418
+ if (names := find_legacy(read_rc(rc_file)))
419
+ }
420
+ if not found:
421
+ return False
1156
422
 
1157
423
  colors = palette()
1158
- marks = {
1159
- OK: f"{colors.green}✓{colors.reset}",
1160
- WARN: f"{colors.yellow}!{colors.reset}",
1161
- FAIL: f"{colors.red}✗{colors.reset}",
1162
- }
424
+ print()
425
+ total = sum(len(names) for names in found.values())
426
+ note(f"Found {total} hand-written wrappers from before che managed them:")
427
+ for rc_file, names in found.items():
428
+ print(
429
+ f" {colors.grey}{rc_file}: {', '.join(names[:6])}"
430
+ f"{'…' if len(names) > 6 else ''}{colors.reset}"
431
+ )
432
+ return confirm(
433
+ "Replace them with the generated ones? (a backup is written first)", default=True
434
+ )
1163
435
 
1164
- print_logo(f"doctor - che {VERSION}")
1165
- width = max(len(check["name"]) for check in checks) + 2
1166
436
 
1167
- for check in checks:
1168
- if check["status"] == OK and not verbose and check["name"].startswith("tool: "):
1169
- continue
1170
- line = f" {marks[check['status']]} {check['name']:<{width}} {colors.grey}{check['detail']}{colors.reset}"
1171
- print(line)
1172
- if check["status"] != OK and check["fix"]:
1173
- print(f" {colors.grey}→ {check['fix']}{colors.reset}")
437
+ def _ask_tools() -> bool:
438
+ """Whether to install the missing external tools, after listing them."""
439
+ missing = missing_binaries()
440
+ if not missing:
441
+ return False
1174
442
 
1175
- failures = sum(1 for check in checks if check["status"] == FAIL)
1176
- warnings = sum(1 for check in checks if check["status"] == WARN)
443
+ colors = palette()
1177
444
  print()
1178
- if failures:
1179
- warning(f"{failures} problem(s), {warnings} warning(s).")
1180
- elif warnings:
1181
- note(f"All good, with {warnings} warning(s).")
1182
- else:
1183
- success("Everything checks out.")
1184
- return 1 if failures else 0
445
+ note(f"Missing external tools: {', '.join(missing)}")
446
+ for name in missing:
447
+ hint = INSTALL_HINTS.get(name)
448
+ if hint:
449
+ print(f" {colors.grey}{name}: {hint}{colors.reset}")
450
+ return bool(shutil.which("brew")) and confirm("Install them now with Homebrew?", default=False)
1185
451
 
1186
452
 
1187
453
  # ---------------------------------------------------------------------------
@@ -1189,26 +455,6 @@ def doctor(*, as_json: bool = False, verbose: bool = False) -> int:
1189
455
  # ---------------------------------------------------------------------------
1190
456
 
1191
457
 
1192
- def install_source() -> str:
1193
- """How this copy of che got here: ``git`` for a clone, ``npm`` for a package.
1194
-
1195
- The npm package is the same tree without ``.git``, unpacked under a
1196
- ``node_modules`` directory, so that is the whole test.
1197
- """
1198
- if (REPO / ".git").exists():
1199
- return "git"
1200
- if "node_modules" in REPO.parts:
1201
- return "npm"
1202
- return "unknown"
1203
-
1204
-
1205
- def update_command() -> str:
1206
- """The command that fetches a newer che for this kind of install."""
1207
- if install_source() == "git":
1208
- return "git pull"
1209
- return f"npm install -g {PACKAGE_NAME}@latest"
1210
-
1211
-
1212
458
  def update(*, check_only: bool = False, dry_run: bool = False) -> int:
1213
459
  """Pull the latest scripts, then refresh whatever is installed.
1214
460
 
@@ -1221,7 +467,7 @@ def update(*, check_only: bool = False, dry_run: bool = False) -> int:
1221
467
  ``git fetch failed``.
1222
468
  """
1223
469
  colors = palette()
1224
- if install_source() != "git":
470
+ if not is_git_checkout():
1225
471
  warning("❌ This copy of che is not a git checkout, so `che update` cannot pull it.")
1226
472
  note(f" Update it with `{update_command()}`, then `che install` to refresh the wrappers.")
1227
473
  return 1
@@ -1229,110 +475,109 @@ def update(*, check_only: bool = False, dry_run: bool = False) -> int:
1229
475
  warning("❌ git is not installed, so che cannot update itself.")
1230
476
  return 1
1231
477
 
1232
- before = run(["git", "rev-parse", "HEAD"], cwd=REPO, capture=True, check=False).stdout.strip()
1233
-
1234
478
  info("Fetching…")
1235
- fetch = run(["git", "fetch", "--prune"], cwd=REPO, check=False)
1236
- if fetch.returncode != 0:
479
+ if run(["git", "fetch", "--prune"], cwd=REPO, check=False).returncode != 0:
1237
480
  warning("❌ git fetch failed.")
1238
481
  return 1
1239
482
 
1240
- behind = run(
1241
- ["git", "rev-list", "--count", "HEAD..@{upstream}"], cwd=REPO, capture=True, check=False
1242
- )
1243
- count = behind.stdout.strip() if behind.returncode == 0 else "?"
1244
-
1245
- if count == "0":
1246
- success("Already up to date.")
1247
- elif check_only:
1248
- note(f"{count} commit(s) available. Run `che update` to apply.")
1249
- return 0
1250
- else:
1251
- dirty = run(
1252
- ["git", "status", "--porcelain", "--untracked-files=no"],
1253
- cwd=REPO,
1254
- capture=True,
1255
- check=False,
1256
- ).stdout.strip()
1257
- if dirty:
1258
- warning("❌ The repo has uncommitted changes; not pulling.")
1259
- note(" Commit or stash them, then run `che update` again.")
1260
- return 1
1261
-
1262
- if dry_run:
1263
- note(f"would pull {count} commit(s)")
1264
- else:
1265
- pull = run(["git", "pull", "--ff-only"], cwd=REPO, check=False)
1266
- if pull.returncode != 0:
1267
- warning("❌ git pull --ff-only failed - the branch has diverged.")
1268
- return 1
1269
-
1270
- after = run(["git", "rev-parse", "HEAD"], cwd=REPO, capture=True, check=False).stdout.strip()
1271
- if before and after and before != after:
1272
- print()
1273
- note("What changed:")
1274
- log = run(
1275
- ["git", "log", "--oneline", "--no-decorate", f"{before}..{after}"],
1276
- cwd=REPO,
1277
- capture=True,
1278
- check=False,
1279
- ).stdout
1280
- for line in log.splitlines()[:20]:
1281
- print(f" {colors.grey}{line}{colors.reset}")
483
+ # Empty when the branch has no upstream or HEAD is detached: git explains
484
+ # on stderr and prints nothing.
485
+ count = run_output(["git", "rev-list", "--count", "HEAD..@{upstream}"], check=False, cwd=REPO)
1282
486
 
487
+ if not count:
488
+ warning("❌ The checked-out branch has no upstream to pull from.")
489
+ note(" Check out a branch that tracks origin, usually main, then run `che update` again.")
490
+ return 1
1283
491
  if check_only:
492
+ if count == "0":
493
+ success("Already up to date.")
494
+ else:
495
+ note(f"{count} commit(s) available. Run `che update` to apply.")
1284
496
  return 0
1285
497
 
1286
- config = load_config()
1287
- if not config:
498
+ python = require_python()
499
+ if python is None:
500
+ return 1
501
+ if count == "0":
502
+ success("Already up to date.")
503
+ elif not _pull(count, dry_run=dry_run):
504
+ return 1
505
+
506
+ record = load_record()
507
+ if record is None:
1288
508
  note("che is not installed yet; running the installer.")
1289
- return wizard(dry_run=dry_run)
509
+ return wizard(InstallPlan(shells=default_shell_selection(), python=python, dry_run=dry_run))
1290
510
 
1291
511
  print()
1292
- plan = InstallPlan(
1293
- shells=config.get("shells", default_shell_selection(detect_shells())),
1294
- python=find_python()[0],
1295
- sounds=config.get("sounds", True),
1296
- path_shim=config.get("path_shim", True),
1297
- dry_run=dry_run,
1298
- )
1299
- result = perform_install(plan)
1300
- report(plan, result)
512
+ status = run_install(InstallPlan.from_record(record, python=python, dry_run=dry_run))
1301
513
 
1302
514
  print()
1303
515
  success("Updated. Reload your shell to pick up new commands:")
1304
516
  print(f" {colors.bold}exec {login_shell() or 'zsh'}{colors.reset}")
1305
- return 0
517
+ return status
1306
518
 
1307
519
 
1308
- def uninstall(*, dry_run: bool = False, purge: bool = False) -> int:
520
+ def _pull(count: str, *, dry_run: bool) -> bool:
521
+ """Fast-forward the clone by its ``count`` new commits, then list them."""
522
+ if has_tracked_changes(REPO):
523
+ warning("❌ The repo has uncommitted changes; not pulling.")
524
+ note(" Commit or stash them, then run `che update` again.")
525
+ return False
526
+ if dry_run:
527
+ note(f"would pull {count} commit(s)")
528
+ return True
529
+
530
+ before = run_output(["git", "rev-parse", "HEAD"], check=False, cwd=REPO)
531
+ if run(["git", "pull", "--ff-only"], cwd=REPO, check=False).returncode != 0:
532
+ warning("❌ git pull --ff-only failed - the branch has diverged.")
533
+ return False
534
+
1309
535
  colors = palette()
536
+ print()
537
+ note("What changed:")
538
+ log = run_output(
539
+ ["git", "log", "--oneline", "--no-decorate", f"{before}..HEAD"], check=False, cwd=REPO
540
+ )
541
+ for line in log.splitlines()[:20]:
542
+ print(f" {colors.grey}{line}{colors.reset}")
543
+ return True
544
+
545
+
546
+ def uninstall(*, dry_run: bool = False, purge: bool = False) -> int:
547
+ """Take out what ``che install`` put in: the rc blocks, the shim and the record.
548
+
549
+ ``purge`` also deletes the whole config directory, backups included, and
550
+ the state directory, which holds the menu's per-prompt input history.
551
+ """
552
+ removed = phrase("removed", dry_run=dry_run)
1310
553
  touched = False
1311
554
 
1312
- for target in detect_shells():
1313
- for rc_file in target.rc_files:
1314
- if remove_block(rc_file, dry_run=dry_run):
555
+ for spec in SHELLS.values():
556
+ for rc_file in spec.rc_files:
557
+ original = read_rc(rc_file)
558
+ if commit_rc(rc_file, original, without_block(original), dry_run=dry_run):
1315
559
  touched = True
1316
- display = str(rc_file).replace(str(home()), "~")
1317
- print(f" {colors.green}✓{colors.reset} removed block from {display}")
1318
-
1319
- shim = shim_path()
1320
- if shim.exists():
1321
- touched = True
1322
- if not dry_run:
1323
- shim.unlink()
1324
- print(f" {colors.green}✓{colors.reset} removed {shim}")
560
+ print(f" {mark()} {removed} block from {tildify(rc_file)}")
1325
561
 
1326
- if purge and config_dir().exists():
562
+ leftovers = [config_dir(), state_dir()] if purge else [config_file()]
563
+ if is_che_shim(shim_path()):
564
+ leftovers.insert(0, shim_path())
565
+ for path in leftovers:
566
+ if not path.exists():
567
+ continue
1327
568
  touched = True
1328
569
  if not dry_run:
1329
- shutil.rmtree(config_dir())
1330
- print(f" {colors.green}✓{colors.reset} removed {config_dir()}")
1331
- elif config_file().exists() and not dry_run:
1332
- config_file().unlink()
570
+ if path.is_dir():
571
+ shutil.rmtree(path)
572
+ else:
573
+ path.unlink()
574
+ print(f" {mark()} {removed} {tildify(path)}")
1333
575
 
1334
576
  if not touched:
1335
577
  note("Nothing to remove - che was not installed here.")
578
+ elif dry_run:
579
+ print()
580
+ note("Dry run: nothing was removed.")
1336
581
  else:
1337
582
  print()
1338
583
  success("Uninstalled. The repo itself is untouched; delete it to finish.")
@@ -1344,9 +589,9 @@ def uninstall(*, dry_run: bool = False, purge: bool = False) -> int:
1344
589
  # ---------------------------------------------------------------------------
1345
590
 
1346
591
 
1347
- def build_install_parser(prog: str = "che install"):
592
+ def build_install_parser() -> BoolFlagParser:
1348
593
  parser = build_parser(
1349
- prog=prog,
594
+ prog="che install",
1350
595
  description="📥 Install the che shell wrappers into your shell startup files.",
1351
596
  epilog=(
1352
597
  "Examples:\n"
@@ -1367,143 +612,109 @@ def build_install_parser(prog: str = "che install"):
1367
612
  add_bool_flag(parser, "--all-shells", help="Install into every shell present on this machine")
1368
613
  add_bool_flag(parser, "--yes", help="Skip the wizard and use the defaults")
1369
614
  add_bool_flag(parser, "--dry-run", help="Report what would change, change nothing")
615
+ # Accepted and ignored: the completions load with the wrappers and cannot
616
+ # be left out. Hidden, but kept so an existing `--no-completions` still runs.
1370
617
  add_bool_flag(
1371
618
  parser,
1372
619
  "--no-completions",
1373
- dest="no_completions",
1374
620
  allow_value=False,
1375
- help="Skip installing tab completions",
621
+ help=argparse.SUPPRESS,
1376
622
  )
1377
623
  add_bool_flag(
1378
624
  parser,
1379
625
  "--no-path-shim",
1380
- dest="no_path_shim",
1381
626
  allow_value=False,
1382
627
  help="Do not put a `che` executable in ~/.local/bin",
1383
628
  )
1384
629
  add_bool_flag(
1385
630
  parser,
1386
631
  "--no-sounds",
1387
- dest="no_sounds",
1388
632
  allow_value=False,
1389
633
  help="Do not call playsound-N when a command finishes",
1390
634
  )
1391
635
  add_bool_flag(
1392
636
  parser,
1393
637
  "--replace-legacy",
1394
- dest="replace_legacy",
1395
638
  help="Remove hand-written wrappers this repo now generates",
1396
639
  )
1397
640
  add_bool_flag(
1398
641
  parser,
1399
642
  "--install-deps",
1400
- dest="install_deps",
1401
643
  help="Install missing external tools with Homebrew",
1402
644
  )
1403
645
  add_bool_flag(parser, "--print", dest="print_block", help="Print the rc block and exit")
1404
- add_bool_flag(parser, "--check", help="Verify the generated files are current (for CI)")
1405
- add_bool_flag(parser, "--generate", help="Rewrite the files under shell/ and exit")
1406
646
  return parser
1407
647
 
1408
648
 
1409
649
  def install_main(argv: list[str] | None = None) -> int:
1410
- parser = build_install_parser()
1411
- args = parser.parse_args(argv)
1412
-
1413
- if args.generate or args.check:
1414
- try:
1415
- _expected_files()
1416
- except ValueError as exc:
1417
- warning(f"❌ {exc}")
1418
- return 1
1419
-
1420
- if args.generate:
1421
- changed = write_generated(dry_run=args.dry_run)
1422
- for relative in changed:
1423
- note(f"wrote {relative}")
1424
- success(f"✓ {len(changed) or 'no'} generated file(s) updated")
1425
- return 0
1426
-
1427
- if args.check:
1428
- stale = check_generated()
1429
- if stale:
1430
- warning("❌ Generated files are out of date:")
1431
- for path in stale:
1432
- note(f" {path}")
1433
- note(" Run `che install` (or `bun run generate`) and commit the result.")
1434
- return 1
1435
- success("✓ Generated shell files match bin/commands.py")
1436
- return 0
1437
-
1438
- python, version = find_python()
650
+ args = build_install_parser().parse_args(argv)
1439
651
 
1440
- if args.print_block:
1441
- shells = _requested_shells(args)
1442
- for shell in shells:
1443
- print(
1444
- shellgen.rc_block(
1445
- WRAPPER_FOR_SHELL[shell], home=REPO, python=python, sounds=not args.no_sounds
1446
- ),
1447
- end="",
1448
- )
1449
- return 0
1450
-
1451
- if not args.yes and not args.shells and not args.all_shells and is_interactive():
1452
- return wizard(dry_run=args.dry_run)
1453
-
1454
- if not version or version[:2] < MIN_PYTHON:
1455
- warning(f"❌ Python {'.'.join(str(p) for p in MIN_PYTHON)}+ is required.")
652
+ shells = _requested_shells(args)
653
+ if shells is None:
654
+ return 1
655
+ python = require_python()
656
+ if python is None:
1456
657
  return 1
1457
658
 
1458
659
  plan = InstallPlan(
1459
- shells=_requested_shells(args),
660
+ shells=shells,
1460
661
  python=python,
1461
662
  sounds=not args.no_sounds,
1462
- completions=not args.no_completions,
1463
663
  path_shim=not args.no_path_shim,
1464
664
  replace_legacy=args.replace_legacy,
1465
665
  dry_run=args.dry_run,
1466
666
  )
1467
667
 
668
+ if args.print_block:
669
+ for shell in plan.shells:
670
+ print(block_for(shell, plan), end="")
671
+ return 0
672
+
673
+ if not args.yes and not args.shells and not args.all_shells and is_interactive():
674
+ return wizard(plan, install_deps=args.install_deps)
675
+
1468
676
  print_logo(f"installing che {VERSION}")
1469
- result = perform_install(plan)
1470
- report(plan, result)
1471
-
1472
- if args.install_deps:
1473
- missing = [
1474
- name
1475
- for name, entry in binary_status().items()
1476
- if not entry["present"] and not entry["system"]
1477
- ]
1478
- if missing:
1479
- print()
1480
- install_missing_binaries(missing, dry_run=args.dry_run)
677
+ status = run_install(plan, install_deps=args.install_deps)
1481
678
 
1482
679
  print()
1483
680
  success("Reload your shell to pick up the wrappers: exec " + (login_shell() or "zsh"))
1484
- return 0
681
+ return status
1485
682
 
1486
683
 
1487
- def _requested_shells(args) -> list[str]:
1488
- targets = detect_shells()
684
+ def _requested_shells(args: argparse.Namespace) -> list[str] | None:
685
+ """The shells the flags ask for, or None, said why, when one is unknown."""
1489
686
  if args.all_shells:
1490
- return [target.name for target in targets if target.installed and target.name != "sh"]
687
+ return [spec.name for spec in SHELLS.values() if spec.installed and not spec.opt_in]
1491
688
  if args.shells:
1492
689
  requested = [part.strip() for part in args.shells.split(",") if part.strip()]
1493
- unknown = [name for name in requested if name not in RC_RESOLVERS]
690
+ unknown = [name for name in requested if name not in SHELLS]
1494
691
  if unknown:
1495
692
  warning(f"❌ Unknown shell(s): {', '.join(unknown)}")
1496
- note(f" Known: {', '.join(RC_RESOLVERS)}")
1497
- sys.exit(1)
693
+ note(f" Known: {', '.join(SHELLS)}")
694
+ return None
1498
695
  return requested
1499
- return default_shell_selection(targets)
696
+ return default_shell_selection()
1500
697
 
1501
698
 
1502
- def main() -> int:
1503
- return install_main()
699
+ def update_main(argv: list[str] | None = None) -> int:
700
+ parser = build_parser(
701
+ prog="che update", description="⬆️ Update the scripts, then refresh the wrappers."
702
+ )
703
+ add_bool_flag(parser, "--check", help="Report whether an update is available, apply nothing")
704
+ add_bool_flag(parser, "--dry-run", help="Report what would change, change nothing")
705
+ args = parser.parse_args(argv)
706
+ return update(check_only=args.check, dry_run=args.dry_run)
1504
707
 
1505
708
 
1506
- if __name__ == "__main__":
1507
- from utils import run_cli
709
+ def uninstall_main(argv: list[str] | None = None) -> int:
710
+ parser = build_parser(
711
+ prog="che uninstall", description="🧽 Remove the wrappers, shim and completions."
712
+ )
713
+ add_bool_flag(parser, "--dry-run", help="Report what would change, change nothing")
714
+ add_bool_flag(parser, "--purge", help="Also delete ~/.config/che and the menu's history")
715
+ args = parser.parse_args(argv)
716
+ return uninstall(dry_run=args.dry_run, purge=args.purge)
717
+
1508
718
 
1509
- run_cli(main)
719
+ if __name__ == "__main__":
720
+ run_cli(install_main)