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/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>&#10003; 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())