From 14ea0d3790cdbcb7ebeb47e3713261c2573da253 Mon Sep 17 00:00:00 2001 From: David Baussart Date: Tue, 28 Apr 2026 14:54:50 +0200 Subject: [PATCH 1/5] broaden MCP and skill discovery beyond Claude-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wlens init` now plants SKILL.md at both `.claude/skills/wlens/` (Claude Code) and `.agents/skills/wlens/` (the agentskills.io open standard, picked up by Gemini CLI, Codex CLI, Cursor, and GitHub Copilot in VS Code). `wlens mcp --dangerously-share` writes two new drop-ins under `wlens/share/` alongside the existing Claude artifacts: - `gemini_settings.json` for Gemini CLI / Antigravity (`httpUrl` shape) - `codex_config.toml` for Codex CLI (TOML, `[mcp_servers.wlens]`) The banner now prints install commands for both, plus a pointer to `docs/mcp.md` for ChatGPT (Apps), Cursor, VS Code Copilot, Windsurf, Continue, Cline, Zed, and Pi — all covered with paste-ready snippets in the rewritten per-client matrix. wlens still writes only inside the target repo; user-level paths appear only as paste targets in docs. --- docs/mcp.md | 221 ++++++++++++++++++++++++++++------- src/wlens/cli.py | 22 +++- src/wlens/mcp/share.py | 103 ++++++++++++++-- src/wlens/templates/SKILL.md | 2 +- tests/test_cli.py | 20 ++++ tests/test_cli_clean.py | 34 +++++- tests/test_mcp_share.py | 79 ++++++++++++- 7 files changed, 421 insertions(+), 60 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 6cc0e2f..ac6e64b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,19 +1,18 @@ # `wlens mcp` — Model Context Protocol server `wlens mcp` starts an MCP server that exposes wlens over HTTP. Teammates -connect from Claude Desktop (or any MCP-speaking client) to query the -warehouse without each installing wlens locally. +connect from any MCP-speaking client to query the warehouse without each +installing wlens locally. Two modes, one binary: - **Production**: `wlens mcp` — you host it on your infra. - **Demo**: `wlens mcp --dangerously-share` — temporary ngrok tunnel + - drop-in config files for Claude Desktop and Claude Code. Not for - daily use. + drop-in config files for every major MCP client. Not for daily use. ## What the server exposes -### Tools (the primary interface — what Claude Desktop surfaces to the agent) +### Tools (the primary interface — what most clients surface to the agent) | Tool | Purpose | |---|---| @@ -107,15 +106,8 @@ What happens: 1. A random 32-character bearer token is generated in-process. 2. wlens opens a pyngrok HTTPS tunnel to `127.0.0.1:`. -3. Three drop-in files are written under `wlens/share/`: - - `wlens.mcpb` — self-contained Claude Desktop extension bundle - (pre-bundled with the Python `mcp` SDK + `httpx`; recipient needs - nothing but Claude Desktop). - - `claude_desktop_config.json` — full Claude Desktop config JSON - that uses your **locally-installed** `wlens` as an mcp-proxy - (works if you have wlens on your machine). - - `.mcp.json` — Claude Code native HTTP config (no proxy needed). -4. A banner prints the URL + token + paths. +3. Drop-in files are written under `wlens/share/` (see next section). +4. A banner prints the URL + token + paths + per-client install hints. 5. When you Ctrl-C the process: tunnel closes, `wlens/share/` cleared, server exits. @@ -130,22 +122,61 @@ demoing, sign up at [dashboard.ngrok.com](https://dashboard.ngrok.com), grab the authtoken, and `export NGROK_AUTHTOKEN=...`. pyngrok picks it up automatically. -## Connecting clients +## What `wlens mcp --dangerously-share` writes -### Claude Desktop — drag-and-drop `.mcpb` +Five drop-in files land under `wlens/share/`. wlens never writes outside +the project repo — references to `~/.gemini/...`, `~/.codex/...`, +`~/Library/Application Support/Claude/...`, etc. are paste targets, +not files wlens touches. -Double-click `wlens/share/wlens.mcpb`. Claude Desktop extracts the bundle -and registers it as an extension — mcp-remote equivalent (Python) is -vendored inside, URL and bearer token are baked into the manifest. +| File | Format | For | +|---|---|---| +| `.mcp.json` | JSON, `mcpServers` with `url` + `type: "http"` | Claude Code (drop into project root). **Universal donor**: same shape works for Cursor, GitHub Copilot in VS Code, Cline, Zed at their own paths. | +| `claude_desktop_config.json` | JSON, `mcpServers` with `command` + `args` | Claude Desktop (stdio via `wlens mcp-proxy` — Claude Desktop doesn't speak HTTP MCP). | +| `wlens.mcpb` | Zip (Python bundle, MCPB v0.3) | Claude Desktop standalone install. Vendors `mcp` + `httpx` + `anyio` so the recipient needs nothing beyond Claude Desktop. Skipped if `uv` and `pip` are both unavailable at build time. | +| `gemini_settings.json` | JSON, `mcpServers` with `httpUrl` | Gemini CLI / Antigravity (different field name from Claude Code). | +| `codex_config.toml` | TOML, `[mcp_servers.]` | Codex CLI (TOML, not JSON). | + +## Per-client setup + +Each client picks one of the files above (or pastes from one). The +demo banner prints the exact CLI commands when applicable. + +### Claude Code + +Move `wlens/share/.mcp.json` into a project root and Claude Code picks +it up on next launch. Or run: + +```bash +claude mcp add --transport http --scope project wlens \ + https://abc.ngrok-free.app/mcp \ + --header "Authorization: Bearer ..." +``` + +File shape (Claude Code speaks HTTP MCP natively): + +```json +{ + "mcpServers": { + "wlens": { + "type": "http", + "url": "https://abc.ngrok-free.app/mcp", + "headers": { "Authorization": "Bearer ..." } + } + } +} +``` -### Claude Desktop — manual config +### Claude Desktop -Paste the contents of `wlens/share/claude_desktop_config.json` into -`~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) -or merge the `wlens` entry into an existing file with other MCPs. -Restart Claude Desktop. +**Preferred:** double-click `wlens/share/wlens.mcpb`. Claude Desktop +extracts the bundle and registers it as an extension — Python proxy is +vendored inside, URL and bearer token are baked into the manifest. -The file looks like: +**Manual fallback:** paste `wlens/share/claude_desktop_config.json` into +`~/Library/Application Support/Claude/claude_desktop_config.json` +(macOS) or merge the `wlens` entry into an existing file with other +MCPs. Restart Claude Desktop. ```json { @@ -162,38 +193,146 @@ The file looks like: Uses your installed `wlens` binary as a stdio↔HTTP bridge (the `mcp-proxy` subcommand). No Node, no npx — pure Python. -### Claude Code — drop-in `.mcp.json` +### Gemini CLI + +Run: -Move `wlens/share/.mcp.json` into any project root. Claude Code reads -it automatically on next launch. File shape (Claude Code speaks HTTP MCP -natively): +```bash +gemini mcp add --transport http wlens \ + https://abc.ngrok-free.app/mcp \ + --header "Authorization: Bearer ..." +``` + +Or merge `wlens/share/gemini_settings.json` into `~/.gemini/settings.json` +(global) or `.gemini/settings.json` (project). Field name is `httpUrl`, +not `url`: ```json { "mcpServers": { "wlens": { - "type": "http", - "url": "https://abc.ngrok-free.app/mcp", + "httpUrl": "https://abc.ngrok-free.app/mcp", "headers": { "Authorization": "Bearer ..." } } } } ``` -Or via CLI: +### Antigravity (Google's IDE) -```bash -claude mcp add --transport http --scope project wlens \ - https://abc.ngrok-free.app/mcp \ - --header "Authorization: Bearer ..." +Settings → MCP Servers → Manage MCP Servers → View raw config → paste +the contents of `wlens/share/gemini_settings.json`. Same Gemini-shape +JSON. + +### Codex CLI (OpenAI) + +Merge the `[mcp_servers.wlens]` block from `wlens/share/codex_config.toml` +into `~/.codex/config.toml`: + +```toml +[mcp_servers.wlens] +url = "https://abc.ngrok-free.app/mcp" +http_headers = { Authorization = "Bearer ..." } ``` -### Other MCP clients +Codex doesn't have a per-project MCP config (everything lives in +`~/.codex/config.toml`). + +### ChatGPT (web / desktop, Apps) + +ChatGPT installs MCP servers via UI, not a config file: + +1. Settings → Connectors → Developer Mode (Plus/Pro/Team/Enterprise/Edu only). +2. **Add custom MCP** (or **Add a custom Apps connector**). +3. URL: `https://abc.ngrok-free.app/mcp` +4. Authentication: Bearer token, paste the token from the share banner. + +Custom MCP Apps live behind Developer Mode; free ChatGPT does not +support them. + +### Cursor + +Copy `wlens/share/.mcp.json` to one of: + +- `.cursor/mcp.json` (project-scoped — only this project) +- `~/.cursor/mcp.json` (global — all your Cursor workspaces) + +Same shape as the Claude Code file works unchanged. + +### GitHub Copilot in VS Code + +Copy `wlens/share/.mcp.json` to `.vscode/mcp.json` in your project +(or merge into your VS Code user `settings.json` under +`chat.mcp.servers`). The Claude Code shape is accepted unchanged. + +### Windsurf + +Paste the contents of `wlens/share/.mcp.json` into +`~/.codeium/windsurf/mcp_config.json`, merging the `wlens` entry into +the `mcpServers` map. + +### Cline (VS Code extension) -Any MCP-speaking client (Cursor, Continue, etc.) can reach the same URL -as long as it supports bearer auth headers. For clients that speak -stdio only, run `wlens mcp-proxy ` locally as the bridge — it's -the same shim the Claude Desktop config uses. +Open Cline's MCP settings (in the extension panel) → Edit MCP Settings +JSON → paste the `mcpServers.wlens` entry from `wlens/share/.mcp.json`. + +### Zed + +Open `~/.config/zed/settings.json` and paste the `mcpServers.wlens` +entry under the `assistant.mcp_servers` map (Zed's exact key may evolve +— check Zed's docs if it doesn't pick up). + +### Continue + +Continue uses YAML (`.continue/config.yaml`), not JSON. Hand-translate +from `.mcp.json`: + +```yaml +mcpServers: + - name: wlens + type: http + url: https://abc.ngrok-free.app/mcp + headers: + Authorization: "Bearer ..." +``` + +### Pi.dev + +Pi has no native MCP. Two options: + +- **MCP via adapter:** install + [`pi-mcp-adapter`](https://github.com/nicobailon/pi-mcp-adapter) + and reuse the `wlens/share/.mcp.json` shape — the adapter reads + standard MCP files automatically. +- **Filesystem mode (no adapter):** Pi has shell + read tools, so it + works with the bundled SKILL.md. Pi scans **user-level** + `~/.agents/skills/` only. To make wlens discoverable across every + project you open Pi in, run a one-time symlink (`wlens` itself + never writes outside the repo): + + ```bash + ln -s "$(pwd)/.agents/skills/wlens" ~/.agents/skills/wlens + ``` + +## Filesystem mode (no MCP) + +Any shell-capable agent works without an MCP server: the agent uses +its built-in `Grep` + `Read` against `wlens/schema/*.md`, then shells +out to `wlens query "SELECT …"`. + +`wlens init` plants the SKILL.md into two project-level discovery +paths so any major agent finds it: + +| Path | Picked up by | +|---|---| +| `.claude/skills/wlens/SKILL.md` | Claude Code (which doesn't scan `.agents/skills/`) | +| `.agents/skills/wlens/SKILL.md` | Gemini CLI, Antigravity, Codex CLI (walks up from cwd to repo root), Cursor, GitHub Copilot in VS Code, anything else following the [Agent Skills open standard](https://agentskills.io) | + +The Agent Skills format went open-standard in December 2025, so the +list of tools that read `.agents/skills/` is growing. Both files are +byte-identical — wlens writes one template to two destinations. (Note: +Gemini CLI scans `.agents/skills/` directly; it does **not** scan +`.gemini/skills/`, contrary to some older third-party docs.) ## `wlens mcp-proxy` — the stdio bridge diff --git a/src/wlens/cli.py b/src/wlens/cli.py index 6cc834b..289f84d 100644 --- a/src/wlens/cli.py +++ b/src/wlens/cli.py @@ -2,8 +2,8 @@ Subcommands: - wlens init — drop wlens.yml + .claude/skills/wlens/SKILL.md into cwd - wlens generate — read dbt artifacts, write per-table markdown into .claude/schema/ + wlens init — drop wlens.yml + skill files (.claude/, .agents/) into cwd + wlens generate — read dbt artifacts, write per-table markdown into wlens/schema/ wlens query — execute a read-only SQL query against the configured warehouse wlens tag-pii — scan dbt yml files and add `meta: pii: true` to likely-PII columns wlens mcp — start the wlens MCP server (team / demo modes) @@ -206,9 +206,16 @@ def _cmd_init(*, force: bool) -> int: detected_project_dir = _detect_dbt_project_dir(cwd) detected_duckdb = _detect_duckdb_file(cwd) + # Two skill destinations cover every native Agent Skills host today: + # .claude/skills/ → Claude Code (doesn't scan .agents/) + # .agents/skills/ → open standard (agentskills.io) — Gemini CLI, + # Codex CLI, Cursor, GitHub Copilot in VS Code, + # and any future tool that adopts the standard. + # Same template body, two write paths — agents discover wherever they look. targets = [ (cwd / DEFAULT_CONFIG_FILENAME, "wlens.yml"), (cwd / ".claude" / "skills" / "wlens" / "SKILL.md", "SKILL.md"), + (cwd / ".agents" / "skills" / "wlens" / "SKILL.md", "SKILL.md"), ] for dest, template_name in targets: @@ -434,10 +441,13 @@ def _cmd_clean( cfg_path = None # Everything wlens creates lives under `wlens/`, so nuke the whole dir in - # one go. The skill file lives under .claude/ by Claude Code convention. + # one go. Skill files live under .claude/ (Claude Code) and .agents/ (the + # open standard — Gemini CLI, Codex CLI, Cursor, …) — two well-known + # directories outside `wlens/` that we own a single subdir of. targets: list[Path] = [ repo_root / "wlens", repo_root / ".claude" / "skills" / "wlens", + repo_root / ".agents" / "skills" / "wlens", ] # Back-compat: previous versions scattered files at the repo root. Clean # them up if they're still there. @@ -491,7 +501,11 @@ def _cmd_clean( logger.info(f"removed {t}") # Tidy empty parent directories that were only there for wlens. - for parent in (repo_root / ".claude" / "skills", repo_root / ".claude"): + empty_parents = [ + repo_root / ".claude" / "skills", repo_root / ".claude", + repo_root / ".agents" / "skills", repo_root / ".agents", + ] + for parent in empty_parents: if parent.exists() and parent.is_dir() and not any(parent.iterdir()): parent.rmdir() logger.info(f"removed empty {parent}") diff --git a/src/wlens/mcp/share.py b/src/wlens/mcp/share.py index c7e2e6b..1b28142 100644 --- a/src/wlens/mcp/share.py +++ b/src/wlens/mcp/share.py @@ -2,11 +2,15 @@ Starts the wlens MCP server on localhost, opens a pyngrok tunnel, auto- generates a bearer token, and writes ready-to-use drop-in files for -Claude Code and Claude Desktop: +the major MCP clients: - - `.mcp.json` native HTTP (Claude Code) + - `.mcp.json` native HTTP (Claude Code; also reusable + for Cursor at .cursor/mcp.json, GitHub + Copilot at .vscode/mcp.json, etc.) - `claude_desktop_config.json` proxied through `wlens mcp-proxy` - `wlens.mcpb` standalone Python bundle (double-click) + - `gemini_settings.json` Gemini CLI / Antigravity (uses `httpUrl`) + - `codex_config.toml` Codex CLI (TOML, [mcp_servers.wlens]) Everything stays pure Python: the .mcpb vendors `mcp` + `httpx` + `anyio` into the bundle with `uv pip install --target`, so the recipient doesn't @@ -117,19 +121,24 @@ def write_share_files( token: str, wlens_binary: str | None = None, ) -> dict[str, Path]: - """Write the Claude Code + Claude Desktop drop-in files into `share_dir`. + """Write the per-client drop-in files into `share_dir`. Returns a dict mapping short client names → absolute file paths. - Three artifacts: - - `.mcp.json` (Claude Code — native HTTP, no proxy needed) - - `claude_desktop_config.json` (Claude Desktop — proxied through `wlens mcp-proxy`) - - `wlens.mcpb` (Claude Desktop standalone bundle — double-click) + Five artifacts (one per format family — see docs/mcp.md for the per-client + paste-and-place table): + - `.mcp.json` Claude Code shape (HTTP, `url` + `headers`). + Universal donor: drops into Cursor, GitHub + Copilot in VS Code, Cline, Zed unchanged. + - `claude_desktop_config.json` Claude Desktop (stdio via `wlens mcp-proxy`). + - `wlens.mcpb` Claude Desktop standalone bundle (double-click). + - `gemini_settings.json` Gemini CLI / Antigravity (HTTP, `httpUrl`). + - `codex_config.toml` Codex CLI (TOML, `[mcp_servers.wlens]`). The `.mcpb` is pure-Python: we vendor `mcp` + `httpx` + `anyio` into `server/lib/` via uv/pip so the recipient needs nothing beyond Claude Desktop's bundled Python. If neither `uv` nor `pip` is available at - build time, the bundle is skipped (the two JSON files are still emitted). + build time, the bundle is skipped (the JSON/TOML files are still emitted). """ share_dir.mkdir(parents=True, exist_ok=True) resolved_binary = wlens_binary or _resolve_wlens_binary() @@ -142,7 +151,18 @@ def write_share_files( _claude_desktop_full_config(url=mcp_url, token=token, wlens_binary=resolved_binary) + "\n" ) - out: dict[str, Path] = {"claude-code": code_path, "claude-desktop": desktop_path} + gemini_path = share_dir / "gemini_settings.json" + gemini_path.write_text(_gemini_mcp_json(url=mcp_url, token=token) + "\n") + + codex_path = share_dir / "codex_config.toml" + codex_path.write_text(_codex_mcp_toml(url=mcp_url, token=token)) + + out: dict[str, Path] = { + "claude-code": code_path, + "claude-desktop": desktop_path, + "gemini": gemini_path, + "codex": codex_path, + } bundle_path = share_dir / "wlens.mcpb" try: @@ -416,6 +436,8 @@ def _print_share_banner( mcp_url = f"{public_url}/mcp" code_path = written_files["claude-code"] desktop_path = written_files["claude-desktop"] + gemini_path = written_files["gemini"] + codex_path = written_files["codex"] mcpb_path = written_files.get("mcpb") banner = [ @@ -467,6 +489,29 @@ def _print_share_banner( "", _claude_code_cli_command(url=mcp_url, token=token), "", + "─── Gemini CLI / Antigravity ─────────────────────────────────────", + "", + f" {gemini_path}", + "", + " Gemini CLI: run", + "", + _gemini_cli_command(url=mcp_url, token=token), + "", + " …or merge the JSON snippet into ~/.gemini/settings.json.", + " Antigravity: Settings → MCP Servers → Manage → View raw config →", + " paste the snippet from the file above.", + "", + "─── Codex CLI ────────────────────────────────────────────────────", + "", + f" {codex_path}", + "", + " Merge the [mcp_servers.wlens] block into ~/.codex/config.toml.", + "", + "─── Other clients (Cursor, VS Code Copilot, Windsurf, ChatGPT, …) ─", + "", + " See docs/mcp.md for paste-ready snippets per client. Most accept", + " the Claude Code .mcp.json shape unchanged at a different path.", + "", "Press Ctrl-C to stop the tunnel, delete the drop-in files, and shut down.", "", ] @@ -481,6 +526,14 @@ def _claude_code_cli_command(*, url: str, token: str) -> str: ) +def _gemini_cli_command(*, url: str, token: str) -> str: + return ( + " gemini mcp add --transport http wlens \\\n" + f" {url} \\\n" + f' --header "Authorization: Bearer {token}"' + ) + + def _claude_code_mcp_json(*, url: str, token: str) -> str: config = { "mcpServers": { @@ -494,6 +547,38 @@ def _claude_code_mcp_json(*, url: str, token: str) -> str: return json.dumps(config, indent=2) +def _gemini_mcp_json(*, url: str, token: str) -> str: + """Gemini CLI / Antigravity drop-in for ~/.gemini/settings.json. + + Differs from the Claude Code shape by one field name: `httpUrl` instead + of `url` (and no `type` discriminator). Same `mcpServers` envelope, same + `headers` map. + """ + config = { + "mcpServers": { + "wlens": { + "httpUrl": url, + "headers": {"Authorization": f"Bearer {token}"}, + } + } + } + return json.dumps(config, indent=2) + + +def _codex_mcp_toml(*, url: str, token: str) -> str: + """Codex CLI drop-in for ~/.codex/config.toml (or .codex/config.toml). + + Codex uses TOML, not JSON. The HTTP transport schema is `url` plus an + `http_headers` inline map (per OpenAI's Codex config reference). We hand- + format rather than pull in a `tomli_w` dep — the structure is fixed. + """ + return ( + "[mcp_servers.wlens]\n" + f'url = "{url}"\n' + f'http_headers = {{ Authorization = "Bearer {token}" }}\n' + ) + + def _claude_desktop_full_config(*, url: str, token: str, wlens_binary: str) -> str: """Full claude_desktop_config.json. diff --git a/src/wlens/templates/SKILL.md b/src/wlens/templates/SKILL.md index 1b475c3..b07bb28 100644 --- a/src/wlens/templates/SKILL.md +++ b/src/wlens/templates/SKILL.md @@ -27,7 +27,7 @@ join hints all live in the docs. Guessing is the most common failure mode. Pick the mode that matches the tools you have. In either mode, the underlying docs and rules are identical — only the access primitives differ. -### Filesystem mode (Claude Code, Cursor, Continue, Codex, any shell-capable agent) +### Filesystem mode (Claude Code, Gemini CLI, Cursor, Continue, Codex, Pi, any shell-capable agent) Every dbt model and source has a per-table markdown file under `wlens/schema/`. `wlens/schema/_index.md` lists them all alphabetically with diff --git a/tests/test_cli.py b/tests/test_cli.py index 739ce3e..c6613b6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -34,6 +34,26 @@ def test_init_writes_starter_files(tmp_path: Path, monkeypatch): assert ".claude/schema/" not in skill +def test_init_writes_skill_to_two_locations(tmp_path: Path, monkeypatch): + """One template, two discovery paths: Claude Code (`.claude/skills/`) and + the open standard `.agents/skills/` — which Gemini CLI, Codex CLI, Cursor, + and GitHub Copilot in VS Code all scan.""" + monkeypatch.chdir(tmp_path) + main(["init"]) + + claude_skill = tmp_path / ".claude" / "skills" / "wlens" / "SKILL.md" + agents_skill = tmp_path / ".agents" / "skills" / "wlens" / "SKILL.md" + + assert claude_skill.exists() + assert agents_skill.exists() + # Gemini CLI scans .agents/skills/ only, NOT .gemini/skills/. + assert not (tmp_path / ".gemini").exists() + + # Both files are byte-identical: same template, two destinations. + body = claude_skill.read_text() + assert agents_skill.read_text() == body + + def test_init_writes_wlens_gitignore(tmp_path: Path, monkeypatch): monkeypatch.chdir(tmp_path) main(["init"]) diff --git a/tests/test_cli_clean.py b/tests/test_cli_clean.py index 921ae12..9cecfe0 100644 --- a/tests/test_cli_clean.py +++ b/tests/test_cli_clean.py @@ -21,9 +21,12 @@ def _wlens_layout(tmp_path: Path) -> None: dir: .claude/schema """).lstrip() ) - skill_dir = tmp_path / ".claude" / "skills" / "wlens" - skill_dir.mkdir(parents=True) - (skill_dir / "SKILL.md").write_text("---\nname: wlens\n---\n") + # `wlens init` plants the skill into two discovery paths: .claude/ for + # Claude Code and .agents/ for the open standard (Gemini CLI, Codex, …). + for top in (".claude", ".agents"): + skill_dir = tmp_path / top / "skills" / "wlens" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("---\nname: wlens\n---\n") schema = tmp_path / ".claude" / "schema" schema.mkdir(parents=True) (schema / "_index.md").write_text("# index\n") @@ -45,12 +48,35 @@ def test_clean_removes_all_wlens_files(tmp_path: Path, monkeypatch): assert not (tmp_path / "wlens.yml").exists() assert not (tmp_path / ".claude" / "skills" / "wlens").exists() + assert not (tmp_path / ".agents" / "skills" / "wlens").exists() assert not (tmp_path / ".claude" / "schema").exists() assert not (tmp_path / ".wlens-cache").exists() assert not (tmp_path / "wlens-share").exists() - # Empty parents are tidied. + # Empty parents are tidied across both skill hosts. assert not (tmp_path / ".claude" / "skills").exists() assert not (tmp_path / ".claude").exists() + assert not (tmp_path / ".agents" / "skills").exists() + assert not (tmp_path / ".agents").exists() + + +def test_clean_preserves_unrelated_agents_content(tmp_path: Path, monkeypatch): + """The same defence-in-depth that protects .claude/skills also protects + .agents/skills: a foreign skill in the parent dir must survive.""" + monkeypatch.chdir(tmp_path) + _wlens_layout(tmp_path) + + # Plant an unrelated skill under .agents/skills/. + other_agents = tmp_path / ".agents" / "skills" / "team-skill" + other_agents.mkdir(parents=True) + (other_agents / "SKILL.md").write_text("team\n") + + assert main(["clean", "--yes"]) == 0 + + # Foreign skill survives… + assert other_agents.exists() + # …and so do its non-empty parents. + assert (tmp_path / ".agents" / "skills").exists() + assert (tmp_path / ".agents").exists() def test_clean_preserves_unrelated_claude_content(tmp_path: Path, monkeypatch): diff --git a/tests/test_mcp_share.py b/tests/test_mcp_share.py index 117c814..73915b1 100644 --- a/tests/test_mcp_share.py +++ b/tests/test_mcp_share.py @@ -10,6 +10,9 @@ _claude_code_cli_command, _claude_code_mcp_json, _claude_desktop_full_config, + _codex_mcp_toml, + _gemini_cli_command, + _gemini_mcp_json, _host_from_url, _mcpb_main_py, _mcpb_manifest, @@ -55,6 +58,39 @@ def test_claude_code_cli_command_shape(): assert f'"Authorization: Bearer {TOKEN}"' in cmd +def test_gemini_mcp_json_uses_httpUrl(): + """Gemini CLI / Antigravity differs from Claude Code by one field: `httpUrl` + instead of `url` + `type: "http"`. Same `mcpServers` envelope otherwise.""" + parsed = json.loads(_gemini_mcp_json(url=URL, token=TOKEN)) + entry = parsed["mcpServers"]["wlens"] + assert entry["httpUrl"] == URL + assert entry["headers"] == {"Authorization": f"Bearer {TOKEN}"} + # Gemini CLI doesn't use `type` or `url` for HTTP MCP servers. + assert "type" not in entry + assert "url" not in entry + assert "command" not in entry + + +def test_gemini_cli_command_shape(): + cmd = _gemini_cli_command(url=URL, token=TOKEN) + assert "gemini mcp add" in cmd + assert "--transport http" in cmd + assert URL in cmd + assert f'"Authorization: Bearer {TOKEN}"' in cmd + + +def test_codex_mcp_toml_shape(): + """Codex CLI uses TOML with `[mcp_servers.]`, `url`, and an inline + `http_headers` map (per OpenAI's Codex config reference).""" + text = _codex_mcp_toml(url=URL, token=TOKEN) + assert "[mcp_servers.wlens]" in text + assert f'url = "{URL}"' in text + assert f'Authorization = "Bearer {TOKEN}"' in text + assert "http_headers" in text + # No JSON artefacts (curly braces around the table, etc.) leaking in. + assert not text.startswith("{") + + def test_host_from_url_strips_scheme_and_path(): assert _host_from_url("https://abc.ngrok.io") == ["abc.ngrok.io", "abc.ngrok.io:*"] assert _host_from_url("https://abc.ngrok-free.app/mcp") == [ @@ -67,7 +103,7 @@ def test_host_from_url_strips_scheme_and_path(): def test_write_share_files_creates_json_configs(tmp_path: Path, monkeypatch): - """JSON drop-ins are unconditional. The .mcpb is opportunistic.""" + """JSON / TOML drop-ins are unconditional. The .mcpb is opportunistic.""" # Stub out vendoring so this test doesn't hit the network / pip. import wlens.mcp.share as share_mod @@ -78,8 +114,43 @@ def test_write_share_files_creates_json_configs(tmp_path: Path, monkeypatch): assert written["claude-code"] == share_dir / ".mcp.json" assert written["claude-desktop"] == share_dir / "claude_desktop_config.json" + assert written["gemini"] == share_dir / "gemini_settings.json" + assert written["codex"] == share_dir / "codex_config.toml" assert written["claude-code"].exists() assert written["claude-desktop"].exists() + assert written["gemini"].exists() + assert written["codex"].exists() + + +def test_written_gemini_file_uses_httpUrl(tmp_path: Path, monkeypatch): + import wlens.mcp.share as share_mod + + monkeypatch.setattr(share_mod, "_vendor_python_deps", lambda target_dir: target_dir.mkdir(exist_ok=True)) + + share_dir = tmp_path / "wlens-share" + written = write_share_files(share_dir, mcp_url=URL, token=TOKEN, wlens_binary=WLENS_BIN) + + parsed = json.loads(written["gemini"].read_text()) + entry = parsed["mcpServers"]["wlens"] + assert entry["httpUrl"] == URL + assert entry["headers"]["Authorization"] == f"Bearer {TOKEN}" + + +def test_written_codex_file_is_valid_toml(tmp_path: Path, monkeypatch): + """Round-trip the Codex TOML through tomllib so we know it parses.""" + import tomllib + + import wlens.mcp.share as share_mod + + monkeypatch.setattr(share_mod, "_vendor_python_deps", lambda target_dir: target_dir.mkdir(exist_ok=True)) + + share_dir = tmp_path / "wlens-share" + written = write_share_files(share_dir, mcp_url=URL, token=TOKEN, wlens_binary=WLENS_BIN) + + parsed = tomllib.loads(written["codex"].read_text()) + server = parsed["mcp_servers"]["wlens"] + assert server["url"] == URL + assert server["http_headers"]["Authorization"] == f"Bearer {TOKEN}" def test_written_claude_code_file_is_standalone_mcp_json(tmp_path: Path, monkeypatch): @@ -113,6 +184,8 @@ def test_written_claude_desktop_file_uses_python_proxy(tmp_path: Path, monkeypat def test_write_share_files_is_idempotent(tmp_path: Path, monkeypatch): + import tomllib + import wlens.mcp.share as share_mod monkeypatch.setattr(share_mod, "_vendor_python_deps", lambda target_dir: target_dir.mkdir(exist_ok=True)) @@ -125,6 +198,10 @@ def test_write_share_files_is_idempotent(tmp_path: Path, monkeypatch): assert parsed["mcpServers"]["wlens"]["headers"]["Authorization"] == "Bearer second-token" desktop = json.loads((share_dir / "claude_desktop_config.json").read_text()) assert desktop["mcpServers"]["wlens"]["env"]["WLENS_AUTH_TOKEN"] == "second-token" + gemini = json.loads((share_dir / "gemini_settings.json").read_text()) + assert gemini["mcpServers"]["wlens"]["headers"]["Authorization"] == "Bearer second-token" + codex = tomllib.loads((share_dir / "codex_config.toml").read_text()) + assert codex["mcp_servers"]["wlens"]["http_headers"]["Authorization"] == "Bearer second-token" def test_share_falls_back_to_wlens_string_when_binary_missing(tmp_path: Path, monkeypatch): From 1d3608a3aa695e737e4d14d990fa2471e9df0e9a Mon Sep 17 00:00:00 2001 From: David Baussart Date: Tue, 28 Apr 2026 16:16:51 +0200 Subject: [PATCH 2/5] add `wlens mcp-clients` subcommand Wraps `write_share_files()` behind a CLI so a team deployment can generate per-client drop-in configs pointing at a stable URL. Token defaults to WLENS_AUTH_TOKEN. --- src/wlens/cli.py | 64 +++++++++++++++++++++++++++++++++++++++-- tests/test_mcp_share.py | 58 +++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 3 deletions(-) diff --git a/src/wlens/cli.py b/src/wlens/cli.py index 289f84d..e32874b 100644 --- a/src/wlens/cli.py +++ b/src/wlens/cli.py @@ -6,9 +6,10 @@ wlens generate — read dbt artifacts, write per-table markdown into wlens/schema/ wlens query — execute a read-only SQL query against the configured warehouse wlens tag-pii — scan dbt yml files and add `meta: pii: true` to likely-PII columns - wlens mcp — start the wlens MCP server (team / demo modes) - wlens mcp-proxy — stdio↔HTTP proxy (used by Claude Desktop to reach remote wlens) - wlens clean — remove every file wlens has installed or generated in this repo + wlens mcp — start the wlens MCP server (team / demo modes) + wlens mcp-proxy — stdio↔HTTP proxy (used by Claude Desktop to reach remote wlens) + wlens mcp-clients — generate per-client MCP config files for a deployed wlens server + wlens clean — remove every file wlens has installed or generated in this repo """ from __future__ import annotations @@ -82,6 +83,29 @@ def main(argv: list[str] | None = None) -> int: ) p_proxy.add_argument("url", help="Remote wlens MCP URL, e.g. https://abc.ngrok-free.app/mcp") + p_clients = sub.add_parser( + "mcp-clients", + help="Generate per-client MCP config files for a deployed wlens server.", + ) + p_clients.add_argument( + "--url", + required=True, + metavar="URL", + help="Full MCP URL of your deployed wlens server, e.g. https://wlens.team.com/mcp.", + ) + p_clients.add_argument( + "--token", + default=None, + metavar="TOKEN", + help="Bearer token. Defaults to the WLENS_AUTH_TOKEN env var.", + ) + p_clients.add_argument( + "--out", + default=None, + metavar="DIR", + help="Output directory for the drop-in files (default: ./wlens/share/).", + ) + p_clean = sub.add_parser( "clean", help="Remove every file wlens installed or generated (wlens.yml, .claude/skills/wlens/, schema dir, cache).", @@ -124,6 +148,12 @@ def main(argv: list[str] | None = None) -> int: if args.command == "mcp-proxy": from .mcp import proxy return proxy.main([args.url]) + if args.command == "mcp-clients": + return _cmd_mcp_clients( + url=args.url, + token=args.token, + out=Path(args.out) if args.out else None, + ) if args.command == "clean": return _cmd_clean( config_path=Path(args.config) if args.config else None, @@ -555,6 +585,34 @@ def _cmd_mcp( ) +def _cmd_mcp_clients(*, url: str, token: str | None, out: Path | None) -> int: + """Generate per-client MCP config files for a deployed wlens server. + + Wraps `write_share_files()` with a stable URL and token from a team + deployment, so the same drop-ins that `--dangerously-share` produces can + be handed to teammates pointing at your production server. + """ + import os + + from .mcp.auth import AUTH_ENV_VAR + from .mcp.share import write_share_files + + resolved_token = token or os.environ.get(AUTH_ENV_VAR) + if not resolved_token: + logger.error("no token provided. Pass --token or set %s.", AUTH_ENV_VAR) + return 1 + + target_dir = out if out is not None else Path.cwd() / "wlens" / "share" + written = write_share_files(target_dir, mcp_url=url, token=resolved_token) + + logger.info("wrote %d client config(s) to %s/:", len(written), target_dir) + for name, path in sorted(written.items()): + logger.info(" %s: %s", name, path.name) + logger.info("each file contains the bearer token; distribute carefully.") + + return 0 + + def _read_template(name: str) -> str: return resources.files("wlens.templates").joinpath(name).read_text(encoding="utf-8") diff --git a/tests/test_mcp_share.py b/tests/test_mcp_share.py index 73915b1..121df08 100644 --- a/tests/test_mcp_share.py +++ b/tests/test_mcp_share.py @@ -204,6 +204,64 @@ def test_write_share_files_is_idempotent(tmp_path: Path, monkeypatch): assert codex["mcp_servers"]["wlens"]["http_headers"]["Authorization"] == "Bearer second-token" +def test_cli_mcp_clients_uses_env_token(tmp_path: Path, monkeypatch): + """`wlens mcp-clients --url ...` defaults the token to WLENS_AUTH_TOKEN.""" + import wlens.mcp.share as share_mod + + monkeypatch.setattr(share_mod, "_vendor_python_deps", lambda target_dir: target_dir.mkdir(exist_ok=True)) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("WLENS_AUTH_TOKEN", "env-token-xyz") + + from wlens.cli import main + rc = main(["mcp-clients", "--url", "https://wlens.team.com/mcp"]) + assert rc == 0 + + out_dir = tmp_path / "wlens" / "share" + parsed = json.loads((out_dir / ".mcp.json").read_text()) + assert parsed["mcpServers"]["wlens"]["headers"]["Authorization"] == "Bearer env-token-xyz" + assert parsed["mcpServers"]["wlens"]["url"] == "https://wlens.team.com/mcp" + + +def test_cli_mcp_clients_explicit_token_overrides_env(tmp_path: Path, monkeypatch): + import wlens.mcp.share as share_mod + + monkeypatch.setattr(share_mod, "_vendor_python_deps", lambda target_dir: target_dir.mkdir(exist_ok=True)) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("WLENS_AUTH_TOKEN", "env-token") + + from wlens.cli import main + rc = main(["mcp-clients", "--url", "https://wlens.team.com/mcp", "--token", "explicit-token"]) + assert rc == 0 + + parsed = json.loads((tmp_path / "wlens" / "share" / ".mcp.json").read_text()) + assert parsed["mcpServers"]["wlens"]["headers"]["Authorization"] == "Bearer explicit-token" + + +def test_cli_mcp_clients_fails_without_token(tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("WLENS_AUTH_TOKEN", raising=False) + + from wlens.cli import main + rc = main(["mcp-clients", "--url", "https://wlens.team.com/mcp"]) + assert rc == 1 + assert not (tmp_path / "wlens" / "share").exists() + + +def test_cli_mcp_clients_custom_out_dir(tmp_path: Path, monkeypatch): + import wlens.mcp.share as share_mod + + monkeypatch.setattr(share_mod, "_vendor_python_deps", lambda target_dir: target_dir.mkdir(exist_ok=True)) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("WLENS_AUTH_TOKEN", "tok") + + from wlens.cli import main + custom = tmp_path / "elsewhere" + rc = main(["mcp-clients", "--url", "https://wlens.team.com/mcp", "--out", str(custom)]) + assert rc == 0 + assert (custom / ".mcp.json").exists() + assert not (tmp_path / "wlens" / "share").exists() + + def test_share_falls_back_to_wlens_string_when_binary_missing(tmp_path: Path, monkeypatch): """If shutil.which can't find wlens, we write the literal string "wlens".""" import wlens.mcp.share as share_mod From 811fd63b68c28179475adee2bb2311421d3e5289 Mon Sep 17 00:00:00 2001 From: David Baussart Date: Tue, 28 Apr 2026 16:27:04 +0200 Subject: [PATCH 3/5] clean up CLI output: drop `INFO:` prefix from log lines --- src/wlens/cli.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/wlens/cli.py b/src/wlens/cli.py index e32874b..a27b12f 100644 --- a/src/wlens/cli.py +++ b/src/wlens/cli.py @@ -115,7 +115,7 @@ def main(argv: list[str] | None = None) -> int: p_clean.add_argument("--yes", "-y", action="store_true", help="Skip the confirmation prompt.") args = parser.parse_args(argv) - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + _configure_logging() if args.command == "init": return _cmd_init(force=args.force) @@ -231,6 +231,29 @@ def _detect_dbt_project_dir(cwd: Path) -> str | None: return "." if rel == Path(".") else str(rel) +class _CliFormatter(logging.Formatter): + """Drop the level prefix for INFO; keep it (lowercased) for warnings/errors. + + Keeps user-facing output clean while letting warnings and errors stand out. + """ + + def format(self, record: logging.LogRecord) -> str: + msg = record.getMessage() + if record.levelno <= logging.INFO: + return msg + return f"{record.levelname.lower()}: {msg}" + + +def _configure_logging() -> None: + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(_CliFormatter()) + root = logging.getLogger() + root.setLevel(logging.INFO) + for h in list(root.handlers): + root.removeHandler(h) + root.addHandler(handler) + + def _cmd_init(*, force: bool) -> int: cwd = Path.cwd() detected_project_dir = _detect_dbt_project_dir(cwd) From f4f09f44a6172c208ccb56b8e9374b975fea950f Mon Sep 17 00:00:00 2001 From: David Baussart Date: Tue, 28 Apr 2026 16:29:15 +0200 Subject: [PATCH 4/5] docs: rewrite README --- README.md | 300 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 165 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index 4026555..78e8a4f 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,12 @@ # wlens -> Warehouse lens for AI agents. - -`wlens` turns your dbt project into per-table markdown docs that any AI agent -can read, and ships a read-only SQL CLI they can run against your warehouse. -Works locally via a skill + shell command, or remotely via an MCP server. - -Works with **Claude Code**, **Cursor**, **Continue**, **Codex**, **Claude Desktop**, -**Pi** and any MCP-speaking client. - -## Why - -Most "semantic layers for AI" make you maintain a parallel schema spec (YAML -dimensions/measures) and ship a runtime that exposes a fixed menu of queries -to the LLM. That fights the way modern agents work. - -wlens goes the other direction. It **exposes the schema as markdown** — -descriptions, column types, `relationships()` / `accepted_values()` tests, -parent models, sample rows, compiled SQL — one file per entity. The LLM -reads like it would any other codebase; when it needs data, it runs a -read-only SELECT. - -- **Zero runtime to explore.** Markdown is committed to the repo. No - warehouse credentials needed until the agent actually runs a query. -- **Zero parallel definitions.** Reads directly from dbt artifacts. -- **LLM-optimized format.** Per-column H3 headers for greppability, enum - values listed inline (via `accepted_values` tests), foreign keys - surfaced (via `relationships` tests), column-first sample rows, - one-sentence index summaries. -- **Human-editable.** The markdown is the data. Team-authored notes - preserved across regeneration under a marker. -- **Two deployment modes.** Solo (filesystem + CLI) or team (MCP server - with bearer auth). +wlens turns your dbt project into per-table markdown that any AI agent +can read. It also ships a read-only SQL command line so the agent can +query the warehouse, and an MCP server so your teammates can ask their +own questions. -## Extensibility - -Beyond dbt models and sources, wlens lets you describe **table -catalogs** — per-table catalogs of named row-instances like analytics -events, feature flags, customer attributes, subscription plans. The -library teaches no domains: it ships only the base `TableCatalog` -class plus a plugin loader. New kinds live in *your* repo, and the -worked examples in [`examples/`](examples/) are sized to be cloned -and adapted by an LLM agent (Claude Code, Cursor, Codex). - -See [`docs/table-catalogs.md`](docs/table-catalogs.md). +Works with any MCP client, including Claude Code, Cursor, Gemini CLI, +Codex, Claude Desktop, ChatGPT, Continue, and Pi. ## Quickstart @@ -51,8 +14,8 @@ See [`docs/table-catalogs.md`](docs/table-catalogs.md). # 1. Install uv tool install wlens # or: pip install wlens -# 2. Initialise at the root of a dbt project -cd ~/your-dbt-project +# 2. Initialise from your repo root +cd ~/your-repo wlens init # 3. Compile the dbt project (needs a profile configured) @@ -64,52 +27,88 @@ wlens generate # 5. Your AI agent can now explore wlens/schema/ and run queries ``` -`wlens init` auto-detects `dbt_project.yml` (in cwd, then common subdirs -like `transform/`, `dbt/`) and any `*.duckdb` file, and wires the config -accordingly. For Redshift / Postgres you edit a few env var names. +Your dbt project can sit at the repo root or in a subfolder like +`dbt/`, `transform/`, or `analytics/`. `wlens init` scans the current +directory and a few levels of subfolders for `dbt_project.yml` and +picks the shallowest match. It also picks up any `*.duckdb` file and +wires the config for you. For Redshift or Postgres, you edit a few env +var names. -After init, your project looks like: +After init, your project looks like this: ``` your-repo/ - wlens.yml # config (root, like pyproject.toml) - wlens/ - .gitignore # auto: ignores cache/ and share/ - schema/ # generated per-table markdown (commit this) - _index.md - ..md - cache/ # query cache (gitignored) - share/ # ngrok config files (gitignored) - .claude/ - skills/ - wlens/ - SKILL.md # Claude Code skill (convention location) +├── wlens.yml # config (root, like pyproject.toml) +├── wlens/ +│ ├── .gitignore # auto: ignores cache/ and share/ +│ ├── schema/ # generated per-table markdown (commit this) +│ │ ├── _index.md +│ │ └── .
.md +│ ├── cache/ # query cache (gitignored) +│ └── share/ # MCP config drop-ins for teammates (gitignored) +├── .claude/skills/wlens/SKILL.md # Claude Code +└── .agents/skills/wlens/SKILL.md # Gemini CLI / Codex / Cursor / VS Code Copilot (open standard) ``` -## The three-move pattern +Two skill files with the same content. Claude Code reads +`.claude/skills/`. Every other major agent reads `.agents/skills/`, +which is the open standard at [agentskills.io](https://agentskills.io), +including Gemini CLI, Codex, Cursor, and GitHub Copilot in VS Code. + +## How it works -Every warehouse question follows the same shape, regardless of client: +wlens reads your dbt artifacts and writes one markdown file per table. +The agent reads those files when it needs to find a table or understand +a column. When it needs data, it runs a SELECT through the wlens command +line. -1. **Discover** — find candidate tables. -2. **Read** — open one table's full docs. -3. **Query** — execute a read-only SELECT. +The markdown is committed to your repo. The agent can grep and read it +without warehouse credentials. Credentials are only needed when a query +runs. -### In filesystem clients (Claude Code, Cursor, Continue, Codex) +### Why markdown -The LLM uses its built-in `Grep` + `Read` against `wlens/schema/*.md`, -then shells out to `wlens query "SELECT …"`. +The agent already knows how to read code. Markdown lets it use the same +tools, grep and read, on your warehouse schema. Each column gets its own +header. Foreign keys and accepted values come from your dbt tests. +Notes you write by hand are kept when wlens regenerates the file. + +## The three-move pattern -### In MCP clients (Claude Desktop, or any hosted MCP client) +Every warehouse question follows the same shape, regardless of client. -The LLM calls four **tools** exposed by the wlens MCP server: +1. **Discover.** Find candidate tables. +2. **Read.** Open one table's full docs. +3. **Query.** Run a read-only SELECT. -- `search_models(keyword)` — keyword-grep the docs, returns matches with snippets. -- `list_models()` — full catalog when no keyword fits. -- `read_model(name)` — full markdown for one entity. -- `execute_sql(query)` — run the SELECT. +In filesystem clients like Claude Code, Gemini CLI, Cursor, Continue, +Codex, and Pi, the agent uses its built-in grep and read on +`wlens/schema/*.md`, then shells out to `wlens query "SELECT ..."`. + +In MCP clients like Claude Desktop, Antigravity, ChatGPT, or any hosted +MCP client, the agent calls four tools exposed by the wlens MCP server. + +- `search_models(keyword)`. Keyword-grep the docs and return matches with snippets. +- `list_models()`. Full catalog when no keyword fits. +- `read_model(name)`. Full markdown for one entity. +- `execute_sql(query)`. Run the SELECT. Same pattern, different primitives. +## Extensibility + +wlens covers dbt models and sources by default. It also lets you +describe table catalogs, which are per-table catalogs of named +row-instances like analytics events, feature flags, customer attributes, +or subscription plans. + +The library teaches no domains. It ships the base `TableCatalog` class +and a plugin loader. New kinds live in your own repo. The worked +examples in [`examples/`](examples/) are sized to be cloned and adapted +by an AI agent. + +See [`docs/table-catalogs.md`](docs/table-catalogs.md). + ## Architecture ``` @@ -124,19 +123,19 @@ dbt artifacts → wlens generate → wlens/schema/*.md ─┬─► filesys → warehouse (read-only, on demand) ``` -## `wlens query` — the CLI +## `wlens query` -A Bash-invocable SQL runner with a **hard read-only guard**. Parses your -SQL and rejects anything that isn't a single `SELECT` / `WITH … SELECT`. +A SQL runner with a hard read-only guard. wlens parses your SQL and +rejects anything that is not a single `SELECT` or `WITH ... SELECT`. Multi-statement queries are also rejected. Results come back as a -markdown table, cached under `wlens/cache/sql/` with a daily TTL -(`CURRENT_DATE`-relative queries refresh each day). +markdown table. Each query is cached under `wlens/cache/sql/` with a +daily TTL, so `CURRENT_DATE`-relative queries refresh each day. ```bash wlens query "SELECT count(*) FROM main_marts.fct_invoice" ``` -Multi-line via heredoc: +For multi-line queries, use a heredoc. ```bash wlens query <<'SQL' @@ -151,98 +150,129 @@ SQL | Command | Purpose | |---|---| -| `wlens init` | Write `wlens.yml` + `.claude/skills/wlens/SKILL.md` + `wlens/.gitignore`. Auto-detects dbt project and `.duckdb` files. | -| `wlens generate` | Read dbt's `target/manifest.json`, write per-table markdown into `wlens/schema/`. | +| `wlens init` | Write `wlens.yml`, the skill files for Claude Code and the open standard, and `wlens/.gitignore`. Auto-detects dbt project and `.duckdb` files. | +| `wlens generate` | Read dbt's `target/manifest.json` and write per-table markdown into `wlens/schema/`. | | `wlens query "SELECT ..."` | Run a read-only query. | -| `wlens tag-pii` | Scan dbt yml, add `meta: pii: true` to likely-PII columns. | -| `wlens mcp` | Start the MCP server for team / demo use. | -| `wlens mcp-proxy ` | Stdio↔HTTP bridge; used by Claude Desktop to reach a remote wlens. | +| `wlens tag-pii` | Scan dbt yml and add `meta: pii: true` to columns that look like PII. | +| `wlens mcp` | Start the MCP server for team or demo use. | +| `wlens mcp-proxy ` | Stdio to HTTP bridge. Used by Claude Desktop to reach a remote wlens. | +| `wlens mcp-clients --url ... [--token ...]` | Generate per-client MCP config files for a deployed wlens server. | | `wlens clean` | Remove every file wlens installed in this repo. | ## Supported warehouses -| Warehouse | v0.1 | v0.2 | -|------------|:----:|:----:| -| DuckDB | ✅ | | -| Postgres | ✅ | | -| Redshift | ✅ | | -| BigQuery | | ⏳ | -| Snowflake | | ⏳ | +| Warehouse | Status | +|------------|:------:| +| DuckDB | v0.1 ✅ | +| Postgres | v0.1 ✅ | +| Redshift | v0.1 ✅ | +| BigQuery | v0.2 ⏳ | +| Snowflake | v0.2 ⏳ | ## PII handling -Sample rows committed to the repo go through two redaction layers: +Sample rows committed to the repo go through two redaction layers. 1. **Explicit.** Any dbt column with `meta: pii: true` renders as ``. -2. **Regex safety net.** Column names matching built-in PII patterns - (`email`, `first_name`, `phone`, `ip_address`, etc.) are redacted +2. **Regex safety net.** Column names that match built-in PII patterns + like `email`, `first_name`, `phone`, or `ip_address` are redacted even without the flag. -Run `wlens tag-pii` to backfill the explicit flags (`--dry-run` to preview). +Run `wlens tag-pii` to backfill the explicit flags. Use `--dry-run` to preview. ## Distribution tiers -wlens ships one binary, three escalating modes: +wlens ships one binary with three modes. -### 1. Solo — `wlens init` + `wlens generate` +### 1. Solo. `wlens init` plus `wlens generate` -Install wlens, point it at a dbt project, use Claude Code / Cursor / -Continue / Codex with the bundled skill + `wlens query`. Works today. +Install wlens, point it at a dbt project, and use it from any agent +with the bundled skill and `wlens query`. Works today. -### 2. Demo a teammate — `wlens mcp --dangerously-share` +### 2. Demo to a teammate. `wlens mcp --dangerously-share` ```bash wlens mcp --dangerously-share ``` -Starts the wlens MCP server locally, opens an ngrok tunnel, auto-generates -a bearer token, and writes three drop-in files under `wlens/share/`: - -- `wlens.mcpb` — double-click to install into Claude Desktop. mcp-remote - and Python deps are pre-bundled; recipient needs nothing beyond Claude. -- `claude_desktop_config.json` — paste into Claude Desktop's config if - you prefer editing by hand. -- `.mcp.json` — drop into a project root for Claude Code (native HTTP). - -Dies when you Ctrl-C the process. The `--dangerously-` prefix is -deliberate: a public URL fronting a warehouse is not a production posture. +Starts the wlens MCP server locally, opens an ngrok tunnel, generates a +bearer token, and writes drop-in config files into `wlens/share/`. + +- `wlens.mcpb`. Double-click to install into Claude Desktop. Python + deps are pre-bundled, so the recipient needs nothing beyond Claude + Desktop. +- `claude_desktop_config.json`. Claude Desktop config for manual paste. +- `.mcp.json`. Claude Code config (drop into a project root). The same + shape works at `.cursor/mcp.json`, `.vscode/mcp.json`, and elsewhere. +- `gemini_settings.json`. Gemini CLI and Antigravity. The field name is + `httpUrl`. +- `codex_config.toml`. Codex CLI. TOML, merge into + `~/.codex/config.toml`. + +The banner prints the install command for each client. ChatGPT, Cursor, +VS Code Copilot, Windsurf, Continue, Cline, Zed, and Pi all reuse one of +these shapes. See [`docs/mcp.md`](docs/mcp.md) for the paste targets. + +The server runs on your laptop and dies when you Ctrl-C it. The public +URL is an ngrok tunnel, so it changes every run and goes away when the +process does. The bearer token is shared, written into five config +files, and printed to your terminal. There is no revocation short of +killing the server, and no audit trail per teammate. The +`--dangerously-` prefix is deliberate. This is fine for showing a +teammate over coffee, not for sustained access to a warehouse. + +### 3. Team deployment. `wlens mcp` on your infra + +Team deployments run on your own infra, like Railway, Fly, Cloud Run, a +VPS, or k8s. wlens does not ship Terraform or Helm. What it does ship is +bearer auth, a `/health` endpoint, a `/refresh` endpoint for CI-driven +doc updates, structured logging, and fail-closed startup rules. + +Before you deploy, check the following. + +- **Read-only warehouse role.** Create a database user with + `SELECT`-only grants. The in-app guard is defence in depth. The role + is primary. +- **`WLENS_AUTH_TOKEN`.** Required. `wlens mcp` refuses to start on a + non-local bind without it. +- **TLS at the platform layer.** Your platform terminates TLS. wlens + binds plain HTTP behind it. +- **CI-driven docs refresh.** After dbt merges, have CI `POST /refresh` + so docs stay current without redeploying. +- **Talk to your platform team.** Auth, secrets, and network policy are + their job, not wlens's. -### 3. Team deployment — `wlens mcp` on your infra +Once those are in place, run the server on whatever runs your +container: ```bash export WLENS_AUTH_TOKEN=$(openssl rand -hex 32) wlens mcp --port 8000 --allowed-host "*" ``` -Team deployments are **your infra's responsibility** — Railway / Fly / -Cloud Run / VPS / k8s. wlens doesn't ship Terraform or Helm. What it -does ship: bearer auth, `/health`, `/refresh` for CI-driven doc updates, -structured logging, fail-closed startup rules. +Then generate the per-client config files for teammates, pointing at +your server URL: -**Before you deploy, check:** +```bash +export WLENS_AUTH_TOKEN= +wlens mcp-clients --url https://wlens.team.com/mcp +``` -- **Read-only warehouse role.** Create a DB user with `SELECT`-only - grants. The in-app guard is defence in depth — the role is primary. -- **`WLENS_AUTH_TOKEN`.** Required. `wlens mcp` refuses to start on a - non-local bind without it. -- **TLS at the platform layer.** Your platform terminates TLS; wlens - binds plain HTTP behind it. -- **CI-driven docs refresh.** After dbt merges, have CI `POST /refresh` - so docs stay current without redeploying. -- **Talk to your platform team.** Auth, secrets, network policy — their - job, not wlens's. +This writes `.mcp.json`, `claude_desktop_config.json`, +`gemini_settings.json`, `codex_config.toml`, and `wlens.mcpb` into +`wlens/share/`. Each file embeds the bearer token, so distribute them +through whatever channel you'd use for any other secret. See [`docs/mcp.md`](docs/mcp.md) for the full reference. ## Roadmap -- **v0.1** (current): dbt adapter; DuckDB + Postgres + Redshift - executors; `wlens init / generate / query / tag-pii / clean`; MCP +- **v0.1** (current). dbt adapter. DuckDB, Postgres, and Redshift + executors. `wlens init / generate / query / tag-pii / clean`. MCP server (`wlens mcp`) with bearer auth, four tools, resources, prompts, - `/refresh` endpoint; `--dangerously-share` with `.mcpb` bundle + drop-in - config files; stdio↔HTTP proxy (`wlens mcp-proxy`). -- **v0.2**: sqlmesh adapter; BigQuery + Snowflake executors; Claude Code - plugin; Claude.ai-remote OAuth support. + and a `/refresh` endpoint. `--dangerously-share` with the `.mcpb` + bundle and drop-in config files. Stdio to HTTP proxy + (`wlens mcp-proxy`). ## License From ddad5cf656962104f26db615da4ce949c8a4e858 Mon Sep 17 00:00:00 2001 From: David Baussart Date: Tue, 28 Apr 2026 16:42:59 +0200 Subject: [PATCH 5/5] chore: bump version to 0.1.1 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a27ca4f..270bd89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "wlens" -version = "0.1.0" +version = "0.1.1" description = "Warehouse lens for AI agents — per-table markdown docs + read-only SQL adaptor, zero-runtime schema exploration for Claude Code, Cursor, Continue." readme = "README.md" license = { file = "LICENSE" } diff --git a/uv.lock b/uv.lock index 1be2307..6edf1aa 100644 --- a/uv.lock +++ b/uv.lock @@ -1159,7 +1159,7 @@ wheels = [ [[package]] name = "wlens" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "anyio" },