Shows the working directory's git log as a graph in a side pane

Personal Claude Code mods.
| Mod | Command | Description |
|---|---|---|
git-graph | /git-graph | Toggles a side pane showing the working directory's git log as a graph |
pii-guard | — | Checks each prompt for personal data before it is sent: blocks Korean unique identifiers, asks before sending phone numbers, emails, cards, accounts, addresses and attachments |
session-outputs | /outputs | Toggles a pane listing what the current session produced — PRs, branches, Jira/Notion/Confluence/Slack links, edited files, scratchpad files — grouped into what the session produced (output) and links you gave it (input) |
todo-band | /todo, /auto-todo | Toggles a pane showing the session scratchpad's TODO.jsonl (or TODO.md); its todo-plan skill has Claude keep the plan there with dependencies, and /auto-todo has Claude keep it up to date on every request |
herdr-screenshot | — | Its herdr-screenshot skill saves a Herdr pane's screen as a PNG by rendering the pane's ANSI dump in headless Chrome |
The panes stay closed until you run the command; running it again closes the pane.
TODO.jsonlThe todo-plan skill has Claude write one JSON object per line to the session scratchpad:
{"id":"api","title":"Implement API","status":"doing","deps":["schema"]}
{"id":"deploy","title":"Deploy to dev1","status":"waiting","deps":["api"],"note":"user syncs ArgoCD"}
status is todo, doing (agent running it), waiting (waiting on the user to run or confirm something), done or cancelled. The pane orders items by deps, marks a todo whose deps are not all done as blocked, and flags broken lines, unknown deps and cycles. The full rules are in todo-band/skills/todo-plan/SKILL.md.
The hide done button in the pane's header (or h while the pane has focus) hides done and cancelled items (in TODO.md, - [x] lines) for the rest of the session; press show done (N) to bring them back. show keys (or k) prefixes each TODO.jsonl row with its id, e.g. ▶ [api] Implement API; hide keys turns it off.
/auto-todo [on|off]Without auto-todo, TODO.jsonl exists only once Claude picks the todo-plan skill for a multi-step task, and later requests in the session go untracked. With /auto-todo on, every prompt you type carries a hidden instruction to keep TODO.jsonl current: work requests (edits, commands, investigation) are appended as items and their statuses updated; questions that need no work get none. If a turn edits files or runs commands but leaves TODO.jsonl untouched, the next prompt also carries a one-time reminder.
auto ● (on) or auto ○ (off).autoTodoDefault to true in /config./auto-todo off stops the instruction and tells Claude once to stop updating the file on its own; the file and the pane stay.프롬프트(붙여넣은 텍스트 포함)와 @경로로 참조한 파일을 전송 전에 검사한다.
| 처리 | 유형 |
|---|---|
| 확인 없이 차단 | 주민등록번호·외국인등록번호(구분자 있는 형식), 여권번호·운전면허번호(키워드 또는 구분자 형식) |
| "그래도 보낼까요?" 확인 | 구분자 없는 13자리 주민번호 형식, 휴대전화, 이메일, 카드(Luhn), 계좌(키워드), 주소(시·도~번지), 이미지·문서 첨부 |
-p, SDK)나 검사 중 오류가 나면 보내지 않는다(fail-closed).Herdr pane 안에서 "이 화면 캡처해줘" 처럼 요청하면 스킬이 scripts/cap.sh 를 실행한다.
cap.sh [--pane <id>] [--out <png|dir>] [--title <text>] [--lines N]
$HERDR_PANE_ID)의 보이는 화면. --lines N 이면 scrollback 포함D2KodingLigature Nerd Font (한글 2칸 정렬용)/outputs 로 pane 을 열고 닫는다. 현재 진행 중인 세션의 transcript(~/.claude/projects/<slug>/<session id>.jsonl)와 scratchpad 를 읽어 수집 출처별로 두 그룹에 나눠 보여준다. 다른 세션의 산출물은 보이지 않는다.
| 그룹 | 출처 |
|---|---|
| output | assistant 의 글과 도구 입력(MCP 호출 포함)에 나온 URL, Write·Edit·NotebookEdit 로 만든 파일, git·gh 명령의 입출력, 세션 중 checkout 된 branch, 세션 scratchpad 의 파일 |
| input | 사용자 메시지의 글에 나온 URL |
.)이고, 링크의 path 는 URL 이다. path 는 흐리게 보인다. 지금 없는 파일은 (deleted) 로 표시한다git·gh 명령에서는 입력·출력 모두 GitHub 링크(PR · Branch · GitHub)만 남긴다. 출력은 gh pr create·gh issue create·gh release create·git push 의 것만 읽는다. gh pr view·gh api·git log 같은 조회 명령의 출력과 PR 본문(--body)의 Jira·Notion 링크는 참고 자료라 제외한다git checkout -b·git switch -c·git worktree add -b·git push -u 명령, git push 출력의 pull/new/<branch> URL 에서 찾는다/tmp 아래라 재부팅 후에는 사라진다r 로 다시 읽는다. 열려 있으면 10초마다 갱신한다j/k 로 한 item 씩 이동하고, c 는 선택한 item 의 이름, p 는 경로(파일은 절대 경로, 링크는 URL, URL 없는 branch 는 이름)를 클립보드에 복사한다tail -c 로 나눠 읽는다 ($.fs.read 상한)file:// URL 이 덧붙지 않도록 링크를 걸지 않는다. p 로 절대 경로를 복사한다claude.ai/code/session_…, claude.com/claude-code), api.* 엔드포인트, Read·WebFetch 등 도구 결과 속 URL 은 잡음이라 제외한다In a Claude Code terminal session:
/plugin install git-graph --marketplace binarythink/claude-marketplace
/plugin install todo-band --marketplace binarythink/claude-marketplace
/plugin install pii-guard --marketplace binarythink/claude-marketplace
/plugin install herdr-screenshot --marketplace binarythink/claude-marketplace
/plugin install session-outputs --marketplace binarythink/claude-marketplace
Answer y to add the marketplace, then pick a scope.
Or from the shell:
claude plugin marketplace add binarythink/claude-marketplace
claude plugin install git-graph@binarythink
claude plugin install todo-band@binarythink
claude plugin install pii-guard@binarythink
claude plugin install herdr-screenshot@binarythink
claude plugin install session-outputs@binarythink
Bump version in the mod's .claude-plugin/plugin.json when publishing a change, then:
claude plugin marketplace update binarythink
claude plugin update git-graph@binarythink
claude --plugin-dir ./git-graph # loads the working copy, reloads on save
claude plugin validate .
claude plugin test ./git-graphhooks/register.tsx 197 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GraphLine, GraphLog, RefKind, RepoStatus } from '../types'
5import {
6 GIT_LOG_ARGV, GIT_STATUS_ARGV, changesGraph, clockTime, cutToWidth, fitPieces, glyphOf,
7 parseGraphLog, parseRefs, parseStatus, relativeTime, widthOf,
8} from './parse'
9
10const PANE = 'git-graph'
11const TITLE = 'Git graph'
12const logAtom = atom({ plugin: 'git-graph', key: 'log' } as const, { lines: [] } as GraphLog)
13const offsetAtom = atom({ plugin: 'git-graph', key: 'offset' } as const, 0)
14
15// graph 열마다 색을 돌려 branch 선을 구분한다.
16const LANE_COLORS = ['cyan', 'magenta', 'yellow', 'green', 'blue', 'red'] as const
17
18const REF_COLORS: Record<RefKind, string> = {
19 head: 'cyan',
20 local: 'green',
21 remote: 'red',
22 tag: 'yellow',
23 other: 'magenta',
24}
25
26type Piece = { text: string; color?: string; bold?: boolean; dimColor?: boolean }
27
28async function readStatus($: EngineInterface): Promise<RepoStatus | undefined> {
29 try {
30 const { exitCode, stdout } = await $.process.run(GIT_STATUS_ARGV, { timeoutMs: 10_000 })
31 return exitCode === 0 ? parseStatus(stdout) : undefined
32 } catch {
33 return undefined
34 }
35}
36
37async function refresh($: EngineInterface): Promise<GraphLog> {
38 let log: GraphLog
39 try {
40 const [{ exitCode, stdout, stderr }, status] = await Promise.all([
41 $.process.run(GIT_LOG_ARGV, { timeoutMs: 10_000 }),
42 readStatus($),
43 ])
44 log = exitCode === 0
45 ? { lines: parseGraphLog(stdout), status, loadedAt: Date.now() }
46 : { lines: [], error: stderr.trim() || `git log exited with ${exitCode}`, loadedAt: Date.now() }
47 } catch (err) {
48 log = { lines: [], error: String(err), loadedAt: Date.now() }
49 }
50 await update($, logAtom, () => log)
51 return log
52}
53
54const paneOf = async ($: EngineInterface) =>
55 (await $.ui.panes()).find(pane => pane.id === PANE)
56
57// 다른 탭 뒤에 있어도 열려 있으면 graph 를 최신으로 유지한다.
58const isOpen = async ($: EngineInterface) => (await paneOf($)) !== undefined
59
60// header 의 상태 조각: branch, upstream 대비 ahead/behind, 변경 파일 수, 마지막 갱신 시각.
61const statusPieces = (status: RepoStatus | undefined, loadedAt: number | undefined): Piece[] => {
62 const pieces: Piece[] = []
63 if (status) {
64 pieces.push({ text: status.branch ?? '(detached)', color: 'cyan', bold: true })
65 if (status.upstream) {
66 if (status.ahead) pieces.push({ text: ` ↑${status.ahead}`, color: 'green' })
67 if (status.behind) pieces.push({ text: ` ↓${status.behind}`, color: 'red' })
68 if (!status.ahead && !status.behind) pieces.push({ text: ' =', dimColor: true })
69 }
70 pieces.push(status.changed
71 ? { text: ` ● ${status.changed} changed`, color: 'yellow' }
72 : { text: ' ✓ clean', color: 'green' })
73 }
74 if (loadedAt) pieces.push({ text: `${pieces.length ? ' · ' : ''}updated ${clockTime(loadedAt)}`, dimColor: true })
75 return pieces
76}
77
78export const register: Register = on => {
79 on('session.start', async ($, e, next) => {
80 await $.command.register({
81 name: 'git-graph',
82 description: 'Toggle the side pane showing the git log of the working directory as a graph',
83 })
84 // reload 로 session.start 가 다시 불려도 pane 이 열려 있을 때만 읽는다.
85 if (await isOpen($).catch(() => false)) void refresh($)
86
87 return next(e)
88 })
89
90 // /git-graph 는 pane 을 열고 닫는 toggle 이다. 세션 시작 시 자동으로 열지 않는다.
91 // 다른 탭 뒤에 열려 있으면 닫지 않고 앞으로 가져온다.
92 on('command.run', { command: 'git-graph' }, async $ => {
93 const pane = await paneOf($)
94 if (pane?.isShown) {
95 await $.ui.close({ id: PANE })
96 return { text: 'Git graph pane closed.' }
97 }
98 if (pane) {
99 // 열린 id 를 다시 open 하면 제목만 바뀌고 탭은 그대로라, 닫았다 열어 맨 앞 탭으로 올린다.
100 const log = await refresh($)
101 await $.ui.close({ id: PANE })
102 await $.ui.open({ id: PANE, title: TITLE })
103 return { text: log.error ? `Git graph: ${log.error}` : 'Git graph pane brought to front.' }
104 }
105 await update($, offsetAtom, () => 0)
106 const log = await refresh($)
107 await $.ui.open({ id: PANE, title: TITLE })
108
109 return { text: log.error ? `Git graph: ${log.error}` : `Git graph pane opened (${log.lines.length} lines).` }
110 })
111
112 // graph 를 바꾸는 git 명령이 Bash 로 실행되고 pane 이 열려 있으면 다시 읽는다.
113 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
114 const ran = await next(e)
115 if (e.tool === 'Bash' && changesGraph(e.command) && await isOpen($).catch(() => false)) void refresh($)
116
117 return ran
118 })
119
120 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
121 const { Box, Text, Button } = $.ui.resolve(e)
122 const log = await read($, logAtom)
123 const offset = await read($, offsetAtom)
124 const columns = Math.max(20, e.props.bodyColumns ?? e.viewport?.columns ?? 80)
125 const room = Math.max(5, (e.props.scroll?.bodyRows ?? (e.viewport?.rows ?? 30) - 4) - 3)
126 const maxOffset = Math.max(0, log.lines.length - room)
127 const start = Math.min(offset, maxOffset)
128 const shown = log.lines.slice(start, start + room)
129 const now = Date.now()
130
131 const scroll = (delta: number) => () =>
132 void update($, offsetAtom, n => Math.min(maxOffset, Math.max(0, (n ?? 0) + delta)))
133
134 const draw = (pieces: Piece[], key: string) =>
135 pieces.map((p, i) => <Text key={`${key}${i}`} color={p.color} bold={p.bold} dimColor={p.dimColor}>{p.text}</Text>)
136
137 const row = (line: GraphLine, i: number) => {
138 const graph = [...line.graph].map((ch, col) =>
139 ch === ' '
140 ? <Text>{ch}</Text>
141 : <Text color={LANE_COLORS[Math.floor(col / 2) % LANE_COLORS.length]} bold={ch === '*'}>{glyphOf(ch, line.isMerge)}</Text>,
142 )
143 if (!line.hash) return <Box key={`l${start + i}`} flexDirection="row">{graph}</Box>
144
145 const refs = line.refs ? parseRefs(line.refs) : []
146 const isHead = refs.some(ref => ref.kind === 'head')
147 let left = columns - widthOf(line.graph) - line.hash.length - 1
148
149 const refPieces: Piece[] = refs.length === 0 ? [] : [
150 { text: '(', dimColor: true },
151 ...refs.flatMap((ref, n): Piece[] => [
152 ...(n > 0 ? [{ text: ', ', dimColor: true }] : []),
153 { text: ref.name, color: REF_COLORS[ref.kind], bold: ref.kind === 'head' || ref.kind === 'local' },
154 ]),
155 { text: ') ', dimColor: true },
156 ]
157 const fitted = fitPieces(refPieces, Math.max(0, left))
158 left -= fitted.reduce((sum, p) => sum + widthOf(p.text), 0)
159
160 const meta = ` ${line.time ? relativeTime(line.time, now) : ''} ${line.author ?? ''}`.trimEnd()
161 const subject = cutToWidth(line.subject ?? '', Math.max(0, left - (left > 40 ? widthOf(meta) : 0)))
162 left -= widthOf(subject)
163 const tail = left >= widthOf(meta) ? meta : ''
164
165 return (
166 <Box key={`l${start + i}`} flexDirection="row">
167 {graph}
168 <Text color="yellow" inverse={isHead}>{line.hash}</Text>
169 <Text> </Text>
170 {draw(fitted, 'r')}
171 <Text bold={isHead}>{subject}</Text>
172 {tail && <Text dimColor>{tail}</Text>}
173 </Box>
174 )
175 }
176
177 const status = statusPieces(log.status, log.loadedAt)
178
179 return (
180 <Box flexDirection="column">
181 <Box flexDirection="row" gap={1}>
182 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => void refresh($)} />
183 <Button key="newer" label="Newer" hotkey="k" onPress={scroll(-room)} />
184 <Button key="older" label="Older" hotkey="j" onPress={scroll(room)} />
185 <Text dimColor>
186 {log.lines.length > 0 ? `${start + 1}-${start + shown.length}/${log.lines.length}` : ''}
187 </Text>
188 </Box>
189 {status.length > 0 && <Box flexDirection="row">{draw(fitPieces(status, columns), 's')}</Box>}
190 {log.error && <Text color="red">{log.error}</Text>}
191 {!log.error && log.lines.length === 0 && <Text dimColor>Loading git log…</Text>}
192 {shown.map(row)}
193 </Box>
194 )
195 })
196}
197hooks/parse.ts 142 lines1import type { GraphLine, Ref, RepoStatus } from '../types'
2
3const SEP = '\x1f'
4
5export const GIT_LOG_ARGV = [
6 'git', 'log', '--graph', '--all', '--decorate=full', '--color=never',
7 `--format=${SEP}%h${SEP}%P${SEP}%D${SEP}%s${SEP}%an${SEP}%at`, '-n', '300',
8] as const
9
10export const GIT_STATUS_ARGV = ['git', 'status', '--porcelain=v2', '--branch'] as const
11
12// `git log --graph` 출력 한 줄을 graph 접두부와 commit 필드로 나눈다.
13// commit 이 없는 줄(분기·병합 연결선)은 graph 만 가진다.
14export const parseGraphLog = (stdout: string): GraphLine[] =>
15 stdout
16 .split('\n')
17 .filter(line => line.length > 0)
18 .map(line => {
19 const at = line.indexOf(SEP)
20 if (at < 0) return { graph: line.trimEnd() }
21 const [hash, parents, refs, subject, author, time] = line.slice(at + 1).split(SEP)
22 return {
23 graph: line.slice(0, at),
24 hash,
25 refs: refs || undefined,
26 subject,
27 author,
28 time: Number(time) || undefined,
29 isMerge: (parents ?? '').trim().split(/\s+/).length > 1,
30 }
31 })
32
33// `--decorate=full` 의 `%D` 를 종류별 ref 로 나눈다.
34export const parseRefs = (refs: string): Ref[] =>
35 refs.split(', ').filter(Boolean).map((ref): Ref => {
36 if (ref === 'HEAD') return { name: 'HEAD', kind: 'head' }
37 if (ref.startsWith('HEAD -> refs/heads/')) return { name: `HEAD -> ${ref.slice(19)}`, kind: 'head' }
38 if (ref.startsWith('tag: refs/tags/')) return { name: `tag: ${ref.slice(15)}`, kind: 'tag' }
39 if (ref.startsWith('refs/heads/')) return { name: ref.slice(11), kind: 'local' }
40 if (ref.startsWith('refs/remotes/')) return { name: ref.slice(13), kind: 'remote' }
41 return { name: ref.replace(/^refs\//, ''), kind: 'other' }
42 })
43
44// `git status --porcelain=v2 --branch` 에서 branch·upstream·ahead/behind·변경 파일 수를 읽는다.
45export const parseStatus = (stdout: string): RepoStatus => {
46 const status: RepoStatus = { ahead: 0, behind: 0, changed: 0 }
47 for (const line of stdout.split('\n')) {
48 if (line.startsWith('# branch.head ')) {
49 const head = line.slice(14)
50 if (head !== '(detached)') status.branch = head
51 } else if (line.startsWith('# branch.upstream ')) {
52 status.upstream = line.slice(18)
53 } else if (line.startsWith('# branch.ab ')) {
54 const [ahead, behind] = line.slice(12).split(' ')
55 status.ahead = Math.abs(Number(ahead)) || 0
56 status.behind = Math.abs(Number(behind)) || 0
57 } else if (line.length > 0 && !line.startsWith('#')) {
58 status.changed += 1
59 }
60 }
61 return status
62}
63
64// commit graph 를 바꾸는 git 하위 명령. status·diff·log 같은 읽기 전용 명령은 제외한다.
65const MUTATING_GIT =
66 /\bgit\s+(?:-\S+\s+(?:\S+\s+)?)*(?:commit|checkout|switch|merge|rebase|reset|pull|fetch|push|branch|tag|cherry-pick|revert|stash|am|restore|worktree|clone|init|gc|filter-branch)\b/
67
68export const changesGraph = (command: string): boolean => MUTATING_GIT.test(command)
69
70// graph 접두부의 ASCII 문자를 box-drawing 글리프로 바꾼다. 모두 1셀 폭이다.
71const GLYPHS: Record<string, string> = { '|': '│', '/': '╱', '\\': '╲', '-': '─' }
72
73export const glyphOf = (ch: string, isMerge = false): string =>
74 ch === '*' ? (isMerge ? '◉' : '●') : GLYPHS[ch] ?? ch
75
76// commit 시각을 현재 기준 상대 시간으로 줄여 쓴다.
77export const relativeTime = (seconds: number, nowMs: number): string => {
78 const diff = Math.max(0, Math.floor(nowMs / 1000 - seconds))
79 if (diff < 60) return 'just now'
80 const units: [number, string][] = [[31_536_000, 'y'], [2_592_000, 'mo'], [604_800, 'w'], [86_400, 'd'], [3_600, 'h'], [60, 'm']]
81 const [size, unit] = units.find(([size]) => diff >= size)!
82 return `${Math.floor(diff / size)}${unit} ago`
83}
84
85const pad2 = (n: number) => String(n).padStart(2, '0')
86
87export const clockTime = (ms: number): string => {
88 const d = new Date(ms)
89 return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
90}
91
92// 터미널 셀 기준 폭: 한글·CJK·전각 문자는 2칸.
93const cellWidth = (ch: string): number => {
94 const cp = ch.codePointAt(0) ?? 0
95 const isWide =
96 (cp >= 0x1100 && cp <= 0x115f) ||
97 (cp >= 0x2e80 && cp <= 0xa4cf) ||
98 (cp >= 0xac00 && cp <= 0xd7a3) ||
99 (cp >= 0xf900 && cp <= 0xfaff) ||
100 (cp >= 0xfe30 && cp <= 0xfe4f) ||
101 (cp >= 0xff00 && cp <= 0xff60) ||
102 (cp >= 0xffe0 && cp <= 0xffe6) ||
103 (cp >= 0x1f300 && cp <= 0x1faff)
104 return isWide ? 2 : 1
105}
106
107export const widthOf = (text: string): number =>
108 [...text].reduce((sum, ch) => sum + cellWidth(ch), 0)
109
110// 셀 폭 `room` 안에 들어가도록 자르고, 잘렸으면 끝에 `…` 을 붙인다.
111export const cutToWidth = (text: string, room: number): string => {
112 if (room <= 0) return ''
113 if (widthOf(text) <= room) return text
114 let out = ''
115 let used = 0
116 for (const ch of text) {
117 const w = cellWidth(ch)
118 if (used + w > room - 1) break
119 out += ch
120 used += w
121 }
122 return `${out}…`
123}
124
125// 색이 다른 조각들을 이어 붙인 문자열을 `room` 셀 안에 맞춘다. 넘치는 조각은 잘리고 그 뒤는 버린다.
126export const fitPieces = <P extends { text: string }>(pieces: P[], room: number): P[] => {
127 const out: P[] = []
128 let left = room
129 for (const piece of pieces) {
130 const w = widthOf(piece.text)
131 if (w <= left) {
132 out.push(piece)
133 left -= w
134 continue
135 }
136 const cut = cutToWidth(piece.text, left)
137 if (cut) out.push({ ...piece, text: cut })
138 break
139 }
140 return out
141}
142types/index.d.ts 37 lines1export type GraphLine = {
2 graph: string
3 hash?: string
4 refs?: string
5 subject?: string
6 author?: string
7 // author date, unix seconds
8 time?: number
9 isMerge?: boolean
10}
11
12export type RefKind = 'head' | 'local' | 'remote' | 'tag' | 'other'
13
14export type Ref = { name: string; kind: RefKind }
15
16export type RepoStatus = {
17 // undefined 면 detached HEAD
18 branch?: string
19 upstream?: string
20 ahead: number
21 behind: number
22 changed: number
23}
24
25export type GraphLog = {
26 lines: GraphLine[]
27 status?: RepoStatus
28 error?: string
29 loadedAt?: number
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 'git-graph': { log: GraphLog; offset: number }
35 }
36}
37