cassis-cli 1.0.0__tar.gz → 1.1.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.0.0
3
+ Version: 1.1.0
4
4
  Summary: Cassis CLI — run Cassis actions (ontology validation, upload and publish) from your CI pipelines
5
5
  License: Proprietary
6
6
  Keywords: cassis,ontology,ci,text-to-sql
@@ -25,8 +25,8 @@ Run Cassis actions from your CI pipelines:
25
25
  - `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.
26
26
  - `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.
27
27
  - `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.
28
- - `cassis ontology pull` downloads the project's unpublished ontology into your repository checkout (full sync — stale local YAML files are pruned), so you can start editing from the current state, or bootstrap a repo that isn't git-synced (e.g. Bitbucket).
29
- - `cassis ontology pull` and `cassis ontology fmt` also write `<base-path>/AGENTS.md`, the Cassis ontology modeling guide, into the checkout (default `cassis/AGENTS.md`) — a managed file (generated banner; the CLI overwrites local edits) so a repo-aware coding agent loads current Cassis modeling doctrine by convention. It sits inside the ontology directory but is not part of the ontology tree (the CLI reads only `*.yml`/`*.yaml`), so it is never uploaded, validated, or pruned. Commit it alongside your ontology changes. The guide text ships inside the CLI package, so its version tracks the **installed cassis-cli version** — upgrade the CLI (`pip install -U cassis-cli`) and re-run `fmt` to pick up doctrine updates; an unpinned `pip install cassis-cli` in CI gets them automatically. The banner stamps a doctrine version, and the CLI never *downgrades* the file: if the checkout's `AGENTS.md` was written by a newer doctrine (a newer CLI, or Cassis itself on a publish), `fmt`/`pull` leave it in place, print an upgrade notice, and `fmt --check` still passes.
28
+ - `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).
29
+ - `cassis ontology pull` and `cassis ontology fmt` also write `<base-path>/AGENTS.md`, the Cassis ontology modeling guide, into the checkout (default `cassis/AGENTS.md`) — a managed file (generated banner; the CLI overwrites local edits) so a repo-aware coding agent loads current Cassis modeling doctrine by convention. It sits inside the ontology directory but is not part of the ontology tree (which is the YAML files plus the domain Markdown files `domains/**/README.md`), so it is never uploaded, validated, or pruned. Commit it alongside your ontology changes. The guide text ships inside the CLI package, so its version tracks the **installed cassis-cli version** — upgrade the CLI (`pip install -U cassis-cli`) and re-run `fmt` to pick up doctrine updates; an unpinned `pip install cassis-cli` in CI gets them automatically. The banner stamps a doctrine version, and the CLI never *downgrades* the file: if the checkout's `AGENTS.md` was written by a newer doctrine (a newer CLI, or Cassis itself on a publish), `fmt`/`pull` leave it in place, print an upgrade notice, and `fmt --check` still passes.
30
30
  - The CLI identifies itself to the API (`User-Agent: cassis-cli/<version>`), and successful API responses advertise the newest published version — when you are behind, commands print a one-line upgrade notice on stderr (purely informational; output and exit codes are unchanged).
31
31
  - `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.
32
32
  - `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.
@@ -38,11 +38,21 @@ Run Cassis actions from your CI pipelines:
38
38
  pip install cassis-cli
39
39
  ```
40
40
 
41
+ ## Ontology file format
42
+
43
+ The ontology tree under `<base-path>` (default `cassis/`) is:
44
+
45
+ - **Project identity** — `project.yml`: the Cassis project id and format version. Written by `pull` and by server-side publish (the contexts that know the id); a local `fmt` won't create it.
46
+ - **Domains** — Markdown files: every domain is the `README.md` of its folder — `domains/README.md` for the root, `domains/<path>/README.md` for each sub-domain. Each has a small YAML frontmatter block (`type`, `title`, `description`) and a Markdown body carrying the domain's `context_md`; a generated section at the bottom links the domain's tables and metrics (kept current by `fmt`/`pull` — edit your prose above it, and the PR check fails if the links are stale, so re-run `fmt`). The layout is a Cassis profile inspired by [OKF](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf): the files render on GitHub and read in any Markdown editor, but Cassis validates them strictly (unknown keys are flagged, not preserved).
47
+ - **Tables, joins, metrics** — YAML, unchanged: `tables/<schema>/<table>.yml`, `joins.yml`, `metrics/<name>.yml`.
48
+
49
+ **Migrating an existing repo** (domains were YAML `_project.yml` / `_domain.yml` before cassis-cli 1.1.0): upgrade and run `cassis ontology fmt` (or `cassis ontology pull` if you have no local edits) — it rewrites the domain files to Markdown and removes the old ones. Review the diff and commit. Cassis reads the old YAML domain files too, so an un-migrated repo keeps working until you convert it. **Uploading requires cassis-cli ≥ 1.1.0** — the server rejects an older CLI (which would drop the Markdown domain files) with a clear upgrade error.
50
+
41
51
  ## Setup
42
52
 
43
53
  1. Create an API key in Cassis under **Organization settings → API keys** (keys start with `sk-k6-`).
44
54
  2. Store it as a CI secret and expose it as `CASSIS_API_KEY`.
45
- 3. For `pull`, `upload` and `eval run`: find the project ID (UUID) in the project's URL and expose it as `CASSIS_PROJECT_ID` (or pass `--project`).
55
+ 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).
46
56
 
47
57
  ## Usage
48
58
 
@@ -132,7 +142,7 @@ cassis ontology fmt --check
132
142
  | 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, out of credits, or `--timeout` reached |
133
143
 
134
144
  Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
135
- 2000 YAML files / 5 MB total — far above real ontologies (a few hundred small
145
+ 2000 ontology files / 5 MB total — far above real ontologies (a few hundred small
136
146
  files). Beyond that the CLI fails fast with exit 2 before uploading anything;
137
147
  double-check `--base-path` if you hit it.
138
148
 
@@ -5,8 +5,8 @@ Run Cassis actions from your CI pipelines:
5
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.
6
6
  - `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
