cli-tools-kit 0.6.0__py3-none-any.whl

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.
@@ -0,0 +1,261 @@
1
+ """First-run setup for an organisation adopting the installer.
2
+
3
+ Running the engine bare — ``cli-tool-installer`` in a fresh tree, with no
4
+ wrapper and no :class:`~cli_tools_kit.InstallerIdentity` — used to open a GUI
5
+ titled "probable.work - Tools Installer" that wrote ``ai_tools_manager.desktop``
6
+ and claimed the first-party alias file. That is never what a third party wants,
7
+ and they had no way to know it happened.
8
+
9
+ So the bare engine stops and offers setup instead. Two ways through it:
10
+
11
+ * **Hand the prompt to your coding agent** (default). :func:`agent_prompt`
12
+ prints a self-contained brief. Paste it into Claude Code (or any agent with
13
+ an ``AskUserQuestion``-style tool), and the agent interviews you for the four
14
+ things it cannot guess, then writes the wrapper. This is the common case:
15
+ the kit is mostly adopted from inside an agent session.
16
+ * **Answer here** (``--setup --interactive``). :func:`scripted_setup` asks the
17
+ same four questions on stdin and writes the same file.
18
+
19
+ Both end at one generated ``installer.py`` produced by :func:`wrapper_source`,
20
+ so the two paths cannot drift apart.
21
+ """
22
+
23
+ import os
24
+ import re
25
+ import sys
26
+ from typing import Optional
27
+
28
+ from .identity import InstallerIdentity
29
+
30
+ WRAPPER_NAME = "installer.py"
31
+
32
+
33
+ def _slugify(text: str) -> str:
34
+ """Best-effort org name → slug: ``Acme Corp Tools`` → ``acme-corp-tools``."""
35
+ slug = re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-")
36
+ return slug or "my-tools"
37
+
38
+
39
+ def wrapper_source(
40
+ identity: InstallerIdentity,
41
+ group_by: str = "capability",
42
+ tools_dirname: Optional[str] = None,
43
+ ) -> str:
44
+ """The ``installer.py`` an organisation drops at the root of its tool tree.
45
+
46
+ Deliberately short: every line that is not identity is a default worth
47
+ keeping. ``tools_dirname`` names a subdirectory holding the tools when they
48
+ do not sit directly at the tree root.
49
+ """
50
+ roots = ""
51
+ if tools_dirname:
52
+ roots = (
53
+ f'\n # Tools live in {tools_dirname}/ rather than beside this file.\n'
54
+ f' discovery_roots=[os.path.join(HERE, "{tools_dirname}")],'
55
+ )
56
+ title = identity.title or identity.display_title
57
+ return f'''#!/usr/bin/env python3
58
+ """Installer for the {title} tool tree.
59
+
60
+ Discovers every tool under this directory that answers --advertise, and lets you
61
+ install or remove its desktop entry, shell alias and Claude Code skill.
62
+
63
+ python3 {WRAPPER_NAME} # GUI
64
+ python3 {WRAPPER_NAME} --list # what was discovered
65
+ python3 {WRAPPER_NAME} --check # headless login reconciliation
66
+
67
+ See PROTOCOL.md in cli-tools-kit for what a tool must advertise to show up here.
68
+ """
69
+
70
+ import os
71
+
72
+ from cli_tools_kit import InstallerIdentity
73
+ from cli_tools_kit.gui_installer import run
74
+
75
+ HERE = os.path.dirname(os.path.abspath(__file__))
76
+
77
+ # Everything this installer claims on a host derives from the slug: the config
78
+ # directory, the alias file, the icon cache, its own .desktop entry, the WM
79
+ # class, and the Keywords marker its orphan sweeper matches on. Change the slug
80
+ # and you move house — existing shortcuts keep the old names.
81
+ IDENTITY = InstallerIdentity(
82
+ slug="{identity.slug}",
83
+ title="{title}",
84
+ icon="{identity.icon}",
85
+ )
86
+
87
+ if __name__ == "__main__":
88
+ run(
89
+ identity=IDENTITY,
90
+ root_dir=HERE,
91
+ entry_script=__file__,{roots}
92
+ # "capability" bands the GUI rows by each tool's advertised capability
93
+ # word; "category" bands by whatever label your discoverer assigns.
94
+ group_by="{group_by}",
95
+ )
96
+ '''
97
+
98
+
99
+ def agent_prompt(root_dir: str) -> str:
100
+ """The brief a user pastes into their coding agent to be set up.
101
+
102
+ Written to be read by an agent, not a person: it states the goal, the four
103
+ unknowns, where to look things up, and what "done" means. It asks the agent
104
+ to interview the user through a structured question tool rather than
105
+ guessing, because three of the four answers are naming decisions that are
106
+ expensive to change afterwards.
107
+ """
108
+ return f"""\
109
+ Set up a cli-tools-kit installer for my organisation in {root_dir}.
110
+
111
+ Context you need:
112
+ - cli-tools-kit is an installed Python package (`pip install cli-tools-kit`).
113
+ The engine is `cli_tools_kit.gui_installer.run()`; the identity type is
114
+ `cli_tools_kit.InstallerIdentity`. Read the package's README.md section
115
+ "Reusing the installer in your org", and PROTOCOL.md for the tool-side
116
+ `--advertise` contract. There is a complete working wrapper plus an example
117
+ tool in the package repo under `examples/org-installer/` — copy that shape.
118
+ - The installer discovers tools by running each candidate's `main.py
119
+ --advertise` (5 second timeout) and reading the JSON it prints.
120
+
121
+ Ask me, using your structured question tool (AskUserQuestion or equivalent) —
122
+ do not guess, these are naming decisions that are painful to change once
123
+ shortcuts exist on people's machines:
124
+ 1. The slug: one lowercase token identifying my org's installer. It becomes
125
+ ~/.config/<slug>/, ~/.<slug>_aliases, <slug>-installer.desktop, the WM
126
+ class, and the desktop Keywords marker. Offer a couple of candidates
127
+ derived from the directory name and from my organisation's name.
128
+ 2. The window title users will see.
129
+ 3. The desktop icon: a freedesktop icon name (e.g. system-software-install,
130
+ applications-utilities) or an absolute path to a PNG.
131
+ 4. Whether my tools sit directly in {root_dir} or in a subdirectory, and
132
+ whether the installer should band rows by each tool's advertised
133
+ `capability` word or by a `category` label I assign myself. Look at the
134
+ directory first and propose what actually fits rather than asking blind.
135
+
136
+ Then:
137
+ - Write {root_dir}/{WRAPPER_NAME} using `InstallerIdentity` and `run()`. Keep
138
+ it to identity plus root_dir/entry_script — every other knob has a default
139
+ worth keeping. `python3 -m cli_tools_kit --setup --print-wrapper` prints a
140
+ correct skeleton you can start from.
141
+ - Run `python3 {WRAPPER_NAME} --list` and show me what it discovered. If a tool
142
+ I expected is missing, its `--advertise` is the thing to fix: it must print
143
+ JSON and exit BEFORE any heavy import, or it trips the 5s timeout.
144
+ - Do NOT run `--install` for me. Tell me what it would install and let me
145
+ decide.
146
+
147
+ If any of my existing tools do not yet answer `--advertise`, show me the
148
+ PROTOCOL.md skeleton and offer to add it to one tool as a worked example
149
+ before doing the rest.
150
+ """
151
+
152
+
153
+ def _ask(prompt: str, default: str = "") -> str:
154
+ """One stdin question with a shown default; empty answer takes the default."""
155
+ suffix = f" [{default}]" if default else ""
156
+ try:
157
+ answer = input(f"{prompt}{suffix}: ").strip()
158
+ except (EOFError, KeyboardInterrupt):
159
+ print()
160
+ raise SystemExit(1)
161
+ return answer or default
162
+
163
+
164
+ def scripted_setup(root_dir: str) -> int:
165
+ """Interview the user on stdin and write the wrapper. Returns an exit code."""
166
+ print(f"\nSetting up a cli-tools-kit installer in {root_dir}\n")
167
+ print("Four questions. Press Enter to take the default in brackets.\n")
168
+
169
+ default_slug = _slugify(os.path.basename(os.path.abspath(root_dir)))
170
+ while True:
171
+ slug = _ask("Slug (lowercase, identifies your installer on the host)", default_slug)
172
+ try:
173
+ identity = InstallerIdentity(slug=slug)
174
+ break
175
+ except ValueError as exc:
176
+ print(f" {exc}\n")
177
+
178
+ title = _ask("Window title", f"{slug} Tools")
179
+ icon = _ask("Desktop icon (freedesktop name or absolute path)", "system-software-install")
180
+ group_by = ""
181
+ while group_by not in ("capability", "category"):
182
+ group_by = _ask("Band rows by 'capability' or 'category'", "capability")
183
+
184
+ identity = InstallerIdentity(slug=slug, title=title, icon=icon)
185
+ target = os.path.join(root_dir, WRAPPER_NAME)
186
+
187
+ if os.path.exists(target):
188
+ if _ask(f"\n{target} exists. Overwrite? (y/N)", "N").lower() not in ("y", "yes"):
189
+ print("Left it alone. Nothing written.")
190
+ return 1
191
+
192
+ with open(target, "w", encoding="utf-8") as fh:
193
+ fh.write(wrapper_source(identity, group_by=group_by))
194
+
195
+ print(f"\nWrote {target}\n")
196
+ print("It will claim these names on this host:")
197
+ print(f" desktop entry {identity.self_desktop_file}")
198
+ print(f" window class {identity.self_wm_class}")
199
+ print(f" config {identity.config_path}")
200
+ print(f" aliases {identity.aliases_path}")
201
+ print(f" desktop marker Keywords={identity.desktop_keywords}")
202
+ print(f"\nNext: python3 {WRAPPER_NAME} --list")
203
+ return 0
204
+
205
+
206
+ def print_onboarding(root_dir: str) -> int:
207
+ """What the bare engine shows instead of opening a mis-branded GUI."""
208
+ print(f"""
209
+ cli-tools-kit — no installer is configured for this tree.
210
+
211
+ Running the engine directly would open an installer that claims the default
212
+ first-party names on this host, which is almost certainly not what you want.
213
+ Set up your own instead; it is one small file.
214
+
215
+ Paste the brief below into your coding agent (Claude Code or similar) and it
216
+ will interview you and write it:
217
+
218
+ {"-" * 72}""")
219
+ print(agent_prompt(os.path.abspath(root_dir)))
220
+ print(f"""{"-" * 72}
221
+
222
+ Or answer the same questions here:
223
+
224
+ python3 -m cli_tools_kit --setup --interactive
225
+
226
+ Or just print the wrapper skeleton and edit it yourself:
227
+
228
+ python3 -m cli_tools_kit --setup --print-wrapper
229
+ """)
230
+ return 0
231
+
232
+
233
+ def main(argv: Optional[list] = None) -> int:
234
+ """``python3 -m cli_tools_kit`` — setup, and nothing else."""
235
+ import argparse
236
+
237
+ parser = argparse.ArgumentParser(
238
+ prog="python3 -m cli_tools_kit",
239
+ description="Set up a cli-tools-kit installer for your organisation.",
240
+ )
241
+ parser.add_argument("--setup", action="store_true",
242
+ help="show setup instructions (the default action)")
243
+ parser.add_argument("--interactive", action="store_true",
244
+ help="answer the setup questions here instead of via an agent")
245
+ parser.add_argument("--print-wrapper", action="store_true",
246
+ help="print an installer.py skeleton to stdout and exit")
247
+ parser.add_argument("--slug", default="",
248
+ help="slug for --print-wrapper (default: this directory's name)")
249
+ parser.add_argument("--root", default=os.getcwd(),
250
+ help="the tool tree to set up (default: current directory)")
251
+ args = parser.parse_args(argv if argv is not None else sys.argv[1:])
252
+
253
+ root = os.path.abspath(args.root)
254
+
255
+ if args.print_wrapper:
256
+ slug = args.slug or _slugify(os.path.basename(root))
257
+ print(wrapper_source(InstallerIdentity(slug=slug, title=f"{slug} Tools")), end="")
258
+ return 0
259
+ if args.interactive:
260
+ return scripted_setup(root)
261
+ return print_onboarding(root)
@@ -0,0 +1,97 @@
1
+ """Skill-freshness helpers for the installer protocol.
2
+
3
+ A tool that bundles a Claude Code skill installs it into
4
+ ``~/.claude/skills/<skill_name>/``. These helpers let an installer — or the tool
5
+ itself, at ``--advertise`` time — decide whether the installed copy is up to
6
+ date with the tool's bundled version, so the installer can *take note* and
7
+ suggest an update instead of silently keeping a stale skill.
8
+
9
+ The unit of comparison is a ``{relative_posix_path: text}`` mapping. A tool
10
+ hashes what it would install; an installer hashes what is installed; equal
11
+ hashes mean "current". This keeps the comparison authoritative and free of any
12
+ cross-package version coupling — each side computes over content it can see.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import os
19
+ from typing import Dict, Optional
20
+
21
+ # Names that may appear inside an installed skill dir without being part of the
22
+ # skill's content — excluded so a stray artifact can't force a false "stale".
23
+ _SKILL_IGNORE_NAMES = {".DS_Store"}
24
+
25
+
26
+ def _default_skills_dir() -> str:
27
+ """``~/.claude/skills`` resolved at call time (honours a patched $HOME)."""
28
+ return os.path.join(os.path.expanduser("~"), ".claude", "skills")
29
+
30
+
31
+ def skill_payload_hash(files: Dict[str, str]) -> str:
32
+ """Stable, order-independent hash of a skill's content.
33
+
34
+ ``files`` maps POSIX-relative paths (e.g. ``"SKILL.md"``,
35
+ ``"scripts/send.sh"``) to their text. The same mapping always yields the
36
+ same digest, so a tool can hash what it bundles and an installer can hash
37
+ what is installed and compare the two.
38
+ """
39
+ h = hashlib.sha256()
40
+ for rel in sorted(files):
41
+ h.update(rel.encode("utf-8"))
42
+ h.update(b"\0")
43
+ h.update(files[rel].encode("utf-8"))
44
+ h.update(b"\0")
45
+ return h.hexdigest()
46
+
47
+
48
+ def read_installed_skill(
49
+ skill_name: str, skills_dir: Optional[str] = None
50
+ ) -> Optional[Dict[str, str]]:
51
+ """Read an installed skill's files as ``{relpath: text}``, or ``None``.
52
+
53
+ Returns ``None`` when the skill is not installed (no ``SKILL.md`` under its
54
+ dir). Binary/unreadable files and obvious junk (``__pycache__``, ``*.pyc``,
55
+ ``.DS_Store``) are skipped so the comparison stays robust.
56
+ """
57
+ root = os.path.join(skills_dir or _default_skills_dir(), skill_name)
58
+ if not os.path.isfile(os.path.join(root, "SKILL.md")):
59
+ return None
60
+ out: Dict[str, str] = {}
61
+ for dirpath, dirnames, names in os.walk(root):
62
+ dirnames[:] = [d for d in dirnames if d != "__pycache__"]
63
+ for name in names:
64
+ if name in _SKILL_IGNORE_NAMES or name.endswith(".pyc"):
65
+ continue
66
+ path = os.path.join(dirpath, name)
67
+ rel = os.path.relpath(path, root).replace(os.sep, "/")
68
+ try:
69
+ with open(path, "r", encoding="utf-8") as f:
70
+ out[rel] = f.read()
71
+ except (OSError, UnicodeDecodeError):
72
+ continue
73
+ return out
74
+
75
+
76
+ def installed_skill_hash(
77
+ skill_name: str, skills_dir: Optional[str] = None
78
+ ) -> Optional[str]:
79
+ """Hash of the installed skill's content, or ``None`` when not installed."""
80
+ files = read_installed_skill(skill_name, skills_dir)
81
+ return None if files is None else skill_payload_hash(files)
82
+
83
+
84
+ def skill_status(
85
+ skill_name: str, bundled: Dict[str, str], skills_dir: Optional[str] = None
86
+ ) -> str:
87
+ """Return ``"absent"`` | ``"current"`` | ``"stale"``.
88
+
89
+ Compares the installed skill against ``bundled`` (the ``{relpath: text}``
90
+ the tool would install). ``"absent"`` if not installed, ``"current"`` if the
91
+ content matches, ``"stale"`` otherwise — the cue for an installer to suggest
92
+ an update.
93
+ """
94
+ installed = read_installed_skill(skill_name, skills_dir)
95
+ if installed is None:
96
+ return "absent"
97
+ return "current" if skill_payload_hash(installed) == skill_payload_hash(bundled) else "stale"
@@ -0,0 +1,335 @@
1
+ """Sources — one installer offering tools that live in several repos.
2
+
3
+ An organisation's tools rarely sit in one checkout. This module lets the
4
+ installer read a list of repos from a TOML file next to it, put each one on
5
+ disk, and hand the engine one discovery root per repo.
6
+
7
+ ``installer.toml`` is tracked and shared by everyone:
8
+
9
+ [[source]]
10
+ name = "acme/tools"
11
+ path = "." # relative to this file
12
+
13
+ [[source]]
14
+ name = "acme/lab"
15
+ url = "https://github.com/acme/lab-tools" # cloned into <root>/acme/lab
16
+
17
+ ``installer.local.toml`` next to it is optional and belongs to one machine, so
18
+ it is gitignored by convention. It sets ``root`` and adds or replaces a ``path``
19
+ for a source matched by ``name``:
20
+
21
+ root = "/home/me/checkouts"
22
+
23
+ [[source]]
24
+ name = "acme/lab"
25
+ path = "/home/me/work/lab-tools"
26
+
27
+ A source resolves in this order: the path from the local file, then the ``path``
28
+ from the tracked file, then an existing ``<root>/<name>``, then a clone of
29
+ ``url`` into ``<root>/<name>``. Only ``https://`` URLs are cloned, the clone is
30
+ full rather than shallow, and a clone that fails prints one line and drops that
31
+ source, so the other repos' tools still install.
32
+
33
+ A resolved repo may carry its own ``installer.toml``. Its ``[[source]]`` entries
34
+ are resolved too, one nested level deep and no further. Paths in a nested file
35
+ are relative to that file, clones still go under the same root, a path already
36
+ resolved is not visited twice, and duplicates are dropped.
37
+
38
+ The whole feature is three calls:
39
+
40
+ sources = load_sources("installer.toml")
41
+ roots = resolve_sources(sources, root="~/acme-tools")
42
+
43
+ or, for a wrapper that just wants the installer:
44
+
45
+ run_installer(os.path.join(HERE, "installer.toml"),
46
+ identity=InstallerIdentity(slug="acme-tools"),
47
+ entry_script=__file__)
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import os
53
+ import subprocess
54
+ import sys
55
+ from dataclasses import dataclass
56
+ from pathlib import Path
57
+ from typing import Callable, List, Optional, Sequence
58
+
59
+ __all__ = ["Source", "load_sources", "resolve_sources", "run_installer"]
60
+
61
+ # How far below the top-level installer.toml a nested one is still read.
62
+ MAX_NESTING = 1
63
+
64
+ # Neutralise the ext:: and file:// transports and any hook, so fetching a repo
65
+ # cannot turn into running code from it.
66
+ GIT_SAFE = ("-c", "protocol.ext.allow=never",
67
+ "-c", "protocol.file.allow=never",
68
+ "-c", "core.hooksPath=/dev/null")
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Source:
73
+ """One repo an installer offers tools from.
74
+
75
+ ``path`` is already absolute: ``load_sources`` resolves it against the TOML
76
+ file it was written in. ``url`` is used only when no path is on disk.
77
+ """
78
+
79
+ name: str
80
+ url: Optional[str] = None
81
+ path: Optional[str] = None
82
+
83
+
84
+ # --- TOML -------------------------------------------------------------------
85
+
86
+ def _toml_module():
87
+ """``tomllib`` (Python 3.11+), ``tomli`` if it is installed, else None."""
88
+ try:
89
+ import tomllib # noqa: PLC0415
90
+ return tomllib
91
+ except ImportError:
92
+ pass
93
+ try:
94
+ import tomli # noqa: PLC0415
95
+ return tomli
96
+ except ImportError:
97
+ return None
98
+
99
+
100
+ def _read_toml(path: Path, log: Callable = print) -> dict:
101
+ """One TOML file as a dict, empty if it is absent or does not parse."""
102
+ toml = _toml_module()
103
+ if toml is None:
104
+ log(f"{path.name}: not read (needs Python 3.11 or `pip install tomli`)")
105
+ return {}
106
+ if not path.is_file():
107
+ return {}
108
+ try:
109
+ with open(path, "rb") as fh:
110
+ return toml.load(fh)
111
+ except (OSError, ValueError) as exc:
112
+ log(f"{path.name}: not read ({exc})")
113
+ return {}
114
+
115
+
116
+ def _local_path_for(config_path: Path) -> Path:
117
+ """``installer.toml`` -> ``installer.local.toml`` in the same directory."""
118
+ return config_path.with_name(config_path.stem + ".local" + config_path.suffix)
119
+
120
+
121
+ def _absolute(base: Path, raw: str) -> str:
122
+ return os.path.abspath(os.path.join(str(base), os.path.expanduser(raw)))
123
+
124
+
125
+ def local_root(config_path, local_path=None) -> Optional[str]:
126
+ """The top-level ``root`` of the local file next to ``config_path``, or None."""
127
+ config_path = Path(config_path)
128
+ local = Path(local_path) if local_path is not None else _local_path_for(config_path)
129
+ value = _read_toml(local).get("root")
130
+ if isinstance(value, str) and value:
131
+ return str(Path(os.path.expanduser(value)).absolute())
132
+ return None
133
+
134
+
135
+ def load_sources(config_path, local_path=None, log: Callable = print) -> List[Source]:
136
+ """The ``[[source]]`` entries of one TOML file, local overrides applied.
137
+
138
+ ``local_path`` defaults to ``installer.local.toml`` next to ``config_path``.
139
+ A ``path`` is taken relative to the file it is written in. An entry without
140
+ a name is reported and dropped.
141
+ """
142
+ config_path = Path(config_path)
143
+ local_path = Path(local_path) if local_path is not None else _local_path_for(config_path)
144
+ data = _read_toml(config_path, log)
145
+ local = _read_toml(local_path, log)
146
+
147
+ overrides = {entry["name"]: entry
148
+ for entry in local.get("source") or []
149
+ if isinstance(entry, dict) and entry.get("name")}
150
+
151
+ sources: List[Source] = []
152
+ for entry in data.get("source") or []:
153
+ if not isinstance(entry, dict):
154
+ continue
155
+ name = entry.get("name")
156
+ if not name:
157
+ log(f"{config_path.name}: a [[source]] without a name, skipped")
158
+ continue
159
+ override = overrides.get(name, {}).get("path")
160
+ raw = override or entry.get("path")
161
+ base = local_path.parent if override else config_path.parent
162
+ path = _absolute(base, raw) if raw else None
163
+ sources.append(Source(name=name, url=entry.get("url"), path=path))
164
+ return sources
165
+
166
+
167
+ # --- resolution -------------------------------------------------------------
168
+
169
+ def _git(*args):
170
+ try:
171
+ result = subprocess.run(["git", *GIT_SAFE, *args], capture_output=True, text=True)
172
+ except OSError as exc:
173
+ return False, str(exc)
174
+ return result.returncode == 0, (result.stdout + result.stderr).strip()
175
+
176
+
177
+ def _clone_reason(output: str) -> str:
178
+ lines = [line.strip() for line in output.splitlines() if line.strip()]
179
+ return next((line for line in lines if line.startswith("fatal:")),
180
+ lines[-1] if lines else "git clone failed")
181
+
182
+
183
+ def _resolve_one(source: Source, root: Path, refresh: bool, log: Callable,
184
+ clone: bool) -> Optional[Path]:
185
+ """Where one source sits on disk, cloning it if that is the only way."""
186
+ if source.path and Path(source.path).is_dir():
187
+ return Path(source.path).resolve()
188
+
189
+ target = root.joinpath(*source.name.split("/"))
190
+ if target.is_dir():
191
+ if refresh and clone and (target / ".git").is_dir() and source.url:
192
+ ok, _ = _git("-C", str(target), "pull", "--ff-only")
193
+ log(f"{source.name}: pulled" if ok else
194
+ f"{source.name}: left as is, local changes or diverged history")
195
+ return target.resolve()
196
+
197
+ if not source.url:
198
+ if clone:
199
+ log(f"{source.name}: no checkout at {target} and no url, skipped")
200
+ return None
201
+ if not source.url.lower().startswith("https://"):
202
+ if clone:
203
+ log(f"{source.name}: {source.url} is not an https:// URL, skipped")
204
+ return None
205
+ if not clone:
206
+ return None
207
+ if not (root.is_dir() and os.access(str(root), os.W_OK)):
208
+ log(f"{source.name}: not cloned, skipped ({root} does not exist or cannot be"
209
+ " written to; pass --root DIR to clone somewhere else)")
210
+ return None
211
+
212
+ log(f"Cloning {source.url} -> {target}")
213
+ target.parent.mkdir(parents=True, exist_ok=True)
214
+ ok, out = _git("clone", source.url, str(target))
215
+ if not ok:
216
+ log(f"{source.name}: not cloned, skipped ({_clone_reason(out)})")
217
+ return None
218
+ return target.resolve()
219
+
220
+
221
+ def _resolve_level(sources: Sequence[Source], root: Path, refresh: bool, log: Callable,
222
+ clone: bool, config_name: str, depth: int,
223
+ found: List[Path], seen: set) -> None:
224
+ for source in sources:
225
+ path = _resolve_one(source, root, refresh, log, clone)
226
+ if path is None or path in seen:
227
+ continue
228
+ seen.add(path)
229
+ found.append(path)
230
+ if depth >= MAX_NESTING:
231
+ continue
232
+ nested = path / config_name
233
+ if nested.is_file():
234
+ _resolve_level(load_sources(nested, log=log), root, refresh, log, clone,
235
+ config_name, depth + 1, found, seen)
236
+
237
+
238
+ def resolve_sources(sources: Sequence[Source], root, refresh: bool = False,
239
+ log: Callable = print, clone: bool = True) -> List[Path]:
240
+ """Put every source on disk and return one discovery root per repo.
241
+
242
+ Clones the sources that are given by a URL and are not on disk yet. With
243
+ ``refresh`` the clones are also brought up to date with ``git pull
244
+ --ff-only``; a checkout given by ``path`` is never pulled. A source that
245
+ cannot be resolved prints one line and is left out.
246
+
247
+ A resolved repo that holds its own ``installer.toml`` contributes its
248
+ sources too, one nested level deep. Clones from a nested file go under the
249
+ same root. A path that is already in the result is not visited again, so a
250
+ file that points back at its parent cannot loop.
251
+
252
+ ``clone=False`` resolves from the filesystem alone and never reaches the
253
+ network, which is what the login check needs.
254
+ """
255
+ root = Path(os.path.expanduser(str(root))).absolute()
256
+ found: List[Path] = []
257
+ _resolve_level(sources, root, refresh, log, clone, "installer.toml", 0, found, set())
258
+ return found
259
+
260
+
261
+ # --- the convenience wrapper ------------------------------------------------
262
+
263
+ def _take_root(argv: List[str]):
264
+ """Pull ``--root DIR`` out of ``argv``. The engine owns every other flag."""
265
+ rest, root = [], None
266
+ i = 0
267
+ while i < len(argv):
268
+ arg = argv[i]
269
+ if arg == "--root" and i + 1 < len(argv):
270
+ root = argv[i + 1]
271
+ i += 2
272
+ continue
273
+ if arg.startswith("--root="):
274
+ root = arg[len("--root="):]
275
+ i += 1
276
+ continue
277
+ rest.append(arg)
278
+ i += 1
279
+ return rest, root
280
+
281
+
282
+ def default_root(config_path) -> str:
283
+ """Two levels above the config file's directory.
284
+
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.
287
+ """
288
+ return str(Path(config_path).absolute().parent.parent.parent)
289
+
290
+
291
+ def run_installer(config_path, argv=None, **run_kwargs):
292
+ """Read a sources file, wire the engine to it, and run the installer.
293
+
294
+ ``--root DIR`` is taken from ``argv`` (``sys.argv[1:]`` by default) and the
295
+ rest is left to the engine, so ``--list``, ``--apply``, ``--skill-target``,
296
+ ``--check``, ``--tui`` and ``--gui`` keep working. ``--refresh`` stays the
297
+ engine's flag: it reaches the pre-discovery hook, which then pulls every
298
+ clone.
299
+
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
302
+ pre-discovery hook, which the engine skips on the ``--check`` path, so that
303
+ check stays network-free and sees whatever is already on disk.
304
+
305
+ Every other keyword goes to :func:`cli_tools_kit.gui_installer.run`.
306
+ ``discovery_roots`` and ``pre_discovery`` are this function's to set.
307
+ ``prune`` reaches the walker that way, so a wrapper can name directories
308
+ its repos keep that hold no tools.
309
+ """
310
+ for reserved in ("discovery_roots", "pre_discovery"):
311
+ if reserved in run_kwargs:
312
+ raise TypeError(f"run_installer sets {reserved} itself")
313
+
314
+ config_path = Path(config_path).absolute()
315
+ argv = list(sys.argv[1:] if argv is None else argv)
316
+ argv, root_arg = _take_root(argv)
317
+ sys.argv = [sys.argv[0]] + argv
318
+
319
+ root = root_arg or local_root(config_path) or default_root(config_path)
320
+ root = str(Path(os.path.expanduser(root)).absolute())
321
+ sources = load_sources(config_path)
322
+
323
+ # The engine reads DISCOVERY_ROOTS after the hook has run, so the hook fills
324
+ # this list in place with what it resolved. It is pre-filled with what is on
325
+ # disk already, for the --check path that never calls the hook.
326
+ roots = [str(p) for p in resolve_sources(sources, root, clone=False,
327
+ log=lambda *_: None)]
328
+
329
+ def pre_discovery(refresh):
330
+ roots[:] = [str(p) for p in resolve_sources(sources, root, refresh=refresh)]
331
+
332
+ from . import gui_installer # noqa: PLC0415 — imports tkinter, keep it lazy
333
+ run_kwargs.setdefault("root_dir", root)
334
+ return gui_installer.run(discovery_roots=roots, pre_discovery=pre_discovery,
335
+ **run_kwargs)