portacode 0.1.0__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.
File without changes
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: portacode
3
+ Version: 0.1.0
4
+ Summary: Portacode CLI client and SDK
5
+ Home-page: https://github.com/portacode/portacode
6
+ Author: Meena Erian
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: click>=8.0
13
+ Requires-Dist: platformdirs>=3.0
14
+ Requires-Dist: cryptography>=41.0
15
+ Requires-Dist: websockets>=12.0
16
+ Requires-Dist: pyperclip>=1.8
17
+ Requires-Dist: psutil>=5.9
18
+ Requires-Dist: pyte>=0.8
19
+ Requires-Dist: pywinpty>=2.0; platform_system == "Windows"
20
+ Provides-Extra: dev
21
+ Requires-Dist: black; extra == "dev"
22
+ Requires-Dist: flake8; extra == "dev"
23
+ Requires-Dist: pytest; extra == "dev"
24
+ Dynamic: author
25
+ Dynamic: classifier
26
+ Dynamic: description
27
+ Dynamic: description-content-type
28
+ Dynamic: home-page
29
+ Dynamic: provides-extra
30
+ Dynamic: requires-dist
31
+ Dynamic: requires-python
32
+ Dynamic: summary
33
+
34
+ # Portacode
35
+
36
+ Portacode is a modular Python package that provides a command-line interface for connecting your local machine to the Portacode cloud gateway.
37
+
38
+ ```
39
+ $ pip install portacode
40
+ $ portacode connect
41
+ ```
42
+
43
+ The first release only ships the `connect` command which:
44
+
45
+ 1. Creates an RSA public/private key-pair (if not already present) in a platform-specific data directory.
46
+ 2. Guides you through adding the public key to your Portacode account.
47
+ 3. Establishes and maintains a resilient WebSocket connection to `wss://portacode.com/gateway`.
48
+
49
+ Future releases will add more sub-commands and build upon the multiplexing channel infrastructure already included in this version.
50
+
51
+ ## Project layout
52
+
53
+ ```
54
+ portacode/ ‑ Top-level package
55
+ ├── cli.py ‑ Click-based CLI entry-point
56
+ ├── data.py ‑ Cross-platform data-directory helpers
57
+ ├── keypair.py ‑ RSA key generation & storage
58
+ ├── connection/ ‑ Networking & multiplexing logic
59
+ │ ├── client.py ‑ WebSocket client with auto-reconnect
60
+ │ └── multiplex.py- Virtual channel multiplexer
61
+ └── …
62
+ ```
63
+
64
+ See the README files inside each sub-module for more details.
@@ -0,0 +1,31 @@
1
+ # Portacode
2
+
3
+ Portacode is a modular Python package that provides a command-line interface for connecting your local machine to the Portacode cloud gateway.
4
+
5
+ ```
6
+ $ pip install portacode
7
+ $ portacode connect
8
+ ```
9
+
10
+ The first release only ships the `connect` command which:
11
+
12
+ 1. Creates an RSA public/private key-pair (if not already present) in a platform-specific data directory.
13
+ 2. Guides you through adding the public key to your Portacode account.
14
+ 3. Establishes and maintains a resilient WebSocket connection to `wss://portacode.com/gateway`.
15
+
16
+ Future releases will add more sub-commands and build upon the multiplexing channel infrastructure already included in this version.
17
+
18
+ ## Project layout
19
+
20
+ ```
21
+ portacode/ ‑ Top-level package
22
+ ├── cli.py ‑ Click-based CLI entry-point
23
+ ├── data.py ‑ Cross-platform data-directory helpers
24
+ ├── keypair.py ‑ RSA key generation & storage
25
+ ├── connection/ ‑ Networking & multiplexing logic
26
+ │ ├── client.py ‑ WebSocket client with auto-reconnect
27
+ │ └── multiplex.py- Virtual channel multiplexer
28
+ └── …
29
+ ```
30
+
31
+ See the README files inside each sub-module for more details.
@@ -0,0 +1,16 @@
1
+ """Portacode SDK & CLI.
2
+
3
+ This package exposes a top-level `cli` entry-point for interacting with the
4
+ Portacode gateway and also provides programmatic helpers for managing user
5
+ configuration and network connections.
6
+ """
7
+
8
+ from importlib.metadata import PackageNotFoundError, version
9
+
10
+ try:
11
+ __version__: str = version("portacode") # type: ignore[arg-type]
12
+ except PackageNotFoundError: # pragma: no cover
13
+ # Package is not installed – most likely running from source tree.
14
+ __version__ = "0.0.0.dev0"
15
+
16
+ __all__: list[str] = ["__version__"]
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import sys
6
+ from multiprocessing import Process
7
+ from pathlib import Path
8
+ import signal
9
+ import json
10
+
11
+ import click
12
+ import pyperclip
13
+
14
+ from .data import get_pid_file, is_process_running
15
+ from .keypair import get_or_create_keypair, fingerprint_public_key
16
+ from .connection.client import ConnectionManager, run_until_interrupt
17
+
18
+ GATEWAY_URL = "wss://portacode.com/gateway"
19
+ GATEWAY_ENV = "PORTACODE_GATEWAY"
20
+
21
+
22
+ @click.group()
23
+ def cli() -> None:
24
+ """Portacode command-line interface."""
25
+
26
+
27
+ @cli.command()
28
+ @click.option("--gateway", "gateway", "-g", help="Gateway websocket URL (overrides env/ default)")
29
+ @click.option("--detach", "detach", "-d", is_flag=True, help="Run connection in background")
30
+ def connect(gateway: str | None, detach: bool) -> None: # noqa: D401 – Click callback
31
+ """Connect this machine to Portacode gateway."""
32
+
33
+ # 1. Ensure only a single connection per user
34
+ pid_file = get_pid_file()
35
+ if pid_file.exists():
36
+ try:
37
+ other_pid = int(pid_file.read_text())
38
+ except ValueError:
39
+ other_pid = None
40
+
41
+ if other_pid and is_process_running(other_pid):
42
+ click.echo(
43
+ click.style(
44
+ f"Another portacode connection (PID {other_pid}) is active.", fg="yellow"
45
+ )
46
+ )
47
+ if click.confirm("Terminate the existing connection?", default=False):
48
+ _terminate_process(other_pid)
49
+ pid_file.unlink(missing_ok=True)
50
+ else:
51
+ click.echo("Aborting.")
52
+ sys.exit(1)
53
+ else:
54
+ # Stale pidfile
55
+ pid_file.unlink(missing_ok=True)
56
+
57
+ # Determine gateway URL
58
+ target_gateway = gateway or os.getenv(GATEWAY_ENV) or GATEWAY_URL
59
+
60
+ # 2. Load or create keypair
61
+ keypair = get_or_create_keypair()
62
+ fingerprint = fingerprint_public_key(keypair.public_key_pem)
63
+
64
+ pubkey_b64 = keypair.public_key_der_b64()
65
+ click.echo()
66
+ click.echo(click.style("✔ Generated / loaded RSA keypair", fg="green"))
67
+
68
+ click.echo()
69
+ click.echo(click.style("Public key (copy & paste to your Portacode account):", bold=True))
70
+ click.echo("-" * 60)
71
+ click.echo(pubkey_b64)
72
+ click.echo("-" * 60)
73
+ try:
74
+ pyperclip.copy(pubkey_b64)
75
+ click.echo(click.style("(Copied to clipboard!)", fg="cyan"))
76
+ except Exception:
77
+ click.echo(click.style("(Could not copy to clipboard. Please copy manually.)", fg="yellow"))
78
+ click.echo(f"Fingerprint: {fingerprint}")
79
+ click.echo()
80
+ click.prompt("Press <enter> once the key is added", default="", show_default=False)
81
+
82
+ # 3. Start connection manager
83
+ if detach:
84
+ click.echo("Establishing connection in the background…")
85
+ p = Process(target=_run_connection_forever, args=(target_gateway, keypair, pid_file))
86
+ p.daemon = False # We want it to live beyond parent process on POSIX; on Windows it's anyway independent
87
+ p.start()
88
+ click.echo(click.style(f"Background process PID: {p.pid}", fg="green"))
89
+ return
90
+
91
+ # Foreground mode → run in current event-loop
92
+ pid_file.write_text(str(os.getpid()))
93
+
94
+ async def _main() -> None:
95
+ mgr = ConnectionManager(target_gateway, keypair)
96
+ await run_until_interrupt(mgr)
97
+
98
+ try:
99
+ asyncio.run(_main())
100
+ finally:
101
+ pid_file.unlink(missing_ok=True)
102
+
103
+
104
+ def _run_connection_forever(url: str, keypair, pid_file: Path):
105
+ """Entry-point for detached background process."""
106
+ try:
107
+ pid_file.write_text(str(os.getpid()))
108
+
109
+ async def _main() -> None:
110
+ mgr = ConnectionManager(url, keypair)
111
+ await run_until_interrupt(mgr)
112
+
113
+ asyncio.run(_main())
114
+ finally:
115
+ pid_file.unlink(missing_ok=True)
116
+
117
+
118
+ def _terminate_process(pid: int):
119
+ if sys.platform.startswith("win"):
120
+ import ctypes
121
+ PROCESS_TERMINATE = 1
122
+ handle = ctypes.windll.kernel32.OpenProcess(PROCESS_TERMINATE, False, pid)
123
+ if handle:
124
+ ctypes.windll.kernel32.TerminateProcess(handle, -1)
125
+ ctypes.windll.kernel32.CloseHandle(handle)
126
+ else:
127
+ try:
128
+ os.kill(pid, signal.SIGTERM) # type: ignore[name-defined]
129
+ except OSError:
130
+ pass
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Debug helpers – NOT intended for production use
135
+ # ---------------------------------------------------------------------------
136
+
137
+
138
+ @cli.command()
139
+ @click.argument("message", nargs=1)
140
+ @click.option("--gateway", "gateway", "-g", help="Gateway websocket URL (overrides env/ default)")
141
+ def send_control(message: str, gateway: str | None) -> None: # noqa: D401 – Click callback
142
+ """Send a raw JSON *control* message on channel 0 and print replies.
143
+
144
+ Example::
145
+
146
+ portacode send-control '{"cmd": "system_info"}'
147
+
148
+ The command opens a short-lived connection, authenticates, sends the
149
+ control message and waits up to 5 seconds for responses which are then
150
+ pretty-printed to stdout.
151
+ """
152
+
153
+ try:
154
+ payload = json.loads(message)
155
+ except json.JSONDecodeError as exc:
156
+ raise click.BadParameter(f"Invalid JSON: {exc}") from exc
157
+
158
+ target_gateway = gateway or os.getenv(GATEWAY_ENV) or GATEWAY_URL
159
+
160
+ async def _run() -> None:
161
+ keypair = get_or_create_keypair()
162
+ mgr = ConnectionManager(target_gateway, keypair)
163
+ await mgr.start()
164
+
165
+ # Wait until mux is available & authenticated (rudimentary – 2s timeout)
166
+ for _ in range(20):
167
+ if mgr.mux is not None:
168
+ break
169
+ await asyncio.sleep(0.1)
170
+ if mgr.mux is None:
171
+ click.echo("Failed to initialise connection – aborting.")
172
+ await mgr.stop()
173
+ return
174
+
175
+ # Send control frame on channel 0
176
+ ctl = mgr.mux.get_channel(0)
177
+ await ctl.send(payload)
178
+
179
+ # Print replies for a short time
180
+ try:
181
+ with click.progressbar(length=50, label="Waiting for replies") as bar:
182
+ for _ in range(50):
183
+ try:
184
+ reply = await asyncio.wait_for(ctl.recv(), timeout=0.1)
185
+ click.echo(click.style("< " + json.dumps(reply, indent=2), fg="cyan"))
186
+ except asyncio.TimeoutError:
187
+ pass
188
+ bar.update(1)
189
+ finally:
190
+ await mgr.stop()
191
+
192
+ asyncio.run(_run())
@@ -0,0 +1,7 @@
1
+ """Networking primitives for Portacode.
2
+
3
+ The :pymod:`portacode.connection` package provides a resilient WebSocket client
4
+ with built-in multiplexer/demultiplexer for arbitrary virtual channels.
5
+ """
6
+
7
+ from .client import ConnectionManager # noqa: F401
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import signal
6
+ from pathlib import Path
7
+ from typing import Optional
8
+ import json
9
+ import base64
10
+ import sys
11
+
12
+ import websockets
13
+ from websockets import WebSocketClientProtocol
14
+
15
+ from ..keypair import KeyPair
16
+ from .multiplex import Multiplexer
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class ConnectionManager:
22
+ """Maintain a persistent connection to the Portacode gateway.
23
+
24
+ Parameters
25
+ ----------
26
+ gateway_url: str
27
+ WebSocket URL, e.g. ``wss://portacode.com/gateway``
28
+ keypair: KeyPair
29
+ User's public/private keypair used for authentication.
30
+ reconnect_delay: float
31
+ Seconds to wait before attempting to reconnect after an unexpected drop.
32
+ max_retries: int
33
+ Maximum number of reconnect attempts before giving up.
34
+ """
35
+
36
+ def __init__(self, gateway_url: str, keypair: KeyPair, reconnect_delay: float = 1.0, max_retries: int = 10):
37
+ self.gateway_url = gateway_url
38
+ self.keypair = keypair
39
+ self.reconnect_delay = reconnect_delay
40
+ self.max_retries = max_retries
41
+
42
+ self._task: Optional[asyncio.Task[None]] = None
43
+ self._stop_event = asyncio.Event()
44
+
45
+ self.websocket: Optional[WebSocketClientProtocol] = None
46
+ self.mux: Optional[Multiplexer] = None
47
+
48
+ async def start(self) -> None:
49
+ """Start the background task that maintains the connection."""
50
+ if self._task is not None:
51
+ raise RuntimeError("Connection already running")
52
+ self._task = asyncio.create_task(self._runner())
53
+
54
+ async def stop(self) -> None:
55
+ """Request graceful shutdown."""
56
+ self._stop_event.set()
57
+ if self._task is not None:
58
+ await self._task
59
+
60
+ async def _runner(self) -> None:
61
+ attempt = 0
62
+ while not self._stop_event.is_set():
63
+ try:
64
+ if attempt:
65
+ delay = min(self.reconnect_delay * 2 ** (attempt - 1), 30)
66
+ logger.warning("Reconnecting in %.1f s (attempt %d/%d)…", delay, attempt, self.max_retries)
67
+ await asyncio.sleep(delay)
68
+ logger.info("Connecting to gateway at %s", self.gateway_url)
69
+ async with websockets.connect(self.gateway_url) as ws:
70
+ # Reset attempt counter after successful connection
71
+ attempt = 0
72
+
73
+ self.websocket = ws
74
+ self.mux = Multiplexer(self.websocket.send)
75
+
76
+ # ------------------------------------------------------------------
77
+ # Initialise terminal/control management (channel 0)
78
+ # ------------------------------------------------------------------
79
+ try:
80
+ from .terminal import TerminalManager # local import to avoid heavy deps on startup
81
+ self._terminal_manager = TerminalManager(self.mux) # noqa: pylint=attribute-defined-outside-init
82
+ except Exception as exc:
83
+ logger.warning("TerminalManager unavailable: %s", exc)
84
+
85
+ # Authenticate – abort loop on auth failures
86
+ await self._authenticate()
87
+
88
+ # Start main receive loop until closed or stop requested
89
+ await self._listen()
90
+ except (OSError, websockets.WebSocketException) as exc:
91
+ attempt += 1
92
+ logger.warning("Connection error: %s", exc)
93
+ if attempt > self.max_retries:
94
+ logger.error("Maximum reconnect attempts reached, giving up.")
95
+ break
96
+ except Exception as exc:
97
+ logger.exception("Fatal error in connection manager: %s", exc)
98
+ break
99
+
100
+ async def _authenticate(self) -> None:
101
+ """Challenge-response authentication with the gateway using base64 DER public key."""
102
+ assert self.websocket is not None, "WebSocket not ready"
103
+ # Step 1: Send public key as base64 DER
104
+ await self.websocket.send(self.keypair.public_key_der_b64())
105
+ # Step 2: Receive challenge or error
106
+ response = await self.websocket.recv()
107
+ try:
108
+ data = json.loads(response)
109
+ challenge = data["challenge"]
110
+ except Exception:
111
+ # Not a challenge, must be an error
112
+ raise RuntimeError(f"Gateway rejected authentication: {response}")
113
+ # Step 3: Sign challenge and send signature
114
+ signature = self.keypair.sign_challenge(challenge)
115
+ signature_b64 = base64.b64encode(signature).decode()
116
+ await self.websocket.send(json.dumps({"signature": signature_b64}))
117
+ # Step 4: Receive final status
118
+ status = await self.websocket.recv()
119
+ if status != "ok":
120
+ raise RuntimeError(f"Gateway rejected authentication: {status}")
121
+ # Print success message in green and show close instructions
122
+ try:
123
+ import click
124
+ click.echo(click.style("Successfully authenticated with the gateway.", fg="green"))
125
+ if sys.platform == "darwin":
126
+ click.echo(click.style("Press Cmd+C to close the connection.", fg="cyan"))
127
+ else:
128
+ click.echo(click.style("Press Ctrl+C to close the connection.", fg="cyan"))
129
+ except ImportError:
130
+ print("Successfully authenticated with the gateway.")
131
+ if sys.platform == "darwin":
132
+ print("Press Cmd+C to close the connection.")
133
+ else:
134
+ print("Press Ctrl+C to close the connection.")
135
+
136
+ async def _listen(self) -> None:
137
+ assert self.websocket is not None, "WebSocket not ready"
138
+ while not self._stop_event.is_set():
139
+ try:
140
+ message = await asyncio.wait_for(self.websocket.recv(), timeout=1.0)
141
+ if self.mux:
142
+ await self.mux.on_raw_message(message)
143
+ except asyncio.TimeoutError:
144
+ continue
145
+ except websockets.ConnectionClosed:
146
+ break
147
+ # Exit listen loop, trigger closure
148
+ try:
149
+ await self.websocket.close()
150
+ except Exception:
151
+ pass
152
+
153
+
154
+ async def run_until_interrupt(manager: ConnectionManager) -> None:
155
+ stop_event = asyncio.Event()
156
+
157
+ def _stop(*_):
158
+ # TODO: Add cleanup logic here (e.g., close sockets, remove PID files, flush logs)
159
+ stop_event.set()
160
+
161
+ # Register SIGTERM handler (works on Unix, ignored on Windows)
162
+ try:
163
+ signal.signal(signal.SIGTERM, _stop)
164
+ except (AttributeError, ValueError):
165
+ pass # Not available on some platforms
166
+
167
+ # Register SIGINT handler (Ctrl+C)
168
+ try:
169
+ signal.signal(signal.SIGINT, _stop)
170
+ except (AttributeError, ValueError):
171
+ pass
172
+
173
+ await manager.start()
174
+ try:
175
+ await stop_event.wait()
176
+ except KeyboardInterrupt:
177
+ # TODO: Add cleanup logic here (e.g., close sockets, remove PID files, flush logs)
178
+ pass
179
+ await manager.stop()
180
+ # TODO: Add any final cleanup logic here (e.g., remove PID files, flush logs)
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ from asyncio import Queue
7
+ from typing import Any, Dict
8
+
9
+ __all__ = ["Multiplexer", "Channel"]
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class Channel:
15
+ """Represents a virtual duplex channel over a single WebSocket connection."""
16
+
17
+ def __init__(self, channel_id: int, multiplexer: "Multiplexer"):
18
+ self.id = channel_id
19
+ self._mux = multiplexer
20
+ self._incoming: "Queue[Any]" = asyncio.Queue()
21
+
22
+ async def send(self, payload: Any) -> None:
23
+ await self._mux._send_on_channel(self.id, payload)
24
+
25
+ async def recv(self) -> Any:
26
+ return await self._incoming.get()
27
+
28
+ # Internal API
29
+ async def _deliver(self, payload: Any) -> None:
30
+ await self._incoming.put(payload)
31
+
32
+
33
+ class Multiplexer:
34
+ """Very small message-based multiplexer.
35
+
36
+ Messages exchanged over the WebSocket are JSON objects with two keys:
37
+
38
+ * ``channel`` – integer channel id.
39
+ * ``payload`` – arbitrary JSON-serialisable object.
40
+ """
41
+
42
+ def __init__(self, send_func):
43
+ self._send_func = send_func # async function (str) -> None
44
+ self._channels: Dict[int, Channel] = {}
45
+
46
+ def get_channel(self, channel_id: int) -> Channel:
47
+ if channel_id not in self._channels:
48
+ self._channels[channel_id] = Channel(channel_id, self)
49
+ return self._channels[channel_id]
50
+
51
+ async def _send_on_channel(self, channel_id: int, payload: Any) -> None:
52
+ frame = json.dumps({"channel": channel_id, "payload": payload})
53
+ await self._send_func(frame)
54
+
55
+ async def on_raw_message(self, raw: str) -> None:
56
+ try:
57
+ data = json.loads(raw)
58
+ channel_id = int(data["channel"])
59
+ payload = data.get("payload")
60
+ except (ValueError, KeyError) as exc:
61
+ logger.warning("Discarding malformed frame: %s", exc)
62
+ return
63
+
64
+ channel = self.get_channel(channel_id)
65
+ await channel._deliver(payload)
@@ -0,0 +1,421 @@
1
+ from __future__ import annotations
2
+
3
+ """Terminal session management for Portacode client.
4
+
5
+ This module allows the Portacode gateway to remotely create interactive shell
6
+ sessions on the client machine. Each session gets its own *virtual* channel
7
+ on the existing WebSocket connection so multiple interactive terminals can run
8
+ concurrently without opening extra sockets.
9
+
10
+ The current implementation intentionally stays simple so it works on both
11
+ Unix and Windows without extra native dependencies:
12
+
13
+ * On Unix we *try* to allocate a real PTY so command‐line applications detect
14
+ a TTY and are willing to emit colour sequences, progress bars, etc.
15
+ * On Windows we fall back to a standard subprocess with pipes. While this
16
+ means some programs may not detect a TTY, it still provides a usable shell
17
+ until winpty/pywinpty support is added in the future.
18
+
19
+ Each message exchanged with the gateway uses JSON frames on the **control
20
+ channel 0**. The schema is kept deliberately small for now – it will evolve
21
+ as we add more capabilities:
22
+
23
+ Gateway → Client commands (via channel 0)
24
+ -----------------------------------------
25
+
26
+ 1. Start new terminal::
27
+
28
+ {"cmd": "terminal_start", "shell": "/bin/bash"} // *shell* optional
29
+
30
+ • Response (same channel):
31
+ {"event": "terminal_started", "terminal_id": "...", "channel": 123}
32
+
33
+ 2. Send data to running terminal::
34
+
35
+ {"cmd": "terminal_send", "terminal_id": "...", "data": "ls -l\n"}
36
+
37
+ 3. Close running terminal::
38
+
39
+ {"cmd": "terminal_stop", "terminal_id": "..."}
40
+
41
+ 4. List terminals::
42
+
43
+ {"cmd": "terminal_list"}
44
+ • Response: {"event": "terminal_list", "sessions": [...]} // see code
45
+
46
+ 5. System information::
47
+
48
+ {"cmd": "system_info"}
49
+ • Response: {"event": "system_info", "info": {...}}
50
+
51
+ Client → Gateway events (channel 0 unless stated otherwise)
52
+ ---------------------------------------------------------
53
+
54
+ • Terminal exited (channel 0)::
55
+
56
+ {"event": "terminal_exit", "terminal_id": "...", "returncode": 0}
57
+
58
+ • Terminal output (dedicated *session* channel)::
59
+
60
+ <raw utf-8 text frames> // JSON encoding avoided for efficiency
61
+
62
+ NOTE: For output we currently send **plain text** frames (not JSON) so binary
63
+ streams like progress bars or colour escapes are delivered unchanged. If the
64
+ payload must be JSON for some transports, base64-encoding could be used – but
65
+ that adds 33 % overhead so plain frames are preferred.
66
+ """
67
+
68
+ import asyncio
69
+ import json
70
+ import logging
71
+ import os
72
+ import sys
73
+ import uuid
74
+ from asyncio.subprocess import Process
75
+ from pathlib import Path
76
+ from typing import Any, Dict, Optional
77
+ from collections import deque
78
+
79
+ import psutil # type: ignore
80
+
81
+ from .multiplex import Multiplexer, Channel
82
+
83
+ logger = logging.getLogger(__name__)
84
+
85
+ __all__ = [
86
+ "TerminalManager",
87
+ ]
88
+
89
+
90
+ _IS_WINDOWS = sys.platform.startswith("win")
91
+
92
+
93
+ class TerminalSession:
94
+ """Represents a local shell subprocess bound to a mux *channel*."""
95
+
96
+ def __init__(self, session_id: str, proc: Process, channel: Channel):
97
+ self.id = session_id
98
+ self.proc = proc
99
+ self.channel = channel
100
+ self._reader_task: Optional[asyncio.Task[None]] = None
101
+ self._buffer: deque[str] = deque(maxlen=400) # keep last ~400 lines of raw output
102
+
103
+ async def start_io_forwarding(self) -> None:
104
+ """Spawn background task that copies stdout/stderr to the channel."""
105
+ assert self.proc.stdout is not None, "stdout pipe not set"
106
+
107
+ async def _pump() -> None:
108
+ try:
109
+ while True:
110
+ data = await self.proc.stdout.read(1024)
111
+ if not data:
112
+ break
113
+ # Send *raw* text – no JSON envelope, keep escapes intact.
114
+ text = data.decode(errors="ignore")
115
+ logging.getLogger("portacode.terminal").debug(f"[MUX] Terminal {self.id} output: {text!r}")
116
+ self._buffer.append(text)
117
+ try:
118
+ await self.channel.send(text)
119
+ except Exception as exc:
120
+ logger.warning("Failed to forward terminal output: %s", exc)
121
+ break
122
+ finally:
123
+ # Ensure process gets reaped if reader exits unexpectedly.
124
+ if self.proc and self.proc.returncode is None:
125
+ self.proc.kill()
126
+
127
+ self._reader_task = asyncio.create_task(_pump())
128
+
129
+ async def write(self, data: str) -> None:
130
+ if self.proc.stdin is None:
131
+ logger.warning("stdin pipe closed for terminal %s", self.id)
132
+ return
133
+ try:
134
+ self.proc.stdin.write(data.encode())
135
+ await self.proc.stdin.drain()
136
+ except Exception as exc:
137
+ logger.warning("Failed to write to terminal %s: %s", self.id, exc)
138
+
139
+ async def stop(self) -> None:
140
+ if self.proc.returncode is None:
141
+ self.proc.terminate()
142
+ if self._reader_task:
143
+ await self._reader_task
144
+ await self.proc.wait()
145
+
146
+ # ------------------------------------------------------------------
147
+ # Introspection helpers
148
+ # ------------------------------------------------------------------
149
+
150
+ def snapshot_buffer(self) -> str:
151
+ """Return concatenated last buffer contents suitable for UI."""
152
+ return "".join(self._buffer)
153
+
154
+
155
+ class TerminalManager:
156
+ """Manage multiple *TerminalSession*s controlled over a mux channel."""
157
+
158
+ CONTROL_CHANNEL_ID = 0 # messages with JSON commands/events
159
+
160
+ def __init__(self, mux: Multiplexer):
161
+ self.mux = mux
162
+ self._sessions: Dict[str, TerminalSession] = {}
163
+ self._next_channel = 100 # channel ids >=100 reserved for terminals
164
+ # Start control-loop task
165
+ self._control_channel = self.mux.get_channel(self.CONTROL_CHANNEL_ID)
166
+ self._ctl_task = asyncio.create_task(self._control_loop())
167
+
168
+ # ---------------------------------------------------------------------
169
+ # Control loop – receives commands from gateway
170
+ # ---------------------------------------------------------------------
171
+
172
+ async def _control_loop(self) -> None:
173
+ while True:
174
+ message = await self._control_channel.recv()
175
+ # Older parts of the system may send *raw* str. Ensure dict.
176
+ if isinstance(message, str):
177
+ try:
178
+ message = json.loads(message)
179
+ except Exception:
180
+ logger.warning("Discarding non-JSON control frame: %s", message)
181
+ continue
182
+ if not isinstance(message, dict):
183
+ logger.warning("Invalid control frame type: %r", type(message))
184
+ continue
185
+ cmd = message.get("cmd")
186
+ if not cmd:
187
+ # Ignore frames that are *events* coming from the remote side
188
+ if message.get("event"):
189
+ continue
190
+ logger.warning("Missing 'cmd' in control frame: %s", message)
191
+ continue
192
+ try:
193
+ if cmd == "terminal_start":
194
+ shell = message.get("shell")
195
+ cwd = message.get("cwd")
196
+ await self._cmd_terminal_start(shell=shell, cwd=cwd)
197
+ elif cmd == "terminal_send":
198
+ await self._cmd_terminal_send(message)
199
+ elif cmd == "terminal_stop":
200
+ await self._cmd_terminal_stop(message)
201
+ elif cmd == "terminal_list":
202
+ await self._cmd_terminal_list()
203
+ elif cmd == "system_info":
204
+ await self._cmd_system_info()
205
+ else:
206
+ await self._send_error(f"Unknown cmd: {cmd}")
207
+ except Exception as exc:
208
+ logger.exception("Unhandled exception processing %s: %s", cmd, exc)
209
+ await self._send_error(str(exc))
210
+
211
+ # ------------------------------------------------------------------
212
+ # Individual command handlers
213
+ # ------------------------------------------------------------------
214
+
215
+ async def _cmd_terminal_start(self, *, shell: Optional[str], cwd: Optional[str] = None) -> None:
216
+ term_id = uuid.uuid4().hex
217
+ channel_id = self._allocate_channel_id()
218
+ channel = self.mux.get_channel(channel_id)
219
+ # Choose shell
220
+ if shell is None:
221
+ shell = os.getenv("SHELL") if not _IS_WINDOWS else os.getenv("COMSPEC", "cmd.exe")
222
+ logger.info("Launching terminal %s using shell=%s on channel=%d", term_id, shell, channel_id)
223
+ if _IS_WINDOWS:
224
+ # Windows: use ConPTY via pywinpty for full TTY semantics
225
+ try:
226
+ from winpty import PtyProcess # type: ignore
227
+ except ImportError as exc:
228
+ logger.error("winpty (pywinpty) not found – please install pywinpty: %s", exc)
229
+ await self._send_error("pywinpty not installed on client")
230
+ return
231
+
232
+ pty_proc = PtyProcess.spawn(shell, cwd=cwd or None)
233
+
234
+ class _WinPTYProxy:
235
+ """Expose .pid and .returncode for compatibility with Linux branch."""
236
+
237
+ def __init__(self, pty):
238
+ self._pty = pty
239
+
240
+ @property
241
+ def pid(self):
242
+ return self._pty.pid
243
+
244
+ @property
245
+ def returncode(self):
246
+ # None while running, else exitstatus
247
+ return None if self._pty.isalive() else self._pty.exitstatus
248
+
249
+ async def wait(self):
250
+ loop = asyncio.get_running_loop()
251
+ await loop.run_in_executor(None, self._pty.wait)
252
+
253
+ class WindowsTerminalSession(TerminalSession):
254
+ """Terminal session backed by a Windows ConPTY."""
255
+
256
+ def __init__(self, session_id: str, pty, channel: Channel):
257
+ super().__init__(session_id, _WinPTYProxy(pty), channel)
258
+ self._pty = pty
259
+
260
+ async def start_io_forwarding(self) -> None:
261
+ loop = asyncio.get_running_loop()
262
+
263
+ async def _pump() -> None:
264
+ try:
265
+ while True:
266
+ data = await loop.run_in_executor(None, self._pty.read, 1024)
267
+ if not data:
268
+ if not self._pty.isalive():
269
+ break
270
+ await asyncio.sleep(0.05)
271
+ continue
272
+ if isinstance(data, bytes):
273
+ text = data.decode(errors="ignore")
274
+ else:
275
+ text = data
276
+ logging.getLogger("portacode.terminal").debug(
277
+ f"[MUX] Terminal {self.id} output: {text!r}"
278
+ )
279
+ self._buffer.append(text)
280
+ try:
281
+ await self.channel.send(text)
282
+ except Exception as exc:
283
+ logger.warning("Failed to forward terminal output: %s", exc)
284
+ break
285
+ finally:
286
+ if self._pty and self._pty.isalive():
287
+ self._pty.kill()
288
+
289
+ self._reader_task = asyncio.create_task(_pump())
290
+
291
+ async def write(self, data: str) -> None:
292
+ loop = asyncio.get_running_loop()
293
+ try:
294
+ await loop.run_in_executor(None, self._pty.write, data)
295
+ except Exception as exc:
296
+ logger.warning("Failed to write to terminal %s: %s", self.id, exc)
297
+
298
+ async def stop(self) -> None:
299
+ if self._pty.isalive():
300
+ self._pty.kill()
301
+ if self._reader_task:
302
+ await self._reader_task
303
+
304
+ session = WindowsTerminalSession(term_id, pty_proc, channel)
305
+ self._sessions[term_id] = session
306
+ await session.start_io_forwarding()
307
+ await self._control_channel.send(
308
+ {
309
+ "event": "terminal_started",
310
+ "terminal_id": term_id,
311
+ "channel": channel_id,
312
+ }
313
+ )
314
+
315
+ asyncio.create_task(self._watch_process_exit(session))
316
+ return # windows branch done
317
+ else:
318
+ # Unix: try real PTY for proper TTY semantics
319
+ try:
320
+ import pty
321
+ master_fd, slave_fd = pty.openpty()
322
+ proc = await asyncio.create_subprocess_exec(
323
+ shell,
324
+ stdin=slave_fd,
325
+ stdout=slave_fd,
326
+ stderr=slave_fd,
327
+ preexec_fn=os.setsid,
328
+ cwd=cwd,
329
+ )
330
+ # Wrap master_fd into a StreamReader
331
+ loop = asyncio.get_running_loop()
332
+ reader = asyncio.StreamReader()
333
+ protocol = asyncio.StreamReaderProtocol(reader)
334
+ await loop.connect_read_pipe(lambda: protocol, os.fdopen(master_fd, "rb", buffering=0))
335
+ proc.stdout = reader # type: ignore[assignment]
336
+ # Use writer for stdin
337
+ writer_transport, writer_protocol = await loop.connect_write_pipe(lambda: asyncio.Protocol(), os.fdopen(master_fd, "wb", buffering=0))
338
+ proc.stdin = asyncio.StreamWriter(writer_transport, writer_protocol, reader, loop) # type: ignore[assignment]
339
+ except Exception:
340
+ logger.warning("Failed to allocate PTY, falling back to pipes")
341
+ proc = await asyncio.create_subprocess_exec(
342
+ shell,
343
+ stdin=asyncio.subprocess.PIPE,
344
+ stdout=asyncio.subprocess.PIPE,
345
+ stderr=asyncio.subprocess.STDOUT,
346
+ cwd=cwd,
347
+ )
348
+ session = TerminalSession(term_id, proc, channel)
349
+ self._sessions[term_id] = session
350
+ await session.start_io_forwarding()
351
+ await self._control_channel.send(
352
+ {
353
+ "event": "terminal_started",
354
+ "terminal_id": term_id,
355
+ "channel": channel_id,
356
+ }
357
+ )
358
+
359
+ # Also create background watcher for process exit
360
+ asyncio.create_task(self._watch_process_exit(session))
361
+
362
+ async def _cmd_terminal_send(self, msg: Dict[str, Any]) -> None:
363
+ term_id = msg.get("terminal_id")
364
+ data = msg.get("data", "")
365
+ session = self._sessions.get(term_id)
366
+ if not session:
367
+ await self._send_error(f"terminal_id {term_id} not found")
368
+ return
369
+ await session.write(data)
370
+
371
+ async def _cmd_terminal_stop(self, msg: Dict[str, Any]) -> None:
372
+ term_id = msg.get("terminal_id")
373
+ session = self._sessions.pop(term_id, None)
374
+ if not session:
375
+ await self._send_error(f"terminal_id {term_id} not found")
376
+ return
377
+ await session.stop()
378
+ await self._control_channel.send({"event": "terminal_stopped", "terminal_id": term_id})
379
+
380
+ async def _cmd_terminal_list(self) -> None:
381
+ sessions = [
382
+ {
383
+ "terminal_id": s.id,
384
+ "channel": s.channel.id,
385
+ "pid": s.proc.pid,
386
+ "returncode": s.proc.returncode,
387
+ "buffer": s.snapshot_buffer(),
388
+ }
389
+ for s in self._sessions.values()
390
+ ]
391
+ await self._control_channel.send({"event": "terminal_list", "sessions": sessions})
392
+
393
+ async def _cmd_system_info(self) -> None:
394
+ info = {
395
+ "cpu_percent": psutil.cpu_percent(interval=0.1),
396
+ "memory": psutil.virtual_memory()._asdict(),
397
+ "disk": psutil.disk_usage(str(Path.home()))._asdict(),
398
+ }
399
+ await self._control_channel.send({"event": "system_info", "info": info})
400
+
401
+ # ------------------------------------------------------------------
402
+ # Helpers
403
+ # ------------------------------------------------------------------
404
+
405
+ async def _send_error(self, message: str) -> None:
406
+ await self._control_channel.send({"event": "error", "message": message})
407
+
408
+ def _allocate_channel_id(self) -> int:
409
+ cid = self._next_channel
410
+ self._next_channel += 1
411
+ return cid
412
+
413
+ async def _watch_process_exit(self, session: TerminalSession) -> None:
414
+ await session.proc.wait()
415
+ await self._control_channel.send({
416
+ "event": "terminal_exit",
417
+ "terminal_id": session.id,
418
+ "returncode": session.proc.returncode,
419
+ })
420
+ # Cleanup session table
421
+ self._sessions.pop(session.id, None)
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from platformdirs import user_data_dir
8
+
9
+ APP_NAME = "portacode"
10
+ APP_AUTHOR = "Portacode"
11
+
12
+
13
+ def get_data_dir() -> Path:
14
+ """Return the platform-specific *user data* directory for Portacode.
15
+
16
+ This is where all persistent user data (keypairs, logs, pid files, …) is stored.
17
+ The directory is created on first use.
18
+ """
19
+
20
+ data_dir = Path(user_data_dir(APP_NAME, APP_AUTHOR))
21
+ data_dir.mkdir(parents=True, exist_ok=True)
22
+ return data_dir
23
+
24
+
25
+ def get_key_dir() -> Path:
26
+ key_dir = get_data_dir() / "keys"
27
+ key_dir.mkdir(parents=True, exist_ok=True)
28
+ return key_dir
29
+
30
+
31
+ def get_runtime_dir() -> Path:
32
+ """Return directory for runtime files (pid, sockets, …)."""
33
+ runtime_dir = get_data_dir() / "run"
34
+ runtime_dir.mkdir(parents=True, exist_ok=True)
35
+ return runtime_dir
36
+
37
+
38
+ def get_pid_file() -> Path:
39
+ return get_runtime_dir() / "gateway.pid"
40
+
41
+
42
+ def is_process_running(pid: int) -> bool:
43
+ """Check whether *pid* refers to a currently running process."""
44
+ if sys.platform.startswith("win"):
45
+ import ctypes
46
+ import ctypes.wintypes as wintypes
47
+
48
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
49
+ handle = ctypes.windll.kernel32.OpenProcess(
50
+ PROCESS_QUERY_LIMITED_INFORMATION, False, pid
51
+ )
52
+ if handle == 0:
53
+ return False
54
+ ctypes.windll.kernel32.CloseHandle(handle)
55
+ return True
56
+ else:
57
+ try:
58
+ os.kill(pid, 0)
59
+ except OSError:
60
+ return False
61
+ return True
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Tuple
5
+ import logging
6
+ import base64
7
+
8
+ from cryptography.hazmat.primitives import hashes, serialization
9
+ from cryptography.hazmat.primitives.asymmetric import rsa, padding
10
+ from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat
11
+
12
+ from .data import get_key_dir
13
+
14
+ PRIVATE_KEY_FILE = "id_portacode"
15
+ PUBLIC_KEY_FILE = "id_portacode.pub"
16
+
17
+ logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(message)s')
18
+
19
+ class KeyPair:
20
+ """Container for RSA keypair paths and objects."""
21
+
22
+ def __init__(self, private_key_path: Path, public_key_path: Path):
23
+ self.private_key_path = private_key_path
24
+ self.public_key_path = public_key_path
25
+
26
+ @property
27
+ def private_key_pem(self) -> bytes:
28
+ return self.private_key_path.read_bytes()
29
+
30
+ @property
31
+ def public_key_pem(self) -> bytes:
32
+ return self.public_key_path.read_bytes()
33
+
34
+ def sign_challenge(self, challenge: str) -> bytes:
35
+ """Sign a challenge string with the private key."""
36
+ private_key = serialization.load_pem_private_key(
37
+ self.private_key_pem, password=None
38
+ )
39
+ return private_key.sign(
40
+ challenge.encode(),
41
+ padding.PKCS1v15(),
42
+ hashes.SHA256(),
43
+ )
44
+
45
+ def public_key_der_b64(self) -> str:
46
+ """Return the public key as base64-encoded DER (single line)."""
47
+ pubkey = serialization.load_pem_public_key(self.public_key_pem)
48
+ der = pubkey.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo)
49
+ return base64.b64encode(der).decode()
50
+
51
+ @staticmethod
52
+ def der_b64_to_pem(der_b64: str) -> bytes:
53
+ """Convert base64 DER to PEM format."""
54
+ der = base64.b64decode(der_b64)
55
+ pubkey = serialization.load_der_public_key(der)
56
+ return pubkey.public_bytes(encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo)
57
+
58
+
59
+ def _generate_keypair() -> Tuple[bytes, bytes]:
60
+ # Use 1024 bits for smaller demo keys (not secure for production)
61
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=1024)
62
+ private_pem = private_key.private_bytes(
63
+ encoding=Encoding.PEM,
64
+ format=PrivateFormat.TraditionalOpenSSL,
65
+ encryption_algorithm=NoEncryption(),
66
+ )
67
+ public_pem = (
68
+ private_key.public_key()
69
+ .public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)
70
+ )
71
+ return private_pem, public_pem
72
+
73
+
74
+ def get_or_create_keypair() -> KeyPair:
75
+ """Return the existing keypair or generate a new one if missing."""
76
+ key_dir = get_key_dir()
77
+ priv_path = key_dir / PRIVATE_KEY_FILE
78
+ pub_path = key_dir / PUBLIC_KEY_FILE
79
+
80
+ if not priv_path.exists() or not pub_path.exists():
81
+ logging.info(f"No keys found, generating new one and saving to {key_dir}")
82
+ private_pem, public_pem = _generate_keypair()
83
+ priv_path.write_bytes(private_pem)
84
+ pub_path.write_bytes(public_pem)
85
+ else:
86
+ logging.info(f"Found existing keys at {key_dir}")
87
+
88
+ return KeyPair(priv_path, pub_path)
89
+
90
+
91
+ def fingerprint_public_key(pem: bytes) -> str:
92
+ """Return a short fingerprint for display purposes (SHA-256)."""
93
+ digest = hashes.Hash(hashes.SHA256())
94
+ digest.update(pem)
95
+ return digest.finalize().hex()[:16]
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: portacode
3
+ Version: 0.1.0
4
+ Summary: Portacode CLI client and SDK
5
+ Home-page: https://github.com/portacode/portacode
6
+ Author: Meena Erian
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: click>=8.0
13
+ Requires-Dist: platformdirs>=3.0
14
+ Requires-Dist: cryptography>=41.0
15
+ Requires-Dist: websockets>=12.0
16
+ Requires-Dist: pyperclip>=1.8
17
+ Requires-Dist: psutil>=5.9
18
+ Requires-Dist: pyte>=0.8
19
+ Requires-Dist: pywinpty>=2.0; platform_system == "Windows"
20
+ Provides-Extra: dev
21
+ Requires-Dist: black; extra == "dev"
22
+ Requires-Dist: flake8; extra == "dev"
23
+ Requires-Dist: pytest; extra == "dev"
24
+ Dynamic: author
25
+ Dynamic: classifier
26
+ Dynamic: description
27
+ Dynamic: description-content-type
28
+ Dynamic: home-page
29
+ Dynamic: provides-extra
30
+ Dynamic: requires-dist
31
+ Dynamic: requires-python
32
+ Dynamic: summary
33
+
34
+ # Portacode
35
+
36
+ Portacode is a modular Python package that provides a command-line interface for connecting your local machine to the Portacode cloud gateway.
37
+
38
+ ```
39
+ $ pip install portacode
40
+ $ portacode connect
41
+ ```
42
+
43
+ The first release only ships the `connect` command which:
44
+
45
+ 1. Creates an RSA public/private key-pair (if not already present) in a platform-specific data directory.
46
+ 2. Guides you through adding the public key to your Portacode account.
47
+ 3. Establishes and maintains a resilient WebSocket connection to `wss://portacode.com/gateway`.
48
+
49
+ Future releases will add more sub-commands and build upon the multiplexing channel infrastructure already included in this version.
50
+
51
+ ## Project layout
52
+
53
+ ```
54
+ portacode/ ‑ Top-level package
55
+ ├── cli.py ‑ Click-based CLI entry-point
56
+ ├── data.py ‑ Cross-platform data-directory helpers
57
+ ├── keypair.py ‑ RSA key generation & storage
58
+ ├── connection/ ‑ Networking & multiplexing logic
59
+ │ ├── client.py ‑ WebSocket client with auto-reconnect
60
+ │ └── multiplex.py- Virtual channel multiplexer
61
+ └── …
62
+ ```
63
+
64
+ See the README files inside each sub-module for more details.
@@ -0,0 +1,17 @@
1
+ MANIFEST.in
2
+ README.md
3
+ setup.py
4
+ portacode/__init__.py
5
+ portacode/cli.py
6
+ portacode/data.py
7
+ portacode/keypair.py
8
+ portacode.egg-info/PKG-INFO
9
+ portacode.egg-info/SOURCES.txt
10
+ portacode.egg-info/dependency_links.txt
11
+ portacode.egg-info/entry_points.txt
12
+ portacode.egg-info/requires.txt
13
+ portacode.egg-info/top_level.txt
14
+ portacode/connection/__init__.py
15
+ portacode/connection/client.py
16
+ portacode/connection/multiplex.py
17
+ portacode/connection/terminal.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ portacode = portacode.cli:cli
@@ -0,0 +1,15 @@
1
+ click>=8.0
2
+ platformdirs>=3.0
3
+ cryptography>=41.0
4
+ websockets>=12.0
5
+ pyperclip>=1.8
6
+ psutil>=5.9
7
+ pyte>=0.8
8
+
9
+ [:platform_system == "Windows"]
10
+ pywinpty>=2.0
11
+
12
+ [dev]
13
+ black
14
+ flake8
15
+ pytest
@@ -0,0 +1 @@
1
+ portacode
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,43 @@
1
+ from pathlib import Path
2
+
3
+ from setuptools import find_packages, setup
4
+
5
+ PACKAGE_NAME = "portacode"
6
+ ROOT = Path(__file__).parent
7
+ README = (ROOT / "README.md").read_text(encoding="utf-8")
8
+
9
+ setup(
10
+ name=PACKAGE_NAME,
11
+ version="0.1.0",
12
+ description="Portacode CLI client and SDK",
13
+ long_description=README,
14
+ long_description_content_type="text/markdown",
15
+ author="Meena Erian",
16
+ url="https://github.com/portacode/portacode",
17
+ packages=find_packages(exclude=("tests", "server")),
18
+ python_requires=">=3.8",
19
+ install_requires=[
20
+ "click>=8.0",
21
+ "platformdirs>=3.0",
22
+ "cryptography>=41.0",
23
+ "websockets>=12.0",
24
+ "pyperclip>=1.8",
25
+ "psutil>=5.9",
26
+ "pyte>=0.8",
27
+ "pywinpty>=2.0; platform_system=='Windows'",
28
+ ],
29
+ extras_require={
30
+ "dev": ["black", "flake8", "pytest"],
31
+ },
32
+ entry_points={
33
+ "console_scripts": [
34
+ "portacode=portacode.cli:cli",
35
+ ]
36
+ },
37
+ classifiers=[
38
+ "Programming Language :: Python :: 3",
39
+ "License :: OSI Approved :: MIT License",
40
+ "Operating System :: OS Independent",
41
+ ],
42
+ include_package_data=True,
43
+ )