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.
Files changed (60) hide show
  1. claxcode/__init__.py +3 -0
  2. claxcode/api/__init__.py +1 -0
  3. claxcode/api/chat.py +51 -0
  4. claxcode/api/session.py +66 -0
  5. claxcode/api/subagents.py +82 -0
  6. claxcode/api/tools.py +38 -0
  7. claxcode/cli.py +1214 -0
  8. claxcode/cli_completion.py +174 -0
  9. claxcode/config.py +54 -0
  10. claxcode/core/__init__.py +1 -0
  11. claxcode/core/agent.py +327 -0
  12. claxcode/core/checkpoint.py +100 -0
  13. claxcode/core/context.py +114 -0
  14. claxcode/core/models.py +446 -0
  15. claxcode/extensions/__init__.py +1 -0
  16. claxcode/extensions/hooks.py +131 -0
  17. claxcode/extensions/mcp.py +13 -0
  18. claxcode/extensions/plugins.py +10 -0
  19. claxcode/extensions/skills.py +205 -0
  20. claxcode/main.py +55 -0
  21. claxcode/memory/__init__.py +23 -0
  22. claxcode/memory/auto_memory.py +68 -0
  23. claxcode/memory/long_term.py +196 -0
  24. claxcode/memory/project_context.py +20 -0
  25. claxcode/memory/project_memory.py +194 -0
  26. claxcode/memory/session_store.py +206 -0
  27. claxcode/models/__init__.py +1 -0
  28. claxcode/models/session.py +25 -0
  29. claxcode/models/subagent.py +25 -0
  30. claxcode/models/tool.py +24 -0
  31. claxcode/security/__init__.py +1 -0
  32. claxcode/security/audit.py +54 -0
  33. claxcode/security/permissions.py +29 -0
  34. claxcode/skills/bug-fix/SKILL.md +33 -0
  35. claxcode/skills/code-review/SKILL.md +35 -0
  36. claxcode/skills/python-refactor/SKILL.md +22 -0
  37. claxcode/skills/task-workflow/SKILL.md +38 -0
  38. claxcode/subagents/__init__.py +1 -0
  39. claxcode/subagents/base.py +108 -0
  40. claxcode/subagents/executor.py +16 -0
  41. claxcode/subagents/explore.py +25 -0
  42. claxcode/subagents/planner.py +25 -0
  43. claxcode/tools/__init__.py +14 -0
  44. claxcode/tools/admin.py +170 -0
  45. claxcode/tools/base.py +19 -0
  46. claxcode/tools/browser.py +499 -0
  47. claxcode/tools/code_intelligence.py +68 -0
  48. claxcode/tools/custom_example.py +76 -0
  49. claxcode/tools/execution.py +114 -0
  50. claxcode/tools/file_operations.py +287 -0
  51. claxcode/tools/memory_tool.py +29 -0
  52. claxcode/tools/registry.py +35 -0
  53. claxcode/tools/search.py +157 -0
  54. claxcode/tools/ssh.py +534 -0
  55. claxcode/tools/web.py +83 -0
  56. claxcode-0.1.1.dist-info/METADATA +359 -0
  57. claxcode-0.1.1.dist-info/RECORD +60 -0
  58. claxcode-0.1.1.dist-info/WHEEL +4 -0
  59. claxcode-0.1.1.dist-info/entry_points.txt +3 -0
  60. claxcode-0.1.1.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,114 @@
