tokenkick 1.0.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.
- tokenkick/__init__.py +8 -0
- tokenkick/antigravity.py +61 -0
- tokenkick/app_commands.py +585 -0
- tokenkick/app_mode.py +101 -0
- tokenkick/claude_setup.py +53 -0
- tokenkick/cli.py +13785 -0
- tokenkick/codex_surface_patterns.py +478 -0
- tokenkick/codex_surface_stats.py +765 -0
- tokenkick/codexbar_source.py +881 -0
- tokenkick/consent.py +89 -0
- tokenkick/direct.py +310 -0
- tokenkick/discovery.py +845 -0
- tokenkick/doctor.py +1425 -0
- tokenkick/interactive.py +3163 -0
- tokenkick/kicker.py +669 -0
- tokenkick/mcp_core.py +1372 -0
- tokenkick/mcp_server.py +444 -0
- tokenkick/mcp_setup.py +784 -0
- tokenkick/migrations.py +1041 -0
- tokenkick/models.py +1251 -0
- tokenkick/notifier.py +398 -0
- tokenkick/orchestration.py +1568 -0
- tokenkick/recovery_hints.py +94 -0
- tokenkick/reservation_advisories.py +374 -0
- tokenkick/reset_calendar.py +526 -0
- tokenkick/reset_defense.py +1043 -0
- tokenkick/scheduling.py +844 -0
- tokenkick/source_utils.py +112 -0
- tokenkick/sources.py +2343 -0
- tokenkick/state_io.py +110 -0
- tokenkick/status_cache.py +1037 -0
- tokenkick/status_rendering.py +929 -0
- tokenkick/telegram_remote.py +594 -0
- tokenkick/versioning.py +70 -0
- tokenkick-1.0.0.dist-info/METADATA +149 -0
- tokenkick-1.0.0.dist-info/RECORD +39 -0
- tokenkick-1.0.0.dist-info/WHEEL +4 -0
- tokenkick-1.0.0.dist-info/entry_points.txt +4 -0
- tokenkick-1.0.0.dist-info/licenses/LICENSE +21 -0
tokenkick/__init__.py
ADDED
tokenkick/antigravity.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Shared Antigravity process detection helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_process_line(line: str) -> tuple[int, str] | None:
|
|
11
|
+
stripped = line.strip()
|
|
12
|
+
if not stripped:
|
|
13
|
+
return None
|
|
14
|
+
parts = stripped.split(maxsplit=1)
|
|
15
|
+
if len(parts) != 2:
|
|
16
|
+
return None
|
|
17
|
+
try:
|
|
18
|
+
return int(parts[0]), parts[1]
|
|
19
|
+
except ValueError:
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_language_server_command(command: str) -> bool:
|
|
24
|
+
return bool(re.search(r"(?:^|[/\\])language_server(?:_macos)?(?:\s|$)", command.lower()))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_antigravity_language_server(command: str) -> bool:
|
|
28
|
+
lower = command.lower()
|
|
29
|
+
return is_language_server_command(lower) and (
|
|
30
|
+
"--app_data_dir" in lower or "/antigravity/" in lower or "\\antigravity\\" in lower
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def lsof_binary() -> str | None:
|
|
35
|
+
return next(
|
|
36
|
+
(candidate for candidate in ["/usr/sbin/lsof", "/usr/bin/lsof", shutil.which("lsof")] if candidate),
|
|
37
|
+
None,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_lsof_listening_ports(output: str) -> list[int]:
|
|
42
|
+
ports: set[int] = set()
|
|
43
|
+
for match in re.finditer(r":(\d+)\s+\(LISTEN\)", output):
|
|
44
|
+
ports.add(int(match.group(1)))
|
|
45
|
+
return sorted(ports)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def listening_ports_for_pid(pid: int, *, timeout_seconds: float = 2.0) -> list[int]:
|
|
49
|
+
lsof = lsof_binary()
|
|
50
|
+
if not lsof:
|
|
51
|
+
return []
|
|
52
|
+
try:
|
|
53
|
+
result = subprocess.run(
|
|
54
|
+
[lsof, "-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", str(pid)],
|
|
55
|
+
capture_output=True,
|
|
56
|
+
text=True,
|
|
57
|
+
timeout=timeout_seconds,
|
|
58
|
+
)
|
|
59
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
60
|
+
return []
|
|
61
|
+
return parse_lsof_listening_ports(result.stdout)
|
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
"""`tk app` — JSON-first commands consumed by the native macOS app.
|
|
2
|
+
|
|
3
|
+
These commands always reserve stdout for JSON (envelopes or JSON-lines),
|
|
4
|
+
regardless of TK_APP_MODE; human-readable side output goes to stderr.
|
|
5
|
+
Provider logic stays in the core helpers — this module only assembles
|
|
6
|
+
payloads.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from dataclasses import replace
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import click
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
|
|
25
|
+
from .app_mode import (
|
|
26
|
+
ERROR_STATE_FILE,
|
|
27
|
+
ERROR_USAGE,
|
|
28
|
+
app_envelope,
|
|
29
|
+
app_mode_enabled,
|
|
30
|
+
emit_app_error,
|
|
31
|
+
emit_app_event,
|
|
32
|
+
emit_app_json,
|
|
33
|
+
emit_app_success,
|
|
34
|
+
)
|
|
35
|
+
from . import models as _models
|
|
36
|
+
from .mcp_setup import MCPSetupError, MCPSetupManager
|
|
37
|
+
from .models import StateFileError
|
|
38
|
+
from .versioning import installed_version
|
|
39
|
+
|
|
40
|
+
SNAPSHOT_RESET_EVENT_HOURS = 48
|
|
41
|
+
EXTERNAL_TK_VERSION_TIMEOUT_SECONDS = 10
|
|
42
|
+
PROVIDER_CLI_NAMES = ("codex", "claude", "gemini")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _cli():
|
|
46
|
+
from . import cli
|
|
47
|
+
|
|
48
|
+
return cli
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@contextmanager
|
|
52
|
+
def _stdout_reserved_for_json():
|
|
53
|
+
"""Route the shared console to stderr so app command stdout stays JSON-only."""
|
|
54
|
+
cli = _cli()
|
|
55
|
+
previous = cli.console
|
|
56
|
+
if not app_mode_enabled():
|
|
57
|
+
cli.console = Console(width=120, stderr=True)
|
|
58
|
+
try:
|
|
59
|
+
yield
|
|
60
|
+
finally:
|
|
61
|
+
cli.console = previous
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@click.group("app")
|
|
65
|
+
def app_group():
|
|
66
|
+
"""JSON-first commands for the native TokenKick app."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# tk app snapshot
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def _external_tk_info(*, probe_version: bool = True) -> dict | None:
|
|
74
|
+
path = shutil.which("tk")
|
|
75
|
+
if not path:
|
|
76
|
+
return None
|
|
77
|
+
resolved = Path(path).resolve()
|
|
78
|
+
current = Path(sys.argv[0]).resolve() if sys.argv and sys.argv[0] else None
|
|
79
|
+
is_current = current is not None and resolved == current
|
|
80
|
+
info: dict = {
|
|
81
|
+
"path": str(resolved),
|
|
82
|
+
"is_current_runtime": is_current,
|
|
83
|
+
"version": installed_version() if is_current else None,
|
|
84
|
+
}
|
|
85
|
+
if is_current or not probe_version:
|
|
86
|
+
return info
|
|
87
|
+
try:
|
|
88
|
+
result = subprocess.run(
|
|
89
|
+
[path, "--version"],
|
|
90
|
+
capture_output=True,
|
|
91
|
+
text=True,
|
|
92
|
+
timeout=EXTERNAL_TK_VERSION_TIMEOUT_SECONDS,
|
|
93
|
+
env={**os.environ, "TK_NO_INTERACTIVE": "1"},
|
|
94
|
+
)
|
|
95
|
+
except (OSError, subprocess.SubprocessError):
|
|
96
|
+
return info
|
|
97
|
+
match = re.search(r"version\s+(\S+)", result.stdout or "")
|
|
98
|
+
if match:
|
|
99
|
+
info["version"] = match.group(1)
|
|
100
|
+
return info
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _snapshot_status_section(cli, config) -> tuple[dict, list, dict, list[str]]:
|
|
104
|
+
warnings: list[str] = []
|
|
105
|
+
cached = cli._load_status_cache(config)
|
|
106
|
+
if cached is None:
|
|
107
|
+
warnings.append("No readable status cache; run setup or a status refresh.")
|
|
108
|
+
empty = {
|
|
109
|
+
"cached": False,
|
|
110
|
+
"cached_at": None,
|
|
111
|
+
"refresh_error": None,
|
|
112
|
+
"refresh_in_progress": cli._status_refresh_lock_active(),
|
|
113
|
+
"schema_version": 1,
|
|
114
|
+
"accounts": [],
|
|
115
|
+
}
|
|
116
|
+
return empty, [], {}, warnings
|
|
117
|
+
accounts, statuses, cache_entries = cached
|
|
118
|
+
payload = cli._status_json_payload(
|
|
119
|
+
accounts=accounts,
|
|
120
|
+
statuses=statuses,
|
|
121
|
+
metadata_accounts=accounts,
|
|
122
|
+
metadata_statuses=statuses,
|
|
123
|
+
cached=True,
|
|
124
|
+
refresh_error=None,
|
|
125
|
+
config=config,
|
|
126
|
+
cache_entries=cache_entries,
|
|
127
|
+
)
|
|
128
|
+
statuses_by_key = cli._cache_statuses_by_key_from_pairs(accounts, statuses)
|
|
129
|
+
return payload, accounts, statuses_by_key, warnings
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_app_snapshot() -> tuple[dict, list[str]]:
|
|
133
|
+
cli = _cli()
|
|
134
|
+
warnings: list[str] = []
|
|
135
|
+
now = datetime.now(timezone.utc)
|
|
136
|
+
config = cli.Config.load()
|
|
137
|
+
|
|
138
|
+
daemon = cli._daemon_status_payload(config)
|
|
139
|
+
if daemon["stale_pidfile"]:
|
|
140
|
+
warnings.append("Daemon pidfile is stale; the daemon is not running.")
|
|
141
|
+
if daemon["running"] and daemon["version_match"] is False:
|
|
142
|
+
warnings.append(
|
|
143
|
+
f"Daemon runs v{daemon['version']} but v{daemon['installed_version']} is installed; "
|
|
144
|
+
"restart the daemon."
|
|
145
|
+
)
|
|
146
|
+
if daemon["running"] and daemon.get("executable_match") is False:
|
|
147
|
+
warnings.append(
|
|
148
|
+
"Daemon is running from a different TokenKick executable; "
|
|
149
|
+
"use TokenKick.app daemon management to take over or repair it."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
external_tk = _external_tk_info()
|
|
153
|
+
if (
|
|
154
|
+
external_tk is not None
|
|
155
|
+
and not external_tk["is_current_runtime"]
|
|
156
|
+
and external_tk["version"] is not None
|
|
157
|
+
and external_tk["version"] != installed_version()
|
|
158
|
+
):
|
|
159
|
+
warnings.append(
|
|
160
|
+
f"External tk v{external_tk['version']} on PATH differs from this runtime "
|
|
161
|
+
f"v{installed_version()}."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
status_payload, accounts, statuses_by_key, status_warnings = _snapshot_status_section(cli, config)
|
|
165
|
+
warnings.extend(status_warnings)
|
|
166
|
+
|
|
167
|
+
pending = cli.load_pending_kicks(now)
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
advisories = [
|
|
171
|
+
advisory.to_dict()
|
|
172
|
+
for advisory in cli.build_reservation_advisories(
|
|
173
|
+
list(accounts) or list(config.accounts),
|
|
174
|
+
statuses_by_key,
|
|
175
|
+
pending,
|
|
176
|
+
now=now,
|
|
177
|
+
)
|
|
178
|
+
]
|
|
179
|
+
except Exception as exc: # noqa: BLE001 — advisories must not break the snapshot
|
|
180
|
+
advisories = []
|
|
181
|
+
warnings.append(f"Reservation advisories unavailable: {exc}")
|
|
182
|
+
|
|
183
|
+
reset_observations = [
|
|
184
|
+
event.to_dict() for event in cli.recent_reset_events(hours=SNAPSHOT_RESET_EVENT_HOURS)
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
notifications = {
|
|
188
|
+
"enabled": config.notifications.enabled,
|
|
189
|
+
"backends": cli._configured_notification_backends(config.notifications),
|
|
190
|
+
"destination": cli._notification_destination_display(config.notifications),
|
|
191
|
+
"accounts": [
|
|
192
|
+
{
|
|
193
|
+
"label": account.label,
|
|
194
|
+
"provider": account.provider,
|
|
195
|
+
"notifications_enabled": account.notifications_enabled,
|
|
196
|
+
"backends": account.notification_backends,
|
|
197
|
+
"route": cli._notification_route_display(account, config.notifications),
|
|
198
|
+
}
|
|
199
|
+
for account in config.accounts
|
|
200
|
+
],
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
schedule = {
|
|
204
|
+
"enabled": config.schedule.enabled,
|
|
205
|
+
"timezone": config.schedule.timezone,
|
|
206
|
+
"scheduling_target": config.schedule.scheduling_target,
|
|
207
|
+
"default": config.schedule.default.to_dict(),
|
|
208
|
+
"accounts": {
|
|
209
|
+
label: value.to_dict()
|
|
210
|
+
for label, value in sorted(config.schedule.accounts.items())
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
update = cli._update_status_payload()
|
|
215
|
+
|
|
216
|
+
payload = {
|
|
217
|
+
"generated_at": now.isoformat(),
|
|
218
|
+
"core": {
|
|
219
|
+
"version": installed_version(),
|
|
220
|
+
"executable": sys.argv[0] if sys.argv else None,
|
|
221
|
+
"python_executable": sys.executable,
|
|
222
|
+
"python_version": platform.python_version(),
|
|
223
|
+
"app_mode": app_mode_enabled(),
|
|
224
|
+
},
|
|
225
|
+
"runtime": {
|
|
226
|
+
"external_tk": external_tk,
|
|
227
|
+
},
|
|
228
|
+
"paths": {
|
|
229
|
+
"config_dir": str(cli.CONFIG_DIR),
|
|
230
|
+
"config_file": str(cli.CONFIG_FILE),
|
|
231
|
+
"status_cache_file": str(cli.STATUS_CACHE_FILE),
|
|
232
|
+
"daemon_pidfile": str(cli.DAEMON_PID_FILE),
|
|
233
|
+
"daemon_log_file": str(cli.DAEMON_LOG_FILE),
|
|
234
|
+
"history_file": str(_models.HISTORY_FILE),
|
|
235
|
+
},
|
|
236
|
+
"daemon": daemon,
|
|
237
|
+
"status": status_payload,
|
|
238
|
+
"pending_kicks": cli._pending_kicks_payload(pending),
|
|
239
|
+
"schedule": schedule,
|
|
240
|
+
"advisories": advisories,
|
|
241
|
+
"reset_observations": reset_observations,
|
|
242
|
+
"notifications": notifications,
|
|
243
|
+
"codex_strategy": cli._codex_burst_ladder_status_payload(config),
|
|
244
|
+
"update": update,
|
|
245
|
+
}
|
|
246
|
+
return payload, warnings
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@app_group.command("snapshot")
|
|
250
|
+
@click.option("--json-output", "as_json", is_flag=True, default=False, help="Output as JSON (always on)")
|
|
251
|
+
def app_snapshot(as_json: bool):
|
|
252
|
+
"""One-call state snapshot for the native app."""
|
|
253
|
+
del as_json # snapshot output is always JSON
|
|
254
|
+
with _stdout_reserved_for_json():
|
|
255
|
+
payload, warnings = build_app_snapshot()
|
|
256
|
+
emit_app_success(payload, warnings=warnings)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# tk app setup
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
def _run_app_setup(emit) -> tuple[dict, list[str]]:
|
|
264
|
+
"""Non-interactive setup mirroring `tk setup`: discover, save, no prompts, no daemon."""
|
|
265
|
+
cli = _cli()
|
|
266
|
+
warnings: list[str] = []
|
|
267
|
+
emit("setup_started", version=installed_version())
|
|
268
|
+
|
|
269
|
+
existing = cli.Config.load()
|
|
270
|
+
emit("config_loaded", accounts=len(existing.accounts))
|
|
271
|
+
|
|
272
|
+
emit("progress", message="Checking saved account migrations")
|
|
273
|
+
existing = cli._repair_codex_home_identity_drift_if_needed(
|
|
274
|
+
cli._migrate_codex_home_keys_if_needed(existing)
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
emit("progress", message="Discovering accounts and reading status")
|
|
278
|
+
accounts, statuses, _discovered, summary, new_accounts = cli._load_account_status_pairs(
|
|
279
|
+
existing,
|
|
280
|
+
prepare_claude_setup=True,
|
|
281
|
+
)
|
|
282
|
+
emit("discovery_completed", summary=summary, accounts=len(accounts))
|
|
283
|
+
|
|
284
|
+
if not accounts:
|
|
285
|
+
warnings.append(summary)
|
|
286
|
+
warnings.append("Log in with Codex/CodexBar, then run setup again.")
|
|
287
|
+
return (
|
|
288
|
+
{
|
|
289
|
+
"summary": summary,
|
|
290
|
+
"config_saved": False,
|
|
291
|
+
"config_path": str(cli.CONFIG_FILE),
|
|
292
|
+
"accounts": [],
|
|
293
|
+
"new_accounts": [],
|
|
294
|
+
"hidden_duplicate_labels": [],
|
|
295
|
+
"status": None,
|
|
296
|
+
},
|
|
297
|
+
warnings,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
emit("progress", message="Checking duplicate and unhealthy homes")
|
|
301
|
+
setup_accounts = cli._with_setup_auto_kick_defaults(accounts, existing)
|
|
302
|
+
setup_accounts, hidden_duplicate_labels = cli._hide_unusable_duplicate_codex_homes(
|
|
303
|
+
setup_accounts,
|
|
304
|
+
statuses,
|
|
305
|
+
existing,
|
|
306
|
+
)
|
|
307
|
+
cli._apply_claude_direct_usage_setup_default(existing, accounts)
|
|
308
|
+
|
|
309
|
+
config = replace(existing, accounts=setup_accounts)
|
|
310
|
+
cli._migrate_pending_kick_keys(existing.accounts, setup_accounts)
|
|
311
|
+
config.save()
|
|
312
|
+
cli._save_status_cache(
|
|
313
|
+
setup_accounts,
|
|
314
|
+
cli._cache_statuses_by_key_from_pairs(setup_accounts, statuses),
|
|
315
|
+
)
|
|
316
|
+
emit("config_saved", path=str(cli.CONFIG_FILE), accounts=len(setup_accounts))
|
|
317
|
+
|
|
318
|
+
for identity, _group in cli._duplicate_codex_home_groups(setup_accounts):
|
|
319
|
+
warnings.append(
|
|
320
|
+
f"Multiple Codex homes found for {identity}; only usable homes should auto-kick."
|
|
321
|
+
)
|
|
322
|
+
if hidden_duplicate_labels:
|
|
323
|
+
warnings.append(
|
|
324
|
+
"Hidden unusable duplicate Codex home(s): "
|
|
325
|
+
+ ", ".join(hidden_duplicate_labels)
|
|
326
|
+
+ ". They remain saved."
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
status_payload = cli._status_json_payload(
|
|
330
|
+
accounts=setup_accounts,
|
|
331
|
+
statuses=statuses,
|
|
332
|
+
metadata_accounts=setup_accounts,
|
|
333
|
+
metadata_statuses=statuses,
|
|
334
|
+
cached=False,
|
|
335
|
+
refresh_error=None,
|
|
336
|
+
config=config,
|
|
337
|
+
cache_entries={},
|
|
338
|
+
)
|
|
339
|
+
payload = {
|
|
340
|
+
"summary": summary,
|
|
341
|
+
"config_saved": True,
|
|
342
|
+
"config_path": str(cli.CONFIG_FILE),
|
|
343
|
+
"accounts": [cli._account_detail_payload(account) for account in setup_accounts],
|
|
344
|
+
"new_accounts": [account.label for account in new_accounts],
|
|
345
|
+
"hidden_duplicate_labels": hidden_duplicate_labels,
|
|
346
|
+
"status": status_payload,
|
|
347
|
+
}
|
|
348
|
+
return payload, warnings
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@app_group.command("setup")
|
|
352
|
+
@click.option("--json-lines", "json_lines", is_flag=True, default=False, help="Stream JSON-lines (always on)")
|
|
353
|
+
def app_setup(json_lines: bool):
|
|
354
|
+
"""Non-interactive setup with JSON-lines progress for the native app."""
|
|
355
|
+
del json_lines # setup output is always JSON-lines
|
|
356
|
+
cli = _cli()
|
|
357
|
+
previous_callback = cli._SETUP_PROGRESS_CALLBACK
|
|
358
|
+
|
|
359
|
+
def progress_callback(message: str | None) -> None:
|
|
360
|
+
if message:
|
|
361
|
+
emit_app_event("progress", message=message)
|
|
362
|
+
|
|
363
|
+
def emit(event: str, **fields) -> None:
|
|
364
|
+
emit_app_event(event, **fields)
|
|
365
|
+
|
|
366
|
+
cli._SETUP_PROGRESS_CALLBACK = progress_callback
|
|
367
|
+
try:
|
|
368
|
+
with _stdout_reserved_for_json():
|
|
369
|
+
payload, warnings = _run_app_setup(emit)
|
|
370
|
+
except KeyboardInterrupt:
|
|
371
|
+
emit_app_json(
|
|
372
|
+
{
|
|
373
|
+
"event": "setup_cancelled",
|
|
374
|
+
**app_envelope(
|
|
375
|
+
ok=False,
|
|
376
|
+
error_code="cancelled",
|
|
377
|
+
message="Setup was cancelled before completion.",
|
|
378
|
+
),
|
|
379
|
+
},
|
|
380
|
+
compact=True,
|
|
381
|
+
)
|
|
382
|
+
sys.exit(130)
|
|
383
|
+
except StateFileError as exc:
|
|
384
|
+
emit_app_json(
|
|
385
|
+
{
|
|
386
|
+
"event": "setup_failed",
|
|
387
|
+
**app_envelope(ok=False, error_code=ERROR_STATE_FILE, message=str(exc)),
|
|
388
|
+
},
|
|
389
|
+
compact=True,
|
|
390
|
+
)
|
|
391
|
+
sys.exit(1)
|
|
392
|
+
except Exception as exc: # noqa: BLE001 — setup must end with a JSON record
|
|
393
|
+
emit_app_json(
|
|
394
|
+
{
|
|
395
|
+
"event": "setup_failed",
|
|
396
|
+
**app_envelope(
|
|
397
|
+
ok=False,
|
|
398
|
+
error_code="setup_failed",
|
|
399
|
+
message=f"{exc.__class__.__name__}: {exc}",
|
|
400
|
+
),
|
|
401
|
+
},
|
|
402
|
+
compact=True,
|
|
403
|
+
)
|
|
404
|
+
sys.exit(1)
|
|
405
|
+
finally:
|
|
406
|
+
cli._SETUP_PROGRESS_CALLBACK = previous_callback
|
|
407
|
+
emit_app_json(
|
|
408
|
+
{
|
|
409
|
+
"event": "setup_completed",
|
|
410
|
+
**app_envelope(ok=True, payload=payload, warnings=warnings),
|
|
411
|
+
},
|
|
412
|
+
compact=True,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ---------------------------------------------------------------------------
|
|
417
|
+
# tk app doctor
|
|
418
|
+
# ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
def _state_dir_writable(config_dir: Path) -> tuple[bool, str | None]:
|
|
421
|
+
try:
|
|
422
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
423
|
+
probe = config_dir / ".tk-app-doctor-probe"
|
|
424
|
+
probe.write_text("ok")
|
|
425
|
+
probe.unlink()
|
|
426
|
+
except OSError as exc:
|
|
427
|
+
return False, str(exc)
|
|
428
|
+
return True, None
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def build_app_doctor() -> tuple[dict, list[str]]:
|
|
432
|
+
cli = _cli()
|
|
433
|
+
warnings: list[str] = []
|
|
434
|
+
|
|
435
|
+
provider_clis = {}
|
|
436
|
+
for name in PROVIDER_CLI_NAMES:
|
|
437
|
+
path = shutil.which(name)
|
|
438
|
+
provider_clis[name] = {"found": path is not None, "path": path}
|
|
439
|
+
if not provider_clis["codex"]["found"] and not provider_clis["claude"]["found"]:
|
|
440
|
+
warnings.append(
|
|
441
|
+
"Neither codex nor claude was found on PATH; provider discovery will fail "
|
|
442
|
+
"from this environment."
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
config_dir = Path(str(cli.CONFIG_DIR))
|
|
446
|
+
writable, write_error = _state_dir_writable(config_dir)
|
|
447
|
+
if not writable:
|
|
448
|
+
warnings.append(f"State directory is not writable: {write_error}")
|
|
449
|
+
|
|
450
|
+
config_loadable = True
|
|
451
|
+
config_error: str | None = None
|
|
452
|
+
config = None
|
|
453
|
+
try:
|
|
454
|
+
config = cli.Config.load()
|
|
455
|
+
except StateFileError as exc:
|
|
456
|
+
config_loadable = False
|
|
457
|
+
config_error = str(exc)
|
|
458
|
+
warnings.append(f"Config could not be loaded: {exc}")
|
|
459
|
+
|
|
460
|
+
daemon = cli._daemon_status_payload(config) if config is not None else None
|
|
461
|
+
|
|
462
|
+
doctor_report = None
|
|
463
|
+
if config_loadable:
|
|
464
|
+
try:
|
|
465
|
+
report = cli.build_doctor_report()
|
|
466
|
+
doctor_report = report.to_dict()
|
|
467
|
+
for check in report.checks:
|
|
468
|
+
if check.level == "FAIL":
|
|
469
|
+
warnings.append(f"doctor: {check.code}: {check.message}")
|
|
470
|
+
except (StateFileError, ValueError) as exc:
|
|
471
|
+
warnings.append(f"Doctor report unavailable: {exc}")
|
|
472
|
+
|
|
473
|
+
payload = {
|
|
474
|
+
"environment": {
|
|
475
|
+
"executable": sys.argv[0] if sys.argv else None,
|
|
476
|
+
"python_executable": sys.executable,
|
|
477
|
+
"python_version": platform.python_version(),
|
|
478
|
+
"platform": sys.platform,
|
|
479
|
+
"cwd": os.getcwd(),
|
|
480
|
+
"path_env": os.environ.get("PATH", ""),
|
|
481
|
+
"app_mode": app_mode_enabled(),
|
|
482
|
+
"core_version": installed_version(),
|
|
483
|
+
},
|
|
484
|
+
"provider_clis": provider_clis,
|
|
485
|
+
"state": {
|
|
486
|
+
"config_dir": str(config_dir),
|
|
487
|
+
"config_dir_exists": config_dir.exists(),
|
|
488
|
+
"config_dir_writable": writable,
|
|
489
|
+
"config_file": str(cli.CONFIG_FILE),
|
|
490
|
+
"config_file_exists": Path(str(cli.CONFIG_FILE)).exists(),
|
|
491
|
+
"config_loadable": config_loadable,
|
|
492
|
+
"config_error": config_error,
|
|
493
|
+
},
|
|
494
|
+
"daemon": daemon,
|
|
495
|
+
"doctor": doctor_report,
|
|
496
|
+
}
|
|
497
|
+
return payload, warnings
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@app_group.command("doctor")
|
|
501
|
+
@click.option("--json-output", "as_json", is_flag=True, default=False, help="Output as JSON (always on)")
|
|
502
|
+
def app_doctor(as_json: bool):
|
|
503
|
+
"""App-environment diagnosis for the native app."""
|
|
504
|
+
del as_json # doctor output is always JSON
|
|
505
|
+
with _stdout_reserved_for_json():
|
|
506
|
+
payload, warnings = build_app_doctor()
|
|
507
|
+
emit_app_success(payload, warnings=warnings)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
# ---------------------------------------------------------------------------
|
|
511
|
+
# tk app mcp-* — JSON-first MCP setup commands
|
|
512
|
+
# ---------------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
def _app_mcp_manager() -> MCPSetupManager:
|
|
515
|
+
return MCPSetupManager()
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _app_mcp_mutation_error(operation: str) -> None:
|
|
519
|
+
emit_app_error(
|
|
520
|
+
ERROR_USAGE,
|
|
521
|
+
f"`tk app mcp-{operation}` requires --yes before it writes client config.",
|
|
522
|
+
payload={"operation": operation, "read_only": True},
|
|
523
|
+
)
|
|
524
|
+
sys.exit(2)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@app_group.command("mcp-status")
|
|
528
|
+
@click.option("--client", type=click.Choice(["all", "auto", "codex", "claude-desktop", "claude-code"]), default="all")
|
|
529
|
+
@click.option("--json-output", "as_json", is_flag=True, default=False, help="Output as JSON (always on)")
|
|
530
|
+
def app_mcp_status(client: str, as_json: bool):
|
|
531
|
+
"""Read MCP client setup status for the native app."""
|
|
532
|
+
del as_json
|
|
533
|
+
emit_app_success(_app_mcp_manager().status(client=client))
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
@app_group.command("mcp-install")
|
|
537
|
+
@click.option("--client", type=click.Choice(["all", "auto", "codex", "claude-desktop", "claude-code"]), default="all")
|
|
538
|
+
@click.option("--use-helper", is_flag=True, help="Use the stable TokenKick helper path")
|
|
539
|
+
@click.option("--yes", is_flag=True, help="Confirm writing MCP client config")
|
|
540
|
+
@click.option("--json-output", "as_json", is_flag=True, default=False, help="Output as JSON (always on)")
|
|
541
|
+
def app_mcp_install(client: str, use_helper: bool, yes: bool, as_json: bool):
|
|
542
|
+
"""Install MCP client config from app mode."""
|
|
543
|
+
del as_json
|
|
544
|
+
if not yes:
|
|
545
|
+
_app_mcp_mutation_error("install")
|
|
546
|
+
try:
|
|
547
|
+
emit_app_success(_app_mcp_manager().install(client=client, use_helper=use_helper))
|
|
548
|
+
except MCPSetupError as exc:
|
|
549
|
+
emit_app_error("mcp_setup_error", str(exc))
|
|
550
|
+
sys.exit(1)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
@app_group.command("mcp-repair")
|
|
554
|
+
@click.option("--client", type=click.Choice(["all", "auto", "codex", "claude-desktop", "claude-code"]), default="all")
|
|
555
|
+
@click.option("--use-helper", is_flag=True, help="Use the stable TokenKick helper path")
|
|
556
|
+
@click.option("--yes", is_flag=True, help="Confirm writing MCP client config")
|
|
557
|
+
@click.option("--json-output", "as_json", is_flag=True, default=False, help="Output as JSON (always on)")
|
|
558
|
+
def app_mcp_repair(client: str, use_helper: bool, yes: bool, as_json: bool):
|
|
559
|
+
"""Repair MCP client config from app mode."""
|
|
560
|
+
del as_json
|
|
561
|
+
if not yes:
|
|
562
|
+
_app_mcp_mutation_error("repair")
|
|
563
|
+
try:
|
|
564
|
+
emit_app_success(
|
|
565
|
+
_app_mcp_manager().install(client=client, use_helper=use_helper, repair_only=True)
|
|
566
|
+
)
|
|
567
|
+
except MCPSetupError as exc:
|
|
568
|
+
emit_app_error("mcp_setup_error", str(exc))
|
|
569
|
+
sys.exit(1)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
@app_group.command("mcp-remove")
|
|
573
|
+
@click.option("--client", type=click.Choice(["all", "auto", "codex", "claude-desktop", "claude-code"]), default="all")
|
|
574
|
+
@click.option("--yes", is_flag=True, help="Confirm removing MCP client config")
|
|
575
|
+
@click.option("--json-output", "as_json", is_flag=True, default=False, help="Output as JSON (always on)")
|
|
576
|
+
def app_mcp_remove(client: str, yes: bool, as_json: bool):
|
|
577
|
+
"""Remove MCP client config from app mode."""
|
|
578
|
+
del as_json
|
|
579
|
+
if not yes:
|
|
580
|
+
_app_mcp_mutation_error("remove")
|
|
581
|
+
try:
|
|
582
|
+
emit_app_success(_app_mcp_manager().remove(client=client))
|
|
583
|
+
except MCPSetupError as exc:
|
|
584
|
+
emit_app_error("mcp_setup_error", str(exc))
|
|
585
|
+
sys.exit(1)
|