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/__init__.py +5 -0
- dbagent/agent/generator.py +264 -0
- dbagent/agent/pipeline.py +259 -0
- dbagent/agent/validator.py +116 -0
- dbagent/cli.py +717 -0
- dbagent/config.py +89 -0
- dbagent/connectors/base.py +75 -0
- dbagent/connectors/factory.py +25 -0
- dbagent/connectors/mongo.py +237 -0
- dbagent/connectors/relational.py +472 -0
- dbagent/llm/auto_setup.py +269 -0
- dbagent/llm/base.py +37 -0
- dbagent/llm/factory.py +73 -0
- dbagent/llm/gemini_provider.py +110 -0
- dbagent/llm/groq_provider.py +99 -0
- dbagent/llm/mock_provider.py +42 -0
- dbagent/llm/ollama_provider.py +145 -0
- dbagent/llm/openrouter_provider.py +100 -0
- dbagent/schema/formatter.py +123 -0
- dbagent/schema/models.py +72 -0
- dbagent/schema/selector.py +70 -0
- dbagent/ui/console.py +77 -0
- dbagent/ui/viewer.py +93 -0
- dbagent_cli-0.1.0.dist-info/METADATA +242 -0
- dbagent_cli-0.1.0.dist-info/RECORD +28 -0
- dbagent_cli-0.1.0.dist-info/WHEEL +5 -0
- dbagent_cli-0.1.0.dist-info/entry_points.txt +3 -0
- dbagent_cli-0.1.0.dist-info/top_level.txt +1 -0
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()
|