cli-tools-kit 0.6.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 (41) hide show
  1. cli_tools_kit-0.6.0/LICENSE +21 -0
  2. cli_tools_kit-0.6.0/PKG-INFO +505 -0
  3. cli_tools_kit-0.6.0/README.md +452 -0
  4. cli_tools_kit-0.6.0/cli_tools_kit/__init__.py +59 -0
  5. cli_tools_kit-0.6.0/cli_tools_kit/__main__.py +43 -0
  6. cli_tools_kit-0.6.0/cli_tools_kit/advertise.py +77 -0
  7. cli_tools_kit-0.6.0/cli_tools_kit/cron_installer.py +159 -0
  8. cli_tools_kit-0.6.0/cli_tools_kit/gui_installer.py +5928 -0
  9. cli_tools_kit-0.6.0/cli_tools_kit/host.py +239 -0
  10. cli_tools_kit-0.6.0/cli_tools_kit/identity.py +229 -0
  11. cli_tools_kit-0.6.0/cli_tools_kit/onboarding.py +261 -0
  12. cli_tools_kit-0.6.0/cli_tools_kit/skills.py +97 -0
  13. cli_tools_kit-0.6.0/cli_tools_kit/sources.py +335 -0
  14. cli_tools_kit-0.6.0/cli_tools_kit/taxonomy/__init__.py +40 -0
  15. cli_tools_kit-0.6.0/cli_tools_kit/taxonomy/build.py +171 -0
  16. cli_tools_kit-0.6.0/cli_tools_kit/taxonomy/capability.py +116 -0
  17. cli_tools_kit-0.6.0/cli_tools_kit/taxonomy/cluster.py +261 -0
  18. cli_tools_kit-0.6.0/cli_tools_kit/taxonomy/corpus.py +268 -0
  19. cli_tools_kit-0.6.0/cli_tools_kit/taxonomy/embedder.py +170 -0
  20. cli_tools_kit-0.6.0/cli_tools_kit/taxonomy/groups.py +108 -0
  21. cli_tools_kit-0.6.0/cli_tools_kit/taxonomy/llm_groups.py +748 -0
  22. cli_tools_kit-0.6.0/cli_tools_kit/tool_installer.py +572 -0
  23. cli_tools_kit-0.6.0/cli_tools_kit/tui_installer.py +462 -0
  24. cli_tools_kit-0.6.0/cli_tools_kit.egg-info/PKG-INFO +505 -0
  25. cli_tools_kit-0.6.0/cli_tools_kit.egg-info/SOURCES.txt +39 -0
  26. cli_tools_kit-0.6.0/cli_tools_kit.egg-info/dependency_links.txt +1 -0
  27. cli_tools_kit-0.6.0/cli_tools_kit.egg-info/entry_points.txt +3 -0
  28. cli_tools_kit-0.6.0/cli_tools_kit.egg-info/requires.txt +10 -0
  29. cli_tools_kit-0.6.0/cli_tools_kit.egg-info/top_level.txt +1 -0
  30. cli_tools_kit-0.6.0/pyproject.toml +59 -0
  31. cli_tools_kit-0.6.0/setup.cfg +4 -0
  32. cli_tools_kit-0.6.0/tests/test_capability_groups.py +80 -0
  33. cli_tools_kit-0.6.0/tests/test_cron_installer.py +138 -0
  34. cli_tools_kit-0.6.0/tests/test_host.py +193 -0
  35. cli_tools_kit-0.6.0/tests/test_identity.py +372 -0
  36. cli_tools_kit-0.6.0/tests/test_llm_groups.py +425 -0
  37. cli_tools_kit-0.6.0/tests/test_skills.py +56 -0
  38. cli_tools_kit-0.6.0/tests/test_sources.py +350 -0
  39. cli_tools_kit-0.6.0/tests/test_taxonomy_cluster.py +373 -0
  40. cli_tools_kit-0.6.0/tests/test_tool_installer.py +279 -0
  41. cli_tools_kit-0.6.0/tests/test_tui_installer.py +158 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Steffen Probst
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,505 @@
1
+ Metadata-Version: 2.4
2
+ Name: cli-tools-kit
3
+ Version: 0.6.0
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
+ Author: Steffen Probst
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Steffen Probst
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/Probst1nator/cli-tools-kit
29
+ Project-URL: Issues, https://github.com/Probst1nator/cli-tools-kit/issues
30
+ Keywords: installer,desktop,cron,cli,linux,kde,gnome
31
+ Classifier: Development Status :: 4 - Beta
32
+ Classifier: Environment :: Console
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Operating System :: POSIX :: Linux
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3 :: Only
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
42
+ Classifier: Topic :: System :: Installation/Setup
43
+ Requires-Python: >=3.10
44
+ Description-Content-Type: text/markdown
45
+ License-File: LICENSE
46
+ Requires-Dist: termcolor>=2.0
47
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
48
+ Provides-Extra: dev
49
+ Requires-Dist: pytest>=7.0; extra == "dev"
50
+ Provides-Extra: gui
51
+ Requires-Dist: Pillow>=9.0; extra == "gui"
52
+ Dynamic: license-file
53
+
54
+ # cli-tools-kit
55
+
56
+ A small library for self-installing Python CLI/GUI tools on Linux and Windows desktops.
57
+ Provides:
58
+
59
+ - **`ToolInstaller`** — install/remove `.desktop` shortcuts or bash aliases
60
+ for a Python script, including auto-sourcing `~/.tools_aliases` from
61
+ `~/.bashrc`.
62
+ - **`CronInstaller`** — idempotent cron-line management with marker comments
63
+ so each tool's entries can be installed/removed without disturbing others.
64
+ - **`advertise()`** — a one-line helper for the `--advertise` JSON probe
65
+ convention that lets parent installers discover and configure your tools.
66
+ - **`skill_status()`** — detect whether a tool's installed Claude Code skill
67
+ (`~/.claude/skills/<name>/`) is `absent`, `current`, or `stale` vs. its
68
+ bundled version, so an installer can suggest updates (`skill_payload_hash`,
69
+ `installed_skill_hash`, `read_installed_skill` alongside).
70
+ - **`gui_installer`** — a full, reusable tkinter GUI installer *engine*: it
71
+ discovers every tool in a project tree that speaks `--advertise`, and offers
72
+ batch install/remove, per-row skill toggles, themes, orphan cleanup, and an
73
+ opt-in login update-check. A thin wrapper points it at its own tree via
74
+ `gui_installer.run(root_dir=..., entry_script=...)`; everything else
75
+ (discovery layout, repo-cache bootstrap, login-check policy, window/desktop
76
+ identities) is configurable. See [§ GUI installer engine](#gui-installer-engine).
77
+
78
+ - **`sources`** — one installer offering tools from several repos. A TOML
79
+ file lists them, the kit clones what is missing over HTTPS and hands the
80
+ engine one discovery root per repo. See
81
+ [§ Sources](#sources-installing-tools-from-several-repos).
82
+
83
+ See [`PROTOCOL.md`](PROTOCOL.md) for the full `--advertise` specification.
84
+
85
+ ## Install
86
+
87
+ ```bash
88
+ pip install cli-tools-kit
89
+ ```
90
+
91
+ Or pin in `requirements.txt`:
92
+
93
+ ```
94
+ cli-tools-kit==0.6.0
95
+ ```
96
+
97
+ The git URL form still works if you need an unreleased commit:
98
+ `pip install git+https://github.com/Probst1nator/cli-tools-kit.git@v0.6.0`.
99
+
100
+ Requires Python ≥ 3.10. Optional runtime dep: `termcolor` (colored
101
+ install/remove output; falls back to plain text if absent).
102
+
103
+ ## Minimal example
104
+
105
+ ```python
106
+ #!/usr/bin/env python3
107
+ import sys
108
+ from cli_tools_kit import ToolMetadata, ToolInstaller, advertise
109
+
110
+ # MUST come before any heavy imports!
111
+ if "--advertise" in sys.argv:
112
+ advertise(ToolMetadata(
113
+ name="My Tool",
114
+ desktop_file="my_tool.desktop",
115
+ icon="utilities-terminal",
116
+ desc="Does the thing",
117
+ tags=["CLI"],
118
+ alias="mytool",
119
+ ))
120
+
121
+ import argparse
122
+
123
+ def main() -> None:
124
+ parser = argparse.ArgumentParser()
125
+ parser.add_argument("--install", action="store_true")
126
+ parser.add_argument("--remove", action="store_true")
127
+ args = parser.parse_args()
128
+
129
+ installer = ToolInstaller(
130
+ script_path=__file__,
131
+ metadata=ToolMetadata(
132
+ name="My Tool",
133
+ desktop_file="my_tool.desktop",
134
+ icon="utilities-terminal",
135
+ desc="Does the thing",
136
+ tags=["CLI"],
137
+ alias="mytool",
138
+ ),
139
+ )
140
+ if args.install:
141
+ installer.install()
142
+ elif args.remove:
143
+ installer.remove()
144
+
145
+ if __name__ == "__main__":
146
+ main()
147
+ ```
148
+
149
+ After `python my_tool.py --install`, the `mytool` alias is available in new
150
+ shells (run `source ~/.bashrc` to pick it up immediately). On Windows the same
151
+ call writes a `mytool` shim instead — see [§ Windows](#windows).
152
+
153
+ ## Windows
154
+
155
+ The kit runs on Windows as well as Linux. Every platform decision lives in
156
+ `cli_tools_kit/host.py`; the differences a user sees are these.
157
+
158
+ - A CLI tool has no bash alias. `--install` writes two launcher scripts into
159
+ `%LOCALAPPDATA%\<slug>\bin`: `<alias>.cmd` for cmd.exe and PowerShell, and
160
+ an extensionless `<alias>` shell script for Git Bash, which is the shell
161
+ Claude Code uses. That directory is added to the user's PATH once, so open a
162
+ new terminal after the first install.
163
+ - A tool tagged `Icon` gets a Start Menu shortcut (`.lnk`) instead of a
164
+ `.desktop` file, and autostart copies that shortcut into the Startup folder.
165
+ Cron-scheduled autostart is not supported on Windows.
166
+ - The tkinter installer window works out of the box. The text screen
167
+ (`--tui`) needs curses, which Python for Windows does not ship:
168
+ `pip install windows-curses`.
169
+
170
+ ## Cron entries
171
+
172
+ ```python
173
+ from cli_tools_kit import CronInstaller
174
+
175
+ cron = CronInstaller("my-tool") # unique marker for this tool's entries
176
+
177
+ cron.install([
178
+ f"@reboot cd {SCRIPT_DIR} && python {SCRIPT} --daemon",
179
+ f"0 6 * * * cd {SCRIPT_DIR} && python {SCRIPT} --daily",
180
+ ])
181
+
182
+ # Later:
183
+ cron.remove() # strips only lines bearing this marker
184
+ ```
185
+
186
+ Each managed line gets a trailing `# cli-tool-kit:<marker>` comment. The
187
+ marker keeps the old project spelling so cron lines installed before the
188
+ rename still match.
189
+ Re-installing the same lines is a no-op; other tools' cron entries are
190
+ untouched.
191
+
192
+ ## Reusing the installer in your org
193
+
194
+ `cli_tools_kit.gui_installer` is a batteries-included tkinter installer that any
195
+ tool tree can reuse instead of forking. Point it at your tree and it discovers
196
+ every tool that answers `--advertise`, then installs or removes each one's
197
+ desktop entry, shell alias and Claude Code skill.
198
+
199
+ **Start here:**
200
+
201
+ ```bash
202
+ cd /path/to/your/tools
203
+ python3 -m cli_tools_kit
204
+ ```
205
+
206
+ That prints a brief you can paste into your coding agent (Claude Code or
207
+ similar); the agent interviews you for the handful of naming decisions and
208
+ writes the wrapper. `--interactive` answers the same questions on the command
209
+ line instead, and `--print-wrapper` just prints the skeleton. A complete
210
+ worked example — wrapper plus a tool — is in
211
+ [`examples/org-installer/`](examples/org-installer/).
212
+
213
+ ### Identity: what your installer claims on a host
214
+
215
+ Several organisations' installers can share a machine, so yours needs a name of
216
+ its own. `InstallerIdentity` derives every per-host artifact from one slug:
217
+
218
+ ```python
219
+ # my-org-tools/installer.py
220
+ import os
221
+ from cli_tools_kit import InstallerIdentity
222
+ from cli_tools_kit.gui_installer import run
223
+
224
+ HERE = os.path.dirname(os.path.abspath(__file__))
225
+
226
+ if __name__ == "__main__":
227
+ run(
228
+ identity=InstallerIdentity(slug="acme-tools", title="Acme Tools"),
229
+ root_dir=HERE,
230
+ entry_script=__file__,
231
+ )
232
+ ```
233
+
234
+ | Derived from `slug="acme-tools"` | Value |
235
+ |---|---|
236
+ | config + icon overrides | `~/.config/acme-tools/` |
237
+ | shell aliases | `~/.acme_tools_aliases` |
238
+ | icon cache | `~/.cache/acme-tools/` |
239
+ | the manager's own shortcut | `acme-tools-installer.desktop` |
240
+ | WM class | `acme_tools_installer` |
241
+ | login-check artifacts | `acme-tools-check.{desktop,log,json}` |
242
+ | `.desktop` marker | `Keywords=acme-tools;ai;tool;` |
243
+
244
+ That last one matters most: it is how the installer's orphan sweeper decides a
245
+ shortcut is *its* shortcut. With distinct markers, two organisations' installers
246
+ never delete each other's entries. Every derived name can be overridden with the
247
+ matching `InstallerIdentity` field (`config_dir`, `aliases_file`, `wm_class`, …).
248
+
249
+ **Passing no identity selects the historical first-party names**, so existing
250
+ installs are untouched by an upgrade. Running the engine bare — no identity, no
251
+ wrapper — stops and offers setup rather than claiming those names.
252
+
253
+ ### `run()`
254
+
255
+ Keyword-only; every argument defaults to `None`, meaning "leave the default".
256
+
257
+ | Argument | Default | What it does |
258
+ |---|---|---|
259
+ | `identity` | `LEGACY_IDENTITY` | The names above. The one argument a third party should always pass. |
260
+ | `root_dir` | cwd | The tree to manage. Discovery, `.env` loading and the self-shortcut's `Path=` all anchor here. |
261
+ | `entry_script` | this module | The script the manager shortcut and the login-check autostart entry launch. Pass `__file__` so they re-enter your wrapper, not the bare engine. |
262
+ | `discoverer` | flat + `tools_*/` walk, or the wider walk once `discovery_roots` is set | `callable(root) -> [(entry_point_path, category), …]`. Pass your own for a differently shaped tree. |
263
+ | `prune` | `None` | Extra directory names the default wider walk never enters, on top of the built-in set. Ignored when you pass your own `discoverer`. |
264
+ | `discovery_roots` | `[root_dir]` | Scan these directories instead — for tools that live in a subdirectory or several. |
265
+ | `group_by` | `"capability"` | Which field bands the GUI rows: `"capability"` (the advertised word) or `"category"` (whatever your discoverer assigned). Anything else raises `ValueError`. |
266
+ | `pre_discovery` | `None` | `callable(refresh: bool)` run once before scanning, for side effects like cloning repos into a cache. Skipped on the `--check` path so a login hook never touches the network. |
267
+ | `check_reconcile_shortcuts` | `True` | Whether `--check` also reinstalls drifted shortcuts. Set `False` when your tools' `--install` has side effects unsafe for a login hook, making `--check` skill-only. |
268
+ | `skill_targets` | `[claude_target()]` | Where the text screen can register a skill — see "The text screen" below. |
269
+ | `tui_preselect` | `None` | Initial ticks on the text screen: `None` ticks everything on a host with nothing installed yet and otherwise mirrors the host; `True`/`False` force one or the other. |
270
+ | `window_title` | identity's title | GUI window title. |
271
+ | `self_desktop_file`, `self_desktop_name`, `self_desktop_icon` | identity's | The manager's own shortcut. |
272
+ | `wm_class` | identity's | `StartupWMClass` for window-manager grouping. |
273
+ | `notify_app` | identity's | `notify-send` application label on the `--check` path. |
274
+ | `autostart_check_desktop_name`, `check_log_name`, `check_state_name` | identity's | Login-check artifact filenames. |
275
+
276
+ The identity is applied first and these individual names override it, so you can
277
+ take the whole namespace from a slug and still change one thing.
278
+
279
+ `run()` owns its own `argparse` and consumes `sys.argv`: `--list`, `--check`,
280
+ `--enable-autostart-check`, `--install`, `--update-all`, `--cleanup`, `--tui`,
281
+ `--gui`, and a screen when given none of them. A wrapper that needs its own
282
+ subcommands should skip `run()` and call the primitives (`discover_tools`,
283
+ `install_tool`, `remove_tool`, `cli_check`) after applying an identity with
284
+ `_apply_identity`.
285
+
286
+ ### The text screen
287
+
288
+ Without a display (`DISPLAY`/`WAYLAND_DISPLAY` unset: SSH, WSL, a server) or
289
+ without `python3-tk`, `run()` opens a curses screen instead of the tkinter
290
+ window; `--tui` and `--gui` force either. Same rows, same Apply: `Space` ticks
291
+ Install, `s` ticks Skill, `a`/`n` tick all or none, `Enter` applies, `q` quits.
292
+ On a host where none of the tools is installed yet every row starts ticked.
293
+
294
+ A skill can go to more than one place. The default target writes
295
+ `~/.claude/skills/<name>/` through the tool's `--install-skill`; a wrapper adds
296
+ others with `skill_targets`, and the screen lets the user tick which ones
297
+ Apply writes to (keys `1`..`9`):
298
+
299
+ ```python
300
+ from cli_tools_kit.tui_installer import SkillTarget, claude_target
301
+
302
+ session = SkillTarget(
303
+ key="fauclaude", label="fauclaude session plugin",
304
+ installed=lambda tool: ..., # bool
305
+ install=lambda tool: (True, "..."), # (ok, output)
306
+ uninstall=lambda tool: (True, ""),
307
+ )
308
+ run(identity=IDENTITY, root_dir=HERE, entry_script=__file__,
309
+ skill_targets=[claude_target(), session])
310
+ ```
311
+
312
+ Without any screen, `--apply NAMES` installs the named tools (aliases, or
313
+ `all`) and their skills, `--skill-target KEYS` says where the skills go
314
+ (`claude`, a wrapper's own keys, or `none`). This is what a coding agent runs
315
+ when it sets a machine up from a pasted prompt:
316
+
317
+ ```bash
318
+ ./installer.py --apply xrdlab,cifsearch --skill-target claude,fauclaude
319
+ ```
320
+
321
+ The screen calls the engine's `install_tool` / `remove_tool` / skill functions
322
+ by name at run time, so a wrapper that replaced them (to run each tool in its
323
+ own venv, say) is honoured there too.
324
+
325
+ ### Discovering your tools
326
+
327
+ With one `root_dir`, the default discoverer accepts two layouts, and a tree may
328
+ mix them:
329
+
330
+ - **flat** — `<root>/<tool>/main.py` (plus `requirements.txt`). Category empty,
331
+ so rows band by each tool's advertised `capability`.
332
+ - **nested** — `<root>/tools_<category>/<tool>/main.py`, where the folder
333
+ supplies the category label.
334
+
335
+ Directories starting with `_` or `.` are skipped.
336
+
337
+ With `discovery_roots` — several repos, each shaped as its authors liked — the
338
+ default is a wider walk of every root. A directory is a tool when it holds
339
+ `requirements.txt` next to `main.py` or `<dirname>.py` with dashes written as
340
+ underscores, which is how a one-tool repo names its script (`manim-kit` ships
341
+ `manim_kit.py`). The root itself counts, so such a repo is one tool. The walk
342
+ goes four levels deep at most and never enters `.venv`, `venv`, `.git`,
343
+ `node_modules`, `__pycache__`, `out`, `cache`, `build`, `dist`, `archive`, a
344
+ name starting with `vendor`, or a dot directory. Pass `prune=[...]` to add more
345
+ names to that list. The category is the tool's parent directory name, empty when
346
+ the parent is the root, and the root's own name when the tool is the root.
347
+
348
+ Anything else: pass a `discoverer`. If an expected tool does not appear, its `--advertise` is the
349
+ thing to fix — it must print JSON and exit *before* any heavy import, or it
350
+ trips the 5-second probe timeout. See [`PROTOCOL.md`](PROTOCOL.md).
351
+
352
+ ### Grouping rows by meaning
353
+
354
+ `group_by="capability"` bands rows by the one word each tool advertises. Once a
355
+ tree outgrows that, `cli_tools_kit.taxonomy` reads what the tree already
356
+ documents about itself and produces a small set of named categories:
357
+
358
+ ```python
359
+ from cli_tools_kit.taxonomy import ensure_groups
360
+
361
+ def discover(root):
362
+ groups = ensure_groups(root) # {tool_name: band label}
363
+ return [(entry, groups.get(name, "")) for entry, name in my_walk(root)]
364
+ ```
365
+
366
+ It fingerprints every `CLAUDE.md`/`README.md` in the tree and rebuilds only when
367
+ one changed (content hashes, not mtimes — a sync checkout restamps mtimes).
368
+ Three tiers, tried in order so it degrades rather than failing: an LLM naming
369
+ and filling the categories (Gemini via `GEMINI_API_KEY`, else a local LM Studio
370
+ / Ollama server), else the advertised capability words banded into a fixed six,
371
+ else embedding + k-means. Nothing configured means tier two, which is instant
372
+ and needs no network.
373
+
374
+ ### The GUI extra
375
+
376
+ Icon thumbnails need Pillow:
377
+
378
+ ```bash
379
+ pip install "cli-tools-kit[gui]==0.6.0"
380
+ ```
381
+
382
+ Installing the package also exposes a `cli-tool-installer` console script.
383
+
384
+ ## Sources: installing tools from several repos
385
+
386
+ An organisation's tools rarely sit in one checkout. `cli_tools_kit.sources` reads
387
+ a list of repos from a TOML file, puts each one on disk, and hands the engine one
388
+ discovery root per repo. The installer that consumes it is a few lines long.
389
+
390
+ `installer.toml` is tracked and shared by everyone:
391
+
392
+ ```toml
393
+ [[source]]
394
+ name = "acme/tools"
395
+ path = "." # relative to this file
396
+
397
+ [[source]]
398
+ name = "acme/lab"
399
+ url = "https://github.com/acme/lab-tools" # cloned into <root>/acme/lab
400
+
401
+ [[source]]
402
+ name = "manim-kit"
403
+ url = "https://github.com/AutomatedAlchemy/manim-kit"
404
+ ```
405
+
406
+ `installer.local.toml` next to it is optional and belongs to one machine, so keep
407
+ it out of git. It sets the root and replaces a `path` for a source matched by
408
+ `name`:
409
+
410
+ ```toml
411
+ root = "/home/me/checkouts"
412
+
413
+ [[source]]
414
+ name = "acme/lab"
415
+ path = "/home/me/work/lab-tools"
416
+ ```
417
+
418
+ A source resolves in this order: the path from the local file, then the `path`
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.
422
+
423
+ Cloning is deliberately narrow. Only `https://` URLs are cloned, `ext::` and
424
+ `file://` transports and any hook are switched off for the git call, the clone is
425
+ full rather than shallow (a tool that stamps its output with its commit needs the
426
+ history), and nothing is cloned into a root that does not exist or cannot be
427
+ written to. A clone that fails prints one line and that source is dropped, so a
428
+ colleague without access to a private repo still gets everybody else's tools.
429
+ `--refresh` brings the clones up to date with `git pull --ff-only`; a checkout
430
+ given by `path` is never pulled. Cloning happens in the engine's `pre_discovery`
431
+ hook, which `--check` skips, so the login check stays network-free.
432
+
433
+ A repo that is itself an installer tree can carry its own `installer.toml`. Its
434
+ `[[source]]` entries are resolved too, one nested level deep and no further, with
435
+ paths relative to that file and clones under the same root. A path that is
436
+ already resolved is not visited twice, so a file pointing back at its parent
437
+ cannot loop, and duplicates are dropped.
438
+
439
+ The consumer:
440
+
441
+ ```python
442
+ #!/usr/bin/env python3
443
+ import os
444
+ from cli_tools_kit import InstallerIdentity
445
+ from cli_tools_kit.sources import run_installer
446
+
447
+ HERE = os.path.dirname(os.path.abspath(__file__))
448
+ run_installer(os.path.join(HERE, "installer.toml"),
449
+ identity=InstallerIdentity(slug="acme-tools", title="Acme Tools"),
450
+ entry_script=__file__)
451
+ ```
452
+
453
+ `run_installer` takes `--root DIR` for itself and leaves every other flag to the
454
+ 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`.
458
+
459
+ Without a wrapper, the same thing from the command line:
460
+
461
+ ```bash
462
+ python3 -m cli_tools_kit install path/to/installer.toml --list
463
+ ```
464
+
465
+ That surface uses the default installer identity, so an organisation that wants
466
+ its own namespace on the host writes the wrapper above and runs that.
467
+
468
+ The two loaders are usable on their own:
469
+
470
+ ```python
471
+ from cli_tools_kit.sources import load_sources, resolve_sources
472
+
473
+ sources = load_sources("installer.toml") # [Source(name, url, path), …]
474
+ roots = resolve_sources(sources, root="~/acme-tools", refresh=False)
475
+ ```
476
+
477
+ `resolve_sources` returns one `Path` per repo it could resolve and logs a line
478
+ per repo it could not (`log=` takes any callable, `print` by default). Pass
479
+ `clone=False` to resolve from the filesystem alone and never reach the network.
480
+ Reading the TOML needs Python 3.11 or the `tomli` package, which is a dependency
481
+ on 3.10.
482
+
483
+ ## Tests
484
+
485
+ ```bash
486
+ pip install -e ".[dev]"
487
+ pytest
488
+ ```
489
+
490
+ ## Used by
491
+
492
+ Consumers, each a self-installing tool that answers `--advertise`:
493
+
494
+ - [`studon-client`](https://github.com/Probst1nator/studon-client) — `ToolInstaller` + `CronInstaller` + a Claude Code skill
495
+ - [`BlogGen`](https://github.com/AutomatedAlchemy/BlogGen) — install machinery falls back gracefully when the kit is absent
496
+ - [`lernclaude`](https://github.com/Probst1nator/lernclaude) — same pattern
497
+ - [`manim-kit`](https://github.com/AutomatedAlchemy/manim-kit) — reports `skill_status`
498
+
499
+ Two parent installers built on `gui_installer` are private (a `tools_*/<tool>/main.py`
500
+ monorepo and a flat tree bootstrapped from a `repos.json` cache); a third walks a
501
+ lab-tools tree and gives each tool its own venv.
502
+
503
+ ## License
504
+
505
+ MIT — see [`LICENSE`](LICENSE).