devlake-mcp 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devlake_mcp/__init__.py +7 -0
- devlake_mcp/__main__.py +10 -0
- devlake_mcp/cli.py +794 -0
- devlake_mcp/client.py +474 -0
- devlake_mcp/compat.py +165 -0
- devlake_mcp/config.py +204 -0
- devlake_mcp/constants.py +161 -0
- devlake_mcp/enums.py +58 -0
- devlake_mcp/generation_manager.py +296 -0
- devlake_mcp/git_utils.py +489 -0
- devlake_mcp/hooks/__init__.py +49 -0
- devlake_mcp/hooks/hook_utils.py +246 -0
- devlake_mcp/hooks/post_tool_use.py +325 -0
- devlake_mcp/hooks/pre_tool_use.py +110 -0
- devlake_mcp/hooks/record_session.py +183 -0
- devlake_mcp/hooks/session_start.py +81 -0
- devlake_mcp/hooks/stop.py +275 -0
- devlake_mcp/hooks/transcript_utils.py +547 -0
- devlake_mcp/hooks/user_prompt_submit.py +204 -0
- devlake_mcp/logging_config.py +202 -0
- devlake_mcp/retry_queue.py +556 -0
- devlake_mcp/server.py +664 -0
- devlake_mcp/session_manager.py +444 -0
- devlake_mcp/utils.py +225 -0
- devlake_mcp/version_utils.py +174 -0
- devlake_mcp-0.4.1.dist-info/METADATA +541 -0
- devlake_mcp-0.4.1.dist-info/RECORD +31 -0
- devlake_mcp-0.4.1.dist-info/WHEEL +5 -0
- devlake_mcp-0.4.1.dist-info/entry_points.txt +3 -0
- devlake_mcp-0.4.1.dist-info/licenses/LICENSE +21 -0
- devlake_mcp-0.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Claude Code Hooks 公共工具模块
|
|
5
|
+
|
|
6
|
+
提供跨 hooks 脚本的通用功能:
|
|
7
|
+
- 错误日志记录
|
|
8
|
+
- 本地队列保存(降级方案)
|
|
9
|
+
- 统一的 logging 配置
|
|
10
|
+
- 异步执行包装
|
|
11
|
+
|
|
12
|
+
注意:临时目录等通用功能已移至 devlake_mcp.utils,避免重复代码
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from typing import Callable
|
|
22
|
+
|
|
23
|
+
# 导入通用工具函数(避免代码重复)
|
|
24
|
+
from devlake_mcp.utils import get_data_dir, get_temp_file_path
|
|
25
|
+
from devlake_mcp.constants import HOOK_LOG_DIR
|
|
26
|
+
|
|
27
|
+
# 注意:hook_utils 是基础模块,不导入其他 hooks 模块以避免循环依赖
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# 模块级 logger(Python logging 有 lastResort 机制,无需手动配置)
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def save_to_local_queue(queue_name: str, data: dict):
|
|
35
|
+
"""
|
|
36
|
+
保存数据到本地队列(降级方案)
|
|
37
|
+
|
|
38
|
+
用于 API 上传失败时的备份,后续可通过定时脚本重试上传
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
queue_name: 队列名称(如 'failed_session_uploads')
|
|
42
|
+
data: 要保存的数据字典
|
|
43
|
+
|
|
44
|
+
文件格式:
|
|
45
|
+
~/.devlake/{queue_name}/{timestamp}.json
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
queue_dir = get_data_dir(persistent=True) / queue_name
|
|
49
|
+
queue_dir.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
|
|
51
|
+
# 使用时间戳作为文件名,确保唯一性
|
|
52
|
+
filename = f"{int(datetime.now().timestamp() * 1000)}.json"
|
|
53
|
+
queue_file = queue_dir / filename
|
|
54
|
+
|
|
55
|
+
with open(queue_file, 'w', encoding='utf-8') as f:
|
|
56
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
57
|
+
except Exception as e:
|
|
58
|
+
# 记录失败,不影响主流程(Python logging 会自动输出到 stderr)
|
|
59
|
+
logger.error(
|
|
60
|
+
f"Failed to save to local queue '{queue_name}': {e}",
|
|
61
|
+
exc_info=True
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cleanup_old_files(directory: str, max_age_hours: int = 24):
|
|
66
|
+
"""
|
|
67
|
+
清理指定目录中的过期文件
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
directory: 目录名称(相对于持久化数据目录)
|
|
71
|
+
max_age_hours: 最大保留时间(小时)
|
|
72
|
+
|
|
73
|
+
示例:
|
|
74
|
+
cleanup_old_files('failed_session_uploads', max_age_hours=168) # 7天
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
target_dir = get_data_dir(persistent=True) / directory
|
|
78
|
+
if not target_dir.exists():
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
now = datetime.now().timestamp()
|
|
82
|
+
max_age_seconds = max_age_hours * 3600
|
|
83
|
+
|
|
84
|
+
for file in target_dir.iterdir():
|
|
85
|
+
if file.is_file():
|
|
86
|
+
file_age = now - file.stat().st_mtime
|
|
87
|
+
if file_age > max_age_seconds:
|
|
88
|
+
file.unlink()
|
|
89
|
+
except Exception as e:
|
|
90
|
+
# 记录失败,不影响主流程(Python logging 会自动输出到 stderr)
|
|
91
|
+
logger.error(
|
|
92
|
+
f"Failed to cleanup old files in '{directory}': {e}",
|
|
93
|
+
exc_info=True
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
__all__ = ['save_to_local_queue', 'cleanup_old_files', 'run_async']
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def run_async(func: Callable):
|
|
101
|
+
"""
|
|
102
|
+
异步执行装饰器,让 hook 立即返回,后台执行任务
|
|
103
|
+
|
|
104
|
+
原理(标准的双重 fork daemon 化):
|
|
105
|
+
1. 第一次 fork:创建子进程,父进程立即退出
|
|
106
|
+
2. setsid():子进程创建新会话,脱离控制终端
|
|
107
|
+
3. 第二次 fork:创建孙进程,第一个子进程退出
|
|
108
|
+
4. 孙进程(真正的 daemon)执行实际工作
|
|
109
|
+
|
|
110
|
+
为什么需要双重 fork?
|
|
111
|
+
- 单次 fork:子进程仍在父进程的会话中,可能被 Claude Code 等待
|
|
112
|
+
- setsid():创建新会话,但子进程成为 session leader
|
|
113
|
+
- 第二次 fork:确保孙进程不是 session leader,完全独立
|
|
114
|
+
|
|
115
|
+
参考:Stevens "Advanced Programming in the UNIX Environment"
|
|
116
|
+
|
|
117
|
+
使用方法:
|
|
118
|
+
@run_async
|
|
119
|
+
def main():
|
|
120
|
+
# 你的 hook 逻辑
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
if __name__ == '__main__':
|
|
124
|
+
main()
|
|
125
|
+
|
|
126
|
+
优点:
|
|
127
|
+
- hook 0 延迟,不阻塞 Claude 响应(即使 API 超时 10 秒)
|
|
128
|
+
- 完全脱离父进程会话,不会被等待
|
|
129
|
+
- API 调用慢或失败不影响用户体验
|
|
130
|
+
|
|
131
|
+
注意:
|
|
132
|
+
- 只在 Unix-like 系统(macOS/Linux)使用 fork
|
|
133
|
+
- Windows 会降级为同步执行(因为 fork 不可用)
|
|
134
|
+
"""
|
|
135
|
+
def wrapper(*args, **kwargs):
|
|
136
|
+
# 检查是否支持 fork(Unix-like 系统)
|
|
137
|
+
if sys.platform == 'win32' or not hasattr(os, 'fork'):
|
|
138
|
+
# Windows 或不支持 fork 的系统,降级为同步执行
|
|
139
|
+
func(*args, **kwargs)
|
|
140
|
+
_check_and_retry_uploads() # 同步模式下也检查重试
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
# === 第一次 fork ===
|
|
144
|
+
try:
|
|
145
|
+
pid = os.fork()
|
|
146
|
+
except OSError:
|
|
147
|
+
# fork 失败,降级为同步执行
|
|
148
|
+
func(*args, **kwargs)
|
|
149
|
+
_check_and_retry_uploads()
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
if pid > 0:
|
|
153
|
+
# 父进程:立即退出(返回给 Claude Code)
|
|
154
|
+
os._exit(0)
|
|
155
|
+
|
|
156
|
+
# === 第一个子进程 ===
|
|
157
|
+
try:
|
|
158
|
+
# 创建新会话,脱离控制终端
|
|
159
|
+
# 此时子进程成为 session leader
|
|
160
|
+
os.setsid()
|
|
161
|
+
except OSError:
|
|
162
|
+
# setsid 失败,退出
|
|
163
|
+
os._exit(1)
|
|
164
|
+
|
|
165
|
+
# === 第二次 fork ===
|
|
166
|
+
try:
|
|
167
|
+
pid = os.fork()
|
|
168
|
+
except OSError:
|
|
169
|
+
# fork 失败,退出
|
|
170
|
+
os._exit(1)
|
|
171
|
+
|
|
172
|
+
if pid > 0:
|
|
173
|
+
# 第一个子进程:退出
|
|
174
|
+
# 让孙进程被 init 进程接管
|
|
175
|
+
os._exit(0)
|
|
176
|
+
|
|
177
|
+
# === 孙进程(真正的 daemon)===
|
|
178
|
+
try:
|
|
179
|
+
# 1. 读取 stdin 内容(在关闭文件描述符之前)
|
|
180
|
+
from io import StringIO
|
|
181
|
+
try:
|
|
182
|
+
stdin_content = sys.stdin.read()
|
|
183
|
+
except Exception:
|
|
184
|
+
stdin_content = ''
|
|
185
|
+
|
|
186
|
+
# 2. 关闭并重定向标准文件描述符(关键!)
|
|
187
|
+
# 这是 daemon 化的必要步骤,确保 subprocess.run 不会等待
|
|
188
|
+
sys.stdout.flush()
|
|
189
|
+
sys.stderr.flush()
|
|
190
|
+
|
|
191
|
+
# 关闭标准输入/输出/错误的文件描述符
|
|
192
|
+
os.close(0) # stdin
|
|
193
|
+
os.close(1) # stdout
|
|
194
|
+
os.close(2) # stderr
|
|
195
|
+
|
|
196
|
+
# 重新打开到 /dev/null 或日志文件
|
|
197
|
+
# stdin -> /dev/null
|
|
198
|
+
os.open('/dev/null', os.O_RDONLY) # 返回 fd 0
|
|
199
|
+
|
|
200
|
+
# stdout 和 stderr -> 保留日志功能
|
|
201
|
+
# 注意:由于我们已经配置了 logging 到文件,这里重定向到 /dev/null 不影响日志
|
|
202
|
+
os.open('/dev/null', os.O_WRONLY) # 返回 fd 1 (stdout)
|
|
203
|
+
os.open('/dev/null', os.O_WRONLY) # 返回 fd 2 (stderr)
|
|
204
|
+
|
|
205
|
+
# 3. 用 StringIO 替换 Python 的 sys.stdin(让代码能正常读取)
|
|
206
|
+
sys.stdin = StringIO(stdin_content)
|
|
207
|
+
|
|
208
|
+
# 4. 执行主 hook 逻辑
|
|
209
|
+
func(*args, **kwargs)
|
|
210
|
+
|
|
211
|
+
# 5. 检查并重试失败的上传记录(非阻塞)
|
|
212
|
+
_check_and_retry_uploads()
|
|
213
|
+
|
|
214
|
+
# daemon 正常退出
|
|
215
|
+
os._exit(0)
|
|
216
|
+
except Exception:
|
|
217
|
+
# daemon 异常退出
|
|
218
|
+
os._exit(1)
|
|
219
|
+
|
|
220
|
+
return wrapper
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _check_and_retry_uploads():
|
|
224
|
+
"""
|
|
225
|
+
检查并重试失败的上传记录(内部函数)
|
|
226
|
+
|
|
227
|
+
说明:
|
|
228
|
+
- 每次 Hook 执行时自动调用
|
|
229
|
+
- 非阻塞,快速返回(默认最多重试3条记录)
|
|
230
|
+
- 静默失败,不影响主流程
|
|
231
|
+
"""
|
|
232
|
+
try:
|
|
233
|
+
# 延迟导入,避免循环依赖
|
|
234
|
+
from devlake_mcp.retry_queue import retry_failed_uploads, get_retry_config
|
|
235
|
+
|
|
236
|
+
# 检查是否启用重试
|
|
237
|
+
config = get_retry_config()
|
|
238
|
+
if not config.get('check_on_hook', True):
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
# 执行重试(限制单次最多3条,避免阻塞)
|
|
242
|
+
retry_failed_uploads(max_parallel=3)
|
|
243
|
+
|
|
244
|
+
except Exception as e:
|
|
245
|
+
# 静默失败,不影响主流程
|
|
246
|
+
logger.debug(f"重试检查失败(不影响主流程): {e}")
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Claude Code Hooks: AI出码数据采集脚本(v1.5 重构版本)
|
|
5
|
+
|
|
6
|
+
改进:
|
|
7
|
+
- 添加统一的日志系统(参考 stop.py)
|
|
8
|
+
- 添加异步执行,立即返回,不阻塞工具执行
|
|
9
|
+
- 移除本地 diff 计算(改为云端计算)
|
|
10
|
+
- 添加 gzip 压缩传输
|
|
11
|
+
- 完整上传 before/after 内容(不截断)
|
|
12
|
+
- 添加降级方案(API 失败时保存本地)
|
|
13
|
+
- 跨平台临时目录支持(Windows/macOS/Linux)
|
|
14
|
+
|
|
15
|
+
作者:Claude Code
|
|
16
|
+
版本:v1.5
|
|
17
|
+
日期:2025-01-04
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import sys
|
|
23
|
+
import os
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
# 导入公共工具(使用包导入)
|
|
28
|
+
from devlake_mcp.hooks.hook_utils import run_async
|
|
29
|
+
from devlake_mcp.utils import get_temp_file_path, compress_content
|
|
30
|
+
from devlake_mcp.git_utils import get_git_context_from_file
|
|
31
|
+
from devlake_mcp.client import DevLakeClient
|
|
32
|
+
from devlake_mcp.retry_queue import save_failed_upload
|
|
33
|
+
from devlake_mcp.session_manager import check_and_switch_session
|
|
34
|
+
from devlake_mcp.generation_manager import get_current_generation_id
|
|
35
|
+
from devlake_mcp.logging_config import configure_logging, get_log_dir
|
|
36
|
+
from devlake_mcp.constants import HOOK_LOG_DIR
|
|
37
|
+
|
|
38
|
+
# 配置日志(启动时调用一次)
|
|
39
|
+
configure_logging(log_dir=get_log_dir(HOOK_LOG_DIR), log_file='post_tool_use.log')
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ============================================================================
|
|
44
|
+
# 上传功能
|
|
45
|
+
# ============================================================================
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def upload_to_api(change_data: dict) -> bool:
|
|
49
|
+
"""
|
|
50
|
+
同步上传数据到 DevLake API
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
change_data: 变更数据字典
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
是否上传成功
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
client = DevLakeClient()
|
|
60
|
+
client.create_file_changes([change_data])
|
|
61
|
+
logger.info(f'成功上传文件变更: {change_data.get("file_path")}')
|
|
62
|
+
return True
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.error(f'上传文件变更失败: {e}')
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ============================================================================
|
|
69
|
+
# 临时文件管理(PreToolUse 使用)
|
|
70
|
+
# ============================================================================
|
|
71
|
+
|
|
72
|
+
def load_before_content(session_id: str, file_path: str) -> str:
|
|
73
|
+
"""
|
|
74
|
+
从临时文件加载 before_content
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
session_id: 会话ID
|
|
78
|
+
file_path: 文件路径
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
文件的 before_content,如果不存在返回空字符串
|
|
82
|
+
"""
|
|
83
|
+
temp_file = get_temp_file_path(session_id, file_path)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
if os.path.exists(temp_file):
|
|
87
|
+
with open(temp_file, 'r', encoding='utf-8') as f:
|
|
88
|
+
data = json.load(f)
|
|
89
|
+
return data.get('content', '')
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
return ''
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_current_file_content(file_path: str) -> str:
|
|
97
|
+
"""
|
|
98
|
+
读取文件当前内容
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
file_path: 文件路径
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
文件内容,读取失败返回空字符串
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
if os.path.exists(file_path):
|
|
108
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
109
|
+
return f.read()
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
return ''
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ============================================================================
|
|
117
|
+
# 辅助函数
|
|
118
|
+
# ============================================================================
|
|
119
|
+
|
|
120
|
+
def get_file_type(file_path: str) -> str:
|
|
121
|
+
"""获取文件类型"""
|
|
122
|
+
return Path(file_path).suffix.lstrip('.') or 'unknown'
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def extract_user_info(session_id: str) -> dict:
|
|
126
|
+
"""从环境变量提取用户信息"""
|
|
127
|
+
return {
|
|
128
|
+
'user_name': os.getenv('USER', 'unknown'),
|
|
129
|
+
'project_name': Path(os.getcwd()).name
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def should_collect_file(file_path: str) -> bool:
|
|
134
|
+
"""判断是否应该采集该文件"""
|
|
135
|
+
# 排除敏感文件
|
|
136
|
+
sensitive_patterns = ['.env', '.secret', '.key']
|
|
137
|
+
file_path_lower = file_path.lower()
|
|
138
|
+
|
|
139
|
+
for pattern in sensitive_patterns:
|
|
140
|
+
if pattern in file_path_lower:
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
# 排除二进制文件(通过后缀判断)
|
|
144
|
+
binary_extensions = {
|
|
145
|
+
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico',
|
|
146
|
+
'.pdf', '.zip', '.tar', '.gz', '.rar',
|
|
147
|
+
'.exe', '.dll', '.so', '.dylib',
|
|
148
|
+
'.class', '.pyc', '.pyo'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
file_ext = Path(file_path).suffix.lower()
|
|
152
|
+
if file_ext in binary_extensions:
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ============================================================================
|
|
159
|
+
# 主逻辑
|
|
160
|
+
# ============================================================================
|
|
161
|
+
|
|
162
|
+
@run_async
|
|
163
|
+
def main():
|
|
164
|
+
temp_file = None # 初始化临时文件路径
|
|
165
|
+
try:
|
|
166
|
+
# 读取 Hook 输入
|
|
167
|
+
input_data = json.load(sys.stdin)
|
|
168
|
+
|
|
169
|
+
hook_event_name = input_data.get('hook_event_name')
|
|
170
|
+
tool_name = input_data.get('tool_name')
|
|
171
|
+
tool_input = input_data.get('tool_input', {})
|
|
172
|
+
session_id = input_data.get('session_id')
|
|
173
|
+
|
|
174
|
+
# 获取当前工作目录(需要在检查会话前获取)
|
|
175
|
+
cwd = input_data.get('cwd', os.getcwd())
|
|
176
|
+
|
|
177
|
+
# 检查会话是否切换(如果切换会自动结束旧会话)
|
|
178
|
+
if session_id:
|
|
179
|
+
try:
|
|
180
|
+
switched = check_and_switch_session(session_id, cwd, ide_type='claude_code')
|
|
181
|
+
if switched:
|
|
182
|
+
logger.info(f'检测到会话切换,旧会话已自动结束,新会话: {session_id}')
|
|
183
|
+
except Exception as e:
|
|
184
|
+
logger.error(f'会话切换检查失败: {e}')
|
|
185
|
+
|
|
186
|
+
# 只处理 PostToolUse 事件
|
|
187
|
+
if hook_event_name != 'PostToolUse':
|
|
188
|
+
sys.exit(0)
|
|
189
|
+
|
|
190
|
+
# 只处理文件修改相关的工具
|
|
191
|
+
if tool_name not in ['Write', 'Edit', 'NotebookEdit']:
|
|
192
|
+
sys.exit(0)
|
|
193
|
+
|
|
194
|
+
logger.debug(f'PostToolUse Hook 触发 - tool: {tool_name}, session: {session_id}')
|
|
195
|
+
|
|
196
|
+
# 提取文件路径
|
|
197
|
+
file_path = tool_input.get('file_path') or tool_input.get('notebook_path')
|
|
198
|
+
if not file_path:
|
|
199
|
+
logger.debug('没有 file_path,跳过')
|
|
200
|
+
sys.exit(0)
|
|
201
|
+
|
|
202
|
+
# 转换为绝对路径
|
|
203
|
+
if not os.path.isabs(file_path):
|
|
204
|
+
cwd = input_data.get('cwd', os.getcwd())
|
|
205
|
+
file_path = os.path.join(cwd, file_path)
|
|
206
|
+
|
|
207
|
+
# 检查是否应该采集
|
|
208
|
+
if not should_collect_file(file_path):
|
|
209
|
+
logger.debug(f'文件不需要采集(敏感文件或二进制文件): {file_path}')
|
|
210
|
+
sys.exit(0)
|
|
211
|
+
|
|
212
|
+
logger.info(f'开始处理文件变更: {file_path}')
|
|
213
|
+
|
|
214
|
+
# 获取用户信息
|
|
215
|
+
user_info = extract_user_info(session_id)
|
|
216
|
+
|
|
217
|
+
# ====================================================================
|
|
218
|
+
# v1.3 核心改进:同步上传 + 分支支持
|
|
219
|
+
# ====================================================================
|
|
220
|
+
|
|
221
|
+
# 1. 获取临时文件路径(用于后续清理)
|
|
222
|
+
temp_file = get_temp_file_path(session_id, file_path)
|
|
223
|
+
|
|
224
|
+
# 2. 从 PreToolUse 临时文件加载 before_content
|
|
225
|
+
before_content = load_before_content(session_id, file_path)
|
|
226
|
+
|
|
227
|
+
# 3. 读取当前文件内容(after_content)
|
|
228
|
+
after_content = get_current_file_content(file_path)
|
|
229
|
+
|
|
230
|
+
# 4. 压缩内容(减少传输大小)
|
|
231
|
+
before_content_gz = compress_content(before_content)
|
|
232
|
+
after_content_gz = compress_content(after_content)
|
|
233
|
+
|
|
234
|
+
# ====================================================================
|
|
235
|
+
# Git 信息获取策略:基于文件路径获取 Git 上下文(支持 workspace)
|
|
236
|
+
# - 从文件路径向上查找 .git 目录
|
|
237
|
+
# - 静态信息(author, email, repo_path):优先从环境变量读取
|
|
238
|
+
# - 动态信息(branch, commit):每次执行 git 命令获取最新值
|
|
239
|
+
# ====================================================================
|
|
240
|
+
|
|
241
|
+
# 5. 获取完整的 Git 上下文(基于文件路径,支持 workspace 多项目)
|
|
242
|
+
git_context = get_git_context_from_file(file_path, use_env_cache=True)
|
|
243
|
+
git_author = git_context.get('git_author', 'unknown')
|
|
244
|
+
git_email = git_context.get('git_email', 'unknown')
|
|
245
|
+
git_repo_path = git_context.get('git_repo_path', 'unknown')
|
|
246
|
+
git_branch = git_context.get('git_branch', 'unknown')
|
|
247
|
+
git_commit = git_context.get('git_commit', 'unknown')
|
|
248
|
+
|
|
249
|
+
# 6. 其他配置
|
|
250
|
+
ide_type = 'claude_code' # 固定值
|
|
251
|
+
model_name = os.getenv('CLAUDE_MODEL', 'claude-sonnet-4-5')
|
|
252
|
+
|
|
253
|
+
logger.debug(f'Git 信息 - branch: {git_branch}, '
|
|
254
|
+
f'commit: {git_commit[:8] if git_commit != "unknown" else "unknown"}, '
|
|
255
|
+
f'email: {git_email}, repo: {git_repo_path}')
|
|
256
|
+
|
|
257
|
+
# 6. 转换 file_path 为相对路径(使用 git_context 中的 git_root)
|
|
258
|
+
git_root = git_context.get('git_root')
|
|
259
|
+
if git_root:
|
|
260
|
+
try:
|
|
261
|
+
# 计算相对路径
|
|
262
|
+
relative_path = os.path.relpath(file_path, git_root)
|
|
263
|
+
logger.debug(f'文件路径转换: {file_path} -> {relative_path}')
|
|
264
|
+
file_path = relative_path
|
|
265
|
+
except Exception as e:
|
|
266
|
+
# 如果转换失败,保持原路径
|
|
267
|
+
logger.warning(f'路径转换失败: {e}')
|
|
268
|
+
|
|
269
|
+
# 7. 获取 prompt_uuid(关联到具体的 prompt)
|
|
270
|
+
prompt_uuid = get_current_generation_id(session_id, ide_type='claude_code')
|
|
271
|
+
logger.debug(f'获取到 prompt_uuid: {prompt_uuid}')
|
|
272
|
+
|
|
273
|
+
# 8. 构造上报数据(不包含 diff 计算结果)
|
|
274
|
+
change_data = {
|
|
275
|
+
'session_id': session_id,
|
|
276
|
+
'prompt_uuid': prompt_uuid, # 新增:关联到具体的 prompt
|
|
277
|
+
'user_name': user_info['user_name'],
|
|
278
|
+
'ide_type': ide_type, # IDE 类型
|
|
279
|
+
'model_name': model_name, # AI 模型名称
|
|
280
|
+
'git_repo_path': git_repo_path, # Git仓库路径 (namespace/name)
|
|
281
|
+
'project_name': user_info['project_name'],
|
|
282
|
+
'file_path': file_path, # 相对路径
|
|
283
|
+
'file_type': get_file_type(file_path),
|
|
284
|
+
'change_type': 'create' if tool_name == 'Write' else 'edit',
|
|
285
|
+
'tool_name': tool_name,
|
|
286
|
+
'before_content_gz': before_content_gz, # 压缩内容
|
|
287
|
+
'after_content_gz': after_content_gz, # 压缩内容
|
|
288
|
+
'git_branch': git_branch, # Git 分支(动态)
|
|
289
|
+
'git_commit': git_commit, # Git commit(动态)
|
|
290
|
+
'git_author': git_author, # Git 作者(环境变量)
|
|
291
|
+
'git_email': git_email, # Git 邮箱(环境变量)
|
|
292
|
+
'change_time': datetime.now().isoformat(),
|
|
293
|
+
'cwd': cwd
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
# 9. 同步上传到 API(超时 3 秒)
|
|
297
|
+
success = upload_to_api(change_data)
|
|
298
|
+
|
|
299
|
+
if not success:
|
|
300
|
+
# 上传失败,保存到本地队列(支持自动重试)
|
|
301
|
+
logger.warning(f'API 上传失败,保存到本地队列: {file_path}')
|
|
302
|
+
save_failed_upload(
|
|
303
|
+
queue_type='file_change',
|
|
304
|
+
data=change_data,
|
|
305
|
+
error='API upload failed'
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
except Exception as e:
|
|
309
|
+
# 任何异常都静默失败,不阻塞 Claude
|
|
310
|
+
logger.error(f'PostToolUse Hook 执行失败: {e}', exc_info=True)
|
|
311
|
+
finally:
|
|
312
|
+
# 10. 清理临时文件(使用 finally 确保一定执行)
|
|
313
|
+
if temp_file and os.path.exists(temp_file):
|
|
314
|
+
try:
|
|
315
|
+
os.remove(temp_file)
|
|
316
|
+
logger.debug(f'清理临时文件: {temp_file}')
|
|
317
|
+
except Exception as e:
|
|
318
|
+
logger.warning(f'清理临时文件失败: {e}')
|
|
319
|
+
|
|
320
|
+
# 静默退出
|
|
321
|
+
sys.exit(0)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == '__main__':
|
|
325
|
+
main()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
PreToolUse Hook - 在工具执行前记录文件内容
|
|
5
|
+
|
|
6
|
+
功能:
|
|
7
|
+
1. 拦截 Write/Edit/NotebookEdit 工具
|
|
8
|
+
2. 读取文件的完整内容(before_content)
|
|
9
|
+
3. 保存到临时文件,供 PostToolUse 使用
|
|
10
|
+
|
|
11
|
+
注意:此 hook 不使用异步执行,因为必须在工具执行前完成文件内容的保存
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import sys
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
|
|
21
|
+
# 导入公共工具(使用包导入)
|
|
22
|
+
from devlake_mcp.utils import get_temp_file_path
|
|
23
|
+
from devlake_mcp.logging_config import configure_logging, get_log_dir
|
|
24
|
+
from devlake_mcp.constants import HOOK_LOG_DIR
|
|
25
|
+
|
|
26
|
+
# 配置日志(启动时调用一次)
|
|
27
|
+
configure_logging(log_dir=get_log_dir(HOOK_LOG_DIR), log_file='pre_tool_use.log')
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def save_before_content(session_id: str, file_path: str, content: str):
|
|
32
|
+
"""
|
|
33
|
+
保存文件的 before_content 到临时文件
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
session_id: 会话ID
|
|
37
|
+
file_path: 文件路径
|
|
38
|
+
content: 文件内容
|
|
39
|
+
"""
|
|
40
|
+
temp_file = get_temp_file_path(session_id, file_path)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
with open(temp_file, 'w', encoding='utf-8') as f:
|
|
44
|
+
# 保存为 JSON 格式,包含元数据
|
|
45
|
+
data = {
|
|
46
|
+
'file_path': file_path,
|
|
47
|
+
'content': content,
|
|
48
|
+
'timestamp': datetime.now().isoformat()
|
|
49
|
+
}
|
|
50
|
+
json.dump(data, f)
|
|
51
|
+
except Exception:
|
|
52
|
+
# 静默失败
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main():
|
|
57
|
+
try:
|
|
58
|
+
input_data = json.load(sys.stdin)
|
|
59
|
+
|
|
60
|
+
tool_name = input_data.get('tool_name')
|
|
61
|
+
tool_input = input_data.get('tool_input', {})
|
|
62
|
+
session_id = input_data.get('session_id')
|
|
63
|
+
|
|
64
|
+
# 只处理文件修改工具
|
|
65
|
+
if tool_name not in ['Write', 'Edit', 'NotebookEdit']:
|
|
66
|
+
sys.exit(0)
|
|
67
|
+
|
|
68
|
+
# 获取文件路径
|
|
69
|
+
file_path = tool_input.get('file_path')
|
|
70
|
+
if not file_path:
|
|
71
|
+
logger.debug(f'工具 {tool_name} 没有 file_path,跳过')
|
|
72
|
+
sys.exit(0)
|
|
73
|
+
|
|
74
|
+
# 转换为绝对路径
|
|
75
|
+
if not os.path.isabs(file_path):
|
|
76
|
+
cwd = input_data.get('cwd', os.getcwd())
|
|
77
|
+
file_path = os.path.join(cwd, file_path)
|
|
78
|
+
|
|
79
|
+
logger.debug(f'PreToolUse Hook 触发 - tool: {tool_name}, file: {file_path}')
|
|
80
|
+
|
|
81
|
+
# 读取文件当前内容(before_content)
|
|
82
|
+
before_content = ''
|
|
83
|
+
|
|
84
|
+
if os.path.exists(file_path):
|
|
85
|
+
try:
|
|
86
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
87
|
+
before_content = f.read()
|
|
88
|
+
logger.debug(f'成功读取文件内容 - 长度: {len(before_content)} 字符')
|
|
89
|
+
except Exception as e:
|
|
90
|
+
# 读取失败(如二进制文件),跳过
|
|
91
|
+
logger.warning(f'读取文件失败(可能是二进制文件): {e}')
|
|
92
|
+
sys.exit(0)
|
|
93
|
+
else:
|
|
94
|
+
logger.debug('文件不存在(新建文件)')
|
|
95
|
+
|
|
96
|
+
# 保存到临时文件
|
|
97
|
+
save_before_content(session_id, file_path, before_content)
|
|
98
|
+
logger.info(f'成功保存 before_content: {file_path}')
|
|
99
|
+
|
|
100
|
+
# 成功,静默退出
|
|
101
|
+
sys.exit(0)
|
|
102
|
+
|
|
103
|
+
except Exception as e:
|
|
104
|
+
# 任何异常都静默失败,不影响 AI 执行
|
|
105
|
+
logger.error(f'PreToolUse Hook 执行失败: {e}', exc_info=True)
|
|
106
|
+
sys.exit(0)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == '__main__':
|
|
110
|
+
main()
|