Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions ai/claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"model": "opus",
"effortLevel": "xhigh",
"permissions": {
"allow": [
"mcp__hive__vault_query",
"mcp__hive__vault_write",
"mcp__sequential-thinking__sequentialthinking"
]
},
"hooks": {
"SessionStart": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "__HOOK_COMMAND__",
"timeout": 30
}
]
}
]
},
"enabledPlugins": {
"code-review@claude-plugins-official": true,
"feature-dev@claude-plugins-official": true,
"code-simplifier@claude-plugins-official": true,
"ralph-loop@claude-plugins-official": true,
"explanatory-output-style@claude-plugins-official": true,
"learning-output-style@claude-plugins-official": true,
"claude-md-management@claude-plugins-official": true,
"github@claude-plugins-official": true,
"claude-code-setup@claude-plugins-official": true,
"gopls-lsp@claude-plugins-official": true,
"security-guidance@claude-plugins-official": true,
"frontend-design@claude-plugins-official": true,
"commit-commands@claude-plugins-official": true,
"pr-review-toolkit@claude-plugins-official": true
}
}
99 changes: 75 additions & 24 deletions setup-linux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,14 @@ log_success "Gemini CLI configured"
# Claude Code
ensure_directory "$HOME/.claude"
ensure_directory "$HOME/.claude/skills"
cp -rf "$CURRENT_DIR/ai/claude/"* "$HOME/.claude/" 2>/dev/null || true
# Bulk copy ai/claude/* EXCEPT settings.json (SDD-002: handled by
# merge_claude_settings below, which substitutes __HOOK_COMMAND__ and applies
# the per-key merge policy preserving user customizations).
for _claude_src in "$CURRENT_DIR/ai/claude/"*; do
[ "$(basename "$_claude_src")" = "settings.json" ] && continue
cp -rf "$_claude_src" "$HOME/.claude/" 2>/dev/null || true
done
unset _claude_src
cp -f "$CURRENT_DIR/scripts/init-project.sh" "$HOME/.claude/" 2>/dev/null || true
# Sync Claude skills: remove stale skill directories not in source.
# CRITICAL: For symlinks (vault-hosted skills, see link_vault_skills below),
Expand Down Expand Up @@ -623,31 +630,75 @@ else
log_warning "Claude Code CLI not found, skipping plugin installation"
fi

