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.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()
|