7
  - `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
- - `cassis ontology pull` downloads the project's unpublished ontology into your repository checkout (full sync — stale local YAML files are pruned), so you can start editing from the current state, or bootstrap a repo that isn't git-synced (e.g. Bitbucket).
9
- - `cassis ontology pull` and `cassis ontology fmt` also write `<base-path>/AGENTS.md`, the Cassis ontology modeling guide, into the checkout (default `cassis/AGENTS.md`) — a managed file (generated banner; the CLI overwrites local edits) so a repo-aware coding agent loads current Cassis modeling doctrine by convention. It sits inside the ontology directory but is not part of the ontology tree (the CLI reads only `*.yml`/`*.yaml`), so it is never uploaded, validated, or pruned. Commit it alongside your ontology changes. The guide text ships inside the CLI package, so its version tracks the **installed cassis-cli version** — upgrade the CLI (`pip install -U cassis-cli`) and re-run `fmt` to pick up doctrine updates; an unpinned `pip install cassis-cli` in CI gets them automatically. The banner stamps a doctrine version, and the CLI never *downgrades* the file: if the checkout's `AGENTS.md` was written by a newer doctrine (a newer CLI, or Cassis itself on a publish), `fmt`/`pull` leave it in place, print an upgrade notice, and `fmt --check` still passes.
8
+ - `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).
9
+ - `cassis ontology pull` and `cassis ontology fmt` also write `<base-path>/AGENTS.md`, the Cassis ontology modeling guide, into the checkout (default `cassis/AGENTS.md`) — a managed file (generated banner; the CLI overwrites local edits) so a repo-aware coding agent loads current Cassis modeling doctrine by convention. It sits inside the ontology directory but is not part of the ontology tree (which is the YAML files plus the domain Markdown files `domains/**/README.md`), so it is never uploaded, validated, or pruned. Commit it alongside your ontology changes. The guide text ships inside the CLI package, so its version tracks the **installed cassis-cli version** — upgrade the CLI (`pip install -U cassis-cli`) and re-run `fmt` to pick up doctrine updates; an unpinned `pip install cassis-cli` in CI gets them automatically. The banner stamps a doctrine version, and the CLI never *downgrades* the file: if the checkout's `AGENTS.md` was written by a newer doctrine (a newer CLI, or Cassis itself on a publish), `fmt`/`pull` leave it in place, print an upgrade notice, and `fmt --check` still passes.
10
10
  - The CLI identifies itself to the API (`User-Agent: cassis-cli/<version>`), and successful API responses advertise the newest published version — when you are behind, commands print a one-line upgrade notice on stderr (purely informational; output and exit codes are unchanged).
11
11
  - `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
12
  - `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.
@@ -18,11 +18,21 @@ Run Cassis actions from your CI pipelines:
18
18
  pip install cassis-cli
