cassis-cli 1.2.0__tar.gz → 1.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cassis-cli
3
- Version: 1.2.0
3
+ Version: 1.4.0
4
4
  Summary: Cassis CLI — run Cassis actions (ontology validation, upload and publish) from your CI pipelines
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -24,7 +24,7 @@ Description-Content-Type: text/markdown
24
24
 
25
25
  Run Cassis actions from your CI pipelines:
26
26
 
27
- - `cassis ontology check` validates the ontology files in your repository with the exact same checks as the Cassis GitHub PR check (YAML parsing, round-trip, import validation) — so you can gate merges in any CI system, not just GitHub. In a checkout bound to a project (`project.yml`, `--project`, or `CASSIS_PROJECT_ID`), it also cross-checks the tree against the project's source schema: references to tables or columns the warehouse doesn't have print as **warnings** — advisory only (the object may simply not be built or synced yet), never a failed check.
27
+ - `cassis ontology check` validates the ontology files in your repository with the exact same checks as the Cassis GitHub PR check (YAML parsing, round-trip, import validation) — so you can gate merges in any CI system, not just GitHub. It then prints advisory **ontology quality warnings** for a tree that parsed — tables not assigned to any domain, joins/metrics pointing at unknown tables or columns, missing table/column descriptions (the same findings `ontology test` reports, without the agent run). In a checkout bound to a project (`project.yml`, `--project`, or `CASSIS_PROJECT_ID`), it also cross-checks the tree against the project's source schema: references to tables or columns the warehouse doesn't have print as **warnings** too — advisory only (the object may simply not be built or synced yet). Warnings never fail the check.
28
28
  - `cassis schema pull` downloads the data source's full source schema (as Cassis last introspected it) into `<base-path>/.schema.json` — a **gitignored** local snapshot (the command maintains the ignore entry) with a `pulled_at` stamp. The warehouse stays authoritative; the snapshot is a cache for offline/bulk work — e.g. a coding agent grepping table and column names during a modeling pass instead of paging through the MCP `get_source_schema` tool. Re-run to refresh.
29
29
  - `cassis ontology fmt` rewrites the ontology files in canonical form (think `black`/`gofmt` for the ontology), so hand or agent edits pass the round-trip check.
30
30
  - `cassis ontology upload` uploads the ontology files to a Cassis project (full replace) and, by default, publishes them immediately as a new version — so a merge to your main branch can go live in one CI step.
@@ -35,6 +35,10 @@ Run Cassis actions from your CI pipelines:
35
35
  - `cassis ontology test` runs individual questions through the text-to-SQL agent using your local ontology files, so you can check that a change actually works (e.g. a new column gets picked) — where `eval run` only checks for regressions on existing eval cases.
36
36
  - `cassis eval add-case` adds a gold question/SQL case to the project's eval suite — after fixing an ontology issue, add the question users were failing on so `eval run` guards it from regressing.
37
37
  - `cassis eval list-cases` and `cassis eval delete-case` maintain the suite: list the current cases with their ids, and prune one that is stale or wrong (e.g. its gold SQL encodes a definition the ontology has since changed).
38
+ - `cassis schema push` uploads a DDL file to detect source-schema changes on a DDL-only project (same as the webapp's "Update from DDL" button): Cassis diffs the DDL against the ontology and surfaces added, dropped, and changed objects in Ontology > Review > Data source for approval. Waits for completion by default; `--no-wait` returns immediately.
39
+ - `cassis projects list` lists the projects your API key can reach — id (what `--project` and `CASSIS_PROJECT_ID` take), name, published ontology version, and data-source dialect — so a pipeline or agent can discover the project id from the terminal instead of fishing it out of a webapp URL.
40
+ - `cassis status` shows the project's published version (number, label, git commit), whether unpublished changes await publication, the git-sync binding, and how your local git HEAD relates to the published commit (in sync / N commits ahead / diverged). `cassis status --watch` polls until the published commit matches your local HEAD — e.g. right after merging a PR whose CI publishes the ontology — instead of watching the GitHub Actions tab.
41
+ - `cassis verify` runs the full local gate in one verb — `ontology fmt --check`, `ontology check`, `eval run` — stopping at the first failure. One command in a checkout ("is this change safe to merge?"), one job in CI. `--no-eval` skips the eval suite.
38
42
 
39
43
  ## Install
40
44
 
@@ -56,7 +60,7 @@ The ontology tree under `<base-path>` (default `cassis/`) is:
56
60
 
57
61
  1. Create an API key in Cassis under **Organization settings → API keys** (keys start with `sk-k6-`).
58
62
  2. Store it as a CI secret and expose it as `CASSIS_API_KEY`.
59
- 3. For `pull`, `upload`, `schema pull`, `eval run`, `ontology test`, and the `eval` case commands (`add-case`, `list-cases`, `delete-case`): the project ID (UUID) is taken from `<base-path>/project.yml` in the checkout (written by `pull` and by publishing) — so once a repo is pulled you don't need to pass it. To override, or before the first pull, set `CASSIS_PROJECT_ID` or pass `--project` (find the UUID in the project's URL). `ontology check` uses the same resolution but treats it as optional: unbound checkouts get the project-less validation (no schema reference warnings).
63
+ 3. For `pull`, `upload`, `schema pull`, `eval run`, `ontology test`, and the `eval` case commands (`add-case`, `list-cases`, `delete-case`): the project ID (UUID) is taken from `<base-path>/project.yml` in the checkout (written by `pull` and by publishing) — so once a repo is pulled you don't need to pass it. To override, or before the first pull, set `CASSIS_PROJECT_ID` or pass `--project` (find the UUID with `cassis projects list`, or in the project's URL). `ontology check` uses the same resolution but treats it as optional: unbound checkouts get the project-less validation (no schema reference warnings).
60
64
 
