feat: cc-hook v1.0 - Claude Code 钩子服务
- Go HTTP 服务,监听 :8082 - 支持 Notification 和 Stop 事件 - 转发至 Gotify 推送通知 - Docker 多阶段构建 - 环境变量配置 - 详细中文 README 文档
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
|||||||
|
FROM golang:1.22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod ./
|
||||||
|
COPY *.go ./
|
||||||
|
RUN go build -o cc-hook .
|
||||||
|
|
||||||
|
FROM alpine:latest
|
||||||
|
RUN apk --no-cache add ca-certificates
|
||||||
|
COPY --from=builder /app/cc-hook /usr/local/bin/
|
||||||
|
|
||||||
|
EXPOSE 8082
|
||||||
|
CMD ["cc-hook"]
|
||||||
@@ -1,3 +1,187 @@
|
|||||||
# cc-hook
|
# cc-hook
|
||||||
|
|
||||||
🤗 适用于 Claude Code 的钩子,包含任务完成提醒等功能
|
🤗 Claude Code 钩子服务,将 Claude Code 事件转发至 Gotify 推送通知。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- **实时通知**:当 Claude Code 需要关注时,立即收到手机推送
|
||||||
|
- **多事件支持**:Notification(权限请求、等待输入等)和 Stop(任务完成)
|
||||||
|
- **轻量部署**:单个 Docker 容器,资源占用极低
|
||||||
|
- **灵活配置**:通过环境变量自定义 Gotify 服务器和 Token
|
||||||
|
|
||||||
|
## 架构概览
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
A[Claude Code] -->|POST /hooks| B[cc-hook]
|
||||||
|
B -->|POST /message| C[Gotify]
|
||||||
|
C -->|推送通知| D[手机/客户端]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
- Docker 和 Docker Compose
|
||||||
|
- Claude Code 已安装
|
||||||
|
- Gotify 服务器(自建或公共)
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 克隆项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <your-repo-url> cc-hook
|
||||||
|
cd cc-hook
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 配置环境变量
|
||||||
|
|
||||||
|
编辑 `docker-compose.yml`,修改以下配置:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- GOTIFY_URL=http://your-gotify-server:port # Gotify 服务器地址
|
||||||
|
- GOTIFY_TOKEN=your-token-here # Gotify 应用 Token
|
||||||
|
- PORT=:8082 # 服务监听端口(默认 8082)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 配置 Claude Code
|
||||||
|
|
||||||
|
将以下内容添加到 `~/.claude/settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"Notification": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "curl -s -X POST http://localhost:8082/hooks -H 'Content-Type: application/json' -d @-"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "curl -s -X POST http://localhost:8082/hooks -H 'Content-Type: application/json' -d @-"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 支持的事件
|
||||||
|
|
||||||
|
### Notification 事件
|
||||||
|
|
||||||
|
| Matcher | 标题 | 优先级 | 说明 |
|
||||||
|
|---------|------|--------|------|
|
||||||
|
| `permission_prompt` | 需要权限 | 7 | Claude 需要你批准一个操作 |
|
||||||
|
| `idle_prompt` | 等待输入 | 5 | Claude 完成工作,等待下一步指令 |
|
||||||
|
| `auth_success` | 认证成功 | 3 | 身份验证完成 |
|
||||||
|
| 其他 | 通知 | 5 | 默认通知 |
|
||||||
|
|
||||||
|
### Stop 事件
|
||||||
|
|
||||||
|
| 条件 | 标题 | 优先级 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| 正常完成 | 任务完成 | 5 | Claude 完成了本轮回复 |
|
||||||
|
| 循环检测 | 循环停止 | 8 | Stop hook 连续触发多次,已自动停止 |
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
| 环境变量 | 默认值 | 说明 |
|
||||||
|
|----------|--------|------|
|
||||||
|
| `GOTIFY_URL` | `http://47.121.181.112:40266` | Gotify 服务器地址 |
|
||||||
|
| `GOTIFY_TOKEN` | `AiousrPBE4Cn04C` | Gotify 应用 Token |
|
||||||
|
| `PORT` | `:8082` | 服务监听端口 |
|
||||||
|
|
||||||
|
## API 端点
|
||||||
|
|
||||||
|
### POST /hooks
|
||||||
|
|
||||||
|
接收 Claude Code Hook 事件并转发至 Gotify。
|
||||||
|
|
||||||
|
**请求体示例:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "abc123",
|
||||||
|
"cwd": "/home/user/project",
|
||||||
|
"hook_event_name": "Notification",
|
||||||
|
"matcher": "permission_prompt"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
- 成功:`{"status":"ok"}`
|
||||||
|
- 忽略:`{"status":"ignored"}`
|
||||||
|
- 错误:HTTP 500
|
||||||
|
|
||||||
|
### GET /health
|
||||||
|
|
||||||
|
健康检查端点。
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
```json
|
||||||
|
{"status":"ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
### 直接运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
export GOTIFY_URL=http://your-gotify-server:port
|
||||||
|
export GOTIFY_TOKEN=your-token
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 健康检查
|
||||||
|
curl http://localhost:8082/health
|
||||||
|
|
||||||
|
# 模拟 Notification 事件
|
||||||
|
curl -X POST http://localhost:8082/hooks \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"session_id":"test","cwd":"/tmp","hook_event_name":"Notification","matcher":"permission_prompt"}'
|
||||||
|
|
||||||
|
# 模拟 Stop 事件
|
||||||
|
curl -X POST http://localhost:8082/hooks \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"session_id":"test","cwd":"/tmp","hook_event_name":"Stop"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
cc-hook/
|
||||||
|
├── config.go # 配置管理(环境变量读取)
|
||||||
|
├── gotify.go # Gotify HTTP 客户端
|
||||||
|
├── handler.go # Hook 事件处理器
|
||||||
|
├── main.go # HTTP 服务入口
|
||||||
|
├── Dockerfile # 多阶段 Docker 构建
|
||||||
|
├── docker-compose.yml # Docker Compose 部署配置
|
||||||
|
└── go.mod # Go 模块定义
|
||||||
|
```
|
||||||
|
|
||||||
|
## 扩展计划
|
||||||
|
|
||||||
|
- [ ] 数据库记录:将 Hook 事件写入数据库,便于复盘和分析
|
||||||
|
- [ ] 更多事件支持:Tool Use、Error 等事件类型
|
||||||
|
- [ ] 消息模板:自定义通知消息格式
|
||||||
|
- [ ] 多 Gotify 支持:同时推送到多个 Gotify 服务器
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
GotifyURL string
|
||||||
|
GotifyToken string
|
||||||
|
Port string
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig() Config {
|
||||||
|
return Config{
|
||||||
|
GotifyURL: getEnv("GOTIFY_URL", "http://47.121.181.112:40266"),
|
||||||
|
GotifyToken: getEnv("GOTIFY_TOKEN", "AiousrPBE4Cn04C"),
|
||||||
|
Port: getEnv("PORT", ":8082"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
cc-hook:
|
||||||
|
build: .
|
||||||
|
container_name: cc-hook
|
||||||
|
ports:
|
||||||
|
- "8082:8082"
|
||||||
|
environment:
|
||||||
|
- GOTIFY_URL=http://47.121.181.112:40266
|
||||||
|
- GOTIFY_TOKEN=AiousrPBE4Cn04C
|
||||||
|
- PORT=:8082
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SendMessage(gotifyURL, token, title, message string, priority int) error {
|
||||||
|
endpoint := fmt.Sprintf("%s/message?token=%s", gotifyURL, token)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("title", title)
|
||||||
|
form.Set("message", message)
|
||||||
|
form.Set("priority", fmt.Sprintf("%d", priority))
|
||||||
|
|
||||||
|
resp, err := http.Post(endpoint, "application/x-www-form-urlencoded", strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send gotify message: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("gotify returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClaudeCodeHookEvent represents the JSON payload from Claude Code hooks
|
||||||
|
type ClaudeCodeHookEvent struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
CWD string `json:"cwd"`
|
||||||
|
HookEventName string `json:"hook_event_name"`
|
||||||
|
ToolName string `json:"tool_name,omitempty"`
|
||||||
|
ToolInput map[string]interface{} `json:"tool_input,omitempty"`
|
||||||
|
Matcher string `json:"matcher,omitempty"`
|
||||||
|
StopHookActive bool `json:"stop_hook_active,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HookHandler struct {
|
||||||
|
config Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHookHandler(config Config) *HookHandler {
|
||||||
|
return &HookHandler{config: config}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookHandler) HandleHook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var event ClaudeCodeHookEvent
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
|
||||||
|
log.Printf("failed to decode hook event: %v", err)
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("received hook: event=%s, matcher=%s", event.HookEventName, event.Matcher)
|
||||||
|
|
||||||
|
var title, message string
|
||||||
|
var priority int
|
||||||
|
|
||||||
|
switch event.HookEventName {
|
||||||
|
case "Notification":
|
||||||
|
title, message, priority = h.handleNotification(event)
|
||||||
|
case "Stop":
|
||||||
|
title, message, priority = h.handleStop(event)
|
||||||
|
default:
|
||||||
|
log.Printf("ignored event: %s", event.HookEventName)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprintf(w, `{"status":"ignored"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SendMessage(h.config.GotifyURL, h.config.GotifyToken, title, message, priority); err != nil {
|
||||||
|
log.Printf("failed to send gotify message: %v", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("notification sent: %s", title)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprintf(w, `{"status":"ok"}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookHandler) handleNotification(event ClaudeCodeHookEvent) (title, message string, priority int) {
|
||||||
|
switch event.Matcher {
|
||||||
|
case "permission_prompt":
|
||||||
|
title = "Claude Code - 需要权限"
|
||||||
|
message = "Claude 需要你批准一个操作"
|
||||||
|
priority = 7
|
||||||
|
case "idle_prompt":
|
||||||
|
title = "Claude Code - 等待输入"
|
||||||
|
message = "Claude 完成工作,等待你的下一步指令"
|
||||||
|
priority = 5
|
||||||
|
case "auth_success":
|
||||||
|
title = "Claude Code - 认证成功"
|
||||||
|
message = "身份验证完成"
|
||||||
|
priority = 3
|
||||||
|
default:
|
||||||
|
title = "Claude Code - 通知"
|
||||||
|
message = "Claude Code 需要你的关注"
|
||||||
|
priority = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
return title, message, priority
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HookHandler) handleStop(event ClaudeCodeHookEvent) (title, message string, priority int) {
|
||||||
|
if event.StopHookActive {
|
||||||
|
// Avoid infinite loop: Stop hook triggered too many times
|
||||||
|
title = "Claude Code - 循环停止"
|
||||||
|
message = "Stop hook 连续触发多次,已自动停止"
|
||||||
|
priority = 8
|
||||||
|
} else {
|
||||||
|
title = "Claude Code - 任务完成"
|
||||||
|
message = "Claude 完成了本轮回复"
|
||||||
|
priority = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
return title, message, priority
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
config := LoadConfig()
|
||||||
|
|
||||||
|
handler := NewHookHandler(config)
|
||||||
|
|
||||||
|
http.HandleFunc("/hooks", handler.HandleHook)
|
||||||
|
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"status":"ok"}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("cc-hook service starting on %s", config.Port)
|
||||||
|
log.Printf("gotify endpoint: %s", config.GotifyURL)
|
||||||
|
|
||||||
|
if err := http.ListenAndServe(config.Port, nil); err != nil {
|
||||||
|
log.Fatalf("server failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user