cli-tools-kit 0.6.0__tar.gz → 0.6.2__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 (41) hide show
  1. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/PKG-INFO +20 -6
  2. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/README.md +19 -5
  3. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit/__init__.py +1 -1
  4. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit/gui_installer.py +46 -0
  5. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit/sources.py +178 -15
  6. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit.egg-info/PKG-INFO +20 -6
  7. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/pyproject.toml +1 -1
  8. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/tests/test_sources.py +164 -4
  9. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/LICENSE +0 -0
  10. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit/__main__.py +0 -0
  11. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit/advertise.py +0 -0
  12. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit/cron_installer.py +0 -0
  13. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit/host.py +0 -0
  14. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit/identity.py +0 -0
  15. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit/onboarding.py +0 -0
  16. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit/skills.py +0 -0
  17. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit/taxonomy/__init__.py +0 -0
  18. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit/taxonomy/build.py +0 -0
  19. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit/taxonomy/capability.py +0 -0
  20. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit/taxonomy/cluster.py +0 -0
  21. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit/taxonomy/corpus.py +0 -0
  22. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit/taxonomy/embedder.py +0 -0
  23. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit/taxonomy/groups.py +0 -0
  24. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
  25. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit/tool_installer.py +0 -0
  26. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit/tui_installer.py +0 -0
  27. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit.egg-info/SOURCES.txt +0 -0
  28. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
  29. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit.egg-info/entry_points.txt +0 -0
  30. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit.egg-info/requires.txt +0 -0
  31. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/cli_tools_kit.egg-info/top_level.txt +0 -0
  32. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/setup.cfg +0 -0
  33. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/tests/test_capability_groups.py +0 -0
  34. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/tests/test_cron_installer.py +0 -0
  35. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/tests/test_host.py +0 -0
  36. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/tests/test_identity.py +0 -0
  37. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/tests/test_llm_groups.py +0 -0
  38. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/tests/test_skills.py +0 -0
  39. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/tests/test_taxonomy_cluster.py +0 -0
  40. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/tests/test_tool_installer.py +0 -0
  41. {cli_tools_kit-0.6.0 → cli_tools_kit-0.6.2}/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: 0.6.0
3
+ Version: 0.6.2
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
@@ -417,8 +417,20 @@ path = "/home/me/work/lab-tools"
417
417
 
418
418
  A source resolves in this order: the path from the local file, then the `path`
419
419
  from the tracked file, then an existing `<root>/<name>`, then a clone of `url`
420
- into `<root>/<name>`. The root is `--root DIR` if given, else the local file's
421
- `root`, else two levels above the directory the config file sits in.
420
+ into `<root>/<name>`.
421
+
422
+ The root is `--root DIR` if given, else the local file's `root`, else the answer
423
+ to a question. The GUI asks in a small dialog before discovery, the text screen
424
+ asks in one line on stdin, and both suggest `<current directory>/<name>` as an
425
+ absolute path — `<name>` is `run_installer`'s `default_root_name`, `tools` by
426
+ default. The answer is written into `installer.local.toml` as its `root`, above
427
+ any `[[source]]` tables already there, so the question is asked once per
428
+ machine; a `root` that file already has is left alone. The directory is created
429
+ if it does not exist, and cancelling the dialog installs nothing.
430
+
431
+ A headless run never asks: `--list`, `--apply`, `--check`, or a text screen with
432
+ no terminal take the suggestion and print `root: /abs/path (pass --root to
433
+ change)`.
422
434
 
423
435
  Cloning is deliberately narrow. Only `https://` URLs are cloned, `ext::` and
424
436
  `file://` transports and any hook are switched off for the git call, the clone is
@@ -447,14 +459,16 @@ from cli_tools_kit.sources import run_installer
447
459
  HERE = os.path.dirname(os.path.abspath(__file__))
448
460
  run_installer(os.path.join(HERE, "installer.toml"),
449
461
  identity=InstallerIdentity(slug="acme-tools", title="Acme Tools"),
462
+ default_root_name="acme-tools",
450
463
  entry_script=__file__)
