atomsh 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.
atomsh/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Atomsh — your autonomous coding agent for science.
2
+
3
+ Installed as the `atomsh` command; entry point is `atomsh.cli:main`.
4
+ """
5
+
6
+ __version__ = "0.1.0"
atomsh/agent.py ADDED
@@ -0,0 +1,147 @@
1
+ """The agent loop: model → tool calls → tool results → model, until it stops."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from . import tools as toolkit
7
+ from .client import AtomGPT, AtomGPTError
8
+ from .config import MAX_STEPS
9
+ from .interrupt import escape_watch
10
+ from .permissions import ALLOW, Permissions
11
+ from .prompt import system_prompt
12
+
13
+ DIM = "\033[2m"
14
+ BOLD = "\033[1m"
15
+ RESET = "\033[0m"
16
+
17
+
18
+ class Agent:
19
+ """Drives one conversation against one model with one tool set."""
20
+
21
+ def __init__(self, client: AtomGPT, model: str, session,
22
+ permissions: Permissions = None, root: Path = None,
23
+ color: bool = True, remote=None):
24
+ self.client = client
25
+ self.model = model
26
+ self.session = session
27
+ self.root = root or Path.cwd()
28
+ self.permissions = permissions or Permissions("ask", self.root)
29
+ self.color = color
30
+ # An MCPClient, when --materials is on. Its tools sit alongside the
31
+ # local ones; the model does not need to know which is which.
32
+ self.remote = remote
33
+ self.remote_tools = set()
34
+ self.tool_schema = list(toolkit.SCHEMA)
35
+ if remote is not None:
36
+ remote_schema = remote.schema()
37
+ self.remote_tools = {t["function"]["name"] for t in remote_schema}
38
+ self.tool_schema += remote_schema
39
+ if not self.session.messages:
40
+ self.session.messages.append({
41
+ "role": "system",
42
+ "content": system_prompt(str(self.root),
43
+ materials=remote is not None),
44
+ })
45
+
46
+ def _dim(self, text: str) -> str:
47
+ return f"{DIM}{text}{RESET}" if self.color else text
48
+
49
+ def run(self, user_text: str) -> str:
50
+ """Run one user turn to completion. Returns the final assistant text."""
51
+ self.session.messages.append({"role": "user", "content": user_text})
52
+ final = ""
53
+
54
+ for _ in range(MAX_STEPS):
55
+ # `last` doubles as "have we printed anything this step": stripped
56
+ # channel markers leave stray blank lines, so leading whitespace is
57
+ # swallowed and the closing newline is only added if one is missing.
58
+ state = {"last": ""}
59
+
60
+ def on_text(piece, state=state):
61
+ if not state["last"] and not piece.strip():
62
+ return
63
+ state["last"] = piece
64
+ print(piece, end="", flush=True)
65
+
66
+ try:
67
+ with escape_watch() as cancelled:
68
+ result = self.client.stream(
69
+ self.session.messages, self.tool_schema, self.model,
70
+ on_text=on_text, cancelled=cancelled,
71
+ )
72
+ except AtomGPTError as e:
73
+ print(f"\n{self._dim('error:')} {e}")
74
+ return ""
75
+
76
+ message = result["message"]
77
+ if state["last"] and not state["last"].endswith("\n"):
78
+ print()
79
+ self.session.messages.append(message)
80
+ self.session.save()
81
+
82
+ if result.get("finish_reason") == "cancelled":
83
+ print(self._dim(" interrupted"))
84
+ return message.get("content") or ""
85
+
86
+ calls = message.get("tool_calls")
87
+ if not calls:
88
+ final = message.get("content") or ""
89
+ if not final and not state["last"]:
90
+ print(self._dim("(no response — try rephrasing)"))
91
+ return final
92
+
93
+ for call in calls:
94
+ self._run_tool(call)
95
+ self.session.save()
96
+
97
+ print(self._dim(f"stopped after {MAX_STEPS} steps"))
98
+ return final
99
+
100
+ def _run_tool(self, call: dict) -> None:
101
+ """Execute one tool call and append its result to the conversation."""
102
+ name = (call.get("function") or {}).get("name") or ""
103
+ raw_args = (call.get("function") or {}).get("arguments") or "{}"
104
+ try:
105
+ args = json.loads(raw_args) if raw_args.strip() else {}
106
+ except ValueError:
107
+ args = {}
108
+
109
+ handler = toolkit.HANDLERS.get(name)
110
+ if handler is None and name not in self.remote_tools:
111
+ return self._reply(call, f"Error: no such tool {name!r}.")
112
+
113
+ decision, reason = self.permissions.check(name, args)
114
+ if decision != ALLOW:
115
+ print(self._dim(f" ✗ {name} — {reason}"))
116
+ return self._reply(call, f"Denied: {reason}")
117
+
118
+ print(self._dim(f" · {name}({self._summarize(args)})"))
119
+ try:
120
+ if handler is None:
121
+ output = self.remote.call(name, args)
122
+ else:
123
+ output = handler(self.root, **args)
124
+ except TypeError as e:
125
+ output = f"Error: bad arguments for {name}: {e}"
126
+ except Exception as e: # a tool must never kill the session
127
+ output = f"Error: {name} raised {type(e).__name__}: {e}"
128
+ self._reply(call, output)
129
+
130
+ def _reply(self, call: dict, content: str) -> None:
131
+ self.session.messages.append({
132
+ "role": "tool",
133
+ "tool_call_id": call.get("id") or "",
134
+ "content": content,
135
+ })
136
+
137
+ @staticmethod
138
+ def _summarize(args: dict) -> str:
139
+ """One-line rendering of tool arguments for the activity log."""
140
+ parts = []
141
+ for key, value in args.items():
142
+ text = value if isinstance(value, str) else json.dumps(value)
143
+ text = text.replace("\n", " ")
144
+ if len(text) > 60:
145
+ text = text[:60] + "…"
146
+ parts.append(f"{key}={text}")
147
+ return ", ".join(parts)
atomsh/auth.py ADDED
@@ -0,0 +1,206 @@
1
+ """Authentication against atomgpt.org.
2
+
3
+ The preferred flow is `atomsh login`: an OAuth 2.1 authorization-code
4
+ exchange with PKCE against atomgpt.org, using a loopback redirect. The
5
+ authorization server hands back the user's existing atomgpt.org API key as the
6
+ access token, so the result is a Bearer credential usable against /api.
7
+
8
+ `atomsh login --key` is the fallback for headless machines, where opening a
9
+ browser is not possible.
10
+ """
11
+
12
+ import base64
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import secrets
17
+ import socket
18
+ import threading
19
+ import urllib.parse
20
+ from http.server import BaseHTTPRequestHandler, HTTPServer
21
+
22
+ import httpx
23
+
24
+ from .browser import open_url
25
+ from .config import (
26
+ API_URL,
27
+ AUTHORIZE_URL,
28
+ AUTH_FILE,
29
+ CLIENT_NAME,
30
+ REGISTER_URL,
31
+ TOKEN_URL,
32
+ )
33
+
34
+
35
+ class AuthError(Exception):
36
+ """Login failed, or the stored credential is not usable."""
37
+
38
+
39
+ # ── stored credential ────────────────────────────────────────────────────────
40
+
41
+ def load_token() -> str:
42
+ """Return the stored token, or None. ATOMSH_API_KEY wins if set."""
43
+ env = os.environ.get("ATOMSH_API_KEY")
44
+ if env:
45
+ return env.strip()
46
+ try:
47
+ with open(AUTH_FILE, encoding="utf-8") as fh:
48
+ return json.load(fh).get("access_token")
49
+ except (OSError, ValueError):
50
+ return None
51
+
52
+
53
+ def save_token(token: str) -> None:
54
+ """Persist the token 0600, creating the config dir if needed."""
55
+ AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
56
+ # Create with restrictive permissions before writing the secret.
57
+ fd = os.open(AUTH_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
58
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
59
+ json.dump({"access_token": token}, fh)
60
+
61
+
62
+ def forget_token() -> bool:
63
+ """Delete the stored credential. Returns whether there was one."""
64
+ try:
65
+ AUTH_FILE.unlink()
66
+ return True
67
+ except OSError:
68
+ return False
69
+
70
+
71
+ def whoami(token: str) -> dict:
72
+ """Validate a token and return the account it belongs to.
73
+
74
+ /api/models is the cheapest authenticated endpoint that exists on every
75
+ deployment, so it doubles as the credential check.
76
+ """
77
+ try:
78
+ r = httpx.get(
79
+ f"{API_URL}/models",
80
+ headers={"Authorization": f"Bearer {token}"},
81
+ timeout=30,
82
+ )
83
+ except httpx.HTTPError as e:
84
+ raise AuthError(f"could not reach {API_URL}: {e}") from e
85
+ if r.status_code in (401, 403):
86
+ raise AuthError("token rejected by atomgpt.org (401/403)")
87
+ r.raise_for_status()
88
+ models = [m.get("id") for m in (r.json().get("data") or [])]
89
+ return {"models": models}
90
+
91
+
92
+ # ── OAuth login ──────────────────────────────────────────────────────────────
93
+
94
+ def _pkce_pair() -> tuple:
95
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(64)).decode().rstrip("=")
96
+ digest = hashlib.sha256(verifier.encode()).digest()
97
+ challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
98
+ return verifier, challenge
99
+
100
+
101
+ class _CallbackHandler(BaseHTTPRequestHandler):
102
+ """Single-shot handler that captures ?code=&state= from the redirect."""
103
+
104
+ result = None
105
+
106
+ def do_GET(self): # noqa: N802 — name fixed by BaseHTTPRequestHandler
107
+ params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
108
+ _CallbackHandler.result = {k: v[0] for k, v in params.items()}
109
+ ok = "code" in _CallbackHandler.result
110
+ body = (
111
+ "<h2>atomsh is connected.</h2><p>You can close this tab.</p>"
112
+ if ok
113
+ else "<h2>Authorization failed.</h2><p>Return to the terminal.</p>"
114
+ )
115
+ payload = f"<!doctype html><meta charset=utf-8><body style='font-family:system-ui;margin:80px auto;max-width:420px'>{body}</body>".encode()
116
+ self.send_response(200 if ok else 400)
117
+ self.send_header("Content-Type", "text/html; charset=utf-8")
118
+ self.send_header("Content-Length", str(len(payload)))
119
+ self.end_headers()
120
+ self.wfile.write(payload)
121
+
122
+ def log_message(self, *args):
123
+ """Silence the default stderr access log."""
124
+
125
+
126
+ def _free_port() -> int:
127
+ with socket.socket() as s:
128
+ s.bind(("127.0.0.1", 0))
129
+ return s.getsockname()[1]
130
+
131
+
132
+ def login_oauth(open_browser: bool = True, timeout: int = 300) -> str:
133
+ """Run the browser login and return the access token.
134
+
135
+ Starts a loopback listener, registers this client, sends the user to the
136
+ consent screen, then exchanges the returned code for a token.
137
+ """
138
+ port = _free_port()
139
+ redirect_uri = f"http://127.0.0.1:{port}/callback"
140
+ verifier, challenge = _pkce_pair()
141
+ state = secrets.token_urlsafe(16)
142
+
143
+ try:
144
+ reg = httpx.post(
145
+ REGISTER_URL,
146
+ json={"client_name": CLIENT_NAME, "redirect_uris": [redirect_uri]},
147
+ timeout=30,
148
+ )
149
+ reg.raise_for_status()
150
+ client_id = reg.json()["client_id"]
151
+ except (httpx.HTTPError, KeyError, ValueError) as e:
152
+ raise AuthError(f"client registration failed: {e}") from e
153
+
154
+ url = AUTHORIZE_URL + "?" + urllib.parse.urlencode({
155
+ "response_type": "code",
156
+ "client_id": client_id,
157
+ "redirect_uri": redirect_uri,
158
+ "code_challenge": challenge,
159
+ "code_challenge_method": "S256",
160
+ "state": state,
161
+ "scope": "mcp",
162
+ })
163
+
164
+ server = HTTPServer(("127.0.0.1", port), _CallbackHandler)
165
+ server.timeout = timeout
166
+ _CallbackHandler.result = None
167
+
168
+ print("Open this URL to authorize atomsh:\n")
169
+ print(f" {url}\n")
170
+ if open_browser:
171
+ # In a thread: launching a Windows browser from WSL can block for
172
+ # seconds, and the callback listener should already be waiting.
173
+ threading.Thread(target=open_url, args=(url,), daemon=True).start()
174
+ print("Trying to open it in your browser…")
175
+ print(f"Waiting for authorization (Ctrl-C to cancel, {timeout}s timeout).")
176
+
177
+ server.handle_request() # blocks until the redirect arrives or times out
178
+ server.server_close()
179
+
180
+ result = _CallbackHandler.result
181
+ if not result:
182
+ raise AuthError("timed out waiting for the browser redirect")
183
+ if result.get("error"):
184
+ raise AuthError(f"authorization denied: {result['error']}")
185
+ if result.get("state") != state:
186
+ raise AuthError("state mismatch — aborting")
187
+
188
+ try:
189
+ tok = httpx.post(
190
+ TOKEN_URL,
191
+ data={
192
+ "grant_type": "authorization_code",
193
+ "code": result["code"],
194
+ "redirect_uri": redirect_uri,
195
+ "client_id": client_id,
196
+ "code_verifier": verifier,
197
+ },
198
+ timeout=30,
199
+ )
200
+ tok.raise_for_status()
201
+ token = tok.json()["access_token"]
202
+ except (httpx.HTTPError, KeyError, ValueError) as e:
203
+ raise AuthError(f"token exchange failed: {e}") from e
204
+
205
+ save_token(token)
206
+ return token
atomsh/browser.py ADDED
@@ -0,0 +1,71 @@
1
+ """Opening a URL in the user's browser, including from WSL.
2
+
3
+ Python's webbrowser module shells out to xdg-open, which on a headless Linux
4
+ box (WSL included) prints a dozen "not found" lines to stderr before giving
5
+ up. That noise is worse than useless during login, so this module picks an
6
+ opener deliberately and keeps every child process quiet.
7
+ """
8
+
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ import webbrowser
13
+
14
+
15
+ def is_wsl() -> bool:
16
+ """Whether we are running under WSL, where the browser lives on Windows."""
17
+ try:
18
+ with open("/proc/version", encoding="utf-8", errors="ignore") as fh:
19
+ return "microsoft" in fh.read().lower()
20
+ except OSError:
21
+ return False
22
+
23
+
24
+ def _run(cmd: list) -> bool:
25
+ """Launch a command with its output discarded. True if it started."""
26
+ try:
27
+ subprocess.run(cmd, stdout=subprocess.DEVNULL,
28
+ stderr=subprocess.DEVNULL, timeout=20, check=False)
29
+ return True
30
+ except (OSError, subprocess.SubprocessError):
31
+ return False
32
+
33
+
34
+ def _candidates(url: str) -> list:
35
+ cmds = []
36
+ if is_wsl():
37
+ # Hand the URL to Windows. explorer.exe exits non-zero even when it
38
+ # works, so "did not raise" is the success test, not the exit code.
39
+ cmds += [
40
+ ["wslview", url],
41
+ ["explorer.exe", url],
42
+ ["powershell.exe", "-NoProfile", "-Command", "Start-Process", url],
43
+ ]
44
+ cmds += [["xdg-open", url], ["open", url]]
45
+ return cmds
46
+
47
+
48
+ def _webbrowser_quiet(url: str) -> bool:
49
+ """Last resort: webbrowser.open with the child's stderr muted."""
50
+ try:
51
+ devnull = os.open(os.devnull, os.O_WRONLY)
52
+ except OSError:
53
+ return webbrowser.open(url)
54
+ saved = os.dup(2)
55
+ try:
56
+ os.dup2(devnull, 2)
57
+ return webbrowser.open(url)
58
+ except Exception:
59
+ return False
60
+ finally:
61
+ os.dup2(saved, 2)
62
+ os.close(saved)
63
+ os.close(devnull)
64
+
65
+
66
+ def open_url(url: str) -> bool:
67
+ """Open `url` in a browser. False means the user has to do it themselves."""
68
+ for cmd in _candidates(url):
69
+ if shutil.which(cmd[0]) and _run(cmd):
70
+ return True
71
+ return _webbrowser_quiet(url)