From 572db47de6f8c8efaff29d47aa37ab5f701909f7 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Tue, 15 Sep 2026 11:21:14 +0200 Subject: [PATCH 1/3] feat(installer): set up a second Claude Code profile CLAUDE_CONFIG_DIR moves a Claude Code config root whole: credentials, projects/, history.jsonl, sessions/ and .claude.json travel together. That is what makes a second login a second directory, and it is what keeps a personal conversation out of the work account's history. Declare the roots in the registry under claude_profiles:. The primary keeps the default location, so a bare `claude` needs nothing remembered; every secondary is created by setup and shares the surfaces it names in shared_paths: by symlinking back into the primary. Plugins install once, because plugins/ is shared and enabledPlugins lives in the shared settings.json. MCP servers are registered once per profile, because a server lives in the profile's own .claude.json - the one file the two profiles must not share. The renderer refuses a shared_paths: entry naming projects, history.jsonl, sessions, .claude.json or the credentials file: sharing one merges the histories the second profile exists to keep apart. Two defects found while running the result: - An unset OPTIONAL credential aborted the whole script. It renders as --env VAR="${VAR}", and under `set -u` that is not an empty string, it is an exit - so an unexported OVERLEAF_SESSION took every step after overleaf with it. Optional credentials now render ${VAR:-}. - The unknown-server check used a PCRE lookahead, `(?=:)`, which BSD grep rejects as "repetition-operator operand invalid". It printed an error and inspected nothing on macOS. A sed expression replaces it. --- docs/REGISTRY.md | 23 +++ docs/SETUP.md | 55 +++++++ installer/setup-workstation.sh | 176 +++++++++++++++++++--- registry/estate-tooling.yaml | 45 ++++++ scripts/render_registry.py | 258 ++++++++++++++++++++++++++++++--- tests/test_registry_render.py | 209 ++++++++++++++++++++++++++ 6 files changed, 724 insertions(+), 42 deletions(-) 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..ccd352f 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,60 @@ 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. + +Run the personal profile with the config root set: + +```bash +alias cp='CLAUDE_CONFIG_DIR=$HOME/.claude-personal claude' +CLAUDE_CONFIG_DIR=$HOME/.claude-personal claude # first run: log in, personal account +``` + +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..8ef2161 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,66 @@ 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}" + # 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 +} + # This script lives in /installer, and the first-party skills it # copies live in /skills. KIT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -180,7 +244,53 @@ 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 [ "${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}") + + if [ -s "${profile_dir}/.claude.json" ]; then + ok "personal: ${profile_dir} is set up" + else + log "personal: log in with CLAUDE_CONFIG_DIR=${profile_dir} claude (its own account)" + 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 +372,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 +461,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 +729,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 +759,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 +784,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 +807,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 +829,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 +838,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 +862,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 +874,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 +891,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 +902,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 +952,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 +966,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 +989,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..5e9d2de 100644 --- a/scripts/render_registry.py +++ b/scripts/render_registry.py @@ -35,6 +35,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 +155,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 +173,62 @@ 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") + 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 +418,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 +430,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 +447,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 +488,66 @@ 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(" # 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("# 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 +598,66 @@ 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}"') + 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}")') + # 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_CONFIG_DIR=${{profile_dir}} claude (its own account)"') + 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 +686,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 +740,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 +799,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 +828,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 +889,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 +902,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 +945,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 +964,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 +981,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 +995,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 +1037,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 +1063,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 +1086,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 +1140,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..4e8af48 100644 --- a/tests/test_registry_render.py +++ b/tests/test_registry_render.py @@ -316,3 +316,212 @@ 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_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_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"] From 1f45962f40fda39542e96fe535976a88eb491476 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Tue, 15 Sep 2026 11:43:02 +0200 Subject: [PATCH 2/3] feat(installer): install a claude- launcher per secondary profile A secondary profile was reachable only as CLAUDE_CONFIG_DIR=$HOME/.claude-personal claude; nothing created a command for it. Setup now writes ~/.local/bin/claude- (or $CLAUDE_LAUNCHER_DIR), which sets the config root and execs claude. A file at that path that setup did not write is left alone and reported, --check writes nothing, and a launcher directory missing from PATH is warned about. Profile names are validated as command names. Also fix a defect found while running it: setup run from inside a secondary profile's session sees CLAUDE_CONFIG_DIR as the primary root, and linked every shared surface of that root onto itself, leaving the profile unable to load skills, plugins or settings. Setup now fails that profile instead, and link_profile_path refuses to link a root onto itself. --- docs/SETUP.md | 13 +++-- installer/setup-workstation.sh | 75 +++++++++++++++++++++------ scripts/render_registry.py | 73 ++++++++++++++++++++++---- tests/test_registry_render.py | 95 ++++++++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 29 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index ccd352f..880adea 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -116,13 +116,20 @@ Setup creates the secondary root and symlinks each surface named in its naming `projects`, `history.jsonl`, `sessions`, `.claude.json` or the credentials file, and a test proves the refusal fires. -Run the personal profile with the config root set: +Setup also writes a launcher per secondary profile into `~/.local/bin` (or +`$CLAUDE_LAUNCHER_DIR`), the directory `claude` itself installs into: ```bash -alias cp='CLAUDE_CONFIG_DIR=$HOME/.claude-personal claude' -CLAUDE_CONFIG_DIR=$HOME/.claude-personal claude # first run: log in, personal account +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. diff --git a/installer/setup-workstation.sh b/installer/setup-workstation.sh index 8ef2161..041aadb 100755 --- a/installer/setup-workstation.sh +++ b/installer/setup-workstation.sh @@ -101,6 +101,12 @@ claude_each_profile() { 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 @@ -142,6 +148,38 @@ link_profile_path() { 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 '#!/usr/bin/env bash\n%s\n# Claude Code with the %s profile config root.\nexport CLAUDE_CONFIG_DIR=%q\nexec claude "$@"\n' \ + "${marker}" "${name}" "${dir}")" + 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)" @@ -263,23 +301,28 @@ if [ "${DO_PROFILES}" = 1 ]; then # MCP fleet as work; its own login and its own conversation # history. profile_dir="$HOME/.claude-personal" - 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}") - - if [ -s "${profile_dir}/.claude.json" ]; then - ok "personal: ${profile_dir} is set up" + 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 - log "personal: log in with CLAUDE_CONFIG_DIR=${profile_dir} claude (its own account)" + 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)" diff --git a/scripts/render_registry.py b/scripts/render_registry.py index 5e9d2de..5a0679f 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 @@ -188,6 +189,12 @@ def _validate_claude_profiles(data: dict[str, Any]) -> None: 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"): @@ -507,6 +514,12 @@ def render_setup_script(data: dict[str, Any]) -> str: 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") @@ -548,6 +561,38 @@ def render_setup_script(data: dict[str, Any]) -> str: 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 '#!/usr/bin/env bash\\n%s\\n# Claude Code with the %s profile config root.\\nexport CLAUDE_CONFIG_DIR=%q\\nexec claude \"$@\"\\n' \\") + w(' "${marker}" "${name}" "${dir}")"') + 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}\" && 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)"') @@ -629,22 +674,30 @@ def render_setup_script(data: dict[str, Any]) -> str: for chunk in _wrap(" ".join(str(profile.get("purpose") or "").split()), 62): w(f" # {chunk}") w(f' profile_dir="{config_dir}"') - w(' if [ "${CHECK_ONLY}" = 1 ] && [ ! -d "${profile_dir}" ]; then') - w(' log "would create ${profile_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(' mkdir -p "${profile_dir}"') - w(" fi") + 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' 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_CONFIG_DIR=${{profile_dir}} claude (its own account)"') + 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)"') diff --git a/tests/test_registry_render.py b/tests/test_registry_render.py index 4e8af48..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 @@ -473,6 +474,33 @@ def test_a_missing_shared_file_is_reported_not_invented(tmp_path: Path) -> None: 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() @@ -481,6 +509,73 @@ def test_check_mode_links_nothing(tmp_path: Path) -> None: 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() From 642976c693e3d6bbb7eed5be5eb7acbf656b16fc Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Tue, 15 Sep 2026 11:50:42 +0200 Subject: [PATCH 3/3] style(installer): wrap the launcher lines ruff flagged as too long --- installer/setup-workstation.sh | 9 ++++++--- scripts/render_registry.py | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/installer/setup-workstation.sh b/installer/setup-workstation.sh index 041aadb..ebf857c 100755 --- a/installer/setup-workstation.sh +++ b/installer/setup-workstation.sh @@ -157,8 +157,10 @@ install_profile_launcher() { local launcher="${bin_dir}/claude-${name}" local marker="# managed by agent-kit setup-workstation.sh" local content - content="$(printf '#!/usr/bin/env bash\n%s\n# Claude Code with the %s profile config root.\nexport CLAUDE_CONFIG_DIR=%q\nexec claude "$@"\n' \ - "${marker}" "${name}" "${dir}")" + 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") @@ -168,7 +170,8 @@ install_profile_launcher() { 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 + 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}" diff --git a/scripts/render_registry.py b/scripts/render_registry.py index 5a0679f..6ef6d4f 100644 --- a/scripts/render_registry.py +++ b/scripts/render_registry.py @@ -570,8 +570,10 @@ def render_setup_script(data: dict[str, Any]) -> str: w(' local launcher="${bin_dir}/claude-${name}"') w(' local marker="# managed by agent-kit setup-workstation.sh"') w(" local content") - w(" content=\"$(printf '#!/usr/bin/env bash\\n%s\\n# Claude Code with the %s profile config root.\\nexport CLAUDE_CONFIG_DIR=%q\\nexec claude \"$@\"\\n' \\") - w(' "${marker}" "${name}" "${dir}")"') + 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")') @@ -581,7 +583,8 @@ def render_setup_script(data: dict[str, Any]) -> str: 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}\" && chmod 755 \"${launcher}\"; then") + 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}"')