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.
- {capt_hook-0.2.0 → capt_hook-0.4.0}/PKG-INFO +15 -5
- {capt_hook-0.2.0 → capt_hook-0.4.0}/README.md +12 -3
- capt_hook-0.4.0/captain_hook/.claude-plugin/plugin.json +10 -0
- capt_hook-0.4.0/captain_hook/__init__.py +257 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/cli.py +177 -67
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/context.py +3 -3
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/primitives/__init__.py +0 -22
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/primitives/llm.py +3 -3
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/prompt.py +10 -11
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/signals/__init__.py +0 -11
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/signals/nlp.py +0 -3
- capt_hook-0.4.0/captain_hook/skills/bootstrapping-hooks/SKILL.md +229 -0
- capt_hook-0.4.0/captain_hook/skills/bootstrapping-hooks/references/capt-hook-api.md +166 -0
- capt_hook-0.4.0/captain_hook/skills/bootstrapping-hooks/references/pattern-catalog.md +404 -0
- capt_hook-0.4.0/captain_hook/skills/bootstrapping-hooks/references/testing-hooks.md +92 -0
- capt_hook-0.4.0/captain_hook/skills/translating-styleguides/SKILL.md +366 -0
- capt_hook-0.4.0/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +136 -0
- capt_hook-0.4.0/captain_hook/skills/translating-styleguides/references/matcher-reference.md +126 -0
- capt_hook-0.4.0/captain_hook/skills/translating-styleguides/references/tier-rubric.md +91 -0
- {capt_hook-0.2.0/captain_hook/styleguide → capt_hook-0.4.0/captain_hook/style}/__init__.py +12 -63
- capt_hook-0.4.0/captain_hook/style/matchers.py +345 -0
- capt_hook-0.4.0/captain_hook/style/types.py +84 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/templates/example_hook.py.tmpl +9 -11
- capt_hook-0.4.0/captain_hook/testing/__init__.py +18 -0
- capt_hook-0.4.0/captain_hook/tests/__init__.py +33 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/tests/helpers.py +28 -50
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/transcript/__init__.py +7 -25
- {capt_hook-0.2.0 → capt_hook-0.4.0}/pyproject.toml +7 -8
- capt_hook-0.2.0/captain_hook/__init__.py +0 -246
- capt_hook-0.2.0/captain_hook/styleguide/query.py +0 -238
- capt_hook-0.2.0/captain_hook/styleguide/types.py +0 -70
- capt_hook-0.2.0/captain_hook/testing/__init__.py +0 -10
- capt_hook-0.2.0/captain_hook/tests/__init__.py +0 -27
- {capt_hook-0.2.0 → capt_hook-0.4.0}/LICENSE +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/__main__.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/app.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/classifiers/__init__.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/classifiers/conductor.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/classifiers/droid.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/classifiers/native.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/command.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/conditions.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/dispatch.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/events.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/file.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/llm/__init__.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/llm/backends.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/loader.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/log.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/primitives/audit.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/primitives/commands.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/primitives/lint.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/primitives/nudge.py +0 -0
- {capt_hook-0.2.0/captain_hook → capt_hook-0.4.0/captain_hook/primitives}/workflow.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/py.typed +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/session.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/settings.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/state.py +0 -0
- {capt_hook-0.2.0/captain_hook/styleguide → capt_hook-0.4.0/captain_hook/style}/scope.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/tasks.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/testing/helpers.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/testing/session_cache.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/testing/types.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/tools.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/transcript/inputs.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/transcript/models.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/types.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/util/__init__.py +0 -0
- {capt_hook-0.2.0 → capt_hook-0.4.0}/captain_hook/util/model_cache.py +0 -0
- {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.
|
|
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
|
|
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
|
[](https://pypi.org/project/capt-hook/)
|
|
48
49
|
[](https://pypi.org/project/capt-hook/)
|
|
49
|
-
[](https://yasyf.github.io/captain-hook/)
|
|
50
51
|
[](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
|
|
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://
|
|
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
|
[](https://pypi.org/project/capt-hook/)
|
|
4
4
|
[](https://pypi.org/project/capt-hook/)
|
|
5
|
-
[](https://yasyf.github.io/captain-hook/)
|
|
6
6
|
[](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
|
|
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://
|
|
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:
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|