61
65
  ## Usage
62
66
 
@@ -93,6 +97,9 @@ cassis eval run --project ...
93
97
  # Run against an existing Cassis ontology branch, or the unpublished ontology:
94
98
  cassis eval run --project ... --branch feature-x
95
99
 
100
+ # Run only specific cases (repeatable) — e.g. prove a fresh add-case in seconds:
101
+ cassis eval run --project ... --case 019f0000-0000-7000-8000-0000000000ca
102
+
96
103
  # Start the run and return immediately (poll in the webapp):
97
104
  cassis eval run --project ... --no-wait
98
105
 
@@ -104,12 +111,33 @@ cassis ontology test --project ... -q "How much was refunded last month?" -q "Ne
104
111
  cassis eval add-case --project ... -q "How much was refunded last month?" \
105
112
  --gold-sql "SELECT SUM(refunded_cents) / 100.0 FROM public.orders WHERE ..."
106
113
 
114
+ # Multi-line gold SQL: read it from a file instead (no shell quoting pitfalls):
115
+ cassis eval add-case --project ... -q "How much was refunded last month?" \
116
+ --gold-sql-file refunds.sql
117
+
107
118
  # List the suite's cases (id + question; --json adds the gold SQL), then prune one:
108
119
  cassis eval list-cases --project ...
109
120
  cassis eval delete-case 019f0000-0000-7000-8000-0000000000ca --project ...
110
121
 
111
122
  # Pull the source schema into <base-path>/.schema.json (gitignored local snapshot):
112
123
  cassis schema pull
