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.
Files changed (38) hide show
  1. package/.env.example +17 -0
  2. package/.vscode/settings.json +5 -0
  3. package/README.md +9 -0
  4. package/bin/nova +3 -0
  5. package/core/__init__.py +0 -0
  6. package/core/prompts.py +158 -0
  7. package/core/state.py +37 -0
  8. package/nova_cli/__init__.py +1 -0
  9. package/nova_cli/__main__.py +4 -0
  10. package/nova_cli/cli/__init__.py +0 -0
  11. package/nova_cli/cli/commands.py +49 -0
  12. package/nova_cli/cli/main.py +83 -0
  13. package/nova_cli/cli/registry.py +14 -0
  14. package/nova_cli/cli/shell.py +657 -0
  15. package/nova_cli/config.py +36 -0
  16. package/nova_cli/local/file_manager/__init__.py +36 -0
  17. package/nova_cli/local/file_manager/commands.py +173 -0
  18. package/nova_cli/local/file_manager/edit_ops.py +98 -0
  19. package/nova_cli/local/file_manager/git_ops.py +135 -0
  20. package/nova_cli/local/file_manager/io_ops.py +297 -0
  21. package/nova_cli/local/file_manager/path_ops.py +48 -0
  22. package/nova_cli/local/file_manager.py +4 -0
  23. package/nova_cli/local/healer/__init__.py +0 -0
  24. package/nova_cli/local/healer/runner.py +313 -0
  25. package/nova_cli/local/ui.py +196 -0
  26. package/nova_cli/local/utils.py +201 -0
  27. package/nova_cli/nova_core/__init__.py +0 -0
  28. package/nova_cli/nova_core/ai/__init__.py +0 -0
  29. package/nova_cli/nova_core/ai/api_client.py +298 -0
  30. package/nova_cli/nova_core/ai/utils.py +12 -0
  31. package/nova_cli/nova_core/auth/__init__.py +0 -0
  32. package/nova_cli/nova_core/auth/client.py +103 -0
  33. package/nova_cli/nova_core/auth/storage.py +146 -0
  34. package/nova_legacy.py +15 -0
  35. package/package.json +19 -0
  36. package/project_context.txt +3528 -0
  37. package/pyproject.toml +26 -0
  38. 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")