runspec-chat 0.1.0__py3-none-any.whl
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.
- runspec_chat/__init__.py +3 -0
- runspec_chat/adapter.py +30 -0
- runspec_chat/adapters/__init__.py +0 -0
- runspec_chat/adapters/anthropic_direct.py +70 -0
- runspec_chat/app.py +468 -0
- runspec_chat/chat.py +82 -0
- runspec_chat/runspec.toml +39 -0
- runspec_chat/setup_keys.py +87 -0
- runspec_chat-0.1.0.dist-info/METADATA +15 -0
- runspec_chat-0.1.0.dist-info/RECORD +12 -0
- runspec_chat-0.1.0.dist-info/WHEEL +4 -0
- runspec_chat-0.1.0.dist-info/entry_points.txt +3 -0
runspec_chat/__init__.py
ADDED
runspec_chat/adapter.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ToolCall:
|
|
8
|
+
id: str
|
|
9
|
+
name: str
|
|
10
|
+
input: dict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ChatResponse:
|
|
15
|
+
text: str | None
|
|
16
|
+
tool_calls: list[ToolCall]
|
|
17
|
+
stop_reason: str # "tool_use", "end_turn", "stop"
|
|
18
|
+
_raw: Any = field(repr=False, default=None)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ModelAdapter(ABC):
|
|
22
|
+
@abstractmethod
|
|
23
|
+
async def chat(self, messages: list[dict], tools: list[dict]) -> ChatResponse: ...
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def make_tool_turn(
|
|
27
|
+
self, response: ChatResponse, results: list[tuple[ToolCall, str]]
|
|
28
|
+
) -> list[dict]:
|
|
29
|
+
"""Returns [assistant_turn, tool_result_turn] messages to append to conversation."""
|
|
30
|
+
...
|
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import anthropic
|
|
2
|
+
|
|
3
|
+
from ..adapter import ChatResponse, ModelAdapter, ToolCall
|
|
4
|
+
|
|
5
|
+
DEFAULT_MODEL = "claude-haiku-4-5-20251001"
|
|
6
|
+
DEFAULT_SYSTEM = (
|
|
7
|
+
"You are a helpful assistant with access to tools running on remote Linux hosts. "
|
|
8
|
+
"Use tools when they help answer the user's request. "
|
|
9
|
+
"When you call a tool, briefly explain what you're doing before the result."
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AnthropicAdapter(ModelAdapter):
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
model: str = DEFAULT_MODEL,
|
|
17
|
+
system: str = DEFAULT_SYSTEM,
|
|
18
|
+
api_key: str | None = None,
|
|
19
|
+
):
|
|
20
|
+
# api_key=None falls back to ANTHROPIC_API_KEY env var
|
|
21
|
+
self.client = anthropic.AsyncAnthropic(api_key=api_key)
|
|
22
|
+
self.model = model
|
|
23
|
+
self.system = system
|
|
24
|
+
|
|
25
|
+
async def chat(self, messages: list[dict], tools: list[dict]) -> ChatResponse:
|
|
26
|
+
kwargs: dict = dict(
|
|
27
|
+
model=self.model,
|
|
28
|
+
max_tokens=4096,
|
|
29
|
+
messages=messages,
|
|
30
|
+
system=self.system,
|
|
31
|
+
)
|
|
32
|
+
if tools:
|
|
33
|
+
kwargs["tools"] = tools
|
|
34
|
+
|
|
35
|
+
response = await self.client.messages.create(**kwargs)
|
|
36
|
+
|
|
37
|
+
text = next(
|
|
38
|
+
(block.text for block in response.content if hasattr(block, "text")),
|
|
39
|
+
None,
|
|
40
|
+
)
|
|
41
|
+
tool_calls = [
|
|
42
|
+
ToolCall(id=block.id, name=block.name, input=block.input)
|
|
43
|
+
for block in response.content
|
|
44
|
+
if block.type == "tool_use"
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
return ChatResponse(
|
|
48
|
+
text=text,
|
|
49
|
+
tool_calls=tool_calls,
|
|
50
|
+
stop_reason=response.stop_reason,
|
|
51
|
+
_raw=response,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def make_tool_turn(
|
|
55
|
+
self, response: ChatResponse, results: list[tuple[ToolCall, str]]
|
|
56
|
+
) -> list[dict]:
|
|
57
|
+
return [
|
|
58
|
+
{"role": "assistant", "content": response._raw.content},
|
|
59
|
+
{
|
|
60
|
+
"role": "user",
|
|
61
|
+
"content": [
|
|
62
|
+
{
|
|
63
|
+
"type": "tool_result",
|
|
64
|
+
"tool_use_id": tc.id,
|
|
65
|
+
"content": result,
|
|
66
|
+
}
|
|
67
|
+
for tc, result in results
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
]
|
runspec_chat/app.py
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import shlex
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import tomllib
|
|
11
|
+
except ImportError:
|
|
12
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
13
|
+
|
|
14
|
+
import chainlit as cl
|
|
15
|
+
from mcp import ClientSession, StdioServerParameters
|
|
16
|
+
from mcp.client.stdio import stdio_client
|
|
17
|
+
|
|
18
|
+
from runspec_chat.adapter import ChatResponse, ToolCall
|
|
19
|
+
from runspec_chat.adapters.anthropic_direct import AnthropicAdapter
|
|
20
|
+
from runspec_chat.chat import _host_pass_key, _shared_pass_key, _sync_user_env
|
|
21
|
+
|
|
22
|
+
_LOCAL_CONN = "__runspec_local__"
|
|
23
|
+
_DEFAULT_MODEL = os.environ.get("RUNSPEC_CHAT_MODEL", "claude-haiku-4-5-20251001")
|
|
24
|
+
_SELF_TOOLS = {"runspec-chat", "setup-keys"} # hide from "Local tools ready" message
|
|
25
|
+
_COMMANDS_HIDE = {"runspec-chat"} # hide from slash-command autocomplete
|
|
26
|
+
|
|
27
|
+
_sync_user_env(
|
|
28
|
+
hosts_path=Path(
|
|
29
|
+
os.environ.get("RUNSPEC_CHAT_HOSTS", "~/.config/runspec-chat/hosts.toml")
|
|
30
|
+
).expanduser(),
|
|
31
|
+
chainlit_config=Path(__file__).parent.parent / ".chainlit" / "config.toml",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def _refresh_commands() -> None:
|
|
36
|
+
# Local tools stored separately so they survive mcp_tools session resets
|
|
37
|
+
local_tools: list = cl.user_session.get("local_tools", [])
|
|
38
|
+
mcp_tools: dict = cl.user_session.get("mcp_tools", {})
|
|
39
|
+
|
|
40
|
+
seen: set[str] = set()
|
|
41
|
+
commands = []
|
|
42
|
+
for t in local_tools + [t for conn, tools in mcp_tools.items() for t in tools if conn != _LOCAL_CONN]:
|
|
43
|
+
if t["name"] in _COMMANDS_HIDE or t["name"] in seen:
|
|
44
|
+
continue
|
|
45
|
+
seen.add(t["name"])
|
|
46
|
+
icon = "key" if t["name"] == "setup-keys" else "terminal"
|
|
47
|
+
commands.append({
|
|
48
|
+
"id": t["name"],
|
|
49
|
+
"description": t.get("description") or t["name"],
|
|
50
|
+
"icon": icon,
|
|
51
|
+
"button": False,
|
|
52
|
+
})
|
|
53
|
+
try:
|
|
54
|
+
await cl.context.emitter.set_commands(commands)
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
await cl.Message(content=f"⚠ Could not update command list: {exc}").send()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _local_runspec_exe() -> str:
|
|
60
|
+
"""Return the runspec executable in the same venv as this process."""
|
|
61
|
+
exe = Path(sys.executable).parent / "runspec"
|
|
62
|
+
return str(exe) if exe.exists() else "runspec"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# MCP connection lifecycle (user-initiated via Chainlit plug icon)
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
@cl.on_mcp_connect
|
|
70
|
+
async def on_mcp_connect(connection, session: ClientSession) -> None:
|
|
71
|
+
result = await session.list_tools()
|
|
72
|
+
tools = [
|
|
73
|
+
{"name": t.name, "description": t.description, "input_schema": t.inputSchema}
|
|
74
|
+
for t in result.tools
|
|
75
|
+
]
|
|
76
|
+
mcp_tools: dict = cl.user_session.get("mcp_tools", {})
|
|
77
|
+
mcp_tools[connection.name] = tools
|
|
78
|
+
cl.user_session.set("mcp_tools", mcp_tools)
|
|
79
|
+
tool_names = [t["name"] for t in tools]
|
|
80
|
+
await cl.Message(
|
|
81
|
+
content=f"Connected to **{connection.name}** — {len(tools)} tool(s): `{'`, `'.join(tool_names)}`"
|
|
82
|
+
).send()
|
|
83
|
+
await _refresh_commands()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@cl.on_mcp_disconnect
|
|
87
|
+
async def on_mcp_disconnect(name: str, session: ClientSession) -> None:
|
|
88
|
+
mcp_tools: dict = cl.user_session.get("mcp_tools", {})
|
|
89
|
+
mcp_tools.pop(name, None)
|
|
90
|
+
cl.user_session.set("mcp_tools", mcp_tools)
|
|
91
|
+
await _refresh_commands()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Chat lifecycle
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
@cl.on_chat_start
|
|
99
|
+
async def on_chat_start() -> None:
|
|
100
|
+
cl.user_session.set("messages", [])
|
|
101
|
+
cl.user_session.set("mcp_tools", {})
|
|
102
|
+
cl.user_session.set("local_sessions", {})
|
|
103
|
+
|
|
104
|
+
# API key: browser settings take precedence over .env
|
|
105
|
+
env = cl.user_session.get("env") or {}
|
|
106
|
+
api_key = env.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_API_KEY")
|
|
107
|
+
if not api_key:
|
|
108
|
+
await cl.Message(
|
|
109
|
+
content="No API key found. Open **Settings** (⚙ gear icon) and enter your `ANTHROPIC_API_KEY`."
|
|
110
|
+
).send()
|
|
111
|
+
cl.user_session.set("adapter", AnthropicAdapter(model=_DEFAULT_MODEL, api_key=api_key or None))
|
|
112
|
+
|
|
113
|
+
await _connect_local()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@cl.on_chat_end
|
|
117
|
+
async def on_chat_end() -> None:
|
|
118
|
+
stop: asyncio.Event | None = cl.user_session.get("local_stop")
|
|
119
|
+
if stop:
|
|
120
|
+
stop.set()
|
|
121
|
+
task: asyncio.Task | None = cl.user_session.get("local_task")
|
|
122
|
+
if task:
|
|
123
|
+
try:
|
|
124
|
+
await asyncio.wait_for(asyncio.shield(task), timeout=2.0)
|
|
125
|
+
except Exception:
|
|
126
|
+
task.cancel()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def _local_mcp_task(
|
|
130
|
+
session_holder: list,
|
|
131
|
+
tools_holder: list,
|
|
132
|
+
ready: asyncio.Event,
|
|
133
|
+
stop: asyncio.Event,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Runs the full local MCP session in one task so anyio cancel scopes are
|
|
136
|
+
entered and exited in the same task — required by anyio's task model."""
|
|
137
|
+
params = StdioServerParameters(command=_local_runspec_exe(), args=["serve"])
|
|
138
|
+
try:
|
|
139
|
+
async with stdio_client(params) as (read, write):
|
|
140
|
+
async with ClientSession(read, write) as session:
|
|
141
|
+
await session.initialize()
|
|
142
|
+
result = await session.list_tools()
|
|
143
|
+
tools_holder.extend(
|
|
144
|
+
{"name": t.name, "description": t.description, "input_schema": t.inputSchema}
|
|
145
|
+
for t in result.tools
|
|
146
|
+
)
|
|
147
|
+
session_holder.append(session)
|
|
148
|
+
ready.set()
|
|
149
|
+
await stop.wait()
|
|
150
|
+
except Exception:
|
|
151
|
+
ready.set() # unblock _connect_local even on failure
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def _connect_local() -> None:
|
|
155
|
+
session_holder: list = []
|
|
156
|
+
tools_holder: list = []
|
|
157
|
+
ready = asyncio.Event()
|
|
158
|
+
stop = asyncio.Event()
|
|
159
|
+
|
|
160
|
+
task = asyncio.create_task(_local_mcp_task(session_holder, tools_holder, ready, stop))
|
|
161
|
+
cl.user_session.set("local_stop", stop)
|
|
162
|
+
cl.user_session.set("local_task", task)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
await asyncio.wait_for(asyncio.shield(ready.wait()), timeout=5.0)
|
|
166
|
+
except asyncio.TimeoutError:
|
|
167
|
+
await cl.Message(content="⚠ Local tools timed out on startup.").send()
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
if not session_holder:
|
|
171
|
+
await cl.Message(content="⚠ Could not start local tools.").send()
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
mcp_tools: dict = cl.user_session.get("mcp_tools", {})
|
|
175
|
+
mcp_tools[_LOCAL_CONN] = tools_holder
|
|
176
|
+
cl.user_session.set("mcp_tools", mcp_tools)
|
|
177
|
+
cl.user_session.set("local_session", session_holder[0])
|
|
178
|
+
cl.user_session.set("local_tools", tools_holder) # survives mcp_tools resets
|
|
179
|
+
|
|
180
|
+
user_tools = [t["name"] for t in tools_holder if t["name"] not in _SELF_TOOLS]
|
|
181
|
+
if user_tools:
|
|
182
|
+
await cl.Message(content=f"Local tools ready: `{'`, `'.join(user_tools)}`").send()
|
|
183
|
+
else:
|
|
184
|
+
await cl.Message(
|
|
185
|
+
content="Ready. Connect a remote host via the **plug icon**, or type `/setup-keys` to set up SSH keys."
|
|
186
|
+
).send()
|
|
187
|
+
await _refresh_commands()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Built-in: setup-keys (runs in-process so it can use browser credentials)
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
async def _builtin_setup_keys(tool_input: dict) -> str:
|
|
195
|
+
hosts_path = Path(tool_input.get("hosts", "~/.config/runspec-chat/hosts.toml")).expanduser()
|
|
196
|
+
if not hosts_path.exists():
|
|
197
|
+
return f"No hosts config at `{hosts_path}`. Copy `hosts.toml.example` and edit it."
|
|
198
|
+
|
|
199
|
+
with open(hosts_path, "rb") as f:
|
|
200
|
+
config = tomllib.load(f)
|
|
201
|
+
|
|
202
|
+
defaults = config.get("config", {})
|
|
203
|
+
default_user = defaults.get("user")
|
|
204
|
+
default_password = defaults.get("password")
|
|
205
|
+
|
|
206
|
+
ssh_hosts = [
|
|
207
|
+
(name, info)
|
|
208
|
+
for name, info in config.get("hosts", {}).items()
|
|
209
|
+
if info.get("ssh")
|
|
210
|
+
]
|
|
211
|
+
if not ssh_hosts:
|
|
212
|
+
return "No SSH hosts found in config (hosts with an `ssh` field)."
|
|
213
|
+
|
|
214
|
+
# Resolve credentials — password comes from browser Settings (user_env)
|
|
215
|
+
env_vals = cl.user_session.get("env") or {}
|
|
216
|
+
resolved: list[tuple[str, str, str]] = [] # (name, target, password)
|
|
217
|
+
missing: list[str] = []
|
|
218
|
+
for name, info in ssh_hosts:
|
|
219
|
+
user = info.get("user") or default_user
|
|
220
|
+
target = f"{user}@{info['ssh']}" if user else info["ssh"]
|
|
221
|
+
# Hosts with their own user get a dedicated key; others share SSH_PASS
|
|
222
|
+
pass_key = _host_pass_key(name) if info.get("user") else _shared_pass_key()
|
|
223
|
+
password = env_vals.get(pass_key)
|
|
224
|
+
if not password:
|
|
225
|
+
missing.append(f" - `{name}`: enter `{pass_key}` in Settings (⚙)")
|
|
226
|
+
else:
|
|
227
|
+
resolved.append((name, target, password))
|
|
228
|
+
|
|
229
|
+
if missing and not resolved:
|
|
230
|
+
return (
|
|
231
|
+
"No passwords set. Open **Settings** (⚙) and fill in:\n"
|
|
232
|
+
+ "\n".join(missing)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
key_type = tool_input.get("key_type", "ed25519")
|
|
236
|
+
key_path = Path.home() / ".ssh" / f"runspec-chat_{key_type}"
|
|
237
|
+
pub_key = key_path.with_suffix(".pub")
|
|
238
|
+
|
|
239
|
+
if not key_path.exists():
|
|
240
|
+
async with cl.Step(name="ssh-keygen") as step:
|
|
241
|
+
proc = await asyncio.create_subprocess_exec(
|
|
242
|
+
"ssh-keygen", "-t", key_type, "-f", str(key_path), "-N", "", "-C", "runspec-chat",
|
|
243
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
244
|
+
)
|
|
245
|
+
_, stderr = await proc.communicate()
|
|
246
|
+
if proc.returncode != 0:
|
|
247
|
+
step.output = f"Failed: {stderr.decode()}"
|
|
248
|
+
return step.output
|
|
249
|
+
step.output = f"Created {pub_key}"
|
|
250
|
+
|
|
251
|
+
is_windows = sys.platform == "win32"
|
|
252
|
+
has_sshpass = not is_windows and bool(shutil.which("sshpass"))
|
|
253
|
+
|
|
254
|
+
if is_windows or not has_sshpass:
|
|
255
|
+
pub_key_content = pub_key.read_text().strip()
|
|
256
|
+
host_lines = "\n".join(
|
|
257
|
+
f" ssh {target} \"cat >> ~/.ssh/authorized_keys\" << 'EOF'\n {pub_key_content}\n EOF"
|
|
258
|
+
for _, target, _ in resolved
|
|
259
|
+
)
|
|
260
|
+
reason = "Windows" if is_windows else "`sshpass` not installed (`sudo apt-get install sshpass`)"
|
|
261
|
+
return (
|
|
262
|
+
f"Key generated at `{pub_key}` ✓\n\n"
|
|
263
|
+
f"Automated copy unavailable ({reason}). "
|
|
264
|
+
f"Run these commands once on the machine running runspec-chat:\n\n"
|
|
265
|
+
f"```bash\n{host_lines}\n```\n\n"
|
|
266
|
+
f"Or ask your admin to add `{pub_key}` to each host's `~/.ssh/authorized_keys`."
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
ok, failed = [], []
|
|
270
|
+
for name, target, password in resolved:
|
|
271
|
+
async with cl.Step(name=f"ssh-copy-id → {name}") as step:
|
|
272
|
+
proc = await asyncio.create_subprocess_exec(
|
|
273
|
+
"sshpass", "-e", "ssh-copy-id", "-i", str(pub_key), target,
|
|
274
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
275
|
+
env={**os.environ, "SSHPASS": password},
|
|
276
|
+
)
|
|
277
|
+
_, stderr = await proc.communicate()
|
|
278
|
+
if proc.returncode != 0:
|
|
279
|
+
step.output = f"Failed: {stderr.decode().strip()}"
|
|
280
|
+
failed.append(name)
|
|
281
|
+
else:
|
|
282
|
+
step.output = "Done"
|
|
283
|
+
ok.append(name)
|
|
284
|
+
|
|
285
|
+
lines = []
|
|
286
|
+
if missing:
|
|
287
|
+
lines.append("Skipped (no password in Settings): " + ", ".join(f"`{m.split('`')[1]}`" for m in missing))
|
|
288
|
+
if ok:
|
|
289
|
+
lines.append(f"{len(ok)} host(s) configured: {', '.join(f'`{n}`' for n in ok)}")
|
|
290
|
+
if failed:
|
|
291
|
+
lines.append(f"{len(failed)} failed: {', '.join(f'`{n}`' for n in failed)}")
|
|
292
|
+
if ok:
|
|
293
|
+
lines.append(
|
|
294
|
+
f"\nAdd to `~/.ssh/config`:\n```\nHost *\n IdentityFile ~/.ssh/runspec-chat_{key_type}\n```"
|
|
295
|
+
)
|
|
296
|
+
return "\n".join(lines)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# ---------------------------------------------------------------------------
|
|
300
|
+
# Tool dispatch
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
def _get_session(mcp_name: str) -> ClientSession | None:
|
|
304
|
+
"""Return the ClientSession for a named connection, local or Chainlit-managed."""
|
|
305
|
+
if mcp_name == _LOCAL_CONN:
|
|
306
|
+
return cl.user_session.get("local_session")
|
|
307
|
+
pair = cl.context.session.mcp_sessions.get(mcp_name)
|
|
308
|
+
return pair.client if pair else None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@cl.step(type="tool")
|
|
312
|
+
async def call_tool(tool_call: ToolCall) -> str:
|
|
313
|
+
step = cl.context.current_step
|
|
314
|
+
step.name = tool_call.name
|
|
315
|
+
step.input = tool_call.input
|
|
316
|
+
|
|
317
|
+
# setup-keys runs in-process so it can access browser credentials
|
|
318
|
+
if tool_call.name == "setup-keys":
|
|
319
|
+
step.output = await _builtin_setup_keys(tool_call.input)
|
|
320
|
+
return step.output
|
|
321
|
+
|
|
322
|
+
mcp_tools: dict = cl.user_session.get("mcp_tools", {})
|
|
323
|
+
mcp_name = next(
|
|
324
|
+
(conn for conn, tools in mcp_tools.items() if any(t["name"] == tool_call.name for t in tools)),
|
|
325
|
+
None,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
if not mcp_name:
|
|
329
|
+
step.output = json.dumps({"error": f"Tool '{tool_call.name}' not found in any connected MCP server"})
|
|
330
|
+
return step.output
|
|
331
|
+
|
|
332
|
+
mcp_session = _get_session(mcp_name)
|
|
333
|
+
if not mcp_session:
|
|
334
|
+
step.output = json.dumps({"error": f"MCP session '{mcp_name}' unavailable"})
|
|
335
|
+
return step.output
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
raw = await mcp_session.call_tool(tool_call.name, tool_call.input)
|
|
339
|
+
if hasattr(raw, "content") and raw.content:
|
|
340
|
+
step.output = "\n".join(block.text for block in raw.content if hasattr(block, "text"))
|
|
341
|
+
else:
|
|
342
|
+
step.output = str(raw)
|
|
343
|
+
rs_meta = (getattr(raw, "meta", None) or {}).get("runspec", {})
|
|
344
|
+
if rs_meta.get("duration_ms") is not None:
|
|
345
|
+
step.name = f"{tool_call.name} ({rs_meta['duration_ms']}ms)"
|
|
346
|
+
except Exception as exc:
|
|
347
|
+
step.output = json.dumps({"error": str(exc)})
|
|
348
|
+
|
|
349
|
+
return step.output
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
# Slash command handler (/toolname --arg value ...)
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
def _parse_slash(text: str) -> tuple[str, dict]:
|
|
357
|
+
try:
|
|
358
|
+
parts = shlex.split(text[1:])
|
|
359
|
+
except ValueError:
|
|
360
|
+
parts = text[1:].split()
|
|
361
|
+
|
|
362
|
+
name = parts[0] if parts else ""
|
|
363
|
+
args: dict = {}
|
|
364
|
+
i = 1
|
|
365
|
+
while i < len(parts):
|
|
366
|
+
token = parts[i]
|
|
367
|
+
if token.startswith("--"):
|
|
368
|
+
key = token[2:].replace("-", "_")
|
|
369
|
+
if i + 1 < len(parts) and not parts[i + 1].startswith("--"):
|
|
370
|
+
args[key] = parts[i + 1]
|
|
371
|
+
i += 2
|
|
372
|
+
else:
|
|
373
|
+
args[key] = True
|
|
374
|
+
i += 1
|
|
375
|
+
else:
|
|
376
|
+
i += 1
|
|
377
|
+
return name, args
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
async def _handle_slash(text: str) -> None:
|
|
381
|
+
tool_name, tool_input = _parse_slash(text)
|
|
382
|
+
if not tool_name:
|
|
383
|
+
await cl.Message(content="Usage: `/tool_name --arg value`").send()
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
local_tools: list = cl.user_session.get("local_tools", [])
|
|
387
|
+
mcp_tools: dict = cl.user_session.get("mcp_tools", {})
|
|
388
|
+
all_tools = local_tools + [t for conn, tools in mcp_tools.items() for t in tools if conn != _LOCAL_CONN]
|
|
389
|
+
tool_def = next((t for t in all_tools if t["name"] == tool_name), None)
|
|
390
|
+
|
|
391
|
+
if tool_def is None:
|
|
392
|
+
known = sorted({t["name"] for t in all_tools})
|
|
393
|
+
available = ", ".join(f"`{n}`" for n in known) if known else "none"
|
|
394
|
+
await cl.Message(content=f"Unknown tool `{tool_name}`. Available: {available}").send()
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
if tool_input.get("help"):
|
|
398
|
+
schema = tool_def.get("input_schema", {})
|
|
399
|
+
props = schema.get("properties", {})
|
|
400
|
+
required = set(schema.get("required", []))
|
|
401
|
+
desc = tool_def.get("description") or ""
|
|
402
|
+
lines = [f"**/{tool_name}** — {desc}" if desc else f"**/{tool_name}**"]
|
|
403
|
+
if props:
|
|
404
|
+
lines.append("\n**Arguments:**")
|
|
405
|
+
for arg, info in props.items():
|
|
406
|
+
req = " *(required)*" if arg in required else ""
|
|
407
|
+
arg_desc = info.get("description", "")
|
|
408
|
+
arg_type = info.get("type", "")
|
|
409
|
+
lines.append(f" `--{arg}`{req} `{arg_type}` — {arg_desc}")
|
|
410
|
+
else:
|
|
411
|
+
lines.append("No arguments.")
|
|
412
|
+
await cl.Message(content="\n".join(lines)).send()
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
tc = ToolCall(id="slash-0", name=tool_name, input=tool_input)
|
|
416
|
+
result = await call_tool(tc)
|
|
417
|
+
content = result if isinstance(result, str) else str(result)
|
|
418
|
+
await cl.Message(content=f"```\n{content.strip()}\n```").send()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# ---------------------------------------------------------------------------
|
|
422
|
+
# LLM message loop
|
|
423
|
+
# ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
async def _llm_loop(user_text: str) -> None:
|
|
426
|
+
adapter: AnthropicAdapter | None = cl.user_session.get("adapter")
|
|
427
|
+
if not adapter:
|
|
428
|
+
await cl.Message(
|
|
429
|
+
content="No LLM configured. Open **Settings** (⚙ gear icon) and enter your `ANTHROPIC_API_KEY`."
|
|
430
|
+
).send()
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
messages: list = cl.user_session.get("messages", [])
|
|
434
|
+
messages.append({"role": "user", "content": user_text})
|
|
435
|
+
|
|
436
|
+
mcp_tools: dict = cl.user_session.get("mcp_tools", {})
|
|
437
|
+
tools = [t for conn_tools in mcp_tools.values() for t in conn_tools]
|
|
438
|
+
|
|
439
|
+
response: ChatResponse = await adapter.chat(messages, tools)
|
|
440
|
+
|
|
441
|
+
while response.stop_reason == "tool_use":
|
|
442
|
+
results: list[tuple[ToolCall, str]] = []
|
|
443
|
+
for tc in response.tool_calls:
|
|
444
|
+
result = await call_tool(tc)
|
|
445
|
+
results.append((tc, str(result)))
|
|
446
|
+
|
|
447
|
+
messages.extend(adapter.make_tool_turn(response, results))
|
|
448
|
+
response = await adapter.chat(messages, tools)
|
|
449
|
+
|
|
450
|
+
reply = response.text or ""
|
|
451
|
+
await cl.Message(content=reply).send()
|
|
452
|
+
|
|
453
|
+
messages.append({"role": "assistant", "content": reply})
|
|
454
|
+
cl.user_session.set("messages", messages)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# ---------------------------------------------------------------------------
|
|
458
|
+
# Entry point
|
|
459
|
+
# ---------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
@cl.on_message
|
|
462
|
+
async def on_message(msg: cl.Message) -> None:
|
|
463
|
+
await _refresh_commands()
|
|
464
|
+
text = msg.content.strip()
|
|
465
|
+
if text.startswith("/"):
|
|
466
|
+
await _handle_slash(text)
|
|
467
|
+
else:
|
|
468
|
+
await _llm_loop(text)
|
runspec_chat/chat.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import runspec as rs
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import tomllib
|
|
12
|
+
except ImportError:
|
|
13
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _shared_pass_key() -> str:
|
|
17
|
+
return "SSH_PASS"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _host_pass_key(host_name: str) -> str:
|
|
21
|
+
return f"SSH_{host_name.upper().replace('-', '_').replace(' ', '_')}_PASS"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _sync_user_env(hosts_path: Path, chainlit_config: Path) -> None:
|
|
25
|
+
"""Rewrite user_env in .chainlit/config.toml from hosts.toml.
|
|
26
|
+
|
|
27
|
+
Hosts that share the default username → one SSH_PASS field.
|
|
28
|
+
Hosts that declare their own username → individual SSH_{HOST}_PASS fields.
|
|
29
|
+
"""
|
|
30
|
+
user_env = ["ANTHROPIC_API_KEY"]
|
|
31
|
+
|
|
32
|
+
if hosts_path.exists():
|
|
33
|
+
with open(hosts_path, "rb") as f:
|
|
34
|
+
cfg = tomllib.load(f)
|
|
35
|
+
|
|
36
|
+
has_shared = False
|
|
37
|
+
host_keys: list[str] = []
|
|
38
|
+
for name, info in cfg.get("hosts", {}).items():
|
|
39
|
+
if not info.get("ssh"):
|
|
40
|
+
continue
|
|
41
|
+
if info.get("user"):
|
|
42
|
+
host_keys.append(_host_pass_key(name))
|
|
43
|
+
else:
|
|
44
|
+
has_shared = True
|
|
45
|
+
|
|
46
|
+
if has_shared:
|
|
47
|
+
user_env.append(_shared_pass_key())
|
|
48
|
+
user_env.extend(host_keys)
|
|
49
|
+
|
|
50
|
+
if not chainlit_config.exists():
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
text = chainlit_config.read_text()
|
|
54
|
+
text = re.sub(r"user_env = \[.*?\]", f"user_env = {json.dumps(user_env)}", text)
|
|
55
|
+
chainlit_config.write_text(text)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main() -> None:
|
|
59
|
+
spec = rs.parse("runspec-chat")
|
|
60
|
+
|
|
61
|
+
package_root = Path(__file__).parent.parent
|
|
62
|
+
os.environ["CHAINLIT_ROOT"] = str(package_root)
|
|
63
|
+
|
|
64
|
+
hosts_path = Path(str(spec.hosts)).expanduser()
|
|
65
|
+
chainlit_config = package_root / ".chainlit" / "config.toml"
|
|
66
|
+
_sync_user_env(hosts_path, chainlit_config)
|
|
67
|
+
|
|
68
|
+
if spec.model:
|
|
69
|
+
os.environ["RUNSPEC_CHAT_MODEL"] = str(spec.model)
|
|
70
|
+
if spec.hosts:
|
|
71
|
+
os.environ["RUNSPEC_CHAT_HOSTS"] = str(spec.hosts)
|
|
72
|
+
|
|
73
|
+
app_py = Path(__file__).parent / "app.py"
|
|
74
|
+
cmd = [
|
|
75
|
+
sys.executable, "-m", "chainlit", "run", str(app_py),
|
|
76
|
+
"--port", str(spec.port),
|
|
77
|
+
"--host", "0.0.0.0",
|
|
78
|
+
]
|
|
79
|
+
try:
|
|
80
|
+
sys.exit(subprocess.run(cmd).returncode)
|
|
81
|
+
except KeyboardInterrupt:
|
|
82
|
+
sys.exit(0)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[runspec-chat]
|
|
2
|
+
description = "Launch the runspec-chat Chainlit web app"
|
|
3
|
+
|
|
4
|
+
[runspec-chat.args.hosts]
|
|
5
|
+
type = "path"
|
|
6
|
+
short = "-H"
|
|
7
|
+
description = "Path to hosts configuration file"
|
|
8
|
+
default = "~/.config/runspec-chat/hosts.toml"
|
|
9
|
+
required = false
|
|
10
|
+
|
|
11
|
+
[runspec-chat.args.port]
|
|
12
|
+
type = "int"
|
|
13
|
+
short = "-p"
|
|
14
|
+
description = "Port for the Chainlit web server"
|
|
15
|
+
default = 8000
|
|
16
|
+
|
|
17
|
+
[runspec-chat.args.model]
|
|
18
|
+
type = "str"
|
|
19
|
+
description = "LLM model identifier passed to the adapter"
|
|
20
|
+
default = "claude-haiku-4-5-20251001"
|
|
21
|
+
|
|
22
|
+
# ---
|
|
23
|
+
|
|
24
|
+
[setup-keys]
|
|
25
|
+
description = "Generate an SSH key (if missing) and copy it to all configured jump hosts"
|
|
26
|
+
autonomy = "confirm"
|
|
27
|
+
|
|
28
|
+
[setup-keys.args.hosts]
|
|
29
|
+
type = "path"
|
|
30
|
+
short = "-H"
|
|
31
|
+
description = "Path to hosts configuration file"
|
|
32
|
+
default = "~/.config/runspec-chat/hosts.toml"
|
|
33
|
+
required = false
|
|
34
|
+
|
|
35
|
+
[setup-keys.args.key-type]
|
|
36
|
+
type = "choice"
|
|
37
|
+
options = ["ed25519", "rsa"]
|
|
38
|
+
default = "ed25519"
|
|
39
|
+
description = "SSH key algorithm to generate"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import tomllib
|
|
7
|
+
except ImportError:
|
|
8
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
9
|
+
|
|
10
|
+
import runspec as rs
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load_hosts(hosts_path: Path) -> dict:
|
|
14
|
+
if not hosts_path.exists():
|
|
15
|
+
print(f"No hosts config found at {hosts_path}")
|
|
16
|
+
print("Copy hosts.toml.example and edit it, then re-run setup-keys.")
|
|
17
|
+
sys.exit(1)
|
|
18
|
+
with open(hosts_path, "rb") as f:
|
|
19
|
+
return tomllib.load(f)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ensure_key(key_path: Path, key_type: str) -> Path:
|
|
23
|
+
pub = key_path.with_suffix(".pub")
|
|
24
|
+
if key_path.exists():
|
|
25
|
+
print(f"Using existing key: {key_path}")
|
|
26
|
+
return pub
|
|
27
|
+
|
|
28
|
+
print(f"Generating {key_type} key at {key_path} ...")
|
|
29
|
+
result = subprocess.run([
|
|
30
|
+
"ssh-keygen", "-t", key_type,
|
|
31
|
+
"-f", str(key_path),
|
|
32
|
+
"-N", "",
|
|
33
|
+
"-C", "runspec-chat",
|
|
34
|
+
])
|
|
35
|
+
if result.returncode != 0:
|
|
36
|
+
print("ssh-keygen failed.")
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
print(f"Key created: {pub}")
|
|
39
|
+
return pub
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _copy_to_host(pub_key: Path, ssh_target: str, name: str) -> bool:
|
|
43
|
+
print(f"\nCopying public key to {name} ({ssh_target}) ...")
|
|
44
|
+
print("You may be prompted for the remote password.")
|
|
45
|
+
result = subprocess.run(["ssh-copy-id", "-i", str(pub_key), ssh_target])
|
|
46
|
+
if result.returncode != 0:
|
|
47
|
+
print(f" Warning: ssh-copy-id failed for {name} — skipping.")
|
|
48
|
+
return False
|
|
49
|
+
print(" Done.")
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main() -> None:
|
|
54
|
+
spec = rs.parse("setup-keys")
|
|
55
|
+
|
|
56
|
+
hosts_path = Path(str(spec.hosts)).expanduser()
|
|
57
|
+
config = _load_hosts(hosts_path)
|
|
58
|
+
|
|
59
|
+
ssh_hosts = [
|
|
60
|
+
(name, info["ssh"])
|
|
61
|
+
for name, info in config.get("hosts", {}).items()
|
|
62
|
+
if info.get("ssh")
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
if not ssh_hosts:
|
|
66
|
+
print("No SSH hosts found in the config (hosts with an 'ssh' field).")
|
|
67
|
+
sys.exit(0)
|
|
68
|
+
|
|
69
|
+
key_type: str = spec.key_type
|
|
70
|
+
key_path = Path.home() / ".ssh" / f"runspec-chat_{key_type}"
|
|
71
|
+
pub_key = _ensure_key(key_path, key_type)
|
|
72
|
+
|
|
73
|
+
ok, failed = [], []
|
|
74
|
+
for name, ssh_target in ssh_hosts:
|
|
75
|
+
if _copy_to_host(pub_key, ssh_target, name):
|
|
76
|
+
ok.append(name)
|
|
77
|
+
else:
|
|
78
|
+
failed.append(name)
|
|
79
|
+
|
|
80
|
+
print(f"\nDone. {len(ok)} host(s) configured", end="")
|
|
81
|
+
if failed:
|
|
82
|
+
print(f", {len(failed)} failed: {', '.join(failed)}", end="")
|
|
83
|
+
print(".")
|
|
84
|
+
|
|
85
|
+
if ok:
|
|
86
|
+
print("\nAdd this to your ~/.ssh/config for each host:")
|
|
87
|
+
print(f" IdentityFile ~/.ssh/runspec-chat_{key_type}")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runspec-chat
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Requires-Python: >=3.11
|
|
5
|
+
Requires-Dist: chainlit>=2.7.0
|
|
6
|
+
Requires-Dist: mcp>=1.0.0
|
|
7
|
+
Requires-Dist: runspec>=0.13.1
|
|
8
|
+
Provides-Extra: anthropic
|
|
9
|
+
Requires-Dist: anthropic>=0.40.0; extra == 'anthropic'
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: anthropic>=0.40.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
13
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
14
|
+
Provides-Extra: openai
|
|
15
|
+
Requires-Dist: openai>=1.0.0; extra == 'openai'
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
runspec_chat/__init__.py,sha256=AT1_k-bAMQTF8ksSi5uHVT77eb-tgS5A1UkgFlJkvSA,126
|
|
2
|
+
runspec_chat/adapter.py,sha256=nDZqVFrEdYqZi7vjNUyt38LcOPd8uZUi4dLATappgL4,738
|
|
3
|
+
runspec_chat/app.py,sha256=WgRzwpVVhym0vEmNq3ie-RZzZisDC4NIalG4qYrQD9o,17852
|
|
4
|
+
runspec_chat/chat.py,sha256=bpmvNVqrZClbZln3stw4vjjRCOZQNM8524iAdrBKt9M,2242
|
|
5
|
+
runspec_chat/runspec.toml,sha256=ZjpTz8_ykwp-qq6b6sLtWxVndXD-BoDdE7vN5GwGkmc,926
|
|
6
|
+
runspec_chat/setup_keys.py,sha256=IZwJgo3WJj_PNmkkfL7tA5ZyqMD_Mb79AhXRKZliwC0,2489
|
|
7
|
+
runspec_chat/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
runspec_chat/adapters/anthropic_direct.py,sha256=72WuCunhiNWQuEftB8H5qIz9Xz4vxbE_9bhKNQYfL6E,2144
|
|
9
|
+
runspec_chat-0.1.0.dist-info/METADATA,sha256=DQ8R3Yb4fr3UBKmLfvu73zzFTRQXlIS3xfK5TTizims,461
|
|
10
|
+
runspec_chat-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
runspec_chat-0.1.0.dist-info/entry_points.txt,sha256=EMzKVTL7Fl8EbRib_LRc9-ubCKzvkULbJj424jbY3Qg,98
|
|
12
|
+
runspec_chat-0.1.0.dist-info/RECORD,,
|