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
@@ -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"]