gencode-agent 1.0.0__tar.gz

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,41 @@
1
+ Metadata-Version: 2.3
2
+ Name: gencode-agent
3
+ Version: 1.0.0
4
+ Summary: A terminal coding agent (run `gencode`) that works in your current folder using Ollama or OpenRouter
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+
8
+ # gencode
9
+
10
+ A terminal coding agent. Open any folder, run `gencode`, and it reads, writes and runs commands in that folder
11
+ using either a local **Ollama** model or an **OpenRouter** hosted model.
12
+
13
+ ## Install
14
+
15
+ ```
16
+ uv tool install gencode-agent # or: pipx install gencode-agent / pip install gencode-agent
17
+ ```
18
+
19
+ ## Use
20
+
21
+ ```
22
+ cd my-project
23
+ gencode
24
+ ```
25
+
26
+ 1. Choose a provider: `ollama` or `openrouter`.
27
+ 2. `ollama`: enter the model name (Ollama must be running locally on `http://localhost:11434`).
28
+ `openrouter`: enter your API key (hidden), then the model name (e.g. `openai/gpt-4o-mini`).
29
+ 3. Type prompts at `gencode>`. After each task you get the prompt back. Type `exit` to quit.
30
+
31
+ Provider, key and model are kept only in memory for that terminal session; nothing is saved to disk.
32
+ Each new terminal asks again.
33
+
34
+ ## Optional environment variables
35
+
36
+ - `OLLAMA_BASE_URL`: default `http://localhost:11434`
37
+ - `GENCODE_NUM_CTX`: Ollama context window, default `32768`
38
+
39
+ ## Warning
40
+
41
+ The agent can run any shell command in the current folder without confirmation. Only use it in folders you can afford to change.
@@ -0,0 +1,34 @@
1
+ # gencode
2
+
3
+ A terminal coding agent. Open any folder, run `gencode`, and it reads, writes and runs commands in that folder
4
+ using either a local **Ollama** model or an **OpenRouter** hosted model.
5
+
6
+ ## Install
7
+
8
+ ```
9
+ uv tool install gencode-agent # or: pipx install gencode-agent / pip install gencode-agent
10
+ ```
11
+
12
+ ## Use
13
+
14
+ ```
15
+ cd my-project
16
+ gencode
17
+ ```
18
+
19
+ 1. Choose a provider: `ollama` or `openrouter`.
20
+ 2. `ollama`: enter the model name (Ollama must be running locally on `http://localhost:11434`).
21
+ `openrouter`: enter your API key (hidden), then the model name (e.g. `openai/gpt-4o-mini`).
22
+ 3. Type prompts at `gencode>`. After each task you get the prompt back. Type `exit` to quit.
23
+
24
+ Provider, key and model are kept only in memory for that terminal session; nothing is saved to disk.
25
+ Each new terminal asks again.
26
+
27
+ ## Optional environment variables
28
+
29
+ - `OLLAMA_BASE_URL`: default `http://localhost:11434`
30
+ - `GENCODE_NUM_CTX`: Ollama context window, default `32768`
31
+
32
+ ## Warning
33
+
34
+ The agent can run any shell command in the current folder without confirmation. Only use it in folders you can afford to change.
@@ -0,0 +1,17 @@
1
+ [project]
2
+ name = "gencode-agent"
3
+ version = "1.0.0"
4
+ description = "A terminal coding agent (run `gencode`) that works in your current folder using Ollama or OpenRouter"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = []
8
+
9
+ [project.scripts]
10
+ gencode = "gencode.main:main"
11
+
12
+ [tool.uv.build-backend]
13
+ module-name = "gencode"
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.11.0,<0.12"]
17
+ build-backend = "uv_build"
File without changes
@@ -0,0 +1,3 @@
1
+ from .main import main
2
+
3
+ main()
File without changes
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Callable
5
+
6
+ from ..types import (
7
+ AssistantMessage,
8
+ Done,
9
+ Message,
10
+ Provider,
11
+ TextDelta,
12
+ Tool,
13
+ ToolCallBlock,
14
+ ToolResultMessage,
15
+ )
16
+
17
+
18
+ # events handed to on_event: dicts with a "type" key
19
+ # {"type": "text", "delta": str}
20
+ # {"type": "tool_start", "call": ToolCallBlock}
21
+ # {"type": "tool_end", "call": ToolCallBlock, "result": str, "is_error": bool}
22
+ # {"type": "turn_end", "message": AssistantMessage}
23
+ @dataclass
24
+ class AgentOptions:
25
+ provider: Provider
26
+ model: str
27
+ tools: list[Tool]
28
+ messages: list[Message]
29
+ on_event: Callable[[dict], None]
30
+ system: str | None = None
31
+ max_turns: int = 20
32
+
33
+
34
+ def run_agent(opts: AgentOptions) -> None:
35
+ for _ in range(opts.max_turns):
36
+ assistant: AssistantMessage | None = None
37
+ for event in opts.provider.stream(opts.messages, opts.model, opts.system, opts.tools):
38
+ if isinstance(event, TextDelta):
39
+ opts.on_event({"type": "text", "delta": event.delta})
40
+ elif isinstance(event, Done):
41
+ assistant = event.message
42
+ if assistant is None:
43
+ raise RuntimeError("stream ended without a done event")
44
+
45
+ opts.messages.append(assistant)
46
+ opts.on_event({"type": "turn_end", "message": assistant})
47
+
48
+ if assistant.stop_reason != "toolUse":
49
+ return
50
+
51
+ for call in assistant.content:
52
+ if not isinstance(call, ToolCallBlock):
53
+ continue
54
+ opts.on_event({"type": "tool_start", "call": call})
55
+ is_error = False
56
+ try:
57
+ if "__parse_error__" in call.arguments:
58
+ raise ValueError(
59
+ f"tool arguments were not valid JSON: {call.arguments['__parse_error__']!r}"
60
+ )
61
+ tool = next((t for t in opts.tools if t.name == call.name), None)
62
+ if tool is None:
63
+ raise ValueError(f"unknown tool name {call.name}")
64
+ result = tool.execute(call.arguments)
65
+ except Exception as e: # tool errors go back to the model, not up the stack
66
+ result = f"Error: {e}"
67
+ is_error = True
68
+ opts.on_event({"type": "tool_end", "call": call, "result": result, "is_error": is_error})
69
+ opts.messages.append(ToolResultMessage(call.id, call.name, result, is_error))
70
+ raise RuntimeError(f"stopped after {opts.max_turns} turns")
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ import getpass
4
+ import os
5
+ import sys
6
+
7
+ from .agent.loop import AgentOptions, run_agent
8
+ from .providers import PROVIDERS, get_provider
9
+ from .tools import tools
10
+ from .types import Message, Provider, UserMessage
11
+
12
+ SYSTEM_PROMPT = (
13
+ "You are a coding agent running in the user's current folder. "
14
+ "Use the tools to inspect and change files: `write` to create files, `read`, `ls`, `find` and `grep` to explore, "
15
+ "and `bash` to run commands (use `uv` for Python projects). "
16
+ "All paths are relative to the current folder. Look at the folder before you change anything, "
17
+ "make the files real by calling `write` instead of only printing code, and finish with a short summary of what you did."
18
+ )
19
+
20
+ EXIT_WORDS = {"exit", "quit"}
21
+
22
+
23
+ def ask(label: str, secret: bool = False) -> str:
24
+ # keep asking until the user types something; Ctrl+C / Ctrl+D at a setup prompt leaves gencode
25
+ while True:
26
+ try:
27
+ value = (getpass.getpass(label) if secret else input(label)).strip()
28
+ except (EOFError, KeyboardInterrupt):
29
+ print()
30
+ sys.exit(0)
31
+ if value.lower() in EXIT_WORDS:
32
+ sys.exit(0)
33
+ if value:
34
+ return value
35
+
36
+
37
+ def setup() -> tuple[Provider, str]:
38
+ # nothing is saved: provider, key and model live only in this process, i.e. this terminal session
39
+ print("Select a provider:")
40
+ for i, name in enumerate(PROVIDERS, 1):
41
+ print(f" {i}. {name}")
42
+ while True:
43
+ choice = ask("Provider (name or number): ").lower()
44
+ if choice.isdigit() and 1 <= int(choice) <= len(PROVIDERS):
45
+ choice = PROVIDERS[int(choice) - 1]
46
+ if choice in PROVIDERS:
47
+ break
48
+ print(f" pick one of: {', '.join(PROVIDERS)}")
49
+
50
+ api_key = ask("OpenRouter API key (hidden): ", secret=True) if choice == "openrouter" else None
51
+ model = ask("Model name: ")
52
+ return get_provider(choice, api_key), model
53
+
54
+
55
+ def make_on_event(provider: Provider, model: str):
56
+ def on_event(event: dict) -> None:
57
+ kind = event["type"]
58
+ if kind == "text":
59
+ print(event["delta"], end="", flush=True)
60
+ elif kind == "tool_start":
61
+ call = event["call"]
62
+ print(f"\n> {call.name} {call.arguments if call.name != 'write' else call.arguments.get('path', '')}", flush=True)
63
+ elif kind == "tool_end":
64
+ shown = event["result"] if event["is_error"] else f"{len(event['result'].splitlines())} lines"
65
+ print(f" {shown}", flush=True)
66
+ elif kind == "turn_end":
67
+ m = event["message"]
68
+ print(f"\n[{provider.name} | {model} | in={m.usage.input} out={m.usage.output} | {m.stop_reason}]", flush=True)
69
+
70
+ return on_event
71
+
72
+
73
+ def main() -> None:
74
+ # keep Windows consoles from choking on non-ASCII model output
75
+ for stream in (sys.stdout, sys.stderr):
76
+ if hasattr(stream, "reconfigure"):
77
+ stream.reconfigure(encoding="utf-8", errors="replace")
78
+
79
+ print(f"gencode - coding agent working in {os.getcwd()}\n(type 'exit' to quit)\n")
80
+ provider, model = setup()
81
+ print(f"\nUsing {provider.name}/{model}. Ask me to do something.\n")
82
+
83
+ messages: list[Message] = [] # kept across prompts so follow-ups have context
84
+ on_event = make_on_event(provider, model)
85
+
86
+ while True:
87
+ try:
88
+ prompt = input("gencode> ").strip()
89
+ except (EOFError, KeyboardInterrupt):
90
+ print()
91
+ break
92
+ if not prompt:
93
+ continue
94
+ if prompt.lower() in EXIT_WORDS:
95
+ break
96
+
97
+ messages.append(UserMessage(prompt))
98
+ try:
99
+ run_agent(
100
+ AgentOptions(
101
+ provider=provider,
102
+ model=model,
103
+ tools=tools,
104
+ messages=messages,
105
+ system=SYSTEM_PROMPT,
106
+ on_event=on_event,
107
+ )
108
+ )
109
+ except KeyboardInterrupt:
110
+ print("\n(interrupted)")
111
+ except Exception as e: # a failed task must not end the session
112
+ print(f"\nerror: {e}", file=sys.stderr)
113
+ print()
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from ..types import Provider
4
+ from .ollama import create_ollama
5
+ from .openrouter import create_openrouter
6
+
7
+ PROVIDERS = ("ollama", "openrouter")
8
+
9
+
10
+ def get_provider(name: str, api_key: str | None = None) -> Provider:
11
+ if name == "ollama":
12
+ return create_ollama()
13
+ if name == "openrouter":
14
+ if not api_key:
15
+ raise ValueError("openrouter needs an API key")
16
+ return create_openrouter(api_key)
17
+ raise ValueError(f"unknown provider '{name}', available: {', '.join(PROVIDERS)}")
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import urllib.error
6
+ import urllib.request
7
+ from typing import Any, Iterator
8
+
9
+ from ..types import (
10
+ AssistantMessage,
11
+ ContentBlock,
12
+ Done,
13
+ Message,
14
+ StopReason,
15
+ TextBlock,
16
+ TextDelta,
17
+ Tool,
18
+ ToolCallBlock,
19
+ Usage,
20
+ )
21
+
22
+ DEFAULT_BASE_URL = "http://localhost:11434"
23
+ DEFAULT_NUM_CTX = 32768 # override with GENCODE_NUM_CTX; Ollama's own default is only 4096
24
+
25
+
26
+ # our messages -> Ollama native /api/chat format
27
+ def to_ollama(messages: list[Message]) -> list[dict[str, Any]]:
28
+ out: list[dict[str, Any]] = []
29
+ for m in messages:
30
+ if m.role == "user":
31
+ out.append({"role": "user", "content": m.content})
32
+ elif m.role == "assistant":
33
+ text = "".join(b.text for b in m.content if isinstance(b, TextBlock))
34
+ calls = [b for b in m.content if isinstance(b, ToolCallBlock)]
35
+ msg: dict[str, Any] = {"role": "assistant", "content": text}
36
+ if calls:
37
+ # unlike OpenAI, the native API takes arguments as an object, not a JSON string
38
+ msg["tool_calls"] = [
39
+ {"function": {"name": c.name, "arguments": c.arguments}} for c in calls
40
+ ]
41
+ out.append(msg)
42
+ else: # toolResult -> its own message with role "tool"
43
+ out.append({"role": "tool", "tool_name": m.tool_name, "content": m.content})
44
+ return out
45
+
46
+
47
+ class OllamaProvider:
48
+ name = "ollama"
49
+
50
+ def __init__(self, base_url: str, num_ctx: int) -> None:
51
+ # accept an old-style ".../v1" URL too; the native API lives at the root
52
+ base_url = base_url.rstrip("/")
53
+ if base_url.endswith("/v1"):
54
+ base_url = base_url[: -len("/v1")]
55
+ self.chat_url = f"{base_url}/api/chat"
56
+ self.num_ctx = num_ctx
57
+
58
+ def stream(
59
+ self,
60
+ messages: list[Message],
61
+ model: str,
62
+ system: str | None = None,
63
+ tools: list[Tool] | None = None,
64
+ ) -> Iterator[Any]:
65
+ tools = tools or []
66
+ chat = to_ollama(messages)
67
+ if system:
68
+ chat = [{"role": "system", "content": system}, *chat]
69
+
70
+ body: dict[str, Any] = {
71
+ "model": model,
72
+ "messages": chat,
73
+ "stream": True,
74
+ "options": {"num_ctx": self.num_ctx},
75
+ }
76
+ if tools:
77
+ body["tools"] = [
78
+ {
79
+ "type": "function",
80
+ "function": {"name": t.name, "description": t.description, "parameters": t.parameters},
81
+ }
82
+ for t in tools
83
+ ]
84
+
85
+ req = urllib.request.Request(
86
+ self.chat_url,
87
+ data=json.dumps(body).encode("utf-8"),
88
+ headers={"Content-Type": "application/json"},
89
+ )
90
+ try:
91
+ resp = urllib.request.urlopen(req)
92
+ except urllib.error.HTTPError as e:
93
+ raise RuntimeError(f"Ollama error {e.code}: {e.read().decode('utf-8', 'replace')}") from e
94
+
95
+ text = ""
96
+ calls: list[ToolCallBlock] = []
97
+ usage = Usage()
98
+ stop_reason: StopReason = "stop"
99
+
100
+ # the response is NDJSON: one JSON object per line
101
+ with resp:
102
+ for line in resp:
103
+ if not line.strip():
104
+ continue
105
+ chunk = json.loads(line)
106
+ if "error" in chunk:
107
+ raise RuntimeError(f"Ollama error: {chunk['error']}")
108
+ msg = chunk.get("message") or {}
109
+ if msg.get("content"):
110
+ text += msg["content"]
111
+ yield TextDelta(msg["content"])
112
+ # tool calls arrive whole (arguments already parsed), not in pieces
113
+ for tc in msg.get("tool_calls") or []:
114
+ fn = tc.get("function") or {}
115
+ args = fn.get("arguments") or {}
116
+ if isinstance(args, str):
117
+ try:
118
+ args = json.loads(args) if args else {}
119
+ except json.JSONDecodeError:
120
+ # small local models sometimes emit broken JSON; let the loop report it to the model
121
+ args = {"__parse_error__": args}
122
+ calls.append(
123
+ ToolCallBlock(id=tc.get("id") or f"call_{len(calls)}", name=fn.get("name", ""), arguments=args)
124
+ )
125
+ if chunk.get("done"):
126
+ if chunk.get("done_reason") == "length":
127
+ stop_reason = "length"
128
+ usage = Usage(chunk.get("prompt_eval_count", 0), chunk.get("eval_count", 0))
129
+
130
+ content: list[ContentBlock] = [TextBlock(text)] if text else []
131
+ content.extend(calls)
132
+ if calls:
133
+ stop_reason = "toolUse"
134
+
135
+ yield Done(AssistantMessage(content=content, usage=usage, stop_reason=stop_reason))
136
+
137
+
138
+ def create_ollama() -> OllamaProvider:
139
+ return OllamaProvider(
140
+ base_url=os.environ.get("OLLAMA_BASE_URL", DEFAULT_BASE_URL),
141
+ num_ctx=int(os.environ.get("GENCODE_NUM_CTX", DEFAULT_NUM_CTX)),
142
+ )
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import urllib.error
6
+ import urllib.request
7
+ from typing import Any, Iterator
8
+
9
+ from ..types import (
10
+ AssistantMessage,
11
+ ContentBlock,
12
+ Done,
13
+ Message,
14
+ StopReason,
15
+ TextBlock,
16
+ TextDelta,
17
+ Tool,
18
+ ToolCallBlock,
19
+ Usage,
20
+ )
21
+
22
+ DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
23
+
24
+
25
+ # our messages -> OpenAI-style chat format (what OpenRouter speaks)
26
+ def to_openai(messages: list[Message]) -> list[dict[str, Any]]:
27
+ out: list[dict[str, Any]] = []
28
+ for m in messages:
29
+ if m.role == "user":
30
+ out.append({"role": "user", "content": m.content})
31
+ elif m.role == "assistant":
32
+ text = "".join(b.text for b in m.content if isinstance(b, TextBlock))
33
+ calls = [b for b in m.content if isinstance(b, ToolCallBlock)]
34
+ msg: dict[str, Any] = {"role": "assistant", "content": text or None}
35
+ if calls:
36
+ # OpenAI wants arguments as a JSON string
37
+ msg["tool_calls"] = [
38
+ {"id": c.id, "type": "function", "function": {"name": c.name, "arguments": json.dumps(c.arguments)}}
39
+ for c in calls
40
+ ]
41
+ out.append(msg)
42
+ else: # toolResult -> role "tool", linked by tool_call_id
43
+ out.append({"role": "tool", "tool_call_id": m.tool_call_id, "content": m.content})
44
+ return out
45
+
46
+
47
+ class OpenRouterProvider:
48
+ name = "openrouter"
49
+
50
+ def __init__(self, base_url: str, api_key: str) -> None:
51
+ self.chat_url = f"{base_url.rstrip('/')}/chat/completions"
52
+ self.api_key = api_key
53
+
54
+ def stream(
55
+ self,
56
+ messages: list[Message],
57
+ model: str,
58
+ system: str | None = None,
59
+ tools: list[Tool] | None = None,
60
+ ) -> Iterator[Any]:
61
+ tools = tools or []
62
+ chat = to_openai(messages)
63
+ if system:
64
+ chat = [{"role": "system", "content": system}, *chat]
65
+
66
+ body: dict[str, Any] = {"model": model, "messages": chat, "stream": True}
67
+ if tools:
68
+ body["tools"] = [
69
+ {
70
+ "type": "function",
71
+ "function": {"name": t.name, "description": t.description, "parameters": t.parameters},
72
+ }
73
+ for t in tools
74
+ ]
75
+
76
+ req = urllib.request.Request(
77
+ self.chat_url,
78
+ data=json.dumps(body).encode("utf-8"),
79
+ headers={
80
+ "Content-Type": "application/json",
81
+ "Authorization": f"Bearer {self.api_key}",
82
+ "X-Title": "gencode",
83
+ },
84
+ )
85
+ try:
86
+ resp = urllib.request.urlopen(req)
87
+ except urllib.error.HTTPError as e:
88
+ raise RuntimeError(f"OpenRouter error {e.code}: {e.read().decode('utf-8', 'replace')}") from e
89
+
90
+ text = ""
91
+ partial: dict[int, dict[str, str]] = {} # tool call index -> pieces, arguments arrive in fragments
92
+ usage = Usage()
93
+ stop_reason: StopReason = "stop"
94
+
95
+ # the response is server-sent events: "data: {json}" lines, ending with "data: [DONE]"
96
+ with resp:
97
+ for raw in resp:
98
+ line = raw.decode("utf-8", "replace").strip()
99
+ if not line.startswith("data:"):
100
+ continue # blank lines and ": OPENROUTER PROCESSING" keep-alives
101
+ payload = line[len("data:") :].strip()
102
+ if payload == "[DONE]":
103
+ break
104
+ chunk = json.loads(payload)
105
+ if "error" in chunk:
106
+ raise RuntimeError(f"OpenRouter error: {chunk['error']}")
107
+ if chunk.get("usage"):
108
+ usage = Usage(chunk["usage"].get("prompt_tokens", 0), chunk["usage"].get("completion_tokens", 0))
109
+ for choice in chunk.get("choices") or []:
110
+ delta = choice.get("delta") or {}
111
+ if delta.get("content"):
112
+ text += delta["content"]
113
+ yield TextDelta(delta["content"])
114
+ for tc in delta.get("tool_calls") or []:
115
+ slot = partial.setdefault(tc.get("index", 0), {"id": "", "name": "", "args": ""})
116
+ fn = tc.get("function") or {}
117
+ slot["id"] = tc.get("id") or slot["id"]
118
+ slot["name"] += fn.get("name") or ""
119
+ slot["args"] += fn.get("arguments") or ""
120
+ if choice.get("finish_reason") == "length":
121
+ stop_reason = "length"
122
+
123
+ calls: list[ToolCallBlock] = []
124
+ for i, slot in sorted(partial.items()):
125
+ try:
126
+ args = json.loads(slot["args"]) if slot["args"] else {}
127
+ except json.JSONDecodeError:
128
+ # let the loop report the broken JSON back to the model
129
+ args = {"__parse_error__": slot["args"]}
130
+ calls.append(ToolCallBlock(id=slot["id"] or f"call_{i}", name=slot["name"], arguments=args))
131
+
132
+ content: list[ContentBlock] = [TextBlock(text)] if text else []
133
+ content.extend(calls)
134
+ if calls:
135
+ stop_reason = "toolUse"
136
+
137
+ yield Done(AssistantMessage(content=content, usage=usage, stop_reason=stop_reason))
138
+
139
+
140
+ def create_openrouter(api_key: str) -> OpenRouterProvider:
141
+ return OpenRouterProvider(base_url=os.environ.get("OPENROUTER_BASE_URL", DEFAULT_BASE_URL), api_key=api_key)
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from ..types import Tool
4
+ from .bash import bash_tool
5
+ from .find import find_tool
6
+ from .grep import grep_tool
7
+ from .ls import ls_tool
8
+ from .read import read_tool
9
+ from .stat import stat_tool
10
+ from .write import write_tool
11
+
12
+ tools: list[Tool] = [bash_tool, read_tool, write_tool, ls_tool, grep_tool, stat_tool, find_tool]
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+
8
+ from ..types import Tool
9
+
10
+ MAX_LINES = 2000
11
+ MAX_BYTES = 50 * 1024
12
+
13
+
14
+ def find_bash() -> str:
15
+ """Prefer Git Bash on Windows; plain 'bash' can be the WSL launcher, which sees another file system."""
16
+ if sys.platform != "win32":
17
+ return "bash"
18
+ candidates = [
19
+ os.path.join(os.environ.get("ProgramFiles", ""), "Git", "bin", "bash.exe"),
20
+ os.path.join(os.environ.get("ProgramFiles(x86)", ""), "Git", "bin", "bash.exe"),
21
+ os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Git", "bin", "bash.exe"),
22
+ ]
23
+ for c in candidates:
24
+ if os.path.exists(c):
25
+ return c
26
+ for d in os.environ.get("PATH", "").split(os.pathsep):
27
+ if not d or d.rstrip("\\/").lower().endswith("system32"):
28
+ continue
29
+ c = os.path.join(d, "bash.exe")
30
+ if os.path.exists(c):
31
+ return c
32
+ return shutil.which("bash") or "bash"
33
+
34
+
35
+ def truncate_tail(text: str, max_lines: int = MAX_LINES, max_bytes: int = MAX_BYTES) -> str:
36
+ """Keep the END of the output (errors/results are at the bottom) and say what was cut."""
37
+ lines = text.split("\n")
38
+ kept = lines[-max_lines:]
39
+ while len(kept) > 1 and len("\n".join(kept).encode()) > max_bytes:
40
+ kept.pop(0)
41
+ out = "\n".join(kept)
42
+ if len(out.encode()) > max_bytes:
43
+ out = out[-max_bytes:] # one giant line
44
+ if len(kept) == len(lines) and len(out) == len(text):
45
+ return text
46
+ return f"[truncated: showing the last {len(kept)} of {len(lines)} lines]\n{out}"
47
+
48
+
49
+ def run_bash(command: str, timeout_s: float) -> str:
50
+ try:
51
+ proc = subprocess.Popen(
52
+ [find_bash(), "-c", command],
53
+ cwd=os.getcwd(),
54
+ stdout=subprocess.PIPE,
55
+ stderr=subprocess.STDOUT,
56
+ text=True,
57
+ encoding="utf-8",
58
+ errors="replace",
59
+ )
60
+ except OSError as e:
61
+ return f"failed to start bash: {e}"
62
+ try:
63
+ out, _ = proc.communicate(timeout=timeout_s)
64
+ return f"{out}\n[exit code: {proc.returncode}]"
65
+ except subprocess.TimeoutExpired:
66
+ # proc.kill() does not kill grandchildren on Windows; taskkill /T kills the whole tree
67
+ if sys.platform == "win32":
68
+ subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], capture_output=True)
69
+ else:
70
+ proc.kill()
71
+ out, _ = proc.communicate()
72
+ return f"{out}\n[timed out after {timeout_s:g}s]"
73
+
74
+
75
+ def _execute(args: dict) -> str:
76
+ timeout = args.get("timeout")
77
+ seconds = timeout if isinstance(timeout, (int, float)) and not isinstance(timeout, bool) else 30
78
+ return truncate_tail(run_bash(str(args["command"]), seconds))
79
+
80
+
81
+ bash_tool = Tool(
82
+ name="bash",
83
+ description="Run a bash command in the current folder. Returns stdout, stderr and the exit code.",
84
+ parameters={
85
+ "type": "object",
86
+ "properties": {
87
+ "command": {"type": "string", "description": "The bash command to run"},
88
+ "timeout": {"type": "number", "description": "Timeout in seconds (default 30)"},
89
+ },
90
+ "required": ["command"],
91
+ },
92
+ execute=_execute,
93
+ )
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from ..types import Tool
6
+ from .fsutil import glob_to_regex, resolve_path, walk
7
+
8
+ LIMIT = 2000
9
+
10
+
11
+ def _execute(args: dict) -> str:
12
+ name = str(args["name"]) if args.get("name") else None
13
+ glob_pattern = str(args["glob"]) if args.get("glob") else None
14
+ regex_pattern = str(args["regex"]) if args.get("regex") else None
15
+ type_filter = str(args["type"]) if args.get("type") else None
16
+
17
+ if not (name or glob_pattern or regex_pattern):
18
+ return "error: provide at least one of 'name', 'glob', or 'regex'"
19
+
20
+ glob_re = glob_to_regex(glob_pattern) if glob_pattern else None
21
+ regex_re = None
22
+ if regex_pattern:
23
+ try:
24
+ regex_re = re.compile(regex_pattern)
25
+ except re.error as e:
26
+ return f"invalid regex: {e}"
27
+
28
+ root = resolve_path(args.get("path") or ".")
29
+ results: list[str] = []
30
+ for entry in walk(root, include_hidden=bool(args.get("includeHidden"))):
31
+ if type_filter == "file" and entry.is_directory:
32
+ continue
33
+ if type_filter == "dir" and not entry.is_directory:
34
+ continue
35
+ base = entry.rel_path.split("/")[-1]
36
+ if name and name.lower() not in base.lower():
37
+ continue
38
+ if glob_re and not glob_re.match(entry.rel_path):
39
+ continue
40
+ if regex_re and not regex_re.search(entry.rel_path):
41
+ continue
42
+ results.append(entry.rel_path + "/" if entry.is_directory else entry.rel_path)
43
+ if len(results) >= LIMIT:
44
+ results.append(f"[truncated at {LIMIT} results]")
45
+ break
46
+ return "\n".join(results) if results else "(no matches)"
47
+
48
+
49
+ find_tool = Tool(
50
+ name="find",
51
+ description=(
52
+ "Find files/directories by name, matching a substring, glob pattern (e.g. '**/*.test.py') "
53
+ "or regex, recursively under a path."
54
+ ),
55
+ parameters={
56
+ "type": "object",
57
+ "properties": {
58
+ "name": {"type": "string", "description": "Substring to match against file/directory names"},
59
+ "glob": {"type": "string", "description": "Glob pattern to match against the relative path, e.g. '**/*.py'"},
60
+ "regex": {"type": "string", "description": "Regex to match against the relative path"},
61
+ "path": {"type": "string", "description": "Directory to search under, relative to the current folder (default '.')"},
62
+ "type": {"type": "string", "description": "Filter by 'file' or 'dir' (default: both)"},
63
+ "includeHidden": {"type": "boolean", "description": "Include dotfiles/dot-directories (default false)"},
64
+ },
65
+ },
66
+ execute=_execute,
67
+ )
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import Iterator
7
+
8
+ DEFAULT_IGNORES = [".git", "node_modules", "dist", "build", ".next", "coverage", ".venv", "__pycache__"]
9
+
10
+
11
+ def glob_to_regex(pattern: str) -> re.Pattern[str]:
12
+ """Turn a glob (supports *, ?, **) into a regex for relative, forward-slash paths."""
13
+ p = pattern.replace("\\", "/")
14
+ out = ""
15
+ i = 0
16
+ while i < len(p):
17
+ c = p[i]
18
+ if c == "*":
19
+ if p[i + 1 : i + 2] == "*":
20
+ # "**" matches across path segments (including none)
21
+ i += 1
22
+ if p[i + 1 : i + 2] == "/":
23
+ i += 1
24
+ out += "(?:.*/)?"
25
+ else:
26
+ out += "[^/]*"
27
+ elif c == "?":
28
+ out += "[^/]"
29
+ else:
30
+ out += re.escape(c)
31
+ i += 1
32
+ return re.compile(f"^{out}$")
33
+
34
+
35
+ @dataclass
36
+ class WalkEntry:
37
+ abs_path: str
38
+ rel_path: str
39
+ is_directory: bool
40
+
41
+
42
+ def walk(
43
+ root: str,
44
+ max_depth: float = float("inf"),
45
+ include_hidden: bool = False,
46
+ ignores: list[str] | None = None,
47
+ ) -> Iterator[WalkEntry]:
48
+ """Depth-first walk yielding files and directories, sorted by name."""
49
+ ignores = DEFAULT_IGNORES if ignores is None else ignores
50
+
51
+ def recurse(directory: str, depth: int) -> Iterator[WalkEntry]:
52
+ try:
53
+ entries = sorted(os.scandir(directory), key=lambda e: e.name.lower())
54
+ except OSError:
55
+ return
56
+ for entry in entries:
57
+ if entry.name in ignores:
58
+ continue
59
+ if not include_hidden and entry.name.startswith("."):
60
+ continue
61
+ abs_path = os.path.join(directory, entry.name)
62
+ rel_path = os.path.relpath(abs_path, root).replace(os.sep, "/")
63
+ is_dir = entry.is_dir()
64
+ yield WalkEntry(abs_path, rel_path, is_dir)
65
+ if is_dir and depth < max_depth:
66
+ yield from recurse(abs_path, depth + 1)
67
+
68
+ yield from recurse(root, 1)
69
+
70
+
71
+ def resolve_path(p: object) -> str:
72
+ return os.path.abspath(os.path.join(os.getcwd(), str(p)))
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+
6
+ from ..types import Tool
7
+ from .fsutil import glob_to_regex, resolve_path, walk
8
+
9
+ MAX_MATCHES = 500
10
+ MAX_FILE_BYTES = 5 * 1024 * 1024 # skip files bigger than 5MB
11
+
12
+
13
+ def _execute(args: dict) -> str:
14
+ root = resolve_path(args.get("path") or ".")
15
+ max_matches = args["maxMatches"] if isinstance(args.get("maxMatches"), (int, float)) else MAX_MATCHES
16
+ glob_re = glob_to_regex(str(args["glob"])) if args.get("glob") else None
17
+ try:
18
+ regex = re.compile(str(args["pattern"]), re.IGNORECASE if args.get("ignoreCase") else 0)
19
+ except re.error as e:
20
+ return f"invalid regex: {e}"
21
+
22
+ # a single file is searched directly
23
+ if os.path.isfile(root):
24
+ entries = [(root, os.path.basename(root))]
25
+ else:
26
+ entries = ((e.abs_path, e.rel_path) for e in walk(root) if not e.is_directory)
27
+
28
+ results: list[str] = []
29
+ for abs_path, rel_path in entries:
30
+ if glob_re and not glob_re.match(rel_path):
31
+ continue
32
+ try:
33
+ if os.path.getsize(abs_path) > MAX_FILE_BYTES:
34
+ continue
35
+ with open(abs_path, "rb") as f:
36
+ data = f.read()
37
+ except OSError:
38
+ continue
39
+ if b"\x00" in data: # skip binary files
40
+ continue
41
+ for i, line in enumerate(data.decode("utf-8", errors="replace").split("\n"), start=1):
42
+ if regex.search(line):
43
+ results.append(f"{rel_path}:{i}:{line.rstrip(chr(13))}")
44
+ if len(results) >= max_matches:
45
+ results.append(f"[truncated at {int(max_matches)} matches]")
46
+ return "\n".join(results)
47
+ return "\n".join(results) if results else "(no matches)"
48
+
49
+
50
+ grep_tool = Tool(
51
+ name="grep",
52
+ description=(
53
+ "Search for a regex pattern inside files under a directory (recursively). "
54
+ "Returns matching file:line:text, like grep -rn."
55
+ ),
56
+ parameters={
57
+ "type": "object",
58
+ "properties": {
59
+ "pattern": {"type": "string", "description": "Regex pattern to search for"},
60
+ "path": {"type": "string", "description": "Directory or file to search in, relative to the current folder (default '.')"},
61
+ "glob": {"type": "string", "description": "Only search files whose relative path matches this glob, e.g. '**/*.py'"},
62
+ "ignoreCase": {"type": "boolean", "description": "Case-insensitive search (default false)"},
63
+ "maxMatches": {"type": "number", "description": f"Max number of matches to return (default {MAX_MATCHES})"},
64
+ },
65
+ "required": ["pattern"],
66
+ },
67
+ execute=_execute,
68
+ )
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from ..types import Tool
4
+ from .fsutil import resolve_path, walk
5
+
6
+ LIMIT = 5000
7
+
8
+
9
+ def _execute(args: dict) -> str:
10
+ root = resolve_path(args.get("path") or ".")
11
+ max_depth = args["maxDepth"] if isinstance(args.get("maxDepth"), (int, float)) else float("inf")
12
+ include_hidden = bool(args.get("includeHidden"))
13
+ dirs_only = bool(args.get("dirsOnly"))
14
+
15
+ lines: list[str] = []
16
+ for entry in walk(root, max_depth=max_depth, include_hidden=include_hidden):
17
+ if dirs_only and not entry.is_directory:
18
+ continue
19
+ lines.append(entry.rel_path + "/" if entry.is_directory else entry.rel_path)
20
+ if len(lines) >= LIMIT:
21
+ lines.append(f"[truncated at {LIMIT} entries]")
22
+ break
23
+ return "\n".join(lines) if lines else "(empty)"
24
+
25
+
26
+ ls_tool = Tool(
27
+ name="ls",
28
+ description=(
29
+ "List files and directories recursively under a path. "
30
+ "Skips common noise dirs (.git, node_modules, dist, build, .venv) and hidden files by default."
31
+ ),
32
+ parameters={
33
+ "type": "object",
34
+ "properties": {
35
+ "path": {"type": "string", "description": "Directory to list, relative to the current folder (default '.')"},
36
+ "maxDepth": {"type": "number", "description": "How many levels deep to recurse (default: unlimited)"},
37
+ "includeHidden": {"type": "boolean", "description": "Include dotfiles/dot-directories (default false)"},
38
+ "dirsOnly": {"type": "boolean", "description": "Only list directories (default false)"},
39
+ },
40
+ },
41
+ execute=_execute,
42
+ )
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from ..types import Tool
4
+ from .fsutil import resolve_path
5
+
6
+
7
+ def _execute(args: dict) -> str:
8
+ with open(resolve_path(args["path"]), encoding="utf-8", errors="replace") as f:
9
+ return f.read()
10
+
11
+
12
+ read_tool = Tool(
13
+ name="read",
14
+ description="Read a text file and returns its contents",
15
+ parameters={
16
+ "type": "object",
17
+ "properties": {
18
+ "path": {"type": "string", "description": "Path to the file, relative to the current folder"},
19
+ },
20
+ "required": ["path"],
21
+ },
22
+ execute=_execute,
23
+ )
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import stat as stat_mod
5
+ from datetime import datetime, timezone
6
+
7
+ from ..types import Tool
8
+ from .fsutil import resolve_path
9
+
10
+
11
+ def format_bytes(n: float) -> str:
12
+ if n < 1024:
13
+ return f"{int(n)} B"
14
+ for unit in ("KB", "MB", "GB", "TB"):
15
+ n /= 1024
16
+ if n < 1024 or unit == "TB":
17
+ return f"{n:.2f} {unit}"
18
+ return f"{n:.2f} TB"
19
+
20
+
21
+ def _iso(ts: float) -> str:
22
+ return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
23
+
24
+
25
+ def _execute(args: dict) -> str:
26
+ target = resolve_path(args["path"])
27
+ s = os.stat(target)
28
+ kind = "directory" if stat_mod.S_ISDIR(s.st_mode) else "file" if stat_mod.S_ISREG(s.st_mode) else "other"
29
+ return "\n".join(
30
+ [
31
+ f"path: {target}",
32
+ f"type: {kind}",
33
+ f"size: {s.st_size} bytes ({format_bytes(s.st_size)})",
34
+ f"created: {_iso(s.st_ctime)}",
35
+ f"modified: {_iso(s.st_mtime)}",
36
+ f"accessed: {_iso(s.st_atime)}",
37
+ f"mode: {oct(s.st_mode & 0o777)[2:]}",
38
+ ]
39
+ )
40
+
41
+
42
+ stat_tool = Tool(
43
+ name="stat",
44
+ description="Get metadata for a file or directory: size, type, created/modified times, permissions.",
45
+ parameters={
46
+ "type": "object",
47
+ "properties": {
48
+ "path": {"type": "string", "description": "Path to the file or directory, relative to the current folder"},
49
+ },
50
+ "required": ["path"],
51
+ },
52
+ execute=_execute,
53
+ )
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from ..types import Tool
6
+ from .fsutil import resolve_path
7
+
8
+
9
+ def _execute(args: dict) -> str:
10
+ if "content" not in args:
11
+ raise ValueError("missing 'content'")
12
+ target = resolve_path(args["path"])
13
+ os.makedirs(os.path.dirname(target), exist_ok=True)
14
+ content = str(args["content"])
15
+ with open(target, "w", encoding="utf-8", newline="\n") as f:
16
+ f.write(content)
17
+ return f"wrote {len(content.encode())} bytes to {args['path']}"
18
+
19
+
20
+ write_tool = Tool(
21
+ name="write",
22
+ description="Create or overwrite a text file with the given content. Parent folders are created automatically.",
23
+ parameters={
24
+ "type": "object",
25
+ "properties": {
26
+ "path": {"type": "string", "description": "Path to the file, relative to the current folder"},
27
+ "content": {"type": "string", "description": "The full file content"},
28
+ },
29
+ "required": ["path", "content"],
30
+ },
31
+ execute=_execute,
32
+ )
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Callable, Iterator, Literal, Protocol, Union
5
+
6
+ StopReason = Literal["stop", "length", "toolUse"]
7
+
8
+
9
+ @dataclass
10
+ class Usage:
11
+ input: int = 0
12
+ output: int = 0
13
+
14
+
15
+ @dataclass
16
+ class TextBlock:
17
+ text: str
18
+ type: str = "text"
19
+
20
+
21
+ @dataclass
22
+ class ToolCallBlock:
23
+ id: str
24
+ name: str
25
+ arguments: dict[str, Any]
26
+ type: str = "toolCall"
27
+
28
+
29
+ ContentBlock = Union[TextBlock, ToolCallBlock]
30
+
31
+
32
+ @dataclass
33
+ class UserMessage:
34
+ content: str
35
+ role: str = "user"
36
+
37
+
38
+ @dataclass
39
+ class AssistantMessage:
40
+ content: list[ContentBlock]
41
+ usage: Usage
42
+ stop_reason: StopReason
43
+ role: str = "assistant"
44
+
45
+
46
+ @dataclass
47
+ class ToolResultMessage:
48
+ tool_call_id: str
49
+ tool_name: str
50
+ content: str
51
+ is_error: bool
52
+ role: str = "toolResult"
53
+
54
+
55
+ Message = Union[UserMessage, AssistantMessage, ToolResultMessage]
56
+
57
+
58
+ @dataclass
59
+ class Tool:
60
+ name: str
61
+ description: str
62
+ parameters: dict[str, Any]
63
+ execute: Callable[[dict[str, Any]], str]
64
+
65
+
66
+ # stream events
67
+ @dataclass
68
+ class TextDelta:
69
+ delta: str
70
+
71
+
72
+ @dataclass
73
+ class Done:
74
+ message: AssistantMessage
75
+
76
+
77
+ StreamEvent = Union[TextDelta, Done]
78
+
79
+
80
+ class Provider(Protocol):
81
+ name: str
82
+
83
+ def stream(
84
+ self,
85
+ messages: list[Message],
86
+ model: str,
87
+ system: str | None = None,
88
+ tools: list[Tool] | None = None,
89
+ ) -> Iterator[StreamEvent]: ...