kiwi-code 0.0.7__tar.gz → 0.0.8__tar.gz

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.
Files changed (33) hide show
  1. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/PKG-INFO +2 -1
  2. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/pyproject.toml +2 -1
  3. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_cli/cli.py +2 -0
  4. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_runtime/main.py +264 -6
  5. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_tui/main.py +4 -3
  6. kiwi_code-0.0.8/src/kiwi_tui/screens/__init__.py +7 -0
  7. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_tui/screens/dashboard.py +11 -0
  8. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_tui/widgets.py +2 -0
  9. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/uv.lock +618 -567
  10. kiwi_code-0.0.7/src/kiwi_tui/screens/__init__.py +0 -9
  11. kiwi_code-0.0.7/src/kiwi_tui/screens/actions.py +0 -271
  12. kiwi_code-0.0.7/src/kiwi_tui/screens/autobots.py +0 -216
  13. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/.github/workflows/publish.yml +0 -0
  14. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/.gitignore +0 -0
  15. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/.python-version +0 -0
  16. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/CLAUDE.md +0 -0
  17. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/Makefile +0 -0
  18. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/README.md +0 -0
  19. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_cli/__init__.py +0 -0
  20. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_cli/auth.py +0 -0
  21. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_cli/client.py +0 -0
  22. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_cli/commands.py +0 -0
  23. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_cli/config.py +0 -0
  24. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_cli/logger.py +0 -0
  25. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_cli/models.py +0 -0
  26. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_cli/runtime_manager.py +0 -0
  27. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_runtime/__init__.py +0 -0
  28. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_runtime/__main__.py +0 -0
  29. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_tui/__init__.py +0 -0
  30. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_tui/screens/file_browser.py +0 -0
  31. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_tui/screens/login.py +0 -0
  32. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  33. {kiwi_code-0.0.7 → kiwi_code-0.0.8}/test_hello.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwi-code
3
- Version: 0.0.7
3
+ Version: 0.0.8
4
4
  Summary: A textual-based terminal user interface application
5
5
  Project-URL: Homepage, https://meetkiwi.ai
6
6
  Project-URL: Repository, https://github.com/jetoslabs/kiwi-code
@@ -18,6 +18,7 @@ Requires-Dist: httpx>=0.25.0
18
18
  Requires-Dist: loguru>=0.7.3
19
19
  Requires-Dist: psutil>=5.9.0
20
20
  Requires-Dist: pydantic>=2.12.5
21
+ Requires-Dist: setproctitle>=1.3.0
21
22
  Requires-Dist: textual-dev>=1.8.0
22
23
  Requires-Dist: textual>=8.1.1
23
24
  Requires-Dist: typer>=0.24.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiwi-code"
3
- version = "0.0.7"
3
+ version = "0.0.8"
4
4
  description = "A textual-based terminal user interface application"
5
5
  readme = {file = "README.md", content-type = "text/markdown"}
6
6
  requires-python = ">=3.13,<4.0"
@@ -14,6 +14,7 @@ dependencies = [
14
14
  "websockets>=14.1",
15
15
  "httpx>=0.25.0",
16
16
  "psutil>=5.9.0",
17
+ "setproctitle>=1.3.0",
17
18
  ]