124
+
125
+ # Push a DDL file to detect source-schema changes (DDL-only projects):
126
+ cassis schema push schema.sql
127
+
128
+ # Push and return immediately (poll in the webapp):
129
+ cassis schema push schema.sql --no-wait
130
+
131
+ # List the projects the API key can reach (id, name, published version, dialect):
132
+ cassis projects list
133
+
134
+ # Published version vs local checkout (add --watch to poll until your merge is published):
135
+ cassis status
136
+ cassis status --watch --timeout 600
137
+
138
+ # The full local gate in one verb (fmt --check, check, eval run; stops at the first failure):
139
+ cassis verify
140
+ cassis verify --no-eval
113
141
  ```
114
142
 
115
143
  Configuration (flags take precedence over env vars):
@@ -121,7 +149,8 @@ Configuration (flags take precedence over env vars):
121
149
  | `--base-path` | `CASSIS_BASE_PATH` | `cassis` — must match the project's git-sync "Path" setting |
122
150
  | `--project` (check, pull, upload, schema pull, eval run, eval add-case, eval list-cases, eval delete-case, test) | `CASSIS_PROJECT_ID` | the id in `<base-path>/project.yml` (required before the first pull; `check` alone falls back to the project-less validation when unbound) |
123
151
 
124
- `cassis eval run` also accepts `--label` (run label in the Evals page; defaults
152
+ `cassis eval run` also accepts `--case <id>` (repeatable; run only the named
153
+ cases, ids from `eval list-cases` or `add-case`), `--label` (run label in the Evals page; defaults
125
154
  to the branch name from the CI environment or the local git checkout; rejected
126
155
  with `--branch`, whose runs are labelled with the branch name), `--wait/--no-wait`, `--poll-interval` (5 s),
127
156
  `--timeout` (30 min — the run keeps going server-side if the CLI stops waiting),
@@ -2,7 +2,7 @@
2
2
 
3
3
  Run Cassis actions from your CI pipelines:
4
4
 
5
- - `cassis ontology check` validates the ontology files in your repository with the exact same checks as the Cassis GitHub PR check (YAML parsing, round-trip, import validation) — so you can gate merges in any CI system, not just GitHub. In a checkout bound to a project (`project.yml`, `--project`, or `CASSIS_PROJECT_ID`), it also cross-checks the tree against the project's source schema: references to tables or columns the warehouse doesn't have print as **warnings** — advisory only (the object may simply not be built or synced yet), never a failed check.
5
+ - `cassis ontology check` validates the ontology files in your repository with the exact same checks as the Cassis GitHub PR check (YAML parsing, round-trip, import validation) — so you can gate merges in any CI system, not just GitHub. It then prints advisory **ontology quality warnings** for a tree that parsed — tables not assigned to any domain, joins/metrics pointing at unknown tables or columns, missing table/column descriptions (the same findings `ontology test` reports, without the agent run). In a checkout bound to a project (`project.yml`, `--project`, or `CASSIS_PROJECT_ID`), it also cross-checks the tree against the project's source schema: references to tables or columns the warehouse doesn't have print as **warnings** too — advisory only (the object may simply not be built or synced yet). Warnings never fail the check.
6
6
  - `cassis schema pull` downloads the data source's full source schema (as Cassis last introspected it) into `<base-path>/.schema.json` — a **gitignored** local snapshot (the command maintains the ignore entry) with a `pulled_at` stamp. The warehouse stays authoritative; the snapshot is a cache for offline/bulk work — e.g. a coding agent grepping table and column names during a modeling pass instead of paging through the MCP `get_source_schema` tool. Re-run to refresh.
7
7
  - `cassis ontology fmt` rewrites the ontology files in canonical form (think `black`/`gofmt` for the ontology), so hand or agent edits pass the round-trip check.
8
8
  - `cassis ontology upload` uploads the ontology files to a Cassis project (full replace) and, by default, publishes them immediately as a new version — so a merge to your main branch can go live in one CI step.
@@ -13,6 +13,10 @@ Run Cassis actions from your CI pipelines:
13
13
  - `cassis ontology test` runs individual questions through the text-to-SQL agent using your local ontology files, so you can check that a change actually works (e.g. a new column gets picked) — where `eval run` only checks for regressions on existing eval cases.
14
14
  - `cassis eval add-case` adds a gold question/SQL case to the project's eval suite — after fixing an ontology issue, add the question users were failing on so `eval run` guards it from regressing.
15
15
  - `cassis eval list-cases` and `cassis eval delete-case` maintain the suite: list the current cases with their ids, and prune one that is stale or wrong (e.g. its gold SQL encodes a definition the ontology has since changed).
16
+ - `cassis schema push` uploads a DDL file to detect source-schema changes on a DDL-only project (same as the webapp's "Update from DDL" button): Cassis diffs the DDL against the ontology and surfaces added, dropped, and changed objects in Ontology > Review > Data source for approval. Waits for completion by default; `--no-wait` returns immediately.
17
+ - `cassis projects list` lists the projects your API key can reach — id (what `--project` and `CASSIS_PROJECT_ID` take), name, published ontology version, and data-source dialect — so a pipeline or agent can discover the project id from the terminal instead of fishing it out of a webapp URL.
18
+ - `cassis status` shows the project's published version (number, label, git commit), whether unpublished changes await publication, the git-sync binding, and how your local git HEAD relates to the published commit (in sync / N commits ahead / diverged). `cassis status --watch` polls until the published commit matches your local HEAD — e.g. right after merging a PR whose CI publishes the ontology — instead of watching the GitHub Actions tab.
19
+ - `cassis verify` runs the full local gate in one verb — `ontology fmt --check`, `ontology check`, `eval run` — stopping at the first failure. One command in a checkout ("is this change safe to merge?"), one job in CI. `--no-eval` skips the eval suite.
16
20
 
17
21
  ## Install
18
22
 
@@ -34,7 +38,7 @@ The ontology tree under `<base-path>` (default `cassis/`) is:
34
38
 
35
39
  1. Create an API key in Cassis under **Organization settings → API keys** (keys start with `sk-k6-`).
36
40
  2. Store it as a CI secret and expose it as `CASSIS_API_KEY`.
37
- 3. For `pull`, `upload`, `schema pull`, `eval run`, `ontology test`, and the `eval` case commands (`add-case`, `list-cases`, `delete-case`): the project ID (UUID) is taken from `<base-path>/project.yml` in the checkout (written by `pull` and by publishing) — so once a repo is pulled you don't need to pass it. To override, or before the first pull, set `CASSIS_PROJECT_ID` or pass `--project` (find the UUID in the project's URL). `ontology check` uses the same resolution but treats it as optional: unbound checkouts get the project-less validation (no schema reference warnings).
41
+ 3. For `pull`, `upload`, `schema pull`, `eval run`, `ontology test`, and the `eval` case commands (`add-case`, `list-cases`, `delete-case`): the project ID (UUID) is taken from `<base-path>/project.yml` in the checkout (written by `pull` and by publishing) — so once a repo is pulled you don't need to pass it. To override, or before the first pull, set `CASSIS_PROJECT_ID` or pass `--project` (find the UUID with `cassis projects list`, or in the project's URL). `ontology check` uses the same resolution but treats it as optional: unbound checkouts get the project-less validation (no schema reference warnings).
38
42
 
39
43
  ## Usage
40
44
 
@@ -71,6 +75,9 @@ cassis eval run --project ...
71
75
  # Run against an existing Cassis ontology branch, or the unpublished ontology:
72
76
  cassis eval run --project ... --branch feature-x
73
77
 
78
+ # Run only specific cases (repeatable) — e.g. prove a fresh add-case in seconds:
79
+ cassis eval run --project ... --case 019f0000-0000-7000-8000-0000000000ca
80
+
74
81
  # Start the run and return immediately (poll in the webapp):
75
82
  cassis eval run --project ... --no-wait
76
83
 
@@ -82,12 +89,33 @@ cassis ontology test --project ... -q "How much was refunded last month?" -q "Ne
82
89
  cassis eval add-case --project ... -q "How much was refunded last month?" \
83
90
  --gold-sql "SELECT SUM(refunded_cents) / 100.0 FROM public.orders WHERE ..."
84
91
 
92
+ # Multi-line gold SQL: read it from a file instead (no shell quoting pitfalls):
93
+ cassis eval add-case --project ... -q "How much was refunded last month?" \
94
+ --gold-sql-file refunds.sql
95
+
85
96
  # List the suite's cases (id + question; --json adds the gold SQL), then prune one:
86
97
  cassis eval list-cases --project ...
87
98
  cassis eval delete-case 019f0000-0000-7000-8000-0000000000ca --project ...
88
99
 
89
100
  # Pull the source schema into <base-path>/.schema.json (gitignored local snapshot):
90
101
  cassis schema pull
102
+
103
+ # Push a DDL file to detect source-schema changes (DDL-only projects):
104
+ cassis schema push schema.sql
105
+
106
+ # Push and return immediately (poll in the webapp):
107
+ cassis schema push schema.sql --no-wait
108
+
109
+ # List the projects the API key can reach (id, name, published version, dialect):
110
+ cassis projects list
111
+
112
+ # Published version vs local checkout (add --watch to poll until your merge is published):
113
+ cassis status
114
+ cassis status --watch --timeout 600
115
+
116
+ # The full local gate in one verb (fmt --check, check, eval run; stops at the first failure):
117
+ cassis verify
118
+ cassis verify --no-eval
91
119
  ```
