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 +1 -0
- morphy_cli/__main__.py +3 -0
- morphy_cli/auth.py +321 -0
- morphy_cli/client.py +192 -0
- morphy_cli/completer.py +65 -0
- morphy_cli/control.py +323 -0
- morphy_cli/formatting.py +143 -0
- morphy_cli/main.py +113 -0
- morphy_cli/queries.py +75 -0
- morphy_cli/shell.py +747 -0
- morphy_cli-0.5.0.dist-info/METADATA +136 -0
- morphy_cli-0.5.0.dist-info/RECORD +15 -0
- morphy_cli-0.5.0.dist-info/WHEEL +5 -0
- morphy_cli-0.5.0.dist-info/entry_points.txt +3 -0
- morphy_cli-0.5.0.dist-info/top_level.txt +1 -0
morphy_cli/shell.py
ADDED
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
"""Interactive prompt-toolkit REPL for MORPHY with auth, org/database selection, and full CLI suite."""
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
from prompt_toolkit import PromptSession
|
|
11
|
+
from prompt_toolkit.formatted_text import HTML
|
|
12
|
+
from prompt_toolkit.history import InMemoryHistory
|
|
13
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
14
|
+
|
|
15
|
+
from .auth import AuthError, AuthManager
|
|
16
|
+
from .client import EngineError, MorphyClient
|
|
17
|
+
from .completer import SlashCompleter
|
|
18
|
+
from .control import ControlClient, ControlPlaneError
|
|
19
|
+
from .queries import QueryStore
|
|
20
|
+
from .formatting import (
|
|
21
|
+
format_banner,
|
|
22
|
+
format_csv,
|
|
23
|
+
format_examples,
|
|
24
|
+
format_generation,
|
|
25
|
+
format_json,
|
|
26
|
+
format_records_table,
|
|
27
|
+
format_schema,
|
|
28
|
+
format_table,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
|
|
33
|
+
_HELP_TEXT = """\
|
|
34
|
+
Available commands:
|
|
35
|
+
Authentication & Session:
|
|
36
|
+
/login Sign in with email and password
|
|
37
|
+
/logout Sign out and remove saved session
|
|
38
|
+
/status Display session, active org and database context
|
|
39
|
+
/profile View, claim, or edit your user profile
|
|
40
|
+
/dashboard Display summary dashboard or render a saved dashboard
|
|
41
|
+
/dashboards List saved dashboards
|
|
42
|
+
/dashboard-create <title> Create a saved dashboard (optional: shared)
|
|
43
|
+
/dashboard-add <d> <q> Add saved query panel to dashboard
|
|
44
|
+
/permissions Display effective permissions on active database
|
|
45
|
+
/perm-revoke <role|user> Revoke permissions on active database
|
|
46
|
+
|
|
47
|
+
Organization & Databases:
|
|
48
|
+
/orgs List your organizations
|
|
49
|
+
/use-org <id|name> Switch active organization
|
|
50
|
+
/dbs List target databases in the active organization
|
|
51
|
+
/use-db <id|name|sample> Switch target database (or switch back to sample)
|
|
52
|
+
|
|
53
|
+
Database Administration:
|
|
54
|
+
/connect <postgres_uri> Connect target PostgreSQL database (Admin only)
|
|
55
|
+
/import <path_to_csv> Import UTF-8 CSV up to 1 MB (Admin only)
|
|
56
|
+
|
|
57
|
+
Team & Invitations:
|
|
58
|
+
/members List members of active organization
|
|
59
|
+
/invites List pending invitations
|
|
60
|
+
/invite <username> Invite a user to active organization
|
|
61
|
+
/accept <invitation_id> Accept a pending invitation
|
|
62
|
+
/withdraw <invitation_id> Withdraw a pending invitation
|
|
63
|
+
|
|
64
|
+
Queries & Saved Queries:
|
|
65
|
+
/schema Display permitted tables and columns
|
|
66
|
+
/examples List curated example questions
|
|
67
|
+
/ask <question> Generate governed SQL from natural language
|
|
68
|
+
/sql <SELECT ...> Execute read-only SQL query
|
|
69
|
+
/save-query <name> [sql] Save current or specified SQL query
|
|
70
|
+
/queries List saved queries
|
|
71
|
+
/run-query <name> Run a saved query by name
|
|
72
|
+
/delete-query <name> Delete a saved query
|
|
73
|
+
/json Show last query result as JSON
|
|
74
|
+
/export <file.csv> Export last query result to a CSV file
|
|
75
|
+
|
|
76
|
+
Session Control:
|
|
77
|
+
/exit or /quit Leave the session
|
|
78
|
+
|
|
79
|
+
Tips:
|
|
80
|
+
• Tab completes slash commands.
|
|
81
|
+
• Ctrl+C cancels the current input line; Ctrl+D exits.
|
|
82
|
+
• In live mode, generated SQL is always displayed before confirmation."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ShellContext:
|
|
86
|
+
"""Holds state for the interactive shell session."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
client: MorphyClient,
|
|
91
|
+
auth_manager: AuthManager,
|
|
92
|
+
query_store: Optional[QueryStore] = None,
|
|
93
|
+
):
|
|
94
|
+
self.client = client
|
|
95
|
+
self.auth = auth_manager
|
|
96
|
+
self.control = ControlClient(auth_manager)
|
|
97
|
+
self.queries = query_store or QueryStore()
|
|
98
|
+
self.active_org_id: Optional[str] = None
|
|
99
|
+
self.active_org_name: Optional[str] = None
|
|
100
|
+
self.active_db_id: Optional[str] = str(client.database) if client.database else None
|
|
101
|
+
self.active_db_name: Optional[str] = None
|
|
102
|
+
self.last_result: Optional[dict[str, Any]] = None
|
|
103
|
+
|
|
104
|
+
def clear_context(self, full_reset: bool = False) -> None:
|
|
105
|
+
"""Clear query results and database context to prevent cross-account/workspace leakage."""
|
|
106
|
+
self.last_result = None
|
|
107
|
+
self.active_db_id = None
|
|
108
|
+
self.active_db_name = None
|
|
109
|
+
self.client.set_database(None)
|
|
110
|
+
if full_reset:
|
|
111
|
+
self.active_org_id = None
|
|
112
|
+
self.active_org_name = None
|
|
113
|
+
|
|
114
|
+
def get_prompt(self) -> HTML:
|
|
115
|
+
if self.active_db_id:
|
|
116
|
+
db_label = self.active_db_name or self.active_db_id[:8]
|
|
117
|
+
if self.active_org_name:
|
|
118
|
+
context = f"{self.active_org_name}/{db_label}"
|
|
119
|
+
else:
|
|
120
|
+
context = db_label
|
|
121
|
+
return HTML(f"<ansicyan>morphy</ansicyan> [<ansigreen>{context}</ansigreen>] > ")
|
|
122
|
+
return HTML("<ansicyan>morphy</ansicyan> (<ansiyellow>sample</ansiyellow>) > ")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _execute_sql(ctx: ShellContext, sql: str) -> str:
|
|
126
|
+
"""Execute SQL via the engine client and cache result."""
|
|
127
|
+
result = ctx.client.query(sql)
|
|
128
|
+
ctx.last_result = result
|
|
129
|
+
return format_table(
|
|
130
|
+
result["columns"], result["rows"], result["row_count"], result["elapsed_ms"]
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _handle_command(ctx: ShellContext, line: str, session: Optional[PromptSession] = None) -> Optional[str]:
|
|
135
|
+
"""Dispatch a single command line."""
|
|
136
|
+
cmd = line.strip()
|
|
137
|
+
if cmd in ("/exit", "/quit"):
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
if cmd == "/help":
|
|
141
|
+
return _HELP_TEXT
|
|
142
|
+
|
|
143
|
+
# ── Status ──────────────────────────────────────────────────────────
|
|
144
|
+
if cmd == "/status":
|
|
145
|
+
auth_status = ctx.auth.status()
|
|
146
|
+
lines = ["=== MORPHY Session Status ==="]
|
|
147
|
+
lines.append(f"Engine URL: {ctx.client.base_url}")
|
|
148
|
+
lines.append(f"Supabase URL: {auth_status.get('supabase_url', '(not configured)')}")
|
|
149
|
+
lines.append(f"Authenticated: {auth_status.get('authenticated', False)}")
|
|
150
|
+
if auth_status.get("email"):
|
|
151
|
+
lines.append(f"User: {auth_status['email']}")
|
|
152
|
+
if auth_status.get("user_id"):
|
|
153
|
+
lines.append(f"User ID: {auth_status['user_id']}")
|
|
154
|
+
if auth_status.get("expires_in_seconds") is not None:
|
|
155
|
+
lines.append(f"Token TTL: {auth_status['expires_in_seconds']}s")
|
|
156
|
+
lines.append(f"Active Org: {ctx.active_org_name or '(none)'} ({ctx.active_org_id or 'none'})")
|
|
157
|
+
lines.append(f"Active Database: {ctx.active_db_name or '(none)'} ({ctx.active_db_id or 'sample mode'})")
|
|
158
|
+
return "\n".join(lines)
|
|
159
|
+
|
|
160
|
+
# ── Login ───────────────────────────────────────────────────────────
|
|
161
|
+
if cmd.startswith("/login"):
|
|
162
|
+
parts = cmd.split(maxsplit=2)
|
|
163
|
+
email = parts[1] if len(parts) > 1 else ""
|
|
164
|
+
if not email and session:
|
|
165
|
+
try:
|
|
166
|
+
email = session.prompt("Email: ").strip()
|
|
167
|
+
except (KeyboardInterrupt, EOFError):
|
|
168
|
+
return "Login cancelled."
|
|
169
|
+
|
|
170
|
+
if not email:
|
|
171
|
+
return "Usage: /login <email>"
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
password = getpass.getpass("Password: ")
|
|
175
|
+
except (KeyboardInterrupt, EOFError):
|
|
176
|
+
return "Login cancelled."
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
sess = ctx.auth.login(email, password)
|
|
180
|
+
ctx.client.set_token_source(lambda: ctx.auth.get_valid_token())
|
|
181
|
+
ctx.clear_context(full_reset=True)
|
|
182
|
+
return f"Successfully signed in as {sess.email}."
|
|
183
|
+
except AuthError as err:
|
|
184
|
+
return f"Authentication failed: {err}"
|
|
185
|
+
|
|
186
|
+
# ── Logout ──────────────────────────────────────────────────────────
|
|
187
|
+
if cmd == "/logout":
|
|
188
|
+
ctx.auth.logout()
|
|
189
|
+
ctx.client.set_token_source(None)
|
|
190
|
+
ctx.clear_context(full_reset=True)
|
|
191
|
+
return "Signed out. Saved credentials cleared."
|
|
192
|
+
|
|
193
|
+
# ── Organizations ───────────────────────────────────────────────────
|
|
194
|
+
if cmd == "/orgs":
|
|
195
|
+
try:
|
|
196
|
+
orgs = ctx.control.list_organizations()
|
|
197
|
+
if not orgs:
|
|
198
|
+
return "No organizations found. Create one with /org-create <name> or via the website."
|
|
199
|
+
return format_records_table(["ID", "Name", "Created"], orgs, ["id", "name", "created_at"])
|
|
200
|
+
except (AuthError, ControlPlaneError) as err:
|
|
201
|
+
return f"Error listing organizations: {err}"
|
|
202
|
+
|
|
203
|
+
if cmd.startswith("/use-org"):
|
|
204
|
+
parts = cmd.split(maxsplit=1)
|
|
205
|
+
if len(parts) < 2:
|
|
206
|
+
return "Usage: /use-org <id|name>"
|
|
207
|
+
query = parts[1].strip()
|
|
208
|
+
try:
|
|
209
|
+
orgs = ctx.control.list_organizations()
|
|
210
|
+
matched = [o for o in orgs if o["id"].startswith(query) or o["name"].lower() == query.lower()]
|
|
211
|
+
if not matched:
|
|
212
|
+
return f"Organization '{query}' not found. Use /orgs to view available organizations."
|
|
213
|
+
org = matched[0]
|
|
214
|
+
ctx.clear_context(full_reset=False)
|
|
215
|
+
ctx.active_org_id = org["id"]
|
|
216
|
+
ctx.active_org_name = org["name"]
|
|
217
|
+
return f"Active organization set to: {ctx.active_org_name} ({ctx.active_org_id})"
|
|
218
|
+
except (AuthError, ControlPlaneError) as err:
|
|
219
|
+
return f"Error selecting organization: {err}"
|
|
220
|
+
|
|
221
|
+
# ── Target Databases ────────────────────────────────────────────────
|
|
222
|
+
if cmd == "/dbs":
|
|
223
|
+
try:
|
|
224
|
+
dbs = ctx.control.list_databases(ctx.active_org_id)
|
|
225
|
+
if not dbs:
|
|
226
|
+
return "No target databases found in current context."
|
|
227
|
+
return format_records_table(["ID", "Name", "Dialect"], dbs, ["id", "name", "dialect"])
|
|
228
|
+
except (AuthError, ControlPlaneError) as err:
|
|
229
|
+
return f"Error listing databases: {err}"
|
|
230
|
+
|
|
231
|
+
if cmd.startswith("/use-db"):
|
|
232
|
+
parts = cmd.split(maxsplit=1)
|
|
233
|
+
if len(parts) < 2:
|
|
234
|
+
return "Usage: /use-db <id|name|sample>"
|
|
235
|
+
target = parts[1].strip()
|
|
236
|
+
if target.lower() == "sample":
|
|
237
|
+
ctx.clear_context(full_reset=False)
|
|
238
|
+
return "Switched to sample workspace (synthetic commerce dataset)."
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
dbs = ctx.control.list_databases(ctx.active_org_id)
|
|
242
|
+
matched = [d for d in dbs if d["id"].startswith(target) or d["name"].lower() == target.lower()]
|
|
243
|
+
if not matched:
|
|
244
|
+
# Direct UUID fallback
|
|
245
|
+
try:
|
|
246
|
+
uuid_val = UUID(target)
|
|
247
|
+
ctx.clear_context(full_reset=False)
|
|
248
|
+
ctx.active_db_id = str(uuid_val)
|
|
249
|
+
ctx.active_db_name = target[:8]
|
|
250
|
+
ctx.client.set_database(uuid_val)
|
|
251
|
+
return f"Active database set to {ctx.active_db_id}."
|
|
252
|
+
except ValueError:
|
|
253
|
+
return f"Database '{target}' not found. Run /dbs to view available databases."
|
|
254
|
+
db_row = matched[0]
|
|
255
|
+
ctx.clear_context(full_reset=False)
|
|
256
|
+
ctx.active_db_id = str(db_row["id"])
|
|
257
|
+
ctx.active_db_name = db_row["name"]
|
|
258
|
+
try:
|
|
259
|
+
ctx.client.set_database(UUID(ctx.active_db_id))
|
|
260
|
+
except ValueError:
|
|
261
|
+
ctx.client.set_database(ctx.active_db_id)
|
|
262
|
+
return f"Active database set to: {ctx.active_db_name} [{db_row.get('dialect', 'db')}] ({ctx.active_db_id})"
|
|
263
|
+
except (AuthError, ControlPlaneError) as err:
|
|
264
|
+
return f"Error setting database: {err}"
|
|
265
|
+
|
|
266
|
+
# ── Connections & CSV Import ─────────────────────────────────────────
|
|
267
|
+
if cmd.startswith("/connect"):
|
|
268
|
+
parts = cmd.split(maxsplit=1)
|
|
269
|
+
if len(parts) < 2:
|
|
270
|
+
return "Usage: /connect <postgres_uri>"
|
|
271
|
+
uri = parts[1].strip()
|
|
272
|
+
if not ctx.active_db_id:
|
|
273
|
+
return "Select a database first with /use-db <id> before configuring a connection."
|
|
274
|
+
try:
|
|
275
|
+
res = ctx.client.connect_database(uri)
|
|
276
|
+
return f"Connected successfully. Reflected {res.get('tables', 0)} tables."
|
|
277
|
+
except EngineError as err:
|
|
278
|
+
return f"Connection error: {err.detail}"
|
|
279
|
+
|
|
280
|
+
if cmd.startswith("/import"):
|
|
281
|
+
parts = cmd.split(maxsplit=1)
|
|
282
|
+
if len(parts) < 2:
|
|
283
|
+
return "Usage: /import <path_to_csv_file>"
|
|
284
|
+
csv_path = Path(parts[1].strip().strip('"').strip("'"))
|
|
285
|
+
if not csv_path.is_file():
|
|
286
|
+
return f"File not found: {csv_path}"
|
|
287
|
+
if not ctx.active_db_id:
|
|
288
|
+
return "Select a database first with /use-db <id> before importing data."
|
|
289
|
+
try:
|
|
290
|
+
data = csv_path.read_bytes()
|
|
291
|
+
res = ctx.client.import_csv(data)
|
|
292
|
+
return f"Imported {res.get('rows', 0)} rows into '{res.get('table', 'records')}'. Columns: {', '.join(res.get('columns', []))}"
|
|
293
|
+
except EngineError as err:
|
|
294
|
+
return f"Import error: {err.detail}"
|
|
295
|
+
except Exception as err:
|
|
296
|
+
return f"Failed to read file: {err}"
|
|
297
|
+
|
|
298
|
+
# ── Profile & Team Management ───────────────────────────────────────
|
|
299
|
+
if cmd == "/profile":
|
|
300
|
+
try:
|
|
301
|
+
profile = ctx.control.get_profile()
|
|
302
|
+
if not profile:
|
|
303
|
+
return "No profile claimed yet. Claim with /profile claim <username> <display_name>"
|
|
304
|
+
return f"Username: {profile.get('username')}\nDisplay Name: {profile.get('display_name')}\nUser ID: {profile.get('id')}"
|
|
305
|
+
except (AuthError, ControlPlaneError) as err:
|
|
306
|
+
return f"Profile error: {err}"
|
|
307
|
+
|
|
308
|
+
if cmd.startswith("/profile claim"):
|
|
309
|
+
parts = cmd.split(maxsplit=3)
|
|
310
|
+
if len(parts) < 4:
|
|
311
|
+
return "Usage: /profile claim <username> <display_name>"
|
|
312
|
+
try:
|
|
313
|
+
ctx.control.claim_profile(parts[2], parts[3])
|
|
314
|
+
return "Profile successfully claimed!"
|
|
315
|
+
except (AuthError, ControlPlaneError) as err:
|
|
316
|
+
return f"Error claiming profile: {err}"
|
|
317
|
+
|
|
318
|
+
if cmd.startswith("/profile edit"):
|
|
319
|
+
parts = cmd.split(maxsplit=2)
|
|
320
|
+
if len(parts) < 3:
|
|
321
|
+
return "Usage: /profile edit <display_name>"
|
|
322
|
+
try:
|
|
323
|
+
ctx.control.update_profile(parts[2])
|
|
324
|
+
return f"Display name updated to: {parts[2].strip()}"
|
|
325
|
+
except (AuthError, ControlPlaneError) as err:
|
|
326
|
+
return f"Error updating profile: {err}"
|
|
327
|
+
|
|
328
|
+
if cmd == "/members":
|
|
329
|
+
if not ctx.active_org_id:
|
|
330
|
+
return "Select an active organization first with /use-org <id>"
|
|
331
|
+
try:
|
|
332
|
+
members = ctx.control.list_members(ctx.active_org_id)
|
|
333
|
+
if not members:
|
|
334
|
+
return "No members found."
|
|
335
|
+
return format_records_table(["User ID", "Username", "Role ID"], members, ["user_id", "username", "role_id"])
|
|
336
|
+
except (AuthError, ControlPlaneError) as err:
|
|
337
|
+
return f"Error listing members: {err}"
|
|
338
|
+
|
|
339
|
+
if cmd == "/invites":
|
|
340
|
+
try:
|
|
341
|
+
invites = ctx.control.list_invitations()
|
|
342
|
+
if not invites:
|
|
343
|
+
return "No pending invitations."
|
|
344
|
+
return format_records_table(["Invitation ID", "Organization", "Status"], invites, ["id", "organization_name", "status"])
|
|
345
|
+
except (AuthError, ControlPlaneError) as err:
|
|
346
|
+
return f"Error listing invitations: {err}"
|
|
347
|
+
|
|
348
|
+
if cmd.startswith("/withdraw") or cmd.startswith("/invite withdraw"):
|
|
349
|
+
parts = cmd.split(maxsplit=2 if cmd.startswith("/invite withdraw") else 1)
|
|
350
|
+
inv_id = parts[-1].strip() if len(parts) > (2 if cmd.startswith("/invite withdraw") else 1) else ""
|
|
351
|
+
if not inv_id or inv_id == "withdraw":
|
|
352
|
+
return "Usage: /withdraw <invitation_id>"
|
|
353
|
+
try:
|
|
354
|
+
ctx.control.withdraw_invitation(inv_id)
|
|
355
|
+
return f"Invitation {inv_id} withdrawn successfully."
|
|
356
|
+
except (AuthError, ControlPlaneError) as err:
|
|
357
|
+
return f"Error withdrawing invitation: {err}"
|
|
358
|
+
|
|
359
|
+
if cmd.startswith("/invite"):
|
|
360
|
+
parts = cmd.split(maxsplit=1)
|
|
361
|
+
if len(parts) < 2 or not ctx.active_org_id:
|
|
362
|
+
return "Usage: /invite <username> (with an active organization set)"
|
|
363
|
+
prefix = parts[1].strip()
|
|
364
|
+
try:
|
|
365
|
+
people = ctx.control.find_people(prefix)
|
|
366
|
+
if not people:
|
|
367
|
+
return f"No users found matching '{prefix}'."
|
|
368
|
+
target_user = people[0]
|
|
369
|
+
roles = ctx.control.list_roles(ctx.active_org_id)
|
|
370
|
+
viewer_role = next((r["id"] for r in roles if r["name"] == "VIEWER"), roles[0]["id"] if roles else None)
|
|
371
|
+
if not viewer_role:
|
|
372
|
+
return "No suitable role found to invite user."
|
|
373
|
+
ctx.control.invite_person(ctx.active_org_id, target_user["id"], viewer_role)
|
|
374
|
+
return f"Invitation sent to {target_user['username']}."
|
|
375
|
+
except (AuthError, ControlPlaneError) as err:
|
|
376
|
+
return f"Error sending invitation: {err}"
|
|
377
|
+
|
|
378
|
+
if cmd.startswith("/accept"):
|
|
379
|
+
parts = cmd.split(maxsplit=1)
|
|
380
|
+
if len(parts) < 2:
|
|
381
|
+
return "Usage: /accept <invitation_id>"
|
|
382
|
+
inv_id = parts[1].strip()
|
|
383
|
+
try:
|
|
384
|
+
ctx.control.accept_invitation(inv_id)
|
|
385
|
+
return f"Invitation {inv_id} accepted!"
|
|
386
|
+
except (AuthError, ControlPlaneError) as err:
|
|
387
|
+
return f"Error accepting invitation: {err}"
|
|
388
|
+
|
|
389
|
+
# ── Saved Queries & Dashboard ───────────────────────────────────────
|
|
390
|
+
if cmd.startswith("/save-query"):
|
|
391
|
+
parts = cmd.split(maxsplit=2)
|
|
392
|
+
if len(parts) < 2:
|
|
393
|
+
return "Usage: /save-query <name> [sql]"
|
|
394
|
+
name = parts[1].strip()
|
|
395
|
+
sql = parts[2].strip() if len(parts) > 2 else ""
|
|
396
|
+
if not sql:
|
|
397
|
+
if ctx.last_result and ctx.last_result.get("sql"):
|
|
398
|
+
sql = ctx.last_result["sql"]
|
|
399
|
+
else:
|
|
400
|
+
return "No previous query result to save. Specify SQL: /save-query <name> <SELECT ...>"
|
|
401
|
+
record = ctx.queries.save(name, sql, database_id=ctx.active_db_id)
|
|
402
|
+
return f"Query '{name}' saved successfully for database '{record['database_id']}'."
|
|
403
|
+
|
|
404
|
+
if cmd == "/queries":
|
|
405
|
+
saved = ctx.queries.list(database_id=ctx.active_db_id)
|
|
406
|
+
if not saved:
|
|
407
|
+
return "No saved queries found. Use /save-query <name> [sql] to save a query."
|
|
408
|
+
return format_records_table(["Name", "SQL", "Database", "Saved At"], saved, ["name", "sql", "database_id", "created_at"])
|
|
409
|
+
|
|
410
|
+
if cmd.startswith("/run-query"):
|
|
411
|
+
parts = cmd.split(maxsplit=1)
|
|
412
|
+
if len(parts) < 2:
|
|
413
|
+
return "Usage: /run-query <name>"
|
|
414
|
+
name = parts[1].strip()
|
|
415
|
+
record = ctx.queries.get(name)
|
|
416
|
+
if not record:
|
|
417
|
+
return f"Saved query '{name}' not found. Use /queries to list saved queries."
|
|
418
|
+
print(f"Running saved query '{name}':\n{record['sql']}\n")
|
|
419
|
+
return _execute_sql(ctx, record["sql"])
|
|
420
|
+
|
|
421
|
+
if cmd.startswith("/delete-query"):
|
|
422
|
+
parts = cmd.split(maxsplit=1)
|
|
423
|
+
if len(parts) < 2:
|
|
424
|
+
return "Usage: /delete-query <name>"
|
|
425
|
+
name = parts[1].strip()
|
|
426
|
+
if ctx.queries.delete(name):
|
|
427
|
+
return f"Saved query '{name}' deleted."
|
|
428
|
+
return f"Saved query '{name}' not found."
|
|
429
|
+
|
|
430
|
+
# ── Permissions & Access Policies ───────────────────────────────────
|
|
431
|
+
if cmd == "/permissions":
|
|
432
|
+
if not ctx.active_db_id:
|
|
433
|
+
return "Active database: sample (governed synthetic APAC policy)."
|
|
434
|
+
try:
|
|
435
|
+
perm = ctx.control.effective_permission(ctx.active_db_id)
|
|
436
|
+
mode = perm.get("mode", "NONE")
|
|
437
|
+
cols = perm.get("table_columns", {})
|
|
438
|
+
rules = perm.get("row_rules", [])
|
|
439
|
+
lines = [f"Effective permission on {ctx.active_db_id}:", f"Mode: {mode}"]
|
|
440
|
+
if cols:
|
|
441
|
+
lines.append("Permitted Columns:")
|
|
442
|
+
for tbl, c_list in cols.items():
|
|
443
|
+
lines.append(f" • {tbl}: {', '.join(c_list)}")
|
|
444
|
+
if rules:
|
|
445
|
+
lines.append("Row Rules:")
|
|
446
|
+
for r in rules:
|
|
447
|
+
lines.append(f" • {r.get('table')}.{r.get('column')} {r.get('op')} {r.get('value')}")
|
|
448
|
+
return "\n".join(lines)
|
|
449
|
+
except (AuthError, ControlPlaneError) as err:
|
|
450
|
+
return f"Error fetching permissions: {err}"
|
|
451
|
+
|
|
452
|
+
if cmd.startswith("/perm-revoke"):
|
|
453
|
+
parts = cmd.split(maxsplit=1)
|
|
454
|
+
if len(parts) < 2:
|
|
455
|
+
return "Usage: /perm-revoke <role_id|user_id>"
|
|
456
|
+
target = parts[1].strip()
|
|
457
|
+
if not ctx.active_org_id or not ctx.active_db_id:
|
|
458
|
+
return "Select an organization and database first (/use-org and /use-db)."
|
|
459
|
+
try:
|
|
460
|
+
roles = ctx.control.list_roles(ctx.active_org_id)
|
|
461
|
+
matched_role = [
|
|
462
|
+
r for r in roles if r["id"] == target or r["name"].lower() == target.lower()
|
|
463
|
+
]
|
|
464
|
+
if matched_role:
|
|
465
|
+
ctx.control.revoke_permission(
|
|
466
|
+
ctx.active_org_id, ctx.active_db_id, role_id=matched_role[0]["id"]
|
|
467
|
+
)
|
|
468
|
+
return f"Revoked permission for role '{matched_role[0]['name']}' ({matched_role[0]['id']}) on database {ctx.active_db_id}."
|
|
469
|
+
else:
|
|
470
|
+
ctx.control.revoke_permission(
|
|
471
|
+
ctx.active_org_id, ctx.active_db_id, user_id=target
|
|
472
|
+
)
|
|
473
|
+
return f"Revoked permission for user '{target}' on database {ctx.active_db_id}."
|
|
474
|
+
except (AuthError, ControlPlaneError) as err:
|
|
475
|
+
return f"Error revoking permission: {err}"
|
|
476
|
+
|
|
477
|
+
# ── Dashboards & Panels ─────────────────────────────────────────────
|
|
478
|
+
if cmd == "/dashboards":
|
|
479
|
+
try:
|
|
480
|
+
boards = ctx.control.list_dashboards(ctx.active_org_id, ctx.active_db_id)
|
|
481
|
+
if not boards:
|
|
482
|
+
return "No saved dashboards found. Create one with /dashboard-create <title>."
|
|
483
|
+
rows = []
|
|
484
|
+
for b in boards:
|
|
485
|
+
panels = b.get("dashboard_panels") or []
|
|
486
|
+
rows.append({
|
|
487
|
+
"id": b.get("id"),
|
|
488
|
+
"title": b.get("title"),
|
|
489
|
+
"shared": "yes" if b.get("shared") else "no",
|
|
490
|
+
"panels": str(len(panels)),
|
|
491
|
+
"updated_at": (b.get("updated_at") or "")[:19],
|
|
492
|
+
})
|
|
493
|
+
return format_records_table(
|
|
494
|
+
["ID", "Title", "Shared", "Panels", "Updated"],
|
|
495
|
+
rows,
|
|
496
|
+
["id", "title", "shared", "panels", "updated_at"],
|
|
497
|
+
)
|
|
498
|
+
except (AuthError, ControlPlaneError) as err:
|
|
499
|
+
return f"Error listing dashboards: {err}"
|
|
500
|
+
|
|
501
|
+
if cmd.startswith("/dashboard-create"):
|
|
502
|
+
parts = cmd.split(maxsplit=2)
|
|
503
|
+
if len(parts) < 2:
|
|
504
|
+
return "Usage: /dashboard-create <title> [shared]"
|
|
505
|
+
title = parts[1].strip()
|
|
506
|
+
shared = False
|
|
507
|
+
if len(parts) > 2 and parts[2].strip().lower() in ("true", "1", "yes", "shared"):
|
|
508
|
+
shared = True
|
|
509
|
+
if not ctx.active_org_id or not ctx.active_db_id:
|
|
510
|
+
return "Select an organization and database first (/use-org and /use-db)."
|
|
511
|
+
try:
|
|
512
|
+
dash_id = ctx.control.create_dashboard(
|
|
513
|
+
ctx.active_org_id, ctx.active_db_id, title, shared=shared
|
|
514
|
+
)
|
|
515
|
+
return f"Dashboard '{title}' created successfully ({dash_id})."
|
|
516
|
+
except (AuthError, ControlPlaneError) as err:
|
|
517
|
+
return f"Error creating dashboard: {err}"
|
|
518
|
+
|
|
519
|
+
if cmd.startswith("/dashboard-add"):
|
|
520
|
+
parts = cmd.split()
|
|
521
|
+
if len(parts) < 3:
|
|
522
|
+
return "Usage: /dashboard-add <dashboard_id> <query_id> [position]"
|
|
523
|
+
d_id = parts[1].strip()
|
|
524
|
+
q_id = parts[2].strip()
|
|
525
|
+
pos = int(parts[3]) if len(parts) > 3 and parts[3].isdigit() else 0
|
|
526
|
+
if not ctx.active_db_id:
|
|
527
|
+
return "Select a database first (/use-db)."
|
|
528
|
+
try:
|
|
529
|
+
ctx.control.add_dashboard_panel(d_id, ctx.active_db_id, q_id, position=pos)
|
|
530
|
+
return f"Panel with query {q_id} added to dashboard {d_id} at position {pos}."
|
|
531
|
+
except (AuthError, ControlPlaneError) as err:
|
|
532
|
+
return f"Error adding dashboard panel: {err}"
|
|
533
|
+
|
|
534
|
+
if cmd == "/dashboard" or cmd.startswith("/dashboard "):
|
|
535
|
+
target_board_id = None
|
|
536
|
+
parts = cmd.split(maxsplit=2)
|
|
537
|
+
if len(parts) == 2 and parts[1].strip().lower() != "render":
|
|
538
|
+
target_board_id = parts[1].strip()
|
|
539
|
+
elif len(parts) >= 3 and parts[1].strip().lower() == "render":
|
|
540
|
+
target_board_id = parts[2].strip()
|
|
541
|
+
|
|
542
|
+
# If a specific dashboard ID or title was requested, render its panels
|
|
543
|
+
if target_board_id:
|
|
544
|
+
try:
|
|
545
|
+
board = ctx.control.get_dashboard(target_board_id)
|
|
546
|
+
if not board:
|
|
547
|
+
all_boards = ctx.control.list_dashboards(ctx.active_org_id, ctx.active_db_id)
|
|
548
|
+
matched = [
|
|
549
|
+
b for b in all_boards
|
|
550
|
+
if b["id"].startswith(target_board_id)
|
|
551
|
+
or b["title"].lower() == target_board_id.lower()
|
|
552
|
+
]
|
|
553
|
+
if matched:
|
|
554
|
+
board = matched[0]
|
|
555
|
+
if not board:
|
|
556
|
+
return f"Dashboard '{target_board_id}' not found. Use /dashboards to list dashboards."
|
|
557
|
+
|
|
558
|
+
panels = board.get("dashboard_panels") or []
|
|
559
|
+
panels.sort(key=lambda p: (p.get("position", 0), str(p.get("query_id", ""))))
|
|
560
|
+
out = [
|
|
561
|
+
"=" * 60,
|
|
562
|
+
f"DASHBOARD: {board.get('title')} ({board.get('id')})",
|
|
563
|
+
f"Shared: {'yes' if board.get('shared') else 'no'} | Panels: {len(panels)}",
|
|
564
|
+
"=" * 60,
|
|
565
|
+
]
|
|
566
|
+
if not panels:
|
|
567
|
+
out.append("No panels configured on this dashboard yet.")
|
|
568
|
+
return "\n".join(out)
|
|
569
|
+
|
|
570
|
+
for idx, panel in enumerate(panels, 1):
|
|
571
|
+
qid = panel.get("query_id")
|
|
572
|
+
out.append(f"\n--- Panel {idx} [Query {qid}] ---")
|
|
573
|
+
try:
|
|
574
|
+
res = ctx.client.render_panel(board["id"], qid)
|
|
575
|
+
ctx.last_result = res
|
|
576
|
+
tbl = format_table(
|
|
577
|
+
res["columns"], res["rows"], res["row_count"], res["elapsed_ms"]
|
|
578
|
+
)
|
|
579
|
+
out.append(tbl)
|
|
580
|
+
except EngineError as eng_err:
|
|
581
|
+
out.append(f"Panel execution error: {eng_err.detail}")
|
|
582
|
+
except Exception as exc:
|
|
583
|
+
out.append(f"Failed to render panel: {exc}")
|
|
584
|
+
return "\n".join(out)
|
|
585
|
+
except (AuthError, ControlPlaneError) as err:
|
|
586
|
+
return f"Error retrieving dashboard: {err}"
|
|
587
|
+
|
|
588
|
+
# Otherwise display workspace overview dashboard
|
|
589
|
+
auth_status = ctx.auth.status()
|
|
590
|
+
user_str = auth_status.get("email") or "Unauthenticated"
|
|
591
|
+
org_str = ctx.active_org_name or "(none)"
|
|
592
|
+
db_str = ctx.active_db_name or ("sample" if not ctx.active_db_id else ctx.active_db_id[:8])
|
|
593
|
+
mode_str = "LIVE (governed)" if ctx.client.is_live else "SAMPLE (synthetic)"
|
|
594
|
+
|
|
595
|
+
table_count = "unknown"
|
|
596
|
+
try:
|
|
597
|
+
schema_data = ctx.client.schema()
|
|
598
|
+
table_count = str(len(schema_data.get("tables", {})))
|
|
599
|
+
except Exception:
|
|
600
|
+
pass
|
|
601
|
+
|
|
602
|
+
saved_count = len(ctx.queries.list(database_id=ctx.active_db_id))
|
|
603
|
+
last_q = "(none)"
|
|
604
|
+
if ctx.last_result:
|
|
605
|
+
last_q = f"{ctx.last_result.get('row_count', 0)} rows in {ctx.last_result.get('elapsed_ms', 0)} ms"
|
|
606
|
+
|
|
607
|
+
dash_lines = [
|
|
608
|
+
"┌────────────────────────────────────────────────────────┐",
|
|
609
|
+
"│ MORPHY DASHBOARD │",
|
|
610
|
+
"├────────────────────────────────────────────────────────┤",
|
|
611
|
+
f"│ Workspace Mode: {mode_str:<35}│",
|
|
612
|
+
f"│ Current User: {user_str[:35]:<35}│",
|
|
613
|
+
f"│ Active Org: {org_str[:35]:<35}│",
|
|
614
|
+
f"│ Active Database: {db_str[:35]:<35}│",
|
|
615
|
+
f"│ Permitted Tables: {table_count:<35}│",
|
|
616
|
+
f"│ Saved Queries: {str(saved_count):<35}│",
|
|
617
|
+
f"│ Last Execution: {last_q:<35}│",
|
|
618
|
+
"├────────────────────────────────────────────────────────┤",
|
|
619
|
+
"│ Quick actions: │",
|
|
620
|
+
"│ /ask <prompt> Generate governed SQL │",
|
|
621
|
+
"│ /dashboards List and render dashboards │",
|
|
622
|
+
"│ /queries List and manage saved queries │",
|
|
623
|
+
"│ /dbs Browse organization databases │",
|
|
624
|
+
"│ /schema Inspect table column types │",
|
|
625
|
+
"└────────────────────────────────────────────────────────┘",
|
|
626
|
+
]
|
|
627
|
+
return "\n".join(dash_lines)
|
|
628
|
+
|
|
629
|
+
# ── Export & JSON ───────────────────────────────────────────────────
|
|
630
|
+
if cmd == "/json":
|
|
631
|
+
if not ctx.last_result:
|
|
632
|
+
return "No previous query result to display."
|
|
633
|
+
return format_json(ctx.last_result)
|
|
634
|
+
|
|
635
|
+
if cmd.startswith("/export"):
|
|
636
|
+
parts = cmd.split(maxsplit=1)
|
|
637
|
+
if len(parts) < 2:
|
|
638
|
+
return "Usage: /export <filename.csv>"
|
|
639
|
+
if not ctx.last_result:
|
|
640
|
+
return "No previous query result to export."
|
|
641
|
+
out_path = Path(parts[1].strip())
|
|
642
|
+
csv_content = format_csv(ctx.last_result["columns"], ctx.last_result["rows"])
|
|
643
|
+
try:
|
|
644
|
+
out_path.write_text(csv_content, encoding="utf-8")
|
|
645
|
+
return f"Saved {ctx.last_result.get('row_count', 0)} rows to {out_path}."
|
|
646
|
+
except Exception as err:
|
|
647
|
+
return f"Failed to write CSV: {err}"
|
|
648
|
+
|
|
649
|
+
# ── Schema & Examples ───────────────────────────────────────────────
|
|
650
|
+
if cmd in ("/schema", "/examples"):
|
|
651
|
+
metadata = ctx.client.schema()
|
|
652
|
+
if cmd == "/schema":
|
|
653
|
+
return format_schema(metadata.get("tables", {}))
|
|
654
|
+
return format_examples(metadata.get("examples", ["Use /ask followed by your question."]))
|
|
655
|
+
|
|
656
|
+
# ── Natural Language /ask ───────────────────────────────────────────
|
|
657
|
+
if cmd.startswith("/ask "):
|
|
658
|
+
question = cmd[5:].strip()
|
|
659
|
+
if not question:
|
|
660
|
+
return "Usage: /ask <question>"
|
|
661
|
+
generated = ctx.client.generate(question)
|
|
662
|
+
output = format_generation(generated)
|
|
663
|
+
|
|
664
|
+
# In sample mode: execute immediately
|
|
665
|
+
if not ctx.client.is_live:
|
|
666
|
+
res_table = _execute_sql(ctx, generated["sql"])
|
|
667
|
+
return output + "\n\n" + res_table
|
|
668
|
+
|
|
669
|
+
# In live mode: Always display generated SQL first, then request confirmation
|
|
670
|
+
print(output)
|
|
671
|
+
try:
|
|
672
|
+
confirm = input("Execute this SQL? [y/N] ").strip().lower()
|
|
673
|
+
except (EOFError, KeyboardInterrupt):
|
|
674
|
+
return "Cancelled."
|
|
675
|
+
if confirm == "y":
|
|
676
|
+
return _execute_sql(ctx, generated["sql"])
|
|
677
|
+
return "(not executed)"
|
|
678
|
+
|
|
679
|
+
# ── Direct SQL Execution ────────────────────────────────────────────
|
|
680
|
+
if cmd.startswith("/sql "):
|
|
681
|
+
sql = cmd[5:].strip()
|
|
682
|
+
if not sql:
|
|
683
|
+
return "Usage: /sql <SELECT ...>"
|
|
684
|
+
return _execute_sql(ctx, sql)
|
|
685
|
+
|
|
686
|
+
return "Unknown command. Use /sql followed by a SELECT query, or /help."
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def run_repl(client: MorphyClient, auth_manager: Optional[AuthManager] = None):
|
|
690
|
+
"""Launch the interactive prompt-toolkit REPL."""
|
|
691
|
+
auth = auth_manager or AuthManager()
|
|
692
|
+
# Try restoring session silently
|
|
693
|
+
auth.restore_session()
|
|
694
|
+
client.set_token_source(lambda: auth.get_valid_token())
|
|
695
|
+
|
|
696
|
+
ctx = ShellContext(client, auth)
|
|
697
|
+
auth_status = auth.status()
|
|
698
|
+
|
|
699
|
+
banner = format_banner(
|
|
700
|
+
version="0.5.0",
|
|
701
|
+
engine_url=client.base_url,
|
|
702
|
+
database=client.database,
|
|
703
|
+
user_email=auth_status.get("email"),
|
|
704
|
+
org_name=ctx.active_org_name,
|
|
705
|
+
)
|
|
706
|
+
print(banner)
|
|
707
|
+
|
|
708
|
+
bindings = KeyBindings()
|
|
709
|
+
|
|
710
|
+
@bindings.add("c-c")
|
|
711
|
+
def _(event):
|
|
712
|
+
"""Ctrl+C clears current input line without exiting REPL."""
|
|
713
|
+
event.current_buffer.reset()
|
|
714
|
+
print()
|
|
715
|
+
|
|
716
|
+
session: PromptSession = PromptSession(
|
|
717
|
+
message=ctx.get_prompt,
|
|
718
|
+
completer=SlashCompleter(is_live=client.is_live),
|
|
719
|
+
history=InMemoryHistory(),
|
|
720
|
+
key_bindings=bindings,
|
|
721
|
+
complete_while_typing=False,
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
while True:
|
|
725
|
+
try:
|
|
726
|
+
line = session.prompt().strip()
|
|
727
|
+
except KeyboardInterrupt:
|
|
728
|
+
continue
|
|
729
|
+
except EOFError:
|
|
730
|
+
print()
|
|
731
|
+
break
|
|
732
|
+
|
|
733
|
+
if not line:
|
|
734
|
+
continue
|
|
735
|
+
|
|
736
|
+
try:
|
|
737
|
+
output = _handle_command(ctx, line, session)
|
|
738
|
+
except EngineError as error:
|
|
739
|
+
print(f"Error: {error.detail}")
|
|
740
|
+
continue
|
|
741
|
+
except httpx.HTTPError as error:
|
|
742
|
+
print(f"Network error: {error}")
|
|
743
|
+
continue
|
|
744
|
+
|
|
745
|
+
if output is None:
|
|
746
|
+
break
|
|
747
|
+
print(output)
|