capt-hook 0.2.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 (70) hide show
  1. {capt_hook-0.2.0 → capt_hook-0.4.0}/PKG-INFO +15 -5
  2. {capt_hook-0.2.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.2.0 → capt_hook-0.4.0}/captain_hook/cli.py +177 -67
  6. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/context.py +3 -3
  7. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/primitives/__init__.py +0 -22
  8. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/primitives/llm.py +3 -3
  9. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/prompt.py +10 -11
  10. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/signals/__init__.py +0 -11
  11. {capt_hook-0.2.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.2.0/captain_hook/styleguide → capt_hook-0.4.0/captain_hook/style}/__init__.py +12 -63
  21. capt_hook-0.4.0/captain_hook/style/matchers.py +345 -0
  22. capt_hook-0.4.0/captain_hook/style/types.py +84 -0
  23. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/templates/example_hook.py.tmpl +9 -11
  24. capt_hook-0.4.0/captain_hook/testing/__init__.py +18 -0
  25. capt_hook-0.4.0/captain_hook/tests/__init__.py +33 -0
  26. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/tests/helpers.py +28 -50
  27. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/transcript/__init__.py +7 -25
  28. {capt_hook-0.2.0 → capt_hook-0.4.0}/pyproject.toml +7 -8
  29. capt_hook-0.2.0/captain_hook/__init__.py +0 -246
  30. capt_hook-0.2.0/captain_hook/styleguide/query.py +0 -238
  31. capt_hook-0.2.0/captain_hook/styleguide/types.py +0 -70
  32. capt_hook-0.2.0/captain_hook/testing/__init__.py +0 -10
  33. capt_hook-0.2.0/captain_hook/tests/__init__.py +0 -27
  34. {capt_hook-0.2.0 → capt_hook-0.4.0}/LICENSE +0 -0
  35. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/__main__.py +0 -0
  36. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/app.py +0 -0
  37. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/classifiers/__init__.py +0 -0
  38. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/classifiers/conductor.py +0 -0
  39. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/classifiers/droid.py +0 -0
  40. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/classifiers/native.py +0 -0
  41. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/command.py +0 -0
  42. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/conditions.py +0 -0
  43. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/dispatch.py +0 -0
  44. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/events.py +0 -0
  45. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/file.py +0 -0
  46. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/llm/__init__.py +0 -0
  47. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/llm/backends.py +0 -0
  48. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/loader.py +0 -0
  49. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/log.py +0 -0
  50. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/primitives/audit.py +0 -0
  51. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/primitives/commands.py +0 -0
  52. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/primitives/lint.py +0 -0
  53. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/primitives/nudge.py +0 -0
  54. {capt_hook-0.2.0/captain_hook → capt_hook-0.4.0/captain_hook/primitives}/workflow.py +0 -0
  55. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/py.typed +0 -0
  56. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/session.py +0 -0
  57. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/settings.py +0 -0
  58. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/state.py +0 -0
  59. {capt_hook-0.2.0/captain_hook/styleguide → capt_hook-0.4.0/captain_hook/style}/scope.py +0 -0
  60. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/tasks.py +0 -0
  61. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/testing/helpers.py +0 -0
  62. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/testing/session_cache.py +0 -0
  63. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/testing/types.py +0 -0
  64. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/tools.py +0 -0
  65. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/transcript/inputs.py +0 -0
  66. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/transcript/models.py +0 -0
  67. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/types.py +0 -0
  68. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/util/__init__.py +0 -0
  69. {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/util/model_cache.py +0 -0
  70. {capt_hook-0.2.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.2.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
+ )
@@ -1,14 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
- import argparse
4
3
  import importlib.resources
5
4
  import json
6
5
  import os
6
+ import shutil
7
+ import subprocess
7
8
  import sys
8
9
  from collections import defaultdict
10
+ from dataclasses import dataclass
9
11
  from pathlib import Path
10
12
  from typing import Any
11
13
 
14
+ import click
15
+
12
16
  from captain_hook.app import _state, load_gitignore, reset
13
17
  from captain_hook.context import HookContext
14
18
  from captain_hook.dispatch import dispatch
@@ -19,6 +23,20 @@ from captain_hook.transcript import Transcript
19
23
  from captain_hook.types import Event
20
24
 
21
25
  DIST_NAME = "capt-hook"
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"
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class CliState:
33
+ root: Path
34
+ hooks: str
35
+
36
+ def discover(self) -> None:
37
+ reset()
38
+ load_gitignore(self.root)
39
+ discover_hooks(self.hooks)
22
40
 
23
41
 
24
42
  def example_hook_source() -> str:
@@ -26,6 +44,68 @@ def example_hook_source() -> str:
26
44
  return (importlib.resources.files("captain_hook") / "templates" / "example_hook.py.tmpl").read_text()
27
45
 
28
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
+
29
109
  def generate_settings(hooks_dir: str = ".claude/hooks", from_source: str = DIST_NAME) -> dict[str, Any]:
30
110
  events_by_async: defaultdict[bool, set[str]] = defaultdict(set)
31
111
  for entry in _state.hooks:
@@ -158,6 +238,8 @@ def init_project(root: Path) -> None:
158
238
  merged, summary = merge_init_settings(".claude/hooks", settings_path)
159
239
  settings_path.write_text(json.dumps(merged, indent=2) + "\n")
160
240
 
241
+ skills_summary = install_skills(root)
242
+
161
243
  print(f"Scaffolded {example.relative_to(root)} + {settings_path.relative_to(root)}.")
162
244
  print()
163
245
  print(f"{settings_path.relative_to(root)}:")
@@ -170,11 +252,20 @@ def init_project(root: Path) -> None:
170
252
  if unchanged:
171
253
  print(f" unchanged: {', '.join(unchanged)} (already present)")
172
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()
173
261
  print("Next:")
174
- print(" 1. Read the quickstart: docs/getting-started/quickstart.md")
262
+ print(" 1. Read the quickstart: https://yasyf.github.io/captain-hook/")
175
263
  print(" 2. Edit example.py or add new files under .claude/hooks/")
176
264
  print(" 3. capt-hook test # verify inline tests")
177
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)
178
269
 
