mlx-code 0.0.1a0__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.
- mlx_code-0.0.1a0/PKG-INFO +33 -0
- mlx_code-0.0.1a0/README.md +12 -0
- mlx_code-0.0.1a0/main.py +488 -0
- mlx_code-0.0.1a0/mlx_code.egg-info/PKG-INFO +33 -0
- mlx_code-0.0.1a0/mlx_code.egg-info/SOURCES.txt +9 -0
- mlx_code-0.0.1a0/mlx_code.egg-info/dependency_links.txt +1 -0
- mlx_code-0.0.1a0/mlx_code.egg-info/entry_points.txt +2 -0
- mlx_code-0.0.1a0/mlx_code.egg-info/requires.txt +1 -0
- mlx_code-0.0.1a0/mlx_code.egg-info/top_level.txt +1 -0
- mlx_code-0.0.1a0/setup.cfg +4 -0
- mlx_code-0.0.1a0/setup.py +18 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mlx-code
|
|
3
|
+
Version: 0.0.1a0
|
|
4
|
+
Summary: Local Claude Code-style coding agent via mlx-lm
|
|
5
|
+
Home-page: https://github.com/JosefAlbers/mlx-code
|
|
6
|
+
Author: J Joe
|
|
7
|
+
Author-email: albersj66@gmail.com
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: mlx-lm>=0.19.0
|
|
12
|
+
Dynamic: author
|
|
13
|
+
Dynamic: author-email
|
|
14
|
+
Dynamic: description
|
|
15
|
+
Dynamic: description-content-type
|
|
16
|
+
Dynamic: home-page
|
|
17
|
+
Dynamic: license
|
|
18
|
+
Dynamic: requires-dist
|
|
19
|
+
Dynamic: requires-python
|
|
20
|
+
Dynamic: summary
|
|
21
|
+
|
|
22
|
+
# mlx-code
|
|
23
|
+
|
|
24
|
+
Local Claude Code-style agent via mlx-lm.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install mlx-code
|
|
28
|
+
mlx-code
|
|
29
|
+
mlx-code --dir ~/myproject
|
|
30
|
+
mlx-code --model mlx-community/Qwen3.5-397B-A17B-8bit
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+

|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# mlx-code
|
|
2
|
+
|
|
3
|
+
Local Claude Code-style agent via mlx-lm.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install mlx-code
|
|
7
|
+
mlx-code
|
|
8
|
+
mlx-code --dir ~/myproject
|
|
9
|
+
mlx-code --model mlx-community/Qwen3.5-397B-A17B-8bit
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+

