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/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>] &gt; ")
122
+ return HTML("<ansicyan>morphy</ansicyan> (<ansiyellow>sample</ansiyellow>) &gt; ")
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)