updateConfig.ts
1 import { toJSONSchema } from 'zod/v4' 2 import { SettingsSchema } from '../../utils/settings/types.js' 3 import { jsonStringify } from '../../utils/slowOperations.js' 4 import { registerBundledSkill } from '../bundledSkills.js' 5 6 /** 7 * Generate JSON Schema from the settings Zod schema. 8 * This keeps the skill prompt in sync with the actual types. 9 */ 10 function generateSettingsSchema(): string { 11 const jsonSchema = toJSONSchema(SettingsSchema(), { io: 'input' }) 12 return jsonStringify(jsonSchema, null, 2) 13 } 14 15 const SETTINGS_EXAMPLES_DOCS = `## Settings File Locations 16 17 Choose the appropriate file based on scope: 18 19 | File | Scope | Git | Use For | 20 |------|-------|-----|---------| 21 | \`~/.claude/settings.json\` | Global | N/A | Personal preferences for all projects | 22 | \`.claude/settings.json\` | Project | Commit | Team-wide hooks, permissions, plugins | 23 | \`.claude/settings.local.json\` | Project | Gitignore | Personal overrides for this project | 24 25 Settings load in order: user → project → local (later overrides earlier). 26 27 ## Settings Schema Reference 28 29 ### Permissions 30 \`\`\`json 31 { 32 "permissions": { 33 "allow": ["Bash(npm:*)", "Edit(.claude)", "Read"], 34 "deny": ["Bash(rm -rf:*)"], 35 "ask": ["Write(/etc/*)"], 36 "defaultMode": "default" | "plan" | "acceptEdits" | "dontAsk", 37 "additionalDirectories": ["/extra/dir"] 38 } 39 } 40 \`\`\` 41 42 **Permission Rule Syntax:** 43 - Exact match: \`"Bash(npm run test)"\` 44 - Prefix wildcard: \`"Bash(git:*)"\` - matches \`git status\`, \`git commit\`, etc. 45 - Tool only: \`"Read"\` - allows all Read operations 46 47 ### Environment Variables 48 \`\`\`json 49 { 50 "env": { 51 "DEBUG": "true", 52 "MY_API_KEY": "value" 53 } 54 } 55 \`\`\` 56 57 ### Model & Agent 58 \`\`\`json 59 { 60 "model": "sonnet", // or "opus", "haiku", full model ID 61 "agent": "agent-name", 62 "alwaysThinkingEnabled": true 63 } 64 \`\`\` 65 66 ### Attribution (Commits & PRs) 67 \`\`\`json 68 { 69 "attribution": { 70 "commit": "Custom commit trailer text", 71 "pr": "Custom PR description text" 72 } 73 } 74 \`\`\` 75 Set \`commit\` or \`pr\` to empty string \`""\` to hide that attribution. 76 77 ### MCP Server Management 78 \`\`\`json 79 { 80 "enableAllProjectMcpServers": true, 81 "enabledMcpjsonServers": ["server1", "server2"], 82 "disabledMcpjsonServers": ["blocked-server"] 83 } 84 \`\`\` 85 86 ### Plugins 87 \`\`\`json 88 { 89 "enabledPlugins": { 90 "formatter@anthropic-tools": true 91 } 92 } 93 \`\`\` 94 Plugin syntax: \`plugin-name@source\` where source is \`claude-code-marketplace\`, \`claude-plugins-official\`, or \`builtin\`. 95 96 ### Other Settings 97 - \`language\`: Preferred response language (e.g., "japanese") 98 - \`cleanupPeriodDays\`: Days to keep transcripts (default: 30; 0 disables persistence entirely) 99 - \`respectGitignore\`: Whether to respect .gitignore (default: true) 100 - \`spinnerTipsEnabled\`: Show tips in spinner 101 - \`spinnerVerbs\`: Customize spinner verbs (\`{ "mode": "append" | "replace", "verbs": [...] }\`) 102 - \`spinnerTipsOverride\`: Override spinner tips (\`{ "excludeDefault": true, "tips": ["Custom tip"] }\`) 103 - \`syntaxHighlightingDisabled\`: Disable diff highlighting 104 ` 105 106 // Note: We keep hand-written examples for common patterns since they're more 107 // actionable than auto-generated schema docs. The generated schema list 108 // provides completeness while examples provide clarity. 109 110 const HOOKS_DOCS = `## Hooks Configuration 111 112 Hooks run commands at specific points in Claude Code's lifecycle. 113 114 ### Hook Structure 115 \`\`\`json 116 { 117 "hooks": { 118 "EVENT_NAME": [ 119 { 120 "matcher": "ToolName|OtherTool", 121 "hooks": [ 122 { 123 "type": "command", 124 "command": "your-command-here", 125 "timeout": 60, 126 "statusMessage": "Running..." 127 } 128 ] 129 } 130 ] 131 } 132 } 133 \`\`\` 134 135 ### Hook Events 136 137 | Event | Matcher | Purpose | 138 |-------|---------|---------| 139 | PermissionRequest | Tool name | Run before permission prompt | 140 | PreToolUse | Tool name | Run before tool, can block | 141 | PostToolUse | Tool name | Run after successful tool | 142 | PostToolUseFailure | Tool name | Run after tool fails | 143 | Notification | Notification type | Run on notifications | 144 | Stop | - | Run when Claude stops (including clear, resume, compact) | 145 | PreCompact | "manual"/"auto" | Before compaction | 146 | PostCompact | "manual"/"auto" | After compaction (receives summary) | 147 | UserPromptSubmit | - | When user submits | 148 | SessionStart | - | When session starts | 149 150 **Common tool matchers:** \`Bash\`, \`Write\`, \`Edit\`, \`Read\`, \`Glob\`, \`Grep\` 151 152 ### Hook Types 153 154 **1. Command Hook** - Runs a shell command: 155 \`\`\`json 156 { "type": "command", "command": "prettier --write $FILE", "timeout": 30 } 157 \`\`\` 158 159 **2. Prompt Hook** - Evaluates a condition with LLM: 160 \`\`\`json 161 { "type": "prompt", "prompt": "Is this safe? $ARGUMENTS" } 162 \`\`\` 163 Only available for tool events: PreToolUse, PostToolUse, PermissionRequest. 164 165 **3. Agent Hook** - Runs an agent with tools: 166 \`\`\`json 167 { "type": "agent", "prompt": "Verify tests pass: $ARGUMENTS" } 168 \`\`\` 169 Only available for tool events: PreToolUse, PostToolUse, PermissionRequest. 170 171 ### Hook Input (stdin JSON) 172 \`\`\`json 173 { 174 "session_id": "abc123", 175 "tool_name": "Write", 176 "tool_input": { "file_path": "/path/to/file.txt", "content": "..." }, 177 "tool_response": { "success": true } // PostToolUse only 178 } 179 \`\`\` 180 181 ### Hook JSON Output 182 183 Hooks can return JSON to control behavior: 184 185 \`\`\`json 186 { 187 "systemMessage": "Warning shown to user in UI", 188 "continue": false, 189 "stopReason": "Message shown when blocking", 190 "suppressOutput": false, 191 "decision": "block", 192 "reason": "Explanation for decision", 193 "hookSpecificOutput": { 194 "hookEventName": "PostToolUse", 195 "additionalContext": "Context injected back to model" 196 } 197 } 198 \`\`\` 199 200 **Fields:** 201 - \`systemMessage\` - Display a message to the user (all hooks) 202 - \`continue\` - Set to \`false\` to block/stop (default: true) 203 - \`stopReason\` - Message shown when \`continue\` is false 204 - \`suppressOutput\` - Hide stdout from transcript (default: false) 205 - \`decision\` - "block" for PostToolUse/Stop/UserPromptSubmit hooks (deprecated for PreToolUse, use hookSpecificOutput.permissionDecision instead) 206 - \`reason\` - Explanation for decision 207 - \`hookSpecificOutput\` - Event-specific output (must include \`hookEventName\`): 208 - \`additionalContext\` - Text injected into model context 209 - \`permissionDecision\` - "allow", "deny", or "ask" (PreToolUse only) 210 - \`permissionDecisionReason\` - Reason for the permission decision (PreToolUse only) 211 - \`updatedInput\` - Modified tool input (PreToolUse only) 212 213 ### Common Patterns 214 215 **Auto-format after writes:** 216 \`\`\`json 217 { 218 "hooks": { 219 "PostToolUse": [{ 220 "matcher": "Write|Edit", 221 "hooks": [{ 222 "type": "command", 223 "command": "jq -r '.tool_response.filePath // .tool_input.file_path' | { read -r f; prettier --write \\"$f\\"; } 2>/dev/null || true" 224 }] 225 }] 226 } 227 } 228 \`\`\` 229 230 **Log all bash commands:** 231 \`\`\`json 232 { 233 "hooks": { 234 "PreToolUse": [{ 235 "matcher": "Bash", 236 "hooks": [{ 237 "type": "command", 238 "command": "jq -r '.tool_input.command' >> ~/.claude/bash-log.txt" 239 }] 240 }] 241 } 242 } 243 \`\`\` 244 245 **Stop hook that displays message to user:** 246 247 Command must output JSON with \`systemMessage\` field: 248 \`\`\`bash 249 # Example command that outputs: {"systemMessage": "Session complete!"} 250 echo '{"systemMessage": "Session complete!"}' 251 \`\`\` 252 253 **Run tests after code changes:** 254 \`\`\`json 255 { 256 "hooks": { 257 "PostToolUse": [{ 258 "matcher": "Write|Edit", 259 "hooks": [{ 260 "type": "command", 261 "command": "jq -r '.tool_input.file_path // .tool_response.filePath' | grep -E '\\\\.(ts|js)$' && npm test || true" 262 }] 263 }] 264 } 265 } 266 \`\`\` 267 ` 268 269 const HOOK_VERIFICATION_FLOW = `## Constructing a Hook (with verification) 270 271 Given an event, matcher, target file, and desired behavior, follow this flow. Each step catches a different failure class — a hook that silently does nothing is worse than no hook. 272 273 1. **Dedup check.** Read the target file. If a hook already exists on the same event+matcher, show the existing command and ask: keep it, replace it, or add alongside. 274 275 2. **Construct the command for THIS project — don't assume.** The hook receives JSON on stdin. Build a command that: 276 - Extracts any needed payload safely — use \`jq -r\` into a quoted variable or \`{ read -r f; ... "$f"; }\`, NOT unquoted \`| xargs\` (splits on spaces) 277 - Invokes the underlying tool the way this project runs it (npx/bunx/yarn/pnpm? Makefile target? globally-installed?) 278 - Skips inputs the tool doesn't handle (formatters often have \`--ignore-unknown\`; if not, guard by extension) 279 - Stays RAW for now — no \`|| true\`, no stderr suppression. You'll wrap it after the pipe-test passes. 280 281 3. **Pipe-test the raw command.** Synthesize the stdin payload the hook will receive and pipe it directly: 282 - \`Pre|PostToolUse\` on \`Write|Edit\`: \`echo '{"tool_name":"Edit","tool_input":{"file_path":"<a real file from this repo>"}}' | <cmd>\` 283 - \`Pre|PostToolUse\` on \`Bash\`: \`echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | <cmd>\` 284 - \`Stop\`/\`UserPromptSubmit\`/\`SessionStart\`: most commands don't read stdin, so \`echo '{}' | <cmd>\` suffices 285 286 Check exit code AND side effect (file actually formatted, test actually ran). If it fails you get a real error — fix (wrong package manager? tool not installed? jq path wrong?) and retest. Once it works, wrap with \`2>/dev/null || true\` (unless the user wants a blocking check). 287 288 4. **Write the JSON.** Merge into the target file (schema shape in the "Hook Structure" section above). If this creates \`.claude/settings.local.json\` for the first time, add it to .gitignore — the Write tool doesn't auto-gitignore it. 289 290 5. **Validate syntax + schema in one shot:** 291 292 \`jq -e '.hooks.<event>[] | select(.matcher == "<matcher>") | .hooks[] | select(.type == "command") | .command' <target-file>\` 293 294 Exit 0 + prints your command = correct. Exit 4 = matcher doesn't match. Exit 5 = malformed JSON or wrong nesting. A broken settings.json silently disables ALL settings from that file — fix any pre-existing malformation too. 295 296 6. **Prove the hook fires** — only for \`Pre|PostToolUse\` on a matcher you can trigger in-turn (\`Write|Edit\` via Edit, \`Bash\` via Bash). \`Stop\`/\`UserPromptSubmit\`/\`SessionStart\` fire outside this turn — skip to step 7. 297 298 For a **formatter** on \`PostToolUse\`/\`Write|Edit\`: introduce a detectable violation via Edit (two consecutive blank lines, bad indentation, missing semicolon — something this formatter corrects; NOT trailing whitespace, Edit strips that before writing), re-read, confirm the hook **fixed** it. For **anything else**: temporarily prefix the command in settings.json with \`echo "$(date) hook fired" >> /tmp/claude-hook-check.txt; \`, trigger the matching tool (Edit for \`Write|Edit\`, a harmless \`true\` for \`Bash\`), read the sentinel file. 299 300 **Always clean up** — revert the violation, strip the sentinel prefix — whether the proof passed or failed. 301 302 **If proof fails but pipe-test passed and \`jq -e\` passed**: the settings watcher isn't watching \`.claude/\` — it only watches directories that had a settings file when this session started. The hook is written correctly. Tell the user to open \`/hooks\` once (reloads config) or restart — you can't do this yourself; \`/hooks\` is a user UI menu and opening it ends this turn. 303 304 7. **Handoff.** Tell the user the hook is live (or needs \`/hooks\`/restart per the watcher caveat). Point them at \`/hooks\` to review, edit, or disable it later. The UI only shows "Ran N hooks" if a hook errors or is slow — silent success is invisible by design. 305 ` 306 307 const UPDATE_CONFIG_PROMPT = `# Update Config Skill 308 309 Modify Claude Code configuration by updating settings.json files. 310 311 ## When Hooks Are Required (Not Memory) 312 313 If the user wants something to happen automatically in response to an EVENT, they need a **hook** configured in settings.json. Memory/preferences cannot trigger automated actions. 314 315 **These require hooks:** 316 - "Before compacting, ask me what to preserve" → PreCompact hook 317 - "After writing files, run prettier" → PostToolUse hook with Write|Edit matcher 318 - "When I run bash commands, log them" → PreToolUse hook with Bash matcher 319 - "Always run tests after code changes" → PostToolUse hook 320 321 **Hook events:** PreToolUse, PostToolUse, PreCompact, PostCompact, Stop, Notification, SessionStart 322 323 ## CRITICAL: Read Before Write 324 325 **Always read the existing settings file before making changes.** Merge new settings with existing ones - never replace the entire file. 326 327 ## CRITICAL: Use AskUserQuestion for Ambiguity 328 329 When the user's request is ambiguous, use AskUserQuestion to clarify: 330 - Which settings file to modify (user/project/local) 331 - Whether to add to existing arrays or replace them 332 - Specific values when multiple options exist 333 334 ## Decision: Config Tool vs Direct Edit 335 336 **Use the Config tool** for these simple settings: 337 - \`theme\`, \`editorMode\`, \`verbose\`, \`model\` 338 - \`language\`, \`alwaysThinkingEnabled\` 339 - \`permissions.defaultMode\` 340 341 **Edit settings.json directly** for: 342 - Hooks (PreToolUse, PostToolUse, etc.) 343 - Complex permission rules (allow/deny arrays) 344 - Environment variables 345 - MCP server configuration 346 - Plugin configuration 347 348 ## Workflow 349 350 1. **Clarify intent** - Ask if the request is ambiguous 351 2. **Read existing file** - Use Read tool on the target settings file 352 3. **Merge carefully** - Preserve existing settings, especially arrays 353 4. **Edit file** - Use Edit tool (if file doesn't exist, ask user to create it first) 354 5. **Confirm** - Tell user what was changed 355 356 ## Merging Arrays (Important!) 357 358 When adding to permission arrays or hook arrays, **merge with existing**, don't replace: 359 360 **WRONG** (replaces existing permissions): 361 \`\`\`json 362 { "permissions": { "allow": ["Bash(npm:*)"] } } 363 \`\`\` 364 365 **RIGHT** (preserves existing + adds new): 366 \`\`\`json 367 { 368 "permissions": { 369 "allow": [ 370 "Bash(git:*)", // existing 371 "Edit(.claude)", // existing 372 "Bash(npm:*)" // new 373 ] 374 } 375 } 376 \`\`\` 377 378 ${SETTINGS_EXAMPLES_DOCS} 379 380 ${HOOKS_DOCS} 381 382 ${HOOK_VERIFICATION_FLOW} 383 384 ## Example Workflows 385 386 ### Adding a Hook 387 388 User: "Format my code after Claude writes it" 389 390 1. **Clarify**: Which formatter? (prettier, gofmt, etc.) 391 2. **Read**: \`.claude/settings.json\` (or create if missing) 392 3. **Merge**: Add to existing hooks, don't replace 393 4. **Result**: 394 \`\`\`json 395 { 396 "hooks": { 397 "PostToolUse": [{ 398 "matcher": "Write|Edit", 399 "hooks": [{ 400 "type": "command", 401 "command": "jq -r '.tool_response.filePath // .tool_input.file_path' | { read -r f; prettier --write \\"$f\\"; } 2>/dev/null || true" 402 }] 403 }] 404 } 405 } 406 \`\`\` 407 408 ### Adding Permissions 409 410 User: "Allow npm commands without prompting" 411 412 1. **Read**: Existing permissions 413 2. **Merge**: Add \`Bash(npm:*)\` to allow array 414 3. **Result**: Combined with existing allows 415 416 ### Environment Variables 417 418 User: "Set DEBUG=true" 419 420 1. **Decide**: User settings (global) or project settings? 421 2. **Read**: Target file 422 3. **Merge**: Add to env object 423 \`\`\`json 424 { "env": { "DEBUG": "true" } } 425 \`\`\` 426 427 ## Common Mistakes to Avoid 428 429 1. **Replacing instead of merging** - Always preserve existing settings 430 2. **Wrong file** - Ask user if scope is unclear 431 3. **Invalid JSON** - Validate syntax after changes 432 4. **Forgetting to read first** - Always read before write 433 434 ## Troubleshooting Hooks 435 436 If a hook isn't running: 437 1. **Check the settings file** - Read ~/.claude/settings.json or .claude/settings.json 438 2. **Verify JSON syntax** - Invalid JSON silently fails 439 3. **Check the matcher** - Does it match the tool name? (e.g., "Bash", "Write", "Edit") 440 4. **Check hook type** - Is it "command", "prompt", or "agent"? 441 5. **Test the command** - Run the hook command manually to see if it works 442 6. **Use --debug** - Run \`claude --debug\` to see hook execution logs 443 ` 444 445 export function registerUpdateConfigSkill(): void { 446 registerBundledSkill({ 447 name: 'update-config', 448 description: 449 'Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, use Config tool.', 450 allowedTools: ['Read'], 451 userInvocable: true, 452 async getPromptForCommand(args) { 453 if (args.startsWith('[hooks-only]')) { 454 const req = args.slice('[hooks-only]'.length).trim() 455 let prompt = HOOKS_DOCS + '\n\n' + HOOK_VERIFICATION_FLOW 456 if (req) { 457 prompt += `\n\n## Task\n\n${req}` 458 } 459 return [{ type: 'text', text: prompt }] 460 } 461 462 // Generate schema dynamically to stay in sync with types 463 const jsonSchema = generateSettingsSchema() 464 465 let prompt = UPDATE_CONFIG_PROMPT 466 prompt += `\n\n## Full Settings JSON Schema\n\n\`\`\`json\n${jsonSchema}\n\`\`\`` 467 468 if (args) { 469 prompt += `\n\n## User Request\n\n${args}` 470 } 471 472 return [{ type: 'text', text: prompt }] 473 }, 474 }) 475 }