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.
- cgate/__init__.py +26 -0
- cgate/__main__.py +8 -0
- cgate/_version.py +24 -0
- cgate/cli/__init__.py +1 -0
- cgate/cli/_console.py +17 -0
- cgate/cli/connections.py +212 -0
- cgate/cli/history.py +191 -0
- cgate/cli/install.py +182 -0
- cgate/cli/main.py +115 -0
- cgate/cli/mcp.py +197 -0
- cgate/cli/uninstall.py +403 -0
- cgate/cli/update.py +538 -0
- cgate/cli/watch.py +20 -0
- cgate/connections/__init__.py +1 -0
- cgate/connections/auth.py +93 -0
- cgate/connections/detect.py +78 -0
- cgate/connections/store.py +88 -0
- cgate/core/__init__.py +1 -0
- cgate/core/path_env.py +218 -0
- cgate/core/paths.py +35 -0
- cgate/core/update_log.py +36 -0
- cgate/db/__init__.py +1 -0
- cgate/db/batches.py +111 -0
- cgate/db/commands.py +191 -0
- cgate/db/connection.py +104 -0
- cgate/db/mode.py +74 -0
- cgate/db/rows.py +99 -0
- cgate/db/schema.py +54 -0
- cgate/db/server_settings.py +105 -0
- cgate/db/types.py +77 -0
- cgate/executor/__init__.py +7 -0
- cgate/executor/base.py +71 -0
- cgate/executor/selector.py +61 -0
- cgate/executor/ssh.py +157 -0
- cgate/executor/winrm.py +129 -0
- cgate/helper/__init__.py +10 -0
- cgate/helper/__main__.py +112 -0
- cgate/helper/waiter.py +123 -0
- cgate/mcp_installer.py +161 -0
- cgate/mcp_server/__init__.py +6 -0
- cgate/mcp_server/__main__.py +6 -0
- cgate/mcp_server/auto_resolution.py +80 -0
- cgate/mcp_server/server.py +271 -0
- cgate/mcp_server/tools.py +351 -0
- cgate/risk.py +129 -0
- cgate/update.py +713 -0
- cgate/watch/__init__.py +7 -0
- cgate/watch/app.py +560 -0
- cgate/watch/approval.py +237 -0
- cgate/watch/command_detail_modal.py +68 -0
- cgate/watch/history_modal.py +242 -0
- cgate/watch/mode_modal.py +110 -0
- cgate/watch/queue.py +106 -0
- cgate/watch/render.py +156 -0
- cgate/watch/server_settings_modal.py +179 -0
- cgate/watch/session.py +40 -0
- cgate/watch/theme.py +32 -0
- cgate/watch/widgets.py +35 -0
- command_gate-0.2.4.dist-info/METADATA +204 -0
- command_gate-0.2.4.dist-info/RECORD +63 -0
- command_gate-0.2.4.dist-info/WHEEL +4 -0
- command_gate-0.2.4.dist-info/entry_points.txt +2 -0
- 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
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...")
|
cgate/cli/connections.py
ADDED
|
@@ -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
|