iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🧠

Standardizing AI Agent Instructions and Permission Management with chezmoi and Hooks

に公開
3

Introduction

Are you using coding agents?

I use Claude Code and OpenAI Codex, and occasionally OpenCode, but these coding agents all have different formats for:

  • Custom instruction files (AGENTS.md, CLAUDE.md)
  • Permission management (e.g., allowing Bash commands)
  • Skills
  • Hooks

Furthermore, I use both Windows and Mac, but these coding agents don't provide remote setting synchronization.

Therefore, I tackled this problem using a tool called chezmoi, which can manage dotfiles (like .zshrc), and while I was at it, I also approached managing other dotfiles collectively via GitHub.

Initial Setup (chezmoi explanation)

Initial Setup (chezmoi explanation)

Installing chezmoi

If you have Homebrew installed on macOS, you can install it with the following command:

brew install chezmoi

For Windows, you can install it with the following command:

winget install twpayne.chezmoi

For Linux, you can install it with the following command:

sh -c "$(curl -fsLS get.chezmoi.io)"

Initialization

By running the following command, you can create a chezmoi working directory. It should be created at ~/.local/share/chezmoi, and a Git repository should also be created automatically.

chezmoi init

You can move to the working directory by running the following command:

chezmoi cd

From here, you can add existing dotfiles. For example, if you want to add ~/.claude/settings.json:

chezmoi add ~/.claude/settings.json

Doing this should make dot_claude/settings.json appear in the working directory. (Depending on the environment, it might have private_ prepended, but the behavior remains the same.)

After editing the file here, running:

chezmoi apply

will make the local ~/.claude/settings.json have the same content as dot_claude/settings.json.

Importing

To import dotfiles from a Git repository already created on another PC:

chezmoi init https://URL-to-Git-repository

This will pull the data into ~/.local/share/chezmoi.
Simply running chezmoi apply will then reflect the dotfiles.

Templates

Adding .tmpl to the end of a filename allows you to control that file using Go Template. For example:

dot_gitconfig.tmpl
[gpg]
{{- if eq .chezmoi.os "windows" }}
	program = "C:\\Program Files\\GnuPG\\bin\\gpg.exe"
{{- else if eq .chezmoi.os "darwin" }}
	program = /opt/homebrew/bin/gpg
{{- else }}
	program = /usr/bin/gpg
{{- end }}

With this setup:

  • Windows: C:\Program Files\GnuPG\bin\gpg.exe
  • macOS: /opt/homebrew/bin/gpg
  • Others: /usr/bin/gpg

will be automatically expanded when you run chezmoi apply.

You can check the available data for conditions using chezmoi data.

.chezmoiignore

Creating a .chezmoiignore file allows you to exclude specific files or directories from chezmoi management. For example:

.chezmoiignore
.github
justfile

Writing this excludes the justfile and the .github directory from management.

Also, this file is processed as if .tmpl is automatically appended, so you can change the excluded files per OS like this:

.chezmoiignore
# Exclude library files
{{- if ne .chezmoi.os "darwin" }}
Library
{{- end }}

Executing Scripts

Placing .sh or .ps1 files starting with run_ in .chezmoiscripts/ or the project root allows you to include scripts that run when chezmoi apply is executed.

By using .chezmoiignore as follows:

.chezmoiignore
{{- if eq .chezmoi.os "windows" }}
.chezmoiscripts/*.sh
{{- else }}
.chezmoiscripts/*.ps1
{{- end }}

You can ensure that scripts corresponding to each environment are executed.

Sensitive Information

For items you don't want to commit to a public Git repository, such as API tokens, you can pull them from a password manager. For example, I use Bitwarden:

private_dot_zshrc.secrets.tmpl
export GITHUB_MCP_API_TOKEN="{{ (bitwarden "item" "github-mcp-api-token").login.password }}"
export CONTEXT7_API_TOKEN="{{ (bitwarden "item" "context7-api-key").login.password }}"

In this way, information is retrieved from Bitwarden.
(Note: When using this method, you must run export BW_SESSION="$(bw unlock --raw)" to unlock the Vault before each chezmoi apply.)

chezmoi supports many password managers, so you will likely find the one you use.

Consolidating Global Instruction Files

Currently, Claude Code—which is likely the most commonly used agent—does not have a feature to preload AGENTS.md into a session as of 2026/01/21. (An issue has been opened and has over 1,900 :+1:, but it has been left for over 5 months with no prospect of a fix.)

  • You could use ln -s ~/.codex/AGENTS.md ~/.claude/CLAUDE.md to create a symbolic link, but I hesitate because I've heard that Git's handling of symbolic links on Windows can be finicky.
  • It seems that writing @AGENTS.md in CLAUDE.md will preload it, which I think is effective on a per-project basis.
    However, since this is global, would I have to write @~/.codex/AGENTS.md? I don't like that either.

(Moreover, all the examples above are based on Codex; it would be a hassle if, for example, OpenCode (~/.config/opencode/) became the main one in the future.)

This is where we use chezmoi!!

chezmoi has a feature that allows you to easily call contents within a directory named .chezmoitemplates/ in all .tmpl files. By using this:

.chezmoitemplates/AGENTS.md
# Global Instructions (User Memory)

## Responses

- Perform thinking in any language, but provide the final response in Japanese.
dot_claude/CLAUDE.md.tmpl
{{- template "AGENTS.md" . }}
dot_codex/AGENTS.md.tmpl
{{- template "AGENTS.md" . }}

By setting it up like this, simply running chezmoi apply will distribute the common AGENTS.md to both ~/.claude/CLAUDE.md and ~/.codex/AGENTS.md!!
Whenever you want to update it, just update .chezmoitemplates/AGENTS.md and run chezmoi apply, and it will be reflected immediately!!

What's more, since these are .tmpl files, it's easy even if you want to provide agent-specific instructions.

dot_claude/CLAUDE.md.tmpl
{{- template "AGENTS.md" . }}

## Agent-specified

- When using paths within Read, Edit, Write, Update tools or Bash tools, use relative paths as much as possible if they can represent the location without using ".. (one level up)" or "~ (home directory)" to avoid redundant confirmation prompts.

This way, there's no need to go through the trouble of writing things like "If you are Claude Code, then...".

Consolidating Skills

In the case of Skills, since they are folders, you cannot use template syntax. Therefore, we copy them from .chezmoitemplates/ using a run_after script.

.chezmoiscripts/run_after_30_copy-skills.sh
#!/bin/sh
set -eu

CHEZMOI_SOURCE_DIR="${CHEZMOI_SOURCE_DIR:-$(chezmoi source-path)}"
SKILLS_SRC="$CHEZMOI_SOURCE_DIR/.chezmoitemplates/skills"

if [ ! -d "$SKILLS_SRC" ]; then
  echo "Skills source directory not found: $SKILLS_SRC" >&2
  exit 1
fi

DESTINATIONS="
$HOME/.claude/skills
$HOME/.codex/skills
$HOME/.config/opencode/skills
"

for dest in $DESTINATIONS; do
  mkdir -p "$dest"
  cp -R "$SKILLS_SRC"/* "$dest"/
done
PowerShell Version
~/.chezmoiscripts/run_after_30_copy-skills.ps1
#Requires -Version 5.1
$ErrorActionPreference = "Stop"

$ChezmoiSourceDir = if ($env:CHEZMOI_SOURCE_DIR) {
    $env:CHEZMOI_SOURCE_DIR
} else {
    chezmoi source-path
}

$SkillsSrc = Join-Path $ChezmoiSourceDir ".chezmoitemplates\skills"

if (-not (Test-Path $SkillsSrc -PathType Container)) {
    Write-Error "Skills source directory not found: $SkillsSrc"
    exit 1
}

$Destinations = @(
    Join-Path $env:USERPROFILE ".claude\skills"
    Join-Path $env:USERPROFILE ".codex\skills"
    Join-Path $env:USERPROFILE ".config\opencode\skills"
)

foreach ($dest in $Destinations) {
    if (-not (Test-Path $dest)) {
        New-Item -ItemType Directory -Path $dest -Force | Out-Null
    }
    Copy-Item -Path "$SkillsSrc\*" -Destination $dest -Recurse -Force
}

With this, the .chezmoitemplates/skills folder will be copied to ~/.claude/skills and ~/.codex/skills.

Consolidating Permission Files

Next, I want to consolidate files like ~/.claude/settings.json and ~/.codex/rules/default.rules that control Bash command approvals and other settings, but there is a problem: their file formats are completely different.

For example, ~/.claude/settings.json is in this format:

~/.claude/settings.json
{
  "permissions": {
    "allow": [
      "Read(~/.claude/**)",
      "Read(//tmp/**)",
      "Edit(//tmp/**)",
      "Write(//tmp/**)",
      "Bash(basename:*)",
      "Bash(cargo add:*)",
      "Bash(cargo build:*)",
      "Bash(cargo check:*)",
      "Bash(cargo clean:*)",
      "Bash(cargo clippy:*)"
    ]
  }
}

While ~/.codex/rules/default.rules is in this format:

~/.codex/rules/default.rules
prefix_rule(
  pattern = ["basename"],
  decision = "allow",
  match = ["basename"],
)

prefix_rule(
  pattern = ["cargo", [
    "add",
    "build",
    "check",
    "clean",
    "clippy"
  ]],
  decision = "allow",
  match = ["cargo add"],
)

As you can see, they have no compatibility whatsoever.

So, I created a Go script that can automatically generate permission settings from a YAML file. It supports Claude Code, Codex, and OpenCode.


https://github.com/waki285/dotfiles-tools/blob/main/permissions-gen/main.go

It uses data placed in .chezmoidata/permissions.yaml.

.chezmoidata/permissions.yaml
bash:
  allow:
    - basename
    - cargo add
    - cargo build
    - cargo check
    - cargo clean
    - cargo clippy
  ask:
    - chmod
  deny:
    - dd

claude:
  allow:
    - "Read(~/.claude/**)"
    - "Read(//tmp/**)"
    - "Edit(//tmp/**)"
    - "Write(//tmp/**)"
  ask: []
  deny: []
  additionalDirectories:
    - "//tmp"

opencode:
  bash:
    default: ask
    allow: []
    ask:
      - "find * -delete"
    deny: []
  webfetch: allow

By writing it like this and running go run . where the Go file is located, the permission settings will be collectively reflected in:

  • dot_claude/settings.json.tmpl
  • dot_codex/rules/default.rules
  • dot_config/opencode/opencode.json

as follows:

dot_claude/settings.json.tmpl
{
  "permissions": {
    {{/* PERMISSIONS:START */}}
    "allow": [
      "Read(~/.claude/**)",
      "Read(//tmp/**)",
      "Edit(//tmp/**)",
      "Write(//tmp/**)",
      "Bash(basename:*)",
      "Bash(cargo add:*)",
      "Bash(cargo build:*)",
      "Bash(cargo check:*)",
      "Bash(cargo clean:*)",
      "Bash(cargo clippy:*)"
    ],
    "ask": [
      "Bash(chmod:*)"
    ],
    "deny": [
      "Bash(dd:*)"
    ],
    "additionalDirectories": [
      "//tmp"
    ]
    {{/* PERMISSIONS:END */}}
  }
}
dot_codex/rules/default.rules
# ~/.codex/rules/default.rules
# Generated by tools/permissions-gen. Do not edit by hand.

