cassis-cli 1.1.1__tar.gz → 1.3.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.1.1
3
+ Version: 1.3.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,8 @@ 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.
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.
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.
28
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.
29
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.
30
31
  - `cassis ontology pull` downloads the project's unpublished ontology into your repository checkout (full sync — stale local ontology files are pruned), so you can start editing from the current state, or bootstrap a repo that isn't git-synced (e.g. Bitbucket).
@@ -33,6 +34,11 @@ Run Cassis actions from your CI pipelines:
33
34
  - `cassis eval run` runs the project's eval suite against your local ontology files (scored in-memory — nothing is pushed to Cassis) and prints per-question results, so you can test the changes on your git branch before merging.
34
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.
35
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
+ - `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.
36
42
 
37
43
  ## Install
38
44
 
@@ -54,7 +60,7 @@ The ontology tree under `<base-path>` (default `cassis/`) is:
54
60
 
55
61
  1. Create an API key in Cassis under **Organization settings → API keys** (keys start with `sk-k6-`).
56
62
  2. Store it as a CI secret and expose it as `CASSIS_API_KEY`.
57
- 3. For `pull`, `upload`, `eval run`, `ontology test`, and `eval add-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).
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).
58
64
 
59
65
  ## Usage
60
66
 
@@ -91,6 +97,9 @@ cassis eval run --project ...
91
97
  # Run against an existing Cassis ontology branch, or the unpublished ontology:
92
98
  cassis eval run --project ... --branch feature-x
93
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
+
94
103
  # Start the run and return immediately (poll in the webapp):
95
104
  cassis eval run --project ... --no-wait
96
105
 
@@ -101,6 +110,34 @@ cassis ontology test --project ... -q "How much was refunded last month?" -q "Ne
101
110
  # Add a gold case to the eval suite (rejected if the exact question already exists):
102
111
  cassis eval add-case --project ... -q "How much was refunded last month?" \
103
112
  --gold-sql "SELECT SUM(refunded_cents) / 100.0 FROM public.orders WHERE ..."
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
+
118
+ # List the suite's cases (id + question; --json adds the gold SQL), then prune one:
119
+ cassis eval list-cases --project ...
120
+ cassis eval delete-case 019f0000-0000-7000-8000-0000000000ca --project ...
121
+
122
+ # Pull the source schema into <base-path>/.schema.json (gitignored local snapshot):
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
104
141
  ```
105
142
 
106
143
  Configuration (flags take precedence over env vars):
@@ -110,9 +147,10 @@ Configuration (flags take precedence over env vars):
110
147
  | `--api-key` | `CASSIS_API_KEY` | — (required) |
111
148
  | `--api-url` | `CASSIS_API_URL` | `https://app.getcassis.com` |
112
149
  | `--base-path` | `CASSIS_BASE_PATH` | `cassis` — must match the project's git-sync "Path" setting |
113
- | `--project` (pull, upload, eval run, eval add-case, test) | `CASSIS_PROJECT_ID` | — (required) |
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) |
114
151
 
115
- `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
116
154
  to the branch name from the CI environment or the local git checkout; rejected
117
155
  with `--branch`, whose runs are labelled with the branch name), `--wait/--no-wait`, `--poll-interval` (5 s),
118
156
  `--timeout` (30 min — the run keeps going server-side if the CLI stops waiting),
@@ -139,7 +177,7 @@ cassis ontology fmt --check
139
177
  | Code | Meaning |
140
178
  | ---- | ------------------------------------------------------------------------------ |
141
179
  | 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) / every probe completed (test — whatever its outcome; probes are informational, don't gate CI on them) |
142
- | 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed) |
180
+ | 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed; add-case: duplicate question or gold SQL that does not run; delete-case: no such case in the project) |
143
181
  | 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
144
182
  | 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, out of credits, or `--timeout` reached |
145
183
 
@@ -2,7 +2,8 @@
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.
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.
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.
6
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.
7
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.
8
9
  - `cassis ontology pull` downloads the project's unpublished ontology into your repository checkout (full sync — stale local ontology files are pruned), so you can start editing from the current state, or bootstrap a repo that isn't git-synced (e.g. Bitbucket).
@@ -11,6 +12,11 @@ Run Cassis actions from your CI pipelines:
11
12
  - `cassis eval run` runs the project's eval suite against your local ontology files (scored in-memory — nothing is pushed to Cassis) and prints per-question results, so you can test the changes on your git branch before merging.
12
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.
13
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
+ - `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.
14
20
 
