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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ chalilulz = chalilulz:main
@@ -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()