Ask the person before Claude edits Claude Code's own config, instruction files or this guard, and before a known-format secret is written.

Personal dotfiles for zsh, WezTerm, and global Codex / Claude Code Skills.
./init.sh
init.sh は idempotent。以下のいずれかをやったら再実行する:
skills/<name>/ を追加した (新 skill)skills/<name>/ を削除した (broken symlink が link_skills で自動 cleanup)zsh/, wezterm/, nvim/, git/, lazygit/, czg/, cz-git/, starship/ の構成を変えた./bootstrap.sh
Then run:
gh auth login
gh auth refresh -s project
git config --global ghq.root "$HOME/src"
mkdir -p "$HOME/.config/zsh" "$HOME/.config/wezterm" "$HOME/.config/codex/skills" "$HOME/.claude/skills"
./init.sh
既にあるマシンへ 追加分だけ 取り込むときは --no-upgrade を付ける。 付けないと brew bundle は古い formula をまとめて upgrade する。
git pull && brew bundle --file=./Brewfile --no-upgrade
./bootstrap.sh also installs Miniforge3 into ~/miniforge3 when missing. ./init.sh links zsh/env.zsh, which loads conda shell support without auto-activating base.
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
echo "" >> "$HOME/.zprofile"
echo 'eval "$(/opt/homebrew/bin/brew shellenv zsh)"' >> "$HOME/.zprofile"
eval "$(/opt/homebrew/bin/brew shellenv zsh)"
# 公式以外の tap は Homebrew 7 から明示的な信頼が要る(~/.homebrew/trust.json, マシンごと)
brew trust olets/tap
# パッケージの一覧は Brewfile が正本(個別の brew install をここに増やさない)
brew bundle --file=./Brewfile
npm install -g git-cz czg cz-git
npx -y czg --api-key="sk-XXXX"
Clone this repo with ghq, then install:
gh auth login
gh auth refresh -s project
git config --global ghq.root "$HOME/src"
mkdir -p "$HOME/.config/zsh" "$HOME/.config/wezterm" "$HOME/.config/codex/skills" "$HOME/.claude/skills"
ghq get https://github.com/38kta-lab/dotfile
cd "$(ghq root)/github.com/38kta-lab/dotfile"
./install_miniforge.sh
./init.sh
gh auth refresh -s project is required on each machine where Codex or gh updates GitHub Projects, such as the Life project Status field.
# Codex CLI
npm install -g @openai/codex
mkdir -p ~/.config/codex
mv ~/.codex/* ~/.config/codex/
codex sign-in
agy) — Gemini CLI の後継Google は 2026-06-18 に個人 Google アカウント向けの Gemini CLI ログインを サーバ側で打ち切った(This client is no longer supported for Gemini Code Assist for individuals エラー)。後継の Antigravity CLI (agy) に移行する。
# 旧 gemini CLI が残っていれば削除
npm uninstall -g @google/gemini-cli 2>/dev/null; rm -rf ~/.gemini
# Antigravity CLI をインストール (Go 製 single binary、~/.local/bin/agy)
curl -fsSL https://antigravity.google/cli/install.sh | bash
# 初回起動でブラウザ認証 (要 Google アカウント。Pro 系モデルはサブスク必要)
agy
~/.local/bin は zsh/env.zsh で既に PATH 追加済み → 追加設定不要。agy / 非対話 agy -p "プロンプト" / モデル指定 agy --model <id> -p ... / 一覧 agy models / 継続 agy -c。Miniforge3 is installed under:
~/miniforge3
Install or verify it:
./install_miniforge.sh
Use a pinned Miniforge release when needed:
MINIFORGE_VERSION=25.11.0-0 ./install_miniforge.sh
The installer supports Apple Silicon and Intel macOS by selecting the matching installer from the official conda-forge/miniforge GitHub releases:
https://github.com/conda-forge/miniforge/releases
base should not auto-activate:
conda config --set auto_activate_base false
For per-repo environments, prefer environment.yml in that repo:
conda env create -f environment.yml
conda activate <env-name>
If an environment already exists:
conda env update -f environment.yml --prune
For Codex-assisted Calendar reads in the life repo, place the OAuth desktop client JSON at:
~/.config/life/google-calendar-credentials.json
Copy this file between personal Macs using a private secure channel. Do not commit it to git, and do not create a public/shared link.
On a new Mac:
mkdir -p "$HOME/.config/life"
mv "$HOME/Downloads/google-calendar-credentials.json" "$HOME/.config/life/google-calendar-credentials.json"
chmod 600 "$HOME/.config/life/google-calendar-credentials.json"
If the downloaded file has a client_secret_*.json name:
mkdir -p "$HOME/.config/life"
mv "$HOME/Downloads"/client_secret_*.json "$HOME/.config/life/google-calendar-credentials.json"
chmod 600 "$HOME/.config/life/google-calendar-credentials.json"
Do not copy this token between Macs:
~/.config/life/google-calendar-read-token.json
Generate that token separately on each Mac by running the Calendar reader from the life repo after activating its conda environment:
conda activate life
python scripts/google_calendar_read.py --format json
Global user Skills are managed in this repo under:
skills/
./init.sh links each directory under skills/ into both:
~/.config/codex/skills/
~/.claude/skills/
System Skills under ~/.config/codex/skills/.system/ are not managed here. Claude Code's auto memory under ~/.claude/projects/.../memory/ is also not managed here.
Use PRs with squash merge to keep main clean and reduce cross-machine conflicts. Rule of thumb: update main, but do not work directly on it.
Branch naming (one branch per machine):
work/<hostname> (example: work/kta38-mini-lab)Get <hostname> with:
hostname -s
Initial setup (first time on a machine):
HOST="$(hostname -s)"
git switch main
git pull --rebase
git switch -c "work/$HOST"
git push -u origin "work/$HOST"
Flow (manual):
# start work (every time)
git switch main
git pull --rebase
git switch work/<hostname>
git rebase main
# work + commit
git add -A
git commit -m "feat: ..."
git push -u origin work/<hostname>
Create a PR from work/<hostname> to main:
gh pr create --base main --head work/<hostname> --fill
Then Squash and merge it on GitHub, or with gh:
gh pr merge <PR_NUMBER> --squash
Merge commit (no squash):
gh pr merge <PR_NUMBER> --merge
Aliases (see zsh/alias.zsh):
winit # initial setup: create/push work/<hostname> branch
wmain # update main only (before switching)
wstart # start work: update main -> switch work/<hostname> -> rebase
wrebase # rebase current work branch onto main
prc # gh pr create --base main --head work/<hostname> --fill
prs # gh pr merge --squash
prm # gh pr merge --merge
Then on other machines:
git switch main
git pull --rebase
Notes:
work/<hostname> rebased onto main to avoid long-lived divergence.main.wezterm/keybinds.lua and linked to ~/.config/wezterm/keybinds.lua.skills/ and linked to both ~/.config/codex/skills/ and ~/.claude/skills/.~/.config/.czrc is not committed; see czrc/.czrc.example for a template.Keyboard
日本語 - ローマ字入力英字オフの時「英字」を入力Trackpad
MaxオンPointer
1つ大きくする#D05654#464758iCloud
オンDesktop
スタックを使用表示オプションを表示10種類名前36x36下から4番目Dock
左オンDefault browser
Chromehooks/register.ts 165 lines1import type { Register } from 'claude-code'
2
3// Claude Code's own configuration and instruction files, and this guard.
4// A change to any of them changes what every later session is allowed to do.
5const PROTECTED_PATHS: readonly RegExp[] = [
6 /\.claude\/settings(\.local)?\.json$/,
7 /(^|\/)\.claude\.json$/,
8 /(^|\/)\.mcp\.json$/,
9 /(^|\/)CLAUDE\.md$/,
10 /(^|\/)AGENTS\.md$/,
11 /\.claude\/hooks\//,
12 /\.claude\/statusline[^/]*$/,
13 /\.claude\/plugins\//,
14 /\/mods\/config-guard\//,
15]
16
17// The same files as they appear inside a shell command (not anchored, and
18// `cd ~/.claude && ... settings.json` still counts).
19const PROTECTED_IN_COMMAND: readonly RegExp[] = [
20 /\.claude\b[\s\S]*settings(\.local)?\.json/,
21 /\.claude\.json/,
22 /\.mcp\.json/,
23 /CLAUDE\.md/,
24 /AGENTS\.md/,
25 /\.claude\/hooks\b/,
26 /\.claude\/statusline/,
27 /\.claude\/plugins\b/,
28 /mods\/config-guard\b/,
29]
30
31// Shell forms that can change a file. Best effort: a command built from
32// variables or an interpreter script gets past it.
33const WRITES =
34 /(^|[^<>&0-9])>>?(?!&)|\btee\b|\bsed\b[^|;&]*\s-[a-zA-Z]*i|\bperl\b[^|;&]*\s-[a-zA-Z]*i|\b(cp|mv|rm|ln|install|rsync|truncate|chmod|chown|touch|dd)\b|\bgit\s+(checkout|restore|apply|mv|rm)\b|\bopen\([^)]*['"][wa]/
35
36// Secrets by their published prefixes only. Never by entropy: research notes
37// carry long hex digests (database md5s) that are not secrets.
38const SECRETS: readonly (readonly [string, RegExp])[] = [
39 ['Anthropic API key', /sk-ant-[A-Za-z0-9_-]{20,}/],
40 ['OpenAI API key', /\bsk-(proj-)?[A-Za-z0-9_-]{32,}/],
41 ['GitHub token', /\bgh[pousr]_[A-Za-z0-9]{36,}|\bgithub_pat_[A-Za-z0-9_]{40,}/],
42 ['AWS access key', /\bAKIA[0-9A-Z]{16}\b/],
43 ['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
44 ['Google API key', /\bAIza[0-9A-Za-z_-]{35}/],
45 ['Google OAuth token', /\bya29\.[0-9A-Za-z_-]{20,}/],
46 ['Tailscale auth key', /\btskey-[A-Za-z0-9-]{20,}/],
47 ['private key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
48]
49
50// The shared task list: only the session started with LIFE_ROLE=ops writes it.
51// Every other session reads it. Refused without a dialog: no other session
52// has a reason to write it.
53const OPS_ONLY_PATH = /(^|\/)ideas\/task-review\/tasks\.md$/
54const OPS_ONLY_IN_COMMAND = /task-review\b[\s\S]*\btasks\.md/
55const OPS_ONLY_DENY =
56 'config-guard: ideas/task-review/tasks.md is written by the ops session only. ' +
57 'Do not write it another way; put the change you wanted in your report instead.'
58
59const ALLOW = 'Allow once'
60const DENY = 'Deny'
61
62function extraPaths(value: unknown): string[] {
63 return String(value ?? '')
64 .split(',')
65 .map(s => s.trim())
66 .filter(Boolean)
67}
68
69function pathReasons(path: string, extra: readonly string[]): string[] {
70 const hit = PROTECTED_PATHS.some(r => r.test(path)) || extra.some(s => path.includes(s))
71 return hit ? [`writes to a protected file: ${path}`] : []
72}
73
74// Names the kind of secret, never the matched text, so the question itself
75// does not carry the secret into the transcript.
76function secretReasons(text: string): string[] {
77 const hit = SECRETS.find(([, r]) => r.test(text))
78 return hit ? [`the content looks like it holds a ${hit[0]}`] : []
79}
80
81// The command cut into the pieces that each run on their own, each paired with
82// the `cd` targets before it (`cd ~/.claude && sed -i ... settings.json` still
83// names settings.json under .claude). A file named in one piece and a write in
84// another (`git rm note/x.md && git commit -m "... CLAUDE.md"`) is not a write
85// to that file. Commit messages are dropped: they name files, they write none.
86function segments(command: string): string[] {
87 const c = command
88 .replace(/\d*&?>>?\s*\/dev\/null/g, '')
89 .replace(/(\s-m|\s--message)(=|\s+)("(?:[^"\\]|\\.)*"|'[^']*')/g, '$1 ""')
90 const out: string[] = []
91 let dirs = ''
92 for (const piece of c.split(/&&|\|\||;|\n|\|/)) {
93 out.push(`${dirs} ${piece}`)
94 const cd = piece.match(/^\s*cd\s+(\S+)/)
95 if (cd) dirs += ` ${cd[1]}`
96 }
97 return out
98}
99
100function opsOnlyCommand(command: string): boolean {
101 return segments(command).some(s => OPS_ONLY_IN_COMMAND.test(s) && WRITES.test(s))
102}
103
104function bashReasons(command: string, extra: readonly string[]): string[] {
105 const hit = segments(command).some(s =>
106 (PROTECTED_IN_COMMAND.some(r => r.test(s)) || extra.some(x => s.includes(x))) && WRITES.test(s))
107 return [
108 ...(hit ? [`a shell command that may change a protected file: ${command.slice(0, 120)}`] : []),
109 ...secretReasons(command),
110 ]
111}
112
113// Puts the call to the person in a dialog. `tool.check`'s `ask` is not used:
114// under auto mode it goes to the auto-mode classifier, not to the person.
115// No dialog (a `-p` run) or a dismissed one refuses the call. Deny is listed
116// first and only the exact Allow label allows, so a dialog that resolves on
117// its own while the person is away does not let the call through.
118async function confirm(ask: (q: string) => Promise<string>, tool: string, reasons: readonly string[]): Promise<string | undefined> {
119 const why = reasons.join(' / ')
120 let answer: string
121 try {
122 answer = await ask(`config-guard: ${tool} ${why}. Allow it?`)
123 } catch {
124 return `config-guard refused this ${tool} call (${why}): nobody could be asked to confirm it.`
125 }
126 return answer === ALLOW ? undefined : `config-guard: the person declined this ${tool} call (${why}).`
127}
128
129const FAIL_CLOSED = 'config-guard: its check failed, so the call was refused.'
130
131export const register: Register = (on, options) => {
132 const extra = extraPaths(options.extra_paths)
133
134 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
135 if (OPS_ONLY_PATH.test(e.file_path) && (await $.env.get('LIFE_ROLE')) !== 'ops') return { deny: OPS_ONLY_DENY }
136 const reasons = [...pathReasons(e.file_path, extra), ...secretReasons(e.content)]
137 if (reasons.length === 0) return next(e)
138 const deny = await confirm(q => $.ui.ask(q, { header: 'Guard', options: [DENY, ALLOW] }), 'Write', reasons)
139 return deny ? { deny } : next(e)
140 }).catch(($, e, next) => (next.called ? next(e) : { deny: FAIL_CLOSED }))
141
142 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
143 if (OPS_ONLY_PATH.test(e.file_path) && (await $.env.get('LIFE_ROLE')) !== 'ops') return { deny: OPS_ONLY_DENY }
144 const reasons = [...pathReasons(e.file_path, extra), ...secretReasons(e.new_string)]
145 if (reasons.length === 0) return next(e)
146 const deny = await confirm(q => $.ui.ask(q, { header: 'Guard', options: [DENY, ALLOW] }), 'Edit', reasons)
147 return deny ? { deny } : next(e)
148 }).catch(($, e, next) => (next.called ? next(e) : { deny: FAIL_CLOSED }))
149
150 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
151 const reasons = [...pathReasons(e.notebook_path, extra), ...secretReasons(e.new_source)]
152 if (reasons.length === 0) return next(e)
153 const deny = await confirm(q => $.ui.ask(q, { header: 'Guard', options: [DENY, ALLOW] }), 'NotebookEdit', reasons)
154 return deny ? { deny } : next(e)
155 }).catch(($, e, next) => (next.called ? next(e) : { deny: FAIL_CLOSED }))
156
157 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
158 if (opsOnlyCommand(e.command) && (await $.env.get('LIFE_ROLE')) !== 'ops') return { deny: OPS_ONLY_DENY }
159 const reasons = bashReasons(e.command, extra)
160 if (reasons.length === 0) return next(e)
161 const deny = await confirm(q => $.ui.ask(q, { header: 'Guard', options: [DENY, ALLOW] }), 'Bash', reasons)
162 return deny ? { deny } : next(e)
163 }).catch(($, e, next) => (next.called ? next(e) : { deny: FAIL_CLOSED }))
164}
165