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/control.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Client for Supabase control-plane RPCs and PostgREST tables."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
import uuid
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .auth import AuthManager, AuthError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ControlPlaneError(Exception):
|
|
11
|
+
"""Error during control plane operations."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ControlClient:
|
|
16
|
+
"""Interacts with Supabase PostgREST endpoints and RPCs."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, auth_manager: AuthManager):
|
|
19
|
+
self.auth = auth_manager
|
|
20
|
+
|
|
21
|
+
def _request(
|
|
22
|
+
self,
|
|
23
|
+
method: str,
|
|
24
|
+
path: str,
|
|
25
|
+
params: Optional[dict[str, Any]] = None,
|
|
26
|
+
json_body: Optional[Any] = None,
|
|
27
|
+
) -> Any:
|
|
28
|
+
token = self.auth.get_valid_token()
|
|
29
|
+
headers = {
|
|
30
|
+
"apikey": self.auth.supabase_key,
|
|
31
|
+
"Authorization": f"Bearer {token}",
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
"Prefer": "return=representation",
|
|
34
|
+
}
|
|
35
|
+
url = f"{self.auth.supabase_url}{path}"
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
with httpx.Client(timeout=15.0) as client:
|
|
39
|
+
resp = client.request(method, url, headers=headers, params=params, json=json_body)
|
|
40
|
+
except httpx.HTTPError as exc:
|
|
41
|
+
raise ControlPlaneError(f"Network error accessing control plane: {exc}")
|
|
42
|
+
|
|
43
|
+
if resp.is_error:
|
|
44
|
+
try:
|
|
45
|
+
err = resp.json()
|
|
46
|
+
detail = err.get("message") or err.get("detail") or resp.text
|
|
47
|
+
except Exception:
|
|
48
|
+
detail = resp.text
|
|
49
|
+
raise ControlPlaneError(f"Control plane error ({resp.status_code}): {detail}")
|
|
50
|
+
|
|
51
|
+
if resp.status_code == 204 or not resp.content:
|
|
52
|
+
return None
|
|
53
|
+
return resp.json()
|
|
54
|
+
|
|
55
|
+
# --- Profile ---
|
|
56
|
+
|
|
57
|
+
def get_profile(self) -> Optional[dict[str, Any]]:
|
|
58
|
+
status = self.auth.status()
|
|
59
|
+
user_id = status.get("user_id")
|
|
60
|
+
if not user_id:
|
|
61
|
+
res = self._request("GET", "/rest/v1/profiles?select=*")
|
|
62
|
+
return res[0] if res else None
|
|
63
|
+
|
|
64
|
+
res = self._request("GET", f"/rest/v1/profiles?id=eq.{user_id}&select=*")
|
|
65
|
+
return res[0] if res else None
|
|
66
|
+
|
|
67
|
+
def claim_profile(self, username: str, display_name: str) -> None:
|
|
68
|
+
self._request(
|
|
69
|
+
"POST",
|
|
70
|
+
"/rest/v1/rpc/claim_profile",
|
|
71
|
+
json_body={"handle": username.strip().lower(), "full_name": display_name.strip()},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def update_profile(self, display_name: str, avatar_path: Optional[str] = None) -> None:
|
|
75
|
+
try:
|
|
76
|
+
self._request(
|
|
77
|
+
"POST",
|
|
78
|
+
"/rest/v1/rpc/update_profile",
|
|
79
|
+
json_body={"full_name": display_name.strip(), "avatar": avatar_path},
|
|
80
|
+
)
|
|
81
|
+
return
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
profile = self.get_profile()
|
|
86
|
+
user_id = profile.get("id") if profile else None
|
|
87
|
+
if not user_id:
|
|
88
|
+
status = self.auth.status()
|
|
89
|
+
user_id = status.get("user_id")
|
|
90
|
+
if not user_id:
|
|
91
|
+
raise ControlPlaneError("Must be authenticated with a claimed profile to edit.")
|
|
92
|
+
self._request(
|
|
93
|
+
"PATCH",
|
|
94
|
+
f"/rest/v1/profiles?id=eq.{user_id}",
|
|
95
|
+
json_body={"display_name": display_name.strip()},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# --- Organizations ---
|
|
99
|
+
|
|
100
|
+
def list_organizations(self) -> list[dict[str, Any]]:
|
|
101
|
+
return self._request("GET", "/rest/v1/organizations?select=*&order=name.asc") or []
|
|
102
|
+
|
|
103
|
+
def create_organization(self, name: str) -> str:
|
|
104
|
+
res = self._request(
|
|
105
|
+
"POST",
|
|
106
|
+
"/rest/v1/rpc/create_workspace",
|
|
107
|
+
json_body={"title": name.strip()},
|
|
108
|
+
)
|
|
109
|
+
return str(res)
|
|
110
|
+
|
|
111
|
+
# --- Target Databases ---
|
|
112
|
+
|
|
113
|
+
def list_databases(self, org_id: Optional[str] = None) -> list[dict[str, Any]]:
|
|
114
|
+
query = "/rest/v1/target_databases?select=*&order=name.asc"
|
|
115
|
+
if org_id:
|
|
116
|
+
query += f"&org_id=eq.{org_id}"
|
|
117
|
+
return self._request("GET", query) or []
|
|
118
|
+
|
|
119
|
+
def register_database(self, org_id: str, name: str, dialect: str) -> str:
|
|
120
|
+
res = self._request(
|
|
121
|
+
"POST",
|
|
122
|
+
"/rest/v1/rpc/register_database",
|
|
123
|
+
json_body={"o": org_id, "title": name.strip(), "engine": dialect.strip().lower()},
|
|
124
|
+
)
|
|
125
|
+
return str(res)
|
|
126
|
+
|
|
127
|
+
# --- Members & Roles ---
|
|
128
|
+
|
|
129
|
+
def list_roles(self, org_id: str) -> list[dict[str, Any]]:
|
|
130
|
+
return self._request("GET", f"/rest/v1/roles?org_id=eq.{org_id}&select=*") or []
|
|
131
|
+
|
|
132
|
+
def list_members(self, org_id: str) -> list[dict[str, Any]]:
|
|
133
|
+
return self._request(
|
|
134
|
+
"POST",
|
|
135
|
+
"/rest/v1/rpc/org_people",
|
|
136
|
+
json_body={"o": org_id},
|
|
137
|
+
) or []
|
|
138
|
+
|
|
139
|
+
def set_member_role(self, org_id: str, user_id: str, role_id: str) -> None:
|
|
140
|
+
self._request(
|
|
141
|
+
"POST",
|
|
142
|
+
"/rest/v1/rpc/set_member_role",
|
|
143
|
+
json_body={"o": org_id, "person": user_id, "r": role_id},
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# --- Invitations ---
|
|
147
|
+
|
|
148
|
+
def list_invitations(self) -> list[dict[str, Any]]:
|
|
149
|
+
return self._request("GET", "/rest/v1/invitations?select=*&order=created_at.desc") or []
|
|
150
|
+
|
|
151
|
+
def invite_person(self, org_id: str, user_id: str, role_id: str) -> None:
|
|
152
|
+
self._request(
|
|
153
|
+
"POST",
|
|
154
|
+
"/rest/v1/rpc/invite_person",
|
|
155
|
+
json_body={"o": org_id, "person": user_id, "r": role_id},
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def accept_invitation(self, invitation_id: str) -> None:
|
|
159
|
+
self._request(
|
|
160
|
+
"POST",
|
|
161
|
+
"/rest/v1/rpc/accept_invitation",
|
|
162
|
+
json_body={"inv": invitation_id},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def withdraw_invitation(self, invitation_id: str) -> None:
|
|
166
|
+
try:
|
|
167
|
+
self._request(
|
|
168
|
+
"POST",
|
|
169
|
+
"/rest/v1/rpc/revoke_invitation",
|
|
170
|
+
json_body={"inv": invitation_id},
|
|
171
|
+
)
|
|
172
|
+
return
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
self._request(
|
|
177
|
+
"DELETE",
|
|
178
|
+
f"/rest/v1/invitations?id=eq.{invitation_id}",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def find_people(self, prefix: str) -> list[dict[str, Any]]:
|
|
182
|
+
return self._request(
|
|
183
|
+
"POST",
|
|
184
|
+
"/rest/v1/rpc/find_people",
|
|
185
|
+
json_body={"prefix": prefix.strip().lower()},
|
|
186
|
+
) or []
|
|
187
|
+
|
|
188
|
+
# --- Dashboards & Panels (004) ---
|
|
189
|
+
|
|
190
|
+
def list_dashboards(
|
|
191
|
+
self, org_id: Optional[str] = None, database_id: Optional[str] = None
|
|
192
|
+
) -> list[dict[str, Any]]:
|
|
193
|
+
query = "/rest/v1/dashboards?select=*,dashboard_panels(*)&order=updated_at.desc"
|
|
194
|
+
if org_id:
|
|
195
|
+
query += f"&org_id=eq.{org_id}"
|
|
196
|
+
if database_id:
|
|
197
|
+
query += f"&database_id=eq.{database_id}"
|
|
198
|
+
return self._request("GET", query) or []
|
|
199
|
+
|
|
200
|
+
def get_dashboard(self, dashboard_id: str) -> Optional[dict[str, Any]]:
|
|
201
|
+
res = self._request(
|
|
202
|
+
"GET",
|
|
203
|
+
f"/rest/v1/dashboards?id=eq.{dashboard_id}&select=*,dashboard_panels(*)",
|
|
204
|
+
)
|
|
205
|
+
return res[0] if res else None
|
|
206
|
+
|
|
207
|
+
def create_dashboard(
|
|
208
|
+
self, org_id: str, database_id: str, title: str, shared: bool = False
|
|
209
|
+
) -> str:
|
|
210
|
+
res = self._request(
|
|
211
|
+
"POST",
|
|
212
|
+
"/rest/v1/dashboards",
|
|
213
|
+
json_body={
|
|
214
|
+
"org_id": org_id,
|
|
215
|
+
"database_id": database_id,
|
|
216
|
+
"title": title.strip(),
|
|
217
|
+
"shared": shared,
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
if isinstance(res, list) and res:
|
|
221
|
+
return str(res[0].get("id"))
|
|
222
|
+
return str(res)
|
|
223
|
+
|
|
224
|
+
def add_dashboard_panel(
|
|
225
|
+
self, dashboard_id: str, database_id: str, query_id: str, position: int = 0
|
|
226
|
+
) -> None:
|
|
227
|
+
self._request(
|
|
228
|
+
"POST",
|
|
229
|
+
"/rest/v1/dashboard_panels",
|
|
230
|
+
json_body={
|
|
231
|
+
"dashboard_id": dashboard_id,
|
|
232
|
+
"database_id": database_id,
|
|
233
|
+
"query_id": query_id,
|
|
234
|
+
"position": position,
|
|
235
|
+
},
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def delete_dashboard(self, dashboard_id: str) -> None:
|
|
239
|
+
self._request("DELETE", f"/rest/v1/dashboards?id=eq.{dashboard_id}")
|
|
240
|
+
|
|
241
|
+
# --- Cloud Saved Queries (003) ---
|
|
242
|
+
|
|
243
|
+
def list_saved_queries(
|
|
244
|
+
self, org_id: Optional[str] = None, database_id: Optional[str] = None
|
|
245
|
+
) -> list[dict[str, Any]]:
|
|
246
|
+
query = "/rest/v1/saved_queries?select=*&order=updated_at.desc"
|
|
247
|
+
if org_id:
|
|
248
|
+
query += f"&org_id=eq.{org_id}"
|
|
249
|
+
if database_id:
|
|
250
|
+
query += f"&database_id=eq.{database_id}"
|
|
251
|
+
return self._request("GET", query) or []
|
|
252
|
+
|
|
253
|
+
def create_saved_query(
|
|
254
|
+
self, org_id: str, database_id: str, title: str, sql_text: str, shared: bool = False
|
|
255
|
+
) -> str:
|
|
256
|
+
res = self._request(
|
|
257
|
+
"POST",
|
|
258
|
+
"/rest/v1/saved_queries",
|
|
259
|
+
json_body={
|
|
260
|
+
"org_id": org_id,
|
|
261
|
+
"database_id": database_id,
|
|
262
|
+
"title": title.strip(),
|
|
263
|
+
"sql_text": sql_text.strip(),
|
|
264
|
+
"shared": shared,
|
|
265
|
+
},
|
|
266
|
+
)
|
|
267
|
+
if isinstance(res, list) and res:
|
|
268
|
+
return str(res[0].get("id"))
|
|
269
|
+
return str(res)
|
|
270
|
+
|
|
271
|
+
def delete_saved_query(self, query_id: str) -> None:
|
|
272
|
+
self._request("DELETE", f"/rest/v1/saved_queries?id=eq.{query_id}")
|
|
273
|
+
|
|
274
|
+
# --- Permissions & Access Policy ---
|
|
275
|
+
|
|
276
|
+
def save_permission(
|
|
277
|
+
self,
|
|
278
|
+
org_id: str,
|
|
279
|
+
database_id: str,
|
|
280
|
+
role_id: Optional[str] = None,
|
|
281
|
+
user_id: Optional[str] = None,
|
|
282
|
+
level: str = "QUERY_ONLY",
|
|
283
|
+
cols: Optional[dict[str, list[str]]] = None,
|
|
284
|
+
rules: Optional[list[dict[str, Any]]] = None,
|
|
285
|
+
) -> None:
|
|
286
|
+
self._request(
|
|
287
|
+
"POST",
|
|
288
|
+
"/rest/v1/rpc/save_permission",
|
|
289
|
+
json_body={
|
|
290
|
+
"o": org_id,
|
|
291
|
+
"d": database_id,
|
|
292
|
+
"r": role_id,
|
|
293
|
+
"person": user_id,
|
|
294
|
+
"level": level,
|
|
295
|
+
"cols": cols or {},
|
|
296
|
+
"rules": rules or [],
|
|
297
|
+
},
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def revoke_permission(
|
|
301
|
+
self,
|
|
302
|
+
org_id: str,
|
|
303
|
+
database_id: str,
|
|
304
|
+
role_id: Optional[str] = None,
|
|
305
|
+
user_id: Optional[str] = None,
|
|
306
|
+
) -> None:
|
|
307
|
+
self.save_permission(
|
|
308
|
+
org_id=org_id,
|
|
309
|
+
database_id=database_id,
|
|
310
|
+
role_id=role_id,
|
|
311
|
+
user_id=user_id,
|
|
312
|
+
level="NONE",
|
|
313
|
+
cols={},
|
|
314
|
+
rules=[],
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def effective_permission(self, database_id: str) -> dict[str, Any]:
|
|
318
|
+
return self._request(
|
|
319
|
+
"POST",
|
|
320
|
+
"/rest/v1/rpc/effective_permission",
|
|
321
|
+
json_body={"d": database_id},
|
|
322
|
+
) or {"mode": "NONE", "table_columns": {}, "row_rules": []}
|
|
323
|
+
|
morphy_cli/formatting.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Result formatting for the MORPHY terminal."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Sequence
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def format_table(columns: list[str], rows: list[list], row_count: int, elapsed_ms: float) -> str:
|
|
10
|
+
"""Format query results as a readable aligned table."""
|
|
11
|
+
if not columns:
|
|
12
|
+
return f"(empty result · {elapsed_ms} ms)"
|
|
13
|
+
|
|
14
|
+
# Convert values to display strings, respecting NULLs.
|
|
15
|
+
str_rows = []
|
|
16
|
+
for row in rows:
|
|
17
|
+
str_rows.append(["\u2205" if v is None else str(v) for v in row])
|
|
18
|
+
|
|
19
|
+
# Compute column widths.
|
|
20
|
+
widths = [len(c) for c in columns]
|
|
21
|
+
for row in str_rows:
|
|
22
|
+
for i, cell in enumerate(row):
|
|
23
|
+
widths[i] = max(widths[i], len(cell))
|
|
24
|
+
|
|
25
|
+
# Cap each column at 48 characters for readability.
|
|
26
|
+
widths = [min(w, 48) for w in widths]
|
|
27
|
+
|
|
28
|
+
def truncate(text: str, width: int) -> str:
|
|
29
|
+
return text if len(text) <= width else text[: width - 1] + "\u2026"
|
|
30
|
+
|
|
31
|
+
lines = []
|
|
32
|
+
header = " \u2502 ".join(truncate(c, w).ljust(w) for c, w in zip(columns, widths))
|
|
33
|
+
lines.append(header)
|
|
34
|
+
lines.append("\u2500" * len(header))
|
|
35
|
+
for row in str_rows:
|
|
36
|
+
line = " \u2502 ".join(truncate(cell, w).ljust(w) for cell, w in zip(row, widths))
|
|
37
|
+
lines.append(line)
|
|
38
|
+
|
|
39
|
+
capped = " (capped)" if row_count >= 1000 else ""
|
|
40
|
+
lines.append(f"\n{row_count} row{'s' if row_count != 1 else ''}{capped} · {elapsed_ms} ms")
|
|
41
|
+
return "\n".join(lines)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def format_csv(columns: list[str], rows: list[list]) -> str:
|
|
45
|
+
"""Export query results as a standard RFC-4180 CSV string."""
|
|
46
|
+
output = io.StringIO()
|
|
47
|
+
writer = csv.writer(output, lineterminator="\n")
|
|
48
|
+
writer.writerow(columns)
|
|
49
|
+
for row in rows:
|
|
50
|
+
writer.writerow(["" if cell is None else cell for cell in row])
|
|
51
|
+
return output.getvalue()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def format_records_table(headers: list[str], items: list[dict[str, Any]], keys: list[str]) -> str:
|
|
55
|
+
"""Format an arbitrary list of objects as an aligned CLI table."""
|
|
56
|
+
if not items:
|
|
57
|
+
return "(none)"
|
|
58
|
+
rows = [[str(item.get(k, "")) for k in keys] for item in items]
|
|
59
|
+
return format_table(headers, rows, len(items), 0.0).split("\n\n")[0]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def format_schema(tables: dict) -> str:
|
|
63
|
+
"""Format schema metadata as an indented tree."""
|
|
64
|
+
if not tables:
|
|
65
|
+
return "(no permitted tables)"
|
|
66
|
+
lines = []
|
|
67
|
+
for table, columns in tables.items():
|
|
68
|
+
lines.append(f" {table}")
|
|
69
|
+
if isinstance(columns, dict):
|
|
70
|
+
for col, dtype in columns.items():
|
|
71
|
+
lines.append(f" {col}: {dtype}")
|
|
72
|
+
elif isinstance(columns, list):
|
|
73
|
+
for col in columns:
|
|
74
|
+
lines.append(f" {col}")
|
|
75
|
+
return "\n".join(lines)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def format_examples(examples: list[str]) -> str:
|
|
79
|
+
"""Format example question titles."""
|
|
80
|
+
if not examples:
|
|
81
|
+
return "(no examples available)"
|
|
82
|
+
return "\n".join(f" • {title}" for title in examples)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def format_generation(gen: dict) -> str:
|
|
86
|
+
"""Format a generation response showing SQL and metadata."""
|
|
87
|
+
lines = []
|
|
88
|
+
lines.append(gen["sql"])
|
|
89
|
+
meta = []
|
|
90
|
+
if gen.get("provider"):
|
|
91
|
+
meta.append(gen["provider"])
|
|
92
|
+
if gen.get("attempts"):
|
|
93
|
+
meta.append(f"{gen['attempts']} attempt{'s' if gen['attempts'] != 1 else ''}")
|
|
94
|
+
if gen.get("cache"):
|
|
95
|
+
meta.append(f"cache {gen['cache']}")
|
|
96
|
+
if meta:
|
|
97
|
+
lines.append(f"({' · '.join(meta)})")
|
|
98
|
+
return "\n".join(lines)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def format_json(data: Any) -> str:
|
|
102
|
+
"""Pretty-print arbitrary data as JSON."""
|
|
103
|
+
return json.dumps(data, indent=2, default=str)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def format_banner(
|
|
107
|
+
*,
|
|
108
|
+
version: str = "0.5.0",
|
|
109
|
+
engine_url: str = "http://127.0.0.1:8000",
|
|
110
|
+
database: Any = None,
|
|
111
|
+
user_email: Any = None,
|
|
112
|
+
org_name: Any = None,
|
|
113
|
+
) -> str:
|
|
114
|
+
"""Render the stylish Claude-Code-inspired ASCII brand banner."""
|
|
115
|
+
ORANGE = "\033[38;5;208m"
|
|
116
|
+
DIM = "\033[90m"
|
|
117
|
+
BOLD = "\033[1m"
|
|
118
|
+
GREEN = "\033[92m"
|
|
119
|
+
RESET = "\033[0m"
|
|
120
|
+
WHITE = "\033[97m"
|
|
121
|
+
CYAN = "\033[96m"
|
|
122
|
+
|
|
123
|
+
logo = f"""
|
|
124
|
+
{ORANGE}{BOLD} ███╗ ███╗ ██████╗ ██████╗ ██████╗ ██╗ ██╗██╗ ██╗
|
|
125
|
+
████╗ ████║██╔═══██╗██╔══██╗██╔══██╗██║ ██║╚██╗ ██╔╝
|
|
126
|
+
██╔████╔██║██║ ██║██████╔╝██████╔╝███████║ ╚████╔╝
|
|
127
|
+
██║╚██╔╝██║██║ ██║██╔══██╗██╔═══╝ ██╔══██║ ╚██╔╝
|
|
128
|
+
██║ ╚═╝ ██║╚██████╔╝██║ ██║██║ ██║ ██║ ██║
|
|
129
|
+
╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ {RESET}
|
|
130
|
+
{DIM}Governed Natural-Language Database Terminal{RESET} {CYAN}v{version}{RESET}"""
|
|
131
|
+
|
|
132
|
+
lines = [logo]
|
|
133
|
+
if database:
|
|
134
|
+
db_label = f"{org_name}/{database}" if org_name else str(database)
|
|
135
|
+
lines.append(f" {GREEN}●{RESET} Database: {WHITE}{BOLD}{db_label}{RESET} {DIM}(live query mode){RESET}")
|
|
136
|
+
if user_email:
|
|
137
|
+
lines.append(f" {GREEN}●{RESET} User: {WHITE}{BOLD}{user_email}{RESET}")
|
|
138
|
+
else:
|
|
139
|
+
lines.append(f" {GREEN}●{RESET} Workspace: {WHITE}{BOLD}SAMPLE{RESET} {DIM}(synthetic commerce · APAC policy){RESET}")
|
|
140
|
+
|
|
141
|
+
lines.append(f" {GREEN}●{RESET} Engine: {WHITE}{BOLD}{engine_url}{RESET}")
|
|
142
|
+
lines.append(f"\n {DIM}Type {CYAN}/help{DIM} for commands · {CYAN}Tab{DIM} to autocomplete · {CYAN}Ctrl+C{DIM} to clear · {CYAN}Ctrl+D{DIM} to exit{RESET}\n")
|
|
143
|
+
return "\n".join(lines)
|
morphy_cli/main.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""MORPHY governed query terminal.
|
|
2
|
+
|
|
3
|
+
Supports both interactive (REPL with prompt-toolkit) and noninteractive
|
|
4
|
+
(--sql) modes. Install with ``python -m pip install -e ./cli``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
from .auth import AuthManager
|
|
12
|
+
from .client import EngineError, MorphyClient
|
|
13
|
+
from .formatting import format_csv, format_json, format_table
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main():
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="morphy",
|
|
21
|
+
description="MORPHY — governed natural-language database terminal",
|
|
22
|
+
epilog=(
|
|
23
|
+
"Examples:\n"
|
|
24
|
+
" morphy Open the sample REPL\n"
|
|
25
|
+
" morphy --database <UUID> Query an organization database\n"
|
|
26
|
+
" morphy --sql 'SELECT 1' Run one query and exit\n"
|
|
27
|
+
" morphy --sql 'SELECT 1' --json Output results in JSON format\n"
|
|
28
|
+
" morphy --sql 'SELECT 1' --csv Output results in CSV format\n"
|
|
29
|
+
),
|
|
30
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--url",
|
|
34
|
+
default="http://127.0.0.1:8000",
|
|
35
|
+
help="Engine base URL (default: http://127.0.0.1:8000)",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--sql",
|
|
39
|
+
metavar="QUERY",
|
|
40
|
+
help="Run one read-only SQL query and exit",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--database",
|
|
44
|
+
type=UUID,
|
|
45
|
+
metavar="UUID",
|
|
46
|
+
help="Query an organization database using saved session or MORPHY_ACCESS_TOKEN",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--json",
|
|
50
|
+
action="store_true",
|
|
51
|
+
help="Output noninteractive query results as JSON",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--csv",
|
|
55
|
+
action="store_true",
|
|
56
|
+
help="Output noninteractive query results as CSV",
|
|
57
|
+
)
|
|
58
|
+
args = parser.parse_args()
|
|
59
|
+
|
|
60
|
+
auth = AuthManager()
|
|
61
|
+
auth.restore_session()
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
client = MorphyClient(
|
|
65
|
+
args.url,
|
|
66
|
+
args.database,
|
|
67
|
+
token=lambda: auth.get_valid_token(),
|
|
68
|
+
)
|
|
69
|
+
except SystemExit as exc:
|
|
70
|
+
parser.error(str(exc))
|
|
71
|
+
|
|
72
|
+
# Noninteractive mode: execute single SQL statement and exit
|
|
73
|
+
if args.sql:
|
|
74
|
+
try:
|
|
75
|
+
result = client.query(args.sql)
|
|
76
|
+
if args.json:
|
|
77
|
+
print(format_json(result))
|
|
78
|
+
elif args.csv:
|
|
79
|
+
print(format_csv(result["columns"], result["rows"]), end="")
|
|
80
|
+
else:
|
|
81
|
+
print(
|
|
82
|
+
format_table(
|
|
83
|
+
result["columns"],
|
|
84
|
+
result["rows"],
|
|
85
|
+
result["row_count"],
|
|
86
|
+
result["elapsed_ms"],
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
except EngineError as error:
|
|
90
|
+
print(f"Error: {error.detail}", file=sys.stderr)
|
|
91
|
+
if error.status_code >= 500:
|
|
92
|
+
sys.exit(2)
|
|
93
|
+
sys.exit(1)
|
|
94
|
+
except httpx.HTTPError as error:
|
|
95
|
+
print(f"Network error: {error}", file=sys.stderr)
|
|
96
|
+
sys.exit(2)
|
|
97
|
+
except KeyboardInterrupt:
|
|
98
|
+
print("\nCancelled.", file=sys.stderr)
|
|
99
|
+
sys.exit(130)
|
|
100
|
+
finally:
|
|
101
|
+
client.close()
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
# Interactive REPL mode
|
|
105
|
+
try:
|
|
106
|
+
from .shell import run_repl
|
|
107
|
+
|
|
108
|
+
run_repl(client, auth_manager=auth)
|
|
109
|
+
except KeyboardInterrupt:
|
|
110
|
+
print()
|
|
111
|
+
sys.exit(130)
|
|
112
|
+
finally:
|
|
113
|
+
client.close()
|
morphy_cli/queries.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Persistent saved queries manager for MORPHY CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
CONFIG_DIR = Path.home() / ".morphy"
|
|
9
|
+
QUERIES_FILE = CONFIG_DIR / "queries.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class QueryStore:
|
|
13
|
+
"""Manages saved queries persisted in ~/.morphy/queries.json."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, filepath: Optional[Path] = None):
|
|
16
|
+
self.filepath = filepath or QUERIES_FILE
|
|
17
|
+
|
|
18
|
+
def _load(self) -> dict[str, dict[str, Any]]:
|
|
19
|
+
if not self.filepath.is_file():
|
|
20
|
+
return {}
|
|
21
|
+
try:
|
|
22
|
+
data = json.loads(self.filepath.read_text(encoding="utf-8"))
|
|
23
|
+
return data if isinstance(data, dict) else {}
|
|
24
|
+
except Exception:
|
|
25
|
+
return {}
|
|
26
|
+
|
|
27
|
+
def _save(self, data: dict[str, dict[str, Any]]) -> None:
|
|
28
|
+
self.filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
self.filepath.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
30
|
+
|
|
31
|
+
def save(
|
|
32
|
+
self,
|
|
33
|
+
name: str,
|
|
34
|
+
sql: str,
|
|
35
|
+
database_id: Optional[str] = None,
|
|
36
|
+
description: str = "",
|
|
37
|
+
) -> dict[str, Any]:
|
|
38
|
+
"""Save or update a query by name."""
|
|
39
|
+
clean_name = name.strip()
|
|
40
|
+
data = self._load()
|
|
41
|
+
record = {
|
|
42
|
+
"name": clean_name,
|
|
43
|
+
"sql": sql.strip(),
|
|
44
|
+
"database_id": database_id or "sample",
|
|
45
|
+
"description": description.strip(),
|
|
46
|
+
"created_at": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()),
|
|
47
|
+
}
|
|
48
|
+
data[clean_name] = record
|
|
49
|
+
self._save(data)
|
|
50
|
+
return record
|
|
51
|
+
|
|
52
|
+
def list(self, database_id: Optional[str] = None) -> list[dict[str, Any]]:
|
|
53
|
+
"""List all saved queries, optionally filtered by database."""
|
|
54
|
+
data = self._load()
|
|
55
|
+
records = list(data.values())
|
|
56
|
+
if database_id:
|
|
57
|
+
records = [r for r in records if r.get("database_id") in (database_id, "sample")]
|
|
58
|
+
records.sort(key=lambda r: r.get("name", ""))
|
|
59
|
+
return records
|
|
60
|
+
|
|
61
|
+
def get(self, name: str) -> Optional[dict[str, Any]]:
|
|
62
|
+
"""Retrieve a saved query by name."""
|
|
63
|
+
data = self._load()
|
|
64
|
+
return data.get(name.strip())
|
|
65
|
+
|
|
66
|
+
def delete(self, name: str) -> bool:
|
|
67
|
+
"""Delete a saved query by name. Returns True if found and deleted."""
|
|
68
|
+
clean_name = name.strip()
|
|
69
|
+
data = self._load()
|
|
70
|
+
if clean_name in data:
|
|
71
|
+
del data[clean_name]
|
|
72
|
+
self._save(data)
|
|
73
|
+
return True
|
|
74
|
+
return False
|
|
75
|
+
|