command-gate 0.2.4__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.
Files changed (63) hide show
  1. cgate/__init__.py +26 -0
  2. cgate/__main__.py +8 -0
  3. cgate/_version.py +24 -0
  4. cgate/cli/__init__.py +1 -0
  5. cgate/cli/_console.py +17 -0
  6. cgate/cli/connections.py +212 -0
  7. cgate/cli/history.py +191 -0
  8. cgate/cli/install.py +182 -0
  9. cgate/cli/main.py +115 -0
  10. cgate/cli/mcp.py +197 -0
  11. cgate/cli/uninstall.py +403 -0
  12. cgate/cli/update.py +538 -0
  13. cgate/cli/watch.py +20 -0
  14. cgate/connections/__init__.py +1 -0
  15. cgate/connections/auth.py +93 -0
  16. cgate/connections/detect.py +78 -0
  17. cgate/connections/store.py +88 -0
  18. cgate/core/__init__.py +1 -0
  19. cgate/core/path_env.py +218 -0
  20. cgate/core/paths.py +35 -0
  21. cgate/core/update_log.py +36 -0
  22. cgate/db/__init__.py +1 -0
  23. cgate/db/batches.py +111 -0
  24. cgate/db/commands.py +191 -0
  25. cgate/db/connection.py +104 -0
  26. cgate/db/mode.py +74 -0
  27. cgate/db/rows.py +99 -0
  28. cgate/db/schema.py +54 -0
  29. cgate/db/server_settings.py +105 -0
  30. cgate/db/types.py +77 -0
  31. cgate/executor/__init__.py +7 -0
  32. cgate/executor/base.py +71 -0
  33. cgate/executor/selector.py +61 -0
  34. cgate/executor/ssh.py +157 -0
  35. cgate/executor/winrm.py +129 -0
  36. cgate/helper/__init__.py +10 -0
  37. cgate/helper/__main__.py +112 -0
  38. cgate/helper/waiter.py +123 -0
  39. cgate/mcp_installer.py +161 -0
  40. cgate/mcp_server/__init__.py +6 -0
  41. cgate/mcp_server/__main__.py +6 -0
  42. cgate/mcp_server/auto_resolution.py +80 -0
  43. cgate/mcp_server/server.py +271 -0
  44. cgate/mcp_server/tools.py +351 -0
  45. cgate/risk.py +129 -0
  46. cgate/update.py +713 -0
  47. cgate/watch/__init__.py +7 -0
  48. cgate/watch/app.py +560 -0
  49. cgate/watch/approval.py +237 -0
  50. cgate/watch/command_detail_modal.py +68 -0
  51. cgate/watch/history_modal.py +242 -0
  52. cgate/watch/mode_modal.py +110 -0
  53. cgate/watch/queue.py +106 -0
  54. cgate/watch/render.py +156 -0
  55. cgate/watch/server_settings_modal.py +179 -0
  56. cgate/watch/session.py +40 -0
  57. cgate/watch/theme.py +32 -0
  58. cgate/watch/widgets.py +35 -0
  59. command_gate-0.2.4.dist-info/METADATA +204 -0
  60. command_gate-0.2.4.dist-info/RECORD +63 -0
  61. command_gate-0.2.4.dist-info/WHEEL +4 -0
  62. command_gate-0.2.4.dist-info/entry_points.txt +2 -0
  63. command_gate-0.2.4.dist-info/licenses/LICENSE +21 -0
