작업 목록·병렬 에이전트 패널, 보안 파일 변경 확인, git 워크트리·PR 템플릿 커맨드

Claude Code에서 쓰는 mod(함수 훅 플러그인) 모음 마켓플레이스입니다.
| mod | 설명 |
|---|---|
| work-hub | 작업 목록·병렬 에이전트 패널, 보안 파일 변경 확인, git 워크트리·PR 템플릿 커맨드 |
Claude Code mod API는 early access라 Claude Code 버전에 따라 동작이 바뀔 수 있습니다. (개발 기준: Claude Code 2.1.308)
mod(함수 훅)는 아직 early access라, 설치한 플러그인의 mod는 기본적으로 로드되지 않습니다. ~/.claude/settings.json에 아래 env를 추가하세요(이미 env가 있으면 키만 추가):
{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
}
설정 후 새로 시작한 세션부터 적용됩니다. 설정 없이 /hub를 실행하면 Unknown command: /hub가 나옵니다.
설정 파일 대신 환경변수로 켜도 됩니다(Windows는 setx CLAUDE_CODE_ENABLE_FUNCTION_HOOKS 1 후 새 터미널, macOS·Linux는 셸 프로필에 export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1).
터미널 세션의 프롬프트에서:
/plugin install work-hub --marketplace no1fc/claude-mods
Add marketplace?에 y, 스코프는 user(기본값)를 고르면 바로 켜집니다.
셸에서:
claude plugin install work-hub --marketplace no1fc/claude-mods
데스크톱 앱(/plugin이 동작하지 않음): 프롬프트 입력창 옆 + → Plugins → Add marketplace에 no1fc/claude-mods → Add plugin.
Releases의 work-hub-<버전>.zip을 받아 원하는 폴더에 풀고:
claude --plugin-dir <압축을 푼 폴더>/work-hub
claude plugin marketplace update claude-mods
claude plugin update work-hub
그다음 실행 중인 세션에서 /reload-plugins.
| 증상 | 확인할 것 |
|---|---|
Unknown command: /hub | 사전 설정의 CLAUDE_CODE_ENABLE_FUNCTION_HOOKS가 빠졌는지. claude --debug로 실행하면 디버그 로그에 hooks module work-hub@claude-mods not loaded 사유가 남습니다 |
Cannot add marketplace "claude-mods": its network source differs ... | 이미 같은 이름(claude-mods)으로 다른 출처(로컬 폴더 등)가 등록된 상태. 그 출처로 설치돼 있으면 그대로 쓰거나, claude plugin marketplace remove claude-mods 후 다시 설치 |
Git Bash에서 claude -p "/hub"가 경로로 바뀜 | MSYS_NO_PATHCONV=1 claude -p "/hub" |
/hubdocs/task_list.md 체크박스(- [ ] / - [x])를 읽어 진행률, ▶ 다음 작업, 남은 작업을 보여줍니다. 5초마다 다시 읽습니다.Claude가 아래 파일을 Edit·Write·NotebookEdit로 변경하거나 Bash·PowerShell 명령에서 다루려 하면 허용 / 차단 확인을 받습니다. 확인 창을 닫으면 차단됩니다.
.env, .env.* (.env.example·.sample·.template·.dist는 제외)*.pem, *.key, *.p12, *.pfx, *.keystore, *.jksid_rsa, id_ed25519 등 SSH 키, credentials*, secret(s).*| 커맨드 | 동작 |
|---|---|
/worktree | git worktree list |
/worktree <브랜치> | 저장소 옆에 <저장소>-<브랜치> 폴더로 워크트리 생성 (브랜치가 없으면 새로 만듦) |
/pr-template | 기본 브랜치 대비 커밋·변경 파일로 PR 템플릿(Summary / Changes / Changed files / Test plan) 출력 |
/pr | 위 템플릿을 바탕으로 Claude가 PR 본문을 작성 (PR 생성·push는 하지 않음) |
기본 브랜치는 origin/HEAD → main → master 순으로 찾습니다.
이 저장소를 로컬 마켓플레이스로 등록하면 폴더를 직접 읽으므로, 수정 후 /reload-plugins만 하면 반영됩니다.
claude plugin marketplace add <이 저장소 경로>
claude plugin install work-hub@claude-mods
검증·테스트:
claude plugin validate work-hub
claude plugin test work-hub
릴리스: work-hub/.claude-plugin/plugin.json의 version을 올리고 커밋·push한 뒤 v<버전> 태그로 GitHub 릴리스를 만듭니다.
구조:
.claude-plugin/marketplace.json # 마켓플레이스 목록
work-hub/
.claude-plugin/plugin.json # mod 매니페스트
hooks/hooks.json # 훅 모듈 경로
hooks/register.tsx # 훅 연결 ($ 사용 코드)
hooks/lib.ts # 순수 로직 (작업 파싱, 보안 파일 판별, PR 템플릿)
hooks/work-hub.test.tsx # claude plugin testhooks/register.tsx 191 lines1import type { Register } from 'claude-code'
2
3import {
4 CREATE_TASKS_PROMPT,
5 OVERVIEW_PROMPT,
6 TASK_FILE,
7 buildPrPrompt,
8 buildPrTemplate,
9 isValidBranch,
10 parseTasks,
11 sensitiveTarget,
12 taskPrompt,
13 worktreePath,
14} from './lib'
15import type { GitInfo, Task } from './lib'
16
17const PANE = 'work-hub'
18const TITLE = 'Work Hub'
19// ponytail: the pane polls every 5s for task-file and agent changes; hook the
20// edits and agent events instead if the redraw ever shows up as a cost.
21const REFRESH_MS = 5000
22const ALLOW = '허용'
23const BLOCK = '차단'
24const ACTIVE_AGENT = new Set(['pending', 'running', 'waiting', 'idle'])
25
26async function git($, args: string[]) {
27 const ran = await $.process.run(['git', ...args], { timeoutMs: 15000 })
28 return { isOk: ran.exitCode === 0, out: ran.stdout.trim(), err: ran.stderr.trim() }
29}
30
31async function baseBranch($): Promise<string> {
32 const origin = await git($, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
33 if (origin.isOk && origin.out) return origin.out
34 for (const name of ['main', 'master']) {
35 if ((await git($, ['rev-parse', '--verify', '--quiet', name])).isOk) return name
36 }
37 return 'main'
38}
39
40async function gitInfo($): Promise<GitInfo | string> {
41 if (!(await git($, ['rev-parse', '--is-inside-work-tree'])).isOk) {
42 return '현재 폴더는 git 저장소가 아닙니다.'
43 }
44 const base = await baseBranch($)
45 const branch = (await git($, ['branch', '--show-current'])).out || 'HEAD'
46 const commits = await git($, ['log', '--oneline', '--no-merges', `${base}..HEAD`])
47 const stat = await git($, ['diff', '--stat', `${base}...HEAD`])
48 const status = await git($, ['status', '--short'])
49 return { branch, base, commits: commits.out, stat: stat.out, status: status.out }
50}
51
52async function readTasks($): Promise<Task[] | undefined> {
53 try {
54 return parseTasks(await $.fs.read(TASK_FILE))
55 } catch {
56 return undefined
57 }
58}
59
60async function runWorktree($, args: string): Promise<string> {
61 const branch = args.trim()
62 if (branch === '') {
63 const list = await git($, ['worktree', 'list'])
64 return list.isOk ? list.out : `git worktree list 실패: ${list.err}`
65 }
66 if (!isValidBranch(branch)) return `브랜치 이름이 올바르지 않습니다: ${branch}`
67
68 const root = await git($, ['rev-parse', '--show-toplevel'])
69 if (!root.isOk) return '현재 폴더는 git 저장소가 아닙니다.'
70 const path = worktreePath(root.out, branch)
71 const exists = (await git($, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])).isOk
72 const added = await git(
73 $,
74 exists ? ['worktree', 'add', path, branch] : ['worktree', 'add', '-b', branch, path],
75 )
76 if (!added.isOk) return `워크트리 생성 실패: ${added.err}`
77 return `워크트리를 만들었습니다: ${path} (브랜치 ${branch}${exists ? '' : ', 새로 생성'}). 이 폴더로 새 세션을 열어 작업하세요.`
78}
79
80export const register: Register = on => {
81 on('session.start', async ($, e, next) => {
82 const commands = [
83 { name: 'hub', description: 'Work Hub 패널(작업 목록·에이전트)을 연다' },
84 { name: 'worktree', description: 'git 워크트리 생성(인자: 브랜치명) 또는 목록(인자 없음)', argumentHint: '[branch]' },
85 { name: 'pr-template', description: '현재 브랜치 커밋·변경 파일로 PR 템플릿을 출력한다' },
86 { name: 'pr', description: 'PR 템플릿을 바탕으로 Claude가 PR 본문을 작성한다' },
87 ]
88 for (const command of commands) await $.command.register(command)
89 $.clock.every(REFRESH_MS, () => $.ui.invalidate('ui.render'))
90
91 return next(e)
92 })
93
94 on('command.run', { command: 'hub' }, async $ => {
95 await $.ui.open({ id: PANE, title: TITLE })
96 return { text: 'Work Hub 패널을 열었습니다.' }
97 })
98
99 on('command.run', { command: 'worktree' }, async ($, e) => ({
100 text: await runWorktree($, e.args),
101 }))
102
103 on('command.run', { command: 'pr-template' }, async $ => {
104 const info = await gitInfo($)
105 return { text: typeof info === 'string' ? info : buildPrTemplate(info) }
106 })
107
108 on('command.run', { command: 'pr' }, async $ => {
109 const info = await gitInfo($)
110 if (typeof info === 'string') return { text: info }
111 await $.prompt.submit({ text: buildPrPrompt(info) })
112 return { text: `${info.branch} → ${info.base} PR 본문 작성을 요청했습니다.` }
113 })
114
115 on('tool.call', async ($, e, next) => {
116 const target = sensitiveTarget(String(e.tool), e as unknown as Record<string, unknown>)
117 if (target === undefined) return next(e)
118 const answer = await $.ui.ask(
119 `${e.tool}이(가) 보안 파일 ${target}에 접근·변경하려고 합니다. 허용할까요?`,
120 { header: '보안 파일', options: [ALLOW, BLOCK] },
121 )
122 return answer === ALLOW ? next(e) : { deny: `work-hub: ${target} 접근을 차단했습니다.` }
123 }).catch(($, e, next) =>
124 next.called ? next(e) : { deny: 'work-hub: 보안 파일 확인을 받지 못해 차단했습니다.' },
125 )
126
127 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
128 const { Box, Text, Button } = $.ui.resolve(e)
129 const tasks = await readTasks($)
130 const agents = await $.agent.list()
131 const viewing = e.props.view?.agentId
132 const fill = (text: string) => async () => {
133 const filled = await $.prompt.fill({ text }).catch(() => ({ isFilled: false }))
134 if (!filled.isFilled) $.ui.toast('프롬프트 입력창에 넣지 못했습니다. 열린 대화상자를 닫고 다시 눌러주세요.')
135 }
136 const remaining = (tasks ?? []).filter(task => !task.isDone)
137 const nextTask = remaining[0]
138
139 return (
140 <Box flexDirection="column" gap={1}>
141 <Box flexDirection="column">
142 <Text bold>작업 ({TASK_FILE})</Text>
143 {tasks === undefined && <Text dimColor>작업 목록 파일이 없습니다.</Text>}
144 {tasks === undefined && (
145 <Button key="create-tasks" plain onPress={fill(CREATE_TASKS_PROMPT)}>
146 + 작업 목록 만들기
147 </Button>
148 )}
149 {tasks !== undefined && (
150 <Button key="overview" plain onPress={fill(OVERVIEW_PROMPT)}>
151 {`진행 ${tasks.length - remaining.length}/${tasks.length} · 전체 현황 보기`}
152 </Button>
153 )}
154 {nextTask && (
155 <Button key="next-task" plain onPress={fill(taskPrompt(nextTask))}>
156 {`▶ 다음: ${nextTask.text}`}
157 </Button>
158 )}
159 {remaining.slice(1).map((task, i) => (
160 <Button key={`task-${i}`} plain onPress={fill(taskPrompt(task))}>
161 {`· ${task.text}`}
162 </Button>
163 ))}
164 {tasks !== undefined && remaining.length === 0 && <Text dimColor>남은 작업이 없습니다.</Text>}
165 </Box>
166 <Box flexDirection="column">
167 <Text bold>병렬 에이전트 ({agents.filter(a => ACTIVE_AGENT.has(a.status)).length} 진행 중)</Text>
168 {agents.length === 0 && <Text dimColor>이 세션에서 실행된 에이전트가 없습니다.</Text>}
169 {agents.map(agent => (
170 <Button
171 key={`agent-${agent.id}`}
172 plain
173 onPress={async () => {
174 const moved = await $.ui.view({ agentId: agent.id })
175 if (moved.deny) $.ui.toast(`대화를 열 수 없습니다: ${moved.deny}`)
176 }}
177 >
178 {`${agent.id === viewing ? '● ' : ''}${agent.description || agent.type} · ${agent.status}`}
179 </Button>
180 ))}
181 {viewing && (
182 <Button key="main-view" plain onPress={() => void $.ui.view({})}>
183 ← 메인 대화로
184 </Button>
185 )}
186 </Box>
187 </Box>
188 )
189 })
190}
191hooks/lib.ts 124 lines1// Pure helpers: no $ here (the validator follows $ only within register.tsx).
2
3export type Task = { text: string; isDone: boolean }
4
5export type GitInfo = {
6 branch: string
7 base: string
8 commits: string
9 stat: string
10 status: string
11}
12
13export const TASK_FILE = 'docs/task_list.md'
14
15const TASK_LINE = /^\s*(?:[-*+]|\d+\.)\s+\[( |x|X)\]\s+(.+?)\s*$/
16
17export function parseTasks(markdown: string): Task[] {
18 return markdown.split(/\r?\n/).flatMap(line => {
19 const match = TASK_LINE.exec(line)
20 return match ? [{ text: match[2], isDone: match[1] !== ' ' }] : []
21 })
22}
23
24// Templates of secrets are edited routinely, so they pass without a question.
25const SAFE_NAMES = /^\.env\.(example|sample|template|dist)$/i
26const SENSITIVE_NAMES = [
27 /^\.env$/i,
28 /^\.env\..+$/i,
29 /\.(pem|key|p12|pfx|keystore|jks)$/i,
30 /^id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i,
31 /^credentials(\.\w+)?$/i,
32 /^secrets?\.\w+$/i,
33]
34
35function baseName(path: string): string {
36 return path.split(/[\\/]/).pop() ?? path
37}
38
39export function isSensitivePath(path: string): boolean {
40 const name = baseName(path.trim().replace(/^['"]|['"]$/g, ''))
41 if (SAFE_NAMES.test(name)) return false
42 return SENSITIVE_NAMES.some(pattern => pattern.test(name))
43}
44
45// Returns the sensitive file a tool call touches, or undefined. For shells
46// every word of the command is checked: a read of .env is asked about too.
47export function sensitiveTarget(
48 tool: string,
49 input: Record<string, unknown>,
50): string | undefined {
51 if (tool === 'Edit' || tool === 'Write') {
52 const path = String(input.file_path ?? '')
53 return isSensitivePath(path) ? path : undefined
54 }
55 if (tool === 'NotebookEdit') {
56 const path = String(input.notebook_path ?? '')
57 return isSensitivePath(path) ? path : undefined
58 }
59 if (tool === 'Bash' || tool === 'PowerShell') {
60 const words = String(input.command ?? '').split(/[\s;|&<>()=,]+/)
61 return words.find(word => word !== '' && isSensitivePath(word))
62 }
63 return undefined
64}
65
66const BRANCH = /^(?!-)(?!.*\.\.)[A-Za-z0-9._\/-]+$/
67
68export function isValidBranch(name: string): boolean {
69 return BRANCH.test(name) && !name.endsWith('/') && !name.endsWith('.lock')
70}
71
72export function worktreePath(root: string, branch: string): string {
73 const parts = root.split(/[\\/]/)
74 const repo = parts.pop() ?? 'repo'
75 return [...parts, `${repo}-${branch.replace(/\//g, '-')}`].join('/')
76}
77
78const ATTRIBUTION = '🤖 Generated with [Claude Code](https://claude.com/claude-code)'
79
80export function buildPrTemplate(info: GitInfo): string {
81 const commits = info.commits.trim() || '(커밋 없음)'
82 const stat = info.stat.trim() || '(변경 없음)'
83 return [
84 `## Summary`,
85 `<!-- ${info.branch} → ${info.base}: 이 PR이 하는 일을 1~3줄로 -->`,
86 `- `,
87 ``,
88 `## Changes`,
89 ...commits.split(/\r?\n/).map(line => `- ${line}`),
90 ``,
91 `## Changed files`,
92 '```',
93 stat,
94 '```',
95 ``,
96 `## Test plan`,
97 `- [ ] `,
98 ``,
99 ATTRIBUTION,
100 ].join('\n')
101}
102
103export function buildPrPrompt(info: GitInfo): string {
104 const pending = info.status.trim()
105 ? `\n\n커밋되지 않은 변경이 있습니다(PR에 포함되지 않음):\n${info.status.trim()}`
106 : ''
107 return [
108 `현재 브랜치 \`${info.branch}\`의 작업으로 \`${info.base}\` 대상 PR 본문을 작성해줘.`,
109 `\`git log ${info.base}..HEAD\`와 \`git diff ${info.base}...HEAD\`를 직접 분석해서`,
110 `아래 템플릿의 Summary·Changes·Test plan을 구체적으로 채우고, 마지막 줄은 그대로 둬.`,
111 `PR 생성이나 push는 하지 말고 본문만 보여줘.${pending}`,
112 ``,
113 buildPrTemplate(info),
114 ].join('\n')
115}
116
117export function taskPrompt(task: Task): string {
118 return `${TASK_FILE}의 작업 "${task.text}"의 현재 진행 상황과, 완료하려면 해야 할 내용을 정리해줘.`
119}
120
121export const OVERVIEW_PROMPT = `${TASK_FILE} 기준으로 전체 작업 진행 상황, 다음 작업, 남은 작업을 정리해줘.`
122
123export const CREATE_TASKS_PROMPT = `현재 프로젝트를 분석해서 ${TASK_FILE}를 만들어줘. 각 작업은 "- [ ] 작업 내용" 체크박스 한 줄로, 완료된 작업은 "- [x]"로 표시해줘.`
124