diff --git a/CLAUDE.md b/CLAUDE.md index f7b02ea..56fae68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,7 @@ The setup script requires explicit user confirmation before installing. CLI flag ### Environment Configuration System -YAML configs define complete environments: dependencies, agents, MCP servers (auto-permission), slash commands, system prompts (append/replace modes), hooks, global config (`~/.claude.json` via deep merge), and selective inheritance via `merge-keys` directive. +YAML configs define complete environments: dependencies, agents, MCP servers, slash commands, system prompts (append/replace modes), hooks, OS environment variables, user settings (`settings.json` content via `user-settings`), global config (`~/.claude.json` via `global-config`), and selective inheritance via `merge-keys` directive. **List Inherit (`inherit: [list]`):** `inherit` accepts `str | list[str | InheritEntry] | None`. Single-element plain-string lists normalize to string (recursive resolution -- the entry's own `inherit` IS resolved). Single-element structured lists (`[{config: ..., merge-keys: [...]}]`) route to `_resolve_list_inherit()` (composition mode). Multi-element lists use flat left-to-right composition via `_resolve_list_inherit()`. Four mandatory rules: @@ -91,7 +91,7 @@ YAML configs define complete environments: dependencies, agents, MCP servers (au `_validate_merge_keys()` is a DRY helper extracted from the former inline validation, reused for both leaf and per-entry merge-keys validation. `InheritEntry` is a Pydantic model (`config: str`, `merge_keys: list[str] | None` with alias `merge-keys`, `extra='forbid'`) containing an inline `mergeable` frozenset (DRY violation covered by parity test `test_mergeable_config_keys_parity.py`). `_resolve_list_inherit()` threads `auth_param` through `load_config_from_source()` for each entry, extends the existing `visited` set for circular dependency detection, and builds `InheritanceChainEntry` entries for each loaded config. -**Env Loader Files:** `generate_env_loader_files()` creates Rustup-style shell scripts containing ONLY `os-env-variables` (not `env-variables`). Per-command files: `~/.claude/{cmd}/env.sh`, `env.fish` (if Fish installed), `env.ps1` (Windows), `env.cmd` (Windows). Files are generated only when `command_names` is provided; without `command_names`, the function returns `{}`. Loader files are toolbox-owned and rebuilt on every run: `None`-valued (deletion) vars are excluded from the exports, and when no active variable remains the files are rewritten header-only so stale exports from a prior run stop being re-applied by the launcher. `main()` Step 7 calls `set_all_os_env_variables()` and `generate_env_loader_files()` unconditionally (an empty or absent dict is an internal no-op for the OS writer; the loader rebuild always runs). `create_launcher_script()` injects guarded source lines in all 6 launcher variants so commands auto-load env vars. +**Env Loader Files:** `generate_env_loader_files()` creates Rustup-style shell scripts containing `os-env-variables` (OS-level persistent exports, distinct from the session-scoped `user-settings.env` written into `config.json`). Per-command files: `~/.claude/{cmd}/env.sh`, `env.fish` (if Fish installed), `env.ps1` (Windows), `env.cmd` (Windows). Files are generated only when `command_names` is provided; without `command_names`, the function returns `{}`. Loader files are toolbox-owned and rebuilt on every run: `None`-valued (deletion) vars are excluded from the exports, and when no active variable remains the files are rewritten header-only so stale exports from a prior run stop being re-applied by the launcher. `main()` Step 7 calls `set_all_os_env_variables()` and `generate_env_loader_files()` unconditionally (an empty or absent dict is an internal no-op for the OS writer; the loader rebuild always runs). `create_launcher_script()` injects guarded source lines in all 6 launcher variants so commands auto-load env vars. **Fish Dual-Mechanism:** `set_os_env_variable_unix()` writes `set -gx` to `config.fish` (durable persistence) AND calls `set -Ux` via subprocess (instant propagation to all running Fish sessions). For deletions, `set -Ue` removes the universal variable. The `config.fish` write is authoritative; `set -Ux` is complementary. @@ -105,18 +105,19 @@ Writes to `~/.claude.json`. When `command-names` is present, dual-writes to BOTH **Asymmetric write strategy:** `.claude.json` uses dual-write (global semantics, bare sessions need it). `settings.json` uses single-write with scope-based routing (environment-specific semantics, dual-write would cause cross-environment contamination). Root cause: CLI resolves `.claude.json` via `getGlobalClaudeFile()` using `homedir()` as base, while `settings.json` is resolved via `getClaudeConfigHomeDir()` using `join(homedir(), '.claude')` as base. Neither has fallback or inheritance. -**Null-as-delete (RFC 7396):** `null` values in `user-settings`/`global-config` delete the key from the target JSON. `_merge_recursive()` handles via `target.pop(key, None)`. Null in arrays is NOT deletion. Bare YAML keys (`key:` with no value) also trigger deletion -- use `key: ""` for empty strings. Dry-run shows `[DELETE]` markers in RED. `env-variables` and `os-env-variables` values support the same convention (`dict[str, str | None]`): a null `env-variables` entry deletes the variable -- `_build_profile_settings()` preserves per-key `None` so the base-mode merge writer deletes `env.VAR` from `~/.claude/settings.json`, while `create_profile_config()` strips per-key nulls before the atomic isolated `config.json` write (absence equals deletion; the literal JSON null is never written for env entries, and the `env` key is dropped entirely when every entry is null) -- and a null `os-env-variables` entry deletes the OS-level variable. +**Null-as-delete (RFC 7396):** `null` values in `user-settings`/`global-config` delete the key from the target JSON. `_merge_recursive()` handles via `target.pop(key, None)`. Null in arrays is NOT deletion. Bare YAML keys (`key:` with no value) also trigger deletion -- use `key: ""` for empty strings. Dry-run shows `[DELETE]` markers in RED. `user-settings.env` and `os-env-variables` values support the same convention (`dict[str, str | None]`): a null `user-settings.env` entry deletes the variable -- `create_profile_config()` strips per-key nulls before the atomic isolated `config.json` write (absence equals deletion; the literal JSON null is never written for env entries, and the `env` key is dropped entirely when every entry is null) while the base-mode merge writer deletes `env.VAR` from `~/.claude/settings.json` -- and a null `os-env-variables` entry deletes the OS-level variable. ### Two-File Architecture for Command Environments -When `command-names` is specified, the setup creates an isolated directory `~/.claude/{cmd}/` containing two configuration files loaded by Claude Code at different priority levels: +When `command-names` is specified, the setup creates an isolated directory `~/.claude/{cmd}/` containing one toolbox-written configuration file delivered to Claude Code at the command-line settings layer: -| File | Priority | Content | Write Mechanism | -|------------------|------------------|------------------------------------------------------------------------------------------------|------------------------------------------------| -| `settings.json` | 5 (userSettings) | YAML `user-settings` | Deep merge via `write_user_settings()` | -| `config.json` | 2 (flagSettings) | Hooks, env vars, permissions, model, MCP permissions, statusLine, attribution, effortLevel | Atomic write via `create_profile_config()` | +| File | Priority | Content | Write Mechanism | +|---------------|------------------|--------------------------------------------------------------------------------------------------|------------------------------------------------| +| `config.json` | 2 (flagSettings) | YAML `user-settings` content merged with toolbox-built `statusLine` and `hooks` entries | Atomic overwrite via `create_profile_config()` | -Scope-based routing: `command-names` presence in the final resolved config determines the SINGLE write target for `write_user_settings()`. Present: `~/.claude/{cmd}/settings.json`. Absent: `~/.claude/settings.json`. Never dual-write. +The toolbox does NOT write `~/.claude/{cmd}/settings.json` in isolated mode. The `user-settings` section (raw `settings.json` keys with camelCase names) is merged directly into `config.json` by `create_profile_config()` alongside the `statusLine` and `hooks` entries built from the root-level YAML keys. The two sources are disjoint by construction: `statusLine` and `hooks` are rejected inside `user-settings`. + +Scope-based routing: `command-names` presence in the final resolved config determines where `write_user_settings()` writes. Present: Step 14 prints an info message and skips the write (user settings are built into `config.json` in Step 18 instead). Absent: `write_user_settings()` deep-merges into `~/.claude/settings.json`. Never dual-write. The launcher (`launch.sh`) passes `config.json` via `--settings` flag and sets `export CLAUDE_CONFIG_DIR` to the isolated directory. @@ -141,19 +142,23 @@ Global commands (e.g., `claude-python`) work across all Windows shells: shared P Pydantic schema `EnvironmentConfig` in `scripts/models/environment_config.py` defines the complete structure for environment YAML configurations. This repository is the canonical source; changes are synced to downstream repos. -**Cross-field model validators** (`@model_validator(mode='after')`): `validate_command_names_and_defaults` (command-names + command-defaults co-dependency), `validate_effort_level_model_support` (effort-level `xhigh`/`max` require an Opus or Fable model -- `EXTENDED_EFFORT_MODEL_MARKERS` substring match -- or the exact alias `best`), `validate_version_requires_command_names` (version requires non-empty command-names), `validate_link_projects_dir_requires_command_names` (link-projects-dir requires non-empty command-names), `validate_merge_keys_requires_inherit` (non-empty merge-keys requires inherit), `validate_profile_mcp_requires_command_names` (profile-scoped MCP servers require command-names), `validate_hooks_files_consistency` (hooks files/events cross-references). +**Cross-field model validators** (`@model_validator(mode='after')`): `validate_command_names_and_defaults` (command-names + command-defaults co-dependency), `validate_version_requires_command_names` (version requires non-empty command-names), `validate_link_projects_dir_requires_command_names` (link-projects-dir requires non-empty command-names), `validate_merge_keys_requires_inherit` (non-empty merge-keys requires inherit), `validate_profile_mcp_requires_command_names` (profile-scoped MCP servers require command-names), `validate_hooks_files_consistency` (hooks files/events cross-references). + +**Field validators**: `validate_os_env_variables` accepts `null` values as deletion requests (`dict[str, str | None]`), enforces key format `^[A-Za-z_][A-Za-z0-9_]*$`, and rejects null bytes in non-null values. + +**Validation constants** (shared between `environment_config.py` and `setup_environment.py` runtime validation, parity enforced by `tests/scripts/models/test_settings_validation_parity.py`): `EFFORT_LEVEL_VALUES` (`{'low', 'medium', 'high', 'xhigh', 'max'}`), `PERMISSIONS_DEFAULT_MODE_VALUES` (`{'default', 'acceptEdits', 'plan', 'auto', 'dontAsk', 'bypassPermissions', 'delegate'}`), `USER_SETTINGS_KEBAB_KEY_CORRECTIONS` (kebab-to-camelCase corrections for common mistakes: `always-thinking-enabled` → `alwaysThinkingEnabled`, `company-announcements` → `companyAnnouncements`, `effort-level` → `effortLevel`, `env-variables` → `env`), `USER_SETTINGS_ROOT_ONLY_KEYS` (`status-line`, `os-env-variables`), `USER_SETTINGS_GLOBAL_ONLY_KEYS` (keys belonging in `global-config`: `autoUpdates`, `installMethod`, etc.), `GLOBAL_CONFIG_SETTINGS_ONLY_KEYS` (keys belonging in `user-settings`/`settings.json`: `model`, `permissions`, `env`, `attribution`, `alwaysThinkingEnabled`, `effortLevel`, `companyAnnouncements`, `statusLine`, `hooks`, `availableModels`, `enforceAvailableModels`). -**Field validators**: `validate_model` accepts any non-empty string (supports Anthropic models, third-party models, and provider-prefixed identifiers). `validate_env_variables` and `validate_os_env_variables` both accept `null` values as deletion requests (`dict[str, str | None]`), enforce key format `^[A-Za-z_][A-Za-z0-9_]*$`, and reject null bytes in non-null values. +**Fail-fast validation**: `validate_user_settings_values()` and `validate_global_config_values()` in `environment_config.py` implement all validation rules that `validate_user_settings()` and `validate_global_config()` in `setup_environment.py` also run at runtime. The `UserSettings` and `GlobalConfig` Pydantic models call these via `@model_validator(mode='before')` during YAML parsing so errors surface early. Runtime validators in `main()` exit 1 on the first failing section. Known-key value validation for `user-settings`: `env` dict keys must match `^[A-Za-z_][A-Za-z0-9_]*$` with string-or-null values; `permissions.defaultMode` must be in `PERMISSIONS_DEFAULT_MODE_VALUES`; `effortLevel` must be in `EFFORT_LEVEL_VALUES` with model-support cross-check for `xhigh`/`max`; `alwaysThinkingEnabled` must be boolean; `companyAnnouncements` must be a list of strings; `attribution.commit`/`attribution.pr` must be strings. **MCPServerStdio fields**: `name`, `scope`, `command`, `args` (`list[str] | None`), `env`. The `args` field provides an optional argument list; when present, the runtime combines `command + args` (via `shlex.quote` in `configure_mcp_server()`) or passes them separately to the MCP config JSON (in `create_mcp_config_file()`). **KNOWN_CONFIG_KEYS parity rule:** When adding new config keys to `setup_environment.py` (extending `KNOWN_CONFIG_KEYS`), the `EnvironmentConfig` model MUST be updated simultaneously (and vice versa). `tests/scripts/models/test_known_config_keys_parity.py` enforces STRICT BIDIRECTIONAL match with ZERO exceptions. Deprecated keys must be DELETED from both, not kept with backward compatibility shims. -**Tests:** `tests/scripts/models/` -- `test_environment_config.py`, `test_mcp_server_scope.py`, `test_known_config_keys_parity.py`, `test_mergeable_config_keys_parity.py`. +**Tests:** `tests/scripts/models/` -- `test_environment_config.py`, `test_mcp_server_scope.py`, `test_known_config_keys_parity.py`, `test_mergeable_config_keys_parity.py`, `test_settings_validation_parity.py`. **Sync & Runtime:** `.github/workflows/sync-to-repos.yml` syncs model changes to `alex-feel/claude-code-artifacts{,-public}` at `.github/environment_config.py`. The model is NOT imported at runtime -- `KNOWN_CONFIG_KEYS` is the active runtime mechanism. -**UserSettings:** Free-form open model (`extra='allow'`, ZERO hardcoded fields). Only guard: `check_excluded_keys` blocks `hooks`/`statusLine` (profile-specific, must be at environment YAML root level). +**UserSettings:** Free-form open model (`extra='allow'`, ZERO hardcoded fields). Guards: `check_excluded_keys` blocks `hooks`/`statusLine` (profile-specific, must be at environment YAML root level); `check_known_key_values` runs fail-fast validation of known built-in settings keys via `validate_user_settings_values()`. ## Documentation Maintenance @@ -225,15 +230,15 @@ Conventional Commits enforced by commitizen: `feat:` (minor bump), `fix:` (patch ### MCP Server Permissions -MCP servers are automatically pre-allowed via `permissions.allow: ["mcp__servername"]` in the profile configuration (`config.json`). When `command-names` creates an isolated environment, `configure_mcp_server()` propagates `CLAUDE_CONFIG_DIR` to the subprocess for `scope: user` servers, ensuring they are written to the isolated `.claude.json`. +When `command-names` creates an isolated environment, `configure_mcp_server()` propagates `CLAUDE_CONFIG_DIR` to the subprocess for `scope: user` servers, ensuring they are written to the isolated `.claude.json`. ### Automatic Auto-Update Management -`setup_environment.py` automatically disables auto-updates when a specific Claude Code version is pinned via `claude-code-version` in YAML, and cleans up stale controls when using latest/absent version. Manages 4 in-memory dict targets (`global-config`, `user-settings.env`, `env-variables`, `os-env-variables`) via `apply_auto_update_settings()`. WARN-but-Respect semantics: if the user explicitly sets a contradicting value in their YAML configuration (e.g., `autoUpdates: true` in `global-config` while pinning a version), the user value is preserved and a warning is emitted. Injection is membership-gated (`key not in dict`), so an explicit user `null` (a deletion request) also counts as a user declaration and is respected with a warning, never overwritten. After injection, `main()` writes the possibly-injected `env-variables` dict back into the resolved config (`config['env-variables'] = env_variables`) so both Step 18 profile builders emit the injected keys even when the YAML lacks the section (a YAML `env-variables: null` is likewise superseded when pinned). The `[auto]` marker in the installation summary shows auto-injected values. Auto-update management is solely the responsibility of `setup_environment.py`; `install_claude.py` does not participate in auto-update configuration. +`setup_environment.py` automatically disables auto-updates when a specific Claude Code version is pinned via `claude-code-version` in YAML, and cleans up stale controls when using latest/absent version. Manages 3 in-memory dict targets (`global-config`, `user-settings.env`, `os-env-variables`) via `apply_auto_update_settings()`. WARN-but-Respect semantics: if the user explicitly sets a contradicting value in their YAML configuration (e.g., `autoUpdates: true` in `global-config` while pinning a version), the user value is preserved and a warning is emitted. Injection is membership-gated (`key not in dict`), so an explicit user `null` (a deletion request) also counts as a user declaration and is respected with a warning, never overwritten. The `[auto]` marker in the installation summary shows auto-injected values. Auto-update management is solely the responsibility of `setup_environment.py`; `install_claude.py` does not participate in auto-update configuration. -**Write-remove symmetry:** Auto-update controls are written to specific targets via scope-based routing, while removal sweeps the filesystem locations that can hold artifacts from prior configurations. `_run_stale_controls_cleanup()` runs as a post-write pass in `main()` (Step 16), passing `is_isolated` (from `primary_command_name`) and the user-declared control keys -- collected by `_collect_user_declared_control_keys()` from `user-settings.env` and `env-variables` BEFORE injection -- to both `cleanup_stale_auto_update_controls()` and `cleanup_stale_ide_extension_controls()`. On unpinned runs they sweep `~/.claude/settings.json`, all `~/.claude/*/settings.json`, `~/.claude.json`, and all `~/.claude/*/.claude.json`, except that the `settings.json` sweep is skipped for a control key the current YAML itself declares (the removal counterpart of WARN-but-Respect). On pinned runs they clean the base `~/.claude/settings.json` only when the run is isolated (bare sessions must not inherit isolated restrictions); a pinned non-isolated run performs no `settings.json` sweep because the base file is the run's own Step 14/18 write target. Removal of `autoUpdates` from `.claude.json` files is value-conditional: only `false` (auto-injected) is removed, `true` (user preference) is preserved. +**Write-remove symmetry:** Auto-update controls are written to specific targets via scope-based routing, while removal sweeps the filesystem locations that can hold artifacts from prior configurations. `_run_stale_controls_cleanup()` runs as a post-write pass in `main()` (Step 16), passing `is_isolated` (from `primary_command_name`) and the user-declared control keys -- collected by `_collect_user_declared_control_keys(user_settings)` from `user-settings.env` BEFORE injection -- to both `cleanup_stale_auto_update_controls()` and `cleanup_stale_ide_extension_controls()`. On unpinned runs they sweep `~/.claude/settings.json`, all `~/.claude/*/settings.json`, `~/.claude.json`, and all `~/.claude/*/.claude.json`, except that the `settings.json` sweep is skipped for a control key the current YAML itself declares (the removal counterpart of WARN-but-Respect). On pinned runs they clean the base `~/.claude/settings.json` only when the run is isolated (bare sessions must not inherit isolated restrictions); a pinned non-isolated run performs no `settings.json` sweep because the base file is the run's own Step 14 write target. Removal of `autoUpdates` from `.claude.json` files is value-conditional: only `false` (auto-injected) is removed, `true` (user preference) is preserved. -**Unpinned removal semantics:** On an unpinned run nothing is auto-injected, so `_remove_auto_update_controls()` and its exact twin `_remove_ide_extension_controls()` preserve every user-declared control in the four in-memory dicts (the removal counterpart of WARN-but-Respect). Their only mutation schedules an OS-level deletion entry (`os_env_variables[KEY] = None`, gated on `KEY not in os_env_variables` so it is skipped only when the user declares the variable in `os-env-variables` specifically) because the OS-level variable has no filesystem sweep; stale on-disk artifacts are removed by the Step 16 sweep instead. +**Unpinned removal semantics:** On an unpinned run nothing is auto-injected, so `_remove_auto_update_controls()` and its exact twin `_remove_ide_extension_controls()` preserve every user-declared control in the three in-memory dicts (the removal counterpart of WARN-but-Respect). Their only mutation schedules an OS-level deletion entry (`os_env_variables[KEY] = None`, gated on `KEY not in os_env_variables` so it is skipped only when the user declares the variable in `os-env-variables` specifically) because the OS-level variable has no filesystem sweep; stale on-disk artifacts are removed by the Step 16 sweep instead. ### Environment Variables @@ -310,7 +315,7 @@ Type-specific fields (setting a field on the wrong type produces a validation er ### Two-Layer Naming Convention (Sub-Keys) -Sub-keys in structured sections (`hooks.events[]`, `permissions`) use kebab-case in YAML and are translated to camelCase for Claude Code JSON output by the setup script. Sub-keys in free-form sections (`user-settings`, `global-config`) pass through as-is and must match Claude Code's native camelCase. +Sub-keys in structured sections (`hooks.events[]`) use kebab-case in YAML and are translated to camelCase for Claude Code JSON output by the setup script. Sub-keys in free-form sections (`user-settings`, `global-config`) pass through as-is and must match Claude Code's native camelCase. ### System Prompt Configuration diff --git a/README.md b/README.md index c40450b..a5b116d 100644 --- a/README.md +++ b/README.md @@ -8,20 +8,18 @@ Automated installers and an environment configuration framework for Claude Code on Windows, macOS, and Linux. -Define your complete Claude Code environment in a single YAML file -- custom agents, MCP servers, slash commands, hooks, skills, model settings, and more -- and install everything with one command. +Define your complete Claude Code environment in a single YAML file -- custom agents, MCP servers, slash commands, hooks, skills, settings, and more -- and install everything with one command. ## Features - **Custom agents** -- specialized subagents for code review, research, debugging, and any workflow you design -- **MCP servers** -- HTTP, SSE, and stdio transports with automatic permission pre-allowing +- **MCP servers** -- HTTP, SSE, and stdio transports with scope-based registration - **Slash commands** -- custom commands for frequently used workflows - **Rules** -- user-scope rule files for coding standards, security policies, and project conventions - **Skills** -- multi-file skill packages for complex agent capabilities - **System prompts** -- replace or append to the default Claude Code prompt - **Hooks** -- four hook types: command (shell scripts), HTTP (webhooks), prompt (LLM evaluation), and agent (subagent with tools) -- **Permissions** -- fine-grained allow, deny, and ask rules for Claude Code tools and actions -- **Model and reasoning control** -- model selection, effort levels (low, medium, high, xhigh, max), thinking mode -- **User and global settings** -- direct control over `settings.json` and `~/.claude.json` +- **User and global settings** -- `user-settings` is raw `settings.json` content (camelCase keys) and `global-config` is raw `~/.claude.json` content, covering model selection, permissions, effort levels, thinking mode, environment variables, and every other Claude Code setting - **Status line** -- custom status bar scripts for real-time session information - **Configuration inheritance** -- extend and override parent configurations with selective per-key merge - **Shared projects directory** -- optionally link an isolated profile's `projects/` to the base `~/.claude/projects/` so the isolated and default Claude share session history @@ -61,8 +59,10 @@ mcp-servers: # so the token stays in your environment, never in the config file. header: "Authorization: Bearer ${CONTEXT_SERVER_TOKEN}" -model: "sonnet" -effort-level: "high" +# user-settings holds raw settings.json content with camelCase keys +user-settings: + model: "sonnet" + effortLevel: "high" command-defaults: system-prompt: "prompts/system-prompt.md" diff --git a/SECURITY.md b/SECURITY.md index 2a18b85..9115877 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ | Version | Supported | |---------|--------------------| -| 5.x | :white_check_mark: | -| < 5.0 | :x: | +| 6.x | :white_check_mark: | +| < 6.0 | :x: | > **Policy:** Only the latest major version receives security updates. When a new major version is released, prior major versions are immediately unsupported. diff --git a/docs/environment-configuration-guide.md b/docs/environment-configuration-guide.md index a1c267c..5c2f19d 100644 --- a/docs/environment-configuration-guide.md +++ b/docs/environment-configuration-guide.md @@ -150,19 +150,12 @@ Quick-reference table of all configuration keys. Each key links to its detailed | [`rules`](#rules) | `list[str]` | No | `[]` | Rule markdown file paths (user-scope) | | [`skills`](#skills) | `list[Skill]` | No | `[]` | Skill configurations | | [`files-to-download`](#files-to-download) | `list[FileToDownload]` | No | `[]` | Files to download during setup | -| [`global-config`](#global-config) | `GlobalConfig` | No | `None` | Settings for `~/.claude.json` | +| [`global-config`](#global-config) | `GlobalConfig` | No | `None` | Raw `~/.claude.json` content (camelCase keys) | | [`hooks`](#hooks) | `Hooks` | No | `None` | Hook configurations (files and events) | | [`mcp-servers`](#mcp-servers) | `list[dict]` | No | `[]` | MCP server configurations | -| [`model`](#model) | `str` | No | `None` | Model alias or custom model name | -| [`permissions`](#permissions) | `Permissions` | No | `None` | Permission rules for tools | -| [`env-variables`](#env-variables) | `dict` | No | `None` | Claude-level environment variables | | [`os-env-variables`](#os-env-variables) | `dict` | No | `None` | OS-level persistent environment variables | | [`command-defaults`](#command-defaults) | `CommandDefaults` | No* | `None` | System prompt and mode | -| [`user-settings`](#user-settings) | `UserSettings` | No | `None` | Merged into `settings.json` | -| [`always-thinking-enabled`](#always-thinking-enabled) | `bool` | No | `None` | Enable always-on thinking mode | -| [`effort-level`](#effort-level) | `str` | No | `None` | Adaptive reasoning effort level | -| [`company-announcements`](#company-announcements) | `list[str]` | No | `None` | Announcement strings for users | -| [`attribution`](#attribution) | `Attribution` | No | `None` | Commit and PR attribution strings | +| [`user-settings`](#user-settings) | `UserSettings` | No | `None` | Raw `settings.json` content (camelCase keys) | | [`status-line`](#status-line) | `StatusLine` | No | `None` | Status line script configuration | > `command-names` and `command-defaults` have a co-dependency: if one is specified, the other must also be specified. @@ -171,15 +164,15 @@ Quick-reference table of all configuration keys. Each key links to its detailed ### Configuration key naming -All configuration keys use **kebab-case** (hyphenated lowercase), for example `mcp-servers`, `effort-level`, `files-to-download`. Using underscores (`effort_level`, `mcp_servers`) will cause the key to be flagged as unknown during installation. +All configuration keys use **kebab-case** (hyphenated lowercase), for example `mcp-servers`, `os-env-variables`, `files-to-download`. Using underscores (`os_env_variables`, `mcp_servers`) will cause the key to be flagged as unknown during installation. **Sub-key naming conventions:** -- **Top-level keys** (`hooks`, `permissions`, `mcp-servers`, etc.): MUST be kebab-case (validated by `KNOWN_CONFIG_KEYS`) -- **Sub-keys in structured sections** (`hooks.events[]`, `permissions`): MUST be kebab-case (the toolbox translates to camelCase for Claude Code JSON output) +- **Top-level keys** (`hooks`, `mcp-servers`, `status-line`, etc.): MUST be kebab-case (validated by `KNOWN_CONFIG_KEYS`) +- **Sub-keys in structured sections** (`hooks.events[]`): MUST be kebab-case (the toolbox translates to camelCase for Claude Code JSON output) - **Sub-keys in free-form sections** (`user-settings`, `global-config`): MUST match Claude Code's native camelCase (pass-through, no translation) -> **Note:** The Pydantic validation model (`EnvironmentConfig`) uses `populate_by_name=True` for testing convenience, which means CI validation accepts both `effort_level` and `effort-level`. However, the runtime setup script (`setup_environment.py`) uses `config.get('effort-level')` and will not recognize underscore variants. Always use kebab-case in your configuration files. +> **Note:** The Pydantic validation model (`EnvironmentConfig`) uses `populate_by_name=True` for testing convenience, which means CI validation accepts both `os_env_variables` and `os-env-variables`. However, the runtime setup script (`setup_environment.py`) uses `config.get('os-env-variables')` and will not recognize underscore variants. Always use kebab-case in your configuration files. ## Configuration Keys @@ -316,7 +309,7 @@ List of top-level keys that should be merged (extended from parent) rather than - **Type:** `list[str] | None` - **Default:** `None` -- **Valid values:** `dependencies`, `agents`, `slash-commands`, `rules`, `skills`, `files-to-download`, `hooks`, `mcp-servers`, `global-config`, `user-settings`, `env-variables`, `os-env-variables` +- **Valid values:** `dependencies`, `agents`, `slash-commands`, `rules`, `skills`, `files-to-download`, `hooks`, `mcp-servers`, `global-config`, `user-settings`, `os-env-variables` - **Validation:** Non-eligible keys produce an error. Non-empty `merge-keys` without `inherit` produces a validation error because `merge-keys` controls merge semantics during inheritance resolution and has no effect without a parent configuration to merge from. An empty `merge-keys` list without `inherit` is permitted (treated as a no-op). - **Stripped from output:** Yes (like `inherit`) - **Inheritance:** Not applicable. Evaluated at each inheritance level independently; not inherited or accumulated across levels. @@ -608,119 +601,12 @@ Sets an HTTP header for both `http` and `sse` transports. - **Format:** `"Header-Name: value"` - **Example:** `header: "Authorization: Bearer token123"` -#### Automatic Permission Pre-Allowing +#### MCP Server Permissions -MCP server names are automatically added to the `permissions.allow` list as `mcp__servername`. You do not need to manually add MCP server permissions. - -### Model and Reasoning - -#### `model` - -Model alias or custom model name for Claude Code. - -- **Type:** `str | None` -- **Default:** `None` -- **Validation:** Any non-empty string. Supports Anthropic model names (for example, `claude-sonnet-4-20250514`), built-in aliases (`default`, `sonnet`, `opus`, `haiku`, `opus[1m]`, `sonnet[1m]`, `opusplan`), and third-party or provider-prefixed model identifiers (for example, `gpt-4o`, `openrouter/anthropic/claude-3.5-sonnet`). Empty or whitespace-only strings produce a validation error. -- **Inheritance:** Standard override (child replaces parent) -- **Example:** `model: "opus"` or `model: "claude-sonnet-4-20250514"` or `model: "openrouter/anthropic/claude-3.5-sonnet"` - -#### `always-thinking-enabled` - -Enable always-on extended thinking mode. - -- **Type:** `bool | None` -- **Default:** `None` -- **Inheritance:** Standard override (child replaces parent) -- **Example:** `always-thinking-enabled: true` - -#### `effort-level` - -Controls adaptive reasoning effort. - -- **Type:** `str | None` (one of `low`, `medium`, `high`, `xhigh`, `max`) -- **Default:** `None` -- **Inheritance:** Standard override (child replaces parent) -- **Values:** - - `low` -- Minimal reasoning, fastest responses - - `medium` -- Balanced reasoning and speed - - `high` -- Thorough reasoning for complex tasks - - `xhigh` -- Extended reasoning for long-horizon coding and agentic work. **Requires the model to be an Opus or Fable variant** (the model name must contain `opus` or `fable`, case-insensitive) **or the exact alias `best`**. Supported on Opus 4.7/4.8 and Fable 5; on models that do not support it, Claude Code runs it as `high` - - `max` -- Maximum reasoning effort. **Requires the model to be an Opus or Fable variant** (the model name must contain `opus` or `fable`, case-insensitive) **or the exact alias `best`**. Supported on Opus 4.6 and later and Fable 5 -- **Example:** - -```yaml -# xhigh requires an Opus or Fable model -model: "claude-fable-5" -effort-level: "xhigh" -``` - -```yaml -# max requires an Opus or Fable model -model: "opus" -effort-level: "max" -``` - -```yaml -# low, medium, and high work with any model -effort-level: "high" -``` - -The model gate matches family substrings because the free-form `model` key cannot resolve which version an alias points to; Claude Code gracefully downgrades an unsupported level to the highest supported level at runtime. The alias `best` is accepted by **exact match only** (it always resolves to Fable 5 or the latest Opus model), so arbitrary model names that merely contain `best` are rejected. The alias `default` is rejected for `xhigh`/`max` because it resolves to a Sonnet model on some account types. - -> **Note:** Per the official Claude Code documentation, persisted settings files accept only `low`, `medium`, `high`, and `xhigh` for the `effortLevel` setting; `max` (like `ultracode`) is session-only and persists across sessions only via the `CLAUDE_CODE_EFFORT_LEVEL` environment variable. The toolbox still accepts `max` because, for isolated profiles, it delivers `config.json` through the `--settings` flag on every launch, where the value applies per-session. `ultracode` is intentionally **not** an `effort-level` value here. - -### Permissions - -Permission rules controlling which tools and actions are allowed, denied, or require confirmation. - -- **Type:** `Permissions | None` -- **Default:** `None` -- **Inheritance:** Standard override (child replaces parent). Note: MCP server permissions are automatically added during setup regardless of inheritance. -- **Fields:** - - `default-mode` -- One of `default`, `acceptEdits`, `plan`, `bypassPermissions` - - `allow` -- List of explicitly allowed actions - - `deny` -- List of explicitly denied actions - - `ask` -- List of actions requiring confirmation - - `additional-directories` -- List of additional directory paths -- **Note:** MCP server permissions (for example, `mcp__servername`) are automatically merged into the `allow` list -- **Example:** - -```yaml -permissions: - default-mode: "default" - allow: - - "Read" - - "Glob" - - "Grep" - deny: - - "Bash(rm -rf)" - ask: - - "Edit" - - "Write" - additional-directories: - - "/opt/project-data" -``` +MCP servers are registered with Claude Code via `claude mcp add` (with scope-based routing). To pre-allow specific MCP tools without a per-use confirmation prompt, add `mcp__servername` (or `mcp__servername__toolname`) entries to `permissions.allow` under [`user-settings`](#user-settings). ### Environment Variables -#### `env-variables` - -Claude-level environment variables set in the settings file. These are available within Claude Code sessions only. - -- **Type:** `dict[str, str | None] | None` -- **Default:** `None` -- **Special value:** Set a value to `null` to delete an existing variable. In non-isolated mode the variable is deleted from the `env` block of `~/.claude/settings.json`; in isolated mode it is omitted from the atomically rebuilt `~/.claude/{cmd}/config.json` (the literal JSON null is never written for an env entry). -- **Validation:** Variable names must match `^[A-Za-z_][A-Za-z0-9_]*$` (must start with a letter or underscore, followed by letters, digits, or underscores). Non-null values cannot contain null bytes. -- **Inheritance:** Standard override (child replaces parent) by default. When listed in `merge-keys`: shallow dictionary merge. Child keys override matching parent keys. Set a value to `null` to delete a parent key (RFC 7396 semantics). -- **Example:** - -```yaml -env-variables: - API_KEY: "sk-..." - DATABASE_URL: "postgres://localhost/mydb" - OLD_UNUSED_VAR: null # Deletes this variable -``` - #### `os-env-variables` OS-level persistent environment variables written to the shell profile (Linux/macOS) or Windows registry. @@ -738,23 +624,25 @@ os-env-variables: OLD_UNUSED_VAR: null # Deletes this variable ``` -- **Automatic string conversion:** Non-string YAML values (integers, booleans, floats) in both `env-variables` and `os-env-variables` are automatically converted to strings by the setup script. For example, `MCP_TIMEOUT: 30000` (YAML integer) becomes `"30000"` (string), and `ENABLE_FEATURE: true` (YAML boolean) becomes `"True"` (string). To preserve exact string representation, quote values in YAML: `ENABLE_FEATURE: "true"`. A `null` value is never stringified -- it is a deletion request (see the `null` special value above). +- **Automatic string conversion:** Non-string YAML values (integers, booleans, floats) in `os-env-variables` are automatically converted to strings by the setup script. For example, `MY_TIMEOUT: 30000` (YAML integer) becomes `"30000"` (string), and `ENABLE_FEATURE: true` (YAML boolean) becomes `"True"` (string). To preserve exact string representation, quote values in YAML: `ENABLE_FEATURE: "true"`. A `null` value is never stringified -- it is a deletion request (see the `null` special value above). - **Current session guidance (Linux/macOS):** When variables are deleted via `null`, the setup script outputs shell-specific `unset` commands so the user can remove those variables from the running session without opening a new terminal: - **Bash/Zsh:** `unset VARNAME` for each deleted variable - **Fish** (when installed): `set -e VARNAME` for each deleted variable #### Environment Variable Loading -The setup script provides two distinct mechanisms for environment variables, each serving a different scope: +The setup script supports two distinct kinds of environment variables, each serving a different scope: + +| Source | Scope | Storage | Available In | +|---------------------|----------------------|-------------------------------------------------------|-------------------------------------| +| `user-settings.env` | Claude Code internal | `settings.json` `env` key (or profile `config.json`) | Claude Code sessions only | +| `os-env-variables` | OS-level persistent | Shell profiles + Windows registry | All processes (terminals, programs) | -| YAML Key | Scope | Storage | Available In | -|--------------------|----------------------|-----------------------------------|--------------------------------------| -| `env-variables` | Claude Code internal | `config.json` `env` key (profile) | Claude Code sessions only | -| `os-env-variables` | OS-level persistent | Shell profiles + Windows registry | All processes (terminals, programs) | +Claude-session variables are declared under [`user-settings.env`](#user-settings) (raw `settings.json` content). See the [`user-settings`](#user-settings) section for the `env` value rules (string-only values, `null` as delete). ##### Env Loader Files -When `os-env-variables` are configured, the setup generates Rustup-style env loader files that can be sourced to load the variables into the current shell session. These files contain **only** `os-env-variables` (not `env-variables`, which are handled by Claude Code's `config.json`). +When `os-env-variables` are configured, the setup generates Rustup-style env loader files that can be sourced to load the variables into the current shell session. These files contain **only** `os-env-variables` (not `user-settings.env`, which Claude Code reads from `settings.json`/`config.json`). **Per-command files** (generated when `command-names` is specified): @@ -813,76 +701,114 @@ command-defaults: #### `user-settings` -Free-form settings merged into `~/.claude/settings.json`. Uses deep merge with universal array union: every list at every depth is unioned with structural dedupe, matching [Claude Code CLI's cross-scope merge semantics](https://code.claude.com/docs/en/settings) ("arrays are concatenated and deduplicated, not replaced"). +Raw `settings.json` content, using Claude Code's native camelCase key names exactly as they appear on disk. This is the single surface for every Claude Code `settings.json` setting -- `model`, `permissions`, `env`, `effortLevel`, and everything else. In non-isolated mode (`command-names` absent) it is deep-merged into `~/.claude/settings.json`; in isolated mode (`command-names` present) it is built into the isolated profile's `config.json` and delivered via `--settings`. See [Profile-Level Settings Routing](#profile-level-settings-routing) for the end-to-end write contract. + +The on-disk write uses deep merge with universal array union: every list at every depth is unioned with structural dedupe, matching [Claude Code CLI's cross-scope merge semantics](https://code.claude.com/docs/en/settings) ("arrays are concatenated and deduplicated, not replaced"). - **Type:** `UserSettings | None` - **Default:** `None` -- **Excluded keys:** `hooks` and `statusLine` (these require dedicated write logic with path resolution and type processing, and must be configured at the root level of the YAML configuration) +- **Excluded keys:** `hooks` and `statusLine` (these require dedicated write logic with file download, path resolution, and type processing, and must be configured at the YAML root level via the [`hooks`](#hooks) and [`status-line`](#status-line) keys) - **Inheritance:** Standard override (child replaces parent) by default. When listed in `merge-keys`: deep recursive merge using `deep_merge_settings()` with `DEFAULT_ARRAY_UNION_KEYS` (`permissions.allow`, `permissions.deny`, `permissions.ask` arrays are unioned with deduplication; other arrays use child-replaces-parent semantics in the YAML inheritance layer). Child keys override matching parent keys; `null` values delete keys. **Note:** YAML inheritance semantics are intentionally separate from on-disk write semantics. The on-disk writer (`write_user_settings()` -> `_write_merged_json()`) uses universal array union at every depth for all keys; `DEFAULT_ARRAY_UNION_KEYS` applies only inside the YAML composition layer. - **Example:** ```yaml user-settings: + model: "opus" + effortLevel: "high" + alwaysThinkingEnabled: true language: "english" theme: "dark" apiKeyHelper: "uv run --no-project --python 3.12 ~/.claude/scripts/api-key-helper.py" + permissions: + defaultMode: "default" + allow: + - "Read" + - "Glob" + - "Grep" + deny: + - "Bash(rm -rf)" + additionalDirectories: + - "/opt/project-data" env: + PROJECT_TYPE: "python" DISABLE_AUTOUPDATER: "1" + OLD_UNUSED_VAR: null # Deletes this variable ``` -##### Relationship to Profile-Owned Keys - -`user-settings` is the **forward-compatibility escape hatch** for the ~85% of Claude Code CLI `settings.json` schema that the toolbox does not model at the YAML root level. It COMPLEMENTS (does not replace or contradict) the profile-settings writer used in non-command-names mode. See also [Profile-Level Settings Routing](#profile-level-settings-routing) for the end-to-end picture. +##### Built-in Key Reference (camelCase) -**Decision matrix -- where should I put a given setting?** +`user-settings` accepts any `settings.json` key. The keys below are the common ones and use Claude Code's native camelCase spelling. Because a misplaced or misspelled built-in key is silently ignored by Claude Code at runtime, the toolbox validates these keys fail-fast (setup exits with an error). Unknown keys pass through untouched for forward compatibility. -| Setting category | YAML root level | `user-settings:` | `global-config:` | -|--------------------------------------|--------------------------------------|--------------------------|--------------------------| -| 9 profile-owned keys (below) | **YES** (profile-owned, auto-routed) | No | No | -| Any other `settings.json` CLI key | No | **YES** (free-form) | No | -| Any `~/.claude.json` CLI key | No | No | **YES** (free-form) | +- **`model`** -- Model alias or custom model name. Any non-empty string: Anthropic model names (`claude-sonnet-4-20250514`), built-in aliases (`default`, `sonnet`, `opus`, `haiku`, `opus[1m]`, `sonnet[1m]`, `opusplan`, `best`), or third-party / provider-prefixed identifiers (`gpt-4o`, `openrouter/anthropic/claude-3.5-sonnet`). Empty or whitespace-only strings are rejected. +- **`permissions`** -- Permission rules controlling which tools and actions are allowed, denied, or require confirmation. Sub-keys use camelCase: `defaultMode`, `allow`, `deny`, `ask`, `additionalDirectories`. `defaultMode` must be one of `acceptEdits`, `auto`, `bypassPermissions`, `default`, `delegate`, `dontAsk`, `plan`; `allow`, `deny`, `ask`, and `additionalDirectories` must be lists of strings. The kebab-case spellings `default-mode` and `additional-directories` are rejected with the camelCase correction. To pre-allow an MCP server's tools, add `mcp__servername` entries to `permissions.allow` (see [MCP Server Permissions](#mcp-server-permissions)). +- **`env`** -- Claude-level environment variables available within Claude Code sessions. A mapping of variable names (matching `^[A-Za-z_][A-Za-z0-9_]*$`) to **string** values. A non-string value is rejected (`user-settings.env.NAME must be a string (quote the value in YAML) or null to delete the variable.`) -- quote the value in YAML to keep it a string. A `null` value deletes the variable (see [Key Deletion](#key-deletion-null-as-delete)). +- **`attribution`** -- Commit and pull-request attribution. A mapping with `commit` and `pr` string sub-keys; set a sub-key to an empty string to hide that attribution. +- **`alwaysThinkingEnabled`** -- Boolean enabling always-on extended thinking mode. +- **`companyAnnouncements`** -- List of announcement strings displayed to users. +- **`effortLevel`** -- Adaptive reasoning effort, one of `high`, `low`, `max`, `medium`, `xhigh`. The `xhigh` level requires `model` to be an Opus or Fable variant (the model name must contain `opus` or `fable`, case-insensitive) or the exact alias `best`; `max` additionally accepts a Sonnet variant. When `model` is absent or outside the required families, the effort level is rejected. See [effortLevel model requirements](#effortlevel-model-requirements) below. -**9 profile-owned keys (placed at YAML root level):** +###### effortLevel model requirements -- `model`, `permissions`, `env-variables`, `attribution`, `always-thinking-enabled`, `effort-level`, `company-announcements`, `status-line`, `hooks` +The model gate matches family substrings because the free-form `model` value cannot resolve which version an alias points to; Claude Code gracefully downgrades an unsupported level to the highest supported level at runtime, but declaring an unsupported combination in the profile is almost always a mistake, so it is rejected. The alias `best` is accepted by **exact match only** (it always resolves to Fable 5 or the latest Opus model), so arbitrary model names that merely contain `best` are rejected. -**Examples of "any other `settings.json` CLI key" (placed under `user-settings:`):** +```yaml +# xhigh requires an Opus or Fable model +user-settings: + model: "claude-fable-5" # or "opus", "fable", "best" + effortLevel: "xhigh" +``` -- `language`, `theme`, `includeGitInstructions`, `apiKeyHelper`, `awsCredentialExport`, `cleanupPeriodDays`, `outputStyle`, `autoMemoryDirectory`, `defaultShell`, `respectGitignore`, `sandbox.*`, plus any new Claude Code CLI setting not yet modeled at YAML root level. `user-settings` uses `extra='allow'` Pydantic semantics, so any key that is not explicitly excluded passes through unchanged. +```yaml +# max requires an Opus, Sonnet, or Fable model +user-settings: + model: "opus" # or "sonnet", "fable", "best" + effortLevel: "max" +``` -**Examples of "any `~/.claude.json` CLI key" (placed under `global-config:`):** +```yaml +# low, medium, and high work with any model +user-settings: + effortLevel: "high" +``` -- `autoConnectIde`, `editorMode`, `showTurnDuration`, `terminalProgressBarEnabled`, and any other global CLI settings. +> **`max` persistence caveat:** Per the official Claude Code documentation, persisted `settings.json` files accept only `low`, `medium`, `high`, and `xhigh` for `effortLevel`; `max` (like `ultracode`) is session-only and, in a plain `settings.json`, persists across sessions only via the `CLAUDE_CODE_EFFORT_LEVEL` environment variable. The toolbox still accepts `max` because, for isolated profiles, it delivers `config.json` through the `--settings` flag on every launch, where the value applies per-session. The setup emits a warning when `user-settings.effortLevel` is `max`. `ultracode` is intentionally **not** an accepted `effortLevel` value. -**Precedence rule for the 9 profile-owned keys:** +##### Fail-Fast Validation Rules -If you declare a profile-owned key (for example, `permissions`) at BOTH YAML root level AND under `user-settings:`, Step 18 `write_profile_settings_to_settings()` runs AFTER Step 14 `write_user_settings()` and deep-merges its delta on top of the Step 14 result. For scalar keys, the root-level value overwrites the `user-settings` value. For dict keys (such as `permissions`), the two contributions are deep-merged: sub-keys declared only on one side are preserved, sub-keys declared on both sides are resolved by the root-level value winning, and every list-valued sub-key at any depth (including `permissions.allow`, `permissions.deny`, `permissions.ask`, `permissions.additionalDirectories`) is unioned with structural dedupe across both sources under the universal array-union contract. A warning from `detect_settings_conflicts()` is emitted during validation to make the contract explicit. The warning fires in BOTH command-names-present and command-names-absent modes. +The toolbox validates `user-settings` against Claude Code's `settings.json` schema and rejects (exit 1) misconfigurations that Claude Code would otherwise ignore silently. A `null` value for any key is always allowed (a deletion request). Unknown keys not covered below pass through untouched. The rules are: -**Why the two surfaces coexist:** +- **`hooks` and `statusLine` are forbidden.** They must be configured at the YAML root level via [`hooks`](#hooks) and [`status-line`](#status-line). Blocked by `check_excluded_keys` in the `UserSettings` Pydantic model (`USER_SETTINGS_EXCLUDED_KEYS = {'hooks', 'statusLine'}`). +- **Root-level YAML keys are forbidden.** `status-line` and `os-env-variables` are YAML root keys, not `settings.json` keys, and are rejected with the message `Key '{key}' is not allowed in user-settings. It is a root-level YAML key, not a settings.json key.` +- **Kebab-case spellings of built-in keys are rejected** with the camelCase correction: `always-thinking-enabled` -> `alwaysThinkingEnabled`, `company-announcements` -> `companyAnnouncements`, `effort-level` -> `effortLevel`, `env-variables` -> `env`; and inside `permissions`, `default-mode` -> `defaultMode` and `additional-directories` -> `additionalDirectories`. +- **Global-only keys are rejected.** Keys that live in `~/.claude.json` (`autoUpdates`, `installMethod`, `autoConnectIde`, `autoInstallIdeExtension`, `externalEditorContext`, `teammateDefaultModel`, `oauthAccount`) belong in [`global-config`](#global-config), not `user-settings`, and are rejected with the message `Key '{key}' belongs in global-config (~/.claude.json), not in user-settings (settings.json).` -- **Profile-owned keys** (9 keys) have first-class atomic semantics because the toolbox fully owns them: kebab-to-camel translation (`default-mode` -> `defaultMode`), file path resolution for hook events, status-line command-string generation with absolute POSIX paths, and auto-update Target 2/3 injection management. -- **`user-settings`** passes through any other key without interpretation. This allows users to configure any Claude Code CLI setting the toolbox does not model at the YAML root level -- including settings added in future Claude Code releases -- without waiting for a toolbox update. This is the meaning of "forward-compatibility escape hatch". +##### `CLAUDE_CONFIG_DIR` override (isolated mode) -**Keys explicitly FORBIDDEN under `user-settings:` (validation error):** +To override the auto-computed isolation directory, set `CLAUDE_CONFIG_DIR` under `user-settings.env` (only meaningful when `command-names` is present). The setup reads and then removes it before writing config.json -- the launcher's `export CLAUDE_CONFIG_DIR` remains the sole authoritative runtime source, so the value is not left in the profile's `env` block. -- `hooks` -- must be at YAML root level (requires file download, path resolution, type processing via `_build_hooks_json()`). -- `statusLine` -- must be at YAML root level via `status-line:` (requires file download and path resolution into `hooks.files`). +```yaml +command-names: + - "my-env" -These two keys are blocked by `check_excluded_keys` in the `UserSettings` Pydantic model (`USER_SETTINGS_EXCLUDED_KEYS = {'hooks', 'statusLine'}`). No other keys are blocked -- this exclusion set is NOT extended to cover the 9 profile-owned keys. +user-settings: + env: + CLAUDE_CONFIG_DIR: "~/.claude/my-custom-dir" +``` -**Preservation contract for `user-settings`:** +##### Preservation contract for `user-settings` -Keys that you put under `user-settings:` are preserved even when you re-run the setup with a different YAML that omits them, because Step 14 `write_user_settings()` uses deep-merge semantics and never deletes keys unless you set them to `null`. Additionally, Step 18 `write_profile_settings_to_settings()` only touches keys that appear in the profile delta; all other keys (including your `user-settings` contributions and any user-managed keys outside the YAML) remain intact. List-valued keys (at any depth) accumulate additively across runs under the universal array-union contract, so elements you wrote in earlier runs are never silently deleted. This is the deliberate shared-file semantics: the toolbox does NOT surprise-delete anything from the shared `~/.claude/settings.json`. See [Profile-Level Settings Routing](#profile-level-settings-routing) below for the full write semantics contract and the deferred stale-key behavior. +Keys that you put under `user-settings:` are preserved even when you re-run the setup with a different YAML that omits them, because in non-isolated mode `write_user_settings()` uses deep-merge semantics and never deletes keys unless you set them to `null`. List-valued keys (at any depth) accumulate additively across runs under the universal array-union contract, so elements you wrote in earlier runs are never silently deleted. This is the deliberate shared-file semantics: the toolbox does NOT surprise-delete anything from the shared `~/.claude/settings.json`. In isolated mode the profile's `config.json` is rebuilt atomically each run, so removing a key from YAML cleanly removes it from `config.json` on the next run. See [Profile-Level Settings Routing](#profile-level-settings-routing) below for the full write semantics contract and the deferred stale-key behavior. #### `global-config` -Settings merged into `~/.claude.json` (the Claude Code global configuration file). When `command-names` is present, additionally written to `~/.claude/{cmd}/.claude.json` for isolated environments (Claude Code CLI resolves `getGlobalClaudeFile()` via `CLAUDE_CONFIG_DIR` with no fallback to the home directory). Uses deep merge with universal array union: every list at every depth is unioned with structural dedupe, matching [Claude Code CLI's cross-scope merge semantics](https://code.claude.com/docs/en/settings) and preserving CLI-managed state at runtime (OAuth tokens, per-project trust decisions, user-scoped MCP server approvals via `/mcp approve`, `enabledPlugins`, `enabledMcpjsonServers`/`disabledMcpjsonServers`). +Raw `~/.claude.json` content, merged into the Claude Code global configuration file. When `command-names` is present, additionally written to `~/.claude/{cmd}/.claude.json` for isolated environments (Claude Code CLI resolves `getGlobalClaudeFile()` via `CLAUDE_CONFIG_DIR` with no fallback to the home directory). Uses deep merge with universal array union: every list at every depth is unioned with structural dedupe, matching [Claude Code CLI's cross-scope merge semantics](https://code.claude.com/docs/en/settings) and preserving CLI-managed state at runtime (OAuth tokens, per-project trust decisions, user-scoped MCP server approvals via `/mcp approve`, `enabledPlugins`, `enabledMcpjsonServers`/`disabledMcpjsonServers`). When `command-names` is present, the setup also propagates the machine's recorded `installMethod` from the base `~/.claude.json` into the `global-config` write (auto-injected, even when the YAML has no `global-config` section; a user-declared `installMethod` in YAML wins with a warning when it differs; nothing is propagated when the base file or key is absent). The dual-write then carries the correct installation type into the isolated `.claude.json`, so isolated sessions report it correctly. - **Type:** `GlobalConfig | None` - **Default:** `None` - **Excluded keys:** `oauthAccount` cannot be set to non-null values (OAuth credentials must not appear in YAML configuration files). Set `oauthAccount: null` to clear authentication state. +- **Settings-only keys rejected:** Keys that live in `settings.json` (`model`, `permissions`, `env`, `attribution`, `alwaysThinkingEnabled`, `effortLevel`, `companyAnnouncements`, `statusLine`, `hooks`, `availableModels`, `enforceAvailableModels`) are rejected in `global-config` because `~/.claude.json` is not a settings file and Claude Code would silently ignore them at runtime. `model` and the other `settings.json` keys are rejected with the message `Key '{key}' is a settings.json key and is not valid in global-config (~/.claude.json). Move it to user-settings.`; `statusLine` and `hooks` are instead directed to the root-level `status-line` and `hooks` YAML keys. A `null` value is always allowed (a deletion request). - **Inheritance:** Standard override (child replaces parent) by default. When listed in `merge-keys`: deep recursive merge using `deep_merge_settings()` with `array_union_keys=set()` (arrays are replaced in the YAML inheritance layer for child-overrides-parent composition). Child keys override matching parent keys; `null` values delete keys (RFC 7396). **Note:** YAML inheritance semantics are intentionally separate from on-disk write semantics. The on-disk writer (`write_global_config()` -> `_write_merged_json()`) uses universal array union at every depth; the `set()` form applies only inside the YAML composition layer. - **Example:** @@ -919,47 +845,14 @@ global-config: > **Warning:** Bare YAML keys with no value (`key:`) are equivalent to `key: null`. This means accidentally omitting a value will DELETE that key rather than set it to an empty string. Always use explicit values: `key: ""` for empty strings, `key: null` for intentional deletion. -**Profile-owned keys in non-command-names mode:** The nine profile-owned keys (`model`, `permissions`, `env-variables`, `attribution`, `always-thinking-enabled`, `effort-level`, `company-announcements`, `status-line`, `hooks`) all support null-as-delete at the YAML root level via the deep-merge writer -- see [Profile-Level Settings Routing](#profile-level-settings-routing). Both top-level and nested nulls are covered end-to-end: +**Profile-owned keys (`status-line`, `hooks`) in non-command-names mode:** The two profile-owned keys support null-as-delete at the YAML root level via the deep-merge writer -- see [Profile-Level Settings Routing](#profile-level-settings-routing). Both top-level and nested nulls are covered end-to-end: -- **Top-level null** (for example, `model: null`, `permissions: null`, `hooks: null`, `env-variables: null`): deletes the entire on-disk key from `~/.claude/settings.json`. -- **Nested null** (for example, `permissions: {deny: null}`, `hooks: {PreToolUse: null}`): deletes only the nested sub-key while preserving the rest of the block (`permissions.allow`/`permissions.ask`, other hook event names). +- **Top-level null** (for example, `status-line: null`, `hooks: null`): deletes the entire on-disk key from `~/.claude/settings.json`. +- **Nested null** (for example, `hooks: {PreToolUse: null}`): deletes only the nested sub-key while preserving the rest of the block (other hook event names). The dict-membership construction in the data flow from YAML root to the writer preserves the distinction between "declared with explicit null" and "absent from YAML" -- only the former triggers deletion. OMITTING a profile-owned key from a subsequent YAML run does NOT delete it; see [Deferred Stale-Key Behavior](#deferred-stale-key-behavior-user-facing-contract) for the intentional preservation contract. -**Per-variable nulls in `env-variables` and `os-env-variables`:** a `null` value deletes the variable instead of setting a literal string -- in BOTH modes for `env-variables` and at the OS level for `os-env-variables`. In non-isolated mode, `env-variables: {VAR: null}` deletes `env.VAR` from `~/.claude/settings.json` via the deep-merge writer (this also cleans up any stale literal value a prior run may have written). In isolated mode, `create_profile_config()` strips null-valued env entries before the atomic `config.json` write -- absence equals deletion under atomic rebuild, and the `env` key is dropped entirely when every entry is null -- so a JSON `null` is never written for an env entry. `os-env-variables: {VAR: null}` deletes the OS-level variable from shell profiles or the Windows registry and excludes it from the env loader files. - -#### `company-announcements` - -Announcement strings displayed to users during setup. - -- **Type:** `list[str] | None` -- **Default:** `None` -- **Inheritance:** Standard override (child replaces parent) -- **Example:** - -```yaml -company-announcements: - - "Welcome to the team development environment!" - - "Run /help for available commands" -``` - -#### `attribution` - -Attribution strings for commits and pull requests. Set a field to an empty string to hide attribution. - -- **Type:** `Attribution | None` -- **Default:** `None` -- **Inheritance:** Standard override (child replaces parent) -- **Fields:** - - `commit` (str) -- Attribution string for commits - - `pr` (str) -- Attribution string for pull requests -- **Example:** - -```yaml -attribution: - commit: "Co-authored-by: AI Assistant" - pr: "" # Hide PR attribution -``` +**Per-variable nulls in `user-settings.env` and `os-env-variables`:** a `null` value deletes the variable instead of setting a literal string. In non-isolated mode, `user-settings: {env: {VAR: null}}` deletes `env.VAR` from `~/.claude/settings.json` via the deep-merge writer (this also cleans up any stale literal value a prior run may have written). In isolated mode, `create_profile_config()` strips null-valued members recursively before the atomic `config.json` write -- absence equals deletion under atomic rebuild -- so a JSON `null` is never written for an `env` entry. `os-env-variables: {VAR: null}` deletes the OS-level variable from shell profiles or the Windows registry and excludes it from the env loader files. #### `status-line` @@ -1227,7 +1120,7 @@ Hooks are routed to different target files based on whether `command-names` is s | `command-names` present | `~/.claude/{cmd}/config.json` | `create_profile_config()` (atomic overwrite) | `~/.claude/{cmd}/hooks/` | | `command-names` absent | `~/.claude/settings.json` | `write_profile_settings_to_settings()` (deep-merge) | `~/.claude/hooks/` | -When `command-names` is absent, the setup writes hooks to the global `~/.claude/settings.json` via `write_profile_settings_to_settings()` as part of the 9-key `PROFILE_OWNED_KEYS` delta. The writer delegates to `_write_merged_json()`, which deep-merges the `hooks` dict into the existing file: disjoint event names (in the delta but not on disk, and vice versa) compose additively, and the per-event matcher-group lists are unioned with structural dedupe across runs (every list at every depth is unioned under the universal array-union contract). All other keys -- user-managed keys, other profile-owned keys not in the current delta, and Step 14 `user-settings` contributions -- are preserved. See [Profile-Level Settings Routing](#profile-level-settings-routing) for the full contract. +When `command-names` is absent, the setup writes hooks to the global `~/.claude/settings.json` via `write_profile_settings_to_settings()` as part of the 2-key `PROFILE_OWNED_KEYS` delta. The writer delegates to `_write_merged_json()`, which deep-merges the `hooks` dict into the existing file: disjoint event names (in the delta but not on disk, and vice versa) compose additively, and the per-event matcher-group lists are unioned with structural dedupe across runs (every list at every depth is unioned under the universal array-union contract). All other keys -- user-managed keys, other profile-owned keys not in the current delta, and Step 14 `user-settings` contributions -- are preserved. See [Profile-Level Settings Routing](#profile-level-settings-routing) for the full contract. **Re-run behavior:** When the YAML re-declares `hooks`, the deep-merge writer recurses into the existing `hooks` dict. Disjoint event names compose additively across runs. For the same event name, matcher groups accumulate: two matcher groups with the same `matcher` string but different inner handlers from different runs coexist as separate entries (naive structural dedupe -- they are not structurally equal, so neither is discarded). Structurally identical matcher groups collapse to one, making repeat runs with the same YAML idempotent. This matches [Claude Code's native cross-scope merge semantics](https://code.claude.com/docs/en/settings); at runtime, Claude Code deduplicates command hooks by command string and HTTP hooks by URL (per the [Claude Code hooks documentation](https://code.claude.com/docs/en/hooks): "Command hooks are deduplicated by command string, and HTTP hooks are deduplicated by URL"), so on-disk consolidation is unnecessary. To fully clear stale events for a specific event name, set `hooks: {EventName: null}` to delete just that event list; declaring a new list under the same event name unions with existing entries rather than replacing them. @@ -1301,7 +1194,8 @@ The `inherit` value uses the same routing as config sources: ```yaml # base.yaml name: "Base Environment" -model: "sonnet" +user-settings: + model: "sonnet" agents: - "agents/core-agent.md" ``` @@ -1313,8 +1207,11 @@ name: "Extended Environment" # Overrides parent's name agents: # Completely REPLACES parent's agents list - "agents/core-agent.md" - "agents/extra-agent.md" -effort-level: "high" # Added (not in parent) -# model: "sonnet" is inherited from parent (not overridden) +user-settings: # Completely REPLACES parent's user-settings (not in merge-keys) + effortLevel: "high" +# The parent's user-settings.model is NOT inherited here, because user-settings +# uses replace semantics by default. Add 'merge-keys: [user-settings]' to deep-merge +# the parent's model with the child's effortLevel instead. ``` ### List Inherit (Composition Chains) @@ -1376,7 +1273,7 @@ Structured entries have the following fields: | `config` | `str` | Yes | Configuration source (URL, path, or repo name) | | `merge-keys` | `list[str]` | No | Keys to merge instead of replace at this composition step | -The `merge-keys` in a structured entry accepts the same values as the top-level `merge-keys` directive: `dependencies`, `agents`, `slash-commands`, `rules`, `skills`, `files-to-download`, `hooks`, `mcp-servers`, `global-config`, `user-settings`, `env-variables`, `os-env-variables`. +The `merge-keys` in a structured entry accepts the same values as the top-level `merge-keys` directive: `dependencies`, `agents`, `slash-commands`, `rules`, `skills`, `files-to-download`, `hooks`, `mcp-servers`, `global-config`, `user-settings`, `os-env-variables`. Plain strings and structured entries can be mixed in the same list: @@ -1417,8 +1314,9 @@ agents: - "agents/core-agent.md" rules: - "rules/base-rule.md" -model: "sonnet" -env-variables: +user-settings: + model: "sonnet" +os-env-variables: SHARED_VAR: "from_base" BASE_VAR: "base_val" @@ -1432,7 +1330,7 @@ agents: - "agents/extra-agent.md" rules: - "rules/extra-rule.md" -env-variables: +os-env-variables: SHARED_VAR: "from_extensions" EXT_VAR: "ext_val" @@ -1446,8 +1344,9 @@ inherit: name: "My Environment" merge-keys: - os-env-variables -model: "opus" -env-variables: +user-settings: + model: "opus" +os-env-variables: LEAF_VAR: "leaf_val" ``` @@ -1455,9 +1354,9 @@ Result: - `agents`: `["agents/core-agent.md", "agents/extra-agent.md"]` -- merged by the structured entry's `merge-keys` (Rule 3) - `rules`: `["rules/base-rule.md", "rules/extra-rule.md"]` -- merged by the structured entry's `merge-keys` (Rule 3) -- `model`: `"opus"` -- leaf overrides +- `user-settings`: `{"model": "opus"}` -- `user-settings` is not in per-entry merge-keys and not in the leaf's top-level `merge-keys`, so it uses replace semantics: extensions declares none, then the leaf replaces with its own `user-settings` - `name`: `"My Environment"` -- leaf overrides -- `env-variables`: `{"LEAF_VAR": "leaf_val"}` -- extensions replaces base's env-variables (not in per-entry merge-keys), then leaf replaces again +- `os-env-variables`: `{"SHARED_VAR": "from_extensions", "EXT_VAR": "ext_val", "LEAF_VAR": "leaf_val"}` -- extensions is not in per-entry merge-keys, so it REPLACES base's `os-env-variables` (dropping `BASE_VAR`); then the leaf's top-level `merge-keys: [os-env-variables]` shallow-merges the leaf on top, adding `LEAF_VAR` - `some-parent.yaml` referenced in extensions.yaml's own inherit is **never loaded** (Rule 1) - extensions.yaml's own `merge-keys: [agents, rules]` is **ignored** (Rule 3) @@ -1501,13 +1400,13 @@ If all levels 2-4 use merge: `[A, B, C, D, E]`. | Composite | `hooks` | `files`: concat + dedup by full path; `events`: concat (no dedup) | | Deep dict | `global-config` | `deep_merge_settings()` with `array_union_keys=set()` (YAML inheritance layer only) | | Deep dict | `user-settings` | `deep_merge_settings()` with `DEFAULT_ARRAY_UNION_KEYS` (YAML inheritance layer only) | -| Shallow dict | `env-variables`, `os-env-variables` | Shallow merge; child overrides; `null` deletes (RFC 7396) | +| Shallow dict | `os-env-variables` | Shallow merge; child overrides; `null` deletes (RFC 7396) | -> **Note:** The `global-config` and `user-settings` rows above describe the YAML inheritance layer only -- how `merge-keys` composes parent and child configurations before the writer touches disk. The on-disk writers (`write_global_config()`, `write_user_settings()`, `write_profile_settings_to_settings()`) use the universal array-union contract: every list at every depth is unioned with structural dedupe, independent of `DEFAULT_ARRAY_UNION_KEYS`. See [Profile-Level Settings Routing](#profile-level-settings-routing) for the on-disk write contract. +> **Note:** The `global-config` and `user-settings` rows above describe the YAML inheritance layer only -- how `merge-keys` composes parent and child configurations before the writer touches disk. The `user-settings` deep merge covers its nested `env` block (Claude-session environment variables). The on-disk writers (`write_global_config()`, `write_user_settings()`, `write_profile_settings_to_settings()`) use the universal array-union contract: every list at every depth is unioned with structural dedupe, independent of `DEFAULT_ARRAY_UNION_KEYS`. See [Profile-Level Settings Routing](#profile-level-settings-routing) for the on-disk write contract. #### Non-Mergeable Keys -Keys not listed in the 12 mergeable keys (such as `name`, `model`, `permissions`, `command-defaults`) always use replace semantics, regardless of `merge-keys`. +Keys not listed in the 11 mergeable keys (such as `name`, `command-defaults`, `status-line`) always use replace semantics, regardless of `merge-keys`. #### Complete Merge Example @@ -1588,18 +1487,17 @@ Authentication is resolved in this order (highest priority first): ### Automatic Auto-Update Management -When `claude-code-version` specifies a pinned version (any value other than `"latest"` or absent), the setup script automatically injects auto-update disable controls into four targets to prevent Claude Code from overwriting the pinned version. When the version is `"latest"` or absent, stale auto-injected controls from prior pinned runs are automatically cleaned up (re-enabling auto-updates) while user-declared controls are preserved. +When `claude-code-version` specifies a pinned version (any value other than `"latest"` or absent), the setup script automatically injects auto-update disable controls into three targets to prevent Claude Code from overwriting the pinned version. When the version is `"latest"` or absent, stale auto-injected controls from prior pinned runs are automatically cleaned up (re-enabling auto-updates) while user-declared controls are preserved. #### Injection Targets | Target | Key | Value | On-disk file (isolated) | On-disk file (non-isolated) | |--------------------|---------------------------|---------|---------------------------------------------------|-----------------------------------------------------| | `global-config` | `autoUpdates` | `false` | `~/.claude/{cmd}/.claude.json` + `~/.claude.json` | `~/.claude.json` | -| `user-settings` | `env.DISABLE_AUTOUPDATER` | `"1"` | `~/.claude/{cmd}/settings.json` | `~/.claude/settings.json` | -| `env-variables` | `DISABLE_AUTOUPDATER` | `"1"` | `~/.claude/{cmd}/config.json` (`env` key) | `~/.claude/settings.json` (`env` key, deep-merge) | +| `user-settings` | `env.DISABLE_AUTOUPDATER` | `"1"` | `~/.claude/{cmd}/config.json` (`env` key) | `~/.claude/settings.json` (`env` key, deep-merge) | | `os-env-variables` | `DISABLE_AUTOUPDATER` | `"1"` | Shell profiles / Windows registry | Shell profiles / Windows registry | -All four targets are injected unconditionally regardless of whether `command-names` is present. In isolated mode (`command-names` present), `env-variables` reaches `~/.claude/{cmd}/config.json` via `create_profile_config()` (Step 18). In non-isolated mode (`command-names` absent), `env-variables` is routed to `~/.claude/settings.json['env']` via `write_profile_settings_to_settings()` (Step 18), which deep-merges the delta into the existing file. Both Target 2 (`user-settings.env.DISABLE_AUTOUPDATER`, written in Step 14) and Target 3 (`env-variables.DISABLE_AUTOUPDATER`, written in Step 18) therefore reach the same on-disk `settings.json['env']` container in non-isolated mode, and deep-merge makes them additive: `_merge_recursive()` recurses into the `env` dict and preserves sub-keys not present in the delta. After injection, the setup writes the injected `env-variables` dict back into the resolved configuration, so Step 18 emits Target 3 even when the YAML declares no `env-variables` block (a YAML `env-variables: null` is likewise superseded by the injected dict when pinned). A pinned non-isolated run performs no Step 16 `settings.json` sweep (the base file is the run's own Step 14 and Step 18 write target), so both env-based controls persist in the final base file. +All three targets are injected unconditionally regardless of whether `command-names` is present. The `user-settings.env.DISABLE_AUTOUPDATER` control follows the standard `user-settings` routing: in isolated mode it is built into `~/.claude/{cmd}/config.json` (`env` key) at Step 18; in non-isolated mode it is deep-merged into `~/.claude/settings.json['env']` at Step 14. Deep-merge makes it additive with any user-declared environment variables: `_merge_recursive()` recurses into the `env` dict and preserves sub-keys not present in the delta. A pinned non-isolated run performs no Step 16 `settings.json` sweep (the base file is the run's own Step 14 write target), so the env-based control persists in the final base file. #### Removal Behavior @@ -1608,7 +1506,7 @@ When the version is `"latest"` or absent, nothing is auto-injected, so every aut - **OS-level variable:** `DISABLE_AUTOUPDATER` has no filesystem sweep, so a deletion entry is scheduled in `os-env-variables` (unless the user explicitly declares the variable there) and the OS environment writer removes any stale OS-level variable left by a prior pinned run. Deleting an absent variable is a safe no-op on all platforms. - **On-disk files:** Stale artifacts in `settings.json` and `.claude.json` files are removed by the Step 16 filesystem sweep described below. -**Write-remove symmetry:** After all write operations, `cleanup_stale_auto_update_controls()` runs as a filesystem sweep pass (Step 16). When not pinned, it removes `DISABLE_AUTOUPDATER` from ALL `settings.json` files (`~/.claude/settings.json` and all `~/.claude/*/settings.json`) -- unless the current YAML itself declares `DISABLE_AUTOUPDATER` in `user-settings.env` or `env-variables`, in which case the `settings.json` sweep is skipped (the removal counterpart of the WARN-but-Respect write semantics) -- and removes `autoUpdates: false` from ALL `.claude.json` files (`~/.claude.json` and all `~/.claude/*/.claude.json`). Removal of `autoUpdates` is value-conditional: only `false` (auto-injected) is removed, `true` (user preference) is preserved. When pinned, the sweep cleans `~/.claude/settings.json` only for isolated runs (`command-names` present), to prevent bare sessions from inheriting isolated environment restrictions; a pinned non-isolated run performs no `settings.json` sweep, because the base file is the run's own write target. +**Write-remove symmetry:** After all write operations, `cleanup_stale_auto_update_controls()` runs as a filesystem sweep pass (Step 16). When not pinned, it removes `DISABLE_AUTOUPDATER` from ALL `settings.json` files (`~/.claude/settings.json` and all `~/.claude/*/settings.json`) -- unless the current YAML itself declares `DISABLE_AUTOUPDATER` in `user-settings.env`, in which case the `settings.json` sweep is skipped (the removal counterpart of the WARN-but-Respect write semantics) -- and removes `autoUpdates: false` from ALL `.claude.json` files (`~/.claude.json` and all `~/.claude/*/.claude.json`). Removal of `autoUpdates` is value-conditional: only `false` (auto-injected) is removed, `true` (user preference) is preserved. When pinned, the sweep cleans `~/.claude/settings.json` only for isolated runs (`command-names` present), to prevent bare sessions from inheriting isolated environment restrictions; a pinned non-isolated run performs no `settings.json` sweep, because the base file is the run's own write target. #### Conflict Resolution (WARN-but-Respect) @@ -1628,30 +1526,28 @@ Auto-injected values are displayed in the installation summary (including `--dry Auto-injected settings (version pinning): [auto] global-config.autoUpdates: false [auto] user-settings.env.DISABLE_AUTOUPDATER: "1" - [auto] env-variables.DISABLE_AUTOUPDATER: "1" [auto] os-env-variables.DISABLE_AUTOUPDATER: "1" ``` #### Defense-in-Depth -The `autoUpdates` key in `~/.claude.json` is considered deprecated by Anthropic (see [issue #3479](https://github.com/anthropics/claude-code/issues/3479)) and may stop working in future Claude Code releases. It is included as a defense-in-depth mechanism alongside the `DISABLE_AUTOUPDATER` environment variable, which is the primary auto-update control. The Claude Code auto-updater may also ignore disable settings in some versions (see issues [#10764](https://github.com/anthropics/claude-code/issues/10764), [#11263](https://github.com/anthropics/claude-code/issues/11263), [#12564](https://github.com/anthropics/claude-code/issues/12564)) -- covering all four targets provides the best protection. +The `autoUpdates` key in `~/.claude.json` is considered deprecated by Anthropic (see [issue #3479](https://github.com/anthropics/claude-code/issues/3479)) and may stop working in future Claude Code releases. It is included as a defense-in-depth mechanism alongside the `DISABLE_AUTOUPDATER` environment variable, which is the primary auto-update control. The Claude Code auto-updater may also ignore disable settings in some versions (see issues [#10764](https://github.com/anthropics/claude-code/issues/10764), [#11263](https://github.com/anthropics/claude-code/issues/11263), [#12564](https://github.com/anthropics/claude-code/issues/12564)) -- covering all three targets provides the best protection. ### Automatic IDE Extension Version Management When `claude-code-version` specifies a pinned version, the setup script also automatically disables IDE extension auto-installation and installs the matching extension version into detected VS Code family IDEs. When the version is `"latest"` or absent, stale auto-injected IDE extension controls from prior pinned runs are automatically cleaned up while user-declared controls are preserved. -This feature mirrors the [Automatic Auto-Update Management](#automatic-auto-update-management) architecture exactly: same 4-target write matrix, same membership-gated WARN-but-Respect conflict resolution, same write-remove symmetry cleanup, and same unpinned removal semantics (user declarations preserved in memory, OS-level deletion scheduled, on-disk cleanup via the Step 16 sweep). +This feature mirrors the [Automatic Auto-Update Management](#automatic-auto-update-management) architecture exactly: same 3-target write matrix, same membership-gated WARN-but-Respect conflict resolution, same write-remove symmetry cleanup, and same unpinned removal semantics (user declarations preserved in memory, OS-level deletion scheduled, on-disk cleanup via the Step 16 sweep). #### Injection Targets | Target | Key | Value | On-disk file (isolated) | On-disk file (non-isolated) | |--------------------|-----------------------------------------|---------|---------------------------------------------------|-----------------------------------------------------| | `global-config` | `autoInstallIdeExtension` | `false` | `~/.claude/{cmd}/.claude.json` + `~/.claude.json` | `~/.claude.json` | -| `user-settings` | `env.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL` | `"1"` | `~/.claude/{cmd}/settings.json` | `~/.claude/settings.json` | -| `env-variables` | `CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL` | `"1"` | `~/.claude/{cmd}/config.json` (`env` key) | `~/.claude/settings.json` (`env` key, deep-merge) | +| `user-settings` | `env.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL` | `"1"` | `~/.claude/{cmd}/config.json` (`env` key) | `~/.claude/settings.json` (`env` key, deep-merge) | | `os-env-variables` | `CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL` | `"1"` | Shell profiles / Windows registry | Shell profiles / Windows registry | -All four targets are injected unconditionally regardless of whether `command-names` is present, consistent with auto-update management behavior. In non-isolated mode, `env-variables` reaches `~/.claude/settings.json['env']` via the deep-merge writer (`write_profile_settings_to_settings()`), identical routing to auto-update Target 3. Deep-merge recurses into the `env` dict so that the injected IDE control coexists with any user-declared environment variables in the same on-disk container. +All three targets are injected unconditionally regardless of whether `command-names` is present, consistent with auto-update management behavior. The `user-settings.env.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL` control follows the standard `user-settings` routing: in isolated mode it is built into `~/.claude/{cmd}/config.json` (`env` key); in non-isolated mode it is deep-merged into `~/.claude/settings.json['env']`. Deep-merge recurses into the `env` dict so that the injected IDE control coexists with any user-declared environment variables in the same on-disk container. #### Removal Behavior @@ -1660,7 +1556,7 @@ When the version is `"latest"` or absent, nothing is auto-injected, so every IDE - **OS-level variable:** `CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL` has no filesystem sweep, so a deletion entry is scheduled in `os-env-variables` (unless the user explicitly declares the variable there) and the OS environment writer removes any stale OS-level variable left by a prior pinned run. Deleting an absent variable is a safe no-op on all platforms. - **On-disk files:** Stale artifacts in `settings.json` and `.claude.json` files are removed by the Step 16 filesystem sweep described below. -**Write-remove symmetry:** After all write operations, `cleanup_stale_ide_extension_controls()` runs alongside `cleanup_stale_auto_update_controls()` as a filesystem sweep pass (Step 16) with identical guards. When not pinned, it removes `CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL` from ALL `settings.json` files -- unless the current YAML itself declares the key in `user-settings.env` or `env-variables`, in which case the `settings.json` sweep is skipped -- and removes `autoInstallIdeExtension: false` from ALL `.claude.json` files (value-conditional: user-set `true` is preserved). When pinned, the sweep cleans `~/.claude/settings.json` only for isolated runs; a pinned non-isolated run performs no `settings.json` sweep. +**Write-remove symmetry:** After all write operations, `cleanup_stale_ide_extension_controls()` runs alongside `cleanup_stale_auto_update_controls()` as a filesystem sweep pass (Step 16) with identical guards. When not pinned, it removes `CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL` from ALL `settings.json` files -- unless the current YAML itself declares the key in `user-settings.env`, in which case the `settings.json` sweep is skipped -- and removes `autoInstallIdeExtension: false` from ALL `.claude.json` files (value-conditional: user-set `true` is preserved). When pinned, the sweep cleans `~/.claude/settings.json` only for isolated runs; a pinned non-isolated run performs no `settings.json` sweep. #### Conflict Resolution (WARN-but-Respect) @@ -1674,7 +1570,6 @@ Auto-injected IDE extension values are displayed with the same green `[auto]` ma Auto-injected settings (version pinning): [auto] global-config.autoInstallIdeExtension: false [auto] user-settings.env.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1" - [auto] env-variables.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1" [auto] os-env-variables.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1" ``` @@ -1777,50 +1672,42 @@ Here is a conceptual overview of what the setup script does when you run it with 11. **Process skills** -- Downloads skill file sets to `~/.claude/skills/{name}/`. 12. **Process system prompt** -- Downloads the prompt file if configured. 13. **Configure MCP servers** -- Sets up MCP servers with scope-based routing. -14. **Write user settings** -- Merges `user-settings` into `~/.claude/settings.json`. +14. **Write user settings** -- In non-isolated mode, deep-merges `user-settings` into `~/.claude/settings.json`. In isolated mode, this write is skipped -- the `user-settings` content is built into the profile's `config.json` at Step 18. 15. **Write global config** -- Merges `global-config` into `~/.claude.json`. With `command-names`, also propagates the machine's recorded `installMethod` from the base `~/.claude.json` into the dual-written isolated `.claude.json`. 16. **Cleanup stale controls** -- Sweeps stale auto-update and IDE extension artifacts from prior configurations (all filesystem locations on unpinned runs, preserving `settings.json` keys the current YAML itself declares; on pinned runs, only the base `~/.claude/settings.json` and only for isolated environments). 17. **Download hooks** -- Downloads hook script files to `~/.claude/{cmd}/hooks/` (with `command-names`) or `~/.claude/hooks/` (without). In non-command-names mode, Step 17 runs when ANY of the following are declared: `hooks.events` non-empty, `hooks.files` non-empty, or `status-line.file` set. -18. **Write profile settings** -- Writes all nine profile-owned keys (`model`, `permissions`, `env`, `attribution`, `alwaysThinkingEnabled`, `effortLevel`, `companyAnnouncements`, `statusLine`, `hooks`) as camelCase keys on disk. With `command-names`: writes to `~/.claude/{cmd}/config.json` via `create_profile_config()` (atomic overwrite -- fresh dict each run). Without `command-names`: writes to `~/.claude/settings.json` via `write_profile_settings_to_settings()`, which delegates to `_write_merged_json()` for **deep-merge, universal array union at every depth, and RFC 7396 null-as-delete** (preserves non-delta keys; see [Profile-Level Settings Routing](#profile-level-settings-routing)). +18. **Write profile settings** -- Writes the profile-owned keys (`statusLine`, `hooks`) as camelCase keys on disk. With `command-names`: writes `~/.claude/{cmd}/config.json` via `create_profile_config()`, merging the `user-settings` content with the built `statusLine`/`hooks` entries (atomic overwrite -- fresh dict each run). Without `command-names`: writes to `~/.claude/settings.json` via `write_profile_settings_to_settings()`, which delegates to `_write_merged_json()` for **deep-merge, universal array union at every depth, and RFC 7396 null-as-delete** (preserves non-delta keys; see [Profile-Level Settings Routing](#profile-level-settings-routing)). 19. **Write manifest** -- Creates an installation tracking manifest. (Only if `command-names` is specified.) 20. **Create launcher** -- Creates the launcher script for the command. (Only if `command-names` is specified.) 21. **Register commands** -- Creates global command wrappers. (Only if `command-names` is specified.) 22. **Link projects directory** -- Links the isolated profile's `projects/` directory to the base `~/.claude/projects/`. (Only if `command-names` is specified and `link-projects-dir: true`.) -Step 17 is skipped if no hooks, hook files, or status-line file are configured. Step 18 is a no-op if the profile delta is empty -- no profile-owned keys declared at YAML root level and no auto-injected `env-variables` controls written back by version pinning. Steps 19-22 are skipped if `command-names` is not specified. Step 22 additionally requires `link-projects-dir: true`. +Step 17 is skipped if no hooks, hook files, or status-line file are configured. In non-isolated mode, Step 18 is a no-op if the profile delta is empty -- no `status-line` or `hooks` declared at YAML root level. Steps 19-22 are skipped if `command-names` is not specified. Step 22 additionally requires `link-projects-dir: true`. ## Profile-Level Settings Routing -The setup script supports two modes of profile-settings routing, controlled by the presence of `command-names:` in the YAML configuration. This section documents how the nine profile-owned keys land on disk in each mode and how they interact with `user-settings:`. +The setup script supports two modes of profile-settings routing, controlled by the presence of `command-names:` in the YAML configuration. This section documents how the profile-owned keys (`status-line`, `hooks`) and the `user-settings` content land on disk in each mode. ### Profile-Owned Keys -Nine YAML root-level keys are **profile-owned** -- they are extracted from YAML root, translated to camelCase, and written to disk by the profile-settings subsystem (`_build_profile_settings()` builder + one of two writers): +Two YAML root-level keys are **profile-owned** -- they are extracted from YAML root, translated to camelCase, and written to disk by the profile-settings subsystem (`_build_profile_settings()` builder + one of two writers) because they require toolbox-side processing (file download, absolute-path command construction): | YAML root key (kebab-case) | On-disk key (camelCase) | |-------------------------------|---------------------------| -| `model` | `model` | -| `permissions` | `permissions` | -| `env-variables` | `env` | -| `attribution` | `attribution` | -| `always-thinking-enabled` | `alwaysThinkingEnabled` | -| `effort-level` | `effortLevel` | -| `company-announcements` | `companyAnnouncements` | | `status-line` | `statusLine` | | `hooks` | `hooks` | -The shared pure builder `_build_profile_settings()` performs kebab-to-camel translation (for nested keys like `permissions.default-mode` -> `permissions.defaultMode`) and delegates to `_build_hooks_json()` for the `hooks` universe. The 9-key set is declared as `PROFILE_OWNED_KEYS` (a `frozenset`) in `scripts/setup_environment.py`, and the mapping of YAML kebab-case root keys to camelCase on-disk names is declared as the module-level constant `_YAML_TO_CAMEL_PROFILE_KEYS`. These keys are distinct from `USER_SETTINGS_EXCLUDED_KEYS = {'hooks', 'statusLine'}`, which remains intentionally narrow because `user-settings` must accept arbitrary Claude Code CLI `settings.json` keys for forward compatibility (see [Relationship to Profile-Owned Keys](#relationship-to-profile-owned-keys)). +The shared pure builder `_build_profile_settings()` performs status-line command-string construction and delegates to `_build_hooks_json()` for the `hooks` universe. The 2-key set is declared as `PROFILE_OWNED_KEYS = frozenset({'statusLine', 'hooks'})` in `scripts/setup_environment.py`, and the mapping of YAML kebab-case root keys to camelCase on-disk names is declared as the module-level constant `_YAML_TO_CAMEL_PROFILE_KEYS`. These keys match `USER_SETTINGS_EXCLUDED_KEYS = {'hooks', 'statusLine'}` exactly: they are forbidden inside `user-settings` because the toolbox owns their processing, while every other `settings.json` key is declared under `user-settings` (see [`user-settings`](#user-settings)). ### Isolated Mode (command-names present) -When `command-names` is specified, the setup creates an isolated directory `~/.claude/{cmd}/` containing: +When `command-names` is specified, the setup creates an isolated directory `~/.claude/{cmd}/`. The `config.json` file carries the complete `settings.json` content -- the `user-settings` section plus the toolbox-built `statusLine` and `hooks` entries -- and the toolbox does not write the isolated `settings.json`: -| File | Priority (CLI) | Content | Writer | Step | Semantics | -|-----------------|------------------|---------------------------------------------------------|---------------------------|------|--------------------------------------------------------------------------------------| -| `settings.json` | 5 (userSettings) | YAML `user-settings:` (all non-excluded keys) | `write_user_settings()` | 14 | Deep merge + universal array union at every depth + RFC 7396 null-as-delete | -| `config.json` | 2 (flagSettings) | 9 `PROFILE_OWNED_KEYS` from YAML root | `create_profile_config()` | 18 | Atomic overwrite (fresh dict each run, fully toolbox-owned) | +| File | Priority (CLI) | Content | Writer | Step | Semantics | +|---------------|------------------|-----------------------------------------------------------------|---------------------------|------|--------------------------------------------------------| +| `config.json` | 2 (flagSettings) | `user-settings:` content + built `statusLine` / `hooks` entries | `create_profile_config()` | 18 | Atomic overwrite (fresh dict each run; nulls stripped) | -The launcher script passes `config.json` via the `--settings` flag and sets `CLAUDE_CONFIG_DIR` to the isolated directory. Claude Code CLI's native priority resolution (`flagSettings (2) > userSettings (5)`) ensures `config.json` wins over `settings.json` for overlapping keys at runtime. In isolated mode, stale-key accumulation in `config.json` is NOT a concern because `create_profile_config()` uses atomic overwrite: every run produces a fresh `config.json` containing only the currently-declared keys, so removing a key from YAML cleanly removes it from `config.json` on the next run. The isolated `settings.json` (written by `write_user_settings()`) follows the universal shared-settings merge contract and preserves contributions from prior runs and manual edits. +The launcher script passes `config.json` via the `--settings` flag (command-line settings layer, priority 2) and sets `CLAUDE_CONFIG_DIR` to the isolated directory. Because `--settings` outranks a repository's project settings, the isolated profile's settings are enforced -- exactly what an isolated environment exists to provide. The `user-settings` section and the built `statusLine`/`hooks` entries are disjoint by construction, because `statusLine` and `hooks` are rejected inside `user-settings`. In isolated mode, stale-key accumulation is NOT a concern because `create_profile_config()` uses atomic overwrite: every run produces a fresh `config.json` containing only the currently-declared keys, so removing a key from YAML cleanly removes it from `config.json` on the next run. Null-valued dict members at every depth are stripped before the write (absence expresses deletion under atomic rebuild), so a literal JSON null is never written. ### Non-Isolated Mode (command-names absent) @@ -1829,12 +1716,12 @@ When `command-names` is ABSENT, the setup writes to the shared `~/.claude/` dire | File | Content | Writer | Step | Semantics | |----------------------------|----------------------------------------------------|-----------------------------------------|------|------------------------------------------------------------------------------| | `~/.claude/settings.json` | YAML `user-settings:` (all non-excluded keys) | `write_user_settings()` | 14 | Deep merge + universal array union at every depth + RFC 7396 null-as-delete | -| `~/.claude/settings.json` | 9 `PROFILE_OWNED_KEYS` delta from YAML root | `write_profile_settings_to_settings()` | 18 | Deep merge + universal array union at every depth + RFC 7396 null-as-delete | +| `~/.claude/settings.json` | `statusLine`/`hooks` delta from YAML root | `write_profile_settings_to_settings()` | 18 | Deep merge + universal array union at every depth + RFC 7396 null-as-delete | 1. **Step 14** deep-merges `user-settings:` into `settings.json`. Existing keys are preserved; for leaf scalar conflicts the new YAML values overwrite the existing values; every list at every depth is unioned with structural dedupe; `null` values delete keys via RFC 7396. -2. **Step 18** deep-merges the profile delta into the same file using the same semantics. Existing keys not in the delta are preserved; existing nested dicts are recursively merged with the delta; every list at every depth is unioned with structural dedupe across both steps; top-level or nested `null` in the delta deletes keys. +2. **Step 18** deep-merges the `statusLine`/`hooks` delta into the same file using the same semantics. Existing keys not in the delta are preserved; existing nested dicts are recursively merged with the delta; every list at every depth is unioned with structural dedupe across both steps; top-level or nested `null` in the delta deletes keys. -Under this contract, the shared `~/.claude/settings.json` is never scrubbed of keys the current YAML does not declare, and contributions from manual user edits, other YAML configurations, the Claude Code CLI itself, and `user-settings.permissions` at Step 14 all survive profile-settings writes at Step 18. List-valued keys (such as `permissions.allow/deny/ask/additionalDirectories`, `companyAnnouncements`, `hooks.` matcher-group lists, `sandbox.filesystem.*` path lists, `disabledMcpjsonServers`/`enabledMcpjsonServers`) accumulate additively across runs, matching [Claude Code CLI's documented cross-scope merge semantics](https://code.claude.com/docs/en/settings): "arrays are concatenated and deduplicated, not replaced". +Under this contract, the shared `~/.claude/settings.json` is never scrubbed of keys the current YAML does not declare, and contributions from manual user edits, other YAML configurations, the Claude Code CLI itself, and the Step 14 `user-settings` write all survive the profile-settings write at Step 18. List-valued keys (such as `permissions.allow/deny/ask/additionalDirectories`, `companyAnnouncements`, `hooks.` matcher-group lists, `sandbox.filesystem.*` path lists, `disabledMcpjsonServers`/`enabledMcpjsonServers`) accumulate additively across runs, matching [Claude Code CLI's documented cross-scope merge semantics](https://code.claude.com/docs/en/settings): "arrays are concatenated and deduplicated, not replaced". ### Write Semantics Contract @@ -1844,7 +1731,7 @@ Under this contract, the shared `~/.claude/settings.json` is never scrubbed of k 1. **READ** the existing `~/.claude/settings.json` (or start fresh with an empty dict if the file is missing, malformed, or has a non-dict top-level value; a warning is emitted in those cases). 2. **DEEP MERGE** the builder delta into the existing content via `_merge_recursive()`, which handles: - - **Deep recursion** into nested dicts (for example, a delta `permissions: {default-mode: ask}` updates only the `defaultMode` sub-key of `permissions`, leaving `permissions.allow`, `permissions.deny`, `permissions.ask`, and any other sub-keys intact if not in the delta). + - **Deep recursion** into nested dicts (for example, a delta `hooks: {PostToolUse: [...]}` updates only the `PostToolUse` sub-key of `hooks`, leaving other event names intact if not in the delta). - **Universal array union** at every depth via Python structural equality -- existing and new arrays are combined, order-preserving (existing elements first), with duplicate elements removed. Applies to every list-valued key at any nesting level, matching Claude Code CLI's cross-scope merge semantics. - **RFC 7396 null-as-delete**: any value of `None` in the delta (top-level or nested) deletes the corresponding key from the target via `target.pop(key, None)`. - **Scalar overwrite** on leaf conflicts (new value wins). @@ -1863,54 +1750,57 @@ The builder `_build_profile_settings()` accepts a `profile_config` dict keyed by | YAML declares `key: null` | `{'key': None}` | DELETE the key from the file (RFC 7396 null-as-delete) | | YAML omits `key` | key absent from delta | PRESERVE existing value unchanged | -**Null-as-delete is supported for all nine profile-owned keys**, and for every key written by the shared-settings writers, both at the top level (`model: null`, `permissions: null`, `hooks: null`, ...) and nested (`permissions: {deny: null}`, `hooks: {PreToolUse: null}`, ...). The top-level and nested cases go through the same `_merge_recursive()` path inside the writer; the top-level path additionally requires the dict-membership threading in `profile_config` to survive main()'s YAML extraction. +**Null-as-delete is supported for both profile-owned keys** (`status-line`, `hooks`), and for every key written by the shared-settings writers, both at the top level (`status-line: null`, `hooks: null`) and nested (`hooks: {PreToolUse: null}`). The top-level and nested cases go through the same `_merge_recursive()` path inside the writer; the top-level path additionally requires the dict-membership threading in `profile_config` to survive main()'s YAML extraction. **Preservation coverage (what survives shared-settings writes):** Keys absent from the delta are preserved in `~/.claude/settings.json`. This covers: - Prior contributions from `write_profile_settings_to_settings()` itself across other YAML configurations, including list-valued keys (which accumulate additively under the universal array-union contract). -- Deep-merged contributions from Step 14 `write_user_settings()` (including any free-form `user-settings` keys, all list-valued keys unioned with structural dedupe across Step 14 and Step 18). +- Deep-merged contributions from Step 14 `write_user_settings()` (all `user-settings` keys -- `model`, `permissions`, `env`, `effortLevel`, and everything else -- with list-valued keys unioned with structural dedupe across Step 14 and Step 18). - User-managed keys outside the toolbox's YAML schema (for example, `includeGitInstructions`, `apiKeyHelper`, `cleanupPeriodDays`, `outputStyle`, `autoMemoryDirectory`, `sandbox.*`, user-managed array-valued keys like `companyAnnouncements` or `permissions.additionalDirectories`). -- Auto-injected `env.DISABLE_AUTOUPDATER` (auto-update Target 2) and `env.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL` (IDE extension Target 2) controls: because deep-merge recurses into the `env` dict, the injected controls coexist with any user-declared environment variables. When the YAML declares its own `env-variables`, the delta's new env keys are deep-merged on top of the existing env dict rather than replacing it, so the Step 14 Target 2 contributions survive the Step 18 write itself. (Pinned non-isolated runs perform no Step 16 `settings.json` sweep, so these controls also survive the cleanup pass -- see [Automatic Auto-Update Management](#automatic-auto-update-management).) +- Auto-injected `env.DISABLE_AUTOUPDATER` (auto-update) and `env.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL` (IDE extension) controls, which are injected into `user-settings.env` and written by Step 14: because deep-merge recurses into the `env` dict, the injected controls coexist with any user-declared environment variables and survive the Step 18 write, which touches only `statusLine`/`hooks`. (Pinned non-isolated runs perform no Step 16 `settings.json` sweep, so these controls also survive the cleanup pass -- see [Automatic Auto-Update Management](#automatic-auto-update-management).) - Elements written to list-valued keys by any prior contributor (manual user edits, the Claude Code CLI, teammate YAMLs): new elements from the current YAML are unioned with the existing list rather than replacing it. -**Empty-delta no-op:** If no profile-owned keys are declared at YAML root level, the builder returns `{}` and `write_profile_settings_to_settings()` performs ZERO file I/O -- it neither creates nor touches `~/.claude/settings.json`. A YAML with only `user-settings:`, `global-config:`, `agents:`, and so on will never have Step 18 modify `settings.json`. (Exception: when `claude-code-version` pins a version, the auto-injected `env-variables` controls are written back into the resolved configuration, so the delta contains `env` and Step 18 does write -- see [Automatic Auto-Update Management](#automatic-auto-update-management).) +**Empty-delta no-op:** If neither `status-line` nor `hooks` is declared at YAML root level, the builder returns `{}` and `write_profile_settings_to_settings()` performs ZERO file I/O -- it neither creates nor touches `~/.claude/settings.json`. A YAML with only `user-settings:`, `global-config:`, `agents:`, and so on will never have Step 18 modify `settings.json`; the `user-settings` content (including auto-injected env controls) reaches `settings.json` through Step 14 instead. **Malformed or non-dict existing content:** If `~/.claude/settings.json` contains invalid JSON, unreadable content, or a non-dict top-level value (for example, a bare list), `_write_merged_json()` emits a warning (`"Existing ... is not a dict, starting fresh"` or `"Invalid JSON in ..."`) and starts fresh (treats the existing content as `{}`). The written file ends with a trailing newline for file-format consistency with `write_user_settings()` and `write_global_config()`. ### Null-as-Delete for Profile-Owned Keys (YAML contract) -All nine profile-owned keys support null-as-delete at the YAML root level in non-command-names mode. Examples: +Both profile-owned keys (`status-line`, `hooks`) support null-as-delete at the YAML root level in non-command-names mode. Every other `settings.json` key deletes the same way from under `user-settings` (see [Key Deletion](#key-deletion-null-as-delete)). Examples: ```yaml -# Delete the entire permissions block (including allow, deny, ask) -permissions: null +# Delete the entire hooks block from settings.json +hooks: null + +# Delete just one event list (keep other event names) +hooks: + PreToolUse: null -# Delete just the deny sub-key (keep allow and ask) -permissions: - deny: null +# Delete the status-line key +status-line: null -# Delete the model, env, and hooks top-level keys in one YAML -model: null -env-variables: null -hooks: null +# Delete a settings.json key managed under user-settings +user-settings: + model: null + permissions: null ``` **Top-level null vs nested null:** -- **Top-level null** (for example, `model: null`): the entire top-level key is removed from `~/.claude/settings.json` via `existing.pop('model', None)`. -- **Nested null** (for example, `permissions: {deny: null}`): deep-merge recurses into the `permissions` dict, and the `deny` sub-key is removed while other sub-keys (`allow`, `ask`, and so on) are preserved. +- **Top-level null** (for example, `hooks: null`): the entire top-level key is removed from `~/.claude/settings.json` via `existing.pop('hooks', None)`. +- **Nested null** (for example, `hooks: {PreToolUse: null}`): deep-merge recurses into the `hooks` dict, and the `PreToolUse` sub-key is removed while other event names are preserved. -Both cases are handled by `_merge_recursive()` inside `_write_merged_json()`. The top-level case additionally requires the dict-membership construction in `main()` and the builder so that the profile_config can carry `None` for top-level YAML nulls -- a plain `config.get('model')` would otherwise erase the distinction between "absent" and "null". +Both cases are handled by `_merge_recursive()` inside `_write_merged_json()`. The top-level case additionally requires the dict-membership construction in `main()` and the builder so that the profile_config can carry `None` for top-level YAML nulls -- a plain `config.get('hooks')` would otherwise erase the distinction between "absent" and "null". -**Re-run semantics:** After `model: null` has been applied, removing the `model: null` line from the YAML (so the key is absent) in a later run preserves the `model` key's then-current state (which is "absent from settings.json", unchanged by the new no-op delta). There is no auto-undelete -- once deleted, a key stays deleted until a subsequent YAML explicitly re-declares it with a non-null value. +**Re-run semantics:** After `hooks: null` has been applied, removing the `hooks: null` line from the YAML (so the key is absent) in a later run preserves the `hooks` key's then-current state (which is "absent from settings.json", unchanged by the new no-op delta). There is no auto-undelete -- once deleted, a key stays deleted until a subsequent YAML explicitly re-declares it with a non-null value. ### Deferred Stale-Key Behavior (User-Facing Contract) This is an INTENTIONAL user-facing contract, not a bug. Understanding this behavior is critical to using `command-names`-absent mode correctly. -**Scenario:** You had `permissions: {allow: [Read]}` at YAML root in one setup run. You then remove the entire `permissions` block from your YAML and re-run setup. +**Scenario:** You had `user-settings: {permissions: {allow: [Read]}}` in one setup run. You then remove the entire `permissions` block from your `user-settings` and re-run setup. **Result:** The `permissions` key in `~/.claude/settings.json` retains its existing on-disk value (`{allow: [Read]}`). It is NOT deleted. @@ -1925,21 +1815,27 @@ This is an INTENTIONAL user-facing contract, not a bug. Understanding this behav 1. **Set the key to `null` in YAML.** Examples: ```yaml - permissions: null # Delete entire permissions block from settings.json - model: null # Delete model key - hooks: null # Delete hooks block - env-variables: null # Delete env block from settings.json - permissions: - deny: null # Delete only the deny sub-key (keep allow/ask) + hooks: null # Delete hooks block (profile-owned, YAML root) + status-line: null # Delete statusLine (profile-owned, YAML root) + user-settings: + permissions: null # Delete entire permissions block from settings.json + model: null # Delete model key + env: null # Delete env block from settings.json + ``` + To delete only a nested sub-key, nest the null under the parent instead of nulling the whole block (the two forms are mutually exclusive alternatives for the same key): + ```yaml + user-settings: + permissions: + deny: null # Delete only the deny sub-key (keep allow/ask) ``` 2. **Manually delete the key** from `~/.claude/settings.json` using a text editor. Automated YAML-removal-triggered cleanup (a state-tracking sidecar approach, for example `~/.claude/toolbox-managed-keys.json` recording which keys the toolbox wrote in the last run) is not implemented. The preservation behavior is the intended design. -**Security framing: preventing silent destruction of user state.** Deep-merge with universal array-union at every depth is the core mechanism that prevents silent destruction of security rules and user state in the shared `~/.claude/settings.json`. The contract applies uniformly to every list-valued key at any depth: `permissions.allow/deny/ask/additionalDirectories`, `companyAnnouncements`, `hooks.` matcher-group lists, `sandbox.filesystem.*` path lists, `disabledMcpjsonServers`/`enabledMcpjsonServers`, `projects..allowedTools`, and every other list-valued key. A narrower YAML declaration such as `permissions: {allow: [Read]}` MUST NOT remove `permissions.deny` entries, `permissions.additionalDirectories`, `companyAnnouncements` entries, or any other user-managed state contributed by other writers (manual user edits, the Claude Code CLI, teammate YAMLs, or `user-settings.permissions.deny` at Step 14). Under the unified deep-merge + universal-array-union contract, list entries accumulate additively across runs and explicit null is the only way to shrink them: +**Security framing: preventing silent destruction of user state.** Deep-merge with universal array-union at every depth is the core mechanism that prevents silent destruction of security rules and user state in the shared `~/.claude/settings.json`. The contract applies uniformly to every list-valued key at any depth: `permissions.allow/deny/ask/additionalDirectories`, `companyAnnouncements`, `hooks.` matcher-group lists, `sandbox.filesystem.*` path lists, `disabledMcpjsonServers`/`enabledMcpjsonServers`, `projects..allowedTools`, and every other list-valued key. A narrower YAML declaration such as `user-settings: {permissions: {allow: [Read]}}` MUST NOT remove `permissions.deny` entries, `permissions.additionalDirectories`, `companyAnnouncements` entries, or any other user-managed state contributed by other writers (manual user edits, the Claude Code CLI, or teammate YAMLs). Under the unified deep-merge + universal-array-union contract, list entries accumulate additively across runs and explicit null is the only way to shrink them: -- To remove ALL entries under a list-valued sub-key: nest-null the sub-key (`permissions: {deny: null}` deletes just the `deny` sub-key) or top-level-null the parent (`permissions: null` deletes the entire `permissions` block). -- To remove a specific element: edit `~/.claude/settings.json` manually, because array union only grows lists -- declaring `permissions: {deny: [X]}` in YAML unions `[X]` with the existing deny list, not replaces it. +- To remove ALL entries under a list-valued sub-key: nest-null the sub-key (`user-settings: {permissions: {deny: null}}` deletes just the `deny` sub-key) or top-level-null the parent (`user-settings: {permissions: null}` deletes the entire `permissions` block). +- To remove a specific element: edit `~/.claude/settings.json` manually, because array union only grows lists -- declaring `user-settings: {permissions: {deny: [X]}}` in YAML unions `[X]` with the existing deny list, not replaces it. Any rule or entry preserved on disk is the combined contribution of all writers; losing rules silently on re-run would be a critical security regression, so the toolbox instead requires explicit null to delete them. This matches [Claude Code CLI's documented cross-scope merge semantics](https://code.claude.com/docs/en/settings) for shared settings files. @@ -1978,38 +1874,16 @@ System prompts are applied by the launcher via `--system-prompt` or `--append-sy Unlike profile-scoped MCP servers (which are a hard error because silently-dropped servers are a correctness risk), a silently-unused system prompt file is merely a configuration mistake -- a warning is sufficient. Warning output is written to stdout. -### Conflict Detection +### Four-Writer Architectural Model Summary -`detect_settings_conflicts()` runs UNCONDITIONALLY in BOTH modes (isolated and non-isolated). If you declare a profile-owned key under BOTH `user-settings:` AND at YAML root level, a warning is emitted during the validation phase: +| Writer | YAML Source | Target | Key Universe | Semantics | Step | +|----------------------------------------|------------------------------------------|--------------------------------------------------------------|--------------------------------------------------|-----------------------------------------------------------------------------|-------------------| +| `write_user_settings()` | `user-settings:` | `~/.claude/settings.json` (non-isolated only) | All non-excluded `settings.json` keys | Deep merge + universal array union at every depth + RFC 7396 null-as-delete | 14 | +| `write_global_config()` | `global-config:` | `~/.claude.json` (+ `~/.claude/{cmd}/.claude.json` isolated) | Free-form `~/.claude.json` keys | Deep merge + universal array union at every depth + RFC 7396 null-as-delete | 15 | +| `create_profile_config()` | `user-settings:` + `status-line`/`hooks` | `~/.claude/{cmd}/config.json` | `user-settings` content + 2 `PROFILE_OWNED_KEYS` | Atomic overwrite (fresh dict each run, nulls stripped, fully toolbox-owned) | 18 (isolated) | +| `write_profile_settings_to_settings()` | `status-line`/`hooks` (YAML root) | `~/.claude/settings.json` | 2 `PROFILE_OWNED_KEYS` delta | Deep merge + universal array union at every depth + RFC 7396 null-as-delete | 18 (non-isolated) | -```text -[WARN] Key 'model' specified in both root level and user-settings. -[WARN] user-settings value: claude-opus-4 -[WARN] root-level value: claude-sonnet-4 -[WARN] Under deep merge semantics, root-level values overwrite user-settings values for scalar keys. -[WARN] For dict keys, user-settings and root-level values are deep-merged. -[WARN] For ALL array-valued keys at any depth, array union with structural dedupe applies -[WARN] (matching Claude Code CLI's cross-scope merge: "arrays are concatenated and -[WARN] deduplicated, not replaced"). -``` - -**How the two contributions compose (in both modes):** - -- **Isolated mode:** Step 14 writes `user-settings:` to `~/.claude/{cmd}/settings.json` (priority 5), Step 18 writes the profile delta to `~/.claude/{cmd}/config.json` (priority 2). The CLI's native `flagSettings > userSettings` resolution means `config.json` wins over `settings.json` at runtime for overlapping keys. -- **Non-isolated mode:** Step 14 writes `user-settings:` to `~/.claude/settings.json` via deep-merge, then Step 18 deep-merges the profile delta into the same file. For scalar keys, the root-level (Step 18) value overwrites the `user-settings` (Step 14) value. For dict keys, the two contributions are deep-merged: sub-keys declared only on one side are preserved, sub-keys declared on both sides are resolved by the root-level value winning. For list-valued keys at any depth, both contributions are unioned with structural dedupe under the universal array-union contract. - -The conflict warning ensures users are informed regardless of which mode they use. - -### Three-Writer Architectural Model Summary - -| Writer | YAML Source | Target | Key Universe | Semantics | Step | -|----------------------------------------|------------------------------|-------------------------------------------------------------------|-------------------------------------------|------------------------------------------------------------------------------|---------------------| -| `write_user_settings()` | `user-settings:` | `~/.claude/settings.json` OR `~/.claude/{cmd}/settings.json` | ~58 non-excluded CLI keys | Deep merge + universal array union at every depth + RFC 7396 null-as-delete | 14 | -| `write_global_config()` | `global-config:` | `~/.claude.json` (+ `~/.claude/{cmd}/.claude.json` when isolated) | Free-form CLI keys | Deep merge + universal array union at every depth + RFC 7396 null-as-delete | 15 | -| `create_profile_config()` | YAML root profile keys | `~/.claude/{cmd}/config.json` | 9 `PROFILE_OWNED_KEYS` | Atomic overwrite (fresh dict each run, fully toolbox-owned) | 18 (isolated) | -| `write_profile_settings_to_settings()` | YAML root profile keys | `~/.claude/settings.json` | 9 `PROFILE_OWNED_KEYS` delta | Deep merge + universal array union at every depth + RFC 7396 null-as-delete | 18 (non-isolated) | - -All three shared-file writers (`write_user_settings()`, `write_global_config()`, `write_profile_settings_to_settings()`) delegate to the same `_write_merged_json()` helper with `array_union_keys=None` (the default), which gives them a single unified universal deep-merge contract: every list at every depth is unioned with structural dedupe, matching [Claude Code CLI's cross-scope merge semantics](https://code.claude.com/docs/en/settings) ("arrays are concatenated and deduplicated, not replaced"). Both Step 18 writers (`create_profile_config()` and `write_profile_settings_to_settings()`) are fed by the shared pure builder `_build_profile_settings()`, which accepts a `profile_config` dict, translates kebab-case YAML keys to camelCase JSON keys, and delegates to `_build_hooks_json()` for hook events. In isolated mode, `create_profile_config()` atomically rewrites `~/.claude/{cmd}/config.json` from scratch on each run (fully toolbox-owned). In non-isolated mode, `write_profile_settings_to_settings()` deep-merges its delta into the shared `~/.claude/settings.json`, preserving contributions from other writers and accumulating list elements additively across runs. +The two shared-file merge writers (`write_user_settings()` and `write_global_config()`) plus `write_profile_settings_to_settings()` delegate to the same `_write_merged_json()` helper with `array_union_keys=None` (the default), which gives them a single unified universal deep-merge contract: every list at every depth is unioned with structural dedupe, matching [Claude Code CLI's cross-scope merge semantics](https://code.claude.com/docs/en/settings) ("arrays are concatenated and deduplicated, not replaced"). Both Step 18 writers (`create_profile_config()` and `write_profile_settings_to_settings()`) are fed by the shared pure builder `_build_profile_settings()`, which accepts a `profile_config` dict for the `status-line`/`hooks` keys and delegates to `_build_hooks_json()` for hook events. In isolated mode, `create_profile_config()` atomically rewrites `~/.claude/{cmd}/config.json` from scratch on each run (fully toolbox-owned), merging the `user-settings` content with the built profile-owned entries and stripping null-valued members. In non-isolated mode, `user-settings` reaches `~/.claude/settings.json` through `write_user_settings()` at Step 14, and `write_profile_settings_to_settings()` deep-merges the `status-line`/`hooks` delta into the same file at Step 18, preserving contributions from other writers and accumulating list elements additively across runs. **YAML inheritance layer is separate.** The per-path whitelist mechanism in `deep_merge_settings()` (`DEFAULT_ARRAY_UNION_KEYS` and explicit `set[str]` arguments) applies only to the YAML composition layer (`_resolve_single_key` call sites): `array_union_keys=set()` for `global-config` inheritance (child replaces parent for arrays) and `array_union_keys=DEFAULT_ARRAY_UNION_KEYS` for `user-settings` inheritance (union only for `permissions.allow/deny/ask`, replace for other arrays). On-disk shared-file writers are decoupled from YAML composition by intent; they always use the universal default. @@ -2086,26 +1960,6 @@ mcp-servers: command: "npx @example/code-search-mcp" scope: "profile" -# Use Opus model with maximum effort -model: "opus" -effort-level: "max" -always-thinking-enabled: true - -# Permissions -permissions: - default-mode: "default" - allow: - - "Read" - - "Glob" - - "Grep" - deny: - - "Bash(rm -rf)" - -# Claude-level environment variables -env-variables: - PROJECT_TYPE: "python" - COVERAGE_THRESHOLD: "80" - # OS-level persistent environment variables os-env-variables: PYTHONDONTWRITEBYTECODE: "1" @@ -2115,25 +1969,40 @@ command-defaults: system-prompt: "prompts/python-system-prompt.md" mode: "append" -# User settings +# User settings -- raw settings.json content (camelCase keys) user-settings: + # Use Opus model with maximum effort + model: "opus" + effortLevel: "max" + alwaysThinkingEnabled: true language: "english" - -# Global config + # Permissions + permissions: + defaultMode: "default" + allow: + - "Read" + - "Glob" + - "Grep" + deny: + - "Bash(rm -rf)" + # Claude-level environment variables + env: + PROJECT_TYPE: "python" + COVERAGE_THRESHOLD: "80" + # Company announcements + companyAnnouncements: + - "Welcome to the Python development environment!" + - "Run /lint to check your code" + # Attribution + attribution: + commit: "Co-authored-by: Claude AI" + pr: "" # Hide PR attribution + +# Global config -- raw ~/.claude.json content (camelCase keys) global-config: autoConnectIde: true showTurnDuration: true -# Company announcements -company-announcements: - - "Welcome to the Python development environment!" - - "Run /lint to check your code" - -# Attribution -attribution: - commit: "Co-authored-by: Claude AI" - pr: "" # Hide PR attribution - # Hooks for code quality and safety hooks: files: @@ -2221,13 +2090,14 @@ Both `command-names` and `command-defaults` must be specified together. Provide The `link-projects-dir` flag links an isolated profile's `projects/` directory to the base `~/.claude/projects/`, and isolated profiles exist only when `command-names` is present. Either add `command-names` or remove `link-projects-dir`. -### effort-level 'xhigh'/'max' is only available for Opus and Fable models +### user-settings.effortLevel 'xhigh'/'max' requires a matching model -The `xhigh` and `max` effort levels require the `model` key to be set to an Opus or Fable variant (the model name must contain `opus` or `fable`, case-insensitive) or the exact alias `best`. The validation errors read `effort-level '{level}' requires model to be specified. The '{level}' effort level is only available for Opus and Fable models.` (when `model` is missing) and `effort-level '{level}' is only available for Opus and Fable models, but model is set to '{model}'. Use 'low', 'medium', or 'high' for other models.` (when the model is outside both families): +The `xhigh` and `max` effort levels require `user-settings.model` to be set to a supporting variant: `xhigh` requires an Opus or Fable model (the model name must contain `opus` or `fable`, case-insensitive) or the exact alias `best`; `max` additionally accepts a Sonnet model. The validation errors read `user-settings.effortLevel '{level}' requires user-settings.model to be specified. This effort level is only available for {families}.` (when `model` is missing) and `user-settings.effortLevel '{level}' is only available for {families}, but model is set to '{model}'. Use 'low', 'medium', or 'high' for other models.` (when the model is outside the required families), where `{families}` is "Opus and Fable models" for `xhigh` and "Opus, Sonnet, and Fable models" for `max`: ```yaml -model: "claude-fable-5" # or "opus", "fable", "best" -effort-level: "max" # or "xhigh" +user-settings: + model: "claude-fable-5" # or "opus", "fable", "best"; "sonnet" also works for max + effortLevel: "max" # or "xhigh" ``` ### Invalid platform keys in dependencies diff --git a/docs/installing-claude-code.md b/docs/installing-claude-code.md index f674954..0b685e4 100644 --- a/docs/installing-claude-code.md +++ b/docs/installing-claude-code.md @@ -158,7 +158,7 @@ To switch manually, uninstall the npm version with `npm uninstall -g @anthropic- ## Auto-Update Management -Auto-update management is handled by `setup_environment.py` via YAML configuration, not by the standalone installer. When `claude-code-version` is specified in a YAML environment configuration, `setup_environment.py` automatically manages auto-update controls across four targets. +Auto-update management is handled by `setup_environment.py` via YAML configuration, not by the standalone installer. When `claude-code-version` is specified in a YAML environment configuration, `setup_environment.py` automatically manages auto-update controls across three targets. For version pinning with auto-update protection, use `setup_environment.py` with a YAML configuration specifying `claude-code-version`. See [Automatic Auto-Update Management](environment-configuration-guide.md#automatic-auto-update-management) in the Environment Configuration Guide. diff --git a/scripts/models/environment_config.py b/scripts/models/environment_config.py index a40a736..8f3a180 100644 --- a/scripts/models/environment_config.py +++ b/scripts/models/environment_config.py @@ -33,11 +33,82 @@ }) # Model family markers whose presence (case-insensitive substring) in the model -# identifier indicates support for the extended 'xhigh' and 'max' effort levels: -# 'xhigh' is supported on Opus 4.7/4.8 and Fable 5; 'max' on Opus 4.6+ and Fable 5. -# Substring matching covers aliases ('opus', 'fable'), full model IDs +# identifier indicates support for the extended effort levels: 'xhigh' is +# supported on Opus 4.7/4.8 and Fable 5; 'max' on Opus 4.6+, Sonnet 4.6+, and +# Fable 5. Substring matching covers aliases ('opus', 'fable'), full model IDs # ('claude-fable-5'), and provider-prefixed IDs ('us.anthropic.claude-opus-4-8'). -EXTENDED_EFFORT_MODEL_MARKERS: tuple[str, ...] = ('opus', 'fable') +XHIGH_EFFORT_MODEL_MARKERS: tuple[str, ...] = ('opus', 'fable') +MAX_EFFORT_MODEL_MARKERS: tuple[str, ...] = ('opus', 'fable', 'sonnet') + +# Valid values for the settings.json effortLevel key +EFFORT_LEVEL_VALUES: frozenset[str] = frozenset({'low', 'medium', 'high', 'xhigh', 'max'}) + +# Valid values for the settings.json permissions.defaultMode key. +# 'delegate' appears in the published JSON schema but not in the prose +# documentation; it is accepted to avoid rejecting valid configurations. +PERMISSIONS_DEFAULT_MODE_VALUES: frozenset[str] = frozenset({ + 'default', + 'acceptEdits', + 'plan', + 'auto', + 'dontAsk', + 'bypassPermissions', + 'delegate', +}) + +# user-settings is raw settings.json content and uses camelCase keys. +# These kebab-case spellings are common mistakes carried over from the +# root-level YAML naming convention; each maps to its camelCase correction. +USER_SETTINGS_KEBAB_KEY_CORRECTIONS: dict[str, str] = { + 'always-thinking-enabled': 'alwaysThinkingEnabled', + 'company-announcements': 'companyAnnouncements', + 'effort-level': 'effortLevel', + 'env-variables': 'env', +} + +# Nested permissions keys also use camelCase inside user-settings +PERMISSIONS_KEBAB_KEY_CORRECTIONS: dict[str, str] = { + 'default-mode': 'defaultMode', + 'additional-directories': 'additionalDirectories', +} + +# Root-level YAML keys that are not settings.json keys and therefore +# never valid inside user-settings +USER_SETTINGS_ROOT_ONLY_KEYS: frozenset[str] = frozenset({ + 'status-line', + 'os-env-variables', +}) + +# Keys that live in ~/.claude.json (global-config), not in settings.json; +# declaring them in user-settings would be a silent no-op at runtime +USER_SETTINGS_GLOBAL_ONLY_KEYS: frozenset[str] = frozenset({ + 'autoUpdates', + 'installMethod', + 'autoConnectIde', + 'autoInstallIdeExtension', + 'externalEditorContext', + 'teammateDefaultModel', + 'oauthAccount', +}) + +# Keys that live in settings.json (user-settings), not in ~/.claude.json; +# declaring them in global-config would be a silent no-op at runtime +GLOBAL_CONFIG_SETTINGS_ONLY_KEYS: frozenset[str] = frozenset({ + 'model', + 'permissions', + 'env', + 'attribution', + 'alwaysThinkingEnabled', + 'effortLevel', + 'companyAnnouncements', + 'statusLine', + 'hooks', + 'availableModels', + 'enforceAvailableModels', +}) + +# Environment variable names: letters, digits, underscores; no leading digit +ENV_VAR_NAME_PATTERN: re.Pattern[str] = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$') def _extract_basename(path_or_url: str) -> str: @@ -153,15 +224,263 @@ def _validate_scope_combination(scopes: list[str]) -> tuple[bool, str | None]: return True, None +def _validate_effort_level_entry(effort_level: object, model: object) -> list[str]: + """Validate the user-settings effortLevel value and its model support. + + The 'xhigh' level requires an Opus or Fable model; 'max' requires an + Opus, Sonnet, or Fable model. The exact alias 'best' (which resolves to + Fable 5 or the latest Opus model) satisfies both. Claude Code gracefully + downgrades an unsupported level at runtime, but declaring one in the + profile is almost always a configuration mistake, so it is rejected. + + Args: + effort_level: The declared effortLevel value (non-null). + model: The declared user-settings model value, or None when absent. + + Returns: + List of error messages. Empty list if the entry is valid. + """ + if effort_level not in EFFORT_LEVEL_VALUES: + return [ + f'user-settings.effortLevel must be one of ' + f'{sorted(EFFORT_LEVEL_VALUES)}, got {effort_level!r}.', + ] + + if effort_level not in ('xhigh', 'max'): + return [] + + markers = XHIGH_EFFORT_MODEL_MARKERS if effort_level == 'xhigh' else MAX_EFFORT_MODEL_MARKERS + families = 'Opus and Fable models' if effort_level == 'xhigh' else 'Opus, Sonnet, and Fable models' + + if not isinstance(model, str) or not model.strip(): + return [ + f"user-settings.effortLevel '{effort_level}' requires user-settings.model " + f'to be specified. This effort level is only available for {families}.', + ] + + model_lower = model.lower() + # The 'best' alias is matched exactly, not as a substring, so arbitrary + # model names that merely contain 'best' are not accepted. + if model_lower != 'best' and not any(marker in model_lower for marker in markers): + return [ + f"user-settings.effortLevel '{effort_level}' is only available for " + f"{families}, but model is set to '{model}'. " + "Use 'low', 'medium', or 'high' for other models.", + ] + + return [] + + +def _validate_permissions_entry(permissions: object) -> list[str]: + """Validate the structure of the user-settings permissions value. + + Known sub-keys are checked (camelCase naming, defaultMode enum, list + shapes); unknown sub-keys pass through untouched for forward + compatibility with new Claude Code permissions options. + + Args: + permissions: The declared permissions value (non-null). + + Returns: + List of error messages. Empty list if the value is valid. + """ + if not isinstance(permissions, dict): + return ['user-settings.permissions must be a mapping.'] + + errors: list[str] = [] + permissions_dict = cast(dict[str, object], permissions) + + for kebab, camel in PERMISSIONS_KEBAB_KEY_CORRECTIONS.items(): + if kebab in permissions_dict: + errors.append( + f'user-settings.permissions uses camelCase keys: ' + f"use '{camel}' instead of '{kebab}'.", + ) + + default_mode = permissions_dict.get('defaultMode') + if 'defaultMode' in permissions_dict and default_mode is not None and default_mode not in PERMISSIONS_DEFAULT_MODE_VALUES: + errors.append( + f'user-settings.permissions.defaultMode must be one of ' + f'{sorted(PERMISSIONS_DEFAULT_MODE_VALUES)}, got {default_mode!r}.', + ) + + for list_key in ('allow', 'deny', 'ask', 'additionalDirectories'): + value = permissions_dict.get(list_key) + if list_key in permissions_dict and value is not None: + value_list = cast(list[object], value) if isinstance(value, list) else None + if value_list is None or any(not isinstance(item, str) for item in value_list): + errors.append(f'user-settings.permissions.{list_key} must be a list of strings.') + + return errors + + +def _validate_env_entry(env: object) -> list[str]: + """Validate the structure of the user-settings env value. + + settings.json requires env to be a mapping of string names to string + values. A null entry value is a deletion request and carries no content + to check. + + Args: + env: The declared env value (non-null). + + Returns: + List of error messages. Empty list if the value is valid. + """ + if not isinstance(env, dict): + return ['user-settings.env must be a mapping of environment variable names to string values.'] + + errors: list[str] = [] + for name, value in cast(dict[object, object], env).items(): + if not isinstance(name, str) or not ENV_VAR_NAME_PATTERN.match(name): + errors.append( + f'user-settings.env: invalid environment variable name {name!r}. ' + 'Must start with letter or underscore, followed by letters, digits, or underscores.', + ) + continue + if value is None: + continue + if not isinstance(value, str): + errors.append( + f'user-settings.env.{name} must be a string ' + '(quote the value in YAML) or null to delete the variable.', + ) + elif '\x00' in value: + errors.append(f'user-settings.env.{name} value cannot contain null bytes.') + + return errors + + +def validate_user_settings_values(data: dict[str, object]) -> list[str]: + """Validate known settings.json keys inside a user-settings mapping. + + user-settings is free-form: unknown keys pass through untouched so new + Claude Code settings work without a toolbox update. Known built-in keys, + however, are validated fail-fast, because Claude Code silently ignores + malformed or misplaced entries at runtime and the misconfiguration would + otherwise go unnoticed. A null value for any key is a deletion request + and is always allowed. + + Checks: + - Root-level YAML keys ('status-line', 'os-env-variables') are rejected. + - Kebab-case spellings of known camelCase keys are rejected with the + camelCase correction. + - Keys that belong in global-config (~/.claude.json) are rejected. + - Value shapes for model, env, permissions, attribution, + alwaysThinkingEnabled, companyAnnouncements, and effortLevel + (including the effortLevel/model support cross-check). + + Args: + data: The user-settings mapping from YAML. + + Returns: + List of error messages. Empty list if validation passes. + """ + errors: list[str] = [ + f"Key '{key}' is not allowed in user-settings. " + 'It is a root-level YAML key, not a settings.json key.' + for key in sorted(USER_SETTINGS_ROOT_ONLY_KEYS & set(data)) + ] + + errors.extend( + f"Key '{kebab}' is not a settings.json key. " + f"user-settings holds raw settings.json content with camelCase keys: use '{camel}' instead." + for kebab, camel in USER_SETTINGS_KEBAB_KEY_CORRECTIONS.items() + if kebab in data + ) + + errors.extend( + f"Key '{key}' belongs in global-config (~/.claude.json), " + 'not in user-settings (settings.json).' + for key in sorted(USER_SETTINGS_GLOBAL_ONLY_KEYS & set(data)) + ) + + model = data.get('model') + if 'model' in data and model is not None and (not isinstance(model, str) or not model.strip()): + errors.append('user-settings.model must be a non-empty string.') + + env = data.get('env') + if 'env' in data and env is not None: + errors.extend(_validate_env_entry(env)) + + permissions = data.get('permissions') + if 'permissions' in data and permissions is not None: + errors.extend(_validate_permissions_entry(permissions)) + + attribution = data.get('attribution') + if 'attribution' in data and attribution is not None: + if not isinstance(attribution, dict): + errors.append('user-settings.attribution must be a mapping.') + else: + attribution_dict = cast(dict[str, object], attribution) + for sub in ('commit', 'pr'): + value = attribution_dict.get(sub) + if sub in attribution_dict and value is not None and not isinstance(value, str): + errors.append( + f'user-settings.attribution.{sub} must be a string ' + '(empty string hides attribution).', + ) + + always_thinking = data.get('alwaysThinkingEnabled') + if 'alwaysThinkingEnabled' in data and always_thinking is not None and not isinstance(always_thinking, bool): + errors.append('user-settings.alwaysThinkingEnabled must be a boolean.') + + announcements = data.get('companyAnnouncements') + if 'companyAnnouncements' in data and announcements is not None: + announcements_list = cast(list[object], announcements) if isinstance(announcements, list) else None + if announcements_list is None or any(not isinstance(item, str) for item in announcements_list): + errors.append('user-settings.companyAnnouncements must be a list of strings.') + + effort_level = data.get('effortLevel') + if 'effortLevel' in data and effort_level is not None: + errors.extend(_validate_effort_level_entry(effort_level, model)) + + return errors + + +def validate_global_config_values(data: dict[str, object]) -> list[str]: + """Validate known key placement inside a global-config mapping. + + global-config is free-form: unknown keys pass through untouched. Known + settings.json keys, however, are rejected because ~/.claude.json is not + a settings file and Claude Code would silently ignore them at runtime. + + Args: + data: The global-config mapping from YAML. + + Returns: + List of error messages. Empty list if validation passes. + """ + errors: list[str] = [] + for key in sorted(GLOBAL_CONFIG_SETTINGS_ONLY_KEYS & set(data)): + if key in ('statusLine', 'hooks'): + root_key = 'status-line' if key == 'statusLine' else 'hooks' + errors.append( + f"Key '{key}' is not valid in global-config (~/.claude.json). " + f"Configure it via the root-level '{root_key}' YAML key.", + ) + else: + errors.append( + f"Key '{key}' is a settings.json key and is not valid in " + 'global-config (~/.claude.json). Move it to user-settings.', + ) + return errors + + class UserSettings(BaseModel): - """User settings configuration for ~/.claude/settings.json. + """User settings configuration holding raw settings.json content. Free-form model that accepts any keys supported by Claude Code's - settings.json schema. No specific keys are hardcoded -- the model - passes through all provided settings without field-level validation. - - The only structural guard is the exclusion of 'hooks' and 'statusLine' - keys, which are profile-specific and must not appear in user-settings. + settings.json schema, using camelCase key names exactly as they appear + on disk. Unknown keys pass through without validation for forward + compatibility. + + Structural guards: + - 'hooks' and 'statusLine' are excluded (profile-specific, configured + via root-level YAML keys with dedicated download and path resolution). + - Known built-in keys are validated fail-fast via + validate_user_settings_values(): value shapes, camelCase naming, and + section placement (settings.json vs ~/.claude.json). """ model_config = ConfigDict(extra='allow') @@ -178,18 +497,31 @@ def check_excluded_keys(cls, data: dict[str, object]) -> dict[str, object]: ) return data + @model_validator(mode='before') + @classmethod + def check_known_key_values(cls, data: dict[str, object]) -> dict[str, object]: + """Validate values and placement of known built-in settings keys.""" + errors = validate_user_settings_values(data) + if errors: + raise ValueError('\n'.join(errors)) + return data + class GlobalConfig(BaseModel): """Global configuration for ~/.claude.json. Free-form model that accepts any keys supported by Claude Code's - global configuration schema. No specific keys are hardcoded -- the model - passes through all provided settings without field-level validation. - - The only structural guard is the exclusion of the OAuth credential key - (oauthAccount): non-null values are rejected to prevent credential - exposure in version-controlled YAML configuration files. Null values - are allowed to support clearing authentication state. + global configuration schema. Unknown keys pass through without + validation for forward compatibility. + + Structural guards: + - The OAuth credential key (oauthAccount) is rejected for non-null + values to prevent credential exposure in version-controlled YAML + files. Null values are allowed to support clearing authentication + state. + - Known settings.json keys are rejected via + validate_global_config_values() because ~/.claude.json is not a + settings file and misplaced keys are silent no-ops at runtime. """ model_config = ConfigDict(extra='allow') @@ -207,6 +539,15 @@ def check_excluded_keys(cls, data: dict[str, object]) -> dict[str, object]: ) return data + @model_validator(mode='before') + @classmethod + def check_known_key_placement(cls, data: dict[str, object]) -> dict[str, object]: + """Reject known settings.json keys misplaced into global-config.""" + errors = validate_global_config_values(data) + if errors: + raise ValueError('\n'.join(errors)) + return data + # MCP Server Models @@ -555,13 +896,6 @@ def validate_files_list(cls, v: list[str]) -> list[str]: return v -class Attribution(BaseModel): - """Attribution configuration for commits and PRs.""" - - commit: str | None = Field(None, description='Custom attribution string for commits. Empty string hides attribution.') - pr: str | None = Field(None, description='Custom attribution string for PRs. Empty string hides attribution.') - - class StatusLine(BaseModel): """Status line configuration for custom status display.""" @@ -602,24 +936,6 @@ class Hooks(BaseModel): events: list[HookEvent] = Field(default_factory=lambda: [], description='Hook event configurations') -class Permissions(BaseModel): - """Permissions configuration.""" - - default_mode: Literal['default', 'acceptEdits', 'plan', 'bypassPermissions'] | None = Field( - None, - alias='default-mode', - description='Default permission mode', - ) - allow: list[str] | None = Field(None, description='Explicitly allowed actions') - deny: list[str] | None = Field(None, description='Explicitly denied actions') - ask: list[str] | None = Field(None, description='Actions requiring confirmation') - additional_directories: list[str] | None = Field( - None, - alias='additional-directories', - description='Additional accessible directories', - ) - - class CommandDefaults(BaseModel): """Command launch configuration.""" @@ -682,7 +998,7 @@ def validate_merge_keys(cls, v: list[str] | None) -> list[str] | None: mergeable: frozenset[str] = frozenset({ 'dependencies', 'agents', 'slash-commands', 'rules', 'skills', 'files-to-download', 'hooks', 'mcp-servers', - 'global-config', 'user-settings', 'env-variables', 'os-env-variables', + 'global-config', 'user-settings', 'os-env-variables', }) invalid = [k for k in v if k not in mergeable] if invalid: @@ -749,45 +1065,16 @@ class EnvironmentConfig(BaseModel): description='Files to download during environment setup', ) hooks: Hooks | None = Field(None, description='Hook configurations') - model: str | None = Field(None, description='Model configuration') - env_variables: dict[str, str | None] | None = Field( - None, - alias='env-variables', - description='Environment variables for Claude Code sessions. ' - 'Set value to null to delete the variable.', - ) - permissions: Permissions | None = Field(None, description='Permissions configuration') command_defaults: CommandDefaults | None = Field( None, alias='command-defaults', description='Command launch defaults', ) - company_announcements: list[str] | None = Field( - None, - alias='company-announcements', - description='List of company announcement strings to display to users', - ) - attribution: Attribution | None = Field( - None, - description='Attribution configuration for commits and PRs. Replaces deprecated include-co-authored-by.', - ) status_line: StatusLine | None = Field( None, alias='status-line', description='Status line configuration with script file and optional padding', ) - always_thinking_enabled: bool | None = Field( - None, - alias='always-thinking-enabled', - description='Whether to enable always-on thinking mode for extended reasoning (default: False)', - ) - effort_level: Literal['low', 'medium', 'high', 'xhigh', 'max'] | None = Field( - None, - alias='effort-level', - description='Effort level for adaptive reasoning. Controls how much thinking is allocated based on task complexity. ' - 'The "xhigh" and "max" levels are only available for Opus and Fable models ' - '("xhigh" on Opus 4.7/4.8 and Fable 5; "max" on Opus 4.6+ and Fable 5).', - ) install_nodejs: bool | None = Field( None, alias='install-nodejs', @@ -835,8 +1122,10 @@ class EnvironmentConfig(BaseModel): user_settings: UserSettings | None = Field( None, alias='user-settings', - description='User-level settings written to ~/.claude/settings.json. ' - 'These settings apply across all sessions.', + description='Raw settings.json content (camelCase keys). Written to ' + '~/.claude/settings.json via deep merge in non-isolated mode, or built ' + "into the isolated profile's config.json (delivered via --settings) " + 'when command-names is present.', ) global_config: GlobalConfig | None = Field( None, @@ -943,25 +1232,6 @@ def validate_mcp_servers(cls, v: list[dict[str, Any]] | None) -> list[dict[str, return validated - @field_validator('model') - @classmethod - def validate_model(cls, v: str | None) -> str | None: - """Validate model configuration. - - Accepts any non-empty model identifier string to support - Anthropic models, third-party models, and provider-prefixed - model names (e.g., OpenRouter, AWS Bedrock). - - Returns: - The validated model string. - - Raises: - ValueError: If model is empty or whitespace-only. - """ - if v is not None and not v.strip(): - raise ValueError('model cannot be empty or whitespace-only') - return v - @field_validator('claude_code_version') @classmethod def validate_claude_code_version(cls, v: str | None) -> str | None: @@ -1082,7 +1352,7 @@ def validate_merge_keys(cls, v: list[str] | None) -> list[str] | None: mergeable: frozenset[str] = frozenset({ 'dependencies', 'agents', 'slash-commands', 'rules', 'skills', 'files-to-download', 'hooks', 'mcp-servers', - 'global-config', 'user-settings', 'env-variables', 'os-env-variables', + 'global-config', 'user-settings', 'os-env-variables', }) invalid = [k for k in v if k not in mergeable] if invalid: @@ -1092,43 +1362,6 @@ def validate_merge_keys(cls, v: list[str] | None) -> list[str] | None: ) return v - @field_validator('env_variables') - @classmethod - def validate_env_variables(cls, v: dict[str, str | None] | None) -> dict[str, str | None] | None: - """Validate environment variables configuration. - - Checks that variable names follow the standard pattern - (letters, digits, underscores; must start with letter or underscore) - and that non-null values do not contain null bytes. A null value is - a deletion request and carries no content to check. - - Args: - v: Dictionary of environment variable names to string values, - or None values for deletion requests. - - Returns: - The validated dictionary. - - Raises: - ValueError: If variable names are invalid or values contain null bytes. - """ - if v is None: - return v - - env_var_pattern = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$') - - for name, value in v.items(): - if not env_var_pattern.match(name): - raise ValueError( - f'Invalid environment variable name: {name}. ' - 'Must start with letter or underscore, followed by letters, digits, or underscores.', - ) - - if value is not None and '\x00' in str(value): - raise ValueError(f'Environment variable {name} value cannot contain null bytes') - - return v - @field_validator('os_env_variables') @classmethod def validate_os_env_variables(cls, v: dict[str, str | None] | None) -> dict[str, str | None] | None: @@ -1179,46 +1412,6 @@ def validate_command_names_and_defaults(self) -> 'EnvironmentConfig': return self - @model_validator(mode='after') - def validate_effort_level_model_support(self) -> 'EnvironmentConfig': - """Validate that effort_level 'xhigh' and 'max' are used with a supporting model. - - 'xhigh' is supported on Opus 4.7/4.8 and Fable 5; 'max' is supported on - Opus 4.6+ and Fable 5. The free-form model field cannot resolve which - version an alias points to, so the gate checks for the family substrings - in EXTENDED_EFFORT_MODEL_MARKERS plus the exact alias 'best' (which always - resolves to Fable 5 or the latest Opus model); Claude Code gracefully - downgrades an unsupported level to the highest supported level at runtime - (for example, 'xhigh' runs as 'high' on Opus 4.6). - - Returns: - The validated EnvironmentConfig instance. - - Raises: - ValueError: If 'xhigh'/'max' is set without a model or with a model - outside the Opus and Fable families. - """ - if self.effort_level not in ('xhigh', 'max'): - return self - - if self.model is None: - raise ValueError( - f"effort-level '{self.effort_level}' requires model to be specified. " - f"The '{self.effort_level}' effort level is only available for Opus and Fable models.", - ) - - model_lower = self.model.lower() - # The 'best' alias is matched exactly, not as a substring, so arbitrary - # model names that merely contain 'best' are not accepted. - if model_lower != 'best' and not any(marker in model_lower for marker in EXTENDED_EFFORT_MODEL_MARKERS): - raise ValueError( - f"effort-level '{self.effort_level}' is only available for Opus and Fable models, " - f"but model is set to '{self.model}'. " - "Use 'low', 'medium', or 'high' for other models.", - ) - - return self - @model_validator(mode='after') def validate_version_requires_command_names(self) -> 'EnvironmentConfig': """Validate that version requires command-names to be present. diff --git a/scripts/setup_environment.py b/scripts/setup_environment.py index c212fb2..bc3abc6 100644 --- a/scripts/setup_environment.py +++ b/scripts/setup_environment.py @@ -74,7 +74,6 @@ 'mcp-servers', 'global-config', 'user-settings', - 'env-variables', 'os-env-variables', }) @@ -100,7 +99,11 @@ MIN_NODE_VERSION = '18.0.0' NODE_LTS_API = 'https://nodejs.org/dist/index.json' -# All valid top-level configuration keys for unknown key detection +# All valid top-level configuration keys for unknown key detection. +# Claude Code settings.json content is declared via 'user-settings' and +# ~/.claude.json content via 'global-config'; only keys that require +# toolbox-side processing (status-line, hooks, os-env-variables) or that +# drive the setup process itself live at the YAML root level. KNOWN_CONFIG_KEYS: frozenset[str] = frozenset({ 'name', 'version', @@ -121,17 +124,10 @@ 'global-config', 'hooks', 'mcp-servers', - 'model', - 'permissions', 'post-install-notes', - 'env-variables', 'os-env-variables', 'command-defaults', 'user-settings', - 'always-thinking-enabled', - 'effort-level', - 'company-announcements', - 'attribution', 'status-line', }) @@ -195,72 +191,116 @@ 'oauthAccount', }) -# Mapping from root-level YAML keys to their user-settings equivalents -# Used for conflict detection between profile settings and user settings -# Root keys use kebab-case, user-settings uses camelCase (matching JSON schema) -ROOT_TO_USER_SETTINGS_KEY_MAP: dict[str, str] = { - 'model': 'model', # Same in both - 'permissions': 'permissions', # Same in both - 'attribution': 'attribution', # Same in both +# Model family markers whose presence (case-insensitive substring) in the model +# identifier indicates support for the extended effort levels: 'xhigh' is +# supported on Opus 4.7/4.8 and Fable 5; 'max' on Opus 4.6+, Sonnet 4.6+, and +# Fable 5. Substring matching covers aliases ('opus', 'fable'), full model IDs +# ('claude-fable-5'), and provider-prefixed IDs ('us.anthropic.claude-opus-4-8'). +# Intentionally identical to scripts/models/environment_config.py (parity-tested). +XHIGH_EFFORT_MODEL_MARKERS: tuple[str, ...] = ('opus', 'fable') +MAX_EFFORT_MODEL_MARKERS: tuple[str, ...] = ('opus', 'fable', 'sonnet') + +# Valid values for the settings.json effortLevel key +EFFORT_LEVEL_VALUES: frozenset[str] = frozenset({'low', 'medium', 'high', 'xhigh', 'max'}) + +# Valid values for the settings.json permissions.defaultMode key. +# 'delegate' appears in the published JSON schema but not in the prose +# documentation; it is accepted to avoid rejecting valid configurations. +PERMISSIONS_DEFAULT_MODE_VALUES: frozenset[str] = frozenset({ + 'default', + 'acceptEdits', + 'plan', + 'auto', + 'dontAsk', + 'bypassPermissions', + 'delegate', +}) + +# user-settings is raw settings.json content and uses camelCase keys. +# These kebab-case spellings are common mistakes carried over from the +# root-level YAML naming convention; each maps to its camelCase correction. +USER_SETTINGS_KEBAB_KEY_CORRECTIONS: dict[str, str] = { 'always-thinking-enabled': 'alwaysThinkingEnabled', 'company-announcements': 'companyAnnouncements', - 'env-variables': 'env', # Different names - 'effort-level': 'effortLevel', # Adaptive reasoning effort + 'effort-level': 'effortLevel', + 'env-variables': 'env', } +# Nested permissions keys also use camelCase inside user-settings +PERMISSIONS_KEBAB_KEY_CORRECTIONS: dict[str, str] = { + 'default-mode': 'defaultMode', + 'additional-directories': 'additionalDirectories', +} + +# Root-level YAML keys that are not settings.json keys and therefore +# never valid inside user-settings +USER_SETTINGS_ROOT_ONLY_KEYS: frozenset[str] = frozenset({ + 'status-line', + 'os-env-variables', +}) + +# Keys that live in ~/.claude.json (global-config), not in settings.json; +# declaring them in user-settings would be a silent no-op at runtime +USER_SETTINGS_GLOBAL_ONLY_KEYS: frozenset[str] = frozenset({ + 'autoUpdates', + 'installMethod', + 'autoConnectIde', + 'autoInstallIdeExtension', + 'externalEditorContext', + 'teammateDefaultModel', + 'oauthAccount', +}) + +# Keys that live in settings.json (user-settings), not in ~/.claude.json; +# declaring them in global-config would be a silent no-op at runtime +GLOBAL_CONFIG_SETTINGS_ONLY_KEYS: frozenset[str] = frozenset({ + 'model', + 'permissions', + 'env', + 'attribution', + 'alwaysThinkingEnabled', + 'effortLevel', + 'companyAnnouncements', + 'statusLine', + 'hooks', + 'availableModels', + 'enforceAvailableModels', +}) + +# Environment variable names: letters, digits, underscores; no leading digit +ENV_VAR_NAME_PATTERN: re.Pattern[str] = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$') + # Keys that are owned and written by the profile-settings subsystem. # These keys are extracted from YAML root level and routed via # create_profile_config() (isolated mode -> config.json) or # write_profile_settings_to_settings() (non-isolated mode -> settings.json). +# They require toolbox-side processing (file download, absolute-path command +# construction) and are therefore excluded from the free-form user-settings +# section (USER_SETTINGS_EXCLUDED_KEYS). # -# Both writers are fed by the shared pure builder _build_profile_settings(), -# which performs kebab-to-camel translation. Values below are the POST-TRANSLATION -# camelCase keys as they appear in settings.json / config.json on disk. +# Both writers are fed by the shared pure builder _build_profile_settings(). +# Values below are the on-disk camelCase keys as they appear in +# settings.json / config.json. # # In non-isolated mode, write_profile_settings_to_settings() deep-merges the # builder's delta into the shared ~/.claude/settings.json via # _write_merged_json(), inheriting: deep-merge for nested dicts, array-union -# for permissions.allow/deny/ask, RFC 7396 null-as-delete for top-level and +# for every list at every depth, RFC 7396 null-as-delete for top-level and # nested None values, and preservation for keys omitted from the delta. -# Keys not declared at YAML root level are PRESERVED in -# ~/.claude/settings.json (unchanged by this writer), including any -# prior-run contributions and any keys written by Step 14 -# write_user_settings() under user-settings:. PROFILE_OWNED_KEYS: frozenset[str] = frozenset({ - 'model', - 'permissions', - 'env', - 'attribution', - 'alwaysThinkingEnabled', - 'effortLevel', - 'companyAnnouncements', 'statusLine', 'hooks', }) # Mapping from YAML root kebab-case key names to their on-disk camelCase -# equivalents for the 9 profile-owned keys. Used by main() to build the +# equivalents for the profile-owned keys. Used by main() to build the # profile_config dict passed to _build_profile_settings(): dict membership # encodes the "declared-vs-absent" distinction (which is lost by -# config.get() alone), and a YAML-level `model: null` declaration passes -# through as `profile_config['model'] = None`, which the builder forwards +# config.get() alone), and a YAML-level `hooks: null` declaration passes +# through as `profile_config['hooks'] = None`, which the builder forwards # to the writer so _write_merged_json() can apply RFC 7396 null-as-delete # to the shared settings.json. -# -# The mapping order matches PROFILE_OWNED_KEYS for visual clarity. It is -# SEPARATE from ROOT_TO_USER_SETTINGS_KEY_MAP (which covers 7 keys and -# drives detect_settings_conflicts()): this map intentionally includes -# `status-line` and `hooks` because they are profile-owned keys that -# participate in the null-as-delete contract even though they are -# excluded from the user-settings conflict surface. _YAML_TO_CAMEL_PROFILE_KEYS: dict[str, str] = { - 'model': 'model', - 'permissions': 'permissions', - 'env-variables': 'env', - 'attribution': 'attribution', - 'always-thinking-enabled': 'alwaysThinkingEnabled', - 'effort-level': 'effortLevel', - 'company-announcements': 'companyAnnouncements', 'status-line': 'statusLine', 'hooks': 'hooks', } @@ -707,22 +747,15 @@ class InstallationPlan: ) # Settings - model: str | None = None system_prompt: str | None = None system_prompt_mode: str = 'replace' command_names: list[str] = field(default_factory=lambda: list[str]()) claude_code_version: str | None = None install_nodejs: bool = False skip_install: bool = False - permissions: dict[str, Any] | None = None - env_variables: dict[str, str | None] | None = None os_env_variables: dict[str, Any] | None = None user_settings: dict[str, Any] | None = None global_config: dict[str, Any] | None = None - always_thinking_enabled: bool | None = None - effort_level: str | None = None - company_announcements: list[str] | None = None - attribution: dict[str, str] | None = None status_line: dict[str, Any] | None = None # Security analysis @@ -1787,6 +1820,10 @@ def write_user_settings( - Windows: Expands tilde paths in command keys (apiKeyHelper, awsCredentialExport) - Linux/macOS/WSL: Preserves tildes for runtime resolution by Claude Code + Used in non-isolated mode only: when command-names is present, the + user-settings section is built into the isolated profile's config.json + via create_profile_config() instead. + Args: settings: User settings dict from YAML user-settings section claude_user_dir: Path to ~/.claude directory @@ -1812,32 +1849,159 @@ def write_user_settings( if ok: success(f'Wrote user settings to {settings_file}') - - # Warn about potential WSL path issues - if is_wsl(): - for key in TILDE_EXPANSION_KEYS: - if key in merged and isinstance(merged[key], str): - value = merged[key] - # Check for Windows path patterns (e.g., C:\, D:\) - if re.search(r'[A-Za-z]:\\', value): - warning( - f'WSL detected: {key} contains Windows-style path: {value}. ' - 'This may not work in the Linux environment. ' - 'Consider re-running setup from within WSL.', - ) - break + _warn_wsl_windows_paths(merged) else: warning(f'Failed to write user settings to {settings_file}') return ok -def validate_user_settings(user_settings: dict[str, Any]) -> list[str]: - """Validate user-settings section for excluded keys. +def _validate_effort_level_entry(effort_level: object, model: object) -> list[str]: + """Validate the user-settings effortLevel value and its model support. + + The 'xhigh' level requires an Opus or Fable model; 'max' requires an + Opus, Sonnet, or Fable model. The exact alias 'best' (which resolves to + Fable 5 or the latest Opus model) satisfies both. Claude Code gracefully + downgrades an unsupported level at runtime, but declaring one in the + profile is almost always a configuration mistake, so it is rejected. + + Args: + effort_level: The declared effortLevel value (non-null). + model: The declared user-settings model value, or None when absent. + + Returns: + List of error messages. Empty list if the entry is valid. + """ + if effort_level not in EFFORT_LEVEL_VALUES: + return [ + f'user-settings.effortLevel must be one of ' + f'{sorted(EFFORT_LEVEL_VALUES)}, got {effort_level!r}.', + ] - Checks that the user-settings section does not contain keys that are - not allowed (hooks, statusLine) due to path resolution or profile-specific - behavior issues. + if effort_level not in ('xhigh', 'max'): + return [] + + markers = XHIGH_EFFORT_MODEL_MARKERS if effort_level == 'xhigh' else MAX_EFFORT_MODEL_MARKERS + families = 'Opus and Fable models' if effort_level == 'xhigh' else 'Opus, Sonnet, and Fable models' + + if not isinstance(model, str) or not model.strip(): + return [ + f"user-settings.effortLevel '{effort_level}' requires user-settings.model " + f'to be specified. This effort level is only available for {families}.', + ] + + model_lower = model.lower() + # The 'best' alias is matched exactly, not as a substring, so arbitrary + # model names that merely contain 'best' are not accepted. + if model_lower != 'best' and not any(marker in model_lower for marker in markers): + return [ + f"user-settings.effortLevel '{effort_level}' is only available for " + f"{families}, but model is set to '{model}'. " + "Use 'low', 'medium', or 'high' for other models.", + ] + + return [] + + +def _validate_permissions_entry(permissions: object) -> list[str]: + """Validate the structure of the user-settings permissions value. + + Known sub-keys are checked (camelCase naming, defaultMode enum, list + shapes); unknown sub-keys pass through untouched for forward + compatibility with new Claude Code permissions options. + + Args: + permissions: The declared permissions value (non-null). + + Returns: + List of error messages. Empty list if the value is valid. + """ + if not isinstance(permissions, dict): + return ['user-settings.permissions must be a mapping.'] + + errors: list[str] = [] + permissions_dict = cast(dict[str, Any], permissions) + + errors.extend( + f"user-settings.permissions uses camelCase keys: use '{camel}' instead of '{kebab}'." + for kebab, camel in PERMISSIONS_KEBAB_KEY_CORRECTIONS.items() + if kebab in permissions_dict + ) + + default_mode = permissions_dict.get('defaultMode') + if 'defaultMode' in permissions_dict and default_mode is not None and default_mode not in PERMISSIONS_DEFAULT_MODE_VALUES: + errors.append( + f'user-settings.permissions.defaultMode must be one of ' + f'{sorted(PERMISSIONS_DEFAULT_MODE_VALUES)}, got {default_mode!r}.', + ) + + for list_key in ('allow', 'deny', 'ask', 'additionalDirectories'): + value = permissions_dict.get(list_key) + if list_key in permissions_dict and value is not None: + value_list = cast(list[Any], value) if isinstance(value, list) else None + if value_list is None or any(not isinstance(item, str) for item in value_list): + errors.append(f'user-settings.permissions.{list_key} must be a list of strings.') + + return errors + + +def _validate_env_entry(env: object) -> list[str]: + """Validate the structure of the user-settings env value. + + settings.json requires env to be a mapping of string names to string + values. A null entry value is a deletion request and carries no content + to check. + + Args: + env: The declared env value (non-null). + + Returns: + List of error messages. Empty list if the value is valid. + """ + if not isinstance(env, dict): + return ['user-settings.env must be a mapping of environment variable names to string values.'] + + errors: list[str] = [] + for name, value in cast(dict[Any, Any], env).items(): + if not isinstance(name, str) or not ENV_VAR_NAME_PATTERN.match(name): + errors.append( + f'user-settings.env: invalid environment variable name {name!r}. ' + 'Must start with letter or underscore, followed by letters, digits, or underscores.', + ) + continue + if value is None: + continue + if not isinstance(value, str): + errors.append( + f'user-settings.env.{name} must be a string ' + '(quote the value in YAML) or null to delete the variable.', + ) + elif '\x00' in value: + errors.append(f'user-settings.env.{name} value cannot contain null bytes.') + + return errors + + +def validate_user_settings(user_settings: dict[str, Any]) -> list[str]: + """Validate the user-settings section (excluded keys and known key values). + + user-settings is free-form raw settings.json content: unknown keys pass + through untouched so new Claude Code settings work without a toolbox + update. Known built-in keys, however, are validated fail-fast, because + Claude Code silently ignores malformed or misplaced entries at runtime + and the misconfiguration would otherwise go unnoticed. A null value for + any key is a deletion request and is always allowed. + + Checks: + - 'hooks' and 'statusLine' are excluded (profile-specific, configured + via root-level YAML keys with dedicated download and path resolution). + - Root-level YAML keys ('status-line', 'os-env-variables') are rejected. + - Kebab-case spellings of known camelCase keys are rejected with the + camelCase correction. + - Keys that belong in global-config (~/.claude.json) are rejected. + - Value shapes for model, env, permissions, attribution, + alwaysThinkingEnabled, companyAnnouncements, and effortLevel + (including the effortLevel/model support cross-check). Args: user_settings: Dict from YAML user-settings section. @@ -1851,23 +2015,83 @@ def validate_user_settings(user_settings: dict[str, Any]) -> list[str]: >>> validate_user_settings({'hooks': {'events': []}}) ["Key 'hooks' is not allowed in user-settings (profile-specific only)"] - - >>> validate_user_settings({'statusLine': {'file': 'script.py'}}) - ["Key 'statusLine' is not allowed in user-settings (profile-specific only)"] """ - return [ + errors: list[str] = [ f"Key '{key}' is not allowed in user-settings (profile-specific only)" for key in user_settings if key in USER_SETTINGS_EXCLUDED_KEYS ] + errors.extend( + f"Key '{key}' is not allowed in user-settings. " + 'It is a root-level YAML key, not a settings.json key.' + for key in sorted(USER_SETTINGS_ROOT_ONLY_KEYS & set(user_settings)) + ) + + errors.extend( + f"Key '{kebab}' is not a settings.json key. " + f"user-settings holds raw settings.json content with camelCase keys: use '{camel}' instead." + for kebab, camel in USER_SETTINGS_KEBAB_KEY_CORRECTIONS.items() + if kebab in user_settings + ) + + errors.extend( + f"Key '{key}' belongs in global-config (~/.claude.json), " + 'not in user-settings (settings.json).' + for key in sorted(USER_SETTINGS_GLOBAL_ONLY_KEYS & set(user_settings)) + ) + + model = user_settings.get('model') + if 'model' in user_settings and model is not None and (not isinstance(model, str) or not model.strip()): + errors.append('user-settings.model must be a non-empty string.') + + env = user_settings.get('env') + if 'env' in user_settings and env is not None: + errors.extend(_validate_env_entry(env)) + + permissions = user_settings.get('permissions') + if 'permissions' in user_settings and permissions is not None: + errors.extend(_validate_permissions_entry(permissions)) + + attribution = user_settings.get('attribution') + if 'attribution' in user_settings and attribution is not None: + if not isinstance(attribution, dict): + errors.append('user-settings.attribution must be a mapping.') + else: + attribution_dict = cast(dict[str, Any], attribution) + for sub in ('commit', 'pr'): + value = attribution_dict.get(sub) + if sub in attribution_dict and value is not None and not isinstance(value, str): + errors.append( + f'user-settings.attribution.{sub} must be a string ' + '(empty string hides attribution).', + ) + + always_thinking = user_settings.get('alwaysThinkingEnabled') + if 'alwaysThinkingEnabled' in user_settings and always_thinking is not None and not isinstance(always_thinking, bool): + errors.append('user-settings.alwaysThinkingEnabled must be a boolean.') + + announcements = user_settings.get('companyAnnouncements') + if 'companyAnnouncements' in user_settings and announcements is not None: + announcements_list = cast(list[Any], announcements) if isinstance(announcements, list) else None + if announcements_list is None or any(not isinstance(item, str) for item in announcements_list): + errors.append('user-settings.companyAnnouncements must be a list of strings.') + + effort_level = user_settings.get('effortLevel') + if 'effortLevel' in user_settings and effort_level is not None: + errors.extend(_validate_effort_level_entry(effort_level, model)) + + return errors + def validate_global_config(global_config: dict[str, Any]) -> list[str]: - """Validate global-config section for excluded keys. + """Validate the global-config section (excluded keys and key placement). - Checks that the global-config section does not contain non-null OAuth - credential values. Null values are allowed to support clearing - authentication state (e.g., oauthAccount: null). + global-config is free-form ~/.claude.json content: unknown keys pass + through untouched. Non-null OAuth credential values are rejected (null + values are allowed to support clearing authentication state), and known + settings.json keys are rejected because ~/.claude.json is not a settings + file and Claude Code would silently ignore them at runtime. Args: global_config: Dict from YAML global-config section. @@ -1875,13 +2099,28 @@ def validate_global_config(global_config: dict[str, Any]) -> list[str]: Returns: List of error messages. Empty list if validation passes. """ - return [ + errors: list[str] = [ f"Key '{key}' cannot be set to a non-null value in global-config " '(OAuth credentials)' for key in global_config if key in GLOBAL_CONFIG_EXCLUDED_KEYS and global_config[key] is not None ] + for key in sorted(GLOBAL_CONFIG_SETTINGS_ONLY_KEYS & set(global_config)): + if key in ('statusLine', 'hooks'): + root_key = 'status-line' if key == 'statusLine' else 'hooks' + errors.append( + f"Key '{key}' is not valid in global-config (~/.claude.json). " + f"Configure it via the root-level '{root_key}' YAML key.", + ) + else: + errors.append( + f"Key '{key}' is a settings.json key and is not valid in " + 'global-config (~/.claude.json). Move it to user-settings.', + ) + + return errors + def write_global_config( global_config: dict[str, Any], @@ -2057,20 +2296,18 @@ def apply_auto_update_settings( claude_code_version_normalized: str | None, global_config: dict[str, Any] | None, user_settings: dict[str, Any] | None, - env_variables: dict[str, str | None] | None, os_env_variables: dict[str, str | None] | None, ) -> tuple[ dict[str, Any] | None, dict[str, Any] | None, dict[str, str | None] | None, - dict[str, str | None] | None, list[str], list[str], ]: """Apply automatic auto-update settings based on version pinning. When a specific version is pinned, disables auto-updates across all - four available targets. When version is None (latest/absent), removes + three available targets. When version is None (latest/absent), removes auto-injected controls. Operates on in-memory dicts ONLY -- has no knowledge of command-names, @@ -2081,11 +2318,10 @@ def apply_auto_update_settings( claude_code_version_normalized: Pinned version string, or None for latest. global_config: Global config dict (may be None). user_settings: User settings dict (may be None). - env_variables: Environment variables dict (may be None). os_env_variables: OS-level environment variables dict (may be None). Returns: - Tuple of (global_config, user_settings, env_variables, os_env_variables, + Tuple of (global_config, user_settings, os_env_variables, warnings, auto_injected_items). """ warnings_list: list[str] = [] @@ -2093,27 +2329,26 @@ def apply_auto_update_settings( if claude_code_version_normalized is not None: # Pinned version: inject auto-update disable controls - global_config, user_settings, env_variables, os_env_variables = ( + global_config, user_settings, os_env_variables = ( _inject_auto_update_controls( - global_config, user_settings, env_variables, os_env_variables, + global_config, user_settings, os_env_variables, warnings_list, auto_injected, ) ) else: # Latest/absent: preserve user declarations, schedule OS-level cleanup - global_config, user_settings, env_variables, os_env_variables = ( + global_config, user_settings, os_env_variables = ( _remove_auto_update_controls( - global_config, user_settings, env_variables, os_env_variables, + global_config, user_settings, os_env_variables, ) ) - return global_config, user_settings, env_variables, os_env_variables, warnings_list, auto_injected + return global_config, user_settings, os_env_variables, warnings_list, auto_injected def _inject_auto_update_controls( global_config: dict[str, Any] | None, user_settings: dict[str, Any] | None, - env_variables: dict[str, str | None] | None, os_env_variables: dict[str, str | None] | None, warnings_list: list[str], auto_injected: list[str], @@ -2121,9 +2356,8 @@ def _inject_auto_update_controls( dict[str, Any] | None, dict[str, Any] | None, dict[str, str | None] | None, - dict[str, str | None] | None, ]: - """Inject auto-update disable controls into all four target dicts. + """Inject auto-update disable controls into all three target dicts. Injection is gated on key MEMBERSHIP, not on value: an explicit user null (a YAML deletion request, legal in every target) is a user @@ -2131,8 +2365,8 @@ def _inject_auto_update_controls( overwritten. Returns: - Tuple of (global_config, user_settings, env_variables, - os_env_variables) with controls injected into absent keys. + Tuple of (global_config, user_settings, os_env_variables) with + controls injected into absent keys. """ # Target 1: global_config.autoUpdates = False if global_config is None: @@ -2169,22 +2403,7 @@ def _inject_auto_update_controls( f'Respecting user value.', ) - # Target 3: env_variables.DISABLE_AUTOUPDATER = "1" - if env_variables is None: - env_variables = {} - if DISABLE_AUTOUPDATER_KEY not in env_variables: - env_variables[DISABLE_AUTOUPDATER_KEY] = DISABLE_AUTOUPDATER_VALUE - auto_injected.append(f'env-variables.{DISABLE_AUTOUPDATER_KEY}: "{DISABLE_AUTOUPDATER_VALUE}"') - elif str(env_variables[DISABLE_AUTOUPDATER_KEY]) == DISABLE_AUTOUPDATER_VALUE: - pass # Already matches intent - else: - warnings_list.append( - f'User set env-variables.{DISABLE_AUTOUPDATER_KEY} to {env_variables[DISABLE_AUTOUPDATER_KEY]!r} ' - f'(auto-update intent is {DISABLE_AUTOUPDATER_VALUE!r} for pinned version). ' - f'Respecting user value.', - ) - - # Target 4: os_env_variables.DISABLE_AUTOUPDATER = "1" + # Target 3: os_env_variables.DISABLE_AUTOUPDATER = "1" if os_env_variables is None: os_env_variables = {} if DISABLE_AUTOUPDATER_KEY not in os_env_variables: @@ -2199,19 +2418,17 @@ def _inject_auto_update_controls( f'Respecting user value.', ) - return global_config, user_settings, env_variables, os_env_variables + return global_config, user_settings, os_env_variables def _remove_auto_update_controls( global_config: dict[str, Any] | None, user_settings: dict[str, Any] | None, - env_variables: dict[str, str | None] | None, os_env_variables: dict[str, str | None] | None, ) -> tuple[ dict[str, Any] | None, dict[str, Any] | None, dict[str, str | None] | None, - dict[str, str | None] | None, ]: """Handle auto-update controls for an unpinned run. @@ -2228,20 +2445,19 @@ def _remove_auto_update_controls( absent variable is a safe no-op on all platforms. Returns: - Tuple of (global_config, user_settings, env_variables, - os_env_variables) with the OS-level deletion entry scheduled. + Tuple of (global_config, user_settings, os_env_variables) with the + OS-level deletion entry scheduled. """ if os_env_variables is None: os_env_variables = {} if DISABLE_AUTOUPDATER_KEY not in os_env_variables: os_env_variables[DISABLE_AUTOUPDATER_KEY] = None - return global_config, user_settings, env_variables, os_env_variables + return global_config, user_settings, os_env_variables def _collect_user_declared_control_keys( user_settings: dict[str, Any] | None, - env_variables: dict[str, str | None] | None, ) -> frozenset[str]: """Identify which managed control keys the resolved YAML itself declares. @@ -2253,21 +2469,16 @@ def _collect_user_declared_control_keys( Args: user_settings: User settings dict from the YAML user-settings section. - env_variables: Environment variables dict from the YAML env-variables - section. Returns: Frozen set containing each managed control key (DISABLE_AUTOUPDATER, - CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL) declared in user-settings.env or - env-variables. + CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL) declared in user-settings.env. """ declared: set[str] = set() env_section = user_settings.get('env') if user_settings is not None else None for key in (DISABLE_AUTOUPDATER_KEY, IDE_SKIP_AUTO_INSTALL_KEY): if isinstance(env_section, dict) and key in env_section: declared.add(key) - if env_variables is not None and key in env_variables: - declared.add(key) return frozenset(declared) @@ -2299,7 +2510,7 @@ def cleanup_stale_auto_update_controls( is_pinned: Whether a specific Claude Code version is pinned. is_isolated: Whether command-names created an isolated environment. user_declared: Whether the current resolved YAML declares - DISABLE_AUTOUPDATER in user-settings.env or env-variables. + DISABLE_AUTOUPDATER in user-settings.env. """ claude_dir = home_dir / '.claude' @@ -2418,20 +2629,18 @@ def apply_ide_extension_settings( claude_code_version_normalized: str | None, global_config: dict[str, Any] | None, user_settings: dict[str, Any] | None, - env_variables: dict[str, str | None] | None, os_env_variables: dict[str, str | None] | None, ) -> tuple[ dict[str, Any] | None, dict[str, Any] | None, dict[str, str | None] | None, - dict[str, str | None] | None, list[str], list[str], ]: """Apply automatic IDE extension auto-install settings based on version pinning. When a specific version is pinned, disables IDE extension auto-installation - across all four available targets. When version is None (latest/absent), + across all three available targets. When version is None (latest/absent), removes auto-injected controls. Operates on in-memory dicts ONLY -- has no knowledge of command-names, @@ -2442,11 +2651,10 @@ def apply_ide_extension_settings( claude_code_version_normalized: Pinned version string, or None for latest. global_config: Global config dict (may be None). user_settings: User settings dict (may be None). - env_variables: Environment variables dict (may be None). os_env_variables: OS-level environment variables dict (may be None). Returns: - Tuple of (global_config, user_settings, env_variables, os_env_variables, + Tuple of (global_config, user_settings, os_env_variables, warnings, auto_injected_items). """ warnings_list: list[str] = [] @@ -2454,27 +2662,26 @@ def apply_ide_extension_settings( if claude_code_version_normalized is not None: # Pinned version: inject IDE extension auto-install disable controls - global_config, user_settings, env_variables, os_env_variables = ( + global_config, user_settings, os_env_variables = ( _inject_ide_extension_controls( - global_config, user_settings, env_variables, os_env_variables, + global_config, user_settings, os_env_variables, warnings_list, auto_injected, ) ) else: # Latest/absent: preserve user declarations, schedule OS-level cleanup - global_config, user_settings, env_variables, os_env_variables = ( + global_config, user_settings, os_env_variables = ( _remove_ide_extension_controls( - global_config, user_settings, env_variables, os_env_variables, + global_config, user_settings, os_env_variables, ) ) - return global_config, user_settings, env_variables, os_env_variables, warnings_list, auto_injected + return global_config, user_settings, os_env_variables, warnings_list, auto_injected def _inject_ide_extension_controls( global_config: dict[str, Any] | None, user_settings: dict[str, Any] | None, - env_variables: dict[str, str | None] | None, os_env_variables: dict[str, str | None] | None, warnings_list: list[str], auto_injected: list[str], @@ -2482,9 +2689,8 @@ def _inject_ide_extension_controls( dict[str, Any] | None, dict[str, Any] | None, dict[str, str | None] | None, - dict[str, str | None] | None, ]: - """Inject IDE extension auto-install disable controls into all four target dicts. + """Inject IDE extension auto-install disable controls into all three target dicts. Injection is gated on key MEMBERSHIP, not on value: an explicit user null (a YAML deletion request, legal in every target) is a user @@ -2492,8 +2698,8 @@ def _inject_ide_extension_controls( overwritten. Returns: - Tuple of (global_config, user_settings, env_variables, - os_env_variables) with controls injected into absent keys. + Tuple of (global_config, user_settings, os_env_variables) with + controls injected into absent keys. """ # Target 1: global_config.autoInstallIdeExtension = False if global_config is None: @@ -2530,22 +2736,7 @@ def _inject_ide_extension_controls( f'Respecting user value.', ) - # Target 3: env_variables.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL = "1" - if env_variables is None: - env_variables = {} - if IDE_SKIP_AUTO_INSTALL_KEY not in env_variables: - env_variables[IDE_SKIP_AUTO_INSTALL_KEY] = IDE_SKIP_AUTO_INSTALL_VALUE - auto_injected.append(f'env-variables.{IDE_SKIP_AUTO_INSTALL_KEY}: "{IDE_SKIP_AUTO_INSTALL_VALUE}"') - elif str(env_variables[IDE_SKIP_AUTO_INSTALL_KEY]) == IDE_SKIP_AUTO_INSTALL_VALUE: - pass # Already matches intent - else: - warnings_list.append( - f'User set env-variables.{IDE_SKIP_AUTO_INSTALL_KEY} to {env_variables[IDE_SKIP_AUTO_INSTALL_KEY]!r} ' - f'(auto-install intent is {IDE_SKIP_AUTO_INSTALL_VALUE!r} for pinned version). ' - f'Respecting user value.', - ) - - # Target 4: os_env_variables.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL = "1" + # Target 3: os_env_variables.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL = "1" if os_env_variables is None: os_env_variables = {} if IDE_SKIP_AUTO_INSTALL_KEY not in os_env_variables: @@ -2560,19 +2751,17 @@ def _inject_ide_extension_controls( f'Respecting user value.', ) - return global_config, user_settings, env_variables, os_env_variables + return global_config, user_settings, os_env_variables def _remove_ide_extension_controls( global_config: dict[str, Any] | None, user_settings: dict[str, Any] | None, - env_variables: dict[str, str | None] | None, os_env_variables: dict[str, str | None] | None, ) -> tuple[ dict[str, Any] | None, dict[str, Any] | None, dict[str, str | None] | None, - dict[str, str | None] | None, ]: """Handle IDE extension auto-install controls for an unpinned run. @@ -2589,15 +2778,15 @@ def _remove_ide_extension_controls( Deleting an absent variable is a safe no-op on all platforms. Returns: - Tuple of (global_config, user_settings, env_variables, - os_env_variables) with the OS-level deletion entry scheduled. + Tuple of (global_config, user_settings, os_env_variables) with the + OS-level deletion entry scheduled. """ if os_env_variables is None: os_env_variables = {} if IDE_SKIP_AUTO_INSTALL_KEY not in os_env_variables: os_env_variables[IDE_SKIP_AUTO_INSTALL_KEY] = None - return global_config, user_settings, env_variables, os_env_variables + return global_config, user_settings, os_env_variables def _cleanup_settings_json_ide_skip(settings_path: Path) -> None: @@ -2673,8 +2862,7 @@ def cleanup_stale_ide_extension_controls( is_pinned: Whether a specific Claude Code version is pinned. is_isolated: Whether command-names created an isolated environment. user_declared: Whether the current resolved YAML declares - CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL in user-settings.env or - env-variables. + CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL in user-settings.env. """ claude_dir = home_dir / '.claude' @@ -3050,59 +3238,6 @@ def install_ide_extensions( Path(temp_vsix_path).unlink(missing_ok=True) -def detect_settings_conflicts( - user_settings: dict[str, Any], - root_config: dict[str, Any], -) -> list[tuple[str, Any, Any]]: - """Detect conflicts where same setting appears in both sections. - - Identifies keys that are specified in both the user-settings section - and at the root level of the config. When using a profile command, - root-level values take precedence over user-settings values. - - Args: - user_settings: Dict from YAML user-settings section. - root_config: The full root-level config dict. - - Returns: - List of (user_settings_key, user_value, root_value) tuples for conflicts. - Empty list if no conflicts found. - - Examples: - >>> user = {'model': 'claude-opus-4'} - >>> root = {'model': 'claude-sonnet-4', 'command-names': ['myenv']} - >>> detect_settings_conflicts(user, root) - [('model', 'claude-opus-4', 'claude-sonnet-4')] - - >>> user = {'alwaysThinkingEnabled': True} - >>> root = {'always-thinking-enabled': False} - >>> detect_settings_conflicts(user, root) - [('alwaysThinkingEnabled', True, False)] - - >>> user = {'language': 'russian'} - >>> root = {'model': 'claude-opus-4'} - >>> detect_settings_conflicts(user, root) - [] - """ - conflicts: list[tuple[str, Any, Any]] = [] - - # Build reverse mapping: user-settings key -> root key - user_to_root_map: dict[str, str] = { - v: k for k, v in ROOT_TO_USER_SETTINGS_KEY_MAP.items() - } - - for user_key, user_value in user_settings.items(): - # Find corresponding root key (may be same or different name) - root_key = user_to_root_map.get(user_key, user_key) - - # Check if this key exists at root level - if root_key in root_config: - root_value = root_config[root_key] - conflicts.append((user_key, user_value, root_value)) - - return conflicts - - def build_platform_aware_command(command: str) -> list[str]: """Build command list with platform-appropriate wrapping. @@ -5321,8 +5456,8 @@ def _merge_config_key( c_us = cast(dict[str, Any], child_value) if isinstance(child_value, dict) else {} return deep_merge_settings(p_us, c_us, array_union_keys=DEFAULT_ARRAY_UNION_KEYS) - # Env-variables and os-env-variables: shallow dict merge, null deletes - if key in ('env-variables', 'os-env-variables'): + # OS-level environment variables: shallow dict merge, null deletes + if key == 'os-env-variables': p_env = cast(dict[str, str | None], parent_value) if isinstance(parent_value, dict) else {} c_env = cast(dict[str, str | None], child_value) if isinstance(child_value, dict) else {} merged_env: dict[str, str | None] = dict(p_env) @@ -5601,7 +5736,7 @@ def resolve_config_inheritance( >>> # resolved contains parent's keys + child's 'name' override >>> # List inheritance (flat composition) - >>> child = {'inherit': ['base.yaml', 'ext.yaml'], 'model': 'opus'} + >>> child = {'inherit': ['base.yaml', 'ext.yaml'], 'name': 'Leaf'} >>> resolved, chain = resolve_config_inheritance(child, 'child.yaml') >>> # entries composed left-to-right, each entry's own inherit stripped """ @@ -5881,22 +6016,15 @@ def collect_installation_plan( hooks_events=hooks_events, mcp_servers=mcp_servers, dependency_commands=dependency_commands, - model=config.get('model'), system_prompt=system_prompt, system_prompt_mode=system_prompt_mode, command_names=command_names, claude_code_version=config.get('claude-code-version'), install_nodejs=bool(config.get('install-nodejs')), skip_install=args.skip_install, - permissions=config.get('permissions'), - env_variables=config.get('env-variables'), os_env_variables=config.get('os-env-variables'), user_settings=config.get('user-settings'), global_config=config.get('global-config'), - always_thinking_enabled=config.get('always-thinking-enabled'), - effort_level=config.get('effort-level'), - company_announcements=config.get('company-announcements'), - attribution=config.get('attribution'), status_line=config.get('status-line'), unknown_keys=unknown_keys, sensitive_paths=sensitive_paths, @@ -5995,29 +6123,10 @@ def _print(*args: object) -> None: # Settings settings_items: list[str] = [] - if plan.model: - settings_items.append(f'Model: {plan.model}') if plan.system_prompt: settings_items.append(f'System prompt: {plan.system_prompt_mode}') - if plan.permissions: - perm_parts: list[str] = [] - if 'default-mode' in plan.permissions: - perm_parts.append(f"default-mode={plan.permissions['default-mode']}") - if 'allow' in plan.permissions: - perm_parts.append(f"{len(plan.permissions['allow'])} allow") - if 'deny' in plan.permissions: - perm_parts.append(f"{len(plan.permissions['deny'])} deny") - if 'ask' in plan.permissions: - perm_parts.append(f"{len(plan.permissions['ask'])} ask") - settings_items.append(f"Permissions: {', '.join(perm_parts)}") - if plan.env_variables: - settings_items.append(f'Environment variables: {len(plan.env_variables)}') if plan.os_env_variables: settings_items.append(f'OS environment variables: {len(plan.os_env_variables)}') - if plan.effort_level: - settings_items.append(f'Effort level: {plan.effort_level}') - if plan.always_thinking_enabled is not None: - settings_items.append(f'Always thinking: {plan.always_thinking_enabled}') if plan.user_settings: null_keys = [k for k, v in plan.user_settings.items() if v is None] set_keys = [k for k, v in plan.user_settings.items() if v is not None] @@ -6042,8 +6151,6 @@ def _print(*args: object) -> None: settings_items.extend( f' {Colors.RED}[DELETE]{Colors.NC} {k}' for k in null_keys ) - if plan.company_announcements: - settings_items.append(f'Company announcements: {len(plan.company_announcements)}') if plan.command_names: settings_items.append(f"Command names: {', '.join(plan.command_names)}") @@ -6756,8 +6863,8 @@ def generate_env_loader_files( """Generate shell-specific env loader files for OS environment variables. Creates Rustup-pattern env files that can be sourced by launchers - and users. Contains ONLY os-env-variables (NOT env-variables, - which are handled by Claude Code's config.json env key). + and users. Contains ONLY os-env-variables (NOT user-settings.env, + which is handled by Claude Code's settings-file env key). Per-command files (when command_names provided): ~/.claude/{cmd}/env.sh (Bash/Zsh) @@ -9556,14 +9663,12 @@ def _build_profile_settings( ) -> dict[str, Any]: """Build profile settings dict from a per-key profile_config dict (pure, zero I/O). - The ``profile_config`` parameter is a dict whose keys are the nine - camelCase on-disk names (``model``, ``permissions``, ``env``, - ``attribution``, ``alwaysThinkingEnabled``, ``effortLevel``, - ``companyAnnouncements``, ``statusLine``, ``hooks``). Dict MEMBERSHIP - encodes the YAML declaration state (present-with-value, present-with-null, - or absent) which is intentionally preserved end-to-end so that the - downstream writer can apply RFC 7396 null-as-delete to the shared - ``settings.json`` for top-level YAML nulls. + The ``profile_config`` parameter is a dict whose keys are the camelCase + on-disk names of the profile-owned keys (``statusLine``, ``hooks``). + Dict MEMBERSHIP encodes the YAML declaration state (present-with-value, + present-with-null, or absent) which is intentionally preserved + end-to-end so that the downstream writer can apply RFC 7396 + null-as-delete to the shared ``settings.json`` for top-level YAML nulls. Three cases per key: @@ -9573,18 +9678,9 @@ def _build_profile_settings( ``settings[key] = None`` verbatim. The writer deletes the on-disk key via RFC 7396 null-as-delete semantics in ``_merge_recursive()``. - **Key present with a non-None value** -- the builder performs the - usual truthy/empty handling (kebab-to-camel translation for - ``permissions``, command-string construction for ``statusLine``, + usual processing (command-string construction for ``statusLine``, ``_build_hooks_json()`` delegation for ``hooks``) and emits the - processed value. Empty dicts or empty lists remain as-is (so an - empty ``attribution`` dict is still stored explicitly). - - Within the ``env`` value, the three-case contract also applies PER KEY: - a non-None entry value is stringified, while an entry value of ``None`` - is preserved verbatim (``settings['env'][key] = None``) so the shared - settings.json writer deletes that nested key via RFC 7396 - null-as-delete and the isolated config.json writer omits it from the - atomic rebuild. + processed value. The ``hooks`` value in ``profile_config`` is either ``None`` (YAML null), absent (YAML omitted), or the full YAML hooks configuration dict with @@ -9594,8 +9690,7 @@ def _build_profile_settings( Args: profile_config: Dict of profile-owned keys to build settings from. Keys use camelCase on-disk names. Values correspond directly to - the YAML-declared values (with pre-translation for ``permissions`` - kebab-case nested keys and pre-processing for ``statusLine`` + the YAML-declared values (with pre-processing for ``statusLine`` file references handled inside this function). hooks_dir: Absolute directory path where downloaded hook files reside. For isolated mode: ``~/.claude/{cmd}/hooks/``. @@ -9608,110 +9703,14 @@ def _build_profile_settings( ``profile_config`` are absent from the result. Examples: - >>> _build_profile_settings({'model': 'sonnet'}, Path('/tmp/hooks')) - {'model': 'sonnet'} - - >>> _build_profile_settings( - ... {'permissions': {'default-mode': 'ask', 'allow': ['Read']}}, - ... Path('/tmp/hooks'), - ... ) - {'permissions': {'defaultMode': 'ask', 'allow': ['Read']}} - >>> _build_profile_settings({}, Path('/tmp/hooks')) {} - >>> _build_profile_settings({'model': None}, Path('/tmp/hooks')) - {'model': None} + >>> _build_profile_settings({'statusLine': None}, Path('/tmp/hooks')) + {'statusLine': None} """ settings: dict[str, Any] = {} - # model - if 'model' in profile_config: - model = profile_config['model'] - if model is None: - settings['model'] = None - info('Deleting model via explicit null') - elif model: - settings['model'] = model - info(f'Setting model: {model}') - - # permissions (kebab-to-camel translation for nested keys) - if 'permissions' in profile_config: - permissions = profile_config['permissions'] - if permissions is None: - settings['permissions'] = None - info('Deleting permissions via explicit null') - elif permissions: - final_permissions = permissions.copy() - if 'default-mode' in final_permissions: - final_permissions['defaultMode'] = final_permissions.pop('default-mode') - if 'additional-directories' in final_permissions: - final_permissions['additionalDirectories'] = final_permissions.pop('additional-directories') - info('Using permissions from environment configuration') - settings['permissions'] = final_permissions - - # env (non-None values stringified; per-key None preserved so the - # downstream writer can delete the key via RFC 7396 null-as-delete) - if 'env' in profile_config: - env = profile_config['env'] - if env is None: - settings['env'] = None - info('Deleting env via explicit null') - elif env: - settings['env'] = {k: (None if v is None else str(v)) for k, v in env.items()} - active_keys = [k for k, v in env.items() if v is not None] - deletion_keys = [k for k, v in env.items() if v is None] - if active_keys: - info(f'Setting {len(active_keys)} environment variables') - for key in active_keys: - info(f' - {key}') - if deletion_keys: - info(f'Deleting {len(deletion_keys)} environment variable(s) via explicit null') - for key in deletion_keys: - info(f' - {key}') - - # attribution (empty dict is explicit and kept) - if 'attribution' in profile_config: - attribution = profile_config['attribution'] - if attribution is None: - settings['attribution'] = None - info('Deleting attribution via explicit null') - else: - settings['attribution'] = attribution - commit_preview = repr(attribution.get('commit', ''))[:30] - pr_preview = repr(attribution.get('pr', ''))[:30] - info(f'Setting attribution: commit={commit_preview}, pr={pr_preview}') - - # alwaysThinkingEnabled (False is explicit and kept) - if 'alwaysThinkingEnabled' in profile_config: - always_thinking_enabled = profile_config['alwaysThinkingEnabled'] - if always_thinking_enabled is None: - settings['alwaysThinkingEnabled'] = None - info('Deleting alwaysThinkingEnabled via explicit null') - else: - settings['alwaysThinkingEnabled'] = always_thinking_enabled - info(f'Setting alwaysThinkingEnabled: {always_thinking_enabled}') - - # effortLevel - if 'effortLevel' in profile_config: - effort_level = profile_config['effortLevel'] - if effort_level is None: - settings['effortLevel'] = None - info('Deleting effortLevel via explicit null') - else: - settings['effortLevel'] = effort_level - info(f'Setting effortLevel: {effort_level}') - - # companyAnnouncements - if 'companyAnnouncements' in profile_config: - company_announcements = profile_config['companyAnnouncements'] - if company_announcements is None: - settings['companyAnnouncements'] = None - info('Deleting companyAnnouncements via explicit null') - else: - settings['companyAnnouncements'] = company_announcements - info(f'Setting companyAnnouncements: {len(company_announcements)} announcement(s)') - # statusLine (command-string construction) if 'statusLine' in profile_config: status_line = profile_config['statusLine'] @@ -9790,40 +9789,108 @@ def _build_profile_settings( return settings +def _strip_null_values(settings: dict[str, Any]) -> dict[str, Any]: + """Recursively remove dict members whose value is None. + + Implements deletion-by-absence for atomically rebuilt JSON files: a + YAML null is a deletion request (RFC 7396 convention), and under atomic + rebuild semantics absence expresses deletion, so the literal JSON null + is never written. A nested dict whose members are ALL deletion requests + is itself dropped (an env section containing only null entries carries + no content, so the emptied key is omitted rather than written as an + empty object); a dict the user declared empty passes through unchanged. + Only dict members are stripped; None elements inside lists are + preserved (a null in an array is data, not a deletion request, + matching RFC 7396 which processes object members only). + + Args: + settings: Dict to strip (not modified). + + Returns: + New dict without None-valued members at any nesting depth. + """ + result: dict[str, Any] = {} + for key, value in settings.items(): + if value is None: + continue + if isinstance(value, dict): + value_dict = cast(dict[str, Any], value) + stripped = _strip_null_values(value_dict) + if stripped or not value_dict: + result[key] = stripped + else: + result[key] = value + return result + + +def _warn_wsl_windows_paths(settings: dict[str, Any]) -> None: + """Warn when WSL is detected and a command key carries a Windows path. + + Tilde paths are preserved on non-Windows platforms, but a settings dict + authored on Windows may contain absolute Windows-style paths (e.g. + C:\\Users\\...) that do not resolve inside the Linux environment. + + Args: + settings: Settings dict to inspect for Windows-style paths. + """ + if not is_wsl(): + return + for key in TILDE_EXPANSION_KEYS: + if key in settings and isinstance(settings[key], str): + value = settings[key] + if re.search(r'[A-Za-z]:\\', value): + warning( + f'WSL detected: {key} contains Windows-style path: {value}. ' + 'This may not work in the Linux environment. ' + 'Consider re-running setup from within WSL.', + ) + break + + def create_profile_config( profile_config: dict[str, Any], config_base_dir: Path, hooks_base_dir: Path | None = None, + user_settings: dict[str, Any] | None = None, ) -> bool: """Create config.json (profile configuration) for the isolated environment. - Delegates to the pure builder _build_profile_settings() and atomically - writes the result to ~/.claude/{cmd}/config.json. This file is always - overwritten on re-run, so YAML removals of keys cleanly propagate to the - isolated profile (isolated-mode atomic rebuild semantics). + The isolated profile's config.json is delivered to Claude Code via the + launcher's --settings flag (command-line settings layer), which outranks + a repository's project settings. It carries the profile's complete + settings.json content: the user-settings section (raw settings.json + keys) plus the toolbox-built statusLine and hooks entries. The two + sources are disjoint by construction because 'statusLine' and 'hooks' + are rejected inside user-settings. + + Delegates to the pure builder _build_profile_settings() for the + profile-owned keys and atomically writes the combined result to + ~/.claude/{cmd}/config.json. This file is always overwritten on re-run, + so YAML removals of keys cleanly propagate to the isolated profile + (isolated-mode atomic rebuild semantics). In isolated mode, the on-disk config.json is fully toolbox-owned: each invocation rebuilds the file from scratch, so the distinction between - "YAML key absent" and "YAML key set to null" is cosmetic -- both cases - produce a config.json without the key (absent keys are omitted by the - builder, and null-valued keys serialize as ``"key": null`` in JSON, - which is equivalent to absent for Claude Code's priority-resolution). - Per-key nulls inside the ``env`` dict are STRIPPED before the write: - under atomic rebuild semantics absence equals deletion, and Claude - Code's treatment of ``"env": {"VAR": null}`` inside ``--settings`` - files is undocumented, so the literal null is never written. - - Args: - profile_config: Dict of profile-owned keys (camelCase on-disk names). - Keys present with values are written; keys present with None are - written as JSON null; keys absent are omitted. For the ``hooks`` - key, the value is the full YAML hooks configuration dict with - ``files`` / ``events`` keys. + "YAML key absent" and "YAML key set to null" collapses -- null-valued + dict members at every depth are STRIPPED before the write, because + under atomic rebuild semantics absence expresses deletion and Claude + Code's treatment of literal nulls inside --settings files is + undocumented. + + Args: + profile_config: Dict of profile-owned keys (camelCase on-disk names: + statusLine, hooks). Keys present with values are written; keys + absent or null-valued are omitted. For the ``hooks`` key, the + value is the full YAML hooks configuration dict with ``files`` / + ``events`` keys. config_base_dir: Path to the isolated environment directory (e.g., ~/.claude/{cmd}/). hooks_base_dir: Optional base directory for hook files. When provided, hook file paths are resolved relative to this directory instead of config_base_dir / 'hooks'. + user_settings: Optional user-settings section (raw settings.json + content with camelCase keys). Tilde paths in command keys are + expanded platform-conditionally before the write. Returns: bool: True if successful, False otherwise. @@ -9833,27 +9900,26 @@ def create_profile_config( info('Creating config.json...') - # Build the profile settings dict via the shared pure builder - settings = _build_profile_settings(profile_config, hooks_dir) - - # Strip per-key env deletions: the isolated config.json is atomically - # rebuilt, so a deleted variable is expressed by absence rather than a - # JSON null whose handling by Claude Code is undocumented. A whole-section - # env null (settings['env'] is None) is kept as-is; top-level nulls are - # documented as equivalent to absent. - env_section = settings.get('env') - if isinstance(env_section, dict): - active_env = {k: v for k, v in env_section.items() if v is not None} - if active_env: - settings['env'] = active_env - else: - del settings['env'] - - # Save settings (always overwrite) - atomic rebuild semantics + # user-settings content forms the base of the profile settings + settings: dict[str, Any] = {} + if user_settings: + expanded_user_settings = _expand_tilde_keys_in_settings(user_settings) + _warn_wsl_windows_paths(expanded_user_settings) + settings.update(_strip_null_values(expanded_user_settings)) + info(f'Including {len(settings)} user-settings key(s) in config.json') + + # Build the profile-owned settings via the shared pure builder + settings.update(_strip_null_values(_build_profile_settings(profile_config, hooks_dir))) + + # Save settings (always overwrite) - atomic rebuild semantics. + # ensure_ascii=False keeps non-ASCII user-settings content readable, + # matching the shared settings.json writer. settings_path = config_base_dir / 'config.json' try: config_base_dir.mkdir(parents=True, exist_ok=True) - settings_path.write_text(json.dumps(settings, indent=2), encoding='utf-8') + settings_path.write_text( + json.dumps(settings, indent=2, ensure_ascii=False), encoding='utf-8', + ) success('Created config.json') return True except Exception as e: @@ -11144,43 +11210,14 @@ def main() -> None: error(f"Invalid mode value: {mode}. Must be 'append' or 'replace'") sys.exit(1) - # Extract model configuration - model = config.get('model') - - # Extract permissions configuration - permissions = config.get('permissions') - - # Extract environment variables configuration - env_variables: dict[str, str | None] | None = config.get('env-variables') - # Extract OS-level environment variables configuration os_env_variables = config.get('os-env-variables') - # Extract company_announcements configuration (still consumed by - # the summary printout below; profile_config is rebuilt from `config` - # directly via _YAML_TO_CAMEL_PROFILE_KEYS in Step 18). - company_announcements = config.get('company-announcements') - # Extract status_line configuration (still consumed by the summary # printout and by download_hook_files() path selection below). status_line = config.get('status-line') - # Extract and validate effort_level configuration. When the value is - # invalid, remove it from the config dict so that the profile_config - # builder (which reads `config` via dict-membership) omits the key - # from both the isolated config.json and the shared settings.json. - effort_level = config.get('effort-level') - if effort_level is not None: - valid_effort_levels = ('low', 'medium', 'high', 'xhigh', 'max') - if effort_level not in valid_effort_levels: - warning( - f'Invalid effort-level value: {effort_level!r}. ' - f'Valid values: {", ".join(valid_effort_levels)}. Skipping.', - ) - effort_level = None - config.pop('effort-level', None) - - # Extract user-settings configuration (global user-level settings) + # Extract user-settings configuration (raw settings.json content) user_settings = config.get('user-settings') # Extract global-config configuration (global Claude Code settings) @@ -11215,14 +11252,13 @@ def main() -> None: # Computed BEFORE injection so auto-injected values are excluded; the # Step 16 unpinned sweep preserves user-declared settings.json keys. user_declared_control_keys = _collect_user_declared_control_keys( - user_settings, env_variables, + user_settings, ) # Apply automatic auto-update settings based on version pinning ( global_config, user_settings, - env_variables, os_env_variables, auto_update_warnings, auto_injected_items, @@ -11230,7 +11266,6 @@ def main() -> None: claude_code_version_normalized, global_config, user_settings, - env_variables, os_env_variables, ) for warn_msg in auto_update_warnings: @@ -11240,7 +11275,6 @@ def main() -> None: ( global_config, user_settings, - env_variables, os_env_variables, ide_ext_warnings, ide_ext_auto_injected, @@ -11248,7 +11282,6 @@ def main() -> None: claude_code_version_normalized, global_config, user_settings, - env_variables, os_env_variables, ) for warn_msg in ide_ext_warnings: @@ -11256,23 +11289,21 @@ def main() -> None: # Merge auto-injected items from both auto-update and IDE extension management auto_injected_items.extend(ide_ext_auto_injected) - # Write the (possibly injected) env-variables dict back into config so - # both Step 18 profile builders -- which read config by dict - # membership -- emit the injected keys even when the YAML lacks the - # section. A YAML-declared `env-variables: null` is likewise - # superseded by the injected dict when a version is pinned. - if env_variables is not None: - config['env-variables'] = env_variables - - # Validate user-settings section for excluded keys + # Validate user-settings section (excluded keys and known key values) if user_settings: user_settings_errors = validate_user_settings(user_settings) if user_settings_errors: for err in user_settings_errors: error(err) sys.exit(1) + if user_settings.get('effortLevel') == 'max': + warning( + "user-settings.effortLevel 'max' may be silently ignored by " + 'Claude Code when loading settings files. To pin the max ' + "effort level reliably, set user-settings.env.CLAUDE_CODE_EFFORT_LEVEL to 'max'.", + ) - # Validate global-config section for excluded keys + # Validate global-config section (excluded keys and key placement) if global_config: global_config_errors = validate_global_config(global_config) if global_config_errors: @@ -11280,27 +11311,6 @@ def main() -> None: error(err) sys.exit(1) - # Detect conflicts between user-settings and root-level settings. - # Warnings fire in BOTH modes (isolated and non-isolated). In non- - # isolated mode, write_profile_settings_to_settings() also applies - # root-level precedence for profile-owned keys via Step 14 / Step 18 - # ordering, so the conflict is meaningful in both branches. - if user_settings: - conflicts = detect_settings_conflicts(user_settings, config) - for user_key, user_value, root_value in conflicts: - warning(f"Key '{user_key}' specified in both root level and user-settings.") - warning(f' user-settings value: {user_value}') - warning(f' root-level value: {root_value}') - warning( - ' Under deep merge semantics, root-level values overwrite ' - 'user-settings values for scalar keys. For dict keys, ' - 'user-settings and root-level values are deep-merged. ' - 'For ALL array-valued keys at any depth, array union with ' - "structural dedupe applies (matching Claude Code CLI's " - 'cross-scope merge: "arrays are concatenated and ' - 'deduplicated, not replaced").', - ) - # Validate: profile-scoped MCP servers require command-names (launcher) # In non-command-names mode, profile-scoped servers have no launcher # to consume --mcp-config, so they would be silently dropped. This is @@ -11410,8 +11420,13 @@ def main() -> None: artifact_base_dir: Path if primary_command_name: - # Check if user explicitly set CLAUDE_CONFIG_DIR in env-variables - user_config_dir = env_variables.get('CLAUDE_CONFIG_DIR') if env_variables else None + # Check if user explicitly set CLAUDE_CONFIG_DIR in user-settings.env + user_env_section = user_settings.get('env') if user_settings else None + user_config_dir = ( + user_env_section.get('CLAUDE_CONFIG_DIR') + if isinstance(user_env_section, dict) + else None + ) if user_config_dir: # User overrides isolation path -- use their value info('Using user-specified CLAUDE_CONFIG_DIR for artifact isolation') @@ -11420,11 +11435,12 @@ def main() -> None: else: isolated_config_dir = Path(user_config_dir) artifact_base_dir = isolated_config_dir - # Remove CLAUDE_CONFIG_DIR from env_variables -- the launcher export - # is the sole authoritative source. Keeping it in config.json env - # section would create a redundant, potentially stale second source. - if env_variables and 'CLAUDE_CONFIG_DIR' in env_variables: - env_variables.pop('CLAUDE_CONFIG_DIR') + # Remove CLAUDE_CONFIG_DIR from user-settings.env -- the launcher + # export is the sole authoritative source. Keeping it in the + # profile's config.json env section would create a redundant, + # potentially stale second source. + if isinstance(user_env_section, dict): + user_env_section.pop('CLAUDE_CONFIG_DIR', None) else: # Auto-compute isolation directory from primary command name isolated_config_dir = claude_user_dir / primary_command_name @@ -11634,17 +11650,22 @@ def main() -> None: ) has_profile_mcp_servers = len(profile_servers) > 0 - # Step 14: Write user settings + # Step 14: Write user settings (non-isolated mode only). In isolated + # mode, the user-settings section is built into the profile's + # config.json at Step 18: the launcher passes config.json via + # --settings (command-line settings layer), so the profile's settings + # outrank a repository's project settings, matching the enforcement + # an isolated environment exists to provide. print() print(f'{Colors.CYAN}Step 14: Writing user settings...{Colors.NC}') - if user_settings: - settings_target_dir = artifact_base_dir if primary_command_name else claude_user_dir - if write_user_settings(user_settings, settings_target_dir): - success('User settings configured successfully') - else: - warning('Failed to write user settings (non-fatal)') - else: + if not user_settings: info('No user settings to configure') + elif primary_command_name: + info('Isolated mode: user settings are built into config.json in Step 18') + elif write_user_settings(user_settings, claude_user_dir): + success('User settings configured successfully') + else: + warning('Failed to write user settings (non-fatal)') # Step 15: Write global config print() @@ -11701,6 +11722,7 @@ def main() -> None: profile_config_isolated, artifact_base_dir, hooks_base_dir=hooks_base_dir_arg, + user_settings=user_settings, ) # Step 19: Write installation manifest @@ -11754,12 +11776,12 @@ def main() -> None: print(f'{Colors.CYAN}Step 22: Linking projects directory to base...{Colors.NC}') link_projects_directory(artifact_base_dir) else: - # No command-names: route all profile-owned YAML keys to the - # shared ~/.claude/settings.json via deep-merge with RFC 7396 - # null-as-delete. This achieves full feature parity for model, - # permissions, env, attribution, alwaysThinkingEnabled, - # effortLevel, companyAnnouncements, statusLine, and hooks - # between command-names present/absent modes. + # No command-names: route the profile-owned YAML keys + # (status-line, hooks) to the shared ~/.claude/settings.json via + # deep-merge with RFC 7396 null-as-delete. The user-settings + # section reaches the same file through Step 14 + # write_user_settings(), so both modes deliver the full + # settings.json content. hooks = config.get('hooks', {}) # Warn about command-defaults.system-prompt without command-names. @@ -11896,8 +11918,6 @@ def main() -> None: print(' * System prompt: appending to default') else: # mode == 'replace' print(' * System prompt: replacing default') - if model: - print(f' * Model: {model}') if mcp_stats['combined_count'] > 0: # Servers with BOTH global AND profile scope profile_only = mcp_stats['profile_count'] - mcp_stats['combined_count'] @@ -11914,24 +11934,6 @@ def main() -> None: f"{mcp_stats['profile_count']} profile-only") else: print(f' * MCP servers: {len(mcp_servers)} configured') - if permissions: - perm_items: list[str] = [] - if 'default-mode' in permissions: - perm_items.append(f"default-mode={permissions['default-mode']}") - if 'allow' in permissions: - perm_items.append(f"{len(permissions['allow'])} allow rules") - if 'deny' in permissions: - perm_items.append(f"{len(permissions['deny'])} deny rules") - if 'ask' in permissions: - perm_items.append(f"{len(permissions['ask'])} ask rules") - if perm_items: - print(f" * Permissions: {', '.join(perm_items)}") - if env_variables: - print(f' * Environment variables: {len(env_variables)} configured') - if company_announcements: - print(f' * Company announcements: {len(company_announcements)} configured') - if effort_level: - print(f' * Effort level: {effort_level}') if status_line and isinstance(status_line, dict): status_line_dict = cast(dict[str, Any], status_line) status_line_file_val = status_line_dict.get('file', '') diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 4e603ea..0d5a16e 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -178,16 +178,12 @@ def golden_config() -> dict[str, Any]: - files-to-download: Additional files with source/dest - hooks: Files and events configuration - mcp-servers: All transport types (http, sse, stdio) - - model: Model configuration - - permissions: default-mode, allow, deny, ask lists - - env-variables: Environment variables for Claude - os-env-variables: OS-level environment variables - command-defaults: System prompt and mode - - user-settings: User settings to merge - - always-thinking-enabled: Thinking mode flag - - effort-level: Adaptive reasoning effort level - - company-announcements: Announcement messages - - attribution: Commit and PR attribution settings + - user-settings: Raw settings.json content (camelCase keys), including + model, permissions, env, alwaysThinkingEnabled, effortLevel, + companyAnnouncements, and attribution + - global-config: Raw ~/.claude.json content - status-line: Status line configuration Returns: diff --git a/tests/e2e/expected/common.py b/tests/e2e/expected/common.py index 21516e1..dd798f7 100644 --- a/tests/e2e/expected/common.py +++ b/tests/e2e/expected/common.py @@ -23,16 +23,22 @@ # Expected keys in generated JSON files EXPECTED_JSON_KEYS: Final[dict[str, list[str]]] = { + # Top-level camelCase keys expected in the isolated config.json: the + # toolbox-built statusLine and hooks entries plus every non-null + # top-level key declared in the golden user-settings section. 'settings': [ + 'language', + 'theme', + 'apiKeyHelper', + 'model', 'permissions', 'env', - 'hooks', - 'model', 'alwaysThinkingEnabled', + 'effortLevel', 'companyAnnouncements', 'attribution', 'statusLine', - 'effortLevel', + 'hooks', ], 'mcp-config': [ 'mcpServers', diff --git a/tests/e2e/fixtures/list_inherit_base.yaml b/tests/e2e/fixtures/list_inherit_base.yaml index 997cc4f..9d5afa3 100644 --- a/tests/e2e/fixtures/list_inherit_base.yaml +++ b/tests/e2e/fixtures/list_inherit_base.yaml @@ -8,6 +8,6 @@ mcp-servers: url: "http://localhost:3000/base" rules: - "rules/base-rule.md" -env-variables: +os-env-variables: BASE_VAR: "base_val" SHARED_VAR: "from_base" diff --git a/tests/e2e/fixtures/list_inherit_base_with_own.yaml b/tests/e2e/fixtures/list_inherit_base_with_own.yaml index e4e6628..bffa77f 100644 --- a/tests/e2e/fixtures/list_inherit_base_with_own.yaml +++ b/tests/e2e/fixtures/list_inherit_base_with_own.yaml @@ -2,4 +2,5 @@ name: "Base With Own Inherit" inherit: "some-deeply-nested-parent.yaml" agents: - "agents/base-own-agent.md" -model: "haiku" +user-settings: + model: "haiku" diff --git a/tests/e2e/fixtures/list_inherit_leaf.yaml b/tests/e2e/fixtures/list_inherit_leaf.yaml index 4a5bdda..fcddc56 100644 --- a/tests/e2e/fixtures/list_inherit_leaf.yaml +++ b/tests/e2e/fixtures/list_inherit_leaf.yaml @@ -4,6 +4,7 @@ inherit: - list_inherit_middle.yaml agents: - "agents/leaf-agent.md" -model: "sonnet" -env-variables: +user-settings: + model: "sonnet" +os-env-variables: LEAF_VAR: "leaf_val" diff --git a/tests/e2e/fixtures/list_inherit_leaf_strip_test.yaml b/tests/e2e/fixtures/list_inherit_leaf_strip_test.yaml index 16c8b2d..2e78e4b 100644 --- a/tests/e2e/fixtures/list_inherit_leaf_strip_test.yaml +++ b/tests/e2e/fixtures/list_inherit_leaf_strip_test.yaml @@ -2,4 +2,5 @@ name: "Strip Test Leaf" inherit: - list_inherit_base_with_own.yaml - list_inherit_middle.yaml -model: "sonnet" +user-settings: + model: "sonnet" diff --git a/tests/e2e/fixtures/list_inherit_middle.yaml b/tests/e2e/fixtures/list_inherit_middle.yaml index 8d13d05..c4113b4 100644 --- a/tests/e2e/fixtures/list_inherit_middle.yaml +++ b/tests/e2e/fixtures/list_inherit_middle.yaml @@ -12,6 +12,6 @@ mcp-servers: url: "http://localhost:3000/middle" rules: - "rules/middle-rule.md" -env-variables: +os-env-variables: MIDDLE_VAR: "middle_val" SHARED_VAR: "from_middle" diff --git a/tests/e2e/fixtures/list_inherit_single.yaml b/tests/e2e/fixtures/list_inherit_single.yaml index d2b229c..946cb8d 100644 --- a/tests/e2e/fixtures/list_inherit_single.yaml +++ b/tests/e2e/fixtures/list_inherit_single.yaml @@ -3,4 +3,5 @@ inherit: - list_inherit_base.yaml agents: - "agents/single-agent.md" -model: "opus" +user-settings: + model: "opus" diff --git a/tests/e2e/fixtures/list_inherit_single_structured.yaml b/tests/e2e/fixtures/list_inherit_single_structured.yaml index d40c3c4..63d817d 100644 --- a/tests/e2e/fixtures/list_inherit_single_structured.yaml +++ b/tests/e2e/fixtures/list_inherit_single_structured.yaml @@ -5,4 +5,5 @@ inherit: - agents agents: - "agents/single-structured-agent.md" -model: "opus" +user-settings: + model: "opus" diff --git a/tests/e2e/fixtures/merge_child.yaml b/tests/e2e/fixtures/merge_child.yaml index 07f9fbd..475bc13 100644 --- a/tests/e2e/fixtures/merge_child.yaml +++ b/tests/e2e/fixtures/merge_child.yaml @@ -10,7 +10,6 @@ merge-keys: - hooks - global-config - user-settings - - env-variables - os-env-variables - skills - files-to-download @@ -55,10 +54,9 @@ user-settings: permissions: allow: - "Write" - -env-variables: - CHILD_VAR: "child_val" - SHARED_VAR: null + env: + CHILD_VAR: "child_val" + SHARED_VAR: null os-env-variables: CHILD_OS: "child_os_val" diff --git a/tests/e2e/fixtures/merge_parent.yaml b/tests/e2e/fixtures/merge_parent.yaml index cfddb30..611dcdb 100644 --- a/tests/e2e/fixtures/merge_parent.yaml +++ b/tests/e2e/fixtures/merge_parent.yaml @@ -37,10 +37,9 @@ user-settings: permissions: allow: - "Read" - -env-variables: - PARENT_VAR: "parent_val" - SHARED_VAR: "from_parent" + env: + PARENT_VAR: "parent_val" + SHARED_VAR: "from_parent" os-env-variables: PARENT_OS: "parent_os_val" diff --git a/tests/e2e/fixtures/pinned_version_config.yaml b/tests/e2e/fixtures/pinned_version_config.yaml index e85da8b..48bd903 100644 --- a/tests/e2e/fixtures/pinned_version_config.yaml +++ b/tests/e2e/fixtures/pinned_version_config.yaml @@ -6,7 +6,5 @@ global-config: editorMode: "vim" user-settings: language: "english" -env-variables: - TEST_VAR: "value" os-env-variables: TEST_OS_VAR: "value" diff --git a/tests/e2e/fixtures/scope_nouser.yaml b/tests/e2e/fixtures/scope_nouser.yaml index 4818a6d..b2292a2 100644 --- a/tests/e2e/fixtures/scope_nouser.yaml +++ b/tests/e2e/fixtures/scope_nouser.yaml @@ -1,4 +1,5 @@ name: "Scope No User Settings" command-names: ["nousercmd"] base-url: "file://./tests/e2e/fixtures/mock_repo" -model: "sonnet" +user-settings: + model: "sonnet" diff --git a/tests/e2e/golden_config.yaml b/tests/e2e/golden_config.yaml index 04ab36a..d71069d 100644 --- a/tests/e2e/golden_config.yaml +++ b/tests/e2e/golden_config.yaml @@ -188,38 +188,6 @@ mcp-servers: transport: "http" url: "http://localhost:3003/combined-api" -# Model configuration -model: "sonnet" - -# Permissions configuration -permissions: - default-mode: "default" - additional-directories: - - "/tmp/e2e-test-dir" - allow: - - "mcp__e2e-http-server" - - "mcp__e2e-http-profile-server" - - "mcp__e2e-sse-server" - - "mcp__e2e-stdio-server" - - "mcp__e2e-npx-server" - - "mcp__e2e-combined-scope-server" - - "Read" - - "Glob" - - "Grep" - deny: - - "Bash(rm -rf)" - ask: - - "Edit" - - "Write" - -# Environment variables for Claude -env-variables: - E2E_TEST_VAR: "test_value" - E2E_ANOTHER_VAR: "another_value" - E2E_PATH_VAR: "~/.claude/e2e-data" - E2E_INT_VAR: 42 - E2E_DELETE_VAR: null # null-as-delete: absent from config.json, deleted from settings.json - # OS-level environment variables os-env-variables: E2E_OS_VAR: "os_test_value" @@ -234,12 +202,56 @@ command-defaults: system-prompt: "prompts/e2e-test-prompt.md" mode: "replace" -# User settings (merged into settings.json) +# User settings (raw settings.json content, camelCase keys). +# In isolated mode this content is built into config.json; in non-isolated +# mode it is deep-merged into ~/.claude/settings.json. user-settings: language: "english" theme: "dark" apiKeyHelper: "uv run --no-project --python 3.12 ~/.claude/scripts/api-key-helper.py" staleSettingToDelete: null # RFC 7396: this key should be ABSENT from output + # Model configuration + model: "sonnet" + # Permissions configuration + permissions: + defaultMode: "default" + additionalDirectories: + - "/tmp/e2e-test-dir" + allow: + - "mcp__e2e-http-server" + - "mcp__e2e-http-profile-server" + - "mcp__e2e-sse-server" + - "mcp__e2e-stdio-server" + - "mcp__e2e-npx-server" + - "mcp__e2e-combined-scope-server" + - "Read" + - "Glob" + - "Grep" + deny: + - "Bash(rm -rf)" + ask: + - "Edit" + - "Write" + # Environment variables for Claude (settings.json env block). + # Non-string scalar values are quoted because env values must be strings. + env: + E2E_TEST_VAR: "test_value" + E2E_ANOTHER_VAR: "another_value" + E2E_PATH_VAR: "~/.claude/e2e-data" + E2E_INT_VAR: "42" + E2E_DELETE_VAR: null # null-as-delete: absent from config.json, deleted from settings.json + # Always-thinking mode + alwaysThinkingEnabled: true + # Effort level for adaptive reasoning + effortLevel: "low" + # Company announcements + companyAnnouncements: + - "Welcome to E2E Testing Environment" + - "This is a test announcement for validation" + # Attribution settings + attribution: + commit: "E2E Test Attribution for Commits" + pr: "E2E Test Attribution for Pull Requests" # Global config (merged into ~/.claude.json) global-config: @@ -250,22 +262,6 @@ global-config: oauthAccount: null staleConfigToDelete: null # RFC 7396: this key should be ABSENT from output -# Always-thinking mode -always-thinking-enabled: true - -# Effort level for adaptive reasoning -effort-level: "low" - -# Company announcements -company-announcements: - - "Welcome to E2E Testing Environment" - - "This is a test announcement for validation" - -# Attribution settings (new format) -attribution: - commit: "E2E Test Attribution for Commits" - pr: "E2E Test Attribution for Pull Requests" - # Status line configuration status-line: file: "hooks/e2e_statusline.py" diff --git a/tests/e2e/golden_config_no_command_names.yaml b/tests/e2e/golden_config_no_command_names.yaml index 0b1860b..b3c15b6 100644 --- a/tests/e2e/golden_config_no_command_names.yaml +++ b/tests/e2e/golden_config_no_command_names.yaml @@ -1,17 +1,18 @@ # E2E Test Environment - No Command-Names Variant # Mirrors golden_config.yaml but with command-names REMOVED. -# Used by tests/e2e/test_profile_settings_routing.py to verify the -# profile-settings routing when command-names is absent (non-isolated -# mode that writes profile-owned keys to the shared settings.json). +# Exercises non-isolated mode, where user-settings is deep-merged into the +# shared ~/.claude/settings.json and the profile-owned keys (status-line, +# hooks) are written into the same file as a delta. # # Differences from golden_config.yaml: # 1. command-names section REMOVED # 2. Profile-scoped MCP servers changed to valid non-profile scopes # (profile scope without command-names triggers validation error, so # we use 'user' scope here for the happy-path coverage). -# 3. All other keys (hooks, permissions, model, env-variables, attribution, -# always-thinking-enabled, company-announcements, status-line, effort-level, -# user-settings, global-config) are identical. +# 3. All other keys (hooks, os-env-variables, status-line, user-settings, +# global-config) are identical. The settings.json content (model, +# permissions, env, attribution, alwaysThinkingEnabled, effortLevel, +# companyAnnouncements) lives inside user-settings. name: "E2E Test Environment (No Command-Names)" @@ -156,35 +157,6 @@ mcp-servers: env: - "E2E_NPX_DEBUG=1" -# Model configuration -model: "sonnet" - -# Permissions configuration -permissions: - default-mode: "default" - additional-directories: - - "/tmp/e2e-test-dir" - allow: - - "mcp__e2e-http-server" - - "mcp__e2e-sse-server" - - "mcp__e2e-npx-server" - - "Read" - - "Glob" - - "Grep" - deny: - - "Bash(rm -rf)" - ask: - - "Edit" - - "Write" - -# Environment variables for Claude -env-variables: - E2E_TEST_VAR: "test_value" - E2E_ANOTHER_VAR: "another_value" - E2E_PATH_VAR: "~/.claude/e2e-data" - E2E_INT_VAR: 42 - E2E_DELETE_VAR: null # null-as-delete: deleted from the shared settings.json env block - # OS-level environment variables os-env-variables: E2E_OS_VAR: "os_test_value" @@ -199,12 +171,52 @@ command-defaults: system-prompt: "prompts/e2e-test-prompt.md" mode: "replace" -# user-settings retains free-form non-profile keys for forward-compat +# User settings (raw settings.json content, camelCase keys). +# In non-isolated mode this content is deep-merged into ~/.claude/settings.json. user-settings: language: "english" theme: "dark" apiKeyHelper: "uv run --no-project --python 3.12 ~/.claude/scripts/api-key-helper.py" staleSettingToDelete: null + # Model configuration + model: "sonnet" + # Permissions configuration + permissions: + defaultMode: "default" + additionalDirectories: + - "/tmp/e2e-test-dir" + allow: + - "mcp__e2e-http-server" + - "mcp__e2e-sse-server" + - "mcp__e2e-npx-server" + - "Read" + - "Glob" + - "Grep" + deny: + - "Bash(rm -rf)" + ask: + - "Edit" + - "Write" + # Environment variables for Claude (settings.json env block). + # Non-string scalar values are quoted because env values must be strings. + env: + E2E_TEST_VAR: "test_value" + E2E_ANOTHER_VAR: "another_value" + E2E_PATH_VAR: "~/.claude/e2e-data" + E2E_INT_VAR: "42" + E2E_DELETE_VAR: null # null-as-delete: deleted from the shared settings.json env block + # Always-thinking mode + alwaysThinkingEnabled: true + # Effort level for adaptive reasoning + effortLevel: "low" + # Company announcements + companyAnnouncements: + - "Welcome to E2E Testing Environment" + - "This is a test announcement for validation" + # Attribution settings + attribution: + commit: "E2E Test Attribution for Commits" + pr: "E2E Test Attribution for Pull Requests" # Global config (merged into ~/.claude.json) global-config: @@ -215,22 +227,6 @@ global-config: oauthAccount: null staleConfigToDelete: null -# Always-thinking mode -always-thinking-enabled: true - -# Effort level for adaptive reasoning -effort-level: "low" - -# Company announcements -company-announcements: - - "Welcome to E2E Testing Environment" - - "This is a test announcement for validation" - -# Attribution settings -attribution: - commit: "E2E Test Attribution for Commits" - pr: "E2E Test Attribution for Pull Requests" - # Status line configuration status-line: file: "hooks/e2e_statusline.py" diff --git a/tests/e2e/test_artifact_isolation.py b/tests/e2e/test_artifact_isolation.py index c8438cb..aadb868 100644 --- a/tests/e2e/test_artifact_isolation.py +++ b/tests/e2e/test_artifact_isolation.py @@ -127,13 +127,15 @@ def test_settings_claude_config_dir_not_injected( hooks_dir = artifact_base / 'hooks' hooks_dir.mkdir(parents=True, exist_ok=True) - # Pass env variables WITHOUT CLAUDE_CONFIG_DIR - env_variables: dict[str, str] = {'MY_VAR': 'test_value'} + # Pass user-settings env WITHOUT CLAUDE_CONFIG_DIR (env lives in + # user-settings; the profile_config carries only statusLine/hooks). + user_settings: dict[str, Any] = {'env': {'MY_VAR': 'test_value'}} result = setup_environment.create_profile_config( - {'hooks': golden_config.get('hooks', {}), 'env': env_variables}, + {'hooks': golden_config.get('hooks', {})}, artifact_base, hooks_base_dir=hooks_dir, + user_settings=user_settings, ) assert result is True @@ -194,22 +196,28 @@ def test_user_override_claude_config_dir( ) -> None: """Verify user-specified CLAUDE_CONFIG_DIR is NOT written to config.json. - Even when user provides CLAUDE_CONFIG_DIR in env-variables, it should - be popped from the env dict (launcher export is the sole source). + Even when the user provides CLAUDE_CONFIG_DIR in user-settings.env, it is + popped before the write (the launcher export is the sole source). """ paths = _run_isolated_setup(e2e_isolated_home, golden_config) artifact_base = paths.artifact_base_dir artifact_base.mkdir(parents=True, exist_ok=True) - # User explicitly sets CLAUDE_CONFIG_DIR along with other vars - user_env = { - 'CLAUDE_CONFIG_DIR': '/custom/user/path', - 'OTHER_VAR': 'keep_this', + # User explicitly sets CLAUDE_CONFIG_DIR along with other vars in + # user-settings.env. main() pops CLAUDE_CONFIG_DIR from this section + # before the isolated write; emulate that here. + user_settings: dict[str, Any] = { + 'env': { + 'CLAUDE_CONFIG_DIR': '/custom/user/path', + 'OTHER_VAR': 'keep_this', + }, } + user_settings['env'].pop('CLAUDE_CONFIG_DIR', None) result = setup_environment.create_profile_config( - {'env': user_env}, + {}, artifact_base, + user_settings=user_settings, ) assert result is True @@ -218,8 +226,9 @@ def test_user_override_claude_config_dir( settings = json.loads(config_file.read_text()) env_block = settings.get('env', {}) - # OTHER_VAR should be present + # OTHER_VAR should be present, CLAUDE_CONFIG_DIR absent assert env_block.get('OTHER_VAR') == 'keep_this' + assert 'CLAUDE_CONFIG_DIR' not in env_block def test_setup_exports_claude_config_dir_for_children( self, diff --git a/tests/e2e/test_auto_update.py b/tests/e2e/test_auto_update.py index fb94629..4cc8189 100644 --- a/tests/e2e/test_auto_update.py +++ b/tests/e2e/test_auto_update.py @@ -1,7 +1,7 @@ """E2E tests for automatic auto-update management. Validates that version pinning correctly injects auto-update disable controls -across all four targets, and that latest/absent versions do not inject controls. +across all three targets, and that latest/absent versions do not inject controls. """ from __future__ import annotations @@ -37,7 +37,6 @@ def test_pinned_version_injects_all_targets( # Extract config sections global_config = config.get('global-config') user_settings = config.get('user-settings') - env_variables = config.get('env-variables') os_env_variables = config.get('os-env-variables') # Normalize version @@ -45,18 +44,16 @@ def test_pinned_version_injects_all_targets( claude_code_version_normalized = None if version_str.lower() == 'latest' else version_str # Apply auto-update settings - gc, us, ev, osev, warns, auto = setup_environment.apply_auto_update_settings( + gc, us, osev, warns, auto = setup_environment.apply_auto_update_settings( claude_code_version_normalized, - global_config, user_settings, env_variables, os_env_variables, + global_config, user_settings, os_env_variables, ) - # Verify all 4 targets injected + # Verify all 3 targets injected assert gc is not None assert gc.get('autoUpdates') is False assert us is not None assert us.get('env', {}).get('DISABLE_AUTOUPDATER') == '1' - assert ev is not None - assert ev.get('DISABLE_AUTOUPDATER') == '1' assert osev is not None assert osev.get('DISABLE_AUTOUPDATER') == '1' @@ -81,15 +78,14 @@ def test_pinned_version_shows_auto_injected_items(self) -> None: version_str = str(config.get('claude-code-version', '')).strip() claude_code_version_normalized = None if version_str.lower() == 'latest' else version_str - _, _, _, _, _, auto = setup_environment.apply_auto_update_settings( + _, _, _, _, auto = setup_environment.apply_auto_update_settings( claude_code_version_normalized, config.get('global-config'), config.get('user-settings'), - config.get('env-variables'), config.get('os-env-variables'), + config.get('os-env-variables'), ) - assert len(auto) == 4 + assert len(auto) == 3 assert any('global-config' in item for item in auto) assert any('user-settings' in item for item in auto) - assert any('env-variables' in item for item in auto) assert any('os-env-variables' in item for item in auto) @@ -103,11 +99,10 @@ def test_latest_version_does_not_inject( version_str = str(version).strip() if version else '' normalized = None if not version_str or version_str.lower() == 'latest' else version_str - gc, us, ev, osev, _, auto = setup_environment.apply_auto_update_settings( + gc, us, osev, _, auto = setup_environment.apply_auto_update_settings( normalized, golden_config.get('global-config'), golden_config.get('user-settings'), - golden_config.get('env-variables'), golden_config.get('os-env-variables'), ) @@ -117,8 +112,8 @@ def test_latest_version_does_not_inject( assert gc.get('autoUpdates') is not False def test_absent_version_does_not_inject(self) -> None: - gc, us, ev, osev, _, auto = setup_environment.apply_auto_update_settings( - None, None, None, None, None, + gc, us, osev, _, auto = setup_environment.apply_auto_update_settings( + None, None, None, None, ) assert not auto @@ -137,7 +132,6 @@ def test_auto_marker_shown_in_plan_display(self) -> None: auto_injected_items=[ 'global-config.autoUpdates: false', 'user-settings.env.DISABLE_AUTOUPDATER: "1"', - 'env-variables.DISABLE_AUTOUPDATER: "1"', 'os-env-variables.DISABLE_AUTOUPDATER: "1"', ], ) @@ -172,8 +166,8 @@ def test_user_autoupdates_true_respected( home = e2e_isolated_home['home'] global_config: dict[str, Any] = {'autoUpdates': True, 'editorMode': 'vim'} - gc, _, _, _, warns, _ = setup_environment.apply_auto_update_settings( - '2.1.85', global_config, {}, {}, {}, + gc, _, _, warns, _ = setup_environment.apply_auto_update_settings( + '2.1.85', global_config, {}, {}, ) assert gc is not None assert gc['autoUpdates'] is True @@ -188,25 +182,25 @@ def test_user_autoupdates_true_respected( class TestPinnedVersionProfileConfig: - """Verify that env_variables injection flows to create_profile_config.""" + """Verify that injected user-settings.env flows to create_profile_config.""" - def test_env_variables_flow_to_profile_config( + def test_user_settings_env_flows_to_profile_config( self, e2e_isolated_home: dict[str, Path], ) -> None: home = e2e_isolated_home['home'] config_dir = home / '.claude' / 'auto-update-test' config_dir.mkdir(parents=True, exist_ok=True) - env_variables: dict[str, str] = {'TEST_VAR': 'value'} + user_settings: dict[str, Any] = {'env': {'TEST_VAR': 'value'}} - # Apply auto-update injection - _, _, ev, _, _, _ = setup_environment.apply_auto_update_settings( - '2.1.85', {}, {}, env_variables, {}, + # Apply auto-update injection into user-settings.env + _, us, _, _, _ = setup_environment.apply_auto_update_settings( + '2.1.85', {}, user_settings, {}, ) - # Create profile config with injected env_variables - assert ev is not None - setup_environment.create_profile_config({'env': ev}, config_dir) + # In isolated mode, the user-settings section is built into config.json + assert us is not None + setup_environment.create_profile_config({}, config_dir, user_settings=us) config_json = config_dir / 'config.json' assert config_json.exists() @@ -232,8 +226,8 @@ def test_unpinned_preserves_user_declared_env_and_sweep_cleans_disk( })) # Apply with no version pin: user-declared controls are preserved - gc, us, ev, osev, warns, _ = setup_environment.apply_auto_update_settings( - None, {}, {'env': {'DISABLE_AUTOUPDATER': '1', 'OTHER_VAR': 'keep'}}, {}, {}, + gc, us, osev, warns, _ = setup_environment.apply_auto_update_settings( + None, {}, {'env': {'DISABLE_AUTOUPDATER': '1', 'OTHER_VAR': 'keep'}}, {}, ) assert us is not None assert us['env']['DISABLE_AUTOUPDATER'] == '1', \ @@ -250,8 +244,8 @@ def test_unpinned_preserves_user_declared_env_and_sweep_cleans_disk( def test_unpinned_schedules_os_level_deletion_when_not_declared(self) -> None: """The OS-level variable gets a deletion entry because it has no disk sweep.""" - _, _, _, osev, _, _ = setup_environment.apply_auto_update_settings( - None, {}, {}, {}, {}, + _, _, osev, _, _ = setup_environment.apply_auto_update_settings( + None, {}, {}, {}, ) assert osev is not None assert osev == {'DISABLE_AUTOUPDATER': None}, \ @@ -304,25 +298,29 @@ class TestPinnedBaseFlowSequence: def test_pinned_non_isolated_sequence_keeps_env_controls( self, e2e_isolated_home: dict[str, Path], ) -> None: - """Step 14 write -> Step 16 cleanup (non-isolated) -> Step 18 keeps both keys.""" + """Step 14 write -> Step 16 cleanup (non-isolated) -> Step 18 keeps both keys. + + In non-isolated mode the injected controls live in user-settings.env + and reach ~/.claude/settings.json via the Step 14 write. Step 18 writes + only the statusLine/hooks delta, so it never touches the env block. + """ home = e2e_isolated_home['home'] claude_dir = home / '.claude' - # YAML with a pinned version and NO env-variables section + # YAML with a pinned version and NO user-settings section config: dict[str, Any] = {'claude-code-version': '2.1.85'} - env_variables = config.get('env-variables') - gc, us, ev, osev, _, _ = setup_environment.apply_auto_update_settings( + gc, us, osev, _, _ = setup_environment.apply_auto_update_settings( '2.1.85', config.get('global-config'), config.get('user-settings'), - env_variables, config.get('os-env-variables'), + config.get('os-env-variables'), ) - gc, us, ev, osev, _, _ = setup_environment.apply_ide_extension_settings( - '2.1.85', gc, us, ev, osev, + gc, us, osev, _, _ = setup_environment.apply_ide_extension_settings( + '2.1.85', gc, us, osev, ) - # main() writes the injected env-variables dict back into config - if ev is not None: - config['env-variables'] = ev + # main() writes the injected user-settings dict back into config + if us is not None: + config['user-settings'] = us # Step 14: write user settings to the base ~/.claude/settings.json assert us is not None @@ -342,6 +340,7 @@ def test_pinned_non_isolated_sequence_keeps_env_controls( 'Pinned non-isolated Step 16 must not delete the run-written control' # Step 18: profile settings delta built from config the way main() does + # (statusLine/hooks only; no env in the profile-owned key map) profile_config: dict[str, Any] = { camel_key: config[yaml_key] for yaml_key, camel_key in setup_environment._YAML_TO_CAMEL_PROFILE_KEYS.items() @@ -356,42 +355,44 @@ def test_pinned_non_isolated_sequence_keeps_env_controls( assert data['env']['CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL'] == '1' -class TestEnvVariablesWriteBack: - """Verify the injected env-variables dict reaches the Step 18 builders.""" +class TestIsolatedInjectedEnvReachesConfigJson: + """Verify injected user-settings.env reaches the isolated config.json.""" def test_isolated_pinned_profile_config_includes_injected_env( self, e2e_isolated_home: dict[str, Path], ) -> None: - """Isolated config.json env carries injected keys when YAML lacks env-variables.""" + """Isolated config.json env carries injected keys when YAML lacks user-settings.""" home = e2e_isolated_home['home'] config_dir = home / '.claude' / 'test-cmd' config_dir.mkdir(parents=True, exist_ok=True) - # YAML with command-names, a pinned version, and NO env-variables + # YAML with command-names, a pinned version, and NO user-settings config: dict[str, Any] = { 'command-names': ['test-cmd'], 'claude-code-version': '2.1.85', } - env_variables = config.get('env-variables') - _, _, ev, _, _, _ = setup_environment.apply_auto_update_settings( - '2.1.85', None, None, env_variables, None, + _, us, _, _, _ = setup_environment.apply_auto_update_settings( + '2.1.85', None, config.get('user-settings'), None, ) - _, _, ev, _, _, _ = setup_environment.apply_ide_extension_settings( - '2.1.85', None, None, ev, None, + _, us, _, _, _ = setup_environment.apply_ide_extension_settings( + '2.1.85', None, us, None, ) - # main() writes the injected dict back into config - if ev is not None: - config['env-variables'] = ev + # main() writes the injected user-settings dict back into config + if us is not None: + config['user-settings'] = us - # Step 18 isolated: profile dict built from config the way main() does + # Step 18 isolated: user-settings is built into config.json; the + # profile-owned keys (statusLine/hooks) are empty here. profile_config: dict[str, Any] = { camel_key: config[yaml_key] for yaml_key, camel_key in setup_environment._YAML_TO_CAMEL_PROFILE_KEYS.items() if yaml_key in config } - setup_environment.create_profile_config(profile_config, config_dir) + setup_environment.create_profile_config( + profile_config, config_dir, user_settings=config.get('user-settings'), + ) data = json.loads((config_dir / 'config.json').read_text()) assert data['env']['DISABLE_AUTOUPDATER'] == '1' @@ -536,8 +537,8 @@ def test_auto_updates_false_in_both_files_when_pinned( artifact_dir = home / '.claude' / 'test-cmd' artifact_dir.mkdir(parents=True, exist_ok=True) - gc, _, _, _, _, _ = setup_environment.apply_auto_update_settings( - '2.1.85', {'editorMode': 'vim'}, {}, {}, {}, + gc, _, _, _, _ = setup_environment.apply_auto_update_settings( + '2.1.85', {'editorMode': 'vim'}, {}, {}, ) assert gc is not None setup_environment.write_global_config(gc, artifact_base_dir=artifact_dir) diff --git a/tests/e2e/test_bug_fixes.py b/tests/e2e/test_bug_fixes.py index 74dc29b..5015424 100644 --- a/tests/e2e/test_bug_fixes.py +++ b/tests/e2e/test_bug_fixes.py @@ -77,12 +77,14 @@ def test_claude_config_dir_only_in_launcher( hooks_dir = artifact_base_dir / 'hooks' hooks_dir.mkdir(parents=True, exist_ok=True) - # Create config with custom env vars (but no CLAUDE_CONFIG_DIR) - env_vars: dict[str, str] = {'MY_VAR': 'val'} + # Create config with custom env vars (but no CLAUDE_CONFIG_DIR). + # env lives in user-settings; profile_config carries hooks/statusLine. + user_settings: dict[str, Any] = {'env': {'MY_VAR': 'val'}} create_profile_config( - {'hooks': golden_config.get('hooks', {}), 'env': env_vars}, + {'hooks': golden_config.get('hooks', {})}, artifact_base_dir, hooks_base_dir=hooks_dir, + user_settings=user_settings, ) # Verify config.json does NOT contain CLAUDE_CONFIG_DIR @@ -114,31 +116,36 @@ def test_user_explicit_claude_config_dir( """Scenario 16: User-explicit CLAUDE_CONFIG_DIR is used for dir, popped from env.""" claude_dir = e2e_isolated_home['claude_dir'] - # Simulate the main() logic for user-supplied CLAUDE_CONFIG_DIR - env_variables: dict[str, str] = { - 'CLAUDE_CONFIG_DIR': str(claude_dir / 'custom-env'), - 'OTHER_VAR': 'keep', + # Simulate the main() logic for a user-supplied CLAUDE_CONFIG_DIR, + # which main() reads from user-settings.env and pops before writes. + user_settings: dict[str, Any] = { + 'env': { + 'CLAUDE_CONFIG_DIR': str(claude_dir / 'custom-env'), + 'OTHER_VAR': 'keep', + }, } + env_section = user_settings['env'] # Step 1: Extract user's custom config dir - user_config_dir = env_variables.get('CLAUDE_CONFIG_DIR') + user_config_dir = env_section.get('CLAUDE_CONFIG_DIR') assert user_config_dir is not None isolated_config_dir = Path(user_config_dir) isolated_config_dir.mkdir(parents=True, exist_ok=True) - # Step 2: Pop CLAUDE_CONFIG_DIR from env before passing to create_profile_config - env_variables.pop('CLAUDE_CONFIG_DIR') - assert 'CLAUDE_CONFIG_DIR' not in env_variables - assert env_variables == {'OTHER_VAR': 'keep'} + # Step 2: Pop CLAUDE_CONFIG_DIR from user-settings.env before the write + env_section.pop('CLAUDE_CONFIG_DIR') + assert 'CLAUDE_CONFIG_DIR' not in env_section + assert env_section == {'OTHER_VAR': 'keep'} # Step 3: Create config -- CLAUDE_CONFIG_DIR must NOT appear in output hooks_dir = isolated_config_dir / 'hooks' hooks_dir.mkdir(parents=True, exist_ok=True) create_profile_config( - {'env': env_variables}, + {}, isolated_config_dir, hooks_base_dir=hooks_dir, + user_settings=user_settings, ) config_path = isolated_config_dir / 'config.json' diff --git a/tests/e2e/test_file_reorganization.py b/tests/e2e/test_file_reorganization.py index cf1fb0f..d9690b5 100644 --- a/tests/e2e/test_file_reorganization.py +++ b/tests/e2e/test_file_reorganization.py @@ -56,11 +56,13 @@ def test_all_files_in_isolated_subdirectory( hooks_dir = artifact_base_dir / 'hooks' hooks_dir.mkdir(parents=True, exist_ok=True) - # Create profile config (config.json) + # Create profile config (config.json): profile-owned hooks plus the + # user-settings content (model lives inside user-settings now). create_profile_config( - {'hooks': golden_config.get('hooks', {}), 'model': golden_config.get('model')}, + {'hooks': golden_config.get('hooks', {})}, artifact_base_dir, hooks_base_dir=hooks_dir, + user_settings=golden_config.get('user-settings'), ) # Create MCP config (mcp.json) @@ -177,7 +179,7 @@ def test_config_json_content_correctness( e2e_isolated_home: dict[str, Path], golden_config: dict[str, Any], ) -> None: - """Scenario 11: config.json contains correct keys and excludes user-settings content.""" + """Scenario 11: config.json merges user-settings content with built statusLine/hooks.""" claude_dir = e2e_isolated_home['claude_dir'] command_names: list[str] = golden_config['command-names'] cmd = command_names[0] @@ -187,36 +189,30 @@ def test_config_json_content_correctness( hooks_dir = artifact_base_dir / 'hooks' hooks_dir.mkdir(parents=True, exist_ok=True) - # Pass raw YAML values through (as main() does): stringification of - # non-null values and stripping of null deletion entries belong to - # the production pipeline, not to the test harness. - env_vars: dict[str, Any] = dict(golden_config.get('env-variables', {})) - + # In isolated mode, config.json is the user-settings content (null + # stripped, tilde expanded) merged with the toolbox-built statusLine + # and hooks. The profile-owned keys carry hooks and status-line; the + # settings.json content (model, permissions, env, ...) is delivered + # through the user_settings parameter. create_profile_config( { 'hooks': golden_config.get('hooks', {}), - 'model': golden_config.get('model'), - 'permissions': golden_config.get('permissions'), - 'env': env_vars, - 'alwaysThinkingEnabled': golden_config.get('always-thinking-enabled'), - 'companyAnnouncements': golden_config.get('company-announcements'), - 'attribution': golden_config.get('attribution'), 'statusLine': golden_config.get('status-line'), - 'effortLevel': golden_config.get('effort-level'), }, artifact_base_dir, hooks_base_dir=hooks_dir, + user_settings=golden_config.get('user-settings'), ) config_path = artifact_base_dir / 'config.json' assert config_path.exists() content = json.loads(config_path.read_text()) - # Verify expected keys present + # Verify expected keys present (from user-settings and built profile keys) assert 'hooks' in content assert 'env' in content assert 'permissions' in content - assert 'model' in content + assert content.get('model') == 'sonnet' assert content.get('alwaysThinkingEnabled') is True assert isinstance(content.get('companyAnnouncements'), list) assert isinstance(content.get('attribution'), dict) @@ -228,16 +224,18 @@ def test_config_json_content_correctness( assert 'CLAUDE_CONFIG_DIR' not in env_block assert env_block.get('E2E_TEST_VAR') == 'test_value' assert env_block.get('E2E_ANOTHER_VAR') == 'another_value' - assert env_block.get('E2E_INT_VAR') == '42' # YAML int -> string conversion + assert env_block.get('E2E_INT_VAR') == '42' # quoted string in user-settings.env # null-as-delete: a null-valued entry is stripped from the atomic # rebuild, never written as JSON null or the literal string 'None' assert 'E2E_DELETE_VAR' not in env_block assert 'None' not in env_block.values() - # Verify user-settings content NOT in config.json - assert 'theme' not in content - assert 'language' not in content - assert 'apiKeyHelper' not in content + # Verify remaining user-settings content IS present in config.json + assert content.get('theme') == 'dark' + assert content.get('language') == 'english' + assert 'apiKeyHelper' in content + # Top-level null user-settings entry is stripped from the rebuild + assert 'staleSettingToDelete' not in content def test_launcher_script_paths_correct( self, diff --git a/tests/e2e/test_full_setup.py b/tests/e2e/test_full_setup.py index 702f394..5a7feb5 100644 --- a/tests/e2e/test_full_setup.py +++ b/tests/e2e/test_full_setup.py @@ -80,20 +80,15 @@ def test_setup_creates_expected_files( artifact_base_dir = claude_dir / cmd artifact_base_dir.mkdir(parents=True, exist_ok=True) - # Create config.json in artifact base dir + # Create config.json in artifact base dir: user-settings content plus + # the profile-owned statusLine/hooks entries. create_profile_config( { 'hooks': golden_config.get('hooks', {}), - 'model': golden_config.get('model'), - 'permissions': golden_config.get('permissions'), - 'env': golden_config.get('env-variables'), - 'alwaysThinkingEnabled': golden_config.get('always-thinking-enabled'), - 'companyAnnouncements': golden_config.get('company-announcements'), - 'attribution': golden_config.get('attribution'), 'statusLine': golden_config.get('status-line'), - 'effortLevel': golden_config.get('effort-level'), }, artifact_base_dir, + user_settings=golden_config.get('user-settings'), ) # Create MCP config file in artifact base dir @@ -143,40 +138,35 @@ def test_create_profile_config_processes_all_keys( e2e_isolated_home: dict[str, Path], golden_config: dict[str, Any], ) -> None: - """Verify create_profile_config writes ALL profile-owned keys to config.json. + """Verify create_profile_config writes user-settings plus statusLine/hooks to config.json. - This test validates the isolated-mode writer: - - model, permissions, env, attribution, alwaysThinkingEnabled, effortLevel, - companyAnnouncements, statusLine, hooks are all present in config.json - (the file written by create_profile_config when command-names is set). + This test validates the isolated-mode writer. The isolated profile's + config.json carries the user-settings section (model, permissions, + env, attribution, alwaysThinkingEnabled, effortLevel, + companyAnnouncements, and every other raw settings.json key) plus the + toolbox-built statusLine and hooks entries. NOTE: This test covers ONLY the isolated mode writer. For the non- command-names mode, where write_profile_settings_to_settings() - writes the same profile-owned keys into the shared settings.json, - see tests/e2e/test_profile_settings_routing.py. + writes the profile-owned statusLine/hooks delta into the shared + settings.json, see tests/e2e/test_profile_settings_routing.py. """ paths = e2e_isolated_home claude_dir = paths['claude_dir'] - # Create settings with ALL config keys + # Build config.json: user-settings content plus statusLine/hooks create_profile_config( { 'hooks': golden_config.get('hooks', {}), - 'model': golden_config.get('model'), - 'permissions': golden_config.get('permissions'), - 'env': golden_config.get('env-variables'), - 'alwaysThinkingEnabled': golden_config.get('always-thinking-enabled'), - 'companyAnnouncements': golden_config.get('company-announcements'), - 'attribution': golden_config.get('attribution'), 'statusLine': golden_config.get('status-line'), - 'effortLevel': golden_config.get('effort-level'), }, claude_dir, + user_settings=golden_config.get('user-settings'), ) - # Verify settings file exists (written to config_base_dir as config.json) + # Verify the file exists (written to config_base_dir as config.json) settings_path = claude_dir / 'config.json' - assert settings_path.exists(), f'settings.json not created: {settings_path}' + assert settings_path.exists(), f'config.json not created: {settings_path}' # Content validation is done in test_output_files.py diff --git a/tests/e2e/test_hooks_settings_routing.py b/tests/e2e/test_hooks_settings_routing.py index bc2f316..55c7aa6 100644 --- a/tests/e2e/test_hooks_settings_routing.py +++ b/tests/e2e/test_hooks_settings_routing.py @@ -87,16 +87,10 @@ def test_hooks_in_config_json_with_command_names( create_profile_config( { 'hooks': hooks, - 'model': golden_config.get('model'), - 'permissions': golden_config.get('permissions'), - 'env': golden_config.get('env-variables'), - 'alwaysThinkingEnabled': golden_config.get('always-thinking-enabled'), - 'companyAnnouncements': golden_config.get('company-announcements'), - 'attribution': golden_config.get('attribution'), 'statusLine': golden_config.get('status-line'), - 'effortLevel': golden_config.get('effort-level'), }, artifact_dir, + user_settings=golden_config.get('user-settings'), ) config_path = artifact_dir / 'config.json' diff --git a/tests/e2e/test_ide_extension.py b/tests/e2e/test_ide_extension.py index 82a3308..ac60248 100644 --- a/tests/e2e/test_ide_extension.py +++ b/tests/e2e/test_ide_extension.py @@ -36,7 +36,6 @@ def test_pinned_version_injects_all_targets( # Extract config sections global_config = config.get('global-config') user_settings = config.get('user-settings') - env_variables = config.get('env-variables') os_env_variables = config.get('os-env-variables') # Normalize version @@ -44,18 +43,16 @@ def test_pinned_version_injects_all_targets( claude_code_version_normalized = None if version_str.lower() == 'latest' else version_str # Apply IDE extension settings - gc, us, ev, osev, warns, auto = setup_environment.apply_ide_extension_settings( + gc, us, osev, warns, auto = setup_environment.apply_ide_extension_settings( claude_code_version_normalized, - global_config, user_settings, env_variables, os_env_variables, + global_config, user_settings, os_env_variables, ) - # Verify all 4 targets injected + # Verify all 3 targets injected assert gc is not None assert gc.get('autoInstallIdeExtension') is False assert us is not None assert us.get('env', {}).get('CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL') == '1' - assert ev is not None - assert ev.get('CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL') == '1' assert osev is not None assert osev.get('CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL') == '1' @@ -80,14 +77,14 @@ class TestLatestVersionNoIdeControls: """Verify that latest/absent version does not inject IDE extension controls.""" def test_latest_version_does_not_inject(self) -> None: - gc, us, ev, osev, _, auto = setup_environment.apply_ide_extension_settings( - None, None, None, None, None, + gc, us, osev, _, auto = setup_environment.apply_ide_extension_settings( + None, None, None, None, ) assert not auto def test_absent_version_does_not_inject(self) -> None: - gc, us, ev, osev, _, auto = setup_environment.apply_ide_extension_settings( - None, None, None, None, None, + gc, us, osev, _, auto = setup_environment.apply_ide_extension_settings( + None, None, None, None, ) assert not auto @@ -100,18 +97,18 @@ def test_auto_marker_items_present(self) -> None: version_str = str(config.get('claude-code-version', '')).strip() claude_code_version_normalized = None if version_str.lower() == 'latest' else version_str - _, _, _, _, _, auto = setup_environment.apply_ide_extension_settings( + _, _, _, _, auto = setup_environment.apply_ide_extension_settings( claude_code_version_normalized, config.get('global-config'), config.get('user-settings'), - config.get('env-variables'), config.get('os-env-variables'), + config.get('os-env-variables'), ) - assert len(auto) == 4 + assert len(auto) == 3 assert any('autoInstallIdeExtension' in item for item in auto) assert any('CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL' in item for item in auto) def test_no_auto_marker_when_latest(self) -> None: - _, _, _, _, _, auto = setup_environment.apply_ide_extension_settings( - None, None, None, None, None, + _, _, _, _, auto = setup_environment.apply_ide_extension_settings( + None, None, None, None, ) assert len(auto) == 0 @@ -121,14 +118,14 @@ class TestIdeUserConflictRespected: def test_user_autoinstall_true_respected(self) -> None: gc = {'autoInstallIdeExtension': True} - gc_out, _, _, _, warns, auto = setup_environment.apply_ide_extension_settings( - '2.1.85', gc, None, None, None, + gc_out, _, _, warns, auto = setup_environment.apply_ide_extension_settings( + '2.1.85', gc, None, None, ) assert gc_out is not None assert gc_out['autoInstallIdeExtension'] is True assert any('Respecting user value' in w for w in warns) # Other targets still get injected - assert len(auto) == 3 # T2, T3, T4 only + assert len(auto) == 2 # user-settings.env and os-env-variables only class TestIdeStaleCleanup: @@ -183,8 +180,8 @@ def test_unpinned_preserves_user_declared_env_and_sweep_cleans_disk( })) # Apply with no version pin: user-declared controls are preserved - gc, us, ev, osev, warns, _ = setup_environment.apply_ide_extension_settings( - None, {}, {'env': {'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL': '1', 'OTHER_VAR': 'keep'}}, {}, {}, + gc, us, osev, warns, _ = setup_environment.apply_ide_extension_settings( + None, {}, {'env': {'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL': '1', 'OTHER_VAR': 'keep'}}, {}, ) assert us is not None assert us['env']['CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL'] == '1', \ @@ -201,8 +198,8 @@ def test_unpinned_preserves_user_declared_env_and_sweep_cleans_disk( def test_unpinned_schedules_os_level_deletion_when_not_declared(self) -> None: """The OS-level variable gets a deletion entry because it has no disk sweep.""" - _, _, _, osev, _, _ = setup_environment.apply_ide_extension_settings( - None, {}, {}, {}, {}, + _, _, osev, _, _ = setup_environment.apply_ide_extension_settings( + None, {}, {}, {}, ) assert osev is not None assert osev == {'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL': None}, \ diff --git a/tests/e2e/test_list_inherit.py b/tests/e2e/test_list_inherit.py index 8069967..9f9135e 100644 --- a/tests/e2e/test_list_inherit.py +++ b/tests/e2e/test_list_inherit.py @@ -79,23 +79,23 @@ def test_left_to_right_priority_mcp_servers(self, fixtures_dir): assert 'middle-server' in server_names assert 'base-server' not in server_names - def test_leaf_replaces_env_variables(self, fixtures_dir): - """Leaf env-variables replaces accumulated (no merge-keys for env-variables).""" + def test_leaf_replaces_os_env_variables(self, fixtures_dir): + """Leaf os-env-variables replaces accumulated (no merge-keys for os-env-variables).""" path = fixtures_dir / 'list_inherit_leaf.yaml' config = _load_yaml(path) resolved, _ = _resolve(config, str(path)) - # Leaf has env-variables without merge-keys, so it replaces entirely - assert resolved['env-variables'] == {'LEAF_VAR': 'leaf_val'} - assert 'BASE_VAR' not in resolved['env-variables'] - assert 'MIDDLE_VAR' not in resolved['env-variables'] + # Leaf has os-env-variables without merge-keys, so it replaces entirely + assert resolved['os-env-variables'] == {'LEAF_VAR': 'leaf_val'} + assert 'BASE_VAR' not in resolved['os-env-variables'] + assert 'MIDDLE_VAR' not in resolved['os-env-variables'] def test_leaf_keys_added(self, fixtures_dir): """Leaf's own keys appear in result.""" path = fixtures_dir / 'list_inherit_leaf.yaml' config = _load_yaml(path) resolved, _ = _resolve(config, str(path)) - assert resolved['model'] == 'sonnet' - assert resolved['env-variables']['LEAF_VAR'] == 'leaf_val' + assert resolved['user-settings']['model'] == 'sonnet' + assert resolved['os-env-variables']['LEAF_VAR'] == 'leaf_val' def test_meta_keys_stripped(self, fixtures_dir): """inherit and merge-keys stripped from final result.""" @@ -244,7 +244,7 @@ def test_single_element_resolves_recursively(self, fixtures_dir): resolved, chain = _resolve(config, str(path)) # Should behave like inherit: list_inherit_base.yaml assert resolved['name'] == 'List Inherit Single' - assert resolved['model'] == 'opus' + assert resolved['user-settings']['model'] == 'opus' def test_single_element_inherits_base_agents(self, fixtures_dir): """Single-element list inherits parent agents (replaced by leaf).""" @@ -269,7 +269,7 @@ def test_single_structured_entry_routes_to_list_path(self, fixtures_dir): # Composition mode: chain has 1 entry (base) assert len(chain) == 1 assert resolved['name'] == 'Single Structured' - assert resolved['model'] == 'opus' + assert resolved['user-settings']['model'] == 'opus' def test_single_structured_merges_agents(self, fixtures_dir): """Single structured entry's merge-keys merges agents with base.""" @@ -449,25 +449,25 @@ def test_chain_names_from_config(self, fixtures_dir): assert chain[1].name == 'List Inherit Middle' -class TestEnvVariableComposition: - """Test that env-variables compose correctly across list entries.""" +class TestOsEnvVariableComposition: + """Test that os-env-variables compose correctly across list entries.""" - def test_leaf_env_vars_replace_accumulated(self, fixtures_dir): - """Leaf env-variables replaces accumulated (no merge-keys for env-variables).""" + def test_leaf_os_env_vars_replace_accumulated(self, fixtures_dir): + """Leaf os-env-variables replaces accumulated (no merge-keys for os-env-variables).""" path = fixtures_dir / 'list_inherit_leaf.yaml' config = _load_yaml(path) resolved, _ = _resolve(config, str(path)) - # Leaf has env-variables without merge-keys, so it replaces entirely - env_vars = resolved['env-variables'] + # Leaf has os-env-variables without merge-keys, so it replaces entirely + env_vars = resolved['os-env-variables'] assert env_vars == {'LEAF_VAR': 'leaf_val'} - def test_env_vars_from_middle_when_no_leaf_env_vars(self, fixtures_dir): - """Middle's env-variables survives when leaf has no env-variables key.""" + def test_os_env_vars_from_middle_when_no_leaf_os_env_vars(self, fixtures_dir): + """Middle's os-env-variables survives when leaf has no os-env-variables key.""" path = fixtures_dir / 'list_inherit_merge_keys_leaf.yaml' config = _load_yaml(path) resolved, _ = _resolve(config, str(path)) - # Middle's own merge-keys is STRIPPED, so middle REPLACES base env-variables - # Leaf has NO env-variables key, so accumulated env-variables survive - env_vars = resolved.get('env-variables', {}) + # Middle's own merge-keys is STRIPPED, so middle REPLACES base os-env-variables + # Leaf has NO os-env-variables key, so accumulated os-env-variables survive + env_vars = resolved.get('os-env-variables', {}) assert env_vars.get('MIDDLE_VAR') == 'middle_val' assert env_vars.get('SHARED_VAR') == 'from_middle' diff --git a/tests/e2e/test_merge_keys.py b/tests/e2e/test_merge_keys.py index 161273b..b8c0dfe 100644 --- a/tests/e2e/test_merge_keys.py +++ b/tests/e2e/test_merge_keys.py @@ -125,12 +125,12 @@ def test_user_settings_deep_merge_with_permission_union(self, fixtures_dir): assert 'Read' in perms assert 'Write' in perms - def test_env_variables_shallow_merge_null_delete(self, fixtures_dir): - """Env-variables shallow merged, null deletes parent key.""" + def test_user_settings_env_deep_merge_null_delete(self, fixtures_dir): + """User-settings.env deep merged, null deletes parent key.""" child_path = fixtures_dir / 'merge_child.yaml' config = _load_yaml(child_path) resolved, _ = _resolve(config, str(child_path)) - env = resolved['env-variables'] + env = resolved['user-settings']['env'] assert env['PARENT_VAR'] == 'parent_val' assert env['CHILD_VAR'] == 'child_val' assert 'SHARED_VAR' not in env diff --git a/tests/e2e/test_output_files.py b/tests/e2e/test_output_files.py index f72be1f..4d4f90f 100644 --- a/tests/e2e/test_output_files.py +++ b/tests/e2e/test_output_files.py @@ -25,34 +25,31 @@ class TestOutputFiles: """Test output file content and structure.""" - def test_settings_json_structure( + def test_config_json_structure( self, e2e_isolated_home: dict[str, Path], golden_config: dict[str, Any], ) -> None: - """Verify settings.json has correct structure and content. + """Verify config.json has correct structure and content. - Uses validate_settings from validators.py. - Checks: model, permissions, env, hooks, alwaysThinkingEnabled, - companyAnnouncements, attribution, statusLine. + The isolated profile's config.json carries the user-settings section + (model, permissions, env, alwaysThinkingEnabled, companyAnnouncements, + attribution, effortLevel, and every other raw settings.json key) plus + the toolbox-built statusLine and hooks entries. Validated via + validate_settings from validators.py. """ paths = e2e_isolated_home claude_dir = paths['claude_dir'] - # Create settings + # Build config.json: user-settings content plus the profile-owned + # statusLine/hooks entries. create_profile_config( { 'hooks': golden_config.get('hooks', {}), - 'model': golden_config.get('model'), - 'permissions': golden_config.get('permissions'), - 'env': golden_config.get('env-variables'), - 'alwaysThinkingEnabled': golden_config.get('always-thinking-enabled'), - 'companyAnnouncements': golden_config.get('company-announcements'), - 'attribution': golden_config.get('attribution'), 'statusLine': golden_config.get('status-line'), - 'effortLevel': golden_config.get('effort-level'), }, claude_dir, + user_settings=golden_config.get('user-settings'), ) # File is written to config_base_dir as config.json @@ -60,34 +57,30 @@ def test_settings_json_structure( # Validate using validators.py errors = validate_settings(settings_path, golden_config) - assert not errors, 'settings.json validation failed:\n' + '\n'.join(errors) + assert not errors, 'config.json validation failed:\n' + '\n'.join(errors) - def test_settings_has_expected_keys( + def test_config_json_has_expected_keys( self, e2e_isolated_home: dict[str, Path], golden_config: dict[str, Any], ) -> None: - """Verify settings.json contains all expected top-level keys. + """Verify config.json contains all expected top-level keys. - Uses EXPECTED_JSON_KEYS['settings'] for reference. + Uses EXPECTED_JSON_KEYS['settings'] for reference: the toolbox-built + statusLine and hooks entries plus every non-null top-level key + declared in the golden user-settings section. """ paths = e2e_isolated_home claude_dir = paths['claude_dir'] - # Create settings + # Build config.json: user-settings content plus statusLine/hooks create_profile_config( { 'hooks': golden_config.get('hooks', {}), - 'model': golden_config.get('model'), - 'permissions': golden_config.get('permissions'), - 'env': golden_config.get('env-variables'), - 'alwaysThinkingEnabled': golden_config.get('always-thinking-enabled'), - 'companyAnnouncements': golden_config.get('company-announcements'), - 'attribution': golden_config.get('attribution'), 'statusLine': golden_config.get('status-line'), - 'effortLevel': golden_config.get('effort-level'), }, claude_dir, + user_settings=golden_config.get('user-settings'), ) # File is written to config_base_dir as config.json @@ -98,7 +91,7 @@ def test_settings_has_expected_keys( expected_keys = EXPECTED_JSON_KEYS['settings'] missing_keys = [k for k in expected_keys if k not in data] - assert not missing_keys, f'Missing expected keys in settings.json: {missing_keys}' + assert not missing_keys, f'Missing expected keys in config.json: {missing_keys}' def test_mcp_json_structure( self, @@ -191,26 +184,31 @@ def test_permissions_structure_complete( e2e_isolated_home: dict[str, Path], golden_config: dict[str, Any], ) -> None: - """Verify permissions in settings has all required arrays. + """Verify permissions in config.json has all required arrays. - Checks: defaultMode, allow, deny, ask arrays present with expected values. + The permissions block lives inside user-settings and is carried into + config.json verbatim (camelCase sub-keys). Checks: defaultMode, allow, + deny, ask arrays present with expected values. """ paths = e2e_isolated_home claude_dir = paths['claude_dir'] - create_profile_config({'permissions': golden_config.get('permissions')}, claude_dir) + user_settings = golden_config.get('user-settings', {}) + expected_permissions = user_settings.get('permissions') + + create_profile_config({}, claude_dir, user_settings=user_settings) # File is written to config_base_dir as config.json settings_path = claude_dir / 'config.json' data = json.loads(settings_path.read_text()) - if 'permissions' in golden_config: + if expected_permissions is not None: assert 'permissions' in data, 'Missing permissions block' expected_perm_keys = EXPECTED_JSON_KEYS['permissions'] for key in expected_perm_keys: - if key in golden_config['permissions']: + if key in expected_permissions: assert key in data['permissions'], f'Missing permissions.{key}' diff --git a/tests/e2e/test_path_handling.py b/tests/e2e/test_path_handling.py index 3a6fc5c..0d9dc60 100644 --- a/tests/e2e/test_path_handling.py +++ b/tests/e2e/test_path_handling.py @@ -98,20 +98,15 @@ def test_settings_paths_expanded( paths = e2e_isolated_home claude_dir = paths['claude_dir'] - # Create settings + # Create settings: profile-owned hooks/statusLine plus the + # settings.json content delivered via the user_settings parameter. create_profile_config( { 'hooks': golden_config.get('hooks', {}), - 'model': golden_config.get('model'), - 'permissions': golden_config.get('permissions'), - 'env': golden_config.get('env-variables'), - 'alwaysThinkingEnabled': golden_config.get('always-thinking-enabled'), - 'companyAnnouncements': golden_config.get('company-announcements'), - 'attribution': golden_config.get('attribution'), 'statusLine': golden_config.get('status-line'), - 'effortLevel': golden_config.get('effort-level'), }, claude_dir, + user_settings=golden_config.get('user-settings'), ) # File is written to claude_user_dir (= claude_dir) diff --git a/tests/e2e/test_profile_settings_routing.py b/tests/e2e/test_profile_settings_routing.py index baf3a99..c3a0aef 100644 --- a/tests/e2e/test_profile_settings_routing.py +++ b/tests/e2e/test_profile_settings_routing.py @@ -1,271 +1,244 @@ -"""E2E tests for profile-settings routing in non-command-names mode. - -Verifies that when `command-names` is absent, all profile-owned keys -(model, permissions, env, attribution, alwaysThinkingEnabled, effortLevel, -companyAnnouncements, statusLine, hooks) are correctly routed to -~/.claude/settings.json via write_profile_settings_to_settings(), which -deep-merges the delta into the existing file: nested dicts are -recursively merged, EVERY list at every depth is unioned with structural -dedupe (matching Claude Code CLI's cross-scope merge: "arrays are -concatenated and deduplicated, not replaced"), RFC 7396 null (both -top-level and nested) deletes keys, and keys not in the delta are -preserved unchanged. - -Test coverage matrix: -- Happy path: all 9 profile-owned keys deep-merged correctly -- Partial config: only subset of keys declared, rest preserved -- Deep-merge preservation: pre-existing settings.json sub-keys survive -- Step 14/18 interaction: user-settings contributions preserved -- Array union at every depth: permissions.allow/deny/ask, - permissions.additionalDirectories, companyAnnouncements, - hooks., and every other list-valued key accumulate - across runs -- Conflict detection: warnings fire in non-command-names mode -- Top-level null-as-delete: model/permissions/env/hooks/... all removable via null -- Nested null-as-delete: permissions.deny=None removes just the deny sub-key -- Re-invocation across configurations: a second invocation updates values - written by the first invocation when keys are re-declared -- Stale-key preservation: when an invocation omits a key already on disk, - the existing value is left in place -- Profile-scoped MCP validation: exit 1 with 4-option message -- System-prompt warning: warning emitted, setup continues -- Empty profile delta: settings.json untouched when no profile keys +"""E2E tests for settings routing in non-command-names (non-isolated) mode. + +When ``command-names`` is absent, the toolbox writes to the shared +``~/.claude/settings.json`` in two passes: + +- Step 14 (``write_user_settings``) deep-merges the raw ``user-settings`` + section (model, permissions, env, and every other settings.json key) into + ``~/.claude/settings.json`` with universal array union and RFC 7396 + null-as-delete. +- Step 18 (``write_profile_settings_to_settings``) applies the profile-owned + delta -- only ``statusLine`` and ``hooks`` -- into the same file via the + same deep-merge helper. + +Both passes preserve every key outside their own delta, so the two +contributions coexist in one file. The profile-owned key set is exactly +``{'statusLine', 'hooks'}`` because those two keys require dedicated write +logic (path resolution and per-event processing) and are rejected inside +``user-settings``; every other settings.json key is expressed directly in +``user-settings``. + +Test coverage: +- ``_build_profile_settings`` statusLine/hooks behavior, including explicit + nulls and the empty/absent cases. +- Step 18 writes ONLY the statusLine/hooks delta and preserves unrelated + on-disk keys; top-level null deletes statusLine/hooks. +- Step 14 user-settings deep-merge: array union, RFC 7396 null-as-delete, + and env sub-dict merge. +- ``golden_config_no_command_names.yaml`` end-to-end: user-settings content + plus statusLine and hooks all land in ``~/.claude/settings.json``. +- Auto-update Target ``user-settings.env`` survival across the Step 14 then + Step 18 ordering. """ from __future__ import annotations import json from pathlib import Path -from typing import TYPE_CHECKING from typing import Any -from unittest.mock import patch import pytest import yaml from scripts.setup_environment import PROFILE_OWNED_KEYS from scripts.setup_environment import _build_profile_settings -from scripts.setup_environment import detect_settings_conflicts from scripts.setup_environment import write_profile_settings_to_settings - -if TYPE_CHECKING: - from unittest.mock import MagicMock - +from scripts.setup_environment import write_user_settings # --------------------------------------------------------------------------- -# Test Class 1: Deep-Merge Writer Filesystem Semantics +# Test Class 1: _build_profile_settings statusLine/hooks Behavior # --------------------------------------------------------------------------- -class TestDeepMergeWriterFilesystem: - """Filesystem-level tests of write_profile_settings_to_settings(). +class TestBuildProfileSettings: + """Verify the pure builder emits only the two profile-owned keys. - Verifies that the shared settings.json writer applies deep-merge via - delegation to _write_merged_json() with the universal union-all-arrays - default (array_union_keys=None): nested dicts are recursively merged, - EVERY array at every depth is unioned with structural dedupe, - RFC 7396 null-as-delete works for both top-level keys and nested - sub-keys, and keys not present in the delta are preserved unchanged. - - Matches Claude Code CLI's documented cross-scope merge semantics: - "Array settings merge across scopes. When the same array-valued - setting appears in multiple scopes, the arrays are concatenated and - deduplicated, not replaced." + ``_build_profile_settings`` is I/O-free. It maps a per-key + ``profile_config`` dict (camelCase names) to a settings delta, handling + exactly ``statusLine`` and ``hooks``. Dict membership encodes the YAML + declaration state: absent keys are omitted, keys present with ``None`` + propagate as ``None`` for downstream null-as-delete. """ - def test_happy_path_all_nine_keys(self, tmp_path: Path) -> None: - """All nine PROFILE_OWNED_KEYS routed to settings.json correctly.""" + def test_profile_owned_keys_is_status_line_and_hooks(self) -> None: + """PROFILE_OWNED_KEYS is exactly {statusLine, hooks}.""" + assert frozenset({'statusLine', 'hooks'}) == PROFILE_OWNED_KEYS + + def test_empty_profile_config_yields_empty_delta(self, tmp_path: Path) -> None: + """No profile-owned keys declared -> empty delta.""" + assert _build_profile_settings({}, tmp_path / 'hooks') == {} + + def test_non_profile_keys_are_ignored(self, tmp_path: Path) -> None: + """Keys other than statusLine/hooks are not emitted by the builder.""" + delta = _build_profile_settings( + {'model': 'sonnet', 'permissions': {'allow': ['Read']}, 'env': {'A': 'b'}}, + tmp_path / 'hooks', + ) + assert delta == {} + + def test_status_line_built_into_command(self, tmp_path: Path) -> None: + """A statusLine file reference becomes a command-string entry.""" + hooks_dir = tmp_path / 'hooks' + hooks_dir.mkdir() + delta = _build_profile_settings( + {'statusLine': {'file': 'status.py', 'padding': 0}}, + hooks_dir, + ) + assert set(delta.keys()) == {'statusLine'} + assert delta['statusLine']['type'] == 'command' + assert 'uv run' in delta['statusLine']['command'] + assert (hooks_dir / 'status.py').as_posix() in delta['statusLine']['command'] + assert delta['statusLine']['padding'] == 0 + + def test_hooks_built_into_event_structure(self, tmp_path: Path) -> None: + """A hooks config with events becomes an event-keyed structure.""" hooks_dir = tmp_path / 'hooks' hooks_dir.mkdir() - delta = _build_profile_settings( { - 'hooks': {'events': [{'event': 'PreToolUse', 'matcher': 'Bash', - 'type': 'command', 'command': 'test.sh'}]}, - 'model': 'sonnet', - 'permissions': {'allow': ['Read'], 'deny': ['Bash(rm -rf)']}, - 'env': {'FOO': 'bar'}, - 'alwaysThinkingEnabled': True, - 'companyAnnouncements': ['Welcome'], - 'attribution': {'commit': 'cm', 'pr': 'pr'}, - 'statusLine': {'file': 'status.py'}, - 'effortLevel': 'high', + 'hooks': { + 'events': [ + { + 'event': 'PreToolUse', 'matcher': 'Bash', + 'type': 'command', 'command': 'a.sh', + }, + ], + }, }, hooks_dir, ) - - write_profile_settings_to_settings(delta, tmp_path) - content = json.loads((tmp_path / 'settings.json').read_text(encoding='utf-8')) - assert set(content.keys()) == PROFILE_OWNED_KEYS - - def test_partial_delta_only_specified_keys_written(self, tmp_path: Path) -> None: - """YAML with only model and permissions writes only those two keys.""" + assert set(delta.keys()) == {'hooks'} + assert 'PreToolUse' in delta['hooks'] + + def test_explicit_null_status_line_propagates(self, tmp_path: Path) -> None: + """statusLine present with None propagates as None for null-as-delete.""" + delta = _build_profile_settings({'statusLine': None}, tmp_path / 'hooks') + assert delta == {'statusLine': None} + + def test_explicit_null_hooks_propagates(self, tmp_path: Path) -> None: + """hooks present with None propagates as None for null-as-delete.""" + delta = _build_profile_settings({'hooks': None}, tmp_path / 'hooks') + assert delta == {'hooks': None} + + def test_empty_hooks_dict_omitted(self, tmp_path: Path) -> None: + """An empty hooks dict (no events) is omitted from the delta.""" + delta = _build_profile_settings({'hooks': {}}, tmp_path / 'hooks') + assert delta == {} + + def test_both_keys_together(self, tmp_path: Path) -> None: + """statusLine and hooks together yield both entries.""" + hooks_dir = tmp_path / 'hooks' + hooks_dir.mkdir() delta = _build_profile_settings( - {'model': 'sonnet', 'permissions': {'allow': ['Read']}}, - tmp_path / 'hooks', + { + 'statusLine': {'file': 'status.py'}, + 'hooks': { + 'events': [ + { + 'event': 'PreToolUse', 'matcher': 'Bash', + 'type': 'command', 'command': 'a.sh', + }, + ], + }, + }, + hooks_dir, ) - write_profile_settings_to_settings(delta, tmp_path) - content = json.loads((tmp_path / 'settings.json').read_text(encoding='utf-8')) - assert set(content.keys()) == {'model', 'permissions'} + assert set(delta.keys()) == {'statusLine', 'hooks'} - def test_unrelated_keys_preserved(self, tmp_path: Path) -> None: - """Non-profile keys in existing settings.json are preserved.""" - settings_file = tmp_path / 'settings.json' - settings_file.write_text(json.dumps({ - 'language': 'english', - 'includeGitInstructions': False, - 'apiKeyHelper': '/tmp/key.sh', - 'cleanupPeriodDays': 30, - }), encoding='utf-8') - delta = _build_profile_settings({'model': 'sonnet'}, tmp_path / 'hooks') - write_profile_settings_to_settings(delta, tmp_path) - content = json.loads(settings_file.read_text(encoding='utf-8')) - assert content['language'] == 'english' - assert content['includeGitInstructions'] is False - assert content['apiKeyHelper'] == '/tmp/key.sh' - assert content['cleanupPeriodDays'] == 30 - assert content['model'] == 'sonnet' +# --------------------------------------------------------------------------- +# Test Class 2: Step 18 Writes ONLY the statusLine/hooks Delta +# --------------------------------------------------------------------------- - def test_omitted_profile_key_preserved_when_yaml_does_not_declare_it( - self, tmp_path: Path, - ) -> None: - """A profile key that the new delta does not declare survives unchanged. - The shared settings.json is a user-facing file, so the writer must - not silently scrub profile keys already on disk when the current - configuration omits them. To delete a key, the user must declare it - explicitly as None in the delta or remove it manually. - """ - settings_file = tmp_path / 'settings.json' - settings_file.write_text(json.dumps({ - 'model': 'sonnet', - 'permissions': {'allow': ['Read']}, - 'effortLevel': 'high', - }), encoding='utf-8') +class TestProfileDeltaWriter: + """Filesystem tests of write_profile_settings_to_settings(). - # New invocation declares ONLY model (permissions and effortLevel omitted) - delta = _build_profile_settings({'model': 'opus'}, tmp_path / 'hooks') - write_profile_settings_to_settings(delta, tmp_path) - content = json.loads(settings_file.read_text(encoding='utf-8')) + Step 18 deep-merges the statusLine/hooks delta into + ~/.claude/settings.json via the shared _write_merged_json() helper: + unrelated keys are preserved, nested dicts merge, every array unions, + and RFC 7396 null deletes keys. + """ - # Model updated - assert content['model'] == 'opus' - # Omitted keys PRESERVED unchanged - assert content['permissions'] == {'allow': ['Read']} - assert content['effortLevel'] == 'high' + def test_empty_delta_does_not_create_file(self, tmp_path: Path) -> None: + """Empty delta -> no file created.""" + write_profile_settings_to_settings({}, tmp_path) + assert not (tmp_path / 'settings.json').exists() - def test_explicit_null_deletes_key(self, tmp_path: Path) -> None: - """Explicit None value in delta deletes the key from settings.json.""" + def test_empty_delta_does_not_modify_existing(self, tmp_path: Path) -> None: + """Empty delta -> existing settings.json unchanged.""" settings_file = tmp_path / 'settings.json' - settings_file.write_text(json.dumps({ - 'model': 'sonnet', - 'permissions': {'allow': ['Read']}, - }), encoding='utf-8') + original = {'language': 'english', 'model': 'sonnet'} + settings_file.write_text(json.dumps(original), encoding='utf-8') - write_profile_settings_to_settings({'model': None}, tmp_path) - content = json.loads(settings_file.read_text(encoding='utf-8')) - assert 'model' not in content - # Unrelated profile key PRESERVED - assert content['permissions'] == {'allow': ['Read']} - - def test_deep_merge_preserves_unrelated_permissions_subkeys(self, tmp_path: Path) -> None: - """Deep-merge preserves permissions sub-keys not declared in the delta. - - When the delta carries a partial permissions dict (e.g., only 'allow'), - existing sub-keys ('deny', 'ask') declared by other contributors must - be preserved. This is the headline security guarantee: a narrower - permissions: {allow: [Read]} YAML declaration MUST NOT destroy - permissions.deny entries set by prior runs, user manual edits, or the - Claude Code CLI itself. The 'allow' sub-key is unioned via - DEFAULT_ARRAY_UNION_KEYS, so existing and new allow entries accumulate. - """ - settings_file = tmp_path / 'settings.json' - settings_file.write_text(json.dumps({ - 'permissions': { - 'allow': ['Read', 'Write', 'Glob'], - 'deny': ['Bash(rm -rf)'], - 'ask': ['Edit'], - }, - }), encoding='utf-8') + write_profile_settings_to_settings({}, tmp_path) - # YAML declares a narrower permissions dict (only 'allow') - delta: dict[str, Any] = {'permissions': {'allow': ['Grep']}} - write_profile_settings_to_settings(delta, tmp_path) content = json.loads(settings_file.read_text(encoding='utf-8')) + assert content == original - # 'allow' array-unioned (order-preserving, deduped) - assert set(content['permissions']['allow']) == {'Read', 'Write', 'Glob', 'Grep'} - # 'deny' and 'ask' PRESERVED intact - assert content['permissions']['deny'] == ['Bash(rm -rf)'] - assert content['permissions']['ask'] == ['Edit'] - - def test_permissions_deny_preserved_across_yaml_runs(self, tmp_path: Path) -> None: - """Security guarantee: permissions.deny entries accumulate across runs. - - Flagship security test. A pre-existing settings.json with enterprise - deny rules is updated by a narrower YAML declaration; the deny rules - must survive and accumulate rather than being silently destroyed. - """ - settings_file = tmp_path / 'settings.json' - settings_file.write_text(json.dumps({ - 'permissions': {'deny': ['Bash(rm -rf *)', 'Bash(sudo *)']}, - }), encoding='utf-8') - - # First YAML run adds allow entries - delta1 = _build_profile_settings( - {'permissions': {'allow': ['Read']}}, tmp_path / 'hooks', - ) - write_profile_settings_to_settings(delta1, tmp_path) - - # Second YAML run adds an additional deny rule - delta2 = _build_profile_settings( - {'permissions': {'deny': ['Bash(curl *)']}}, tmp_path / 'hooks', + def test_only_status_line_and_hooks_written(self, tmp_path: Path) -> None: + """The written keys are exactly the profile-owned delta keys.""" + hooks_dir = tmp_path / 'hooks' + hooks_dir.mkdir() + delta = _build_profile_settings( + { + 'statusLine': {'file': 'status.py'}, + 'hooks': { + 'events': [ + { + 'event': 'PreToolUse', 'matcher': 'Bash', + 'type': 'command', 'command': 'a.sh', + }, + ], + }, + }, + hooks_dir, ) - write_profile_settings_to_settings(delta2, tmp_path) - - content = json.loads(settings_file.read_text(encoding='utf-8')) - # All 3 deny rules present, union preserved - assert set(content['permissions']['deny']) == { - 'Bash(rm -rf *)', 'Bash(sudo *)', 'Bash(curl *)', - } - # 'allow' from first run still present - assert content['permissions']['allow'] == ['Read'] + write_profile_settings_to_settings(delta, tmp_path) + content = json.loads((tmp_path / 'settings.json').read_text(encoding='utf-8')) + assert set(content.keys()) == {'statusLine', 'hooks'} - def test_env_deep_merge_preserves_auto_update_injection(self, tmp_path: Path) -> None: - """env dict deep-merges, preserving Target 2 auto-update injection. + def test_unrelated_keys_preserved(self, tmp_path: Path) -> None: + """User-settings keys written by Step 14 survive the Step 18 delta write. - Pre-populate with DISABLE_AUTOUPDATER (the state after Step 14 runs - with an auto-update-pinned YAML). Write a delta with a new env key. - Both keys must be present in the result. + Step 14 leaves model/permissions/env/language in settings.json. The + Step 18 profile delta only carries statusLine, so every user-settings + key must remain intact. """ settings_file = tmp_path / 'settings.json' settings_file.write_text(json.dumps({ - 'env': {'DISABLE_AUTOUPDATER': '1'}, + 'model': 'sonnet', + 'permissions': {'allow': ['Read'], 'deny': ['Bash(rm -rf)']}, + 'env': {'FOO': 'bar'}, + 'language': 'english', }), encoding='utf-8') - delta = _build_profile_settings( - {'env': {'MY_VAR': 'x'}}, tmp_path / 'hooks', - ) + hooks_dir = tmp_path / 'hooks' + hooks_dir.mkdir() + delta = _build_profile_settings({'statusLine': {'file': 'status.py'}}, hooks_dir) write_profile_settings_to_settings(delta, tmp_path) + content = json.loads(settings_file.read_text(encoding='utf-8')) - # Both keys present - assert content['env']['DISABLE_AUTOUPDATER'] == '1' - assert content['env']['MY_VAR'] == 'x' + assert content['model'] == 'sonnet' + assert content['permissions'] == {'allow': ['Read'], 'deny': ['Bash(rm -rf)']} + assert content['env'] == {'FOO': 'bar'} + assert content['language'] == 'english' + assert content['statusLine']['type'] == 'command' - def test_top_level_null_permissions_deletes_block(self, tmp_path: Path) -> None: - """Top-level None for permissions deletes the entire permissions block.""" + def test_top_level_null_status_line_deletes_key(self, tmp_path: Path) -> None: + """Top-level None for statusLine deletes only that key.""" settings_file = tmp_path / 'settings.json' settings_file.write_text(json.dumps({ - 'permissions': {'allow': ['Read'], 'deny': ['Bash']}, + 'statusLine': {'type': 'command', 'command': 'x'}, 'model': 'sonnet', }), encoding='utf-8') - write_profile_settings_to_settings({'permissions': None}, tmp_path) + write_profile_settings_to_settings({'statusLine': None}, tmp_path) content = json.loads(settings_file.read_text(encoding='utf-8')) - assert content == {'model': 'sonnet'} + assert 'statusLine' not in content + assert content['model'] == 'sonnet' - def test_top_level_null_hooks_deletes_block(self, tmp_path: Path) -> None: - """Top-level None for hooks deletes the entire hooks block.""" + def test_top_level_null_hooks_deletes_key(self, tmp_path: Path) -> None: + """Top-level None for hooks deletes only that key.""" settings_file = tmp_path / 'settings.json' settings_file.write_text(json.dumps({ 'hooks': {'PreToolUse': [{'matcher': '', 'hooks': []}]}, @@ -274,75 +247,33 @@ def test_top_level_null_hooks_deletes_block(self, tmp_path: Path) -> None: write_profile_settings_to_settings({'hooks': None}, tmp_path) content = json.loads(settings_file.read_text(encoding='utf-8')) - assert content == {'model': 'sonnet'} - - def test_top_level_null_all_profile_keys_deletes_all(self, tmp_path: Path) -> None: - """All nine profile-owned keys set to None deletes them all.""" - settings_file = tmp_path / 'settings.json' - settings_file.write_text(json.dumps({ - 'model': 'sonnet', - 'permissions': {'allow': ['Read']}, - 'env': {'FOO': 'bar'}, - 'attribution': {'commit': 'x'}, - 'alwaysThinkingEnabled': True, - 'effortLevel': 'high', - 'companyAnnouncements': ['msg'], - 'statusLine': {'type': 'command', 'command': 'a'}, - 'hooks': {'PreToolUse': []}, - 'language': 'english', # Unrelated key, should be preserved - }), encoding='utf-8') - - delta: dict[str, Any] = dict.fromkeys(PROFILE_OWNED_KEYS) - write_profile_settings_to_settings(delta, tmp_path) - content = json.loads(settings_file.read_text(encoding='utf-8')) - # All nine profile-owned keys deleted; unrelated key preserved - assert content == {'language': 'english'} - - def test_nested_null_permissions_deny_only_deletes_sub_key( - self, tmp_path: Path, - ) -> None: - """Nested None (permissions.deny) deletes only the deny sub-key.""" - settings_file = tmp_path / 'settings.json' - settings_file.write_text(json.dumps({ - 'permissions': {'allow': ['Read'], 'deny': ['Bash']}, - }), encoding='utf-8') - - write_profile_settings_to_settings( - {'permissions': {'deny': None}}, tmp_path, - ) - content = json.loads(settings_file.read_text(encoding='utf-8')) - # 'allow' preserved, 'deny' deleted - assert content['permissions'] == {'allow': ['Read']} - - def test_company_announcements_preserved_across_runs(self, tmp_path: Path) -> None: - """companyAnnouncements unions across two Step 18 filesystem writes.""" - hooks_dir = tmp_path / 'hooks' - hooks_dir.mkdir() - delta_a = _build_profile_settings({'companyAnnouncements': ['Welcome']}, hooks_dir) - write_profile_settings_to_settings(delta_a, tmp_path) - delta_b = _build_profile_settings({'companyAnnouncements': ['Maintenance']}, hooks_dir) - write_profile_settings_to_settings(delta_b, tmp_path) - content = json.loads((tmp_path / 'settings.json').read_text(encoding='utf-8')) - assert content['companyAnnouncements'] == ['Welcome', 'Maintenance'] + assert 'hooks' not in content + assert content['model'] == 'sonnet' - def test_permissions_additional_directories_preserved(self, tmp_path: Path) -> None: - """permissions.additionalDirectories unions across two Step 18 writes.""" + def test_explicit_null_hooks_removes_stale_block(self, tmp_path: Path) -> None: + """A YAML root null removes a hooks block written by an earlier run.""" hooks_dir = tmp_path / 'hooks' hooks_dir.mkdir() delta_a = _build_profile_settings( - {'permissions': {'additionalDirectories': ['/existing']}}, + { + 'hooks': { + 'events': [ + { + 'event': 'PreToolUse', 'matcher': 'Bash', + 'type': 'command', 'command': 'a.sh', + }, + ], + }, + }, hooks_dir, ) write_profile_settings_to_settings(delta_a, tmp_path) - delta_b = _build_profile_settings( - {'permissions': {'additionalDirectories': ['/new']}}, - hooks_dir, - ) - write_profile_settings_to_settings(delta_b, tmp_path) + # A later YAML with hooks: null -> delta {'hooks': None} + write_profile_settings_to_settings({'hooks': None}, tmp_path) content = json.loads((tmp_path / 'settings.json').read_text(encoding='utf-8')) - assert content['permissions']['additionalDirectories'] == ['/existing', '/new'] + assert 'hooks' not in content - def test_hooks_event_list_preserved_across_runs(self, tmp_path: Path) -> None: + def test_hooks_event_lists_union_across_runs(self, tmp_path: Path) -> None: """Two Step 18 writes with different hook events both survive on disk.""" hooks_dir = tmp_path / 'hooks' hooks_dir.mkdir() @@ -378,480 +309,107 @@ def test_hooks_event_list_preserved_across_runs(self, tmp_path: Path) -> None: assert 'PreToolUse' in content['hooks'] assert 'PostToolUse' in content['hooks'] - def test_user_managed_array_outside_delta_preserved(self, tmp_path: Path) -> None: - """A user-managed array outside the toolbox delta survives Step 18.""" - settings_file = tmp_path / 'settings.json' - settings_file.write_text( - json.dumps({'customUserArray': ['a', 'b']}), - encoding='utf-8', - ) - hooks_dir = tmp_path / 'hooks' - hooks_dir.mkdir() - delta = _build_profile_settings({'model': 'sonnet'}, hooks_dir) - write_profile_settings_to_settings(delta, tmp_path) - content = json.loads(settings_file.read_text(encoding='utf-8')) - assert content['customUserArray'] == ['a', 'b'] - assert content['model'] == 'sonnet' - - def test_explicit_null_hooks_removes_stale_block(self, tmp_path: Path) -> None: - """Explicit RFC 7396 null at YAML root removes the entire hooks block.""" - hooks_dir = tmp_path / 'hooks' - hooks_dir.mkdir() - delta_a = _build_profile_settings( - { - 'hooks': { - 'events': [ - { - 'event': 'PreToolUse', 'matcher': 'Bash', - 'type': 'command', 'command': 'a.sh', - }, - ], - }, - }, - hooks_dir, - ) - write_profile_settings_to_settings(delta_a, tmp_path) - # Simulate YAML with hooks: null -> delta has {'hooks': None} - write_profile_settings_to_settings({'hooks': None}, tmp_path) - content = json.loads((tmp_path / 'settings.json').read_text(encoding='utf-8')) - assert 'hooks' not in content - # --------------------------------------------------------------------------- -# Test Class 2: Step 14 / Step 18 Interaction (user-settings + profile delta) +# Test Class 3: Step 14 user-settings Deep-Merge Into Shared settings.json # --------------------------------------------------------------------------- -class TestStep14Step18Interaction: - """Verify write_user_settings() contributions survive Step 18 writes. +class TestUserSettingsDeepMerge: + """Verify write_user_settings() deep-merge semantics on ~/.claude/settings.json. - Step 14 (write_user_settings) runs first and deep-merges the - user-settings YAML section into settings.json. Step 18 - (write_profile_settings_to_settings) runs second and applies the - profile delta. Step 18 must not delete keys that Step 14 wrote - when those keys are absent from the profile delta. + Step 14 deep-merges the raw user-settings section into the shared file: + every array unions with structural dedupe, nested dicts merge, RFC 7396 + null deletes keys, and the env sub-dict merges key-by-key. """ - def test_user_settings_permissions_preserved_when_no_root_permissions( - self, tmp_path: Path, - ) -> None: - """user-settings.permissions survives when YAML has no root-level permissions. - - Step 14 write_user_settings() runs first and writes - user-settings.permissions into settings.json via deep-merge. Step - 18 write_profile_settings_to_settings() then runs against a - profile delta that does not contain 'permissions' (root YAML has - no permissions declaration, so the builder omits the key). The - Step 14 permissions entry remains intact because the deep-merge - writer only touches keys present in its own delta. - """ - # Simulate Step 14 having written user-settings.permissions - settings_file = tmp_path / 'settings.json' - settings_file.write_text(json.dumps({ - 'permissions': {'allow': ['Read', 'Write']}, - 'language': 'english', - }), encoding='utf-8') - - # Step 18: delta has NO 'permissions' (YAML root lacks it) - delta = _build_profile_settings({'model': 'sonnet'}, tmp_path / 'hooks') - assert 'permissions' not in delta + def test_initial_write_creates_file(self, tmp_path: Path) -> None: + """First write against an empty directory creates settings.json.""" + write_user_settings({'model': 'sonnet', 'theme': 'dark'}, tmp_path) + content = json.loads((tmp_path / 'settings.json').read_text(encoding='utf-8')) + assert content == {'model': 'sonnet', 'theme': 'dark'} - write_profile_settings_to_settings(delta, tmp_path) + def test_unrelated_keys_preserved(self, tmp_path: Path) -> None: + """Keys already on disk and outside the delta are preserved.""" + settings_file = tmp_path / 'settings.json' + settings_file.write_text(json.dumps({'cleanupPeriodDays': 30}), encoding='utf-8') + write_user_settings({'model': 'sonnet'}, tmp_path) content = json.loads(settings_file.read_text(encoding='utf-8')) - - # user-settings.permissions PRESERVED - assert content['permissions'] == {'allow': ['Read', 'Write']} - # user-settings.language PRESERVED - assert content['language'] == 'english' - # Profile delta model ADDED + assert content['cleanupPeriodDays'] == 30 assert content['model'] == 'sonnet' - def test_root_permissions_union_with_user_settings_permissions( - self, tmp_path: Path, - ) -> None: - """Step 14 user-settings.permissions and Step 18 root permissions accumulate via union. - - Under the universal union-all-arrays default, EVERY list at - every depth is unioned across Step 14 (write_user_settings) and - Step 18 (write_profile_settings_to_settings). This specific test - is one instance of a general behavior that covers - permissions.allow, permissions.deny, permissions.ask, and every - other array-valued key. A team's shared user-settings 'deny' - rules compose with a per-run YAML's additional 'deny' rules - rather than one destroying the other. - """ - # Step 14 wrote user-settings.permissions + def test_permissions_allow_unions_across_runs(self, tmp_path: Path) -> None: + """permissions.allow accumulates across successive user-settings writes.""" settings_file = tmp_path / 'settings.json' settings_file.write_text(json.dumps({ - 'permissions': { - 'allow': ['Read'], - 'deny': ['Bash(sudo *)'], - }, + 'permissions': {'allow': ['Read'], 'deny': ['Bash(sudo *)']}, }), encoding='utf-8') - # Step 18 has root-level permissions (different but overlapping rules) - delta = _build_profile_settings( - { - 'permissions': { - 'allow': ['Write', 'Edit'], - 'deny': ['Bash(rm -rf)'], - }, - }, - tmp_path / 'hooks', + write_user_settings( + {'permissions': {'allow': ['Write', 'Edit']}}, tmp_path, ) - write_profile_settings_to_settings(delta, tmp_path) content = json.loads(settings_file.read_text(encoding='utf-8')) - - # Both 'allow' sets are array-unioned (Read + Write + Edit) + # allow unioned, deny preserved intact assert set(content['permissions']['allow']) == {'Read', 'Write', 'Edit'} - # Both 'deny' sets are array-unioned (sudo rule + rm -rf rule) - assert set(content['permissions']['deny']) == {'Bash(sudo *)', 'Bash(rm -rf)'} + assert content['permissions']['deny'] == ['Bash(sudo *)'] - -# --------------------------------------------------------------------------- -# Test Class 3: Conflict Detection in Non-Command-Names Mode -# --------------------------------------------------------------------------- - - -class TestConflictDetectionInNonCommandNamesMode: - """Verify detect_settings_conflicts() fires when command-names is absent. - - The function runs unconditionally in both isolated and non-isolated modes - and reports keys declared at both YAML root level and under user-settings. - """ - - def test_conflict_detected_in_non_command_names_mode(self) -> None: - """Conflict between user-settings and root fires in non-command-names mode.""" - user_settings = {'model': 'claude-opus-4'} - root_config = {'model': 'claude-sonnet-4'} # NO command-names - - conflicts = detect_settings_conflicts(user_settings, root_config) - assert conflicts == [('model', 'claude-opus-4', 'claude-sonnet-4')] - - def test_kebab_to_camel_key_mapped(self) -> None: - """kebab-case root keys mapped to camelCase user-settings keys.""" - user_settings = {'alwaysThinkingEnabled': True} - root_config = {'always-thinking-enabled': False} - - conflicts = detect_settings_conflicts(user_settings, root_config) - assert conflicts == [('alwaysThinkingEnabled', True, False)] - - @patch('scripts.setup_environment.load_config_from_source') - @patch('scripts.setup_environment.validate_all_config_files') - @patch('scripts.setup_environment.install_claude') - @patch('scripts.setup_environment.install_dependencies', return_value=[]) - @patch('scripts.setup_environment.process_resources') - @patch('scripts.setup_environment.process_skills') - @patch('scripts.setup_environment.configure_all_mcp_servers') - @patch('scripts.setup_environment.find_command', return_value='/usr/bin/claude') - @patch('scripts.setup_environment.is_admin', return_value=True) - @patch('pathlib.Path.mkdir') - def test_conflict_warning_emitted_via_main_without_command_names( - self, - mock_mkdir: MagicMock, - mock_is_admin: MagicMock, - mock_find_cmd: MagicMock, - mock_mcp: MagicMock, - mock_skills: MagicMock, - mock_resources: MagicMock, - mock_deps: MagicMock, - mock_install: MagicMock, - mock_validate: MagicMock, - mock_load: MagicMock, - e2e_isolated_home: dict[str, Path], - capsys: pytest.CaptureFixture[str], - ) -> None: - """Warning is emitted via main() when root-level and user-settings conflict without command-names.""" - del mock_mkdir, mock_is_admin, mock_find_cmd, mock_skills, mock_resources - del mock_deps, e2e_isolated_home - mock_load.return_value = ( - { - 'name': 'Conflict Test', - # NO command-names - 'model': 'claude-sonnet-4', - 'user-settings': {'model': 'claude-opus-4'}, - }, - 'test.yaml', - ) - mock_validate.return_value = (True, []) - mock_install.return_value = True - mock_mcp.return_value = (True, [], {'global_count': 0, 'profile_count': 0, 'combined_count': 0}) - - from scripts import setup_environment - - with patch('sys.argv', ['setup_environment.py', 'test', '--yes', '--skip-install']), \ - patch('sys.exit') as mock_exit, \ - patch.object(setup_environment, 'write_user_settings', return_value=True), \ - patch.object(setup_environment, 'write_profile_settings_to_settings', return_value=True): - setup_environment.main() - mock_exit.assert_not_called() - - captured = capsys.readouterr() - # Warning text identifies the conflicting key and both surfaces - assert "Key 'model' specified in both root level and user-settings" in captured.out - # Composite deep-merge precedence message - assert 'Under deep merge semantics' in captured.out - assert 'array union with structural dedupe applies' in captured.out - - -# --------------------------------------------------------------------------- -# Test Class 4: Profile-Scoped MCP Validation Error -# --------------------------------------------------------------------------- - - -class TestProfileMcpValidationError: - """Verify exit 1 with 4-option fix-up message for profile MCP without command-names.""" - - @patch('scripts.setup_environment.load_config_from_source') - @patch('scripts.setup_environment.validate_all_config_files') - @patch('scripts.setup_environment.install_claude') - @patch('scripts.setup_environment.install_dependencies', return_value=[]) - @patch('scripts.setup_environment.process_resources') - @patch('scripts.setup_environment.process_skills') - @patch('scripts.setup_environment.configure_all_mcp_servers') - @patch('scripts.setup_environment.find_command', return_value='/usr/bin/claude') - @patch('scripts.setup_environment.is_admin', return_value=True) - @patch('pathlib.Path.mkdir') - def test_profile_scope_alone_triggers_error( - self, - mock_mkdir: MagicMock, - mock_is_admin: MagicMock, - mock_find_cmd: MagicMock, - mock_mcp: MagicMock, - mock_skills: MagicMock, - mock_resources: MagicMock, - mock_deps: MagicMock, - mock_install: MagicMock, - mock_validate: MagicMock, - mock_load: MagicMock, - e2e_isolated_home: dict[str, Path], - capsys: pytest.CaptureFixture[str], - ) -> None: - """Scope 'profile' without command-names -> exit 1 with actionable message.""" - del mock_mkdir, mock_is_admin, mock_find_cmd, mock_skills, mock_resources - del mock_deps, e2e_isolated_home - mock_load.return_value = ( - { - 'name': 'Profile MCP No Command', - # NO command-names - 'mcp-servers': [ - { - 'name': 'profile-server', - 'scope': 'profile', - 'transport': 'http', - 'url': 'http://localhost:3000', - }, - ], - }, - 'test.yaml', - ) - mock_validate.return_value = (True, []) - mock_install.return_value = True - mock_mcp.return_value = (True, [], {'global_count': 0, 'profile_count': 0, 'combined_count': 0}) - - from scripts import setup_environment - - with patch('sys.argv', ['setup_environment.py', 'test', '--yes', '--skip-install']), \ - pytest.raises(SystemExit) as excinfo: - setup_environment.main() - - # sys.exit(1) invoked - assert excinfo.value.code == 1 - - # error() messages go to stderr - captured = capsys.readouterr() - assert "MCP server 'profile-server' declares scope: profile" in captured.err - assert 'command-names is not specified' in captured.err - - @patch('scripts.setup_environment.load_config_from_source') - @patch('scripts.setup_environment.validate_all_config_files') - @patch('scripts.setup_environment.install_claude') - @patch('scripts.setup_environment.install_dependencies', return_value=[]) - @patch('scripts.setup_environment.process_resources') - @patch('scripts.setup_environment.process_skills') - @patch('scripts.setup_environment.configure_all_mcp_servers') - @patch('scripts.setup_environment.find_command', return_value='/usr/bin/claude') - @patch('scripts.setup_environment.is_admin', return_value=True) - @patch('pathlib.Path.mkdir') - def test_combined_user_profile_scope_triggers_error( - self, - mock_mkdir: MagicMock, - mock_is_admin: MagicMock, - mock_find_cmd: MagicMock, - mock_mcp: MagicMock, - mock_skills: MagicMock, - mock_resources: MagicMock, - mock_deps: MagicMock, - mock_install: MagicMock, - mock_validate: MagicMock, - mock_load: MagicMock, - e2e_isolated_home: dict[str, Path], - capsys: pytest.CaptureFixture[str], - ) -> None: - """Scope [user, profile] without command-names -> exit 1.""" - del mock_mkdir, mock_is_admin, mock_find_cmd, mock_skills, mock_resources - del mock_deps, e2e_isolated_home - mock_load.return_value = ( - { - 'name': 'Combined Profile MCP No Command', - 'mcp-servers': [ - { - 'name': 'combined-server', - 'scope': ['user', 'profile'], - 'transport': 'http', - 'url': 'http://localhost:3000', - }, - ], - }, - 'test.yaml', - ) - mock_validate.return_value = (True, []) - mock_install.return_value = True - mock_mcp.return_value = (True, [], {'global_count': 0, 'profile_count': 0, 'combined_count': 0}) - - from scripts import setup_environment - - with patch('sys.argv', ['setup_environment.py', 'test', '--yes', '--skip-install']), \ - pytest.raises(SystemExit) as excinfo: - setup_environment.main() - - assert excinfo.value.code == 1 - - captured = capsys.readouterr() - # error() messages go to stderr - assert "MCP server 'combined-server' declares scope: profile" in captured.err - - @patch('scripts.setup_environment.load_config_from_source') - @patch('scripts.setup_environment.validate_all_config_files') - @patch('scripts.setup_environment.install_claude') - @patch('scripts.setup_environment.install_dependencies', return_value=[]) - @patch('scripts.setup_environment.process_resources') - @patch('scripts.setup_environment.process_skills') - @patch('scripts.setup_environment.configure_all_mcp_servers') - @patch('scripts.setup_environment.find_command', return_value='/usr/bin/claude') - @patch('scripts.setup_environment.is_admin', return_value=True) - @patch('pathlib.Path.mkdir') - def test_error_message_contains_4_options( - self, - mock_mkdir: MagicMock, - mock_is_admin: MagicMock, - mock_find_cmd: MagicMock, - mock_mcp: MagicMock, - mock_skills: MagicMock, - mock_resources: MagicMock, - mock_deps: MagicMock, - mock_install: MagicMock, - mock_validate: MagicMock, - mock_load: MagicMock, - e2e_isolated_home: dict[str, Path], - capsys: pytest.CaptureFixture[str], - ) -> None: - """Error message enumerates all 4 fix-up options.""" - del mock_mkdir, mock_is_admin, mock_find_cmd, mock_skills, mock_resources - del mock_deps, e2e_isolated_home - mock_load.return_value = ( - { - 'name': 'Profile MCP 4 Options Test', - 'mcp-servers': [ - { - 'name': 'any-profile-server', - 'scope': 'profile', - 'transport': 'http', - 'url': 'http://localhost:3000', - }, - ], + def test_permissions_deny_preserved_when_only_allow_declared(self, tmp_path: Path) -> None: + """A narrower permissions dict must not destroy existing deny rules.""" + settings_file = tmp_path / 'settings.json' + settings_file.write_text(json.dumps({ + 'permissions': { + 'allow': ['Read'], + 'deny': ['Bash(rm -rf *)', 'Bash(curl *)'], + 'ask': ['Edit'], }, - 'test.yaml', - ) - mock_validate.return_value = (True, []) - mock_install.return_value = True - mock_mcp.return_value = (True, [], {'global_count': 0, 'profile_count': 0, 'combined_count': 0}) - - from scripts import setup_environment - - with patch('sys.argv', ['setup_environment.py', 'test', '--yes', '--skip-install']), \ - pytest.raises(SystemExit): - setup_environment.main() - - captured = capsys.readouterr() - # error() messages go to stderr - all four options enumerated in the error message - assert '1. Add "command-names: [your-name]"' in captured.err - assert '2. Change scope to "user"' in captured.err - assert '3. Change scope to "local"' in captured.err - assert '4. Change scope to "project"' in captured.err + }), encoding='utf-8') + write_user_settings({'permissions': {'allow': ['Grep']}}, tmp_path) + content = json.loads(settings_file.read_text(encoding='utf-8')) + assert set(content['permissions']['allow']) == {'Read', 'Grep'} + assert content['permissions']['deny'] == ['Bash(rm -rf *)', 'Bash(curl *)'] + assert content['permissions']['ask'] == ['Edit'] -# --------------------------------------------------------------------------- -# Test Class 5: System-Prompt Warning Without Command-Names -# --------------------------------------------------------------------------- + def test_env_sub_dict_merges_key_by_key(self, tmp_path: Path) -> None: + """The env sub-dict merges: existing keys survive, new keys are added.""" + settings_file = tmp_path / 'settings.json' + settings_file.write_text(json.dumps({ + 'env': {'DISABLE_AUTOUPDATER': '1', 'KEEP_ME': 'preserved'}, + }), encoding='utf-8') + write_user_settings({'env': {'FOO': 'bar'}}, tmp_path) + content = json.loads(settings_file.read_text(encoding='utf-8')) + assert content['env']['DISABLE_AUTOUPDATER'] == '1' + assert content['env']['KEEP_ME'] == 'preserved' + assert content['env']['FOO'] == 'bar' -class TestSystemPromptWarning: - """Verify warning emitted when command-defaults.system-prompt is set without command-names.""" - - @patch('scripts.setup_environment.load_config_from_source') - @patch('scripts.setup_environment.validate_all_config_files') - @patch('scripts.setup_environment.install_claude') - @patch('scripts.setup_environment.install_dependencies', return_value=[]) - @patch('scripts.setup_environment.process_resources') - @patch('scripts.setup_environment.process_skills') - @patch('scripts.setup_environment.configure_all_mcp_servers') - @patch('scripts.setup_environment.find_command', return_value='/usr/bin/claude') - @patch('scripts.setup_environment.is_admin', return_value=True) - @patch('pathlib.Path.mkdir') - def test_warning_emitted( - self, - mock_mkdir: MagicMock, - mock_is_admin: MagicMock, - mock_find_cmd: MagicMock, - mock_mcp: MagicMock, - mock_skills: MagicMock, - mock_resources: MagicMock, - mock_deps: MagicMock, - mock_install: MagicMock, - mock_validate: MagicMock, - mock_load: MagicMock, - e2e_isolated_home: dict[str, Path], - capsys: pytest.CaptureFixture[str], - ) -> None: - """Warning is printed; setup continues successfully.""" - del mock_mkdir, mock_is_admin, mock_find_cmd, mock_skills, mock_resources - del mock_deps, e2e_isolated_home - mock_load.return_value = ( - { - 'name': 'System Prompt Warning Test', - # NO command-names - 'command-defaults': { - 'system-prompt': 'prompts/my-prompt.md', - 'mode': 'replace', - }, - }, - 'test.yaml', - ) - mock_validate.return_value = (True, []) - mock_install.return_value = True - mock_mcp.return_value = (True, [], {'global_count': 0, 'profile_count': 0, 'combined_count': 0}) + def test_top_level_null_deletes_key(self, tmp_path: Path) -> None: + """RFC 7396: a top-level null deletes the on-disk key.""" + settings_file = tmp_path / 'settings.json' + settings_file.write_text(json.dumps({ + 'model': 'sonnet', 'theme': 'dark', + }), encoding='utf-8') - from scripts import setup_environment + write_user_settings({'model': None}, tmp_path) + content = json.loads(settings_file.read_text(encoding='utf-8')) + assert 'model' not in content + assert content['theme'] == 'dark' - with patch('sys.argv', ['setup_environment.py', 'test', '--yes', '--skip-install']), \ - patch('sys.exit') as mock_exit, \ - patch.object(setup_environment, 'handle_resource', return_value=True), \ - patch.object(setup_environment, 'download_hook_files', return_value=True), \ - patch.object(setup_environment, 'write_profile_settings_to_settings', return_value=True): - setup_environment.main() - mock_exit.assert_not_called() + def test_nested_null_deletes_env_sub_key(self, tmp_path: Path) -> None: + """RFC 7396: a nested null deletes just that env sub-key.""" + settings_file = tmp_path / 'settings.json' + settings_file.write_text(json.dumps({ + 'env': {'STALE_VAR': 'x', 'KEEP_ME': 'y'}, + }), encoding='utf-8') - captured = capsys.readouterr() - # warning() messages go to stdout - assert 'command-defaults.system-prompt' in captured.out - assert 'but command-names is not specified' in captured.out - assert 'there is no launcher' in captured.out - assert 'system prompt will NOT be applied' in captured.out + write_user_settings({'env': {'STALE_VAR': None}}, tmp_path) + content = json.loads(settings_file.read_text(encoding='utf-8')) + assert 'STALE_VAR' not in content['env'] + assert content['env']['KEEP_ME'] == 'y' # --------------------------------------------------------------------------- -# Test Class 6: Golden Config (No Command-Names) End-to-End Integration +# Test Class 4: golden_config_no_command_names End-to-End # --------------------------------------------------------------------------- @@ -866,40 +424,48 @@ def golden_config_no_command_names() -> dict[str, Any]: class TestGoldenConfigNoCommandNames: - """End-to-end integration test using golden_config_no_command_names.yaml.""" + """End-to-end integration test using golden_config_no_command_names.yaml. - def test_golden_config_absence_of_command_names( + Reproduces the Step 14 then Step 18 ordering that main() applies in + non-isolated mode and asserts that both the user-settings content and the + profile-owned statusLine/hooks entries coexist in ~/.claude/settings.json, + with no config.json written anywhere. + """ + + def test_no_command_names_declared( self, golden_config_no_command_names: dict[str, Any], ) -> None: - """Verify golden_config_no_command_names.yaml does not declare command-names.""" + """The fixture intentionally omits command-names (non-isolated mode).""" assert 'command-names' not in golden_config_no_command_names - def test_golden_config_has_all_profile_keys( + def test_settings_json_content_key_lives_in_user_settings( self, golden_config_no_command_names: dict[str, Any], ) -> None: - """Verify the golden config declares every profile-owned key at root level.""" + """settings.json content keys are declared inside user-settings, not at root.""" cfg = golden_config_no_command_names - assert 'model' in cfg - assert 'permissions' in cfg - assert 'env-variables' in cfg - assert 'attribution' in cfg - assert 'always-thinking-enabled' in cfg - assert 'effort-level' in cfg - assert 'company-announcements' in cfg + user_settings = cfg['user-settings'] + assert user_settings.get('model') == 'sonnet' + assert 'permissions' in user_settings + assert 'env' in user_settings + assert user_settings.get('alwaysThinkingEnabled') is True + assert user_settings.get('effortLevel') == 'low' + # The two profile-owned keys remain at the YAML root (dedicated write logic) assert 'status-line' in cfg assert 'hooks' in cfg - - def test_golden_config_no_profile_scoped_mcp( + # Removed root keys must not resurface at the YAML root + for stale_root_key in ( + 'model', 'permissions', 'env-variables', 'attribution', + 'always-thinking-enabled', 'effort-level', 'company-announcements', + ): + assert stale_root_key not in cfg + + def test_no_profile_scoped_mcp( self, golden_config_no_command_names: dict[str, Any], ) -> None: - """Verify NO mcp-server in the fixture uses scope 'profile'. - - Profile-scoped servers without command-names trigger an error; the - no-command-names fixture must avoid them for happy-path coverage. - """ + """No mcp-server uses scope 'profile' (which requires command-names).""" for server in golden_config_no_command_names.get('mcp-servers', []): scope = server.get('scope') if isinstance(scope, str): @@ -907,65 +473,80 @@ def test_golden_config_no_profile_scoped_mcp( elif isinstance(scope, list): assert 'profile' not in scope - def test_all_profile_keys_in_settings_json( + def test_user_settings_and_profile_keys_coexist_in_settings_json( self, e2e_isolated_home: dict[str, Path], golden_config_no_command_names: dict[str, Any], ) -> None: - """After building delta + writing, all 9 profile-owned keys appear in settings.json.""" - paths = e2e_isolated_home - claude_dir = paths['claude_dir'] + """Step 14 then Step 18 leave both contributions in settings.json. + + The user-settings content (model, permissions, env, ...) is written by + write_user_settings, then the statusLine/hooks delta is written by + write_profile_settings_to_settings. Both survive in the same file, and + no config.json is created in non-isolated mode. + """ + claude_dir = e2e_isolated_home['claude_dir'] hooks_dir = claude_dir / 'hooks' hooks_dir.mkdir(parents=True, exist_ok=True) cfg = golden_config_no_command_names + # Step 14: write user-settings section + write_user_settings(cfg['user-settings'], claude_dir) + + # Step 18: build and write the profile-owned delta (statusLine + hooks) profile_config = { camel_key: cfg[yaml_key] for yaml_key, camel_key in { - 'hooks': 'hooks', - 'model': 'model', - 'permissions': 'permissions', - 'env-variables': 'env', - 'always-thinking-enabled': 'alwaysThinkingEnabled', - 'company-announcements': 'companyAnnouncements', - 'attribution': 'attribution', 'status-line': 'statusLine', - 'effort-level': 'effortLevel', + 'hooks': 'hooks', }.items() if yaml_key in cfg } delta = _build_profile_settings(profile_config, hooks_dir) - write_profile_settings_to_settings(delta, claude_dir) settings_path = claude_dir / 'settings.json' assert settings_path.exists() content = json.loads(settings_path.read_text(encoding='utf-8')) - # All 9 profile keys present - assert set(content.keys()) == PROFILE_OWNED_KEYS + # user-settings content present + assert content['model'] == 'sonnet' + assert content['permissions']['defaultMode'] == 'default' + assert content['alwaysThinkingEnabled'] is True + assert content['effortLevel'] == 'low' + assert content['companyAnnouncements'] == [ + 'Welcome to E2E Testing Environment', + 'This is a test announcement for validation', + ] + assert content['attribution'] == { + 'commit': 'E2E Test Attribution for Commits', + 'pr': 'E2E Test Attribution for Pull Requests', + } + # profile-owned keys present + assert content['statusLine']['type'] == 'command' + assert 'PostToolUse' in content['hooks'] + + # Non-isolated mode: NO config.json anywhere + assert not (claude_dir / 'config.json').exists() - def test_env_null_entry_deletes_stale_value_from_settings_json( + def test_env_null_entry_deletes_stale_value( self, e2e_isolated_home: dict[str, Path], golden_config_no_command_names: dict[str, Any], ) -> None: """The golden config's null env entry deletes the stale settings.json value. - Base-mode null-as-delete: a per-key env null flows builder -> writer - -> RFC 7396 merge and removes the key, while active values are set - as strings and the literal string 'None' never appears. + Base-mode null-as-delete: a per-key env null in user-settings flows + through the deep-merge writer and removes the key, while active values + are set as strings and the literal string 'None' never appears. """ - paths = e2e_isolated_home - claude_dir = paths['claude_dir'] - hooks_dir = claude_dir / 'hooks' - hooks_dir.mkdir(parents=True, exist_ok=True) + claude_dir = e2e_isolated_home['claude_dir'] cfg = golden_config_no_command_names - env_section = cfg.get('env-variables', {}) + env_section = cfg['user-settings']['env'] assert env_section.get('E2E_DELETE_VAR', '') is None, ( - 'Golden no-command-names config must declare E2E_DELETE_VAR: null' + 'Golden no-command-names config must declare user-settings.env.E2E_DELETE_VAR: null' ) # Pre-seed a stale value as if a prior run had set the variable @@ -975,8 +556,7 @@ def test_env_null_entry_deletes_stale_value_from_settings_json( encoding='utf-8', ) - delta = _build_profile_settings({'env': env_section}, hooks_dir) - write_profile_settings_to_settings(delta, claude_dir) + write_user_settings(cfg['user-settings'], claude_dir) content = json.loads(settings_path.read_text(encoding='utf-8')) env_block = content['env'] @@ -985,50 +565,20 @@ def test_env_null_entry_deletes_stale_value_from_settings_json( assert env_block.get('E2E_INT_VAR') == '42' assert 'None' not in env_block.values() - def test_hooks_in_settings_not_config_json( - self, - e2e_isolated_home: dict[str, Path], - golden_config_no_command_names: dict[str, Any], - ) -> None: - """Hooks key appears in settings.json; no config.json is created.""" - paths = e2e_isolated_home - claude_dir = paths['claude_dir'] - hooks_dir = claude_dir / 'hooks' - hooks_dir.mkdir(parents=True, exist_ok=True) - - cfg = golden_config_no_command_names - - delta = _build_profile_settings( - {'hooks': cfg.get('hooks', {}), 'model': cfg.get('model')}, - hooks_dir, - ) - write_profile_settings_to_settings(delta, claude_dir) - - settings_path = claude_dir / 'settings.json' - content = json.loads(settings_path.read_text(encoding='utf-8')) - assert 'hooks' in content - assert 'PostToolUse' in content['hooks'] - # Non-isolated mode: NO config.json anywhere - assert not (claude_dir / 'config.json').exists() - def test_status_line_absolute_path_in_settings( self, e2e_isolated_home: dict[str, Path], golden_config_no_command_names: dict[str, Any], ) -> None: - """statusLine.command has absolute POSIX path under ~/.claude/hooks/.""" - paths = e2e_isolated_home - claude_dir = paths['claude_dir'] + """statusLine.command has an absolute POSIX path under ~/.claude/hooks/.""" + claude_dir = e2e_isolated_home['claude_dir'] hooks_dir = claude_dir / 'hooks' hooks_dir.mkdir(parents=True, exist_ok=True) cfg = golden_config_no_command_names delta = _build_profile_settings( - { - 'hooks': cfg.get('hooks', {}), - 'statusLine': cfg.get('status-line'), - }, + {'statusLine': cfg.get('status-line')}, hooks_dir, ) write_profile_settings_to_settings(delta, claude_dir) @@ -1041,276 +591,80 @@ def test_status_line_absolute_path_in_settings( assert expected_path in sl['command'] # Python script -> uv run prefix assert 'uv run' in sl['command'] - # Config file embedded in command string + # Config file embedded in the command string expected_cfg = (hooks_dir / 'e2e-statusline-config.yaml').as_posix() assert expected_cfg in sl['command'] # --------------------------------------------------------------------------- -# Test Class 7: Behavior Across Repeated Invocations With Evolving Content +# Test Class 5: Auto-Update Target user-settings.env Survival # --------------------------------------------------------------------------- -class TestRepeatedInvocationSemantics: - """Verify behavior across multiple invocations with evolving YAML content.""" - - def test_initial_invocation_populates_declared_keys(self, tmp_path: Path) -> None: - """First invocation against an empty file writes all declared keys.""" - delta = _build_profile_settings( - {'model': 'sonnet', 'permissions': {'allow': ['Read']}}, - tmp_path / 'hooks', - ) - write_profile_settings_to_settings(delta, tmp_path) - content = json.loads((tmp_path / 'settings.json').read_text(encoding='utf-8')) - assert content == {'model': 'sonnet', 'permissions': {'allow': ['Read']}} - - def test_re_declaration_overwrites_previous_value(self, tmp_path: Path) -> None: - """Re-declaring a key with a new value overwrites the previous value.""" - # First invocation - delta1 = _build_profile_settings( - {'model': 'sonnet', 'permissions': {'allow': ['Read']}}, - tmp_path / 'hooks', - ) - write_profile_settings_to_settings(delta1, tmp_path) - - # Second invocation (same keys, different values; deep-merge unions - # permissions.allow) - delta2 = _build_profile_settings( - {'model': 'opus', 'permissions': {'allow': ['Write']}}, - tmp_path / 'hooks', - ) - write_profile_settings_to_settings(delta2, tmp_path) - content = json.loads((tmp_path / 'settings.json').read_text(encoding='utf-8')) - assert content['model'] == 'opus' - # Array-union semantics: both 'Read' and 'Write' accumulate - assert set(content['permissions']['allow']) == {'Read', 'Write'} - - def test_omitting_key_preserves_previous_value(self, tmp_path: Path) -> None: - """Omitting a key on a later invocation preserves the previous value. - - The shared settings.json is a user-facing file, so the writer - does not delete profile-owned keys when the current invocation - does not declare them. To delete a key, the user must declare it - explicitly as None or remove it manually. - """ - # First invocation: both model and permissions - delta1 = _build_profile_settings( - {'model': 'sonnet', 'permissions': {'allow': ['Read']}}, - tmp_path / 'hooks', - ) - write_profile_settings_to_settings(delta1, tmp_path) - - # Second invocation: only model (permissions omitted) - delta2 = _build_profile_settings({'model': 'opus'}, tmp_path / 'hooks') - write_profile_settings_to_settings(delta2, tmp_path) - content = json.loads((tmp_path / 'settings.json').read_text(encoding='utf-8')) - - # Model updated, permissions PRESERVED from previous invocation - assert content['model'] == 'opus' - assert content['permissions'] == {'allow': ['Read']} - - def test_multiple_invocations_accumulate_distinct_keys(self, tmp_path: Path) -> None: - """Multiple invocations with different subsets preserve accumulated state.""" - # First invocation: model + permissions - write_profile_settings_to_settings( - _build_profile_settings( - {'model': 'sonnet', 'permissions': {'allow': ['Read']}}, - tmp_path / 'hooks', - ), - tmp_path, - ) - - # Second invocation: add env (model re-declared, permissions omitted) - write_profile_settings_to_settings( - _build_profile_settings( - {'model': 'opus', 'env': {'FOO': 'bar'}}, - tmp_path / 'hooks', - ), - tmp_path, - ) - - # Third invocation: add effort_level (previous keys all omitted) - write_profile_settings_to_settings( - _build_profile_settings( - {'effortLevel': 'high'}, - tmp_path / 'hooks', - ), - tmp_path, - ) - - content = json.loads((tmp_path / 'settings.json').read_text(encoding='utf-8')) - # Accumulated: permissions from the first invocation, env from the - # second, effortLevel from the third. Model gets re-declared in the - # second invocation, so it ends up as 'opus'. - assert content['model'] == 'opus' - assert content['permissions'] == {'allow': ['Read']} - assert content['env'] == {'FOO': 'bar'} - assert content['effortLevel'] == 'high' - - -# --------------------------------------------------------------------------- -# Test Class 8: Auto-Update Target 2 Survival (regression) -# --------------------------------------------------------------------------- - - -class TestAutoUpdateTarget2Survival: - """Verify the Target 2 auto-update injection survives the profile settings write. +class TestAutoUpdateEnvSurvival: + """Verify the auto-update user-settings.env injection survives Step 14 then Step 18. When version pinning is active, apply_auto_update_settings() injects - DISABLE_AUTOUPDATER into user_settings.env (its Target 2). Step 14 - then writes that entry to settings.json.env. The subsequent Step 18 - profile-settings write must not destroy that entry when the YAML - root has no env-variables declaration. + DISABLE_AUTOUPDATER into user_settings.env (one of its three targets). + Step 14 (write_user_settings) writes that entry to settings.json.env. + The subsequent Step 18 profile-settings write carries only statusLine and + hooks, so it must not touch the env block that Step 14 wrote. """ - def test_disable_autoupdater_preserved_when_no_root_env(self, tmp_path: Path) -> None: - """DISABLE_AUTOUPDATER in settings.json.env survives the profile write. + def test_disable_autoupdater_survives_step14_then_step18(self, tmp_path: Path) -> None: + """DISABLE_AUTOUPDATER written by Step 14 survives the Step 18 delta write.""" + claude_dir = tmp_path + hooks_dir = claude_dir / 'hooks' + hooks_dir.mkdir() - Set up settings.json.env containing DISABLE_AUTOUPDATER (the state - Step 14 leaves behind for a pinned-version configuration), then - run the profile-settings writer with a delta that contains no - 'env' key. The writer must leave the existing 'env' value alone. - """ - # Simulate Step 14 having written user_settings with env DISABLE_AUTOUPDATER - settings_file = tmp_path / 'settings.json' - settings_file.write_text(json.dumps({ - 'env': {'DISABLE_AUTOUPDATER': '1'}, - }), encoding='utf-8') + # Step 14: user-settings carrying the injected env target + write_user_settings( + {'model': 'sonnet', 'env': {'DISABLE_AUTOUPDATER': '1'}}, + claude_dir, + ) - # Step 18: delta has no 'env' key (YAML has no env-variables) - delta = _build_profile_settings({'model': 'sonnet'}, tmp_path / 'hooks') + # Step 18: profile delta has statusLine only (no env) + delta = _build_profile_settings({'statusLine': {'file': 'status.py'}}, hooks_dir) assert 'env' not in delta + write_profile_settings_to_settings(delta, claude_dir) - write_profile_settings_to_settings(delta, tmp_path) - content = json.loads(settings_file.read_text(encoding='utf-8')) + content = json.loads((claude_dir / 'settings.json').read_text(encoding='utf-8')) assert content['env']['DISABLE_AUTOUPDATER'] == '1' assert content['model'] == 'sonnet' + assert content['statusLine']['type'] == 'command' - def test_root_env_variables_deep_merge_preserves_existing_env_keys( - self, tmp_path: Path, - ) -> None: - """Deep-merge preserves existing env keys not declared in the delta. - - When the YAML declares env-variables with a narrow set of keys, any - existing env entries written by prior steps (e.g., DISABLE_AUTOUPDATER - from auto-update Target 2, CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL from IDE - Target 2) MUST survive. Deep-merge recurses into the 'env' dict so the - delta's new keys are added and existing keys are preserved; keys also - declared in the delta are overwritten by the delta value. - """ - settings_file = tmp_path / 'settings.json' - settings_file.write_text(json.dumps({ - 'env': { - 'DISABLE_AUTOUPDATER': '1', - 'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL': 'true', - 'KEEP_ME': 'preserved', + def test_ide_and_auto_update_env_targets_both_survive(self, tmp_path: Path) -> None: + """Both env auto-control targets written by Step 14 survive Step 18.""" + claude_dir = tmp_path + hooks_dir = claude_dir / 'hooks' + hooks_dir.mkdir() + + write_user_settings( + { + 'env': { + 'DISABLE_AUTOUPDATER': '1', + 'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL': 'true', + }, }, - }), encoding='utf-8') + claude_dir, + ) - # The delta carries a root-level env value adding a new key delta = _build_profile_settings( - {'env': {'FOO': 'bar'}}, tmp_path / 'hooks', + { + 'hooks': { + 'events': [ + { + 'event': 'PreToolUse', 'matcher': 'Bash', + 'type': 'command', 'command': 'a.sh', + }, + ], + }, + }, + hooks_dir, ) - write_profile_settings_to_settings(delta, tmp_path) - content = json.loads(settings_file.read_text(encoding='utf-8')) + write_profile_settings_to_settings(delta, claude_dir) - # Existing env keys PRESERVED + content = json.loads((claude_dir / 'settings.json').read_text(encoding='utf-8')) assert content['env']['DISABLE_AUTOUPDATER'] == '1' assert content['env']['CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL'] == 'true' - assert content['env']['KEEP_ME'] == 'preserved' - # New env key ADDED - assert content['env']['FOO'] == 'bar' - - -# --------------------------------------------------------------------------- -# Test Class 9: No Profile Keys = No File I/O -# --------------------------------------------------------------------------- - - -class TestEmptyDeltaNoOp: - """Verify writer does not touch settings.json when delta is empty.""" - - def test_empty_delta_does_not_create_file(self, tmp_path: Path) -> None: - """Empty delta -> no file created.""" - write_profile_settings_to_settings({}, tmp_path) - assert not (tmp_path / 'settings.json').exists() - - def test_empty_delta_does_not_modify_existing(self, tmp_path: Path) -> None: - """Empty delta -> existing settings.json unchanged.""" - settings_file = tmp_path / 'settings.json' - original = {'language': 'english', 'model': 'sonnet'} - settings_file.write_text(json.dumps(original), encoding='utf-8') - - write_profile_settings_to_settings({}, tmp_path) - - content = json.loads(settings_file.read_text(encoding='utf-8')) - assert content == original - - -# --------------------------------------------------------------------------- -# Test Class 10: YAML Null Propagation End-to-End -# --------------------------------------------------------------------------- - - -class TestBuildProfileSettingsNullPropagation: - """Verify YAML null declarations propagate to settings.json deletions end-to-end. - - These tests construct a mock YAML config dict (with explicit None - values for profile-owned keys), compute the profile_config dict the - same way main() does via _YAML_TO_CAMEL_PROFILE_KEYS, invoke - _build_profile_settings() with that dict, and verify - write_profile_settings_to_settings() deletes the corresponding keys - from a pre-populated settings.json. - """ - - @pytest.mark.parametrize( - ('yaml_key', 'camel_key', 'initial_value'), - [ - ('model', 'model', 'sonnet'), - ('permissions', 'permissions', {'allow': ['Read']}), - ('env-variables', 'env', {'FOO': 'bar'}), - ('attribution', 'attribution', {'commit': 'x', 'pr': 'y'}), - ('always-thinking-enabled', 'alwaysThinkingEnabled', True), - ('effort-level', 'effortLevel', 'high'), - ('company-announcements', 'companyAnnouncements', ['Welcome']), - ('status-line', 'statusLine', {'type': 'command', 'command': 'x'}), - ('hooks', 'hooks', {'PreToolUse': []}), - ], - ) - def test_yaml_null_deletes_key_end_to_end( - self, - tmp_path: Path, - yaml_key: str, - camel_key: str, - initial_value: object, - ) -> None: - """A YAML-level `key: null` declaration deletes the on-disk key.""" - from scripts.setup_environment import _YAML_TO_CAMEL_PROFILE_KEYS - - # Pre-populate settings.json with the key - settings_file = tmp_path / 'settings.json' - settings_file.write_text( - json.dumps({camel_key: initial_value}), encoding='utf-8', - ) - - # Mock YAML config with an explicit null for the key - mock_config = {yaml_key: None} - - # Replicate the main() call site logic: build profile_config dict - profile_config = { - ck: mock_config[yk] - for yk, ck in _YAML_TO_CAMEL_PROFILE_KEYS.items() - if yk in mock_config - } - assert profile_config == {camel_key: None} - - # Invoke the builder - delta = _build_profile_settings(profile_config, tmp_path / 'hooks') - assert delta == {camel_key: None} - - # Apply the delta via the writer - write_profile_settings_to_settings(delta, tmp_path) - content = json.loads(settings_file.read_text(encoding='utf-8')) - - # The key has been deleted - assert camel_key not in content + assert 'PreToolUse' in content['hooks'] diff --git a/tests/e2e/test_scope_routing.py b/tests/e2e/test_scope_routing.py index f957440..f9332c0 100644 --- a/tests/e2e/test_scope_routing.py +++ b/tests/e2e/test_scope_routing.py @@ -1,8 +1,14 @@ """E2E tests for scope-based routing of settings and config files. Verifies that the presence or absence of command-names in the resolved -configuration determines whether settings.json is written to the isolated -directory (~/.claude/{cmd}/) or the standard directory (~/.claude/). +configuration determines where the user-settings content is written. + +- Isolated mode (command-names present): the user-settings section is built + into the isolated profile's config.json via create_profile_config(). The + toolbox never writes ~/.claude/{cmd}/settings.json; that file is owned by + Claude Code (user layer), not the toolbox. +- Non-isolated mode (command-names absent): the user-settings section is + deep-merged into the shared ~/.claude/settings.json via write_user_settings(). Covers: Scenarios 1-8 (scope-based routing) and Scenario 18 (preservation). """ @@ -15,6 +21,7 @@ from scripts import setup_environment from scripts.setup_environment import _merge_configs +from scripts.setup_environment import create_profile_config from scripts.setup_environment import write_user_settings @@ -36,13 +43,13 @@ def _resolve_fixture( class TestScopeBasedRouting: - """Verify settings.json is written to the correct directory based on command-names.""" + """Verify user-settings content is routed to the correct file based on command-names.""" - def test_isolated_config_settings_in_artifact_dir( + def test_isolated_config_settings_in_config_json( self, e2e_isolated_home: dict[str, Path], ) -> None: - """Scenario 1: Config WITH command-names writes settings.json to isolated dir.""" + """Scenario 1: Config WITH command-names builds user-settings into config.json.""" config = _load_fixture('scope_isolated.yaml') claude_dir = e2e_isolated_home['claude_dir'] @@ -51,17 +58,22 @@ def test_isolated_config_settings_in_artifact_dir( artifact_base_dir = claude_dir / primary_cmd artifact_base_dir.mkdir(parents=True, exist_ok=True) - write_user_settings(config['user-settings'], artifact_base_dir) + create_profile_config({}, artifact_base_dir, user_settings=config['user-settings']) - isolated_settings = artifact_base_dir / 'settings.json' - assert isolated_settings.exists(), ( - f'settings.json not created in isolated dir: {isolated_settings}' + isolated_config = artifact_base_dir / 'config.json' + assert isolated_config.exists(), ( + f'config.json not created in isolated dir: {isolated_config}' ) - content = json.loads(isolated_settings.read_text()) + content = json.loads(isolated_config.read_text()) assert content.get('theme') == 'dark' assert content.get('language') == 'english' + # The toolbox does NOT write an isolated settings.json in isolated mode. + assert not (artifact_base_dir / 'settings.json').exists(), ( + 'toolbox must not write an isolated settings.json in isolated mode' + ) + standard_settings = claude_dir / 'settings.json' assert not standard_settings.exists(), ( 'settings.json should NOT exist in standard claude_dir for isolated config' @@ -108,19 +120,20 @@ def test_child_inherits_command_names_from_parent( # Without merge-keys, child's user-settings REPLACES parent's assert resolved['user-settings'] == {'language': 'french'} - # Verify routing goes to isolated dir + # Verify routing goes to config.json in the isolated dir claude_dir = e2e_isolated_home['claude_dir'] primary_cmd = resolved['command-names'][0] artifact_base_dir = claude_dir / primary_cmd artifact_base_dir.mkdir(parents=True, exist_ok=True) - write_user_settings(resolved['user-settings'], artifact_base_dir) + create_profile_config({}, artifact_base_dir, user_settings=resolved['user-settings']) - isolated_settings = artifact_base_dir / 'settings.json' - assert isolated_settings.exists() + isolated_config = artifact_base_dir / 'config.json' + assert isolated_config.exists() + assert json.loads(isolated_config.read_text()).get('language') == 'french' - standard_settings = claude_dir / 'settings.json' - assert not standard_settings.exists() + assert not (artifact_base_dir / 'settings.json').exists() + assert not (claude_dir / 'settings.json').exists() def test_merge_keys_preserves_command_names_merges_selected( self, @@ -163,15 +176,16 @@ def test_child_cannot_escape_isolation( assert 'command-names' in resolved assert resolved['command-names'] == ['parent-cmd'] - # Routing must go to isolated dir + # Routing must build config.json in the isolated dir claude_dir = e2e_isolated_home['claude_dir'] primary_cmd = resolved['command-names'][0] artifact_base_dir = claude_dir / primary_cmd artifact_base_dir.mkdir(parents=True, exist_ok=True) - write_user_settings(resolved['user-settings'], artifact_base_dir) + create_profile_config({}, artifact_base_dir, user_settings=resolved['user-settings']) - assert (artifact_base_dir / 'settings.json').exists() + assert (artifact_base_dir / 'config.json').exists() + assert not (artifact_base_dir / 'settings.json').exists() assert not (claude_dir / 'settings.json').exists() def test_explicit_null_deisolates( @@ -212,47 +226,51 @@ def test_non_interference_between_configs( claude_dir = e2e_isolated_home['claude_dir'] configs: list[tuple[str, dict[str, Any]]] = [ - ('cmd-a', {'a': 1}), - ('cmd-b', {'b': 2}), + ('cmd-a', {'theme': 'dark'}), + ('cmd-b', {'theme': 'light'}), ] for cmd, user_settings in configs: target = claude_dir / cmd target.mkdir(parents=True, exist_ok=True) - write_user_settings(user_settings, target) - - settings_a = json.loads((claude_dir / 'cmd-a' / 'settings.json').read_text()) - settings_b = json.loads((claude_dir / 'cmd-b' / 'settings.json').read_text()) + create_profile_config({}, target, user_settings=user_settings) - assert settings_a == {'a': 1} - assert settings_b == {'b': 2} + config_a = json.loads((claude_dir / 'cmd-a' / 'config.json').read_text()) + config_b = json.loads((claude_dir / 'cmd-b' / 'config.json').read_text()) - # No cross-contamination - assert 'b' not in settings_a - assert 'a' not in settings_b + assert config_a == {'theme': 'dark'} + assert config_b == {'theme': 'light'} def test_no_user_settings_no_settings_json( self, e2e_isolated_home: dict[str, Path], ) -> None: - """Scenario 8: Config with command-names but no user-settings produces no settings.json.""" + """Scenario 8: Isolated config never yields a toolbox-written settings.json. + + The user-settings section is built into config.json, so the isolated + directory receives config.json but never a toolbox-written + settings.json (that file is Claude Code's user layer, not the + toolbox's). + """ config = _load_fixture('scope_nouser.yaml') claude_dir = e2e_isolated_home['claude_dir'] assert config['command-names'] == ['nousercmd'] - assert 'user-settings' not in config primary_cmd = config['command-names'][0] artifact_base_dir = claude_dir / primary_cmd artifact_base_dir.mkdir(parents=True, exist_ok=True) - # Matching main() logic: only call write_user_settings if user_settings is truthy - user_settings = config.get('user-settings') - if user_settings: - write_user_settings(user_settings, artifact_base_dir) + create_profile_config({}, artifact_base_dir, user_settings=config.get('user-settings')) + + # The user-settings model lands in config.json ... + isolated_config = artifact_base_dir / 'config.json' + assert isolated_config.exists() + assert json.loads(isolated_config.read_text()).get('model') == 'sonnet' + # ... and no toolbox-written settings.json exists anywhere. assert not (artifact_base_dir / 'settings.json').exists(), ( - 'settings.json should NOT be created when user-settings is absent' + 'toolbox must not write an isolated settings.json in isolated mode' ) assert not (claude_dir / 'settings.json').exists() diff --git a/tests/e2e/validators.py b/tests/e2e/validators.py index 0737f3a..ae0edc7 100644 --- a/tests/e2e/validators.py +++ b/tests/e2e/validators.py @@ -314,25 +314,27 @@ def _validate_mcp_server_config(name: str, server: dict[str, Any]) -> list[str]: def validate_settings(path: Path, config: dict[str, Any]) -> list[str]: - """Validate profile configuration (config.json). + """Validate the isolated profile configuration (config.json). - Validates the environment-specific settings file that is loaded via --settings flag. - Contains: model, permissions, env, hooks, attribution, statusLine, etc. + The isolated profile's config.json is loaded via the launcher's --settings + flag. It carries the profile's complete settings.json content: the + user-settings section (raw settings.json keys, camelCase) plus the + toolbox-built statusLine and hooks entries. The two sources are disjoint + by construction because 'statusLine' and 'hooks' are rejected inside + user-settings. Validates: - File exists and is valid JSON - - Model matches config if specified - - Permissions structure is correct - - MCP server permissions are auto-added to allow list - - Hooks structure is correct if present - - Environment variables are present - - alwaysThinkingEnabled matches config if specified - - effortLevel matches config if specified - - companyAnnouncements are present if specified - - statusLine structure is correct if specified + - Every non-null top-level user-settings key is present verbatim + - The user-settings env block has null entries stripped and non-null + string values preserved + - The user-settings permissions block is present verbatim (camelCase) + - Hooks structure (from the root-level hooks key) is correct if present + - statusLine structure (from the root-level status-line key) is correct + if specified Args: - path: Path to the settings.json file + path: Path to the config.json file config: Golden configuration dictionary Returns: @@ -346,103 +348,94 @@ def validate_settings(path: Path, config: dict[str, Any]) -> list[str]: assert data is not None # For type checker - # Model verification - if 'model' in config and data.get('model') != config['model']: - errors.append( - f"Model mismatch: expected {config['model']!r}, got {data.get('model')!r}", - ) + user_settings = config.get('user-settings', {}) - # Permissions validation - if 'permissions' in config: - if 'permissions' not in data: - errors.append("Missing 'permissions' block in settings.json") - else: - perm_errors = _validate_permissions(data['permissions'], config['permissions']) - errors.extend(perm_errors) - - # Environment variables - config_env = config.get('env-variables', {}) - env_block = data.get('env') or {} - for key, expected_value in config_env.items(): - # null-as-delete: a null-valued entry must be ABSENT from the - # atomically rebuilt config.json env block (never a JSON null and - # never the literal string 'None') + # Keys handled by dedicated validators below (env and permissions carry + # transform semantics) or verified structurally elsewhere. + specially_handled = {'env', 'permissions'} + + # Keys that undergo platform-conditional tilde handling during the write. + # Windows expands ~ to an absolute path (the shell cannot resolve ~); + # Linux/macOS/WSL preserve the tilde for Claude Code to resolve at runtime. + tilde_keys = {'apiKeyHelper', 'awsCredentialExport'} + + for key, expected_value in user_settings.items(): + if key in specially_handled: + continue + # RFC 7396: null-valued keys are stripped from the atomically rebuilt + # config.json (deletion-by-absence), never written as a JSON null. if expected_value is None: - if key in env_block: + if key in data: errors.append( - f"Env var '{key}': expected ABSENT (null-as-delete), " - f'but found {env_block[key]!r}', + f"config.json key '{key}': expected ABSENT (null-as-delete), " + f'but found {data[key]!r}', ) continue - actual = env_block.get(key) - # Production code coerces non-null env values to strings via str(v) - expected_str = str(expected_value) - if actual != expected_str: + actual_value = data.get(key) + if key in tilde_keys: + if sys.platform == 'win32': + # Windows: the tilde must have been expanded to an absolute path + if actual_value is None: + errors.append(f"config.json key '{key}': missing (expected expanded form)") + elif isinstance(actual_value, str) and actual_value.startswith('~'): + errors.append( + f"config.json key '{key}' contains unexpanded tilde: {actual_value}", + ) + elif actual_value is None: + errors.append(f"config.json key '{key}': missing (expected preserved form)") + elif actual_value != expected_value: + # Unix/WSL: the tilde is preserved verbatim + errors.append( + f"config.json key '{key}': expected tilde preserved " + f'{expected_value!r}, got {actual_value!r}', + ) + elif actual_value != expected_value: errors.append( - f"Env var '{key}': expected {expected_str!r}, got {actual!r}", + f"config.json key '{key}': expected {expected_value!r}, got {actual_value!r}", ) - # Hooks structure validation + # env block: null entries stripped, non-null values preserved verbatim + # (production writes string values as-is; the golden config quotes them). + config_env = user_settings.get('env') + if config_env is not None: + env_block = data.get('env') or {} + for key, expected_value in config_env.items(): + if expected_value is None: + if key in env_block: + errors.append( + f"config.json env '{key}': expected ABSENT (null-as-delete), " + f'but found {env_block[key]!r}', + ) + continue + actual = env_block.get(key) + if actual != expected_value: + errors.append( + f"config.json env '{key}': expected {expected_value!r}, got {actual!r}", + ) + + # Permissions validation (camelCase subkeys, no translation) + config_permissions = user_settings.get('permissions') + if config_permissions is not None: + if 'permissions' not in data: + errors.append("Missing 'permissions' block in config.json") + else: + errors.extend(_validate_permissions(data['permissions'], config_permissions)) + + # Hooks structure validation (from the root-level hooks key) hooks_config = config.get('hooks', {}) events = hooks_config.get('events', []) if events: if 'hooks' not in data: errors.append("Missing 'hooks' block (expected due to hooks.events in config)") else: - hooks_errors = _validate_hooks_structure(data['hooks'], hooks_config) - errors.extend(hooks_errors) - - # alwaysThinkingEnabled - if 'always-thinking-enabled' in config: - expected = config['always-thinking-enabled'] - actual = data.get('alwaysThinkingEnabled') - if actual != expected: - errors.append( - f'alwaysThinkingEnabled: expected {expected!r}, got {actual!r}', - ) + errors.extend(_validate_hooks_structure(data['hooks'], hooks_config)) - # effortLevel - if 'effort-level' in config: - expected = config['effort-level'] - actual = data.get('effortLevel') - if actual != expected: - errors.append( - f'effortLevel: expected {expected!r}, got {actual!r}', - ) - - # companyAnnouncements - if 'company-announcements' in config: - if 'companyAnnouncements' not in data: - errors.append("Missing 'companyAnnouncements' (expected due to config)") - else: - expected_count = len(config['company-announcements']) - actual_count = len(data.get('companyAnnouncements', [])) - if actual_count != expected_count: - errors.append( - f'companyAnnouncements count: expected {expected_count}, got {actual_count}', - ) - - # statusLine + # statusLine (from the root-level status-line key) if 'status-line' in config: if 'statusLine' not in data: errors.append("Missing 'statusLine' (expected due to config)") else: - status_errors = _validate_status_line(data['statusLine'], config['status-line']) - errors.extend(status_errors) - - # attribution - if 'attribution' in config: - if 'attribution' not in data: - errors.append("Missing 'attribution' (expected due to config)") - else: - for key in ['commit', 'pr']: - if key in config['attribution']: - expected = config['attribution'][key] - actual = data['attribution'].get(key) - if actual != expected: - errors.append( - f'attribution.{key}: expected {expected!r}, got {actual!r}', - ) + errors.extend(_validate_status_line(data['statusLine'], config['status-line'])) return errors @@ -450,19 +443,23 @@ def validate_settings(path: Path, config: dict[str, Any]) -> list[str]: def _validate_permissions(actual: dict[str, Any], expected: dict[str, Any]) -> list[str]: """Validate permissions structure. + Reads expectations from the user-settings permissions block, whose + sub-keys already use camelCase (defaultMode, additionalDirectories), so + no key translation is applied. + Args: actual: Actual permissions dict from generated file - expected: Expected permissions dict from config + expected: Expected permissions dict from user-settings Returns: List of error strings """ errors: list[str] = [] - # Check defaultMode (YAML key: default-mode, JSON key: defaultMode) - if 'default-mode' in expected and actual.get('defaultMode') != expected['default-mode']: + # Check defaultMode (camelCase in both sides) + if 'defaultMode' in expected and actual.get('defaultMode') != expected['defaultMode']: errors.append( - f"permissions.defaultMode: expected {expected['default-mode']!r}, " + f"permissions.defaultMode: expected {expected['defaultMode']!r}, " f"got {actual.get('defaultMode')!r}", ) @@ -490,9 +487,9 @@ def _validate_permissions(actual: dict[str, Any], expected: dict[str, Any]) -> l if item not in actual_ask ) - # Check additionalDirectories (YAML key: additional-directories, JSON key: additionalDirectories) - if 'additional-directories' in expected: - expected_dirs = expected['additional-directories'] + # Check additionalDirectories (camelCase in both sides) + if 'additionalDirectories' in expected: + expected_dirs = expected['additionalDirectories'] actual_dirs = actual.get('additionalDirectories', []) if actual_dirs != expected_dirs: errors.append( diff --git a/tests/scripts/models/test_environment_config.py b/tests/scripts/models/test_environment_config.py index 43c9b87..3afefab 100644 --- a/tests/scripts/models/test_environment_config.py +++ b/tests/scripts/models/test_environment_config.py @@ -73,7 +73,7 @@ def test_inherit_entry_multiple_merge_keys(self): """Multiple valid merge-keys accepted.""" entry = InheritEntry.model_validate({ 'config': 'x.yaml', - 'merge-keys': ['agents', 'rules', 'mcp-servers', 'env-variables'], + 'merge-keys': ['agents', 'rules', 'mcp-servers', 'os-env-variables'], }) assert len(entry.merge_keys) == 4 @@ -1184,390 +1184,6 @@ def test_environment_config_with_both_settings_types(self) -> None: assert config.global_config.model_extra.get('autoConnectIde') is True -class TestEffortLevel: - """Test effort-level field validation on EnvironmentConfig.""" - - def test_effort_level_low(self) -> None: - """effort-level 'low' validates correctly.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'effort-level': 'low', - }) - assert config.effort_level == 'low' - - def test_effort_level_medium(self) -> None: - """effort-level 'medium' validates correctly.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'effort-level': 'medium', - }) - assert config.effort_level == 'medium' - - def test_effort_level_high(self) -> None: - """effort-level 'high' validates correctly.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'effort-level': 'high', - }) - assert config.effort_level == 'high' - - def test_effort_level_none_by_default(self) -> None: - """effort-level defaults to None when not specified.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - }) - assert config.effort_level is None - - def test_effort_level_invalid_value_rejected(self) -> None: - """effort-level rejects invalid values.""" - with pytest.raises(ValidationError): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'effort-level': 'extreme', - }) - - def test_effort_level_empty_string_rejected(self) -> None: - """effort-level rejects empty string.""" - with pytest.raises(ValidationError): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'effort-level': '', - }) - - def test_effort_level_max_with_opus_alias(self) -> None: - """effort-level 'max' accepted when model is 'opus'.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'opus', - 'effort-level': 'max', - }) - assert config.effort_level == 'max' - - def test_effort_level_max_with_opus_1m_alias(self) -> None: - """effort-level 'max' accepted when model is 'opus[1m]'.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'opus[1m]', - 'effort-level': 'max', - }) - assert config.effort_level == 'max' - - def test_effort_level_max_with_opusplan_alias(self) -> None: - """effort-level 'max' accepted when model is 'opusplan'.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'opusplan', - 'effort-level': 'max', - }) - assert config.effort_level == 'max' - - def test_effort_level_max_with_claude_opus_model(self) -> None: - """effort-level 'max' accepted when model is 'claude-opus-4-6'.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'claude-opus-4-6', - 'effort-level': 'max', - }) - assert config.effort_level == 'max' - - def test_effort_level_max_with_claude_opus_1m_model(self) -> None: - """effort-level 'max' accepted when model is 'claude-opus-4-6[1m]'.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'claude-opus-4-6[1m]', - 'effort-level': 'max', - }) - assert config.effort_level == 'max' - - def test_effort_level_max_with_fable_alias(self) -> None: - """effort-level 'max' accepted when model is 'fable'.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'fable', - 'effort-level': 'max', - }) - assert config.effort_level == 'max' - - def test_effort_level_max_with_claude_fable_model(self) -> None: - """effort-level 'max' accepted when model is 'claude-fable-5'.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'claude-fable-5', - 'effort-level': 'max', - }) - assert config.effort_level == 'max' - - def test_effort_level_max_with_claude_fable_1m_model(self) -> None: - """effort-level 'max' accepted when model is 'claude-fable-5[1m]'.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'claude-fable-5[1m]', - 'effort-level': 'max', - }) - assert config.effort_level == 'max' - - def test_effort_level_max_with_bedrock_fable_model(self) -> None: - """effort-level 'max' accepted when model is a provider-prefixed Fable ID.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'us.anthropic.claude-fable-5-20260601-v1:0', - 'effort-level': 'max', - }) - assert config.effort_level == 'max' - - def test_effort_level_max_with_best_alias(self) -> None: - """effort-level 'max' accepted when model is exactly 'best'. - - The 'best' alias always resolves to Fable 5 or the latest Opus model, - both of which support 'max'. - """ - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'best', - 'effort-level': 'max', - }) - assert config.effort_level == 'max' - - def test_effort_level_max_rejected_with_sonnet_model(self) -> None: - """effort-level 'max' rejected when model is 'sonnet'.""" - with pytest.raises(ValidationError, match='only available for Opus and Fable models'): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'sonnet', - 'effort-level': 'max', - }) - - def test_effort_level_max_rejected_with_haiku_model(self) -> None: - """effort-level 'max' rejected when model is 'haiku'.""" - with pytest.raises(ValidationError, match='only available for Opus and Fable models'): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'haiku', - 'effort-level': 'max', - }) - - def test_effort_level_max_rejected_with_claude_sonnet_model(self) -> None: - """effort-level 'max' rejected when model is 'claude-sonnet-4-6'.""" - with pytest.raises(ValidationError, match='only available for Opus and Fable models'): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'claude-sonnet-4-6', - 'effort-level': 'max', - }) - - def test_effort_level_max_rejected_without_model(self) -> None: - """effort-level 'max' rejected when model is not specified.""" - with pytest.raises(ValidationError, match='requires model to be specified'): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'effort-level': 'max', - }) - - def test_effort_level_max_rejected_with_default_model(self) -> None: - """effort-level 'max' rejected when model is 'default'.""" - with pytest.raises(ValidationError, match='only available for Opus and Fable models'): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'default', - 'effort-level': 'max', - }) - - def test_effort_level_max_rejected_with_model_containing_best(self) -> None: - """effort-level 'max' rejected when model merely contains 'best'. - - The 'best' alias is accepted by exact match only; a substring match - would wrongly accept arbitrary model names containing 'best'. - """ - with pytest.raises(ValidationError, match='only available for Opus and Fable models'): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'bestest-model', - 'effort-level': 'max', - }) - - def test_effort_level_high_allowed_without_model(self) -> None: - """effort-level 'high' accepted even without model (non-max levels are unrestricted).""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'effort-level': 'high', - }) - assert config.effort_level == 'high' - - def test_effort_level_high_allowed_with_sonnet_model(self) -> None: - """effort-level 'high' accepted with non-opus model (non-max levels are unrestricted).""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'sonnet', - 'effort-level': 'high', - }) - assert config.effort_level == 'high' - - def test_effort_level_xhigh_with_opus_alias(self) -> None: - """effort-level 'xhigh' accepted when model is 'opus'.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'opus', - 'effort-level': 'xhigh', - }) - assert config.effort_level == 'xhigh' - - def test_effort_level_xhigh_with_opus_1m_alias(self) -> None: - """effort-level 'xhigh' accepted when model is 'opus[1m]'.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'opus[1m]', - 'effort-level': 'xhigh', - }) - assert config.effort_level == 'xhigh' - - def test_effort_level_xhigh_with_opusplan_alias(self) -> None: - """effort-level 'xhigh' accepted when model is 'opusplan'.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'opusplan', - 'effort-level': 'xhigh', - }) - assert config.effort_level == 'xhigh' - - def test_effort_level_xhigh_with_claude_opus_4_7_model(self) -> None: - """effort-level 'xhigh' accepted when model is 'claude-opus-4-7'.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'claude-opus-4-7', - 'effort-level': 'xhigh', - }) - assert config.effort_level == 'xhigh' - - def test_effort_level_xhigh_with_claude_opus_4_8_model(self) -> None: - """effort-level 'xhigh' accepted when model is 'claude-opus-4-8'.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'claude-opus-4-8', - 'effort-level': 'xhigh', - }) - assert config.effort_level == 'xhigh' - - def test_effort_level_xhigh_with_fable_alias(self) -> None: - """effort-level 'xhigh' accepted when model is 'fable'.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'fable', - 'effort-level': 'xhigh', - }) - assert config.effort_level == 'xhigh' - - def test_effort_level_xhigh_with_claude_fable_model(self) -> None: - """effort-level 'xhigh' accepted when model is 'claude-fable-5'.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'claude-fable-5', - 'effort-level': 'xhigh', - }) - assert config.effort_level == 'xhigh' - - def test_effort_level_xhigh_with_claude_fable_1m_model(self) -> None: - """effort-level 'xhigh' accepted when model is 'claude-fable-5[1m]'.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'claude-fable-5[1m]', - 'effort-level': 'xhigh', - }) - assert config.effort_level == 'xhigh' - - def test_effort_level_xhigh_with_bedrock_fable_model(self) -> None: - """effort-level 'xhigh' accepted when model is a provider-prefixed Fable ID.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'us.anthropic.claude-fable-5-20260601-v1:0', - 'effort-level': 'xhigh', - }) - assert config.effort_level == 'xhigh' - - def test_effort_level_xhigh_with_best_alias(self) -> None: - """effort-level 'xhigh' accepted when model is exactly 'best'. - - The 'best' alias always resolves to Fable 5 or the latest Opus model, - both of which support 'xhigh'. - """ - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'best', - 'effort-level': 'xhigh', - }) - assert config.effort_level == 'xhigh' - - def test_effort_level_xhigh_rejected_with_sonnet_model(self) -> None: - """effort-level 'xhigh' rejected when model is 'sonnet'.""" - with pytest.raises(ValidationError, match='only available for Opus and Fable models'): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'sonnet', - 'effort-level': 'xhigh', - }) - - def test_effort_level_xhigh_rejected_with_haiku_model(self) -> None: - """effort-level 'xhigh' rejected when model is 'haiku'.""" - with pytest.raises(ValidationError, match='only available for Opus and Fable models'): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'haiku', - 'effort-level': 'xhigh', - }) - - def test_effort_level_xhigh_rejected_with_claude_sonnet_model(self) -> None: - """effort-level 'xhigh' rejected when model is 'claude-sonnet-4-6'.""" - with pytest.raises(ValidationError, match='only available for Opus and Fable models'): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'claude-sonnet-4-6', - 'effort-level': 'xhigh', - }) - - def test_effort_level_xhigh_rejected_without_model(self) -> None: - """effort-level 'xhigh' rejected when model is not specified.""" - with pytest.raises(ValidationError, match='requires model to be specified'): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'effort-level': 'xhigh', - }) - - def test_effort_level_xhigh_rejected_with_default_model(self) -> None: - """effort-level 'xhigh' rejected when model is 'default'.""" - with pytest.raises(ValidationError, match='only available for Opus and Fable models'): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'default', - 'effort-level': 'xhigh', - }) - - def test_effort_level_xhigh_rejected_with_model_containing_best(self) -> None: - """effort-level 'xhigh' rejected when model merely contains 'best'. - - The 'best' alias is accepted by exact match only; a substring match - would wrongly accept arbitrary model names containing 'best'. - """ - with pytest.raises(ValidationError, match='only available for Opus and Fable models'): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'bestest-model', - 'effort-level': 'xhigh', - }) - - def test_effort_level_ultracode_rejected(self) -> None: - """effort-level 'ultracode' rejected: it is not an effortLevel value. - - 'ultracode' is a session-only Claude Code mode (set via /effort ultracode - or the 'ultracode' session flag), not a value accepted in the effortLevel - setting that this toolbox writes. It must never be an accepted effort-level. - """ - with pytest.raises(ValidationError): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'opus', - 'effort-level': 'ultracode', - }) - - class TestVersionValidation: """Test version field validation (semantic versioning).""" @@ -1766,11 +1382,11 @@ def test_merge_keys_empty_list(self) -> None: assert config.merge_keys == [] def test_merge_keys_all_valid_keys(self) -> None: - """Field accepts all 12 mergeable keys at once.""" + """Field accepts all 11 mergeable keys at once.""" all_keys = [ 'dependencies', 'agents', 'slash-commands', 'rules', 'skills', 'files-to-download', 'hooks', 'mcp-servers', - 'global-config', 'user-settings', 'env-variables', 'os-env-variables', + 'global-config', 'user-settings', 'os-env-variables', ] config = EnvironmentConfig.model_validate({ 'name': 'Test', @@ -1904,128 +1520,6 @@ def test_inherit_single_structured_entry_valid(self) -> None: assert len(config.inherit) == 1 -class TestModelValidation: - """Tests for relaxed model field validation.""" - - def test_claude_model_valid(self) -> None: - """Claude model name passes.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'claude-sonnet-4-20250514', - }) - assert config.model == 'claude-sonnet-4-20250514' - - def test_model_alias_valid(self) -> None: - """Known alias passes.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'opus', - }) - assert config.model == 'opus' - - def test_third_party_model_valid(self) -> None: - """Third-party model name passes.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'gpt-4o', - }) - assert config.model == 'gpt-4o' - - def test_openrouter_model_valid(self) -> None: - """OpenRouter-prefixed model passes.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': 'openrouter/anthropic/claude-3.5-sonnet', - }) - assert config.model == 'openrouter/anthropic/claude-3.5-sonnet' - - def test_empty_model_raises(self) -> None: - """Empty string model raises.""" - with pytest.raises(ValidationError, match='empty or whitespace'): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': '', - }) - - def test_whitespace_model_raises(self) -> None: - """Whitespace-only model raises.""" - with pytest.raises(ValidationError, match='empty or whitespace'): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'model': ' ', - }) - - def test_none_model_valid(self) -> None: - """None (omitted) model is valid.""" - config = EnvironmentConfig.model_validate({'name': 'Test'}) - assert config.model is None - - -class TestEnvVariablesValidation: - """Tests for env-variables field validation.""" - - def test_valid_env_variables(self) -> None: - """Valid environment variable names pass.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'env-variables': {'MY_VAR': 'value', '_PRIVATE': 'secret', 'PATH_EXT': '/usr/bin'}, - }) - assert config.env_variables == {'MY_VAR': 'value', '_PRIVATE': 'secret', 'PATH_EXT': '/usr/bin'} - - def test_invalid_env_variable_name_starts_with_digit(self) -> None: - """Variable name starting with digit raises.""" - with pytest.raises(ValidationError, match='Invalid environment variable name'): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'env-variables': {'1BAD': 'value'}, - }) - - def test_invalid_env_variable_name_has_dash(self) -> None: - """Variable name with dash raises.""" - with pytest.raises(ValidationError, match='Invalid environment variable name'): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'env-variables': {'MY-VAR': 'value'}, - }) - - def test_env_variable_value_with_null_byte_raises(self) -> None: - """Value containing null byte raises.""" - with pytest.raises(ValidationError, match='null bytes'): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'env-variables': {'MY_VAR': 'val\x00ue'}, - }) - - def test_none_env_variables_valid(self) -> None: - """None (omitted) env-variables is valid.""" - config = EnvironmentConfig.model_validate({'name': 'Test'}) - assert config.env_variables is None - - def test_empty_env_variables_valid(self) -> None: - """Empty dict for env-variables is valid.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'env-variables': {}, - }) - assert config.env_variables == {} - - def test_null_env_variable_value_valid(self) -> None: - """A null value (deletion request) passes validation and skips the null-byte check.""" - config = EnvironmentConfig.model_validate({ - 'name': 'Test', - 'env-variables': {'DELETE_ME': None, 'KEEP': 'value'}, - }) - assert config.env_variables == {'DELETE_ME': None, 'KEEP': 'value'} - - def test_invalid_name_with_null_value_still_raises(self) -> None: - """Name validation applies even when the value is a null deletion request.""" - with pytest.raises(ValidationError, match='Invalid environment variable name'): - EnvironmentConfig.model_validate({ - 'name': 'Test', - 'env-variables': {'1BAD': None}, - }) - - class TestMCPServerStdioArgs: """Tests for MCPServerStdio args field.""" @@ -2264,3 +1758,558 @@ def test_http_profile_mcp_without_command_names_raises(self) -> None: {'name': 'my-http', 'scope': 'profile', 'transport': 'http', 'url': 'http://localhost:3000'}, ], }) + + +class TestUserSettingsRootOnlyKeys: + """Reject root-level YAML keys placed inside user-settings.""" + + def test_status_line_kebab_rejected(self) -> None: + """'status-line' is a root-level YAML key, not a settings.json key.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'status-line': {'file': 'x.py'}}) + assert "Key 'status-line' is not allowed in user-settings" in str(exc_info.value) + assert 'root-level YAML key' in str(exc_info.value) + + def test_os_env_variables_rejected(self) -> None: + """'os-env-variables' is a root-level YAML key, not a settings.json key.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'os-env-variables': {'VAR': 'value'}}) + assert "Key 'os-env-variables' is not allowed in user-settings" in str(exc_info.value) + assert 'root-level YAML key' in str(exc_info.value) + + +class TestUserSettingsKebabCorrections: + """Reject kebab-case spellings of known camelCase settings keys.""" + + def test_always_thinking_enabled_kebab_rejected(self) -> None: + """'always-thinking-enabled' must be spelled 'alwaysThinkingEnabled'.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'always-thinking-enabled': True}) + assert "Key 'always-thinking-enabled' is not a settings.json key" in str(exc_info.value) + assert "use 'alwaysThinkingEnabled' instead" in str(exc_info.value) + + def test_company_announcements_kebab_rejected(self) -> None: + """'company-announcements' must be spelled 'companyAnnouncements'.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'company-announcements': ['note']}) + assert "use 'companyAnnouncements' instead" in str(exc_info.value) + + def test_effort_level_kebab_rejected(self) -> None: + """'effort-level' must be spelled 'effortLevel'.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'effort-level': 'high'}) + assert "use 'effortLevel' instead" in str(exc_info.value) + + def test_env_variables_kebab_rejected(self) -> None: + """'env-variables' must be spelled 'env'.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'env-variables': {'VAR': 'value'}}) + assert "Key 'env-variables' is not a settings.json key" in str(exc_info.value) + assert "use 'env' instead" in str(exc_info.value) + + +class TestUserSettingsGlobalOnlyKeys: + """Reject keys that live in ~/.claude.json (global-config).""" + + @pytest.mark.parametrize('key', [ + 'autoUpdates', + 'installMethod', + 'autoConnectIde', + 'autoInstallIdeExtension', + 'externalEditorContext', + 'teammateDefaultModel', + ]) + def test_global_only_key_rejected(self, key: str) -> None: + """Global-config keys are rejected in user-settings.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({key: 'value'}) + assert f"Key '{key}' belongs in global-config" in str(exc_info.value) + assert 'not in user-settings' in str(exc_info.value) + + def test_global_only_key_rejected_even_when_null(self) -> None: + """Placement is wrong regardless of value; a null global-only key is still rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'autoConnectIde': None}) + assert "Key 'autoConnectIde' belongs in global-config" in str(exc_info.value) + + +class TestUserSettingsModelValue: + """Validate the user-settings model value shape.""" + + def test_model_string_accepted(self) -> None: + """A non-empty model string is accepted.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'model': 'opus'}) + assert settings.model_extra is not None + assert settings.model_extra.get('model') == 'opus' + + def test_model_empty_string_rejected(self) -> None: + """An empty model string is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'model': ''}) + assert 'user-settings.model must be a non-empty string' in str(exc_info.value) + + def test_model_non_string_rejected(self) -> None: + """A non-string model is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'model': 123}) + assert 'user-settings.model must be a non-empty string' in str(exc_info.value) + + def test_model_null_allowed(self) -> None: + """A null model is a deletion request and is allowed.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'model': None}) + assert settings.model_extra is not None + assert settings.model_extra.get('model') is None + + +class TestUserSettingsEnvValue: + """Validate the user-settings env value shape.""" + + def test_env_string_values_accepted(self) -> None: + """A mapping of names to string values is accepted.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'env': {'FOO': 'bar', 'BAZ': 'qux'}}) + assert settings.model_extra is not None + assert settings.model_extra['env'] == {'FOO': 'bar', 'BAZ': 'qux'} + + def test_env_null_entry_value_allowed(self) -> None: + """A null env entry value is a deletion request and is allowed.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'env': {'DELETE_ME': None, 'KEEP': 'v'}}) + assert settings.model_extra is not None + assert settings.model_extra['env'] == {'DELETE_ME': None, 'KEEP': 'v'} + + def test_env_non_string_value_rejected(self) -> None: + """A non-string, non-null env value is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'env': {'PORT': 8080}}) + assert 'user-settings.env.PORT must be a string' in str(exc_info.value) + assert 'quote the value in YAML' in str(exc_info.value) + + def test_env_not_a_mapping_rejected(self) -> None: + """A non-mapping env value is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'env': ['FOO=bar']}) + assert 'user-settings.env must be a mapping' in str(exc_info.value) + + def test_env_invalid_name_rejected(self) -> None: + """An invalid environment variable name is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'env': {'1BAD': 'value'}}) + assert 'invalid environment variable name' in str(exc_info.value) + + def test_env_value_with_null_byte_rejected(self) -> None: + """A string env value containing a null byte is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'env': {'FOO': 'bar\x00baz'}}) + assert 'user-settings.env.FOO value cannot contain null bytes' in str(exc_info.value) + + def test_env_null_whole_value_allowed(self) -> None: + """A null env value (deleting the whole env key) is allowed.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'env': None}) + assert settings.model_extra is not None + assert settings.model_extra.get('env') is None + + +class TestUserSettingsPermissionsValue: + """Validate the user-settings permissions value shape.""" + + def test_permissions_valid_shape_accepted(self) -> None: + """A well-formed permissions mapping is accepted.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({ + 'permissions': { + 'defaultMode': 'acceptEdits', + 'allow': ['Read(*)'], + 'deny': ['Bash(rm *)'], + 'ask': ['Write(*)'], + 'additionalDirectories': ['/tmp'], + }, + }) + assert settings.model_extra is not None + assert settings.model_extra['permissions']['defaultMode'] == 'acceptEdits' + + def test_permissions_not_a_mapping_rejected(self) -> None: + """A non-mapping permissions value is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'permissions': ['Read(*)']}) + assert 'user-settings.permissions must be a mapping' in str(exc_info.value) + + def test_permissions_default_mode_kebab_rejected(self) -> None: + """Kebab-case 'default-mode' must be 'defaultMode'.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'permissions': {'default-mode': 'default'}}) + assert 'user-settings.permissions uses camelCase keys' in str(exc_info.value) + assert "use 'defaultMode' instead of 'default-mode'" in str(exc_info.value) + + def test_permissions_additional_directories_kebab_rejected(self) -> None: + """Kebab-case 'additional-directories' must be 'additionalDirectories'.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'permissions': {'additional-directories': ['/tmp']}}) + assert "use 'additionalDirectories' instead of 'additional-directories'" in str(exc_info.value) + + def test_permissions_default_mode_invalid_enum_rejected(self) -> None: + """An unknown defaultMode value is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'permissions': {'defaultMode': 'turbo'}}) + assert 'user-settings.permissions.defaultMode must be one of' in str(exc_info.value) + + @pytest.mark.parametrize('mode', [ + 'acceptEdits', 'auto', 'bypassPermissions', 'default', 'delegate', 'dontAsk', 'plan', + ]) + def test_permissions_default_mode_valid_enum_accepted(self, mode: str) -> None: + """Every documented defaultMode value is accepted.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'permissions': {'defaultMode': mode}}) + assert settings.model_extra is not None + assert settings.model_extra['permissions']['defaultMode'] == mode + + def test_permissions_list_key_non_list_rejected(self) -> None: + """A list-typed permissions key with a non-list value is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'permissions': {'allow': 'Read(*)'}}) + assert 'user-settings.permissions.allow must be a list of strings' in str(exc_info.value) + + def test_permissions_list_key_non_string_item_rejected(self) -> None: + """A list-typed permissions key with a non-string item is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'permissions': {'deny': [123]}}) + assert 'user-settings.permissions.deny must be a list of strings' in str(exc_info.value) + + def test_permissions_null_allowed(self) -> None: + """A null permissions value is a deletion request and is allowed.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'permissions': None}) + assert settings.model_extra is not None + assert settings.model_extra.get('permissions') is None + + def test_permissions_unknown_subkey_passes_through(self) -> None: + """Unknown permissions sub-keys pass through untouched.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'permissions': {'futureOption': True}}) + assert settings.model_extra is not None + assert settings.model_extra['permissions']['futureOption'] is True + + +class TestUserSettingsAttributionValue: + """Validate the user-settings attribution value shape.""" + + def test_attribution_valid_shape_accepted(self) -> None: + """A well-formed attribution mapping is accepted.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'attribution': {'commit': '', 'pr': 'Co-authored-by'}}) + assert settings.model_extra is not None + assert settings.model_extra['attribution'] == {'commit': '', 'pr': 'Co-authored-by'} + + def test_attribution_not_a_mapping_rejected(self) -> None: + """A non-mapping attribution value is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'attribution': 'none'}) + assert 'user-settings.attribution must be a mapping' in str(exc_info.value) + + def test_attribution_non_string_sub_value_rejected(self) -> None: + """A non-string attribution sub-value is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'attribution': {'commit': True}}) + assert 'user-settings.attribution.commit must be a string' in str(exc_info.value) + + def test_attribution_null_allowed(self) -> None: + """A null attribution value is a deletion request and is allowed.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'attribution': None}) + assert settings.model_extra is not None + assert settings.model_extra.get('attribution') is None + + +class TestUserSettingsAlwaysThinkingEnabledValue: + """Validate the user-settings alwaysThinkingEnabled value shape.""" + + def test_boolean_accepted(self) -> None: + """A boolean value is accepted.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'alwaysThinkingEnabled': True}) + assert settings.model_extra is not None + assert settings.model_extra['alwaysThinkingEnabled'] is True + + def test_non_boolean_rejected(self) -> None: + """A non-boolean value is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'alwaysThinkingEnabled': 'yes'}) + assert 'user-settings.alwaysThinkingEnabled must be a boolean' in str(exc_info.value) + + def test_null_allowed(self) -> None: + """A null value is a deletion request and is allowed.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'alwaysThinkingEnabled': None}) + assert settings.model_extra is not None + assert settings.model_extra.get('alwaysThinkingEnabled') is None + + +class TestUserSettingsCompanyAnnouncementsValue: + """Validate the user-settings companyAnnouncements value shape.""" + + def test_list_of_strings_accepted(self) -> None: + """A list of strings is accepted.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'companyAnnouncements': ['a', 'b']}) + assert settings.model_extra is not None + assert settings.model_extra['companyAnnouncements'] == ['a', 'b'] + + def test_non_list_rejected(self) -> None: + """A non-list value is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'companyAnnouncements': 'single'}) + assert 'user-settings.companyAnnouncements must be a list of strings' in str(exc_info.value) + + def test_non_string_item_rejected(self) -> None: + """A list with a non-string item is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'companyAnnouncements': ['ok', 42]}) + assert 'user-settings.companyAnnouncements must be a list of strings' in str(exc_info.value) + + def test_null_allowed(self) -> None: + """A null value is a deletion request and is allowed.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'companyAnnouncements': None}) + assert settings.model_extra is not None + assert settings.model_extra.get('companyAnnouncements') is None + + +class TestUserSettingsEffortLevelValue: + """Validate the user-settings effortLevel value and its model support.""" + + @pytest.mark.parametrize('level', ['low', 'medium', 'high']) + def test_unrestricted_levels_accepted_without_model(self, level: str) -> None: + """low/medium/high require no model and are accepted.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'effortLevel': level}) + assert settings.model_extra is not None + assert settings.model_extra['effortLevel'] == level + + def test_invalid_effort_level_rejected(self) -> None: + """An unknown effortLevel value is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'effortLevel': 'extreme'}) + assert 'user-settings.effortLevel must be one of' in str(exc_info.value) + + def test_null_allowed(self) -> None: + """A null effortLevel value is a deletion request and is allowed.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'effortLevel': None}) + assert settings.model_extra is not None + assert settings.model_extra.get('effortLevel') is None + + # --- xhigh model support cross-check --- + def test_xhigh_requires_model(self) -> None: + """effortLevel 'xhigh' without a model is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'effortLevel': 'xhigh'}) + assert 'requires user-settings.model to be specified' in str(exc_info.value) + + def test_xhigh_with_opus_accepted(self) -> None: + """effortLevel 'xhigh' with an Opus model is accepted.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'model': 'claude-opus-4-8', 'effortLevel': 'xhigh'}) + assert settings.model_extra is not None + assert settings.model_extra['effortLevel'] == 'xhigh' + + def test_xhigh_with_fable_accepted(self) -> None: + """effortLevel 'xhigh' with a Fable model is accepted.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'model': 'claude-fable-5', 'effortLevel': 'xhigh'}) + assert settings.model_extra is not None + assert settings.model_extra['effortLevel'] == 'xhigh' + + def test_xhigh_with_best_alias_accepted(self) -> None: + """effortLevel 'xhigh' with the exact 'best' alias is accepted.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'model': 'best', 'effortLevel': 'xhigh'}) + assert settings.model_extra is not None + assert settings.model_extra['effortLevel'] == 'xhigh' + + def test_xhigh_with_sonnet_rejected(self) -> None: + """effortLevel 'xhigh' with a Sonnet model is rejected (Opus/Fable only).""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'model': 'claude-sonnet-4-6', 'effortLevel': 'xhigh'}) + assert 'only available for Opus and Fable models' in str(exc_info.value) + + def test_xhigh_with_bestish_substring_rejected(self) -> None: + """effortLevel 'xhigh' with a model merely containing 'best' is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'model': 'bestish-model', 'effortLevel': 'xhigh'}) + assert 'only available for Opus and Fable models' in str(exc_info.value) + + # --- max model support cross-check --- + def test_max_requires_model(self) -> None: + """effortLevel 'max' without a model is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'effortLevel': 'max'}) + assert 'requires user-settings.model to be specified' in str(exc_info.value) + + def test_max_with_sonnet_accepted(self) -> None: + """effortLevel 'max' with a Sonnet model is accepted (Sonnet supports max).""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'model': 'claude-sonnet-4-6', 'effortLevel': 'max'}) + assert settings.model_extra is not None + assert settings.model_extra['effortLevel'] == 'max' + + def test_max_with_opus_accepted(self) -> None: + """effortLevel 'max' with an Opus model is accepted.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'model': 'opus', 'effortLevel': 'max'}) + assert settings.model_extra is not None + assert settings.model_extra['effortLevel'] == 'max' + + def test_max_with_best_alias_accepted(self) -> None: + """effortLevel 'max' with the exact 'best' alias is accepted.""" + from scripts.models.environment_config import UserSettings + settings = UserSettings.model_validate({'model': 'best', 'effortLevel': 'max'}) + assert settings.model_extra is not None + assert settings.model_extra['effortLevel'] == 'max' + + def test_max_with_haiku_rejected(self) -> None: + """effortLevel 'max' with a Haiku model is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'model': 'haiku', 'effortLevel': 'max'}) + assert 'only available for Opus, Sonnet, and Fable models' in str(exc_info.value) + + def test_max_with_bestish_substring_rejected(self) -> None: + """effortLevel 'max' with a model merely containing 'best' is rejected.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({'model': 'bestish-model', 'effortLevel': 'max'}) + assert 'only available for Opus, Sonnet, and Fable models' in str(exc_info.value) + + +class TestUserSettingsMultipleErrors: + """Multiple validation failures are surfaced together (newline-joined).""" + + def test_multiple_errors_reported(self) -> None: + """Several rule violations appear in one raised message.""" + from scripts.models.environment_config import UserSettings + with pytest.raises(ValidationError) as exc_info: + UserSettings.model_validate({ + 'model': '', + 'autoUpdates': False, + 'effort-level': 'high', + }) + message = str(exc_info.value) + assert 'user-settings.model must be a non-empty string' in message + assert "Key 'autoUpdates' belongs in global-config" in message + assert "use 'effortLevel' instead" in message + + +class TestGlobalConfigKnownKeyPlacement: + """Reject known settings.json keys misplaced into global-config.""" + + @pytest.mark.parametrize('key', [ + 'model', + 'permissions', + 'env', + 'attribution', + 'alwaysThinkingEnabled', + 'effortLevel', + 'companyAnnouncements', + 'availableModels', + 'enforceAvailableModels', + ]) + def test_settings_only_key_rejected(self, key: str) -> None: + """A settings.json key placed in global-config is rejected.""" + from scripts.models.environment_config import GlobalConfig + with pytest.raises(ValidationError) as exc_info: + GlobalConfig.model_validate({key: 'value'}) + message = str(exc_info.value) + assert f"Key '{key}' is a settings.json key" in message + assert 'Move it to user-settings' in message + + def test_status_line_key_rejected_with_root_hint(self) -> None: + """'statusLine' in global-config points to the root-level status-line key.""" + from scripts.models.environment_config import GlobalConfig + with pytest.raises(ValidationError) as exc_info: + GlobalConfig.model_validate({'statusLine': {'file': 'x.py'}}) + message = str(exc_info.value) + assert "Key 'statusLine' is not valid in global-config" in message + assert "root-level 'status-line' YAML key" in message + + def test_hooks_key_rejected_with_root_hint(self) -> None: + """'hooks' in global-config points to the root-level hooks key.""" + from scripts.models.environment_config import GlobalConfig + with pytest.raises(ValidationError) as exc_info: + GlobalConfig.model_validate({'hooks': {'events': []}}) + message = str(exc_info.value) + assert "Key 'hooks' is not valid in global-config" in message + assert "root-level 'hooks' YAML key" in message + + def test_settings_only_null_value_still_rejected(self) -> None: + """Placement errors apply regardless of value, including null.""" + from scripts.models.environment_config import GlobalConfig + with pytest.raises(ValidationError) as exc_info: + GlobalConfig.model_validate({'model': None}) + assert "Key 'model' is a settings.json key" in str(exc_info.value) + + def test_global_only_key_accepted(self) -> None: + """A genuine global-config key passes.""" + from scripts.models.environment_config import GlobalConfig + config = GlobalConfig.model_validate({'autoUpdates': True, 'installMethod': 'native'}) + assert config.model_extra is not None + assert config.model_extra['autoUpdates'] is True + assert config.model_extra['installMethod'] == 'native' + + +class TestSettingsValidationValueFunctions: + """Direct coverage of the pure validation helpers.""" + + def test_validate_user_settings_values_empty(self) -> None: + """No known keys means no errors.""" + from scripts.models.environment_config import validate_user_settings_values + assert validate_user_settings_values({'customKey': 'value'}) == [] + + def test_validate_user_settings_values_collects_errors(self) -> None: + """Errors are returned as a list rather than raised.""" + from scripts.models.environment_config import validate_user_settings_values + errors = validate_user_settings_values({'status-line': {}, 'model': ''}) + assert len(errors) == 2 + + def test_validate_global_config_values_empty(self) -> None: + """No settings.json keys means no errors.""" + from scripts.models.environment_config import validate_global_config_values + assert validate_global_config_values({'autoConnectIde': True}) == [] + + def test_validate_global_config_values_collects_errors(self) -> None: + """Settings-only keys are returned as errors.""" + from scripts.models.environment_config import validate_global_config_values + errors = validate_global_config_values({'model': 'opus', 'hooks': {}}) + assert len(errors) == 2 diff --git a/tests/scripts/models/test_settings_validation_parity.py b/tests/scripts/models/test_settings_validation_parity.py new file mode 100644 index 0000000..b3853e1 --- /dev/null +++ b/tests/scripts/models/test_settings_validation_parity.py @@ -0,0 +1,88 @@ +"""Parity test: settings-validation constants must match across both modules. + +setup_environment.py and scripts/models/environment_config.py each define the +same set of constants that drive user-settings and global-config validation. +The standalone script policy forbids a cross-import, so the two definitions are +deliberate duplicates. This test enforces strict equality between them: every +shared constant, plus the two excluded-key frozensets, must be identical in both +modules. + +If this test fails, a validation constant was changed in one module but not the +other. Fix: update BOTH modules so the constants match exactly. +""" + +from __future__ import annotations + +from scripts.models import environment_config as model_mod +from scripts.setup_environment import EFFORT_LEVEL_VALUES as SETUP_EFFORT_LEVEL_VALUES +from scripts.setup_environment import ENV_VAR_NAME_PATTERN as SETUP_ENV_VAR_NAME_PATTERN +from scripts.setup_environment import GLOBAL_CONFIG_EXCLUDED_KEYS as SETUP_GLOBAL_CONFIG_EXCLUDED_KEYS +from scripts.setup_environment import GLOBAL_CONFIG_SETTINGS_ONLY_KEYS as SETUP_GLOBAL_CONFIG_SETTINGS_ONLY_KEYS +from scripts.setup_environment import MAX_EFFORT_MODEL_MARKERS as SETUP_MAX_EFFORT_MODEL_MARKERS +from scripts.setup_environment import PERMISSIONS_DEFAULT_MODE_VALUES as SETUP_PERMISSIONS_DEFAULT_MODE_VALUES +from scripts.setup_environment import PERMISSIONS_KEBAB_KEY_CORRECTIONS as SETUP_PERMISSIONS_KEBAB_KEY_CORRECTIONS +from scripts.setup_environment import USER_SETTINGS_EXCLUDED_KEYS as SETUP_USER_SETTINGS_EXCLUDED_KEYS +from scripts.setup_environment import USER_SETTINGS_GLOBAL_ONLY_KEYS as SETUP_USER_SETTINGS_GLOBAL_ONLY_KEYS +from scripts.setup_environment import USER_SETTINGS_KEBAB_KEY_CORRECTIONS as SETUP_USER_SETTINGS_KEBAB_KEY_CORRECTIONS +from scripts.setup_environment import USER_SETTINGS_ROOT_ONLY_KEYS as SETUP_USER_SETTINGS_ROOT_ONLY_KEYS +from scripts.setup_environment import XHIGH_EFFORT_MODEL_MARKERS as SETUP_XHIGH_EFFORT_MODEL_MARKERS + + +def test_xhigh_effort_model_markers_parity() -> None: + """XHIGH_EFFORT_MODEL_MARKERS is identical in both modules.""" + assert SETUP_XHIGH_EFFORT_MODEL_MARKERS == model_mod.XHIGH_EFFORT_MODEL_MARKERS + + +def test_max_effort_model_markers_parity() -> None: + """MAX_EFFORT_MODEL_MARKERS is identical in both modules.""" + assert SETUP_MAX_EFFORT_MODEL_MARKERS == model_mod.MAX_EFFORT_MODEL_MARKERS + + +def test_effort_level_values_parity() -> None: + """EFFORT_LEVEL_VALUES is identical in both modules.""" + assert SETUP_EFFORT_LEVEL_VALUES == model_mod.EFFORT_LEVEL_VALUES + + +def test_permissions_default_mode_values_parity() -> None: + """PERMISSIONS_DEFAULT_MODE_VALUES is identical in both modules.""" + assert SETUP_PERMISSIONS_DEFAULT_MODE_VALUES == model_mod.PERMISSIONS_DEFAULT_MODE_VALUES + + +def test_user_settings_kebab_key_corrections_parity() -> None: + """USER_SETTINGS_KEBAB_KEY_CORRECTIONS is identical in both modules.""" + assert SETUP_USER_SETTINGS_KEBAB_KEY_CORRECTIONS == model_mod.USER_SETTINGS_KEBAB_KEY_CORRECTIONS + + +def test_permissions_kebab_key_corrections_parity() -> None: + """PERMISSIONS_KEBAB_KEY_CORRECTIONS is identical in both modules.""" + assert SETUP_PERMISSIONS_KEBAB_KEY_CORRECTIONS == model_mod.PERMISSIONS_KEBAB_KEY_CORRECTIONS + + +def test_user_settings_root_only_keys_parity() -> None: + """USER_SETTINGS_ROOT_ONLY_KEYS is identical in both modules.""" + assert SETUP_USER_SETTINGS_ROOT_ONLY_KEYS == model_mod.USER_SETTINGS_ROOT_ONLY_KEYS + + +def test_user_settings_global_only_keys_parity() -> None: + """USER_SETTINGS_GLOBAL_ONLY_KEYS is identical in both modules.""" + assert SETUP_USER_SETTINGS_GLOBAL_ONLY_KEYS == model_mod.USER_SETTINGS_GLOBAL_ONLY_KEYS + + +def test_global_config_settings_only_keys_parity() -> None: + """GLOBAL_CONFIG_SETTINGS_ONLY_KEYS is identical in both modules.""" + assert SETUP_GLOBAL_CONFIG_SETTINGS_ONLY_KEYS == model_mod.GLOBAL_CONFIG_SETTINGS_ONLY_KEYS + + +def test_env_var_name_pattern_parity() -> None: + """ENV_VAR_NAME_PATTERN compiles the same source in both modules.""" + assert SETUP_ENV_VAR_NAME_PATTERN.pattern == model_mod.ENV_VAR_NAME_PATTERN.pattern + + +def test_user_settings_excluded_keys_parity() -> None: + """USER_SETTINGS_EXCLUDED_KEYS holds the same keys in both modules.""" + assert set(SETUP_USER_SETTINGS_EXCLUDED_KEYS) == set(model_mod.USER_SETTINGS_EXCLUDED_KEYS) + + +def test_global_config_excluded_keys_parity() -> None: + """GLOBAL_CONFIG_EXCLUDED_KEYS is identical in both modules.""" + assert SETUP_GLOBAL_CONFIG_EXCLUDED_KEYS == model_mod.GLOBAL_CONFIG_EXCLUDED_KEYS diff --git a/tests/test_setup_environment.py b/tests/test_setup_environment.py index 8c3e28d..7b1fa72 100644 --- a/tests/test_setup_environment.py +++ b/tests/test_setup_environment.py @@ -3632,12 +3632,12 @@ class TestCreateSettings: """Test settings creation.""" def test_create_profile_config_basic(self): - """Test creating basic settings.""" + """Test creating basic empty config.json.""" with tempfile.TemporaryDirectory() as tmpdir: claude_dir = Path(tmpdir) result = setup_environment.create_profile_config( - {'model': 'claude-3-opus'}, + {}, claude_dir, ) @@ -3646,54 +3646,136 @@ def test_create_profile_config_basic(self): assert settings_file.exists() settings = json.loads(settings_file.read_text()) - assert settings['model'] == 'claude-3-opus' + assert settings == {} - def test_create_profile_config_with_mcp_permissions(self): - """Test creating settings without automatic MCP server permissions.""" + def test_create_profile_config_user_settings_content_written(self): + """Test user-settings content lands in config.json verbatim.""" with tempfile.TemporaryDirectory() as tmpdir: claude_dir = Path(tmpdir) + user_settings = { + 'model': 'claude-3-opus', + 'permissions': {'allow': ['mcp__server1'], 'deny': ['mcp__server3']}, + 'effortLevel': 'high', + } + result = setup_environment.create_profile_config( {}, claude_dir, + user_settings=user_settings, ) assert result is True settings_file = claude_dir / 'config.json' settings = json.loads(settings_file.read_text()) - # MCP servers should NOT be automatically added to permissions - assert 'permissions' not in settings or ( - 'mcp__server1' not in settings.get('permissions', {}).get('allow', []) - and 'mcp__server2' not in settings.get('permissions', {}).get('allow', []) + assert settings['model'] == 'claude-3-opus' + assert settings['permissions']['allow'] == ['mcp__server1'] + assert settings['permissions']['deny'] == ['mcp__server3'] + assert settings['effortLevel'] == 'high' + + def test_create_profile_config_user_settings_top_level_null_stripped(self): + """Test a top-level null user-settings member is stripped from config.json.""" + with tempfile.TemporaryDirectory() as tmpdir: + claude_dir = Path(tmpdir) + + user_settings = {'model': 'sonnet', 'effortLevel': None} + + result = setup_environment.create_profile_config( + {}, + claude_dir, + user_settings=user_settings, ) - def test_create_profile_config_with_explicit_permissions(self): - """Test that explicit permissions are still preserved.""" + assert result is True + settings = json.loads((claude_dir / 'config.json').read_text()) + + assert settings == {'model': 'sonnet'} + assert 'effortLevel' not in settings + + def test_create_profile_config_user_settings_nested_null_stripped(self): + """Test a nested null member is stripped recursively from config.json.""" with tempfile.TemporaryDirectory() as tmpdir: claude_dir = Path(tmpdir) - permissions = { - 'allow': ['mcp__server1', 'tool__*'], - 'deny': ['mcp__server3'], + user_settings = { + 'env': {'KEEP': '1', 'DROP': None}, + 'permissions': {'allow': ['tool__*'], 'defaultMode': None}, } result = setup_environment.create_profile_config( - {'permissions': permissions}, + {}, claude_dir, + user_settings=user_settings, ) assert result is True - settings_file = claude_dir / 'config.json' - settings = json.loads(settings_file.read_text()) + settings = json.loads((claude_dir / 'config.json').read_text()) + + assert settings['env'] == {'KEEP': '1'} + assert 'DROP' not in settings['env'] + assert settings['permissions'] == {'allow': ['tool__*']} + assert 'defaultMode' not in settings['permissions'] + + def test_create_profile_config_user_settings_list_null_preserved(self): + """Test None elements inside lists are preserved (RFC 7396 object-member-only).""" + with tempfile.TemporaryDirectory() as tmpdir: + claude_dir = Path(tmpdir) + + user_settings = {'someList': ['a', None, 'b']} + + result = setup_environment.create_profile_config( + {}, + claude_dir, + user_settings=user_settings, + ) + + assert result is True + settings = json.loads((claude_dir / 'config.json').read_text()) - # Explicit permissions should be preserved exactly as provided - assert 'permissions' in settings - assert 'mcp__server1' in settings['permissions']['allow'] - assert 'tool__*' in settings['permissions']['allow'] - # server2 should NOT be auto-added - assert 'mcp__server2' not in settings['permissions']['allow'] - assert 'mcp__server3' in settings['permissions']['deny'] + assert settings['someList'] == ['a', None, 'b'] + + @patch('setup_environment.handle_resource') + def test_create_profile_config_user_settings_and_hooks_combine(self, mock_download): + """Test user-settings content and built hooks combine disjointly in config.json.""" + mock_download.return_value = True + + with tempfile.TemporaryDirectory() as tmpdir: + claude_dir = Path(tmpdir) + + hooks = { + 'files': ['hooks/test.py'], + 'events': [ + { + 'event': 'PostToolUse', + 'matcher': 'Edit', + 'type': 'command', + 'command': 'test.py', + }, + ], + } + user_settings = {'model': 'sonnet', 'effortLevel': 'high'} + + setup_environment.download_hook_files( + hooks, + claude_dir, + config_source='https://example.com/config.yaml', + ) + + result = setup_environment.create_profile_config( + {'hooks': hooks}, + claude_dir, + user_settings=user_settings, + ) + + assert result is True + settings = json.loads((claude_dir / 'config.json').read_text()) + + # user-settings content + assert settings['model'] == 'sonnet' + assert settings['effortLevel'] == 'high' + # built hooks + assert 'PostToolUse' in settings['hooks'] @patch('setup_environment.handle_resource') def test_create_profile_config_with_hooks(self, mock_download): @@ -3962,286 +4044,6 @@ def test_create_profile_config_javascript_case_insensitive(self, mock_download): hook_command = settings['hooks']['PostToolUse'][0]['hooks'][0]['command'] assert hook_command.startswith('node ') - def test_create_profile_config_always_thinking_enabled_true(self): - """Test alwaysThinkingEnabled set to true.""" - with tempfile.TemporaryDirectory() as tmpdir: - claude_dir = Path(tmpdir) - - result = setup_environment.create_profile_config( - {'alwaysThinkingEnabled': True}, - claude_dir, - ) - - assert result is True - settings_file = claude_dir / 'config.json' - settings = json.loads(settings_file.read_text()) - - assert 'alwaysThinkingEnabled' in settings - assert settings['alwaysThinkingEnabled'] is True - - def test_create_profile_config_always_thinking_enabled_false(self): - """Test alwaysThinkingEnabled set to false.""" - with tempfile.TemporaryDirectory() as tmpdir: - claude_dir = Path(tmpdir) - - result = setup_environment.create_profile_config( - {'alwaysThinkingEnabled': False}, - claude_dir, - ) - - assert result is True - settings_file = claude_dir / 'config.json' - settings = json.loads(settings_file.read_text()) - - assert 'alwaysThinkingEnabled' in settings - assert settings['alwaysThinkingEnabled'] is False - - def test_create_profile_config_always_thinking_enabled_none_not_included(self): - """Test alwaysThinkingEnabled not included when absent from profile_config.""" - with tempfile.TemporaryDirectory() as tmpdir: - claude_dir = Path(tmpdir) - - result = setup_environment.create_profile_config( - {}, - claude_dir, - ) - - assert result is True - settings_file = claude_dir / 'config.json' - settings = json.loads(settings_file.read_text()) - - assert 'alwaysThinkingEnabled' not in settings - - def test_create_profile_config_effort_level_low(self): - """Test effortLevel set to low.""" - with tempfile.TemporaryDirectory() as tmpdir: - claude_dir = Path(tmpdir) - - result = setup_environment.create_profile_config( - {'effortLevel': 'low'}, - claude_dir, - ) - - assert result is True - settings_file = claude_dir / 'config.json' - settings = json.loads(settings_file.read_text()) - - assert 'effortLevel' in settings - assert settings['effortLevel'] == 'low' - - def test_create_profile_config_effort_level_medium(self): - """Test effortLevel set to medium.""" - with tempfile.TemporaryDirectory() as tmpdir: - claude_dir = Path(tmpdir) - - result = setup_environment.create_profile_config( - {'effortLevel': 'medium'}, - claude_dir, - ) - - assert result is True - settings_file = claude_dir / 'config.json' - settings = json.loads(settings_file.read_text()) - - assert 'effortLevel' in settings - assert settings['effortLevel'] == 'medium' - - def test_create_profile_config_effort_level_high(self): - """Test effortLevel set to high.""" - with tempfile.TemporaryDirectory() as tmpdir: - claude_dir = Path(tmpdir) - - result = setup_environment.create_profile_config( - {'effortLevel': 'high'}, - claude_dir, - ) - - assert result is True - settings_file = claude_dir / 'config.json' - settings = json.loads(settings_file.read_text()) - - assert 'effortLevel' in settings - assert settings['effortLevel'] == 'high' - - def test_create_profile_config_effort_level_max(self): - """Test effortLevel set to max.""" - with tempfile.TemporaryDirectory() as tmpdir: - claude_dir = Path(tmpdir) - - result = setup_environment.create_profile_config( - {'effortLevel': 'max'}, - claude_dir, - ) - - assert result is True - settings_file = claude_dir / 'config.json' - settings = json.loads(settings_file.read_text()) - - assert 'effortLevel' in settings - assert settings['effortLevel'] == 'max' - - def test_create_profile_config_effort_level_xhigh(self): - """Test effortLevel set to xhigh.""" - with tempfile.TemporaryDirectory() as tmpdir: - claude_dir = Path(tmpdir) - - result = setup_environment.create_profile_config( - {'effortLevel': 'xhigh'}, - claude_dir, - ) - - assert result is True - settings_file = claude_dir / 'config.json' - settings = json.loads(settings_file.read_text()) - - assert 'effortLevel' in settings - assert settings['effortLevel'] == 'xhigh' - - def test_create_profile_config_effort_level_none_not_included(self): - """Test effortLevel not included when absent from profile_config.""" - with tempfile.TemporaryDirectory() as tmpdir: - claude_dir = Path(tmpdir) - - result = setup_environment.create_profile_config( - {}, - claude_dir, - ) - - assert result is True - settings_file = claude_dir / 'config.json' - settings = json.loads(settings_file.read_text()) - - assert 'effortLevel' not in settings - - def test_create_profile_config_company_announcements(self): - """Test companyAnnouncements set with multiple items.""" - with tempfile.TemporaryDirectory() as tmpdir: - claude_dir = Path(tmpdir) - announcements = [ - 'Welcome to Acme Corp!', - 'Code reviews required for all PRs', - ] - - result = setup_environment.create_profile_config( - {'companyAnnouncements': announcements}, - claude_dir, - ) - - assert result is True - settings_file = claude_dir / 'config.json' - settings = json.loads(settings_file.read_text()) - - assert 'companyAnnouncements' in settings - assert settings['companyAnnouncements'] == announcements - assert len(settings['companyAnnouncements']) == 2 - - def test_create_profile_config_company_announcements_single(self): - """Test companyAnnouncements with single announcement.""" - with tempfile.TemporaryDirectory() as tmpdir: - claude_dir = Path(tmpdir) - announcements = ['Single announcement'] - - result = setup_environment.create_profile_config( - {'companyAnnouncements': announcements}, - claude_dir, - ) - - assert result is True - settings_file = claude_dir / 'config.json' - settings = json.loads(settings_file.read_text()) - - assert 'companyAnnouncements' in settings - assert settings['companyAnnouncements'] == announcements - - def test_create_profile_config_company_announcements_empty_list(self): - """Test companyAnnouncements with empty list.""" - with tempfile.TemporaryDirectory() as tmpdir: - claude_dir = Path(tmpdir) - - result = setup_environment.create_profile_config( - {'companyAnnouncements': []}, - claude_dir, - ) - - assert result is True - settings_file = claude_dir / 'config.json' - settings = json.loads(settings_file.read_text()) - - # Empty list should still be included (explicit configuration) - assert 'companyAnnouncements' in settings - assert settings['companyAnnouncements'] == [] - - def test_create_profile_config_company_announcements_none_not_included(self): - """Test companyAnnouncements not included when absent from profile_config.""" - with tempfile.TemporaryDirectory() as tmpdir: - claude_dir = Path(tmpdir) - - result = setup_environment.create_profile_config( - {}, - claude_dir, - ) - - assert result is True - settings_file = claude_dir / 'config.json' - settings = json.loads(settings_file.read_text()) - - assert 'companyAnnouncements' not in settings - - def test_create_profile_config_attribution_full(self): - """Test attribution with both commit and pr values.""" - with tempfile.TemporaryDirectory() as tmpdir: - claude_dir = Path(tmpdir) - attribution = { - 'commit': 'Custom commit attribution', - 'pr': 'Custom PR attribution', - } - - result = setup_environment.create_profile_config( - {'attribution': attribution}, - claude_dir, - ) - - assert result is True - settings_file = claude_dir / 'config.json' - settings = json.loads(settings_file.read_text()) - - assert 'attribution' in settings - assert settings['attribution']['commit'] == 'Custom commit attribution' - assert settings['attribution']['pr'] == 'Custom PR attribution' - - def test_create_profile_config_attribution_hide_all(self): - """Test attribution with empty strings to hide all.""" - with tempfile.TemporaryDirectory() as tmpdir: - claude_dir = Path(tmpdir) - attribution = {'commit': '', 'pr': ''} - - result = setup_environment.create_profile_config( - {'attribution': attribution}, - claude_dir, - ) - - assert result is True - settings_file = claude_dir / 'config.json' - settings = json.loads(settings_file.read_text()) - - assert settings['attribution'] == {'commit': '', 'pr': ''} - - def test_create_profile_config_attribution_none_not_included(self): - """Test attribution not included when absent from profile_config.""" - with tempfile.TemporaryDirectory() as tmpdir: - claude_dir = Path(tmpdir) - - result = setup_environment.create_profile_config( - {}, - claude_dir, - ) - - assert result is True - settings_file = claude_dir / 'config.json' - settings = json.loads(settings_file.read_text()) - - assert 'attribution' not in settings - def test_create_profile_config_status_line_python(self): """Test statusLine with Python script.""" with tempfile.TemporaryDirectory() as tmpdir: @@ -4751,7 +4553,7 @@ def test_download_hook_files_hooks_base_dir(self): assert result is True def test_create_profile_config_claude_config_dir_injection(self): - """Test that CLAUDE_CONFIG_DIR is injected into settings env block.""" + """Test that CLAUDE_CONFIG_DIR from user-settings.env lands in config.json.""" with tempfile.TemporaryDirectory() as tmpdir: claude_dir = Path(tmpdir) @@ -4759,8 +4561,9 @@ def test_create_profile_config_claude_config_dir_injection(self): env_vars['CLAUDE_CONFIG_DIR'] = '~/.claude/my-env' result = setup_environment.create_profile_config( - {'env': env_vars}, + {}, claude_dir, + user_settings={'env': env_vars}, ) assert result is True @@ -4779,8 +4582,9 @@ def test_create_profile_config_user_override_claude_config_dir(self): env_vars = {'CLAUDE_CONFIG_DIR': '/custom/path'} result = setup_environment.create_profile_config( - {'env': env_vars}, + {}, claude_dir, + user_settings={'env': env_vars}, ) assert result is True @@ -4800,8 +4604,9 @@ def test_create_profile_config_claude_config_dir_tilde_linux(self): env_vars = {'CLAUDE_CONFIG_DIR': '~/.claude/my-env'} result = setup_environment.create_profile_config( - {'env': env_vars}, + {}, claude_dir, + user_settings={'env': env_vars}, ) assert result is True @@ -4819,8 +4624,9 @@ def test_create_profile_config_claude_config_dir_absolute_windows(self): env_vars = {'CLAUDE_CONFIG_DIR': r'C:\Users\test\.claude\my-env'} result = setup_environment.create_profile_config( - {'env': env_vars}, + {}, claude_dir, + user_settings={'env': env_vars}, ) assert result is True @@ -5442,159 +5248,23 @@ def test_main_skip_install(self, mock_mkdir, mock_cleanup_stale, mock_write_mani { 'name': 'Test Environment', 'dependencies': [], - 'agents': [], - 'slash-commands': [], - }, - 'test.yaml', - ) - mock_find.return_value = 'claude' - - with ( - patch('sys.argv', ['setup_environment.py', 'test', '--skip-install', '--yes']), - patch('setup_environment.create_profile_config', return_value=True), - patch('setup_environment.create_launcher_script', return_value=(Path('/tmp/launcher'), Path('/tmp/launcher'))), - patch('setup_environment.register_global_command', return_value=True), - patch('sys.exit') as mock_exit, - ): - setup_environment.main() - mock_exit.assert_not_called() - del mock_mkdir # Explicitly acknowledge usage - - @patch('setup_environment.load_config_from_source') - @patch('setup_environment.validate_all_config_files') - @patch('setup_environment.install_claude') - @patch('setup_environment.install_dependencies', return_value=[]) - @patch('setup_environment.process_resources') - @patch('setup_environment.process_skills') - @patch('setup_environment.configure_all_mcp_servers') - @patch('setup_environment.create_profile_config') - @patch('setup_environment.create_launcher_script') - @patch('setup_environment.register_global_command') - @patch('setup_environment.is_admin', return_value=True) - @patch('setup_environment.write_manifest') - @patch('setup_environment.cleanup_stale_marker') - @patch('pathlib.Path.mkdir') - def test_main_invalid_effort_level_warns_and_skips( - self, - mock_mkdir: MagicMock, - mock_cleanup_stale_marker: MagicMock, - mock_write_manifest: MagicMock, - mock_is_admin: MagicMock, - mock_register: MagicMock, - mock_launcher: MagicMock, - mock_settings: MagicMock, - mock_mcp: MagicMock, - mock_skills: MagicMock, - mock_resources: MagicMock, - mock_deps: MagicMock, - mock_install: MagicMock, - mock_validate: MagicMock, - mock_load: MagicMock, - capsys: pytest.CaptureFixture[str], - ) -> None: - """main() warns and skips invalid effort-level value (non-fatal).""" - # Mocks required by @patch decorators but not directly asserted - del mock_mkdir, mock_is_admin, mock_skills, mock_resources, mock_deps - del mock_cleanup_stale_marker, mock_write_manifest - mock_load.return_value = ( - { - 'name': 'Effort Level Test', - 'command-names': ['test-cmd'], - 'effort-level': 'extreme', # Invalid value - }, - 'test.yaml', - ) - mock_validate.return_value = (True, []) - mock_install.return_value = True - mock_mcp.return_value = (True, [], {'global_count': 0, 'profile_count': 0, 'combined_count': 0}) - mock_settings.return_value = True - mock_launcher.return_value = (Path('/tmp/launcher.sh'), Path('/tmp/launcher.sh')) - mock_register.return_value = True - - with patch('sys.argv', ['setup_environment.py', 'test', '--yes']), patch('sys.exit') as mock_exit: - setup_environment.main() - mock_exit.assert_not_called() - - captured = capsys.readouterr() - assert 'Invalid effort-level value' in captured.out - assert "'extreme'" in captured.out - - # Verify create_profile_config receives a profile_config without - # an effortLevel entry (invalid values are stripped from config - # before profile_config construction). - mock_settings.assert_called_once() - call_args = mock_settings.call_args - # profile_config is the first positional argument - profile_config = call_args[0][0] - assert 'effortLevel' not in profile_config - - @patch('setup_environment.load_config_from_source') - @patch('setup_environment.validate_all_config_files') - @patch('setup_environment.install_claude') - @patch('setup_environment.install_dependencies', return_value=[]) - @patch('setup_environment.process_resources') - @patch('setup_environment.process_skills') - @patch('setup_environment.configure_all_mcp_servers') - @patch('setup_environment.create_profile_config') - @patch('setup_environment.create_launcher_script') - @patch('setup_environment.register_global_command') - @patch('setup_environment.is_admin', return_value=True) - @patch('setup_environment.write_manifest') - @patch('setup_environment.cleanup_stale_marker') - @patch('pathlib.Path.mkdir') - def test_main_xhigh_effort_level_not_stripped( - self, - mock_mkdir: MagicMock, - mock_cleanup_stale_marker: MagicMock, - mock_write_manifest: MagicMock, - mock_is_admin: MagicMock, - mock_register: MagicMock, - mock_launcher: MagicMock, - mock_settings: MagicMock, - mock_mcp: MagicMock, - mock_skills: MagicMock, - mock_resources: MagicMock, - mock_deps: MagicMock, - mock_install: MagicMock, - mock_validate: MagicMock, - mock_load: MagicMock, - capsys: pytest.CaptureFixture[str], - ) -> None: - """main() keeps 'xhigh' effort-level (valid value is not stripped).""" - # Mocks required by @patch decorators but not directly asserted - del mock_mkdir, mock_is_admin, mock_skills, mock_resources, mock_deps - del mock_cleanup_stale_marker, mock_write_manifest - mock_load.return_value = ( - { - 'name': 'Effort Level Test', - 'command-names': ['test-cmd'], - 'model': 'opus', - 'effort-level': 'xhigh', # Valid value (Opus-only) + 'agents': [], + 'slash-commands': [], }, 'test.yaml', ) - mock_validate.return_value = (True, []) - mock_install.return_value = True - mock_mcp.return_value = (True, [], {'global_count': 0, 'profile_count': 0, 'combined_count': 0}) - mock_settings.return_value = True - mock_launcher.return_value = (Path('/tmp/launcher.sh'), Path('/tmp/launcher.sh')) - mock_register.return_value = True + mock_find.return_value = 'claude' - with patch('sys.argv', ['setup_environment.py', 'test', '--yes']), patch('sys.exit') as mock_exit: + with ( + patch('sys.argv', ['setup_environment.py', 'test', '--skip-install', '--yes']), + patch('setup_environment.create_profile_config', return_value=True), + patch('setup_environment.create_launcher_script', return_value=(Path('/tmp/launcher'), Path('/tmp/launcher'))), + patch('setup_environment.register_global_command', return_value=True), + patch('sys.exit') as mock_exit, + ): setup_environment.main() mock_exit.assert_not_called() - - captured = capsys.readouterr() - assert 'Invalid effort-level value' not in captured.out - - # Verify create_profile_config receives a profile_config that retains - # the effortLevel entry set to 'xhigh' (valid values pass through the - # runtime allowlist without being stripped). - mock_settings.assert_called_once() - call_args = mock_settings.call_args - # profile_config is the first positional argument - profile_config = call_args[0][0] - assert profile_config.get('effortLevel') == 'xhigh' + del mock_mkdir # Explicitly acknowledge usage class TestDownloadFailureTracking: @@ -6307,13 +5977,6 @@ def test_merge_config_key_user_settings(self): result = setup_environment._merge_config_key('user-settings', parent, child) assert set(result['permissions']['allow']) == {'Read', 'Write'} - def test_merge_config_key_env_variables(self): - """Dispatch: env-variables uses shallow dict merge with null deletes.""" - parent = {'A': '1', 'B': '2'} - child = {'B': None, 'C': '3'} - result = setup_environment._merge_config_key('env-variables', parent, child) - assert result == {'A': '1', 'C': '3'} - def test_merge_config_key_os_env_variables(self): """Dispatch: os-env-variables uses shallow dict merge with null deletes.""" parent = {'X': 'val1'} @@ -6378,7 +6041,7 @@ def test_merge_configs_empty_merge_keys(self): def test_validation_invalid_key_in_merge_keys(self, mock_load): """Invalid key in merge-keys raises ValueError.""" mock_load.return_value = ({'name': 'Parent'}, 'parent.yaml') - config = {'inherit': 'parent.yaml', 'merge-keys': ['model']} + config = {'inherit': 'parent.yaml', 'merge-keys': ['name']} with pytest.raises(ValueError, match='Invalid keys in merge-keys'): setup_environment.resolve_config_inheritance(config, 'child.yaml') @@ -7518,16 +7181,182 @@ def test_excluded_keys_constant_used(self) -> None: assert key in result[0] +class TestValidateUserSettingsKeyPlacement: + """Test key-placement validation rules in validate_user_settings().""" + + def test_root_only_key_status_line_rejected(self) -> None: + """status-line is a root-level YAML key, not a settings.json key.""" + result = setup_environment.validate_user_settings({'status-line': {'file': 'x.py'}}) + assert any('status-line' in e and 'root-level YAML key' in e for e in result) + + def test_root_only_key_os_env_variables_rejected(self) -> None: + """os-env-variables is a root-level YAML key, not a settings.json key.""" + result = setup_environment.validate_user_settings({'os-env-variables': {'FOO': 'bar'}}) + assert any('os-env-variables' in e and 'root-level YAML key' in e for e in result) + + def test_kebab_effort_level_rejected_with_camel_correction(self) -> None: + """effort-level (kebab) is rejected with the effortLevel camelCase correction.""" + result = setup_environment.validate_user_settings({'effort-level': 'high'}) + assert any("'effort-level'" in e and "'effortLevel'" in e for e in result) + + def test_kebab_env_variables_rejected_with_env_correction(self) -> None: + """env-variables (kebab) is rejected with the env camelCase correction.""" + result = setup_environment.validate_user_settings({'env-variables': {'FOO': 'bar'}}) + assert any("'env-variables'" in e and "'env'" in e for e in result) + + def test_kebab_always_thinking_enabled_rejected(self) -> None: + """always-thinking-enabled (kebab) is rejected with the camelCase correction.""" + result = setup_environment.validate_user_settings({'always-thinking-enabled': True}) + assert any("'always-thinking-enabled'" in e and "'alwaysThinkingEnabled'" in e for e in result) + + def test_kebab_company_announcements_rejected(self) -> None: + """company-announcements (kebab) is rejected with the camelCase correction.""" + result = setup_environment.validate_user_settings({'company-announcements': ['x']}) + assert any("'company-announcements'" in e and "'companyAnnouncements'" in e for e in result) + + def test_global_only_key_auto_updates_rejected(self) -> None: + """autoUpdates belongs in global-config, not user-settings.""" + result = setup_environment.validate_user_settings({'autoUpdates': False}) + assert any('autoUpdates' in e and 'global-config' in e for e in result) + + def test_global_only_key_install_method_rejected(self) -> None: + """installMethod belongs in global-config, not user-settings.""" + result = setup_environment.validate_user_settings({'installMethod': 'native'}) + assert any('installMethod' in e and 'global-config' in e for e in result) + + def test_unknown_key_passes_through(self) -> None: + """An unknown camelCase key passes validation (extra='allow' escape hatch).""" + result = setup_environment.validate_user_settings({'someFutureSetting': 'value'}) + assert result == [] + + +class TestValidateUserSettingsValues: + """Test value-shape validation rules in validate_user_settings().""" + + def test_model_non_empty_string_valid(self) -> None: + assert setup_environment.validate_user_settings({'model': 'sonnet'}) == [] + + def test_model_null_allowed(self) -> None: + """A null model is a deletion request and is allowed.""" + assert setup_environment.validate_user_settings({'model': None}) == [] + + def test_model_empty_string_rejected(self) -> None: + result = setup_environment.validate_user_settings({'model': ' '}) + assert any(e == 'user-settings.model must be a non-empty string.' for e in result) + + def test_env_string_value_valid(self) -> None: + assert setup_environment.validate_user_settings({'env': {'FOO': 'bar'}}) == [] + + def test_env_null_entry_allowed(self) -> None: + """A per-key env null is a deletion request and is allowed.""" + assert setup_environment.validate_user_settings({'env': {'FOO': None}}) == [] + + def test_env_non_string_value_rejected(self) -> None: + result = setup_environment.validate_user_settings({'env': {'FOO': 42}}) + assert any( + e == 'user-settings.env.FOO must be a string ' + '(quote the value in YAML) or null to delete the variable.' + for e in result + ) + + def test_env_invalid_name_rejected(self) -> None: + result = setup_environment.validate_user_settings({'env': {'1BAD': 'x'}}) + assert any('invalid environment variable name' in e for e in result) + + def test_env_not_a_mapping_rejected(self) -> None: + result = setup_environment.validate_user_settings({'env': ['not', 'a', 'dict']}) + assert any('user-settings.env must be a mapping' in e for e in result) + + def test_permissions_valid(self) -> None: + settings = {'permissions': {'defaultMode': 'acceptEdits', 'allow': ['Read']}} + assert setup_environment.validate_user_settings(settings) == [] + + def test_permissions_default_mode_enum_rejected(self) -> None: + result = setup_environment.validate_user_settings( + {'permissions': {'defaultMode': 'bogus'}}, + ) + expected_enum = "['acceptEdits', 'auto', 'bypassPermissions', 'default', 'delegate', 'dontAsk', 'plan']" + assert any('defaultMode must be one of' in e and expected_enum in e for e in result) + + def test_permissions_kebab_default_mode_rejected(self) -> None: + result = setup_environment.validate_user_settings( + {'permissions': {'default-mode': 'plan'}}, + ) + assert any("'defaultMode' instead of 'default-mode'" in e for e in result) + + def test_permissions_allow_not_list_of_strings_rejected(self) -> None: + result = setup_environment.validate_user_settings( + {'permissions': {'allow': [1, 2]}}, + ) + assert any(e == 'user-settings.permissions.allow must be a list of strings.' for e in result) + + def test_attribution_valid(self) -> None: + assert setup_environment.validate_user_settings( + {'attribution': {'commit': '', 'pr': 'x'}}, + ) == [] + + def test_attribution_non_string_rejected(self) -> None: + result = setup_environment.validate_user_settings({'attribution': {'commit': 5}}) + assert any('user-settings.attribution.commit must be a string' in e for e in result) + + def test_always_thinking_enabled_bool_valid(self) -> None: + assert setup_environment.validate_user_settings({'alwaysThinkingEnabled': True}) == [] + + def test_always_thinking_enabled_non_bool_rejected(self) -> None: + result = setup_environment.validate_user_settings({'alwaysThinkingEnabled': 'yes'}) + assert any(e == 'user-settings.alwaysThinkingEnabled must be a boolean.' for e in result) + + def test_company_announcements_list_of_strings_valid(self) -> None: + assert setup_environment.validate_user_settings( + {'companyAnnouncements': ['a', 'b']}, + ) == [] + + def test_company_announcements_non_string_item_rejected(self) -> None: + result = setup_environment.validate_user_settings({'companyAnnouncements': ['a', 3]}) + assert any(e == 'user-settings.companyAnnouncements must be a list of strings.' for e in result) + + def test_effort_level_enum_rejected(self) -> None: + result = setup_environment.validate_user_settings({'effortLevel': 'extreme'}) + expected_enum = "['high', 'low', 'max', 'medium', 'xhigh']" + assert any('effortLevel must be one of' in e and expected_enum in e for e in result) + + def test_effort_level_low_needs_no_model(self) -> None: + assert setup_environment.validate_user_settings({'effortLevel': 'low'}) == [] + + def test_effort_level_xhigh_with_opus_model_valid(self) -> None: + assert setup_environment.validate_user_settings( + {'effortLevel': 'xhigh', 'model': 'claude-opus-4-8'}, + ) == [] + + def test_effort_level_xhigh_with_best_alias_valid(self) -> None: + assert setup_environment.validate_user_settings( + {'effortLevel': 'xhigh', 'model': 'best'}, + ) == [] + + def test_effort_level_xhigh_with_sonnet_rejected(self) -> None: + """xhigh requires an Opus or Fable model; sonnet is rejected.""" + result = setup_environment.validate_user_settings( + {'effortLevel': 'xhigh', 'model': 'sonnet'}, + ) + assert any("effortLevel 'xhigh' is only available for" in e for e in result) + + def test_effort_level_max_with_sonnet_valid(self) -> None: + """max additionally accepts a Sonnet model.""" + assert setup_environment.validate_user_settings( + {'effortLevel': 'max', 'model': 'claude-sonnet-4-6'}, + ) == [] + + def test_effort_level_xhigh_without_model_rejected(self) -> None: + result = setup_environment.validate_user_settings({'effortLevel': 'xhigh'}) + assert any("effortLevel 'xhigh' requires user-settings.model" in e for e in result) + + class TestProfileOwnedKeys: """Unit tests for PROFILE_OWNED_KEYS constant.""" - def test_contains_all_nine_profile_keys(self) -> None: - """PROFILE_OWNED_KEYS must contain exactly the 9 profile-owned keys.""" - expected = { - 'model', 'permissions', 'env', 'attribution', - 'alwaysThinkingEnabled', 'effortLevel', 'companyAnnouncements', - 'statusLine', 'hooks', - } + def test_contains_status_line_and_hooks(self) -> None: + """PROFILE_OWNED_KEYS must contain exactly statusLine and hooks.""" + expected = {'statusLine', 'hooks'} assert expected == setup_environment.PROFILE_OWNED_KEYS def test_is_frozenset(self) -> None: @@ -7561,105 +7390,12 @@ def test_empty_config_returns_empty_dict(self, tmp_path: Path) -> None: result = setup_environment._build_profile_settings({}, tmp_path) assert result == {} - def test_model_only(self, tmp_path: Path) -> None: - """Only model set -> only 'model' key in result.""" - result = setup_environment._build_profile_settings( - {'model': 'sonnet'}, tmp_path, - ) - assert result == {'model': 'sonnet'} - - def test_permissions_kebab_to_camel(self, tmp_path: Path) -> None: - """'default-mode' translated to 'defaultMode'.""" - result = setup_environment._build_profile_settings( - {'permissions': {'default-mode': 'ask', 'allow': ['Read']}}, - tmp_path, - ) - assert 'defaultMode' in result['permissions'] - assert 'default-mode' not in result['permissions'] - assert result['permissions']['allow'] == ['Read'] - - def test_permissions_additional_directories_translation(self, tmp_path: Path) -> None: - """'additional-directories' translated to 'additionalDirectories'.""" - result = setup_environment._build_profile_settings( - {'permissions': {'additional-directories': ['/tmp/test']}}, - tmp_path, - ) - assert 'additionalDirectories' in result['permissions'] - assert 'additional-directories' not in result['permissions'] - - def test_env_values_stringified(self, tmp_path: Path) -> None: - """Env values are preserved as strings in the output env dict.""" - result = setup_environment._build_profile_settings( - {'env': {'FOO': 'bar', 'HELLO': 'world'}}, - tmp_path, - ) - assert result['env']['FOO'] == 'bar' - assert result['env']['HELLO'] == 'world' - - def test_env_none_value_preserved_for_null_as_delete(self, tmp_path: Path) -> None: - """A per-key env null passes through verbatim while other values stringify. - - The preserved None lets the shared settings.json writer delete the - nested key via RFC 7396 null-as-delete and lets the isolated - config.json writer omit it from the atomic rebuild. - """ - result = setup_environment._build_profile_settings( - {'env': {'DELETE_ME': None, 'KEEP': 'x', 'NUM': 42}}, - tmp_path, - ) - assert result['env'] == {'DELETE_ME': None, 'KEEP': 'x', 'NUM': '42'} - assert result['env']['DELETE_ME'] is None - - def test_env_null_never_becomes_literal_none_string(self, tmp_path: Path) -> None: - """The literal string 'None' never appears as an env value for null input.""" - result = setup_environment._build_profile_settings( - {'env': {'A': None, 'B': None}}, tmp_path, - ) - assert 'None' not in result['env'].values() - - def test_attribution_passthrough(self, tmp_path: Path) -> None: - """Attribution dict passed through unchanged.""" - attr = {'commit': 'Test commit', 'pr': 'Test PR'} - result = setup_environment._build_profile_settings( - {'attribution': attr}, tmp_path, - ) - assert result['attribution'] == attr - - def test_attribution_empty_dict_not_omitted(self, tmp_path: Path) -> None: - """Empty attribution dict is kept (not same as None).""" - result = setup_environment._build_profile_settings( - {'attribution': {}}, tmp_path, - ) - assert 'attribution' in result - assert result['attribution'] == {} - - def test_always_thinking_enabled_true(self, tmp_path: Path) -> None: - """alwaysThinkingEnabled=True stored under alwaysThinkingEnabled.""" - result = setup_environment._build_profile_settings( - {'alwaysThinkingEnabled': True}, tmp_path, - ) - assert result == {'alwaysThinkingEnabled': True} - - def test_always_thinking_enabled_false_not_omitted(self, tmp_path: Path) -> None: - """alwaysThinkingEnabled=False is explicit and kept.""" - result = setup_environment._build_profile_settings( - {'alwaysThinkingEnabled': False}, tmp_path, - ) - assert result == {'alwaysThinkingEnabled': False} - - def test_effort_level(self, tmp_path: Path) -> None: - """effortLevel stored under effortLevel.""" - result = setup_environment._build_profile_settings( - {'effortLevel': 'high'}, tmp_path, - ) - assert result == {'effortLevel': 'high'} - - def test_company_announcements_list(self, tmp_path: Path) -> None: - """companyAnnouncements list stored under companyAnnouncements.""" + def test_non_profile_key_ignored(self, tmp_path: Path) -> None: + """Keys outside statusLine/hooks are not emitted by the builder.""" result = setup_environment._build_profile_settings( - {'companyAnnouncements': ['msg1', 'msg2']}, tmp_path, + {'model': 'sonnet', 'permissions': {'allow': ['Read']}}, tmp_path, ) - assert result == {'companyAnnouncements': ['msg1', 'msg2']} + assert result == {} def test_status_line_python_script(self, tmp_path: Path) -> None: """statusLine with .py file builds uv run command with absolute path.""" @@ -7722,17 +7458,10 @@ def test_hooks_empty_events_omitted(self, tmp_path: Path) -> None: ) assert 'hooks' not in result - def test_all_nine_keys_together(self, tmp_path: Path) -> None: - """All nine keys provided simultaneously produce full delta.""" + def test_both_profile_keys_together(self, tmp_path: Path) -> None: + """Both profile-owned keys provided simultaneously produce full delta.""" result = setup_environment._build_profile_settings( { - 'model': 'sonnet', - 'permissions': {'allow': ['Read']}, - 'env': {'FOO': 'bar'}, - 'attribution': {'commit': 'x', 'pr': 'y'}, - 'alwaysThinkingEnabled': True, - 'effortLevel': 'high', - 'companyAnnouncements': ['Welcome'], 'statusLine': {'file': 'status.py'}, 'hooks': {'events': [{'event': 'PreToolUse', 'matcher': 'Bash', 'type': 'command', 'command': 'test.sh'}]}, @@ -7744,16 +7473,17 @@ def test_all_nine_keys_together(self, tmp_path: Path) -> None: def test_absent_keys_omitted_from_result(self, tmp_path: Path) -> None: """Keys absent from profile_config are absent from result. - The builder does NOT backfill the 9-key universe with Nones or empty - values. Absent means absent. Present-with-value means included. - Present-with-None means included-as-None (for downstream null-as-delete). + The builder does NOT backfill the profile-owned key universe with + Nones or empty values. Absent means absent. Present-with-value means + included. Present-with-None means included-as-None (for downstream + null-as-delete). """ result = setup_environment._build_profile_settings( - {'model': 'sonnet', 'permissions': {'allow': ['Read']}}, + {'statusLine': {'file': 'status.py'}}, tmp_path, ) - # Only 'model' and 'permissions' present; none of the other keys - assert set(result.keys()) == {'model', 'permissions'} + # Only 'statusLine' present; 'hooks' is OMITTED + assert set(result.keys()) == {'statusLine'} class TestYamlToCamelProfileKeysParity: @@ -7782,38 +7512,6 @@ class TestBuildProfileSettingsExplicitNulls: ``key: null`` at the root level. """ - def test_explicit_null_model_emits_none(self, tmp_path: Path) -> None: - result = setup_environment._build_profile_settings({'model': None}, tmp_path) - assert result == {'model': None} - - def test_explicit_null_permissions_emits_none(self, tmp_path: Path) -> None: - result = setup_environment._build_profile_settings({'permissions': None}, tmp_path) - assert result == {'permissions': None} - - def test_explicit_null_env_emits_none(self, tmp_path: Path) -> None: - result = setup_environment._build_profile_settings({'env': None}, tmp_path) - assert result == {'env': None} - - def test_explicit_null_attribution_emits_none(self, tmp_path: Path) -> None: - result = setup_environment._build_profile_settings({'attribution': None}, tmp_path) - assert result == {'attribution': None} - - def test_explicit_null_always_thinking_enabled_emits_none(self, tmp_path: Path) -> None: - result = setup_environment._build_profile_settings( - {'alwaysThinkingEnabled': None}, tmp_path, - ) - assert result == {'alwaysThinkingEnabled': None} - - def test_explicit_null_effort_level_emits_none(self, tmp_path: Path) -> None: - result = setup_environment._build_profile_settings({'effortLevel': None}, tmp_path) - assert result == {'effortLevel': None} - - def test_explicit_null_company_announcements_emits_none(self, tmp_path: Path) -> None: - result = setup_environment._build_profile_settings( - {'companyAnnouncements': None}, tmp_path, - ) - assert result == {'companyAnnouncements': None} - def test_explicit_null_status_line_emits_none(self, tmp_path: Path) -> None: result = setup_environment._build_profile_settings({'statusLine': None}, tmp_path) assert result == {'statusLine': None} @@ -7825,37 +7523,23 @@ def test_explicit_null_hooks_emits_none(self, tmp_path: Path) -> None: def test_absent_key_omitted_even_when_other_key_is_null(self, tmp_path: Path) -> None: """Absent keys remain OMITTED even when another key is explicitly null.""" result = setup_environment._build_profile_settings( - {'permissions': None}, + {'hooks': None}, tmp_path, ) - # Only 'permissions' is in result (as None); model is OMITTED - assert result == {'permissions': None} - assert 'model' not in result + # Only 'hooks' is in result (as None); statusLine is OMITTED + assert result == {'hooks': None} + assert 'statusLine' not in result def test_multiple_explicit_nulls_together(self, tmp_path: Path) -> None: - """Multiple keys declared null all emit None simultaneously.""" + """Both profile-owned keys declared null emit None simultaneously.""" result = setup_environment._build_profile_settings( { - 'model': None, - 'permissions': None, - 'env': None, - 'attribution': None, - 'alwaysThinkingEnabled': None, - 'effortLevel': None, - 'companyAnnouncements': None, 'statusLine': None, 'hooks': None, }, tmp_path, ) assert result == { - 'model': None, - 'permissions': None, - 'env': None, - 'attribution': None, - 'alwaysThinkingEnabled': None, - 'effortLevel': None, - 'companyAnnouncements': None, 'statusLine': None, 'hooks': None, } @@ -7879,22 +7563,25 @@ def test_explicit_null_hooks_does_not_call_build_hooks_json(self, tmp_path: Path assert result == {'hooks': None} def test_mixed_null_and_value(self, tmp_path: Path) -> None: - """Null and non-null keys can coexist in the same profile_config.""" + """Null and non-null profile-owned keys can coexist in the same profile_config.""" result = setup_environment._build_profile_settings( - {'model': 'sonnet', 'permissions': None}, + {'statusLine': {'file': 'status.sh'}, 'hooks': None}, tmp_path, ) - assert result == {'model': 'sonnet', 'permissions': None} + assert result['hooks'] is None + assert result['statusLine']['type'] == 'command' + assert set(result.keys()) == {'statusLine', 'hooks'} class TestWriteProfileSettingsToSettings: """Unit tests for write_profile_settings_to_settings(). Verifies the deep-merge + RFC 7396 null-as-delete semantics the - writer inherits from ``_write_merged_json()`` when writing the nine - profile-owned keys to the shared ~/.claude/settings.json file in - non-command-names mode. Keys not present in the delta are - preserved; nested dicts are deep-merged; ``permissions.allow/deny/ask`` + writer inherits from ``_write_merged_json()`` when writing the + statusLine/hooks delta to the shared ~/.claude/settings.json file in + non-command-names mode. Keys not present in the delta are preserved + (including keys other contributors such as Step 14 write_user_settings() + placed on disk); nested dicts are deep-merged; ``permissions.allow/deny/ask`` arrays are unioned; and top-level ``None`` values delete keys. """ @@ -8000,26 +7687,25 @@ def test_deep_merge_preserves_unrelated_permissions_subkeys(self, tmp_path: Path assert content['permissions']['deny'] == ['Bash(rm -rf)'] assert content['permissions']['ask'] == ['Edit'] - def test_env_entry_null_deletes_nested_key(self, tmp_path: Path) -> None: - """A per-key env null in the delta deletes that key from settings.json env. + def test_nested_null_in_delta_deletes_nested_key(self, tmp_path: Path) -> None: + """A nested null in the delta deletes that sub-key from settings.json. - Builds the delta through _build_profile_settings() so the full - base-mode pipeline (builder -> writer -> merge) is exercised: the - stale key is removed and never written as the literal string 'None'. + The writer inherits RFC 7396 null-as-delete from _write_merged_json() + for any nested None value, so a stale sub-key is removed and never + written as the literal string 'None'. """ settings_file = tmp_path / 'settings.json' settings_file.write_text(json.dumps({ - 'env': {'STALE_VAR': 'old', 'KEEP_ME': 'yes'}, + 'permissions': {'allow': ['Read'], 'deny': ['Bash']}, }), encoding='utf-8') - delta = setup_environment._build_profile_settings( - {'env': {'STALE_VAR': None}}, tmp_path / 'hooks', + result = setup_environment.write_profile_settings_to_settings( + {'permissions': {'deny': None}}, tmp_path, ) - result = setup_environment.write_profile_settings_to_settings(delta, tmp_path) assert result is True content = json.loads(settings_file.read_text(encoding='utf-8')) - assert content['env'] == {'KEEP_ME': 'yes'} - assert 'None' not in content['env'].values() + assert content['permissions'] == {'allow': ['Read']} + assert 'None' not in str(content['permissions']) def test_deep_merge_preserves_env_subkeys(self, tmp_path: Path) -> None: """Deep-merge preserves env sub-keys not declared in the delta.""" @@ -8127,17 +7813,11 @@ def test_top_level_null_env_deletes_key(self, tmp_path: Path) -> None: content = json.loads(settings_file.read_text(encoding='utf-8')) assert content == {'model': 'sonnet'} - def test_top_level_null_all_nine_keys_deletes_all(self, tmp_path: Path) -> None: - """All nine profile-owned keys set to None deletes them all.""" + def test_top_level_null_all_profile_keys_deletes_all(self, tmp_path: Path) -> None: + """Both profile-owned keys set to None deletes them, preserving others.""" settings_file = tmp_path / 'settings.json' settings_file.write_text(json.dumps({ 'model': 'sonnet', - 'permissions': {'allow': ['Read']}, - 'env': {'FOO': 'bar'}, - 'attribution': {'commit': 'x'}, - 'alwaysThinkingEnabled': True, - 'effortLevel': 'high', - 'companyAnnouncements': ['msg'], 'statusLine': {'type': 'command', 'command': 'a'}, 'hooks': {'PreToolUse': []}, }), encoding='utf-8') @@ -8146,8 +7826,8 @@ def test_top_level_null_all_nine_keys_deletes_all(self, tmp_path: Path) -> None: result = setup_environment.write_profile_settings_to_settings(delta, tmp_path) assert result is True content = json.loads(settings_file.read_text(encoding='utf-8')) - # All nine keys deleted - assert content == {} + # Both profile-owned keys deleted; non-profile key preserved + assert content == {'model': 'sonnet'} def test_deep_merge_preserves_unrelated_top_level_keys_via_delegate( self, tmp_path: Path, @@ -8372,8 +8052,11 @@ class TestCreateProfileConfigDelegation: """Regression tests verifying create_profile_config() delegates to builder.""" def test_output_matches_builder(self, tmp_path: Path) -> None: - """create_profile_config() output matches _build_profile_settings() result.""" - # Setup + """create_profile_config() output matches _build_profile_settings() result. + + With no user-settings, config.json is exactly the null-stripped + profile-owned delta (statusLine/hooks). + """ config_dir = tmp_path / 'profile' hooks = { 'events': [ @@ -8382,11 +8065,7 @@ def test_output_matches_builder(self, tmp_path: Path) -> None: } profile_config = { 'hooks': hooks, - 'model': 'sonnet', - 'permissions': {'allow': ['Read']}, - 'env': {'FOO': 'bar'}, - 'alwaysThinkingEnabled': True, - 'effortLevel': 'high', + 'statusLine': {'file': 'status.sh'}, } # Call create_profile_config (writes to config.json) @@ -8401,8 +8080,21 @@ def test_output_matches_builder(self, tmp_path: Path) -> None: # Must match assert on_disk == expected - def test_env_null_entries_stripped_from_config_json(self, tmp_path: Path) -> None: - """Per-key env nulls are omitted from the atomically rebuilt config.json. + def test_user_settings_passed_through_to_config_json(self, tmp_path: Path) -> None: + """user-settings content forms the base of config.json alongside the delta.""" + config_dir = tmp_path / 'profile' + setup_environment.create_profile_config( + {'statusLine': {'file': 'status.sh'}}, + config_dir, + user_settings={'model': 'sonnet', 'permissions': {'allow': ['Read']}}, + ) + on_disk = json.loads((config_dir / 'config.json').read_text(encoding='utf-8')) + assert on_disk['model'] == 'sonnet' + assert on_disk['permissions'] == {'allow': ['Read']} + assert on_disk['statusLine']['type'] == 'command' + + def test_user_settings_env_null_entries_stripped_from_config_json(self, tmp_path: Path) -> None: + """Per-key env nulls in user-settings are omitted from the rebuilt config.json. Under atomic rebuild semantics absence equals deletion, so the isolated profile never carries a JSON null (or the literal string @@ -8410,34 +8102,64 @@ def test_env_null_entries_stripped_from_config_json(self, tmp_path: Path) -> Non """ config_dir = tmp_path / 'profile' setup_environment.create_profile_config( - {'env': {'DELETE_ME': None, 'KEEP': 'x'}}, config_dir, + {}, + config_dir, + user_settings={'env': {'DELETE_ME': None, 'KEEP': 'x'}}, ) on_disk = json.loads((config_dir / 'config.json').read_text(encoding='utf-8')) assert on_disk['env'] == {'KEEP': 'x'} assert 'DELETE_ME' not in on_disk['env'] assert 'None' not in on_disk['env'].values() - def test_env_key_dropped_when_all_entries_null(self, tmp_path: Path) -> None: - """An env dict whose entries are all nulls produces no env key at all.""" + def test_whole_env_section_null_stripped(self, tmp_path: Path) -> None: + """A whole-section env null in user-settings is stripped, not written as JSON null. + + create_profile_config() applies recursive null stripping to the + atomically rebuilt config.json, so a top-level null member is dropped + entirely rather than serialized as a literal JSON null. + """ config_dir = tmp_path / 'profile' setup_environment.create_profile_config( - {'env': {'DELETE_A': None, 'DELETE_B': None}}, config_dir, + {}, config_dir, user_settings={'env': None, 'model': 'sonnet'}, ) on_disk = json.loads((config_dir / 'config.json').read_text(encoding='utf-8')) assert 'env' not in on_disk + assert on_disk == {'model': 'sonnet'} - def test_whole_env_section_null_written_as_json_null(self, tmp_path: Path) -> None: - """A whole-section env null serializes as a top-level JSON null. + def test_env_section_with_only_null_entries_dropped(self, tmp_path: Path) -> None: + """An env section holding only deletion entries is dropped from config.json. - Top-level nulls are documented as equivalent to absent for Claude - Code's priority resolution, so they are kept as-is rather than - stripped. + A dict whose members are all deletion requests carries no content, + so the emptied key is omitted rather than written as an empty + object; a dict the user declared empty passes through unchanged. """ config_dir = tmp_path / 'profile' - setup_environment.create_profile_config({'env': None}, config_dir) + setup_environment.create_profile_config( + {}, + config_dir, + user_settings={'env': {'A': None, 'B': None}, 'attribution': {}, 'model': 'sonnet'}, + ) on_disk = json.loads((config_dir / 'config.json').read_text(encoding='utf-8')) - assert 'env' in on_disk - assert on_disk['env'] is None + assert 'env' not in on_disk + assert on_disk['attribution'] == {} + assert on_disk['model'] == 'sonnet' + + def test_non_ascii_user_settings_written_verbatim(self, tmp_path: Path) -> None: + """Non-ASCII user-settings content is written unescaped to config.json. + + The isolated writer matches the shared settings.json writer's + encoding (ensure_ascii=False), so announcements and attribution + strings in any language stay human-readable on disk. + """ + config_dir = tmp_path / 'profile' + setup_environment.create_profile_config( + {}, + config_dir, + user_settings={'companyAnnouncements': ['Willkommen zurück']}, + ) + raw = (config_dir / 'config.json').read_text(encoding='utf-8') + assert 'Willkommen zurück' in raw + assert '\\u' not in raw class TestWriteMergedJson: @@ -8624,6 +8346,43 @@ def test_excluded_keys_constant_null_accepted(self) -> None: result = setup_environment.validate_global_config({key: None}) assert result == [] + def test_settings_only_key_model_rejected(self) -> None: + """model is a settings.json key and is not valid in global-config.""" + result = setup_environment.validate_global_config({'model': 'sonnet'}) + assert any( + 'model' in e and 'settings.json key' in e and 'Move it to user-settings' in e + for e in result + ) + + def test_settings_only_key_permissions_rejected(self) -> None: + """permissions is a settings.json key and is not valid in global-config.""" + result = setup_environment.validate_global_config({'permissions': {'allow': ['Read']}}) + assert any('permissions' in e and 'Move it to user-settings' in e for e in result) + + def test_settings_only_key_env_rejected(self) -> None: + """env is a settings.json key and is not valid in global-config.""" + result = setup_environment.validate_global_config({'env': {'FOO': 'bar'}}) + assert any('settings.json key' in e for e in result) + + def test_profile_owned_status_line_key_rejected_with_root_hint(self) -> None: + """statusLine points the user to the root-level status-line YAML key.""" + result = setup_environment.validate_global_config({'statusLine': {'type': 'command'}}) + assert any('statusLine' in e and 'root-level' in e and 'status-line' in e for e in result) + + def test_profile_owned_hooks_key_rejected_with_root_hint(self) -> None: + """hooks points the user to the root-level hooks YAML key.""" + result = setup_environment.validate_global_config({'hooks': {'PreToolUse': []}}) + assert any('hooks' in e and 'root-level' in e for e in result) + + def test_global_only_key_auto_updates_passes(self) -> None: + """A genuine global-config key (autoUpdates) passes validation.""" + assert setup_environment.validate_global_config({'autoUpdates': False}) == [] + + def test_settings_only_key_null_still_rejected(self) -> None: + """A settings-only key is a misplacement error even when null-valued.""" + result = setup_environment.validate_global_config({'effortLevel': None}) + assert any('effortLevel' in e for e in result) + class TestWriteGlobalConfig: """Tests for write_global_config function.""" @@ -8803,106 +8562,6 @@ def test_global_config_nested_dict_still_deep_merges( assert 'srv2' in data['mcpServers'] -class TestDetectSettingsConflicts: - """Test the detect_settings_conflicts function.""" - - def test_no_conflicts_empty_sections(self) -> None: - """Empty sections have no conflicts.""" - result = setup_environment.detect_settings_conflicts({}, {}) - assert result == [] - - def test_no_conflicts_disjoint_keys(self) -> None: - """Different keys in each section have no conflicts.""" - user_settings = {'language': 'russian'} - root_config = {'model': 'claude-opus-4'} - result = setup_environment.detect_settings_conflicts(user_settings, root_config) - assert result == [] - - def test_same_key_conflict_detected(self) -> None: - """Same key in both sections is detected as conflict.""" - user_settings = {'model': 'claude-opus-4'} - root_config = {'model': 'claude-sonnet-4'} - result = setup_environment.detect_settings_conflicts(user_settings, root_config) - assert len(result) == 1 - assert result[0] == ('model', 'claude-opus-4', 'claude-sonnet-4') - - def test_kebab_to_camel_mapping_conflict(self) -> None: - """Kebab-case root key maps to camelCase user-settings key.""" - user_settings = {'alwaysThinkingEnabled': True} - root_config = {'always-thinking-enabled': False} - result = setup_environment.detect_settings_conflicts(user_settings, root_config) - assert len(result) == 1 - assert result[0] == ('alwaysThinkingEnabled', True, False) - - def test_env_variables_mapping_conflict(self) -> None: - """env-variables root key maps to env user-settings key.""" - user_settings = {'env': {'FOO': 'bar'}} - root_config = {'env-variables': {'FOO': 'baz'}} - result = setup_environment.detect_settings_conflicts(user_settings, root_config) - assert len(result) == 1 - assert result[0] == ('env', {'FOO': 'bar'}, {'FOO': 'baz'}) - - def test_effort_level_mapping_conflict(self) -> None: - """effort-level root key maps to effortLevel user-settings key.""" - user_settings = {'effortLevel': 'high'} - root_config = {'effort-level': 'low'} - result = setup_environment.detect_settings_conflicts(user_settings, root_config) - assert len(result) == 1 - assert result[0] == ('effortLevel', 'high', 'low') - - def test_multiple_conflicts_detected(self) -> None: - """Multiple conflicts are all detected.""" - user_settings = { - 'model': 'claude-opus-4', - 'alwaysThinkingEnabled': True, - } - root_config = { - 'model': 'claude-sonnet-4', - 'always-thinking-enabled': False, - } - result = setup_environment.detect_settings_conflicts(user_settings, root_config) - assert len(result) == 2 - conflicts_dict = {r[0]: (r[1], r[2]) for r in result} - assert conflicts_dict['model'] == ('claude-opus-4', 'claude-sonnet-4') - assert conflicts_dict['alwaysThinkingEnabled'] == (True, False) - - def test_unmapped_key_uses_same_name(self) -> None: - """Keys not in mapping use same name for lookup.""" - user_settings = {'permissions': {'allow': ['Bash']}} - root_config = {'permissions': {'deny': ['Web']}} - result = setup_environment.detect_settings_conflicts(user_settings, root_config) - assert len(result) == 1 - assert result[0][0] == 'permissions' - - def test_root_key_without_user_key_no_conflict(self) -> None: - """Root key present without corresponding user key is not a conflict.""" - user_settings = {'language': 'russian'} - root_config = {'model': 'claude-opus-4', 'always-thinking-enabled': True} - result = setup_environment.detect_settings_conflicts(user_settings, root_config) - assert result == [] - - def test_all_mapped_keys_checked(self) -> None: - """All keys in ROOT_TO_USER_SETTINGS_KEY_MAP are properly checked.""" - for root_key, user_key in setup_environment.ROOT_TO_USER_SETTINGS_KEY_MAP.items(): - user_settings = {user_key: 'user_value'} - root_config = {root_key: 'root_value'} - result = setup_environment.detect_settings_conflicts(user_settings, root_config) - assert len(result) == 1, f'Failed for mapping {root_key} -> {user_key}' - assert result[0][0] == user_key - - def test_conflict_returns_correct_tuple_structure(self) -> None: - """Conflict tuple has correct structure (user_key, user_value, root_value).""" - user_settings = {'model': 'user_model'} - root_config = {'model': 'root_model'} - result = setup_environment.detect_settings_conflicts(user_settings, root_config) - assert len(result) == 1 - conflict = result[0] - assert len(conflict) == 3 - assert conflict[0] == 'model' - assert conflict[1] == 'user_model' - assert conflict[2] == 'root_model' - - class TestResolveInheritPath: """Test the _resolve_inherit_path helper function.""" @@ -9512,12 +9171,12 @@ def test_non_string_entry(self): def test_invalid_key(self): """Key not in MERGEABLE_CONFIG_KEYS raises ValueError.""" with pytest.raises(ValueError, match='Invalid keys'): - setup_environment._validate_merge_keys(['model']) + setup_environment._validate_merge_keys(['name']) def test_context_in_error_message(self): """Context string appears in error messages.""" with pytest.raises(ValueError, match=r'inherit\[1\]:'): - setup_environment._validate_merge_keys(['model'], context='inherit[1]') + setup_environment._validate_merge_keys(['name'], context='inherit[1]') def test_empty_list_returns_empty_frozenset(self): """Empty list returns empty frozenset (no keys to merge).""" @@ -11473,7 +11132,13 @@ def test_main_user_settings_combined_mode( mock_load: MagicMock, capsys: pytest.CaptureFixture[str], ) -> None: - """Test main with user-settings AND command-names - combined mode.""" + """Test main with user-settings AND command-names - isolated mode. + + In isolated mode the user-settings section is built into the + profile's config.json at Step 18 (via create_profile_config), so + Step 14 does NOT call write_user_settings() and instead announces + that user settings are built into config.json. + """ # Mocks required by @patch decorators but not directly asserted del mock_mkdir, mock_is_admin, mock_skills, mock_resources, mock_deps mock_load.return_value = ( @@ -11482,8 +11147,8 @@ def test_main_user_settings_combined_mode( 'command-names': ['mydev'], 'user-settings': { 'language': 'russian', + 'model': 'claude-sonnet-4', }, - 'model': 'claude-sonnet-4', # Profile-level model }, 'test.yaml', ) @@ -11499,15 +11164,19 @@ def test_main_user_settings_combined_mode( setup_environment.main() mock_exit.assert_not_called() - # Verify write_user_settings was called - mock_write_user_settings.assert_called_once() - - # Verify profile settings were also created + # Isolated mode: write_user_settings is NOT called; the user-settings + # section is passed to create_profile_config instead. + mock_write_user_settings.assert_not_called() mock_settings.assert_called_once() + assert mock_settings.call_args.kwargs['user_settings'] == { + 'language': 'russian', + 'model': 'claude-sonnet-4', + } - # Verify output shows Step 13 and Steps 16-20 + # Verify output shows the isolated Step 14 skip message and Steps 17-21 captured = capsys.readouterr() assert 'Step 14: Writing user settings' in captured.out + assert 'Isolated mode: user settings are built into config.json in Step 18' in captured.out assert 'Step 17: Downloading hooks' in captured.out assert 'Step 18: Creating profile configuration' in captured.out assert 'Step 20: Creating launcher script' in captured.out @@ -11539,67 +11208,6 @@ def test_main_user_settings_excluded_key_error( assert 'hooks' in captured.err assert 'not allowed' in captured.err - @patch('setup_environment.load_config_from_source') - @patch('setup_environment.validate_all_config_files') - @patch('setup_environment.install_claude') - @patch('setup_environment.install_dependencies', return_value=[]) - @patch('setup_environment.process_resources') - @patch('setup_environment.process_skills') - @patch('setup_environment.configure_all_mcp_servers') - @patch('setup_environment.write_user_settings') - @patch('setup_environment.create_profile_config') - @patch('setup_environment.create_launcher_script') - @patch('setup_environment.register_global_command') - @patch('setup_environment.is_admin', return_value=True) - @patch('pathlib.Path.mkdir') - def test_main_user_settings_conflict_warning( - self, - mock_mkdir: MagicMock, - mock_is_admin: MagicMock, - mock_register: MagicMock, - mock_launcher: MagicMock, - mock_settings: MagicMock, - mock_write_user_settings: MagicMock, - mock_mcp: MagicMock, - mock_skills: MagicMock, - mock_resources: MagicMock, - mock_deps: MagicMock, - mock_install: MagicMock, - mock_validate: MagicMock, - mock_load: MagicMock, - capsys: pytest.CaptureFixture[str], - ) -> None: - """Test main with conflicting keys emits warning.""" - # Mocks required by @patch decorators but not directly asserted - del mock_mkdir, mock_is_admin, mock_skills, mock_resources, mock_deps - mock_load.return_value = ( - { - 'name': 'Conflict Config', - 'command-names': ['mydev'], - 'user-settings': { - 'model': 'claude-opus-4', # Conflict with root level - }, - 'model': 'claude-sonnet-4', # Root level model - }, - 'test.yaml', - ) - mock_validate.return_value = (True, []) - mock_install.return_value = True - mock_mcp.return_value = (True, [], {'global_count': 0, 'profile_count': 0, 'combined_count': 0}) - mock_write_user_settings.return_value = True - mock_settings.return_value = True - mock_launcher.return_value = (Path('/tmp/launcher.sh'), Path('/tmp/launcher.sh')) - mock_register.return_value = True - - with patch('sys.argv', ['setup_environment.py', 'test', '--yes']), patch('sys.exit') as mock_exit: - setup_environment.main() - mock_exit.assert_not_called() - - captured = capsys.readouterr() - # Warning should be emitted about model conflict - assert 'model' in captured.out - assert 'both root level and user-settings' in captured.out or 'specified in both' in captured.out - @patch('setup_environment.load_config_from_source') @patch('setup_environment.validate_all_config_files') @patch('setup_environment.install_claude') @@ -11762,37 +11370,6 @@ def test_main_user_settings_write_failure_non_fatal( # ============================================================================= -class TestDetectSettingsConflictsComplete: - """Exhaustive conflict detection tests for all ROOT_TO_USER_SETTINGS_KEY_MAP entries.""" - - @pytest.mark.parametrize( - ('root_key', 'user_key'), - [ - ('model', 'model'), - ('permissions', 'permissions'), - ('attribution', 'attribution'), - ('always-thinking-enabled', 'alwaysThinkingEnabled'), - ('company-announcements', 'companyAnnouncements'), - ('env-variables', 'env'), - ('effort-level', 'effortLevel'), - ], - ) - def test_conflict_all_mapped_keys_exhaustive( - self, root_key: str, user_key: str, - ) -> None: - """Every ROOT_TO_USER_SETTINGS_KEY_MAP entry produces a conflict when both present.""" - user_settings = {user_key: 'user_value'} - root_settings = {root_key: 'root_value'} - - conflicts = setup_environment.detect_settings_conflicts(user_settings, root_settings) - - assert len(conflicts) == 1, f'Expected conflict for {root_key} -> {user_key}' - key, user_val, root_val = conflicts[0] - assert key == user_key - assert user_val == 'user_value' - assert root_val == 'root_value' - - class TestDeepMergeEdgeCases: """Edge case tests for deep_merge_settings not covered in Phase 1.""" @@ -12535,7 +12112,6 @@ def test_collect_plan_full_config(self) -> None: 'events': [{'event': 'PostToolUse', 'type': 'command', 'command': 'hook.py'}], }, 'mcp-servers': [{'name': 'srv', 'transport': 'http', 'url': 'http://localhost'}], - 'model': 'sonnet', 'dependencies': { 'common': ['pip install flask'], 'windows': ['winget install Git'], @@ -12560,7 +12136,6 @@ def test_collect_plan_full_config(self) -> None: assert len(plan.hooks_files) == 1 assert len(plan.hooks_events) == 1 assert len(plan.mcp_servers) == 1 - assert plan.model == 'sonnet' assert plan.config_version == '2.0.0' def test_collect_plan_unknown_keys(self) -> None: @@ -13282,9 +12857,9 @@ class TestSuggestKnownKey: """Test _suggest_known_key() fuzzy matching for unknown config keys.""" def test_suggest_known_key_underscore_to_hyphen(self) -> None: - """Underscore variant 'effort_level' suggests 'effort-level'.""" - result = setup_environment._suggest_known_key('effort_level') - assert result == 'effort-level' + """Underscore variant 'os_env_variables' suggests 'os-env-variables'.""" + result = setup_environment._suggest_known_key('os_env_variables') + assert result == 'os-env-variables' def test_suggest_known_key_close_typo(self) -> None: """Underscore variant 'mcp_servers' suggests 'mcp-servers'.""" @@ -13304,13 +12879,13 @@ def test_display_summary_did_you_mean(self) -> None: config_source='test', config_source_type='repo', config_version='1.0', - unknown_keys=['effort_level'], + unknown_keys=['os_env_variables'], ) buf = io.StringIO() setup_environment.display_installation_summary(plan, output=buf) output = buf.getvalue() assert 'did you mean' in output - assert "'effort-level'" in output + assert "'os-env-variables'" in output def test_display_summary_no_suggestion_for_unknown(self) -> None: """No suggestion shown for a completely unrelated key.""" @@ -13332,20 +12907,18 @@ def test_display_summary_no_suggestion_for_unknown(self) -> None: class TestApplyAutoUpdateSettings: """Tests for apply_auto_update_settings() auto-update management.""" - def test_pinned_version_injects_all_four_targets(self) -> None: - gc, us, ev, osev, warns, auto = setup_environment.apply_auto_update_settings( - '2.1.85', None, None, None, None, + def test_pinned_version_injects_all_three_targets(self) -> None: + gc, us, osev, warns, auto = setup_environment.apply_auto_update_settings( + '2.1.85', None, None, None, ) assert gc is not None assert gc['autoUpdates'] is False assert us is not None assert us['env']['DISABLE_AUTOUPDATER'] == '1' - assert ev is not None - assert ev['DISABLE_AUTOUPDATER'] == '1' assert osev is not None assert osev['DISABLE_AUTOUPDATER'] == '1' assert not warns - assert len(auto) == 4 + assert len(auto) == 3 def test_none_version_preserves_user_declared_controls(self) -> None: """Unpinned: every control present comes from the user's YAML and is kept. @@ -13355,10 +12928,9 @@ def test_none_version_preserves_user_declared_controls(self) -> None: """ gc = {'autoUpdates': False, 'other': 'keep'} us: dict[str, Any] = {'env': {'DISABLE_AUTOUPDATER': '1', 'OTHER': 'val'}} - ev = {'DISABLE_AUTOUPDATER': '1', 'SOME_VAR': 'x'} osev: dict[str, str | None] = {'DISABLE_AUTOUPDATER': '1', 'PATH_VAR': '/usr'} - gc_r, us_r, ev_r, osev_r, warns, auto = setup_environment.apply_auto_update_settings( - None, gc, us, ev, osev, + gc_r, us_r, osev_r, warns, auto = setup_environment.apply_auto_update_settings( + None, gc, us, osev, ) assert gc_r is not None assert gc_r['autoUpdates'] is False @@ -13366,8 +12938,6 @@ def test_none_version_preserves_user_declared_controls(self) -> None: assert us_r is not None assert us_r['env']['DISABLE_AUTOUPDATER'] == '1' assert us_r['env']['OTHER'] == 'val' - assert ev_r is not None - assert ev_r['DISABLE_AUTOUPDATER'] == '1' assert osev_r is not None assert osev_r['DISABLE_AUTOUPDATER'] == '1' assert osev_r['PATH_VAR'] == '/usr' @@ -13375,37 +12945,30 @@ def test_none_version_preserves_user_declared_controls(self) -> None: assert not auto def test_none_global_config_creates_dict(self) -> None: - gc, _, _, _, _, _ = setup_environment.apply_auto_update_settings( - '1.0.0', None, {'env': {}}, {}, {}, + gc, _, _, _, _ = setup_environment.apply_auto_update_settings( + '1.0.0', None, {'env': {}}, {}, ) assert gc is not None assert gc['autoUpdates'] is False def test_none_user_settings_creates_dict(self) -> None: - _, us, _, _, _, _ = setup_environment.apply_auto_update_settings( - '1.0.0', {}, None, {}, {}, + _, us, _, _, _ = setup_environment.apply_auto_update_settings( + '1.0.0', {}, None, {}, ) assert us is not None assert us['env']['DISABLE_AUTOUPDATER'] == '1' - def test_none_env_variables_creates_dict(self) -> None: - _, _, ev, _, _, _ = setup_environment.apply_auto_update_settings( - '1.0.0', {}, {}, None, {}, - ) - assert ev is not None - assert ev['DISABLE_AUTOUPDATER'] == '1' - def test_none_os_env_variables_creates_dict(self) -> None: - _, _, _, osev, _, _ = setup_environment.apply_auto_update_settings( - '1.0.0', {}, {}, {}, None, + _, _, osev, _, _ = setup_environment.apply_auto_update_settings( + '1.0.0', {}, {}, None, ) assert osev is not None assert osev['DISABLE_AUTOUPDATER'] == '1' def test_conflict_detection_respects_user_autoupdates_true(self) -> None: gc = {'autoUpdates': True} - gc_r, _, _, _, warns, auto = setup_environment.apply_auto_update_settings( - '2.1.85', gc, {}, {}, {}, + gc_r, _, _, warns, auto = setup_environment.apply_auto_update_settings( + '2.1.85', gc, {}, {}, ) assert gc_r is not None assert gc_r['autoUpdates'] is True @@ -13415,8 +12978,8 @@ def test_conflict_detection_respects_user_autoupdates_true(self) -> None: def test_conflict_detection_respects_user_disable_autoupdater(self) -> None: us: dict[str, Any] = {'env': {'DISABLE_AUTOUPDATER': '0'}} - _, us_r, _, _, warns, _ = setup_environment.apply_auto_update_settings( - '2.1.85', {}, us, {}, {}, + _, us_r, _, warns, _ = setup_environment.apply_auto_update_settings( + '2.1.85', {}, us, {}, ) assert us_r is not None assert us_r['env']['DISABLE_AUTOUPDATER'] == '0' @@ -13424,8 +12987,8 @@ def test_conflict_detection_respects_user_disable_autoupdater(self) -> None: def test_unset_preserves_user_declared_false_autoupdates(self) -> None: gc = {'autoUpdates': False} - gc_r, _, _, _, warns, _ = setup_environment.apply_auto_update_settings( - None, gc, None, None, None, + gc_r, _, _, warns, _ = setup_environment.apply_auto_update_settings( + None, gc, None, None, ) assert gc_r is not None assert gc_r['autoUpdates'] is False @@ -13433,8 +12996,8 @@ def test_unset_preserves_user_declared_false_autoupdates(self) -> None: def test_unset_leaves_true_autoupdates_alone(self) -> None: gc = {'autoUpdates': True} - gc_r, _, _, _, warns, _ = setup_environment.apply_auto_update_settings( - None, gc, None, None, None, + gc_r, _, _, warns, _ = setup_environment.apply_auto_update_settings( + None, gc, None, None, ) assert gc_r is not None assert gc_r['autoUpdates'] is True @@ -13447,8 +13010,8 @@ def test_unset_schedules_os_level_deletion_when_not_declared(self) -> None: lets set_all_os_env_variables() remove any stale value left by a prior pinned run. """ - _, _, _, osev, warns, auto = setup_environment.apply_auto_update_settings( - None, None, None, None, None, + _, _, osev, warns, auto = setup_environment.apply_auto_update_settings( + None, None, None, None, ) assert osev is not None assert osev == {'DISABLE_AUTOUPDATER': None} @@ -13457,36 +13020,37 @@ def test_unset_schedules_os_level_deletion_when_not_declared(self) -> None: def test_unset_keeps_user_declared_os_variable(self) -> None: osev: dict[str, str | None] = {'DISABLE_AUTOUPDATER': '1'} - _, _, _, osev_r, _, _ = setup_environment.apply_auto_update_settings( - None, None, None, None, osev, + _, _, osev_r, _, _ = setup_environment.apply_auto_update_settings( + None, None, None, osev, ) assert osev_r is not None assert osev_r['DISABLE_AUTOUPDATER'] == '1' def test_idempotent_double_injection(self) -> None: - gc1, us1, ev1, osev1, _, auto1 = setup_environment.apply_auto_update_settings( - '2.1.85', None, None, None, None, + gc1, us1, osev1, _, auto1 = setup_environment.apply_auto_update_settings( + '2.1.85', None, None, None, ) - gc2, us2, ev2, osev2, _, auto2 = setup_environment.apply_auto_update_settings( - '2.1.85', gc1, us1, ev1, osev1, + gc2, us2, osev2, _, auto2 = setup_environment.apply_auto_update_settings( + '2.1.85', gc1, us1, osev1, ) assert gc1 == gc2 assert us1 == us2 - assert ev1 == ev2 assert osev1 == osev2 # Second call should not produce new auto-injected items (values already match) assert not auto2 def test_auto_injected_items_list(self) -> None: - _, _, _, _, _, auto = setup_environment.apply_auto_update_settings( - '2.1.85', None, None, None, None, + _, _, _, _, auto = setup_environment.apply_auto_update_settings( + '2.1.85', None, None, None, ) + # Exactly three targets: global-config, user-settings.env, os-env-variables. + # The dropped settings-level env-variables target leaves no fourth entry. + assert len(auto) == 3 assert any('global-config.autoUpdates' in a for a in auto) assert any('user-settings.env.DISABLE_AUTOUPDATER' in a for a in auto) - assert any('env-variables.DISABLE_AUTOUPDATER' in a for a in auto) assert any('os-env-variables.DISABLE_AUTOUPDATER' in a for a in auto) - def test_pinned_respects_explicit_null_in_all_four_targets(self) -> None: + def test_pinned_respects_explicit_null_in_all_three_targets(self) -> None: """Explicit null is a user deletion request, never overwritten with controls. Injection is membership-gated: a key present with None counts as a @@ -13496,21 +13060,18 @@ def test_pinned_respects_explicit_null_in_all_four_targets(self) -> None: """ gc: dict[str, Any] = {'autoUpdates': None} us: dict[str, Any] = {'env': {'DISABLE_AUTOUPDATER': None}} - ev: dict[str, str | None] = {'DISABLE_AUTOUPDATER': None} osev: dict[str, str | None] = {'DISABLE_AUTOUPDATER': None} - gc_r, us_r, ev_r, osev_r, warns, auto = setup_environment.apply_auto_update_settings( - '2.1.85', gc, us, ev, osev, + gc_r, us_r, osev_r, warns, auto = setup_environment.apply_auto_update_settings( + '2.1.85', gc, us, osev, ) assert gc_r is not None assert 'autoUpdates' in gc_r assert gc_r['autoUpdates'] is None assert us_r is not None assert us_r['env']['DISABLE_AUTOUPDATER'] is None - assert ev_r is not None - assert ev_r['DISABLE_AUTOUPDATER'] is None assert osev_r is not None assert osev_r['DISABLE_AUTOUPDATER'] is None - assert len(warns) == 4 + assert len(warns) == 3 assert all('Respecting user value' in w for w in warns) assert not auto @@ -13526,16 +13087,14 @@ def test_unconditional_injection_regardless_of_command_names(self) -> None: class TestApplyIdeExtensionSettings: """Tests for apply_ide_extension_settings() and its helpers.""" - def test_pinned_version_injects_all_four_targets(self) -> None: - gc, us, ev, osev, warns, auto = setup_environment.apply_ide_extension_settings( - '2.1.85', None, None, None, None, + def test_pinned_version_injects_all_three_targets(self) -> None: + gc, us, osev, warns, auto = setup_environment.apply_ide_extension_settings( + '2.1.85', None, None, None, ) assert gc is not None assert gc.get('autoInstallIdeExtension') is False assert us is not None assert us.get('env', {}).get('CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL') == '1' - assert ev is not None - assert ev.get('CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL') == '1' assert osev is not None assert osev.get('CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL') == '1' @@ -13547,10 +13106,9 @@ def test_none_version_preserves_user_declared_controls(self) -> None: """ gc = {'autoInstallIdeExtension': False, 'other': 'keep'} us: dict[str, Any] = {'env': {'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL': '1', 'OTHER': 'val'}} - ev = {'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL': '1', 'SOME_VAR': 'x'} osev: dict[str, str | None] = {'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL': '1', 'PATH_VAR': '/usr'} - gc_out, us_out, ev_out, osev_out, warns, auto = setup_environment.apply_ide_extension_settings( - None, gc, us, ev, osev, + gc_out, us_out, osev_out, warns, auto = setup_environment.apply_ide_extension_settings( + None, gc, us, osev, ) assert gc_out is not None assert gc_out['autoInstallIdeExtension'] is False @@ -13558,8 +13116,6 @@ def test_none_version_preserves_user_declared_controls(self) -> None: assert us_out is not None assert us_out['env']['CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL'] == '1' assert us_out['env']['OTHER'] == 'val' - assert ev_out is not None - assert ev_out['CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL'] == '1' assert osev_out is not None assert osev_out['CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL'] == '1' assert osev_out['PATH_VAR'] == '/usr' @@ -13567,33 +13123,27 @@ def test_none_version_preserves_user_declared_controls(self) -> None: assert not auto def test_none_global_config_creates_dict(self) -> None: - gc, _, _, _, _, _ = setup_environment.apply_ide_extension_settings( - '1.0.0', None, None, None, None, + gc, _, _, _, _ = setup_environment.apply_ide_extension_settings( + '1.0.0', None, None, None, ) assert gc is not None def test_none_user_settings_creates_dict(self) -> None: - _, us, _, _, _, _ = setup_environment.apply_ide_extension_settings( - '1.0.0', None, None, None, None, + _, us, _, _, _ = setup_environment.apply_ide_extension_settings( + '1.0.0', None, None, None, ) assert us is not None - def test_none_env_variables_creates_dict(self) -> None: - _, _, ev, _, _, _ = setup_environment.apply_ide_extension_settings( - '1.0.0', None, None, None, None, - ) - assert ev is not None - def test_none_os_env_variables_creates_dict(self) -> None: - _, _, _, osev, _, _ = setup_environment.apply_ide_extension_settings( - '1.0.0', None, None, None, None, + _, _, osev, _, _ = setup_environment.apply_ide_extension_settings( + '1.0.0', None, None, None, ) assert osev is not None def test_conflict_detection_respects_user_autoinstall_true(self) -> None: gc = {'autoInstallIdeExtension': True} - gc_out, _, _, _, warns, _ = setup_environment.apply_ide_extension_settings( - '2.0.0', gc, None, None, None, + gc_out, _, _, warns, _ = setup_environment.apply_ide_extension_settings( + '2.0.0', gc, None, None, ) assert gc_out is not None assert gc_out['autoInstallIdeExtension'] is True @@ -13601,8 +13151,8 @@ def test_conflict_detection_respects_user_autoinstall_true(self) -> None: def test_conflict_detection_respects_user_skip_env(self) -> None: us = {'env': {'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL': '0'}} - _, us_out, _, _, warns, _ = setup_environment.apply_ide_extension_settings( - '2.0.0', None, us, None, None, + _, us_out, _, warns, _ = setup_environment.apply_ide_extension_settings( + '2.0.0', None, us, None, ) assert us_out is not None assert us_out['env']['CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL'] == '0' @@ -13610,14 +13160,14 @@ def test_conflict_detection_respects_user_skip_env(self) -> None: def test_unset_preserves_user_declared_false_autoinstall(self) -> None: gc = {'autoInstallIdeExtension': False} - gc_out, _, _, _, warns, _ = setup_environment.apply_ide_extension_settings(None, gc, None, None, None) + gc_out, _, _, warns, _ = setup_environment.apply_ide_extension_settings(None, gc, None, None) assert gc_out is not None assert gc_out['autoInstallIdeExtension'] is False assert not warns def test_unset_leaves_true_autoinstall_alone(self) -> None: gc = {'autoInstallIdeExtension': True} - gc_out, _, _, _, warns, _ = setup_environment.apply_ide_extension_settings(None, gc, None, None, None) + gc_out, _, _, warns, _ = setup_environment.apply_ide_extension_settings(None, gc, None, None) assert gc_out is not None assert gc_out['autoInstallIdeExtension'] is True assert not warns @@ -13629,8 +13179,8 @@ def test_unset_schedules_os_level_deletion_when_not_declared(self) -> None: lets set_all_os_env_variables() remove any stale value left by a prior pinned run. """ - _, _, _, osev, warns, auto = setup_environment.apply_ide_extension_settings( - None, None, None, None, None, + _, _, osev, warns, auto = setup_environment.apply_ide_extension_settings( + None, None, None, None, ) assert osev is not None assert osev == {'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL': None} @@ -13639,33 +13189,34 @@ def test_unset_schedules_os_level_deletion_when_not_declared(self) -> None: def test_unset_keeps_user_declared_os_variable(self) -> None: osev: dict[str, str | None] = {'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL': '1'} - _, _, _, osev_out, _, _ = setup_environment.apply_ide_extension_settings( - None, None, None, None, osev, + _, _, osev_out, _, _ = setup_environment.apply_ide_extension_settings( + None, None, None, osev, ) assert osev_out is not None assert osev_out['CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL'] == '1' def test_idempotent_double_injection(self) -> None: - gc, us, ev, osev, _, auto1 = setup_environment.apply_ide_extension_settings( - '2.0.0', None, None, None, None, + gc, us, osev, _, auto1 = setup_environment.apply_ide_extension_settings( + '2.0.0', None, None, None, ) - gc2, us2, ev2, osev2, _, auto2 = setup_environment.apply_ide_extension_settings( - '2.0.0', gc, us, ev, osev, + gc2, us2, osev2, _, auto2 = setup_environment.apply_ide_extension_settings( + '2.0.0', gc, us, osev, ) - assert len(auto1) == 4 + assert len(auto1) == 3 assert len(auto2) == 0 # Already injected, no new items def test_auto_injected_items_list(self) -> None: - _, _, _, _, _, auto = setup_environment.apply_ide_extension_settings( - '2.0.0', None, None, None, None, + _, _, _, _, auto = setup_environment.apply_ide_extension_settings( + '2.0.0', None, None, None, ) - assert len(auto) == 4 + # Exactly three targets: global-config, user-settings.env, os-env-variables. + # The dropped settings-level env-variables target leaves no fourth entry. + assert len(auto) == 3 assert any('global-config.autoInstallIdeExtension' in a for a in auto) assert any('user-settings.env.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL' in a for a in auto) - assert any('env-variables.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL' in a for a in auto) assert any('os-env-variables.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL' in a for a in auto) - def test_pinned_respects_explicit_null_in_all_four_targets(self) -> None: + def test_pinned_respects_explicit_null_in_all_three_targets(self) -> None: """Explicit null is a user deletion request, never overwritten with controls. Injection is membership-gated: a key present with None counts as a @@ -13675,21 +13226,18 @@ def test_pinned_respects_explicit_null_in_all_four_targets(self) -> None: """ gc: dict[str, Any] = {'autoInstallIdeExtension': None} us: dict[str, Any] = {'env': {'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL': None}} - ev: dict[str, str | None] = {'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL': None} osev: dict[str, str | None] = {'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL': None} - gc_out, us_out, ev_out, osev_out, warns, auto = setup_environment.apply_ide_extension_settings( - '2.0.0', gc, us, ev, osev, + gc_out, us_out, osev_out, warns, auto = setup_environment.apply_ide_extension_settings( + '2.0.0', gc, us, osev, ) assert gc_out is not None assert 'autoInstallIdeExtension' in gc_out assert gc_out['autoInstallIdeExtension'] is None assert us_out is not None assert us_out['env']['CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL'] is None - assert ev_out is not None - assert ev_out['CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL'] is None assert osev_out is not None assert osev_out['CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL'] is None - assert len(warns) == 4 + assert len(warns) == 3 assert all('Respecting user value' in w for w in warns) assert not auto @@ -13702,16 +13250,10 @@ def test_signature_matches_auto_update(self) -> None: def test_unset_keeps_user_declared_env_section_value(self) -> None: us = {'env': {'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL': '1'}} - _, us_out, _, _, _, _ = setup_environment.apply_ide_extension_settings(None, None, us, None, None) + _, us_out, _, _, _ = setup_environment.apply_ide_extension_settings(None, None, us, None) assert us_out is not None assert us_out['env']['CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL'] == '1' - def test_unset_keeps_user_declared_env_variables_value(self) -> None: - ev = {'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL': '1'} - _, _, ev_out, _, _, _ = setup_environment.apply_ide_extension_settings(None, None, None, ev, None) - assert ev_out is not None - assert ev_out['CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL'] == '1' - class TestCleanupStaleIdeExtensionControls: """Tests for cleanup_stale_ide_extension_controls(). @@ -13963,35 +13505,35 @@ class TestCollectUserDeclaredControlKeys: """Tests for _collect_user_declared_control_keys().""" def test_empty_inputs_return_empty_set(self) -> None: - assert setup_environment._collect_user_declared_control_keys(None, None) == frozenset() - assert setup_environment._collect_user_declared_control_keys({}, {}) == frozenset() + assert setup_environment._collect_user_declared_control_keys(None) == frozenset() + assert setup_environment._collect_user_declared_control_keys({}) == frozenset() - def test_detects_keys_in_user_settings_env(self) -> None: + def test_detects_disable_autoupdater_in_user_settings_env(self) -> None: us: dict[str, Any] = {'env': {'DISABLE_AUTOUPDATER': '1'}} - result = setup_environment._collect_user_declared_control_keys(us, None) + result = setup_environment._collect_user_declared_control_keys(us) assert result == frozenset({'DISABLE_AUTOUPDATER'}) - def test_detects_keys_in_env_variables(self) -> None: - ev = {'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL': '1'} - result = setup_environment._collect_user_declared_control_keys(None, ev) + def test_detects_ide_skip_in_user_settings_env(self) -> None: + us: dict[str, Any] = {'env': {'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL': '1'}} + result = setup_environment._collect_user_declared_control_keys(us) assert result == frozenset({'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL'}) - def test_detects_keys_in_both_sources(self) -> None: - us: dict[str, Any] = {'env': {'DISABLE_AUTOUPDATER': '0'}} - ev = {'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL': 'true'} - result = setup_environment._collect_user_declared_control_keys(us, ev) + def test_detects_both_controls_in_user_settings_env(self) -> None: + us: dict[str, Any] = { + 'env': {'DISABLE_AUTOUPDATER': '0', 'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL': 'true'}, + } + result = setup_environment._collect_user_declared_control_keys(us) assert result == frozenset({ 'DISABLE_AUTOUPDATER', 'CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL', }) def test_ignores_non_dict_env_section(self) -> None: us: dict[str, Any] = {'env': 'not-a-dict'} - assert setup_environment._collect_user_declared_control_keys(us, None) == frozenset() + assert setup_environment._collect_user_declared_control_keys(us) == frozenset() def test_ignores_unrelated_keys(self) -> None: us: dict[str, Any] = {'env': {'OTHER': 'x'}} - ev = {'ANOTHER': 'y'} - assert setup_environment._collect_user_declared_control_keys(us, ev) == frozenset() + assert setup_environment._collect_user_declared_control_keys(us) == frozenset() class TestPropagateInstallMethod: diff --git a/tests/test_setup_environment_additional.py b/tests/test_setup_environment_additional.py index 3bf3cde..962e5e0 100644 --- a/tests/test_setup_environment_additional.py +++ b/tests/test_setup_environment_additional.py @@ -1261,8 +1261,9 @@ def test_create_profile_config_with_permissions_merge(self): } result = setup_environment.create_profile_config( - {'permissions': permissions}, + {}, claude_dir, + user_settings={'permissions': permissions}, ) assert result is True @@ -1284,8 +1285,9 @@ def test_create_profile_config_mcp_in_deny_list(self): permissions = {'deny': ['mcp__blocked_server']} setup_environment.create_profile_config( - {'permissions': permissions}, + {}, claude_dir, + user_settings={'permissions': permissions}, ) settings_file = claude_dir / 'config.json' @@ -1305,8 +1307,9 @@ def test_create_profile_config_mcp_in_ask_list(self): permissions = {'ask': ['mcp__ask_server']} setup_environment.create_profile_config( - {'permissions': permissions}, + {}, claude_dir, + user_settings={'permissions': permissions}, ) settings_file = claude_dir / 'config.json' @@ -1316,7 +1319,7 @@ def test_create_profile_config_mcp_in_ask_list(self): assert 'allow' not in settings['permissions'] or 'mcp__ask_server' not in settings['permissions'].get('allow', []) def test_create_profile_config_with_env_variables(self): - """Test creating settings with environment variables.""" + """Test creating settings with environment variables passed via user_settings.""" with tempfile.TemporaryDirectory() as tmpdir: claude_dir = Path(tmpdir) @@ -1326,8 +1329,9 @@ def test_create_profile_config_with_env_variables(self): } setup_environment.create_profile_config( - {'env': env_vars}, + {}, claude_dir, + user_settings={'env': env_vars}, ) settings_file = claude_dir / 'config.json' @@ -1336,7 +1340,7 @@ def test_create_profile_config_with_env_variables(self): assert settings['env'] == env_vars def test_create_profile_config_with_non_string_env_values(self) -> None: - """Non-string env variable values are coerced to strings in config.json.""" + """Non-string env values passed via user_settings are written as-is without stringification.""" with tempfile.TemporaryDirectory() as tmpdir: claude_dir = Path(tmpdir) @@ -1349,18 +1353,19 @@ def test_create_profile_config_with_non_string_env_values(self) -> None: } setup_environment.create_profile_config( - {'env': env_vars}, + {}, claude_dir, + user_settings={'env': env_vars}, ) settings_file = claude_dir / 'config.json' settings = json.loads(settings_file.read_text()) - # All values must be strings in the output + # Values pass through as-is; json.loads converts JSON number/boolean back to Python types env_output = settings['env'] - assert env_output['TIMEOUT'] == '30000' - assert env_output['ENABLE_FEATURE'] == 'True' - assert env_output['THRESHOLD'] == '0.5' + assert env_output['TIMEOUT'] == 30000 + assert env_output['ENABLE_FEATURE'] is True + assert env_output['THRESHOLD'] == 0.5 assert env_output['NORMAL_VAR'] == 'string_value' @patch('setup_environment.download_file') @@ -1907,16 +1912,17 @@ def test_main_with_all_features( 'name': 'Full Test', 'command-names': ['full-test'], 'base-url': 'https://example.com/base/{path}', - 'model': 'claude-3-opus', - 'permissions': { - 'default-mode': 'ask', - 'allow': ['tool__*'], - 'deny': ['mcp__dangerous'], - 'ask': ['mcp__sensitive'], - }, - 'env-variables': { - 'API_KEY': 'test123', - 'DEBUG': 'true', + 'user-settings': { + 'model': 'claude-3-opus', + 'permissions': { + 'defaultMode': 'auto', + 'allow': ['tool__*'], + 'deny': ['mcp__dangerous'], + }, + 'env': { + 'API_KEY': 'test123', + 'DEBUG': 'true', + }, }, 'dependencies': { 'windows': ['npm install test'],