iTranslated by AI
Standardizing AI Agent Instructions and Permission Management with chezmoi and Hooks
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:
[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:
.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:
# 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:
{{- 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:
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.mdto 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.mdinCLAUDE.mdwill 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:
# Global Instructions (User Memory)
## Responses
- Perform thinking in any language, but provide the final response in Japanese.
{{- template "AGENTS.md" . }}
{{- 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.
{{- 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.
#!/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
#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:
{
"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:
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.
It uses data placed in .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.tmpldot_codex/rules/default.rulesdot_config/opencode/opencode.json
as follows:
{
"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 */}}
}
}
# ~/.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"],
)
{
"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,
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
rmcommand and instead guide the agent to usetrash. - Prohibit the use of destructive
find. - Prohibit targeting specific paths in
rm,trash, andmvcommands. - 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 iin an environment wherepackage-lock.jsonexists.
I've made these hooks!!
6c!-- markdownlint-disable-next-line MD034 -->
To use them with Claude Code:
{
"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.
You can load it like this.
These hooks are available on GitHub 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.)
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.
Discussion
codex以外はtoPrettyJson使えるので
Goでスクリプト回すより*.tmpl内で
{{- range .permissions -}}のほうが楽かもしれないですね残念ながら、Claude Code と Opencode ではデータ形式が違うのです。。。
汚くなるけど
setValueAtPathやdeleteValueAtPathで吸収するしかないですね...MCPとかプロトコル決めるならこのあたりの設定ファイルやらディレクトリ構造周りも統一して欲しいですな