cassis-cli 1.1.1__tar.gz → 1.2.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.2.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,7 @@ 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).
36
38
 
37
39
  ## Install
38
40
 
@@ -54,7 +56,7 @@ The ontology tree under `<base-path>` (default `cassis/`) is:
54
56
 
55
57
  1. Create an API key in Cassis under **Organization settings → API keys** (keys start with `sk-k6-`).
56
58
  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).
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).
58
60
 
59
61
  ## Usage
60
62
 
@@ -101,6 +103,13 @@ cassis ontology test --project ... -q "How much was refunded last month?" -q "Ne
101
103
  # Add a gold case to the eval suite (rejected if the exact question already exists):
102
104
  cassis eval add-case --project ... -q "How much was refunded last month?" \
103
105
  --gold-sql "SELECT SUM(refunded_cents) / 100.0 FROM public.orders WHERE ..."
106
+
107
+ # List the suite's cases (id + question; --json adds the gold SQL), then prune one:
108
+ cassis eval list-cases --project ...
109
+ cassis eval delete-case 019f0000-0000-7000-8000-0000000000ca --project ...
110
+
111
+ # Pull the source schema into <base-path>/.schema.json (gitignored local snapshot):
112
+ cassis schema pull
104
113
  ```
105
114
 
106
115
  Configuration (flags take precedence over env vars):
@@ -110,7 +119,7 @@ Configuration (flags take precedence over env vars):
110
119
  | `--api-key` | `CASSIS_API_KEY` | — (required) |
111
120
  | `--api-url` | `CASSIS_API_URL` | `https://app.getcassis.com` |
112
121
  | `--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) |
122
+ | `--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
123
 
115
124
  `cassis eval run` also accepts `--label` (run label in the Evals page; defaults
116
125
  to the branch name from the CI environment or the local git checkout; rejected
@@ -139,7 +148,7 @@ cassis ontology fmt --check
139
148
  | Code | Meaning |
140
149
  | ---- | ------------------------------------------------------------------------------ |
141
150
  | 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) |
151
+ | 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
152
  | 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
144
153
  | 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, out of credits, or `--timeout` reached |
145
154
 
@@ -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,7 @@ 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).
14
16
 
15
17
  ## Install
16
18
 
@@ -32,7 +34,7 @@ The ontology tree under `<base-path>` (default `cassis/`) is:
32
34
 
33
35
  1. Create an API key in Cassis under **Organization settings → API keys** (keys start with `sk-k6-`).
34
36
  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).
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).
36
38
 
37
39
  ## Usage
38
40
 
@@ -79,6 +81,13 @@ cassis ontology test --project ... -q "How much was refunded last month?" -q "Ne
79
81
  # Add a gold case to the eval suite (rejected if the exact question already exists):
80
82
  cassis eval add-case --project ... -q "How much was refunded last month?" \
81
83
  --gold-sql "SELECT SUM(refunded_cents) / 100.0 FROM public.orders WHERE ..."
84
+
85
+ # List the suite's cases (id + question; --json adds the gold SQL), then prune one:
86
+ cassis eval list-cases --project ...
87
+ cassis eval delete-case 019f0000-0000-7000-8000-0000000000ca --project ...
88
+
89
+ # Pull the source schema into <base-path>/.schema.json (gitignored local snapshot):
90
+ cassis schema pull
82
91
  ```
83
92
 
84
93
  Configuration (flags take precedence over env vars):
@@ -88,7 +97,7 @@ Configuration (flags take precedence over env vars):
88
97
  | `--api-key` | `CASSIS_API_KEY` | — (required) |
89
98
  | `--api-url` | `CASSIS_API_URL` | `https://app.getcassis.com` |
90
99
  | `--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) |
100
+ | `--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
101
 
93
102
  `cassis eval run` also accepts `--label` (run label in the Evals page; defaults
94
103
  to the branch name from the CI environment or the local git checkout; rejected
@@ -117,7 +126,7 @@ cassis ontology fmt --check
117
126
  | Code | Meaning |
118
127
  | ---- | ------------------------------------------------------------------------------ |
119
128
  | 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) |
