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/watch/queue.py ADDED
@@ -0,0 +1,106 @@
1
+ """Pure helpers for selecting the active batch, counting waiters, and healing it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from cgate.db.commands import all_terminal
8
+ from cgate.db.types import CommandStatus
9
+
10
+ if TYPE_CHECKING:
11
+ from cgate.db.batches import BatchesRepo
12
+ from cgate.db.commands import CommandsRepo
13
+ from cgate.db.types import Batch, BatchId, Command
14
+
15
+
16
+ def active_batch(batches: BatchesRepo) -> Batch | None:
17
+ """Return the next pending batch in FIFO order, or None."""
18
+ pending = batches.list_pending()
19
+ return pending[0] if pending else None
20
+
21
+
22
+ def select_active_batch(pending: list[Batch], pinned_id: BatchId | None) -> Batch | None:
23
+ """Return the pinned batch if it's still pending, else the FIFO-oldest one.
24
+
25
+ Lets a human jump the queue and prioritize a specific batch instead
26
+ of being forced through strict FIFO order. Self-correcting: once the
27
+ pinned batch resolves (or otherwise drops out of `pending`), this
28
+ falls straight back to FIFO without needing the caller to notice and
29
+ clear the pin.
30
+ """
31
+ if pinned_id is not None:
32
+ for batch in pending:
33
+ if batch.id == pinned_id:
34
+ return batch
35
+ return pending[0] if pending else None
36
+
37
+
38
+ def count_waiting(batches: BatchesRepo) -> int:
39
+ """Count batches queued behind the active one."""
40
+ return max(0, len(batches.list_pending()) - 1)
41
+
42
+
43
+ def count_pending_commands(pending_batches: list[Batch], commands: CommandsRepo) -> int:
44
+ """Total PENDING commands across every batch still in the queue."""
45
+ return sum(
46
+ len(pending_commands_in_batch(commands.list_for_batch(batch.id)))
47
+ for batch in pending_batches
48
+ )
49
+
50
+
51
+ def pending_commands_in_batch(commands_for_batch: list[Command]) -> list[Command]:
52
+ """Filter pending commands while preserving their repository order."""
53
+ return [
54
+ command
55
+ for command in commands_for_batch
56
+ if command.status is CommandStatus.PENDING
57
+ ]
58
+
59
+
60
+ def is_batch_resolved(commands_for_batch: list[Command]) -> bool:
61
+ """Return whether every command in the batch has a terminal status."""
62
+ return all_terminal(commands_for_batch)
63
+
64
+
65
+ def resolve_stale_batches(batches: BatchesRepo, commands: CommandsRepo) -> None:
66
+ """Stamp `resolved_at` on any pending batch whose commands are all terminal.
67
+
68
+ A command can turn terminal without going through the TUI's own
69
+ approve/reject path -- the MCP AUTO-mode auto-execution path is one
70
+ example. Whenever that happens the batch's `resolved_at` must be
71
+ stamped too, or it sits at the head of the FIFO queue forever with
72
+ nothing left to approve or reject, silently blocking every batch
73
+ behind it. This sweeps up any batch left in that state.
74
+ """
75
+ for batch in batches.list_pending():
76
+ commands_for_batch = commands.list_for_batch(batch.id)
77
+ if commands_for_batch and is_batch_resolved(commands_for_batch):
78
+ batches.mark_resolved(batch.id)
79
+
80
+
81
+ def fail_orphaned_approvals(commands: CommandsRepo) -> None:
82
+ """Fail any command left `APPROVED` by a process that died mid-execution.
83
+
84
+ Approval runs synchronously end-to-end in one call: mark APPROVED, run
85
+ the command, stamp EXECUTED/FAILED. A command still APPROVED means that
86
+ call never got to finish -- it can never reach a terminal status on its
87
+ own, which keeps its batch stuck exactly like the `resolved_at` gap
88
+ `resolve_stale_batches` sweeps up.
89
+ """
90
+ for command in commands.list_by_status(CommandStatus.APPROVED):
91
+ _ = commands.update_status(
92
+ command.id,
93
+ status=CommandStatus.FAILED,
94
+ approved_by=command.approved_by,
95
+ result="interrupted before completion (cgate restarted mid-execution)",
96
+ expected_status=CommandStatus.APPROVED,
97
+ )
98
+
99
+
100
+ def heal_queue(batches: BatchesRepo, commands: CommandsRepo) -> None:
101
+ """Repair queue state left inconsistent by a crash or a since-fixed bug.
102
+
103
+ Runs once when `cgate watch` starts, before the dashboard is shown.
104
+ """
105
+ fail_orphaned_approvals(commands)
106
+ resolve_stale_batches(batches, commands)
cgate/watch/render.py ADDED
@@ -0,0 +1,156 @@
1
+ """Pure markup helpers for the cgate watch TUI (spec §Colors in cgate watch)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Final
6
+
7
+ from rich.markup import escape as escape_markup
8
+
9
+ from cgate.db.types import CommandStatus, ServerType
10
+ from cgate.watch.theme import CGATE_THEME
11
+
12
+ if TYPE_CHECKING:
13
+ from cgate.db.types import Batch, Command
14
+
15
+ _RESULT_SNIPPET_MAX_CHARS: Final = 200
16
+
17
+ # Rich markup can't reference Textual's $warning/$error/$success CSS
18
+ # variables directly, so these pull the same hex values from CGATE_THEME
19
+ # instead of hardcoding Rich's generic "yellow"/"red"/"green" names --
20
+ # otherwise the two would drift apart the moment the theme's palette changes.
21
+ _STATUS_GLYPHS: Final[dict[CommandStatus, str]] = {
22
+ CommandStatus.PENDING: f"[{CGATE_THEME.warning}]●[/{CGATE_THEME.warning}]",
23
+ CommandStatus.APPROVED: f"[{CGATE_THEME.warning}]◐[/{CGATE_THEME.warning}]",
24
+ CommandStatus.EXECUTED: f"[{CGATE_THEME.success}]✓[/{CGATE_THEME.success}]",
25
+ CommandStatus.REJECTED: f"[{CGATE_THEME.error}]✗[/{CGATE_THEME.error}]",
26
+ CommandStatus.FAILED: f"[{CGATE_THEME.error}]✗[/{CGATE_THEME.error}]",
27
+ }
28
+
29
+
30
+ def status_glyph(status: CommandStatus) -> str:
31
+ """Return the colored status symbol for one command."""
32
+ return _STATUS_GLYPHS[status]
33
+
34
+
35
+ def server_badge(server_type: ServerType) -> str:
36
+ """Render a compact, colored server-type badge."""
37
+ match server_type:
38
+ case ServerType.WINDOWS:
39
+ return "[cyan]WIN[/cyan]"
40
+ case ServerType.LINUX:
41
+ return "[green]LNX[/green]"
42
+
43
+
44
+ def approver_badge(approved_by: str | None) -> str:
45
+ """Render who or what approved a command.
46
+
47
+ ``mcp_server/tools.py`` prefixes auto-executed approvals with
48
+ ``auto:`` (see ``_auto_approve_by``) specifically so this can tell
49
+ them apart from a human's OS username at render time.
50
+ """
51
+ if not approved_by:
52
+ return ""
53
+ if approved_by.startswith("auto:"):
54
+ return " [cyan]⚙ auto[/cyan]"
55
+ return f" [green]👤 {escape_markup(approved_by)}[/green]"
56
+
57
+
58
+ def risk_warning(risk_label: str | None) -> str:
59
+ """Render the high-blast-radius warning line, or an empty string when unflagged.
60
+
61
+ Shown regardless of PROPOSE/AUTO mode -- a human scanning the queue
62
+ should see it either way, not just when it happened to be the reason
63
+ AUTO mode queued the command instead of running it.
64
+ """
65
+ if not risk_label:
66
+ return ""
67
+ error = CGATE_THEME.error
68
+ return f"[bold {error}]⚠ RISKY:[/bold {error}] [{error}]{escape_markup(risk_label)}[/{error}]"
69
+
70
+
71
+ def format_batch_header(batch: Batch) -> str:
72
+ """Render a bold, brand-colored title with an optional grey italic description below it."""
73
+ primary = CGATE_THEME.primary
74
+ lines = [f"[bold {primary}]{escape_markup(batch.title)}[/bold {primary}]"]
75
+ if batch.description:
76
+ lines.append(f"[grey50 italic]{escape_markup(batch.description)}[/grey50 italic]")
77
+ return "\n".join(lines)
78
+
79
+
80
+ def format_queue_summary(*, pending_commands: int, waiting_batches: int) -> str:
81
+ """Render the always-on queue summary, or an empty string when it's empty.
82
+
83
+ Replaces the old "N batch(es) waiting" notice, which only ever
84
+ appeared once a *second* batch queued up -- with only one batch
85
+ pending (the common case) it showed nothing at all, even though
86
+ there could be several commands in it still needing a decision.
87
+ """
88
+ if pending_commands <= 0:
89
+ return ""
90
+ parts = [f"{pending_commands} pending command(s)"]
91
+ if waiting_batches > 0:
92
+ parts.append(f"{waiting_batches} batch(es) waiting")
93
+ warning = CGATE_THEME.warning
94
+ return f"[{warning}]▲ {' · '.join(parts)}[/{warning}]"
95
+
96
+
97
+ def _result_snippet(result: str, *, max_chars: int = _RESULT_SNIPPET_MAX_CHARS) -> str:
98
+ """Trim a result blob to its first line, capped to a display-friendly length."""
99
+ stripped = result.strip()
100
+ if not stripped:
101
+ return ""
102
+ first_line = stripped.splitlines()[0]
103
+ if len(first_line) > max_chars:
104
+ return first_line[:max_chars] + "…"
105
+ return first_line
106
+
107
+
108
+ def format_command_line(command: Command) -> str:
109
+ """Render one command row: status glyph, text, badges, reason, result snippet."""
110
+ glyph = status_glyph(command.status)
111
+ badge = server_badge(command.server_type)
112
+ line = (
113
+ f"{glyph} {escape_markup(command.command)} {badge} "
114
+ f"[dim]{escape_markup(command.server_alias)}[/dim]"
115
+ f"{approver_badge(command.approved_by)}"
116
+ )
117
+ if command.risk_label:
118
+ line += f"\n {risk_warning(command.risk_label)}"
119
+ if command.reason:
120
+ line += f"\n [dim italic]↳ {escape_markup(command.reason)}[/dim italic]"
121
+ if command.status is CommandStatus.APPROVED:
122
+ line += "\n [dim]⏳ running…[/dim]"
123
+ elif command.status is CommandStatus.EXECUTED and command.result:
124
+ line += f"\n [dim]{escape_markup(_result_snippet(command.result))}[/dim]"
125
+ elif command.status is CommandStatus.FAILED and command.result:
126
+ error = CGATE_THEME.error
127
+ line += f"\n [{error} dim]{escape_markup(_result_snippet(command.result))}[/{error} dim]"
128
+ return line
129
+
130
+
131
+ def format_command_detail(command: Command) -> str:
132
+ """Render a command's full detail: status, server, reason, approver, full result.
133
+
134
+ Unlike `format_command_line`, the result is shown in full -- not
135
+ capped to `_RESULT_SNIPPET_MAX_CHARS` or truncated to one line.
136
+ """
137
+ server_line = (
138
+ f"[dim]{escape_markup(command.server_alias)}[/dim] {server_badge(command.server_type)}"
139
+ f" [dim]status: {command.status.value}[/dim]"
140
+ )
141
+ lines = [
142
+ f"{status_glyph(command.status)} [bold]{escape_markup(command.command)}[/bold]",
143
+ server_line,
144
+ ]
145
+ if command.risk_label:
146
+ lines.append(risk_warning(command.risk_label))
147
+ if command.reason:
148
+ lines.append(f"\n[italic]Reason:[/italic] {escape_markup(command.reason)}")
149
+ if command.approved_by:
150
+ lines.append(f"\n[italic]Approved by:[/italic]{approver_badge(command.approved_by)}")
151
+ if command.result:
152
+ stripped = command.result.strip()
153
+ if stripped:
154
+ lines.append("\n[bold]Result:[/bold]")
155
+ lines.append(escape_markup(stripped))
156
+ return "\n".join(lines)
@@ -0,0 +1,179 @@
1
+ """Modal for editing per-server auto-approve opt-in flags (`s` key in cgate watch)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import sqlite3
7
+ from typing import TYPE_CHECKING, ClassVar
8
+
9
+ from textual.binding import Binding
10
+ from textual.containers import Vertical
11
+ from textual.screen import ModalScreen
12
+ from textual.widgets import ListItem, ListView, Static
13
+ from typing_extensions import override
14
+
15
+ from cgate.db.mode import AppModeNotSetError, Mode
16
+ from cgate.watch.mode_modal import mode_markup
17
+ from cgate.watch.render import server_badge
18
+ from cgate.watch.theme import CGATE_THEME
19
+
20
+ if TYPE_CHECKING:
21
+ from textual.app import ComposeResult
22
+ from textual.binding import BindingType
23
+
24
+ from cgate.connections.store import ConnectionsRepo
25
+ from cgate.db.mode import AppModeRepo
26
+ from cgate.db.server_settings import ServerSettingsRepo
27
+ from cgate.db.types import Connection
28
+
29
+
30
+ def _updated_by() -> str:
31
+ """Return the OS username for the audit column, falling back to 'unknown'."""
32
+ try:
33
+ return getpass.getuser() or "unknown"
34
+ except (KeyError, OSError):
35
+ return "unknown"
36
+
37
+
38
+ def _row_markup(connection: Connection, *, auto_allowed: bool) -> str:
39
+ """Render one row: alias, server-type badge, and the auto-allowed checkbox."""
40
+ checkbox = "[✓]" if auto_allowed else "[ ]"
41
+ return f"{connection.alias} {server_badge(connection.server_type)} {checkbox}"
42
+
43
+
44
+ class ServerRow(ListItem):
45
+ """One connection row in the settings list, carrying its alias for toggling."""
46
+
47
+ alias: str
48
+ _connection: Connection # class-level annotation required by strict mode
49
+
50
+ def __init__(self, connection: Connection, *, auto_allowed: bool) -> None:
51
+ """Render the row and remember which connection it belongs to."""
52
+ super().__init__(Static(_row_markup(connection, auto_allowed=auto_allowed), markup=True))
53
+ self.alias = connection.alias
54
+ self._connection = connection
55
+
56
+ def set_auto_allowed(self, *, auto_allowed: bool) -> None:
57
+ """Re-render the row's checkbox for the new in-memory state."""
58
+ _ = self.query_one(Static).update(_row_markup(self._connection, auto_allowed=auto_allowed))
59
+
60
+
61
+ class ServerSettingsModal(ModalScreen[bool]):
62
+ """Edit per-server auto-approve flags: Space toggles, Enter saves, Esc cancels.
63
+
64
+ Dismisses with ``True`` after a successful commit (even when nothing
65
+ changed); dismisses with ``False`` on Esc, with no writes at all.
66
+ """
67
+
68
+ CSS: ClassVar[str] = """
69
+ ServerSettingsModal { align: center middle; }
70
+ #settings-dialog {
71
+ width: 72;
72
+ height: auto;
73
+ max-height: 80%;
74
+ border: round $primary;
75
+ background: $surface;
76
+ padding: 1 2;
77
+ }
78
+ #settings-list { height: auto; max-height: 16; margin-top: 1; }
79
+ #settings-error { color: $error; }
80
+ """
81
+
82
+ # Priority bindings: checked before the focused ListView's own bindings,
83
+ # so Enter commits instead of triggering ListView's row-select.
84
+ BINDINGS: ClassVar[list[BindingType]] = [
85
+ Binding("space", "toggle_row", "Toggle", show=False, priority=True),
86
+ Binding("enter", "commit", "Save", show=False, priority=True),
87
+ Binding("escape", "cancel", "Cancel", show=False, priority=True),
88
+ ]
89
+
90
+ _connections: ConnectionsRepo # class-level annotation required by strict mode
91
+ _settings: ServerSettingsRepo # class-level annotation required by strict mode
92
+ _mode_repo: AppModeRepo # class-level annotation required by strict mode
93
+ _state: dict[str, bool]
94
+ _initial: dict[str, bool]
95
+
96
+ def __init__(
97
+ self,
98
+ *,
99
+ connections: ConnectionsRepo,
100
+ settings: ServerSettingsRepo,
101
+ mode_repo: AppModeRepo,
102
+ ) -> None:
103
+ """Store the repositories used to list connections and persist flag changes."""
104
+ super().__init__()
105
+ self._connections = connections
106
+ self._settings = settings
107
+ self._mode_repo = mode_repo
108
+ self._state = {}
109
+ self._initial = {}
110
+
111
+ @override
112
+ def compose(self) -> ComposeResult:
113
+ """Show the title, the current global mode, the hint line, and the list."""
114
+ try:
115
+ current = self._mode_repo.get().mode
116
+ except AppModeNotSetError:
117
+ # Safe-by-default: an unset mode behaves as PROPOSE everywhere else.
118
+ current = Mode.PROPOSE
119
+ with Vertical(id="settings-dialog"):
120
+ yield Static("[bold]Auto-approve per server[/bold]")
121
+ yield Static(f"Global mode: {mode_markup(current)}")
122
+ yield Static("[dim]Space = toggle — Enter = save — Esc = cancel[/dim]")
123
+ yield ListView(id="settings-list")
124
+ yield Static("", id="settings-error")
125
+
126
+ def on_mount(self) -> None:
127
+ """Populate the list with every connection and its effective flag."""
128
+ list_view = self.query_one("#settings-list", ListView)
129
+ try:
130
+ connections = self._connections.list_all()
131
+ for connection in connections:
132
+ allowed = self._settings.get_or_default(connection.alias).auto_allowed
133
+ self._state[connection.alias] = allowed
134
+ self._initial[connection.alias] = allowed
135
+ _ = list_view.append(ServerRow(connection, auto_allowed=allowed))
136
+ except sqlite3.Error as exc:
137
+ error = CGATE_THEME.error
138
+ _ = self.query_one("#settings-error", Static).update(
139
+ f"[{error}]Could not read the servers:[/{error}] {exc}"
140
+ )
141
+ return
142
+ if self._state:
143
+ list_view.index = 0
144
+ list_view.focus()
145
+
146
+ def action_toggle_row(self) -> None:
147
+ """Flip the highlighted row's in-memory flag (no writes until Enter)."""
148
+ list_view = self.query_one("#settings-list", ListView)
149
+ row = list_view.highlighted_child
150
+ if not isinstance(row, ServerRow):
151
+ return
152
+ new_value = not self._state[row.alias]
153
+ self._state[row.alias] = new_value
154
+ row.set_auto_allowed(auto_allowed=new_value)
155
+
156
+ def action_commit(self) -> None:
157
+ """Persist every alias whose flag changed since the modal opened."""
158
+ try:
159
+ for alias, allowed in self._state.items():
160
+ if allowed != self._initial[alias]:
161
+ _ = self._settings.set(
162
+ alias=alias,
163
+ auto_allowed=allowed,
164
+ updated_by=_updated_by(),
165
+ )
166
+ except sqlite3.Error as exc:
167
+ error = CGATE_THEME.error
168
+ _ = self.query_one("#settings-error", Static).update(
169
+ f"[{error}]Could not save the changes:[/{error}] {exc}"
170
+ )
171
+ return
172
+ self.dismiss(True) # noqa: FBT003 - ModalScreen[bool].dismiss takes the result positionally
173
+
174
+ def action_cancel(self) -> None:
175
+ """Dismiss without writing anything."""
176
+ self.dismiss(False) # noqa: FBT003 - ModalScreen[bool].dismiss takes the result positionally
177
+
178
+
179
+ __all__ = ["ServerSettingsModal"]
cgate/watch/session.py ADDED
@@ -0,0 +1,40 @@
1
+ """Entry point for `cgate watch`: always launches the live approval dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from cgate.connections.store import ConnectionsRepo
8
+ from cgate.db.batches import BatchesRepo
9
+ from cgate.db.commands import CommandsRepo
10
+ from cgate.db.mode import AppModeRepo
11
+ from cgate.db.server_settings import ServerSettingsRepo
12
+ from cgate.watch.app import WatchApp
13
+ from cgate.watch.queue import heal_queue
14
+
15
+ if TYPE_CHECKING:
16
+ from cgate.db.connection import Database
17
+
18
+
19
+ def run_watch_session(db: Database) -> None:
20
+ """Launch the live approval dashboard, starting idle if the queue is empty.
21
+
22
+ The dashboard never exits on its own: it polls the database for new
23
+ batches and idles when the queue is empty or drains, so it survives
24
+ proposals that arrive at any point while it's open, not only ones
25
+ already pending when it was launched.
26
+ """
27
+ batches = BatchesRepo(db)
28
+ commands = CommandsRepo(db)
29
+ connections = ConnectionsRepo(db)
30
+ mode = AppModeRepo(db)
31
+ server_settings = ServerSettingsRepo(db)
32
+ heal_queue(batches, commands)
33
+ WatchApp(
34
+ db=db,
35
+ batches=batches,
36
+ commands=commands,
37
+ connections=connections,
38
+ mode=mode,
39
+ server_settings=server_settings,
40
+ ).run()
cgate/watch/theme.py ADDED
@@ -0,0 +1,32 @@
1
+ """The `cgate watch` dashboard's Textual theme -- single source for its palette.
2
+
3
+ Lives in its own module (rather than `watch/app.py`, where it's activated)
4
+ so `watch/render.py`'s pure markup helpers can pull the same hex values for
5
+ status glyphs and warnings, instead of Rich's generic named colors drifting
6
+ out of sync with whatever the app's theme actually is.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from textual.theme import Theme
12
+
13
+ # Deep, near-black surfaces with a teal primary give the dashboard its own
14
+ # identity instead of Textual's generic default. warning/error/success map
15
+ # directly onto the semantics already baked into render.py's status glyphs
16
+ # (pending/running=warning, executed=success, rejected/failed/risky=error).
17
+ CGATE_THEME: Theme = Theme(
18
+ name="cgate",
19
+ primary="#2DD4BF",
20
+ secondary="#60A5FA",
21
+ warning="#FBBF24",
22
+ error="#F87171",
23
+ success="#4ADE80",
24
+ accent="#FB923C",
25
+ foreground="#E5E7EB",
26
+ background="#0B0E14",
27
+ surface="#12151C",
28
+ panel="#1E2330",
29
+ dark=True,
30
+ )
31
+
32
+ __all__ = ["CGATE_THEME"]
cgate/watch/widgets.py ADDED
@@ -0,0 +1,35 @@
1
+ """Shared Textual widgets used by both the live dashboard and the history browser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from textual.widgets import Static
8
+
9
+ from cgate.watch.render import format_command_line
10
+
11
+ if TYPE_CHECKING:
12
+ from cgate.db.types import Command, CommandId
13
+
14
+
15
+ class CommandRow(Static):
16
+ """One command line, updatable in place.
17
+
18
+ Lives in its own module (rather than `watch/app.py`, where it used
19
+ to be defined) so `watch/history_modal.py` can reuse it without
20
+ creating an import cycle back into `app.py`.
21
+ """
22
+
23
+ command_id: CommandId
24
+
25
+ def __init__(self, command: Command) -> None:
26
+ """Render the initial line for this command and remember its id."""
27
+ super().__init__(format_command_line(command), markup=True)
28
+ self.command_id = command.id
29
+
30
+ def update_command(self, command: Command) -> None:
31
+ """Refresh this row's text for the command's current state."""
32
+ _ = self.update(format_command_line(command))
33
+
34
+
35
+ __all__ = ["CommandRow"]