ai-worklog 1.0.0
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.
- package/README.md +102 -0
- package/bin/index.js +272 -0
- package/package.json +18 -0
- package/scripts/collect_work_log.py +605 -0
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# 工作日志自动收集系统
|
|
2
|
+
|
|
3
|
+
自动从 Claude Code 和 Codex 的 AI 对话记录中提取工作内容,调用 Claude API 生成结构化工作日志并提交到 GitLab。
|
|
4
|
+
|
|
5
|
+
## 快速开始
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# 将 scripts/ 加入 PATH(建议加到 ~/.zshrc 或 ~/.bashrc)
|
|
9
|
+
export PATH="$HOME/Developer/local-work-record/scripts:$PATH"
|
|
10
|
+
|
|
11
|
+
# 生成今天的日志(自动 commit + push)
|
|
12
|
+
worklog
|
|
13
|
+
|
|
14
|
+
# 生成昨天的日志
|
|
15
|
+
worklog yesterday
|
|
16
|
+
|
|
17
|
+
# 指定日期
|
|
18
|
+
worklog 2026-03-06
|
|
19
|
+
|
|
20
|
+
# 生成但不 push
|
|
21
|
+
worklog --no-push
|
|
22
|
+
|
|
23
|
+
# 仅预览收集到的数据(不生成文件)
|
|
24
|
+
worklog --dry-run
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 数据来源
|
|
28
|
+
|
|
29
|
+
| 来源 | 路径 | 过滤规则 |
|
|
30
|
+
|------|------|----------|
|
|
31
|
+
| Claude Code | `~/.claude/projects/{project}/*.jsonl` | 按 timestamp 过滤日期,过滤系统注入消息 |
|
|
32
|
+
| Codex | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | 按目录日期,提取 event_msg/user_message |
|
|
33
|
+
|
|
34
|
+
**过滤的系统消息前缀:**
|
|
35
|
+
- `# AGENTS.md instructions`
|
|
36
|
+
- `<environment_context>`
|
|
37
|
+
- `<system-reminder>`
|
|
38
|
+
- `<local-command-caveat>`
|
|
39
|
+
- 含超过 20 个 `<` 标签的消息
|
|
40
|
+
|
|
41
|
+
## 日志格式
|
|
42
|
+
|
|
43
|
+
```markdown
|
|
44
|
+
# 工作日志 - 2026-03-07
|
|
45
|
+
|
|
46
|
+
## 今日概览
|
|
47
|
+
- 涉及项目数: N
|
|
48
|
+
- AI 对话次数: N(Claude Code: N,Codex: N)
|
|
49
|
+
|
|
50
|
+
## 项目工作详情
|
|
51
|
+
### [project-name] 一句话概括
|
|
52
|
+
**工作类型:** 探索 + 开发
|
|
53
|
+
**主要工作:**
|
|
54
|
+
- ...
|
|
55
|
+
**关键决策:**
|
|
56
|
+
- ...
|
|
57
|
+
|
|
58
|
+
## 今日总结
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
*由 collect_work_log.py 自动生成于 ...*
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
> **注意:日志不估算工时,工时由工程师自报填写。**
|
|
66
|
+
|
|
67
|
+
## 目录结构
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
local-work-record/
|
|
71
|
+
├── scripts/
|
|
72
|
+
│ ├── collect_work_log.py # 主脚本(Python 3.8+,无额外依赖)
|
|
73
|
+
│ └── worklog # Shell 入口(chmod +x)
|
|
74
|
+
├── logs/
|
|
75
|
+
│ └── 2026/
|
|
76
|
+
│ └── 2026-03-07.md
|
|
77
|
+
└── README.md
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## API 配置
|
|
81
|
+
|
|
82
|
+
脚本自动从 `~/.claude/settings.json` 读取:
|
|
83
|
+
- `env.ANTHROPIC_AUTH_TOKEN` — API 密钥
|
|
84
|
+
- `env.ANTHROPIC_BASE_URL` — API 地址(默认 `https://api.anthropic.com`)
|
|
85
|
+
|
|
86
|
+
**无需手动配置任何环境变量。**
|
|
87
|
+
|
|
88
|
+
## 命令行选项
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
worklog [date] [options]
|
|
92
|
+
|
|
93
|
+
date:
|
|
94
|
+
today 今天(默认)
|
|
95
|
+
yesterday 昨天
|
|
96
|
+
YYYY-MM-DD 指定日期
|
|
97
|
+
|
|
98
|
+
options:
|
|
99
|
+
--no-push 仅 commit,不 push 到远程
|
|
100
|
+
--dry-run 仅预览收集数据,不生成文件
|
|
101
|
+
--no-git 仅生成文件,跳过所有 git 操作
|
|
102
|
+
```
|
package/bin/index.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ai-worklog 安装向导
|
|
4
|
+
* 运行方式: npx ai-worklog
|
|
5
|
+
*
|
|
6
|
+
* 执行内容:
|
|
7
|
+
* 1. 安装 collect_work_log.py 到 ~/.local/share/ai-worklog/
|
|
8
|
+
* 2. 创建 worklog 命令到 ~/.local/bin/
|
|
9
|
+
* 3. 自动添加 PATH 到 shell 配置
|
|
10
|
+
* 4. 引导配置 GitLab Token(只需一次)
|
|
11
|
+
* 5. 在 GitLab 上创建公开项目 local-work-record
|
|
12
|
+
* 6. 初始化本地 git 仓库并关联远程
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const https = require('https');
|
|
21
|
+
const { spawnSync } = require('child_process');
|
|
22
|
+
const readline = require('readline');
|
|
23
|
+
|
|
24
|
+
// ─── 路径常量 ────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
const INSTALL_DIR = path.join(os.homedir(), '.local', 'share', 'ai-worklog');
|
|
27
|
+
const BIN_DIR = path.join(os.homedir(), '.local', 'bin');
|
|
28
|
+
const CONFIG_FILE = path.join(os.homedir(), '.config', 'worklog.json');
|
|
29
|
+
const REPO_DIR = path.join(os.homedir(), 'local-work-record');
|
|
30
|
+
const GITLAB_HOST = 'gitcode.lingjingai.cn';
|
|
31
|
+
|
|
32
|
+
// ─── 工具函数 ────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
function run(cmd, opts = {}) {
|
|
35
|
+
return spawnSync(cmd[0], cmd.slice(1), {
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
stdio: opts.stdio || 'pipe',
|
|
38
|
+
cwd: opts.cwd,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function prompt(question) {
|
|
43
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
44
|
+
return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function apiRequest(options, body) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const req = https.request({ ...options, rejectUnauthorized: false }, res => {
|
|
50
|
+
let data = '';
|
|
51
|
+
res.on('data', c => data += c);
|
|
52
|
+
res.on('end', () => {
|
|
53
|
+
try { resolve({ status: res.statusCode, body: JSON.parse(data) }); }
|
|
54
|
+
catch { resolve({ status: res.statusCode, body: data }); }
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
req.on('error', reject);
|
|
58
|
+
if (body) req.write(body);
|
|
59
|
+
req.end();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── 步骤 1:安装 Python 脚本 ────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
function installScript() {
|
|
66
|
+
fs.mkdirSync(INSTALL_DIR, { recursive: true });
|
|
67
|
+
const src = path.join(__dirname, '..', 'scripts', 'collect_work_log.py');
|
|
68
|
+
const dst = path.join(INSTALL_DIR, 'collect_work_log.py');
|
|
69
|
+
fs.copyFileSync(src, dst);
|
|
70
|
+
fs.chmodSync(dst, 0o755);
|
|
71
|
+
console.log(` ✓ collect_work_log.py → ${dst}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── 步骤 2:创建 worklog 命令 ───────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
function installCommand() {
|
|
77
|
+
fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
78
|
+
const pyScript = path.join(INSTALL_DIR, 'collect_work_log.py');
|
|
79
|
+
const cmd = BIN_DIR + '/worklog';
|
|
80
|
+
|
|
81
|
+
fs.writeFileSync(cmd, `#!/usr/bin/env bash
|
|
82
|
+
# worklog - AI 工作日志生成工具(由 ai-worklog 安装)
|
|
83
|
+
SCRIPT="${pyScript}"
|
|
84
|
+
DATE_ARG=""
|
|
85
|
+
EXTRA_ARGS=()
|
|
86
|
+
for arg in "$@"; do
|
|
87
|
+
case "$arg" in
|
|
88
|
+
--no-push|--dry-run|--no-git) EXTRA_ARGS+=("$arg") ;;
|
|
89
|
+
today|yesterday|[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) DATE_ARG="$arg" ;;
|
|
90
|
+
-*) EXTRA_ARGS+=("$arg") ;;
|
|
91
|
+
*) DATE_ARG="$arg" ;;
|
|
92
|
+
esac
|
|
93
|
+
done
|
|
94
|
+
CMD=(python3 "$SCRIPT")
|
|
95
|
+
[ -n "$DATE_ARG" ] && CMD+=(--date "$DATE_ARG")
|
|
96
|
+
CMD+=("\${EXTRA_ARGS[@]}")
|
|
97
|
+
exec "\${CMD[@]}"
|
|
98
|
+
`);
|
|
99
|
+
fs.chmodSync(cmd, 0o755);
|
|
100
|
+
console.log(` ✓ worklog 命令 → ${cmd}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── 步骤 3:添加 PATH ───────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
function setupPath() {
|
|
106
|
+
const shell = process.env.SHELL || '';
|
|
107
|
+
const rcFile = shell.includes('zsh')
|
|
108
|
+
? path.join(os.homedir(), '.zshrc')
|
|
109
|
+
: path.join(os.homedir(), '.bashrc');
|
|
110
|
+
|
|
111
|
+
if (!fs.existsSync(rcFile)) return;
|
|
112
|
+
const content = fs.readFileSync(rcFile, 'utf8');
|
|
113
|
+
if (content.includes(BIN_DIR)) {
|
|
114
|
+
console.log(` ✓ PATH 已包含 ${BIN_DIR}`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
fs.appendFileSync(rcFile, `\n# ai-worklog\nexport PATH="${BIN_DIR}:$PATH"\n`);
|
|
118
|
+
console.log(` ✓ 已添加 PATH 到 ${path.basename(rcFile)}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── 步骤 4:配置 GitLab ─────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
async function setupGitlabConfig() {
|
|
124
|
+
// 读取已有配置
|
|
125
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
126
|
+
try {
|
|
127
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
128
|
+
if (cfg.gitlab_token && cfg.gitlab_username) {
|
|
129
|
+
console.log(` ✓ 已读取配置(用户: ${cfg.gitlab_username})`);
|
|
130
|
+
return cfg;
|
|
131
|
+
}
|
|
132
|
+
} catch {}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 尝试从 glab 配置自动读取
|
|
136
|
+
let token = '', username = '';
|
|
137
|
+
const glabPaths = [
|
|
138
|
+
path.join(os.homedir(), 'Library', 'Application Support', 'glab-cli', 'config.yml'),
|
|
139
|
+
path.join(os.homedir(), '.config', 'glab-cli', 'config.yml'),
|
|
140
|
+
];
|
|
141
|
+
for (const p of glabPaths) {
|
|
142
|
+
if (!fs.existsSync(p)) continue;
|
|
143
|
+
const lines = fs.readFileSync(p, 'utf8').split('\n');
|
|
144
|
+
for (const line of lines) {
|
|
145
|
+
const t = line.match(/^\s*token:\s*["']?([^"'\s]+)/);
|
|
146
|
+
const u = line.match(/^\s*user:\s*["']?([^"'\s]+)/);
|
|
147
|
+
if (t && !token) token = t[1];
|
|
148
|
+
if (u && !username) username = u[1];
|
|
149
|
+
}
|
|
150
|
+
if (token) { console.log(' ✓ 从 glab 配置自动读取了 Token'); break; }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 未找到则手动输入
|
|
154
|
+
if (!token) {
|
|
155
|
+
console.log(`\n 需要 GitLab Personal Access Token(api + write_repository 权限)`);
|
|
156
|
+
console.log(` 获取地址: https://${GITLAB_HOST}/-/user_settings/personal_access_tokens\n`);
|
|
157
|
+
token = await prompt(' GitLab Token: ');
|
|
158
|
+
}
|
|
159
|
+
if (!username) {
|
|
160
|
+
username = await prompt(' GitLab 用户名: ');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const cfg = { gitlab_host: GITLAB_HOST, gitlab_token: token, gitlab_username: username, repo_dir: REPO_DIR };
|
|
164
|
+
fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true });
|
|
165
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2));
|
|
166
|
+
console.log(` ✓ 配置已保存到 ${CONFIG_FILE}`);
|
|
167
|
+
return cfg;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── 步骤 5:创建 GitLab 项目 ───────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
async function createGitlabProject(cfg) {
|
|
173
|
+
const payload = JSON.stringify({
|
|
174
|
+
name: 'local-work-record',
|
|
175
|
+
visibility: 'public',
|
|
176
|
+
description: 'AI 对话工作日志(由 ai-worklog 自动生成)',
|
|
177
|
+
initialize_with_readme: false,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const res = await apiRequest({
|
|
181
|
+
hostname: cfg.gitlab_host,
|
|
182
|
+
path: '/api/v4/projects',
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: {
|
|
185
|
+
'Content-Type': 'application/json',
|
|
186
|
+
'PRIVATE-TOKEN': cfg.gitlab_token,
|
|
187
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
188
|
+
},
|
|
189
|
+
}, payload);
|
|
190
|
+
|
|
191
|
+
if (res.body && res.body.ssh_url_to_repo) {
|
|
192
|
+
return res.body.ssh_url_to_repo;
|
|
193
|
+
}
|
|
194
|
+
// 已存在则直接返回 SSH URL
|
|
195
|
+
const msg = JSON.stringify(res.body?.message || res.body);
|
|
196
|
+
if (msg.includes('already been taken') || msg.includes('已')) {
|
|
197
|
+
return `git@${cfg.gitlab_host}:${cfg.gitlab_username}/local-work-record.git`;
|
|
198
|
+
}
|
|
199
|
+
throw new Error(msg);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── 步骤 6:初始化本地 git 仓库 ────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
async function setupLocalRepo(cfg) {
|
|
205
|
+
fs.mkdirSync(path.join(REPO_DIR, 'logs'), { recursive: true });
|
|
206
|
+
|
|
207
|
+
const isGit = run(['git', 'rev-parse', '--git-dir'], { cwd: REPO_DIR }).status === 0;
|
|
208
|
+
if (!isGit) {
|
|
209
|
+
run(['git', 'init'], { cwd: REPO_DIR });
|
|
210
|
+
run(['git', 'checkout', '-b', 'main'], { cwd: REPO_DIR });
|
|
211
|
+
console.log(' ✓ git init');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const hasRemote = run(['git', 'remote', 'get-url', 'origin'], { cwd: REPO_DIR }).status === 0;
|
|
215
|
+
if (hasRemote) {
|
|
216
|
+
const url = run(['git', 'remote', 'get-url', 'origin'], { cwd: REPO_DIR }).stdout.trim();
|
|
217
|
+
console.log(` ✓ remote 已存在: ${url}`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.log(` 正在 ${cfg.gitlab_host} 上创建公开项目...`);
|
|
222
|
+
try {
|
|
223
|
+
const sshUrl = await createGitlabProject(cfg);
|
|
224
|
+
run(['git', 'remote', 'add', 'origin', sshUrl], { cwd: REPO_DIR });
|
|
225
|
+
console.log(` ✓ GitLab 项目已创建: ${sshUrl}`);
|
|
226
|
+
} catch (e) {
|
|
227
|
+
console.error(` ✗ GitLab 项目创建失败: ${e.message}`);
|
|
228
|
+
console.error(` 请手动运行: git -C ${REPO_DIR} remote add origin <url>`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── 主流程 ──────────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
async function main() {
|
|
235
|
+
console.log('\n╔══════════════════════════════╗');
|
|
236
|
+
console.log('║ ai-worklog 安装配置 ║');
|
|
237
|
+
console.log('╚══════════════════════════════╝\n');
|
|
238
|
+
|
|
239
|
+
console.log('【1/5】安装脚本...');
|
|
240
|
+
installScript();
|
|
241
|
+
|
|
242
|
+
console.log('【2/5】创建 worklog 命令...');
|
|
243
|
+
installCommand();
|
|
244
|
+
|
|
245
|
+
console.log('【3/5】配置 PATH...');
|
|
246
|
+
setupPath();
|
|
247
|
+
|
|
248
|
+
console.log('【4/5】配置 GitLab...');
|
|
249
|
+
const cfg = await setupGitlabConfig();
|
|
250
|
+
|
|
251
|
+
console.log('【5/5】初始化日志仓库...');
|
|
252
|
+
await setupLocalRepo(cfg);
|
|
253
|
+
|
|
254
|
+
console.log('\n╔══════════════════════════════╗');
|
|
255
|
+
console.log('║ 安装完成 ✓ ║');
|
|
256
|
+
console.log('╚══════════════════════════════╝\n');
|
|
257
|
+
|
|
258
|
+
const inPath = process.env.PATH?.includes(BIN_DIR);
|
|
259
|
+
if (!inPath) {
|
|
260
|
+
console.log('请执行以下命令让 worklog 立即生效(或重启终端):');
|
|
261
|
+
console.log(`\n export PATH="${BIN_DIR}:$PATH"\n`);
|
|
262
|
+
}
|
|
263
|
+
console.log('使用方式:');
|
|
264
|
+
console.log(' worklog # 生成今天的日志');
|
|
265
|
+
console.log(' worklog yesterday # 生成昨天的日志');
|
|
266
|
+
console.log(' worklog 2026-03-06 # 指定日期\n');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
main().catch(e => {
|
|
270
|
+
console.error('\n安装失败:', e.message);
|
|
271
|
+
process.exit(1);
|
|
272
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-worklog",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "AI 对话工作日志自动收集工具 —— 从 Claude Code / Codex 对话记录生成每日工作日志并推送到 GitLab",
|
|
5
|
+
"bin": {
|
|
6
|
+
"ai-worklog": "./bin/index.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"scripts/collect_work_log.py"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=16"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["worklog", "claude", "ai", "gitlab", "daily-log"],
|
|
16
|
+
"author": "zheyong",
|
|
17
|
+
"license": "MIT"
|
|
18
|
+
}
|
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
工作日志自动收集脚本
|
|
4
|
+
从 Claude Code 和 Codex 对话记录中提取用户消息,调用 Claude API 生成结构化工作日志。
|
|
5
|
+
|
|
6
|
+
用法:
|
|
7
|
+
python3 collect_work_log.py [--date YYYY-MM-DD] [--no-push] [--dry-run]
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import glob
|
|
14
|
+
import argparse
|
|
15
|
+
import subprocess
|
|
16
|
+
import time
|
|
17
|
+
from datetime import datetime, timezone, timedelta
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ─── 配置 ───────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
WORKLOG_CONFIG_FILE = Path.home() / ".config" / "worklog.json"
|
|
25
|
+
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects"
|
|
26
|
+
CODEX_SESSIONS_DIR = Path.home() / ".codex" / "sessions"
|
|
27
|
+
CLAUDE_SETTINGS_FILE = Path.home() / ".claude" / "settings.json"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_repo_dir() -> Path:
|
|
31
|
+
"""从配置文件读取日志仓库路径,默认 ~/local-work-record"""
|
|
32
|
+
if WORKLOG_CONFIG_FILE.exists():
|
|
33
|
+
try:
|
|
34
|
+
cfg = json.loads(WORKLOG_CONFIG_FILE.read_text())
|
|
35
|
+
if cfg.get("repo_dir"):
|
|
36
|
+
return Path(cfg["repo_dir"]).expanduser().resolve()
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
return Path.home() / "local-work-record"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
REPO_DIR = _get_repo_dir()
|
|
43
|
+
LOGS_DIR = REPO_DIR / "logs"
|
|
44
|
+
|
|
45
|
+
# 过滤掉系统注入消息的前缀
|
|
46
|
+
SYSTEM_MESSAGE_PREFIXES = [
|
|
47
|
+
"# AGENTS.md instructions",
|
|
48
|
+
"<environment_context>",
|
|
49
|
+
"<system-reminder>",
|
|
50
|
+
"<local-command-caveat>",
|
|
51
|
+
"<command-name>",
|
|
52
|
+
"<command-message>",
|
|
53
|
+
"<local-command-stdout>",
|
|
54
|
+
"<local-command-stderr>",
|
|
55
|
+
"[Request interrupted by user",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ─── 工具函数 ─────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
def load_api_config() -> tuple[str, str]:
|
|
62
|
+
"""从 ~/.claude/settings.json 读取 API 配置"""
|
|
63
|
+
if not CLAUDE_SETTINGS_FILE.exists():
|
|
64
|
+
print("错误: 找不到 ~/.claude/settings.json", file=sys.stderr)
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
with open(CLAUDE_SETTINGS_FILE) as f:
|
|
67
|
+
settings = json.load(f)
|
|
68
|
+
env = settings.get("env", {})
|
|
69
|
+
token = env.get("ANTHROPIC_AUTH_TOKEN", "")
|
|
70
|
+
base_url = env.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com")
|
|
71
|
+
if not token:
|
|
72
|
+
print("错误: settings.json 中未找到 ANTHROPIC_AUTH_TOKEN", file=sys.stderr)
|
|
73
|
+
sys.exit(1)
|
|
74
|
+
return token, base_url.rstrip("/")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_system_message(text: str) -> bool:
|
|
78
|
+
"""判断是否为系统注入的消息,需要过滤掉"""
|
|
79
|
+
if not text or not text.strip():
|
|
80
|
+
return True
|
|
81
|
+
text_stripped = text.strip()
|
|
82
|
+
for prefix in SYSTEM_MESSAGE_PREFIXES:
|
|
83
|
+
if text_stripped.startswith(prefix):
|
|
84
|
+
return True
|
|
85
|
+
# 含超过 20 个 '<' 的消息(大量 XML 标签)
|
|
86
|
+
if text.count("<") > 20:
|
|
87
|
+
return True
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def utc_to_local_date(utc_ts: str) -> Optional[str]:
|
|
92
|
+
"""将 UTC ISO8601 时间戳转换为本地日期字符串 YYYY-MM-DD"""
|
|
93
|
+
try:
|
|
94
|
+
# 兼容 .000Z 和 +00:00 两种格式
|
|
95
|
+
ts = utc_ts.replace("Z", "+00:00")
|
|
96
|
+
dt_utc = datetime.fromisoformat(ts)
|
|
97
|
+
dt_local = dt_utc.astimezone()
|
|
98
|
+
return dt_local.strftime("%Y-%m-%d")
|
|
99
|
+
except Exception:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ─── Claude Code 数据收集 ─────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
def collect_claude_sessions(target_date: str) -> dict[str, list[str]]:
|
|
106
|
+
"""
|
|
107
|
+
遍历 ~/.claude/projects/ 下所有 jsonl,
|
|
108
|
+
按 message timestamp 过滤当天(UTC→本地),提取 user 消息,
|
|
109
|
+
按项目名(cwd basename)分组。
|
|
110
|
+
|
|
111
|
+
返回: {project_name: [message1, message2, ...]}
|
|
112
|
+
"""
|
|
113
|
+
results: dict[str, list[str]] = {}
|
|
114
|
+
|
|
115
|
+
if not CLAUDE_PROJECTS_DIR.exists():
|
|
116
|
+
return results
|
|
117
|
+
|
|
118
|
+
for project_dir in CLAUDE_PROJECTS_DIR.iterdir():
|
|
119
|
+
if not project_dir.is_dir():
|
|
120
|
+
continue
|
|
121
|
+
for jsonl_file in project_dir.glob("*.jsonl"):
|
|
122
|
+
_parse_claude_jsonl(jsonl_file, target_date, results)
|
|
123
|
+
|
|
124
|
+
return results
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parse_claude_jsonl(
|
|
128
|
+
jsonl_file: Path, target_date: str, results: dict[str, list[str]]
|
|
129
|
+
) -> None:
|
|
130
|
+
"""解析单个 Claude Code jsonl 文件"""
|
|
131
|
+
try:
|
|
132
|
+
with open(jsonl_file, encoding="utf-8", errors="ignore") as f:
|
|
133
|
+
for line in f:
|
|
134
|
+
line = line.strip()
|
|
135
|
+
if not line:
|
|
136
|
+
continue
|
|
137
|
+
try:
|
|
138
|
+
entry = json.loads(line)
|
|
139
|
+
except json.JSONDecodeError:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
if entry.get("type") != "user":
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
# 按时间戳过滤日期
|
|
146
|
+
ts = entry.get("timestamp", "")
|
|
147
|
+
if ts and utc_to_local_date(ts) != target_date:
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
# 提取项目名
|
|
151
|
+
cwd = entry.get("cwd", "")
|
|
152
|
+
project_name = os.path.basename(cwd) if cwd else "unknown"
|
|
153
|
+
|
|
154
|
+
# 提取消息内容
|
|
155
|
+
message = entry.get("message", {})
|
|
156
|
+
content = message.get("content", "")
|
|
157
|
+
texts = []
|
|
158
|
+
if isinstance(content, str):
|
|
159
|
+
texts = [content]
|
|
160
|
+
elif isinstance(content, list):
|
|
161
|
+
for c in content:
|
|
162
|
+
if isinstance(c, dict) and c.get("type") == "text":
|
|
163
|
+
texts.append(c.get("text", ""))
|
|
164
|
+
elif isinstance(c, str):
|
|
165
|
+
texts.append(c)
|
|
166
|
+
|
|
167
|
+
for text in texts:
|
|
168
|
+
if text and not is_system_message(text):
|
|
169
|
+
results.setdefault(project_name, []).append(text.strip())
|
|
170
|
+
|
|
171
|
+
except Exception as e:
|
|
172
|
+
print(f"警告: 解析 {jsonl_file} 失败: {e}", file=sys.stderr)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ─── Codex 数据收集 ───────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
def collect_codex_sessions(target_date: str) -> dict[str, list[str]]:
|
|
178
|
+
"""
|
|
179
|
+
读取 ~/.codex/sessions/YYYY/MM/DD/ 下所有 rollout-*.jsonl,
|
|
180
|
+
提取 event_msg + user_message,按项目名(cwd basename)分组。
|
|
181
|
+
|
|
182
|
+
返回: {project_name: [message1, message2, ...]}
|
|
183
|
+
"""
|
|
184
|
+
results: dict[str, list[str]] = {}
|
|
185
|
+
|
|
186
|
+
if not CODEX_SESSIONS_DIR.exists():
|
|
187
|
+
return results
|
|
188
|
+
|
|
189
|
+
# 解析目标日期
|
|
190
|
+
try:
|
|
191
|
+
dt = datetime.strptime(target_date, "%Y-%m-%d")
|
|
192
|
+
except ValueError:
|
|
193
|
+
return results
|
|
194
|
+
|
|
195
|
+
date_dir = CODEX_SESSIONS_DIR / dt.strftime("%Y") / dt.strftime("%m") / dt.strftime("%d")
|
|
196
|
+
if not date_dir.exists():
|
|
197
|
+
return results
|
|
198
|
+
|
|
199
|
+
for jsonl_file in date_dir.glob("rollout-*.jsonl"):
|
|
200
|
+
_parse_codex_jsonl(jsonl_file, results)
|
|
201
|
+
|
|
202
|
+
return results
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _parse_codex_jsonl(jsonl_file: Path, results: dict[str, list[str]]) -> None:
|
|
206
|
+
"""解析单个 Codex jsonl 文件"""
|
|
207
|
+
cwd = ""
|
|
208
|
+
try:
|
|
209
|
+
with open(jsonl_file, encoding="utf-8", errors="ignore") as f:
|
|
210
|
+
for line in f:
|
|
211
|
+
line = line.strip()
|
|
212
|
+
if not line:
|
|
213
|
+
continue
|
|
214
|
+
try:
|
|
215
|
+
entry = json.loads(line)
|
|
216
|
+
except json.JSONDecodeError:
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
entry_type = entry.get("type", "")
|
|
220
|
+
payload = entry.get("payload", {})
|
|
221
|
+
if not isinstance(payload, dict):
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
# 获取 cwd
|
|
225
|
+
if entry_type == "session_meta":
|
|
226
|
+
cwd = payload.get("cwd", "")
|
|
227
|
+
|
|
228
|
+
# 提取用户消息
|
|
229
|
+
elif entry_type == "event_msg" and payload.get("type") == "user_message":
|
|
230
|
+
text = payload.get("message", "")
|
|
231
|
+
if text and not is_system_message(text):
|
|
232
|
+
project_name = os.path.basename(cwd) if cwd else "unknown"
|
|
233
|
+
results.setdefault(project_name, []).append(text.strip())
|
|
234
|
+
|
|
235
|
+
except Exception as e:
|
|
236
|
+
print(f"警告: 解析 {jsonl_file} 失败: {e}", file=sys.stderr)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ─── AI 摘要生成 ──────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
MODEL = "claude-haiku-4-5-20251001"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _call_api_claude_cli(prompt: str) -> str:
|
|
245
|
+
"""用 claude -p 调用 API(复用 Claude Code CLI 认证,绕过代理限制)"""
|
|
246
|
+
env = os.environ.copy()
|
|
247
|
+
env.pop("CLAUDECODE", None)
|
|
248
|
+
env.pop("CLAUDE_CODE_ENTRYPOINT", None)
|
|
249
|
+
|
|
250
|
+
cmd = ["claude", "-p", "--model", MODEL, prompt]
|
|
251
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=180, env=env)
|
|
252
|
+
if result.returncode != 0:
|
|
253
|
+
raise RuntimeError(result.stderr.strip() or result.stdout[:300])
|
|
254
|
+
output = result.stdout.strip()
|
|
255
|
+
if not output:
|
|
256
|
+
raise RuntimeError("返回空内容")
|
|
257
|
+
return output
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def generate_summary(
|
|
261
|
+
target_date: str,
|
|
262
|
+
claude_data: dict[str, list[str]],
|
|
263
|
+
codex_data: dict[str, list[str]],
|
|
264
|
+
) -> str:
|
|
265
|
+
"""调用 Claude API 生成结构化工作日志"""
|
|
266
|
+
|
|
267
|
+
# 合并数据(同项目名合并)
|
|
268
|
+
all_projects: dict[str, dict] = {}
|
|
269
|
+
for proj, msgs in claude_data.items():
|
|
270
|
+
all_projects.setdefault(proj, {"claude": [], "codex": []})["claude"].extend(msgs)
|
|
271
|
+
for proj, msgs in codex_data.items():
|
|
272
|
+
all_projects.setdefault(proj, {"claude": [], "codex": []})["codex"].extend(msgs)
|
|
273
|
+
|
|
274
|
+
total_claude = sum(len(v["claude"]) for v in all_projects.values())
|
|
275
|
+
total_codex = sum(len(v["codex"]) for v in all_projects.values())
|
|
276
|
+
total_sessions = total_claude + total_codex
|
|
277
|
+
|
|
278
|
+
if total_sessions == 0:
|
|
279
|
+
return _generate_empty_log(target_date)
|
|
280
|
+
|
|
281
|
+
# 构建给 AI 的原始数据
|
|
282
|
+
project_sections = []
|
|
283
|
+
for proj_name, data in all_projects.items():
|
|
284
|
+
msgs_parts = []
|
|
285
|
+
if data["claude"]:
|
|
286
|
+
msgs_parts.append(f"[Claude Code 对话 {len(data['claude'])} 条]")
|
|
287
|
+
for i, m in enumerate(data["claude"], 1):
|
|
288
|
+
# 截断过长消息
|
|
289
|
+
msg = m if len(m) <= 500 else m[:500] + "..."
|
|
290
|
+
msgs_parts.append(f" {i}. {msg}")
|
|
291
|
+
if data["codex"]:
|
|
292
|
+
msgs_parts.append(f"[Codex 对话 {len(data['codex'])} 条]")
|
|
293
|
+
for i, m in enumerate(data["codex"], 1):
|
|
294
|
+
msg = m if len(m) <= 500 else m[:500] + "..."
|
|
295
|
+
msgs_parts.append(f" {i}. {msg}")
|
|
296
|
+
project_sections.append(f"项目: {proj_name}\n" + "\n".join(msgs_parts))
|
|
297
|
+
|
|
298
|
+
raw_data = "\n\n".join(project_sections)
|
|
299
|
+
|
|
300
|
+
prompt = f"""你是一个技术团队的工作日志助手。请根据以下 AI 对话记录,为工程师生成一份结构化的工作日志。
|
|
301
|
+
|
|
302
|
+
日期: {target_date}
|
|
303
|
+
涉及项目数: {len(all_projects)}
|
|
304
|
+
AI 对话总计: {total_sessions} 条(Claude Code: {total_claude},Codex: {total_codex})
|
|
305
|
+
|
|
306
|
+
=== 原始对话记录 ===
|
|
307
|
+
{raw_data}
|
|
308
|
+
=== 原始对话记录结束 ===
|
|
309
|
+
|
|
310
|
+
请按以下 Markdown 格式生成工作日志,要求:
|
|
311
|
+
1. 每个项目的工作内容描述要具体、准确,反映实际技术工作
|
|
312
|
+
2. 工作类型从以下选择(可组合):探索/开发/设计/讨论/调试/重构/分析
|
|
313
|
+
3. 关键决策要简明扼要,说明技术选择或方向
|
|
314
|
+
4. **不估算工时**,工时由工程师自报
|
|
315
|
+
5. 今日总结要简洁,概括整体工作重心
|
|
316
|
+
6. 若某项目只有简短/零散的问题,也要如实描述
|
|
317
|
+
|
|
318
|
+
输出格式(严格遵守,不要加额外说明):
|
|
319
|
+
|
|
320
|
+
## 项目工作详情
|
|
321
|
+
|
|
322
|
+
### [{proj_name}] <一句话概括>
|
|
323
|
+
**工作类型:** <类型>
|
|
324
|
+
**主要工作:**
|
|
325
|
+
- <具体工作点1>
|
|
326
|
+
- <具体工作点2>
|
|
327
|
+
**关键决策:**
|
|
328
|
+
- <决策1>(如无则写"无")
|
|
329
|
+
|
|
330
|
+
(每个项目重复以上结构)
|
|
331
|
+
|
|
332
|
+
## 今日总结
|
|
333
|
+
<2-3句话概括今日整体工作重心和产出>"""
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
ai_content = _call_api_claude_cli(prompt)
|
|
337
|
+
except Exception as e:
|
|
338
|
+
print(f"API 请求失败: {e}", file=sys.stderr)
|
|
339
|
+
ai_content = f"[AI 摘要生成失败: {e}]"
|
|
340
|
+
|
|
341
|
+
# 组装最终 Markdown
|
|
342
|
+
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
343
|
+
header = f"""# 工作日志 - {target_date}
|
|
344
|
+
|
|
345
|
+
## 今日概览
|
|
346
|
+
- 涉及项目数: {len(all_projects)}
|
|
347
|
+
- AI 对话次数: {total_sessions}(Claude Code: {total_claude},Codex: {total_codex})
|
|
348
|
+
|
|
349
|
+
"""
|
|
350
|
+
footer = f"\n\n---\n*由 collect_work_log.py 自动生成于 {now_str}*\n"
|
|
351
|
+
|
|
352
|
+
return header + ai_content + footer
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _generate_empty_log(target_date: str) -> str:
|
|
356
|
+
"""无对话记录时生成空日志"""
|
|
357
|
+
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
358
|
+
return f"""# 工作日志 - {target_date}
|
|
359
|
+
|
|
360
|
+
## 今日概览
|
|
361
|
+
- 涉及项目数: 0
|
|
362
|
+
- AI 对话次数: 0(Claude Code: 0,Codex: 0)
|
|
363
|
+
|
|
364
|
+
## 项目工作详情
|
|
365
|
+
*今日无 AI 对话记录*
|
|
366
|
+
|
|
367
|
+
## 今日总结
|
|
368
|
+
*今日无 AI 对话记录。如有工作内容,请手动补充。*
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
*由 collect_work_log.py 自动生成于 {now_str}*
|
|
372
|
+
"""
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ─── 文件保存 & Git 操作 ──────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
def save_log(target_date: str, content: str) -> Path:
|
|
378
|
+
"""保存日志文件到 logs/YYYY/YYYY-MM-DD.md"""
|
|
379
|
+
year = target_date[:4]
|
|
380
|
+
log_dir = LOGS_DIR / year
|
|
381
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
382
|
+
log_file = log_dir / f"{target_date}.md"
|
|
383
|
+
log_file.write_text(content, encoding="utf-8")
|
|
384
|
+
print(f"日志已保存: {log_file}")
|
|
385
|
+
return log_file
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
GITLAB_HOST = "gitcode.lingjingai.cn"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def load_gitlab_config() -> dict:
|
|
392
|
+
"""
|
|
393
|
+
读取 GitLab 配置。优先读 ~/.config/worklog.json,
|
|
394
|
+
其次尝试从 glab config 读取 token,否则交互式引导首次配置。
|
|
395
|
+
配置项: gitlab_host, gitlab_token, gitlab_username
|
|
396
|
+
"""
|
|
397
|
+
if WORKLOG_CONFIG_FILE.exists():
|
|
398
|
+
cfg = json.loads(WORKLOG_CONFIG_FILE.read_text())
|
|
399
|
+
if cfg.get("gitlab_token") and cfg.get("gitlab_username"):
|
|
400
|
+
return cfg
|
|
401
|
+
|
|
402
|
+
# 尝试从 glab 读取 token(不强依赖 glab,失败忽略)
|
|
403
|
+
token = ""
|
|
404
|
+
username = ""
|
|
405
|
+
glab_cfg = Path.home() / "Library" / "Application Support" / "glab-cli" / "config.yml"
|
|
406
|
+
if not glab_cfg.exists():
|
|
407
|
+
glab_cfg = Path.home() / ".config" / "glab-cli" / "config.yml"
|
|
408
|
+
if glab_cfg.exists():
|
|
409
|
+
for line in glab_cfg.read_text().splitlines():
|
|
410
|
+
line = line.strip()
|
|
411
|
+
if "token:" in line and not token:
|
|
412
|
+
token = line.split("token:", 1)[-1].strip().strip('"')
|
|
413
|
+
if "user:" in line and not username:
|
|
414
|
+
username = line.split("user:", 1)[-1].strip().strip('"')
|
|
415
|
+
|
|
416
|
+
if not token or not username:
|
|
417
|
+
print("\n首次配置 GitLab,请输入以下信息:")
|
|
418
|
+
print(f" GitLab 地址: {GITLAB_HOST}")
|
|
419
|
+
print(" Personal Access Token 创建地址:")
|
|
420
|
+
print(f" https://{GITLAB_HOST}/-/user_settings/personal_access_tokens")
|
|
421
|
+
print(" (需要 api 权限)\n")
|
|
422
|
+
token = input("请输入 GitLab Personal Access Token: ").strip()
|
|
423
|
+
username = input("请输入 GitLab 用户名: ").strip()
|
|
424
|
+
|
|
425
|
+
cfg = {"gitlab_host": GITLAB_HOST, "gitlab_token": token, "gitlab_username": username}
|
|
426
|
+
WORKLOG_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
427
|
+
WORKLOG_CONFIG_FILE.write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
|
|
428
|
+
print(f"配置已保存到 {WORKLOG_CONFIG_FILE}")
|
|
429
|
+
return cfg
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def gitlab_create_project(cfg: dict, repo_name: str) -> str:
|
|
433
|
+
"""
|
|
434
|
+
用 GitLab REST API 创建公开项目,返回 SSH clone URL。
|
|
435
|
+
无需 glab,只依赖 curl。
|
|
436
|
+
"""
|
|
437
|
+
host = cfg["gitlab_host"]
|
|
438
|
+
token = cfg["gitlab_token"]
|
|
439
|
+
payload = json.dumps({
|
|
440
|
+
"name": repo_name,
|
|
441
|
+
"visibility": "public",
|
|
442
|
+
"description": "AI 对话工作日志(由 worklog 自动生成)",
|
|
443
|
+
"initialize_with_readme": False,
|
|
444
|
+
})
|
|
445
|
+
cmd = [
|
|
446
|
+
"curl", "-sS", "-k",
|
|
447
|
+
"-X", "POST", f"https://{host}/api/v4/projects",
|
|
448
|
+
"-H", "Content-Type: application/json",
|
|
449
|
+
"-H", f"PRIVATE-TOKEN: {token}",
|
|
450
|
+
"-d", payload,
|
|
451
|
+
]
|
|
452
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
453
|
+
if r.returncode != 0:
|
|
454
|
+
raise RuntimeError(f"curl 失败: {r.stderr.strip()}")
|
|
455
|
+
data = json.loads(r.stdout)
|
|
456
|
+
if "message" in data and "ssh_url_to_repo" not in data:
|
|
457
|
+
raise RuntimeError(f"GitLab API 错误: {data['message']}")
|
|
458
|
+
return data["ssh_url_to_repo"]
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def git_commit_and_push(log_file: Path, target_date: str, push: bool = True) -> None:
|
|
462
|
+
"""全自动 git init → GitLab 项目创建 → commit → push"""
|
|
463
|
+
|
|
464
|
+
def run(cmd: list[str], check_err: bool = True) -> subprocess.CompletedProcess:
|
|
465
|
+
r = subprocess.run(cmd, cwd=REPO_DIR, capture_output=True, text=True)
|
|
466
|
+
if check_err and r.returncode != 0:
|
|
467
|
+
print(f"命令失败: {' '.join(cmd)}\n{r.stderr.strip()}", file=sys.stderr)
|
|
468
|
+
return r
|
|
469
|
+
|
|
470
|
+
# ── 1. 初始化 git 仓库 ──────────────────────────────────────────────────
|
|
471
|
+
is_git = run(["git", "rev-parse", "--git-dir"], check_err=False).returncode == 0
|
|
472
|
+
if not is_git:
|
|
473
|
+
print("Git: 初始化仓库...")
|
|
474
|
+
if run(["git", "init"]).returncode != 0:
|
|
475
|
+
return
|
|
476
|
+
run(["git", "checkout", "-b", "main"], check_err=False)
|
|
477
|
+
|
|
478
|
+
# ── 2. 检查并创建 GitLab 远程(无需 glab)────────────────────────────────
|
|
479
|
+
has_remote = run(["git", "remote", "get-url", "origin"], check_err=False).returncode == 0
|
|
480
|
+
if not has_remote:
|
|
481
|
+
repo_name = REPO_DIR.name
|
|
482
|
+
print(f"GitLab: 创建公开项目 {repo_name} ...")
|
|
483
|
+
try:
|
|
484
|
+
cfg = load_gitlab_config()
|
|
485
|
+
ssh_url = gitlab_create_project(cfg, repo_name)
|
|
486
|
+
run(["git", "remote", "add", "origin", ssh_url])
|
|
487
|
+
print(f"GitLab: 项目已创建 → {ssh_url}")
|
|
488
|
+
except Exception as e:
|
|
489
|
+
print(f"GitLab 创建失败: {e}", file=sys.stderr)
|
|
490
|
+
print("请手动设置 remote: git remote add origin <url>", file=sys.stderr)
|
|
491
|
+
return
|
|
492
|
+
|
|
493
|
+
# ── 3. 确保有初始提交 ─────────────────────────────────────────────────────
|
|
494
|
+
if run(["git", "rev-parse", "HEAD"], check_err=False).returncode != 0:
|
|
495
|
+
print("Git: 创建初始提交...")
|
|
496
|
+
run(["git", "add", "-A"])
|
|
497
|
+
run(["git", "commit", "-m", "init: 初始化工作日志仓库"])
|
|
498
|
+
|
|
499
|
+
# ── 4. 提交日志 ────────────────────────────────────────────────────────────
|
|
500
|
+
rel_path = log_file.relative_to(REPO_DIR)
|
|
501
|
+
print(f"Git: 添加 {rel_path}")
|
|
502
|
+
if run(["git", "add", str(rel_path)]).returncode != 0:
|
|
503
|
+
return
|
|
504
|
+
|
|
505
|
+
commit_msg = f"docs: 工作日志 {target_date}"
|
|
506
|
+
print(f"Git: 提交 '{commit_msg}'")
|
|
507
|
+
r = run(["git", "commit", "-m", commit_msg], check_err=False)
|
|
508
|
+
if r.returncode != 0:
|
|
509
|
+
if "nothing to commit" in r.stdout + r.stderr:
|
|
510
|
+
print("Git: 无变更,跳过提交")
|
|
511
|
+
else:
|
|
512
|
+
print(f"Git commit 失败: {r.stderr.strip()}", file=sys.stderr)
|
|
513
|
+
return
|
|
514
|
+
|
|
515
|
+
# ── 5. 推送 ────────────────────────────────────────────────────────────────
|
|
516
|
+
if push:
|
|
517
|
+
print("Git: 推送到远程...")
|
|
518
|
+
r = run(["git", "push", "--set-upstream", "origin", "main"], check_err=False)
|
|
519
|
+
if r.returncode != 0:
|
|
520
|
+
r = run(["git", "push"])
|
|
521
|
+
if r.returncode == 0:
|
|
522
|
+
print("Git: 推送成功 ✓")
|
|
523
|
+
else:
|
|
524
|
+
print("Git: 已跳过 push(--no-push 模式)")
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# ─── 主入口 ───────────────────────────────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
def parse_date_arg(date_arg: Optional[str]) -> str:
|
|
530
|
+
"""解析日期参数,返回 YYYY-MM-DD 格式"""
|
|
531
|
+
if not date_arg or date_arg == "today":
|
|
532
|
+
return datetime.now().strftime("%Y-%m-%d")
|
|
533
|
+
if date_arg == "yesterday":
|
|
534
|
+
return (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
|
|
535
|
+
# 验证格式
|
|
536
|
+
try:
|
|
537
|
+
datetime.strptime(date_arg, "%Y-%m-%d")
|
|
538
|
+
return date_arg
|
|
539
|
+
except ValueError:
|
|
540
|
+
print(f"错误: 日期格式错误 '{date_arg}',应为 YYYY-MM-DD 或 today/yesterday", file=sys.stderr)
|
|
541
|
+
sys.exit(1)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def main():
|
|
545
|
+
parser = argparse.ArgumentParser(description="收集 AI 对话记录,生成工作日志")
|
|
546
|
+
parser.add_argument(
|
|
547
|
+
"--date", "-d",
|
|
548
|
+
default=None,
|
|
549
|
+
help="目标日期 (YYYY-MM-DD, today, yesterday),默认今天",
|
|
550
|
+
)
|
|
551
|
+
parser.add_argument(
|
|
552
|
+
"--no-push",
|
|
553
|
+
action="store_true",
|
|
554
|
+
help="生成并 commit,但不 push",
|
|
555
|
+
)
|
|
556
|
+
parser.add_argument(
|
|
557
|
+
"--dry-run",
|
|
558
|
+
action="store_true",
|
|
559
|
+
help="仅收集数据,不调用 API、不保存文件",
|
|
560
|
+
)
|
|
561
|
+
parser.add_argument(
|
|
562
|
+
"--no-git",
|
|
563
|
+
action="store_true",
|
|
564
|
+
help="不执行任何 git 操作",
|
|
565
|
+
)
|
|
566
|
+
args = parser.parse_args()
|
|
567
|
+
|
|
568
|
+
target_date = parse_date_arg(args.date)
|
|
569
|
+
print(f"=== 收集 {target_date} 的工作日志 ===")
|
|
570
|
+
|
|
571
|
+
# 1. 收集数据
|
|
572
|
+
print("收集 Claude Code 对话记录...")
|
|
573
|
+
claude_data = collect_claude_sessions(target_date)
|
|
574
|
+
total_claude = sum(len(v) for v in claude_data.values())
|
|
575
|
+
print(f" 找到 {len(claude_data)} 个项目,{total_claude} 条消息")
|
|
576
|
+
|
|
577
|
+
print("收集 Codex 对话记录...")
|
|
578
|
+
codex_data = collect_codex_sessions(target_date)
|
|
579
|
+
total_codex = sum(len(v) for v in codex_data.values())
|
|
580
|
+
print(f" 找到 {len(codex_data)} 个项目,{total_codex} 条消息")
|
|
581
|
+
|
|
582
|
+
if args.dry_run:
|
|
583
|
+
print("\n[dry-run] 数据预览:")
|
|
584
|
+
for proj, msgs in {**claude_data, **codex_data}.items():
|
|
585
|
+
print(f" [{proj}] {len(msgs)} 条")
|
|
586
|
+
for m in msgs[:2]:
|
|
587
|
+
print(f" - {m[:80]}")
|
|
588
|
+
return
|
|
589
|
+
|
|
590
|
+
# 2. 生成摘要(通过 claude -p 复用 Claude Code CLI 认证)
|
|
591
|
+
print("调用 Claude API 生成摘要...")
|
|
592
|
+
content = generate_summary(target_date, claude_data, codex_data)
|
|
593
|
+
|
|
594
|
+
# 4. 保存文件
|
|
595
|
+
log_file = save_log(target_date, content)
|
|
596
|
+
|
|
597
|
+
# 5. Git 操作
|
|
598
|
+
if not args.no_git:
|
|
599
|
+
git_commit_and_push(log_file, target_date, push=not args.no_push)
|
|
600
|
+
|
|
601
|
+
print(f"\n完成!日志文件: {log_file}")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
if __name__ == "__main__":
|
|
605
|
+
main()
|