|
mlx_code-0.0.1a0/main.py
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""mlx-code: local Claude Code-style agent via mlx-lm."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import argparse, json, re, subprocess, sys, textwrap
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from mlx_lm import load, stream_generate
|
|
9
|
+
except ImportError:
|
|
10
|
+
sys.exit("mlx-lm not found. Run: pip install mlx-lm")
|
|
11
|
+
|
|
12
|
+
FILES = {
|
|
13
|
+
|
|
14
|
+
"~/.claude/CLAUDE.md": """## Project
|
|
15
|
+
mlx-code — local Claude Code-style agent via mlx-lm on Apple Silicon.
|
|
16
|
+
|
|
17
|
+
## Stack
|
|
18
|
+
Python 3.11+, mlx-lm, no other runtime deps.
|
|
19
|
+
|
|
20
|
+
## Commands
|
|
21
|
+
- mlx-code run agent in current directory
|
|
22
|
+
- mlx-code --dir ~/project specify project directory
|
|
23
|
+
- mlx-code --model <hf-id> use a different model
|
|
24
|
+
- python main.py run without installing
|
|
25
|
+
|
|
26
|
+
## Conventions
|
|
27
|
+
- Type hints on all function signatures
|
|
28
|
+
- grep before cat — never read whole files speculatively
|
|
29
|
+
- Functions under 40 lines
|
|
30
|
+
- f-strings, not .format()
|
|
31
|
+
|
|
32
|
+
## Adding skills
|
|
33
|
+
Drop a SKILL.md into ~/.claude/skills/<n>/SKILL.md
|
|
34
|
+
Or ask mlx-code to create one for you.
|
|
35
|
+
description field drives auto-activation — make it keyword-rich.
|
|
36
|
+
disable-model-invocation: true for sensitive skills (deploy etc).
|
|
37
|
+
""",
|
|
38
|
+
|
|
39
|
+
"~/.claude/skills/code-search/SKILL.md": """---
|
|
40
|
+
name: code-search
|
|
41
|
+
description: Search and explore a codebase to understand how it works, find functions, trace data flow, or read code. Use when asked to explain, understand, find, read, or explore code.
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Rules
|
|
45
|
+
- ALWAYS grep before reading. Never cat a whole file without knowing its size.
|
|
46
|
+
- Check line count with `wc -l` before reading anything.
|
|
47
|
+
- Read specific ranges with `cat -n file.py | sed -n 'A,Bp'`.
|
|
48
|
+
|
|
49
|
+
## Workflow
|
|
50
|
+
|
|
51
|
+
### Find relevant code first
|
|
52
|
+
```
|
|
53
|
+
grep -n "def agent_loop" main.py
|
|
54
|
+
grep -rn "class Trainer" src/
|
|
55
|
+
grep -n "import" main.py | head -20
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Then read only that section
|
|
59
|
+
```
|
|
60
|
+
grep -n "def agent_loop" main.py # shows line 230
|
|
61
|
+
cat -n main.py | sed -n '230,280p' # read just those lines
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Map a large file before diving in
|
|
65
|
+
```
|
|
66
|
+
wc -l bigfile.py
|
|
67
|
+
grep -n "^class\\|^def " bigfile.py | head -40
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Trace data flow
|
|
71
|
+
```
|
|
72
|
+
grep -rn "history" main.py
|
|
73
|
+
grep -n "def.*history\\|history.*=" main.py
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Output format
|
|
77
|
+
1. What the code does in 1-2 sentences
|
|
78
|
+
2. Key data structures
|
|
79
|
+
3. Execution flow step by step
|
|
80
|
+
4. Any gotchas
|
|
81
|
+
""",
|
|
82
|
+
|
|
83
|
+
"~/.claude/skills/python-debug/SKILL.md": """---
|
|
84
|
+
name: python-debug
|
|
85
|
+
description: Debug Python errors, tracebacks, exceptions, crashes, and unexpected behaviour. Use when the user mentions an error, traceback, crash, bug, or asks why something is broken.
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Workflow
|
|
89
|
+
1. Read the traceback — bottom line is the error, last frame in YOUR code is relevant
|
|
90
|
+
2. Jump to the line: `grep -n "fn_name" file.py` then `cat -n file.py | sed -n 'A,Bp'`
|
|
91
|
+
3. Reproduce mentally — what value arrives there, why does it fail
|
|
92
|
+
4. Fix minimally — no refactoring while debugging
|
|
93
|
+
5. Verify: `python -m pytest tests/ -x -q` or `python script.py`
|
|
94
|
+
|
|
95
|
+
## Common errors
|
|
96
|
+
| Error | Cause |
|
|
97
|
+
|-------|-------|
|
|
98
|
+
| `AttributeError: 'NoneType'` | missing None check before .attr |
|
|
99
|
+
| `KeyError` | key absent — use .get() or check with `in` |
|
|
100
|
+
| `IndexError` | off-by-one or empty list |
|
|
101
|
+
| `TypeError: unsupported operand` | wrong type passed |
|
|
102
|
+
| `RecursionError` | missing base case |
|
|
103
|
+
| `ImportError` | missing package or wrong path |
|
|
104
|
+
|
|
105
|
+
## Debug commands
|
|
106
|
+
```
|
|
107
|
+
python -W all script.py
|
|
108
|
+
git diff HEAD~1 -- file.py
|
|
109
|
+
grep -n "TODO\\|FIXME\\|HACK" .
|
|
110
|
+
```
|
|
111
|
+
""",
|
|
112
|
+
|
|
113
|
+
"~/.claude/skills/write-skill/SKILL.md": """---
|
|
114
|
+
name: write-skill
|
|
115
|
+
description: Create a new skill to teach the agent a reusable capability. Use when asked to add a skill, create a skill, or teach the agent to do something new.
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Steps
|
|
119
|
+
1. Choose name: lowercase hyphens, e.g. `deploy-heroku`
|
|
120
|
+
2. Use write_file with path `~/.claude/skills/<n>/SKILL.md` for global,
|
|
121
|
+
or `.claude/skills/<n>/SKILL.md` for project-only
|
|
122
|
+
|
|
123
|
+
## SKILL.md structure
|
|
124
|
+
```
|
|
125
|
+
---
|
|
126
|
+
name: skill-name
|
|
127
|
+
description: What it does AND when to activate it. Keywords drive auto-activation.
|
|
128
|
+
disable-model-invocation: true # optional: manual /skill only
|
|
129
|
+
allowed-tools: bash # optional: restrict tools
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
# Instructions
|
|
133
|
+
Step by step. Under 400 lines.
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Verify
|
|
137
|
+
```
|
|
138
|
+
ls ~/.claude/skills/
|
|
139
|
+
cat ~/.claude/skills/<n>/SKILL.md
|
|
140
|
+
```
|
|
141
|
+
The agent rescans after every turn — new skills appear immediately.
|
|
142
|
+
""",
|
|
143
|
+
|
|
144
|
+
} # end FILES
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _deploy_files() -> None:
|
|
148
|
+
"""Write any missing FILES to disk. Skips files that already exist."""
|
|
149
|
+
deployed = []
|
|
150
|
+
for dest_s, content in FILES.items():
|
|
151
|
+
dest = Path(dest_s).expanduser()
|
|
152
|
+
if dest.exists():
|
|
153
|
+
continue
|
|
154
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
dest.write_text(content)
|
|
156
|
+
deployed.append(str(dest))
|
|
157
|
+
if deployed:
|
|
158
|
+
print("mlx-code: installed missing files:")
|
|
159
|
+
for f in deployed: print(f" {f}")
|
|
160
|
+
|
|
161
|
+
_deploy_files()
|
|
162
|
+
|
|
163
|
+
DEFAULT_MODEL = "mlx-community/Qwen3.5-4B-OptiQ-4bit"
|
|
164
|
+
MAX_TOOL_TURNS = 30
|
|
165
|
+
|
|
166
|
+
R="\033[0m"; BOLD="\033[1m"; DIM="\033[2m"
|
|
167
|
+
CYAN="\033[36m"; GREEN="\033[32m"; YELLOW="\033[33m"; RED="\033[31m"
|
|
168
|
+
def cp(c,t): print(f"{c}{t}{R}",flush=True)
|
|
169
|
+
|
|
170
|
+
# ── CLAUDE.md ─────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
def load_claude_md(project_dir: Path) -> str:
|
|
173
|
+
parts = []
|
|
174
|
+
for path, label in [
|
|
175
|
+
(Path.home()/".claude"/"CLAUDE.md", "~/.claude/CLAUDE.md"),
|
|
176
|
+
(project_dir/"CLAUDE.md", "CLAUDE.md"),
|
|
177
|
+
]:
|
|
178
|
+
if path.exists():
|
|
179
|
+
parts.append(f"<!-- {label} -->\n{path.read_text().strip()}")
|
|
180
|
+
root = project_dir/"CLAUDE.md"
|
|
181
|
+
for sub in sorted(project_dir.rglob("CLAUDE.md")):
|
|
182
|
+
if sub == root: continue
|
|
183
|
+
parts.append(f"<!-- {sub.relative_to(project_dir)} -->\n{sub.read_text().strip()}")
|
|
184
|
+
return "\n\n".join(parts)
|
|
185
|
+
|
|
186
|
+
# ── Skills ────────────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
SKILL_DIRS = [Path.home()/".claude"/"skills"]
|
|
189
|
+
|
|
190
|
+
def _fm(text: str) -> dict:
|
|
191
|
+
m = re.match(r"^---[ \t]*\n(.*?)\n---[ \t]*\n", text, re.DOTALL)
|
|
192
|
+
if not m: return {}
|
|
193
|
+
fm: dict = {}
|
|
194
|
+
for line in m.group(1).splitlines():
|
|
195
|
+
if ":" in line:
|
|
196
|
+
k,_,v = line.partition(":")
|
|
197
|
+
fm[k.strip()] = v.strip().strip('"').strip("'")
|
|
198
|
+
return fm
|
|
199
|
+
|
|
200
|
+
def discover_skills(project_dir: Path) -> list[dict]:
|
|
201
|
+
dirs = SKILL_DIRS + [project_dir/".claude"/"skills"]
|
|
202
|
+
out: list[dict] = []; seen: set[str] = set()
|
|
203
|
+
for base in dirs:
|
|
204
|
+
if not base.exists(): continue
|
|
205
|
+
for md in sorted(base.rglob("SKILL.md")):
|
|
206
|
+
text = md.read_text(errors="replace")
|
|
207
|
+
fm = _fm(text)
|
|
208
|
+
name = (fm.get("name") or md.parent.name).strip()
|
|
209
|
+
if not name or name in seen: continue
|
|
210
|
+
seen.add(name)
|
|
211
|
+
desc = fm.get("description","").strip()
|
|
212
|
+
if not desc: continue
|
|
213
|
+
raw = fm.get("allowed-tools","")
|
|
214
|
+
out.append({
|
|
215
|
+
"name": name,
|
|
216
|
+
"desc": desc,
|
|
217
|
+
"path": md,
|
|
218
|
+
"allowed": {t.strip() for t in raw.split(",")} if raw else None,
|
|
219
|
+
"no_auto": fm.get("disable-model-invocation","false").lower() in ("true","1","yes"),
|
|
220
|
+
"no_user": fm.get("user-invocable","true").lower() in ("false","0","no"),
|
|
221
|
+
})
|
|
222
|
+
return out
|
|
223
|
+
|
|
224
|
+
# ── Prompt ────────────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
CORE = """\
|
|
227
|
+
━━━ TOOLS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
228
|
+
Call tools with a JSON object on its own line:
|
|
229
|
+
|
|
230
|
+
{"tool": "bash", "args": {"cmd": "..."}}
|
|
231
|
+
{"tool": "write_file", "args": {"path": "...", "content": "..."}}
|
|
232
|
+
|
|
233
|
+
bash cwd=project root, 30s timeout
|
|
234
|
+
ALWAYS grep/find before cat:
|
|
235
|
+
grep -n "def foo" main.py
|
|
236
|
+
grep -rn "pattern" src/
|
|
237
|
+
cat -n file.py | sed -n '40,80p'
|
|
238
|
+
wc -l file.py
|
|
239
|
+
Run: python -m pytest tests/ -x
|
|
240
|
+
Update: printf '\\n## Rule\\n- text\\n' >> CLAUDE.md
|
|
241
|
+
|
|
242
|
+
write_file path relative to project root, content = full file text\
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def skill_tools(skills: list[dict]) -> str:
|
|
246
|
+
auto = [s for s in skills if not s["no_auto"]]
|
|
247
|
+
if not auto: return "\n(no skills installed)"
|
|
248
|
+
lines = ["\n━━━ SKILLS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
249
|
+
"Call a skill tool to load its full instructions into context:\n"]
|
|
250
|
+
for s in auto:
|
|
251
|
+
lines.append(f'{{"tool":"activate_skill_{s["name"]}","args":{{"reason":"..."}}}}\n → {s["desc"]}')
|
|
252
|
+
return "\n".join(lines)
|
|
253
|
+
|
|
254
|
+
def sys_prompt(cwd: Path, claude_md: str, skills: list[dict]) -> str:
|
|
255
|
+
return (
|
|
256
|
+
f"You are mlx-code, a local AI coding assistant (mlx-lm, Apple Silicon).\n"
|
|
257
|
+
f"Explore before writing. Persist knowledge to files.\n\n"
|
|
258
|
+
f"PROJECT: {cwd}\n\n"
|
|
259
|
+
f"━━━ CLAUDE.md ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
|
|
260
|
+
f"{claude_md.strip() or '(none)'}\n\n"
|
|
261
|
+
+ CORE + skill_tools(skills)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# ── Tools ─────────────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
def run_bash(cmd: str, cwd: Path) -> str:
|
|
267
|
+
if not cmd.strip(): return "ERROR: empty cmd"
|
|
268
|
+
try:
|
|
269
|
+
r = subprocess.run(cmd, shell=True, capture_output=True,
|
|
270
|
+
text=True, cwd=str(cwd), timeout=30)
|
|
271
|
+
out = (r.stdout+r.stderr).strip()
|
|
272
|
+
if not out: return f"(exit {r.returncode}, no output)"
|
|
273
|
+
return out[:8000]+("\n…[truncated]" if len(out)>8000 else "")
|
|
274
|
+
except subprocess.TimeoutExpired: return "ERROR: timed out"
|
|
275
|
+
except Exception as e: return f"ERROR: {e}"
|
|
276
|
+
|
|
277
|
+
def execute(name: str, args: dict, cwd: Path,
|
|
278
|
+
skills: list[dict], active: dict|None
|
|
279
|
+
) -> tuple[str, bool, dict|None, str|None]:
|
|
280
|
+
if name == "done":
|
|
281
|
+
return args.get("answer",""), True, active, None
|
|
282
|
+
if name.startswith("activate_skill_"):
|
|
283
|
+
sname = name[len("activate_skill_"):]
|
|
284
|
+
s = next((x for x in skills if x["name"]==sname), None)
|
|
285
|
+
if not s: return f"ERROR: no skill '{sname}'", False, active, None
|
|
286
|
+
return f"Skill '{sname}' loaded.", False, s, s["path"].read_text(errors="replace")
|
|
287
|
+
if active and active.get("allowed"):
|
|
288
|
+
if name.lower() not in {t.lower() for t in active["allowed"]}:
|
|
289
|
+
return f"ERROR: skill '{active['name']}' restricts to {active['allowed']}", False, active, None
|
|
290
|
+
if name == "bash":
|
|
291
|
+
return run_bash(args.get("cmd",""), cwd), False, active, None
|
|
292
|
+
if name == "write_file":
|
|
293
|
+
p = args.get("path","")
|
|
294
|
+
if not p: return "ERROR: missing path", False, active, None
|
|
295
|
+
fp = cwd/p; fp.parent.mkdir(parents=True, exist_ok=True)
|
|
296
|
+
fp.write_text(args.get("content",""))
|
|
297
|
+
return f"OK: wrote {fp}", False, active, None
|
|
298
|
+
return f"ERROR: unknown tool '{name}'", False, active, None
|
|
299
|
+
|
|
300
|
+
# ── Parser ────────────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
_JSON_RE = re.compile(r'\{\s*"tool"\s*:\s*"(?P<n>[^"]+)"\s*,\s*"args"\s*:\s*\{')
|
|
303
|
+
_FENCE_RE = re.compile(r'```(?:bash|sh)\n(.*?)```', re.DOTALL)
|
|
304
|
+
|
|
305
|
+
def _bend(text: str, start: int) -> int|None:
|
|
306
|
+
d=0; ins=False; esc=False
|
|
307
|
+
for i in range(start, len(text)):
|
|
308
|
+
c=text[i]
|
|
309
|
+
if esc: esc=False; continue
|
|
310
|
+
if c=="\\" and ins: esc=True; continue
|
|
311
|
+
if c=='"': ins=not ins; continue
|
|
312
|
+
if ins: continue
|
|
313
|
+
if c=="{": d+=1
|
|
314
|
+
elif c=="}":
|
|
315
|
+
d-=1
|
|
316
|
+
if d==0: return i
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
# def parse_call(text: str) -> tuple[str,dict]|None:
|
|
320
|
+
# text = _FENCE_RE.sub(
|
|
321
|
+
# lambda m: json.dumps({"tool":"bash","args":{"cmd":m.group(1).strip()}}), text)
|
|
322
|
+
# for m in _JSON_RE.finditer(text):
|
|
323
|
+
# ab=m.end()-1; ae=_bend(text,ab)
|
|
324
|
+
# if ae is None: continue
|
|
325
|
+
# try: return m.group("n"), json.loads(text[ab:ae+1])
|
|
326
|
+
# except json.JSONDecodeError: continue
|
|
327
|
+
# return None
|
|
328
|
+
|
|
329
|
+
def parse_call(text: str):
|
|
330
|
+
last = text.strip().splitlines()[-1]
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
obj = json.loads(last)
|
|
334
|
+
except Exception:
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
if "tool" in obj and "args" in obj:
|
|
338
|
+
return obj["tool"], obj["args"]
|
|
339
|
+
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
# ── Generate ──────────────────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
def generate(model, tok, messages: list[dict], max_tok: int) -> str:
|
|
345
|
+
prompt = tok.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
|
|
346
|
+
buf: list[str] = []
|
|
347
|
+
for chunk in stream_generate(model, tok, prompt=prompt, max_tokens=max_tok):
|
|
348
|
+
print(chunk.text, end="", flush=True); buf.append(chunk.text)
|
|
349
|
+
print()
|
|
350
|
+
return "".join(buf)
|
|
351
|
+
|
|
352
|
+
# ── Agent loop ────────────────────────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
def agent_loop(model, tok, user_input: str, cwd: Path,
|
|
355
|
+
claude_md: str, skills: list[dict], history: list[dict],
|
|
356
|
+
max_tok: int, forced_skill: dict|None=None
|
|
357
|
+
) -> tuple[str, str, list[dict]]:
|
|
358
|
+
active: dict|None = None
|
|
359
|
+
preamble = ""
|
|
360
|
+
if forced_skill:
|
|
361
|
+
preamble = f"[Skill '{forced_skill['name']}' activated]\n{forced_skill['path'].read_text().strip()}\n\n"
|
|
362
|
+
active = forced_skill
|
|
363
|
+
cp(DIM, f" [skill '{forced_skill['name']}' force-loaded]")
|
|
364
|
+
|
|
365
|
+
history.append({"role":"user","content":preamble+user_input})
|
|
366
|
+
injected: list[str] = []
|
|
367
|
+
recent: list[str] = []
|
|
368
|
+
|
|
369
|
+
for _ in range(MAX_TOOL_TURNS):
|
|
370
|
+
msgs = ([{"role":"system","content":sys_prompt(cwd,claude_md,skills)}]
|
|
371
|
+
+ [{"role":"system","content":b} for b in injected]
|
|
372
|
+
+ history)
|
|
373
|
+
cp(CYAN, f"\n{'─'*60}"); cp(BOLD+CYAN, "Assistant:")
|
|
374
|
+
resp = generate(model, tok, msgs, max_tok)
|
|
375
|
+
history.append({"role":"assistant","content":resp})
|
|
376
|
+
|
|
377
|
+
call = parse_call(resp)
|
|
378
|
+
if call is None: return resp, claude_md, skills
|
|
379
|
+
|
|
380
|
+
name, args = call
|
|
381
|
+
if name == "done":
|
|
382
|
+
cp(GREEN,"\n✅ Done.")
|
|
383
|
+
return args.get("answer",resp), claude_md, skills
|
|
384
|
+
|
|
385
|
+
sig = json.dumps({"t":name,"a":args},sort_keys=True)
|
|
386
|
+
if sig in recent:
|
|
387
|
+
history.append({"role":"user","content":
|
|
388
|
+
"[SYSTEM] You already ran this exact call. Do not repeat it. "
|
|
389
|
+
"Write your final answer as plain text now."})
|
|
390
|
+
recent.clear(); continue
|
|
391
|
+
recent.append(sig)
|
|
392
|
+
if len(recent)>6: recent.pop(0)
|
|
393
|
+
|
|
394
|
+
cp(YELLOW, f"\n🔧 {name}: {json.dumps(args,ensure_ascii=False)[:160]}")
|
|
395
|
+
result, done, active, skill_body = execute(name, args, cwd, skills, active)
|
|
396
|
+
|
|
397
|
+
if done: cp(GREEN,"\n✅ Done."); return result, claude_md, skills
|
|
398
|
+
if skill_body:
|
|
399
|
+
injected.append(f"[Skill '{name[len('activate_skill_'):]}']\n{skill_body}")
|
|
400
|
+
cp(DIM, " [skill body injected]")
|
|
401
|
+
|
|
402
|
+
cp(DIM, textwrap.indent(result[:600]," "))
|
|
403
|
+
|
|
404
|
+
touched = args.get("cmd","") + args.get("path","")
|
|
405
|
+
if "CLAUDE.md" in touched:
|
|
406
|
+
claude_md = load_claude_md(cwd); cp(DIM," [CLAUDE.md reloaded]")
|
|
407
|
+
if ".claude/skills" in touched:
|
|
408
|
+
skills = discover_skills(cwd); cp(DIM,f" [skills reloaded: {len(skills)}]")
|
|
409
|
+
|
|
410
|
+
history.append({"role":"user","content":f"[tool result: {name}]\n{result}"})
|
|
411
|
+
|
|
412
|
+
cp(RED,f"\n⚠️ Hit {MAX_TOOL_TURNS}-turn limit.")
|
|
413
|
+
return "(turn limit reached)", claude_md, skills
|
|
414
|
+
|
|
415
|
+
# ── REPL ──────────────────────────────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
HELP = """\
|
|
418
|
+
/help this message
|
|
419
|
+
/claude print CLAUDE.md
|
|
420
|
+
/skills list skills
|
|
421
|
+
/skill <n> force-invoke a skill
|
|
422
|
+
/reload rescan CLAUDE.md + skills
|
|
423
|
+
/clear clear conversation history
|
|
424
|
+
/quit exit"""
|
|
425
|
+
|
|
426
|
+
def main():
|
|
427
|
+
ap = argparse.ArgumentParser(description="mlx-code: local Claude Code via mlx-lm")
|
|
428
|
+
ap.add_argument("--model", default=DEFAULT_MODEL)
|
|
429
|
+
ap.add_argument("--dir", default=".")
|
|
430
|
+
ap.add_argument("--max-tokens", default=2048, type=int)
|
|
431
|
+
a = ap.parse_args(); cwd = Path(a.dir).resolve()
|
|
432
|
+
|
|
433
|
+
cp(BOLD+GREEN,"\n╔════════════════════════════════╗")
|
|
434
|
+
cp(BOLD+GREEN, "║ mlx-code ║")
|
|
435
|
+
cp(BOLD+GREEN, "║ Local Claude Code via mlx-lm ║")
|
|
436
|
+
cp(BOLD+GREEN, "╚════════════════════════════════╝\n")
|
|
437
|
+
cp(DIM,f"Project : {cwd}"); cp(DIM,f"Model : {a.model}\n")
|
|
438
|
+
|
|
439
|
+
cp(YELLOW,f"Loading {a.model} …")
|
|
440
|
+
tc = {"trust_remote_code":True} if "qwen" in a.model.lower() else {}
|
|
441
|
+
model, tok = load(a.model, tokenizer_config=tc)
|
|
442
|
+
cp(GREEN,"Model loaded ✓\n")
|
|
443
|
+
|
|
444
|
+
claude_md = load_claude_md(cwd)
|
|
445
|
+
skills = discover_skills(cwd)
|
|
446
|
+
cp(DIM,f"📄 CLAUDE.md : {'loaded' if claude_md else 'not found'}")
|
|
447
|
+
n = sum(1 for s in skills if not s["no_auto"])
|
|
448
|
+
cp(DIM,f"🧩 Skills : {len(skills)} total, {n} model-invocable" +
|
|
449
|
+
(f" ({', '.join(s['name'] for s in skills)})" if skills else
|
|
450
|
+
" (none — add ~/.claude/skills/<n>/SKILL.md)"))
|
|
451
|
+
cp(DIM,"\nType /help for commands.\n")
|
|
452
|
+
|
|
453
|
+
history: list[dict] = []; forced: dict|None = None
|
|
454
|
+
while True:
|
|
455
|
+
try:
|
|
456
|
+
cp(BOLD+GREEN,"\n> You: "); ui = input("").strip()
|
|
457
|
+
except (EOFError,KeyboardInterrupt):
|
|
458
|
+
cp(YELLOW,"\nGoodbye!"); break
|
|
459
|
+
if not ui: continue
|
|
460
|
+
if ui.startswith("/"):
|
|
461
|
+
ps=ui.split(maxsplit=1); cmd=ps[0].lower(); arg=ps[1].strip() if len(ps)>1 else ""
|
|
462
|
+
if cmd=="/quit": cp(YELLOW,"Goodbye!"); break
|
|
463
|
+
elif cmd=="/help": print(HELP)
|
|
464
|
+
elif cmd=="/claude": print(claude_md or "(empty)")
|
|
465
|
+
elif cmd=="/skills":
|
|
466
|
+
if skills:
|
|
467
|
+
for s in skills:
|
|
468
|
+
flags = " [manual-only]" if s["no_auto"] else ""
|
|
469
|
+
cp(GREEN,f" • {s['name']}{flags}"); print(f" {s['desc']}")
|
|
470
|
+
else: cp(DIM,"No skills found.")
|
|
471
|
+
elif cmd=="/skill":
|
|
472
|
+
m=next((s for s in skills if s["name"]==arg and not s["no_user"]),None)
|
|
473
|
+
if m: forced=m; cp(GREEN,f"Skill '{arg}' injected next turn.")
|
|
474
|
+
else: cp(RED,f"No skill '{arg}'. /skills to list.")
|
|
475
|
+
elif cmd=="/reload":
|
|
476
|
+
claude_md=load_claude_md(cwd); skills=discover_skills(cwd)
|
|
477
|
+
cp(GREEN,f"Reloaded. {len(skills)} skill(s).")
|
|
478
|
+
elif cmd=="/clear":
|
|
479
|
+
history.clear(); cp(GREEN,"History cleared.")
|
|
480
|
+
else: cp(RED,f"Unknown '{cmd}'. /help")
|
|
481
|
+
continue
|
|
482
|
+
_, claude_md, skills = agent_loop(
|
|
483
|
+
model,tok,ui,cwd,claude_md,skills,history,a.max_tokens,forced)
|
|
484
|
+
forced=None
|
|
485
|
+
claude_md=load_claude_md(cwd); skills=discover_skills(cwd)
|
|
486
|
+
|
|
487
|
+
if __name__=="__main__":
|
|
488
|
+
main()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mlx-code
|
|
3
|
+
Version: 0.0.1a0
|
|
4
|
+
Summary: Local Claude Code-style coding agent via mlx-lm
|
|
5
|
+
Home-page: https://github.com/JosefAlbers/mlx-code
|
|
6
|
+
Author: J Joe
|
|
7
|
+
Author-email: albersj66@gmail.com
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: mlx-lm>=0.19.0
|
|
12
|
+
Dynamic: author
|
|
13
|
+
Dynamic: author-email
|
|
14
|
+
Dynamic: description
|
|
15
|
+
Dynamic: description-content-type
|
|
16
|
+
Dynamic: home-page
|
|
17
|
+
Dynamic: license
|
|
18
|
+
Dynamic: requires-dist
|
|
19
|
+
Dynamic: requires-python
|
|
20
|
+
Dynamic: summary
|
|
21
|
+
|
|
22
|
+
# mlx-code
|
|
23
|
+
|
|
24
|
+
Local Claude Code-style agent via mlx-lm.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install mlx-code
|
|
28
|
+
mlx-code
|
|
29
|
+
mlx-code --dir ~/myproject
|
|
30
|
+
mlx-code --model mlx-community/Qwen3.5-397B-A17B-8bit
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+

|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mlx-lm>=0.19.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
main
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from setuptools import setup
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="mlx-code",
|
|
5
|
+
url='https://github.com/JosefAlbers/mlx-code',
|
|
6
|
+
author_email="albersj66@gmail.com",
|
|
7
|
+
author="J Joe",
|
|
8
|
+
license="Apache-2.0",
|
|
9
|
+
version="0.0.1a0",
|
|
10
|
+
readme="README.md",
|
|
11
|
+
description="Local Claude Code-style coding agent via mlx-lm",
|
|
12
|
+
long_description=open("README.md").read(),
|
|
13
|
+
long_description_content_type="text/markdown",
|
|
14
|
+
python_requires=">=3.11",
|
|
15
|
+
install_requires=["mlx-lm>=0.19.0"],
|
|
16
|
+
py_modules=["main"],
|
|
17
|
+
entry_points={"console_scripts": ["mlx-code=main:main"]},
|
|
18
|
+
)
|