19
19
  ```
20
20
 
21
+ ## Ontology file format
22
+
23
+ The ontology tree under `<base-path>` (default `cassis/`) is:
24
+
25
+ - **Project identity** — `project.yml`: the Cassis project id and format version. Written by `pull` and by server-side publish (the contexts that know the id); a local `fmt` won't create it.
26
+ - **Domains** — Markdown files: every domain is the `README.md` of its folder — `domains/README.md` for the root, `domains/<path>/README.md` for each sub-domain. Each has a small YAML frontmatter block (`type`, `title`, `description`) and a Markdown body carrying the domain's `context_md`; a generated section at the bottom links the domain's tables and metrics (kept current by `fmt`/`pull` — edit your prose above it, and the PR check fails if the links are stale, so re-run `fmt`). The layout is a Cassis profile inspired by [OKF](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf): the files render on GitHub and read in any Markdown editor, but Cassis validates them strictly (unknown keys are flagged, not preserved).
27
+ - **Tables, joins, metrics** — YAML, unchanged: `tables/<schema>/<table>.yml`, `joins.yml`, `metrics/<name>.yml`.
28
+
29
+ **Migrating an existing repo** (domains were YAML `_project.yml` / `_domain.yml` before cassis-cli 1.1.0): upgrade and run `cassis ontology fmt` (or `cassis ontology pull` if you have no local edits) — it rewrites the domain files to Markdown and removes the old ones. Review the diff and commit. Cassis reads the old YAML domain files too, so an un-migrated repo keeps working until you convert it. **Uploading requires cassis-cli ≥ 1.1.0** — the server rejects an older CLI (which would drop the Markdown domain files) with a clear upgrade error.
30
+
21
31
  ## Setup
22
32
 
23
33
  1. Create an API key in Cassis under **Organization settings → API keys** (keys start with `sk-k6-`).
24
34
  2. Store it as a CI secret and expose it as `CASSIS_API_KEY`.
25
- 3. For `pull`, `upload` and `eval run`: find the project ID (UUID) in the project's URL and expose it as `CASSIS_PROJECT_ID` (or pass `--project`).
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).
26
36
 
27
37
  ## Usage
28
38
 
@@ -112,7 +122,7 @@ cassis ontology fmt --check
112
122
  | 3 | Transport/API error (unreachable API, invalid key, inaccessible project, unexpected response), another run already active, out of credits, or `--timeout` reached |
113
123
 
114
124
  Commands that send the local tree (`check`, `fmt`, `upload`, `eval run`, `test`) accept up to
115
- 2000 YAML files / 5 MB total — far above real ontologies (a few hundred small
125
+ 2000 ontology files / 5 MB total — far above real ontologies (a few hundred small
116
126
  files). Beyond that the CLI fails fast with exit 2 before uploading anything;
117
127
  double-check `--base-path` if you hit it.
118
128
 
@@ -1,3 +1,3 @@
1
1
  """Cassis CLI — run Cassis actions from your CI pipelines."""
2
2
 
3
- __version__ = "1.0.0"
3
+ __version__ = "1.1.0"
@@ -191,6 +191,8 @@ def post_ontology_import(
191
191
  raise AuthError("The Cassis API rejected the API key (invalid or expired).")
192
192
  if response.status_code == 400:
193
193
  raise UploadValidationError(str(_detail_or_text(response)))
194
+ if response.status_code == 426: # this CLI is too old for the server's ontology format
195
+ raise UploadValidationError(str(_detail_or_text(response)))
194
196
  if response.status_code in (403, 404):
195
197
  raise _project_scope_error(response)
196
198
  if response.status_code >= 400:
@@ -0,0 +1,177 @@
1
+ """Helpers shared by the `cassis` subcommands (tree collection, auth, exit codes)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Optional
8
+ from uuid import UUID
9
+
10
+ import typer
11
+
12
+ # Repository directory the ontology tree is exported under. Must match the
13
+ # project's git-sync "Path" setting in Cassis (default "cassis").
14
+ DEFAULT_BASE_PATH = "cassis"
15
+
16
+ # Exit codes (documented in the README; stable contract for CI scripts).
17
+ EXIT_OK = 0
18
+ EXIT_VALIDATION_FAILED = 1
19
+ EXIT_USAGE = 2
20
+ EXIT_TRANSPORT = 3
21
+
22
+ # Request ceilings of the /api/ci file-tree endpoints, mirrored so oversized
23
+ # trees fail fast with a clear message before any upload. Source of truth:
24
+ # backend/app/schemas/ci.py (the server's 422 remains the backstop).
25
+ MAX_FILES = 2000
26
+ MAX_TOTAL_BYTES = 5 * 1024 * 1024
27
+
28
+
29
+ def is_ontology_file(rel_path: str) -> bool:
30
+ """Whether a base-relative path is an ontology file the server reads.
31
+
32
+ YAML (``project.yml``, tables, joins, metrics, legacy domains) plus domain
33
+ Markdown — every domain is the ``README.md`` of its folder, root included
34
+ (``domains/README.md``), so ``domains/**/README.md`` covers them all. Mirrors
35
+ the server's ``ontology_fs.is_ontology_tree_file`` (kept in sync by hand —
36
+ the CLI can't import the backend). Excludes the managed ``AGENTS.md`` and any
37
+ stray Markdown note, so neither is uploaded nor deleted by ``pull --prune``.
38
+ """
39
+ if rel_path.endswith((".yml", ".yaml")):
40
+ return True
41
+ return rel_path.startswith("domains/") and rel_path.endswith("/README.md")
42
+
43
+
44
+ def is_legacy_domain_file(rel_path: str) -> bool:
45
+ """Whether a base-relative path is a legacy (pre-Markdown) domain file.
46
+
47
+ Used to report the one-time migration to the Markdown domain format, when
48
+ ``pull``/``fmt`` remove a ``_project.yml``/``_domain.yml`` and write the
49
+ ``domains/README.md`` (and sub-domain ``README.md``) that replaces it.
50
+ """
51
+ return rel_path == "_project.yml" or rel_path.endswith("/_domain.yml")
52
+
53
+
54
+ def collect_files(ontology_dir: Path) -> dict[str, str]:
55
+ """Read every ontology file under the ontology dir, keyed by posix relpath.
56
+
57
+ Ontology files are YAML and domain Markdown (see ``is_ontology_file``); the
58
+ managed ``AGENTS.md`` and stray notes are skipped. Exits 2 (usage) on an
59
+ unreadable or non-UTF-8 file — a local checkout problem, reported before
60
+ anything is sent to the API.
61
+ """
62
+ files: dict[str, str] = {}
63
+ for pattern in ("**/*.yml", "**/*.yaml", "**/*.md"):
64
+ for file in sorted(ontology_dir.glob(pattern)):
65
+ if not file.is_file():
66
+ continue
67
+ rel = file.relative_to(ontology_dir).as_posix()
68
+ if not is_ontology_file(rel):
69
+ continue
70
+ try:
71
+ files[rel] = file.read_text(encoding="utf-8")
72
+ except (UnicodeDecodeError, OSError) as exc:
73
+ typer.secho(f"Cannot read {file}: {exc}", fg=typer.colors.RED, err=True)
74
+ raise typer.Exit(EXIT_USAGE) from exc
75
+ return files
76
+
77
+
78
+ # project.yml is a two-line machine-written file (`cassis_format_version`, `project_id`);
79
+ # match the id line directly rather than pull in a YAML parser just for this.
80
+ _PROJECT_ID_LINE = re.compile(r"^project_id:\s*['\"]?([^'\"\s]+)['\"]?\s*$")
81
+
82
+
83
+ def read_project_id_from_dir(ontology_dir: Path) -> Optional[str]:
84
+ """Return the ``project_id`` recorded in ``<ontology_dir>/project.yml``, or None."""
85
+ try:
86
+ text = (ontology_dir / "project.yml").read_text(encoding="utf-8")
87
+ except (OSError, UnicodeDecodeError) as _exc: # `as` keeps black from stripping the parens (3.14-only syntax)
88
+ return None
89
+ for line in text.splitlines():
90
+ match = _PROJECT_ID_LINE.match(line.strip())
91
+ if match:
92
+ return match.group(1)
93
+ return None
94
+
95
+
96
+ def resolve_project_id(project_id: Optional[str], ontology_dir: Path) -> str:
97
+ """Resolve the target project id, defaulting to the checkout's ``project.yml``.
98
+
99
+ Precedence: an explicit ``--project`` / ``CASSIS_PROJECT_ID`` wins; otherwise
100
+ the ``project_id`` recorded in ``<base-path>/project.yml`` (written by
101
+ ``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
+ """
105
+ from_file = False
106
+ if not project_id:
107
+ project_id = read_project_id_from_dir(ontology_dir)
108
+ from_file = project_id is not None
109
+ if not project_id:
110
+ typer.secho(
111
+ f"No project. Pass --project (or set CASSIS_PROJECT_ID), or run in a checkout whose "
112
+ f"{ontology_dir.name}/project.yml records it (written by `cassis ontology pull` or a publish).",
113
+ fg=typer.colors.RED,
114
+ err=True,
115
+ )
116
+ raise typer.Exit(EXIT_USAGE)
117
+ try:
118
+ UUID(project_id)
119
+ except ValueError:
120
+ typer.secho(f"--project must be a project ID (UUID), got {project_id!r}.", fg=typer.colors.RED, err=True)
121
+ raise typer.Exit(EXIT_USAGE)
122
+ if from_file:
123
+ typer.secho(f"Using project {project_id} from {ontology_dir.name}/project.yml.", fg=typer.colors.CYAN, err=True)
124
+ return project_id
125
+
126
+
127
+ def require_api_key(api_key: Optional[str]) -> str:
128
+ """Exit 2 (usage) when no API key was provided."""
129
+ if not api_key:
130
+ typer.secho(
131
+ "No API key. Set CASSIS_API_KEY or pass --api-key "
132
+ "(create one in Cassis under Organization settings -> API keys).",
133
+ fg=typer.colors.RED,
134
+ err=True,
135
+ )
136
+ raise typer.Exit(EXIT_USAGE)
137
+ return api_key
138
+
139
+
140
+ def collect_tree(path: Path, base_path: str) -> "tuple[dict[str, str], str]":
141
+ """Resolve the ontology dir under the checkout and read its file tree.
142
+
143
+ Returns ``(files, normalized_base_path)``. Exits 2 (usage) on an empty or
144
+ missing dir, or a tree beyond the API request ceilings — all local checkout
145
+ problems, reported before anything is sent to the API.
146
+ """
147
+ base_path = base_path.strip().strip("/")
148
+ if not base_path:
149
+ typer.secho("--base-path must not be empty.", fg=typer.colors.RED, err=True)
150
+ raise typer.Exit(EXIT_USAGE)
151
+
152
+ ontology_dir = path / Path(base_path)
153
+ if not ontology_dir.is_dir():
154
+ typer.secho(
155
+ f"No {base_path}/ directory found under {path}. "
156
+ "If the project exports to a custom path, pass it with --base-path (or CASSIS_BASE_PATH).",
157
+ fg=typer.colors.RED,
158
+ err=True,
159
+ )
160
+ raise typer.Exit(EXIT_USAGE)
161
+
162
+ files = collect_files(ontology_dir)
163
+ if not files:
164
+ typer.secho(f"No ontology files found under {ontology_dir}.", fg=typer.colors.RED, err=True)
165
+ raise typer.Exit(EXIT_USAGE)
166
+
167
+ total_bytes = sum(len(content.encode()) for content in files.values())
168
+ if len(files) > MAX_FILES or total_bytes > MAX_TOTAL_BYTES:
169
+ typer.secho(
170
+ f"Ontology tree too large: {len(files)} files / {total_bytes / (1024 * 1024):.1f} MB "
171
+ f"(limits: {MAX_FILES} files / {MAX_TOTAL_BYTES // (1024 * 1024)} MB). "
172
+ "Check that --base-path points at the ontology directory, not a larger tree.",
173
+ fg=typer.colors.RED,
174
+ err=True,
175
+ )
176
+ raise typer.Exit(EXIT_USAGE)
177
+ return files, base_path
@@ -8,7 +8,6 @@ import subprocess
8
8
  import time
9
9
  from pathlib import Path
10
10
  from typing import Any, Optional
11
- from uuid import UUID
12
11
 
13
12
  import typer
14
13
  from cassis_cli.api import (
@@ -33,6 +32,7 @@ from cassis_cli.common import (
33
32
  EXIT_VALIDATION_FAILED,
34
33
  collect_tree,
35
34
  require_api_key,
35
+ resolve_project_id,
36
36
  )
37
37
 
38
38
  app = typer.Typer(no_args_is_help=True, help="Eval commands.")
@@ -144,11 +144,15 @@ def _print_summary(run: dict[str, Any]) -> None:
144
144
 
145
145
  @app.command(name="add-case")
146
146
  def add_case(
147
- project_id: str = typer.Option(
148
- ...,
147
+ path: Path = typer.Argument(
148
+ Path("."),
149
+ help="Repository checkout root (holds <base-path>/project.yml for the --project default).",
150
+ ),
151
+ project_id: Optional[str] = typer.Option(
152
+ None,
149
153
  "--project",
150
154
  envvar="CASSIS_PROJECT_ID",
151
- help="Target Cassis project ID (UUID, shown in the project's URL).",
155
+ help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
152
156
  ),
153
157
  question: str = typer.Option(
154
158
  ...,
@@ -173,6 +177,12 @@ def add_case(
173
177
  envvar="CASSIS_API_URL",
174
178
  help="Cassis API base URL.",
175
179
  ),
180
+ base_path: str = typer.Option(
181
+ DEFAULT_BASE_PATH,
182
+ "--base-path",
183
+ envvar="CASSIS_BASE_PATH",
184
+ help="Repository directory the ontology is exported under (holds project.yml for the --project default).",
185
+ ),
176
186
  json_output: bool = typer.Option(False, "--json", help="Print the created case as raw JSON."),
177
187
  ) -> None:
178
188
  """Add a gold test case to the project's eval suite.
@@ -185,11 +195,7 @@ def add_case(
185
195
  run, 2 on usage errors, 3 on transport/API errors.
186
196
  """
187
197
  api_key = require_api_key(api_key)
188
- try:
189
- UUID(project_id)
190
- except ValueError:
191
- typer.secho(f"--project must be a project ID (UUID), got {project_id!r}.", fg=typer.colors.RED, err=True)
192
- raise typer.Exit(EXIT_USAGE)
198
+ project_id = resolve_project_id(project_id, path / Path(base_path))
193
199
  if not question.strip() or not gold_sql.strip():
194
200
  typer.secho("--question and --gold-sql must not be empty.", fg=typer.colors.RED, err=True)
195
201
  raise typer.Exit(EXIT_USAGE)
@@ -221,11 +227,11 @@ def run(
221
227
  Path("."),
222
228
  help="Repository checkout root (the directory containing the ontology export path).",
223
229
  ),
224
- project_id: str = typer.Option(
225
- ...,
230
+ project_id: Optional[str] = typer.Option(
231
+ None,
226
232
  "--project",
227
233
  envvar="CASSIS_PROJECT_ID",
228
- help="Target Cassis project ID (UUID, shown in the project's URL).",
234
+ help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
229
235
  ),
230
236
  api_key: Optional[str] = typer.Option(
231
237
  None,
@@ -272,18 +278,13 @@ def run(
272
278
  ) -> None:
273
279
  """Run the project's eval suite against your local ontology files.
274
280
 
275
- Uploads the local YAML tree and scores it in-memory — nothing is pushed or
281
+ Uploads the local ontology file tree and scores it in-memory — nothing is pushed or
276
282
  persisted in Cassis besides the eval run itself. With --branch, runs against
277
283
  an existing Cassis branch instead (no files are sent). Exits 0 when the run
278
284
  completes with every case passed, 1 on any failed case / failed run /
279
285
  invalid tree, 2 on usage errors, 3 on transport errors or --timeout.
280
286
  """
281
287
  api_key = require_api_key(api_key)
282
- try:
283
- UUID(project_id)
284
- except ValueError:
285
- typer.secho(f"--project must be a project ID (UUID), got {project_id!r}.", fg=typer.colors.RED, err=True)
286
- raise typer.Exit(EXIT_USAGE)
287
288
  if branch is not None and label is not None:
288
289
  typer.secho(
289
290
  "--label cannot be used with --branch: branch runs are labelled with the branch name.",
@@ -297,6 +298,7 @@ def run(
297
298
  files, base_path = collect_tree(path, base_path)
298
299
  if label is None:
299
300
  label = _git_branch(path)
301
+ project_id = resolve_project_id(project_id, path / Path(base_path))
300
302
 
301
303
  try:
302
304
  run_record = post_eval_run_start(
@@ -6,7 +6,7 @@ identical — the backend image doesn't ship ``docs/``, so the CLI carries its o
6
6
  copy). ``pull`` writes it into the checkout as ``<base_path>/AGENTS.md`` and
7
7
  ``fmt`` keeps it canonical, so a repo-aware agent loads current Cassis modeling
8
8
  doctrine by convention. The file is managed: a banner marks it generated and the
9
- CLI overwrites local edits, exactly as ``fmt`` rewrites drifted ontology YAML.
9
+ CLI overwrites local edits, exactly as ``fmt`` rewrites drifted ontology files.
10
10
 
11
11
  Two writers manage the file — this CLI and the Cassis server's git export — and
12
12
  they may run different doctrine versions (the guide ships inside each). The
@@ -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 = 1
34
+ DOCTRINE_VERSION = 3
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
@@ -5,7 +5,6 @@ from __future__ import annotations
5
5
  import json
6
6
  from pathlib import Path
7
7
  from typing import List, Optional
8
- from uuid import UUID
9
8
 
10
9
  import typer
11
10
  from cassis_cli.api import (
@@ -29,7 +28,9 @@ from cassis_cli.common import (
29
28
  )
30
29
  from cassis_cli.common import collect_files as _collect_files
31
30
  from cassis_cli.common import collect_tree as _collect_tree
31
+ from cassis_cli.common import is_legacy_domain_file as _is_legacy_domain_file
32
32
  from cassis_cli.common import require_api_key as _require_api_key
33
+ from cassis_cli.common import resolve_project_id as _resolve_project_id
33
34
  from cassis_cli.guide import DOCTRINE_VERSION, GUIDE_FILENAME, guide_status, refresh_guide
34
35
 
35
36
  app = typer.Typer(no_args_is_help=True, help="Ontology commands.")
@@ -113,11 +114,11 @@ def pull(
113
114
  Path("."),
114
115
  help="Repository checkout root (the directory containing the ontology export path).",
115
116
  ),
116
- project_id: str = typer.Option(
117
- ...,
117
+ project_id: Optional[str] = typer.Option(
118
+ None,
118
119
  "--project",
119
120
  envvar="CASSIS_PROJECT_ID",
120
- help="Source Cassis project ID (UUID, shown in the project's URL).",
121
+ help="Source Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
121
122
  ),
122
123
  api_key: Optional[str] = typer.Option(
123
124
  None,
@@ -140,28 +141,24 @@ def pull(
140
141
  prune: bool = typer.Option(
141
142
  True,
142
143
  "--prune/--no-prune",
143
- help="Delete local YAML files that no longer exist in the project's ontology (default: prune).",
144
+ help="Delete local ontology files that no longer exist in the project's ontology (default: prune).",
144
145
  ),
145
146
  json_output: bool = typer.Option(False, "--json", help="Print a JSON summary of written/deleted files."),
146
147
  ) -> None:
147
148
  """Download the project's unpublished ontology into a repository checkout.
148
149
 
149
- Writes the ontology YAML tree under the export path (full sync: files are
150
- overwritten and, unless --no-prune, stale local YAML files are deleted, so
151
- the checkout ends up matching the project exactly). Review the changes with
150
+ Writes the ontology tree under the export path (full sync: files are
151
+ overwritten and, unless --no-prune, stale local ontology files are deleted,
152
+ so the checkout ends up matching the project exactly). Review the changes with
152
153
  git diff before committing. Exits 0 on success, 2 on usage errors, 3 on
153
154
  transport/API errors.
154
155
  """
155
156
  api_key = _require_api_key(api_key)
156
- try:
157
- UUID(project_id)
158
- except ValueError:
159
- typer.secho(f"--project must be a project ID (UUID), got {project_id!r}.", fg=typer.colors.RED, err=True)
160
- raise typer.Exit(EXIT_USAGE)
161
157
  base_path = base_path.strip().strip("/")
162
158
  if not base_path:
163
159
  typer.secho("--base-path must not be empty.", fg=typer.colors.RED, err=True)
164
160
  raise typer.Exit(EXIT_USAGE)
161
+ project_id = _resolve_project_id(project_id, path / Path(base_path))
165
162
 
166
163
  try:
167
164
  files = get_ontology_export(api_url=api_url, api_key=api_key, project_id=project_id)
@@ -221,6 +218,13 @@ def pull(
221
218
  if guide_written:
222
219
  summary += f"; wrote {base_path}/{GUIDE_FILENAME}"
223
220
  typer.secho(f"{summary}.", fg=typer.colors.GREEN)
221
+ migrated = sum(1 for rel in deleted if _is_legacy_domain_file(rel))
222
+ if migrated:
223
+ typer.secho(
224
+ f" Migrated {migrated} domain(s) to Markdown README.md files; "
225
+ "the old _domain.yml / _project.yml were removed. Review the diff before committing.",
226
+ fg=typer.colors.YELLOW,
227
+ )
224
228
  raise typer.Exit(EXIT_OK)
225
229
 
226
230
 
@@ -230,11 +234,11 @@ def upload(
230
234
  Path("."),
231
235
  help="Repository checkout root (the directory containing the ontology export path).",
232
236
  ),
233
- project_id: str = typer.Option(
234
- ...,
237
+ project_id: Optional[str] = typer.Option(
238
+ None,
235
239
  "--project",
236
240
  envvar="CASSIS_PROJECT_ID",
237
- help="Target Cassis project ID (UUID, shown in the project's URL).",
241
+ help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
238
242
  ),
239
243
  api_key: Optional[str] = typer.Option(
240
244
  None,
@@ -270,12 +274,8 @@ def upload(
270
274
  usage errors, 3 on transport/API errors.
271
275
  """
272
276
  api_key = _require_api_key(api_key)
273
- try:
274
- UUID(project_id)
275
- except ValueError:
276
- typer.secho(f"--project must be a project ID (UUID), got {project_id!r}.", fg=typer.colors.RED, err=True)
277
- raise typer.Exit(EXIT_USAGE)
278
277
  files, base_path = _collect_tree(path, base_path)
278
+ project_id = _resolve_project_id(project_id, path / Path(base_path))
279
279
 
280
280
  try:
281
281
  result = post_ontology_import(
@@ -377,7 +377,7 @@ def fmt(
377
377
 
378
378
  changed = result["changed_paths"]
379
379
  removed = result["removed_paths"]
380
- # The managed AGENTS.md guide is canonicalized alongside the YAML tree
380
+ # The managed AGENTS.md guide is canonicalized alongside the ontology tree
381
381
  # (it isn't in the tree, so the server round-trip above never sees it).
382
382
  # A guide stamped with a NEWER doctrine than this CLI carries is left
383
383
  # alone and does not fail --check: the repo is fine, the CLI is old.
@@ -422,6 +422,13 @@ def fmt(
422
422
  + ". Review the diff: fields Cassis does not recognize are dropped.",
423
423
  fg=typer.colors.YELLOW,
424
424
  )
425
+ migrated = sum(1 for p in removed if _is_legacy_domain_file(p))
426
+ if migrated:
427
+ typer.secho(
428
+ f"Migrated {migrated} domain(s) to Markdown README.md files; "
429
+ "the old _domain.yml / _project.yml were removed.",
430
+ fg=typer.colors.YELLOW,
431
+ )
425
432
  else:
426
433
  # Only the guide was refreshed — the "rewrote ..." line above already said so.
427
434
  typer.secho(f"✓ {len(files)} file(s) already canonical.", fg=typer.colors.GREEN)
@@ -440,11 +447,11 @@ def test(
440
447
  "-q",
441
448
  help="Natural-language question to probe (repeat for several).",
442
449
  ),
443
- project_id: str = typer.Option(
444
- ...,
450
+ project_id: Optional[str] = typer.Option(
451
+ None,
445
452
  "--project",
446
453
  envvar="CASSIS_PROJECT_ID",
447
- help="Target Cassis project ID (UUID, shown in the project's URL).",
454
+ help="Target Cassis project ID (UUID). Defaults to the id in <base-path>/project.yml.",
448
455
  ),
449
456
  api_key: Optional[str] = typer.Option(
450
457
  None,
@@ -478,12 +485,8 @@ def test(
478
485
  invalid or a probe failed, 2 on usage errors, 3 on transport errors.
479
486
  """
480
487
  api_key = _require_api_key(api_key)
481
- try:
482
- UUID(project_id)
483
- except ValueError:
484
- typer.secho(f"--project must be a project ID (UUID), got {project_id!r}.", fg=typer.colors.RED, err=True)
485
- raise typer.Exit(EXIT_USAGE)
486
488
  files, base_path = _collect_tree(path, base_path)
489
+ project_id = _resolve_project_id(project_id, path / Path(base_path))
487
490
 
488
491
  outcomes: "list[dict]" = []
489
492
  failed = False
@@ -13,13 +13,13 @@ Read this before proposing an ontology change.
13
13
  ## 1. What a Cassis ontology is
14
14
 
15
15
  The ontology is the curated business context the agent uses to translate a
16
- natural-language question into SQL. It has five kinds of object plus one
17
- project-level field, edited as YAML files under the repository's ontology
18
- directory (`cassis/` by default):
16
+ natural-language question into SQL. It has five kinds of object plus a project-level root context, edited as
17
+ files under the repository's ontology directory (`cassis/` by default) —
18
+ domains as Markdown, everything else as YAML:
19
19
 
20
20
  | Layer | What it is | Key fields |
21
21
  |---|---|---|
22
- | **Root context** (`_project.yml → context_md`) | One free-text block, always in the agent's prompt | markdown |
22
+ | **Root context** (`domains/README.md` body) | One free-text block, always in the agent's prompt | markdown |
23
23
  | **Domains** | A navigable tree grouping the business by subject area | `path`, `display_name`, `description`, `context_md` |
24
24
  | **Tables** | A physical warehouse table (introspected) or a virtual one (SQL-defined) placed in a domain | `name` (`schema.TABLE`), `description`, `synonyms`, `grain`, columns |
25
25
  | **Columns** | Enrichment on a table's columns | `description`, `unit`, `synonyms` |
@@ -31,11 +31,12 @@ On disk, the export layout is fixed — create each object in its canonical home
31
31
 
32
32
  ```
