diff --git a/docs/REGISTRY.md b/docs/REGISTRY.md index e225314..be41cb6 100644 --- a/docs/REGISTRY.md +++ b/docs/REGISTRY.md @@ -126,6 +126,29 @@ Two traps this catches: default, so a new `10.43.0.0/16` server reports **zero tools while the hosted ones work**. That is the block, not the NetworkPolicy. `hermes doctor` first. +## Adding a Claude profile + +```yaml +claude_profiles: + - name: work + primary: true + config_dir: "${CLAUDE_CONFIG_DIR:-$HOME/.claude}" + - name: personal + config_dir: "$HOME/.claude-personal" + shares_from: work + shared_paths: [skills, agents, commands, hooks, plugins, settings.json] +``` + +Exactly one profile is `primary:`, and it keeps the default location so a bare +`claude` needs nothing remembered. Every other profile names the surfaces it +shares, and setup symlinks each one back into the primary. + +`shared_paths:` may not name `projects`, `history.jsonl`, `sessions`, +`.claude.json` or `.credentials.json`. Those are the profile's own state, and +sharing them merges the histories the second profile exists to keep apart — +the renderer rejects the edit rather than trusting it. See +[SETUP.md](SETUP.md#two-claude-logins-one-setup). + ## Adding a plugin or language server Plugins go under `plugins:` and must name a marketplace that `marketplaces:` diff --git a/docs/SETUP.md b/docs/SETUP.md index 88dc470..880adea 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -8,6 +8,7 @@ plugins drive, and the MCP fleet. uv run python scripts/render_registry.py --check # artifacts current? ./installer/setup-workstation.sh --check # what would change ./installer/setup-workstation.sh # do it +./installer/setup-workstation.sh --no-profiles # primary Claude profile only ``` `setup-workstation.sh` is **generated** from @@ -87,6 +88,67 @@ upstream should not abandon the other twenty steps. It counts instead. The last line is the tally. A zero exit with warnings is a normal outcome on a machine that does not do Ruby or C#. +## Two Claude logins, one setup + +`CLAUDE_CONFIG_DIR` moves a Claude Code config root whole: credentials, +`projects/`, `history.jsonl`, `sessions/` and `.claude.json` all travel with +it. That is what makes a second *login* a second *directory*, and it is also +what keeps a personal conversation out of the work account's history. + +The registry declares the profiles under `claude_profiles:`. Today: + +| Profile | Config root | Holds | +|---|---|---| +| `work` (primary) | `~/.claude` | the default a bare `claude` uses, and every shared asset | +| `personal` | `~/.claude-personal` | its own login and its own history; everything else is a symlink | + +Setup creates the secondary root and symlinks each surface named in its +`shared_paths:` back into the primary — today `skills`, `agents`, `commands`, +`hooks`, `plugins` and `settings.json`. So: + +- **Plugins install once.** `plugins/` is shared and `enabledPlugins` lives in + the shared `settings.json`, so a plugin installed for work is already + installed, and already enabled, for personal. +- **MCP servers are registered twice.** A server lives in the profile's own + `.claude.json`, which is exactly the file the two profiles must not share. + Every `claude mcp add` in the generated script runs once per profile. +- **Nothing stateful is shared.** The renderer refuses a `shared_paths:` entry + naming `projects`, `history.jsonl`, `sessions`, `.claude.json` or the + credentials file, and a test proves the refusal fires. + +Setup also writes a launcher per secondary profile into `~/.local/bin` (or +`$CLAUDE_LAUNCHER_DIR`), the directory `claude` itself installs into: + +```bash +claude-personal # first run: log in, personal account +``` + +The launcher only sets `CLAUDE_CONFIG_DIR` and execs `claude`. A +`claude-` file that setup did not write is left alone and reported. + +Run setup from a shell **without** `CLAUDE_CONFIG_DIR` set, not from inside a +`claude-personal` session. With it set, the personal root looks like the +primary; setup refuses to link a root onto itself and fails that profile. + +A real file or directory already sitting where a symlink would go is **left +alone** and reported in the summary; the script never replaces one. Fix it by +moving your own copy aside and re-running. + +### The one thing to verify on macOS + +Claude Code stores the OAuth tokens in the login keychain under the service +`Claude Code-credentials`. Whether that entry is namespaced per config root +decides whether two profiles can hold two accounts at once. Check after +logging both in: + +```bash +security dump-keychain | grep -c '"svce"="Claude Code-credentials"' +``` + +Two entries: the profiles are independent. One: the logins overwrite each +other and a switch means logging in again — use a separate macOS user account +for the personal profile instead. + ## Language servers have two halves A `*-lsp` plugin declares the LSP wiring; the language server **binary** does diff --git a/installer/setup-workstation.sh b/installer/setup-workstation.sh index 6d22efe..ebf857c 100755 --- a/installer/setup-workstation.sh +++ b/installer/setup-workstation.sh @@ -13,6 +13,7 @@ # ./setup-workstation.sh --check report only, change nothing # ./setup-workstation.sh --no-lsp skip the language servers # ./setup-workstation.sh --no-mcp skip MCP registration +# ./setup-workstation.sh --no-profiles only the primary Claude profile # # Secrets are read from the environment and never written here: # MEMORY_API_KEY -> the memory MCP server @@ -23,11 +24,13 @@ set -uo pipefail CHECK_ONLY=0 DO_LSP=1 DO_MCP=1 +DO_PROFILES=1 failures=0 warnings=0 # Track what needs attention after the run skipped_mcp_servers=() # MCP servers skipped due to missing credentials +unshared_profile_paths=() # Shared surfaces a secondary profile did not get missing_lsp_binaries=() # Language servers with missing binaries disabled_plugins=() # Plugins disabled (missing binary or on purpose) plugin_drift=() # Plugins whose commit drifted @@ -38,6 +41,7 @@ while [ "$#" -gt 0 ]; do --check) CHECK_ONLY=1 ;; --no-lsp) DO_LSP=0 ;; --no-mcp) DO_MCP=0 ;; + --no-profiles) DO_PROFILES=0 ;; --help|-h) sed -n '2,30p' "$0"; exit 0 ;; *) echo "unknown option: $1" >&2; exit 64 ;; esac @@ -78,6 +82,107 @@ run_sh() { bash -c "$1" } +# Runs argv once per Claude profile, each with its own config root. +# MCP servers live in a profile's own .claude.json, so the fleet has to +# be registered per profile; plugins and skills do not, because those +# directories are shared by symlink. +claude_each_profile() { + local dir rc=0 + for dir in "${CLAUDE_PROFILE_DIRS[@]}"; do + CLAUDE_CONFIG_DIR="${dir}" "$@" || rc=1 + done + return "${rc}" +} + +# Links one shared surface of the primary profile into a secondary one. +# A real file or directory already sitting at the destination is left +# alone: that is someone's own config, and a symlink cannot give back +# what replacing it would lose. +link_profile_path() { + local primary="$1" secondary="$2" rel="$3" + local src="${primary}/${rel}" dest="${secondary}/${rel}" + # Linking a root onto itself replaces every shared surface with a + # symlink to itself, and the profile stops loading anything. + if [ "${primary}" = "${secondary}" ] || [ "${primary}" -ef "${secondary}" ]; then + fail "profile: ${secondary} is the primary root; refusing to link it onto itself" + return 0 + fi + # A shared DIRECTORY that the primary does not have yet is created, so + # the link exists before the thing it points at does and whatever + # writes there later reaches both profiles. A shared FILE is not + # invented: an empty settings.json would look like a real answer. + if [ ! -e "${src}" ]; then + case "${rel}" in + *.*) + warn "profile: ${src} does not exist yet; ${rel} not shared" + unshared_profile_paths+=("${dest}: ${src} does not exist") + return 0 + ;; + *) + if [ "${CHECK_ONLY}" = 1 ]; then + log "would create ${src}" + else + mkdir -p "${src}" + fi + ;; + esac + fi + if [ -L "${dest}" ] && [ "$(readlink "${dest}")" = "${src}" ]; then + ok "profile: ${dest} -> ${src}" + return 0 + fi + if [ -e "${dest}" ] && [ ! -L "${dest}" ]; then + warn "profile: ${dest} exists and is not a symlink; left alone" + unshared_profile_paths+=("${dest}: real file or directory, not replaced") + return 0 + fi + if [ "${CHECK_ONLY}" = 1 ]; then + log "would link ${dest} -> ${src}" + return 0 + fi + mkdir -p "$(dirname "${dest}")" + if ln -sfn "${src}" "${dest}"; then + ok "profile: ${dest} -> ${src}" + else + fail "profile: could not link ${dest} -> ${src}" + fi +} + +# Writes the claude- launcher for one secondary profile, so the +# profile is a command rather than an environment variable to remember. +# A file at that path that this script did not write is left alone. +install_profile_launcher() { + local name="$1" dir="$2" + local bin_dir="${CLAUDE_LAUNCHER_DIR:-$HOME/.local/bin}" + local launcher="${bin_dir}/claude-${name}" + local marker="# managed by agent-kit setup-workstation.sh" + local content + content="$(printf '%s\n' '#!/usr/bin/env bash' "${marker}" \ + "# Claude Code with the ${name} profile config root." \ + "export CLAUDE_CONFIG_DIR=$(printf '%q' "${dir}")" \ + 'exec claude "$@"')" + if [ -e "${launcher}" ] && ! grep -qxF "${marker}" "${launcher}"; then + warn "profile: ${launcher} exists and was not written by setup; left alone" + unshared_profile_paths+=("${launcher}: not a managed launcher, not replaced") + return 0 + fi + if [ -x "${launcher}" ] && [ "$(cat "${launcher}")" = "${content}" ]; then + ok "profile: ${launcher}" + elif [ "${CHECK_ONLY}" = 1 ]; then + log "would write ${launcher}" + elif mkdir -p "${bin_dir}" && printf '%s\n' "${content}" > "${launcher}" \ + && chmod 755 "${launcher}"; then + ok "profile: wrote ${launcher}" + else + fail "profile: could not write ${launcher}" + return 0 + fi + case ":${PATH}:" in + *":${bin_dir}:"*) ;; + *) warn "profile: ${bin_dir} is not on PATH; claude-${name} will not be found" ;; + esac +} + # This script lives in /installer, and the first-party skills it # copies live in /skills. KIT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -180,7 +285,58 @@ else fi # ----------------------------------------------------------------- -# 2. Claude Code marketplaces and plugins. +# 2. Claude Code profiles. +# +# A profile is a config root: CLAUDE_CONFIG_DIR moves credentials AND +# conversation history together, so a second login is a second root. +# The primary keeps the default location; every secondary shares the +# surfaces named in the registry by symlinking back into it, and keeps +# its own projects/, sessions/, history.jsonl and .claude.json. +# ----------------------------------------------------------------- +# work: The default profile a bare `claude` uses, and the home of every shared asset the other profiles link back to. +CLAUDE_PROFILE_DIRS=("${CLAUDE_HOME}") + +if [ "${DO_PROFILES}" = 1 ]; then + log "claude profiles" + ok "${CLAUDE_HOME} (primary)" + + # The personal-account profile. Same skills, plugins, LSPs and + # MCP fleet as work; its own login and its own conversation + # history. + profile_dir="$HOME/.claude-personal" + if [ "${profile_dir}" = "${CLAUDE_HOME}" ] || [ "${profile_dir}" -ef "${CLAUDE_HOME}" ]; then + fail "personal: CLAUDE_CONFIG_DIR points at ${profile_dir}; re-run with it unset" + else + if [ "${CHECK_ONLY}" = 1 ] && [ ! -d "${profile_dir}" ]; then + log "would create ${profile_dir}" + else + mkdir -p "${profile_dir}" + fi + link_profile_path "${CLAUDE_HOME}" "${profile_dir}" "skills" + link_profile_path "${CLAUDE_HOME}" "${profile_dir}" "agents" + link_profile_path "${CLAUDE_HOME}" "${profile_dir}" "commands" + link_profile_path "${CLAUDE_HOME}" "${profile_dir}" "hooks" + link_profile_path "${CLAUDE_HOME}" "${profile_dir}" "plugins" + link_profile_path "${CLAUDE_HOME}" "${profile_dir}" "settings.json" + CLAUDE_PROFILE_DIRS+=("${profile_dir}") + install_profile_launcher "personal" "${profile_dir}" + + if [ -s "${profile_dir}/.claude.json" ]; then + ok "personal: ${profile_dir} is set up" + else + log "personal: log in with claude-personal (its own account)" + fi + fi +else + log "secondary claude profiles skipped (--no-profiles)" +fi + +# ----------------------------------------------------------------- +# 3. Claude Code marketplaces and plugins. +# +# Installed into the PRIMARY profile only. plugins/ is a shared +# surface and enabledPlugins lives in the shared settings.json, so a +# second install per profile would write the same state twice. # ----------------------------------------------------------------- if ! command -v claude >/dev/null 2>&1; then fail "claude is not on PATH; skipping plugins, language servers and MCP" @@ -262,7 +418,7 @@ else fi # --------------------------------------------------------------- - # 3. Plugin drift detection. + # 4. Plugin drift detection. # # The CLI does not support pinning plugins to a commit, so pins in # the registry are advisory. Compare installed commits against @@ -351,7 +507,7 @@ for install in installs: fi # --------------------------------------------------------------- - # 3. Language servers: the plugin AND the binary it drives. + # 5. Language servers: the plugin AND the binary it drives. # # An LSP plugin with no binary on PATH registers no tools and says # nothing about it. Install the plugin, but leave it disabled until @@ -619,19 +775,23 @@ for install in installs: fi # --------------------------------------------------------------- - # 4. MCP servers, registered for Claude Code at user scope. + # 6. MCP servers, registered for Claude Code at user scope. # # The registry is authoritative: servers with `surfaces: []` or # `enabled: false` are removed. Others are ensured. Plugin-provided # servers (plugin:*:*) and hand-added ones are left untouched. + # + # Every claude call here runs once per profile: a server lives in the + # profile's own .claude.json, which is the one file two profiles must + # not share, so the fleet is registered into each of them. # --------------------------------------------------------------- if [ "${DO_MCP}" = 1 ]; then log "MCP servers" - run claude mcp remove --scope user memory >/dev/null 2>&1 || true + run claude_each_profile claude mcp remove --scope user memory >/dev/null 2>&1 || true log "memory: removed (retired in the registry)" - run claude mcp remove --scope user vuetify >/dev/null 2>&1 || true + run claude_each_profile claude mcp remove --scope user vuetify >/dev/null 2>&1 || true log "vuetify: removed (retired in the registry)" - run claude mcp remove --scope user knowledge >/dev/null 2>&1 || true + run claude_each_profile claude mcp remove --scope user knowledge >/dev/null 2>&1 || true log "knowledge: removed (retired in the registry)" @@ -645,8 +805,8 @@ for install in installs: # ClusterIP service kubernetes-mcp-server.agents-system (no ingress route exists for it). ensure_port_forward kubernetes agents-system kubernetes-mcp-server 18080 8080 if command -v claude >/dev/null 2>&1; then - run claude mcp remove --scope user kubernetes >/dev/null 2>&1 || true - if run_redacted "claude mcp add kubernetes" claude mcp add --scope user kubernetes --transport http http://127.0.0.1:18080/mcp; then + run claude_each_profile claude mcp remove --scope user kubernetes >/dev/null 2>&1 || true + if run_redacted "claude mcp add kubernetes" claude_each_profile claude mcp add --scope user kubernetes --transport http http://127.0.0.1:18080/mcp; then ok "kubernetes registered (claude)" else fail "kubernetes registration failed (claude)" @@ -670,8 +830,8 @@ for install in installs: || warn "playwright: npx is not on PATH; skipping" fi if command -v claude >/dev/null 2>&1; then - run claude mcp remove --scope user playwright >/dev/null 2>&1 || true - if run_redacted "claude mcp add playwright" claude mcp add --scope user playwright -- npx -y @playwright/mcp@latest --headless --browser chromium; then + run claude_each_profile claude mcp remove --scope user playwright >/dev/null 2>&1 || true + if run_redacted "claude mcp add playwright" claude_each_profile claude mcp add --scope user playwright -- npx -y @playwright/mcp@latest --headless --browser chromium; then ok "playwright registered (claude)" else fail "playwright registration failed (claude)" @@ -693,8 +853,8 @@ for install in installs: || warn "drawio: drawio-mcp is not on PATH; skipping" fi if command -v claude >/dev/null 2>&1; then - run claude mcp remove --scope user drawio >/dev/null 2>&1 || true - if run_redacted "claude mcp add drawio" claude mcp add --scope user drawio -- drawio-mcp; then + run claude_each_profile claude mcp remove --scope user drawio >/dev/null 2>&1 || true + if run_redacted "claude mcp add drawio" claude_each_profile claude mcp add --scope user drawio -- drawio-mcp; then ok "drawio registered (claude)" else fail "drawio registration failed (claude)" @@ -715,8 +875,8 @@ for install in installs: || warn "overleaf: olcli-mcp is not on PATH; skipping" fi if command -v claude >/dev/null 2>&1; then - run claude mcp remove --scope user overleaf >/dev/null 2>&1 || true - if run_redacted "claude mcp add overleaf" claude mcp add --scope user overleaf --env OVERLEAF_BASE_URL="https://overleaf.jorisjonkers.dev" --env OVERLEAF_COOKIE_NAME="overleaf.sid" --env OVERLEAF_SESSION="${OVERLEAF_SESSION}" -- olcli-mcp; then + run claude_each_profile claude mcp remove --scope user overleaf >/dev/null 2>&1 || true + if run_redacted "claude mcp add overleaf" claude_each_profile claude mcp add --scope user overleaf --env OVERLEAF_BASE_URL="https://overleaf.jorisjonkers.dev" --env OVERLEAF_COOKIE_NAME="overleaf.sid" --env OVERLEAF_SESSION="${OVERLEAF_SESSION:-}" -- olcli-mcp; then ok "overleaf registered (claude)" else fail "overleaf registration failed (claude)" @@ -724,7 +884,7 @@ for install in installs: fi if command -v codex >/dev/null 2>&1; then run codex mcp remove overleaf >/dev/null 2>&1 || true - if run_redacted "codex mcp add overleaf" codex mcp add overleaf --env OVERLEAF_BASE_URL="https://overleaf.jorisjonkers.dev" --env OVERLEAF_COOKIE_NAME="overleaf.sid" --env OVERLEAF_SESSION="${OVERLEAF_SESSION}" -- olcli-mcp; then + if run_redacted "codex mcp add overleaf" codex mcp add overleaf --env OVERLEAF_BASE_URL="https://overleaf.jorisjonkers.dev" --env OVERLEAF_COOKIE_NAME="overleaf.sid" --env OVERLEAF_SESSION="${OVERLEAF_SESSION:-}" -- olcli-mcp; then ok "overleaf registered (codex)" else fail "overleaf registration failed (codex)" @@ -748,7 +908,9 @@ for install in installs: # actually has. Check expected servers are present, and report # any unexpected ones (but leave plugin-provided and hand-added). if [ "${CHECK_ONLY}" != 1 ]; then - registered=$(claude mcp list 2>/dev/null || true) + # Once per profile: each one answers for its own .claude.json. + for profile_dir in "${CLAUDE_PROFILE_DIRS[@]}"; do + registered=$(CLAUDE_CONFIG_DIR="${profile_dir}" claude mcp list 2>/dev/null || true) # Check all expected servers are registered for want in \ kubernetes \ @@ -758,11 +920,15 @@ for install in installs: ; do case "${registered}" in *"${want}"*) ;; - *) warn "MCP server ${want} is not in \`claude mcp list\` output" ;; + *) warn "MCP server ${want} is not registered in ${profile_dir}" ;; esac done # Report unknown servers (but ignore plugin-provided and hand-added ones) - echo "${registered}" | grep -oE "\b[a-z0-9_-]+\b(?=:)" | sort -u | while read -r found; do + # One name per line, taken from the start of the line up to the first + # colon. NOT a lookahead: BSD grep has no PCRE, so `(?=:)` is a + # "repetition-operator operand invalid" error and the whole check + # silently inspected nothing on macOS. + echo "${registered}" | sed -n 's/^\([a-zA-Z0-9_:-]*\):[[:space:]].*/\1/p' | sort -u | while read -r found; do case "${found}" in memory) ;; kubernetes) ;; @@ -771,9 +937,10 @@ for install in installs: drawio) ;; overleaf) ;; plugin:*|idea|rubymine) ;; - *) warn "unknown MCP server ${found} -- hand-added or from a removed registry entry?" ;; + *) warn "unknown MCP server ${found} in ${profile_dir} -- hand-added or from a removed registry entry?" ;; esac done + done fi else log "MCP registration skipped (--no-mcp)" @@ -781,7 +948,7 @@ for install in installs: fi # ----------------------------------------------------------------- -# 5. First-party skills that ship in this repository. +# 7. First-party skills that ship in this repository. # # Copied, not fetched. Multi-file skills, so the whole tree moves and # the destination is replaced rather than merged -- a stale script left @@ -831,7 +998,7 @@ else fi # ----------------------------------------------------------------- -# 6. Retired hooks. +# 8. Retired hooks. # # The estate ships no agent hooks. install-agents.sh owns the purge; # this only reports a machine that still has them so the operator @@ -845,7 +1012,7 @@ else fi # ----------------------------------------------------------------- -# 7. Summary: what changed and what still needs attention. +# 9. Summary: what changed and what still needs attention. # ----------------------------------------------------------------- log "Summary of findings:" @@ -868,6 +1035,15 @@ if [ "${#skipped_mcp_servers[@]}" -gt 0 ]; then log "" fi +# Report shared surfaces a secondary profile did not get +if [ "${#unshared_profile_paths[@]}" -gt 0 ]; then + log "Profile surfaces not shared:" + for entry in "${unshared_profile_paths[@]}"; do + log " - ${entry}" + done + log "" +fi + # Report language servers with missing binaries if [ "${#missing_lsp_binaries[@]}" -gt 0 ]; then log "Language servers with missing binaries:" diff --git a/registry/estate-tooling.yaml b/registry/estate-tooling.yaml index fafbea2..ca60c90 100644 --- a/registry/estate-tooling.yaml +++ b/registry/estate-tooling.yaml @@ -34,6 +34,51 @@ version: 1 verified_at: 2026-09-09 +# ------------------------------------------------------------------- +# Claude Code profiles. +# +# One login per profile. CLAUDE_CONFIG_DIR moves the WHOLE config root, +# so a second profile is a second directory: separate credentials, +# separate `projects/`, `history.jsonl`, `sessions/` and `.claude.json`. +# That separation is the point -- a personal conversation must not land +# in the work account's history, and vice versa. +# +# What every profile shares is declared per profile in `shared_paths:`, +# and shared by SYMLINK back into the primary. The primary keeps the +# default location, so a bare `claude` with no environment set is the +# primary profile and nothing has to be remembered for the common case. +# +# `shared_paths:` may not name per-profile state. Sharing `projects/`, +# `history.jsonl`, `sessions/` or `.claude.json` would merge the two +# histories back together and hand each account the other's transcripts, +# so the renderer rejects them rather than trusting the edit. +# +# Plugins install once, into the primary: `plugins/` is shared, and +# `settings.json` (shared too) is where `enabledPlugins` lives. MCP +# servers are NOT shared -- they live in each profile's own +# `.claude.json` -- so setup registers the fleet once per profile. +# ------------------------------------------------------------------- +claude_profiles: + - name: work + primary: true + # The default location. `CLAUDE_CONFIG_DIR` still overrides it, which + # is what lets a throwaway root be set up without touching this one. + config_dir: "${CLAUDE_CONFIG_DIR:-$HOME/.claude}" + purpose: >- + The default profile a bare `claude` uses, and the home of every + shared asset the other profiles link back to. + + - name: personal + config_dir: "$HOME/.claude-personal" + shares_from: work + # hooks/ is shared because settings.json is: a hook command there is + # an absolute path into the primary's tree, so splitting the two + # leaves the secondary profile wired to scripts it cannot see. + shared_paths: [skills, agents, commands, hooks, plugins, settings.json] + purpose: >- + The personal-account profile. Same skills, plugins, LSPs and MCP + fleet as work; its own login and its own conversation history. + # ------------------------------------------------------------------- # Command-line tools. # diff --git a/scripts/render_registry.py b/scripts/render_registry.py index 7125f1f..6ef6d4f 100644 --- a/scripts/render_registry.py +++ b/scripts/render_registry.py @@ -17,6 +17,7 @@ from __future__ import annotations import argparse +import re import sys from pathlib import Path from typing import Any @@ -35,6 +36,22 @@ GENERATED_BANNER = "GENERATED FROM registry/estate-tooling.yaml -- DO NOT EDIT." +# Paths under a Claude Code config root that hold ONE profile's own state. +# Sharing any of them between profiles merges the histories the profiles +# exist to keep apart, so a `shared_paths:` naming one is refused. +PROFILE_PRIVATE_PATHS = frozenset( + { + ".claude.json", + ".credentials.json", + "history.jsonl", + "projects", + "sessions", + "shell-snapshots", + "statsig", + "todos", + }, +) + class RegistryError(RuntimeError): """The registry is malformed in a way that would render a broken artifact.""" @@ -139,6 +156,8 @@ def validate(data: dict[str, Any]) -> None: "public git URLs with no credential and this repository is private", ) + _validate_claude_profiles(data) + for key in ("clis", "skill_sources", "mcp_servers", "local_skills"): for item in _entries(data, key): surfaces = item.get("surfaces") @@ -155,6 +174,68 @@ def on_surface(item: dict[str, Any], surface: str) -> bool: return surface in (item.get("surfaces") or []) +def _validate_claude_profiles(data: dict[str, Any]) -> None: + profiles = _entries(data, "claude_profiles") + if not profiles: + raise RegistryError("claude_profiles must declare at least the primary profile") + + names = [p.get("name") for p in profiles] + if len(set(names)) != len(names): + raise RegistryError("claude_profiles names must be unique") + + primaries = [p for p in profiles if p.get("primary")] + if len(primaries) != 1: + raise RegistryError("claude_profiles must declare exactly one primary profile") + + for profile in profiles: + name = profile.get("name") + # The name becomes the claude- launcher, so it has to be a + # plain command name. + if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", str(name or "")): + raise RegistryError( + f"claude profile name {name!r} must be lowercase letters, digits and dashes", + ) + if not profile.get("config_dir"): + raise RegistryError(f"claude profile {name} must name a config_dir") + if profile.get("primary"): + if profile.get("shares_from") or profile.get("shared_paths"): + raise RegistryError( + f"claude profile {name} is the primary and shares from nothing", + ) + continue + + # A secondary profile that shares nothing is a second install, not a + # second login into the same setup -- and that is what the operator + # asked the registry for. + parent = profile.get("shares_from") + if parent != primaries[0].get("name"): + raise RegistryError( + f"claude profile {name} must share from the primary profile " + f"{primaries[0].get('name')!r}, not {parent!r}", + ) + shared = profile.get("shared_paths") or [] + if not shared: + raise RegistryError(f"claude profile {name} must name the paths it shares") + for rel in shared: + rel = str(rel) + if rel.startswith("/") or ".." in Path(rel).parts: + raise RegistryError( + f"claude profile {name} shares {rel!r}; shared paths are relative " + "to the config root and may not escape it", + ) + if rel in PROFILE_PRIVATE_PATHS: + raise RegistryError( + f"claude profile {name} shares {rel!r}, which is that profile's own " + "history or credentials; sharing it defeats the separate profile", + ) + + +def claude_profiles(data: dict[str, Any]) -> list[dict[str, Any]]: + """Every Claude Code profile, the primary first.""" + profiles = _entries(data, "claude_profiles") + return sorted(profiles, key=lambda p: not p.get("primary")) + + # --------------------------------------------------------------------------- # Hermes: sources.conf # --------------------------------------------------------------------------- @@ -344,6 +425,7 @@ def render_setup_script(data: dict[str, Any]) -> str: w("# ./setup-workstation.sh --check report only, change nothing") w("# ./setup-workstation.sh --no-lsp skip the language servers") w("# ./setup-workstation.sh --no-mcp skip MCP registration") + w("# ./setup-workstation.sh --no-profiles only the primary Claude profile") w("#") w("# Secrets are read from the environment and never written here:") for server in _entries(data, "mcp_servers"): @@ -355,11 +437,13 @@ def render_setup_script(data: dict[str, Any]) -> str: w("CHECK_ONLY=0") w("DO_LSP=1") w("DO_MCP=1") + w("DO_PROFILES=1") w("failures=0") w("warnings=0") w("") w("# Track what needs attention after the run") w("skipped_mcp_servers=() # MCP servers skipped due to missing credentials") + w("unshared_profile_paths=() # Shared surfaces a secondary profile did not get") w("missing_lsp_binaries=() # Language servers with missing binaries") w("disabled_plugins=() # Plugins disabled (missing binary or on purpose)") w("plugin_drift=() # Plugins whose commit drifted") @@ -370,6 +454,7 @@ def render_setup_script(data: dict[str, Any]) -> str: w(" --check) CHECK_ONLY=1 ;;") w(" --no-lsp) DO_LSP=0 ;;") w(" --no-mcp) DO_MCP=0 ;;") + w(" --no-profiles) DO_PROFILES=0 ;;") w(" --help|-h) sed -n '2,30p' \"$0\"; exit 0 ;;") w(' *) echo "unknown option: $1" >&2; exit 64 ;;') w(" esac") @@ -410,6 +495,107 @@ def render_setup_script(data: dict[str, Any]) -> str: w(' bash -c "$1"') w("}") w("") + w("# Runs argv once per Claude profile, each with its own config root.") + w("# MCP servers live in a profile's own .claude.json, so the fleet has to") + w("# be registered per profile; plugins and skills do not, because those") + w("# directories are shared by symlink.") + w("claude_each_profile() {") + w(" local dir rc=0") + w(' for dir in "${CLAUDE_PROFILE_DIRS[@]}"; do') + w(' CLAUDE_CONFIG_DIR="${dir}" "$@" || rc=1') + w(" done") + w(' return "${rc}"') + w("}") + w("") + w("# Links one shared surface of the primary profile into a secondary one.") + w("# A real file or directory already sitting at the destination is left") + w("# alone: that is someone's own config, and a symlink cannot give back") + w("# what replacing it would lose.") + w("link_profile_path() {") + w(' local primary="$1" secondary="$2" rel="$3"') + w(' local src="${primary}/${rel}" dest="${secondary}/${rel}"') + w(" # Linking a root onto itself replaces every shared surface with a") + w(" # symlink to itself, and the profile stops loading anything.") + w(' if [ "${primary}" = "${secondary}" ] || [ "${primary}" -ef "${secondary}" ]; then') + w(' fail "profile: ${secondary} is the primary root; refusing to link it onto itself"') + w(" return 0") + w(" fi") + w(" # A shared DIRECTORY that the primary does not have yet is created, so") + w(" # the link exists before the thing it points at does and whatever") + w(" # writes there later reaches both profiles. A shared FILE is not") + w(" # invented: an empty settings.json would look like a real answer.") + w(' if [ ! -e "${src}" ]; then') + w(' case "${rel}" in') + w(' *.*)') + w(' warn "profile: ${src} does not exist yet; ${rel} not shared"') + w(' unshared_profile_paths+=("${dest}: ${src} does not exist")') + w(" return 0") + w(" ;;") + w(' *)') + w(' if [ "${CHECK_ONLY}" = 1 ]; then') + w(' log "would create ${src}"') + w(" else") + w(' mkdir -p "${src}"') + w(" fi") + w(" ;;") + w(" esac") + w(" fi") + w(' if [ -L "${dest}" ] && [ "$(readlink "${dest}")" = "${src}" ]; then') + w(' ok "profile: ${dest} -> ${src}"') + w(" return 0") + w(" fi") + w(' if [ -e "${dest}" ] && [ ! -L "${dest}" ]; then') + w(' warn "profile: ${dest} exists and is not a symlink; left alone"') + w(' unshared_profile_paths+=("${dest}: real file or directory, not replaced")') + w(" return 0") + w(" fi") + w(' if [ "${CHECK_ONLY}" = 1 ]; then') + w(' log "would link ${dest} -> ${src}"') + w(" return 0") + w(" fi") + w(' mkdir -p "$(dirname "${dest}")"') + w(' if ln -sfn "${src}" "${dest}"; then') + w(' ok "profile: ${dest} -> ${src}"') + w(" else") + w(' fail "profile: could not link ${dest} -> ${src}"') + w(" fi") + w("}") + w("") + w("# Writes the claude- launcher for one secondary profile, so the") + w("# profile is a command rather than an environment variable to remember.") + w("# A file at that path that this script did not write is left alone.") + w("install_profile_launcher() {") + w(' local name="$1" dir="$2"') + w(' local bin_dir="${CLAUDE_LAUNCHER_DIR:-$HOME/.local/bin}"') + w(' local launcher="${bin_dir}/claude-${name}"') + w(' local marker="# managed by agent-kit setup-workstation.sh"') + w(" local content") + w(" content=\"$(printf '%s\\n' '#!/usr/bin/env bash' \"${marker}\" \\") + w(' "# Claude Code with the ${name} profile config root." \\') + w(" \"export CLAUDE_CONFIG_DIR=$(printf '%q' \"${dir}\")\" \\") + w(" 'exec claude \"$@\"')\"") + w(' if [ -e "${launcher}" ] && ! grep -qxF "${marker}" "${launcher}"; then') + w(' warn "profile: ${launcher} exists and was not written by setup; left alone"') + w(' unshared_profile_paths+=("${launcher}: not a managed launcher, not replaced")') + w(" return 0") + w(" fi") + w(' if [ -x "${launcher}" ] && [ "$(cat "${launcher}")" = "${content}" ]; then') + w(' ok "profile: ${launcher}"') + w(" elif [ \"${CHECK_ONLY}\" = 1 ]; then") + w(' log "would write ${launcher}"') + w(" elif mkdir -p \"${bin_dir}\" && printf '%s\\n' \"${content}\" > \"${launcher}\" \\") + w(' && chmod 755 "${launcher}"; then') + w(' ok "profile: wrote ${launcher}"') + w(" else") + w(' fail "profile: could not write ${launcher}"') + w(" return 0") + w(" fi") + w(' case ":${PATH}:" in') + w(' *":${bin_dir}:"*) ;;') + w(' *) warn "profile: ${bin_dir} is not on PATH; claude-${name} will not be found" ;;') + w(" esac") + w("}") + w("") w("# This script lives in /installer, and the first-party skills it") w("# copies live in /skills.") w('KIT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"') @@ -460,9 +646,74 @@ def render_setup_script(data: dict[str, Any]) -> str: w("fi") w("") + # --- Claude profiles --- + # + # Emitted BEFORE anything that writes into a config root: the secondary + # profiles have to exist, and their shared surfaces have to be symlinks + # into the primary, before plugins or MCP servers are written anywhere. + w("# -----------------------------------------------------------------") + w("# 2. Claude Code profiles.") + w("#") + w("# A profile is a config root: CLAUDE_CONFIG_DIR moves credentials AND") + w("# conversation history together, so a second login is a second root.") + w("# The primary keeps the default location; every secondary shares the") + w("# surfaces named in the registry by symlinking back into it, and keeps") + w("# its own projects/, sessions/, history.jsonl and .claude.json.") + w("# -----------------------------------------------------------------") + profiles = claude_profiles(data) + primary = profiles[0] + secondaries = profiles[1:] + w(f'# {primary["name"]}: ' + " ".join(str(primary.get("purpose") or "").split())[:200]) + w('CLAUDE_PROFILE_DIRS=("${CLAUDE_HOME}")') + w("") + if secondaries: + w('if [ "${DO_PROFILES}" = 1 ]; then') + w(' log "claude profiles"') + w(' ok "${CLAUDE_HOME} (primary)"') + for profile in secondaries: + name = profile["name"] + config_dir = profile["config_dir"] + w("") + for chunk in _wrap(" ".join(str(profile.get("purpose") or "").split()), 62): + w(f" # {chunk}") + w(f' profile_dir="{config_dir}"') + # Run from inside a secondary profile's session, CLAUDE_CONFIG_DIR + # makes that secondary the primary too. Everything below would + # then point the root at itself, so stop before touching it. + w(' if [ "${profile_dir}" = "${CLAUDE_HOME}" ] || [ "${profile_dir}" -ef "${CLAUDE_HOME}" ]; then') + w(f' fail "{name}: CLAUDE_CONFIG_DIR points at ${{profile_dir}}; re-run with it unset"') + w(" else") + w(' if [ "${CHECK_ONLY}" = 1 ] && [ ! -d "${profile_dir}" ]; then') + w(' log "would create ${profile_dir}"') + w(" else") + w(' mkdir -p "${profile_dir}"') + w(" fi") + for rel in profile["shared_paths"]: + w(f' link_profile_path "${{CLAUDE_HOME}}" "${{profile_dir}}" "{rel}"') + w(' CLAUDE_PROFILE_DIRS+=("${profile_dir}")') + w(f' install_profile_launcher "{name}" "${{profile_dir}}"') + # The login is the operator's to do: it is interactive, and the + # whole point of the second root is that it holds a DIFFERENT + # account, which this script has no way to choose. + w("") + w(' if [ -s "${profile_dir}/.claude.json" ]; then') + w(f' ok "{name}: ${{profile_dir}} is set up"') + w(" else") + w(f' log "{name}: log in with claude-{name} (its own account)"') + w(" fi") + w(" fi") + w("else") + w(' log "secondary claude profiles skipped (--no-profiles)"') + w("fi") + w("") + # --- Marketplaces --- w("# -----------------------------------------------------------------") - w("# 2. Claude Code marketplaces and plugins.") + w("# 3. Claude Code marketplaces and plugins.") + w("#") + w("# Installed into the PRIMARY profile only. plugins/ is a shared") + w("# surface and enabledPlugins lives in the shared settings.json, so a") + w("# second install per profile would write the same state twice.") w("# -----------------------------------------------------------------") w('if ! command -v claude >/dev/null 2>&1; then') w(' fail "claude is not on PATH; skipping plugins, language servers and MCP"') @@ -491,7 +742,7 @@ def render_setup_script(data: dict[str, Any]) -> str: w(" fi") w("") w(" # ---------------------------------------------------------------") - w(" # 3. Plugin drift detection.") + w(" # 4. Plugin drift detection.") w(" #") w(" # The CLI does not support pinning plugins to a commit, so pins in") w(" # the registry are advisory. Compare installed commits against") @@ -545,7 +796,7 @@ def render_setup_script(data: dict[str, Any]) -> str: # --- Language servers --- w(" # ---------------------------------------------------------------") - w(" # 3. Language servers: the plugin AND the binary it drives.") + w(" # 5. Language servers: the plugin AND the binary it drives.") w(" #") w(" # An LSP plugin with no binary on PATH registers no tools and says") w(" # nothing about it. Install the plugin, but leave it disabled until") @@ -604,11 +855,15 @@ def render_setup_script(data: dict[str, Any]) -> str: # --- MCP --- w(" # ---------------------------------------------------------------") - w(" # 4. MCP servers, registered for Claude Code at user scope.") + w(" # 6. MCP servers, registered for Claude Code at user scope.") w(" #") w(" # The registry is authoritative: servers with `surfaces: []` or") w(" # `enabled: false` are removed. Others are ensured. Plugin-provided") w(" # servers (plugin:*:*) and hand-added ones are left untouched.") + w(" #") + w(" # Every claude call here runs once per profile: a server lives in the") + w(" # profile's own .claude.json, which is the one file two profiles must") + w(" # not share, so the fleet is registered into each of them.") w(" # ---------------------------------------------------------------") w(' if [ "${DO_MCP}" = 1 ]; then') w(' log "MCP servers"') @@ -629,7 +884,7 @@ def render_setup_script(data: dict[str, Any]) -> str: retired = server.get("enabled") is False or not (server.get("surfaces") or []) if not retired: continue - w(f' run claude mcp remove --scope user {name} >/dev/null 2>&1 || true') + w(f' run claude_each_profile claude mcp remove --scope user {name} >/dev/null 2>&1 || true') w(f' log "{name}: removed (retired in the registry)"') w("") # Now add/ensure active servers. @@ -690,7 +945,7 @@ def render_setup_script(data: dict[str, Any]) -> str: w(f" ensure_port_forward {name} {ns} {svc} {lport} {rport}") # --- Claude Code --- w(f"{indent}if command -v claude >/dev/null 2>&1; then") - w(f'{indent} run claude mcp remove --scope user {name} >/dev/null 2>&1 || true') + w(f'{indent} run claude_each_profile claude mcp remove --scope user {name} >/dev/null 2>&1 || true') if server["transport"] == "http": url = server.get("url_workstation") if not url: @@ -703,7 +958,7 @@ def render_setup_script(data: dict[str, Any]) -> str: # order (`--env K=V` then the name) with "missing required argument # 'commandOrUrl'". Verified against claude 2.1.267. add = f"claude mcp add --scope user {name}{env_flags} -- {server['command']} {args}".rstrip() - w(f'{indent} if run_redacted "claude mcp add {name}" {add}; then') + w(f'{indent} if run_redacted "claude mcp add {name}" claude_each_profile {add}; then') w(f'{indent} ok "{name} registered (claude)"') w(f"{indent} else") w(f'{indent} fail "{name} registration failed (claude)"') @@ -746,7 +1001,9 @@ def render_setup_script(data: dict[str, Any]) -> str: w(" # actually has. Check expected servers are present, and report") w(" # any unexpected ones (but leave plugin-provided and hand-added).") w(' if [ "${CHECK_ONLY}" != 1 ]; then') - w(" registered=$(claude mcp list 2>/dev/null || true)") + w(" # Once per profile: each one answers for its own .claude.json.") + w(' for profile_dir in "${CLAUDE_PROFILE_DIRS[@]}"; do') + w(' registered=$(CLAUDE_CONFIG_DIR="${profile_dir}" claude mcp list 2>/dev/null || true)') w(" # Check all expected servers are registered") w(" for want in \\") expected = [ @@ -763,11 +1020,16 @@ def render_setup_script(data: dict[str, Any]) -> str: w(" ; do") w(' case "${registered}" in') w(' *"${want}"*) ;;') - w(' *) warn "MCP server ${want} is not in \\`claude mcp list\\` output" ;;') + w(' *) warn "MCP server ${want} is not registered in ${profile_dir}" ;;') w(" esac") w(" done") w(" # Report unknown servers (but ignore plugin-provided and hand-added ones)") - w(' echo "${registered}" | grep -oE "\\b[a-z0-9_-]+\\b(?=:)" | sort -u | while read -r found; do') + w(" # One name per line, taken from the start of the line up to the first") + w(" # colon. NOT a lookahead: BSD grep has no PCRE, so `(?=:)` is a") + w(' # "repetition-operator operand invalid" error and the whole check') + w(" # silently inspected nothing on macOS.") + w(' echo "${registered}" | sed -n \'s/^\\([a-zA-Z0-9_:-]*\\):[[:space:]].*/\\1/p\' ' + '| sort -u | while read -r found; do') w(' case "${found}" in') # All registry-owned servers registry_servers = [s["name"] for s in _entries(data, "mcp_servers") if on_surface(s, "workstation")] @@ -775,9 +1037,11 @@ def render_setup_script(data: dict[str, Any]) -> str: w(f' {name}) ;;') # Plugin-provided and hand-added servers w(' plugin:*|idea|rubymine) ;;') - w(' *) warn "unknown MCP server ${found} -- hand-added or from a removed registry entry?" ;;') + w(' *) warn "unknown MCP server ${found} in ${profile_dir} ' + '-- hand-added or from a removed registry entry?" ;;') w(" esac") w(" done") + w(" done") w(" fi") w(" else") w(' log "MCP registration skipped (--no-mcp)"') @@ -787,7 +1051,7 @@ def render_setup_script(data: dict[str, Any]) -> str: # --- first-party skills --- w("# -----------------------------------------------------------------") - w("# 5. First-party skills that ship in this repository.") + w("# 7. First-party skills that ship in this repository.") w("#") w("# Copied, not fetched. Multi-file skills, so the whole tree moves and") w("# the destination is replaced rather than merged -- a stale script left") @@ -829,7 +1093,7 @@ def render_setup_script(data: dict[str, Any]) -> str: # --- retired hooks --- w("# -----------------------------------------------------------------") - w("# 6. Retired hooks.") + w("# 8. Retired hooks.") w("#") w("# The estate ships no agent hooks. install-agents.sh owns the purge;") w("# this only reports a machine that still has them so the operator") @@ -855,7 +1119,7 @@ def render_setup_script(data: dict[str, Any]) -> str: w("fi") w("") w("# -----------------------------------------------------------------") - w("# 7. Summary: what changed and what still needs attention.") + w("# 9. Summary: what changed and what still needs attention.") w("# -----------------------------------------------------------------") w("") w("log \"Summary of findings:\"") @@ -878,6 +1142,15 @@ def render_setup_script(data: dict[str, Any]) -> str: w(' log ""') w("fi") w("") + w("# Report shared surfaces a secondary profile did not get") + w('if [ "${#unshared_profile_paths[@]}" -gt 0 ]; then') + w(' log "Profile surfaces not shared:"') + w(' for entry in "${unshared_profile_paths[@]}"; do') + w(' log " - ${entry}"') + w(" done") + w(' log ""') + w("fi") + w("") w("# Report language servers with missing binaries") w('if [ "${#missing_lsp_binaries[@]}" -gt 0 ]; then') w(' log "Language servers with missing binaries:"') @@ -923,15 +1196,18 @@ def _mcp_env_flags(server: dict[str, Any], credential: str | None, optional_cred Shared by the Claude and Codex registration commands. Emits one flag per static ``env`` entry, plus the credential when the server declares one. - The optional-flag does not change emission: a credential var is emitted in - either case, since ``${VAR}`` expands to an empty string if unset (the - optional marker only controls the registration *guard*, not whether the - env var is passed). + + An OPTIONAL credential is emitted as ``${VAR:-}``. The script runs under + ``set -u``, and a bare ``${VAR}`` for an unset variable does not expand to + an empty string there -- it aborts the run, taking every step after it + with it. A required credential is already guarded by a ``-z`` test that + skips the registration, so it is only ever expanded when it is set. """ env = server.get("env") or {} flags = "".join(f' --env {key}="{value}"' for key, value in env.items()) if credential: - flags += f' --env {credential}="${{{credential}}}"' + default = ":-" if optional_cred else "" + flags += f' --env {credential}="${{{credential}{default}}}"' return flags diff --git a/tests/test_registry_render.py b/tests/test_registry_render.py index 625aff5..b1b423e 100644 --- a/tests/test_registry_render.py +++ b/tests/test_registry_render.py @@ -3,6 +3,7 @@ from __future__ import annotations import copy +import os import shutil import subprocess import sys @@ -316,3 +317,306 @@ def test_other_os_falls_back_to_a_one_shot_forward(tmp_path: Path) -> None: assert "one-shot kubectl port-forward" in result.stdout, result.stdout assert not _plist(tmp_path).exists() assert not (tmp_path / "state" / "launchctl.log").exists() + + +# --- Claude Code profiles --- + + +def test_exactly_one_primary_profile() -> None: + data = _registry() + data["claude_profiles"][1]["primary"] = True + with pytest.raises(render_registry.RegistryError, match="exactly one primary"): + render_registry.validate(data) + + +def test_a_secondary_profile_shares_from_the_primary() -> None: + data = _registry() + data["claude_profiles"][1]["shares_from"] = "nowhere" + with pytest.raises(render_registry.RegistryError, match="must share from the primary"): + render_registry.validate(data) + + +def test_a_secondary_profile_must_share_something() -> None: + """A profile that shares nothing is a second install, not a second login.""" + data = _registry() + data["claude_profiles"][1]["shared_paths"] = [] + with pytest.raises(render_registry.RegistryError, match="must name the paths it shares"): + render_registry.validate(data) + + +@pytest.mark.parametrize("private", ["projects", "history.jsonl", ".claude.json", "sessions"]) +def test_history_and_credentials_may_not_be_shared(private: str) -> None: + """Sharing either one merges the histories the profiles exist to keep apart.""" + data = _registry() + data["claude_profiles"][1]["shared_paths"] = [private] + with pytest.raises(render_registry.RegistryError, match="defeats the separate profile"): + render_registry.validate(data) + + +def test_a_shared_path_may_not_escape_the_config_root() -> None: + data = _registry() + data["claude_profiles"][1]["shared_paths"] = ["../.ssh"] + with pytest.raises(render_registry.RegistryError, match="may not escape it"): + render_registry.validate(data) + + +def test_setup_script_links_every_shared_surface_of_every_secondary() -> None: + data = _registry() + script = render_registry.render_setup_script(data) + for profile in render_registry.claude_profiles(data)[1:]: + assert f'profile_dir="{profile["config_dir"]}"' in script + for rel in profile["shared_paths"]: + assert f'link_profile_path "${{CLAUDE_HOME}}" "${{profile_dir}}" "{rel}"' in script + # The profile has to exist before anything registers a server into it. + assert script.index(f'profile_dir="{profile["config_dir"]}"') < script.index('log "MCP servers"') + + +def test_every_mcp_registration_reaches_every_profile() -> None: + """A server lives in a profile's own .claude.json, so one add per profile.""" + data = _registry() + script = render_registry.render_setup_script(data) + for server in data["mcp_servers"]: + name = server["name"] + retired = server.get("enabled") is False or not server.get("surfaces") + if retired: + assert f"claude_each_profile claude mcp remove --scope user {name}" in script + continue + if "workstation" not in server["surfaces"]: + continue + assert f"claude_each_profile claude mcp add --scope user {name}" in script + assert f"claude_each_profile claude mcp remove --scope user {name}" in script + # And the verification asks each profile what it actually has. + assert 'for profile_dir in "${CLAUDE_PROFILE_DIRS[@]}"; do' in script + assert 'CLAUDE_CONFIG_DIR="${profile_dir}" claude mcp list' in script + + +def test_plugins_are_installed_once_into_the_primary() -> None: + """plugins/ is shared and enabledPlugins lives in the shared settings.json.""" + script = render_registry.render_setup_script(_registry()) + assert "claude_each_profile claude plugin" not in script + + +# --- link_profile_path, driven against a real filesystem --- + + +def _shell_function(name: str) -> str: + script = render_registry.render_setup_script(_registry()) + start = script.index(f"{name}() {{") + return script[start : script.index("\n}\n", start) + 3] + + +def _link(tmp_path: Path, shared: list[str], check_only: int = 0) -> subprocess.CompletedProcess: + primary = tmp_path / ".claude" + secondary = tmp_path / ".claude-personal" + secondary.mkdir(parents=True, exist_ok=True) + body = "\n".join( + [ + f"CHECK_ONLY={check_only}", + "unshared_profile_paths=()", + "warnings=0", + "failures=0", + 'log() { echo "log $*"; }', + 'ok() { echo "ok $*"; }', + 'warn() { echo "warn $*"; }', + 'fail() { echo "FAIL $*"; }', + _shell_function("link_profile_path"), + *[f'link_profile_path "{primary}" "{secondary}" "{rel}"' for rel in shared], + ], + ) + return subprocess.run(["bash", "-c", body], capture_output=True, text=True, check=False) + + +def test_shared_surfaces_become_symlinks_into_the_primary(tmp_path: Path) -> None: + primary = tmp_path / ".claude" + (primary / "skills").mkdir(parents=True) + (primary / "settings.json").write_text("{}") + + result = _link(tmp_path, ["skills", "settings.json"]) + assert "FAIL" not in result.stdout, result.stdout + result.stderr + + for rel in ("skills", "settings.json"): + dest = tmp_path / ".claude-personal" / rel + assert dest.is_symlink() and dest.resolve() == (primary / rel).resolve() + + # Idempotent: a second run reports the link and changes nothing. + again = _link(tmp_path, ["skills", "settings.json"]) + assert "warn" not in again.stdout, again.stdout + + +def test_a_real_file_in_the_way_is_left_alone(tmp_path: Path) -> None: + """Replacing someone's own config with a symlink loses what it cannot give back.""" + (tmp_path / ".claude" / "skills").mkdir(parents=True) + own = tmp_path / ".claude-personal" / "skills" + own.mkdir(parents=True) + (own / "mine.md").write_text("mine") + + result = _link(tmp_path, ["skills"]) + assert "not a symlink" in result.stdout, result.stdout + assert (own / "mine.md").read_text() == "mine" + assert not own.is_symlink() + + +def test_a_missing_shared_directory_is_created_then_linked(tmp_path: Path) -> None: + """The link has to exist before whatever writes into it does.""" + (tmp_path / ".claude").mkdir() + result = _link(tmp_path, ["skills"]) + assert "warn" not in result.stdout, result.stdout + assert (tmp_path / ".claude" / "skills").is_dir() + assert (tmp_path / ".claude-personal" / "skills").is_symlink() + + +def test_a_missing_shared_file_is_reported_not_invented(tmp_path: Path) -> None: + """An empty settings.json would read as a real answer to a real question.""" + (tmp_path / ".claude").mkdir() + result = _link(tmp_path, ["settings.json"]) + assert "does not exist" in result.stdout, result.stdout + assert not (tmp_path / ".claude" / "settings.json").exists() + assert not (tmp_path / ".claude-personal" / "settings.json").exists() + + +def test_a_root_is_never_linked_onto_itself(tmp_path: Path) -> None: + """Setup run from a personal session sees CLAUDE_CONFIG_DIR as the primary.""" + root = tmp_path / ".claude-personal" + (root / "skills").mkdir(parents=True) + body = "\n".join( + [ + "CHECK_ONLY=0", + "unshared_profile_paths=()", + 'log() { echo "log $*"; }', + 'ok() { echo "ok $*"; }', + 'warn() { echo "warn $*"; }', + 'fail() { echo "FAIL $*"; }', + _shell_function("link_profile_path"), + f'link_profile_path "{root}" "{root}" skills', + ], + ) + result = subprocess.run(["bash", "-c", body], capture_output=True, text=True, check=False) + assert "refusing to link it onto itself" in result.stdout, result.stdout + assert (root / "skills").is_dir() and not (root / "skills").is_symlink() + + +def test_setup_script_skips_a_secondary_that_is_the_config_root() -> None: + script = render_registry.render_setup_script(_registry()) + guard = script.index('if [ "${profile_dir}" = "${CLAUDE_HOME}" ]') + assert guard < script.index('link_profile_path "${CLAUDE_HOME}" "${profile_dir}"') + + +def test_check_mode_links_nothing(tmp_path: Path) -> None: + (tmp_path / ".claude" / "skills").mkdir(parents=True) + (tmp_path / ".claude" / "agents").mkdir() + result = _link(tmp_path, ["skills"], check_only=1) + assert "would link" in result.stdout, result.stdout + assert not (tmp_path / ".claude-personal" / "skills").exists() + + +def test_a_profile_name_must_be_a_command_name() -> None: + data = _registry() + data["claude_profiles"][1]["name"] = "my profile" + with pytest.raises(render_registry.RegistryError, match="lowercase letters"): + render_registry.validate(data) + + +def test_setup_script_installs_a_launcher_for_every_secondary() -> None: + data = _registry() + script = render_registry.render_setup_script(data) + for profile in render_registry.claude_profiles(data)[1:]: + assert f'install_profile_launcher "{profile["name"]}" "${{profile_dir}}"' in script + + +def _launch(tmp_path: Path, check_only: int = 0) -> subprocess.CompletedProcess: + body = "\n".join( + [ + f"CHECK_ONLY={check_only}", + f'CLAUDE_LAUNCHER_DIR="{tmp_path / "bin"}"', + f'PATH="{tmp_path / "bin"}:$PATH"', + "unshared_profile_paths=()", + 'log() { echo "log $*"; }', + 'ok() { echo "ok $*"; }', + 'warn() { echo "warn $*"; }', + 'fail() { echo "FAIL $*"; }', + _shell_function("install_profile_launcher"), + f'install_profile_launcher personal "{tmp_path / "my root"}"', + ], + ) + return subprocess.run(["bash", "-c", body], capture_output=True, text=True, check=False) + + +def test_the_launcher_runs_claude_with_the_profile_root(tmp_path: Path) -> None: + result = _launch(tmp_path) + assert "FAIL" not in result.stdout and "warn" not in result.stdout, result.stdout + result.stderr + launcher = tmp_path / "bin" / "claude-personal" + assert os.access(launcher, os.X_OK) + + # A fake claude that reports the root it was given and its arguments. + fake = tmp_path / "fake" + fake.mkdir() + (fake / "claude").write_text('#!/usr/bin/env bash\necho "root=$CLAUDE_CONFIG_DIR args=$*"\n') + (fake / "claude").chmod(0o755) + env = {**os.environ, "PATH": f"{fake}:{os.environ['PATH']}"} + ran = subprocess.run([str(launcher), "--resume", "x y"], capture_output=True, text=True, env=env, check=False) + assert ran.stdout.strip() == f"root={tmp_path / 'my root'} args=--resume x y", ran.stdout + ran.stderr + + # Idempotent: a second run leaves the launcher as it is. + again = _launch(tmp_path) + assert "wrote" not in again.stdout, again.stdout + + +def test_a_hand_written_launcher_is_left_alone(tmp_path: Path) -> None: + (tmp_path / "bin").mkdir() + own = tmp_path / "bin" / "claude-personal" + own.write_text("#!/bin/sh\necho mine\n") + result = _launch(tmp_path) + assert "not written by setup" in result.stdout, result.stdout + assert own.read_text() == "#!/bin/sh\necho mine\n" + + +def test_check_mode_writes_no_launcher(tmp_path: Path) -> None: + result = _launch(tmp_path, check_only=1) + assert "would write" in result.stdout, result.stdout + assert not (tmp_path / "bin" / "claude-personal").exists() + + +def test_an_optional_credential_survives_being_unset(tmp_path: Path) -> None: + """`set -u` turns a bare ${VAR} for an unset var into an aborted run.""" + data = _registry() + script = render_registry.render_setup_script(data) + for server in data["mcp_servers"]: + if not server.get("credential_optional"): + continue + var = server["credential"] + assert f'--env {var}="${{{var}}}"' not in script + assert f'--env {var}="${{{var}:-}}"' in script + + # And prove it against bash: the expansion, under the script's own flags. + probe = tmp_path / "probe.sh" + probe.write_text("set -uo pipefail\necho \"[${OVERLEAF_SESSION:-}]\"\necho reached-the-end\n") + result = subprocess.run( + ["bash", str(probe)], capture_output=True, text=True, check=False, + env={"PATH": "/usr/bin:/bin"}, + ) + assert result.returncode == 0, result.stderr + assert "reached-the-end" in result.stdout + + +def test_registered_server_names_are_extracted_without_pcre(tmp_path: Path) -> None: + """BSD grep has no lookahead: `(?=:)` errors out and inspects nothing.""" + script = render_registry.render_setup_script(_registry()) + assert 'grep -oE "\\b[a-z0-9_-]+\\b(?=:)"' not in script + + listing = ( + "Checking MCP server health…\n\n" + "plugin:github:github: https://api.githubcopilot.com/mcp/ (HTTP) - ✘ Failed\n" + "idea: http://127.0.0.1:64342/stream (HTTP) - ✔ Connected\n" + "playwright: npx -y @playwright/mcp@latest --headless - ✔ Connected\n" + ) + extract = next( + line.strip() for line in script.splitlines() if "sort -u | while read -r found" in line + ).split("| sort -u")[0] + probe = tmp_path / "probe.sh" + probe.write_text(f'registered=$(cat)\n{extract} | sort -u\n') + result = subprocess.run( + ["bash", str(probe)], input=listing, capture_output=True, text=True, check=False, + ) + assert result.returncode == 0, result.stderr + assert result.stderr == "" + assert result.stdout.split() == ["idea", "playwright", "plugin:github:github"]