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/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