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.
@@ -0,0 +1,3 @@
1
+ from runspec_chat.adapter import ModelAdapter, ChatResponse, ToolCall
2
+
3
+ __all__ = ["ModelAdapter", "ChatResponse", "ToolCall"]
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ runspec-chat = runspec_chat.chat:main
3
+ setup-keys = runspec_chat.setup_keys:main