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/watch/approval.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Decision handlers for approving, executing, and rejecting commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from cgate.db.types import CommandStatus
|
|
9
|
+
from cgate.executor.selector import execute_command
|
|
10
|
+
from cgate.watch.queue import is_batch_resolved
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from cgate.connections.store import ConnectionsRepo
|
|
14
|
+
from cgate.db.batches import BatchesRepo
|
|
15
|
+
from cgate.db.commands import CommandsRepo
|
|
16
|
+
from cgate.db.connection import Database
|
|
17
|
+
from cgate.db.types import BatchId, Command, CommandId
|
|
18
|
+
from cgate.executor.base import ExecutionResult
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConnectionNotFoundError(LookupError):
|
|
22
|
+
"""A command references a connection alias absent from local storage."""
|
|
23
|
+
|
|
24
|
+
alias: str
|
|
25
|
+
|
|
26
|
+
def __init__(self, alias: str) -> None:
|
|
27
|
+
"""Store the missing alias for a useful boundary error."""
|
|
28
|
+
self.alias = alias
|
|
29
|
+
message = f"connection '{alias}' not found"
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CommandDisappearedError(RuntimeError):
|
|
34
|
+
"""A command could not be fetched immediately after a status update."""
|
|
35
|
+
|
|
36
|
+
command_id: CommandId
|
|
37
|
+
|
|
38
|
+
def __init__(self, command_id: CommandId) -> None:
|
|
39
|
+
"""Store the missing command ID for a useful boundary error."""
|
|
40
|
+
self.command_id = command_id
|
|
41
|
+
message = f"command {command_id} disappeared after status update"
|
|
42
|
+
super().__init__(message)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _approve_by() -> str:
|
|
46
|
+
"""Return the OS username for the audit column."""
|
|
47
|
+
try:
|
|
48
|
+
return getpass.getuser()
|
|
49
|
+
except (KeyError, OSError):
|
|
50
|
+
return "unknown"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def mark_approved(
|
|
54
|
+
*,
|
|
55
|
+
commands: CommandsRepo,
|
|
56
|
+
connections: ConnectionsRepo,
|
|
57
|
+
command_id: CommandId,
|
|
58
|
+
) -> Command | None:
|
|
59
|
+
"""Transition one pending command to APPROVED, without executing it.
|
|
60
|
+
|
|
61
|
+
The fast half of `approve_one`'s two-step split (paired with
|
|
62
|
+
`execute_and_finalize`): a plain DB write, done in milliseconds. The
|
|
63
|
+
watch TUI calls this first and refreshes before the slow half, so a
|
|
64
|
+
human sees the "approved, running" state (status glyph ◐) instead of
|
|
65
|
+
the queue looking frozen while the executor's timeout runs (up to a
|
|
66
|
+
minute by default).
|
|
67
|
+
|
|
68
|
+
Returns the command as-is (still PENDING) if `expected_status` loses
|
|
69
|
+
a race with another decision on it, or if it's already past PENDING
|
|
70
|
+
for any other reason -- mirroring `approve_one`'s prior behavior.
|
|
71
|
+
"""
|
|
72
|
+
command = commands.get(command_id)
|
|
73
|
+
if command is None or command.status is not CommandStatus.PENDING:
|
|
74
|
+
return command
|
|
75
|
+
connection = connections.get(command.server_alias)
|
|
76
|
+
if connection is None:
|
|
77
|
+
raise ConnectionNotFoundError(command.server_alias)
|
|
78
|
+
approved = commands.update_status(
|
|
79
|
+
command_id,
|
|
80
|
+
status=CommandStatus.APPROVED,
|
|
81
|
+
approved_by=_approve_by(),
|
|
82
|
+
expected_status=CommandStatus.PENDING,
|
|
83
|
+
)
|
|
84
|
+
return commands.get(command_id) if approved else command
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def execute_and_finalize( # noqa: PLR0913 - signature follows the required repository DI boundary
|
|
88
|
+
*,
|
|
89
|
+
db: Database,
|
|
90
|
+
commands: CommandsRepo,
|
|
91
|
+
connections: ConnectionsRepo,
|
|
92
|
+
batches: BatchesRepo,
|
|
93
|
+
command_id: CommandId,
|
|
94
|
+
timeout: float = 60.0,
|
|
95
|
+
) -> tuple[Command | None, ExecutionResult | None]:
|
|
96
|
+
"""Execute an already-APPROVED command and stamp EXECUTED/FAILED.
|
|
97
|
+
|
|
98
|
+
The slow half of `approve_one`'s two-step split: the network call
|
|
99
|
+
that can take up to `timeout`. No-ops (returns the command as-is, no
|
|
100
|
+
ExecutionResult) if it isn't APPROVED -- `mark_approved` lost a race,
|
|
101
|
+
or a human rejected it in the gap between the two calls.
|
|
102
|
+
"""
|
|
103
|
+
del db
|
|
104
|
+
command = commands.get(command_id)
|
|
105
|
+
if command is None or command.status is not CommandStatus.APPROVED:
|
|
106
|
+
return command, None
|
|
107
|
+
connection = connections.get(command.server_alias)
|
|
108
|
+
if connection is None:
|
|
109
|
+
raise ConnectionNotFoundError(command.server_alias)
|
|
110
|
+
approver = command.approved_by or _approve_by()
|
|
111
|
+
result = execute_command(connection, command.command, timeout=timeout)
|
|
112
|
+
status = CommandStatus.EXECUTED if result.ok else CommandStatus.FAILED
|
|
113
|
+
output = result.stdout
|
|
114
|
+
if result.stderr:
|
|
115
|
+
separator = "\n" if output else ""
|
|
116
|
+
output = f"{output}{separator}--- stderr ---\n{result.stderr}"
|
|
117
|
+
_ = commands.update_status(
|
|
118
|
+
command_id,
|
|
119
|
+
status=status,
|
|
120
|
+
approved_by=approver,
|
|
121
|
+
result=output,
|
|
122
|
+
)
|
|
123
|
+
updated = commands.get(command_id)
|
|
124
|
+
if updated is None:
|
|
125
|
+
raise CommandDisappearedError(command_id)
|
|
126
|
+
_maybe_resolve_batch(batches, commands, updated.batch_id)
|
|
127
|
+
return updated, result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def approve_one( # noqa: PLR0913 - signature follows the required repository DI boundary
|
|
131
|
+
*,
|
|
132
|
+
db: Database,
|
|
133
|
+
commands: CommandsRepo,
|
|
134
|
+
connections: ConnectionsRepo,
|
|
135
|
+
batches: BatchesRepo,
|
|
136
|
+
command_id: CommandId,
|
|
137
|
+
timeout: float = 60.0,
|
|
138
|
+
) -> tuple[Command | None, ExecutionResult | None]:
|
|
139
|
+
"""Approve and synchronously execute one pending command.
|
|
140
|
+
|
|
141
|
+
Composes `mark_approved` + `execute_and_finalize` in one call, for
|
|
142
|
+
callers that don't need the mid-flight refresh those two are split
|
|
143
|
+
for: `approve_remaining` and the MCP AUTO-mode auto-execution path.
|
|
144
|
+
"""
|
|
145
|
+
_ = mark_approved(commands=commands, connections=connections, command_id=command_id)
|
|
146
|
+
return execute_and_finalize(
|
|
147
|
+
db=db,
|
|
148
|
+
commands=commands,
|
|
149
|
+
connections=connections,
|
|
150
|
+
batches=batches,
|
|
151
|
+
command_id=command_id,
|
|
152
|
+
timeout=timeout,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def reject_one(
|
|
157
|
+
*,
|
|
158
|
+
commands: CommandsRepo,
|
|
159
|
+
batches: BatchesRepo,
|
|
160
|
+
command_id: CommandId,
|
|
161
|
+
) -> Command:
|
|
162
|
+
"""Reject one command and resolve its batch when it becomes terminal."""
|
|
163
|
+
_ = commands.update_status(
|
|
164
|
+
command_id,
|
|
165
|
+
status=CommandStatus.REJECTED,
|
|
166
|
+
approved_by=_approve_by(),
|
|
167
|
+
expected_status=CommandStatus.PENDING,
|
|
168
|
+
)
|
|
169
|
+
updated = commands.get(command_id)
|
|
170
|
+
if updated is None:
|
|
171
|
+
raise CommandDisappearedError(command_id)
|
|
172
|
+
_maybe_resolve_batch(batches, commands, updated.batch_id)
|
|
173
|
+
return updated
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def approve_remaining( # noqa: PLR0913 - signature follows the required repository DI boundary
|
|
177
|
+
*,
|
|
178
|
+
db: Database,
|
|
179
|
+
commands: CommandsRepo,
|
|
180
|
+
connections: ConnectionsRepo,
|
|
181
|
+
batches: BatchesRepo,
|
|
182
|
+
remaining: list[Command],
|
|
183
|
+
timeout: float = 60.0,
|
|
184
|
+
) -> list[tuple[Command, ExecutionResult | None]]:
|
|
185
|
+
"""Approve and execute pending commands in their natural order."""
|
|
186
|
+
results: list[tuple[Command, ExecutionResult | None]] = []
|
|
187
|
+
for command in remaining:
|
|
188
|
+
if command.status is not CommandStatus.PENDING:
|
|
189
|
+
continue
|
|
190
|
+
try:
|
|
191
|
+
updated, result = approve_one(
|
|
192
|
+
db=db,
|
|
193
|
+
commands=commands,
|
|
194
|
+
connections=connections,
|
|
195
|
+
batches=batches,
|
|
196
|
+
command_id=command.id,
|
|
197
|
+
timeout=timeout,
|
|
198
|
+
)
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
_ = commands.update_status(
|
|
201
|
+
command.id,
|
|
202
|
+
status=CommandStatus.FAILED,
|
|
203
|
+
approved_by=_approve_by(),
|
|
204
|
+
result=f"approval/connect failed: {exc}",
|
|
205
|
+
)
|
|
206
|
+
updated = commands.get(command.id)
|
|
207
|
+
result = None
|
|
208
|
+
if updated is None:
|
|
209
|
+
raise CommandDisappearedError(command.id) from exc
|
|
210
|
+
_maybe_resolve_batch(batches, commands, updated.batch_id)
|
|
211
|
+
if updated is not None:
|
|
212
|
+
results.append((updated, result))
|
|
213
|
+
return results
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def reject_remaining(
|
|
217
|
+
*,
|
|
218
|
+
commands: CommandsRepo,
|
|
219
|
+
batches: BatchesRepo,
|
|
220
|
+
remaining: list[Command],
|
|
221
|
+
) -> list[Command]:
|
|
222
|
+
"""Reject each pending command in its natural order."""
|
|
223
|
+
return [
|
|
224
|
+
reject_one(commands=commands, batches=batches, command_id=command.id)
|
|
225
|
+
for command in remaining
|
|
226
|
+
if command.status is CommandStatus.PENDING
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _maybe_resolve_batch(
|
|
231
|
+
batches: BatchesRepo,
|
|
232
|
+
commands: CommandsRepo,
|
|
233
|
+
batch_id: BatchId,
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Stamp a batch when every command has reached a terminal status."""
|
|
236
|
+
if is_batch_resolved(commands.list_for_batch(batch_id)):
|
|
237
|
+
batches.mark_resolved(batch_id)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Read-only detail view for one command: full result, reason, audit fields."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
6
|
+
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.containers import Vertical, VerticalScroll
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import Static
|
|
11
|
+
from typing_extensions import override
|
|
12
|
+
|
|
13
|
+
from cgate.watch.render import format_command_detail
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from textual.app import ComposeResult
|
|
17
|
+
from textual.binding import BindingType
|
|
18
|
+
|
|
19
|
+
from cgate.db.types import Command
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CommandDetailModal(ModalScreen[None]):
|
|
23
|
+
"""Full detail for one command: untruncated result, reason, and approver.
|
|
24
|
+
|
|
25
|
+
Unlike the queue rows -- capped at `render._RESULT_SNIPPET_MAX_CHARS`
|
|
26
|
+
and one line -- this shows the command's full result. Read-only:
|
|
27
|
+
makes no writes, dismisses on Escape or Enter.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
CSS: ClassVar[str] = """
|
|
31
|
+
CommandDetailModal { align: center middle; }
|
|
32
|
+
#detail-dialog {
|
|
33
|
+
width: 90%;
|
|
34
|
+
height: 80%;
|
|
35
|
+
border: round $primary;
|
|
36
|
+
background: $surface;
|
|
37
|
+
padding: 1 2;
|
|
38
|
+
}
|
|
39
|
+
#detail-body { height: 1fr; margin-top: 1; }
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
43
|
+
Binding("escape", "close", "Close", show=False, priority=True),
|
|
44
|
+
Binding("enter", "close", "Close", show=False, priority=True),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
_command: Command
|
|
48
|
+
|
|
49
|
+
def __init__(self, command: Command) -> None:
|
|
50
|
+
"""Remember the command whose detail this modal shows."""
|
|
51
|
+
super().__init__()
|
|
52
|
+
self._command = command
|
|
53
|
+
|
|
54
|
+
@override
|
|
55
|
+
def compose(self) -> ComposeResult:
|
|
56
|
+
"""Show the title, the full command detail, and the close hint."""
|
|
57
|
+
with Vertical(id="detail-dialog"):
|
|
58
|
+
yield Static("[bold]Command detail[/bold]")
|
|
59
|
+
with VerticalScroll(id="detail-body"):
|
|
60
|
+
yield Static(format_command_detail(self._command), id="detail-text", markup=True)
|
|
61
|
+
yield Static("[dim]Esc / Enter = close[/dim]")
|
|
62
|
+
|
|
63
|
+
def action_close(self) -> None:
|
|
64
|
+
"""Dismiss without writing anything -- this view is read-only."""
|
|
65
|
+
_ = self.dismiss(None)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
__all__ = ["CommandDetailModal"]
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Read-only browser for resolved batches (`h` key in `cgate watch`).
|
|
2
|
+
|
|
3
|
+
The live queue drops a batch the instant it resolves -- `resolve_at IS
|
|
4
|
+
NULL` is exactly what makes it disappear from `list_pending`. Without
|
|
5
|
+
this, there was no way to look back at anything that already went
|
|
6
|
+
through: not in the TUI, not from any CLI command.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sqlite3
|
|
12
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
13
|
+
|
|
14
|
+
from textual.binding import Binding
|
|
15
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
16
|
+
from textual.screen import ModalScreen
|
|
17
|
+
from textual.widgets import Input, ListItem, ListView, Static
|
|
18
|
+
from typing_extensions import override
|
|
19
|
+
|
|
20
|
+
from cgate.watch.command_detail_modal import CommandDetailModal
|
|
21
|
+
from cgate.watch.render import format_batch_header
|
|
22
|
+
from cgate.watch.theme import CGATE_THEME
|
|
23
|
+
from cgate.watch.widgets import CommandRow
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from textual.app import ComposeResult
|
|
27
|
+
from textual.binding import BindingType
|
|
28
|
+
|
|
29
|
+
from cgate.db.batches import BatchesRepo
|
|
30
|
+
from cgate.db.commands import CommandsRepo
|
|
31
|
+
from cgate.db.types import Batch, BatchId
|
|
32
|
+
|
|
33
|
+
_HISTORY_LIMIT = 50
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BatchHistoryRow(ListItem):
|
|
37
|
+
"""One resolved batch in the history list, carrying its id for lookups."""
|
|
38
|
+
|
|
39
|
+
batch_id: BatchId
|
|
40
|
+
|
|
41
|
+
def __init__(self, batch: Batch) -> None:
|
|
42
|
+
"""Render the resolved timestamp and title, and remember the batch id."""
|
|
43
|
+
resolved = batch.resolved_at.strftime("%Y-%m-%d %H:%M") if batch.resolved_at else "?"
|
|
44
|
+
super().__init__(Static(f"[dim]{resolved}[/dim] {batch.title}", markup=True))
|
|
45
|
+
self.batch_id = batch.id
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class HistoryModal(ModalScreen[None]):
|
|
49
|
+
"""Browse resolved batches: title/timestamp list on the left, commands on the right.
|
|
50
|
+
|
|
51
|
+
Press `/` to filter (fzf/vim-style): the list narrows live against
|
|
52
|
+
every batch's title, description, and its commands' text/server alias.
|
|
53
|
+
Escape exits filter mode first, then closes the modal on a second press.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
CSS: ClassVar[str] = """
|
|
57
|
+
HistoryModal { align: center middle; }
|
|
58
|
+
#history-dialog {
|
|
59
|
+
width: 96%;
|
|
60
|
+
height: 90%;
|
|
61
|
+
border: round $primary;
|
|
62
|
+
background: $surface;
|
|
63
|
+
}
|
|
64
|
+
#history-title { padding: 1 2 0 2; }
|
|
65
|
+
#history-hint { padding: 0 2 1 2; }
|
|
66
|
+
#history-body { height: 1fr; }
|
|
67
|
+
#history-list-pane { width: 40; border-right: solid $panel; padding: 1; }
|
|
68
|
+
#history-filter { margin-bottom: 1; }
|
|
69
|
+
#history-detail-pane { padding: 1 2; }
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
73
|
+
Binding("escape", "close", "Close", priority=True),
|
|
74
|
+
# Not priority: a focused #history-filter Input must still receive a
|
|
75
|
+
# literal "q" character while the human is typing a query.
|
|
76
|
+
Binding("q", "close", "Close", show=False),
|
|
77
|
+
Binding("slash", "search", "Filter", priority=True),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
_batches: BatchesRepo
|
|
81
|
+
_commands: CommandsRepo
|
|
82
|
+
_resolved: list[Batch]
|
|
83
|
+
_visible: list[Batch]
|
|
84
|
+
_search_text: dict[BatchId, str]
|
|
85
|
+
|
|
86
|
+
def __init__(self, *, batches: BatchesRepo, commands: CommandsRepo) -> None:
|
|
87
|
+
"""Store the repositories used to list resolved batches and their commands."""
|
|
88
|
+
super().__init__()
|
|
89
|
+
self._batches = batches
|
|
90
|
+
self._commands = commands
|
|
91
|
+
self._resolved = []
|
|
92
|
+
self._visible = []
|
|
93
|
+
self._search_text = {}
|
|
94
|
+
|
|
95
|
+
@override
|
|
96
|
+
def compose(self) -> ComposeResult:
|
|
97
|
+
"""Lay out the title, the filter input, the resolved-batch list, and the detail pane."""
|
|
98
|
+
with Vertical(id="history-dialog"):
|
|
99
|
+
yield Static("[bold]History[/bold] [dim](most recently resolved first)[/dim]",
|
|
100
|
+
id="history-title")
|
|
101
|
+
with Horizontal(id="history-body"):
|
|
102
|
+
with Vertical(id="history-list-pane"):
|
|
103
|
+
yield Input(placeholder="/ to filter…", id="history-filter")
|
|
104
|
+
yield ListView(id="history-list")
|
|
105
|
+
with VerticalScroll(id="history-detail-pane"):
|
|
106
|
+
yield Static(id="history-detail-header")
|
|
107
|
+
yield ListView(id="history-rows")
|
|
108
|
+
yield Static(
|
|
109
|
+
(
|
|
110
|
+
"[dim]↑/↓ = browse batches — Enter on a command = view full result "
|
|
111
|
+
"— / = filter — Esc = close[/dim]"
|
|
112
|
+
),
|
|
113
|
+
id="history-hint",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def on_mount(self) -> None:
|
|
117
|
+
"""Load the resolved batches, index them for filtering, and show the newest."""
|
|
118
|
+
try:
|
|
119
|
+
self._resolved = self._batches.list_resolved(limit=_HISTORY_LIMIT)
|
|
120
|
+
except sqlite3.Error as exc:
|
|
121
|
+
error = CGATE_THEME.error
|
|
122
|
+
self.query_one("#history-detail-header", Static).update(
|
|
123
|
+
f"[{error}]Could not read history:[/{error}] {exc}"
|
|
124
|
+
)
|
|
125
|
+
return
|
|
126
|
+
self._search_text = {batch.id: self._index_batch(batch) for batch in self._resolved}
|
|
127
|
+
self._visible = self._resolved
|
|
128
|
+
self._rebuild_list()
|
|
129
|
+
_ = self.query_one("#history-list", ListView).focus()
|
|
130
|
+
|
|
131
|
+
def _index_batch(self, batch: Batch) -> str:
|
|
132
|
+
"""Build the lowercased blob `_apply_filter` matches a query against.
|
|
133
|
+
|
|
134
|
+
Best-effort: a DB hiccup while indexing one batch's commands just
|
|
135
|
+
means that batch won't match on its command/server text -- title
|
|
136
|
+
and description still will -- rather than breaking the whole list.
|
|
137
|
+
"""
|
|
138
|
+
parts = [batch.title, batch.description or ""]
|
|
139
|
+
try:
|
|
140
|
+
for command in self._commands.list_for_batch(batch.id):
|
|
141
|
+
parts.append(command.command)
|
|
142
|
+
parts.append(command.server_alias)
|
|
143
|
+
except sqlite3.Error:
|
|
144
|
+
pass
|
|
145
|
+
return " ".join(parts).lower()
|
|
146
|
+
|
|
147
|
+
def action_search(self) -> None:
|
|
148
|
+
"""Focus the filter input (`/`), fzf/vim-style."""
|
|
149
|
+
_ = self.query_one("#history-filter", Input).focus()
|
|
150
|
+
|
|
151
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
152
|
+
"""Re-filter the batch list live as the query changes."""
|
|
153
|
+
if event.input.id != "history-filter":
|
|
154
|
+
return
|
|
155
|
+
self._apply_filter(event.value)
|
|
156
|
+
|
|
157
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
158
|
+
"""Enter in the filter jumps focus to the (now-narrowed) results list."""
|
|
159
|
+
if event.input.id != "history-filter":
|
|
160
|
+
return
|
|
161
|
+
_ = self.query_one("#history-list", ListView).focus()
|
|
162
|
+
|
|
163
|
+
def _apply_filter(self, query: str) -> None:
|
|
164
|
+
"""Narrow `_visible` to batches whose index contains `query`, then re-render."""
|
|
165
|
+
normalized = query.strip().lower()
|
|
166
|
+
self._visible = (
|
|
167
|
+
self._resolved
|
|
168
|
+
if not normalized
|
|
169
|
+
else [b for b in self._resolved if normalized in self._search_text.get(b.id, "")]
|
|
170
|
+
)
|
|
171
|
+
self._rebuild_list()
|
|
172
|
+
|
|
173
|
+
def _rebuild_list(self) -> None:
|
|
174
|
+
"""Redraw #history-list from `_visible` and show the first match's detail."""
|
|
175
|
+
list_view = self.query_one("#history-list", ListView)
|
|
176
|
+
_ = list_view.clear()
|
|
177
|
+
for batch in self._visible:
|
|
178
|
+
_ = list_view.append(BatchHistoryRow(batch))
|
|
179
|
+
if self._visible:
|
|
180
|
+
list_view.index = 0
|
|
181
|
+
self._show_batch(self._visible[0])
|
|
182
|
+
return
|
|
183
|
+
message = "No resolved batches yet." if not self._resolved else "No matches."
|
|
184
|
+
self.query_one("#history-detail-header", Static).update(f"[dim]{message}[/dim]")
|
|
185
|
+
_ = self.query_one("#history-rows", ListView).clear()
|
|
186
|
+
|
|
187
|
+
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
|
188
|
+
"""Show the highlighted batch's commands as the sidebar cursor moves."""
|
|
189
|
+
if event.list_view.id != "history-list" or event.item is None:
|
|
190
|
+
return
|
|
191
|
+
if isinstance(event.item, BatchHistoryRow):
|
|
192
|
+
batch = next(
|
|
193
|
+
(b for b in self._resolved if b.id == event.item.batch_id), None
|
|
194
|
+
)
|
|
195
|
+
if batch is not None:
|
|
196
|
+
self._show_batch(batch)
|
|
197
|
+
|
|
198
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
199
|
+
"""Open the full-detail modal for the command highlighted in the detail pane."""
|
|
200
|
+
if event.list_view.id != "history-rows":
|
|
201
|
+
return
|
|
202
|
+
row = event.item.query_one(CommandRow)
|
|
203
|
+
try:
|
|
204
|
+
command = self._commands.get(row.command_id)
|
|
205
|
+
except sqlite3.Error as exc:
|
|
206
|
+
error = CGATE_THEME.error
|
|
207
|
+
self.query_one("#history-detail-header", Static).update(
|
|
208
|
+
f"[{error}]Could not read this command:[/{error}] {exc}"
|
|
209
|
+
)
|
|
210
|
+
return
|
|
211
|
+
if command is not None:
|
|
212
|
+
_ = self.app.push_screen(CommandDetailModal(command)) # pyright: ignore[reportUnknownMemberType]
|
|
213
|
+
|
|
214
|
+
def _show_batch(self, batch: Batch) -> None:
|
|
215
|
+
"""Render one resolved batch's header and its commands in the detail pane."""
|
|
216
|
+
self.query_one("#history-detail-header", Static).update(format_batch_header(batch))
|
|
217
|
+
rows = self.query_one("#history-rows", ListView)
|
|
218
|
+
_ = rows.clear()
|
|
219
|
+
try:
|
|
220
|
+
commands_in_batch = self._commands.list_for_batch(batch.id)
|
|
221
|
+
except sqlite3.Error as exc:
|
|
222
|
+
error = CGATE_THEME.error
|
|
223
|
+
self.query_one("#history-detail-header", Static).update(
|
|
224
|
+
f"[{error}]Could not read this batch's commands:[/{error}] {exc}"
|
|
225
|
+
)
|
|
226
|
+
return
|
|
227
|
+
for command in commands_in_batch:
|
|
228
|
+
_ = rows.append(ListItem(CommandRow(command)))
|
|
229
|
+
if commands_in_batch:
|
|
230
|
+
rows.index = 0
|
|
231
|
+
|
|
232
|
+
def action_close(self) -> None:
|
|
233
|
+
"""Exit filter mode first if it's active; otherwise dismiss (read-only view)."""
|
|
234
|
+
filter_input = self.query_one("#history-filter", Input)
|
|
235
|
+
if self.focused is filter_input or filter_input.value:
|
|
236
|
+
filter_input.value = ""
|
|
237
|
+
_ = self.query_one("#history-list", ListView).focus()
|
|
238
|
+
return
|
|
239
|
+
_ = self.dismiss(None)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
__all__ = ["HistoryModal"]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Confirmation modal for toggling the global behavior mode (PROPOSE <-> AUTO)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import sqlite3
|
|
7
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
8
|
+
|
|
9
|
+
from textual.containers import Vertical
|
|
10
|
+
from textual.screen import ModalScreen
|
|
11
|
+
from textual.widgets import Static
|
|
12
|
+
from typing_extensions import override
|
|
13
|
+
|
|
14
|
+
from cgate.db.mode import AppModeNotSetError, Mode
|
|
15
|
+
from cgate.watch.theme import CGATE_THEME
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from textual.app import ComposeResult
|
|
19
|
+
from textual.events import Key
|
|
20
|
+
|
|
21
|
+
from cgate.db.mode import AppModeRepo
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _updated_by() -> str:
|
|
25
|
+
"""Return the OS username for the audit column, falling back to 'unknown'."""
|
|
26
|
+
try:
|
|
27
|
+
return getpass.getuser() or "unknown"
|
|
28
|
+
except (KeyError, OSError):
|
|
29
|
+
return "unknown"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def mode_markup(mode: Mode) -> str:
|
|
33
|
+
"""Render a mode label: warning-colored for PROPOSE (safe), error for AUTO (live fire)."""
|
|
34
|
+
match mode:
|
|
35
|
+
case Mode.PROPOSE:
|
|
36
|
+
warning = CGATE_THEME.warning
|
|
37
|
+
return f"[{warning}]PROPOSE[/{warning}]"
|
|
38
|
+
case Mode.AUTO:
|
|
39
|
+
error = CGATE_THEME.error
|
|
40
|
+
return f"[{error}]AUTO ⚡[/{error}]"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ModeModal(ModalScreen[bool]):
|
|
44
|
+
"""Ask the user to confirm switching the global mode to the OTHER mode.
|
|
45
|
+
|
|
46
|
+
Dismisses with ``True`` when the user pressed ``y`` and the new mode was
|
|
47
|
+
persisted; dismisses with ``False`` on any other key, with no writes.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
CSS: ClassVar[str] = """
|
|
51
|
+
ModeModal { align: center middle; }
|
|
52
|
+
#mode-dialog {
|
|
53
|
+
width: 64;
|
|
54
|
+
height: auto;
|
|
55
|
+
border: round $primary;
|
|
56
|
+
background: $surface;
|
|
57
|
+
padding: 1 2;
|
|
58
|
+
}
|
|
59
|
+
#mode-error { color: $error; }
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
_mode_repo: AppModeRepo # class-level annotation required by strict mode
|
|
63
|
+
_target: Mode # class-level annotation required by strict mode
|
|
64
|
+
|
|
65
|
+
def __init__(self, *, mode_repo: AppModeRepo) -> None:
|
|
66
|
+
"""Store the repository used to read the current mode and persist the new one."""
|
|
67
|
+
super().__init__()
|
|
68
|
+
self._mode_repo = mode_repo
|
|
69
|
+
self._target = Mode.PROPOSE
|
|
70
|
+
|
|
71
|
+
@override
|
|
72
|
+
def compose(self) -> ComposeResult:
|
|
73
|
+
"""Show the current mode, the target mode, and the confirm/cancel hint."""
|
|
74
|
+
try:
|
|
75
|
+
current = self._mode_repo.get().mode
|
|
76
|
+
except AppModeNotSetError:
|
|
77
|
+
# Safe-by-default: an unset mode behaves as PROPOSE everywhere else.
|
|
78
|
+
current = Mode.PROPOSE
|
|
79
|
+
self._target = Mode.AUTO if current is Mode.PROPOSE else Mode.PROPOSE
|
|
80
|
+
with Vertical(id="mode-dialog"):
|
|
81
|
+
yield Static("[bold]Switch global mode[/bold]")
|
|
82
|
+
yield Static(f"Current mode: {mode_markup(current)}")
|
|
83
|
+
yield Static(f"Switch to: {mode_markup(self._target)}")
|
|
84
|
+
if self._target is Mode.AUTO:
|
|
85
|
+
error = CGATE_THEME.error
|
|
86
|
+
warning_text = (
|
|
87
|
+
f"[{error}]In AUTO mode, commands for servers with auto-approve "
|
|
88
|
+
f"run without manual approval.[/{error}]"
|
|
89
|
+
)
|
|
90
|
+
yield Static(warning_text)
|
|
91
|
+
yield Static("[dim]y = confirm — any other key cancels[/dim]")
|
|
92
|
+
yield Static("", id="mode-error")
|
|
93
|
+
|
|
94
|
+
def on_key(self, event: Key) -> None:
|
|
95
|
+
"""Confirm on ``y``; cancel on any other key, writing nothing."""
|
|
96
|
+
if event.key != "y":
|
|
97
|
+
self.dismiss(False) # noqa: FBT003 - ModalScreen[bool].dismiss takes the result positionally
|
|
98
|
+
return
|
|
99
|
+
try:
|
|
100
|
+
_ = self._mode_repo.set(mode=self._target, updated_by=_updated_by())
|
|
101
|
+
except sqlite3.Error as exc:
|
|
102
|
+
error = CGATE_THEME.error
|
|
103
|
+
_ = self.query_one("#mode-error", Static).update(
|
|
104
|
+
f"[{error}]Could not save the mode:[/{error}] {exc}"
|
|
105
|
+
)
|
|
106
|
+
return
|
|
107
|
+
self.dismiss(True) # noqa: FBT003 - ModalScreen[bool].dismiss takes the result positionally
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = ["ModeModal"]
|