capt-hook 0.2.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.
- capt_hook-0.2.0.dist-info/METADATA +113 -0
- capt_hook-0.2.0.dist-info/RECORD +57 -0
- capt_hook-0.2.0.dist-info/WHEEL +4 -0
- capt_hook-0.2.0.dist-info/entry_points.txt +3 -0
- capt_hook-0.2.0.dist-info/licenses/LICENSE +73 -0
- captain_hook/__init__.py +246 -0
- captain_hook/__main__.py +6 -0
- captain_hook/app.py +278 -0
- captain_hook/classifiers/__init__.py +30 -0
- captain_hook/classifiers/conductor.py +35 -0
- captain_hook/classifiers/droid.py +20 -0
- captain_hook/classifiers/native.py +19 -0
- captain_hook/cli.py +341 -0
- captain_hook/command.py +356 -0
- captain_hook/conditions.py +136 -0
- captain_hook/context.py +161 -0
- captain_hook/dispatch.py +107 -0
- captain_hook/events.py +318 -0
- captain_hook/file.py +120 -0
- captain_hook/llm/__init__.py +9 -0
- captain_hook/llm/backends.py +152 -0
- captain_hook/loader.py +62 -0
- captain_hook/log.py +60 -0
- captain_hook/primitives/__init__.py +51 -0
- captain_hook/primitives/audit.py +71 -0
- captain_hook/primitives/commands.py +61 -0
- captain_hook/primitives/lint.py +216 -0
- captain_hook/primitives/llm.py +376 -0
- captain_hook/primitives/nudge.py +95 -0
- captain_hook/prompt.py +103 -0
- captain_hook/py.typed +1 -0
- captain_hook/session.py +158 -0
- captain_hook/settings.py +120 -0
- captain_hook/signals/__init__.py +86 -0
- captain_hook/signals/nlp.py +105 -0
- captain_hook/state.py +221 -0
- captain_hook/styleguide/__init__.py +183 -0
- captain_hook/styleguide/query.py +238 -0
- captain_hook/styleguide/scope.py +46 -0
- captain_hook/styleguide/types.py +70 -0
- captain_hook/tasks.py +112 -0
- captain_hook/templates/example_hook.py.tmpl +85 -0
- captain_hook/testing/__init__.py +10 -0
- captain_hook/testing/helpers.py +392 -0
- captain_hook/testing/session_cache.py +50 -0
- captain_hook/testing/types.py +88 -0
- captain_hook/tests/__init__.py +27 -0
- captain_hook/tests/helpers.py +361 -0
- captain_hook/tools.py +59 -0
- captain_hook/transcript/__init__.py +572 -0
- captain_hook/transcript/inputs.py +226 -0
- captain_hook/transcript/models.py +186 -0
- captain_hook/types.py +381 -0
- captain_hook/util/__init__.py +0 -0
- captain_hook/util/model_cache.py +87 -0
- captain_hook/utils.py +27 -0
- captain_hook/workflow.py +119 -0
captain_hook/events.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
9
|
+
|
|
10
|
+
from captain_hook.file import File
|
|
11
|
+
from captain_hook.transcript.inputs import (
|
|
12
|
+
AgentInput,
|
|
13
|
+
BashInput,
|
|
14
|
+
EditInput,
|
|
15
|
+
FileInputBase,
|
|
16
|
+
ToolInput,
|
|
17
|
+
WriteInput,
|
|
18
|
+
parse_tool_input,
|
|
19
|
+
)
|
|
20
|
+
from captain_hook.types import Event
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from captain_hook.command import CommandLine as CommandLineType
|
|
24
|
+
from captain_hook.context import HookContext
|
|
25
|
+
from captain_hook.tasks import Tasks
|
|
26
|
+
from captain_hook.types import HookResult
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class BaseHookEvent:
|
|
31
|
+
"""Base class for all hook events, providing access to raw payload, context, and convenience methods."""
|
|
32
|
+
|
|
33
|
+
event_name: ClassVar[Event]
|
|
34
|
+
|
|
35
|
+
_raw: dict[str, Any]
|
|
36
|
+
ctx: HookContext
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def event(self) -> Event:
|
|
40
|
+
return self.event_name
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_subagent(self) -> bool:
|
|
44
|
+
return "agent_id" in self._raw
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def session_id(self) -> str:
|
|
48
|
+
return self._raw["session_id"]
|
|
49
|
+
|
|
50
|
+
@cached_property
|
|
51
|
+
def tasks(self) -> Tasks:
|
|
52
|
+
"""The live task list for this session, read from Claude Code's native task store.
|
|
53
|
+
|
|
54
|
+
Unlike transcript-derived ``task_ops()``, this reflects updates made by
|
|
55
|
+
subagents, teammates, or resumed sessions, and is empty when the session
|
|
56
|
+
has no task store — it never falls back to another session's tasks.
|
|
57
|
+
"""
|
|
58
|
+
from captain_hook.tasks import Tasks as T
|
|
59
|
+
|
|
60
|
+
return T.for_session(self.session_id)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def user_prompt(self) -> str | None:
|
|
64
|
+
return self._raw.get("prompt")
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def stop_hook_active(self) -> bool:
|
|
68
|
+
return self._raw.get("stop_hook_active", False)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def transcript_path(self) -> Path | None:
|
|
72
|
+
return Path(p) if (p := self._raw.get("transcript_path")) else None
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def permission_mode(self) -> str | None:
|
|
76
|
+
return self._raw.get("permission_mode")
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def parent_agent_type(self) -> str | None:
|
|
80
|
+
return self._raw.get("agent_type")
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def tool_name(self) -> str | None:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def _tool_input(self) -> dict[str, Any]:
|
|
88
|
+
return {}
|
|
89
|
+
|
|
90
|
+
@cached_property
|
|
91
|
+
def input(self) -> ToolInput:
|
|
92
|
+
return parse_tool_input(self.tool_name or "", self._tool_input)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def command(self) -> str | None:
|
|
96
|
+
return self.input.command if isinstance(self.input, BashInput) else None
|
|
97
|
+
|
|
98
|
+
@cached_property
|
|
99
|
+
def command_line(self) -> CommandLineType | None:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def file(self) -> File | None:
|
|
104
|
+
return self.input.file if isinstance(self.input, FileInputBase) else None
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def content(self) -> str | None:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def old(self) -> str | None:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def agent_type(self) -> str | None:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
def command_matches(self, *patterns: str) -> bool:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
def file_matches(self, *globs: str) -> bool:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
def content_matches(self, pattern: str) -> bool:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
def allow(self) -> HookResult:
|
|
128
|
+
from captain_hook.types import Action
|
|
129
|
+
from captain_hook.types import HookResult as HR
|
|
130
|
+
|
|
131
|
+
return HR.of(Action.allow)
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _render_part(part: str | tuple[str, object] | object) -> str:
|
|
135
|
+
match part:
|
|
136
|
+
case str():
|
|
137
|
+
return part
|
|
138
|
+
case (str() as label, value):
|
|
139
|
+
return f"{label}: " + json.dumps(value, default=str)
|
|
140
|
+
case _:
|
|
141
|
+
return json.dumps(part, default=str)
|
|
142
|
+
|
|
143
|
+
def warn(self, *parts: str | tuple[str, object] | object) -> HookResult:
|
|
144
|
+
r"""Emit a warning whose parts are auto-rendered and joined with newlines.
|
|
145
|
+
|
|
146
|
+
Each part is rendered by form: a plain ``str`` passes through verbatim; a
|
|
147
|
+
``(label, value)`` tuple becomes ``"{label}: {json}"`` with ``value``
|
|
148
|
+
JSON-encoded; any other object is JSON-encoded directly. Rendered parts are
|
|
149
|
+
joined with ``"\n"``.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
*parts: Warning fragments, each a ``str``, a ``(label, value)`` tuple, or
|
|
153
|
+
any JSON-serializable object.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
A warn :class:`HookResult` carrying the joined message.
|
|
157
|
+
"""
|
|
158
|
+
from captain_hook.types import Action
|
|
159
|
+
from captain_hook.types import HookResult as HR
|
|
160
|
+
|
|
161
|
+
return HR.of(Action.warn, "\n".join(self._render_part(p) for p in parts))
|
|
162
|
+
|
|
163
|
+
def block(self, message: str) -> HookResult:
|
|
164
|
+
from captain_hook.types import Action
|
|
165
|
+
from captain_hook.types import HookResult as HR
|
|
166
|
+
|
|
167
|
+
return HR.of(Action.block, message)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass
|
|
171
|
+
class ToolHookEvent(BaseHookEvent):
|
|
172
|
+
"""Event for tool-related hooks, adding tool name, input, command, and file access."""
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def tool_name(self) -> str | None:
|
|
176
|
+
return self._raw.get("tool_name")
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def _tool_input(self) -> dict[str, Any]:
|
|
180
|
+
return self._raw.get("tool_input", {})
|
|
181
|
+
|
|
182
|
+
@cached_property
|
|
183
|
+
def command_line(self) -> CommandLineType | None:
|
|
184
|
+
from captain_hook.command import CommandLine
|
|
185
|
+
|
|
186
|
+
return CommandLine.parse(cmd) if (cmd := self.command) else None
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def content(self) -> str | None:
|
|
190
|
+
match self.input:
|
|
191
|
+
case EditInput(new=new):
|
|
192
|
+
return new
|
|
193
|
+
case WriteInput(content=c):
|
|
194
|
+
return c
|
|
195
|
+
case _:
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def old(self) -> str | None:
|
|
200
|
+
return self.input.old if isinstance(self.input, EditInput) else None
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def agent_type(self) -> str | None:
|
|
204
|
+
return ti.agent_type if isinstance(ti := self.input, AgentInput) else None
|
|
205
|
+
|
|
206
|
+
def command_matches(self, *patterns: str) -> bool:
|
|
207
|
+
if not (cl := self.command_line):
|
|
208
|
+
return False
|
|
209
|
+
return any(cl.primary.matches(p) for p in patterns)
|
|
210
|
+
|
|
211
|
+
def file_matches(self, *globs: str) -> bool:
|
|
212
|
+
return bool(self.file) and self.file.matches(*globs)
|
|
213
|
+
|
|
214
|
+
def content_matches(self, pattern: str) -> bool:
|
|
215
|
+
return bool(self.content) and bool(re.search(pattern, self.content, re.MULTILINE))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass
|
|
219
|
+
class PreToolUseEvent(ToolHookEvent):
|
|
220
|
+
"""Fires before a tool is executed. Return a block result to prevent execution."""
|
|
221
|
+
|
|
222
|
+
event_name: ClassVar[Event] = Event.PreToolUse
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass
|
|
226
|
+
class PostToolUseEvent(ToolHookEvent):
|
|
227
|
+
"""Fires after a tool completes successfully, with access to the tool response."""
|
|
228
|
+
|
|
229
|
+
event_name: ClassVar[Event] = Event.PostToolUse
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def tool_response(self) -> str | None:
|
|
233
|
+
return self._raw.get("tool_response")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass
|
|
237
|
+
class PostToolUseFailureEvent(ToolHookEvent):
|
|
238
|
+
"""Fires after a tool fails, providing the error message and interrupt status."""
|
|
239
|
+
|
|
240
|
+
event_name: ClassVar[Event] = Event.PostToolUseFailure
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def error(self) -> str:
|
|
244
|
+
return self._raw["error"]
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def is_interrupt(self) -> bool:
|
|
248
|
+
return self._raw.get("is_interrupt", False)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass
|
|
252
|
+
class UserPromptSubmitEvent(BaseHookEvent):
|
|
253
|
+
"""Fires when the user submits a prompt, before the agent processes it."""
|
|
254
|
+
|
|
255
|
+
event_name: ClassVar[Event] = Event.UserPromptSubmit
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@dataclass
|
|
259
|
+
class StopEvent(BaseHookEvent):
|
|
260
|
+
"""Fires when the agent is about to stop. Return a block result to prevent stopping."""
|
|
261
|
+
|
|
262
|
+
event_name: ClassVar[Event] = Event.Stop
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass
|
|
266
|
+
class SubagentStopEvent(BaseHookEvent):
|
|
267
|
+
"""Fires when a subagent finishes. Provides ``agent_type`` for filtering."""
|
|
268
|
+
|
|
269
|
+
event_name: ClassVar[Event] = Event.SubagentStop
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def agent_type(self) -> str | None:
|
|
273
|
+
return self._raw.get("agent_type") or self._raw.get("tool_input", {}).get("subagent_type")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@dataclass
|
|
277
|
+
class SubagentStartEvent(BaseHookEvent):
|
|
278
|
+
"""Fires when a subagent is launched. Provides ``agent_type`` for filtering."""
|
|
279
|
+
|
|
280
|
+
event_name: ClassVar[Event] = Event.SubagentStart
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def agent_type(self) -> str | None:
|
|
284
|
+
return self._raw.get("agent_type") or self._raw.get("tool_input", {}).get("subagent_type")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@dataclass
|
|
288
|
+
class PreCompactEvent(BaseHookEvent):
|
|
289
|
+
"""Fires before context compaction, providing the trigger and custom instructions."""
|
|
290
|
+
|
|
291
|
+
event_name: ClassVar[Event] = Event.PreCompact
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def trigger(self) -> str | None:
|
|
295
|
+
return self._raw.get("trigger")
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def custom_instructions(self) -> str | None:
|
|
299
|
+
return self._raw.get("custom_instructions")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@dataclass
|
|
303
|
+
class NotificationEvent(BaseHookEvent):
|
|
304
|
+
"""Fires on system notifications, providing message, title, and notification type."""
|
|
305
|
+
|
|
306
|
+
event_name: ClassVar[Event] = Event.Notification
|
|
307
|
+
|
|
308
|
+
@property
|
|
309
|
+
def message(self) -> str | None:
|
|
310
|
+
return self._raw.get("message")
|
|
311
|
+
|
|
312
|
+
@property
|
|
313
|
+
def title(self) -> str | None:
|
|
314
|
+
return self._raw.get("title")
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def notification_type(self) -> str | None:
|
|
318
|
+
return self._raw.get("notification_type")
|
captain_hook/file.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from fnmatch import fnmatch
|
|
7
|
+
from functools import cached_property
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, ClassVar
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, kw_only=True)
|
|
13
|
+
class File:
|
|
14
|
+
"""A file path wrapper with glob matching, prefix checks, and test-file detection.
|
|
15
|
+
|
|
16
|
+
Delegates ``Path`` methods via ``__getattr__`` so ``.suffix``, ``.name``,
|
|
17
|
+
``.parent``, ``.exists()`` etc. work directly.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
path: Path
|
|
21
|
+
|
|
22
|
+
TEST_PATTERNS: ClassVar[list[str]] = ["**/test_*.py", "**/conftest.py", "**/tests/**/*.py"]
|
|
23
|
+
|
|
24
|
+
def __getattr__(self, name: str) -> Any:
|
|
25
|
+
return getattr(self.path, name)
|
|
26
|
+
|
|
27
|
+
def __str__(self) -> str:
|
|
28
|
+
return str(self.path)
|
|
29
|
+
|
|
30
|
+
def __fspath__(self) -> str:
|
|
31
|
+
return str(self.path)
|
|
32
|
+
|
|
33
|
+
def __eq__(self, other: object) -> bool:
|
|
34
|
+
match other:
|
|
35
|
+
case File(path=p):
|
|
36
|
+
return self.path == p
|
|
37
|
+
case Path():
|
|
38
|
+
return self.path == other
|
|
39
|
+
case _:
|
|
40
|
+
return NotImplemented
|
|
41
|
+
|
|
42
|
+
def __hash__(self) -> int:
|
|
43
|
+
return hash(self.path)
|
|
44
|
+
|
|
45
|
+
@cached_property
|
|
46
|
+
def is_test(self) -> bool:
|
|
47
|
+
return self.matches(*self.TEST_PATTERNS)
|
|
48
|
+
|
|
49
|
+
def matches(self, *patterns: str) -> bool:
|
|
50
|
+
s, name = str(self.path), self.path.name
|
|
51
|
+
return any(fnmatch(s, p) or fnmatch(name, p) for p in patterns)
|
|
52
|
+
|
|
53
|
+
def under(self, *prefixes: str) -> bool:
|
|
54
|
+
s = str(self.path)
|
|
55
|
+
return any(s.startswith(p) or f"/{p}" in s for p in prefixes)
|
|
56
|
+
|
|
57
|
+
def exists(self) -> bool:
|
|
58
|
+
return self.path.exists()
|
|
59
|
+
|
|
60
|
+
def read_text(self) -> str:
|
|
61
|
+
return self.path.read_text()
|
|
62
|
+
|
|
63
|
+
def contains(self, pattern: str) -> bool:
|
|
64
|
+
try:
|
|
65
|
+
return bool(re.search(pattern, self.read_text()))
|
|
66
|
+
except (OSError, UnicodeDecodeError):
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, kw_only=True)
|
|
71
|
+
class PathMatcher:
|
|
72
|
+
"""A reusable set of glob patterns for matching file paths. Supports ``in`` operator."""
|
|
73
|
+
|
|
74
|
+
patterns: list[str]
|
|
75
|
+
|
|
76
|
+
def matches(self, path: str | Path | File) -> bool:
|
|
77
|
+
match path:
|
|
78
|
+
case File():
|
|
79
|
+
return path.matches(*self.patterns)
|
|
80
|
+
case _:
|
|
81
|
+
return File(path=Path(path)).matches(*self.patterns)
|
|
82
|
+
|
|
83
|
+
def __contains__(self, path: str | Path | File) -> bool:
|
|
84
|
+
return self.matches(path)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def categorize_files(
|
|
88
|
+
paths: Iterable[str | Path], *, lang: str = "py"
|
|
89
|
+
) -> tuple[list[str], list[str], list[str]]:
|
|
90
|
+
"""Split paths into source, test, and skipped buckets for a language.
|
|
91
|
+
|
|
92
|
+
A path that does not match the ``lang`` globs is skipped; otherwise it is
|
|
93
|
+
classified as a test file (via :attr:`File.is_test`, which treats ``conftest.py``
|
|
94
|
+
and anything under a ``tests/`` directory as tests) or as source.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
paths: File paths to categorize; blank entries are ignored.
|
|
98
|
+
lang: Language key into ``LANG_GLOBS`` (defaults to ``"py"``); unknown keys
|
|
99
|
+
fall back to ``*.<lang>``.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
A ``(source, test, skipped)`` tuple, each a sorted, de-duplicated list of
|
|
103
|
+
path strings.
|
|
104
|
+
"""
|
|
105
|
+
from captain_hook.types import LANG_GLOBS
|
|
106
|
+
|
|
107
|
+
matcher = PathMatcher(patterns=list(LANG_GLOBS.get(lang, (f"*.{lang}",))))
|
|
108
|
+
source: set[str] = set()
|
|
109
|
+
test: set[str] = set()
|
|
110
|
+
skipped: set[str] = set()
|
|
111
|
+
for raw in paths:
|
|
112
|
+
if not (p := str(raw).strip()):
|
|
113
|
+
continue
|
|
114
|
+
if (f := File(path=Path(p))) not in matcher:
|
|
115
|
+
skipped.add(p)
|
|
116
|
+
elif f.is_test:
|
|
117
|
+
test.add(p)
|
|
118
|
+
else:
|
|
119
|
+
source.add(p)
|
|
120
|
+
return sorted(source), sorted(test), sorted(skipped)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""LLM backend abstractions for captain-hook's ``call_llm`` helper."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from captain_hook.llm.backends import ClaudeBackend as ClaudeBackend
|
|
5
|
+
from captain_hook.llm.backends import CodexBackend as CodexBackend
|
|
6
|
+
from captain_hook.llm.backends import LlmBackend as LlmBackend
|
|
7
|
+
from captain_hook.llm.backends import LlmBackends as LlmBackends
|
|
8
|
+
from captain_hook.llm.backends import TModel as TModel
|
|
9
|
+
from captain_hook.llm.backends import TSpecialty as TSpecialty
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""LLM CLI backends for :func:`HookContext.call_llm`.
|
|
2
|
+
|
|
3
|
+
Each backend maps the framework's abstract :data:`TModel` sizes to provider
|
|
4
|
+
model names and knows how to build the CLI invocation and parse its response.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from typing import Any, ClassVar, Literal, cast
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
TSpecialty = Literal["debugging", "review", "general"]
|
|
15
|
+
TModel = Literal["small", "medium", "large"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LlmBackend(ABC):
|
|
19
|
+
"""Abstract interface for an LLM CLI backend.
|
|
20
|
+
|
|
21
|
+
Concrete backends map abstract :data:`TModel` sizes to provider-specific
|
|
22
|
+
model names and encapsulate how to invoke the provider's CLI and parse the
|
|
23
|
+
raw response.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
models: Mapping from abstract model size to the provider's model name.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
models: ClassVar[dict[TModel, str]]
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def build_command(self, model: str, schema_path: str | None, agent: bool) -> list[str]:
|
|
33
|
+
"""Build the CLI argv for a single LLM invocation.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
model: Provider-specific model name.
|
|
37
|
+
schema_path: Path to a JSON schema for structured output, or ``None``.
|
|
38
|
+
agent: Whether the invocation may use tools / agent capabilities.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The argv list to execute.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def parse_response(self, raw: str, response_model: type[BaseModel] | None) -> str | BaseModel:
|
|
46
|
+
"""Parse raw CLI stdout into text or a validated model.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
raw: Raw stdout from the backend CLI.
|
|
50
|
+
response_model: Model to validate against, or ``None`` for raw text.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
``raw`` when ``response_model`` is ``None``, else a validated instance.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def env(self) -> dict[str, str]:
|
|
58
|
+
"""Return extra environment variables to set for the CLI invocation."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CodexBackend(LlmBackend):
|
|
62
|
+
""":class:`LlmBackend` for the OpenAI ``codex`` CLI."""
|
|
63
|
+
|
|
64
|
+
models: ClassVar[dict[TModel, str]] = {
|
|
65
|
+
"small": "gpt-5.3-codex-spark",
|
|
66
|
+
"medium": "gpt-5.4-mini",
|
|
67
|
+
"large": "gpt-5.5",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
def build_command(self, model: str, schema_path: str | None, agent: bool) -> list[str]:
|
|
71
|
+
return [
|
|
72
|
+
"codex",
|
|
73
|
+
"exec",
|
|
74
|
+
"--ephemeral",
|
|
75
|
+
"--sandbox",
|
|
76
|
+
"read-only",
|
|
77
|
+
"--model",
|
|
78
|
+
model,
|
|
79
|
+
*([] if agent else ["-c", "features.codex_hooks=false", "-c", "features.mcp_servers=false"]),
|
|
80
|
+
*(["--output-schema", schema_path] if schema_path else []),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
def parse_response(self, raw: str, response_model: type[BaseModel] | None) -> str | BaseModel:
|
|
84
|
+
return raw if not response_model else response_model.model_validate_json(raw)
|
|
85
|
+
|
|
86
|
+
def env(self) -> dict[str, str]:
|
|
87
|
+
return {}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ClaudeBackend(LlmBackend):
|
|
91
|
+
""":class:`LlmBackend` for the Anthropic ``claude`` CLI."""
|
|
92
|
+
|
|
93
|
+
models: ClassVar[dict[TModel, str]] = {
|
|
94
|
+
"small": "haiku",
|
|
95
|
+
"medium": "sonnet",
|
|
96
|
+
"large": "opus",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def build_command(self, model: str, schema_path: str | None, agent: bool) -> list[str]:
|
|
100
|
+
return [
|
|
101
|
+
"claude",
|
|
102
|
+
"-p",
|
|
103
|
+
"--no-session-persistence",
|
|
104
|
+
"--model",
|
|
105
|
+
model,
|
|
106
|
+
*(
|
|
107
|
+
["--permission-mode", "auto", "--max-budget-usd", "1"]
|
|
108
|
+
if agent
|
|
109
|
+
else [
|
|
110
|
+
"--system-prompt", "",
|
|
111
|
+
"--setting-sources", "",
|
|
112
|
+
"--strict-mcp-config",
|
|
113
|
+
]
|
|
114
|
+
),
|
|
115
|
+
*(["--json-schema", schema_path, "--output-format", "json"] if schema_path else []),
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
def parse_response(self, raw: str, response_model: type[BaseModel] | None) -> str | BaseModel:
|
|
119
|
+
if not response_model:
|
|
120
|
+
return raw
|
|
121
|
+
data: Any = json.loads(raw)
|
|
122
|
+
if isinstance(data, list) and data:
|
|
123
|
+
return self.extract_structured(
|
|
124
|
+
cast(list[dict[str, Any]], data), response_model
|
|
125
|
+
) or response_model.model_validate_json(raw)
|
|
126
|
+
return response_model.model_validate_json(raw)
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def extract_structured(events: list[dict[str, Any]], model: type[BaseModel]) -> BaseModel | None:
|
|
130
|
+
"""Return the validated ``structured_output`` from a stream-json event list, if present."""
|
|
131
|
+
for e in events:
|
|
132
|
+
if e.get("type") == "result" and "structured_output" in e:
|
|
133
|
+
return model.model_validate(e["structured_output"])
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
def env(self) -> dict[str, str]:
|
|
137
|
+
return {"CLAUDE_CODE_SIMPLE": "1"}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class LlmBackends:
|
|
141
|
+
"""Registry mapping each :data:`TSpecialty` to the :class:`LlmBackend` that serves it."""
|
|
142
|
+
|
|
143
|
+
LLM_BACKENDS: ClassVar[dict[TSpecialty, LlmBackend]] = {
|
|
144
|
+
"debugging": CodexBackend(),
|
|
145
|
+
"review": CodexBackend(),
|
|
146
|
+
"general": ClaudeBackend(),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def for_specialty(cls, specialty: TSpecialty) -> LlmBackend:
|
|
151
|
+
"""Return the backend registered for ``specialty``."""
|
|
152
|
+
return cls.LLM_BACKENDS[specialty]
|
captain_hook/loader.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Hook discovery: imports a hooks package, loads its ``conf`` module, and registers every hook module."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import importlib
|
|
5
|
+
import importlib.util
|
|
6
|
+
import pkgutil
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import ModuleType
|
|
10
|
+
|
|
11
|
+
from pydantic_settings import BaseSettings
|
|
12
|
+
|
|
13
|
+
from captain_hook.app import State, _state
|
|
14
|
+
|
|
15
|
+
CONF_MODULE = "conf"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_hook_settings(module: ModuleType) -> BaseSettings | ModuleType:
|
|
19
|
+
if importlib.util.find_spec("captain_hook.settings"):
|
|
20
|
+
settings_mod = importlib.import_module("captain_hook.settings")
|
|
21
|
+
return settings_mod.build_settings(module)
|
|
22
|
+
return module
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def import_or_reload(fqn: str, fresh_this_pass: set[str]) -> ModuleType:
|
|
26
|
+
if fqn in fresh_this_pass:
|
|
27
|
+
return sys.modules[fqn]
|
|
28
|
+
before = set(sys.modules)
|
|
29
|
+
if fqn in sys.modules:
|
|
30
|
+
mod = importlib.reload(sys.modules[fqn])
|
|
31
|
+
else:
|
|
32
|
+
mod = importlib.import_module(fqn)
|
|
33
|
+
fresh_this_pass.update(set(sys.modules) - before)
|
|
34
|
+
fresh_this_pass.add(fqn)
|
|
35
|
+
return mod
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def discover_hooks(hooks_dir: str | Path, state: State | None = None) -> None:
|
|
39
|
+
target = state or _state
|
|
40
|
+
hooks_path = Path(hooks_dir).resolve()
|
|
41
|
+
if str(hooks_path.parent) not in sys.path:
|
|
42
|
+
sys.path.insert(0, str(hooks_path.parent))
|
|
43
|
+
|
|
44
|
+
pkg = hooks_path.name
|
|
45
|
+
fresh_this_pass: set[str] = set()
|
|
46
|
+
|
|
47
|
+
top_level = {info.name for info in pkgutil.iter_modules([str(hooks_path)]) if not info.name.startswith("_")}
|
|
48
|
+
|
|
49
|
+
if CONF_MODULE in top_level:
|
|
50
|
+
conf_module = import_or_reload(f"{pkg}.{CONF_MODULE}", fresh_this_pass)
|
|
51
|
+
target.settings = build_hook_settings(conf_module)
|
|
52
|
+
if classifier := getattr(conf_module, "classifier", None):
|
|
53
|
+
target.classifier = classifier
|
|
54
|
+
|
|
55
|
+
all_modules = {
|
|
56
|
+
info.name
|
|
57
|
+
for info in pkgutil.walk_packages([str(hooks_path)], prefix=f"{pkg}.")
|
|
58
|
+
if not info.name.rpartition(".")[2].startswith("_")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for fqn in sorted(all_modules - {f"{pkg}.{CONF_MODULE}"}):
|
|
62
|
+
import_or_reload(fqn, fresh_this_pass)
|