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 ADDED
@@ -0,0 +1,8 @@
1
+ """TokenKick package."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("tokenkick")
7
+ except PackageNotFoundError:
8
+ __version__ = "unknown"
@@ -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)