cgate/cli/uninstall.py ADDED
@@ -0,0 +1,403 @@
1
+ """Remove cgate from this machine: binary, data, MCP registrations, or all."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Annotated
12
+
13
+ import typer
14
+ from rich.console import Console
15
+
16
+ from cgate.connections.auth import remove_credential
17
+ from cgate.connections.store import ConnectionsRepo
18
+ from cgate.core.paths import data_dir, db_path
19
+ from cgate.core.update_log import append_log
20
+ from cgate.db.connection import Database, init_database
21
+ from cgate.db.types import Connection
22
+ from cgate.mcp_installer import ClientInstall, detect_clients, is_registered, unregister
23
+ from cgate.update import (
24
+ current_binary_path,
25
+ ensure_helper_binary,
26
+ find_blocking_processes,
27
+ find_mcp_serving_pids,
28
+ helper_binary_path,
29
+ kill_process,
30
+ spawn_helper,
31
+ )
32
+
33
+ # Brief delay after killing a blocking process so Windows releases the file
34
+ # lock before we retry the delete. Same value `update apply` uses for the
35
+ # analogous kill-and-retry swap (cli/update.py).
36
+ _KILL_SETTLE_SECONDS: float = 1.0
37
+
38
+ console = Console()
39
+
40
+
41
+ def uninstall_cmd(
42
+ *,
43
+ binary: Annotated[
44
+ bool,
45
+ typer.Option(
46
+ "--binary",
47
+ help="Only remove the cgate binary (skips data and MCP cleanup).",
48
+ ),
49
+ ] = False,
50
+ data: Annotated[
51
+ bool,
52
+ typer.Option(
53
+ "--data",
54
+ help="Only remove the data directory (DB + OS keyring entries).",
55
+ ),
56
+ ] = False,
57
+ mcp: Annotated[
58
+ bool,
59
+ typer.Option(
60
+ "--mcp",
61
+ help="Only unregister cgate from detected IA clients.",
62
+ ),
63
+ ] = False,
64
+ yes: Annotated[
65
+ bool,
66
+ typer.Option(
67
+ "--yes",
68
+ "-y",
69
+ help="Skip every confirmation prompt.",
70
+ ),
71
+ ] = False,
72
+ ) -> None:
73
+ """Remove cgate from this machine.
74
+
75
+ Without any scope flag, removes everything. Combine flags to limit
76
+ scope (e.g. `--data --yes` for an unattended reset). Without --yes,
77
+ each destructive step prompts individually so partial failures leave
78
+ the rest of the install recoverable.
79
+ """
80
+ do_all = not (binary or data or mcp)
81
+ targets = {
82
+ "binary": binary or do_all,
83
+ "data": data or do_all,
84
+ "mcp": mcp or do_all,
85
+ }
86
+
87
+ binary_path = current_binary_path()
88
+ data_path = data_dir()
89
+ mcp_clients: list[ClientInstall] = (
90
+ [c for c in detect_clients() if is_registered(c)] if targets["mcp"] else []
91
+ )
92
+
93
+ # Each target is only actionable if the user asked for it AND there is
94
+ # something to act on. Without this distinction `cgate uninstall` in a
95
+ # dev environment would silently print three "nothing here" lines
96
+ # instead of one concise "Nothing to do".
97
+ binary_actionable = (
98
+ targets["binary"] and binary_path is not None and binary_path.exists()
99
+ )
100
+ data_actionable = targets["data"] and data_path.exists()
101
+ mcp_actionable = targets["mcp"] and bool(mcp_clients)
102
+
103
+ console.print("[bold]Will remove:[/bold]")
104
+ if targets["mcp"]:
105
+ if mcp_actionable:
106
+ console.print(f" MCP: unregister from {len(mcp_clients)} client(s):")
107
+ for c in mcp_clients:
108
+ console.print(f" - {c.label}")
109
+ else:
110
+ console.print(" MCP: [dim](no clients currently registered)[/dim]")
111
+ if targets["data"]:
112
+ if data_actionable:
113
+ console.print(f" Data: [bold]{data_path}[/bold]")
114
+ else:
115
+ console.print(f" Data: [dim]{data_path} (does not exist)[/dim]")
116
+ if targets["binary"]:
117
+ if binary_path and binary_path.exists():
118
+ console.print(f" Binary: [bold]{binary_path}[/bold]")
119
+ elif binary_path:
120
+ console.print(f" Binary: [dim]{binary_path} (already gone)[/dim]")
121
+ else:
122
+ console.print(
123
+ " Binary: [dim]not running from a frozen PyInstaller binary[/dim]"
124
+ )
125
+
126
+ # Order: MCP first (external state, easy to redo), then data (local state),
127
+ # then binary last (the running program itself). Each helper handles its
128
+ # own "not actionable" state (no clients, missing dir, dev env) so the
129
+ # output stays informative even when one or more scopes are no-ops.
130
+ mcp_ok = True
131
+ data_ok = True
132
+ binary_status = "not_applicable"
133
+ if targets["mcp"]:
134
+ mcp_ok = _uninstall_mcp(mcp_clients, yes)
135
+ if targets["data"]:
136
+ data_ok = _uninstall_data(data_path, yes)
137
+ if targets["binary"]:
138
+ binary_status = _uninstall_binary(binary_path, yes)
139
+
140
+ # Only summarise as "Nothing to do" when the user did not request any
141
+ # specific scope (do_all path) AND none of the scopes were actionable.
142
+ # Otherwise the summary must reflect what each helper actually
143
+ # accomplished -- printing "Uninstall complete" regardless of per-step
144
+ # outcome previously hid real failures (e.g. a locked binary on
145
+ # Windows) behind a misleading green line.
146
+ explicit_flags = binary or data or mcp
147
+ if (
148
+ not explicit_flags
149
+ and not binary_actionable
150
+ and not data_actionable
151
+ and not mcp_actionable
152
+ ):
153
+ console.print("[yellow]Nothing to do.[/yellow]")
154
+ elif binary_status == "deferred":
155
+ console.print(
156
+ "[green]Uninstall complete[/green] [dim](the binary is locked by "
157
+ "this running process and will be deleted automatically a few "
158
+ "seconds after it exits -- no further action needed).[/dim]"
159
+ )
160
+ elif not mcp_ok or not data_ok or binary_status in ("failed", "skipped"):
161
+ console.print(
162
+ "[yellow]Uninstall finished with unresolved steps -- see warnings above.[/yellow]"
163
+ )
164
+ else:
165
+ console.print("[green]Uninstall complete.[/green]")
166
+
167
+
168
+ def _uninstall_mcp(clients: list[ClientInstall], yes: bool) -> bool:
169
+ """Unregister cgate from each detected IA client.
170
+
171
+ Returns False if any client was skipped or failed to unregister, so
172
+ the final summary can report unresolved steps instead of a blanket
173
+ "Uninstall complete.".
174
+ """
175
+ if not clients:
176
+ console.print(" [dim]No MCP clients to unregister.[/dim]")
177
+ return True
178
+ all_ok = True
179
+ for client in clients:
180
+ if not yes and not typer.confirm(
181
+ f"Unregister from {client.label}?", default=True
182
+ ):
183
+ console.print(f" [dim]Skipped {client.label}.[/dim]")
184
+ all_ok = False
185
+ continue
186
+ try:
187
+ _ = unregister(client)
188
+ console.print(f" Unregistered [bold]{client.label}[/bold].")
189
+ except OSError as exc:
190
+ console.print(
191
+ f" [red]Failed to unregister {client.label}:[/red] {exc}"
192
+ )
193
+ all_ok = False
194
+ return all_ok
195
+
196
+
197
+ def _uninstall_data(data_path: Path, yes: bool) -> bool:
198
+ """Remove the data directory, including keyring entries for its connections.
199
+
200
+ Returns False on skip or any failure, so the final summary can report
201
+ unresolved steps instead of a blanket "Uninstall complete.".
202
+ """
203
+ if not data_path.exists():
204
+ console.print(f" [dim]{data_path} already gone.[/dim]")
205
+ return True
206
+
207
+ if not yes and not typer.confirm(
208
+ f"Delete data directory {data_path}?", default=False
209
+ ):
210
+ console.print(" [dim]Skipped.[/dim]")
211
+ return False
212
+
213
+ ok = True
214
+
215
+ # Enumerate connections first so we can clean each connection's
216
+ # OS keyring entry before the DB row that names it disappears.
217
+ connections: list[Connection] = []
218
+ try:
219
+ db = Database(path=db_path())
220
+ init_database(db)
221
+ connections = ConnectionsRepo(db).list_all()
222
+ except Exception as exc: # noqa: BLE001 - listing must not block cleanup
223
+ console.print(
224
+ f" [yellow]Could not enumerate connections:[/yellow] {exc}"
225
+ )
226
+ ok = False
227
+
228
+ for conn in connections:
229
+ try:
230
+ remove_credential(conn.alias)
231
+ console.print(f" Removed keyring entry for [bold]{conn.alias}[/bold].")
232
+ except Exception as exc: # noqa: BLE001 - per-credential failures are non-fatal
233
+ console.print(
234
+ f" [yellow]Could not remove keyring for {conn.alias}:[/yellow] {exc}"
235
+ )
236
+ ok = False
237
+
238
+ try:
239
+ shutil.rmtree(data_path)
240
+ console.print(f" Removed [bold]{data_path}[/bold].")
241
+ except OSError as exc:
242
+ console.print(f" [red]Failed to remove {data_path}:[/red] {exc}")
243
+ ok = False
244
+
245
+ return ok
246
+
247
+
248
+ def _uninstall_binary(binary_path: Path | None, yes: bool) -> str:
249
+ """Delete the cgate binary.
250
+
251
+ Returns one of "removed", "deferred", "skipped", "already_gone",
252
+ "not_applicable", or "failed" so the caller can print an honest final
253
+ summary instead of a blanket "Uninstall complete" regardless of outcome.
254
+ """
255
+ if binary_path is None:
256
+ console.print(
257
+ " [dim]Not running from a PyInstaller binary; nothing to remove.[/dim]"
258
+ )
259
+ return "not_applicable"
260
+ if not binary_path.exists():
261
+ console.print(f" [dim]{binary_path} already gone.[/dim]")
262
+ return "already_gone"
263
+
264
+ if not yes and not typer.confirm(
265
+ f"Delete binary at {binary_path}?", default=False
266
+ ):
267
+ console.print(" [dim]Skipped.[/dim]")
268
+ return "skipped"
269
+
270
+ try:
271
+ binary_path.unlink()
272
+ console.print(f" Removed [bold]{binary_path}[/bold].")
273
+ _cleanup_helper_binary(binary_path)
274
+ return "removed"
275
+ except OSError as exc:
276
+ if sys.platform != "win32":
277
+ console.print(f" [red]Could not delete binary:[/red] {exc}")
278
+ return "failed"
279
+
280
+ # Windows only, direct unlink failed above. The running cgate.exe always
281
+ # holds a lock on its own image file while executing, so this is the
282
+ # expected case, not a genuine error -- the exact self-lock problem
283
+ # `update apply` already solves in cli/update.py. Reuse its blocker
284
+ # detection instead of guessing whether the lock is us or someone else.
285
+ self_pid = os.getpid()
286
+ other_blockers = find_blocking_processes(binary_path, exclude_pid=self_pid)
287
+
288
+ if other_blockers:
289
+ pid_list = ", ".join(str(pid) for pid in other_blockers)
290
+ console.print(
291
+ f" [yellow]Other running cgate processes are blocking the "
292
+ f"delete:[/yellow] PID(s) {pid_list}"
293
+ )
294
+ mcp_pids = find_mcp_serving_pids(other_blockers)
295
+ if mcp_pids:
296
+ console.print(
297
+ " [red]One of them appears to be serving a live MCP "
298
+ "session for an IA client (Claude Code / opencode / "
299
+ "Cursor). Killing it disconnects that session "
300
+ "immediately.[/red]"
301
+ )
302
+ proceed = yes or typer.confirm(
303
+ " Kill blocking process(es) and retry?", default=False
304
+ )
305
+ if not proceed:
306
+ console.print(
307
+ " [dim]Skipped. Close those processes and re-run "
308
+ "`cgate uninstall --binary` to finish.[/dim]"
309
+ )
310
+ return "skipped"
311
+ if any(kill_process(pid) for pid in other_blockers):
312
+ time.sleep(_KILL_SETTLE_SECONDS)
313
+ try:
314
+ binary_path.unlink()
315
+ console.print(f" Removed [bold]{binary_path}[/bold].")
316
+ _cleanup_helper_binary(binary_path)
317
+ return "removed"
318
+ except OSError:
319
+ pass # still locked (likely by us) -- fall through below
320
+
321
+ if _delete_via_helper_or_fallback(binary_path, wait_pids=[self_pid]):
322
+ console.print(
323
+ f" [yellow]{binary_path}[/yellow] is locked by this running "
324
+ "process. It will be deleted automatically a few seconds "
325
+ "after this command exits -- no further action needed."
326
+ )
327
+ _cleanup_helper_binary(binary_path)
328
+ return "deferred"
329
+
330
+ console.print(
331
+ " [red]Could not delete binary:[/red] locked by this running "
332
+ "process, and the background delete helper failed to start."
333
+ )
334
+ console.print(f' [dim]Close cgate and delete manually: del "{binary_path}"[/dim]')
335
+ return "failed"
336
+
337
+
338
+ def _cleanup_helper_binary(binary_path: Path) -> None:
339
+ """Best-effort removal of the sibling ``cgate-helper.exe``.
340
+
341
+ Called once the main binary is gone or on its way out. Never raises
342
+ and never affects the overall uninstall outcome -- this is
343
+ disk-clutter cleanup (the helper is an implementation detail the
344
+ user never installed by hand), not core functionality. If the helper
345
+ is still in use -- most likely because it's the very process
346
+ performing a deferred delete of ``binary_path`` right now -- fall
347
+ back to the ``cmd.exe`` shell-chain instead of another compiled
348
+ helper: ``cmd.exe`` isn't a PyInstaller ``--onefile`` binary, so it
349
+ has no self-lock problem to solve for a second time.
350
+ """
351
+ helper = helper_binary_path(binary_path)
352
+ if not helper.exists():
353
+ return
354
+ try:
355
+ helper.unlink()
356
+ except OSError:
357
+ _spawn_delayed_delete(helper)
358
+
359
+
360
+ def _delete_via_helper_or_fallback(target: Path, *, wait_pids: list[int]) -> bool:
361
+ """Prefer the compiled ``cgate-helper.exe`` to delete ``target``.
362
+
363
+ Falls back to the ``cmd.exe`` shell-chain when it isn't available or
364
+ fails to spawn. There's no in-flight ``Release`` here (unlike
365
+ ``update apply``, this isn't installing anything), so
366
+ ``ensure_helper_binary`` pairs the helper with the *currently
367
+ installed* version's own release tag instead of "latest".
368
+ """
369
+ helper = ensure_helper_binary(target)
370
+ if helper is not None:
371
+ wait_args = [arg for pid in wait_pids for arg in ("--wait-pid", str(pid))]
372
+ if spawn_helper(helper, "delete", "--target", str(target), *wait_args):
373
+ append_log(f"uninstall --binary: dispatched compiled helper {helper} for {target}")
374
+ return True
375
+ append_log("uninstall --binary: compiled helper spawn failed, falling back to shell-chain")
376
+ return _spawn_delayed_delete(target)
377
+
378
+
379
+ def _spawn_delayed_delete(target: Path) -> bool:
380
+ """Spawn a detached helper that deletes ``target`` a few seconds after this process exits.
381
+
382
+ Windows only -- mirrors ``_spawn_delayed_swap`` in cli/update.py, which
383
+ solves the identical self-lock problem for ``update apply``. ``cmd.exe``
384
+ is not ``cgate.exe`` so it never holds the lock our own process does;
385
+ the ``ping`` burns ~4s so our handle on the file is guaranteed closed
386
+ (process exited) by the time ``del`` runs.
387
+ """
388
+ try:
389
+ cmd_str = f'ping -n 5 127.0.0.1 > nul & del /F /Q "{target}"'
390
+ subprocess.Popen(
391
+ f'cmd.exe /c "{cmd_str}"',
392
+ # DETACHED_PROCESS | CREATE_NO_WINDOW, same combination
393
+ # cli/update.py uses and for the same reason: detach from our
394
+ # console AND suppress the window Windows would otherwise
395
+ # flash for cmd.exe/ping.exe.
396
+ creationflags=0x00000008 | 0x08000000,
397
+ stdout=subprocess.DEVNULL,
398
+ stderr=subprocess.DEVNULL,
399
+ close_fds=True,
400
+ )
401
+ except (subprocess.SubprocessError, OSError):
402
+ return False
403
+ return True