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/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """cgate: middleware and CLI between AI assistants and managed servers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ from cgate._version import __version__ as __version__
7
+ except ImportError:
8
+ # _version.py is generated by hatch-vcs (for source installs) and by
9
+ # scripts/_ensure_version.py (for PyInstaller binaries). When neither
10
+ # has run yet (e.g., a bare `python -c "import cgate"` from a fresh
11
+ # clone), fall back to git describe so the runtime version is still
12
+ # meaningful instead of crashing with a hard-coded stale value.
13
+ import subprocess
14
+ from pathlib import Path
15
+
16
+ try:
17
+ result = subprocess.run(
18
+ ["git", "describe", "--tags", "--abbrev=0", "--match=v*"],
19
+ cwd=Path(__file__).resolve().parents[2],
20
+ capture_output=True,
21
+ text=True,
22
+ check=True,
23
+ )
24
+ __version__ = result.stdout.strip().lstrip("v")
25
+ except (subprocess.CalledProcessError, FileNotFoundError, OSError):
26
+ __version__ = "0.0.0+unknown"
cgate/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Run command-gate as a Python module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from cgate.cli.main import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
cgate/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.2.4'
22
+ __version_tuple__ = version_tuple = (0, 2, 4)
23
+
24
+ __commit_id__ = commit_id = None
cgate/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from __future__ import annotations
cgate/cli/_console.py ADDED
@@ -0,0 +1,17 @@
1
+ """Shared console-interaction helpers for CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import sys
7
+
8
+
9
+ def should_pause(no_pause: bool) -> bool: # noqa: FBT001 -- CLI flags are plain bools
10
+ """Pause for human review unless --no-pause or stdin is not a TTY."""
11
+ return not no_pause and sys.stdin.isatty()
12
+
13
+
14
+ def wait_for_enter() -> None:
15
+ """Block until the user presses Enter; silently no-op on EOF (pipe/CI)."""
16
+ with contextlib.suppress(EOFError):
17
+ input("Press Enter to close...")
@@ -0,0 +1,212 @@
1
+ """CLI group for saved server connections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+
7
+ import typer
8
+ from click import Choice
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from cgate.connections.auth import (
13
+ get_credential,
14
+ is_kerberos_available,
15
+ remove_credential,
16
+ store_credential,
17
+ )
18
+ from cgate.connections.detect import (
19
+ AmbiguousHostError,
20
+ UnknownHostError,
21
+ probe_host,
22
+ )
23
+ from cgate.connections.store import ConnectionsRepo
24
+ from cgate.core.paths import db_path
25
+ from cgate.db.connection import Database, init_database
26
+ from cgate.db.types import ServerType
27
+
28
+ connections_app = typer.Typer(help="Manage saved server connections.")
29
+ console = Console()
30
+
31
+
32
+ def _db() -> Database:
33
+ """Open the local SQLite database and create its schema when absent."""
34
+ db = Database(path=db_path())
35
+ init_database(db)
36
+ return db
37
+
38
+
39
+ @connections_app.command("add")
40
+ def add(alias: str, hostname: str) -> None:
41
+ """Detect the server type, save the connection, and collect credentials."""
42
+ # Pre-check alias before doing any network probe or prompting for
43
+ # credentials. Without this, a duplicate alias wastes the user's
44
+ # time on a probe plus username/password prompts before the
45
+ # IntegrityError surfaces. See issue #16.
46
+ repo = ConnectionsRepo(_db())
47
+ if repo.get(alias) is not None:
48
+ console.print(
49
+ f"[red]Error:[/red] A connection with alias [bold]'{alias}'[/bold] "
50
+ f"already exists. Use [bold]cgate connections remove {alias}[/bold] "
51
+ f"first, or choose a different alias."
52
+ )
53
+ raise typer.Exit(code=1) from None
54
+
55
+ probe = probe_host(hostname)
56
+ try:
57
+ server_type = probe.server_type
58
+ except AmbiguousHostError:
59
+ console.print(
60
+ f"[yellow]{hostname} responded to BOTH SSH and WinRM. Choose one.[/yellow]"
61
+ )
62
+ choice = typer.prompt("Protocol", type=Choice(["ssh", "winrm"]))
63
+ server_type = (
64
+ ServerType.LINUX if choice == "ssh" else ServerType.WINDOWS
65
+ )
66
+ except UnknownHostError as exc:
67
+ console.print(f"[red]{exc}[/red]")
68
+ raise typer.Exit(code=1) from exc
69
+
70
+ try:
71
+ _ = repo.add(
72
+ alias=alias,
73
+ hostname=hostname,
74
+ server_type=server_type,
75
+ detection_ssh=probe.ssh,
76
+ detection_winrm=probe.winrm,
77
+ )
78
+ except sqlite3.IntegrityError:
79
+ # Race fallback: between the pre-check above and this INSERT,
80
+ # another process could have added the same alias. Surface the
81
+ # same friendly message instead of a raw traceback (issue #1).
82
+ console.print(
83
+ f"[red]Error:[/red] A connection with alias [bold]'{alias}'[/bold] "
84
+ f"already exists. Use [bold]cgate connections remove {alias}[/bold] "
85
+ f"first, or choose a different alias."
86
+ )
87
+ raise typer.Exit(code=1) from None
88
+ console.print(f"Saved [bold]{alias}[/bold] -> {hostname} ({server_type.value}).")
89
+
90
+ if is_kerberos_available():
91
+ console.print(
92
+ "[dim]Kerberos passthrough detected; no credentials to store.[/dim]"
93
+ )
94
+ return
95
+
96
+ username = typer.prompt("Username")
97
+ if server_type is ServerType.LINUX:
98
+ ssh_key = typer.prompt(
99
+ "SSH key path (blank for password auth)", default="", show_default=False
100
+ )
101
+ if ssh_key.strip():
102
+ store_credential(
103
+ alias, username=username, password=None, ssh_key=ssh_key.strip()
104
+ )
105
+ console.print(
106
+ f"Stored SSH-key credential for [bold]{alias}[/bold] in OS keyring."
107
+ )
108
+ else:
109
+ password = typer.prompt("Password", hide_input=True)
110
+ store_credential(
111
+ alias, username=username, password=password, ssh_key=None
112
+ )
113
+ console.print(
114
+ f"Stored password credential for [bold]{alias}[/bold] in OS keyring."
115
+ )
116
+ else:
117
+ password = typer.prompt("Password", hide_input=True)
118
+ store_credential(alias, username=username, password=password, ssh_key=None)
119
+ console.print(
120
+ f"Stored WinRM credential for [bold]{alias}[/bold] in OS keyring."
121
+ )
122
+
123
+
124
+ @connections_app.command("list")
125
+ def list_cmd() -> None:
126
+ """List all saved connections in a table."""
127
+ conns = ConnectionsRepo(_db()).list_all()
128
+ if not conns:
129
+ console.print("[dim]No connections saved.[/dim]")
130
+ return
131
+ table = Table(title="Saved connections")
132
+ table.add_column("Alias", style="bold")
133
+ table.add_column("Hostname")
134
+ table.add_column("Type")
135
+ table.add_column("SSH?", justify="center")
136
+ table.add_column("WinRM?", justify="center")
137
+ table.add_column("Created")
138
+ for connection in conns:
139
+ table.add_row(
140
+ connection.alias,
141
+ connection.hostname,
142
+ connection.server_type.value,
143
+ "Y" if connection.detection_ssh else "N",
144
+ "Y" if connection.detection_winrm else "N",
145
+ connection.created_at.isoformat(),
146
+ )
147
+ console.print(table)
148
+
149
+
150
+ @connections_app.command("remove")
151
+ def remove(alias: str) -> None:
152
+ """Remove a connection and its credential from the OS keyring."""
153
+ if not typer.confirm(f"Remove connection '{alias}'?"):
154
+ raise typer.Abort
155
+ repo = ConnectionsRepo(_db())
156
+ if repo.get(alias) is None:
157
+ console.print(f"[red]No connection '{alias}' found.[/red]")
158
+ raise typer.Exit(code=1)
159
+
160
+ # Remove the keyring credential BEFORE the DB row (issue #5). If the
161
+ # keyring backend is unavailable -- common on headless Linux, where
162
+ # keyring.errors.NoKeyringError is raised -- aborting here leaves the
163
+ # connection intact and reusable instead of deleting the DB row and
164
+ # orphaning a credential no connection can ever reference again.
165
+ #
166
+ # Three cases to distinguish:
167
+ # 1. No credential was ever stored (kerberos-mode adds store none,
168
+ # or the keyring was down at add time, etc.). Removing the DB row
169
+ # is safe -- there is nothing in the keyring to orphan.
170
+ # 2. A credential exists. Removal must succeed; otherwise we'd
171
+ # orphan it. `remove_credential` swallows `KeyringError` (incl.
172
+ # `NoKeyringError`) and returns False -- so we must check the
173
+ # bool, since the `except Exception` below never sees that case.
174
+ # 3. We can't even read the keyring (backend unavailable). We can't
175
+ # tell whether case 1 or 2 applies, so conservatively abort.
176
+ try:
177
+ existing = get_credential(alias)
178
+ except Exception as exc:
179
+ console.print(
180
+ f"[red]Could not read the stored credential for '{alias}' "
181
+ f"from the OS keyring:[/red] {exc}\n"
182
+ "[dim]The connection was NOT removed. Fix the keyring backend "
183
+ "and retry, or remove the credential manually first.[/dim]"
184
+ )
185
+ raise typer.Exit(code=1) from exc
186
+ if existing is not None:
187
+ try:
188
+ keyring_removed = remove_credential(alias)
189
+ except Exception as exc:
190
+ console.print(
191
+ f"[red]Could not remove the stored credential for '{alias}' "
192
+ f"from the OS keyring:[/red] {exc}\n"
193
+ "[dim]The connection was NOT removed. Fix the keyring backend "
194
+ "and retry, or remove the credential manually first.[/dim]"
195
+ )
196
+ raise typer.Exit(code=1) from exc
197
+ if not keyring_removed:
198
+ console.print(
199
+ f"[red]Could not remove the stored credential for '{alias}' "
200
+ f"from the OS keyring.[/red]\n"
201
+ "[dim]The keyring backend is unavailable (common on headless "
202
+ "Linux, where no backend is registered with the `keyring` "
203
+ "library); the connection was NOT removed. Fix the keyring "
204
+ "backend or remove the credential manually first, then retry.[/dim]"
205
+ )
206
+ raise typer.Exit(code=1) from None
207
+
208
+ removed = repo.remove(alias)
209
+ if not removed:
210
+ console.print(f"[red]No connection '{alias}' found.[/red]")
211
+ raise typer.Exit(code=1)
212
+ console.print(f"Removed [bold]{alias}[/bold].")
cgate/cli/history.py ADDED
@@ -0,0 +1,191 @@
1
+ """CLI commands for browsing and exporting the resolved-batch audit trail."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ import json
8
+ from enum import StrEnum
9
+
10
+ # Typer resolves Annotated parameter types at runtime via
11
+ # inspect.signature(eval_str=True); moving this into TYPE_CHECKING (as the
12
+ # linter would otherwise suggest) makes `history export --help` raise
13
+ # NameError: name 'Path' is not defined the moment Typer builds the command.
14
+ from pathlib import Path # noqa: TC003
15
+ from typing import TYPE_CHECKING, Annotated
16
+
17
+ import typer
18
+ from rich.console import Console
19
+
20
+ from cgate.core.paths import db_path
21
+ from cgate.db.batches import BatchesRepo
22
+ from cgate.db.commands import CommandsRepo
23
+ from cgate.db.connection import Database, init_database
24
+ from cgate.db.rows import iso
25
+
26
+ if TYPE_CHECKING:
27
+ from cgate.db.types import Batch, Command
28
+
29
+ history_app = typer.Typer(help="Browse and export the resolved-batch audit trail.")
30
+ console = Console()
31
+
32
+ _CSV_FIELDNAMES: list[str] = [
33
+ "batch_id",
34
+ "batch_title",
35
+ "batch_description",
36
+ "requested_by_agent",
37
+ "batch_created_at",
38
+ "batch_resolved_at",
39
+ "command_id",
40
+ "position",
41
+ "server_alias",
42
+ "server_type",
43
+ "command",
44
+ "status",
45
+ "result",
46
+ "approved_by",
47
+ "reason",
48
+ "risk_label",
49
+ "command_created_at",
50
+ "command_resolved_at",
51
+ ]
52
+
53
+
54
+ class ExportFormat(StrEnum):
55
+ """Output format for `cgate history export`."""
56
+
57
+ CSV = "csv"
58
+ JSON = "json"
59
+
60
+
61
+ def _csv_row(batch: Batch, command: Command) -> dict[str, str]:
62
+ """Flatten one command, with its batch's context repeated, into a CSV row.
63
+
64
+ One row per command (not per batch) so a spreadsheet or `awk`/`grep`
65
+ over the export can filter/sort by any command-level field directly.
66
+ """
67
+ return {
68
+ "batch_id": batch.id,
69
+ "batch_title": batch.title,
70
+ "batch_description": batch.description or "",
71
+ "requested_by_agent": batch.requested_by_agent or "",
72
+ "batch_created_at": iso(batch.created_at),
73
+ "batch_resolved_at": iso(batch.resolved_at) if batch.resolved_at else "",
74
+ "command_id": command.id,
75
+ "position": str(command.position),
76
+ "server_alias": command.server_alias,
77
+ "server_type": command.server_type.value,
78
+ "command": command.command,
79
+ "status": command.status.value,
80
+ "result": command.result or "",
81
+ "approved_by": command.approved_by or "",
82
+ "reason": command.reason or "",
83
+ "risk_label": command.risk_label or "",
84
+ "command_created_at": iso(command.created_at),
85
+ "command_resolved_at": iso(command.resolved_at) if command.resolved_at else "",
86
+ }
87
+
88
+
89
+ def _json_command(command: Command) -> dict[str, object]:
90
+ return {
91
+ "id": command.id,
92
+ "position": command.position,
93
+ "server_alias": command.server_alias,
94
+ "server_type": command.server_type.value,
95
+ "command": command.command,
96
+ "status": command.status.value,
97
+ "result": command.result,
98
+ "approved_by": command.approved_by,
99
+ "reason": command.reason,
100
+ "risk_label": command.risk_label,
101
+ "created_at": iso(command.created_at),
102
+ "resolved_at": iso(command.resolved_at) if command.resolved_at else None,
103
+ }
104
+
105
+
106
+ def _json_batch(batch: Batch, commands: list[Command]) -> dict[str, object]:
107
+ """Nest a batch's commands under it.
108
+
109
+ The natural shape for JSON, unlike the CSV export's
110
+ one-row-per-command flattening.
111
+ """
112
+ return {
113
+ "batch_id": batch.id,
114
+ "title": batch.title,
115
+ "description": batch.description,
116
+ "requested_by_agent": batch.requested_by_agent,
117
+ "created_at": iso(batch.created_at),
118
+ "resolved_at": iso(batch.resolved_at) if batch.resolved_at else None,
119
+ "commands": [_json_command(command) for command in commands],
120
+ }
121
+
122
+
123
+ def _render_csv(batches: list[Batch], commands_repo: CommandsRepo) -> str:
124
+ buffer = io.StringIO()
125
+ writer = csv.DictWriter(buffer, fieldnames=_CSV_FIELDNAMES)
126
+ writer.writeheader()
127
+ for batch in batches:
128
+ for command in commands_repo.list_for_batch(batch.id):
129
+ writer.writerow(_csv_row(batch, command))
130
+ return buffer.getvalue()
131
+
132
+
133
+ def _render_json(batches: list[Batch], commands_repo: CommandsRepo) -> str:
134
+ payload = [_json_batch(batch, commands_repo.list_for_batch(batch.id)) for batch in batches]
135
+ return json.dumps(payload, indent=2)
136
+
137
+
138
+ @history_app.command("export")
139
+ def export_cmd(
140
+ *,
141
+ output: Annotated[
142
+ Path | None,
143
+ typer.Option(
144
+ "--output",
145
+ "-o",
146
+ help="File to write to. Prints to stdout when omitted.",
147
+ ),
148
+ ] = None,
149
+ export_format: Annotated[
150
+ ExportFormat,
151
+ typer.Option("--format", "-f", help="Output format."),
152
+ ] = ExportFormat.CSV,
153
+ limit: Annotated[
154
+ int | None,
155
+ typer.Option(
156
+ "--limit",
157
+ help=(
158
+ "Cap how many resolved batches to include (most recently resolved "
159
+ "first). Exports the full audit trail by default."
160
+ ),
161
+ ),
162
+ ] = None,
163
+ ) -> None:
164
+ """Export every resolved batch and its commands -- the full audit trail.
165
+
166
+ A human-only export: this reads the same data `cgate watch`'s History
167
+ screen (`h`) shows, for compliance reporting or offline analysis. Not
168
+ exposed to AI agents over MCP -- an agent that proposed a batch can
169
+ already poll its own `check_status(batch_id)` at any time, resolved or
170
+ not, and there's no reason to hand it a browse of everyone else's.
171
+ """
172
+ db = Database(path=db_path())
173
+ init_database(db)
174
+ batches_repo = BatchesRepo(db)
175
+ commands_repo = CommandsRepo(db)
176
+ resolved = batches_repo.list_resolved(limit=limit)
177
+
178
+ text = (
179
+ _render_csv(resolved, commands_repo)
180
+ if export_format is ExportFormat.CSV
181
+ else _render_json(resolved, commands_repo)
182
+ )
183
+
184
+ if output is None:
185
+ typer.echo(text)
186
+ return
187
+ _ = output.write_text(text, encoding="utf-8")
188
+ console.print(f"Wrote [bold]{len(resolved)}[/bold] resolved batch(es) to {output}")
189
+
190
+
191
+ __all__ = ["history_app"]
cgate/cli/install.py ADDED
@@ -0,0 +1,182 @@
1
+ """Install cgate onto this machine.
2
+
3
+ Copies the running binary into a per-user bin directory and registers
4
+ that directory on PATH.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from typing import TYPE_CHECKING
11
+
12
+ import typer
13
+ from rich.console import Console
14
+
15
+ from cgate.cli._console import should_pause, wait_for_enter
16
+ from cgate.core.path_env import (
17
+ copy_binary,
18
+ ensure_posix_shell_path,
19
+ ensure_windows_user_path,
20
+ install_dir,
21
+ install_target_path,
22
+ )
23
+ from cgate.mcp_installer import detect_clients, register
24
+ from cgate.update import current_binary_path
25
+
26
+ if TYPE_CHECKING:
27
+ from pathlib import Path
28
+
29
+ console = Console()
30
+
31
+
32
+ def _current_source_and_target() -> tuple[Path, Path] | None:
33
+ """Return (source, target) when running from a frozen binary, else None."""
34
+ source = current_binary_path()
35
+ if source is None:
36
+ return None
37
+ return source, install_target_path()
38
+
39
+
40
+ def _perform_install(source: Path, target: Path) -> bool:
41
+ """Copy the binary to ``target`` and register it on PATH.
42
+
43
+ Returns True if a fresh install actually happened, False if already
44
+ installed at ``target``. Raises ``typer.Exit(code=1)`` if a
45
+ different file already exists at the target, or if the copy/PATH
46
+ write itself fails -- both print their own explanation first.
47
+ """
48
+ console.print("[bold]Install:[/bold]")
49
+ already_at_target = source.resolve() == target.resolve()
50
+ if already_at_target:
51
+ console.print(f" Binary: [dim]already running from {target}[/dim]")
52
+ elif target.exists():
53
+ console.print(f" [red]{target} already exists.[/red]")
54
+ console.print(
55
+ f" [dim]That looks like an existing cgate install. To update it, "
56
+ f"run `cgate update apply` from {target} instead.[/dim]"
57
+ )
58
+ raise typer.Exit(code=1)
59
+ else:
60
+ try:
61
+ copy_binary(source, target)
62
+ except OSError as exc:
63
+ console.print(f" [red]Failed to copy binary to {target}:[/red] {exc}")
64
+ raise typer.Exit(code=1) from exc
65
+ console.print(f" Binary: [green]copied to {target}[/green]")
66
+
67
+ directory = install_dir()
68
+ rc_path = None
69
+ try:
70
+ if sys.platform == "win32":
71
+ path_status = ensure_windows_user_path(directory)
72
+ else:
73
+ path_status, rc_path = ensure_posix_shell_path(directory)
74
+ except OSError as exc:
75
+ console.print(f" [red]Failed to update PATH:[/red] {exc}")
76
+ raise typer.Exit(code=1) from exc
77
+
78
+ if path_status == "already_present":
79
+ console.print(f" PATH: [dim]{directory} already on PATH[/dim]")
80
+ else:
81
+ console.print(f" PATH: [green]added {directory}[/green]")
82
+ if sys.platform == "win32":
83
+ console.print(
84
+ " [dim]Open a new terminal for `cgate` to be found on PATH "
85
+ "(already-open shells won't pick this up).[/dim]"
86
+ )
87
+ else:
88
+ console.print(
89
+ f" [dim]Run `source {rc_path}` or open a new terminal for "
90
+ "`cgate` to be found on PATH.[/dim]"
91
+ )
92
+
93
+ return not already_at_target
94
+
95
+
96
+ def _auto_register_mcp_clients(target: Path) -> None:
97
+ """Register every detected IA client with no confirmation prompts.
98
+
99
+ Used only by the zero-argument first-run flow, where the user has
100
+ already opted into full automation by not typing anything else.
101
+ Registers against ``target`` directly (``mcp serve``), not
102
+ ``current_binary_command()``'s ``sys.executable`` resolution, which
103
+ at this point in the process still resolves to the pre-copy source
104
+ binary, not the just-installed one.
105
+ """
106
+ clients = detect_clients()
107
+ if not clients:
108
+ console.print(
109
+ "\n[dim]No IA clients detected yet -- run `cgate mcp install` "
110
+ "later once you have one.[/dim]"
111
+ )
112
+ return
113
+ console.print(f"\nRegistering with {len(clients)} detected IA client(s)...")
114
+ command, args = str(target), ["mcp", "serve"]
115
+ for client in clients:
116
+ try:
117
+ register(client, command, args)
118
+ except OSError as exc:
119
+ console.print(f" [red]Failed to register {client.label}:[/red] {exc}")
120
+ continue
121
+ console.print(f" [green]Registered {client.label}.[/green]")
122
+
123
+
124
+ def install_cmd() -> None:
125
+ """Copy this binary into ~/bin (Windows) or ~/.local/bin (macOS/Linux) and add it to PATH.
126
+
127
+ Safe to run repeatedly: already installed at the target, and already
128
+ on PATH, are both detected and reported rather than redone.
129
+ """
130
+ result = _current_source_and_target()
131
+ if result is None:
132
+ console.print(
133
+ "[yellow]Not running from a packaged binary; nothing to install.[/yellow]\n"
134
+ "[dim]`cgate install` only applies to the downloaded release "
135
+ "binary, not a source checkout.[/dim]"
136
+ )
137
+ return
138
+
139
+ source, target = result
140
+ _perform_install(source, target)
141
+ console.print("\nNext: run [bold]cgate mcp install[/bold] to register with your AI clients.")
142
+
143
+
144
+ def maybe_auto_install(*, unattended: bool = False) -> bool:
145
+ """Run automatically when cgate is invoked with no arguments at all.
146
+
147
+ A binary just downloaded and double-clicked (or run bare from a
148
+ terminal) is a frozen binary not yet at the install target -- in
149
+ that case, do the full first-run setup (install + MCP client
150
+ registration) with no confirmation prompts, since the whole point
151
+ of this path is "just run the exe, nothing else." Returns True if
152
+ it did this (the caller should not also print help); False when
153
+ there was nothing to auto-install (dev mode, or already properly
154
+ installed), in which case the caller falls through to normal help.
155
+
156
+ ``unattended`` skips the "Press Enter to close" pause at the end
157
+ (there are no prompts to skip either way -- this path never has
158
+ any) so a scripted/silent deployment (``cgate-windows-amd64.exe
159
+ --unattended``) runs start to finish and exits on its own, instead
160
+ of waiting on a keypress nobody is there to send.
161
+ """
162
+ result = _current_source_and_target()
163
+ if result is None:
164
+ return False
165
+ source, target = result
166
+ if source.resolve() == target.resolve():
167
+ return False # already installed -- bare `cgate` should just show help
168
+
169
+ console.print("[bold]First run detected -- setting up cgate automatically.[/bold]\n")
170
+ _perform_install(source, target)
171
+ _auto_register_mcp_clients(target)
172
+
173
+ console.print(
174
+ "\n[bold]Almost done -- two things only your terminal/AI client can do:[/bold]\n"
175
+ " 1. Open a new terminal (already-open ones won't see the PATH change).\n"
176
+ " 2. Restart your AI client (Claude Code / opencode / Cursor) to load "
177
+ "the new MCP server."
178
+ )
179
+ if should_pause(no_pause=unattended):
180
+ console.print()
181
+ wait_for_enter()
182
+ return True