acm-cli 0.1.0__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.
acm/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ACM-CLI: 基于 LLM 的 Git Commit Message 自动生成工具"""
2
+
3
+ __version__ = "0.1.0"
acm/cli.py ADDED
@@ -0,0 +1,472 @@
1
+ """CLI 入口:click 命令组,默认命令、init、config 子命令。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from acm import __version__
11
+ from acm.config import (
12
+ ACMConfig,
13
+ CommitConfig,
14
+ ConfigError,
15
+ GLOBAL_CONFIG_DIR,
16
+ GLOBAL_CONFIG_FILE,
17
+ LLMConfig,
18
+ PROJECT_CONFIG_FILE,
19
+ PROVIDER_DEFAULTS,
20
+ config_get,
21
+ config_set,
22
+ load_config,
23
+ save_config,
24
+ validate_config,
25
+ )
26
+ from acm.feedback import add_feedback, clear_feedback, load_feedback
27
+ from acm.generator import CommitGenerator, GenerateError
28
+ from acm.git import (
29
+ DiffResult,
30
+ GitError,
31
+ add_all,
32
+ build_diff_result,
33
+ commit,
34
+ commit_files,
35
+ get_staged_files,
36
+ has_unstaged_changes,
37
+ has_untracked_files,
38
+ is_git_repo,
39
+ )
40
+ from acm.llm import create_llm
41
+ from acm.splitter import CommitSplitter, SplitError
42
+ from acm.ui import (
43
+ console,
44
+ print_error,
45
+ print_info,
46
+ print_success,
47
+ print_verbose,
48
+ print_warning,
49
+ prompt_action,
50
+ prompt_confirm,
51
+ prompt_edit,
52
+ prompt_feedback,
53
+ show_commit_message,
54
+ show_diff_summary,
55
+ show_split_suggestion,
56
+ spinner,
57
+ stream_commit_message,
58
+ warn_large_commit,
59
+ )
60
+
61
+
62
+ LARGE_COMMIT_THRESHOLD = 20
63
+
64
+
65
+ @click.group(invoke_without_command=True)
66
+ @click.option("--dry-run", is_flag=True, help="仅生成 message,不执行 git commit")
67
+ @click.option("--verbose", is_flag=True, help="输出详细调试信息")
68
+ @click.option("--split", is_flag=True, help="智能拆分为多个原子提交")
69
+ @click.version_option(__version__, prog_name="acm")
70
+ @click.pass_context
71
+ def main(ctx: click.Context, dry_run: bool, verbose: bool, split: bool) -> None:
72
+ """ACM - 基于 LLM 的 Git Commit Message 自动生成工具。"""
73
+ ctx.ensure_object(dict)
74
+ ctx.obj["verbose"] = verbose
75
+
76
+ if ctx.invoked_subcommand is not None:
77
+ return
78
+
79
+ # 主流程:生成 commit message
80
+ _run_generate(dry_run=dry_run, verbose=verbose, split_mode=split)
81
+
82
+
83
+ def _run_generate(dry_run: bool, verbose: bool, split_mode: bool) -> None:
84
+ """主流程:分析 staged 内容,生成 commit message。"""
85
+ # 1. 检查 Git 仓库
86
+ if not is_git_repo():
87
+ print_error("当前目录不是 Git 仓库,请在 Git 项目根目录下运行")
88
+ sys.exit(1)
89
+
90
+ # 2. 检查 staged 文件
91
+ try:
92
+ staged_files = get_staged_files()
93
+ except GitError as e:
94
+ print_error(str(e))
95
+ sys.exit(1)
96
+
97
+ if not staged_files:
98
+ has_unstaged = has_unstaged_changes()
99
+ has_untracked = has_untracked_files()
100
+ if has_unstaged or has_untracked:
101
+ if has_unstaged and has_untracked:
102
+ print_warning("暂存区为空,但检测到未暂存的修改和未跟踪的新文件")
103
+ elif has_unstaged:
104
+ print_warning("暂存区为空,但检测到未暂存的修改")
105
+ else:
106
+ print_warning("暂存区为空,但检测到未跟踪的新文件")
107
+
108
+ if prompt_confirm("是否执行 git add . 将所有变更添加到暂存区并继续?"):
109
+ try:
110
+ add_all()
111
+ print_success("已执行 git add -A,所有变更已添加到暂存区")
112
+ # 重新获取 staged 文件并继续流程
113
+ staged_files = get_staged_files()
114
+ if not staged_files:
115
+ print_error("执行 git add 后暂存区仍为空")
116
+ sys.exit(1)
117
+ except GitError as e:
118
+ print_error(f"执行 git add 失败: {e}")
119
+ sys.exit(1)
120
+ else:
121
+ print_info("已取消,请手动执行 git add 后再重试")
122
+ sys.exit(0)
123
+ else:
124
+ print_error("暂存区为空,没有需要提交的变更")
125
+ sys.exit(1)
126
+
127
+ # 3. 大提交警告
128
+ if len(staged_files) > LARGE_COMMIT_THRESHOLD:
129
+ if not warn_large_commit(len(staged_files)):
130
+ sys.exit(0)
131
+
132
+ # 4. 加载配置
133
+ try:
134
+ config = load_config()
135
+ validate_config(config)
136
+ except ConfigError as e:
137
+ print_error(str(e))
138
+ sys.exit(1)
139
+
140
+ if verbose:
141
+ _show_verbose_config(config)
142
+
143
+ # 5. 获取 diff
144
+ try:
145
+ diff_result = build_diff_result(max_diff_lines=config.commit.max_diff_lines)
146
+ except GitError as e:
147
+ print_error(str(e))
148
+ sys.exit(1)
149
+
150
+ show_diff_summary(diff_result)
151
+
152
+ # 6. 创建 LLM 实例
153
+ try:
154
+ llm = create_llm(config)
155
+ except Exception as e:
156
+ print_error(f"创建 LLM 实例失败: {e}")
157
+ sys.exit(1)
158
+
159
+ # 7. 智能拆分模式
160
+ if split_mode:
161
+ _run_split_mode(llm, config, diff_result, staged_files, dry_run, verbose)
162
+ return
163
+
164
+ # 8. 常规生成模式(流式输出)
165
+ generator = CommitGenerator(llm, config)
166
+
167
+ try:
168
+ stream = generator.stream_generate(diff_result.diff_content)
169
+ message = stream_commit_message(stream)
170
+ except (GenerateError, Exception) as e:
171
+ print_error(f"LLM 接口调用失败: {e}")
172
+ if verbose:
173
+ import traceback
174
+ print_verbose(traceback.format_exc())
175
+ sys.exit(1)
176
+
177
+ if verbose:
178
+ print_verbose(f"LLM 原始响应: {message}")
179
+
180
+ show_commit_message(message)
181
+
182
+ if dry_run:
183
+ print_info("(dry-run 模式,不执行 git commit)")
184
+ return
185
+
186
+ # 9. 交互循环
187
+ _interaction_loop(generator, diff_result, message, verbose)
188
+
189
+
190
+ def _interaction_loop(
191
+ generator: CommitGenerator,
192
+ diff_result: DiffResult,
193
+ message: str,
194
+ verbose: bool,
195
+ ) -> None:
196
+ """用户交互循环:确认/编辑/反馈/放弃。"""
197
+ while True:
198
+ action = prompt_action()
199
+
200
+ if action == "y":
201
+ _do_commit(message)
202
+ return
203
+
204
+ elif action == "e":
205
+ previous_message = message
206
+ message = prompt_edit(message)
207
+ # 记录编辑偏好(仅当用户实际修改了 message 时)
208
+ if message != previous_message:
209
+ try:
210
+ add_feedback(previous_message, "用户手动编辑", message)
211
+ except Exception:
212
+ pass
213
+ show_commit_message(message)
214
+ continue
215
+
216
+ elif action == "r":
217
+ feedback = prompt_feedback()
218
+ if not feedback:
219
+ print_warning("未输入反馈内容,请重新选择操作")
220
+ show_commit_message(message)
221
+ continue
222
+
223
+ previous_message = message
224
+ try:
225
+ stream = generator.stream_regenerate(feedback)
226
+ message = stream_commit_message(stream)
227
+ except (GenerateError, Exception) as e:
228
+ print_error(f"重新生成失败: {e}")
229
+ if verbose:
230
+ import traceback
231
+ print_verbose(traceback.format_exc())
232
+ continue
233
+
234
+ # 记录反馈到本地,用于后续 prompt 优化
235
+ try:
236
+ add_feedback(previous_message, feedback, message)
237
+ except Exception:
238
+ pass # 反馈记录失败不影响主流程
239
+
240
+ show_commit_message(message)
241
+ continue
242
+
243
+ elif action == "q":
244
+ print_info("已放弃提交")
245
+ return
246
+
247
+
248
+ def _do_commit(message: str) -> None:
249
+ """执行 git commit。"""
250
+ try:
251
+ output = commit(message)
252
+ print_success("提交成功!")
253
+ console.print(f"[dim]{output.strip()}[/dim]")
254
+ except GitError as e:
255
+ print_error(f"提交失败: {e}")
256
+
257
+
258
+ def _run_split_mode(
259
+ llm,
260
+ config: ACMConfig,
261
+ diff_result: DiffResult,
262
+ staged_files,
263
+ dry_run: bool,
264
+ verbose: bool,
265
+ ) -> None:
266
+ """智能拆分模式。"""
267
+ splitter = CommitSplitter(llm, config)
268
+ file_paths = [f.path for f in staged_files]
269
+
270
+ with spinner("分析变更,生成拆分建议..."):
271
+ try:
272
+ groups = splitter.split(diff_result.diff_content, file_paths)
273
+ except (SplitError, Exception) as e:
274
+ print_error(f"拆分分析失败: {e}")
275
+ sys.exit(1)
276
+
277
+ show_split_suggestion(groups)
278
+
279
+ if dry_run:
280
+ print_info("(dry-run 模式,不执行 git commit)")
281
+ return
282
+
283
+ if not prompt_confirm("是否按上述方案执行拆分提交?"):
284
+ print_info("已取消拆分提交")
285
+ return
286
+
287
+ for i, group in enumerate(groups, 1):
288
+ try:
289
+ output = commit_files(group.files, group.message)
290
+ print_success(f"提交 {i}/{len(groups)} 完成: {group.message}")
291
+ except GitError as e:
292
+ print_error(f"提交 {i} 失败: {e}")
293
+ return
294
+
295
+
296
+ def _show_verbose_config(config: ACMConfig) -> None:
297
+ """verbose 模式下显示配置信息。"""
298
+ print_verbose("--- 当前配置 ---")
299
+ print_verbose(f"提供商: {config.llm.provider}")
300
+ print_verbose(f"模型: {config.llm.model}")
301
+ print_verbose(f"Base URL: {config.llm.base_url}")
302
+ print_verbose(f"API Key: {'*' * 8}...{config.llm.api_key[-4:]}" if len(config.llm.api_key) > 4 else "API Key: ***")
303
+ print_verbose(f"语言: {config.commit.language}")
304
+ print_verbose(f"Diff 截断阈值: {config.commit.max_diff_lines} 行")
305
+ print_verbose("---")
306
+ console.print()
307
+
308
+
309
+ # ---------------------------------------------------------------------------
310
+ # init 子命令
311
+ # ---------------------------------------------------------------------------
312
+
313
+ @main.command()
314
+ @click.option("--global", "is_global", is_flag=True, default=False, help="生成全局配置文件")
315
+ def init(is_global: bool) -> None:
316
+ """交互式初始化配置。"""
317
+ console.print("[bold]ACM 配置初始化[/bold]")
318
+ console.print()
319
+
320
+ # 1. 选择提供商
321
+ providers = list(PROVIDER_DEFAULTS.keys()) + ["other"]
322
+ console.print("可选提供商:")
323
+ for i, p in enumerate(providers, 1):
324
+ console.print(f" {i}. {p}")
325
+
326
+ provider_idx = click.prompt("选择提供商", type=int, default=1) - 1
327
+ if provider_idx < 0 or provider_idx >= len(providers):
328
+ provider_idx = 0
329
+
330
+ provider = providers[provider_idx]
331
+ if provider == "other":
332
+ provider = click.prompt("输入提供商名称", type=str)
333
+
334
+ # 2. API Key
335
+ api_key = click.prompt("输入 API Key", type=str, hide_input=True)
336
+
337
+ # 3. 模型名称
338
+ model = click.prompt("输入模型名称 (必填)", type=str)
339
+ if not model:
340
+ print_error("模型名称不能为空")
341
+ sys.exit(1)
342
+
343
+ # 4. Base URL(可选)
344
+ default_url = PROVIDER_DEFAULTS.get(provider, {}).get("base_url", "")
345
+ if default_url:
346
+ console.print(f"[dim]默认 Base URL: {default_url}[/dim]")
347
+ base_url = click.prompt("自定义 Base URL (留空使用默认)", type=str, default="")
348
+
349
+ # 5. 语言
350
+ language = click.prompt("输出语言", type=click.Choice(["zh-CN", "en"]), default="zh-CN")
351
+
352
+ # 构建配置
353
+ config = ACMConfig(
354
+ llm=LLMConfig(provider=provider, api_key=api_key, model=model, base_url=base_url),
355
+ commit=CommitConfig(language=language),
356
+ )
357
+
358
+ # 保存
359
+ if is_global:
360
+ path = GLOBAL_CONFIG_FILE
361
+ else:
362
+ path = Path.cwd() / PROJECT_CONFIG_FILE
363
+
364
+ save_config(config, path)
365
+ print_success(f"配置已保存到: {path}")
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # config 子命令
370
+ # ---------------------------------------------------------------------------
371
+
372
+ @main.group()
373
+ def config() -> None:
374
+ """查看和修改配置项。"""
375
+ pass
376
+
377
+
378
+ @config.command("list")
379
+ def config_list() -> None:
380
+ """列出当前生效的所有配置。"""
381
+ cfg = load_config()
382
+ console.print("[bold]当前生效配置:[/bold]")
383
+ console.print()
384
+
385
+ items = [
386
+ ("llm.provider", cfg.llm.provider),
387
+ ("llm.model", cfg.llm.model),
388
+ ("llm.base_url", cfg.llm.base_url),
389
+ ("llm.api_key", f"{'*' * 8}...{cfg.llm.api_key[-4:]}" if len(cfg.llm.api_key) > 4 else "***"),
390
+ ("commit.language", cfg.commit.language),
391
+ ("commit.max_diff_lines", str(cfg.commit.max_diff_lines)),
392
+ ("commit.types", ", ".join(cfg.commit.types) if cfg.commit.types else "(默认)"),
393
+ ("prompt.system", "(自定义)" if cfg.prompt.system else "(默认)"),
394
+ ("prompt.extra", cfg.prompt.extra or "(无)"),
395
+ ]
396
+
397
+ for key, value in items:
398
+ console.print(f" [cyan]{key}[/cyan] = {value}")
399
+
400
+
401
+ @config.command("get")
402
+ @click.argument("key")
403
+ def config_get_cmd(key: str) -> None:
404
+ """查看某个配置项的值。"""
405
+ try:
406
+ cfg = load_config()
407
+ value = config_get(key, cfg)
408
+ console.print(value)
409
+ except ConfigError as e:
410
+ print_error(str(e))
411
+ sys.exit(1)
412
+
413
+
414
+ @config.command("set")
415
+ @click.argument("key")
416
+ @click.argument("value")
417
+ @click.option("--global", "is_global", is_flag=True, default=False, help="修改全局配置文件")
418
+ def config_set_cmd(key: str, value: str, is_global: bool) -> None:
419
+ """修改某个配置项的值。"""
420
+ if is_global:
421
+ path = GLOBAL_CONFIG_FILE
422
+ else:
423
+ path = Path.cwd() / PROJECT_CONFIG_FILE
424
+
425
+ try:
426
+ config_set(key, value, path)
427
+ print_success(f"已设置 {key} = {value} (文件: {path})")
428
+ except ConfigError as e:
429
+ print_error(str(e))
430
+ sys.exit(1)
431
+
432
+
433
+ # ---------------------------------------------------------------------------
434
+ # feedback 子命令
435
+ # ---------------------------------------------------------------------------
436
+
437
+ @main.group()
438
+ def feedback() -> None:
439
+ """管理反馈学习记录。"""
440
+ pass
441
+
442
+
443
+ @feedback.command("list")
444
+ def feedback_list() -> None:
445
+ """列出所有历史反馈记录。"""
446
+ records = load_feedback()
447
+ if not records:
448
+ print_info("暂无反馈记录")
449
+ return
450
+
451
+ console.print(f"[bold]共 {len(records)} 条反馈记录:[/bold]")
452
+ console.print()
453
+ for i, r in enumerate(records, 1):
454
+ console.print(f" [cyan]#{i}[/cyan]")
455
+ console.print(f" 原始: [dim]{r.original_message}[/dim]")
456
+ console.print(f" 反馈: [yellow]{r.feedback}[/yellow]")
457
+ console.print(f" 改进: [green]{r.improved_message}[/green]")
458
+ console.print()
459
+
460
+
461
+ @feedback.command("clear")
462
+ def feedback_clear() -> None:
463
+ """清空所有反馈记录。"""
464
+ count = clear_feedback()
465
+ if count > 0:
466
+ print_success(f"已清除 {count} 条反馈记录")
467
+ else:
468
+ print_info("暂无反馈记录需要清除")
469
+
470
+
471
+ if __name__ == "__main__":
472
+ main()