neuroncli 2.1.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.
- neuroncli/__init__.py +18 -0
- neuroncli/__main__.py +14 -0
- neuroncli/agent.py +544 -0
- neuroncli/auth.py +263 -0
- neuroncli/cli.py +409 -0
- neuroncli/config.py +112 -0
- neuroncli/git_integration.py +86 -0
- neuroncli/ollama_client.py +150 -0
- neuroncli/openrouter_client.py +220 -0
- neuroncli/prompts.py +109 -0
- neuroncli/provider.py +45 -0
- neuroncli/session.py +88 -0
- neuroncli/tools.py +413 -0
- neuroncli/ui.py +138 -0
- neuroncli-2.1.0.dist-info/METADATA +171 -0
- neuroncli-2.1.0.dist-info/RECORD +19 -0
- neuroncli-2.1.0.dist-info/WHEEL +5 -0
- neuroncli-2.1.0.dist-info/entry_points.txt +2 -0
- neuroncli-2.1.0.dist-info/top_level.txt +1 -0
neuroncli/auth.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""NeuronCLI — OAuth PKCE flow for automatic OpenRouter API key provisioning.
|
|
2
|
+
|
|
3
|
+
On first run, opens the user's browser to OpenRouter's login page.
|
|
4
|
+
After the user logs in, OpenRouter redirects back to a tiny localhost server
|
|
5
|
+
which captures the auth code and exchanges it for a permanent API key.
|
|
6
|
+
|
|
7
|
+
Zero manual copy-paste. One click.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import http.server
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import secrets
|
|
18
|
+
import threading
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.parse
|
|
21
|
+
import urllib.request
|
|
22
|
+
import webbrowser
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ── Config paths ──────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
CONFIG_DIR = Path.home() / ".neuroncli"
|
|
29
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
30
|
+
CALLBACK_PORT = 19284 # Obscure port to avoid collisions
|
|
31
|
+
CALLBACK_URL = f"http://localhost:{CALLBACK_PORT}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── PKCE helpers ──────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def _generate_code_verifier() -> str:
|
|
37
|
+
"""Generate a cryptographically random code verifier (43-128 chars)."""
|
|
38
|
+
return secrets.token_urlsafe(64)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _generate_code_challenge(verifier: str) -> str:
|
|
42
|
+
"""SHA-256 hash the verifier and base64url-encode it."""
|
|
43
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
44
|
+
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── Local config storage ─────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
def load_config() -> dict:
|
|
50
|
+
"""Load saved config from ~/.neuroncli/config.json."""
|
|
51
|
+
if CONFIG_FILE.exists():
|
|
52
|
+
try:
|
|
53
|
+
return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
54
|
+
except (json.JSONDecodeError, OSError):
|
|
55
|
+
pass
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def save_config(data: dict) -> None:
|
|
60
|
+
"""Save config to ~/.neuroncli/config.json."""
|
|
61
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
CONFIG_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_stored_api_key() -> str | None:
|
|
66
|
+
"""Get the stored OpenRouter API key, if any."""
|
|
67
|
+
config = load_config()
|
|
68
|
+
return config.get("openrouter_api_key")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def store_api_key(key: str) -> None:
|
|
72
|
+
"""Store the OpenRouter API key locally."""
|
|
73
|
+
config = load_config()
|
|
74
|
+
config["openrouter_api_key"] = key
|
|
75
|
+
save_config(config)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ── OAuth PKCE flow ──────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
class _OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
81
|
+
"""Tiny HTTP handler that captures the OAuth redirect code."""
|
|
82
|
+
|
|
83
|
+
auth_code: str | None = None
|
|
84
|
+
|
|
85
|
+
def do_GET(self):
|
|
86
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
87
|
+
params = urllib.parse.parse_qs(parsed.query)
|
|
88
|
+
|
|
89
|
+
code = params.get("code", [None])[0]
|
|
90
|
+
|
|
91
|
+
if code:
|
|
92
|
+
_OAuthCallbackHandler.auth_code = code
|
|
93
|
+
# Show success page
|
|
94
|
+
self.send_response(200)
|
|
95
|
+
self.send_header("Content-Type", "text/html")
|
|
96
|
+
self.end_headers()
|
|
97
|
+
self.wfile.write(b"""
|
|
98
|
+
<html>
|
|
99
|
+
<head>
|
|
100
|
+
<style>
|
|
101
|
+
body {
|
|
102
|
+
font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
103
|
+
display: flex; justify-content: center; align-items: center;
|
|
104
|
+
min-height: 100vh; margin: 0;
|
|
105
|
+
background: linear-gradient(135deg, #0f0f23 0%, #1a1a3e 100%);
|
|
106
|
+
color: #e0e0e0;
|
|
107
|
+
}
|
|
108
|
+
.card {
|
|
109
|
+
text-align: center; padding: 60px;
|
|
110
|
+
background: rgba(255,255,255,0.05);
|
|
111
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
112
|
+
border-radius: 24px;
|
|
113
|
+
backdrop-filter: blur(20px);
|
|
114
|
+
}
|
|
115
|
+
h1 { color: #00ff88; font-size: 2em; }
|
|
116
|
+
p { color: #aaa; font-size: 1.1em; }
|
|
117
|
+
</style>
|
|
118
|
+
</head>
|
|
119
|
+
<body>
|
|
120
|
+
<div class="card">
|
|
121
|
+
<h1>✓ NeuronCLI Connected</h1>
|
|
122
|
+
<p>Your OpenRouter API key has been provisioned.<br>
|
|
123
|
+
You can close this tab and return to the terminal.</p>
|
|
124
|
+
</div>
|
|
125
|
+
</body>
|
|
126
|
+
</html>
|
|
127
|
+
""")
|
|
128
|
+
else:
|
|
129
|
+
self.send_response(400)
|
|
130
|
+
self.send_header("Content-Type", "text/html")
|
|
131
|
+
self.end_headers()
|
|
132
|
+
self.wfile.write(b"<h1>Error: No auth code received</h1>")
|
|
133
|
+
|
|
134
|
+
def log_message(self, format, *args):
|
|
135
|
+
"""Suppress default HTTP server logging."""
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _exchange_code_for_key(code: str, code_verifier: str) -> str:
|
|
140
|
+
"""POST to OpenRouter to exchange the auth code for an API key."""
|
|
141
|
+
payload = json.dumps({
|
|
142
|
+
"code": code,
|
|
143
|
+
"code_verifier": code_verifier,
|
|
144
|
+
"code_challenge_method": "S256",
|
|
145
|
+
}).encode("utf-8")
|
|
146
|
+
|
|
147
|
+
req = urllib.request.Request(
|
|
148
|
+
"https://openrouter.ai/api/v1/auth/keys",
|
|
149
|
+
data=payload,
|
|
150
|
+
headers={"Content-Type": "application/json"},
|
|
151
|
+
method="POST",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
156
|
+
data = json.loads(resp.read().decode())
|
|
157
|
+
return data["key"]
|
|
158
|
+
except urllib.error.HTTPError as e:
|
|
159
|
+
body = e.read().decode("utf-8", errors="replace")
|
|
160
|
+
raise RuntimeError(f"OpenRouter key exchange failed (HTTP {e.code}): {body}")
|
|
161
|
+
except Exception as e:
|
|
162
|
+
raise RuntimeError(f"OpenRouter key exchange failed: {e}")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def run_oauth_flow() -> str | None:
|
|
166
|
+
"""
|
|
167
|
+
Run the full OAuth PKCE flow:
|
|
168
|
+
1. Generate PKCE challenge
|
|
169
|
+
2. Open browser to OpenRouter auth
|
|
170
|
+
3. Start localhost server to catch redirect
|
|
171
|
+
4. Exchange code for API key
|
|
172
|
+
5. Store key locally
|
|
173
|
+
|
|
174
|
+
Returns the API key on success, None on failure.
|
|
175
|
+
"""
|
|
176
|
+
# Reset
|
|
177
|
+
_OAuthCallbackHandler.auth_code = None
|
|
178
|
+
|
|
179
|
+
# Generate PKCE pair
|
|
180
|
+
code_verifier = _generate_code_verifier()
|
|
181
|
+
code_challenge = _generate_code_challenge(code_verifier)
|
|
182
|
+
|
|
183
|
+
# Build auth URL
|
|
184
|
+
auth_url = (
|
|
185
|
+
f"https://openrouter.ai/auth?"
|
|
186
|
+
f"callback_url={urllib.parse.quote(CALLBACK_URL)}"
|
|
187
|
+
f"&code_challenge={code_challenge}"
|
|
188
|
+
f"&code_challenge_method=S256"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Start callback server in background
|
|
192
|
+
server = http.server.HTTPServer(("127.0.0.1", CALLBACK_PORT), _OAuthCallbackHandler)
|
|
193
|
+
server.timeout = 120 # 2 minute timeout
|
|
194
|
+
|
|
195
|
+
print("\n \033[96m\033[1m[AUTH]\033[0m Opening browser for OpenRouter login...")
|
|
196
|
+
print(f" \033[90mIf browser doesn't open, visit:\033[0m")
|
|
197
|
+
print(f" \033[90m{auth_url[:80]}...\033[0m\n")
|
|
198
|
+
|
|
199
|
+
# Open browser
|
|
200
|
+
webbrowser.open(auth_url)
|
|
201
|
+
|
|
202
|
+
print(" \033[93mWaiting for authentication...\033[0m", end="", flush=True)
|
|
203
|
+
|
|
204
|
+
# Wait for the callback (blocks until request or timeout)
|
|
205
|
+
try:
|
|
206
|
+
server.handle_request() # Handles exactly one request
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
finally:
|
|
210
|
+
server.server_close()
|
|
211
|
+
|
|
212
|
+
code = _OAuthCallbackHandler.auth_code
|
|
213
|
+
if not code:
|
|
214
|
+
print(f"\n \033[91m[X] Authentication timed out or failed.\033[0m")
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
print(f" \033[92m✓\033[0m")
|
|
218
|
+
print(" \033[96mExchanging code for API key...\033[0m", end="", flush=True)
|
|
219
|
+
|
|
220
|
+
# Exchange code for key
|
|
221
|
+
try:
|
|
222
|
+
api_key = _exchange_code_for_key(code, code_verifier)
|
|
223
|
+
except RuntimeError as e:
|
|
224
|
+
print(f"\n \033[91m[X] {e}\033[0m")
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
# Store
|
|
228
|
+
store_api_key(api_key)
|
|
229
|
+
print(f" \033[92m✓\033[0m")
|
|
230
|
+
print(f" \033[92m\033[1m[OK] API key saved to {CONFIG_FILE}\033[0m\n")
|
|
231
|
+
|
|
232
|
+
return api_key
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ── Public API ────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
def ensure_api_key() -> str | None:
|
|
238
|
+
"""
|
|
239
|
+
Ensure an API key is available. Checks in order:
|
|
240
|
+
1. OPENROUTER_API_KEY env var
|
|
241
|
+
2. ~/.neuroncli/config.json
|
|
242
|
+
3. Interactive OAuth PKCE flow (first-run)
|
|
243
|
+
|
|
244
|
+
Returns the API key or None if all methods fail.
|
|
245
|
+
"""
|
|
246
|
+
# Check env var first
|
|
247
|
+
env_key = os.environ.get("OPENROUTER_API_KEY")
|
|
248
|
+
if env_key:
|
|
249
|
+
return env_key
|
|
250
|
+
|
|
251
|
+
# Check local config
|
|
252
|
+
stored_key = get_stored_api_key()
|
|
253
|
+
if stored_key:
|
|
254
|
+
return stored_key
|
|
255
|
+
|
|
256
|
+
# First run — interactive OAuth
|
|
257
|
+
print("\n \033[96m\033[1m╔════════════════════════════════════════════════╗")
|
|
258
|
+
print(" ║ Welcome to NeuronCLI! ║")
|
|
259
|
+
print(" ║ Let's connect your free OpenRouter account. ║")
|
|
260
|
+
print(" ║ This only happens once. ║")
|
|
261
|
+
print(" ╚════════════════════════════════════════════════╝\033[0m")
|
|
262
|
+
|
|
263
|
+
return run_oauth_flow()
|
neuroncli/cli.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""NeuronCLI — CLI v2.0. Full Claude Code-style interface with modes and trust prompt."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from .agent import Agent
|
|
10
|
+
from .config import VERSION, AgentConfig, MODE_STANDARD, MODE_PLAN, MODE_YOLO
|
|
11
|
+
from .provider import create_provider
|
|
12
|
+
from .ui import (
|
|
13
|
+
render_startup_screen, _neuron_text, _ORANGE, _GREEN, _RED, _GRAY,
|
|
14
|
+
RST, BOLD, DIM,
|
|
15
|
+
SYM_OK, SYM_ERR, SYM_PROMPT,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ── Argument Parser ───────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
22
|
+
parser = argparse.ArgumentParser(
|
|
23
|
+
prog="neuron",
|
|
24
|
+
description="NeuronCLI - AI Coding Agent powered by Kimi K2.5 + Ollama",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument("task", nargs="?", default=None,
|
|
27
|
+
help="Task to execute (omit for interactive REPL)")
|
|
28
|
+
parser.add_argument("--model", "-m", default=None, help="Model to use")
|
|
29
|
+
parser.add_argument("--provider", "-p", choices=["openrouter", "ollama"],
|
|
30
|
+
default=None, help="LLM provider")
|
|
31
|
+
parser.add_argument("--dir", "-d", default=None, help="Working directory")
|
|
32
|
+
parser.add_argument("--max-iter", type=int, default=None)
|
|
33
|
+
parser.add_argument("--no-stream", action="store_true")
|
|
34
|
+
parser.add_argument("--yolo", action="store_true",
|
|
35
|
+
help="YOLO mode — skip all permission prompts")
|
|
36
|
+
parser.add_argument("--plan", action="store_true",
|
|
37
|
+
help="Plan mode — reason and plan before coding")
|
|
38
|
+
parser.add_argument("--version", action="version",
|
|
39
|
+
version=f"NeuronCLI v{VERSION}")
|
|
40
|
+
return parser
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── NEURON.md Support ─────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def _find_neuron_md(working_dir: str) -> str | None:
|
|
46
|
+
p = os.path.join(working_dir, "NEURON.md")
|
|
47
|
+
if os.path.isfile(p):
|
|
48
|
+
try:
|
|
49
|
+
with open(p, "r", encoding="utf-8", errors="replace") as f:
|
|
50
|
+
return f.read()
|
|
51
|
+
except OSError:
|
|
52
|
+
pass
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _create_neuron_md(working_dir: str) -> None:
|
|
57
|
+
template = """# NEURON.md — Project Context for NeuronCLI
|
|
58
|
+
|
|
59
|
+
## Project Overview
|
|
60
|
+
<!-- Describe what this project does -->
|
|
61
|
+
|
|
62
|
+
## Tech Stack
|
|
63
|
+
<!-- List the technologies used -->
|
|
64
|
+
|
|
65
|
+
## Project Structure
|
|
66
|
+
<!-- Outline the key directories and files -->
|
|
67
|
+
|
|
68
|
+
## Rules
|
|
69
|
+
<!-- Workflow rules for the AI agent -->
|
|
70
|
+
<!-- Examples: -->
|
|
71
|
+
<!-- - Always create a git branch before making changes -->
|
|
72
|
+
<!-- - Run tests after editing code -->
|
|
73
|
+
<!-- - Use TypeScript, not JavaScript -->
|
|
74
|
+
|
|
75
|
+
## Notes
|
|
76
|
+
<!-- Any additional context -->
|
|
77
|
+
"""
|
|
78
|
+
path = os.path.join(working_dir, "NEURON.md")
|
|
79
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
80
|
+
f.write(template)
|
|
81
|
+
print(f" {_GREEN}{BOLD}v{RST} Created NEURON.md in {working_dir}")
|
|
82
|
+
print(f" {DIM}Edit it to give NeuronCLI context about your project.{RST}\n")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── Trust Prompt ──────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
def _check_trust(working_dir: str) -> bool:
|
|
88
|
+
"""Check if user trusts this directory (first-run security prompt)."""
|
|
89
|
+
trust_file = os.path.join(os.path.expanduser("~"), ".neuroncli", "trusted_dirs.txt")
|
|
90
|
+
os.makedirs(os.path.dirname(trust_file), exist_ok=True)
|
|
91
|
+
|
|
92
|
+
# Check if already trusted
|
|
93
|
+
if os.path.isfile(trust_file):
|
|
94
|
+
with open(trust_file, "r") as f:
|
|
95
|
+
trusted = [line.strip() for line in f.readlines()]
|
|
96
|
+
if working_dir in trusted:
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
# Ask user to trust
|
|
100
|
+
print(f"\n {_ORANGE}{BOLD}Security Check{RST}")
|
|
101
|
+
print(f" {DIM}NeuronCLI can read, write, and execute commands in:{RST}")
|
|
102
|
+
print(f" {BOLD}{working_dir}{RST}\n")
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
response = input(f" {_ORANGE}Press 1 to trust this directory:{RST} ").strip()
|
|
106
|
+
except (EOFError, KeyboardInterrupt):
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
if response == "1":
|
|
110
|
+
with open(trust_file, "a") as f:
|
|
111
|
+
f.write(working_dir + "\n")
|
|
112
|
+
print(f" {_GREEN}{BOLD}v{RST} Directory trusted.\n")
|
|
113
|
+
return True
|
|
114
|
+
else:
|
|
115
|
+
print(f" {_RED}X{RST} Directory not trusted. Exiting.\n")
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ── Mode Display ──────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
_MODE_LABELS = {
|
|
122
|
+
MODE_STANDARD: "",
|
|
123
|
+
MODE_PLAN: f" {_ORANGE}[plan]{RST}",
|
|
124
|
+
MODE_YOLO: f" {_RED}[yolo]{RST}",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ── Interactive REPL ──────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def run_repl(config: AgentConfig):
|
|
131
|
+
"""Interactive session — full Claude Code-style experience."""
|
|
132
|
+
neuron_md = _find_neuron_md(config.working_dir)
|
|
133
|
+
agent = Agent(config)
|
|
134
|
+
|
|
135
|
+
# Render startup screen
|
|
136
|
+
startup = render_startup_screen(
|
|
137
|
+
working_dir=config.working_dir,
|
|
138
|
+
provider=config.provider,
|
|
139
|
+
model=config.active_model,
|
|
140
|
+
neuron_md_exists=neuron_md is not None,
|
|
141
|
+
)
|
|
142
|
+
print(startup)
|
|
143
|
+
|
|
144
|
+
# Mode indicator
|
|
145
|
+
mode_label = _MODE_LABELS.get(config.mode, "")
|
|
146
|
+
print(f" {DIM}Type a task, /help for commands, /exit to quit.{RST}{mode_label}\n")
|
|
147
|
+
|
|
148
|
+
while True:
|
|
149
|
+
try:
|
|
150
|
+
mode_tag = _MODE_LABELS.get(config.mode, "")
|
|
151
|
+
prompt_text = f" {_ORANGE}{BOLD}>{RST}{mode_tag} "
|
|
152
|
+
user_input = input(prompt_text).strip()
|
|
153
|
+
if not user_input:
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
# Slash commands
|
|
157
|
+
if user_input.startswith("/"):
|
|
158
|
+
result = _handle_command(user_input, agent, config)
|
|
159
|
+
if result == "exit":
|
|
160
|
+
break
|
|
161
|
+
# Refresh neuron_md in case /init was run
|
|
162
|
+
neuron_md = _find_neuron_md(config.working_dir)
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
# Execute task
|
|
166
|
+
print()
|
|
167
|
+
agent.run(user_input, neuron_md=neuron_md)
|
|
168
|
+
|
|
169
|
+
except KeyboardInterrupt:
|
|
170
|
+
print(f"\n {DIM}(interrupted){RST}")
|
|
171
|
+
continue
|
|
172
|
+
except EOFError:
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
print(f"\n {DIM}Session ended.{RST}\n")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _handle_command(cmd: str, agent: Agent, config: AgentConfig) -> str | None:
|
|
179
|
+
parts = cmd.split(maxsplit=1)
|
|
180
|
+
command = parts[0].lower()
|
|
181
|
+
arg = parts[1] if len(parts) > 1 else ""
|
|
182
|
+
|
|
183
|
+
if command in ("/exit", "/quit", "/q"):
|
|
184
|
+
return "exit"
|
|
185
|
+
|
|
186
|
+
elif command == "/help":
|
|
187
|
+
print(f"""
|
|
188
|
+
{BOLD}Commands:{RST}
|
|
189
|
+
{_ORANGE}/help{RST} Show this help
|
|
190
|
+
{_ORANGE}/exit{RST} Exit NeuronCLI
|
|
191
|
+
{_ORANGE}/init{RST} Create NEURON.md project context
|
|
192
|
+
{_ORANGE}/clear{RST} Clear conversation history
|
|
193
|
+
{_ORANGE}/compact{RST} Compress context (free up memory)
|
|
194
|
+
{_ORANGE}/mode <mode>{RST} Switch mode: standard, plan, yolo
|
|
195
|
+
{_ORANGE}/model <name>{RST} Switch model
|
|
196
|
+
{_ORANGE}/models{RST} List available models
|
|
197
|
+
{_ORANGE}/provider <name>{RST} Switch provider (openrouter/ollama)
|
|
198
|
+
{_ORANGE}/upgrade{RST} Info on faster response times
|
|
199
|
+
{_ORANGE}/login{RST} Re-authenticate with OpenRouter
|
|
200
|
+
{_ORANGE}/logout{RST} Remove stored API key
|
|
201
|
+
{_ORANGE}/dir <path>{RST} Change working directory
|
|
202
|
+
{_ORANGE}/status{RST} Check provider connection
|
|
203
|
+
{_ORANGE}/config{RST} Show current configuration
|
|
204
|
+
""")
|
|
205
|
+
|
|
206
|
+
elif command == "/init":
|
|
207
|
+
_create_neuron_md(config.working_dir)
|
|
208
|
+
|
|
209
|
+
elif command == "/clear":
|
|
210
|
+
agent.clear_history()
|
|
211
|
+
print(f" {_GREEN}{BOLD}v{RST} History cleared.\n")
|
|
212
|
+
|
|
213
|
+
elif command == "/compact":
|
|
214
|
+
removed = agent.compact()
|
|
215
|
+
if removed > 0:
|
|
216
|
+
print(f" {_GREEN}{BOLD}v{RST} Context compressed ({removed} messages removed).\n")
|
|
217
|
+
else:
|
|
218
|
+
print(f" {DIM}Context is already compact.{RST}\n")
|
|
219
|
+
|
|
220
|
+
elif command == "/mode":
|
|
221
|
+
if not arg:
|
|
222
|
+
print(f" Mode: {BOLD}{config.mode}{RST}")
|
|
223
|
+
print(f" {DIM}Options: standard, plan, yolo{RST}\n")
|
|
224
|
+
else:
|
|
225
|
+
new_mode = arg.strip().lower()
|
|
226
|
+
if new_mode in (MODE_STANDARD, MODE_PLAN, MODE_YOLO):
|
|
227
|
+
config.mode = new_mode
|
|
228
|
+
label = _MODE_LABELS.get(new_mode, "")
|
|
229
|
+
print(f" {_GREEN}{BOLD}v{RST} Mode: {BOLD}{new_mode}{RST}{label}\n")
|
|
230
|
+
else:
|
|
231
|
+
print(f" {_RED}X{RST} Unknown mode. Use: standard, plan, yolo\n")
|
|
232
|
+
|
|
233
|
+
elif command == "/upgrade":
|
|
234
|
+
print(f"""
|
|
235
|
+
{BOLD}Faster Responses{RST}
|
|
236
|
+
|
|
237
|
+
The free tier routes through OpenRouter's shared queue,
|
|
238
|
+
which can be slow during peak hours.
|
|
239
|
+
|
|
240
|
+
{_ORANGE}Option 1: Moonshot Direct ($1 one-time){RST}
|
|
241
|
+
Sign up at platform.moonshot.ai
|
|
242
|
+
$1 recharge = ~230 fast tasks
|
|
243
|
+
Set: NEURON_PROVIDER=moonshot
|
|
244
|
+
|
|
245
|
+
{_ORANGE}Option 2: Local Ollama (free, offline){RST}
|
|
246
|
+
Install from ollama.com
|
|
247
|
+
Run: ollama pull qwen2.5-coder:7b
|
|
248
|
+
Use: neuron --provider ollama
|
|
249
|
+
|
|
250
|
+
{_ORANGE}Option 3: Self-host Kimi K2.5{RST}
|
|
251
|
+
Requires a GPU with 48GB+ VRAM
|
|
252
|
+
Fastest possible, zero API cost
|
|
253
|
+
""")
|
|
254
|
+
|
|
255
|
+
elif command == "/model":
|
|
256
|
+
if not arg:
|
|
257
|
+
print(f" Model: {BOLD}{config.active_model}{RST}\n")
|
|
258
|
+
else:
|
|
259
|
+
if config.provider == "ollama":
|
|
260
|
+
config.ollama_model = arg.strip()
|
|
261
|
+
else:
|
|
262
|
+
config.model = arg.strip()
|
|
263
|
+
agent.client = create_provider(config)
|
|
264
|
+
print(f" {_GREEN}{BOLD}v{RST} Model: {BOLD}{config.active_model}{RST}\n")
|
|
265
|
+
|
|
266
|
+
elif command == "/models":
|
|
267
|
+
models = agent.client.list_models()
|
|
268
|
+
if models:
|
|
269
|
+
print(f"\n {BOLD}Models ({config.provider}):{RST}")
|
|
270
|
+
for m in models:
|
|
271
|
+
marker = f" {_GREEN}<{RST}" if m == config.active_model else ""
|
|
272
|
+
print(f" {m}{marker}")
|
|
273
|
+
print()
|
|
274
|
+
else:
|
|
275
|
+
print(f" {_RED}X{RST} Cannot fetch models.\n")
|
|
276
|
+
|
|
277
|
+
elif command == "/provider":
|
|
278
|
+
if not arg:
|
|
279
|
+
print(f" Provider: {BOLD}{config.provider}{RST}\n")
|
|
280
|
+
else:
|
|
281
|
+
new_provider = arg.strip().lower()
|
|
282
|
+
if new_provider not in ("openrouter", "ollama"):
|
|
283
|
+
print(f" {_RED}X{RST} Unknown provider. Use 'openrouter' or 'ollama'.\n")
|
|
284
|
+
else:
|
|
285
|
+
config.provider = new_provider
|
|
286
|
+
agent.client = create_provider(config)
|
|
287
|
+
print(f" {_GREEN}{BOLD}v{RST} {new_provider} ({config.active_model})\n")
|
|
288
|
+
|
|
289
|
+
elif command == "/login":
|
|
290
|
+
from .auth import run_oauth_flow
|
|
291
|
+
new_key = run_oauth_flow()
|
|
292
|
+
if new_key:
|
|
293
|
+
config.api_key = new_key
|
|
294
|
+
config.provider = "openrouter"
|
|
295
|
+
agent.client = create_provider(config)
|
|
296
|
+
print(f" {_GREEN}{BOLD}v{RST} Authenticated.\n")
|
|
297
|
+
|
|
298
|
+
elif command == "/logout":
|
|
299
|
+
from .auth import CONFIG_FILE
|
|
300
|
+
if CONFIG_FILE.exists():
|
|
301
|
+
CONFIG_FILE.unlink()
|
|
302
|
+
print(f" {_GREEN}{BOLD}v{RST} API key removed.\n")
|
|
303
|
+
else:
|
|
304
|
+
print(f" {DIM}No stored key.{RST}\n")
|
|
305
|
+
|
|
306
|
+
elif command == "/dir":
|
|
307
|
+
if not arg:
|
|
308
|
+
print(f" Dir: {BOLD}{config.working_dir}{RST}\n")
|
|
309
|
+
else:
|
|
310
|
+
path = os.path.abspath(arg.strip())
|
|
311
|
+
if os.path.isdir(path):
|
|
312
|
+
config.working_dir = path
|
|
313
|
+
print(f" {_GREEN}{BOLD}v{RST} {path}\n")
|
|
314
|
+
else:
|
|
315
|
+
print(f" {_RED}X{RST} Not a directory: {path}\n")
|
|
316
|
+
|
|
317
|
+
elif command == "/status":
|
|
318
|
+
client = create_provider(config)
|
|
319
|
+
if client.health_check():
|
|
320
|
+
print(f" {_GREEN}{BOLD}v{RST} {config.provider} is up ({config.active_model})\n")
|
|
321
|
+
else:
|
|
322
|
+
print(f" {_RED}X{RST} {config.provider} is not responding.\n")
|
|
323
|
+
|
|
324
|
+
elif command == "/config":
|
|
325
|
+
key_display = "***" + config.api_key[-4:] if config.api_key else "(none)"
|
|
326
|
+
print(f"""
|
|
327
|
+
{BOLD}Configuration:{RST}
|
|
328
|
+
Provider: {config.provider}
|
|
329
|
+
Model: {config.active_model}
|
|
330
|
+
Mode: {config.mode}
|
|
331
|
+
Temperature: {config.temperature}
|
|
332
|
+
Max Tokens: {config.max_tokens}
|
|
333
|
+
Max Iterations: {config.max_iterations}
|
|
334
|
+
Working Dir: {config.working_dir}
|
|
335
|
+
Streaming: {config.streaming}
|
|
336
|
+
API Key: {key_display}
|
|
337
|
+
""")
|
|
338
|
+
|
|
339
|
+
else:
|
|
340
|
+
print(f" {DIM}Unknown command. Type /help{RST}\n")
|
|
341
|
+
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# ── Main Entry Point ──────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
def main(argv: list[str] | None = None) -> int:
|
|
348
|
+
if sys.platform == "win32":
|
|
349
|
+
os.system("")
|
|
350
|
+
try:
|
|
351
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
352
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
353
|
+
except Exception:
|
|
354
|
+
pass
|
|
355
|
+
|
|
356
|
+
parser = build_parser()
|
|
357
|
+
args = parser.parse_args(argv)
|
|
358
|
+
|
|
359
|
+
config = AgentConfig.from_env()
|
|
360
|
+
if args.provider:
|
|
361
|
+
config.provider = args.provider
|
|
362
|
+
if args.model:
|
|
363
|
+
if config.provider == "ollama":
|
|
364
|
+
config.ollama_model = args.model
|
|
365
|
+
else:
|
|
366
|
+
config.model = args.model
|
|
367
|
+
if args.dir:
|
|
368
|
+
config.working_dir = args.dir
|
|
369
|
+
if args.max_iter:
|
|
370
|
+
config.max_iterations = args.max_iter
|
|
371
|
+
if args.no_stream:
|
|
372
|
+
config.streaming = False
|
|
373
|
+
if args.yolo:
|
|
374
|
+
config.mode = MODE_YOLO
|
|
375
|
+
config.confirm_dangerous = False
|
|
376
|
+
if args.plan:
|
|
377
|
+
config.mode = MODE_PLAN
|
|
378
|
+
|
|
379
|
+
# Check provider
|
|
380
|
+
client = create_provider(config)
|
|
381
|
+
if not client.health_check():
|
|
382
|
+
if config.provider == "openrouter":
|
|
383
|
+
print(f"\n {_RED}X OpenRouter not reachable.{RST}")
|
|
384
|
+
print(f" {DIM}Check internet or use --provider ollama{RST}\n")
|
|
385
|
+
else:
|
|
386
|
+
print(f"\n {_RED}X Ollama not running.{RST}")
|
|
387
|
+
print(f" {DIM}Start with: ollama serve{RST}\n")
|
|
388
|
+
return 1
|
|
389
|
+
|
|
390
|
+
# Trust check for REPL mode
|
|
391
|
+
if not args.task and not _check_trust(config.working_dir):
|
|
392
|
+
return 1
|
|
393
|
+
|
|
394
|
+
if args.task:
|
|
395
|
+
# One-shot mode
|
|
396
|
+
model_short = config.active_model.split("/")[-1]
|
|
397
|
+
mode_tag = f" [{config.mode}]" if config.mode != "standard" else ""
|
|
398
|
+
print(f"\n {_neuron_text()} {DIM}v{VERSION} | {config.provider} | {model_short}{mode_tag}{RST}\n")
|
|
399
|
+
neuron_md = _find_neuron_md(config.working_dir)
|
|
400
|
+
agent = Agent(config)
|
|
401
|
+
result = agent.run(args.task, neuron_md=neuron_md)
|
|
402
|
+
return 0 if not result.aborted else 1
|
|
403
|
+
else:
|
|
404
|
+
run_repl(config)
|
|
405
|
+
return 0
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
if __name__ == "__main__":
|
|
409
|
+
raise SystemExit(main())
|