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.
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/PKG-INFO +2 -1
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/changelog.md +30 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/pyproject.toml +2 -1
- sqlsaber-0.28.0/src/sqlsaber/application/__init__.py +1 -0
- sqlsaber-0.28.0/src/sqlsaber/application/auth_setup.py +164 -0
- sqlsaber-0.28.0/src/sqlsaber/application/db_setup.py +222 -0
- sqlsaber-0.28.0/src/sqlsaber/application/model_selection.py +98 -0
- sqlsaber-0.28.0/src/sqlsaber/application/prompts.py +115 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/auth.py +24 -52
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/commands.py +13 -2
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/database.py +26 -87
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/display.py +59 -40
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/interactive.py +138 -131
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/memory.py +2 -2
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/models.py +20 -30
- sqlsaber-0.28.0/src/sqlsaber/cli/onboarding.py +325 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/streaming.py +1 -1
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/threads.py +35 -16
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/config/api_keys.py +4 -4
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/config/oauth_flow.py +3 -2
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/config/oauth_tokens.py +3 -5
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/database/base.py +6 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/database/csv.py +5 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/database/duckdb.py +5 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/database/mysql.py +5 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/database/postgresql.py +5 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/database/sqlite.py +5 -0
- sqlsaber-0.28.0/src/sqlsaber/theme/__init__.py +5 -0
- sqlsaber-0.28.0/src/sqlsaber/theme/manager.py +219 -0
- sqlsaber-0.28.0/src/sqlsaber/tools/sql_guard.py +225 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/tools/sql_tools.py +10 -35
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_cli/test_threads.py +1 -1
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_database/test_schema_display.py +3 -3
- sqlsaber-0.28.0/tests/test_tools/test_sql_guard.py +314 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_tools/test_sql_tools.py +1 -1
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/uv.lock +45 -1
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/.github/workflows/claude-code-review.yml +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/.github/workflows/claude.yml +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/.github/workflows/deploy-docs.yml +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/.github/workflows/publish.yml +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/.github/workflows/test.yml +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/.gitignore +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/.python-version +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/AGENT.md +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/CLAUDE.md +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/LICENSE +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/README.md +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/.gitignore +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/.vscode/extensions.json +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/.vscode/launch.json +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/CLAUDE.md +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/astro.config.mjs +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/package-lock.json +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/package.json +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/public/CNAME +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/public/favicon.svg +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/assets/sqlsaber-hero.svg +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/guides/authentication.mdx +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/guides/database-setup.mdx +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/guides/getting-started.mdx +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/guides/memory.mdx +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/guides/models.mdx +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/guides/queries.mdx +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/guides/threads.md +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/index.mdx +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/installation.mdx +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content/docs/reference/commands.md +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/content.config.ts +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/src/styles/global.css +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/docs/tsconfig.json +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/legislators.db +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/pytest.ini +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/sqlsaber.gif +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/sqlsaber.svg +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/__init__.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/__main__.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/agents/__init__.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/agents/base.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/agents/mcp.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/agents/pydantic_ai_agent.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/__init__.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/cli/completers.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/config/__init__.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/config/auth.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/config/database.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/config/providers.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/config/settings.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/database/__init__.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/database/resolver.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/database/schema.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/mcp/__init__.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/mcp/mcp.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/memory/__init__.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/memory/manager.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/memory/storage.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/threads/__init__.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/threads/storage.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/tools/__init__.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/tools/base.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/tools/enums.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/tools/instructions.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/src/sqlsaber/tools/registry.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/__init__.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/conftest.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_cli/__init__.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_cli/test_auth_reset.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_cli/test_commands.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_config/__init__.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_config/test_database.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_config/test_oauth.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_config/test_providers.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_config/test_settings.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_database/__init__.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_database/test_connection.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_database/test_csv_connection.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_database/test_csv_module.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_database/test_duckdb_module.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_database/test_schema.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_database/test_sqlite_module.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_database/test_timeout.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_database_resolver.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_threads_storage.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_tools/__init__.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_tools/test_base.py +0 -0
- {sqlsaber-0.26.0 → sqlsaber-0.28.0}/tests/test_tools/test_instructions.py +0 -0
- {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.
|
|
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.
|
|
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()
|