Chapter 2. Installing Claude Code and Configuring It for Real Work

Install Claude Code with the native script (curl -fsSL https://claude.ai/install.sh | bash on macOS, Linux, and WSL), or through Homebrew, WinGet, npm, or a signed Linux repository. Configure it by hand-editing JSON settings files, because claude config get and claude config set, still circulating in older writeups, do not exist in 2.1.211: the word “config” parses as a prompt, not a command.

The machine this book runs on carries Claude Code 2.1.211 as a native build, and every path, key name, and byte count in this chapter is read off its disk rather than quoted from documentation. Where the docs and the binary disagree, the binary wins. On this build, claude config --help produces output byte-identical to claude zzzbogusnotacommand --help. Configuration lives in JSON files you edit by hand, plus the in-session /config command and the --settings and --setting-sources flags. Treat any tutorial built on claude config set as archaeology.

Install routes and where the binary lands

The first-party install routes:

Route Command Updates
Native script, macOS/Linux/WSL curl -fsSL https://claude.ai/install.sh \| bash auto-updates in the background
Native script, Windows PowerShell irm https://claude.ai/install.ps1 \| iex auto-updates in the background
Homebrew stable claude-code cask manual brew upgrade; about a week behind
Homebrew latest claude-code@latest cask manual brew upgrade
WinGet winget install Anthropic.ClaudeCode manual winget upgrade
npm npm install -g @anthropic-ai/claude-code needs Node 22 or later as of v2.1.198

Linux also gets signed apt, dnf, and apk repositories at https://downloads.claude.ai/claude-code/{apt,rpm,apk}/<channel> with stable and latest channels; the package name is claude-code. Even the npm route is a wrapper around the same native binary now: the package pulls it in through per-platform optional dependencies like @anthropic-ai/claude-code-darwin-arm64.

What the native installer leaves on disk is compact. which claude resolves to ~/.local/bin/claude, a symlink into ~/.local/share/claude/versions/2.1.211. The target is one Mach-O 64-bit executable of 251,966,736 bytes, not a node wrapper script, and ~/.claude.json records installMethod: "native". One consequence of the packaging: strings -a over the 252 MB binary returns zero matches for the help text it prints at runtime. The payload is compressed, so you cannot enumerate a build’s command surface by grepping the binary. If you want to know what a given build can do, you ask the build.

Updates are governed by one setting and two environment variables. autoUpdatesChannel accepts "latest" (the default) or "stable". DISABLE_AUTOUPDATER stops the background update check; DISABLE_UPDATES blocks manual updates too. This machine sets DISABLE_AUTOUPDATER, because a shared box running concurrent agent sessions does not need the CLI swapping itself out mid-task; updates happen when I run claude update. The receipt for the last one sits at ~/.claude/.last-update-result.json with the shape {timestamp, path, outcome, status, version_from, version_to, error_code}: path "native", outcome and status "success", 2.1.207 to 2.1.211, error_code null.

claude doctor verifies all of it. On this machine it reports: Running native (2.1.211), Commit 17a4b6d7b2ee, Platform darwin-x64, Config install method native, Search OK (bundled), Auto-updates disabled (set by env: DISABLE_AUTOUPDATER), Auto-update channel latest, Last update attempt success to 2.1.211 (2026-07-16), plus a Remote Control section and a final installation-issues verdict. Its help text notes it reads settings files in the current directory without a trust prompt. Run it after any settings edit; the failure mode it catches is described below.

Settings precedence: which file wins

Settings resolve across layers, highest precedence first:

  1. Managed settings (fleet policy; cannot be overridden, not even by command-line arguments)
  2. Command-line arguments
  3. Local project settings, .claude/settings.local.json
  4. Shared project settings, .claude/settings.json
  5. User settings, ~/.claude/settings.json

Inside the managed tier there is no merging: one source wins, in the order policyHelper output, remote server-managed settings, MDM/OS-level policy, the file pair managed-settings.json plus a managed-settings.d/ drop-in directory, then the Windows HKCU registry. On macOS the file path is /Library/Application Support/ClaudeCode/managed-settings.json. This machine has none; there is no fleet policy above a solo operator. The drop-in directory follows the systemd convention: the base file merges first, then *.json fragments in alphabetical order, scalars overriding, arrays concatenating with duplicates removed, objects deep-merging.

Across the non-managed tiers, scalar keys resolve by precedence, but array-valued keys like permissions.allow and sandbox.filesystem.allowWrite concatenate and de-duplicate across scopes. That cuts both ways. A project can extend my user allowlist without restating it, and nothing at a lower tier can subtract a rule granted above it. Subtraction is what permissions.deny is for: rules evaluate deny first, then ask, then allow, and the first match decides regardless of specificity. When you need to control which tiers load at all, --setting-sources takes a comma-separated list whose three valid values are user, project, and local.

Two operational facts matter more than the precedence chart. First, validation for user, project, and local files is strict and file-granular: one bad key rejects the whole file. An interactive session shows a Settings Error dialog at startup. A -p (print-mode) run does not: the CLI’s own help for -p describes invalid settings files as ignored with no error dialog, while the settings docs say a summary prints to stderr. Either way, a cron job or CI script proceeds without the permission rules you thought you set. This is why claude doctor follows any hand edit (it lists each invalid entry with its source and field), why /status shows a Setting sources line where a broken file goes missing, and why a $schema line pointing at https://json.schemastore.org/claude-code-settings.json is worth adding so your editor catches the typo before the harness discards the file. Second, settings hot-reload mid-session, including permissions, hooks, and apiKeyHelper; only model and outputStyle are read once at start.

The documented key table runs to 124 keys. My user file uses nine of them.

What is in ~/.claude/settings.json on this machine

The keys present in ~/.claude/settings.json on this machine, with real values and the longer lists elided:

{
  "permissions": {
    "allow": [
      "Bash(gh auth status)",
      "Bash(gh auth status:*)",
      "Bash(git commit -m ' *)"
    ]
  },
  "hooks": {},
  "enabledPlugins": {
    "superpowers@claude-plugins-official": true,
    "frontend-design@claude-plugins-official": true,
    "vercel@claude-plugins-official": true,
    "suede-skills@suede": true
  },
  "skipWorkflowUsageWarning": true,
  "theme": "dark",
  "agentPushNotifEnabled": true,
  "inputNeededNotifEnabled": true,
  "promptSuggestionEnabled": true,
  "emojiCompletionEnabled": true
}

The permission grammar deserves a close read, because this one file demonstrates three things the docs state in the abstract. Rules are strings of the form Tool or Tool(specifier). Bash rules carry the command inside the parens, and two distinct wildcard forms appear side by side: the colon-star suffix (Bash(gh auth status:*)) and a bare trailing space-star (Bash(git commit -m ' *)). The same command shows up in both exact and wildcard form, Bash(gh auth status) beside Bash(gh auth status:*), because the exact form does not imply the argument-wildcard form; approving the bare command did not cover invocations with flags, so both rules accumulated. Non-Bash tools are named bare (WebSearch), and MCP tools use the mcp__<server>__<tool> form (mcp__scheduled-tasks__list_scheduled_tasks). This machine runs on permissions.allow alone; deny and ask are absent.

enabledPlugins holds 12 entries: 4 true and 8 set to false rather than deleted. Disabling keeps a plugin installed and listed; removal is a separate act. And note what is not in this file. The suede marketplace that suede-skills@suede resolves against is registered in ~/.claude/plugins/known_marketplaces.json, not here, so at user level the two halves of plugin registration live in different files. Five marketplaces are registered on this machine: claude-plugins-official, superpowers-dev, google-labs-code-stitch-skills, suede, and addy-agent-skills.

The absent keys say as much as the present ones. No env, no model, no statusLine, no outputStyle, no apiKeyHelper, no cleanupPeriodDays: the defaults hold, and nothing secret-shaped goes near a settings file. hooks is present but {} at this tier; the working hook lives one file over.

settings.local.json: hooks wired to one machine

Beside the user file sits ~/.claude/settings.local.json with three top-level keys: permissions, hooks, and a description string. That last one is not a documented settings key; the validator tolerates it, and it works as an annotation telling a future reader why the file exists. The one live hook on this machine is a PostToolUse hook, and its shape is the whole anatomy of the hook system:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"",
            "timeout": 5,
            "statusMessage": "..."
          }
        ]
      }
    ]
  }
}

