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/sync_session.py ADDED
@@ -0,0 +1,418 @@
1
+ """Thin wrapper around the Mutagen CLI.
2
+
3
+ Each workspace gets one Mutagen session named `cinna-<short-agent-id>` that
4
+ continuously syncs `./workspace` against the remote agent env via the
5
+ `cinna-sync-ssh` shim.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import os
11
+ import shlex
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ import click
19
+
20
+ from cinna.config import (
21
+ CinnaConfig,
22
+ GLOBAL_STATE_DIR,
23
+ config_dir,
24
+ upsert_agent_registry,
25
+ workspace_dir,
26
+ )
27
+ from cinna import console
28
+
29
+ logger = logging.getLogger("cinna.sync_session")
30
+
31
+
32
+ MUTAGEN_YML_TEMPLATE = """\
33
+ sync:
34
+ defaults:
35
+ mode: two-way-safe
36
+ permissions:
37
+ mode: portable
38
+ ignore:
39
+ vcs: true
40
+ paths:
41
+ - __pycache__/
42
+ - node_modules/
43
+ - .venv/
44
+ - .cinna/
45
+ - .mypy_cache/
46
+ - .pytest_cache/
47
+ - .DS_Store
48
+ scan:
49
+ mode: accelerated
50
+ """
51
+
52
+
53
+ @dataclass
54
+ class SyncStatus:
55
+ session_name: str
56
+ state: str # "connected", "disconnected", "paused", "error", "unknown"
57
+ pending_to_remote: int = 0
58
+ pending_to_local: int = 0
59
+ conflict_count: int = 0
60
+ last_error: str | None = None
61
+ raw: dict = field(default_factory=dict)
62
+
63
+ @property
64
+ def exists(self) -> bool:
65
+ return self.state != "missing"
66
+
67
+
68
+ def session_name(agent_id: str) -> str:
69
+ """Stable session label — one Mutagen session per agent."""
70
+ short = agent_id.replace("-", "")[:8]
71
+ return f"cinna-{short}"
72
+
73
+
74
+ def mutagen_yml_path(workspace_root: Path) -> Path:
75
+ return workspace_root / "mutagen.yml"
76
+
77
+
78
+ def write_mutagen_yml(workspace_root: Path, overwrite: bool = False) -> Path:
79
+ """Seed a default mutagen.yml if one is not already present."""
80
+ path = mutagen_yml_path(workspace_root)
81
+ if path.exists() and not overwrite:
82
+ return path
83
+ path.write_text(MUTAGEN_YML_TEMPLATE)
84
+ logger.info("Wrote %s", path)
85
+ return path
86
+
87
+
88
+ MUTAGEN_SSH_DIR = GLOBAL_STATE_DIR / "mutagen-ssh"
89
+
90
+
91
+ def _ensure_ssh_shim_dir() -> Path:
92
+ """Materialize a directory containing an `ssh` executable that dispatches
93
+ to `cinna-sync-ssh`.
94
+
95
+ Mutagen's `MUTAGEN_SSH_PATH` is a directory search path — it looks for an
96
+ executable literally named `ssh` inside. Pointing it directly at the
97
+ shim binary does not work; Mutagen reports "unable to locate command".
98
+
99
+ The wrapper is regenerated on each call so the embedded interpreter /
100
+ shim path stays in sync with the current cinna install.
101
+ """
102
+ MUTAGEN_SSH_DIR.mkdir(parents=True, exist_ok=True)
103
+ wrapper = MUTAGEN_SSH_DIR / "ssh"
104
+
105
+ shim_bin = shutil.which("cinna-sync-ssh")
106
+ if shim_bin:
107
+ script = f'#!/usr/bin/env bash\nexec {shlex.quote(shim_bin)} "$@"\n'
108
+ else:
109
+ # Dev / broken-packaging fallback: invoke the module directly with
110
+ # whichever interpreter is running the current cinna command.
111
+ script = (
112
+ f'#!/usr/bin/env bash\n'
113
+ f'exec {shlex.quote(sys.executable)} -m cinna.sync_ssh_shim "$@"\n'
114
+ )
115
+
116
+ wrapper.write_text(script)
117
+ wrapper.chmod(0o755)
118
+ return MUTAGEN_SSH_DIR
119
+
120
+
121
+ def _mutagen_env(config: CinnaConfig) -> dict[str, str]:
122
+ """Env vars Mutagen and the shim need.
123
+
124
+ `MUTAGEN_SSH_PATH` points at our shim directory. The `CINNA_*` vars are
125
+ kept as a fast-path hint for the shim; the authoritative source is the
126
+ per-user `~/.cinna/agents.json` registry, which the shim consults on
127
+ every invocation so a shared Mutagen daemon can serve multiple agents.
128
+ """
129
+ env = os.environ.copy()
130
+ env["MUTAGEN_SSH_PATH"] = str(_ensure_ssh_shim_dir())
131
+ env["CINNA_AGENT_ID"] = config.agent_id
132
+ env["CINNA_CLI_TOKEN"] = config.cli_token
133
+ env["CINNA_PLATFORM_URL"] = config.platform_url
134
+ return env
135
+
136
+
137
+ def _run_mutagen(
138
+ args: list[str],
139
+ config: CinnaConfig,
140
+ cwd: Path | None = None,
141
+ check: bool = False,
142
+ capture: bool = True,
143
+ ) -> subprocess.CompletedProcess:
144
+ """Run `mutagen <args>` with the right env."""
145
+ cmd = ["mutagen", *args]
146
+ logger.debug("exec: %s (cwd=%s)", " ".join(cmd), cwd)
147
+ return subprocess.run(
148
+ cmd,
149
+ cwd=str(cwd) if cwd else None,
150
+ env=_mutagen_env(config),
151
+ capture_output=capture,
152
+ text=True,
153
+ check=check,
154
+ )
155
+
156
+
157
+ def ensure_daemon_running(config: CinnaConfig) -> None:
158
+ """Start the Mutagen daemon if it isn't already."""
159
+ result = _run_mutagen(["daemon", "start"], config)
160
+ if result.returncode != 0 and "already running" not in (result.stderr or ""):
161
+ raise click.ClickException(
162
+ f"Failed to start Mutagen daemon:\n{result.stderr.strip()}"
163
+ )
164
+
165
+
166
+ # Mutagen's daemon captures its env at startup. If the user had a daemon running
167
+ # from an older cinna-cli (broken MUTAGEN_SSH_PATH) or from another Mutagen
168
+ # consumer, the env it uses to spawn `ssh` will be stale and `sync create`
169
+ # fails with one of these messages. Detecting the leaf string lets us restart
170
+ # the daemon once and retry transparently.
171
+ _STALE_DAEMON_MARKERS = (
172
+ "unable to locate command",
173
+ "unable to identify 'ssh' command",
174
+ )
175
+
176
+
177
+ def _looks_like_stale_daemon_error(stderr: str) -> bool:
178
+ text = stderr or ""
179
+ return any(marker in text for marker in _STALE_DAEMON_MARKERS)
180
+
181
+
182
+ def _restart_daemon(config: CinnaConfig) -> None:
183
+ """Bounce the Mutagen daemon so it picks up our env on next spawn.
184
+
185
+ Warning: this terminates any other Mutagen sessions the daemon is managing,
186
+ not just cinna's. They will auto-resume on the next `mutagen sync list` /
187
+ `cinna sync start`, but in-flight syncs pause briefly.
188
+ """
189
+ logger.info("Restarting Mutagen daemon to refresh its environment")
190
+ console.warn("Restarting Mutagen daemon to pick up updated SSH transport…")
191
+ _run_mutagen(["daemon", "stop"], config)
192
+ start_result = _run_mutagen(["daemon", "start"], config)
193
+ if start_result.returncode != 0 and "already running" not in (start_result.stderr or ""):
194
+ raise click.ClickException(
195
+ f"Failed to restart Mutagen daemon:\n{start_result.stderr.strip()}"
196
+ )
197
+
198
+
199
+ def _list_sessions(config: CinnaConfig) -> list[dict]:
200
+ """Return parsed session list from Mutagen.
201
+
202
+ Mutagen 0.18.x has no ``--json`` flag; we render via a Go template that
203
+ pipes the payload through ``json``. The top-level value is a list.
204
+ """
205
+ result = _run_mutagen(["sync", "list", "--template", "{{json .}}"], config)
206
+ if result.returncode != 0:
207
+ logger.debug("mutagen sync list failed: %s", result.stderr)
208
+ return []
209
+ stdout = (result.stdout or "").strip()
210
+ if not stdout or stdout == "null":
211
+ return []
212
+ try:
213
+ data = json.loads(stdout)
214
+ except json.JSONDecodeError as exc:
215
+ logger.warning("Could not parse mutagen JSON: %s", exc)
216
+ return []
217
+ if isinstance(data, list):
218
+ return data
219
+ if isinstance(data, dict):
220
+ return data.get("sessions") or [data]
221
+ return []
222
+
223
+
224
+ def _find_session(config: CinnaConfig) -> dict | None:
225
+ target = session_name(config.agent_id)
226
+ for s in _list_sessions(config):
227
+ if s.get("name") == target or s.get("identifier", "").endswith(target):
228
+ return s
229
+ return None
230
+
231
+
232
+ def start(config: CinnaConfig, workspace_root: Path) -> SyncStatus:
233
+ """Create or resume the per-agent Mutagen sync session.
234
+
235
+ No-ops when a session already exists — callers see a friendly message.
236
+ """
237
+ # Make sure the SSH shim knows how to resolve this agent's credentials
238
+ # even if the daemon was started earlier for a different agent.
239
+ upsert_agent_registry(
240
+ config.agent_id,
241
+ config.platform_url,
242
+ config.cli_token,
243
+ workspace_root,
244
+ frontend_url=config.frontend_url,
245
+ )
246
+
247
+ ensure_daemon_running(config)
248
+ write_mutagen_yml(workspace_root)
249
+
250
+ # Foreground-sync model: every `cinna sync start` owns a fresh session for
251
+ # its lifetime. If a same-named session is already present — either from a
252
+ # crashed previous run or a parallel terminal — we terminate it first so
253
+ # there's exactly one owner. Other terminals wanting to observe can use
254
+ # `cinna sync status`.
255
+ existing = _find_session(config)
256
+ if existing is not None:
257
+ logger.info("Terminating pre-existing session %s before creating fresh one", existing.get("name"))
258
+ _run_mutagen(["sync", "terminate", session_name(config.agent_id)], config)
259
+
260
+ local_path = workspace_dir(workspace_root)
261
+ local_path.mkdir(parents=True, exist_ok=True)
262
+ # OpenSSH-style `host:path`, not `ssh://host/path`. Mutagen's parser
263
+ # resolves the first `:` against the OpenSSH form first and would otherwise
264
+ # treat the literal string "ssh" as the host. The shim parses the resulting
265
+ # argv host token (`cinna-agent-<uuid>`) to derive the agent_id.
266
+ # `/app/workspace` is the fixed bind-mount inside the agent env container
267
+ # (see env-templates/*/Dockerfile and /sync/exec's cwd). mutagen-agent
268
+ # resolves this path absolutely — not relative to its cwd.
269
+ remote_url = f"cinna@cinna-agent-{config.agent_id}:/app/workspace"
270
+
271
+ args = [
272
+ "sync",
273
+ "create",
274
+ "--name",
275
+ session_name(config.agent_id),
276
+ "--sync-mode=two-way-safe",
277
+ "--ignore-vcs",
278
+ str(local_path),
279
+ remote_url,
280
+ ]
281
+ result = _run_mutagen(args, config, cwd=workspace_root)
282
+ if result.returncode != 0 and _looks_like_stale_daemon_error(result.stderr):
283
+ # Daemon was started before our current MUTAGEN_SSH_PATH wiring. Bounce
284
+ # it and retry once; the second pass runs against a fresh env.
285
+ _restart_daemon(config)
286
+ result = _run_mutagen(args, config, cwd=workspace_root)
287
+ if result.returncode != 0:
288
+ raise click.ClickException(
289
+ f"Failed to create Mutagen session:\n{result.stderr.strip() or result.stdout.strip()}"
290
+ )
291
+
292
+ return status(config)
293
+
294
+
295
+ def stop(config: CinnaConfig) -> None:
296
+ """Terminate the per-agent Mutagen session (daemon stays up)."""
297
+ _run_mutagen(["sync", "terminate", session_name(config.agent_id)], config)
298
+
299
+
300
+ def run_foreground(config: CinnaConfig) -> int:
301
+ """Attach the terminal to the Mutagen sync session via a two-tab TUI.
302
+
303
+ The TUI polls ``mutagen sync list`` once per second. Tab 1 renders a
304
+ friendly status block and a derived activity log; Tab 2 shows the raw
305
+ ``mutagen sync list --long`` output for power users.
306
+
307
+ Blocks until the user presses ``q`` / Ctrl-C. On return the Mutagen
308
+ session is terminated so sync does not outlive the TUI.
309
+
310
+ Returns 0 on clean exit.
311
+ """
312
+ from .sync_tui import run_tui
313
+
314
+ session = session_name(config.agent_id)
315
+ env = _mutagen_env(config)
316
+ rc = 0
317
+ try:
318
+ rc = run_tui(config, session, env)
319
+ except KeyboardInterrupt:
320
+ rc = 0
321
+ finally:
322
+ # Terminate the Mutagen session — sync does not outlive the TUI.
323
+ try:
324
+ stop(config)
325
+ except Exception as exc:
326
+ logger.debug("sync stop on foreground exit failed: %s", exc)
327
+ return rc
328
+
329
+
330
+ def status(config: CinnaConfig) -> SyncStatus:
331
+ """Current state of the agent's sync session."""
332
+ session = _find_session(config)
333
+ if session is None:
334
+ return SyncStatus(session_name=session_name(config.agent_id), state="missing")
335
+ return _to_status(config, session)
336
+
337
+
338
+ def _to_status(config: CinnaConfig, session: dict) -> SyncStatus:
339
+ """Map the Mutagen JSON shape onto our SyncStatus dataclass.
340
+
341
+ The shape varies a bit across Mutagen versions; we pull the keys we care
342
+ about defensively and stash the raw blob for callers that want more.
343
+ """
344
+ raw_state = (session.get("status") or session.get("state") or "").lower()
345
+ paused = bool(session.get("paused"))
346
+ if paused:
347
+ state = "paused"
348
+ elif raw_state in {"watching", "scanning", "staging", "transitioning", "saving", "reconciling", "connected", "watching-changes"}:
349
+ state = "connected"
350
+ elif raw_state in {"disconnected", "connecting"}:
351
+ state = "disconnected"
352
+ elif "error" in raw_state or session.get("lastError"):
353
+ state = "error"
354
+ elif raw_state:
355
+ state = raw_state
356
+ else:
357
+ state = "unknown"
358
+
359
+ alpha = session.get("alpha") or {}
360
+ beta = session.get("beta") or {}
361
+ pending_to_remote = _safe_int(alpha.get("stagedChanges"))
362
+ pending_to_local = _safe_int(beta.get("stagedChanges"))
363
+ conflicts = _safe_int(session.get("conflictCount") or len(session.get("conflicts") or []))
364
+
365
+ return SyncStatus(
366
+ session_name=session.get("name") or session_name(config.agent_id),
367
+ state=state,
368
+ pending_to_remote=pending_to_remote,
369
+ pending_to_local=pending_to_local,
370
+ conflict_count=conflicts,
371
+ last_error=session.get("lastError") or None,
372
+ raw=session,
373
+ )
374
+
375
+
376
+ def _safe_int(v) -> int:
377
+ try:
378
+ return int(v or 0)
379
+ except (TypeError, ValueError):
380
+ return 0
381
+
382
+
383
+ @dataclass
384
+ class Conflict:
385
+ path: Path
386
+ kind: str # "alpha" (local) | "beta" (remote) | "unknown"
387
+
388
+
389
+ def list_conflicts(config: CinnaConfig, workspace_root: Path) -> list[Conflict]:
390
+ """Walk workspace for Mutagen conflict copies.
391
+
392
+ Mutagen writes `<name>.conflict.<side>.<ts>` when it can't auto-merge. We
393
+ surface them path-first so the CLI/TUI can offer resolution UX.
394
+ """
395
+ root = workspace_dir(workspace_root)
396
+ if not root.exists():
397
+ return []
398
+
399
+ results: list[Conflict] = []
400
+ for path in root.rglob("*.conflict.*"):
401
+ if not path.is_file():
402
+ continue
403
+ # Parse side from suffix if present — best effort.
404
+ parts = path.name.split(".conflict.")
405
+ kind = "unknown"
406
+ if len(parts) == 2:
407
+ tail = parts[1]
408
+ if tail.startswith("alpha"):
409
+ kind = "alpha"
410
+ elif tail.startswith("beta"):
411
+ kind = "beta"
412
+ results.append(Conflict(path=path, kind=kind))
413
+ return results
414
+
415
+
416
+ def session_log_dir(workspace_root: Path) -> Path:
417
+ """Where we cache per-session breadcrumbs (exec history, etc.)."""
418
+ return config_dir(workspace_root) / "sync"
cinna/sync_ssh_shim.py ADDED
@@ -0,0 +1,232 @@
1
+ """`cinna-sync-ssh` — SSH transport shim for Mutagen.
2
+
3
+ Mutagen invokes us with an argv that looks like:
4
+ cinna-sync-ssh user@cinna-agent-<uuid> -- mutagen-agent <args…>
5
+
6
+ We translate that into a WebSocket to the platform's /sync-stream endpoint and
7
+ pump stdin/stdout both ways. The shim is stateless — each invocation opens a
8
+ fresh WebSocket and exits when either side closes.
9
+
10
+ Credential resolution (per invocation, keyed by argv agent_id):
11
+ 1. `~/.cinna/agents.json` registry — authoritative per-agent credentials,
12
+ always re-read from disk so token rotations made by `cinna connect`
13
+ take effect immediately, even if the Mutagen daemon was launched with
14
+ a stale environment.
15
+ 2. Env-var fallback — only used when the registry has no entry for the
16
+ agent_id. Guarded by a CINNA_AGENT_ID match so a daemon spawned for
17
+ agent A never leaks its env to a later sync of agent B.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ import os
25
+ import sys
26
+ from typing import Sequence
27
+ from urllib.parse import urlparse
28
+
29
+
30
+ def _extract_agent_id(host: str) -> str | None:
31
+ """Accept `user@cinna-agent-<id>` or just `cinna-agent-<id>`."""
32
+ if "@" in host:
33
+ host = host.split("@", 1)[1]
34
+ prefix = "cinna-agent-"
35
+ if host.startswith(prefix):
36
+ return host[len(prefix):]
37
+ return None
38
+
39
+
40
+ def _parse_argv(argv: Sequence[str]) -> tuple[str, list[str]]:
41
+ """Return (host, remote_command_tokens).
42
+
43
+ Mutagen's SSH invocation can look like any of these — OpenSSH-compatible:
44
+ cinna-sync-ssh user@host -- mutagen-agent arg1 arg2
45
+ cinna-sync-ssh user@host mutagen-agent arg1 arg2
46
+ cinna-sync-ssh -p 22 user@host mutagen-agent arg1 arg2
47
+ We accept anything reasonable and extract the host + tail command.
48
+ """
49
+ args = list(argv[1:])
50
+ # Drop flags that take a value (e.g. -p 22, -i key). Conservative: strip
51
+ # any token starting with `-` and the following token if it's a value.
52
+ tokens: list[str] = []
53
+ i = 0
54
+ while i < len(args):
55
+ tok = args[i]
56
+ if tok == "--":
57
+ tokens.extend(args[i + 1:])
58
+ i = len(args)
59
+ break
60
+ if tok.startswith("-") and tok not in ("-T", "-q"):
61
+ # Two-token options OpenSSH uses that Mutagen may pass through.
62
+ if tok in ("-p", "-i", "-o", "-l", "-F") and i + 1 < len(args):
63
+ i += 2
64
+ continue
65
+ i += 1
66
+ continue
67
+ tokens.append(tok)
68
+ i += 1
69
+
70
+ if not tokens:
71
+ sys.stderr.write("cinna-sync-ssh: no host in argv\n")
72
+ sys.exit(2)
73
+
74
+ host = tokens[0]
75
+ remote = tokens[1:]
76
+ return host, remote
77
+
78
+
79
+ def _ws_url(platform_url: str, agent_id: str) -> str:
80
+ """Derive wss:// URL from the CLI's http(s) platform URL."""
81
+ parsed = urlparse(platform_url)
82
+ scheme = "wss" if parsed.scheme == "https" else "ws"
83
+ prefix = parsed.path.rstrip("/")
84
+ return f"{scheme}://{parsed.netloc}{prefix}/api/v1/cli/agents/{agent_id}/sync-stream"
85
+
86
+
87
+ async def _run(ws_url: str, token: str, preamble: dict) -> int:
88
+ try:
89
+ import websockets
90
+ except ImportError:
91
+ sys.stderr.write(
92
+ "cinna-sync-ssh: the 'websockets' package is required. "
93
+ "Reinstall cinna-cli.\n"
94
+ )
95
+ return 1
96
+
97
+ headers = [("Authorization", f"Bearer {token}")]
98
+ try:
99
+ connection = await websockets.connect(
100
+ ws_url,
101
+ additional_headers=headers,
102
+ max_size=None,
103
+ ping_interval=20,
104
+ ping_timeout=20,
105
+ )
106
+ except TypeError:
107
+ # Older websockets releases used `extra_headers`.
108
+ connection = await websockets.connect(
109
+ ws_url,
110
+ extra_headers=headers,
111
+ max_size=None,
112
+ ping_interval=20,
113
+ ping_timeout=20,
114
+ )
115
+ except Exception as exc: # noqa: BLE001 — surface any connect failure
116
+ sys.stderr.write(f"cinna-sync-ssh: connect failed: {exc}\n")
117
+ return 1
118
+
119
+ async with connection as ws:
120
+ await ws.send(json.dumps(preamble).encode("utf-8"))
121
+
122
+ loop = asyncio.get_running_loop()
123
+ stdin_reader = asyncio.StreamReader()
124
+ await loop.connect_read_pipe(
125
+ lambda: asyncio.StreamReaderProtocol(stdin_reader), sys.stdin.buffer
126
+ )
127
+ stdout_fd = sys.stdout.buffer
128
+
129
+ async def stdin_to_ws() -> None:
130
+ try:
131
+ while True:
132
+ data = await stdin_reader.read(65536)
133
+ if not data:
134
+ break
135
+ await ws.send(data)
136
+ except Exception as exc: # noqa: BLE001
137
+ sys.stderr.write(f"cinna-sync-ssh: stdin pump ended: {exc}\n")
138
+
139
+ async def ws_to_stdout() -> None:
140
+ try:
141
+ async for message in ws:
142
+ if isinstance(message, str):
143
+ stdout_fd.write(message.encode("utf-8"))
144
+ else:
145
+ stdout_fd.write(message)
146
+ stdout_fd.flush()
147
+ except Exception as exc: # noqa: BLE001
148
+ sys.stderr.write(f"cinna-sync-ssh: ws pump ended: {exc}\n")
149
+
150
+ pump_stdin = asyncio.create_task(stdin_to_ws())
151
+ pump_out = asyncio.create_task(ws_to_stdout())
152
+
153
+ done, pending = await asyncio.wait(
154
+ [pump_stdin, pump_out], return_when=asyncio.FIRST_COMPLETED
155
+ )
156
+ for task in pending:
157
+ task.cancel()
158
+ await asyncio.gather(*pending, return_exceptions=True)
159
+
160
+ return 0
161
+
162
+
163
+ def _resolve_credentials(agent_id: str) -> tuple[str, str]:
164
+ """Resolve (cli_token, platform_url) for a given agent_id.
165
+
166
+ Registry is preferred over env because:
167
+ * the Mutagen daemon captures its env once at startup and never refreshes,
168
+ so env can be arbitrarily stale after `cinna connect` rotates the token
169
+ (the old env token would be revoked server-side and produce HTTP 403);
170
+ * the registry file is re-read on every invocation and is rewritten by
171
+ every `cinna connect`, so it is always the freshest view.
172
+
173
+ Env is kept as a fallback for first-run edge cases (registry file missing
174
+ entirely), gated by a CINNA_AGENT_ID match to prevent cross-agent leakage.
175
+ """
176
+ try:
177
+ from cinna.config import lookup_agent_registry
178
+ except ImportError as exc:
179
+ sys.stderr.write(f"cinna-sync-ssh: cannot import cinna.config: {exc}\n")
180
+ sys.exit(2)
181
+
182
+ entry = lookup_agent_registry(agent_id)
183
+ if entry:
184
+ token = entry.get("cli_token")
185
+ url = entry.get("platform_url")
186
+ if token and url:
187
+ return token, url
188
+ sys.stderr.write(
189
+ f"cinna-sync-ssh: incomplete registry entry for agent {agent_id}\n"
190
+ )
191
+ sys.exit(2)
192
+
193
+ env_agent = os.environ.get("CINNA_AGENT_ID")
194
+ env_token = os.environ.get("CINNA_CLI_TOKEN")
195
+ env_url = os.environ.get("CINNA_PLATFORM_URL")
196
+ if env_agent == agent_id and env_token and env_url:
197
+ return env_token, env_url
198
+
199
+ sys.stderr.write(
200
+ f"cinna-sync-ssh: no registered credentials for agent {agent_id}. "
201
+ "Run 'cinna connect' from the agent's workspace to register it.\n"
202
+ )
203
+ sys.exit(2)
204
+
205
+
206
+ def main() -> None:
207
+ host, remote = _parse_argv(sys.argv)
208
+ agent_id = _extract_agent_id(host)
209
+ if not agent_id:
210
+ sys.stderr.write(
211
+ f"cinna-sync-ssh: unexpected host '{host}', expected cinna-agent-<uuid>\n"
212
+ )
213
+ sys.exit(2)
214
+
215
+ if not remote:
216
+ sys.stderr.write("cinna-sync-ssh: no remote command in argv\n")
217
+ sys.exit(2)
218
+
219
+ token, platform_url = _resolve_credentials(agent_id)
220
+
221
+ ws_url = _ws_url(platform_url, agent_id)
222
+ preamble = {"remote_command": remote}
223
+
224
+ try:
225
+ exit_code = asyncio.run(_run(ws_url, token, preamble))
226
+ except KeyboardInterrupt:
227
+ exit_code = 130
228
+ sys.exit(exit_code)
229
+
230
+
231
+ if __name__ == "__main__":
232
+ main()