1
+ """Context window management."""
2
+
3
+ from typing import Any
4
+
5
+ from claxcode.config import settings
6
+
7
+
8
+ def estimate_tokens(text: str) -> int:
9
+ """Very rough token estimation (1 token ~= 4 chars for English/CJK mix)."""
10
+ return len(text) // 3 + 1
11
+
12
+
13
+ class ContextManager:
14
+ """Manages conversation history with compression support."""
15
+
16
+ def __init__(self, system_prompt: str = ""):
17
+ self.system_prompt = system_prompt
18
+ self.messages: list[dict[str, Any]] = []
19
+ if system_prompt:
20
+ self.messages.append({"role": "system", "content": system_prompt})
21
+
22
+ def add_message(self, role: str, content: str) -> None:
23
+ self.messages.append({"role": role, "content": content})
24
+ self._maybe_compress()
25
+
26
+ def add_tool_result(self, tool_call_id: str, name: str, output: str) -> None:
27
+ """Add a tool result message (OpenAI format)."""
28
+ self.messages.append(
29
+ {
30
+ "role": "tool",
31
+ "tool_call_id": tool_call_id,
32
+ "content": output,
33
+ }
34
+ )
35
+ self._maybe_compress()
36
+
37
+ def add_assistant_tool_calls(self, tool_calls: list[dict[str, Any]]) -> None:
38
+ self.messages.append(
39
+ {
40
+ "role": "assistant",
41
+ "content": "",
42
+ "tool_calls": tool_calls,
43
+ }
44
+ )
45
+ self._maybe_compress()
46
+
47
+ def get_messages(self) -> list[dict[str, Any]]:
48
+ return self.messages.copy()
49
+
50
+ def _total_tokens(self) -> int:
51
+ text = "\n".join(str(m.get("content", "")) for m in self.messages)
52
+ return estimate_tokens(text) + estimate_tokens(self.system_prompt)
53
+
54
+ def over_threshold(self) -> bool:
55
+ """Whether the estimated token count exceeds the compression threshold."""
56
+ threshold = int(settings.max_context_tokens * settings.context_compression_threshold)
57
+ return self._total_tokens() > threshold
58
+
59
+ def pop_oldest(self, count: int) -> list[dict[str, Any]]:
60
+ """Remove and return up to `count` oldest non-system messages.
61
+
62
+ Orphaned tool results at the cut boundary are dropped as well —
63
+ providers reject a tool result without its assistant tool call.
64
+ """
65
+ end = min(1 + count, len(self.messages))
66
+ removed = self.messages[1:end]
67
+ del self.messages[1:end]
68
+ while len(self.messages) > 1 and self.messages[1].get("role") == "tool":
69
+ removed.append(self.messages.pop(1))
70
+ return removed
71
+
72
+ def insert_summary(self, summary: str) -> None:
73
+ """Insert a compression summary right after the system prompt."""
74
+ self.messages.insert(1, {"role": "system", "content": summary})
75
+
76
+ def restore(self, messages: list[dict[str, Any]]) -> None:
77
+ """Restore persisted messages after the system prompt.
78
+
79
+ Leading tool results are dropped — without their assistant tool call
80
+ (e.g. lost to compression in a previous session) providers reject them.
81
+ Trailing assistant tool_calls without matching tool results are also
82
+ dropped, e.g. when a previous run crashed between tool_calls and results.
83
+ """
84
+ self.messages.extend(messages)
85
+ while len(self.messages) > 1 and self.messages[1].get("role") == "tool":
86
+ self.messages.pop(1)
87
+
88
+ i = 1
89
+ while i < len(self.messages):
90
+ msg = self.messages[i]
91
+ if msg.get("role") == "assistant" and msg.get("tool_calls"):
92
+ needed = {tc["id"] for tc in msg["tool_calls"]}
93
+ j = i + 1
94
+ found: set[str] = set()
95
+ while j < len(self.messages) and self.messages[j].get("role") == "tool":
96
+ found.add(self.messages[j].get("tool_call_id"))
97
+ j += 1
98
+ if needed != found:
99
+ # Drop the orphan assistant and any trailing tool messages.
100
+ del self.messages[i:j]
101
+ continue
102
+ i += 1
103
+
104
+ def _maybe_compress(self) -> None:
105
+ """Emergency fallback: hard-drop oldest messages while over the threshold."""
106
+ while self.over_threshold() and len(self.messages) > 3:
107
+ self.messages.pop(1) # keep system at index 0
108
+ while len(self.messages) > 1 and self.messages[1].get("role") == "tool":
109
+ self.messages.pop(1)
110
+
111
+ def clear(self) -> None:
112
+ self.messages = []
113
+ if self.system_prompt:
114
+ self.messages.append({"role": "system", "content": self.system_prompt})
@@ -0,0 +1,446 @@
1
+ """LLM abstraction layer supporting multiple providers and custom models."""
2
+
3
+ import json
4
+ from abc import ABC, abstractmethod
5
+ from collections.abc import AsyncIterator
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from claxcode.config import settings
11
+
12
+
13
+ class ToolCallChunk:
14
+ def __init__(self):
15
+ self.id: str = ""
16
+ self.name: str = ""
17
+ self.arguments: str = ""
18
+
19
+
20
+ class LLMProvider(ABC):
21
+ """Abstract LLM provider."""
22
+
23
+ @abstractmethod
24
+ async def stream_chat(
25
+ self,
26
+ messages: list[dict[str, str]],
27
+ tools: list[dict[str, Any]] | None = None,
28
+ ) -> AsyncIterator[str | dict[str, Any]]:
29
+ """Yield text chunks or tool-call dicts."""
30
+ ...
31
+
32
+
33
+ class DummyProvider(LLMProvider):
34
+ """Fallback provider when no LLM package is installed or config is invalid."""
35
+
36
+ def __init__(self, reason: str = ""):
37
+ self.reason = reason
38
+
39
+ async def stream_chat(
40
+ self,
41
+ messages: list[dict[str, str]],
42
+ tools: list[dict[str, Any]] | None = None,
43
+ ) -> AsyncIterator[str | dict[str, Any]]:
44
+ if self.reason:
45
+ yield f"[DummyProvider] {self.reason}"
46
+ else:
47
+ yield "[DummyProvider] 未配置 LLM。请设置 LLM_PROVIDER 和 LLM_API_KEY。"
48
+
49
+
50
+ class OpenAIProvider(LLMProvider):
51
+ """OpenAI-compatible API provider using httpx (no openai package required)."""
52
+
53
+ def __init__(self):
54
+ self.api_key = settings.llm_api_key or "sk-test"
55
+ self.base_url = (settings.llm_base_url or "https://api.openai.com").rstrip("/")
56
+ self.model = settings.llm_model
57
+ self.temperature = settings.llm_temperature
58
+ self.max_tokens = settings.llm_max_tokens
59
+
60
+ async def stream_chat(
61
+ self,
62
+ messages: list[dict[str, str]],
63
+ tools: list[dict[str, Any]] | None = None,
64
+ ) -> AsyncIterator[str | dict[str, Any]]:
65
+ url = f"{self.base_url}/v1/chat/completions"
66
+ headers = {
67
+ "Authorization": f"Bearer {self.api_key}",
68
+ "Content-Type": "application/json",
69
+ }
70
+ payload: dict[str, Any] = {
71
+ "model": self.model,
72
+ "messages": messages,
73
+ "temperature": self.temperature,
74
+ "max_tokens": self.max_tokens,
75
+ "stream": True,
76
+ }
77
+ if tools:
78
+ payload["tools"] = tools
79
+
80
+ if settings.debug:
81
+ print("[LLM PAYLOAD]", json.dumps(payload, ensure_ascii=False, indent=2))
82
+
83
+ async with httpx.AsyncClient(timeout=120) as client:
84
+ async with client.stream("POST", url, headers=headers, json=payload) as response:
85
+ if response.status_code >= 400:
86
+ body = await response.aread()
87
+ print(f"[LLM ERROR {response.status_code}] {body.decode('utf-8', errors='replace')}")
88
+ response.raise_for_status()
89
+ current_tool: ToolCallChunk | None = None
90
+
91
+ async for line in response.aiter_lines():
92
+ if not line:
93
+ continue
94
+ if line.startswith("data: "):
95
+ line = line[6:]
96
+ if line == "[DONE]":
97
+ break
98
+ try:
99
+ data = json.loads(line)
100
+ except json.JSONDecodeError:
101
+ continue
102
+
103
+ delta = data.get("choices", [{}])[0].get("delta", {})
104
+ content = delta.get("content", "")
105
+ if content:
106
+ yield content
107
+
108
+ # Tool calls
109
+ tcs = delta.get("tool_calls", [])
110
+ for tc in tcs:
111
+ if tc.get("id"):
112
+ if current_tool is None:
113
+ current_tool = ToolCallChunk()
114
+ current_tool.id = tc["id"]
115
+ func = tc.get("function", {})
116
+ if func.get("name"):
117
+ if current_tool is None:
118
+ current_tool = ToolCallChunk()
119
+ current_tool.name = func["name"]
120
+ if func.get("arguments"):
121
+ if current_tool is None:
122
+ current_tool = ToolCallChunk()
123
+ current_tool.arguments += func["arguments"]
124
+
125
+ finish_reason = data.get("choices", [{}])[0].get("finish_reason")
126
+ if finish_reason and current_tool and current_tool.name:
127
+ try:
128
+ args = json.loads(current_tool.arguments or "{}")
129
+ except json.JSONDecodeError:
130
+ # Some providers return malformed JSON; fall back to raw string
131
+ args = {"_raw": current_tool.arguments}
132
+ yield {
133
+ "type": "tool_call",
134
+ "id": current_tool.id,
135
+ "name": current_tool.name,
136
+ "arguments": args,
137
+ }
138
+ current_tool = None
139
+
140
+
141
+ class AnthropicProvider(LLMProvider):
142
+ def __init__(self):
143
+ try:
144
+ from anthropic import AsyncAnthropic
145
+ except ImportError as exc:
146
+ raise ImportError(
147
+ "AnthropicProvider 需要 anthropic 包。请安装: pip install anthropic"
148
+ ) from exc
149
+
150
+ self.client = AsyncAnthropic(api_key=settings.llm_api_key or "sk-test")
151
+ self.model = settings.llm_model
152
+ self.temperature = settings.llm_temperature
153
+ self.max_tokens = settings.llm_max_tokens
154
+
155
+ async def stream_chat(
156
+ self,
157
+ messages: list[dict[str, str]],
158
+ tools: list[dict[str, Any]] | None = None,
159
+ ) -> AsyncIterator[str | dict[str, Any]]:
160
+ system = ""
161
+ anthropic_messages = []
162
+ for m in messages:
163
+ if m["role"] == "system":
164
+ system = m["content"]
165
+ else:
166
+ anthropic_messages.append({"role": m["role"], "content": m["content"]})
167
+
168
+ kwargs: dict[str, Any] = {
169
+ "model": self.model,
170
+ "messages": anthropic_messages,
171
+ "max_tokens": self.max_tokens,
172
+ "temperature": self.temperature,
173
+ "stream": True,
174
+ }
175
+ if system:
176
+ kwargs["system"] = system
177
+ if tools:
178
+ kwargs["tools"] = tools
179
+
180
+ async with self.client.messages.stream(**kwargs) as stream:
181
+ current_tool: ToolCallChunk | None = None
182
+ async for event in stream:
183
+ if event.type == "content_block_delta":
184
+ if hasattr(event.delta, "text") and event.delta.text:
185
+ yield event.delta.text
186
+ if hasattr(event.delta, "partial_json"):
187
+ if current_tool is None:
188
+ current_tool = ToolCallChunk()
189
+ current_tool.arguments += event.delta.partial_json
190
+ elif event.type == "content_block_start":
191
+ if hasattr(event.content_block, "name"):
192
+ if current_tool is None:
193
+ current_tool = ToolCallChunk()
194
+ current_tool.name = event.content_block.name
195
+ current_tool.id = getattr(event.content_block, "id", "")
196
+ elif event.type == "message_stop":
197
+ if current_tool and current_tool.name:
198
+ yield {
199
+ "type": "tool_call",
200
+ "id": current_tool.id,
201
+ "name": current_tool.name,
202
+ "arguments": json.loads(current_tool.arguments or "{}"),
203
+ }
204
+ current_tool = None
205
+
206
+
207
+ class GenericOpenAIProvider(OpenAIProvider):
208
+ """For local models with OpenAI-compatible API (e.g., vLLM, LM Studio, DashScope)."""
209
+
210
+ pass
211
+
212
+
213
+ class OllamaProvider(LLMProvider):
214
+ """Native Ollama API provider."""
215
+
216
+ def __init__(self):
217
+ self.base_url = settings.llm_base_url or "http://localhost:11434"
218
+ self.model = settings.llm_model
219
+ self.temperature = settings.llm_temperature
220
+ self.max_tokens = settings.llm_max_tokens
221
+ self.options = self._parse_options()
222
+
223
+ def _parse_options(self) -> dict[str, Any]:
224
+ """Parse extra Ollama options from custom config."""
225
+ opts: dict[str, Any] = {}
226
+ try:
227
+ custom = json.loads(settings.llm_custom_config or "{}")
228
+ opts.update(custom.get("ollama_options", {}))
229
+ except json.JSONDecodeError:
230
+ pass
231
+ return opts
232
+
233
+ async def stream_chat(
234
+ self,
235
+ messages: list[dict[str, str]],
236
+ tools: list[dict[str, Any]] | None = None,
237
+ ) -> AsyncIterator[str | dict[str, Any]]:
238
+ url = f"{self.base_url.rstrip('/')}/api/chat"
239
+ payload: dict[str, Any] = {
240
+ "model": self.model,
241
+ "messages": messages,
242
+ "stream": True,
243
+ "options": {
244
+ "temperature": self.temperature,
245
+ "num_predict": self.max_tokens,
246
+ **self.options,
247
+ },
248
+ }
249
+ if tools:
250
+ payload["tools"] = [
251
+ {
252
+ "type": "function",
253
+ "function": {
254
+ "name": t["function"]["name"],
255
+ "description": t["function"]["description"],
256
+ "parameters": t["function"]["parameters"],
257
+ },
258
+ }
259
+ for t in tools
260
+ ]
261
+
262
+ async with httpx.AsyncClient(timeout=120) as client:
263
+ async with client.stream("POST", url, json=payload) as response:
264
+ response.raise_for_status()
265
+ async for line in response.aiter_lines():
266
+ if not line:
267
+ continue
268
+ try:
269
+ data = json.loads(line)
270
+ except json.JSONDecodeError:
271
+ continue
272
+ if data.get("done"):
273
+ break
274
+ msg = data.get("message", {})
275
+ content = msg.get("content", "")
276
+ if content:
277
+ yield content
278
+ tool_calls = msg.get("tool_calls", [])
279
+ for tc in tool_calls:
280
+ func = tc.get("function", {})
281
+ yield {
282
+ "type": "tool_call",
283
+ "id": tc.get("id", ""),
284
+ "name": func.get("name", ""),
285
+ "arguments": func.get("arguments", {}),
286
+ }
287
+
288
+
289
+ class CustomHTTPProvider(LLMProvider):
290
+ """Generic HTTP provider for custom LLM APIs.
291
+
292
+ Uses llm_custom_config to define request/response format:
293
+ {
294
+ "request_template": {
295
+ "url": "http://localhost:8000/v1/chat",
296
+ "method": "POST",
297
+ "headers": {"Authorization": "Bearer {api_key}"},
298
+ "body": {
299
+ "model": "{model}",
300
+ "messages": "{messages}",
301
+ "temperature": "{temperature}",
302
+ "max_tokens": "{max_tokens}",
303
+ "stream": true
304
+ }
305
+ },
306
+ "response_parser": {
307
+ "content_path": "choices.0.delta.content",
308
+ "tool_calls_path": "choices.0.delta.tool_calls"
309
+ }
310
+ }
311
+ """
312
+
313
+ def __init__(self):
314
+ self.model = settings.llm_model
315
+ self.temperature = settings.llm_temperature
316
+ self.max_tokens = settings.llm_max_tokens
317
+ self.api_key = settings.llm_api_key
318
+ self.custom_config = self._load_config()
319
+
320
+ def _load_config(self) -> dict[str, Any]:
321
+ try:
322
+ return json.loads(settings.llm_custom_config or "{}")
323
+ except json.JSONDecodeError:
324
+ return {}
325
+
326
+ def _render_template(self, template: Any, **values: Any) -> Any:
327
+ if isinstance(template, str):
328
+ result = template
329
+ for key, val in values.items():
330
+ result = result.replace(f"{{{key}}}", json.dumps(val) if isinstance(val, (list, dict)) else str(val))
331
+ return result
332
+ if isinstance(template, dict):
333
+ return {k: self._render_template(v, **values) for k, v in template.items()}
334
+ if isinstance(template, list):
335
+ return [self._render_template(item, **values) for item in template]
336
+ return template
337
+
338
+ async def stream_chat(
339
+ self,
340
+ messages: list[dict[str, str]],
341
+ tools: list[dict[str, Any]] | None = None,
342
+ ) -> AsyncIterator[str | dict[str, Any]]:
343
+ cfg = self.custom_config.get("request_template", {})
344
+ url = cfg.get("url", "http://localhost:8000/v1/chat")
345
+ method = cfg.get("method", "POST")
346
+ headers = self._render_template(cfg.get("headers", {}), api_key=self.api_key)
347
+ body = self._render_template(
348
+ cfg.get("body", {}),
349
+ model=self.model,
350
+ messages=messages,
351
+ temperature=self.temperature,
352
+ max_tokens=self.max_tokens,
353
+ tools=tools or [],
354
+ )
355
+
356
+ async with httpx.AsyncClient(timeout=120) as client:
357
+ async with client.stream(method, url, headers=headers, json=body) as response:
358
+ response.raise_for_status()
359
+ parser = self.custom_config.get("response_parser", {})
360
+ content_path = parser.get("content_path", "choices.0.delta.content")
361
+
362
+ async for line in response.aiter_lines():
363
+ if not line:
364
+ continue
365
+ if line.startswith("data: "):
366
+ line = line[6:]
367
+ if line == "[DONE]":
368
+ break
369
+ try:
370
+ data = json.loads(line)
371
+ except json.JSONDecodeError:
372
+ continue
373
+
374
+ content = self._get_path(data, content_path)
375
+ if content:
376
+ yield content
377
+
378
+ tool_path = parser.get("tool_calls_path")
379
+ if tool_path:
380
+ tcs = self._get_path(data, tool_path)
381
+ if tcs:
382
+ for tc in tcs:
383
+ yield {
384
+ "type": "tool_call",
385
+ "id": tc.get("id", ""),
386
+ "name": tc.get("function", {}).get("name", ""),
387
+ "arguments": tc.get("function", {}).get("arguments", {}),
388
+ }
389
+
390
+ def _get_path(self, data: dict, path: str) -> Any:
391
+ parts = path.split(".")
392
+ current: Any = data
393
+ for part in parts:
394
+ if isinstance(current, dict):
395
+ current = current.get(part)
396
+ elif isinstance(current, list) and part.isdigit():
397
+ current = current[int(part)] if int(part) < len(current) else None
398
+ else:
399
+ return None
400
+ if current is None:
401
+ return None
402
+ return current
403
+
404
+
405
+ _CUSTOM_PROVIDERS: dict[str, type[LLMProvider]] = {}
406
+
407
+
408
+ def register_custom_provider(name: str, provider_cls: type[LLMProvider]) -> None:
409
+ """Register a custom LLM provider class at runtime.
410
+
411
+ Example:
412
+ from claxcode.core.models import register_custom_provider, LLMProvider
413
+
414
+ class MyProvider(LLMProvider):
415
+ async def stream_chat(self, messages, tools=None):
416
+ yield "Hello from my model!"
417
+
418
+ register_custom_provider("my_model", MyProvider)
419
+ """
420
+ _CUSTOM_PROVIDERS[name.lower()] = provider_cls
421
+
422
+
423
+ def get_provider() -> LLMProvider:
424
+ provider = settings.llm_provider.lower()
425
+ if provider in _CUSTOM_PROVIDERS:
426
+ return _CUSTOM_PROVIDERS[provider]()
427
+ if provider == "openai":
428
+ return OpenAIProvider()
429
+ if provider == "anthropic":
430
+ try:
431
+ return AnthropicProvider()
432
+ except ImportError as exc:
433
+ return DummyProvider(
434
+ reason=f"加载提供商 '{provider}' 失败。{exc}. "
435
+ "请安装所需的包或选择其他 LLM_PROVIDER。"
436
+ )
437
+ if provider == "generic_openai":
438
+ return GenericOpenAIProvider()
439
+ if provider == "ollama":
440
+ return OllamaProvider()
441
+ if provider == "custom_http":
442
+ return CustomHTTPProvider()
443
+ return DummyProvider(
444
+ reason=f"未知的提供商 '{provider}'. "
445
+ "支持的提供商: openai, anthropic, generic_openai, ollama, custom_http。"
446
+ )
@@ -0,0 +1 @@
1
+ """Extension system for ClaxCode."""
@@ -0,0 +1,131 @@
1
+ """Lifecycle hooks — run shell commands before/after tool execution.
2
+
3
+ Config is read from `~/.claxcode/hooks.json` (global) and
4
+ `<project>/.claxcode/hooks.json` (project). Format:
5
+
6
+ {
7
+ "PreToolUse": [{"match": "run_shell|write_file", "command": "python guard.py"}],
8
+ "PostToolUse": [{"match": "", "command": "echo done"}]
9
+ }
10
+
11
+ `match` is a regex tested against the tool name (empty or "*" matches all).
12
+ The hook command receives a JSON payload on stdin
13
+ ({"tool": ..., "arguments": {...}, "result": ...}) plus the env vars
14
+ CLAXCODE_TOOL_NAME / CLAXCODE_TOOL_ARGS, and runs with the config's
15
+ project directory as cwd. A PreToolUse hook blocks the tool call when it
16
+ exits non-zero; its stdout becomes the block reason. Hooks that fail to
17
+ spawn or time out never block the main flow.
18
+ """
19
+
20
+ import asyncio
21
+ import json
22
+ import os
23
+ import re
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ HOOK_TIMEOUT = 30
29
+ HOOK_EVENTS = ("PreToolUse", "PostToolUse")
30
+
31
+
32
+ @dataclass
33
+ class Hook:
34
+ """A single shell hook from a hooks.json config."""
35
+
36
+ match: str
37
+ command: str
38
+ cwd: Path
39
+
40
+ def matches(self, tool_name: str) -> bool:
41
+ if not self.match or self.match == "*":
42
+ return True
43
+ try:
44
+ return re.search(self.match, tool_name) is not None
45
+ except re.error:
46
+ return self.match == tool_name
47
+
48
+
49
+ class HookManager:
50
+ """Load and execute PreToolUse/PostToolUse shell hooks."""
51
+
52
+ def __init__(self, project_path: Path | None = None):
53
+ self._hooks: dict[str, list[Hook]] = {event: [] for event in HOOK_EVENTS}
54
+ for config_path in self._config_paths(project_path):
55
+ self._load_config(config_path)
56
+
57
+ def _config_paths(self, project_path: Path | None) -> list[Path]:
58
+ paths = [Path.home() / ".claxcode" / "hooks.json"]
59
+ if project_path is not None:
60
+ paths.append(project_path / ".claxcode" / "hooks.json")
61
+ return paths
62
+
63
+ def _load_config(self, path: Path) -> None:
64
+ if not path.exists():
65
+ return
66
+ try:
67
+ data = json.loads(path.read_text(encoding="utf-8"))
68
+ except (json.JSONDecodeError, OSError):
69
+ return
70
+ cwd = path.parent.parent if path.parent.name == ".claxcode" else path.parent
71
+ for event in HOOK_EVENTS:
72
+ for entry in data.get(event, []):
73
+ command = entry.get("command", "")
74
+ if command:
75
+ self._hooks[event].append(Hook(entry.get("match", ""), command, cwd))
76
+
77
+ async def run_pre(self, tool_name: str, arguments: dict[str, Any]) -> tuple[bool, str]:
78
+ """Run PreToolUse hooks. Returns (allowed, reason)."""
79
+ for hook in self._hooks["PreToolUse"]:
80
+ if not hook.matches(tool_name):
81
+ continue
82
+ code, output = await self._run_command(hook, tool_name, arguments)
83
+ if code != 0:
84
+ return False, output or f"钩子命令退出码 {code}"
85
+ return True, ""
86
+
87
+ async def run_post(self, tool_name: str, arguments: dict[str, Any], result: str) -> str:
88
+ """Run PostToolUse hooks. Returns combined hook output (may be empty)."""
89
+ outputs = []
90
+ for hook in self._hooks["PostToolUse"]:
91
+ if not hook.matches(tool_name):
92
+ continue
93
+ _, output = await self._run_command(hook, tool_name, arguments, result)
94
+ if output:
95
+ outputs.append(output)
96
+ return "\n".join(outputs)
97
+
98
+ async def _run_command(
99
+ self,
100
+ hook: Hook,
101
+ tool_name: str,
102
+ arguments: dict[str, Any],
103
+ result: str = "",
104
+ ) -> tuple[int, str]:
105
+ payload = {"tool": tool_name, "arguments": arguments, "result": result}
106
+ env = {
107
+ **os.environ,
108
+ "CLAXCODE_TOOL_NAME": tool_name,
109
+ "CLAXCODE_TOOL_ARGS": json.dumps(arguments, ensure_ascii=False),
110
+ }
111
+ try:
112
+ proc = await asyncio.create_subprocess_shell(
113
+ hook.command,
114
+ stdin=asyncio.subprocess.PIPE,
115
+ stdout=asyncio.subprocess.PIPE,
116
+ stderr=asyncio.subprocess.STDOUT,
117
+ cwd=str(hook.cwd),
118
+ env=env,
119
+ )
120
+ except OSError:
121
+ return 0, "" # 钩子无法启动时不阻塞主流程
122
+
123
+ try:
124
+ stdout, _ = await asyncio.wait_for(
125
+ proc.communicate(json.dumps(payload, ensure_ascii=False).encode()),
126
+ timeout=HOOK_TIMEOUT,
127
+ )
128
+ except TimeoutError:
129
+ proc.kill()
130
+ return 0, ""
131
+ return proc.returncode or 0, stdout.decode("utf-8", errors="replace").strip()