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 +6 -0
- atomsh/agent.py +147 -0
- atomsh/auth.py +206 -0
- atomsh/browser.py +71 -0
- atomsh/cli.py +388 -0
- atomsh/client.py +147 -0
- atomsh/config.py +56 -0
- atomsh/interrupt.py +64 -0
- atomsh/mcp.py +172 -0
- atomsh/permissions.py +67 -0
- atomsh/prompt.py +51 -0
- atomsh/session.py +63 -0
- atomsh/text.py +54 -0
- atomsh/tools.py +265 -0
- atomsh-0.1.0.dist-info/METADATA +119 -0
- atomsh-0.1.0.dist-info/RECORD +20 -0
- atomsh-0.1.0.dist-info/WHEEL +5 -0
- atomsh-0.1.0.dist-info/entry_points.txt +2 -0
- atomsh-0.1.0.dist-info/licenses/LICENSE +201 -0
- atomsh-0.1.0.dist-info/top_level.txt +1 -0
atomsh/__init__.py
ADDED
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)
|