cassis-cli 1.2.0__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.2.0
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
@@ -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),
@@ -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),
@@ -243,6 +243,57 @@ def get_ontology_export(
243
243
  return result["files"]
244
244
 
245
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
+
246
297
  class NoSourceSchemaError(ApiError):
247
298
  """The project's data source has no introspected schema to pull."""
248
299
 
@@ -284,6 +335,68 @@ def get_schema_export(
284
335
  return result
285
336
 
286
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
+
287
400
  def post_eval_run_start(
288
401
  *,
289
402
  api_url: str,
@@ -292,6 +405,7 @@ def post_eval_run_start(
292
405
  files: Optional[dict[str, str]] = None,
293
406
  branch: Optional[str] = None,
294
407
  label: Optional[str] = None,
408
+ case_ids: Optional[list[str]] = None,
295
409
  transport: Optional[httpx.BaseTransport] = None,
296
410
  ) -> dict[str, Any]:
297
411
  """POST to /api/ci/projects/{project_id}/eval/runs and return the run record."""
@@ -303,6 +417,8 @@ def post_eval_run_start(
303
417
  body["branch"] = branch
304
418
  if label is not None:
305
419
  body["label"] = label
420
+ if case_ids is not None:
421
+ body["test_case_ids"] = case_ids
306
422
  try:
307
423
  with _client(transport=transport) as client:
308
424
  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()
@@ -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)
@@ -0,0 +1,295 @@
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
+ import time
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any, Optional
17
+
18
+ import typer
19
+ from cassis_cli.api import (
20
+ DEFAULT_API_URL,
21
+ ApiError,
22
+ AuthError,
23
+ SourceChangeConflictError,
24
+ get_schema_export,
25
+ get_source_change_run,
26
+ post_detect_from_ddl,
27
+ )
28
+ from cassis_cli.common import (
29
+ DEFAULT_BASE_PATH,
30
+ EXIT_OK,
31
+ EXIT_TRANSPORT,
32
+ EXIT_USAGE,
33
+ EXIT_VALIDATION_FAILED,
34
+ require_api_key,
35
+ resolve_project_id,
36
+ )
37
+
38
+ app = typer.Typer(help="Pull or push the data source's schema.")
39
+
40
+ _TERMINAL_RUN_STATUSES = {"completed", "failed", "cancelled"}
41
+ _MAX_CONSECUTIVE_POLL_FAILURES = 5
42
+
43
+ SNAPSHOT_FILENAME = ".schema.json"
44
+ _GITIGNORE_HEADER = "# Cassis local caches (observed state — never commit)"
45
+
46
+
47
+ @app.command()
48
+ def pull(
49
+ path: Path = typer.Argument(
50
+ Path("."),
51
+ help="Repository checkout root (the directory containing the ontology export path).",
52
+ ),
53
+ project_id: Optional[str] = typer.Option(
54
+ None,
55
+ "--project",
56
+ envvar="CASSIS_PROJECT_ID",
57
+ help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
58
+ ),
59
+ api_key: Optional[str] = typer.Option(
60
+ None,
61
+ "--api-key",
62
+ envvar="CASSIS_API_KEY",
63
+ help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
64
+ ),
65
+ api_url: str = typer.Option(
66
+ DEFAULT_API_URL,
67
+ "--api-url",
68
+ envvar="CASSIS_API_URL",
69
+ help="Cassis API base URL.",
70
+ ),
71
+ base_path: str = typer.Option(
72
+ DEFAULT_BASE_PATH,
73
+ "--base-path",
74
+ envvar="CASSIS_BASE_PATH",
75
+ help="Repository directory the ontology is exported under (the project's git-sync Path setting).",
76
+ ),
77
+ ) -> None:
78
+ """Download the source schema into `<base-path>/.schema.json` (gitignored).
79
+
80
+ The snapshot is the schema as Cassis last introspected it from the
81
+ warehouse (or parsed from an uploaded DDL) — every table with its columns
82
+ and types, plus a `pulled_at` stamp so staleness is visible. Re-run after
83
+ a warehouse sync to refresh. Exits 0 on success, 2 on usage errors, 3 on
84
+ transport/API errors.
85
+ """
86
+ api_key = require_api_key(api_key)
87
+ ontology_dir = path / base_path
88
+ project_id = resolve_project_id(project_id, ontology_dir)
89
+
90
+ try:
91
+ result = get_schema_export(api_url=api_url, api_key=api_key, project_id=project_id)
92
+ except (AuthError, ApiError) as exc:
93
+ # NoSourceSchemaError lands here too: the server's message already says
94
+ # what to do (sync or upload a DDL); the class exists so api.py doesn't
95
+ # bury it under the misleading project-scope hint.
96
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
97
+ raise typer.Exit(EXIT_TRANSPORT) from exc
98
+
99
+ snapshot = {
100
+ "pulled_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
101
+ "project_id": project_id,
102
+ "schema_version": result["schema_version"],
103
+ "tables": result["tables"],
104
+ }
105
+
106
+ try:
107
+ ontology_dir.mkdir(parents=True, exist_ok=True)
108
+ snapshot_path = ontology_dir / SNAPSHOT_FILENAME
109
+ snapshot_path.write_text(json.dumps(snapshot, indent=2) + "\n", encoding="utf-8")
110
+ ensure_gitignored(ontology_dir)
111
+ except OSError as exc:
112
+ # Usage-class exit (2), like the other local-file failures in the exit
113
+ # table — the API call succeeded, the checkout is what's broken.
114
+ typer.secho(f"Could not write the snapshot: {exc}", fg=typer.colors.RED, err=True)
115
+ raise typer.Exit(EXIT_USAGE) from exc
116
+
117
+ table_count = len(result["tables"])
118
+ column_count = sum(len(t.get("columns") or []) for t in result["tables"])
119
+ version = result["schema_version"].get("version")
120
+ typer.secho(
121
+ f"✓ Pulled source schema v{version}: {table_count} tables, {column_count} columns "
122
+ f"-> {snapshot_path} (gitignored)",
123
+ fg=typer.colors.GREEN,
124
+ )
125
+
126
+
127
+ def ensure_gitignored(ontology_dir: Path) -> None:
128
+ """Make sure the snapshot never lands in git: keep `.gitignore` covering it.
129
+
130
+ Appends to (or creates) the ontology dir's own `.gitignore` — local to the
131
+ export directory, so it survives repo-level `.gitignore` rewrites and needs
132
+ no knowledge of the checkout layout.
133
+ """
134
+ gitignore = ontology_dir / ".gitignore"
135
+ try:
136
+ existing = gitignore.read_text(encoding="utf-8")
137
+ except (OSError, UnicodeDecodeError) as _exc: # `as` keeps black from stripping the parens (3.14-only syntax)
138
+ existing = ""
139
+ if SNAPSHOT_FILENAME in existing.splitlines():
140
+ return
141
+ prefix = "" if not existing else existing.rstrip("\n") + "\n"
142
+ gitignore.write_text(f"{prefix}{_GITIGNORE_HEADER}\n{SNAPSHOT_FILENAME}\n", encoding="utf-8")
143
+
144
+
145
+ @app.command()
146
+ def push(
147
+ ddl_file: Path = typer.Argument(
148
+ ..., help="Path to the DDL file (.sql, .ddl, .txt) containing CREATE TABLE statements."
149
+ ),
150
+ path: Path = typer.Option(
151
+ Path("."),
152
+ help="Repository checkout root (the directory containing the ontology export path).",
153
+ ),
154
+ project_id: Optional[str] = typer.Option(
155
+ None,
156
+ "--project",
157
+ envvar="CASSIS_PROJECT_ID",
158
+ help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
159
+ ),
160
+ api_key: Optional[str] = typer.Option(
161
+ None,
162
+ "--api-key",
163
+ envvar="CASSIS_API_KEY",
164
+ help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
165
+ ),
166
+ api_url: str = typer.Option(
167
+ DEFAULT_API_URL,
168
+ "--api-url",
169
+ envvar="CASSIS_API_URL",
170
+ help="Cassis API base URL.",
171
+ ),
172
+ base_path: str = typer.Option(
173
+ DEFAULT_BASE_PATH,
174
+ "--base-path",
175
+ envvar="CASSIS_BASE_PATH",
176
+ help="Repository directory the ontology is exported under (the project's git-sync Path setting).",
177
+ ),
178
+ wait: bool = typer.Option(True, "--wait/--no-wait", help="Wait for the detection run to complete."),
179
+ poll_interval: float = typer.Option(5.0, "--poll-interval", help="Seconds between polls with --wait."),
180
+ timeout: float = typer.Option(600.0, "--timeout", help="Give up waiting after this many seconds."),
181
+ json_output: bool = typer.Option(False, "--json", help="Print the run record as raw JSON."),
182
+ ) -> None:
183
+ """Upload a DDL file to detect source-schema changes (same as the webapp's "Update from DDL").
184
+
185
+ The DDL must contain at least one CREATE TABLE statement and represents the
186
+ project's complete source schema. Cassis diffs it against the ontology:
187
+ added, dropped, and changed objects appear in Ontology > Review > Data
188
+ source for approval. Re-uploading a corrected DDL supersedes the previous
189
+ one. Only works on DDL-only projects (no warehouse connection). Exits 0 on
190
+ success, 1 on a failed detection run, 2 on usage errors, 3 on transport/API
191
+ errors or a --wait timeout.
192
+ """
193
+ api_key = require_api_key(api_key)
194
+ project_id = resolve_project_id(project_id, path / base_path)
195
+
196
+ try:
197
+ ddl_text = ddl_file.read_text(encoding="utf-8")
198
+ except OSError as exc:
199
+ typer.secho(f"Cannot read {ddl_file}: {exc}", fg=typer.colors.RED, err=True)
200
+ raise typer.Exit(EXIT_USAGE) from exc
201
+ except UnicodeDecodeError as exc:
202
+ typer.secho(f"Cannot read {ddl_file}: {exc}", fg=typer.colors.RED, err=True)
203
+ raise typer.Exit(EXIT_USAGE) from exc
204
+
205
+ if not ddl_text.strip():
206
+ typer.secho("DDL file is empty.", fg=typer.colors.RED, err=True)
207
+ raise typer.Exit(EXIT_USAGE)
208
+
209
+ try:
210
+ run = post_detect_from_ddl(api_url=api_url, api_key=api_key, project_id=project_id, ddl=ddl_text)
211
+ except SourceChangeConflictError as exc:
212
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
213
+ raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
214
+ except (AuthError, ApiError) as exc:
215
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
216
+ raise typer.Exit(EXIT_TRANSPORT) from exc
217
+
218
+ run_id = run["run_id"]
219
+ typer.echo(f"Detection run started: {run_id}")
220
+
221
+ if not wait:
222
+ if json_output:
223
+ typer.echo(json.dumps(run, indent=2))
224
+ raise typer.Exit(EXIT_OK)
225
+
226
+ run = _wait_for_detection_run(
227
+ api_url=api_url,
228
+ api_key=api_key,
229
+ project_id=project_id,
230
+ run_id=run_id,
231
+ poll_interval=poll_interval,
232
+ timeout=timeout,
233
+ )
234
+
235
+ if json_output:
236
+ typer.echo(json.dumps(run, indent=2))
237
+
238
+ run_status = run.get("status")
239
+ if run_status == "completed":
240
+ summary = run.get("summary") or {}
241
+ total = summary.get("total_changes", 0)
242
+ typer.secho(
243
+ f"✓ Detection completed: {total} change(s) detected." if total else "✓ Detection completed: no changes.",
244
+ fg=typer.colors.GREEN,
245
+ )
246
+ raise typer.Exit(EXIT_OK)
247
+ if run_status == "failed":
248
+ error = run.get("error") or "unknown error"
249
+ typer.secho(f"Detection failed: {error}", fg=typer.colors.RED, err=True)
250
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
251
+ if run_status == "cancelled":
252
+ typer.secho("Detection run was cancelled.", fg=typer.colors.YELLOW, err=True)
253
+ raise typer.Exit(EXIT_VALIDATION_FAILED)
254
+ typer.secho(f"Detection run ended with unexpected status: {run_status}", fg=typer.colors.RED, err=True)
255
+ raise typer.Exit(EXIT_TRANSPORT)
256
+
257
+
258
+ def _wait_for_detection_run(
259
+ *,
260
+ api_url: str,
261
+ api_key: str,
262
+ project_id: str,
263
+ run_id: str,
264
+ poll_interval: float,
265
+ timeout: float,
266
+ ) -> "dict[str, Any]":
267
+ """Poll until the detection run reaches a terminal status."""
268
+ deadline = time.monotonic() + timeout
269
+ consecutive_failures = 0
270
+ while True:
271
+ try:
272
+ run = get_source_change_run(api_url=api_url, api_key=api_key, project_id=project_id, run_id=run_id)
273
+ consecutive_failures = 0
274
+ except AuthError as exc:
275
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
276
+ raise typer.Exit(EXIT_TRANSPORT) from exc
277
+ except ApiError as exc:
278
+ consecutive_failures += 1
279
+ if consecutive_failures >= _MAX_CONSECUTIVE_POLL_FAILURES:
280
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
281
+ raise typer.Exit(EXIT_TRANSPORT) from exc
282
+ time.sleep(poll_interval)
283
+ continue
284
+
285
+ if run.get("status") in _TERMINAL_RUN_STATUSES:
286
+ return run
287
+
288
+ if time.monotonic() >= deadline:
289
+ typer.secho(
290
+ f"Timed out after {timeout:.0f}s: the detection run is still {run.get('status', '?')}.",
291
+ fg=typer.colors.YELLOW,
292
+ err=True,
293
+ )
294
+ raise typer.Exit(EXIT_TRANSPORT)
295
+ time.sleep(poll_interval)
@@ -0,0 +1,184 @@
1
+ """`cassis status` — published version vs repository head, from the terminal.
2
+
3
+ One command answers what previously needed the webapp or the GitHub Actions
4
+ tab: which version is published, whether it matches the local checkout, and
5
+ whether anything is awaiting publication. `--watch` polls until the published
6
+ version catches up with the local head (e.g. right after merging a PR whose
7
+ CI publishes the ontology).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import subprocess
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any, Optional, Tuple
17
+
18
+ import typer
19
+ from cassis_cli.api import DEFAULT_API_URL, ApiError, AuthError, get_project_status
20
+ from cassis_cli.common import (
21
+ DEFAULT_BASE_PATH,
22
+ EXIT_OK,
23
+ EXIT_TRANSPORT,
24
+ EXIT_USAGE,
25
+ require_api_key,
26
+ resolve_project_id,
27
+ )
28
+
29
+
30
+ def _git(path: Path, *args: str) -> Optional[str]:
31
+ """Run a git command in the checkout; None on failure, stdout (possibly empty) on success."""
32
+ try:
33
+ proc = subprocess.run(
34
+ ["git", *args],
35
+ cwd=path,
36
+ capture_output=True,
37
+ text=True,
38
+ timeout=5,
39
+ )
40
+ # Two separate handlers: the repo-wide black targets py314 and would strip
41
+ # the parens off a tuple form, which is a SyntaxError on the CLI's
42
+ # supported Python (>=3.10).
43
+ except OSError:
44
+ return None
45
+ except subprocess.TimeoutExpired:
46
+ return None
47
+ if proc.returncode != 0:
48
+ return None
49
+ return proc.stdout.strip()
50
+
51
+
52
+ def _local_comparison(path: Path, head: Optional[str], published_sha: Optional[str]) -> "Tuple[str, bool]":
53
+ """Describe the local checkout vs the published commit. Returns (text, in_sync)."""
54
+ if head is None:
55
+ return "not a git checkout (no local comparison)", False
56
+ if published_sha is None:
57
+ return f"at {head[:9]} (the published version records no git commit)", False
58
+ if head == published_sha:
59
+ return f"in sync with the published version ({head[:9]})", True
60
+ if _git(path, "merge-base", "--is-ancestor", published_sha, "HEAD") is not None:
61
+ count = _git(path, "rev-list", "--count", f"{published_sha}..HEAD") or "?"
62
+ return f"{count} commit(s) ahead of the published version ({published_sha[:9]})", False
63
+ if _git(path, "cat-file", "-e", f"{published_sha}^{{commit}}") is None:
64
+ return (
65
+ f"published commit {published_sha[:9]} not found locally (run git fetch, or the checkout is behind)",
66
+ False,
67
+ )
68
+ if _git(path, "merge-base", "--is-ancestor", "HEAD", published_sha) is not None:
69
+ count = _git(path, "rev-list", "--count", f"HEAD..{published_sha}") or "?"
70
+ return f"{count} commit(s) behind the published version ({published_sha[:9]}), run git pull", False
71
+ return f"diverged from the published commit {published_sha[:9]}", False
72
+
73
+
74
+ def _render(status_record: "dict[str, Any]", comparison_text: str) -> None:
75
+ published = status_record.get("published_version")
76
+ if published:
77
+ label = f" {published['label']!r}" if published.get("label") else ""
78
+ sha = published.get("git_commit_sha")
79
+ sha_text = f", commit {sha[:9]}" if sha else ""
80
+ typer.echo(f"Published: v{published['version']}{label} ({published.get('published_at')}{sha_text})")
81
+ else:
82
+ typer.echo("Published: nothing yet")
83
+ changes = "yes" if status_record.get("has_unpublished_changes") else "no"
84
+ typer.echo(f"Unpublished changes: {changes}")
85
+ git_sync = status_record.get("git_sync")
86
+ if git_sync:
87
+ typer.echo(f"Git sync: {git_sync['provider']} {git_sync['repo']} (path {git_sync['base_path']})")
88
+ else:
89
+ typer.echo("Git sync: not configured")
90
+ typer.echo(f"Local checkout: {comparison_text}")
91
+
92
+
93
+ def status(
94
+ path: Path = typer.Argument(
95
+ Path("."),
96
+ help="Repository checkout root (the directory containing the ontology export path).",
97
+ ),
98
+ project_id: Optional[str] = typer.Option(
99
+ None,
100
+ "--project",
101
+ envvar="CASSIS_PROJECT_ID",
102
+ help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
103
+ ),
104
+ api_key: Optional[str] = typer.Option(
105
+ None,
106
+ "--api-key",
107
+ envvar="CASSIS_API_KEY",
108
+ help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
109
+ ),
110
+ api_url: str = typer.Option(
111
+ DEFAULT_API_URL,
112
+ "--api-url",
113
+ envvar="CASSIS_API_URL",
114
+ help="Cassis API base URL.",
115
+ ),
116
+ base_path: str = typer.Option(
117
+ DEFAULT_BASE_PATH,
118
+ "--base-path",
119
+ envvar="CASSIS_BASE_PATH",
120
+ help="Repository directory the ontology is exported under (the project's git-sync Path setting).",
121
+ ),
122
+ watch: bool = typer.Option(
123
+ False,
124
+ "--watch",
125
+ help="Poll until the published version's commit matches the local git HEAD.",
126
+ ),
127
+ poll_interval: float = typer.Option(5.0, "--poll-interval", help="Seconds between polls with --watch."),
128
+ timeout: float = typer.Option(600.0, "--timeout", help="Give up watching after this many seconds."),
129
+ json_output: bool = typer.Option(False, "--json", help="Print the status as raw JSON."),
130
+ ) -> None:
131
+ """Show the project's published version vs the local checkout.
132
+
133
+ Prints the published head (version, label, commit), whether unpublished
134
+ changes are awaiting publication, the git-sync binding, and how the local
135
+ git HEAD relates to the published commit. With --watch, polls until the
136
+ published commit equals the local HEAD (a publish of your merge landing),
137
+ then exits 0. Exits 0 on success, 2 on usage errors, 3 on transport/API
138
+ errors or a --watch timeout.
139
+ """
140
+ api_key = require_api_key(api_key)
141
+ project_id = resolve_project_id(project_id, path / base_path, quiet=json_output)
142
+ head = _git(path, "rev-parse", "HEAD")
143
+
144
+ if watch and head is None:
145
+ typer.secho("--watch needs a git checkout (no local HEAD to compare against).", fg=typer.colors.RED, err=True)
146
+ raise typer.Exit(EXIT_USAGE)
147
+
148
+ deadline = time.monotonic() + timeout
149
+ last_line: Optional[str] = None
150
+ while True:
151
+ try:
152
+ record = get_project_status(api_url=api_url, api_key=api_key, project_id=project_id)
153
+ except (AuthError, ApiError) as exc:
154
+ typer.secho(str(exc), fg=typer.colors.RED, err=True)
155
+ raise typer.Exit(EXIT_TRANSPORT) from exc
156
+
157
+ published = record.get("published_version") or {}
158
+ published_sha = published.get("git_commit_sha")
159
+ comparison_text, in_sync = _local_comparison(path, head, published_sha)
160
+
161
+ if json_output:
162
+ typer.echo(json.dumps({**record, "local": {"head": head, "in_sync": in_sync}}, indent=2))
163
+ elif not watch:
164
+ _render(record, comparison_text)
165
+ else:
166
+ version_text = f"v{published['version']}" if published else "nothing published"
167
+ line = f"{version_text} (commit {published_sha[:9] if published_sha else 'none'}); local: {comparison_text}"
168
+ if line != last_line:
169
+ typer.echo(line)
170
+ last_line = line
171
+
172
+ if not watch:
173
+ raise typer.Exit(EXIT_OK)
174
+ if in_sync:
175
+ typer.secho("✓ Published version matches the local HEAD.", fg=typer.colors.GREEN)
176
+ raise typer.Exit(EXIT_OK)
177
+ if time.monotonic() >= deadline:
178
+ typer.secho(
179
+ f"Timed out after {timeout:.0f}s: the published version still does not match the local HEAD.",
180
+ fg=typer.colors.YELLOW,
181
+ err=True,
182
+ )
183
+ raise typer.Exit(EXIT_TRANSPORT)
184
+ time.sleep(poll_interval)
@@ -0,0 +1,123 @@
1
+ """`cassis verify` — the full local gate in one verb: fmt --check, check, eval run.
2
+
3
+ Every edit session recites the same litany by hand, and the README's CI
4
+ examples chain the same three commands as separate jobs. `verify` runs them in
5
+ order and stops at the first failure, so "is this change safe to merge?" is
6
+ one command in a checkout and one job in CI.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Callable, Optional
13
+
14
+ import typer
15
+ from cassis_cli.api import DEFAULT_API_URL
16
+ from cassis_cli.common import DEFAULT_BASE_PATH, EXIT_OK, require_api_key
17
+ from cassis_cli.eval import run as eval_run
18
+ from cassis_cli.ontology import check as ontology_check
19
+ from cassis_cli.ontology import fmt as ontology_fmt
20
+
21
+
22
+ def _step(title: str, fn: "Callable[[], None]") -> int:
23
+ """Run one gate; return its exit code (commands exit via typer.Exit)."""
24
+ typer.secho(f"==> {title}", bold=True)
25
+ try:
26
+ fn()
27
+ except typer.Exit as exc:
28
+ return exc.exit_code
29
+ return EXIT_OK
30
+
31
+
32
+ def verify(
33
+ path: Path = typer.Argument(
34
+ Path("."),
35
+ help="Repository checkout root (the directory containing the ontology export path).",
36
+ ),
37
+ project_id: Optional[str] = typer.Option(
38
+ None,
39
+ "--project",
40
+ envvar="CASSIS_PROJECT_ID",
41
+ help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
42
+ ),
43
+ api_key: Optional[str] = typer.Option(
44
+ None,
45
+ "--api-key",
46
+ envvar="CASSIS_API_KEY",
47
+ help="Cassis API key (sk-k6-...). Create one in Organization settings -> API keys.",
48
+ ),
49
+ api_url: str = typer.Option(
50
+ DEFAULT_API_URL,
51
+ "--api-url",
52
+ envvar="CASSIS_API_URL",
53
+ help="Cassis API base URL.",
54
+ ),
55
+ base_path: str = typer.Option(
56
+ DEFAULT_BASE_PATH,
57
+ "--base-path",
58
+ envvar="CASSIS_BASE_PATH",
59
+ help="Repository directory the ontology is exported under (the project's git-sync Path setting).",
60
+ ),
61
+ run_eval: bool = typer.Option(
62
+ True,
63
+ "--eval/--no-eval",
64
+ help="Run the project's eval suite as the last gate (default: run it).",
65
+ ),
66
+ ) -> None:
67
+ """Run the three local gates in order: `ontology fmt --check`, `ontology check`, `eval run`.
68
+
69
+ The same sequence the README's CI examples chain as separate jobs, stopping
70
+ at the first failure. `fmt` runs in --check mode and writes nothing. Pass
71
+ --no-eval to skip the eval suite (e.g. a project with no cases yet).
72
+ Exits with the first failing gate's code: 0 all gates passed, 1 validation
73
+ or eval failure, 2 usage errors, 3 transport/API errors, 130 interrupted.
74
+ """
75
+ api_key = require_api_key(api_key)
76
+
77
+ code = _step(
78
+ "cassis ontology fmt --check",
79
+ lambda: ontology_fmt(path=path, api_key=api_key, api_url=api_url, base_path=base_path, check_only=True),
80
+ )
81
+ if code != EXIT_OK:
82
+ typer.secho("Not canonical: run `cassis ontology fmt` and review the diff.", fg=typer.colors.RED, err=True)
83
+ raise typer.Exit(code)
84
+
85
+ code = _step(
86
+ "cassis ontology check",
87
+ lambda: ontology_check(
88
+ path=path,
89
+ project_id=project_id,
90
+ api_key=api_key,
91
+ api_url=api_url,
92
+ base_path=base_path,
93
+ json_output=False,
94
+ ),
95
+ )
96
+ if code != EXIT_OK:
97
+ raise typer.Exit(code)
98
+
99
+ if run_eval:
100
+ # Kwargs must track eval_run's signature; a new required param breaks here.
101
+ code = _step(
102
+ "cassis eval run",
103
+ lambda: eval_run(
104
+ path=path,
105
+ project_id=project_id,
106
+ api_key=api_key,
107
+ api_url=api_url,
108
+ base_path=base_path,
109
+ branch=None,
110
+ case=None,
111
+ label=None,
112
+ wait=True,
113
+ poll_interval=5.0,
114
+ timeout=1800.0,
115
+ json_output=False,
116
+ app_url=None,
117
+ ),
118
+ )
119
+ if code != EXIT_OK:
120
+ raise typer.Exit(code)
121
+
122
+ typer.secho("✓ verify passed" + ("" if run_eval else " (eval skipped)"), fg=typer.colors.GREEN, bold=True)
123
+ raise typer.Exit(EXIT_OK)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cassis-cli"
3
- version = "1.2.0"
3
+ version = "1.3.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" }
@@ -1,129 +0,0 @@
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")
File without changes
File without changes