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_tui/main.py ADDED
@@ -0,0 +1,337 @@
1
+ """Main entry point for Autobots TUI application."""
2
+
3
+ import collections
4
+ import os
5
+ import subprocess
6
+
7
+ from textual.app import App
8
+ from textual.binding import Binding
9
+ from loguru import logger
10
+
11
+ from .logger import setup_logging
12
+ from .config import ConfigManager
13
+ from .client import AutobotsClientWrapper
14
+ from .auth import TokenManager
15
+ from . import runtime_manager
16
+ from .screens import LoginScreen, DashboardScreen, AutobotsScreen, ActionsScreen, RuntimeLogsScreen
17
+
18
+
19
+ class AutobotsTUI(App):
20
+ """A modern Textual app for managing Autobots."""
21
+
22
+ CSS = """
23
+ Screen {
24
+ background: $surface;
25
+ }
26
+
27
+ Header {
28
+ background: #006666;
29
+ color: #00ffff;
30
+ }
31
+
32
+ Footer {
33
+ background: #006666;
34
+ }
35
+
36
+ Footer > .footer--key {
37
+ background: #008888;
38
+ color: #00ffff;
39
+ }
40
+ """
41
+
42
+ TITLE = "Kiwi Code"
43
+ SUB_TITLE = ""
44
+
45
+ SCREENS = {
46
+ "login": LoginScreen,
47
+ "dashboard": DashboardScreen,
48
+ "autobots": AutobotsScreen,
49
+ "actions": ActionsScreen,
50
+ "runtime_logs": RuntimeLogsScreen,
51
+ }
52
+
53
+ BINDINGS = [
54
+ Binding("ctrl+c", "quit", "Quit", show=True),
55
+ Binding("ctrl+d", "toggle_dark", "Toggle Dark Mode", show=False),
56
+ Binding("ctrl+r", "runtime_logs", "Runtime Logs", show=True),
57
+ Binding("ctrl+l", "logout", "Logout", show=False),
58
+ ]
59
+
60
+ # Map backend HTTP URLs to kiwi connect server presets
61
+ _KIWI_SERVER_MAP = {
62
+ "https://api.meetkiwi.ai": "app",
63
+ "https://dev.api.myautobots.com": "dev",
64
+ "http://localhost:8000": "local",
65
+ }
66
+
67
+ # Max lines to keep in the runtime log buffer
68
+ _KIWI_LOG_BUFFER_SIZE = 5000
69
+
70
+ def __init__(self, config_manager: ConfigManager | None = None, token_manager: TokenManager | None = None):
71
+ """Initialize Autobots TUI app.
72
+
73
+ Args:
74
+ config_manager: Configuration manager instance
75
+ token_manager: Token manager instance
76
+ """
77
+ super().__init__()
78
+ self.config_manager = config_manager or ConfigManager()
79
+ self.config = self.config_manager.config
80
+
81
+ self._kiwi_token = None # Set after auth for kiwi CLI session
82
+ self._kiwi_pid = None # PID of the runtime process
83
+ self._log_fp = None # File pointer for tailing runtime log
84
+ self._kiwi_log_lines = collections.deque(maxlen=self._KIWI_LOG_BUFFER_SIZE)
85
+
86
+ # Initialize token manager
87
+ self.token_manager = token_manager or TokenManager()
88
+
89
+ # Check for existing tokens
90
+ tokens = self.token_manager.load_tokens()
91
+ access_token = None
92
+
93
+ if tokens:
94
+ # Check if token needs refresh
95
+ if tokens.is_expired():
96
+ logger.info("Access token expired, attempting refresh")
97
+ # Try to refresh token
98
+ temp_client = AutobotsClientWrapper(base_url=self.config.backend_url)
99
+ success, new_tokens, message = temp_client.refresh_token(tokens.refresh_token)
100
+
101
+ if success and new_tokens:
102
+ logger.info("Token refreshed successfully")
103
+ self.token_manager.save_tokens(new_tokens)
104
+ access_token = new_tokens.access_token
105
+ else:
106
+ logger.warning(f"Token refresh failed: {message}")
107
+ self.token_manager.clear_tokens()
108
+ else:
109
+ logger.info("Valid access token found")
110
+ access_token = tokens.access_token
111
+
112
+ # Share token with kiwi CLI session
113
+ self._kiwi_token = access_token
114
+
115
+ # Initialize autobots client
116
+ self.autobots_client = AutobotsClientWrapper(
117
+ base_url=self.config.backend_url,
118
+ access_token=access_token,
119
+ api_key=self.config.api_key,
120
+ )
121
+
122
+ def on_mount(self) -> None:
123
+ """Called when app is mounted."""
124
+ logger.info("Autobots TUI on_mount called")
125
+ logger.info(f"Backend URL: {self.config.backend_url}")
126
+
127
+ # Set theme
128
+ self.dark = self.config.theme == "dark"
129
+
130
+ # Check if user is authenticated
131
+ if self.token_manager.is_authenticated():
132
+ logger.info("User is authenticated, showing dashboard")
133
+ self._start_kiwi_session()
134
+ try:
135
+ self.switch_screen("dashboard")
136
+ except IndexError:
137
+ self.push_screen("dashboard")
138
+ else:
139
+ logger.info("User not authenticated, showing login screen")
140
+ try:
141
+ self.switch_screen("login")
142
+ except IndexError:
143
+ self.push_screen("login")
144
+
145
+ @property
146
+ def runtime_log_cmd(self) -> str:
147
+ """Derive the kiwi connect command from the configured backend_url."""
148
+ # Allow env var override
149
+ override = os.getenv("KIWI_CODE_RUNTIME_LOG_CMD")
150
+ if override:
151
+ return override
152
+
153
+ import sys
154
+ python = sys.executable
155
+
156
+ backend_url = self.config.backend_url.rstrip("/")
157
+ server = self._KIWI_SERVER_MAP.get(backend_url)
158
+ if server:
159
+ cmd = f"{python} -m kiwi_runtime connect --server {server}"
160
+ else:
161
+ # Custom URL — convert http(s) to ws(s)
162
+ ws_url = backend_url.replace("https://", "wss://").replace("http://", "ws://")
163
+ cmd = f"{python} -m kiwi_runtime connect --server {ws_url}"
164
+
165
+ return cmd
166
+
167
+ # ------------------------------------------------------------------
168
+ # Persistent kiwi runtime (per working directory, survives TUI restarts)
169
+ # ------------------------------------------------------------------
170
+
171
+ def _start_kiwi_session(self) -> None:
172
+ """Start a new kiwi-runtime for this directory, or attach if already running."""
173
+ existing_pid = runtime_manager.get_running_pid()
174
+ if existing_pid:
175
+ logger.info(f"Kiwi runtime already running (PID {existing_pid})")
176
+ self._kiwi_pid = existing_pid
177
+ self._start_log_tail()
178
+ return
179
+
180
+ cmd = self.runtime_log_cmd
181
+ if not cmd:
182
+ return
183
+
184
+ logger.info(f"Starting kiwi runtime: {cmd}")
185
+ try:
186
+ env = os.environ.copy()
187
+ env["PYTHONUNBUFFERED"] = "1"
188
+
189
+ token = self._kiwi_token
190
+ if token:
191
+ env["KIWI_AUTH_TOKEN"] = token
192
+
193
+ rt_log_path = runtime_manager.log_path()
194
+ log_fp = open(rt_log_path, "a")
195
+
196
+ proc = subprocess.Popen(
197
+ cmd,
198
+ shell=True,
199
+ stdin=subprocess.DEVNULL,
200
+ stdout=log_fp,
201
+ stderr=subprocess.STDOUT,
202
+ start_new_session=True,
203
+ env=env,
204
+ )
205
+ log_fp.close() # Child inherited the fd via fork
206
+
207
+ runtime_manager.save_pid(proc.pid)
208
+ self._kiwi_pid = proc.pid
209
+
210
+ self._start_log_tail()
211
+ logger.info(f"Kiwi runtime started (PID {proc.pid})")
212
+ except Exception as e:
213
+ logger.error(f"Failed to start kiwi runtime: {e}")
214
+
215
+ def _start_log_tail(self) -> None:
216
+ """Open the runtime log file for tailing."""
217
+ if self._log_fp is not None:
218
+ return # Already tailing
219
+
220
+ rt_log_path = runtime_manager.log_path()
221
+ if not rt_log_path.exists():
222
+ return
223
+
224
+ try:
225
+ self._log_fp = open(rt_log_path, "r", encoding="utf-8", errors="replace")
226
+ # Seek near end so we don't replay the entire history
227
+ size = rt_log_path.stat().st_size
228
+ if size > 65536:
229
+ self._log_fp.seek(size - 65536)
230
+ self.set_interval(0.1, self._drain_kiwi_output)
231
+ logger.info("Started tailing runtime log")
232
+ except OSError as e:
233
+ logger.error(f"Failed to tail runtime log: {e}")
234
+
235
+ def _drain_kiwi_output(self) -> None:
236
+ """Read new lines from the runtime log file and buffer them."""
237
+ fp = self._log_fp
238
+ if fp is None:
239
+ return
240
+
241
+ chunk = fp.read()
242
+ if not chunk:
243
+ # No new output — check if runtime is still alive
244
+ if self._kiwi_pid:
245
+ try:
246
+ os.kill(self._kiwi_pid, 0)
247
+ except ProcessLookupError:
248
+ logger.info("Kiwi runtime exited, restarting...")
249
+ self._kiwi_pid = None
250
+ self._close_log_tail()
251
+ self._start_kiwi_session()
252
+ return
253
+
254
+ for line in chunk.splitlines():
255
+ self._kiwi_log_lines.append(line)
256
+
257
+ def _close_log_tail(self) -> None:
258
+ """Close the log file we are tailing (does NOT stop the runtime)."""
259
+ fp = self._log_fp
260
+ if fp:
261
+ try:
262
+ fp.close()
263
+ except OSError:
264
+ pass
265
+ self._log_fp = None
266
+
267
+ def _stop_runtime(self) -> None:
268
+ """Explicitly stop the kiwi-runtime for the current directory."""
269
+ runtime_manager.stop_runtime()
270
+ self._kiwi_pid = None
271
+ self._close_log_tail()
272
+
273
+ # ------------------------------------------------------------------
274
+
275
+ def action_runtime_logs(self) -> None:
276
+ """Show the runtime log stream."""
277
+ self.push_screen("runtime_logs")
278
+
279
+ def action_toggle_dark(self) -> None:
280
+ """Toggle dark mode."""
281
+ self.dark = not self.dark
282
+ theme = "dark" if self.dark else "light"
283
+ self.config_manager.update(theme=theme)
284
+ logger.info(f"Theme changed to {theme}")
285
+ self.notify(f"Theme: {theme}", severity="information")
286
+
287
+ def action_logout(self) -> None:
288
+ """Logout user and return to login screen."""
289
+ logger.info("User logout requested")
290
+
291
+ # Detach from runtime logs — do NOT stop the runtime process
292
+ self._close_log_tail()
293
+ self._kiwi_pid = None
294
+
295
+ # Clear tokens
296
+ self.token_manager.clear_tokens()
297
+
298
+ # Reset client to unauthenticated
299
+ self.autobots_client = AutobotsClientWrapper(
300
+ base_url=self.config.backend_url,
301
+ )
302
+
303
+ # Switch to login screen
304
+ self.switch_screen("login")
305
+ self.notify("Logged out successfully", severity="information")
306
+
307
+ def action_quit(self) -> None:
308
+ """Quit the application."""
309
+ logger.info("Autobots TUI shutting down")
310
+ # Detach from runtime logs — do NOT stop the runtime process
311
+ self._close_log_tail()
312
+ # Close autobots client connection
313
+ if hasattr(self, 'autobots_client'):
314
+ self.autobots_client.close()
315
+ self.exit()
316
+
317
+
318
+ def main():
319
+ """Run the Autobots TUI application."""
320
+ # Setup logging
321
+ config_manager = ConfigManager()
322
+ config = config_manager.load()
323
+ setup_logging(log_level=config.log_level)
324
+
325
+ logger.info("="*60)
326
+ logger.info("Starting Autobots TUI")
327
+ logger.info("="*60)
328
+
329
+ # Run app
330
+ app = AutobotsTUI(config_manager=config_manager)
331
+ app.run()
332
+
333
+ logger.info("Autobots TUI terminated")
334
+
335
+
336
+ if __name__ == "__main__":
337
+ main()
kiwi_tui/models.py ADDED
@@ -0,0 +1,85 @@
1
+ """Pydantic models for Autobots TUI."""
2
+
3
+ from datetime import datetime, timedelta
4
+ from enum import Enum
5
+ from typing import Optional, Any
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class ActionStatus(str, Enum):
10
+ """Status of an action execution."""
11
+ PENDING = "pending"
12
+ RUNNING = "running"
13
+ COMPLETED = "completed"
14
+ FAILED = "failed"
15
+ CANCELLED = "cancelled"
16
+
17
+
18
+ class AutobotType(str, Enum):
19
+ """Type of autobot."""
20
+ CHAT = "chat"
21
+ SEARCH = "search"
22
+ AUTOMATION = "automation"
23
+ ANALYSIS = "analysis"
24
+
25
+
26
+ class Autobot(BaseModel):
27
+ """Represents an autobot instance."""
28
+ id: str
29
+ name: str
30
+ type: AutobotType
31
+ description: str
32
+ enabled: bool = True
33
+ created_at: datetime = Field(default_factory=datetime.now)
34
+ last_run: Optional[datetime] = None
35
+ run_count: int = 0
36
+
37
+
38
+ class Action(BaseModel):
39
+ """Represents an action that can be executed."""
40
+ id: str
41
+ name: str
42
+ description: str
43
+ autobot_id: str
44
+ parameters: dict[str, Any] = Field(default_factory=dict)
45
+
46
+
47
+ class ActionExecution(BaseModel):
48
+ """Represents an action execution instance."""
49
+ id: str
50
+ action_id: str
51
+ status: ActionStatus
52
+ started_at: datetime = Field(default_factory=datetime.now)
53
+ completed_at: Optional[datetime] = None
54
+ result: Optional[dict[str, Any]] = None
55
+ error: Optional[str] = None
56
+
57
+
58
+ class AuthTokens(BaseModel):
59
+ """Authentication tokens."""
60
+ access_token: str
61
+ refresh_token: str
62
+ token_type: str = "Bearer"
63
+ expires_at: Optional[datetime] = None
64
+
65
+ def is_expired(self) -> bool:
66
+ """Check if access token is expired."""
67
+ if not self.expires_at:
68
+ return False
69
+ # Add 60 second buffer before expiry
70
+ return datetime.now() >= (self.expires_at - timedelta(seconds=60))
71
+
72
+
73
+ class LoginCredentials(BaseModel):
74
+ """Login credentials."""
75
+ username: str
76
+ password: str
77
+
78
+
79
+ class AppConfig(BaseModel):
80
+ """Application configuration."""
81
+ backend_url: str = "https://dev.api.myautobots.com"#"https://api.meetkiwi.ai"
82
+ api_key: Optional[str] = None
83
+ log_level: str = "INFO"
84
+ theme: str = "dark"
85
+ refresh_interval: int = 5
@@ -0,0 +1,130 @@
1
+ """Shared helpers for managing per-directory kiwi-runtime processes.
2
+
3
+ Each working directory gets its own runtime, identified by a hash of the
4
+ absolute path. State is stored under ~/.autobots-tui/runtimes/<key>/:
5
+ pid — PID of the running process
6
+ log — stdout/stderr of the runtime
7
+ cwd — the working directory this runtime is scoped to
8
+ """
9
+
10
+ import hashlib
11
+ import os
12
+ import signal
13
+ import time
14
+ from pathlib import Path
15
+
16
+ RUNTIMES_DIR = Path.home() / ".autobots-tui" / "runtimes"
17
+
18
+
19
+ def runtime_key(cwd: str | None = None) -> str:
20
+ """Generate a short hash key for a working directory."""
21
+ if cwd is None:
22
+ cwd = os.getcwd()
23
+ return hashlib.sha256(os.path.realpath(cwd).encode()).hexdigest()[:12]
24
+
25
+
26
+ def runtime_dir(cwd: str | None = None) -> Path:
27
+ """Return the runtime state directory for a given working directory."""
28
+ d = RUNTIMES_DIR / runtime_key(cwd)
29
+ d.mkdir(parents=True, exist_ok=True)
30
+ return d
31
+
32
+
33
+ def get_running_pid(cwd: str | None = None) -> int | None:
34
+ """Return PID of a running runtime for the directory, or None."""
35
+ pid_path = runtime_dir(cwd) / "pid"
36
+ if not pid_path.exists():
37
+ return None
38
+ try:
39
+ pid = int(pid_path.read_text().strip())
40
+ os.kill(pid, 0)
41
+ return pid
42
+ except (ValueError, ProcessLookupError, PermissionError):
43
+ pid_path.unlink(missing_ok=True)
44
+ return None
45
+
46
+
47
+ def save_pid(pid: int, cwd: str | None = None) -> None:
48
+ """Record a runtime PID and its working directory."""
49
+ if cwd is None:
50
+ cwd = os.getcwd()
51
+ rd = runtime_dir(cwd)
52
+ (rd / "pid").write_text(str(pid))
53
+ (rd / "cwd").write_text(os.path.realpath(cwd))
54
+
55
+
56
+ def log_path(cwd: str | None = None) -> Path:
57
+ """Return the log file path for a directory's runtime."""
58
+ return runtime_dir(cwd) / "log"
59
+
60
+
61
+ def stop_runtime(cwd: str | None = None) -> bool:
62
+ """Stop the runtime for a directory. Returns True if a process was stopped."""
63
+ pid = get_running_pid(cwd)
64
+ if not pid:
65
+ return False
66
+
67
+ try:
68
+ os.kill(pid, signal.SIGTERM)
69
+ for _ in range(20):
70
+ time.sleep(0.1)
71
+ try:
72
+ os.kill(pid, 0)
73
+ except ProcessLookupError:
74
+ break
75
+ else:
76
+ try:
77
+ os.kill(pid, signal.SIGKILL)
78
+ except ProcessLookupError:
79
+ pass
80
+ except ProcessLookupError:
81
+ pass
82
+
83
+ pid_path = runtime_dir(cwd) / "pid"
84
+ pid_path.unlink(missing_ok=True)
85
+ return True
86
+
87
+
88
+ def list_all() -> list[dict]:
89
+ """List all known runtimes with their status."""
90
+ if not RUNTIMES_DIR.exists():
91
+ return []
92
+
93
+ results = []
94
+ for entry in sorted(RUNTIMES_DIR.iterdir()):
95
+ if not entry.is_dir():
96
+ continue
97
+ cwd_file = entry / "cwd"
98
+ pid_file = entry / "pid"
99
+ if not cwd_file.exists():
100
+ continue
101
+
102
+ cwd = cwd_file.read_text().strip()
103
+ pid = None
104
+ running = False
105
+ if pid_file.exists():
106
+ try:
107
+ pid = int(pid_file.read_text().strip())
108
+ os.kill(pid, 0)
109
+ running = True
110
+ except (ValueError, ProcessLookupError, PermissionError):
111
+ pid_file.unlink(missing_ok=True)
112
+ pid = None
113
+
114
+ results.append({
115
+ "key": entry.name,
116
+ "cwd": cwd,
117
+ "pid": pid,
118
+ "running": running,
119
+ })
120
+ return results
121
+
122
+
123
+ def stop_all() -> int:
124
+ """Stop all running runtimes. Returns count of processes stopped."""
125
+ count = 0
126
+ for rt in list_all():
127
+ if rt["running"]:
128
+ if stop_runtime(rt["cwd"]):
129
+ count += 1
130
+ return count
@@ -0,0 +1,9 @@
1
+ """Screens for Autobots TUI."""
2
+
3
+ from .login import LoginScreen
4
+ from .dashboard import DashboardScreen
5
+ from .autobots import AutobotsScreen
6
+ from .actions import ActionsScreen
7
+ from .runtime_logs import RuntimeLogsScreen
8
+
9
+ __all__ = ["LoginScreen", "DashboardScreen", "AutobotsScreen", "ActionsScreen", "RuntimeLogsScreen"]