chalilulz 1.0.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,259 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chalilulz
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Agentic coding CLI with multi-provider LLM support
|
|
5
|
+
Author-email: Chalilulz <chalilulz@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ForgedInFiles/chalilulz
|
|
8
|
+
Project-URL: Repository, https://github.com/ForgedInFiles/chalilulz
|
|
9
|
+
Keywords: cli,llm,ai,coding,openrouter,ollama,mistral,groq,gemini
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Interpreters
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: ruff; extra == "dev"
|
|
25
|
+
Dynamic: requires-python
|
|
26
|
+
|
|
27
|
+
# chalilulz
|
|
28
|
+
|
|
29
|
+
<p align="center">
|
|
30
|
+
<img src="https://img.shields.io/badge/python-3.8+-blue.svg" alt="Python 3.8+">
|
|
31
|
+
<img src="https://img.shields.io/badge/LLM-Cli-orange.svg" alt="LLM CLI">
|
|
32
|
+
<img src="https://img.shields.io/badge/Open-Source-green.svg" alt="Open Source">
|
|
33
|
+
<img src="https://img.shields.io/badge/Platform-Cross--Platform-yellow.svg" alt="Cross Platform">
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
<p align="center">
|
|
37
|
+
<strong>Agentic coding CLI with multi-provider LLM support</strong><br>
|
|
38
|
+
<em>OpenRouter · Ollama · Mistral · Groq · Gemini</em>
|
|
39
|
+
</p>
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Features
|
|
44
|
+
|
|
45
|
+
- **Multi-Provider Support** — Seamlessly switch between OpenRouter, Ollama, Mistral, Groq, and Gemini
|
|
46
|
+
- **Built-in Tools** — File operations, grep search, glob patterns, bash execution, and more
|
|
47
|
+
- **Agent Skills** — Load custom skill sets from `.skills/` directory
|
|
48
|
+
- **Cross-Platform** — Works on Linux, macOS, and Windows with ANSI color support
|
|
49
|
+
- **Zero Dependencies** — Pure Python standard library — no external packages required
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
### Quick Install (pip)
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# Install globally from PyPI (coming soon)
|
|
59
|
+
pip install chalilulz
|
|
60
|
+
|
|
61
|
+
# Or install from source
|
|
62
|
+
pip install git+https://github.com/ForgedInFiles/chalilulz.git
|
|
63
|
+
|
|
64
|
+
# Or install locally (editable mode)
|
|
65
|
+
git clone https://github.com/ForgedInFiles/chalilulz.git
|
|
66
|
+
cd chalilulz
|
|
67
|
+
pip install -e .
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Manual Install
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Clone the repository
|
|
74
|
+
git clone https://github.com/ForgedInFiles/chalilulz.git
|
|
75
|
+
cd chalilulz
|
|
76
|
+
|
|
77
|
+
# Make executable and add to PATH
|
|
78
|
+
chmod +x chalilulz.py
|
|
79
|
+
|
|
80
|
+
# Option 1: Add to your PATH (Linux/macOS)
|
|
81
|
+
sudo ln -s "$(pwd)/chalilulz.py" /usr/local/bin/chalilulz
|
|
82
|
+
|
|
83
|
+
# Option 2: Add to PATH on Windows (PowerShell)
|
|
84
|
+
# Add the folder to your PATH environment variable
|
|
85
|
+
|
|
86
|
+
# Option 3: Use directly
|
|
87
|
+
./chalilulz.py
|
|
88
|
+
python chalilulz.py
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Requirements
|
|
92
|
+
|
|
93
|
+
- Python 3.8 or higher
|
|
94
|
+
- API keys for your chosen provider (optional for local Ollama)
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Quick Start
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
# After installation, run from anywhere
|
|
102
|
+
chalilulz
|
|
103
|
+
|
|
104
|
+
# Or set a specific model
|
|
105
|
+
chalilulz --model openrouter:arcee-ai/trinity-large-preview:free
|
|
106
|
+
|
|
107
|
+
# Run with Ollama (default)
|
|
108
|
+
chalilulz --model ollama:llama2
|
|
109
|
+
|
|
110
|
+
# Run with Mistral
|
|
111
|
+
chalilulz --model mistral:mistral-small-latest --mistral-key $MISTRAL_API_KEY
|
|
112
|
+
|
|
113
|
+
# Run with Groq
|
|
114
|
+
chalilulz --model groq:llama-3.1-70b-versatile --groq-key $GROQ_API_KEY
|
|
115
|
+
|
|
116
|
+
# Run with Gemini
|
|
117
|
+
chalilulz --model gemini:gemini-2.0-flash --gemini-key $GOOGLE_API_KEY
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Environment Variables
|
|
123
|
+
|
|
124
|
+
| Variable | Description |
|
|
125
|
+
|----------|-------------|
|
|
126
|
+
| `OPENROUTER_API_KEY` | API key for OpenRouter |
|
|
127
|
+
| `MISTRAL_API_KEY` | API key for Mistral AI |
|
|
128
|
+
| `GROQ_API_KEY` | API key for Groq |
|
|
129
|
+
| `GOOGLE_API_KEY` | API key for Gemini |
|
|
130
|
+
| `CHALILULZ_MODEL` | Default model (e.g., `openrouter:arcee-ai/trinity-large-preview:free`) |
|
|
131
|
+
| `CHALILULZ_OLLAMA_HOST` | Ollama host (default: `http://localhost:11434`) |
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Available Tools
|
|
136
|
+
|
|
137
|
+
| Tool | Description |
|
|
138
|
+
|------|-------------|
|
|
139
|
+
| `read` | Read files with line numbers |
|
|
140
|
+
| `write` | Write/create files (auto mkdir) |
|
|
141
|
+
| `edit` | Replace unique string in files |
|
|
142
|
+
| `glob` | Find files by glob pattern sorted by mtime |
|
|
143
|
+
| `grep` | Search files by regex |
|
|
144
|
+
| `bash` | Execute shell commands |
|
|
145
|
+
| `ls` | List directory contents |
|
|
146
|
+
| `mkdir` | Create directories recursively |
|
|
147
|
+
| `rm` | Delete files or directories |
|
|
148
|
+
| `mv` | Move/rename files |
|
|
149
|
+
| `cp` | Copy files or directories |
|
|
150
|
+
| `find` | Recursive find by name pattern |
|
|
151
|
+
| `load_skill` | Load full skill instructions by name |
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Project Structure
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
chalilulz.py # Main application
|
|
159
|
+
setup.py # Installation script
|
|
160
|
+
pyproject.toml # Package configuration
|
|
161
|
+
tests/ # Comprehensive test suite
|
|
162
|
+
test_tools.py # Tool function tests
|
|
163
|
+
test_parsing.py # Model parsing tests
|
|
164
|
+
test_api.py # API call tests
|
|
165
|
+
test_skills.py # Skills loading tests
|
|
166
|
+
test_schema.py # Schema generation tests
|
|
167
|
+
test_main.py # Main loop tests
|
|
168
|
+
test_utils.py # Utility function tests
|
|
169
|
+
test_do_tool_calls.py # Tool call execution tests
|
|
170
|
+
AGENTS.md # Development guidelines
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Testing
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
# Run all tests
|
|
179
|
+
python -m unittest discover -s tests -p 'test_*.py' -v
|
|
180
|
+
|
|
181
|
+
# Run specific test module
|
|
182
|
+
python -m unittest tests.test_tools -v
|
|
183
|
+
python -m unittest tests.test_parsing -v
|
|
184
|
+
python -m unittest tests.test_api -v
|
|
185
|
+
|
|
186
|
+
# Run single test method
|
|
187
|
+
python -m unittest tests.test_tools.TestReadTool.test_read_basic -v
|
|
188
|
+
|
|
189
|
+
# Syntax check
|
|
190
|
+
python -m py_compile chalilulz.py
|
|
191
|
+
|
|
192
|
+
# Lint (requires ruff)
|
|
193
|
+
pip install ruff
|
|
194
|
+
ruff check chalilulz.py tests/
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Usage Examples
|
|
200
|
+
|
|
201
|
+
### Interactive Chat
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
$ chalilulz
|
|
205
|
+
> Write a hello world program in Python
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### With Custom Skills
|
|
209
|
+
|
|
210
|
+
Place skill files in `.skills/` directory:
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
.skills/
|
|
214
|
+
├── code-review/
|
|
215
|
+
│ └── SKILL.md
|
|
216
|
+
└── refactor/
|
|
217
|
+
└── SKILL.md
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Model Switching
|
|
221
|
+
|
|
222
|
+
During runtime, use `/model` command to switch providers:
|
|
223
|
+
|
|
224
|
+
```
|
|
225
|
+
/model ollama:codellama
|
|
226
|
+
/model groq:llama-3.1-70b-versatile
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Configuration
|
|
232
|
+
|
|
233
|
+
### Model Syntax
|
|
234
|
+
|
|
235
|
+
```
|
|
236
|
+
provider:model-id
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Supported Providers
|
|
240
|
+
|
|
241
|
+
| Prefix | Endpoint |
|
|
242
|
+
|--------|----------|
|
|
243
|
+
| `ollama:` | `http://localhost:11434` |
|
|
244
|
+
| `mistral:` | `https://api.mistral.ai/v1` |
|
|
245
|
+
| `groq:` | `https://api.groq.com/openai/v1` |
|
|
246
|
+
| `gemini:` | `https://generativelanguage.googleapis.com/v1beta/openai` |
|
|
247
|
+
| `openrouter:` | `https://openrouter.ai/api/v1` |
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## License
|
|
252
|
+
|
|
253
|
+
MIT License — Feel free to use, modify, and distribute.
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
<p align="center">
|
|
258
|
+
Built with love for developers who love CLI tools
|
|
259
|
+
</p>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
chalilulz.py,sha256=ZoHhzg9rXsSlvmeIEfi8uyXp0ucblFICwy3uK3V3MtU,26930
|
|
2
|
+
chalilulz-1.0.0.dist-info/METADATA,sha256=KsvGWrwVzw2_jKSZCMSb3leJ-mwH3bfgDadgg6tRmao,6636
|
|
3
|
+
chalilulz-1.0.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
4
|
+
chalilulz-1.0.0.dist-info/entry_points.txt,sha256=smNNbrOjj6n-KG1DVAqYLwXf1Wv_x_Uhf2ob2hLTRqo,45
|
|
5
|
+
chalilulz-1.0.0.dist-info/top_level.txt,sha256=Mk4-YbJt4hoal_60F3thRINPGp36NOtxxqBRRFeGV0M,10
|
|
6
|
+
chalilulz-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
chalilulz
|
chalilulz.py
ADDED
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""chalilulz — agentic coding cli · openrouter · agent skills"""
|
|
3
|
+
|
|
4
|
+
import argparse, glob as G, json, os, pathlib, re, shutil, subprocess, sys, threading, time, urllib.request, urllib.error
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Enable ANSI colors on Windows if needed
|
|
8
|
+
def _enable_windows_ansi():
|
|
9
|
+
if sys.platform == "win32":
|
|
10
|
+
try:
|
|
11
|
+
from ctypes import windll, byref
|
|
12
|
+
from ctypes.wintypes import DWORD
|
|
13
|
+
|
|
14
|
+
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
|
|
15
|
+
IN_HANDLE = -10
|
|
16
|
+
CONSOLE_MODE = DWORD()
|
|
17
|
+
h = windll.kernel32.GetStdHandle(IN_HANDLE)
|
|
18
|
+
if windll.kernel32.GetConsoleMode(h, byref(CONSOLE_MODE)):
|
|
19
|
+
if not (CONSOLE_MODE.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING):
|
|
20
|
+
CONSOLE_MODE.value |= ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
|
21
|
+
windll.kernel32.SetConsoleMode(h, CONSOLE_MODE)
|
|
22
|
+
except Exception:
|
|
23
|
+
pass # Silently ignore - colors may not work
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_enable_windows_ansi()
|
|
27
|
+
|
|
28
|
+
# ─ ansi
|
|
29
|
+
R = "\033[0m"
|
|
30
|
+
Bo = "\033[1m"
|
|
31
|
+
D = "\033[2m"
|
|
32
|
+
BL = "\033[34m"
|
|
33
|
+
C = "\033[36m"
|
|
34
|
+
Gr = "\033[32m"
|
|
35
|
+
Y = "\033[33m"
|
|
36
|
+
Re = "\033[31m"
|
|
37
|
+
M = "\033[35m"
|
|
38
|
+
I = "\033[3m"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ─ args
|
|
42
|
+
def _get_default_args():
|
|
43
|
+
class DefaultArgs:
|
|
44
|
+
model = os.getenv(
|
|
45
|
+
"CHALILULZ_MODEL", "openrouter:arcee-ai/trinity-large-preview:free"
|
|
46
|
+
)
|
|
47
|
+
ollama_host = os.getenv("CHALILULZ_OLLAMA_HOST", "http://localhost:11434")
|
|
48
|
+
mistral_key = os.getenv("MISTRAL_API_KEY", "")
|
|
49
|
+
groq_key = os.getenv("GROQ_API_KEY", "")
|
|
50
|
+
gemini_key = os.getenv("GOOGLE_API_KEY", "")
|
|
51
|
+
mistral_host = os.getenv("MISTRAL_HOST", "https://api.mistral.ai/v1")
|
|
52
|
+
groq_host = os.getenv("GROQ_HOST", "https://api.groq.com/openai/v1")
|
|
53
|
+
gemini_host = os.getenv(
|
|
54
|
+
"GEMINI_HOST", "https://generativelanguage.googleapis.com/v1beta/openai"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return DefaultArgs()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
A = argparse.ArgumentParser(prog="chalilulz")
|
|
62
|
+
A.add_argument(
|
|
63
|
+
"--model", "-m", default="openrouter:arcee-ai/trinity-large-preview:free"
|
|
64
|
+
)
|
|
65
|
+
A.add_argument(
|
|
66
|
+
"--ollama-host", default="http://localhost:11434", help="Ollama API host"
|
|
67
|
+
)
|
|
68
|
+
A.add_argument(
|
|
69
|
+
"--mistral-key",
|
|
70
|
+
default=os.environ.get("MISTRAL_API_KEY", ""),
|
|
71
|
+
help="Mistral API key",
|
|
72
|
+
)
|
|
73
|
+
A.add_argument(
|
|
74
|
+
"--groq-key", default=os.environ.get("GROQ_API_KEY", ""), help="Groq API key"
|
|
75
|
+
)
|
|
76
|
+
A.add_argument(
|
|
77
|
+
"--gemini-key",
|
|
78
|
+
default=os.environ.get("GOOGLE_API_KEY", ""),
|
|
79
|
+
help="Google Gemini API key",
|
|
80
|
+
)
|
|
81
|
+
A.add_argument(
|
|
82
|
+
"--mistral-host",
|
|
83
|
+
default="https://api.mistral.ai/v1",
|
|
84
|
+
help="Mistral API base URL",
|
|
85
|
+
)
|
|
86
|
+
A.add_argument(
|
|
87
|
+
"--groq-host",
|
|
88
|
+
default="https://api.groq.com/openai/v1",
|
|
89
|
+
help="Groq API base URL",
|
|
90
|
+
)
|
|
91
|
+
A.add_argument(
|
|
92
|
+
"--gemini-host",
|
|
93
|
+
default="https://generativelanguage.googleapis.com/v1beta/openai",
|
|
94
|
+
help="Gemini OpenAI-compatible base URL",
|
|
95
|
+
)
|
|
96
|
+
ARGS = A.parse_args()
|
|
97
|
+
else:
|
|
98
|
+
ARGS = _get_default_args()
|
|
99
|
+
|
|
100
|
+
MODEL = ARGS.model
|
|
101
|
+
KEY = os.environ.get("OPENROUTER_API_KEY", "")
|
|
102
|
+
OLLAMA_HOST = ARGS.ollama_host
|
|
103
|
+
MISTRAL_KEY = ARGS.mistral_key
|
|
104
|
+
MISTRAL_HOST = ARGS.mistral_host
|
|
105
|
+
GROQ_KEY = ARGS.groq_key
|
|
106
|
+
GROQ_HOST = ARGS.groq_host
|
|
107
|
+
GEMINI_KEY = ARGS.gemini_key
|
|
108
|
+
GEMINI_HOST = ARGS.gemini_host
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ─ tools
|
|
112
|
+
def _r(a):
|
|
113
|
+
try:
|
|
114
|
+
ls = open(a["path"], encoding="utf-8", errors="replace").readlines()
|
|
115
|
+
o, l = a.get("offset", 0), a.get("limit", 9999)
|
|
116
|
+
return "".join(f"{o + i + 1:5}│{ln}" for i, ln in enumerate(ls[o : o + l]))
|
|
117
|
+
except Exception as e:
|
|
118
|
+
return f"error:{e}"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _w(a):
|
|
122
|
+
try:
|
|
123
|
+
pathlib.Path(a["path"]).parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
open(a["path"], "w", encoding="utf-8").write(a["content"])
|
|
125
|
+
return f"wrote {len(a['content'])}B"
|
|
126
|
+
except Exception as e:
|
|
127
|
+
return f"error:{e}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _e(a):
|
|
131
|
+
try:
|
|
132
|
+
t = open(a["path"], encoding="utf-8").read()
|
|
133
|
+
o, n = a["old"], a["new"]
|
|
134
|
+
if o not in t:
|
|
135
|
+
return "error:old_string not found"
|
|
136
|
+
c = t.count(o)
|
|
137
|
+
if not a.get("all") and c > 1:
|
|
138
|
+
return f"error:{c} hits — use all=true"
|
|
139
|
+
open(a["path"], "w", encoding="utf-8").write(
|
|
140
|
+
t.replace(o, n) if a.get("all") else t.replace(o, n, 1)
|
|
141
|
+
)
|
|
142
|
+
return f"ok({c if a.get('all') else 1} replaced)"
|
|
143
|
+
except Exception as e:
|
|
144
|
+
return f"error:{e}"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _gl(a):
|
|
148
|
+
try:
|
|
149
|
+
b = a.get("path", ".")
|
|
150
|
+
p = f"{b}/{a['pat']}".replace("//", "/")
|
|
151
|
+
return (
|
|
152
|
+
"\n".join(
|
|
153
|
+
sorted(
|
|
154
|
+
G.glob(p, recursive=True),
|
|
155
|
+
key=lambda f: os.path.getmtime(f) if os.path.isfile(f) else 0,
|
|
156
|
+
reverse=True,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
or "none"
|
|
160
|
+
)
|
|
161
|
+
except Exception as e:
|
|
162
|
+
return f"error:{e}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _gp(a):
|
|
166
|
+
try:
|
|
167
|
+
rx = re.compile(a["pat"])
|
|
168
|
+
h = []
|
|
169
|
+
for fp in G.glob(a.get("path", ".") + "/**", recursive=True):
|
|
170
|
+
try:
|
|
171
|
+
for i, ln in enumerate(open(fp, encoding="utf-8", errors="replace"), 1):
|
|
172
|
+
if rx.search(ln):
|
|
173
|
+
h.append(f"{fp}:{i}:{ln.rstrip()}")
|
|
174
|
+
except:
|
|
175
|
+
pass
|
|
176
|
+
return "\n".join(h[:100]) or "none"
|
|
177
|
+
except Exception as e:
|
|
178
|
+
return f"error:{e}"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _b(a):
|
|
182
|
+
try:
|
|
183
|
+
p = subprocess.Popen(
|
|
184
|
+
a["cmd"],
|
|
185
|
+
shell=True,
|
|
186
|
+
stdout=subprocess.PIPE,
|
|
187
|
+
stderr=subprocess.STDOUT,
|
|
188
|
+
text=True,
|
|
189
|
+
cwd=a.get("cwd"),
|
|
190
|
+
)
|
|
191
|
+
out = []
|
|
192
|
+
for ln in iter(p.stdout.readline, ""):
|
|
193
|
+
print(f" {D}│{ln.rstrip()}{R}", flush=True)
|
|
194
|
+
out.append(ln)
|
|
195
|
+
try:
|
|
196
|
+
p.wait(timeout=120)
|
|
197
|
+
except subprocess.TimeoutExpired:
|
|
198
|
+
p.kill()
|
|
199
|
+
out.append("(timeout)")
|
|
200
|
+
return ("".join(out).strip() or "(empty)") + f"\n[exit {p.returncode}]"
|
|
201
|
+
except Exception as e:
|
|
202
|
+
return f"error:{e}"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _ls(a):
|
|
206
|
+
try:
|
|
207
|
+
d = pathlib.Path(a.get("path", "."))
|
|
208
|
+
lines = []
|
|
209
|
+
for e in sorted(d.iterdir(), key=lambda x: (x.is_file(), x.name)):
|
|
210
|
+
try:
|
|
211
|
+
s = f"{e.stat().st_size:>10}"
|
|
212
|
+
except:
|
|
213
|
+
s = " " * 10
|
|
214
|
+
lines.append(
|
|
215
|
+
f"{'d' if e.is_dir() else 'f'} {s} {e.name}{'/' if e.is_dir() else ''}"
|
|
216
|
+
)
|
|
217
|
+
return "\n".join(lines) or "(empty)"
|
|
218
|
+
except Exception as e:
|
|
219
|
+
return f"error:{e}"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _mk(a):
|
|
223
|
+
try:
|
|
224
|
+
pathlib.Path(a["path"]).mkdir(parents=True, exist_ok=True)
|
|
225
|
+
return f"created"
|
|
226
|
+
except Exception as e:
|
|
227
|
+
return f"error:{e}"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _rm(a):
|
|
231
|
+
try:
|
|
232
|
+
p = pathlib.Path(a["path"])
|
|
233
|
+
if not p.exists():
|
|
234
|
+
return "error:not found"
|
|
235
|
+
shutil.rmtree(p) if p.is_dir() else p.unlink()
|
|
236
|
+
return "deleted"
|
|
237
|
+
except Exception as e:
|
|
238
|
+
return f"error:{e}"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _mv(a):
|
|
242
|
+
try:
|
|
243
|
+
shutil.move(a["src"], a["dest"])
|
|
244
|
+
return f"→{a['dest']}"
|
|
245
|
+
except Exception as e:
|
|
246
|
+
return f"error:{e}"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _cp(a):
|
|
250
|
+
try:
|
|
251
|
+
s = pathlib.Path(a["src"])
|
|
252
|
+
(shutil.copytree if s.is_dir() else shutil.copy2)(s, a["dest"])
|
|
253
|
+
return f"→{a['dest']}"
|
|
254
|
+
except Exception as e:
|
|
255
|
+
return f"error:{e}"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _fd(a):
|
|
259
|
+
try:
|
|
260
|
+
return (
|
|
261
|
+
"\n".join(
|
|
262
|
+
str(p)
|
|
263
|
+
for p in sorted(
|
|
264
|
+
pathlib.Path(a.get("path", ".")).rglob(a.get("pat", "*"))
|
|
265
|
+
)[:200]
|
|
266
|
+
)
|
|
267
|
+
or "none"
|
|
268
|
+
)
|
|
269
|
+
except Exception as e:
|
|
270
|
+
return f"error:{e}"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _sk(a):
|
|
274
|
+
"""load full skill body by name"""
|
|
275
|
+
name = a["name"]
|
|
276
|
+
sd = _skill_dirs()
|
|
277
|
+
for d in sd:
|
|
278
|
+
sm = d / name / "SKILL.md"
|
|
279
|
+
if sm.exists():
|
|
280
|
+
try:
|
|
281
|
+
body = sm.read_text(encoding="utf-8")
|
|
282
|
+
# strip frontmatter
|
|
283
|
+
if body.startswith("---"):
|
|
284
|
+
end = body.find("---", 3)
|
|
285
|
+
body = body[end + 3 :].strip() if end > 0 else body
|
|
286
|
+
# also load scripts listing
|
|
287
|
+
sc = d / name / "scripts"
|
|
288
|
+
extra = ""
|
|
289
|
+
if sc.is_dir():
|
|
290
|
+
extra = "\n\nScripts:\n" + "\n".join(
|
|
291
|
+
str(p) for p in sc.rglob("*") if p.is_file()
|
|
292
|
+
)
|
|
293
|
+
return body + extra
|
|
294
|
+
except Exception as e:
|
|
295
|
+
return f"error:{e}"
|
|
296
|
+
return f"skill '{name}' not found"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# (desc, params{k:type}, fn)
|
|
300
|
+
TOOLS = {
|
|
301
|
+
"read": (
|
|
302
|
+
"Read file w/ line numbers",
|
|
303
|
+
{"path": "string", "offset": "integer", "limit": "integer"},
|
|
304
|
+
_r,
|
|
305
|
+
),
|
|
306
|
+
"write": (
|
|
307
|
+
"Write/create file (auto mkdir)",
|
|
308
|
+
{"path": "string", "content": "string"},
|
|
309
|
+
_w,
|
|
310
|
+
),
|
|
311
|
+
"edit": (
|
|
312
|
+
"Replace unique string in file",
|
|
313
|
+
{"path": "string", "old": "string", "new": "string", "all": "boolean"},
|
|
314
|
+
_e,
|
|
315
|
+
),
|
|
316
|
+
"glob": (
|
|
317
|
+
"Find files by glob sorted by mtime",
|
|
318
|
+
{"pat": "string", "path": "string"},
|
|
319
|
+
_gl,
|
|
320
|
+
),
|
|
321
|
+
"grep": ("Search files by regex", {"pat": "string", "path": "string"}, _gp),
|
|
322
|
+
"bash": ("Run shell command", {"cmd": "string", "cwd": "string"}, _b),
|
|
323
|
+
"ls": ("List directory", {"path": "string"}, _ls),
|
|
324
|
+
"mkdir": ("Create dir recursively", {"path": "string"}, _mk),
|
|
325
|
+
"rm": ("Delete file or dir", {"path": "string"}, _rm),
|
|
326
|
+
"mv": ("Move/rename", {"src": "string", "dest": "string"}, _mv),
|
|
327
|
+
"cp": ("Copy file or dir", {"src": "string", "dest": "string"}, _cp),
|
|
328
|
+
"find": ("rglob find by name pattern", {"pat": "string", "path": "string"}, _fd),
|
|
329
|
+
"load_skill": ("Load full skill instructions by name", {"name": "string"}, _sk),
|
|
330
|
+
}
|
|
331
|
+
# optional params (types without required enforcement)
|
|
332
|
+
OPT = {"offset", "limit", "path", "cwd", "all"}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def mk_schema():
|
|
336
|
+
out = []
|
|
337
|
+
for name, (desc, params, _) in TOOLS.items():
|
|
338
|
+
props = {}
|
|
339
|
+
req = []
|
|
340
|
+
for k, v in params.items():
|
|
341
|
+
props[k] = {"type": v}
|
|
342
|
+
if k not in OPT:
|
|
343
|
+
req.append(k)
|
|
344
|
+
out.append(
|
|
345
|
+
{
|
|
346
|
+
"type": "function",
|
|
347
|
+
"function": {
|
|
348
|
+
"name": name,
|
|
349
|
+
"description": desc,
|
|
350
|
+
"parameters": {
|
|
351
|
+
"type": "object",
|
|
352
|
+
"properties": props,
|
|
353
|
+
"required": req,
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
return out
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
SCHEMA = mk_schema()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# Provider handling
|
|
365
|
+
def parse_model(model_str):
|
|
366
|
+
"""Parse provider prefix from model string. Returns (provider, model_id)."""
|
|
367
|
+
prefix, sep, rest = model_str.partition(":")
|
|
368
|
+
if sep and prefix in ["ollama", "mistral", "groq", "gemini", "openrouter"]:
|
|
369
|
+
return prefix, rest
|
|
370
|
+
# No prefix: default to ollama
|
|
371
|
+
return "ollama", model_str
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def update_model(model_str):
|
|
375
|
+
"""Update global MODEL, PROVIDER, ACTUAL_MODEL."""
|
|
376
|
+
global MODEL, PROVIDER, ACTUAL_MODEL
|
|
377
|
+
MODEL = model_str
|
|
378
|
+
PROVIDER, ACTUAL_MODEL = parse_model(model_str)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# Set initial values
|
|
382
|
+
PROVIDER = None
|
|
383
|
+
ACTUAL_MODEL = None
|
|
384
|
+
update_model(MODEL)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def get_required_key(provider):
|
|
388
|
+
"""Return the API key variable for the given provider, or None if no key needed."""
|
|
389
|
+
if provider == "openrouter":
|
|
390
|
+
return KEY
|
|
391
|
+
elif provider == "mistral":
|
|
392
|
+
return MISTRAL_KEY
|
|
393
|
+
elif provider == "groq":
|
|
394
|
+
return GROQ_KEY
|
|
395
|
+
elif provider == "gemini":
|
|
396
|
+
return GEMINI_KEY
|
|
397
|
+
elif provider == "ollama":
|
|
398
|
+
return None
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def run_tool(name, args):
|
|
403
|
+
if name not in TOOLS:
|
|
404
|
+
return f"error:unknown tool {name!r}"
|
|
405
|
+
return TOOLS[name][2](args)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# ─ agent skills (agentskills.io spec)
|
|
409
|
+
def _skill_dirs():
|
|
410
|
+
cands = [pathlib.Path(os.getcwd())]
|
|
411
|
+
# walk up to repo root looking for .agents/skills
|
|
412
|
+
p = pathlib.Path(os.getcwd())
|
|
413
|
+
while True:
|
|
414
|
+
cands.append(p / ".agents" / "skills")
|
|
415
|
+
cands.append(p / ".skills")
|
|
416
|
+
cands.append(p / "skills")
|
|
417
|
+
if (p / ".git").exists() or p.parent == p:
|
|
418
|
+
break
|
|
419
|
+
p = p.parent
|
|
420
|
+
cands += [
|
|
421
|
+
pathlib.Path.home() / ".agents" / "skills",
|
|
422
|
+
pathlib.Path.home() / ".local" / "share" / "agent-skills",
|
|
423
|
+
]
|
|
424
|
+
return [d for d in cands if d.is_dir()]
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _parse_frontmatter(text):
|
|
428
|
+
if not text.startswith("---"):
|
|
429
|
+
return {}
|
|
430
|
+
end = text.find("---", 3)
|
|
431
|
+
if end < 0:
|
|
432
|
+
return {}
|
|
433
|
+
fm = {}
|
|
434
|
+
body = text[3:end]
|
|
435
|
+
for ln in body.splitlines():
|
|
436
|
+
if ":" in ln:
|
|
437
|
+
k, _, v = ln.partition(":")
|
|
438
|
+
fm[k.strip()] = v.strip()
|
|
439
|
+
return fm
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def load_skills():
|
|
443
|
+
"""returns list of (name,description,path,full_loaded) — only name+desc at startup"""
|
|
444
|
+
found = {}
|
|
445
|
+
for sd in _skill_dirs():
|
|
446
|
+
for skill_dir in sd.iterdir():
|
|
447
|
+
sm = skill_dir / "SKILL.md"
|
|
448
|
+
if sm.is_file() and skill_dir.name not in found:
|
|
449
|
+
try:
|
|
450
|
+
fm = _parse_frontmatter(sm.read_text(encoding="utf-8"))
|
|
451
|
+
if "name" in fm and "description" in fm:
|
|
452
|
+
found[skill_dir.name] = {
|
|
453
|
+
"name": fm["name"],
|
|
454
|
+
"desc": fm["description"],
|
|
455
|
+
"path": str(skill_dir),
|
|
456
|
+
}
|
|
457
|
+
except:
|
|
458
|
+
pass
|
|
459
|
+
return list(found.values())
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def skills_prompt(skills):
|
|
463
|
+
if not skills:
|
|
464
|
+
return ""
|
|
465
|
+
lines = ["\n## Available Skills (use load_skill to activate full instructions):"]
|
|
466
|
+
for s in skills:
|
|
467
|
+
lines.append(f"- {s['name']}: {s['desc'][:120]}")
|
|
468
|
+
return "\n".join(lines)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ─ spinner
|
|
472
|
+
class Spin:
|
|
473
|
+
F = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
474
|
+
|
|
475
|
+
def __init__(self):
|
|
476
|
+
self._e = threading.Event()
|
|
477
|
+
self._t = None
|
|
478
|
+
|
|
479
|
+
def start(self, msg="Thinking"):
|
|
480
|
+
self._e.clear()
|
|
481
|
+
|
|
482
|
+
def _r(i=0):
|
|
483
|
+
w = len(msg) + 14
|
|
484
|
+
while not self._e.is_set():
|
|
485
|
+
print(
|
|
486
|
+
f"\r {C}{self.F[i % 10]}{R} {D}{I}{msg}{R}{D}…{R}",
|
|
487
|
+
end="",
|
|
488
|
+
flush=True,
|
|
489
|
+
)
|
|
490
|
+
i += 1
|
|
491
|
+
time.sleep(0.08)
|
|
492
|
+
print(f"\r{' ' * w}\r", end="", flush=True)
|
|
493
|
+
|
|
494
|
+
self._t = threading.Thread(target=_r, daemon=True)
|
|
495
|
+
self._t.start()
|
|
496
|
+
|
|
497
|
+
def stop(self):
|
|
498
|
+
self._e.set()
|
|
499
|
+
self._t and self._t.join(0.3)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
SP = Spin()
|
|
503
|
+
# ─ xml tool fallback parser (for models without native tool support)
|
|
504
|
+
TC_RE = re.compile(r"<tool_call>(.*?)</tool_call>", re.S)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def parse_xml_calls(text):
|
|
508
|
+
calls = []
|
|
509
|
+
for m in TC_RE.finditer(text):
|
|
510
|
+
try:
|
|
511
|
+
d = json.loads(m.group(1).strip())
|
|
512
|
+
calls.append(d)
|
|
513
|
+
except:
|
|
514
|
+
pass
|
|
515
|
+
return calls
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
XML_TOOL_INST = """
|
|
519
|
+
When you need a tool, output ONLY this format (one per action, then STOP and wait):
|
|
520
|
+
<tool_call>{"name":"TOOLNAME","args":{"key":"val"}}</tool_call>
|
|
521
|
+
Available tools:\n""" + "\n".join(
|
|
522
|
+
f" {k}: {v[0]} | args:{list(v[1].keys())}" for k, v in TOOLS.items()
|
|
523
|
+
)
|
|
524
|
+
# ─ api
|
|
525
|
+
NO_TOOLS_MODELS = set()
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def call_openrouter(msgs, sysp, force_no_tools=False):
|
|
529
|
+
use_tools = not force_no_tools and ACTUAL_MODEL not in NO_TOOLS_MODELS
|
|
530
|
+
body = {
|
|
531
|
+
"model": ACTUAL_MODEL,
|
|
532
|
+
"messages": [{"role": "system", "content": sysp}] + msgs,
|
|
533
|
+
"temperature": 0.3,
|
|
534
|
+
}
|
|
535
|
+
if use_tools:
|
|
536
|
+
body["tools"] = SCHEMA
|
|
537
|
+
body["tool_choice"] = "auto"
|
|
538
|
+
req = urllib.request.Request(
|
|
539
|
+
"https://openrouter.ai/api/v1/chat/completions",
|
|
540
|
+
data=json.dumps(body).encode(),
|
|
541
|
+
headers={
|
|
542
|
+
"Content-Type": "application/json",
|
|
543
|
+
"Authorization": f"Bearer {KEY}",
|
|
544
|
+
"HTTP-Referer": "https://github.com/chalilulz",
|
|
545
|
+
"X-Title": "chalilulz",
|
|
546
|
+
},
|
|
547
|
+
method="POST",
|
|
548
|
+
)
|
|
549
|
+
try:
|
|
550
|
+
resp = json.loads(urllib.request.urlopen(req, timeout=120).read())
|
|
551
|
+
except urllib.error.HTTPError as e:
|
|
552
|
+
raw = e.read().decode()
|
|
553
|
+
if e.code == 400 and use_tools:
|
|
554
|
+
NO_TOOLS_MODELS.add(ACTUAL_MODEL)
|
|
555
|
+
print(f" {Y}⚠ model doesn't support tools — switching to XML mode{R}")
|
|
556
|
+
return call_openrouter(msgs, sysp, force_no_tools=True)
|
|
557
|
+
raise RuntimeError(f"HTTP {e.code}: {raw[:300]}")
|
|
558
|
+
if "error" in resp:
|
|
559
|
+
raise RuntimeError(resp["error"].get("message", str(resp["error"])))
|
|
560
|
+
return resp, use_tools
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def call_ollama(msgs, sysp, force_no_tools=False):
|
|
564
|
+
use_tools = not force_no_tools and ACTUAL_MODEL not in NO_TOOLS_MODELS
|
|
565
|
+
body = {
|
|
566
|
+
"model": ACTUAL_MODEL,
|
|
567
|
+
"messages": [{"role": "system", "content": sysp}] + msgs,
|
|
568
|
+
"stream": False,
|
|
569
|
+
"options": {"temperature": 0.3},
|
|
570
|
+
}
|
|
571
|
+
if use_tools:
|
|
572
|
+
body["tools"] = SCHEMA
|
|
573
|
+
url = OLLAMA_HOST.rstrip("/") + "/api/chat"
|
|
574
|
+
req = urllib.request.Request(
|
|
575
|
+
url,
|
|
576
|
+
data=json.dumps(body).encode(),
|
|
577
|
+
headers={"Content-Type": "application/json"},
|
|
578
|
+
method="POST",
|
|
579
|
+
)
|
|
580
|
+
try:
|
|
581
|
+
resp = json.loads(urllib.request.urlopen(req, timeout=120).read())
|
|
582
|
+
except urllib.error.HTTPError as e:
|
|
583
|
+
raw = e.read().decode()
|
|
584
|
+
if e.code == 400 and use_tools:
|
|
585
|
+
NO_TOOLS_MODELS.add(ACTUAL_MODEL)
|
|
586
|
+
print(f" {Y}⚠ model doesn't support tools — switching to XML mode{R}")
|
|
587
|
+
return call_ollama(msgs, sysp, force_no_tools=True)
|
|
588
|
+
raise RuntimeError(f"HTTP {e.code}: {raw[:300]}")
|
|
589
|
+
if "error" in resp:
|
|
590
|
+
raise RuntimeError(resp["error"].get("message", str(resp["error"])))
|
|
591
|
+
# Transform Ollama response to OpenRouter-compatible format
|
|
592
|
+
transformed = {
|
|
593
|
+
"choices": [{"message": resp["message"]}],
|
|
594
|
+
"usage": {
|
|
595
|
+
"prompt_tokens": resp.get("prompt_eval_count", 0),
|
|
596
|
+
"completion_tokens": resp.get("eval_count", 0),
|
|
597
|
+
},
|
|
598
|
+
}
|
|
599
|
+
return transformed, use_tools
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def call_openai_compatible(
|
|
603
|
+
base_url, api_key, msgs, sysp, force_no_tools=False, auth_header="Bearer"
|
|
604
|
+
):
|
|
605
|
+
use_tools = not force_no_tools and ACTUAL_MODEL not in NO_TOOLS_MODELS
|
|
606
|
+
body = {
|
|
607
|
+
"model": ACTUAL_MODEL,
|
|
608
|
+
"messages": [{"role": "system", "content": sysp}] + msgs,
|
|
609
|
+
"temperature": 0.3,
|
|
610
|
+
}
|
|
611
|
+
if use_tools:
|
|
612
|
+
body["tools"] = SCHEMA
|
|
613
|
+
body["tool_choice"] = "auto"
|
|
614
|
+
headers = {"Content-Type": "application/json"}
|
|
615
|
+
if auth_header == "Bearer":
|
|
616
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
617
|
+
elif auth_header == "x-goog-api-key":
|
|
618
|
+
headers["x-goog-api-key"] = api_key
|
|
619
|
+
url = base_url.rstrip("/") + "/chat/completions"
|
|
620
|
+
req = urllib.request.Request(
|
|
621
|
+
url,
|
|
622
|
+
data=json.dumps(body).encode(),
|
|
623
|
+
headers=headers,
|
|
624
|
+
method="POST",
|
|
625
|
+
)
|
|
626
|
+
try:
|
|
627
|
+
resp = json.loads(urllib.request.urlopen(req, timeout=120).read())
|
|
628
|
+
except urllib.error.HTTPError as e:
|
|
629
|
+
raw = e.read().decode()
|
|
630
|
+
if e.code == 400 and use_tools:
|
|
631
|
+
NO_TOOLS_MODELS.add(ACTUAL_MODEL)
|
|
632
|
+
print(f" {Y}⚠ model doesn't support tools — switching to XML mode{R}")
|
|
633
|
+
return call_openai_compatible(
|
|
634
|
+
base_url,
|
|
635
|
+
api_key,
|
|
636
|
+
msgs,
|
|
637
|
+
sysp,
|
|
638
|
+
force_no_tools=True,
|
|
639
|
+
auth_header=auth_header,
|
|
640
|
+
)
|
|
641
|
+
raise RuntimeError(f"HTTP {e.code}: {raw[:300]}")
|
|
642
|
+
if "error" in resp:
|
|
643
|
+
raise RuntimeError(resp["error"].get("message", str(resp["error"])))
|
|
644
|
+
return resp, use_tools
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def call_mistral(msgs, sysp, force_no_tools=False):
|
|
648
|
+
return call_openai_compatible(
|
|
649
|
+
MISTRAL_HOST, MISTRAL_KEY, msgs, sysp, force_no_tools, "Bearer"
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def call_groq(msgs, sysp, force_no_tools=False):
|
|
654
|
+
return call_openai_compatible(
|
|
655
|
+
GROQ_HOST, GROQ_KEY, msgs, sysp, force_no_tools, "Bearer"
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def call_gemini(msgs, sysp, force_no_tools=False):
|
|
660
|
+
return call_openai_compatible(
|
|
661
|
+
GEMINI_HOST, GEMINI_KEY, msgs, sysp, force_no_tools, "x-goog-api-key"
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def call_api(msgs, sysp, force_no_tools=False):
|
|
666
|
+
if PROVIDER == "openrouter":
|
|
667
|
+
return call_openrouter(msgs, sysp, force_no_tools)
|
|
668
|
+
elif PROVIDER == "ollama":
|
|
669
|
+
return call_ollama(msgs, sysp, force_no_tools)
|
|
670
|
+
elif PROVIDER == "mistral":
|
|
671
|
+
return call_mistral(msgs, sysp, force_no_tools)
|
|
672
|
+
elif PROVIDER == "groq":
|
|
673
|
+
return call_groq(msgs, sysp, force_no_tools)
|
|
674
|
+
elif PROVIDER == "gemini":
|
|
675
|
+
return call_gemini(msgs, sysp, force_no_tools)
|
|
676
|
+
else:
|
|
677
|
+
raise RuntimeError(f"Unknown provider: {PROVIDER}")
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
# ─ ui helpers
|
|
681
|
+
def cols():
|
|
682
|
+
return min(shutil.get_terminal_size((88, 24)).columns, 100)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def sep(c="─", col=D):
|
|
686
|
+
print(f"{col}{c * cols()}{R}")
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def pvw(s, n=74):
|
|
690
|
+
ls = s.split("\n")
|
|
691
|
+
p = ls[0][:n]
|
|
692
|
+
if len(ls) > 1:
|
|
693
|
+
p += f"{D} +{len(ls) - 1}L{R}"
|
|
694
|
+
elif len(ls[0]) > n:
|
|
695
|
+
p += f"{D}…{R}"
|
|
696
|
+
return p
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def rmd(t):
|
|
700
|
+
t = re.sub(r"```\w*\n(.*?)```", f"{D}[code]{R}\\1{D}[/code]{R}", t, flags=re.S)
|
|
701
|
+
t = re.sub(r"`([^`\n]+)`", f"{Y}\\1{R}", t)
|
|
702
|
+
t = re.sub(r"\*\*(.+?)\*\*", f"{Bo}\\1{R}", t)
|
|
703
|
+
t = re.sub(r"\*([^*\n]+)\*", f"{I}\\1{R}", t)
|
|
704
|
+
return t
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
TIC = {
|
|
708
|
+
"read": "📖",
|
|
709
|
+
"write": "✏️",
|
|
710
|
+
"edit": "🔧",
|
|
711
|
+
"glob": "🔍",
|
|
712
|
+
"grep": "🔎",
|
|
713
|
+
"bash": "⚡",
|
|
714
|
+
"ls": "📂",
|
|
715
|
+
"mkdir": "📁",
|
|
716
|
+
"rm": "🗑",
|
|
717
|
+
"mv": "↪",
|
|
718
|
+
"cp": "📋",
|
|
719
|
+
"find": "🔎",
|
|
720
|
+
"load_skill": "🧠",
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def show_tc(name, args, res):
|
|
725
|
+
ic = TIC.get(name, "⚙")
|
|
726
|
+
av = str(list(args.values())[0])[:64] if args else ""
|
|
727
|
+
print(f"\n {Gr}{ic} {Bo}{name}{R}{D}({av}){R}")
|
|
728
|
+
print(f" {D}⎿ {pvw(str(res))}{R}")
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
# ─ agentic loop helpers
|
|
732
|
+
def _do_tool_calls(calls, msgs, xml_mode):
|
|
733
|
+
"""execute tool calls (list of dicts: name+args or id+function), append results, return result msgs"""
|
|
734
|
+
results = []
|
|
735
|
+
for tc in calls:
|
|
736
|
+
if xml_mode:
|
|
737
|
+
name = tc.get("name", "")
|
|
738
|
+
args = tc.get("args", {})
|
|
739
|
+
else:
|
|
740
|
+
name = tc["function"]["name"]
|
|
741
|
+
try:
|
|
742
|
+
args = json.loads(tc["function"].get("arguments") or "{}")
|
|
743
|
+
except:
|
|
744
|
+
args = {}
|
|
745
|
+
res = run_tool(name, args)
|
|
746
|
+
show_tc(name, args, res)
|
|
747
|
+
if not xml_mode:
|
|
748
|
+
if PROVIDER == "ollama": # Ollama format
|
|
749
|
+
results.append({"role": "tool", "tool_name": name, "content": str(res)})
|
|
750
|
+
else: # OpenAI-compatible format (openrouter, mistral, groq, gemini)
|
|
751
|
+
results.append(
|
|
752
|
+
{"role": "tool", "tool_call_id": tc["id"], "content": str(res)}
|
|
753
|
+
)
|
|
754
|
+
else:
|
|
755
|
+
results.append(
|
|
756
|
+
{
|
|
757
|
+
"role": "user",
|
|
758
|
+
"content": f"<tool_result>{json.dumps({'name': name, 'result': str(res)})}</tool_result>",
|
|
759
|
+
}
|
|
760
|
+
)
|
|
761
|
+
msgs.extend(results)
|
|
762
|
+
return results
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
# ─ main
|
|
766
|
+
def main():
|
|
767
|
+
global MODEL
|
|
768
|
+
# Check API key for current provider
|
|
769
|
+
required_key = get_required_key(PROVIDER)
|
|
770
|
+
if required_key is not None and not required_key:
|
|
771
|
+
print(f"\n {Re}✗ set API key for {PROVIDER} provider{R}\n")
|
|
772
|
+
sys.exit(1)
|
|
773
|
+
cwd = os.getcwd()
|
|
774
|
+
skills = load_skills()
|
|
775
|
+
sep("═", Bo + C)
|
|
776
|
+
print(f" {Bo}◆ chalilulz{R} {D}{MODEL}{R}")
|
|
777
|
+
print(f" {D}cwd:{cwd} skills:{len(skills)}{R}")
|
|
778
|
+
sep("═", Bo + C)
|
|
779
|
+
print(f" {D}/q quit /c clear /model <slug> /skills list{R}\n")
|
|
780
|
+
SP_PART = skills_prompt(skills)
|
|
781
|
+
SYS = f"""Expert concise coding assistant. cwd:{cwd} os:{sys.platform}
|
|
782
|
+
Prefer minimal edits. No filler. Think step by step silently.{SP_PART}"""
|
|
783
|
+
XML_SYS = SYS + XML_TOOL_INST
|
|
784
|
+
msgs = []
|
|
785
|
+
while True:
|
|
786
|
+
try:
|
|
787
|
+
sep()
|
|
788
|
+
try:
|
|
789
|
+
ui = input(f" {Bo}{BL}❯ {R}").strip()
|
|
790
|
+
except (KeyboardInterrupt, EOFError):
|
|
791
|
+
print(f"\n {D}bye{R}")
|
|
792
|
+
break
|
|
793
|
+
if not ui:
|
|
794
|
+
continue
|
|
795
|
+
if ui in ("/q", "exit", "quit"):
|
|
796
|
+
print(f"\n {D}bye{R}")
|
|
797
|
+
break
|
|
798
|
+
if ui == "/c":
|
|
799
|
+
msgs = []
|
|
800
|
+
print(f"\n {Gr}✓ cleared{R}")
|
|
801
|
+
continue
|
|
802
|
+
if ui.startswith("/model "):
|
|
803
|
+
new_model = ui[7:].strip()
|
|
804
|
+
update_model(new_model)
|
|
805
|
+
required_key = get_required_key(PROVIDER)
|
|
806
|
+
if required_key is None:
|
|
807
|
+
pass
|
|
808
|
+
elif not required_key:
|
|
809
|
+
print(f"\n {Y}⚠ missing API key for {PROVIDER} provider{R}")
|
|
810
|
+
print(f"\n {Gr}✓ model→{Bo}{MODEL}{R}")
|
|
811
|
+
continue
|
|
812
|
+
if ui == "/skills":
|
|
813
|
+
if not skills:
|
|
814
|
+
print(f"\n {D}no skills found{R}")
|
|
815
|
+
else:
|
|
816
|
+
print(f"\n {Bo}Skills:{R}")
|
|
817
|
+
for s in skills:
|
|
818
|
+
print(f" {C}{s['name']}{R} {D}{s['desc'][:80]}{R}")
|
|
819
|
+
print(f" {D}paths:{[s['path'] for s in skills]}{R}")
|
|
820
|
+
continue
|
|
821
|
+
msgs.append({"role": "user", "content": ui})
|
|
822
|
+
sep()
|
|
823
|
+
while True:
|
|
824
|
+
SP.start()
|
|
825
|
+
try:
|
|
826
|
+
resp, use_tools = call_api(
|
|
827
|
+
msgs, SYS if ACTUAL_MODEL not in NO_TOOLS_MODELS else XML_SYS
|
|
828
|
+
)
|
|
829
|
+
except Exception as e:
|
|
830
|
+
SP.stop()
|
|
831
|
+
print(f"\n {Re}✗ {e}{R}\n")
|
|
832
|
+
msgs.pop()
|
|
833
|
+
break
|
|
834
|
+
SP.stop()
|
|
835
|
+
ch = resp["choices"][0]
|
|
836
|
+
msg = ch["message"]
|
|
837
|
+
usage = resp.get("usage", {})
|
|
838
|
+
if usage:
|
|
839
|
+
print(
|
|
840
|
+
f" {D}↑{usage.get('prompt_tokens', '-')} ↓{usage.get('completion_tokens', '-')}{R}"
|
|
841
|
+
)
|
|
842
|
+
text = (msg.get("content") or "").strip()
|
|
843
|
+
calls = msg.get("tool_calls") or []
|
|
844
|
+
if use_tools:
|
|
845
|
+
msgs.append(msg)
|
|
846
|
+
if text:
|
|
847
|
+
print(f"\n {C}◆{R} {rmd(text)}\n")
|
|
848
|
+
if not calls:
|
|
849
|
+
break
|
|
850
|
+
_do_tool_calls(calls, msgs, xml_mode=False)
|
|
851
|
+
else:
|
|
852
|
+
# xml mode: strip tool_call tags from display
|
|
853
|
+
display = TC_RE.sub("", text).strip()
|
|
854
|
+
if display:
|
|
855
|
+
print(f"\n {C}◆{R} {rmd(display)}\n")
|
|
856
|
+
xml_calls = parse_xml_calls(text)
|
|
857
|
+
msgs.append({"role": "assistant", "content": text})
|
|
858
|
+
if not xml_calls:
|
|
859
|
+
break
|
|
860
|
+
_do_tool_calls(xml_calls, msgs, xml_mode=True)
|
|
861
|
+
print()
|
|
862
|
+
except Exception as e:
|
|
863
|
+
SP.stop()
|
|
864
|
+
print(f"\n {Re}✗ {e}{R}\n")
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
if __name__ == "__main__":
|
|
868
|
+
main()
|