euron-code 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,214 @@
1
+ """bash / run_command: runs a shell command with a persistent working directory.
2
+
3
+ Windows uses PowerShell (pwsh if installed, else Windows PowerShell 5.1), falling back to cmd.
4
+ macOS / Linux use $SHELL when it is bash or zsh, else bash, else sh.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import os
11
+ import shutil
12
+ import signal
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import List, Optional, Tuple
17
+
18
+ from euron_code.permissions import Request
19
+ from euron_code.tools.base import Tool, ToolError, ToolResult, opt_int, require, truncate
20
+
21
+ MARK = "__EURON_CWD__"
22
+
23
+
24
+ def detect_shell(preference: Optional[str] = None) -> Tuple[str, str]:
25
+ """Return (kind, executable). kind: bash | sh | zsh | powershell | cmd."""
26
+ pref = (preference or "").strip()
27
+ if pref:
28
+ exe = shutil.which(pref) or pref
29
+ low = Path(exe).name.lower()
30
+ if "pwsh" in low or "powershell" in low:
31
+ return "powershell", exe
32
+ if low.startswith("cmd"):
33
+ return "cmd", exe
34
+ if "zsh" in low:
35
+ return "zsh", exe
36
+ if "bash" in low:
37
+ return "bash", exe
38
+ return "sh", exe
39
+ if os.name == "nt":
40
+ for cand in ("pwsh", "powershell"):
41
+ exe = shutil.which(cand)
42
+ if exe:
43
+ return "powershell", exe
44
+ sysroot = os.environ.get("SystemRoot") or os.environ.get("SYSTEMROOT") or r"C:\Windows"
45
+ builtin = Path(sysroot) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
46
+ if builtin.is_file():
47
+ return "powershell", str(builtin)
48
+ return "cmd", os.environ.get("COMSPEC") or str(Path(sysroot) / "System32" / "cmd.exe")
49
+ sh = os.environ.get("SHELL", "")
50
+ if sh and Path(sh).name in ("bash", "zsh") and os.path.exists(sh):
51
+ return Path(sh).name, sh
52
+ exe = shutil.which("bash")
53
+ if exe:
54
+ return "bash", exe
55
+ return "sh", shutil.which("sh") or "/bin/sh"
56
+
57
+
58
+ def build_argv(kind: str, exe: str, command: str):
59
+ """Return the argv list (or, for cmd.exe, a raw command line string)."""
60
+ if kind == "powershell":
61
+ script = (
62
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n"
63
+ "$OutputEncoding = [System.Text.Encoding]::UTF8\n"
64
+ "$ProgressPreference = 'SilentlyContinue'\n"
65
+ "$global:LASTEXITCODE = 0\n"
66
+ f"{command}\n"
67
+ "$__euron_ok = $?\n"
68
+ "$__euron_ec = if ($global:LASTEXITCODE -ne 0) { $global:LASTEXITCODE } elseif ($__euron_ok) { 0 } else { 1 }\n"
69
+ f"[Console]::Out.Write(\"`n{MARK}\" + (Get-Location).ProviderPath + \"`n\")\n"
70
+ "exit $__euron_ec\n"
71
+ )
72
+ enc = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
73
+ return [exe, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", enc]
74
+ if kind == "cmd":
75
+ # delayed expansion so ERRORLEVEL/CD are read after the command runs
76
+ # a raw string: list2cmdline's backslash-escaping confuses cmd.exe. /s strips the outer quotes.
77
+ script = f"{command} & set __EURON_EC=!ERRORLEVEL! & echo.{MARK}!CD! & exit /b !__EURON_EC!"
78
+ return f'"{exe}" /d /v:on /s /c "{script}"'
79
+
80
+ # Git Bash / MSYS on Windows: `pwd -W` prints a C:/... path Python can use
81
+ pwd = "pwd -W 2>/dev/null || pwd" if os.name == "nt" else "pwd"
82
+ script = (
83
+ f"{command}\n"
84
+ "__euron_ec=$?\n"
85
+ f"printf '\\n{MARK}%s\\n' \"$({pwd})\"\n"
86
+ "exit $__euron_ec\n"
87
+ )
88
+ return [exe, "-c", script]
89
+
90
+
91
+ def _kill_tree(proc: subprocess.Popen) -> None:
92
+ try:
93
+ if os.name == "nt":
94
+ subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
95
+ capture_output=True, timeout=10)
96
+ else:
97
+ os.killpg(proc.pid, signal.SIGKILL)
98
+ except Exception:
99
+ try:
100
+ proc.kill()
101
+ except Exception:
102
+ pass
103
+
104
+
105
+ class ShellSession:
106
+ def __init__(self, root: Path, preference: Optional[str] = None):
107
+ self.root = root
108
+ self.cwd = root
109
+ self.kind, self.exe = detect_shell(preference)
110
+
111
+ def run(self, command: str, timeout: float) -> Tuple[int, str, bool]:
112
+ """Return (exit_code, output, timed_out). Raises KeyboardInterrupt after killing the child."""
113
+ argv = build_argv(self.kind, self.exe, command)
114
+ env = dict(os.environ)
115
+ env.setdefault("PAGER", "cat")
116
+ env.setdefault("GIT_PAGER", "cat")
117
+ env["EURON_CODE"] = "1"
118
+ kw = {}
119
+ if os.name == "nt":
120
+ kw["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
121
+ else:
122
+ kw["start_new_session"] = True
123
+ cwd = self.cwd if self.cwd.is_dir() else self.root
124
+ proc = subprocess.Popen(argv, cwd=str(cwd), stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
125
+ stderr=subprocess.STDOUT, env=env, **kw)
126
+ timed_out = False
127
+ try:
128
+ out, _ = proc.communicate(timeout=timeout)
129
+ except subprocess.TimeoutExpired:
130
+ timed_out = True
131
+ _kill_tree(proc)
132
+ try:
133
+ out, _ = proc.communicate(timeout=5)
134
+ except Exception:
135
+ out = b""
136
+ except KeyboardInterrupt:
137
+ _kill_tree(proc)
138
+ raise
139
+ text = (out or b"").decode("utf-8", errors="replace").replace("\r\n", "\n")
140
+ code = proc.returncode if proc.returncode is not None else -1
141
+ idx = text.rfind(MARK)
142
+ if idx != -1:
143
+ line_end = text.find("\n", idx)
144
+ new_cwd = text[idx + len(MARK): line_end if line_end != -1 else None].strip()
145
+ text = text[:idx].rstrip("\n") + (text[line_end + 1:] if line_end != -1 else "")
146
+ if new_cwd and Path(new_cwd).is_dir():
147
+ self.cwd = Path(new_cwd)
148
+ if code < 0 and os.name != "nt" and not timed_out:
149
+ code = 128 + abs(code)
150
+ return code, text.rstrip(), timed_out
151
+
152
+
153
+ class Bash(Tool):
154
+ name = "bash"
155
+ description = (
156
+ "Run a shell command on the user's machine and return its output and exit code. The working "
157
+ "directory persists between calls (a `cd` carries over). Use for tests, builds, git and package "
158
+ "commands. Do not use it to read or edit files (use read_file/edit_file) or to search (use "
159
+ "grep/glob). Do not start servers or watchers that never exit; they are killed at the timeout."
160
+ )
161
+ parameters = {
162
+ "type": "object",
163
+ "properties": {
164
+ "command": {"type": "string", "description": "The command to run."},
165
+ "timeout": {"type": "integer", "description": "Seconds before the command is killed (default 120, max 600)."},
166
+ "description": {"type": "string", "description": "5-10 words on what the command does."},
167
+ },
168
+ "required": ["command"],
169
+ }
170
+
171
+ def _session(self, ctx) -> ShellSession:
172
+ if ctx.shell is None:
173
+ ctx.shell = ShellSession(ctx.root, ctx.settings.get("shell"))
174
+ return ctx.shell
175
+
176
+ def permission(self, args, ctx):
177
+ return Request("Bash", self.name, command=require(args, "command").strip())
178
+
179
+ def describe(self, args, ctx):
180
+ return args.get("command", "").strip().splitlines()[0][:160] if args.get("command") else ""
181
+
182
+ def preview(self, args, ctx):
183
+ sh = self._session(ctx)
184
+ cmd = require(args, "command")
185
+ where = ctx.rel(sh.cwd)
186
+ return f"$ {cmd}\n(in {where or '.'}, shell: {sh.kind})"
187
+
188
+ def run(self, args, ctx):
189
+ cmd = require(args, "command").strip()
190
+ if not cmd:
191
+ raise ToolError("Empty command.")
192
+ default_t = int(ctx.settings.get("bashTimeout") or 120)
193
+ timeout = max(1, min(opt_int(args, "timeout", default_t) or default_t, 600))
194
+ sh = self._session(ctx)
195
+ before = sh.cwd
196
+ try:
197
+ code, out, timed_out = sh.run(cmd, timeout)
198
+ except FileNotFoundError as e:
199
+ raise ToolError(f"Shell not found: {e}")
200
+ except KeyboardInterrupt:
201
+ return ToolResult("The user interrupted the command (Ctrl-C); it was killed.", is_error=True,
202
+ preview="interrupted")
203
+ body = truncate(out, ctx.max_output_chars, "pipe through a filter such as `| tail -50` for less")
204
+ head = f"Exit code: {code}"
205
+ if timed_out:
206
+ head += f" (killed after {timeout}s timeout)"
207
+ if sh.cwd != before:
208
+ head += f"\nWorking directory is now: {ctx.rel(sh.cwd)}"
209
+ content = head + ("\n" + body if body else "\n(no output)")
210
+ return ToolResult(content, is_error=(code != 0), preview=(body or "(no output)") + f"\n[exit {code}]")
211
+
212
+
213
+ def python_executable() -> str:
214
+ return sys.executable
@@ -0,0 +1,41 @@
1
+ """task: a read-only sub-agent with its own context window.
2
+
3
+ The sub-agent runs sequentially (never in parallel with the main agent: the gateway allows one
4
+ generation per user), sees only read-only tools, and returns its final answer as the tool result.
5
+ Useful for broad searches whose file dumps would otherwise fill the main context.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from euron_code.tools.base import Tool, ToolError, ToolResult, require, truncate
11
+
12
+
13
+ class Task(Tool):
14
+ name = "task"
15
+ read_only = True
16
+ description = (
17
+ "Delegate a self-contained research question to a sub-agent with a fresh context and READ-ONLY "
18
+ "tools (read_file, list_dir, glob, grep). It cannot edit files or run commands. Use it for broad "
19
+ "searches (\"where is X handled?\", \"summarise how module Y works\") so the file contents do not "
20
+ "fill your own context. Give it a complete, specific prompt; it returns a concise report."
21
+ )
22
+ parameters = {
23
+ "type": "object",
24
+ "properties": {
25
+ "description": {"type": "string", "description": "3-5 word label."},
26
+ "prompt": {"type": "string", "description": "The full task for the sub-agent."},
27
+ },
28
+ "required": ["prompt"],
29
+ }
30
+
31
+ def describe(self, args, ctx):
32
+ return args.get("description") or (args.get("prompt") or "")[:80]
33
+
34
+ def run(self, args, ctx):
35
+ prompt = require(args, "prompt")
36
+ if ctx.agent is None:
37
+ raise ToolError("Sub-agents are not available here.")
38
+ if getattr(ctx.agent, "is_subagent", False):
39
+ raise ToolError("A sub-agent cannot start another sub-agent.")
40
+ report = ctx.agent.run_subagent(prompt)
41
+ return ToolResult(truncate(report, ctx.max_output_chars), preview=report[:300])
@@ -0,0 +1,64 @@
1
+ """todo_write: the task list the model keeps for multi-step work (shown to the user)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List
6
+
7
+ from euron_code.tools.base import Tool, ToolError, ToolResult
8
+
9
+ STATUSES = ("pending", "in_progress", "completed")
10
+
11
+
12
+ def render_todos(todos: List[Dict[str, Any]]) -> str:
13
+ if not todos:
14
+ return "(no todos)"
15
+ mark = {"completed": "[x]", "in_progress": "[~]", "pending": "[ ]"}
16
+ return "\n".join(f"{mark.get(t['status'], '[ ]')} {t['content']}" for t in todos)
17
+
18
+
19
+ class TodoWrite(Tool):
20
+ name = "todo_write"
21
+ read_only = True
22
+ description = (
23
+ "Create or update your task list for multi-step work. Send the WHOLE list each time. "
24
+ "Keep exactly one item in_progress while working; mark items completed as soon as they are done. "
25
+ "Skip this for trivial one-step requests."
26
+ )
27
+ parameters = {
28
+ "type": "object",
29
+ "properties": {
30
+ "todos": {
31
+ "type": "array",
32
+ "items": {
33
+ "type": "object",
34
+ "properties": {
35
+ "content": {"type": "string"},
36
+ "status": {"type": "string", "enum": list(STATUSES)},
37
+ },
38
+ "required": ["content", "status"],
39
+ },
40
+ }
41
+ },
42
+ "required": ["todos"],
43
+ }
44
+
45
+ def describe(self, args, ctx):
46
+ todos = args.get("todos") or []
47
+ done = sum(1 for t in todos if isinstance(t, dict) and t.get("status") == "completed")
48
+ return f"{done}/{len(todos)} done"
49
+
50
+ def run(self, args, ctx):
51
+ todos = args.get("todos")
52
+ if not isinstance(todos, list):
53
+ raise ToolError("`todos` must be a list.")
54
+ clean = []
55
+ for i, t in enumerate(todos, 1):
56
+ if not isinstance(t, dict) or not str(t.get("content", "")).strip():
57
+ raise ToolError(f"todo {i} needs a non-empty `content`.")
58
+ st = t.get("status", "pending")
59
+ if st not in STATUSES:
60
+ raise ToolError(f"todo {i} has invalid status {st!r}; use {', '.join(STATUSES)}.")
61
+ clean.append({"content": str(t["content"]).strip(), "status": st})
62
+ ctx.todos[:] = clean
63
+ ctx.ui.show_todos(clean)
64
+ return ToolResult("Todo list updated:\n" + render_todos(clean), preview="")
@@ -0,0 +1,112 @@
1
+ """web_fetch: fetch a URL and return readable text (no JavaScript, no binaries)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import re
7
+ from html.parser import HTMLParser
8
+ from typing import List
9
+ from urllib.parse import urlparse
10
+
11
+ import httpx
12
+
13
+ from euron_code import api
14
+ from euron_code.permissions import Request
15
+ from euron_code.tools.base import Tool, ToolError, ToolResult, require, truncate
16
+
17
+ SKIP_TAGS = {"script", "style", "noscript", "svg", "head", "template", "iframe"}
18
+ BLOCK_TAGS = {"p", "div", "br", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "section",
19
+ "article", "header", "footer", "table", "ul", "ol", "blockquote", "hr"}
20
+
21
+
22
+ class _TextExtractor(HTMLParser):
23
+ def __init__(self) -> None:
24
+ super().__init__(convert_charrefs=True)
25
+ self.parts: List[str] = []
26
+ self.skip = 0
27
+
28
+ def handle_starttag(self, tag, attrs):
29
+ if tag in SKIP_TAGS:
30
+ self.skip += 1
31
+ elif tag in BLOCK_TAGS:
32
+ self.parts.append("\n")
33
+ if tag == "a":
34
+ href = dict(attrs).get("href")
35
+ if href and href.startswith("http"):
36
+ self._href = href
37
+
38
+ def handle_endtag(self, tag):
39
+ if tag in SKIP_TAGS and self.skip:
40
+ self.skip -= 1
41
+ elif tag in BLOCK_TAGS:
42
+ self.parts.append("\n")
43
+
44
+ def handle_data(self, data):
45
+ if not self.skip:
46
+ self.parts.append(data)
47
+
48
+
49
+ def html_to_text(src: str) -> str:
50
+ p = _TextExtractor()
51
+ try:
52
+ p.feed(src)
53
+ p.close()
54
+ except Exception:
55
+ return re.sub(r"<[^>]+>", " ", html.unescape(src))
56
+ text = "".join(p.parts)
57
+ text = re.sub(r"[ \t\r\f\v]+", " ", text)
58
+ text = re.sub(r"\n\s*\n+", "\n\n", text)
59
+ return text.strip()
60
+
61
+
62
+ class WebFetch(Tool):
63
+ name = "web_fetch"
64
+ read_only = True
65
+ description = (
66
+ "Fetch an http(s) URL and return its text content (HTML is converted to plain text). "
67
+ "Use for documentation pages and API references. Asks the user before each new domain."
68
+ )
69
+ parameters = {
70
+ "type": "object",
71
+ "properties": {"url": {"type": "string"}},
72
+ "required": ["url"],
73
+ }
74
+
75
+ def permission(self, args, ctx):
76
+ url = require(args, "url")
77
+ return Request("WebFetch", self.name, domain=(urlparse(url).hostname or ""))
78
+
79
+ def describe(self, args, ctx):
80
+ return args.get("url", "")
81
+
82
+ def run(self, args, ctx):
83
+ url = require(args, "url").strip()
84
+ u = urlparse(url)
85
+ if u.scheme not in ("http", "https") or not u.hostname:
86
+ raise ToolError("Only http:// and https:// URLs are supported.")
87
+ host = u.hostname.lower()
88
+ try:
89
+ with httpx.Client(timeout=20.0, follow_redirects=False,
90
+ headers={"User-Agent": api.user_agent()}) as c:
91
+ resp = c.get(url)
92
+ for _ in range(5): # follow redirects only within the approved site
93
+ if resp.status_code not in (301, 302, 303, 307, 308) or "location" not in resp.headers:
94
+ break
95
+ nxt = resp.url.join(resp.headers["location"])
96
+ nh = (nxt.host or "").lower()
97
+ if nh != host and not nh.endswith("." + host) and not host.endswith("." + nh):
98
+ return ToolResult(f"{url} redirects to another site: {nxt}. Call web_fetch on that URL "
99
+ "if you need it (the user will be asked).", preview="redirect")
100
+ resp = c.get(nxt)
101
+ except httpx.HTTPError as e:
102
+ raise ToolError(f"Fetch failed: {e}")
103
+ ctype = resp.headers.get("content-type", "")
104
+ if resp.status_code >= 400:
105
+ raise ToolError(f"HTTP {resp.status_code} from {url}")
106
+ if not any(t in ctype for t in ("text/", "json", "xml", "javascript")) and ctype:
107
+ raise ToolError(f"Not a text page (content-type {ctype}).")
108
+ body = resp.text
109
+ if "html" in ctype or body.lstrip()[:15].lower().startswith(("<!doctype", "<html")):
110
+ body = html_to_text(body)
111
+ header = f"URL: {resp.url}\nStatus: {resp.status_code}\n\n"
112
+ return ToolResult(header + truncate(body, ctx.max_output_chars), preview=f"{len(body)} chars")