nova-bridgeye 0.1.0
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.
- package/.env.example +17 -0
- package/.vscode/settings.json +5 -0
- package/README.md +9 -0
- package/bin/nova +3 -0
- package/core/__init__.py +0 -0
- package/core/prompts.py +158 -0
- package/core/state.py +37 -0
- package/nova_cli/__init__.py +1 -0
- package/nova_cli/__main__.py +4 -0
- package/nova_cli/cli/__init__.py +0 -0
- package/nova_cli/cli/commands.py +49 -0
- package/nova_cli/cli/main.py +83 -0
- package/nova_cli/cli/registry.py +14 -0
- package/nova_cli/cli/shell.py +657 -0
- package/nova_cli/config.py +36 -0
- package/nova_cli/local/file_manager/__init__.py +36 -0
- package/nova_cli/local/file_manager/commands.py +173 -0
- package/nova_cli/local/file_manager/edit_ops.py +98 -0
- package/nova_cli/local/file_manager/git_ops.py +135 -0
- package/nova_cli/local/file_manager/io_ops.py +297 -0
- package/nova_cli/local/file_manager/path_ops.py +48 -0
- package/nova_cli/local/file_manager.py +4 -0
- package/nova_cli/local/healer/__init__.py +0 -0
- package/nova_cli/local/healer/runner.py +313 -0
- package/nova_cli/local/ui.py +196 -0
- package/nova_cli/local/utils.py +201 -0
- package/nova_cli/nova_core/__init__.py +0 -0
- package/nova_cli/nova_core/ai/__init__.py +0 -0
- package/nova_cli/nova_core/ai/api_client.py +298 -0
- package/nova_cli/nova_core/ai/utils.py +12 -0
- package/nova_cli/nova_core/auth/__init__.py +0 -0
- package/nova_cli/nova_core/auth/client.py +103 -0
- package/nova_cli/nova_core/auth/storage.py +146 -0
- package/nova_legacy.py +15 -0
- package/package.json +19 -0
- package/project_context.txt +3528 -0
- package/pyproject.toml +26 -0
- package/requirements.txt +6 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
#NOVA_CLI\nova_cli\cli\shell.py
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
# Suppress the Deprecation/Future warnings from Google/Tiktoken cluttering the Mac terminal
|
|
5
|
+
warnings.filterwarnings("ignore", category=FutureWarning)
|
|
6
|
+
warnings.filterwarnings("ignore", category=UserWarning)
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from rich import print
|
|
11
|
+
|
|
12
|
+
if sys.platform == "win32":
|
|
13
|
+
import pyreadline3 as readline
|
|
14
|
+
else:
|
|
15
|
+
import readline
|
|
16
|
+
|
|
17
|
+
import shlex
|
|
18
|
+
import time
|
|
19
|
+
import logging
|
|
20
|
+
import re
|
|
21
|
+
from rich.panel import Panel
|
|
22
|
+
|
|
23
|
+
# ✅ Client-side API wrapper (CLI calls NOVA_API over HTTP)
|
|
24
|
+
from nova_cli.nova_core.ai.api_client import BridgeyeAPIClient
|
|
25
|
+
|
|
26
|
+
from nova_cli import config
|
|
27
|
+
import core.state as state
|
|
28
|
+
import core.prompts as prompts
|
|
29
|
+
|
|
30
|
+
# ✅ CLI-local registry/commands (no nova.* imports)
|
|
31
|
+
from nova_cli.cli.registry import CommandRegistry
|
|
32
|
+
from nova_cli.cli.commands import register_core_commands
|
|
33
|
+
|
|
34
|
+
# ✅ Client-side auth (CLI talks to nova-web / token endpoints)
|
|
35
|
+
from nova_cli.nova_core.auth.client import NovaAuthClient
|
|
36
|
+
from nova_cli.nova_core.auth.storage import save_auth
|
|
37
|
+
|
|
38
|
+
from nova_cli.local.ui import ui
|
|
39
|
+
from nova_cli.local.file_manager.path_ops import resolve_path
|
|
40
|
+
from nova_cli.local.file_manager.io_ops import load_file, map_directory, save_code_to_file
|
|
41
|
+
from nova_cli.local.file_manager.commands import handle_ai_commands
|
|
42
|
+
from nova_cli.local.file_manager.git_ops import (
|
|
43
|
+
update_repo_path,
|
|
44
|
+
manual_commit,
|
|
45
|
+
perform_push,
|
|
46
|
+
perform_pull,
|
|
47
|
+
git_status,
|
|
48
|
+
)
|
|
49
|
+
from nova_cli.local.utils import extract_code_from_markdown
|
|
50
|
+
from nova_cli.local.healer.runner import run_with_healing
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class NovaShell:
|
|
54
|
+
def __init__(self):
|
|
55
|
+
self.state = state.SessionState()
|
|
56
|
+
self.interface = ui
|
|
57
|
+
|
|
58
|
+
# API-only mode (no local model client)
|
|
59
|
+
self.groq_client = None
|
|
60
|
+
self.chat_session = None
|
|
61
|
+
self.context_loader = None
|
|
62
|
+
|
|
63
|
+
self.history_file = os.path.expanduser("~/.nova_history")
|
|
64
|
+
try:
|
|
65
|
+
if os.path.exists(self.history_file):
|
|
66
|
+
readline.read_history_file(self.history_file)
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
# --- ROBUST TAB COMPLETION CONFIG ---
|
|
71
|
+
def completer(text, state_idx):
|
|
72
|
+
buffer = readline.get_line_buffer()
|
|
73
|
+
line = buffer.lstrip()
|
|
74
|
+
|
|
75
|
+
# If completing the first word, suggest commands
|
|
76
|
+
if " " not in line:
|
|
77
|
+
options = [cmd for cmd in self.registry.all().keys() if cmd.startswith(text)]
|
|
78
|
+
else:
|
|
79
|
+
# If completing arguments, suggest files/directories
|
|
80
|
+
import glob
|
|
81
|
+
matches = glob.glob(text + "*")
|
|
82
|
+
options = []
|
|
83
|
+
for m in matches:
|
|
84
|
+
if os.path.isdir(m):
|
|
85
|
+
options.append(m + "/")
|
|
86
|
+
else:
|
|
87
|
+
options.append(m)
|
|
88
|
+
|
|
89
|
+
return options[state_idx] if state_idx < len(options) else None
|
|
90
|
+
|
|
91
|
+
# Platform-safe readline setup (Windows / macOS / Linux)
|
|
92
|
+
if hasattr(readline, "parse_and_bind"):
|
|
93
|
+
doc = getattr(readline, "__doc__", "") or ""
|
|
94
|
+
|
|
95
|
+
if sys.platform == "darwin" and "libedit" in doc:
|
|
96
|
+
readline.parse_and_bind("bind ^I rl_complete")
|
|
97
|
+
else:
|
|
98
|
+
readline.parse_and_bind("tab: complete")
|
|
99
|
+
readline.parse_and_bind("set show-all-if-ambiguous on")
|
|
100
|
+
|
|
101
|
+
# Safe completer setup
|
|
102
|
+
if hasattr(readline, "set_completer"):
|
|
103
|
+
readline.set_completer(completer)
|
|
104
|
+
|
|
105
|
+
# Safe delimiter handling
|
|
106
|
+
if hasattr(readline, "get_completer_delims") and hasattr(readline, "set_completer_delims"):
|
|
107
|
+
current_delims = readline.get_completer_delims()
|
|
108
|
+
readline.set_completer_delims(current_delims.replace("/", ""))
|
|
109
|
+
|
|
110
|
+
# Configuration
|
|
111
|
+
self.provider = "groq"
|
|
112
|
+
self.model_name = config.DEFAULT_MODEL
|
|
113
|
+
self.project_root = os.path.abspath(os.getcwd())
|
|
114
|
+
|
|
115
|
+
# Command Registry
|
|
116
|
+
self.registry = CommandRegistry()
|
|
117
|
+
register_core_commands(self.registry, self)
|
|
118
|
+
|
|
119
|
+
def get_prompt_text(self):
|
|
120
|
+
prefix = ""
|
|
121
|
+
if self.state.active_file:
|
|
122
|
+
prefix = f"[dim]({os.path.basename(self.state.active_file)})[/dim] "
|
|
123
|
+
if len(self.state.loaded_files) > 0:
|
|
124
|
+
prefix += f"[dim][{len(self.state.loaded_files)} loaded][/dim] "
|
|
125
|
+
return f"{prefix}[bold cyan]spark terminal >[/bold cyan] "
|
|
126
|
+
|
|
127
|
+
# --- COMMAND HANDLERS ---
|
|
128
|
+
|
|
129
|
+
def cmd_help(self, args):
|
|
130
|
+
help_text = """
|
|
131
|
+
[bold cyan]NOVA COMMANDS[/bold cyan]
|
|
132
|
+
[green]Core Modes:[/green]
|
|
133
|
+
run <cmd> : Run a command with Auto-Healing (e.g., 'run python app.py')
|
|
134
|
+
clean : Run the Code Janitor to refactor/clean code
|
|
135
|
+
|
|
136
|
+
[green]File & Navigation:[/green]
|
|
137
|
+
ls / :map : Show file tree
|
|
138
|
+
cd <path> : Change directory
|
|
139
|
+
pwd : Print working directory
|
|
140
|
+
:load <file> : Load a file into AI context
|
|
141
|
+
:unload : Unload files from context
|
|
142
|
+
:paste : Paste multiline text/logs
|
|
143
|
+
|
|
144
|
+
[green]System:[/green]
|
|
145
|
+
:gitoptions : Open Git operations menu
|
|
146
|
+
:model : Change AI Model
|
|
147
|
+
:wizard : Run Project Creation Wizard
|
|
148
|
+
reset : Reset session memory
|
|
149
|
+
exit : Shutdown
|
|
150
|
+
"""
|
|
151
|
+
self.interface.print(Panel(help_text.strip(), title="Help Menu", border_style="cyan"))
|
|
152
|
+
|
|
153
|
+
def cmd_gitoptions(self, args):
|
|
154
|
+
while True:
|
|
155
|
+
choice = self.interface.show_git_options(config.GIT_AUTO_COMMIT, config.GIT_AUTO_PUSH)
|
|
156
|
+
|
|
157
|
+
if choice.startswith("Back"):
|
|
158
|
+
break
|
|
159
|
+
elif choice.startswith("Toggle Auto-Commit"):
|
|
160
|
+
config.GIT_AUTO_COMMIT = not config.GIT_AUTO_COMMIT
|
|
161
|
+
status = "ENABLED" if config.GIT_AUTO_COMMIT else "DISABLED"
|
|
162
|
+
self.interface.print(f"[yellow]>> Auto-Commit is now {status}[/yellow]")
|
|
163
|
+
elif choice.startswith("Toggle Auto-Push"):
|
|
164
|
+
config.GIT_AUTO_PUSH = not config.GIT_AUTO_PUSH
|
|
165
|
+
status = "ENABLED" if config.GIT_AUTO_PUSH else "DISABLED"
|
|
166
|
+
self.interface.print(f"[yellow]>> Auto-Push is now {status}[/yellow]")
|
|
167
|
+
elif choice.startswith("Manual Commit"):
|
|
168
|
+
msg = self.interface.input("[cyan]Commit Message > [/cyan]").strip()
|
|
169
|
+
if msg:
|
|
170
|
+
manual_commit(msg)
|
|
171
|
+
elif choice.startswith("Push"):
|
|
172
|
+
perform_push()
|
|
173
|
+
elif choice.startswith("Pull"):
|
|
174
|
+
perform_pull()
|
|
175
|
+
elif choice.startswith("Git Status"):
|
|
176
|
+
git_status()
|
|
177
|
+
self.interface.input("[dim]Press Enter to continue...[/dim]")
|
|
178
|
+
|
|
179
|
+
def cmd_login(self, args):
|
|
180
|
+
auth = NovaAuthClient()
|
|
181
|
+
|
|
182
|
+
print("[cyan]Opening browser for authentication...[/cyan]")
|
|
183
|
+
session_id = auth.create_session()
|
|
184
|
+
auth.open_browser(session_id)
|
|
185
|
+
|
|
186
|
+
print("[dim]Waiting for approval...[/dim]")
|
|
187
|
+
|
|
188
|
+
auth_code = auth.poll_session(session_id)
|
|
189
|
+
|
|
190
|
+
if not auth_code:
|
|
191
|
+
print("[red]Login timed out.[/red]")
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
tokens = auth.exchange_auth_code(auth_code)
|
|
195
|
+
|
|
196
|
+
if not tokens or tokens.get("error"):
|
|
197
|
+
print(f"[red]Authentication failed: {tokens}[/red]")
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
tokens["issued_at"] = time.time()
|
|
201
|
+
save_auth(tokens)
|
|
202
|
+
|
|
203
|
+
print("[green]Login successful! You are now authenticated.[/green]")
|
|
204
|
+
|
|
205
|
+
def cmd_model(self, args):
|
|
206
|
+
new_model, new_provider = self.interface.show_model_selector(self.model_name)
|
|
207
|
+
if new_model != self.model_name:
|
|
208
|
+
self.model_name = new_model
|
|
209
|
+
self.provider = new_provider
|
|
210
|
+
logging.info(f"Switched model to {self.model_name}")
|
|
211
|
+
self.interface.display_header(self.model_name, os.getcwd())
|
|
212
|
+
|
|
213
|
+
def cmd_reset(self, args):
|
|
214
|
+
self.state.reset()
|
|
215
|
+
self.interface.clear()
|
|
216
|
+
logging.info("Session reset")
|
|
217
|
+
self.interface.display_header(self.model_name, os.getcwd())
|
|
218
|
+
|
|
219
|
+
def cmd_unload(self, args):
|
|
220
|
+
if not args:
|
|
221
|
+
self.state.active_file = None
|
|
222
|
+
self.state.loaded_files = {}
|
|
223
|
+
self.state.loaded_paths = {}
|
|
224
|
+
self.interface.print("[dim] Unloaded all files.[/dim]")
|
|
225
|
+
else:
|
|
226
|
+
fname = args.replace('"', "").replace("'", "").strip()
|
|
227
|
+
found_key = None
|
|
228
|
+
for key in self.state.loaded_files.keys():
|
|
229
|
+
if key == fname or os.path.basename(key) == fname:
|
|
230
|
+
found_key = key
|
|
231
|
+
break
|
|
232
|
+
|
|
233
|
+
if found_key:
|
|
234
|
+
del self.state.loaded_files[found_key]
|
|
235
|
+
if found_key in self.state.loaded_paths:
|
|
236
|
+
del self.state.loaded_paths[found_key]
|
|
237
|
+
|
|
238
|
+
self.interface.print(f"[dim] Unloaded: {found_key}[/dim]")
|
|
239
|
+
|
|
240
|
+
if self.state.active_file and os.path.basename(self.state.active_file) == found_key:
|
|
241
|
+
self.state.active_file = None
|
|
242
|
+
else:
|
|
243
|
+
self.interface.print(f"[red] File '{fname}' is not currently loaded.[/red]")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def cmd_map(self, args):
|
|
247
|
+
prompts.clear_file_tree_cache()
|
|
248
|
+
map_directory()
|
|
249
|
+
|
|
250
|
+
def cmd_wizard(self, args):
|
|
251
|
+
self.interface.print("[yellow]Wizard is not available in API-only CLI mode yet.[/yellow]")
|
|
252
|
+
|
|
253
|
+
def cmd_apply(self, args):
|
|
254
|
+
save_code_to_file(self.state.active_file, self.state.last_generated_code)
|
|
255
|
+
|
|
256
|
+
def cmd_cd(self, args):
|
|
257
|
+
if not args:
|
|
258
|
+
return
|
|
259
|
+
try:
|
|
260
|
+
target_path = os.path.abspath(args)
|
|
261
|
+
if not target_path.startswith(self.project_root):
|
|
262
|
+
self.interface.print("[bold red]SECURITY ALERT:[/bold red] Access denied outside project root.")
|
|
263
|
+
logging.warning(f"Access denied: {target_path}")
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
os.chdir(target_path)
|
|
267
|
+
self.interface.print(f"[dim] cwd: {os.getcwd()}[/dim]")
|
|
268
|
+
update_repo_path(os.getcwd())
|
|
269
|
+
except Exception as e:
|
|
270
|
+
self.interface.print(f"[red]{e}[/red]")
|
|
271
|
+
|
|
272
|
+
def cmd_pwd(self, args):
|
|
273
|
+
self.interface.print(f"[dim]{os.getcwd()}[/dim]")
|
|
274
|
+
|
|
275
|
+
def cmd_build_it(self, args):
|
|
276
|
+
plan_path = os.path.join(os.getcwd(), "PLAN.md")
|
|
277
|
+
if not os.path.exists(plan_path):
|
|
278
|
+
self.interface.print("[red]No PLAN.md found. Please describe your project first.[/red]")
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
with open(plan_path, "r", encoding="utf-8") as f:
|
|
282
|
+
plan_content = f.read()
|
|
283
|
+
|
|
284
|
+
# Command the model to be a Coder, not a Planner
|
|
285
|
+
prompt = (
|
|
286
|
+
"IMPLEMENTATION PHASE: Read the following PLAN.md and generate the FULL CODE for all files listed. "
|
|
287
|
+
"Use [CREATE: path/to/file] tags followed by triple-backtick code blocks. "
|
|
288
|
+
"Do NOT explain the code. Do NOT output more plans. Just implementation.\n\n"
|
|
289
|
+
f"PLAN.md CONTENT:\n{plan_content}"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# Force Qwen for implementation
|
|
293
|
+
self.handle_ai_request(prompt, override_model="qwen/qwen3-32b")
|
|
294
|
+
|
|
295
|
+
def cmd_exit(self, args):
|
|
296
|
+
try:
|
|
297
|
+
readline.write_history_file(self.history_file)
|
|
298
|
+
except Exception:
|
|
299
|
+
pass
|
|
300
|
+
logging.info("Shutdown")
|
|
301
|
+
return "EXIT"
|
|
302
|
+
|
|
303
|
+
def cmd_load(self, args):
|
|
304
|
+
"""
|
|
305
|
+
:load should ONLY load file contents into context.
|
|
306
|
+
It must NOT trigger any AI call.
|
|
307
|
+
"""
|
|
308
|
+
try:
|
|
309
|
+
path_arg = (args or "").strip().replace('"', "").replace("'", "")
|
|
310
|
+
if not path_arg:
|
|
311
|
+
self.interface.print("[yellow]Usage: :load <file>[/yellow]")
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
f_path, content = load_file(path_arg)
|
|
315
|
+
if not f_path:
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
abs_path = os.path.abspath(f_path).replace("\\", "/")
|
|
319
|
+
base = os.path.basename(f_path)
|
|
320
|
+
|
|
321
|
+
# mark active (keep absolute path)
|
|
322
|
+
self.state.active_file = abs_path
|
|
323
|
+
|
|
324
|
+
# canonical storage: basename only
|
|
325
|
+
self.state.loaded_files[base] = content
|
|
326
|
+
self.state.loaded_paths[base] = abs_path
|
|
327
|
+
|
|
328
|
+
self.interface.print(f"[green]>> Loaded into context:[/green] {base}")
|
|
329
|
+
return # IMPORTANT: do not return a string (prevents AI call)
|
|
330
|
+
|
|
331
|
+
except Exception as e:
|
|
332
|
+
self.interface.print(f"[red]Load failed: {e}[/red]")
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def cmd_paste(self, args):
|
|
338
|
+
if not self.state.active_file:
|
|
339
|
+
self.interface.print("[red]Load a file first.[/red]")
|
|
340
|
+
return
|
|
341
|
+
error_log = self.interface.get_multiline_input()
|
|
342
|
+
if error_log:
|
|
343
|
+
return f"DEBUG_REQUEST: Fix {self.state.active_file}\nERROR:\n{error_log}"
|
|
344
|
+
|
|
345
|
+
def cmd_run(self, args):
|
|
346
|
+
if not args:
|
|
347
|
+
self.interface.print("[yellow]Usage: run <filename> or run <command>[/yellow]")
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
target_file = args.strip().replace('"', "").replace("'", "")
|
|
351
|
+
if target_file.lower().endswith((".html", ".htm")):
|
|
352
|
+
import webbrowser
|
|
353
|
+
fpath = os.path.abspath(target_file)
|
|
354
|
+
if os.path.exists(fpath):
|
|
355
|
+
self.interface.print(f"[green]>> Opening {os.path.basename(fpath)} in browser...[/green]")
|
|
356
|
+
webbrowser.open(f"file://{fpath}")
|
|
357
|
+
return
|
|
358
|
+
else:
|
|
359
|
+
self.interface.print(f"[red]>> File not found: {target_file}[/red]")
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
command_list = shlex.split(args)
|
|
363
|
+
resolved = resolve_path(command_list[0]) if command_list else None
|
|
364
|
+
|
|
365
|
+
if len(command_list) == 1 and resolved and os.path.exists(resolved):
|
|
366
|
+
command_list[0] = resolved
|
|
367
|
+
ext = os.path.splitext(resolved)[1].lower()
|
|
368
|
+
|
|
369
|
+
runners = {
|
|
370
|
+
".py": [sys.executable],
|
|
371
|
+
".js": ["node"],
|
|
372
|
+
".ts": ["ts-node"],
|
|
373
|
+
".rb": ["ruby"],
|
|
374
|
+
".go": ["go", "run"],
|
|
375
|
+
".php": ["php"],
|
|
376
|
+
".sh": ["bash"],
|
|
377
|
+
".pl": ["perl"],
|
|
378
|
+
".lua": ["lua"],
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if ext in runners:
|
|
382
|
+
command_list = runners[ext] + command_list
|
|
383
|
+
|
|
384
|
+
elif len(command_list) > 0 and command_list[0].endswith(".py"):
|
|
385
|
+
if "python" not in command_list[0]:
|
|
386
|
+
command_list.insert(0, sys.executable)
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
output = run_with_healing(
|
|
390
|
+
command_args=command_list,
|
|
391
|
+
cwd=os.getcwd(),
|
|
392
|
+
model=self.model_name,
|
|
393
|
+
provider=self.provider,
|
|
394
|
+
context=self.state.loaded_files,
|
|
395
|
+
repo_map=None,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# runner prints stdout itself on success; print fallback output if any
|
|
399
|
+
if output and output != "SUCCESS_SIGNAL":
|
|
400
|
+
self.interface.console.print(output, markup=False)
|
|
401
|
+
|
|
402
|
+
except Exception as e:
|
|
403
|
+
self.interface.print(f"[bold red]Healer Error:[/bold red] {e}")
|
|
404
|
+
|
|
405
|
+
def cmd_clean(self, args):
|
|
406
|
+
if not self.state.loaded_files:
|
|
407
|
+
self.interface.print("[yellow]No files loaded. Use :load first.[yellow]")
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
api = BridgeyeAPIClient()
|
|
411
|
+
|
|
412
|
+
for filename, content in self.state.loaded_files.items():
|
|
413
|
+
try:
|
|
414
|
+
self.interface.print(f"[dim]Refactoring {filename}...[/dim]")
|
|
415
|
+
|
|
416
|
+
resp = api.refactor(
|
|
417
|
+
filename=filename,
|
|
418
|
+
content=content,
|
|
419
|
+
model=self.model_name,
|
|
420
|
+
provider=self.provider,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
file_path = (
|
|
424
|
+
self.state.loaded_paths.get(filename)
|
|
425
|
+
or (
|
|
426
|
+
self.state.active_file
|
|
427
|
+
if self.state.active_file and os.path.basename(self.state.active_file) == filename
|
|
428
|
+
else filename
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
# --- CASE 1: Janitor returned a Nova [EDIT] patch ---
|
|
433
|
+
if isinstance(resp, str) and "[EDIT:" in resp:
|
|
434
|
+
# Quick client-side sanity check before applying
|
|
435
|
+
if "<<<<<<<" not in resp or "=======" not in resp or ">>>>>>>" not in resp:
|
|
436
|
+
self.interface.print(
|
|
437
|
+
f"[bold red]Invalid Janitor patch format for {filename} (missing SEARCH/REPLACE markers).[/bold red]"
|
|
438
|
+
)
|
|
439
|
+
self.interface.print((resp or "")[:2000])
|
|
440
|
+
continue
|
|
441
|
+
|
|
442
|
+
modified = handle_ai_commands(resp)
|
|
443
|
+
|
|
444
|
+
if not modified:
|
|
445
|
+
self.interface.print(
|
|
446
|
+
f"[bold red]Janitor returned an [EDIT] block but it could not be applied for {filename}.[/bold red]"
|
|
447
|
+
)
|
|
448
|
+
self.interface.print((resp or "")[:2000])
|
|
449
|
+
continue
|
|
450
|
+
|
|
451
|
+
# Reload file after successful patch
|
|
452
|
+
if os.path.exists(file_path):
|
|
453
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
454
|
+
new_code = f.read()
|
|
455
|
+
self.state.loaded_files[filename] = new_code
|
|
456
|
+
|
|
457
|
+
self.interface.print(f"[green]✔ Refactored {filename}[/green]")
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
# --- CASE 2: Server returned plain refactored code (fallback path) ---
|
|
461
|
+
# This should almost never happen now that Janitor is strict,
|
|
462
|
+
# but we keep it as a safety fallback.
|
|
463
|
+
if not isinstance(resp, str):
|
|
464
|
+
raise ValueError(f"Unexpected response type from Janitor: {type(resp)}")
|
|
465
|
+
|
|
466
|
+
new_code = resp or ""
|
|
467
|
+
|
|
468
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
469
|
+
f.write(new_code)
|
|
470
|
+
|
|
471
|
+
self.state.loaded_files[filename] = new_code
|
|
472
|
+
self.interface.print(f"[green]✔ Refactored {filename}[/green]")
|
|
473
|
+
|
|
474
|
+
except Exception as e:
|
|
475
|
+
self.interface.print(f"[bold red]Janitor API error for {filename}:[/bold red] {e}")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def scan_and_load_context(self, text):
|
|
479
|
+
potential_files = re.findall(r"\b[\w\-\/]+\.\w+\b", text)
|
|
480
|
+
|
|
481
|
+
loaded_any = False
|
|
482
|
+
for fname in potential_files:
|
|
483
|
+
if os.path.exists(fname) and os.path.isfile(fname):
|
|
484
|
+
try:
|
|
485
|
+
abs_path = os.path.abspath(fname).replace("\\", "/")
|
|
486
|
+
base = os.path.basename(fname)
|
|
487
|
+
|
|
488
|
+
with open(fname, "r", encoding="utf-8") as f:
|
|
489
|
+
self.state.loaded_files[base] = f.read()
|
|
490
|
+
self.state.loaded_paths[base] = abs_path
|
|
491
|
+
|
|
492
|
+
if not self.state.active_file:
|
|
493
|
+
self.state.active_file = abs_path
|
|
494
|
+
|
|
495
|
+
loaded_any = True
|
|
496
|
+
except Exception:
|
|
497
|
+
pass
|
|
498
|
+
|
|
499
|
+
if self.state.active_file and os.path.exists(self.state.active_file):
|
|
500
|
+
try:
|
|
501
|
+
base = os.path.basename(self.state.active_file)
|
|
502
|
+
with open(self.state.active_file, "r", encoding="utf-8") as f:
|
|
503
|
+
self.state.loaded_files[base] = f.read()
|
|
504
|
+
self.state.loaded_paths[base] = os.path.abspath(self.state.active_file).replace("\\", "/")
|
|
505
|
+
except Exception:
|
|
506
|
+
pass
|
|
507
|
+
|
|
508
|
+
if loaded_any:
|
|
509
|
+
self.interface.print("[dim]>> Auto-loaded file context for AI visibility.[/dim]")
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def run(self):
|
|
513
|
+
self.interface.clear()
|
|
514
|
+
logging.info(f"Session started in {self.project_root}")
|
|
515
|
+
|
|
516
|
+
# Simple non-interactive entry
|
|
517
|
+
if len(sys.argv) > 1:
|
|
518
|
+
cmd = sys.argv[1]
|
|
519
|
+
args = sys.argv[2:] if len(sys.argv) > 2 else []
|
|
520
|
+
|
|
521
|
+
if cmd == "run":
|
|
522
|
+
if args:
|
|
523
|
+
try:
|
|
524
|
+
output = run_with_healing(
|
|
525
|
+
command_args=args,
|
|
526
|
+
cwd=os.getcwd(),
|
|
527
|
+
model=self.model_name,
|
|
528
|
+
provider=self.provider,
|
|
529
|
+
context=self.state.loaded_files,
|
|
530
|
+
repo_map=prompts.get_repo_map_cached(os.getcwd()),
|
|
531
|
+
)
|
|
532
|
+
if output and output != "SUCCESS_SIGNAL":
|
|
533
|
+
print(output)
|
|
534
|
+
except Exception as e:
|
|
535
|
+
print(f"Healer Error: {e}")
|
|
536
|
+
return
|
|
537
|
+
print("Usage: nova run <command>")
|
|
538
|
+
return
|
|
539
|
+
|
|
540
|
+
if cmd == "clean":
|
|
541
|
+
# in non-interactive mode, clean requires loaded context; keep it simple for now
|
|
542
|
+
print("Use interactive mode for clean (load files first).")
|
|
543
|
+
return
|
|
544
|
+
|
|
545
|
+
if cmd.lower() == "build":
|
|
546
|
+
self.cmd_build_it(args)
|
|
547
|
+
return
|
|
548
|
+
|
|
549
|
+
self.interface.display_header(self.model_name, os.getcwd())
|
|
550
|
+
|
|
551
|
+
while True:
|
|
552
|
+
try:
|
|
553
|
+
cmd_raw = self.interface.input(self.get_prompt_text()).strip()
|
|
554
|
+
if not cmd_raw:
|
|
555
|
+
continue
|
|
556
|
+
|
|
557
|
+
try:
|
|
558
|
+
parts = shlex.split(cmd_raw)
|
|
559
|
+
except ValueError:
|
|
560
|
+
self.interface.print("[red]Syntax Error: Unbalanced quotes[/red]")
|
|
561
|
+
continue
|
|
562
|
+
|
|
563
|
+
cmd_base = parts[0]
|
|
564
|
+
args = " ".join(parts[1:]) if len(parts) > 1 else ""
|
|
565
|
+
|
|
566
|
+
ai_prompt = None
|
|
567
|
+
|
|
568
|
+
handler = self.registry.get(cmd_base)
|
|
569
|
+
|
|
570
|
+
if handler:
|
|
571
|
+
result = handler(args)
|
|
572
|
+
if result == "EXIT":
|
|
573
|
+
break
|
|
574
|
+
if isinstance(result, str):
|
|
575
|
+
ai_prompt = result
|
|
576
|
+
else:
|
|
577
|
+
ai_prompt = cmd_raw
|
|
578
|
+
|
|
579
|
+
if ai_prompt:
|
|
580
|
+
self.handle_ai_request(ai_prompt)
|
|
581
|
+
|
|
582
|
+
except KeyboardInterrupt:
|
|
583
|
+
self.interface.print("\n[dim]Shutdown.[/dim]")
|
|
584
|
+
break
|
|
585
|
+
except EOFError:
|
|
586
|
+
try:
|
|
587
|
+
readline.write_history_file(self.history_file)
|
|
588
|
+
except Exception:
|
|
589
|
+
pass
|
|
590
|
+
self.interface.print("\n[dim]Shutdown.[/dim]")
|
|
591
|
+
break
|
|
592
|
+
|
|
593
|
+
def handle_ai_request(self, prompt_text, override_model=None):
|
|
594
|
+
try:
|
|
595
|
+
api = BridgeyeAPIClient()
|
|
596
|
+
|
|
597
|
+
# --- INTENT DETECTION ---
|
|
598
|
+
prompt_lower = prompt_text.lower()
|
|
599
|
+
# Expanded triggers to catch "do this project" or "start with this"
|
|
600
|
+
build_keywords = ["build", "make", "create", "implement", "do it", "do this", "start", "proceed"]
|
|
601
|
+
plan_keywords = ["plan", "outline", "todo", "to-do"]
|
|
602
|
+
|
|
603
|
+
is_build_intent = any(k in prompt_lower for k in build_keywords)
|
|
604
|
+
has_plan_word = any(k in prompt_lower for k in plan_keywords)
|
|
605
|
+
|
|
606
|
+
plan_path = os.path.join(os.getcwd(), "PLAN.md")
|
|
607
|
+
|
|
608
|
+
# Switch to Qwen only if build intent is clear and we aren't discussing the "plan"
|
|
609
|
+
if is_build_intent and not has_plan_word and os.path.exists(plan_path) and not override_model:
|
|
610
|
+
self.interface.print("[cyan]>> Intent detected: Switching to Qwen-3 to build from PLAN.md...[/cyan]")
|
|
611
|
+
return self.cmd_build_it(prompt_text)
|
|
612
|
+
# ------------------------
|
|
613
|
+
|
|
614
|
+
target_model = override_model if override_model else self.model_name
|
|
615
|
+
|
|
616
|
+
if not override_model:
|
|
617
|
+
prompt_text = (
|
|
618
|
+
"INSTRUCTION: You are in the PLANNING PHASE.\n"
|
|
619
|
+
"1. If the request is a new project or task, create a detailed To-Do list in PLAN.md using [CREATE: PLAN.md].\n"
|
|
620
|
+
"2. If the user is giving feedback (e.g. 'cool', 'looks good') or asking a question, respond conversationally "
|
|
621
|
+
"and do NOT recreate the PLAN.md file.\n"
|
|
622
|
+
"3. NEVER write the actual implementation code in this phase.\n\n"
|
|
623
|
+
f"USER REQUEST: {prompt_text}"
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
with self.interface.create_loader(""):
|
|
627
|
+
output = api.chat(
|
|
628
|
+
prompt=prompt_text,
|
|
629
|
+
context=self.state.loaded_files,
|
|
630
|
+
model=target_model,
|
|
631
|
+
provider=self.provider,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
if not output:
|
|
635
|
+
return
|
|
636
|
+
|
|
637
|
+
self.interface.print(output)
|
|
638
|
+
self.state.last_ai_response = output
|
|
639
|
+
|
|
640
|
+
extracted_code = extract_code_from_markdown(output)
|
|
641
|
+
if extracted_code:
|
|
642
|
+
self.state.last_generated_code = extracted_code
|
|
643
|
+
|
|
644
|
+
created_files = handle_ai_commands(output)
|
|
645
|
+
|
|
646
|
+
if isinstance(created_files, list) and created_files:
|
|
647
|
+
last_file = created_files[-1]
|
|
648
|
+
if os.path.exists(last_file):
|
|
649
|
+
self.state.active_file = os.path.abspath(last_file)
|
|
650
|
+
with open(last_file, "r", encoding="utf-8") as f:
|
|
651
|
+
self.state.loaded_files[os.path.basename(last_file)] = f.read()
|
|
652
|
+
|
|
653
|
+
logging.info(f"Interaction | Len: {len(output)}")
|
|
654
|
+
|
|
655
|
+
except Exception as e:
|
|
656
|
+
self.interface.print(f"[bold red]API Error:[/bold red] {e}")
|
|
657
|
+
logging.error(f"Chat API Error: {e}")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
# -----------------------------
|
|
4
|
+
# NOVA CLI (API-only) Config
|
|
5
|
+
# -----------------------------
|
|
6
|
+
# This repo MUST NOT store model provider API keys.
|
|
7
|
+
# All AI + execution happens via NOVA_API over HTTP.
|
|
8
|
+
# Auth happens via nova-web.
|
|
9
|
+
# -----------------------------
|
|
10
|
+
|
|
11
|
+
# Location / display (optional; used only for UX)
|
|
12
|
+
LOCATION = os.getenv("NOVA_LOCATION", "India")
|
|
13
|
+
USER_NAME = os.getenv("NOVA_USER_NAME", "User")
|
|
14
|
+
|
|
15
|
+
# Defaults for CLI requests (sent to API)
|
|
16
|
+
DEFAULT_PROVIDER = os.getenv("NOVA_DEFAULT_PROVIDER", "groq")
|
|
17
|
+
DEFAULT_MODEL = os.getenv("NOVA_DEFAULT_MODEL", "openai/gpt-oss-120b")
|
|
18
|
+
|
|
19
|
+
# Base URLs
|
|
20
|
+
# - NOVA_API_BASE_URL points to the NOVA_API server (local or remote)
|
|
21
|
+
# - NOVA_AUTH_BASE_URL points to nova-web (Render)
|
|
22
|
+
NOVA_API_BASE_URL = os.getenv("NOVA_API_BASE_URL", "https://api.nova.bridgeye.com")
|
|
23
|
+
NOVA_AUTH_BASE_URL = os.getenv("NOVA_AUTH_BASE_URL", "https://nova.bridgeye.com")
|
|
24
|
+
|
|
25
|
+
# Client identity
|
|
26
|
+
NOVA_USER_AGENT = os.getenv("NOVA_USER_AGENT", "NovaCLI/1.0")
|
|
27
|
+
|
|
28
|
+
# Debug toggles
|
|
29
|
+
DEBUG_AUTH = os.getenv("NOVA_DEBUG_AUTH", "").strip() in ("1", "true", "TRUE", "yes", "YES")
|
|
30
|
+
|
|
31
|
+
# Git behavior (CLI-only UX; file_manager uses these)
|
|
32
|
+
GIT_AUTO_COMMIT = os.getenv("NOVA_GIT_AUTO_COMMIT", "").strip() in ("1", "true", "TRUE", "yes", "YES")
|
|
33
|
+
GIT_AUTO_PUSH = os.getenv("NOVA_GIT_AUTO_PUSH", "").strip() in ("1", "true", "TRUE", "yes", "YES")
|
|
34
|
+
|
|
35
|
+
# Context load toggle (CLI-side feature; does not affect API)
|
|
36
|
+
AUTO_CONTEXT_LOAD = os.getenv("NOVA_AUTO_CONTEXT_LOAD", "").strip() in ("1", "true", "TRUE", "yes", "YES")
|