neuroncli 2.3.0__tar.gz → 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {neuroncli-2.3.0 → neuroncli-3.0.0}/PKG-INFO +3 -3
  2. neuroncli-3.0.0/neuroncli/bin/neuron.exe +0 -0
  3. {neuroncli-2.3.0 → neuroncli-3.0.0}/neuroncli/cli.py +43 -0
  4. {neuroncli-2.3.0 → neuroncli-3.0.0}/neuroncli/openrouter_client.py +231 -220
  5. neuroncli-3.0.0/neuroncli/repo_map.py +212 -0
  6. neuroncli-3.0.0/neuroncli/validators.py +169 -0
  7. {neuroncli-2.3.0 → neuroncli-3.0.0}/neuroncli.egg-info/PKG-INFO +3 -3
  8. {neuroncli-2.3.0 → neuroncli-3.0.0}/neuroncli.egg-info/SOURCES.txt +3 -0
  9. {neuroncli-2.3.0 → neuroncli-3.0.0}/pyproject.toml +6 -3
  10. {neuroncli-2.3.0 → neuroncli-3.0.0}/README.md +0 -0
  11. {neuroncli-2.3.0 → neuroncli-3.0.0}/neuroncli/__init__.py +0 -0
  12. {neuroncli-2.3.0 → neuroncli-3.0.0}/neuroncli/__main__.py +0 -0
  13. {neuroncli-2.3.0 → neuroncli-3.0.0}/neuroncli/agent.py +0 -0
  14. {neuroncli-2.3.0 → neuroncli-3.0.0}/neuroncli/auth.py +0 -0
  15. {neuroncli-2.3.0 → neuroncli-3.0.0}/neuroncli/config.py +0 -0
  16. {neuroncli-2.3.0 → neuroncli-3.0.0}/neuroncli/git_integration.py +0 -0
  17. {neuroncli-2.3.0 → neuroncli-3.0.0}/neuroncli/ollama_client.py +0 -0
  18. {neuroncli-2.3.0 → neuroncli-3.0.0}/neuroncli/prompts.py +0 -0
  19. {neuroncli-2.3.0 → neuroncli-3.0.0}/neuroncli/provider.py +0 -0
  20. {neuroncli-2.3.0 → neuroncli-3.0.0}/neuroncli/session.py +0 -0
  21. {neuroncli-2.3.0 → neuroncli-3.0.0}/neuroncli/tools.py +0 -0
  22. {neuroncli-2.3.0 → neuroncli-3.0.0}/neuroncli/ui.py +0 -0
  23. {neuroncli-2.3.0 → neuroncli-3.0.0}/neuroncli.egg-info/dependency_links.txt +0 -0
  24. {neuroncli-2.3.0 → neuroncli-3.0.0}/neuroncli.egg-info/entry_points.txt +0 -0
  25. {neuroncli-2.3.0 → neuroncli-3.0.0}/neuroncli.egg-info/requires.txt +0 -0
  26. {neuroncli-2.3.0 → neuroncli-3.0.0}/neuroncli.egg-info/top_level.txt +0 -0
  27. {neuroncli-2.3.0 → neuroncli-3.0.0}/setup.cfg +0 -0
  28. {neuroncli-2.3.0 → neuroncli-3.0.0}/tests/test_agent.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: neuroncli
3
- Version: 2.3.0
4
- Summary: AI Coding Agent — Free Claude Code alternative powered by Kimi K2.5 + Ollama
3
+ Version: 3.0.0
4
+ Summary: AI Coding Agent — Free Claude Code alternative. Native Rust binary with plan mode, shell escapes, and Azure AI + OpenRouter support.
5
5
  Author-email: Rahul S <rahultech72216@gmail.com>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://zero-x.live
8
8
  Project-URL: Repository, https://github.com/RAHUL-DevelopeRR/neuroncli
9
9
  Project-URL: Issues, https://github.com/RAHUL-DevelopeRR/neuroncli/issues
10
- Keywords: ai,coding,agent,cli,llm,kimi,ollama,code-assistant
10
+ Keywords: ai,coding,agent,cli,llm,claude-code,rust,code-assistant,neuron
11
11
  Classifier: Development Status :: 4 - Beta
12
12
  Classifier: Environment :: Console
13
13
  Classifier: Intended Audience :: Developers
Binary file
@@ -353,6 +353,19 @@ def main(argv: list[str] | None = None) -> int:
353
353
  except Exception:
354
354
  pass
355
355
 
356
+ # ── Try native Rust binary first ─────────────────────────
357
+
358
+ native = _find_native_binary()
359
+ if native:
360
+ import subprocess
361
+ args_list = argv if argv is not None else sys.argv[1:]
362
+ try:
363
+ result = subprocess.run([native] + args_list)
364
+ return result.returncode
365
+ except (OSError, FileNotFoundError):
366
+ pass # Fall through to Python implementation
367
+
368
+ # ── Python fallback ──────────────────────────────────────
356
369
  parser = build_parser()
357
370
  args = parser.parse_args(argv)
358
371
 
@@ -405,5 +418,35 @@ def main(argv: list[str] | None = None) -> int:
405
418
  return 0
406
419
 
407
420
 
