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/main_helpers.py
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"""Helper functions extracted from ai_cli.main for maintainability."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import filecmp
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import urlparse
|
|
16
|
+
|
|
17
|
+
from ai_cli.log import append_log
|
|
18
|
+
from ai_cli.remote import RemoteSpec
|
|
19
|
+
|
|
20
|
+
CODEX_PROXY_WARN_HOLD = 5
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check_codex_proxy_compat(log_path: Path | None = None) -> None:
|
|
24
|
+
"""Detect Codex network-proxy settings that can break ai-cli MITM."""
|
|
25
|
+
issues: list[str] = []
|
|
26
|
+
|
|
27
|
+
codex_config = Path.home() / ".codex" / "config.toml"
|
|
28
|
+
if codex_config.is_file():
|
|
29
|
+
try:
|
|
30
|
+
text = codex_config.read_text(encoding="utf-8")
|
|
31
|
+
in_network = False
|
|
32
|
+
for raw_line in text.splitlines():
|
|
33
|
+
line = raw_line.strip()
|
|
34
|
+
if line.startswith("["):
|
|
35
|
+
in_network = line.strip("[] ").lower() == "network"
|
|
36
|
+
continue
|
|
37
|
+
if not in_network:
|
|
38
|
+
continue
|
|
39
|
+
key, _, val = line.partition("=")
|
|
40
|
+
key = key.strip().lower()
|
|
41
|
+
val = val.strip().strip('"').strip("'").lower()
|
|
42
|
+
if key == "allow_upstream_proxy" and val == "false":
|
|
43
|
+
issues.append(
|
|
44
|
+
"allow_upstream_proxy = false in ~/.codex/config.toml\n"
|
|
45
|
+
" -> Codex proxy will bypass ai-cli mitmproxy (no traffic capture or injection)\n"
|
|
46
|
+
" Fix: set allow_upstream_proxy = true in ~/.codex/config.toml [network]"
|
|
47
|
+
)
|
|
48
|
+
if key == "mitm" and val == "true":
|
|
49
|
+
issues.append(
|
|
50
|
+
"mitm = true in ~/.codex/config.toml\n"
|
|
51
|
+
" -> Double-MITM: Codex terminates TLS with its own CA, breaking our injection\n"
|
|
52
|
+
" Fix: set mitm = false in ~/.codex/config.toml [network]"
|
|
53
|
+
)
|
|
54
|
+
except OSError:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
project_config = Path.cwd() / ".codex" / "config.toml"
|
|
58
|
+
if project_config.is_file() and project_config != codex_config:
|
|
59
|
+
try:
|
|
60
|
+
text = project_config.read_text(encoding="utf-8")
|
|
61
|
+
in_network = False
|
|
62
|
+
for raw_line in text.splitlines():
|
|
63
|
+
line = raw_line.strip()
|
|
64
|
+
if line.startswith("["):
|
|
65
|
+
in_network = line.strip("[] ").lower() == "network"
|
|
66
|
+
continue
|
|
67
|
+
if not in_network:
|
|
68
|
+
continue
|
|
69
|
+
key, _, val = line.partition("=")
|
|
70
|
+
key = key.strip().lower()
|
|
71
|
+
val = val.strip().strip('"').strip("'").lower()
|
|
72
|
+
if key == "allow_upstream_proxy" and val == "false":
|
|
73
|
+
issues.append(
|
|
74
|
+
f"allow_upstream_proxy = false in {project_config}\n"
|
|
75
|
+
" -> Project-level override bypasses ai-cli proxy\n"
|
|
76
|
+
" Fix: remove or set allow_upstream_proxy = true"
|
|
77
|
+
)
|
|
78
|
+
if key == "mitm" and val == "true":
|
|
79
|
+
issues.append(
|
|
80
|
+
f"mitm = true in {project_config}\n"
|
|
81
|
+
" -> Project-level double-MITM override\n"
|
|
82
|
+
" Fix: remove or set mitm = false"
|
|
83
|
+
)
|
|
84
|
+
except OSError:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
codex_ca = Path.home() / ".codex" / "proxy" / "ca.pem"
|
|
88
|
+
if codex_ca.is_file():
|
|
89
|
+
issues.append(
|
|
90
|
+
f"Codex MITM CA exists at {codex_ca}\n"
|
|
91
|
+
" -> Codex has generated its own CA for TLS interception\n"
|
|
92
|
+
" If mitm=true is active, this creates double-MITM with ai-cli"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if not issues:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
border = "=" * 72
|
|
99
|
+
print(f"\n{border}", file=sys.stderr)
|
|
100
|
+
print(" WARNING: CODEX PROXY COMPATIBILITY", file=sys.stderr)
|
|
101
|
+
print(border, file=sys.stderr)
|
|
102
|
+
for issue in issues:
|
|
103
|
+
print(file=sys.stderr)
|
|
104
|
+
print(f" {issue}", file=sys.stderr)
|
|
105
|
+
print(file=sys.stderr)
|
|
106
|
+
print(" If ai-cli instruction injection or traffic capture stops working,", file=sys.stderr)
|
|
107
|
+
print(" these are the likely causes. See the remediation steps above.", file=sys.stderr)
|
|
108
|
+
print(f"{border}", file=sys.stderr)
|
|
109
|
+
print(f" (continuing in {CODEX_PROXY_WARN_HOLD}s...)", file=sys.stderr)
|
|
110
|
+
|
|
111
|
+
if log_path:
|
|
112
|
+
for issue in issues:
|
|
113
|
+
append_log(log_path, f"CODEX PROXY WARNING: {issue.splitlines()[0]}")
|
|
114
|
+
|
|
115
|
+
time.sleep(CODEX_PROXY_WARN_HOLD)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def session_id(tool_name: str) -> str:
|
|
119
|
+
return f"ai-cli-{tool_name}-{os.getpid()}-{time.time_ns()}"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def write_session_files(session_id_value: str, port: int) -> None:
|
|
123
|
+
pid_path = Path(f"/tmp/{session_id_value}.pid")
|
|
124
|
+
port_path = Path(f"/tmp/{session_id_value}.port")
|
|
125
|
+
try:
|
|
126
|
+
pid_path.write_text(str(os.getpid()), encoding="utf-8")
|
|
127
|
+
port_path.write_text(str(port), encoding="utf-8")
|
|
128
|
+
except OSError:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def cleanup_session_files(session_id_value: str) -> None:
|
|
133
|
+
for suffix in (".pid", ".port"):
|
|
134
|
+
try:
|
|
135
|
+
Path(f"/tmp/{session_id_value}{suffix}").unlink(missing_ok=True)
|
|
136
|
+
except OSError:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def spawn_detached_proxy_watcher(
|
|
141
|
+
mitm_pid: int,
|
|
142
|
+
session_id_value: str,
|
|
143
|
+
tmux_sessions: list[str],
|
|
144
|
+
log_path: Path,
|
|
145
|
+
) -> bool:
|
|
146
|
+
python = sys.executable or shutil.which("python3") or "python3"
|
|
147
|
+
cmd = [
|
|
148
|
+
python,
|
|
149
|
+
"-m",
|
|
150
|
+
"ai_cli.detached_cleanup",
|
|
151
|
+
"--mitm-pid",
|
|
152
|
+
str(mitm_pid),
|
|
153
|
+
"--session-id",
|
|
154
|
+
session_id_value,
|
|
155
|
+
"--wrapper-log-file",
|
|
156
|
+
str(log_path),
|
|
157
|
+
"--tmux-socket",
|
|
158
|
+
"ai-mux",
|
|
159
|
+
]
|
|
160
|
+
for session_name in tmux_sessions:
|
|
161
|
+
cmd.extend(["--tmux-session", session_name])
|
|
162
|
+
try:
|
|
163
|
+
subprocess.Popen(
|
|
164
|
+
cmd,
|
|
165
|
+
stdin=subprocess.DEVNULL,
|
|
166
|
+
stdout=subprocess.DEVNULL,
|
|
167
|
+
stderr=subprocess.DEVNULL,
|
|
168
|
+
start_new_session=True,
|
|
169
|
+
)
|
|
170
|
+
except OSError as exc:
|
|
171
|
+
append_log(log_path, f"Failed spawning detached proxy watcher: {exc}")
|
|
172
|
+
return False
|
|
173
|
+
append_log(log_path, f"Spawned detached proxy watcher for mitmdump pid {mitm_pid}")
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def tmux_list_sessions(socket_name: str = "ai-mux") -> list[str]:
|
|
178
|
+
try:
|
|
179
|
+
probe = subprocess.run(
|
|
180
|
+
["tmux", "-L", socket_name, "list-sessions", "-F", "#{session_name}"],
|
|
181
|
+
check=False,
|
|
182
|
+
stdout=subprocess.PIPE,
|
|
183
|
+
stderr=subprocess.DEVNULL,
|
|
184
|
+
text=True,
|
|
185
|
+
)
|
|
186
|
+
except OSError:
|
|
187
|
+
return []
|
|
188
|
+
if probe.returncode != 0:
|
|
189
|
+
return []
|
|
190
|
+
return [line.strip() for line in (probe.stdout or "").splitlines() if line.strip()]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def tmux_session_env(session_name: str, socket_name: str = "ai-mux") -> dict[str, str]:
|
|
194
|
+
try:
|
|
195
|
+
probe = subprocess.run(
|
|
196
|
+
["tmux", "-L", socket_name, "show-environment", "-t", session_name],
|
|
197
|
+
check=False,
|
|
198
|
+
stdout=subprocess.PIPE,
|
|
199
|
+
stderr=subprocess.DEVNULL,
|
|
200
|
+
text=True,
|
|
201
|
+
)
|
|
202
|
+
except OSError:
|
|
203
|
+
return {}
|
|
204
|
+
if probe.returncode != 0:
|
|
205
|
+
return {}
|
|
206
|
+
|
|
207
|
+
env: dict[str, str] = {}
|
|
208
|
+
for raw in (probe.stdout or "").splitlines():
|
|
209
|
+
line = raw.strip()
|
|
210
|
+
if not line or line.startswith("-") or "=" not in line:
|
|
211
|
+
continue
|
|
212
|
+
key, value = line.split("=", 1)
|
|
213
|
+
env[key] = value
|
|
214
|
+
return env
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def normalize_dir(path_value: str) -> str:
|
|
218
|
+
if not path_value:
|
|
219
|
+
return ""
|
|
220
|
+
return os.path.realpath(os.path.expanduser(path_value))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def resolve_recv_context_file(cwd: Path) -> str:
|
|
224
|
+
env_path = os.environ.get("AI_CLI_RECV_CONTEXT_FILE", "").strip()
|
|
225
|
+
if env_path:
|
|
226
|
+
candidate = Path(env_path).expanduser()
|
|
227
|
+
if candidate.is_file():
|
|
228
|
+
return str(candidate.resolve())
|
|
229
|
+
candidate = cwd / "received_instructions_context.txt"
|
|
230
|
+
if candidate.is_file():
|
|
231
|
+
return str(candidate.resolve())
|
|
232
|
+
return ""
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def find_reusable_tmux_session(
|
|
236
|
+
tool_name: str,
|
|
237
|
+
effective_cwd: Path,
|
|
238
|
+
socket_name: str = "ai-mux",
|
|
239
|
+
) -> tuple[str, dict[str, str]] | None:
|
|
240
|
+
target_dir = normalize_dir(str(effective_cwd))
|
|
241
|
+
for session_name in tmux_list_sessions(socket_name=socket_name):
|
|
242
|
+
env = tmux_session_env(session_name, socket_name=socket_name)
|
|
243
|
+
if env.get("AI_CLI_TOOL", "") != tool_name:
|
|
244
|
+
continue
|
|
245
|
+
session_dir = normalize_dir(env.get("AI_CLI_WORKDIR", ""))
|
|
246
|
+
if session_dir and session_dir == target_dir:
|
|
247
|
+
return session_name, env
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def terminate_pid(pid: int, timeout_seconds: float = 3.0) -> None:
|
|
252
|
+
try:
|
|
253
|
+
os.kill(pid, 0)
|
|
254
|
+
except OSError:
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
os.kill(pid, signal.SIGTERM)
|
|
259
|
+
except OSError:
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
deadline = time.time() + timeout_seconds
|
|
263
|
+
while time.time() < deadline:
|
|
264
|
+
try:
|
|
265
|
+
os.kill(pid, 0)
|
|
266
|
+
except OSError:
|
|
267
|
+
return
|
|
268
|
+
time.sleep(0.1)
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
os.kill(pid, signal.SIGKILL)
|
|
272
|
+
except OSError:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def kill_proxy_from_env(session_env: dict[str, str], log_path: Path) -> None:
|
|
277
|
+
pid_raw = session_env.get("AI_CLI_PROXY_PID", "").strip()
|
|
278
|
+
if pid_raw.isdigit():
|
|
279
|
+
pid = int(pid_raw)
|
|
280
|
+
terminate_pid(pid)
|
|
281
|
+
append_log(log_path, f"Stopped existing proxy by PID: {pid}")
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
proxy_url = (
|
|
285
|
+
session_env.get("HTTP_PROXY", "").strip()
|
|
286
|
+
or session_env.get("HTTPS_PROXY", "").strip()
|
|
287
|
+
or session_env.get("http_proxy", "").strip()
|
|
288
|
+
or session_env.get("https_proxy", "").strip()
|
|
289
|
+
)
|
|
290
|
+
parsed = urlparse(proxy_url)
|
|
291
|
+
if parsed.port is None:
|
|
292
|
+
append_log(log_path, "Existing session proxy PID/port not found; skipping direct proxy stop")
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
probe = subprocess.run(
|
|
297
|
+
["lsof", "-n", f"-iTCP:{parsed.port}", "-sTCP:LISTEN", "-t"],
|
|
298
|
+
check=False,
|
|
299
|
+
stdout=subprocess.PIPE,
|
|
300
|
+
stderr=subprocess.DEVNULL,
|
|
301
|
+
text=True,
|
|
302
|
+
)
|
|
303
|
+
except OSError:
|
|
304
|
+
append_log(log_path, "lsof unavailable; unable to stop existing proxy by port")
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
pids = [line.strip() for line in (probe.stdout or "").splitlines() if line.strip().isdigit()]
|
|
308
|
+
if not pids:
|
|
309
|
+
append_log(log_path, f"No listening proxy PID found on port {parsed.port}")
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
for raw in pids:
|
|
313
|
+
terminate_pid(int(raw))
|
|
314
|
+
append_log(log_path, f"Stopped existing proxy by port {parsed.port}: pids={','.join(pids)}")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def replace_existing_tmux_session(
|
|
318
|
+
session_name: str,
|
|
319
|
+
session_env: dict[str, str],
|
|
320
|
+
log_path: Path,
|
|
321
|
+
socket_name: str = "ai-mux",
|
|
322
|
+
) -> None:
|
|
323
|
+
kill_proxy_from_env(session_env, log_path)
|
|
324
|
+
try:
|
|
325
|
+
subprocess.call(
|
|
326
|
+
["tmux", "-L", socket_name, "kill-session", "-t", session_name],
|
|
327
|
+
stdout=subprocess.DEVNULL,
|
|
328
|
+
stderr=subprocess.DEVNULL,
|
|
329
|
+
)
|
|
330
|
+
append_log(log_path, f"Killed existing tmux session: {session_name}")
|
|
331
|
+
except OSError as exc:
|
|
332
|
+
append_log(log_path, f"Failed to kill existing tmux session {session_name}: {exc}")
|
|
333
|
+
|
|
334
|
+
old_session_id = session_env.get("AI_CLI_SESSION", "").strip()
|
|
335
|
+
if old_session_id:
|
|
336
|
+
cleanup_session_files(old_session_id)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def parse_wrapper_overrides(args: list[str]) -> tuple[list[str], dict[str, Any]]:
|
|
340
|
+
parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
|
|
341
|
+
parser.add_argument("--ai-cli-system-instructions-file", dest="instructions_file")
|
|
342
|
+
parser.add_argument("--ai-cli-system-instructions-text", dest="instructions_text")
|
|
343
|
+
parser.add_argument("--ai-cli-canary-rule", dest="canary_rule")
|
|
344
|
+
parser.add_argument(
|
|
345
|
+
"--ai-cli-passthrough",
|
|
346
|
+
dest="passthrough",
|
|
347
|
+
action="store_true",
|
|
348
|
+
default=None,
|
|
349
|
+
)
|
|
350
|
+
parser.add_argument(
|
|
351
|
+
"--ai-cli-debug-requests",
|
|
352
|
+
dest="debug_requests",
|
|
353
|
+
action="store_true",
|
|
354
|
+
default=None,
|
|
355
|
+
)
|
|
356
|
+
parser.add_argument(
|
|
357
|
+
"--ai-cli-rewrite-test-mode",
|
|
358
|
+
dest="rewrite_test_mode",
|
|
359
|
+
choices=("off", "outgoing", "incoming", "both"),
|
|
360
|
+
default=None,
|
|
361
|
+
)
|
|
362
|
+
parser.add_argument(
|
|
363
|
+
"--ai-cli-developer-instructions-mode",
|
|
364
|
+
dest="developer_instructions_mode",
|
|
365
|
+
choices=("overwrite", "append", "prepend"),
|
|
366
|
+
default=None,
|
|
367
|
+
)
|
|
368
|
+
parser.add_argument("--ai-cli-rewrite-test-tag", dest="rewrite_test_tag")
|
|
369
|
+
parser.add_argument(
|
|
370
|
+
"--ai-cli-no-startup-context",
|
|
371
|
+
dest="no_startup_context",
|
|
372
|
+
action="store_true",
|
|
373
|
+
default=False,
|
|
374
|
+
)
|
|
375
|
+
parser.add_argument(
|
|
376
|
+
"--app",
|
|
377
|
+
dest="use_app_binary",
|
|
378
|
+
action="store_true",
|
|
379
|
+
default=False,
|
|
380
|
+
)
|
|
381
|
+
parser.add_argument(
|
|
382
|
+
"--ai-cli-remote-rsync",
|
|
383
|
+
dest="remote_rsync",
|
|
384
|
+
action="store_true",
|
|
385
|
+
default=False,
|
|
386
|
+
)
|
|
387
|
+
parser.add_argument(
|
|
388
|
+
"--ai-cli-remote-init",
|
|
389
|
+
dest="remote_init",
|
|
390
|
+
default=None,
|
|
391
|
+
)
|
|
392
|
+
parser.add_argument(
|
|
393
|
+
"--ai-cli-remote-session-name",
|
|
394
|
+
dest="remote_session_name",
|
|
395
|
+
default=None,
|
|
396
|
+
)
|
|
397
|
+
parser.add_argument(
|
|
398
|
+
"--ai-cli-remote-no-package",
|
|
399
|
+
dest="remote_no_package",
|
|
400
|
+
action="store_true",
|
|
401
|
+
default=False,
|
|
402
|
+
)
|
|
403
|
+
known, remaining = parser.parse_known_args(args)
|
|
404
|
+
return remaining, {
|
|
405
|
+
"instructions_file": known.instructions_file,
|
|
406
|
+
"instructions_text": known.instructions_text,
|
|
407
|
+
"canary_rule": known.canary_rule,
|
|
408
|
+
"passthrough": known.passthrough,
|
|
409
|
+
"debug_requests": known.debug_requests,
|
|
410
|
+
"rewrite_test_mode": known.rewrite_test_mode,
|
|
411
|
+
"developer_instructions_mode": known.developer_instructions_mode,
|
|
412
|
+
"rewrite_test_tag": known.rewrite_test_tag,
|
|
413
|
+
"no_startup_context": known.no_startup_context,
|
|
414
|
+
"use_app_binary": known.use_app_binary,
|
|
415
|
+
"remote_rsync": known.remote_rsync,
|
|
416
|
+
"remote_init": known.remote_init,
|
|
417
|
+
"remote_session_name": known.remote_session_name,
|
|
418
|
+
"remote_no_package": known.remote_no_package,
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def extract_launch_cwd(
|
|
423
|
+
args: list[str],
|
|
424
|
+
) -> tuple[Path | None, list[str], RemoteSpec | None]:
|
|
425
|
+
"""Extract the directory (or remote spec) from the head of *args*.
|
|
426
|
+
|
|
427
|
+
Returns ``(local_path, remaining_args, remote_spec)``. When the first arg
|
|
428
|
+
matches ``user@host:/path``, *remote_spec* is populated and *local_path* is
|
|
429
|
+
``None`` (the caller is responsible for syncing down and setting up the
|
|
430
|
+
local mirror).
|
|
431
|
+
"""
|
|
432
|
+
from ai_cli.remote import RemoteSpec, parse_remote_spec
|
|
433
|
+
|
|
434
|
+
if not args:
|
|
435
|
+
return None, args, None
|
|
436
|
+
first = args[0]
|
|
437
|
+
if not first or first.startswith("-"):
|
|
438
|
+
return None, args, None
|
|
439
|
+
|
|
440
|
+
# Check for remote spec (user@host:/path) before local path
|
|
441
|
+
remote = parse_remote_spec(first)
|
|
442
|
+
if remote is not None:
|
|
443
|
+
return None, args[1:], remote
|
|
444
|
+
|
|
445
|
+
candidate = Path(first).expanduser()
|
|
446
|
+
if not candidate.is_dir():
|
|
447
|
+
return None, args, None
|
|
448
|
+
resolved = candidate.resolve()
|
|
449
|
+
return resolved, args[1:], None
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
_INSTALLED_MUX = Path("~/.ai-cli/bin/ai-mux").expanduser()
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _packaged_mux_binary() -> Path | None:
|
|
456
|
+
"""Return the correct packaged ai-mux binary for the current platform/arch."""
|
|
457
|
+
import platform
|
|
458
|
+
|
|
459
|
+
bin_dir = Path(__file__).resolve().parent / "bin"
|
|
460
|
+
system = platform.system().lower() # 'darwin', 'linux'
|
|
461
|
+
machine = platform.machine().lower() # 'arm64', 'aarch64', 'x86_64'
|
|
462
|
+
|
|
463
|
+
# Build candidate list: most specific first, generic fallback last.
|
|
464
|
+
candidates: list[Path] = []
|
|
465
|
+
if system == "linux" and machine in ("x86_64", "amd64"):
|
|
466
|
+
candidates.append(bin_dir / "ai-mux-linux-x86_64")
|
|
467
|
+
elif system == "linux" and machine in ("arm64", "aarch64"):
|
|
468
|
+
candidates.append(bin_dir / "ai-mux-linux-arm64")
|
|
469
|
+
elif system == "darwin" and machine in ("arm64", "aarch64"):
|
|
470
|
+
candidates.append(bin_dir / "ai-mux-darwin-arm64")
|
|
471
|
+
elif system == "darwin" and machine in ("x86_64", "amd64"):
|
|
472
|
+
candidates.append(bin_dir / "ai-mux-darwin-x86_64")
|
|
473
|
+
# Generic fallback (the default arm64 macOS binary)
|
|
474
|
+
candidates.append(bin_dir / "ai-mux")
|
|
475
|
+
|
|
476
|
+
for c in candidates:
|
|
477
|
+
if c.is_file() and os.access(c, os.X_OK):
|
|
478
|
+
return c
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _ensure_installed_mux() -> Path | None:
|
|
483
|
+
packaged = _packaged_mux_binary()
|
|
484
|
+
if packaged is None:
|
|
485
|
+
if _INSTALLED_MUX.is_file() and os.access(_INSTALLED_MUX, os.X_OK):
|
|
486
|
+
return _INSTALLED_MUX
|
|
487
|
+
return None
|
|
488
|
+
|
|
489
|
+
if _INSTALLED_MUX.is_file() and os.access(_INSTALLED_MUX, os.X_OK):
|
|
490
|
+
try:
|
|
491
|
+
if filecmp.cmp(packaged, _INSTALLED_MUX, shallow=False):
|
|
492
|
+
return _INSTALLED_MUX
|
|
493
|
+
except OSError:
|
|
494
|
+
pass
|
|
495
|
+
|
|
496
|
+
_INSTALLED_MUX.parent.mkdir(parents=True, exist_ok=True)
|
|
497
|
+
shutil.copy2(packaged, _INSTALLED_MUX)
|
|
498
|
+
_INSTALLED_MUX.chmod(0o755)
|
|
499
|
+
# Clear macOS quarantine/provenance xattrs so the binary can run from
|
|
500
|
+
# any volume without being SIGKILL'd by Gatekeeper.
|
|
501
|
+
try:
|
|
502
|
+
subprocess.run(
|
|
503
|
+
["xattr", "-cr", str(_INSTALLED_MUX)],
|
|
504
|
+
capture_output=True, timeout=5,
|
|
505
|
+
)
|
|
506
|
+
except Exception:
|
|
507
|
+
pass
|
|
508
|
+
return _INSTALLED_MUX
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def find_ai_mux() -> str | None:
|
|
512
|
+
# Prefer the installed copy in ~/.ai-cli/bin (safe from volume restrictions).
|
|
513
|
+
installed = _ensure_installed_mux()
|
|
514
|
+
if installed and installed.is_file() and os.access(installed, os.X_OK):
|
|
515
|
+
return str(installed)
|
|
516
|
+
|
|
517
|
+
repo_root = Path(__file__).resolve().parent.parent
|
|
518
|
+
candidates: list[Path] = []
|
|
519
|
+
# Platform-aware packaged binary
|
|
520
|
+
packaged = _packaged_mux_binary()
|
|
521
|
+
if packaged:
|
|
522
|
+
candidates.append(packaged)
|
|
523
|
+
candidates.extend([
|
|
524
|
+
repo_root / "mux" / "target" / "release" / "ai-mux",
|
|
525
|
+
Path("~/.local/bin/ai-mux").expanduser(),
|
|
526
|
+
])
|
|
527
|
+
path_hit = shutil.which("ai-mux")
|
|
528
|
+
if path_hit:
|
|
529
|
+
candidates.insert(1, Path(path_hit))
|
|
530
|
+
|
|
531
|
+
for candidate in candidates:
|
|
532
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
533
|
+
return str(candidate)
|
|
534
|
+
return None
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def ai_mux_status() -> tuple[str, str | None]:
|
|
538
|
+
if _INSTALLED_MUX.is_file() and os.access(_INSTALLED_MUX, os.X_OK):
|
|
539
|
+
return "installed", str(_INSTALLED_MUX)
|
|
540
|
+
|
|
541
|
+
packaged = _packaged_mux_binary()
|
|
542
|
+
if packaged:
|
|
543
|
+
return "packaged", str(packaged)
|
|
544
|
+
|
|
545
|
+
path_hit = shutil.which("ai-mux")
|
|
546
|
+
if path_hit:
|
|
547
|
+
return "path", path_hit
|
|
548
|
+
|
|
549
|
+
repo_build = Path(__file__).resolve().parent.parent / "mux" / "target" / "release" / "ai-mux"
|
|
550
|
+
if repo_build.is_file() and os.access(repo_build, os.X_OK):
|
|
551
|
+
return "repo-build", str(repo_build)
|
|
552
|
+
|
|
553
|
+
return "python-fallback", None
|