mosscode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mosscode/__init__.py +10 -0
- mosscode/__main__.py +3 -0
- mosscode/agent.py +486 -0
- mosscode/agents/__init__.py +249 -0
- mosscode/agents/builtin/explore.md +16 -0
- mosscode/agents/builtin/general.md +12 -0
- mosscode/agents/builtin/plan.md +16 -0
- mosscode/agents/runtime.py +685 -0
- mosscode/agents/tasks.py +193 -0
- mosscode/appearance.py +184 -0
- mosscode/builtin_commands.py +1363 -0
- mosscode/cli.py +547 -0
- mosscode/commands.py +254 -0
- mosscode/compaction.py +259 -0
- mosscode/config.py +337 -0
- mosscode/context.py +102 -0
- mosscode/conversation.py +1046 -0
- mosscode/coordinator.py +54 -0
- mosscode/doctor.py +303 -0
- mosscode/hooks/__init__.py +22 -0
- mosscode/hooks/actions.py +151 -0
- mosscode/hooks/conditions.py +170 -0
- mosscode/hooks/config.py +404 -0
- mosscode/hooks/engine.py +401 -0
- mosscode/hooks/events.py +48 -0
- mosscode/launcher.py +177 -0
- mosscode/mcp_client.py +460 -0
- mosscode/mcp_config.py +216 -0
- mosscode/member_ui.py +289 -0
- mosscode/memory.py +291 -0
- mosscode/memory_extract.py +245 -0
- mosscode/messages.py +216 -0
- mosscode/paths.py +31 -0
- mosscode/permissions.py +733 -0
- mosscode/prompts.py +483 -0
- mosscode/providers.py +587 -0
- mosscode/scrollback.py +106 -0
- mosscode/security.py +282 -0
- mosscode/serialization.py +121 -0
- mosscode/session.py +563 -0
- mosscode/setup.py +248 -0
- mosscode/skills/__init__.py +455 -0
- mosscode/skills/builtin/commit/SKILL.md +15 -0
- mosscode/skills/builtin/review/SKILL.md +15 -0
- mosscode/skills/builtin/test/SKILL.md +13 -0
- mosscode/skills/builtin/test/scripts/detect.py +96 -0
- mosscode/skills/builtin/test/tool.json +9 -0
- mosscode/teams/__init__.py +216 -0
- mosscode/teams/backends.py +259 -0
- mosscode/teams/board.py +230 -0
- mosscode/teams/lock.py +173 -0
- mosscode/teams/mailbox.py +172 -0
- mosscode/teams/member.py +427 -0
- mosscode/teams/merge.py +207 -0
- mosscode/teams/pane.py +20 -0
- mosscode/teams/pane_app.py +167 -0
- mosscode/teams/process.py +371 -0
- mosscode/teams/runtime.py +1355 -0
- mosscode/teams/view.py +182 -0
- mosscode/tool_budget.py +86 -0
- mosscode/tools/__init__.py +38 -0
- mosscode/tools/agent.py +127 -0
- mosscode/tools/base.py +384 -0
- mosscode/tools/diffing.py +16 -0
- mosscode/tools/execution.py +99 -0
- mosscode/tools/files.py +287 -0
- mosscode/tools/memory.py +65 -0
- mosscode/tools/plan.py +30 -0
- mosscode/tools/process.py +216 -0
- mosscode/tools/search.py +229 -0
- mosscode/tools/search_worker.py +243 -0
- mosscode/tools/shell.py +121 -0
- mosscode/tools/skill.py +118 -0
- mosscode/tools/team.py +447 -0
- mosscode/tools/windows_job.py +75 -0
- mosscode/tui.py +2325 -0
- mosscode/usage.py +68 -0
- mosscode/windows_inline.py +148 -0
- mosscode/worktree/__init__.py +29 -0
- mosscode/worktree/changes.py +59 -0
- mosscode/worktree/git.py +125 -0
- mosscode/worktree/manager.py +462 -0
- mosscode/worktree/meta.py +82 -0
- mosscode/worktree/names.py +52 -0
- mosscode/worktree/setup.py +119 -0
- mosscode-0.1.0.dist-info/METADATA +92 -0
- mosscode-0.1.0.dist-info/RECORD +91 -0
- mosscode-0.1.0.dist-info/WHEEL +5 -0
- mosscode-0.1.0.dist-info/entry_points.txt +3 -0
- mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
- mosscode-0.1.0.dist-info/top_level.txt +1 -0
mosscode/coordinator.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Coordinator mode, a role for the lead — it plans and delegates, members implement.
|
|
2
|
+
|
|
3
|
+
A role constraint, not a security boundary: execute_command can still write files, and the permission
|
|
4
|
+
rules apply as always.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
|
|
11
|
+
ENV = "MOSSCODE_COORDINATOR_MODE"
|
|
12
|
+
ON_VALUES = ("1", "true", "yes")
|
|
13
|
+
BADGE = "[协调]"
|
|
14
|
+
STATUS = "协调者模式(配置与环境变量均已开启)"
|
|
15
|
+
NO_ENV = f"协调者模式未开启:配置中 coordinator_mode 为 true,但环境变量 {ENV} 没有设为 1、true 或 yes。"
|
|
16
|
+
NO_CONFIG = f"协调者模式未开启:已设置环境变量 {ENV},但配置中 coordinator_mode 不是 true。"
|
|
17
|
+
HEADLESS = "协调者模式只用于交互界面,本次运行按普通模式。"
|
|
18
|
+
NO_AGENTS = "协调者模式需要工具和 Agent 团队,本次运行按普通模式。"
|
|
19
|
+
WITHHELD = "协调者模式下 Lead 不使用 {name}:把实施交给团队成员或子 Agent,或用 execute_command 运行只读检查。"
|
|
20
|
+
WRITE_TOOLS = frozenset({"write_file", "edit_file"})
|
|
21
|
+
|
|
22
|
+
SECTION = """## 协调者模式
|
|
23
|
+
|
|
24
|
+
你是协调者:负责调查、拆分、派发和验证,不亲自改代码。没有 write_file、edit_file、MCP 工具和 Skill 脚本工具;execute_command 只用于只读的查看和检查。按四个阶段工作:
|
|
25
|
+
1. 调查:读代码,或派只读的子 Agent 并行调查,弄清改动范围。子 Agent 只做调查和运行测试,不派它改文件。
|
|
26
|
+
2. 综合:把工作拆成任务,用 team_task_create 写清每个任务的范围、完成标准和依赖(blocked_by)。
|
|
27
|
+
3. 实施:所有文件改动(哪怕只有一行)都由 team_spawn 创建的成员在自己的工作树中完成,合并时由用户确认。派发后不要自己动手,有问题用 send_message 沟通。
|
|
28
|
+
4. 验证:让成员或子 Agent 运行测试;team_merge 合并后,你可以自己运行只读的检查(测试、git log、git diff),再向用户汇报。"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def env_on(env: Mapping[str, str]) -> bool:
|
|
32
|
+
return env.get(ENV, "").strip().lower() in ON_VALUES
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def decide(configured: bool, env: Mapping[str, str], *, interactive: bool) -> tuple[bool, str]:
|
|
36
|
+
"""On only with both switches, in the interactive UI; the notice says why not."""
|
|
37
|
+
environment = env_on(env)
|
|
38
|
+
if configured and not environment:
|
|
39
|
+
return False, NO_ENV
|
|
40
|
+
if environment and not configured:
|
|
41
|
+
return False, NO_CONFIG
|
|
42
|
+
if not configured:
|
|
43
|
+
return False, ""
|
|
44
|
+
if not interactive:
|
|
45
|
+
return False, HEADLESS
|
|
46
|
+
return True, ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def withheld(tool) -> bool:
|
|
50
|
+
"""Kept in the registry for sub-agents and members, but not offered to the lead."""
|
|
51
|
+
from .mcp_client import MCPTool
|
|
52
|
+
from .tools.skill import SkillTool
|
|
53
|
+
|
|
54
|
+
return tool.name in WRITE_TOOLS or isinstance(tool, (MCPTool, SkillTool))
|
mosscode/doctor.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""`moss doctor` reads the installation, the configuration and the tools it relies on.
|
|
2
|
+
|
|
3
|
+
It starts no session and sends no request. Credentials are reported as set or not, never shown.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
OK, WARN, ERROR = "✓", "!", "✗"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Item:
|
|
23
|
+
level: str
|
|
24
|
+
text: str
|
|
25
|
+
fix: str = ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _distribution():
|
|
29
|
+
from importlib.metadata import PackageNotFoundError, distribution
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
return distribution("mosscode")
|
|
33
|
+
except PackageNotFoundError:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def install_method(prefix: str | None = None, dist=None) -> str:
|
|
38
|
+
prefix = sys.prefix if prefix is None else prefix
|
|
39
|
+
parts = [part.lower() for part in Path(prefix).parts]
|
|
40
|
+
if "uv" in parts and "tools" in parts:
|
|
41
|
+
kind = "uv tool"
|
|
42
|
+
elif "pipx" in parts:
|
|
43
|
+
kind = "pipx"
|
|
44
|
+
elif sys.prefix != sys.base_prefix:
|
|
45
|
+
kind = f"虚拟环境 {prefix}"
|
|
46
|
+
else:
|
|
47
|
+
kind = f"Python 环境 {prefix}"
|
|
48
|
+
try:
|
|
49
|
+
direct = json.loads(dist.read_text("direct_url.json") or "{}") if dist is not None else {}
|
|
50
|
+
except (ValueError, OSError):
|
|
51
|
+
direct = {}
|
|
52
|
+
if (direct.get("dir_info") or {}).get("editable"):
|
|
53
|
+
source = direct.get("url", "").removeprefix("file:///").removeprefix("file://")
|
|
54
|
+
kind += f",可编辑安装(源码 {Path(source)})" if source else ",可编辑安装"
|
|
55
|
+
return kind
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def check_install() -> list[Item]:
|
|
59
|
+
from .launcher import version
|
|
60
|
+
|
|
61
|
+
dist = _distribution()
|
|
62
|
+
items = [
|
|
63
|
+
Item(OK, f"mosscode {version()}(命令 moss 与 mosscode 相同)"),
|
|
64
|
+
Item(OK, f"Python {sys.version.split()[0]}:{sys.executable}"),
|
|
65
|
+
]
|
|
66
|
+
if dist is None:
|
|
67
|
+
items.append(
|
|
68
|
+
Item(WARN, "mosscode 没有安装,正直接从源码运行", "在仓库中运行 uv tool install --editable . 安装为命令")
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
items.append(Item(OK, "安装方式:" + install_method(dist=dist)))
|
|
72
|
+
return items
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _all_on_path(name: str, path: str | None = None) -> list[Path]:
|
|
76
|
+
found, seen = [], set()
|
|
77
|
+
extensions = os.environ.get("PATHEXT", ".EXE").split(os.pathsep) if os.name == "nt" else [""]
|
|
78
|
+
for folder in (os.environ.get("PATH", "") if path is None else path).split(os.pathsep):
|
|
79
|
+
if not folder:
|
|
80
|
+
continue
|
|
81
|
+
for extension in extensions:
|
|
82
|
+
candidate = Path(folder) / (name + extension.lower())
|
|
83
|
+
key = os.path.normcase(str(candidate))
|
|
84
|
+
if key not in seen and candidate.is_file():
|
|
85
|
+
seen.add(key)
|
|
86
|
+
found.append(candidate)
|
|
87
|
+
return found
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def check_path(path: str | None = None, scripts: Path | None = None) -> list[Item]:
|
|
91
|
+
"""Which moss / mosscode a new terminal runs, and whether it belongs to this installation."""
|
|
92
|
+
scripts = Path(sys.executable).parent if scripts is None else scripts
|
|
93
|
+
tool = install_method().startswith(("uv tool", "pipx"))
|
|
94
|
+
items = []
|
|
95
|
+
for name in ("moss", "mosscode"):
|
|
96
|
+
found = _all_on_path(name, path)
|
|
97
|
+
if not found:
|
|
98
|
+
items.append(
|
|
99
|
+
Item(WARN, f"PATH 中找不到 {name}", "用 uv tool 安装后运行 uv tool update-shell,再打开新的终端")
|
|
100
|
+
)
|
|
101
|
+
continue
|
|
102
|
+
first = found[0]
|
|
103
|
+
same = os.path.normcase(str(first.parent)) == os.path.normcase(str(scripts)) or tool
|
|
104
|
+
text = f"{name} → {first}" + ("" if same else "(不是当前运行的这一个)")
|
|
105
|
+
others = ";".join(str(item) for item in found[1:])
|
|
106
|
+
if len(found) > 1:
|
|
107
|
+
items.append(Item(WARN, text + f";PATH 中还有 {others}", "排在前面的生效;卸载不用的那一个"))
|
|
108
|
+
else:
|
|
109
|
+
items.append(Item(OK if same else WARN, text, "" if same else "检查 PATH 的顺序,或卸载旧的安装"))
|
|
110
|
+
return items
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _credential(provider) -> tuple[str, str]:
|
|
114
|
+
if provider.api_key_env:
|
|
115
|
+
if os.environ.get(provider.api_key_env):
|
|
116
|
+
return OK, f"环境变量 {provider.api_key_env} 已设置"
|
|
117
|
+
return WARN, f"环境变量 {provider.api_key_env} 未设置"
|
|
118
|
+
if provider.api_key:
|
|
119
|
+
return OK, "已设置(配置文件中的 api_key)"
|
|
120
|
+
return WARN, "未配置"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def check_config(config: str | None = None, cwd: Path | None = None, home: Path | None = None) -> list[Item]:
|
|
124
|
+
from .config import ConfigError, find_config, load_settings
|
|
125
|
+
from .setup import OFFICIAL
|
|
126
|
+
|
|
127
|
+
cwd = Path.cwd() if cwd is None else cwd
|
|
128
|
+
items = []
|
|
129
|
+
try:
|
|
130
|
+
path = Path(config) if config is not None else find_config(cwd, home)
|
|
131
|
+
except ConfigError as exc:
|
|
132
|
+
items.append(Item(ERROR, str(exc).split("。")[0], "运行 moss setup 创建配置,或用 --config 指定"))
|
|
133
|
+
path = None
|
|
134
|
+
if path is not None:
|
|
135
|
+
try:
|
|
136
|
+
settings = load_settings(path)
|
|
137
|
+
except ConfigError as exc:
|
|
138
|
+
items.append(Item(ERROR, f"配置 {path}:{exc}", "对照 config.example.yaml 修改"))
|
|
139
|
+
else:
|
|
140
|
+
items.append(Item(OK, f"配置 {path}"))
|
|
141
|
+
for provider in settings.providers:
|
|
142
|
+
level, credential = _credential(provider)
|
|
143
|
+
host = (
|
|
144
|
+
urlsplit(provider.base_url).hostname
|
|
145
|
+
if provider.base_url
|
|
146
|
+
else f"{urlsplit(OFFICIAL[provider.protocol]).hostname}(官方地址)"
|
|
147
|
+
)
|
|
148
|
+
default = "(默认)" if provider.name == settings.default_provider else ""
|
|
149
|
+
items.append(
|
|
150
|
+
Item(
|
|
151
|
+
level,
|
|
152
|
+
f"Provider {provider.name}{default}:{provider.protocol} · {provider.model}"
|
|
153
|
+
f" · {host} · 凭据:{credential}",
|
|
154
|
+
""
|
|
155
|
+
if level == OK
|
|
156
|
+
else f"设置环境变量 {provider.api_key_env} 后打开新的终端"
|
|
157
|
+
if provider.api_key_env
|
|
158
|
+
else "在配置中填写 api_key_env",
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
legacy = cwd / "config.yaml"
|
|
162
|
+
if config is None and legacy.is_file():
|
|
163
|
+
items.append(
|
|
164
|
+
Item(
|
|
165
|
+
WARN,
|
|
166
|
+
f"{legacy} 不会被读取(只查找 .mosscode/config.yaml 和 ~/.mosscode/config.yaml)",
|
|
167
|
+
f"需要用它时加 --config {legacy}",
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
return items
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def check_tools(which=shutil.which, windows: bool | None = None) -> list[Item]:
|
|
174
|
+
windows = os.name == "nt" if windows is None else windows
|
|
175
|
+
items = []
|
|
176
|
+
rg = which("rg")
|
|
177
|
+
items.append(
|
|
178
|
+
Item(OK, f"ripgrep:{rg}")
|
|
179
|
+
if rg
|
|
180
|
+
else Item(
|
|
181
|
+
WARN,
|
|
182
|
+
"找不到 ripgrep(rg):搜索改用较慢的内置实现",
|
|
183
|
+
"安装 ripgrep,例如 winget install BurntSushi.ripgrep.MSVC",
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
git = which("git")
|
|
187
|
+
items.append(
|
|
188
|
+
Item(OK, f"git:{git}")
|
|
189
|
+
if git
|
|
190
|
+
else Item(WARN, "找不到 git:工作树、团队合并和差异不可用", "安装 Git for Windows")
|
|
191
|
+
)
|
|
192
|
+
if windows:
|
|
193
|
+
from .tools.shell import git_bash
|
|
194
|
+
|
|
195
|
+
bash = git_bash()
|
|
196
|
+
items.append(
|
|
197
|
+
Item(OK, f"Git Bash:{bash}")
|
|
198
|
+
if bash
|
|
199
|
+
else Item(
|
|
200
|
+
WARN,
|
|
201
|
+
"找不到 Git Bash:命令改用 PowerShell 执行",
|
|
202
|
+
"安装 Git for Windows,或在配置中设 shell: powershell",
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
wt = which("wt")
|
|
206
|
+
items.append(
|
|
207
|
+
Item(OK, f"Windows Terminal:{wt}")
|
|
208
|
+
if wt
|
|
209
|
+
else Item(WARN, "找不到 wt.exe:团队成员无法在分屏窗格中运行,改为进程内运行", "安装 Windows Terminal")
|
|
210
|
+
)
|
|
211
|
+
return items
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def check_home(home: Path | None = None) -> list[Item]:
|
|
215
|
+
from .paths import mosscode_home
|
|
216
|
+
|
|
217
|
+
folder = mosscode_home(home)
|
|
218
|
+
try:
|
|
219
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
with tempfile.NamedTemporaryFile(dir=folder, prefix=".doctor-", delete=True):
|
|
221
|
+
pass
|
|
222
|
+
except OSError as exc:
|
|
223
|
+
return [Item(ERROR, f"{folder} 不可写:{exc.strerror or exc}", "检查这个目录的权限")]
|
|
224
|
+
return [Item(OK, f"{folder} 可写")]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def check_project(cwd: Path | None = None, config: str | None = None, home: Path | None = None) -> list[Item]:
|
|
228
|
+
"""The project's own files under .mosscode/; nothing when it has none."""
|
|
229
|
+
cwd = Path.cwd() if cwd is None else cwd
|
|
230
|
+
from .paths import mosscode_home
|
|
231
|
+
|
|
232
|
+
folder = cwd / ".mosscode"
|
|
233
|
+
if not folder.is_dir() or os.path.normcase(os.path.abspath(folder)) == os.path.normcase(
|
|
234
|
+
os.path.abspath(mosscode_home(home))
|
|
235
|
+
): # In the home folder it is the user folder, not a project's.
|
|
236
|
+
return []
|
|
237
|
+
items = []
|
|
238
|
+
project_config = folder / "config.yaml"
|
|
239
|
+
if config is not None and project_config.is_file(): # Without --config it is the one checked above.
|
|
240
|
+
from .config import ConfigError, load_settings
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
load_settings(project_config)
|
|
244
|
+
except ConfigError as exc:
|
|
245
|
+
items.append(Item(ERROR, f"{project_config}:{exc}", "对照 config.example.yaml 修改"))
|
|
246
|
+
from .permissions import PermissionConfigError, read_policy
|
|
247
|
+
|
|
248
|
+
for path in (folder / "permissions.yaml", folder / "permissions.local.yaml"):
|
|
249
|
+
try:
|
|
250
|
+
read_policy(path)
|
|
251
|
+
except PermissionConfigError as exc:
|
|
252
|
+
items.append(Item(ERROR, str(exc), "修改后重新启动"))
|
|
253
|
+
from .mcp_config import MCPConfigError, load_mcp_settings
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
mcp = load_mcp_settings(cwd, user_path=None if home is None else home / ".mosscode" / "mcp.yaml")
|
|
257
|
+
except MCPConfigError as exc:
|
|
258
|
+
items.append(Item(ERROR, f"MCP 配置:{exc}", "修改 .mosscode/mcp.yaml"))
|
|
259
|
+
else:
|
|
260
|
+
items.extend(Item(WARN, notice) for notice in mcp.notices)
|
|
261
|
+
from .hooks.config import hook_paths, read_file
|
|
262
|
+
|
|
263
|
+
for source, path in hook_paths(cwd, home):
|
|
264
|
+
if source != "user":
|
|
265
|
+
items.extend(Item(WARN, issue) for issue in read_file(source, path)[1])
|
|
266
|
+
if not items:
|
|
267
|
+
items.append(Item(OK, f"项目配置 {folder} 可以解析"))
|
|
268
|
+
return items
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def run(config: str | None = None) -> list[Item]:
|
|
272
|
+
return [
|
|
273
|
+
*check_install(),
|
|
274
|
+
*check_path(),
|
|
275
|
+
*check_config(config),
|
|
276
|
+
*check_home(),
|
|
277
|
+
*check_tools(),
|
|
278
|
+
*check_project(config=config),
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def report(items: list[Item]) -> str:
|
|
283
|
+
lines = []
|
|
284
|
+
for item in items:
|
|
285
|
+
lines.append(f"{item.level} {item.text}")
|
|
286
|
+
if item.fix and item.level != OK:
|
|
287
|
+
lines.append(f" → {item.fix}")
|
|
288
|
+
return "\n".join(lines)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def main(argv: list[str] | None = None) -> int:
|
|
292
|
+
command = argparse.ArgumentParser(
|
|
293
|
+
prog="moss doctor", description="检查安装、配置和依赖工具;不启动会话,不发送请求。"
|
|
294
|
+
)
|
|
295
|
+
command.add_argument("--config", help="检查这个配置文件,而不是按默认顺序查找")
|
|
296
|
+
args = command.parse_args(argv)
|
|
297
|
+
items = run(args.config)
|
|
298
|
+
text = report(items)
|
|
299
|
+
try:
|
|
300
|
+
print(text)
|
|
301
|
+
except UnicodeEncodeError: # A console that cannot show ✓ and ✗.
|
|
302
|
+
print(text.encode(sys.stdout.encoding or "ascii", "replace").decode(sys.stdout.encoding or "ascii"))
|
|
303
|
+
return 1 if any(item.level == ERROR for item in items) else 0
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""User-defined hooks at the agent's lifecycle points."""
|
|
2
|
+
|
|
3
|
+
from .conditions import HookConfigError, parse_condition
|
|
4
|
+
from .config import Hook, HookConfig, HookFile, TrustStore, hook_paths, load_hooks
|
|
5
|
+
from .engine import HookEngine, HookEvent, HookOutcome, tool_event
|
|
6
|
+
from .events import EVENTS
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"EVENTS",
|
|
10
|
+
"Hook",
|
|
11
|
+
"HookConfig",
|
|
12
|
+
"HookConfigError",
|
|
13
|
+
"HookEngine",
|
|
14
|
+
"HookEvent",
|
|
15
|
+
"HookFile",
|
|
16
|
+
"HookOutcome",
|
|
17
|
+
"TrustStore",
|
|
18
|
+
"hook_paths",
|
|
19
|
+
"load_hooks",
|
|
20
|
+
"parse_condition",
|
|
21
|
+
"tool_event",
|
|
22
|
+
]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Run one hook action and report what happened; the engine decides what it means."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
from .config import TEMPLATE, Action
|
|
13
|
+
|
|
14
|
+
STREAM_HALF = 16 * 1024 # Head and tail of stdout and stderr: 32 KB each.
|
|
15
|
+
BODY_LIMIT = 64 * 1024
|
|
16
|
+
DETAIL_LIMIT = 500
|
|
17
|
+
FEEDBACK_LIMIT = 8000
|
|
18
|
+
REJECT_LIMIT = 2000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ActionResult:
|
|
23
|
+
outcome: str # ok, failed, rejected, timeout, skipped
|
|
24
|
+
detail: str = "" # Failure reason or the end of the output, for the log and the notice.
|
|
25
|
+
output: str = "" # Text for the model: a prompt message or command feedback.
|
|
26
|
+
exit_code: int | None = None
|
|
27
|
+
status: int | None = None
|
|
28
|
+
reason: str = "" # Dynamic rejection (exit code 2).
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def short(text: str, limit: int = DETAIL_LIMIT) -> str:
|
|
32
|
+
text = text.strip()
|
|
33
|
+
return text if len(text) <= limit else "…" + text[-limit:]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render(text: str, fields) -> str:
|
|
37
|
+
"""{{field}} becomes the field's value; only ever in text for the model or a JSON string."""
|
|
38
|
+
return TEMPLATE.sub(lambda match: str(fields.get(match[1], "")), text)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_json(value, fields):
|
|
42
|
+
if isinstance(value, str):
|
|
43
|
+
return render(value, fields)
|
|
44
|
+
if isinstance(value, dict):
|
|
45
|
+
return {key: render_json(item, fields) for key, item in value.items()}
|
|
46
|
+
if isinstance(value, list):
|
|
47
|
+
return [render_json(item, fields) for item in value]
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def public_url(url: str) -> str:
|
|
52
|
+
"""Never log query strings (they often carry tokens)."""
|
|
53
|
+
return urlsplit(url)._replace(query="", fragment="").geturl()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _executable(first: str) -> str | None:
|
|
57
|
+
if os.sep in first or "/" in first:
|
|
58
|
+
return first if Path(first).is_file() else None
|
|
59
|
+
return shutil.which(first)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def argv(command: tuple[str, ...], values: dict[str, str]) -> list[str]:
|
|
63
|
+
"""Each ${NAME} becomes one whole argument's text; nothing is re-split or interpreted."""
|
|
64
|
+
parts = []
|
|
65
|
+
for part in command:
|
|
66
|
+
for name, value in values.items():
|
|
67
|
+
part = part.replace("${" + name + "}", value)
|
|
68
|
+
parts.append(part)
|
|
69
|
+
return parts
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def run_command(
|
|
73
|
+
action: Action, *, root: Path, shell, env: dict[str, str], values: dict[str, str], stdin: bytes, rejecting: bool
|
|
74
|
+
) -> ActionResult:
|
|
75
|
+
from ..tools.base import ToolFailure
|
|
76
|
+
from ..tools.process import run_process
|
|
77
|
+
|
|
78
|
+
if isinstance(action.command, str):
|
|
79
|
+
if shell is None:
|
|
80
|
+
return ActionResult("failed", "没有可用的 shell,无法执行字符串命令;可改用 argv 数组。")
|
|
81
|
+
args = shell.args(action.command)
|
|
82
|
+
environment = {**(shell.env(action.command) or os.environ), **env}
|
|
83
|
+
else:
|
|
84
|
+
args = argv(action.command, values)
|
|
85
|
+
found = _executable(args[0])
|
|
86
|
+
if found is None:
|
|
87
|
+
return ActionResult("failed", f"找不到命令:{args[0]}")
|
|
88
|
+
args[0] = found
|
|
89
|
+
environment = {**os.environ, **env}
|
|
90
|
+
try:
|
|
91
|
+
details = await asyncio.wait_for(
|
|
92
|
+
run_process(args, root, check=False, limit=STREAM_HALF, tail=STREAM_HALF, env=environment, input=stdin),
|
|
93
|
+
timeout=action.timeout,
|
|
94
|
+
)
|
|
95
|
+
except TimeoutError:
|
|
96
|
+
return ActionResult("timeout", f"超过 {action.timeout:g} 秒未结束,已终止。")
|
|
97
|
+
except ToolFailure as exc:
|
|
98
|
+
return ActionResult("failed", str(exc))
|
|
99
|
+
except OSError as exc:
|
|
100
|
+
return ActionResult("failed", f"无法启动命令:{exc.strerror or type(exc).__name__}")
|
|
101
|
+
code, stdout, stderr = details["exit_code"], details["stdout"], details["stderr"]
|
|
102
|
+
if code == 0:
|
|
103
|
+
output = stdout.strip()[:FEEDBACK_LIMIT] if action.feedback else ""
|
|
104
|
+
return ActionResult("ok", short(stdout), output, exit_code=0)
|
|
105
|
+
if code == 2 and rejecting:
|
|
106
|
+
reason = (stderr.strip() or stdout.strip() or "未说明原因")[:REJECT_LIMIT]
|
|
107
|
+
return ActionResult("rejected", short(reason), exit_code=2, reason=reason)
|
|
108
|
+
tail = short(stderr) or short(stdout)
|
|
109
|
+
return ActionResult("failed", f"退出码为 {code}" + (f":{tail}" if tail else ""), exit_code=code)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def run_http(action: Action, *, fields, context: dict) -> ActionResult:
|
|
113
|
+
import httpx
|
|
114
|
+
|
|
115
|
+
body = None
|
|
116
|
+
if action.has_json:
|
|
117
|
+
body = render_json(action.json_body, fields)
|
|
118
|
+
elif action.method in {"POST", "PUT", "PATCH"}:
|
|
119
|
+
body = context
|
|
120
|
+
options = {"json": body} if body is not None else {}
|
|
121
|
+
|
|
122
|
+
async def send():
|
|
123
|
+
async with httpx.AsyncClient(follow_redirects=False, timeout=action.timeout) as client:
|
|
124
|
+
async with client.stream(action.method, action.url, headers=dict(action.headers), **options) as response:
|
|
125
|
+
data = bytearray()
|
|
126
|
+
async for chunk in response.aiter_bytes():
|
|
127
|
+
data.extend(chunk)
|
|
128
|
+
if len(data) >= BODY_LIMIT:
|
|
129
|
+
break
|
|
130
|
+
return response.status_code, bytes(data[:BODY_LIMIT]).decode("utf-8", errors="replace")
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
status, text = await asyncio.wait_for(send(), timeout=action.timeout)
|
|
134
|
+
except (TimeoutError, httpx.TimeoutException):
|
|
135
|
+
return ActionResult("timeout", f"{public_url(action.url)} 超过 {action.timeout:g} 秒未响应。")
|
|
136
|
+
except httpx.HTTPError as exc:
|
|
137
|
+
return ActionResult("failed", f"{public_url(action.url)} 请求失败({type(exc).__name__})。")
|
|
138
|
+
if 200 <= status < 300:
|
|
139
|
+
return ActionResult("ok", text[:DETAIL_LIMIT], status=status)
|
|
140
|
+
return ActionResult(
|
|
141
|
+
"failed",
|
|
142
|
+
f"{public_url(action.url)} 返回 HTTP {status}" + (f":{text[:200]}" if text.strip() else ""),
|
|
143
|
+
status=status,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def stdin_json(context: dict) -> bytes:
|
|
148
|
+
return json.dumps(context, ensure_ascii=False, default=str).encode("utf-8")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
PYTHON = sys.executable
|