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.
Files changed (91) hide show
  1. mosscode/__init__.py +10 -0
  2. mosscode/__main__.py +3 -0
  3. mosscode/agent.py +486 -0
  4. mosscode/agents/__init__.py +249 -0
  5. mosscode/agents/builtin/explore.md +16 -0
  6. mosscode/agents/builtin/general.md +12 -0
  7. mosscode/agents/builtin/plan.md +16 -0
  8. mosscode/agents/runtime.py +685 -0
  9. mosscode/agents/tasks.py +193 -0
  10. mosscode/appearance.py +184 -0
  11. mosscode/builtin_commands.py +1363 -0
  12. mosscode/cli.py +547 -0
  13. mosscode/commands.py +254 -0
  14. mosscode/compaction.py +259 -0
  15. mosscode/config.py +337 -0
  16. mosscode/context.py +102 -0
  17. mosscode/conversation.py +1046 -0
  18. mosscode/coordinator.py +54 -0
  19. mosscode/doctor.py +303 -0
  20. mosscode/hooks/__init__.py +22 -0
  21. mosscode/hooks/actions.py +151 -0
  22. mosscode/hooks/conditions.py +170 -0
  23. mosscode/hooks/config.py +404 -0
  24. mosscode/hooks/engine.py +401 -0
  25. mosscode/hooks/events.py +48 -0
  26. mosscode/launcher.py +177 -0
  27. mosscode/mcp_client.py +460 -0
  28. mosscode/mcp_config.py +216 -0
  29. mosscode/member_ui.py +289 -0
  30. mosscode/memory.py +291 -0
  31. mosscode/memory_extract.py +245 -0
  32. mosscode/messages.py +216 -0
  33. mosscode/paths.py +31 -0
  34. mosscode/permissions.py +733 -0
  35. mosscode/prompts.py +483 -0
  36. mosscode/providers.py +587 -0
  37. mosscode/scrollback.py +106 -0
  38. mosscode/security.py +282 -0
  39. mosscode/serialization.py +121 -0
  40. mosscode/session.py +563 -0
  41. mosscode/setup.py +248 -0
  42. mosscode/skills/__init__.py +455 -0
  43. mosscode/skills/builtin/commit/SKILL.md +15 -0
  44. mosscode/skills/builtin/review/SKILL.md +15 -0
  45. mosscode/skills/builtin/test/SKILL.md +13 -0
  46. mosscode/skills/builtin/test/scripts/detect.py +96 -0
  47. mosscode/skills/builtin/test/tool.json +9 -0
  48. mosscode/teams/__init__.py +216 -0
  49. mosscode/teams/backends.py +259 -0
  50. mosscode/teams/board.py +230 -0
  51. mosscode/teams/lock.py +173 -0
  52. mosscode/teams/mailbox.py +172 -0
  53. mosscode/teams/member.py +427 -0
  54. mosscode/teams/merge.py +207 -0
  55. mosscode/teams/pane.py +20 -0
  56. mosscode/teams/pane_app.py +167 -0
  57. mosscode/teams/process.py +371 -0
  58. mosscode/teams/runtime.py +1355 -0
  59. mosscode/teams/view.py +182 -0
  60. mosscode/tool_budget.py +86 -0
  61. mosscode/tools/__init__.py +38 -0
  62. mosscode/tools/agent.py +127 -0
  63. mosscode/tools/base.py +384 -0
  64. mosscode/tools/diffing.py +16 -0
  65. mosscode/tools/execution.py +99 -0
  66. mosscode/tools/files.py +287 -0
  67. mosscode/tools/memory.py +65 -0
  68. mosscode/tools/plan.py +30 -0
  69. mosscode/tools/process.py +216 -0
  70. mosscode/tools/search.py +229 -0
  71. mosscode/tools/search_worker.py +243 -0
  72. mosscode/tools/shell.py +121 -0
  73. mosscode/tools/skill.py +118 -0
  74. mosscode/tools/team.py +447 -0
  75. mosscode/tools/windows_job.py +75 -0
  76. mosscode/tui.py +2325 -0
  77. mosscode/usage.py +68 -0
  78. mosscode/windows_inline.py +148 -0
  79. mosscode/worktree/__init__.py +29 -0
  80. mosscode/worktree/changes.py +59 -0
  81. mosscode/worktree/git.py +125 -0
  82. mosscode/worktree/manager.py +462 -0
  83. mosscode/worktree/meta.py +82 -0
  84. mosscode/worktree/names.py +52 -0
  85. mosscode/worktree/setup.py +119 -0
  86. mosscode-0.1.0.dist-info/METADATA +92 -0
  87. mosscode-0.1.0.dist-info/RECORD +91 -0
  88. mosscode-0.1.0.dist-info/WHEEL +5 -0
  89. mosscode-0.1.0.dist-info/entry_points.txt +3 -0
  90. mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
  91. mosscode-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,170 @@
