ai-cli-toolkit 0.2.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.
ai_cli/remote.py ADDED
@@ -0,0 +1,669 @@
1
+ """Remote folder proxy — rsync-based bidirectional sync for user@host:/path specs.
2
+
3
+ When a tool is launched with a ``user@host:/path`` directory argument, this
4
+ module rsyncs the remote tree into a local tmpdir, lets the tool work locally,
5
+ then mirrors edits back with rsync on exit. rsync is preferred over scp
6
+ because it transfers only changed bytes (delta compression) and can propagate
7
+ file deletions with ``--delete``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Optional, TextIO
20
+
21
+ # Pattern: user@host:/path or user@host:path (colon required to disambiguate)
22
+ _REMOTE_RE = re.compile(
23
+ r"^(?P<user>[A-Za-z0-9._-]+)@(?P<host>[A-Za-z0-9._-]+):(?P<path>.+)$"
24
+ )
25
+
26
+ _SSH_OPTS = "ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class RemoteSpec:
31
+ """Parsed remote folder specification."""
32
+
33
+ user: str
34
+ host: str
35
+ path: str
36
+
37
+ @property
38
+ def ssh_target(self) -> str:
39
+ """Return ``user@host`` for SSH/rsync commands."""
40
+ return f"{self.user}@{self.host}"
41
+
42
+ @property
43
+ def display(self) -> str:
44
+ return f"{self.user}@{self.host}:{self.path}"
45
+
46
+
47
+ def parse_remote_spec(arg: str) -> Optional[RemoteSpec]:
48
+ """Parse a ``user@host:/path`` string. Returns *None* if not a remote spec."""
49
+ m = _REMOTE_RE.match(arg.strip())
50
+ if m is None:
51
+ return None
52
+ return RemoteSpec(user=m.group("user"), host=m.group("host"), path=m.group("path"))
53
+
54
+
55
+ def is_remote_spec(arg: str) -> bool:
56
+ """Quick check whether *arg* looks like a remote folder spec."""
57
+ return _REMOTE_RE.match(arg.strip()) is not None
58
+
59
+
60
+ def make_local_mirror(spec: RemoteSpec) -> Path:
61
+ """Return a deterministic local mirror directory for *spec*, creating it if needed.
62
+
63
+ Uses ``~/.ai-cli/remote/<host>__<slug>__<hash>/`` so repeated launches
64
+ against the same remote reuse the same local dir (faster incremental rsync).
65
+ """
66
+ root = Path("~/.ai-cli/remote").expanduser()
67
+ digest = hashlib.sha256(spec.display.encode()).hexdigest()[:16]
68
+ safe_host = re.sub(r"[^A-Za-z0-9_-]", "_", spec.host)
69
+ safe_path = re.sub(r"[^A-Za-z0-9_-]", "_", spec.path.strip("/"))[:40]
70
+ local_dir = root / f"{safe_host}__{safe_path}__{digest}"
71
+ local_dir.mkdir(parents=True, exist_ok=True)
72
+ return local_dir
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # rsync helpers
77
+ # ---------------------------------------------------------------------------
78
+
79
+ def _rsync_bin() -> str:
80
+ path = shutil.which("rsync")
81
+ if path is None:
82
+ raise FileNotFoundError(
83
+ "rsync is required for remote folder proxy but was not found on PATH.\n"
84
+ "Install with: brew install rsync (macOS) or apt install rsync (Linux)"
85
+ )
86
+ return path
87
+
88
+
89
+ def _run_rsync(cmd: list[str], *, label: str) -> None:
90
+ proc = subprocess.run(cmd, capture_output=True, text=True)
91
+ if proc.returncode != 0:
92
+ raise RuntimeError(f"rsync {label} failed (rc={proc.returncode}): {proc.stderr.strip()}")
93
+
94
+
95
+ def sync_down(spec: RemoteSpec, local_dir: Path) -> None:
96
+ """Pull remote folder contents into *local_dir* via rsync."""
97
+ local_dir.mkdir(parents=True, exist_ok=True)
98
+ src = f"{spec.ssh_target}:{spec.path.rstrip('/')}/"
99
+ _run_rsync(
100
+ [_rsync_bin(), "-az", "--delete", "--ignore-existing", "--progress", "--exclude=conda/", "--exclude=runtime/tools/analytics/", "-e", _SSH_OPTS, src, str(local_dir) + "/"],
101
+ label="download",
102
+ )
103
+
104
+
105
+ def sync_up(spec: RemoteSpec, local_dir: Path) -> None:
106
+ """Push local changes back to remote via rsync."""
107
+ dest = f"{spec.ssh_target}:{spec.path.rstrip('/')}/"
108
+ _run_rsync(
109
+ [_rsync_bin(), "-az", "--delete", "--progress", "--ignore-existing", "--exclude=conda/", "--exclude=runtime/tools/analytics/", "-e", _SSH_OPTS, str(local_dir) + "/", dest],
110
+ label="upload",
111
+ )
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Pre-flight checks
116
+ # ---------------------------------------------------------------------------
117
+
118
+ def verify_ssh(spec: RemoteSpec) -> None:
119
+ """Quick SSH connectivity + remote dir existence check; raises on failure."""
120
+ ssh_cmd = ["ssh"] + _SSH_OPTS.split()[1:] + [spec.ssh_target]
121
+ # connectivity
122
+ proc = subprocess.run(
123
+ [*ssh_cmd, "echo ok"], capture_output=True, text=True,
124
+ )
125
+ if proc.returncode != 0:
126
+ raise RuntimeError(
127
+ f"Cannot connect to {spec.ssh_target}: {proc.stderr.strip()}\n"
128
+ "Ensure SSH key-based auth is configured for this host."
129
+ )
130
+ # directory exists
131
+ proc = subprocess.run(
132
+ [*ssh_cmd, f"test -d {spec.path!r}"], capture_output=True, text=True,
133
+ )
134
+ if proc.returncode != 0:
135
+ raise RuntimeError(
136
+ f"Remote directory does not exist: {spec.display}\n"
137
+ f"Create it first: ssh {spec.ssh_target} 'mkdir -p {spec.path}'"
138
+ )
139
+
140
+
141
+ def resolve_remote_tool_env(
142
+ spec: RemoteSpec,
143
+ tool_name: str,
144
+ *,
145
+ real_home: str,
146
+ ) -> tuple[str, str]:
147
+ """Resolve a real remote tool binary and usable PATH outside tmux."""
148
+ import shlex as _shlex
149
+
150
+ path_py = (
151
+ "import os; "
152
+ "drop = os.path.join(os.environ[\"REAL_HOME\"], \".ai-cli/bin\"); "
153
+ "print(\":\".join(p for p in os.environ.get(\"PATH\", \"\").split(\":\") "
154
+ "if p and p != drop))"
155
+ )
156
+ # Resolve tilde-prefixed binaries to $REAL_HOME and also try bare name
157
+ bare_name = Path(tool_name).name # e.g. "claude"
158
+ if tool_name.startswith("~/"):
159
+ explicit_path = "$REAL_HOME/" + tool_name[2:]
160
+ elif tool_name.startswith("$HOME/"):
161
+ explicit_path = "$REAL_HOME/" + tool_name[6:]
162
+ else:
163
+ explicit_path = ""
164
+ bare_q = _shlex.quote(bare_name)
165
+ home_q = _shlex.quote(real_home)
166
+ # Try command -v on the bare name first; fall back to explicit tilde-expanded path
167
+ if explicit_path:
168
+ resolve_expr = (
169
+ f'_ai_cli_bin=$(command -v {bare_q} 2>/dev/null || true)'
170
+ f' ; [ -z "$_ai_cli_bin" ] && [ -x {explicit_path} ] && _ai_cli_bin={explicit_path}'
171
+ )
172
+ else:
173
+ resolve_expr = f'_ai_cli_bin=$(command -v {bare_q} 2>/dev/null || true)'
174
+ remote_cmd = (
175
+ f"export REAL_HOME={home_q}"
176
+ " ; . /etc/profile 2>/dev/null"
177
+ " ; . $REAL_HOME/.profile 2>/dev/null"
178
+ " ; . $REAL_HOME/.bashrc 2>/dev/null"
179
+ " ; . $REAL_HOME/.zshrc 2>/dev/null"
180
+ # nvm/fnm/volta often guard on interactive mode; source explicitly
181
+ ' ; [ -s "${NVM_DIR:-$REAL_HOME/.nvm}/nvm.sh" ] && . "${NVM_DIR:-$REAL_HOME/.nvm}/nvm.sh" 2>/dev/null'
182
+ ' ; [ -s "$REAL_HOME/.config/fnm/fnm_multishells" ] && eval "$(fnm env 2>/dev/null)" 2>/dev/null'
183
+ ' ; [ -d "$REAL_HOME/.volta" ] && export VOLTA_HOME="$REAL_HOME/.volta" && export PATH="$VOLTA_HOME/bin:$PATH"'
184
+ f" ; export REAL_HOME={home_q}"
185
+ f" ; export PATH=$(python3 -c {_shlex.quote(path_py)})"
186
+ " ; unalias codex claude gemini copilot 2>/dev/null || true"
187
+ " ; hash -r 2>/dev/null || true"
188
+ f" ; {resolve_expr}"
189
+ " ; printf 'AI_CLI_REMOTE_PATH=%s\\nAI_CLI_REMOTE_BIN=%s\\n' \"$PATH\" \"$_ai_cli_bin\""
190
+ )
191
+ proc = subprocess.run(
192
+ ["ssh", *_SSH_OPTS.split()[1:], spec.ssh_target, remote_cmd],
193
+ capture_output=True,
194
+ text=True,
195
+ check=False,
196
+ )
197
+ if proc.returncode != 0:
198
+ raise RuntimeError(
199
+ f"Could not resolve remote {tool_name} on {spec.ssh_target}: "
200
+ f"{proc.stderr.strip() or 'ssh command failed'}"
201
+ )
202
+
203
+ resolved_path = ""
204
+ resolved_bin = ""
205
+ for line in proc.stdout.splitlines():
206
+ if line.startswith("AI_CLI_REMOTE_PATH="):
207
+ resolved_path = line.removeprefix("AI_CLI_REMOTE_PATH=")
208
+ elif line.startswith("AI_CLI_REMOTE_BIN="):
209
+ resolved_bin = line.removeprefix("AI_CLI_REMOTE_BIN=")
210
+
211
+ if not resolved_bin:
212
+ raise RuntimeError(
213
+ f"Could not resolve remote {tool_name} on {spec.ssh_target}: "
214
+ "tool not found after loading shell profiles"
215
+ )
216
+ if not resolved_path:
217
+ raise RuntimeError(
218
+ f"Could not resolve remote {tool_name} on {spec.ssh_target}: "
219
+ "PATH probe returned an empty value"
220
+ )
221
+ return resolved_bin, resolved_path
222
+
223
+
224
+ def install_remote_tool(
225
+ spec: RemoteSpec,
226
+ tool_name: str,
227
+ install_command: str,
228
+ *,
229
+ real_home: str,
230
+ ) -> None:
231
+ """Run *install_command* on the remote host via SSH.
232
+
233
+ Sources the same shell profiles and node-version-manager scripts used by
234
+ :func:`resolve_remote_tool_env` so that ``npm`` / ``npx`` are available
235
+ even when they're managed by nvm/fnm/volta.
236
+ """
237
+ import shlex as _shlex
238
+
239
+ home_q = _shlex.quote(real_home)
240
+ remote_cmd = (
241
+ f"export REAL_HOME={home_q}"
242
+ " ; export HOME=$REAL_HOME"
243
+ " ; . /etc/profile 2>/dev/null"
244
+ " ; . $REAL_HOME/.profile 2>/dev/null"
245
+ " ; . $REAL_HOME/.bashrc 2>/dev/null"
246
+ " ; . $REAL_HOME/.zshrc 2>/dev/null"
247
+ ' ; [ -s "${NVM_DIR:-$REAL_HOME/.nvm}/nvm.sh" ] && . "${NVM_DIR:-$REAL_HOME/.nvm}/nvm.sh" 2>/dev/null'
248
+ ' ; [ -s "$REAL_HOME/.config/fnm/fnm_multishells" ] && eval "$(fnm env 2>/dev/null)" 2>/dev/null'
249
+ ' ; [ -d "$REAL_HOME/.volta" ] && export VOLTA_HOME="$REAL_HOME/.volta" && export PATH="$VOLTA_HOME/bin:$PATH"'
250
+ f" ; {install_command}"
251
+ )
252
+ print_sync_status(f"Installing {tool_name} on {spec.ssh_target}: {install_command}")
253
+ proc = subprocess.run(
254
+ ["ssh", *_SSH_OPTS.split()[1:], "-t", spec.ssh_target, remote_cmd],
255
+ check=False,
256
+ )
257
+ if proc.returncode != 0:
258
+ raise RuntimeError(
259
+ f"Failed to install {tool_name} on {spec.ssh_target} "
260
+ f"(exit {proc.returncode})"
261
+ )
262
+ print_sync_status(f"Installed {tool_name} on {spec.ssh_target}")
263
+
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # Cleanup
267
+ # ---------------------------------------------------------------------------
268
+
269
+ def cleanup_mirror(spec: RemoteSpec) -> bool:
270
+ """Remove the local mirror directory for a remote spec. Returns True if removed."""
271
+ d = make_local_mirror(spec)
272
+ if d.is_dir():
273
+ shutil.rmtree(d)
274
+ return True
275
+ return False
276
+
277
+
278
+ def print_sync_status(msg: str, *, file: TextIO | None = None) -> None:
279
+ """Print a coloured status line for remote sync operations."""
280
+ print(f"\033[1;36m[remote]\033[0m {msg}", file=file or sys.stderr)
281
+
282
+
283
+ # ---------------------------------------------------------------------------
284
+ # Remote Session Runner (uses remote-tty-wrapper)
285
+ # ---------------------------------------------------------------------------
286
+
287
+ def _find_remote_tty_wrapper() -> str:
288
+ """Locate the bundled remote-tty-wrapper script."""
289
+ bundled = Path(__file__).resolve().parent / "bin" / "remote-tty-wrapper"
290
+ if bundled.is_file():
291
+ return str(bundled)
292
+ from_path = shutil.which("remote-tty-wrapper")
293
+ if from_path:
294
+ return from_path
295
+ raise FileNotFoundError(
296
+ "remote-tty-wrapper not found. Expected at:\n"
297
+ f" {bundled}\n"
298
+ "or on PATH."
299
+ )
300
+
301
+
302
+ class RemoteSessionRunner:
303
+ """Run an AI tool inside a remote tmux session via remote-tty-wrapper.
304
+
305
+ Instead of rsync-ing files locally, this launches the tool directly on the
306
+ remote host so all file operations and shell commands happen natively.
307
+ """
308
+
309
+ def __init__(
310
+ self,
311
+ spec: RemoteSpec,
312
+ session_name: str = "ai-cli",
313
+ ssh_opts: Optional[list[str]] = None,
314
+ ) -> None:
315
+ self.spec = spec
316
+ self.session_name = session_name
317
+ self.ssh_opts = ssh_opts or []
318
+ self._wrapper = _find_remote_tty_wrapper()
319
+
320
+ def _base_cmd(self) -> list[str]:
321
+ cmd = [self._wrapper, "-H", self.spec.ssh_target, "-s", self.session_name]
322
+ for opt in self.ssh_opts:
323
+ cmd.extend(["--ssh-opt", opt])
324
+ return cmd
325
+
326
+ def start(self, init_cmd: str = "") -> None:
327
+ """Ensure the remote tmux session exists, optionally running *init_cmd*."""
328
+ cmd = self._base_cmd() + ["start"]
329
+ if init_cmd:
330
+ cmd += ["--init", init_cmd]
331
+ print_sync_status(f"Starting remote session '{self.session_name}' on {self.spec.ssh_target}")
332
+ proc = subprocess.run(cmd, check=False)
333
+ if proc.returncode != 0:
334
+ raise RuntimeError(
335
+ f"remote-tty-wrapper start failed (rc={proc.returncode})"
336
+ )
337
+
338
+ def send(self, *commands: str) -> None:
339
+ """Send one or more command lines into the remote tmux session."""
340
+ if not commands:
341
+ return
342
+ if len(commands) == 1:
343
+ cmd = self._base_cmd() + ["send", commands[0]]
344
+ else:
345
+ cmd = self._base_cmd() + ["send"]
346
+ for c in commands:
347
+ cmd += ["--", c]
348
+ proc = subprocess.run(cmd, check=False)
349
+ if proc.returncode != 0:
350
+ raise RuntimeError(
351
+ f"remote-tty-wrapper send failed (rc={proc.returncode})"
352
+ )
353
+
354
+ def shell(self, init_cmd: str = "") -> int:
355
+ """Attach to the remote tmux session interactively. Returns exit code."""
356
+ cmd = self._base_cmd() + ["shell"]
357
+ if init_cmd:
358
+ cmd += ["--init", init_cmd]
359
+ print_sync_status(f"Attaching to remote session '{self.session_name}'")
360
+ proc = subprocess.run(cmd, check=False)
361
+ return proc.returncode
362
+
363
+ # Remote path where we stash the CA cert for proxy trust
364
+ _REMOTE_CA_PATH = "~/.ai-cli/remote-ca.pem"
365
+
366
+ def _push_ca_cert(self, local_ca: Path) -> None:
367
+ """Copy the local mitmproxy CA cert to the remote host."""
368
+ if not local_ca.is_file():
369
+ return
370
+ dest = f"{self.spec.ssh_target}:{self._REMOTE_CA_PATH}"
371
+ # Ensure the remote directory exists, then scp the cert
372
+ ssh_base = [
373
+ "ssh",
374
+ "-o", "BatchMode=yes",
375
+ "-o", "ConnectTimeout=10",
376
+ "-o", "StrictHostKeyChecking=accept-new",
377
+ ]
378
+ subprocess.run(
379
+ [*ssh_base, self.spec.ssh_target, "mkdir -p ~/.ai-cli"],
380
+ check=False, capture_output=True,
381
+ )
382
+ proc = subprocess.run(
383
+ ["scp", "-q", "-o", "BatchMode=yes", str(local_ca), dest],
384
+ check=False, capture_output=True, text=True,
385
+ )
386
+ if proc.returncode != 0:
387
+ print_sync_status(f"Warning: failed to push CA cert: {proc.stderr.strip()}")
388
+
389
+ def run_attached(
390
+ self,
391
+ command: str,
392
+ init_cmd: str = "",
393
+ proxy_port: int = 0,
394
+ ca_path: Optional[Path] = None,
395
+ home_dir: Optional[str] = None,
396
+ real_home: Optional[str] = None,
397
+ launch_path: Optional[str] = None,
398
+ tmux_socket: Optional[str] = None,
399
+ ) -> int:
400
+ """Create a tmux session running *command* as the pane process, then attach.
401
+
402
+ When *proxy_port* is non-zero, an SSH reverse tunnel is established so
403
+ that ``127.0.0.1:<proxy_port>`` on the remote reaches the local
404
+ mitmproxy. The tool's environment is set up with ``HTTP_PROXY``,
405
+ ``HTTPS_PROXY``, ``SSL_CERT_FILE``, etc. pointing at the tunnel.
406
+
407
+ When *home_dir* is set, the pane sources the real user's shell profile
408
+ first (for PATH, nvm, conda, etc.) then overrides ``$HOME`` to point
409
+ at the packaged session directory.
410
+
411
+ When *tmux_socket* is set, all tmux commands use ``-L <socket>`` so
412
+ the session lives on a dedicated, discoverable server.
413
+
414
+ Any pre-existing session with the same name is killed first so we
415
+ always get a fresh pane running the requested command. When the tool
416
+ exits the pane closes, ending the tmux session and SSH connection.
417
+ """
418
+ import shlex as _shlex
419
+
420
+ tmux_L = f" -L {_shlex.quote(tmux_socket)}" if tmux_socket else ""
421
+ tmux_conf = f" -f {_shlex.quote(home_dir + '/.tmux.conf')}" if home_dir else ""
422
+
423
+ # ── Environment preamble ──────────────────────────────────────────
424
+ env_parts: list[str] = []
425
+
426
+ # Bootstrap the user's normal shell PATH, then remove ai-cli's
427
+ # wrapper-bin directory so remote sessions invoke the real tool
428
+ # binary instead of recursively calling back into ai-cli.
429
+ real_home_expr = _shlex.quote(real_home) if real_home else "$HOME"
430
+ shell_bootstrap = (
431
+ f"export REAL_HOME={real_home_expr}"
432
+ " ; . /etc/profile 2>/dev/null"
433
+ " ; . $REAL_HOME/.profile 2>/dev/null"
434
+ " ; . $REAL_HOME/.bashrc 2>/dev/null"
435
+ " ; . $REAL_HOME/.zshrc 2>/dev/null"
436
+ ' ; [ -s "${NVM_DIR:-$REAL_HOME/.nvm}/nvm.sh" ] && . "${NVM_DIR:-$REAL_HOME/.nvm}/nvm.sh" 2>/dev/null'
437
+ ' ; [ -s "$REAL_HOME/.config/fnm/fnm_multishells" ] && eval "$(fnm env 2>/dev/null)" 2>/dev/null'
438
+ ' ; [ -d "$REAL_HOME/.volta" ] && export VOLTA_HOME="$REAL_HOME/.volta" && export PATH="$VOLTA_HOME/bin:$PATH"'
439
+ f" ; export REAL_HOME={real_home_expr}"
440
+ )
441
+ if launch_path:
442
+ shell_bootstrap += f" ; export PATH={_shlex.quote(launch_path)}"
443
+ else:
444
+ shell_bootstrap += (
445
+ " ; export PATH=$(python3 -c 'import os; "
446
+ "drop = os.path.join(os.environ[\"REAL_HOME\"], \".ai-cli/bin\"); "
447
+ "print(\":\".join(p for p in os.environ.get(\"PATH\", \"\").split(\":\") "
448
+ "if p and p != drop))')"
449
+ )
450
+ shell_bootstrap += (
451
+ " ; unalias codex claude gemini copilot 2>/dev/null || true"
452
+ " ; hash -r 2>/dev/null || true"
453
+ )
454
+ if home_dir:
455
+ home_q = _shlex.quote(home_dir)
456
+ shell_bootstrap += (
457
+ f" ; export HOME={home_q}"
458
+ f" ; export ZDOTDIR={home_q}"
459
+ f" ; export BASH_ENV={home_q}/.bash_env"
460
+ f" ; export ENV={home_q}/.shrc"
461
+ f" ; export KSHRC={home_q}/.kshrc"
462
+ )
463
+ env_parts.append(shell_bootstrap)
464
+
465
+ # Proxy variables
466
+ if proxy_port:
467
+ proxy_url = f"http://127.0.0.1:{proxy_port}"
468
+ env_parts += [
469
+ f"export HTTP_PROXY={_shlex.quote(proxy_url)}",
470
+ f"export HTTPS_PROXY={_shlex.quote(proxy_url)}",
471
+ f"export http_proxy={_shlex.quote(proxy_url)}",
472
+ f"export https_proxy={_shlex.quote(proxy_url)}",
473
+ ]
474
+ # CA cert paths — relative to the session dir when packaged
475
+ if home_dir:
476
+ remote_ca = f"{home_dir}/.ai-cli/remote-ca.pem"
477
+ elif ca_path and ca_path.is_file():
478
+ self._push_ca_cert(ca_path)
479
+ remote_ca = self._REMOTE_CA_PATH
480
+ else:
481
+ remote_ca = ""
482
+ if remote_ca:
483
+ env_parts += [
484
+ f"export SSL_CERT_FILE={remote_ca}",
485
+ f"export REQUESTS_CA_BUNDLE={remote_ca}",
486
+ f"export NODE_EXTRA_CA_CERTS={remote_ca}",
487
+ ]
488
+
489
+ env_exports = " && ".join(env_parts) if env_parts else ""
490
+
491
+ # ── Build pane command ────────────────────────────────────────────
492
+ segments = []
493
+ if env_exports:
494
+ segments.append(env_exports)
495
+ if init_cmd:
496
+ segments.append(init_cmd)
497
+ segments.append(command)
498
+ pane_cmd = " && ".join(segments)
499
+
500
+ sess_q = _shlex.quote(self.session_name)
501
+ pane_q = _shlex.quote(pane_cmd)
502
+ tmux_prefix = ""
503
+ if home_dir:
504
+ home_q = _shlex.quote(home_dir)
505
+ tmux_prefix = (
506
+ f"env HOME={home_q} ZDOTDIR={home_q} "
507
+ f"BASH_ENV={home_q}/.bash_env ENV={home_q}/.shrc "
508
+ f"KSHRC={home_q}/.kshrc "
509
+ )
510
+ # Kill any stale session, then create a fresh one with the command
511
+ # as the pane process and attach.
512
+ remote_cmd = (
513
+ f"{tmux_prefix}tmux{tmux_conf}{tmux_L} kill-session -t {sess_q} 2>/dev/null;"
514
+ f" {tmux_prefix}tmux{tmux_conf}{tmux_L} new-session -d -s {sess_q} {pane_q}"
515
+ f" && {tmux_prefix}tmux{tmux_conf}{tmux_L} attach -t {sess_q}"
516
+ )
517
+
518
+ # ── SSH command ───────────────────────────────────────────────────
519
+ ssh_cmd = [
520
+ "ssh",
521
+ "-o", "PermitLocalCommand=no",
522
+ "-o", "ServerAliveInterval=30",
523
+ "-o", "ServerAliveCountMax=3",
524
+ "-o", "RequestTTY=force",
525
+ ]
526
+ # Reverse tunnel: remote port → local mitmproxy
527
+ if proxy_port:
528
+ ssh_cmd += ["-R", f"127.0.0.1:{proxy_port}:127.0.0.1:{proxy_port}"]
529
+ for opt in self.ssh_opts:
530
+ ssh_cmd.append(opt)
531
+ ssh_cmd += [self.spec.ssh_target, remote_cmd]
532
+
533
+ detail = f"proxy tunnel :{proxy_port}" if proxy_port else "no proxy"
534
+ if home_dir:
535
+ detail += f", HOME={home_dir}"
536
+ print_sync_status(
537
+ f"Launching in remote session '{self.session_name}' on "
538
+ f"{self.spec.ssh_target} ({detail})"
539
+ )
540
+ proc = subprocess.run(ssh_cmd, check=False)
541
+ return proc.returncode
542
+
543
+ def exec_attached(
544
+ self,
545
+ command: str,
546
+ proxy_port: int = 0,
547
+ ca_path: Optional[Path] = None,
548
+ home_dir: Optional[str] = None,
549
+ real_home: Optional[str] = None,
550
+ launch_path: Optional[str] = None,
551
+ ) -> int:
552
+ """Run a command directly on the remote host in an attached SSH TTY."""
553
+ import shlex as _shlex
554
+
555
+ env_parts: list[str] = []
556
+ real_home_expr = _shlex.quote(real_home) if real_home else "$HOME"
557
+ shell_bootstrap = (
558
+ f"export REAL_HOME={real_home_expr}"
559
+ " ; . /etc/profile 2>/dev/null"
560
+ " ; . $REAL_HOME/.profile 2>/dev/null"
561
+ " ; . $REAL_HOME/.bashrc 2>/dev/null"
562
+ " ; . $REAL_HOME/.zshrc 2>/dev/null"
563
+ ' ; [ -s "${NVM_DIR:-$REAL_HOME/.nvm}/nvm.sh" ] && . "${NVM_DIR:-$REAL_HOME/.nvm}/nvm.sh" 2>/dev/null'
564
+ ' ; [ -s "$REAL_HOME/.config/fnm/fnm_multishells" ] && eval "$(fnm env 2>/dev/null)" 2>/dev/null'
565
+ ' ; [ -d "$REAL_HOME/.volta" ] && export VOLTA_HOME="$REAL_HOME/.volta" && export PATH="$VOLTA_HOME/bin:$PATH"'
566
+ f" ; export REAL_HOME={real_home_expr}"
567
+ )
568
+ if launch_path:
569
+ shell_bootstrap += f" ; export PATH={_shlex.quote(launch_path)}"
570
+ if home_dir:
571
+ home_q = _shlex.quote(home_dir)
572
+ shell_bootstrap += (
573
+ f" ; export HOME={home_q}"
574
+ f" ; export ZDOTDIR={home_q}"
575
+ f" ; export BASH_ENV={home_q}/.bash_env"
576
+ f" ; export ENV={home_q}/.shrc"
577
+ f" ; export KSHRC={home_q}/.kshrc"
578
+ )
579
+ env_parts.append(shell_bootstrap)
580
+
581
+ if proxy_port:
582
+ proxy_url = f"http://127.0.0.1:{proxy_port}"
583
+ env_parts += [
584
+ f"export HTTP_PROXY={_shlex.quote(proxy_url)}",
585
+ f"export HTTPS_PROXY={_shlex.quote(proxy_url)}",
586
+ f"export http_proxy={_shlex.quote(proxy_url)}",
587
+ f"export https_proxy={_shlex.quote(proxy_url)}",
588
+ ]
589
+ if home_dir:
590
+ remote_ca = f"{home_dir}/.ai-cli/remote-ca.pem"
591
+ elif ca_path and ca_path.is_file():
592
+ self._push_ca_cert(ca_path)
593
+ remote_ca = self._REMOTE_CA_PATH
594
+ else:
595
+ remote_ca = ""
596
+ if remote_ca:
597
+ env_parts += [
598
+ f"export SSL_CERT_FILE={remote_ca}",
599
+ f"export REQUESTS_CA_BUNDLE={remote_ca}",
600
+ f"export NODE_EXTRA_CA_CERTS={remote_ca}",
601
+ ]
602
+
603
+ remote_cmd = " && ".join([part for part in [" && ".join(env_parts), command] if part])
604
+
605
+ ssh_cmd = [
606
+ "ssh",
607
+ "-o", "PermitLocalCommand=no",
608
+ "-o", "ServerAliveInterval=30",
609
+ "-o", "ServerAliveCountMax=3",
610
+ "-o", "RequestTTY=force",
611
+ ]
612
+ if proxy_port:
613
+ ssh_cmd += ["-R", f"127.0.0.1:{proxy_port}:127.0.0.1:{proxy_port}"]
614
+ for opt in self.ssh_opts:
615
+ ssh_cmd.append(opt)
616
+ ssh_cmd += [self.spec.ssh_target, remote_cmd]
617
+
618
+ proc = subprocess.run(ssh_cmd, check=False)
619
+ return proc.returncode
620
+
621
+ _REMOTE_LOG_GLOBS = [
622
+ "~/.ai-cli/logs/",
623
+ "~/.claude/projects/",
624
+ "~/.codex/sessions/",
625
+ "~/.codex/projects/",
626
+ "~/.copilot/sessions/",
627
+ "~/.gemini/sessions/",
628
+ ]
629
+
630
+ def pull_logs(self, local_log_dir: Path, session_home: str = "") -> None:
631
+ """Rsync tool logs and session data from the remote to the local host.
632
+
633
+ When *session_home* is set, log globs are resolved relative to that
634
+ directory (the packaged ``$HOME``). Otherwise they resolve from the
635
+ remote user's real ``~``.
636
+ """
637
+ local_log_dir.mkdir(parents=True, exist_ok=True)
638
+ rsync_bin = shutil.which("rsync")
639
+ if not rsync_bin:
640
+ print_sync_status("Warning: rsync not found; skipping log pull")
641
+ return
642
+
643
+ if session_home:
644
+ globs = [
645
+ f"{session_home.rstrip('/')}/{g.lstrip('~/')}"
646
+ for g in self._REMOTE_LOG_GLOBS
647
+ ]
648
+ else:
649
+ globs = list(self._REMOTE_LOG_GLOBS)
650
+
651
+ ssh_str = "ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new"
652
+ for remote_glob in globs:
653
+ src = f"{self.spec.ssh_target}:{remote_glob}"
654
+ safe_name = re.sub(r"[^A-Za-z0-9_-]", "-", remote_glob.strip("~/").strip("/"))
655
+ dest = local_log_dir / f"remote-{self.spec.host}" / safe_name
656
+ dest.mkdir(parents=True, exist_ok=True)
657
+ proc = subprocess.run(
658
+ [rsync_bin, "-az", "--ignore-errors", "-e", ssh_str,
659
+ src, str(dest) + "/"],
660
+ capture_output=True, text=True, check=False,
661
+ )
662
+ if proc.returncode == 0:
663
+ print_sync_status(f"Pulled {remote_glob} → {dest}")
664
+
665
+ def close(self) -> None:
666
+ """Kill the remote tmux session."""
667
+ cmd = self._base_cmd() + ["close"]
668
+ subprocess.run(cmd, check=False)
669
+ print_sync_status(f"Closed remote session '{self.session_name}'")