prefix_rule(
  pattern = ["basename"],
  decision = "allow",
  match = ["basename"],
)

prefix_rule(
  pattern = ["cargo", [
    "add",
    "build",
    "check",
    "clean",
    "clippy"
  ],
  decision = "allow",
  match = ["cargo add"],
)

prefix_rule(
  pattern = ["chmod"],
  decision = "prompt",
  match = ["chmod"],
)

prefix_rule(
  pattern = ["dd"],
  decision = "forbidden",
  match = ["dd"],
)
dot_config/opencode/opencode.json
{
  "permission": {
    "bash": {
      "*": "ask",
      "basename": "allow",
      "basename *": "allow",
      "cargo add": "allow",
      "cargo add *": "allow",
      "cargo build": "allow",
      "cargo build *": "allow",
      "cargo check": "allow",
      "cargo check *": "allow",
      "cargo clean": "allow",
      "cargo clean *": "allow",
      "cargo clippy": "allow",
      "cargo clippy *": "allow",
      "chmod": "ask",
      "chmod *": "ask",
      "find * -delete": "ask",
      "dd": "deny",
      "dd *": "deny"
    },
    "webfetch": "allow"
  }
}

In this way, permission settings are reflected across all files at once!!
Although I have omitted parts of the files here, other existing settings are naturally preserved.

By using a justfile to make it callable immediately from the working directory,


https://github.com/waki285/dotfiles/blob/main/justfile

you can synchronize them quickly with just perms.

Creating Hooks

Claude Code has Hooks, and OpenCode has Plugins, which are tools that allow interference with the tools used by agents.

So, I used Rust to create hooks that:

  • Prohibit the use of the rm command and instead guide the agent to use trash.
  • Prohibit the use of destructive find.
  • Prohibit targeting specific paths in rm, trash, and mv commands.
  • In Rust, prohibit the use of #[allow(lint)] and guide the agent toward #[expect(lint)] via options.
  • Prohibit package manager mistakes, such as trying to run pnpm i in an environment where package-lock.json exists.

I've made these hooks!!
6c!-- markdownlint-disable-next-line MD034 -->
https://github.com/waki285/dotfiles-tools

To use them with Claude Code:

dot_claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.claude/hooks/agent_hooks_claude pre-tool-use --deny-rust-allow --expect"
          }
        ]
      },
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.claude/hooks/agent_hooks_claude pre-tool-use --check-package-manager"
          }
        ]
      }
    ],
    "PermissionRequest": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.claude/hooks/agent_hooks_claude permission-request --block-rm --confirm-destructive-find"
          }
        ]
      }
    ]
  }
}

