cli-tools-kit 1.0.0__tar.gz → 1.1.0__tar.gz

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 (60) hide show
  1. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/PKG-INFO +35 -6
  2. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/README.md +34 -5
  3. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/__init__.py +1 -1
  4. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/cli.py +29 -1
  5. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/gui_installer.py +221 -44
  6. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/host.py +16 -0
  7. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/sources.py +37 -10
  8. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/state.py +9 -1
  9. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/tui_installer.py +48 -2
  10. cli_tools_kit-1.1.0/cli_tools_kit/upgrade.py +312 -0
  11. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit.egg-info/PKG-INFO +35 -6
  12. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit.egg-info/SOURCES.txt +3 -1
  13. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/tests/test_public_api.py +2 -2
  14. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/tests/test_sources.py +14 -0
  15. cli_tools_kit-1.1.0/tests/test_upgrade.py +196 -0
  16. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/LICENSE +0 -0
  17. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/__main__.py +0 -0
  18. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/advertise.py +0 -0
  19. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/autostart.py +0 -0
  20. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/autostart_gate.py +0 -0
  21. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/cron_installer.py +0 -0
  22. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/discovery.py +0 -0
  23. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/icons.py +0 -0
  24. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/identity.py +0 -0
  25. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/install.py +0 -0
  26. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/onboarding.py +0 -0
  27. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/settings.py +0 -0
  28. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/skills.py +0 -0
  29. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/sweep.py +0 -0
  30. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/__init__.py +0 -0
  31. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/build.py +0 -0
  32. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/capability.py +0 -0
  33. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/cluster.py +0 -0
  34. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/corpus.py +0 -0
  35. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/embedder.py +0 -0
  36. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/groups.py +0 -0
  37. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
  38. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/testing.py +0 -0
  39. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit/tool_installer.py +0 -0
  40. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
  41. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit.egg-info/entry_points.txt +0 -0
  42. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit.egg-info/requires.txt +0 -0
  43. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/cli_tools_kit.egg-info/top_level.txt +0 -0
  44. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/pyproject.toml +0 -0
  45. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/setup.cfg +0 -0
  46. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/tests/test_autostart_gate.py +0 -0
  47. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/tests/test_capability_groups.py +0 -0
  48. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/tests/test_cron_installer.py +0 -0
  49. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/tests/test_e2e.py +0 -0
  50. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/tests/test_engine.py +0 -0
  51. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/tests/test_gui_smoke.py +0 -0
  52. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/tests/test_hooks.py +0 -0
  53. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/tests/test_host.py +0 -0
  54. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/tests/test_identity.py +0 -0
  55. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/tests/test_llm_groups.py +0 -0
  56. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/tests/test_skills.py +0 -0
  57. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/tests/test_taxonomy_cluster.py +0 -0
  58. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/tests/test_testing.py +0 -0
  59. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/tests/test_tool_installer.py +0 -0
  60. {cli_tools_kit-1.0.0 → cli_tools_kit-1.1.0}/tests/test_tui_installer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-tools-kit
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Installer protocol + helpers for self-installing Python CLI/GUI tools (desktop shortcuts, bash aliases, cron entries), plus reusable tkinter and curses installer screens
5
5
  Author: Steffen Probst
6
6
  License: MIT License
@@ -305,23 +305,52 @@ Keyword-only; every argument defaults to `None`, meaning "leave the default".
305
305
  | `notify_app` | identity's | `notify-send` application label on the `--check` path. |
306
306
  | `autostart_check_desktop_name`, `check_log_name`, `check_state_name` | identity's | Login-check artifact filenames. |
307
307
  | `hooks` | `None` | `InstallHooks(install_tool=…, remove_tool=…, install_skill=…, uninstall_skill=…)`: replace how one tool is installed, for a wrapper that builds a venv per tool. A field left `None` keeps the kit's own. |
308
+ | `upgrade_repos` | `[]` | `(name, path)` of the tool repos an upgrade may pull. `sources.run_installer` fills it with the repos it cloned. See "Upgrades" below. |
308
309
 
309
310
  The identity is applied first and these individual names override it, so you can
310
311
  take the whole namespace from a slug and still change one thing.
311
312
 
312
313
  `run()` owns its own `argparse` and consumes `sys.argv`: `--list`, `--check`,
313
- `--enable-autostart-check`, `--install`, `--update-all`, `--cleanup`, `--tui`,
314
- `--gui`, and a screen when given none of them. A wrapper that needs its own
314
+ `--enable-autostart-check`, `--install`, `--update-all`, `--upgrade`, `--cleanup`,
315
+ `--tui`, `--gui`, and a screen when given none of them. A wrapper that needs its own
315
316
  subcommands should skip `run()` and call the primitives (`discover_tools`,
316
317
  `install_tool`, `remove_tool`, `cli_check`) after applying an identity with
317
318
  `_apply_identity`.
318
319
 