1
+ """`field op value` conditions, joined by && or ||, with the permission rules' matching."""
2
+
3
+ import fnmatch
4
+ import os
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from ..permissions import ALIASES, normalized, path_matches
10
+ from ..security import command_segments, starts_with
11
+ from .events import ALL_FIELDS, EVENTS, fields_of
12
+
13
+ OPERATORS = ("==", "!=", "~=", "=~")
14
+ ARGS_NAME = re.compile(r"[A-Za-z0-9_-]+")
15
+ CONDITION = re.compile(r"\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*(==|!=|~=|=~)\s*(.*?)\s*", re.DOTALL)
16
+
17
+
18
+ class HookConfigError(ValueError):
19
+ pass
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Condition:
24
+ field: str # "tool", "path", "args.command" ...
25
+ op: str # "==" "!=" "~=" "=~"
26
+ value: str # tool aliases resolved, paths normalized
27
+ regex: re.Pattern | None = None
28
+
29
+ def matches(self, fields) -> bool:
30
+ actual = str(fields.get(self.field, ""))
31
+ if self.op == "=~":
32
+ return bool(self.regex.search(actual))
33
+ if self.op in {"==", "!="}:
34
+ return _equal(self.field, actual, self.value) == (self.op == "==")
35
+ return _glob(self.field, actual, self.value)
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ConditionGroup:
40
+ items: tuple[Condition, ...]
41
+ any: bool = False # True: ||; False: &&
42
+ text: str = "" # As written, for /hooks.
43
+
44
+ def matches(self, fields) -> bool:
45
+ results = (item.matches(fields) for item in self.items)
46
+ return any(results) if self.any else all(results)
47
+
48
+
49
+ def _equal(field: str, actual: str, expected: str) -> bool:
50
+ if field == "path":
51
+ actual = normalized(actual) if actual else ""
52
+ if os.name == "nt":
53
+ return actual.casefold() == expected.casefold()
54
+ return actual == expected
55
+
56
+
57
+ def _glob(field: str, actual: str, pattern: str) -> bool:
58
+ if field == "path":
59
+ # Relative patterns cover the project only; absolute ones cover outside targets (as Rule.matches).
60
+ if not actual or Path(pattern).is_absolute() != Path(actual).is_absolute():
61
+ return False
62
+ return path_matches(actual, pattern)
63
+ if field == "command":
64
+ if not actual:
65
+ return False
66
+ candidates = [actual, *command_segments(actual)]
67
+ if pattern.endswith(":*"):
68
+ return any(starts_with(candidate, pattern[:-2]) for candidate in candidates)
69
+ return any(fnmatch.fnmatchcase(candidate, pattern) for candidate in candidates)
70
+ return fnmatch.fnmatchcase(actual, pattern)
71
+
72
+
73
+ def _split(expression: str) -> tuple[list[str], set[str]]:
74
+ """Split on && and || outside double quotes."""
75
+ parts, joins, current, quoted, index = [], set(), [], False, 0
76
+ while index < len(expression):
77
+ char = expression[index]
78
+ if quoted:
79
+ current.append(char)
80
+ if char == "\\" and index + 1 < len(expression):
81
+ current.append(expression[index + 1])
82
+ index += 2
83
+ continue
84
+ if char == '"':
85
+ quoted = False
86
+ elif char == '"':
87
+ quoted = True
88
+ current.append(char)
89
+ elif expression.startswith(("&&", "||"), index):
90
+ parts.append("".join(current))
91
+ joins.add(expression[index : index + 2])
92
+ current = []
93
+ index += 2
94
+ continue
95
+ else:
96
+ current.append(char)
97
+ index += 1
98
+ if quoted:
99
+ raise HookConfigError("条件中的双引号没有闭合。")
100
+ parts.append("".join(current))
101
+ return parts, joins
102
+
103
+
104
+ def _unquote(value: str) -> str:
105
+ if not value.startswith('"'):
106
+ return value
107
+ if len(value) < 2 or not value.endswith('"'):
108
+ raise HookConfigError(f"条件值的引号不完整:{value}")
109
+ inner, result, index = value[1:-1], [], 0
110
+ while index < len(inner):
111
+ char = inner[index]
112
+ if char == "\\" and index + 1 < len(inner) and inner[index + 1] in '"\\':
113
+ result.append(inner[index + 1])
114
+ index += 2
115
+ continue
116
+ if char == '"':
117
+ raise HookConfigError(f'条件值中的引号需写成 \\":{value}')
118
+ result.append(char)
119
+ index += 1
120
+ return "".join(result)
121
+
122
+
123
+ def check_field(name: str, event: str) -> None:
124
+ """A field this event has; args.<name> on tool events only, one level."""
125
+ if name.startswith("args."):
126
+ if not EVENTS[event].tool:
127
+ raise HookConfigError(f"事件 {event} 没有条件字段 {name}(args.* 只用于工具事件)。")
128
+ if not ARGS_NAME.fullmatch(name[len("args.") :]):
129
+ raise HookConfigError(f"args 字段只支持顶层参数名(如 args.command):{name}")
130
+ return
131
+ if name not in ALL_FIELDS:
132
+ raise HookConfigError(f"未知条件字段:{name}")
133
+ if name not in fields_of(event):
134
+ raise HookConfigError(f"事件 {event} 没有条件字段 {name}。")
135
+
136
+
137
+ def _condition(text: str, event: str) -> Condition:
138
+ if not text.strip():
139
+ raise HookConfigError("条件中有空的子条件(检查 && 或 || 两侧)。")
140
+ match = CONDITION.fullmatch(text)
141
+ if not match:
142
+ raise HookConfigError(f"子条件需为“字段 运算符 值”,运算符为 {' '.join(OPERATORS)}:{text.strip()}")
143
+ name, op, raw = match[1], match[2], match[3]
144
+ check_field(name, event)
145
+ if not raw:
146
+ raise HookConfigError(f'子条件缺少值(空值请写 ""):{text.strip()}')
147
+ value = _unquote(raw)
148
+ if op == "=~":
149
+ if len(value) < 2 or not value.startswith("/") or not value.endswith("/"):
150
+ raise HookConfigError(f"正则需写成 /…/:{raw}")
151
+ try:
152
+ return Condition(name, op, value, re.compile(value[1:-1]))
153
+ except re.error as exc:
154
+ raise HookConfigError(f"正则不合法({exc}):{raw}") from None
155
+ if name == "tool" and (op != "~=" or value in ALIASES):
156
+ value = ALIASES.get(value, value)
157
+ elif name == "path" and op != "~=":
158
+ value = normalized(value)
159
+ elif name == "path":
160
+ value = value.replace("\\", "/")
161
+ return Condition(name, op, value)
162
+
163
+
164
+ def parse_condition(expression, event: str) -> ConditionGroup:
165
+ if not isinstance(expression, str) or not expression.strip():
166
+ raise HookConfigError("if 需为非空的条件字符串。")
167
+ parts, joins = _split(expression)
168
+ if len(joins) > 1:
169
+ raise HookConfigError("同一条件中不能混用 && 和 ||;需要时拆成多条 Hook。")
170
+ return ConditionGroup(tuple(_condition(part, event) for part in parts), joins == {"||"}, expression.strip())
@@ -0,0 +1,404 @@
1
+ """Read the three hook files, validate each hook, trust records."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import tempfile
8
+ from dataclasses import dataclass, field, replace
9
+ from pathlib import Path
10
+ from typing import Any, Literal
11
+ from urllib.parse import urlsplit
12
+
13
+ import yaml
14
+
15
+ from ..mcp_config import MCPConfigError, substitute_env
16
+ from ..paths import mosscode_home, project_dir
17
+ from .conditions import ConditionGroup, HookConfigError, check_field, parse_condition
18
+ from .events import EVENTS
19
+
20
+ FILE_LIMIT = 256 * 1024
21
+ TEXT_LIMIT = 8000
22
+ REJECT_LIMIT = 2000
23
+ HOOK_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}")
24
+ HOOK_KEYS = {"id", "event", "if", "action", "reject", "on_error", "once", "async"}
25
+ ACTION_KEYS = {
26
+ "command": {"type", "command", "timeout", "feedback"},
27
+ "prompt": {"type", "message"},
28
+ "http": {"type", "url", "method", "headers", "json", "timeout"},
29
+ "agent": {"type", "prompt", "agent_type", "timeout"},
30
+ }
31
+ METHODS = ("POST", "GET", "PUT", "PATCH", "DELETE")
32
+ ARGV_PLACEHOLDERS = ("FILE_PATH", "PROJECT_ROOT", "PYTHON", "TOOL_NAME", "EVENT")
33
+ ARGV_PLACEHOLDER = re.compile(r"\$\{([^}]*)\}")
34
+ AGENT_TYPE = re.compile(r"[a-z][a-z0-9-]{0,63}")
35
+ AGENT_TIMEOUT = 300 # A sub-agent needs longer than a command.
36
+ TEMPLATE = re.compile(r"\{\{\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*\}\}")
37
+ REJECT_TYPE = "reject 需为拒绝原因;动态拒绝请让命令以退出码 2 结束。"
38
+ SOURCES = ("user", "project", "local")
39
+ SOURCE_LABELS = {"user": "用户级", "project": "项目级", "local": "本地"}
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Action:
44
+ type: Literal["command", "prompt", "http", "agent"]
45
+ command: str | tuple[str, ...] | None = None
46
+ message: str = ""
47
+ url: str = ""
48
+ method: str = "POST"
49
+ headers: tuple[tuple[str, str], ...] = ()
50
+ json_body: Any = None # The json field; None also when absent (has_json tells them apart).
51
+ has_json: bool = False
52
+ prompt: str = "" # agent
53
+ agent_type: str = "general" # agent: the definition to run.
54
+ timeout: float = 30.0
55
+ feedback: bool = False
56
+
57
+ def summary(self) -> str:
58
+ if self.type == "command":
59
+ return self.command if isinstance(self.command, str) else " ".join(self.command)
60
+ if self.type == "http":
61
+ return f"{self.method} {urlsplit(self.url)._replace(query='', fragment='').geturl()}"
62
+ if self.type == "prompt":
63
+ return "注入:" + self.message.replace("\n", " ")[:60]
64
+ return f"子 Agent {self.agent_type}:" + self.prompt.replace("\n", " ")[:60]
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Hook:
69
+ id: str
70
+ event: str
71
+ source: Literal["user", "project", "local"]
72
+ path: Path
73
+ condition: ConditionGroup | None
74
+ action: Action | None # None only for a static rejection.
75
+ reject: str | None = None
76
+ on_error: Literal["ignore", "block"] = "ignore"
77
+ once: bool = False
78
+ background: bool = False # async, or an event that always runs in the background
79
+ raw: dict = field(default_factory=dict, compare=False) # For /hooks <id>.
80
+
81
+ def summary(self) -> str:
82
+ if self.action is None:
83
+ return "拒绝:" + self.reject.replace("\n", " ")[:60]
84
+ return self.action.summary()
85
+
86
+ def flags(self) -> tuple[str, ...]:
87
+ return tuple(
88
+ name
89
+ for name, on in (
90
+ ("reject", self.reject is not None),
91
+ ("once", self.once),
92
+ ("async", self.background),
93
+ ("on_error: block", self.on_error == "block"),
94
+ )
95
+ if on
96
+ )
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class HookFile:
101
+ source: str
102
+ path: Path
103
+ digest: str | None # None when the file does not exist or could not be read.
104
+ hooks: tuple[Hook, ...] = ()
105
+ trusted: bool = True
106
+ error: str | None = None
107
+
108
+ @property
109
+ def needs_trust(self) -> bool:
110
+ return not self.trusted and bool(self.hooks)
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class HookConfig:
115
+ files: tuple[HookFile, ...] = ()
116
+ problems: tuple[str, ...] = ()
117
+
118
+ @property
119
+ def hooks(self) -> tuple[Hook, ...]:
120
+ """Enabled hooks, in order: user, project, local; each file in written order."""
121
+ return tuple(hook for file in self.files if file.trusted for hook in file.hooks)
122
+
123
+ @property
124
+ def untrusted(self) -> tuple[HookFile, ...]:
125
+ return tuple(file for file in self.files if file.needs_trust)
126
+
127
+
128
+ def hook_paths(root: Path, home: Path | None = None) -> tuple[tuple[str, Path], ...]:
129
+ root = Path(root).resolve()
130
+ return (
131
+ ("user", mosscode_home(home) / "hooks.yaml"),
132
+ ("project", root / ".mosscode" / "hooks.yaml"),
133
+ ("local", root / ".mosscode" / "hooks.local.yaml"),
134
+ )
135
+
136
+
137
+ class TrustStore:
138
+ """SHA-256 of each project hook file's content when the user trusted it."""
139
+
140
+ def __init__(self, root: Path, home: Path | None = None):
141
+ self.path = project_dir(root, home) / "hooks-trust.json"
142
+
143
+ def _read(self) -> dict[str, str]:
144
+ try:
145
+ raw = json.loads(self.path.read_text(encoding="utf-8"))
146
+ files = raw.get("files", {}) if isinstance(raw, dict) else {}
147
+ return {k: v for k, v in files.items() if isinstance(k, str) and isinstance(v, str)}
148
+ except (OSError, ValueError):
149
+ return {}
150
+
151
+ def trusted(self, name: str, digest: str) -> bool:
152
+ return self._read().get(name) == digest
153
+
154
+ def trust(self, name: str, digest: str) -> None:
155
+ files = {**self._read(), name: digest}
156
+ self.path.parent.mkdir(parents=True, exist_ok=True)
157
+ descriptor, temporary = tempfile.mkstemp(prefix=".hooks-trust-", dir=self.path.parent)
158
+ try:
159
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
160
+ json.dump({"version": 1, "files": files}, stream, ensure_ascii=False, indent=2)
161
+ os.replace(temporary, self.path)
162
+ finally:
163
+ if os.path.exists(temporary):
164
+ os.unlink(temporary)
165
+
166
+
167
+ def _templates(text: str, event: str) -> None:
168
+ for match in TEMPLATE.finditer(text):
169
+ check_field(match[1], event)
170
+
171
+
172
+ def _json_templates(value, event: str) -> None:
173
+ if isinstance(value, str):
174
+ _templates(value, event)
175
+ elif isinstance(value, dict):
176
+ for item in value.values():
177
+ _json_templates(item, event)
178
+ elif isinstance(value, list):
179
+ for item in value:
180
+ _json_templates(item, event)
181
+
182
+
183
+ def _timeout(raw: dict, default: float = 30) -> float:
184
+ value = raw.get("timeout", default)
185
+ if type(value) not in {int, float} or not 1 <= value <= 600:
186
+ raise HookConfigError("timeout 需为 1–600 之间的秒数。")
187
+ return float(value)
188
+
189
+
190
+ def _text(value, name: str, limit: int) -> str:
191
+ if not isinstance(value, str) or not value.strip():
192
+ raise HookConfigError(f"{name} 需为非空字符串。")
193
+ if len(value) > limit:
194
+ raise HookConfigError(f"{name} 最长 {limit} 个字符。")
195
+ return value
196
+
197
+
198
+ def _action(raw, event: str) -> Action:
199
+ if not isinstance(raw, dict):
200
+ raise HookConfigError("action 需为对象,包含 type。")
201
+ kind = raw.get("type")
202
+ if kind not in ACTION_KEYS:
203
+ raise HookConfigError("action.type 需为 command、prompt、http、agent 之一。")
204
+ unknown = sorted(map(str, set(raw) - ACTION_KEYS[kind]))
205
+ if unknown:
206
+ raise HookConfigError(f"{kind} 动作不认识的字段:{', '.join(unknown)}")
207
+ if kind not in EVENTS[event].actions:
208
+ raise HookConfigError(f"事件 {event} 不能使用 {kind} 动作。")
209
+ if kind == "prompt":
210
+ message = _text(raw.get("message"), "message", TEXT_LIMIT)
211
+ _templates(message, event)
212
+ return Action("prompt", message=message)
213
+ if kind == "agent":
214
+ agent_type = raw.get("agent_type", "general")
215
+ if not isinstance(agent_type, str) or not AGENT_TYPE.fullmatch(agent_type):
216
+ raise HookConfigError("agent_type 需为子 Agent 定义的名称(小写字母开头,只含小写字母、数字和 -)。")
217
+ return Action(
218
+ "agent",
219
+ prompt=_text(raw.get("prompt"), "prompt", TEXT_LIMIT),
220
+ agent_type=agent_type,
221
+ timeout=_timeout(raw, AGENT_TIMEOUT),
222
+ )
223
+ if kind == "command":
224
+ command = raw.get("command")
225
+ if isinstance(command, list):
226
+ # YAML reads bare `-3` or `8080` as numbers; argv wants their text.
227
+ command = [
228
+ str(part) if isinstance(part, (int, float)) and not isinstance(part, bool) else part for part in command
229
+ ]
230
+ if not command or any(not isinstance(part, str) or not part for part in command):
231
+ raise HookConfigError("command 数组需由非空字符串组成。")
232
+ for part in command:
233
+ for match in ARGV_PLACEHOLDER.finditer(part):
234
+ if match[1] not in ARGV_PLACEHOLDERS:
235
+ raise HookConfigError(
236
+ f"command 数组只支持 {', '.join('${' + p + '}' for p in ARGV_PLACEHOLDERS)}:{match[0]}"
237
+ )
238
+ command = tuple(command)
239
+ elif not isinstance(command, str) or not command.strip():
240
+ raise HookConfigError("command 需为字符串或非空字符串数组。")
241
+ feedback = raw.get("feedback", False)
242
+ if type(feedback) is not bool:
243
+ raise HookConfigError("feedback 需为 true 或 false。")
244
+ if feedback and not EVENTS[event].feedback:
245
+ raise HookConfigError("feedback 只能用于 session_start、turn_start、pre_tool_use、post_tool_use。")
246
+ return Action("command", command=command, timeout=_timeout(raw), feedback=feedback)
247
+ url = raw.get("url")
248
+ if not isinstance(url, str) or not url:
249
+ raise HookConfigError("http 动作需要 url。")
250
+ try:
251
+ url = substitute_env(url)
252
+ headers = raw.get("headers", {})
253
+ if not isinstance(headers, dict) or any(
254
+ not isinstance(k, str) or not isinstance(v, str) for k, v in headers.items()
255
+ ):
256
+ raise HookConfigError("headers 需为字符串键值对象。")
257
+ headers = tuple((k, substitute_env(v)) for k, v in headers.items())
258
+ except MCPConfigError as exc:
259
+ raise HookConfigError(str(exc)) from None
260
+ if any(not (k + v).isascii() for k, v in headers):
261
+ raise HookConfigError("headers 的名称和值只能包含 ASCII 字符。")
262
+ if TEMPLATE.search(url):
263
+ raise HookConfigError("url 中不支持 {{字段}};请放在 json 请求体中。")
264
+ parts = urlsplit(url)
265
+ if parts.scheme not in {"http", "https"} or not parts.netloc:
266
+ raise HookConfigError("url 需为 http 或 https 地址。")
267
+ method = raw.get("method", "POST")
268
+ if not isinstance(method, str) or method.upper() not in METHODS:
269
+ raise HookConfigError("method 需为 " + "、".join(METHODS) + " 之一。")
270
+ has_json = "json" in raw
271
+ if has_json:
272
+ _json_templates(raw["json"], event)
273
+ try:
274
+ json.dumps(raw["json"], allow_nan=False)
275
+ except (TypeError, ValueError):
276
+ raise HookConfigError("json 需为可序列化为 JSON 的值。") from None
277
+ return Action(
278
+ "http",
279
+ url=url,
280
+ method=method.upper(),
281
+ headers=headers,
282
+ json_body=raw.get("json"),
283
+ has_json=has_json,
284
+ timeout=_timeout(raw),
285
+ )
286
+
287
+
288
+ def parse_hook(raw, source: str, path: Path) -> Hook:
289
+ if not isinstance(raw, dict):
290
+ raise HookConfigError("Hook 需为对象。")
291
+ unknown = sorted(map(str, set(raw) - HOOK_KEYS))
292
+ if unknown:
293
+ raise HookConfigError(f"不认识的字段:{', '.join(unknown)}")
294
+ hook_id = raw.get("id")
295
+ if not isinstance(hook_id, str) or not HOOK_ID.fullmatch(hook_id):
296
+ raise HookConfigError("id 必填,由字母、数字、_ . - 组成,以字母或数字开头,最长 64 个字符。")
297
+ event = raw.get("event")
298
+ if event not in EVENTS:
299
+ raise HookConfigError("event 需为 " + "、".join(EVENTS) + " 之一。")
300
+ spec = EVENTS[event]
301
+ condition = parse_condition(raw["if"], event) if "if" in raw else None
302
+ reject = None
303
+ if "reject" in raw:
304
+ if event != "pre_tool_use":
305
+ raise HookConfigError("reject 只能用于 pre_tool_use。")
306
+ if not isinstance(raw["reject"], str) or not raw["reject"].strip():
307
+ raise HookConfigError(REJECT_TYPE)
308
+ reject = _text(raw["reject"], "reject", REJECT_LIMIT).strip()
309
+ on_error = raw.get("on_error", "ignore")
310
+ if "on_error" in raw and event != "pre_tool_use":
311
+ raise HookConfigError("on_error 只能用于 pre_tool_use。")
312
+ if on_error not in {"ignore", "block"}:
313
+ raise HookConfigError("on_error 需为 ignore 或 block。")
314
+ for name in ("once", "async"):
315
+ if type(raw.get(name, False)) is not bool:
316
+ raise HookConfigError(f"{name} 需为 true 或 false。")
317
+ if "action" in raw:
318
+ action = _action(raw["action"], event)
319
+ elif reject is not None:
320
+ action = None # A static rejection needs no action.
321
+ else:
322
+ raise HookConfigError("缺少 action(只有写了 reject 的 Hook 可以省略)。")
323
+ if reject is not None and action is not None and action.type == "prompt":
324
+ raise HookConfigError("reject 不能与 prompt 动作同用:被拒绝的调用不再注入内容。")
325
+ asynchronous = raw.get("async", False)
326
+ if asynchronous:
327
+ if spec.sync_only:
328
+ raise HookConfigError("pre_tool_use 只能同步执行,不能 async。")
329
+ if action is not None and action.type == "prompt":
330
+ raise HookConfigError("prompt 动作不能 async:后台结果来不及注入。")
331
+ if action is not None and action.feedback:
332
+ raise HookConfigError("feedback 不能与 async 同用。")
333
+ if spec.background and "async" in raw and not asynchronous and action is not None and action.type != "prompt":
334
+ raise HookConfigError(f"{event} 上的 {action.type} 动作总在后台执行,不能写 async: false。")
335
+ agent = action is not None and action.type == "agent"
336
+ if agent and ("async" in raw and not asynchronous or reject is not None or raw.get("on_error") == "block"):
337
+ # The sub-agent always runs in the background; it can neither reject nor block a call.
338
+ raise HookConfigError("agent 动作总在后台执行,不能写 async: false、reject 或 on_error: block。")
339
+ background = asynchronous or agent or (spec.background and action is not None and action.type != "prompt")
340
+ return Hook(
341
+ hook_id, event, source, path, condition, action, reject, on_error, raw.get("once", False), background, raw=raw
342
+ )
343
+
344
+
345
+ def read_file(source: str, path: Path) -> tuple[HookFile, list[str]]:
346
+ """A file-level error skips the file; a hook-level error skips that hook."""
347
+ try:
348
+ if not path.is_file():
349
+ return HookFile(source, path, None), []
350
+ data = path.read_bytes()
351
+ except OSError:
352
+ return HookFile(source, path, None, error="无法读取"), [f"Hook 文件 {path}:无法读取,已跳过。"]
353
+ digest = hashlib.sha256(data).hexdigest()
354
+ try:
355
+ if len(data) > FILE_LIMIT:
356
+ raise HookConfigError("超过 256 KB")
357
+ raw = yaml.safe_load(data.decode("utf-8-sig"))
358
+ if raw is None:
359
+ raw = {}
360
+ if not isinstance(raw, dict):
361
+ raise HookConfigError("顶层需为对象")
362
+ if set(raw) - {"hooks"}:
363
+ raise HookConfigError("顶层只支持 hooks 字段")
364
+ entries = raw.get("hooks") or []
365
+ if not isinstance(entries, list):
366
+ raise HookConfigError("hooks 需为列表")
367
+ except (UnicodeError, yaml.YAMLError, HookConfigError) as exc:
368
+ # Never echo file contents: they may contain tokens.
369
+ detail = str(exc) if isinstance(exc, HookConfigError) else "不是有效的 UTF-8 YAML"
370
+ return HookFile(source, path, digest, error=detail), [f"Hook 文件 {path}:{detail},已跳过。"]
371
+ hooks, problems = [], []
372
+ for index, entry in enumerate(entries, 1):
373
+ try:
374
+ hooks.append(parse_hook(entry, source, path))
375
+ except HookConfigError as exc:
376
+ label = (
377
+ entry.get("id") if isinstance(entry, dict) and isinstance(entry.get("id"), str) else f"第 {index} 项"
378
+ )
379
+ problems.append(f"{path} · {label} · {exc}")
380
+ return HookFile(source, path, digest, tuple(hooks)), problems
381
+
382
+
383
+ def load_hooks(
384
+ root: Path, home: Path | None = None, *, store: TrustStore | None = None, trust_project: bool = False
385
+ ) -> HookConfig:
386
+ files, problems, seen = [], [], set()
387
+ for source, path in hook_paths(root, home):
388
+ file, issues = read_file(source, path)
389
+ problems.extend(issues)
390
+ unique = []
391
+ for hook in file.hooks:
392
+ if hook.id in seen:
393
+ problems.append(f"{path} · {hook.id} · id 与之前加载的 Hook 重复,已跳过。")
394
+ continue
395
+ seen.add(hook.id)
396
+ unique.append(hook)
397
+ trusted = (
398
+ source == "user"
399
+ or trust_project
400
+ or file.digest is None
401
+ or (store is not None and store.trusted(path.name, file.digest))
402
+ )
403
+ files.append(replace(file, hooks=tuple(unique), trusted=trusted))
404
+ return HookConfig(tuple(files), tuple(problems))