In OpenCode, there was a barrier where Plugins were exclusively for TypeScript. However, since I wanted to share the logic, I used napi-rs to allow it to be loaded from TypeScript as a .node file.

https://github.com/waki285/dotfiles/blob/main/dot_config/opencode/plugin/agent_hooks.ts

You can load it like this.

These hooks are available on GitHub Releases!!

https://github.com/waki285/dotfiles-tools/releases

There is also a script to fetch the latest version every time you run chezmoi apply. (It skips if it is already the latest version.)

https://github.com/waki285/dotfiles/blob/main/.chezmoiscripts/run_after_20_agent-hooks.sh

Conclusion

In this article, to make it easier to use multiple coding agents (Claude Code / OpenAI Codex / OpenCode), I have built a workflow using chezmoi that:

  • Standardizes global instruction files using templates
  • Distributes and synchronizes Skills via hook scripts
  • Centrally manages diverse permission settings by auto-generating them from YAML
  • Eliminates errors by creating custom Hooks and Plugins

Manually remembering setting differences for each agent tends to break down as the number of environments increases. However, with this configuration, the "source of truth" lies in the source (.chezmoitemplates / .chezmoidata), allowing the settings on the agent side to be treated as virtually non-existent, making updates and rollbacks much easier. Since it supports both Windows and Mac, the reduction in stress from having everything aligned just by running apply on the same repository is greater than imagined.

The dotfiles and related tools I use are available on GitHub. If you like them, I would appreciate it if you could give them a star.

https://github.com/waki285/dotfiles

https://github.com/waki285/dotfiles-tools

GitHubで編集を提案

Discussion

ShabbbyBeeShabbbyBee

codex以外はtoPrettyJson使えるので
Goでスクリプト回すより*.tmpl内で{{- range .permissions -}}のほうが楽かもしれないですね

ShabbbyBeeShabbbyBee

汚くなるけどsetValueAtPathdeleteValueAtPathで吸収するしかないですね...
MCPとかプロトコル決めるならこのあたりの設定ファイルやらディレクトリ構造周りも統一して欲しいですな