15
21
  ## Install
16
22
 
@@ -32,7 +38,7 @@ The ontology tree under `<base-path>` (default `cassis/`) is:
32
38
 
33
39
  1. Create an API key in Cassis under **Organization settings → API keys** (keys start with `sk-k6-`).
34
40
  2. Store it as a CI secret and expose it as `CASSIS_API_KEY`.
35
- 3. For `pull`, `upload`, `eval run`, `ontology test`, and `eval add-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).
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).
36
42
 
37
43
  ## Usage
38
44
 
@@ -69,6 +75,9 @@ cassis eval run --project ...
69
75
  # Run against an existing Cassis ontology branch, or the unpublished ontology:
70
76
  cassis eval run --project ... --branch feature-x
71
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
+
72
81
  # Start the run and return immediately (poll in the webapp):
73
82
  cassis eval run --project ... --no-wait
74
83
 
@@ -79,6 +88,34 @@ cassis ontology test --project ... -q "How much was refunded last month?" -q "Ne
79
88
  # Add a gold case to the eval suite (rejected if the exact question already exists):
80
89
  cassis eval add-case --project ... -q "How much was refunded last month?" \
81
90
  --gold-sql "SELECT SUM(refunded_cents) / 100.0 FROM public.orders WHERE ..."
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
+
96
+ # List the suite's cases (id + question; --json adds the gold SQL), then prune one:
97
+ cassis eval list-cases --project ...
98
+ cassis eval delete-case 019f0000-0000-7000-8000-0000000000ca --project ...
99
+
100
+ # Pull the source schema into <base-path>/.schema.json (gitignored local snapshot):
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
82
119
  ```
83
120
 
84
121
  Configuration (flags take precedence over env vars):
@@ -88,9 +125,10 @@ Configuration (flags take precedence over env vars):
88
125
  | `--api-key` | `CASSIS_API_KEY` | — (required) |
89
126
  | `--api-url` | `CASSIS_API_URL` | `https://app.getcassis.com` |
90
127
  | `--base-path` | `CASSIS_BASE_PATH` | `cassis` — must match the project's git-sync "Path" setting |
91
- | `--project` (pull, upload, eval run, eval add-case, test) | `CASSIS_PROJECT_ID` | — (required) |
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) |
92
129
 
