<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: VincentChabran</title>
    <description>The latest articles on DEV Community by VincentChabran (@vincentchabran).</description>
    <link>https://dev.to/vincentchabran</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4018187%2F4eaa2608-d665-4c4b-ae95-f1a1889ca318.jpg</url>
      <title>DEV Community: VincentChabran</title>
      <link>https://dev.to/vincentchabran</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vincentchabran"/>
    <language>en</language>
    <item>
      <title>Why Claude Code keeps asking for permission — and how to fix your setup so it stops</title>
      <dc:creator>VincentChabran</dc:creator>
      <pubDate>Wed, 08 Jul 2026 17:34:21 +0000</pubDate>
      <link>https://dev.to/vincentchabran/why-claude-code-keeps-asking-for-permission-and-how-to-fix-your-setup-so-it-stops-3ho8</link>
      <guid>https://dev.to/vincentchabran/why-claude-code-keeps-asking-for-permission-and-how-to-fix-your-setup-so-it-stops-3ho8</guid>
      <description>&lt;p&gt;You set up Claude Code, you're in flow, and then: &lt;em&gt;"Allow Bash(git diff)?"&lt;/em&gt; You click allow. Thirty seconds later: &lt;em&gt;"Allow Bash(git log)?"&lt;/em&gt; Allow. Then the same slash command you already approved yesterday asks &lt;strong&gt;again&lt;/strong&gt;. At some point you're tempted to reach for &lt;code&gt;--dangerously-skip-permissions&lt;/code&gt; and be done with it.&lt;/p&gt;

&lt;p&gt;Don't. Permission fatigue isn't fixed by surrendering — it's fixed by understanding the three specific reasons Claude Code prompts you, and configuring each one away. That takes about ten minutes, and this article is the complete walkthrough: the permission model, a documented trap inside slash commands that keeps prompting &lt;em&gt;even when you've allowed everything&lt;/em&gt;, a copy-paste &lt;code&gt;settings.json&lt;/code&gt;, and the case where a hook is safer than a broader allow.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://dev.to/vincentchabran/the-claude-code-setup-i-install-in-every-repo-slash-commands-a-review-subagent-and-hooks-404j"&gt;a previous article&lt;/a&gt; I showed the slash commands, subagent, and hooks I install in every repo. This is the deep-dive on the part of that setup that generates the most questions.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Unofficial, community-made. Not affiliated with or endorsed by Anthropic.&lt;/strong&gt; "Claude" and "Claude Code" are trademarks of Anthropic. This is independent config &lt;em&gt;for&lt;/em&gt; Claude Code, not a product &lt;em&gt;of&lt;/em&gt; Anthropic.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Everything below was verified by actually running it on &lt;strong&gt;Claude Code v2.1.x (July 2026)&lt;/strong&gt;. The tool ships fast — if you're reading this much later, double-check the details against the current docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three reasons you get prompted
&lt;/h2&gt;

&lt;p&gt;Every permission prompt you see is one of these:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No rule covers the tool call.&lt;/strong&gt; The default answer for anything not explicitly allowed is "ask." Fix: an allowlist in &lt;code&gt;settings.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A slash command injects shell output, and nothing covers it&lt;/strong&gt; — neither the command's &lt;code&gt;allowed-tools&lt;/code&gt; frontmatter nor your settings. Fix: list the commands in the frontmatter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The command &lt;em&gt;is&lt;/em&gt; listed, but Claude Code can't statically verify it&lt;/strong&gt; — because it contains dynamic constructs like &lt;code&gt;$(...)&lt;/code&gt;. This is the sneaky one, and it prompts on &lt;em&gt;every&lt;/em&gt; invocation no matter what you've approved. Fix: rewrite the command (section below).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Let's take them in order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason 1: the allowlist you haven't written yet
&lt;/h2&gt;