# Register Claude Code SessionStart hook for vault health
# Self-healing: compare existing command against expected and rewrite if they
# diverge — never trust "an entry exists" to mean "the entry is correct".
# Merge `ai/claude/settings.json` template into the deployed `~/.claude/settings.json`
# per the per-key policy in specs/SDD-002-settings-portability/proposal.md. Bootstrap
# when target missing. Preserves user customizations (Read paths,
# additionalDirectories, third-party hooks like claude-mem / GitGuardian) by only
# touching the keys declared as "ours" in the template. The template's
# __HOOK_COMMAND__ placeholder is replaced via jq --arg before any merge / write.
merge_claude_settings() {
local template_path="$1"
local target_path="$2"
local hook_command="$3"

if [ ! -f "$template_path" ]; then
log_warning "Claude settings template not found at $template_path, skipping merge"
return 0
fi

if ! command -v jq >/dev/null 2>&1; then
log_warning "jq not found, skipping settings merge (install jq and re-run)"
return 0
fi

local template_substituted
template_substituted=$(jq --arg cmd "$hook_command" \
'(.hooks.SessionStart[0].hooks[0].command) = $cmd' \
"$template_path" 2>/dev/null)
if [ -z "$template_substituted" ]; then
log_warning "Claude settings template substitution failed, skipping merge"
return 0
fi

if [ ! -f "$target_path" ]; then
log_info "Bootstrapping ~/.claude/settings.json from template (file did not exist)"
echo "$template_substituted" > "$target_path"
log_success "Claude settings.json bootstrapped from template"
return 0
fi

# Per-key merge via single jq invocation. Policy table in proposal.md:
# model, effortLevel: template wins. permissions.allow: UNION (deduped).
# hooks.SessionStart: template wins (replace). enabledPlugins: object
# merge (template wins on conflict). All other keys: existing preserved.
local merged
merged=$(jq --argjson tmpl "$template_substituted" '
.model = $tmpl.model
| .effortLevel = $tmpl.effortLevel
| .permissions = (.permissions // {})
| .permissions.allow = (((.permissions.allow // []) + $tmpl.permissions.allow) | unique)
| .hooks = (.hooks // {})
| .hooks.SessionStart = $tmpl.hooks.SessionStart
| .enabledPlugins = ((.enabledPlugins // {}) + $tmpl.enabledPlugins)
' "$target_path" 2>/dev/null)
if [ -z "$merged" ]; then
log_warning "Claude settings merge produced empty output, skipping write"
return 0
fi

echo "$merged" > "$target_path"
log_success "Claude settings.json merged from template (user customizations preserved)"
}

# SDD-002 (PR #51): single source of truth for the "dotfiles-owned" subset of
# settings.json lives at ai/claude/settings.json. Previous inline `HOOK_ENTRY`
# heredoc + `jq --argjson` is gone -- merge_claude_settings reads the template,
# substitutes __HOOK_COMMAND__, applies per-key policy, bootstraps if missing.
log_info "Applying Claude settings.json template + registering SessionStart hook..."
CLAUDE_SETTINGS="$HOME/.claude/settings.json"
CLAUDE_SETTINGS_TEMPLATE="$CURRENT_DIR/ai/claude/settings.json"
EXPECTED_HOOK_COMMAND="$HOME/.dotfiles/scripts/claude-session-start.sh"
if [ -f "$CLAUDE_SETTINGS" ] && command -v jq >/dev/null 2>&1; then
EXISTING_HOOK_COMMAND=$(jq -r '.hooks.SessionStart[0].hooks[0].command // ""' "$CLAUDE_SETTINGS" 2>/dev/null)
if [ "$EXISTING_HOOK_COMMAND" = "$EXPECTED_HOOK_COMMAND" ]; then
log_info "SessionStart hook already correctly configured, skipping"
else
if [ -n "$EXISTING_HOOK_COMMAND" ]; then
log_info "SessionStart hook points to '$EXISTING_HOOK_COMMAND'; updating to '$EXPECTED_HOOK_COMMAND'"
else
log_info "Adding SessionStart hook to Claude Code settings..."
fi
HOOK_ENTRY=$(jq -n --arg cmd "$EXPECTED_HOOK_COMMAND" '{"matcher":"","hooks":[{"type":"command","command":$cmd,"timeout":30}]}')
jq --argjson hook "[$HOOK_ENTRY]" '.hooks.SessionStart = $hook' "$CLAUDE_SETTINGS" > "${CLAUDE_SETTINGS}.tmp" \
&& mv "${CLAUDE_SETTINGS}.tmp" "$CLAUDE_SETTINGS"
log_success "SessionStart hook registered"
fi
elif [ ! -f "$CLAUDE_SETTINGS" ]; then
log_warning "Claude Code settings.json not found, skipping hook registration"
else
log_warning "jq not found, skipping hook registration (install jq and re-run)"
fi
merge_claude_settings "$CLAUDE_SETTINGS_TEMPLATE" "$CLAUDE_SETTINGS" "$EXPECTED_HOOK_COMMAND"

# Deploy auto-memory symlinks (vault → Claude Code)
# Memory lives in the knowledge vault, not in this repo (see ADR-007)
Expand Down
140 changes: 96 additions & 44 deletions setup-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,90 @@ function Ensure-Directory {
}
}

# Merge `ai/claude/settings.json` template into the deployed `~/.claude/settings.json`
# per the per-key policy in specs/SDD-002-settings-portability/proposal.md. Bootstrap
# when target missing. Preserves user customizations (Read paths,
# additionalDirectories, third-party hooks like claude-mem / GitGuardian) by only
# touching the keys declared as "ours" in the template. The template's
# __HOOK_COMMAND__ placeholder is replaced with the OS-specific hook command
# before any merge / write.
function Merge-ClaudeSettings {
[CmdletBinding()]
# "Settings" is the canonical Claude Code config-file name (settings.json);
# the function operates on the whole file, not one setting -- using
# `Setting` (singular) would be misleading. Plural noun warning suppressed.
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')]
param(
[Parameter(Mandatory)][string]$TemplatePath,
[Parameter(Mandatory)][string]$TargetPath,
[Parameter(Mandatory)][string]$HookCommand
)

if (-not (Test-Path $TemplatePath)) {
Write-Warn "Claude settings template not found at $TemplatePath, skipping merge"
return
}

# Read template, JSON-escape the hook command, substitute __HOOK_COMMAND__
$escapedCommand = ($HookCommand -replace '\\', '\\') -replace '"', '\"'
$templateRaw = (Get-Content $TemplatePath -Raw) -replace '__HOOK_COMMAND__', $escapedCommand
try {
$template = $templateRaw | ConvertFrom-Json -AsHashtable
} catch {
Write-Warn "Claude settings template is not valid JSON after placeholder substitution: $_"
return
}

# Bootstrap if target missing
if (-not (Test-Path $TargetPath)) {
Write-Info "Bootstrapping ~/.claude/settings.json from template (file did not exist)"
$template | ConvertTo-Json -Depth 10 | Set-Content $TargetPath -Encoding UTF8
Write-Success "Claude settings.json bootstrapped from template"
return
}

# Read existing target
try {
$existing = Get-Content $TargetPath -Raw | ConvertFrom-Json -AsHashtable
} catch {
Write-Warn "Claude settings.json at $TargetPath is not valid JSON, skipping merge: $_"
return
}
if ($null -eq $existing) { $existing = @{} }

# Per-key merge policy (table in proposal.md)
if ($template.ContainsKey('model')) { $existing['model'] = $template['model'] }
if ($template.ContainsKey('effortLevel')) { $existing['effortLevel'] = $template['effortLevel'] }

# permissions.allow: UNION (template + existing, deduped)
if ($template.ContainsKey('permissions') -and $template['permissions'].ContainsKey('allow')) {
if (-not $existing.ContainsKey('permissions')) { $existing['permissions'] = @{} }
if (-not $existing['permissions'].ContainsKey('allow')) { $existing['permissions']['allow'] = @() }
$merged = @(@($existing['permissions']['allow']) + @($template['permissions']['allow']) | Select-Object -Unique)
$existing['permissions']['allow'] = $merged
}

# hooks.SessionStart: TEMPLATE wins (replace entire array). Other hook
# surfaces (PreToolUse, PostToolUse, Stop) are untouched -- third parties
# register there.
if ($template.ContainsKey('hooks') -and $template['hooks'].ContainsKey('SessionStart')) {
if (-not $existing.ContainsKey('hooks')) { $existing['hooks'] = @{} }
$existing['hooks']['SessionStart'] = $template['hooks']['SessionStart']
}

# enabledPlugins: object merge (template wins on conflict). User-added
# plugins beyond the 14 universal ones survive.
if ($template.ContainsKey('enabledPlugins')) {
if (-not $existing.ContainsKey('enabledPlugins')) { $existing['enabledPlugins'] = @{} }
foreach ($plugin in $template['enabledPlugins'].Keys) {
$existing['enabledPlugins'][$plugin] = $template['enabledPlugins'][$plugin]
}
}

$existing | ConvertTo-Json -Depth 10 | Set-Content $TargetPath -Encoding UTF8
Write-Success "Claude settings.json merged from template (user customizations preserved)"
}

# ============================================================================
# BANNER
# ============================================================================
Expand Down Expand Up @@ -135,10 +219,12 @@ if ($wingetCmd) {

Write-Info "Deploying Claude configuration..."

# Bulk copy all Claude config files
# Bulk copy all Claude config files EXCEPT settings.json (SDD-002: handled by
# Merge-ClaudeSettings below, which substitutes __HOOK_COMMAND__ and applies the
# per-key merge policy preserving user customizations).
$claudeSource = "$DotfilesDir\ai\claude"
if (Test-Path $claudeSource) {
Copy-Item "$claudeSource\*" "$ClaudeHome\" -Recurse -Force -ErrorAction SilentlyContinue
Copy-Item "$claudeSource\*" "$ClaudeHome\" -Recurse -Force -Exclude 'settings.json' -ErrorAction SilentlyContinue
}

# Force copy CLAUDE.md (Neural Hive Protocol)
Expand Down Expand Up @@ -744,53 +830,19 @@ if (Test-Path $sensitiveSource) {
# 7c. REGISTER SESSIONSTART HOOK
# ============================================================================

Write-Info "Registering Claude Code SessionStart hook..."
Write-Info "Applying Claude settings.json template + registering SessionStart hook..."

# SDD-002 (PR #51): single source of truth for the "dotfiles-owned" subset of
# settings.json lives at ai/claude/settings.json. The previous inline hashtable
# for the hook entry is gone -- Merge-ClaudeSettings reads the template,
# substitutes __HOOK_COMMAND__, and applies the per-key policy. Bootstraps a
# fresh settings.json if missing (closes the v1 doble-paso friction).
$ClaudeSettings = "$ClaudeHome\settings.json"
$ClaudeSettingsTemplate = "$DotfilesDir\ai\claude\settings.json"
$sessionStartCmd = "$ScriptsDir\claude-session-start.ps1"
$expectedHookCommand = "pwsh -NoProfile -File `"$sessionStartCmd`""

if (Test-Path $ClaudeSettings) {
try {
$settings = Get-Content $ClaudeSettings -Raw | ConvertFrom-Json

$existingHookCommand = $null
if ($settings.hooks -and $settings.hooks.SessionStart -and $settings.hooks.SessionStart[0] -and $settings.hooks.SessionStart[0].hooks) {
$existingHookCommand = $settings.hooks.SessionStart[0].hooks[0].command
}

if ($existingHookCommand -eq $expectedHookCommand) {
Write-Info "SessionStart hook already correctly configured, skipping"
} else {
if ($existingHookCommand) {
Write-Info "SessionStart hook points to '$existingHookCommand'; updating to '$expectedHookCommand'"
}

$hookEntry = @{
matcher = ''
hooks = @(
@{
type = 'command'
command = $expectedHookCommand
timeout = 30
}
)
}

if (-not $settings.hooks) {
$settings | Add-Member -NotePropertyName 'hooks' -NotePropertyValue @{} -Force
}

$settings.hooks | Add-Member -NotePropertyName 'SessionStart' -NotePropertyValue @($hookEntry) -Force
$settings | ConvertTo-Json -Depth 10 | Set-Content $ClaudeSettings -Encoding UTF8
Write-Success "SessionStart hook registered"
}
} catch {
Write-Warn "Failed to register SessionStart hook: $_"
}
} else {
Write-Warn "Claude Code settings.json not found, skipping hook registration"
}
Merge-ClaudeSettings -TemplatePath $ClaudeSettingsTemplate -TargetPath $ClaudeSettings -HookCommand $expectedHookCommand

# ============================================================================
# 8. GITHUB COPILOT CLI
Expand Down
Loading
Loading