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/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
+ )