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.
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/PKG-INFO +2 -1
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/pyproject.toml +2 -1
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_cli/cli.py +2 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_runtime/main.py +264 -6
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_tui/main.py +4 -3
- kiwi_code-0.0.8/src/kiwi_tui/screens/__init__.py +7 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_tui/screens/dashboard.py +11 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_tui/widgets.py +2 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/uv.lock +618 -567
- kiwi_code-0.0.7/src/kiwi_tui/screens/__init__.py +0 -9
- kiwi_code-0.0.7/src/kiwi_tui/screens/actions.py +0 -271
- kiwi_code-0.0.7/src/kiwi_tui/screens/autobots.py +0 -216
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/.gitignore +0 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/.python-version +0 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/CLAUDE.md +0 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/Makefile +0 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/README.md +0 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_cli/config.py +0 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.7 → kiwi_code-0.0.8}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {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.
|
|
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.
|
|
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" }
|
|
@@ -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
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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,
|
|
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")
|
|
@@ -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)
|