93
- `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
94
132
  to the branch name from the CI environment or the local git checkout; rejected
95
133
  with `--branch`, whose runs are labelled with the branch name), `--wait/--no-wait`, `--poll-interval` (5 s),
96
134
  `--timeout` (30 min — the run keeps going server-side if the CLI stops waiting),
@@ -117,7 +155,7 @@ cassis ontology fmt --check
117
155
  | Code | Meaning |
118
156
  | ---- | ------------------------------------------------------------------------------ |
119
157
  | 0 | Ontology is valid (check) / pulled (pull) / uploaded (upload) / eval run completed all-passed (eval run) / every probe completed (test — whatever its outcome; probes are informational, don't gate CI on them) |
120
- | 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed) |
158
+ | 1 | Validation failed (check: findings printed; upload: nothing imported; eval run: invalid tree, failed cases, or failed/cancelled run; test: invalid tree or a probe failed; add-case: duplicate question or gold SQL that does not run; delete-case: no such case in the project) |
121
159
  | 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
122
160
  | 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, out of credits, or `--timeout` reached |
123
161
 
@@ -1,3 +1,3 @@
1
1
  """Cassis CLI — run Cassis actions from your CI pipelines."""
2
2
 
3
- __version__ = "1.1.1"
3
+ __version__ = "1.2.0"
@@ -134,10 +134,19 @@ def post_ontology_check(
134
134
  api_url: str,
135
135
  api_key: str,
136
136
  files: dict[str, str],
137
+ project_id: Optional[str] = None,
137
138
  transport: Optional[httpx.BaseTransport] = None,
138
139
  ) -> dict[str, Any]:
139
- """POST the ontology tree to /api/ci/ontology-check and return the response body."""
140
- url = api_url.rstrip("/") + "/api/ci/ontology-check"
140
+ """POST the ontology tree to the check endpoint and return the response body.
141
+
142
+ 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.
145
+ """
146
+ if project_id:
147
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/ontology/check"
148
+ else:
149
+ url = api_url.rstrip("/") + "/api/ci/ontology-check"
141
150
  try:
142
151
  with _client(transport=transport) as client:
143
152
  response = client.post(
@@ -150,6 +159,8 @@ def post_ontology_check(
150
159
 
151
160
  if response.status_code == 401:
152
161
  raise AuthError("The Cassis API rejected the API key (invalid or expired).")
162
+ if project_id and response.status_code in (403, 404):
163
+ raise _project_scope_error(response)
153
164
  if response.status_code >= 400:
154
165
  raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
155
166
  result = _parse_json_response(response, url)
@@ -232,6 +243,160 @@ def get_ontology_export(
232
243
  return result["files"]
233
244
 
234
245
 
246
+ def get_projects(
247
+ *,
248
+ api_url: str,
249
+ api_key: str,
250
+ transport: Optional[httpx.BaseTransport] = None,
251
+ ) -> list[dict[str, Any]]:
252
+ """GET /api/ci/projects and return the project list."""
253
+ url = api_url.rstrip("/") + "/api/ci/projects"
254
+ try:
255
+ with _client(transport=transport) as client:
256
+ response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
257
+ except httpx.HTTPError as exc:
258
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
259
+
260
+ if response.status_code == 401:
261
+ raise AuthError("The Cassis API rejected the API key (invalid or expired).")
262
+ if response.status_code >= 400:
263
+ raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
264
+ result = _parse_json_response(response, url)
265
+ if not isinstance(result, list) or not all(isinstance(p, dict) and "id" in p and "name" in p for p in result):
266
+ raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
267
+ return result
268
+
269
+
270
+ def get_project_status(
271
+ *,
272
+ api_url: str,
273
+ api_key: str,
274
+ project_id: str,
275
+ transport: Optional[httpx.BaseTransport] = None,
276
+ ) -> dict[str, Any]:
277
+ """GET /api/ci/projects/{project_id}/status and return the status record."""
278
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/status"
279
+ try:
280
+ with _client(transport=transport) as client:
281
+ response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
282
+ except httpx.HTTPError as exc:
283
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
284
+
285
+ if response.status_code == 401:
286
+ raise AuthError("The Cassis API rejected the API key (invalid or expired).")
287
+ if response.status_code in (403, 404):
288
+ raise _project_scope_error(response)
289
+ if response.status_code >= 400:
290
+ raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
291
+ result = _parse_json_response(response, url)
292
+ if not isinstance(result, dict) or "has_unpublished_changes" not in result:
293
+ raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
294
+ return result
295
+
296
+
297
+ class NoSourceSchemaError(ApiError):
298
+ """The project's data source has no introspected schema to pull."""
299
+
300
+
301
+ def get_schema_export(
302
+ *,
303
+ api_url: str,
304
+ api_key: str,
305
+ project_id: str,
306
+ transport: Optional[httpx.BaseTransport] = None,
307
+ ) -> dict[str, Any]:
308
+ """GET /api/ci/projects/{project_id}/schema and return the response body."""
309
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/schema"
310
+ try:
311
+ with _client(transport=transport) as client:
312
+ response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
313
+ except httpx.HTTPError as exc:
314
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
315
+
316
+ if response.status_code == 401:
317
+ raise AuthError("The Cassis API rejected the API key (invalid or expired).")
318
+ # "No source schema" is the marker the server's export endpoint puts in its
319
+ # 404 detail (backend endpoints/ci.py::export_source_schema — reworded only
320
+ # with a paired CLI release). Without this routing, a schema-less project
321
+ # would surface as the misleading "check --project / key access" hint below.
322
+ if response.status_code == 404 and "No source schema" in response.text:
323
+ raise NoSourceSchemaError(str(_detail_or_text(response)))
324
+ if response.status_code in (400, 403, 404):
325
+ raise _project_scope_error(response)
326
+ if response.status_code >= 400:
327
+ raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
328
+ result = _parse_json_response(response, url)
329
+ if (
330
+ not isinstance(result, dict)
331
+ or not isinstance(result.get("tables"), list)
332
+ or not isinstance(result.get("schema_version"), dict)
333
+ ):
334
+ raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
335
+ return result
336
+
337
+
338
+ class SourceChangeConflictError(ApiError):
339
+ """A concurrent detection run is already active, or the project has a connected data source."""
340
+
341
+
342
+ def post_detect_from_ddl(
343
+ *,
344
+ api_url: str,
345
+ api_key: str,
346
+ project_id: str,
347
+ ddl: str,
348
+ transport: Optional[httpx.BaseTransport] = None,
349
+ ) -> dict[str, Any]:
350
+ """POST /api/ci/projects/{project_id}/source-changes/detect-from-ddl and return the run record."""
351
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/source-changes/detect-from-ddl"
352
+ try:
353
+ with _client(transport=transport) as client:
354
+ response = client.post(url, json={"ddl": ddl}, headers={"Authorization": f"Bearer {api_key}"})
355
+ except httpx.HTTPError as exc:
356
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
357
+
358
+ if response.status_code == 401:
359
+ raise AuthError("The Cassis API rejected the API key (invalid or expired).")
360
+ if response.status_code == 409:
361
+ raise SourceChangeConflictError(str(_detail_or_text(response)))
362
+ if response.status_code in (403, 404):
363
+ raise _project_scope_error(response)
364
+ if response.status_code >= 400:
365
+ raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
366
+ result = _parse_json_response(response, url)
367
+ if not isinstance(result, dict) or "run_id" not in result:
368
+ raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
369
+ return result
370
+
371
+
372
+ def get_source_change_run(
373
+ *,
374
+ api_url: str,
375
+ api_key: str,
376
+ project_id: str,
377
+ run_id: str,
378
+ transport: Optional[httpx.BaseTransport] = None,
379
+ ) -> dict[str, Any]:
380
+ """GET /api/ci/projects/{project_id}/source-changes/runs/{run_id} and return the run record."""
381
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/source-changes/runs/{run_id}"
382
+ try:
383
+ with _client(transport=transport) as client:
384
+ response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
385
+ except httpx.HTTPError as exc:
386
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
387
+
388
+ if response.status_code == 401:
389
+ raise AuthError("The Cassis API rejected the API key (invalid or expired).")
390
+ if response.status_code in (403, 404):
391
+ raise _project_scope_error(response)
392
+ if response.status_code >= 400:
393
+ raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
394
+ result = _parse_json_response(response, url)
395
+ if not isinstance(result, dict) or "run_id" not in result:
396
+ raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
397
+ return result
398
+
399
+
235
400
  def post_eval_run_start(
236
401
  *,
237
402
  api_url: str,
@@ -240,6 +405,7 @@ def post_eval_run_start(
240
405
  files: Optional[dict[str, str]] = None,
241
406
  branch: Optional[str] = None,
242
407
  label: Optional[str] = None,
408
+ case_ids: Optional[list[str]] = None,
243
409
  transport: Optional[httpx.BaseTransport] = None,
244
410
  ) -> dict[str, Any]:
245
411
  """POST to /api/ci/projects/{project_id}/eval/runs and return the run record."""
@@ -251,6 +417,8 @@ def post_eval_run_start(
251
417
  body["branch"] = branch
252
418
  if label is not None:
253
419
  body["label"] = label
420
+ if case_ids is not None:
421
+ body["test_case_ids"] = case_ids
254
422
  try:
255
423
  with _client(transport=transport) as client:
256
424
  response = client.post(url, json=body, headers={"Authorization": f"Bearer {api_key}"})
@@ -321,6 +489,54 @@ def post_eval_case_create(
321
489
  return result
322
490
 
323
491
 
492
+ class EvalCaseNotFoundError(ApiError):
493
+ """The project has no current eval case with this id."""
494
+
495
+
496
+ def get_eval_cases(
497
+ *,
498
+ api_url: str,
499
+ api_key: str,
500
+ project_id: str,
501
+ transport: Optional[httpx.BaseTransport] = None,
502
+ ) -> list[dict[str, Any]]:
503
+ """GET /api/ci/projects/{project_id}/eval/cases and return the case list."""
504
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/eval/cases"
505
+ result = _get_eval_json(url, api_key, transport)
506
+ if not isinstance(result, list):
507
+ raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
508
+ return result
509
+
510
+
511
+ def delete_eval_case(
512
+ *,
513
+ api_url: str,
514
+ api_key: str,
515
+ project_id: str,
516
+ case_id: str,
517
+ transport: Optional[httpx.BaseTransport] = None,
518
+ ) -> None:
519
+ """DELETE /api/ci/projects/{project_id}/eval/cases/{case_id}."""
520
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/eval/cases/{case_id}"
521
+ try:
522
+ with _client(transport=transport) as client:
523
+ response = client.delete(url, headers={"Authorization": f"Bearer {api_key}"})
524
+ except httpx.HTTPError as exc:
525
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
526
+
527
+ if response.status_code == 401:
528
+ raise AuthError("The Cassis API rejected the API key (invalid or expired).")
529
+ # Exact-match wire contract with the DELETE endpoint's 404 detail (see
530
+ # `delete_eval_case` in backend/app/endpoints/ci.py): it distinguishes a
531
+ # missing case (exit 1) from a project-scope 404 (exit 3).
532
+ if response.status_code == 404 and _detail_or_text(response) == "Eval case not found":
533
+ raise EvalCaseNotFoundError(f"No current eval case {case_id} in this project (already deleted, or wrong id?).")
534
+ if response.status_code in (403, 404):
535
+ raise _project_scope_error(response)
536
+ if response.status_code >= 400:
537
+ raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
538
+
539
+
324
540
  def _get_eval_json(url: str, api_key: str, transport: Optional[httpx.BaseTransport]) -> Any:
325
541
  """GET an eval-run URL with the shared error mapping."""
326
542
  try:
@@ -93,20 +93,27 @@ def read_project_id_from_dir(ontology_dir: Path) -> Optional[str]:
93
93
  return None
94
94
 
95
95
 
96
- def resolve_project_id(project_id: Optional[str], ontology_dir: Path) -> str:
96
+ def resolve_project_id(
97
+ project_id: Optional[str], ontology_dir: Path, *, optional: bool = False, quiet: bool = False
98
+ ) -> Optional[str]:
97
99
  """Resolve the target project id, defaulting to the checkout's ``project.yml``.
98
100
 
99
101
  Precedence: an explicit ``--project`` / ``CASSIS_PROJECT_ID`` wins; otherwise
100
102
  the ``project_id`` recorded in ``<base-path>/project.yml`` (written by
101
103
  ``pull`` / publish) is used, and where it came from is noted on stderr so a
102
- stale value in a copied repo is visible. Exits 2 (usage) when neither is
103
- available or the value isn't a UUID.
104
+ stale value in a copied repo is visible (``quiet`` suppresses the note for
105
+ machine-readable output). Exits 2 (usage) when the value isn't a UUID, or —
106
+ unless ``optional`` — when no value is available at all; with ``optional``,
107
+ an unbound checkout returns None (``check`` falls back to the project-less
108
+ validation).
104
109
  """
105
110
  from_file = False
106
111
  if not project_id:
107
112
  project_id = read_project_id_from_dir(ontology_dir)
108
113
  from_file = project_id is not None
109
114
  if not project_id:
115
+ if optional:
116
+ return None
110
117
  typer.secho(
111
118
  f"No project. Pass --project (or set CASSIS_PROJECT_ID), or run in a checkout whose "
112
119
  f"{ontology_dir.name}/project.yml records it (written by `cassis ontology pull` or a publish).",
@@ -119,7 +126,7 @@ def resolve_project_id(project_id: Optional[str], ontology_dir: Path) -> str:
119
126
  except ValueError:
120
127
  typer.secho(f"--project must be a project ID (UUID), got {project_id!r}.", fg=typer.colors.RED, err=True)
121
128
  raise typer.Exit(EXIT_USAGE)
122
- if from_file:
129
+ if from_file and not quiet:
123
130
  typer.secho(f"Using project {project_id} from {ontology_dir.name}/project.yml.", fg=typer.colors.CYAN, err=True)
124
131
  return project_id
125
132