33
33
  cassis/ (the git-sync base path)
34
- _project.yml root context (context_md), project display name
35
- domains/<path>/_domain.yml one per domain, nested by path
36
- tables/<schema>/<table>.yml one per table, columns inline
37
- metrics/<name>.yml one per metric
38
- joins.yml ALL joins, one list in one file
34
+ project.yml project identity: project id + Cassis format version (written by publish / pull)
35
+ domains/README.md root domain (path ""): frontmatter (type/title/description) + context_md body
36
+ domains/<path>/README.md one per sub-domain, nested by path (same Markdown format)
37
+ tables/<schema>/<table>.yml one per table, columns inline (YAML)
38
+ metrics/<name>.yml one per metric (YAML)
39
+ joins.yml ALL joins, one list in one file (YAML)
39
40
  ```
40
41
 
41
42
  Joins and metrics are never embedded inside a table's file, and a table's file
@@ -45,7 +46,7 @@ time (run `cassis ontology fmt` to see what would be lost).
45
46
  The **published** ontology is an immutable numbered snapshot — what production
46
47
  answers from. The **unpublished** ontology is the editable state (the published
47
48
  base plus unpublished changes). In a git-synced project the repository *is* the
48
- edit surface: you edit the YAML, open a pull request, and merging syncs and
49
+ edit surface: you edit the files, open a pull request, and merging syncs and
49
50
  publishes it.
50
51
 
51
52
  Physical tables and their columns come from schema introspection — you never
@@ -151,6 +152,12 @@ its own — an empty navigational domain is just noise. Every table and every
151
152
  metric names a `domain_path` that must resolve to a domain you've declared (or the
152
153
  root, `""`).
153
154
 
155
+ Each domain is a Markdown file (`domains/<path>/README.md`, or `domains/README.md`
156
+ for the root). Its YAML frontmatter holds the structured fields — `type: Domain`,
157
+ `title:` (the display name), `description:` — and the Markdown body **is** the
158
+ domain's `context_md`. Write the display name as `title`, not `display_name`: an
159
+ unrecognized frontmatter key is dropped on sync.
160
+
154
161
  A useful convention inside `context_md`: a `## Terms` section for the domain's