451
464
  ```
452
465
 
453
466
  `run_installer` takes `--root DIR` for itself and leaves every other flag to the
454
467
  engine, so `--list`, `--apply`, `--skill-target`, `--check`, `--refresh`, `--tui`
455
- and `--gui` work as they do without sources. Every keyword besides `config_path`
456
- and `argv` goes to `run()`; `discovery_roots` and `pre_discovery` are the
457
- function's own to set and passing either raises `TypeError`.
468
+ and `--gui` work as they do without sources. Every keyword besides `config_path`,
469
+ `argv` and `default_root_name` goes to `run()`; `discovery_roots` and
470
+ `pre_discovery` are the function's own to set and passing either raises
471
+ `TypeError`.
458
472
 
459
473
  Without a wrapper, the same thing from the command line:
460
474
 
@@ -364,8 +364,20 @@ path = "/home/me/work/lab-tools"
364
364
 
365
365
  A source resolves in this order: the path from the local file, then the `path`
366
366
  from the tracked file, then an existing `<root>/<name>`, then a clone of `url`
367
- into `<root>/<name>`. The root is `--root DIR` if given, else the local file's
368
- `root`, else two levels above the directory the config file sits in.
367
+ into `<root>/<name>`.
368
+
369
+ The root is `--root DIR` if given, else the local file's `root`, else the answer
370
+ to a question. The GUI asks in a small dialog before discovery, the text screen
371
+ asks in one line on stdin, and both suggest `<current directory>/<name>` as an
372
+ absolute path — `<name>` is `run_installer`'s `default_root_name`, `tools` by
373
+ default. The answer is written into `installer.local.toml` as its `root`, above
374
+ any `[[source]]` tables already there, so the question is asked once per
375
+ machine; a `root` that file already has is left alone. The directory is created
376
+ if it does not exist, and cancelling the dialog installs nothing.
377
+
378
+ A headless run never asks: `--list`, `--apply`, `--check`, or a text screen with
379
+ no terminal take the suggestion and print `root: /abs/path (pass --root to
380
+ change)`.
369
381
 
370
382
  Cloning is deliberately narrow. Only `https://` URLs are cloned, `ext::` and
371
383
  `file://` transports and any hook are switched off for the git call, the clone is
@@ -394,14 +406,16 @@ from cli_tools_kit.sources import run_installer
394
406
  HERE = os.path.dirname(os.path.abspath(__file__))
395
407
  run_installer(os.path.join(HERE, "installer.toml"),
396
408
  identity=InstallerIdentity(slug="acme-tools", title="Acme Tools"),
409
+ default_root_name="acme-tools",
397
410
  entry_script=__file__)
