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/provider.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""NeuronCLI — Provider abstraction. OpenRouter primary, Ollama fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Generator, Protocol
|
|
6
|
+
|
|
7
|
+
from .config import AgentConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ChatMessage:
|
|
11
|
+
"""A single message in the conversation."""
|
|
12
|
+
__slots__ = ("role", "content")
|
|
13
|
+
|
|
14
|
+
def __init__(self, role: str, content: str):
|
|
15
|
+
self.role = role
|
|
16
|
+
self.content = content
|
|
17
|
+
|
|
18
|
+
def to_dict(self) -> dict[str, str]:
|
|
19
|
+
return {"role": self.role, "content": self.content}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LLMProvider(Protocol):
|
|
23
|
+
"""Protocol for LLM backends."""
|
|
24
|
+
def health_check(self) -> bool: ...
|
|
25
|
+
def list_models(self) -> list[str]: ...
|
|
26
|
+
def chat(self, messages: list, model: str | None = None) -> str: ...
|
|
27
|
+
def chat_stream(self, messages: list, model: str | None = None) -> Generator[str, None, None]: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ProviderConnectionError(Exception):
|
|
31
|
+
"""Raised when the active provider cannot be reached."""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_provider(config: AgentConfig):
|
|
36
|
+
"""
|
|
37
|
+
Factory: returns the correct LLM client based on config.provider.
|
|
38
|
+
Supports 'openrouter' (primary) and 'ollama' (fallback).
|
|
39
|
+
"""
|
|
40
|
+
if config.provider == "ollama":
|
|
41
|
+
from .ollama_client import OllamaClient
|
|
42
|
+
return OllamaClient(config)
|
|
43
|
+
else:
|
|
44
|
+
from .openrouter_client import OpenRouterClient
|
|
45
|
+
return OpenRouterClient(config)
|
neuroncli/session.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""NeuronCLI — Session persistence. Save/resume conversation history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SESSIONS_DIR = Path.home() / ".neuroncli" / "sessions"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _session_id(working_dir: str) -> str:
|
|
17
|
+
"""Generate a stable session ID from the working directory."""
|
|
18
|
+
return hashlib.md5(working_dir.encode()).hexdigest()[:12]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _session_path(working_dir: str) -> Path:
|
|
22
|
+
return SESSIONS_DIR / f"{_session_id(working_dir)}.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def save_session(working_dir: str, messages: list[dict], metadata: dict | None = None) -> Path:
|
|
26
|
+
"""Save the current conversation to disk."""
|
|
27
|
+
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
path = _session_path(working_dir)
|
|
29
|
+
|
|
30
|
+
data = {
|
|
31
|
+
"working_dir": working_dir,
|
|
32
|
+
"saved_at": time.time(),
|
|
33
|
+
"saved_at_human": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
34
|
+
"message_count": len(messages),
|
|
35
|
+
"messages": messages,
|
|
36
|
+
"metadata": metadata or {},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
40
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
41
|
+
|
|
42
|
+
return path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_session(working_dir: str) -> list[dict] | None:
|
|
46
|
+
"""Load previous session for this directory. Returns messages or None."""
|
|
47
|
+
path = _session_path(working_dir)
|
|
48
|
+
if not path.exists():
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
53
|
+
data = json.load(f)
|
|
54
|
+
return data.get("messages")
|
|
55
|
+
except (json.JSONDecodeError, OSError):
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def list_sessions() -> list[dict[str, Any]]:
|
|
60
|
+
"""List all saved sessions with metadata."""
|
|
61
|
+
if not SESSIONS_DIR.exists():
|
|
62
|
+
return []
|
|
63
|
+
|
|
64
|
+
sessions = []
|
|
65
|
+
for f in SESSIONS_DIR.glob("*.json"):
|
|
66
|
+
try:
|
|
67
|
+
with open(f, "r", encoding="utf-8") as fh:
|
|
68
|
+
data = json.load(fh)
|
|
69
|
+
sessions.append({
|
|
70
|
+
"id": f.stem,
|
|
71
|
+
"working_dir": data.get("working_dir", "?"),
|
|
72
|
+
"saved_at": data.get("saved_at_human", "?"),
|
|
73
|
+
"messages": data.get("message_count", 0),
|
|
74
|
+
})
|
|
75
|
+
except (json.JSONDecodeError, OSError):
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
sessions.sort(key=lambda s: s.get("saved_at", ""), reverse=True)
|
|
79
|
+
return sessions
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def delete_session(working_dir: str) -> bool:
|
|
83
|
+
"""Delete the session for a directory."""
|
|
84
|
+
path = _session_path(working_dir)
|
|
85
|
+
if path.exists():
|
|
86
|
+
path.unlink()
|
|
87
|
+
return True
|
|
88
|
+
return False
|
neuroncli/tools.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""NeuronCLI — Tool implementations and registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from .config import AgentConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ── Tool Registry ─────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ToolSpec:
|
|
20
|
+
"""Metadata for a single tool."""
|
|
21
|
+
name: str
|
|
22
|
+
description: str
|
|
23
|
+
parameters: dict[str, str] # param_name -> description
|
|
24
|
+
required: list[str]
|
|
25
|
+
function: Callable[..., str]
|
|
26
|
+
|
|
27
|
+
def signature_for_prompt(self) -> str:
|
|
28
|
+
"""Generate a description for the system prompt."""
|
|
29
|
+
params = ", ".join(
|
|
30
|
+
f'{name}: {desc}' for name, desc in self.parameters.items()
|
|
31
|
+
)
|
|
32
|
+
req = ", ".join(self.required)
|
|
33
|
+
return (
|
|
34
|
+
f"### {self.name}\n"
|
|
35
|
+
f"Description: {self.description}\n"
|
|
36
|
+
f"Parameters: {params}\n"
|
|
37
|
+
f"Required: {req}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ToolRegistry:
|
|
42
|
+
"""Central registry of all available tools."""
|
|
43
|
+
|
|
44
|
+
def __init__(self):
|
|
45
|
+
self._tools: dict[str, ToolSpec] = {}
|
|
46
|
+
|
|
47
|
+
def register(
|
|
48
|
+
self,
|
|
49
|
+
name: str,
|
|
50
|
+
description: str,
|
|
51
|
+
parameters: dict[str, str],
|
|
52
|
+
required: list[str] | None = None,
|
|
53
|
+
) -> Callable:
|
|
54
|
+
"""Decorator to register a tool function."""
|
|
55
|
+
def decorator(func: Callable[..., str]) -> Callable[..., str]:
|
|
56
|
+
self._tools[name] = ToolSpec(
|
|
57
|
+
name=name,
|
|
58
|
+
description=description,
|
|
59
|
+
parameters=parameters,
|
|
60
|
+
required=required or list(parameters.keys()),
|
|
61
|
+
function=func,
|
|
62
|
+
)
|
|
63
|
+
return func
|
|
64
|
+
return decorator
|
|
65
|
+
|
|
66
|
+
def get(self, name: str) -> ToolSpec | None:
|
|
67
|
+
return self._tools.get(name)
|
|
68
|
+
|
|
69
|
+
def all_tools(self) -> list[ToolSpec]:
|
|
70
|
+
return list(self._tools.values())
|
|
71
|
+
|
|
72
|
+
def tool_names(self) -> list[str]:
|
|
73
|
+
return list(self._tools.keys())
|
|
74
|
+
|
|
75
|
+
def execute(self, name: str, args: dict, config: AgentConfig) -> str:
|
|
76
|
+
"""Execute a tool by name with given arguments."""
|
|
77
|
+
spec = self._tools.get(name)
|
|
78
|
+
if spec is None:
|
|
79
|
+
return f"Error: Unknown tool '{name}'. Available: {', '.join(self.tool_names())}"
|
|
80
|
+
|
|
81
|
+
# Validate required params
|
|
82
|
+
for req in spec.required:
|
|
83
|
+
if req not in args:
|
|
84
|
+
return f"Error: Missing required parameter '{req}' for tool '{name}'"
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
return spec.function(config=config, **args)
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
return f"Error executing {name}: {type(exc).__name__}: {exc}"
|
|
90
|
+
|
|
91
|
+
def generate_prompt_section(self) -> str:
|
|
92
|
+
"""Generate tool descriptions for the system prompt."""
|
|
93
|
+
sections = []
|
|
94
|
+
for spec in self._tools.values():
|
|
95
|
+
sections.append(spec.signature_for_prompt())
|
|
96
|
+
return "\n\n".join(sections)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── Global registry instance ─────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
registry = ToolRegistry()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── Tool Implementations ─────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
@registry.register(
|
|
107
|
+
name="read_file",
|
|
108
|
+
description="Read the contents of a file. Returns the file content with line numbers.",
|
|
109
|
+
parameters={
|
|
110
|
+
"path": "Path to the file (relative to working directory or absolute)",
|
|
111
|
+
},
|
|
112
|
+
required=["path"],
|
|
113
|
+
)
|
|
114
|
+
def read_file(config: AgentConfig, path: str) -> str:
|
|
115
|
+
resolved = config.resolve_path(path)
|
|
116
|
+
if not resolved.exists():
|
|
117
|
+
return f"Error: File not found: {resolved}"
|
|
118
|
+
if not resolved.is_file():
|
|
119
|
+
return f"Error: Not a file: {resolved}"
|
|
120
|
+
try:
|
|
121
|
+
content = resolved.read_text(encoding="utf-8", errors="replace")
|
|
122
|
+
lines = content.splitlines()
|
|
123
|
+
# Add line numbers
|
|
124
|
+
numbered = "\n".join(f"{i+1:4d} | {line}" for i, line in enumerate(lines))
|
|
125
|
+
return f"File: {path} ({len(lines)} lines, {resolved.stat().st_size} bytes)\n{'─' * 60}\n{numbered}"
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
return f"Error reading {path}: {exc}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@registry.register(
|
|
131
|
+
name="write_file",
|
|
132
|
+
description="Write content to a file. Creates the file (and parent directories) if it doesn't exist, or overwrites it.",
|
|
133
|
+
parameters={
|
|
134
|
+
"path": "Path to the file",
|
|
135
|
+
"content": "The full content to write to the file",
|
|
136
|
+
},
|
|
137
|
+
required=["path", "content"],
|
|
138
|
+
)
|
|
139
|
+
def write_file(config: AgentConfig, path: str, content: str) -> str:
|
|
140
|
+
resolved = config.resolve_path(path)
|
|
141
|
+
try:
|
|
142
|
+
resolved.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
resolved.write_text(content, encoding="utf-8")
|
|
144
|
+
line_count = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
|
|
145
|
+
return f"[OK] Written {len(content)} bytes ({line_count} lines) to {path}"
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
return f"Error writing {path}: {exc}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@registry.register(
|
|
151
|
+
name="edit_file",
|
|
152
|
+
description="Edit a file by replacing a specific text block with new content. Use this for surgical edits instead of rewriting the entire file.",
|
|
153
|
+
parameters={
|
|
154
|
+
"path": "Path to the file",
|
|
155
|
+
"old_text": "The exact text to find and replace (must match precisely including whitespace)",
|
|
156
|
+
"new_text": "The replacement text",
|
|
157
|
+
},
|
|
158
|
+
required=["path", "old_text", "new_text"],
|
|
159
|
+
)
|
|
160
|
+
def edit_file(config: AgentConfig, path: str, old_text: str, new_text: str) -> str:
|
|
161
|
+
resolved = config.resolve_path(path)
|
|
162
|
+
if not resolved.exists():
|
|
163
|
+
return f"Error: File not found: {resolved}"
|
|
164
|
+
try:
|
|
165
|
+
content = resolved.read_text(encoding="utf-8")
|
|
166
|
+
if old_text not in content:
|
|
167
|
+
# Try with normalized whitespace
|
|
168
|
+
normalized_content = content.replace("\r\n", "\n")
|
|
169
|
+
normalized_old = old_text.replace("\r\n", "\n")
|
|
170
|
+
if normalized_old in normalized_content:
|
|
171
|
+
content = normalized_content
|
|
172
|
+
old_text = normalized_old
|
|
173
|
+
else:
|
|
174
|
+
# Show a snippet of the file for debugging
|
|
175
|
+
preview = content[:500]
|
|
176
|
+
return (
|
|
177
|
+
f"Error: Could not find the exact text to replace in {path}.\n"
|
|
178
|
+
f"File preview (first 500 chars):\n{preview}"
|
|
179
|
+
)
|
|
180
|
+
count = content.count(old_text)
|
|
181
|
+
new_content = content.replace(old_text, new_text, 1)
|
|
182
|
+
resolved.write_text(new_content, encoding="utf-8")
|
|
183
|
+
return f"[OK] Edited {path}: replaced {count} occurrence(s) ({len(old_text)} chars -> {len(new_text)} chars)"
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
return f"Error editing {path}: {exc}"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@registry.register(
|
|
189
|
+
name="list_directory",
|
|
190
|
+
description="List files and subdirectories in a directory. Shows a tree view with file sizes.",
|
|
191
|
+
parameters={
|
|
192
|
+
"path": "Directory path (default: current working directory)",
|
|
193
|
+
"recursive": "If 'true', show full recursive tree (default: 'false', shows 2 levels)",
|
|
194
|
+
},
|
|
195
|
+
required=["path"],
|
|
196
|
+
)
|
|
197
|
+
def list_directory(config: AgentConfig, path: str = ".", recursive: str = "false") -> str:
|
|
198
|
+
resolved = config.resolve_path(path)
|
|
199
|
+
if not resolved.exists():
|
|
200
|
+
return f"Error: Directory not found: {resolved}"
|
|
201
|
+
if not resolved.is_dir():
|
|
202
|
+
return f"Error: Not a directory: {resolved}"
|
|
203
|
+
|
|
204
|
+
is_recursive = recursive.lower() in ("true", "yes", "1")
|
|
205
|
+
max_depth = 100 if is_recursive else 2
|
|
206
|
+
|
|
207
|
+
lines: list[str] = [f"[DIR] {resolved.name}/"]
|
|
208
|
+
_tree_walk(resolved, lines, prefix="", depth=0, max_depth=max_depth)
|
|
209
|
+
|
|
210
|
+
if len(lines) > 200:
|
|
211
|
+
lines = lines[:200]
|
|
212
|
+
lines.append(f"... (truncated, {len(lines)}+ entries)")
|
|
213
|
+
|
|
214
|
+
return "\n".join(lines)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _tree_walk(directory: Path, lines: list[str], prefix: str, depth: int, max_depth: int):
|
|
218
|
+
"""Recursively build a tree view. Robust against Windows permission errors."""
|
|
219
|
+
if depth >= max_depth:
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
SKIP = {".git", "__pycache__", "node_modules", ".venv", "venv", ".mypy_cache", ".pytest_cache", "dist", "build", ".tox", ".eggs"}
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
entries = sorted(directory.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower()))
|
|
226
|
+
except (PermissionError, OSError):
|
|
227
|
+
lines.append(f"{prefix}└── [access denied]")
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
entries = [e for e in entries if e.name not in SKIP and not e.name.startswith('.')]
|
|
231
|
+
total = len(entries)
|
|
232
|
+
|
|
233
|
+
for idx, entry in enumerate(entries):
|
|
234
|
+
is_last = idx == total - 1
|
|
235
|
+
connector = "└── " if is_last else "├── "
|
|
236
|
+
extension = " " if is_last else "│ "
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
if entry.is_dir():
|
|
240
|
+
try:
|
|
241
|
+
child_count = sum(1 for _ in entry.iterdir()) if depth < max_depth - 1 else 0
|
|
242
|
+
except (PermissionError, OSError):
|
|
243
|
+
child_count = 0
|
|
244
|
+
lines.append(f"{prefix}{connector}[DIR] {entry.name}/ ({child_count} items)")
|
|
245
|
+
_tree_walk(entry, lines, prefix + extension, depth + 1, max_depth)
|
|
246
|
+
else:
|
|
247
|
+
try:
|
|
248
|
+
size = entry.stat().st_size
|
|
249
|
+
size_str = _format_size(size)
|
|
250
|
+
except (PermissionError, OSError):
|
|
251
|
+
size_str = "?"
|
|
252
|
+
lines.append(f"{prefix}{connector}{entry.name} ({size_str})")
|
|
253
|
+
except (PermissionError, OSError):
|
|
254
|
+
lines.append(f"{prefix}{connector}{entry.name} [access denied]")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _format_size(size: int) -> str:
|
|
258
|
+
if size < 1024:
|
|
259
|
+
return f"{size} B"
|
|
260
|
+
elif size < 1024 * 1024:
|
|
261
|
+
return f"{size / 1024:.1f} KB"
|
|
262
|
+
else:
|
|
263
|
+
return f"{size / (1024 * 1024):.1f} MB"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@registry.register(
|
|
267
|
+
name="run_command",
|
|
268
|
+
description="Run a shell command and return its stdout and stderr. Use for build, test, git, or any terminal command.",
|
|
269
|
+
parameters={
|
|
270
|
+
"command": "The shell command to execute",
|
|
271
|
+
},
|
|
272
|
+
required=["command"],
|
|
273
|
+
)
|
|
274
|
+
def run_command(config: AgentConfig, command: str) -> str:
|
|
275
|
+
# Safety check
|
|
276
|
+
if config.is_dangerous_command(command):
|
|
277
|
+
return (
|
|
278
|
+
f"[BLOCKED] The command '{command}' matches a dangerous pattern.\n"
|
|
279
|
+
f"If you're sure, the user can re-run with confirm_dangerous=False."
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
result = subprocess.run(
|
|
284
|
+
command,
|
|
285
|
+
shell=True,
|
|
286
|
+
capture_output=True,
|
|
287
|
+
text=True,
|
|
288
|
+
cwd=config.working_dir,
|
|
289
|
+
timeout=60,
|
|
290
|
+
)
|
|
291
|
+
parts = []
|
|
292
|
+
if result.stdout:
|
|
293
|
+
parts.append(f"STDOUT:\n{result.stdout.strip()}")
|
|
294
|
+
if result.stderr:
|
|
295
|
+
parts.append(f"STDERR:\n{result.stderr.strip()}")
|
|
296
|
+
parts.append(f"Exit code: {result.returncode}")
|
|
297
|
+
|
|
298
|
+
output = "\n".join(parts) if parts else "Command completed with no output."
|
|
299
|
+
|
|
300
|
+
# Truncate very long output
|
|
301
|
+
if len(output) > 5000:
|
|
302
|
+
output = output[:5000] + f"\n... (truncated, {len(output)} total chars)"
|
|
303
|
+
|
|
304
|
+
return output
|
|
305
|
+
|
|
306
|
+
except subprocess.TimeoutExpired:
|
|
307
|
+
return f"Error: Command timed out after 60 seconds: {command}"
|
|
308
|
+
except Exception as exc:
|
|
309
|
+
return f"Error running command: {exc}"
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@registry.register(
|
|
313
|
+
name="search_in_files",
|
|
314
|
+
description="Search for a text pattern across files in a directory. Like grep. Returns matching lines with file paths and line numbers.",
|
|
315
|
+
parameters={
|
|
316
|
+
"query": "Text or regex pattern to search for",
|
|
317
|
+
"directory": "Directory to search in (default: working directory)",
|
|
318
|
+
"pattern": "File glob pattern to filter files (default: '*.*')",
|
|
319
|
+
},
|
|
320
|
+
required=["query"],
|
|
321
|
+
)
|
|
322
|
+
def search_in_files(
|
|
323
|
+
config: AgentConfig,
|
|
324
|
+
query: str,
|
|
325
|
+
directory: str = ".",
|
|
326
|
+
pattern: str = "*.*",
|
|
327
|
+
) -> str:
|
|
328
|
+
resolved = config.resolve_path(directory)
|
|
329
|
+
if not resolved.exists():
|
|
330
|
+
return f"Error: Directory not found: {resolved}"
|
|
331
|
+
|
|
332
|
+
SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", "dist", "build"}
|
|
333
|
+
matches: list[str] = []
|
|
334
|
+
files_searched = 0
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
compiled = re.compile(query, re.IGNORECASE)
|
|
338
|
+
except re.error:
|
|
339
|
+
compiled = re.compile(re.escape(query), re.IGNORECASE)
|
|
340
|
+
|
|
341
|
+
for root_path, dirs, files in os.walk(resolved):
|
|
342
|
+
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
|
343
|
+
for filename in files:
|
|
344
|
+
if not fnmatch.fnmatch(filename, pattern):
|
|
345
|
+
continue
|
|
346
|
+
filepath = Path(root_path) / filename
|
|
347
|
+
files_searched += 1
|
|
348
|
+
try:
|
|
349
|
+
text = filepath.read_text(encoding="utf-8", errors="replace")
|
|
350
|
+
for line_no, line in enumerate(text.splitlines(), 1):
|
|
351
|
+
if compiled.search(line):
|
|
352
|
+
rel = filepath.relative_to(resolved)
|
|
353
|
+
matches.append(f" {rel}:{line_no} {line.strip()}")
|
|
354
|
+
if len(matches) >= 50:
|
|
355
|
+
break
|
|
356
|
+
except (OSError, UnicodeDecodeError):
|
|
357
|
+
continue
|
|
358
|
+
if len(matches) >= 50:
|
|
359
|
+
break
|
|
360
|
+
|
|
361
|
+
if not matches:
|
|
362
|
+
return f"No matches for '{query}' in {files_searched} files."
|
|
363
|
+
|
|
364
|
+
header = f"Found {len(matches)} match(es) for '{query}' in {files_searched} files:\n"
|
|
365
|
+
return header + "\n".join(matches)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@registry.register(
|
|
369
|
+
name="get_project_structure",
|
|
370
|
+
description="Get a comprehensive overview of the project: directory tree, key files, and README content if available.",
|
|
371
|
+
parameters={
|
|
372
|
+
"path": "Root path of the project (default: working directory)",
|
|
373
|
+
},
|
|
374
|
+
required=[],
|
|
375
|
+
)
|
|
376
|
+
def get_project_structure(config: AgentConfig, path: str = ".") -> str:
|
|
377
|
+
resolved = config.resolve_path(path)
|
|
378
|
+
if not resolved.exists():
|
|
379
|
+
return f"Error: Path not found: {resolved}"
|
|
380
|
+
|
|
381
|
+
parts: list[str] = ["# Project Structure\n"]
|
|
382
|
+
|
|
383
|
+
# Directory tree (2 levels)
|
|
384
|
+
tree_lines: list[str] = [f"[DIR] {resolved.name}/"]
|
|
385
|
+
_tree_walk(resolved, tree_lines, prefix="", depth=0, max_depth=2)
|
|
386
|
+
parts.append("\n".join(tree_lines))
|
|
387
|
+
|
|
388
|
+
# Count stats
|
|
389
|
+
py_files = list(resolved.rglob("*.py"))
|
|
390
|
+
js_files = list(resolved.rglob("*.js")) + list(resolved.rglob("*.ts"))
|
|
391
|
+
rs_files = list(resolved.rglob("*.rs"))
|
|
392
|
+
|
|
393
|
+
parts.append(f"\nStats: {len(py_files)} .py | {len(js_files)} .js/.ts | {len(rs_files)} .rs")
|
|
394
|
+
|
|
395
|
+
# Check for key files
|
|
396
|
+
key_files = ["README.md", "setup.py", "pyproject.toml", "package.json",
|
|
397
|
+
"Cargo.toml", "requirements.txt", "Makefile", ".gitignore"]
|
|
398
|
+
found = [f for f in key_files if (resolved / f).exists()]
|
|
399
|
+
if found:
|
|
400
|
+
parts.append(f"Key files: {', '.join(found)}")
|
|
401
|
+
|
|
402
|
+
# Read README if available
|
|
403
|
+
readme = resolved / "README.md"
|
|
404
|
+
if readme.exists():
|
|
405
|
+
try:
|
|
406
|
+
content = readme.read_text(encoding="utf-8", errors="replace")
|
|
407
|
+
if len(content) > 1000:
|
|
408
|
+
content = content[:1000] + "\n... (truncated)"
|
|
409
|
+
parts.append(f"\n## README.md\n{content}")
|
|
410
|
+
except OSError:
|
|
411
|
+
pass
|
|
412
|
+
|
|
413
|
+
return "\n".join(parts)
|
neuroncli/ui.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""NeuronCLI — Terminal UI components v2.0.
|
|
2
|
+
Brand symbols, NO emojis, consistent colors, proper table rendering."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import getpass
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from .config import VERSION
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ── True Color ANSI ───────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
def _rgb(r: int, g: int, b: int) -> str:
|
|
15
|
+
return f"\033[38;2;{r};{g};{b}m"
|
|
16
|
+
|
|
17
|
+
def _bg_rgb(r: int, g: int, b: int) -> str:
|
|
18
|
+
return f"\033[48;2;{r};{g};{b}m"
|
|
19
|
+
|
|
20
|
+
RST = "\033[0m"
|
|
21
|
+
BOLD = "\033[1m"
|
|
22
|
+
DIM = "\033[2m"
|
|
23
|
+
|
|
24
|
+
# ── Brand Colors (sampled from the Neuron DNA watercolor logo) ────
|
|
25
|
+
_BLUE = _rgb(65, 105, 195)
|
|
26
|
+
_INDIGO = _rgb(95, 55, 140)
|
|
27
|
+
_RED = _rgb(200, 50, 40)
|
|
28
|
+
_ORANGE = _rgb(240, 160, 40) # PRIMARY brand color
|
|
29
|
+
_GREEN = _rgb(45, 140, 60)
|
|
30
|
+
_CYAN = _rgb(80, 170, 210)
|
|
31
|
+
_GRAY = _rgb(100, 100, 100)
|
|
32
|
+
_YELLOW = _rgb(230, 190, 50)
|
|
33
|
+
_WHITE = _rgb(220, 220, 220)
|
|
34
|
+
|
|
35
|
+
# ── Brand Symbols (NO emojis — Claude uses plain Unicode chars) ───
|
|
36
|
+
# Claude uses: * (asterisk) colored orange for their logo indicator
|
|
37
|
+
# We use the same approach with our brand color
|
|
38
|
+
|
|
39
|
+
SYM_LOGO = f"{_ORANGE}{BOLD}*{RST}" # Brand indicator (like Claude's flower)
|
|
40
|
+
SYM_BULLET = f"{_ORANGE}{BOLD}*{RST}" # Action bullet
|
|
41
|
+
SYM_THINK = f"{_ORANGE}+{RST}" # Thinking/working spinner
|
|
42
|
+
SYM_OK = f"{_GREEN}{BOLD}v{RST}" # Success checkmark
|
|
43
|
+
SYM_ERR = f"{_RED}{BOLD}x{RST}" # Error cross
|
|
44
|
+
SYM_WARN = f"{_YELLOW}{BOLD}!{RST}" # Warning
|
|
45
|
+
SYM_ARROW = f"{_GRAY}|_{RST}" # Sub-item tree connector
|
|
46
|
+
SYM_PROMPT = f"{_ORANGE}{BOLD}>{RST}" # Input prompt
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── DNA Helix Logo (brand-colored ASCII art) ──────────────────────
|
|
50
|
+
|
|
51
|
+
def _build_logo() -> list[str]:
|
|
52
|
+
"""Build the colored DNA helix logo matching the Neuron watercolor logo.
|
|
53
|
+
Blue (top) -> Indigo -> Red -> Orange -> Green (bottom)."""
|
|
54
|
+
return [
|
|
55
|
+
f" {_BLUE}* *{RST}",
|
|
56
|
+
f" {_BLUE}/ \\ / \\{RST}",
|
|
57
|
+
f" {_BLUE}/ {_INDIGO}/ \\/ \\{_BLUE} \\{RST}",
|
|
58
|
+
f" {_INDIGO}/ / /--\\ \\ \\{RST}",
|
|
59
|
+
f" {_INDIGO}| {_RED}/--------\\{_INDIGO} |{RST}",
|
|
60
|
+
f" {_RED}X----------X{RST}",
|
|
61
|
+
f" {_RED}| {_ORANGE}\\--------/{_RED} |{RST}",
|
|
62
|
+
f" {_ORANGE}\\ \\ \\--/ / /{RST}",
|
|
63
|
+
f" {_ORANGE}\\ {_GREEN}\\ /{_ORANGE} /{RST}",
|
|
64
|
+
f" {_GREEN}\\ / \\ /{RST}",
|
|
65
|
+
f" {_GREEN}* *{RST}",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _neuron_text() -> str:
|
|
70
|
+
"""'Neuron' in gradient colors: N(blue) e(red) u(red) r(orange) o(orange) n(green)."""
|
|
71
|
+
return f"{_BLUE}{BOLD}N{_RED}e{_RED}u{_ORANGE}r{_ORANGE}o{_GREEN}n{RST}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── Startup Screen ────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def render_startup_screen(
|
|
77
|
+
working_dir: str,
|
|
78
|
+
provider: str,
|
|
79
|
+
model: str,
|
|
80
|
+
neuron_md_exists: bool = False,
|
|
81
|
+
) -> str:
|
|
82
|
+
"""Claude Code-style startup: logo left, tips right, in a box."""
|
|
83
|
+
try:
|
|
84
|
+
username = getpass.getuser().capitalize()
|
|
85
|
+
except Exception:
|
|
86
|
+
username = "Developer"
|
|
87
|
+
|
|
88
|
+
model_short = model.split("/")[-1] if "/" in model else model
|
|
89
|
+
|
|
90
|
+
logo = _build_logo()
|
|
91
|
+
width = 64
|
|
92
|
+
bc = _GRAY # border color
|
|
93
|
+
|
|
94
|
+
# Right panel
|
|
95
|
+
if neuron_md_exists:
|
|
96
|
+
tips = [
|
|
97
|
+
f"{_ORANGE}{BOLD}Tips{RST}",
|
|
98
|
+
f"{DIM}NEURON.md loaded for this project{RST}",
|
|
99
|
+
f"{DIM}Type a task to get started{RST}",
|
|
100
|
+
]
|
|
101
|
+
else:
|
|
102
|
+
tips = [
|
|
103
|
+
f"{_ORANGE}{BOLD}Tips for getting started{RST}",
|
|
104
|
+
f"{DIM}Run {RST}/init{DIM} to create a NEURON.md{RST}",
|
|
105
|
+
f"{DIM}file with project context...{RST}",
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
activity = [
|
|
109
|
+
"",
|
|
110
|
+
f"{_ORANGE}{BOLD}Recent activity{RST}",
|
|
111
|
+
f"{DIM}No recent activity{RST}",
|
|
112
|
+
]
|
|
113
|
+
right_panel = tips + activity
|
|
114
|
+
|
|
115
|
+
lines = []
|
|
116
|
+
title = f" {_neuron_text()} {DIM}CLI v{VERSION}{RST} "
|
|
117
|
+
lines.append(f" {bc}+-{RST}{title}{bc}{'-' * (width - 22)}+{RST}")
|
|
118
|
+
lines.append(f" {bc}|{RST}")
|
|
119
|
+
lines.append(f" {bc}|{RST} {BOLD}Welcome, {username}!{RST}")
|
|
120
|
+
lines.append(f" {bc}|{RST}")
|
|
121
|
+
|
|
122
|
+
ri = 0
|
|
123
|
+
for logo_line in logo:
|
|
124
|
+
right = right_panel[ri] if ri < len(right_panel) else ""
|
|
125
|
+
ri += 1
|
|
126
|
+
lines.append(f" {bc}|{RST} {logo_line} {right}")
|
|
127
|
+
|
|
128
|
+
while ri < len(right_panel):
|
|
129
|
+
lines.append(f" {bc}|{RST}{'':30}{right_panel[ri]}")
|
|
130
|
+
ri += 1
|
|
131
|
+
|
|
132
|
+
lines.append(f" {bc}|{RST}")
|
|
133
|
+
lines.append(f" {bc}|{RST} {DIM}{model_short} . {provider}{RST}")
|
|
134
|
+
lines.append(f" {bc}|{RST} {DIM}{working_dir}{RST}")
|
|
135
|
+
lines.append(f" {bc}|{RST}")
|
|
136
|
+
lines.append(f" {bc}+{'-' * width}+{RST}")
|
|
137
|
+
|
|
138
|
+
return "\n".join(lines)
|