320
+ ### Upgrades
321
+
322
+ When the window or the text screen opens, the installer checks in the
323
+ background whether anything it runs is out of date:
324
+
325
+ - the installer's own checkout (the git repo `entry_script` is in) is behind
326
+ its upstream;
327
+ - a tool repo in `upgrade_repos` is behind its upstream;
328
+ - pip would install a newer cli-tools-kit within the installer's pin. The pin is
329
+ the `requirements.txt` next to `entry_script` when it names the kit, else
330
+ anything below the next major version.
331
+
332
+ If something is, a strip above the table lists it with an Upgrade button (on
333
+ the text screen, a log line and the `u` key). Upgrade pulls the repos with `git
334
+ pull --ff-only`, runs `pip install --upgrade` for the kit, reinstalls the
335
+ installed tools of each pulled repo, and starts the installer again with its
336
+ original command line so the new code is loaded. `--upgrade` does the same
337
+ from a script, checking at once and without the restart.
338
+
339
+ The network is used at most once a day: one `git fetch` per repo and one `pip
340
+ install --dry-run`, cached under the identity's cache directory. The
341
+ comparison with what is on disk runs on every start, so a repo pulled by hand
342
+ stops showing at once. Git never asks for a password here; a private repo
343
+ without stored credentials is skipped. The kit is left alone when it runs from
344
+ a development checkout or outside a virtual environment. `--check` never runs
345
+ any of this, so the login check stays network-free.
346
+
319
347
  ### The text screen
320
348
 
321
349
  Without a display (`DISPLAY`/`WAYLAND_DISPLAY` unset: SSH, WSL, a server) or
322
350
  without `python3-tk`, `run()` opens a curses screen instead of the tkinter
323
351
  window; `--tui` and `--gui` force either. Same rows, same Apply: `Space` ticks
324
- Install, `s` ticks Skill, `a`/`n` tick all or none, `Enter` applies, `q` quits.
352
+ Install, `s` ticks Skill, `a`/`n` tick all or none, `Enter` applies, `u`
353
+ upgrades when an upgrade is offered, `q` quits.
325
354
  On a host where none of the tools is installed yet every row starts ticked.
326
355
 
327
356
  A skill can go to more than one place. The default target writes
@@ -502,8 +531,8 @@ full rather than shallow (a tool that stamps its output with its commit needs th
502
531
  history), and nothing is cloned into a root that does not exist or cannot be
503
532
  written to. A clone that fails prints one line and that source is dropped, so a
504
533
  colleague without access to a private repo still gets everybody else's tools.
505
- `--refresh` brings the clones up to date with `git pull --ff-only`; a checkout
506
- given by `path` is never pulled. Cloning happens in the engine's `pre_discovery`
534
+ `--refresh` and the upgrade (see "Upgrades") bring the clones up to date with
535
+ `git pull --ff-only`; a checkout given by `path` is never pulled. Cloning happens in the engine's `pre_discovery`
507
536
  hook, which `--check` skips, so the login check stays network-free.
508
537
 
509
538
  An `org` entry is the only thing in this module that reaches anything but git,
@@ -245,23 +245,52 @@ Keyword-only; every argument defaults to `None`, meaning "leave the default".
245
245
  | `notify_app` | identity's | `notify-send` application label on the `--check` path. |
246
246
  | `autostart_check_desktop_name`, `check_log_name`, `check_state_name` | identity's | Login-check artifact filenames. |
247
247
  | `hooks` | `None` | `InstallHooks(install_tool=…, remove_tool=…, install_skill=…, uninstall_skill=…)`: replace how one tool is installed, for a wrapper that builds a venv per tool. A field left `None` keeps the kit's own. |
248
+ | `upgrade_repos` | `[]` | `(name, path)` of the tool repos an upgrade may pull. `sources.run_installer` fills it with the repos it cloned. See "Upgrades" below. |
248
249
 
249
250
  The identity is applied first and these individual names override it, so you can
250
251
  take the whole namespace from a slug and still change one thing.
251
252
 
252
253
  `run()` owns its own `argparse` and consumes `sys.argv`: `--list`, `--check`,
253
- `--enable-autostart-check`, `--install`, `--update-all`, `--cleanup`, `--tui`,
254
- `--gui`, and a screen when given none of them. A wrapper that needs its own
254
+ `--enable-autostart-check`, `--install`, `--update-all`, `--upgrade`, `--cleanup`,
255
+ `--tui`, `--gui`, and a screen when given none of them. A wrapper that needs its own
255
256
  subcommands should skip `run()` and call the primitives (`discover_tools`,
256
257
  `install_tool`, `remove_tool`, `cli_check`) after applying an identity with
257
258
  `_apply_identity`.
258
259
 
260
+ ### Upgrades
261
+
262
+ When the window or the text screen opens, the installer checks in the
263
+ background whether anything it runs is out of date:
264
+
265
+ - the installer's own checkout (the git repo `entry_script` is in) is behind
266
+ its upstream;
267
+ - a tool repo in `upgrade_repos` is behind its upstream;
268
+ - pip would install a newer cli-tools-kit within the installer's pin. The pin is
269
+ the `requirements.txt` next to `entry_script` when it names the kit, else
270
+ anything below the next major version.
271
+
272
+ If something is, a strip above the table lists it with an Upgrade button (on
273
+ the text screen, a log line and the `u` key). Upgrade pulls the repos with `git
274
+ pull --ff-only`, runs `pip install --upgrade` for the kit, reinstalls the
275
+ installed tools of each pulled repo, and starts the installer again with its
276
+ original command line so the new code is loaded. `--upgrade` does the same
277
+ from a script, checking at once and without the restart.
278
+
279
+ The network is used at most once a day: one `git fetch` per repo and one `pip
280
+ install --dry-run`, cached under the identity's cache directory. The
281
+ comparison with what is on disk runs on every start, so a repo pulled by hand
282
+ stops showing at once. Git never asks for a password here; a private repo
283
+ without stored credentials is skipped. The kit is left alone when it runs from
284
+ a development checkout or outside a virtual environment. `--check` never runs
285
+ any of this, so the login check stays network-free.
286
+
259
287
  ### The text screen
