codicent-cli 0.8.0__tar.gz → 0.8.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codicent-cli
3
- Version: 0.8.0
3
+ Version: 0.8.2
4
4
  Summary: Command-line interface for the Codicent API
5
5
  Home-page: https://github.com/izaxon/codicent-cli
6
6
  Author: Johan Isaksson
@@ -28,6 +28,9 @@ Requires-Dist: codicent-py
28
28
  Requires-Dist: prompt_toolkit
29
29
  Requires-Dist: requests
30
30
  Requires-Dist: pyyaml
31
+ Requires-Dist: packaging
32
+ Requires-Dist: pyte
33
+ Requires-Dist: websocket-client
31
34
  Dynamic: author
32
35
  Dynamic: author-email
33
36
  Dynamic: classifier
@@ -106,6 +106,23 @@ WRAP (AI CLI Remote Bridge):
106
106
  codicent wrap --poll-interval 5 codex
107
107
  codicent wrap --binary /usr/local/bin/claude claude --model claude-opus-4-5
108
108
 
109
+ TERMINAL (Web Terminal Host):
110
+ terminal Start a local shell and host it as a Codicent terminal session.
111
+ Browser users can connect to it via the Codicent web UI.
112
+
113
+ [shell] Shell to run (default: $SHELL or bash)
114
+
115
+ How it works:
116
+ 1. Spawns the shell locally using a PTY
117
+ 2. Connects to the Codicent server as a "terminal host" via WebSocket
118
+ 3. Relays shell I/O to any browser that connects to the session
119
+ 4. Session output is logged as #terminal-session in Codicent when done
120
+
121
+ Examples:
122
+ codicent terminal
123
+ codicent terminal bash
124
+ codicent terminal zsh
125
+
109
126
  EXAMPLES:
110
127
  codicent auth
111
128
  codicent "What is Python?"
@@ -114,8 +131,15 @@ EXAMPLES:
114
131
  codicent upload document.txt *.log
115
132
  codicent init --template koi-studio
116
133
  codicent wrap claude
134
+ codicent terminal
135
+ codicent update
136
+ codicent update --install
117
137
  echo "Help me debug this" | codicent
118
138
 
139
+ UPDATE:
140
+ update Check if a newer version is available on PyPI
141
+ --install Also upgrade immediately via pip
142
+
119
143
  ENVIRONMENT:
120
144
  CODICENT_TOKEN Your Codicent API token (fallback if no cached auth)
121
145
 
