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/main.py ADDED
@@ -0,0 +1,3232 @@
1
+ """DataSpring CLI - Main entry point.
2
+
3
+ Two layers, one wire. ``cli/generated.py`` is the dispatch registry as
4
+ commands, generated from the models in ``cli/contract.py`` (decision D19,
5
+ step 4): ``dataspring <key> --<field>`` for a flat tool, ``dataspring <key>
6
+ <verb> --<field>`` for an edit family, every flag a model field, so the
7
+ tree can never lack a field the server has. This module mounts that tree
8
+ and adds the human layer on top: the session commands (login, whoami,
9
+ version, upgrade, skills), the reads over the REST routes (``metrics
10
+ list``, ``dashboards show``, ...), and a few short-named conveniences
11
+ (``query``, ``switch``, ``sql``, ``export``, ``secret set --stdin``,
12
+ ``datacore pull|push|check|deploy|run|reset``) that are each a few lines
13
+ over the same ``cli.runtime.call_tool`` the generated commands use, with a
14
+ prompt, a file, stdin or a table where a person wants one. Nothing here
15
+ builds a dispatch body by hand.
16
+ """
17
+
18
+ import asyncio
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import Annotated, Literal, NoReturn, Optional
23
+
24
+ import typer
25
+ import yaml
26
+
27
+ from cli.auth import CLIAuthManager, AuthenticationError
28
+ from cli.generated import mount as mount_generated
29
+ from cli.output import (
30
+ console,
31
+ format_output,
32
+ print_error,
33
+ print_info,
34
+ print_report_hint,
35
+ print_success,
36
+ print_warning,
37
+ print_yaml,
38
+ print_user_context,
39
+ print_visualization_suggestion,
40
+ OutputFormat,
41
+ )
42
+ from cli.runtime import call_tool
43
+
44
+
45
+ # Initialize auth manager
46
+ auth = CLIAuthManager()
47
+
48
+ # Initialize main app
49
+ app = typer.Typer(
50
+ name="dataspring",
51
+ help="DataSpring CLI - Query metrics and manage dashboards from the terminal.",
52
+ no_args_is_help=True,
53
+ )
54
+
55
+ # Sub-command groups: the reads and the human conveniences. Writes are the
56
+ # generated tree (`dataspring dashboard_edit create ...`, mounted at the end
57
+ # of this module); a group here that once held create/update/delete keeps
58
+ # only what the generated twin cannot be: a read, a prompt, a file, stdin.
59
+ metrics_app = typer.Typer(help="Metrics: list and show (writes: dataspring metric_edit)")
60
+ dimensions_app = typer.Typer(help="Dimension operations")
61
+ org_app = typer.Typer(help="Organization operations")
62
+ dashboards_app = typer.Typer(help="Dashboards: list, show, update from a file, widgets by id (writes: dashboard_edit, widget_edit)")
63
+ manifest_app = typer.Typer(help="Manifest: status and export (upload: dataspring import_manifest)")
64
+ models_app = typer.Typer(help="Semantic models: list and show (writes: dataspring semantic_model_edit)")
65
+ schedules_app = typer.Typer(help="Scheduled reports: list and show (writes: dataspring report_edit)")
66
+ quick_metrics_app = typer.Typer(help="Quick metrics: list and show (writes: dataspring quick_metric_edit)")
67
+ context_app = typer.Typer(help="Your preferences (update: dataspring update_context)")
68
+
69
+ # Register sub-command groups
70
+ app.add_typer(metrics_app, name="metrics")
71
+ app.add_typer(dimensions_app, name="dimensions")
72
+ app.add_typer(org_app, name="org")
73
+ app.add_typer(dashboards_app, name="dashboards")
74
+ app.add_typer(manifest_app, name="manifest")
75
+ app.add_typer(models_app, name="models")
76
+ app.add_typer(schedules_app, name="schedules")
77
+ app.add_typer(quick_metrics_app, name="quick-metrics")
78
+ app.add_typer(context_app, name="context")
79
+ business_context_app = typer.Typer(help="Org business_context document operations")
80
+ app.add_typer(business_context_app, name="business-context")
81
+ learned_app = typer.Typer(help="The learned trail: what DataSpring learned for this org")
82
+ app.add_typer(learned_app, name="learned")
83
+ warehouses_app = typer.Typer(help="The org's warehouses: external and managed, and which is active")
84
+ app.add_typer(warehouses_app, name="warehouses")
85
+ datacore_app = typer.Typer(help="The datacore workspace: pull, edit locally, push, check, deploy, run")
86
+ app.add_typer(datacore_app, name="datacore")
87
+ from cli.skills_commands import skills_app # noqa: E402
88
+ app.add_typer(skills_app, name="skills")
89
+
90
+
91
+ # ============================================================================
92
+ # Helpers
93
+ # ============================================================================
94
+ #
95
+ # Every command talks to the DataSpring API over HTTPS with the user's OAuth
96
+ # access token. Reads use the REST routes; every write, and every operation
97
+ # the server owns end to end (query, explain, export, render, org switch), goes
98
+ # through ``POST /api/dispatch/{tool_key}`` - the same handlers MCP and the
99
+ # inline agent call, so role checks, validation and the learned trail happen
100
+ # once, on the server. The CLI never opens Firestore or a warehouse itself:
101
+ # nothing under backend/cli/ imports `services`, `storage`, `warehouse`,
102
+ # `mf_engine` or google-cloud-firestore (tests/test_cli_thin_client.py pins
103
+ # that). Before this, thirty-eight commands imported the service layer and hit
104
+ # Firestore with the operator's local Google credentials, keyed by an e-mail
105
+ # in a user-editable config file - which bypassed every server role check.
106
+
107
+
108
+ def run_async(coro):
109
+ """Run an async function synchronously."""
110
+ return asyncio.run(coro)
111
+
112
+
113
+ def split_commas(values: Optional[list[str]]) -> Optional[list[str]]:
114
+ """Allow ``-m a,b,c`` as a shorthand for ``-m a -m b -m c``.
115
+
116
+ Typer's ``list[str]`` only splits on repeated flags; bare commas pass
117
+ through as one string and MetricFlow then 500s on the unknown name.
118
+ """
119
+ if not values:
120
+ return values
121
+ return [item.strip() for entry in values for item in entry.split(",") if item.strip()]
122
+
123
+
124
+ def require_auth():
125
+ """Get authenticated user or exit with error.
126
+
127
+ Returns:
128
+ UserContext for authenticated user
129
+
130
+ Raises:
131
+ typer.Exit: If not authenticated
132
+ """
133
+ try:
134
+ return run_async(auth.get_user())
135
+ except AuthenticationError as e:
136
+ print_error(str(e), hint="Run 'dataspring login' to authenticate")
137
+ raise typer.Exit(1)
138
+
139
+
140
+ def get_api_base() -> str:
141
+ """Get the backend API base URL."""
142
+ from settings import get_settings
143
+ # mcp_server_base_url is https://dataspring.app/api
144
+ # /query endpoint is at root, not under /api
145
+ base = get_settings().mcp_server_base_url
146
+ # Strip /api suffix to get the host root
147
+ return base.removesuffix("/api")
148
+
149
+
150
+ def api_get(path: str, token: str, timeout: float = 60.0) -> dict:
151
+ """Make an authenticated GET request to the backend API."""
152
+ import httpx
153
+
154
+ url = f"{get_api_base()}{path}"
155
+ with httpx.Client(timeout=timeout) as client:
156
+ response = client.get(
157
+ url,
158
+ headers={"Authorization": f"Bearer {token}"},
159
+ )
160
+ response.raise_for_status()
161
+ return response.json()
162
+
163
+
164
+ def api_delete(path: str, token: str, timeout: float = 60.0) -> dict:
165
+ """Make an authenticated DELETE request to the backend API."""
166
+ import httpx
167
+
168
+ url = f"{get_api_base()}{path}"
169
+ with httpx.Client(timeout=timeout) as client:
170
+ response = client.delete(
171
+ url,
172
+ headers={"Authorization": f"Bearer {token}"},
173
+ )
174
+ response.raise_for_status()
175
+ return response.json()
176
+
177
+
178
+ def api_put(path: str, body: dict, token: str, timeout: float = 60.0) -> dict:
179
+ """Make an authenticated PUT request to the backend API."""
180
+ import httpx
181
+
182
+ url = f"{get_api_base()}{path}"
183
+ with httpx.Client(timeout=timeout) as client:
184
+ response = client.put(
185
+ url,
186
+ headers={"Authorization": f"Bearer {token}"},
187
+ json=body,
188
+ )
189
+ response.raise_for_status()
190
+ return response.json()
191
+
192
+
193
+ def api_post(path: str, body: dict, token: str, timeout: float = 60.0) -> dict:
194
+ """Make an authenticated POST request to the backend API.
195
+
196
+ Args:
197
+ path: URL path (e.g. "/api/dispatch/query_metrics")
198
+ body: JSON request body
199
+ token: OAuth access token
200
+
201
+ Returns:
202
+ Parsed JSON response
203
+
204
+ Raises:
205
+ Exception: On HTTP or connection errors
206
+ """
207
+ import httpx
208
+
209
+ url = f"{get_api_base()}{path}"
210
+ with httpx.Client(timeout=timeout) as client:
211
+ response = client.post(
212
+ url,
213
+ headers={"Authorization": f"Bearer {token}"},
214
+ json=body,
215
+ )
216
+ response.raise_for_status()
217
+ return response.json()
218
+
219
+
220
+ class DispatchToolError(Exception):
221
+ """A dispatch handler answered ``{"error": ...}`` (with HTTP 200).
222
+
223
+ The handlers report domain failures - "Metric 'x' not found", a role the
224
+ user lacks, an expression that does not parse - in the body rather than as
225
+ a status code, so the wire shape is the same on MCP and here. Raised by
226
+ `api_dispatch` so a command handles it on the same path as an HTTP error.
227
+ """
228
+
229
+ def __init__(self, payload: dict):
230
+ self.payload = payload
231
+ self.code = payload.get("code")
232
+ self.hint = payload.get("suggestion") or payload.get("hint")
233
+ super().__init__(str(payload.get("error")))
234
+
235
+
236
+ def api_dispatch(tool_key: str, body: dict, token: str, timeout: float = 60.0) -> dict:
237
+ """``POST /api/dispatch/{tool_key}`` and unwrap the handler's answer.
238
+
239
+ Action families take ``{"action": {"action": "<verb>", ...}}``; flat tools
240
+ take their keyword arguments as the body. A ``{"error": ...}`` answer
241
+ becomes `DispatchToolError`; HTTP 400/403/404 arrive as
242
+ ``httpx.HTTPStatusError`` from `api_post`. `format_api_error` turns either
243
+ into a message the user can act on.
244
+ """
245
+ result = api_post(f"/api/dispatch/{tool_key}", body, token, timeout=timeout)
246
+ if isinstance(result, dict) and result.get("error"):
247
+ raise DispatchToolError(result)
248
+ return result
249
+
250
+
251
+ def _response_detail(response) -> str | None:
252
+ """The server's ``detail`` as one line, whatever shape it came in.
253
+
254
+ FastAPI's own errors carry a string; the dispatch route answers a
255
+ malformed action with pydantic's list of ``{loc, msg}``; ``/query`` used
256
+ to answer ``{"message": ..., "suggestions": [...]}``.
257
+ """
258
+ try:
259
+ body = response.json()
260
+ except Exception:
261
+ return None
262
+ detail = body.get("detail") if isinstance(body, dict) else None
263
+ if detail is None:
264
+ return None
265
+ if isinstance(detail, dict):
266
+ return str(detail.get("message") or detail)
267
+ if isinstance(detail, list):
268
+ parts = []
269
+ for err in detail:
270
+ if isinstance(err, dict):
271
+ loc = ".".join(str(p) for p in err.get("loc", []) if p != "body")
272
+ parts.append(f"{loc}: {err.get('msg')}" if loc else str(err.get("msg")))
273
+ else:
274
+ parts.append(str(err))
275
+ return "; ".join(parts)
276
+ return str(detail)
277
+
278
+
279
+ def format_api_error(exc: Exception, role_action: str | None = None) -> tuple[str, str | None]:
280
+ """Turn an exception from an api_* call into (message, hint).
281
+
282
+ Pulls the server's ``detail`` field out of an HTTPStatusError when present so
283
+ the user sees the actual error rather than a generic httpx repr. The
284
+ ``role_action`` hint (e.g. ``"delete metrics"``) is only attached to 403
285
+ responses; on other failures the hint is omitted so we don't gaslight the
286
+ user into thinking it's a permission problem. A `DispatchToolError` carries
287
+ the handler's own message and, when it offered one, its suggestion.
288
+ """
289
+ import httpx
290
+
291
+ if isinstance(exc, DispatchToolError):
292
+ return str(exc), exc.hint
293
+
294
+ if isinstance(exc, httpx.HTTPStatusError):
295
+ status = exc.response.status_code
296
+ detail = _response_detail(exc.response)
297
+ message = detail if detail else str(exc)
298
+ if status == 401:
299
+ hint = "Your login was rejected - run 'dataspring login' again."
300
+ elif status == 403:
301
+ hint = (
302
+ f"You need a higher role to {role_action}." if role_action
303
+ else "You don't have permission for this action."
304
+ )
305
+ elif status == 404:
306
+ hint = None # message already says "not found"
307
+ elif status >= 500:
308
+ hint = "Server error — check backend logs or try again."
309
+ else:
310
+ hint = None
311
+ return message, hint
312
+
313
+ if isinstance(exc, httpx.ConnectError):
314
+ return (
315
+ f"Could not connect to {get_api_base()}",
316
+ "Check MCP_SERVER_BASE_URL or your network.",
317
+ )
318
+ if isinstance(exc, httpx.TimeoutException):
319
+ return "Request timed out", "Try again, or narrow the request."
320
+
321
+ return str(exc), None
322
+
323
+
324
+ def fail(
325
+ exc: BaseException, role_action: str | None = None, *, prefix: str | None = None
326
+ ) -> NoReturn:
327
+ """Report a failed API call and exit 1.
328
+
329
+ `typer.Exit` / `typer.Abort` pass through untouched, so a command can keep
330
+ "Cancelled" (exit 0) inside the same ``try`` as its API calls.
331
+ """
332
+ if isinstance(exc, (typer.Exit, typer.Abort)):
333
+ raise exc
334
+ msg, hint = format_api_error(exc, role_action) # type: ignore[arg-type]
335
+ print_error(f"{prefix}: {msg}" if prefix else msg, hint=hint)
336
+ raise typer.Exit(1)
337
+
338
+
339
+ def load_definition_file(file: Path) -> dict:
340
+ """Load a metric/model definition from a YAML or JSON file.
341
+
342
+ Args:
343
+ file: Path to the definition file
344
+
345
+ Returns:
346
+ Parsed definition dictionary
347
+
348
+ Raises:
349
+ typer.Exit: If file not found or parsing fails
350
+ """
351
+ if not file.exists():
352
+ print_error(f"File not found: {file}")
353
+ raise typer.Exit(1)
354
+
355
+ try:
356
+ content = file.read_text()
357
+ if file.suffix in (".yaml", ".yml"):
358
+ return yaml.safe_load(content)
359
+ return json.loads(content)
360
+ except yaml.YAMLError as e:
361
+ print_error(f"Invalid YAML: {e}")
362
+ raise typer.Exit(1)
363
+ except json.JSONDecodeError as e:
364
+ print_error(f"Invalid JSON: {e}")
365
+ raise typer.Exit(1)
366
+
367
+
368
+ # ============================================================================
369
+ # Top-level commands
370
+ # ============================================================================
371
+
372
+
373
+ def _print_version_report() -> None:
374
+ """The version, and the manifest skew against the server when logged in."""
375
+ from cli.version import version_report
376
+
377
+ token = auth.get_access_token() if auth.is_logged_in() else None
378
+ try:
379
+ lines = version_report(base=get_api_base(), token=token)
380
+ except AuthenticationError:
381
+ lines = version_report(base=get_api_base(), token=None)
382
+ console.print(f"[bold]DataSpring CLI[/] v{lines[0].split(' ', 1)[1]}")
383
+ for line in lines[1:]:
384
+ console.print(line)
385
+
386
+
387
+ def _version_option(value: bool) -> None:
388
+ if value:
389
+ _print_version_report()
390
+ raise typer.Exit()
391
+
392
+
393
+ @app.callback()
394
+ def _root(
395
+ version: Annotated[
396
+ Optional[bool],
397
+ typer.Option("--version", callback=_version_option, is_eager=True, help="Print the version and exit"),
398
+ ] = None,
399
+ ):
400
+ """DataSpring CLI - Query metrics and manage dashboards from the terminal."""
401
+
402
+
403
+ @app.command()
404
+ def version():
405
+ """Show the CLI version and, when logged in, its skew against the server."""
406
+ _print_version_report()
407
+
408
+
409
+ @app.command()
410
+ def upgrade(
411
+ check: Annotated[bool, typer.Option("--check", help="Only compare with PyPI's latest; change nothing")] = False,
412
+ ):
413
+ """Upgrade the CLI in place (uv tool, pipx or pip, whichever installed it)."""
414
+ import subprocess
415
+
416
+ from cli import upgrade as up
417
+
418
+ current = up.cli_version()
419
+ console.print(f"installed: {up.DIST} {current}")
420
+ try:
421
+ latest = up.latest_pypi_version()
422
+ except Exception as e:
423
+ print_error(f"Could not read PyPI: {e}")
424
+ raise typer.Exit(1)
425
+ console.print(f"latest on PyPI: {latest}")
426
+ if not up.is_newer(latest, current):
427
+ print_success("Already at the latest version")
428
+ return
429
+ if check:
430
+ console.print("an upgrade is available; run `dataspring upgrade`")
431
+ return
432
+ method = up.detect_install_method()
433
+ command = up.upgrade_command(method)
434
+ console.print(f"upgrading via {method}: {' '.join(command)}")
435
+ result = subprocess.run(command, check=False)
436
+ if result.returncode != 0:
437
+ print_error(f"upgrade command exited {result.returncode}")
438
+ raise typer.Exit(1)
439
+ after = up.installed_version_after_upgrade()
440
+ print_success(f"{up.DIST} {current} -> {after}")
441
+ if after == current:
442
+ print_warning("the version did not change; the installer may have used another environment")
443
+ console.print("run `dataspring skills install --all` to bring the installed skills up to date")
444
+
445
+
446
+ @app.command()
447
+ def login():
448
+ """Login via Google OAuth.
449
+
450
+ Opens a browser window to authenticate with Google.
451
+ Your credentials are stored locally in ~/.dataspring/credentials.json.
452
+ """
453
+ if auth.is_logged_in():
454
+ email = auth.get_stored_email()
455
+ print_info(f"Already logged in as {email}")
456
+ print_info("Run 'dataspring logout' first to switch accounts")
457
+ return
458
+
459
+ try:
460
+ user = run_async(auth.login())
461
+ print_success(f"Logged in as {user.email}")
462
+ print_info(f"Organization: {user.org_id} ({user.role})")
463
+ except AuthenticationError as e:
464
+ print_error(str(e))
465
+ raise typer.Exit(1)
466
+ except Exception as e:
467
+ print_error(f"Login failed: {e}")
468
+ raise typer.Exit(1)
469
+
470
+
471
+ @app.command()
472
+ def logout():
473
+ """Logout and clear stored credentials."""
474
+ if not auth.is_logged_in():
475
+ print_info("Not logged in")
476
+ return
477
+
478
+ email = auth.get_stored_email()
479
+ run_async(auth.logout())
480
+ print_success(f"Logged out {email}")
481
+
482
+
483
+ @app.command()
484
+ def whoami():
485
+ """Show current user and organization."""
486
+ user = require_auth()
487
+ print_user_context(
488
+ email=user.email,
489
+ org_id=user.org_id,
490
+ role=user.role,
491
+ org_name=user.org_name,
492
+ )
493
+
494
+
495
+ @app.command()
496
+ def report(
497
+ command: Annotated[
498
+ str,
499
+ typer.Argument(help="The CLI command that failed (e.g. 'query', 'dashboards list')"),
500
+ ],
501
+ error_message: Annotated[
502
+ str,
503
+ typer.Argument(help="The exact error message you received"),
504
+ ],
505
+ expected: Annotated[
506
+ Optional[str],
507
+ typer.Option("--expected", "-e", help="What you expected to happen"),
508
+ ] = None,
509
+ context: Annotated[
510
+ Optional[str],
511
+ typer.Option("--context", "-c", help="What you were trying to achieve"),
512
+ ] = None,
513
+ ):
514
+ """Submit an error report to help improve DataSpring.
515
+
516
+ Examples:
517
+ dataspring report "query" "Permission denied" --expected "Query should succeed" --context "Querying revenue metric"
518
+ dataspring report "dashboards list" "Connection timeout"
519
+ """
520
+ token = auth.get_access_token()
521
+
522
+ from importlib.metadata import version as get_version
523
+
524
+ try:
525
+ cli_version = get_version("dataspring-cli")
526
+ except Exception:
527
+ cli_version = "unknown"
528
+
529
+ # The handler has no cli_version field; keep the version in the context
530
+ # line so triage still sees which build produced the report.
531
+ context_info = f"{context} (cli {cli_version})" if context else f"cli {cli_version}"
532
+
533
+ try:
534
+ result = call_tool(
535
+ "submit_error_report",
536
+ None,
537
+ {
538
+ "command": command,
539
+ "error_message": error_message,
540
+ "expected": expected,
541
+ "context_info": context_info,
542
+ "source": "cli",
543
+ },
544
+ token=token,
545
+ timeout=15.0,
546
+ )
547
+ print_success(f"Error report submitted (ID: {result['id']})")
548
+ print_info("Thank you for the feedback.")
549
+ except Exception as e:
550
+ fail(e, prefix="Failed to submit report")
551
+
552
+
553
+ @app.command()
554
+ def docs(
555
+ full: Annotated[
556
+ bool,
557
+ typer.Option("--full", help="Show full documentation (llms-full.txt)"),
558
+ ] = False,
559
+ raw: Annotated[
560
+ bool,
561
+ typer.Option("--raw", help="Output raw markdown without formatting"),
562
+ ] = False,
563
+ ):
564
+ """Show DataSpring documentation for LLMs.
565
+
566
+ Fetches documentation from dataspring.app designed for LLM consumption.
567
+ Use this to understand DataSpring's capabilities, API, and CLI commands.
568
+
569
+ Examples:
570
+ dataspring docs # Show navigation/summary
571
+ dataspring docs --full # Show complete documentation
572
+ dataspring docs --full --raw # Raw markdown for piping
573
+ """
574
+ import httpx
575
+ from rich.markdown import Markdown
576
+
577
+ url = "https://dataspring.app/llms-full.txt" if full else "https://dataspring.app/llms.txt"
578
+
579
+ try:
580
+ with httpx.Client(timeout=30.0) as client:
581
+ response = client.get(url)
582
+ response.raise_for_status()
583
+ content = response.text
584
+
585
+ if raw:
586
+ print(content)
587
+ else:
588
+ console.print(Markdown(content))
589
+
590
+ except httpx.HTTPStatusError as e:
591
+ print_error(f"Failed to fetch documentation: HTTP {e.response.status_code}")
592
+ raise typer.Exit(1)
593
+ except httpx.RequestError as e:
594
+ print_error(f"Failed to fetch documentation: {e}")
595
+ print_info("Check your internet connection or try again later")
596
+ raise typer.Exit(1)
597
+
598
+
599
+ def _query_body(
600
+ metrics: list[str],
601
+ dimensions: Optional[list[str]] = None,
602
+ grain: Optional[str] = None,
603
+ start_date: Optional[str] = None,
604
+ end_date: Optional[str] = None,
605
+ limit: Optional[int] = None,
606
+ order_by: Optional[str] = None,
607
+ warehouse: Optional[str] = None,
608
+ ) -> dict:
609
+ """The keyword arguments of the ``query_metrics`` / ``export_data`` tools.
610
+
611
+ Only what the user asked for goes on the wire, so the server's defaults
612
+ (and the user's stored query preferences) apply to the rest.
613
+ """
614
+ body: dict = {"metrics": metrics}
615
+ if dimensions:
616
+ body["dimensions"] = dimensions
617
+ if grain:
618
+ body["grain"] = grain
619
+ if start_date:
620
+ body["start_date"] = start_date
621
+ if end_date:
622
+ body["end_date"] = end_date
623
+ if limit:
624
+ body["limit"] = limit
625
+ if order_by:
626
+ body["order_by"] = order_by
627
+ if warehouse:
628
+ body["warehouse"] = warehouse
629
+ return body
630
+
631
+
632
+ def _print_query_notes(result: dict) -> None:
633
+ """What the server folded into the query, so a number is never shown bare."""
634
+ for applied in result.get("applied_defaults") or []:
635
+ text = applied.get("echo_text") if isinstance(applied, dict) else None
636
+ print_info(text or f"Applied a saved preference: {applied}")
637
+ for note in result.get("notes") or []:
638
+ console.print(f"[dim]{note}[/]")
639
+ if result.get("hint"):
640
+ print_info(result["hint"])
641
+
642
+
643
+ @app.command()
644
+ def query(
645
+ metrics: Annotated[
646
+ list[str],
647
+ typer.Option("-m", "--metrics", help="Metric names to query"),
648
+ ],
649
+ dimensions: Annotated[
650
+ Optional[list[str]],
651
+ typer.Option("-d", "--dimensions", help="Dimensions to group by (use qualified names, e.g. customer__region)"),
652
+ ] = None,
653
+ grain: Annotated[
654
+ Optional[str],
655
+ typer.Option("-g", "--grain", help="Time grain: day, week, month, quarter, year"),
656
+ ] = None,
657
+ start_date: Annotated[
658
+ Optional[str],
659
+ typer.Option("--start", help="Start date (YYYY-MM-DD)"),
660
+ ] = None,
661
+ end_date: Annotated[
662
+ Optional[str],
663
+ typer.Option("--end", help="End date (YYYY-MM-DD)"),
664
+ ] = None,
665
+ limit: Annotated[
666
+ Optional[int],
667
+ typer.Option("--limit", help="Maximum rows to return"),
668
+ ] = None,
669
+ order_by: Annotated[
670
+ Optional[str],
671
+ typer.Option("--order-by", help="Column to sort by (append ' desc' for descending)"),
672
+ ] = None,
673
+ suggest_viz: Annotated[
674
+ bool,
675
+ typer.Option("--suggest-viz", help="Suggest a visualization type"),
676
+ ] = False,
677
+ warehouse: Annotated[
678
+ Optional[str],
679
+ typer.Option("--warehouse", help="Which of the org's warehouses to query (see `warehouses list`); default: the active one"),
680
+ ] = None,
681
+ format: Annotated[
682
+ OutputFormat,
683
+ typer.Option("--format", "-f", help="Output format"),
684
+ ] = "table",
685
+ ):
686
+ """Query metrics from your data warehouse.
687
+
688
+ Examples:
689
+ dataspring query -m total_revenue -g month
690
+ dataspring query -m revenue -d region --limit 10
691
+ dataspring query -m revenue -m orders --start 2024-01-01 --end 2024-12-31
692
+ dataspring query -m total_revenue -g month --warehouse managed
693
+ """
694
+ require_auth()
695
+ token = auth.get_access_token()
696
+
697
+ body = _query_body(
698
+ split_commas(metrics) or [],
699
+ split_commas(dimensions),
700
+ grain,
701
+ start_date,
702
+ end_date,
703
+ limit,
704
+ order_by,
705
+ warehouse,
706
+ )
707
+ if suggest_viz:
708
+ body["suggest_visualization"] = True
709
+
710
+ try:
711
+ result = call_tool("query_metrics", None, body, token=token, timeout=120.0)
712
+ except Exception as e:
713
+ if isinstance(e, (typer.Exit, typer.Abort)):
714
+ raise
715
+ msg, hint = format_api_error(e)
716
+ print_error(f"Query failed: {msg}", hint=hint)
717
+ print_report_hint("query", msg)
718
+ raise typer.Exit(1)
719
+
720
+ data = result.get("data", [])
721
+ columns = result.get("columns", [])
722
+
723
+ format_output(
724
+ data,
725
+ format=format,
726
+ columns=columns,
727
+ title=f"Query Results ({len(data)} rows)",
728
+ )
729
+ if format == "table":
730
+ _print_query_notes(result)
731
+ viz = result.get("visualization")
732
+ if suggest_viz and isinstance(viz, dict):
733
+ print_visualization_suggestion(
734
+ viz.get("widget_type", "?"), viz.get("rationale", "")
735
+ )
736
+
737
+
738
+ @app.command()
739
+ def export(
740
+ output: Annotated[
741
+ Path,
742
+ typer.Option("--output", "-o", help="Output file path (required)"),
743
+ ],
744
+ metrics: Annotated[
745
+ Optional[list[str]],
746
+ typer.Option("-m", "--metrics", help="Metric names to export"),
747
+ ] = None,
748
+ dimensions: Annotated[
749
+ Optional[list[str]],
750
+ typer.Option("-d", "--dimensions", help="Dimensions to group by"),
751
+ ] = None,
752
+ grain: Annotated[
753
+ Optional[str],
754
+ typer.Option("-g", "--grain", help="Time grain: day, week, month, quarter, year"),
755
+ ] = None,
756
+ start_date: Annotated[
757
+ Optional[str],
758
+ typer.Option("--start", help="Start date (YYYY-MM-DD)"),
759
+ ] = None,
760
+ end_date: Annotated[
761
+ Optional[str],
762
+ typer.Option("--end", help="End date (YYYY-MM-DD)"),
763
+ ] = None,
764
+ dashboard: Annotated[
765
+ Optional[str],
766
+ typer.Option("--dashboard", help="Dashboard ID to export (exports all widget data)"),
767
+ ] = None,
768
+ warehouse: Annotated[
769
+ Optional[str],
770
+ typer.Option("--warehouse", help="Which of the org's warehouses to query (metrics export); default: the active one"),
771
+ ] = None,
772
+ format: Annotated[
773
+ str,
774
+ typer.Option("--format", "-f", help="Output format: csv or json"),
775
+ ] = "csv",
776
+ ):
777
+ """Export query results or dashboard data to CSV or JSON.
778
+
779
+ You can either:
780
+ - Export query results by specifying metrics
781
+ - Export all data from a dashboard by specifying --dashboard
782
+
783
+ Examples:
784
+ dataspring export -o revenue.csv -m total_revenue -g month
785
+ dataspring export -o sales.json -m revenue -m orders -f json
786
+ dataspring export -o dashboard.csv --dashboard abc123
787
+ dataspring export -o report.csv -m revenue -d region --start 2024-01-01
788
+ """
789
+ require_auth()
790
+
791
+ metrics = split_commas(metrics)
792
+ dimensions = split_commas(dimensions)
793
+
794
+ # Validate format
795
+ format_lower = format.lower()
796
+ if format_lower not in ("csv", "json"):
797
+ print_error(
798
+ f"Invalid format: {format}",
799
+ hint="Use 'csv' or 'json'",
800
+ )
801
+ raise typer.Exit(1)
802
+
803
+ # Ensure output has correct extension
804
+ output_path = output
805
+ expected_ext = f".{format_lower}"
806
+ if output_path.suffix.lower() != expected_ext:
807
+ output_path = output_path.with_suffix(expected_ext)
808
+ print_warning(f"Output file extension adjusted to {output_path.name}")
809
+
810
+ # Must have either metrics or dashboard
811
+ if not metrics and not dashboard:
812
+ print_error(
813
+ "Either --metrics or --dashboard must be provided",
814
+ hint="Use -m metric_name or --dashboard dashboard_id",
815
+ )
816
+ raise typer.Exit(1)
817
+
818
+ token = auth.get_access_token()
819
+
820
+ if dashboard:
821
+ body: dict = {"format": format_lower, "dashboard_id": dashboard}
822
+ print_info(f"Exporting data from dashboard {dashboard}...")
823
+ else:
824
+ body = {
825
+ "format": format_lower,
826
+ **_query_body(metrics or [], dimensions, grain, start_date, end_date, warehouse=warehouse),
827
+ }
828
+ print_info(f"Exporting metrics: {', '.join(metrics or [])}...")
829
+
830
+ try:
831
+ result = call_tool("export_data", None, body, token=token, timeout=120.0)
832
+ except Exception as e:
833
+ if isinstance(e, (typer.Exit, typer.Abort)):
834
+ raise
835
+ msg, hint = format_api_error(e)
836
+ print_error(f"Export failed: {msg}", hint=hint)
837
+ print_report_hint("export", msg)
838
+ raise typer.Exit(1)
839
+
840
+ # The server renders the CSV/JSON; the CLI only writes the bytes it got.
841
+ output_path.write_text(result.get("content") or "", encoding="utf-8")
842
+
843
+ row_count = result.get("row_count", 0)
844
+ columns = result.get("columns") or []
845
+ print_success(f"Exported {row_count} rows to {output_path}")
846
+ if columns:
847
+ print_info(f"Columns: {', '.join(columns)}")
848
+
849
+
850
+ # ============================================================================
851
+ # Metrics sub-commands
852
+ # ============================================================================
853
+ #
854
+ # ``list`` and ``show`` read ``GET /api/semantic-layer/metrics[/{name}]``.
855
+ # Writes are the generated ``dataspring metric_edit create|update|delete|
856
+ # preview|impact`` (``--metric-data-json @metric.yaml``), so the ownership
857
+ # rules (dbt owns computation, DataSpring owns text), the confirmation gate
858
+ # for redefining a native metric and the learned-trail entry all happen on
859
+ # the server and the flags can never lag the model.
860
+
861
+
862
+ @metrics_app.command("list")
863
+ def metrics_list(
864
+ format: Annotated[
865
+ OutputFormat,
866
+ typer.Option("--format", "-f", help="Output format"),
867
+ ] = "table",
868
+ ):
869
+ """List available metrics."""
870
+ require_auth()
871
+ token = auth.get_access_token()
872
+
873
+ try:
874
+ result = api_get("/api/semantic-layer/metrics", token)
875
+ except Exception as e:
876
+ fail(e, role_action="list metrics")
877
+
878
+ format_output(
879
+ result.get("metrics", []),
880
+ format=format,
881
+ columns=["name", "type", "description"],
882
+ title="Available Metrics",
883
+ )
884
+
885
+
886
+ @metrics_app.command("show")
887
+ def metrics_show(
888
+ name: Annotated[str, typer.Argument(help="Metric name")],
889
+ format: Annotated[
890
+ OutputFormat,
891
+ typer.Option("--format", "-f", help="Output format"),
892
+ ] = "table",
893
+ ):
894
+ """Show details for a specific metric."""
895
+ require_auth()
896
+ token = auth.get_access_token()
897
+
898
+ try:
899
+ metric = api_get(f"/api/semantic-layer/metrics/{name}", token)
900
+ except Exception as e:
901
+ fail(e, role_action="view metrics")
902
+
903
+ format_output(metric, format=format, title=f"Metric: {name}")
904
+
905
+
906
+ # ============================================================================
907
+ # Dimensions sub-commands
908
+ # ============================================================================
909
+
910
+
911
+ @dimensions_app.command("list")
912
+ def dimensions_list(
913
+ format: Annotated[
914
+ OutputFormat,
915
+ typer.Option("--format", "-f", help="Output format"),
916
+ ] = "table",
917
+ ):
918
+ """List available dimensions."""
919
+ user = require_auth()
920
+ token = auth.get_access_token()
921
+
922
+ try:
923
+ result = api_get("/api/semantic-layer/dimensions", token)
924
+ dimensions = result.get("dimensions", [])
925
+ format_output(
926
+ dimensions,
927
+ format=format,
928
+ columns=["name"],
929
+ title="Available Dimensions",
930
+ )
931
+ except Exception as e:
932
+ print_error(str(e))
933
+ raise typer.Exit(1)
934
+
935
+
936
+ # ============================================================================
937
+ # Org sub-commands
938
+ # ============================================================================
939
+ #
940
+ # ``switch`` is the ``switch_organization`` dispatch tool: the server resolves
941
+ # the caller from the bearer token and writes only that user's document.
942
+ # Before this, the CLI called `services.auth.switch_org(email, org_id)` with
943
+ # the e-mail from a user-editable config file, over the operator's own
944
+ # Firestore credentials - anyone with ADC could switch another user's org.
945
+ # ``list`` reads ``GET /api/me/organizations``, the REST twin of the
946
+ # ``dataspring://organizations`` resource; ``/api/me`` carries only the
947
+ # current org.
948
+
949
+ #: The read route for the caller's organizations.
950
+ ORGANIZATIONS_PATH = "/api/me/organizations"
951
+
952
+
953
+ def _orgs_from(result) -> list[dict]:
954
+ """A bare list or an ``{"organizations": [...]}`` envelope, either way."""
955
+ if isinstance(result, dict):
956
+ return result.get("organizations") or []
957
+ return list(result or [])
958
+
959
+
960
+ @org_app.command("list")
961
+ def org_list(
962
+ format: Annotated[
963
+ OutputFormat,
964
+ typer.Option("--format", "-f", help="Output format"),
965
+ ] = "table",
966
+ ):
967
+ """List organizations you belong to."""
968
+ if not auth.is_logged_in():
969
+ print_error("Not logged in", hint="Run 'dataspring login' first")
970
+ raise typer.Exit(1)
971
+
972
+ token = auth.get_access_token()
973
+
974
+ try:
975
+ orgs = _orgs_from(api_get(ORGANIZATIONS_PATH, token))
976
+ except Exception as e:
977
+ fail(e, role_action="list your organizations")
978
+
979
+ format_output(
980
+ orgs,
981
+ format=format,
982
+ columns=["id", "name", "role", "current"],
983
+ title="Your Organizations",
984
+ )
985
+
986
+
987
+ @org_app.command("switch")
988
+ def org_switch(
989
+ org_id: Annotated[str, typer.Argument(help="Organization ID to switch to")],
990
+ ):
991
+ """Switch to a different organization."""
992
+ if not auth.is_logged_in():
993
+ print_error("Not logged in", hint="Run 'dataspring login' first")
994
+ raise typer.Exit(1)
995
+
996
+ token = auth.get_access_token()
997
+
998
+ try:
999
+ result = call_tool("switch_organization", None, {"org_id": org_id}, token=token)
1000
+ except Exception as e:
1001
+ fail(e, role_action="switch to this organization")
1002
+
1003
+ print_success(f"Switched to organization: {result.get('org_id', org_id)}")
1004
+ print_info(f"Role: {result.get('role')}")
1005
+
1006
+
1007
+ # `dataspring switch <org>` is the same command at the root (the D19 note's
1008
+ # name for it); `org switch` stays for the hands that know it.
1009
+ app.command("switch")(org_switch)
1010
+
1011
+
1012
+ # ============================================================================
1013
+ # Dashboards sub-commands
1014
+ # ============================================================================
1015
+ #
1016
+ # Reads: ``GET /api/dashboards`` and ``GET /api/dashboards/{id}``, which answer
1017
+ # with the stored document as-is. Writes are the generated ``dashboard_edit``,
1018
+ # ``widget_edit``, ``page_edit`` and ``section_edit`` groups and the
1019
+ # ``render_dashboard`` / ``render_widget`` commands (``-o file.pdf`` saves
1020
+ # the render). What stays here is what a person wants and the generated
1021
+ # twin cannot be: ``update`` from a file (its whole-definition path is the
1022
+ # one REST write, ``PUT /api/dashboards/{id}``, which no dispatch action
1023
+ # covers), ``add-widget`` building the widget from flags, and the
1024
+ # ``*-widget`` commands that take a widget id where the action wants
1025
+ # page/section/widget indices.
1026
+
1027
+
1028
+ def _get_dashboard(dashboard_id: str, token: str) -> dict:
1029
+ """``GET /api/dashboards/{id}`` - 404 when missing or not visible to the user."""
1030
+ return api_get(f"/api/dashboards/{dashboard_id}", token)
1031
+
1032
+
1033
+ def _owner_label(owner_uid: str | None, user) -> str:
1034
+ """"you" for the caller; otherwise the uid, shortened.
1035
+
1036
+ Display names lived in Firestore and were read with the operator's own
1037
+ credentials; no read-only route exposes them, so the uid stands in.
1038
+ """
1039
+ if not owner_uid:
1040
+ return "-"
1041
+ if owner_uid == user.uid:
1042
+ return "you"
1043
+ return owner_uid[:8] + "..."
1044
+
1045
+
1046
+ def _page_widgets(page: dict) -> int:
1047
+ return sum(len(section.get("widgets") or []) for section in page.get("sections") or [])
1048
+
1049
+
1050
+ def _page_index(dashboard: dict, page_id: str) -> int | None:
1051
+ for i, page in enumerate(dashboard.get("pages") or []):
1052
+ if page.get("id") == page_id:
1053
+ return i
1054
+ return None
1055
+
1056
+
1057
+ def _find_widget_location(dashboard: dict, widget_id: str) -> tuple[int, int, int] | None:
1058
+ """(page_index, section_index, widget_index) of a widget, by id."""
1059
+ for p, page in enumerate(dashboard.get("pages") or []):
1060
+ for s, section in enumerate(page.get("sections") or []):
1061
+ for w, widget in enumerate(section.get("widgets") or []):
1062
+ if widget.get("id") == widget_id:
1063
+ return p, s, w
1064
+ return None
1065
+
1066
+
1067
+ def _dashboard_summary(dashboard: dict) -> dict:
1068
+ return {
1069
+ "id": dashboard.get("id"),
1070
+ "title": dashboard.get("title"),
1071
+ "description": dashboard.get("description") or "-",
1072
+ "visibility": dashboard.get("visibility"),
1073
+ }
1074
+
1075
+
1076
+ def _print_dashboard_result(result: dict, format: OutputFormat, summary: dict, title: str) -> None:
1077
+ """The `_ok(dashboard)` shape: full document as JSON, a summary otherwise."""
1078
+ if format == "json":
1079
+ format_output(result.get("dashboard", result), format=format)
1080
+ else:
1081
+ format_output(summary, format=format, title=title)
1082
+
1083
+
1084
+ @dashboards_app.command("list")
1085
+ def dashboards_list(
1086
+ format: Annotated[
1087
+ OutputFormat,
1088
+ typer.Option("--format", "-f", help="Output format"),
1089
+ ] = "table",
1090
+ ):
1091
+ """List your dashboards (personal and team).
1092
+
1093
+ Shows dashboards you own (personal) and dashboards shared with
1094
+ your organization (team).
1095
+ """
1096
+ user = require_auth()
1097
+ token = auth.get_access_token()
1098
+
1099
+ try:
1100
+ result = api_get("/api/dashboards", token)
1101
+ except Exception as e:
1102
+ fail(e, role_action="list dashboards")
1103
+
1104
+ all_dashboards = []
1105
+ for d in result.get("personal") or []:
1106
+ all_dashboards.append({
1107
+ "id": d.get("id"),
1108
+ "title": d.get("title"),
1109
+ "visibility": d.get("visibility"),
1110
+ "owner": "you",
1111
+ "updated_at": d.get("updated_at"),
1112
+ })
1113
+ for d in result.get("team") or []:
1114
+ all_dashboards.append({
1115
+ "id": d.get("id"),
1116
+ "title": d.get("title"),
1117
+ "visibility": d.get("visibility"),
1118
+ "owner": _owner_label(d.get("owner"), user),
1119
+ "updated_at": d.get("updated_at"),
1120
+ })
1121
+
1122
+ if not all_dashboards:
1123
+ print_info("No dashboards found")
1124
+ print_info("Create one with: dataspring dashboards create 'My Dashboard'")
1125
+ return
1126
+
1127
+ format_output(
1128
+ all_dashboards,
1129
+ format=format,
1130
+ columns=["id", "title", "visibility", "owner", "updated_at"],
1131
+ title="Dashboards",
1132
+ )
1133
+
1134
+
1135
+ @dashboards_app.command("show")
1136
+ def dashboards_show(
1137
+ dashboard_id: Annotated[str, typer.Argument(help="Dashboard ID")],
1138
+ format: Annotated[
1139
+ OutputFormat,
1140
+ typer.Option("--format", "-f", help="Output format"),
1141
+ ] = "table",
1142
+ ):
1143
+ """Show details for a specific dashboard.
1144
+
1145
+ Displays dashboard metadata and page/widget structure.
1146
+ """
1147
+ user = require_auth()
1148
+ token = auth.get_access_token()
1149
+
1150
+ try:
1151
+ dashboard = _get_dashboard(dashboard_id, token)
1152
+ except Exception as e:
1153
+ fail(e, role_action="view this dashboard")
1154
+
1155
+ if format == "json":
1156
+ format_output(dashboard, format=format)
1157
+ return
1158
+
1159
+ pages = dashboard.get("pages") or []
1160
+ summary = {
1161
+ "id": dashboard.get("id"),
1162
+ "title": dashboard.get("title"),
1163
+ "description": dashboard.get("description") or "-",
1164
+ "visibility": dashboard.get("visibility"),
1165
+ "owner": _owner_label(dashboard.get("owner"), user),
1166
+ "created_at": dashboard.get("created_at"),
1167
+ "updated_at": dashboard.get("updated_at"),
1168
+ "pages": len(pages),
1169
+ "widgets": sum(_page_widgets(page) for page in pages),
1170
+ }
1171
+ format_output(summary, format=format, title=f"Dashboard: {dashboard.get('title')}")
1172
+
1173
+ if pages:
1174
+ console.print()
1175
+ pages_data = [
1176
+ {
1177
+ "index": i,
1178
+ "title": page.get("title"),
1179
+ "sections": len(page.get("sections") or []),
1180
+ "widgets": _page_widgets(page),
1181
+ }
1182
+ for i, page in enumerate(pages)
1183
+ ]
1184
+ format_output(
1185
+ pages_data,
1186
+ format="table",
1187
+ columns=["index", "title", "sections", "widgets"],
1188
+ title="Pages",
1189
+ )
1190
+
1191
+
1192
+ _DASHBOARD_METADATA_KEYS = {"title", "description", "visibility"}
1193
+
1194
+
1195
+ @dashboards_app.command("update")
1196
+ def dashboards_update(
1197
+ dashboard_id: Annotated[str, typer.Argument(help="Dashboard ID to update")],
1198
+ file: Annotated[
1199
+ Optional[Path],
1200
+ typer.Argument(help="Path to YAML or JSON file with updates (optional)"),
1201
+ ] = None,
1202
+ title: Annotated[
1203
+ Optional[str],
1204
+ typer.Option("--title", "-t", help="New dashboard title"),
1205
+ ] = None,
1206
+ description: Annotated[
1207
+ Optional[str],
1208
+ typer.Option("--description", "-d", help="New dashboard description"),
1209
+ ] = None,
1210
+ visibility: Annotated[
1211
+ Optional[str],
1212
+ typer.Option(
1213
+ "--visibility",
1214
+ "-v",
1215
+ help="New visibility: 'private' (only you) or 'org' (team)",
1216
+ ),
1217
+ ] = None,
1218
+ format: Annotated[
1219
+ OutputFormat,
1220
+ typer.Option("--format", "-f", help="Output format"),
1221
+ ] = "table",
1222
+ ):
1223
+ """Update a dashboard's metadata.
1224
+
1225
+ You can update using command-line options, a file, or both.
1226
+ Command-line options override values from the file.
1227
+
1228
+ Examples:
1229
+ dataspring dashboards update abc123 --title 'New Title'
1230
+ dataspring dashboards update abc123 --description 'Updated description'
1231
+ dataspring dashboards update abc123 --visibility org
1232
+ dataspring dashboards update abc123 dashboard.yaml
1233
+ dataspring dashboards update abc123 updates.yaml --title 'Override Title'
1234
+ """
1235
+ require_auth()
1236
+
1237
+ import uuid
1238
+
1239
+ def ensure_ids(data: dict) -> dict:
1240
+ """Ensure all pages, sections, and widgets have IDs."""
1241
+ if "pages" in data:
1242
+ for page in data["pages"]:
1243
+ if not page.get("id"): # Missing, None, or empty string
1244
+ page["id"] = str(uuid.uuid4())
1245
+ for section in page.get("sections", []):
1246
+ # Sections don't have IDs by default, but widgets do
1247
+ for widget in section.get("widgets", []):
1248
+ if not widget.get("id"): # Missing, None, or empty string
1249
+ widget["id"] = str(uuid.uuid4())
1250
+ return data
1251
+
1252
+ # Build updates dict
1253
+ updates = {}
1254
+
1255
+ # Load from file if provided
1256
+ if file:
1257
+ file_updates = load_definition_file(file)
1258
+ # Auto-generate IDs for widgets/pages that don't have them
1259
+ file_updates = ensure_ids(file_updates)
1260
+ updates.update(file_updates)
1261
+
1262
+ # Override with command-line options
1263
+ if title is not None:
1264
+ updates["title"] = title
1265
+ if description is not None:
1266
+ updates["description"] = description
1267
+ if visibility is not None:
1268
+ # Validate visibility
1269
+ if visibility not in ("private", "org"):
1270
+ print_error(
1271
+ f"Invalid visibility: {visibility}",
1272
+ hint="Use 'private' or 'org'",
1273
+ )
1274
+ raise typer.Exit(1)
1275
+ updates["visibility"] = visibility
1276
+
1277
+ # Check that we have something to update
1278
+ if not updates:
1279
+ print_error(
1280
+ "No updates provided",
1281
+ hint="Use --title, --description, --visibility, or provide a file",
1282
+ )
1283
+ raise typer.Exit(1)
1284
+
1285
+ token = auth.get_access_token()
1286
+
1287
+ try:
1288
+ if set(updates) <= _DASHBOARD_METADATA_KEYS:
1289
+ result = call_tool("dashboard_edit", "update", {"id": dashboard_id, **updates}, token=token)
1290
+ updated = result.get("dashboard", {})
1291
+ else:
1292
+ # A whole-definition edit (pages, controls, ...) has no dispatch
1293
+ # action; the REST route validates and saves the document.
1294
+ updated = api_put(f"/api/dashboards/{dashboard_id}", updates, token)
1295
+ except Exception as e:
1296
+ fail(e, role_action="update this dashboard")
1297
+
1298
+ print_success(f"Updated dashboard: {updated.get('title')}")
1299
+ format_output(_dashboard_summary(updated), format=format, title="Updated Dashboard")
1300
+
1301
+
1302
+ @dashboards_app.command("add-widget")
1303
+ def add_widget_cmd(
1304
+ dashboard_id: Annotated[str, typer.Argument(help="Dashboard ID")],
1305
+ widget_type: Annotated[
1306
+ str,
1307
+ typer.Option("--type", "-t", help="Widget type: kpi, line_chart, bar_chart, area_chart, donut, table, heatmap"),
1308
+ ],
1309
+ title: Annotated[str, typer.Option("--title", help="Widget title")],
1310
+ metrics: Annotated[
1311
+ list[str],
1312
+ typer.Option("-m", "--metrics", help="Metric names to query"),
1313
+ ],
1314
+ dimensions: Annotated[
1315
+ Optional[list[str]],
1316
+ typer.Option("-d", "--dimensions", help="Dimensions to group by"),
1317
+ ] = None,
1318
+ grain: Annotated[
1319
+ Optional[str],
1320
+ typer.Option("-g", "--grain", help="Time grain: day, week, month, quarter, year, or $grain for dashboard control"),
1321
+ ] = None,
1322
+ page: Annotated[
1323
+ int,
1324
+ typer.Option("--page", help="Page index (0-based)"),
1325
+ ] = 0,
1326
+ section: Annotated[
1327
+ int,
1328
+ typer.Option("--section", help="Section index (0-based)"),
1329
+ ] = 0,
1330
+ width: Annotated[
1331
+ Optional[int],
1332
+ typer.Option("--width", "-w", help="Widget width (1-10). Defaults: kpi=2, donut=3, heatmap=5, charts/tables=10"),
1333
+ ] = None,
1334
+ time_scope: Annotated[
1335
+ Optional[str],
1336
+ typer.Option("--time-scope", help="Date scope: range (full), latest (current period), latest_complete (last complete period)"),
1337
+ ] = None,
1338
+ pivot_on: Annotated[
1339
+ Optional[str],
1340
+ typer.Option("--pivot-on", help="Pivot dimension (table only): dimension values become columns"),
1341
+ ] = None,
1342
+ pivot_totals: Annotated[
1343
+ bool,
1344
+ typer.Option("--pivot-totals", help="Show row/column totals in pivot table"),
1345
+ ] = False,
1346
+ stacked: Annotated[
1347
+ bool,
1348
+ typer.Option("--stacked", help="Stack bars by second dimension (bar_chart only)"),
1349
+ ] = False,
1350
+ format: Annotated[
1351
+ OutputFormat,
1352
+ typer.Option("--format", "-f", help="Output format"),
1353
+ ] = "table",
1354
+ ):
1355
+ """Add a new widget to a dashboard.
1356
+
1357
+ Creates a new widget with the specified configuration and adds it
1358
+ to the specified page and section. Uses a 10-column grid layout.
1359
+
1360
+ Default widths by type (if --width not specified):
1361
+ kpi: 2 columns (5 per row)
1362
+ donut: 3 columns (3 per row)
1363
+ heatmap: 5 columns (2 per row)
1364
+ area_chart, line_chart, bar_chart, table: 10 columns (full width)
1365
+
1366
+ Time scope (for non-time-series widgets):
1367
+ range: Use full dashboard date range (default)
1368
+ latest: Current period only (may be incomplete)
1369
+ latest_complete: Last complete period
1370
+
1371
+ Stacked bar charts (bar_chart only):
1372
+ Use --stacked with 2 dimensions: first for x-axis, second for stack segments.
1373
+
1374
+ Pivot tables (table type only):
1375
+ Use --pivot-on to transform dimension values into columns.
1376
+ The pivot dimension must be included in --dimensions.
1377
+
1378
+ Examples:
1379
+ dataspring dashboards add-widget abc123 --type kpi --title "Revenue" -m total_revenue
1380
+ dataspring dashboards add-widget abc123 -t line_chart --title "Trends" -m revenue -g month
1381
+ dataspring dashboards add-widget abc123 -t kpi --title "Last Month" -m revenue --time-scope latest_complete
1382
+ dataspring dashboards add-widget abc123 -t kpi --title "Custom" -m revenue --width 4
1383
+ dataspring dashboards add-widget abc123 -t bar_chart --title "Spend by Platform" \\
1384
+ -m ad_spend -d metric_time -d platform -g month --stacked
1385
+ dataspring dashboards add-widget abc123 -t table --title "Revenue by Region and Category" \\
1386
+ -m revenue -d region -d category --pivot-on category
1387
+ dataspring dashboards add-widget abc123 -t table --title "Monthly Revenue by Region" \\
1388
+ -m revenue -d region -g month --pivot-on region --pivot-totals
1389
+ """
1390
+ require_auth()
1391
+
1392
+ metrics = split_commas(metrics) or []
1393
+ dimensions = split_commas(dimensions)
1394
+
1395
+ # Build widget definition
1396
+ widget = {
1397
+ "type": widget_type,
1398
+ "title": title,
1399
+ "query": {
1400
+ "metrics": metrics,
1401
+ },
1402
+ }
1403
+
1404
+ if dimensions:
1405
+ widget["query"]["dimensions"] = dimensions
1406
+ if grain:
1407
+ widget["query"]["grain"] = grain
1408
+ if width:
1409
+ widget["width"] = width
1410
+ if time_scope:
1411
+ if time_scope not in ("range", "latest", "latest_complete"):
1412
+ print_error(f"Invalid time_scope: {time_scope}. Must be: range, latest, latest_complete")
1413
+ raise typer.Exit(1)
1414
+ widget["time_scope"] = time_scope
1415
+
1416
+ if pivot_on:
1417
+ if widget_type != "table":
1418
+ print_error("--pivot-on can only be used with table widgets")
1419
+ raise typer.Exit(1)
1420
+ if not dimensions or pivot_on not in dimensions:
1421
+ print_error(f"Pivot dimension '{pivot_on}' must be included in --dimensions")
1422
+ raise typer.Exit(1)
1423
+ widget["pivot"] = {"on": pivot_on, "show_totals": pivot_totals}
1424
+
1425
+ if stacked:
1426
+ if widget_type != "bar_chart":
1427
+ print_error("--stacked can only be used with bar_chart widgets")
1428
+ raise typer.Exit(1)
1429
+ if not dimensions or len(dimensions) < 2:
1430
+ print_error("Stacked bar charts require 2 dimensions: first for x-axis, second for stack segments")
1431
+ raise typer.Exit(1)
1432
+ widget["stacked"] = True
1433
+
1434
+ token = auth.get_access_token()
1435
+
1436
+ try:
1437
+ result = call_tool(
1438
+ "widget_edit",
1439
+ "add",
1440
+ {"dashboard_id": dashboard_id, "widget": widget, "page_index": page, "section_index": section},
1441
+ token=token,
1442
+ )
1443
+ except Exception as e:
1444
+ fail(e, role_action="edit this dashboard")
1445
+
1446
+ dashboard = result.get("dashboard", {})
1447
+ print_success(f"Added widget '{title}' to dashboard")
1448
+ summary = {
1449
+ "dashboard_id": dashboard.get("id"),
1450
+ "widget_title": title,
1451
+ "widget_type": widget_type,
1452
+ "page_index": page,
1453
+ "section_index": section,
1454
+ }
1455
+ _print_dashboard_result(result, format, summary, "Widget Added")
1456
+
1457
+
1458
+ @dashboards_app.command("remove-widget")
1459
+ def remove_widget_cmd(
1460
+ dashboard_id: Annotated[str, typer.Argument(help="Dashboard ID")],
1461
+ widget_id: Annotated[str, typer.Argument(help="Widget ID to remove")],
1462
+ yes: Annotated[
1463
+ bool,
1464
+ typer.Option("--yes", "-y", help="Skip confirmation"),
1465
+ ] = False,
1466
+ format: Annotated[
1467
+ OutputFormat,
1468
+ typer.Option("--format", "-f", help="Output format"),
1469
+ ] = "table",
1470
+ ):
1471
+ """Remove a widget from a dashboard.
1472
+
1473
+ Permanently deletes a widget from the dashboard.
1474
+
1475
+ Examples:
1476
+ dataspring dashboards remove-widget abc123 widget-1
1477
+ dataspring dashboards remove-widget abc123 widget-1 --yes
1478
+ """
1479
+ require_auth()
1480
+
1481
+ if not yes:
1482
+ confirm = typer.confirm(f"Remove widget '{widget_id}'?")
1483
+ if not confirm:
1484
+ print_info("Cancelled")
1485
+ return
1486
+
1487
+ token = auth.get_access_token()
1488
+
1489
+ try:
1490
+ # Resolve widget ID to page/section/widget indices
1491
+ loc = _find_widget_location(_get_dashboard(dashboard_id, token), widget_id)
1492
+ if loc is None:
1493
+ print_error(f"Widget '{widget_id}' not found in dashboard")
1494
+ raise typer.Exit(1)
1495
+ page_index, section_index, widget_index = loc
1496
+
1497
+ result = call_tool(
1498
+ "widget_edit",
1499
+ "remove",
1500
+ {
1501
+ "dashboard_id": dashboard_id,
1502
+ "page_index": page_index,
1503
+ "section_index": section_index,
1504
+ "widget_index": widget_index,
1505
+ },
1506
+ token=token,
1507
+ )
1508
+ except Exception as e:
1509
+ fail(e, role_action="edit this dashboard")
1510
+
1511
+ dashboard = result.get("dashboard", {})
1512
+ print_success(f"Removed widget '{widget_id}'")
1513
+ summary = {
1514
+ "dashboard_id": dashboard.get("id"),
1515
+ "removed_widget_id": widget_id,
1516
+ }
1517
+ _print_dashboard_result(result, format, summary, "Widget Removed")
1518
+
1519
+
1520
+ @dashboards_app.command("update-widget")
1521
+ def update_widget_cmd(
1522
+ dashboard_id: Annotated[str, typer.Argument(help="Dashboard ID")],
1523
+ widget_id: Annotated[str, typer.Argument(help="Widget ID to update")],
1524
+ title: Annotated[
1525
+ Optional[str],
1526
+ typer.Option("--title", help="New widget title"),
1527
+ ] = None,
1528
+ metrics: Annotated[
1529
+ Optional[list[str]],
1530
+ typer.Option("-m", "--metrics", help="New metric names"),
1531
+ ] = None,
1532
+ dimensions: Annotated[
1533
+ Optional[list[str]],
1534
+ typer.Option("-d", "--dimensions", help="New dimensions"),
1535
+ ] = None,
1536
+ grain: Annotated[
1537
+ Optional[str],
1538
+ typer.Option("-g", "--grain", help="New time grain"),
1539
+ ] = None,
1540
+ width: Annotated[
1541
+ Optional[int],
1542
+ typer.Option("--width", "-w", help="New widget width (1-10)"),
1543
+ ] = None,
1544
+ format: Annotated[
1545
+ OutputFormat,
1546
+ typer.Option("--format", "-f", help="Output format"),
1547
+ ] = "table",
1548
+ ):
1549
+ """Update an existing widget.
1550
+
1551
+ Modify the configuration of an existing widget. Only specified
1552
+ fields are updated; others remain unchanged.
1553
+
1554
+ Examples:
1555
+ dataspring dashboards update-widget abc123 widget-1 --title "New Title"
1556
+ dataspring dashboards update-widget abc123 widget-1 -m revenue -m orders
1557
+ dataspring dashboards update-widget abc123 widget-1 --width 6
1558
+ """
1559
+ require_auth()
1560
+
1561
+ metrics = split_commas(metrics)
1562
+ dimensions = split_commas(dimensions)
1563
+
1564
+ # Build updates dict
1565
+ updates = {}
1566
+ if title:
1567
+ updates["title"] = title
1568
+ if width:
1569
+ updates["width"] = width
1570
+
1571
+ # Build query updates if any query fields provided
1572
+ query_updates = {}
1573
+ if metrics:
1574
+ query_updates["metrics"] = metrics
1575
+ if dimensions:
1576
+ query_updates["dimensions"] = dimensions
1577
+ if grain:
1578
+ query_updates["grain"] = grain
1579
+
1580
+ if query_updates:
1581
+ updates["query"] = query_updates
1582
+
1583
+ if not updates:
1584
+ print_error("No updates specified")
1585
+ raise typer.Exit(1)
1586
+
1587
+ token = auth.get_access_token()
1588
+
1589
+ try:
1590
+ # Resolve widget ID to page/section/widget indices
1591
+ loc = _find_widget_location(_get_dashboard(dashboard_id, token), widget_id)
1592
+ if loc is None:
1593
+ print_error(f"Widget '{widget_id}' not found in dashboard")
1594
+ raise typer.Exit(1)
1595
+ page_index, section_index, widget_index = loc
1596
+
1597
+ result = call_tool(
1598
+ "widget_edit",
1599
+ "update",
1600
+ {
1601
+ "dashboard_id": dashboard_id,
1602
+ "page_index": page_index,
1603
+ "section_index": section_index,
1604
+ "widget_index": widget_index,
1605
+ "updates": updates,
1606
+ },
1607
+ token=token,
1608
+ )
1609
+ except Exception as e:
1610
+ fail(e, role_action="edit this dashboard")
1611
+
1612
+ dashboard = result.get("dashboard", {})
1613
+ print_success(f"Updated widget '{widget_id}'")
1614
+ summary = {
1615
+ "dashboard_id": dashboard.get("id"),
1616
+ "widget_id": widget_id,
1617
+ "updated_fields": list(updates.keys()),
1618
+ }
1619
+ _print_dashboard_result(result, format, summary, "Widget Updated")
1620
+
1621
+
1622
+ @dashboards_app.command("reorder-widgets")
1623
+ def reorder_widgets_cmd(
1624
+ dashboard_id: Annotated[str, typer.Argument(help="Dashboard ID")],
1625
+ page_id: Annotated[str, typer.Option("--page", help="Page ID")],
1626
+ section_index: Annotated[int, typer.Option("--section", help="Section index (0-based)")],
1627
+ widget_ids: Annotated[
1628
+ list[str],
1629
+ typer.Argument(help="Widget IDs in desired order"),
1630
+ ],
1631
+ format: Annotated[
1632
+ OutputFormat,
1633
+ typer.Option("--format", "-f", help="Output format"),
1634
+ ] = "table",
1635
+ ):
1636
+ """Reorder widgets within a section.
1637
+
1638
+ Specify the widget IDs in the desired order. All widgets in the
1639
+ section must be included.
1640
+
1641
+ Examples:
1642
+ dataspring dashboards reorder-widgets abc123 --page page-1 --section 0 widget-3 widget-1 widget-2
1643
+ """
1644
+ require_auth()
1645
+ token = auth.get_access_token()
1646
+
1647
+ try:
1648
+ # The action addresses the page by index; the option takes an id.
1649
+ page_index = _page_index(_get_dashboard(dashboard_id, token), page_id)
1650
+ if page_index is None:
1651
+ print_error(f"Page '{page_id}' not found in dashboard")
1652
+ raise typer.Exit(1)
1653
+
1654
+ result = call_tool(
1655
+ "widget_edit",
1656
+ "reorder",
1657
+ {
1658
+ "dashboard_id": dashboard_id,
1659
+ "page_index": page_index,
1660
+ "section_index": section_index,
1661
+ "widget_ids": widget_ids,
1662
+ },
1663
+ token=token,
1664
+ )
1665
+ except Exception as e:
1666
+ fail(e, role_action="edit this dashboard")
1667
+
1668
+ dashboard = result.get("dashboard", {})
1669
+ print_success(f"Reordered {len(widget_ids)} widgets")
1670
+ summary = {
1671
+ "dashboard_id": dashboard.get("id"),
1672
+ "page_id": page_id,
1673
+ "section_index": section_index,
1674
+ "new_order": widget_ids,
1675
+ }
1676
+ _print_dashboard_result(result, format, summary, "Widgets Reordered")
1677
+
1678
+
1679
+ # ============================================================================
1680
+ # Manifest sub-commands
1681
+ # ============================================================================
1682
+ #
1683
+ # ``status`` and ``export`` read ``GET /api/manifest`` and
1684
+ # ``GET /api/semantic-layer/export``; an upload is the generated ``dataspring
1685
+ # import_manifest --manifest-data-json @manifest.yaml``, whose replace
1686
+ # semantics and reference validation live on the server.
1687
+
1688
+
1689
+ @manifest_app.command("status")
1690
+ def manifest_status(
1691
+ format: Annotated[
1692
+ OutputFormat,
1693
+ typer.Option("--format", "-f", help="Output format"),
1694
+ ] = "table",
1695
+ ):
1696
+ """Show manifest status (project name, counts).
1697
+
1698
+ Displays information about the organization's semantic layer manifest
1699
+ including project name, metric count, model count, and dimension count.
1700
+ """
1701
+ user = require_auth()
1702
+ token = auth.get_access_token()
1703
+
1704
+ try:
1705
+ result = api_get("/api/manifest", token)
1706
+ except Exception as e:
1707
+ fail(e, role_action="view the manifest")
1708
+
1709
+ summary = {
1710
+ "organization": result.get("org_id", user.org_id),
1711
+ "project": result.get("project_name") or "-",
1712
+ "metrics": result.get("metrics", 0),
1713
+ "models": result.get("semantic_models", 0),
1714
+ "dimensions": result.get("dimensions", 0),
1715
+ "last_updated": result.get("uploaded_at") or "-",
1716
+ }
1717
+ format_output(summary, format=format, title="Manifest Status")
1718
+
1719
+
1720
+ @manifest_app.command("export")
1721
+ def manifest_export(
1722
+ output: Annotated[
1723
+ str,
1724
+ typer.Option("--output", "-o", help="Output file path (YAML or JSON)"),
1725
+ ],
1726
+ ):
1727
+ """Export manifest to a file.
1728
+
1729
+ Exports the organization's semantic layer manifest to a YAML or JSON
1730
+ file. The format is determined by the file extension.
1731
+
1732
+ Examples:
1733
+ dataspring manifest export -o manifest.yaml
1734
+ dataspring manifest export --output backup.json
1735
+ """
1736
+ require_auth()
1737
+ token = auth.get_access_token()
1738
+
1739
+ # Determine format from extension
1740
+ output_path = Path(output)
1741
+ ext = output_path.suffix.lower()
1742
+
1743
+ if ext not in (".yaml", ".yml", ".json"):
1744
+ print_error(
1745
+ f"Unsupported file format: {ext}",
1746
+ hint="Use .yaml, .yml, or .json",
1747
+ )
1748
+ raise typer.Exit(1)
1749
+
1750
+ try:
1751
+ manifest = api_get("/api/semantic-layer/export", token)
1752
+ except Exception as e:
1753
+ fail(e, role_action="export the manifest")
1754
+
1755
+ # Check if manifest is empty
1756
+ if not manifest.get("metrics") and not manifest.get("semantic_models"):
1757
+ print_warning("Manifest is empty (no metrics or models)")
1758
+
1759
+ # Write to file
1760
+ if ext in (".yaml", ".yml"):
1761
+ with open(output_path, "w") as f:
1762
+ yaml.dump(manifest, f, default_flow_style=False, sort_keys=False)
1763
+ else:
1764
+ with open(output_path, "w") as f:
1765
+ json.dump(manifest, f, indent=2)
1766
+
1767
+ print_success(f"Exported manifest to {output}")
1768
+ print_info(f" Metrics: {len(manifest.get('metrics', []))}")
1769
+ print_info(f" Models: {len(manifest.get('semantic_models', []))}")
1770
+
1771
+
1772
+ # ============================================================================
1773
+ # Models sub-commands
1774
+ # ============================================================================
1775
+ #
1776
+ # ``list`` and ``show`` read ``GET /api/semantic-layer/models[/{name}]``;
1777
+ # writes are the generated ``dataspring semantic_model_edit`` (admin/owner,
1778
+ # checked on the server).
1779
+
1780
+
1781
+ @models_app.command("list")
1782
+ def models_list(
1783
+ format: Annotated[
1784
+ OutputFormat,
1785
+ typer.Option("--format", "-f", help="Output format"),
1786
+ ] = "table",
1787
+ ):
1788
+ """List semantic models.
1789
+
1790
+ Shows all semantic models with their name, description,
1791
+ measure count, and dimension count.
1792
+ """
1793
+ require_auth()
1794
+ token = auth.get_access_token()
1795
+
1796
+ try:
1797
+ result = api_get("/api/semantic-layer/models", token)
1798
+ except Exception as e:
1799
+ fail(e, role_action="list semantic models")
1800
+
1801
+ models = result.get("models", [])
1802
+ if not models:
1803
+ print_info("No semantic models found")
1804
+ return
1805
+
1806
+ model_data = [
1807
+ {
1808
+ "name": model.get("name", "-"),
1809
+ "description": model.get("description", "-") or "-",
1810
+ "measures": len(model.get("measures", [])),
1811
+ "dimensions": len(model.get("dimensions", [])),
1812
+ }
1813
+ for model in models
1814
+ ]
1815
+ format_output(
1816
+ model_data,
1817
+ format=format,
1818
+ columns=["name", "description", "measures", "dimensions"],
1819
+ title="Semantic Models",
1820
+ )
1821
+
1822
+
1823
+ @models_app.command("show")
1824
+ def models_show(
1825
+ name: Annotated[str, typer.Argument(help="Model name")],
1826
+ format: Annotated[
1827
+ OutputFormat,
1828
+ typer.Option("--format", "-f", help="Output format"),
1829
+ ] = "table",
1830
+ ):
1831
+ """Show details for a semantic model.
1832
+
1833
+ Displays full model definition including all measures and dimensions.
1834
+ """
1835
+ require_auth()
1836
+ token = auth.get_access_token()
1837
+
1838
+ try:
1839
+ model = api_get(f"/api/semantic-layer/models/{name}", token)
1840
+ except Exception as e:
1841
+ fail(e, role_action="view semantic models")
1842
+
1843
+ if format in ("json", "yaml"):
1844
+ format_output(model, format=format)
1845
+ return
1846
+
1847
+ # Show model summary
1848
+ summary = {
1849
+ "name": model.get("name", "-"),
1850
+ "description": model.get("description", "-") or "-",
1851
+ "model_ref": model.get("model", "-"),
1852
+ "measures": len(model.get("measures", [])),
1853
+ "dimensions": len(model.get("dimensions", [])),
1854
+ }
1855
+ format_output(summary, format=format, title=f"Model: {name}")
1856
+
1857
+ for label, key in (("Measures", "measures"), ("Dimensions", "dimensions")):
1858
+ entries = model.get(key, [])
1859
+ if not entries:
1860
+ continue
1861
+ console.print()
1862
+ format_output(
1863
+ [
1864
+ {
1865
+ "name": entry.get("name", "-"),
1866
+ "type": entry.get("type", "-"),
1867
+ "expr": entry.get("expr", "-"),
1868
+ }
1869
+ for entry in entries
1870
+ ],
1871
+ format="table",
1872
+ columns=["name", "type", "expr"],
1873
+ title=label,
1874
+ )
1875
+
1876
+
1877
+ # ============================================================================
1878
+ # Schedules sub-commands
1879
+ # ============================================================================
1880
+ #
1881
+ # Writes are the generated ``dataspring report_edit``, which answers
1882
+ # "Scheduled reports coming soon" while the feature is off - the server
1883
+ # decides, not a local settings file. ``list`` and ``show`` read
1884
+ # ``GET /api/schedules`` / ``GET /api/schedules/{id}``, the REST twins of
1885
+ # ``dataspring://reports``; no other route exposes them.
1886
+
1887
+ #: The read routes for scheduled reports.
1888
+ SCHEDULES_PATH = "/api/schedules"
1889
+
1890
+ _WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
1891
+
1892
+
1893
+ def _schedules_from(result) -> list[dict]:
1894
+ """A bare list or a ``{"schedules": [...]}`` envelope, either way."""
1895
+ if isinstance(result, dict):
1896
+ return result.get("schedules") or []
1897
+ return list(result or [])
1898
+
1899
+
1900
+ def _short_time(value) -> str:
1901
+ """``YYYY-MM-DD HH:MM`` from an ISO timestamp, "-" when absent."""
1902
+ if not value:
1903
+ return "-"
1904
+ text = str(value)
1905
+ return text[:16].replace("T", " ") if len(text) >= 16 else text
1906
+
1907
+
1908
+ def _frequency_description(freq: dict) -> str:
1909
+ kind = freq.get("type")
1910
+ time_ = freq.get("time")
1911
+ if kind == "daily":
1912
+ return f"Daily at {time_} UTC"
1913
+ if kind == "weekly":
1914
+ day = freq.get("day_of_week")
1915
+ day_name = _WEEKDAYS[day] if isinstance(day, int) and 0 <= day < 7 else "?"
1916
+ return f"Weekly on {day_name} at {time_} UTC"
1917
+ return f"Monthly on day {freq.get('day_of_month')} at {time_} UTC"
1918
+
1919
+
1920
+ @schedules_app.command("list")
1921
+ def schedules_list(
1922
+ format: Annotated[
1923
+ OutputFormat,
1924
+ typer.Option("--format", "-f", help="Output format"),
1925
+ ] = "table",
1926
+ ):
1927
+ """List all scheduled reports.
1928
+
1929
+ Shows all schedules in your organization with their status
1930
+ and configuration summary.
1931
+
1932
+ Examples:
1933
+ dataspring schedules list
1934
+ dataspring schedules list --format json
1935
+ """
1936
+ require_auth()
1937
+ token = auth.get_access_token()
1938
+
1939
+ try:
1940
+ schedules = _schedules_from(api_get(SCHEDULES_PATH, token))
1941
+ except Exception as e:
1942
+ fail(e, role_action="list scheduled reports")
1943
+
1944
+ if not schedules:
1945
+ print_info("No scheduled reports found")
1946
+ print_info("Create one with: dataspring schedules create 'My Report' ...")
1947
+ return
1948
+
1949
+ schedules_data = [
1950
+ {
1951
+ "id": s.get("id"),
1952
+ "name": s.get("name"),
1953
+ "frequency": s.get("frequency_type"),
1954
+ "time": s.get("frequency_time"),
1955
+ "type": s.get("report_type"),
1956
+ "enabled": s.get("enabled"),
1957
+ "recipients": s.get("recipients_count"),
1958
+ "last_run": _short_time(s.get("last_run_at")),
1959
+ "status": s.get("last_run_status") or "-",
1960
+ }
1961
+ for s in schedules
1962
+ ]
1963
+ format_output(
1964
+ schedules_data,
1965
+ format=format,
1966
+ columns=["id", "name", "frequency", "time", "type", "enabled", "recipients", "last_run", "status"],
1967
+ title="Scheduled Reports",
1968
+ )
1969
+
1970
+
1971
+ @schedules_app.command("show")
1972
+ def schedules_show(
1973
+ schedule_id: Annotated[str, typer.Argument(help="Schedule ID")],
1974
+ format: Annotated[
1975
+ OutputFormat,
1976
+ typer.Option("--format", "-f", help="Output format"),
1977
+ ] = "table",
1978
+ ):
1979
+ """Show details of a scheduled report.
1980
+
1981
+ Displays full schedule configuration including frequency,
1982
+ report type, and recipients.
1983
+
1984
+ Examples:
1985
+ dataspring schedules show abc123
1986
+ dataspring schedules show abc123 --format json
1987
+ """
1988
+ require_auth()
1989
+ token = auth.get_access_token()
1990
+
1991
+ try:
1992
+ schedule = api_get(f"{SCHEDULES_PATH}/{schedule_id}", token)
1993
+ except Exception as e:
1994
+ fail(e, role_action="view scheduled reports")
1995
+
1996
+ if format == "json":
1997
+ format_output(schedule, format=format)
1998
+ return
1999
+
2000
+ report = schedule.get("report") or {}
2001
+ if report.get("dashboard_id"):
2002
+ report_type = "dashboard"
2003
+ report_details = f"Dashboard: {report.get('dashboard_id')} ({report.get('format')})"
2004
+ else:
2005
+ report_type = "query"
2006
+ metrics_str = ", ".join(report.get("metrics") or [])
2007
+ dims_str = ", ".join(report.get("dimensions") or []) or "-"
2008
+ report_details = f"Metrics: {metrics_str}\nDimensions: {dims_str}\nFormat: {report.get('format')}"
2009
+
2010
+ summary = {
2011
+ "id": schedule.get("id"),
2012
+ "name": schedule.get("name"),
2013
+ "enabled": schedule.get("enabled"),
2014
+ "frequency": _frequency_description(schedule.get("frequency") or {}),
2015
+ "report_type": report_type,
2016
+ "report_details": report_details,
2017
+ "recipients": ", ".join(schedule.get("recipients") or []),
2018
+ "created_by": schedule.get("created_by"),
2019
+ "created_at": _short_time(schedule.get("created_at")),
2020
+ "updated_at": _short_time(schedule.get("updated_at")),
2021
+ "last_run": _short_time(schedule.get("last_run_at")),
2022
+ "last_status": schedule.get("last_run_status") or "-",
2023
+ }
2024
+ format_output(summary, format=format, title=f"Schedule: {schedule.get('name')}")
2025
+
2026
+
2027
+ # ============================================================================
2028
+ # Quick Metrics sub-commands
2029
+ # ============================================================================
2030
+ #
2031
+ # Writes are the generated ``dataspring quick_metric_edit``. ``list`` and
2032
+ # ``show`` read ``GET /api/quick-metrics`` / ``GET /api/quick-metrics/{id}`` -
2033
+ # the REST twins of the ``dataspring://quick_metrics`` resources; no other
2034
+ # route exposes them.
2035
+
2036
+ #: The read routes for quick metrics.
2037
+ QUICK_METRICS_PATH = "/api/quick-metrics"
2038
+
2039
+
2040
+ def _quick_metrics_from(result) -> list[dict]:
2041
+ """A bare list or a ``{"quick_metrics": [...]}`` envelope, either way."""
2042
+ if isinstance(result, dict):
2043
+ return result.get("quick_metrics") or []
2044
+ return list(result or [])
2045
+
2046
+
2047
+ def _quick_metric_summary(metric: dict) -> dict:
2048
+ return {
2049
+ "id": metric.get("id"),
2050
+ "name": metric.get("name"),
2051
+ "query_name": metric.get("query_name") or f"qm:{metric.get('name')}",
2052
+ "expression": metric.get("expression"),
2053
+ "base_metrics": ", ".join(metric.get("base_metrics") or []),
2054
+ }
2055
+
2056
+
2057
+ @quick_metrics_app.command("list")
2058
+ def quick_metrics_list(
2059
+ format: Annotated[
2060
+ OutputFormat,
2061
+ typer.Option("--format", "-f", help="Output format"),
2062
+ ] = "table",
2063
+ ):
2064
+ """List all quick metrics.
2065
+
2066
+ Quick metrics are user-defined calculated metrics that combine
2067
+ base metrics with arithmetic expressions. Query them using the
2068
+ qm: prefix (e.g., qm:revenue_per_order).
2069
+
2070
+ Examples:
2071
+ dataspring quick-metrics list
2072
+ dataspring quick-metrics list --format json
2073
+ """
2074
+ require_auth()
2075
+ token = auth.get_access_token()
2076
+
2077
+ try:
2078
+ metrics = _quick_metrics_from(api_get(QUICK_METRICS_PATH, token))
2079
+ except Exception as e:
2080
+ fail(e, role_action="list quick metrics")
2081
+
2082
+ if not metrics:
2083
+ print_info("No quick metrics found")
2084
+ print_info("Create one with: dataspring quick-metrics create revenue_per_order 'total_revenue / order_count'")
2085
+ return
2086
+
2087
+ metrics_data = [
2088
+ {
2089
+ "id": m.get("id"),
2090
+ "name": m.get("name"),
2091
+ "expression": m.get("expression"),
2092
+ "query_name": m.get("query_name") or f"qm:{m.get('name')}",
2093
+ "description": m.get("description") or "-",
2094
+ }
2095
+ for m in metrics
2096
+ ]
2097
+ format_output(
2098
+ metrics_data,
2099
+ format=format,
2100
+ columns=["id", "name", "expression", "query_name", "description"],
2101
+ title="Quick Metrics",
2102
+ )
2103
+
2104
+
2105
+ @quick_metrics_app.command("show")
2106
+ def quick_metrics_show(
2107
+ metric_id: Annotated[str, typer.Argument(help="Quick metric ID")],
2108
+ format: Annotated[
2109
+ OutputFormat,
2110
+ typer.Option("--format", "-f", help="Output format"),
2111
+ ] = "table",
2112
+ ):
2113
+ """Show details of a quick metric.
2114
+
2115
+ Displays the full configuration of a quick metric including
2116
+ its expression and the base metrics it references.
2117
+
2118
+ Examples:
2119
+ dataspring quick-metrics show abc123
2120
+ dataspring quick-metrics show abc123 --format json
2121
+ """
2122
+ require_auth()
2123
+ token = auth.get_access_token()
2124
+
2125
+ try:
2126
+ metric = api_get(f"{QUICK_METRICS_PATH}/{metric_id}", token)
2127
+ except Exception as e:
2128
+ fail(e, role_action="view quick metrics")
2129
+
2130
+ if format == "json":
2131
+ format_output(metric, format=format)
2132
+ return
2133
+
2134
+ summary = {
2135
+ **_quick_metric_summary(metric),
2136
+ "description": metric.get("description") or "-",
2137
+ "created_by": metric.get("created_by"),
2138
+ "created_at": metric.get("created_at"),
2139
+ "updated_at": metric.get("updated_at"),
2140
+ }
2141
+ format_output(summary, format=format, title=f"Quick Metric: {metric.get('name')}")
2142
+
2143
+ # Show usage hint
2144
+ console.print()
2145
+ print_info(f"Query this metric with: dataspring query -m qm:{metric.get('name')} -g month")
2146
+
2147
+
2148
+ # ============================================================================
2149
+ # Context sub-commands
2150
+ # ============================================================================
2151
+ #
2152
+ # An update is the generated ``dataspring update_context --updates-json``
2153
+ # (validated and recorded on the learned trail by the server). ``get`` reads
2154
+ # ``GET /api/me/preferences`` - the REST twin of the ``dataspring://context``
2155
+ # MCP resource; no other route exposes a user's stored preferences.
2156
+
2157
+ #: The read route for the caller's own query preferences.
2158
+ PREFERENCES_PATH = "/api/me/preferences"
2159
+
2160
+
2161
+ def _print_preferences(prefs: dict) -> None:
2162
+ console.print("\n[bold]Preferences[/]")
2163
+ has_prefs = False
2164
+ for key in ("default_currency", "default_grain", "decimal_places", "preferred_chart_type"):
2165
+ value = prefs.get(key)
2166
+ if value is not None:
2167
+ console.print(f" {key}: {value}")
2168
+ has_prefs = True
2169
+ if not has_prefs:
2170
+ console.print(" (none)")
2171
+
2172
+ console.print("\n[bold]Favorite Metrics[/]")
2173
+ favorites = prefs.get("favorite_metrics") or []
2174
+ if favorites:
2175
+ for fav in favorites:
2176
+ console.print(f" - {fav}")
2177
+ else:
2178
+ console.print(" (none)")
2179
+
2180
+ standing = prefs.get("standing_filters") or []
2181
+ substitutions = prefs.get("metric_substitutions") or {}
2182
+ segment = prefs.get("default_segment")
2183
+ if standing or substitutions or segment:
2184
+ console.print("\n[bold]Standing query intent[/]")
2185
+ for item in standing:
2186
+ console.print(f" filter: {item}")
2187
+ for asked, preferred in substitutions.items():
2188
+ console.print(f" {asked} -> {preferred}")
2189
+ if segment:
2190
+ console.print(f" default segment: {segment}")
2191
+
2192
+ console.print()
2193
+
2194
+
2195
+ @context_app.command("get")
2196
+ def context_get(
2197
+ format: Annotated[
2198
+ OutputFormat,
2199
+ typer.Option("--format", "-f", help="Output format"),
2200
+ ] = "table",
2201
+ ):
2202
+ """Get your accumulated user context.
2203
+
2204
+ Shows preferences and favorite metrics stored across sessions.
2205
+ This context is used by agents to personalize responses.
2206
+
2207
+ Examples:
2208
+ dataspring context get
2209
+ dataspring context get --format json
2210
+ """
2211
+ require_auth()
2212
+ token = auth.get_access_token()
2213
+
2214
+ try:
2215
+ prefs = api_get(PREFERENCES_PATH, token)
2216
+ except Exception as e:
2217
+ fail(e, role_action="read your context")
2218
+
2219
+ if format == "json":
2220
+ format_output(prefs, format=format)
2221
+ else:
2222
+ _print_preferences(prefs)
2223
+
2224
+
2225
+ # ============================================================================
2226
+ # business_context sub-commands (T2.6 — plan task)
2227
+ # ============================================================================
2228
+ #
2229
+ # Reads ``GET /api/context``; writes are the ``business_context_edit``
2230
+ # dispatch family (``set``), whose 2000-byte UTF-8 cap and admin/owner check
2231
+ # live on the server. The CLI surfaces the server's answer verbatim.
2232
+
2233
+ #: UTF-8 byte cap for business_context — locked in plan T1.0; mirrored
2234
+ #: on the server in mcp_dispatch.BUSINESS_CONTEXT_CAP_BYTES. Counted in
2235
+ #: bytes (not chars) so non-ASCII content (Danish ``ø/æ/å``, emoji)
2236
+ #: doesn't smuggle past the cap and then disappear at initialize time.
2237
+ _BUSINESS_CONTEXT_CAP_BYTES = 2000
2238
+
2239
+
2240
+ def _read_business_context(token: str) -> str:
2241
+ """GET /api/context → ``content`` string (or empty)."""
2242
+ return api_get("/api/context", token).get("content", "") or ""
2243
+
2244
+
2245
+ def _write_business_context(token: str, content: str) -> dict:
2246
+ """``business_context_edit set`` - replaces the document."""
2247
+ return call_tool("business_context_edit", "set", {"content": content}, token=token)
2248
+
2249
+
2250
+ @business_context_app.command("get")
2251
+ def business_context_get():
2252
+ """Print the org's business_context document to stdout."""
2253
+ require_auth()
2254
+ token = auth.get_access_token()
2255
+
2256
+ try:
2257
+ content = _read_business_context(token)
2258
+ except Exception as e:
2259
+ fail(e, role_action="read business_context")
2260
+
2261
+ if not content:
2262
+ print_info("(no business_context set for this org)")
2263
+ return
2264
+ # Plain print so the output is shell-pipeable (e.g. `… get > file`).
2265
+ typer.echo(content)
2266
+
2267
+
2268
+ @business_context_app.command("size")
2269
+ def business_context_size():
2270
+ """Show the current business_context size against the 2000-byte UTF-8 cap."""
2271
+ require_auth()
2272
+ token = auth.get_access_token()
2273
+
2274
+ try:
2275
+ content = _read_business_context(token)
2276
+ except Exception as e:
2277
+ fail(e, role_action="read business_context")
2278
+
2279
+ n = len(content.encode("utf-8"))
2280
+ cap = _BUSINESS_CONTEXT_CAP_BYTES
2281
+ remaining = max(0, cap - n)
2282
+ console.print(f"{n} / {cap} bytes ({remaining} remaining)")
2283
+
2284
+
2285
+ @business_context_app.command("set")
2286
+ def business_context_set():
2287
+ """Replace the org's business_context. Reads new content from stdin.
2288
+
2289
+ Requires admin or owner role server-side.
2290
+
2291
+ Examples:
2292
+ cat ctx.md | dataspring business-context set
2293
+ echo "Org overview..." | dataspring business-context set
2294
+ """
2295
+ require_auth()
2296
+ token = auth.get_access_token()
2297
+
2298
+ content = sys.stdin.read()
2299
+ if not content:
2300
+ print_error("No content on stdin", hint="Pipe the document or use `set` interactively")
2301
+ raise typer.Exit(1)
2302
+
2303
+ try:
2304
+ _write_business_context(token, content)
2305
+ except Exception as e:
2306
+ fail(e, role_action="set business_context")
2307
+
2308
+ print_success(
2309
+ f"business_context updated ({len(content.encode('utf-8'))} bytes)"
2310
+ )
2311
+
2312
+
2313
+ @business_context_app.command("edit")
2314
+ def business_context_edit_cmd():
2315
+ """Open the current business_context in $EDITOR; save on exit.
2316
+
2317
+ Requires admin or owner role server-side.
2318
+ """
2319
+ # _cmd suffix avoids shadowing the ``business_context_edit`` MCP
2320
+ # tool name (would mismatch surface naming if both were imported
2321
+ # in the same module — defensive even though they're not yet).
2322
+ import os
2323
+ import subprocess
2324
+ import tempfile
2325
+
2326
+ require_auth()
2327
+ token = auth.get_access_token()
2328
+
2329
+ try:
2330
+ existing = _read_business_context(token)
2331
+ except Exception as e:
2332
+ fail(e, role_action="read business_context")
2333
+
2334
+ editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "vi"
2335
+ with tempfile.NamedTemporaryFile(
2336
+ suffix=".md", mode="w", delete=False, encoding="utf-8"
2337
+ ) as tf:
2338
+ tf.write(existing)
2339
+ tmp_path = tf.name
2340
+
2341
+ try:
2342
+ subprocess.call([editor, tmp_path])
2343
+ with open(tmp_path, encoding="utf-8") as f:
2344
+ new_content = f.read()
2345
+ finally:
2346
+ try:
2347
+ os.unlink(tmp_path)
2348
+ except OSError:
2349
+ pass
2350
+
2351
+ if new_content == existing:
2352
+ print_info("No changes")
2353
+ return
2354
+ if not new_content.strip():
2355
+ print_warning("Empty content — aborting save")
2356
+ raise typer.Exit(1)
2357
+
2358
+ try:
2359
+ _write_business_context(token, new_content)
2360
+ except Exception as e:
2361
+ fail(e, role_action="set business_context")
2362
+
2363
+ print_success(
2364
+ f"business_context updated ({len(new_content.encode('utf-8'))} bytes)"
2365
+ )
2366
+
2367
+
2368
+ @business_context_app.command("import")
2369
+ def business_context_import(
2370
+ file: Annotated[
2371
+ Path,
2372
+ typer.Argument(help="Path to the markdown file to import", exists=True),
2373
+ ],
2374
+ ):
2375
+ """Replace the org's business_context with the contents of FILE.
2376
+
2377
+ Equivalent to ``cat FILE | dataspring business-context set``.
2378
+ Requires admin or owner role server-side.
2379
+ """
2380
+ require_auth()
2381
+ token = auth.get_access_token()
2382
+
2383
+ content = file.read_text(encoding="utf-8")
2384
+
2385
+ try:
2386
+ _write_business_context(token, content)
2387
+ except Exception as e:
2388
+ fail(e, role_action="set business_context")
2389
+
2390
+ print_success(
2391
+ f"business_context imported from {file} ({len(content.encode('utf-8'))} bytes)"
2392
+ )
2393
+
2394
+
2395
+ @business_context_app.command("export")
2396
+ def business_context_export(
2397
+ output: Annotated[
2398
+ Optional[Path],
2399
+ typer.Option("--output", "-o", help="Write to file instead of stdout"),
2400
+ ] = None,
2401
+ ):
2402
+ """Write the org's business_context to a file (or stdout if no -o)."""
2403
+ require_auth()
2404
+ token = auth.get_access_token()
2405
+
2406
+ try:
2407
+ content = _read_business_context(token)
2408
+ except Exception as e:
2409
+ fail(e, role_action="read business_context")
2410
+
2411
+ if output is None:
2412
+ typer.echo(content)
2413
+ return
2414
+ output.write_text(content, encoding="utf-8")
2415
+ print_success(
2416
+ f"business_context exported to {output} ({len(content.encode('utf-8'))} bytes)"
2417
+ )
2418
+
2419
+
2420
+ # ============================================================================
2421
+ # learned sub-commands (tick rkn)
2422
+ # ============================================================================
2423
+ #
2424
+ # Reads ``GET /api/learned`` / ``GET /api/learned/{id}``; an undo is the
2425
+ # generated ``dataspring learned_edit undo [--learning-id]``, which enforces
2426
+ # who may revert what: org-scope entries need admin/owner, personal entries
2427
+ # their author.
2428
+
2429
+
2430
+ def _learned_row(entry: dict) -> dict:
2431
+ summary = entry.get("summary", "")
2432
+ if entry.get("kind") == "proposal":
2433
+ summary = f"{summary} (proposed, not applied)"
2434
+ return {
2435
+ "id": entry.get("id"),
2436
+ "when": entry.get("created_at"),
2437
+ "who": entry.get("actor_email") or entry.get("actor_id"),
2438
+ "surface": entry.get("actor_type"),
2439
+ "scope": entry.get("scope"),
2440
+ "summary": summary,
2441
+ "reverted": entry.get("reverted", False),
2442
+ }
2443
+
2444
+
2445
+ @learned_app.command("list")
2446
+ def learned_list(
2447
+ limit: Annotated[
2448
+ int, typer.Option("--limit", help="Max entries to show")
2449
+ ] = 50,
2450
+ include_reverted: Annotated[
2451
+ bool, typer.Option("--include-reverted", help="Include already-reverted entries")
2452
+ ] = False,
2453
+ format: Annotated[
2454
+ OutputFormat,
2455
+ typer.Option("--format", "-f", help="Output format"),
2456
+ ] = "table",
2457
+ ):
2458
+ """List what DataSpring learned for this org (plus your personal entries).
2459
+
2460
+ Examples:
2461
+ dataspring learned list
2462
+ dataspring learned list --limit 10 --include-reverted
2463
+ dataspring learned list -f json
2464
+ """
2465
+ require_auth()
2466
+ token = auth.get_access_token()
2467
+
2468
+ try:
2469
+ data = api_get(
2470
+ f"/api/learned?limit={limit}&include_reverted={str(include_reverted).lower()}",
2471
+ token,
2472
+ )
2473
+ except Exception as e:
2474
+ fail(e, role_action="view the learned trail")
2475
+
2476
+ entries = data.get("learnings", [])
2477
+ if not entries:
2478
+ print_info("Nothing learned yet")
2479
+ return
2480
+
2481
+ if format == "json":
2482
+ format_output(entries, format=format)
2483
+ return
2484
+
2485
+ format_output(
2486
+ [_learned_row(entry) for entry in entries],
2487
+ format=format,
2488
+ columns=["id", "when", "who", "surface", "scope", "summary", "reverted"],
2489
+ title="Learned",
2490
+ )
2491
+
2492
+
2493
+ @learned_app.command("show")
2494
+ def learned_show(
2495
+ learning_id: Annotated[str, typer.Argument(help="Learning entry ID")],
2496
+ ):
2497
+ """Show one learned entry, with before/after as YAML."""
2498
+ require_auth()
2499
+ token = auth.get_access_token()
2500
+
2501
+ try:
2502
+ entry = api_get(f"/api/learned/{learning_id}", token)
2503
+ except Exception as e:
2504
+ fail(e, role_action="view the learned trail")
2505
+
2506
+ print_yaml(entry)
2507
+
2508
+
2509
+ # ============================================================================
2510
+ # Warehouses sub-commands (docs/2026-09-17-datacore-chunk2-plan.md)
2511
+ # ============================================================================
2512
+ #
2513
+ # The read is ``GET /api/warehouses`` (the ``dataspring://warehouses``
2514
+ # payload); the writes are the generated ``dataspring warehouse_edit
2515
+ # activate|add_external|remove``.
2516
+
2517
+ WAREHOUSES_PATH = "/api/warehouses"
2518
+
2519
+
2520
+ def _warehouses_from(result) -> list[dict]:
2521
+ if isinstance(result, dict):
2522
+ return list(result.get("warehouses") or [])
2523
+ return list(result or [])
2524
+
2525
+
2526
+ def _warehouse_row(entry: dict) -> dict:
2527
+ connection = entry.get("connection") or {}
2528
+ where = connection.get("path") or (
2529
+ f"{connection.get('project')}.{connection.get('dataset')}"
2530
+ if connection.get("project")
2531
+ else "-"
2532
+ )
2533
+ return {
2534
+ "id": entry.get("id"),
2535
+ "kind": entry.get("kind"),
2536
+ "label": entry.get("label"),
2537
+ "active": "*" if entry.get("active") else "",
2538
+ "models": entry.get("semantic_models", 0),
2539
+ "metrics": entry.get("metrics", 0),
2540
+ "imported": (entry.get("manifest_imported_at") or "-")[:19],
2541
+ "where": where,
2542
+ }
2543
+
2544
+
2545
+ @warehouses_app.command("list")
2546
+ def warehouses_list(
2547
+ format: Annotated[
2548
+ OutputFormat,
2549
+ typer.Option("--format", "-f", help="Output format"),
2550
+ ] = "table",
2551
+ ):
2552
+ """List the org's warehouses: id, kind, label, which is active, manifest counts.
2553
+
2554
+ Examples:
2555
+ dataspring warehouses list
2556
+ dataspring warehouses list -f json
2557
+ """
2558
+ require_auth()
2559
+ token = auth.get_access_token()
2560
+
2561
+ try:
2562
+ entries = _warehouses_from(api_get(WAREHOUSES_PATH, token))
2563
+ except Exception as e:
2564
+ fail(e, role_action="list warehouses")
2565
+
2566
+ if not entries:
2567
+ print_info("No warehouses configured")
2568
+ return
2569
+
2570
+ if format == "json":
2571
+ format_output(entries, format=format)
2572
+ return
2573
+
2574
+ format_output(
2575
+ [_warehouse_row(entry) for entry in entries],
2576
+ format=format,
2577
+ columns=["id", "kind", "label", "active", "models", "metrics", "imported", "where"],
2578
+ title="Warehouses",
2579
+ )
2580
+
2581
+
2582
+ # ============================================================================
2583
+ # Secrets, both directions, and run_sql (datacore chunk 3, step 2)
2584
+ # ============================================================================
2585
+ #
2586
+ # `dataspring secret set <name> --stdin` is the terminal door of D17: the
2587
+ # value is read from stdin here and posted straight to the paste link the
2588
+ # server minted (`POST /api/secret-links/<nonce>`), so it goes terminal to
2589
+ # API and an agent driving the CLI sees only the exit status. `dataspring
2590
+ # airbyte credentials` prints the one-time reveal link (`--stdout` fetches
2591
+ # the value for a terminal user). `dataspring sql` is `run_sql`.
2592
+
2593
+ secret_app = typer.Typer(help="Customer secrets: set a value without it passing through an agent")
2594
+ app.add_typer(secret_app, name="secret")
2595
+ airbyte_app = typer.Typer(help="The tenant's Airbyte destination: its issued credentials")
2596
+ app.add_typer(airbyte_app, name="airbyte")
2597
+
2598
+
2599
+ def _link_nonce(link: str) -> str:
2600
+ """The nonce at the end of a `https://dataspring.app/s/<nonce>` link."""
2601
+ return link.rstrip("/").rsplit("/", 1)[-1]
2602
+
2603
+
2604
+ @secret_app.command("set")
2605
+ def secret_set_cmd(
2606
+ name: Annotated[str, typer.Argument(help="The secret: t-<org>-<connection>-<field>")],
2607
+ stdin: Annotated[
2608
+ bool,
2609
+ typer.Option("--stdin", help="Read the value from stdin and post it to the paste link (required)"),
2610
+ ] = False,
2611
+ ):
2612
+ """Set or rotate a customer secret from stdin, never from an argument.
2613
+
2614
+ Examples:
2615
+ printf '%s' "$KEY" | dataspring secret set t-acme-kanpla-api_key --stdin
2616
+ dataspring secret set t-acme-kanpla-api_key --stdin < key.txt
2617
+ """
2618
+ require_auth()
2619
+ token = auth.get_access_token()
2620
+ if not stdin:
2621
+ print_error(
2622
+ "The value is read from stdin only; pass --stdin",
2623
+ hint="printf '%s' \"$VALUE\" | dataspring secret set <name> --stdin. Never put a secret in an argument: it lands in shell history.",
2624
+ )
2625
+ raise typer.Exit(2)
2626
+ value = sys.stdin.read()
2627
+ if value.endswith("\n") and not value.endswith("\n\n"):
2628
+ value = value[:-1] # the one trailing newline a shell adds; a JSON key keeps its shape
2629
+ if not value.strip():
2630
+ print_error("stdin was empty; nothing was set")
2631
+ raise typer.Exit(2)
2632
+
2633
+ try:
2634
+ minted = call_tool("secret_edit", "set", {"name": name}, token=token, timeout=30.0)
2635
+ result = api_post(f"/api/secret-links/{_link_nonce(minted['link'])}", {"value": value}, token, timeout=60.0)
2636
+ except Exception as e:
2637
+ fail(e, role_action="set secrets", prefix="Failed to set the secret")
2638
+
2639
+ destroyed = result.get("destroyed") or []
2640
+ verb = "created" if result.get("created") else "rotated"
2641
+ print_success(f"Secret {result.get('secret', name)} {verb}: version {result.get('version')}")
2642
+ if destroyed:
2643
+ print_info(f"Previous version(s) destroyed: {', '.join(destroyed)}")
2644
+
2645
+
2646
+ @airbyte_app.command("credentials")
2647
+ def airbyte_credentials(
2648
+ stdout: Annotated[
2649
+ bool,
2650
+ typer.Option("--stdout", help="Fetch the key through the link and print it (for a terminal user; it is one-time)"),
2651
+ ] = False,
2652
+ ):
2653
+ """A one-time link to the tenant's Airbyte destination key (admin).
2654
+
2655
+ Examples:
2656
+ dataspring airbyte credentials # prints the link to hand to the person entering it in Airbyte
2657
+ dataspring airbyte credentials --stdout # prints the key itself, once
2658
+ """
2659
+ require_auth()
2660
+ token = auth.get_access_token()
2661
+ try:
2662
+ minted = call_tool("secret_edit", "reveal", {}, token=token, timeout=30.0)
2663
+ except Exception as e:
2664
+ fail(e, role_action="reveal issued credentials", prefix="Failed to mint a reveal link")
2665
+
2666
+ if not stdout:
2667
+ console.print(minted["link"])
2668
+ print_info(f"Shows {minted.get('secret')} once, valid until {minted.get('expires_at')}; never paste it into a chat.")
2669
+ return
2670
+ try:
2671
+ result = api_post(f"/api/secret-links/{_link_nonce(minted['link'])}/reveal", {}, token, timeout=30.0)
2672
+ except Exception as e:
2673
+ fail(e, prefix="Failed to read the key through the link")
2674
+ sys.stdout.write(result["value"])
2675
+ if not result["value"].endswith("\n"):
2676
+ sys.stdout.write("\n")
2677
+
2678
+
2679
+ SqlFormat = Literal["table", "json", "csv", "markdown"]
2680
+
2681
+
2682
+ @app.command("sql")
2683
+ def sql_cmd(
2684
+ statement: Annotated[str, typer.Argument(help="One SQL statement; '-' reads it from stdin")],
2685
+ warehouse: Annotated[
2686
+ Optional[str],
2687
+ typer.Option("--warehouse", help="Which of the org's warehouses to run against (see `warehouses list`); default: the active one"),
2688
+ ] = None,
2689
+ max_rows: Annotated[int, typer.Option("--max-rows", help="Rows to return at most (cap 10000)")] = 1000,
2690
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Estimate the bytes only; run nothing")] = False,
2691
+ format: Annotated[SqlFormat, typer.Option("--format", "-f", help="Output format")] = "table",
2692
+ ):
2693
+ """Run one read-only SQL statement as the org's reader identity (ungoverned).
2694
+
2695
+ Examples:
2696
+ dataspring sql "SELECT venue, SUM(revenue) FROM acme_marts.fct_orders GROUP BY 1"
2697
+ dataspring sql "SELECT COUNT(*) FROM acme_staging.stg_orders" --dry-run
2698
+ dataspring sql - --format csv < query.sql > rows.csv
2699
+ """
2700
+ require_auth()
2701
+ token = auth.get_access_token()
2702
+ if statement == "-":
2703
+ statement = sys.stdin.read()
2704
+
2705
+ body: dict = {"sql": statement, "max_rows": max_rows}
2706
+ if warehouse:
2707
+ body["warehouse"] = warehouse
2708
+ if dry_run:
2709
+ body["dry_run"] = True
2710
+ if format in ("csv", "markdown"):
2711
+ body["format"] = format
2712
+
2713
+ try:
2714
+ result = call_tool("run_sql", None, body, token=token, timeout=120.0)
2715
+ except Exception as e:
2716
+ if isinstance(e, (typer.Exit, typer.Abort)):
2717
+ raise
2718
+ msg, hint = format_api_error(e, role_action="run SQL")
2719
+ print_error(f"SQL failed: {msg}", hint=hint)
2720
+ raise typer.Exit(1)
2721
+
2722
+ if dry_run:
2723
+ print_success(f"Dry run: {result.get('bytes_processed') or 0:,} bytes would be processed as {result.get('identity')}")
2724
+ return
2725
+ if format in ("csv", "markdown"):
2726
+ sys.stdout.write(result.get("content") or "")
2727
+ return
2728
+ data = result.get("data", [])
2729
+ columns = result.get("columns", [])
2730
+ format_output(data, format=format, columns=columns, title=f"SQL ({len(data)} rows, ungoverned)")
2731
+ if format == "table":
2732
+ bytes_processed = result.get("bytes_processed")
2733
+ console.print(f"[dim]{result.get('note')}[/]")
2734
+ console.print(
2735
+ f"[dim]{bytes_processed:,} bytes processed[/]" if bytes_processed is not None else "",
2736
+ f"[dim]as {result.get('identity')}; job {result.get('job_id')}[/]",
2737
+ )
2738
+
2739
+
2740
+ # ============================================================================
2741
+ # datacore sub-commands (chunk 3): the local-folder door to the datacore
2742
+ # ============================================================================
2743
+ #
2744
+ # `pull` writes the deployed snapshot to a directory; `push` sends every
2745
+ # changed file back as a `datacore_edit` (kind connection, pipeline or model
2746
+ # by path, each with the previous edit's `version` as `expect_version`);
2747
+ # `check` and `deploy` are `datacore_run` actions, as are `run` and `reset`;
2748
+ # `status` reads. Reads are the `/api/datacore/*` routes, the same
2749
+ # payloads as the `dataspring://datacore/*` resources.
2750
+
2751
+ DATACORE_FILES_PATH = "/api/datacore/files"
2752
+ DATACORE_RUNS_PATH = "/api/datacore/runs"
2753
+
2754
+
2755
+ def _datacore_family(path: str) -> tuple[str, str] | None:
2756
+ """Which `datacore_edit` kind writes a workspace path, and the name it takes."""
2757
+ if path.startswith("connections/") and path.count("/") == 1 and path.endswith((".yaml", ".yml")):
2758
+ return "connection", path.split("/", 1)[1].rsplit(".", 1)[0]
2759
+ if path.startswith("pipelines/") and path.count("/") == 1 and path.endswith((".yaml", ".yml")):
2760
+ return "pipeline", path.split("/", 1)[1].rsplit(".", 1)[0]
2761
+ if path.startswith("models/"):
2762
+ return "model", path
2763
+ return None
2764
+
2765
+
2766
+ def _sha256_of(data: bytes) -> str:
2767
+ import hashlib
2768
+
2769
+ return hashlib.sha256(data).hexdigest()
2770
+
2771
+
2772
+ def _local_files(directory: Path) -> dict[str, bytes]:
2773
+ out = {}
2774
+ for path in sorted(directory.rglob("*")):
2775
+ if not path.is_file():
2776
+ continue
2777
+ rel = path.relative_to(directory).as_posix()
2778
+ if rel.startswith(".") or "/." in rel or any(part in ("target", "dbt_packages", "logs") for part in rel.split("/")):
2779
+ continue
2780
+ out[rel] = path.read_bytes()
2781
+ return out
2782
+
2783
+
2784
+ @datacore_app.command("pull")
2785
+ def datacore_pull(
2786
+ directory: Annotated[Path, typer.Argument(help="Where to write the workspace")],
2787
+ snapshot: Annotated[
2788
+ str, typer.Option("--snapshot", help="deployed (default) or draft")
2789
+ ] = "deployed",
2790
+ ):
2791
+ """Write the deployed snapshot (or the draft) to a directory, file by file.
2792
+
2793
+ Example:
2794
+ dataspring datacore pull ./noon-datacore
2795
+ """
2796
+ require_auth()
2797
+ token = auth.get_access_token()
2798
+ if snapshot not in ("deployed", "draft"):
2799
+ print_error("--snapshot is deployed or draft")
2800
+ raise typer.Exit(1)
2801
+ try:
2802
+ files = api_get(DATACORE_FILES_PATH, token)
2803
+ except Exception as e:
2804
+ fail(e, role_action="read the datacore")
2805
+ listing = files.get(snapshot)
2806
+ if not listing:
2807
+ print_error(f"the org has no {snapshot} snapshot")
2808
+ raise typer.Exit(1)
2809
+ snapshot_id = listing["id"]
2810
+ paths = [f["path"] for f in listing.get("files") or []]
2811
+ directory.mkdir(parents=True, exist_ok=True)
2812
+ written = 0
2813
+ for rel in paths:
2814
+ try:
2815
+ doc = api_get(f"/api/datacore/file/{rel}?snapshot={snapshot_id}", token)
2816
+ except Exception as e:
2817
+ fail(e, prefix=rel)
2818
+ if doc.get("binary"):
2819
+ print_warning(f"skipped binary file {rel}")
2820
+ continue
2821
+ target = directory / rel
2822
+ target.parent.mkdir(parents=True, exist_ok=True)
2823
+ target.write_text(doc.get("content") or "", encoding="utf-8")
2824
+ written += 1
2825
+ (directory / ".dataspring-snapshot").write_text(snapshot_id + "\n", encoding="utf-8")
2826
+ print_success(f"pulled snapshot {snapshot_id}: {written} files into {directory}")
2827
+
2828
+
2829
+ @datacore_app.command("push")
2830
+ def datacore_push(
2831
+ directory: Annotated[Path, typer.Argument(help="A pulled (and edited) workspace directory")],
2832
+ prune: Annotated[bool, typer.Option("--prune", help="Also delete remote files absent locally")] = False,
2833
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Show what would change, send nothing")] = False,
2834
+ ):
2835
+ """Upload every changed file as an edit into the draft (connections,
2836
+ pipelines and the dbt project each through their own edit family), then
2837
+ print the new version. Run `dataspring datacore check` next.
2838
+
2839
+ Example:
2840
+ dataspring datacore push ./noon-datacore
2841
+ """
2842
+ require_auth()
2843
+ token = auth.get_access_token()
2844
+ if not directory.is_dir():
2845
+ print_error(f"{directory} is not a directory")
2846
+ raise typer.Exit(1)
2847
+ try:
2848
+ files = api_get(DATACORE_FILES_PATH, token)
2849
+ except Exception as e:
2850
+ fail(e, role_action="read the datacore")
2851
+ remote = {f["path"]: f for f in ((files.get("draft") or files.get("deployed") or {}).get("files") or [])}
2852
+ version = files.get("version")
2853
+ local = _local_files(directory)
2854
+
2855
+ changes: list[tuple[str, str]] = []
2856
+ for rel, data in local.items():
2857
+ if _datacore_family(rel) is None:
2858
+ continue
2859
+ if rel not in remote or remote[rel].get("sha256") != _sha256_of(data):
2860
+ changes.append(("apply", rel))
2861
+ if prune:
2862
+ for rel in remote:
2863
+ if rel not in local and _datacore_family(rel) is not None:
2864
+ changes.append(("delete", rel))
2865
+ if not changes:
2866
+ print_info(f"nothing to push: the directory matches version {version}")
2867
+ return
2868
+ for verb, rel in changes:
2869
+ typer.echo(f"{verb:6} {rel}")
2870
+ if dry_run:
2871
+ return
2872
+
2873
+ for verb, rel in changes:
2874
+ kind, name = _datacore_family(rel) # type: ignore[misc]
2875
+ try:
2876
+ fields: dict = {"kind": kind, "name": name, "expect_version": version}
2877
+ if verb == "delete":
2878
+ result = call_tool("datacore_edit", "delete", fields, token=token)
2879
+ elif kind == "model":
2880
+ result = call_tool("datacore_edit", "apply", {**fields, "content": local[rel].decode("utf-8")}, token=token)
2881
+ else:
2882
+ document = yaml.safe_load(local[rel].decode("utf-8")) or {}
2883
+ result = call_tool("datacore_edit", "apply", {**fields, "document": document}, token=token)
2884
+ except Exception as e:
2885
+ fail(e, prefix=rel)
2886
+ version = result.get("version", version)
2887
+ print_success(f"pushed {len(changes)} change(s); draft version {version}")
2888
+
2889
+
2890
+ def _print_check_report(run: dict) -> None:
2891
+ report = run.get("check") or {}
2892
+ status = run.get("status")
2893
+ line = f"check {run.get('id')}: {status}"
2894
+ if report.get("snapshot"):
2895
+ line += f" (snapshot {report['snapshot']})"
2896
+ (print_success if status == "succeeded" else print_error)(line)
2897
+ for entry in report.get("pipelines") or []:
2898
+ mark = "ok " if entry.get("ok") else "ERR"
2899
+ typer.echo(f" pipeline {mark} {entry['name']}" + ("" if entry.get("ok") else f": {entry.get('error')}"))
2900
+ for entry in report.get("connections") or []:
2901
+ mark = "ok " if entry.get("ok") else "ERR"
2902
+ typer.echo(f" connection {mark} {entry['name']}" + ("" if entry.get("ok") else f": {entry.get('error')}"))
2903
+ dbt = report.get("dbt") or {}
2904
+ if dbt:
2905
+ typer.echo(
2906
+ f" dbt {dbt.get('status')}: {dbt.get('models', 0)} models, {dbt.get('tests', 0)} tests, "
2907
+ f"{dbt.get('seeds', 0)} seeds" + (f" -> {dbt.get('target')}" if dbt.get("target") else "")
2908
+ )
2909
+ for failure in dbt.get("failures") or []:
2910
+ typer.echo(f" FAIL {failure.get('node')}: {failure.get('message')}")
2911
+ for warning in dbt.get("warnings") or []:
2912
+ typer.echo(f" warn {warning.get('node')}: {warning.get('message')}")
2913
+ if report.get("dry_run"):
2914
+ typer.echo(f" dry run {report.get('dry_run_bytes', 0):,} bytes over {len(report['dry_run'])} models")
2915
+ for error in report.get("errors") or []:
2916
+ typer.echo(f" error {error}")
2917
+ if run.get("error") and not report:
2918
+ typer.echo(f" error {run['error'].get('message')}")
2919
+
2920
+
2921
+ def _poll_run(run_id: str, token: str, *, interval: float = 10.0, timeout: float = 1800.0) -> dict:
2922
+ import time
2923
+
2924
+ deadline = time.monotonic() + timeout
2925
+ last = None
2926
+ while True:
2927
+ run = api_get(f"{DATACORE_RUNS_PATH}/{run_id}", token)
2928
+ if run.get("status") != last:
2929
+ last = run.get("status")
2930
+ print_info(f"run {run_id}: {last}")
2931
+ if last in ("succeeded", "failed"):
2932
+ return run
2933
+ if time.monotonic() > deadline:
2934
+ print_error(f"run {run_id} still {last} after {int(timeout)}s")
2935
+ raise typer.Exit(1)
2936
+ time.sleep(interval)
2937
+
2938
+
2939
+ @datacore_app.command("check")
2940
+ def datacore_check_cmd(
2941
+ wait: Annotated[bool, typer.Option("--wait/--no-wait", help="Poll the check run to its end")] = True,
2942
+ ):
2943
+ """Check the draft: schema validation here, then a check run of the
2944
+ tenant's job (compile, dbt build into <org>_dev, tests, dry-run bytes).
2945
+
2946
+ Example:
2947
+ dataspring datacore check
2948
+ """
2949
+ require_auth()
2950
+ token = auth.get_access_token()
2951
+ try:
2952
+ result = call_tool("datacore_run", "check", {}, token=token)
2953
+ except Exception as e:
2954
+ fail(e, role_action="check the datacore")
2955
+ if not result.get("ok"):
2956
+ print_error(f"check failed at {result.get('stage')} for snapshot {result.get('snapshot')}")
2957
+ for error in result.get("errors") or []:
2958
+ typer.echo(f" {error}")
2959
+ raise typer.Exit(1)
2960
+ run_id = result["run_id"]
2961
+ print_info(f"check run {run_id} {result.get('status')} for snapshot {result.get('snapshot')}")
2962
+ if not wait:
2963
+ return
2964
+ try:
2965
+ run = _poll_run(run_id, token)
2966
+ except typer.Exit:
2967
+ raise
2968
+ except Exception as e:
2969
+ fail(e, role_action="read the check run")
2970
+ _print_check_report(run)
2971
+ if run.get("status") != "succeeded":
2972
+ raise typer.Exit(1)
2973
+
2974
+
2975
+ @datacore_app.command("deploy")
2976
+ def datacore_deploy_cmd(
2977
+ snapshot: Annotated[Optional[str], typer.Option("--snapshot", help="A previously deployed snapshot id to roll back to")] = None,
2978
+ note: Annotated[Optional[str], typer.Option("--note", help="Why, for the learned trail")] = None,
2979
+ ):
2980
+ """Promote the checked draft to deployed (or roll back to a snapshot).
2981
+
2982
+ Example:
2983
+ dataspring datacore deploy --note "orders mart reads the new partner tables"
2984
+ """
2985
+ require_auth()
2986
+ token = auth.get_access_token()
2987
+ fields: dict = {}
2988
+ if snapshot:
2989
+ fields["snapshot"] = snapshot
2990
+ if note:
2991
+ fields["note"] = note
2992
+ try:
2993
+ result = call_tool("datacore_run", "deploy", fields, token=token)
2994
+ except Exception as e:
2995
+ fail(e, role_action="deploy the datacore")
2996
+ print_success(f"deployed snapshot {result.get('deployed_snapshot')} (was {result.get('previous_snapshot') or 'none'})")
2997
+ for run in result.get("runs_triggered") or []:
2998
+ typer.echo(f" run {run['run_id']} started for pipeline {run['pipeline']}")
2999
+ for skipped in result.get("runs_skipped") or []:
3000
+ typer.echo(f" pipeline {skipped['pipeline']} not started: run {skipped['active_run']} is active")
3001
+
3002
+
3003
+ @datacore_app.command("run")
3004
+ def datacore_run_cmd(
3005
+ pipeline: Annotated[str, typer.Argument(help="Pipeline name")],
3006
+ start: Annotated[Optional[str], typer.Option("--start", help="Window start, YYYY-MM-DD")] = None,
3007
+ end: Annotated[Optional[str], typer.Option("--end", help="Window end, YYYY-MM-DD")] = None,
3008
+ chunk: Annotated[Optional[str], typer.Option("--chunk", help="Backfill chunk: 7d, 2w or 1M")] = None,
3009
+ cursor_from: Annotated[Optional[str], typer.Option("--from", help="Cursor value to re-pull from")] = None,
3010
+ wait: Annotated[bool, typer.Option("--wait", help="Poll the run to its end")] = False,
3011
+ ):
3012
+ """Run a pipeline now, over a window, or as a chunked backfill.
3013
+
3014
+ Examples:
3015
+ dataspring datacore run kanpla
3016
+ dataspring datacore run kanpla --start 2026-01-01 --end 2026-06-30 --chunk 1M
3017
+ """
3018
+ require_auth()
3019
+ token = auth.get_access_token()
3020
+ if bool(start) != bool(end):
3021
+ print_error("--start and --end go together")
3022
+ raise typer.Exit(1)
3023
+ fields: dict = {"pipeline": pipeline}
3024
+ if start and end:
3025
+ fields["window"] = {"start": start, "end": end}
3026
+ if chunk:
3027
+ fields["backfill_chunk"] = chunk
3028
+ if cursor_from:
3029
+ fields["cursor"] = {"from": cursor_from}
3030
+ try:
3031
+ result = call_tool("datacore_run", "run", fields, token=token)
3032
+ except Exception as e:
3033
+ fail(e, role_action="run a pipeline")
3034
+ if result.get("kind") == "backfill":
3035
+ print_success(f"backfill {result['run_id']} of {pipeline}: {result.get('total')} chunks, first child {result.get('children', [None])[0]}")
3036
+ else:
3037
+ print_success(f"run {result['run_id']} of {pipeline}: {result.get('status')}")
3038
+ if wait:
3039
+ try:
3040
+ run = _poll_run(result["run_id"], token, timeout=6 * 3600)
3041
+ except typer.Exit:
3042
+ raise
3043
+ except Exception as e:
3044
+ fail(e, role_action="read the run")
3045
+ if run.get("status") != "succeeded":
3046
+ print_error(f"run {run['id']} failed: {(run.get('error') or {}).get('message')}")
3047
+ raise typer.Exit(1)
3048
+
3049
+
3050
+ @datacore_app.command("reset")
3051
+ def datacore_reset_cmd(
3052
+ pipeline: Annotated[str, typer.Argument(help="Pipeline name")],
3053
+ approve: Annotated[bool, typer.Option("--approve", help="Required: the reset is irreversible")] = False,
3054
+ ):
3055
+ """Delete a pipeline's dlt state and drop its raw tables (irreversible).
3056
+
3057
+ Example:
3058
+ dataspring datacore reset kanpla --approve
3059
+ """
3060
+ require_auth()
3061
+ token = auth.get_access_token()
3062
+ if not approve:
3063
+ print_error(f"resetting {pipeline} deletes its dlt state and drops its raw tables; repeat with --approve")
3064
+ raise typer.Exit(1)
3065
+ try:
3066
+ result = call_tool("datacore_run", "reset", {"pipeline": pipeline, "approve": True}, token=token)
3067
+ except Exception as e:
3068
+ fail(e, role_action="reset a pipeline")
3069
+ print_success(f"reset {pipeline}: dropped {', '.join(result.get('tables') or []) or 'no tables'}")
3070
+
3071
+
3072
+ @datacore_app.command("status")
3073
+ def datacore_status(
3074
+ format: Annotated[OutputFormat, typer.Option("--format", "-f", help="Output format")] = "table",
3075
+ ):
3076
+ """The deployed and draft snapshots, the draft's check, and recent runs.
3077
+
3078
+ Example:
3079
+ dataspring datacore status
3080
+ """
3081
+ require_auth()
3082
+ token = auth.get_access_token()
3083
+ try:
3084
+ files = api_get(DATACORE_FILES_PATH, token)
3085
+ runs = api_get(DATACORE_RUNS_PATH, token).get("runs") or []
3086
+ except Exception as e:
3087
+ fail(e, role_action="read the datacore")
3088
+ if format == "json":
3089
+ format_output({"files": files, "runs": runs}, format=format)
3090
+ return
3091
+ deployed, draft = files.get("deployed"), files.get("draft")
3092
+ typer.echo(f"deployed {deployed['id'] if deployed else 'none'}" + (f" ({deployed['file_count']} files, {deployed.get('deployed_at') or deployed.get('created_at')})" if deployed else ""))
3093
+ if draft:
3094
+ checks = draft.get("checks") or {}
3095
+ typer.echo(f"draft {draft['id']} ({draft['file_count']} files, check: {checks.get('status') or 'not run'}" + (f", run {checks['run_id']}" if checks.get("run_id") else "") + ")")
3096
+ else:
3097
+ typer.echo("draft none")
3098
+ typer.echo(f"version {files.get('version')} auto_deploy: {'on' if files.get('auto_deploy') else 'off'}")
3099
+ rows = [
3100
+ {
3101
+ "id": r.get("id"),
3102
+ "pipeline": r.get("pipeline"),
3103
+ "trigger": r.get("trigger"),
3104
+ "status": r.get("status") + (f" {r.get('done')}/{r.get('total')}" if r.get("kind") == "backfill" else ""),
3105
+ "window": f"{r['window']['start']}..{r['window']['end']}" if r.get("window") else "",
3106
+ "requested": r.get("requested_at"),
3107
+ "error": ((r.get("error") or {}).get("message") or "")[:60],
3108
+ }
3109
+ for r in runs[:15]
3110
+ ]
3111
+ if rows:
3112
+ format_output(rows, format=format, columns=["id", "pipeline", "trigger", "status", "window", "requested", "error"], title="Recent runs")
3113
+
3114
+
3115
+ # ============================================================================
3116
+ # Skill command (T3.5 — plan task)
3117
+ # ============================================================================
3118
+ #
3119
+ # Hits the no-auth /api/v1/skill/* endpoints. Useful even when the user
3120
+ # isn't logged in — the skills are public conventions, not org data.
3121
+ #
3122
+ # Forms:
3123
+ # dataspring skill — list personas
3124
+ # dataspring skill <persona> — frontmatter
3125
+ # dataspring skill <persona> instructions — full body
3126
+
3127
+
3128
+ def _skill_url(path: str) -> str:
3129
+ """Compute the public skill URL from the configured base."""
3130
+ return f"{get_api_base()}{path}"
3131
+
3132
+
3133
+ def _fetch_skill_markdown(path: str) -> str:
3134
+ """GET text/markdown without auth; surface friendly errors."""
3135
+ import httpx
3136
+
3137
+ url = _skill_url(path)
3138
+ try:
3139
+ response = httpx.get(url, timeout=30.0)
3140
+ response.raise_for_status()
3141
+ return response.text
3142
+ except httpx.HTTPStatusError as e:
3143
+ # Use the server's body verbatim — the 404 includes the list
3144
+ # of available personas, which is the most helpful thing.
3145
+ body = (e.response.text or "").strip()
3146
+ if e.response.status_code == 404 and body:
3147
+ print_error(body)
3148
+ else:
3149
+ print_error(f"Server error {e.response.status_code} fetching {url}")
3150
+ raise typer.Exit(1)
3151
+ except httpx.ConnectError:
3152
+ print_error(
3153
+ f"Could not connect to {url}",
3154
+ hint="Check the API base URL or your network.",
3155
+ )
3156
+ raise typer.Exit(1)
3157
+ except httpx.RequestError as e:
3158
+ print_error(
3159
+ f"Network error fetching skills: {e}",
3160
+ hint="Check the API base URL or your network.",
3161
+ )
3162
+ raise typer.Exit(1)
3163
+
3164
+
3165
+ skill_app = typer.Typer(
3166
+ invoke_without_command=True,
3167
+ help="Fetch published DataSpring skills (no authentication required)",
3168
+ )
3169
+ app.add_typer(skill_app, name="skill")
3170
+
3171
+
3172
+ @skill_app.callback(invoke_without_command=True)
3173
+ def _skill_callback(ctx: typer.Context):
3174
+ """List all personas when invoked without a subcommand.
3175
+
3176
+ ``dataspring skill`` → index. ``dataspring skill <persona>`` and
3177
+ ``dataspring skill <persona> instructions`` are explicit subcommands
3178
+ below — keeps Typer in charge of dispatch instead of magic
3179
+ positional strings.
3180
+ """
3181
+ if ctx.invoked_subcommand is not None:
3182
+ return
3183
+ body = _fetch_skill_markdown("/api/v1/skill")
3184
+ # The server's index includes hints like
3185
+ # Fetch full body: `GET /api/v1/skill/<persona>/instructions`
3186
+ # which is helpful for curl users but irritating for CLI users
3187
+ # ("why are you telling me to curl?"). Rewrite to the CLI form so
3188
+ # the next step the user takes is `dataspring skill instructions ...`.
3189
+ import re
3190
+
3191
+ body = re.sub(
3192
+ r"Fetch full body: `GET /api/v1/skill/([^/`]+)/instructions`",
3193
+ r"Fetch full body: `dataspring skill instructions \1`",
3194
+ body,
3195
+ )
3196
+ typer.echo(body)
3197
+
3198
+
3199
+ @skill_app.command("show")
3200
+ def skill_show(
3201
+ persona: Annotated[
3202
+ str,
3203
+ typer.Argument(help="Skill persona name (e.g. author, consume)"),
3204
+ ],
3205
+ ):
3206
+ """Print a persona's frontmatter."""
3207
+ typer.echo(_fetch_skill_markdown(f"/api/v1/skill/{persona}"))
3208
+
3209
+
3210
+ @skill_app.command("instructions")
3211
+ def skill_instructions(
3212
+ persona: Annotated[
3213
+ str,
3214
+ typer.Argument(help="Skill persona name (e.g. author, consume)"),
3215
+ ],
3216
+ ):
3217
+ """Print a persona's full body (the SKILL.md content)."""
3218
+ typer.echo(_fetch_skill_markdown(f"/api/v1/skill/{persona}/instructions"))
3219
+
3220
+
3221
+ # ============================================================================
3222
+ # The generated tree: every registry operation, by its key
3223
+ # ============================================================================
3224
+ #
3225
+ # `dataspring <key> --<field>` and `dataspring <key> <verb> --<field>`, from
3226
+ # cli/generated.py. Mounted last so `--help` lists the human layer first.
3227
+
3228
+ mount_generated(app)
3229
+
3230
+
3231
+ if __name__ == "__main__":
3232
+ app()