18
19
  authors = [
19
20
  { name = "Anurag Jha", email = "anurag@meetkiwi.co" }
@@ -237,6 +237,8 @@ def tui():
237
237
 
238
238
  def cli():
239
239
  """Entry point for the CLI."""
240
+ import setproctitle
241
+ setproctitle.setproctitle("kiwicli")
240
242
  app()
241
243
 
242
244
  if __name__ == "__main__":
@@ -610,6 +610,229 @@ if not IS_WINDOWS:
610
610
  self.slave_fd = None
611
611
 
612
612
 
613
+ class PersistentShell:
614
+ """A long-lived bash PTY that persists across commands.
615
+
616
+ All one-shot ``command`` messages are routed through the same shell so
617
+ that ``cd``, environment variables, virtualenv activations, aliases, and
618
+ other shell state carry over between commands. The server still
619
+ receives ``{stdout, stderr, exit_code}`` — no backend changes needed.
620
+ """
621
+
622
+ _SHELL_SENTINEL = "__KIWI_SHELL_DONE__"
623
+
624
+ def __init__(self, cwd: str | None = None, cols: int = 200, rows: int = 50):
625
+ self._cwd = cwd
626
+ self._cols = cols
627
+ self._rows = rows
628
+ self.master_fd: int | None = None
629
+ self._pid: int | None = None
630
+
631
+ # -- lifecycle --------------------------------------------------------
632
+
633
+ def start(self) -> None:
634
+ """Spawn a bash PTY that stays alive for the runtime's lifetime."""
635
+ master_fd, slave_fd = pty.openpty()
636
+
637
+ # Set terminal size
638
+ winsize = struct.pack("HHHH", self._rows, self._cols, 0, 0)
639
+ fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, winsize)
640
+
641
+ pid = os.fork()
642
+ if pid == 0:
643
+ # Child — become session leader and exec bash
644
+ os.setsid()
645
+ os.dup2(slave_fd, 0)
646
+ os.dup2(slave_fd, 1)
647
+ os.dup2(slave_fd, 2)
648
+ if slave_fd > 2:
649
+ os.close(slave_fd)
650
+ os.close(master_fd)
651
+ if self._cwd:
652
+ os.chdir(self._cwd)
653
+ os.execvp("bash", [
654
+ "bash", "--norc", "--noprofile", "-i",
655
+ ])
656
+ else:
657
+ # Parent
658
+ os.close(slave_fd)
659
+ self.master_fd = master_fd
660
+ self._pid = pid
661
+
662
+ # Set non-blocking reads
663
+ flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
664
+ fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
665
+
666
+ # Wait for bash to be ready, drain the initial prompt
667
+ import time
668
+ time.sleep(0.2)
669
+ self._drain()
670
+
671
+ # Disable echo and set a blank prompt so output is clean
672
+ self._write("stty -echo\n")
673
+ time.sleep(0.1)
674
+ self._drain()
675
+ self._write("PS1=''; PS2=''\n")
676
+ time.sleep(0.1)
677
+ self._drain()
678
+
679
+ print_status(">", "Persistent shell started (bash PTY)", GREEN)
680
+
681
+ def is_alive(self) -> bool:
682
+ """Check whether the shell process is still running."""
683
+ if self._pid is None:
684
+ return False
685
+ try:
686
+ pid, status = os.waitpid(self._pid, os.WNOHANG)
687
+ if pid != 0:
688
+ self._pid = None
689
+ return False
690
+ return True
691
+ except ChildProcessError:
692
+ self._pid = None
693
+ return False
694
+
695
+ def close(self) -> None:
696
+ """Terminate the persistent shell."""
697
+ if self.master_fd is not None:
698
+ try:
699
+ os.write(self.master_fd, b"exit\n")
700
+ except OSError:
701
+ pass
702
+ try:
703
+ os.close(self.master_fd)
704
+ except OSError:
705
+ pass
706
+ self.master_fd = None
707
+ if self._pid is not None:
708
+ try:
709
+ os.kill(self._pid, signal.SIGTERM)
710
+ os.waitpid(self._pid, 0)
711
+ except (ProcessLookupError, ChildProcessError):
712
+ pass
713
+ self._pid = None
714
+
715
+ # -- I/O helpers ------------------------------------------------------
716
+
717
+ def _write(self, data: str) -> None:
718
+ if self.master_fd is not None:
719
+ os.write(self.master_fd, data.encode("utf-8"))
720
+
721
+ def _drain(self) -> str:
722
+ """Read all currently available data from the PTY (non-blocking)."""
723
+ import select
724
+ chunks: list[str] = []
725
+ while True:
726
+ ready, _, _ = select.select([self.master_fd], [], [], 0.05)
727
+ if not ready:
728
+ break
729
+ try:
730
+ data = os.read(self.master_fd, 65536)
731
+ if not data:
732
+ break
733
+ chunks.append(data.decode("utf-8", errors="replace"))
734
+ except OSError:
735
+ break
736
+ return "".join(chunks)
737
+
738
+ # -- command execution ------------------------------------------------
739
+
740
+ async def run_command(self, command: str, timeout: int = 120) -> dict:
741
+ """Run a command in the persistent shell, returning {stdout, stderr, exit_code}.
742
+
743
+ Uses a sentinel pattern to detect when the command has finished.
744
+ """
745
+ if not self.is_alive():
746
+ return {"stdout": "", "stderr": "Persistent shell is not running", "exit_code": -1}
747
+
748
+ sentinel = self._SHELL_SENTINEL
749
+ # Wrap the command: run it, then echo sentinel+exit_code+sentinel
750
+ wrapped = (
751
+ f"{command}\n"
752
+ f"__kiwi_ec=$?; echo \"{sentinel}${{__kiwi_ec}}{sentinel}\"; "
753
+ f"echo \"{sentinel}${{__kiwi_ec}}{sentinel}\" >&2\n"
754
+ )
755
+
756
+ # Drain any leftover output
757
+ self._drain()
758
+
759
+ # Send the command
760
+ self._write(wrapped)
761
+
762
+ # Read output until we see the sentinel
763
+ import select
764
+ collected: list[str] = []
765
+ loop = asyncio.get_event_loop()
766
+
767
+ try:
768
+ output = await asyncio.wait_for(
769
+ loop.run_in_executor(None, self._read_until_sentinel, sentinel, timeout),
770
+ timeout=timeout + 5,
771
+ )
772
+ except (asyncio.TimeoutError, TimeoutError):
773
+ return {"stdout": "", "stderr": f"Command timed out after {timeout}s", "exit_code": -1}
774
+
775
+ return output
776
+
777
+ def _read_until_sentinel(self, sentinel: str, timeout: int) -> dict:
778
+ """Blocking read from PTY until sentinel is found. Returns parsed result."""
779
+ import select
780
+ import time
781
+
782
+ deadline = time.monotonic() + timeout
783
+ buf = ""
784
+
785
+ while time.monotonic() < deadline:
786
+ remaining = deadline - time.monotonic()
787
+ if remaining <= 0:
788
+ break
789
+
790
+ ready, _, _ = select.select(
791
+ [self.master_fd], [], [], min(0.1, remaining)
792
+ )
793
+ if ready:
794
+ try:
795
+ data = os.read(self.master_fd, 65536)
796
+ if not data:
797
+ break
798
+ buf += data.decode("utf-8", errors="replace")
799
+ except OSError:
800
+ break
801
+
802
+ # Check if sentinel appeared
803
+ sentinel_marker = sentinel
804
+ # We look for the pattern: sentinel + exit_code + sentinel
805
+ import re
806
+ pattern = re.escape(sentinel) + r"(\d+)" + re.escape(sentinel)
807
+ match = re.search(pattern, buf)
808
+ if match:
809
+ exit_code = int(match.group(1))
810
+ # Everything before the sentinel line is stdout
811
+ stdout = buf[:match.start()]
812
+ # Clean up: remove the sentinel echo command itself if visible
813
+ lines = stdout.splitlines(keepends=True)
814
+ clean_lines = []
815
+ for line in lines:
816
+ stripped = line.strip()
817
+ if sentinel in stripped:
818
+ continue
819
+ if stripped.startswith("__kiwi_ec="):
820
+ continue
821
+ clean_lines.append(line)
822
+ stdout = "".join(clean_lines).strip()
823
+
824
+ # Truncate if needed
825
+ stdout = stdout[:MAX_OUTPUT_BYTES]
826
+
827
+ return {
828
+ "stdout": stdout,
829
+ "stderr": "",
830
+ "exit_code": exit_code,
831
+ }
832
+
833
+ return {"stdout": buf[:MAX_OUTPUT_BYTES], "stderr": "Command timed out", "exit_code": -1}
834
+
835
+
613
836
  class PipeProcess:
