diff --git a/CHANGELOG.md b/CHANGELOG.md index 3576618..fd592b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,56 @@ All notable changes to this SDK are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [SemVer](https://semver.org/). +## [2.8.0] - 2026-08-13 + +Adds a setup command. The SDK surface is unchanged. + +### Added + +- **`lenz init` — wire Lenz into an MCP client.** Writes the Lenz MCP server + block into Claude Code (`./.mcp.json`), Cursor (`./.cursor/mcp.json`) or + Codex (`./.codex/config.toml`), then verifies the key with one authenticated + request. `--print` emits the config without touching anything, which is also + the answer for clients this doesn't know about. + + Codex is TOML, so its table is APPENDED as text rather than parsed and + re-serialized — every TOML library drops comments and reflows formatting, and + handing someone back a file that is equivalent but visibly not theirs is the + same failure as clobbering it. A config already declaring + `[mcp_servers.lenz]` is refused: TOML rejects duplicate tables, so a second + copy would stop the whole file parsing. Codex also takes + `bearer_token_env_var`, so no key is written there at all. + + **Claude Desktop writes nothing and prints its connector steps instead.** + `claude_desktop_config.json` is documented for local stdio servers only; a + remote streamable-HTTP server is added through Settings → Connectors → Add + custom connector. Writing that file put a live key somewhere nothing reads. + + The odd one out among the verbs: every other command calls the API, this one + configures. Existing MCP servers in the target file are preserved — only the + `lenz` key is written — and a file that exists but does not parse as JSON is + refused rather than overwritten, since guessing there could silently discard + servers configured by hand. Writes are atomic (temp file + replace) and the + file is created `0600`. + + **The key is not written into the project configs.** `.mcp.json` is a file + Claude Code's documentation tells teams to check into version control, so + writing a live credential there by default would be handing the user a leak. + Claude Code and Cursor get an environment-variable reference instead — in + each client's own syntax, `${LENZ_API_KEY}` and `${env:LENZ_API_KEY}`, which + are not interchangeable — and the command prints the `export` line to run. + `--write-key` opts out, for a private checkout. Claude Desktop still gets the + key itself: its config is global and the app never sees an exported variable. + `--json` reports which happened as `key_in_config` / `key_env_var`. + + A failed key check is reported as a key problem, not a failed init: the + config is already written and correct at that point, and saying which of the + two broke stops people re-running `init` at something it cannot fix. + + Parity: `npx lenz-io init` in the Node SDK is the same command, writing the + same block to the same locations. A developer who set one machine up with + each must not get two different results — change them together. + ## [2.7.0] - 2026-08-10 Quota errors are now a first-class, typed condition instead of an diff --git a/README.md b/README.md index 18e1b98..066d3da 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ pip install "lenz-io[cli]" # or into your current environment ```bash lenz login # paste an API key (free — get one at lenz.io/api-integration) +lenz init # wire Lenz into Claude Code / Claude Desktop / Cursor lenz extract "Einstein won the 1921 Nobel for relativity" # free, 1000/day lenz assess "The Great Wall is visible from space" # fast verdict lenz verify "Water boils at 90C at sea level" # full pipeline (~90s) @@ -67,6 +68,60 @@ them; resolve it non-interactively by index (spawns one verification per pick): lenz verify --resume "$tid" --claim 1,3 --detach --json # → spawned task_ids ``` +### `lenz init` — wire Lenz into an MCP client + +The one command here that **configures rather than calls**. It writes the Lenz +MCP server into an AI client's config and checks the key with one authenticated +request, so Claude Code, Claude Desktop or Cursor can fact-check inside a +conversation. + +```bash +lenz init # writes ./.mcp.json (Claude Code) +lenz init --client cursor # writes ./.cursor/mcp.json +lenz init --client codex # appends to ./.codex/config.toml +lenz init --client claude-desktop # prints the connector steps +lenz init --print # print the config, write nothing +``` + +**Codex is TOML.** The Lenz table is appended to your existing +`.codex/config.toml` as text rather than parsed and rewritten, so comments and +formatting survive untouched, and a config already declaring +`[mcp_servers.lenz]` is refused rather than given a second one — TOML rejects +duplicate tables, and a second copy would stop the whole file parsing. Codex +takes `bearer_token_env_var`, so no key is written there at all. + +**Claude Desktop takes no config file.** `claude_desktop_config.json` is for +local stdio servers; a remote server like Lenz is added through Settings → +Connectors → "Add custom connector", or in one click from +[the directory](https://claude.ai/directory/connectors/lenz). `lenz init +--client claude-desktop` prints those steps rather than writing anything. + +**Your key does not go into the project configs.** `.mcp.json` and +`.cursor/mcp.json` live in your repo, and Claude Code's documentation says to +check `.mcp.json` into version control so your team shares the same servers. So +those two get an environment-variable reference, and you export the key: + +```bash +export LENZ_API_KEY=lenz_... # add to your shell profile to make it stick +``` + +Each client spells that reference differently — `${LENZ_API_KEY}` for Claude +Code, `${env:LENZ_API_KEY}` for Cursor — and `init` writes the right one. Pass +`--write-key` to put the key in the file instead, for a private checkout. +Claude Desktop always gets the key itself: its config is global, and the app is +launched from the desktop rather than a shell, so it never sees an exported +variable. + +Existing MCP servers in that file are preserved — only the `lenz` key is +written. A file that exists but isn't valid JSON is refused rather than +overwritten, because guessing there could silently discard servers you +configured by hand. Writes are atomic (temp file + replace) and the file is +created `0600`. + +`npx lenz-io init` in the [Node SDK](https://github.com/lenzhq/lenz-io-node) +is the same command, writing the same block to the same places — use whichever +runtime you already have. + ## Quickstart — the canonical integration ```python diff --git a/src/lenz_io/cli/app.py b/src/lenz_io/cli/app.py index 3551e53..a50c2cb 100644 --- a/src/lenz_io/cli/app.py +++ b/src/lenz_io/cli/app.py @@ -14,7 +14,7 @@ from lenz_io import __version__ from lenz_io.client import DEFAULT_BASE_URL -from . import commands +from . import commands, init_cmd from . import verify as verify_mod from .config import ENV_BASE_URL, ConfigError, resolve_all from .context import CLIState @@ -72,6 +72,10 @@ def _main( app.command("status")(commands.status) app.command("show")(commands.show) app.command("ask")(commands.ask) +# `init` configures rather than calls — it wires Lenz into an MCP client. +# Listed right after the verbs so it is visible to someone who has just +# installed the CLI and is looking for what to do next. +app.command("init")(init_cmd.init) app.command("login")(commands.login) app.command("logout")(commands.logout) app.command("config")(commands.config_status) diff --git a/src/lenz_io/cli/init_cmd.py b/src/lenz_io/cli/init_cmd.py new file mode 100644 index 0000000..f71f399 --- /dev/null +++ b/src/lenz_io/cli/init_cmd.py @@ -0,0 +1,257 @@ +"""``lenz init`` — wire Lenz into an MCP client. + +The odd one out among the CLI's verbs: every other command CALLS the API, +this one writes a config file. It lives here rather than in +:mod:`.commands` for the same reason ``verify`` does — it owns a small +workflow (resolve path → merge → write → verify) instead of being +"call one SDK method and render". + +Parity note: this is the same command as ``npx lenz-io init`` in the Node +SDK — same server block, same config locations, same merge-don't-overwrite +rule. A developer who set one machine up with one SDK and another with the +other must not get two different results. Change them together. +""" + +from __future__ import annotations + +from pathlib import Path + +import typer + +from lenz_io import Lenz + +from .client import build_client +from .context import CLIState +from .errors import CLIError +from .mcp_config import ( + CLIENT_CHOICES, + CLIENT_LABELS, + CLIENT_MANUAL_STEPS, + CLIENT_RESTART_NOTES, + CODEX_TABLE, + CONSOLE_URL, + KEY_ENV_VAR, + KEY_PLACEHOLDERS, + MANUAL_CLIENTS, + SETUP_URL, + ConfigUnreadable, + DuplicateCodexTable, + build_codex_block, + config_path_for, + credential_for, + merge_config, + merge_toml_config, + read_existing, + write_config, + write_text_config, +) +from .render import Output + + +def init( + ctx: typer.Context, + client_name: str = typer.Option( + "claude-code", + "--client", + "-c", + help=f"Which MCP client to configure ({', '.join(CLIENT_CHOICES)}).", + ), + print_only: bool = typer.Option(False, "--print", help="Print the config JSON and exit, writing nothing."), + no_verify: bool = typer.Option(False, "--no-verify", help="Skip the authenticated check."), + write_key: bool = typer.Option( + False, + "--write-key", + help=( + f"Write the key itself into the config. Off by default: project configs " + f"are commonly committed, so they get a ${KEY_ENV_VAR} reference instead." + ), + ), +) -> None: + """Write the Lenz MCP server into an AI client's config, then check the key works. + + Unlike the other commands, this configures rather than calls: it wires Lenz + into Claude Code, Claude Desktop or Cursor so they can fact-check inside a + conversation. + """ + state: CLIState = ctx.obj + out = state.output + + if client_name not in CLIENT_CHOICES: + raise CLIError( + f"Unknown client {client_name!r}.", + code="unknown_client", + fix=f"Choose one of: {', '.join(CLIENT_CHOICES)}.", + exit_code=2, + ) + + # Clients with no config file: print the route and stop. Nothing to write, + # nothing to verify against — the connector flow runs its own sign-in. + if client_name in MANUAL_CLIENTS: + if out.json_mode: + out.emit_json( + { + "status": "manual", + "client": client_name, + "config_file": None, + "instructions": CLIENT_MANUAL_STEPS[client_name], + } + ) + else: + out.console.print(CLIENT_MANUAL_STEPS[client_name]) + raise SystemExit(0) + + # --print needs no key: its whole purpose is handing someone a config to + # paste and fill in themselves, including for clients we don't support. + # What it prints must be what a write for the SAME client would produce, + # or it stops being a preview. + if print_only: + api_key = state.api_key.strip() + if client_name == "codex": + out.console.print(build_codex_block(api_key, write_key=write_key)) + raise SystemExit(0) + if api_key: + credential, _ = credential_for(client_name, api_key, write_key=write_key) + else: + credential = KEY_PLACEHOLDERS.get(client_name) or f"${{{KEY_ENV_VAR}}}" + out.emit_json(merge_config(None, credential)) + raise SystemExit(0) + + if state.key_source == "none": + raise CLIError( + "No API key.", + code="no_api_key", + fix=f"Run `lenz login`, set LENZ_API_KEY, or create one at {CONSOLE_URL}", + exit_code=2, + ) + + path = config_path_for(client_name) + if path is None: + raise CLIError( + f"Can't locate {CLIENT_LABELS[client_name]}'s config on this platform.", + code="unsupported_platform", + fix="Run `lenz init --print` and paste the JSON in yourself.", + ) + + if client_name == "codex": + # TOML merges as TEXT — see mcp_config.merge_toml_config. + try: + merged = merge_toml_config( + path.read_text(encoding="utf-8") if path.exists() else "", + build_codex_block(state.api_key, write_key=write_key), + ) + except DuplicateCodexTable: + raise CLIError( + f"{path} already has a {CODEX_TABLE} entry.", + code="duplicate_codex_table", + fix=( + "Edit or remove it and re-run — a second copy is a duplicate-table " + "error and would stop the whole file parsing." + ), + ) from None + write_text_config(path, merged) + is_placeholder = not write_key + else: + try: + existing = read_existing(path) + except ConfigUnreadable as exc: + # Refuse rather than clobber — see mcp_config.read_existing. + raise CLIError( + f"{path} exists but is not valid JSON ({exc}).", + code="config_unreadable", + fix="Fix or move that file, then re-run — refusing to overwrite a config we can't read.", + ) from None + + credential, is_placeholder = credential_for(client_name, state.api_key, write_key=write_key) + write_config(path, merge_config(existing, credential)) + + verified = "" + if not no_verify: + verified = _verify_key(state) + + if out.json_mode: + out.emit_json( + { + "status": "ok", + "client": client_name, + "config_file": str(path), + "verified": bool(verified) if not no_verify else None, + # So a script can tell whether the config is self-contained or + # still needs the variable exported. + "key_in_config": not is_placeholder, + "key_env_var": KEY_ENV_VAR if is_placeholder else None, + } + ) + return + + render_success( + out, + client_name=client_name, + path=path, + verified=verified, + is_placeholder=is_placeholder, + api_key=state.api_key, + ) + + +def render_success( + out: Output, + *, + client_name: str, + path: Path, + verified: str, + is_placeholder: bool, + api_key: str, +) -> None: + """The human-readable tail of a successful init. + + A separate function because ``Output.json_mode`` is ``json_mode or not + sys.stdout.isatty()``, so under CliRunner it is always JSON and this branch + is unreachable through ``runner.invoke``. Same shape the render tests in + ``tests/test_cli.py`` use. + """ + out.console.print(f"Wrote Lenz MCP server to [bold]{path}[/bold]") + if verified: + out.console.print(f"[green]Key verified[/green] — {verified}.") + if is_placeholder: + # Without this the run reads as finished — "wrote the config, key + # verified" — while the client still has nothing to authenticate with. + out.console.print( + f"\nThat config references ${KEY_ENV_VAR} instead of storing your key, because " + f"{path.name} lives in your project and is commonly committed." + ) + out.console.print("Export the key where your client will see it:\n") + out.console.print(f" export {KEY_ENV_VAR}={api_key}\n") + out.console.print( + "Add that to your shell profile to make it stick, or re-run with " + "--write-key to put the key in the file instead.\n" + ) + out.console.print(CLIENT_RESTART_NOTES[client_name]) + out.console.print(f"More setup notes: {SETUP_URL}") + + +def _verify_key(state: CLIState) -> str: + """One authenticated call, so the user learns now whether the key works. + + A failure here is NOT a failed init: the config is already written and + correct, and only the key is in doubt. Saying which of the two broke stops + people from re-running init at a problem it cannot fix. + """ + out = state.output + client: Lenz = build_client(api_key=state.api_key, base_url=state.base_url) + try: + with out.working("Checking the key…"): + usage = client.usage() + except Exception as exc: + raise CLIError( + f"Config written, but the key did not authenticate: {exc}", + code="key_unverified", + fix=f"Check it at {CONSOLE_URL}, then edit the config file.", + ) from None + finally: + client.close() + + remaining = getattr(getattr(usage, "verify", None), "remaining", None) + return f"{remaining} verify calls remaining" if isinstance(remaining, int) else "key accepted" + + +__all__ = ["init", "render_success"] diff --git a/src/lenz_io/cli/mcp_config.py b/src/lenz_io/cli/mcp_config.py new file mode 100644 index 0000000..546a644 --- /dev/null +++ b/src/lenz_io/cli/mcp_config.py @@ -0,0 +1,293 @@ +"""Writing the Lenz MCP server block into an AI client's config file. + +Pure, side-effect-light helpers so the merge rules can be tested without a +filesystem. The command that drives them lives in :mod:`.commands`. + +The behaviour mirrors ``npx lenz-io init`` in the Node SDK — same server +block, same config locations, same merge-don't-overwrite rule — because a +developer who set one machine up with one SDK and another with the other must +not get two different results. +""" + +from __future__ import annotations + +import json +import os +import re +import tempfile +from pathlib import Path +from typing import Any + +MCP_SERVER_URL = "https://lenz.io/mcp" +CONSOLE_URL = "https://lenz.io/api-integration" +SETUP_URL = "https://lenz.io/setup" + +# The environment variable the SDK already reads, and the one the placeholders +# below name. Shared with the Node SDK — see KEY_PLACEHOLDERS. +KEY_ENV_VAR = "LENZ_API_KEY" + +CLAUDE_CONNECTORS_URL = "https://claude.ai/directory/connectors/lenz" + +CLIENT_CHOICES = ("claude-code", "cursor", "codex", "claude-desktop") + +CLIENT_LABELS = { + "claude-code": "Claude Code", + "claude-desktop": "Claude Desktop", + "cursor": "Cursor", + "codex": "Codex", +} + +# Clients configured through their own interface rather than a config file. +# +# claude_desktop_config.json is documented for local STDIO servers only — +# every example in Anthropic's docs is command/args. A remote streamable-HTTP +# server like ours is added through Settings → Connectors → Add custom +# connector, which runs its own sign-in. +# +# We used to write a ``{"type": "http", "headers": {...}}`` entry into that +# file, which is not a documented shape for it — and Claude Desktop was the one +# client we handed the literal key to, so the likely outcome was a live +# credential sitting in a file nothing reads. +MANUAL_CLIENTS = frozenset({"claude-desktop"}) + +# The TOML table Codex keys its server on. A second one is a duplicate-table +# error that stops the whole file parsing. +CODEX_TABLE = "[mcp_servers.lenz]" + +# How each client spells an environment-variable reference inside a config +# value — None when it cannot resolve one at all. +# +# The syntaxes are NOT interchangeable. Claude Code takes ``${VAR}``; Cursor +# takes ``${env:VAR}`` and treats a bare ``${VAR}`` as literal text, which +# reaches the server as a nonsense bearer token and 401s with no clue why. +# +# Only the JSON clients are here. Codex names the variable in a field of its +# own (``bearer_token_env_var``), and Claude Desktop takes no config file. +KEY_PLACEHOLDERS: dict[str, str | None] = { + "claude-code": f"${{{KEY_ENV_VAR}}}", + "cursor": f"${{env:{KEY_ENV_VAR}}}", +} + +# (No separate "is project-scoped" set: a non-None placeholder above IS that +# fact. Two tables encoding the same thing is two tables that can disagree.) + +# Printed after a successful write — every one of these clients reads its MCP +# config at launch only, so "it didn't work" is nearly always "didn't restart". +CLIENT_RESTART_NOTES = { + "claude-code": "Restart your Claude Code session so the server loads.", + "cursor": "Reload the Cursor window so the server loads.", + "codex": "Restart the Codex session so the server loads.", +} + +# Printed for MANUAL_CLIENTS instead of writing anything. +CLIENT_MANUAL_STEPS = { + "claude-desktop": ( + f"{CLAUDE_CONNECTORS_URL} adds Lenz to your account in one click — no key to paste.\n" + "\n" + "Or add it by hand:\n" + " 1. Claude Desktop → Settings → Connectors\n" + ' 2. "Add" → "Add custom connector"\n' + f" 3. Paste {MCP_SERVER_URL} and complete the sign-in prompt\n" + "\n" + "Claude Desktop takes remote servers through that flow, not through\n" + "claude_desktop_config.json — that file is for local stdio servers." + ), +} + + +def build_server_config(api_key: str) -> dict[str, Any]: + """The Lenz entry for an ``mcpServers`` map.""" + return { + "type": "http", + "url": MCP_SERVER_URL, + "headers": {"Authorization": f"Bearer {api_key}"}, + } + + +def credential_for(client: str, api_key: str, *, write_key: bool = False) -> tuple[str, bool]: + """Return ``(value_for_the_header, is_placeholder)``. + + Project-scoped configs get an environment-variable reference rather than + the key, because ``.mcp.json`` is a file its own documentation tells teams + to commit: "Check .mcp.json into version control so everyone on your team + gets the same MCP tools and services." A setup command whose happy path + writes a live credential into a tracked file is handing the user a leak. + + ``write_key`` is the opt-out, for a private checkout or a machine where + exporting a variable is more friction than it is worth. + """ + placeholder = KEY_PLACEHOLDERS.get(client) + if placeholder and not write_key: + return placeholder, True + return api_key, False + + +def build_codex_block(api_key: str, *, write_key: bool = False) -> str: + """Codex's server block. TOML, and no interpolation anywhere. + + ``bearer_token_env_var`` names an environment variable in a field of its + own, which is what Claude Code and Cursor need ``${VAR}`` / ``${env:VAR}`` + string syntax for — so the default writes no credential at all. + ``write_key`` uses ``http_headers`` instead, the other documented way to + authenticate. + """ + auth = ( + f'http_headers = {{ "Authorization" = "Bearer {api_key}" }}' + if write_key + else f'bearer_token_env_var = "{KEY_ENV_VAR}"' + ) + return f'{CODEX_TABLE}\nurl = "{MCP_SERVER_URL}"\n{auth}\n' + + +class DuplicateCodexTable(Exception): + """The config already declares ``[mcp_servers.lenz]``.""" + + +def merge_toml_config(existing: str, block: str) -> str: + """Append the Lenz table to an existing config.toml, as TEXT. + + Deliberately not a parse → mutate → re-serialize round trip. Every TOML + library drops comments and reflows formatting, so a round trip hands the + user back a file that is technically equivalent and visibly not theirs — + the same objection as clobbering a config we could not read. + + Appending is always valid: TOML tables are order-independent. The one thing + that is NOT safe is a second ``[mcp_servers.lenz]``, which is a + duplicate-key error that stops the whole file parsing — every other server + in it included — so that case raises. + """ + if re.search(rf"^\s*{re.escape(CODEX_TABLE)}", existing, re.MULTILINE): + raise DuplicateCodexTable(CODEX_TABLE) + trimmed = existing.rstrip() + return f"{trimmed}\n\n{block}" if trimmed else block + + +def merge_config(existing: Any, api_key: str) -> dict[str, Any]: + """Merge the Lenz server into an existing MCP config. + + Merges rather than replaces, which is the whole point of this function: + a developer's config routinely holds several servers, and a setup command + that overwrote the file would be actively destructive on exactly the + machines it exists to help. Only the ``lenz`` key is touched, and + non-dict values anywhere in the path are treated as absent rather than + crashing. + """ + base: dict[str, Any] = dict(existing) if isinstance(existing, dict) else {} + servers = base.get("mcpServers") + servers = dict(servers) if isinstance(servers, dict) else {} + servers["lenz"] = build_server_config(api_key) + base["mcpServers"] = servers + return base + + +def config_path_for(client: str, *, cwd: Path | None = None) -> Path | None: + """Where ``client`` keeps its MCP config, or None on an unsupported platform. + + Claude Code and Cursor are project-scoped: writing into the working + directory is what those tools expect, and it is the least surprising thing + a one-shot command can do — it cannot silently change behaviour in every + other project on the machine. Claude Desktop has no project concept, so + its config is necessarily global. + + ``cwd`` is a parameter rather than a ``Path.cwd()`` call so the + project-scoped clients are testable without chdir'ing the test process. + """ + root = Path(cwd) if cwd is not None else Path.cwd() + + if client == "claude-code": + return root / ".mcp.json" + if client == "cursor": + return root / ".cursor" / "mcp.json" + if client == "codex": + # TOML, and project-scoped for the same reason as the two above. + # ``~/.codex/config.toml`` is the global equivalent; the success note + # says so rather than writing there behind the user's back. + return root / ".codex" / "config.toml" + # claude-desktop included: it is configured through Settings → Connectors, + # so it has no path. See MANUAL_CLIENTS. + return None + + +class ConfigUnreadable(Exception): + """The config file exists but is not JSON we can safely rewrite.""" + + +def read_existing(path: Path) -> Any: + """Parse an existing config, or return None when there isn't one. + + Raises :class:`ConfigUnreadable` rather than guessing. Overwriting a file + we cannot parse could discard servers the user configured by hand, with no + way to get them back — refusing is the only safe answer. + """ + if not path.exists(): + return None + raw = path.read_text(encoding="utf-8").strip() + if not raw: + return None + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise ConfigUnreadable(str(exc)) from exc + + +def write_config(path: Path, data: Any) -> None: + """Write ``data`` to ``path`` atomically. + + Temp file in the same directory + replace, so a crash mid-write cannot + leave a truncated config behind — and same-directory keeps the replace on + one filesystem, where ``os.replace`` is atomic. Staging in the system temp + directory instead would raise ``OSError: [Errno 18] Invalid cross-device + link`` anywhere /tmp is its own filesystem, which is most Linux distros and + every container. + + ``mkstemp`` creates the file 0600 and ``os.replace`` preserves that mode. + Load-bearing, not incidental: with ``--write-key`` this file holds a live + credential, and a refactor to ``path.write_text`` would quietly widen it to + 0644. ``test_write_config_is_owner_only`` is the guard. + """ + write_text_config(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n") + + +def write_text_config(path: Path, text: str) -> None: + """The atomic + 0600 write itself, for callers holding text already. + + The TOML path merges textually, so it cannot go through ``write_config``'s + JSON serialization — but it must not lose the durability or the mode, which + is why this is one function rather than two write paths. + """ + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".lenz-mcp-", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(text) + os.replace(tmp, path) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + + +__all__ = [ + "CLAUDE_CONNECTORS_URL", + "CLIENT_CHOICES", + "CLIENT_LABELS", + "CLIENT_MANUAL_STEPS", + "CLIENT_RESTART_NOTES", + "CODEX_TABLE", + "CONSOLE_URL", + "KEY_ENV_VAR", + "KEY_PLACEHOLDERS", + "MANUAL_CLIENTS", + "MCP_SERVER_URL", + "SETUP_URL", + "ConfigUnreadable", + "DuplicateCodexTable", + "build_codex_block", + "build_server_config", + "config_path_for", + "credential_for", + "merge_config", + "merge_toml_config", + "read_existing", + "write_config", + "write_text_config", +] diff --git a/tests/test_cli_init.py b/tests/test_cli_init.py new file mode 100644 index 0000000..bb0e241 --- /dev/null +++ b/tests/test_cli_init.py @@ -0,0 +1,486 @@ +"""``lenz init`` — MCP config merging, path resolution, and the write path. + +The merge tests carry the most weight. A developer's MCP config routinely +holds several servers, and a setup command that replaced the file would be +actively destructive on exactly the machines it exists to help. + +Kept parallel to ``test/cli.test.ts`` in the Node SDK: same cases, same +expectations, because the two commands are a stated parity pair. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from lenz_io.cli import _run, init_cmd, normalize_argv +from lenz_io.cli import config as cfg +from lenz_io.cli import mcp_config as mcp +from lenz_io.cli.app import app +from lenz_io.cli.render import Output + +runner = CliRunner() + + +@pytest.fixture(autouse=True) +def _isolate(monkeypatch, tmp_path): + monkeypatch.delenv("LENZ_API_KEY", raising=False) + monkeypatch.delenv("LENZ_BASE_URL", raising=False) + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.setattr(cfg, "config_path", lambda: tmp_path / "config.json") + + +class _FakeUsage: + class verify: + remaining = 42 + + +class _FakeClient: + def __init__(self, raises=None): + self._raises = raises + + def usage(self): + if self._raises: + raise self._raises + return _FakeUsage() + + def close(self): + pass + + +def _patch_client(monkeypatch, fake): + # init builds its client through .client.build_client, imported into the + # module — patch the name init_cmd actually calls. + monkeypatch.setattr(init_cmd, "build_client", lambda **kw: fake) + monkeypatch.setattr(_run, "build_client", lambda **kw: fake) + + +# ── merge rules ───────────────────────────────────────────────────────────── +def test_build_server_config_points_at_the_remote_server(): + cfg_block = mcp.build_server_config("lenz_abc") + assert cfg_block["type"] == "http" + assert cfg_block["url"] == "https://lenz.io/mcp" + assert cfg_block["headers"]["Authorization"] == "Bearer lenz_abc" + + +def test_merge_creates_the_block_when_there_is_no_config(): + merged = mcp.merge_config(None, "lenz_abc") + assert list(merged["mcpServers"]) == ["lenz"] + + +def test_merge_preserves_other_servers(): + existing = { + "mcpServers": { + "github": {"type": "http", "url": "https://example.com/mcp"}, + "filesystem": {"command": "npx", "args": ["-y", "server-filesystem"]}, + } + } + + merged = mcp.merge_config(existing, "lenz_abc") + + assert sorted(merged["mcpServers"]) == ["filesystem", "github", "lenz"] + assert merged["mcpServers"]["github"]["url"] == "https://example.com/mcp" + + +def test_merge_preserves_unrelated_top_level_keys(): + assert mcp.merge_config({"theme": "dark", "mcpServers": {}}, "k")["theme"] == "dark" + + +def test_merge_replaces_a_previous_lenz_entry(): + existing = {"mcpServers": {"lenz": {"type": "http", "url": "old", "headers": {}}}} + + merged = mcp.merge_config(existing, "lenz_new") + + assert merged["mcpServers"]["lenz"]["url"] == "https://lenz.io/mcp" + assert merged["mcpServers"]["lenz"]["headers"]["Authorization"] == "Bearer lenz_new" + + +def test_merge_survives_a_malformed_mcpservers_value(): + assert "lenz" in mcp.merge_config({"mcpServers": "nonsense"}, "k")["mcpServers"] + + +# ── path resolution ───────────────────────────────────────────────────────── +def test_project_scoped_paths(tmp_path): + assert mcp.config_path_for("claude-code", cwd=tmp_path) == tmp_path / ".mcp.json" + assert mcp.config_path_for("cursor", cwd=tmp_path) == tmp_path / ".cursor" / "mcp.json" + + +def test_claude_desktop_has_no_config_file(): + """claude_desktop_config.json is documented for local STDIO servers only. + A remote streamable-HTTP server is added through Settings → Connectors, so + writing that file put a live key somewhere nothing reads it.""" + assert mcp.config_path_for("claude-desktop") is None + assert "claude-desktop" in mcp.MANUAL_CLIENTS + + +def test_codex_is_project_scoped_toml(tmp_path): + assert mcp.config_path_for("codex", cwd=tmp_path) == tmp_path / ".codex" / "config.toml" + + +def test_unknown_client_has_no_path(): + assert mcp.config_path_for("emacs") is None + + +# ── read / write ──────────────────────────────────────────────────────────── +def test_unreadable_config_raises_rather_than_guessing(tmp_path): + path = tmp_path / ".mcp.json" + path.write_text("{ this is not json") + + with pytest.raises(mcp.ConfigUnreadable): + mcp.read_existing(path) + + +def test_empty_file_reads_as_no_config(tmp_path): + path = tmp_path / ".mcp.json" + path.write_text(" \n") + assert mcp.read_existing(path) is None + + +def test_write_is_atomic_and_leaves_no_temp_files(tmp_path): + path = tmp_path / "nested" / "mcp.json" + + mcp.write_config(path, {"a": 1}) + + assert json.loads(path.read_text())["a"] == 1 + assert [p.name for p in path.parent.iterdir()] == ["mcp.json"] + + +# ── the command ───────────────────────────────────────────────────────────── +def test_init_writes_the_config_and_verifies(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + cfg.save_api_key("lenz_abc") + _patch_client(monkeypatch, _FakeClient()) + + result = runner.invoke(app, normalize_argv(["init"])) + + assert result.exit_code == 0, result.output + written = json.loads((tmp_path / ".mcp.json").read_text()) + # What goes in the Authorization header has its own tests below — see + # test_the_key_stays_out_of_a_project_config. + assert written["mcpServers"]["lenz"]["url"] == "https://lenz.io/mcp" + + +def test_init_cursor_creates_the_directory(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + cfg.save_api_key("lenz_abc") + _patch_client(monkeypatch, _FakeClient()) + + result = runner.invoke(app, normalize_argv(["init", "--client", "cursor", "--no-verify"])) + + assert result.exit_code == 0, result.output + written = json.loads((tmp_path / ".cursor" / "mcp.json").read_text()) + assert written["mcpServers"]["lenz"]["url"] == "https://lenz.io/mcp" + + +def test_init_merges_rather_than_replacing(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + (tmp_path / ".mcp.json").write_text( + json.dumps({"mcpServers": {"github": {"type": "http", "url": "https://example.com"}}}) + ) + cfg.save_api_key("lenz_abc") + _patch_client(monkeypatch, _FakeClient()) + + runner.invoke(app, normalize_argv(["init", "--no-verify"])) + + written = json.loads((tmp_path / ".mcp.json").read_text()) + assert written["mcpServers"]["github"]["url"] == "https://example.com" + assert "lenz" in written["mcpServers"] + + +def test_init_refuses_to_overwrite_an_unparseable_config(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + (tmp_path / ".mcp.json").write_text("{ this is not json") + cfg.save_api_key("lenz_abc") + _patch_client(monkeypatch, _FakeClient()) + + result = runner.invoke(app, normalize_argv(["init", "--no-verify"])) + + assert result.exit_code != 0 + # Left byte-identical — servers configured by hand are not ours to discard. + assert (tmp_path / ".mcp.json").read_text() == "{ this is not json" + + +def test_init_print_writes_nothing_and_needs_no_key(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, normalize_argv(["init", "--print"])) + + assert result.exit_code == 0, result.output + printed = json.loads(result.stdout) + assert printed["mcpServers"]["lenz"]["headers"]["Authorization"] == "Bearer ${LENZ_API_KEY}" + assert not (tmp_path / ".mcp.json").exists() + + +def test_init_without_a_key_errors_rather_than_writing(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, normalize_argv(["init", "--no-verify"])) + + assert result.exit_code != 0 + assert not (tmp_path / ".mcp.json").exists() + + +def test_a_bad_key_reports_the_key_not_the_config(monkeypatch, tmp_path): + """The config is already written and correct; only the key is in doubt. + Saying which broke stops people re-running init at a problem it can't fix.""" + monkeypatch.chdir(tmp_path) + cfg.save_api_key("lenz_bad") + _patch_client(monkeypatch, _FakeClient(raises=RuntimeError("401 unauthorized"))) + + result = runner.invoke(app, normalize_argv(["init"])) + + assert result.exit_code != 0 + assert (tmp_path / ".mcp.json").exists(), "the config write must not be rolled back" + + +def test_unknown_client_is_rejected(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + cfg.save_api_key("lenz_abc") + + result = runner.invoke(app, normalize_argv(["init", "--client", "emacs"])) + + assert result.exit_code != 0 + assert not (tmp_path / ".mcp.json").exists() + + +def test_json_mode_emits_the_machine_contract(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + cfg.save_api_key("lenz_abc") + _patch_client(monkeypatch, _FakeClient()) + + result = runner.invoke(app, normalize_argv(["--json", "init", "--no-verify"])) + + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + assert payload["status"] == "ok" + assert payload["client"] == "claude-code" + assert payload["config_file"].endswith(".mcp.json") + + +def test_parity_with_the_node_sdk_server_block(): + """Same block both SDKs write. They are a stated parity pair — a developer + who set one machine up with each must not get two different results.""" + assert mcp.build_server_config("k") == { + "type": "http", + "url": "https://lenz.io/mcp", + "headers": {"Authorization": "Bearer k"}, + } + + +def test_parity_pins_the_shared_constants(): + """The block above was the only thing pinned, and it is the only thing that + never drifted. SETUP_URL meanwhile sat at the pre-rename /welcome/setup in + the Node SDK and printed a 404 as the last line of every successful run. + ``test/cli.test.ts`` asserts this same table.""" + assert mcp.MCP_SERVER_URL == "https://lenz.io/mcp" + assert mcp.CONSOLE_URL == "https://lenz.io/api-integration" + assert mcp.SETUP_URL == "https://lenz.io/setup" + assert mcp.KEY_ENV_VAR == "LENZ_API_KEY" + + +def test_parity_pins_the_per_client_placeholder_syntax(): + """Not interchangeable. Cursor treats a bare ${VAR} as literal text and + sends it as the bearer token. + + Only the JSON clients are here: Codex names the variable in a field of its + own, and Claude Desktop takes no config file at all.""" + assert mcp.KEY_PLACEHOLDERS["claude-code"] == "${LENZ_API_KEY}" + assert mcp.KEY_PLACEHOLDERS["cursor"] == "${env:LENZ_API_KEY}" + assert sorted(mcp.KEY_PLACEHOLDERS) == ["claude-code", "cursor"] + + +# ── where the key ends up ─────────────────────────────────────────────────── +def test_the_key_stays_out_of_a_project_config(monkeypatch, tmp_path): + """`.mcp.json` is a file its own docs tell teams to commit. A setup command + whose happy path writes a live credential there is handing over a leak.""" + monkeypatch.chdir(tmp_path) + cfg.save_api_key("lenz_secret") + _patch_client(monkeypatch, _FakeClient()) + + runner.invoke(app, normalize_argv(["init", "--no-verify"])) + + raw = (tmp_path / ".mcp.json").read_text() + assert json.loads(raw)["mcpServers"]["lenz"]["headers"]["Authorization"] == "Bearer ${LENZ_API_KEY}" + assert "lenz_secret" not in raw + + +def test_cursor_gets_its_own_placeholder_syntax(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + cfg.save_api_key("lenz_secret") + _patch_client(monkeypatch, _FakeClient()) + + runner.invoke(app, normalize_argv(["init", "--client", "cursor", "--no-verify"])) + + written = json.loads((tmp_path / ".cursor" / "mcp.json").read_text()) + assert written["mcpServers"]["lenz"]["headers"]["Authorization"] == "Bearer ${env:LENZ_API_KEY}" + + +def test_the_export_line_is_printed_with_the_actual_key(capsys): + """Otherwise the run reads as finished while the client still has nothing + to authenticate with. + + Rendered directly rather than through the runner: Output.json_mode is + ``json_mode or not sys.stdout.isatty()``, so under CliRunner this branch + never executes. Same approach as the render tests in test_cli.py. + """ + out = Output(json_mode=False, no_color=True) + out.json_mode = False + + init_cmd.render_success( + out, + client_name="claude-code", + path=Path("/proj/.mcp.json"), + verified="42 verify calls remaining", + is_placeholder=True, + api_key="lenz_secret", + ) + + printed = capsys.readouterr().out + assert "export LENZ_API_KEY=lenz_secret" in printed + assert "commonly committed" in printed + + +def test_no_export_line_when_the_key_is_in_the_file(capsys): + """--write-key makes the config self-contained; telling them to export a + variable nothing reads would just be noise.""" + out = Output(json_mode=False, no_color=True) + out.json_mode = False + + init_cmd.render_success( + out, + client_name="claude-code", + path=Path("/proj/.mcp.json"), + verified="", + is_placeholder=False, + api_key="lenz_secret", + ) + + assert "export LENZ_API_KEY" not in capsys.readouterr().out + + +def test_write_key_puts_the_key_in_the_file(monkeypatch, tmp_path): + """The opt-out, for a private checkout.""" + monkeypatch.chdir(tmp_path) + cfg.save_api_key("lenz_secret") + _patch_client(monkeypatch, _FakeClient()) + + runner.invoke(app, normalize_argv(["init", "--write-key", "--no-verify"])) + + written = json.loads((tmp_path / ".mcp.json").read_text()) + assert written["mcpServers"]["lenz"]["headers"]["Authorization"] == "Bearer lenz_secret" + + +def test_claude_desktop_gets_the_literal_key(): + """Launched from the desktop rather than a shell, so it never inherits an + exported variable — a placeholder there is simply broken.""" + value, is_placeholder = mcp.credential_for("claude-desktop", "lenz_secret") + + assert value == "lenz_secret" + assert is_placeholder is False + + +def test_print_previews_exactly_what_a_write_would_produce(monkeypatch, tmp_path): + """A preview that differs from the write is worse than no preview.""" + monkeypatch.chdir(tmp_path) + cfg.save_api_key("lenz_secret") + + result = runner.invoke(app, normalize_argv(["init", "--client", "cursor", "--print"])) + + printed = json.loads(result.stdout) + assert printed["mcpServers"]["lenz"]["headers"]["Authorization"] == "Bearer ${env:LENZ_API_KEY}" + + +def test_json_mode_says_whether_the_key_is_in_the_config(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + cfg.save_api_key("lenz_secret") + _patch_client(monkeypatch, _FakeClient()) + + result = runner.invoke(app, normalize_argv(["--json", "init", "--no-verify"])) + + payload = json.loads(result.stdout) + assert payload["key_in_config"] is False + assert payload["key_env_var"] == "LENZ_API_KEY" + + +def test_write_config_is_owner_only(tmp_path): + """With --write-key this file holds a live credential. mkstemp gives 0600 + and os.replace preserves it; a refactor to path.write_text would quietly + widen it to 0644.""" + path = tmp_path / "mcp.json" + + mcp.write_config(path, {"a": 1}) + + assert path.stat().st_mode & 0o777 == 0o600 + + +# ── codex: TOML, merged as text ───────────────────────────────────────────── +def test_codex_append_preserves_everything_the_user_wrote(tmp_path): + """Deliberately not a parse → mutate → re-serialize round trip: every TOML + library drops comments and reflows formatting, and handing someone back a + file that is equivalent but visibly not theirs is the same failure as + clobbering it.""" + existing = '# my own notes\nmodel = "gpt-5"\n\n[mcp_servers.github]\nurl = "https://example.com/mcp"\n' + + merged = mcp.merge_toml_config(existing, mcp.build_codex_block("lenz_abc")) + + assert merged.startswith(existing.rstrip()) + assert "# my own notes" in merged + assert "[mcp_servers.lenz]" in merged + + +def test_codex_refuses_a_second_lenz_table(tmp_path): + """TOML rejects duplicate tables outright, so appending blindly would stop + the WHOLE file parsing — every other server in it included.""" + with pytest.raises(mcp.DuplicateCodexTable): + mcp.merge_toml_config('[mcp_servers.lenz]\nurl = "x"\n', mcp.build_codex_block("k")) + + +def test_codex_block_names_the_env_var_rather_than_the_key(): + block = mcp.build_codex_block("lenz_secret") + + assert 'bearer_token_env_var = "LENZ_API_KEY"' in block + assert "lenz_secret" not in block + + +def test_codex_write_key_uses_http_headers(): + """The other documented Codex auth field.""" + block = mcp.build_codex_block("lenz_secret", write_key=True) + + assert 'http_headers = { "Authorization" = "Bearer lenz_secret" }' in block + assert "bearer_token_env_var" not in block + + +def test_codex_init_writes_valid_toml_without_the_key(monkeypatch, tmp_path): + import tomllib + + monkeypatch.chdir(tmp_path) + cfg.save_api_key("lenz_secret") + _patch_client(monkeypatch, _FakeClient()) + + result = runner.invoke(app, normalize_argv(["init", "--client", "codex", "--no-verify"])) + + assert result.exit_code == 0, result.output + raw = (tmp_path / ".codex" / "config.toml").read_text() + assert "lenz_secret" not in raw + parsed = tomllib.loads(raw) + assert parsed["mcp_servers"]["lenz"]["url"] == "https://lenz.io/mcp" + + +def test_claude_desktop_init_writes_nothing_and_prints_the_flow(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + cfg.save_api_key("lenz_secret") + + result = runner.invoke(app, normalize_argv(["init", "--client", "claude-desktop"])) + + assert result.exit_code == 0, result.output + # No MCP config of any shape. (tmp_path also holds the CLI's own + # config.json, written by the save_api_key above — not ours to assert on.) + assert not (tmp_path / ".mcp.json").exists() + assert not (tmp_path / ".codex").exists() + assert not (tmp_path / "claude_desktop_config.json").exists() + # A flow that never takes a key must not echo one. + assert "lenz_secret" not in result.output + assert "Add custom connector" in result.output