My Dotfiles!

sh -c "$(curl -fsLS get.chezmoi.io)" -- -b $HOME/.local/bin init --source=~/ghq/github.com/0ta2/dots git@github.com:0ta2/dots.git
~/ghq/github.com/0ta2/dots/bootstrap.sh
Homebrew・mise を導入した後 mise run install (ドットファイル反映・不足ツール・skill・plugin の 導入・Codex 設定同期) を実行する。
mise run sync # ドットファイル反映 + Codex設定マージ (副作用なし、何度実行しても収束する)
mise run update # brew/mise/skill/uv tool の更新、最後に sync
直接 chezmoi コマンドを実行する場合は下記の通り:
~/.local/bin/chezmoi --source=~/ghq/github.com/0ta2/dots apply
コンピュータ名 を変更パスワードやパスキーを自動入力 をOFFドラッグにトラックパッドを使用 をONDock のサイズ小さく好みのサイズへ変更Dock の拡大を好みの大きさへ変更Dock を自動的に表示/非表示をOFF外観モードをダークに変更Spotlight検索を表示 のショートカットのチェックを外すCmd + スペース に変更Cmd + shift + スペース に変更CapsLock を Ctrl に変更するiwano.nvim / amanoukihashi.nvim をローカルのリポジトリから読み込むため、下記のシンボリックリンクを作成する:
mkdir -p ~/.local/share/nvim/site/pack/mine/opt
ln -s ~/ghq/github.com/0ta2/iwano.nvim ~/.local/share/nvim/site/pack/mine/opt/iwano.nvim-local
ln -s ~/ghq/github.com/0ta2/amanoukihashi.nvim ~/.local/share/nvim/site/pack/mine/opt/amanoukihashi.nvim-localhooks/register.ts 29 lines1import type { Register } from 'claude-code'
2import { findRule, type Config } from './guard.ts'
3
4export const register: Register = on => {
5 on('tool.check', async ($, e, next) => {
6 let config: Config
7 try {
8 config = JSON.parse(await $.fs.read(`${$.plugin.root}/rules.json`))
9 } catch (err) {
10 $.ui.toast(`command-guard: rules.json を読めません (${err instanceof Error ? err.message : err})`)
11 return next(e)
12 }
13 const cwd = await $.session.cwd()
14 const home = (await $.env.get('HOME')) ?? ''
15 const git = async (dir: string, args: string[]) => {
16 const r = await $.process.run(['git', '-C', dir, ...args]).catch(() => undefined)
17 return r?.exitCode === 0 ? r.stdout.trim() : ''
18 }
19 const rule = await findRule(config, e.tool, e.input, { cwd, home, git })
20 if (!rule) return next(e)
21 const reason = `command-guard: ${rule.name}`
22 if (rule.action === 'ask') {
23 const verdict = await next(e)
24 return verdict.decision === 'deny' ? verdict : { decision: 'ask', reason }
25 }
26 return { decision: 'deny', reason: `${reason} (blocked)` }
27 })
28}
29hooks/guard.ts 158 lines1export type Rule = {
2 name: string
3 tool?: string
4 field?: string
5 pattern: string | string[]
6 flags?: string
7 scrub?: boolean
8 when?: { branch?: string[] }
9 exceptRepos?: string[]
10 exceptMerged?: boolean
11 action?: 'deny' | 'ask'
12}
13
14export type Config = { vars?: Record<string, string>; rules: Rule[] }
15
16export type Context = {
17 cwd: string
18 home: string
19 git: (dir: string, args: string[]) => Promise<string>
20}
21
22const HEREDOC = /<<-?\s*(['"]?)([A-Za-z_]\w*)\1/g
23const MESSAGE_FLAG = /(-m|--message|--body|--title|--notes|--content)(\s*=?\s*)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g
24const SUBSTITUTION = /\$\((?:[^()]|\([^()]*\))*\)|`[^`]*`/g
25const SHELL = /^(\S*\/)?((ba|z|da|k)?sh|eval|ssh)$/
26const WRAPPER = /^(\S*\/)?(env|command|exec|nohup|time|nice|sudo|xargs)$/
27
28function runsShell(segment: string): boolean {
29 const words = segment.trim().split(/\s+/).filter(w => !/^\w+=/.test(w))
30 const [first = '', ...rest] = words
31 return SHELL.test(first) || (WRAPPER.test(first) && rest.some(w => SHELL.test(w)))
32}
33
34function feedsShell(cmd: string, at: number, after: number): boolean {
35 const before = cmd.slice(cmd.lastIndexOf('\n', at) + 1, at).split(/[;&|(]/).pop() ?? ''
36 const lineEnd = cmd.indexOf('\n', after)
37 const piped = cmd.slice(after, lineEnd === -1 ? undefined : lineEnd).split('|').slice(1)
38 return [before, ...piped].some(runsShell)
39}
40
41const substitutions = (s: string) =>
42 (s.match(SUBSTITUTION) ?? []).map(sub => ` ; ${sub.replace(/^\$\(|^`|\)$|`$/g, '')} ; `).join('')
43
44export function scrub(cmd: string): string {
45 let out = ''
46 let pos = 0
47 for (const m of cmd.matchAll(HEREDOC)) {
48 if (m.index < pos) continue
49 const head = m.index + m[0].length
50 out += cmd.slice(pos, head)
51 pos = head
52 if (feedsShell(cmd, m.index, head)) continue
53 const end = new RegExp(`^[ \\t]*${m[2]}[ \\t]*$`, 'm').exec(cmd.slice(head))
54 if (!end) continue
55 if (!m[1]) out += ` ${substitutions(cmd.slice(head, head + end.index))}`
56 pos = head + end.index + end[0].length
57 }
58 out += cmd.slice(pos)
59 return out.replace(MESSAGE_FLAG, (_, flag, sep, value: string) =>
60 `${flag}${sep}"${value.startsWith('"') ? substitutions(value) : ''}"`,
61 )
62}
63
64const unquote = (s: string) => s.replace(/^(["'])(.*)\1$/, '$2')
65
66const expand = (p: string, home: string) => (p === '~' ? home : p.startsWith('~/') ? home + p.slice(1) : p)
67
68const PATH_ARG = `("[^"]+"|'[^']+'|\\S+)`
69const LIT = '[A-Za-z0-9._~/-]+'
70const NAME = '[A-Za-z0-9._/][A-Za-z0-9._/-]*'
71const CD = new RegExp(`^cd\\s+(${LIT})$`)
72const BRANCH_DELETE = new RegExp(`^git(?:\\s+-C\\s+(${LIT}))?\\s+branch((?:\\s+(?:-[dDf]+|--delete|--force))+)(\\s+${NAME}(?:\\s+${NAME})*)$`)
73
74const resolve = (base: string, p: string, home: string) => {
75 const abs = expand(unquote(p), home)
76 return abs.startsWith('/') ? abs : `${base}/${abs}`
77}
78
79export function targetDir(cmd: string, re: RegExp, ctx: Context): string {
80 let dir = ctx.cwd
81 for (const seg of cmd.split(/&&|\|\||[;|\n]/)) {
82 const cd = new RegExp(`^\\s*\\(?\\s*cd\\s+${PATH_ARG}`).exec(seg)?.[1]
83 if (cd) {
84 dir = resolve(dir, cd, ctx.home)
85 continue
86 }
87 if (re.test(seg)) {
88 const c = new RegExp(`git\\s+-C\\s+${PATH_ARG}`).exec(seg)?.[1]
89 return c ? resolve(dir, c, ctx.home) : dir
90 }
91 }
92 return dir
93}
94
95function branchNames(cmd: string, ctx: Context): { dir: string; name: string }[] | undefined {
96 if (cmd.includes('|')) return undefined
97 let dir = ctx.cwd
98 const branches = [] as { dir: string; name: string }[]
99 for (const seg of cmd.split(/&&|\|\||[;|\n]/).map(s => s.trim()).filter(Boolean)) {
100 const cd = CD.exec(seg)?.[1]
101 if (cd) {
102 if (!/^(\/|~$|~\/)/.test(cd)) return undefined
103 dir = resolve(dir, cd, ctx.home)
104 continue
105 }
106 const deletion = BRANCH_DELETE.exec(seg)
107 if (!deletion) return undefined
108 const [, deletionDir, flags = '', names = ''] = deletion
109 if (!/(?:^|\s)(?:-[dDf]*[dD][dDf]*|--delete)(?=\s|$)/.test(flags)) return undefined
110 if (!/[Df]|--force/.test(flags)) continue
111 if (deletionDir && !/^(\/|~$|~\/)/.test(deletionDir)) return undefined
112 const target = deletionDir ? resolve(dir, deletionDir, ctx.home) : dir
113 branches.push(...names.trim().split(/\s+/).map(name => ({ dir: target, name })))
114 }
115 return branches
116}
117
118const toolMatches = (glob: string, tool: string) =>
119 new RegExp(`^${glob.split('*').map(s => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`).test(tool)
120
121export function compile(config: Config): (Rule & { regexes: RegExp[] })[] {
122 const vars = config.vars ?? {}
123 const sub = (s: string) => s.replace(/\$\{(\w+)\}/g, (_, k) => vars[k] ?? '')
124 return config.rules.map(r => ({
125 ...r,
126 regexes: [r.pattern].flat().map(p => new RegExp(sub(p), r.flags)),
127 }))
128}
129
130export async function findRule(config: Config, tool: string, input: unknown, ctx: Context) {
131 for (const rule of compile(config)) {
132 if (!toolMatches(rule.tool ?? 'Bash', tool)) continue
133 const raw = (input as Record<string, unknown> | null)?.[rule.field ?? 'command']
134 if (typeof raw !== 'string') continue
135 const text = rule.scrub === false ? raw : scrub(raw)
136 if (!rule.regexes.every(re => re.test(text))) continue
137 if (rule.when?.branch || rule.exceptRepos) {
138 let dir = targetDir(raw, rule.regexes[0]!, ctx)
139 let top = await ctx.git(dir, ['rev-parse', '--show-toplevel'])
140 if (!top && dir !== ctx.cwd) {
141 dir = ctx.cwd
142 top = await ctx.git(dir, ['rev-parse', '--show-toplevel'])
143 }
144 if (rule.when?.branch && !rule.when.branch.includes(await ctx.git(dir, ['branch', '--show-current']))) continue
145 if (rule.exceptRepos?.some(p => expand(p, ctx.home) === top)) continue
146 }
147 if (rule.exceptMerged) {
148 const branches = branchNames(raw, ctx)
149 if (branches) {
150 const merged = await Promise.all(branches.map(({ dir, name }) => ctx.git(dir, ['branch', '--list', '--merged', 'refs/remotes/origin/HEAD', name])))
151 if (branches.length > 0 && merged.every(Boolean)) continue
152 }
153 }
154 return rule
155 }
156 return undefined
157}
158