421
+ def _find_native_binary() -> str | None:
422
+ """Locate the native Rust binary — bundled in package or in PATH."""
423
+ import shutil
424
+
425
+ # Check bundled binary in package
426
+ pkg_dir = os.path.dirname(os.path.abspath(__file__))
427
+ ext = ".exe" if sys.platform == "win32" else ""
428
+ bundled = os.path.join(pkg_dir, "bin", f"neuron{ext}")
429
+ if os.path.isfile(bundled) and os.access(bundled, os.X_OK):
430
+ return bundled
431
+
432
+ # Check PATH (but not ourselves — avoid infinite recursion)
433
+ path_binary = shutil.which(f"neuron{ext}")
434
+ if path_binary:
435
+ # Verify it's the Rust binary by checking --version output
436
+ import subprocess
437
+ try:
438
+ result = subprocess.run(
439
+ [path_binary, "--version"],
440
+ capture_output=True, text=True, timeout=3
441
+ )
442
+ if "NeuronCLI" in result.stdout:
443
+ return path_binary
444
+ except (OSError, subprocess.TimeoutExpired):
445
+ pass
446
+
447
+ return None
448
+
449
+
408
450
  if __name__ == "__main__":
409
451
  raise SystemExit(main())
452
+
@@ -1,220 +1,231 @@
1
- """NeuronCLI — OpenRouter HTTP streaming client. Zero external dependencies.
2
-
3
- Supports reasoning models (Kimi K2.5) where the response has both
4
- a `reasoning` field (chain-of-thought) and a `content` field (final answer).
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import json
10
- import urllib.error
11
- import urllib.request
12
- from dataclasses import dataclass
13
- from typing import Generator
14
-
15
- from .config import AgentConfig
16
-
17
-
18
- class OpenRouterConnectionError(Exception):
19
- """Raised when the OpenRouter API is unreachable or returns an error."""
20
- pass
21
-
22
-
23
- @dataclass(frozen=True)
24
- class ChatMessage:
25
- """A single message in the conversation."""
26
- role: str # "system", "user", "assistant"
27
- content: str
28
-
29
- def to_dict(self) -> dict[str, str]:
30
- return {"role": self.role, "content": self.content}
31
-
32
-
33
- class OpenRouterClient:
34
- """
35
- HTTP client for OpenRouter's OpenAI-compatible /api/v1/chat/completions.
36
- Uses only stdlib — no pip dependencies needed.
37
-
38
- Handles reasoning models (like Kimi K2.5) that emit:
39
- - `delta.reasoning` tokens (thinking, shown dimmed)
40
- - `delta.content` tokens (final answer)
41
- """
42
-
43
- API_BASE = "https://openrouter.ai/api/v1"
44
-
45
- def __init__(self, config: AgentConfig | None = None):
46
- self.config = config or AgentConfig.from_env()
47
-
48
- def health_check(self) -> bool:
49
- """Check if OpenRouter API is reachable and the API key is valid."""
50
- try:
51
- req = urllib.request.Request(
52
- f"{self.API_BASE}/models",
53
- headers=self._headers(),
54
- )
55
- with urllib.request.urlopen(req, timeout=10) as resp:
56
- return resp.status == 200
57
- except (urllib.error.URLError, OSError):
58
- return False
59
-
60
- def list_models(self) -> list[str]:
61
- """List coding-relevant models from OpenRouter."""
62
- try:
63
- req = urllib.request.Request(
64
- f"{self.API_BASE}/models",
65
- headers=self._headers(),
66
- )
67
- with urllib.request.urlopen(req, timeout=10) as resp:
68
- data = json.loads(resp.read().decode())
69
- models = data.get("data", [])
70
- keywords = ("kimi", "deepseek", "qwen", "codestral", "claude", "gemini")
71
- names = [m["id"] for m in models
72
- if any(k in m["id"].lower() for k in keywords)]
73
- return sorted(names)[:25]
74
- except (urllib.error.URLError, OSError):
75
- return []
76
-
77
- def chat(self, messages: list[ChatMessage], model: str | None = None) -> str:
78
- """Send a chat request and return the full response text."""
79
- payload = self._build_payload(messages, model, stream=False)
80
- data = self._post(payload)
81
- choice = data.get("choices", [{}])[0]
82
- msg = choice.get("message", {})
83
- # Kimi K2.5 reasoning models: content may be in `content` or `reasoning`
84
- content = msg.get("content") or ""
85
- if not content:
86
- content = msg.get("reasoning", "")
87
- return content
88
-
89
- def chat_stream(
90
- self,
91
- messages: list[ChatMessage],
92
- model: str | None = None,
93
- ) -> Generator[str, None, None]:
94
- """
95
- Stream a chat response token by token.
96
-
97
- For reasoning models (Kimi K2.5), yields:
98
- - reasoning tokens with a dim prefix (hidden thinking)
99
- - content tokens normally (the actual response)
100
- """
101
- payload = self._build_payload(messages, model, stream=True)
102
- body = json.dumps(payload).encode("utf-8")
103
- req = urllib.request.Request(
104
- f"{self.API_BASE}/chat/completions",
105
- data=body,
106
- headers=self._headers(json_body=True),
107
- method="POST",
108
- )
109
-
110
- try:
111
- resp = urllib.request.urlopen(req, timeout=300)
112
- except urllib.error.HTTPError as exc:
113
- error_body = exc.read().decode("utf-8", errors="replace")
114
- try:
115
- err_data = json.loads(error_body)
116
- msg = err_data.get("error", {}).get("message", error_body)
117
- except json.JSONDecodeError:
118
- msg = error_body
119
- raise OpenRouterConnectionError(
120
- f"\n [X] OpenRouter API error ({exc.code})\n"
121
- f" {msg}"
122
- ) from exc
123
- except urllib.error.URLError as exc:
124
- raise OpenRouterConnectionError(
125
- f"\n [X] Cannot reach OpenRouter API.\n"
126
- f" Check your internet connection.\n"
127
- f" Error: {exc}"
128
- ) from exc
129
-
130
- in_reasoning = False
131
- try:
132
- for raw_line in resp:
133
- line = raw_line.decode("utf-8", errors="replace").strip()
134
- if not line or line == "data: [DONE]":
135
- continue
136
- if not line.startswith("data: "):
137
- continue
138
- try:
139
- chunk = json.loads(line[6:])
140
- except json.JSONDecodeError:
141
- continue
142
- choices = chunk.get("choices", [])
143
- if not choices:
144
- continue
145
- delta = choices[0].get("delta", {})
146
-
147
- # Handle reasoning tokens (Kimi K2.5 chain-of-thought)
148
- reasoning_token = delta.get("reasoning", "")
149
- content_token = delta.get("content", "")
150
-
151
- if reasoning_token:
152
- if not in_reasoning:
153
- yield "\033[2m\033[90m" # DIM + GRAY
154
- in_reasoning = True
155
- yield reasoning_token
156
-
157
- if content_token:
158
- if in_reasoning:
159
- yield "\033[0m\n" # Reset after reasoning
160
- in_reasoning = False
161
- yield content_token
162
- finally:
163
- if in_reasoning:
164
- yield "\033[0m\n"
165
- resp.close()
166
-
167
- # ── Internal helpers ──────────────────────────────────────────
168
-
169
- def _headers(self, json_body: bool = False) -> dict[str, str]:
170
- h: dict[str, str] = {
171
- "Authorization": f"Bearer {self.config.api_key}",
172
- "HTTP-Referer": "https://github.com/RAHUL-DevelopeRR/neuroncli",
173
- "X-Title": "NeuronCLI",
174
- }
175
- if json_body:
176
- h["Content-Type"] = "application/json"
177
- return h
178
-
179
- def _build_payload(
180
- self,
181
- messages: list[ChatMessage],
182
- model: str | None,
183
- stream: bool,
184
- ) -> dict:
185
- return {
186
- "model": model or self.config.model,
187
- "messages": [m.to_dict() for m in messages],
188
- "stream": stream,
189
- "max_tokens": self.config.max_tokens,
190
- "temperature": self.config.temperature,
191
- "top_p": self.config.top_p,
192
- }
193
-
194
- def _post(self, payload: dict) -> dict:
195
- body = json.dumps(payload).encode("utf-8")
196
- req = urllib.request.Request(
197
- f"{self.API_BASE}/chat/completions",
198
- data=body,
199
- headers=self._headers(json_body=True),
200
- method="POST",
201
- )
202
- try:
203
- with urllib.request.urlopen(req, timeout=300) as resp:
204
- return json.loads(resp.read().decode("utf-8"))
205
- except urllib.error.HTTPError as exc:
206
- error_body = exc.read().decode("utf-8", errors="replace")
207
- try:
208
- err_data = json.loads(error_body)
209
- msg = err_data.get("error", {}).get("message", error_body)
210
- except json.JSONDecodeError:
211
- msg = error_body
212
- raise OpenRouterConnectionError(
213
- f"\n [X] OpenRouter API error ({exc.code})\n"
214
- f" {msg}"
215
- ) from exc
216
- except urllib.error.URLError as exc:
217
- raise OpenRouterConnectionError(
218
- f"\n [X] Cannot reach OpenRouter API.\n"
219
- f" Error: {exc}"
220
- ) from exc
1
+ """NeuronCLI — OpenRouter HTTP streaming client. Zero external dependencies.
2
+
3
+ Supports reasoning models (Kimi K2.5) where the response has both
4
+ a `reasoning` field (chain-of-thought) and a `content` field (final answer).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import urllib.error
11
+ import urllib.request
12
+ from dataclasses import dataclass
13
+ from typing import Generator
14
+
15
+ from .config import AgentConfig
16
+
17
+
18
+ class OpenRouterConnectionError(Exception):
19
+ """Raised when the OpenRouter API is unreachable or returns an error."""
20
+ pass
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ChatMessage:
25
+ """A single message in the conversation."""
26
+ role: str # "system", "user", "assistant"
27
+ content: str
28
+
29
+ def to_dict(self) -> dict[str, str]:
30
+ return {"role": self.role, "content": self.content}
31
+
32
+
33
+ class OpenRouterClient:
34
+ """
35
+ HTTP client for OpenRouter's OpenAI-compatible /api/v1/chat/completions.
36
+ Uses only stdlib — no pip dependencies needed.
37
+
38
+ Handles reasoning models (like Kimi K2.5) that emit:
39
+ - `delta.reasoning` tokens (thinking, shown dimmed)
40
+ - `delta.content` tokens (final answer)
41
+ """
42
+
43
+ API_BASE = "https://openrouter.ai/api/v1"
44
+
45
+ def __init__(self, config: AgentConfig | None = None):
46
+ self.config = config or AgentConfig.from_env()
47
+
48
+ def health_check(self) -> bool:
49
+ """Check if OpenRouter API is reachable and the API key is valid."""
50
+ try:
51
+ req = urllib.request.Request(
52
+ f"{self.API_BASE}/models",
53
+ headers=self._headers(),
54
+ )
55
+ with urllib.request.urlopen(req, timeout=10) as resp:
56
+ return resp.status == 200
57
+ except (urllib.error.URLError, OSError):
58
+ return False
59
+
60
+ def list_models(self) -> list[str]:
61
+ """List coding-relevant models from OpenRouter."""
62
+ try:
63
+ req = urllib.request.Request(
64
+ f"{self.API_BASE}/models",
65
+ headers=self._headers(),
66
+ )
67
+ with urllib.request.urlopen(req, timeout=10) as resp:
68
+ data = json.loads(resp.read().decode())
69
+ models = data.get("data", [])
70
+ keywords = ("kimi", "deepseek", "qwen", "codestral", "claude", "gemini")
71
+ names = [m["id"] for m in models
72
+ if any(k in m["id"].lower() for k in keywords)]
73
+ return sorted(names)[:25]
74
+ except (urllib.error.URLError, OSError):
75
+ return []
76
+
77
+ def chat(self, messages: list[ChatMessage], model: str | None = None) -> str:
78
+ """Send a chat request and return the full response text."""
79
+ payload = self._build_payload(messages, model, stream=False)
80
+ data = self._post(payload)
81
+ choice = data.get("choices", [{}])[0]
82
+ msg = choice.get("message", {})
83
+ # Kimi K2.5 reasoning models: content may be in `content` or `reasoning`
84
+ content = msg.get("content") or ""
85
+ if not content:
86
+ content = msg.get("reasoning", "")
87
+ return content
88
+
89
+ def chat_stream(
90
+ self,
91
+ messages: list[ChatMessage],
92
+ model: str | None = None,
93
+ ) -> Generator[str, None, None]:
94
+ """
95
+ Stream a chat response token by token.
96
+
97
+ For reasoning models (Kimi K2.5), yields:
98
+ - reasoning tokens with a dim prefix (hidden thinking)
99
+ - content tokens normally (the actual response)
100
+ """
101
+ payload = self._build_payload(messages, model, stream=True)
102
+ body = json.dumps(payload).encode("utf-8")
103
+ req = urllib.request.Request(
104
+ f"{self.API_BASE}/chat/completions",
105
+ data=body,
106
+ headers=self._headers(json_body=True),
107
+ method="POST",
108
+ )
109
+
110
+ try:
111
+ resp = urllib.request.urlopen(req, timeout=300)
112
+ except urllib.error.HTTPError as exc:
113
+ error_body = exc.read().decode("utf-8", errors="replace")
114
+ try:
115
+ err_data = json.loads(error_body)
116
+ msg = err_data.get("error", {}).get("message", error_body)
117
+ except json.JSONDecodeError:
118
+ msg = error_body
119
+ if exc.code == 402:
120
+ raise OpenRouterConnectionError(
121
+ f"\n [X] OpenRouter: Insufficient credits (402)\n"
122
+ f" {msg}\n\n"
123
+ f" Fix options:\n"
124
+ f" 1. Add $5 credits: https://openrouter.ai/settings/credits\n"
125
+ f" 2. Switch to a free model: neuron --model moonshotai/kimi-k2.5:free\n"
126
+ f" 3. Use Ollama (offline): NEURON_PROVIDER=ollama neuron\n"
127
+ f" 4. Re-auth with a funded account: neuron --reauth\n"
128
+ ) from exc
129
+ raise OpenRouterConnectionError(
130
+ f"\n [X] OpenRouter API error ({exc.code})\n"
131
+ f" {msg}"
132
+ ) from exc
133
+ except urllib.error.URLError as exc:
134
+ raise OpenRouterConnectionError(
135
+ f"\n [X] Cannot reach OpenRouter API.\n"
136
+ f" Check your internet connection.\n"
137
+ f" Error: {exc}"
138
+ ) from exc
139
+
140
+ in_reasoning = False
141
+ try:
142
+ for raw_line in resp:
143
+ line = raw_line.decode("utf-8", errors="replace").strip()
144
+ if not line or line == "data: [DONE]":
145
+ continue
146
+ if not line.startswith("data: "):
147
+ continue
148
+ try:
149
+ chunk = json.loads(line[6:])
150
+ except json.JSONDecodeError:
151
+ continue
152
+ choices = chunk.get("choices", [])
153
+ if not choices:
154
+ continue
155
+ delta = choices[0].get("delta", {})
156
+
157
+ # Handle reasoning tokens (Kimi K2.5 chain-of-thought)
158
+ reasoning_token = delta.get("reasoning", "")
159
+ content_token = delta.get("content", "")
160
+
161
+ if reasoning_token:
162
+ if not in_reasoning:
163
+ yield "\033[2m\033[90m" # DIM + GRAY
164
+ in_reasoning = True
165
+ yield reasoning_token
166
+
167
+ if content_token:
168
+ if in_reasoning:
169
+ yield "\033[0m\n" # Reset after reasoning
170
+ in_reasoning = False
171
+ yield content_token
172
+ finally:
173
+ if in_reasoning:
174
+ yield "\033[0m\n"
175
+ resp.close()
176
+
177
+ # ── Internal helpers ──────────────────────────────────────────
178
+
179
+ def _headers(self, json_body: bool = False) -> dict[str, str]:
180
+ h: dict[str, str] = {
181
+ "Authorization": f"Bearer {self.config.api_key}",
182
+ "HTTP-Referer": "https://github.com/RAHUL-DevelopeRR/neuroncli",
183
+ "X-Title": "NeuronCLI",
184
+ }
185
+ if json_body:
186
+ h["Content-Type"] = "application/json"
187
+ return h
188
+
189
+ def _build_payload(
190
+ self,
191
+ messages: list[ChatMessage],
192
+ model: str | None,
193
+ stream: bool,
194
+ ) -> dict:
195
+ return {
196
+ "model": model or self.config.model,
197
+ "messages": [m.to_dict() for m in messages],
198
+ "stream": stream,
199
+ "max_tokens": self.config.max_tokens,
200
+ "temperature": self.config.temperature,
201
+ "top_p": self.config.top_p,
202
+ "route": "fallback", # Auto-select cheapest available provider
203
+ }
204
+
205
+ def _post(self, payload: dict) -> dict:
206
+ body = json.dumps(payload).encode("utf-8")
207
+ req = urllib.request.Request(
208
+ f"{self.API_BASE}/chat/completions",
209
+ data=body,
210
+ headers=self._headers(json_body=True),
211
+ method="POST",
212
+ )
213
+ try:
214
+ with urllib.request.urlopen(req, timeout=300) as resp:
215
+ return json.loads(resp.read().decode("utf-8"))
216
+ except urllib.error.HTTPError as exc:
217
+ error_body = exc.read().decode("utf-8", errors="replace")
218
+ try:
219
+ err_data = json.loads(error_body)
220
+ msg = err_data.get("error", {}).get("message", error_body)
221
+ except json.JSONDecodeError:
222
+ msg = error_body
223
+ raise OpenRouterConnectionError(
224
+ f"\n [X] OpenRouter API error ({exc.code})\n"
225
+ f" {msg}"
226
+ ) from exc
227
+ except urllib.error.URLError as exc:
228
+ raise OpenRouterConnectionError(
229
+ f"\n [X] Cannot reach OpenRouter API.\n"
230
+ f" Error: {exc}"
231
+ ) from exc
@@ -0,0 +1,212 @@
1
+ """NeuronCLI — AST-based semantic repo map.
2
+
3
+ Generates a concise structural overview of the project for the system prompt.
4
+ Replaces the 200-item directory dump with ~500 tokens of meaningful context.
5
+ Inspired by Aider's repo-map concept.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import os
12
+ from pathlib import Path
13
+
14
+
15
+ # Directories to always skip
16
+ _SKIP_DIRS = {
17
+ ".git", "__pycache__", "node_modules", ".venv", "venv",
18
+ ".mypy_cache", ".pytest_cache", "dist", "build", ".tox",
19
+ ".eggs", ".next", ".nuxt", "coverage", ".coverage",
20
+ }
21
+
22
+
23
+ def build_repo_map(working_dir: str, max_tokens: int = 500) -> str:
24
+ """Generate a concise structural overview of the project.
25
+
26
+ Output format:
27
+ src/
28
+ agent.py: class Agent [run, compact, clear_history]
29
+ tools.py: class ToolRegistry [register, execute]; read_file, write_file
30
+ tests/
31
+ test_agent.py: TestAgent [test_run_simple, test_tools]
32
+ """
33
+ root = Path(working_dir)
34
+ if not root.is_dir():
35
+ return f"[Not a directory: {working_dir}]"
36
+
37
+ lines: list[str] = []
38
+ _walk_dir(root, root, lines, depth=0, max_depth=3)
39
+
40
+ full_map = "\n".join(lines)
41
+ return _smart_truncate(full_map, max_tokens)
42
+
43
+
44
+ def _walk_dir(base: Path, current: Path, lines: list[str], depth: int, max_depth: int):
45
+ """Recursively build the repo map."""
46
+ if depth >= max_depth:
47
+ return
48
+
49
+ try:
50
+ entries = sorted(current.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower()))
51
+ except (PermissionError, OSError):
52
+ return
53
+
54
+ for entry in entries:
55
+ if entry.name.startswith(".") or entry.name in _SKIP_DIRS:
56
+ continue
57
+
58
+ rel = entry.relative_to(base)
59
+ indent = " " * depth
60
+
61
+ if entry.is_dir():
62
+ # Count meaningful files in directory
63
+ try:
64
+ child_count = sum(1 for c in entry.iterdir()
65
+ if not c.name.startswith(".") and c.name not in _SKIP_DIRS)
66
+ except (PermissionError, OSError):
67
+ child_count = 0
68
+
69
+ if child_count > 0:
70
+ lines.append(f"{indent}{entry.name}/")
71
+ _walk_dir(base, entry, lines, depth + 1, max_depth)
72
+
73
+ elif entry.is_file():
74
+ symbols = _extract_symbols(entry)
75
+ if symbols:
76
+ lines.append(f"{indent}{entry.name}: {symbols}")
77
+ else:
78
+ lines.append(f"{indent}{entry.name}")
79
+
80
+
81
+ def _extract_symbols(filepath: Path) -> str:
82
+ """Extract key symbols from a file based on its type."""
83
+ ext = filepath.suffix.lower()
84
+
85
+ if ext == ".py":
86
+ return _extract_python_symbols(filepath)
87
+ elif ext in (".js", ".ts", ".jsx", ".tsx"):
88
+ return _extract_js_symbols(filepath)
89
+ elif ext == ".go":
90
+ return _extract_go_symbols(filepath)
91
+
92
+ # For other files, return size hint
93
+ try:
94
+ size = filepath.stat().st_size
95
+ if size > 10240:
96
+ return f"({size // 1024}KB)"
97
+ except OSError:
98
+ pass
99
+ return ""
100
+
101
+
102
+ def _extract_python_symbols(filepath: Path) -> str:
103
+ """Use Python's ast module to extract classes and top-level functions."""
104
+ try:
105
+ source = filepath.read_text(encoding="utf-8", errors="replace")
106
+ tree = ast.parse(source, filename=str(filepath))
107
+ except (SyntaxError, OSError, ValueError):
108
+ return ""
109
+
110
+ parts = []
111
+
112
+ for node in ast.iter_child_nodes(tree):
113
+ if isinstance(node, ast.ClassDef):
114
+ methods = [n.name for n in ast.iter_child_nodes(node)
115
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
116
+ and not n.name.startswith("_")]
117
+ if methods:
118
+ methods_str = ", ".join(methods[:5])
119
+ if len(methods) > 5:
120
+ methods_str += f" +{len(methods) - 5}"
121
+ parts.append(f"class {node.name} [{methods_str}]")
122
+ else:
123
+ parts.append(f"class {node.name}")
124
+
125
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
126
+ if not node.name.startswith("_"):
127
+ parts.append(node.name + "()")
128
+
129
+ if not parts:
130
+ # Fallback: count lines
131
+ try:
132
+ line_count = len(source.splitlines())
133
+ return f"({line_count} lines)"
134
+ except Exception:
135
+ pass
136
+
137
+ return "; ".join(parts[:6])
138
+
139
+
140
+ def _extract_js_symbols(filepath: Path) -> str:
141
+ """Basic regex extraction for JS/TS files."""
142
+ import re
143
+ try:
144
+ source = filepath.read_text(encoding="utf-8", errors="replace")
145
+ except OSError:
146
+ return ""
147
+
148
+ parts = []
149
+
150
+ # Classes
151
+ for match in re.finditer(r'(?:export\s+)?class\s+(\w+)', source):
152
+ parts.append(f"class {match.group(1)}")
153
+ # Named exports / functions
154
+ for match in re.finditer(r'(?:export\s+)?(?:async\s+)?function\s+(\w+)', source):
155
+ parts.append(match.group(1) + "()")
156
+ # Arrow function exports
157
+ for match in re.finditer(r'export\s+(?:const|let)\s+(\w+)\s*=', source):
158
+ name = match.group(1)
159
+ if name not in [p.rstrip("()") for p in parts]:
160
+ parts.append(name)
161
+
162
+ return "; ".join(parts[:6])
163
+
164
+
165
+ def _extract_go_symbols(filepath: Path) -> str:
166
+ """Basic regex extraction for Go files."""
167
+ import re
168
+ try:
169
+ source = filepath.read_text(encoding="utf-8", errors="replace")
170
+ except OSError:
171
+ return ""
172
+
173
+ parts = []
174
+ for match in re.finditer(r'type\s+(\w+)\s+struct', source):
175
+ parts.append(f"type {match.group(1)}")
176
+ for match in re.finditer(r'func\s+(?:\([^)]+\)\s+)?(\w+)\s*\(', source):
177
+ name = match.group(1)
178
+ if name[0].isupper(): # Only exported
179
+ parts.append(name + "()")
180
+
181
+ return "; ".join(parts[:6])
182
+
183
+
184
+ def _smart_truncate(map_text: str, max_tokens: int) -> str:
185
+ """Progressively reduce detail to fit within token budget.
186
+
187
+ Estimation: 1 token ≈ 4 characters.
188
+ """
189
+ max_chars = max_tokens * 4
190
+ if len(map_text) <= max_chars:
191
+ return map_text
192
+
193
+ # Phase 1: Remove size hints like (123 lines), (45KB)
194
+ import re
195
+ truncated = re.sub(r'\s*\(\d+\s*(?:lines|KB|B)\)', '', map_text)
196
+ if len(truncated) <= max_chars:
197
+ return truncated
198
+
199
+ # Phase 2: Remove method lists from classes
200
+ truncated = re.sub(r'\s*\[.+?\]', '', truncated)
201
+ if len(truncated) <= max_chars:
202
+ return truncated
203
+
204
+ # Phase 3: Just keep directory structure + filenames
205
+ lines = truncated.split("\n")
206
+ kept = [l for l in lines if l.strip().endswith("/") or "." in l.split("/")[-1]]
207
+ truncated = "\n".join(kept)
208
+ if len(truncated) <= max_chars:
209
+ return truncated
210
+
211
+ # Phase 4: Hard truncate
212
+ return truncated[:max_chars] + "\n... (truncated)"
@@ -0,0 +1,169 @@
1
+ """NeuronCLI — Post-write file validation engine.
2
+
3
+ Automatically validates files after write/edit operations.
4
+ Feeds errors back into the agent loop for self-correction.
5
+ Inspired by Crush's LSP diagnostics + Aider's lint-fix pattern.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import subprocess
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+
17
+ @dataclass
18
+ class ValidationResult:
19
+ """Result of validating a generated file."""
20
+ valid: bool
21
+ errors: list[str] = field(default_factory=list)
22
+ warnings: list[str] = field(default_factory=list)
23
+
24
+ @property
25
+ def feedback(self) -> str:
26
+ """Format as feedback string for the agent loop."""
27
+ if self.valid:
28
+ return ""
29
+ parts = []
30
+ for e in self.errors:
31
+ parts.append(f" ERROR: {e}")
32
+ for w in self.warnings:
33
+ parts.append(f" WARNING: {w}")
34
+ return "\n".join(parts)
35
+
36
+
37
+ def validate_file(path: str, content: str | None = None) -> ValidationResult:
38
+ """Validate a file based on its extension.
39
+
40
+ Args:
41
+ path: File path (used for extension detection)
42
+ content: File content (read from disk if not provided)
43
+ """
44
+ p = Path(path)
45
+ ext = p.suffix.lower()
46
+
47
+ if content is None:
48
+ try:
49
+ content = p.read_text(encoding="utf-8", errors="replace")
50
+ except OSError as e:
51
+ return ValidationResult(valid=False, errors=[f"Cannot read file: {e}"])
52
+
53
+ validators = {
54
+ ".py": _validate_python,
55
+ ".json": _validate_json,
56
+ ".html": _validate_html,
57
+ ".htm": _validate_html,
58
+ ".css": _validate_css,
59
+ ".js": _validate_javascript,
60
+ ".ts": _validate_javascript,
61
+ }
62
+
63
+ validator = validators.get(ext)
64
+ if validator is None:
65
+ return ValidationResult(valid=True) # No validator = assume OK
66
+
67
+ return validator(str(p), content)
68
+
69
+
70
+ # ── Python Validator ──────────────────────────────────────────────
71
+
72
+ def _validate_python(path: str, content: str) -> ValidationResult:
73
+ """Validate Python via py_compile (catches syntax errors)."""
74
+ errors = []
75
+ warnings = []
76
+
77
+ try:
78
+ compile(content, path, "exec")
79
+ except SyntaxError as e:
80
+ errors.append(f"SyntaxError at line {e.lineno}: {e.msg}")
81
+
82
+ # Check for common mistakes
83
+ if "import *" in content:
84
+ warnings.append("Wildcard import detected (import *)")
85
+
86
+ return ValidationResult(valid=len(errors) == 0, errors=errors, warnings=warnings)
87
+
88
+
89
+ # ── JSON Validator ────────────────────────────────────────────────
90
+
91
+ def _validate_json(path: str, content: str) -> ValidationResult:
92
+ """Validate JSON structure."""
93
+ try:
94
+ json.loads(content)
95
+ return ValidationResult(valid=True)
96
+ except json.JSONDecodeError as e:
97
+ return ValidationResult(valid=False, errors=[f"Invalid JSON at line {e.lineno}: {e.msg}"])
98
+
99
+
100
+ # ── HTML Validator ────────────────────────────────────────────────
101
+
102
+ def _validate_html(path: str, content: str) -> ValidationResult:
103
+ """Validate HTML structure — checks for complete document."""
104
+ errors = []
105
+ warnings = []
106
+
107
+ # Check for escaped tags (common LLM mistake)
108
+ if "&lt;" in content and "<html" not in content.lower():
109
+ errors.append("HTML tags are escaped (&lt; instead of <). The LLM outputted escaped HTML.")
110
+
111
+ # Check for required structural elements
112
+ lower = content.lower()
113
+ if "<!doctype" not in lower:
114
+ warnings.append("Missing <!DOCTYPE html> declaration")
115
+ if "<html" not in lower:
116
+ errors.append("Missing <html> tag")
117
+ if "<head" not in lower:
118
+ warnings.append("Missing <head> tag")
119
+ if "<body" not in lower:
120
+ errors.append("Missing <body> tag")
121
+
122
+ # Check for unclosed tags (basic check)
123
+ open_tags = len(re.findall(r'<(?!/)(?!meta)(?!br)(?!hr)(?!img)(?!input)(?!link)([a-z]+)', lower))
124
+ close_tags = len(re.findall(r'</([a-z]+)', lower))
125
+ if open_tags > 0 and close_tags == 0:
126
+ errors.append("No closing tags found — file may be truncated")
127
+
128
+ return ValidationResult(valid=len(errors) == 0, errors=errors, warnings=warnings)
129
+
130
+
131
+ # ── CSS Validator ─────────────────────────────────────────────────
132
+
133
+ def _validate_css(path: str, content: str) -> ValidationResult:
134
+ """Basic CSS validation — brace matching."""
135
+ errors = []
136
+
137
+ open_braces = content.count("{")
138
+ close_braces = content.count("}")
139
+ if open_braces != close_braces:
140
+ errors.append(f"Unmatched braces: {open_braces} open, {close_braces} close")
141
+
142
+ return ValidationResult(valid=len(errors) == 0, errors=errors)
143
+
144
+
145
+ # ── JavaScript Validator ──────────────────────────────────────────
146
+
147
+ def _validate_javascript(path: str, content: str) -> ValidationResult:
148
+ """Validate JS/TS — try node --check if available, else brace match."""
149
+ errors = []
150
+
151
+ # Try node --check (fast syntax check)
152
+ try:
153
+ result = subprocess.run(
154
+ ["node", "--check", path],
155
+ capture_output=True, text=True, timeout=5,
156
+ )
157
+ if result.returncode != 0:
158
+ # Extract the actual error line
159
+ stderr = result.stderr.strip()
160
+ if stderr:
161
+ errors.append(stderr.split("\n")[0])
162
+ except (FileNotFoundError, subprocess.TimeoutExpired):
163
+ # Node not available — fall back to brace matching
164
+ open_braces = content.count("{")
165
+ close_braces = content.count("}")
166
+ if abs(open_braces - close_braces) > 2:
167
+ errors.append(f"Suspicious brace mismatch: {open_braces} open, {close_braces} close")
168
+
169
+ return ValidationResult(valid=len(errors) == 0, errors=errors)
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: neuroncli
3
- Version: 2.3.0
4
- Summary: AI Coding Agent — Free Claude Code alternative powered by Kimi K2.5 + Ollama
3
+ Version: 3.0.0
4
+ Summary: AI Coding Agent — Free Claude Code alternative. Native Rust binary with plan mode, shell escapes, and Azure AI + OpenRouter support.
5
5
  Author-email: Rahul S <rahultech72216@gmail.com>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://zero-x.live
8
8
  Project-URL: Repository, https://github.com/RAHUL-DevelopeRR/neuroncli
9
9
  Project-URL: Issues, https://github.com/RAHUL-DevelopeRR/neuroncli/issues
10
- Keywords: ai,coding,agent,cli,llm,kimi,ollama,code-assistant
10
+ Keywords: ai,coding,agent,cli,llm,claude-code,rust,code-assistant,neuron
11
11
  Classifier: Development Status :: 4 - Beta
12
12
  Classifier: Environment :: Console
13
13
  Classifier: Intended Audience :: Developers
@@ -11,13 +11,16 @@ neuroncli/ollama_client.py
11
11
  neuroncli/openrouter_client.py
12
12
  neuroncli/prompts.py
13
13
  neuroncli/provider.py
14
+ neuroncli/repo_map.py
14
15
  neuroncli/session.py
15
16
  neuroncli/tools.py
16
17
  neuroncli/ui.py
18
+ neuroncli/validators.py
17
19
  neuroncli.egg-info/PKG-INFO
18
20
  neuroncli.egg-info/SOURCES.txt
19
21
  neuroncli.egg-info/dependency_links.txt
20
22
  neuroncli.egg-info/entry_points.txt
21
23
  neuroncli.egg-info/requires.txt
22
24
  neuroncli.egg-info/top_level.txt
25
+ neuroncli/bin/neuron.exe
23
26
  tests/test_agent.py
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "neuroncli"
7
- version = "2.3.0"
8
- description = "AI Coding Agent — Free Claude Code alternative powered by Kimi K2.5 + Ollama"
7
+ version = "3.0.0"
8
+ description = "AI Coding Agent — Free Claude Code alternative. Native Rust binary with plan mode, shell escapes, and Azure AI + OpenRouter support."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
11
11
  requires-python = ">=3.10"
12
12
  authors = [
13
13
  {name = "Rahul S", email = "rahultech72216@gmail.com"},
14
14
  ]
15
- keywords = ["ai", "coding", "agent", "cli", "llm", "kimi", "ollama", "code-assistant"]
15
+ keywords = ["ai", "coding", "agent", "cli", "llm", "claude-code", "rust", "code-assistant", "neuron"]
16
16
  classifiers = [
17
17
  "Development Status :: 4 - Beta",
18
18
  "Environment :: Console",
@@ -41,5 +41,8 @@ neuron = "neuroncli.cli:main"
41
41
  [tool.setuptools.packages.find]
42
42
  include = ["neuroncli*"]
43
43
 
44
+ [tool.setuptools.package-data]
45
+ neuroncli = ["bin/*"]
46
+
44
47
  [tool.pytest.ini_options]
45
48
  testpaths = ["tests"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes