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,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"))