capt-hook 0.3.0__tar.gz → 0.5.0__tar.gz

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 (69) hide show
  1. {capt_hook-0.3.0 → capt_hook-0.5.0}/PKG-INFO +15 -5
  2. {capt_hook-0.3.0 → capt_hook-0.5.0}/README.md +12 -3
  3. capt_hook-0.5.0/captain_hook/.claude-plugin/plugin.json +10 -0
  4. capt_hook-0.5.0/captain_hook/__init__.py +112 -0
  5. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/app.py +6 -11
  6. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/classifiers/__init__.py +1 -0
  7. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/cli.py +105 -18
  8. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/command.py +1 -3
  9. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/conditions.py +6 -7
  10. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/context.py +5 -7
  11. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/dispatch.py +2 -1
  12. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/file.py +1 -3
  13. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/llm/__init__.py +1 -0
  14. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/llm/backends.py +5 -2
  15. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/loader.py +1 -0
  16. capt_hook-0.5.0/captain_hook/primitives/__init__.py +16 -0
  17. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/primitives/commands.py +1 -0
  18. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/primitives/llm.py +4 -5
  19. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/prompt.py +10 -13
  20. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/settings.py +4 -2
  21. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/signals/__init__.py +0 -11
  22. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/signals/nlp.py +0 -2
  23. capt_hook-0.5.0/captain_hook/skills/bootstrapping-hooks/SKILL.md +229 -0
  24. capt_hook-0.5.0/captain_hook/skills/bootstrapping-hooks/references/capt-hook-api.md +166 -0
  25. capt_hook-0.5.0/captain_hook/skills/bootstrapping-hooks/references/pattern-catalog.md +404 -0
  26. capt_hook-0.5.0/captain_hook/skills/bootstrapping-hooks/references/testing-hooks.md +92 -0
  27. capt_hook-0.5.0/captain_hook/skills/translating-styleguides/SKILL.md +366 -0
  28. capt_hook-0.5.0/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +136 -0
  29. capt_hook-0.5.0/captain_hook/skills/translating-styleguides/references/matcher-reference.md +126 -0
  30. capt_hook-0.5.0/captain_hook/skills/translating-styleguides/references/tier-rubric.md +91 -0
  31. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/state.py +4 -8
  32. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/style/__init__.py +5 -15
  33. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/style/matchers.py +1 -30
  34. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/templates/example_hook.py.tmpl +9 -11
  35. capt_hook-0.5.0/captain_hook/testing/__init__.py +6 -0
  36. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/testing/helpers.py +24 -20
  37. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/testing/session_cache.py +1 -3
  38. capt_hook-0.5.0/captain_hook/tests/__init__.py +11 -0
  39. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/tests/helpers.py +28 -50
  40. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/transcript/__init__.py +27 -49
  41. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/transcript/models.py +2 -6
  42. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/types.py +2 -4
  43. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/util/model_cache.py +1 -2
  44. {capt_hook-0.3.0 → capt_hook-0.5.0}/pyproject.toml +10 -9
  45. capt_hook-0.3.0/captain_hook/__init__.py +0 -240
  46. capt_hook-0.3.0/captain_hook/primitives/__init__.py +0 -49
  47. capt_hook-0.3.0/captain_hook/testing/__init__.py +0 -10
  48. capt_hook-0.3.0/captain_hook/tests/__init__.py +0 -27
  49. {capt_hook-0.3.0 → capt_hook-0.5.0}/LICENSE +0 -0
  50. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/__main__.py +0 -0
  51. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/classifiers/conductor.py +0 -0
  52. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/classifiers/droid.py +0 -0
  53. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/classifiers/native.py +0 -0
  54. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/events.py +0 -0
  55. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/log.py +0 -0
  56. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/primitives/audit.py +0 -0
  57. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/primitives/lint.py +0 -0
  58. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/primitives/nudge.py +0 -0
  59. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/primitives/workflow.py +0 -0
  60. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/py.typed +0 -0
  61. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/session.py +0 -0
  62. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/style/scope.py +0 -0
  63. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/style/types.py +0 -0
  64. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/tasks.py +0 -0
  65. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/testing/types.py +0 -0
  66. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/tools.py +0 -0
  67. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/transcript/inputs.py +0 -0
  68. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/util/__init__.py +0 -0
  69. {capt_hook-0.3.0 → capt_hook-0.5.0}/captain_hook/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capt-hook
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Declarative hook framework for Claude Code
5
5
  Keywords: claude,claude-code,hooks,llm,agents,guardrails,cli
6
6
  Author: Yasyf Mohamedali
@@ -32,10 +32,11 @@ Requires-Dist: loguru>=0.7.3
32
32
  Requires-Dist: pytest>=8.0 ; extra == 'dev'
33
33
  Requires-Dist: pytest-asyncio>=0.24 ; extra == 'dev'
34
34
  Requires-Dist: pyright>=1.1 ; extra == 'dev'
35
+ Requires-Dist: pyyaml>=6 ; extra == 'dev'
35
36
  Requires-Dist: ruff>=0.8 ; extra == 'dev'
