feat(cli): lenz init — wire Lenz into an MCP client - #38
Closed
lenzhq wants to merge 3 commits into
Closed
Conversation
Parity with `npx lenz-io init` in the Node SDK (lenzhq/lenz-io-node#38). Same server block, same config locations, same merge rule — a developer who set one machine up with each SDK must not get two different results. The gap it closes: ~31 MCP connectors created against 2 activations. People connect and never reach a first successful call, and a good share of that is the config file — which one, where it lives on their platform, what shape the server block takes. A Python shop shouldn't need Node installed to answer that. `lenz init` writes the block into Claude Code (./.mcp.json), Cursor (./.cursor/mcp.json) or Claude Desktop (the platform's global config), then verifies the key with one authenticated request so the user learns immediately whether it works. `--print` emits the JSON without touching anything, which is also the answer for clients this doesn't know about. It is the odd one out among the verbs — every other command calls the API, this one configures — so it lives in its own module alongside `verify`, the other command that owns a workflow rather than one SDK call. Three things worth stating: Merging, not overwriting. A developer's MCP config routinely holds several servers, so a setup command that replaced the file would be actively destructive on exactly the machines it exists to help. Only the `lenz` key is written. A file that exists but doesn't parse as JSON is refused rather than overwritten — guessing could silently discard servers configured by hand, with no way to get them back. Writes go through a same-directory temp file and os.replace, so a crash mid-write can't truncate a config either. A failed key check is reported as a key problem, not a failed init. The config is already written and correct at that point; saying which of the two broke stops people re-running init at something it cannot fix. The config path takes `cwd` as a parameter rather than calling Path.cwd(), so the project-scoped clients are testable without chdir'ing the test process. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015zMPNusWpZ6dHjEwMAn6GC
Parity half of the same review. The Node SDK carried the write-path bugs; this
side carried the credential one, because both wrote a live bearer token into
./.mcp.json by default and Claude Code's documentation says "Check .mcp.json
into version control so everyone on your team gets the same MCP tools and
services." A setup command whose happy path puts a credential in a file the
docs tell you to commit is handing the user a leak. The documented mechanism
for exactly this case is environment-variable expansion, supported inside
`headers`, and the docs' own example is our line verbatim.
Project-scoped clients now get a reference; --write-key is the opt-out for a
private checkout. The syntaxes are per-client and NOT interchangeable — Claude
Code takes ${VAR}, Cursor takes ${env:VAR} and treats a bare ${VAR} as literal
text — so KEY_PLACEHOLDERS is a table rather than one constant. Claude Desktop
still gets the key itself: launched from the desktop rather than a shell, it
never inherits an exported variable.
Writing a placeholder makes "key verified" a half-truth, so the run prints the
export line with the key in it, and --json reports key_in_config / key_env_var
so a script can tell whether the config is self-contained. That copy lives in
render_success() because Output.json_mode is `json_mode or not isatty()`, which
means CliRunner never reaches the human branch — the same shape the render
tests in test_cli.py already use.
The parity test pinned the server block, the one thing that never drifted,
while SETUP_URL sat at the pre-rename /welcome/setup on the Node side and
printed a 404 on every successful run. Both SDKs now pin the shared constants
and the placeholder table.
write_config already staged its temp file beside the target and cleaned up on
failure; the docstring now says why, since the Node side had staged in the
system temp directory and would have raised EXDEV anywhere /tmp is its own
filesystem. Its 0600 mode is likewise load-bearing rather than incidental, and
now has a test saying so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zMPNusWpZ6dHjEwMAn6GC
…reads
Parity half of the Node change — same two clients, same reasoning, so a
developer who sets one machine up with each still gets one result.
**Claude Desktop wrote a file nothing reads.** claude_desktop_config.json is
documented for local stdio servers; a remote streamable-HTTP server like ours
is added through Settings → Connectors → "Add custom connector". We wrote a
`{"type": "http", "headers": {...}}` entry there, which is not a documented
shape for that file, and it was the one client we handed the literal key to —
so the likely outcome was a live credential sitting somewhere inert. It now
prints the connector steps (MANUAL_CLIENTS) and writes nothing; --json reports
status "manual" with the instructions.
**Codex was missing because its config is TOML.** Added without tomli-w: the
Lenz table is appended as TEXT, because a parse → mutate → re-serialize round
trip drops comments and reflows formatting, handing back a file that is
equivalent and visibly not theirs. Appending is always valid (TOML tables are
order-independent); a second [mcp_servers.lenz] is a duplicate-table error that
stops the whole file parsing, so that raises DuplicateCodexTable and the
command says what to edit.
bearer_token_env_var names the variable in a field of its own, so Codex needs
none of the ${VAR} / ${env:VAR} interpolation the JSON clients require and no
key is written at all. --write-key uses http_headers instead.
write_text_config carries the atomic-replace and 0600 mode over to the TOML
path — one write path, so neither loses the durability or the file mode.
Smoke-tested against the installed `lenz init --client codex` on a real
config.toml holding a comment, a top-level key and another server: all survived
byte-for-byte, and the block is byte-identical to the one the Node CLI writes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zMPNusWpZ6dHjEwMAn6GC
Owner
Author
|
Closing unmerged — the init CLI is killed before release, same rationale as lenzhq/lenz-io-node#38: first-party registration commands exist for Claude Code and Codex, Claude Desktop is Connectors-only, and Cursor (the one client init uniquely served) is covered by the pasted setup prompt from lenz.io/setup. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Python-side parity for lenzhq/lenz-io-node#38. Same server block, same config locations, same merge rule — a developer who set one machine up with each SDK must not get two different results.
The gap: ~31 MCP connectors created against 2 activations. People connect and never reach a first successful call, and a good share of that is the config file — which one, where it lives on their platform, what shape the server block takes. A Python shop shouldn't need Node installed to answer that.
What it does
Writes the block, then verifies the key with one authenticated request so the user learns immediately whether it works.
--printneeds no key — its purpose is handing someone a config to paste and fill in, and it's also the answer for clients this doesn't know about.Key resolution is the CLI's existing chain:
--api-key→LENZ_API_KEY→~/.config/lenz/config.json. The SDK surface is unchanged.Three deliberate choices
Merging, not overwriting. A developer's MCP config routinely holds several servers, so a setup command that replaced the file would be actively destructive on exactly the machines it exists to help. Only the
lenzkey is written. A file that exists but doesn't parse as JSON is refused rather than overwritten — guessing could silently discard servers configured by hand, with no way to get them back. Writes go through a same-directory temp file +os.replace, so a crash mid-write can't truncate a config either.A failed key check is reported as a key problem, not a failed init. The config is already written and correct at that point; saying which of the two broke stops people re-running
initat something it cannot fix.config_path_fortakescwdas a parameter rather than callingPath.cwd(), so the project-scoped clients are testable without chdir'ing the test process.Structure
initis the odd one out among the verbs — every other command calls the API, this one configures — so it lives incli/init_cmd.pyalongsideverify, the other command that owns a workflow rather than one SDK call. The pure merge/path helpers are incli/mcp_config.pyso they're testable without a filesystem.Verification
pytestgreen (full suite),ruff check,ruff format --checkandmypy src/lenz_ioclean. 22 new tests, deliberately parallel totest/cli.test.tsin the Node SDK: merge behaviour (other servers preserved, unrelated top-level keys preserved, re-init replaces rather than duplicates, malformedmcpServerssurvived), the unparseable-file refusal leaving the file byte-identical, path resolution per client and platform, atomic write leaving no temp files,--printwriting nothing, the no-key path, the bad-key path leaving the config in place, and the--jsoncontract. One test asserts the server block matches the Node SDK's byte for byte.Also smoke-tested through the real installed command: fresh write, re-init, and a merge against a hand-added second server plus an unrelated top-level key — output byte-identical to the Node CLI's.
🤖 Generated with Claude Code
https://claude.ai/code/session_015zMPNusWpZ6dHjEwMAn6GC