claxcode 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- claxcode/__init__.py +3 -0
- claxcode/api/__init__.py +1 -0
- claxcode/api/chat.py +51 -0
- claxcode/api/session.py +66 -0
- claxcode/api/subagents.py +82 -0
- claxcode/api/tools.py +38 -0
- claxcode/cli.py +1214 -0
- claxcode/cli_completion.py +174 -0
- claxcode/config.py +54 -0
- claxcode/core/__init__.py +1 -0
- claxcode/core/agent.py +327 -0
- claxcode/core/checkpoint.py +100 -0
- claxcode/core/context.py +114 -0
- claxcode/core/models.py +446 -0
- claxcode/extensions/__init__.py +1 -0
- claxcode/extensions/hooks.py +131 -0
- claxcode/extensions/mcp.py +13 -0
- claxcode/extensions/plugins.py +10 -0
- claxcode/extensions/skills.py +205 -0
- claxcode/main.py +55 -0
- claxcode/memory/__init__.py +23 -0
- claxcode/memory/auto_memory.py +68 -0
- claxcode/memory/long_term.py +196 -0
- claxcode/memory/project_context.py +20 -0
- claxcode/memory/project_memory.py +194 -0
- claxcode/memory/session_store.py +206 -0
- claxcode/models/__init__.py +1 -0
- claxcode/models/session.py +25 -0
- claxcode/models/subagent.py +25 -0
- claxcode/models/tool.py +24 -0
- claxcode/security/__init__.py +1 -0
- claxcode/security/audit.py +54 -0
- claxcode/security/permissions.py +29 -0
- claxcode/skills/bug-fix/SKILL.md +33 -0
- claxcode/skills/code-review/SKILL.md +35 -0
- claxcode/skills/python-refactor/SKILL.md +22 -0
- claxcode/skills/task-workflow/SKILL.md +38 -0
- claxcode/subagents/__init__.py +1 -0
- claxcode/subagents/base.py +108 -0
- claxcode/subagents/executor.py +16 -0
- claxcode/subagents/explore.py +25 -0
- claxcode/subagents/planner.py +25 -0
- claxcode/tools/__init__.py +14 -0
- claxcode/tools/admin.py +170 -0
- claxcode/tools/base.py +19 -0
- claxcode/tools/browser.py +499 -0
- claxcode/tools/code_intelligence.py +68 -0
- claxcode/tools/custom_example.py +76 -0
- claxcode/tools/execution.py +114 -0
- claxcode/tools/file_operations.py +287 -0
- claxcode/tools/memory_tool.py +29 -0
- claxcode/tools/registry.py +35 -0
- claxcode/tools/search.py +157 -0
- claxcode/tools/ssh.py +534 -0
- claxcode/tools/web.py +83 -0
- claxcode-0.1.1.dist-info/METADATA +359 -0
- claxcode-0.1.1.dist-info/RECORD +60 -0
- claxcode-0.1.1.dist-info/WHEEL +4 -0
- claxcode-0.1.1.dist-info/entry_points.txt +3 -0
- claxcode-0.1.1.dist-info/licenses/LICENSE +201 -0
claxcode/cli.py
ADDED
|
@@ -0,0 +1,1214 @@
|
|
|
1
|
+
"""Interactive CLI for ClaxCode — terminal-based agentic coding assistant."""
|
|
2
|
+
|
|
3
|
+
# Load .env BEFORE importing settings so that pydantic picks up the values
|
|
4
|
+
import argparse
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Awaitable, Callable
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from importlib.metadata import PackageNotFoundError
|
|
15
|
+
from importlib.metadata import version as package_version
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
from uuid import UUID, uuid4
|
|
19
|
+
|
|
20
|
+
from dotenv import load_dotenv
|
|
21
|
+
|
|
22
|
+
# Parse --env before importing settings; all other arguments are validated in main().
|
|
23
|
+
_env_parser = argparse.ArgumentParser(add_help=False)
|
|
24
|
+
_env_parser.add_argument("--env", "-e")
|
|
25
|
+
_env_args, _ = _env_parser.parse_known_args()
|
|
26
|
+
_missing_env_file: Path | None = None
|
|
27
|
+
if _env_args.env:
|
|
28
|
+
_env_path = Path(_env_args.env).resolve()
|
|
29
|
+
if _env_path.exists():
|
|
30
|
+
load_dotenv(_env_path, override=True)
|
|
31
|
+
else:
|
|
32
|
+
_missing_env_file = _env_path
|
|
33
|
+
else:
|
|
34
|
+
for _env_path in [Path(".env").resolve(), Path.home() / ".claxcode" / ".env"]:
|
|
35
|
+
if _env_path.exists():
|
|
36
|
+
load_dotenv(_env_path, override=True)
|
|
37
|
+
break
|
|
38
|
+
|
|
39
|
+
# Ensure all built-in tools are registered
|
|
40
|
+
import claxcode.tools # noqa: F401
|
|
41
|
+
from claxcode.config import settings
|
|
42
|
+
from claxcode.core.agent import Agent
|
|
43
|
+
from claxcode.core.checkpoint import CheckpointManager
|
|
44
|
+
from claxcode.extensions.skills import SkillManager
|
|
45
|
+
from claxcode.memory import (
|
|
46
|
+
init_db,
|
|
47
|
+
long_term_memory,
|
|
48
|
+
project_memory,
|
|
49
|
+
)
|
|
50
|
+
from claxcode.memory.auto_memory import auto_memory
|
|
51
|
+
from claxcode.memory.project_context import load_project_context
|
|
52
|
+
from claxcode.memory.session_store import get_session_messages, save_session_messages
|
|
53
|
+
from claxcode.security.audit import audit_logger
|
|
54
|
+
from claxcode.tools.registry import registry
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
from prompt_toolkit import PromptSession
|
|
58
|
+
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
|
59
|
+
from prompt_toolkit.enums import DEFAULT_BUFFER
|
|
60
|
+
from prompt_toolkit.filters import completion_is_selected, has_focus
|
|
61
|
+
from prompt_toolkit.history import FileHistory
|
|
62
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
63
|
+
|
|
64
|
+
from claxcode.cli_completion import CliCompleter
|
|
65
|
+
except ImportError: # prompt_toolkit 未安装时退化为朴素 input()
|
|
66
|
+
PromptSession = None # type: ignore[assignment]
|
|
67
|
+
CliCompleter = None # type: ignore[assignment,misc]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_MENTION_RE = re.compile(r'(?<![A-Za-z0-9_@])@(?:"([^"]+)"|([^\s,。;:、!?]+))')
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _split_command(command: str) -> list[str]:
|
|
74
|
+
"""Split shell-like arguments without corrupting Windows backslashes."""
|
|
75
|
+
parts = shlex.split(command, posix=False)
|
|
76
|
+
return [
|
|
77
|
+
part[1:-1]
|
|
78
|
+
if len(part) >= 2 and part[0] == part[-1] and part[0] in {'"', "'"}
|
|
79
|
+
else part
|
|
80
|
+
for part in parts
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _get_version() -> str:
|
|
85
|
+
try:
|
|
86
|
+
return package_version("claxcode")
|
|
87
|
+
except PackageNotFoundError:
|
|
88
|
+
return "未知"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class SlashCommand:
|
|
93
|
+
"""A built-in CLI command."""
|
|
94
|
+
|
|
95
|
+
usage: str
|
|
96
|
+
description: str
|
|
97
|
+
handler: Callable[[list[str]], Awaitable[bool]] # returns True to exit the REPL
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class InteractiveCLI:
|
|
101
|
+
"""REPL-style interactive CLI."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, project_path: str = ".", permission_mode: str = "ask"):
|
|
104
|
+
self.session_id = uuid4()
|
|
105
|
+
self.project_path = Path(project_path).resolve()
|
|
106
|
+
self.permission_mode = permission_mode
|
|
107
|
+
self._auto_approved: set[str] = set()
|
|
108
|
+
self.agent: Agent | None = None
|
|
109
|
+
self.skill_manager = SkillManager()
|
|
110
|
+
self._load_skills()
|
|
111
|
+
self._command_table = self._build_commands()
|
|
112
|
+
self._prompt_session = self._create_prompt_session()
|
|
113
|
+
|
|
114
|
+
def _create_prompt_session(self) -> "PromptSession[Any] | None":
|
|
115
|
+
"""Build the enhanced prompt, or fall back to plain input()."""
|
|
116
|
+
if PromptSession is None or CliCompleter is None:
|
|
117
|
+
return None
|
|
118
|
+
completer = CliCompleter(self.project_path, self._completable_commands)
|
|
119
|
+
history_file = self.project_path / ".claxcode" / "cli_history"
|
|
120
|
+
bindings = KeyBindings()
|
|
121
|
+
|
|
122
|
+
@bindings.add("enter", filter=has_focus(DEFAULT_BUFFER) & ~completion_is_selected)
|
|
123
|
+
def _accept_input(event: Any) -> None:
|
|
124
|
+
event.current_buffer.validate_and_handle()
|
|
125
|
+
|
|
126
|
+
@bindings.add("escape", "enter")
|
|
127
|
+
def _insert_newline(event: Any) -> None:
|
|
128
|
+
event.current_buffer.insert_text("\n")
|
|
129
|
+
|
|
130
|
+
@bindings.add("c-c")
|
|
131
|
+
def _clear_or_exit(event: Any) -> None:
|
|
132
|
+
if event.current_buffer.text:
|
|
133
|
+
event.current_buffer.reset()
|
|
134
|
+
else:
|
|
135
|
+
event.app.exit(exception=KeyboardInterrupt())
|
|
136
|
+
|
|
137
|
+
with suppress(Exception):
|
|
138
|
+
history_file.parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
return PromptSession(
|
|
140
|
+
completer=completer,
|
|
141
|
+
complete_while_typing=True,
|
|
142
|
+
complete_in_thread=True,
|
|
143
|
+
history=FileHistory(str(history_file)),
|
|
144
|
+
auto_suggest=AutoSuggestFromHistory(),
|
|
145
|
+
enable_history_search=True,
|
|
146
|
+
key_bindings=bindings,
|
|
147
|
+
multiline=True,
|
|
148
|
+
bottom_toolbar=self._bottom_toolbar,
|
|
149
|
+
)
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
def _bottom_toolbar(self) -> str:
|
|
153
|
+
return f" {self.permission_mode} | {self.project_path.name} | /help 帮助 | Alt+Enter 换行 "
|
|
154
|
+
|
|
155
|
+
def _completable_commands(self) -> list[tuple[str, str]]:
|
|
156
|
+
"""Slash commands (builtin + skill-defined) for completion."""
|
|
157
|
+
items = [(name, entry.description) for name, entry in self._command_table.items()]
|
|
158
|
+
items += [
|
|
159
|
+
("/" + c["name"], c["description"])
|
|
160
|
+
for c in self.skill_manager.get_skill_commands()
|
|
161
|
+
]
|
|
162
|
+
return items
|
|
163
|
+
|
|
164
|
+
def _load_skills(self) -> None:
|
|
165
|
+
"""Load built-in, user-global and project skills."""
|
|
166
|
+
self.skill_manager.load_defaults(self.project_path)
|
|
167
|
+
|
|
168
|
+
async def run(self) -> None:
|
|
169
|
+
# 初始化数据库
|
|
170
|
+
await init_db()
|
|
171
|
+
|
|
172
|
+
print(f"\n🚀 ClaxCode · {settings.llm_provider}/{settings.llm_model}")
|
|
173
|
+
print(f"📁 {self.project_path} · 模式 {self.permission_mode}")
|
|
174
|
+
print("💡 @ 文件 · ! 命令 · # 记忆 · /help 帮助 · Alt+Enter 换行\n")
|
|
175
|
+
|
|
176
|
+
self.agent = Agent(
|
|
177
|
+
session_id=self.session_id,
|
|
178
|
+
project_path=str(self.project_path),
|
|
179
|
+
permission_mode=self.permission_mode,
|
|
180
|
+
skill_manager=self.skill_manager,
|
|
181
|
+
approval_callback=self._approve_tool,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
restored = await self._resume_session()
|
|
185
|
+
if restored:
|
|
186
|
+
print(f"💾 已恢复上次会话({restored} 条消息),输入 /clear 可重新开始。\n")
|
|
187
|
+
|
|
188
|
+
while True:
|
|
189
|
+
try:
|
|
190
|
+
user_input = await self._read_input()
|
|
191
|
+
except (EOFError, KeyboardInterrupt):
|
|
192
|
+
print("\n👋 再见!")
|
|
193
|
+
break
|
|
194
|
+
|
|
195
|
+
user_input = user_input.strip()
|
|
196
|
+
if not user_input:
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
if user_input.startswith("/"):
|
|
200
|
+
should_exit = await self._handle_command(user_input)
|
|
201
|
+
if should_exit:
|
|
202
|
+
break
|
|
203
|
+
elif user_input.startswith("!"):
|
|
204
|
+
await self._run_shell_inline(user_input[1:].strip())
|
|
205
|
+
elif user_input.startswith("#"):
|
|
206
|
+
self._quick_memory(user_input[1:].strip())
|
|
207
|
+
else:
|
|
208
|
+
text = self._expand_mentions(user_input)
|
|
209
|
+
await self._chat(text)
|
|
210
|
+
|
|
211
|
+
async def _read_input(self, prompt: str = "❯ ", is_password: bool = False) -> str:
|
|
212
|
+
if self._prompt_session is not None:
|
|
213
|
+
return await self._prompt_session.prompt_async(prompt, is_password=is_password)
|
|
214
|
+
if is_password:
|
|
215
|
+
from getpass import getpass
|
|
216
|
+
|
|
217
|
+
loop = asyncio.get_event_loop()
|
|
218
|
+
return await loop.run_in_executor(None, lambda: getpass(prompt))
|
|
219
|
+
loop = asyncio.get_event_loop()
|
|
220
|
+
return await loop.run_in_executor(None, lambda: input(prompt))
|
|
221
|
+
|
|
222
|
+
async def _approve_tool(self, tool_name: str, args: dict[str, Any]) -> bool:
|
|
223
|
+
"""Prompt the user to approve a tool call in ask mode."""
|
|
224
|
+
if tool_name in self._auto_approved:
|
|
225
|
+
return True
|
|
226
|
+
args_preview = json.dumps(args, ensure_ascii=False)[:200]
|
|
227
|
+
print(f"\n🔐 权限请求: {tool_name}({args_preview})")
|
|
228
|
+
try:
|
|
229
|
+
answer = (await self._read_input(" 允许? [y]es / [a]lways / [N]o: ")).strip().lower()
|
|
230
|
+
except (EOFError, KeyboardInterrupt):
|
|
231
|
+
print()
|
|
232
|
+
return False
|
|
233
|
+
if answer == "a":
|
|
234
|
+
self._auto_approved.add(tool_name)
|
|
235
|
+
return True
|
|
236
|
+
return answer in ("y", "yes")
|
|
237
|
+
|
|
238
|
+
async def _resume_session(self) -> int:
|
|
239
|
+
"""Resume the previous CLI session for this project. Returns messages restored."""
|
|
240
|
+
state_file = self.project_path / ".claxcode" / "cli_session"
|
|
241
|
+
if state_file.exists():
|
|
242
|
+
with suppress(ValueError): # 损坏的状态文件:沿用新会话 ID
|
|
243
|
+
self.session_id = UUID(state_file.read_text(encoding="utf-8").strip())
|
|
244
|
+
else:
|
|
245
|
+
state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
246
|
+
state_file.write_text(str(self.session_id), encoding="utf-8")
|
|
247
|
+
|
|
248
|
+
if self.agent is None:
|
|
249
|
+
return 0
|
|
250
|
+
self.agent.session_id = self.session_id
|
|
251
|
+
saved = await get_session_messages(self.session_id)
|
|
252
|
+
if saved:
|
|
253
|
+
self.agent.context.restore(saved)
|
|
254
|
+
return len(saved)
|
|
255
|
+
|
|
256
|
+
async def _persist_session(self) -> None:
|
|
257
|
+
"""Persist the conversation (minus the system prompt) for later resume."""
|
|
258
|
+
if self.agent is None:
|
|
259
|
+
return
|
|
260
|
+
with suppress(Exception): # 持久化失败不应影响交互
|
|
261
|
+
await save_session_messages(self.session_id, self.agent.context.messages[1:])
|
|
262
|
+
|
|
263
|
+
async def _chat(self, message: str) -> None:
|
|
264
|
+
if self.agent is None:
|
|
265
|
+
return
|
|
266
|
+
print()
|
|
267
|
+
|
|
268
|
+
# 导入工具取消控制
|
|
269
|
+
from claxcode.tools.execution import set_tool_cancelled
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
async for event in self.agent.run(message):
|
|
273
|
+
etype = event.get("type")
|
|
274
|
+
if etype == "thinking":
|
|
275
|
+
print(f"💭 {event['content']}")
|
|
276
|
+
elif etype == "message_delta":
|
|
277
|
+
print(event["content"], end="", flush=True)
|
|
278
|
+
elif etype == "tool_call":
|
|
279
|
+
print(f"\n🔧 调用工具: {event['name']}({json.dumps(event.get('arguments', {}))})")
|
|
280
|
+
print(" (按 Ctrl+C 中止工具执行)")
|
|
281
|
+
elif etype == "tool_result":
|
|
282
|
+
print(f"📋 结果:\n{event['output'][:500]}{'...' if len(event['output']) > 500 else ''}")
|
|
283
|
+
elif etype == "debug":
|
|
284
|
+
self._print_debug(event)
|
|
285
|
+
elif etype == "done":
|
|
286
|
+
print("\n")
|
|
287
|
+
except KeyboardInterrupt:
|
|
288
|
+
# 用户按 Ctrl+C,尝试取消工具执行
|
|
289
|
+
set_tool_cancelled()
|
|
290
|
+
print("\n\n⏹️ 已请求中止工具执行...")
|
|
291
|
+
# 给工具一些时间来响应取消
|
|
292
|
+
await asyncio.sleep(0.5)
|
|
293
|
+
print("📝 当前操作已中止。您可以继续输入新的指令。\n")
|
|
294
|
+
finally:
|
|
295
|
+
await self._persist_session()
|
|
296
|
+
|
|
297
|
+
async def _handle_command(self, command: str) -> bool:
|
|
298
|
+
try:
|
|
299
|
+
parts = _split_command(command)
|
|
300
|
+
except ValueError:
|
|
301
|
+
print("❌ 命令中的引号不完整。\n")
|
|
302
|
+
return False
|
|
303
|
+
if not parts:
|
|
304
|
+
return False
|
|
305
|
+
cmd, args = parts[0].lower(), parts[1:]
|
|
306
|
+
|
|
307
|
+
entry = self._command_table.get(cmd)
|
|
308
|
+
if entry is not None:
|
|
309
|
+
return await entry.handler(args)
|
|
310
|
+
|
|
311
|
+
skill_cmd = self.skill_manager.find_command(cmd.lstrip("/"))
|
|
312
|
+
if skill_cmd is not None:
|
|
313
|
+
await self._run_skill_command(skill_cmd, args)
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
print(f"❓ 未知命令: {cmd}。输入 /help 查看可用命令。\n")
|
|
317
|
+
return False
|
|
318
|
+
|
|
319
|
+
def _build_commands(self) -> dict[str, SlashCommand]:
|
|
320
|
+
"""Build the slash-command dispatch table (also the source of /help)."""
|
|
321
|
+
return {
|
|
322
|
+
"/help": SlashCommand("/help", "显示帮助信息", self._cmd_help),
|
|
323
|
+
"/tools": SlashCommand("/tools", "列出可用工具", self._cmd_tools),
|
|
324
|
+
"/mode": SlashCommand(
|
|
325
|
+
"/mode <mode>", "设置权限模式 (ask|auto_edit|plan|auto_safe|yolo)", self._cmd_mode
|
|
326
|
+
),
|
|
327
|
+
"/skills": SlashCommand("/skills [list|reload|create]", "技能管理", self._cmd_skills),
|
|
328
|
+
"/config": SlashCommand("/config", "显示当前 LLM 配置", self._cmd_config),
|
|
329
|
+
"/version": SlashCommand("/version", "查看 ClaxCode 版本号", self._cmd_version),
|
|
330
|
+
"/debug": SlashCommand("/debug", "切换调试模式", self._cmd_debug),
|
|
331
|
+
"/clear": SlashCommand("/clear", "清除对话上下文", self._cmd_clear),
|
|
332
|
+
"/project": SlashCommand("/project [list|switch|info]", "项目管理", self._cmd_project),
|
|
333
|
+
"/memory": SlashCommand(
|
|
334
|
+
"/memory [history|list|learn|forget|facts|...]", "记忆管理", self._cmd_memory
|
|
335
|
+
),
|
|
336
|
+
"/init": SlashCommand("/init", "初始化项目信息", self._cmd_init),
|
|
337
|
+
"/compact": SlashCommand("/compact", "压缩会话到长期记忆", self._cmd_compact),
|
|
338
|
+
"/audit": SlashCommand("/audit [n]", "查看最近 n 条审计日志", self._cmd_audit),
|
|
339
|
+
"/rollback": SlashCommand("/rollback [n|list]", "回滚文件编辑快照", self._cmd_rollback),
|
|
340
|
+
"/exit": SlashCommand("/exit, /quit", "退出 ClaxCode", self._cmd_exit),
|
|
341
|
+
"/quit": SlashCommand("/exit, /quit", "退出 ClaxCode", self._cmd_exit),
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async def _run_skill_command(self, skill_cmd: dict[str, str], args: list[str]) -> None:
|
|
345
|
+
"""Route a skill-defined command to the agent as an instruction."""
|
|
346
|
+
request = " ".join(args) if args else skill_cmd["description"]
|
|
347
|
+
prompt = f"使用 {skill_cmd['skill']} 技能的 {skill_cmd['name']} 命令:{request}"
|
|
348
|
+
await self._chat(prompt)
|
|
349
|
+
|
|
350
|
+
async def _run_shell_inline(self, command: str) -> None:
|
|
351
|
+
"""Run a shell command directly (input prefixed with '!')."""
|
|
352
|
+
if not command:
|
|
353
|
+
return
|
|
354
|
+
tool = registry.get("run_shell")
|
|
355
|
+
if tool is None:
|
|
356
|
+
print("❌ run_shell 工具不可用。\n")
|
|
357
|
+
return
|
|
358
|
+
print(f"$ {command}")
|
|
359
|
+
try:
|
|
360
|
+
result = await tool.execute(command=command, cwd=str(self.project_path))
|
|
361
|
+
except KeyboardInterrupt:
|
|
362
|
+
from claxcode.tools.execution import set_tool_cancelled
|
|
363
|
+
|
|
364
|
+
set_tool_cancelled()
|
|
365
|
+
print("\n⏹️ 已中止命令。\n")
|
|
366
|
+
return
|
|
367
|
+
print(f"{result}\n")
|
|
368
|
+
|
|
369
|
+
def _quick_memory(self, text: str) -> None:
|
|
370
|
+
"""Store a quick note into auto memory (input prefixed with '#')."""
|
|
371
|
+
if not text:
|
|
372
|
+
return
|
|
373
|
+
key = f"note_{datetime.now():%Y%m%d_%H%M%S}_{uuid4().hex[:8]}"
|
|
374
|
+
print(f"✅ {auto_memory.remember(key, text)}(可用 /memory forget {key} 删除)\n")
|
|
375
|
+
|
|
376
|
+
def _expand_mentions(self, text: str) -> str:
|
|
377
|
+
"""Inline distinct project text files referenced by @path or @\"path with spaces\"."""
|
|
378
|
+
blocks: list[str] = []
|
|
379
|
+
warnings: list[str] = []
|
|
380
|
+
seen: set[Path] = set()
|
|
381
|
+
for match in _MENTION_RE.finditer(text):
|
|
382
|
+
quoted, unquoted = match.groups()
|
|
383
|
+
relative = quoted if quoted is not None else unquoted.rstrip(",.;!?")
|
|
384
|
+
path = (self.project_path / relative).resolve()
|
|
385
|
+
try:
|
|
386
|
+
path.relative_to(self.project_path)
|
|
387
|
+
except ValueError:
|
|
388
|
+
warnings.append(f"{relative}(不在项目目录内)")
|
|
389
|
+
continue
|
|
390
|
+
if path in seen:
|
|
391
|
+
continue
|
|
392
|
+
if not path.is_file():
|
|
393
|
+
warnings.append(f"{relative}(文件不存在或不是普通文件)")
|
|
394
|
+
continue
|
|
395
|
+
if path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}:
|
|
396
|
+
warnings.append(f"{relative}(当前不支持图片输入)")
|
|
397
|
+
continue
|
|
398
|
+
try:
|
|
399
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
400
|
+
except OSError as exc:
|
|
401
|
+
warnings.append(f"{relative}(读取失败: {exc})")
|
|
402
|
+
continue
|
|
403
|
+
seen.add(path)
|
|
404
|
+
truncated = "\n...[内容已截断]" if len(content) > 8000 else ""
|
|
405
|
+
blocks.append(f"\n\n[文件 {relative} 的内容]\n```\n{content[:8000]}{truncated}\n```")
|
|
406
|
+
for warning in dict.fromkeys(warnings):
|
|
407
|
+
print(f"⚠️ 已跳过文件引用: {warning}", file=sys.stderr)
|
|
408
|
+
return text + "".join(blocks)
|
|
409
|
+
|
|
410
|
+
async def one_shot(self, prompt: str) -> None:
|
|
411
|
+
"""Run a single prompt non-interactively (content to stdout)."""
|
|
412
|
+
await init_db()
|
|
413
|
+
self.agent = Agent(
|
|
414
|
+
session_id=self.session_id,
|
|
415
|
+
project_path=str(self.project_path),
|
|
416
|
+
permission_mode=self.permission_mode,
|
|
417
|
+
skill_manager=self.skill_manager,
|
|
418
|
+
)
|
|
419
|
+
text = self._expand_mentions(prompt)
|
|
420
|
+
async for event in self.agent.run(text):
|
|
421
|
+
etype = event.get("type")
|
|
422
|
+
if etype == "message_delta":
|
|
423
|
+
print(event["content"], end="", flush=True)
|
|
424
|
+
elif etype in ("tool_call", "tool_result"):
|
|
425
|
+
print(f"[{etype}] {event.get('name')}", file=sys.stderr)
|
|
426
|
+
print()
|
|
427
|
+
await self._persist_session()
|
|
428
|
+
|
|
429
|
+
async def _cmd_help(self, args: list[str]) -> bool:
|
|
430
|
+
self._print_help()
|
|
431
|
+
return False
|
|
432
|
+
|
|
433
|
+
async def _cmd_tools(self, args: list[str]) -> bool:
|
|
434
|
+
self._print_tools()
|
|
435
|
+
return False
|
|
436
|
+
|
|
437
|
+
async def _cmd_mode(self, args: list[str]) -> bool:
|
|
438
|
+
self._set_mode(args)
|
|
439
|
+
return False
|
|
440
|
+
|
|
441
|
+
async def _cmd_skills(self, args: list[str]) -> bool:
|
|
442
|
+
subcmd = args[0] if args else "list"
|
|
443
|
+
if subcmd == "list":
|
|
444
|
+
self._print_skills()
|
|
445
|
+
elif subcmd == "reload":
|
|
446
|
+
await self._reload_skills()
|
|
447
|
+
elif subcmd == "create":
|
|
448
|
+
await self._create_skill(args[1:])
|
|
449
|
+
else:
|
|
450
|
+
print(f"❓ 未知子命令: {subcmd}。用法: /skills [list|reload|create]\n")
|
|
451
|
+
return False
|
|
452
|
+
|
|
453
|
+
async def _cmd_config(self, args: list[str]) -> bool:
|
|
454
|
+
if args:
|
|
455
|
+
print("❓ 未知子命令。用法: /config\n")
|
|
456
|
+
else:
|
|
457
|
+
self._print_config()
|
|
458
|
+
return False
|
|
459
|
+
|
|
460
|
+
async def _init_global_config(self) -> None:
|
|
461
|
+
"""Interactively create the user-global model configuration."""
|
|
462
|
+
config_file = Path.home() / ".claxcode" / ".env"
|
|
463
|
+
providers = {"openai", "anthropic", "generic_openai", "ollama", "custom_http"}
|
|
464
|
+
try:
|
|
465
|
+
if config_file.exists():
|
|
466
|
+
answer = await self._read_input("⚠️ 全局配置已存在,覆盖? [y/N] ")
|
|
467
|
+
if answer.strip().lower() not in ("y", "yes"):
|
|
468
|
+
print("已取消,保留现有全局配置。\n")
|
|
469
|
+
return
|
|
470
|
+
provider = (
|
|
471
|
+
await self._read_input(f"模型提供商 [{settings.llm_provider}]: ")
|
|
472
|
+
).strip() or settings.llm_provider
|
|
473
|
+
model = (await self._read_input(f"模型名称 [{settings.llm_model}]: ")).strip()
|
|
474
|
+
base_url = (
|
|
475
|
+
await self._read_input(f"接口地址 [{settings.llm_base_url or '默认'}]: ")
|
|
476
|
+
).strip()
|
|
477
|
+
api_key = await self._read_input("API 密钥(可留空): ", is_password=True)
|
|
478
|
+
except (EOFError, KeyboardInterrupt):
|
|
479
|
+
print("\n已取消全局配置。\n")
|
|
480
|
+
return
|
|
481
|
+
|
|
482
|
+
model = model or settings.llm_model
|
|
483
|
+
if provider not in providers:
|
|
484
|
+
print(f"❌ 不支持的模型提供商: {provider},可选: {', '.join(sorted(providers))}\n")
|
|
485
|
+
return
|
|
486
|
+
values = [provider, model, base_url, api_key]
|
|
487
|
+
if any("\n" in value or "\r" in value for value in values):
|
|
488
|
+
print("❌ 配置值不能包含换行。\n")
|
|
489
|
+
return
|
|
490
|
+
|
|
491
|
+
content = (
|
|
492
|
+
f"LLM_PROVIDER={provider}\n"
|
|
493
|
+
f"LLM_API_KEY={api_key.strip()}\n"
|
|
494
|
+
f"LLM_BASE_URL={base_url}\n"
|
|
495
|
+
f"LLM_MODEL={model}\n"
|
|
496
|
+
f"LLM_TEMPERATURE={settings.llm_temperature}\n"
|
|
497
|
+
f"LLM_MAX_TOKENS={settings.llm_max_tokens}\n"
|
|
498
|
+
)
|
|
499
|
+
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
500
|
+
config_file.write_text(content, encoding="utf-8")
|
|
501
|
+
print(f"✅ 全局配置已保存: {config_file}")
|
|
502
|
+
print("重启 CLI 后生效,可使用 /config 查看。\n")
|
|
503
|
+
|
|
504
|
+
async def _cmd_version(self, args: list[str]) -> bool:
|
|
505
|
+
print(f"ClaxCode {_get_version()}\n")
|
|
506
|
+
return False
|
|
507
|
+
|
|
508
|
+
async def _cmd_debug(self, args: list[str]) -> bool:
|
|
509
|
+
self._toggle_debug()
|
|
510
|
+
return False
|
|
511
|
+
|
|
512
|
+
async def _cmd_clear(self, args: list[str]) -> bool:
|
|
513
|
+
if self.agent:
|
|
514
|
+
self.agent.context.clear()
|
|
515
|
+
await self._persist_session()
|
|
516
|
+
print("🧹 上下文已清除。\n")
|
|
517
|
+
return False
|
|
518
|
+
|
|
519
|
+
async def _cmd_project(self, args: list[str]) -> bool:
|
|
520
|
+
await self._handle_project_command(args)
|
|
521
|
+
return False
|
|
522
|
+
|
|
523
|
+
async def _cmd_memory(self, args: list[str]) -> bool:
|
|
524
|
+
await self._handle_memory_command(args)
|
|
525
|
+
return False
|
|
526
|
+
|
|
527
|
+
async def _cmd_init(self, args: list[str]) -> bool:
|
|
528
|
+
await self._handle_init_command()
|
|
529
|
+
return False
|
|
530
|
+
|
|
531
|
+
async def _cmd_compact(self, args: list[str]) -> bool:
|
|
532
|
+
await self._handle_compact_command()
|
|
533
|
+
return False
|
|
534
|
+
|
|
535
|
+
async def _cmd_audit(self, args: list[str]) -> bool:
|
|
536
|
+
if args and (not args[0].isdigit() or int(args[0]) < 1):
|
|
537
|
+
print("❌ 用法: /audit [正整数]\n")
|
|
538
|
+
return False
|
|
539
|
+
limit = int(args[0]) if args else 10
|
|
540
|
+
entries = audit_logger.read_recent(limit)
|
|
541
|
+
if not entries:
|
|
542
|
+
print("ℹ️ 暂无审计日志。\n")
|
|
543
|
+
return False
|
|
544
|
+
print(f"\n📜 最近 {len(entries)} 条审计日志:")
|
|
545
|
+
for e in entries:
|
|
546
|
+
mark = "✅" if e["allowed"] else "⛔"
|
|
547
|
+
print(f" {mark} [{e['timestamp'][:19]}] {e['tool_name']} — {e['result_preview'][:60]}")
|
|
548
|
+
print()
|
|
549
|
+
return False
|
|
550
|
+
|
|
551
|
+
async def _cmd_rollback(self, args: list[str]) -> bool:
|
|
552
|
+
mgr = self.agent.checkpoint if self.agent else CheckpointManager()
|
|
553
|
+
if args and args[0] == "list":
|
|
554
|
+
return self._print_snapshots(mgr.list_snapshots(self.session_id))
|
|
555
|
+
|
|
556
|
+
if args and (not args[0].isdigit() or int(args[0]) < 1):
|
|
557
|
+
print("❌ 用法: /rollback [正整数|list]\n")
|
|
558
|
+
return False
|
|
559
|
+
steps = int(args[0]) if args else 1
|
|
560
|
+
batches = mgr.list_snapshots(self.session_id, limit=steps)
|
|
561
|
+
if not batches:
|
|
562
|
+
print("ℹ️ 没有可回滚的快照。\n")
|
|
563
|
+
return False
|
|
564
|
+
prompt = f"⚠️ 将回滚最近 {len(batches)} 个快照,确认? [y/N] "
|
|
565
|
+
try:
|
|
566
|
+
answer = (await self._read_input(prompt)).strip().lower()
|
|
567
|
+
except (EOFError, KeyboardInterrupt):
|
|
568
|
+
print()
|
|
569
|
+
return False
|
|
570
|
+
if answer not in ("y", "yes"):
|
|
571
|
+
print("已取消。\n")
|
|
572
|
+
return False
|
|
573
|
+
restored = mgr.restore(self.session_id, steps=steps)
|
|
574
|
+
print(f"✅ 已恢复 {len(restored)} 个文件:")
|
|
575
|
+
for p in restored:
|
|
576
|
+
print(f" • {p}")
|
|
577
|
+
print()
|
|
578
|
+
return False
|
|
579
|
+
|
|
580
|
+
def _print_snapshots(self, batches: list[dict]) -> bool:
|
|
581
|
+
if not batches:
|
|
582
|
+
print("ℹ️ 没有可用的快照。\n")
|
|
583
|
+
return False
|
|
584
|
+
print(f"\n📸 最近 {len(batches)} 个快照:")
|
|
585
|
+
for i, b in enumerate(batches, 1):
|
|
586
|
+
files = ", ".join(b["files"][:3])
|
|
587
|
+
print(f" {i}. [{b['timestamp']}] {files}")
|
|
588
|
+
print()
|
|
589
|
+
return False
|
|
590
|
+
|
|
591
|
+
async def _cmd_exit(self, args: list[str]) -> bool:
|
|
592
|
+
print("👋 再见!")
|
|
593
|
+
return True
|
|
594
|
+
|
|
595
|
+
async def _create_skill(self, args: list[str]) -> None:
|
|
596
|
+
"""创建规范格式的 Skill 目录。"""
|
|
597
|
+
if not args:
|
|
598
|
+
print("❌ 用法: /skills create <skill-name>\n")
|
|
599
|
+
return
|
|
600
|
+
name = args[0]
|
|
601
|
+
skill_dir = self.project_path / ".claxcode" / "skills" / name
|
|
602
|
+
if skill_dir.exists():
|
|
603
|
+
print(f"❌ Skill '{name}' 已存在。\n")
|
|
604
|
+
return
|
|
605
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
606
|
+
|
|
607
|
+
skill_md = skill_dir / "SKILL.md"
|
|
608
|
+
skill_md.write_text(
|
|
609
|
+
f"""# {name}
|
|
610
|
+
|
|
611
|
+
## Description
|
|
612
|
+
简要描述此 Skill 的功能和用途。
|
|
613
|
+
|
|
614
|
+
## Instructions
|
|
615
|
+
详细说明 Agent 应如何使用此 Skill。
|
|
616
|
+
可以包含示例、约定、文件模式等。
|
|
617
|
+
|
|
618
|
+
## Commands (可选)
|
|
619
|
+
- `example_cmd`: 命令描述
|
|
620
|
+
|
|
621
|
+
## Notes
|
|
622
|
+
其他补充说明。
|
|
623
|
+
""",
|
|
624
|
+
encoding="utf-8",
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
tools_py = skill_dir / "tools.py"
|
|
628
|
+
tools_py.write_text(
|
|
629
|
+
f'''"""Tools for {name} skill.
|
|
630
|
+
|
|
631
|
+
在此文件中定义 Skill 专属的工具,使用 @tool 装饰器自动注册。
|
|
632
|
+
"""
|
|
633
|
+
|
|
634
|
+
from claxcode.tools.base import Tool
|
|
635
|
+
from claxcode.tools.registry import tool
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
# @tool
|
|
639
|
+
# class ExampleTool(Tool):
|
|
640
|
+
# name = "example_tool"
|
|
641
|
+
# description = "An example tool."
|
|
642
|
+
# input_schema = {{
|
|
643
|
+
# "type": "object",
|
|
644
|
+
# "properties": {{"arg": {{"type": "string"}}}},
|
|
645
|
+
# "required": ["arg"],
|
|
646
|
+
# }}
|
|
647
|
+
# category = "{name}"
|
|
648
|
+
# required_permissions = []
|
|
649
|
+
#
|
|
650
|
+
# async def execute(self, arg: str, **kwargs) -> str:
|
|
651
|
+
# return f"Result: {{arg}}"
|
|
652
|
+
''',
|
|
653
|
+
encoding="utf-8",
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
print(f"✅ Skill '{name}' 已创建!")
|
|
657
|
+
print(f" 路径: {skill_dir}")
|
|
658
|
+
print(" 文件: SKILL.md, tools.py")
|
|
659
|
+
print(" 编辑后运行 '/skills reload' 生效。\n")
|
|
660
|
+
|
|
661
|
+
def _print_help(self) -> None:
|
|
662
|
+
print("\n命令列表:")
|
|
663
|
+
seen: set[str] = set()
|
|
664
|
+
for entry in self._command_table.values():
|
|
665
|
+
if entry.usage in seen:
|
|
666
|
+
continue
|
|
667
|
+
seen.add(entry.usage)
|
|
668
|
+
print(f" {entry.usage:<38} {entry.description}")
|
|
669
|
+
|
|
670
|
+
skill_cmds = self.skill_manager.get_skill_commands()
|
|
671
|
+
if skill_cmds:
|
|
672
|
+
print("\n技能命令:")
|
|
673
|
+
for c in skill_cmds:
|
|
674
|
+
print(f" /{c['name']:<37} {c['description']} (技能: {c['skill']})")
|
|
675
|
+
print("\n输入技巧:")
|
|
676
|
+
print(" @文件路径 / @\"带 空格的路径\" 引用项目文件(Tab 模糊补全)")
|
|
677
|
+
print(" !命令 / #内容 执行 Shell / 保存自动记忆")
|
|
678
|
+
print(" Alt+Enter / Ctrl+C 换行 / 清空输入(空输入时退出)")
|
|
679
|
+
print("\n其他输入将发送给智能助手。\n")
|
|
680
|
+
|
|
681
|
+
def _print_tools(self) -> None:
|
|
682
|
+
print("\n可用工具:")
|
|
683
|
+
for tool in registry.list_tools():
|
|
684
|
+
print(f" • {tool.name} ({tool.category}) — {tool.description}")
|
|
685
|
+
print()
|
|
686
|
+
|
|
687
|
+
def _set_mode(self, args: list[str]) -> None:
|
|
688
|
+
modes = ["ask", "auto_edit", "plan", "auto_safe", "yolo"]
|
|
689
|
+
if not args:
|
|
690
|
+
mode = self._interactive_select(modes, prompt="选择模式:")
|
|
691
|
+
if mode is None:
|
|
692
|
+
print(" 已取消。\n")
|
|
693
|
+
return
|
|
694
|
+
else:
|
|
695
|
+
mode = args[0].lower()
|
|
696
|
+
if mode not in modes:
|
|
697
|
+
print("❌ 无效模式。可选: ask, auto_edit, plan, auto_safe, yolo\n")
|
|
698
|
+
return
|
|
699
|
+
self.permission_mode = mode
|
|
700
|
+
self._auto_approved.clear()
|
|
701
|
+
if self.agent:
|
|
702
|
+
self.agent.permission_mode = mode
|
|
703
|
+
print(f"✅ 模式已设置为: {mode}\n")
|
|
704
|
+
|
|
705
|
+
def _interactive_select(self, options: list[str], prompt: str = "选择:") -> str | None:
|
|
706
|
+
"""Show an inline arrow-key selector. Returns the chosen option or None if cancelled."""
|
|
707
|
+
import sys # noqa: F811
|
|
708
|
+
|
|
709
|
+
idx = options.index(self.permission_mode) if self.permission_mode in options else 0
|
|
710
|
+
prefix = f"{prompt} "
|
|
711
|
+
# Print initial line
|
|
712
|
+
print(f"{prefix}{options[idx]} [←↑↓→ 移动, Enter 确认, Esc 取消]", end="", flush=True)
|
|
713
|
+
|
|
714
|
+
if sys.platform == "win32":
|
|
715
|
+
import msvcrt
|
|
716
|
+
|
|
717
|
+
while True:
|
|
718
|
+
key = msvcrt.getch()
|
|
719
|
+
if key == b"\r": # Enter
|
|
720
|
+
print()
|
|
721
|
+
return options[idx]
|
|
722
|
+
if key == b"\x1b": # Esc
|
|
723
|
+
print()
|
|
724
|
+
return None
|
|
725
|
+
if key == b"\xe0": # Arrow-key prefix on Windows
|
|
726
|
+
key2 = msvcrt.getch()
|
|
727
|
+
if key2 == b"H": # Up
|
|
728
|
+
idx = (idx - 1) % len(options)
|
|
729
|
+
elif key2 == b"P": # Down
|
|
730
|
+
idx = (idx + 1) % len(options)
|
|
731
|
+
elif key2 == b"K": # Left
|
|
732
|
+
idx = (idx - 1) % len(options)
|
|
733
|
+
elif key2 == b"M": # Right
|
|
734
|
+
idx = (idx + 1) % len(options)
|
|
735
|
+
# Clear to end of line and reprint
|
|
736
|
+
print(f"\r{prefix}{options[idx]} [←↑↓→ 移动, Enter 确认, Esc 取消]", end="", flush=True)
|
|
737
|
+
else:
|
|
738
|
+
import termios
|
|
739
|
+
import tty
|
|
740
|
+
|
|
741
|
+
fd = sys.stdin.fileno()
|
|
742
|
+
old = termios.tcgetattr(fd)
|
|
743
|
+
try:
|
|
744
|
+
tty.setraw(fd)
|
|
745
|
+
while True:
|
|
746
|
+
ch = sys.stdin.read(1)
|
|
747
|
+
if ch == "\r":
|
|
748
|
+
print()
|
|
749
|
+
return options[idx]
|
|
750
|
+
if ch == "\x1b":
|
|
751
|
+
seq = sys.stdin.read(2)
|
|
752
|
+
if seq == "[A": # Up
|
|
753
|
+
idx = (idx - 1) % len(options)
|
|
754
|
+
elif seq == "[B": # Down
|
|
755
|
+
idx = (idx + 1) % len(options)
|
|
756
|
+
elif seq == "[D": # Left
|
|
757
|
+
idx = (idx - 1) % len(options)
|
|
758
|
+
elif seq == "[C": # Right
|
|
759
|
+
idx = (idx + 1) % len(options)
|
|
760
|
+
print(f"\r{prefix}{options[idx]} [←↑↓→ 移动, Enter 确认, Esc 取消]", end="", flush=True)
|
|
761
|
+
finally:
|
|
762
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
763
|
+
|
|
764
|
+
def _print_config(self) -> None:
|
|
765
|
+
print(f"""
|
|
766
|
+
配置信息:
|
|
767
|
+
LLM_PROVIDER: {settings.llm_provider}
|
|
768
|
+
LLM_MODEL: {settings.llm_model}
|
|
769
|
+
LLM_BASE_URL: {settings.llm_base_url or '(默认)'}
|
|
770
|
+
LLM_TEMPERATURE: {settings.llm_temperature}
|
|
771
|
+
LLM_MAX_TOKENS: {settings.llm_max_tokens}
|
|
772
|
+
API_KEY 状态: {'已设置' if settings.llm_api_key else '未设置'}
|
|
773
|
+
数据库: {settings.database_url}
|
|
774
|
+
权限模式: {self.permission_mode}
|
|
775
|
+
调试模式: {'开启' if self.agent and self.agent.debug else '关闭'}
|
|
776
|
+
工具超时: {settings.tool_timeout}秒
|
|
777
|
+
""")
|
|
778
|
+
|
|
779
|
+
async def _handle_project_command(self, args: list[str]) -> None:
|
|
780
|
+
"""处理 /project 命令."""
|
|
781
|
+
subcmd = args[0] if args else "info"
|
|
782
|
+
|
|
783
|
+
if subcmd == "list":
|
|
784
|
+
projects = await project_memory.list_all_projects()
|
|
785
|
+
if not projects:
|
|
786
|
+
print("ℹ️ 没有找到已初始化的项目。\n")
|
|
787
|
+
return
|
|
788
|
+
print("\n📁 已初始化的项目:")
|
|
789
|
+
for p in projects:
|
|
790
|
+
name = p.get("name", "未知")
|
|
791
|
+
path = p.get("project_path", "")
|
|
792
|
+
updated = p.get("updated_at", "")[:10]
|
|
793
|
+
has_summary = "✓" if p.get("has_summary") else " "
|
|
794
|
+
print(f" [{has_summary}] {name}")
|
|
795
|
+
print(f" 路径: {path}")
|
|
796
|
+
print(f" 更新: {updated}")
|
|
797
|
+
print()
|
|
798
|
+
|
|
799
|
+
elif subcmd == "switch":
|
|
800
|
+
if len(args) < 2:
|
|
801
|
+
print("❌ 用法: /project switch <项目路径>\n")
|
|
802
|
+
return
|
|
803
|
+
new_path = args[1]
|
|
804
|
+
path_obj = Path(new_path).resolve()
|
|
805
|
+
if not path_obj.is_dir():
|
|
806
|
+
print(f"❌ 项目目录不存在: {new_path}\n")
|
|
807
|
+
return
|
|
808
|
+
await self._persist_session()
|
|
809
|
+
debug = self.agent.debug if self.agent else None
|
|
810
|
+
has_agent = self.agent is not None
|
|
811
|
+
self.project_path = path_obj
|
|
812
|
+
self._auto_approved.clear()
|
|
813
|
+
self.skill_manager = SkillManager()
|
|
814
|
+
self._load_skills()
|
|
815
|
+
self._prompt_session = self._create_prompt_session()
|
|
816
|
+
self.session_id = uuid4()
|
|
817
|
+
restored = 0
|
|
818
|
+
if has_agent:
|
|
819
|
+
self.agent = Agent(
|
|
820
|
+
session_id=self.session_id,
|
|
821
|
+
project_path=str(self.project_path),
|
|
822
|
+
permission_mode=self.permission_mode,
|
|
823
|
+
skill_manager=self.skill_manager,
|
|
824
|
+
debug=debug,
|
|
825
|
+
approval_callback=self._approve_tool,
|
|
826
|
+
)
|
|
827
|
+
restored = await self._resume_session()
|
|
828
|
+
else:
|
|
829
|
+
self.agent = None
|
|
830
|
+
print(f"✅ 已切换到项目: {self.project_path}")
|
|
831
|
+
if restored:
|
|
832
|
+
print(f"💾 已恢复项目会话({restored} 条消息)。")
|
|
833
|
+
print()
|
|
834
|
+
|
|
835
|
+
elif subcmd == "info":
|
|
836
|
+
# 显示当前项目信息
|
|
837
|
+
project = await project_memory.get_project(str(self.project_path))
|
|
838
|
+
if project:
|
|
839
|
+
config = project.get("config", {})
|
|
840
|
+
print("\n📁 项目信息:")
|
|
841
|
+
print(f" 名称: {config.get('name', '未命名')}")
|
|
842
|
+
print(f" 路径: {project.get('project_path', '')}")
|
|
843
|
+
print(f" 描述: {config.get('description', '无')}")
|
|
844
|
+
print(f" 技术栈: {config.get('tech_stack', '未指定')}")
|
|
845
|
+
print(f" 初始化时间: {project.get('initialized_at', '')[:10]}")
|
|
846
|
+
if project.get("context_summary"):
|
|
847
|
+
print(f" 摘要: {project['context_summary'][:100]}...")
|
|
848
|
+
print()
|
|
849
|
+
else:
|
|
850
|
+
# 显示 CLAUDE.md 或提示未初始化
|
|
851
|
+
ctx = load_project_context(self.project_path)
|
|
852
|
+
if ctx:
|
|
853
|
+
print(f"📄 项目上下文:\n{ctx}\n")
|
|
854
|
+
else:
|
|
855
|
+
print("ℹ️ 项目未初始化。使用 /init 初始化项目。\n")
|
|
856
|
+
|
|
857
|
+
else:
|
|
858
|
+
print(f"❓ 未知子命令: {subcmd}。用法: /project [list|switch|info]\n")
|
|
859
|
+
|
|
860
|
+
async def _handle_memory_command(self, args: list[str]) -> None:
|
|
861
|
+
"""处理 /memory 命令."""
|
|
862
|
+
subcmd = args[0] if args else "overview"
|
|
863
|
+
|
|
864
|
+
if subcmd == "overview":
|
|
865
|
+
await self._memory_overview()
|
|
866
|
+
|
|
867
|
+
elif subcmd == "history":
|
|
868
|
+
self._memory_history(args)
|
|
869
|
+
|
|
870
|
+
elif subcmd == "list":
|
|
871
|
+
memories = await long_term_memory.list_all(
|
|
872
|
+
limit=20, project_path=str(self.project_path), include_global=True
|
|
873
|
+
)
|
|
874
|
+
if not memories:
|
|
875
|
+
print("ℹ️ 当前项目和全局长期记忆为空。\n")
|
|
876
|
+
return
|
|
877
|
+
print("\n🧠 长期记忆(当前项目 + 全局):")
|
|
878
|
+
for m in memories:
|
|
879
|
+
ts = m.get("timestamp", "")[:10]
|
|
880
|
+
scope = "全局" if not m.get("project_path") else "项目"
|
|
881
|
+
keywords = ", ".join(m.get("keywords", [])[:5])
|
|
882
|
+
content = m.get("compressed_content", "")[:80]
|
|
883
|
+
print(f" [{ts}] [{scope}] [{m['id']}] {keywords}")
|
|
884
|
+
print(f" {content}")
|
|
885
|
+
print(" 使用 /memory delete <完整记忆ID> 删除。\n")
|
|
886
|
+
|
|
887
|
+
elif subcmd == "search":
|
|
888
|
+
if len(args) < 2:
|
|
889
|
+
print("❌ 用法: /memory search <关键词>\n")
|
|
890
|
+
return
|
|
891
|
+
keywords = args[1:]
|
|
892
|
+
memories = await long_term_memory.search(
|
|
893
|
+
keywords=keywords,
|
|
894
|
+
project_path=str(self.project_path),
|
|
895
|
+
include_global=True,
|
|
896
|
+
limit=10,
|
|
897
|
+
)
|
|
898
|
+
if not memories:
|
|
899
|
+
print(f"ℹ️ 未找到包含关键词 '{', '.join(keywords)}' 的记忆。\n")
|
|
900
|
+
return
|
|
901
|
+
print(f"\n🔍 搜索结果 ({len(memories)} 条):")
|
|
902
|
+
for m in memories:
|
|
903
|
+
ts = m.get("timestamp", "")[:10]
|
|
904
|
+
scope = "全局" if not m.get("project_path") else "项目"
|
|
905
|
+
kw = ", ".join(m.get("keywords", []))
|
|
906
|
+
print(f" [{ts}] [{scope}] {kw}")
|
|
907
|
+
print(f" {m.get('compressed_content', '')[:100]}")
|
|
908
|
+
print()
|
|
909
|
+
|
|
910
|
+
elif subcmd == "export":
|
|
911
|
+
filepath = args[1] if len(args) > 1 else "memories_export.json"
|
|
912
|
+
count = await long_term_memory.export_to_file(
|
|
913
|
+
filepath,
|
|
914
|
+
project_path=str(self.project_path)
|
|
915
|
+
)
|
|
916
|
+
print(f"✅ 已导出 {count} 条记忆到: {filepath}\n")
|
|
917
|
+
|
|
918
|
+
elif subcmd == "import":
|
|
919
|
+
if len(args) < 2:
|
|
920
|
+
print("❌ 用法: /memory import <文件路径>\n")
|
|
921
|
+
return
|
|
922
|
+
filepath = args[1]
|
|
923
|
+
try:
|
|
924
|
+
count = await long_term_memory.import_from_file(filepath)
|
|
925
|
+
print(f"✅ 已导入 {count} 条记忆。\n")
|
|
926
|
+
except Exception as e:
|
|
927
|
+
print(f"❌ 导入失败: {e}\n")
|
|
928
|
+
|
|
929
|
+
elif subcmd == "delete":
|
|
930
|
+
if len(args) < 2:
|
|
931
|
+
print("❌ 用法: /memory delete <记忆ID>\n")
|
|
932
|
+
return
|
|
933
|
+
memory_id = args[1]
|
|
934
|
+
success = await long_term_memory.delete(memory_id)
|
|
935
|
+
if success:
|
|
936
|
+
print(f"✅ 已删除记忆: {memory_id}\n")
|
|
937
|
+
else:
|
|
938
|
+
print(f"❌ 未找到记忆: {memory_id}\n")
|
|
939
|
+
|
|
940
|
+
elif subcmd == "learn":
|
|
941
|
+
if len(args) < 3:
|
|
942
|
+
print("❌ 用法: /memory learn <键> <值>\n")
|
|
943
|
+
return
|
|
944
|
+
print(f"✅ {auto_memory.remember(args[1], ' '.join(args[2:]))}\n")
|
|
945
|
+
|
|
946
|
+
elif subcmd == "forget":
|
|
947
|
+
if len(args) < 2:
|
|
948
|
+
print("❌ 用法: /memory forget <键>\n")
|
|
949
|
+
return
|
|
950
|
+
if auto_memory.forget(args[1]):
|
|
951
|
+
print(f"✅ 已遗忘: {args[1]}\n")
|
|
952
|
+
else:
|
|
953
|
+
print(f"❌ 未找到: {args[1]}\n")
|
|
954
|
+
|
|
955
|
+
elif subcmd == "facts":
|
|
956
|
+
facts = auto_memory.items()
|
|
957
|
+
if not facts:
|
|
958
|
+
print("ℹ️ 自动记忆为空。可用 /memory learn <键> <值> 添加。\n")
|
|
959
|
+
return
|
|
960
|
+
print(f"\n🧠 自动记忆 ({len(facts)} 条):")
|
|
961
|
+
for k, v in facts.items():
|
|
962
|
+
print(f" • {k}: {v}")
|
|
963
|
+
print()
|
|
964
|
+
|
|
965
|
+
else:
|
|
966
|
+
print(f"❓ 未知子命令: {subcmd}。用法: /memory [history|list|learn|forget|...]\n")
|
|
967
|
+
|
|
968
|
+
async def _memory_overview(self) -> None:
|
|
969
|
+
"""打印记忆总览:长期记忆、自动记忆、当前会话。"""
|
|
970
|
+
memories = await long_term_memory.list_all(
|
|
971
|
+
limit=100, project_path=str(self.project_path), include_global=True
|
|
972
|
+
)
|
|
973
|
+
facts = auto_memory.items()
|
|
974
|
+
msg_count = len(self.agent.context.messages) - 1 if self.agent else 0
|
|
975
|
+
|
|
976
|
+
print("\n🧠 记忆总览:")
|
|
977
|
+
print(f" • 长期记忆(当前项目 + 全局): {len(memories)} 条(/memory list 查看)")
|
|
978
|
+
for m in memories[:3]:
|
|
979
|
+
print(f" - {m.get('compressed_content', '')[:60]}")
|
|
980
|
+
print(f" • 全局自动记忆: {len(facts)} 条(/memory facts 查看)")
|
|
981
|
+
print(f" • 当前会话消息: {max(msg_count, 0)} 条(/memory history 查看)")
|
|
982
|
+
print()
|
|
983
|
+
|
|
984
|
+
def _memory_history(self, args: list[str]) -> None:
|
|
985
|
+
"""显示当前会话的最近消息(含恢复的会话)。"""
|
|
986
|
+
if not self.agent:
|
|
987
|
+
print("❌ 代理未初始化。\n")
|
|
988
|
+
return
|
|
989
|
+
limit = int(args[1]) if len(args) > 1 and args[1].isdigit() else 20
|
|
990
|
+
messages = [m for m in self.agent.context.messages if m.get("role") != "system"]
|
|
991
|
+
if not messages:
|
|
992
|
+
print("ℹ️ 当前会话没有历史消息。\n")
|
|
993
|
+
return
|
|
994
|
+
shown = messages[-limit:]
|
|
995
|
+
print(f"\n📜 会话历史(共 {len(messages)} 条,显示最近 {len(shown)} 条):")
|
|
996
|
+
for m in shown:
|
|
997
|
+
role = m.get("role", "?")
|
|
998
|
+
content = str(m.get("content") or "")
|
|
999
|
+
if role == "assistant" and not content and m.get("tool_calls"):
|
|
1000
|
+
names = ", ".join(tc["function"]["name"] for tc in m["tool_calls"])
|
|
1001
|
+
content = f"[调用工具: {names}]"
|
|
1002
|
+
icon = {"user": "👤", "assistant": "🤖", "tool": "🔧"}.get(role, "•")
|
|
1003
|
+
print(f" {icon} {content.replace(chr(10), ' ')[:80]}")
|
|
1004
|
+
print()
|
|
1005
|
+
|
|
1006
|
+
async def _handle_init_command(self) -> None:
|
|
1007
|
+
"""处理 /init 命令 - 初始化项目."""
|
|
1008
|
+
context_file = self.project_path / "CLAUDE.md"
|
|
1009
|
+
try:
|
|
1010
|
+
if context_file.exists():
|
|
1011
|
+
answer = await self._read_input(f"⚠️ {context_file.name} 已存在,覆盖? [y/N] ")
|
|
1012
|
+
if answer.strip().lower() not in ("y", "yes"):
|
|
1013
|
+
print("已取消,保留现有项目上下文。\n")
|
|
1014
|
+
return
|
|
1015
|
+
|
|
1016
|
+
print("\n📝 初始化项目信息")
|
|
1017
|
+
print("-" * 40)
|
|
1018
|
+
name = await self._read_input(f"项目名称 [{self.project_path.name}]: ")
|
|
1019
|
+
description = await self._read_input("项目描述: ")
|
|
1020
|
+
tech_stack = await self._read_input("技术栈 (如: Python, FastAPI, React): ")
|
|
1021
|
+
build_command = await self._read_input("构建命令 (如: npm run build): ")
|
|
1022
|
+
test_command = await self._read_input("测试命令 (如: pytest): ")
|
|
1023
|
+
code_style = await self._read_input("代码风格偏好: ")
|
|
1024
|
+
notes = await self._read_input("其他说明: ")
|
|
1025
|
+
except (EOFError, KeyboardInterrupt):
|
|
1026
|
+
print("\n已取消项目初始化。\n")
|
|
1027
|
+
return
|
|
1028
|
+
|
|
1029
|
+
config = {
|
|
1030
|
+
"name": name.strip() or self.project_path.name,
|
|
1031
|
+
"description": description.strip(),
|
|
1032
|
+
"tech_stack": tech_stack.strip(),
|
|
1033
|
+
"build_command": build_command.strip(),
|
|
1034
|
+
"test_command": test_command.strip(),
|
|
1035
|
+
"code_style": code_style.strip(),
|
|
1036
|
+
"notes": notes.strip(),
|
|
1037
|
+
}
|
|
1038
|
+
memory_id = await project_memory.init_project(str(self.project_path), config)
|
|
1039
|
+
content = project_memory.generate_context_md(config)
|
|
1040
|
+
context_file.write_text(content, encoding="utf-8")
|
|
1041
|
+
|
|
1042
|
+
print("\n✅ 项目已初始化!")
|
|
1043
|
+
print(f" ID: {memory_id}")
|
|
1044
|
+
print(f" CLAUDE.md 已创建: {context_file}\n")
|
|
1045
|
+
|
|
1046
|
+
async def _handle_compact_command(self) -> None:
|
|
1047
|
+
"""处理 /compact 命令 - 压缩会话."""
|
|
1048
|
+
if not self.agent:
|
|
1049
|
+
print("❌ 代理未初始化。\n")
|
|
1050
|
+
return
|
|
1051
|
+
|
|
1052
|
+
print("\n🗜️ 正在压缩会话...")
|
|
1053
|
+
|
|
1054
|
+
# 获取当前会话的消息
|
|
1055
|
+
messages = self.agent.context.get_messages()
|
|
1056
|
+
if len(messages) <= 2: # 只有系统提示和少量消息
|
|
1057
|
+
print("ℹ️ 会话内容太少,无需压缩。\n")
|
|
1058
|
+
return
|
|
1059
|
+
|
|
1060
|
+
# 构建需要总结的内容
|
|
1061
|
+
content_parts = []
|
|
1062
|
+
for msg in messages:
|
|
1063
|
+
role = msg.get("role", "")
|
|
1064
|
+
content = msg.get("content", "")
|
|
1065
|
+
if role in ("user", "assistant") and content:
|
|
1066
|
+
content_parts.append(f"{role}: {content}")
|
|
1067
|
+
|
|
1068
|
+
full_content = "\n\n".join(content_parts)
|
|
1069
|
+
|
|
1070
|
+
if len(full_content) < 200:
|
|
1071
|
+
print("ℹ️ 会话内容太少,无需压缩。\n")
|
|
1072
|
+
return
|
|
1073
|
+
|
|
1074
|
+
try:
|
|
1075
|
+
project = await project_memory.get_project(str(self.project_path))
|
|
1076
|
+
previous_summary = project.get("context_summary", "") if project else ""
|
|
1077
|
+
summary_prompt = f"""请总结本次对话,并更新项目滚动摘要。
|
|
1078
|
+
|
|
1079
|
+
已有项目摘要(保留仍有效的信息,移除已被推翻的结论):
|
|
1080
|
+
{previous_summary[:2000] or "(无)"}
|
|
1081
|
+
|
|
1082
|
+
本次对话:
|
|
1083
|
+
{full_content[:8000]}
|
|
1084
|
+
|
|
1085
|
+
要求:
|
|
1086
|
+
1. 关键词覆盖本次讨论的主题。
|
|
1087
|
+
2. 本次总结聚焦这轮完成的工作、重要文件和结论,150字以内。
|
|
1088
|
+
3. 项目摘要融合旧摘要与本次对话,记录当前有效状态和未完成事项,250字以内。
|
|
1089
|
+
|
|
1090
|
+
严格使用以下格式:
|
|
1091
|
+
关键词: keyword1, keyword2, keyword3
|
|
1092
|
+
本次总结: ...
|
|
1093
|
+
项目摘要: ..."""
|
|
1094
|
+
|
|
1095
|
+
summary_response = ""
|
|
1096
|
+
async for chunk in self.agent.provider.stream_chat(
|
|
1097
|
+
messages=[{"role": "user", "content": summary_prompt}], tools=None
|
|
1098
|
+
):
|
|
1099
|
+
if isinstance(chunk, str):
|
|
1100
|
+
summary_response += chunk
|
|
1101
|
+
|
|
1102
|
+
keyword_match = re.search(r"^关键词[::]\s*(.+)$", summary_response, re.MULTILINE)
|
|
1103
|
+
summary_match = re.search(
|
|
1104
|
+
r"(?:本次总结|总结)[::]\s*(.*?)(?=\n项目摘要[::]|$)",
|
|
1105
|
+
summary_response,
|
|
1106
|
+
re.DOTALL,
|
|
1107
|
+
)
|
|
1108
|
+
project_match = re.search(r"项目摘要[::]\s*(.*)", summary_response, re.DOTALL)
|
|
1109
|
+
summary = (summary_match.group(1) if summary_match else summary_response).strip()
|
|
1110
|
+
if not summary:
|
|
1111
|
+
raise ValueError("模型未返回有效摘要")
|
|
1112
|
+
project_summary = (project_match.group(1) if project_match else summary).strip()
|
|
1113
|
+
keywords = (
|
|
1114
|
+
[k.strip() for k in re.split(r"[,,]", keyword_match.group(1)) if k.strip()]
|
|
1115
|
+
if keyword_match
|
|
1116
|
+
else []
|
|
1117
|
+
)
|
|
1118
|
+
|
|
1119
|
+
memory_id = await long_term_memory.save(
|
|
1120
|
+
session_id=str(self.session_id),
|
|
1121
|
+
compressed_content=summary,
|
|
1122
|
+
keywords=keywords,
|
|
1123
|
+
project_path=str(self.project_path),
|
|
1124
|
+
)
|
|
1125
|
+
await project_memory.update_summary(str(self.project_path), project_summary)
|
|
1126
|
+
|
|
1127
|
+
self.agent.context.clear()
|
|
1128
|
+
await self._persist_session()
|
|
1129
|
+
|
|
1130
|
+
print("✅ 会话已压缩,长期记忆与项目滚动摘要已更新!")
|
|
1131
|
+
print(f" 记忆ID: {memory_id}")
|
|
1132
|
+
print(f" 关键词: {', '.join(keywords[:5])}")
|
|
1133
|
+
print(f" 本次总结: {summary[:100]}\n")
|
|
1134
|
+
|
|
1135
|
+
except Exception as e:
|
|
1136
|
+
print(f"❌ 压缩失败: {e}\n")
|
|
1137
|
+
|
|
1138
|
+
def _toggle_debug(self) -> None:
|
|
1139
|
+
if self.agent is None:
|
|
1140
|
+
return
|
|
1141
|
+
self.agent.debug = not self.agent.debug
|
|
1142
|
+
state = "开启" if self.agent.debug else "关闭"
|
|
1143
|
+
print(f"🐛 调试模式: {state}\n")
|
|
1144
|
+
|
|
1145
|
+
def _print_debug(self, event: dict) -> None:
|
|
1146
|
+
label = event.get("label", "调试")
|
|
1147
|
+
payload = event.get("payload", {})
|
|
1148
|
+
print(f"\n{'─' * 60}")
|
|
1149
|
+
print(f"🐛 调试 — {label}")
|
|
1150
|
+
print(f"{'─' * 60}")
|
|
1151
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
1152
|
+
print(f"{'─' * 60}\n")
|
|
1153
|
+
|
|
1154
|
+
async def _reload_skills(self) -> None:
|
|
1155
|
+
"""热重载所有技能。"""
|
|
1156
|
+
count, tools_count = self.skill_manager.reload_all()
|
|
1157
|
+
if self.agent:
|
|
1158
|
+
self.agent.update_skills(self.skill_manager)
|
|
1159
|
+
print(f"✅ 已重载 {count} 个技能,注册 {tools_count} 个工具。\n")
|
|
1160
|
+
|
|
1161
|
+
def _print_skills(self) -> None:
|
|
1162
|
+
if not self.skill_manager.skills:
|
|
1163
|
+
print("ℹ️ 未加载任何技能。\n")
|
|
1164
|
+
return
|
|
1165
|
+
print("\n已加载的技能:")
|
|
1166
|
+
for skill in self.skill_manager.skills:
|
|
1167
|
+
tools_info = f" (+{len(skill.registered_tools)} 工具)" if skill.registered_tools else ""
|
|
1168
|
+
print(f" • {skill.name} [{skill.source}]{tools_info} — {skill.description}")
|
|
1169
|
+
print()
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _setup_console_utf8() -> None:
|
|
1173
|
+
"""Reconfigure stdout/stderr as UTF-8 so emoji output works on Windows cmd (GBK)."""
|
|
1174
|
+
for stream in (sys.stdout, sys.stderr):
|
|
1175
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
1176
|
+
if callable(reconfigure):
|
|
1177
|
+
with suppress(Exception):
|
|
1178
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def main() -> None:
|
|
1182
|
+
_setup_console_utf8()
|
|
1183
|
+
if _missing_env_file is not None:
|
|
1184
|
+
print(f"⚠️ 未找到环境文件: {_missing_env_file}")
|
|
1185
|
+
parser = argparse.ArgumentParser(description="ClaxCode 交互式命令行")
|
|
1186
|
+
parser.add_argument("--project", "-p", default=".", help="项目目录")
|
|
1187
|
+
parser.add_argument(
|
|
1188
|
+
"--mode", "-m", default="ask",
|
|
1189
|
+
choices=["ask", "auto_edit", "plan", "auto_safe", "yolo"],
|
|
1190
|
+
)
|
|
1191
|
+
parser.add_argument(
|
|
1192
|
+
"--version", "-v", action="version", version=f"%(prog)s {_get_version()}", help="显示版本号"
|
|
1193
|
+
)
|
|
1194
|
+
parser.add_argument("--env", "-e", default=None, help=".env 文件路径")
|
|
1195
|
+
parser.add_argument(
|
|
1196
|
+
"--print", "-P", dest="one_shot", default=None, metavar="PROMPT",
|
|
1197
|
+
help="非交互模式:执行单条指令后退出(工具日志走 stderr)",
|
|
1198
|
+
)
|
|
1199
|
+
subcommands = parser.add_subparsers(dest="command")
|
|
1200
|
+
subcommands.add_parser("init", help="交互式创建全局模型配置")
|
|
1201
|
+
args = parser.parse_args()
|
|
1202
|
+
|
|
1203
|
+
project_path = Path(args.project).resolve()
|
|
1204
|
+
cli = InteractiveCLI(project_path=str(project_path), permission_mode=args.mode)
|
|
1205
|
+
if args.command == "init":
|
|
1206
|
+
asyncio.run(cli._init_global_config())
|
|
1207
|
+
elif args.one_shot:
|
|
1208
|
+
asyncio.run(cli.one_shot(args.one_shot))
|
|
1209
|
+
else:
|
|
1210
|
+
asyncio.run(cli.run())
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
if __name__ == "__main__":
|
|
1214
|
+
main()
|