260
288
 
261
289
  Without a display (`DISPLAY`/`WAYLAND_DISPLAY` unset: SSH, WSL, a server) or
262
290
  without `python3-tk`, `run()` opens a curses screen instead of the tkinter
263
291
  window; `--tui` and `--gui` force either. Same rows, same Apply: `Space` ticks
264
- Install, `s` ticks Skill, `a`/`n` tick all or none, `Enter` applies, `q` quits.
292
+ Install, `s` ticks Skill, `a`/`n` tick all or none, `Enter` applies, `u`
293
+ upgrades when an upgrade is offered, `q` quits.
265
294
  On a host where none of the tools is installed yet every row starts ticked.
266
295
 
267
296
  A skill can go to more than one place. The default target writes
@@ -442,8 +471,8 @@ full rather than shallow (a tool that stamps its output with its commit needs th
442
471
  history), and nothing is cloned into a root that does not exist or cannot be
443
472
  written to. A clone that fails prints one line and that source is dropped, so a
444
473
  colleague without access to a private repo still gets everybody else's tools.
445
- `--refresh` brings the clones up to date with `git pull --ff-only`; a checkout
446
- given by `path` is never pulled. Cloning happens in the engine's `pre_discovery`
474
+ `--refresh` and the upgrade (see "Upgrades") bring the clones up to date with
475
+ `git pull --ff-only`; a checkout given by `path` is never pulled. Cloning happens in the engine's `pre_discovery`
447
476
  hook, which `--check` skips, so the login check stays network-free.
448
477
 
449
478
  An `org` entry is the only thing in this module that reaches anything but git,
@@ -56,4 +56,4 @@ __all__ = [
56
56
  "read_installed_skill",
57
57
  ]
58
58
 
59
- __version__ = "1.0.0"
59
+ __version__ = "1.1.0"
@@ -226,6 +226,22 @@ def cli_cleanup(dry_run: bool = False) -> int:
226
226
  return errors
227
227
 
228
228
 
229
+ def cli_upgrade(tools: List[discovery.ToolEntry]) -> int:
230
+ """Check for newer versions now and upgrade what is behind. Returns 0 or 1."""
231
+ from . import upgrade # noqa: PLC0415 — imports sources, only this path needs it
232
+ print("\nChecking for upgrades...")
233
+ items = upgrade.check(force=True)
234
+ if not items:
235
+ print("Everything is up to date.\n")
236
+ return 0
237
+ for item in items:
238
+ print(f" {item.label()}")
239
+ print()
240
+ result = upgrade.upgrade(items, tools, lambda msg, tag="info": print(msg))
241
+ print(f"\nDone{', with errors' if result['errors'] else ''}.\n")
242
+ return 1 if result["errors"] else 0
243
+
244
+
229
245
  def cli_update_all(tools: List[discovery.ToolEntry]) -> int:
230
246
  """Sync: clean up orphans, then reinstall manager and all installed tool shortcuts.
231
247
 
@@ -439,6 +455,9 @@ def main():
439
455
  help="Install the login update-check autostart entry")
440
456
  parser.add_argument("--disable-autostart-check", action="store_true",
441
457
  help="Remove the login update-check autostart entry")
458
+ parser.add_argument("--upgrade", action="store_true",
459
+ help="Check the installer's checkout, the tool repos it cloned and "
460
+ "cli-tools-kit for newer versions, and upgrade them (network).")
442
461
  parser.add_argument("--refresh", action="store_true",
443
462
  help="Before discovery, run the configured PRE_DISCOVERY hook in refresh "
444
463
  "mode (e.g. ff-only pull every known repo checkout). No-op without a hook.")
@@ -494,6 +513,9 @@ def main():
494
513
  if args.update_all:
495
514
  sys.exit(1 if cli_update_all(tools) else 0)
496
515
 
516
+ if args.upgrade:
517
+ sys.exit(cli_upgrade(tools))
518
+
497
519
  if args.apply:
498
520
  from . import tui_installer
499
521
  sys.exit(tui_installer.apply_headless(tools, ",".join(args.apply), args.skill_target,
@@ -549,7 +571,8 @@ def run(*, identity: Optional[InstallerIdentity] = None,
549
571
  self_desktop_file: Optional[str] = None, self_desktop_name: Optional[str] = None,
550
572
  self_desktop_icon: Optional[str] = None,
551
573
  wm_class: Optional[str] = None, notify_app: Optional[str] = None,
552
- hooks: Optional[install.InstallHooks] = None) -> None:
574
+ hooks: Optional[install.InstallHooks] = None,
575
+ upgrade_repos: Optional[List] = None) -> None:
553
576
  """Configure the engine from a thin wrapper and dispatch the standard CLI/GUI.
