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