kiwi-code 0.0.4__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.
- kiwi_code-0.0.4.dist-info/METADATA +234 -0
- kiwi_code-0.0.4.dist-info/RECORD +24 -0
- kiwi_code-0.0.4.dist-info/WHEEL +4 -0
- kiwi_code-0.0.4.dist-info/entry_points.txt +4 -0
- kiwi_runtime/__init__.py +3 -0
- kiwi_runtime/__main__.py +5 -0
- kiwi_runtime/main.py +989 -0
- kiwi_tui/__init__.py +3 -0
- kiwi_tui/auth.py +125 -0
- kiwi_tui/cli.py +243 -0
- kiwi_tui/client.py +539 -0
- kiwi_tui/commands.py +434 -0
- kiwi_tui/config.py +79 -0
- kiwi_tui/logger.py +32 -0
- kiwi_tui/main.py +337 -0
- kiwi_tui/models.py +85 -0
- kiwi_tui/runtime_manager.py +130 -0
- kiwi_tui/screens/__init__.py +9 -0
- kiwi_tui/screens/actions.py +271 -0
- kiwi_tui/screens/autobots.py +216 -0
- kiwi_tui/screens/dashboard.py +608 -0
- kiwi_tui/screens/login.py +320 -0
- kiwi_tui/screens/runtime_logs.py +96 -0
- kiwi_tui/widgets.py +197 -0
kiwi_runtime/main.py
ADDED
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Kiwi AI CLI Agent — connects to the server via WebSocket and executes
|
|
4
|
+
terminal commands on behalf of the LLM agent.
|
|
5
|
+
|
|
6
|
+
Authenticates with email and password.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
kiwi connect --server local
|
|
10
|
+
kiwi connect --server dev
|
|
11
|
+
kiwi connect --server wss://custom.server.com
|
|
12
|
+
kiwi connect --server dev --scope full
|
|
13
|
+
kiwi connect --server dev --allow /path/to/extra/dir
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import asyncio
|
|
18
|
+
import getpass
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import shlex
|
|
23
|
+
import signal
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
IS_WINDOWS = sys.platform == "win32"
|
|
27
|
+
|
|
28
|
+
if not IS_WINDOWS:
|
|
29
|
+
import fcntl
|
|
30
|
+
import pty
|
|
31
|
+
import struct
|
|
32
|
+
import termios
|
|
33
|
+
|
|
34
|
+
import httpx
|
|
35
|
+
import websockets
|
|
36
|
+
|
|
37
|
+
MAX_OUTPUT_BYTES = 50 * 1024 # 50KB cap on command output
|
|
38
|
+
|
|
39
|
+
# Sentinel must match the server-side terminal_tool.py so blocked commands
|
|
40
|
+
# are detected as "completed" instead of timing out.
|
|
41
|
+
_SENTINEL = "__CMD_DONE__"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _clean_cmd_for_display(data: str) -> str:
|
|
45
|
+
"""Strip the sentinel wrapper from a PTY input command for display."""
|
|
46
|
+
cmd = data.strip()
|
|
47
|
+
# Remove sentinel suffix: '; echo __CMD_DONE__$?__CMD_DONE__' (Unix)
|
|
48
|
+
# or '& echo __CMD_DONE__%ERRORLEVEL%__CMD_DONE__' (Windows)
|
|
49
|
+
idx = cmd.find(_SENTINEL)
|
|
50
|
+
if idx > 0:
|
|
51
|
+
cmd = cmd[:idx]
|
|
52
|
+
# Remove the separator before the sentinel ('; echo ' or '& echo ')
|
|
53
|
+
for sep in ("; echo ", "& echo "):
|
|
54
|
+
if cmd.endswith(sep):
|
|
55
|
+
cmd = cmd[:-len(sep)]
|
|
56
|
+
break
|
|
57
|
+
# Handle without space
|
|
58
|
+
if cmd.endswith(sep.rstrip()):
|
|
59
|
+
cmd = cmd[:-len(sep.rstrip())]
|
|
60
|
+
break
|
|
61
|
+
return cmd.strip()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
SERVER_PRESETS = {
|
|
65
|
+
"app": {"ws": "wss://api.meetkiwi.ai", "http": "https://api.meetkiwi.ai"},
|
|
66
|
+
"dev": {"ws": "wss://dev.api.myautobots.com", "http": "https://dev.api.myautobots.com"},
|
|
67
|
+
"local": {"ws": "ws://localhost:8000", "http": "http://localhost:8000"},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# ANSI colors
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
RESET = "\033[0m"
|
|
75
|
+
BOLD = "\033[1m"
|
|
76
|
+
|
|
77
|
+
# Brand color — ANSI cyan (works reliably across all terminals)
|
|
78
|
+
C = "\033[36m"
|
|
79
|
+
CB = "\033[1;36m"
|
|
80
|
+
GREY = "\033[90m"
|
|
81
|
+
RED = "\033[91m"
|
|
82
|
+
YELLOW = "\033[93m"
|
|
83
|
+
GREEN = "\033[92m"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Banner — Kiwi logo (from SVG) + KIWI AI text side by side
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
# Logo: 32 wide x 16 tall (half-block, perfectly symmetric from SVG)
|
|
91
|
+
LOGO = [
|
|
92
|
+
" ▄██▄ ",
|
|
93
|
+
" ████ ",
|
|
94
|
+
" ▄██▄▄ ████ ▄▄██▄ ",
|
|
95
|
+
" ▀█████▄ █▀▀█ ▄█████▀ ",
|
|
96
|
+
" ▀███▀▀█ ████ █▀▀███▀ ",
|
|
97
|
+
" ▀█▄ █ ████ █ ▄█▀ ",
|
|
98
|
+
" ▀▀▄██████▄▀▀ ",
|
|
99
|
+
"▄██████▀▀█▄██████████▄█▀▀██████▄",
|
|
100
|
+
"▀██████▄▄█▀██████████▀█▄▄██████▀",
|
|
101
|
+
" ▄▄▀██████▀▄▄ ",
|
|
102
|
+
" ▄█▀ █ ████ █ ▀█▄ ",
|
|
103
|
+
" ▄███▄▄█ ████ █▄▄███▄ ",
|
|
104
|
+
" ▄█████▀ █▄▄█ ▀█████▄ ",
|
|
105
|
+
" ▀██▀▀ ████ ▀▀██▀ ",
|
|
106
|
+
" ████ ",
|
|
107
|
+
" ▀██▀ ",
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
# Text: 6 tall
|
|
111
|
+
TEXT = [
|
|
112
|
+
"██╗ ██╗██╗██╗ ██╗██╗ █████╗ ██╗",
|
|
113
|
+
"██║ ██╔╝██║██║ ██║██║ ██╔══██╗██║",
|
|
114
|
+
"█████╔╝ ██║██║ █╗ ██║██║ ███████║██║",
|
|
115
|
+
"██╔═██╗ ██║██║███╗██║██║ ██╔══██║██║",
|
|
116
|
+
"██║ ██╗██║╚███╔███╔╝██║ ██║ ██║██║",
|
|
117
|
+
"╚═╝ ╚═╝╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝ ╚═╝╚═╝",
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
TAGLINE = "Terminal Agent for AI-Powered Automation"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def print_banner():
|
|
124
|
+
"""Print the Kiwi AI startup banner."""
|
|
125
|
+
print()
|
|
126
|
+
|
|
127
|
+
# Side-by-side: logo (16 lines) with text (6 lines) vertically centered
|
|
128
|
+
gap = " "
|
|
129
|
+
text_start = 5
|
|
130
|
+
logo_w = 33
|
|
131
|
+
|
|
132
|
+
for i, logo_line in enumerate(LOGO):
|
|
133
|
+
padded_logo = logo_line.ljust(logo_w)
|
|
134
|
+
text_idx = i - text_start
|
|
135
|
+
if 0 <= text_idx < len(TEXT):
|
|
136
|
+
row = f"{padded_logo}{gap}{TEXT[text_idx]}"
|
|
137
|
+
elif text_idx == len(TEXT) + 1:
|
|
138
|
+
row = f"{padded_logo}{gap}{TAGLINE}"
|
|
139
|
+
else:
|
|
140
|
+
row = padded_logo
|
|
141
|
+
|
|
142
|
+
print(f" {CB}{row}{RESET}")
|
|
143
|
+
|
|
144
|
+
print(f" {GREY}{'━' * 72}{RESET}")
|
|
145
|
+
print()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def print_status(icon: str, message: str, color: str = C):
|
|
149
|
+
"""Print a styled status line."""
|
|
150
|
+
print(f" {color}{icon}{RESET} {message}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def print_section(title: str):
|
|
154
|
+
"""Print a section header."""
|
|
155
|
+
print(f"\n {CB}{title}{RESET}")
|
|
156
|
+
print(f" {GREY}{'─' * 40}{RESET}")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def print_cmd_log(request_id: str, message: str, success: bool | None = None):
|
|
160
|
+
"""Print a command execution log line."""
|
|
161
|
+
tag = f"{GREY}[{request_id[:8]}]{RESET}"
|
|
162
|
+
if success is True:
|
|
163
|
+
icon = f"{GREEN}>{RESET}"
|
|
164
|
+
elif success is False:
|
|
165
|
+
icon = f"{RED}x{RESET}"
|
|
166
|
+
else:
|
|
167
|
+
icon = f"{C}>{RESET}"
|
|
168
|
+
print(f" {icon} {tag} {message}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def print_pty_log(session_id: str, message: str, level: str = "info"):
|
|
172
|
+
"""Print a PTY session log line."""
|
|
173
|
+
tag = f"{GREY}[PTY {session_id[:8]}]{RESET}"
|
|
174
|
+
if level == "error":
|
|
175
|
+
icon = f"{RED}x{RESET}"
|
|
176
|
+
elif level == "success":
|
|
177
|
+
icon = f"{GREEN}>{RESET}"
|
|
178
|
+
else:
|
|
179
|
+
icon = f"{C}~{RESET}"
|
|
180
|
+
print(f" {icon} {tag} {message}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# Path validator for restricted mode
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
# Regex to detect Windows absolute paths like C:\ or D:/
|
|
188
|
+
_WIN_ABS_PATH = re.compile(r'^[A-Za-z]:[/\\]')
|
|
189
|
+
# Regex to detect UNC paths like \\server\share
|
|
190
|
+
_UNC_PATH = re.compile(r'^\\\\')
|
|
191
|
+
# Windows environment variables like %USERPROFILE%
|
|
192
|
+
_WIN_ENV_VAR = re.compile(r'%([^%]+)%')
|
|
193
|
+
|
|
194
|
+
# Regex patterns to find paths embedded anywhere in a command string
|
|
195
|
+
_EMBEDDED_UNIX_PATH = re.compile(r'(?:^|[\s\'";=])(/[^\s\'";|&>]+)')
|
|
196
|
+
_EMBEDDED_HOME_PATH = re.compile(r'(?:^|[\s\'";=])(~[^\s\'";|&>]*)')
|
|
197
|
+
_EMBEDDED_WIN_PATH = re.compile(r'(?:^|[\s\'";=])([A-Za-z]:[/\\][^\s\'";|&>]*)')
|
|
198
|
+
_EMBEDDED_UNC_PATH = re.compile(r'(?:^|[\s\'";=])(\\\\[^\s\'";|&>]+)')
|
|
199
|
+
_EMBEDDED_DOTDOT = re.compile(r'(?:^|[\s\'";=])(\.\.[/\\][^\s\'";|&>]*)')
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _scan_paths(command: str) -> list[str]:
|
|
203
|
+
"""Scan the raw command string for embedded path patterns."""
|
|
204
|
+
paths = []
|
|
205
|
+
patterns = [_EMBEDDED_HOME_PATH, _EMBEDDED_WIN_PATH, _EMBEDDED_UNC_PATH, _EMBEDDED_DOTDOT]
|
|
206
|
+
# Unix absolute paths don't exist on Windows; /x tokens are command flags
|
|
207
|
+
if not IS_WINDOWS:
|
|
208
|
+
patterns.insert(0, _EMBEDDED_UNIX_PATH)
|
|
209
|
+
for pattern in patterns:
|
|
210
|
+
for m in pattern.finditer(command):
|
|
211
|
+
p = m.group(1).rstrip(";|&'\"")
|
|
212
|
+
if not p:
|
|
213
|
+
continue
|
|
214
|
+
# Skip standard system device paths
|
|
215
|
+
if p in _SAFE_PATHS or any(p.startswith(sp + "/") for sp in _SAFE_PATHS):
|
|
216
|
+
continue
|
|
217
|
+
paths.append(p)
|
|
218
|
+
return paths
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _get_process_cwd(pid: int) -> str | None:
|
|
222
|
+
"""Get the current working directory of a process by PID."""
|
|
223
|
+
try:
|
|
224
|
+
import psutil
|
|
225
|
+
return psutil.Process(pid).cwd()
|
|
226
|
+
except Exception:
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def validate_command(
|
|
231
|
+
command: str, allowed_dirs: list[str], pid: int | None = None
|
|
232
|
+
) -> tuple[bool, str]:
|
|
233
|
+
"""Check if a command references paths outside the allowed directories.
|
|
234
|
+
|
|
235
|
+
Returns (True, "") if allowed, (False, reason) if blocked.
|
|
236
|
+
"""
|
|
237
|
+
# Use the shell's actual cwd for resolving relative paths (e.g. ..)
|
|
238
|
+
base_dir = allowed_dirs[0]
|
|
239
|
+
if pid is not None:
|
|
240
|
+
real_cwd = _get_process_cwd(pid)
|
|
241
|
+
if real_cwd:
|
|
242
|
+
base_dir = real_cwd
|
|
243
|
+
|
|
244
|
+
# Token-based extraction
|
|
245
|
+
try:
|
|
246
|
+
tokens = shlex.split(command)
|
|
247
|
+
except ValueError:
|
|
248
|
+
tokens = command.split()
|
|
249
|
+
|
|
250
|
+
candidates = []
|
|
251
|
+
for token in tokens:
|
|
252
|
+
path = _extract_path(token)
|
|
253
|
+
if path is not None:
|
|
254
|
+
candidates.append((path, False))
|
|
255
|
+
|
|
256
|
+
# Regex scan for embedded paths (catches paths inside quoted strings)
|
|
257
|
+
for path in _scan_paths(command):
|
|
258
|
+
candidates.append((path, True))
|
|
259
|
+
|
|
260
|
+
# Deduplicate
|
|
261
|
+
seen = set()
|
|
262
|
+
for path, from_regex in candidates:
|
|
263
|
+
if path in seen:
|
|
264
|
+
continue
|
|
265
|
+
seen.add(path)
|
|
266
|
+
resolved = _resolve_path(path, base_dir)
|
|
267
|
+
if resolved is None:
|
|
268
|
+
continue
|
|
269
|
+
# Regex-scanned paths may be string literals (e.g. echo "hello /world"),
|
|
270
|
+
# so only flag them if the path actually exists on disk.
|
|
271
|
+
if from_regex and not os.path.exists(resolved):
|
|
272
|
+
continue
|
|
273
|
+
if not _is_within_allowed(resolved, allowed_dirs):
|
|
274
|
+
return False, (
|
|
275
|
+
f"Restricted: '{path}' resolves to '{resolved}' "
|
|
276
|
+
f"which is outside the allowed directories"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
return True, ""
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
_SAFE_PATHS = frozenset({
|
|
283
|
+
"/dev/null", "/dev/stdin", "/dev/stdout", "/dev/stderr",
|
|
284
|
+
"/dev/zero", "/dev/random", "/dev/urandom", "/dev/tty",
|
|
285
|
+
"/dev/fd", "/proc/self",
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _extract_path(token: str) -> str | None:
|
|
290
|
+
"""Extract a file path from a token, or None if it doesn't look like one."""
|
|
291
|
+
# Strip trailing shell metacharacters (;, &&, ||, |, &)
|
|
292
|
+
token = token.rstrip(";|&")
|
|
293
|
+
if not token:
|
|
294
|
+
return None
|
|
295
|
+
# Strip common shell prefixes that precede paths in redirections
|
|
296
|
+
for prefix in (">", ">>", "<", "2>", "2>>", "&>", "&>>"):
|
|
297
|
+
if token.startswith(prefix) and len(token) > len(prefix):
|
|
298
|
+
token = token[len(prefix):]
|
|
299
|
+
break
|
|
300
|
+
|
|
301
|
+
# Whitelist standard system device paths
|
|
302
|
+
if token in _SAFE_PATHS or any(token.startswith(p + "/") for p in _SAFE_PATHS):
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
# Home directory references
|
|
306
|
+
if token.startswith("~"):
|
|
307
|
+
return token
|
|
308
|
+
|
|
309
|
+
# Unix absolute paths (skip on Windows — /x tokens are command flags like /d, /b, /ad)
|
|
310
|
+
if token.startswith("/"):
|
|
311
|
+
if IS_WINDOWS:
|
|
312
|
+
return None
|
|
313
|
+
return token
|
|
314
|
+
|
|
315
|
+
# Windows absolute paths
|
|
316
|
+
if IS_WINDOWS:
|
|
317
|
+
if _WIN_ABS_PATH.match(token):
|
|
318
|
+
return token
|
|
319
|
+
if _UNC_PATH.match(token):
|
|
320
|
+
return token
|
|
321
|
+
# Expand %VAR% and check
|
|
322
|
+
expanded = _WIN_ENV_VAR.sub(_expand_win_var, token)
|
|
323
|
+
if expanded != token and (_WIN_ABS_PATH.match(expanded) or expanded.startswith("/")):
|
|
324
|
+
return expanded
|
|
325
|
+
|
|
326
|
+
# Relative paths with .. traversal
|
|
327
|
+
if ".." in token.split(os.sep) or ".." in token.split("/"):
|
|
328
|
+
return token
|
|
329
|
+
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _expand_win_var(match: re.Match) -> str:
|
|
334
|
+
"""Expand a Windows %VAR% environment variable."""
|
|
335
|
+
return os.environ.get(match.group(1), match.group(0))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _resolve_path(path: str, base_dir: str) -> str | None:
|
|
339
|
+
"""Resolve a path to its real absolute location."""
|
|
340
|
+
try:
|
|
341
|
+
# Expand ~ to home directory
|
|
342
|
+
path = os.path.expanduser(path)
|
|
343
|
+
|
|
344
|
+
# Make relative paths absolute against base_dir
|
|
345
|
+
if not os.path.isabs(path):
|
|
346
|
+
path = os.path.join(base_dir, path)
|
|
347
|
+
|
|
348
|
+
# Normalize (resolve ..) and resolve symlinks
|
|
349
|
+
return os.path.realpath(path)
|
|
350
|
+
except (OSError, ValueError):
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _is_within_allowed(resolved_path: str, allowed_dirs: list[str]) -> bool:
|
|
355
|
+
"""Check if a resolved path is within any of the allowed directories."""
|
|
356
|
+
resolved = os.path.normcase(resolved_path)
|
|
357
|
+
# macOS filesystem is typically case-insensitive
|
|
358
|
+
if sys.platform == "darwin":
|
|
359
|
+
resolved = resolved.lower()
|
|
360
|
+
for allowed in allowed_dirs:
|
|
361
|
+
allowed_norm = os.path.normcase(allowed)
|
|
362
|
+
if sys.platform == "darwin":
|
|
363
|
+
allowed_norm = allowed_norm.lower()
|
|
364
|
+
if resolved == allowed_norm or resolved.startswith(allowed_norm + os.sep):
|
|
365
|
+
return True
|
|
366
|
+
return False
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
# Core logic
|
|
371
|
+
# ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
async def login(http_base_url: str, email: str, password: str) -> str:
|
|
374
|
+
"""Authenticate with email/password and return the JWT access token."""
|
|
375
|
+
url = f"{http_base_url}/v1/auth/token"
|
|
376
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
377
|
+
resp = await client.post(
|
|
378
|
+
url,
|
|
379
|
+
data={"username": email, "password": password},
|
|
380
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
381
|
+
)
|
|
382
|
+
if resp.status_code != 200:
|
|
383
|
+
detail = resp.text
|
|
384
|
+
try:
|
|
385
|
+
detail = resp.json().get("detail", resp.text)
|
|
386
|
+
except Exception:
|
|
387
|
+
pass
|
|
388
|
+
raise Exception(f"Login failed (HTTP {resp.status_code}): {detail}")
|
|
389
|
+
|
|
390
|
+
body = resp.json()
|
|
391
|
+
token = body.get("access_token")
|
|
392
|
+
if not token:
|
|
393
|
+
raise Exception(f"No access_token in response: {body}")
|
|
394
|
+
return token
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
async def run_command(
|
|
398
|
+
command: str,
|
|
399
|
+
timeout: int = 120,
|
|
400
|
+
mode: str = "restricted",
|
|
401
|
+
allowed_dirs: list[str] | None = None,
|
|
402
|
+
) -> dict:
|
|
403
|
+
"""Execute a shell command and return stdout, stderr, exit_code."""
|
|
404
|
+
# Validate in restricted mode
|
|
405
|
+
if mode == "restricted" and allowed_dirs:
|
|
406
|
+
ok, reason = validate_command(command, allowed_dirs)
|
|
407
|
+
if not ok:
|
|
408
|
+
return {"stdout": "", "stderr": reason, "exit_code": 1}
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
cwd = allowed_dirs[0] if allowed_dirs else None
|
|
412
|
+
proc = await asyncio.create_subprocess_shell(
|
|
413
|
+
command,
|
|
414
|
+
stdout=asyncio.subprocess.PIPE,
|
|
415
|
+
stderr=asyncio.subprocess.PIPE,
|
|
416
|
+
cwd=cwd,
|
|
417
|
+
)
|
|
418
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
419
|
+
proc.communicate(), timeout=timeout
|
|
420
|
+
)
|
|
421
|
+
stdout = stdout_bytes.decode("utf-8", errors="replace")[:MAX_OUTPUT_BYTES]
|
|
422
|
+
stderr = stderr_bytes.decode("utf-8", errors="replace")[:MAX_OUTPUT_BYTES]
|
|
423
|
+
return {"stdout": stdout, "stderr": stderr, "exit_code": proc.returncode}
|
|
424
|
+
except asyncio.TimeoutError:
|
|
425
|
+
proc.kill()
|
|
426
|
+
await proc.wait()
|
|
427
|
+
return {"stdout": "", "stderr": f"Command timed out after {timeout}s", "exit_code": -1}
|
|
428
|
+
except Exception as e:
|
|
429
|
+
return {"stdout": "", "stderr": str(e), "exit_code": -1}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
if not IS_WINDOWS:
|
|
433
|
+
|
|
434
|
+
class PTYProcess:
|
|
435
|
+
"""Manages a single interactive PTY session (Unix/macOS only)."""
|
|
436
|
+
|
|
437
|
+
def __init__(self, session_id: str, command: str, cols: int = 120, rows: int = 40):
|
|
438
|
+
self.session_id = session_id
|
|
439
|
+
self.command = command
|
|
440
|
+
self.cols = cols
|
|
441
|
+
self.rows = rows
|
|
442
|
+
self.master_fd = None
|
|
443
|
+
self.slave_fd = None
|
|
444
|
+
self.proc = None
|
|
445
|
+
self._read_task = None
|
|
446
|
+
|
|
447
|
+
async def start(self, ws, cwd: str | None = None):
|
|
448
|
+
"""Spawn the PTY process and begin reading output."""
|
|
449
|
+
self.master_fd, self.slave_fd = pty.openpty()
|
|
450
|
+
|
|
451
|
+
winsize = struct.pack("HHHH", self.rows, self.cols, 0, 0)
|
|
452
|
+
fcntl.ioctl(self.slave_fd, termios.TIOCSWINSZ, winsize)
|
|
453
|
+
|
|
454
|
+
self.proc = await asyncio.create_subprocess_exec(
|
|
455
|
+
"/bin/sh", "-c", self.command,
|
|
456
|
+
stdin=self.slave_fd,
|
|
457
|
+
stdout=self.slave_fd,
|
|
458
|
+
stderr=self.slave_fd,
|
|
459
|
+
preexec_fn=os.setsid,
|
|
460
|
+
cwd=cwd,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
os.close(self.slave_fd)
|
|
464
|
+
self.slave_fd = None
|
|
465
|
+
|
|
466
|
+
flags = fcntl.fcntl(self.master_fd, fcntl.F_GETFL)
|
|
467
|
+
fcntl.fcntl(self.master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
468
|
+
|
|
469
|
+
self._read_task = asyncio.create_task(self._read_loop(ws))
|
|
470
|
+
|
|
471
|
+
async def _read_loop(self, ws):
|
|
472
|
+
"""Read output from master fd and send over WebSocket."""
|
|
473
|
+
loop = asyncio.get_event_loop()
|
|
474
|
+
try:
|
|
475
|
+
while True:
|
|
476
|
+
try:
|
|
477
|
+
data = await loop.run_in_executor(None, self._blocking_read)
|
|
478
|
+
if data is None:
|
|
479
|
+
break
|
|
480
|
+
# Echo output to stdout for observability
|
|
481
|
+
for line in data.splitlines():
|
|
482
|
+
print_cmd_log(self.session_id, line)
|
|
483
|
+
await ws.send(json.dumps({
|
|
484
|
+
"type": "pty_output",
|
|
485
|
+
"session_id": self.session_id,
|
|
486
|
+
"data": data,
|
|
487
|
+
}))
|
|
488
|
+
except OSError:
|
|
489
|
+
break
|
|
490
|
+
except Exception as e:
|
|
491
|
+
print_pty_log(self.session_id, f"Read error: {e}", "error")
|
|
492
|
+
break
|
|
493
|
+
|
|
494
|
+
if self.proc:
|
|
495
|
+
try:
|
|
496
|
+
exit_code = await asyncio.wait_for(self.proc.wait(), timeout=5.0)
|
|
497
|
+
except asyncio.TimeoutError:
|
|
498
|
+
self.proc.kill()
|
|
499
|
+
exit_code = -1
|
|
500
|
+
else:
|
|
501
|
+
exit_code = -1
|
|
502
|
+
|
|
503
|
+
await ws.send(json.dumps({
|
|
504
|
+
"type": "pty_exited",
|
|
505
|
+
"session_id": self.session_id,
|
|
506
|
+
"exit_code": exit_code,
|
|
507
|
+
}))
|
|
508
|
+
except Exception as e:
|
|
509
|
+
try:
|
|
510
|
+
await ws.send(json.dumps({
|
|
511
|
+
"type": "pty_error",
|
|
512
|
+
"session_id": self.session_id,
|
|
513
|
+
"error": str(e),
|
|
514
|
+
}))
|
|
515
|
+
except Exception:
|
|
516
|
+
pass
|
|
517
|
+
|
|
518
|
+
def _blocking_read(self) -> str | None:
|
|
519
|
+
"""Blocking read from master fd. Returns None on EOF."""
|
|
520
|
+
import select
|
|
521
|
+
while True:
|
|
522
|
+
ready, _, _ = select.select([self.master_fd], [], [], 0.5)
|
|
523
|
+
if ready:
|
|
524
|
+
try:
|
|
525
|
+
data = os.read(self.master_fd, 4096)
|
|
526
|
+
if not data:
|
|
527
|
+
return None
|
|
528
|
+
return data.decode("utf-8", errors="replace")
|
|
529
|
+
except OSError:
|
|
530
|
+
return None
|
|
531
|
+
if self.proc and self.proc.returncode is not None:
|
|
532
|
+
try:
|
|
533
|
+
data = os.read(self.master_fd, 4096)
|
|
534
|
+
if data:
|
|
535
|
+
return data.decode("utf-8", errors="replace")
|
|
536
|
+
except OSError:
|
|
537
|
+
pass
|
|
538
|
+
return None
|
|
539
|
+
|
|
540
|
+
async def send_input(self, data: str) -> None:
|
|
541
|
+
"""Write data to the PTY master fd."""
|
|
542
|
+
if self.master_fd is not None:
|
|
543
|
+
os.write(self.master_fd, data.encode("utf-8"))
|
|
544
|
+
|
|
545
|
+
async def close(self) -> None:
|
|
546
|
+
"""Terminate the process and clean up fds."""
|
|
547
|
+
if self._read_task and not self._read_task.done():
|
|
548
|
+
self._read_task.cancel()
|
|
549
|
+
try:
|
|
550
|
+
await self._read_task
|
|
551
|
+
except asyncio.CancelledError:
|
|
552
|
+
pass
|
|
553
|
+
|
|
554
|
+
if self.proc and self.proc.returncode is None:
|
|
555
|
+
try:
|
|
556
|
+
self.proc.terminate()
|
|
557
|
+
await asyncio.wait_for(self.proc.wait(), timeout=3.0)
|
|
558
|
+
except (asyncio.TimeoutError, ProcessLookupError):
|
|
559
|
+
try:
|
|
560
|
+
self.proc.kill()
|
|
561
|
+
except ProcessLookupError:
|
|
562
|
+
pass
|
|
563
|
+
|
|
564
|
+
if self.master_fd is not None:
|
|
565
|
+
try:
|
|
566
|
+
os.close(self.master_fd)
|
|
567
|
+
except OSError:
|
|
568
|
+
pass
|
|
569
|
+
self.master_fd = None
|
|
570
|
+
|
|
571
|
+
if self.slave_fd is not None:
|
|
572
|
+
try:
|
|
573
|
+
os.close(self.slave_fd)
|
|
574
|
+
except OSError:
|
|
575
|
+
pass
|
|
576
|
+
self.slave_fd = None
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
class PipeProcess:
|
|
580
|
+
"""Manages a persistent shell session using subprocess pipes (Windows-compatible)."""
|
|
581
|
+
|
|
582
|
+
def __init__(self, session_id: str, command: str, cols: int = 120, rows: int = 40):
|
|
583
|
+
self.session_id = session_id
|
|
584
|
+
self.command = command
|
|
585
|
+
self.proc = None
|
|
586
|
+
self._read_task = None
|
|
587
|
+
|
|
588
|
+
async def start(self, ws, cwd: str | None = None):
|
|
589
|
+
"""Spawn the shell process with piped stdin/stdout."""
|
|
590
|
+
if IS_WINDOWS:
|
|
591
|
+
shell = os.environ.get("COMSPEC", "cmd.exe")
|
|
592
|
+
else:
|
|
593
|
+
shell = self.command
|
|
594
|
+
|
|
595
|
+
self.proc = await asyncio.create_subprocess_exec(
|
|
596
|
+
shell,
|
|
597
|
+
stdin=asyncio.subprocess.PIPE,
|
|
598
|
+
stdout=asyncio.subprocess.PIPE,
|
|
599
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
600
|
+
cwd=cwd,
|
|
601
|
+
)
|
|
602
|
+
self._read_task = asyncio.create_task(self._read_loop(ws))
|
|
603
|
+
|
|
604
|
+
async def _read_loop(self, ws):
|
|
605
|
+
"""Read output from stdout and send over WebSocket."""
|
|
606
|
+
try:
|
|
607
|
+
while True:
|
|
608
|
+
data = await self.proc.stdout.read(4096)
|
|
609
|
+
if not data:
|
|
610
|
+
break
|
|
611
|
+
decoded = data.decode("utf-8", errors="replace")
|
|
612
|
+
# Echo output to stdout for observability
|
|
613
|
+
for line in decoded.splitlines():
|
|
614
|
+
print_cmd_log(self.session_id, line)
|
|
615
|
+
await ws.send(json.dumps({
|
|
616
|
+
"type": "pty_output",
|
|
617
|
+
"session_id": self.session_id,
|
|
618
|
+
"data": decoded,
|
|
619
|
+
}))
|
|
620
|
+
|
|
621
|
+
if self.proc:
|
|
622
|
+
try:
|
|
623
|
+
exit_code = await asyncio.wait_for(self.proc.wait(), timeout=5.0)
|
|
624
|
+
except asyncio.TimeoutError:
|
|
625
|
+
self.proc.kill()
|
|
626
|
+
exit_code = -1
|
|
627
|
+
else:
|
|
628
|
+
exit_code = -1
|
|
629
|
+
|
|
630
|
+
await ws.send(json.dumps({
|
|
631
|
+
"type": "pty_exited",
|
|
632
|
+
"session_id": self.session_id,
|
|
633
|
+
"exit_code": exit_code,
|
|
634
|
+
}))
|
|
635
|
+
except Exception as e:
|
|
636
|
+
try:
|
|
637
|
+
await ws.send(json.dumps({
|
|
638
|
+
"type": "pty_error",
|
|
639
|
+
"session_id": self.session_id,
|
|
640
|
+
"error": str(e),
|
|
641
|
+
}))
|
|
642
|
+
except Exception:
|
|
643
|
+
pass
|
|
644
|
+
|
|
645
|
+
async def send_input(self, data: str) -> None:
|
|
646
|
+
"""Write data to the process stdin."""
|
|
647
|
+
if self.proc and self.proc.stdin:
|
|
648
|
+
self.proc.stdin.write(data.encode("utf-8"))
|
|
649
|
+
await self.proc.stdin.drain()
|
|
650
|
+
|
|
651
|
+
async def close(self) -> None:
|
|
652
|
+
"""Terminate the process and clean up."""
|
|
653
|
+
if self._read_task and not self._read_task.done():
|
|
654
|
+
self._read_task.cancel()
|
|
655
|
+
try:
|
|
656
|
+
await self._read_task
|
|
657
|
+
except asyncio.CancelledError:
|
|
658
|
+
pass
|
|
659
|
+
|
|
660
|
+
if self.proc and self.proc.returncode is None:
|
|
661
|
+
try:
|
|
662
|
+
self.proc.terminate()
|
|
663
|
+
await asyncio.wait_for(self.proc.wait(), timeout=3.0)
|
|
664
|
+
except (asyncio.TimeoutError, ProcessLookupError):
|
|
665
|
+
try:
|
|
666
|
+
self.proc.kill()
|
|
667
|
+
except ProcessLookupError:
|
|
668
|
+
pass
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
async def connect(
|
|
672
|
+
ws_url: str,
|
|
673
|
+
token: str,
|
|
674
|
+
mode: str = "restricted",
|
|
675
|
+
allowed_dirs: list[str] | None = None,
|
|
676
|
+
):
|
|
677
|
+
"""Connect to the server WebSocket and process commands."""
|
|
678
|
+
ws_endpoint = f"{ws_url}/v1/terminal/ws/connect"
|
|
679
|
+
print_status("~", f"Connecting to {BOLD}{ws_endpoint}{RESET}", C)
|
|
680
|
+
|
|
681
|
+
try:
|
|
682
|
+
async with websockets.connect(ws_endpoint) as ws:
|
|
683
|
+
await ws.send(json.dumps({"type": "auth", "token": token}))
|
|
684
|
+
auth_resp = json.loads(await ws.recv())
|
|
685
|
+
|
|
686
|
+
if auth_resp.get("type") == "error":
|
|
687
|
+
print_status("x", f"Auth failed: {auth_resp.get('message')}", RED)
|
|
688
|
+
return
|
|
689
|
+
|
|
690
|
+
if auth_resp.get("type") == "auth_ok":
|
|
691
|
+
user_id = auth_resp.get('user_id', 'unknown')
|
|
692
|
+
print_status(">", f"Connected as {BOLD}{user_id}{RESET}", GREEN)
|
|
693
|
+
else:
|
|
694
|
+
print_status("x", f"Unexpected response: {auth_resp}", RED)
|
|
695
|
+
return
|
|
696
|
+
|
|
697
|
+
print()
|
|
698
|
+
if mode == "restricted" and allowed_dirs:
|
|
699
|
+
mode_label = f"{GREEN}restricted{RESET}"
|
|
700
|
+
print(f" {C}Ready{RESET} {GREY}| Mode: {mode_label} {GREY}| Ctrl+C to disconnect{RESET}")
|
|
701
|
+
for d in allowed_dirs:
|
|
702
|
+
print(f" {GREY}Allowed: {d}{RESET}")
|
|
703
|
+
else:
|
|
704
|
+
mode_label = f"{YELLOW}unrestricted{RESET}"
|
|
705
|
+
print(f" {C}Ready{RESET} {GREY}| Mode: {mode_label} {GREY}| Ctrl+C to disconnect{RESET}")
|
|
706
|
+
print(f" {GREY}{'─' * 50}{RESET}")
|
|
707
|
+
print()
|
|
708
|
+
|
|
709
|
+
active_pty_sessions: dict[str, PTYProcess] = {}
|
|
710
|
+
|
|
711
|
+
try:
|
|
712
|
+
async for message in ws:
|
|
713
|
+
msg = json.loads(message)
|
|
714
|
+
msg_type = msg.get("type")
|
|
715
|
+
|
|
716
|
+
if msg_type == "command":
|
|
717
|
+
request_id = msg.get("request_id", "")
|
|
718
|
+
command = msg.get("command", "")
|
|
719
|
+
print_cmd_log(request_id, f"$ {BOLD}{command}{RESET}")
|
|
720
|
+
|
|
721
|
+
result = await run_command(
|
|
722
|
+
command,
|
|
723
|
+
mode=mode,
|
|
724
|
+
allowed_dirs=allowed_dirs,
|
|
725
|
+
)
|
|
726
|
+
exit_code = result["exit_code"]
|
|
727
|
+
success = exit_code == 0
|
|
728
|
+
status_text = (
|
|
729
|
+
f"{GREEN}OK{RESET}" if success
|
|
730
|
+
else f"{RED}FAILED (exit {exit_code}){RESET}"
|
|
731
|
+
)
|
|
732
|
+
print_cmd_log(request_id, status_text, success)
|
|
733
|
+
|
|
734
|
+
await ws.send(json.dumps({
|
|
735
|
+
"type": "result",
|
|
736
|
+
"request_id": request_id,
|
|
737
|
+
**result,
|
|
738
|
+
}))
|
|
739
|
+
|
|
740
|
+
elif msg_type == "pty_start":
|
|
741
|
+
session_id = msg.get("session_id", "")
|
|
742
|
+
command = msg.get("command", "bash")
|
|
743
|
+
cols = msg.get("cols", 120)
|
|
744
|
+
rows = msg.get("rows", 40)
|
|
745
|
+
print_pty_log(session_id, f"Starting: {BOLD}{command}{RESET}")
|
|
746
|
+
|
|
747
|
+
try:
|
|
748
|
+
if IS_WINDOWS:
|
|
749
|
+
pty_proc = PipeProcess(session_id, command, cols, rows)
|
|
750
|
+
else:
|
|
751
|
+
pty_proc = PTYProcess(session_id, command, cols, rows)
|
|
752
|
+
cwd = allowed_dirs[0] if allowed_dirs else None
|
|
753
|
+
await pty_proc.start(ws, cwd=cwd)
|
|
754
|
+
active_pty_sessions[session_id] = pty_proc
|
|
755
|
+
started_msg = {
|
|
756
|
+
"type": "pty_started",
|
|
757
|
+
"session_id": session_id,
|
|
758
|
+
"platform": sys.platform,
|
|
759
|
+
"mode": mode,
|
|
760
|
+
}
|
|
761
|
+
if mode == "restricted" and allowed_dirs:
|
|
762
|
+
started_msg["allowed_dirs"] = allowed_dirs
|
|
763
|
+
await ws.send(json.dumps(started_msg))
|
|
764
|
+
print_pty_log(session_id, "Started", "success")
|
|
765
|
+
except Exception as e:
|
|
766
|
+
print_pty_log(session_id, f"Failed: {e}", "error")
|
|
767
|
+
await ws.send(json.dumps({
|
|
768
|
+
"type": "pty_error",
|
|
769
|
+
"session_id": session_id,
|
|
770
|
+
"error": str(e),
|
|
771
|
+
}))
|
|
772
|
+
|
|
773
|
+
elif msg_type == "pty_input":
|
|
774
|
+
session_id = msg.get("session_id", "")
|
|
775
|
+
data = msg.get("data", "")
|
|
776
|
+
display_cmd = _clean_cmd_for_display(data)
|
|
777
|
+
if display_cmd:
|
|
778
|
+
print_pty_log(session_id, f"$ {BOLD}{display_cmd}{RESET}")
|
|
779
|
+
pty_proc = active_pty_sessions.get(session_id)
|
|
780
|
+
if pty_proc:
|
|
781
|
+
# Validate input in restricted mode
|
|
782
|
+
if mode == "restricted" and allowed_dirs:
|
|
783
|
+
pty_pid = pty_proc.proc.pid if pty_proc.proc else None
|
|
784
|
+
ok, reason = validate_command(data, allowed_dirs, pid=pty_pid)
|
|
785
|
+
if not ok:
|
|
786
|
+
# Include sentinel so server detects completion
|
|
787
|
+
err_msg = (
|
|
788
|
+
f"\r\n{reason}\r\n"
|
|
789
|
+
f"{_SENTINEL}1{_SENTINEL}\r\n"
|
|
790
|
+
)
|
|
791
|
+
await ws.send(json.dumps({
|
|
792
|
+
"type": "pty_output",
|
|
793
|
+
"session_id": session_id,
|
|
794
|
+
"data": err_msg,
|
|
795
|
+
}))
|
|
796
|
+
print_pty_log(session_id, f"Blocked: {reason}", "error")
|
|
797
|
+
continue
|
|
798
|
+
try:
|
|
799
|
+
await pty_proc.send_input(data)
|
|
800
|
+
except Exception as e:
|
|
801
|
+
print_pty_log(session_id, f"Input error: {e}", "error")
|
|
802
|
+
else:
|
|
803
|
+
print_pty_log(session_id, "Input for unknown session", "error")
|
|
804
|
+
|
|
805
|
+
elif msg_type == "pty_close":
|
|
806
|
+
session_id = msg.get("session_id", "")
|
|
807
|
+
pty_proc = active_pty_sessions.pop(session_id, None)
|
|
808
|
+
if pty_proc:
|
|
809
|
+
print_pty_log(session_id, "Closing")
|
|
810
|
+
await pty_proc.close()
|
|
811
|
+
else:
|
|
812
|
+
print_pty_log(session_id, "Close for unknown session", "error")
|
|
813
|
+
|
|
814
|
+
elif msg_type == "ping":
|
|
815
|
+
await ws.send(json.dumps({"type": "pong"}))
|
|
816
|
+
|
|
817
|
+
else:
|
|
818
|
+
print_status("?", f"Unknown message: {msg_type}", YELLOW)
|
|
819
|
+
finally:
|
|
820
|
+
for sid, pty_proc in active_pty_sessions.items():
|
|
821
|
+
try:
|
|
822
|
+
await pty_proc.close()
|
|
823
|
+
except Exception:
|
|
824
|
+
pass
|
|
825
|
+
|
|
826
|
+
except websockets.exceptions.ConnectionClosed as e:
|
|
827
|
+
print()
|
|
828
|
+
print_status("!", f"Connection closed: {e}", YELLOW)
|
|
829
|
+
except ConnectionRefusedError:
|
|
830
|
+
print()
|
|
831
|
+
print_status("x", f"Could not connect to {ws_endpoint}. Is the server running?", RED)
|
|
832
|
+
except Exception as e:
|
|
833
|
+
print()
|
|
834
|
+
print_status("x", f"Error: {e}", RED)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def main():
|
|
838
|
+
parser = argparse.ArgumentParser(
|
|
839
|
+
description="Kiwi AI CLI Agent — execute terminal commands for LLM agents"
|
|
840
|
+
)
|
|
841
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
842
|
+
|
|
843
|
+
connect_parser = subparsers.add_parser("connect", help="Connect to the server")
|
|
844
|
+
connect_parser.add_argument(
|
|
845
|
+
"--server",
|
|
846
|
+
default="app",
|
|
847
|
+
help=(
|
|
848
|
+
"Server to connect to. Use a preset name (app, dev, local) "
|
|
849
|
+
"or a full URL (e.g. https://custom.server.com). "
|
|
850
|
+
"Presets: app=api.meetkiwi.ai, dev=dev.api.myautobots.com, "
|
|
851
|
+
"local=localhost:8000 (default: app)"
|
|
852
|
+
),
|
|
853
|
+
)
|
|
854
|
+
connect_parser.add_argument(
|
|
855
|
+
"--email",
|
|
856
|
+
default=None,
|
|
857
|
+
help="Email address (will prompt if not provided)",
|
|
858
|
+
)
|
|
859
|
+
connect_parser.add_argument(
|
|
860
|
+
"--token",
|
|
861
|
+
default=None,
|
|
862
|
+
help="Access token to skip login (used by autobots-tui for shared auth)",
|
|
863
|
+
)
|
|
864
|
+
connect_parser.add_argument(
|
|
865
|
+
"--scope",
|
|
866
|
+
choices=["restricted", "full"],
|
|
867
|
+
default="restricted",
|
|
868
|
+
help="Execution scope. restricted (default) limits commands to the current directory.",
|
|
869
|
+
)
|
|
870
|
+
connect_parser.add_argument(
|
|
871
|
+
"--allow",
|
|
872
|
+
action="append",
|
|
873
|
+
dest="allow_dirs",
|
|
874
|
+
default=[],
|
|
875
|
+
metavar="PATH",
|
|
876
|
+
help="Additional allowed directory (can be specified multiple times).",
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
args = parser.parse_args()
|
|
880
|
+
|
|
881
|
+
if args.command == "connect":
|
|
882
|
+
print_banner()
|
|
883
|
+
|
|
884
|
+
preset = SERVER_PRESETS.get(args.server)
|
|
885
|
+
if preset:
|
|
886
|
+
ws_url = preset["ws"]
|
|
887
|
+
http_url = preset["http"]
|
|
888
|
+
else:
|
|
889
|
+
url = args.server.rstrip("/")
|
|
890
|
+
if url.startswith("wss://"):
|
|
891
|
+
ws_url = url
|
|
892
|
+
http_url = url.replace("wss://", "https://", 1)
|
|
893
|
+
elif url.startswith("ws://"):
|
|
894
|
+
ws_url = url
|
|
895
|
+
http_url = url.replace("ws://", "http://", 1)
|
|
896
|
+
elif url.startswith("https://"):
|
|
897
|
+
http_url = url
|
|
898
|
+
ws_url = url.replace("https://", "wss://", 1)
|
|
899
|
+
elif url.startswith("http://"):
|
|
900
|
+
http_url = url
|
|
901
|
+
ws_url = url.replace("http://", "ws://", 1)
|
|
902
|
+
else:
|
|
903
|
+
http_url = f"https://{url}"
|
|
904
|
+
ws_url = f"wss://{url}"
|
|
905
|
+
|
|
906
|
+
server_label = args.server if args.server in SERVER_PRESETS else "custom"
|
|
907
|
+
print_status(">", f"Server: {BOLD}{server_label}{RESET} ({http_url})", C)
|
|
908
|
+
|
|
909
|
+
# Build allowed_dirs list
|
|
910
|
+
mode = args.scope
|
|
911
|
+
allowed_dirs = None
|
|
912
|
+
if mode == "restricted":
|
|
913
|
+
allowed_dirs = [os.path.realpath(os.getcwd())]
|
|
914
|
+
for d in args.allow_dirs:
|
|
915
|
+
resolved = os.path.realpath(d)
|
|
916
|
+
if not os.path.isdir(resolved):
|
|
917
|
+
print_status("x", f"Directory not found: {d}", RED)
|
|
918
|
+
sys.exit(1)
|
|
919
|
+
if resolved not in allowed_dirs:
|
|
920
|
+
allowed_dirs.append(resolved)
|
|
921
|
+
|
|
922
|
+
print_status(">", f"Mode: {BOLD}restricted{RESET}", C)
|
|
923
|
+
for d in allowed_dirs:
|
|
924
|
+
print_status(" ", f" Allowed: {d}", GREY)
|
|
925
|
+
else:
|
|
926
|
+
print_status(">", f"Mode: {BOLD}unrestricted{RESET}", YELLOW)
|
|
927
|
+
|
|
928
|
+
print()
|
|
929
|
+
|
|
930
|
+
loop = asyncio.new_event_loop()
|
|
931
|
+
|
|
932
|
+
provided_token = args.token or os.environ.get("KIWI_AUTH_TOKEN")
|
|
933
|
+
if provided_token:
|
|
934
|
+
# Use provided token — skip interactive login
|
|
935
|
+
token = provided_token
|
|
936
|
+
print_section("Authentication")
|
|
937
|
+
print_status(">", "Using provided access token", GREEN)
|
|
938
|
+
else:
|
|
939
|
+
print_section("Authentication")
|
|
940
|
+
email = args.email
|
|
941
|
+
if email:
|
|
942
|
+
print_status(">", f"Email: {email}", GREY)
|
|
943
|
+
else:
|
|
944
|
+
email = input(f" {C}>{RESET} Email: ")
|
|
945
|
+
password = getpass.getpass(f" {C}>{RESET} Password: ")
|
|
946
|
+
print()
|
|
947
|
+
|
|
948
|
+
print_status("~", f"Authenticating with {BOLD}{http_url}{RESET}...", C)
|
|
949
|
+
try:
|
|
950
|
+
token = loop.run_until_complete(login(http_url, email, password))
|
|
951
|
+
print_status(">", "Login successful!", GREEN)
|
|
952
|
+
except Exception as e:
|
|
953
|
+
print_status("x", f"Login failed: {e}", RED)
|
|
954
|
+
loop.close()
|
|
955
|
+
sys.exit(1)
|
|
956
|
+
|
|
957
|
+
print_section("Connection")
|
|
958
|
+
|
|
959
|
+
_shutting_down = False
|
|
960
|
+
|
|
961
|
+
def shutdown():
|
|
962
|
+
nonlocal _shutting_down
|
|
963
|
+
if _shutting_down:
|
|
964
|
+
os._exit(0)
|
|
965
|
+
_shutting_down = True
|
|
966
|
+
print()
|
|
967
|
+
print_status("!", "Disconnecting...", YELLOW)
|
|
968
|
+
for task in asyncio.all_tasks(loop):
|
|
969
|
+
task.cancel()
|
|
970
|
+
|
|
971
|
+
signal.signal(signal.SIGINT, lambda *_: shutdown())
|
|
972
|
+
|
|
973
|
+
try:
|
|
974
|
+
loop.run_until_complete(connect(ws_url, token, mode=mode, allowed_dirs=allowed_dirs))
|
|
975
|
+
except asyncio.CancelledError:
|
|
976
|
+
pass
|
|
977
|
+
finally:
|
|
978
|
+
loop.close()
|
|
979
|
+
print()
|
|
980
|
+
print(f" {GREY}{'─' * 50}{RESET}")
|
|
981
|
+
print_status(">", "Disconnected. Goodbye!", C)
|
|
982
|
+
print()
|
|
983
|
+
else:
|
|
984
|
+
print_banner()
|
|
985
|
+
parser.print_help()
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
if __name__ == "__main__":
|
|
989
|
+
main()
|