capt-hook 0.3.0__tar.gz → 0.4.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 (68) hide show
  1. {capt_hook-0.3.0 → capt_hook-0.4.0}/PKG-INFO +15 -5
  2. {capt_hook-0.3.0 → capt_hook-0.4.0}/README.md +12 -3
  3. capt_hook-0.4.0/captain_hook/.claude-plugin/plugin.json +10 -0
  4. capt_hook-0.4.0/captain_hook/__init__.py +257 -0
  5. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/cli.py +93 -2
  6. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/context.py +3 -3
  7. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/primitives/__init__.py +0 -20
  8. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/primitives/llm.py +3 -3
  9. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/prompt.py +10 -11
  10. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/signals/__init__.py +0 -11
  11. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/signals/nlp.py +0 -3
  12. capt_hook-0.4.0/captain_hook/skills/bootstrapping-hooks/SKILL.md +229 -0
  13. capt_hook-0.4.0/captain_hook/skills/bootstrapping-hooks/references/capt-hook-api.md +166 -0
  14. capt_hook-0.4.0/captain_hook/skills/bootstrapping-hooks/references/pattern-catalog.md +404 -0
  15. capt_hook-0.4.0/captain_hook/skills/bootstrapping-hooks/references/testing-hooks.md +92 -0
  16. capt_hook-0.4.0/captain_hook/skills/translating-styleguides/SKILL.md +366 -0
  17. capt_hook-0.4.0/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +136 -0
  18. capt_hook-0.4.0/captain_hook/skills/translating-styleguides/references/matcher-reference.md +126 -0
  19. capt_hook-0.4.0/captain_hook/skills/translating-styleguides/references/tier-rubric.md +91 -0
  20. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/style/__init__.py +4 -13
  21. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/style/matchers.py +0 -27
  22. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/templates/example_hook.py.tmpl +9 -11
  23. capt_hook-0.4.0/captain_hook/testing/__init__.py +18 -0
  24. capt_hook-0.4.0/captain_hook/tests/__init__.py +33 -0
  25. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/tests/helpers.py +28 -50
  26. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/transcript/__init__.py +7 -25
  27. {capt_hook-0.3.0 → capt_hook-0.4.0}/pyproject.toml +6 -8
  28. capt_hook-0.3.0/captain_hook/__init__.py +0 -240
  29. capt_hook-0.3.0/captain_hook/testing/__init__.py +0 -10
  30. capt_hook-0.3.0/captain_hook/tests/__init__.py +0 -27
  31. {capt_hook-0.3.0 → capt_hook-0.4.0}/LICENSE +0 -0
  32. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/__main__.py +0 -0
  33. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/app.py +0 -0
  34. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/classifiers/__init__.py +0 -0
  35. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/classifiers/conductor.py +0 -0
  36. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/classifiers/droid.py +0 -0
  37. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/classifiers/native.py +0 -0
  38. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/command.py +0 -0
  39. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/conditions.py +0 -0
  40. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/dispatch.py +0 -0
  41. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/events.py +0 -0
  42. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/file.py +0 -0
  43. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/llm/__init__.py +0 -0
  44. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/llm/backends.py +0 -0
  45. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/loader.py +0 -0
  46. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/log.py +0 -0
  47. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/primitives/audit.py +0 -0
  48. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/primitives/commands.py +0 -0
  49. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/primitives/lint.py +0 -0
  50. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/primitives/nudge.py +0 -0
  51. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/primitives/workflow.py +0 -0
  52. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/py.typed +0 -0
  53. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/session.py +0 -0
  54. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/settings.py +0 -0
  55. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/state.py +0 -0
  56. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/style/scope.py +0 -0
  57. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/style/types.py +0 -0
  58. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/tasks.py +0 -0
  59. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/testing/helpers.py +0 -0
  60. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/testing/session_cache.py +0 -0
  61. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/testing/types.py +0 -0
  62. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/tools.py +0 -0
  63. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/transcript/inputs.py +0 -0
  64. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/transcript/models.py +0 -0
  65. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/types.py +0 -0
  66. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/util/__init__.py +0 -0
  67. {capt_hook-0.3.0 → capt_hook-0.4.0}/captain_hook/util/model_cache.py +0 -0
  68. {capt_hook-0.3.0 → capt_hook-0.4.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.4.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,257 @@
1
+ from __future__ import annotations
2
+
3
+ from captain_hook import style as style
4
+ from captain_hook.app import hook as hook
5
+ from captain_hook.app import on as on
6
+ from captain_hook.app import register as register
7
+ from captain_hook.command import Command as Command
8
+ from captain_hook.command import CommandLine as CommandLine
9
+ from captain_hook.command import Redirect as Redirect
10
+ from captain_hook.context import HookContext as HookContext
11
+ from captain_hook.events import (
12
+ BaseHookEvent as BaseHookEvent,
13
+ )
14
+ from captain_hook.events import (
15
+ NotificationEvent as NotificationEvent,
16
+ )
17
+ from captain_hook.events import (
18
+ PostToolUseEvent as PostToolUseEvent,
19
+ )
20
+ from captain_hook.events import (
21
+ PostToolUseFailureEvent as PostToolUseFailureEvent,
22
+ )
23
+ from captain_hook.events import (
24
+ PreCompactEvent as PreCompactEvent,
25
+ )
26
+ from captain_hook.events import (
27
+ PreToolUseEvent as PreToolUseEvent,
28
+ )
29
+ from captain_hook.events import (
30
+ StopEvent as StopEvent,
31
+ )
32
+ from captain_hook.events import (
33
+ SubagentStartEvent as SubagentStartEvent,
34
+ )
35
+ from captain_hook.events import (
36
+ SubagentStopEvent as SubagentStopEvent,
37
+ )
38
+ from captain_hook.events import (
39
+ ToolHookEvent as ToolHookEvent,
40
+ )
41
+ from captain_hook.events import (
42
+ UserPromptSubmitEvent as UserPromptSubmitEvent,
43
+ )
44
+ from captain_hook.file import File as File
45
+ from captain_hook.primitives import (
46
+ GateVerdict as GateVerdict,
47
+ )
48
+ from captain_hook.primitives import (
49
+ NudgeVerdict as NudgeVerdict,
50
+ )
51
+ from captain_hook.primitives import (
52
+ PromptCheckVerdict as PromptCheckVerdict,
53
+ )
54
+ from captain_hook.primitives import (
55
+ block_command as block_command,
56
+ )
57
+ from captain_hook.primitives import (
58
+ gate as gate,
59
+ )
60
+ from captain_hook.primitives import (
61
+ llm_evaluate as llm_evaluate,
62
+ )
63
+ from captain_hook.primitives import (
64
+ llm_gate as llm_gate,
65
+ )
66
+ from captain_hook.primitives import (
67
+ llm_nudge as llm_nudge,
68
+ )
69
+ from captain_hook.primitives import (
70
+ prompt_check as prompt_check,
71
+ )
72
+ from captain_hook.primitives import (
73
+ warn_command as warn_command,
74
+ )
75
+
76
+ # audit/lint/nudge are imported from their defining modules, not the
77
+ # primitives package: the package attribute and the submodule share a name,
78
+ # and an alias targeting captain_hook.primitives.<name> resolves to the
79
+ # module under static analysis (griffe), shadowing the function.
80
+ from captain_hook.primitives.audit import audit as audit
81
+ from captain_hook.primitives.lint import lint as lint
82
+ from captain_hook.primitives.nudge import nudge as nudge
83
+ from captain_hook.primitives.workflow import (
84
+ Artifact as Artifact,
85
+ )
86
+ from captain_hook.primitives.workflow import (
87
+ Step as Step,
88
+ )
89
+ from captain_hook.primitives.workflow import (
90
+ Workflow as Workflow,
91
+ )
92
+ from captain_hook.primitives.workflow import (
93
+ text_matches as text_matches,
94
+ )
95
+ from captain_hook.primitives.workflow import (
96
+ workflow as workflow,
97
+ )
98
+ from captain_hook.prompt import Prompt as Prompt
99
+ from captain_hook.session import SessionSlot as SessionSlot
100
+ from captain_hook.session import SessionStore as SessionStore
101
+ from captain_hook.session import session_state as session_state
102
+ from captain_hook.settings import HooksSettings as HooksSettings
103
+ from captain_hook.settings import build_settings as build_settings
104
+ from captain_hook.signals.nlp import Clause as Clause
105
+ from captain_hook.signals.nlp import NlpSignal as NlpSignal
106
+ from captain_hook.signals.nlp import Phrase as Phrase
107
+ from captain_hook.state import (
108
+ HookState as HookState,
109
+ )
110
+ from captain_hook.state import (
111
+ PrimitiveState as PrimitiveState,
112
+ )
113
+ from captain_hook.state import (
114
+ workflow_state as workflow_state,
115
+ )
116
+ from captain_hook.tasks import Task as Task
117
+ from captain_hook.tasks import Tasks as Tasks
118
+ from captain_hook.testing import (
119
+ Allow as Allow,
120
+ )
121
+ from captain_hook.testing import (
122
+ Block as Block,
123
+ )
124
+ from captain_hook.testing import (
125
+ InlineTests as InlineTests,
126
+ )
127
+ from captain_hook.testing import (
128
+ Input as Input,
129
+ )
130
+ from captain_hook.testing import (
131
+ TranscriptFixture as TranscriptFixture,
132
+ )
133
+ from captain_hook.testing import (
134
+ Warn as Warn,
135
+ )
136
+ from captain_hook.tools import EditOp as EditOp
137
+ from captain_hook.tools import TaskOp as TaskOp
138
+ from captain_hook.tools import WriteOp as WriteOp
139
+ from captain_hook.transcript import (
140
+ ToolUse as ToolUse,
141
+ )
142
+ from captain_hook.transcript import (
143
+ ToolUseQuery as ToolUseQuery,
144
+ )
145
+ from captain_hook.transcript import (
146
+ ToolUseSequence as ToolUseSequence,
147
+ )
148
+ from captain_hook.transcript import (
149
+ Transcript as Transcript,
150
+ )
151
+ from captain_hook.transcript import (
152
+ TranscriptMessage as TranscriptMessage,
153
+ )
154
+ from captain_hook.transcript import (
155
+ TranscriptSlice as TranscriptSlice,
156
+ )
157
+ from captain_hook.transcript import (
158
+ Turn as Turn,
159
+ )
160
+ from captain_hook.transcript.inputs import (
161
+ AgentInput as AgentInput,
162
+ )
163
+ from captain_hook.transcript.inputs import (
164
+ BashInput as BashInput,
165
+ )
166
+ from captain_hook.transcript.inputs import (
167
+ EditInput as EditInput,
168
+ )
169
+ from captain_hook.transcript.inputs import (
170
+ FileInputBase as FileInputBase,
171
+ )
172
+ from captain_hook.transcript.inputs import (
173
+ GenericInput as GenericInput,
174
+ )
175
+ from captain_hook.transcript.inputs import (
176
+ GlobInput as GlobInput,
177
+ )
178
+ from captain_hook.transcript.inputs import (
179
+ GrepInput as GrepInput,
180
+ )
181
+ from captain_hook.transcript.inputs import (
182
+ InputBase as InputBase,
183
+ )
184
+ from captain_hook.transcript.inputs import (
185
+ ReadInput as ReadInput,
186
+ )
187
+ from captain_hook.transcript.inputs import (
188
+ SkillInput as SkillInput,
189
+ )
190
+ from captain_hook.transcript.inputs import (
191
+ TaskCreateInput as TaskCreateInput,
192
+ )
193
+ from captain_hook.transcript.inputs import (
194
+ TaskUpdateInput as TaskUpdateInput,
195
+ )
196
+ from captain_hook.transcript.inputs import (
197
+ WriteInput as WriteInput,
198
+ )
199
+ from captain_hook.transcript.models import ContentBlock as ContentBlock
200
+ from captain_hook.transcript.models import ToolResult as ToolResult
201
+ from captain_hook.types import (
202
+ Action as Action,
203
+ )
204
+ from captain_hook.types import (
205
+ Agent as Agent,
206
+ )
207
+ from captain_hook.types import (
208
+ Content as Content,
209
+ )
210
+ from captain_hook.types import (
211
+ CustomCondition as CustomCondition,
212
+ )
213
+ from captain_hook.types import (
214
+ Event as Event,
215
+ )
216
+ from captain_hook.types import (
217
+ FilePath as FilePath,
218
+ )
219
+ from captain_hook.types import (
220
+ HookResult as HookResult,
221
+ )
222
+ from captain_hook.types import (
223
+ InPlanMode as InPlanMode,
224
+ )
225
+ from captain_hook.types import (
226
+ RanCommand as RanCommand,
227
+ )
228
+ from captain_hook.types import (
229
+ ReadFile as ReadFile,
230
+ )
231
+ from captain_hook.types import (
232
+ Signal as Signal,
233
+ )
234
+ from captain_hook.types import (
235
+ Signals as Signals,
236
+ )
237
+ from captain_hook.types import (
238
+ SourceEdits as SourceEdits,
239
+ )
240
+ from captain_hook.types import (
241
+ TCondition as TCondition,
242
+ )
243
+ from captain_hook.types import (
244
+ TestFile as TestFile,
245
+ )
246
+ from captain_hook.types import (
247
+ Tool as Tool,
248
+ )
249
+ from captain_hook.types import (
250
+ TouchedFile as TouchedFile,
251
+ )
252
+ from captain_hook.types import (
253
+ UsedSkill as UsedSkill,
254
+ )
255
+ from captain_hook.types import (
256
+ Waiting as Waiting,
257
+ )
@@ -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:
@@ -172,6 +238,8 @@ def init_project(root: Path) -> None:
172
238
  merged, summary = merge_init_settings(".claude/hooks", settings_path)
173
239
  settings_path.write_text(json.dumps(merged, indent=2) + "\n")
174
240
 
241
+ skills_summary = install_skills(root)
242
+
175
243
  print(f"Scaffolded {example.relative_to(root)} + {settings_path.relative_to(root)}.")
176
244
  print()
177
245
  print(f"{settings_path.relative_to(root)}:")
@@ -184,11 +252,20 @@ def init_project(root: Path) -> None:
184
252
  if unchanged:
185
253
  print(f" unchanged: {', '.join(unchanged)} (already present)")
186
254
  print()
255
+ print(".claude/skills/:")
256
+ for name in (n for n, status in skills_summary.items() if status == "installed"):
257
+ print(f" + installed {name}")
258
+ if skipped := [n for n, status in skills_summary.items() if status == "skipped"]:
259
+ print(f" unchanged: {', '.join(skipped)} (already present; capt-hook skills install --force to refresh)")
260
+ print()
187
261
  print("Next:")
188
- print(" 1. Read the quickstart: docs/getting-started/quickstart.md")
262
+ print(" 1. Read the quickstart: https://yasyf.github.io/captain-hook/")
189
263
  print(" 2. Edit example.py or add new files under .claude/hooks/")
190
264
  print(" 3. capt-hook test # verify inline tests")
191
265
  print(" 4. capt-hook generate-settings # rewire after adding events")
266
+ print(" 5. /bootstrapping-hooks in Claude # mine hooks from this repo's conventions")
267
+ print()
268
+ maybe_launch_bootstrap(root)
192
269
 
193
270
 
194
271
  def show_logs(session: str | None = None, tail: int | None = None) -> None:
@@ -345,7 +422,7 @@ def test(state: CliState, json_output: bool) -> None:
345
422
  @cli.command()
346
423
  @click.pass_obj
347
424
  def init(state: CliState) -> None:
348
- """Scaffold hooks directory, bin script, and settings."""
425
+ """Scaffold the hooks directory, install bundled skills, and wire settings."""
349
426
  init_project(state.root)
350
427
 
351
428
 
@@ -357,4 +434,18 @@ def logs(session: str | None, tail: int | None) -> None:
357
434
  show_logs(session=session, tail=tail)
358
435
 
359
436
 
437
+ @cli.group()
438
+ def skills() -> None:
439
+ """Manage the bundled Claude Code skills."""
440
+
441
+
442
+ @skills.command(name="install")
443
+ @click.option("--force", is_flag=True, default=False, help="Replace skills that already exist in .claude/skills")
444
+ @click.pass_obj
445
+ def skills_install(state: CliState, force: bool) -> None:
446
+ """Copy the bundled skills into .claude/skills/."""
447
+ for name, status in install_skills(state.root, force=force).items():
448
+ click.echo(f" {status} {name}")
449
+
450
+
360
451
  main = cli
@@ -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:
@@ -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>"
@@ -1,10 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from captain_hook.primitives.audit import audit as audit
4
- from captain_hook.primitives.audit import session_id_for as session_id_for
5
4
  from captain_hook.primitives.commands import block_command as block_command
6
5
  from captain_hook.primitives.commands import warn_command as warn_command
7
- from captain_hook.primitives.lint import diff_lint as diff_lint
8
6
  from captain_hook.primitives.lint import lint as lint
9
7
  from captain_hook.primitives.llm import (
10
8
  GateVerdict as GateVerdict,
@@ -29,21 +27,3 @@ from captain_hook.primitives.llm import (
29
27
  )
30
28
  from captain_hook.primitives.nudge import gate as gate
31
29
  from captain_hook.primitives.nudge import nudge as nudge
32
-
33
- __all__ = [
34
- "GateVerdict",
35
- "NudgeVerdict",
36
- "PromptCheckVerdict",
37
- "audit",
38
- "block_command",
39
- "diff_lint",
40
- "gate",
41
- "lint",
42
- "llm_evaluate",
43
- "llm_gate",
44
- "llm_nudge",
45
- "nudge",
46
- "prompt_check",
47
- "session_id_for",
48
- "warn_command",
49
- ]
@@ -12,7 +12,7 @@ from pydantic import BaseModel
12
12
  from captain_hook import state
13
13
  from captain_hook.app import on
14
14
  from captain_hook.primitives.audit import session_id_for
15
- from captain_hook.prompt import Prompt, PromptMessage
15
+ from captain_hook.prompt import Prompt, Prompt
16
16
  from captain_hook.state import PrimitiveState, fired_this_turn, hook_name, record_fire
17
17
  from captain_hook.types import (
18
18
  Action,
@@ -335,7 +335,7 @@ def record_prompt_check_failure(
335
335
 
336
336
  def prompt_check(
337
337
  evt: BaseHookEvent,
338
- template: str | PromptMessage,
338
+ template: str | Prompt,
339
339
  fmt: dict[str, Any] | None = None,
340
340
  *,
341
341
  prefix: str,
@@ -349,7 +349,7 @@ def prompt_check(
349
349
  if include_reasoning:
350
350
  reasoning = evt.ctx.t.recent(50).assistant_text() if hasattr(evt.ctx.t, "recent") else ""
351
351
 
352
- base = template if isinstance(template, PromptMessage) else Prompt().system(template.format(**(fmt or {})))
352
+ base = template if isinstance(template, Prompt) else Prompt().system(template.format(**(fmt or {})))
353
353
  built = base.context("agent_reasoning", reasoning or None)
354
354
  prompt_str = str(built)
355
355
 
@@ -21,7 +21,7 @@ def _caller_dir() -> Path:
21
21
 
22
22
 
23
23
  @dataclass(frozen=True, kw_only=True)
24
- class PromptMessage:
24
+ class Prompt:
25
25
  """Fluent builder for structured LLM prompts with system text, XML context sections, and a question.
26
26
 
27
27
  Chain ``.system()``, ``.context(tag, content)``, and ``.ask()`` to build prompts.
@@ -32,38 +32,38 @@ class PromptMessage:
32
32
  contexts: tuple[tuple[str, str], ...] = ()
33
33
  ask_text: str = ""
34
34
 
35
- def system(self, text: str) -> PromptMessage:
36
- return PromptMessage(
35
+ def system(self, text: str) -> Prompt:
36
+ return Prompt(
37
37
  system_text=dedent_text(text),
38
38
  contexts=self.contexts,
39
39
  ask_text=self.ask_text,
40
40
  )
41
41
 
42
- def context(self, tag: str, content: str | None) -> PromptMessage:
42
+ def context(self, tag: str, content: str | None) -> Prompt:
43
43
  if content is None or not (normalized := dedent_text(content)):
44
44
  return self
45
- return PromptMessage(
45
+ return Prompt(
46
46
  system_text=self.system_text,
47
47
  contexts=(*self.contexts, (tag, normalized)),
48
48
  ask_text=self.ask_text,
49
49
  )
50
50
 
51
- def ask(self, text: str) -> PromptMessage:
52
- return PromptMessage(
51
+ def ask(self, text: str) -> Prompt:
52
+ return Prompt(
53
53
  system_text=self.system_text,
54
54
  contexts=self.contexts,
55
55
  ask_text=dedent_text(text),
56
56
  )
57
57
 
58
58
  @classmethod
59
- def from_template(cls, text: str, **vars: object) -> PromptMessage:
59
+ def from_template(cls, text: str, **vars: object) -> Prompt:
60
60
  try:
61
61
  return cls(system_text=textwrap.dedent(text).strip().format_map(vars))
62
62
  except KeyError as exc:
63
63
  raise KeyError(f"template variable {exc.args[0]!r} not supplied") from exc
64
64
 
65
65
  @classmethod
66
- def load(cls, name: str, *, base: str | Path | None = None, **vars: object) -> PromptMessage:
66
+ def load(cls, name: str, *, base: str | Path | None = None, **vars: object) -> Prompt:
67
67
  """Load a prompt from a ``.md`` file and render it via :meth:`from_template`.
68
68
 
69
69
  Resolution searches directories in order, returning the first existing file:
@@ -77,7 +77,7 @@ class PromptMessage:
77
77
  **vars: Template variables substituted into the file via ``str.format_map``.
78
78
 
79
79
  Returns:
80
- A :class:`PromptMessage` whose system text is the rendered file contents.
80
+ A :class:`Prompt` whose system text is the rendered file contents.
81
81
 
82
82
  Raises:
83
83
  FileNotFoundError: If no matching file exists in any searched directory.
@@ -100,4 +100,3 @@ class PromptMessage:
100
100
  return "\n\n".join(parts)
101
101
 
102
102
 
103
- Prompt = PromptMessage
@@ -73,14 +73,3 @@ def resolve_signals(signals: Sequence[Signal | NlpSignal] | Signals | None) -> S
73
73
  if isinstance(signals, Signals):
74
74
  return signals
75
75
  return Signals(patterns=list(signals), threshold=1)
76
-
77
-
78
- __all__ = [
79
- "Signal",
80
- "Signals",
81
- "cite_message",
82
- "extract_signal_context",
83
- "resolve_signals",
84
- "score_signals",
85
- "transcript_texts",
86
- ]
@@ -8,9 +8,6 @@ from typing import TYPE_CHECKING
8
8
  if TYPE_CHECKING:
9
9
  from spacy.tokens import Doc, Span, Token
10
10
 
11
- __all__ = ["Clause", "NlpSignal", "Phrase", "dep_related", "nlp_scan"]
12
-
13
-
14
11
  @dataclass(frozen=True, slots=True, init=False)
15
12
  class Phrase:
16
13
  lemmas: tuple[str, ...]