local-coding-agent 1.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.
- local_coding_agent/__init__.py +314 -0
- local_coding_agent/__main__.py +5 -0
- local_coding_agent/acp_server/__init__.py +38 -0
- local_coding_agent/acp_server/_codec.py +95 -0
- local_coding_agent/acp_server/_protocol.py +13 -0
- local_coding_agent/acp_server/_server.py +663 -0
- local_coding_agent/acp_server/_session.py +24 -0
- local_coding_agent/acp_server/_tools.py +126 -0
- local_coding_agent/ast_compactor.py +130 -0
- local_coding_agent/atomizer.py +77 -0
- local_coding_agent/benchmark/__init__.py +57 -0
- local_coding_agent/benchmark/_cases.py +307 -0
- local_coding_agent/benchmark/_judge.py +175 -0
- local_coding_agent/benchmark/_oracles.py +157 -0
- local_coding_agent/benchmark/_runner.py +259 -0
- local_coding_agent/benchmark/_stats.py +22 -0
- local_coding_agent/benchmark/_summary.py +143 -0
- local_coding_agent/benchmark_oracle_worker.py +169 -0
- local_coding_agent/calibration.py +154 -0
- local_coding_agent/capability.py +450 -0
- local_coding_agent/chat_compaction.py +276 -0
- local_coding_agent/cli/__init__.py +12 -0
- local_coding_agent/cli/_dispatch.py +377 -0
- local_coding_agent/cli/_handlers.py +320 -0
- local_coding_agent/cli/_handlers2.py +725 -0
- local_coding_agent/cli/_input.py +61 -0
- local_coding_agent/cli/_main.py +159 -0
- local_coding_agent/cli/_parser.py +327 -0
- local_coding_agent/context_manager.py +155 -0
- local_coding_agent/controller/__init__.py +18 -0
- local_coding_agent/controller/_constants.py +108 -0
- local_coding_agent/controller/_controller.py +1008 -0
- local_coding_agent/controller/_post_apply.py +216 -0
- local_coding_agent/delegator.py +180 -0
- local_coding_agent/desktop/__init__.py +8 -0
- local_coding_agent/desktop/app.py +100 -0
- local_coding_agent/desktop/assets/lucide.min.js +12 -0
- local_coding_agent/desktop/assets/tailwind.css +1 -0
- local_coding_agent/desktop/client_js.py +1271 -0
- local_coding_agent/desktop/components.py +555 -0
- local_coding_agent/desktop/server/__init__.py +37 -0
- local_coding_agent/desktop/server/_handlers.py +2178 -0
- local_coding_agent/desktop/server/_models.py +204 -0
- local_coding_agent/desktop/server/_server.py +347 -0
- local_coding_agent/desktop/server/_telemetry.py +87 -0
- local_coding_agent/desktop/styles.py +76 -0
- local_coding_agent/desktop/ui.py +53 -0
- local_coding_agent/doctor.py +413 -0
- local_coding_agent/hooks/__init__.py +19 -0
- local_coding_agent/hooks/_adapters.py +458 -0
- local_coding_agent/hooks/_bridge.py +346 -0
- local_coding_agent/hooks/_decision.py +26 -0
- local_coding_agent/lsp/__init__.py +60 -0
- local_coding_agent/lsp/_client.py +322 -0
- local_coding_agent/lsp/_fallback.py +297 -0
- local_coding_agent/lsp/_manager.py +173 -0
- local_coding_agent/lsp/_mock.py +200 -0
- local_coding_agent/lsp/_types.py +398 -0
- local_coding_agent/mcp_config.py +398 -0
- local_coding_agent/mcp_server.py +377 -0
- local_coding_agent/memory.py +140 -0
- local_coding_agent/mode_router.py +200 -0
- local_coding_agent/model_scanner.py +525 -0
- local_coding_agent/monitor.py +635 -0
- local_coding_agent/observation_policy.py +184 -0
- local_coding_agent/ollama_adapter.py +736 -0
- local_coding_agent/plan_mode.py +653 -0
- local_coding_agent/prescriptions.py +278 -0
- local_coding_agent/profiles.py +239 -0
- local_coding_agent/repository_tools.py +782 -0
- local_coding_agent/ripgrep.py +326 -0
- local_coding_agent/semantic_linter.py +122 -0
- local_coding_agent/service.py +405 -0
- local_coding_agent/session_events.py +684 -0
- local_coding_agent/session_query.py +578 -0
- local_coding_agent/skill_config.py +426 -0
- local_coding_agent/smoke.py +268 -0
- local_coding_agent/speculative_racing.py +85 -0
- local_coding_agent/spill.py +258 -0
- local_coding_agent/stats.py +255 -0
- local_coding_agent/stdio.py +132 -0
- local_coding_agent/subagent.py +516 -0
- local_coding_agent/task.py +90 -0
- local_coding_agent/task_store.py +142 -0
- local_coding_agent/tasks.py +240 -0
- local_coding_agent/terminal/__init__.py +35 -0
- local_coding_agent/terminal/_manager.py +156 -0
- local_coding_agent/terminal/_process.py +148 -0
- local_coding_agent/terminal/_session.py +452 -0
- local_coding_agent/terminal/_tools.py +233 -0
- local_coding_agent/terminal/_types.py +60 -0
- local_coding_agent/tool_call_parser.py +178 -0
- local_coding_agent/validators.py +563 -0
- local_coding_agent/vram_fit.py +240 -0
- local_coding_agent/worker_pool.py +516 -0
- local_coding_agent-1.1.0.dist-info/METADATA +202 -0
- local_coding_agent-1.1.0.dist-info/RECORD +101 -0
- local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
- local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
- local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
- local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""Wire-protocol adapters for Codex and Claude Code hooks (R30)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ._bridge import HookBridge
|
|
12
|
+
from ._decision import HookDecision
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CodexHookAdapter:
|
|
16
|
+
"""Wire-protocol adapter and command runner for Codex hooks."""
|
|
17
|
+
|
|
18
|
+
DIALECT = "codex"
|
|
19
|
+
|
|
20
|
+
def __init__(self, bridge: HookBridge | None = None, *, model: str = "") -> None:
|
|
21
|
+
self.bridge = bridge or HookBridge()
|
|
22
|
+
self.model = model
|
|
23
|
+
|
|
24
|
+
def format_session_start(
|
|
25
|
+
self, session_id: str, cwd: str, model: str | None = None, source: str = "user"
|
|
26
|
+
) -> dict[str, Any]:
|
|
27
|
+
"""Format SessionStart event into Codex wire protocol."""
|
|
28
|
+
return {
|
|
29
|
+
"event": "SessionStart",
|
|
30
|
+
"session_id": session_id,
|
|
31
|
+
"cwd": cwd,
|
|
32
|
+
"model": model or self.model,
|
|
33
|
+
"source": source,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
def format_user_prompt_submit(
|
|
37
|
+
self, turn_id: str | int, prompt: str, cwd: str = "", model: str | None = None
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
"""Format UserPromptSubmit event into Codex wire protocol."""
|
|
40
|
+
return {
|
|
41
|
+
"event": "UserPromptSubmit",
|
|
42
|
+
"turn_id": str(turn_id),
|
|
43
|
+
"prompt": prompt,
|
|
44
|
+
"cwd": cwd,
|
|
45
|
+
"model": model or self.model,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
def format_pre_tool_use(
|
|
49
|
+
self,
|
|
50
|
+
tool_name: str,
|
|
51
|
+
tool_input: dict[str, Any],
|
|
52
|
+
turn_id: str | int = "",
|
|
53
|
+
cwd: str = "",
|
|
54
|
+
model: str | None = None,
|
|
55
|
+
) -> dict[str, Any]:
|
|
56
|
+
"""Format PreToolUse event into Codex wire protocol."""
|
|
57
|
+
return {
|
|
58
|
+
"event": "PreToolUse",
|
|
59
|
+
"tool_name": tool_name,
|
|
60
|
+
"tool_input": tool_input,
|
|
61
|
+
"turn_id": str(turn_id),
|
|
62
|
+
"cwd": cwd,
|
|
63
|
+
"model": model or self.model,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
def format_post_tool_use(
|
|
67
|
+
self,
|
|
68
|
+
tool_name: str,
|
|
69
|
+
tool_input: dict[str, Any],
|
|
70
|
+
tool_output: dict[str, Any],
|
|
71
|
+
turn_id: str | int = "",
|
|
72
|
+
cwd: str = "",
|
|
73
|
+
model: str | None = None,
|
|
74
|
+
) -> dict[str, Any]:
|
|
75
|
+
"""Format PostToolUse event into Codex wire protocol."""
|
|
76
|
+
return {
|
|
77
|
+
"event": "PostToolUse",
|
|
78
|
+
"tool_name": tool_name,
|
|
79
|
+
"tool_input": tool_input,
|
|
80
|
+
"tool_output": tool_output,
|
|
81
|
+
"turn_id": str(turn_id),
|
|
82
|
+
"cwd": cwd,
|
|
83
|
+
"model": model or self.model,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
def format_stop(
|
|
87
|
+
self, session_id: str, reason: str = "", cwd: str = "", model: str | None = None
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
"""Format Stop event into Codex wire protocol."""
|
|
90
|
+
return {
|
|
91
|
+
"event": "Stop",
|
|
92
|
+
"session_id": session_id,
|
|
93
|
+
"reason": reason,
|
|
94
|
+
"cwd": cwd,
|
|
95
|
+
"model": model or self.model,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
def parse_hook_output(self, exit_code: int, stdout: str, stderr: str) -> HookDecision:
|
|
99
|
+
"""Parse raw process output into a normalized HookDecision."""
|
|
100
|
+
trimmed_stdout = stdout.strip()
|
|
101
|
+
trimmed_stderr = stderr.strip()
|
|
102
|
+
|
|
103
|
+
# In Codex spec, non-zero exit codes indicate failure / blocking
|
|
104
|
+
if exit_code != 0:
|
|
105
|
+
reason = trimmed_stderr or trimmed_stdout or f"blocked by hook (exit code {exit_code})"
|
|
106
|
+
return HookDecision(
|
|
107
|
+
decision="deny",
|
|
108
|
+
allowed=False,
|
|
109
|
+
reason=reason,
|
|
110
|
+
raw_outputs=[{"exit_code": exit_code, "stdout": stdout, "stderr": stderr}],
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if not trimmed_stdout:
|
|
114
|
+
return HookDecision(raw_outputs=[{"exit_code": exit_code, "stdout": stdout, "stderr": stderr}])
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
parsed = json.loads(trimmed_stdout)
|
|
118
|
+
if isinstance(parsed, dict):
|
|
119
|
+
decision = HookDecision()
|
|
120
|
+
self.bridge._fold_outcome(decision, parsed)
|
|
121
|
+
decision.raw_outputs.append({"exit_code": exit_code, "stdout": stdout, "stderr": stderr})
|
|
122
|
+
return decision
|
|
123
|
+
except json.JSONDecodeError:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
# Plain text stdout on exit code 0 is treated as additional context
|
|
127
|
+
if exit_code == 0 and trimmed_stdout:
|
|
128
|
+
return HookDecision(
|
|
129
|
+
additional_context=[trimmed_stdout],
|
|
130
|
+
raw_outputs=[{"exit_code": exit_code, "stdout": stdout, "stderr": stderr}],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if exit_code != 0:
|
|
134
|
+
err_msg = stderr.strip() or f"hook exited with code {exit_code}"
|
|
135
|
+
return HookDecision(
|
|
136
|
+
decision="deny",
|
|
137
|
+
allowed=False,
|
|
138
|
+
reason=err_msg,
|
|
139
|
+
raw_outputs=[{"exit_code": exit_code, "stdout": stdout, "stderr": stderr}],
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return HookDecision(raw_outputs=[{"exit_code": exit_code, "stdout": stdout, "stderr": stderr}])
|
|
143
|
+
|
|
144
|
+
def run_command_hook(
|
|
145
|
+
self,
|
|
146
|
+
command: str,
|
|
147
|
+
payload: dict[str, Any],
|
|
148
|
+
*,
|
|
149
|
+
timeout: float = 30.0,
|
|
150
|
+
cwd: str | None = None,
|
|
151
|
+
) -> HookDecision:
|
|
152
|
+
"""Run an external command hook sending JSON to stdin and return HookDecision."""
|
|
153
|
+
try:
|
|
154
|
+
# Codex sends payload without trailing newline
|
|
155
|
+
input_bytes = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
156
|
+
proc = subprocess.run(
|
|
157
|
+
command,
|
|
158
|
+
input=input_bytes,
|
|
159
|
+
capture_output=True,
|
|
160
|
+
timeout=timeout,
|
|
161
|
+
cwd=cwd,
|
|
162
|
+
shell=True,
|
|
163
|
+
)
|
|
164
|
+
stdout = proc.stdout.decode("utf-8", errors="replace")
|
|
165
|
+
stderr = proc.stderr.decode("utf-8", errors="replace")
|
|
166
|
+
return self.parse_hook_output(proc.returncode, stdout, stderr)
|
|
167
|
+
except subprocess.TimeoutExpired:
|
|
168
|
+
return HookDecision(
|
|
169
|
+
decision="deny",
|
|
170
|
+
allowed=False,
|
|
171
|
+
reason=f"hook command timed out after {timeout}s",
|
|
172
|
+
raw_outputs=[{"error": "timeout", "command": command}],
|
|
173
|
+
)
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
return HookDecision(
|
|
176
|
+
decision="deny",
|
|
177
|
+
allowed=False,
|
|
178
|
+
reason=f"hook command execution error: {exc}",
|
|
179
|
+
raw_outputs=[{"error": str(exc), "command": command}],
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def load_config(
|
|
183
|
+
self, config_data: dict[str, Any] | str | Path, cwd: str | None = None
|
|
184
|
+
) -> HookBridge:
|
|
185
|
+
"""Load a Codex hooks.json config and register command handlers onto the bridge."""
|
|
186
|
+
if isinstance(config_data, (str, Path)):
|
|
187
|
+
with open(config_data, "r", encoding="utf-8") as f:
|
|
188
|
+
data = json.load(f)
|
|
189
|
+
else:
|
|
190
|
+
data = dict(config_data)
|
|
191
|
+
|
|
192
|
+
for event_name, groups in data.items():
|
|
193
|
+
if not isinstance(groups, list):
|
|
194
|
+
continue
|
|
195
|
+
for grp in groups:
|
|
196
|
+
if not isinstance(grp, dict):
|
|
197
|
+
continue
|
|
198
|
+
matcher = grp.get("matcher", "")
|
|
199
|
+
hooks = grp.get("hooks", [])
|
|
200
|
+
for h in hooks:
|
|
201
|
+
cmd = h.get("command")
|
|
202
|
+
timeout = float(h.get("timeoutSec", 30))
|
|
203
|
+
if not cmd:
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
def make_handler(command: str, timeout_sec: float):
|
|
207
|
+
def handler(match_target: str, payload: dict[str, Any]) -> HookDecision:
|
|
208
|
+
return self.run_command_hook(command, payload, timeout=timeout_sec, cwd=cwd)
|
|
209
|
+
return handler
|
|
210
|
+
|
|
211
|
+
self.bridge.register(
|
|
212
|
+
event_name,
|
|
213
|
+
make_handler(cmd, timeout),
|
|
214
|
+
matcher=matcher,
|
|
215
|
+
)
|
|
216
|
+
return self.bridge
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class ClaudeCodeHookAdapter:
|
|
220
|
+
"""Wire-protocol adapter and command runner for Claude Code hooks."""
|
|
221
|
+
|
|
222
|
+
DIALECT = "claude-code"
|
|
223
|
+
|
|
224
|
+
def __init__(
|
|
225
|
+
self,
|
|
226
|
+
bridge: HookBridge | None = None,
|
|
227
|
+
*,
|
|
228
|
+
plugin_root: str = "",
|
|
229
|
+
project_dir: str = "",
|
|
230
|
+
) -> None:
|
|
231
|
+
self.bridge = bridge or HookBridge()
|
|
232
|
+
self.plugin_root = plugin_root
|
|
233
|
+
self.project_dir = project_dir
|
|
234
|
+
|
|
235
|
+
def format_session_start(
|
|
236
|
+
self, session_id: str, project_dir: str = "", source: str = "startup"
|
|
237
|
+
) -> dict[str, Any]:
|
|
238
|
+
"""Format SessionStart event into Claude Code wire protocol."""
|
|
239
|
+
return {
|
|
240
|
+
"hookEventName": "SessionStart",
|
|
241
|
+
"sessionId": session_id,
|
|
242
|
+
"projectDir": project_dir or self.project_dir,
|
|
243
|
+
"source": source,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
def format_user_prompt_submit(
|
|
247
|
+
self, turn: int, prompt: str, session_id: str = ""
|
|
248
|
+
) -> dict[str, Any]:
|
|
249
|
+
"""Format UserPromptSubmit event into Claude Code wire protocol."""
|
|
250
|
+
return {
|
|
251
|
+
"hookEventName": "UserPromptSubmit",
|
|
252
|
+
"turn": turn,
|
|
253
|
+
"prompt": prompt,
|
|
254
|
+
"sessionId": session_id,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
def format_pre_tool_use(
|
|
258
|
+
self,
|
|
259
|
+
tool_name: str,
|
|
260
|
+
tool_input: dict[str, Any],
|
|
261
|
+
turn: int = 1,
|
|
262
|
+
session_id: str = "",
|
|
263
|
+
) -> dict[str, Any]:
|
|
264
|
+
"""Format PreToolUse event into Claude Code wire protocol."""
|
|
265
|
+
return {
|
|
266
|
+
"hookEventName": "PreToolUse",
|
|
267
|
+
"tool": tool_name,
|
|
268
|
+
"toolInput": tool_input,
|
|
269
|
+
"turn": turn,
|
|
270
|
+
"sessionId": session_id,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
def format_post_tool_use(
|
|
274
|
+
self,
|
|
275
|
+
tool_name: str,
|
|
276
|
+
tool_input: dict[str, Any],
|
|
277
|
+
tool_output: dict[str, Any],
|
|
278
|
+
turn: int = 1,
|
|
279
|
+
session_id: str = "",
|
|
280
|
+
) -> dict[str, Any]:
|
|
281
|
+
"""Format PostToolUse event into Claude Code wire protocol."""
|
|
282
|
+
return {
|
|
283
|
+
"hookEventName": "PostToolUse",
|
|
284
|
+
"tool": tool_name,
|
|
285
|
+
"toolInput": tool_input,
|
|
286
|
+
"toolOutput": tool_output,
|
|
287
|
+
"turn": turn,
|
|
288
|
+
"sessionId": session_id,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
def format_stop(self, session_id: str, stop_reason: str = "") -> dict[str, Any]:
|
|
292
|
+
"""Format Stop event into Claude Code wire protocol."""
|
|
293
|
+
return {
|
|
294
|
+
"hookEventName": "Stop",
|
|
295
|
+
"sessionId": session_id,
|
|
296
|
+
"stopReason": stop_reason,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
def format_subagent_start(
|
|
300
|
+
self, subagent_id: str, role: str, session_id: str = ""
|
|
301
|
+
) -> dict[str, Any]:
|
|
302
|
+
"""Format SubagentStart event into Claude Code wire protocol."""
|
|
303
|
+
return {
|
|
304
|
+
"hookEventName": "SubagentStart",
|
|
305
|
+
"subagentId": subagent_id,
|
|
306
|
+
"role": role,
|
|
307
|
+
"sessionId": session_id,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
def format_subagent_stop(
|
|
311
|
+
self, subagent_id: str, session_id: str = "", status: str = "completed"
|
|
312
|
+
) -> dict[str, Any]:
|
|
313
|
+
"""Format SubagentStop event into Claude Code wire protocol."""
|
|
314
|
+
return {
|
|
315
|
+
"hookEventName": "SubagentStop",
|
|
316
|
+
"subagentId": subagent_id,
|
|
317
|
+
"sessionId": session_id,
|
|
318
|
+
"status": status,
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
def parse_hook_output(self, exit_code: int, stdout: str, stderr: str) -> HookDecision:
|
|
322
|
+
"""Parse Claude Code hook command output into a normalized HookDecision."""
|
|
323
|
+
trimmed_stdout = stdout.strip()
|
|
324
|
+
trimmed_stderr = stderr.strip()
|
|
325
|
+
|
|
326
|
+
if exit_code != 0 and not trimmed_stdout:
|
|
327
|
+
return HookDecision(
|
|
328
|
+
decision="deny",
|
|
329
|
+
allowed=False,
|
|
330
|
+
reason=trimmed_stderr or f"hook failed with exit code {exit_code}",
|
|
331
|
+
raw_outputs=[{"exit_code": exit_code, "stdout": stdout, "stderr": stderr}],
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
if not trimmed_stdout:
|
|
335
|
+
return HookDecision(raw_outputs=[{"exit_code": exit_code, "stdout": stdout, "stderr": stderr}])
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
parsed = json.loads(trimmed_stdout)
|
|
339
|
+
if isinstance(parsed, dict):
|
|
340
|
+
decision = HookDecision()
|
|
341
|
+
self.bridge._fold_outcome(decision, parsed)
|
|
342
|
+
decision.raw_outputs.append({"exit_code": exit_code, "stdout": stdout, "stderr": stderr})
|
|
343
|
+
return decision
|
|
344
|
+
except json.JSONDecodeError:
|
|
345
|
+
pass
|
|
346
|
+
|
|
347
|
+
return HookDecision(
|
|
348
|
+
additional_context=[trimmed_stdout] if exit_code == 0 and trimmed_stdout else [],
|
|
349
|
+
raw_outputs=[{"exit_code": exit_code, "stdout": stdout, "stderr": stderr}],
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
def _substitute_vars(self, command: str, plugin_root: str, project_dir: str) -> str:
|
|
353
|
+
res = command.replace("${CLAUDE_PLUGIN_ROOT}", plugin_root)
|
|
354
|
+
res = res.replace("$CLAUDE_PLUGIN_ROOT", plugin_root)
|
|
355
|
+
res = res.replace("${CLAUDE_PROJECT_DIR}", project_dir)
|
|
356
|
+
res = res.replace("$CLAUDE_PROJECT_DIR", project_dir)
|
|
357
|
+
return res
|
|
358
|
+
|
|
359
|
+
def run_command_hook(
|
|
360
|
+
self,
|
|
361
|
+
command: str,
|
|
362
|
+
payload: dict[str, Any],
|
|
363
|
+
*,
|
|
364
|
+
timeout: float = 30.0,
|
|
365
|
+
cwd: str | None = None,
|
|
366
|
+
) -> HookDecision:
|
|
367
|
+
"""Run a Claude Code command hook with JSON input and variable substitutions."""
|
|
368
|
+
plugin_root = self.plugin_root or (cwd or os.getcwd())
|
|
369
|
+
project_dir = self.project_dir or (cwd or os.getcwd())
|
|
370
|
+
substituted_cmd = self._substitute_vars(command, plugin_root, project_dir)
|
|
371
|
+
|
|
372
|
+
env = os.environ.copy()
|
|
373
|
+
env["CLAUDE_PROJECT_DIR"] = project_dir
|
|
374
|
+
if self.plugin_root:
|
|
375
|
+
env["CLAUDE_PLUGIN_ROOT"] = self.plugin_root
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
input_bytes = (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")
|
|
379
|
+
proc = subprocess.run(
|
|
380
|
+
substituted_cmd,
|
|
381
|
+
input=input_bytes,
|
|
382
|
+
capture_output=True,
|
|
383
|
+
timeout=timeout,
|
|
384
|
+
cwd=cwd or project_dir,
|
|
385
|
+
shell=True,
|
|
386
|
+
env=env,
|
|
387
|
+
)
|
|
388
|
+
stdout = proc.stdout.decode("utf-8", errors="replace")
|
|
389
|
+
stderr = proc.stderr.decode("utf-8", errors="replace")
|
|
390
|
+
return self.parse_hook_output(proc.returncode, stdout, stderr)
|
|
391
|
+
except subprocess.TimeoutExpired:
|
|
392
|
+
return HookDecision(
|
|
393
|
+
decision="deny",
|
|
394
|
+
allowed=False,
|
|
395
|
+
reason=f"hook command timed out after {timeout}s",
|
|
396
|
+
raw_outputs=[{"error": "timeout", "command": command}],
|
|
397
|
+
)
|
|
398
|
+
except Exception as exc:
|
|
399
|
+
return HookDecision(
|
|
400
|
+
decision="deny",
|
|
401
|
+
allowed=False,
|
|
402
|
+
reason=f"hook execution error: {exc}",
|
|
403
|
+
raw_outputs=[{"error": str(exc), "command": command}],
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
def load_config(
|
|
407
|
+
self,
|
|
408
|
+
config_data: dict[str, Any] | str | Path,
|
|
409
|
+
cwd: str | None = None,
|
|
410
|
+
plugin_root: str | None = None,
|
|
411
|
+
project_dir: str | None = None,
|
|
412
|
+
) -> HookBridge:
|
|
413
|
+
"""Load Claude Code hooks.json / settings.json config and register onto bridge."""
|
|
414
|
+
if plugin_root:
|
|
415
|
+
self.plugin_root = plugin_root
|
|
416
|
+
if project_dir:
|
|
417
|
+
self.project_dir = project_dir
|
|
418
|
+
|
|
419
|
+
if isinstance(config_data, (str, Path)):
|
|
420
|
+
with open(config_data, "r", encoding="utf-8") as f:
|
|
421
|
+
data = json.load(f)
|
|
422
|
+
else:
|
|
423
|
+
data = dict(config_data)
|
|
424
|
+
|
|
425
|
+
# Handle top-level 'hooks' wrapper if present
|
|
426
|
+
hooks_dict = data.get("hooks", data)
|
|
427
|
+
if not isinstance(hooks_dict, dict):
|
|
428
|
+
return self.bridge
|
|
429
|
+
|
|
430
|
+
for event_name, groups in hooks_dict.items():
|
|
431
|
+
if not isinstance(groups, list):
|
|
432
|
+
continue
|
|
433
|
+
for grp in groups:
|
|
434
|
+
if not isinstance(grp, dict):
|
|
435
|
+
continue
|
|
436
|
+
matcher = grp.get("matcher", "")
|
|
437
|
+
hooks = grp.get("hooks", [])
|
|
438
|
+
for h in hooks:
|
|
439
|
+
# Claude code supports {type: "command", command: ...}
|
|
440
|
+
if isinstance(h, dict) and h.get("type", "command") == "command":
|
|
441
|
+
cmd = h.get("command")
|
|
442
|
+
timeout = float(h.get("timeoutSec", h.get("timeout", 30)))
|
|
443
|
+
if not cmd:
|
|
444
|
+
continue
|
|
445
|
+
|
|
446
|
+
def make_handler(command: str, timeout_sec: float):
|
|
447
|
+
def handler(match_target: str, payload: dict[str, Any]) -> HookDecision:
|
|
448
|
+
return self.run_command_hook(
|
|
449
|
+
command, payload, timeout=timeout_sec, cwd=cwd
|
|
450
|
+
)
|
|
451
|
+
return handler
|
|
452
|
+
|
|
453
|
+
self.bridge.register(
|
|
454
|
+
event_name,
|
|
455
|
+
make_handler(cmd, timeout),
|
|
456
|
+
matcher=matcher,
|
|
457
|
+
)
|
|
458
|
+
return self.bridge
|