dataspring-cli 0.3.0__tar.gz → 0.4.1__tar.gz

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.
Files changed (22) hide show
  1. {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/PKG-INFO +1 -1
  2. dataspring_cli-0.4.1/cli/auth.py +279 -0
  3. {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/bundled_manifest.py +7 -9
  4. {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/contract.py +460 -374
  5. {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/generated.py +334 -468
  6. {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/main.py +141 -459
  7. {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/output.py +22 -8
  8. {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/pyproject.toml +8 -2
  9. {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/settings.py +32 -15
  10. {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/skills/dataspring-author/SKILL.md +87 -2
  11. {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/skills/dataspring-consume/SKILL.md +151 -6
  12. {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/skills/dataspring-correct/SKILL.md +1 -1
  13. dataspring_cli-0.3.0/cli/auth.py +0 -375
  14. {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/.gitignore +0 -0
  15. {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/README.md +0 -0
  16. {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/__init__.py +0 -0
  17. {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/runtime.py +0 -0
  18. {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/skills_commands.py +0 -0
  19. {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/skilltree.py +0 -0
  20. {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/upgrade.py +0 -0
  21. {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/cli/version.py +0 -0
  22. {dataspring_cli-0.3.0 → dataspring_cli-0.4.1}/hatch_build.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dataspring-cli
3
- Version: 0.3.0
3
+ Version: 0.4.1
4
4
  Summary: DataSpring CLI - Query metrics and manage dashboards from the terminal
5
5
  Project-URL: Homepage, https://dataspring.app
6
6
  Project-URL: Documentation, https://dataspring.app/docs
@@ -0,0 +1,279 @@
1
+ """CLI authentication: suite standard §3 (/home/dev/workspace/forspring/docs/suite-standards.md).
2
+
3
+ ``dataspring auth login`` runs the device flow (RFC 8628 with PKCE,
4
+ backend/routes_device.py): the CLI shows a code and a URL, a person signed in
5
+ to DataSpring in any browser (this machine needs none) approves it, and the
6
+ CLI collects an ordinary ``ds_`` API key labelled ``CLI on <hostname>`` (one
7
+ per org with ``--all-orgs``, the default).
8
+
9
+ Credentials live in ``~/.dataspring/credentials.json`` (mode 0600): a profile
10
+ per org and which one is current. ``dataspring auth use <org>`` switches
11
+ profile; a key belongs to its org, so that is the only switching there is
12
+ (hub D-96: no server-side switch). ``--key`` stores a key you already have,
13
+ for CI, and the ``DATASPRING_API_KEY`` environment variable always wins over
14
+ anything stored. ``dataspring auth logout`` revokes the stored keys on the
15
+ server, then forgets them.
16
+
17
+ Who the person is and which org a key is for are the server's answer to
18
+ ``/api/me``; nothing here opens Firestore.
19
+ """
20
+
21
+ import base64
22
+ import hashlib
23
+ import json
24
+ import os
25
+ import secrets
26
+ import socket
27
+ import time
28
+ import webbrowser
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+
32
+ import httpx
33
+
34
+ ENV_KEY = "DATASPRING_API_KEY"
35
+
36
+
37
+ def _host() -> str:
38
+ """The backend's root (https://dataspring.app; locally http://localhost:8000)."""
39
+ from settings import get_settings
40
+
41
+ return get_settings().mcp_server_base_url.removesuffix("/api")
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class UserContext:
46
+ """The signed-in user as ``GET /api/me`` reports them.
47
+
48
+ Mirrors the fields of ``services.auth.UserContext`` that the CLI shows,
49
+ without importing the service layer: the CLI wheel ships without it.
50
+ """
51
+
52
+ uid: str
53
+ email: str
54
+ org_id: str | None
55
+ org_name: str | None
56
+ role: str | None
57
+
58
+ @property
59
+ def has_org(self) -> bool:
60
+ """False for an account no administrator has added to an org yet.
61
+
62
+ Since D25 sign-up creates no organization, so ``/api/me`` answers 200
63
+ with the three org fields null. The CLI says so rather than crashing
64
+ on a ``KeyError`` the way it would have.
65
+ """
66
+ return self.org_id is not None
67
+
68
+
69
+ class AuthenticationError(Exception):
70
+ """Raised when authentication fails or user is not logged in."""
71
+ pass
72
+
73
+
74
+ class CLIAuthManager:
75
+ """API keys for the CLI: device-flow login, profiles per org, the env var."""
76
+
77
+ CONFIG_DIR = Path.home() / ".dataspring"
78
+ CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
79
+ CONFIG_FILE = CONFIG_DIR / "config.json"
80
+
81
+ def __init__(self):
82
+ """Initialize the auth manager."""
83
+ self._ensure_config_dir()
84
+
85
+ def _ensure_config_dir(self):
86
+ """Create config directory if it doesn't exist."""
87
+ self.CONFIG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
88
+
89
+ # -- stored profiles -------------------------------------------------
90
+
91
+ def _load(self) -> dict:
92
+ """``{current, profiles: {org: {api_key, key_id, org_name, email}}}``;
93
+ empty for none, or for what an older CLI stored (Google credentials,
94
+ or CLI tokens): those sign nobody in now."""
95
+ if not self.CREDENTIALS_FILE.exists():
96
+ return {}
97
+ try:
98
+ data = json.loads(self.CREDENTIALS_FILE.read_text())
99
+ except json.JSONDecodeError:
100
+ return {}
101
+ return data if isinstance(data.get("profiles"), dict) else {}
102
+
103
+ def _save(self, data: dict) -> None:
104
+ self.CREDENTIALS_FILE.write_text(json.dumps(data, indent=2))
105
+ self.CREDENTIALS_FILE.chmod(0o600)
106
+
107
+ def _add_profile(self, data: dict, entry: dict, email: str | None) -> None:
108
+ data.setdefault("profiles", {})[entry["org_id"]] = {
109
+ "api_key": entry["api_key"],
110
+ "key_id": entry.get("key_id"),
111
+ "org_name": entry.get("org_name") or entry["org_id"],
112
+ "email": email,
113
+ }
114
+
115
+ def profiles(self) -> dict[str, dict]:
116
+ return self._load().get("profiles", {})
117
+
118
+ def current_org(self) -> str | None:
119
+ return self._load().get("current")
120
+
121
+ def use(self, org_id: str) -> dict:
122
+ """Make the stored profile for ``org_id`` current."""
123
+ data = self._load()
124
+ if org_id not in data.get("profiles", {}):
125
+ raise AuthenticationError(
126
+ f"No key for {org_id} on this machine. Run 'dataspring auth login' and approve {org_id}."
127
+ )
128
+ data["current"] = org_id
129
+ self._save(data)
130
+ return data["profiles"][org_id]
131
+
132
+ # -- login -----------------------------------------------------------
133
+
134
+ async def login(self, *, all_orgs: bool = False, open_browser: bool = True, timeout: float = 600.0) -> "UserContext":
135
+ """The device flow: show a code, wait for approval in a browser, store the key(s)."""
136
+ verifier = secrets.token_urlsafe(48)
137
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()).rstrip(b"=").decode("ascii")
138
+ try:
139
+ r = httpx.post(f"{_host()}/api/device/code", json={
140
+ "code_challenge": challenge, "code_challenge_method": "S256",
141
+ "hostname": socket.gethostname(), "all_orgs": all_orgs,
142
+ }, timeout=15.0)
143
+ except httpx.HTTPError as e:
144
+ raise AuthenticationError(f"Could not reach DataSpring: {e}")
145
+ if r.status_code != 200:
146
+ raise AuthenticationError(f"Could not start the sign-in (HTTP {r.status_code})")
147
+ start = r.json()
148
+ # flush: an agent runs this with stdout redirected, where Python
149
+ # buffers it; unflushed, the code appeared only after the CLI exited,
150
+ # too late for anyone to approve it (tick 373).
151
+ print(f"To sign in, open {start['verification_uri']} and enter the code {start['user_code']}")
152
+ print(f" (or open {start['verification_uri_complete']})", flush=True)
153
+ if open_browser:
154
+ try:
155
+ webbrowser.open(start["verification_uri_complete"])
156
+ except Exception:
157
+ pass # no browser on this machine: the URL above is enough
158
+
159
+ interval = float(start.get("interval", 5))
160
+ deadline = time.monotonic() + min(timeout, float(start.get("expires_in", 600)))
161
+ while time.monotonic() < deadline:
162
+ time.sleep(interval)
163
+ r = httpx.post(f"{_host()}/api/device/token", json={"device_code": start["device_code"], "code_verifier": verifier}, timeout=15.0)
164
+ if r.status_code == 200:
165
+ body = r.json()
166
+ data = self._load()
167
+ self._add_profile(data, body, body.get("email"))
168
+ for other in body.get("other_orgs") or []:
169
+ self._add_profile(data, other, body.get("email"))
170
+ data["current"] = body["org_id"]
171
+ self._save(data)
172
+ if body.get("email"):
173
+ self._save_config({"email": body["email"]})
174
+ return await self.get_user()
175
+ error = (r.json() or {}).get("error") if r.headers.get("content-type", "").startswith("application/json") else None
176
+ if error == "authorization_pending":
177
+ continue
178
+ if error == "slow_down":
179
+ interval += 5
180
+ continue
181
+ if error == "access_denied":
182
+ raise AuthenticationError("The sign-in was denied in the browser.")
183
+ raise AuthenticationError("The sign-in expired or failed. Run 'dataspring auth login' again.")
184
+ raise AuthenticationError("The sign-in timed out. Run 'dataspring auth login' again.")
185
+
186
+ async def store_key(self, key: str) -> "UserContext":
187
+ """``--key``: keep a key you already have (CI). The server says which org it is for."""
188
+ me = await self._me(key)
189
+ data = self._load()
190
+ self._add_profile(data, {"api_key": key, "key_id": None, "org_id": me["org_id"], "org_name": me.get("org_name")}, me.get("email"))
191
+ data["current"] = me["org_id"]
192
+ self._save(data)
193
+ return self._context(me)
194
+
195
+ # -- using the key ---------------------------------------------------
196
+
197
+ def get_access_token(self) -> str:
198
+ """The key requests carry: ``DATASPRING_API_KEY`` if set, else the current profile's.
199
+
200
+ Raises:
201
+ AuthenticationError: If not logged in
202
+ """
203
+ if os.environ.get(ENV_KEY):
204
+ return os.environ[ENV_KEY]
205
+ data = self._load()
206
+ profile = data.get("profiles", {}).get(data.get("current") or "")
207
+ if not profile:
208
+ raise AuthenticationError("Not logged in. Run 'dataspring auth login' first.")
209
+ return profile["api_key"]
210
+
211
+ async def _me(self, key: str) -> dict:
212
+ async with httpx.AsyncClient(timeout=15.0) as client:
213
+ r = await client.get(f"{_host()}/api/me", headers={"Authorization": f"Bearer {key}"})
214
+ if r.status_code == 401:
215
+ raise AuthenticationError("This key is unknown or revoked. Run 'dataspring auth login' again.")
216
+ if r.status_code == 403:
217
+ raise AuthenticationError(r.json().get("detail", "Access denied"))
218
+ if r.status_code != 200:
219
+ raise AuthenticationError(f"Failed to resolve user context (HTTP {r.status_code})")
220
+ return r.json()
221
+
222
+ @staticmethod
223
+ def _context(me: dict) -> "UserContext":
224
+ org_id = me.get("org_id")
225
+ return UserContext(uid=me["uid"], email=me.get("email") or "", org_id=org_id,
226
+ org_name=me.get("org_name") or org_id, role=me.get("org_role"))
227
+
228
+ async def get_user(self) -> "UserContext":
229
+ """The person and the key's org, as ``GET /api/me`` reports them."""
230
+ return self._context(await self._me(self.get_access_token()))
231
+
232
+ async def logout(self):
233
+ """Revoke every stored key on the server, then forget them."""
234
+ for profile in self.profiles().values():
235
+ if not profile.get("key_id"):
236
+ continue # a --key key: not ours to revoke, just forgotten
237
+ try:
238
+ httpx.delete(f"{_host()}/api/keys/{profile['key_id']}",
239
+ headers={"Authorization": f"Bearer {profile['api_key']}"}, timeout=10.0)
240
+ except httpx.HTTPError:
241
+ pass # offline: revoke it from /api/keys later
242
+ if self.CREDENTIALS_FILE.exists():
243
+ self.CREDENTIALS_FILE.unlink()
244
+ if self.CONFIG_FILE.exists():
245
+ self.CONFIG_FILE.unlink()
246
+
247
+ def is_logged_in(self) -> bool:
248
+ if os.environ.get(ENV_KEY):
249
+ return True
250
+ data = self._load()
251
+ return bool(data.get("profiles", {}).get(data.get("current") or ""))
252
+
253
+ def get_stored_email(self) -> str | None:
254
+ """Get stored email from config (doesn't validate the key)."""
255
+ return self._load_config().get("email")
256
+
257
+ def _save_config(self, data: dict):
258
+ """Save config data (non-sensitive)."""
259
+ existing = {}
260
+ if self.CONFIG_FILE.exists():
261
+ try:
262
+ existing = json.loads(self.CONFIG_FILE.read_text())
263
+ except json.JSONDecodeError:
264
+ pass
265
+ existing.update(data)
266
+ self.CONFIG_FILE.write_text(json.dumps(existing, indent=2))
267
+
268
+ def _load_config(self) -> dict:
269
+ """Load config data."""
270
+ if not self.CONFIG_FILE.exists():
271
+ return {}
272
+ try:
273
+ return json.loads(self.CONFIG_FILE.read_text())
274
+ except json.JSONDecodeError:
275
+ return {}
276
+
277
+
278
+ # Global auth manager instance
279
+ auth_manager = CLIAuthManager()
@@ -6,17 +6,14 @@ the server's to say whether the server has operations this CLI does not
6
6
  know. tests/test_skills_surface.py asserts it is current.
7
7
  """
8
8
 
9
- MANIFEST_HASH = "76c44516f0729b5b4c1d4b8f17ebd7a3ffbd63b94dae6308c740a4af62db1811"
9
+ MANIFEST_HASH = "eacbe5d755f415a814b50b568eb105ff60693b92fe1ce44d30e39f71670d15d0"
10
10
  TOOL_KEYS = (
11
- "dashboard_edit",
12
- "widget_edit",
13
- "page_edit",
14
- "section_edit",
15
11
  "business_context_edit",
16
12
  "semantic_model_edit",
17
13
  "metric_edit",
18
14
  "quick_metric_edit",
19
- "report_edit",
15
+ "briefing_edit",
16
+ "verify_numbers",
20
17
  "learned_edit",
21
18
  "verified_query_edit",
22
19
  "warehouse_edit",
@@ -25,12 +22,13 @@ TOOL_KEYS = (
25
22
  "update_context",
26
23
  "export_data",
27
24
  "import_manifest",
28
- "render_dashboard",
29
- "render_widget",
30
- "switch_organization",
31
25
  "submit_error_report",
32
26
  "secret_edit",
33
27
  "run_sql",
34
28
  "datacore_edit",
35
29
  "datacore_run",
30
+ "tenant_edit",
31
+ "board_edit",
32
+ "board_fetch",
33
+ "watch_edit",
36
34
  )