zrcoder 0.2.0__tar.gz → 0.4.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zrcoder
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Terminal coding agent chat — OpenAI-compatible APIs, tools, and slash commands
5
5
  Keywords: cli,agent,coding,terminal,openai
6
6
  Author: Zr
@@ -18,6 +18,7 @@ Classifier: Topic :: Software Development
18
18
  Requires-Dist: prompt-toolkit>=3.0.53
19
19
  Requires-Dist: pydantic-ai>=2.40.0
20
20
  Requires-Dist: python-dotenv>=1.2.3
21
+ Requires-Dist: questionary>=2.1.1
21
22
  Requires-Dist: rich>=15.0.0
22
23
  Requires-Python: >=3.12
23
24
  Project-URL: Homepage, https://github.com/ZRMYDYCG/ClaudeCode
@@ -28,7 +29,11 @@ Description-Content-Type: text/markdown
28
29
 
29
30
  # Zrcoder
30
31
 
31
- 终端里的编程助手:连 OpenAI 兼容 API,可读写文件、执行命令,并支持 `/help` 等斜杠命令。
32
+ <p align="center">
33
+ <img src="docs/zrcoder-banner.jpg" alt="Zrcoder — AI Terminal CLI" width="100%">
34
+ </p>
35
+
36
+ 终端里的编程助手:连 OpenAI 兼容 API,可读写文件、执行命令,并支持斜杠命令与会话恢复。
32
37
 
33
38
  ## 安装
34
39
 
@@ -48,7 +53,11 @@ pip install zrcoder
48
53
 
49
54
  ## 配置
50
55
 
51
- 在环境变量或项目目录的 `.env` 中设置(可参考 `.env.example`):
56
+ 设置环境变量,或在以下位置之一创建 `.env`(可参考 `.env.example`):
57
+
58
+ - 当前目录:`./.env`
59
+ - 用户配置:`~/.config/zrcoder/.env`
60
+ - 兼容路径:`~/.zrcoder.env`
52
61
 
53
62
  ```bash
54
63
  export API_KEY=your_api_key
@@ -63,7 +72,7 @@ export BASE_URL=https://api.example.com/v1
63
72
  zrcoder
64
73
  ```
65
74
 
66
- 常用命令:`/help`、`/status`、`/new`、`/api-detail`、`/exit`。
75
+ 常用命令:`/help`、`/status`、`/new`、`/resume`、`/api-detail`、`/exit`。
67
76
 
68
77
  ## 变更与发版
69
78
 
@@ -1,6 +1,10 @@
1
1
  # Zrcoder
2
2
 
3
- 终端里的编程助手:连 OpenAI 兼容 API,可读写文件、执行命令,并支持 `/help` 等斜杠命令。
3
+ <p align="center">
4
+ <img src="docs/zrcoder-banner.jpg" alt="Zrcoder — AI Terminal CLI" width="100%">
5
+ </p>
6
+
7
+ 终端里的编程助手:连 OpenAI 兼容 API,可读写文件、执行命令,并支持斜杠命令与会话恢复。
4
8
 
5
9
  ## 安装
6
10
 
@@ -20,7 +24,11 @@ pip install zrcoder
20
24
 
21
25
  ## 配置
22
26
 
23
- 在环境变量或项目目录的 `.env` 中设置(可参考 `.env.example`):
27
+ 设置环境变量,或在以下位置之一创建 `.env`(可参考 `.env.example`):
28
+
29
+ - 当前目录:`./.env`
30
+ - 用户配置:`~/.config/zrcoder/.env`
31
+ - 兼容路径:`~/.zrcoder.env`
24
32
 
25
33
  ```bash
26
34
  export API_KEY=your_api_key
@@ -35,7 +43,7 @@ export BASE_URL=https://api.example.com/v1
35
43
  zrcoder
36
44
  ```
37
45
 
38
- 常用命令:`/help`、`/status`、`/new`、`/api-detail`、`/exit`。
46
+ 常用命令:`/help`、`/status`、`/new`、`/resume`、`/api-detail`、`/exit`。
39
47
 