@@ -123,9 +147,11 @@ For more information, visit: https://github.com/izaxon/codicent-cli
123
147
  """
124
148
  print(help_text.strip())
125
149
 
150
+ CURRENT_VERSION = "0.8.1"
151
+
126
152
  def show_version():
127
153
  """Display version information."""
128
- print("Codicent CLI v0.8.0")
154
+ print(f"Codicent CLI v{CURRENT_VERSION}")
129
155
 
130
156
  def validate_input(question):
131
157
  """Validate user input."""
@@ -200,6 +226,54 @@ def main():
200
226
  console.print("[dim]Run 'codicent auth' to authenticate[/dim]")
201
227
  return 1
202
228
 
229
+ # Handle update command
230
+ if len(sys.argv) > 1 and sys.argv[1] == "update":
231
+ do_install = "--install" in sys.argv
232
+ try:
233
+ resp = requests.get(
234
+ "https://pypi.org/pypi/codicent-cli/json",
235
+ timeout=10,
236
+ )
237
+ resp.raise_for_status()
238
+ latest = resp.json()["info"]["version"]
239
+ except Exception as e:
240
+ console.print(f"[red]❌ Could not check PyPI: {e}[/red]")
241
+ return 1
242
+
243
+ from packaging.version import Version
244
+
245
+ # Detect whether we're running inside a pipx-managed environment
246
+ is_pipx = "pipx" in sys.executable.replace("\\", "/")
247
+
248
+ if Version(latest) > Version(CURRENT_VERSION):
249
+ console.print(
250
+ f"[yellow]⬆ New version available:[/yellow] "
251
+ f"[bold]{latest}[/bold] (you have {CURRENT_VERSION})"
252
+ )
253
+ if do_install:
254
+ if is_pipx:
255
+ console.print("[dim]Running pipx upgrade codicent-cli...[/dim]")
256
+ result = subprocess.run(
257
+ ["pipx", "upgrade", "codicent-cli"],
258
+ capture_output=False,
259
+ )
260
+ else:
261
+ console.print("[dim]Running pip install --upgrade codicent-cli...[/dim]")
262
+ result = subprocess.run(
263
+ [sys.executable, "-m", "pip", "install", "--upgrade", "codicent-cli"],
264
+ capture_output=False,
265
+ )
266
+ return result.returncode
267
+ else:
268
+ upgrade_cmd = "pipx upgrade codicent-cli" if is_pipx else "pip install --upgrade codicent-cli"
269
+ console.print(
270
+ f"[dim]Run [bold]codicent update --install[/bold] to upgrade, "
271
+ f"or [bold]{upgrade_cmd}[/bold][/dim]"
272
+ )
273
+ else:
274
+ console.print(f"[green]✅ Already up to date (v{CURRENT_VERSION})[/green]")
275
+ return 0
276
+
203
277
  # Handle upload command
204
278
  if len(sys.argv) > 1 and sys.argv[1] == "upload":
205
279
  if len(sys.argv) < 3:
@@ -499,6 +573,37 @@ def main():
499
573
  project=wrap_project,
500
574
  )
501
575
 
576
+ # Handle terminal command
577
+ if len(sys.argv) > 1 and sys.argv[1] == "terminal":
578
+ from terminal import run_terminal_host
579
+
580
+ term_token = auth.get_cached_token()
581
+ if not term_token:
582
+ term_token = os.getenv("CODICENT_TOKEN")
583
+ if not term_token:
584
+ console.print("[red]❌ No authentication found.[/red]")
585
+ console.print("[dim]Run 'codicent auth' to authenticate, or set CODICENT_TOKEN[/dim]")
586
+ return 1
587
+
588
+ try:
589
+ term_payload = term_token.split(".")[1]
590
+ term_payload += '=' * (-len(term_payload) % 4)
591
+ term_jwt = json.loads(base64.urlsafe_b64decode(term_payload).decode('utf-8'))
592
+ term_project = term_jwt["project"]
593
+ term_base_url = term_jwt.get("baseUrl", "https://codicent.com/")
594
+ except Exception as e:
595
+ console.print(f"[red]❌ Failed to extract project from token: {e}[/red]")
596
+ return 1
597
+
598
+ shell_arg = sys.argv[2] if len(sys.argv) > 2 else None
599
+ run_terminal_host(
600
+ token=term_token,
601
+ base_url=term_base_url,
602
+ project=term_project,
603
+ shell=shell_arg,
604
+ )
605
+ return 0
606
+
502
607
  # Get authentication token
503
608
  token = auth.get_cached_token()
504
609
  if not token:
@@ -727,7 +832,7 @@ def main():
727
832
 
728
833
  header = Text()
729
834
  header.append("Codicent CLI", style="bold bright_cyan")
730
- header.append(" v0.8.0\n", style="bright_cyan")
835
+ header.append(f" v{CURRENT_VERSION}\n", style="bright_cyan")
731
836
  header.append("Type your questions or use Ctrl+C to exit.\n", style="dim")
732
837
  header.append("\n")
733
838
  header.append("Tip: ", style="bold dim")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codicent-cli
3
- Version: 0.8.0
3
+ Version: 0.8.2
4
4
  Summary: Command-line interface for the Codicent API
5
5
  Home-page: https://github.com/izaxon/codicent-cli
6
6
  Author: Johan Isaksson
@@ -28,6 +28,9 @@ Requires-Dist: codicent-py
28
28
  Requires-Dist: prompt_toolkit
29
29
  Requires-Dist: requests
30
30
  Requires-Dist: pyyaml
31
+ Requires-Dist: packaging
32
+ Requires-Dist: pyte
33
+ Requires-Dist: websocket-client
31
34
  Dynamic: author
32
35
  Dynamic: author-email
33
36
  Dynamic: classifier
@@ -3,6 +3,7 @@ README.md
3
3
  app.py
4
4
  auth.py
5
5
  setup.py
6
+ terminal.py
6
7
  wrap.py
7
8
  codicent_cli.egg-info/PKG-INFO
8
9
  codicent_cli.egg-info/SOURCES.txt
@@ -3,3 +3,6 @@ codicent-py
3
3
  prompt_toolkit
4
4
  requests
5
5
  pyyaml
6
+ packaging
7
+ pyte
8
+ websocket-client
@@ -1,4 +1,5 @@
1
1
  app
2
2
  auth
3
3
  templates
4
+ terminal
4
5
  wrap
@@ -9,7 +9,7 @@ except FileNotFoundError:
9
9
 
10
10
  setup(
11
11
  name='codicent-cli',
12
- version='0.8.0',
12
+ version='0.8.2',
13
13
  author='Johan Isaksson',
14
14
  author_email='johan@izaxon.com',
15
15
  description='Command-line interface for the Codicent API',
@@ -35,7 +35,7 @@ setup(
35
35
  'Programming Language :: Python :: 3.12',
36
36
  ],
37
37
  keywords='codicent cli api chat ai',
38
- py_modules=['app', 'auth', 'wrap'],
38
+ py_modules=['app', 'auth', 'wrap', 'terminal'],
39
39
  packages=['templates'],
40
40
  package_data={'templates': ['*.json']},
41
41
  python_requires='>=3.6',
@@ -45,6 +45,9 @@ setup(
45
45
  'prompt_toolkit',
46
46
  'requests',
47
47
  'pyyaml',
48
+ 'packaging',
49
+ 'pyte',
50
+ 'websocket-client',
48
51
  ],
49
52
  entry_points={
50
53
  'console_scripts': [
@@ -0,0 +1,247 @@
1
+ """
2
+ codicent terminal — web terminal host
3
+
4
+ Spawns a local shell process and registers it as a terminal session on the
5
+ Codicent server. Browser users can then connect to it via the web UI.
6
+
7
+ Usage:
8
+ codicent terminal [shell] # shell defaults to $SHELL or bash
9
+
10
+ The CLI tool:
11
+ 1. Spawns the shell locally via PTY (or subprocess on Windows)
12
+ 2. Connects to /terminal/host on the Codicent server via WebSocket
13
+ 3. Relays PTY I/O <-> WebSocket in real time
14
+ 4. Session output is logged to Codicent as #terminal-session when it ends
15
+ """
16
+
17
+ import os
18
+ import sys
19
+ import threading
20
+ import signal
21
+ import uuid
22
+ import logging
23
+
24
+ try:
25
+ import websocket as websocket_client # websocket-client library
26
+ _HAS_WS = True
27
+ except ImportError:
28
+ _HAS_WS = False
29
+
30
+ # PTY support (Linux/macOS/WSL); falls back to subprocess on Windows
31
+ try:
32
+ import pty
33
+ import termios
34
+ import fcntl
35
+ import struct
36
+ _HAS_PTY = True
37
+ except ImportError:
38
+ _HAS_PTY = False
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ def _get_terminal_size():
44
+ try:
45
+ import shutil
46
+ sz = shutil.get_terminal_size()
47
+ return sz.columns, sz.lines
48
+ except Exception:
49
+ return 80, 24
50
+
51
+
52
+ def run_terminal_host(token: str, base_url: str, project: str, shell: str = None):
53
+ """Start a local shell and host it as a Codicent terminal session."""
54
+
55
+ if not _HAS_WS:
56
+ print("[error] websocket-client is required: pip install websocket-client", file=sys.stderr)
57
+ sys.exit(1)
58
+
59
+ shell = shell or os.environ.get("SHELL", "bash")
60
+ session_id = uuid.uuid4().hex
61
+
62
+ # Build WebSocket URL
63
+ ws_base = base_url.rstrip("/").replace("https://", "wss://").replace("http://", "ws://")
64
+ ws_url = (
65
+ f"{ws_base}/terminal/host"
66
+ f"?project={project}"
67
+ f"&command={os.path.basename(shell)}"
68
+ f"&sessionId={session_id}"
69
+ f"&access_token={token}"
70
+ )
71
+
72
+ print(f"[codicent terminal] project=@{project} shell={shell} session={session_id}")
73
+ print(f"[codicent terminal] Connecting to {base_url} ...")
74
+
75
+ stop_event = threading.Event()
76
+ ws_app = None
77
+
78
+ # ── PTY-based (POSIX) ─────────────────────────────────────────────
79
+ if _HAS_PTY:
80
+ cols, rows = _get_terminal_size()
81
+
82
+ # Fork a PTY
83
+ pid, fd = pty.fork()
84
+
85
+ if pid == 0:
86
+ # Child: exec the shell
87
+ env = os.environ.copy()
88
+ env["TERM"] = "xterm-256color"
89
+ os.execvpe(shell, [shell, "-i"], env)
90
+ sys.exit(1)
91
+
92
+ # Parent: relay PTY <-> WebSocket
93
+
94
+ def _set_pty_size(c, r):
95
+ try:
96
+ import struct, termios, fcntl
97
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", r, c, 0, 0))
98
+ except Exception:
99
+ pass
100
+
101
+ _set_pty_size(cols, rows)
102
+
103
+ def on_open(ws):
104
+ print("[codicent terminal] Connected. Shell is ready in the browser.")
105
+ # Send initial resize
106
+ import json
107
+ ws.send(json.dumps({"type": "resize", "cols": cols, "rows": rows}))
108
+
109
+ def on_message(ws, message):
110
+ # Incoming from browser: either resize JSON or raw stdin
111
+ try:
112
+ import json
113
+ obj = json.loads(message)
114
+ if obj.get("type") == "resize":
115
+ _set_pty_size(obj.get("cols", 80), obj.get("rows", 24))
116
+ return
117
+ except (ValueError, KeyError):
118
+ pass
119
+ # Write to PTY stdin
120
+ try:
121
+ os.write(fd, message.encode() if isinstance(message, str) else message)
122
+ except OSError:
123
+ ws.close()
124
+
125
+ def on_error(ws, error):
126
+ print(f"[codicent terminal] WebSocket error: {error}", file=sys.stderr)
127
+
128
+ def on_close(ws, code, reason):
129
+ print(f"[codicent terminal] Disconnected ({code}). Session ended.")
130
+ stop_event.set()
131
+
132
+ ws_app = websocket_client.WebSocketApp(
133
+ ws_url,
134
+ on_open=on_open,
135
+ on_message=on_message,
136
+ on_error=on_error,
137
+ on_close=on_close,
138
+ )
139
+
140
+ # Thread: read PTY output and forward to WebSocket
141
+ def _pty_reader():
142
+ import select as sel
143
+ while not stop_event.is_set():
144
+ try:
145
+ r, _, _ = sel.select([fd], [], [], 0.05)
146
+ if not r:
147
+ continue
148
+ data = os.read(fd, 4096)
149
+ if not data:
150
+ break
151
+ if ws_app.sock and ws_app.sock.connected:
152
+ ws_app.send(data.decode("utf-8", errors="replace"))
153
+ except OSError:
154
+ break
155
+ except Exception as e:
156
+ logger.debug(f"PTY reader: {e}")
157
+ stop_event.set()
158
+ if ws_app.sock:
159
+ ws_app.close()
160
+
161
+ t = threading.Thread(target=_pty_reader, daemon=True)
162
+ t.start()
163
+
164
+ def _sigchld_handler(signum, frame):
165
+ stop_event.set()
166
+
167
+ signal.signal(signal.SIGCHLD, _sigchld_handler)
168
+
169
+ ws_app.run_forever()
170
+
171
+ # Clean up child process
172
+ try:
173
+ os.kill(pid, signal.SIGTERM)
174
+ except ProcessLookupError:
175
+ pass
176
+
177
+ # ── Subprocess-based (Windows fallback) ──────────────────────────
178
+ else:
179
+ import subprocess
180
+ import queue
181
+
182
+ proc = subprocess.Popen(
183
+ [shell],
184
+ stdin=subprocess.PIPE,
185
+ stdout=subprocess.PIPE,
186
+ stderr=subprocess.STDOUT,
187
+ )
188
+
189
+ output_queue: queue.Queue = queue.Queue()
190
+
191
+ def _stdout_reader():
192
+ while True:
193
+ chunk = proc.stdout.read(4096)
194
+ if not chunk:
195
+ break
196
+ output_queue.put(chunk.decode("utf-8", errors="replace"))
197
+ stop_event.set()
198
+
199
+ threading.Thread(target=_stdout_reader, daemon=True).start()
200
+
201
+ def on_open(ws):
202
+ print("[codicent terminal] Connected.")
203
+
204
+ # Thread: drain queue -> WebSocket
205
+ def _sender():
206
+ while not stop_event.is_set():
207
+ try:
208
+ text = output_queue.get(timeout=0.05)
209
+ if ws.sock and ws.sock.connected:
210
+ ws.send(text)
211
+ except queue.Empty:
212
+ continue
213
+ except Exception:
214
+ break
215
+
216
+ threading.Thread(target=_sender, daemon=True).start()
217
+
218
+ def on_message(ws, message):
219
+ try:
220
+ import json
221
+ obj = json.loads(message)
222
+ if obj.get("type") == "resize":
223
+ return # resize not applicable without PTY
224
+ except (ValueError, KeyError):
225
+ pass
226
+ try:
227
+ proc.stdin.write((message if isinstance(message, bytes) else message.encode()))
228
+ proc.stdin.flush()
229
+ except OSError:
230
+ ws.close()
231
+
232
+ def on_error(ws, error):
233
+ print(f"[codicent terminal] WebSocket error: {error}", file=sys.stderr)
234
+
235
+ def on_close(ws, code, reason):
236
+ print("[codicent terminal] Disconnected.")
237
+ stop_event.set()
238
+ proc.terminate()
239
+
240
+ ws_app = websocket_client.WebSocketApp(
241
+ ws_url,
242
+ on_open=on_open,
243
+ on_message=on_message,
244
+ on_error=on_error,
245
+ on_close=on_close,
246
+ )
247
+ ws_app.run_forever()