92
120
 
93
121
  Configuration (flags take precedence over env vars):
@@ -99,7 +127,8 @@ Configuration (flags take precedence over env vars):
99
127
  | `--base-path` | `CASSIS_BASE_PATH` | `cassis` — must match the project's git-sync "Path" setting |
100
128
  | `--project` (check, pull, upload, schema pull, eval run, eval add-case, eval list-cases, eval delete-case, test) | `CASSIS_PROJECT_ID` | the id in `<base-path>/project.yml` (required before the first pull; `check` alone falls back to the project-less validation when unbound) |
101
129
 
102
- `cassis eval run` also accepts `--label` (run label in the Evals page; defaults
130
+ `cassis eval run` also accepts `--case <id>` (repeatable; run only the named
131
+ cases, ids from `eval list-cases` or `add-case`), `--label` (run label in the Evals page; defaults
103
132
  to the branch name from the CI environment or the local git checkout; rejected
104
133
  with `--branch`, whose runs are labelled with the branch name), `--wait/--no-wait`, `--poll-interval` (5 s),
105
134
  `--timeout` (30 min — the run keeps going server-side if the CLI stops waiting),
@@ -1,3 +1,3 @@
1
1
  """Cassis CLI — run Cassis actions from your CI pipelines."""
2
2
 
3
- __version__ = "1.2.0"
3
+ __version__ = "1.4.0"
@@ -139,9 +139,10 @@ def post_ontology_check(
139
139
  ) -> dict[str, Any]:
140
140
  """POST the ontology tree to the check endpoint and return the response body.
141
141
 
142
+ Both routes return advisory ``audit`` quality findings in ``warnings``.
142
143
  With ``project_id``, calls the project-scoped route, which additionally
143
- cross-checks the tree against the project's source schema and returns
144
- advisory ``warnings``; without it, the pure tree check.
144
+ cross-checks the tree against the project's source schema and adds
145
+ ``references`` warnings; without it, the pure tree check.
145
146
  """
146
147
  if project_id:
147
148
  url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/ontology/check"
@@ -243,6 +244,57 @@ def get_ontology_export(
243
244
  return result["files"]
244
245
 
245
246
 
247
+ def get_projects(
248
+ *,
249
+ api_url: str,
250
+ api_key: str,
251
+ transport: Optional[httpx.BaseTransport] = None,
252
+ ) -> list[dict[str, Any]]:
253
+ """GET /api/ci/projects and return the project list."""
254
+ url = api_url.rstrip("/") + "/api/ci/projects"
255
+ try:
256
+ with _client(transport=transport) as client:
257
+ response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
258
+ except httpx.HTTPError as exc:
259
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
260
+
261
+ if response.status_code == 401:
262
+ raise AuthError("The Cassis API rejected the API key (invalid or expired).")
263
+ if response.status_code >= 400:
264
+ raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
265
+ result = _parse_json_response(response, url)
266
+ if not isinstance(result, list) or not all(isinstance(p, dict) and "id" in p and "name" in p for p in result):
267
+ raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
268
+ return result
269
+
270
+
271
+ def get_project_status(
272
+ *,
273
+ api_url: str,
274
+ api_key: str,
275
+ project_id: str,
276
+ transport: Optional[httpx.BaseTransport] = None,
277
+ ) -> dict[str, Any]:
278
+ """GET /api/ci/projects/{project_id}/status and return the status record."""
279
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/status"
280
+ try:
281
+ with _client(transport=transport) as client:
282
+ response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
283
+ except httpx.HTTPError as exc:
284
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
285
+
286
+ if response.status_code == 401:
287
+ raise AuthError("The Cassis API rejected the API key (invalid or expired).")
288
+ if response.status_code in (403, 404):
289
+ raise _project_scope_error(response)
290
+ if response.status_code >= 400:
291
+ raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
292
+ result = _parse_json_response(response, url)
293
+ if not isinstance(result, dict) or "has_unpublished_changes" not in result:
294
+ raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
295
+ return result
296
+
297
+
246
298
  class NoSourceSchemaError(ApiError):
247
299
  """The project's data source has no introspected schema to pull."""
248
300
 
@@ -284,6 +336,68 @@ def get_schema_export(
284
336
  return result
285
337
 
286
338
 
339
+ class SourceChangeConflictError(ApiError):
340
+ """A concurrent detection run is already active, or the project has a connected data source."""
341
+
342
+
343
+ def post_detect_from_ddl(
344
+ *,
345
+ api_url: str,
346
+ api_key: str,
347
+ project_id: str,
348
+ ddl: str,
349
+ transport: Optional[httpx.BaseTransport] = None,
350
+ ) -> dict[str, Any]:
351
+ """POST /api/ci/projects/{project_id}/source-changes/detect-from-ddl and return the run record."""
352
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/source-changes/detect-from-ddl"
353
+ try:
354
+ with _client(transport=transport) as client:
355
+ response = client.post(url, json={"ddl": ddl}, headers={"Authorization": f"Bearer {api_key}"})
356
+ except httpx.HTTPError as exc:
357
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
358
+
359
+ if response.status_code == 401:
360
+ raise AuthError("The Cassis API rejected the API key (invalid or expired).")
361
+ if response.status_code == 409:
362
+ raise SourceChangeConflictError(str(_detail_or_text(response)))
363
+ if response.status_code in (403, 404):
364
+ raise _project_scope_error(response)
365
+ if response.status_code >= 400:
366
+ raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
367
+ result = _parse_json_response(response, url)
368
+ if not isinstance(result, dict) or "run_id" not in result:
369
+ raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
370
+ return result
371
+
372
+
373
+ def get_source_change_run(
374
+ *,
375
+ api_url: str,
376
+ api_key: str,
377
+ project_id: str,
378
+ run_id: str,
379
+ transport: Optional[httpx.BaseTransport] = None,
380
+ ) -> dict[str, Any]:
381
+ """GET /api/ci/projects/{project_id}/source-changes/runs/{run_id} and return the run record."""
382
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/source-changes/runs/{run_id}"
383
+ try:
384
+ with _client(transport=transport) as client:
385
+ response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
386
+ except httpx.HTTPError as exc:
387
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
388
+
389
+ if response.status_code == 401:
390
+ raise AuthError("The Cassis API rejected the API key (invalid or expired).")
391
+ if response.status_code in (403, 404):
392
+ raise _project_scope_error(response)
393
+ if response.status_code >= 400:
394
+ raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
395
+ result = _parse_json_response(response, url)
396
+ if not isinstance(result, dict) or "run_id" not in result:
397
+ raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
398
+ return result
399
+
400
+
287
401
  def post_eval_run_start(
288
402
  *,
289
403
  api_url: str,
@@ -292,6 +406,7 @@ def post_eval_run_start(
292
406
  files: Optional[dict[str, str]] = None,
293
407
  branch: Optional[str] = None,
294
408
  label: Optional[str] = None,
409
+ case_ids: Optional[list[str]] = None,
295
410
  transport: Optional[httpx.BaseTransport] = None,
296
411
  ) -> dict[str, Any]:
297
412
  """POST to /api/ci/projects/{project_id}/eval/runs and return the run record."""
@@ -303,6 +418,8 @@ def post_eval_run_start(
303
418
  body["branch"] = branch
304
419
  if label is not None:
305
420
  body["label"] = label
421
+ if case_ids is not None:
422
+ body["test_case_ids"] = case_ids
306
423
  try:
307
424
  with _client(transport=transport) as client:
308
425
  response = client.post(url, json=body, headers={"Authorization": f"Bearer {api_key}"})
@@ -7,7 +7,7 @@ import os
7
7
  import subprocess
8
8
  import time
9
9
  from pathlib import Path
10
- from typing import Any, Optional
10
+ from typing import Any, List, Optional
11
11
  from uuid import UUID
12
12
 
13
13
  import typer
@@ -164,11 +164,16 @@ def add_case(
164
164
  "--question",
165
165
  help="The natural-language question the case guards.",
166
166
  ),
167
- gold_sql: str = typer.Option(
168
- ...,
167
+ gold_sql: Optional[str] = typer.Option(
168
+ None,
169
169
  "--gold-sql",
170
170
  help="The correct SQL for the question; executed at run time to produce the expected output.",
171
171
  ),
172
+ gold_sql_file: Optional[Path] = typer.Option(
173
+ None,
174
+ "--gold-sql-file",
175
+ help="Read the gold SQL from this file instead of --gold-sql (no shell quoting of multi-line SQL).",
176
+ ),
172
177
  api_key: Optional[str] = typer.Option(
173
178
  None,
174
179
  "--api-key",
@@ -195,13 +200,31 @@ def add_case(
195
200
  failing on becomes a gold case, so `cassis eval run` guards it from
196
201
  regressing. On an executable data source the gold SQL is run before the
197
202
  case is stored, so a case that cannot execute never enters the suite.
203
+ The SQL comes from --gold-sql (inline) or --gold-sql-file (a file path;
204
+ prefer it for multi-line SQL, which shell quoting mangles inline).
198
205
  Exits 0 on creation, 1 on a duplicate question or gold SQL that does not
199
206
  run, 2 on usage errors, 3 on transport/API errors.
200
207
  """
201
208
  api_key = require_api_key(api_key)
202
209
  project_id = resolve_project_id(project_id, path / Path(base_path))
210
+ if (gold_sql is None) == (gold_sql_file is None):
211
+ typer.secho("Pass exactly one of --gold-sql or --gold-sql-file.", fg=typer.colors.RED, err=True)
212
+ raise typer.Exit(EXIT_USAGE)
213
+ if gold_sql_file is not None:
214
+ try:
215
+ gold_sql = gold_sql_file.read_text(encoding="utf-8")
216
+ # Two separate handlers: the repo-wide black targets py314 and would
217
+ # strip the parens off a tuple form, which is a SyntaxError on the
218
+ # CLI's supported Python (>=3.10).
219
+ except OSError as exc:
220
+ typer.secho(f"Cannot read {gold_sql_file}: {exc}", fg=typer.colors.RED, err=True)
221
+ raise typer.Exit(EXIT_USAGE) from exc
222
+ except UnicodeDecodeError as exc:
223
+ typer.secho(f"Cannot read {gold_sql_file}: {exc}", fg=typer.colors.RED, err=True)
224
+ raise typer.Exit(EXIT_USAGE) from exc
225
+ assert gold_sql is not None
203
226
  if not question.strip() or not gold_sql.strip():
204
- typer.secho("--question and --gold-sql must not be empty.", fg=typer.colors.RED, err=True)
227
+ typer.secho("--question and the gold SQL must not be empty.", fg=typer.colors.RED, err=True)
205
228
  raise typer.Exit(EXIT_USAGE)
206
229
 
207
230
  try:
@@ -388,6 +411,11 @@ def run(
388
411
  "--branch",
389
412
  help="Run against an existing Cassis ontology branch by name instead of local files.",
390
413
  ),
414
+ case: Optional[List[str]] = typer.Option(
415
+ None,
416
+ "--case",
417
+ help="Run only this eval case id (repeatable; ids from `eval list-cases` or `add-case`).",
418
+ ),
391
419
  label: Optional[str] = typer.Option(
392
420
  None,
393
421
  "--label",
@@ -412,11 +440,19 @@ def run(
412
440
 
413
441
  Uploads the local ontology file tree and scores it in-memory — nothing is pushed or
414
442
  persisted in Cassis besides the eval run itself. With --branch, runs against
415
- an existing Cassis branch instead (no files are sent). Exits 0 when the run
416
- completes with every case passed, 1 on any failed case / failed run /
417
- invalid tree, 2 on usage errors, 3 on transport errors or --timeout.
443
+ an existing Cassis branch instead (no files are sent). With --case, only the
444
+ named case(s) run e.g. proving one fresh `add-case` in seconds instead of
445
+ rerunning the whole suite. Exits 0 when the run completes with every case
446
+ passed, 1 on any failed case / failed run / invalid tree, 2 on usage
447
+ errors, 3 on transport errors or --timeout.
418
448
  """
419
449
  api_key = require_api_key(api_key)
450
+ for case_id in case or []:
451
+ try:
452
+ UUID(case_id)
453
+ except ValueError:
454
+ typer.secho(f"--case must be an eval case ID (UUID), got {case_id!r}.", fg=typer.colors.RED, err=True)
455
+ raise typer.Exit(EXIT_USAGE)
420
456
  if branch is not None and label is not None:
421
457
  typer.secho(
422
458
  "--label cannot be used with --branch: branch runs are labelled with the branch name.",
@@ -440,6 +476,7 @@ def run(
440
476
  files=files,
441
477
  branch=branch,
442
478
  label=label,
479
+ case_ids=case,
443
480
  )
444
481
  except EvalStartValidationError as exc:
445
482
  _print_validation_failure(exc.detail, base_path)
@@ -6,7 +6,10 @@ import typer
6
6
  from cassis_cli import __version__
7
7
  from cassis_cli.eval import app as eval_app
8
8
  from cassis_cli.ontology import app as ontology_app
9
+ from cassis_cli.projects import app as projects_app
9
10
  from cassis_cli.schema import app as schema_app
11
+ from cassis_cli.status import status
12
+ from cassis_cli.verify import verify
10
13
 
11
14
  app = typer.Typer(
12
15
  no_args_is_help=True,
@@ -15,6 +18,9 @@ app = typer.Typer(
15
18
  app.add_typer(ontology_app, name="ontology")
16
19
  app.add_typer(eval_app, name="eval")
17
20
  app.add_typer(schema_app, name="schema")
21
+ app.add_typer(projects_app, name="projects")
22
+ app.command()(status)
23
+ app.command()(verify)
18
24
 
19
25
 
20
26
  @app.command()
@@ -85,11 +85,14 @@ def check(
85
85
  """Validate the ontology files in a repository checkout.
86
86
 
87
87
  Runs the same checks as the Cassis GitHub PR check (YAML parsing,
88
- round-trip, import validation). When the checkout is bound to a project
88
+ round-trip, import validation), then prints advisory quality warnings
89
+ (unassigned tables, broken joins/metrics, missing descriptions) for a
90
+ tree that parsed. When the checkout is bound to a project
89
91
  (``project.yml``, ``--project``, or ``CASSIS_PROJECT_ID``), the tree is
90
92
  additionally cross-checked against the project's source schema; unmatched
91
- references print as warnings and never fail the check. Exits 0 when valid,
92
- 1 when validation fails, 2 on usage errors, 3 on transport/API errors.
93
+ references print as warnings too. Warnings never fail the check or change
94
+ the exit code. Exits 0 when valid, 1 when validation fails, 2 on usage
95
+ errors, 3 on transport/API errors.
93
96
  """
94
97
  api_key = _require_api_key(api_key)
95
98
  files, base_path = _collect_tree(path, base_path)
@@ -114,13 +117,17 @@ def check(
114
117
  raise typer.Exit(EXIT_TRANSPORT) from exc
115
118
 
116
119
  warnings = result.get("warnings") or []
120
+ ref_warnings = [w for w in warnings if w.get("stage") == "references"]
121
+ # Everything else is the quality audit; bucketing by "not references" keeps
122
+ # a warning stage this CLI predates visible instead of silently dropped.
123
+ audit_warnings = [w for w in warnings if w.get("stage") != "references"]
117
124
  if json_output:
118
125
  typer.echo(json.dumps(result, indent=2))
119
126
  elif result["passed"]:
120
127
  typer.secho(f"✓ {result['summary']}", fg=typer.colors.GREEN)
121
128
  # Disambiguate silence: "no warnings" must never read as "references
122
129
  # verified" when the stage didn't run.
123
- if project_id and result.get("references_checked") and not warnings:
130
+ if project_id and result.get("references_checked") and not ref_warnings:
124
131
  typer.secho("✓ Schema references resolve against the source schema.", fg=typer.colors.GREEN)
125
132
  elif project_id and not result.get("references_checked"):
126
133
  typer.secho(
@@ -135,14 +142,23 @@ def check(
135
142
  location = f"{base_path}/{finding.get('path')}: " if finding.get("path") else ""
136
143
  typer.echo(f" {location}{finding.get('message', '')} ({finding.get('stage', '?')})")
137
144
 
138
- if warnings and not json_output:
145
+ if ref_warnings and not json_output:
139
146
  typer.secho(
140
- f"{len(warnings)} schema reference warning(s) — advisory, expected if the objects "
147
+ f"{len(ref_warnings)} schema reference warning(s) — advisory, expected if the objects "
141
148
  "haven't been built or synced yet:",
142
149
  fg=typer.colors.YELLOW,
143
150
  bold=True,
144
151
  )
145
- for warning in warnings:
152
+ for warning in ref_warnings:
153
+ typer.secho(f" {warning.get('message', '')}", fg=typer.colors.YELLOW)
154
+ if audit_warnings and not json_output:
155
+ # Same wording as `ontology test` so the two commands read alike.
156
+ typer.secho(
157
+ f"{len(audit_warnings)} ontology quality warning(s) — advisory, never fail the check:",
158
+ fg=typer.colors.YELLOW,
159
+ bold=True,
160
+ )
161
+ for warning in audit_warnings:
146
162
  typer.secho(f" {warning.get('message', '')}", fg=typer.colors.YELLOW)
147
163
 
148
164
  raise typer.Exit(EXIT_OK if result["passed"] else EXIT_VALIDATION_FAILED)
@@ -0,0 +1,69 @@
1
+ """`cassis projects` — discover the projects an API key can reach.
2
+
3
+ Every other CI route is project-scoped, so the very first thing a pipeline or
4
+ checkout agent needs is a project id. `projects list` answers that from the
5
+ terminal instead of fishing the UUID out of a webapp URL.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Optional
12
+
13
+ import typer
14
+ from cassis_cli.api import DEFAULT_API_URL, ApiError, AuthError, get_projects
15
+ from cassis_cli.common import EXIT_OK, EXIT_TRANSPORT, require_api_key
16
+
17
+ app = typer.Typer(help="Discover the projects this API key can reach.")
18
+
19
+
20
+ @app.command(name="list")
21
+ def list_projects(
22
+ api_key: Optional[str] = typer.Option(
23
+ None,
24
+ "--api-key",
25
+ envvar="CASSIS_API_KEY",
26
+ help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
27
+ ),
28
+ api_url: str = typer.Option(
29
+ DEFAULT_API_URL,
30
+ "--api-url",
31
+ envvar="CASSIS_API_URL",
32
+ help="Cassis API base URL.",
33
+ ),
34
+ json_output: bool = typer.Option(False, "--json", help="Print the raw JSON response."),
35
+ ) -> None:
36
+ """List the projects available to the API key.
37
+
38
+ Prints each project's id (what --project and CASSIS_PROJECT_ID take), name,
39
+ published ontology version, and data-source dialect. A schema-only source
40
+ (no connection) is marked "not executable": SQL is generated but never run.
41
+ Exits 0 on success, 2 on usage errors, 3 on transport/API errors.
42
+ """
43
+ api_key = require_api_key(api_key)
44
+
45
+ try:
46
+ projects = get_projects(api_url=api_url, api_key=api_key)
47
+ except (AuthError, ApiError) as exc:
48
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
49
+ raise typer.Exit(EXIT_TRANSPORT) from exc
50
+
51
+ if json_output:
52
+ typer.echo(json.dumps(projects, indent=2))
53
+ raise typer.Exit(EXIT_OK)
54
+
55
+ if not projects:
56
+ typer.echo("No projects visible to this API key.")
57
+ raise typer.Exit(EXIT_OK)
58
+
59
+ for project in projects:
60
+ version = project.get("published_version")
61
+ version_text = f"v{version}" if version is not None else "unpublished"
62
+ source = project.get("data_source")
63
+ if source:
64
+ dialect = source.get("sql_dialect") or "?"
65
+ source_text = dialect if source.get("is_executable") else f"{dialect}, not executable"
66
+ else:
67
+ source_text = "no data source"
68
+ typer.echo(f"{project['id']} {project['name']} ({version_text}; {source_text})")
69
+ raise typer.Exit(EXIT_OK)