duckhaven-cli 0.2.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. duckhaven_cli-0.4.0/CHANGELOG.md +99 -0
  2. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/PKG-INFO +31 -3
  3. duckhaven_cli-0.4.0/README.md +67 -0
  4. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/pyproject.toml +2 -2
  5. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/_version.py +2 -2
  6. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/admin.py +118 -5
  7. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/catalog.py +107 -3
  8. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/query.py +15 -1
  9. duckhaven_cli-0.4.0/src/dh/commands/saved.py +371 -0
  10. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/session.py +21 -3
  11. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/execute.py +52 -22
  12. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/main.py +1 -0
  13. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/repl.py +1 -1
  14. duckhaven_cli-0.2.0/CHANGELOG.md +0 -48
  15. duckhaven_cli-0.2.0/README.md +0 -39
  16. duckhaven_cli-0.2.0/src/dh/commands/saved.py +0 -213
  17. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/.gitignore +0 -0
  18. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/LICENSE +0 -0
  19. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/__init__.py +0 -0
  20. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/__init__.py +0 -0
  21. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/api.py +0 -0
  22. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/auth.py +0 -0
  23. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/dbt.py +0 -0
  24. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/grant.py +0 -0
  25. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/health.py +0 -0
  26. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/profile.py +0 -0
  27. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/config.py +0 -0
  28. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/context.py +0 -0
  29. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/errors.py +0 -0
  30. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/introspect.py +0 -0
  31. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/output/__init__.py +0 -0
  32. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/resolve.py +0 -0
  33. {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/rest.py +0 -0
@@ -0,0 +1,99 @@
1
+ # Changelog
2
+
3
+ All notable changes to `duckhaven-cli` are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions come from git tags
5
+ (`duckhaven-cli-vX.Y.Z`).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.4.0] - 2026-10-15
10
+
11
+ ### Changed
12
+
13
+ - `dh sql` and the REPL ask the server to hold the submit for up to two seconds, so a quick
14
+ query finishes in one request instead of a submit and a poll. `--no-wait` still returns at
15
+ once. A server that predates the hold ignores it.
16
+ - Ready for DuckHaven `api_version` 3: `DECIMAL` and wide integers, which it sends as
17
+ strings, print as before in table and CSV output and stay exact strings with
18
+ `--format json`.
19
+
20
+ ## [0.3.0] - 2026-10-13
21
+
22
+ Brings the CLI up to date with the DuckHaven server's result cache, DuckLake catalogs,
23
+ agent runtimes, worksheets and reshaped monitoring. Everything new needs a server that
24
+ offers it; against an older one the command fails with that server's own error.
25
+
26
+ ### Added
27
+
28
+ - **Result cache.** `--no-cache` on `dh sql`, `dh query run`, `dh session open` and
29
+ `dh session exec`, and `dh workspace update --result-cache/--no-result-cache` (owner
30
+ only). When a query was answered from the cache, `dh sql` and `dh session exec` say so on
31
+ stderr. The REPL is not cache-aware.
32
+ - **Catalogs.** `dh catalog create --kind` (`iceberg_polaris` or `ducklake`),
33
+ `dh catalog kinds`, `dh catalog rotate-role`, and `dh catalog export list|create|cancel`
34
+ for copying a DuckLake catalog out as Iceberg. `dh admin catalog reconcile-roles` creates
35
+ any DuckLake database role that is missing (idempotent, deployment-wide).
36
+ - **Runtimes.** `dh agent runtimes`, and `--runtime` / `--allow-beta` on
37
+ `dh admin agent elastic-create`.
38
+ - **Monitoring.** `dh admin agent monitoring` takes `--window` or `--start`/`--end`, and
39
+ `dh admin agent queries` lists the runs behind a time range, sortable by wait, duration,
40
+ peak memory, CPU time, spill or bytes read.
41
+ - **Maintenance.** `dh admin maintenance recommendations` and `dh admin maintenance apply`
42
+ (asks first; needs `maintenance:manage`).
43
+ - **Worksheets.** `dh worksheet list|get|create|update|delete`. Changing a worksheet's
44
+ title or SQL requires `--base-version`; a concurrent edit is refused with exit `5` and
45
+ the current worksheet in the error's details.
46
+ - `dh saved-query create --on-conflict error` fails with exit `5` instead of overwriting
47
+ a query with the same name, and `dh search --type` limits the kinds of object searched.
48
+
49
+ ### Changed
50
+
51
+ - With no agent named, `dh sql` and `dh session open` no longer list agents first and leave
52
+ the choice to the server, which picks a connected agent on the default runtime. Before,
53
+ an account that could see several agents sent no choice and a server without elastic
54
+ compute refused with `agent_required`.
55
+ - Status polls ask the server to hold the call until the query finishes, and a query the
56
+ submit response already reports as finished is not polled at all.
57
+ - The pinned API contract is refreshed, and now also checks the query parameters the new
58
+ commands send.
59
+
60
+ ## [0.2.0] - 2026-09-09
61
+
62
+ ### Changed
63
+
64
+ - Drop support for Python 3.10; the minimum supported version is now 3.11.
65
+
66
+ ## [0.1.1] - 2026-09-09
67
+
68
+ ### Fixed
69
+
70
+ - Declare `click` as a direct dependency. `dh` failed to start with
71
+ `ModuleNotFoundError: No module named 'click'` when installed standalone:
72
+ `context.py`/`repl.py` catch `click.Abort` directly, but `typer>=0.27` no longer
73
+ depends on `click` itself, and the workspace dev environment masked this because
74
+ other packages pulled `click` in transitively.
75
+
76
+ ## [0.1.0] - 2026-09-09
77
+
78
+ ### Added
79
+
80
+ - `dh`, the DuckHaven command-line interface.
81
+ - **Running SQL.** `dh sql` submits, polls to a terminal state and fetches every page
82
+ of results; `--no-wait` returns the id instead. Ctrl-C cancels the query on the
83
+ server rather than orphaning it, and a query that runs and fails exits `6` so a
84
+ pipeline can tell bad SQL from a broken CLI. `dh sql` with no arguments on a
85
+ terminal opens a REPL, session-backed where the server allows it.
86
+ - **The dbt path.** `dh lineage import` and `dh semantic import` publish build
87
+ artifacts, including the `manifest.json` + `catalog.json` pair that unlocks
88
+ column-level lineage, plus `validate`/`publish` to promote imported drafts.
89
+ - **Catalog and access.** `dh workspace`, `dh catalog`, `dh schema`, `dh table` and
90
+ `dh grant`, with catalog resolution and `catalog.schema.table` references.
91
+ - **History.** `dh query list` with the full server-side filter set, `dh saved-query`,
92
+ `dh schedule` and `dh search`.
93
+ - **Operators.** A thin `dh admin` over service accounts, tokens, users and agents,
94
+ and `dh api` for anything without a hand-written command.
95
+ - **Auth.** `dh auth login` signs in and mints a personal access token, storing only
96
+ the token at mode 0600 and refusing a config other users can read.
97
+ - Output as `--format json|table|csv` behind one stable envelope
98
+ (`{data, cursor, has_more}`), defaulting to a table on a terminal and JSON when
99
+ piped. Errors are JSON too when JSON was asked for.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: duckhaven-cli
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: dh — the command-line interface for DuckHaven
5
5
  Project-URL: Homepage, https://github.com/tamasmrtn/duckhaven-clients