554
577
 
555
578
  Every argument maps to a module-level config global; ``None`` leaves the
@@ -567,6 +590,9 @@ def run(*, identity: Optional[InstallerIdentity] = None,
567
590
  ``hooks`` replaces how one tool is installed, removed or given its skill,
568
591
  for a wrapper that builds a venv per tool itself. See :class:`InstallHooks`.
569
592
 
593
+ ``upgrade_repos`` lists ``(name, path)`` of the tool repos the upgrade may
594
+ pull; ``sources.run_installer`` fills it with the repos it cloned.
595
+
570
596
  ``prune`` adds directory names the default wider walk never enters, on top
571
597
  of ``DISCOVERY_PRUNE``. It does nothing when a wrapper passes its own
572
598
  ``discoverer``.
@@ -629,6 +655,8 @@ def run(*, identity: Optional[InstallerIdentity] = None,
629
655
  state.NOTIFY_APP = notify_app
630
656
  if hooks is not None:
631
657
  install._apply_hooks(hooks)
658
+ if upgrade_repos is not None:
659
+ state.UPGRADE_REPOS = upgrade_repos
632
660
 
633
661
  state._load_env() # re-read ROOT_DIR/.env now that ROOT_DIR is final
634
662
  state._recompute_check_paths() # re-derive login-check artifact paths from the names
@@ -62,6 +62,7 @@ except ImportError: # pragma: no cover - exercised only where python3-tk is mis
62
62
  from typing import Dict, List, Optional
63
63
 
64
64
  from . import host, state, settings, discovery, install, sweep, autostart, icons, cli
65
+ from . import upgrade
65
66
  from .autostart_gate import (
66
67
  _parse_hhmm,
67
68
  current_ssids,
@@ -98,6 +99,18 @@ RIGHT_COLS = (
98
99
  ("autostart", 85),
99
100
  )
100
101
 
102
+ # The texts each right-hand column shows. A column is made wide enough for its
103
+ # longest text in the current font, so "✗ Not installed" is not cut off when
104
+ # the display scaling makes the font larger than the widths above assume.
105
+ _COLUMN_TEXTS = {
106
+ "uses": ("Uses", "9999"),
107
+ "status": ("Status", "✓ Installed", "✗ Not installed", "⟳ Needs Update",
108
+ "⟳ Skill update", "⟳ Update + skill"),
109
+ "skill": ("Skill",),
110
+ "icon": ("Icon",),
111
+ "autostart": ("Auto-Start",),
112
+ }
113
+
101
114
  _DEBUG_CELL_COLORS = {
102
115
  "uses": "#00ffff",
103
116
  "status": "#0000ff",
@@ -258,6 +271,10 @@ class InstallerApp:
258
271
  self.tk_widgets: List[tk.Widget] = [] # Other tk widgets that need bg updates
259
272
  self.usage_counts: Dict[str, int] = get_all_usage_counts() # Tool usage statistics
260
273
 
274
+ # What upgrade.check() found; the strip above the table shows it and
275
+ # survives a theme rebuild.
276
+ self._upgrade_items: List[upgrade.Item] = []
277
+
261
278
  # Search/filter tracking
262
279
  self.search_var = tk.StringVar()
263
280
  self.category_widgets: Dict[str, tk.Widget] = {} # category_name -> frame
@@ -272,6 +289,8 @@ class InstallerApp:
272
289
  self.orphan_desktops = sweep.find_orphan_desktop_files()
273
290
  self.orphan_aliases = sweep.find_orphan_aliases()
274
291
 
292
+ self._col_widths = self._column_widths()
293
+ self._wrap_held = False # see _position_window
275
294
  self._setup_ui()
276
295
  self._position_window()
277
296
  self.root.deiconify() # Show now that it's properly sized
@@ -285,6 +304,10 @@ class InstallerApp:
285
304
  # warning so the two don't fight over the log/dialog at the same tick.
286
305
  self.root.after(200, self._maybe_auto_update_on_startup)
287
306
 
307
+ # Newer installer, tool repos or kit: asks git and pip off the main
308
+ # thread, over the network at most once a day (see upgrade.check).
309
+ self.root.after(300, self._start_upgrade_check)
310
+
288
311
  # Standard freedesktop icon sizes. wm iconphoto silently fails to set
289
312
  # _NET_WM_ICON at all when handed only a source-resolution (e.g. 512x512)
290
313
  # image on some Tk/X11 combinations, so multiple smaller sizes are passed
@@ -314,6 +337,22 @@ class InstallerApp:
314
337
  except Exception:
315
338
  pass # cosmetic only; never block startup over a bad/unsupported icon
316
339
 
340
+ def _column_widths(self) -> Dict[str, int]:
341
+ """RIGHT_COLS widths, scaled with the display and fitted to their texts.
342
+
343
+ The pixel widths in RIGHT_COLS were chosen at Tk's 96 dpi scaling.
344
+ They grow with the scaling and never drop below what the column's
345
+ longest text needs in the default font.
346
+ """
347
+ from tkinter import font as tkfont # noqa: PLC0415 — tkinter is optional at import
348
+ font = tkfont.nametofont("TkDefaultFont")
349
+ scale = max(1.0, float(self.root.tk.call("tk", "scaling")) / (96 / 72))
350
+ widths = {}
351
+ for key, base in RIGHT_COLS:
352
+ text = max((font.measure(t) for t in _COLUMN_TEXTS.get(key, ())), default=0)
353
+ widths[key] = max(round(base * scale), text + 16)
354
+ return widths
355
+
317
356
  def _get_primary_monitor_geometry(self) -> tuple[int, int, int, int]:
318
357
  """Get primary monitor geometry (x, y, width, height). Falls back to tkinter defaults."""
319
358
  try:
@@ -338,41 +377,54 @@ class InstallerApp:
338
377
  return 0, 0, self.root.winfo_screenwidth(), self.root.winfo_screenheight()
339
378
 
340
379
  def _position_window(self):
341
- """Position window: fit to content if possible, otherwise use full screen height with scrollbar."""
380
+ """Size the window to what every row asks for, within the monitor.
381
+
382
+ The width is the widest of the title row, the footer and the tool
383
+ table. The height is the fixed rows plus the table, measured after the
384
+ width is set, because tool descriptions wrap to the width and a wider
385
+ table is shorter. Both stay within 90% of the monitor, which leaves
386
+ room for panels and the title bar; beyond that the table scrolls. The
387
+ minimum size keeps the footer's buttons from being cut off.
388
+ """
342
389
  self.root.update_idletasks()
343
-
344
390
  mon_x, mon_y, mon_width, mon_height = self._get_primary_monitor_geometry()
391
+ avail_w, avail_h = int(mon_width * 0.9), int(mon_height * 0.9)
392
+
393
+ # The root's request covers the title, search and footer rows; the table
394
+ # sits in a canvas and is not part of it. The table's own request is no
395
+ # use either: until the window is drawn every description asks for one
396
+ # long line. So the table counts with its fixed columns plus room for
397
+ # the tool names.
398
+ scale = max(1.0, float(self.root.tk.call("tk", "scaling")) / (96 / 72))
399
+ scrollbar_w = self.scrollbar.winfo_reqwidth()
400
+ fixed_cols = sum(self._col_widths.values()) + len(RIGHT_COLS)
401
+ overhead = 40 + scrollbar_w + sum(TABLE_PADX) + fixed_cols + 70 # padding, checkbox
402
+ rows_w = self.root.winfo_reqwidth()
403
+ window_width = min(max(rows_w, overhead + round(300 * scale)), avail_w)
404
+
405
+ # Wrap the descriptions for that width before measuring the height.
406
+ # Their <Configure> handlers are held meanwhile: while the window is
407
+ # hidden they see the table's narrow first layout and wrap at 100 px.
408
+ # Once it is shown they set the exact wrap.
409
+ self._wrap_held = True
410
+ for label in self._desc_labels:
411
+ label.configure(wraplength=max(100, window_width - overhead))
412
+ self.root.update_idletasks()
345
413
 
346
- # Get actual content dimensions
347
- scrollable_height = self.scrollable_frame.winfo_reqheight()
348
- content_width = self.scrollable_frame.winfo_reqwidth() + 60 # padding
349
- # Add height for title (~60), footer (~60), log header (~40), status bar (~30), padding (~40)
350
- non_scrollable_height = 230
351
- content_height = scrollable_height + non_scrollable_height
352
-
353
- # Clamp width to reasonable bounds (max 800 for usability)
354
- window_width = max(500, min(content_width, 800, mon_width - 100))
355
-
356
- # Use content height if it fits, otherwise use full monitor height
357
- if content_height <= mon_height:
358
- window_height = content_height
359
- # Center vertically on primary monitor
360
- y = mon_y + (mon_height - window_height) // 2
361
- # Hide scrollbar initially - content fits
362
- self.scrollbar.grid_forget()
363
- else:
364
- # Use full monitor height, align to top
365
- window_height = mon_height
366
- y = mon_y
367
- # Show scrollbar initially - content exceeds screen
414
+ fixed_h = self.root.winfo_reqheight() - self.outer_frame.winfo_reqheight()
415
+ content_h = fixed_h + self.scrollable_frame.winfo_reqheight()
416
+ window_height = min(content_h, avail_h)
417
+ if content_h > avail_h:
368
418
  self.scrollbar.grid(row=0, column=1, sticky="ns")
419
+ else:
420
+ self.scrollbar.grid_forget()
369
421
 
370
- # Center horizontally on primary monitor
371
422
  x = mon_x + (mon_width - window_width) // 2
372
-
423
+ y = mon_y + (mon_height - window_height) // 2
373
424
  self.root.geometry(f"{window_width}x{window_height}+{x}+{y}")
374
- # Allow resizing in both directions
425
+ self.root.minsize(min(rows_w, avail_w), min(fixed_h + 150, window_height))
375
426
  self.root.resizable(True, True)
427
+ self._wrap_held = False
376
428
 
377
429
  # Always enable mouse wheel scrolling
378
430
  self._bind_mousewheel()
@@ -720,7 +772,7 @@ class InstallerApp:
720
772
  main_container = ttk.Frame(self.root, padding="20")
721
773
  main_container.grid(row=0, column=0, sticky="nsew")
722
774
  main_container.columnconfigure(0, weight=1)
723
- main_container.rowconfigure(2, weight=1)
775
+ main_container.rowconfigure(3, weight=1)
724
776
 
725
777
  # Title
726
778
  title_frame = ttk.Frame(main_container)
@@ -771,9 +823,14 @@ class InstallerApp:
771
823
  "Re-tint this theme from a base color "
772
824
  "(re-click the theme emoji to reset)")
773
825
 
826
+ # Upgrade strip — only gridded while upgrade.check() has found something.
827
+ self.upgrade_bar = tk.Frame(main_container, bg=t["panel"], highlightthickness=1,
828
+ highlightbackground=t["accent"])
829
+ self._render_upgrade_bar()
830
+
774
831
  # Search bar
775
832
  search_frame = ttk.Frame(main_container)
776
- search_frame.grid(row=1, column=0, pady=(0, 10), sticky="ew")
833
+ search_frame.grid(row=2, column=0, pady=(0, 10), sticky="ew")
777
834
 
778
835
  self.search_entry = tk.Entry(
779
836
  search_frame, textvariable=self.search_var,
@@ -832,7 +889,7 @@ class InstallerApp:
832
889
 
833
890
  # Scrollable Area
834
891
  self.outer_frame = ttk.Frame(main_container, relief="flat")
835
- self.outer_frame.grid(row=2, column=0, sticky="nsew")
892
+ self.outer_frame.grid(row=3, column=0, sticky="nsew")
836
893
  self.outer_frame.columnconfigure(0, weight=1)
837
894
  self.outer_frame.rowconfigure(0, weight=1)
838
895
 
@@ -868,6 +925,7 @@ class InstallerApp:
868
925
  for group_label in sorted(tools_by_group):
869
926
  categories[group_label] = discovery.group_tools(tools_by_group[group_label])
870
927
 
928
+ self._desc_labels: List[ttk.Label] = [] # wrapped to the width in _position_window
871
929
  self.expand_vars: Dict[str, tk.BooleanVar] = {} # Track expanded state
872
930
  self.children_frames: Dict[str, ttk.Frame] = {} # Track child frames for show/hide
873
931
 
@@ -925,7 +983,15 @@ class InstallerApp:
925
983
 
926
984
  # Footer / Buttons
927
985
  footer = ttk.Frame(main_container, padding=(0, 20, 0, 0))
928
- footer.grid(row=3, column=0, sticky="ew")
986
+ footer.grid(row=4, column=0, sticky="ew")
987
+
988
+ # Packed first: when the window is narrower than the footer, pack takes
989
+ # the space from the widgets packed last, so Apply stays visible.
990
+ self._apply_btn = ttk.Button(footer, text="Apply Changes", style="Accent.TButton", command=self._apply_changes)
991
+ self._apply_btn.pack(side="right", padx=5)
992
+ self._apply_highlighted = False
993
+ self._op_in_progress = False # guards against overlapping bulk operations
994
+ ttk.Button(footer, text="Refresh Status", command=self._update_status_labels).pack(side="right", padx=5)
929
995
 
930
996
  ttk.Button(footer, text="Select All", command=self._select_all).pack(side="left", padx=5)
931
997
  ttk.Button(footer, text="Select None", command=self._select_none).pack(side="left", padx=5)
@@ -993,17 +1059,11 @@ class InstallerApp:
993
1059
 
994
1060
  ttk.Separator(footer, orient="vertical").pack(side="left", fill="y", padx=15)
995
1061
 
996
- self._apply_btn = ttk.Button(footer, text="Apply Changes", style="Accent.TButton", command=self._apply_changes)
997
- self._apply_btn.pack(side="right", padx=5)
998
- self._apply_highlighted = False
999
- self._op_in_progress = False # guards against overlapping bulk operations
1000
- ttk.Button(footer, text="Refresh Status", command=self._update_status_labels).pack(side="right", padx=5)
1001
-
1002
1062
  # Collapsible Log Area
1003
1063
  self.log_expanded = tk.BooleanVar(value=False)
1004
1064
 
1005
1065
  log_container = ttk.Frame(main_container)
1006
- log_container.grid(row=4, column=0, sticky="nsew", pady=(15, 0))
1066
+ log_container.grid(row=5, column=0, sticky="nsew", pady=(15, 0))
1007
1067
  log_container.columnconfigure(0, weight=1)
1008
1068
 
1009
1069
  # Header row (clickable to expand/collapse)
@@ -1060,7 +1120,7 @@ class InstallerApp:
1060
1120
  self.log_text.see("end")
1061
1121
 
1062
1122
  indicator_frame = ttk.Frame(main_container)
1063
- indicator_frame.grid(row=5, column=0, sticky="ew", pady=(10, 0))
1123
+ indicator_frame.grid(row=6, column=0, sticky="ew", pady=(10, 0))
1064
1124
 
1065
1125
  self.status_bar = ttk.Label(indicator_frame, text="Ready", foreground=t["muted"], font=("", 9, "italic"))
1066
1126
  self.status_bar.pack(side="left")
@@ -2700,7 +2760,8 @@ class InstallerApp:
2700
2760
  height=24 is only a floor for the header row, whose labels are short).
2701
2761
  """
2702
2762
  cells: Dict[str, tk.Frame] = {}
2703
- for col_key, width in RIGHT_COLS:
2763
+ for col_key, _ in RIGHT_COLS:
2764
+ width = self._col_widths[col_key]
2704
2765
  sep = tk.Frame(container, width=1, bg=self.theme["border"],
2705
2766
  highlightthickness=0)
2706
2767
  sep.pack(side="left", fill="y")
@@ -2840,9 +2901,11 @@ class InstallerApp:
2840
2901
  desc_text = self._format_desc_with_alias(tool)
2841
2902
  desc_label = ttk.Label(info_frame, text=desc_text, style="CardMuted.TLabel", font=("", 9))
2842
2903
  desc_label.pack(anchor="w", fill="x")
2904
+ self._desc_labels.append(desc_label)
2843
2905
  # Dynamic wraplength based on available width
2844
2906
  def update_wrap(event, lbl=desc_label):
2845
- lbl.configure(wraplength=max(100, event.width - 10))
2907
+ if not self._wrap_held:
2908
+ lbl.configure(wraplength=max(100, event.width - 10))
2846
2909
  info_frame.bind("<Configure>", update_wrap)
2847
2910
 
2848
2911
  def _render_tool_group(self, group: discovery.ToolGroup, current_row: int) -> int:
@@ -2985,9 +3048,11 @@ class InstallerApp:
2985
3048
  desc_text = self._format_desc_with_alias(parent)
2986
3049
  desc_label = ttk.Label(info_frame, text=desc_text, style="CardMuted.TLabel", font=("", 9))
2987
3050
  desc_label.pack(anchor="w", fill="x")
3051
+ self._desc_labels.append(desc_label)
2988
3052
  # Dynamic wraplength based on available width
2989
3053
  def update_wrap(event, lbl=desc_label):
2990
- lbl.configure(wraplength=max(100, event.width - 10))
3054
+ if not self._wrap_held:
3055
+ lbl.configure(wraplength=max(100, event.width - 10))
2991
3056
  info_frame.bind("<Configure>", update_wrap)
2992
3057
 
2993
3058
  current_row += 1
@@ -3888,6 +3953,117 @@ class InstallerApp:
3888
3953
  else:
3889
3954
  self._log("Already up to date — nothing to do.", "success")
3890
3955
 
3956
+ # --- upgrades -----------------------------------------------------------------
3957
+
3958
+ def _start_upgrade_check(self):
3959
+ """Run upgrade.check() on a thread and show what it finds in the strip."""
3960
+ results: queue.Queue = queue.Queue()
3961
+
3962
+ def work():
3963
+ try:
3964
+ results.put(upgrade.check())
3965
+ except Exception as exc: # a failed check must not take the window down
3966
+ results.put(exc)
3967
+
3968
+ threading.Thread(target=work, daemon=True).start()
3969
+ self.root.after(200, lambda: self._poll_upgrade_check(results))
3970
+
3971
+ def _poll_upgrade_check(self, results):
3972
+ try:
3973
+ found = results.get_nowait()
3974
+ except queue.Empty:
3975
+ self.root.after(200, lambda: self._poll_upgrade_check(results))
3976
+ return
3977
+ if isinstance(found, Exception):
3978
+ self._log(f"Upgrade check failed: {found}", "error")
3979
+ return
3980
+ was_shown = bool(self._upgrade_items)
3981
+ self._upgrade_items = list(found)
3982
+ self._render_upgrade_bar()
3983
+ if self._upgrade_items and not was_shown:
3984
+ # The window was sized without the strip; make room for it rather
3985
+ # than take its height from the table.
3986
+ self.root.update_idletasks()
3987
+ _, _, _, mon_height = self._get_primary_monitor_geometry()
3988
+ height = min(self.root.winfo_height() + self.upgrade_bar.winfo_reqheight() + 10,
3989
+ int(mon_height * 0.9))
3990
+ self.root.geometry(f"{self.root.winfo_width()}x{height}")
3991
+
3992
+ def _render_upgrade_bar(self):
3993
+ """Show the upgrade strip for what the last check found, or hide it."""
3994
+ bar = self.upgrade_bar
3995
+ for child in bar.winfo_children():
3996
+ child.destroy()
3997
+ self._upgrade_btn = None
3998
+ if not self._upgrade_items:
3999
+ bar.grid_remove()
4000
+ return
4001
+ t = self.theme
4002
+ # The button is packed first so a narrow window squeezes the text, not it.
4003
+ self._upgrade_btn = ttk.Button(bar, text="Upgrade", style="Accent.TButton",
4004
+ command=self._upgrade_clicked)
4005
+ self._upgrade_btn.pack(side="right", padx=8, pady=6)
4006
+ if getattr(self, "_op_in_progress", False):
4007
+ self._upgrade_btn.configure(state="disabled")
4008
+ tk.Label(bar, text="Upgrade available:", bg=t["panel"], fg=t["accent"],
4009
+ font=("", 10, "bold")).pack(side="left", padx=(10, 6), pady=6)
4010
+ detail = tk.Label(bar, text=" · ".join(item.label() for item in self._upgrade_items),
4011
+ bg=t["panel"], fg=t["fg"], font=("", 10), anchor="w",
4012
+ justify="left", wraplength=400)
4013
+ detail.pack(side="left", fill="x", expand=True, pady=6)
4014
+ detail.bind("<Configure>",
4015
+ lambda e: detail.configure(wraplength=max(150, e.width - 10)))
4016
+ bar.grid(row=1, column=0, pady=(0, 10), sticky="ew")
4017
+
4018
+ def _upgrade_clicked(self):
4019
+ if self._op_in_progress or not self._upgrade_items:
4020
+ return
4021
+ items = list(self._upgrade_items)
4022
+ lines = "\n".join(f" • {item.label()}" for item in items)
4023
+ if not messagebox.askyesno(
4024
+ "Upgrade?",
4025
+ f"This brings these up to date:\n\n{lines}\n\n"
4026
+ "Repos are pulled, cli-tools-kit is upgraded with pip, the installed "
4027
+ "tools of a pulled repo are reinstalled, and the installer restarts.\n\n"
4028
+ "This uses the network. Continue?",
4029
+ ):
4030
+ return
4031
+ self._op_in_progress = True
4032
+ self._set_action_buttons_state("disabled")
4033
+ self._clear_hint()
4034
+ self._toggle_log(force_expand=True)
4035
+ events: queue.Queue = queue.Queue()
4036
+
4037
+ def work():
4038
+ def log(msg, tag="info"):
4039
+ events.put(("log", msg, tag))
4040
+ try:
4041
+ result = upgrade.upgrade(items, self.tools, log)
4042
+ except Exception as exc: # the UI must come back whatever went wrong
4043
+ log(f"Upgrade failed: {exc}", "error")
4044
+ result = {"changed": False, "errors": 1}
4045
+ events.put(("done", result))
4046
+
4047
+ threading.Thread(target=work, daemon=True).start()
4048
+ self.root.after(50, lambda: self._drain_op_events(events, self._finish_upgrade))
4049
+
4050
+ def _finish_upgrade(self, result):
4051
+ self._op_in_progress = False
4052
+ self._set_action_buttons_state("normal")
4053
+ if result["changed"] and not result["errors"]:
4054
+ self._log("Restarting the installer to load the new version...", "header")
4055
+ self.root.after(1500, self._restart_installer)
4056
+ return
4057
+ if result["changed"]:
4058
+ self._log("Some steps failed (see above). Close and reopen the installer "
4059
+ "to load what was upgraded.", "error")
4060
+ self._start_upgrade_check() # the strip then shows only what is still behind
4061
+
4062
+ def _restart_installer(self):
4063
+ argv = upgrade.restart_argv()
4064
+ self.root.destroy()
4065
+ host.restart(argv)
4066
+
3891
4067
  def _toggle_autostart_check(self):
3892
4068
  """Enable/disable the login update-check autostart entry."""
3893
4069
  if self._autostart_check_var.get():
@@ -4087,7 +4263,7 @@ class InstallerApp:
4087
4263
 
4088
4264
  Greys the ttk action buttons and the custom update badge together; the badge
4089
4265
  also ignores clicks while disabled (see _set_update_enabled)."""
4090
- for attr in ("_reinstall_btn", "_apply_btn"):
4266
+ for attr in ("_reinstall_btn", "_apply_btn", "_upgrade_btn"):
4091
4267
  btn = getattr(self, attr, None)
4092
4268
  if btn is not None:
4093
4269
  btn.configure(state=state)
@@ -4098,7 +4274,8 @@ class InstallerApp:
4098
4274
  # globals (gi.IDENTITY, gi.install_tool = ...). Those names now live in the
4099
4275
  # modules below. Reading, assigning or deleting one through gui_installer goes
4100
4276
  # to the module that holds it, where every screen looks it up at call time.
4101
- _ENGINE_MODULES = (state, settings, discovery, install, sweep, autostart, icons, cli)
4277
+ _ENGINE_MODULES = (state, settings, discovery, install, sweep, autostart, icons, cli,
4278
+ upgrade)
4102
4279
 
4103
4280
 
4104
4281
  def _home_of(name: str):
@@ -68,6 +68,22 @@ def child_env(env: Optional[dict] = None) -> dict:
68
68
  return env
69
69
 
70
70
 
71
+ # --- restarting -------------------------------------------------------------------
72
+
73
+ def restart(argv: Iterable[str]) -> None:
74
+ """Replace this process with a new one running ``argv``; does not return.
75
+
76
+ After an upgrade the old code is still loaded, so the installer starts
77
+ again. Windows has no real exec (``os.execv`` there spawns a child and
78
+ leaves the console behind), so it starts the new process and exits.
79
+ """
80
+ argv = list(argv)
81
+ if IS_WINDOWS:
82
+ subprocess.Popen(argv)
83
+ os._exit(0)
84
+ os.execv(argv[0], argv)
85
+
86
+
71
87
  # --- where the shims live -----------------------------------------------------
72
88
 
73
89
  def shim_dir(identity) -> str: