zrcoder 0.1.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.
- zrcoder-0.1.0/LICENSE +21 -0
- zrcoder-0.1.0/PKG-INFO +86 -0
- zrcoder-0.1.0/README.md +59 -0
- zrcoder-0.1.0/core/__init__.py +1 -0
- zrcoder-0.1.0/core/agent/__init__.py +15 -0
- zrcoder-0.1.0/core/agent/core.py +58 -0
- zrcoder-0.1.0/core/agent/hooks.py +73 -0
- zrcoder-0.1.0/core/agent/tools.py +45 -0
- zrcoder-0.1.0/core/cli.py +90 -0
- zrcoder-0.1.0/core/ui/__init__.py +6 -0
- zrcoder-0.1.0/core/ui/commands.py +212 -0
- zrcoder-0.1.0/core/ui/render.py +38 -0
- zrcoder-0.1.0/pyproject.toml +85 -0
- zrcoder-0.1.0/pyproject.toml.orig +72 -0
zrcoder-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
zrcoder-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zrcoder
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Terminal coding agent chat — OpenAI-compatible APIs, tools, and slash commands
|
|
5
|
+
Keywords: cli,agent,coding,terminal,openai
|
|
6
|
+
Author: Zr
|
|
7
|
+
Author-email: Zr <547471919@qq.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Software Development
|
|
18
|
+
Requires-Dist: prompt-toolkit>=3.0.53
|
|
19
|
+
Requires-Dist: pydantic-ai>=2.40.0
|
|
20
|
+
Requires-Dist: python-dotenv>=1.2.3
|
|
21
|
+
Requires-Dist: rich>=15.0.0
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Project-URL: Homepage, https://github.com/ZRMYDYCG/ClaudeCode
|
|
24
|
+
Project-URL: Repository, https://github.com/ZRMYDYCG/ClaudeCode
|
|
25
|
+
Project-URL: Issues, https://github.com/ZRMYDYCG/ClaudeCode/issues
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Zrcoder
|
|
29
|
+
|
|
30
|
+
终端里的编程助手:连 OpenAI 兼容 API,可读写文件、执行命令,并支持 `/help` 等斜杠命令。
|
|
31
|
+
|
|
32
|
+
## 安装
|
|
33
|
+
|
|
34
|
+
需要 Python **3.12+**。推荐用 [uv](https://docs.astral.sh/uv/):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uv tool install zrcoder
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
也可用 pipx / pip:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pipx install zrcoder
|
|
44
|
+
# 或
|
|
45
|
+
pip install zrcoder
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 配置
|
|
49
|
+
|
|
50
|
+
在环境变量或项目目录的 `.env` 中设置(可参考 `.env.example`):
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
export API_KEY=your_api_key
|
|
54
|
+
export BASE_URL=https://api.example.com/v1
|
|
55
|
+
# 可选,默认 deepseek-v4-flash
|
|
56
|
+
# export MODEL_NAME=deepseek-v4-flash
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## 使用
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
zrcoder
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
常用命令:`/help`、`/status`、`/new`、`/api-detail`、`/exit`。
|
|
66
|
+
|
|
67
|
+
## 从源码开发
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
git clone https://github.com/ZRMYDYCG/ClaudeCode.git
|
|
71
|
+
cd ClaudeCode
|
|
72
|
+
uv sync --group dev
|
|
73
|
+
|
|
74
|
+
uv run zrcoder
|
|
75
|
+
# 或
|
|
76
|
+
uv run python main.py
|
|
77
|
+
|
|
78
|
+
uv run ruff format .
|
|
79
|
+
uv run ruff check --fix .
|
|
80
|
+
uv run pytest
|
|
81
|
+
uv run mypy core
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
zrcoder-0.1.0/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Zrcoder
|
|
2
|
+
|
|
3
|
+
终端里的编程助手:连 OpenAI 兼容 API,可读写文件、执行命令,并支持 `/help` 等斜杠命令。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
需要 Python **3.12+**。推荐用 [uv](https://docs.astral.sh/uv/):
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv tool install zrcoder
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
也可用 pipx / pip:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pipx install zrcoder
|
|
17
|
+
# 或
|
|
18
|
+
pip install zrcoder
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## 配置
|
|
22
|
+
|
|
23
|
+
在环境变量或项目目录的 `.env` 中设置(可参考 `.env.example`):
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
export API_KEY=your_api_key
|
|
27
|
+
export BASE_URL=https://api.example.com/v1
|
|
28
|
+
# 可选,默认 deepseek-v4-flash
|
|
29
|
+
# export MODEL_NAME=deepseek-v4-flash
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## 使用
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
zrcoder
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
常用命令:`/help`、`/status`、`/new`、`/api-detail`、`/exit`。
|
|
39
|
+
|
|
40
|
+
## 从源码开发
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
git clone https://github.com/ZRMYDYCG/ClaudeCode.git
|
|
44
|
+
cd ClaudeCode
|
|
45
|
+
uv sync --group dev
|
|
46
|
+
|
|
47
|
+
uv run zrcoder
|
|
48
|
+
# 或
|
|
49
|
+
uv run python main.py
|
|
50
|
+
|
|
51
|
+
uv run ruff format .
|
|
52
|
+
uv run ruff check --fix .
|
|
53
|
+
uv run pytest
|
|
54
|
+
uv run mypy core
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core package: agent runtime and terminal UI."""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Coding Agent 完整封装包。
|
|
2
|
+
|
|
3
|
+
外部代码只关心:
|
|
4
|
+
- agent: 配置好工具和 hooks 的 Agent 实例
|
|
5
|
+
- MODEL_NAME: 当前使用的模型名(/status 命令要用)
|
|
6
|
+
- api_call_log: 主循环跑 Agent 时收集的 API 调用元数据(/api-detail 命令要用)
|
|
7
|
+
- ApiCall: 一次 API 调用的数据结构
|
|
8
|
+
|
|
9
|
+
子模块(tools / hooks / core)是实现细节,不需要直接 import。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .core import MODEL_NAME, agent
|
|
13
|
+
from .hooks import ApiCall, api_call_log
|
|
14
|
+
|
|
15
|
+
__all__ = ["agent", "MODEL_NAME", "api_call_log", "ApiCall"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent 实例化:把 model / instructions / tools / hooks 拼起来。
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
from urllib.parse import urlparse, urlunparse
|
|
8
|
+
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
from pydantic_ai import Agent
|
|
11
|
+
from pydantic_ai.models.openai import OpenAIChatModel
|
|
12
|
+
from pydantic_ai.providers.openai import OpenAIProvider
|
|
13
|
+
|
|
14
|
+
from .hooks import hooks
|
|
15
|
+
from .tools import TOOLS
|
|
16
|
+
|
|
17
|
+
load_dotenv()
|
|
18
|
+
|
|
19
|
+
# 从环境变量读取 API Key / Base URL(OpenAI 兼容接口)
|
|
20
|
+
API_KEY = os.environ.get("API_KEY")
|
|
21
|
+
if not API_KEY:
|
|
22
|
+
raise RuntimeError("请先设置环境变量 API_KEY")
|
|
23
|
+
|
|
24
|
+
BASE_URL = os.environ.get("BASE_URL")
|
|
25
|
+
if not BASE_URL:
|
|
26
|
+
raise RuntimeError("请先设置环境变量 BASE_URL")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _normalize_openai_base_url(url: str) -> str:
|
|
30
|
+
"""
|
|
31
|
+
OpenAI SDK 会自己拼 /chat/completions;若 .env 里写了完整 path,去掉末尾这段。
|
|
32
|
+
"""
|
|
33
|
+
parsed = urlparse(url.strip())
|
|
34
|
+
path = parsed.path.rstrip("/")
|
|
35
|
+
for suffix in ("/chat/completions", "/completions"):
|
|
36
|
+
if path.endswith(suffix):
|
|
37
|
+
path = path[: -len(suffix)] or "/"
|
|
38
|
+
break
|
|
39
|
+
return urlunparse((parsed.scheme, parsed.netloc, path, "", "", ""))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
MODEL_NAME = os.environ.get("MODEL_NAME", "deepseek-v4-flash")
|
|
43
|
+
|
|
44
|
+
model = OpenAIChatModel(
|
|
45
|
+
MODEL_NAME,
|
|
46
|
+
provider=OpenAIProvider(base_url=_normalize_openai_base_url(BASE_URL), api_key=API_KEY),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
agent = Agent(
|
|
50
|
+
model,
|
|
51
|
+
instructions=(
|
|
52
|
+
"你是一个编程助手。你可以读写文件和执行命令来帮用户完成编程任务。\n"
|
|
53
|
+
"工作流程:先理解需求,写代码,然后运行验证。"
|
|
54
|
+
"如果有错误就修复并重新运行,直到确认正确。"
|
|
55
|
+
),
|
|
56
|
+
tools=cast(Any, TOOLS),
|
|
57
|
+
capabilities=[hooks],
|
|
58
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
挂在 Agent 上的 hooks,用来抓每次 model API 调用的元数据。
|
|
3
|
+
|
|
4
|
+
主循环在每轮 run_sync 之前清空 api_call_log,跑完后快照到 SessionState 里,
|
|
5
|
+
/api-detail 命令再把这一轮的所有调用展示给用户。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic_ai.capabilities import Hooks
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ApiCall:
|
|
16
|
+
"""
|
|
17
|
+
一次 model API 调用的元数据。before_model_request 创建并填充上半部分,
|
|
18
|
+
after_model_request 填充下半部分。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# request 侧
|
|
22
|
+
model: str
|
|
23
|
+
messages_count: int
|
|
24
|
+
# 这次发送给模型的 messages 中最后一条消息的最后一个 part
|
|
25
|
+
last_part: Any
|
|
26
|
+
tools: list[str]
|
|
27
|
+
# response 侧(after hook 填充)
|
|
28
|
+
finish_reason: str = ""
|
|
29
|
+
parts_kinds: list[str] = field(default_factory=list)
|
|
30
|
+
input_tokens: int = 0
|
|
31
|
+
output_tokens: int = 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# 主循环在每轮 run_sync 之前清空它
|
|
35
|
+
api_call_log: list[ApiCall] = []
|
|
36
|
+
|
|
37
|
+
hooks = Hooks()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@hooks.on.before_model_request
|
|
41
|
+
async def _record_request(ctx: Any, request_context: Any) -> Any:
|
|
42
|
+
"""
|
|
43
|
+
每次发起 model 调用之前,创建一条 ApiCall 记录。
|
|
44
|
+
"""
|
|
45
|
+
msgs = list(request_context.messages)
|
|
46
|
+
last_part = msgs[-1].parts[-1] if msgs and msgs[-1].parts else None
|
|
47
|
+
try:
|
|
48
|
+
tool_names = [t.name for t in request_context.model_request_parameters.function_tools]
|
|
49
|
+
except AttributeError:
|
|
50
|
+
tool_names = []
|
|
51
|
+
api_call_log.append(
|
|
52
|
+
ApiCall(
|
|
53
|
+
model=request_context.model.model_name,
|
|
54
|
+
messages_count=len(msgs),
|
|
55
|
+
last_part=last_part,
|
|
56
|
+
tools=tool_names,
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
return request_context
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@hooks.on.after_model_request
|
|
63
|
+
async def _record_response(ctx: Any, request_context: Any, response: Any) -> Any:
|
|
64
|
+
"""
|
|
65
|
+
每次 model 调用返回后,填充上面这条 ApiCall 的 response 字段。
|
|
66
|
+
"""
|
|
67
|
+
if api_call_log:
|
|
68
|
+
call = api_call_log[-1]
|
|
69
|
+
call.finish_reason = str(response.finish_reason) if response.finish_reason else "unknown"
|
|
70
|
+
call.parts_kinds = [p.part_kind for p in response.parts]
|
|
71
|
+
call.input_tokens = response.usage.input_tokens
|
|
72
|
+
call.output_tokens = response.usage.output_tokens
|
|
73
|
+
return response
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Coding Agent 用到的三个工具:读文件、写文件、跑 shell 命令。
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def read_file(path: str) -> str:
|
|
9
|
+
"""
|
|
10
|
+
读取指定文件的内容。
|
|
11
|
+
"""
|
|
12
|
+
try:
|
|
13
|
+
with open(path, encoding="utf-8") as f:
|
|
14
|
+
return f.read()
|
|
15
|
+
except FileNotFoundError:
|
|
16
|
+
return f"错误:文件 {path} 不存在"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def write_file(path: str, content: str) -> str:
|
|
20
|
+
"""
|
|
21
|
+
将内容写入指定文件。
|
|
22
|
+
"""
|
|
23
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
24
|
+
f.write(content)
|
|
25
|
+
return f"已写入 {path}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_command(command: str) -> str:
|
|
29
|
+
"""
|
|
30
|
+
执行一条 shell 命令并返回输出。
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
result = subprocess.run(
|
|
34
|
+
command, shell=True, capture_output=True, text=True, errors="replace", timeout=10
|
|
35
|
+
)
|
|
36
|
+
output = result.stdout
|
|
37
|
+
if result.returncode != 0:
|
|
38
|
+
output += f"\n[错误] {result.stderr}"
|
|
39
|
+
return output or "(无输出)"
|
|
40
|
+
except subprocess.TimeoutExpired:
|
|
41
|
+
return "[错误] 命令执行超时(10秒)"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Pydantic AI 支持 tools=[plain_function],从函数签名 + docstring 自动生成 JSON Schema
|
|
45
|
+
TOOLS = [read_file, write_file, run_command]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
终端主循环:读输入、分发斜杠命令、驱动 Agent。
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from prompt_toolkit import PromptSession
|
|
8
|
+
|
|
9
|
+
from core.agent import MODEL_NAME, agent, api_call_log
|
|
10
|
+
from core.ui.commands import COMMANDS, SessionState, print_agent_steps, print_divider
|
|
11
|
+
from core.ui.render import console, print_welcome_banner
|
|
12
|
+
|
|
13
|
+
# PromptSession 比内置 input() 好用:支持左右移光标编辑,并记住本次输入历史
|
|
14
|
+
prompt_session: PromptSession[str] = PromptSession()
|
|
15
|
+
|
|
16
|
+
CommandAction = Literal["pass", "continue", "break"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_user_input() -> str | None:
|
|
20
|
+
"""
|
|
21
|
+
打印上横线并读一行用户输入;回车后再补一条下横线。
|
|
22
|
+
返回 None 表示用户希望退出(Ctrl-C / Ctrl-D)。
|
|
23
|
+
"""
|
|
24
|
+
print_divider()
|
|
25
|
+
try:
|
|
26
|
+
user_input = prompt_session.prompt("❯ ").strip()
|
|
27
|
+
except (EOFError, KeyboardInterrupt):
|
|
28
|
+
print()
|
|
29
|
+
return None
|
|
30
|
+
print_divider()
|
|
31
|
+
return user_input
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def handle_command(user_input: str, state: SessionState) -> CommandAction:
|
|
35
|
+
"""
|
|
36
|
+
处理以 / 开头的命令。
|
|
37
|
+
返回 'pass':不是命令,主循环继续往下走交给 Agent;
|
|
38
|
+
返回 'continue':命令已处理,主循环跳到下一轮;
|
|
39
|
+
返回 'break':命令要求退出主循环。
|
|
40
|
+
"""
|
|
41
|
+
if not user_input.startswith("/"):
|
|
42
|
+
return "pass"
|
|
43
|
+
cmd_name = user_input[1:].split()[0]
|
|
44
|
+
command = COMMANDS.get(cmd_name)
|
|
45
|
+
if command is None:
|
|
46
|
+
console.print(f"未知命令:/{cmd_name},输入 /help 查看可用命令\n")
|
|
47
|
+
return "continue"
|
|
48
|
+
return "continue" if command.handler(state) else "break"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def apply_result(state: SessionState, result: Any) -> None:
|
|
52
|
+
"""
|
|
53
|
+
跑完一轮 Agent 后,把结果同步到 SessionState 并显示新增的中间过程。
|
|
54
|
+
"""
|
|
55
|
+
state.history = result.all_messages()
|
|
56
|
+
usage = result.usage
|
|
57
|
+
state.input_tokens += usage.input_tokens
|
|
58
|
+
state.output_tokens += usage.output_tokens
|
|
59
|
+
state.last_api_calls = list(api_call_log)
|
|
60
|
+
# result.new_messages() 直接拿到这一轮新增的 message,不需要手动算偏移
|
|
61
|
+
print_agent_steps(result.new_messages())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main() -> None:
|
|
65
|
+
state = SessionState(model_name=MODEL_NAME)
|
|
66
|
+
print_welcome_banner("Zrcoder")
|
|
67
|
+
|
|
68
|
+
while True:
|
|
69
|
+
# 读用户输入
|
|
70
|
+
user_input = read_user_input()
|
|
71
|
+
if user_input is None:
|
|
72
|
+
break
|
|
73
|
+
if not user_input:
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
# 处理 / 开头的命令
|
|
77
|
+
action = handle_command(user_input, state)
|
|
78
|
+
if action == "break":
|
|
79
|
+
break
|
|
80
|
+
if action == "continue":
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
# 核心 Agent 循环:清空收集 buffer,跑一轮,把结果应用到 state
|
|
84
|
+
api_call_log.clear()
|
|
85
|
+
result = agent.run_sync(user_input, message_history=state.history)
|
|
86
|
+
apply_result(state, result)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
main()
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from collections.abc import Callable, Iterable
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from rich.console import Console, ConsoleOptions, RenderResult
|
|
6
|
+
from rich.markdown import Heading, Markdown
|
|
7
|
+
from rich.markup import escape
|
|
8
|
+
from rich.padding import Padding
|
|
9
|
+
from rich.rule import Rule
|
|
10
|
+
|
|
11
|
+
from .render import console, print_step
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LeftAlignedHeading(Heading):
|
|
15
|
+
"""
|
|
16
|
+
rich 默认把 Markdown 标题渲染成居中对齐,宽终端里看着像错位,覆盖成左对齐。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
20
|
+
text = self.text
|
|
21
|
+
text.justify = "left"
|
|
22
|
+
yield text
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# 全局替换 Markdown 的标题渲染元素
|
|
26
|
+
Markdown.elements["heading_open"] = LeftAlignedHeading
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class SessionState:
|
|
31
|
+
"""
|
|
32
|
+
跨命令共享的会话状态,主循环把它传给每个命令处理函数。
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
history: list[Any] = field(default_factory=list)
|
|
36
|
+
input_tokens: int = 0
|
|
37
|
+
output_tokens: int = 0
|
|
38
|
+
model_name: str = ""
|
|
39
|
+
# 最近一轮 user input 触发的所有 model API 调用记录
|
|
40
|
+
last_api_calls: list[Any] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Command:
|
|
45
|
+
name: str
|
|
46
|
+
description: str
|
|
47
|
+
# handler 返回 False 表示主循环应当退出
|
|
48
|
+
handler: Callable[[SessionState], bool]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def print_divider() -> None:
|
|
52
|
+
"""
|
|
53
|
+
每轮交互之前打印一条分割线,区分输入区域。Rule 会自适应终端宽度。
|
|
54
|
+
"""
|
|
55
|
+
console.print(Rule(style="grey50"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _truncate(text: Any, limit: int = 120) -> str:
|
|
59
|
+
"""
|
|
60
|
+
截断并 escape,用于 tool 参数 / 返回值 / 用户输入这类可能过长的内容。
|
|
61
|
+
"""
|
|
62
|
+
text = str(text).strip()
|
|
63
|
+
text = text if len(text) <= limit else text[:limit] + "..."
|
|
64
|
+
return escape(text)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _full(text: Any) -> str:
|
|
68
|
+
"""
|
|
69
|
+
完整显示,只做 escape 不截断,用于 thinking 和 assistant text 这种用户关心的内容。
|
|
70
|
+
"""
|
|
71
|
+
return escape(str(text).strip())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _format_part_line(part: Any) -> str | None:
|
|
75
|
+
"""
|
|
76
|
+
把一条消息里的单个 part 格式化为带 Rich markup 的字符串。
|
|
77
|
+
版式:图标 + role 标签独占一行,内容换行到下一行,不用「|」分隔。
|
|
78
|
+
"""
|
|
79
|
+
# 内容行统一缩进 2 格,和图标(占 2 格:图标 + 空格)后的 role 名对齐
|
|
80
|
+
kind = part.part_kind
|
|
81
|
+
if kind == "user-prompt":
|
|
82
|
+
return f"[cyan]❯ user[/]\n {_truncate(part.content)}"
|
|
83
|
+
if kind == "thinking":
|
|
84
|
+
# thinking 整块 dim,弱化视觉权重;不截断,完整保留思考过程
|
|
85
|
+
return f"[dim]✻ thinking[/]\n [dim]{_full(part.content)}[/]"
|
|
86
|
+
if kind == "text":
|
|
87
|
+
content = (part.content or "").strip()
|
|
88
|
+
if not content:
|
|
89
|
+
return None
|
|
90
|
+
# assistant 是用户最关心的最终回答,完整显示
|
|
91
|
+
return f"[green]● assistant[/]\n {_full(content)}"
|
|
92
|
+
if kind == "tool-call":
|
|
93
|
+
# 命令、路径动辄上百字符,参数放宽到 500 字符再截断
|
|
94
|
+
args = _truncate(part.args, 500)
|
|
95
|
+
return f"[yellow]⏺ tool_call[/]\n [yellow dim]{part.tool_name}({args})[/]"
|
|
96
|
+
if kind == "tool-return":
|
|
97
|
+
content = _truncate(part.content)
|
|
98
|
+
return f"[magenta]✔ tool_return[/]\n [magenta dim]{part.tool_name} -> {content}[/]"
|
|
99
|
+
if kind == "retry-prompt":
|
|
100
|
+
# 工具抛 ModelRetry 后,SDK 生成 retry-prompt 把错误反馈给模型
|
|
101
|
+
content = _truncate(part.content)
|
|
102
|
+
return f"[yellow]✘ tool_retry[/]\n [yellow dim]{part.tool_name} -> {content}[/]"
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def print_assistant_markdown(content: str) -> None:
|
|
107
|
+
"""
|
|
108
|
+
模型的回复天然是 Markdown 格式,整块渲染出来,而不是打印原始文本。
|
|
109
|
+
"""
|
|
110
|
+
console.print("[green]● assistant[/]")
|
|
111
|
+
# Markdown 是块级渲染对象,没法跟在行内前缀后面,所以另起一行渲染;左缩进 2 格和 role 名对齐
|
|
112
|
+
console.print(Padding(Markdown(content), (0, 0, 0, 2)))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def print_part(part: Any) -> None:
|
|
116
|
+
"""
|
|
117
|
+
渲染单个消息 part:assistant 文本走 Markdown 块渲染,其余 part 是单行文本。
|
|
118
|
+
"""
|
|
119
|
+
if part.part_kind == "text":
|
|
120
|
+
content = (part.content or "").strip()
|
|
121
|
+
if content:
|
|
122
|
+
print_assistant_markdown(content)
|
|
123
|
+
# 每个 role block 末尾留一个空行,块与块之间不那么挤
|
|
124
|
+
console.print()
|
|
125
|
+
return
|
|
126
|
+
line = _format_part_line(part)
|
|
127
|
+
if line:
|
|
128
|
+
label, _, body = line.partition("\n")
|
|
129
|
+
# body 形如 " [markup]…":去掉字面前导 2 空格,交给 print_step 用 Padding 缩进
|
|
130
|
+
print_step(label, body[2:])
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def print_agent_steps(new_messages: Iterable[Any]) -> None:
|
|
134
|
+
"""
|
|
135
|
+
主循环里调用:显示这一轮 Agent 新增的中间过程(thinking、文本、工具调用、工具返回)。
|
|
136
|
+
"""
|
|
137
|
+
for msg in new_messages:
|
|
138
|
+
for part in msg.parts:
|
|
139
|
+
# 主循环里不重复显示用户刚刚输入的内容
|
|
140
|
+
if part.part_kind == "user-prompt":
|
|
141
|
+
continue
|
|
142
|
+
print_part(part)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def cmd_exit(state: SessionState) -> bool:
|
|
146
|
+
console.print("再见 👋")
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def cmd_help(state: SessionState) -> bool:
|
|
151
|
+
console.print("可用命令:")
|
|
152
|
+
for cmd in COMMANDS.values():
|
|
153
|
+
console.print(f" /{cmd.name:<10} {cmd.description}")
|
|
154
|
+
console.print()
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cmd_new(state: SessionState) -> bool:
|
|
159
|
+
"""
|
|
160
|
+
开启新会话:清空历史、token 计数、API 调用记录。
|
|
161
|
+
"""
|
|
162
|
+
state.history.clear()
|
|
163
|
+
state.input_tokens = 0
|
|
164
|
+
state.output_tokens = 0
|
|
165
|
+
state.last_api_calls.clear()
|
|
166
|
+
console.print("已开启新会话\n")
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def cmd_status(state: SessionState) -> bool:
|
|
171
|
+
console.print(f"模型: {state.model_name}")
|
|
172
|
+
console.print(f"历史消息条数: {len(state.history)}")
|
|
173
|
+
console.print(f"累计输入 tokens:{state.input_tokens}")
|
|
174
|
+
console.print(f"累计输出 tokens:{state.output_tokens}\n")
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def cmd_api_detail(state: SessionState) -> bool:
|
|
179
|
+
"""
|
|
180
|
+
显示最近一轮 user input 触发的所有 model API 调用元数据。
|
|
181
|
+
"""
|
|
182
|
+
if not state.last_api_calls:
|
|
183
|
+
console.print("(还没有任何模型调用记录,先发一条消息再来看)\n")
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
console.print(f"最近一轮共发起 {len(state.last_api_calls)} 次 model API 调用\n")
|
|
187
|
+
|
|
188
|
+
for i, call in enumerate(state.last_api_calls, 1):
|
|
189
|
+
console.print(f"[bold]Call #{i}[/]")
|
|
190
|
+
console.print(" Request:")
|
|
191
|
+
console.print(f" model: {call.model}")
|
|
192
|
+
console.print(f" messages: {call.messages_count} 条")
|
|
193
|
+
if call.last_part is not None:
|
|
194
|
+
preview = _format_part_line(call.last_part)
|
|
195
|
+
if preview:
|
|
196
|
+
console.print(f" last_message: {preview}")
|
|
197
|
+
console.print(f" tools: {', '.join(call.tools)}")
|
|
198
|
+
console.print(" Response:")
|
|
199
|
+
console.print(f" finish_reason: {call.finish_reason}")
|
|
200
|
+
console.print(f" parts: {', '.join(call.parts_kinds)}")
|
|
201
|
+
console.print(f" usage: input={call.input_tokens}, output={call.output_tokens}")
|
|
202
|
+
console.print()
|
|
203
|
+
return True
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
COMMANDS = {
|
|
207
|
+
"new": Command("new", "开启新会话", cmd_new),
|
|
208
|
+
"status": Command("status", "显示当前会话状态", cmd_status),
|
|
209
|
+
"api-detail": Command("api-detail", "显示最近一轮 model API 调用详情", cmd_api_detail),
|
|
210
|
+
"help": Command("help", "显示可用命令", cmd_help),
|
|
211
|
+
"exit": Command("exit", "退出程序", cmd_exit),
|
|
212
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
共享的终端渲染原语,供 commands / permissions / hooks 复用,只依赖 rich。
|
|
3
|
+
抽到这里是为了打破 commands 与 permissions/hooks 的循环依赖,调用方就能在文件顶部直接 import。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.padding import Padding
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
# 共享的 Console 实例,跨平台自动处理 ANSI 颜色(Windows 老终端也能正常显示)。
|
|
12
|
+
# highlight=False 关闭 Rich 自带的数字/字符串自动高亮,只让我们手动加的 markup 生效。
|
|
13
|
+
console = Console(highlight=False)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def print_step(label: str, content: str = "") -> None:
|
|
17
|
+
"""
|
|
18
|
+
打印一个中间过程 block:标签独占一行,内容用 Padding 左缩进 2 格
|
|
19
|
+
(这样自动折行的续行也能保持缩进),末尾留一个空行。
|
|
20
|
+
"""
|
|
21
|
+
console.print(label)
|
|
22
|
+
if content:
|
|
23
|
+
console.print(Padding(content, (0, 0, 0, 2)))
|
|
24
|
+
console.print()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def print_welcome_banner(title: str) -> None:
|
|
28
|
+
"""
|
|
29
|
+
打印启动欢迎横幅:标题装在带色彩的 rich.Panel 里,附 /help 提示。
|
|
30
|
+
"""
|
|
31
|
+
body = Text.assemble(
|
|
32
|
+
("✻ ", "bright_magenta"),
|
|
33
|
+
(f"欢迎使用 {title}\n\n", "bold"),
|
|
34
|
+
(" /help ", "cyan"),
|
|
35
|
+
("查看可用命令", "dim"),
|
|
36
|
+
)
|
|
37
|
+
console.print(Panel(body, border_style="bright_blue", padding=(0, 1), width=48))
|
|
38
|
+
console.print()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "zrcoder"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Terminal coding agent chat — OpenAI-compatible APIs, tools, and slash commands"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"prompt-toolkit>=3.0.53",
|
|
10
|
+
"pydantic-ai>=2.40.0",
|
|
11
|
+
"python-dotenv>=1.2.3",
|
|
12
|
+
"rich>=15.0.0",
|
|
13
|
+
]
|
|
14
|
+
keywords = [
|
|
15
|
+
"cli",
|
|
16
|
+
"agent",
|
|
17
|
+
"coding",
|
|
18
|
+
"terminal",
|
|
19
|
+
"openai",
|
|
20
|
+
]
|
|
21
|
+
license-files = ["LICENSE"]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 3 - Alpha",
|
|
24
|
+
"Environment :: Console",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Programming Language :: Python :: 3.14",
|
|
30
|
+
"Topic :: Software Development",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[[project.authors]]
|
|
34
|
+
name = "Zr"
|
|
35
|
+
email = "547471919@qq.com"
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/ZRMYDYCG/ClaudeCode"
|
|
39
|
+
Repository = "https://github.com/ZRMYDYCG/ClaudeCode"
|
|
40
|
+
Issues = "https://github.com/ZRMYDYCG/ClaudeCode/issues"
|
|
41
|
+
|
|
42
|
+
[project.scripts]
|
|
43
|
+
zrcoder = "core.cli:main"
|
|
44
|
+
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = [
|
|
47
|
+
"ruff>=0.12.0",
|
|
48
|
+
"pytest>=8.0.0",
|
|
49
|
+
"mypy>=1.17.0",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[build-system]
|
|
53
|
+
requires = ["uv_build>=0.12.10,<0.13.0"]
|
|
54
|
+
build-backend = "uv_build"
|
|
55
|
+
|
|
56
|
+
[tool.uv.build-backend]
|
|
57
|
+
module-root = ""
|
|
58
|
+
module-name = "core"
|
|
59
|
+
|
|
60
|
+
[tool.ruff]
|
|
61
|
+
target-version = "py312"
|
|
62
|
+
line-length = 100
|
|
63
|
+
|
|
64
|
+
[tool.ruff.format]
|
|
65
|
+
quote-style = "double"
|
|
66
|
+
indent-style = "space"
|
|
67
|
+
line-ending = "lf"
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint]
|
|
70
|
+
select = [
|
|
71
|
+
"E",
|
|
72
|
+
"F",
|
|
73
|
+
"I",
|
|
74
|
+
"UP",
|
|
75
|
+
"B",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
[tool.pytest.ini_options]
|
|
79
|
+
testpaths = ["tests"]
|
|
80
|
+
pythonpath = ["."]
|
|
81
|
+
|
|
82
|
+
[tool.mypy]
|
|
83
|
+
python_version = "3.12"
|
|
84
|
+
strict = true
|
|
85
|
+
packages = ["core"]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "zrcoder"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Terminal coding agent chat — OpenAI-compatible APIs, tools, and slash commands"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Zr", email = "547471919@qq.com" }
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"prompt-toolkit>=3.0.53",
|
|
13
|
+
"pydantic-ai>=2.40.0",
|
|
14
|
+
"python-dotenv>=1.2.3",
|
|
15
|
+
"rich>=15.0.0",
|
|
16
|
+
]
|
|
17
|
+
keywords = ["cli", "agent", "coding", "terminal", "openai"]
|
|
18
|
+
license-files = ["LICENSE"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 3 - Alpha",
|
|
21
|
+
"Environment :: Console",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Topic :: Software Development",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/ZRMYDYCG/ClaudeCode"
|
|
32
|
+
Repository = "https://github.com/ZRMYDYCG/ClaudeCode"
|
|
33
|
+
Issues = "https://github.com/ZRMYDYCG/ClaudeCode/issues"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
zrcoder = "core.cli:main"
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = [
|
|
40
|
+
"ruff>=0.12.0",
|
|
41
|
+
"pytest>=8.0.0",
|
|
42
|
+
"mypy>=1.17.0",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[build-system]
|
|
46
|
+
requires = ["uv_build>=0.12.10,<0.13.0"]
|
|
47
|
+
build-backend = "uv_build"
|
|
48
|
+
|
|
49
|
+
[tool.uv.build-backend]
|
|
50
|
+
module-root = ""
|
|
51
|
+
module-name = "core"
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
target-version = "py312"
|
|
55
|
+
line-length = 100
|
|
56
|
+
|
|
57
|
+
[tool.ruff.format]
|
|
58
|
+
quote-style = "double"
|
|
59
|
+
indent-style = "space"
|
|
60
|
+
line-ending = "lf"
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
64
|
+
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
testpaths = ["tests"]
|
|
67
|
+
pythonpath = ["."]
|
|
68
|
+
|
|
69
|
+
[tool.mypy]
|
|
70
|
+
python_version = "3.12"
|
|
71
|
+
strict = true
|
|
72
|
+
packages = ["core"]
|