129
+ | 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
130
  | 2 | Usage error (missing API key or project, no ontology directory, unreadable file, tree over the size limits) |
122
131
  | 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, out of credits, or `--timeout` reached |
123
132
 
@@ -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,47 @@ def get_ontology_export(
232
243
  return result["files"]
233
244
 
234
245
 
246
+ class NoSourceSchemaError(ApiError):
247
+ """The project's data source has no introspected schema to pull."""
248
+
249
+
250
+ def get_schema_export(
251
+ *,
252
+ api_url: str,
253
+ api_key: str,
254
+ project_id: str,
255
+ transport: Optional[httpx.BaseTransport] = None,
256
+ ) -> dict[str, Any]:
257
+ """GET /api/ci/projects/{project_id}/schema and return the response body."""
258
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/schema"
259
+ try:
260
+ with _client(transport=transport) as client:
261
+ response = client.get(url, headers={"Authorization": f"Bearer {api_key}"})
262
+ except httpx.HTTPError as exc:
263
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
264
+
265
+ if response.status_code == 401:
266
+ raise AuthError("The Cassis API rejected the API key (invalid or expired).")
267
+ # "No source schema" is the marker the server's export endpoint puts in its
268
+ # 404 detail (backend endpoints/ci.py::export_source_schema — reworded only
269
+ # with a paired CLI release). Without this routing, a schema-less project
270
+ # would surface as the misleading "check --project / key access" hint below.
271
+ if response.status_code == 404 and "No source schema" in response.text:
272
+ raise NoSourceSchemaError(str(_detail_or_text(response)))
273
+ if response.status_code in (400, 403, 404):
274
+ raise _project_scope_error(response)
275
+ if response.status_code >= 400:
276
+ raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
277
+ result = _parse_json_response(response, url)
278
+ if (
279
+ not isinstance(result, dict)
280
+ or not isinstance(result.get("tables"), list)
281
+ or not isinstance(result.get("schema_version"), dict)
282
+ ):
283
+ raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
284
+ return result
285
+
286
+
235
287
  def post_eval_run_start(
236
288
  *,
237
289
  api_url: str,
@@ -321,6 +373,54 @@ def post_eval_case_create(
321
373
  return result
322
374
 
323
375
 
376
+ class EvalCaseNotFoundError(ApiError):
377
+ """The project has no current eval case with this id."""
378
+
379
+
380
+ def get_eval_cases(
381
+ *,
382
+ api_url: str,
383
+ api_key: str,
384
+ project_id: str,
385
+ transport: Optional[httpx.BaseTransport] = None,
386
+ ) -> list[dict[str, Any]]:
387
+ """GET /api/ci/projects/{project_id}/eval/cases and return the case list."""
388
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/eval/cases"
389
+ result = _get_eval_json(url, api_key, transport)
390
+ if not isinstance(result, list):
391
+ raise ApiError(f"Unexpected response shape from the Cassis API at {url}.")
392
+ return result
393
+
394
+
395
+ def delete_eval_case(
396
+ *,
397
+ api_url: str,
398
+ api_key: str,
399
+ project_id: str,
400
+ case_id: str,
401
+ transport: Optional[httpx.BaseTransport] = None,
402
+ ) -> None:
403
+ """DELETE /api/ci/projects/{project_id}/eval/cases/{case_id}."""
404
+ url = api_url.rstrip("/") + f"/api/ci/projects/{project_id}/eval/cases/{case_id}"
405
+ try:
406
+ with _client(transport=transport) as client:
407
+ response = client.delete(url, headers={"Authorization": f"Bearer {api_key}"})
408
+ except httpx.HTTPError as exc:
409
+ raise ApiError(f"Could not reach the Cassis API at {url}: {exc}") from exc
410
+
411
+ if response.status_code == 401:
412
+ raise AuthError("The Cassis API rejected the API key (invalid or expired).")
413
+ # Exact-match wire contract with the DELETE endpoint's 404 detail (see
414
+ # `delete_eval_case` in backend/app/endpoints/ci.py): it distinguishes a
415
+ # missing case (exit 1) from a project-scope 404 (exit 3).
416
+ if response.status_code == 404 and _detail_or_text(response) == "Eval case not found":
417
+ raise EvalCaseNotFoundError(f"No current eval case {case_id} in this project (already deleted, or wrong id?).")
418
+ if response.status_code in (403, 404):
419
+ raise _project_scope_error(response)
420
+ if response.status_code >= 400:
421
+ raise ApiError(f"Cassis API returned HTTP {response.status_code}: {response.text[:500]}")
422
+
423
+
324
424
  def _get_eval_json(url: str, api_key: str, transport: Optional[httpx.BaseTransport]) -> Any:
325
425
  """GET an eval-run URL with the shared error mapping."""
326
426
  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
 
@@ -8,6 +8,7 @@ import subprocess
8
8
  import time
9
9
  from pathlib import Path
10
10
  from typing import Any, Optional
11
+ from uuid import UUID
11
12
 
12
13
  import typer
13
14
  from cassis_cli.api import (
@@ -16,8 +17,11 @@ from cassis_cli.api import (
16
17
  AuthError,
17
18
  EvalCaseExistsError,
18
19
  EvalCaseGoldSqlError,
20
+ EvalCaseNotFoundError,
19
21
  EvalRunActiveError,
20
22
  EvalStartValidationError,
23
+ delete_eval_case,
24
+ get_eval_cases,
21
25
  get_eval_run,
22
26
  get_eval_run_results,
23
27
  post_eval_case_create,
@@ -221,6 +225,134 @@ def add_case(
221
225
  raise typer.Exit(EXIT_OK)
222
226
 
223
227
 
228
+ @app.command(name="list-cases")
229
+ def list_cases(
230
+ path: Path = typer.Argument(
231
+ Path("."),
232
+ help="Repository checkout root (holds <base-path>/project.yml for the --project default).",
233
+ ),
234
+ project_id: Optional[str] = typer.Option(
235
+ None,
236
+ "--project",
237
+ envvar="CASSIS_PROJECT_ID",
238
+ help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
239
+ ),
240
+ api_key: Optional[str] = typer.Option(
241
+ None,
242
+ "--api-key",
243
+ envvar="CASSIS_API_KEY",
244
+ help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
245
+ ),
246
+ api_url: str = typer.Option(
247
+ DEFAULT_API_URL,
248
+ "--api-url",
249
+ envvar="CASSIS_API_URL",
250
+ help="Cassis API base URL.",
251
+ ),
252
+ base_path: str = typer.Option(
253
+ DEFAULT_BASE_PATH,
254
+ "--base-path",
255
+ envvar="CASSIS_BASE_PATH",
256
+ help="Repository directory the ontology is exported under (holds project.yml for the --project default).",
257
+ ),
258
+ json_output: bool = typer.Option(False, "--json", help="Print the cases as raw JSON (includes gold SQL)."),
259
+ ) -> None:
260
+ """List the project's eval cases: the suite `cassis eval run` scores.
261
+
262
+ Prints each case's id and question (--json adds the gold SQL); the id is
263
+ what `cassis eval delete-case` takes. Exits 0 on success, 2 on usage
264
+ errors, 3 on transport/API errors.
265
+ """
266
+ api_key = require_api_key(api_key)
267
+ project_id = resolve_project_id(project_id, path / Path(base_path))
268
+
269
+ try:
270
+ cases = get_eval_cases(api_url=api_url, api_key=api_key, project_id=project_id)
271
+ except AuthError as exc:
272
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
273
+ raise typer.Exit(EXIT_TRANSPORT) from exc
274
+ except ApiError as exc:
275
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
276
+ raise typer.Exit(EXIT_TRANSPORT) from exc
277
+
278
+ if json_output:
279
+ typer.echo(json.dumps(cases, indent=2))
280
+ else:
281
+ if not cases:
282
+ typer.echo("No eval cases yet — add one with `cassis eval add-case`.")
283
+ for case in cases:
284
+ question = str(case.get("question", "")).replace("\n", " ")
285
+ typer.echo(f"{case.get('id')} {question}")
286
+ raise typer.Exit(EXIT_OK)
287
+
288
+
289
+ @app.command(name="delete-case")
290
+ def delete_case(
291
+ case_id: str = typer.Argument(
292
+ ...,
293
+ help="Id of the eval case to delete (shown by `cassis eval list-cases`).",
294
+ ),
295
+ path: Path = typer.Option(
296
+ Path("."),
297
+ "--path",
298
+ help="Repository checkout root (holds <base-path>/project.yml for the --project default).",
299
+ ),
300
+ project_id: Optional[str] = typer.Option(
301
+ None,
302
+ "--project",
303
+ envvar="CASSIS_PROJECT_ID",
304
+ help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
305
+ ),
306
+ api_key: Optional[str] = typer.Option(
307
+ None,
308
+ "--api-key",
309
+ envvar="CASSIS_API_KEY",
310
+ help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
311
+ ),
312
+ api_url: str = typer.Option(
313
+ DEFAULT_API_URL,
314
+ "--api-url",
315
+ envvar="CASSIS_API_URL",
316
+ help="Cassis API base URL.",
317
+ ),
318
+ base_path: str = typer.Option(
319
+ DEFAULT_BASE_PATH,
320
+ "--base-path",
321
+ envvar="CASSIS_BASE_PATH",
322
+ help="Repository directory the ontology is exported under (holds project.yml for the --project default).",
323
+ ),
324
+ ) -> None:
325
+ """Delete an eval case from the project's suite.
326
+
327
+ For pruning a case that is stale or wrong — e.g. its gold SQL encodes a
328
+ definition the ontology has since changed. Exits 0 on deletion, 1 when
329
+ the case does not exist in the project, 2 on usage errors, 3 on
330
+ transport/API errors.
331
+ """
332
+ api_key = require_api_key(api_key)
333
+ project_id = resolve_project_id(project_id, path / Path(base_path))
334
+ try:
335
+ UUID(case_id)
336
+ except ValueError:
337
+ typer.secho(f"CASE_ID must be a UUID, got {case_id!r}.", fg=typer.colors.RED, err=True)
338
+ raise typer.Exit(EXIT_USAGE)
339
+
340
+ try:
341
+ delete_eval_case(api_url=api_url, api_key=api_key, project_id=project_id, case_id=case_id)
342
+ except EvalCaseNotFoundError as exc:
343
+ typer.secho(str(exc), fg=typer.colors.YELLOW, err=True)
344
+ raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
345
+ except AuthError as exc:
346
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
347
+ raise typer.Exit(EXIT_TRANSPORT) from exc
348
+ except ApiError as exc:
349
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
350
+ raise typer.Exit(EXIT_TRANSPORT) from exc
351
+
352
+ typer.secho(f"✓ Deleted eval case {case_id}.", fg=typer.colors.GREEN)
353
+ raise typer.Exit(EXIT_OK)
354
+
355
+
224
356
  @app.command()
225
357
  def run(
226
358
  path: Path = typer.Argument(
@@ -31,7 +31,7 @@ GUIDE_FILENAME = "AGENTS.md"
31
31
  # Monotonic version of the doctrine text below. Bump it whenever
32
32
  # ontology_design_guide.md changes (a backend test enforces the pairing) — it
33
33
  # is what lets an older writer recognize a newer guide and leave it alone.
34
- DOCTRINE_VERSION = 4
34
+ DOCTRINE_VERSION = 5
35
35
 
36
36
  # Must stay byte-identical to backend/app/services/ontology_guide.py::_BANNER —
37
37
  # the server-side git export writes the same file, and differing banners would
@@ -6,6 +6,7 @@ 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.schema import app as schema_app
9
10
 
10
11
  app = typer.Typer(
11
12
  no_args_is_help=True,
@@ -13,6 +14,7 @@ app = typer.Typer(
13
14
  )
14
15
  app.add_typer(ontology_app, name="ontology")
15
16
  app.add_typer(eval_app, name="eval")
17
+ app.add_typer(schema_app, name="schema")
16
18
 
17
19
 
18
20
  @app.command()
@@ -56,6 +56,12 @@ def check(
56
56
  Path("."),
57
57
  help="Repository checkout root (the directory containing the ontology export path).",
58
58
  ),
59
+ project_id: Optional[str] = typer.Option(
60
+ None,
61
+ "--project",
62
+ envvar="CASSIS_PROJECT_ID",
63
+ help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
64
+ ),
59
65
  api_key: Optional[str] = typer.Option(
60
66
  None,
61
67
  "--api-key",
@@ -79,14 +85,27 @@ def check(
79
85
  """Validate the ontology files in a repository checkout.
80
86
 
81
87
  Runs the same checks as the Cassis GitHub PR check (YAML parsing,
82
- round-trip, import validation). Exits 0 when valid, 1 when validation
83
- fails, 2 on usage errors, 3 on transport/API errors.
88
+ round-trip, import validation). When the checkout is bound to a project
89
+ (``project.yml``, ``--project``, or ``CASSIS_PROJECT_ID``), the tree is
90
+ 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.
84
93
  """
85
94
  api_key = _require_api_key(api_key)
86
95
  files, base_path = _collect_tree(path, base_path)
87
96
 
97
+ # Soft resolution: an unbound checkout is not an error — the check falls
98
+ # back to the project-less route (no schema reference stage).
99
+ project_id = _resolve_project_id(project_id, path / base_path, optional=True, quiet=json_output)
100
+ if not project_id and not json_output:
101
+ typer.secho(
102
+ "No project binding — schema reference checks skipped (bind with `cassis ontology pull` or --project).",
103
+ fg=typer.colors.CYAN,
104
+ err=True,
105
+ )
106
+
88
107
  try:
89
- result = post_ontology_check(api_url=api_url, api_key=api_key, files=files)
108
+ result = post_ontology_check(api_url=api_url, api_key=api_key, files=files, project_id=project_id)
90
109
  except AuthError as exc:
91
110
  typer.secho(str(exc), fg=typer.colors.RED, err=True)
92
111
  raise typer.Exit(EXIT_TRANSPORT) from exc
@@ -94,10 +113,21 @@ def check(
94
113
  typer.secho(str(exc), fg=typer.colors.RED, err=True)
95
114
  raise typer.Exit(EXIT_TRANSPORT) from exc
96
115
 
116
+ warnings = result.get("warnings") or []
97
117
  if json_output:
98
118
  typer.echo(json.dumps(result, indent=2))
99
119
  elif result["passed"]:
100
120
  typer.secho(f"✓ {result['summary']}", fg=typer.colors.GREEN)
121
+ # Disambiguate silence: "no warnings" must never read as "references
122
+ # verified" when the stage didn't run.
123
+ if project_id and result.get("references_checked") and not warnings:
124
+ typer.secho("✓ Schema references resolve against the source schema.", fg=typer.colors.GREEN)
125
+ elif project_id and not result.get("references_checked"):
126
+ typer.secho(
127
+ "Schema reference check skipped — the project has no source schema yet.",
128
+ fg=typer.colors.YELLOW,
129
+ err=True,
130
+ )
101
131
  else:
102
132
  typer.secho(result["title"], fg=typer.colors.RED, bold=True)
103
133
  typer.echo(result["summary"])
@@ -105,6 +135,16 @@ def check(
105
135
  location = f"{base_path}/{finding.get('path')}: " if finding.get("path") else ""
106
136
  typer.echo(f" {location}{finding.get('message', '')} ({finding.get('stage', '?')})")
107
137
 
138
+ if warnings and not json_output:
139
+ typer.secho(
140
+ f"{len(warnings)} schema reference warning(s) — advisory, expected if the objects "
141
+ "haven't been built or synced yet:",
142
+ fg=typer.colors.YELLOW,
143
+ bold=True,
144
+ )
145
+ for warning in warnings:
146
+ typer.secho(f" {warning.get('message', '')}", fg=typer.colors.YELLOW)
147
+
108
148
  raise typer.Exit(EXIT_OK if result["passed"] else EXIT_VALIDATION_FAILED)
109
149
 
110
150
 
@@ -357,6 +357,16 @@ description, so a wrong example is worse than none.
357
357
  real mechanic diverges.
358
358
  - **Don't describe things that don't exist.** Every table, column, and value you
359
359
  reference must be real.
360
+ - **A hedge is not a safeguard.** "Likely `'M:SS.mmm'`", "probably", "appears to
361
+ be" reach the agent as the claim itself; the qualifier does not survive into
362
+ its reasoning. If a storage format, a unit, or a reason for nullability is not
363
+ in the source, leave it out and record the open question instead.
364
+ - **Never invent a contrast with a column you did not read.** When a new column
365
+ shares a name with one on another table (`position_order`, `status_id`,
366
+ `lap`), the tempting sentence is "here it means X, unlike there". A schema
367
+ dump cannot support that. Open the sibling's description and reuse it if it
368
+ applies; if you can't confirm the relationship, say it's unverified. A false
369
+ distinction drawn confidently survives review as a documented decision.
360
370
 
361
371
  ---
362
372
 
@@ -0,0 +1,129 @@
1
+ """`cassis schema` — local snapshot of the data source's source schema.
2
+
3
+ The source schema is OBSERVED state (the warehouse is authoritative), so the
4
+ snapshot is a gitignored cache, never a committed file: `pull` writes
5
+ `<base-path>/.schema.json` and keeps it out of git via the ontology dir's
6
+ `.gitignore`. Agents working in a checkout grep it instead of paging through
7
+ the MCP `get_source_schema` tool; `pulled_at` records how stale it is.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ import typer
18
+ from cassis_cli.api import DEFAULT_API_URL, ApiError, AuthError, get_schema_export
19
+ from cassis_cli.common import (
20
+ DEFAULT_BASE_PATH,
21
+ EXIT_OK,
22
+ EXIT_TRANSPORT,
23
+ EXIT_USAGE,
24
+ require_api_key,
25
+ resolve_project_id,
26
+ )
27
+
28
+ app = typer.Typer(help="Pull a local, gitignored snapshot of the data source's schema.")
29
+
30
+ SNAPSHOT_FILENAME = ".schema.json"
31
+ _GITIGNORE_HEADER = "# Cassis local caches (observed state — never commit)"
32
+
33
+
34
+ @app.command()
35
+ def pull(
36
+ path: Path = typer.Argument(
37
+ Path("."),
38
+ help="Repository checkout root (the directory containing the ontology export path).",
39
+ ),
40
+ project_id: Optional[str] = typer.Option(
41
+ None,
42
+ "--project",
43
+ envvar="CASSIS_PROJECT_ID",
44
+ help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
45
+ ),
46
+ api_key: Optional[str] = typer.Option(
47
+ None,
48
+ "--api-key",
49
+ envvar="CASSIS_API_KEY",
50
+ help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
51
+ ),
52
+ api_url: str = typer.Option(
53
+ DEFAULT_API_URL,
54
+ "--api-url",
55
+ envvar="CASSIS_API_URL",
56
+ help="Cassis API base URL.",
57
+ ),
58
+ base_path: str = typer.Option(
59
+ DEFAULT_BASE_PATH,
60
+ "--base-path",
61
+ envvar="CASSIS_BASE_PATH",
62
+ help="Repository directory the ontology is exported under (the project's git-sync Path setting).",
63
+ ),
64
+ ) -> None:
65
+ """Download the source schema into `<base-path>/.schema.json` (gitignored).
66
+
67
+ The snapshot is the schema as Cassis last introspected it from the
68
+ warehouse (or parsed from an uploaded DDL) — every table with its columns
69
+ and types, plus a `pulled_at` stamp so staleness is visible. Re-run after
70
+ a warehouse sync to refresh. Exits 0 on success, 2 on usage errors, 3 on
71
+ transport/API errors.
72
+ """
73
+ api_key = require_api_key(api_key)
74
+ ontology_dir = path / base_path
75
+ project_id = resolve_project_id(project_id, ontology_dir)
76
+
77
+ try:
78
+ result = get_schema_export(api_url=api_url, api_key=api_key, project_id=project_id)
79
+ except (AuthError, ApiError) as exc:
80
+ # NoSourceSchemaError lands here too: the server's message already says
81
+ # what to do (sync or upload a DDL); the class exists so api.py doesn't
82
+ # bury it under the misleading project-scope hint.
83
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
84
+ raise typer.Exit(EXIT_TRANSPORT) from exc
85
+
86
+ snapshot = {
87
+ "pulled_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
88
+ "project_id": project_id,
89
+ "schema_version": result["schema_version"],
90
+ "tables": result["tables"],
91
+ }
92
+
93
+ try:
94
+ ontology_dir.mkdir(parents=True, exist_ok=True)
95
+ snapshot_path = ontology_dir / SNAPSHOT_FILENAME
96
+ snapshot_path.write_text(json.dumps(snapshot, indent=2) + "\n", encoding="utf-8")
97
+ ensure_gitignored(ontology_dir)
98
+ except OSError as exc:
99
+ # Usage-class exit (2), like the other local-file failures in the exit
100
+ # table — the API call succeeded, the checkout is what's broken.
101
+ typer.secho(f"Could not write the snapshot: {exc}", fg=typer.colors.RED, err=True)
102
+ raise typer.Exit(EXIT_USAGE) from exc
103
+
104
+ table_count = len(result["tables"])
105
+ column_count = sum(len(t.get("columns") or []) for t in result["tables"])
106
+ version = result["schema_version"].get("version")
107
+ typer.secho(
108
+ f"✓ Pulled source schema v{version}: {table_count} tables, {column_count} columns "
109
+ f"-> {snapshot_path} (gitignored)",
110
+ fg=typer.colors.GREEN,
111
+ )
112
+
113
+
114
+ def ensure_gitignored(ontology_dir: Path) -> None:
115
+ """Make sure the snapshot never lands in git: keep `.gitignore` covering it.
116
+
117
+ Appends to (or creates) the ontology dir's own `.gitignore` — local to the
118
+ export directory, so it survives repo-level `.gitignore` rewrites and needs
119
+ no knowledge of the checkout layout.
120
+ """
121
+ gitignore = ontology_dir / ".gitignore"
122
+ try:
123
+ existing = gitignore.read_text(encoding="utf-8")
124
+ except (OSError, UnicodeDecodeError) as _exc: # `as` keeps black from stripping the parens (3.14-only syntax)
125
+ existing = ""
126
+ if SNAPSHOT_FILENAME in existing.splitlines():
127
+ return
128
+ prefix = "" if not existing else existing.rstrip("\n") + "\n"
129
+ gitignore.write_text(f"{prefix}{_GITIGNORE_HEADER}\n{SNAPSHOT_FILENAME}\n", encoding="utf-8")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cassis-cli"
3
- version = "1.1.1"
3
+ version = "1.2.0"
4
4
  description = "Cassis CLI — run Cassis actions (ontology validation, upload and publish) from your CI pipelines"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
File without changes
File without changes