commit 55f40e015d5c565eb07d1a3b19171a56be12614a
Author: unclesam <2036253946@qq.com>
Date: Mon Aug 17 16:52:39 2026 +0800
feat: 初始化 git-air AI 代码评审 CLI 工具
新增多模型 LLM 客户端、Git diff 提取与过滤、配置管理及 pre-commit 钩子支持。
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e52f6a2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+# Binaries
+git-air
+git-air.exe
+dist/
+
+# Local sensitive configs
+.git-air.yaml
+
+# IDEs
+.idea/
+.vscode/
+*.swp
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..2a0f7a2
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 unclesam-ly
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..21d2d9f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,185 @@
+
+
+
+
+git-air 🍃
+
+
+ 纯 Go 编写、像空气一样轻盈极速的 Git 原生 AI 代码评审工具。
+ A lightweight, lightning-fast, Git-native AI code reviewer written in pure Go.
+
+
+
+
+
+
+
+
+---
+
+## 🌟 设计初衷与定位
+
+市面上有许多优秀的云端与 CI/CD 级别的 AI Code Review 平台(如各类 PR 自动审查 Bot),它们在团队协作和远程代码门禁上表现出色。
+
+**而 `git-air` 的定位是:将审查能力极致前置到开发者的本地终端(Pre-Commit / Local CLI)。**
+
+在代码正式 push 或发起 PR 之前,在本地敲一行命令,就能以零延迟、极简的方式先做一轮深度扫描,把潜在的并发隐患、逻辑缺陷与敏感信息拦截在本地,作为开发者最轻量顺手的日常命令行结对编程助手。
+
+- ⚡ **Git 原生极简体验**:无需改变任何工作习惯,在命令行敲 `git air` 即可秒级唤起。
+- 🧠 **全模型自由接入**:开箱即用支持 **Google Gemini、DeepSeek、本地离线 Ollama、OpenAI** 以及任何兼容 OpenAI 协议的自定义端点。
+- 🛡️ **智能降噪过滤**:自动剥离 `go.sum`、`package-lock.json`、`*.pb.go`、`*.gen.go` 等锁文件与自动生成代码,省 Token、防死循环。
+- 🎯 **严谨架构师准则**:直奔主题,聚焦并发竞态、死锁、SQL 注入、空指针、资源泄露,并直接提供修复参考代码。
+- 📋 **团队规范适配(`.airules`)**:在项目根目录放置 `.airules`,AI 自动加载团队专属架构规范并最高优先级执行。
+- 🪝 **一键 Pre-commit 钩子**:一行命令挂载 Git 提交前自动拦截,在代码上库前把好第一道质量关。
+
+---
+
+## 📦 安装方式
+
+### 方式 1:通过 Go 一键安装(推荐)
+```bash
+go install github.com/unclesam-ly/git-air@latest
+```
+
+### 方式 2:源码编译
+```bash
+git clone https://github.com/unclesam-ly/git-air.git
+cd git-air
+go build -o git-air .
+sudo mv git-air /usr/local/bin/
+```
+
+---
+
+## 🚀 快速上手
+
+### 1. 配置模型与 API Key
+
+`git-air` 原生预置了国内外所有主流大模型的官方端点与推荐模型,只需指定 `--provider` 即可秒级切换:
+
+```bash
+# 1. Google Gemini (推荐:极速且经济)
+git air config set --provider gemini --key "YOUR_KEY"
+
+# 2. Anthropic Claude (Claude-3.7 / 3.5 Sonnet)
+git air config set --provider claude --key "YOUR_KEY" --model anthropic/claude-3.7-sonnet
+
+# 3. xAI Grok (Grok-2 / Grok-3)
+git air config set --provider grok --key "YOUR_KEY" --model grok-2-latest
+
+# 4. DeepSeek (高推理能力 / 深度思考)
+git air config set --provider deepseek --key "YOUR_KEY" --model deepseek-chat
+
+# 5. 硅基流动 (SiliconFlow)
+git air config set --provider siliconflow --key "YOUR_KEY" --model deepseek-ai/DeepSeek-V3
+
+# 6. 通义千问 (Qwen / 阿里百炼)
+git air config set --provider qwen --key "YOUR_KEY" --model qwen-plus
+
+# 7. 智谱 AI (GLM-4)
+git air config set --provider zhipu --key "YOUR_KEY" --model glm-4-plus
+
+# 8. 月之暗面 (Kimi / Moonshot)
+git air config set --provider moonshot --key "YOUR_KEY"
+
+# 9. Groq (极致硬件加速推理)
+git air config set --provider groq --key "YOUR_KEY" --model llama-3.3-70b-versatile
+
+# 10. OpenRouter (全聚合模型网关)
+git air config set --provider openrouter --key "YOUR_KEY"
+
+# 11. 本地 100% 离线隐私模型 (Ollama)
+git air config set --provider ollama --model qwen2.5-coder
+
+# 12. OpenAI 官方 (GPT-4o / o3-mini)
+git air config set --provider openai --key "YOUR_KEY" --model gpt-4o-mini
+```
+
+查看当前配置状态:
+```bash
+git air config get
+```
+
+---
+
+### 2. 常用评审命令
+
+```bash
+# 评审当前已暂存(git add)的代码(默认行为)
+git air
+
+# 评审上一次 Commit 的代码
+git air HEAD~1
+
+# 评审特性分支与主干分支之间的所有差异
+git air main..feature-agent
+
+# 只评审指定文件的改动
+git air internal/service/chat.go
+
+# 临时指定模型或 Key 进行评审
+git air --provider deepseek --model deepseek-chat
+```
+
+---
+
+### 3. 一键安装 Git Pre-commit 拦截钩子
+
+在你的 Git 项目根目录下执行:
+```bash
+git air hook install
+```
+以后团队成员在执行 `git commit` 时,`git-air` 会在终端自动执行审查,若存在阻断性缺陷将自动提醒!
+
+如需卸载钩子:
+```bash
+git air hook uninstall
+```
+
+---
+
+## ⚙️ 配置文件说明
+
+`git-air` 支持多层配置覆盖,优先级从高到低为:
+1. **命令行 Flags**(如 `--key`, `--model`, `--provider`)
+2. **环境变量**(`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`)
+3. **项目级配置**(当前 Git 项目根目录下的 `config.yaml` 或 `.git-air.yaml`)
+4. **全局用户配置**(`~/.git-air/config.yaml`)
+
+### `config.yaml` 示例模板:
+```yaml
+# 模型提供商: gemini, deepseek, ollama, openai, custom
+provider: "gemini"
+
+# API 密钥 (本地 ollama 可留空)
+api_key: "YOUR_API_KEY_HERE"
+
+# 模型名称
+model: "gemini-3.7-flash"
+
+# 自定义端点 (可选)
+# base_url: "https://api.deepseek.com/v1"
+
+# 自定义基础提示词 (可选,留空则使用默认资深架构师准则)
+# custom_prompt: ""
+```
+
+---
+
+## 📋 团队规则定制(`.airules`)
+
+在任何 Git 仓库的根目录下创建一个 `.airules` 文件,`git-air` 在评审时会自动将其作为**团队最高优先级铁律**注入大模型:
+
+```markdown
+# 团队专属开发铁律
+1. 所有 SQL 操作必须下沉至 DAO 层,严禁裸拼 SQL 字符串;
+2. 并发操作共享变量必须加锁或使用原子包(sync/atomic);
+3. 打开的连接/文件句柄必须紧跟 defer Close() 释放;
+4. 严禁使用下划线 _ 盲目丢弃未处理的 error。
+```
+
+---
+
+## 📄 开源许可证
+
+本项目采用 [MIT 许可证](LICENSE)。欢迎提交 Issue 与 Pull Request!
diff --git a/assets/logo.png b/assets/logo.png
new file mode 100644
index 0000000..dea4e77
Binary files /dev/null and b/assets/logo.png differ
diff --git a/cmd/config.go b/cmd/config.go
new file mode 100644
index 0000000..d1dd96b
--- /dev/null
+++ b/cmd/config.go
@@ -0,0 +1,175 @@
+package cmd
+
+import (
+ "fmt"
+ "git-air/internal/ui"
+ "os"
+ "path/filepath"
+
+ "github.com/spf13/cobra"
+ "gopkg.in/yaml.v3"
+)
+
+// AppConfig 全局配置结构体
+type AppConfig struct {
+ Provider string `yaml:"provider"` // gemini, deepseek, ollama, openai, custom
+ APIKey string `yaml:"api_key"` // API Key
+ BaseURL string `yaml:"base_url"` // 自定义请求地址
+ Model string `yaml:"model"` // 模型名称
+ CustomPrompt string `yaml:"custom_prompt"` // 用户自定义的 Base System Prompt
+}
+
+func getConfigPath() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ dir := filepath.Join(home, ".git-air")
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return "", err
+ }
+ return filepath.Join(dir, "config.yaml"), nil
+}
+
+// LoadConfig 读取全局配置(优先使用环境变量,其次读取 yaml 文件)
+func LoadConfig() (*AppConfig, error) {
+ cfg := &AppConfig{
+ Provider: "gemini",
+ Model: "gemini-3.7-flash",
+ }
+
+ // 1. 优先探测当前项目根目录下的配置文件
+ localCandidates := []string{"config.yaml", ".git-air.yaml", ".git-air.yml", "configs/config.yaml"}
+ loaded := false
+
+ for _, name := range localCandidates {
+ if data, err := os.ReadFile(name); err == nil && len(data) > 0 {
+ if err := yaml.Unmarshal(data, cfg); err != nil {
+ return nil, fmt.Errorf("解析配置文件失败: %w", err)
+ }
+ loaded = true
+ break
+ }
+ }
+
+ // 2. 如果项目根目录下没有,则回退读取用户全局配置 ~/.git-air/config.yaml
+ if !loaded {
+ configPath, err := getConfigPath()
+ if err == nil {
+ if data, err := os.ReadFile(configPath); err == nil {
+ _ = yaml.Unmarshal(data, cfg)
+ }
+ }
+ }
+
+ // 3. 环境变量最高优先级覆盖
+ if envKey := os.Getenv("GIT_AIR_API_KEY"); envKey != "" {
+ cfg.APIKey = envKey
+ }
+ if envProvider := os.Getenv("GIT_AIR_PROVIDER"); envProvider != "" {
+ cfg.Provider = envProvider
+ }
+ if envModel := os.Getenv("GIT_AIR_MODEL"); envModel != "" {
+ cfg.Model = envModel
+ }
+ if envBaseURL := os.Getenv("GIT_AIR_BASE_URL"); envBaseURL != "" {
+ cfg.BaseURL = envBaseURL
+ }
+
+ return cfg, nil
+}
+
+// SaveConfig 保存配置到 ~/.git-air/config.yaml
+func SaveConfig(cfg *AppConfig) error {
+ configPath, err := getConfigPath()
+ if err != nil {
+ return err
+ }
+ data, err := yaml.Marshal(cfg)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(configPath, data, 0600)
+}
+
+// configCmd 配置子命令
+var configCmd = &cobra.Command{
+ Use: "config",
+ Short: "查看或修改 git-air 全局配置 (~/.git-air/config.yaml)",
+}
+var configSetCmd = &cobra.Command{
+ Use: "set",
+ Short: "设置全局配置项",
+ Example: ` git air config set --provider gemini --key YOUR_KEY
+ git air config set --provider deepseek --key YOUR_KEY --model deepseek-chat
+ git air config set --provider ollama --model qwen2.5-coder`,
+ Run: func(cmd *cobra.Command, args []string) {
+ cfg, err := LoadConfig()
+ if err != nil || cfg == nil {
+ cfg = &AppConfig{
+ Provider: "gemini",
+ Model: "gemini-3.7-flash",
+ }
+ }
+ if p, _ := cmd.Flags().GetString("provider"); p != "" {
+ cfg.Provider = p
+ }
+ if k, _ := cmd.Flags().GetString("key"); k != "" {
+ cfg.APIKey = k
+ }
+ if m, _ := cmd.Flags().GetString("model"); m != "" {
+ cfg.Model = m
+ }
+ if u, _ := cmd.Flags().GetString("base-url"); u != "" {
+ cfg.BaseURL = u
+ }
+ if pr, _ := cmd.Flags().GetString("prompt"); pr != "" {
+ cfg.CustomPrompt = pr
+ }
+ if err := SaveConfig(cfg); err != nil {
+ ui.PrintError("保存配置失败: %v", err)
+ return
+ }
+ ui.PrintSuccess("配置更新成功!")
+ },
+}
+
+var configGetCmd = &cobra.Command{
+ Use: "get",
+ Short: "查看当前所有配置",
+ Run: func(cmd *cobra.Command, args []string) {
+ cfg, err := LoadConfig()
+ if err != nil || cfg == nil {
+ cfg = &AppConfig{
+ Provider: "gemini",
+ Model: "gemini-3.7-flash",
+ }
+ }
+ path, _ := getConfigPath()
+ fmt.Printf("配置文件路径: %s\n\n", path)
+ fmt.Printf("Provider: %s\n", cfg.Provider)
+ fmt.Printf("Model: %s\n", cfg.Model)
+ fmt.Printf("BaseURL: %s\n", cfg.BaseURL)
+ maskedKey := "未配置"
+ if len(cfg.APIKey) > 8 {
+ maskedKey = cfg.APIKey[:4] + "..." + cfg.APIKey[len(cfg.APIKey)-4:]
+ }
+ fmt.Printf("APIKey: %s\n", maskedKey)
+ hasPrompt := "否 (使用默认内置架构师 Prompt)"
+ if cfg.CustomPrompt != "" {
+ hasPrompt = "是 (已自定义)"
+ }
+ fmt.Printf("CustomPrompt: %s\n", hasPrompt)
+ },
+}
+
+func init() {
+ configSetCmd.Flags().StringP("provider", "p", "", "模型提供商 (gemini, claude, grok, deepseek, qwen, zhipu, moonshot, siliconflow, doubao, minimax, yi, groq, openrouter, ollama, openai)")
+ configSetCmd.Flags().StringP("key", "k", "", "API 密钥")
+ configSetCmd.Flags().StringP("model", "m", "", "模型名称")
+ configSetCmd.Flags().StringP("base-url", "u", "", "自定义 API 地址")
+ configSetCmd.Flags().String("prompt", "", "自定义 Base System Prompt")
+
+ configCmd.AddCommand(configSetCmd)
+ configCmd.AddCommand(configGetCmd)
+}
diff --git a/cmd/hook.go b/cmd/hook.go
new file mode 100644
index 0000000..09ae15e
--- /dev/null
+++ b/cmd/hook.go
@@ -0,0 +1,60 @@
+package cmd
+
+import (
+ "git-air/internal/git"
+ "git-air/internal/ui"
+ "os"
+ "path/filepath"
+
+ "github.com/spf13/cobra"
+)
+
+const preCommitScript = `#!/bin/sh
+# git-air pre-commit hook
+echo "[git-air] 正在执行 Commit 前代码自动化评审..."
+git-air --cached
+# 如果评审发现阻断性问题,可通过退出码阻断 commit
+`
+
+var hookCmd = &cobra.Command{
+ Use: "hook",
+ Short: "管理 Git Pre-commit 自动审查钩子",
+}
+
+var hookInstallCmd = &cobra.Command{
+ Use: "install",
+ Short: "在当前仓库一键安装 pre-commit 钩子",
+ Run: func(cmd *cobra.Command, args []string) {
+ repoRoot, err := git.GetRepoRoot(cmd.Context())
+ if err != nil {
+ ui.PrintError("请在 Git 仓库根目录下执行此命令")
+ return
+ }
+ hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit")
+ if err := os.WriteFile(hookPath, []byte(preCommitScript), 0755); err != nil {
+ ui.PrintError("写入 hook 文件失败: %v", err)
+ return
+ }
+ ui.PrintSuccess("已成功安装 pre-commit 钩子!每次执行 git commit 时将自动触发评审。")
+ },
+}
+
+var hookUninstallCmd = &cobra.Command{
+ Use: "uninstall",
+ Short: "移除当前仓库的 pre-commit 钩子",
+ Run: func(cmd *cobra.Command, args []string) {
+ repoRoot, err := git.GetRepoRoot(cmd.Context())
+ if err != nil {
+ ui.PrintError("请在 Git 仓库根目录下执行此命令")
+ return
+ }
+ hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit")
+ _ = os.Remove(hookPath)
+ ui.PrintSuccess("已成功卸载 pre-commit 钩子!")
+ },
+}
+
+func init() {
+ hookCmd.AddCommand(hookInstallCmd)
+ hookCmd.AddCommand(hookUninstallCmd)
+}
diff --git a/cmd/root.go b/cmd/root.go
new file mode 100644
index 0000000..a0f0261
--- /dev/null
+++ b/cmd/root.go
@@ -0,0 +1,101 @@
+package cmd
+
+import (
+ "context"
+ "git-air/internal/git"
+ "git-air/internal/llm"
+ "git-air/internal/reviewer"
+ "git-air/internal/ui"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+var rootCmd = &cobra.Command{
+ Use: "git-air [git diff 参数,如 HEAD~1, main..feature, 文件名等]",
+ Short: "git-air: 极轻量、极速的 Git 原生 AI 代码评审工具",
+ Long: `git-air 是一个纯 Go 编写的 Git AI 评审利器。
+支持一键评审暂存区、指定 Commit、分支差异或单文件,无缝兼容 Gemini、DeepSeek、Ollama 和 OpenAI。`,
+ Run: func(cmd *cobra.Command, args []string) {
+ ctx := cmd.Context()
+
+ // 1. 检查并获取 Diff
+ diff, err := git.GetDiff(ctx, args...)
+ if err != nil {
+ ui.PrintError("%v", err)
+ return
+ }
+
+ // 2. 加载配置
+ cfg, err := LoadConfig()
+ if err != nil {
+ ui.PrintError("加载配置失败: %v", err)
+ return
+ }
+
+ // 允许命令行临时覆盖模型/Key
+ if flagKey, _ := cmd.Flags().GetString("key"); flagKey != "" {
+ cfg.APIKey = flagKey
+ }
+ if flagModel, _ := cmd.Flags().GetString("model"); flagModel != "" {
+ cfg.Model = flagModel
+ }
+ if flagProvider, _ := cmd.Flags().GetString("provider"); flagProvider != "" {
+ cfg.Provider = flagProvider
+ }
+
+ // 3. 初始化大模型客户端
+ llmClient, err := llm.NewClient(llm.Config{
+ Provider: cfg.Provider,
+ APIKey: cfg.APIKey,
+ BaseURL: cfg.BaseURL,
+ Model: cfg.Model,
+ })
+ if err != nil {
+ ui.PrintError("初始化模型失败: %v (提示: 请先执行 'git air config set --key YOUR_KEY')", err)
+ return
+ }
+
+ // 4. 获取仓库根目录 (用于加载 .airules)
+ repoRoot, _ := git.GetRepoRoot(ctx)
+
+ // 5. 初始化评审引擎
+ customPrompt, _ := cmd.Flags().GetString("prompt")
+ if customPrompt == "" {
+ customPrompt = cfg.CustomPrompt
+ }
+ rev := reviewer.NewReviewer(llmClient, repoRoot, customPrompt)
+
+ // 6. 打印开场横幅并开始流式评审
+ ui.PrintBanner(cfg.Provider, cfg.Model)
+ printer := ui.NewStreamPrinter()
+ _, err = rev.Execute(ctx, diff, true, func(chunk string) {
+ printer.PrintChunk(chunk)
+ })
+ if err != nil {
+ ui.PrintError("\n评审过程中发生错误: %v", err)
+ return
+ }
+
+ ui.PrintFooter()
+ },
+}
+
+func Execute() {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ if err := rootCmd.ExecuteContext(ctx); err != nil {
+ os.Exit(1)
+ }
+}
+
+func init() {
+ rootCmd.AddCommand(configCmd)
+ rootCmd.AddCommand(hookCmd)
+
+ rootCmd.Flags().StringP("key", "k", "", "临时指定 API Key")
+ rootCmd.Flags().StringP("model", "m", "", "临时指定模型名称")
+ rootCmd.Flags().StringP("provider", "p", "", "临时指定 Provider (gemini, deepseek, ollama)")
+ rootCmd.Flags().String("prompt", "", "临时指定 System Prompt")
+}
diff --git a/config.yaml b/config.yaml
new file mode 100644
index 0000000..40acb06
--- /dev/null
+++ b/config.yaml
@@ -0,0 +1,33 @@
+# ==========================================
+# git-air 配置文件 (~/.git-air/config.yaml)
+# ==========================================
+
+# 模型提供商,可选:
+# - 国际主流: gemini, claude (Anthropic), grok (xAI), openai, groq, openrouter, ollama
+# - 国内主流: deepseek, qwen (通义千问), zhipu (智谱GLM), moonshot (Kimi), siliconflow (硅基流动), doubao (字节豆包), minimax, yi (零一万物)
+# - 自定义端点: custom (支持任何兼容 OpenAI 的第三方/私有化端点)
+provider: "gemini"
+
+# API 密钥 (如果是本地 ollama/lmstudio 可填任意字符或留空)
+api_key: "YOUR_API_KEY_HERE"
+
+# 模型名称 (可选,若留空则自动分配各平台的默认主力模型)
+# gemini: gemini-3.7-flash
+# claude: anthropic/claude-3.7-sonnet
+# grok: grok-2-latest
+# deepseek: deepseek-chat (或 deepseek-reasoner)
+# qwen: qwen-plus (或 qwen2.5-coder-32b-instruct)
+# zhipu: glm-4-plus (或 glm-4-flash)
+# moonshot: moonshot-v1-8k
+# siliconflow: deepseek-ai/DeepSeek-V3
+# openrouter: anthropic/claude-3.7-sonnet
+# groq: llama-3.3-70b-versatile
+# ollama: qwen2.5-coder
+# openai: gpt-4o-mini (或 gpt-4o)
+model: "gemini-3.7-flash"
+
+# 自定义 API 接口地址 (可选,不填默认走对应 Provider 的官方端点)
+# base_url: ""
+
+# 自定义全局评审提示词 (可选,留空则使用内置资深架构师准则)
+custom_prompt: ""
\ No newline at end of file
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..863193b
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,11 @@
+module git-air
+
+go 1.26.6
+
+require (
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/sashabaranov/go-openai v1.42.0 // indirect
+ github.com/spf13/cobra v1.10.2 // indirect
+ github.com/spf13/pflag v1.0.9 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..64ae97a
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,14 @@
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sashabaranov/go-openai v1.42.0 h1:fgeZx7/D8dRT//PwXAGe9ylOMtj6vrs999uWF71K+f8=
+github.com/sashabaranov/go-openai v1.42.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/git/diff.go b/internal/git/diff.go
new file mode 100644
index 0000000..7f522f5
--- /dev/null
+++ b/internal/git/diff.go
@@ -0,0 +1,101 @@
+package git
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "os/exec"
+ "strings"
+)
+
+var (
+ ErrNotGitRepo = errors.New("当前目录不是 Git 仓库(未找到 .git)")
+ ErrEmptyDiff = errors.New("没有检测到任何需要评审的代码变更(Diff 为空)")
+)
+
+// IsGitRepo 检查当前工作目录是否在一个 Git 仓库内
+func IsGitRepo(ctx context.Context) bool {
+ cmd := exec.CommandContext(ctx, "git", "rev-parse", "--is-inside-work-tree")
+ err := cmd.Run()
+ return err == nil
+}
+
+// GetRepoRoot 获取当前 Git 仓库的根目录绝对路径
+func GetRepoRoot(ctx context.Context) (string, error) {
+ cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
+ out, err := cmd.Output()
+ if err != nil {
+ return "", ErrNotGitRepo
+ }
+
+ return strings.TrimSpace(string(out)), nil
+}
+
+const maxDiffSize = 300 * 1024 // 限制最大 300KB Diff,避免打爆上下文与 OOM
+
+// GetDiff 提取并过滤 Git 代码差异
+// args 可传:
+// - 无参数:默认先查暂存区 (git diff --cached),若暂存区为空则自动查工作区 (git diff)
+// - 自定义参数:如 "HEAD~1"、"main..feature"、"internal/service/chat.go" 等
+func GetDiff(ctx context.Context, args ...string) (string, error) {
+ if !IsGitRepo(ctx) {
+ return "", ErrNotGitRepo
+ }
+
+ var cmdArgs []string
+ if len(args) == 0 {
+ // 1. 默认先提取暂存区代码 (准备 commit 的代码)
+ stagedDiff, err := execGitDiff(ctx, "--cached")
+ if err != nil {
+ return "", err
+ }
+ if strings.TrimSpace(stagedDiff) != "" {
+ return truncateDiffIfNeeded(FilterDiff(stagedDiff)), nil
+ }
+ // 2. 如果暂存区为空,回退提取工作区未暂存的修改
+ cmdArgs = []string{}
+ } else {
+ cmdArgs = args
+ }
+
+ rawDiff, err := execGitDiff(ctx, cmdArgs...)
+ if err != nil {
+ return "", err
+ }
+
+ filteredDiff := FilterDiff(rawDiff)
+ if strings.TrimSpace(filteredDiff) == "" {
+ return "", ErrEmptyDiff
+ }
+
+ return truncateDiffIfNeeded(filteredDiff), nil
+}
+
+func truncateDiffIfNeeded(diff string) string {
+ runes := []rune(diff)
+ if len(runes) > maxDiffSize {
+ runes = runes[:maxDiffSize]
+ }
+ return string(runes) + "\n\n[WARNING: 代码变更量过大,已自动截断前 300KB 内容进行评审]"
+}
+
+// execGitDiff 执行底层的 git diff 命令
+func execGitDiff(ctx context.Context, args ...string) (string, error) {
+ fullArgs := append([]string{"diff"}, args...)
+ cmd := exec.CommandContext(ctx, "git", fullArgs...)
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ if errors.Is(err, exec.ErrNotFound) {
+ return "", errors.New("系统未检测到 git 命令,请确保已安装 Git 并配置到系统 PATH 中")
+ }
+ errMsg := strings.TrimSpace(stderr.String())
+ if errMsg != "" {
+ return "", fmt.Errorf("git diff 执行失败: %s (%w)", errMsg, err)
+ }
+ return "", fmt.Errorf("git diff 执行失败: %w", err)
+ }
+ return stdout.String(), nil
+}
diff --git a/internal/git/filter.go b/internal/git/filter.go
new file mode 100644
index 0000000..19bbd4d
--- /dev/null
+++ b/internal/git/filter.go
@@ -0,0 +1,97 @@
+package git
+
+import (
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+// 默认需要忽略的文件名与后缀列表
+var ignoredPatterns = []string{
+ // 依赖与锁文件
+ "go.sum",
+ "package-lock.json",
+ "yarn.lock",
+ "pnpm-lock.yaml",
+ "composer.lock",
+ "Cargo.lock",
+ "Gemfile.lock",
+ // 自动生成的代码
+ "*.pb.go",
+ "*.gen.go",
+ "*_generated.go",
+ "*.g.dart",
+ // 压缩/静态构建产物
+ "*.min.js",
+ "*.min.css",
+ "*.map",
+ "*.bundle.js",
+ // 图片与多媒体
+ "*.png",
+ "*.jpg",
+ "*.jpeg",
+ "*.gif",
+ "*.svg",
+ "*.ico",
+ "*.pdf",
+}
+
+// isIgnoredFile 判断文件路径是否应该在 Code Review 中被忽略
+func isIgnoredFile(filePath string) bool {
+ cleanPath := filepath.ToSlash(filePath)
+ baseName := filepath.Base(cleanPath)
+
+ for _, pattern := range ignoredPatterns {
+ // 精确匹配文件名(如 go.sum)
+ if pattern == baseName {
+ return true
+ }
+
+ // 通配符匹配(如 *.pb.go)
+ if matched, _ := filepath.Match(pattern, baseName); matched {
+ return true
+ }
+ }
+
+ return false
+}
+
+var diffHeaderRegex = regexp.MustCompile(`(?m)^diff --git a/(.*?) b/(.*?)$`)
+
+// FilterDiff 过滤掉 diff 中无意义的锁文件和自动生成文件的区块
+func FilterDiff(rawDiff string) string {
+ if strings.TrimSpace(rawDiff) == "" {
+ return ""
+ }
+
+ // 按照 diff --git 切分各个文件的差异区块
+ chunks := strings.Split(rawDiff, "diff --git ")
+ var filteredChunks []string
+
+ for _, chunk := range chunks {
+ if strings.TrimSpace(chunk) == "" {
+ continue
+ }
+
+ // 还原切分前缀
+ fullChunk := "diff --git " + chunk
+ lines := strings.Split(chunk, "\n")
+ if len(lines) == 0 {
+ continue
+ }
+
+ // 解析第一行中的文件路径,如:a/internal/ent/schema.go b/internal/ent/schema.go
+ firstLine := lines[0]
+ parts := strings.Fields(firstLine)
+ if len(parts) >= 2 {
+ filePath := strings.TrimPrefix(parts[1], "b/")
+ if isIgnoredFile(filePath) {
+ continue // 命中忽略规则,跳过这个文件的 diff
+ }
+ }
+
+ filteredChunks = append(filteredChunks, fullChunk)
+ }
+
+ return strings.Join(filteredChunks, "")
+}
diff --git a/internal/llm/client.go b/internal/llm/client.go
new file mode 100644
index 0000000..26f2ef3
--- /dev/null
+++ b/internal/llm/client.go
@@ -0,0 +1,298 @@
+package llm
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/sashabaranov/go-openai"
+)
+
+// Config 大模型统一配置
+type Config struct {
+ Provider string // "gemini", "deepseek", "openai", "ollama", "custom"
+ APIKey string // 密钥 (Ollama 本地可填任意字符或留空)
+ BaseURL string // 自定义接口地址 (非必填,会自动根据 Provider 设置默认值)
+ Model string // 模型名称 (非必填,会自动设置默认主力模型)
+}
+
+// Client 统一大模型客户端
+type Client struct {
+ client *openai.Client
+ model string
+}
+
+// NewClient 初始化客户端
+func NewClient(cfg Config) (*Client, error) {
+ provider := strings.ToLower(strings.TrimSpace(cfg.Provider))
+ if provider == "" {
+ provider = "gemini"
+ }
+
+ apiKey := cfg.APIKey
+ baseURL := cfg.BaseURL
+ model := cfg.Model
+
+ // 智能适配各大主流模型的官方地址与主力模型
+ switch provider {
+ case "gemini", "google":
+ if baseURL == "" {
+ baseURL = "https://generativelanguage.googleapis.com/v1beta/openai/"
+ }
+ if model == "" {
+ model = "gemini-3.7-flash"
+ }
+ if apiKey == "" {
+ return nil, fmt.Errorf("gemini 必须配置 api_key")
+ }
+
+ case "deepseek":
+ if baseURL == "" {
+ baseURL = "https://api.deepseek.com/v1"
+ }
+ if model == "" {
+ model = "deepseek-chat"
+ }
+ if apiKey == "" {
+ return nil, fmt.Errorf("deepseek 必须配置 api_key")
+ }
+
+ case "siliconflow", "silicon":
+ if baseURL == "" {
+ baseURL = "https://api.siliconflow.cn/v1"
+ }
+ if model == "" {
+ model = "deepseek-ai/DeepSeek-V3"
+ }
+ if apiKey == "" {
+ return nil, fmt.Errorf("siliconflow 必须配置 api_key")
+ }
+
+ case "qwen", "dashscope", "aliyun":
+ if baseURL == "" {
+ baseURL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
+ }
+ if model == "" {
+ model = "qwen-plus"
+ }
+ if apiKey == "" {
+ return nil, fmt.Errorf("qwen/dashscope 必须配置 api_key")
+ }
+
+ case "zhipu", "glm":
+ if baseURL == "" {
+ baseURL = "https://open.bigmodel.cn/api/paas/v4/"
+ }
+ if model == "" {
+ model = "glm-4-plus"
+ }
+ if apiKey == "" {
+ return nil, fmt.Errorf("zhipu 必须配置 api_key")
+ }
+
+ case "moonshot", "kimi":
+ if baseURL == "" {
+ baseURL = "https://api.moonshot.cn/v1"
+ }
+ if model == "" {
+ model = "moonshot-v1-8k"
+ }
+ if apiKey == "" {
+ return nil, fmt.Errorf("moonshot/kimi 必须配置 api_key")
+ }
+
+ case "doubao", "volcengine":
+ if baseURL == "" {
+ baseURL = "https://ark.cn-beijing.volces.com/api/v3"
+ }
+ if model == "" {
+ model = "doubao-pro-32k"
+ }
+ if apiKey == "" {
+ return nil, fmt.Errorf("doubao 必须配置 api_key (并在 model 中填写接入点 Endpoint ID)")
+ }
+
+ case "minimax":
+ if baseURL == "" {
+ baseURL = "https://api.minimax.chat/v1"
+ }
+ if model == "" {
+ model = "abab6.5s-chat"
+ }
+ if apiKey == "" {
+ return nil, fmt.Errorf("minimax 必须配置 api_key")
+ }
+
+ case "yi", "lingyi":
+ if baseURL == "" {
+ baseURL = "https://api.lingyiwanwu.com/v1"
+ }
+ if model == "" {
+ model = "yi-lightning"
+ }
+ if apiKey == "" {
+ return nil, fmt.Errorf("yi 必须配置 api_key")
+ }
+
+ case "groq":
+ if baseURL == "" {
+ baseURL = "https://api.groq.com/openai/v1"
+ }
+ if model == "" {
+ model = "llama-3.3-70b-versatile"
+ }
+ if apiKey == "" {
+ return nil, fmt.Errorf("groq 必须配置 api_key")
+ }
+
+ case "grok", "xai":
+ if baseURL == "" {
+ baseURL = "https://api.x.ai/v1"
+ }
+ if model == "" {
+ model = "grok-2-latest"
+ }
+ if apiKey == "" {
+ return nil, fmt.Errorf("grok/xai 必须配置 api_key")
+ }
+
+ case "claude", "anthropic":
+ if baseURL == "" {
+ baseURL = "https://openrouter.ai/api/v1"
+ }
+ if model == "" {
+ model = "anthropic/claude-3.7-sonnet"
+ }
+ if apiKey == "" {
+ return nil, fmt.Errorf("claude 必须配置 api_key (默认走 OpenRouter 统一网关,或通过 base_url 指定自建代理端点)")
+ }
+
+ case "openrouter":
+ if baseURL == "" {
+ baseURL = "https://openrouter.ai/api/v1"
+ }
+ if model == "" {
+ model = "anthropic/claude-3.7-sonnet"
+ }
+ if apiKey == "" {
+ return nil, fmt.Errorf("openrouter 必须配置 api_key")
+ }
+
+ case "ollama":
+ if baseURL == "" {
+ baseURL = "http://localhost:11434/v1"
+ }
+ if model == "" {
+ model = "qwen2.5-coder"
+ }
+ if apiKey == "" {
+ apiKey = "ollama" // 本地 ollama 传非空占位符即可
+ }
+
+ case "lmstudio", "vllm":
+ if baseURL == "" {
+ baseURL = "http://localhost:1234/v1"
+ }
+ if model == "" {
+ model = "local-model"
+ }
+ if apiKey == "" {
+ apiKey = "local"
+ }
+
+ case "openai":
+ if baseURL == "" {
+ baseURL = "https://api.openai.com/v1"
+ }
+ if model == "" {
+ model = "gpt-4o-mini"
+ }
+ if apiKey == "" {
+ return nil, fmt.Errorf("openai 必须配置 api_key")
+ }
+
+ default:
+ // custom 自定义端点(只要提供了 baseURL 就能直连)
+ if baseURL == "" {
+ return nil, fmt.Errorf("未知 Provider: '%s',如需自定义请提供 base_url", provider)
+ }
+ if model == "" {
+ model = "gpt-4o"
+ }
+ }
+
+ clientConfig := openai.DefaultConfig(apiKey)
+ clientConfig.BaseURL = baseURL
+
+ return &Client{
+ client: openai.NewClientWithConfig(clientConfig),
+ model: model,
+ }, nil
+}
+
+// Review 一次性完整返回代码评审结果
+func (c *Client) Review(ctx context.Context, systemPrompt, diff string) (string, error) {
+ resp, err := c.client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
+ Model: c.model,
+ Messages: []openai.ChatCompletionMessage{
+ {Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
+ {Role: openai.ChatMessageRoleUser, Content: diff},
+ },
+ Temperature: 0.2, // 代码评审低随机性
+ })
+
+ if err != nil {
+ return "", fmt.Errorf("代码评审调用失败: %w", err)
+ }
+
+ if len(resp.Choices) == 0 {
+ return "", fmt.Errorf("模型未返回任何有效评审结果")
+ }
+
+ return resp.Choices[0].Message.Content, nil
+}
+
+// ReviewStream 流式逐字返回评审结果(打字机效果)
+func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, onChunk func(chunk string)) error {
+ stream, err := c.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
+ Model: c.model,
+ Messages: []openai.ChatCompletionMessage{
+ {Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
+ {Role: openai.ChatMessageRoleUser, Content: diff},
+ },
+ Temperature: 0.2,
+ Stream: true,
+ })
+ if err != nil {
+ return fmt.Errorf("创建流式请求失败: %w", err)
+ }
+ if stream != nil {
+ defer stream.Close()
+ }
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+
+ response, err := stream.Recv()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ return fmt.Errorf("流式读取异常: %w", err)
+ }
+ if len(response.Choices) > 0 {
+ chunk := response.Choices[0].Delta.Content
+ if chunk != "" {
+ onChunk(chunk)
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/internal/reviewer/prompt.go b/internal/reviewer/prompt.go
new file mode 100644
index 0000000..1b2e817
--- /dev/null
+++ b/internal/reviewer/prompt.go
@@ -0,0 +1,51 @@
+package reviewer
+
+import (
+ "strings"
+)
+
+// DefaultBaseSystemPrompt 默认内置的资深架构师评审准则
+const DefaultBaseSystemPrompt = `你是一位拥有 15 年高并发系统设计经验的资深架构师与代码安全专家。
+你的任务是对开发者提交的 Git Diff 增量代码进行工业级、严苛、精炼的代码评审(Code Review)。
+### 评审原则:
+1. 严禁任何客套话与无意义吹捧,直奔技术实质。
+2. 聚焦于逻辑缺陷、并发竞态、安全漏洞、内存与协程泄漏、异常处理等实质性问题,不要纠结纯粹的代码缩进或格式。
+3. 发现具体问题时,必须直接给出修改前后的对比例子或修复代码块。
+4. 如果代码质量优秀且无缺陷,直接返回 "LGTM: 代码质量良好,未发现明显缺陷",不要强行挑刺。
+### 重点审查维度:
+- 并发与竞态安全:Goroutine 泄露、Channel 阻塞、锁竞争与未释放、共享变量无锁并发读写。
+- 安全与权限:SQL 注入风险、敏感密钥/Token 硬编码、未校验的外部输入、未做权限校验。
+- 资源与错误处理:连接/文件未 defer Close()、错误被盲目下划线 _ 忽略、Context 未向后传递。
+- 性能与边界:大循环内的频繁内存分配或数据库查询、切片越界、空指针异常(NIL Dereference)。
+### 输出格式规范(使用清晰严谨的纯文本标签):
+#### 变更概述
+(用 1-2 句话总结本次代码改动核心内容)
+#### 详细审查意见
+(如果有问题,按严重程度列出;若无问题则省略此段)
+- [BLOCKER] 文件路径:行号 - 问题描述及潜在事故危害(必须附带修复代码块)
+- [WARNING] 文件路径:行号 - 潜在隐患或性能优化点
+- [SUGGESTION] 文件路径:行号 - 更优雅的 Idiomatic 写法
+#### 评审结论
+- 结论: [PASS / WARN / REJECT]
+- 评分: X / 100
+`
+
+// BuildSystemPrompt 构建最终 Prompt(合并 Base Prompt 与 rules.go 加载的项目规则)
+func BuildSystemPrompt(customPrompt string, repoRoot string) string {
+ var builder strings.Builder
+ // 1. 如果用户指定了自定义 Base Prompt 则使用,否则使用默认内置 Prompt
+ basePrompt := strings.TrimSpace(customPrompt)
+ if basePrompt == "" {
+ basePrompt = DefaultBaseSystemPrompt
+ }
+
+ builder.WriteString(basePrompt)
+ // 2. 调用 rules.go 读取项目专属规则
+ customRules := LoadCustomRules(repoRoot)
+ if customRules != "" {
+ builder.WriteString("\n\n### 团队专属规范(最高优先级准则):\n")
+ builder.WriteString(customRules)
+ }
+
+ return builder.String()
+}
diff --git a/internal/reviewer/reviewer.go b/internal/reviewer/reviewer.go
new file mode 100644
index 0000000..1af6ba4
--- /dev/null
+++ b/internal/reviewer/reviewer.go
@@ -0,0 +1,40 @@
+package reviewer
+
+import (
+ "context"
+ "fmt"
+ "git-air/internal/llm"
+ "strings"
+)
+
+type Reviewer struct {
+ client *llm.Client
+ repoRoot string
+ customPrompt string
+}
+
+// NewReviewer 初始化评审引擎
+// customPrompt 可传入用户自定义配置的 Prompt,传空字符串则使用默认 Prompt
+func NewReviewer(client *llm.Client, repoRoot string, customPrompt string) *Reviewer {
+ return &Reviewer{
+ client: client,
+ repoRoot: repoRoot,
+ customPrompt: customPrompt,
+ }
+}
+
+// Execute 执行代码评审
+func (r *Reviewer) Execute(ctx context.Context, diff string, stream bool, onChunk func(chunk string)) (string, error) {
+ if strings.TrimSpace(diff) == "" {
+ return "", fmt.Errorf("diff 内容为空,无需评审")
+ }
+
+ // 动态组装最终 System Prompt
+ systemPrompt := BuildSystemPrompt(r.customPrompt, r.repoRoot)
+ if stream && onChunk != nil {
+ err := r.client.ReviewStream(ctx, systemPrompt, diff, onChunk)
+ return "", err
+ }
+
+ return r.client.Review(ctx, systemPrompt, diff)
+}
diff --git a/internal/reviewer/rules.go b/internal/reviewer/rules.go
new file mode 100644
index 0000000..051c73b
--- /dev/null
+++ b/internal/reviewer/rules.go
@@ -0,0 +1,43 @@
+package reviewer
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// RuleFileCandidates 支持的团队规则文件名优先级列表
+var RuleFileCandidates = []string{
+ ".airules",
+ ".git-air-rules",
+ ".git-air.yaml",
+ ".git-air.md",
+}
+
+// LoadCustomRules 读取项目根目录下的团队专属规则
+func LoadCustomRules(repoRoot string) string {
+ if repoRoot == "" {
+ return ""
+ }
+ rulePath := FindRuleFile(repoRoot)
+ if rulePath == "" {
+ return ""
+ }
+ data, err := os.ReadFile(rulePath)
+ if err != nil || len(data) == 0 {
+ return ""
+ }
+ return strings.TrimSpace(string(data))
+}
+
+// FindRuleFile 探测当前仓库根目录下是否存在匹配的规则文件,返回其绝对路径
+func FindRuleFile(repoRoot string) string {
+ for _, filename := range RuleFileCandidates {
+ fullPath := filepath.Join(repoRoot, filename)
+ if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
+ return fullPath
+ }
+
+ }
+ return ""
+}
diff --git a/internal/ui/render.go b/internal/ui/render.go
new file mode 100644
index 0000000..a1c5aa3
--- /dev/null
+++ b/internal/ui/render.go
@@ -0,0 +1,66 @@
+package ui
+
+import (
+ "fmt"
+ "os"
+ "strings"
+)
+
+// ANSI 终端颜色常量
+const (
+ colorReset = "\033[0m"
+ colorRed = "\033[1;31m"
+ colorGreen = "\033[1;32m"
+ colorYellow = "\033[1;33m"
+ colorBlue = "\033[1;34m"
+ colorCyan = "\033[1;36m"
+ colorGray = "\033[0;90m"
+ colorBold = "\033[1m"
+)
+
+// PrintBanner 打印开场横幅与当前评审状态
+func PrintBanner(provider, model string) {
+ fmt.Printf("%s[git-air]%s 代码评审中... %s(Engine: %s / %s)%s\n", colorCyan, colorReset, colorGray, provider, model, colorReset)
+ fmt.Println(colorGray + strings.Repeat("─", 65) + colorReset)
+}
+
+// PrintFooter 打印结束分界线
+func PrintFooter() {
+ fmt.Println()
+ fmt.Println(colorGray + strings.Repeat("─", 65) + colorReset)
+}
+
+// PrintError 打印错误信息
+func PrintError(format string, a ...interface{}) {
+ msg := fmt.Sprintf(format, a...)
+ fmt.Fprintf(os.Stderr, "%s[ERROR]%s %s\n", colorRed, colorReset, msg)
+}
+
+// PrintSuccess 打印成功信息
+func PrintSuccess(format string, a ...interface{}) {
+ msg := fmt.Sprintf(format, a...)
+ fmt.Printf("%s[SUCCESS]%s %s\n", colorGreen, colorReset, msg)
+}
+
+// StreamPrinter 带有标签高亮的流式输出打印器
+type StreamPrinter struct{}
+
+func NewStreamPrinter() *StreamPrinter {
+ return &StreamPrinter{}
+}
+
+// PrintChunk 逐字流式输出,遇到关键标签进行颜色增强
+func (p *StreamPrinter) PrintChunk(chunk string) {
+ // 对关键标签进行颜色替换
+ highlighted := chunk
+ highlighted = strings.ReplaceAll(highlighted, "[BLOCKER]", colorRed+"[BLOCKER]"+colorReset)
+ highlighted = strings.ReplaceAll(highlighted, "[CRITICAL]", colorRed+"[CRITICAL]"+colorReset)
+ highlighted = strings.ReplaceAll(highlighted, "[WARNING]", colorYellow+"[WARNING]"+colorReset)
+ highlighted = strings.ReplaceAll(highlighted, "[WARN]", colorYellow+"[WARN]"+colorReset)
+ highlighted = strings.ReplaceAll(highlighted, "[SUGGESTION]", colorBlue+"[SUGGESTION]"+colorReset)
+ highlighted = strings.ReplaceAll(highlighted, "[PASS]", colorGreen+"[PASS]"+colorReset)
+ highlighted = strings.ReplaceAll(highlighted, "[LGTM]", colorGreen+"[LGTM]"+colorReset)
+ highlighted = strings.ReplaceAll(highlighted, "[REJECT]", colorRed+"[REJECT]"+colorReset)
+
+ fmt.Print(highlighted)
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..c3ffaa9
--- /dev/null
+++ b/main.go
@@ -0,0 +1,7 @@
+package main
+
+import "git-air/cmd"
+
+func main() {
+ cmd.Execute()
+}