tool-tray 0.3.8__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.
- tool_tray/__init__.py +368 -0
- tool_tray/__main__.py +4 -0
- tool_tray/autostart.py +248 -0
- tool_tray/config.py +109 -0
- tool_tray/desktop.py +103 -0
- tool_tray/logging.py +76 -0
- tool_tray/manifest.py +61 -0
- tool_tray/setup_dialog.py +83 -0
- tool_tray/state.py +107 -0
- tool_tray/tray.py +414 -0
- tool_tray/updater.py +139 -0
- tool_tray-0.3.8.dist-info/METADATA +185 -0
- tool_tray-0.3.8.dist-info/RECORD +15 -0
- tool_tray-0.3.8.dist-info/WHEEL +4 -0
- tool_tray-0.3.8.dist-info/entry_points.txt +3 -0
tool_tray/tray.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import threading
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pystray
|
|
8
|
+
from PIL import Image, ImageDraw
|
|
9
|
+
|
|
10
|
+
from tool_tray.config import config_exists, load_config
|
|
11
|
+
from tool_tray.manifest import Manifest, fetch_manifest
|
|
12
|
+
from tool_tray.updater import get_installed_version, get_remote_version, install_tool
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class OrphanedIcon:
|
|
17
|
+
"""A desktop icon that should be cleaned up."""
|
|
18
|
+
|
|
19
|
+
tool_name: str
|
|
20
|
+
path: str
|
|
21
|
+
reason: str # "tool_removed", "desktop_icon_disabled", "file_missing"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ToolStatus:
|
|
26
|
+
repo: str
|
|
27
|
+
manifest: Manifest
|
|
28
|
+
installed: str | None
|
|
29
|
+
remote: str | None
|
|
30
|
+
executable: str | None = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def name(self) -> str:
|
|
34
|
+
return self.manifest.name
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def has_update(self) -> bool:
|
|
38
|
+
if not self.installed or not self.remote:
|
|
39
|
+
return False
|
|
40
|
+
return self.installed != self.remote
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def display_text(self) -> str:
|
|
44
|
+
if not self.installed:
|
|
45
|
+
return f"{self.name} (not installed)"
|
|
46
|
+
if self.has_update:
|
|
47
|
+
return f"{self.name} {self.installed} -> {self.remote}"
|
|
48
|
+
return f"{self.name} {self.installed}"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def can_launch(self) -> bool:
|
|
52
|
+
return self.executable is not None and self.manifest.launch is not None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_token: str = ""
|
|
56
|
+
_repos: list[str] = []
|
|
57
|
+
_tool_statuses: list[ToolStatus] = []
|
|
58
|
+
_icon: Any = None
|
|
59
|
+
_last_refresh: float = 0
|
|
60
|
+
_REFRESH_THROTTLE_SECONDS: int = 30
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def create_icon() -> Image.Image:
|
|
64
|
+
"""Create a simple tray icon."""
|
|
65
|
+
size = 64
|
|
66
|
+
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
67
|
+
draw = ImageDraw.Draw(img)
|
|
68
|
+
draw.rounded_rectangle([8, 8, 56, 56], radius=8, fill="#2563eb")
|
|
69
|
+
draw.text((22, 12), "T", fill="white", font_size=36)
|
|
70
|
+
return img
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_tool_executable(tool_name: str) -> str | None:
|
|
74
|
+
"""Get executable path from uv tool list --show-paths."""
|
|
75
|
+
try:
|
|
76
|
+
result = subprocess.run(
|
|
77
|
+
["uv", "tool", "list", "--show-paths"],
|
|
78
|
+
capture_output=True,
|
|
79
|
+
text=True,
|
|
80
|
+
check=True,
|
|
81
|
+
)
|
|
82
|
+
for line in result.stdout.splitlines():
|
|
83
|
+
if line.startswith(f"- {tool_name} "):
|
|
84
|
+
start = line.find("(")
|
|
85
|
+
end = line.find(")")
|
|
86
|
+
if start != -1 and end != -1:
|
|
87
|
+
return line[start + 1 : end]
|
|
88
|
+
return None
|
|
89
|
+
except subprocess.CalledProcessError:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def launch_tool(tool_name: str) -> None:
|
|
94
|
+
"""Launch a tool by name."""
|
|
95
|
+
from tool_tray.logging import log_error, log_info
|
|
96
|
+
|
|
97
|
+
for status in _tool_statuses:
|
|
98
|
+
if status.name == tool_name and status.executable:
|
|
99
|
+
log_info(f"Launching: {tool_name} -> {status.executable}")
|
|
100
|
+
try:
|
|
101
|
+
subprocess.Popen([status.executable])
|
|
102
|
+
except OSError as e:
|
|
103
|
+
log_error(f"Failed to launch {tool_name}", e)
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def reload_config() -> bool:
|
|
108
|
+
"""Reload config from disk. Returns True if config exists."""
|
|
109
|
+
global _token, _repos
|
|
110
|
+
|
|
111
|
+
config = load_config()
|
|
112
|
+
if not config:
|
|
113
|
+
_token = ""
|
|
114
|
+
_repos = []
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
_token = config.get("token", "")
|
|
118
|
+
_repos = config.get("repos", [])
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def refresh_statuses(force: bool = False) -> None:
|
|
123
|
+
"""Refresh version info for all repos with manifests."""
|
|
124
|
+
import time
|
|
125
|
+
|
|
126
|
+
from tool_tray.logging import log_debug, log_info
|
|
127
|
+
|
|
128
|
+
global _tool_statuses, _last_refresh
|
|
129
|
+
|
|
130
|
+
# Throttle refreshes to avoid hitting GitHub API repeatedly
|
|
131
|
+
now = time.time()
|
|
132
|
+
if (
|
|
133
|
+
not force
|
|
134
|
+
and _last_refresh
|
|
135
|
+
and (now - _last_refresh) < _REFRESH_THROTTLE_SECONDS
|
|
136
|
+
):
|
|
137
|
+
log_debug(f"Refresh throttled ({int(now - _last_refresh)}s since last)")
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
_tool_statuses = []
|
|
141
|
+
_last_refresh = now
|
|
142
|
+
|
|
143
|
+
log_info(f"Refreshing {len(_repos)} repos")
|
|
144
|
+
for repo in _repos:
|
|
145
|
+
manifest = fetch_manifest(repo, _token)
|
|
146
|
+
if not manifest:
|
|
147
|
+
continue # Skip repos without tooltray.toml
|
|
148
|
+
|
|
149
|
+
# Get launch command for executable lookup
|
|
150
|
+
launch_cmd = manifest.launch or manifest.name
|
|
151
|
+
installed = get_installed_version(launch_cmd)
|
|
152
|
+
remote = get_remote_version(repo, _token) if _token else None
|
|
153
|
+
executable = get_tool_executable(launch_cmd) if installed else None
|
|
154
|
+
|
|
155
|
+
_tool_statuses.append(
|
|
156
|
+
ToolStatus(
|
|
157
|
+
repo=repo,
|
|
158
|
+
manifest=manifest,
|
|
159
|
+
installed=installed,
|
|
160
|
+
remote=remote,
|
|
161
|
+
executable=executable,
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
log_debug(f"Status: {manifest.name} installed={installed} remote={remote}")
|
|
165
|
+
|
|
166
|
+
log_info(f"Refresh complete: {len(_tool_statuses)} tools loaded")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def find_orphaned_icons() -> list[OrphanedIcon]:
|
|
170
|
+
"""Find desktop icons that should be cleaned up. Called each time menu opens."""
|
|
171
|
+
from tool_tray.state import load_state
|
|
172
|
+
|
|
173
|
+
orphans: list[OrphanedIcon] = []
|
|
174
|
+
|
|
175
|
+
state = load_state()
|
|
176
|
+
if not state.desktop_icons:
|
|
177
|
+
return orphans
|
|
178
|
+
|
|
179
|
+
# Build set of active repos and their manifests
|
|
180
|
+
active_repos = set(_repos)
|
|
181
|
+
manifest_by_repo: dict[str, Manifest] = {}
|
|
182
|
+
for status in _tool_statuses:
|
|
183
|
+
manifest_by_repo[status.repo] = status.manifest
|
|
184
|
+
|
|
185
|
+
for tool_name, record in state.desktop_icons.items():
|
|
186
|
+
icon_path = Path(record.path)
|
|
187
|
+
|
|
188
|
+
# Check if file was deleted externally
|
|
189
|
+
if not icon_path.exists():
|
|
190
|
+
orphans.append(
|
|
191
|
+
OrphanedIcon(
|
|
192
|
+
tool_name=tool_name,
|
|
193
|
+
path=record.path,
|
|
194
|
+
reason="file_missing",
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
# Check if repo was removed from config
|
|
200
|
+
if record.repo not in active_repos:
|
|
201
|
+
orphans.append(
|
|
202
|
+
OrphanedIcon(
|
|
203
|
+
tool_name=tool_name,
|
|
204
|
+
path=record.path,
|
|
205
|
+
reason="tool_removed",
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
# Check if desktop_icon was disabled in manifest
|
|
211
|
+
manifest = manifest_by_repo.get(record.repo)
|
|
212
|
+
if manifest and not manifest.desktop_icon:
|
|
213
|
+
orphans.append(
|
|
214
|
+
OrphanedIcon(
|
|
215
|
+
tool_name=tool_name,
|
|
216
|
+
path=record.path,
|
|
217
|
+
reason="desktop_icon_disabled",
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return orphans
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def cleanup_orphans(orphans: list[OrphanedIcon]) -> int:
|
|
225
|
+
"""Remove orphaned icons. Returns count of removed icons."""
|
|
226
|
+
from tool_tray.desktop import remove_desktop_icon
|
|
227
|
+
from tool_tray.logging import log_info
|
|
228
|
+
from tool_tray.state import remove_icon_record
|
|
229
|
+
|
|
230
|
+
count = 0
|
|
231
|
+
|
|
232
|
+
for orphan in orphans:
|
|
233
|
+
if orphan.reason == "file_missing":
|
|
234
|
+
# Just remove the record
|
|
235
|
+
remove_icon_record(orphan.tool_name)
|
|
236
|
+
count += 1
|
|
237
|
+
else:
|
|
238
|
+
# Remove file and record
|
|
239
|
+
if remove_desktop_icon(orphan.tool_name):
|
|
240
|
+
remove_icon_record(orphan.tool_name)
|
|
241
|
+
count += 1
|
|
242
|
+
|
|
243
|
+
if count:
|
|
244
|
+
log_info(f"Cleaned up {count} orphaned icons")
|
|
245
|
+
|
|
246
|
+
return count
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def update_all() -> None:
|
|
250
|
+
"""Install/update all tools with available updates."""
|
|
251
|
+
if not _token:
|
|
252
|
+
return
|
|
253
|
+
for status in _tool_statuses:
|
|
254
|
+
if status.has_update or not status.installed:
|
|
255
|
+
install_tool(status.repo, status.manifest, _token)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def on_update_all(icon: Any, item: Any) -> None:
|
|
259
|
+
"""Install/update all tools in background."""
|
|
260
|
+
threading.Thread(target=update_all, daemon=True).start()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def make_cleanup_callback(orphans: list[OrphanedIcon]) -> Any:
|
|
264
|
+
"""Create a callback that cleans up the given orphans."""
|
|
265
|
+
|
|
266
|
+
def callback(icon: Any, item: Any) -> None:
|
|
267
|
+
cleanup_orphans(orphans)
|
|
268
|
+
|
|
269
|
+
return callback
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def on_quit(icon: Any, item: Any) -> None:
|
|
273
|
+
icon.stop()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def make_tool_callback(tool_name: str) -> Any:
|
|
277
|
+
"""Create a callback for launching a tool."""
|
|
278
|
+
|
|
279
|
+
def callback(icon: Any, item: Any) -> None:
|
|
280
|
+
launch_tool(tool_name)
|
|
281
|
+
|
|
282
|
+
return callback
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def build_menu_items() -> list[Any]:
|
|
286
|
+
"""Build menu items from current state. Called each time menu opens."""
|
|
287
|
+
# Reload config and statuses fresh each time menu opens
|
|
288
|
+
reload_config()
|
|
289
|
+
if _token:
|
|
290
|
+
refresh_statuses()
|
|
291
|
+
|
|
292
|
+
items: list[Any] = []
|
|
293
|
+
|
|
294
|
+
# Not configured state
|
|
295
|
+
if not _token:
|
|
296
|
+
items.append(pystray.MenuItem("[!] Not configured", None, enabled=False))
|
|
297
|
+
items.append(pystray.MenuItem("Setup...", on_configure))
|
|
298
|
+
items.append(pystray.Menu.SEPARATOR)
|
|
299
|
+
items.append(pystray.MenuItem("Quit", on_quit))
|
|
300
|
+
return items
|
|
301
|
+
|
|
302
|
+
# Configured state - show tools
|
|
303
|
+
for status in _tool_statuses:
|
|
304
|
+
text = status.display_text
|
|
305
|
+
if status.has_update:
|
|
306
|
+
text += " *"
|
|
307
|
+
if status.can_launch:
|
|
308
|
+
items.append(
|
|
309
|
+
pystray.MenuItem(
|
|
310
|
+
f"> {text}",
|
|
311
|
+
make_tool_callback(status.name),
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
else:
|
|
315
|
+
items.append(pystray.MenuItem(text, None, enabled=False))
|
|
316
|
+
|
|
317
|
+
if not _tool_statuses:
|
|
318
|
+
items.append(
|
|
319
|
+
pystray.MenuItem("No tools with tooltray.toml", None, enabled=False)
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# Show orphaned icons section if any exist
|
|
323
|
+
orphans = find_orphaned_icons()
|
|
324
|
+
if orphans:
|
|
325
|
+
items.append(pystray.Menu.SEPARATOR)
|
|
326
|
+
items.append(pystray.MenuItem("Orphaned Icons:", None, enabled=False))
|
|
327
|
+
for orphan in orphans:
|
|
328
|
+
reason_text = {
|
|
329
|
+
"tool_removed": "repo removed",
|
|
330
|
+
"desktop_icon_disabled": "disabled",
|
|
331
|
+
"file_missing": "file missing",
|
|
332
|
+
}.get(orphan.reason, orphan.reason)
|
|
333
|
+
items.append(
|
|
334
|
+
pystray.MenuItem(
|
|
335
|
+
f" {orphan.tool_name} ({reason_text})", None, enabled=False
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
items.append(
|
|
339
|
+
pystray.MenuItem(
|
|
340
|
+
f"Clean Up ({len(orphans)})",
|
|
341
|
+
make_cleanup_callback(orphans),
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
items.append(pystray.Menu.SEPARATOR)
|
|
346
|
+
|
|
347
|
+
has_updates = any(s.has_update or not s.installed for s in _tool_statuses)
|
|
348
|
+
items.append(
|
|
349
|
+
pystray.MenuItem(
|
|
350
|
+
"Update All",
|
|
351
|
+
on_update_all,
|
|
352
|
+
enabled=has_updates,
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
items.append(pystray.MenuItem("Configure...", on_configure))
|
|
356
|
+
items.append(pystray.Menu.SEPARATOR)
|
|
357
|
+
items.append(pystray.MenuItem("Quit", on_quit))
|
|
358
|
+
|
|
359
|
+
return items
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def build_menu() -> Any:
|
|
363
|
+
"""Build dynamic menu that rebuilds items each time it's opened."""
|
|
364
|
+
return pystray.Menu(lambda: iter(build_menu_items()))
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def on_startup(icon: Any) -> None:
|
|
368
|
+
"""Called when tray icon is ready."""
|
|
369
|
+
icon.visible = True
|
|
370
|
+
refresh_statuses()
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def spawn_setup() -> None:
|
|
374
|
+
"""Spawn setup dialog as subprocess."""
|
|
375
|
+
import sys
|
|
376
|
+
|
|
377
|
+
from tool_tray.logging import log_info
|
|
378
|
+
|
|
379
|
+
# Use sys.executable to run with same Python interpreter
|
|
380
|
+
cmd = [sys.executable, "-m", "tool_tray", "setup"]
|
|
381
|
+
log_info(f"Spawning setup subprocess: {cmd}")
|
|
382
|
+
subprocess.Popen(cmd)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def on_configure(icon: Any, item: Any) -> None:
|
|
386
|
+
"""Open setup dialog for configuration."""
|
|
387
|
+
spawn_setup()
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def run_tray() -> None:
|
|
391
|
+
"""Main entry point - create and run the tray icon."""
|
|
392
|
+
from tool_tray import __version__
|
|
393
|
+
from tool_tray.logging import log_info
|
|
394
|
+
|
|
395
|
+
global _icon
|
|
396
|
+
|
|
397
|
+
log_info(f"Starting tooltray v{__version__}")
|
|
398
|
+
|
|
399
|
+
# Spawn setup dialog if no config (non-blocking)
|
|
400
|
+
if not config_exists():
|
|
401
|
+
log_info("No config found, spawning setup")
|
|
402
|
+
spawn_setup()
|
|
403
|
+
|
|
404
|
+
reload_config()
|
|
405
|
+
refresh_statuses()
|
|
406
|
+
|
|
407
|
+
log_info("Tray icon starting")
|
|
408
|
+
_icon = pystray.Icon(
|
|
409
|
+
"tooltray",
|
|
410
|
+
icon=create_icon(),
|
|
411
|
+
title="Tool Tray",
|
|
412
|
+
menu=build_menu(),
|
|
413
|
+
)
|
|
414
|
+
_icon.run(setup=on_startup)
|
tool_tray/updater.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from tool_tray.manifest import Manifest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_installed_version(tool_name: str) -> str | None:
|
|
10
|
+
"""Get installed version from uv tool list."""
|
|
11
|
+
try:
|
|
12
|
+
result = subprocess.run(
|
|
13
|
+
["uv", "tool", "list"],
|
|
14
|
+
capture_output=True,
|
|
15
|
+
text=True,
|
|
16
|
+
check=True,
|
|
17
|
+
)
|
|
18
|
+
for line in result.stdout.splitlines():
|
|
19
|
+
if line.startswith(tool_name):
|
|
20
|
+
match = re.search(r"v?(\d+\.\d+\.\d+)", line)
|
|
21
|
+
if match:
|
|
22
|
+
return match.group(1)
|
|
23
|
+
return None
|
|
24
|
+
except subprocess.CalledProcessError:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_remote_version(repo: str, token: str) -> str | None:
|
|
29
|
+
"""Fetch version from pyproject.toml via GitHub API."""
|
|
30
|
+
url = f"https://api.github.com/repos/{repo}/contents/pyproject.toml"
|
|
31
|
+
headers = {
|
|
32
|
+
"Authorization": f"Bearer {token}",
|
|
33
|
+
"Accept": "application/vnd.github.raw+json",
|
|
34
|
+
}
|
|
35
|
+
try:
|
|
36
|
+
resp = httpx.get(url, headers=headers, timeout=10)
|
|
37
|
+
resp.raise_for_status()
|
|
38
|
+
content = resp.text
|
|
39
|
+
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
|
|
40
|
+
if match:
|
|
41
|
+
return match.group(1)
|
|
42
|
+
return None
|
|
43
|
+
except httpx.HTTPError:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _install_url(repo: str, token: str) -> str:
|
|
48
|
+
"""Get the git URL for uv tool install."""
|
|
49
|
+
return f"git+https://oauth2:{token}@github.com/{repo}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def install_tool(repo: str, manifest: Manifest, token: str) -> bool:
|
|
53
|
+
"""Install or update tool based on manifest type."""
|
|
54
|
+
from tool_tray.logging import log_error, log_info
|
|
55
|
+
|
|
56
|
+
log_info(f"Installing: {repo} (type={manifest.type})")
|
|
57
|
+
if manifest.type == "uv":
|
|
58
|
+
success = _install_uv_tool(repo, token)
|
|
59
|
+
elif manifest.type == "git":
|
|
60
|
+
success = _install_git_tool(repo, manifest, token)
|
|
61
|
+
else:
|
|
62
|
+
log_error(f"Unknown manifest type: {manifest.type}")
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
# Auto-create desktop icon if enabled
|
|
66
|
+
if success and manifest.desktop_icon:
|
|
67
|
+
from tool_tray.desktop import create_desktop_icon
|
|
68
|
+
|
|
69
|
+
tool_name = manifest.launch or manifest.name
|
|
70
|
+
log_info(f"Auto-creating desktop icon: {tool_name}")
|
|
71
|
+
create_desktop_icon(tool_name, repo=repo)
|
|
72
|
+
|
|
73
|
+
return success
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _install_uv_tool(repo: str, token: str) -> bool:
|
|
77
|
+
"""Install or update tool via uv tool install --force."""
|
|
78
|
+
from tool_tray.logging import log_error, log_info
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
subprocess.run(
|
|
82
|
+
["uv", "tool", "install", _install_url(repo, token), "--force"],
|
|
83
|
+
check=True,
|
|
84
|
+
capture_output=True,
|
|
85
|
+
text=True,
|
|
86
|
+
)
|
|
87
|
+
log_info(f"Installed: {repo}")
|
|
88
|
+
return True
|
|
89
|
+
except subprocess.CalledProcessError as e:
|
|
90
|
+
# Don't log exception - it contains the token in the command
|
|
91
|
+
stderr = e.stderr if e.stderr else "unknown error"
|
|
92
|
+
log_error(f"Failed to install {repo}: {stderr}")
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _install_git_tool(repo: str, manifest: Manifest, token: str) -> bool:
|
|
97
|
+
"""Install tool via git clone + optional build command."""
|
|
98
|
+
from pathlib import Path
|
|
99
|
+
|
|
100
|
+
from tool_tray.logging import log_error, log_info
|
|
101
|
+
|
|
102
|
+
clone_url = f"https://oauth2:{token}@github.com/{repo}"
|
|
103
|
+
install_dir = Path.home() / ".local/share/tooltray" / repo.split("/")[-1]
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
# Remove existing if present
|
|
107
|
+
if install_dir.exists():
|
|
108
|
+
import shutil
|
|
109
|
+
|
|
110
|
+
shutil.rmtree(install_dir)
|
|
111
|
+
|
|
112
|
+
install_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
|
|
114
|
+
# Clone repo
|
|
115
|
+
subprocess.run(
|
|
116
|
+
["git", "clone", "--depth=1", clone_url, str(install_dir)],
|
|
117
|
+
check=True,
|
|
118
|
+
capture_output=True,
|
|
119
|
+
text=True,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Run build command if specified
|
|
123
|
+
if manifest.build:
|
|
124
|
+
subprocess.run(
|
|
125
|
+
manifest.build,
|
|
126
|
+
shell=True,
|
|
127
|
+
cwd=install_dir,
|
|
128
|
+
check=True,
|
|
129
|
+
capture_output=True,
|
|
130
|
+
text=True,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
log_info(f"Installed (git): {repo}")
|
|
134
|
+
return True
|
|
135
|
+
except subprocess.CalledProcessError as e:
|
|
136
|
+
# Don't log exception - it may contain the token
|
|
137
|
+
stderr = e.stderr if e.stderr else "unknown error"
|
|
138
|
+
log_error(f"Failed to install {repo}: {stderr}")
|
|
139
|
+
return False
|