179
270
 
180
271
  def show_logs(session: str | None = None, tail: int | None = None) -> None:
@@ -212,41 +303,6 @@ def show_logs(session: str | None = None, tail: int | None = None) -> None:
212
303
  print("\n".join(lines[-tail:] if tail else lines))
213
304
 
214
305
 
215
- def build_parser() -> argparse.ArgumentParser:
216
- parser = argparse.ArgumentParser(
217
- prog="capt-hook",
218
- description="Captain Hook — declarative hook framework for Claude Code lifecycle events.",
219
- )
220
- parser.add_argument(
221
- "--hooks",
222
- default=None,
223
- help="Path to hooks package directory (default: $CLAUDE_PROJECT_DIR/.claude/hooks)",
224
- )
225
- parser.add_argument("--root", default=None, help="Project root for gitignore and session resolution")
226
- sub = parser.add_subparsers(dest="command", required=True)
227
-
228
- run_parser = sub.add_parser("run", help="Dispatch a hook event (reads JSON from stdin, writes JSON to stdout)")
229
- run_parser.add_argument("event", help=f"Event type: {', '.join(n for e in Event if (n := e.name))}")
230
- run_parser.add_argument("--async", dest="async_", action="store_true", default=False, help="Run async hooks only")
231
-
232
- settings_parser = sub.add_parser(
233
- "generate-settings", help="Generate Claude Code settings JSON for .claude/settings.local.json"
234
- )
235
- settings_parser.add_argument("--hooks-dir", default=".claude/hooks", help="Hooks directory relative to project root")
236
- settings_parser.add_argument("--no-merge", action="store_true", help="Output standalone JSON instead of merging")
237
- settings_parser.add_argument("--from", dest="from_source", default=DIST_NAME, help=f"Package source for uvx --from (local path or PyPI spec, default: {DIST_NAME})")
238
-
239
- test_parser = sub.add_parser("test", help="Run inline tests from all registered hooks")
240
- test_parser.add_argument("--json", dest="json_output", action="store_true", help="Emit one JSON record per test (CI mode)")
241
- sub.add_parser("init", help="Scaffold hooks directory, bin script, and settings")
242
-
243
- logs_parser = sub.add_parser("logs", help="View a recent captain-hook session log")
244
- logs_parser.add_argument("--session", default=None, help="Session id or transcript path (hashed) to view")
245
- logs_parser.add_argument("--tail", type=int, default=None, help="Show only the last N lines")
246
-
247
- return parser
248
-
249
-
250
306
  def expected_kinds_from_state() -> dict[str, str]:
251
307
  out: dict[str, str] = {}
252
308
  for entry in _state.hooks:
@@ -305,37 +361,91 @@ def run_tests(json_output: bool = False) -> None:
305
361
  sys.exit(1)
306
362
 
307
363
 
