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/__init__.py +3 -0
- ai_cli/__main__.py +6 -0
- ai_cli/bin/ai-mux-linux-x86_64 +0 -0
- ai_cli/bin/remote-tty-wrapper +153 -0
- ai_cli/ca.py +175 -0
- ai_cli/completion_gen.py +680 -0
- ai_cli/config.py +185 -0
- ai_cli/credentials.py +341 -0
- ai_cli/detached_cleanup.py +135 -0
- ai_cli/housekeeping.py +50 -0
- ai_cli/instructions.py +308 -0
- ai_cli/log.py +53 -0
- ai_cli/main.py +1516 -0
- ai_cli/main_helpers.py +553 -0
- ai_cli/prompt_editor_launcher.py +324 -0
- ai_cli/proxy.py +627 -0
- ai_cli/remote.py +669 -0
- ai_cli/remote_package.py +1111 -0
- ai_cli/session.py +1344 -0
- ai_cli/session_store.py +236 -0
- ai_cli/traffic.py +1510 -0
- ai_cli/traffic_db.py +118 -0
- ai_cli/tui.py +525 -0
- ai_cli/update.py +200 -0
- ai_cli_toolkit-0.2.0.dist-info/METADATA +17 -0
- ai_cli_toolkit-0.2.0.dist-info/RECORD +30 -0
- ai_cli_toolkit-0.2.0.dist-info/WHEEL +5 -0
- ai_cli_toolkit-0.2.0.dist-info/entry_points.txt +2 -0
- ai_cli_toolkit-0.2.0.dist-info/licenses/LICENSE +21 -0
- ai_cli_toolkit-0.2.0.dist-info/top_level.txt +1 -0
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}'")
|