diff --git a/core/wren/src/wren/cli.py b/core/wren/src/wren/cli.py index 1e1cfa006..27ffd58a7 100644 --- a/core/wren/src/wren/cli.py +++ b/core/wren/src/wren/cli.py @@ -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 `` 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``. ``/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()) diff --git a/core/wren/src/wren/context.py b/core/wren/src/wren/context.py index d5df83b9f..1706cb875 100644 --- a/core/wren/src/wren/context.py +++ b/core/wren/src/wren/context.py @@ -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)) diff --git a/core/wren/src/wren/context_cli.py b/core/wren/src/wren/context_cli.py index d6339f394..3f2b73869 100644 --- a/core/wren/src/wren/context_cli.py +++ b/core/wren/src/wren/context_cli.py @@ -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 ` 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 ` 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: `` and ``data_source: `` 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 ` 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 "" + 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, diff --git a/core/wren/src/wren/profile.py b/core/wren/src/wren/profile.py index 0a94cb8f5..948a21196 100644 --- a/core/wren/src/wren/profile.py +++ b/core/wren/src/wren/profile.py @@ -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 ``/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 ` 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() diff --git a/core/wren/tests/test_profile.py b/core/wren/tests/test_profile.py index dd9ece1c0..830e95757 100644 --- a/core/wren/tests/test_profile.py +++ b/core/wren/tests/test_profile.py @@ -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 ──────────────────────────────────────────────────── diff --git a/core/wren/tests/unit/test_cli_profile_resolve.py b/core/wren/tests/unit/test_cli_profile_resolve.py new file mode 100644 index 000000000..c2ade91ac --- /dev/null +++ b/core/wren/tests/unit/test_cli_profile_resolve.py @@ -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 /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 /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 /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." + ) diff --git a/core/wren/tests/unit/test_context_cli.py b/core/wren/tests/unit/test_context_cli.py index 437b6884e..86d95860e 100644 --- a/core/wren/tests/unit/test_context_cli.py +++ b/core/wren/tests/unit/test_context_cli.py @@ -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 diff --git a/docs/core/get_started/connect.md b/docs/core/get_started/connect.md index c747d3034..39ad63231 100644 --- a/docs/core/get_started/connect.md +++ b/docs/core/get_started/connect.md @@ -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 +``` + +Writes both `profile: ` and `data_source: ` (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 diff --git a/docs/core/get_started/installation.md b/docs/core/get_started/installation.md index 7fee529f4..0f631e365 100644 --- a/docs/core/get_started/installation.md +++ b/docs/core/get_started/installation.md @@ -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. diff --git a/docs/core/get_started/quickstart.md b/docs/core/get_started/quickstart.md index 87c47601e..af1e3ce65 100644 --- a/docs/core/get_started/quickstart.md +++ b/docs/core/get_started/quickstart.md @@ -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 diff --git a/skills/index.json b/skills/index.json index fe754ad07..5654b9c55 100644 --- a/skills/index.json +++ b/skills/index.json @@ -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", diff --git a/skills/versions.json b/skills/versions.json index 3decc8cff..df6251edb 100644 --- a/skills/versions.json +++ b/skills/versions.json @@ -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" } diff --git a/skills/wren-onboarding/SKILL.md b/skills/wren-onboarding/SKILL.md index 344d5a48f..0a1ce2463 100644 --- a/skills/wren-onboarding/SKILL.md +++ b/skills/wren-onboarding/SKILL.md @@ -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 `~//`, installs `wren-engine[,main]`, scaffolds project files, writes an empty `.env` template. +Side effects: creates `~//`, installs `wren-engine[,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 ~/ && cd ~/`. Refuse to overwrite an existing `wren_project.yml`. +1. `mkdir -p ~/ && cd ~/`. 2. `pip install "wren-engine[,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: `. -4. **Generate the `.env` template by introspecting the connector**: +3. **Generate the `.env` template by introspecting the connector**: ```bash wren docs connection-info --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 ``, 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 ``, please fill every value and reply **"done"**. ## Step 3 — Create the connection profile @@ -118,9 +117,27 @@ wren profile add --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 +``` + +Writes both `profile: ` and `data_source: ` 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.