{ config, lib, pkgs, ... }: let # This is intentionally an explicit guard in addition to flake.lock. An # input update must consciously update this value and repeat the MCP smoke # test documented in docs/playwright-mcp.md. playwrightMcpVersion = "0.0.76"; stateDir = "${config.home.homeDirectory}/.local/share/playwright-mcp"; # A Chromium user-data directory may be used by exactly one process at a # time. Each agent therefore gets its own profile, so Codex and Claude may # drive a browser in parallel instead of failing over a locked profile. agents = [ "codex" "claude" ]; profileDir = agent: "${stateDir}/${agent}/profile"; # The browsers Playwright was tested against already ship with the # playwright-mcp package; pkgs.chromium would be a second, redundant browser # closure. "chrome" would instead look for a branded Google Chrome under # /opt/google/chrome and fail. mkServer = agent: { command = "${pkgs.playwright-mcp}/bin/playwright-mcp"; args = [ "--browser" "chromium" "--user-data-dir" (profileDir agent) # Without this the server drops page snapshots into a .playwright-mcp # directory below the current working directory, i.e. into whatever # repository the agent happens to run in. "--output-dir" "${stateDir}/${agent}/output" ]; # The nixpkgs wrapper switches to an in-memory profile unless this is set, # which would discard logins between sessions. env.PLAYWRIGHT_MCP_USER_DATA_DIR = profileDir agent; }; codexConfig = (pkgs.formats.toml { }).generate "codex-playwright.toml" { mcp_servers.playwright = mkServer "codex" // { startup_timeout_sec = 20; tool_timeout_sec = 60; }; }; claudeConfig = (pkgs.formats.json { }).generate "claude-playwright.json" { mcpServers.playwright = mkServer "claude"; }; claudeConfigPath = "${config.home.homeDirectory}/.claude/mcp-playwright.json"; in { assertions = [ { assertion = pkgs.playwright-mcp.version == playwrightMcpVersion; message = "playwright-mcp changed from ${playwrightMcpVersion}; review its release notes, update the pin, and repeat the MCP smoke test."; } ]; # Both agents read a file this module owns. Neither agent's own mutable # state file is touched: Codex rewrites ~/.codex/config.toml (trust levels, # model notices) and Claude rewrites ~/.claude.json, so home-manager must # not own either. Codex layers a named profile on top of its base config, # Claude takes an additional config file on the command line. home.file.".codex/playwright.config.toml".source = codexConfig; home.file.".claude/mcp-playwright.json".source = claudeConfig; # Make it the default for the interactive shell rather than a separate # command that has to be remembered. zsh does not re-expand an alias into # itself, so these still call the real binaries. programs.zsh.shellAliases = { codex = "codex --profile playwright"; claude = "claude --mcp-config ${claudeConfigPath}"; }; # Browser profiles are runtime data, not Nix store artifacts; they only need # to exist with private permissions before the first launch. home.activation.playwrightMcpDirs = lib.hm.dag.entryAfter [ "writeBoundary" ] '' run ${pkgs.coreutils}/bin/install -d -m 0700 ${lib.escapeShellArg stateDir} \ ${lib.concatMapStringsSep " " (agent: lib.escapeShellArg "${stateDir}/${agent}") agents} ''; }