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/traffic_db.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Database helpers for traffic viewer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
DEFAULT_DB_PATH = Path.home() / ".ai-cli" / "traffic.db"
|
|
11
|
+
SORT_MODES = ("time", "domain", "request", "provider")
|
|
12
|
+
HAS_CALLER_COL = True
|
|
13
|
+
HAS_PORT_COL = True
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def connect(db_path: Path) -> sqlite3.Connection:
|
|
17
|
+
"""Open the traffic DB and run best-effort schema migration."""
|
|
18
|
+
if not db_path.is_file():
|
|
19
|
+
print(f"No traffic database found at {db_path}", file=sys.stderr)
|
|
20
|
+
print("Traffic is recorded when you use ai-cli to launch a tool.", file=sys.stderr)
|
|
21
|
+
raise SystemExit(0)
|
|
22
|
+
conn = sqlite3.connect(str(db_path))
|
|
23
|
+
conn.row_factory = sqlite3.Row
|
|
24
|
+
ensure_schema(conn)
|
|
25
|
+
return conn
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _column_names(conn: sqlite3.Connection) -> set[str]:
|
|
29
|
+
rows = conn.execute("PRAGMA table_info(traffic)").fetchall()
|
|
30
|
+
return {str(r[1]) for r in rows if len(r) > 1}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ensure_schema(conn: sqlite3.Connection) -> None:
|
|
34
|
+
"""Best-effort migration for older traffic DB schemas."""
|
|
35
|
+
global HAS_CALLER_COL, HAS_PORT_COL
|
|
36
|
+
cols = _column_names(conn)
|
|
37
|
+
if not cols:
|
|
38
|
+
HAS_CALLER_COL = False
|
|
39
|
+
HAS_PORT_COL = False
|
|
40
|
+
return
|
|
41
|
+
if "port" not in cols:
|
|
42
|
+
try:
|
|
43
|
+
conn.execute("ALTER TABLE traffic ADD COLUMN port INTEGER")
|
|
44
|
+
except sqlite3.OperationalError:
|
|
45
|
+
pass
|
|
46
|
+
if "caller" not in cols:
|
|
47
|
+
try:
|
|
48
|
+
conn.execute("ALTER TABLE traffic ADD COLUMN caller TEXT NOT NULL DEFAULT ''")
|
|
49
|
+
except sqlite3.OperationalError:
|
|
50
|
+
pass
|
|
51
|
+
try:
|
|
52
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_traffic_caller ON traffic(caller)")
|
|
53
|
+
except sqlite3.OperationalError:
|
|
54
|
+
pass
|
|
55
|
+
conn.commit()
|
|
56
|
+
cols = _column_names(conn)
|
|
57
|
+
HAS_CALLER_COL = "caller" in cols
|
|
58
|
+
HAS_PORT_COL = "port" in cols
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_query(
|
|
62
|
+
caller: str = "",
|
|
63
|
+
host: str = "",
|
|
64
|
+
search: str = "",
|
|
65
|
+
provider: str = "",
|
|
66
|
+
api_only: bool = False,
|
|
67
|
+
sort: str = "time",
|
|
68
|
+
limit: int = 100,
|
|
69
|
+
) -> tuple[str, list[Any]]:
|
|
70
|
+
"""Build SQL query with filters."""
|
|
71
|
+
conditions: list[str] = []
|
|
72
|
+
params: list[Any] = []
|
|
73
|
+
|
|
74
|
+
if caller:
|
|
75
|
+
if HAS_CALLER_COL:
|
|
76
|
+
conditions.append("caller = ?")
|
|
77
|
+
params.append(caller)
|
|
78
|
+
else:
|
|
79
|
+
conditions.append("1 = 0")
|
|
80
|
+
if host:
|
|
81
|
+
conditions.append("host LIKE ?")
|
|
82
|
+
params.append(f"%{host}%")
|
|
83
|
+
if provider:
|
|
84
|
+
conditions.append("provider = ?")
|
|
85
|
+
params.append(provider)
|
|
86
|
+
if search:
|
|
87
|
+
like = f"%{search}%"
|
|
88
|
+
conditions.append(
|
|
89
|
+
"("
|
|
90
|
+
"req_body LIKE ? OR resp_body LIKE ? OR path LIKE ? OR host LIKE ? "
|
|
91
|
+
"OR provider LIKE ? OR method LIKE ? OR CAST(id AS TEXT) LIKE ?"
|
|
92
|
+
")"
|
|
93
|
+
)
|
|
94
|
+
params.extend([like, like, like, like, like, like, like])
|
|
95
|
+
if api_only:
|
|
96
|
+
conditions.append("is_api = 1")
|
|
97
|
+
|
|
98
|
+
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
99
|
+
|
|
100
|
+
if sort == "domain":
|
|
101
|
+
order = "host ASC, path ASC, ts DESC"
|
|
102
|
+
elif sort == "request":
|
|
103
|
+
order = "id DESC"
|
|
104
|
+
elif sort == "provider":
|
|
105
|
+
order = "provider ASC, host ASC, ts DESC"
|
|
106
|
+
else:
|
|
107
|
+
order = "ts DESC"
|
|
108
|
+
|
|
109
|
+
caller_sel = "caller" if HAS_CALLER_COL else "'' AS caller"
|
|
110
|
+
port_sel = "port" if HAS_PORT_COL else "NULL AS port"
|
|
111
|
+
query = (
|
|
112
|
+
f"SELECT id, ts, {caller_sel}, method, scheme, host, {port_sel}, path, "
|
|
113
|
+
"provider, is_api, status, req_bytes, resp_bytes, "
|
|
114
|
+
"req_body, resp_body "
|
|
115
|
+
f"FROM traffic {where} ORDER BY {order} LIMIT ?"
|
|
116
|
+
)
|
|
117
|
+
params.append(limit)
|
|
118
|
+
return query, params
|
ai_cli/tui.py
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""Curses-based interactive menu for tool management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import curses
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ai_cli import __version__
|
|
13
|
+
from ai_cli.config import ensure_config, get_tool_config
|
|
14
|
+
from ai_cli.instructions import resolve_instructions_file
|
|
15
|
+
from ai_cli.tools import load_registry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
MenuAction = tuple[str, str]
|
|
19
|
+
TmuxSessionRow = tuple[str, str, str, str]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _read_key(stdscr: curses.window) -> int:
|
|
23
|
+
"""Read one key, normalizing common ESC arrow sequences."""
|
|
24
|
+
key = stdscr.getch()
|
|
25
|
+
if key != 27:
|
|
26
|
+
return key
|
|
27
|
+
|
|
28
|
+
# Some terminals deliver arrows as raw ESC sequences even with keypad.
|
|
29
|
+
stdscr.nodelay(True)
|
|
30
|
+
try:
|
|
31
|
+
nxt = stdscr.getch()
|
|
32
|
+
if nxt == -1:
|
|
33
|
+
return 27
|
|
34
|
+
if nxt == 91: # '['
|
|
35
|
+
final = stdscr.getch()
|
|
36
|
+
if final == 65:
|
|
37
|
+
return curses.KEY_UP
|
|
38
|
+
if final == 66:
|
|
39
|
+
return curses.KEY_DOWN
|
|
40
|
+
if final == 67:
|
|
41
|
+
return curses.KEY_RIGHT
|
|
42
|
+
if final == 68:
|
|
43
|
+
return curses.KEY_LEFT
|
|
44
|
+
return 27
|
|
45
|
+
finally:
|
|
46
|
+
stdscr.nodelay(False)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _status_lines() -> list[str]:
|
|
50
|
+
config = ensure_config()
|
|
51
|
+
registry = load_registry()
|
|
52
|
+
lines = [f"ai-cli v{__version__}"]
|
|
53
|
+
for name, spec in registry.items():
|
|
54
|
+
tool_cfg = get_tool_config(config, name)
|
|
55
|
+
installed = spec.detect_installed(tool_cfg.get("binary", ""))
|
|
56
|
+
enabled = "enabled" if tool_cfg.get("enabled", True) else "disabled"
|
|
57
|
+
state = "installed" if installed else "missing"
|
|
58
|
+
lines.append(f"{name:<8} {state:<9} {enabled}")
|
|
59
|
+
return lines
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _editor_command() -> list[str]:
|
|
63
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
|
|
64
|
+
if not editor:
|
|
65
|
+
for fallback in ("nano", "vi", "vim"):
|
|
66
|
+
if shutil.which(fallback):
|
|
67
|
+
editor = fallback
|
|
68
|
+
break
|
|
69
|
+
if not editor:
|
|
70
|
+
return []
|
|
71
|
+
return editor.split()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _edit_instructions_blocking(tool: str = "") -> int:
|
|
75
|
+
config = ensure_config()
|
|
76
|
+
path_value = ""
|
|
77
|
+
if tool:
|
|
78
|
+
path_value = get_tool_config(config, tool).get("instructions_file", "")
|
|
79
|
+
path = resolve_instructions_file(path_value)
|
|
80
|
+
editor = _editor_command()
|
|
81
|
+
if not editor:
|
|
82
|
+
print(f"No editor found. Edit this file manually: {path}", file=sys.stderr)
|
|
83
|
+
return 1
|
|
84
|
+
return subprocess.call([*editor, path])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _list_recent_sessions() -> int:
|
|
88
|
+
from ai_cli import session as session_mod
|
|
89
|
+
|
|
90
|
+
return session_mod.main(["--list"])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _fetch_tmux_sessions() -> list[TmuxSessionRow]:
|
|
94
|
+
"""Fetch active ai-cli tmux sessions."""
|
|
95
|
+
try:
|
|
96
|
+
result = subprocess.run(
|
|
97
|
+
["tmux", "-L", "ai-mux", "list-sessions", "-F",
|
|
98
|
+
"#{session_name}\t#{session_windows}\t#{?session_attached,attached,detached}\t#{session_created_string}"],
|
|
99
|
+
capture_output=True,
|
|
100
|
+
text=True,
|
|
101
|
+
)
|
|
102
|
+
except OSError:
|
|
103
|
+
return []
|
|
104
|
+
if result.returncode != 0:
|
|
105
|
+
return []
|
|
106
|
+
output = result.stdout.strip()
|
|
107
|
+
if not output:
|
|
108
|
+
return []
|
|
109
|
+
rows: list[TmuxSessionRow] = []
|
|
110
|
+
for raw in output.splitlines():
|
|
111
|
+
parts = raw.split("\t")
|
|
112
|
+
if len(parts) != 4:
|
|
113
|
+
continue
|
|
114
|
+
rows.append((parts[0], parts[1], parts[2], parts[3]))
|
|
115
|
+
return rows
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _draw_tmux_picker(stdscr: curses.window, rows: list[TmuxSessionRow], selected: int) -> None:
|
|
119
|
+
stdscr.erase()
|
|
120
|
+
height, width = stdscr.getmaxyx()
|
|
121
|
+
stdscr.addnstr(0, 2, "Active ai-cli tmux sessions", max(1, width - 4), curses.A_BOLD)
|
|
122
|
+
stdscr.addnstr(1, 2, "Up/Down move, Enter attach, x kill selected, q cancel", max(1, width - 4))
|
|
123
|
+
stdscr.addnstr(3, 2, f"{'Session':<28} {'Windows':<7} {'State':<9} Created", max(1, width - 4), curses.A_DIM)
|
|
124
|
+
stdscr.addnstr(4, 2, "-" * max(1, min(80, width - 4)), max(1, width - 4), curses.A_DIM)
|
|
125
|
+
|
|
126
|
+
start = 5
|
|
127
|
+
for idx, (name, windows, state, created) in enumerate(rows):
|
|
128
|
+
row = start + idx
|
|
129
|
+
if row >= height - 1:
|
|
130
|
+
break
|
|
131
|
+
label = f"{name:<28} {windows:<7} {state:<9} {created}"
|
|
132
|
+
attr = curses.A_REVERSE if idx == selected else curses.A_NORMAL
|
|
133
|
+
stdscr.addnstr(row, 2, label, max(1, width - 4), attr)
|
|
134
|
+
stdscr.refresh()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _kill_tmux_session(name: str) -> int:
|
|
138
|
+
try:
|
|
139
|
+
return subprocess.call(
|
|
140
|
+
["tmux", "-L", "ai-mux", "kill-session", "-t", name],
|
|
141
|
+
stdout=subprocess.DEVNULL,
|
|
142
|
+
stderr=subprocess.DEVNULL,
|
|
143
|
+
)
|
|
144
|
+
except OSError:
|
|
145
|
+
return 1
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _pick_tmux_session_curses(rows: list[TmuxSessionRow]) -> str | None:
|
|
149
|
+
def _inner(stdscr: curses.window) -> str | None:
|
|
150
|
+
curses.curs_set(0)
|
|
151
|
+
stdscr.keypad(True)
|
|
152
|
+
selected = 0
|
|
153
|
+
while True:
|
|
154
|
+
if not rows:
|
|
155
|
+
return None
|
|
156
|
+
_draw_tmux_picker(stdscr, rows, selected)
|
|
157
|
+
key = _read_key(stdscr)
|
|
158
|
+
if key in (ord("q"), 27):
|
|
159
|
+
return None
|
|
160
|
+
if key in (curses.KEY_UP, ord("k")):
|
|
161
|
+
selected = (selected - 1) % len(rows)
|
|
162
|
+
continue
|
|
163
|
+
if key in (curses.KEY_DOWN, ord("j")):
|
|
164
|
+
selected = (selected + 1) % len(rows)
|
|
165
|
+
continue
|
|
166
|
+
if key in (ord("x"), ord("X"), curses.KEY_DC):
|
|
167
|
+
doomed = rows[selected][0]
|
|
168
|
+
_kill_tmux_session(doomed)
|
|
169
|
+
rows.pop(selected)
|
|
170
|
+
if not rows:
|
|
171
|
+
return None
|
|
172
|
+
selected = min(selected, len(rows) - 1)
|
|
173
|
+
continue
|
|
174
|
+
if key in (10, 13, curses.KEY_ENTER):
|
|
175
|
+
return rows[selected][0]
|
|
176
|
+
return curses.wrapper(_inner)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _pick_tmux_session_text(rows: list[TmuxSessionRow]) -> str | None:
|
|
180
|
+
while rows:
|
|
181
|
+
print("Active ai-cli sessions:")
|
|
182
|
+
print()
|
|
183
|
+
for idx, (name, windows, state, created) in enumerate(rows, 1):
|
|
184
|
+
print(f"{idx}. {name:<24} {windows} windows {state:<8} created {created}")
|
|
185
|
+
print()
|
|
186
|
+
try:
|
|
187
|
+
choice = input("Enter number to attach, k<number> to kill, blank to cancel: ").strip()
|
|
188
|
+
except EOFError:
|
|
189
|
+
return None
|
|
190
|
+
if not choice:
|
|
191
|
+
return None
|
|
192
|
+
if choice.startswith(("k", "K")):
|
|
193
|
+
suffix = choice[1:].strip()
|
|
194
|
+
if suffix.isdigit():
|
|
195
|
+
index = int(suffix) - 1
|
|
196
|
+
if 0 <= index < len(rows):
|
|
197
|
+
doomed = rows[index][0]
|
|
198
|
+
_kill_tmux_session(doomed)
|
|
199
|
+
rows.pop(index)
|
|
200
|
+
continue
|
|
201
|
+
if choice.isdigit():
|
|
202
|
+
index = int(choice) - 1
|
|
203
|
+
if 0 <= index < len(rows):
|
|
204
|
+
return rows[index][0]
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _list_tmux_sessions() -> int:
|
|
209
|
+
"""Interactive tmux session picker; Enter attaches to selected session."""
|
|
210
|
+
rows = _fetch_tmux_sessions()
|
|
211
|
+
if not rows:
|
|
212
|
+
print("No active ai-cli sessions.", file=sys.stderr)
|
|
213
|
+
return 0
|
|
214
|
+
|
|
215
|
+
selected: str | None
|
|
216
|
+
if sys.stdin.isatty() and sys.stdout.isatty():
|
|
217
|
+
try:
|
|
218
|
+
selected = _pick_tmux_session_curses(rows)
|
|
219
|
+
except curses.error:
|
|
220
|
+
selected = _pick_tmux_session_text(rows)
|
|
221
|
+
else:
|
|
222
|
+
selected = _pick_tmux_session_text(rows)
|
|
223
|
+
|
|
224
|
+
if not selected:
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
return subprocess.call(["tmux", "-L", "ai-mux", "attach-session", "-t", selected])
|
|
229
|
+
except OSError:
|
|
230
|
+
print("tmux not available.", file=sys.stderr)
|
|
231
|
+
return 1
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _run_update(tool: str) -> int:
|
|
235
|
+
from ai_cli import update as update_mod
|
|
236
|
+
|
|
237
|
+
return update_mod.update_tool(tool)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _run_tool(tool: str, tool_args: list[str] | None = None) -> int:
|
|
241
|
+
from ai_cli.main import run_tool
|
|
242
|
+
|
|
243
|
+
return run_tool(tool, tool_args or [])
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _actions() -> list[MenuAction]:
|
|
247
|
+
items: list[MenuAction] = []
|
|
248
|
+
for tool in load_registry().keys():
|
|
249
|
+
items.append((f"Launch {tool}", f"launch:{tool}"))
|
|
250
|
+
items.extend(
|
|
251
|
+
[
|
|
252
|
+
("Status", "status"),
|
|
253
|
+
("Edit global instructions", "edit:global"),
|
|
254
|
+
("Edit claude instructions", "edit:claude"),
|
|
255
|
+
("Edit codex instructions", "edit:codex"),
|
|
256
|
+
("Edit copilot instructions", "edit:copilot"),
|
|
257
|
+
("Edit gemini instructions", "edit:gemini"),
|
|
258
|
+
("Update claude", "update:claude"),
|
|
259
|
+
("Update codex", "update:codex"),
|
|
260
|
+
("Update copilot", "update:copilot"),
|
|
261
|
+
("Update gemini", "update:gemini"),
|
|
262
|
+
("Active sessions", "sessions"),
|
|
263
|
+
("Session history", "history"),
|
|
264
|
+
("Browse traffic (all)", "traffic"),
|
|
265
|
+
("Browse traffic (API only)", "traffic:api"),
|
|
266
|
+
("Browse traffic (Anthropic)", "traffic:anthropic"),
|
|
267
|
+
("Browse traffic (OpenAI)", "traffic:openai"),
|
|
268
|
+
("Browse traffic (Copilot)", "traffic:copilot"),
|
|
269
|
+
("Browse traffic (Google)", "traffic:google"),
|
|
270
|
+
("Browse system prompts", "prompts"),
|
|
271
|
+
("Quit", "quit"),
|
|
272
|
+
]
|
|
273
|
+
)
|
|
274
|
+
return items
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _draw_menu(
|
|
278
|
+
stdscr: curses.window,
|
|
279
|
+
actions: list[MenuAction],
|
|
280
|
+
selected: int,
|
|
281
|
+
top_index: int,
|
|
282
|
+
) -> None:
|
|
283
|
+
stdscr.erase()
|
|
284
|
+
height, width = stdscr.getmaxyx()
|
|
285
|
+
|
|
286
|
+
title = f"ai-cli Menu v{__version__}"
|
|
287
|
+
stdscr.addnstr(0, 2, title, max(1, width - 4), curses.A_BOLD)
|
|
288
|
+
stdscr.addnstr(1, 2, "Up/Down or j/k to move, Enter to select, q to quit", max(1, width - 4))
|
|
289
|
+
|
|
290
|
+
lines = _status_lines()
|
|
291
|
+
for idx, line in enumerate(lines):
|
|
292
|
+
row = 3 + idx
|
|
293
|
+
if row >= height - 1:
|
|
294
|
+
break
|
|
295
|
+
stdscr.addnstr(row, 2, line, max(1, width - 4), curses.A_DIM)
|
|
296
|
+
|
|
297
|
+
start_row = 5 + len(lines)
|
|
298
|
+
visible = max(1, height - start_row - 1)
|
|
299
|
+
visible_actions = actions[top_index: top_index + visible]
|
|
300
|
+
for rel_idx, (label, _) in enumerate(visible_actions):
|
|
301
|
+
idx = top_index + rel_idx
|
|
302
|
+
row = start_row + rel_idx
|
|
303
|
+
if row >= height - 1:
|
|
304
|
+
break
|
|
305
|
+
attr = curses.A_REVERSE if idx == selected else curses.A_NORMAL
|
|
306
|
+
stdscr.addnstr(row, 4, label, max(1, width - 8), attr)
|
|
307
|
+
|
|
308
|
+
stdscr.refresh()
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _select_action_curses(actions: list[MenuAction]) -> str:
|
|
312
|
+
def _inner(stdscr: curses.window) -> str:
|
|
313
|
+
curses.curs_set(0)
|
|
314
|
+
stdscr.keypad(True)
|
|
315
|
+
selected = 0
|
|
316
|
+
top_index = 0
|
|
317
|
+
while True:
|
|
318
|
+
height, _ = stdscr.getmaxyx()
|
|
319
|
+
visible = max(1, height - (5 + len(_status_lines())) - 1)
|
|
320
|
+
if selected < top_index:
|
|
321
|
+
top_index = selected
|
|
322
|
+
elif selected >= top_index + visible:
|
|
323
|
+
top_index = selected - visible + 1
|
|
324
|
+
|
|
325
|
+
_draw_menu(stdscr, actions, selected, top_index)
|
|
326
|
+
key = _read_key(stdscr)
|
|
327
|
+
if key in (ord("q"), 27):
|
|
328
|
+
return "quit"
|
|
329
|
+
if key in (curses.KEY_UP, ord("k")):
|
|
330
|
+
selected = (selected - 1) % len(actions)
|
|
331
|
+
continue
|
|
332
|
+
if key in (curses.KEY_DOWN, ord("j")):
|
|
333
|
+
selected = (selected + 1) % len(actions)
|
|
334
|
+
continue
|
|
335
|
+
if key in (10, 13, curses.KEY_ENTER):
|
|
336
|
+
return actions[selected][1]
|
|
337
|
+
|
|
338
|
+
return curses.wrapper(_inner)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _select_action_text(actions: list[MenuAction]) -> str:
|
|
342
|
+
while True:
|
|
343
|
+
print("ai-cli menu")
|
|
344
|
+
print()
|
|
345
|
+
for line in _status_lines():
|
|
346
|
+
print(f" {line}")
|
|
347
|
+
print()
|
|
348
|
+
for idx, (label, _) in enumerate(actions, 1):
|
|
349
|
+
print(f"{idx}. {label}")
|
|
350
|
+
print()
|
|
351
|
+
try:
|
|
352
|
+
choice = input("Select an action (q to quit): ").strip().lower()
|
|
353
|
+
except EOFError:
|
|
354
|
+
return "quit"
|
|
355
|
+
if not choice:
|
|
356
|
+
continue
|
|
357
|
+
if choice == "q":
|
|
358
|
+
return "quit"
|
|
359
|
+
if not choice.isdigit():
|
|
360
|
+
print("Invalid selection.")
|
|
361
|
+
print()
|
|
362
|
+
continue
|
|
363
|
+
index = int(choice) - 1
|
|
364
|
+
if index < 0 or index >= len(actions):
|
|
365
|
+
print("Selection out of range.")
|
|
366
|
+
print()
|
|
367
|
+
continue
|
|
368
|
+
return actions[index][1]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _browse_system_prompts() -> int:
|
|
372
|
+
"""List captured system prompts and let the user pick one to view."""
|
|
373
|
+
from ai_cli.addons.system_prompt_addon import _DEFAULT_DB_DIR, _DEFAULT_DB_NAME
|
|
374
|
+
import sqlite3
|
|
375
|
+
|
|
376
|
+
db_path = _DEFAULT_DB_DIR / _DEFAULT_DB_NAME
|
|
377
|
+
if not db_path.is_file():
|
|
378
|
+
print("No system prompts captured yet.", file=sys.stderr)
|
|
379
|
+
print(f"(Expected database at {db_path})", file=sys.stderr)
|
|
380
|
+
return 0
|
|
381
|
+
|
|
382
|
+
conn = sqlite3.connect(str(db_path))
|
|
383
|
+
conn.row_factory = sqlite3.Row
|
|
384
|
+
rows = conn.execute(
|
|
385
|
+
"SELECT id, provider, model, role, char_count, seen_count, last_seen "
|
|
386
|
+
"FROM system_prompts ORDER BY last_seen DESC"
|
|
387
|
+
).fetchall()
|
|
388
|
+
if not rows:
|
|
389
|
+
print("No system prompts captured yet.", file=sys.stderr)
|
|
390
|
+
conn.close()
|
|
391
|
+
return 0
|
|
392
|
+
|
|
393
|
+
print(f"{'#':<4} {'Provider':<12} {'Model':<28} {'Role':<14} {'Chars':>7} {'Seen':>5} Last Seen")
|
|
394
|
+
print("-" * 110)
|
|
395
|
+
for idx, r in enumerate(rows, 1):
|
|
396
|
+
last = (r["last_seen"] or "?")[:19]
|
|
397
|
+
role = r["role"] or "system"
|
|
398
|
+
print(f"{idx:<4} {r['provider']:<12} {r['model']:<28} {role:<14} {r['char_count']:>7} {r['seen_count']:>5} {last}")
|
|
399
|
+
|
|
400
|
+
print()
|
|
401
|
+
try:
|
|
402
|
+
choice = input("Enter number to view full prompt (blank to cancel): ").strip()
|
|
403
|
+
except EOFError:
|
|
404
|
+
conn.close()
|
|
405
|
+
return 0
|
|
406
|
+
|
|
407
|
+
if not choice.isdigit():
|
|
408
|
+
conn.close()
|
|
409
|
+
return 0
|
|
410
|
+
|
|
411
|
+
index = int(choice) - 1
|
|
412
|
+
if index < 0 or index >= len(rows):
|
|
413
|
+
print("Invalid selection.", file=sys.stderr)
|
|
414
|
+
conn.close()
|
|
415
|
+
return 1
|
|
416
|
+
|
|
417
|
+
row_id = rows[index]["id"]
|
|
418
|
+
full = conn.execute(
|
|
419
|
+
"SELECT provider, model, role, content, char_count, first_seen, last_seen, seen_count "
|
|
420
|
+
"FROM system_prompts WHERE id = ?",
|
|
421
|
+
(row_id,),
|
|
422
|
+
).fetchone()
|
|
423
|
+
conn.close()
|
|
424
|
+
|
|
425
|
+
if not full:
|
|
426
|
+
print("Prompt not found.", file=sys.stderr)
|
|
427
|
+
return 1
|
|
428
|
+
|
|
429
|
+
print()
|
|
430
|
+
print(f"Provider: {full['provider']}")
|
|
431
|
+
print(f"Model: {full['model']}")
|
|
432
|
+
print(f"Role: {full['role'] or 'system'}")
|
|
433
|
+
print(f"Chars: {full['char_count']}")
|
|
434
|
+
print(f"First: {full['first_seen']}")
|
|
435
|
+
print(f"Last: {full['last_seen']}")
|
|
436
|
+
print(f"Seen: {full['seen_count']} time(s)")
|
|
437
|
+
print("─" * 80)
|
|
438
|
+
print(full["content"])
|
|
439
|
+
return 0
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _browse_traffic(provider: str = "", api_only: bool = False) -> int:
|
|
443
|
+
"""Launch the traffic viewer in an isolated subprocess.
|
|
444
|
+
|
|
445
|
+
Keep traffic in a clean terminal context while forcing this repo's module
|
|
446
|
+
resolution to avoid stale globally-installed ai-cli binaries.
|
|
447
|
+
"""
|
|
448
|
+
python = sys.executable or "python3"
|
|
449
|
+
repo_root = Path(__file__).resolve().parent.parent
|
|
450
|
+
|
|
451
|
+
env = os.environ.copy()
|
|
452
|
+
current_pp = env.get("PYTHONPATH", "")
|
|
453
|
+
root_str = str(repo_root)
|
|
454
|
+
env["PYTHONPATH"] = f"{root_str}{os.pathsep}{current_pp}" if current_pp else root_str
|
|
455
|
+
|
|
456
|
+
cmd = [python, "-m", "ai_cli", "traffic"]
|
|
457
|
+
if provider:
|
|
458
|
+
cmd.extend(["--provider", provider])
|
|
459
|
+
if api_only:
|
|
460
|
+
cmd.append("--api")
|
|
461
|
+
return subprocess.call(cmd, env=env)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _run_action(action: str) -> int:
|
|
465
|
+
if action == "quit":
|
|
466
|
+
return 0
|
|
467
|
+
if action == "status":
|
|
468
|
+
print("\n".join(_status_lines()))
|
|
469
|
+
return 0
|
|
470
|
+
if action == "sessions":
|
|
471
|
+
return _list_tmux_sessions()
|
|
472
|
+
if action == "history":
|
|
473
|
+
return _list_recent_sessions()
|
|
474
|
+
if action == "prompts":
|
|
475
|
+
return _browse_system_prompts()
|
|
476
|
+
if action == "traffic":
|
|
477
|
+
return _browse_traffic()
|
|
478
|
+
if action.startswith("traffic:"):
|
|
479
|
+
suffix = action.split(":", 1)[1]
|
|
480
|
+
if suffix == "api":
|
|
481
|
+
return _browse_traffic(api_only=True)
|
|
482
|
+
return _browse_traffic(provider=suffix)
|
|
483
|
+
if action.startswith("launch:"):
|
|
484
|
+
return _run_tool(action.split(":", 1)[1])
|
|
485
|
+
if action.startswith("edit:"):
|
|
486
|
+
suffix = action.split(":", 1)[1]
|
|
487
|
+
tool = "" if suffix == "global" else suffix
|
|
488
|
+
return _edit_instructions_blocking(tool)
|
|
489
|
+
if action.startswith("update:"):
|
|
490
|
+
return _run_update(action.split(":", 1)[1])
|
|
491
|
+
return 1
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def interactive_menu() -> int:
|
|
495
|
+
"""Open the interactive menu.
|
|
496
|
+
|
|
497
|
+
Returns command exit code. Launch actions exit immediately into the selected
|
|
498
|
+
tool flow; management actions return to the menu until the user quits.
|
|
499
|
+
"""
|
|
500
|
+
actions = _actions()
|
|
501
|
+
|
|
502
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
503
|
+
print("\n".join(_status_lines()))
|
|
504
|
+
return 0
|
|
505
|
+
|
|
506
|
+
while True:
|
|
507
|
+
try:
|
|
508
|
+
action = _select_action_curses(actions)
|
|
509
|
+
except curses.error:
|
|
510
|
+
action = _select_action_text(actions)
|
|
511
|
+
|
|
512
|
+
if action == "quit":
|
|
513
|
+
return 0
|
|
514
|
+
|
|
515
|
+
rc = _run_action(action)
|
|
516
|
+
|
|
517
|
+
if action.startswith("launch:"):
|
|
518
|
+
return rc
|
|
519
|
+
# Always return directly to the menu after non-launch actions.
|
|
520
|
+
# Avoiding an extra prompt keeps menu navigation fluid.
|
|
521
|
+
_ = rc
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
if __name__ == "__main__":
|
|
525
|
+
raise SystemExit(interactive_menu())
|