398
411
  ```
399
412
 
400
413
  `run_installer` takes `--root DIR` for itself and leaves every other flag to the
401
414
  engine, so `--list`, `--apply`, `--skill-target`, `--check`, `--refresh`, `--tui`
402
- and `--gui` work as they do without sources. Every keyword besides `config_path`
403
- and `argv` goes to `run()`; `discovery_roots` and `pre_discovery` are the
404
- function's own to set and passing either raises `TypeError`.
415
+ and `--gui` work as they do without sources. Every keyword besides `config_path`,
416
+ `argv` and `default_root_name` goes to `run()`; `discovery_roots` and
417
+ `pre_discovery` are the function's own to set and passing either raises
418
+ `TypeError`.
405
419
 
406
420
  Without a wrapper, the same thing from the command line:
407
421
 
@@ -56,4 +56,4 @@ __all__ = [
56
56
  "read_installed_skill",
57
57
  ]
58
58
 
59
- __version__ = "0.6.0"
59
+ __version__ = "0.6.1"
@@ -1794,6 +1794,7 @@ class InstallerApp:
1794
1794
  self.root.withdraw() # Hide until properly sized
1795
1795
  self.tools = tools
1796
1796
  self.root.title(WINDOW_TITLE)
1797
+ self._set_window_icon()
1797
1798
  saved_theme = load_config().get("theme", "ocean")
1798
1799
  self.current_theme = saved_theme if saved_theme in self.THEMES else "ocean"
1799
1800
 
@@ -1835,6 +1836,27 @@ class InstallerApp:
1835
1836
  # warning so the two don't fight over the log/dialog at the same tick.
1836
1837
  self.root.after(200, self._maybe_auto_update_on_startup)
1837
1838
 
1839
+ def _set_window_icon(self) -> None:
1840
+ """Apply SELF_DESKTOP_ICON to the live window (taskbar/titlebar).
1841
+
1842
+ SELF_DESKTOP_ICON is otherwise only written into the .desktop file's
1843
+ Icon= line, so a run straight from a terminal showed the generic Tk
1844
+ icon instead. Only an absolute image path can be used here (a
1845
+ freedesktop icon *name* has no file to load without a theme lookup).
1846
+ """
1847
+ icon_path = SELF_DESKTOP_ICON
1848
+ if not icon_path or not os.path.isabs(icon_path) or not os.path.isfile(icon_path):
1849
+ return
1850
+ try:
1851
+ if _HAVE_PIL:
1852
+ image = ImageTk.PhotoImage(Image.open(icon_path))
1853
+ else:
1854
+ image = tk.PhotoImage(file=icon_path)
1855
+ self.root.iconphoto(True, image)
1856
+ self._icon_photo = image # keep a reference; Tk drops the icon otherwise
1857
+ except Exception:
1858
+ pass # cosmetic only; never block startup over a bad/unsupported icon
1859
+
1838
1860
  def _get_primary_monitor_geometry(self) -> tuple[int, int, int, int]:
1839
1861
  """Get primary monitor geometry (x, y, width, height). Falls back to tkinter defaults."""
1840
1862
  try:
@@ -1918,6 +1940,17 @@ class InstallerApp:
1918
1940
  # Windows/Mac use MouseWheel
1919
1941
  self.canvas.bind_all("<MouseWheel>", _on_mousewheel)
1920
1942
 
1943
+ def _fit_canvas_width(self):
1944
+ """Stretch the scrollable content to the canvas' current width.
1945
+
1946
+ The canvas window item keeps the width it is given, so without this the
1947
+ table falls back to its requested width — the minimum every column
1948
+ needs — and leaves the right of the window empty.
1949
+ """
1950
+ width = self.canvas.winfo_width()
1951
+ if width > 1:
1952
+ self.canvas.itemconfig(self.canvas_window, width=width)
1953
+
1921
1954
  def _on_canvas_configure(self, event):
1922
1955
  """Handle canvas resize: adjust content width and show/hide scrollbar."""
1923
1956
  # Make content fill canvas width
@@ -2203,6 +2236,15 @@ class InstallerApp:
2203
2236
  # Restore window geometry
2204
2237
  self.root.geometry(geometry)
2205
2238
 
2239
+ # _setup_ui built a new canvas, and the old one died with the old UI.
2240
+ # The mouse wheel is bound to the widget by name, so re-bind it, and
2241
+ # give the new window item the canvas width at once: without it the
2242
+ # table keeps its requested width, which is the sum of the minimum
2243
+ # column widths, and sits at the left with empty space to its right.
2244
+ self._bind_mousewheel()
2245
+ self.root.update_idletasks()
2246
+ self._fit_canvas_width()
2247
+
2206
2248
  def _setup_ui(self):
2207
2249
  # Configure styles
2208
2250
  self.style = ttk.Style()
@@ -2416,6 +2458,10 @@ class InstallerApp:
2416
2458
  current_row = self._render_tool_group(group, current_row)
2417
2459
 
2418
2460
  self.canvas.grid(row=0, column=0, sticky="nsew")
2461
+ # Bound here rather than only in _position_window, which runs once at
2462
+ # startup: a theme switch rebuilds the UI, and the new canvas needs the
2463
+ # binding that keeps the table as wide as the window.
2464
+ self.canvas.bind("<Configure>", self._on_canvas_configure)
2419
2465
  # Scrollbar initially hidden - will be shown in _position_window if needed
2420
2466
 
2421
2467
  # Footer / Buttons
@@ -24,6 +24,11 @@ for a source matched by ``name``:
24
24
  name = "acme/lab"
25
25
  path = "/home/me/work/lab-tools"
26
26
 
27
+ The root is never guessed from where the config file happens to sit.
28
+ ``run_installer`` takes ``--root``, else the ``root`` of the local file, else it
29
+ asks the user, and writes the answer back into the local file so the question is
30
+ asked once.
31
+
27
32
  A source resolves in this order: the path from the local file, then the ``path``
28
33
  from the tracked file, then an existing ``<root>/<name>``, then a clone of
29
34
  ``url`` into ``<root>/<name>``. Only ``https://`` URLs are cloned, the clone is
@@ -56,7 +61,8 @@ from dataclasses import dataclass
56
61
  from pathlib import Path
57
62
  from typing import Callable, List, Optional, Sequence
58
63
 
59
- __all__ = ["Source", "load_sources", "resolve_sources", "run_installer"]
64
+ __all__ = ["Source", "load_sources", "resolve_sources", "run_installer",
65
+ "local_root", "save_local_root", "default_root"]
60
66
 
61
67
  # How far below the top-level installer.toml a nested one is still read.
62
68
  MAX_NESTING = 1
@@ -132,6 +138,35 @@ def local_root(config_path, local_path=None) -> Optional[str]:
132
138
  return None
133
139
 
134
140
 
141
+ def save_local_root(config_path, root, local_path=None, log: Callable = print) -> bool:
142
+ """Write ``root`` into the local file next to ``config_path``.
143
+
144
+ Returns False and changes nothing when the file already sets a ``root``: a
145
+ value someone put there by hand is never overwritten. Existing
146
+ ``[[source]]`` entries are kept, and the key is written above them, because
147
+ a top-level key written after a table would belong to that table.
148
+ """
149
+ config_path = Path(config_path)
150
+ local = Path(local_path) if local_path is not None else _local_path_for(config_path)
151
+ if local_root(config_path, local) is not None:
152
+ return False
153
+ existing = ""
154
+ if local.is_file():
155
+ try:
156
+ existing = local.read_text(encoding="utf-8")
157
+ except OSError as exc:
158
+ log(f"{local.name}: not updated ({exc})")
159
+ return False
160
+ line = f'root = "{root}"\n'
161
+ try:
162
+ local.write_text(line + ("\n" + existing.lstrip("\n") if existing.strip() else ""),
163
+ encoding="utf-8")
164
+ except OSError as exc:
165
+ log(f"{local.name}: not written ({exc})")
166
+ return False
167
+ return True
168
+
169
+
135
170
  def load_sources(config_path, local_path=None, log: Callable = print) -> List[Source]:
136
171
  """The ``[[source]]`` entries of one TOML file, local overrides applied.
137
172
 
@@ -174,10 +209,14 @@ def _git(*args):
174
209
  return result.returncode == 0, (result.stdout + result.stderr).strip()
175
210
 
176
211
 
177
- def _clone_reason(output: str) -> str:
212
+ def _git_reason(output: str, fallback: str = "git failed") -> str:
178
213
  lines = [line.strip() for line in output.splitlines() if line.strip()]
179
214
  return next((line for line in lines if line.startswith("fatal:")),
180
- lines[-1] if lines else "git clone failed")
215
+ lines[-1] if lines else fallback)
216
+
217
+
218
+ def _clone_reason(output: str) -> str:
219
+ return _git_reason(output, "git clone failed")
181
220
 
182
221
 
183
222
  def _resolve_one(source: Source, root: Path, refresh: bool, log: Callable,
@@ -189,9 +228,13 @@ def _resolve_one(source: Source, root: Path, refresh: bool, log: Callable,
189
228
  target = root.joinpath(*source.name.split("/"))
190
229
  if target.is_dir():
191
230
  if refresh and clone and (target / ".git").is_dir() and source.url:
192
- ok, _ = _git("-C", str(target), "pull", "--ff-only")
231
+ ok, out = _git("-C", str(target), "pull", "--ff-only")
232
+ # A pull can fail for reasons the user cannot fix here: the remote
233
+ # is gone, the network is down, the history diverged. One line, and
234
+ # the checkout that is already on disk is used as it stands.
193
235
  log(f"{source.name}: pulled" if ok else
194
- f"{source.name}: left as is, local changes or diverged history")
236
+ f"{source.name}: not updated, using the checkout as it is"
237
+ f" ({_git_reason(out)})")
195
238
  return target.resolve()
196
239
 
197
240
  if not source.url:
@@ -279,16 +322,126 @@ def _take_root(argv: List[str]):
279
322
  return rest, root
280
323
 
281
324
 
282
- def default_root(config_path) -> str:
283
- """Two levels above the config file's directory.
325
+ def default_root(name: str = "tools") -> str:
326
+ """The suggested install location: ``<current directory>/<name>``.
284
327
 
285
- A bootstrap script clones the first repo to ``<root>/<org>/<tool tree>``, so
286
- the other sources belong two levels up from the file that lists them.
328
+ Absolute, because the user is shown this string and has to recognise where
329
+ it points. It is only ever a suggestion: what the user types in the dialog
330
+ or on stdin wins, and ``--root`` wins over both.
287
331
  """
288
- return str(Path(config_path).absolute().parent.parent.parent)
332
+ return str(Path.cwd().joinpath(name).absolute())
333
+
334
+
335
+ # Flags that mean nobody is watching the screen, so nothing may block on input.
336
+ HEADLESS_FLAGS = ("--list", "--check", "--apply")
337
+
338
+
339
+ def _is_headless(argv: Sequence[str]) -> bool:
340
+ return any(arg in HEADLESS_FLAGS or arg.startswith("--apply=") for arg in argv)
341
+
342
+
343
+ def _wants_gui(argv: Sequence[str]) -> bool:
344
+ """True when the tkinter window is the screen this run will open."""
345
+ from . import tui_installer # noqa: PLC0415 — pulls in the engine, keep it lazy
346
+ from .gui_installer import _HAVE_TK # noqa: PLC0415
347
+ return not tui_installer.prefer_tui(force_tui="--tui" in argv,
348
+ force_gui="--gui" in argv,
349
+ have_tk=_HAVE_TK)
289
350
 
290
351
 
291
- def run_installer(config_path, argv=None, **run_kwargs):
352
+ def _ask_root_gui(default: str) -> Optional[str]:
353
+ """A small window asking where the tools go. None when the user cancels."""
354
+ import tkinter as tk # noqa: PLC0415
355
+ from tkinter import filedialog # noqa: PLC0415
356
+
357
+ win = tk.Tk()
358
+ win.title("Install location")
359
+ chosen: List[str] = []
360
+ value = tk.StringVar(value=default)
361
+
362
+ tk.Label(win, text="Where should the tools be installed?",
363
+ font=("", 12, "bold")).pack(anchor="w", padx=16, pady=(16, 6))
364
+ tk.Label(win, text="The folder is created if it does not exist.",
365
+ justify="left").pack(anchor="w", padx=16)
366
+
367
+ row = tk.Frame(win)
368
+ row.pack(fill="x", padx=16, pady=12)
369
+ entry = tk.Entry(row, textvariable=value, width=54)
370
+ entry.pack(side="left", fill="x", expand=True)
371
+
372
+ def browse():
373
+ picked = filedialog.askdirectory(parent=win, title="Install location",
374
+ initialdir=os.path.dirname(value.get()) or "/")
375
+ if picked:
376
+ value.set(str(Path(picked).absolute()))
377
+
378
+ tk.Button(row, text="Browse…", command=browse).pack(side="left", padx=(8, 0))
379
+
380
+ buttons = tk.Frame(win)
381
+ buttons.pack(fill="x", padx=16, pady=(0, 16))
382
+
383
+ def ok(*_):
384
+ text = value.get().strip()
385
+ if text:
386
+ chosen.append(text)
387
+ win.destroy()
388
+
389
+ tk.Button(buttons, text="OK", command=ok, width=10).pack(side="right")
390
+ tk.Button(buttons, text="Cancel", command=win.destroy, width=10).pack(side="right", padx=8)
391
+ win.bind("<Return>", ok)
392
+ win.protocol("WM_DELETE_WINDOW", win.destroy)
393
+ entry.focus_set()
394
+ entry.icursor("end")
395
+ win.mainloop()
396
+ return chosen[0] if chosen else None
397
+
398
+
399
+ def _ask_root_stdin(default: str) -> Optional[str]:
400
+ """One line on stdin, before any curses screen opens. None on Ctrl-D."""
401
+ try:
402
+ answer = input(f"Where should the tools be installed? [{default}] ")
403
+ except EOFError:
404
+ return None
405
+ return answer.strip() or default
406
+
407
+
408
+ def _resolve_root(config_path: Path, root_arg: Optional[str], argv: Sequence[str],
409
+ default_root_name: str, log: Callable = print) -> str:
410
+ """Settle the clone root: the flag, the local file, the user, the default.
411
+
412
+ Asks only when the first two miss. The tkinter dialog is used when this run
413
+ opens the tkinter window, the stdin prompt when it opens the text screen on
414
+ a terminal. A headless run (``--list``, ``--apply``, ``--check``, or a text
415
+ screen without a terminal) never asks: it takes the default and says so.
416
+ """
417
+ if root_arg:
418
+ return str(Path(os.path.expanduser(root_arg)).absolute())
419
+ stored = local_root(config_path)
420
+ if stored:
421
+ return stored
422
+
423
+ default = default_root(default_root_name)
424
+ if _is_headless(argv):
425
+ log(f"root: {default} (pass --root to change)")
426
+ return default
427
+
428
+ if _wants_gui(argv):
429
+ answer = _ask_root_gui(default)
430
+ elif sys.stdin is not None and sys.stdin.isatty():
431
+ answer = _ask_root_stdin(default)
432
+ else:
433
+ log(f"root: {default} (pass --root to change)")
434
+ return default
435
+
436
+ if answer is None:
437
+ print("No install location chosen, nothing was installed.")
438
+ raise SystemExit(0)
439
+ chosen = str(Path(os.path.expanduser(answer)).absolute())
440
+ save_local_root(config_path, chosen, log=log)
441
+ return chosen
442
+
443
+
444
+ def run_installer(config_path, argv=None, default_root_name: str = "tools", **run_kwargs):
292
445
  """Read a sources file, wire the engine to it, and run the installer.
293
446
 
294
447
  ``--root DIR`` is taken from ``argv`` (``sys.argv[1:]`` by default) and the
@@ -297,11 +450,18 @@ def run_installer(config_path, argv=None, **run_kwargs):
297
450
  engine's flag: it reaches the pre-discovery hook, which then pulls every
298
451
  clone.
299
452
 
300
- The root is ``--root`` if given, else the ``root`` of the local file, else
301
- two levels above the config file's directory. Cloning happens in the
453
+ The root is ``--root`` if given, else the ``root`` of
454
+ ``installer.local.toml``, else the user's answer to a question, else — on a
455
+ headless run only — ``<current directory>/<default_root_name>``. The answer
456
+ is written into ``installer.local.toml``, so the question is asked once.
457
+ The root directory is created if it does not exist. Cloning happens in the
302
458
  pre-discovery hook, which the engine skips on the ``--check`` path, so that
303
459
  check stays network-free and sees whatever is already on disk.
304
460
 
461
+ ``default_root_name`` is the folder name the suggestion ends in, so an
462
+ organisation's installer can suggest ``<cwd>/WW3-tools`` rather than
463
+ ``<cwd>/tools``.
464
+
305
465
  Every other keyword goes to :func:`cli_tools_kit.gui_installer.run`.
306
466
  ``discovery_roots`` and ``pre_discovery`` are this function's to set.
307
467
  ``prune`` reaches the walker that way, so a wrapper can name directories
@@ -316,8 +476,11 @@ def run_installer(config_path, argv=None, **run_kwargs):
316
476
  argv, root_arg = _take_root(argv)
317
477
  sys.argv = [sys.argv[0]] + argv
318
478
 
319
- root = root_arg or local_root(config_path) or default_root(config_path)
320
- root = str(Path(os.path.expanduser(root)).absolute())
479
+ root = _resolve_root(config_path, root_arg, argv, default_root_name)
480
+ try:
481
+ os.makedirs(root, exist_ok=True)
482
+ except OSError as exc:
483
+ print(f"{root}: not created ({exc})")
321
484
  sources = load_sources(config_path)
322
485
 
323
486
  # The engine reads DISCOVERY_ROOTS after the hook has run, so the hook fills
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-tools-kit
3
- Version: 0.6.0
3
+ Version: 0.6.2
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
@@ -417,8 +417,20 @@ path = "/home/me/work/lab-tools"
417
417
 
418
418
  A source resolves in this order: the path from the local file, then the `path`
419
419
  from the tracked file, then an existing `<root>/<name>`, then a clone of `url`
420
- into `<root>/<name>`. The root is `--root DIR` if given, else the local file's
421
- `root`, else two levels above the directory the config file sits in.
420
+ into `<root>/<name>`.
421
+
422
+ The root is `--root DIR` if given, else the local file's `root`, else the answer
423
+ to a question. The GUI asks in a small dialog before discovery, the text screen
424
+ asks in one line on stdin, and both suggest `<current directory>/<name>` as an
425
+ absolute path — `<name>` is `run_installer`'s `default_root_name`, `tools` by
426
+ default. The answer is written into `installer.local.toml` as its `root`, above
427
+ any `[[source]]` tables already there, so the question is asked once per
428
+ machine; a `root` that file already has is left alone. The directory is created
429
+ if it does not exist, and cancelling the dialog installs nothing.
430
+
431
+ A headless run never asks: `--list`, `--apply`, `--check`, or a text screen with
432
+ no terminal take the suggestion and print `root: /abs/path (pass --root to
433
+ change)`.
422
434
 
423
435
  Cloning is deliberately narrow. Only `https://` URLs are cloned, `ext::` and
