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.py ADDED
@@ -0,0 +1,1516 @@
1
+ """CLI dispatch — routes by argv[0] name or subcommand.
2
+
3
+ Entry point for the ai-cli unified wrapper. Supports:
4
+ - Binary-name routing: symlinks named 'claude', 'codex', etc. auto-dispatch
5
+ - Subcommand routing: 'ai-cli claude [args]', 'ai-cli menu', 'ai-cli system', etc.
6
+ - Default: opens TUI menu when invoked with no args
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ import time
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from ai_cli import __version__
24
+ from ai_cli.ca import bootstrap_ca_cert
25
+ from ai_cli.config import (
26
+ ensure_config,
27
+ get_privacy_config,
28
+ get_proxy_config,
29
+ get_retention_config,
30
+ get_tool_config,
31
+ )
32
+ from ai_cli.housekeeping import prune_old_logs, prune_old_traffic_rows
33
+ from ai_cli.instructions import (
34
+ ensure_project_instructions_file,
35
+ edit_instructions,
36
+ resolve_base_instructions_path,
37
+ resolve_instructions_file,
38
+ )
39
+ from ai_cli.log import append_log, fmt_cmd
40
+ from ai_cli.main_helpers import (
41
+ ai_mux_status as _mh_ai_mux_status,
42
+ check_codex_proxy_compat as _mh_check_codex_proxy_compat,
43
+ cleanup_session_files as _mh_cleanup_session_files,
44
+ extract_launch_cwd as _mh_extract_launch_cwd,
45
+ find_ai_mux as _mh_find_ai_mux,
46
+ find_reusable_tmux_session as _mh_find_reusable_tmux_session,
47
+ kill_proxy_from_env as _mh_kill_proxy_from_env,
48
+ parse_wrapper_overrides as _mh_parse_wrapper_overrides,
49
+ replace_existing_tmux_session as _mh_replace_existing_tmux_session,
50
+ resolve_recv_context_file as _mh_resolve_recv_context_file,
51
+ session_id as _mh_session_id,
52
+ spawn_detached_proxy_watcher as _mh_spawn_detached_proxy_watcher,
53
+ terminate_pid as _mh_terminate_pid,
54
+ tmux_list_sessions as _mh_tmux_list_sessions,
55
+ tmux_session_env as _mh_tmux_session_env,
56
+ write_session_files as _mh_write_session_files,
57
+ )
58
+ from ai_cli.session import build_recent_context_for_cwd
59
+ from ai_cli.proxy import (
60
+ allocate_port,
61
+ apply_pinned_mitmdump_path,
62
+ build_mitmdump_cmd,
63
+ build_proxy_env,
64
+ ensure_mitmdump,
65
+ resolve_proxy_host,
66
+ start_proxy,
67
+ stop_process,
68
+ verify_proxy_flow,
69
+ )
70
+ from ai_cli.remote import RemoteSpec
71
+ from ai_cli.tools import TOOL_ALIASES, load_registry, ToolSpec
72
+
73
+
74
+ def _check_codex_proxy_compat(log_path: Path | None = None) -> None:
75
+ _mh_check_codex_proxy_compat(log_path=log_path)
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Session management
80
+ # ---------------------------------------------------------------------------
81
+
82
+ def _session_id(tool_name: str) -> str:
83
+ return _mh_session_id(tool_name)
84
+
85
+
86
+ def _write_session_files(session_id: str, port: int) -> None:
87
+ _mh_write_session_files(session_id, port)
88
+
89
+
90
+ def _cleanup_session_files(session_id: str) -> None:
91
+ _mh_cleanup_session_files(session_id)
92
+
93
+
94
+ def _install_prompt_editor_launcher() -> str:
95
+ src = Path(__file__).resolve().parent / "prompt_editor_launcher.py"
96
+ dest = Path("~/.ai-cli/bin/ai-prompt-editor").expanduser()
97
+ dest.parent.mkdir(parents=True, exist_ok=True)
98
+ shutil.copy2(src, dest)
99
+ os.chmod(dest, 0o755)
100
+ return str(dest)
101
+
102
+
103
+ def _spawn_detached_proxy_watcher(
104
+ mitm_pid: int,
105
+ session_id: str,
106
+ tmux_sessions: list[str],
107
+ log_path: Path,
108
+ ) -> bool:
109
+ return _mh_spawn_detached_proxy_watcher(
110
+ mitm_pid=mitm_pid,
111
+ session_id_value=session_id,
112
+ tmux_sessions=tmux_sessions,
113
+ log_path=log_path,
114
+ )
115
+
116
+
117
+ def _tmux_list_sessions(socket_name: str = "ai-mux") -> list[str]:
118
+ return _mh_tmux_list_sessions(socket_name=socket_name)
119
+
120
+
121
+ def _tmux_session_env(session_name: str, socket_name: str = "ai-mux") -> dict[str, str]:
122
+ return _mh_tmux_session_env(session_name=session_name, socket_name=socket_name)
123
+
124
+
125
+ def _resolve_recv_context_file(cwd: Path) -> str:
126
+ return _mh_resolve_recv_context_file(cwd)
127
+
128
+
129
+ def _find_reusable_tmux_session(
130
+ tool_name: str,
131
+ effective_cwd: Path,
132
+ socket_name: str = "ai-mux",
133
+ ) -> tuple[str, dict[str, str]] | None:
134
+ return _mh_find_reusable_tmux_session(
135
+ tool_name=tool_name,
136
+ effective_cwd=effective_cwd,
137
+ socket_name=socket_name,
138
+ )
139
+
140
+
141
+ def _terminate_pid(pid: int, timeout_seconds: float = 3.0) -> None:
142
+ _mh_terminate_pid(pid=pid, timeout_seconds=timeout_seconds)
143
+
144
+
145
+ def _kill_proxy_from_env(session_env: dict[str, str], log_path: Path) -> None:
146
+ _mh_kill_proxy_from_env(session_env=session_env, log_path=log_path)
147
+
148
+
149
+ def _replace_existing_tmux_session(
150
+ session_name: str,
151
+ session_env: dict[str, str],
152
+ log_path: Path,
153
+ socket_name: str = "ai-mux",
154
+ ) -> None:
155
+ _mh_replace_existing_tmux_session(
156
+ session_name=session_name,
157
+ session_env=session_env,
158
+ log_path=log_path,
159
+ socket_name=socket_name,
160
+ )
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Tool runner
165
+ # ---------------------------------------------------------------------------
166
+
167
+ def _parse_wrapper_overrides(args: list[str]) -> tuple[list[str], dict[str, Any]]:
168
+ return _mh_parse_wrapper_overrides(args)
169
+
170
+
171
+ def _extract_launch_cwd(args: list[str]) -> tuple[Path | None, list[str], RemoteSpec | None]:
172
+ return _mh_extract_launch_cwd(args)
173
+
174
+
175
+ def _find_ai_mux() -> str | None:
176
+ return _mh_find_ai_mux()
177
+
178
+
179
+ def _ai_mux_status() -> tuple[str, str | None]:
180
+ return _mh_ai_mux_status()
181
+
182
+
183
+ def _default_remote_session_name(tool_name: str, remote_spec: RemoteSpec) -> str:
184
+ digest = hashlib.sha256(
185
+ f"{tool_name}:{remote_spec.display}".encode("utf-8")
186
+ ).hexdigest()[:12]
187
+ return f"ai-cli-{tool_name}-{digest}"
188
+
189
+
190
+ def _resolve_tool_prompt_file(config: dict[str, Any], tool_name: str) -> str:
191
+ tools_cfg = config.get("tools", {})
192
+ raw_tool_cfg = tools_cfg.get(tool_name, {}) if isinstance(tools_cfg, dict) else {}
193
+ path_value = ""
194
+ if isinstance(raw_tool_cfg, dict):
195
+ raw = raw_tool_cfg.get("instructions_file")
196
+ if isinstance(raw, str):
197
+ path_value = raw.strip()
198
+ if not path_value:
199
+ path_value = str(Path("~/.ai-cli/instructions").expanduser() / f"{tool_name}.txt")
200
+ return resolve_instructions_file(path_value)
201
+
202
+
203
+ _PROXY_STRIP_KEYS = (
204
+ "HTTP_PROXY",
205
+ "HTTPS_PROXY",
206
+ "ALL_PROXY",
207
+ "http_proxy",
208
+ "https_proxy",
209
+ "all_proxy",
210
+ "SSL_CERT_FILE",
211
+ "REQUESTS_CA_BUNDLE",
212
+ "NODE_EXTRA_CA_CERTS",
213
+ "AI_CLI_PROXY_PID",
214
+ "AI_CLI_PROXY_URL",
215
+ )
216
+
217
+
218
+ def _build_direct_env(extra_env: dict[str, str] | None = None) -> dict[str, str]:
219
+ """Build environment for direct launches with proxy overrides removed."""
220
+ env = dict(os.environ)
221
+ for key in _PROXY_STRIP_KEYS:
222
+ env.pop(key, None)
223
+ if extra_env:
224
+ env.update(extra_env)
225
+ apply_pinned_mitmdump_path(env)
226
+ return env
227
+
228
+
229
+ def _warn_proxy_disabled(log_path: Path, mitm_log_path: Path, reason: str) -> None:
230
+ """Emit a loud warning when proxy startup fails and wrapper degrades gracefully."""
231
+ message = (
232
+ "\n"
233
+ "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
234
+ "!! AI-CLI PROXY DISABLED !!\n"
235
+ "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
236
+ "mitmproxy/mitmdump failed to start; launching tool WITHOUT interception.\n"
237
+ "Instruction injection and traffic capture are DISABLED for this run.\n"
238
+ f"Reason: {reason or 'unknown proxy startup failure'}\n"
239
+ f"Wrapper log: {log_path}\n"
240
+ f"Proxy log: {mitm_log_path}\n"
241
+ "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n"
242
+ )
243
+ print(message, file=sys.stderr)
244
+ append_log(log_path, "PROXY DISABLED: running tool without MITM interception")
245
+ if reason:
246
+ append_log(log_path, f"Proxy failure reason: {reason}")
247
+
248
+
249
+ def run_tool(tool_name: str, args: list[str]) -> int:
250
+ """Run a managed AI CLI tool through the mitmproxy wrapper."""
251
+ parsed_tool_args, wrapper_overrides = _parse_wrapper_overrides(args)
252
+ launch_cwd, parsed_tool_args, remote_spec = _extract_launch_cwd(parsed_tool_args)
253
+
254
+ registry = load_registry()
255
+ spec = registry.get(tool_name)
256
+ if spec is None:
257
+ print(f"Unknown tool: {tool_name}", file=sys.stderr)
258
+ print(f"Available: {', '.join(registry.keys())}", file=sys.stderr)
259
+ return 1
260
+
261
+ config = ensure_config()
262
+ tool_cfg = get_tool_config(config, tool_name)
263
+ proxy_cfg = get_proxy_config(config)
264
+ retention_cfg = get_retention_config(config)
265
+ privacy_cfg = get_privacy_config(config)
266
+
267
+ if not tool_cfg["enabled"]:
268
+ print(f"Tool '{tool_name}' is disabled in config.", file=sys.stderr)
269
+ return 1
270
+
271
+ # Resolve binary
272
+ use_app = wrapper_overrides.get("use_app_binary", False)
273
+ if use_app:
274
+ if not spec.app_binary:
275
+ print(f"Tool '{tool_name}' has no macOS app binary configured.", file=sys.stderr)
276
+ return 1
277
+ if not Path(spec.app_binary).is_file():
278
+ print(f"macOS app binary not found: {spec.app_binary}", file=sys.stderr)
279
+ return 1
280
+ binary = spec.app_binary
281
+ else:
282
+ binary = spec.resolve_binary(tool_cfg["binary"])
283
+ if not spec.detect_installed(tool_cfg["binary"]):
284
+ print(f"Tool binary not found: {binary}", file=sys.stderr)
285
+ if spec.install_command:
286
+ print(f"Install with: {spec.install_command}", file=sys.stderr)
287
+ return 1
288
+
289
+ # Codex-specific: check for network-proxy settings that could break our MITM
290
+ if tool_name == "codex":
291
+ _check_codex_proxy_compat(log_path=None)
292
+
293
+ # Session setup
294
+ session_id = _session_id(tool_name)
295
+ log_dir = Path("~/.ai-cli/logs").expanduser()
296
+ log_dir.mkdir(parents=True, exist_ok=True)
297
+ log_path = log_dir / f"{session_id}.log"
298
+ mitm_log_path = log_dir / f"{session_id}.mitmdump.log"
299
+ traffic_db_path = Path("~/.ai-cli/traffic.db").expanduser()
300
+
301
+ prune_old_logs(log_dir=log_dir, max_age_days=retention_cfg["logs_days"], log_path=log_path)
302
+ prune_old_traffic_rows(
303
+ db_path=traffic_db_path,
304
+ max_age_days=retention_cfg["traffic_days"],
305
+ log_path=log_path,
306
+ )
307
+
308
+ # ── Compute remote mode ──────────────────────────────────────────────
309
+ # Default for remote specs is session mode (tool runs on the remote).
310
+ # Use --ai-cli-remote-rsync / AI_CLI_REMOTE_RSYNC=1 to force old rsync mode.
311
+ _remote_rsync_flag = (
312
+ wrapper_overrides.get("remote_rsync", False)
313
+ or (
314
+ os.environ.get("AI_CLI_REMOTE_RSYNC", "").strip().lower()
315
+ in {"1", "true", "yes", "on"}
316
+ )
317
+ )
318
+ _remote_session_flag = (
319
+ remote_spec is not None and not _remote_rsync_flag
320
+ )
321
+
322
+ local_mirror: Path | None = None
323
+ runner: RemoteSessionRunner | None = None
324
+
325
+ # ── Remote folder proxy (rsync mode — only when explicitly requested) ─
326
+ if remote_spec is not None and _remote_rsync_flag:
327
+ from ai_cli.remote import (
328
+ make_local_mirror,
329
+ print_sync_status,
330
+ sync_down,
331
+ sync_up,
332
+ verify_ssh,
333
+ )
334
+
335
+ print_sync_status(f"Connecting to {remote_spec.display} …")
336
+ try:
337
+ verify_ssh(remote_spec)
338
+ except RuntimeError as exc:
339
+ print(str(exc), file=sys.stderr)
340
+ return 1
341
+
342
+ local_mirror = make_local_mirror(remote_spec)
343
+ print_sync_status(f"Syncing down → {local_mirror}")
344
+ try:
345
+ sync_down(remote_spec, local_mirror)
346
+ except (RuntimeError, FileNotFoundError) as exc:
347
+ print(str(exc), file=sys.stderr)
348
+ return 1
349
+
350
+ launch_cwd = local_mirror
351
+ print_sync_status("Local mirror ready")
352
+
353
+ effective_cwd = launch_cwd or Path.cwd()
354
+ context_cwd = remote_spec.path if remote_spec is not None else str(effective_cwd)
355
+ append_log(
356
+ log_path,
357
+ f"Wrapper start (ai-cli {__version__}, tool={tool_name}, cwd={effective_cwd}"
358
+ + (f", remote={remote_spec.display}" if remote_spec else "")
359
+ + ")",
360
+ )
361
+
362
+ # Build tool command early so we can rehook/replace before starting a proxy.
363
+ tool_args = list(parsed_tool_args)
364
+ if tool_args and tool_args[0] == "--":
365
+ tool_args = tool_args[1:]
366
+ if tool_args and Path(tool_args[0]).name == Path(binary).name:
367
+ tool_args = tool_args[1:]
368
+ tool_cmd = [binary, *tool_args]
369
+
370
+ wrapper_flag_used = (
371
+ wrapper_overrides["instructions_file"] is not None
372
+ or wrapper_overrides["instructions_text"] is not None
373
+ or wrapper_overrides["canary_rule"] is not None
374
+ or wrapper_overrides["passthrough"] is not None
375
+ or wrapper_overrides["debug_requests"] is not None
376
+ or wrapper_overrides["rewrite_test_mode"] is not None
377
+ or wrapper_overrides["developer_instructions_mode"] is not None
378
+ or bool((wrapper_overrides["rewrite_test_tag"] or "").strip())
379
+ or bool(wrapper_overrides["no_startup_context"])
380
+ or bool(wrapper_overrides.get("use_app_binary"))
381
+ )
382
+ tool_flag_used = any(arg.startswith("-") for arg in tool_args)
383
+ replace_existing = wrapper_flag_used or tool_flag_used
384
+
385
+ reusable = _find_reusable_tmux_session(tool_name, effective_cwd, socket_name="ai-mux")
386
+ if reusable is not None:
387
+ existing_session_name, existing_session_env = reusable
388
+ if replace_existing:
389
+ append_log(
390
+ log_path,
391
+ f"Replacing existing tmux session for cwd/tool: {existing_session_name}",
392
+ )
393
+ _replace_existing_tmux_session(
394
+ existing_session_name,
395
+ existing_session_env,
396
+ log_path,
397
+ socket_name="ai-mux",
398
+ )
399
+ elif sys.stdin.isatty() and sys.stdout.isatty():
400
+ if tool_args:
401
+ append_log(
402
+ log_path,
403
+ "Rehooking existing session and ignoring forwarded non-flag args",
404
+ )
405
+ append_log(log_path, f"Rehooking existing tmux session: {existing_session_name}")
406
+ attach_rc = subprocess.call(
407
+ ["tmux", "-L", "ai-mux", "attach-session", "-t", existing_session_name]
408
+ )
409
+ if attach_rc == 0:
410
+ return 0
411
+ append_log(
412
+ log_path,
413
+ f"Rehook failed (rc={attach_rc}); creating parallel session instead",
414
+ )
415
+
416
+ append_log(log_path, f"Tool command: {fmt_cmd(tool_cmd)}")
417
+
418
+ # Resolve instructions
419
+ explicit_instructions_file = wrapper_overrides["instructions_file"]
420
+ if explicit_instructions_file is not None and not explicit_instructions_file.strip():
421
+ print(
422
+ "--ai-cli-system-instructions-file requires a non-empty path.",
423
+ file=sys.stderr,
424
+ )
425
+ return 1
426
+
427
+ resolved_global_instructions_path = (
428
+ explicit_instructions_file
429
+ if explicit_instructions_file is not None
430
+ else config.get("instructions_file", "")
431
+ )
432
+ try:
433
+ instructions_file = resolve_instructions_file(resolved_global_instructions_path)
434
+ except OSError as exc:
435
+ append_log(log_path, str(exc))
436
+ return 1
437
+
438
+ canary_rule = (
439
+ wrapper_overrides["canary_rule"]
440
+ if wrapper_overrides["canary_rule"] is not None
441
+ else tool_cfg["canary_rule"]
442
+ )
443
+ startup_context = ""
444
+ if not wrapper_overrides["no_startup_context"]:
445
+ startup_context = build_recent_context_for_cwd(
446
+ context_cwd,
447
+ remote_host=remote_spec.host if remote_spec is not None else "",
448
+ )
449
+ runtime_canary = canary_rule
450
+ if startup_context:
451
+ runtime_canary = (
452
+ f"{canary_rule}\n\n{startup_context}"
453
+ if canary_rule.strip()
454
+ else startup_context
455
+ )
456
+ append_log(log_path, "Loaded startup recent-context block from session history.")
457
+ print("ai-cli startup context:", file=sys.stderr)
458
+ for line in startup_context.splitlines():
459
+ print(f" {line}", file=sys.stderr)
460
+
461
+ # Inline text override (--ai-cli-instructions-text) — only passed when
462
+ # the user explicitly provides text instead of relying on the file.
463
+ inline_global_override = wrapper_overrides["instructions_text"]
464
+ proxy_instructions_file = (
465
+ ""
466
+ if inline_global_override is not None
467
+ else instructions_file
468
+ )
469
+
470
+ tool_prompt_file = _resolve_tool_prompt_file(config, tool_name)
471
+ project_prompt_file = ensure_project_instructions_file(
472
+ project_cwd=str(effective_cwd),
473
+ remote_spec=remote_spec.display if remote_spec is not None else "",
474
+ )
475
+ base_prompt_file = str(resolve_base_instructions_path())
476
+
477
+ # Addons use prompt_builder to read files fresh on each request —
478
+ # we only pass file paths and canary_rule via --set, not text blobs.
479
+ append_log(
480
+ log_path,
481
+ f"Instructions: global={instructions_file} base={base_prompt_file} "
482
+ f"project={project_prompt_file} tool={tool_prompt_file}",
483
+ )
484
+
485
+ # Build addon list
486
+ # System prompt capture must load first (before injection modifies the body)
487
+ addons_dir = Path(__file__).resolve().parent / "addons"
488
+ addon_paths = [str(addons_dir / "system_prompt_addon.py")]
489
+ addon_paths.append(spec.addon_path())
490
+ # Claude gets the credentials addon too
491
+ if tool_name == "claude":
492
+ addon_paths.append(str(addons_dir / "credentials_addon.py"))
493
+ # Traffic logger — always loaded (logs all URLs, API bodies to SQLite)
494
+ addon_paths.append(str(addons_dir / "traffic_log_addon.py"))
495
+
496
+ # Start proxy (best effort). If it fails, continue without MITM.
497
+ mitm_proc: subprocess.Popen[Any] | None = None
498
+ proxy_enabled = False
499
+ proxy_url = ""
500
+ proxy_failure_reason = ""
501
+ ca_path = Path(proxy_cfg["ca_path"]).expanduser()
502
+ host = proxy_cfg["host"]
503
+ port = 0
504
+
505
+ try:
506
+ mitmdump_bin = ensure_mitmdump(log_path)
507
+ bootstrap_ca_cert(ca_path, mitmdump_bin, log_path)
508
+ port = allocate_port(host, fallback=spec.fallback_port)
509
+ append_log(log_path, f"Allocated port {port}")
510
+
511
+ mitm_cmd = build_mitmdump_cmd(
512
+ mitmdump_bin=mitmdump_bin,
513
+ host=host,
514
+ port=port,
515
+ addon_paths=addon_paths,
516
+ target_path=spec.target_path,
517
+ wrapper_log_file=str(log_path),
518
+ instructions_file=proxy_instructions_file,
519
+ instructions_text=inline_global_override,
520
+ instructions_text_explicit=(inline_global_override is not None),
521
+ base_instructions_file=base_prompt_file,
522
+ project_instructions_file=str(project_prompt_file),
523
+ tool_instructions_file=tool_prompt_file,
524
+ canary_rule=runtime_canary,
525
+ passthrough=(
526
+ wrapper_overrides["passthrough"]
527
+ if wrapper_overrides["passthrough"] is not None
528
+ else tool_cfg["passthrough"]
529
+ ),
530
+ debug_requests=(
531
+ wrapper_overrides["debug_requests"]
532
+ if wrapper_overrides["debug_requests"] is not None
533
+ else tool_cfg["debug_requests"]
534
+ ),
535
+ rewrite_test_mode=(
536
+ (wrapper_overrides["rewrite_test_mode"] or "").strip().lower()
537
+ if tool_name == "codex" and wrapper_overrides["rewrite_test_mode"] is not None
538
+ else ""
539
+ ),
540
+ developer_instructions_mode=(
541
+ (wrapper_overrides["developer_instructions_mode"] or "").strip().lower()
542
+ if tool_name == "codex" and wrapper_overrides["developer_instructions_mode"] is not None
543
+ else (
544
+ (tool_cfg.get("developer_instructions_mode", "") or "").strip().lower()
545
+ if tool_name == "codex"
546
+ else ""
547
+ )
548
+ ),
549
+ rewrite_test_tag=(
550
+ (wrapper_overrides["rewrite_test_tag"] or "").strip()
551
+ if tool_name == "codex" and wrapper_overrides["rewrite_test_tag"] is not None
552
+ else ""
553
+ ),
554
+ codex_developer_prompt_file=(
555
+ tool_prompt_file if tool_name == "codex" else ""
556
+ ),
557
+ traffic_caller=tool_name,
558
+ traffic_max_age_days=retention_cfg["traffic_days"],
559
+ traffic_redact=privacy_cfg["redact_traffic_bodies"],
560
+ prompt_recv_prefix_file=_resolve_recv_context_file(effective_cwd),
561
+ prompt_context_cwd=context_cwd,
562
+ )
563
+
564
+ proxy_host = resolve_proxy_host(host)
565
+ proxy_url = f"http://{proxy_host}:{port}"
566
+ append_log(log_path, f"Starting proxy at {proxy_url}")
567
+ mitm_proc = start_proxy(mitm_cmd, log_path, mitm_log_path)
568
+ if not verify_proxy_flow(proxy_host, port, log_path):
569
+ raise RuntimeError("Proxy started but failed flow health check.")
570
+ _write_session_files(session_id, port)
571
+ proxy_enabled = True
572
+ except (FileNotFoundError, RuntimeError, OSError) as exc:
573
+ proxy_failure_reason = str(exc).strip()
574
+ if mitm_proc is not None:
575
+ try:
576
+ stop_process(mitm_proc)
577
+ except Exception:
578
+ pass
579
+ mitm_proc = None
580
+ _warn_proxy_disabled(log_path, mitm_log_path, proxy_failure_reason)
581
+ _cleanup_session_files(session_id)
582
+
583
+ # Build environment
584
+ if proxy_enabled:
585
+ env = build_proxy_env(proxy_url, ca_path, log_path, spec.extra_env)
586
+ else:
587
+ env = _build_direct_env(spec.extra_env)
588
+ prompt_editor_launcher = _install_prompt_editor_launcher()
589
+ env["AI_CLI_TOOL"] = tool_name
590
+ env["AI_CLI_SESSION"] = session_id
591
+ env["AI_CLI_WORKDIR"] = str(effective_cwd)
592
+ env["AI_CLI_BASE_PROMPT_FILE"] = base_prompt_file
593
+ env["AI_CLI_GLOBAL_PROMPT_FILE"] = instructions_file
594
+ env["AI_CLI_TOOL_PROMPT_FILE"] = tool_prompt_file
595
+ env["AI_CLI_PROJECT_PROMPT_FILE"] = str(project_prompt_file)
596
+ env["AI_CLI_PYTHON"] = sys.executable or "python3"
597
+ env["AI_CLI_PROMPT_EDITOR_LAUNCHER"] = prompt_editor_launcher
598
+ if remote_spec is not None:
599
+ env["AI_CLI_REMOTE_SPEC"] = remote_spec.display
600
+ if proxy_enabled and mitm_proc is not None:
601
+ env["AI_CLI_PROXY_PID"] = str(mitm_proc.pid)
602
+ env["AI_CLI_PROXY_URL"] = proxy_url
603
+ else:
604
+ env["AI_CLI_PROXY_DISABLED"] = "1"
605
+ if proxy_failure_reason:
606
+ env["AI_CLI_PROXY_FAILURE_REASON"] = proxy_failure_reason
607
+
608
+ # Codex maps Ctrl+G to "edit in external editor". Keep it enabled by
609
+ # default; allow users to disable it explicitly for wrapped sessions.
610
+ if tool_name == "codex":
611
+ disable_external_editor = (
612
+ os.environ.get("AI_CLI_CODEX_DISABLE_EXTERNAL_EDITOR", "")
613
+ .strip()
614
+ .lower()
615
+ in {"1", "true", "yes", "on"}
616
+ )
617
+ if disable_external_editor:
618
+ env.pop("VISUAL", None)
619
+ env.pop("EDITOR", None)
620
+ append_log(
621
+ log_path,
622
+ "Codex external editor disabled for wrapper session "
623
+ "(set AI_CLI_CODEX_DISABLE_EXTERNAL_EDITOR=0 to re-enable).",
624
+ )
625
+
626
+ # ── Remote session mode ─────────────────────────────────────────────
627
+ # Proxy is running locally; launch the tool on the remote with an SSH
628
+ # reverse tunnel so the remote tool's traffic flows through our proxy.
629
+ # A "remote package" is assembled and pushed first so the tool runs
630
+ # inside an isolated $HOME with the right configs/credentials/CA certs.
631
+ if remote_spec is not None and _remote_session_flag:
632
+ from ai_cli.remote import (
633
+ RemoteSessionRunner,
634
+ install_remote_tool,
635
+ print_sync_status,
636
+ resolve_remote_tool_env,
637
+ )
638
+
639
+ remote_init = wrapper_overrides.get("remote_init") or ""
640
+ import shlex as _shlex
641
+ remote_tool_cmd = spec.default_binary
642
+ remote_tool_args_suffix = ""
643
+ # Forward any extra tool args
644
+ _rs_tool_args = list(parsed_tool_args)
645
+ if _rs_tool_args and _rs_tool_args[0] == "--":
646
+ _rs_tool_args = _rs_tool_args[1:]
647
+ if _rs_tool_args and Path(_rs_tool_args[0]).name == Path(binary).name:
648
+ _rs_tool_args = _rs_tool_args[1:]
649
+ if _rs_tool_args:
650
+ remote_tool_args_suffix = " " + " ".join(_shlex.quote(a) for a in _rs_tool_args)
651
+ remote_tool_cmd += remote_tool_args_suffix
652
+
653
+ _effective_proxy_port = port if proxy_enabled else 0
654
+ _no_package = (
655
+ wrapper_overrides.get("remote_no_package", False)
656
+ or (
657
+ os.environ.get("AI_CLI_REMOTE_NO_PACKAGE", "").strip().lower()
658
+ in {"1", "true", "yes", "on"}
659
+ )
660
+ )
661
+
662
+ # ── Packaged mode (default) ──────────────────────────────────────
663
+ if not _no_package:
664
+ from ai_cli.remote_package import (
665
+ PackageFileEntry,
666
+ build_package_manifest,
667
+ ensure_remote_ai_mux_asset,
668
+ probe_remote_session,
669
+ pull_session_artifacts,
670
+ push_package,
671
+ reattach_remote_session,
672
+ render_remote_ai_mux_config,
673
+ )
674
+
675
+ remote_ai_mux_binary = None
676
+ try:
677
+ remote_ai_mux_binary = ensure_remote_ai_mux_asset(remote_spec)
678
+ except (FileNotFoundError, RuntimeError) as exc:
679
+ append_log(log_path, f"Remote ai-mux unavailable, falling back to tmux: {exc}")
680
+
681
+ package = build_package_manifest(
682
+ tool_name,
683
+ remote_spec,
684
+ ca_path=ca_path if proxy_enabled else None,
685
+ ai_mux_binary=remote_ai_mux_binary,
686
+ )
687
+ try:
688
+ resolved_remote_binary, resolved_remote_path = resolve_remote_tool_env(
689
+ remote_spec,
690
+ spec.default_binary,
691
+ real_home=package.real_home,
692
+ )
693
+ except RuntimeError:
694
+ # Tool not found — attempt auto-install
695
+ _install_cmd = spec.get_install_command()
696
+ if _install_cmd:
697
+ print_sync_status(
698
+ f"{tool_name} not found on {remote_spec.ssh_target}, "
699
+ f"installing..."
700
+ )
701
+ install_remote_tool(
702
+ remote_spec,
703
+ tool_name,
704
+ _install_cmd,
705
+ real_home=package.real_home,
706
+ )
707
+ resolved_remote_binary, resolved_remote_path = resolve_remote_tool_env(
708
+ remote_spec,
709
+ spec.default_binary,
710
+ real_home=package.real_home,
711
+ )
712
+ else:
713
+ raise
714
+ remote_tool_cmd = _shlex.quote(resolved_remote_binary) + remote_tool_args_suffix
715
+ remote_tool_cmd_parts = [resolved_remote_binary, *_rs_tool_args]
716
+
717
+ remote_mux_env = {
718
+ "AI_CLI_PYTHON": "python3",
719
+ "AI_CLI_PROMPT_EDITOR_LAUNCHER": f"{package.session_dir}/.ai-cli/bin/ai-prompt-editor",
720
+ "AI_CLI_GLOBAL_PROMPT_FILE": f"{package.session_dir}/.ai-cli/system_instructions.txt",
721
+ "AI_CLI_BASE_PROMPT_FILE": f"{package.session_dir}/.ai-cli/base_instructions.txt",
722
+ "AI_CLI_TOOL_PROMPT_FILE": f"{package.session_dir}/.ai-cli/instructions/{tool_name}.txt",
723
+ "AI_CLI_PROJECT_PROMPT_FILE": f"{package.session_dir}/{package.project_prompt_rel_path}",
724
+ "AI_CLI_TOOL": tool_name,
725
+ "AI_CLI_WORKDIR": remote_spec.path,
726
+ "PATH": resolved_remote_path,
727
+ "REAL_HOME": package.real_home,
728
+ "HOME": package.session_dir,
729
+ "ZDOTDIR": package.session_dir,
730
+ "BASH_ENV": f"{package.session_dir}/.bash_env",
731
+ "ENV": f"{package.session_dir}/.shrc",
732
+ "KSHRC": f"{package.session_dir}/.kshrc",
733
+ }
734
+ if _effective_proxy_port:
735
+ proxy_url = f"http://127.0.0.1:{_effective_proxy_port}"
736
+ remote_ca = f"{package.session_dir}/.ai-cli/remote-ca.pem"
737
+ remote_mux_env.update(
738
+ {
739
+ "HTTP_PROXY": proxy_url,
740
+ "HTTPS_PROXY": proxy_url,
741
+ "http_proxy": proxy_url,
742
+ "https_proxy": proxy_url,
743
+ "SSL_CERT_FILE": remote_ca,
744
+ "REQUESTS_CA_BUNDLE": remote_ca,
745
+ "NODE_EXTRA_CA_CERTS": remote_ca,
746
+ }
747
+ )
748
+
749
+ ai_mux_command_parts = remote_tool_cmd_parts
750
+ if remote_init:
751
+ ai_mux_command_parts = [
752
+ "sh",
753
+ "-lc",
754
+ f"{remote_init} && exec {remote_tool_cmd}",
755
+ ]
756
+
757
+ if remote_ai_mux_binary is not None:
758
+ package.entries.append(
759
+ PackageFileEntry(
760
+ remote_rel_path=".ai-cli/ai-mux.json",
761
+ content=render_remote_ai_mux_config(
762
+ tool_name=tool_name,
763
+ session_name=package.session_name,
764
+ command=ai_mux_command_parts,
765
+ cwd=remote_spec.path,
766
+ env=remote_mux_env,
767
+ ),
768
+ )
769
+ )
770
+
771
+ append_log(
772
+ log_path,
773
+ f"Remote session mode (packaged): {remote_spec.display} "
774
+ f"session={package.session_name} home={package.session_dir} "
775
+ f"proxy={'yes' if proxy_enabled else 'no'}",
776
+ )
777
+
778
+ try:
779
+ print_sync_status(
780
+ f"Pushing config package → "
781
+ f"{remote_spec.ssh_target}:{package.session_dir}"
782
+ )
783
+ push_package(package, remote_spec)
784
+
785
+ if remote_ai_mux_binary is not None:
786
+ pkg_runner = RemoteSessionRunner(
787
+ spec=remote_spec,
788
+ session_name=package.session_name,
789
+ )
790
+ ai_mux_launch = (
791
+ f"{_shlex.quote(package.session_dir + '/.ai-cli/bin/ai-mux')} "
792
+ f"--config {_shlex.quote(package.session_dir + '/.ai-cli/ai-mux.json')} "
793
+ f"--session-name {_shlex.quote(package.session_name)} "
794
+ f"--socket-name {_shlex.quote(package.tmux_socket)}"
795
+ )
796
+ exit_code = pkg_runner.exec_attached(
797
+ command=ai_mux_launch,
798
+ proxy_port=_effective_proxy_port,
799
+ home_dir=package.session_dir,
800
+ real_home=package.real_home,
801
+ launch_path=resolved_remote_path,
802
+ )
803
+ append_log(log_path, f"Remote ai-mux session exit code: {exit_code}")
804
+ else:
805
+
806
+ # Check for an existing session — reattach without re-pushing
807
+ if probe_remote_session(package, remote_spec):
808
+ print_sync_status(
809
+ f"Existing session found: {package.session_name}"
810
+ )
811
+ exit_code = reattach_remote_session(
812
+ package,
813
+ remote_spec,
814
+ proxy_port=_effective_proxy_port,
815
+ )
816
+ append_log(log_path, f"Remote reattach exit code: {exit_code}")
817
+ else:
818
+ pkg_runner = RemoteSessionRunner(
819
+ spec=remote_spec,
820
+ session_name=package.session_name,
821
+ )
822
+ cd_cmd = f"cd {_shlex.quote(remote_spec.path)}"
823
+ if remote_init:
824
+ init_sequence = f"{cd_cmd} && {remote_init}"
825
+ else:
826
+ init_sequence = cd_cmd
827
+
828
+ exit_code = pkg_runner.run_attached(
829
+ command=remote_tool_cmd,
830
+ init_cmd=init_sequence,
831
+ proxy_port=_effective_proxy_port,
832
+ home_dir=package.session_dir,
833
+ real_home=package.real_home,
834
+ launch_path=resolved_remote_path,
835
+ tmux_socket=package.tmux_socket,
836
+ )
837
+ append_log(log_path, f"Remote session exit code: {exit_code}")
838
+ except (FileNotFoundError, RuntimeError) as exc:
839
+ print(f"Remote session failed: {exc}", file=sys.stderr)
840
+ append_log(log_path, f"Remote session failed: {exc}")
841
+ exit_code = 1
842
+ finally:
843
+ try:
844
+ pull_session_artifacts(package, remote_spec, log_dir)
845
+ except Exception as exc:
846
+ append_log(log_path, f"Remote artifact pull failed: {exc}")
847
+ if mitm_proc is not None:
848
+ try:
849
+ stop_process(mitm_proc)
850
+ except Exception:
851
+ pass
852
+ _cleanup_session_files(session_id)
853
+ append_log(log_path, "Wrapper stop (remote session, packaged)")
854
+ return exit_code
855
+
856
+ # ── No-package mode (raw $HOME, no isolation) ────────────────────
857
+ remote_session_name = (
858
+ wrapper_overrides.get("remote_session_name")
859
+ or _default_remote_session_name(tool_name, remote_spec)
860
+ )
861
+ append_log(
862
+ log_path,
863
+ f"Remote session mode (no-package): {remote_spec.display} "
864
+ f"session={remote_session_name} "
865
+ f"proxy={'yes' if proxy_enabled else 'no'}",
866
+ )
867
+
868
+ try:
869
+ runner = RemoteSessionRunner(
870
+ spec=remote_spec,
871
+ session_name=remote_session_name,
872
+ )
873
+ import shlex as _shlex
874
+ cd_cmd = f"cd {_shlex.quote(remote_spec.path)}"
875
+ if remote_init:
876
+ init_sequence = f"{cd_cmd} && {remote_init}"
877
+ else:
878
+ init_sequence = cd_cmd
879
+
880
+ exit_code = runner.run_attached(
881
+ command=remote_tool_cmd,
882
+ init_cmd=init_sequence,
883
+ proxy_port=_effective_proxy_port,
884
+ ca_path=ca_path if proxy_enabled else None,
885
+ )
886
+ append_log(log_path, f"Remote session exit code: {exit_code}")
887
+ except (FileNotFoundError, RuntimeError) as exc:
888
+ print(f"Remote session failed: {exc}", file=sys.stderr)
889
+ append_log(log_path, f"Remote session failed: {exc}")
890
+ exit_code = 1
891
+ finally:
892
+ if runner is not None:
893
+ try:
894
+ runner.pull_logs(log_dir)
895
+ except Exception as exc:
896
+ append_log(log_path, f"Remote log pull failed: {exc}")
897
+ if mitm_proc is not None:
898
+ try:
899
+ stop_process(mitm_proc)
900
+ except Exception:
901
+ pass
902
+ _cleanup_session_files(session_id)
903
+ append_log(log_path, "Wrapper stop (remote session, no-package)")
904
+ return exit_code
905
+
906
+ # Run the tool via PTY multiplexer (TTY) or plain subprocess (non-TTY)
907
+ def _truthy_env(name: str) -> bool:
908
+ return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
909
+
910
+ def _tmux_has_session(session_name: str) -> bool:
911
+ try:
912
+ code = subprocess.call(
913
+ ["tmux", "-L", "ai-mux", "has-session", "-t", session_name],
914
+ stdout=subprocess.DEVNULL,
915
+ stderr=subprocess.DEVNULL,
916
+ )
917
+ except OSError:
918
+ return False
919
+ return code == 0
920
+
921
+ exit_code = 1
922
+ keep_proxy_running = False
923
+ remote_sync_deferred = False
924
+ force_mux_for_claude = _truthy_env("AI_CLI_CLAUDE_USE_MUX")
925
+ use_mux = (
926
+ sys.stdin.isatty()
927
+ and sys.stdout.isatty()
928
+ and (tool_name != "claude" or force_mux_for_claude)
929
+ )
930
+ try:
931
+ if use_mux:
932
+ python = sys.executable or shutil.which("python3") or "python3"
933
+ base_env = dict(os.environ)
934
+ ai_mux_bin = _find_ai_mux()
935
+
936
+ if not ai_mux_bin:
937
+ append_log(log_path, "ai-mux binary not found")
938
+ return 1
939
+
940
+ mux_config = {
941
+ "session_name": f"ai-{session_id}",
942
+ "tabs": [
943
+ {
944
+ "label": tool_name,
945
+ "cmd": tool_cmd,
946
+ "env": env,
947
+ "cwd": str(effective_cwd),
948
+ "primary": True,
949
+ },
950
+ {
951
+ "label": "sessions",
952
+ "cmd": [python, "-m", "ai_cli", "session", "--list"],
953
+ "env": base_env,
954
+ "cwd": str(effective_cwd),
955
+ "primary": False,
956
+ },
957
+ {
958
+ "label": "status",
959
+ "cmd": [python, "-m", "ai_cli", "status"],
960
+ "env": base_env,
961
+ "cwd": str(effective_cwd),
962
+ "primary": False,
963
+ },
964
+ ]
965
+ }
966
+
967
+ config_path = ""
968
+ try:
969
+ with tempfile.NamedTemporaryFile(
970
+ mode="w",
971
+ encoding="utf-8",
972
+ prefix="ai-mux-",
973
+ suffix=".json",
974
+ delete=False,
975
+ ) as f:
976
+ json.dump(mux_config, f)
977
+ config_path = f.name
978
+
979
+ append_log(log_path, f"Starting ai-mux (tmux): {ai_mux_bin}")
980
+ sys.stdout.flush()
981
+ sys.stderr.flush()
982
+ mux_proc = subprocess.Popen(
983
+ [ai_mux_bin, "--config", config_path, "--session-name", f"ai-{session_id}"]
984
+ )
985
+ exit_code = mux_proc.wait()
986
+ append_log(log_path, f"ai-mux exit code: {exit_code}")
987
+
988
+ owned_session = f"ai-{session_id}"
989
+ if _tmux_has_session(owned_session):
990
+ remote_sync_deferred = True
991
+ if proxy_enabled and mitm_proc is not None:
992
+ append_log(
993
+ log_path,
994
+ "tmux session still alive (detached); leaving proxy running "
995
+ f"for session: {owned_session}",
996
+ )
997
+ _spawn_detached_proxy_watcher(
998
+ mitm_pid=mitm_proc.pid,
999
+ session_id=session_id,
1000
+ tmux_sessions=[owned_session],
1001
+ log_path=log_path,
1002
+ )
1003
+ keep_proxy_running = True
1004
+ else:
1005
+ append_log(
1006
+ log_path,
1007
+ "tmux session still alive (detached) with proxy disabled.",
1008
+ )
1009
+ return exit_code
1010
+ append_log(
1011
+ log_path,
1012
+ f"owned tmux session not found ({owned_session}); stopping proxy",
1013
+ )
1014
+ except OSError as exc:
1015
+ append_log(log_path, f"ai-mux launch failed: {exc}")
1016
+ finally:
1017
+ if config_path:
1018
+ try:
1019
+ Path(config_path).unlink(missing_ok=True)
1020
+ except OSError:
1021
+ pass
1022
+ else:
1023
+ if sys.stdin.isatty() and sys.stdout.isatty() and tool_name == "claude":
1024
+ append_log(
1025
+ log_path,
1026
+ "Launching Claude in direct TTY mode "
1027
+ "(set AI_CLI_CLAUDE_USE_MUX=1 to force ai-mux).",
1028
+ )
1029
+ # Non-TTY fallback: plain subprocess
1030
+ child_proc = subprocess.Popen(
1031
+ tool_cmd, env=env, cwd=str(effective_cwd)
1032
+ )
1033
+ exit_code = child_proc.wait()
1034
+ append_log(log_path, f"Tool exit code: {exit_code}")
1035
+
1036
+ return exit_code
1037
+ finally:
1038
+ # ── Remote sync-up ───────────────────────────────────────────────
1039
+ if remote_spec is not None and not remote_sync_deferred and local_mirror is not None:
1040
+ from ai_cli.remote import print_sync_status, sync_up
1041
+
1042
+ print_sync_status(f"Syncing edits back to {remote_spec.display} …")
1043
+ try:
1044
+ sync_up(remote_spec, local_mirror)
1045
+ print_sync_status("Upload complete ✓")
1046
+ append_log(log_path, f"Remote sync-up complete: {remote_spec.display}")
1047
+ except (RuntimeError, FileNotFoundError) as exc:
1048
+ print_sync_status(f"Upload FAILED: {exc}")
1049
+ append_log(log_path, f"Remote sync-up failed: {exc}")
1050
+ elif remote_spec is not None and remote_sync_deferred:
1051
+ append_log(
1052
+ log_path,
1053
+ f"Remote sync-up deferred; tmux session still active for {remote_spec.display}",
1054
+ )
1055
+
1056
+ # Ensure proxy is stopped when the session has actually ended.
1057
+ if keep_proxy_running:
1058
+ append_log(
1059
+ log_path,
1060
+ "Wrapper stop (detached tmux session still running; proxy and session files left alive)",
1061
+ )
1062
+ else:
1063
+ if mitm_proc is not None:
1064
+ try:
1065
+ stop_process(mitm_proc)
1066
+ except Exception:
1067
+ pass
1068
+ _cleanup_session_files(session_id)
1069
+ append_log(log_path, "Wrapper stop")
1070
+
1071
+
1072
+ # ---------------------------------------------------------------------------
1073
+ # Subcommands
1074
+ # ---------------------------------------------------------------------------
1075
+
1076
+ def _cmd_system_prompt(model_query: str) -> int:
1077
+ """Cat a captured system prompt by model name (fuzzy match)."""
1078
+ import sqlite3
1079
+ from difflib import get_close_matches
1080
+
1081
+ db_path = Path.home() / ".ai-cli" / "system_prompts.db"
1082
+ if not db_path.is_file():
1083
+ print("No system prompts captured yet.", file=sys.stderr)
1084
+ print(f"(Expected database at {db_path})", file=sys.stderr)
1085
+ return 1
1086
+
1087
+ conn = sqlite3.connect(str(db_path))
1088
+ conn.row_factory = sqlite3.Row
1089
+
1090
+ all_rows = conn.execute(
1091
+ "SELECT id, provider, model, role, content, char_count, last_seen "
1092
+ "FROM system_prompts ORDER BY last_seen DESC"
1093
+ ).fetchall()
1094
+ if not all_rows:
1095
+ print("No system prompts captured yet.", file=sys.stderr)
1096
+ conn.close()
1097
+ return 1
1098
+
1099
+ # If no query, list all available
1100
+ if not model_query:
1101
+ print(f"{'Provider':<12} {'Model':<28} {'Role':<14} {'Chars':>7} Last Seen")
1102
+ print("-" * 90)
1103
+ for r in all_rows:
1104
+ last = (r["last_seen"] or "?")[:19]
1105
+ print(f"{r['provider']:<12} {r['model']:<28} {r['role']:<14} {r['char_count']:>7} {last}")
1106
+ print()
1107
+ print("Usage: ai-cli system prompt <model>", file=sys.stderr)
1108
+ conn.close()
1109
+ return 0
1110
+
1111
+ query = model_query.lower().strip()
1112
+
1113
+ # Build lookup: try exact match first, then substring, then fuzzy
1114
+ # Combine "provider/model/role" as matchable keys
1115
+ candidates: dict[str, list[sqlite3.Row]] = {}
1116
+ for r in all_rows:
1117
+ for key in (
1118
+ r["model"],
1119
+ f"{r['provider']}/{r['model']}",
1120
+ f"{r['provider']}/{r['model']}/{r['role']}",
1121
+ ):
1122
+ candidates.setdefault(key.lower(), []).append(r)
1123
+
1124
+ # 1. Exact match
1125
+ matched = candidates.get(query)
1126
+
1127
+ # 2. Substring match
1128
+ if not matched:
1129
+ matched = []
1130
+ for r in all_rows:
1131
+ combined = f"{r['provider']} {r['model']} {r['role']}".lower()
1132
+ if query in combined:
1133
+ matched.append(r)
1134
+
1135
+ # 3. Fuzzy match on model names
1136
+ if not matched:
1137
+ all_models = list({r["model"] for r in all_rows})
1138
+ close = get_close_matches(query, [m.lower() for m in all_models], n=3, cutoff=0.4)
1139
+ if close:
1140
+ best = close[0]
1141
+ matched = [r for r in all_rows if r["model"].lower() == best]
1142
+
1143
+ conn.close()
1144
+
1145
+ if not matched:
1146
+ print(f"No system prompt matching '{model_query}'.", file=sys.stderr)
1147
+ print("Available models:", file=sys.stderr)
1148
+ seen = set()
1149
+ for r in all_rows:
1150
+ key = f" {r['provider']}/{r['model']} ({r['role']})"
1151
+ if key not in seen:
1152
+ seen.add(key)
1153
+ print(key, file=sys.stderr)
1154
+ return 1
1155
+
1156
+ # Print all matching prompts (may be multiple roles for same model)
1157
+ for i, r in enumerate(matched):
1158
+ if i > 0:
1159
+ print()
1160
+ print("═" * 80)
1161
+ print()
1162
+ print(f"# {r['provider']}/{r['model']} [{r['role']}]", file=sys.stderr)
1163
+ print(f"# {r['char_count']} chars, last seen {(r['last_seen'] or '?')[:19]}", file=sys.stderr)
1164
+ print(r["content"])
1165
+
1166
+ return 0
1167
+
1168
+
1169
+ def cmd_status() -> int:
1170
+ """Show installed tools, versions, and alias state."""
1171
+ config = ensure_config()
1172
+ registry = load_registry()
1173
+ aliases = config.get("aliases", {})
1174
+
1175
+ print(f"ai-cli v{__version__}")
1176
+ mux_mode, mux_path = _ai_mux_status()
1177
+ if mux_path:
1178
+ print(f"PTY mux: ai-mux ({mux_mode})")
1179
+ print(f" {mux_path}")
1180
+ else:
1181
+ print("PTY mux: ai-mux NOT FOUND (install tmux)")
1182
+ print()
1183
+ for name, spec in registry.items():
1184
+ tool_cfg = get_tool_config(config, name)
1185
+ installed = spec.detect_installed(tool_cfg["binary"])
1186
+ version = spec.get_version(tool_cfg["binary"]) if installed else None
1187
+ alias_state = "aliased" if aliases.get(name) else "no alias"
1188
+ status = "installed" if installed else "not found"
1189
+ version_str = f" ({version})" if version else ""
1190
+ enabled = "enabled" if tool_cfg["enabled"] else "disabled"
1191
+ print(f" {spec.display_name:<20} {status}{version_str} [{enabled}, {alias_state}]")
1192
+ return 0
1193
+
1194
+
1195
+ def _collect_cleanup_targets() -> list[dict[str, Any]]:
1196
+ targets: list[dict[str, Any]] = []
1197
+ seen_proxy_pids: set[int] = set()
1198
+
1199
+ for session_name in _tmux_list_sessions(socket_name="ai-mux"):
1200
+ session_env = _tmux_session_env(session_name, socket_name="ai-mux")
1201
+ tool = session_env.get("AI_CLI_TOOL", "?")
1202
+ cwd = session_env.get("AI_CLI_WORKDIR", "?")
1203
+ proxy_pid_raw = session_env.get("AI_CLI_PROXY_PID", "").strip()
1204
+ proxy_part = f", proxy_pid={proxy_pid_raw}" if proxy_pid_raw else ""
1205
+ targets.append(
1206
+ {
1207
+ "kind": "tmux",
1208
+ "session_name": session_name,
1209
+ "session_env": session_env,
1210
+ "label": (
1211
+ f"[tmux] session={session_name} "
1212
+ f"(tool={tool}, cwd={cwd}{proxy_part})"
1213
+ ),
1214
+ }
1215
+ )
1216
+ if proxy_pid_raw.isdigit():
1217
+ seen_proxy_pids.add(int(proxy_pid_raw))
1218
+
1219
+ try:
1220
+ probe = subprocess.run(
1221
+ ["ps", "-axo", "pid=,command="],
1222
+ check=False,
1223
+ stdout=subprocess.PIPE,
1224
+ stderr=subprocess.DEVNULL,
1225
+ text=True,
1226
+ )
1227
+ except OSError:
1228
+ probe = None
1229
+
1230
+ if probe is not None:
1231
+ for raw in (probe.stdout or "").splitlines():
1232
+ line = raw.strip()
1233
+ if not line:
1234
+ continue
1235
+ parts = line.split(None, 1)
1236
+ if not parts or not parts[0].isdigit():
1237
+ continue
1238
+ pid = int(parts[0])
1239
+ cmd = parts[1] if len(parts) > 1 else ""
1240
+ lowered = cmd.lower()
1241
+ if "mitmdump" not in lowered and "mitmproxy" not in lowered:
1242
+ continue
1243
+ if pid in seen_proxy_pids:
1244
+ continue
1245
+ targets.append(
1246
+ {
1247
+ "kind": "proxy",
1248
+ "pid": pid,
1249
+ "label": f"[proxy] pid={pid} cmd={cmd}",
1250
+ }
1251
+ )
1252
+ seen_proxy_pids.add(pid)
1253
+
1254
+ return targets
1255
+
1256
+
1257
+ def _parse_cleanup_selection(raw: str, total: int) -> list[int]:
1258
+ text = (raw or "").strip().lower()
1259
+ if not text:
1260
+ return []
1261
+ if text in {"a", "all"}:
1262
+ return list(range(total))
1263
+
1264
+ selected: list[int] = []
1265
+ seen: set[int] = set()
1266
+ for piece in text.split(","):
1267
+ item = piece.strip()
1268
+ if not item:
1269
+ continue
1270
+ if not item.isdigit():
1271
+ return []
1272
+ index = int(item) - 1
1273
+ if index < 0 or index >= total:
1274
+ return []
1275
+ if index in seen:
1276
+ continue
1277
+ seen.add(index)
1278
+ selected.append(index)
1279
+ return selected
1280
+
1281
+
1282
+ def cmd_cleanup(args: list[str]) -> int:
1283
+ parser = argparse.ArgumentParser(add_help=False, prog="ai-cli cleanup")
1284
+ parser.add_argument("--list", action="store_true")
1285
+ parser.add_argument("--all", action="store_true")
1286
+ parser.add_argument("--select", default="")
1287
+ parser.add_argument("-y", "--yes", action="store_true")
1288
+ parser.add_argument("-h", "--help", action="store_true")
1289
+ known, unknown = parser.parse_known_args(args)
1290
+
1291
+ if known.help:
1292
+ print("Usage: ai-cli cleanup [--list] [--all | --select 1,2,3] [-y]")
1293
+ print(" --list Show detected tmux/proxy targets without killing")
1294
+ print(" --all Select all detected targets")
1295
+ print(" --select Comma-separated item numbers from the target list")
1296
+ print(" -y, --yes Skip confirmation prompt")
1297
+ return 0
1298
+
1299
+ if unknown:
1300
+ print(f"Unknown cleanup arguments: {' '.join(unknown)}", file=sys.stderr)
1301
+ return 1
1302
+
1303
+ targets = _collect_cleanup_targets()
1304
+ if not targets:
1305
+ print("No ai-cli tmux/proxy cleanup targets found.")
1306
+ return 0
1307
+
1308
+ print("Cleanup targets:")
1309
+ for idx, target in enumerate(targets, start=1):
1310
+ print(f" {idx}. {target['label']}")
1311
+
1312
+ if known.list:
1313
+ return 0
1314
+
1315
+ selected_indexes: list[int] = []
1316
+ if known.all:
1317
+ selected_indexes = list(range(len(targets)))
1318
+ elif known.select:
1319
+ selected_indexes = _parse_cleanup_selection(known.select, len(targets))
1320
+ if not selected_indexes:
1321
+ print("Invalid --select value. Use comma-separated item numbers.", file=sys.stderr)
1322
+ return 1
1323
+ else:
1324
+ if not sys.stdin.isatty():
1325
+ print(
1326
+ "Non-interactive cleanup requires --all or --select.",
1327
+ file=sys.stderr,
1328
+ )
1329
+ return 1
1330
+ selection = input("Select items to kill (e.g. 1,2 or 'all', blank to cancel): ")
1331
+ selected_indexes = _parse_cleanup_selection(selection, len(targets))
1332
+ if not selected_indexes:
1333
+ print("No targets selected; nothing killed.")
1334
+ return 0
1335
+
1336
+ selected_targets = [targets[i] for i in selected_indexes]
1337
+ if not known.yes:
1338
+ if not sys.stdin.isatty():
1339
+ print("Use --yes in non-interactive mode.", file=sys.stderr)
1340
+ return 1
1341
+ confirm = input(f"Kill {len(selected_targets)} selected target(s)? [y/N]: ").strip().lower()
1342
+ if confirm not in {"y", "yes"}:
1343
+ print("Cancelled; nothing killed.")
1344
+ return 0
1345
+
1346
+ log_dir = Path("~/.ai-cli/logs").expanduser()
1347
+ log_dir.mkdir(parents=True, exist_ok=True)
1348
+ cleanup_log = log_dir / f"cleanup-{os.getpid()}-{time.time_ns()}.log"
1349
+ append_log(cleanup_log, "Manual cleanup start")
1350
+
1351
+ killed = 0
1352
+ for target in selected_targets:
1353
+ kind = str(target.get("kind", ""))
1354
+ if kind == "tmux":
1355
+ session_name = str(target.get("session_name", ""))
1356
+ session_env = target.get("session_env", {})
1357
+ if session_name and isinstance(session_env, dict):
1358
+ _replace_existing_tmux_session(
1359
+ session_name,
1360
+ session_env,
1361
+ cleanup_log,
1362
+ socket_name="ai-mux",
1363
+ )
1364
+ print(f"Killed tmux session: {session_name}")
1365
+ killed += 1
1366
+ continue
1367
+ if kind == "proxy":
1368
+ pid = int(target.get("pid", 0) or 0)
1369
+ if pid > 0:
1370
+ _terminate_pid(pid)
1371
+ append_log(cleanup_log, f"Killed standalone proxy pid: {pid}")
1372
+ print(f"Killed proxy PID: {pid}")
1373
+ killed += 1
1374
+
1375
+ append_log(cleanup_log, f"Manual cleanup complete. killed={killed}")
1376
+ print(f"Cleanup complete. killed={killed}. log={cleanup_log}")
1377
+ return 0
1378
+
1379
+
1380
+ def _cmd_prompt_edit(scope: str, tool_arg: str = "") -> int:
1381
+ config = ensure_config()
1382
+ normalized = (scope or "").strip().lower()
1383
+ if normalized == "global":
1384
+ return edit_instructions(config.get("instructions_file", ""))
1385
+
1386
+ if normalized == "tool":
1387
+ registry = load_registry()
1388
+ tool_name = (
1389
+ (tool_arg or "").strip()
1390
+ or os.environ.get("AI_CLI_TOOL", "").strip()
1391
+ or str(config.get("default_tool", "") or "").strip()
1392
+ )
1393
+ if tool_name not in registry:
1394
+ print(f"Unknown tool for prompt-edit: {tool_name or '(empty)'}", file=sys.stderr)
1395
+ print(f"Available: {', '.join(registry.keys())}", file=sys.stderr)
1396
+ return 1
1397
+ return edit_instructions(_resolve_tool_prompt_file(config, tool_name))
1398
+
1399
+ print("Usage: ai-cli prompt-edit <global|tool> [tool]", file=sys.stderr)
1400
+ return 1
1401
+
1402
+
1403
+ # ---------------------------------------------------------------------------
1404
+ # Main entry point
1405
+ # ---------------------------------------------------------------------------
1406
+
1407
+ def main() -> int:
1408
+ # Binary-name routing: check argv[0]
1409
+ invoked_name = Path(sys.argv[0]).name
1410
+ tool_from_name = TOOL_ALIASES.get(invoked_name)
1411
+ if tool_from_name:
1412
+ return run_tool(tool_from_name, sys.argv[1:])
1413
+
1414
+ # Subcommand routing
1415
+ if len(sys.argv) < 2:
1416
+ # No args — open interactive menu
1417
+ from ai_cli.tui import interactive_menu
1418
+
1419
+ return interactive_menu()
1420
+
1421
+ subcommand = sys.argv[1]
1422
+
1423
+ # Direct tool names
1424
+ registry = load_registry()
1425
+ if subcommand in registry:
1426
+ return run_tool(subcommand, sys.argv[2:])
1427
+
1428
+ # Management subcommands
1429
+ if subcommand == "system":
1430
+ # "ai-cli system prompt [model]" — cat a captured system prompt
1431
+ if len(sys.argv) > 2 and sys.argv[2] == "prompt":
1432
+ return _cmd_system_prompt(sys.argv[3] if len(sys.argv) > 3 else "")
1433
+ tool = sys.argv[2] if len(sys.argv) > 2 else ""
1434
+ config = ensure_config()
1435
+ if tool:
1436
+ registry = load_registry()
1437
+ if tool in registry:
1438
+ return edit_instructions(_resolve_tool_prompt_file(config, tool))
1439
+ print(f"Unknown tool: {tool}", file=sys.stderr)
1440
+ print(f"Available: {', '.join(registry.keys())}", file=sys.stderr)
1441
+ return 1
1442
+ return edit_instructions(config.get("instructions_file", ""))
1443
+
1444
+ if subcommand == "prompt-edit":
1445
+ scope = sys.argv[2] if len(sys.argv) > 2 else ""
1446
+ tool_name = sys.argv[3] if len(sys.argv) > 3 else ""
1447
+ return _cmd_prompt_edit(scope, tool_name)
1448
+
1449
+ if subcommand == "status":
1450
+ return cmd_status()
1451
+
1452
+ if subcommand == "cleanup":
1453
+ return cmd_cleanup(sys.argv[2:])
1454
+
1455
+ if subcommand in ("session", "history"):
1456
+ from ai_cli.session import main as session_main
1457
+
1458
+ return session_main(sys.argv[2:])
1459
+
1460
+ if subcommand == "traffic":
1461
+ from ai_cli.traffic import main as traffic_main
1462
+
1463
+ return traffic_main(sys.argv[2:])
1464
+
1465
+ if subcommand == "update":
1466
+ from ai_cli.update import main as update_main
1467
+
1468
+ return update_main(sys.argv[2:])
1469
+
1470
+ if subcommand == "completions":
1471
+ from ai_cli.completion_gen import main as completion_main
1472
+
1473
+ return completion_main(sys.argv[2:])
1474
+
1475
+ if subcommand == "menu":
1476
+ from ai_cli.tui import interactive_menu
1477
+
1478
+ return interactive_menu()
1479
+
1480
+ if subcommand in ("--version", "-v"):
1481
+ print(f"ai-cli {__version__}")
1482
+ return 0
1483
+
1484
+ if subcommand in ("--help", "-h", "help"):
1485
+ print(f"ai-cli {__version__} — Unified AI CLI wrapper")
1486
+ print()
1487
+ print("Usage:")
1488
+ print(" ai-cli <tool> [DIR] [args...] Launch a tool (claude, codex, copilot, gemini)")
1489
+ print(" ai-cli menu Interactive tool manager (TUI)")
1490
+ print(" ai-cli system [tool] Edit system instructions")
1491
+ print(" ai-cli prompt-edit ... Edit global/tool prompt files")
1492
+ print(" ai-cli system prompt [model] Show captured system prompt for a model")
1493
+ print(" ai-cli status Show installed tools and versions")
1494
+ print(" ai-cli cleanup [opts] Kill stray ai-mux and mitmproxy processes")
1495
+ print(" ai-cli history [opts] Browse agent conversation history")
1496
+ print(" ai-cli traffic [opts] Browse proxied API traffic")
1497
+ print(" ai-cli update [opts] Install or update wrapped tools")
1498
+ print(" ai-cli completions ... Generate completion scripts")
1499
+ print(" ai-cli --version Show version")
1500
+ print()
1501
+ print("When installed as an alias (e.g., 'claude'), routes automatically.")
1502
+ return 0
1503
+
1504
+ # Unknown — try as tool name
1505
+ print(f"Unknown subcommand: {subcommand}", file=sys.stderr)
1506
+ print(f"Run 'ai-cli --help' for usage.", file=sys.stderr)
1507
+ return 1
1508
+
1509
+
1510
+ def main_cli() -> None:
1511
+ """Console script entry point (raises SystemExit)."""
1512
+ raise SystemExit(main())
1513
+
1514
+
1515
+ if __name__ == "__main__":
1516
+ main_cli()