40
48
  ## 变更与发版
41
49
 
@@ -0,0 +1,179 @@
1
+ """
2
+ 挂在 Agent 上的 hooks:
3
+ 1. API 调用元数据记录(/api-detail 命令用)
4
+ 2. API 请求失败时的自动重试(wrap_model_request)
5
+ 3. 工具调用权限检查(on_tool_execute)
6
+ 4. 工具执行异常的兜底处理(on_tool_execute_error)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ from pydantic_ai.capabilities import Hooks
16
+ from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError, UnexpectedModelBehavior
17
+
18
+ from core import permissions
19
+ from core.ui.render import console
20
+
21
+ MAX_RETRIES = 3
22
+
23
+
24
+ @dataclass
25
+ class ApiCall:
26
+ """
27
+ 一次 model API 调用的元数据。before_model_request 创建并填充上半部分,
28
+ after_model_request 填充下半部分。
29
+ """
30
+
31
+ # request 侧
32
+ model: str
33
+ messages_count: int
34
+ # 这次发送给模型的 messages 中最后一条消息的最后一个 part
35
+ last_part: Any
36
+ tools: list[str]
37
+ # response 侧(after hook 填充)
38
+ finish_reason: str = ""
39
+ parts_kinds: list[str] = field(default_factory=list)
40
+ input_tokens: int = 0
41
+ output_tokens: int = 0
42
+
43
+
44
+ # 主循环在每轮 agent.iter() 之前清空它
45
+ api_call_log: list[ApiCall] = []
46
+
47
+ hooks = Hooks()
48
+
49
+
50
+ # ---------- API 调用记录 ----------
51
+
52
+
53
+ @hooks.on.before_model_request
54
+ async def _record_request(ctx: Any, request_context: Any) -> Any:
55
+ """
56
+ 每次发起 model 调用之前,创建一条 ApiCall 记录。
57
+ """
58
+ msgs = list(request_context.messages)
59
+ last_part = msgs[-1].parts[-1] if msgs and msgs[-1].parts else None
60
+ try:
61
+ tool_names = [t.name for t in request_context.model_request_parameters.function_tools]
62
+ except AttributeError:
63
+ tool_names = []
64
+ api_call_log.append(
65
+ ApiCall(
66
+ model=request_context.model.model_name,
67
+ messages_count=len(msgs),
68
+ last_part=last_part,
69
+ tools=tool_names,
70
+ )
71
+ )
72
+ return request_context
73
+
74
+
75
+ @hooks.on.after_model_request
76
+ async def _record_response(ctx: Any, *, request_context: Any, response: Any) -> Any:
77
+ """
78
+ 每次 model 调用返回后,填充上面这条 ApiCall 的 response 字段。
79
+ """
80
+ if api_call_log:
81
+ call = api_call_log[-1]
82
+ call.finish_reason = str(response.finish_reason) if response.finish_reason else "unknown"
83
+ call.parts_kinds = [p.part_kind for p in response.parts]
84
+ call.input_tokens = response.usage.input_tokens
85
+ call.output_tokens = response.usage.output_tokens
86
+ return response
87
+
88
+
89
+ # ---------- API 请求重试 ----------
90
+
91
+
92
+ @hooks.on.model_request
93
+ async def _retry_on_error(ctx: Any, *, request_context: Any, handler: Any) -> Any:
94
+ """
95
+ 包裹 model 请求,遇到可重试错误时自动指数退避重试。
96
+
97
+ 重试在 wrap 内部完成,对话历史和 before/after hooks 不受影响。
98
+ 兼容网关偶发的 UnexpectedModelBehavior(响应 schema 不对)也会重试。
99
+ """
100
+ for attempt in range(MAX_RETRIES + 1):
101
+ try:
102
+ return await handler(request_context)
103
+ except ModelHTTPError as e:
104
+ if e.status_code < 500:
105
+ raise
106
+ if attempt >= MAX_RETRIES:
107
+ console.print(f"[bold red]✗ HTTP {e.status_code},重试 {MAX_RETRIES} 次后仍失败[/]")
108
+ raise
109
+ wait = 2**attempt
110
+ console.print(
111
+ f"[bold yellow]⟳ HTTP {e.status_code},{wait}s 后重试 "
112
+ f"({attempt + 1}/{MAX_RETRIES})...[/]"
113
+ )
114
+ await asyncio.sleep(wait)
115
+ except UnexpectedModelBehavior as e:
116
+ if attempt >= MAX_RETRIES:
117
+ console.print(f"[bold red]✗ 模型响应异常,重试 {MAX_RETRIES} 次后仍失败:{e}[/]")
118
+ raise
119
+ wait = 2**attempt
120
+ console.print(
121
+ f"[bold yellow]⟳ 模型响应异常,{wait}s 后重试 ({attempt + 1}/{MAX_RETRIES})...[/]"
122
+ )
123
+ await asyncio.sleep(wait)
124
+ except ModelAPIError:
125
+ if attempt >= MAX_RETRIES:
126
+ console.print(f"[bold red]✗ 网络连接失败,重试 {MAX_RETRIES} 次后仍无法连接[/]")
127
+ raise
128
+ wait = 2**attempt
129
+ console.print(
130
+ f"[bold yellow]⟳ 网络连接失败,{wait}s 后重试 ({attempt + 1}/{MAX_RETRIES})...[/]"
131
+ )
132
+ await asyncio.sleep(wait)
133
+
134
+ raise RuntimeError("unreachable") # pragma: no cover
135
+
136
+
137
+ # ---------- 工具调用权限检查 ----------
138
+
139
+
140
+ @hooks.on.tool_execute
141
+ async def _check_permission(ctx: Any, *, call: Any, tool_def: Any, args: Any, handler: Any) -> Any:
142
+ """
143
+ 工具执行前的权限关卡。allow 就调用 handler 真正执行;
144
+ ask 就弹审批列表;deny 则把拒绝原因当作工具结果回填,让模型自行纠正。
145
+ """
146
+ decision = permissions.compute_decision(call.tool_name, args)
147
+ if decision == "allow":
148
+ # 放行,handler(args) 才是真正执行工具的那一步
149
+ return await handler(args)
150
+
151
+ # decision == "ask",弹审批让用户决定
152
+ choice = await permissions.prompt_approval(call.tool_name, args)
153
+ if choice == "once":
154
+ return await handler(args)
155
+ if choice == "always":
156
+ # 记进会话白名单,本会话内这个工具不再询问
157
+ permissions.state.session_allowed.add(call.tool_name)
158
+ return await handler(args)
159
+
160
+ # 拒绝:不执行工具,把拒绝原因回填给模型,让它停下来等用户发话,而不是自作主张绕过去
161
+ return (
162
+ f"用户拒绝了对 {call.tool_name} 的调用,这次调用没有执行。"
163
+ "请停下手上的事,等用户告诉你接下来该怎么做。"
164
+ )
165
+
166
+
167
+ # ---------- 工具执行异常兜底 ----------
168
+
169
+
170
+ @hooks.on.tool_execute_error
171
+ async def _handle_tool_error(
172
+ ctx: Any, *, call: Any, tool_def: Any, args: Any, error: Exception
173
+ ) -> str:
174
+ """
175
+ 工具函数抛出未捕获异常时,不让进程崩溃,
176
+ 而是把错误信息作为 tool result 返回给模型,让它自行纠正。
177
+ """
178
+ console.print(f"[bold red]✗ 工具 {call.tool_name} 出错:{error}[/]")
179
+ return f"工具执行出错:{type(error).__name__}: {error}"
@@ -0,0 +1,96 @@
1
+ """
2
+ Coding Agent 用到的三个工具:读文件、写文件、跑 shell 命令。
3
+
4
+ 每个工具自己处理已知错误:能靠换参数纠正的(路径不存在、没权限)抛 ModelRetry
5
+ 让模型重试,不能纠正的(二进制文件、命令出错)直接返回错误信息。
6
+ 意料之外的异常由 hooks 里的 on_tool_execute_error 统一兜底。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import subprocess
13
+ from typing import Any
14
+
15
+ from pydantic_ai.exceptions import ModelRetry
16
+
17
+ from core import permissions
18
+
19
+
20
+ def read_file(path: str) -> str:
21
+ """
22
+ 读取指定文件的内容。
23
+ """
24
+ try:
25
+ with open(path, encoding="utf-8") as f:
26
+ return f.read()
27
+ except FileNotFoundError as e:
28
+ raise ModelRetry(f"文件 {path} 不存在,请确认路径或换一个文件") from e
29
+ except PermissionError as e:
30
+ raise ModelRetry(f"没有权限读取 {path},请换一个可读的文件") from e
31
+ except IsADirectoryError as e:
32
+ raise ModelRetry(f"{path} 是一个目录,请指定目录下的具体文件") from e
33
+ except UnicodeDecodeError:
34
+ return f"错误:{path} 不是文本文件,无法读取"
35
+
36
+
37
+ def write_file(path: str, content: str) -> str:
38
+ """
39
+ 将内容写入指定文件。
40
+ """
41
+ try:
42
+ with open(path, "w", encoding="utf-8") as f:
43
+ f.write(content)
44
+ return f"已写入 {path}"
45
+ except FileNotFoundError as e:
46
+ raise ModelRetry(f"目录不存在,无法写入 {path},请换一个已存在的目录") from e
47
+ except PermissionError as e:
48
+ raise ModelRetry(f"没有权限写入 {path},请换一个可写的路径") from e
49
+ except OSError as e:
50
+ return f"错误:写入 {path} 失败 ({e})"
51
+
52
+
53
+ def run_command(command: str) -> str:
54
+ """
55
+ 执行一条 shell 命令并返回输出。
56
+ """
57
+ try:
58
+ result = subprocess.run(
59
+ command, shell=True, capture_output=True, text=True, errors="replace", timeout=10
60
+ )
61
+ output = result.stdout
62
+ if result.returncode != 0:
63
+ output += f"\n[错误] {result.stderr}"
64
+ return output or "(无输出)"
65
+ except subprocess.TimeoutExpired:
66
+ return "[错误] 命令执行超时(10秒)"
67
+ except OSError as e:
68
+ return f"[错误] 无法执行命令 ({e})"
69
+
70
+
71
+ # 高危命令的特征:删除文件、提权、直写磁盘
72
+ DANGEROUS_PATTERNS = [
73
+ r"\brm\b",
74
+ r"\bsudo\b",
75
+ r"\bdd\b",
76
+ r"\bmkfs\w*\b",
77
+ ]
78
+
79
+
80
+ def run_command_self_check(args: dict[str, Any]) -> str | None:
81
+ """
82
+ run_command 的权限自检:扫一遍命令字符串,命中高危特征就要求审批。
83
+ 误伤的代价不过是多弹一次窗,绝不能把真正的高危命令漏过去。
84
+ """
85
+ command = str(args.get("command", ""))
86
+ if any(re.search(pattern, command) for pattern in DANGEROUS_PATTERNS):
87
+ return "ask"
88
+ # 没命中高危特征,交给通用规则决定
89
+ return None
90
+
91
+
92
+ # 把自检挂到权限模块的注册表上
93
+ permissions.register_self_check("run_command", run_command_self_check)
94
+
95
+ # Pydantic AI 支持 tools=[plain_function],从函数签名 + docstring 自动生成 JSON Schema
96
+ TOOLS = [read_file, write_file, run_command]
@@ -0,0 +1,119 @@
1
+ """
2
+ 终端主循环:读输入、分发斜杠命令、驱动 Agent。
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import inspect
9
+ from typing import Any, Literal
10
+
11
+ from pydantic_ai import Agent
12
+ from pydantic_graph import End
13
+
14
+ from core.agent import MODEL_NAME, agent, api_call_log
15
+ from core.session import append_messages, new_session_id
16
+ from core.ui.commands import COMMANDS, SessionState, print_part
17
+ from core.ui.input import Repl
18
+ from core.ui.render import console, print_welcome_banner
19
+
20
+ CommandAction = Literal["pass", "continue", "break"]
21
+
22
+
23
+ async def handle_command(user_input: str, state: SessionState) -> CommandAction:
24
+ """
25
+ 处理以 / 开头的命令。
26
+ 返回 'pass':不是命令,交给 Agent;
27
+ 返回 'continue':命令已处理,进入下一轮;
28
+ 返回 'break':命令要求退出程序。
29
+ """
30
+ if not user_input.startswith("/"):
31
+ return "pass"
32
+ cmd_name = user_input[1:].split()[0]
33
+ command = COMMANDS.get(cmd_name)
34
+ if command is None:
35
+ console.print(f"未知命令:/{cmd_name},输入 /help 查看可用命令\n")
36
+ return "continue"
37
+ result = command.handler(state)
38
+ # 个别命令(如 /resume)要弹交互式列表,是异步的,需要 await
39
+ if inspect.isawaitable(result):
40
+ result = await result
41
+ return "continue" if result else "break"
42
+
43
+
44
+ def apply_result(state: SessionState, result: Any) -> None:
45
+ """
46
+ 跑完一轮 Agent 后,把结果同步到 SessionState,并追加写入会话文件。
47
+ """
48
+ state.history = result.all_messages()
49
+ usage = result.usage
50
+ state.input_tokens += usage.input_tokens
51
+ state.output_tokens += usage.output_tokens
52
+ state.last_api_calls = list(api_call_log)
53
+ append_messages(state.session_id, result.new_messages())
54
+
55
+
56
+ async def run_agent_loop(user_input: str, state: SessionState) -> None:
57
+ """
58
+ 展开 agent.run_sync(),逐节点驱动 Agent 循环,每步实时打印。
59
+ """
60
+ api_call_log.clear()
61
+
62
+ async with agent.iter(user_input, message_history=state.history) as run:
63
+ node = run.next_node
64
+
65
+ while not isinstance(node, End):
66
+ node = await run.next(node)
67
+
68
+ if Agent.is_call_tools_node(node):
69
+ for response_part in node.model_response.parts:
70
+ print_part(response_part)
71
+
72
+ elif Agent.is_model_request_node(node):
73
+ for request_part in node.request.parts:
74
+ kind = getattr(request_part, "part_kind", None)
75
+ if kind in ("tool-return", "retry-prompt"):
76
+ print_part(request_part)
77
+
78
+ apply_result(state, run.result)
79
+ console.print()
80
+
81
+
82
+ async def async_main() -> None:
83
+ state = SessionState(
84
+ model_name=MODEL_NAME,
85
+ session_id=new_session_id(),
86
+ )
87
+ print_welcome_banner("Zrcoder")
88
+
89
+ # 常驻输入区:输入框整个会话期间不消失
90
+ repl = Repl(state)
91
+
92
+ async def on_submit(user_input: str) -> None:
93
+ # 每次回车提交一行输入,都走这里
94
+ # 先处理 / 开头的命令
95
+ action = await handle_command(user_input, state)
96
+ if action == "break":
97
+ # 命令要求退出,结束常驻输入区
98
+ repl.exit()
99
+ return
100
+ if action == "continue":
101
+ return
102
+
103
+ # 核心 Agent 循环:开请求时显示 working...,结束 / 被打断后由 Repl 统一隐藏;
104
+ # 中途按 ESC / Ctrl+C 会打断
105
+ repl.start_working()
106
+ await run_agent_loop(user_input, state)
107
+
108
+ await repl.run(on_submit)
109
+
110
+
111
+ def main() -> None:
112
+ try:
113
+ asyncio.run(async_main())
114
+ except (KeyboardInterrupt, EOFError):
115
+ pass
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()