dbagent-cli 0.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.
dbagent/cli.py ADDED
@@ -0,0 +1,717 @@
1
+ """
2
+ DB-Agent CLI: Main Terminal Entry Point.
3
+ Seamless database CLI — ask questions in natural language, get results instantly.
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Optional, List
11
+ import typer
12
+ from rich.prompt import Prompt, Confirm
13
+ from rich.table import Table
14
+ from prompt_toolkit import PromptSession
15
+ from prompt_toolkit.history import FileHistory
16
+
17
+ from dbagent import __version__
18
+ from dbagent.config import ConfigManager
19
+ from dbagent.connectors.factory import create_connector
20
+ from dbagent.schema.formatter import SchemaFormatter
21
+ from dbagent.schema.models import DatabaseSchema
22
+ from dbagent.llm.factory import get_llm_provider
23
+ from dbagent.agent.generator import ScriptGenerator
24
+ from dbagent.agent.validator import ScriptValidator
25
+ from dbagent.agent.pipeline import QueryPipeline, PipelineResult
26
+ from dbagent.ui.console import (
27
+ console,
28
+ print_banner,
29
+ print_success,
30
+ print_error,
31
+ print_warning,
32
+ print_info,
33
+ print_code,
34
+ print_results_table,
35
+ )
36
+ from dbagent.ui.viewer import SchemaViewer
37
+
38
+ app = typer.Typer(
39
+ name="db-agent",
40
+ help="DB-Agent: Universal Database CLI with AI — ask in plain English, get results instantly.",
41
+ add_completion=False,
42
+ )
43
+
44
+ config_mgr = ConfigManager()
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Shared helpers
49
+ # ---------------------------------------------------------------------------
50
+
51
+ def resolve_db_url(db: Optional[str] = None) -> str:
52
+ """Resolve database connection URL from CLI arg, saved profiles, or user prompt."""
53
+ if db:
54
+ profile = config_mgr.get_profile(db)
55
+ if profile:
56
+ return profile["url"]
57
+ return db
58
+
59
+ env_db = os.getenv("DATABASE_URL")
60
+ if env_db:
61
+ return env_db
62
+
63
+ profiles = config_mgr.list_profiles()
64
+ if len(profiles) == 1:
65
+ only_prof = list(profiles.values())[0]
66
+ return only_prof["url"]
67
+
68
+ if profiles:
69
+ console.print("[cyan]Available Saved Profiles:[/cyan]")
70
+ for name, data in profiles.items():
71
+ console.print(f" * [bold]{name}[/bold]: {data['url']}")
72
+ chosen = Prompt.ask("Enter profile name or full database connection URL")
73
+ prof = config_mgr.get_profile(chosen)
74
+ if prof:
75
+ return prof["url"]
76
+ return chosen
77
+
78
+ url = Prompt.ask(
79
+ "Enter database URL (e.g. postgresql://user:pass@localhost:5432/mydb, or sqlite:///app.db, or mongo URL)"
80
+ )
81
+ return url
82
+
83
+
84
+ def _confirm_callback(sql: str, warnings: List[str]) -> bool:
85
+ """Rich prompt for confirming write/ddl queries."""
86
+ console.print("\n[bold yellow]Generated SQL (requires confirmation):[/bold yellow]")
87
+ print_code(sql, "sql", "Review Query")
88
+ if warnings:
89
+ for w in warnings:
90
+ print_warning(w)
91
+ return Confirm.ask("[bold yellow]Execute this query?[/bold yellow]", default=False)
92
+
93
+
94
+ def _choice_callback(prompt_text: str, choices: List[str]) -> Optional[str]:
95
+ """Rich prompt for resolving ambiguous table matches."""
96
+ console.print(f"\n[bold cyan]{prompt_text}[/bold cyan]")
97
+ for i, c in enumerate(choices, 1):
98
+ console.print(f" [bold]{i}[/bold]. {c}")
99
+ console.print(f" [bold]{len(choices) + 1}[/bold]. All of the above")
100
+ choice = Prompt.ask("Enter number", default="1")
101
+ try:
102
+ idx = int(choice)
103
+ if idx == len(choices) + 1:
104
+ return choices # All
105
+ if 1 <= idx <= len(choices):
106
+ return choices[idx - 1]
107
+ except (ValueError, IndexError):
108
+ pass
109
+ # Try as direct table name
110
+ if choice in choices:
111
+ return choice
112
+ return choices[0]
113
+
114
+
115
+ def _display_pipeline_result(result: PipelineResult, show_sql: bool = True) -> None:
116
+ """Display a pipeline result with SQL and results table."""
117
+ if result.error:
118
+ print_error(f"Error: {result.error}")
119
+ if result.sql:
120
+ console.print(f"[dim]Generated SQL:[/dim]")
121
+ print_code(result.sql, "sql", "Failed Query")
122
+ if result.retries > 0:
123
+ console.print(f"[dim](Retried {result.retries} time(s))[/dim]")
124
+ return
125
+
126
+ if result.was_executed:
127
+ # Show SQL in dim/collapsed form
128
+ if show_sql and result.sql:
129
+ console.print(f"[dim]SQL:[/dim] [dim italic]{result.sql.strip()}[/dim italic]")
130
+ console.print("")
131
+
132
+ if result.has_results:
133
+ print_results_table(result.columns, result.rows, title="Results")
134
+ else:
135
+ print_info("Query executed successfully. No rows returned.")
136
+
137
+ if result.retries > 0:
138
+ console.print(f"[dim](Auto-corrected after {result.retries} retry(ies))[/dim]")
139
+ elif result.needs_confirmation and not result.was_executed:
140
+ # User declined or no callback — just show SQL
141
+ if result.sql:
142
+ print_code(result.sql, "sql", f"Generated {result.query_type.upper()} Query")
143
+ console.print("[dim]This query was not executed (requires confirmation).[/dim]")
144
+ elif result.sql:
145
+ print_code(result.sql, "sql", "Generated SQL")
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # CLI Commands
150
+ # ---------------------------------------------------------------------------
151
+
152
+ @app.callback(invoke_without_command=True)
153
+ def main(
154
+ ctx: typer.Context,
155
+ version: bool = typer.Option(False, "--version", "-v", help="Show DB-Agent version"),
156
+ ):
157
+ """DB-Agent CLI entry point."""
158
+ if version:
159
+ console.print(f"DB-Agent version [bold cyan]{__version__}[/bold cyan]")
160
+ raise typer.Exit()
161
+ if ctx.invoked_subcommand is None:
162
+ print_banner()
163
+ console.print("[dim]Run [bold]db-agent --help[/bold] to see all available commands, or [bold]db-agent chat[/bold] to start interactive mode.[/dim]\n")
164
+
165
+
166
+ # ---- ask (flagship seamless command) ----
167
+
168
+ @app.command(name="ask")
169
+ def ask_command(
170
+ prompt: str = typer.Argument(..., help="Natural language question (e.g. 'show recent users')"),
171
+ db: Optional[str] = typer.Option(None, "--db", "-d", help="Database connection URL or profile alias"),
172
+ provider: Optional[str] = typer.Option(None, "--provider", "-p", help="LLM provider: ollama, gemini, groq, openrouter, mock"),
173
+ model: Optional[str] = typer.Option(None, "--model", "-m", help="Specific model name"),
174
+ force: bool = typer.Option(False, "--force", "-f", help="Force execute even write/DDL queries without confirmation"),
175
+ limit: int = typer.Option(50, "--limit", "-l", help="Maximum rows to return"),
176
+ no_execute: bool = typer.Option(False, "--no-exec", help="Generate SQL only, don't execute"),
177
+ show_sql: bool = typer.Option(True, "--sql/--no-sql", help="Show generated SQL alongside results"),
178
+ ):
179
+ """
180
+ Ask a question in plain English — get database results instantly.
181
+ Works like a database CLI: generates SQL, validates, executes, and returns results.
182
+ Write/DDL queries require confirmation unless --force is passed.
183
+ """
184
+ print_banner()
185
+ db_url = resolve_db_url(db)
186
+
187
+ try:
188
+ connector = create_connector(db_url)
189
+ success, msg = connector.test_connection()
190
+ if not success:
191
+ print_error(msg)
192
+ raise typer.Exit(1)
193
+ except Exception as e:
194
+ print_error(f"Connection failed: {str(e)}")
195
+ raise typer.Exit(1)
196
+
197
+ llm = get_llm_provider(provider_name=provider, model=model, config_mgr=config_mgr)
198
+ print_info(f"Database: [bold]{connector.engine.url.database or db_url}[/bold] ({connector.engine.dialect.name}) | AI: [bold]{llm.name}[/bold]")
199
+ console.print(f"[bold cyan]>[/bold cyan] {prompt}\n")
200
+
201
+ pipeline = QueryPipeline(
202
+ connector=connector,
203
+ llm=llm,
204
+ model=model,
205
+ auto_execute=not no_execute,
206
+ confirm_callback=_confirm_callback if not force else None,
207
+ choice_callback=_choice_callback,
208
+ )
209
+
210
+ with console.status("[bold cyan]Thinking...[/bold cyan]"):
211
+ result = pipeline.run(user_prompt=prompt, force=force)
212
+
213
+ _display_pipeline_result(result, show_sql=show_sql)
214
+
215
+ connector.close()
216
+
217
+
218
+ # ---- connect ----
219
+
220
+ @app.command(name="connect")
221
+ def connect_command(
222
+ db: str = typer.Argument(..., help="Database connection URL or file path"),
223
+ alias: Optional[str] = typer.Option(None, "--alias", "-a", help="Save this database connection under an alias profile name"),
224
+ scan: bool = typer.Option(False, "--scan", "-s", help="Scan and display full schema after connecting"),
225
+ ):
226
+ """Connect to a database, verify credentials, and save it under an alias name instantly."""
227
+ print_banner()
228
+ db_url = resolve_db_url(db)
229
+
230
+ with console.status("[bold cyan]Testing connection credentials...[/bold cyan]"):
231
+ try:
232
+ connector = create_connector(db_url)
233
+ success, msg = connector.test_connection()
234
+ if not success:
235
+ print_error(msg)
236
+ raise typer.Exit(1)
237
+ print_success(msg)
238
+
239
+ if alias:
240
+ config_mgr.save_profile(name=alias, connection_url=db_url, description=f"Alias for {db_url}")
241
+ print_success(f"Saved connection profile as alias '[bold cyan]{alias}[/bold cyan]'!")
242
+ console.print(f"[dim]You can now use this alias in any command: [bold]db-agent ask 'your question' --db {alias}[/bold][/dim]\n")
243
+
244
+ if scan:
245
+ with console.status("[bold cyan]Scanning schema...[/bold cyan]"):
246
+ schema = connector.inspect_schema(include_samples=True, max_samples=2)
247
+ SchemaViewer.display_schema_summary(schema)
248
+ except Exception as e:
249
+ print_error(f"Connection error: {str(e)}")
250
+ raise typer.Exit(1)
251
+ finally:
252
+ if "connector" in locals():
253
+ connector.close()
254
+
255
+
256
+ # ---- scan ----
257
+
258
+ @app.command(name="scan")
259
+ def scan_command(
260
+ db: Optional[str] = typer.Option(None, "--db", "-d", help="Database connection URL or profile name"),
261
+ alias: Optional[str] = typer.Option(None, "--alias", "-a", help="Save this database connection under an alias profile name"),
262
+ table: Optional[str] = typer.Option(None, "--table", "-t", help="Specific table to drill into"),
263
+ export: Optional[str] = typer.Option(None, "--export", "-e", help="Export schema to file (.json or .md)"),
264
+ no_samples: bool = typer.Option(False, "--no-samples", help="Skip fetching sample rows"),
265
+ counts: bool = typer.Option(False, "--counts", "-c", help="Calculate exact table row counts (slower on large DBs)"),
266
+ ):
267
+ """Scan and introspect database schema, tables, columns, constraints, and relationships."""
268
+ print_banner()
269
+ db_url = resolve_db_url(db)
270
+
271
+ with console.status("[bold cyan]Connecting and introspecting database schema...[/bold cyan]"):
272
+ try:
273
+ connector = create_connector(db_url)
274
+ success, msg = connector.test_connection()
275
+ if not success:
276
+ print_error(msg)
277
+ raise typer.Exit(1)
278
+
279
+ if alias:
280
+ config_mgr.save_profile(name=alias, connection_url=db_url, description=f"Alias for {db_url}")
281
+ print_success(f"Saved connection profile as alias '[bold cyan]{alias}[/bold cyan]'!")
282
+
283
+ target_tables = [table] if table else None
284
+ schema = connector.inspect_schema(
285
+ table_names=target_tables,
286
+ include_samples=not no_samples,
287
+ max_samples=3,
288
+ include_row_counts=counts,
289
+ )
290
+ except Exception as e:
291
+ print_error(f"Failed to scan database: {str(e)}")
292
+ raise typer.Exit(1)
293
+ finally:
294
+ if "connector" in locals():
295
+ connector.close()
296
+
297
+ print_success(f"Successfully introspected database [bold]{schema.database_name}[/bold] ({schema.dialect_name})")
298
+
299
+ if table:
300
+ tbl_model = schema.get_table(table)
301
+ if not tbl_model:
302
+ print_error(f"Table '{table}' not found in database.")
303
+ raise typer.Exit(1)
304
+ SchemaViewer.display_table_detail(tbl_model)
305
+ else:
306
+ SchemaViewer.display_schema_summary(schema)
307
+
308
+ if export:
309
+ out_path = Path(export)
310
+ if out_path.suffix.lower() == ".json":
311
+ out_path.write_text(schema.model_dump_json(indent=2), encoding="utf-8")
312
+ else:
313
+ out_path.write_text(SchemaFormatter.to_markdown(schema), encoding="utf-8")
314
+ print_success(f"Schema catalog exported to [bold]{out_path}[/bold]")
315
+
316
+
317
+ # ---- generate (full explanatory mode) ----
318
+
319
+ @app.command(name="generate")
320
+ def generate_command(
321
+ prompt: str = typer.Argument(..., help="Natural language description of what script/query to generate"),
322
+ db: Optional[str] = typer.Option(None, "--db", "-d", help="Database connection URL or profile name"),
323
+ alias: Optional[str] = typer.Option(None, "--alias", "-a", help="Save this database connection under an alias profile name"),
324
+ type: str = typer.Option("auto", "--type", "-t", help="Script type: auto, sql, migration, etl, api, optimization"),
325
+ provider: Optional[str] = typer.Option(None, "--provider", "-p", help="LLM provider: ollama, gemini, groq, openrouter, mock"),
326
+ model: Optional[str] = typer.Option(None, "--model", "-m", help="Specific model name"),
327
+ output: Optional[str] = typer.Option(None, "--output", "-o", help="Save generated code directly to file path"),
328
+ run: bool = typer.Option(False, "--run", "-r", help="Immediately execute generated SQL query if safe"),
329
+ ):
330
+ """Generate dialect-specific SQL queries, migrations, ETL scripts, or APIs with full explanations."""
331
+ print_banner()
332
+ db_url = resolve_db_url(db)
333
+
334
+ with console.status("[bold cyan]Fetching relevant table schema on demand...[/bold cyan]"):
335
+ try:
336
+ connector = create_connector(db_url)
337
+ if alias:
338
+ config_mgr.save_profile(name=alias, connection_url=db_url, description=f"Alias for {db_url}")
339
+ print_success(f"Saved connection profile as alias '[bold cyan]{alias}[/bold cyan]'!")
340
+ schema = connector.inspect_targeted(user_prompt=prompt, max_tables=10, include_samples=True)
341
+ except Exception as e:
342
+ print_error(f"Introspection failed: {str(e)}")
343
+ raise typer.Exit(1)
344
+
345
+ llm = get_llm_provider(provider_name=provider, model=model, config_mgr=config_mgr)
346
+ print_info(f"Target Database: [bold]{schema.database_name}[/bold] ({schema.dialect_name}) | AI Provider: [bold]{llm.name}[/bold]")
347
+ console.print(f"[bold cyan]Prompt:[/bold cyan] {prompt}\n")
348
+
349
+ generator = ScriptGenerator(llm)
350
+ console.print("[bold green]Generated Script / Solution:[/bold green]\n")
351
+ try:
352
+ response = generator.generate_script(
353
+ schema=schema,
354
+ user_prompt=prompt,
355
+ script_type=type,
356
+ model=model,
357
+ stream_callback=lambda chunk: console.print(chunk, end=""),
358
+ )
359
+ console.print("\n")
360
+ except Exception as e:
361
+ print_error(f"AI Generation failed: {str(e)}")
362
+ raise typer.Exit(1)
363
+
364
+ code_block = generator.extract_code_block(response)
365
+
366
+ if output and code_block:
367
+ out_path = Path(output)
368
+ out_path.parent.mkdir(parents=True, exist_ok=True)
369
+ out_path.write_text(code_block, encoding="utf-8")
370
+ print_success(f"Generated code saved to [bold]{out_path}[/bold]")
371
+
372
+ if run and code_block:
373
+ is_safe, warnings = ScriptValidator.analyze_safety(code_block)
374
+ if not is_safe:
375
+ for w in warnings:
376
+ print_warning(w)
377
+ if not Confirm.ask("Do you want to proceed with executing this query?"):
378
+ console.print("[dim]Execution cancelled.[/dim]")
379
+ return
380
+
381
+ with console.status("[bold cyan]Executing query...[/bold cyan]"):
382
+ cols, rows, err = connector.execute_query(code_block, limit=50)
383
+ if err:
384
+ print_error(f"Query execution error: {err}")
385
+ else:
386
+ print_results_table(cols, rows)
387
+
388
+ connector.close()
389
+
390
+
391
+ # ---- chat (interactive REPL with auto-execute) ----
392
+
393
+ @app.command(name="chat")
394
+ def chat_command(
395
+ db: Optional[str] = typer.Option(None, "--db", "-d", help="Database connection URL or profile name"),
396
+ alias: Optional[str] = typer.Option(None, "--alias", "-a", help="Save this database connection under an alias profile name"),
397
+ provider: Optional[str] = typer.Option(None, "--provider", "-p", help="LLM provider: ollama, gemini, groq, openrouter"),
398
+ model: Optional[str] = typer.Option(None, "--model", "-m", help="Specific model name"),
399
+ ):
400
+ """Interactive database CLI — type questions in English, get results instantly. Auto-executes reads."""
401
+ print_banner()
402
+ db_url = resolve_db_url(db)
403
+
404
+ with console.status("[bold cyan]Connecting to database...[/bold cyan]"):
405
+ try:
406
+ connector = create_connector(db_url)
407
+ success, msg = connector.test_connection()
408
+ if not success:
409
+ print_error(msg)
410
+ raise typer.Exit(1)
411
+ if alias:
412
+ config_mgr.save_profile(name=alias, connection_url=db_url, description=f"Alias for {db_url}")
413
+ print_success(f"Saved connection profile as alias '[bold cyan]{alias}[/bold cyan]'!")
414
+ # Get table count for display
415
+ table_names = connector.get_table_names()
416
+ except Exception as e:
417
+ print_error(f"Connection failed: {str(e)}")
418
+ raise typer.Exit(1)
419
+
420
+ llm = get_llm_provider(provider_name=provider, model=model, config_mgr=config_mgr)
421
+
422
+ db_name = connector.engine.url.database or db_url
423
+ dialect = connector.engine.dialect.name
424
+ console.print(f"[bold green]Connected:[/bold green] {db_name} ({dialect}) - {len(table_names)} tables")
425
+ console.print(f"[bold cyan]AI:[/bold cyan] {llm.name}")
426
+ console.print("[dim]Type your question in plain English. Read queries auto-execute. Write queries ask for confirmation.[/dim]")
427
+ console.print("[dim]Commands: [bold]:tables[/bold] | [bold]:table <name>[/bold] | [bold]:explain[/bold] | [bold]:history[/bold] | [bold]:auto[/bold]/[bold]:noauto[/bold] | [bold]:run[/bold] | [bold]:export <file>[/bold] | [bold]:exit[/bold][/dim]\n")
428
+
429
+ history_file = config_mgr.config_dir / "chat_history.txt"
430
+ session = PromptSession(history=FileHistory(str(history_file)))
431
+
432
+ auto_execute = True
433
+ query_history: List[PipelineResult] = []
434
+ last_result: Optional[PipelineResult] = None
435
+
436
+ while True:
437
+ try:
438
+ user_input = session.prompt("db-agent> ").strip()
439
+ if not user_input:
440
+ continue
441
+
442
+ # --- REPL commands ---
443
+ if user_input.lower() in [":exit", ":quit", "exit", "quit", "q"]:
444
+ console.print("[bold cyan]Goodbye![/bold cyan]")
445
+ break
446
+
447
+ if user_input.lower() in [":tables", ":schema", "\\d", "\\dt"]:
448
+ with console.status("[bold cyan]Fetching schema...[/bold cyan]"):
449
+ schema = connector.inspect_schema(include_samples=False, max_samples=0, include_row_counts=False)
450
+ SchemaViewer.display_schema_summary(schema)
451
+ continue
452
+
453
+ if user_input.startswith(":table "):
454
+ tname = user_input.split(" ", 1)[1].strip()
455
+ with console.status(f"[bold cyan]Inspecting {tname}...[/bold cyan]"):
456
+ on_demand_schema = connector.inspect_schema(table_names=[tname], include_samples=True)
457
+ tmodel = on_demand_schema.get_table(tname)
458
+ if tmodel:
459
+ SchemaViewer.display_table_detail(tmodel)
460
+ else:
461
+ print_error(f"Table '{tname}' not found.")
462
+ continue
463
+
464
+ if user_input.lower() == ":explain":
465
+ if last_result and last_result.sql:
466
+ print_code(last_result.sql, "sql", "Last Executed SQL")
467
+ console.print(f"[dim]Query type: {last_result.query_type} | Intent: {last_result.intent} | Retries: {last_result.retries}[/dim]")
468
+ if last_result.exact_tables:
469
+ console.print(f"[dim]Tables (exact): {', '.join(last_result.exact_tables)}[/dim]")
470
+ if last_result.fuzzy_tables:
471
+ console.print(f"[dim]Tables (fuzzy): {', '.join(last_result.fuzzy_tables)}[/dim]")
472
+ else:
473
+ print_warning("No previous query to explain.")
474
+ continue
475
+
476
+ if user_input.lower() == ":history":
477
+ if not query_history:
478
+ print_warning("No query history in this session.")
479
+ continue
480
+ for i, r in enumerate(query_history[-10:], 1):
481
+ status = "[green]OK[/green]" if r.success else "[red]FAIL[/red]"
482
+ sql_preview = (r.sql[:80] + "...") if len(r.sql) > 80 else r.sql
483
+ console.print(f" {i}. {status} [{r.query_type}] {sql_preview}")
484
+ continue
485
+
486
+ if user_input.lower() == ":auto":
487
+ auto_execute = True
488
+ print_success("Auto-execute enabled. Read queries will run automatically.")
489
+ continue
490
+
491
+ if user_input.lower() == ":noauto":
492
+ auto_execute = False
493
+ print_info("Auto-execute disabled. All queries will show SQL only. Use :run to execute.")
494
+ continue
495
+
496
+ if user_input.lower() == ":run":
497
+ if not last_result or not last_result.sql:
498
+ print_warning("No generated SQL query to run.")
499
+ continue
500
+ if last_result.was_executed:
501
+ # Re-run the last query
502
+ console.print("[dim]Re-executing last query...[/dim]")
503
+ cols, rows, err = connector.execute_query(last_result.sql, limit=100)
504
+ if err:
505
+ print_error(f"Execution error: {err}")
506
+ else:
507
+ print_results_table(cols, rows)
508
+ continue
509
+
510
+ if user_input.startswith(":export "):
511
+ file_target = user_input.split(" ", 1)[1].strip()
512
+ if not last_result or not last_result.sql:
513
+ print_warning("No code block to export.")
514
+ continue
515
+ p = Path(file_target)
516
+ p.parent.mkdir(parents=True, exist_ok=True)
517
+ p.write_text(last_result.sql, encoding="utf-8")
518
+ print_success(f"SQL saved to {p}")
519
+ continue
520
+
521
+ # --- Natural language query via pipeline ---
522
+ pipeline = QueryPipeline(
523
+ connector=connector,
524
+ llm=llm,
525
+ model=model,
526
+ auto_execute=auto_execute,
527
+ confirm_callback=_confirm_callback,
528
+ choice_callback=_choice_callback,
529
+ )
530
+
531
+ with console.status("[bold cyan]Thinking...[/bold cyan]"):
532
+ result = pipeline.run(user_prompt=user_input)
533
+
534
+ _display_pipeline_result(result, show_sql=True)
535
+
536
+ last_result = result
537
+ query_history.append(result)
538
+
539
+ except (KeyboardInterrupt, EOFError):
540
+ console.print("\n[bold cyan]Session closed.[/bold cyan]")
541
+ break
542
+ except Exception as e:
543
+ print_error(f"Error: {str(e)}")
544
+
545
+ connector.close()
546
+
547
+
548
+ # ---- run (direct SQL execution) ----
549
+
550
+ @app.command(name="run")
551
+ def run_command(
552
+ query: str = typer.Argument(..., help="SQL query to execute directly"),
553
+ db: Optional[str] = typer.Option(None, "--db", "-d", help="Database connection URL or profile name"),
554
+ limit: int = typer.Option(50, "--limit", "-l", help="Maximum rows to fetch"),
555
+ force: bool = typer.Option(False, "--force", "-f", help="Bypass safety confirmation"),
556
+ ):
557
+ """Directly execute a SQL query on the target database and format output as a rich table."""
558
+ db_url = resolve_db_url(db)
559
+ connector = create_connector(db_url)
560
+
561
+ is_safe, warnings = ScriptValidator.analyze_safety(query)
562
+ if not is_safe and not force:
563
+ for w in warnings:
564
+ print_warning(w)
565
+ if not Confirm.ask("Are you sure you want to execute this potentially modifying query?"):
566
+ console.print("[dim]Execution cancelled.[/dim]")
567
+ connector.close()
568
+ return
569
+
570
+ with console.status("[bold cyan]Executing query...[/bold cyan]"):
571
+ cols, rows, err = connector.execute_query(query, limit=limit)
572
+
573
+ connector.close()
574
+
575
+ if err:
576
+ print_error(f"Execution failed: {err}")
577
+ raise typer.Exit(1)
578
+
579
+ print_results_table(cols, rows)
580
+
581
+
582
+ # ---- config ----
583
+
584
+ @app.command(name="config")
585
+ def config_command():
586
+ """Interactive wizard to configure AI providers and database connection profiles."""
587
+ print_banner()
588
+ console.print("[bold cyan]DB-Agent Configuration Wizard[/bold cyan]\n")
589
+
590
+ curr_provider = config_mgr.get_setting("default_provider", "ollama")
591
+ console.print(f"Current Default AI Provider: [bold green]{curr_provider}[/bold green]")
592
+ provider_choice = Prompt.ask(
593
+ "Select Default AI Provider",
594
+ choices=["ollama", "gemini", "groq", "openrouter", "keep"],
595
+ default="keep",
596
+ )
597
+ if provider_choice != "keep":
598
+ config_mgr.set_setting("default_provider", provider_choice)
599
+ print_success(f"Default provider updated to [bold]{provider_choice}[/bold]")
600
+
601
+ if provider_choice == "gemini" or Prompt.ask("Configure Google Gemini API key (Free Tier)?", choices=["y", "n"], default="n") == "y":
602
+ key = Prompt.ask("Enter GEMINI_API_KEY (leave blank to skip)", default="")
603
+ if key:
604
+ config_mgr.set_setting("gemini_api_key", key)
605
+ print_success("Gemini API key saved.")
606
+
607
+ if provider_choice == "groq" or Prompt.ask("Configure Groq API key (Free Tier)?", choices=["y", "n"], default="n") == "y":
608
+ key = Prompt.ask("Enter GROQ_API_KEY (leave blank to skip)", default="")
609
+ if key:
610
+ config_mgr.set_setting("groq_api_key", key)
611
+ print_success("Groq API key saved.")
612
+
613
+ if provider_choice == "openrouter" or Prompt.ask("Configure OpenRouter API key (Free Tier)?", choices=["y", "n"], default="n") == "y":
614
+ key = Prompt.ask("Enter OPENROUTER_API_KEY (leave blank to skip)", default="")
615
+ if key:
616
+ config_mgr.set_setting("openrouter_api_key", key)
617
+ print_success("OpenRouter API key saved.")
618
+
619
+ if Prompt.ask("\nAdd a new database connection profile?", choices=["y", "n"], default="n") == "y":
620
+ p_name = Prompt.ask("Profile Name (e.g. prod_pg, staging, local_dev)")
621
+ p_url = Prompt.ask("Connection URL (e.g. postgresql://user:pass@localhost:5432/dbname)")
622
+ p_desc = Prompt.ask("Description", default=f"{p_name} database")
623
+ config_mgr.save_profile(name=p_name, connection_url=p_url, description=p_desc)
624
+ print_success(f"Profile '[bold]{p_name}[/bold]' saved successfully!")
625
+
626
+
627
+ # ---- profiles ----
628
+
629
+ @app.command(name="profiles")
630
+ def profiles_command():
631
+ """List all saved database connection profiles."""
632
+ profiles = config_mgr.list_profiles()
633
+ if not profiles:
634
+ console.print("[dim]No database profiles saved yet. Run [bold]db-agent config[/bold] to add one.[/dim]")
635
+ return
636
+
637
+ table = Table(title="[Profiles] Saved Database Profiles", show_header=True, header_style="bold cyan", border_style="dim")
638
+ table.add_column("Profile Name", style="bold white")
639
+ table.add_column("Connection URL", style="cyan")
640
+ table.add_column("Description", style="dim")
641
+
642
+ for name, p in profiles.items():
643
+ table.add_row(name, p["url"], p.get("description", ""))
644
+
645
+ console.print(table)
646
+
647
+
648
+ # ---- models ----
649
+
650
+ @app.command(name="models")
651
+ def models_command():
652
+ """List available models across local Ollama and free cloud providers."""
653
+ print_banner()
654
+ console.print("[bold cyan]Supported Free & Standalone LLMs:[/bold cyan]\n")
655
+
656
+ ollama = get_llm_provider("ollama")
657
+ ollama_status = "[green]ONLINE (Ready)[/green]" if ollama.is_available() else "[red]OFFLINE (Run 'ollama serve')[/red]"
658
+ console.print(f"1. [bold white]Ollama (100% Local, Offline & Zero-Cost)[/bold white] - Status: {ollama_status}")
659
+ if ollama.is_available():
660
+ models = ollama.list_models()
661
+ console.print(f" Installed Local Models: {', '.join(models) if models else 'None'}")
662
+ else:
663
+ console.print(" Recommended models: `ollama run qwen2.5-coder` or `ollama run llama3.2`")
664
+
665
+ gemini = get_llm_provider("gemini")
666
+ gemini_status = "[green]Configured[/green]" if gemini.is_available() else "[yellow]Not Configured (Free Key: aistudio.google.com)[/yellow]"
667
+ console.print(f"\n2. [bold white]Google Gemini (Free Tier)[/bold white] - Status: {gemini_status}")
668
+ console.print(" Available Models: `gemini-2.0-flash`, `gemini-1.5-flash`, `gemini-1.5-pro`")
669
+
670
+ groq = get_llm_provider("groq")
671
+ groq_status = "[green]Configured[/green]" if groq.is_available() else "[yellow]Not Configured (Free Key: console.groq.com)[/yellow]"
672
+ console.print(f"\n3. [bold white]Groq (Free Ultra-Fast Tier)[/bold white] - Status: {groq_status}")
673
+ console.print(" Available Models: `llama-3.3-70b-versatile`, `qwen-2.5-32b`")
674
+
675
+ openrouter = get_llm_provider("openrouter")
676
+ openrouter_status = "[green]Configured[/green]" if openrouter.is_available() else "[yellow]Not Configured (openrouter.ai)[/yellow]"
677
+ console.print(f"\n4. [bold white]OpenRouter (Free Community Models)[/bold white] - Status: {openrouter_status}")
678
+ console.print(" Available Models: `meta-llama/llama-3.3-70b-instruct:free`, `qwen/qwen-2.5-coder-32b-instruct:free`\n")
679
+
680
+
681
+ # ---- setup ----
682
+
683
+ @app.command(name="setup")
684
+ def setup_command(
685
+ model: str = typer.Option("qwen2.5-coder:1.5b", "--model", "-m", help="Ollama model to install"),
686
+ ):
687
+ """Automatically download, install, start, and configure Ollama for 100% standalone offline AI."""
688
+ print_banner()
689
+ console.print("[bold cyan]DB-Agent Standalone AI Engine Setup[/bold cyan]\n")
690
+ console.print("[dim]This will ensure Ollama is installed, running, and has a local code model ready.[/dim]\n")
691
+
692
+ from dbagent.llm.auto_setup import ensure_ollama_ready
693
+
694
+ def _log(msg, level="info"):
695
+ if level == "success":
696
+ print_success(msg)
697
+ elif level == "warning":
698
+ print_warning(msg)
699
+ elif level == "error":
700
+ print_error(msg)
701
+ elif level == "progress":
702
+ console.print(f"[dim]{msg}[/dim]")
703
+ else:
704
+ print_info(msg)
705
+
706
+ success, message = ensure_ollama_ready(model=model, print_fn=_log)
707
+ if success:
708
+ config_mgr.set_setting("default_provider", "ollama")
709
+ print_success(message)
710
+ console.print("\n[bold green]You're all set![/bold green] DB-Agent is now 100% standalone and ready to run queries.\n")
711
+ else:
712
+ print_error(message)
713
+ raise typer.Exit(1)
714
+
715
+
716
+ if __name__ == "__main__":
717
+ app()