Each event key holds an array of matcher objects. The matcher is one pipe-delimited string of tool names, not an array. Each matcher object holds an inner hooks array whose entries carry type: "command", the command line, a timeout in seconds (5 here), and a statusMessage, a short present-participle label the UI shows while the hook runs. ${CLAUDE_PROJECT_DIR} interpolates the project directory, which is what lets a skill ship its own hook script (the target here is a 1,999-byte hook.mjs inside the impeccable skill’s scripts/ directory) and have the harness run it after edits. A supporting detail: ~/.claude/package.json contains {"type":"commonjs"} and nothing else, pinning the module system for any .js hook script under ~/.claude.

Why does this live in the local file? Because the command embeds an assumption about this machine’s layout, and because hooks are the config most prone to rot. The evidence sits on the same disk. ~/.claude/hooks/ exists and is empty, zero entries, while a retained backup, settings.json.bak-20260618-223239, references six scripts by that path. The backup documents a fuller configuration that once ran here: SessionStart, PreToolUse, and PostToolUse arrays plus a top-level statusLine of shape {type: "command", command: "..."}. It preserves two shape facts worth stealing. SessionStart entries carry no matcher key at all. And one event array can hold multiple objects with the same matcher: the backup has three PreToolUse entries all matching Write|Edit, each running a different script, instead of one entry with three inner hooks. That full stack got cut down to a single hook because the scripts moved and the config kept pointing at ghosts. Hooks that reference machine-specific paths belong in the untracked local file, where the blast radius of a stale path is one machine.

.claude/settings.json: what rides with the repo

