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
cli_tools_kit/host.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Platform decisions, in one place.
|
|
2
|
+
|
|
3
|
+
Everything the engine does differently on Windows lives here, so the rest of
|
|
4
|
+
the package can stay written for one host. On Linux a CLI tool becomes a bash
|
|
5
|
+
alias in ``~/.tools_aliases`` sourced from ``~/.bashrc``; Windows has no such
|
|
6
|
+
file, so the tool becomes a pair of small launcher scripts (shims) in a
|
|
7
|
+
directory that is added to the user's PATH once.
|
|
8
|
+
|
|
9
|
+
``IS_WINDOWS`` is a module constant rather than a call so tests can patch it
|
|
10
|
+
(``monkeypatch.setattr(host, "IS_WINDOWS", True)``) and exercise the Windows
|
|
11
|
+
paths on Linux. Read it as ``host.IS_WINDOWS`` at call time, never
|
|
12
|
+
``from .host import IS_WINDOWS``, or the patch will not be seen.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import shlex
|
|
19
|
+
import subprocess
|
|
20
|
+
from typing import Iterable, Optional
|
|
21
|
+
|
|
22
|
+
IS_WINDOWS = os.name == "nt"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# --- where the shims live -----------------------------------------------------
|
|
26
|
+
|
|
27
|
+
def shim_dir(identity) -> str:
|
|
28
|
+
"""The directory this installer's Windows shims go in.
|
|
29
|
+
|
|
30
|
+
Derived from the identity's slug, like every other per-host name, so two
|
|
31
|
+
organisations on one host do not share a shim directory.
|
|
32
|
+
"""
|
|
33
|
+
return identity.shim_path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _quote_cmd(value: str) -> str:
|
|
37
|
+
"""Quote one argument for a .cmd batch file."""
|
|
38
|
+
return f'"{value}"' if value else '""'
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def write_shims(directory: str, alias: str, python: str, script: str,
|
|
42
|
+
args: Iterable[str] = ()) -> list:
|
|
43
|
+
"""Write the two launcher files for one CLI tool. Returns their paths.
|
|
44
|
+
|
|
45
|
+
``<alias>.cmd`` is what cmd.exe and PowerShell run. The extensionless
|
|
46
|
+
``<alias>`` is a /bin/sh script for Git Bash, which is the shell Claude
|
|
47
|
+
Code uses on Windows; Git Bash ignores the .cmd file, and cmd.exe ignores
|
|
48
|
+
the extensionless one, so both can sit in the same directory.
|
|
49
|
+
"""
|
|
50
|
+
os.makedirs(directory, exist_ok=True)
|
|
51
|
+
args = list(args or [])
|
|
52
|
+
|
|
53
|
+
cmd_line = " ".join([_quote_cmd(python), _quote_cmd(script)]
|
|
54
|
+
+ [_quote_cmd(a) for a in args])
|
|
55
|
+
cmd_path = os.path.join(directory, alias + ".cmd")
|
|
56
|
+
with open(cmd_path, "w", newline="\r\n") as fh:
|
|
57
|
+
fh.write("@echo off\n")
|
|
58
|
+
fh.write(cmd_line + " %*\n")
|
|
59
|
+
|
|
60
|
+
# Git Bash wants forward slashes; a backslash there is an escape character.
|
|
61
|
+
sh_python = python.replace("\\", "/")
|
|
62
|
+
sh_script = script.replace("\\", "/")
|
|
63
|
+
sh_args = " ".join(shlex.quote(a) for a in args)
|
|
64
|
+
sh_line = f'exec "{sh_python}" "{sh_script}"'
|
|
65
|
+
if sh_args:
|
|
66
|
+
sh_line += " " + sh_args
|
|
67
|
+
sh_line += ' "$@"'
|
|
68
|
+
sh_path = os.path.join(directory, alias)
|
|
69
|
+
with open(sh_path, "w", newline="\n") as fh:
|
|
70
|
+
fh.write("#!/bin/sh\n")
|
|
71
|
+
fh.write(sh_line + "\n")
|
|
72
|
+
try:
|
|
73
|
+
os.chmod(sh_path, 0o755)
|
|
74
|
+
except OSError:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
return [cmd_path, sh_path]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def remove_shims(directory: str, alias: str) -> list:
|
|
81
|
+
"""Delete both shim files for one tool. Returns the ones that were there."""
|
|
82
|
+
removed = []
|
|
83
|
+
for name in (alias + ".cmd", alias):
|
|
84
|
+
path = os.path.join(directory, name)
|
|
85
|
+
try:
|
|
86
|
+
os.remove(path)
|
|
87
|
+
removed.append(path)
|
|
88
|
+
except OSError:
|
|
89
|
+
pass
|
|
90
|
+
return removed
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --- the user PATH ------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def _default_registry():
|
|
96
|
+
"""The stdlib ``winreg`` module, or None off Windows."""
|
|
97
|
+
try:
|
|
98
|
+
import winreg
|
|
99
|
+
return winreg
|
|
100
|
+
except ImportError:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _broadcast_setting_change() -> None:
|
|
105
|
+
"""Tell running programs the environment changed. Best effort."""
|
|
106
|
+
try:
|
|
107
|
+
import ctypes
|
|
108
|
+
HWND_BROADCAST = 0xFFFF
|
|
109
|
+
WM_SETTINGCHANGE = 0x001A
|
|
110
|
+
SMTO_ABORTIFHUNG = 0x0002
|
|
111
|
+
ctypes.windll.user32.SendMessageTimeoutW( # type: ignore[attr-defined]
|
|
112
|
+
HWND_BROADCAST, WM_SETTINGCHANGE, 0, "Environment",
|
|
113
|
+
SMTO_ABORTIFHUNG, 5000, None,
|
|
114
|
+
)
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _same_dir(a: str, b: str) -> bool:
|
|
120
|
+
"""Compare two Windows directory paths: case-insensitive, either slash,
|
|
121
|
+
trailing separator ignored. Spelled out rather than left to os.path.normcase
|
|
122
|
+
so the comparison is the Windows one even when the tests run on Linux."""
|
|
123
|
+
def norm(value: str) -> str:
|
|
124
|
+
return value.replace("/", "\\").rstrip("\\").lower()
|
|
125
|
+
return norm(a) == norm(b)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def ensure_user_path(directory: str, reg=None) -> bool:
|
|
129
|
+
"""Add ``directory`` to the user's PATH, once.
|
|
130
|
+
|
|
131
|
+
Writes ``HKCU\\Environment\\Path`` as REG_EXPAND_SZ, broadcasts the change
|
|
132
|
+
so newly started programs pick it up, and prepends the directory to this
|
|
133
|
+
process's PATH. Returns True if the registry value was changed, False if
|
|
134
|
+
the directory was already listed (or there is no registry to write).
|
|
135
|
+
|
|
136
|
+
``reg`` is the registry module to use; it defaults to ``winreg`` and is
|
|
137
|
+
what the tests replace with a fake.
|
|
138
|
+
"""
|
|
139
|
+
reg = reg if reg is not None else _default_registry()
|
|
140
|
+
|
|
141
|
+
changed = False
|
|
142
|
+
if reg is not None:
|
|
143
|
+
key = reg.OpenKey(reg.HKEY_CURRENT_USER, "Environment", 0,
|
|
144
|
+
reg.KEY_READ | reg.KEY_WRITE)
|
|
145
|
+
try:
|
|
146
|
+
try:
|
|
147
|
+
current = reg.QueryValueEx(key, "Path")[0] or ""
|
|
148
|
+
except OSError:
|
|
149
|
+
current = ""
|
|
150
|
+
parts = [p for p in current.split(";") if p.strip()]
|
|
151
|
+
if not any(_same_dir(p, directory) for p in parts):
|
|
152
|
+
parts.append(directory)
|
|
153
|
+
reg.SetValueEx(key, "Path", 0, reg.REG_EXPAND_SZ, ";".join(parts))
|
|
154
|
+
changed = True
|
|
155
|
+
finally:
|
|
156
|
+
reg.CloseKey(key)
|
|
157
|
+
if changed:
|
|
158
|
+
_broadcast_setting_change()
|
|
159
|
+
|
|
160
|
+
live = os.environ.get("PATH", "")
|
|
161
|
+
if not any(_same_dir(p, directory) for p in live.split(os.pathsep) if p):
|
|
162
|
+
os.environ["PATH"] = directory + os.pathsep + live if live else directory
|
|
163
|
+
return changed
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def shell_hint() -> str:
|
|
167
|
+
"""What to tell the user so a freshly installed command is found."""
|
|
168
|
+
if IS_WINDOWS:
|
|
169
|
+
return "Open a new terminal"
|
|
170
|
+
return "Open a new shell, or run: source ~/.bashrc"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# --- Start Menu ---------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def _appdata() -> str:
|
|
176
|
+
return os.environ.get("APPDATA") or os.path.join(
|
|
177
|
+
os.path.expanduser("~"), "AppData", "Roaming")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def start_menu_dir() -> str:
|
|
181
|
+
"""``%APPDATA%\\Microsoft\\Windows\\Start Menu\\Programs``."""
|
|
182
|
+
return os.path.join(_appdata(), "Microsoft", "Windows", "Start Menu", "Programs")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def startup_dir() -> str:
|
|
186
|
+
"""The Startup folder inside the Start Menu — entries here run at login."""
|
|
187
|
+
return os.path.join(start_menu_dir(), "Startup")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def write_shortcut(lnk_path: str, target: str, args: str = "",
|
|
191
|
+
icon: Optional[str] = None, terminal: bool = False,
|
|
192
|
+
workdir: Optional[str] = None) -> bool:
|
|
193
|
+
"""Write a .lnk shortcut through PowerShell's WScript.Shell.
|
|
194
|
+
|
|
195
|
+
This avoids a pywin32 dependency for the one thing it would be needed for.
|
|
196
|
+
Best effort: returns False instead of raising when PowerShell is missing or
|
|
197
|
+
the call fails.
|
|
198
|
+
"""
|
|
199
|
+
try:
|
|
200
|
+
os.makedirs(os.path.dirname(lnk_path) or ".", exist_ok=True)
|
|
201
|
+
except OSError:
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
def ps(value: str) -> str:
|
|
205
|
+
return "'" + str(value).replace("'", "''") + "'"
|
|
206
|
+
|
|
207
|
+
script = [
|
|
208
|
+
"$s = New-Object -ComObject WScript.Shell",
|
|
209
|
+
f"$l = $s.CreateShortcut({ps(lnk_path)})",
|
|
210
|
+
f"$l.TargetPath = {ps(target)}",
|
|
211
|
+
]
|
|
212
|
+
if args:
|
|
213
|
+
script.append(f"$l.Arguments = {ps(args)}")
|
|
214
|
+
if workdir:
|
|
215
|
+
script.append(f"$l.WorkingDirectory = {ps(workdir)}")
|
|
216
|
+
if icon:
|
|
217
|
+
script.append(f"$l.IconLocation = {ps(icon)}")
|
|
218
|
+
# 1 = normal window. `terminal` is already expressed in the target
|
|
219
|
+
# (cmd.exe /k ...), so it does not change the window style here.
|
|
220
|
+
script.append("$l.WindowStyle = 1")
|
|
221
|
+
script.append("$l.Save()")
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
result = subprocess.run(
|
|
225
|
+
["powershell", "-NoProfile", "-NonInteractive", "-Command", "; ".join(script)],
|
|
226
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30,
|
|
227
|
+
)
|
|
228
|
+
return result.returncode == 0
|
|
229
|
+
except Exception:
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def remove_shortcut(lnk_path: str) -> bool:
|
|
234
|
+
"""Delete a .lnk. True if one was there."""
|
|
235
|
+
try:
|
|
236
|
+
os.remove(lnk_path)
|
|
237
|
+
return True
|
|
238
|
+
except OSError:
|
|
239
|
+
return False
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Who an installer is, so two orgs' installers can share a host.
|
|
2
|
+
|
|
3
|
+
Before this module every per-host artifact the engine touched was named after
|
|
4
|
+
the first-party tree: ``~/.config/tools-installer/config.json``,
|
|
5
|
+
``~/.tools_aliases``, ``ai_tools_manager.desktop``, the WM class
|
|
6
|
+
``tools_installer``, and the ``Keywords=probable.work;ai;tool;`` line that
|
|
7
|
+
doubles as branding *and* as the marker the orphan sweeper uses to decide
|
|
8
|
+
"this shortcut is mine". A second organisation reusing the engine therefore
|
|
9
|
+
overwrote the first one's desktop entry, shared its icon overrides and alias
|
|
10
|
+
file, and reaped its shortcuts.
|
|
11
|
+
|
|
12
|
+
:class:`InstallerIdentity` collects those names behind a single ``slug``. Give
|
|
13
|
+
it one word and every path, filename and marker derives from it; override any
|
|
14
|
+
individual field when a name has to be something else.
|
|
15
|
+
|
|
16
|
+
from cli_tools_kit import InstallerIdentity
|
|
17
|
+
|
|
18
|
+
ACME = InstallerIdentity(slug="acme-tools", title="Acme Tools")
|
|
19
|
+
|
|
20
|
+
# ~/.config/acme-tools/, ~/.acme_tools_aliases, acme-tools.desktop,
|
|
21
|
+
# WM class acme_tools, Keywords=acme-tools;ai;tool;
|
|
22
|
+
|
|
23
|
+
``LEGACY_IDENTITY`` reproduces the historical first-party names exactly and is
|
|
24
|
+
what the engine uses when a wrapper passes no identity, so existing installs
|
|
25
|
+
keep their files.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Optional
|
|
32
|
+
|
|
33
|
+
# A slug lands in filesystem paths, a .desktop filename and the desktop
|
|
34
|
+
# Keywords line, so it stays to characters that are safe in all three.
|
|
35
|
+
# \Z, not $: "$" also matches just before a trailing newline, which would
|
|
36
|
+
# let "acme\n" through and inject a second key into the .desktop file.
|
|
37
|
+
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*\Z")
|
|
38
|
+
|
|
39
|
+
DEFAULT_ICON = "system-software-install"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _home(*parts: str) -> str:
|
|
43
|
+
"""A path under the home directory, kept unexpanded.
|
|
44
|
+
|
|
45
|
+
Expansion happens in the properties below, at access time. Resolving "~"
|
|
46
|
+
when the identity is *constructed* would freeze whatever HOME held at
|
|
47
|
+
import — which silently sends a sandboxed test (or a process that changes
|
|
48
|
+
HOME) back to the real home directory.
|
|
49
|
+
"""
|
|
50
|
+
return os.path.join("~", *parts)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class InstallerIdentity:
|
|
55
|
+
"""The names one organisation's installer claims on a host.
|
|
56
|
+
|
|
57
|
+
Only ``slug`` is required. Every other field defaults to something derived
|
|
58
|
+
from it; set one explicitly when you need a specific name (as
|
|
59
|
+
``LEGACY_IDENTITY`` does to keep the first-party filenames).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
slug: str
|
|
63
|
+
title: Optional[str] = None
|
|
64
|
+
icon: str = DEFAULT_ICON
|
|
65
|
+
|
|
66
|
+
# Desktop Keywords token. Both the branding on every shortcut this
|
|
67
|
+
# installer writes and the marker its orphan sweeper matches on, so an org
|
|
68
|
+
# only ever sweeps its own entries. Defaults to the slug.
|
|
69
|
+
marker: Optional[str] = None
|
|
70
|
+
|
|
71
|
+
# The manager's own shortcut and window.
|
|
72
|
+
desktop_file: Optional[str] = None
|
|
73
|
+
desktop_name: Optional[str] = None
|
|
74
|
+
wm_class: Optional[str] = None
|
|
75
|
+
notify_app: Optional[str] = None
|
|
76
|
+
|
|
77
|
+
# Per-host state. Unnamespaced before 0.2.2; two orgs shared all three.
|
|
78
|
+
aliases_file: Optional[str] = None
|
|
79
|
+
config_dir: Optional[str] = None
|
|
80
|
+
cache_dir: Optional[str] = None
|
|
81
|
+
|
|
82
|
+
# Login update-check artifacts.
|
|
83
|
+
check_desktop_name: Optional[str] = None
|
|
84
|
+
check_log_name: Optional[str] = None
|
|
85
|
+
check_state_name: Optional[str] = None
|
|
86
|
+
|
|
87
|
+
def __post_init__(self) -> None:
|
|
88
|
+
if not _SLUG_RE.match(self.slug or ""):
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"slug must be lowercase letters, digits, '.', '-' or '_' and "
|
|
91
|
+
f"start with a letter or digit, got {self.slug!r}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# --- derived names ----------------------------------------------------
|
|
95
|
+
#
|
|
96
|
+
# Properties rather than __post_init__ assignment: the dataclass is frozen
|
|
97
|
+
# (an identity is a value, not mutable config) and this keeps the stored
|
|
98
|
+
# fields to exactly what the caller chose.
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def under(self) -> str:
|
|
102
|
+
"""The slug as a shell/WM-safe identifier: ``acme-tools`` → ``acme_tools``."""
|
|
103
|
+
return re.sub(r"[.\-]", "_", self.slug)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def display_title(self) -> str:
|
|
107
|
+
"""Window title. Falls back to the slug when no title was given."""
|
|
108
|
+
return self.title or self.slug
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def desktop_keywords(self) -> str:
|
|
112
|
+
"""The full ``Keywords=`` value written into every shortcut."""
|
|
113
|
+
return f"{self.marker or self.slug};ai;tool;"
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def marker_token(self) -> str:
|
|
117
|
+
"""The token :func:`find_orphan_desktop_files` matches to claim a file."""
|
|
118
|
+
return self.marker or self.slug
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def self_desktop_file(self) -> str:
|
|
122
|
+
return self.desktop_file or f"{self.slug}-installer.desktop"
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def self_desktop_name(self) -> str:
|
|
126
|
+
return self.desktop_name or self.display_title
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def self_wm_class(self) -> str:
|
|
130
|
+
return self.wm_class or f"{self.under}_installer"
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def notify_label(self) -> str:
|
|
134
|
+
return self.notify_app or self.display_title
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def aliases_path(self) -> str:
|
|
138
|
+
return os.path.expanduser(self.aliases_file or _home(f".{self.under}_aliases"))
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def config_path(self) -> str:
|
|
142
|
+
return os.path.expanduser(self.config_dir or _home(".config", self.slug))
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def config_file(self) -> str:
|
|
146
|
+
return os.path.join(self.config_path, "config.json")
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def icons_dir(self) -> str:
|
|
150
|
+
return os.path.join(self.config_path, "icons")
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def cache_path(self) -> str:
|
|
154
|
+
return os.path.expanduser(self.cache_dir or _home(".cache", self.slug))
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def shim_path(self) -> str:
|
|
158
|
+
"""Where this installer's Windows launcher scripts go.
|
|
159
|
+
|
|
160
|
+
``%LOCALAPPDATA%\\<slug>\\bin`` — the directory added to the user PATH
|
|
161
|
+
on Windows in place of the alias file. Derived from the slug like every
|
|
162
|
+
other per-host name, so two orgs on one host keep separate shims.
|
|
163
|
+
"""
|
|
164
|
+
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser(
|
|
165
|
+
_home("AppData", "Local")
|
|
166
|
+
)
|
|
167
|
+
return os.path.join(base, self.slug, "bin")
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def check_desktop(self) -> str:
|
|
171
|
+
return self.check_desktop_name or f"{self.slug}-check.desktop"
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def check_log(self) -> str:
|
|
175
|
+
return self.check_log_name or f"{self.slug}-check.log"
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def check_state(self) -> str:
|
|
179
|
+
return self.check_state_name or f"{self.slug}-check.json"
|
|
180
|
+
|
|
181
|
+
# --- interop ----------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
ENV_VAR = "CLI_TOOL_KIT_SLUG"
|
|
184
|
+
|
|
185
|
+
def env(self) -> dict:
|
|
186
|
+
"""Environment additions that let a child process rebuild this identity.
|
|
187
|
+
|
|
188
|
+
The engine installs a tool by running that tool's own ``--install`` in a
|
|
189
|
+
subprocess, where the tool's :class:`~cli_tools_kit.ToolInstaller` writes
|
|
190
|
+
the .desktop file and the alias. Passing the slug down means those
|
|
191
|
+
artifacts carry the parent installer's marker and land in its alias
|
|
192
|
+
file, instead of the first-party defaults.
|
|
193
|
+
"""
|
|
194
|
+
return {self.ENV_VAR: self.slug}
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def from_env(cls, default: Optional["InstallerIdentity"] = None) -> "InstallerIdentity":
|
|
198
|
+
"""Rebuild the calling installer's identity from the environment.
|
|
199
|
+
|
|
200
|
+
Returns ``default`` (or :data:`LEGACY_IDENTITY`) when the variable is
|
|
201
|
+
unset or malformed, so a tool run by hand still installs normally.
|
|
202
|
+
"""
|
|
203
|
+
slug = os.environ.get(cls.ENV_VAR, "").strip()
|
|
204
|
+
fallback = default if default is not None else LEGACY_IDENTITY
|
|
205
|
+
if not slug:
|
|
206
|
+
return fallback
|
|
207
|
+
try:
|
|
208
|
+
return cls(slug=slug)
|
|
209
|
+
except ValueError:
|
|
210
|
+
return fallback
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# The names the engine used before identities existed. Passing no identity
|
|
214
|
+
# selects this, so a host that already has first-party shortcuts, aliases and
|
|
215
|
+
# icon overrides keeps them.
|
|
216
|
+
LEGACY_IDENTITY = InstallerIdentity(
|
|
217
|
+
slug="probable.work",
|
|
218
|
+
title="probable.work - Tools Installer",
|
|
219
|
+
desktop_file="ai_tools_manager.desktop",
|
|
220
|
+
desktop_name="Tools Installer",
|
|
221
|
+
wm_class="tools_installer",
|
|
222
|
+
notify_app="Tools Installer",
|
|
223
|
+
aliases_file=_home(".tools_aliases"),
|
|
224
|
+
config_dir=_home(".config", "tools-installer"),
|
|
225
|
+
cache_dir=_home(".cache", "tools-installer"),
|
|
226
|
+
check_desktop_name="tools-installer-check.desktop",
|
|
227
|
+
check_log_name="tools-installer-check.log",
|
|
228
|
+
check_state_name="tools-installer-check.json",
|
|
229
|
+
)
|