@codebend3r/che 1.0.0

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