308
- def main() -> None:
309
- parser = build_parser()
310
- args = parser.parse_args()
364
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
365
+ @click.option(
366
+ "--hooks",
367
+ default=None,
368
+ help="Path to hooks package directory (default: $CLAUDE_PROJECT_DIR/.claude/hooks)",
369
+ )
370
+ @click.option("--root", "root_path", default=None, help="Project root for gitignore and session resolution")
371
+ @click.pass_context
372
+ def cli(ctx: click.Context, hooks: str | None, root_path: str | None) -> None:
373
+ """Captain Hook — declarative hook framework for Claude Code lifecycle events."""
374
+ root = Path(root_path) if root_path else Path(env) if (env := os.environ.get("CLAUDE_PROJECT_DIR")) else Path.cwd()
375
+ ctx.obj = CliState(root=root, hooks=hooks or str(root / ".claude" / "hooks"))
376
+
377
+
378
+ @cli.command(
379
+ short_help="Dispatch a hook event (reads JSON from stdin, writes JSON to stdout)",
380
+ help=(
381
+ "Dispatch a hook event (reads JSON from stdin, writes JSON to stdout).\n\n"
382
+ f"EVENT is one of: {EVENT_NAMES}."
383
+ ),
384
+ )
385
+ @click.argument("event")
386
+ @click.option("--async", "async_", is_flag=True, default=False, help="Run async hooks only")
387
+ @click.pass_obj
388
+ def run(state: CliState, event: str, async_: bool) -> None:
389
+ state.discover()
390
+ run_event(event, async_=async_, root=state.root)
391
+
392
+
393
+ @cli.command(name="generate-settings")
394
+ @click.option("--hooks-dir", default=".claude/hooks", help="Hooks directory relative to project root")
395
+ @click.option("--no-merge", is_flag=True, default=False, help="Output standalone JSON instead of merging")
396
+ @click.option(
397
+ "--from",
398
+ "from_source",
399
+ default=DIST_NAME,
400
+ help=f"Package source for uvx --from (local path or PyPI spec, default: {DIST_NAME})",
401
+ )
402
+ @click.pass_obj
403
+ def generate_settings_cmd(state: CliState, hooks_dir: str, no_merge: bool, from_source: str) -> None:
404
+ """Generate Claude Code settings JSON for .claude/settings.local.json."""
405
+ state.discover()
406
+ if no_merge:
407
+ click.echo(generate_settings_json(hooks_dir, from_source=from_source))
408
+ else:
409
+ settings_path = state.root / ".claude" / "settings.local.json"
410
+ click.echo(json.dumps(merge_settings(hooks_dir, settings_path, from_source=from_source), indent=2))
311
411
 
312
- project_dir = os.environ.get("CLAUDE_PROJECT_DIR")
313
- root = Path(args.root) if args.root else Path(project_dir) if project_dir else Path.cwd()
314
- hooks = args.hooks or str(root / ".claude" / "hooks")
315
412
 
316
- if args.command == "init":
317
- init_project(root)
318
- return
413
+ @cli.command()
414
+ @click.option("--json", "json_output", is_flag=True, default=False, help="Emit one JSON record per test (CI mode)")
415
+ @click.pass_obj
416
+ def test(state: CliState, json_output: bool) -> None:
417
+ """Run inline tests from all registered hooks."""
418
+ state.discover()
419
+ run_tests(json_output=json_output)
319
420
 
320
- if args.command == "logs":
321
- show_logs(session=args.session, tail=args.tail)
322
- return
323
421
 
324
- reset()
325
- load_gitignore(root)
326
- discover_hooks(hooks)
327
-
328
- match args.command:
329
- case "run":
330
- run_event(args.event, async_=args.async_, root=root)
331
- case "generate-settings":
332
- if args.no_merge:
333
- print(generate_settings_json(args.hooks_dir, from_source=args.from_source))
334
- else:
335
- settings_path = root / ".claude" / "settings.local.json"
336
- merged = merge_settings(args.hooks_dir, settings_path, from_source=args.from_source)
337
- print(json.dumps(merged, indent=2))
338
- case "test":
339
- run_tests(json_output=args.json_output)
340
- case _:
341
- parser.error(f"Unknown command: {args.command}")
422
+ @cli.command()
423
+ @click.pass_obj
424
+ def init(state: CliState) -> None:
425
+ """Scaffold the hooks directory, install bundled skills, and wire settings."""
426
+ init_project(state.root)
427
+
428
+
429
+ @cli.command()
430
+ @click.option("--session", default=None, help="Session id or transcript path (hashed) to view")
431
+ @click.option("--tail", type=int, default=None, help="Show only the last N lines")
432
+ def logs(session: str | None, tail: int | None) -> None:
433
+ """View a recent captain-hook session log."""
434
+ show_logs(session=session, tail=tail)
435
+
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
+
451
+ main = cli