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.
- cli_tools_kit/__init__.py +59 -0
- cli_tools_kit/__main__.py +43 -0
- cli_tools_kit/advertise.py +77 -0
- cli_tools_kit/cron_installer.py +159 -0
- cli_tools_kit/gui_installer.py +5928 -0
- cli_tools_kit/host.py +239 -0
- cli_tools_kit/identity.py +229 -0
- cli_tools_kit/onboarding.py +261 -0
- cli_tools_kit/skills.py +97 -0
- cli_tools_kit/sources.py +335 -0
- cli_tools_kit/taxonomy/__init__.py +40 -0
- cli_tools_kit/taxonomy/build.py +171 -0
- cli_tools_kit/taxonomy/capability.py +116 -0
- cli_tools_kit/taxonomy/cluster.py +261 -0
- cli_tools_kit/taxonomy/corpus.py +268 -0
- cli_tools_kit/taxonomy/embedder.py +170 -0
- cli_tools_kit/taxonomy/groups.py +108 -0
- cli_tools_kit/taxonomy/llm_groups.py +748 -0
- cli_tools_kit/tool_installer.py +572 -0
- cli_tools_kit/tui_installer.py +462 -0
- cli_tools_kit-0.6.0.dist-info/METADATA +505 -0
- cli_tools_kit-0.6.0.dist-info/RECORD +26 -0
- cli_tools_kit-0.6.0.dist-info/WHEEL +5 -0
- cli_tools_kit-0.6.0.dist-info/entry_points.txt +3 -0
- cli_tools_kit-0.6.0.dist-info/licenses/LICENSE +21 -0
- cli_tools_kit-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|
cli_tools_kit/skills.py
ADDED
|
@@ -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"
|
cli_tools_kit/sources.py
ADDED
|
@@ -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)
|