feat(context): bind a connection profile to a project (#2251)

This commit is contained in:
Pin Hsu Chen
2026-05-13 13:28:21 +08:00
committed by GitHub
parent ba187b78c0
commit 41fbe411fd
13 changed files with 1151 additions and 37 deletions
+60 -8
View File
@@ -127,6 +127,55 @@ def _load_conn(
return {}
def _resolve_engine_profile(mdl: str | None) -> tuple[str | None, dict]:
"""Resolve the connection profile for project-context CLI commands.
Project detection is decoupled from ``--mdl`` shape: ``--mdl`` is a pure
"which MDL artifact to load" override, project context is determined
independently by walking up from the MDL path (when it's a real file)
AND from cwd. Active profile is reserved for the case where neither
discovery finds a project — preventing ``--mdl <base64>`` or
``--mdl /external.json`` from silently bypassing cwd's pin.
"""
from wren.profile import ( # noqa: PLC0415
get_active_profile,
resolve_profile_for_project,
)
project_path = _discover_project_for_engine(mdl)
if project_path is None:
return get_active_profile()
return resolve_profile_for_project(project_path)
def _discover_project_for_engine(mdl: str | None) -> Path | None:
"""Find the project root for engine commands. Returns ``None`` if no
project context exists anywhere (caller should fall back to active).
Resolution order:
1. If ``--mdl`` is a real file, walk up its directory tree looking
for ``wren_project.yml``. ``<project>/target/mdl.json`` is a build
default, not a contract — users may keep MDL elsewhere.
2. Otherwise (``--mdl`` is base64, points outside a project, or is
absent), discover from cwd.
"""
if mdl is not None:
mdl_path = Path(mdl).expanduser()
if mdl_path.exists() and mdl_path.is_file():
for parent in mdl_path.resolve().parents:
if (parent / "wren_project.yml").exists():
return parent
if parent == Path.home() or parent == parent.parent:
break
try:
from wren.context import discover_project_path # noqa: PLC0415
return discover_project_path()
except SystemExit:
return None
def _resolve_datasource(conn_dict: dict, explicit: str | None = None) -> str:
"""Return datasource from explicit arg or connection dict.
@@ -160,15 +209,15 @@ def _build_engine(
manifest_str = _load_manifest(_require_mdl(mdl))
# Try active profile when no explicit connection flags given
# Try project-pinned profile (or fall back to active) when no explicit
# connection flags given.
if not connection_info and not connection_file:
from wren.profile import ( # noqa: PLC0415
MissingSecretError,
expand_profile_secrets,
get_active_profile,
)
prof_name, prof_dict = get_active_profile()
prof_name, prof_dict = _resolve_engine_profile(mdl)
if prof_dict:
prof_ds = prof_dict.pop("datasource", None)
ds_str = datasource or prof_ds
@@ -419,15 +468,18 @@ def dry_plan(
manifest_str = _load_manifest(_require_mdl(mdl))
# Try active profile when no explicit flags given
# Try project-pinned profile (or fall back to active) when no explicit
# connection flags given.
if datasource is None and connection_file is None:
from wren.profile import get_active_profile # noqa: PLC0415
_prof_name, prof_dict = get_active_profile()
_prof_name, prof_dict = _resolve_engine_profile(mdl)
if prof_dict:
prof_ds = prof_dict.pop("datasource", None)
if prof_ds is None:
typer.echo("Error: no datasource in active profile.", err=True)
typer.echo(
"Error: no datasource in resolved profile "
"(project-pinned or active).",
err=True,
)
raise typer.Exit(1)
try:
ds = DataSource(prof_ds.lower())
+46 -12
View File
@@ -13,7 +13,7 @@ import yaml
_WREN_HOME = Path(os.environ.get("WREN_HOME", Path.home() / ".wren"))
_DEFAULT_PROJECT = _WREN_HOME / "project"
_PROJECT_FILE = "wren_project.yml"
PROJECT_FILE = "wren_project.yml"
_TARGET_DIR = "target"
_TARGET_FILE = "mdl.json"
@@ -355,7 +355,7 @@ def discover_project_path(explicit: str | None = None) -> Path:
# 3. Walk up from cwd looking for wren_project.yml
current = Path.cwd()
for parent in [current, *current.parents]:
if (parent / _PROJECT_FILE).exists():
if (parent / PROJECT_FILE).exists():
return parent
# Stop at home or root
if parent == Path.home() or parent == parent.parent:
@@ -375,12 +375,46 @@ def discover_project_path(explicit: str | None = None) -> Path:
def load_project_config(project_path: Path) -> dict:
"""Load wren_project.yml and return as dict."""
config_file = project_path / _PROJECT_FILE
config_file = project_path / PROJECT_FILE
if not config_file.exists():
return {}
return yaml.safe_load(config_file.read_text()) or {}
# Field order preferred when writing wren_project.yml back from a dict —
# keeps the file readable for humans even after CLI commands rewrite it.
# Anything not in this list is appended in dict-iteration order at the end.
_PROJECT_FIELD_ORDER = (
"schema_version",
"name",
"version",
"catalog",
"schema",
"data_source",
"profile",
)
def save_project_config(project_path: Path, config: dict) -> None:
"""Write ``wren_project.yml`` with a stable field ordering.
Drops YAML comments — round-tripping with comments would require
``ruamel.yaml`` which isn't a dependency. The file is rewritten by
set-profile / upgrade-style commands; comments are init-template only.
"""
ordered: dict = {}
for key in _PROJECT_FIELD_ORDER:
if key in config:
ordered[key] = config[key]
for key, value in config.items():
if key not in ordered:
ordered[key] = value
(project_path / PROJECT_FILE).write_text(
yaml.safe_dump(ordered, default_flow_style=False, sort_keys=False)
)
_SUPPORTED_SCHEMA_VERSIONS = {1, 2, 3}
# schema_version → layoutVersion mapping for the engine
@@ -419,7 +453,7 @@ def get_schema_version(project_path: Path) -> int:
return int(raw)
except (TypeError, ValueError):
raise SystemExit(
f"Error: invalid schema_version {raw!r} in {_PROJECT_FILE}. Expected an integer."
f"Error: invalid schema_version {raw!r} in {PROJECT_FILE}. Expected an integer."
)
@@ -428,7 +462,7 @@ def require_schema_version(project_path: Path) -> int:
sv = get_schema_version(project_path)
if sv not in _SUPPORTED_SCHEMA_VERSIONS:
raise SystemExit(
f"Error: unsupported schema_version {sv} in {_PROJECT_FILE}. "
f"Error: unsupported schema_version {sv} in {PROJECT_FILE}. "
"Please upgrade wren CLI."
)
return sv
@@ -658,7 +692,7 @@ def validate_project(project_path: Path) -> list[ValidationError]:
if not config:
errors.append(
ValidationError(
"error", _PROJECT_FILE, f"'{_PROJECT_FILE}' not found or empty"
"error", PROJECT_FILE, f"'{PROJECT_FILE}' not found or empty"
)
)
else:
@@ -666,7 +700,7 @@ def validate_project(project_path: Path) -> list[ValidationError]:
if not config.get(required):
errors.append(
ValidationError(
"error", _PROJECT_FILE, f"missing required field '{required}'"
"error", PROJECT_FILE, f"missing required field '{required}'"
)
)
raw_sv = config.get("schema_version", 1)
@@ -676,7 +710,7 @@ def validate_project(project_path: Path) -> list[ValidationError]:
errors.append(
ValidationError(
"error",
_PROJECT_FILE,
PROJECT_FILE,
f"schema_version must be an integer, got {raw_sv!r}",
)
)
@@ -685,12 +719,12 @@ def validate_project(project_path: Path) -> list[ValidationError]:
errors.append(
ValidationError(
"error",
_PROJECT_FILE,
PROJECT_FILE,
f"unsupported schema_version {sv} — please upgrade wren CLI",
)
)
if any(e.path == _PROJECT_FILE and "schema_version" in e.message for e in errors):
if any(e.path == PROJECT_FILE and "schema_version" in e.message for e in errors):
return errors
# Load data (snake_case)
@@ -979,7 +1013,7 @@ def plan_upgrade(
to_version=target,
files_created=files_created,
files_deleted=files_deleted,
files_modified=[_PROJECT_FILE],
files_modified=[PROJECT_FILE],
)
@@ -1036,7 +1070,7 @@ def apply_upgrade(project_path: Path, result: UpgradeResult) -> None:
# Update wren_project.yml
config = load_project_config(project_path)
config["schema_version"] = result.to_version
config_file = project_path / _PROJECT_FILE
config_file = project_path / PROJECT_FILE
config_file.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
+138 -1
View File
@@ -90,7 +90,9 @@ def init(
return
# ── Scaffold empty project (existing behavior) ────────────
project_file = project_path / "wren_project.yml"
from wren.context import PROJECT_FILE # noqa: PLC0415
project_file = project_path / PROJECT_FILE
agents_file = project_path / "AGENTS.md"
queries_file = project_path / "queries.yml"
conflicts = [f for f in (project_file, agents_file, queries_file) if f.exists()]
@@ -288,6 +290,7 @@ def validate(
# ── Semantic validation (dry-plan + description checks) ──────────────
sem_errors: list[str] = []
sem_warnings: list[str] = []
config: dict = {}
try:
config = load_project_config(project_path)
ds_str = config.get("data_source", "")
@@ -301,6 +304,30 @@ def validate(
except Exception as e:
sem_errors = [f"Semantic validation failed: {e}"]
# ── Profile binding check ─────────────────────────────────────────────
# Pinned profile that no longer exists → warning (or error in --strict).
# No pin at all → friendly info hint pointing to `set-profile`.
profile_pin = config.get("profile") if isinstance(config, dict) else None
if isinstance(profile_pin, str) and profile_pin.strip():
# Guard the lookup: if profiles.yml itself is unreadable / malformed,
# the user shouldn't see a raw traceback — surface it as a warning so
# validate can still report the rest.
try:
from wren.profile import list_profiles # noqa: PLC0415
registered = list_profiles()
except Exception as profile_exc:
sem_warnings.append(
f"could not check pinned profile '{profile_pin}': {profile_exc}"
)
else:
if profile_pin.strip() not in registered:
sem_warnings.append(
f"project pins profile '{profile_pin}' but it doesn't "
"exist in ~/.wren/profiles.yml. "
"Run `wren context set-profile <name>` to rebind."
)
if sem_errors:
typer.echo("\nSemantic errors:")
for msg in sem_errors:
@@ -313,6 +340,21 @@ def validate(
has_hard_error = bool(struct_hard or sem_errors)
has_warning = bool(all_warnings)
# No-pin info hint — surface whenever validation has no hard errors,
# regardless of warning count. Gating it on a pristine project (the old
# behavior) hid the nudge from the users most likely to need it: anyone
# actively working through warnings. Placed BEFORE the exit raise so the
# hint is still visible under --strict (where warnings become exit 1).
# Hard errors still suppress so error output stays focused on the blocker.
no_pin = not (isinstance(profile_pin, str) and profile_pin.strip())
if no_pin and not has_hard_error:
typer.echo(
"\nNote: no profile bound to this project. Connection will fall "
"back to the\n"
" globally active profile in ~/.wren/profiles.yml.\n"
" Run `wren context set-profile <name>` to pin one explicitly."
)
if has_hard_error or (strict and has_warning):
raise typer.Exit(1)
@@ -521,6 +563,101 @@ def instructions(
typer.echo(content)
@context_app.command(name="set-profile")
def set_profile(
name: Annotated[str, typer.Argument(help="Profile name to bind to this project.")],
path: ProjectPathOpt = None,
) -> None:
"""Bind a connection profile to this project.
Writes ``profile: <name>`` and ``data_source: <profile.datasource>`` into
``wren_project.yml``. Future CLI commands and the SDK use the bound
profile regardless of which profile is globally active.
"""
from wren.context import ( # noqa: PLC0415
discover_project_path,
load_project_config,
save_project_config,
)
from wren.profile import list_profiles # noqa: PLC0415
try:
project_path = discover_project_path(path)
except SystemExit as e:
typer.echo(str(e), err=True)
raise typer.Exit(1)
# discover_project_path() with explicit --path returns it un-checked, so
# confirm the project actually exists before binding a profile to nothing.
from wren.context import PROJECT_FILE # noqa: PLC0415
if not (project_path / PROJECT_FILE).exists():
typer.echo(
f"Error: no {PROJECT_FILE} found at {project_path}.\n"
" Run `wren context init` to scaffold a project first.",
err=True,
)
raise typer.Exit(1)
try:
profiles = list_profiles()
except Exception as exc:
typer.echo(
f"Error: could not read ~/.wren/profiles.yml: {exc}",
err=True,
)
raise typer.Exit(1)
if name not in profiles:
avail = ", ".join(sorted(profiles)) or "(none)"
typer.echo(
f"Error: profile '{name}' not found in ~/.wren/profiles.yml.\n"
f" Available profiles: {avail}\n"
f" Run `wren profile add {name} --datasource <ds>` to create it.",
err=True,
)
raise typer.Exit(1)
new_ds = profiles[name].get("datasource")
if not new_ds:
typer.echo(
f"Error: profile '{name}' has no datasource field. "
"Edit ~/.wren/profiles.yml or recreate it via `wren profile add`.",
err=True,
)
raise typer.Exit(1)
config = load_project_config(project_path)
old_ds = config.get("data_source")
config["profile"] = name
config["data_source"] = new_ds
try:
save_project_config(project_path, config)
except OSError as exc:
typer.echo(
f"Error: could not write {project_path / PROJECT_FILE}: {exc}",
err=True,
)
raise typer.Exit(1)
project_name = config.get("name") or "<unnamed>"
typer.echo(f"✓ Bound profile '{name}' to project {project_name}")
typer.echo(f" profile: {name}")
if old_ds and old_ds != new_ds:
typer.echo(f" data_source: {old_ds} -> {new_ds}")
else:
typer.echo(f" data_source: {new_ds}")
# Stale-MDL warning: if datasource changed AND a built manifest already
# exists, it was emitted for the previous dialect and queries will break
# against the new connection until the user rebuilds.
if old_ds and old_ds != new_ds and (project_path / "target" / "mdl.json").exists():
typer.echo(
f"\n⚠ MDL was built for {old_ds}. Run `wren context build` "
"to regenerate before querying."
)
@context_app.command()
def upgrade(
path: ProjectPathOpt = None,
+50
View File
@@ -214,6 +214,56 @@ def get_active_profile() -> tuple[str | None, dict]:
return name, dict(profiles.get(name, {}))
def resolve_profile_for_project(project_path: Path) -> tuple[str | None, dict]:
"""Resolve the connection profile for a given project.
Resolution order:
1. ``profile:`` field in ``<project>/wren_project.yml`` (if non-empty)
2. Global active profile in ``~/.wren/profiles.yml``
Returns ``(name, profile_dict)``. Returns ``(None, {})`` if neither a
project pin nor a global active profile is set.
Raises ``SystemExit`` when the project pins a profile name that does not
exist in profiles.yml — fail loudly because the user explicitly bound
a profile that's no longer there.
"""
project_yml = project_path / "wren_project.yml"
pinned_name: str | None = None
if project_yml.exists():
try:
config = yaml.safe_load(project_yml.read_text()) or {}
except yaml.YAMLError as exc:
# Fail loudly: a malformed project file shouldn't silently fall
# back to the global active profile — that risks running against
# the wrong database.
raise SystemExit(
f"Error: invalid YAML in {project_yml}: {exc}\n"
" Fix the file or run `wren context init --force` to "
"rescaffold."
) from exc
if isinstance(config, dict):
value = config.get("profile")
if isinstance(value, str) and value.strip():
pinned_name = value.strip()
if pinned_name is None:
return get_active_profile()
data = _load_raw()
profiles = data.get("profiles", {})
if pinned_name not in profiles:
available = ", ".join(sorted(profiles)) or "(none)"
raise SystemExit(
f"Error: project pins profile '{pinned_name}' but it doesn't exist "
f"in {_PROFILES_FILE}.\n"
f"Available profiles: {available}.\n"
"Run `wren context set-profile <name>` to rebind, or "
f"`wren profile add {pinned_name}` to recreate the missing profile."
)
return pinned_name, dict(profiles[pinned_name])
def add_profile(name: str, profile: dict, *, activate: bool = False) -> None:
"""Add or overwrite a named profile."""
data = _load_raw()
+88
View File
@@ -145,6 +145,94 @@ def test_resolve_no_profile():
assert conn == {}
# ── resolve_profile_for_project ───────────────────────────────────────────────
def _write_project(project_path: Path, **fields) -> None:
"""Write a minimal wren_project.yml with the given fields."""
project_path.mkdir(parents=True, exist_ok=True)
config = {
"schema_version": 3,
"name": "test_proj",
"version": "1.0",
"catalog": "wren",
"schema": "public",
"data_source": "duckdb",
}
config.update(fields)
(project_path / "wren_project.yml").write_text(yaml.safe_dump(config))
def test_resolve_profile_for_project_uses_pinned_profile(tmp_path: Path):
profile_mod.add_profile("project_a", {"datasource": "duckdb", "path": "/a"})
profile_mod.add_profile("project_b", {"datasource": "postgres", "host": "x"})
profile_mod.switch_profile("project_a") # active != pinned
proj = tmp_path / "myproj"
_write_project(proj, profile="project_b")
name, prof = profile_mod.resolve_profile_for_project(proj)
assert name == "project_b"
assert prof["datasource"] == "postgres"
assert prof["host"] == "x"
def test_resolve_profile_for_project_falls_back_to_active(tmp_path: Path):
profile_mod.add_profile("active_one", {"datasource": "duckdb", "path": "/x"})
proj = tmp_path / "myproj"
_write_project(proj) # no `profile:` field
name, prof = profile_mod.resolve_profile_for_project(proj)
assert name == "active_one"
assert prof["datasource"] == "duckdb"
def test_resolve_profile_for_project_raises_when_pinned_missing(tmp_path: Path):
profile_mod.add_profile("real", {"datasource": "duckdb"})
proj = tmp_path / "myproj"
_write_project(proj, profile="ghost") # ghost doesn't exist
with pytest.raises(SystemExit) as exc:
profile_mod.resolve_profile_for_project(proj)
assert "ghost" in str(exc.value)
assert "real" in str(exc.value) # available profiles listed
def test_resolve_profile_for_project_returns_empty_when_no_pin_no_active(
tmp_path: Path,
):
proj = tmp_path / "myproj"
_write_project(proj) # no profile field, no profiles.yml setup
name, prof = profile_mod.resolve_profile_for_project(proj)
assert name is None
assert prof == {}
def test_resolve_profile_for_project_raises_on_malformed_yaml(tmp_path: Path):
"""A broken wren_project.yml should fail loudly, not silently fall back to
the global active profile — the latter risks targeting the wrong DB."""
proj = tmp_path / "myproj"
proj.mkdir()
(proj / "wren_project.yml").write_text("schema_version: 3\nname: [unclosed\n")
with pytest.raises(SystemExit) as exc:
profile_mod.resolve_profile_for_project(proj)
msg = str(exc.value).lower()
assert "wren_project.yml" in msg or "yaml" in msg
def test_resolve_profile_for_project_treats_empty_profile_field_as_unset(
tmp_path: Path,
):
profile_mod.add_profile("active_one", {"datasource": "duckdb"})
proj = tmp_path / "myproj"
_write_project(proj, profile="") # explicitly empty
name, prof = profile_mod.resolve_profile_for_project(proj)
# Should fall back to active, not error on empty pin
assert name == "active_one"
# ── Round-trip persistence ────────────────────────────────────────────────────
@@ -0,0 +1,196 @@
"""Unit tests for cli._resolve_engine_profile — the project-aware profile lookup
used by dry-plan and dry-run. Behavior is backward-compatible when a project
has no `profile:` field (falls back to global active)."""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
import wren.profile as profile_mod
from wren.cli import _resolve_engine_profile
@pytest.fixture(autouse=True)
def isolated_profiles(tmp_path, monkeypatch):
"""Redirect all profile I/O to a temp directory."""
profiles_file = tmp_path / "profiles.yml"
monkeypatch.setattr(profile_mod, "_WREN_HOME", tmp_path)
monkeypatch.setattr(profile_mod, "_PROFILES_FILE", profiles_file)
return profiles_file
def _write_project(project_dir: Path, **fields) -> Path:
project_dir.mkdir(parents=True, exist_ok=True)
config = {
"schema_version": 3,
"name": "test_proj",
"version": "1.0",
"catalog": "wren",
"schema": "public",
"data_source": "duckdb",
}
config.update(fields)
(project_dir / "wren_project.yml").write_text(yaml.safe_dump(config))
target = project_dir / "target"
target.mkdir(exist_ok=True)
mdl = target / "mdl.json"
mdl.write_text("{}")
return mdl
def test_resolve_engine_profile_prefers_project_pin_when_mdl_given(
tmp_path, monkeypatch
):
"""--mdl <project>/target/mdl.json + project pins profile B → returns B."""
profile_mod.add_profile("active_a", {"datasource": "duckdb", "path": "/a"})
profile_mod.add_profile("pinned_b", {"datasource": "postgres", "host": "b"})
# active is A but project pins B
profile_mod.switch_profile("active_a")
mdl = _write_project(tmp_path / "myproj", profile="pinned_b")
name, prof = _resolve_engine_profile(str(mdl))
assert name == "pinned_b"
assert prof["datasource"] == "postgres"
def test_resolve_engine_profile_falls_back_to_active_when_no_pin(
tmp_path, monkeypatch
):
"""No `profile:` field → falls back to global active."""
profile_mod.add_profile("active_only", {"datasource": "duckdb"})
mdl = _write_project(tmp_path / "myproj") # no profile field
name, prof = _resolve_engine_profile(str(mdl))
assert name == "active_only"
assert prof["datasource"] == "duckdb"
def test_resolve_engine_profile_uses_cwd_when_mdl_none(tmp_path, monkeypatch):
"""No --mdl → discover from cwd."""
profile_mod.add_profile("via_cwd", {"datasource": "mysql", "host": "y"})
profile_mod.add_profile("global_active", {"datasource": "postgres"})
profile_mod.switch_profile("global_active")
proj = tmp_path / "myproj"
_write_project(proj, profile="via_cwd")
monkeypatch.chdir(proj)
name, prof = _resolve_engine_profile(None)
assert name == "via_cwd"
assert prof["datasource"] == "mysql"
def test_resolve_engine_profile_falls_back_when_no_project_at_cwd(
tmp_path, monkeypatch
):
"""No --mdl and cwd not in any project → global active."""
profile_mod.add_profile("only", {"datasource": "duckdb"})
monkeypatch.chdir(tmp_path) # tmp_path has no wren_project.yml
name, prof = _resolve_engine_profile(None)
assert name == "only"
def test_resolve_engine_profile_falls_back_when_mdl_not_a_file(tmp_path):
"""--mdl is a base64 string (not a file path) → cannot resolve project,
falls back to global active without crashing."""
profile_mod.add_profile("only", {"datasource": "duckdb"})
name, prof = _resolve_engine_profile("base64stringthatisnotapath==")
assert name == "only"
def test_resolve_engine_profile_raises_when_pinned_profile_missing(
tmp_path, monkeypatch
):
"""Project pins a profile that doesn't exist → SystemExit (loud failure)."""
profile_mod.add_profile("real", {"datasource": "duckdb"})
mdl = _write_project(tmp_path / "myproj", profile="ghost")
with pytest.raises(SystemExit):
_resolve_engine_profile(str(mdl))
def test_resolve_engine_profile_uses_cwd_pin_when_mdl_is_base64(
tmp_path, monkeypatch
):
"""--mdl as a base64 string must NOT silently bypass cwd's project pin —
that would re-introduce the silent-mismatch problem this PR is closing."""
profile_mod.add_profile("active_one", {"datasource": "postgres"})
profile_mod.add_profile("cwd_pin", {"datasource": "duckdb"})
profile_mod.switch_profile("active_one")
proj = tmp_path / "myproj"
_write_project(proj, profile="cwd_pin")
monkeypatch.chdir(proj)
name, prof = _resolve_engine_profile("base64stringthatisnotapath==")
assert name == "cwd_pin"
assert prof["datasource"] == "duckdb"
def test_resolve_engine_profile_uses_cwd_pin_when_mdl_outside_project(
tmp_path, monkeypatch
):
"""--mdl pointing to a file outside any project must still let cwd's
pin win, so users can test external MDL artifacts against the bound DB."""
profile_mod.add_profile("active_one", {"datasource": "postgres"})
profile_mod.add_profile("cwd_pin", {"datasource": "duckdb"})
profile_mod.switch_profile("active_one")
proj = tmp_path / "myproj"
_write_project(proj, profile="cwd_pin")
monkeypatch.chdir(proj)
external = tmp_path / "external.json"
external.write_text("{}")
name, prof = _resolve_engine_profile(str(external))
assert name == "cwd_pin"
def test_resolve_engine_profile_walks_up_from_nonstandard_mdl_layout(
tmp_path, monkeypatch
):
"""--mdl doesn't have to sit at <project>/target/mdl.json. Anywhere
inside the project tree should resolve via walk-up — the current
parent.parent shortcut hard-codes a layout that's just a build default."""
# Use distinct active vs pinned profiles so the test actually exercises
# walk-up rather than vacuously matching whatever active happens to be.
profile_mod.add_profile("not_this_one", {"datasource": "postgres"})
profile_mod.add_profile("via_walk_up", {"datasource": "duckdb"})
profile_mod.switch_profile("not_this_one")
proj = tmp_path / "myproj"
proj.mkdir()
import yaml as _yaml # noqa: PLC0415
(proj / "wren_project.yml").write_text(
_yaml.safe_dump(
{
"schema_version": 3,
"name": "test_proj",
"version": "1.0",
"catalog": "wren",
"schema": "public",
"data_source": "duckdb",
"profile": "via_walk_up",
}
)
)
# MDL several levels deep, not at <proj>/target/mdl.json
deep = proj / "build" / "dist" / "artifacts"
deep.mkdir(parents=True)
mdl = deep / "manifest.json"
mdl.write_text("{}")
name, prof = _resolve_engine_profile(str(mdl))
assert name == "via_walk_up", (
"Walk-up didn't find wren_project.yml; resolver still relies on the "
"parent.parent shortcut."
)
+488
View File
@@ -347,3 +347,491 @@ def test_upgrade_cli_explicit_to_version(tmp_path):
config = yaml.safe_load((tmp_path / "wren_project.yml").read_text())
assert config["schema_version"] == 2
# ── wren context set-profile ──────────────────────────────────────────────
def _isolate_profiles(home_dir: Path, monkeypatch) -> None:
"""Redirect ~/.wren profile I/O to ``home_dir`` for the duration of a test."""
import wren.profile as profile_mod # noqa: PLC0415
home_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(profile_mod, "_WREN_HOME", home_dir)
monkeypatch.setattr(profile_mod, "_PROFILES_FILE", home_dir / "profiles.yml")
def _invoke_ok(args):
"""Run the CLI and assert exit_code 0. Use for setup invocations so a
failed scaffold/init is surfaced immediately instead of masking the
real assertion failure later in the test."""
result = runner.invoke(app, args)
assert result.exit_code == 0, result.output
return result
def test_set_profile_writes_profile_field(tmp_path, monkeypatch):
import wren.profile as profile_mod # noqa: PLC0415
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
profile_mod.add_profile("loans_local", {"datasource": "duckdb"})
proj = tmp_path / "myproj"
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
result = runner.invoke(
app, ["context", "set-profile", "loans_local", "--path", str(proj)]
)
assert result.exit_code == 0, result.output
import yaml # noqa: PLC0415
config = yaml.safe_load((proj / "wren_project.yml").read_text())
assert config["profile"] == "loans_local"
assert config["data_source"] == "duckdb"
def test_set_profile_overwrites_placeholder_data_source(tmp_path, monkeypatch):
"""init writes `data_source: postgres` placeholder; set-profile overwrites it
with the bound profile's datasource (no --force needed for first bind)."""
import wren.profile as profile_mod # noqa: PLC0415
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
profile_mod.add_profile("duck_one", {"datasource": "duckdb"})
proj = tmp_path / "myproj"
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
result = runner.invoke(
app, ["context", "set-profile", "duck_one", "--path", str(proj)]
)
assert result.exit_code == 0, result.output
import yaml # noqa: PLC0415
config = yaml.safe_load((proj / "wren_project.yml").read_text())
assert config["data_source"] == "duckdb"
def test_set_profile_rebind_overwrites(tmp_path, monkeypatch):
"""Re-binding from X to Y: both profile and data_source update."""
import wren.profile as profile_mod # noqa: PLC0415
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
profile_mod.add_profile("X", {"datasource": "postgres"})
profile_mod.add_profile("Y", {"datasource": "duckdb"})
proj = tmp_path / "myproj"
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
_invoke_ok(["context", "set-profile", "X", "--path", str(proj)])
result = runner.invoke(
app, ["context", "set-profile", "Y", "--path", str(proj)]
)
assert result.exit_code == 0, result.output
import yaml # noqa: PLC0415
config = yaml.safe_load((proj / "wren_project.yml").read_text())
assert config["profile"] == "Y"
assert config["data_source"] == "duckdb"
def test_set_profile_errors_when_profile_not_found(tmp_path, monkeypatch):
import wren.profile as profile_mod # noqa: PLC0415
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
profile_mod.add_profile("real", {"datasource": "duckdb"})
proj = tmp_path / "myproj"
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
result = runner.invoke(
app, ["context", "set-profile", "ghost", "--path", str(proj)]
)
assert result.exit_code != 0
assert "ghost" in result.output
assert "real" in result.output # available profiles listed in error
def test_set_profile_errors_cleanly_when_list_profiles_fails(
tmp_path, monkeypatch
):
"""If list_profiles() raises (e.g. malformed profiles.yml), set-profile
should exit cleanly with an error message — not crash with a traceback."""
proj = tmp_path / "myproj"
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
import wren.profile as profile_mod # noqa: PLC0415
def _broken(*_args, **_kwargs):
raise OSError("simulated permission denied")
monkeypatch.setattr(profile_mod, "list_profiles", _broken)
result = runner.invoke(
app, ["context", "set-profile", "anything", "--path", str(proj)]
)
assert result.exit_code != 0
assert not isinstance(result.exception, OSError), (
f"OSError leaked from list_profiles(): {result.exception!r}"
)
def test_set_profile_errors_cleanly_when_save_fails(tmp_path, monkeypatch):
"""If save_project_config() fails (disk full / permission denied),
set-profile should exit with a clean error rather than a traceback."""
import wren.profile as profile_mod # noqa: PLC0415
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
profile_mod.add_profile("real", {"datasource": "duckdb"})
proj = tmp_path / "myproj"
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
import wren.context as context_mod # noqa: PLC0415
def _broken_save(*_args, **_kwargs):
raise OSError("disk full")
monkeypatch.setattr(context_mod, "save_project_config", _broken_save)
result = runner.invoke(
app, ["context", "set-profile", "real", "--path", str(proj)]
)
assert result.exit_code != 0
assert not isinstance(result.exception, OSError), (
f"OSError leaked from save_project_config(): {result.exception!r}"
)
def test_set_profile_errors_when_profile_has_no_datasource(tmp_path, monkeypatch):
"""The third validation gate in set_profile — profile exists but has
no datasource field — exits non-zero with a helpful message."""
import wren.profile as profile_mod # noqa: PLC0415
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
profile_mod.add_profile("incomplete", {}) # no datasource key
proj = tmp_path / "myproj"
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
result = runner.invoke(
app, ["context", "set-profile", "incomplete", "--path", str(proj)]
)
assert result.exit_code != 0
assert "datasource" in result.output.lower()
def test_set_profile_errors_when_no_project(tmp_path, monkeypatch):
import wren.profile as profile_mod # noqa: PLC0415
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
profile_mod.add_profile("real", {"datasource": "duckdb"})
empty_dir = tmp_path / "no-project"
empty_dir.mkdir()
result = runner.invoke(
app, ["context", "set-profile", "real", "--path", str(empty_dir)]
)
assert result.exit_code != 0
assert "wren_project.yml" in result.output
def test_set_profile_preserves_other_fields(tmp_path, monkeypatch):
"""Binding doesn't touch unrelated fields (name, catalog, schema, schema_version)."""
import wren.profile as profile_mod # noqa: PLC0415
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
profile_mod.add_profile("duck", {"datasource": "duckdb"})
proj = tmp_path / "myproj"
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
result = runner.invoke(
app, ["context", "set-profile", "duck", "--path", str(proj)]
)
assert result.exit_code == 0, result.output
import yaml # noqa: PLC0415
config = yaml.safe_load((proj / "wren_project.yml").read_text())
assert config["name"] == "my_project"
assert config["catalog"] == "wren"
assert config["schema"] == "public"
assert config["schema_version"] == 3
def test_set_profile_preserves_custom_fields(tmp_path, monkeypatch):
"""Unknown / custom fields in wren_project.yml must survive set-profile.
save_project_config appends out-of-order keys at the end; this test
locks that contract so a future shuffle of _PROJECT_FIELD_ORDER can't
silently drop user-added metadata."""
import wren.profile as profile_mod # noqa: PLC0415
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
profile_mod.add_profile("duck", {"datasource": "duckdb"})
proj = tmp_path / "myproj"
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
# Inject a custom field the CLI doesn't know about
import yaml # noqa: PLC0415
config = yaml.safe_load((proj / "wren_project.yml").read_text())
config["tags"] = ["analytics", "experimental"]
config["owner"] = "data-platform"
(proj / "wren_project.yml").write_text(yaml.safe_dump(config))
result = runner.invoke(
app, ["context", "set-profile", "duck", "--path", str(proj)]
)
assert result.exit_code == 0, result.output
# Round-trip: custom keys survive the rewrite
config_after = yaml.safe_load((proj / "wren_project.yml").read_text())
assert config_after["tags"] == ["analytics", "experimental"]
assert config_after["owner"] == "data-platform"
# And the binding fields landed correctly
assert config_after["profile"] == "duck"
assert config_after["data_source"] == "duckdb"
def test_set_profile_warns_about_stale_mdl_when_datasource_changes(
tmp_path, monkeypatch
):
"""Re-binding to a profile with a different datasource leaves
target/mdl.json built for the old dialect. Surface that risk so the
user knows to rebuild before querying."""
import wren.profile as profile_mod # noqa: PLC0415
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
profile_mod.add_profile("ds_pg", {"datasource": "postgres"})
profile_mod.add_profile("ds_duck", {"datasource": "duckdb"})
proj = tmp_path / "myproj"
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
_invoke_ok(["context", "set-profile", "ds_pg", "--path", str(proj)])
# Simulate that the user has built MDL against the previous dialect.
target = proj / "target"
target.mkdir(exist_ok=True)
(target / "mdl.json").write_text("{}")
result = runner.invoke(
app, ["context", "set-profile", "ds_duck", "--path", str(proj)]
)
assert result.exit_code == 0, result.output
assert "wren context build" in result.output
# Mention either the old dialect or the word 'rebuild'/'regenerate' so
# the warning context is clear.
msg = result.output.lower()
assert "postgres" in msg or "rebuild" in msg or "regenerate" in msg
def test_set_profile_no_stale_mdl_warning_when_datasource_unchanged(
tmp_path, monkeypatch
):
"""If datasource doesn't change on rebind, the stale-MDL warning
shouldn't appear — there's no actual stale state."""
import wren.profile as profile_mod # noqa: PLC0415
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
profile_mod.add_profile("a", {"datasource": "duckdb"})
profile_mod.add_profile("b", {"datasource": "duckdb"})
proj = tmp_path / "myproj"
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
_invoke_ok(["context", "set-profile", "a", "--path", str(proj)])
target = proj / "target"
target.mkdir(exist_ok=True)
(target / "mdl.json").write_text("{}")
result = runner.invoke(
app, ["context", "set-profile", "b", "--path", str(proj)]
)
assert result.exit_code == 0, result.output
assert "wren context build" not in result.output
def test_set_profile_prints_summary_with_arrow_when_data_source_changes(
tmp_path, monkeypatch
):
"""When binding overwrites data_source, summary shows the transition."""
import wren.profile as profile_mod # noqa: PLC0415
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
profile_mod.add_profile("duck", {"datasource": "duckdb"})
proj = tmp_path / "myproj"
_invoke_ok(["context", "init", "--empty", "--path", str(proj)])
result = runner.invoke(
app, ["context", "set-profile", "duck", "--path", str(proj)]
)
assert result.exit_code == 0, result.output
# init wrote postgres placeholder; we're binding duck (duckdb)
assert "postgres" in result.output
assert "duckdb" in result.output
# ── wren context validate — profile binding hint ──────────────────────────
def test_validate_hints_when_no_profile_bound(tmp_path, monkeypatch):
"""No `profile:` field → friendly info pointing to set-profile."""
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
_make_valid_project(tmp_path) # no profile field
result = runner.invoke(app, ["context", "validate", "--path", str(tmp_path)])
assert result.exit_code == 0, result.output
assert "set-profile" in result.output
# Wording should make it clear it's a hint, not an error.
assert "fall back" in result.output.lower() or "fallback" in result.output.lower()
def test_validate_warns_when_pinned_profile_missing(tmp_path, monkeypatch):
"""`profile: ghost` but ghost doesn't exist → warning, exit 0 without --strict."""
import wren.profile as profile_mod # noqa: PLC0415
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
profile_mod.add_profile("real", {"datasource": "postgres"})
_make_valid_project(tmp_path)
import yaml # noqa: PLC0415
config = yaml.safe_load((tmp_path / "wren_project.yml").read_text())
config["profile"] = "ghost"
(tmp_path / "wren_project.yml").write_text(yaml.safe_dump(config))
result = runner.invoke(app, ["context", "validate", "--path", str(tmp_path)])
assert result.exit_code == 0, result.output
assert "ghost" in result.output
def test_validate_no_profile_hint_when_correctly_bound(tmp_path, monkeypatch):
"""`profile: real` + real exists → no profile hint noise in output."""
import wren.profile as profile_mod # noqa: PLC0415
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
profile_mod.add_profile("real", {"datasource": "postgres"})
_make_valid_project(tmp_path)
import yaml # noqa: PLC0415
config = yaml.safe_load((tmp_path / "wren_project.yml").read_text())
config["profile"] = "real"
(tmp_path / "wren_project.yml").write_text(yaml.safe_dump(config))
result = runner.invoke(app, ["context", "validate", "--path", str(tmp_path)])
assert result.exit_code == 0, result.output
# No hint text triggered when binding is correct.
assert "set-profile" not in result.output
def test_validate_handles_list_profiles_exception(tmp_path, monkeypatch):
"""If list_profiles() raises (e.g. permission denied on profiles.yml),
validate must surface it as a warning, not crash with a raw traceback."""
_make_valid_project(tmp_path)
import yaml # noqa: PLC0415
config = yaml.safe_load((tmp_path / "wren_project.yml").read_text())
config["profile"] = "anything" # triggers the binding check
(tmp_path / "wren_project.yml").write_text(yaml.safe_dump(config))
import wren.profile as profile_mod # noqa: PLC0415
def _broken(*_args, **_kwargs):
raise OSError("simulated permission denied")
monkeypatch.setattr(profile_mod, "list_profiles", _broken)
result = runner.invoke(app, ["context", "validate", "--path", str(tmp_path)])
# The binding check must catch the exception internally — letting the
# OSError propagate would crash the CLI with a Python traceback in real
# use (CliRunner swallows it into result.exception, but real users see
# the traceback on stderr).
assert not isinstance(result.exception, OSError), (
f"OSError leaked from list_profiles(): {result.exception!r}\n"
"Wrap the binding check in try/except so validate degrades gracefully."
)
# Lock the contract: warning-only path exits 0 so users can pipe / script
# validate without a probe-failure becoming a hard failure.
assert result.exit_code == 0, result.output
# Validate should still surface the failure to the user somewhere visible.
assert "permission denied" in result.output.lower() or "anything" in result.output
# And under --strict the same warning becomes a hard error (exit 1) — keep
# both ends of the contract pinned so neither direction silently flips.
strict_result = runner.invoke(
app, ["context", "validate", "--path", str(tmp_path), "--strict"]
)
assert strict_result.exit_code == 1, strict_result.output
def test_validate_hint_shown_even_when_warnings_present(tmp_path, monkeypatch):
"""The no-pin hint should fire whenever the project lacks a binding,
not only on perfectly clean projects. A project with warnings still
benefits from the nudge — arguably more, since it's actively being
worked on."""
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
_make_valid_project(tmp_path)
# Inject a synthetic semantic warning without breaking the manifest.
# Using a real warning trigger (e.g. broken relationship) inevitably
# also surfaces a hard error, which would mask what we're testing.
from wren import context as context_mod # noqa: PLC0415
original = context_mod.validate_manifest
def _with_warning(*args, **kwargs):
result = original(*args, **kwargs)
result["warnings"] = list(result.get("warnings", [])) + [
"synthetic warning for hint test"
]
return result
monkeypatch.setattr(context_mod, "validate_manifest", _with_warning)
result = runner.invoke(app, ["context", "validate", "--path", str(tmp_path)])
# The hint must appear regardless of warning count — gating it on a
# pristine project hides it from the people who most need to see it.
assert "set-profile" in result.output
# And the warning we injected should still be visible (sanity check
# that we actually got into the warning-bearing code path).
assert "synthetic warning" in result.output
def test_validate_hint_suppressed_when_hard_errors(tmp_path, monkeypatch):
"""When validation has hard errors, the no-pin hint must not pile on
extra noise — the user should fix the real problem first."""
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
# Project missing data_source → hard error
(tmp_path / "wren_project.yml").write_text(
"schema_version: 2\nname: broken\n"
)
result = runner.invoke(app, ["context", "validate", "--path", str(tmp_path)])
assert result.exit_code == 1
# hint must not appear when there are hard errors
assert "set-profile" not in result.output
def test_validate_strict_fails_on_missing_pinned_profile(tmp_path, monkeypatch):
"""--strict treats the missing-pin warning as an error."""
import wren.profile as profile_mod # noqa: PLC0415
_isolate_profiles(tmp_path / "wren-home", monkeypatch)
profile_mod.add_profile("real", {"datasource": "postgres"})
_make_valid_project(tmp_path)
import yaml # noqa: PLC0415
config = yaml.safe_load((tmp_path / "wren_project.yml").read_text())
config["profile"] = "ghost"
(tmp_path / "wren_project.yml").write_text(yaml.safe_dump(config))
result = runner.invoke(
app, ["context", "validate", "--path", str(tmp_path), "--strict"]
)
assert result.exit_code == 1
assert "ghost" in result.output
+46 -2
View File
@@ -153,7 +153,51 @@ rm -rf models/example_model views/example_view
---
## Step 5 — Generate MDL from your database
## Step 5 — Bind the profile to your project
```bash
wren context set-profile <profile-name>
```
Writes both `profile: <name>` and `data_source: <ds>` (taken from the profile)
into `wren_project.yml`. Once bound, every CLI command and the SDK use this
profile for the project — independent of which profile is **globally active**
(`wren profile switch` elsewhere on the same machine never affects this
project's queries).
### Why bind explicitly
Without a binding, the CLI falls back to the globally active profile from
`~/.wren/profiles.yml`. That works for a single-project setup, but breaks the
moment you have two projects pointing at different databases — the active
profile inevitably becomes wrong for one of them. Binding closes that gap.
### When the binding mismatches
`set-profile` always overwrites the project's `data_source` from the profile,
so first-bind and rebind both work the same way:
```bash
$ wren context set-profile loans_local
✓ Bound profile 'loans_local' to project loans_proj
profile: loans_local
data_source: postgres -> duckdb # overwritten from profile
```
Re-binding to a profile with a different datasource than the project's
current MDL was built against will succeed, but the next `wren context build`
or query will fail with a dialect mismatch — that's the right time to
regenerate models with `wren-generate-mdl`.
### Inspecting and validating
`wren context validate` prints a friendly hint when no profile is bound, and
warns when the bound profile no longer exists in `~/.wren/profiles.yml`. The
hint disappears once a valid binding is in place.
---
## Step 6 — Generate MDL from your database
### With an AI agent (recommended)
@@ -231,7 +275,7 @@ wren context build
---
## Step 6 — Index memory and start querying
## Step 7 — Index memory and start querying
```bash
wren memory index
+2 -2
View File
@@ -22,7 +22,7 @@ This installs all four skills into your agent's skill directory:
| Skill | Purpose |
|-------|---------|
| **wren-onboarding** | Entry point — handles install, project scaffolding, profile setup, first query |
| **wren-onboarding** | Entry point — handles install, `.env` setup, profile creation, project scaffolding, profile binding, first query |
| **wren-generate-mdl** | Schema discovery and MDL project generation from a connected database |
| **wren-usage** | Day-to-day query workflow — context, recall, SQL, store results |
| **wren-dlt-connector** | Connect SaaS APIs (HubSpot, Stripe, Salesforce, GitHub, Slack, …) into DuckDB via dlt |
@@ -31,7 +31,7 @@ Then **start a new agent session** (skills are loaded at session start) and ask:
> Use the `wren-onboarding` skill to install and set up Wren AI Core.
The `wren-onboarding` skill drives the rest of the setup — environment checks, project scaffolding, data source connection via `.env`, and a first query — and dispatches to the other skills as needed. Skill source: [github.com/Canner/WrenAI/tree/main/skills](https://github.com/Canner/WrenAI/tree/main/skills)
The `wren-onboarding` skill drives the rest of the setup — environment checks, `.env` configuration, connection profile creation, project scaffolding, binding the profile to the project, and a first query — and dispatches to the other skills as needed. Skill source: [github.com/Canner/WrenAI/tree/main/skills](https://github.com/Canner/WrenAI/tree/main/skills)
See the [Skills reference](../reference/skills.md) for what each skill does in detail.
+8
View File
@@ -180,6 +180,14 @@ The generated `wren_project.yml` contains default values for `catalog` and `sche
> **Note:** `catalog` and `schema` in `wren_project.yml` define the **Wren AI Core namespace** — they have nothing to do with your database's catalog or schema. Keep the defaults (`wren` / `public`). The actual database location of each table is specified per-model in the `table_reference` section.
Bind the profile you just created to this project:
```bash
wren context set-profile jaffle-shop
```
This writes `profile: jaffle-shop` and `data_source: duckdb` into `wren_project.yml`, locking this project to its connection. Future commands (and the SDK) use the bound profile regardless of which profile is globally active — so `wren profile switch` elsewhere can't accidentally redirect this project's queries.
---
## Step 6 — Generate MDL with Claude Code
+2 -2
View File
@@ -7,8 +7,8 @@
"skills": [
{
"name": "wren-onboarding",
"version": "2.0",
"description": "Onboard a user to Wren Engine end-to-end. Walks the user through environment checks, project scaffolding, connection configuration via .env, and first query. Defers procedural details, per-datasource notes, and the troubleshooting playbook to docs/get_started/connect.md so the skill stays focused on agent-side rules and routing. Use when the user wants to install Wren Engine, set up a new data source connection, or bootstrap a new project from scratch.",
"version": "2.1",
"description": "Onboard a user to Wren Engine end-to-end. Walks the user through environment checks, .env configuration, connection profile creation, project scaffolding, binding the profile to the project, and first query. Defers procedural details, per-datasource notes, and the troubleshooting playbook to docs/core/get_started/connect.md so the skill stays focused on agent-side rules and routing. Use when the user wants to install Wren Engine, set up a new data source connection, or bootstrap a new project from scratch.",
"tags": [
"wren",
"onboarding",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"wren-dlt-connector": "1.0",
"wren-generate-mdl": "2.2",
"wren-onboarding": "2.0",
"wren-onboarding": "2.1",
"wren-usage": "2.2"
}
+26 -9
View File
@@ -4,7 +4,7 @@ description: "Onboard a user to Wren Engine end-to-end. Walks through environmen
license: Apache-2.0
metadata:
author: wren-engine
version: "2.0"
version: "2.1"
---
# Wren Onboarding — Agent Workflow
@@ -67,16 +67,15 @@ These two are the only thing Step 2 needs; ask both together so the user has a c
Wait for both. Don't ask for credentials.
## Step 2 — Project setup (batch)
## Step 2 — Workspace + .env setup (batch)
Side effects: creates `~/<project>/`, installs `wren-engine[<ds>,main]`, scaffolds project files, writes an empty `.env` template.
Side effects: creates `~/<project>/`, installs `wren-engine[<ds>,main]`, generates an empty `.env` template. The project files (`wren_project.yml` etc.) come later in Step 3.5 — at this point we only have a directory with credentials waiting to be filled.
Run as a batch — report each command briefly, then end with one "please fill `.env`" ask:
1. `mkdir -p ~/<project> && cd ~/<project>`. Refuse to overwrite an existing `wren_project.yml`.
1. `mkdir -p ~/<project> && cd ~/<project>`.
2. `pip install "wren-engine[<ds>,main]"`. For datasource-specific install gotchas (macOS mysql, etc.), see [`connect.md#per-datasource-setup-notes`](https://github.com/Canner/wren-engine/blob/main/docs/get_started/connect.md).
3. `wren context init --empty` to scaffold without placeholder examples. Edit `wren_project.yml` to set `data_source: <ds>`.
4. **Generate the `.env` template by introspecting the connector**:
3. **Generate the `.env` template by introspecting the connector**:
```bash
wren docs connection-info <ds> --format md
@@ -94,8 +93,8 @@ Run as a batch — report each command briefly, then end with one "please fill `
Special encodings (BigQuery base64, Snowflake account format, Athena AWS creds, etc.) are documented in [`connect.md#per-datasource-setup-notes`](https://github.com/Canner/wren-engine/blob/main/docs/get_started/connect.md). Surface the relevant section to the user verbatim — don't paraphrase.
5. Add `.env` to `.gitignore` if the project is a git repo. Suggest `chmod 600 .env`.
6. Tell the user: project is ready, `.env` is at `<path>`, please fill every value and reply **"done"**.
4. Add `.env` to `.gitignore` if the project is a git repo. Suggest `chmod 600 .env`.
5. Tell the user: `.env` is at `<path>`, please fill every value and reply **"done"**.
## Step 3 — Create the connection profile
@@ -118,9 +117,27 @@ wren profile add <project> --from-file /tmp/conn.yml
Validation runs automatically. The CLI overwrites profiles silently — there is no `--force` flag.
- ✓ **Success** → continue to Step 4.
- ✓ **Success** → continue to Step 3.5.
- ⚠ **Any warning** → consult [`connect.md#troubleshooting`](https://github.com/Canner/wren-engine/blob/main/docs/get_started/connect.md) for the exact symptom (missing secret, driver auth failure, ValidationError, unreachable host, …) and tell the user what to fix.
## Step 3.5 — Scaffold the project
```bash
wren context init --empty
```
Refuses to overwrite an existing `wren_project.yml`. Creates the project directory layout (`models/`, `views/`, `relationships.yml`, `instructions.md`, `AGENTS.md`, `queries.yml`).
## Step 3.6 — Bind the profile to the project
```bash
wren context set-profile <project>
```
Writes both `profile: <project>` and `data_source: <ds>` into `wren_project.yml` (data_source is taken from the profile we just validated, so it's guaranteed correct). Future CLI commands and the SDK resolve the connection deterministically — independent of which profile is globally active.
This step also future-proofs the project for multi-project setups: once the binding is recorded, switching `wren profile switch` elsewhere never breaks this project's queries.
## Step 4 — Generate MDL (hand off)
> ⚠️ The agent **must** build MDL before any data query. Queries against tables not in MDL will fail.