tokenhoggers 0.1.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.
- collectors/__init__.py +0 -0
- collectors/cli/__init__.py +0 -0
- collectors/cli/auth.py +247 -0
- collectors/cli/config.py +44 -0
- collectors/cli/daemon.py +186 -0
- collectors/cli/main.py +242 -0
- collectors/cli/parsers/__init__.py +7 -0
- collectors/cli/parsers/base.py +80 -0
- collectors/cli/parsers/claude_code.py +239 -0
- collectors/cli/parsers/cursor.py +95 -0
- collectors/cli/parsers/gemini_cli.py +84 -0
- collectors/cli/parsers/ollama.py +46 -0
- collectors/cli/paths.py +58 -0
- collectors/cli/state.py +36 -0
- collectors/cli/sync.py +92 -0
- tokenhoggers-0.1.0.dist-info/METADATA +138 -0
- tokenhoggers-0.1.0.dist-info/RECORD +21 -0
- tokenhoggers-0.1.0.dist-info/WHEEL +5 -0
- tokenhoggers-0.1.0.dist-info/entry_points.txt +2 -0
- tokenhoggers-0.1.0.dist-info/licenses/LICENSE +21 -0
- tokenhoggers-0.1.0.dist-info/top_level.txt +1 -0
collectors/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
collectors/cli/auth.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Browser-based OAuth login and token refresh for Token Hoggers CLI."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import socket
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import webbrowser
|
|
10
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
11
|
+
from typing import Optional
|
|
12
|
+
from urllib.error import HTTPError
|
|
13
|
+
from urllib.request import Request, urlopen
|
|
14
|
+
|
|
15
|
+
from collectors.cli.config import Config
|
|
16
|
+
|
|
17
|
+
SUPABASE_URL = "https://aaxkbwyolbhjgovmcael.supabase.co"
|
|
18
|
+
SUPABASE_ANON_KEY = (
|
|
19
|
+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
|
|
20
|
+
"eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFheGtid3lvbGJoamdvdm1jYWVsIiwi"
|
|
21
|
+
"cm9sZSI6ImFub24iLCJpYXQiOjE3NzQxODM4MTUsImV4cCI6MjA4OTc1OTgxNX0."
|
|
22
|
+
"nNDUcYm2xzpGW6ervqNQEIXQdENi1qYXpQo4Rp0JrI8"
|
|
23
|
+
)
|
|
24
|
+
SYNC_API_URL = f"{SUPABASE_URL}/functions/v1/sync"
|
|
25
|
+
|
|
26
|
+
_PORT_RANGE = range(19876, 19900)
|
|
27
|
+
_LOGIN_TIMEOUT = 120 # seconds
|
|
28
|
+
|
|
29
|
+
# HTML page served at /callback — reads hash fragment and POSTs tokens back
|
|
30
|
+
_CALLBACK_HTML = """<!DOCTYPE html>
|
|
31
|
+
<html>
|
|
32
|
+
<head>
|
|
33
|
+
<title>Token Hoggers</title>
|
|
34
|
+
<style>
|
|
35
|
+
body { font-family: -apple-system, system-ui, sans-serif; display: flex;
|
|
36
|
+
justify-content: center; align-items: center; min-height: 100vh;
|
|
37
|
+
margin: 0; background: #0f0f0f; color: #e0e0e0; }
|
|
38
|
+
.card { text-align: center; padding: 48px; border-radius: 16px;
|
|
39
|
+
background: #1a1a1a; border: 1px solid #333; max-width: 400px; }
|
|
40
|
+
h2 { margin: 0 0 8px; color: #fff; }
|
|
41
|
+
p { margin: 0; color: #888; font-size: 14px; }
|
|
42
|
+
.err { color: #ff6b6b; }
|
|
43
|
+
</style>
|
|
44
|
+
</head>
|
|
45
|
+
<body>
|
|
46
|
+
<div class="card">
|
|
47
|
+
<div id="status"><h2>Completing login...</h2></div>
|
|
48
|
+
</div>
|
|
49
|
+
<script>
|
|
50
|
+
(function() {
|
|
51
|
+
var hash = window.location.hash.substring(1);
|
|
52
|
+
if (!hash) {
|
|
53
|
+
document.getElementById('status').innerHTML =
|
|
54
|
+
'<h2 class="err">Login failed</h2><p>No auth tokens received. Please try again.</p>';
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
var params = new URLSearchParams(hash);
|
|
58
|
+
var data = {
|
|
59
|
+
access_token: params.get('access_token'),
|
|
60
|
+
refresh_token: params.get('refresh_token'),
|
|
61
|
+
expires_in: parseInt(params.get('expires_in') || '3600', 10),
|
|
62
|
+
token_type: params.get('token_type')
|
|
63
|
+
};
|
|
64
|
+
if (!data.access_token) {
|
|
65
|
+
document.getElementById('status').innerHTML =
|
|
66
|
+
'<h2 class="err">Login failed</h2><p>Missing access token. Please try again.</p>';
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
fetch('/receive-tokens', {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: {'Content-Type': 'application/json'},
|
|
72
|
+
body: JSON.stringify(data)
|
|
73
|
+
}).then(function(r) { return r.json(); }).then(function() {
|
|
74
|
+
document.getElementById('status').innerHTML =
|
|
75
|
+
'<h2>Login successful!</h2><p>You can close this tab and return to your terminal.</p>';
|
|
76
|
+
}).catch(function(e) {
|
|
77
|
+
document.getElementById('status').innerHTML =
|
|
78
|
+
'<h2 class="err">Error</h2><p>' + e.message + '</p>';
|
|
79
|
+
});
|
|
80
|
+
})();
|
|
81
|
+
</script>
|
|
82
|
+
</body>
|
|
83
|
+
</html>"""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _find_available_port() -> int:
|
|
87
|
+
"""Find an available port in the designated range."""
|
|
88
|
+
for port in _PORT_RANGE:
|
|
89
|
+
try:
|
|
90
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
91
|
+
s.bind(("127.0.0.1", port))
|
|
92
|
+
return port
|
|
93
|
+
except OSError:
|
|
94
|
+
continue
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
f"No available port in range {_PORT_RANGE.start}-{_PORT_RANGE.stop - 1}. "
|
|
97
|
+
"Close other applications or use: tokenhoggers config --token <JWT>"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def browser_login() -> dict:
|
|
102
|
+
"""Open browser for Google OAuth and capture tokens via local HTTP server.
|
|
103
|
+
|
|
104
|
+
Returns dict with access_token, refresh_token, expires_in.
|
|
105
|
+
Raises TimeoutError if user doesn't complete login within the timeout.
|
|
106
|
+
Raises RuntimeError on other failures.
|
|
107
|
+
"""
|
|
108
|
+
port = _find_available_port()
|
|
109
|
+
tokens = {}
|
|
110
|
+
token_event = threading.Event()
|
|
111
|
+
|
|
112
|
+
class CallbackHandler(BaseHTTPRequestHandler):
|
|
113
|
+
def do_GET(self):
|
|
114
|
+
if self.path.startswith("/callback"):
|
|
115
|
+
self.send_response(200)
|
|
116
|
+
self.send_header("Content-Type", "text/html")
|
|
117
|
+
self.end_headers()
|
|
118
|
+
self.wfile.write(_CALLBACK_HTML.encode())
|
|
119
|
+
else:
|
|
120
|
+
self.send_response(404)
|
|
121
|
+
self.end_headers()
|
|
122
|
+
|
|
123
|
+
def do_POST(self):
|
|
124
|
+
if self.path == "/receive-tokens":
|
|
125
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
126
|
+
body = json.loads(self.rfile.read(length))
|
|
127
|
+
tokens.update(body)
|
|
128
|
+
token_event.set()
|
|
129
|
+
self.send_response(200)
|
|
130
|
+
self.send_header("Content-Type", "application/json")
|
|
131
|
+
self.end_headers()
|
|
132
|
+
self.wfile.write(b'{"ok":true}')
|
|
133
|
+
else:
|
|
134
|
+
self.send_response(404)
|
|
135
|
+
self.end_headers()
|
|
136
|
+
|
|
137
|
+
def log_message(self, format, *args):
|
|
138
|
+
pass # suppress HTTP server logging
|
|
139
|
+
|
|
140
|
+
server = HTTPServer(("127.0.0.1", port), CallbackHandler)
|
|
141
|
+
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
142
|
+
server_thread.start()
|
|
143
|
+
|
|
144
|
+
auth_url = (
|
|
145
|
+
f"{SUPABASE_URL}/auth/v1/authorize"
|
|
146
|
+
f"?provider=google"
|
|
147
|
+
f"&redirect_to=http://localhost:{port}/callback"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
opened = webbrowser.open(auth_url)
|
|
151
|
+
if not opened:
|
|
152
|
+
print(f"\n Could not open browser. Please visit this URL manually:\n")
|
|
153
|
+
print(f" {auth_url}\n")
|
|
154
|
+
|
|
155
|
+
if not token_event.wait(timeout=_LOGIN_TIMEOUT):
|
|
156
|
+
server.shutdown()
|
|
157
|
+
raise TimeoutError("Login timed out. Please try again.")
|
|
158
|
+
|
|
159
|
+
server.shutdown()
|
|
160
|
+
|
|
161
|
+
if not tokens.get("access_token"):
|
|
162
|
+
raise RuntimeError("No access token received.")
|
|
163
|
+
|
|
164
|
+
return tokens
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def decode_jwt_exp(token: str) -> int:
|
|
168
|
+
"""Decode a JWT and return the exp (expiry) claim as a Unix timestamp.
|
|
169
|
+
|
|
170
|
+
Does NOT verify the signature — only used for local expiry checks.
|
|
171
|
+
Returns 0 if the token is malformed or has no exp claim.
|
|
172
|
+
"""
|
|
173
|
+
try:
|
|
174
|
+
parts = token.split(".")
|
|
175
|
+
if len(parts) != 3:
|
|
176
|
+
return 0
|
|
177
|
+
payload = parts[1]
|
|
178
|
+
# Add base64 padding
|
|
179
|
+
padding = 4 - len(payload) % 4
|
|
180
|
+
if padding != 4:
|
|
181
|
+
payload += "=" * padding
|
|
182
|
+
decoded = base64.urlsafe_b64decode(payload)
|
|
183
|
+
claims = json.loads(decoded)
|
|
184
|
+
return int(claims.get("exp", 0))
|
|
185
|
+
except Exception:
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def refresh_access_token(refresh_token: str) -> dict:
|
|
190
|
+
"""Exchange a refresh token for a new access token pair.
|
|
191
|
+
|
|
192
|
+
Returns dict with access_token, refresh_token, expires_in.
|
|
193
|
+
Raises RuntimeError if refresh fails (token revoked/expired).
|
|
194
|
+
"""
|
|
195
|
+
url = f"{SUPABASE_URL}/auth/v1/token?grant_type=refresh_token"
|
|
196
|
+
body = json.dumps({"refresh_token": refresh_token}).encode()
|
|
197
|
+
req = Request(
|
|
198
|
+
url,
|
|
199
|
+
data=body,
|
|
200
|
+
headers={
|
|
201
|
+
"apikey": SUPABASE_ANON_KEY,
|
|
202
|
+
"Content-Type": "application/json",
|
|
203
|
+
},
|
|
204
|
+
method="POST",
|
|
205
|
+
)
|
|
206
|
+
try:
|
|
207
|
+
with urlopen(req, timeout=15) as resp:
|
|
208
|
+
data = json.loads(resp.read().decode())
|
|
209
|
+
return {
|
|
210
|
+
"access_token": data["access_token"],
|
|
211
|
+
"refresh_token": data["refresh_token"],
|
|
212
|
+
"expires_in": data.get("expires_in", 3600),
|
|
213
|
+
}
|
|
214
|
+
except HTTPError as e:
|
|
215
|
+
if e.code in (400, 401, 403):
|
|
216
|
+
raise RuntimeError(
|
|
217
|
+
"Session expired. Please run `tokenhoggers login` again."
|
|
218
|
+
)
|
|
219
|
+
raise RuntimeError(f"Token refresh failed (HTTP {e.code})")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def refresh_if_needed(config: Config) -> Config:
|
|
223
|
+
"""Check if the access token is expired and refresh it if possible.
|
|
224
|
+
|
|
225
|
+
Modifies and saves config in-place if a refresh occurs.
|
|
226
|
+
Returns the (possibly updated) config.
|
|
227
|
+
"""
|
|
228
|
+
if not config.token:
|
|
229
|
+
return config
|
|
230
|
+
|
|
231
|
+
exp = decode_jwt_exp(config.token)
|
|
232
|
+
now = int(time.time())
|
|
233
|
+
|
|
234
|
+
# Token still valid for more than 60 seconds
|
|
235
|
+
if exp > 0 and (exp - now) > 60:
|
|
236
|
+
return config
|
|
237
|
+
|
|
238
|
+
# No refresh token available (manual token setup)
|
|
239
|
+
if not config.refresh_token:
|
|
240
|
+
return config
|
|
241
|
+
|
|
242
|
+
tokens = refresh_access_token(config.refresh_token)
|
|
243
|
+
config.token = tokens["access_token"]
|
|
244
|
+
config.refresh_token = tokens["refresh_token"]
|
|
245
|
+
config.token_expiry = now + tokens.get("expires_in", 3600)
|
|
246
|
+
config.save()
|
|
247
|
+
return config
|
collectors/cli/config.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""User configuration for the Token Hoggers CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from collectors.cli.paths import config_dir
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Config:
|
|
12
|
+
api_url: str = ""
|
|
13
|
+
token: str = ""
|
|
14
|
+
refresh_token: str = ""
|
|
15
|
+
token_expiry: int = 0
|
|
16
|
+
enable_fluency: bool = False
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def load(cls) -> "Config":
|
|
20
|
+
path = config_dir() / "config.json"
|
|
21
|
+
if path.exists():
|
|
22
|
+
data = json.loads(path.read_text())
|
|
23
|
+
return cls(
|
|
24
|
+
api_url=data.get("api_url", ""),
|
|
25
|
+
token=data.get("token", ""),
|
|
26
|
+
refresh_token=data.get("refresh_token", ""),
|
|
27
|
+
token_expiry=data.get("token_expiry", 0),
|
|
28
|
+
enable_fluency=data.get("enable_fluency", False),
|
|
29
|
+
)
|
|
30
|
+
return cls()
|
|
31
|
+
|
|
32
|
+
def save(self) -> None:
|
|
33
|
+
path = config_dir() / "config.json"
|
|
34
|
+
path.write_text(json.dumps({
|
|
35
|
+
"api_url": self.api_url,
|
|
36
|
+
"token": self.token,
|
|
37
|
+
"refresh_token": self.refresh_token,
|
|
38
|
+
"token_expiry": self.token_expiry,
|
|
39
|
+
"enable_fluency": self.enable_fluency,
|
|
40
|
+
}, indent=2))
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_configured(self) -> bool:
|
|
44
|
+
return bool(self.api_url and self.token)
|
collectors/cli/daemon.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Auto-sync daemon setup — LaunchAgent (macOS) or cron (Linux)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from collectors.cli.paths import config_dir
|
|
11
|
+
|
|
12
|
+
_LABEL = "ai.tokenhoggers.sync"
|
|
13
|
+
_INTERVAL_SECONDS = 14400 # 4 hours
|
|
14
|
+
_CRON_MARKER = "# tokenhoggers-autosync"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _plist_path() -> Path:
|
|
18
|
+
return Path.home() / "Library" / "LaunchAgents" / f"{_LABEL}.plist"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _find_executable() -> str:
|
|
22
|
+
"""Find the tokenhoggers executable path."""
|
|
23
|
+
path = shutil.which("tokenhoggers")
|
|
24
|
+
if path:
|
|
25
|
+
return path
|
|
26
|
+
# Fallback: run as python module
|
|
27
|
+
return sys.executable
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _executable_args() -> list:
|
|
31
|
+
"""Return the command + args list for running tokenhoggers sync."""
|
|
32
|
+
path = shutil.which("tokenhoggers")
|
|
33
|
+
if path:
|
|
34
|
+
return [path, "sync"]
|
|
35
|
+
return [sys.executable, "-m", "collectors.cli.main", "sync"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _log_path() -> str:
|
|
39
|
+
return str(config_dir() / "sync.log")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------- macOS LaunchAgent ----------
|
|
43
|
+
|
|
44
|
+
def _install_launchagent() -> None:
|
|
45
|
+
args = _executable_args()
|
|
46
|
+
prog_args = "\n".join(f" <string>{a}</string>" for a in args)
|
|
47
|
+
log = _log_path()
|
|
48
|
+
|
|
49
|
+
plist = f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
50
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
51
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
52
|
+
<plist version="1.0">
|
|
53
|
+
<dict>
|
|
54
|
+
<key>Label</key>
|
|
55
|
+
<string>{_LABEL}</string>
|
|
56
|
+
<key>ProgramArguments</key>
|
|
57
|
+
<array>
|
|
58
|
+
{prog_args}
|
|
59
|
+
</array>
|
|
60
|
+
<key>StartInterval</key>
|
|
61
|
+
<integer>{_INTERVAL_SECONDS}</integer>
|
|
62
|
+
<key>StandardOutPath</key>
|
|
63
|
+
<string>{log}</string>
|
|
64
|
+
<key>StandardErrorPath</key>
|
|
65
|
+
<string>{log}</string>
|
|
66
|
+
<key>RunAtLoad</key>
|
|
67
|
+
<true/>
|
|
68
|
+
</dict>
|
|
69
|
+
</plist>
|
|
70
|
+
"""
|
|
71
|
+
plist_file = _plist_path()
|
|
72
|
+
plist_file.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
# Unload if already loaded (ignore errors)
|
|
75
|
+
_unload_launchagent()
|
|
76
|
+
|
|
77
|
+
plist_file.write_text(plist)
|
|
78
|
+
|
|
79
|
+
uid = os.getuid()
|
|
80
|
+
subprocess.run(
|
|
81
|
+
["launchctl", "bootstrap", f"gui/{uid}", str(plist_file)],
|
|
82
|
+
capture_output=True,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _unload_launchagent() -> None:
|
|
87
|
+
plist_file = _plist_path()
|
|
88
|
+
uid = os.getuid()
|
|
89
|
+
subprocess.run(
|
|
90
|
+
["launchctl", "bootout", f"gui/{uid}", str(plist_file)],
|
|
91
|
+
capture_output=True,
|
|
92
|
+
)
|
|
93
|
+
if plist_file.exists():
|
|
94
|
+
plist_file.unlink()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _launchagent_status() -> str:
|
|
98
|
+
plist_file = _plist_path()
|
|
99
|
+
if not plist_file.exists():
|
|
100
|
+
return "not installed"
|
|
101
|
+
result = subprocess.run(
|
|
102
|
+
["launchctl", "list", _LABEL],
|
|
103
|
+
capture_output=True,
|
|
104
|
+
)
|
|
105
|
+
if result.returncode == 0:
|
|
106
|
+
return "installed and running"
|
|
107
|
+
return "installed but not loaded"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------- Linux cron ----------
|
|
111
|
+
|
|
112
|
+
def _get_crontab() -> str:
|
|
113
|
+
result = subprocess.run(
|
|
114
|
+
["crontab", "-l"], capture_output=True, text=True
|
|
115
|
+
)
|
|
116
|
+
if result.returncode != 0:
|
|
117
|
+
return ""
|
|
118
|
+
return result.stdout
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _set_crontab(content: str) -> None:
|
|
122
|
+
subprocess.run(
|
|
123
|
+
["crontab", "-"], input=content, text=True, check=True
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _install_cron() -> None:
|
|
128
|
+
args = _executable_args()
|
|
129
|
+
cmd = " ".join(args)
|
|
130
|
+
log = _log_path()
|
|
131
|
+
cron_line = f"0 */4 * * * {cmd} >> {log} 2>&1 {_CRON_MARKER}"
|
|
132
|
+
|
|
133
|
+
existing = _get_crontab()
|
|
134
|
+
|
|
135
|
+
# Remove old entry if present
|
|
136
|
+
lines = [l for l in existing.splitlines() if _CRON_MARKER not in l]
|
|
137
|
+
lines.append(cron_line)
|
|
138
|
+
|
|
139
|
+
_set_crontab("\n".join(lines) + "\n")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _uninstall_cron() -> None:
|
|
143
|
+
existing = _get_crontab()
|
|
144
|
+
if not existing:
|
|
145
|
+
return
|
|
146
|
+
lines = [l for l in existing.splitlines() if _CRON_MARKER not in l]
|
|
147
|
+
_set_crontab("\n".join(lines) + "\n" if lines else "")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _cron_status() -> str:
|
|
151
|
+
existing = _get_crontab()
|
|
152
|
+
if _CRON_MARKER in existing:
|
|
153
|
+
return "installed (cron)"
|
|
154
|
+
return "not installed"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------- Public API ----------
|
|
158
|
+
|
|
159
|
+
def install_daemon() -> None:
|
|
160
|
+
"""Install auto-sync daemon for the current platform."""
|
|
161
|
+
system = platform.system()
|
|
162
|
+
if system == "Darwin":
|
|
163
|
+
_install_launchagent()
|
|
164
|
+
elif system == "Linux":
|
|
165
|
+
_install_cron()
|
|
166
|
+
else:
|
|
167
|
+
raise RuntimeError(f"Auto-sync not supported on {system} yet")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def uninstall_daemon() -> None:
|
|
171
|
+
"""Remove auto-sync daemon."""
|
|
172
|
+
system = platform.system()
|
|
173
|
+
if system == "Darwin":
|
|
174
|
+
_unload_launchagent()
|
|
175
|
+
elif system == "Linux":
|
|
176
|
+
_uninstall_cron()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def daemon_status() -> str:
|
|
180
|
+
"""Return human-readable daemon status."""
|
|
181
|
+
system = platform.system()
|
|
182
|
+
if system == "Darwin":
|
|
183
|
+
return _launchagent_status()
|
|
184
|
+
elif system == "Linux":
|
|
185
|
+
return _cron_status()
|
|
186
|
+
return "not supported on this platform"
|