sqlsaber 0.26.0__tar.gz → 0.28.0__tar.gz

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.

Potentially problematic release.


This version of sqlsaber might be problematic. Click here for more details.

Files changed (126) hide show
  1. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/PKG-INFO +2 -1
  2. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/changelog.md +30 -0
  3. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/pyproject.toml +2 -1
  4. sqlsaber-0.28.0/src/sqlsaber/application/__init__.py +1 -0
  5. sqlsaber-0.28.0/src/sqlsaber/application/auth_setup.py +164 -0
  6. sqlsaber-0.28.0/src/sqlsaber/application/db_setup.py +222 -0
  7. sqlsaber-0.28.0/src/sqlsaber/application/model_selection.py +98 -0
  8. sqlsaber-0.28.0/src/sqlsaber/application/prompts.py +115 -0
  9. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/auth.py +24 -52
  10. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/commands.py +13 -2
  11. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/database.py +26 -87
  12. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/display.py +59 -40
  13. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/interactive.py +138 -131
  14. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/memory.py +2 -2
  15. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/models.py +20 -30
  16. sqlsaber-0.28.0/src/sqlsaber/cli/onboarding.py +325 -0
  17. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/streaming.py +1 -1
  18. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/threads.py +35 -16
  19. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/config/api_keys.py +4 -4
  20. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/config/oauth_flow.py +3 -2
  21. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/config/oauth_tokens.py +3 -5
  22. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/database/base.py +6 -0
  23. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/database/csv.py +5 -0
  24. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/database/duckdb.py +5 -0
  25. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/database/mysql.py +5 -0
  26. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/database/postgresql.py +5 -0
  27. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/database/sqlite.py +5 -0
  28. sqlsaber-0.28.0/src/sqlsaber/theme/__init__.py +5 -0
  29. sqlsaber-0.28.0/src/sqlsaber/theme/manager.py +219 -0
  30. sqlsaber-0.28.0/src/sqlsaber/tools/sql_guard.py +225 -0
  31. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/tools/sql_tools.py +10 -35
  32. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_cli/test_threads.py +1 -1
  33. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_database/test_schema_display.py +3 -3
  34. sqlsaber-0.28.0/tests/test_tools/test_sql_guard.py +314 -0
  35. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_tools/test_sql_tools.py +1 -1
  36. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/uv.lock +45 -1
  37. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/.github/workflows/claude-code-review.yml +0 -0
  38. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/.github/workflows/claude.yml +0 -0
  39. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/.github/workflows/deploy-docs.yml +0 -0
  40. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/.github/workflows/publish.yml +0 -0
  41. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/.github/workflows/test.yml +0 -0
  42. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/.gitignore +0 -0
  43. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/.python-version +0 -0
  44. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/AGENT.md +0 -0
  45. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/CLAUDE.md +0 -0
  46. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/LICENSE +0 -0
  47. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/README.md +0 -0
  48. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/.gitignore +0 -0
  49. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/.vscode/extensions.json +0 -0
  50. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/.vscode/launch.json +0 -0
  51. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/CLAUDE.md +0 -0
  52. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/astro.config.mjs +0 -0
  53. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/package-lock.json +0 -0
  54. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/package.json +0 -0
  55. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/public/CNAME +0 -0
  56. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/public/favicon.svg +0 -0
  57. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/assets/sqlsaber-hero.svg +0 -0
  58. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/guides/authentication.mdx +0 -0
  59. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/guides/database-setup.mdx +0 -0
  60. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/guides/getting-started.mdx +0 -0
  61. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/guides/memory.mdx +0 -0
  62. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/guides/models.mdx +0 -0
  63. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/guides/queries.mdx +0 -0
  64. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/guides/threads.md +0 -0
  65. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/index.mdx +0 -0
  66. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/installation.mdx +0 -0
  67. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/reference/commands.md +0 -0
  68. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content.config.ts +0 -0
  69. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/styles/global.css +0 -0
  70. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/tsconfig.json +0 -0
  71. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/legislators.db +0 -0
  72. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/pytest.ini +0 -0
  73. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/sqlsaber.gif +0 -0
  74. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/sqlsaber.svg +0 -0
  75. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/__init__.py +0 -0
  76. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/__main__.py +0 -0
  77. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/agents/__init__.py +0 -0
  78. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/agents/base.py +0 -0
  79. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/agents/mcp.py +0 -0
  80. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/agents/pydantic_ai_agent.py +0 -0
  81. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/__init__.py +0 -0
  82. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/completers.py +0 -0
  83. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/config/__init__.py +0 -0
  84. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/config/auth.py +0 -0
  85. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/config/database.py +0 -0
  86. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/config/providers.py +0 -0
  87. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/config/settings.py +0 -0
  88. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/database/__init__.py +0 -0
  89. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/database/resolver.py +0 -0
  90. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/database/schema.py +0 -0
  91. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/mcp/__init__.py +0 -0
  92. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/mcp/mcp.py +0 -0
  93. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/memory/__init__.py +0 -0
  94. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/memory/manager.py +0 -0
  95. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/memory/storage.py +0 -0
  96. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/threads/__init__.py +0 -0
  97. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/threads/storage.py +0 -0
  98. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/tools/__init__.py +0 -0
  99. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/tools/base.py +0 -0
  100. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/tools/enums.py +0 -0
  101. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/tools/instructions.py +0 -0
  102. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/tools/registry.py +0 -0
  103. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/__init__.py +0 -0
  104. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/conftest.py +0 -0
  105. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_cli/__init__.py +0 -0
  106. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_cli/test_auth_reset.py +0 -0
  107. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_cli/test_commands.py +0 -0
  108. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_config/__init__.py +0 -0
  109. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_config/test_database.py +0 -0
  110. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_config/test_oauth.py +0 -0
  111. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_config/test_providers.py +0 -0
  112. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_config/test_settings.py +0 -0
  113. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_database/__init__.py +0 -0
  114. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_database/test_connection.py +0 -0
  115. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_database/test_csv_connection.py +0 -0
  116. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_database/test_csv_module.py +0 -0
  117. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_database/test_duckdb_module.py +0 -0
  118. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_database/test_schema.py +0 -0
  119. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_database/test_sqlite_module.py +0 -0
  120. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_database/test_timeout.py +0 -0
  121. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_database_resolver.py +0 -0
  122. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_threads_storage.py +0 -0
  123. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_tools/__init__.py +0 -0
  124. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_tools/test_base.py +0 -0
  125. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_tools/test_instructions.py +0 -0
  126. {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_tools/test_registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlsaber
3
- Version: 0.26.0
3
+ Version: 0.28.0
4
4
  Summary: SQLsaber - Open-source agentic SQL assistant
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -17,6 +17,7 @@ Requires-Dist: prompt-toolkit>3.0.51
17
17
  Requires-Dist: pydantic-ai
18
18
  Requires-Dist: questionary>=2.1.0
19
19
  Requires-Dist: rich>=13.7.0
20
+ Requires-Dist: sqlglot[rs]>=27.20.0
20
21
  Description-Content-Type: text/markdown
21
22
 
22
23
  # SQLsaber
@@ -7,6 +7,36 @@ All notable changes to SQLsaber will be documented here.
7
7
 
8
8
  ### Unreleased
9
9
 
10
+ ### v0.28.0 - 2025-10-03
11
+
12
+ #### Added
13
+
14
+ - Unified theming system
15
+ - 7 built-in themes with exact Pygments color matching
16
+ - Dark themes: `nord` (default), `dracula`, `one-dark`, `material`, `lightbulb`
17
+ - Light themes: `solarized-light`, `vs`
18
+ - Easy theme switching via `SQLSABER_THEME` environment variable or config file
19
+
20
+ #### Changed
21
+
22
+ - Enhanced read-only query validation using `sqlglot` AST analysis
23
+ - Improved security with comprehensive AST-based detection of write operations
24
+ - Blocks dangerous operations in nested queries, CTEs, and subqueries
25
+ - Detects dialect-specific dangerous functions (pg_read_file, LOAD_FILE, readfile, etc.)
26
+ - Prevents SELECT INTO, SELECT FOR UPDATE/SHARE, and multi-statement queries
27
+ - Dialect-aware LIMIT clause injection for Postgres, MySQL, SQLite, and DuckDB
28
+
29
+ ### v0.27.0 - 2025-10-01
30
+
31
+ #### Added
32
+
33
+ - Added onboarding flow for new users
34
+ - If users don't have a database set up or model provider API key setup, SQLsaber will guide them through the process interactively in a frictionless and delightful manner.
35
+
36
+ #### Changed
37
+
38
+ - Final thinking blocks now render Markdown
39
+
10
40
  ### v0.26.0 - 2025-09-30
11
41
 
12
42
  #### Added
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlsaber"
3
- version = "0.26.0"
3
+ version = "0.28.0"
4
4
  description = "SQLsaber - Open-source agentic SQL assistant"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -18,6 +18,7 @@ dependencies = [
18
18
  "fastmcp>=2.9.0",
19
19
  "cyclopts>=3.22.1",
20
20
  "prompt-toolkit>3.0.51",
21
+ "sqlglot[rs]>=27.20.0",
21
22
  ]
22
23
 
23
24
  [tool.uv]
@@ -0,0 +1 @@
1
+ """Application layer for SQLsaber - shared business logic and interactive flows."""
@@ -0,0 +1,164 @@
1
+ """Shared auth setup logic for onboarding and CLI."""
2
+
3
+ import asyncio
4
+
5
+ from questionary import Choice
6
+
7
+ from sqlsaber.application.prompts import Prompter
8
+ from sqlsaber.config import providers
9
+ from sqlsaber.config.api_keys import APIKeyManager
10
+ from sqlsaber.config.auth import AuthConfigManager, AuthMethod
11
+ from sqlsaber.config.oauth_flow import AnthropicOAuthFlow
12
+ from sqlsaber.theme.manager import create_console
13
+
14
+ console = create_console()
15
+
16
+
17
+ async def select_provider(prompter: Prompter, default: str = "anthropic") -> str | None:
18
+ """Interactive provider selection.
19
+
20
+ Args:
21
+ prompter: Prompter instance for interaction
22
+ default: Default provider to select
23
+
24
+ Returns:
25
+ Selected provider name or None if cancelled
26
+ """
27
+ provider = await prompter.select(
28
+ "Select AI provider:", choices=providers.all_keys(), default=default
29
+ )
30
+ return provider
31
+
32
+
33
+ async def configure_oauth_anthropic(
34
+ auth_manager: AuthConfigManager, run_in_thread: bool = False
35
+ ) -> bool:
36
+ """Configure Anthropic OAuth.
37
+
38
+ Args:
39
+ auth_manager: AuthConfigManager instance
40
+ run_in_thread: Whether to run OAuth flow in a separate thread (for onboarding)
41
+
42
+ Returns:
43
+ True if OAuth configured successfully, False otherwise
44
+ """
45
+ flow = AnthropicOAuthFlow()
46
+
47
+ if run_in_thread:
48
+ # Run in thread to avoid event loop conflicts (onboarding)
49
+ oauth_success = await asyncio.to_thread(flow.authenticate)
50
+ else:
51
+ # Run directly (CLI)
52
+ oauth_success = flow.authenticate()
53
+
54
+ if oauth_success:
55
+ auth_manager.set_auth_method(AuthMethod.CLAUDE_PRO)
56
+ return True
57
+
58
+ return False
59
+
60
+
61
+ async def configure_api_key(
62
+ provider: str, api_key_manager: APIKeyManager, auth_manager: AuthConfigManager
63
+ ) -> bool:
64
+ """Configure API key for a provider.
65
+
66
+ Args:
67
+ provider: Provider name
68
+ api_key_manager: APIKeyManager instance
69
+ auth_manager: AuthConfigManager instance
70
+
71
+ Returns:
72
+ True if API key configured successfully, False otherwise
73
+ """
74
+ # Get API key (cascades env -> keyring -> prompt)
75
+ api_key = api_key_manager.get_api_key(provider)
76
+
77
+ if api_key:
78
+ auth_manager.set_auth_method(AuthMethod.API_KEY)
79
+ return True
80
+
81
+ return False
82
+
83
+
84
+ async def setup_auth(
85
+ prompter: Prompter,
86
+ auth_manager: AuthConfigManager,
87
+ api_key_manager: APIKeyManager,
88
+ allow_oauth: bool = True,
89
+ default_provider: str = "anthropic",
90
+ run_oauth_in_thread: bool = False,
91
+ ) -> tuple[bool, str | None]:
92
+ """Interactive authentication setup.
93
+
94
+ Args:
95
+ prompter: Prompter instance for interaction
96
+ auth_manager: AuthConfigManager instance
97
+ api_key_manager: APIKeyManager instance
98
+ allow_oauth: Whether to offer OAuth option for Anthropic
99
+ default_provider: Default provider to select
100
+ run_oauth_in_thread: Whether to run OAuth in thread (for onboarding)
101
+
102
+ Returns:
103
+ Tuple of (success: bool, provider: str | None)
104
+ """
105
+ # Check if auth is already configured
106
+ if auth_manager.has_auth_configured():
107
+ console.print("[green]✓ Authentication already configured![/green]")
108
+ return True, None
109
+
110
+ # Select provider
111
+ provider = await select_provider(prompter, default=default_provider)
112
+
113
+ if provider is None:
114
+ return False, None
115
+
116
+ # For Anthropic, offer OAuth or API key
117
+ if provider == "anthropic" and allow_oauth:
118
+ method_choice = await prompter.select(
119
+ "Authentication method:",
120
+ choices=[
121
+ Choice("API Key", value=AuthMethod.API_KEY),
122
+ Choice("Claude Pro/Max (OAuth)", value=AuthMethod.CLAUDE_PRO),
123
+ ],
124
+ )
125
+
126
+ if method_choice is None:
127
+ return False, None
128
+
129
+ if method_choice == AuthMethod.CLAUDE_PRO:
130
+ console.print()
131
+ oauth_success = await configure_oauth_anthropic(
132
+ auth_manager, run_in_thread=run_oauth_in_thread
133
+ )
134
+ if oauth_success:
135
+ console.print(
136
+ "[green]✓ Anthropic OAuth configured successfully![/green]"
137
+ )
138
+ return True, provider
139
+ else:
140
+ console.print("[red]✗ Anthropic OAuth setup failed.[/red]")
141
+ return False, None
142
+
143
+ # API key flow
144
+ env_var = api_key_manager.get_env_var_name(provider)
145
+
146
+ console.print()
147
+ console.print(f"[dim]To use {provider.title()}, you need an API key.[/dim]")
148
+ console.print(f"[dim]You can set the {env_var} environment variable,[/dim]")
149
+ console.print("[dim]or enter it now to store securely in your OS keychain.[/dim]")
150
+ console.print()
151
+
152
+ # Configure API key
153
+ api_key_configured = await configure_api_key(
154
+ provider, api_key_manager, auth_manager
155
+ )
156
+
157
+ if api_key_configured:
158
+ console.print(
159
+ f"[green]✓ {provider.title()} API key configured successfully![/green]"
160
+ )
161
+ return True, provider
162
+ else:
163
+ console.print("[yellow]No API key provided.[/yellow]")
164
+ return False, None
@@ -0,0 +1,222 @@
1
+ """Shared database setup logic for onboarding and CLI."""
2
+
3
+ import getpass
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from sqlsaber.application.prompts import Prompter
8
+ from sqlsaber.config.database import DatabaseConfig, DatabaseConfigManager
9
+ from sqlsaber.theme.manager import create_console
10
+
11
+ console = create_console()
12
+
13
+
14
+ @dataclass
15
+ class DatabaseInput:
16
+ """Input data for database configuration."""
17
+
18
+ name: str
19
+ type: str
20
+ host: str
21
+ port: int
22
+ database: str
23
+ username: str
24
+ password: str | None
25
+ ssl_mode: str | None = None
26
+ ssl_ca: str | None = None
27
+ ssl_cert: str | None = None
28
+ ssl_key: str | None = None
29
+
30
+
31
+ async def collect_db_input(
32
+ prompter: Prompter,
33
+ name: str,
34
+ db_type: str = "postgresql",
35
+ include_ssl: bool = True,
36
+ ) -> DatabaseInput | None:
37
+ """Collect database connection details interactively.
38
+
39
+ Args:
40
+ prompter: Prompter instance for interaction
41
+ name: Database connection name
42
+ db_type: Initial database type (can be changed via prompt)
43
+ include_ssl: Whether to prompt for SSL configuration
44
+
45
+ Returns:
46
+ DatabaseInput with collected values or None if cancelled
47
+ """
48
+ # Ask for database type
49
+ db_type = await prompter.select(
50
+ "Database type:",
51
+ choices=["postgresql", "mysql", "sqlite", "duckdb"],
52
+ default=db_type,
53
+ )
54
+
55
+ if db_type is None:
56
+ return None
57
+
58
+ # Handle file-based databases
59
+ if db_type in {"sqlite", "duckdb"}:
60
+ database_path = await prompter.path(
61
+ f"{db_type.upper()} file path:", only_directories=False
62
+ )
63
+
64
+ if database_path is None:
65
+ return None
66
+
67
+ database = str(Path(database_path).expanduser().resolve())
68
+ host = "localhost"
69
+ port = 0
70
+ username = db_type
71
+ password = ""
72
+ ssl_mode = None
73
+ ssl_ca = None
74
+ ssl_cert = None
75
+ ssl_key = None
76
+
77
+ else:
78
+ # PostgreSQL/MySQL need connection details
79
+ host = await prompter.text("Host:", default="localhost")
80
+ if host is None:
81
+ return None
82
+
83
+ default_port = 5432 if db_type == "postgresql" else 3306
84
+ port_str = await prompter.text("Port:", default=str(default_port))
85
+ if port_str is None:
86
+ return None
87
+
88
+ try:
89
+ port = int(port_str)
90
+ except ValueError:
91
+ console.print("[red]Invalid port number. Using default.[/red]")
92
+ port = default_port
93
+
94
+ database = await prompter.text("Database name:")
95
+ if database is None:
96
+ return None
97
+
98
+ username = await prompter.text("Username:")
99
+ if username is None:
100
+ return None
101
+
102
+ password = getpass.getpass("Password (stored in your OS keychain): ")
103
+
104
+ ssl_mode = None
105
+ ssl_ca = None
106
+ ssl_cert = None
107
+ ssl_key = None
108
+
109
+ # Ask for SSL configuration if enabled
110
+ if include_ssl:
111
+ configure_ssl = await prompter.confirm(
112
+ "Configure SSL/TLS settings?", default=False
113
+ )
114
+ if configure_ssl:
115
+ if db_type == "postgresql":
116
+ ssl_mode = await prompter.select(
117
+ "SSL mode for PostgreSQL:",
118
+ choices=[
119
+ "disable",
120
+ "allow",
121
+ "prefer",
122
+ "require",
123
+ "verify-ca",
124
+ "verify-full",
125
+ ],
126
+ default="prefer",
127
+ )
128
+ elif db_type == "mysql":
129
+ ssl_mode = await prompter.select(
130
+ "SSL mode for MySQL:",
131
+ choices=[
132
+ "DISABLED",
133
+ "PREFERRED",
134
+ "REQUIRED",
135
+ "VERIFY_CA",
136
+ "VERIFY_IDENTITY",
137
+ ],
138
+ default="PREFERRED",
139
+ )
140
+
141
+ if ssl_mode and ssl_mode not in ["disable", "DISABLED"]:
142
+ specify_certs = await prompter.confirm(
143
+ "Specify SSL certificate files?", default=False
144
+ )
145
+ if specify_certs:
146
+ ssl_ca = await prompter.path("SSL CA certificate file:")
147
+ specify_client = await prompter.confirm(
148
+ "Specify client certificate?", default=False
149
+ )
150
+ if specify_client:
151
+ ssl_cert = await prompter.path(
152
+ "SSL client certificate file:"
153
+ )
154
+ ssl_key = await prompter.path(
155
+ "SSL client private key file:"
156
+ )
157
+
158
+ return DatabaseInput(
159
+ name=name,
160
+ type=db_type,
161
+ host=host,
162
+ port=port,
163
+ database=database,
164
+ username=username,
165
+ password=password,
166
+ ssl_mode=ssl_mode,
167
+ ssl_ca=ssl_ca,
168
+ ssl_cert=ssl_cert,
169
+ ssl_key=ssl_key,
170
+ )
171
+
172
+
173
+ def build_config(db_input: DatabaseInput) -> DatabaseConfig:
174
+ """Build DatabaseConfig from DatabaseInput."""
175
+ return DatabaseConfig(
176
+ name=db_input.name,
177
+ type=db_input.type,
178
+ host=db_input.host,
179
+ port=db_input.port,
180
+ database=db_input.database,
181
+ username=db_input.username,
182
+ ssl_mode=db_input.ssl_mode,
183
+ ssl_ca=db_input.ssl_ca,
184
+ ssl_cert=db_input.ssl_cert,
185
+ ssl_key=db_input.ssl_key,
186
+ )
187
+
188
+
189
+ async def test_connection(config: DatabaseConfig, password: str | None) -> bool:
190
+ """Test database connection.
191
+
192
+ Args:
193
+ config: DatabaseConfig to test
194
+ password: Password for connection (not stored in config yet)
195
+
196
+ Returns:
197
+ True if connection successful, False otherwise
198
+ """
199
+ from sqlsaber.database import DatabaseConnection
200
+
201
+ try:
202
+ connection_string = config.to_connection_string()
203
+ db_conn = DatabaseConnection(connection_string)
204
+ await db_conn.execute_query("SELECT 1 as test")
205
+ await db_conn.close()
206
+ return True
207
+ except Exception as e:
208
+ console.print(f"[bold red]Connection failed:[/bold red] {e}", style="red")
209
+ return False
210
+
211
+
212
+ def save_database(
213
+ config_manager: DatabaseConfigManager, config: DatabaseConfig, password: str | None
214
+ ) -> None:
215
+ """Save database configuration.
216
+
217
+ Args:
218
+ config_manager: DatabaseConfigManager instance
219
+ config: DatabaseConfig to save
220
+ password: Password to store in keyring (if provided)
221
+ """
222
+ config_manager.add_database(config, password if password else None)
@@ -0,0 +1,98 @@
1
+ """Shared model selection logic for onboarding and CLI."""
2
+
3
+ from questionary import Choice
4
+
5
+ from sqlsaber.application.prompts import Prompter
6
+ from sqlsaber.cli.models import ModelManager
7
+ from sqlsaber.theme.manager import create_console
8
+
9
+ console = create_console()
10
+
11
+
12
+ async def fetch_models(
13
+ model_manager: ModelManager, providers: list[str] | None = None
14
+ ) -> list[dict]:
15
+ """Fetch available models from models.dev API."""
16
+ return await model_manager.fetch_available_models(providers=providers)
17
+
18
+
19
+ async def choose_model(
20
+ prompter: Prompter,
21
+ models: list[dict],
22
+ restrict_provider: str | None = None,
23
+ use_search_filter: bool = True,
24
+ ) -> str | None:
25
+ """Interactive model selection with recommended models prioritized.
26
+
27
+ Args:
28
+ prompter: Prompter instance for interaction
29
+ models: List of model dicts from fetch_models
30
+ restrict_provider: If set, only show models from this provider and use provider-specific recommendation
31
+ use_search_filter: Enable search filter for large lists
32
+
33
+ Returns:
34
+ Selected model ID (provider:model_id) or None if cancelled
35
+ """
36
+ if not models:
37
+ console.print("[yellow]No models available[/yellow]")
38
+ return None
39
+
40
+ # Filter by provider if restricted
41
+ if restrict_provider:
42
+ models = [m for m in models if m.get("provider") == restrict_provider]
43
+ if not models:
44
+ console.print(
45
+ f"[yellow]No models available for {restrict_provider}[/yellow]"
46
+ )
47
+ return None
48
+
49
+ # Get recommended model for the provider
50
+ recommended_id = None
51
+ if restrict_provider and restrict_provider in ModelManager.RECOMMENDED_MODELS:
52
+ recommended_id = ModelManager.RECOMMENDED_MODELS[restrict_provider]
53
+
54
+ # Build choices
55
+ choices = []
56
+ recommended_index = 0
57
+
58
+ for i, model in enumerate(models):
59
+ model_id_without_provider = model["id"].split(":", 1)[1]
60
+ is_recommended = recommended_id == model_id_without_provider
61
+
62
+ choice_text = model["name"]
63
+ if is_recommended:
64
+ choice_text += " (Recommended)"
65
+ recommended_index = i
66
+ elif model["description"]:
67
+ desc_short = model["description"][:40]
68
+ choice_text += (
69
+ f" ({desc_short}...)"
70
+ if len(model["description"]) > 40
71
+ else f" ({desc_short})"
72
+ )
73
+
74
+ choices.append(Choice(choice_text, value=model["id"]))
75
+
76
+ # Move recommended model to top if it exists
77
+ if recommended_index > 0:
78
+ choices.insert(0, choices.pop(recommended_index))
79
+
80
+ # Prompt user
81
+ selected_model = await prompter.select(
82
+ "Select a model:",
83
+ choices=choices,
84
+ use_search_filter=use_search_filter,
85
+ )
86
+
87
+ if selected_model:
88
+ return selected_model
89
+
90
+ # User cancelled, return recommended or first available
91
+ if recommended_id and restrict_provider:
92
+ return f"{restrict_provider}:{recommended_id}"
93
+ return models[0]["id"] if models else None
94
+
95
+
96
+ def set_model(model_manager: ModelManager, model_id: str) -> bool:
97
+ """Set the current model."""
98
+ return model_manager.set_model(model_id)
@@ -0,0 +1,115 @@
1
+ """Prompter abstraction for sync/async questionary interactions."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any, Callable
5
+
6
+ import questionary
7
+ from questionary import Choice
8
+
9
+
10
+ class Prompter(ABC):
11
+ """Abstract base class for interactive prompting."""
12
+
13
+ @abstractmethod
14
+ async def text(
15
+ self,
16
+ message: str,
17
+ default: str = "",
18
+ validate: Callable[[str], bool | str] | None = None,
19
+ ) -> str | None:
20
+ """Prompt for text input."""
21
+ pass
22
+
23
+ @abstractmethod
24
+ async def select(
25
+ self,
26
+ message: str,
27
+ choices: list[str] | list[Choice] | list[dict],
28
+ default: Any = None,
29
+ use_search_filter: bool = False,
30
+ use_jk_keys: bool = True,
31
+ ) -> Any:
32
+ """Prompt for selection from choices."""
33
+ pass
34
+
35
+ @abstractmethod
36
+ async def confirm(self, message: str, default: bool = False) -> bool | None:
37
+ """Prompt for yes/no confirmation."""
38
+ pass
39
+
40
+ @abstractmethod
41
+ async def path(self, message: str, only_directories: bool = False) -> str | None:
42
+ """Prompt for file/directory path."""
43
+ pass
44
+
45
+
46
+ class AsyncPrompter(Prompter):
47
+ """Async prompter using questionary.ask_async() for onboarding."""
48
+
49
+ async def text(
50
+ self,
51
+ message: str,
52
+ default: str = "",
53
+ validate: Callable[[str], bool | str] | None = None,
54
+ ) -> str | None:
55
+ return await questionary.text(
56
+ message, default=default, validate=validate
57
+ ).ask_async()
58
+
59
+ async def select(
60
+ self,
61
+ message: str,
62
+ choices: list[str] | list[Choice] | list[dict],
63
+ default: Any = None,
64
+ use_search_filter: bool = True,
65
+ use_jk_keys: bool = False,
66
+ ) -> Any:
67
+ return await questionary.select(
68
+ message,
69
+ choices=choices,
70
+ default=default,
71
+ use_search_filter=use_search_filter,
72
+ use_jk_keys=use_jk_keys,
73
+ ).ask_async()
74
+
75
+ async def confirm(self, message: str, default: bool = False) -> bool | None:
76
+ return await questionary.confirm(message, default=default).ask_async()
77
+
78
+ async def path(self, message: str, only_directories: bool = False) -> str | None:
79
+ return await questionary.path(
80
+ message, only_directories=only_directories
81
+ ).ask_async()
82
+
83
+
84
+ class SyncPrompter(Prompter):
85
+ """Sync prompter using questionary.ask() for CLI commands."""
86
+
87
+ async def text(
88
+ self,
89
+ message: str,
90
+ default: str = "",
91
+ validate: Callable[[str], bool | str] | None = None,
92
+ ) -> str | None:
93
+ return questionary.text(message, default=default, validate=validate).ask()
94
+
95
+ async def select(
96
+ self,
97
+ message: str,
98
+ choices: list[str] | list[Choice] | list[dict],
99
+ default: Any = None,
100
+ use_search_filter: bool = True,
101
+ use_jk_keys: bool = False,
102
+ ) -> Any:
103
+ return questionary.select(
104
+ message,
105
+ choices=choices,
106
+ default=default,
107
+ use_search_filter=use_search_filter,
108
+ use_jk_keys=use_jk_keys,
109
+ ).ask()
110
+
111
+ async def confirm(self, message: str, default: bool = False) -> bool | None:
112
+ return questionary.confirm(message, default=default).ask()
113
+
114
+ async def path(self, message: str, only_directories: bool = False) -> str | None:
115
+ return questionary.path(message, only_directories=only_directories).ask()