Skip to content

feat(setup): track ai/claude/settings.json template + per-key merge (SDD-002)#51

Merged
mlorentedev merged 2 commits into
mainfrom
feat/SDD-002-settings-portability
May 18, 2026
Merged

feat(setup): track ai/claude/settings.json template + per-key merge (SDD-002)#51
mlorentedev merged 2 commits into
mainfrom
feat/SDD-002-settings-portability

Conversation

@mlorentedev
Copy link
Copy Markdown
Owner

Summary

Tier 3 of the 5-layer SDD enforcement stack started by SDD-001 (#49). Adds a canonical ai/claude/settings.json template as the SSOT for the "dotfiles-owned" subset of ~/.claude/settings.json, refactors both setup scripts to read it via a Merge-ClaudeSettings / merge_claude_settings helper, and bootstraps the target on fresh machines (closes the doble-paso friction).

Why

Hook registration logic was duplicated in setup-windows.ps1 (PowerShell hashtable) and setup-linux.sh (bash heredoc) -- the exact duplication class that caused WIN-003 + BUG-002 + BUG-003. Also, on fresh machines without ~/.claude/settings.json, both scripts logged "settings.json not found, skipping" and required the user to run claude once then re-run setup.

Per-key merge policy (the heart of this PR)

Key Policy Why
model, effortLevel TEMPLATE wins Universal user prefs
permissions.allow UNION (deduped) Preserve user's machine-specific Read paths AND add our 3 portable MCP entries
permissions.additionalDirectories / others USER preserved Machine-specific
hooks.SessionStart TEMPLATE wins (replace) We own this hook
hooks.PreToolUse / PostToolUse / Stop USER preserved Third-party tools (claude-mem, GitGuardian) register here
enabledPlugins Object merge, template wins on conflict 14 universal plugins always enabled; user can add more
Other top-level keys USER preserved Future-proof

Full proposal + reasoning at specs/SDD-002-settings-portability/proposal.md.

Eat-our-dog-food

Followed the SDD Discipline Gate (instituted in PR #49) from the first commit:

  • Vault entry added BEFORE branch: 10_projects/dotfiles/11-tasks.md SDD-002 entry (auto-synced)
  • scripts/init-spec.ps1 SDD-002-settings-portability scaffolded the spec folder BEFORE any code
  • TDD: bats tests written before implementation
  • Smoke empirical on this Windows machine: both merge AND bootstrap flows verified

Test plan

  • Manual smoke on Windows (this machine): merge preserves user customizations (6 permissions.allow entries before -> 6 after, including 3 absolute-path Read entries the template doesn't touch); additionalDirectories preserved untouched; hooks.SessionStart rewritten with __HOOK_COMMAND__ correctly substituted to actual path; 14 enabledPlugins preserved
  • Bootstrap smoke: moved settings.json to .bootstrap-test, re-ran setup, output emitted "Bootstrapping ~/.claude/settings.json from template (file did not exist)" + "Claude settings.json bootstrapped from template"; new file shape matches template; original restored
  • PSScriptAnalyzer on setup-windows.ps1: clean (the expected PSUseSingularNouns for Merge-ClaudeSettings is suppressed in-line with explicit rationale)
  • bash -n setup-linux.sh: clean
  • Local simulation of all 29 new bats asserts: 100% green
  • CI: lint + lint-powershell + test (bats) + integration + GitGuardian

Out of scope (deferred)

  • SDD-003 (next): .github/workflows/ci.yml spec-gate job + .github/PULL_REQUEST_TEMPLATE.md SDD checklist. PR-time hard enforcement, Tier 4+5 of the SDD-001 stack.
  • Cross-machine path substitution beyond __HOOK_COMMAND__. Machine-specific paths (Read entries, additionalDirectories) stay user-owned by the merge policy.
  • Linux empirical smoke: deferred to CI (no Linux access this session). The merge_claude_settings shell function mirrors the PowerShell logic line-for-line.

Cross-references

…SDD-002)

Tier 3 of the 5-layer SDD enforcement stack started by SDD-001 (PR #49).
Closes two gaps:

1. No SSOT for "what does dotfiles own in settings.json" -- the hook entry
   was hardcoded as a PowerShell hashtable in setup-windows.ps1 and as a
   bash heredoc in setup-linux.sh. Adding a new structural key required
   editing both scripts in parallel -- the exact duplication class that
   caused WIN-003 + BUG-002 + BUG-003.
2. Fresh-machine doble-paso: without ~/.claude/settings.json, both scripts
   logged "settings.json not found, skipping" and left the hook unregistered.
   User had to run `claude` once (to create the default), then re-run setup.

What this PR does:

- New `ai/claude/settings.json` template (42 lines, plain JSON). Contains the
  curated "ours" subset: model=opus, effortLevel=xhigh, hooks.SessionStart
  (with __HOOK_COMMAND__ placeholder), enabledPlugins (the 14 universal
  plugins), permissions.allow (only 3 portable MCP entries; no Read paths).
- `setup-windows.ps1`: new `Merge-ClaudeSettings` helper function (next to
  existing Write-* / Ensure-Directory helpers). The previous 45-line inline
  hashtable hook-registration block becomes a single function call. Bulk
  copy of ai/claude/* now excludes settings.json explicitly (was a latent
  collision -- would have deployed the template verbatim with placeholder).
- `setup-linux.sh`: equivalent `merge_claude_settings` shell function using
  jq with --argjson for the per-key merge. Bulk cp loop now skips
  settings.json. The previous 24-line inline HOOK_ENTRY heredoc + jq merge
  becomes a single function call.
- Bootstrap branch in both scripts: when ~/.claude/settings.json does not
  exist, setup writes the template (with __HOOK_COMMAND__ substituted)
  directly. Closes the doble-paso. Logs "Bootstrapping ~/.claude/settings.json
  from template (file did not exist)".

Per-key merge policy (table in specs/SDD-002-settings-portability/proposal.md):
- model, effortLevel: TEMPLATE wins
- permissions.allow: UNION (template + existing, deduped) -- preserves user's
  machine-specific Read paths AND adds our 3 portable MCP entries
- permissions.additionalDirectories / .deny / other subkeys: USER preserved
- hooks.SessionStart: TEMPLATE wins (replace entire array) -- this is the
  one we own
- hooks.PreToolUse / PostToolUse / Stop: USER preserved -- third-party
  tools (claude-mem, GitGuardian) register hooks here
- enabledPlugins: object merge (template wins on conflict; user-added
  plugins beyond the 14 universal ones survive)
- All other top-level keys: USER preserved (future-proof)

Verification (empirical, this machine, 2026-05-18):

- Merge smoke: pre-state had permissions.allow count=6 (3 MCPs + 3 Read
  paths), additionalDirectories=1, hooks.SessionStart=1, 14 plugins.
  Post-merge: every count identical (UNION dedup correctly kept all 6,
  not 9), hooks.SessionStart command substituted to actual hook path.
- Bootstrap smoke: moved settings.json to .bootstrap-test, re-ran setup,
  log emitted "Bootstrapping ~/.claude/settings.json from template (file
  did not exist)" + "Claude settings.json bootstrapped from template".
  New file contained the template content with __HOOK_COMMAND__ correctly
  replaced. Original restored.
- PSScriptAnalyzer clean (the expected PSUseSingularNouns warning for
  `Merge-ClaudeSettings` is suppressed in-line with explicit rationale:
  "Settings" is the canonical Claude Code config-file name; singular
  `Setting` would be misleading).
- `bash -n setup-linux.sh` clean.
- All 18 + 11 = 29 new bats asserts simulated locally via jq + grep, 100%
  green.

Tests (additive):

- `tests/claude-settings-template.bats` (new file, 18 asserts): template
  exists, valid JSON, contains required keys, has placeholder, 14 plugins,
  3 MCP entries with no Read paths, no additionalDirectories, no
  PreToolUse / PostToolUse / Stop.
- `tests/setup-windows.bats` SDD-002 parity section (11 asserts): both
  scripts reference the template path, define their merge function, call
  it (not inline), exclude settings.json from bulk-copy, log the bootstrap
  message, substitute __HOOK_COMMAND__.

Spec scaffolding:

- `specs/SDD-002-settings-portability/` with proposal + tasks +
  verification.md, all filled with substantive content. Eat-our-dog-food:
  vault entry added BEFORE branch + init-spec scaffold BEFORE code, per
  the discipline gate instituted in SDD-001 (PR #49).

Out of scope (deferred):

- SDD-003: CI spec-gate workflow + PR template (PR-time enforcement layer).
- Cross-machine variable substitution beyond __HOOK_COMMAND__ (e.g., user
  path placeholders) -- machine-specific paths stay user-owned.
- Promotion of the user/machine-specific keys (Read paths, additionalDirectories)
  into the template -- they are intentionally user-owned by the policy.
…r + add -- separator

PR #51 CI caught three test failures:
1. setup-windows.bats "SessionStart hook self-heals on path drift": asserted
   the old `$existingHookCommand -eq $expectedHookCommand` compare-then-rewrite
   pattern, gone after SDD-002 moved hook registration into Merge-ClaudeSettings.
2. setup-linux.bats same test: asserted the old `EXISTING_HOOK_COMMAND` /
   `EXPECTED_HOOK_COMMAND` jq-then-rewrite, gone after the merge_claude_settings
   refactor.
3. setup-windows.bats "SDD-002 ... -Exclude 'settings.json'": grep -qF
   misparsed the pattern starting with `-Exclude` as a flag. Fixed with `--`
   separator (same fix pattern as the BUG-002 CORE PRINCIPLE assert).

Self-heal semantic is PRESERVED in both setup scripts (in fact strengthened --
the merge function always rewrites hooks.SessionStart from the template, not
just on detected drift). Tests updated to assert the new mechanism.

Pre-flight audit would have caught these locally if I had grep-simulated the
exact bats assertions instead of paraphrasing. Lesson for future refactors:
when changing implementation shape of a function with bats parity asserts,
grep the exact assertion patterns against the new code BEFORE pushing.
@mlorentedev mlorentedev merged commit 042e5dc into main May 18, 2026
5 checks passed
@mlorentedev mlorentedev deleted the feat/SDD-002-settings-portability branch May 18, 2026 19:53
mlorentedev added a commit that referenced this pull request May 19, 2026
…werShell 5.1 (BUG-005)

SDD-002 (PR #51) introduced Merge-ClaudeSettings which uses
`ConvertFrom-Json -AsHashtable` -- a parameter added in PowerShell 7.0 that
does NOT exist in Windows PowerShell 5.1. The natural Windows command
`PowerShell -ExecutionPolicy Bypass -File .\setup-windows.ps1` resolves
`PowerShell` to 5.1 on a stock Windows machine, the Merge function's wide
try/catch swallows the ParameterBindingException as if it were a JSON parse
error ("not valid JSON after placeholder substitution: A parameter cannot be
found that matches parameter name 'AsHashtable'"), and the per-key merge from
ai/claude/settings.json is silently skipped. Existing ~/.claude/settings.json
survives untouched, so the bug is invisible until a fresh-machine bootstrap
depends on the template merge.

Implementation: preamble block after param() and before CONFIGURATION header
detects $PSVersionTable.PSVersion.Major < 7. If `Get-Command pwsh` resolves
to PowerShell 7+, re-exec under it with `-NoProfile -ExecutionPolicy Bypass
-File $PSCommandPath @args` and exit with the child's exit code. Otherwise,
print an actionable [ERROR] line pointing at `winget install Microsoft.PowerShell`
and exit 1 (fail-loud, no silent skip).

Verification:
- Smoke under Windows PowerShell 5.1.22621.6931: first output line is
  `[INFO] Windows PowerShell 5.1.22621.6931 detected; re-executing under
  pwsh (...) for full feature compatibility (BUG-005)`. Setup proceeds under
  pwsh, settings.json merged correctly (1687 bytes, 14 plugins + preserved
  user customizations), zero AsHashtable warnings.
- Smoke under pwsh 7.6.1 direct: preamble no-op, no `[INFO] ... detected`
  lines, existing behavior preserved (verified during BUG-004 smoke same day).
- pwsh-missing branch: not directly testable (pwsh installed on dev machine);
  structurally locked by bats grep asserting `winget install Microsoft.PowerShell`
  literal + `exit 1`.
- 5 new bats asserts in tests/setup-windows.bats; RED -> GREEN via grep-by-grep
  emulation.
- PSScriptAnalyzer: 4 new Write-Host warnings, style-consistent with existing
  Write-Info/Success/Warn/Err wrappers (those wrappers are themselves
  Write-Host calls; preamble cannot use them because the wrappers are defined
  below the preamble's runtime position). Zero Errors. Zero new rule classes.

Linux side (setup-linux.sh) is immune by construction (bash + jq, no
shell-version coupling); zero changes there. Negative-parity bats assert
locks this in.

Spec: specs/BUG-005-setup-ps7-reexec/
Vault: 10_projects/dotfiles/11-tasks.md (entry added in same session)
Sibling: PR #57 (BUG-004 claude-mem-truncate-guard)
Upstream: https://learn.microsoft.com/powershell/scripting/whats-new/what-s-new-in-powershell-70

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant