diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8f8034e --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 8b7d469..65fb253 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,187 @@ # cc-hook -🤗 适用于 Claude Code 的钩子,包含任务完成提醒等功能 \ No newline at end of file +🤗 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 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 diff --git a/config.go b/config.go new file mode 100644 index 0000000..f4ec87e --- /dev/null +++ b/config.go @@ -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 +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..99466b1 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1cadad4 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module cc-hook + +go 1.22.2 diff --git a/gotify.go b/gotify.go new file mode 100644 index 0000000..fefb57a --- /dev/null +++ b/gotify.go @@ -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 +} diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..715114d --- /dev/null +++ b/handler.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..5ea5bf3 --- /dev/null +++ b/main.go @@ -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) + } +}