A committed .claude/settings.json is for configuration a fresh clone needs before an agent on some other machine can behave the way mine does. The cleanest live example here is ~/code/podcast-sync/.claude/settings.json, which holds two keys and nothing else:

{
  "extraKnownMarketplaces": {
    "suede": {
      "source": { "source": "github", "repo": "JasonColapietro/suede-creator-skills" }
    }
  },
  "enabledPlugins": { "suede-skills@suede": true }
}

Compare this with the user tier, where marketplace registration and plugin enablement live in different files. At project level the two halves combine in one committed file, which is the point: clone the repo anywhere and the Suede skills marketplace resolves and its plugin enables with no per-machine setup.

My working memory records a 2026-07-21 sweep that claimed to put this file into every public repo of mine. A 2026-07-29 remote-tree check disproved that claim: podcast-sync carries .claude/settings.json on origin/main; strumly and suede-agent-studio do not. The sweep was incomplete, and the memory entry overstated it. The pattern is proven in one repository and remains a rollout standard for the others, not a completed estate-wide fact.

The fourth file, a repo’s own .claude/settings.local.json, takes what the committed project file must not: per-checkout permission grants, experiments, anything with an absolute path in it.

launch.json: dev servers as declared config

~/.claude/launch.json is not a settings file and plays no precedence game. It declares dev servers so the harness can start them by name instead of me pasting npm run dev incantations into prompts. Two top-level keys: version (the string "0.0.1") and configurations, an array holding 41 entries on this machine. The union of fields across all 41 is six names: name, runtimeExecutable, runtimeArgs, cwd, port, autoPort. A real entry:

{
  "version": "0.0.1",
  "configurations": [
    {
      "name": "suede-agent-studio",
      "runtimeExecutable": "npm",
      "runtimeArgs": ["run", "dev", "--", "-p", "3210"],
      "cwd": "/Users/jasoncolapietro/code/suede-agent-studio",
      "port": 3210
    }
  ]
}

Field discipline, learned across 41 entries. runtimeArgs is a flat array of separate string tokens: the npm double-dash separator and the port flag are individual elements ("--", "-p", "3210"), never one combined string. runtimeExecutable takes a bare command resolved on PATH (npm appears 29 times, python3 10 times, node once) or an absolute path to a project-local binary; one entry points at /Users/jasoncolapietro/code/strumly/node_modules/.bin/next. cwd is optional: 35 of 41 entries set it, and the 6 that skip it pass the directory inside runtimeArgs instead, the pattern being python3 -m http.server with --directory <absolute path>. autoPort: true appears on 3 entries, all with ports that could collide (4173, 4180, 3001); everywhere else I want the fixed port, because a server that moves ports breaks the URLs an agent has already written down.

name is the unique handle used to start the server, and the naming convention carries load: names encode repo plus task or worktree (suede-muse-seo-sweep, agent-studio-visual-baseline), because concurrent sessions in sibling worktrees each need their own server on their own port, and a name like dev works for one session and no more.

The companion convention is the port table. launch.json says how to start a server; the project CLAUDE.md carries a table mapping each app to its port and working directory, so a session knows before it starts anything that strumly’s dev server belongs on 3001 and suede-agent-studio’s on 3210, and that a worktree gets its own row rather than borrowing the main checkout’s port. One app, one port, written down where the agent reads it at session start. That single convention has done more to stop two agents from fighting over the same port than any amount of prompt discipline.

Putting ~/.claude under git

~/.claude is a git repository on branch main with remote https://github.com/JasonColapietro/claude-dotfiles.git. Its .gitignore is allowlist-style: a bare * ignores everything, then negations un-ignore /.gitignore, /CLAUDE.md, /launch.json, /settings.json, /package.json, /agents/ with /agents/**, and /hooks/ with /hooks/**. settings.local.json is not in the allowlist and stays untracked. Read that closely: the harness does not enforce the local/shared split; my gitignore does. The tier system tells the CLI which file wins, but which file leaves the machine is a decision git makes.

Two adjacent details round out the layout. ~/.claude/skills/ is its own independent repository (remote suede-personal-skills), and ~/.claude/commands/ is a repository with no remote configured; both are excluded from the parent dotfiles repo to avoid nested-repo gitlinks. File modes differ too: settings.json and settings.local.json sit at mode 600, launch.json at 644.

The tier logic, compressed to a placement rule:

Tier File What goes in it
Managed /Library/Application Support/ClaudeCode/managed-settings.json Fleet policy. Absent on a solo machine.
User ~/.claude/settings.json Preferences and broad allow rules that follow you to every repo. Tracked in dotfiles.
Project <repo>/.claude/settings.json Registration a fresh clone needs: marketplaces, plugin enablement. Committed.
Local .claude/settings.local.json Machine-wired hooks, per-checkout grants, absolute paths. Untracked.

If a value would break on another machine, it goes local. If a clone without it behaves differently, it goes project. If it is about you rather than any repo, it goes user. And if someone above you sets it, it is managed and you were never asked, which is the one tier a solo operator gets to skip.