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.
Files changed (57) hide show
  1. capt_hook-0.2.0.dist-info/METADATA +113 -0
  2. capt_hook-0.2.0.dist-info/RECORD +57 -0
  3. capt_hook-0.2.0.dist-info/WHEEL +4 -0
  4. capt_hook-0.2.0.dist-info/entry_points.txt +3 -0
  5. capt_hook-0.2.0.dist-info/licenses/LICENSE +73 -0
  6. captain_hook/__init__.py +246 -0
  7. captain_hook/__main__.py +6 -0
  8. captain_hook/app.py +278 -0
  9. captain_hook/classifiers/__init__.py +30 -0
  10. captain_hook/classifiers/conductor.py +35 -0
  11. captain_hook/classifiers/droid.py +20 -0
  12. captain_hook/classifiers/native.py +19 -0
  13. captain_hook/cli.py +341 -0
  14. captain_hook/command.py +356 -0
  15. captain_hook/conditions.py +136 -0
  16. captain_hook/context.py +161 -0
  17. captain_hook/dispatch.py +107 -0
  18. captain_hook/events.py +318 -0
  19. captain_hook/file.py +120 -0
  20. captain_hook/llm/__init__.py +9 -0
  21. captain_hook/llm/backends.py +152 -0
  22. captain_hook/loader.py +62 -0
  23. captain_hook/log.py +60 -0
  24. captain_hook/primitives/__init__.py +51 -0
  25. captain_hook/primitives/audit.py +71 -0
  26. captain_hook/primitives/commands.py +61 -0
  27. captain_hook/primitives/lint.py +216 -0
  28. captain_hook/primitives/llm.py +376 -0
  29. captain_hook/primitives/nudge.py +95 -0
  30. captain_hook/prompt.py +103 -0
  31. captain_hook/py.typed +1 -0
  32. captain_hook/session.py +158 -0
  33. captain_hook/settings.py +120 -0
  34. captain_hook/signals/__init__.py +86 -0
  35. captain_hook/signals/nlp.py +105 -0
  36. captain_hook/state.py +221 -0
  37. captain_hook/styleguide/__init__.py +183 -0
  38. captain_hook/styleguide/query.py +238 -0
  39. captain_hook/styleguide/scope.py +46 -0
  40. captain_hook/styleguide/types.py +70 -0
  41. captain_hook/tasks.py +112 -0
  42. captain_hook/templates/example_hook.py.tmpl +85 -0
  43. captain_hook/testing/__init__.py +10 -0
  44. captain_hook/testing/helpers.py +392 -0
  45. captain_hook/testing/session_cache.py +50 -0
  46. captain_hook/testing/types.py +88 -0
  47. captain_hook/tests/__init__.py +27 -0
  48. captain_hook/tests/helpers.py +361 -0
  49. captain_hook/tools.py +59 -0
  50. captain_hook/transcript/__init__.py +572 -0
  51. captain_hook/transcript/inputs.py +226 -0
  52. captain_hook/transcript/models.py +186 -0
  53. captain_hook/types.py +381 -0
  54. captain_hook/util/__init__.py +0 -0
  55. captain_hook/util/model_cache.py +87 -0
  56. captain_hook/utils.py +27 -0
  57. 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)