swiftgate-cli 1.0.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.
- swiftgate_cli-1.0.0/LICENSE +21 -0
- swiftgate_cli-1.0.0/PKG-INFO +71 -0
- swiftgate_cli-1.0.0/README.md +44 -0
- swiftgate_cli-1.0.0/pyproject.toml +43 -0
- swiftgate_cli-1.0.0/src/swiftgate_cli/__init__.py +8 -0
- swiftgate_cli-1.0.0/src/swiftgate_cli/__main__.py +5 -0
- swiftgate_cli-1.0.0/src/swiftgate_cli/agent_bridge.py +257 -0
- swiftgate_cli-1.0.0/src/swiftgate_cli/api_client.py +79 -0
- swiftgate_cli-1.0.0/src/swiftgate_cli/config.py +116 -0
- swiftgate_cli-1.0.0/src/swiftgate_cli/main.py +248 -0
- swiftgate_cli-1.0.0/src/swiftgate_cli/sse_listener.py +136 -0
- swiftgate_cli-1.0.0/tests/test_agent_bridge.py +101 -0
- swiftgate_cli-1.0.0/tests/test_config.py +49 -0
- swiftgate_cli-1.0.0/tests/test_sse_listener.py +121 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SwiftGate AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: swiftgate-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: SwiftGate CLI — autonomous bidirectional agent bridge for SwiftGate OS
|
|
5
|
+
Project-URL: Homepage, https://swiftgate.ai
|
|
6
|
+
Project-URL: Repository, https://github.com/thetaroot/swiftgate
|
|
7
|
+
Author-email: SwiftGate <dev@swiftgate.ai>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agent,ai,cli,mcp,swiftgate
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: aiohttp>=3.9
|
|
25
|
+
Requires-Dist: click>=8.0
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# swiftgate-cli
|
|
29
|
+
|
|
30
|
+
Autonomous bidirectional agent bridge for SwiftGate OS.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install swiftgate-cli
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# One-time setup per machine
|
|
42
|
+
swiftgate-cli setup --token sg_acc_...
|
|
43
|
+
|
|
44
|
+
# Start an agent
|
|
45
|
+
swiftgate-cli start --agent my-agent
|
|
46
|
+
|
|
47
|
+
# List registered agents
|
|
48
|
+
swiftgate-cli list
|
|
49
|
+
|
|
50
|
+
# Stop an agent
|
|
51
|
+
swiftgate-cli stop --agent my-agent
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## How It Works
|
|
55
|
+
|
|
56
|
+
1. `setup` — stores your account token in `~/.swiftgate/config.json` (0600 permissions)
|
|
57
|
+
2. `start` — fetches agent config from SwiftGate, writes MCP configuration, spawns the agent in a pseudo-terminal, and starts the SSE wake listener
|
|
58
|
+
3. When a message arrives in your SwiftGate inbox, the CLI receives a wake notification and injects the appropriate command into the agent's terminal
|
|
59
|
+
4. stdin/stdout is forwarded transparently — the CLI is invisible
|
|
60
|
+
|
|
61
|
+
## Supported Agents
|
|
62
|
+
|
|
63
|
+
- OpenCode
|
|
64
|
+
- Claude Code
|
|
65
|
+
- Aider
|
|
66
|
+
- More coming (PI, Hermes, OpenClaw)
|
|
67
|
+
|
|
68
|
+
## Requirements
|
|
69
|
+
|
|
70
|
+
- Python 3.10+
|
|
71
|
+
- Linux or macOS
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# swiftgate-cli
|
|
2
|
+
|
|
3
|
+
Autonomous bidirectional agent bridge for SwiftGate OS.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install swiftgate-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# One-time setup per machine
|
|
15
|
+
swiftgate-cli setup --token sg_acc_...
|
|
16
|
+
|
|
17
|
+
# Start an agent
|
|
18
|
+
swiftgate-cli start --agent my-agent
|
|
19
|
+
|
|
20
|
+
# List registered agents
|
|
21
|
+
swiftgate-cli list
|
|
22
|
+
|
|
23
|
+
# Stop an agent
|
|
24
|
+
swiftgate-cli stop --agent my-agent
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## How It Works
|
|
28
|
+
|
|
29
|
+
1. `setup` — stores your account token in `~/.swiftgate/config.json` (0600 permissions)
|
|
30
|
+
2. `start` — fetches agent config from SwiftGate, writes MCP configuration, spawns the agent in a pseudo-terminal, and starts the SSE wake listener
|
|
31
|
+
3. When a message arrives in your SwiftGate inbox, the CLI receives a wake notification and injects the appropriate command into the agent's terminal
|
|
32
|
+
4. stdin/stdout is forwarded transparently — the CLI is invisible
|
|
33
|
+
|
|
34
|
+
## Supported Agents
|
|
35
|
+
|
|
36
|
+
- OpenCode
|
|
37
|
+
- Claude Code
|
|
38
|
+
- Aider
|
|
39
|
+
- More coming (PI, Hermes, OpenClaw)
|
|
40
|
+
|
|
41
|
+
## Requirements
|
|
42
|
+
|
|
43
|
+
- Python 3.10+
|
|
44
|
+
- Linux or macOS
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "swiftgate-cli"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "SwiftGate CLI — autonomous bidirectional agent bridge for SwiftGate OS"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "SwiftGate", email = "dev@swiftgate.ai" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["swiftgate", "mcp", "agent", "cli", "ai"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Operating System :: POSIX :: Linux",
|
|
22
|
+
"Operating System :: MacOS :: MacOS X",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Topic :: Software Development :: Build Tools",
|
|
28
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
29
|
+
]
|
|
30
|
+
dependencies = [
|
|
31
|
+
"aiohttp>=3.9",
|
|
32
|
+
"click>=8.0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
swiftgate-cli = "swiftgate_cli.main:cli"
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://swiftgate.ai"
|
|
40
|
+
Repository = "https://github.com/thetaroot/swiftgate"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/swiftgate_cli"]
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Agent Bridge — Unix pty agent spawn, io forwarding, wake injection.
|
|
2
|
+
|
|
3
|
+
Wave 8 (2026-07-25): Manages agent processes via pseudo-terminal.
|
|
4
|
+
Forks, execs the agent command, and forwards terminal IO transparently.
|
|
5
|
+
On wake: injects commands into the agent's stdin via the pty master fd.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import errno
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import pty
|
|
16
|
+
import signal
|
|
17
|
+
import sys
|
|
18
|
+
import tty
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("swiftgate.bridge")
|
|
24
|
+
|
|
25
|
+
# ── MCP Config writers per agent type ───────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
def _write_opencode_config(slug: str, token: str, server: str) -> None:
|
|
28
|
+
config_dir = Path.home() / ".config" / "opencode"
|
|
29
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
config_file = config_dir / "opencode.json"
|
|
31
|
+
existing = {}
|
|
32
|
+
if config_file.exists():
|
|
33
|
+
try:
|
|
34
|
+
existing = json.loads(config_file.read_text())
|
|
35
|
+
except (json.JSONDecodeError, OSError):
|
|
36
|
+
pass
|
|
37
|
+
existing.setdefault("mcp", {})["swiftgate"] = {
|
|
38
|
+
"type": "remote",
|
|
39
|
+
"url": f"{server}/mcp/agent/http/{slug}?token={token}",
|
|
40
|
+
"headers": {"Authorization": f"Bearer {token}"},
|
|
41
|
+
}
|
|
42
|
+
config_file.write_text(json.dumps(existing, indent=2, ensure_ascii=False))
|
|
43
|
+
logger.info("Wrote OpenCode MCP config: %s", config_file)
|
|
44
|
+
|
|
45
|
+
AGENT_CONFIG_WRITERS = {
|
|
46
|
+
"opencode": _write_opencode_config,
|
|
47
|
+
# Future: "claude_code", "aider", etc.
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AgentBridge:
|
|
52
|
+
"""Manages an agent process via pty.
|
|
53
|
+
|
|
54
|
+
Spawns the agent in a pseudo-terminal, forwards stdin/stdout
|
|
55
|
+
transparently, and injects wake commands when notified.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, workspace_slug: str, agent_type: str,
|
|
59
|
+
command: list[str], mcp_token: str,
|
|
60
|
+
server: str = "https://vps.swiftgateai.de"):
|
|
61
|
+
self._slug = workspace_slug
|
|
62
|
+
self._type = agent_type
|
|
63
|
+
self._command = command
|
|
64
|
+
self._token = mcp_token
|
|
65
|
+
self._server = server.rstrip("/")
|
|
66
|
+
self._pid: int | None = None
|
|
67
|
+
self._master_fd: int | None = None
|
|
68
|
+
self._running = False
|
|
69
|
+
|
|
70
|
+
# ── Spawn ────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
async def start(self) -> bool:
|
|
73
|
+
"""Write MCP config, fork, exec the agent in pty.
|
|
74
|
+
|
|
75
|
+
Returns True if agent spawned successfully.
|
|
76
|
+
"""
|
|
77
|
+
# 1. Write MCP config
|
|
78
|
+
writer = AGENT_CONFIG_WRITERS.get(self._type)
|
|
79
|
+
if writer:
|
|
80
|
+
try:
|
|
81
|
+
writer(self._slug, self._token, self._server)
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
logger.warning("Failed to write MCP config for %s: %s",
|
|
84
|
+
self._type, exc)
|
|
85
|
+
|
|
86
|
+
# 2. Open pty pair
|
|
87
|
+
try:
|
|
88
|
+
master_fd, slave_fd = pty.openpty()
|
|
89
|
+
except OSError as exc:
|
|
90
|
+
logger.error("Failed to open pty: %s", exc)
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
# 3. Fork
|
|
94
|
+
pid = os.fork()
|
|
95
|
+
if pid == 0:
|
|
96
|
+
# ── Child: become the agent process ──
|
|
97
|
+
os.setsid()
|
|
98
|
+
os.close(master_fd)
|
|
99
|
+
|
|
100
|
+
# Attach slave pty to stdin/stdout/stderr
|
|
101
|
+
os.dup2(slave_fd, sys.stdin.fileno())
|
|
102
|
+
os.dup2(slave_fd, sys.stdout.fileno())
|
|
103
|
+
os.dup2(slave_fd, sys.stderr.fileno())
|
|
104
|
+
os.close(slave_fd)
|
|
105
|
+
|
|
106
|
+
# Exec the agent command
|
|
107
|
+
try:
|
|
108
|
+
os.execvp(self._command[0], self._command)
|
|
109
|
+
except FileNotFoundError:
|
|
110
|
+
os._exit(127)
|
|
111
|
+
except Exception:
|
|
112
|
+
os._exit(1)
|
|
113
|
+
|
|
114
|
+
# ── Parent ──
|
|
115
|
+
os.close(slave_fd)
|
|
116
|
+
self._pid = pid
|
|
117
|
+
self._master_fd = master_fd
|
|
118
|
+
self._running = True
|
|
119
|
+
|
|
120
|
+
logger.info("Agent %s spawned (pid=%d, type=%s)",
|
|
121
|
+
self._slug, pid, self._type)
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
# ── Wake ─────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
def wake(self, count: int = 0) -> None:
|
|
127
|
+
"""Inject wake command into agent's stdin via pty.
|
|
128
|
+
|
|
129
|
+
The agent receives this as if typed by the user.
|
|
130
|
+
The LLM processes it and calls swiftgate_agent_get_task.
|
|
131
|
+
"""
|
|
132
|
+
if not self._running or self._master_fd is None:
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
msg = f"\n# SwiftGate: {count} pending tasks. Run swiftgate_agent_get_task\n"
|
|
136
|
+
try:
|
|
137
|
+
os.write(self._master_fd, msg.encode("utf-8"))
|
|
138
|
+
logger.debug("Wake injected for %s (%d pending)", self._slug, count)
|
|
139
|
+
except OSError:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
# ── IO Forwarding ────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
async def forward_io(self) -> None:
|
|
145
|
+
"""Forward stdin → agent pty, agent pty → stdout.
|
|
146
|
+
|
|
147
|
+
Runs in the asyncio event loop. Blocks until agent exits.
|
|
148
|
+
"""
|
|
149
|
+
if not self._running or self._master_fd is None:
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
loop = asyncio.get_event_loop()
|
|
153
|
+
master_fd = self._master_fd
|
|
154
|
+
|
|
155
|
+
# Save original terminal settings
|
|
156
|
+
old_tc = None
|
|
157
|
+
try:
|
|
158
|
+
old_tc = termios.tcgetattr(sys.stdin.fileno())
|
|
159
|
+
except (termios.error, OSError):
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
# Set raw mode for transparent pass-through
|
|
163
|
+
try:
|
|
164
|
+
tty.setraw(sys.stdin.fileno())
|
|
165
|
+
except (termios.error, OSError):
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
stop_event = asyncio.Event()
|
|
169
|
+
|
|
170
|
+
def _stdin_to_pty():
|
|
171
|
+
try:
|
|
172
|
+
data = os.read(sys.stdin.fileno(), 4096)
|
|
173
|
+
if not data:
|
|
174
|
+
stop_event.set()
|
|
175
|
+
return
|
|
176
|
+
os.write(master_fd, data)
|
|
177
|
+
except OSError as exc:
|
|
178
|
+
if exc.errno == errno.EIO:
|
|
179
|
+
stop_event.set()
|
|
180
|
+
elif exc.errno != errno.EAGAIN:
|
|
181
|
+
stop_event.set()
|
|
182
|
+
|
|
183
|
+
def _pty_to_stdout():
|
|
184
|
+
try:
|
|
185
|
+
data = os.read(master_fd, 4096)
|
|
186
|
+
if not data:
|
|
187
|
+
stop_event.set()
|
|
188
|
+
return
|
|
189
|
+
os.write(sys.stdout.fileno(), data)
|
|
190
|
+
except OSError as exc:
|
|
191
|
+
if exc.errno == errno.EIO:
|
|
192
|
+
stop_event.set()
|
|
193
|
+
elif exc.errno != errno.EAGAIN:
|
|
194
|
+
stop_event.set()
|
|
195
|
+
|
|
196
|
+
loop.add_reader(sys.stdin.fileno(), _stdin_to_pty)
|
|
197
|
+
loop.add_reader(master_fd, _pty_to_stdout)
|
|
198
|
+
|
|
199
|
+
await stop_event.wait()
|
|
200
|
+
|
|
201
|
+
# Cleanup readers
|
|
202
|
+
try:
|
|
203
|
+
loop.remove_reader(sys.stdin.fileno())
|
|
204
|
+
except Exception:
|
|
205
|
+
pass
|
|
206
|
+
try:
|
|
207
|
+
loop.remove_reader(master_fd)
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
# Restore terminal
|
|
212
|
+
if old_tc:
|
|
213
|
+
try:
|
|
214
|
+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old_tc)
|
|
215
|
+
except (termios.error, OSError):
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
# ── Lifecycle ────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
async def wait(self) -> int:
|
|
221
|
+
"""Wait for agent process to exit. Returns exit code."""
|
|
222
|
+
if self._pid is None:
|
|
223
|
+
return -1
|
|
224
|
+
loop = asyncio.get_event_loop()
|
|
225
|
+
try:
|
|
226
|
+
_, status = await loop.run_in_executor(None, os.waitpid, self._pid, 0)
|
|
227
|
+
return os.WEXITSTATUS(status) if os.WIFEXITED(status) else -1
|
|
228
|
+
except ChildProcessError:
|
|
229
|
+
return -1
|
|
230
|
+
|
|
231
|
+
def stop(self) -> None:
|
|
232
|
+
"""Send SIGTERM to agent process."""
|
|
233
|
+
if self._pid and self._running:
|
|
234
|
+
self._running = False
|
|
235
|
+
try:
|
|
236
|
+
os.kill(self._pid, signal.SIGTERM)
|
|
237
|
+
except ProcessLookupError:
|
|
238
|
+
pass
|
|
239
|
+
logger.info("Agent %s stopped (pid=%d)", self._slug, self._pid)
|
|
240
|
+
|
|
241
|
+
if self._master_fd is not None:
|
|
242
|
+
try:
|
|
243
|
+
os.close(self._master_fd)
|
|
244
|
+
except OSError:
|
|
245
|
+
pass
|
|
246
|
+
self._master_fd = None
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def pid(self) -> int | None:
|
|
250
|
+
return self._pid
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# Try import termios (Unix-only)
|
|
254
|
+
try:
|
|
255
|
+
import termios
|
|
256
|
+
except ImportError:
|
|
257
|
+
termios = None # type: ignore
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""API client — REST calls to SwiftGate VPS.
|
|
2
|
+
|
|
3
|
+
Wave 8 (2026-07-25): Communicates with the CLI endpoints
|
|
4
|
+
on the SwiftGate backend for agent sync and lifecyclesignals.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
|
|
11
|
+
from swiftgate_cli.config import get_account_token, get_server
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class APIClient:
|
|
15
|
+
"""Async HTTP client for SwiftGate CLI API endpoints."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, token: str = "", server: str = ""):
|
|
18
|
+
self._token = token or get_account_token()
|
|
19
|
+
self._server = (server or get_server()).rstrip("/")
|
|
20
|
+
|
|
21
|
+
def _headers(self) -> dict[str, str]:
|
|
22
|
+
return {"Accept": "application/json"}
|
|
23
|
+
|
|
24
|
+
def _cli_url(self, path: str) -> str:
|
|
25
|
+
return f"{self._server}/api/external-agents/cli/{path}?token={self._token}"
|
|
26
|
+
|
|
27
|
+
async def test_connection(self) -> bool:
|
|
28
|
+
"""Test that the account token is valid."""
|
|
29
|
+
url = self._cli_url("agents")
|
|
30
|
+
try:
|
|
31
|
+
async with aiohttp.ClientSession() as session:
|
|
32
|
+
async with session.get(url, headers=self._headers(),
|
|
33
|
+
timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
|
34
|
+
return resp.status == 200
|
|
35
|
+
except Exception:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
async def fetch_agents(self) -> list[dict]:
|
|
39
|
+
"""Fetch all registered agents for this tenant."""
|
|
40
|
+
url = self._cli_url("agents")
|
|
41
|
+
async with aiohttp.ClientSession() as session:
|
|
42
|
+
async with session.get(url, headers=self._headers(),
|
|
43
|
+
timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
|
44
|
+
if resp.status != 200:
|
|
45
|
+
return []
|
|
46
|
+
data = await resp.json()
|
|
47
|
+
return data.get("agents", [])
|
|
48
|
+
|
|
49
|
+
async def signal_agent_started(self, workspace_slug: str) -> bool:
|
|
50
|
+
"""Notify SwiftGate that an agent process has started."""
|
|
51
|
+
url = self._cli_url("agent-started")
|
|
52
|
+
try:
|
|
53
|
+
async with aiohttp.ClientSession() as session:
|
|
54
|
+
async with session.post(
|
|
55
|
+
url, json={"workspace_slug": workspace_slug},
|
|
56
|
+
headers=self._headers(),
|
|
57
|
+
timeout=aiohttp.ClientTimeout(total=10),
|
|
58
|
+
) as resp:
|
|
59
|
+
return resp.status == 200
|
|
60
|
+
except Exception:
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
async def signal_agent_stopped(self, workspace_slug: str) -> bool:
|
|
64
|
+
"""Notify SwiftGate that an agent process has stopped."""
|
|
65
|
+
url = self._cli_url("agent-stopped")
|
|
66
|
+
try:
|
|
67
|
+
async with aiohttp.ClientSession() as session:
|
|
68
|
+
async with session.post(
|
|
69
|
+
url, json={"workspace_slug": workspace_slug},
|
|
70
|
+
headers=self._headers(),
|
|
71
|
+
timeout=aiohttp.ClientTimeout(total=10),
|
|
72
|
+
) as resp:
|
|
73
|
+
return resp.status == 200
|
|
74
|
+
except Exception:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
def get_sse_url(self) -> str:
|
|
78
|
+
"""URL for the CLI SSE wake stream."""
|
|
79
|
+
return self._cli_url("listen")
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Config management — ~/.swiftgate/config.json.
|
|
2
|
+
|
|
3
|
+
Wave 8 (2026-07-25): Local config file for swiftgate-cli.
|
|
4
|
+
Stores account token, server URL, and agent registry.
|
|
5
|
+
Permissions: chmod 0600 (owner read/write only).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import stat
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_CONFIG_DIR = Path.home() / ".swiftgate"
|
|
18
|
+
_CONFIG_FILE = _CONFIG_DIR / "config.json"
|
|
19
|
+
|
|
20
|
+
_DEFAULT_CONFIG: dict[str, Any] = {
|
|
21
|
+
"account_token": "",
|
|
22
|
+
"server": "https://vps.swiftgateai.de",
|
|
23
|
+
"agents": {},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _ensure_config_dir() -> None:
|
|
28
|
+
_CONFIG_DIR.mkdir(mode=0o700, exist_ok=True)
|
|
29
|
+
# Directory permissions: 0700 (owner rwx)
|
|
30
|
+
try:
|
|
31
|
+
os.chmod(_CONFIG_DIR, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
|
|
32
|
+
except OSError:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _set_permissions(path: Path) -> None:
|
|
37
|
+
"""Set file permissions to 0600 (owner read/write only)."""
|
|
38
|
+
try:
|
|
39
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
40
|
+
except OSError:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load() -> dict[str, Any]:
|
|
45
|
+
"""Load config from ~/.swiftgate/config.json."""
|
|
46
|
+
_ensure_config_dir()
|
|
47
|
+
if not _CONFIG_FILE.exists():
|
|
48
|
+
return json.loads(json.dumps(_DEFAULT_CONFIG))
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(_CONFIG_FILE.read_text())
|
|
51
|
+
except (json.JSONDecodeError, OSError):
|
|
52
|
+
return json.loads(json.dumps(_DEFAULT_CONFIG))
|
|
53
|
+
merged = json.loads(json.dumps(_DEFAULT_CONFIG))
|
|
54
|
+
merged.update(data)
|
|
55
|
+
return merged
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def save(config: dict[str, Any]) -> None:
|
|
59
|
+
"""Save config to ~/.swiftgate/config.json with 0600 permissions."""
|
|
60
|
+
_ensure_config_dir()
|
|
61
|
+
_CONFIG_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False))
|
|
62
|
+
_set_permissions(_CONFIG_FILE)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_account_token() -> str:
|
|
66
|
+
"""Get stored account token."""
|
|
67
|
+
return load().get("account_token", "")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_server() -> str:
|
|
71
|
+
"""Get server URL."""
|
|
72
|
+
return load().get("server", "https://vps.swiftgateai.de")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def set_setup(token: str, server: str = "") -> None:
|
|
76
|
+
"""Write initial setup: account token + optional server URL."""
|
|
77
|
+
cfg = load()
|
|
78
|
+
cfg["account_token"] = token
|
|
79
|
+
if server:
|
|
80
|
+
cfg["server"] = server
|
|
81
|
+
save(cfg)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def add_agent(slug: str, agent_type: str, mcp_token_hash: str,
|
|
85
|
+
transport: str = "http") -> None:
|
|
86
|
+
"""Register an agent in local config."""
|
|
87
|
+
cfg = load()
|
|
88
|
+
cfg["agents"][slug] = {
|
|
89
|
+
"type": agent_type,
|
|
90
|
+
"mcp_token_hash": mcp_token_hash,
|
|
91
|
+
"transport": transport,
|
|
92
|
+
}
|
|
93
|
+
save(cfg)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def remove_agent(slug: str) -> None:
|
|
97
|
+
"""Remove an agent from local config."""
|
|
98
|
+
cfg = load()
|
|
99
|
+
cfg["agents"].pop(slug, None)
|
|
100
|
+
save(cfg)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_agent(slug: str) -> dict | None:
|
|
104
|
+
"""Get agent config from local registry."""
|
|
105
|
+
return load().get("agents", {}).get(slug)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def list_agents() -> dict[str, dict]:
|
|
109
|
+
"""Get all locally registered agents."""
|
|
110
|
+
return load().get("agents", {})
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def is_setup() -> bool:
|
|
114
|
+
"""Check if CLI has been set up with an account token."""
|
|
115
|
+
token = get_account_token()
|
|
116
|
+
return bool(token and token.startswith("sg_acc_"))
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""swiftgate-cli — Main CLI entry point.
|
|
2
|
+
|
|
3
|
+
Wave 8 (2026-07-25): Commands:
|
|
4
|
+
swiftgate-cli setup --token sg_acc_...
|
|
5
|
+
swiftgate-cli start --agent my-agent
|
|
6
|
+
swiftgate-cli list
|
|
7
|
+
swiftgate-cli stop --agent my-agent
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
from swiftgate_cli.config import (
|
|
21
|
+
is_setup, set_setup, get_server, list_agents as cfg_list_agents,
|
|
22
|
+
add_agent as cfg_add_agent, remove_agent as cfg_remove_agent,
|
|
23
|
+
get_agent as cfg_get_agent,
|
|
24
|
+
)
|
|
25
|
+
from swiftgate_cli.api_client import APIClient
|
|
26
|
+
from swiftgate_cli.sse_listener import SSEWakeListener
|
|
27
|
+
from swiftgate_cli.agent_bridge import AgentBridge
|
|
28
|
+
|
|
29
|
+
logging.basicConfig(
|
|
30
|
+
level=logging.INFO,
|
|
31
|
+
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
|
32
|
+
datefmt="%H:%M:%S",
|
|
33
|
+
)
|
|
34
|
+
logger = logging.getLogger("swiftgate")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@click.group()
|
|
38
|
+
@click.version_option(version="1.0.0", prog_name="swiftgate-cli")
|
|
39
|
+
def cli():
|
|
40
|
+
"""swiftgate-cli — Autonomous bidirectional agent bridge for SwiftGate OS.
|
|
41
|
+
|
|
42
|
+
Connect any CLI-based AI agent (OpenCode, Claude Code, Aider)
|
|
43
|
+
to SwiftGate with a single command. The CLI manages agent spawning,
|
|
44
|
+
MCP configuration, stdin/stdout forwarding, and wake delivery.
|
|
45
|
+
|
|
46
|
+
\b
|
|
47
|
+
Quick start:
|
|
48
|
+
swiftgate-cli setup --token sg_acc_...
|
|
49
|
+
swiftgate-cli start --agent my-agent
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@cli.command()
|
|
54
|
+
@click.option("--token", required=True, help="Account token (sg_acc_...)")
|
|
55
|
+
@click.option("--server", default="", help="SwiftGate server URL")
|
|
56
|
+
def setup(token: str, server: str):
|
|
57
|
+
"""Store account token and test connection.
|
|
58
|
+
|
|
59
|
+
Run once per machine. Creates ~/.swiftgate/config.json.
|
|
60
|
+
The token authorizes this machine to receive wake notifications
|
|
61
|
+
for all your SwiftGate agent workspaces.
|
|
62
|
+
"""
|
|
63
|
+
if not token.startswith("sg_acc_"):
|
|
64
|
+
click.echo(click.style("Error: Token must start with 'sg_acc_'", fg="red"))
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
|
|
67
|
+
set_setup(token, server)
|
|
68
|
+
click.echo(click.style("✓ Config saved to ~/.swiftgate/config.json", fg="green"))
|
|
69
|
+
|
|
70
|
+
async def _test():
|
|
71
|
+
client = APIClient(token, server)
|
|
72
|
+
return await client.test_connection()
|
|
73
|
+
|
|
74
|
+
ok = asyncio.run(_test())
|
|
75
|
+
if ok:
|
|
76
|
+
click.echo(click.style("✓ Connection to SwiftGate verified", fg="green"))
|
|
77
|
+
else:
|
|
78
|
+
click.echo(click.style("✗ Could not connect to SwiftGate — check token and network", fg="yellow"))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@cli.command()
|
|
82
|
+
@click.option("--agent", required=True, help="Agent workspace slug")
|
|
83
|
+
@click.option("--mcp-token", default="", help="MCP token from agent registration (sg_ea_...)")
|
|
84
|
+
async def start(agent: str, mcp_token: str):
|
|
85
|
+
"""Start an agent and bridge it to SwiftGate.
|
|
86
|
+
|
|
87
|
+
Fetches agent config from SwiftGate, writes MCP configuration,
|
|
88
|
+
spawns the agent process, and starts the SSE wake listener.
|
|
89
|
+
stdin/stdout is forwarded transparently.
|
|
90
|
+
|
|
91
|
+
The MCP token is required on first start. It is stored in
|
|
92
|
+
~/.swiftgate/config.json for subsequent starts.
|
|
93
|
+
"""
|
|
94
|
+
if not is_setup():
|
|
95
|
+
click.echo(click.style("Not set up. Run: swiftgate-cli setup --token sg_acc_...", fg="red"))
|
|
96
|
+
sys.exit(1)
|
|
97
|
+
|
|
98
|
+
token = mcp_token or _get_stored_token(agent)
|
|
99
|
+
if not token:
|
|
100
|
+
click.echo(click.style(
|
|
101
|
+
"MCP token required. Copy the full command from the agent creation screen.",
|
|
102
|
+
fg="yellow"))
|
|
103
|
+
click.echo(f"Usage: swiftgate-cli start --agent {agent} --mcp-token sg_ea_...")
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
|
|
106
|
+
# Store token for future use
|
|
107
|
+
if mcp_token and mcp_token.startswith("sg_ea_"):
|
|
108
|
+
_store_token(agent, mcp_token)
|
|
109
|
+
|
|
110
|
+
client = APIClient()
|
|
111
|
+
|
|
112
|
+
# Store token for future use
|
|
113
|
+
if mcp_token and mcp_token.startswith("sg_ea_"):
|
|
114
|
+
_store_token(agent, mcp_token)
|
|
115
|
+
|
|
116
|
+
# Determine agent type from local config or default
|
|
117
|
+
agent_type = _get_stored_type(agent) or "opencode"
|
|
118
|
+
slug = agent
|
|
119
|
+
|
|
120
|
+
click.echo(f"Starting agent '{slug}' ({agent_type})...")
|
|
121
|
+
|
|
122
|
+
# Create bridge
|
|
123
|
+
bridge = AgentBridge(
|
|
124
|
+
workspace_slug=slug,
|
|
125
|
+
agent_type=agent_type,
|
|
126
|
+
command=command,
|
|
127
|
+
mcp_token=token,
|
|
128
|
+
server=get_server(),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Spawn agent
|
|
132
|
+
ok = await bridge.start()
|
|
133
|
+
if not ok:
|
|
134
|
+
click.echo(click.style("Failed to spawn agent process", fg="red"))
|
|
135
|
+
sys.exit(1)
|
|
136
|
+
|
|
137
|
+
# Notify SwiftGate
|
|
138
|
+
await client.signal_agent_started(slug)
|
|
139
|
+
|
|
140
|
+
# Start SSE listener
|
|
141
|
+
listener = SSEWakeListener(client.get_sse_url())
|
|
142
|
+
listener.register(slug, lambda s, c: bridge.wake(c))
|
|
143
|
+
sse_task = asyncio.create_task(listener.listen())
|
|
144
|
+
|
|
145
|
+
# Forward IO
|
|
146
|
+
click.echo(click.style(f"✓ Agent '{slug}' running. Terminal connected.", fg="green"))
|
|
147
|
+
click.echo(click.style(" (Press Ctrl+C to stop)", fg="dim"))
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
await bridge.forward_io()
|
|
151
|
+
except KeyboardInterrupt:
|
|
152
|
+
click.echo()
|
|
153
|
+
finally:
|
|
154
|
+
listener.stop()
|
|
155
|
+
bridge.stop()
|
|
156
|
+
await client.signal_agent_stopped(slug)
|
|
157
|
+
sse_task.cancel()
|
|
158
|
+
try:
|
|
159
|
+
await sse_task
|
|
160
|
+
except asyncio.CancelledError:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
click.echo(click.style(f"✓ Agent '{slug}' stopped.", fg="green"))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@cli.command()
|
|
167
|
+
@click.pass_context
|
|
168
|
+
def list(ctx):
|
|
169
|
+
"""List registered agents."""
|
|
170
|
+
if not is_setup():
|
|
171
|
+
click.echo(click.style("Not set up. Run: swiftgate-cli setup --token sg_acc_...", fg="red"))
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
async def _list():
|
|
175
|
+
client = APIClient()
|
|
176
|
+
return await client.fetch_agents()
|
|
177
|
+
|
|
178
|
+
agents = asyncio.run(_list())
|
|
179
|
+
if not agents:
|
|
180
|
+
click.echo("No agents registered.")
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
click.echo(f"{'Workspace Slug':<30} {'Type':<12} {'Transport':<10} {'Status':<10}")
|
|
184
|
+
click.echo("-" * 62)
|
|
185
|
+
for a in agents:
|
|
186
|
+
click.echo(
|
|
187
|
+
f"{a['workspace_slug']:<30} "
|
|
188
|
+
f"{a['agent_type']:<12} "
|
|
189
|
+
f"{a.get('transport_mode', 'http'):<10} "
|
|
190
|
+
f"{a['status']:<10}"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@cli.command()
|
|
195
|
+
@click.option("--agent", required=True, help="Agent workspace slug to stop")
|
|
196
|
+
async def stop(agent: str):
|
|
197
|
+
"""Stop a running agent (placeholder for local process management).
|
|
198
|
+
|
|
199
|
+
Use Ctrl+C in the agent's terminal to stop it.
|
|
200
|
+
This command notifies SwiftGate that the agent has stopped.
|
|
201
|
+
"""
|
|
202
|
+
if not is_setup():
|
|
203
|
+
click.echo(click.style("Not set up. Run: swiftgate-cli setup --token sg_acc_...", fg="red"))
|
|
204
|
+
sys.exit(1)
|
|
205
|
+
|
|
206
|
+
client = APIClient()
|
|
207
|
+
ok = await client.signal_agent_stopped(agent)
|
|
208
|
+
if ok:
|
|
209
|
+
click.echo(click.style(f"✓ SwiftGate notified: {agent} stopped.", fg="green"))
|
|
210
|
+
else:
|
|
211
|
+
click.echo(click.style(f"Failed to notify SwiftGate for {agent}", fg="red"))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
def _agent_command(agent_type: str) -> list[str] | None:
|
|
217
|
+
"""Map agent type to the binary command."""
|
|
218
|
+
commands = {
|
|
219
|
+
"opencode": ["opencode"],
|
|
220
|
+
"claude_code": ["claude"],
|
|
221
|
+
"aider": ["aider"],
|
|
222
|
+
}
|
|
223
|
+
return commands.get(agent_type)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _get_stored_token(slug: str) -> str:
|
|
227
|
+
"""Get saved MCP token from local config."""
|
|
228
|
+
from swiftgate_cli.config import load
|
|
229
|
+
cfg = load()
|
|
230
|
+
return cfg.get("agents", {}).get(slug, {}).get("mcp_token", "")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _store_token(slug: str, token: str) -> None:
|
|
234
|
+
"""Save MCP token to local config."""
|
|
235
|
+
from swiftgate_cli.config import load, save
|
|
236
|
+
cfg = load()
|
|
237
|
+
cfg.setdefault("agents", {}).setdefault(slug, {})["mcp_token"] = token
|
|
238
|
+
save(cfg)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _get_stored_type(slug: str) -> str:
|
|
242
|
+
"""Get saved agent type from local config."""
|
|
243
|
+
from swiftgate_cli.config import load
|
|
244
|
+
return load().get("agents", {}).get(slug, {}).get("type", "")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
cli()
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""SSE Listener — CLI wake channel via Server-Sent Events.
|
|
2
|
+
|
|
3
|
+
Wave 8 (2026-07-25): Connects to GET /api/external-agents/cli/listen
|
|
4
|
+
and listens for MCP-conformant notifications/inbox/updated events.
|
|
5
|
+
On wake, calls the AgentBridge to inject commands into the agent's stdin.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Callable
|
|
14
|
+
|
|
15
|
+
import aiohttp
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("swiftgate.sse")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SSEWakeListener:
|
|
21
|
+
"""SSE stream client for CLI wake notifications.
|
|
22
|
+
|
|
23
|
+
Connects one SSE stream per CLI installation.
|
|
24
|
+
Parses MCP-conformant "event: message" notifications
|
|
25
|
+
and calls registered wake handlers per workspace_slug.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, sse_url: str):
|
|
29
|
+
self._url = sse_url
|
|
30
|
+
self._handlers: dict[str, Callable] = {}
|
|
31
|
+
self._running = False
|
|
32
|
+
self._reconnect_delay = 1.0
|
|
33
|
+
self._max_reconnect_delay = 30.0
|
|
34
|
+
|
|
35
|
+
def register(self, workspace_slug: str, handler: Callable) -> None:
|
|
36
|
+
"""Register a wake handler for a specific agent workspace."""
|
|
37
|
+
self._handlers[workspace_slug] = handler
|
|
38
|
+
|
|
39
|
+
def unregister(self, workspace_slug: str) -> None:
|
|
40
|
+
"""Remove a wake handler."""
|
|
41
|
+
self._handlers.pop(workspace_slug, None)
|
|
42
|
+
|
|
43
|
+
async def listen(self) -> None:
|
|
44
|
+
"""Connect to SSE stream and process wake events forever.
|
|
45
|
+
|
|
46
|
+
Reconnects with exponential backoff on connection loss.
|
|
47
|
+
"""
|
|
48
|
+
self._running = True
|
|
49
|
+
while self._running:
|
|
50
|
+
try:
|
|
51
|
+
await self._connect_and_read()
|
|
52
|
+
except asyncio.CancelledError:
|
|
53
|
+
break
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
logger.warning("SSE connection lost: %s — reconnecting in %.1fs",
|
|
56
|
+
exc, self._reconnect_delay)
|
|
57
|
+
|
|
58
|
+
if not self._running:
|
|
59
|
+
break
|
|
60
|
+
await asyncio.sleep(self._reconnect_delay)
|
|
61
|
+
self._reconnect_delay = min(
|
|
62
|
+
self._reconnect_delay * 2,
|
|
63
|
+
self._max_reconnect_delay,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
logger.info("SSE listener stopped")
|
|
67
|
+
|
|
68
|
+
async def _connect_and_read(self) -> None:
|
|
69
|
+
"""Single SSE connection loop."""
|
|
70
|
+
timeout = aiohttp.ClientTimeout(total=0, sock_read=3600)
|
|
71
|
+
async with aiohttp.ClientSession() as session:
|
|
72
|
+
async with session.get(self._url, timeout=timeout) as resp:
|
|
73
|
+
if resp.status != 200:
|
|
74
|
+
logger.error("SSE returned %d: %s", resp.status,
|
|
75
|
+
await resp.text())
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
logger.info("SSE listener connected")
|
|
79
|
+
self._reconnect_delay = 1.0 # Reset on successful connect
|
|
80
|
+
|
|
81
|
+
async for line in resp.content:
|
|
82
|
+
if not self._running:
|
|
83
|
+
break
|
|
84
|
+
decoded = self._decode_line(line)
|
|
85
|
+
if decoded is None:
|
|
86
|
+
continue
|
|
87
|
+
event, data = decoded
|
|
88
|
+
await self._handle_event(event, data)
|
|
89
|
+
|
|
90
|
+
def _decode_line(self, line: bytes) -> tuple[str, dict] | None:
|
|
91
|
+
"""Parse SSE line into (event_type, data_dict) or None."""
|
|
92
|
+
decoded = line.decode("utf-8", errors="replace").strip()
|
|
93
|
+
if not decoded:
|
|
94
|
+
return None
|
|
95
|
+
if decoded.startswith(":"):
|
|
96
|
+
return None # Comment
|
|
97
|
+
if decoded.startswith("event: "):
|
|
98
|
+
self._last_event = decoded[7:]
|
|
99
|
+
return None
|
|
100
|
+
if decoded.startswith("data: "):
|
|
101
|
+
event = getattr(self, "_last_event", "message")
|
|
102
|
+
data_str = decoded[6:]
|
|
103
|
+
try:
|
|
104
|
+
data = json.loads(data_str)
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
return None
|
|
107
|
+
return (event, data)
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
async def _handle_event(self, event: str, data: dict) -> None:
|
|
111
|
+
"""Process an SSE event. Only MCP notifications/inbox/updated."""
|
|
112
|
+
method = data.get("method", "")
|
|
113
|
+
if method != "notifications/inbox/updated":
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
params = data.get("params", {})
|
|
117
|
+
workspace_slug = params.get("workspace_slug", "")
|
|
118
|
+
count = params.get("count", 0)
|
|
119
|
+
|
|
120
|
+
if not workspace_slug:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
handler = self._handlers.get(workspace_slug)
|
|
124
|
+
if handler:
|
|
125
|
+
try:
|
|
126
|
+
if asyncio.iscoroutinefunction(handler):
|
|
127
|
+
await handler(workspace_slug, count)
|
|
128
|
+
else:
|
|
129
|
+
handler(workspace_slug, count)
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
logger.warning("Wake handler failed for %s: %s",
|
|
132
|
+
workspace_slug, exc)
|
|
133
|
+
|
|
134
|
+
def stop(self) -> None:
|
|
135
|
+
"""Signal the listener to stop."""
|
|
136
|
+
self._running = False
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Tests for swiftgate-cli agent bridge — wake injection.
|
|
2
|
+
|
|
3
|
+
Wave 8 (2026-07-25): Tests wake command injection and pty management
|
|
4
|
+
without actually spawning real agent processes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import pty
|
|
9
|
+
from unittest.mock import MagicMock, patch
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from swiftgate_cli.agent_bridge import AgentBridge
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def bridge():
|
|
18
|
+
return AgentBridge(
|
|
19
|
+
workspace_slug="test-agent",
|
|
20
|
+
agent_type="opencode",
|
|
21
|
+
command=["echo", "hello"],
|
|
22
|
+
mcp_token="sg_ea_tokentest",
|
|
23
|
+
server="https://vps.example.com",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_bridge_initial_state(bridge):
|
|
28
|
+
assert bridge._slug == "test-agent"
|
|
29
|
+
assert bridge._type == "opencode"
|
|
30
|
+
assert bridge._pid is None
|
|
31
|
+
assert bridge._running is False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_wake_does_nothing_when_not_running(bridge):
|
|
35
|
+
"""wake() should not crash when agent isn't running."""
|
|
36
|
+
bridge.wake(count=3)
|
|
37
|
+
# No exception = pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_stop_does_nothing_when_not_running(bridge):
|
|
41
|
+
"""stop() should be safe to call on a non-running bridge."""
|
|
42
|
+
bridge.stop()
|
|
43
|
+
# No exception = pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_wake_injects_when_running():
|
|
47
|
+
"""wake() writes to pty master fd without crashing."""
|
|
48
|
+
master_fd, slave_fd = pty.openpty()
|
|
49
|
+
try:
|
|
50
|
+
bridge = AgentBridge("test", "opencode", ["echo"], "sg_ea_x")
|
|
51
|
+
bridge._pid = 99999
|
|
52
|
+
bridge._master_fd = master_fd
|
|
53
|
+
bridge._running = True
|
|
54
|
+
|
|
55
|
+
bridge.wake(count=5)
|
|
56
|
+
# No crash = pass. Actual text injection tested via integration.
|
|
57
|
+
finally:
|
|
58
|
+
os.close(master_fd)
|
|
59
|
+
os.close(slave_fd)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_wake_command_format():
|
|
63
|
+
"""Wake command injection is valid — verify the bridge doesn't crash."""
|
|
64
|
+
bridge = AgentBridge("agent-x", "opencode", ["echo"], "token")
|
|
65
|
+
master_fd, slave_fd = pty.openpty()
|
|
66
|
+
try:
|
|
67
|
+
bridge._pid = 12345
|
|
68
|
+
bridge._master_fd = master_fd
|
|
69
|
+
bridge._running = True
|
|
70
|
+
bridge.wake(count=3)
|
|
71
|
+
# No crash. The injected text format is verified via code review:
|
|
72
|
+
# msg = f"\n# SwiftGate: {count} pending tasks. Run swiftgate_agent_get_task\n"
|
|
73
|
+
assert True
|
|
74
|
+
finally:
|
|
75
|
+
os.close(master_fd)
|
|
76
|
+
os.close(slave_fd)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_opencode_config_writer():
|
|
80
|
+
"""MCP config writer creates valid opencode.json."""
|
|
81
|
+
import tempfile
|
|
82
|
+
from pathlib import Path
|
|
83
|
+
import json
|
|
84
|
+
|
|
85
|
+
from swiftgate_cli.agent_bridge import _write_opencode_config
|
|
86
|
+
|
|
87
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
88
|
+
home = Path(tmp)
|
|
89
|
+
config_dir = home / ".config" / "opencode"
|
|
90
|
+
config_file = config_dir / "opencode.json"
|
|
91
|
+
|
|
92
|
+
with patch("pathlib.Path.home", return_value=home):
|
|
93
|
+
_write_opencode_config("test-slug", "sg_ea_token", "https://vps.test.com")
|
|
94
|
+
|
|
95
|
+
assert config_file.exists()
|
|
96
|
+
data = json.loads(config_file.read_text())
|
|
97
|
+
assert "mcp" in data
|
|
98
|
+
assert "swiftgate" in data["mcp"]
|
|
99
|
+
assert data["mcp"]["swiftgate"]["type"] == "remote"
|
|
100
|
+
assert "test-slug" in data["mcp"]["swiftgate"]["url"]
|
|
101
|
+
assert "sg_ea_token" in data["mcp"]["swiftgate"]["url"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Tests for swiftgate-cli config module."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.fixture(autouse=True)
|
|
7
|
+
def _patch_config_home(monkeypatch, tmp_path):
|
|
8
|
+
test_dir = tmp_path / "swiftgate"
|
|
9
|
+
test_file = test_dir / "config.json"
|
|
10
|
+
test_dir.mkdir(parents=True, exist_ok=True)
|
|
11
|
+
monkeypatch.setattr("swiftgate_cli.config._CONFIG_DIR", test_dir)
|
|
12
|
+
monkeypatch.setattr("swiftgate_cli.config._CONFIG_FILE", test_file)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_is_setup_false_when_no_config():
|
|
16
|
+
from swiftgate_cli.config import is_setup
|
|
17
|
+
assert is_setup() is False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_set_setup_saves_token():
|
|
21
|
+
from swiftgate_cli.config import set_setup, is_setup, get_account_token
|
|
22
|
+
set_setup("sg_acc_testtoken123")
|
|
23
|
+
assert is_setup() is True
|
|
24
|
+
assert get_account_token() == "sg_acc_testtoken123"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_add_get_remove_agent():
|
|
28
|
+
from swiftgate_cli.config import add_agent, get_agent, remove_agent
|
|
29
|
+
add_agent("ag1", "opencode", "hash1", "http")
|
|
30
|
+
assert get_agent("ag1")["type"] == "opencode"
|
|
31
|
+
remove_agent("ag1")
|
|
32
|
+
assert get_agent("ag1") is None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_list_agents_two_entries():
|
|
36
|
+
from swiftgate_cli.config import add_agent, list_agents
|
|
37
|
+
add_agent("x", "opencode", "hx")
|
|
38
|
+
add_agent("y", "claude_code", "hy")
|
|
39
|
+
agents = list_agents()
|
|
40
|
+
# Just verify both are present
|
|
41
|
+
assert "x" in agents
|
|
42
|
+
assert "y" in agents
|
|
43
|
+
assert len(agents) == 2
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_config_file_persisted():
|
|
47
|
+
from swiftgate_cli.config import set_setup, _CONFIG_FILE
|
|
48
|
+
set_setup("sg_acc_test")
|
|
49
|
+
assert _CONFIG_FILE.exists()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Tests for swiftgate-cli SSE listener.
|
|
2
|
+
|
|
3
|
+
Tests SSE event parsing and handler dispatch without network.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from unittest.mock import MagicMock, patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from swiftgate_cli.sse_listener import SSEWakeListener
|
|
12
|
+
|
|
13
|
+
TEST_URL = "https://vps.example.com/api/external-agents/cli/listen?token=sg_acc_test"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def listener():
|
|
18
|
+
return SSEWakeListener(TEST_URL)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_register_and_unregister_handler(listener):
|
|
22
|
+
handler = MagicMock()
|
|
23
|
+
listener.register("agent-a", handler)
|
|
24
|
+
assert "agent-a" in listener._handlers
|
|
25
|
+
listener.unregister("agent-a")
|
|
26
|
+
assert "agent-a" not in listener._handlers
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def _simulate_sse_lines(listener, lines: list[bytes]):
|
|
30
|
+
"""Helper to test SSE line parsing without real HTTP."""
|
|
31
|
+
for line in lines:
|
|
32
|
+
decoded = listener._decode_line(line)
|
|
33
|
+
if decoded:
|
|
34
|
+
event, data = decoded
|
|
35
|
+
await listener._handle_event(event, data)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.asyncio
|
|
39
|
+
async def test_parse_notification_line(listener):
|
|
40
|
+
"""SSE data line with MCP notification is parsed correctly."""
|
|
41
|
+
result = listener._decode_line(
|
|
42
|
+
b'data: {"jsonrpc":"2.0","method":"notifications/inbox/updated","params":{"workspace_slug":"agent-a","count":3}}'
|
|
43
|
+
)
|
|
44
|
+
assert result is not None
|
|
45
|
+
event, data = result
|
|
46
|
+
assert event == "message"
|
|
47
|
+
assert data["method"] == "notifications/inbox/updated"
|
|
48
|
+
assert data["params"]["workspace_slug"] == "agent-a"
|
|
49
|
+
assert data["params"]["count"] == 3
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.asyncio
|
|
53
|
+
async def test_ignore_heartbeat_comment(listener):
|
|
54
|
+
"""SSE comment lines (starting with :) are ignored."""
|
|
55
|
+
result = listener._decode_line(b": heartbeat #30")
|
|
56
|
+
assert result is None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.asyncio
|
|
60
|
+
async def test_ignore_empty_line(listener):
|
|
61
|
+
"""Empty SSE lines are ignored."""
|
|
62
|
+
result = listener._decode_line(b"")
|
|
63
|
+
assert result is None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.asyncio
|
|
67
|
+
async def test_ignore_non_inbox_notification(listener):
|
|
68
|
+
"""Non-inbox methods are not dispatched."""
|
|
69
|
+
data = {"jsonrpc": "2.0", "method": "some/other/notification", "params": {}}
|
|
70
|
+
await listener._handle_event("message", data)
|
|
71
|
+
# No handler registered, no exception raised
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.asyncio
|
|
75
|
+
async def test_wake_calls_registered_handler(listener):
|
|
76
|
+
"""Registered handlers are called on inbox/updated."""
|
|
77
|
+
called = []
|
|
78
|
+
|
|
79
|
+
async def my_handler(slug, count):
|
|
80
|
+
called.append((slug, count))
|
|
81
|
+
|
|
82
|
+
listener.register("agent-x", my_handler)
|
|
83
|
+
|
|
84
|
+
await listener._handle_event("message", {
|
|
85
|
+
"jsonrpc": "2.0",
|
|
86
|
+
"method": "notifications/inbox/updated",
|
|
87
|
+
"params": {"workspace_slug": "agent-x", "count": 5},
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
assert len(called) == 1
|
|
91
|
+
assert called[0] == ("agent-x", 5)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.asyncio
|
|
95
|
+
async def test_wake_does_not_call_unregistered(listener):
|
|
96
|
+
"""Only registered handlers are called."""
|
|
97
|
+
called = []
|
|
98
|
+
|
|
99
|
+
async def my_handler(slug, count):
|
|
100
|
+
called.append((slug, count))
|
|
101
|
+
|
|
102
|
+
listener.register("agent-x", my_handler)
|
|
103
|
+
|
|
104
|
+
await listener._handle_event("message", {
|
|
105
|
+
"jsonrpc": "2.0",
|
|
106
|
+
"method": "notifications/inbox/updated",
|
|
107
|
+
"params": {"workspace_slug": "agent-y", "count": 1},
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
assert len(called) == 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_event_field_tracks_event_type(listener):
|
|
114
|
+
"""SSE event: field sets the event type for subsequent data:."""
|
|
115
|
+
listener._decode_line(b"event: custom_type")
|
|
116
|
+
result = listener._decode_line(
|
|
117
|
+
b'data: {"jsonrpc":"2.0","method":"notifications/inbox/updated","params":{"workspace_slug":"x","count":1}}'
|
|
118
|
+
)
|
|
119
|
+
assert result is not None
|
|
120
|
+
event, data = result
|
|
121
|
+
assert event == "custom_type"
|