6
6
  Project-URL: Source, https://github.com/tamasmrtn/duckhaven-clients
@@ -21,10 +21,10 @@ Classifier: Topic :: Database
21
21
  Classifier: Topic :: Utilities
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.11
24
- Requires-Dist: click>=8.0
24
+ Requires-Dist: click>=8.5.0
25
25
  Requires-Dist: duckhaven-sql-connector>=0.4.1
26
26
  Requires-Dist: httpx>=0.28
27
- Requires-Dist: typer>=0.15
27
+ Requires-Dist: typer>=0.27.3
28
28
  Description-Content-Type: text/markdown
29
29
 
30
30
  # duckhaven-cli
@@ -57,6 +57,34 @@ dh auth login --host https://duckhaven.example.com
57
57
  dh sql -q "select 1"
58
58
  ```
59
59
 
60
+ ## The result cache
61
+
62
+ DuckHaven answers a repeated read from its result cache when nothing the query reads has
63
+ changed. `dh sql` says so on stderr when it happens; pass `--no-cache` to run the query
64
+ anyway (also on `dh session open` and `dh session exec`). An owner can switch the cache off
65
+ for a whole workspace with `dh workspace update --no-result-cache`. The interactive REPL
66
+ does not take `--no-cache`: it holds its session through the connector, which has its own
67
+ `use_cache` option, and the shell does not expose it yet.
68
+
69
+ ## Newer server features
70
+
71
+ A server that supports them offers: DuckLake catalogs (`dh catalog kinds`,
72
+ `dh catalog create --kind ducklake`, `dh catalog rotate-role`, `dh catalog export`), the
73
+ DuckDB runtimes agents run (`dh agent runtimes`, `--runtime` on
74
+ `dh admin agent elastic-create`), per-agent monitoring over a window or range and the
75
+ queries behind it (`dh admin agent monitoring`, `dh admin agent queries`), applying
76
+ maintenance recommendations (`dh admin maintenance recommendations|apply`), and your
77
+ private editor tabs (`dh worksheet`). Against an older server these commands answer with the
78
+ server's own not-found or validation error.
79
+
80
+ ## Exact numbers
81
+
82
+ A DuckHaven with `api_version` 3 or later sends `DECIMAL` values and 64-bit-or-wider integers
83
+ (`BIGINT`, `HUGEINT`, `count(*)`) as strings, because JSON numbers lose precision past about
84
+ 16 digits. `dh` prints them exactly as before in table and CSV output. With `--format json`
85
+ they stay strings (`"12.50"`, `"9007199254740993"`), exact. Scripts reading `dh` JSON should
86
+ convert those columns themselves rather than expect JSON numbers.
87
+
60
88
  ## Documentation
61
89
 
62
90
  - [Command-line quickstart](https://tamasmrtn.github.io/duckhaven/getting-started/cli-quickstart/) — install, sign in,
@@ -0,0 +1,67 @@
1
+ # duckhaven-cli
2
+
3
+ `dh` — the command-line interface for [DuckHaven](https://github.com/tamasmrtn/duckhaven).
4
+
5
+ It gives analysts, CI pipelines and operators scriptable access to DuckHaven's REST API:
6
+ running SQL, browsing the catalog, publishing dbt lineage and semantic models, managing
7
+ grants, and operating service accounts, users and agents.
8
+
9
+ Like every member of this repo it is a **pure HTTP client of DuckHaven's public REST
10
+ API**, authenticating with a Personal Access Token (`dh_pat_…`). The interactive REPL
11
+ holds a SQL session through
12
+ [`duckhaven-sql-connector`](../duckhaven-sql-connector/README.md); everything else,
13
+ including one-shot `dh sql`, goes over the REST API directly, because the one-shot query
14
+ route works on deployments where SQL sessions are switched off.
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ pip install duckhaven-cli
20
+ # or, to get an isolated tool install:
21
+ uv tool install duckhaven-cli
22
+ ```
23
+
24
+ ## Getting started
25
+
26
+ ```sh
27
+ dh auth login --host https://duckhaven.example.com
28
+ dh sql -q "select 1"
29
+ ```
30
+
31
+ ## The result cache
32
+
33
+ DuckHaven answers a repeated read from its result cache when nothing the query reads has
34
+ changed. `dh sql` says so on stderr when it happens; pass `--no-cache` to run the query
35
+ anyway (also on `dh session open` and `dh session exec`). An owner can switch the cache off
36
+ for a whole workspace with `dh workspace update --no-result-cache`. The interactive REPL
37
+ does not take `--no-cache`: it holds its session through the connector, which has its own
38
+ `use_cache` option, and the shell does not expose it yet.
39
+
40
+ ## Newer server features
41
+
42
+ A server that supports them offers: DuckLake catalogs (`dh catalog kinds`,
43
+ `dh catalog create --kind ducklake`, `dh catalog rotate-role`, `dh catalog export`), the
44
+ DuckDB runtimes agents run (`dh agent runtimes`, `--runtime` on
45
+ `dh admin agent elastic-create`), per-agent monitoring over a window or range and the
46
+ queries behind it (`dh admin agent monitoring`, `dh admin agent queries`), applying
47
+ maintenance recommendations (`dh admin maintenance recommendations|apply`), and your
48
+ private editor tabs (`dh worksheet`). Against an older server these commands answer with the
49
+ server's own not-found or validation error.
50
+
51
+ ## Exact numbers
52
+
53
+ A DuckHaven with `api_version` 3 or later sends `DECIMAL` values and 64-bit-or-wider integers
54
+ (`BIGINT`, `HUGEINT`, `count(*)`) as strings, because JSON numbers lose precision past about
55
+ 16 digits. `dh` prints them exactly as before in table and CSV output. With `--format json`
56
+ they stay strings (`"12.50"`, `"9007199254740993"`), exact. Scripts reading `dh` JSON should
57
+ convert those columns themselves rather than expect JSON numbers.
58
+
59
+ ## Documentation
60
+
61
+ - [Command-line quickstart](https://tamasmrtn.github.io/duckhaven/getting-started/cli-quickstart/) — install, sign in,
62
+ run a query
63
+ - [CLI reference](https://tamasmrtn.github.io/duckhaven/reference/cli/) — every command and flag
64
+
65
+ ## Licence
66
+
67
+ Apache-2.0. See [LICENSE](LICENSE).
@@ -22,10 +22,10 @@ classifiers = [
22
22
  "Typing :: Typed",
23
23
  ]
24
24
  dependencies = [
25
- "typer>=0.15",
25
+ "typer>=0.27.3",
26
26
  # click.Abort is caught directly (Ctrl-C/Ctrl-D handling); typer>=0.27 no longer
27
27
  # pulls click in as its own dependency, so it must be declared here too.
28
- "click>=8.0",
28
+ "click>=8.5.0",
29
29
  "httpx>=0.28",
30
30
  # The SQL execution path (sessions, statements, polling, row pagination) is the
31
31
  # connector's, not a second implementation of it.
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.0'
22
- __version_tuple__ = version_tuple = (0, 2, 0)
21
+ __version__ = version = '0.4.0'
22
+ __version_tuple__ = version_tuple = (0, 4, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
  import typer
13
13
 
14
14
  from dh import context
15
+ from dh.errors import ConflictError
15
16
 
16
17
  app = typer.Typer(name="admin", help="Operator tasks: accounts, users, agents.")
17
18
 
@@ -20,9 +21,12 @@ pat_app = typer.Typer(name="pat", help="Tokens issued to a service account.")
20
21
  user_app = typer.Typer(name="user", help="People and their workspace roles.")
21
22
  agent_app = typer.Typer(name="agent", help="Compute agents.")
22
23
  storage_app = typer.Typer(name="storage", help="Storage backends.")
23
- maintenance_app = typer.Typer(name="maintenance", help="Deployment maintenance policy.")
24
+ maintenance_app = typer.Typer(
25
+ name="maintenance", help="Maintenance policy, scans and recommendations."
26
+ )
27
+ catalog_app = typer.Typer(name="catalog", help="Catalog-wide operations.")
24
28
 
25
- for sub in (sa_app, pat_app, user_app, agent_app, storage_app, maintenance_app):
29
+ for sub in (sa_app, pat_app, user_app, agent_app, storage_app, maintenance_app, catalog_app):
26
30
  app.add_typer(sub)
27
31
 
28
32
 
@@ -239,12 +243,24 @@ def create_elastic_agent(
239
243
  cpu: float = typer.Option(..., "--cpu"),
240
244
  memory_gb: float = typer.Option(..., "--memory-gb"),
241
245
  idle_timeout_minutes: int = typer.Option(None, "--idle-timeout-minutes"),
246
+ runtime: str = typer.Option(
247
+ None,
248
+ "--runtime",
249
+ help="DuckDB runtime id, from `dh agent runtimes`. Omitted: the deployment's default.",
250
+ ),
251
+ allow_beta: bool = typer.Option(
252
+ False, "--allow-beta", help="Permit a beta runtime, which is otherwise refused."
253
+ ),
242
254
  ):
243
255
  """Provision an elastic agent. Accepted asynchronously; poll `admin agent get`."""
244
256
  cli = context.of(ctx)
245
257
  body: dict[str, object] = {"cpu": cpu, "memory_gb": memory_gb}
246
258
  if idle_timeout_minutes is not None:
247
259
  body["idle_timeout_minutes"] = idle_timeout_minutes
260
+ if runtime:
261
+ body["runtime_id"] = runtime
262
+ if allow_beta:
263
+ body["allow_beta"] = True
248
264
  with cli.client() as client:
249
265
  cli.emit(client.post("admin/agents/elastic", json=body))
250
266
 
@@ -266,11 +282,60 @@ def agent_metrics(ctx: typer.Context):
266
282
 
267
283
 
268
284
  @agent_app.command("monitoring")
269
- def agent_monitoring(ctx: typer.Context, agent_id: str):
270
- """One agent's monitoring detail."""
285
+ def agent_monitoring(
286
+ ctx: typer.Context,
287
+ agent_id: str,
288
+ window: str = typer.Option(
289
+ None, "--window", help="A preset ending now: 1h, 3h, 8h (the default), 12h, 24h, 3d or 7d."
290
+ ),
291
+ start: str = typer.Option(None, "--start", help="ISO-8601 start of a custom range."),
292
+ end: str = typer.Option(None, "--end", help="ISO-8601 end of a custom range."),
293
+ ):
294
+ """One agent's monitoring detail over a window or a custom range.
295
+
296
+ Returns flat time buckets (busy, idle, concurrency, outcomes, CPU and memory)
297
+ on one shared grid, the lifecycle spans and a summary for the range.
298
+ """
271
299
  cli = context.of(ctx)
300
+ if (start is None) != (end is None):
301
+ raise ConflictError("bad_range", "Give both --start and --end, or neither.")
302
+ if window and start:
303
+ raise ConflictError("bad_range", "Give --window or --start/--end, not both.")
304
+ params = {"window": window, "start": start, "end": end}
272
305
  with cli.client() as client:
273
- cli.emit(client.get(f"admin/agents/{agent_id}/monitoring"))
306
+ cli.emit(client.get(f"admin/agents/{agent_id}/monitoring", params=params))
307
+
308
+
309
+ @agent_app.command("queries")
310
+ def agent_queries(
311
+ ctx: typer.Context,
312
+ agent_id: str,
313
+ start: str = typer.Option(..., "--start", help="ISO-8601 start of the range."),
314
+ end: str = typer.Option(..., "--end", help="ISO-8601 end of the range."),
315
+ status: list[str] = typer.Option(None, "--status", help="Repeatable."),
316
+ sort: str = typer.Option(
317
+ None,
318
+ "--sort",
319
+ help="started_at (default), duration, wait, peak_memory, cpu_time, spill or bytes_read.",
320
+ ),
321
+ direction: str = typer.Option(None, "--dir", help="asc or desc (default)."),
322
+ limit: int = typer.Option(None, "--limit"),
323
+ fetch_all: bool = typer.Option(False, "--all"),
324
+ ):
325
+ """The runs that were running or waiting on an agent during a range, with what they cost.
326
+
327
+ Needs the permission to see queries across workspaces as well as `use` on the agent.
328
+ """
329
+ cli = context.of(ctx)
330
+ params = {"start": start, "end": end, "status": status, "sort": sort, "dir": direction}
331
+ with cli.client() as client:
332
+ cli.page(
333
+ client,
334
+ f"admin/agents/{agent_id}/queries",
335
+ params=params,
336
+ limit=limit,
337
+ fetch_all=fetch_all,
338
+ )
274
339
 
275
340
 
276
341
  @agent_app.command("access")
@@ -351,3 +416,51 @@ def trigger_scan(ctx: typer.Context):
351
416
  cli = context.of(ctx)
352
417
  with cli.client() as client:
353
418
  cli.emit(client.post("admin/maintenance/scan"))
419
+
420
+
421
+ @maintenance_app.command("recommendations")
422
+ def list_recommendations(
423
+ ctx: typer.Context,
424
+ status: list[str] = typer.Option(None, "--status", help="Repeatable."),
425
+ severity: list[str] = typer.Option(None, "--severity", help="Repeatable."),
426
+ limit: int = typer.Option(None, "--limit"),
427
+ fetch_all: bool = typer.Option(False, "--all"),
428
+ ):
429
+ """What the maintenance scan recommends, with the outcome of any apply."""
430
+ cli = context.of(ctx)
431
+ params = {"status": status, "severity": severity}
432
+ with cli.client() as client:
433
+ cli.page(
434
+ client, "maintenance/recommendations", params=params, limit=limit, fetch_all=fetch_all
435
+ )
436
+
437
+
438
+ @maintenance_app.command("apply")
439
+ def apply_recommendation(ctx: typer.Context, recommendation_id: str, yes: bool = context.YES):
440
+ """Run the maintenance a recommendation asks for.
441
+
442
+ Rewrites or deletes data files, so it asks first. Needs `maintenance:manage` and
443
+ only works for catalog kinds that support applying (see `dh catalog kinds`).
444
+ Accepted asynchronously: the recommendation's `apply_status` reports the outcome,
445
+ so follow with `dh admin maintenance recommendations`.
446
+ """
447
+ cli = context.of(ctx)
448
+ cli.confirm("Apply maintenance recommendation", recommendation_id, yes=yes)
449
+ with cli.client() as client:
450
+ cli.emit(client.post(f"maintenance/recommendations/{recommendation_id}/apply"))
451
+
452
+
453
+ # --- Catalogs ----------------------------------------------------------------
454
+
455
+
456
+ @catalog_app.command("reconcile-roles")
457
+ def reconcile_ducklake_roles(ctx: typer.Context):
458
+ """Create any DuckLake database role that does not exist yet.
459
+
460
+ A post-upgrade step for operators: a migration can only write the credentials,
461
+ not the roles themselves in the catalog database. Idempotent, and it also repairs
462
+ a role that was dropped. Prints how many catalogs it saw and how many it reconciled.
463
+ """
464
+ cli = context.of(ctx)
465
+ with cli.client() as client:
466
+ cli.emit(client.post("admin/catalogs/ducklake/reconcile-roles"))
@@ -102,13 +102,21 @@ def update_workspace(
102
102
  workspace: str = typer.Argument(None),
103
103
  name: str = typer.Option(None, "--name"),
104
104
  description: str = typer.Option(None, "--description"),
105
+ result_cache: bool = typer.Option(
106
+ None,
107
+ "--result-cache/--no-result-cache",
108
+ help="Whether repeated queries may be answered from the result cache. Owner only.",
109
+ ),
105
110
  ) -> None:
106
- """Rename or re-describe a workspace."""
111
+ """Rename or re-describe a workspace, or switch its result cache on or off."""
107
112
  cli = context.of(ctx)
108
113
  settings = cli.settings()
109
- body = {k: v for k, v in {"name": name, "description": description}.items() if v is not None}
114
+ fields = {"name": name, "description": description, "result_cache_enabled": result_cache}
115
+ body = {k: v for k, v in fields.items() if v is not None}
110
116
  if not body:
111
- raise ConflictError("nothing_to_update", "Give --name or --description.")
117
+ raise ConflictError(
118
+ "nothing_to_update", "Give --name, --description or --result-cache/--no-result-cache."
119
+ )
112
120
  with cli.client(settings) as client:
113
121
  target = workspace or settings.require("workspace")
114
122
  cli.emit(client.patch(f"workspaces/{target}", json=body))
@@ -171,6 +179,19 @@ def list_agents(ctx: typer.Context) -> None:
171
179
  cli.emit(client.get("agents"))
172
180
 
173
181
 
182
+ @agent_app.command("runtimes")
183
+ def list_runtimes(ctx: typer.Context) -> None:
184
+ """The DuckDB runtimes agents can run, and which one is the default.
185
+
186
+ Each is a DuckDB line with its baked-in extensions and a lifecycle status
187
+ (`beta`, `ga`, `deprecated`, `retired`). Retired runtimes are listed so an agent
188
+ still running one can be recognised, but the server will not send them work.
189
+ """
190
+ cli = context.of(ctx)
191
+ with cli.client() as client:
192
+ cli.emit(client.get("runtimes"))
193
+
194
+
174
195
  # --- Catalogs --------------------------------------------------------------
175
196
 
176
197
 
@@ -191,6 +212,11 @@ def list_catalogs(
191
212
  def create_catalog(
192
213
  ctx: typer.Context,
193
214
  name: str = typer.Argument(..., help="Identifier-safe name."),
215
+ kind: str = typer.Option(
216
+ None,
217
+ "--kind",
218
+ help="iceberg_polaris (the server default) or ducklake. See `dh catalog kinds`.",
219
+ ),
194
220
  storage_backend: str = typer.Option(None, "--storage-backend", help="Backend id."),
195
221
  access_mode: str = typer.Option(None, "--access-mode", help="open or scoped."),
196
222
  ) -> None:
@@ -198,6 +224,8 @@ def create_catalog(
198
224
  cli = context.of(ctx)
199
225
  settings = cli.settings()
200
226
  body = {"name": name}
227
+ if kind:
228
+ body["kind"] = kind
201
229
  if storage_backend:
202
230
  body["storage_backend_id"] = storage_backend
203
231
  if access_mode:
@@ -206,6 +234,82 @@ def create_catalog(
206
234
  cli.emit(client.post(f"workspaces/{settings.require('workspace')}/catalogs", json=body))
207
235
 
208
236
 
237
+ @catalog_app.command("kinds")
238
+ def list_catalog_kinds(ctx: typer.Context) -> None:
239
+ """The catalog kinds this deployment offers, and what each can do.
240
+
241
+ A kind that is known but switched off is listed with `available: false` and the
242
+ reason, so `dh catalog create --kind` failing is explicable.
243
+ """
244
+ cli = context.of(ctx)
245
+ with cli.client() as client:
246
+ cli.emit(client.get("catalog-kinds"))
247
+
248
+
249
+ @catalog_app.command("rotate-role")
250
+ def rotate_role(ctx: typer.Context, catalog_id: str, yes: bool = context.YES) -> None:
251
+ """Rotate the database credential of a DuckLake catalog.
252
+
253
+ Queries already running keep their connection; ones that start afterwards use
254
+ the new credential.
255
+ """
256
+ cli = context.of(ctx)
257
+ cli.confirm("Rotate the database role of catalog", catalog_id, yes=yes)
258
+ with cli.client() as client:
259
+ cli.emit(client.post(f"catalogs/{catalog_id}/ducklake/rotate-role"))
260
+
261
+
262
+ export_app = typer.Typer(name="export", help="Copy a DuckLake catalog out as Iceberg.")
263
+ catalog_app.add_typer(export_app)
264
+
265
+
266
+ @export_app.command("list")
267
+ def list_exports(
268
+ ctx: typer.Context, catalog_id: str, fetch_all: bool = typer.Option(False, "--all")
269
+ ) -> None:
270
+ """A catalog's exports, newest first."""
271
+ cli = context.of(ctx)
272
+ with cli.client() as client:
273
+ cli.page(client, f"catalogs/{catalog_id}/exports", fetch_all=fetch_all)
274
+
275
+
276
+ @export_app.command("create")
277
+ def create_export(
278
+ ctx: typer.Context,
279
+ catalog_id: str,
280
+ target_name: str = typer.Option(..., "--target-name", help="Name of the new Iceberg catalog."),
281
+ storage_backend: str = typer.Option(
282
+ ..., "--storage-backend", help="Storage backend id for the new catalog."
283
+ ),
284
+ ) -> None:
285
+ """Start exporting a DuckLake catalog to a new Iceberg catalog.
286
+
287
+ The new catalog is attached to the current workspace. This only starts the
288
+ export; follow it with `dh catalog export list`.
289
+ """
290
+ cli = context.of(ctx)
291
+ settings = cli.settings()
292
+ with cli.client(settings) as client:
293
+ cli.emit(
294
+ client.post(
295
+ f"catalogs/{catalog_id}/exports",
296
+ params={"workspace": settings.require("workspace")},
297
+ json={
298
+ "target_name": target_name,
299
+ "target_storage_backend_id": storage_backend,
300
+ },
301
+ )
302
+ )
303
+
304
+
305
+ @export_app.command("cancel")
306
+ def cancel_export(ctx: typer.Context, catalog_id: str, export_id: str) -> None:
307
+ """Stop an export that has not finished."""
308
+ cli = context.of(ctx)
309
+ with cli.client() as client:
310
+ cli.emit(client.post(f"catalogs/{catalog_id}/exports/{export_id}/cancel"))
311
+
312
+
209
313
  @catalog_app.command("attach")
210
314
  def attach_catalog(
211
315
  ctx: typer.Context,
@@ -41,6 +41,7 @@ def _run(
41
41
  limit: int | None,
42
42
  fetch_all: bool,
43
43
  agent: str | None,
44
+ no_cache: bool,
44
45
  ) -> None:
45
46
  cli = context.of(ctx)
46
47
  settings = cli.settings()
@@ -62,16 +63,20 @@ def _run(
62
63
  # The same budget bounds the client's wait and the server's execution.
63
64
  # Two timeouts would be a contract people get wrong; one cannot drift.
64
65
  timeout_s=budget,
66
+ use_cache=False if no_cache else None,
67
+ hold=not no_wait,
65
68
  )
66
69
  if no_wait:
67
70
  cli.note(f"Submitted {submitted['id']}.")
68
71
  cli.emit(submitted)
69
72
  return
70
73
 
71
- finished = execute.wait(client, submitted["id"], timeout=budget)
74
+ finished = execute.wait(client, submitted["id"], timeout=budget, known=submitted)
72
75
  execute.raise_for_status(finished)
73
76
  page = execute.fetch_rows(client, finished["id"], limit=None if fetch_all else limit)
74
77
 
78
+ if finished.get("cache_status") == "hit":
79
+ cli.note("Answered from the result cache. Use --no-cache to run it again.")
75
80
  if page["truncated"]:
76
81
  cli.note(f"Showing {len(page['rows'])} of {page['total']} rows. Use --all for the rest.")
77
82
  cli.emit({"columns": page["columns"], "rows": page["rows"], "total": page["total"]})
@@ -85,6 +90,11 @@ _TIMEOUT = typer.Option("20m", "--timeout", help="Give up after this long, e.g.
85
90
  _LIMIT = typer.Option(None, "--limit", help="Stop after this many rows.")
86
91
  _ALL = typer.Option(False, "--all", help="Fetch every row, ignoring --limit.")
87
92
  _AGENT = typer.Option(None, "--agent", help="Agent name or id to run on.")
93
+ _NO_CACHE = typer.Option(
94
+ False,
95
+ "--no-cache",
96
+ help="Always run the query instead of answering it from the result cache.",
97
+ )
88
98
 
89
99
 
90
100
  def sql(
@@ -97,6 +107,7 @@ def sql(
97
107
  limit: int = _LIMIT,
98
108
  fetch_all: bool = _ALL,
99
109
  agent: str = _AGENT,
110
+ no_cache: bool = _NO_CACHE,
100
111
  ) -> None:
101
112
  """Run SQL and print the results.
102
113
 
@@ -117,6 +128,7 @@ def sql(
117
128
  limit=limit,
118
129
  fetch_all=fetch_all,
119
130
  agent=agent,
131
+ no_cache=no_cache,
120
132
  )
121
133
 
122
134
 
@@ -131,6 +143,7 @@ def run_query(
131
143
  limit: int = _LIMIT,
132
144
  fetch_all: bool = _ALL,
133
145
  agent: str = _AGENT,
146
+ no_cache: bool = _NO_CACHE,
134
147
  ) -> None:
135
148
  """Run SQL and print the results. The noun-first spelling of `dh sql`."""
136
149
  _run(
@@ -143,6 +156,7 @@ def run_query(
143
156
  limit=limit,
144
157
  fetch_all=fetch_all,
145
158
  agent=agent,
159
+ no_cache=no_cache,
146
160
  )
147
161
 
148
162