dataspring-cli 0.3.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.
- cli/__init__.py +15 -0
- cli/_skills/dataspring-author/SKILL.md +401 -0
- cli/_skills/dataspring-consume/SKILL.md +712 -0
- cli/_skills/dataspring-correct/SKILL.md +124 -0
- cli/auth.py +375 -0
- cli/bundled_manifest.py +36 -0
- cli/contract.py +1138 -0
- cli/generated.py +1297 -0
- cli/main.py +3232 -0
- cli/output.py +266 -0
- cli/runtime.py +201 -0
- cli/skills_commands.py +247 -0
- cli/skilltree.py +350 -0
- cli/upgrade.py +66 -0
- cli/version.py +123 -0
- dataspring_cli-0.3.0.dist-info/METADATA +202 -0
- dataspring_cli-0.3.0.dist-info/RECORD +20 -0
- dataspring_cli-0.3.0.dist-info/WHEEL +4 -0
- dataspring_cli-0.3.0.dist-info/entry_points.txt +2 -0
- settings.py +78 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dataspring-correct
|
|
3
|
+
description: How to handle a correction — where the fact belongs, what this user may change, and what needs a dbt change instead. Use when a user disputes a number, says a metric means something else, tells you a caveat, asks what you computed, or asks you to remember a query.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Correcting DataSpring
|
|
7
|
+
|
|
8
|
+
A correction is a fact arriving late. Find its home, write it where future
|
|
9
|
+
readers see it, and be exact about what changed.
|
|
10
|
+
|
|
11
|
+
## 1. First explain, never guess
|
|
12
|
+
|
|
13
|
+
Before agreeing anything is wrong, call `explain_query`; with no
|
|
14
|
+
`params` it explains this session's last query. Quote back the metric it
|
|
15
|
+
resolved, the filters applied (standing preferences included), the effective
|
|
16
|
+
window, and briefly the SQL. Often the number is right and the disagreement
|
|
17
|
+
is about which metric was meant — ambiguity, not a correction. Never
|
|
18
|
+
apologise for a number you have not re-read.
|
|
19
|
+
|
|
20
|
+
If that query returned an EMPTY or null result, try the other query shape —
|
|
21
|
+
grouped instead of filtered, or vice versa — before reporting that there is
|
|
22
|
+
no data. Explaining a null is not an answer.
|
|
23
|
+
|
|
24
|
+
## 2. Classify the fact
|
|
25
|
+
|
|
26
|
+
| Type of fact | Goes in... |
|
|
27
|
+
|---|---|
|
|
28
|
+
| One metric's meaning, computation, unit, source rollup | That metric's description |
|
|
29
|
+
| One metric's known caveat (bug warning, temporal discontinuity, methodology change) | That metric's description |
|
|
30
|
+
| One metric's standing filter rule ("for B2B, pair with...") | That metric's description |
|
|
31
|
+
| One dimension's allowed values, derivation, "use this not raw" steering | That dimension's description, via `semantic_model_edit` |
|
|
32
|
+
| One source's freshness, format quirks, "field X is 100% NULL" | That source's description |
|
|
33
|
+
| Cross-cutting derivation ("B2B is derived in event_segment, never raw event_type") | `business_context` |
|
|
34
|
+
| Org-level system inventory (what's connected, what's sunset) | `business_context` |
|
|
35
|
+
| Org-level coverage gap ("we don't sync system X") | `business_context` |
|
|
36
|
+
| Region, dbt layout, project-level operational pointers | `business_context` |
|
|
37
|
+
| Findings, analysis results, history | NOT in the context layer at all |
|
|
38
|
+
| Per-metric definition logic | NOT in context — lives in the dbt model SQL |
|
|
39
|
+
| The user's own standing intent ("always exclude Direct for me") | A personal preference, via `update_user_preferences` |
|
|
40
|
+
| A question and the query that correctly answers it, confirmed by the user | `verified_query_edit` record, scope `user` |
|
|
41
|
+
|
|
42
|
+
A verified query is scope `org` only on an admin's say-so. When torn between
|
|
43
|
+
an entity description and `business_context`, choose the entity.
|
|
44
|
+
|
|
45
|
+
## 3. Offer only what this user may change
|
|
46
|
+
|
|
47
|
+
Read the WHAT YOU MAY CHANGE block — this user's targets, nothing else. An
|
|
48
|
+
admin gets the systemic fix; a member or viewer a personal preference,
|
|
49
|
+
verified query or quick metric, which fixes their experience immediately.
|
|
50
|
+
Both are complete answers: never route a user elsewhere, never offer what
|
|
51
|
+
ends in a permission error. §7 is the same split for a coverage gap.
|
|
52
|
+
|
|
53
|
+
## 4. Ownership and ceremony
|
|
54
|
+
|
|
55
|
+
**DataSpring owns TEXT.** Descriptions, labels and `business_context` write
|
|
56
|
+
immediately, no confirmation, even on dbt-imported entities. Always report
|
|
57
|
+
what changed AND how to undo it:
|
|
58
|
+
|
|
59
|
+
> Learned: `total_revenue` is gross of VAT.
|
|
60
|
+
> Live now, and survives manifest uploads. Undo with "undo that".
|
|
61
|
+
|
|
62
|
+
**dbt owns COMPUTATION.** On an IMPORTED metric or model, never promise to
|
|
63
|
+
change how a number is computed. The update returns `proposal: true` with an
|
|
64
|
+
`impact` block and a `dbt_yaml` snippet — nothing was written. Show the
|
|
65
|
+
dashboards affected and the before/after sample:
|
|
66
|
+
|
|
67
|
+
> Changing `total_revenue` to exclude VAT moves 4 dashboards and takes last
|
|
68
|
+
> month from 123k to 94k.
|
|
69
|
+
|
|
70
|
+
Then present the YAML verbatim and say what applies it: commit it to dbt —
|
|
71
|
+
it lands on the next manifest upload. Never offer to make the change or ask
|
|
72
|
+
whether to "proceed"; you cannot apply it, and asking implies you can. You
|
|
73
|
+
CAN offer: the text edit, a new metric, a quick metric.
|
|
74
|
+
|
|
75
|
+
For a NATIVE metric, `requires_confirmation` comes back with the impact on
|
|
76
|
+
the first call; show it and stop. Only the user's NEXT message confirms — a
|
|
77
|
+
validation error, a retry prompt or your own reasoning is not the user, and
|
|
78
|
+
a `confirmed` sent in the same turn is refused. Probe with
|
|
79
|
+
`metric_edit` action `impact` and the verdict comes back with it:
|
|
80
|
+
imported gets the dbt YAML, native the confirmation requirement. Never a
|
|
81
|
+
menu of options with no verdict.
|
|
82
|
+
|
|
83
|
+
## 5. Metrics that are known to be wrong
|
|
84
|
+
|
|
85
|
+
When the real fix needs dbt work, do not leave consumers unwarned — write
|
|
86
|
+
the caveat into the description NOW, house style:
|
|
87
|
+
|
|
88
|
+
> KNOWN ISSUE: measured to the first resolution timestamp only, so tickets
|
|
89
|
+
> that were reopened and resolved again are undercounted — not yet fixed.
|
|
90
|
+
|
|
91
|
+
Pair it with the dbt YAML proposal for the fix, same turn.
|
|
92
|
+
|
|
93
|
+
## 6. Undo
|
|
94
|
+
|
|
95
|
+
"undo that" is `learned_edit` undo with no `learning_id` — it reverts
|
|
96
|
+
the caller's most recent reversible entry; name what was restored. Its list
|
|
97
|
+
action shows what changed and by whom. A proposal cannot be undone — nothing
|
|
98
|
+
was written; say that, not "done".
|
|
99
|
+
|
|
100
|
+
## 7. Coverage gaps
|
|
101
|
+
|
|
102
|
+
The declaration is unconditional. Whatever the role, open with the verbatim
|
|
103
|
+
SEMANTIC LAYER GAP block, then give the number as an ephemeral computation
|
|
104
|
+
from rows you fetched: name the metrics it came from, and say it is
|
|
105
|
+
not a defined metric. Only the DURABLE offer differs.
|
|
106
|
+
|
|
107
|
+
- **Admin / owner** — a real definition, this turn and unasked.
|
|
108
|
+
Ask `metric_edit` for a `preview`, then quote the `dbt_yaml` in full. A preview writes nothing, so needs no permission; only create needs
|
|
109
|
+
a yes — a native, additive entity that moves no existing number.
|
|
110
|
+
- **Member / viewer** — a quick metric via `quick_metric_edit` for a
|
|
111
|
+
recurring need (query it with the `qm:` prefix); an ephemeral computation
|
|
112
|
+
alone for a one-off. Never an org-scoped offer.
|
|
113
|
+
|
|
114
|
+
Bad, for an admin: a business heading, an inline ratio, an offer to save a
|
|
115
|
+
quick metric — or to propose a definition, with none shown. Good: the
|
|
116
|
+
declaration, the labelled ratio, the delegated preview's dbt YAML in full,
|
|
117
|
+
then "create it?". Raw SQL is never an option on any path.
|
|
118
|
+
|
|
119
|
+
## 8. Do not
|
|
120
|
+
|
|
121
|
+
- Do not describe a queue, review step or approval state; none exist.
|
|
122
|
+
- Do not record a verified query on your own judgement — only when confirmed.
|
|
123
|
+
- Do not store numbers, results or findings in a description, preference or
|
|
124
|
+
memory — they go stale tomorrow; facts do not.
|
cli/auth.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""CLI Authentication - Google OAuth flow and token management.
|
|
2
|
+
|
|
3
|
+
Handles:
|
|
4
|
+
- Interactive OAuth login via browser
|
|
5
|
+
- Token storage and refresh
|
|
6
|
+
- User context retrieval from ``GET /api/me``
|
|
7
|
+
|
|
8
|
+
This module is the only place the CLI touches Google directly, and only for
|
|
9
|
+
the OAuth login flow. Who the user is - and which org they are in - is the
|
|
10
|
+
server's answer to ``/api/me``; nothing here opens Firestore.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import secrets
|
|
15
|
+
import webbrowser
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from threading import Thread
|
|
21
|
+
from typing import Any
|
|
22
|
+
from urllib.parse import parse_qs, urlparse
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
from google.auth.transport.requests import Request
|
|
26
|
+
from google.oauth2.credentials import Credentials
|
|
27
|
+
from google_auth_oauthlib.flow import Flow
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class UserContext:
|
|
32
|
+
"""The signed-in user as ``GET /api/me`` reports them.
|
|
33
|
+
|
|
34
|
+
Mirrors the fields of ``services.auth.UserContext`` that the CLI shows,
|
|
35
|
+
without importing the service layer: the CLI wheel ships without it.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
uid: str
|
|
39
|
+
email: str
|
|
40
|
+
org_id: str
|
|
41
|
+
org_name: str
|
|
42
|
+
role: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AuthenticationError(Exception):
|
|
46
|
+
"""Raised when authentication fails or user is not logged in."""
|
|
47
|
+
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CLIAuthManager:
|
|
52
|
+
"""Manages OAuth tokens for CLI sessions.
|
|
53
|
+
|
|
54
|
+
Handles the complete OAuth flow:
|
|
55
|
+
1. Start local callback server
|
|
56
|
+
2. Open browser to Google consent screen
|
|
57
|
+
3. Receive callback with auth code
|
|
58
|
+
4. Exchange for tokens
|
|
59
|
+
5. Store refresh token locally
|
|
60
|
+
6. Resolve the user's org context from the API (``/api/me``)
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
CONFIG_DIR = Path.home() / ".dataspring"
|
|
64
|
+
CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
|
|
65
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
66
|
+
|
|
67
|
+
# OAuth configuration - Desktop app client for CLI login flow
|
|
68
|
+
# Created in Google Cloud Console > APIs & Services > Credentials
|
|
69
|
+
# Application type: Desktop app, Name: DataSpring CLI
|
|
70
|
+
CLI_OAUTH_CLIENT_ID = "910366018047-ekplje5p344nbq60o8vqk88378turj52.apps.googleusercontent.com"
|
|
71
|
+
CLI_OAUTH_CLIENT_SECRET = "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"
|
|
72
|
+
|
|
73
|
+
SCOPES = [
|
|
74
|
+
"openid",
|
|
75
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
76
|
+
"https://www.googleapis.com/auth/userinfo.profile",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
def __init__(self):
|
|
80
|
+
"""Initialize the auth manager."""
|
|
81
|
+
self._ensure_config_dir()
|
|
82
|
+
|
|
83
|
+
def _ensure_config_dir(self):
|
|
84
|
+
"""Create config directory if it doesn't exist."""
|
|
85
|
+
self.CONFIG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
86
|
+
|
|
87
|
+
def _get_oauth_config(self) -> dict[str, Any]:
|
|
88
|
+
"""Get OAuth client configuration for desktop app flow."""
|
|
89
|
+
return {
|
|
90
|
+
"installed": {
|
|
91
|
+
"client_id": self.CLI_OAUTH_CLIENT_ID,
|
|
92
|
+
"client_secret": self.CLI_OAUTH_CLIENT_SECRET,
|
|
93
|
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
94
|
+
"token_uri": "https://oauth2.googleapis.com/token",
|
|
95
|
+
"redirect_uris": ["http://localhost"],
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def _save_credentials(self, credentials: Credentials):
|
|
100
|
+
"""Save credentials to file with restricted permissions."""
|
|
101
|
+
data = {
|
|
102
|
+
"token": credentials.token,
|
|
103
|
+
"refresh_token": credentials.refresh_token,
|
|
104
|
+
"token_uri": credentials.token_uri,
|
|
105
|
+
"client_id": credentials.client_id,
|
|
106
|
+
"client_secret": credentials.client_secret,
|
|
107
|
+
"scopes": list(credentials.scopes) if credentials.scopes else None,
|
|
108
|
+
"expiry": credentials.expiry.isoformat() if credentials.expiry else None,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
self.CREDENTIALS_FILE.write_text(json.dumps(data, indent=2))
|
|
112
|
+
self.CREDENTIALS_FILE.chmod(0o600)
|
|
113
|
+
|
|
114
|
+
def _load_credentials(self) -> Credentials | None:
|
|
115
|
+
"""Load credentials from file."""
|
|
116
|
+
if not self.CREDENTIALS_FILE.exists():
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
data = json.loads(self.CREDENTIALS_FILE.read_text())
|
|
121
|
+
expiry = None
|
|
122
|
+
if data.get("expiry"):
|
|
123
|
+
expiry = datetime.fromisoformat(data["expiry"])
|
|
124
|
+
|
|
125
|
+
return Credentials(
|
|
126
|
+
token=data.get("token"),
|
|
127
|
+
refresh_token=data.get("refresh_token"),
|
|
128
|
+
token_uri=data.get("token_uri"),
|
|
129
|
+
client_id=data.get("client_id"),
|
|
130
|
+
client_secret=data.get("client_secret"),
|
|
131
|
+
scopes=data.get("scopes"),
|
|
132
|
+
expiry=expiry,
|
|
133
|
+
)
|
|
134
|
+
except (json.JSONDecodeError, KeyError):
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
def _refresh_if_needed(self, credentials: Credentials) -> Credentials:
|
|
138
|
+
"""Refresh credentials if expired."""
|
|
139
|
+
if credentials.expired and credentials.refresh_token:
|
|
140
|
+
credentials.refresh(Request())
|
|
141
|
+
self._save_credentials(credentials)
|
|
142
|
+
return credentials
|
|
143
|
+
|
|
144
|
+
async def login(self) -> UserContext:
|
|
145
|
+
"""Interactive OAuth login flow.
|
|
146
|
+
|
|
147
|
+
Opens browser for Google consent, then looks up user context.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
UserContext with user info and org context
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
AuthenticationError: If login fails
|
|
154
|
+
"""
|
|
155
|
+
config = self._get_oauth_config()
|
|
156
|
+
|
|
157
|
+
# Create flow with redirect to localhost
|
|
158
|
+
flow = Flow.from_client_config(
|
|
159
|
+
config,
|
|
160
|
+
scopes=self.SCOPES,
|
|
161
|
+
redirect_uri="http://localhost:8085/callback",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Generate authorization URL with state for CSRF protection
|
|
165
|
+
state = secrets.token_urlsafe(32)
|
|
166
|
+
auth_url, _ = flow.authorization_url(
|
|
167
|
+
access_type="offline",
|
|
168
|
+
include_granted_scopes="true",
|
|
169
|
+
prompt="consent",
|
|
170
|
+
state=state,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Start local callback server
|
|
174
|
+
auth_code = None
|
|
175
|
+
received_state = None
|
|
176
|
+
|
|
177
|
+
class CallbackHandler(BaseHTTPRequestHandler):
|
|
178
|
+
def do_GET(self):
|
|
179
|
+
nonlocal auth_code, received_state
|
|
180
|
+
parsed = urlparse(self.path)
|
|
181
|
+
|
|
182
|
+
if parsed.path == "/callback":
|
|
183
|
+
params = parse_qs(parsed.query)
|
|
184
|
+
auth_code = params.get("code", [None])[0]
|
|
185
|
+
received_state = params.get("state", [None])[0]
|
|
186
|
+
|
|
187
|
+
self.send_response(200)
|
|
188
|
+
self.send_header("Content-type", "text/html")
|
|
189
|
+
self.end_headers()
|
|
190
|
+
self.wfile.write(
|
|
191
|
+
b"<html><body><h1>Login successful!</h1>"
|
|
192
|
+
b"<p>You can close this window and return to the terminal.</p>"
|
|
193
|
+
b"</body></html>"
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
self.send_response(404)
|
|
197
|
+
self.end_headers()
|
|
198
|
+
|
|
199
|
+
def log_message(self, format, *args):
|
|
200
|
+
pass # Suppress server logs
|
|
201
|
+
|
|
202
|
+
server = HTTPServer(("localhost", 8085), CallbackHandler)
|
|
203
|
+
|
|
204
|
+
# Run server in background thread
|
|
205
|
+
server_thread = Thread(target=lambda: server.handle_request())
|
|
206
|
+
server_thread.start()
|
|
207
|
+
|
|
208
|
+
# Open browser
|
|
209
|
+
print("Opening browser for login...")
|
|
210
|
+
webbrowser.open(auth_url)
|
|
211
|
+
|
|
212
|
+
# Wait for callback
|
|
213
|
+
server_thread.join(timeout=120)
|
|
214
|
+
|
|
215
|
+
if not auth_code:
|
|
216
|
+
raise AuthenticationError("Login timeout or failed to receive auth code")
|
|
217
|
+
|
|
218
|
+
if received_state != state:
|
|
219
|
+
raise AuthenticationError("Invalid state parameter - possible CSRF attack")
|
|
220
|
+
|
|
221
|
+
# Exchange code for tokens
|
|
222
|
+
flow.fetch_token(code=auth_code)
|
|
223
|
+
credentials = flow.credentials
|
|
224
|
+
|
|
225
|
+
# Save credentials
|
|
226
|
+
self._save_credentials(credentials)
|
|
227
|
+
|
|
228
|
+
# Get user context
|
|
229
|
+
return await self._get_user_context(credentials)
|
|
230
|
+
|
|
231
|
+
async def _get_user_context(self, credentials: Credentials) -> UserContext:
|
|
232
|
+
"""Get user context from email in token."""
|
|
233
|
+
# Get user email from Google userinfo
|
|
234
|
+
async with httpx.AsyncClient() as client:
|
|
235
|
+
resp = await client.get(
|
|
236
|
+
"https://www.googleapis.com/oauth2/v3/userinfo",
|
|
237
|
+
headers={"Authorization": f"Bearer {credentials.token}"},
|
|
238
|
+
)
|
|
239
|
+
if resp.status_code != 200:
|
|
240
|
+
raise AuthenticationError(f"Failed to get user info: {resp.text}")
|
|
241
|
+
user_info = resp.json()
|
|
242
|
+
|
|
243
|
+
email = user_info.get("email")
|
|
244
|
+
if not email:
|
|
245
|
+
raise AuthenticationError("Email not found in OAuth response")
|
|
246
|
+
|
|
247
|
+
# Save email to config for future use
|
|
248
|
+
self._save_config({"email": email})
|
|
249
|
+
|
|
250
|
+
# Resolve full user context via backend API — no local Firestore/ADC required
|
|
251
|
+
from settings import get_settings
|
|
252
|
+
api_base = get_settings().mcp_server_base_url # e.g. https://dataspring.app/api
|
|
253
|
+
|
|
254
|
+
async with httpx.AsyncClient(timeout=15.0) as api_client:
|
|
255
|
+
me_resp = await api_client.get(
|
|
256
|
+
f"{api_base}/me",
|
|
257
|
+
headers={"Authorization": f"Bearer {credentials.token}"},
|
|
258
|
+
)
|
|
259
|
+
if me_resp.status_code == 401:
|
|
260
|
+
raise AuthenticationError(
|
|
261
|
+
"Authentication failed — token rejected by server"
|
|
262
|
+
)
|
|
263
|
+
if me_resp.status_code == 403:
|
|
264
|
+
detail = me_resp.json().get("detail", "Access denied")
|
|
265
|
+
raise AuthenticationError(detail)
|
|
266
|
+
if me_resp.status_code != 200:
|
|
267
|
+
raise AuthenticationError(
|
|
268
|
+
f"Failed to resolve user context (HTTP {me_resp.status_code})"
|
|
269
|
+
)
|
|
270
|
+
data = me_resp.json()
|
|
271
|
+
|
|
272
|
+
return UserContext(
|
|
273
|
+
uid=data["uid"],
|
|
274
|
+
email=data["email"] or email,
|
|
275
|
+
org_id=data["org_id"],
|
|
276
|
+
org_name=data.get("org_name") or data["org_id"],
|
|
277
|
+
role=data["org_role"],
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def _save_config(self, data: dict):
|
|
281
|
+
"""Save config data (non-sensitive)."""
|
|
282
|
+
existing = {}
|
|
283
|
+
if self.CONFIG_FILE.exists():
|
|
284
|
+
try:
|
|
285
|
+
existing = json.loads(self.CONFIG_FILE.read_text())
|
|
286
|
+
except json.JSONDecodeError:
|
|
287
|
+
pass
|
|
288
|
+
existing.update(data)
|
|
289
|
+
self.CONFIG_FILE.write_text(json.dumps(existing, indent=2))
|
|
290
|
+
|
|
291
|
+
def _load_config(self) -> dict:
|
|
292
|
+
"""Load config data."""
|
|
293
|
+
if not self.CONFIG_FILE.exists():
|
|
294
|
+
return {}
|
|
295
|
+
try:
|
|
296
|
+
return json.loads(self.CONFIG_FILE.read_text())
|
|
297
|
+
except json.JSONDecodeError:
|
|
298
|
+
return {}
|
|
299
|
+
|
|
300
|
+
async def get_user(self) -> UserContext:
|
|
301
|
+
"""Get current user, refreshing token if needed.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
UserContext with user info and org context
|
|
305
|
+
|
|
306
|
+
Raises:
|
|
307
|
+
AuthenticationError: If not logged in or token refresh fails
|
|
308
|
+
"""
|
|
309
|
+
credentials = self._load_credentials()
|
|
310
|
+
if not credentials:
|
|
311
|
+
raise AuthenticationError("Not logged in. Run 'dataspring login' first.")
|
|
312
|
+
|
|
313
|
+
if not credentials.valid:
|
|
314
|
+
if credentials.refresh_token:
|
|
315
|
+
try:
|
|
316
|
+
credentials = self._refresh_if_needed(credentials)
|
|
317
|
+
except Exception as e:
|
|
318
|
+
raise AuthenticationError(f"Failed to refresh token: {e}")
|
|
319
|
+
else:
|
|
320
|
+
raise AuthenticationError(
|
|
321
|
+
"Token expired and no refresh token. Run 'dataspring login' again."
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
return await self._get_user_context(credentials)
|
|
325
|
+
|
|
326
|
+
async def logout(self):
|
|
327
|
+
"""Clear stored credentials and config."""
|
|
328
|
+
if self.CREDENTIALS_FILE.exists():
|
|
329
|
+
self.CREDENTIALS_FILE.unlink()
|
|
330
|
+
if self.CONFIG_FILE.exists():
|
|
331
|
+
self.CONFIG_FILE.unlink()
|
|
332
|
+
|
|
333
|
+
def is_logged_in(self) -> bool:
|
|
334
|
+
"""Check if user has valid credentials."""
|
|
335
|
+
credentials = self._load_credentials()
|
|
336
|
+
if not credentials:
|
|
337
|
+
return False
|
|
338
|
+
if credentials.expired and not credentials.refresh_token:
|
|
339
|
+
return False
|
|
340
|
+
return True
|
|
341
|
+
|
|
342
|
+
def get_stored_email(self) -> str | None:
|
|
343
|
+
"""Get stored email from config (doesn't validate token)."""
|
|
344
|
+
config = self._load_config()
|
|
345
|
+
return config.get("email")
|
|
346
|
+
|
|
347
|
+
def get_access_token(self) -> str:
|
|
348
|
+
"""Get a valid access token, refreshing if needed.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Valid access token string
|
|
352
|
+
|
|
353
|
+
Raises:
|
|
354
|
+
AuthenticationError: If not logged in or token refresh fails
|
|
355
|
+
"""
|
|
356
|
+
credentials = self._load_credentials()
|
|
357
|
+
if not credentials:
|
|
358
|
+
raise AuthenticationError("Not logged in. Run 'dataspring login' first.")
|
|
359
|
+
|
|
360
|
+
if not credentials.valid:
|
|
361
|
+
if credentials.refresh_token:
|
|
362
|
+
try:
|
|
363
|
+
credentials = self._refresh_if_needed(credentials)
|
|
364
|
+
except Exception as e:
|
|
365
|
+
raise AuthenticationError(f"Failed to refresh token: {e}")
|
|
366
|
+
else:
|
|
367
|
+
raise AuthenticationError(
|
|
368
|
+
"Token expired and no refresh token. Run 'dataspring login' again."
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
return credentials.token
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# Global auth manager instance
|
|
375
|
+
auth_manager = CLIAuthManager()
|
cli/bundled_manifest.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""GENERATED by `python -m services.skill_tables`; do not edit.
|
|
2
|
+
|
|
3
|
+
The hash of the dispatch manifest this CLI was built against, the one
|
|
4
|
+
``GET /api/dispatch`` answers. ``dataspring --version`` compares it with
|
|
5
|
+
the server's to say whether the server has operations this CLI does not
|
|
6
|
+
know. tests/test_skills_surface.py asserts it is current.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
MANIFEST_HASH = "76c44516f0729b5b4c1d4b8f17ebd7a3ffbd63b94dae6308c740a4af62db1811"
|
|
10
|
+
TOOL_KEYS = (
|
|
11
|
+
"dashboard_edit",
|
|
12
|
+
"widget_edit",
|
|
13
|
+
"page_edit",
|
|
14
|
+
"section_edit",
|
|
15
|
+
"business_context_edit",
|
|
16
|
+
"semantic_model_edit",
|
|
17
|
+
"metric_edit",
|
|
18
|
+
"quick_metric_edit",
|
|
19
|
+
"report_edit",
|
|
20
|
+
"learned_edit",
|
|
21
|
+
"verified_query_edit",
|
|
22
|
+
"warehouse_edit",
|
|
23
|
+
"query_metrics",
|
|
24
|
+
"explain_query",
|
|
25
|
+
"update_context",
|
|
26
|
+
"export_data",
|
|
27
|
+
"import_manifest",
|
|
28
|
+
"render_dashboard",
|
|
29
|
+
"render_widget",
|
|
30
|
+
"switch_organization",
|
|
31
|
+
"submit_error_report",
|
|
32
|
+
"secret_edit",
|
|
33
|
+
"run_sql",
|
|
34
|
+
"datacore_edit",
|
|
35
|
+
"datacore_run",
|
|
36
|
+
)
|