424
436
  `file://` transports and any hook are switched off for the git call, the clone is
@@ -447,14 +459,16 @@ from cli_tools_kit.sources import run_installer
447
459
  HERE = os.path.dirname(os.path.abspath(__file__))
448
460
  run_installer(os.path.join(HERE, "installer.toml"),
449
461
  identity=InstallerIdentity(slug="acme-tools", title="Acme Tools"),
462
+ default_root_name="acme-tools",
450
463
  entry_script=__file__)
451
464
  ```
452
465
 
453
466
  `run_installer` takes `--root DIR` for itself and leaves every other flag to the
454
467
  engine, so `--list`, `--apply`, `--skill-target`, `--check`, `--refresh`, `--tui`
455
- and `--gui` work as they do without sources. Every keyword besides `config_path`
456
- and `argv` goes to `run()`; `discovery_roots` and `pre_discovery` are the
457
- function's own to set and passing either raises `TypeError`.
468
+ and `--gui` work as they do without sources. Every keyword besides `config_path`,
469
+ `argv` and `default_root_name` goes to `run()`; `discovery_roots` and
470
+ `pre_discovery` are the function's own to set and passing either raises
471
+ `TypeError`.
458
472
 
459
473
  Without a wrapper, the same thing from the command line:
460
474
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cli-tools-kit"
7
- version = "0.6.0"
7
+ version = "0.6.2"
8
8
  description = "Installer protocol + helpers for self-installing Python CLI/GUI tools (desktop shortcuts, bash aliases, cron entries), plus reusable tkinter and curses installer screens"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -151,6 +151,29 @@ def test_refresh_pulls_a_clone_ff_only(tmp_path: Path, monkeypatch) -> None:
151
151
  assert "clone" not in calls[0]
152
152
 
153
153
 
154
+ def test_a_failed_pull_keeps_the_clone_as_a_discovery_root(tmp_path: Path,
155
+ monkeypatch) -> None:
156
+ """A remote that is gone, a network that is down, a diverged history.
157
+
158
+ One line about it, and the checkout that is already on disk is still used.
159
+ """
160
+ target = _repo(tmp_path / "root" / "org" / "lab")
161
+
162
+ class Result:
163
+ returncode = 128
164
+ stdout = ""
165
+ stderr = ("fatal: repository 'https://example.invalid/lab.git/' not found\n")
166
+
167
+ monkeypatch.setattr(sources.subprocess, "run", lambda cmd, **kw: Result())
168
+ lines: list = []
169
+ source = Source(name="org/lab", url="https://example.invalid/lab.git")
170
+ assert resolve_sources([source], tmp_path / "root", refresh=True,
171
+ log=lines.append) == [target]
172
+ assert target.is_dir()
173
+ assert len(lines) == 1
174
+ assert "not found" in lines[0]
175
+
176
+
154
177
  def test_a_checkout_given_by_path_is_never_pulled(tmp_path: Path, monkeypatch) -> None:
155
178
  given = _repo(tmp_path / "given")
156
179
  calls: list = []
@@ -289,9 +312,11 @@ def _tree(tmp_path: Path) -> Path:
289
312
  '[[source]]\nname = "org/tools"\npath = "."\n')
290
313
 
291
314
 
292
- def test_run_installer_wires_roots_and_the_hook(tmp_path: Path, engine) -> None:
315
+ def test_run_installer_wires_roots_and_the_hook(tmp_path: Path, engine,
316
+ monkeypatch) -> None:
293
317
  config = _tree(tmp_path)
294
- sources.run_installer(config, argv=["--list"])
318
+ monkeypatch.chdir(tmp_path)
319
+ sources.run_installer(config, argv=["--list", "--root", str(tmp_path)])
295
320
  assert engine["root_dir"] == str(tmp_path)
296
321
  assert engine["discovery_roots"] == [str(tmp_path / "org" / "tools")]
297
322
  assert callable(engine["pre_discovery"])
@@ -311,7 +336,8 @@ def test_root_flag_wins_and_is_consumed(tmp_path: Path, engine) -> None:
311
336
  assert sys.argv[1:] == ["--check"]
312
337
 
313
338
 
314
- def test_local_root_is_the_default_when_no_flag(tmp_path: Path, engine) -> None:
339
+ def test_local_root_is_the_default_when_no_flag(tmp_path: Path, engine,
340
+ never_asks) -> None:
315
341
  config = _tree(tmp_path)
316
342
  _write(config.with_name("installer.local.toml"), f'root = "{tmp_path / "here"}"\n')
317
343
  sources.run_installer(config, argv=[])
@@ -332,7 +358,7 @@ url = "https://example.invalid/lab.git"
332
358
  """)
333
359
  calls: list = []
334
360
  monkeypatch.setattr(sources.subprocess, "run", _fake_git(calls))
335
- sources.run_installer(config, argv=[])
361
+ sources.run_installer(config, argv=["--root", str(tmp_path)])
336
362
  roots = engine["discovery_roots"]
337
363
  # Before the hook: only what is on disk, so --check never needs the network.
338
364
  assert roots == [str(tmp_path / "org" / "tools")]
@@ -341,6 +367,140 @@ url = "https://example.invalid/lab.git"
341
367
  assert any("clone" in call for call in calls)
342
368
 
343
369
 
370
+ # --- where the tools are installed ------------------------------------------
371
+
372
+ def _refuse(default):
373
+ raise AssertionError(f"asked for a root, suggesting {default}")
374
+
375
+
376
+ @pytest.fixture
377
+ def never_asks(monkeypatch):
378
+ """Fail the test if either prompt is reached."""
379
+ monkeypatch.setattr(sources, "_ask_root_gui", _refuse)
380
+ monkeypatch.setattr(sources, "_ask_root_stdin", _refuse)
381
+
382
+
383
+ def _answers(monkeypatch, answer, gui: bool = True):
384
+ """Route the question to one screen and answer it. Returns the suggestions."""
385
+ seen: list = []
386
+
387
+ def ask(default):
388
+ seen.append(default)
389
+ return answer
390
+
391
+ monkeypatch.setattr(sources, "_wants_gui", lambda argv: gui)
392
+ monkeypatch.setattr(sources, "_ask_root_gui" if gui else "_ask_root_stdin", ask)
393
+ if not gui:
394
+ monkeypatch.setattr(sources.sys, "stdin",
395
+ type("Tty", (), {"isatty": staticmethod(lambda: True)})())
396
+ return seen
397
+
398
+
399
+ def test_the_root_flag_is_used_without_asking(tmp_path: Path, engine, never_asks,
400
+ monkeypatch) -> None:
401
+ config = _tree(tmp_path)
402
+ monkeypatch.chdir(tmp_path)
403
+ sources.run_installer(config, argv=["--root", str(tmp_path / "chosen")])
404
+ assert engine["root_dir"] == str(tmp_path / "chosen")
405
+ assert (tmp_path / "chosen").is_dir() # created for us
406
+ # Nothing is remembered: the flag is for this run.
407
+ assert not config.with_name("installer.local.toml").exists()
408
+
409
+
410
+ def test_the_answer_is_used_and_remembered(tmp_path: Path, engine, monkeypatch) -> None:
411
+ config = _tree(tmp_path)
412
+ monkeypatch.chdir(tmp_path)
413
+ chosen = tmp_path / "picked"
414
+ suggested = _answers(monkeypatch, str(chosen))
415
+
416
+ sources.run_installer(config, argv=[])
417
+ assert engine["root_dir"] == str(chosen)
418
+ assert chosen.is_dir()
419
+ assert suggested == [str(tmp_path / "tools")] # <cwd>/<default_root_name>
420
+ assert sources.local_root(config) == str(chosen)
421
+
422
+ # Asked once: the second run reads the file.
423
+ monkeypatch.setattr(sources, "_ask_root_gui", _refuse)
424
+ sources.run_installer(config, argv=[])
425
+ assert engine["root_dir"] == str(chosen)
426
+
427
+
428
+ def test_the_answer_keeps_the_local_files_sources(tmp_path: Path, engine,
429
+ monkeypatch) -> None:
430
+ config = _tree(tmp_path)
431
+ monkeypatch.chdir(tmp_path)
432
+ local = _write(config.with_name("installer.local.toml"),
433
+ f'[[source]]\nname = "org/tools"\npath = "{tmp_path / "other"}"\n')
434
+ _answers(monkeypatch, str(tmp_path / "picked"))
435
+ sources.run_installer(config, argv=[])
436
+ assert sources.local_root(config) == str(tmp_path / "picked")
437
+ assert load_sources(config)[0].path == str(tmp_path / "other")
438
+ assert '[[source]]' in local.read_text(encoding="utf-8")
439
+
440
+
441
+ def test_an_existing_root_in_the_local_file_is_never_overwritten(tmp_path: Path) -> None:
442
+ config = _write(tmp_path / "installer.toml", "")
443
+ _write(config.with_name("installer.local.toml"), f'root = "{tmp_path / "mine"}"\n')
444
+ assert sources.save_local_root(config, str(tmp_path / "other")) is False
445
+ assert sources.local_root(config) == str(tmp_path / "mine")
446
+
447
+
448
+ def test_the_default_root_name_names_the_suggested_folder(tmp_path: Path, engine,
449
+ monkeypatch) -> None:
450
+ config = _tree(tmp_path)
451
+ monkeypatch.chdir(tmp_path)
452
+ suggested = _answers(monkeypatch, str(tmp_path / "picked"))
453
+ sources.run_installer(config, argv=[], default_root_name="WW3-tools")
454
+ assert suggested == [str(tmp_path / "WW3-tools")]
455
+
456
+
457
+ def test_cancelling_the_question_stops_cleanly(tmp_path: Path, engine,
458
+ monkeypatch) -> None:
459
+ config = _tree(tmp_path)
460
+ monkeypatch.chdir(tmp_path)
461
+ _answers(monkeypatch, None)
462
+ with pytest.raises(SystemExit) as exit_info:
463
+ sources.run_installer(config, argv=[])
464
+ assert exit_info.value.code == 0
465
+ assert engine == {} # the installer never opened
466
+
467
+
468
+ def test_the_text_screen_asks_on_stdin(tmp_path: Path, engine, monkeypatch) -> None:
469
+ config = _tree(tmp_path)
470
+ monkeypatch.chdir(tmp_path)
471
+ suggested = _answers(monkeypatch, str(tmp_path / "picked"), gui=False)
472
+ sources.run_installer(config, argv=["--tui"])
473
+ assert suggested == [str(tmp_path / "tools")]
474
+ assert engine["root_dir"] == str(tmp_path / "picked")
475
+
476
+
477
+ @pytest.mark.parametrize("argv", [["--list"], ["--check"], ["--apply", "all"],
478
+ ["--apply=all"]])
479
+ def test_a_headless_run_takes_the_default_and_says_so(tmp_path: Path, engine,
480
+ never_asks, monkeypatch,
481
+ capsys, argv) -> None:
482
+ config = _tree(tmp_path)
483
+ monkeypatch.chdir(tmp_path)
484
+ sources.run_installer(config, argv=list(argv))
485
+ assert engine["root_dir"] == str(tmp_path / "tools")
486
+ assert (f"root: {tmp_path / 'tools'} (pass --root to change)"
487
+ in capsys.readouterr().out)
488
+ # A default is not an answer, so it is not written to the local file.
489
+ assert not config.with_name("installer.local.toml").exists()
490
+
491
+
492
+ def test_no_question_without_a_terminal(tmp_path: Path, engine, never_asks,
493
+ monkeypatch, capsys) -> None:
494
+ config = _tree(tmp_path)
495
+ monkeypatch.chdir(tmp_path)
496
+ monkeypatch.setattr(sources, "_wants_gui", lambda argv: False)
497
+ monkeypatch.setattr(sources.sys, "stdin",
498
+ type("Pipe", (), {"isatty": staticmethod(lambda: False)})())
499
+ sources.run_installer(config, argv=[])
500
+ assert engine["root_dir"] == str(tmp_path / "tools")
501
+ assert "pass --root to change" in capsys.readouterr().out
502
+
503
+
344
504
  def test_run_installer_refuses_to_have_its_own_arguments_overridden(tmp_path: Path,
345
505
  engine) -> None:
346
506
  config = _tree(tmp_path)
File without changes
File without changes