cinna-cli 0.1.0__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.
- cinna/__init__.py +3 -0
- cinna/auth.py +42 -0
- cinna/bootstrap.py +278 -0
- cinna/client.py +169 -0
- cinna/config.py +193 -0
- cinna/console.py +39 -0
- cinna/context.py +216 -0
- cinna/errors.py +56 -0
- cinna/logging.py +38 -0
- cinna/main.py +715 -0
- cinna/mcp_proxy.py +151 -0
- cinna/mutagen_runtime.py +168 -0
- cinna/sync.py +120 -0
- cinna/sync_session.py +418 -0
- cinna/sync_ssh_shim.py +232 -0
- cinna/sync_tui.py +352 -0
- cinna/templates/CLAUDE.md.template +558 -0
- cinna/templates/__init__.py +0 -0
- cinna_cli-0.1.0.dist-info/METADATA +231 -0
- cinna_cli-0.1.0.dist-info/RECORD +23 -0
- cinna_cli-0.1.0.dist-info/WHEEL +4 -0
- cinna_cli-0.1.0.dist-info/entry_points.txt +3 -0
- cinna_cli-0.1.0.dist-info/licenses/LICENSE.md +21 -0
cinna/sync_tui.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Textual TUI for `cinna sync start`.
|
|
2
|
+
|
|
3
|
+
Two tabs:
|
|
4
|
+
* **Sync** — friendly status block with a live activity log derived from
|
|
5
|
+
polling ``mutagen sync list``. This is the default view.
|
|
6
|
+
* **Details** — raw ``mutagen sync list --long <name>`` output, what
|
|
7
|
+
Mutagen itself shows a power user.
|
|
8
|
+
|
|
9
|
+
The TUI polls the Mutagen daemon once per second. Ctrl-C / ``q`` quits; the
|
|
10
|
+
caller (``sync_session.run_foreground``) is responsible for terminating the
|
|
11
|
+
Mutagen session once the TUI exits — sync does not outlive the terminal.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
from collections import deque
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from textual.app import App, ComposeResult
|
|
25
|
+
from textual.binding import Binding
|
|
26
|
+
from textual.containers import VerticalScroll
|
|
27
|
+
from textual.widgets import Footer, Header, Log, Static, TabbedContent, TabPane
|
|
28
|
+
|
|
29
|
+
from .config import CinnaConfig
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _fmt_size(n: int) -> str:
|
|
35
|
+
if n < 1024:
|
|
36
|
+
return f"{n} B"
|
|
37
|
+
for unit in ("KB", "MB", "GB", "TB"):
|
|
38
|
+
n /= 1024.0
|
|
39
|
+
if n < 1024:
|
|
40
|
+
return f"{n:.1f} {unit}"
|
|
41
|
+
return f"{n:.1f} PB"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _state_pill(session: dict | None) -> str:
|
|
45
|
+
if session is None:
|
|
46
|
+
return "[red]⬤ Session not found[/red]"
|
|
47
|
+
if session.get("paused"):
|
|
48
|
+
return "[yellow]⬤ Paused[/yellow]"
|
|
49
|
+
alpha_conn = bool((session.get("alpha") or {}).get("connected"))
|
|
50
|
+
beta_conn = bool((session.get("beta") or {}).get("connected"))
|
|
51
|
+
last_error = session.get("lastError")
|
|
52
|
+
status = (session.get("status") or "").lower()
|
|
53
|
+
if last_error:
|
|
54
|
+
return f"[red]⬤ Error[/red]"
|
|
55
|
+
if not (alpha_conn and beta_conn):
|
|
56
|
+
return "[red]⬤ Disconnected[/red]"
|
|
57
|
+
if status in {"watching", "watching-changes", "ready"}:
|
|
58
|
+
return "[green]⬤ Watching for changes[/green]"
|
|
59
|
+
if status in {"scanning", "staging", "transitioning", "saving", "reconciling", "transferring"}:
|
|
60
|
+
return f"[cyan]⬤ {status.title()}[/cyan]"
|
|
61
|
+
return f"[cyan]⬤ {status.title() or 'Connected'}[/cyan]"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SyncApp(App):
|
|
65
|
+
"""Live status TUI for a single Mutagen sync session."""
|
|
66
|
+
|
|
67
|
+
CSS = """
|
|
68
|
+
Screen { background: $surface; }
|
|
69
|
+
#status { height: 7; padding: 1 2; border: round $primary; margin: 1 1 0 1; }
|
|
70
|
+
#stats { height: 3; padding: 0 2; margin: 0 1; color: $text; }
|
|
71
|
+
#activity { border: round $primary; margin: 0 1 1 1; }
|
|
72
|
+
#details-scroll { padding: 1 2; margin: 1; }
|
|
73
|
+
#details-text { color: $text; }
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
# Tab order matters for left/right cycling.
|
|
77
|
+
TAB_IDS: tuple[str, ...] = ("sync-tab", "details-tab")
|
|
78
|
+
|
|
79
|
+
BINDINGS = [
|
|
80
|
+
Binding("q", "quit", "Quit", show=True, priority=True),
|
|
81
|
+
Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
|
|
82
|
+
Binding("left", "cycle_tab(-1)", "◀ Tab", show=True, priority=True),
|
|
83
|
+
Binding("right", "cycle_tab(1)", "Tab ▶", show=True, priority=True),
|
|
84
|
+
Binding("1", "show_tab('sync-tab')", "Sync", show=False, priority=True),
|
|
85
|
+
Binding("2", "show_tab('details-tab')", "Details", show=False, priority=True),
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
POLL_INTERVAL = 1.0
|
|
89
|
+
MAX_LOG_LINES = 500
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
config: CinnaConfig,
|
|
94
|
+
session_name: str,
|
|
95
|
+
mutagen_env: dict[str, str],
|
|
96
|
+
) -> None:
|
|
97
|
+
super().__init__()
|
|
98
|
+
self.config = config
|
|
99
|
+
self.session_name = session_name
|
|
100
|
+
self._env = mutagen_env
|
|
101
|
+
self._prev: dict | None = None
|
|
102
|
+
self._poll_task: asyncio.Task | None = None
|
|
103
|
+
|
|
104
|
+
# ── Layout ────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
def compose(self) -> ComposeResult:
|
|
107
|
+
yield Header(show_clock=True)
|
|
108
|
+
with TabbedContent(initial="sync-tab"):
|
|
109
|
+
with TabPane("Sync", id="sync-tab"):
|
|
110
|
+
yield Static("", id="status")
|
|
111
|
+
yield Static("", id="stats")
|
|
112
|
+
yield Log(
|
|
113
|
+
id="activity",
|
|
114
|
+
highlight=False,
|
|
115
|
+
auto_scroll=True,
|
|
116
|
+
max_lines=self.MAX_LOG_LINES,
|
|
117
|
+
)
|
|
118
|
+
with TabPane("Details", id="details-tab"):
|
|
119
|
+
with VerticalScroll(id="details-scroll"):
|
|
120
|
+
yield Static("Loading…", id="details-text")
|
|
121
|
+
yield Footer()
|
|
122
|
+
|
|
123
|
+
# ── Lifecycle ─────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
async def on_mount(self) -> None:
|
|
126
|
+
self.title = f"cinna sync — {self.config.agent_name}"
|
|
127
|
+
self.sub_title = self.session_name
|
|
128
|
+
self._disable_mouse_tracking()
|
|
129
|
+
self._poll_task = asyncio.create_task(self._poll_loop())
|
|
130
|
+
await self._refresh_once()
|
|
131
|
+
|
|
132
|
+
async def on_unmount(self) -> None:
|
|
133
|
+
if self._poll_task is not None:
|
|
134
|
+
self._poll_task.cancel()
|
|
135
|
+
|
|
136
|
+
def _disable_mouse_tracking(self) -> None:
|
|
137
|
+
"""Turn off the mouse-tracking modes textual enabled on startup.
|
|
138
|
+
|
|
139
|
+
Some terminal/shell combinations echo SGR mouse sequences as literal
|
|
140
|
+
text in the scrollback instead of consuming them as input events. We
|
|
141
|
+
don't use the mouse in this app (keyboard-only), so disabling tracking
|
|
142
|
+
makes the issue impossible.
|
|
143
|
+
|
|
144
|
+
Sequences mirror the ones textual emits to enable tracking — disable
|
|
145
|
+
variants use ``l`` instead of ``h``.
|
|
146
|
+
"""
|
|
147
|
+
import sys as _sys
|
|
148
|
+
try:
|
|
149
|
+
_sys.__stdout__.write(
|
|
150
|
+
"\033[?1000l" # X10 mouse off
|
|
151
|
+
"\033[?1002l" # button-event tracking off
|
|
152
|
+
"\033[?1003l" # any-event tracking off
|
|
153
|
+
"\033[?1006l" # SGR extended mode off
|
|
154
|
+
"\033[?1015l" # URxvt extended mode off
|
|
155
|
+
)
|
|
156
|
+
_sys.__stdout__.flush()
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
def action_show_tab(self, tab_id: str) -> None:
|
|
161
|
+
self.query_one(TabbedContent).active = tab_id
|
|
162
|
+
|
|
163
|
+
def action_cycle_tab(self, direction: int) -> None:
|
|
164
|
+
"""Cycle tabs with left/right arrows (wraps at ends)."""
|
|
165
|
+
tabs = self.query_one(TabbedContent)
|
|
166
|
+
try:
|
|
167
|
+
idx = self.TAB_IDS.index(tabs.active)
|
|
168
|
+
except ValueError:
|
|
169
|
+
idx = 0
|
|
170
|
+
tabs.active = self.TAB_IDS[(idx + direction) % len(self.TAB_IDS)]
|
|
171
|
+
|
|
172
|
+
# All content widgets are read-only — don't let them swallow keys meant
|
|
173
|
+
# for the app bindings (e.g. Log grabs pgup/pgdn, Scroll consumes arrows).
|
|
174
|
+
async def on_ready(self) -> None:
|
|
175
|
+
for widget_id in ("status", "stats", "activity", "details-text", "details-scroll"):
|
|
176
|
+
try:
|
|
177
|
+
w = self.query_one(f"#{widget_id}")
|
|
178
|
+
except Exception:
|
|
179
|
+
continue
|
|
180
|
+
w.can_focus = False
|
|
181
|
+
|
|
182
|
+
# ── Polling ───────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
async def _poll_loop(self) -> None:
|
|
185
|
+
try:
|
|
186
|
+
while True:
|
|
187
|
+
await asyncio.sleep(self.POLL_INTERVAL)
|
|
188
|
+
await self._refresh_once()
|
|
189
|
+
except asyncio.CancelledError:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
async def _refresh_once(self) -> None:
|
|
193
|
+
session = await self._fetch_session_json()
|
|
194
|
+
self._render_sync_tab(session)
|
|
195
|
+
details_text = await self._fetch_session_long()
|
|
196
|
+
self.query_one("#details-text", Static).update(details_text)
|
|
197
|
+
|
|
198
|
+
async def _fetch_session_json(self) -> dict | None:
|
|
199
|
+
stdout = await self._run_mutagen(
|
|
200
|
+
["sync", "list", "--template", "{{json .}}", self.session_name],
|
|
201
|
+
)
|
|
202
|
+
if not stdout or stdout == "null":
|
|
203
|
+
return None
|
|
204
|
+
try:
|
|
205
|
+
data = json.loads(stdout)
|
|
206
|
+
except json.JSONDecodeError:
|
|
207
|
+
return None
|
|
208
|
+
if isinstance(data, list):
|
|
209
|
+
return data[0] if data else None
|
|
210
|
+
if isinstance(data, dict):
|
|
211
|
+
return data
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
async def _fetch_session_long(self) -> str:
|
|
215
|
+
stdout = await self._run_mutagen(
|
|
216
|
+
["sync", "list", "--long", self.session_name],
|
|
217
|
+
)
|
|
218
|
+
return stdout or "(no data)"
|
|
219
|
+
|
|
220
|
+
async def _run_mutagen(self, args: list[str]) -> str:
|
|
221
|
+
try:
|
|
222
|
+
proc = await asyncio.create_subprocess_exec(
|
|
223
|
+
"mutagen",
|
|
224
|
+
*args,
|
|
225
|
+
# Detach from the controlling tty — otherwise each spawn races
|
|
226
|
+
# textual's driver for stdin and leaks raw mouse/key escape
|
|
227
|
+
# sequences into the rendered screen.
|
|
228
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
229
|
+
stdout=asyncio.subprocess.PIPE,
|
|
230
|
+
stderr=asyncio.subprocess.PIPE,
|
|
231
|
+
env=self._env,
|
|
232
|
+
start_new_session=True,
|
|
233
|
+
)
|
|
234
|
+
stdout, _ = await proc.communicate()
|
|
235
|
+
except (FileNotFoundError, OSError) as exc:
|
|
236
|
+
return f"(mutagen unavailable: {exc})"
|
|
237
|
+
if proc.returncode != 0:
|
|
238
|
+
return ""
|
|
239
|
+
return stdout.decode("utf-8", errors="replace").strip()
|
|
240
|
+
|
|
241
|
+
# ── Render: Sync tab ──────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
def _render_sync_tab(self, session: dict | None) -> None:
|
|
244
|
+
status_w = self.query_one("#status", Static)
|
|
245
|
+
stats_w = self.query_one("#stats", Static)
|
|
246
|
+
activity = self.query_one("#activity", Log)
|
|
247
|
+
|
|
248
|
+
pill = _state_pill(session)
|
|
249
|
+
alpha = (session or {}).get("alpha") or {}
|
|
250
|
+
beta = (session or {}).get("beta") or {}
|
|
251
|
+
|
|
252
|
+
alpha_url = alpha.get("path") or "?"
|
|
253
|
+
beta_host = beta.get("host")
|
|
254
|
+
beta_path = beta.get("path") or "?"
|
|
255
|
+
beta_url = f"{beta.get('user','')}@{beta_host}:{beta_path}" if beta_host else beta_path
|
|
256
|
+
|
|
257
|
+
last_error = (session or {}).get("lastError") or ""
|
|
258
|
+
status_lines = [
|
|
259
|
+
pill,
|
|
260
|
+
f"[dim]Agent:[/dim] {self.config.agent_name} [dim]@[/dim] {self.config.platform_url}",
|
|
261
|
+
f"[dim]Local:[/dim] {alpha_url}",
|
|
262
|
+
f"[dim]Remote:[/dim] {beta_url}",
|
|
263
|
+
]
|
|
264
|
+
if last_error:
|
|
265
|
+
status_lines.append(f"[red]{last_error}[/red]")
|
|
266
|
+
status_w.update("\n".join(status_lines))
|
|
267
|
+
|
|
268
|
+
files = int(alpha.get("files") or 0)
|
|
269
|
+
dirs = int(alpha.get("directories") or 0)
|
|
270
|
+
size = int(alpha.get("totalFileSize") or 0)
|
|
271
|
+
cycles = int((session or {}).get("successfulCycles") or 0)
|
|
272
|
+
stats_w.update(
|
|
273
|
+
f"[bold]{files}[/bold] files · [bold]{dirs}[/bold] dirs · "
|
|
274
|
+
f"[bold]{_fmt_size(size)}[/bold] "
|
|
275
|
+
f"Successful cycles: [bold]{cycles}[/bold]"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
self._emit_events(activity, session)
|
|
279
|
+
|
|
280
|
+
def _emit_events(self, log: Log, session: dict | None) -> None:
|
|
281
|
+
now = datetime.now().strftime("%H:%M:%S")
|
|
282
|
+
|
|
283
|
+
def line(msg: str) -> None:
|
|
284
|
+
log.write_line(f"{now} {msg}")
|
|
285
|
+
|
|
286
|
+
if session is None:
|
|
287
|
+
if self._prev is not None:
|
|
288
|
+
line("session disappeared from Mutagen daemon")
|
|
289
|
+
self._prev = None
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
if self._prev is None:
|
|
293
|
+
a_conn = bool((session.get("alpha") or {}).get("connected"))
|
|
294
|
+
b_conn = bool((session.get("beta") or {}).get("connected"))
|
|
295
|
+
if a_conn and b_conn:
|
|
296
|
+
line("sync attached — both endpoints connected")
|
|
297
|
+
else:
|
|
298
|
+
line("sync attached — waiting for endpoints to connect")
|
|
299
|
+
self._prev = session
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
prev = self._prev
|
|
303
|
+
self._prev = session
|
|
304
|
+
|
|
305
|
+
prev_status = (prev.get("status") or "").lower()
|
|
306
|
+
cur_status = (session.get("status") or "").lower()
|
|
307
|
+
if prev_status != cur_status:
|
|
308
|
+
line(f"status: {prev_status or '-'} → {cur_status or '-'}")
|
|
309
|
+
|
|
310
|
+
for side, label in (("alpha", "local"), ("beta", "remote")):
|
|
311
|
+
prev_conn = bool((prev.get(side) or {}).get("connected"))
|
|
312
|
+
cur_conn = bool((session.get(side) or {}).get("connected"))
|
|
313
|
+
if prev_conn != cur_conn:
|
|
314
|
+
line(f"{label} endpoint: {'connected' if cur_conn else 'disconnected'}")
|
|
315
|
+
|
|
316
|
+
prev_files = int((prev.get(side) or {}).get("files") or 0)
|
|
317
|
+
cur_files = int((session.get(side) or {}).get("files") or 0)
|
|
318
|
+
if cur_files != prev_files:
|
|
319
|
+
delta = cur_files - prev_files
|
|
320
|
+
sign = "+" if delta > 0 else ""
|
|
321
|
+
line(f"{label} file count: {prev_files} → {cur_files} ({sign}{delta})")
|
|
322
|
+
|
|
323
|
+
prev_cycles = int(prev.get("successfulCycles") or 0)
|
|
324
|
+
cur_cycles = int(session.get("successfulCycles") or 0)
|
|
325
|
+
if cur_cycles > prev_cycles:
|
|
326
|
+
line(f"completed sync cycle #{cur_cycles}")
|
|
327
|
+
|
|
328
|
+
prev_err = prev.get("lastError") or ""
|
|
329
|
+
cur_err = session.get("lastError") or ""
|
|
330
|
+
if cur_err and cur_err != prev_err:
|
|
331
|
+
line(f"[red]error:[/red] {cur_err}")
|
|
332
|
+
elif prev_err and not cur_err:
|
|
333
|
+
line("error cleared")
|
|
334
|
+
|
|
335
|
+
prev_paused = bool(prev.get("paused"))
|
|
336
|
+
cur_paused = bool(session.get("paused"))
|
|
337
|
+
if prev_paused != cur_paused:
|
|
338
|
+
line("session paused" if cur_paused else "session resumed")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def run_tui(
|
|
342
|
+
config: CinnaConfig,
|
|
343
|
+
session_name: str,
|
|
344
|
+
mutagen_env: dict[str, str],
|
|
345
|
+
) -> int:
|
|
346
|
+
"""Start the TUI app in the current terminal. Returns on user quit."""
|
|
347
|
+
# Suppress textual's INFO logging to keep our logger output clean in the
|
|
348
|
+
# (unlikely) event the user has DEBUG on.
|
|
349
|
+
logging.getLogger("textual").setLevel(logging.WARNING)
|
|
350
|
+
app = SyncApp(config, session_name, mutagen_env)
|
|
351
|
+
app.run()
|
|
352
|
+
return 0
|