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,572 @@
|
|
|
1
|
+
"""Shared installer utilities for all tools.
|
|
2
|
+
|
|
3
|
+
Provides a reusable ToolInstaller class that handles:
|
|
4
|
+
- Desktop shortcut creation/removal
|
|
5
|
+
- Dependency installation from requirements.txt
|
|
6
|
+
- Consistent styling for terminal output
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import shlex
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Optional, Union, List
|
|
16
|
+
|
|
17
|
+
from . import host
|
|
18
|
+
from .identity import InstallerIdentity
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_DESKTOP_FORBIDDEN = ("\n", "\r", "\x00")
|
|
22
|
+
|
|
23
|
+
_EXEC_BITS = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _check_desktop_field(value: str, field: str) -> None:
|
|
27
|
+
"""Reject embedded newlines/NUL — they let a crafted value inject extra
|
|
28
|
+
.desktop keys (e.g. an attacker-controlled Exec= line) since the writer
|
|
29
|
+
serialises fields with f-strings rather than the freedesktop quoting spec.
|
|
30
|
+
"""
|
|
31
|
+
if any(c in value for c in _DESKTOP_FORBIDDEN):
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"ToolMetadata.{field} must not contain newline or NUL characters"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
from termcolor import colored
|
|
38
|
+
except ImportError:
|
|
39
|
+
def colored(text: str, *args, **kwargs) -> str:
|
|
40
|
+
"""Fallback when termcolor is not available."""
|
|
41
|
+
return text
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ToolMetadata:
|
|
46
|
+
"""Metadata for a tool's desktop entry and advertise output."""
|
|
47
|
+
name: str
|
|
48
|
+
desktop_file: str
|
|
49
|
+
icon: str
|
|
50
|
+
desc: str
|
|
51
|
+
terminal: bool = False
|
|
52
|
+
args: Optional[list] = None
|
|
53
|
+
categories: str = "Utility;"
|
|
54
|
+
tags: Optional[list] = None # Capability tags: GUI, CLI, Icon (default: ["GUI", "Icon"])
|
|
55
|
+
alias: Optional[str] = None # Custom alias name for non-Icon tools (defaults to desktop_file stem)
|
|
56
|
+
alias_args: Optional[list] = None # Override args used in the bash alias only; falls back to `args` if unset. Lets a tool's alias auto-inject a flag (e.g. --clip) without leaking it into --install invocations.
|
|
57
|
+
# Taxonomy (PROTOCOL.md § Taxonomy). `capability` is the one controlled word a
|
|
58
|
+
# parent installer groups rows by; `domain` a free distinguisher inside it;
|
|
59
|
+
# `category` legacy provenance (the folder of origin). All optional.
|
|
60
|
+
capability: Optional[str] = None
|
|
61
|
+
domain: Optional[str] = None
|
|
62
|
+
category: Optional[str] = None
|
|
63
|
+
# Claude Code skill registration (PROTOCOL.md § Optional skill_name).
|
|
64
|
+
# `skill_status` is "absent" | "current" | "stale"; compute it with
|
|
65
|
+
# cli_tools_kit.skill_status() at advertise time and pass it in.
|
|
66
|
+
skill_name: Optional[str] = None
|
|
67
|
+
skill_status: Optional[str] = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ToolInstaller:
|
|
71
|
+
"""Handles installation and removal of tool desktop shortcuts.
|
|
72
|
+
|
|
73
|
+
Provides a consistent interface for:
|
|
74
|
+
- Installing pip dependencies from requirements.txt
|
|
75
|
+
- Creating .desktop files in ~/.local/share/applications/
|
|
76
|
+
- Removing desktop shortcuts
|
|
77
|
+
- Generating --advertise JSON output
|
|
78
|
+
|
|
79
|
+
Example usage:
|
|
80
|
+
installer = ToolInstaller(
|
|
81
|
+
script_path=__file__,
|
|
82
|
+
metadata=ToolMetadata(
|
|
83
|
+
name="My Tool",
|
|
84
|
+
desktop_file="my_tool.desktop",
|
|
85
|
+
icon="utilities-terminal",
|
|
86
|
+
desc="A helpful tool"
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# In argument handling:
|
|
91
|
+
if args.install:
|
|
92
|
+
installer.install()
|
|
93
|
+
elif args.remove:
|
|
94
|
+
installer.remove()
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
script_path: str,
|
|
100
|
+
metadata: Union[ToolMetadata, List[ToolMetadata]],
|
|
101
|
+
identity: Optional[InstallerIdentity] = None,
|
|
102
|
+
):
|
|
103
|
+
"""Initialize the installer.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
script_path: Path to the tool's main script (usually __file__).
|
|
107
|
+
metadata: One ToolMetadata, or a list for tools that expose
|
|
108
|
+
multiple variants (e.g. private and public launchers from
|
|
109
|
+
the same script with different --args).
|
|
110
|
+
identity: Which installer these artifacts belong to — it decides
|
|
111
|
+
the .desktop Keywords marker and which alias file the tool
|
|
112
|
+
writes into. Defaults to the identity of the parent installer
|
|
113
|
+
that spawned this --install (read from the environment), and
|
|
114
|
+
to the historical first-party names when run by hand.
|
|
115
|
+
"""
|
|
116
|
+
self.identity = identity if identity is not None else InstallerIdentity.from_env()
|
|
117
|
+
self.script_path = os.path.abspath(script_path)
|
|
118
|
+
self.script_dir = os.path.dirname(self.script_path)
|
|
119
|
+
self.script_name = os.path.splitext(os.path.basename(self.script_path))[0]
|
|
120
|
+
self.metadatas: List[ToolMetadata] = (
|
|
121
|
+
list(metadata) if isinstance(metadata, (list, tuple)) else [metadata]
|
|
122
|
+
)
|
|
123
|
+
# Back-compat alias: tools that read `.metadata` get the first entry.
|
|
124
|
+
self.metadata = self.metadatas[0]
|
|
125
|
+
self.apps_dir = os.path.join(
|
|
126
|
+
os.path.expanduser("~"), ".local", "share", "applications"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def _select(self, desktop_file: Optional[str]) -> List[ToolMetadata]:
|
|
130
|
+
"""Return metadata entries matching desktop_file, or all if None."""
|
|
131
|
+
if desktop_file is None:
|
|
132
|
+
return list(self.metadatas)
|
|
133
|
+
matches = [m for m in self.metadatas if m.desktop_file == desktop_file]
|
|
134
|
+
if not matches:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
f"No variant with desktop_file={desktop_file!r}. "
|
|
137
|
+
f"Available: {[m.desktop_file for m in self.metadatas]}"
|
|
138
|
+
)
|
|
139
|
+
return matches
|
|
140
|
+
|
|
141
|
+
def _ensure_apps_dir(self) -> None:
|
|
142
|
+
"""Create applications directory if it doesn't exist."""
|
|
143
|
+
if host.IS_WINDOWS:
|
|
144
|
+
return # no freedesktop applications directory here
|
|
145
|
+
if not os.path.exists(self.apps_dir):
|
|
146
|
+
os.makedirs(self.apps_dir)
|
|
147
|
+
|
|
148
|
+
def _get_requirements_path(self) -> str:
|
|
149
|
+
"""Get path to requirements file."""
|
|
150
|
+
return os.path.join(self.script_dir, "requirements.txt")
|
|
151
|
+
|
|
152
|
+
def install_dependencies(self) -> bool:
|
|
153
|
+
"""Install dependencies from requirements file.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
True if successful or no requirements file, False on error.
|
|
157
|
+
|
|
158
|
+
If the environment variable ``TOOLS_INSTALLER_SKIP_DEPS=1`` is set, pip
|
|
159
|
+
is not invoked at all. The tools installer sets this for a shortcuts-only
|
|
160
|
+
"Update all" so a refresh stays local and never reaches PyPI.
|
|
161
|
+
"""
|
|
162
|
+
if os.environ.get("TOOLS_INSTALLER_SKIP_DEPS") == "1":
|
|
163
|
+
print(colored("Skipping dependency install (shortcuts-only update).", "cyan"))
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
req_file = self._get_requirements_path()
|
|
167
|
+
if not os.path.exists(req_file):
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
print(colored("Installing dependencies...", "cyan"))
|
|
171
|
+
try:
|
|
172
|
+
subprocess.run(
|
|
173
|
+
[sys.executable, "-m", "pip", "install", "-r", req_file],
|
|
174
|
+
check=True
|
|
175
|
+
)
|
|
176
|
+
print(colored("Dependencies installed successfully.", "green"))
|
|
177
|
+
return True
|
|
178
|
+
except subprocess.CalledProcessError as e:
|
|
179
|
+
print(colored(f"Warning: Failed to install dependencies: {e}", "yellow"))
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
def _get_tags(self, metadata: Optional[ToolMetadata] = None) -> list:
|
|
183
|
+
"""Get tags with default fallback."""
|
|
184
|
+
m = metadata if metadata is not None else self.metadata
|
|
185
|
+
return m.tags if m.tags else ["GUI", "Icon"]
|
|
186
|
+
|
|
187
|
+
def _has_icon(self, metadata: Optional[ToolMetadata] = None) -> bool:
|
|
188
|
+
"""Check if this tool should have a desktop icon."""
|
|
189
|
+
return "Icon" in self._get_tags(metadata)
|
|
190
|
+
|
|
191
|
+
def _wants_alias(self, metadata: Optional[ToolMetadata] = None) -> bool:
|
|
192
|
+
"""Tool wants a bash alias either because it's CLI-only (no Icon),
|
|
193
|
+
or because an explicit alias name is set alongside the Icon."""
|
|
194
|
+
m = metadata if metadata is not None else self.metadata
|
|
195
|
+
return (not self._has_icon(m)) or bool(m.alias)
|
|
196
|
+
|
|
197
|
+
def install(self, desktop_file: Optional[str] = None) -> None:
|
|
198
|
+
"""Install one or all variants of the tool.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
desktop_file: Restrict to the variant whose ``desktop_file`` matches.
|
|
202
|
+
If None, installs every variant registered on this installer.
|
|
203
|
+
"""
|
|
204
|
+
self.install_dependencies()
|
|
205
|
+
for m in self._select(desktop_file):
|
|
206
|
+
self._install_one(m)
|
|
207
|
+
|
|
208
|
+
def remove(self, desktop_file: Optional[str] = None) -> None:
|
|
209
|
+
"""Remove one or all variants of the tool."""
|
|
210
|
+
for m in self._select(desktop_file):
|
|
211
|
+
self._remove_one(m)
|
|
212
|
+
|
|
213
|
+
def _install_one(self, metadata: ToolMetadata) -> None:
|
|
214
|
+
"""Install a single variant."""
|
|
215
|
+
if not self._has_icon(metadata):
|
|
216
|
+
self._install_cli_alias(metadata)
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
self._ensure_apps_dir()
|
|
220
|
+
|
|
221
|
+
for field_name in ("name", "desc", "categories", "icon", "desktop_file"):
|
|
222
|
+
_check_desktop_field(getattr(metadata, field_name), field_name)
|
|
223
|
+
for i, arg in enumerate(metadata.args or []):
|
|
224
|
+
_check_desktop_field(arg, f"args[{i}]")
|
|
225
|
+
|
|
226
|
+
if host.IS_WINDOWS:
|
|
227
|
+
self._install_windows_shortcut(metadata)
|
|
228
|
+
if metadata.alias:
|
|
229
|
+
self._install_cli_alias(metadata)
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
desktop_path = os.path.join(self.apps_dir, metadata.desktop_file)
|
|
233
|
+
|
|
234
|
+
exec_line = f"{shlex.quote(sys.executable)} {shlex.quote(self.script_path)}"
|
|
235
|
+
if metadata.args:
|
|
236
|
+
exec_line += " " + " ".join(shlex.quote(a) for a in metadata.args)
|
|
237
|
+
|
|
238
|
+
if metadata.terminal:
|
|
239
|
+
exec_line = f"konsole -e {exec_line}"
|
|
240
|
+
|
|
241
|
+
wm_class = os.path.splitext(metadata.desktop_file)[0]
|
|
242
|
+
|
|
243
|
+
content = f"""[Desktop Entry]
|
|
244
|
+
Type=Application
|
|
245
|
+
Name={metadata.name}
|
|
246
|
+
Comment={metadata.desc}
|
|
247
|
+
Exec={exec_line}
|
|
248
|
+
Path={self.script_dir}
|
|
249
|
+
Icon={metadata.icon}
|
|
250
|
+
Terminal=false
|
|
251
|
+
Categories={metadata.categories}
|
|
252
|
+
Keywords={self.identity.desktop_keywords}
|
|
253
|
+
StartupNotify=true
|
|
254
|
+
StartupWMClass={wm_class}
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
with open(desktop_path, "w") as f:
|
|
258
|
+
f.write(content)
|
|
259
|
+
|
|
260
|
+
# Deliberately NOT executable. A .desktop in the applications dir needs
|
|
261
|
+
# no exec bit, and an executable one makes systemd-xdg-autostart-generator
|
|
262
|
+
# warn on every login once the entry is symlinked into ~/.config/autostart.
|
|
263
|
+
os.chmod(desktop_path, os.stat(desktop_path).st_mode & ~_EXEC_BITS)
|
|
264
|
+
|
|
265
|
+
print(colored(f"Installed: {desktop_path}", "green"))
|
|
266
|
+
self._refresh_desktop_database()
|
|
267
|
+
|
|
268
|
+
if metadata.alias:
|
|
269
|
+
self._install_cli_alias(metadata)
|
|
270
|
+
|
|
271
|
+
def _remove_one(self, metadata: ToolMetadata) -> None:
|
|
272
|
+
"""Remove a single variant."""
|
|
273
|
+
if not self._has_icon(metadata):
|
|
274
|
+
self._remove_cli_alias(metadata)
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
if host.IS_WINDOWS:
|
|
278
|
+
self._remove_windows_shortcut(metadata)
|
|
279
|
+
if metadata.alias:
|
|
280
|
+
self._remove_cli_alias(metadata)
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
desktop_path = os.path.join(self.apps_dir, metadata.desktop_file)
|
|
284
|
+
|
|
285
|
+
if os.path.exists(desktop_path):
|
|
286
|
+
os.remove(desktop_path)
|
|
287
|
+
print(colored(f"Removed: {desktop_path}", "green"))
|
|
288
|
+
self._refresh_desktop_database()
|
|
289
|
+
else:
|
|
290
|
+
print(colored(f"Shortcut not found: {desktop_path}", "yellow"))
|
|
291
|
+
|
|
292
|
+
if metadata.alias:
|
|
293
|
+
self._remove_cli_alias(metadata)
|
|
294
|
+
|
|
295
|
+
def _refresh_desktop_database(self) -> None:
|
|
296
|
+
"""Refresh desktop database caches."""
|
|
297
|
+
if host.IS_WINDOWS:
|
|
298
|
+
return
|
|
299
|
+
for cmd in ["update-desktop-database", "kbuildsycoca5"]:
|
|
300
|
+
try:
|
|
301
|
+
args = [cmd]
|
|
302
|
+
if cmd == "update-desktop-database":
|
|
303
|
+
args.append(self.apps_dir)
|
|
304
|
+
subprocess.run(
|
|
305
|
+
args,
|
|
306
|
+
stdout=subprocess.DEVNULL,
|
|
307
|
+
stderr=subprocess.DEVNULL
|
|
308
|
+
)
|
|
309
|
+
except FileNotFoundError:
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
def get_advertise_data(self) -> list:
|
|
313
|
+
"""Get JSON-serializable data for --advertise output.
|
|
314
|
+
|
|
315
|
+
Returns one dict per registered variant.
|
|
316
|
+
"""
|
|
317
|
+
out = []
|
|
318
|
+
for m in self.metadatas:
|
|
319
|
+
tags = self._get_tags(m)
|
|
320
|
+
data = {
|
|
321
|
+
"name": m.name,
|
|
322
|
+
"desktop_file": m.desktop_file,
|
|
323
|
+
"icon": m.icon,
|
|
324
|
+
"desc": m.desc,
|
|
325
|
+
"terminal": m.terminal,
|
|
326
|
+
"args": m.args or [],
|
|
327
|
+
"tags": tags,
|
|
328
|
+
}
|
|
329
|
+
if self._wants_alias(m):
|
|
330
|
+
data["alias"] = self._get_alias_name(m)
|
|
331
|
+
if m.alias_args is not None:
|
|
332
|
+
data["alias_args"] = list(m.alias_args)
|
|
333
|
+
out.append(data)
|
|
334
|
+
return out
|
|
335
|
+
|
|
336
|
+
def _get_alias_name(self, metadata: Optional[ToolMetadata] = None) -> str:
|
|
337
|
+
"""Get the alias name for a variant."""
|
|
338
|
+
m = metadata if metadata is not None else self.metadata
|
|
339
|
+
if m.alias:
|
|
340
|
+
return m.alias
|
|
341
|
+
# Default: derive from desktop_file (e.g., "git_commit.desktop" -> "git_commit")
|
|
342
|
+
return os.path.splitext(m.desktop_file)[0]
|
|
343
|
+
|
|
344
|
+
def _get_aliases_file(self) -> str:
|
|
345
|
+
"""Path to this installer's alias file (namespaced by identity)."""
|
|
346
|
+
return self.identity.aliases_path
|
|
347
|
+
|
|
348
|
+
def _load_aliases(self) -> dict:
|
|
349
|
+
"""Load existing aliases from file. Returns dict of alias_name -> command."""
|
|
350
|
+
aliases_file = self._get_aliases_file()
|
|
351
|
+
aliases = {}
|
|
352
|
+
if os.path.exists(aliases_file):
|
|
353
|
+
with open(aliases_file, "r") as f:
|
|
354
|
+
for line in f:
|
|
355
|
+
line = line.strip()
|
|
356
|
+
if line.startswith("alias ") and "=" in line:
|
|
357
|
+
# Parse: alias name='command'
|
|
358
|
+
parts = line[6:].split("=", 1)
|
|
359
|
+
if len(parts) == 2:
|
|
360
|
+
name = parts[0].strip()
|
|
361
|
+
cmd = parts[1].strip()
|
|
362
|
+
# Strip a trailing unquoted comment (e.g. a tool's
|
|
363
|
+
# own ` # marker` tag) before unwrapping. Without
|
|
364
|
+
# this, a line like `alias n='cmd' # tag` fails the
|
|
365
|
+
# closing-quote test below, so the inner quotes are
|
|
366
|
+
# kept and then double-wrapped on the next save —
|
|
367
|
+
# corrupting the alias. Only strip the comment when
|
|
368
|
+
# it sits outside a balanced quote (the quote count
|
|
369
|
+
# before the `#` is even).
|
|
370
|
+
hash_idx = cmd.find("#")
|
|
371
|
+
if hash_idx > 0:
|
|
372
|
+
before = cmd[:hash_idx]
|
|
373
|
+
if before.count("'") % 2 == 0 and \
|
|
374
|
+
before.count('"') % 2 == 0:
|
|
375
|
+
cmd = before.strip()
|
|
376
|
+
# Only strip outer wrapping quotes (not inner quotes in the command)
|
|
377
|
+
if (cmd.startswith("'") and cmd.endswith("'")) or \
|
|
378
|
+
(cmd.startswith('"') and cmd.endswith('"')):
|
|
379
|
+
cmd = cmd[1:-1]
|
|
380
|
+
aliases[name] = cmd
|
|
381
|
+
return aliases
|
|
382
|
+
|
|
383
|
+
def _save_aliases(self, aliases: dict) -> None:
|
|
384
|
+
"""Save aliases to file.
|
|
385
|
+
|
|
386
|
+
Uses shlex.quote on the command so a `'` (or other shell metachar) in
|
|
387
|
+
the script path or in metadata.alias_args can't break out of the alias
|
|
388
|
+
and execute arbitrary shell on the next `source ~/.tools_aliases`.
|
|
389
|
+
"""
|
|
390
|
+
aliases_file = self._get_aliases_file()
|
|
391
|
+
with open(aliases_file, "w") as f:
|
|
392
|
+
f.write("# Auto-generated by tools installer - do not edit manually\n")
|
|
393
|
+
f.write("# Source this file in your .bashrc/.zshrc:\n")
|
|
394
|
+
f.write(f"# [ -f {aliases_file} ] && source {aliases_file}\n\n")
|
|
395
|
+
for name, cmd in sorted(aliases.items()):
|
|
396
|
+
f.write(f"alias {name}={shlex.quote(cmd)}\n")
|
|
397
|
+
|
|
398
|
+
def _ensure_bashrc_sources_aliases(self) -> bool:
|
|
399
|
+
"""Ensure .bashrc sources the tools aliases file.
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
True if bashrc was modified, False if already configured.
|
|
403
|
+
"""
|
|
404
|
+
aliases_file = self._get_aliases_file()
|
|
405
|
+
marker = os.path.basename(aliases_file)
|
|
406
|
+
bashrc_path = os.path.join(os.path.expanduser("~"), ".bashrc")
|
|
407
|
+
source_line = f'[ -f "{aliases_file}" ] && source "{aliases_file}"'
|
|
408
|
+
|
|
409
|
+
# Check if already sourced. Matched on THIS identity's alias filename,
|
|
410
|
+
# so a second org's installer adds its own line instead of seeing the
|
|
411
|
+
# first one's and concluding it is done.
|
|
412
|
+
if os.path.exists(bashrc_path):
|
|
413
|
+
with open(bashrc_path, "r") as f:
|
|
414
|
+
content = f.read()
|
|
415
|
+
# Check for various forms of the source line
|
|
416
|
+
if marker in content:
|
|
417
|
+
return False
|
|
418
|
+
|
|
419
|
+
# Add source line to bashrc
|
|
420
|
+
with open(bashrc_path, "a") as f:
|
|
421
|
+
f.write(f"\n{self._bashrc_comment()}\n")
|
|
422
|
+
f.write(f"{source_line}\n")
|
|
423
|
+
|
|
424
|
+
return True
|
|
425
|
+
|
|
426
|
+
def _bashrc_comment(self) -> str:
|
|
427
|
+
"""The comment line that labels this installer's block in ~/.bashrc."""
|
|
428
|
+
return f"# {self.identity.notify_label} aliases (auto-added by installer)"
|
|
429
|
+
|
|
430
|
+
def _remove_bashrc_source_if_empty(self) -> None:
|
|
431
|
+
"""Remove the source line from .bashrc if no aliases remain."""
|
|
432
|
+
aliases_file = self._get_aliases_file()
|
|
433
|
+
|
|
434
|
+
# Check if aliases file is empty or only has comments
|
|
435
|
+
if os.path.exists(aliases_file):
|
|
436
|
+
aliases = self._load_aliases()
|
|
437
|
+
if aliases:
|
|
438
|
+
return # Still have aliases, keep the source line
|
|
439
|
+
|
|
440
|
+
# Remove from bashrc
|
|
441
|
+
bashrc_path = os.path.join(os.path.expanduser("~"), ".bashrc")
|
|
442
|
+
if not os.path.exists(bashrc_path):
|
|
443
|
+
return
|
|
444
|
+
|
|
445
|
+
with open(bashrc_path, "r") as f:
|
|
446
|
+
lines = f.readlines()
|
|
447
|
+
|
|
448
|
+
# Filter out this installer's alias block. Both the current comment
|
|
449
|
+
# and the pre-0.2.2 first-party one are recognised, so an old ~/.bashrc
|
|
450
|
+
# still gets cleaned up.
|
|
451
|
+
marker = os.path.basename(aliases_file)
|
|
452
|
+
comments = {
|
|
453
|
+
self._bashrc_comment(),
|
|
454
|
+
"# Tools aliases (auto-added by tools installer)",
|
|
455
|
+
}
|
|
456
|
+
new_lines = []
|
|
457
|
+
skip_next = False
|
|
458
|
+
for line in lines:
|
|
459
|
+
if any(c in line for c in comments):
|
|
460
|
+
skip_next = True
|
|
461
|
+
continue
|
|
462
|
+
if skip_next and marker in line:
|
|
463
|
+
skip_next = False
|
|
464
|
+
continue
|
|
465
|
+
skip_next = False
|
|
466
|
+
new_lines.append(line)
|
|
467
|
+
|
|
468
|
+
# Remove trailing empty lines
|
|
469
|
+
while new_lines and new_lines[-1].strip() == "":
|
|
470
|
+
new_lines.pop()
|
|
471
|
+
|
|
472
|
+
with open(bashrc_path, "w") as f:
|
|
473
|
+
f.writelines(new_lines)
|
|
474
|
+
if new_lines and not new_lines[-1].endswith("\n"):
|
|
475
|
+
f.write("\n")
|
|
476
|
+
|
|
477
|
+
def _install_cli_alias(self, metadata: Optional[ToolMetadata] = None) -> None:
|
|
478
|
+
"""Install a bash alias for CLI-only tools (or for an Icon tool with alias=...)."""
|
|
479
|
+
m = metadata if metadata is not None else self.metadata
|
|
480
|
+
alias_name = self._get_alias_name(m)
|
|
481
|
+
|
|
482
|
+
# alias_args takes precedence over args for the alias command, letting
|
|
483
|
+
# a tool's alias auto-inject flags (e.g. --clip) that should NOT be
|
|
484
|
+
# passed through during --install / .desktop launches.
|
|
485
|
+
alias_cmd_args = m.alias_args if m.alias_args is not None else m.args
|
|
486
|
+
|
|
487
|
+
if host.IS_WINDOWS:
|
|
488
|
+
self._install_windows_shims(alias_name, alias_cmd_args or [])
|
|
489
|
+
return
|
|
490
|
+
|
|
491
|
+
cmd = f'{sys.executable} "{self.script_path}"'
|
|
492
|
+
if alias_cmd_args:
|
|
493
|
+
cmd += " " + " ".join(alias_cmd_args)
|
|
494
|
+
|
|
495
|
+
aliases = self._load_aliases()
|
|
496
|
+
aliases[alias_name] = cmd
|
|
497
|
+
self._save_aliases(aliases)
|
|
498
|
+
|
|
499
|
+
bashrc_modified = self._ensure_bashrc_sources_aliases()
|
|
500
|
+
|
|
501
|
+
aliases_file = self._get_aliases_file()
|
|
502
|
+
print(colored(f"Alias '{alias_name}' added to {aliases_file}", "green"))
|
|
503
|
+
if bashrc_modified:
|
|
504
|
+
print(colored("Added source line to ~/.bashrc", "green"))
|
|
505
|
+
print(colored(host.shell_hint(), "cyan"))
|
|
506
|
+
|
|
507
|
+
def _remove_cli_alias(self, metadata: Optional[ToolMetadata] = None) -> None:
|
|
508
|
+
"""Remove a bash alias for a variant."""
|
|
509
|
+
m = metadata if metadata is not None else self.metadata
|
|
510
|
+
alias_name = self._get_alias_name(m)
|
|
511
|
+
|
|
512
|
+
if host.IS_WINDOWS:
|
|
513
|
+
shim_dir = host.shim_dir(self.identity)
|
|
514
|
+
removed = host.remove_shims(shim_dir, alias_name)
|
|
515
|
+
if removed:
|
|
516
|
+
print(colored(f"Command '{alias_name}' removed from {shim_dir}", "green"))
|
|
517
|
+
else:
|
|
518
|
+
print(colored(f"Command '{alias_name}' not found.", "yellow"))
|
|
519
|
+
return
|
|
520
|
+
|
|
521
|
+
aliases = self._load_aliases()
|
|
522
|
+
|
|
523
|
+
if alias_name in aliases:
|
|
524
|
+
del aliases[alias_name]
|
|
525
|
+
self._save_aliases(aliases)
|
|
526
|
+
print(colored(f"Alias '{alias_name}' removed.", "green"))
|
|
527
|
+
|
|
528
|
+
self._remove_bashrc_source_if_empty()
|
|
529
|
+
else:
|
|
530
|
+
print(colored(f"Alias '{alias_name}' not found.", "yellow"))
|
|
531
|
+
|
|
532
|
+
# --- Windows ---------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
def _install_windows_shims(self, alias_name: str, args: list) -> None:
|
|
535
|
+
"""Write the two launcher scripts and put their directory on PATH."""
|
|
536
|
+
shim_dir = host.shim_dir(self.identity)
|
|
537
|
+
host.write_shims(shim_dir, alias_name, sys.executable, self.script_path, args)
|
|
538
|
+
changed = host.ensure_user_path(shim_dir)
|
|
539
|
+
print(colored(f"Command '{alias_name}' installed in {shim_dir}", "green"))
|
|
540
|
+
if changed:
|
|
541
|
+
print(colored(f"Added {shim_dir} to your PATH", "green"))
|
|
542
|
+
print(colored(host.shell_hint(), "cyan"))
|
|
543
|
+
|
|
544
|
+
def _windows_shortcut_path(self, metadata: ToolMetadata) -> str:
|
|
545
|
+
stem = os.path.splitext(metadata.desktop_file)[0]
|
|
546
|
+
return os.path.join(host.start_menu_dir(), stem + ".lnk")
|
|
547
|
+
|
|
548
|
+
def _install_windows_shortcut(self, metadata: ToolMetadata) -> None:
|
|
549
|
+
"""A Start Menu .lnk in place of the .desktop file."""
|
|
550
|
+
lnk_path = self._windows_shortcut_path(metadata)
|
|
551
|
+
args = list(metadata.args or [])
|
|
552
|
+
if metadata.terminal:
|
|
553
|
+
target = os.environ.get("COMSPEC", "cmd.exe")
|
|
554
|
+
arg_line = " ".join(
|
|
555
|
+
f'"{a}"' for a in ["/k", sys.executable, self.script_path] + args
|
|
556
|
+
)
|
|
557
|
+
else:
|
|
558
|
+
target = sys.executable
|
|
559
|
+
arg_line = " ".join(f'"{a}"' for a in [self.script_path] + args)
|
|
560
|
+
ok = host.write_shortcut(lnk_path, target, arg_line,
|
|
561
|
+
terminal=metadata.terminal, workdir=self.script_dir)
|
|
562
|
+
if ok:
|
|
563
|
+
print(colored(f"Installed: {lnk_path}", "green"))
|
|
564
|
+
else:
|
|
565
|
+
print(colored(f"Warning: could not write {lnk_path}", "yellow"))
|
|
566
|
+
|
|
567
|
+
def _remove_windows_shortcut(self, metadata: ToolMetadata) -> None:
|
|
568
|
+
lnk_path = self._windows_shortcut_path(metadata)
|
|
569
|
+
if host.remove_shortcut(lnk_path):
|
|
570
|
+
print(colored(f"Removed: {lnk_path}", "green"))
|
|
571
|
+
else:
|
|
572
|
+
print(colored(f"Shortcut not found: {lnk_path}", "yellow"))
|