morphy-cli 0.5.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.
morphy_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+
morphy_cli/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .main import main
2
+
3
+ main()
morphy_cli/auth.py ADDED
@@ -0,0 +1,321 @@
1
+ """Authentication and session management for MORPHY CLI using Supabase Auth and OS keyring."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import time
7
+ from typing import Any, Optional
8
+ import httpx
9
+ import keyring
10
+
11
+ CONFIG_DIR = Path.home() / ".morphy"
12
+ CONFIG_FILE = CONFIG_DIR / "config.json"
13
+ FALLBACK_TOKEN_FILE = CONFIG_DIR / ".tokens"
14
+ KEYRING_SERVICE = "morphy-cli"
15
+
16
+
17
+ class AuthError(Exception):
18
+ """Authentication or session error with user-actionable message."""
19
+ pass
20
+
21
+
22
+ class Session:
23
+ """Holds authenticated session details."""
24
+
25
+ def __init__(
26
+ self,
27
+ access_token: str,
28
+ refresh_token: str,
29
+ user_id: str,
30
+ email: str,
31
+ expires_at: float,
32
+ ):
33
+ self.access_token = access_token
34
+ self.refresh_token = refresh_token
35
+ self.user_id = user_id
36
+ self.email = email
37
+ self.expires_at = expires_at
38
+
39
+ @property
40
+ def is_expired(self) -> bool:
41
+ """Returns True if the access token has expired or is expiring in <= 60s."""
42
+ return time.time() >= (self.expires_at - 60)
43
+
44
+ def to_dict(self) -> dict[str, Any]:
45
+ return {
46
+ "access_token": self.access_token,
47
+ "refresh_token": self.refresh_token,
48
+ "user_id": self.user_id,
49
+ "email": self.email,
50
+ "expires_at": self.expires_at,
51
+ }
52
+
53
+ @classmethod
54
+ def from_dict(cls, data: dict[str, Any]) -> "Session":
55
+ return cls(
56
+ access_token=data["access_token"],
57
+ refresh_token=data["refresh_token"],
58
+ user_id=data["user_id"],
59
+ email=data["email"],
60
+ expires_at=data["expires_at"],
61
+ )
62
+
63
+
64
+ def load_config() -> tuple[str, str]:
65
+ """Load Supabase URL and anon key from env vars or ~/.morphy/config.json."""
66
+ url = os.getenv("MORPHY_SUPABASE_URL")
67
+ key = os.getenv("MORPHY_SUPABASE_ANON_KEY") or os.getenv("MORPHY_SUPABASE_PUBLIC_KEY")
68
+
69
+ if not url or not key:
70
+ if CONFIG_FILE.is_file():
71
+ try:
72
+ data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
73
+ url = url or data.get("supabase_url")
74
+ key = key or data.get("supabase_anon_key")
75
+ except Exception:
76
+ pass
77
+
78
+ url = (url or "http://127.0.0.1:54321").rstrip("/")
79
+ key = key or ""
80
+ return url, key
81
+
82
+
83
+ def save_config(supabase_url: str, supabase_anon_key: str) -> None:
84
+ """Save Supabase configuration to ~/.morphy/config.json."""
85
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
86
+ payload = {
87
+ "supabase_url": supabase_url.rstrip("/"),
88
+ "supabase_anon_key": supabase_anon_key,
89
+ }
90
+ CONFIG_FILE.write_text(json.dumps(payload, indent=2), encoding="utf-8")
91
+
92
+
93
+ class AuthManager:
94
+ """Manages Supabase sessions, token refreshing, and OS credential storage."""
95
+
96
+ def __init__(self, supabase_url: Optional[str] = None, supabase_key: Optional[str] = None):
97
+ cfg_url, cfg_key = load_config()
98
+ self.supabase_url = (supabase_url or cfg_url).rstrip("/")
99
+ self.supabase_key = supabase_key if supabase_key is not None else cfg_key
100
+ self.current_session: Optional[Session] = None
101
+ self._keyring_key = f"refresh:{self.supabase_url}"
102
+
103
+ def _headers(self) -> dict[str, str]:
104
+ headers = {"apikey": self.supabase_key}
105
+ if self.current_session and self.current_session.access_token:
106
+ headers["Authorization"] = f"Bearer {self.current_session.access_token}"
107
+ return headers
108
+
109
+ def _store_refresh_token(self, refresh_token: str, session: Session) -> None:
110
+ stored = False
111
+ try:
112
+ keyring.set_password(KEYRING_SERVICE, self._keyring_key, refresh_token)
113
+ stored = True
114
+ except Exception:
115
+ stored = False
116
+
117
+ # Fallback file if keyring fails (e.g. headless CI or container)
118
+ if not stored:
119
+ try:
120
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
121
+ FALLBACK_TOKEN_FILE.write_text(
122
+ json.dumps(session.to_dict()),
123
+ encoding="utf-8"
124
+ )
125
+ try:
126
+ os.chmod(FALLBACK_TOKEN_FILE, 0o600)
127
+ except Exception:
128
+ pass
129
+ except Exception:
130
+ pass
131
+
132
+ def _get_stored_refresh_token(self) -> Optional[str]:
133
+ try:
134
+ token = keyring.get_password(KEYRING_SERVICE, self._keyring_key)
135
+ if token:
136
+ return token
137
+ except Exception:
138
+ pass
139
+
140
+ if FALLBACK_TOKEN_FILE.is_file():
141
+ try:
142
+ data = json.loads(FALLBACK_TOKEN_FILE.read_text(encoding="utf-8"))
143
+ return data.get("refresh_token")
144
+ except Exception:
145
+ pass
146
+ return None
147
+
148
+ def _clear_stored_tokens(self) -> None:
149
+ try:
150
+ keyring.delete_password(KEYRING_SERVICE, self._keyring_key)
151
+ except Exception:
152
+ pass
153
+
154
+ if FALLBACK_TOKEN_FILE.is_file():
155
+ try:
156
+ FALLBACK_TOKEN_FILE.unlink(missing_ok=True)
157
+ except Exception:
158
+ pass
159
+
160
+ def login(self, email: str, password: str) -> Session:
161
+ """Authenticate with Supabase GoTrue using email and password."""
162
+ if not self.supabase_key:
163
+ raise AuthError(
164
+ "Supabase Anon Key is not configured. Set MORPHY_SUPABASE_ANON_KEY or run config."
165
+ )
166
+
167
+ endpoint = f"{self.supabase_url}/auth/v1/token?grant_type=password"
168
+ payload = {"email": email, "password": password}
169
+
170
+ try:
171
+ with httpx.Client(timeout=15.0) as client:
172
+ res = client.post(endpoint, json=payload, headers={"apikey": self.supabase_key})
173
+ except httpx.HTTPError as exc:
174
+ raise AuthError(f"Network error during authentication: {exc}")
175
+
176
+ if res.status_code != 200:
177
+ try:
178
+ err_data = res.json()
179
+ detail = err_data.get("msg") or err_data.get("error_description") or res.text
180
+ except Exception:
181
+ detail = res.text
182
+ raise AuthError(f"Login failed ({res.status_code}): {detail}")
183
+
184
+ data = res.json()
185
+ expires_in = data.get("expires_in", 3600)
186
+ user_info = data.get("user", {})
187
+
188
+ session = Session(
189
+ access_token=data["access_token"],
190
+ refresh_token=data["refresh_token"],
191
+ user_id=user_info.get("id", ""),
192
+ email=user_info.get("email", email),
193
+ expires_at=time.time() + expires_in,
194
+ )
195
+ self.current_session = session
196
+ self._store_refresh_token(session.refresh_token, session)
197
+ return session
198
+
199
+ def refresh(self) -> Session:
200
+ """Refresh current session using refresh token."""
201
+ refresh_token = (
202
+ self.current_session.refresh_token
203
+ if self.current_session
204
+ else self._get_stored_refresh_token()
205
+ )
206
+ if not refresh_token:
207
+ raise AuthError("No active session or refresh token found. Please /login.")
208
+
209
+ endpoint = f"{self.supabase_url}/auth/v1/token?grant_type=refresh_token"
210
+ payload = {"refresh_token": refresh_token}
211
+
212
+ try:
213
+ with httpx.Client(timeout=15.0) as client:
214
+ res = client.post(endpoint, json=payload, headers={"apikey": self.supabase_key})
215
+ except httpx.HTTPError as exc:
216
+ raise AuthError(f"Network error during token refresh: {exc}")
217
+
218
+ if res.status_code != 200:
219
+ self._clear_stored_tokens()
220
+ self.current_session = None
221
+ raise AuthError("Session expired or refresh token invalid. Please /login again.")
222
+
223
+ data = res.json()
224
+ expires_in = data.get("expires_in", 3600)
225
+ user_info = data.get("user", {})
226
+
227
+ session = Session(
228
+ access_token=data["access_token"],
229
+ refresh_token=data["refresh_token"],
230
+ user_id=user_info.get("id", self.current_session.user_id if self.current_session else ""),
231
+ email=user_info.get("email", self.current_session.email if self.current_session else ""),
232
+ expires_at=time.time() + expires_in,
233
+ )
234
+ self.current_session = session
235
+ self._store_refresh_token(session.refresh_token, session)
236
+ return session
237
+
238
+ def restore_session(self) -> Optional[Session]:
239
+ """Attempt to restore or refresh a session from saved token."""
240
+ env_token = os.getenv("MORPHY_ACCESS_TOKEN")
241
+ if env_token:
242
+ self.current_session = Session(
243
+ access_token=env_token,
244
+ refresh_token="",
245
+ user_id="",
246
+ email="",
247
+ expires_at=time.time() + 86400,
248
+ )
249
+ return self.current_session
250
+
251
+ refresh_token = self._get_stored_refresh_token()
252
+ if not refresh_token:
253
+ return None
254
+
255
+ try:
256
+ return self.refresh()
257
+ except Exception:
258
+ return None
259
+
260
+ def get_valid_token(self) -> str:
261
+ """Return a valid access token, refreshing if necessary."""
262
+ if not self.current_session:
263
+ restored = self.restore_session()
264
+ if not restored:
265
+ env_token = os.getenv("MORPHY_ACCESS_TOKEN")
266
+ if env_token:
267
+ return env_token
268
+ raise AuthError("Authentication required. Please run /login.")
269
+
270
+ if self.current_session.is_expired:
271
+ self.refresh()
272
+
273
+ return self.current_session.access_token
274
+
275
+ def logout(self) -> None:
276
+ """Sign out and invalidate session credentials."""
277
+ if self.current_session and self.current_session.access_token and self.supabase_key:
278
+ try:
279
+ endpoint = f"{self.supabase_url}/auth/v1/logout"
280
+ with httpx.Client(timeout=8.0) as client:
281
+ client.post(
282
+ endpoint,
283
+ headers={
284
+ "apikey": self.supabase_key,
285
+ "Authorization": f"Bearer {self.current_session.access_token}",
286
+ },
287
+ )
288
+ except Exception:
289
+ pass
290
+
291
+ self._clear_stored_tokens()
292
+ self.current_session = None
293
+
294
+ def status(self) -> dict[str, Any]:
295
+ """Return current status dictionary."""
296
+ token = os.getenv("MORPHY_ACCESS_TOKEN")
297
+ if token and not self.current_session:
298
+ return {
299
+ "authenticated": True,
300
+ "source": "MORPHY_ACCESS_TOKEN environment variable",
301
+ "email": "injected-environment-session",
302
+ "supabase_url": self.supabase_url,
303
+ }
304
+
305
+ if not self.current_session:
306
+ restored = self.restore_session()
307
+ if not restored:
308
+ return {
309
+ "authenticated": False,
310
+ "supabase_url": self.supabase_url,
311
+ }
312
+
313
+ remaining_sec = max(0, int(self.current_session.expires_at - time.time()))
314
+ return {
315
+ "authenticated": True,
316
+ "email": self.current_session.email,
317
+ "user_id": self.current_session.user_id,
318
+ "expires_in_seconds": remaining_sec,
319
+ "supabase_url": self.supabase_url,
320
+ }
321
+
morphy_cli/client.py ADDED
@@ -0,0 +1,192 @@
1
+ """Shared HTTP client for the MORPHY engine."""
2
+
3
+ import os
4
+ from typing import Any, Callable, Optional, Union
5
+ from uuid import UUID
6
+ from urllib.parse import urlsplit
7
+ import httpx
8
+
9
+
10
+ class EngineError(Exception):
11
+ """Error returned by the MORPHY engine with an actionable message."""
12
+
13
+ def __init__(self, status_code: int, detail: str):
14
+ self.status_code = status_code
15
+ self.detail = detail
16
+ super().__init__(detail)
17
+
18
+
19
+ _STATUS_HINTS = {
20
+ 401: "Sign in again — your session may have expired.",
21
+ 403: "Access denied. Check your database permissions.",
22
+ 409: "An administrator must connect this database first.",
23
+ 413: "The input or result is too large. Narrow your query or CSV.",
24
+ 422: "Validation or generation failed. Check the SQL syntax or permitted schema.",
25
+ 502: "The database is unreachable. Check the connection and credentials.",
26
+ 503: "The engine or a required service is unavailable.",
27
+ }
28
+
29
+
30
+ class MorphyClient:
31
+ """Thin wrapper around httpx for the MORPHY engine API."""
32
+
33
+ def __init__(
34
+ self,
35
+ base_url: str,
36
+ database: Optional[Union[UUID, str]] = None,
37
+ token: Optional[Union[str, Callable[[], str]]] = None,
38
+ ):
39
+ self.base_url = base_url.rstrip("/")
40
+ self.database: Optional[UUID] = UUID(str(database)) if database else None
41
+ self._token_source = token
42
+ self._validate_endpoint(self.base_url)
43
+
44
+ self._http = httpx.Client(
45
+ base_url=self.base_url,
46
+ timeout=195.0,
47
+ follow_redirects=False,
48
+ )
49
+
50
+ def _validate_endpoint(self, url: str) -> None:
51
+ if self.database:
52
+ endpoint = urlsplit(url)
53
+ if endpoint.scheme != "https" and not (
54
+ endpoint.scheme == "http"
55
+ and endpoint.hostname in {"localhost", "127.0.0.1", "::1"}
56
+ ):
57
+ raise SystemExit(
58
+ "Use HTTPS or a loopback address for authenticated requests."
59
+ )
60
+
61
+ def _get_token(self) -> Optional[str]:
62
+ if callable(self._token_source):
63
+ try:
64
+ return self._token_source()
65
+ except Exception:
66
+ return None
67
+ if self._token_source:
68
+ return str(self._token_source)
69
+ return os.getenv("MORPHY_ACCESS_TOKEN")
70
+
71
+ def set_token_source(self, source: Union[str, Callable[[], str], None]) -> None:
72
+ self._token_source = source
73
+
74
+ def set_database(self, database: Optional[Union[UUID, str]]) -> None:
75
+ if database is None:
76
+ self.database = None
77
+ else:
78
+ try:
79
+ self.database = UUID(str(database))
80
+ except ValueError:
81
+ self.database = str(database) # type: ignore
82
+ self._validate_endpoint(self.base_url)
83
+
84
+ @property
85
+ def prefix(self) -> str:
86
+ return f"/api/engine/databases/{self.database}" if self.database else "/api/sample"
87
+
88
+ @property
89
+ def is_live(self) -> bool:
90
+ return self.database is not None
91
+
92
+ def close(self) -> None:
93
+ self._http.close()
94
+
95
+ def _build_headers(self) -> dict[str, str]:
96
+ headers: dict[str, str] = {}
97
+ if self.database:
98
+ token = self._get_token()
99
+ if not token:
100
+ raise SystemExit(
101
+ "Set MORPHY_ACCESS_TOKEN or run /login to authenticate live database queries."
102
+ )
103
+ headers["Authorization"] = f"Bearer {token}"
104
+ return headers
105
+
106
+ def _request(
107
+ self,
108
+ method: str,
109
+ path: str,
110
+ payload: Optional[Any] = None,
111
+ content: Optional[bytes] = None,
112
+ headers: Optional[dict[str, str]] = None,
113
+ ) -> Any:
114
+ req_headers = self._build_headers()
115
+ if headers:
116
+ req_headers.update(headers)
117
+
118
+ try:
119
+ if method == "GET":
120
+ response = self._http.get(path, headers=req_headers)
121
+ elif method == "POST":
122
+ response = self._http.post(path, json=payload, headers=req_headers)
123
+ elif method == "PUT":
124
+ if content is not None:
125
+ response = self._http.put(path, content=content, headers=req_headers)
126
+ else:
127
+ response = self._http.put(path, json=payload, headers=req_headers)
128
+ else:
129
+ raise ValueError(f"Unsupported method: {method}")
130
+ except httpx.ConnectError as err:
131
+ raise EngineError(503, f"Cannot connect to engine at {self.base_url}. Service unavailable.") from err
132
+ except httpx.TimeoutException as err:
133
+ raise EngineError(504, "Engine request timed out.") from err
134
+ except httpx.HTTPError as err:
135
+ raise EngineError(500, f"HTTP communication failed: {err}") from err
136
+
137
+ if response.is_error:
138
+ try:
139
+ detail = response.json().get("detail", response.text)
140
+ except Exception:
141
+ detail = response.text
142
+ hint = _STATUS_HINTS.get(response.status_code, "")
143
+ message = str(detail)
144
+ if hint and hint not in message:
145
+ message = f"{message}\n{hint}"
146
+ raise EngineError(response.status_code, message)
147
+ return response.json()
148
+
149
+ def health(self) -> dict[str, Any]:
150
+ return self._request("GET", "/api/health")
151
+
152
+ def schema(self) -> dict[str, Any]:
153
+ return self._request("GET", self.prefix + "/schema")
154
+
155
+ def generate(self, question: str) -> dict[str, Any]:
156
+ return self._request("POST", self.prefix + "/generate", payload={"question": question})
157
+
158
+ def query(self, sql: str) -> dict[str, Any]:
159
+ return self._request("POST", self.prefix + "/query", payload={"sql": sql})
160
+
161
+ def connect_database(self, uri: str) -> dict[str, Any]:
162
+ """Save and test PostgreSQL connection URI on the active database."""
163
+ if not self.database:
164
+ raise EngineError(400, "Select a database first using /use-db <id>")
165
+ return self._request(
166
+ "PUT",
167
+ f"/api/engine/databases/{self.database}/connection",
168
+ payload={"uri": uri},
169
+ )
170
+
171
+ def import_csv(self, csv_data: Union[str, bytes]) -> dict[str, Any]:
172
+ """Import UTF-8 CSV data into the active SQLite/CSV database."""
173
+ if not self.database:
174
+ raise EngineError(400, "Select a database first using /use-db <id>")
175
+ raw_bytes = csv_data.encode("utf-8") if isinstance(csv_data, str) else csv_data
176
+ if len(raw_bytes) > 1_048_576:
177
+ raise EngineError(413, "Choose a UTF-8 CSV up to 1 MB.")
178
+ return self._request(
179
+ "PUT",
180
+ f"/api/engine/databases/{self.database}/import",
181
+ content=raw_bytes,
182
+ headers={"Content-Type": "text/csv; charset=utf-8"},
183
+ )
184
+
185
+ def render_panel(
186
+ self, dashboard_id: Union[UUID, str], query_id: Union[UUID, str]
187
+ ) -> dict[str, Any]:
188
+ """Render a saved dashboard panel under the caller's RLS policy."""
189
+ return self._request(
190
+ "POST",
191
+ f"/api/engine/dashboards/{dashboard_id}/panels/{query_id}/render",
192
+ )
@@ -0,0 +1,65 @@
1
+ """Slash-command completion for the MORPHY interactive shell."""
2
+
3
+ from prompt_toolkit.completion import Completer, Completion
4
+
5
+
6
+ # Commands available in the interactive shell.
7
+ _ALL_COMMANDS = [
8
+ ("/help", "Show available commands"),
9
+ ("/schema", "Display the permitted database schema"),
10
+ ("/examples", "List curated example questions"),
11
+ ("/ask", "Generate SQL from a natural-language question"),
12
+ ("/sql", "Execute a read-only SQL query"),
13
+ ("/login", "Sign in with email and password"),
14
+ ("/logout", "Sign out and clear saved credentials"),
15
+ ("/status", "Display authentication, active org and database status"),
16
+ ("/orgs", "List your organizations"),
17
+ ("/use-org", "Switch active organization context (/use-org <id>)"),
18
+ ("/dbs", "List databases in active organization"),
19
+ ("/use-db", "Switch active target database context (/use-db <id|sample>)"),
20
+ ("/connect", "Save and verify PostgreSQL connection (/connect <uri>)"),
21
+ ("/import", "Import UTF-8 CSV into active database (/import <file.csv>)"),
22
+ ("/profile", "View or claim your user profile"),
23
+ ("/members", "List members of active organization"),
24
+ ("/invites", "List pending invitations"),
25
+ ("/invite", "Invite a user to active organization (/invite <username>)"),
26
+ ("/accept", "Accept an organization invitation (/accept <id>)"),
27
+ ("/withdraw", "Withdraw a pending invitation (/withdraw <id>)"),
28
+ ("/save-query", "Save current or specified SQL query (/save-query <name> [sql])"),
29
+ ("/queries", "List saved queries"),
30
+ ("/run-query", "Run a saved query by name (/run-query <name>)"),
31
+ ("/delete-query", "Delete a saved query (/delete-query <name>)"),
32
+ ("/dashboards", "List saved dashboards"),
33
+ ("/dashboard", "Display summary dashboard or render a saved dashboard (/dashboard [render <id>])"),
34
+ ("/dashboard-create", "Create a saved dashboard (/dashboard-create <title> [shared])"),
35
+ ("/dashboard-add", "Add a query panel to dashboard (/dashboard-add <dash_id> <query_id>)"),
36
+ ("/permissions", "Show effective permissions on active database"),
37
+ ("/perm-revoke", "Revoke permissions for a role or user (/perm-revoke <role|user>)"),
38
+ ("/export", "Export last query result to CSV file (/export <file.csv>)"),
39
+ ("/json", "Display last query result formatted as JSON"),
40
+ ("/exit", "Leave the session"),
41
+ ("/quit", "Leave the session"),
42
+ ]
43
+
44
+
45
+ class SlashCompleter(Completer):
46
+ """Tab-complete slash commands in the MORPHY REPL."""
47
+
48
+ def __init__(self, *, is_live: bool = False):
49
+ self._commands = list(_ALL_COMMANDS)
50
+
51
+ def get_completions(self, document, complete_event):
52
+ text = document.text_before_cursor.lstrip()
53
+ # Only complete at the beginning of the line and when starting with '/'.
54
+ if document.cursor_position != len(document.text_before_cursor.lstrip()):
55
+ return
56
+ if not text.startswith("/"):
57
+ return
58
+ for name, description in self._commands:
59
+ if name.startswith(text):
60
+ yield Completion(
61
+ name,
62
+ start_position=-len(text),
63
+ display=name,
64
+ display_meta=description,
65
+ )