36
37
  Requires-Python: >=3.12
37
38
  Project-URL: Homepage, https://github.com/yasyf/captain-hook
38
- Project-URL: Documentation, https://captain-hook.readthedocs.io
39
+ Project-URL: Documentation, https://yasyf.github.io/captain-hook/
39
40
  Project-URL: Repository, https://github.com/yasyf/captain-hook
40
41
  Project-URL: Issues, https://github.com/yasyf/captain-hook/issues
41
42
  Project-URL: Changelog, https://github.com/yasyf/captain-hook/blob/main/CHANGELOG.md
@@ -46,7 +47,7 @@ Description-Content-Type: text/markdown
46
47
 
47
48
  [![PyPI](https://img.shields.io/pypi/v/capt-hook.svg)](https://pypi.org/project/capt-hook/)
48
49
  [![Python](https://img.shields.io/pypi/pyversions/capt-hook.svg)](https://pypi.org/project/capt-hook/)
49
- [![Docs](https://readthedocs.org/projects/captain-hook/badge/?version=latest)](https://captain-hook.readthedocs.io)
50
+ [![Docs](https://github.com/yasyf/captain-hook/actions/workflows/docs.yml/badge.svg)](https://yasyf.github.io/captain-hook/)
50
51
  [![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm_Noncommercial_1.0.0-blue.svg)](https://github.com/yasyf/captain-hook/blob/main/LICENSE)
51
52
 
52
53
  Declarative hook framework for Claude Code. Write hooks as data, test them inline, and ship them to CI in the same shape they run in production.
@@ -99,6 +100,15 @@ capt-hook generate-settings > .claude/settings.local.json
99
100
 
100
101
  The next time Claude tries `git stash`, captain-hook returns a deny with your reason and hint.
101
102
 
103
+ ## Agent skills & plugin
104
+
105
+ Don't want to write hooks by hand? capt-hook ships two [Agent Skills](https://yasyf.github.io/captain-hook/docs/getting-started/skills.html) — `bootstrapping-hooks` mines your repo's docs, CI, and git history into proposed gates and nudges; `translating-styleguides` turns a STYLEGUIDE.md into enforced rules. `uvx capt-hook init` installs them into `.claude/skills/`, or get them as a plugin:
106
+
107
+ ```
108
+ /plugin marketplace add yasyf/captain-hook
109
+ /plugin install captain-hook@captain-hook
110
+ ```
111
+
102
112
  ## What problems does this solve?
103
113
 
104
114
  - **Block dangerous tool calls** before they execute (`PreToolUse`) — force-push, package-manager footguns, raw `rm -rf`.
@@ -108,6 +118,6 @@ The next time Claude tries `git stash`, captain-hook returns a deny with your re
108
118
 
109
119
  ## Docs
110
120
 
111
- [Read the docs](https://captain-hook.readthedocs.io) for the full guide: conditions, primitives, LLM hooks, workflows, state, and real-world patterns.
121
+ [Read the docs](https://yasyf.github.io/captain-hook/) for the full guide: conditions, primitives, LLM hooks, workflows, state, and real-world patterns.
112
122
 
113
- Working on captain-hook itself? See the [development guide](https://captain-hook.readthedocs.io/en/latest/development/).
123
+ Working on captain-hook itself? See the [development guide](https://yasyf.github.io/captain-hook/docs/development/).
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![PyPI](https://img.shields.io/pypi/v/capt-hook.svg)](https://pypi.org/project/capt-hook/)
4
4
  [![Python](https://img.shields.io/pypi/pyversions/capt-hook.svg)](https://pypi.org/project/capt-hook/)
5
- [![Docs](https://readthedocs.org/projects/captain-hook/badge/?version=latest)](https://captain-hook.readthedocs.io)
5
+ [![Docs](https://github.com/yasyf/captain-hook/actions/workflows/docs.yml/badge.svg)](https://yasyf.github.io/captain-hook/)
6
6
  [![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm_Noncommercial_1.0.0-blue.svg)](https://github.com/yasyf/captain-hook/blob/main/LICENSE)
7
7
 
8
8
  Declarative hook framework for Claude Code. Write hooks as data, test them inline, and ship them to CI in the same shape they run in production.
@@ -55,6 +55,15 @@ capt-hook generate-settings > .claude/settings.local.json
55
55
 
56
56
  The next time Claude tries `git stash`, captain-hook returns a deny with your reason and hint.
57
57
 
58
+ ## Agent skills & plugin
59
+
60
+ Don't want to write hooks by hand? capt-hook ships two [Agent Skills](https://yasyf.github.io/captain-hook/docs/getting-started/skills.html) — `bootstrapping-hooks` mines your repo's docs, CI, and git history into proposed gates and nudges; `translating-styleguides` turns a STYLEGUIDE.md into enforced rules. `uvx capt-hook init` installs them into `.claude/skills/`, or get them as a plugin:
61
+
62
+ ```
63
+ /plugin marketplace add yasyf/captain-hook
64
+ /plugin install captain-hook@captain-hook
65
+ ```
66
+
58
67
  ## What problems does this solve?
59
68
 
60
69
  - **Block dangerous tool calls** before they execute (`PreToolUse`) — force-push, package-manager footguns, raw `rm -rf`.
@@ -64,6 +73,6 @@ The next time Claude tries `git stash`, captain-hook returns a deny with your re
64
73
 
65
74
  ## Docs
66
75
 
67
- [Read the docs](https://captain-hook.readthedocs.io) for the full guide: conditions, primitives, LLM hooks, workflows, state, and real-world patterns.
76
+ [Read the docs](https://yasyf.github.io/captain-hook/) for the full guide: conditions, primitives, LLM hooks, workflows, state, and real-world patterns.
68
77
 
69
- Working on captain-hook itself? See the [development guide](https://captain-hook.readthedocs.io/en/latest/development/).
78
+ Working on captain-hook itself? See the [development guide](https://yasyf.github.io/captain-hook/docs/development/).
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "captain-hook",
3
+ "displayName": "Captain Hook",
4
+ "description": "Skills for the capt-hook declarative hook framework: bootstrap gates and nudges from a repo's conventions, and translate style guides into enforced StyleRules.",
5
+ "author": { "name": "Yasyf Mohamedali", "email": "yasyfm@gmail.com" },
6
+ "homepage": "https://yasyf.github.io/captain-hook/",
7
+ "repository": "https://github.com/yasyf/captain-hook",
8
+ "license": "PolyForm-Noncommercial-1.0.0",
9
+ "keywords": ["claude-code", "hooks", "guardrails", "styleguide"]
10
+ }
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ from captain_hook import file as file
4
+ from captain_hook import style as style
5
+ from captain_hook import utils as utils
6
+ from captain_hook.app import hook as hook
7
+ from captain_hook.app import on as on
8
+ from captain_hook.app import register as register
9
+ from captain_hook.command import Command as Command
10
+ from captain_hook.command import CommandLine as CommandLine
11
+ from captain_hook.command import Redirect as Redirect
12
+ from captain_hook.context import HookContext as HookContext
13
+ from captain_hook.events import BaseHookEvent as BaseHookEvent
14
+ from captain_hook.events import NotificationEvent as NotificationEvent
15
+ from captain_hook.events import PostToolUseEvent as PostToolUseEvent
16
+ from captain_hook.events import PostToolUseFailureEvent as PostToolUseFailureEvent
17
+ from captain_hook.events import PreCompactEvent as PreCompactEvent
18
+ from captain_hook.events import PreToolUseEvent as PreToolUseEvent
19
+ from captain_hook.events import StopEvent as StopEvent
20
+ from captain_hook.events import SubagentStartEvent as SubagentStartEvent
21
+ from captain_hook.events import SubagentStopEvent as SubagentStopEvent
22
+ from captain_hook.events import ToolHookEvent as ToolHookEvent
23
+ from captain_hook.events import UserPromptSubmitEvent as UserPromptSubmitEvent
24
+ from captain_hook.file import File as File
25
+ from captain_hook.primitives import GateVerdict as GateVerdict
26
+ from captain_hook.primitives import NudgeVerdict as NudgeVerdict
27
+ from captain_hook.primitives import PromptCheckVerdict as PromptCheckVerdict
28
+ from captain_hook.primitives import block_command as block_command
29
+ from captain_hook.primitives import gate as gate
30
+ from captain_hook.primitives import llm_evaluate as llm_evaluate
31
+ from captain_hook.primitives import llm_gate as llm_gate
32
+ from captain_hook.primitives import llm_nudge as llm_nudge
33
+ from captain_hook.primitives import prompt_check as prompt_check
34
+ from captain_hook.primitives import warn_command as warn_command
35
+
36
+ # audit/lint/nudge are imported from their defining modules, not the
37
+ # primitives package: the package attribute and the submodule share a name,
38
+ # and an alias targeting captain_hook.primitives.<name> resolves to the
39
+ # module under static analysis (griffe), shadowing the function.
40
+ from captain_hook.primitives.audit import audit as audit
41
+ from captain_hook.primitives.lint import diff_lint as diff_lint
42
+ from captain_hook.primitives.lint import lint as lint
43
+ from captain_hook.primitives.nudge import nudge as nudge
44
+ from captain_hook.primitives.workflow import Artifact as Artifact
45
+ from captain_hook.primitives.workflow import Step as Step
46
+ from captain_hook.primitives.workflow import Workflow as Workflow
47
+ from captain_hook.primitives.workflow import text_matches as text_matches
48
+ from captain_hook.primitives.workflow import workflow as workflow
49
+ from captain_hook.prompt import Prompt as Prompt
50
+ from captain_hook.session import SessionSlot as SessionSlot
51
+ from captain_hook.session import SessionStore as SessionStore
52
+ from captain_hook.session import session_state as session_state
53
+ from captain_hook.settings import HooksSettings as HooksSettings
54
+ from captain_hook.settings import build_settings as build_settings
55
+ from captain_hook.signals.nlp import Clause as Clause
56
+ from captain_hook.signals.nlp import NlpSignal as NlpSignal
57
+ from captain_hook.signals.nlp import Phrase as Phrase
58
+ from captain_hook.state import HookState as HookState
59
+ from captain_hook.state import PrimitiveState as PrimitiveState
60
+ from captain_hook.state import workflow_state as workflow_state
61
+ from captain_hook.tasks import Task as Task
62
+ from captain_hook.tasks import Tasks as Tasks
63
+ from captain_hook.testing import Allow as Allow
64
+ from captain_hook.testing import Block as Block
65
+ from captain_hook.testing import InlineTests as InlineTests
66
+ from captain_hook.testing import Input as Input
67
+ from captain_hook.testing import TranscriptFixture as TranscriptFixture
68
+ from captain_hook.testing import Warn as Warn
69
+ from captain_hook.tools import EditOp as EditOp
70
+ from captain_hook.tools import TaskOp as TaskOp
71
+ from captain_hook.tools import WriteOp as WriteOp
72
+ from captain_hook.transcript import ToolUse as ToolUse
73
+ from captain_hook.transcript import ToolUseQuery as ToolUseQuery
74
+ from captain_hook.transcript import ToolUseSequence as ToolUseSequence
75
+ from captain_hook.transcript import Transcript as Transcript
76
+ from captain_hook.transcript import TranscriptMessage as TranscriptMessage
77
+ from captain_hook.transcript import TranscriptSlice as TranscriptSlice
78
+ from captain_hook.transcript import Turn as Turn
79
+ from captain_hook.transcript.inputs import AgentInput as AgentInput
80
+ from captain_hook.transcript.inputs import BashInput as BashInput
81
+ from captain_hook.transcript.inputs import EditInput as EditInput
82
+ from captain_hook.transcript.inputs import FileInputBase as FileInputBase
83
+ from captain_hook.transcript.inputs import GenericInput as GenericInput
84
+ from captain_hook.transcript.inputs import GlobInput as GlobInput
85
+ from captain_hook.transcript.inputs import GrepInput as GrepInput
86
+ from captain_hook.transcript.inputs import InputBase as InputBase
87
+ from captain_hook.transcript.inputs import ReadInput as ReadInput
88
+ from captain_hook.transcript.inputs import SkillInput as SkillInput
89
+ from captain_hook.transcript.inputs import TaskCreateInput as TaskCreateInput
90
+ from captain_hook.transcript.inputs import TaskUpdateInput as TaskUpdateInput
91
+ from captain_hook.transcript.inputs import WriteInput as WriteInput
92
+ from captain_hook.transcript.models import ContentBlock as ContentBlock
93
+ from captain_hook.transcript.models import ToolResult as ToolResult
94
+ from captain_hook.types import Action as Action
95
+ from captain_hook.types import Agent as Agent
96
+ from captain_hook.types import Content as Content
97
+ from captain_hook.types import CustomCondition as CustomCondition
98
+ from captain_hook.types import Event as Event
99
+ from captain_hook.types import FilePath as FilePath
100
+ from captain_hook.types import HookResult as HookResult
101
+ from captain_hook.types import InPlanMode as InPlanMode
102
+ from captain_hook.types import RanCommand as RanCommand
103
+ from captain_hook.types import ReadFile as ReadFile
104
+ from captain_hook.types import Signal as Signal
105
+ from captain_hook.types import Signals as Signals
106
+ from captain_hook.types import SourceEdits as SourceEdits
107
+ from captain_hook.types import TCondition as TCondition
108
+ from captain_hook.types import TestFile as TestFile
109
+ from captain_hook.types import Tool as Tool
110
+ from captain_hook.types import TouchedFile as TouchedFile
111
+ from captain_hook.types import UsedSkill as UsedSkill
112
+ from captain_hook.types import Waiting as Waiting
@@ -26,9 +26,7 @@ if TYPE_CHECKING:
26
26
 
27
27
  HookHandler = Callable[["BaseHookEvent"], "HookResult | None"]
28
28
 
29
- VALID_CONDITION_TYPES = tuple(
30
- t for t in get_args(TCondition) if t is not CustomCondition
31
- )
29
+ VALID_CONDITION_TYPES = tuple(t for t in get_args(TCondition) if t is not CustomCondition)
32
30
  VALID_CONDITION_NAMES = ", ".join(t.__name__ for t in VALID_CONDITION_TYPES) + ", or a CustomCondition"
33
31
 
34
32
 
@@ -44,7 +42,8 @@ def validate_conditions(conditions: Sequence[TCondition], label: str) -> None:
44
42
  def validate_handler_signature(fn: HookHandler) -> None:
45
43
  sig = inspect.signature(fn)
46
44
  params = [
47
- p for p in sig.parameters.values()
45
+ p
46
+ for p in sig.parameters.values()
48
47
  if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
49
48
  ]
50
49
  if len(params) != 1:
@@ -53,7 +52,8 @@ def validate_handler_signature(fn: HookHandler) -> None:
53
52
  f"got {sig}. Hook handlers must accept exactly one positional parameter (the event)."
54
53
  )
55
54
  required_kw = [
56
- p for p in sig.parameters.values()
55
+ p
56
+ for p in sig.parameters.values()
57
57
  if p.kind == inspect.Parameter.KEYWORD_ONLY and p.default is inspect.Parameter.empty
58
58
  ]
59
59
  if required_kw:
@@ -99,9 +99,7 @@ def is_gitignored(path_str: str) -> bool:
99
99
  if not _state.gitignore_patterns:
100
100
  return False
101
101
  p = Path(path_str)
102
- return any(
103
- fnmatch(p.name, pat) or any(fnmatch(part, pat) for part in p.parts) for pat in _state.gitignore_patterns
104
- )
102
+ return any(fnmatch(p.name, pat) or any(fnmatch(part, pat) for part in p.parts) for pat in _state.gitignore_patterns)
105
103
 
106
104
 
107
105
  def hook(
@@ -109,7 +107,6 @@ def hook(
109
107
  *,
110
108
  only_if: Sequence[TCondition] = (),
111
109
  skip_if: Sequence[TCondition] = (),
112
-
113
110
  message: str | None = None,
114
111
  block: bool = False,
115
112
  respect_gitignore: bool = True,
@@ -274,5 +271,3 @@ def get_matching_hooks(evt: BaseHookEvent) -> list[RegisteredHook]:
274
271
  or not is_gitignored(str(evt.file))
275
272
  )
276
273
  ]
277
-
278
-
@@ -13,6 +13,7 @@ class MessageClassifier(Protocol):
13
13
 
14
14
  def __call__(self, msg: TranscriptMessage) -> bool: ...
15
15
 
16
+
16
17
  CLASSIFIER_MODULES = ("droid", "conductor", "native")
17
18
 
18
19
 
@@ -3,6 +3,8 @@ from __future__ import annotations
3
3
  import importlib.resources
4
4
  import json
5
5
  import os
6
+ import shutil
7
+ import subprocess
6
8
  import sys
7
9
  from collections import defaultdict
8
10
  from dataclasses import dataclass
@@ -22,6 +24,8 @@ from captain_hook.types import Event
22
24
 
23
25
  DIST_NAME = "capt-hook"
24
26
  EVENT_NAMES = ", ".join(n for e in Event if (n := e.name))
27
+ MARKETPLACE = {"captain-hook": {"source": {"source": "github", "repo": "yasyf/captain-hook"}}}
28
+ PLUGIN_ID = "captain-hook@captain-hook"
25
29
 
26
30
 
27
31
  @dataclass(frozen=True, slots=True)
@@ -40,6 +44,68 @@ def example_hook_source() -> str:
40
44
  return (importlib.resources.files("captain_hook") / "templates" / "example_hook.py.tmpl").read_text()
41
45
 
42
46
 
47
+ def install_skills(root: Path, *, force: bool = False) -> dict[str, str]:
48
+ """Copy the bundled Claude Code skills into ``root/.claude/skills``.
49
+
50
+ Args:
51
+ root: Project root receiving the skills.
52
+ force: Replace existing skill directories wholesale instead of skipping them.
53
+
54
+ Returns:
55
+ Per-skill status of ``"installed"``, ``"replaced"``, or ``"skipped"``.
56
+ """
57
+ dest_root = root / ".claude" / "skills"
58
+ summary: dict[str, str] = {}
59
+ with importlib.resources.as_file(importlib.resources.files("captain_hook") / "skills") as src_root:
60
+ for skill in sorted(p for p in src_root.iterdir() if p.is_dir()):
61
+ dest = dest_root / skill.name
62
+ if dest.exists() and not force:
63
+ summary[skill.name] = "skipped"
64
+ continue
65
+ if dest.exists():
66
+ shutil.rmtree(dest)
67
+ summary[skill.name] = "replaced"
68
+ else:
69
+ summary[skill.name] = "installed"
70
+ shutil.copytree(skill, dest)
71
+ return summary
72
+
73
+
74
+ def register_marketplace(root: Path) -> None:
75
+ """Enable the captain-hook plugin marketplace in ``root/.claude/settings.local.json``.
76
+
77
+ Merges ``extraKnownMarketplaces`` and ``enabledPlugins`` entries into the
78
+ existing settings so the bundled skills track the repository as a plugin.
79
+ """
80
+ settings_path = root / ".claude" / "settings.local.json"
81
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
82
+ existing = json.loads(settings_path.read_text()) if settings_path.exists() else {}
83
+ merged = existing | {
84
+ "extraKnownMarketplaces": existing.get("extraKnownMarketplaces", {}) | MARKETPLACE,
85
+ "enabledPlugins": existing.get("enabledPlugins", {}) | {PLUGIN_ID: True},
86
+ }
87
+ settings_path.write_text(json.dumps(merged, indent=2) + "\n")
88
+
89
+
90
+ def maybe_launch_bootstrap(root: Path) -> bool:
91
+ """Offer to launch Claude with the ``bootstrapping-hooks`` skill after ``init``.
92
+
93
+ Only fires in an interactive session with the ``claude`` CLI on PATH; CI and
94
+ scripted runs skip the prompt entirely. On acceptance, the captain-hook plugin
95
+ marketplace is registered in ``.claude/settings.local.json`` before launching.
96
+
97
+ Returns:
98
+ Whether Claude was launched.
99
+ """
100
+ if not (sys.stdin.isatty() and shutil.which("claude")):
101
+ return False
102
+ if not click.confirm("Bootstrap hooks now? (launches Claude with the bootstrapping-hooks skill)", default=True):
103
+ return False
104
+ register_marketplace(root)
105
+ subprocess.run(["claude", "/bootstrapping-hooks"], cwd=root, check=False)
106
+ return True
107
+
108
+
43
109
  def generate_settings(hooks_dir: str = ".claude/hooks", from_source: str = DIST_NAME) -> dict[str, Any]:
44
110
  events_by_async: defaultdict[bool, set[str]] = defaultdict(set)
45
111
  for entry in _state.hooks:
@@ -54,12 +120,7 @@ def generate_settings(hooks_dir: str = ".claude/hooks", from_source: str = DIST_
54
120
  return [
55
121
  {
56
122
  "type": "command",
57
- "command": (
58
- f"uvx{from_flag} capt-hook"
59
- f"{hooks_flag}"
60
- f" run {event}"
61
- f"{' --async' if is_async else ''}"
62
- ),
123
+ "command": (f"uvx{from_flag} capt-hook{hooks_flag} run {event}{' --async' if is_async else ''}"),
63
124
  }
64
125
  | ({"async": True} if is_async else {})
65
126
  for is_async, events in sorted(events_by_async.items())
@@ -172,6 +233,8 @@ def init_project(root: Path) -> None:
172
233
  merged, summary = merge_init_settings(".claude/hooks", settings_path)
173
234
  settings_path.write_text(json.dumps(merged, indent=2) + "\n")
174
235
 
236
+ skills_summary = install_skills(root)
237
+
175
238
  print(f"Scaffolded {example.relative_to(root)} + {settings_path.relative_to(root)}.")
176
239
  print()
177
240
  print(f"{settings_path.relative_to(root)}:")
@@ -184,11 +247,20 @@ def init_project(root: Path) -> None:
184
247
  if unchanged:
185
248
  print(f" unchanged: {', '.join(unchanged)} (already present)")
186
249
  print()
250
+ print(".claude/skills/:")
251
+ for name in (n for n, status in skills_summary.items() if status == "installed"):
252
+ print(f" + installed {name}")
253
+ if skipped := [n for n, status in skills_summary.items() if status == "skipped"]:
254
+ print(f" unchanged: {', '.join(skipped)} (already present; capt-hook skills install --force to refresh)")
255
+ print()
187
256
  print("Next:")
188
- print(" 1. Read the quickstart: docs/getting-started/quickstart.md")
257
+ print(" 1. Read the quickstart: https://yasyf.github.io/captain-hook/")
189
258
  print(" 2. Edit example.py or add new files under .claude/hooks/")
190
259
  print(" 3. capt-hook test # verify inline tests")
191
260
  print(" 4. capt-hook generate-settings # rewire after adding events")
261
+ print(" 5. /bootstrapping-hooks in Claude # mine hooks from this repo's conventions")
262
+ print()
263
+ maybe_launch_bootstrap(root)
192
264
 
193
265
 
194
266
  def show_logs(session: str | None = None, tail: int | None = None) -> None:
@@ -251,12 +323,16 @@ def run_tests(json_output: bool = False) -> None:
251
323
  passed = failed = errors = skipped = 0
252
324
  for name, status, _ok, detail in results:
253
325
  if json_output:
254
- print(json.dumps({
255
- "id": name,
256
- "status": status,
257
- "expected": expected_by_id.get(name, ""),
258
- "reason": detail,
259
- }))
326
+ print(
327
+ json.dumps(
328
+ {
329
+ "id": name,
330
+ "status": status,
331
+ "expected": expected_by_id.get(name, ""),
332
+ "reason": detail,
333
+ }
334
+ )
335
+ )
260
336
  match status:
261
337
  case "pass":
262
338
  passed += 1
@@ -300,10 +376,7 @@ def cli(ctx: click.Context, hooks: str | None, root_path: str | None) -> None:
300
376
 
301
377
  @cli.command(
302
378
  short_help="Dispatch a hook event (reads JSON from stdin, writes JSON to stdout)",
303
- help=(
304
- "Dispatch a hook event (reads JSON from stdin, writes JSON to stdout).\n\n"
305
- f"EVENT is one of: {EVENT_NAMES}."
306
- ),
379
+ help=(f"Dispatch a hook event (reads JSON from stdin, writes JSON to stdout).\n\nEVENT is one of: {EVENT_NAMES}."),
307
380
  )
308
381
  @click.argument("event")
309
382
  @click.option("--async", "async_", is_flag=True, default=False, help="Run async hooks only")
@@ -345,7 +418,7 @@ def test(state: CliState, json_output: bool) -> None:
345
418
  @cli.command()
346
419
  @click.pass_obj
347
420
  def init(state: CliState) -> None:
348
- """Scaffold hooks directory, bin script, and settings."""
421
+ """Scaffold the hooks directory, install bundled skills, and wire settings."""
349
422
  init_project(state.root)
350
423
 
351
424
 
@@ -357,4 +430,18 @@ def logs(session: str | None, tail: int | None) -> None:
357
430
  show_logs(session=session, tail=tail)
358
431
 
359
432
 
433
+ @cli.group()
434
+ def skills() -> None:
435
+ """Manage the bundled Claude Code skills."""
436
+
437
+
438
+ @skills.command(name="install")
439
+ @click.option("--force", is_flag=True, default=False, help="Replace skills that already exist in .claude/skills")
440
+ @click.pass_obj
441
+ def skills_install(state: CliState, force: bool) -> None:
442
+ """Copy the bundled skills into .claude/skills/."""
443
+ for name, status in install_skills(state.root, force=force).items():
444
+ click.echo(f" {status} {name}")
445
+
446
+
360
447
  main = cli
@@ -337,9 +337,7 @@ class CommandLineQuery:
337
337
  ``True`` if any command has a file redirect or the parts are joined
338
338
  by a pipe (``|``) operator.
339
339
  """
340
- return any(cmd.redirects for cmd in self.line.commands) or any(
341
- op == "|" for _, op in self.line.parts if op
342
- )
340
+ return any(cmd.redirects for cmd in self.line.commands) or any(op == "|" for _, op in self.line.parts if op)
343
341
 
344
342
  def contains_token(self, token: str) -> bool:
345
343
  """Return whether ``token`` appears as a whole argv element in any command.
@@ -1,4 +1,5 @@
1
1
  """Condition evaluation: checks ``TCondition`` instances against the current event."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import re
@@ -32,10 +33,7 @@ if TYPE_CHECKING:
32
33
 
33
34
 
34
35
  def has_completion_notification(t: Transcript, tool_use_id: str, after_idx: int) -> bool:
35
- return any(
36
- (n := m.notification) and n.tool_use_id == tool_use_id
37
- for m in t.messages[after_idx + 1 :]
38
- )
36
+ return any((n := m.notification) and n.tool_use_id == tool_use_id for m in t.messages[after_idx + 1 :])
39
37
 
40
38
 
41
39
  def waiting_tool_names(evt: BaseHookEvent) -> set[str]:
@@ -109,15 +107,16 @@ def check_condition(c: TCondition, evt: BaseHookEvent) -> bool:
109
107
  case UsedSkill(name, subagents):
110
108
  return bool(evt.ctx.transcript) and evt.ctx.transcript.has_skill(*name.split("|"), subagents=subagents)
111
109
  case ReadFile(patterns, subagents):
112
- return bool(evt.ctx.transcript) and any(evt.ctx.transcript.has_read(p, subagents=subagents) for p in patterns)
110
+ return bool(evt.ctx.transcript) and any(
111
+ evt.ctx.transcript.has_read(p, subagents=subagents) for p in patterns
112
+ )
113
113
  case TouchedFile(patterns, subagents):
114
114
  return bool(evt.ctx.transcript) and evt.ctx.transcript.has_edit_to(*patterns, subagents=subagents)
115
115
  case RanCommand(pattern, subagents):
116
116
  return bool(evt.ctx.transcript) and evt.ctx.transcript.has_command(pattern, subagents=subagents)
117
117
  case InPlanMode():
118
118
  return evt.permission_mode == "plan" or (
119
- bool(t := evt.ctx.transcript)
120
- and t.count_tools("EnterPlanMode") > t.count_tools("ExitPlanMode")
119
+ bool(t := evt.ctx.transcript) and t.count_tools("EnterPlanMode") > t.count_tools("ExitPlanMode")
121
120
  )
122
121
  case Waiting():
123
122
  return is_waiting(evt)
@@ -13,7 +13,7 @@ from pydantic import BaseModel
13
13
  from pydantic_settings import BaseSettings
14
14
 
15
15
  from captain_hook.llm import CodexBackend, LlmBackend, LlmBackends, TModel, TSpecialty
16
- from captain_hook.prompt import PromptMessage
16
+ from captain_hook.prompt import Prompt
17
17
  from captain_hook.session import SessionStore
18
18
 
19
19
  if TYPE_CHECKING:
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
22
22
 
23
23
  @dataclass
24
24
  class HookContext:
25
- """Runtime context injected into every hook event, providing session state, transcript, settings, and LLM/CLI helpers."""
25
+ """Runtime context injected into every hook event: session state, transcript, settings, and LLM/CLI helpers."""
26
26
 
27
27
  session: SessionStore
28
28
  transcript: Transcript
@@ -119,7 +119,7 @@ class HookContext:
119
119
 
120
120
  def call_llm(
121
121
  self,
122
- template: str | PromptMessage,
122
+ template: str | Prompt,
123
123
  *args: Any,
124
124
  specialty: TSpecialty = "general",
125
125
  model: TModel = "small",
@@ -129,7 +129,7 @@ class HookContext:
129
129
  response_model: type[BaseModel] | None = None,
130
130
  **kwargs: Any,
131
131
  ) -> str | BaseModel:
132
- if isinstance(template, PromptMessage):
132
+ if isinstance(template, Prompt):
133
133
  prompt = str(template)
134
134
  if transcript:
135
135
  prompt = f"{self.transcript}\n\n<task>\n{prompt}\n</task>"
@@ -138,9 +138,7 @@ class HookContext:
138
138
  template = f"{{transcript}}\n\n<task>\n{template}\n</task>"
139
139
  prompt = template.format(*args, **kwargs, transcript=self.transcript)
140
140
  schema = (
141
- json.dumps(response_model.model_json_schema() | {"additionalProperties": False})
142
- if response_model
143
- else None
141
+ json.dumps(response_model.model_json_schema() | {"additionalProperties": False}) if response_model else None
144
142
  )
145
143
  backend = LlmBackends.for_specialty(specialty)
146
144
  schema_path = self.resolve_schema_path(backend, schema)
@@ -1,4 +1,5 @@
1
- """Hook dispatch: select matching hooks, run their handlers, and translate ``HookResult`` into the Claude Code stdout envelope."""
1
+ """Select matching hooks, run their handlers, and translate ``HookResult`` into the Claude Code stdout envelope."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  from pathlib import Path
@@ -84,9 +84,7 @@ class PathMatcher:
84
84
  return self.matches(path)
85
85
 
86
86
 
87
- def categorize_files(
88
- paths: Iterable[str | Path], *, lang: str = "py"
89
- ) -> tuple[list[str], list[str], list[str]]:
87
+ def categorize_files(paths: Iterable[str | Path], *, lang: str = "py") -> tuple[list[str], list[str], list[str]]:
90
88
  """Split paths into source, test, and skipped buckets for a language.
91
89
 
92
90
  A path that does not match the ``lang`` globs is skipped; otherwise it is
@@ -1,4 +1,5 @@
1
1
  """LLM backend abstractions for captain-hook's ``call_llm`` helper."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  from captain_hook.llm.backends import ClaudeBackend as ClaudeBackend
@@ -3,6 +3,7 @@
3
3
  Each backend maps the framework's abstract :data:`TModel` sizes to provider
4
4
  model names and knows how to build the CLI invocation and parse its response.
5
5
  """
6
+
6
7
  from __future__ import annotations
7
8
 
8
9
  import json
@@ -107,8 +108,10 @@ class ClaudeBackend(LlmBackend):
107
108
  ["--permission-mode", "auto", "--max-budget-usd", "1"]
108
109
  if agent
109
110
  else [
110
- "--system-prompt", "",
111
- "--setting-sources", "",
111
+ "--system-prompt",
112
+ "",
113
+ "--setting-sources",
114
+ "",
112
115
  "--strict-mcp-config",
113
116
  ]
114
117
  ),
@@ -1,4 +1,5 @@
1
1
  """Hook discovery: imports a hooks package, loads its ``conf`` module, and registers every hook module."""
2
+
2
3
  from __future__ import annotations
3
4
 
4
5
  import importlib
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from captain_hook.primitives.audit import audit as audit
4
+ from captain_hook.primitives.commands import block_command as block_command
5
+ from captain_hook.primitives.commands import warn_command as warn_command
6
+ from captain_hook.primitives.lint import diff_lint as diff_lint
7
+ from captain_hook.primitives.lint import lint as lint
8
+ from captain_hook.primitives.llm import GateVerdict as GateVerdict
9
+ from captain_hook.primitives.llm import NudgeVerdict as NudgeVerdict
10
+ from captain_hook.primitives.llm import PromptCheckVerdict as PromptCheckVerdict
11
+ from captain_hook.primitives.llm import llm_evaluate as llm_evaluate
12
+ from captain_hook.primitives.llm import llm_gate as llm_gate
13
+ from captain_hook.primitives.llm import llm_nudge as llm_nudge
14
+ from captain_hook.primitives.llm import prompt_check as prompt_check
15
+ from captain_hook.primitives.nudge import gate as gate
16
+ from captain_hook.primitives.nudge import nudge as nudge
@@ -16,6 +16,7 @@ def block_command_pattern(tokens: list[str]) -> str:
16
16
  >>> block_command_pattern(["git", "stash", "*"])
17
17
  'git\\\\s+stash\\\\s+\\\\S+'
18
18
  """
19
+
19
20
  def convert(token: str) -> str:
20
21
  match token:
21
22
  case "*":