&lt;p&gt;Claude Code reads permission rules from three settings files, most specific wins:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;~/.claude/settings.json&lt;/code&gt; — you, on every project.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.claude/settings.json&lt;/code&gt; — this project, committed and shared with your team.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.claude/settings.local.json&lt;/code&gt; — this project, just you (git-ignored automatically).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rules live under a &lt;code&gt;permissions&lt;/code&gt; key in three lists: &lt;code&gt;allow&lt;/code&gt;, &lt;code&gt;ask&lt;/code&gt;, and &lt;code&gt;deny&lt;/code&gt; — and &lt;strong&gt;deny always beats ask, which beats allow&lt;/strong&gt;. A rule is a tool name plus an optional specifier:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Bash(git diff:*)&lt;/code&gt; — any command starting with &lt;code&gt;git diff&lt;/code&gt; (the &lt;code&gt;:*&lt;/code&gt; makes it a prefix match; without it, only the exact command matches).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Read(./.env)&lt;/code&gt; / &lt;code&gt;Read(./secrets/**)&lt;/code&gt; — gitignore-style paths for file tools.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WebFetch(domain:docs.anthropic.com)&lt;/code&gt; — fetches scoped to a domain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two practical notes. First, when you click "always allow" on a prompt, Claude Code writes the rule to your &lt;strong&gt;local project settings&lt;/strong&gt; — handy, but it means your allowlist grows ad hoc and never transfers to the next repo. Writing it deliberately in &lt;code&gt;~/.claude/settings.json&lt;/code&gt; fixes the fatigue everywhere at once. Second, run &lt;code&gt;/permissions&lt;/code&gt; inside Claude Code any time to see exactly which rules are in effect and where each one comes from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason 2: slash commands have their own gate
&lt;/h2&gt;

&lt;p&gt;A custom slash command (a markdown file in &lt;code&gt;.claude/commands/&lt;/code&gt;) can inject live shell output into its prompt with &lt;code&gt;!`command`&lt;/code&gt;. That's the feature that makes commands like &lt;code&gt;/review&lt;/code&gt; work on your &lt;em&gt;actual&lt;/em&gt; diff. But those injected commands run the moment you invoke the slash command — so each one needs approval first. On v2.1.x, an injection is auto-approved if &lt;em&gt;any&lt;/em&gt; of three things covers it: the command's own &lt;code&gt;allowed-tools&lt;/code&gt; frontmatter, an &lt;code&gt;allow&lt;/code&gt; rule in your settings, or Claude Code's built-in pass for a few safe read-only commands (like &lt;code&gt;date&lt;/code&gt;). If none of the three covers it, you get prompted. The frontmatter is the one to reach for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Review&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;current&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;changes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;before&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;committing"&lt;/span&gt;
&lt;span class="na"&gt;allowed-tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bash(git status:*), Bash(git diff:*)&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="p"&gt;-&lt;/span&gt; Changed files: !&lt;span class="sb"&gt;`git status --short`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Full diff: !&lt;span class="sb"&gt;`git diff HEAD`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both injections are covered, so this runs silently — for everyone. Skip the frontmatter and the command may still &lt;em&gt;seem&lt;/em&gt; fine on your machine, if your own settings happen to allow &lt;code&gt;git status&lt;/code&gt; and &lt;code&gt;git diff&lt;/code&gt; — and then it prompts the teammate who cloned the repo without your allowlist. The frontmatter is what makes a slash command portable and self-sufficient: it carries its own permissions instead of borrowing yours. If a command of yours always asks, this is the first thing to check.&lt;/p&gt;

&lt;p&gt;So far, so documented. Now the trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason 3: the "too complex to verify" trap
&lt;/h2&gt;

&lt;p&gt;Here's a command that looks perfectly configured — and prompts on &lt;strong&gt;every invocation anyway&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Summarize what this branch changes compared to main&lt;/span&gt;
&lt;span class="na"&gt;allowed-tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bash(git log:*), Bash(git diff:*), Bash(git merge-base:*)&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gu"&gt;## Context&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Commits on this branch: !&lt;span class="sb"&gt;`git log --oneline $(git merge-base main HEAD)..HEAD`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Files touched: !&lt;span class="sb"&gt;`git diff --stat $(git merge-base main HEAD)...HEAD`&lt;/span&gt;

&lt;span class="gu"&gt;## Task&lt;/span&gt;

Summarize this branch for a teammate: what changed, why, and what looks risky.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every piece is listed in &lt;code&gt;allowed-tools&lt;/code&gt; — &lt;code&gt;git log&lt;/code&gt;, &lt;code&gt;git diff&lt;/code&gt;, &lt;code&gt;git merge-base&lt;/code&gt;. And yet: permission prompt, each time.&lt;/p&gt;

&lt;p&gt;The reason: to auto-approve an injected command, Claude Code has to &lt;strong&gt;statically prove&lt;/strong&gt; it matches an allowed pattern. &lt;code&gt;git diff --stat HEAD&lt;/code&gt; is a literal string — provable. &lt;code&gt;git diff --stat $(git merge-base main HEAD)...HEAD&lt;/code&gt; contains a command substitution whose output can't be known before running it — so the analyzer classifies the whole command as &lt;strong&gt;too complex to verify&lt;/strong&gt; and falls back to asking you. In my testing on v2.1.x this applies to &lt;code&gt;$(...)&lt;/code&gt;, backticks, and even innocent-looking git expansions like &lt;code&gt;@{u}&lt;/code&gt; (&lt;code&gt;!`git log @{u}..HEAD`&lt;/code&gt; prompts too, despite containing no substitution at all).&lt;/p&gt;

&lt;p&gt;This is a security feature, not a bug — a &lt;code&gt;$(...)&lt;/code&gt; buried inside a pre-approved-looking command is exactly how a prompt-injection attack would smuggle something past your allowlist. So don't fight the analyzer. Restructure instead:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep the injections static. Move the dynamic logic into the task, where Claude resolves it at runtime.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Summarize what this branch changes compared to main&lt;/span&gt;
&lt;span class="na"&gt;allowed-tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bash(git branch:*), Bash(git log:*), Bash(git diff:*), Bash(git merge-base:*)&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gu"&gt;## Context&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Current branch: !&lt;span class="sb"&gt;`git branch --show-current`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Recent commits (fallback view): !&lt;span class="sb"&gt;`git log --oneline -15`&lt;/span&gt;

&lt;span class="gu"&gt;## Task&lt;/span&gt;
&lt;span class="p"&gt;
1.&lt;/span&gt; Find where this branch forked: run &lt;span class="sb"&gt;`git merge-base main HEAD`&lt;/span&gt;.
&lt;span class="p"&gt;2.&lt;/span&gt; Read the exact range using the real SHA from step 1: &lt;span class="sb"&gt;`git log &amp;lt;base&amp;gt;..HEAD --oneline`&lt;/span&gt;
   and &lt;span class="sb"&gt;`git diff --stat &amp;lt;base&amp;gt;...HEAD`&lt;/span&gt; (substitute the SHA for &lt;span class="sb"&gt;`&amp;lt;base&amp;gt;`&lt;/span&gt; — don't nest
   &lt;span class="sb"&gt;`$(git merge-base ...)`&lt;/span&gt; inside the command, or it will trigger a permission prompt).
&lt;span class="p"&gt;3.&lt;/span&gt; Summarize this branch for a teammate: what changed, why, and what looks risky.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same capability, zero prompts. The injections are now literal strings the analyzer can match. The fork-point lookup happens as a normal tool call during the task: Claude runs &lt;code&gt;git merge-base main HEAD&lt;/code&gt; (covered by &lt;code&gt;Bash(git merge-base:*)&lt;/code&gt;), reads the SHA, then runs &lt;code&gt;git log a1b2c3d..HEAD&lt;/code&gt; (covered by &lt;code&gt;Bash(git log:*)&lt;/code&gt;). Note the warning inside the prompt itself: the same rule applies at runtime, so telling Claude to substitute the literal SHA — instead of nesting &lt;code&gt;$(...)&lt;/code&gt; — is what keeps step 2 prompt-free.&lt;/p&gt;

&lt;p&gt;Rule of thumb: &lt;strong&gt;&lt;code&gt;!`...`&lt;/code&gt; injections should be commands you could type from muscle memory — fixed strings, no substitutions, no expansions.&lt;/strong&gt; Anything conditional belongs in the task instructions.&lt;/p&gt;

&lt;h2&gt;
  
  
  A clean, copyable &lt;code&gt;settings.json&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Here's a complete baseline that removes the everyday prompts without opening anything scary. It also includes an auto-format hook (from the free kit linked at the end) so you can see how &lt;code&gt;permissions&lt;/code&gt; and &lt;code&gt;hooks&lt;/code&gt; coexist in one file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git status:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git diff:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git log:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git branch:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git show:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git add:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git commit:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git merge-base:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(ls:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(npm test:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(npm run lint:*)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Read(./.env)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Read(./.env.*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Read(./secrets/**)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git push --force:*)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PostToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Edit|Write|MultiEdit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"f=$(jq -r '.tool_input.file_path // empty'); [ -z &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$f&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; ] &amp;amp;&amp;amp; exit 0; case &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$f&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; in *.js|*.jsx|*.ts|*.tsx|*.json|*.css|*.scss|*.html|*.md|*.yml|*.yaml) command -v prettier &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;amp;&amp;amp; prettier --write &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$f&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 ;; *.py) command -v black &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;amp;&amp;amp; black -q &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$f&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 ;; *.go) command -v gofmt &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;amp;&amp;amp; gofmt -w &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$f&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 ;; *.rs) command -v rustfmt &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;amp;&amp;amp; rustfmt &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$f&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 ;; esac; exit 0"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why these choices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The allows are read-mostly.&lt;/strong&gt; Status, diffs, logs, tests, lint — the commands Claude runs constantly and that can't hurt you. &lt;code&gt;git add&lt;/code&gt; and &lt;code&gt;git commit&lt;/code&gt; are there because committing is the point; there's deliberately &lt;strong&gt;no &lt;code&gt;git push&lt;/code&gt;&lt;/strong&gt; in the allowlist, so anything leaving your machine still asks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The denies protect secrets from being &lt;em&gt;read&lt;/em&gt;,&lt;/strong&gt; not just edited. Claude never needs the contents of &lt;code&gt;.env&lt;/code&gt; to help you code.&lt;/li&gt;
&lt;li&gt;Put it in &lt;code&gt;~/.claude/settings.json&lt;/code&gt; for yourself everywhere, or &lt;code&gt;.claude/settings.json&lt;/code&gt; to share the policy with your team. Restart Claude Code after saving.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When a hook beats a broader allow
&lt;/h2&gt;

&lt;p&gt;Notice the deny rule &lt;code&gt;Bash(git push --force:*)&lt;/code&gt;. It works — but it's a &lt;strong&gt;string prefix&lt;/strong&gt;. &lt;code&gt;git push -f&lt;/code&gt; sails right past it. Permission rules are a convenience layer; when you want a &lt;em&gt;guarantee&lt;/em&gt;, use a &lt;code&gt;PreToolUse&lt;/code&gt; hook, which runs before the tool executes and can block it regardless of what the permission rules would have allowed.&lt;/p&gt;

&lt;p&gt;Say you've allowed file edits broadly because the edit prompts were driving you mad. This hook makes sure that no matter what, credential files stay untouched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Edit|Write|MultiEdit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"f=$(jq -r '.tool_input.file_path // empty'); case &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$f&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; in *.env|*.env.*|*.pem|*.key) echo &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;Blocked: $f looks like a credentials file. Edit it yourself.&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &amp;gt;&amp;amp;2; exit 2 ;; esac; exit 0"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hook reads the tool's JSON payload on stdin (&lt;code&gt;jq&lt;/code&gt; pulls out the file path — &lt;code&gt;brew install jq&lt;/code&gt; / &lt;code&gt;sudo apt install jq&lt;/code&gt;), and &lt;strong&gt;exit code 2 blocks the action&lt;/strong&gt; and hands your stderr message back to Claude as the reason, so it adapts instead of retrying blindly. Exit 0 stays out of the way.&lt;/p&gt;

&lt;p&gt;The mental model: &lt;strong&gt;allow rules decide what runs without asking; hooks decide what never runs at all.&lt;/strong&gt; Broad allow + narrow hard-block is a much nicer place to work than narrow allows + constant prompts — you get the flow back without giving up the guarantees you actually care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 10-minute fix, recapped
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Write a deliberate allowlist of read-mostly commands in &lt;code&gt;~/.claude/settings.json&lt;/code&gt; — stop accumulating one-off "always allow" clicks.&lt;/li&gt;
&lt;li&gt;Give every custom slash command an &lt;code&gt;allowed-tools&lt;/code&gt; frontmatter covering its &lt;code&gt;!`...`&lt;/code&gt; injections.&lt;/li&gt;
&lt;li&gt;Keep those injections &lt;strong&gt;static&lt;/strong&gt;. Move anything with &lt;code&gt;$(...)&lt;/code&gt;, backticks, or &lt;code&gt;@{u}&lt;/code&gt;-style expansions into the task instructions.&lt;/li&gt;
&lt;li&gt;Guard the truly untouchable (secrets, force-pushes) with a &lt;code&gt;PreToolUse&lt;/code&gt; hook exiting 2, not with an ever-longer prompt-clicking habit.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Get the files
&lt;/h2&gt;

&lt;p&gt;The slash commands, the subagent, and the auto-format hook from this series — with the permission lessons above already applied — live in a free, MIT-licensed repo you can copy straight into &lt;code&gt;.claude/&lt;/code&gt; (the PreToolUse credentials guard isn't bundled with the kit — copy it straight from this article):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;→ &lt;a href="https://github.com/VincentChabran/claude-code-starter-kit" rel="noopener noreferrer"&gt;Claude Code Starter Kit on GitHub&lt;/a&gt;&lt;/strong&gt; (MIT — use it, change it, ship it)&lt;/p&gt;

&lt;p&gt;And if you want the extended set (12 commands, 6 subagents, 5 hooks, a git-aware status line, and a step-by-step guide), that's the paid &lt;a href="https://vincentdu2a.gumroad.com/l/bqndfi" rel="noopener noreferrer"&gt;Power Pack&lt;/a&gt; — but everything in this article works with the free repo alone.&lt;/p&gt;

&lt;p&gt;Happy building.&lt;/p&gt;

</description>
      <category>claude</category>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The Claude Code setup I install in every repo: slash commands, a review subagent, and hooks</title>
      <dc:creator>VincentChabran</dc:creator>
      <pubDate>Tue, 07 Jul 2026 18:04:30 +0000</pubDate>
      <link>https://dev.to/vincentchabran/the-claude-code-setup-i-install-in-every-repo-slash-commands-a-review-subagent-and-hooks-404j</link>
      <guid>https://dev.to/vincentchabran/the-claude-code-setup-i-install-in-every-repo-slash-commands-a-review-subagent-and-hooks-404j</guid>
      <description>&lt;p&gt;Claude Code is powerful the moment you install it. But the thing that makes it feel like a real teammate isn't the model — it's the configuration you build up around it: custom slash commands, specialist subagents, and hooks that run at the right moments.&lt;/p&gt;

&lt;p&gt;The problem is that a fresh install is a blank &lt;code&gt;.claude/&lt;/code&gt; folder. You get all that value only after you read the docs and write the config yourself.&lt;/p&gt;

&lt;p&gt;So here's the config I now copy into every repo. Everything below is a plain file you can paste, read, and change. I'll show you real commands, a subagent, and a hook — with the actual code and why each part is there — so you can rebuild this yourself in an afternoon.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Unofficial, community-made. Not affiliated with or endorsed by Anthropic.&lt;/strong&gt; "Claude" and "Claude Code" are trademarks of Anthropic. This is independent config &lt;em&gt;for&lt;/em&gt; Claude Code, not a product &lt;em&gt;of&lt;/em&gt; Anthropic.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where this config lives
&lt;/h2&gt;

&lt;p&gt;Two locations, and you can use both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;~/.claude/&lt;/code&gt; in your home directory — applies to &lt;strong&gt;every&lt;/strong&gt; project you open.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.claude/&lt;/code&gt; in a repo root — applies to &lt;strong&gt;that&lt;/strong&gt; project, and you can commit it to share with your team.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a project file and a global file collide, the project one wins. I keep general-purpose commands and subagents in &lt;code&gt;~/.claude/&lt;/code&gt;, and project-specific rules in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A slash command is just a markdown file
&lt;/h2&gt;

&lt;p&gt;The file name is the command name: &lt;code&gt;.claude/commands/review.md&lt;/code&gt; becomes &lt;code&gt;/review&lt;/code&gt;. The body is the prompt Claude runs. Here's a full, useful one — a pre-commit code review:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Review&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;current&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;changes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;bugs,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;risks&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;quality&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;before&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;you&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;commit"&lt;/span&gt;
&lt;span class="na"&gt;argument-hint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;optional path or focus&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;allowed-tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bash(git status:*), Bash(git diff:*), Bash(git log:*)&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gu"&gt;## Context&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Changed files: !&lt;span class="sb"&gt;`git status --short`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Full diff: !&lt;span class="sb"&gt;`git diff HEAD`&lt;/span&gt;

&lt;span class="gu"&gt;## Task&lt;/span&gt;

Act as a careful senior reviewer of the diff above (focus on "$ARGUMENTS" if provided).

Report findings grouped by severity — &lt;span class="gs"&gt;**Blocking**&lt;/span&gt;, &lt;span class="gs"&gt;**Should-fix**&lt;/span&gt;, &lt;span class="gs"&gt;**Nit**&lt;/span&gt; — and for
each give the &lt;span class="sb"&gt;`file:line`&lt;/span&gt;, what is wrong, and a concrete fix. Look specifically for:
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="gs"&gt;**Correctness**&lt;/span&gt;: off-by-one, null/undefined, wrong operators, missed edge cases.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Security**&lt;/span&gt;: injection, unsafe input handling, secrets committed, missing auth checks.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Concurrency &amp;amp; resources**&lt;/span&gt;: races, leaks, unclosed handles, missing timeouts.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Contracts**&lt;/span&gt;: API changes that break callers or aren't reflected in tests/docs.

If the diff is clean, say so plainly instead of inventing problems. End with a one-line
verdict: safe to commit, or not yet.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things are doing the work here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;!&lt;/code&gt; runs a shell command and injects its output into the prompt.&lt;/strong&gt; &lt;code&gt;!`git diff HEAD`&lt;/code&gt; means Claude reviews your &lt;em&gt;actual&lt;/em&gt; current diff, not a description of it. This is the single most useful trick in the whole system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;allowed-tools&lt;/code&gt;&lt;/strong&gt; pre-approves exactly those git commands, so &lt;code&gt;/review&lt;/code&gt; doesn't stop to ask permission every time it reads the diff. (If Claude Code still prompts you all the time, I wrote a &lt;a href="https://dev.to/vincentchabran/why-claude-code-keeps-asking-for-permission-and-how-to-fix-your-setup-so-it-stops-3ho8"&gt;deep-dive on the permission model and how to fix it&lt;/a&gt; — including a trap that makes a command prompt on every run even when everything looks allowed.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;$ARGUMENTS&lt;/code&gt;&lt;/strong&gt; is whatever you type after the command. &lt;code&gt;/review the auth changes&lt;/code&gt; focuses the review; &lt;code&gt;/review&lt;/code&gt; alone reviews everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now &lt;code&gt;/review&lt;/code&gt; gives you a consistent, senior-style pass over your changes every time, instead of a fuzzy "can you check this?"&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Bake your repo's conventions into &lt;code&gt;/commit&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Same idea, pointed at commits. The trick here is feeding Claude your recent history so the new message &lt;em&gt;matches the style you already use&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Create a well-formed git commit from the current changes&lt;/span&gt;
&lt;span class="na"&gt;argument-hint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;optional message or intent&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;allowed-tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bash(git status:*), Bash(git diff:*), Bash(git add:*), Bash(git commit:*), Bash(git log:*)&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gu"&gt;## Context&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Current status: !&lt;span class="sb"&gt;`git status --short`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Staged diff: !&lt;span class="sb"&gt;`git diff --cached`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Recent commits (match this style): !&lt;span class="sb"&gt;`git log --oneline -10`&lt;/span&gt;

&lt;span class="gu"&gt;## Task&lt;/span&gt;

Create one focused git commit for the changes above.
&lt;span class="p"&gt;
1.&lt;/span&gt; If nothing is staged, stage the files that clearly belong together — never unrelated
   files, build artifacts, or anything that looks like a secret.
&lt;span class="p"&gt;2.&lt;/span&gt; Write a Conventional Commits message (&lt;span class="sb"&gt;`type(scope): summary`&lt;/span&gt;), imperative mood,
   ≤ 72 chars, matching the tone of the recent commits shown above.
&lt;span class="p"&gt;3.&lt;/span&gt; Commit with &lt;span class="sb"&gt;`git commit`&lt;/span&gt;. Do &lt;span class="gs"&gt;**not**&lt;/span&gt; push. No co-author trailers or tool ads.

If the staged changes cover several unrelated concerns, say so and propose splitting them.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;!`git log --oneline -10`&lt;/code&gt; injects your last ten commits, Claude writes in &lt;em&gt;your&lt;/em&gt; house style — not a generic &lt;code&gt;chore: update files&lt;/code&gt;. And "do not push" keeps a human in the loop for the one step that leaves your machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Subagents: specialists with their own context window
&lt;/h2&gt;

&lt;p&gt;A subagent is a markdown file in &lt;code&gt;.claude/agents/&lt;/code&gt; with a bit of frontmatter and a system prompt. Claude can hand a task to it automatically, or you can call it by name. The win: the subagent works in a &lt;strong&gt;separate context window&lt;/strong&gt;, so a long review or audit doesn't clutter your main conversation.&lt;/p&gt;

&lt;p&gt;Here's the &lt;code&gt;code-reviewer&lt;/code&gt; I use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;code-reviewer&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Expert code reviewer. Use PROACTIVELY right after writing or changing code,&lt;/span&gt;
  &lt;span class="s"&gt;and before committing, to catch correctness, security and design issues.&lt;/span&gt;
&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read, Grep, Glob, Bash&lt;/span&gt;
&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inherit&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

You are a senior software engineer doing a focused, high-signal code review.
Your job is to catch what matters and say it plainly.

&lt;span class="gu"&gt;## What you look for, in priority order&lt;/span&gt;
&lt;span class="p"&gt;
1.&lt;/span&gt; &lt;span class="gs"&gt;**Correctness**&lt;/span&gt; — logic errors, off-by-one, null/undefined, unhandled error paths.
&lt;span class="p"&gt;2.&lt;/span&gt; &lt;span class="gs"&gt;**Security**&lt;/span&gt; — untrusted input reaching queries/commands/filesystem, injection,
   missing auth, secrets committed.
&lt;span class="p"&gt;3.&lt;/span&gt; &lt;span class="gs"&gt;**Contracts**&lt;/span&gt; — API changes that break callers or aren't reflected in tests/docs.
&lt;span class="p"&gt;4.&lt;/span&gt; &lt;span class="gs"&gt;**Resource &amp;amp; concurrency**&lt;/span&gt; — leaks, races, missing timeouts.
&lt;span class="p"&gt;5.&lt;/span&gt; &lt;span class="gs"&gt;**Design &amp;amp; readability**&lt;/span&gt; — duplication, dead code, unclear names. Lower priority.

&lt;span class="gu"&gt;## Rules&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Do not invent problems to seem thorough. If the code is solid, say so and stop.
&lt;span class="p"&gt;-&lt;/span&gt; End with a one-line verdict: &lt;span class="gs"&gt;**safe to merge**&lt;/span&gt;, &lt;span class="gs"&gt;**safe after should-fixes**&lt;/span&gt;, or &lt;span class="gs"&gt;**not yet**&lt;/span&gt;.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;description&lt;/code&gt; is how Claude decides when to delegate.&lt;/strong&gt; Writing it as "use PROACTIVELY right after writing code" is what makes the subagent trigger on its own. Vague descriptions never fire.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;tools&lt;/code&gt; scopes what it can touch.&lt;/strong&gt; A reviewer only needs to read and search (&lt;code&gt;Read, Grep, Glob, Bash&lt;/code&gt;) — it shouldn't be editing your files. Omit &lt;code&gt;tools&lt;/code&gt; entirely and it inherits everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After this is in place, Claude tends to call the reviewer itself after a change. You can always force it: &lt;em&gt;"have the code-reviewer look at the auth module."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Hooks: automate what you'd otherwise forget
&lt;/h2&gt;

&lt;p&gt;Hooks run &lt;strong&gt;your&lt;/strong&gt; shell commands at defined moments in a session. They live in &lt;code&gt;settings.json&lt;/code&gt;. The one I'd never work without formats every file the instant Claude edits it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PostToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Edit|Write|MultiEdit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"f=$(jq -r '.tool_input.file_path // empty'); [ -z &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$f&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; ] &amp;amp;&amp;amp; exit 0; case &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$f&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; in *.js|*.jsx|*.ts|*.tsx|*.json|*.css|*.scss|*.html|*.md|*.yml|*.yaml) command -v prettier &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;amp;&amp;amp; prettier --write &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$f&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 ;; *.py) command -v black &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;amp;&amp;amp; black -q &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$f&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 ;; *.go) command -v gofmt &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;amp;&amp;amp; gofmt -w &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$f&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 ;; *.rs) command -v rustfmt &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;amp;&amp;amp; rustfmt &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$f&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 ;; esac; exit 0"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How to read that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PostToolUse&lt;/code&gt; + &lt;code&gt;matcher: "Edit|Write|MultiEdit"&lt;/code&gt;&lt;/strong&gt; means "run after Claude edits or writes a file." The matcher is a regex on the tool name.&lt;/li&gt;
&lt;li&gt;Claude Code sends the hook a &lt;strong&gt;JSON payload on stdin&lt;/strong&gt;. Here &lt;code&gt;jq&lt;/code&gt; pulls out &lt;code&gt;.tool_input.file_path&lt;/code&gt; — the file that was just touched.&lt;/li&gt;
&lt;li&gt;It picks a formatter by extension and &lt;strong&gt;no-ops silently if that formatter isn't installed&lt;/strong&gt;, so it never breaks a session on a machine that doesn't have &lt;code&gt;black&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exit codes are the contract:&lt;/strong&gt; &lt;code&gt;0&lt;/code&gt; = fine, continue. &lt;code&gt;2&lt;/code&gt; = block the action and hand your message back to Claude as the reason. (A &lt;code&gt;PreToolUse&lt;/code&gt; hook that exits &lt;code&gt;2&lt;/code&gt; on &lt;code&gt;.env&lt;/code&gt; paths is a nice guard against Claude ever editing your secrets.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;jq&lt;/code&gt; is the only dependency (&lt;code&gt;brew install jq&lt;/code&gt; / &lt;code&gt;sudo apt install jq&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Install it in two minutes
&lt;/h2&gt;

&lt;p&gt;Grab the files, drop them in, restart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/.claude/commands ~/.claude/agents
&lt;span class="nb"&gt;cp &lt;/span&gt;commands/&lt;span class="k"&gt;*&lt;/span&gt;.md ~/.claude/commands/
&lt;span class="nb"&gt;cp &lt;/span&gt;agents/&lt;span class="k"&gt;*&lt;/span&gt;.md   ~/.claude/agents/
&lt;span class="c"&gt;# then merge the hooks block into ~/.claude/settings.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart Claude Code, type &lt;code&gt;/&lt;/code&gt;, and the commands are there. Run &lt;code&gt;/agents&lt;/code&gt; to see the subagent, &lt;code&gt;/hooks&lt;/code&gt; to confirm the hook loaded.&lt;/p&gt;

&lt;h2&gt;
  
  
  One habit that makes all of this better
&lt;/h2&gt;

&lt;p&gt;Notice a theme in the prompts above: they're written to be &lt;strong&gt;honest&lt;/strong&gt;. The review command is told not to invent problems. A companion &lt;code&gt;/pr&lt;/code&gt; command writes "not yet tested" instead of pretending. That matters — the fastest way to lose trust in an AI teammate is catching it claim a test passed when it never ran. Bake that into your prompts and the output stays trustworthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the files
&lt;/h2&gt;

&lt;p&gt;I put the four commands, the &lt;code&gt;code-reviewer&lt;/code&gt; subagent, the auto-format hook, and a &lt;code&gt;CLAUDE.md&lt;/code&gt; template in a free, MIT-licensed repo so you can copy them straight in:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;→ &lt;a href="https://github.com/VincentChabran/claude-code-starter-kit" rel="noopener noreferrer"&gt;Claude Code Starter Kit on GitHub&lt;/a&gt;&lt;/strong&gt; (MIT — use it, change it, ship it)&lt;/p&gt;

&lt;p&gt;If you end up wanting the full set — 12 commands, 6 subagents, 5 hooks, a git-aware status line, and a full CLAUDE.md template — there's a paid &lt;a href="https://vincentdu2a.gumroad.com/l/bqndfi" rel="noopener noreferrer"&gt;Power Pack&lt;/a&gt; that bundles it with a step-by-step guide. But the repo above is enough to get real value today.&lt;/p&gt;

&lt;p&gt;Happy building.&lt;/p&gt;

</description>
      <category>claude</category>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
    </item>
    <item>
      <title>One Block to Force a Default AWS Region Across Every Terragrunt Module</title>
      <dc:creator>VincentChabran</dc:creator>
      <pubDate>Mon, 06 Jul 2026 17:32:57 +0000</pubDate>
      <link>https://dev.to/vincentchabran/one-block-to-force-a-default-aws-region-across-every-terragrunt-module-51ol</link>
      <guid>https://dev.to/vincentchabran/one-block-to-force-a-default-aws-region-across-every-terragrunt-module-51ol</guid>
      <description>&lt;p&gt;A quiet failure mode in multi-module Terragrunt setups: someone copies an existing module to bootstrap a new one, forgets to check the region in the provider config, and a resource lands in &lt;code&gt;us-east-1&lt;/code&gt; instead of &lt;code&gt;eu-west-1&lt;/code&gt; — three weeks later, someone's debugging why a Lambda can't reach a VPC that "should" be right there.&lt;/p&gt;

&lt;p&gt;This isn't a Terraform bug. It's a consequence of region being just another string that every module has to get right, independently, forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default pattern (and why it's fragile)
&lt;/h2&gt;

&lt;p&gt;Most Terragrunt setups declare region per-project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# projects/some-service/project.hcl&lt;/span&gt;
&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;aws_region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eu-west-1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the provider generation block in &lt;code&gt;root.hcl&lt;/code&gt; reads it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# root.hcl&lt;/span&gt;
&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;project&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;read_terragrunt_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;find_in_parent_folders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"project.hcl"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;generate&lt;/span&gt; &lt;span class="s2"&gt;"provider"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"provider.tf"&lt;/span&gt;
  &lt;span class="nx"&gt;if_exists&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"overwrite"&lt;/span&gt;
  &lt;span class="nx"&gt;contents&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
provider "aws" {
  region = "${local.project.locals.aws_region}"
}
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing wrong with this in isolation. The problem shows up at scale: with 20+ modules, &lt;code&gt;aws_region&lt;/code&gt; is set 20+ times, by hand, by whoever created each module — usually by copy-pasting an existing &lt;code&gt;project.hcl&lt;/code&gt; and hoping they remembered to check every field. New modules silently inherit whatever the copy-paste source happened to have, correct or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern: a default that wins unless explicitly overridden
&lt;/h2&gt;

&lt;p&gt;Flip the default. &lt;code&gt;root.hcl&lt;/code&gt; computes the region with a fallback, so a project only needs to declare &lt;code&gt;aws_region&lt;/code&gt; when it's an actual, deliberate exception:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# root.hcl&lt;/span&gt;
&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;project&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;read_terragrunt_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;find_in_parent_folders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"project.hcl"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

  &lt;span class="c1"&gt;# Falls back to the org-wide default if the project doesn't override it.&lt;/span&gt;
  &lt;span class="nx"&gt;aws_region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;try&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"eu-west-1"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;generate&lt;/span&gt; &lt;span class="s2"&gt;"provider"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"provider.tf"&lt;/span&gt;
  &lt;span class="nx"&gt;if_exists&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"overwrite"&lt;/span&gt;
  &lt;span class="nx"&gt;contents&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
provider "aws" {
  region = "${local.aws_region}"
}
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a brand-new &lt;code&gt;project.hcl&lt;/code&gt; can be &lt;em&gt;empty&lt;/em&gt; on the region question and still deploy to the right place by default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# projects/brand-new-service/project.hcl&lt;/span&gt;
&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;legacy_prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"brand-new-service"&lt;/span&gt;
  &lt;span class="c1"&gt;# no aws_region — inherits eu-west-1 from root.hcl automatically&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An exception is now a &lt;em&gt;visible, deliberate&lt;/em&gt; line, not a silent inheritance from whatever was copy-pasted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# projects/legacy-email-sender/project.hcl&lt;/span&gt;
&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;legacy_prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"legacy-email-sender"&lt;/span&gt;
  &lt;span class="nx"&gt;aws_region&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eu-north-1"&lt;/span&gt; &lt;span class="c1"&gt;# locked to this region — legacy third-party integration, see runbook §3&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference is where the burden of proof sits. Before: every module has to actively get the region right. After: every module gets it right by doing nothing, and getting it "wrong" (i.e., different from the default) requires writing a line down and, ideally, a comment saying why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this generalizes
&lt;/h2&gt;

&lt;p&gt;Region is the clearest example because a wrong region is easy to notice, but the same shape applies to any setting that should be consistent across a growing set of modules and currently isn't enforced by anything except habit: default tags, default VPC, default log retention. Anywhere you find yourself saying "just copy an existing module's config and change what's different," that's a signal the &lt;em&gt;common&lt;/em&gt; part belongs in the root config with a &lt;code&gt;try()&lt;/code&gt; fallback, and only the actual differences belong in each project's own file.&lt;/p&gt;

&lt;p&gt;It costs one extra local in &lt;code&gt;root.hcl&lt;/code&gt;. It saves the debugging session three weeks from now.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>terraform</category>
    </item>
    <item>
      <title>Renaming Terragrunt Projects Without Migrating State</title>
      <dc:creator>VincentChabran</dc:creator>
      <pubDate>Mon, 06 Jul 2026 17:31:33 +0000</pubDate>
      <link>https://dev.to/vincentchabran/renaming-terragrunt-projects-without-migrating-state-52jg</link>
      <guid>https://dev.to/vincentchabran/renaming-terragrunt-projects-without-migrating-state-52jg</guid>
      <description>&lt;p&gt;If you've run Terragrunt across more than a handful of modules for a while, you've hit this: you want to reorganize your repo — rename a folder, group projects differently, fix a naming mistake from six months ago — and Terragrunt's default behavior punishes you for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;By default, Terragrunt derives your remote state key from the file path of the module. Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# root.hcl&lt;/span&gt;
&lt;span class="nx"&gt;remote_state&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt;
  &lt;span class="nx"&gt;generate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"backend.tf"&lt;/span&gt;
    &lt;span class="nx"&gt;if_exists&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"overwrite"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"your-terraform-state-bucket"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${path_relative_to_include()}/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eu-west-1"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;path_relative_to_include()&lt;/code&gt; is convenient until the day you move &lt;code&gt;projects/old-name/&lt;/code&gt; to &lt;code&gt;projects/new-name/&lt;/code&gt;. The key changes, Terraform can no longer find the state for those resources, and you're one &lt;code&gt;terragrunt apply&lt;/code&gt; away from either a pile of "already exists" errors or, worse, Terraform trying to recreate live infrastructure because it thinks it's gone.&lt;/p&gt;

&lt;p&gt;The usual fixes are painful: &lt;code&gt;terraform state mv&lt;/code&gt; per resource, or a full &lt;code&gt;terraform import&lt;/code&gt; pass. Both work, both are slow, both are one typo away from downtime on something that's just supposed to be a folder rename.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern: decouple the state key from the file path
&lt;/h2&gt;

&lt;p&gt;Give every project a &lt;code&gt;project.hcl&lt;/code&gt; with a &lt;code&gt;legacy_prefix&lt;/code&gt; — the state key it should keep, independent of wherever the folder happens to live today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# projects/new-name/project.hcl&lt;/span&gt;
&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;legacy_prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"old-name"&lt;/span&gt;   &lt;span class="c1"&gt;# the prefix this project's state has always used&lt;/span&gt;
  &lt;span class="nx"&gt;aws_region&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eu-west-1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then have &lt;code&gt;root.hcl&lt;/code&gt; build the state key from that prefix instead of the live path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# root.hcl&lt;/span&gt;
&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;project&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;read_terragrunt_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;find_in_parent_folders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"project.hcl"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;remote_state&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt;
  &lt;span class="nx"&gt;generate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"backend.tf"&lt;/span&gt;
    &lt;span class="nx"&gt;if_exists&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"overwrite"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"your-terraform-state-bucket"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${local.project.locals.legacy_prefix}/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eu-west-1"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the folder path is free to change. Rename it, move it, restructure the whole repo — the state key is pinned to &lt;code&gt;legacy_prefix&lt;/code&gt;, not to &lt;code&gt;path_relative_to_include()&lt;/code&gt;. A new project just sets &lt;code&gt;legacy_prefix&lt;/code&gt; to whatever it wants (usually matching its own path, since it has no history to preserve); an existing project keeps its old prefix forever, regardless of where the folder lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The proof, not just the theory
&lt;/h2&gt;

&lt;p&gt;Don't take "it should work" on faith. Before you consider a reorganization safe, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terragrunt plan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;on every module you touched, and the only acceptable output is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Plan: 0 to add, 0 to change, 0 to destroy.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything else means the key computation didn't land on the same value as before, and you stop right there. This one-line proof requirement is what makes the whole pattern trustworthy enough to use on live infrastructure: you're not hoping the refactor is safe, you're verifying it before it touches anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this is worth it
&lt;/h2&gt;

&lt;p&gt;This pattern earns its keep the moment you have more than a few Terragrunt modules and expect to reorganize them more than once — a new team convention, a naming scheme that didn't survive contact with reality, projects that outgrew their original folder structure. If you have three modules that will never move, plain &lt;code&gt;path_relative_to_include()&lt;/code&gt; is simpler and you don't need this.&lt;/p&gt;

&lt;p&gt;The core idea generalizes beyond Terragrunt: whenever a tool derives a stable identifier (a state key, a resource ID, a cache key) from something that isn't actually stable (a file path, a display name), that's worth decoupling explicitly — before the first painful migration forces you to do it under pressure.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>terraform</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