155
162
  vocabulary and disambiguation, and a `## Notes` section for routing hints and
156
163
  scope caveats. Omit either if you have nothing for it.
@@ -167,7 +174,7 @@ scope caveats. Omit either if you have nothing for it.
167
174
  - Markdown **links** to related domains, using the resolvable domain path
168
175
  (`[berries](play/features/berries)`), not a bare filename.
169
176
 
170
- **Root context** — the `context_md` in `_project.yml`, injected into every
177
+ **Root context** — the body of the root domain (`domains/README.md`), injected into every
171
178
  conversation — **holds only what applies across almost every query:** corporate
172
179
  identity, the core entity hierarchy (how the central models relate — e.g. "users
173
180
  belong to companies via enrollments; primary enrollments are employees, partner
@@ -381,11 +388,11 @@ description, so a wrong example is worse than none.
381
388
 
382
389
  ## 12. Working in a git-synced repo
383
390
 
384
- The repository is the source of truth. Edit the YAML, then verify before opening
391
+ The repository is the source of truth. Edit the files, then verify before opening
385
392
  a pull request — the CLI runs the same checks the platform does, from your
386
393
  checkout:
387
394
 
388
- - `cassis ontology check` — validate the files (YAML parse, round-trip, semantic
395
+ - `cassis ontology check` — validate the files (parse, round-trip, semantic
389
396
  checks); the same gate that runs on the pull request.
390
397
  - `cassis ontology fmt` — rewrite the files in canonical form, so hand or agent
391
398
  edits round-trip cleanly and any dropped/unknown fields become visible in the
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cassis-cli"
3
- version = "1.0.0"
3
+ version = "1.1.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 = "Proprietary" }
@@ -1,95 +0,0 @@
1
- """Helpers shared by the `cassis` subcommands (tree collection, auth, exit codes)."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
- from typing import Optional
7
-
8
- import typer
9
-
10
- # Repository directory the ontology tree is exported under. Must match the
11
- # project's git-sync "Path" setting in Cassis (default "cassis").
12
- DEFAULT_BASE_PATH = "cassis"
13
-
14
- # Exit codes (documented in the README; stable contract for CI scripts).
15
- EXIT_OK = 0
16
- EXIT_VALIDATION_FAILED = 1
17
- EXIT_USAGE = 2
18
- EXIT_TRANSPORT = 3
19
-
20
- # Request ceilings of the /api/ci file-tree endpoints, mirrored so oversized
21
- # trees fail fast with a clear message before any upload. Source of truth:
22
- # backend/app/schemas/ci.py (the server's 422 remains the backstop).
23
- MAX_FILES = 2000
24
- MAX_TOTAL_BYTES = 5 * 1024 * 1024
25
-
26
-
27
- def collect_files(ontology_dir: Path) -> dict[str, str]:
28
- """Read every YAML file under the ontology dir, keyed by posix relpath.
29
-
30
- Exits 2 (usage) on an unreadable or non-UTF-8 file — a local checkout
31
- problem, reported before anything is sent to the API.
32
- """
33
- files: dict[str, str] = {}
34
- for pattern in ("**/*.yml", "**/*.yaml"):
35
- for file in sorted(ontology_dir.glob(pattern)):
36
- if file.is_file():
37
- try:
38
- files[file.relative_to(ontology_dir).as_posix()] = file.read_text(encoding="utf-8")
39
- except (UnicodeDecodeError, OSError) as exc:
40
- typer.secho(f"Cannot read {file}: {exc}", fg=typer.colors.RED, err=True)
41
- raise typer.Exit(EXIT_USAGE) from exc
42
- return files
43
-
44
-
45
- def require_api_key(api_key: Optional[str]) -> str:
46
- """Exit 2 (usage) when no API key was provided."""
47
- if not api_key:
48
- typer.secho(
49
- "No API key. Set CASSIS_API_KEY or pass --api-key "
50
- "(create one in Cassis under Organization settings -> API keys).",
51
- fg=typer.colors.RED,
52
- err=True,
53
- )
54
- raise typer.Exit(EXIT_USAGE)
55
- return api_key
56
-
57
-
58
- def collect_tree(path: Path, base_path: str) -> "tuple[dict[str, str], str]":
59
- """Resolve the ontology dir under the checkout and read its YAML tree.
60
-
61
- Returns ``(files, normalized_base_path)``. Exits 2 (usage) on an empty or
62
- missing dir, or a tree beyond the API request ceilings — all local checkout
63
- problems, reported before anything is sent to the API.
64
- """
65
- base_path = base_path.strip().strip("/")
66
- if not base_path:
67
- typer.secho("--base-path must not be empty.", fg=typer.colors.RED, err=True)
68
- raise typer.Exit(EXIT_USAGE)
69
-
70
- ontology_dir = path / Path(base_path)
71
- if not ontology_dir.is_dir():
72
- typer.secho(
73
- f"No {base_path}/ directory found under {path}. "
74
- "If the project exports to a custom path, pass it with --base-path (or CASSIS_BASE_PATH).",
75
- fg=typer.colors.RED,
76
- err=True,
77
- )
78
- raise typer.Exit(EXIT_USAGE)
79
-
80
- files = collect_files(ontology_dir)
81
- if not files:
82
- typer.secho(f"No YAML files found under {ontology_dir}.", fg=typer.colors.RED, err=True)
83
- raise typer.Exit(EXIT_USAGE)
84
-
85
- total_bytes = sum(len(content.encode()) for content in files.values())
86
- if len(files) > MAX_FILES or total_bytes > MAX_TOTAL_BYTES:
87
- typer.secho(
88
- f"Ontology tree too large: {len(files)} files / {total_bytes / (1024 * 1024):.1f} MB "
89
- f"(limits: {MAX_FILES} files / {MAX_TOTAL_BYTES // (1024 * 1024)} MB). "
90
- "Check that --base-path points at the ontology directory, not a larger tree.",
91
- fg=typer.colors.RED,
92
- err=True,
93
- )
94
- raise typer.Exit(EXIT_USAGE)
95
- return files, base_path