port42 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.
@@ -0,0 +1,23 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ id-token: write
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.11"
18
+ - name: Install hatch
19
+ run: pip install hatch
20
+ - name: Build
21
+ run: hatch build
22
+ - name: Publish
23
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ venv/
8
+ .port42/
9
+ *.json
10
+ !pyproject.toml
port42-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: port42
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Port42 — connect external agents to your macOS companion
5
+ Project-URL: Homepage, https://github.com/gordonmattey/port42-python
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: websockets>=12.0
9
+ Provides-Extra: all
10
+ Requires-Dist: click>=8.0; extra == 'all'
11
+ Requires-Dist: langchain-core>=0.3; extra == 'all'
12
+ Provides-Extra: cli
13
+ Requires-Dist: click>=8.0; extra == 'cli'
14
+ Provides-Extra: langchain
15
+ Requires-Dist: langchain-core>=0.3; extra == 'langchain'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # port42
19
+
20
+ Python SDK for [Port42](https://github.com/gordonmattey/port42-native) — connect external agents to your macOS companion.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install port42
26
+ ```
27
+
28
+ ## Quickstart
29
+
30
+ ```python
31
+ from port42 import Agent
32
+
33
+ agent = Agent("my-agent", channels=["#general"])
34
+
35
+ @agent.on_mention
36
+ def handle(msg):
37
+ return f"Hello {msg.sender}!"
38
+
39
+ agent.run()
40
+ ```
41
+
42
+ Port42 must be running. The agent connects via WebSocket and appears in your companion list.
43
+
44
+ ## With LangChain
45
+
46
+ ```bash
47
+ pip install port42[langchain]
48
+ ```
49
+
50
+ ```python
51
+ from port42 import Agent
52
+ from port42.langchain import Port42CallbackHandler
53
+ from langchain_anthropic import ChatAnthropic
54
+
55
+ agent = Agent("assistant", channels=["#general"])
56
+ handler = Port42CallbackHandler(agent)
57
+
58
+ chain = ChatAnthropic(model="claude-sonnet-4-5")
59
+
60
+ @agent.on_mention
61
+ def handle(msg):
62
+ result = chain.invoke(msg.text, config={"callbacks": [handler]})
63
+ return result.content
64
+
65
+ agent.run()
66
+ ```
67
+
68
+ ## Scaffold a project
69
+
70
+ ```bash
71
+ pip install port42[cli]
72
+ port42 init my-agent
73
+ cd my-agent && python agent.py
74
+ ```
75
+
76
+ Templates: `basic`, `langchain`, `pipeline`
77
+
78
+ ## Bridge APIs
79
+
80
+ Once connected, your agent can call Port42 device APIs:
81
+
82
+ ```python
83
+ agent.terminal_exec("ls ~/Desktop")
84
+ agent.screen_capture(scale=0.5)
85
+ agent.notify("Done", body="Pipeline complete")
86
+ agent.port_push(port_id, {"key": "value"})
87
+ agent.rest_call("https://api.example.com/data", secret="MY_API_KEY")
88
+ ```
port42-0.1.0/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # port42
2
+
3
+ Python SDK for [Port42](https://github.com/gordonmattey/port42-native) — connect external agents to your macOS companion.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install port42
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ```python
14
+ from port42 import Agent
15
+
16
+ agent = Agent("my-agent", channels=["#general"])
17
+
18
+ @agent.on_mention
19
+ def handle(msg):
20
+ return f"Hello {msg.sender}!"
21
+
22
+ agent.run()
23
+ ```
24
+
25
+ Port42 must be running. The agent connects via WebSocket and appears in your companion list.
26
+
27
+ ## With LangChain
28
+
29
+ ```bash
30
+ pip install port42[langchain]
31
+ ```
32
+
33
+ ```python
34
+ from port42 import Agent
35
+ from port42.langchain import Port42CallbackHandler
36
+ from langchain_anthropic import ChatAnthropic
37
+
38
+ agent = Agent("assistant", channels=["#general"])
39
+ handler = Port42CallbackHandler(agent)
40
+
41
+ chain = ChatAnthropic(model="claude-sonnet-4-5")
42
+
43
+ @agent.on_mention
44
+ def handle(msg):
45
+ result = chain.invoke(msg.text, config={"callbacks": [handler]})
46
+ return result.content
47
+
48
+ agent.run()
49
+ ```
50
+
51
+ ## Scaffold a project
52
+
53
+ ```bash
54
+ pip install port42[cli]
55
+ port42 init my-agent
56
+ cd my-agent && python agent.py
57
+ ```
58
+
59
+ Templates: `basic`, `langchain`, `pipeline`
60
+
61
+ ## Bridge APIs
62
+
63
+ Once connected, your agent can call Port42 device APIs:
64
+
65
+ ```python
66
+ agent.terminal_exec("ls ~/Desktop")
67
+ agent.screen_capture(scale=0.5)
68
+ agent.notify("Done", body="Pipeline complete")
69
+ agent.port_push(port_id, {"key": "value"})
70
+ agent.rest_call("https://api.example.com/data", secret="MY_API_KEY")
71
+ ```
@@ -0,0 +1,9 @@
1
+ from port42 import Agent
2
+
3
+ agent = Agent("echo", channels=["#general"])
4
+
5
+ @agent.on_mention
6
+ def handle(msg):
7
+ return f"Hello {msg.sender}! You said: {msg.text}"
8
+
9
+ agent.run()
@@ -0,0 +1,23 @@
1
+ from port42 import Agent
2
+ from port42.langchain import Port42CallbackHandler
3
+ from langchain_anthropic import ChatAnthropic
4
+ from langchain_core.prompts import ChatPromptTemplate
5
+
6
+ agent = Agent("assistant", channels=["#general"])
7
+ handler = Port42CallbackHandler(agent)
8
+
9
+ prompt = ChatPromptTemplate.from_messages([
10
+ ("system", "You are a helpful assistant."),
11
+ ("human", "{input}"),
12
+ ])
13
+ chain = prompt | ChatAnthropic(model="claude-sonnet-4-5")
14
+
15
+ @agent.on_mention
16
+ def handle(msg):
17
+ result = chain.invoke(
18
+ {"input": msg.text},
19
+ config={"callbacks": [handler]}
20
+ )
21
+ return result.content
22
+
23
+ agent.run()
@@ -0,0 +1,25 @@
1
+ from port42 import Agent
2
+
3
+ agent = Agent("pipeline", channels=["#ops"])
4
+
5
+ @agent.on_mention
6
+ def handle(msg):
7
+ agent.typing()
8
+
9
+ # Run your pipeline
10
+ result = f"Processed: {msg.text}"
11
+
12
+ # Push a port with results
13
+ html = f"""<!DOCTYPE html>
14
+ <html>
15
+ <head><title>Pipeline Result</title><meta name="version" content="1"></head>
16
+ <body style="padding:16px;font-family:monospace">
17
+ <p style="color:#00d4aa">Result</p>
18
+ <pre>{result}</pre>
19
+ </body>
20
+ </html>"""
21
+ agent.port_create(html=html, title="Pipeline Result")
22
+
23
+ return result
24
+
25
+ agent.run()
@@ -0,0 +1,4 @@
1
+ from .agent import Agent
2
+
3
+ __all__ = ["Agent"]
4
+ __version__ = "0.1.0"
@@ -0,0 +1,304 @@
1
+ import json
2
+ import os
3
+ import threading
4
+ import time
5
+ import uuid
6
+
7
+ from .types import Message, Feedback
8
+
9
+
10
+ class Agent:
11
+ def __init__(
12
+ self,
13
+ name: str,
14
+ channels: list[str] | None = None,
15
+ trigger: str = "mention",
16
+ gateway: str = "ws://127.0.0.1:4242",
17
+ tokens: list[str] | None = None,
18
+ ):
19
+ self.name = name
20
+ self.channels = channels or []
21
+ self.trigger = trigger
22
+ self.gateway_url = gateway.rstrip("/") + "/ws"
23
+ self.http_url = gateway.rstrip("/").replace("ws://", "http://").replace("wss://", "https://")
24
+ self._channel_tokens: dict[str, str] = dict(zip(channels or [], tokens or []))
25
+ self._channel_ids: list[str] = []
26
+ self._ws = None
27
+ self._handlers: dict[str, list] = {
28
+ "mention": [],
29
+ "message": [],
30
+ "feedback": [],
31
+ }
32
+ self._pending_calls: dict[str, threading.Event] = {}
33
+ self._call_results: dict[str, dict] = {}
34
+ self._recv_thread: threading.Thread | None = None
35
+ self._state_file = f".port42/{name}.json"
36
+ self.sender_id: str | None = None
37
+ self._load_state()
38
+ if not self.sender_id:
39
+ self.sender_id = f"agent-{name}-{uuid.uuid4().hex[:12]}"
40
+
41
+ # --- State persistence ---
42
+
43
+ def _load_state(self):
44
+ try:
45
+ with open(self._state_file) as f:
46
+ state = json.load(f)
47
+ self.sender_id = state.get("sender_id")
48
+ except FileNotFoundError:
49
+ pass
50
+
51
+ def _save_state(self):
52
+ os.makedirs(os.path.dirname(self._state_file), exist_ok=True)
53
+ with open(self._state_file, "w") as f:
54
+ json.dump({"sender_id": self.sender_id}, f)
55
+
56
+ # --- Decorators ---
57
+
58
+ def on_mention(self, fn):
59
+ self._handlers["mention"].append(fn)
60
+ return fn
61
+
62
+ def on_message(self, fn):
63
+ self._handlers["message"].append(fn)
64
+ return fn
65
+
66
+ def on_feedback(self, fn):
67
+ self._handlers["feedback"].append(fn)
68
+ return fn
69
+
70
+ # --- Run ---
71
+
72
+ def run(self):
73
+ """Block and listen for messages."""
74
+ self._connect()
75
+ self._recv_thread = threading.Thread(target=self._recv_loop, daemon=True)
76
+ self._recv_thread.start()
77
+ try:
78
+ self._recv_thread.join()
79
+ except KeyboardInterrupt:
80
+ self._disconnect()
81
+
82
+ # --- Connection ---
83
+
84
+ def _connect(self):
85
+ from websockets.sync.client import connect as ws_connect
86
+
87
+ print(f"[port42] connecting to {self.gateway_url} as {self.name} ({self.sender_id})")
88
+ self._ws = ws_connect(self.gateway_url)
89
+
90
+ # Auth handshake
91
+ auth_msg = json.loads(self._ws.recv())
92
+ if auth_msg["type"] == "challenge":
93
+ raise ConnectionError("Remote auth not yet supported — use a local gateway or provide a token")
94
+
95
+ # Identify
96
+ self._ws.send(json.dumps({
97
+ "type": "identify",
98
+ "sender_id": self.sender_id,
99
+ "sender_name": self.name,
100
+ }))
101
+ welcome = json.loads(self._ws.recv())
102
+ if welcome.get("type") != "welcome":
103
+ raise ConnectionError(f"Expected welcome, got: {welcome}")
104
+ self._save_state()
105
+ print(f"[port42] connected")
106
+
107
+ # Resolve channel names → IDs
108
+ if self.channels and not self._channel_ids:
109
+ self._channel_ids = self._resolve_channels(self.channels)
110
+
111
+ # Join channels
112
+ for ch_id in self._channel_ids:
113
+ join = {"type": "join", "channel_id": ch_id}
114
+ token = self._channel_tokens.get(ch_id)
115
+ if token:
116
+ join["token"] = token
117
+ self._ws.send(json.dumps(join))
118
+ # Drain until presence (joined) or error
119
+ for _ in range(10):
120
+ resp = json.loads(self._ws.recv())
121
+ if resp["type"] in ("presence", "error"):
122
+ if resp["type"] == "error":
123
+ print(f"[port42] failed to join {ch_id}: {resp.get('error')}")
124
+ break
125
+
126
+ def _resolve_channels(self, names: list[str]) -> list[str]:
127
+ import urllib.request
128
+ url = self.http_url + "/call"
129
+ data = json.dumps({"method": "channel.list"}).encode()
130
+ req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
131
+ with urllib.request.urlopen(req) as resp:
132
+ result = json.loads(resp.read())
133
+ # result is {"content": "[{...}]"} or direct list
134
+ content = result.get("content", result)
135
+ if isinstance(content, str):
136
+ channels = json.loads(content)
137
+ else:
138
+ channels = content
139
+ name_set = {n.lstrip("#").lower() for n in names}
140
+ ids = [ch["id"] for ch in channels if ch.get("name", "").lower() in name_set]
141
+ if not ids:
142
+ print(f"[port42] warning: no channels matched {names}")
143
+ return ids
144
+
145
+ def _disconnect(self):
146
+ if self._ws:
147
+ self._ws.close()
148
+
149
+ # --- Recv loop ---
150
+
151
+ def _recv_loop(self):
152
+ while True:
153
+ try:
154
+ raw = self._ws.recv()
155
+ except Exception as e:
156
+ print(f"[port42] disconnected: {e}")
157
+ break
158
+ env = json.loads(raw)
159
+ call_id = env.get("call_id")
160
+ if call_id and call_id in self._pending_calls:
161
+ self._call_results[call_id] = env
162
+ self._pending_calls[call_id].set()
163
+ else:
164
+ threading.Thread(target=self._dispatch, args=(env,), daemon=True).start()
165
+
166
+ def _dispatch(self, env: dict):
167
+ msg_type = env.get("type")
168
+
169
+ if msg_type == "message":
170
+ # Skip own messages
171
+ if env.get("sender_id") == self.sender_id:
172
+ return
173
+ msg = Message.from_envelope(env)
174
+ is_mention = f"@{self.name.lower()}" in (msg.text or "").lower()
175
+ if is_mention:
176
+ for handler in self._handlers["mention"]:
177
+ try:
178
+ result = handler(msg)
179
+ if result is not None:
180
+ self.send(str(result), channel_id=msg.channel_id)
181
+ except Exception as e:
182
+ print(f"[port42] handler error: {e}")
183
+ else:
184
+ for handler in self._handlers["message"]:
185
+ try:
186
+ result = handler(msg)
187
+ if result is not None:
188
+ self.send(str(result), channel_id=msg.channel_id)
189
+ except Exception as e:
190
+ print(f"[port42] handler error: {e}")
191
+
192
+ elif msg_type == "feedback":
193
+ fb = Feedback.from_envelope(env)
194
+ for handler in self._handlers["feedback"]:
195
+ try:
196
+ handler(fb)
197
+ except Exception as e:
198
+ print(f"[port42] feedback handler error: {e}")
199
+
200
+ # --- Send ---
201
+
202
+ def send(self, text: str, channel_id: str | None = None):
203
+ ch = channel_id or (self._channel_ids[0] if self._channel_ids else None)
204
+ if not ch:
205
+ raise RuntimeError("No channel to send to — specify channel_id or join a channel first")
206
+ env = {
207
+ "type": "message",
208
+ "channel_id": ch,
209
+ "sender_id": self.sender_id,
210
+ "sender_name": self.name,
211
+ "message_id": f"agent-{uuid.uuid4().hex[:16]}",
212
+ "payload": json.dumps({"text": text}),
213
+ }
214
+ self._ws.send(json.dumps(env))
215
+
216
+ def typing(self, channel_id: str | None = None):
217
+ ch = channel_id or (self._channel_ids[0] if self._channel_ids else None)
218
+ if not ch:
219
+ return
220
+ self._ws.send(json.dumps({
221
+ "type": "typing",
222
+ "channel_id": ch,
223
+ "sender_id": self.sender_id,
224
+ }))
225
+
226
+ # --- Bridge API ---
227
+
228
+ def _call(self, method: str, args: dict | None = None) -> dict:
229
+ call_id = f"sdk-{uuid.uuid4().hex[:12]}"
230
+ event = threading.Event()
231
+ self._pending_calls[call_id] = event
232
+ self._ws.send(json.dumps({
233
+ "type": "call",
234
+ "method": method,
235
+ "args": args or {},
236
+ "call_id": call_id,
237
+ "sender_id": self.sender_id,
238
+ }))
239
+ if not event.wait(timeout=30):
240
+ del self._pending_calls[call_id]
241
+ raise TimeoutError(f"{method} timed out")
242
+ result = self._call_results.pop(call_id)
243
+ del self._pending_calls[call_id]
244
+ if result.get("error"):
245
+ raise RuntimeError(result["error"])
246
+ payload = result.get("payload", "{}")
247
+ if isinstance(payload, (bytes, str)):
248
+ try:
249
+ return json.loads(payload)
250
+ except Exception:
251
+ return {"content": payload}
252
+ return payload or {}
253
+
254
+ def port_push(self, port_id: str, data: dict):
255
+ return self._call("port_push", {"id": port_id, "data": data})
256
+
257
+ def port_exec(self, port_id: str, js: str):
258
+ return self._call("port_exec", {"id": port_id, "js": js})
259
+
260
+ def port_patch(self, port_id: str, search: str, replace: str):
261
+ return self._call("port_patch", {"id": port_id, "search": search, "replace": replace})
262
+
263
+ def port_create(self, html: str, title: str | None = None):
264
+ return self._call("messages.send", {"text": f"```port\n{html}\n```"})
265
+
266
+ def ports_list(self):
267
+ return self._call("ports_list")
268
+
269
+ def rest_call(self, url: str, method: str = "GET", secret: str | None = None, **kwargs):
270
+ args: dict = {"url": url, "method": method}
271
+ if secret:
272
+ args["secret"] = secret
273
+ args.update(kwargs)
274
+ return self._call("rest.call", args)
275
+
276
+ def clipboard_read(self):
277
+ return self._call("clipboard.read")
278
+
279
+ def clipboard_write(self, data: str):
280
+ return self._call("clipboard.write", {"data": data})
281
+
282
+ def notify(self, title: str, body: str | None = None):
283
+ args: dict = {"title": title}
284
+ if body:
285
+ args["body"] = body
286
+ return self._call("notify.send", args)
287
+
288
+ def screen_capture(self, scale: float = 0.5):
289
+ return self._call("screen_capture", {"scale": scale})
290
+
291
+ def terminal_exec(self, command: str):
292
+ return self._call("terminal.exec", {"command": command})
293
+
294
+ def files_read(self, path: str):
295
+ return self._call("files.read", {"path": path})
296
+
297
+ def files_write(self, path: str, data: str):
298
+ return self._call("files.write", {"path": path, "data": data})
299
+
300
+ def storage_get(self, key: str, scope: str = "global"):
301
+ return self._call("storage.get", {"key": key, "scope": scope})
302
+
303
+ def storage_set(self, key: str, value, scope: str = "global"):
304
+ return self._call("storage.set", {"key": key, "value": value, "scope": scope})
@@ -0,0 +1,90 @@
1
+ try:
2
+ import click
3
+ except ImportError:
4
+ raise ImportError("click is required: pip install port42[cli]")
5
+
6
+ import os
7
+
8
+
9
+ BASIC_TEMPLATE = '''from port42 import Agent
10
+
11
+ agent = Agent("{name}", channels=["#general"])
12
+
13
+ @agent.on_mention
14
+ def handle(msg):
15
+ return f"Hello {{msg.sender}}! You said: {{msg.text}}"
16
+
17
+ agent.run()
18
+ '''
19
+
20
+ LANGCHAIN_TEMPLATE = '''from port42 import Agent
21
+ from port42.langchain import Port42CallbackHandler
22
+ from langchain_anthropic import ChatAnthropic
23
+ from langchain_core.prompts import ChatPromptTemplate
24
+
25
+ agent = Agent("{name}", channels=["#general"])
26
+ handler = Port42CallbackHandler(agent)
27
+
28
+ prompt = ChatPromptTemplate.from_messages([
29
+ ("system", "You are a helpful assistant."),
30
+ ("human", "{{input}}"),
31
+ ])
32
+ chain = prompt | ChatAnthropic(model="claude-sonnet-4-5")
33
+
34
+ @agent.on_mention
35
+ def handle(msg):
36
+ result = chain.invoke(
37
+ {{"input": msg.text}},
38
+ config={{"callbacks": [handler]}}
39
+ )
40
+ return result.content
41
+
42
+ agent.run()
43
+ '''
44
+
45
+ PIPELINE_TEMPLATE = '''from port42 import Agent
46
+
47
+ agent = Agent("{name}", channels=["#ops"])
48
+
49
+ @agent.on_mention
50
+ def handle(msg):
51
+ agent.typing()
52
+
53
+ # TODO: run your pipeline here
54
+ result = f"processed: {{msg.text}}"
55
+
56
+ # Optionally create a port to display results
57
+ # agent.port_create(html="<html>...</html>", title="Result")
58
+
59
+ return result
60
+
61
+ agent.run()
62
+ '''
63
+
64
+
65
+ @click.group()
66
+ def main():
67
+ pass
68
+
69
+
70
+ @main.command()
71
+ @click.argument("name")
72
+ @click.option("--template", default="basic", help="Template: basic, langchain, pipeline")
73
+ def init(name: str, template: str):
74
+ """Create a new Port42 agent project."""
75
+ os.makedirs(name, exist_ok=True)
76
+
77
+ templates = {
78
+ "basic": BASIC_TEMPLATE,
79
+ "langchain": LANGCHAIN_TEMPLATE,
80
+ "pipeline": PIPELINE_TEMPLATE,
81
+ }
82
+ if template not in templates:
83
+ raise click.BadParameter(f"Unknown template '{template}'. Choose: basic, langchain, pipeline")
84
+
85
+ agent_py = os.path.join(name, "agent.py")
86
+ with open(agent_py, "w") as f:
87
+ f.write(templates[template].format(name=name))
88
+
89
+ click.echo(f"Created {name}/agent.py")
90
+ click.echo(f"Run with: cd {name} && python agent.py")
@@ -0,0 +1,11 @@
1
+ from .callback import Port42CallbackHandler
2
+ from .tools import port42_tools, RestCallTool, ScreenCaptureTool, ClipboardTool, TerminalTool
3
+
4
+ __all__ = [
5
+ "Port42CallbackHandler",
6
+ "port42_tools",
7
+ "RestCallTool",
8
+ "ScreenCaptureTool",
9
+ "ClipboardTool",
10
+ "TerminalTool",
11
+ ]
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ try:
4
+ from langchain_core.callbacks import BaseCallbackHandler
5
+ except ImportError:
6
+ raise ImportError("langchain-core is required: pip install port42[langchain]")
7
+
8
+
9
+ class Port42CallbackHandler(BaseCallbackHandler):
10
+ """Streams LangChain execution progress into Port42 channels."""
11
+
12
+ def __init__(self, agent, channel_id: str | None = None):
13
+ self.agent = agent
14
+ self.channel_id = channel_id
15
+
16
+ def on_chain_start(self, serialized, inputs, **kwargs):
17
+ self.agent.typing(self.channel_id)
18
+
19
+ def on_chain_end(self, outputs, **kwargs):
20
+ if hasattr(outputs, "model_dump"):
21
+ # Pydantic model — render as port
22
+ self._render_as_port(outputs)
23
+ elif isinstance(outputs, str):
24
+ self.agent.send(outputs, channel_id=self.channel_id)
25
+
26
+ def on_chain_error(self, error, **kwargs):
27
+ self.agent.send(f"Chain error: {error}", channel_id=self.channel_id)
28
+
29
+ def on_tool_start(self, serialized, input_str, **kwargs):
30
+ self.agent.typing(self.channel_id)
31
+
32
+ def on_tool_end(self, output, **kwargs):
33
+ pass
34
+
35
+ def _render_as_port(self, model):
36
+ fields = model.model_dump()
37
+ rows = "".join(
38
+ f"<tr><td>{k}</td><td>{v}</td></tr>"
39
+ for k, v in fields.items()
40
+ )
41
+ html = f"""<!DOCTYPE html>
42
+ <html>
43
+ <head><title>{type(model).__name__}</title><meta name="version" content="1"></head>
44
+ <body>
45
+ <table style="width:100%;border-collapse:collapse;font-family:monospace;font-size:12px">
46
+ {rows}
47
+ </table>
48
+ </body>
49
+ </html>"""
50
+ self.agent.port_create(html=html, title=type(model).__name__)
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ try:
4
+ from langchain_core.tools import BaseTool
5
+ except ImportError:
6
+ raise ImportError("langchain-core is required: pip install port42[langchain]")
7
+
8
+ from pydantic import Field
9
+
10
+
11
+ class Port42Tool(BaseTool):
12
+ agent: object = Field(exclude=True)
13
+
14
+
15
+ class RestCallTool(Port42Tool):
16
+ name: str = "rest_call"
17
+ description: str = "Make an HTTP request using Port42's secret store"
18
+
19
+ def _run(self, url: str, method: str = "GET", secret: str | None = None, **kwargs):
20
+ return self.agent.rest_call(url, method=method, secret=secret, **kwargs)
21
+
22
+
23
+ class ScreenCaptureTool(Port42Tool):
24
+ name: str = "screen_capture"
25
+ description: str = "Take a screenshot of the user's screen"
26
+
27
+ def _run(self, scale: float = 0.5):
28
+ return self.agent.screen_capture(scale=scale)
29
+
30
+
31
+ class ClipboardTool(Port42Tool):
32
+ name: str = "clipboard"
33
+ description: str = "Read or write the user's clipboard"
34
+
35
+ def _run(self, action: str = "read", data: str | None = None):
36
+ if action == "write" and data:
37
+ return self.agent.clipboard_write(data)
38
+ return self.agent.clipboard_read()
39
+
40
+
41
+ class TerminalTool(Port42Tool):
42
+ name: str = "terminal_exec"
43
+ description: str = "Run a shell command on the user's machine"
44
+
45
+ def _run(self, command: str):
46
+ return self.agent.terminal_exec(command)
47
+
48
+
49
+ def port42_tools(agent) -> list[BaseTool]:
50
+ return [
51
+ RestCallTool(agent=agent),
52
+ ScreenCaptureTool(agent=agent),
53
+ ClipboardTool(agent=agent),
54
+ TerminalTool(agent=agent),
55
+ ]
@@ -0,0 +1,50 @@
1
+ from dataclasses import dataclass, field
2
+ import json
3
+
4
+
5
+ @dataclass
6
+ class Message:
7
+ text: str
8
+ sender: str
9
+ sender_id: str
10
+ channel_id: str
11
+ message_id: str
12
+ timestamp: int
13
+ history: list = field(default_factory=list)
14
+
15
+ @classmethod
16
+ def from_envelope(cls, env: dict) -> "Message":
17
+ payload = json.loads(env.get("payload", "{}") or "{}")
18
+ if isinstance(payload, bytes):
19
+ payload = json.loads(payload)
20
+ return cls(
21
+ text=payload.get("text", ""),
22
+ sender=env.get("sender_name", ""),
23
+ sender_id=env.get("sender_id", ""),
24
+ channel_id=env.get("channel_id", ""),
25
+ message_id=env.get("message_id", ""),
26
+ timestamp=env.get("timestamp", 0),
27
+ history=payload.get("history", []),
28
+ )
29
+
30
+
31
+ @dataclass
32
+ class Feedback:
33
+ message_id: str
34
+ type: str
35
+ content: str
36
+ sender: str
37
+ sender_id: str
38
+
39
+ @classmethod
40
+ def from_envelope(cls, env: dict) -> "Feedback":
41
+ payload = json.loads(env.get("payload", "{}") or "{}")
42
+ if isinstance(payload, bytes):
43
+ payload = json.loads(payload)
44
+ return cls(
45
+ message_id=env.get("message_id", ""),
46
+ type=payload.get("feedback_type", ""),
47
+ content=payload.get("content", ""),
48
+ sender=env.get("sender_name", ""),
49
+ sender_id=env.get("sender_id", ""),
50
+ )
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "port42"
7
+ version = "0.1.0"
8
+ description = "Python SDK for Port42 — connect external agents to your macOS companion"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ dependencies = [
13
+ "websockets>=12.0",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ langchain = [
18
+ "langchain-core>=0.3",
19
+ ]
20
+ cli = [
21
+ "click>=8.0",
22
+ ]
23
+ all = [
24
+ "port42[langchain,cli]",
25
+ ]
26
+
27
+ [project.scripts]
28
+ port42 = "port42.cli:main"
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/gordonmattey/port42-python"