614
837
  """Manages a persistent shell session using subprocess pipes (Windows-compatible)."""
615
838
 
@@ -777,6 +1000,7 @@ async def connect(
777
1000
  token: str,
778
1001
  mode: str = "restricted",
779
1002
  allowed_dirs: list[str] | None = None,
1003
+ shell: "PersistentShell | None" = None,
780
1004
  ) -> str:
781
1005
  """Connect to the server WebSocket and process commands.
782
1006
 
@@ -831,11 +1055,26 @@ async def connect(
831
1055
  command = msg.get("command", "")
832
1056
  print_cmd_log(request_id, f"$ {BOLD}{command}{RESET}")
833
1057
 
834
- result = await run_command(
835
- command,
836
- mode=mode,
837
- allowed_dirs=allowed_dirs,
838
- )
1058
+ # Validate in restricted mode before execution
1059
+ if mode == "restricted" and allowed_dirs:
1060
+ ok, reason = validate_command(
1061
+ command, allowed_dirs,
1062
+ pid=shell._pid if shell and shell.is_alive() else None,
1063
+ )
1064
+ if not ok:
1065
+ result = {"stdout": "", "stderr": reason, "exit_code": 1}
1066
+ elif shell and shell.is_alive():
1067
+ result = await shell.run_command(command)
1068
+ else:
1069
+ result = await run_command(
1070
+ command, mode=mode, allowed_dirs=allowed_dirs,
1071
+ )
1072
+ elif shell and shell.is_alive():
1073
+ result = await shell.run_command(command)
1074
+ else:
1075
+ result = await run_command(
1076
+ command, mode=mode, allowed_dirs=allowed_dirs,
1077
+ )
839
1078
  exit_code = result["exit_code"]
840
1079
  success = exit_code == 0
841
1080
  status_text = (
@@ -954,6 +1193,9 @@ async def connect(
954
1193
 
955
1194
 
956
1195
  def main():
1196
+ import setproctitle
1197
+ setproctitle.setproctitle("kiwi-runtime")
1198
+
957
1199
  parser = argparse.ArgumentParser(
958
1200
  description="Kiwi AI CLI Agent — execute terminal commands for LLM agents"
959
1201
  )
@@ -1084,6 +1326,18 @@ def main():
1084
1326
  loop.close()
1085
1327
  sys.exit(1)
1086
1328
 
1329
+ # Start persistent shell (Unix only) — survives reconnections
1330
+ persistent_shell = None
1331
+ if not IS_WINDOWS:
1332
+ print_section("Shell")
1333
+ try:
1334
+ cwd = allowed_dirs[0] if allowed_dirs else os.getcwd()
1335
+ persistent_shell = PersistentShell(cwd=cwd)
1336
+ persistent_shell.start()
1337
+ except Exception as e:
1338
+ print_status("!", f"Persistent shell failed, using one-shot mode: {e}", YELLOW)
1339
+ persistent_shell = None
1340
+
1087
1341
  print_section("Connection")
1088
1342
 
1089
1343
  _shutting_down = False
@@ -1115,7 +1369,7 @@ def main():
1115
1369
 
1116
1370
  try:
1117
1371
  status = loop.run_until_complete(
1118
- connect(ws_url, token, mode=mode, allowed_dirs=allowed_dirs)
1372
+ connect(ws_url, token, mode=mode, allowed_dirs=allowed_dirs, shell=persistent_shell)
1119
1373
  )
1120
1374
  except asyncio.CancelledError:
1121
1375
  break
@@ -1157,6 +1411,10 @@ def main():
1157
1411
  break
1158
1412
  backoff = min(backoff * 2, max_backoff)
1159
1413
 
1414
+ # Clean up persistent shell
1415
+ if persistent_shell:
1416
+ persistent_shell.close()
1417
+
1160
1418
  loop.close()
1161
1419
  print()
1162
1420
  print(f" {GREY}{'─' * 50}{RESET}")
@@ -13,7 +13,7 @@ from kiwi_cli.config import ConfigManager
13
13
  from kiwi_cli.client import AutobotsClientWrapper
14
14
  from kiwi_cli.auth import TokenManager
15
15
  from kiwi_cli import runtime_manager
16
- from .screens import LoginScreen, DashboardScreen, AutobotsScreen, ActionsScreen, RuntimeLogsScreen
16
+ from .screens import LoginScreen, DashboardScreen, RuntimeLogsScreen
17
17
 
18
18
 
19
19
  class AutobotsTUI(App):
@@ -45,8 +45,6 @@ class AutobotsTUI(App):
45
45
  SCREENS = {
46
46
  "login": LoginScreen,
47
47
  "dashboard": DashboardScreen,
48
- "autobots": AutobotsScreen,
49
- "actions": ActionsScreen,
50
48
  "runtime_logs": RuntimeLogsScreen,
51
49
  }
52
50
 
@@ -420,6 +418,9 @@ def _serve_tui(port: int = 8566):
420
418
 
421
419
  def main():
422
420
  """Entry point for the kiwi command."""
421
+ import setproctitle
422
+ setproctitle.setproctitle("kiwi")
423
+
423
424
  import argparse
424
425
 
425
426
  parser = argparse.ArgumentParser(prog="kiwi", description="Kiwi Code TUI")
@@ -0,0 +1,7 @@
1
+ """Screens for Autobots TUI."""
2
+
3
+ from .login import LoginScreen
4
+ from .dashboard import DashboardScreen
5
+ from .runtime_logs import RuntimeLogsScreen
6
+
7
+ __all__ = ["LoginScreen", "DashboardScreen", "RuntimeLogsScreen"]
@@ -239,6 +239,17 @@ class DashboardScreen(Screen):
239
239
  self.add_message(f"Cleared {count} pending file(s).", "info")
240
240
  return
241
241
 
242
+ if cmd == "/login":
243
+ if self.app.token_manager.is_authenticated():
244
+ self.add_message("Already logged in. Use /logout first to switch accounts.", "info")
245
+ return
246
+ self.app.push_screen("login")
247
+ return
248
+
249
+ if cmd == "/logout":
250
+ self.app.action_logout()
251
+ return
252
+
242
253
  if cmd == "/metadata":
243
254
  if not args:
244
255
  # Show effective metadata (defaults + overrides)
@@ -27,6 +27,8 @@ _SLASH_COMMANDS = [
27
27
  "/upload ",
28
28
  "/files",
29
29
  "/clear-files",
30
+ "/login",
31
+ "/logout",
30
32
  "/metadata",
31
33
  "/metadata set ",
32
34
  "/metadata remove ",