capt-hook 0.2.0__tar.gz → 0.3.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 (58) hide show
  1. {capt_hook-0.2.0 → capt_hook-0.3.0}/PKG-INFO +1 -1
  2. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/__init__.py +5 -11
  3. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/cli.py +85 -66
  4. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/primitives/__init__.py +0 -2
  5. {capt_hook-0.2.0/captain_hook/styleguide → capt_hook-0.3.0/captain_hook/style}/__init__.py +13 -55
  6. capt_hook-0.3.0/captain_hook/style/matchers.py +372 -0
  7. capt_hook-0.3.0/captain_hook/style/types.py +84 -0
  8. {capt_hook-0.2.0 → capt_hook-0.3.0}/pyproject.toml +2 -1
  9. capt_hook-0.2.0/captain_hook/styleguide/query.py +0 -238
  10. capt_hook-0.2.0/captain_hook/styleguide/types.py +0 -70
  11. {capt_hook-0.2.0 → capt_hook-0.3.0}/LICENSE +0 -0
  12. {capt_hook-0.2.0 → capt_hook-0.3.0}/README.md +0 -0
  13. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/__main__.py +0 -0
  14. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/app.py +0 -0
  15. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/classifiers/__init__.py +0 -0
  16. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/classifiers/conductor.py +0 -0
  17. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/classifiers/droid.py +0 -0
  18. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/classifiers/native.py +0 -0
  19. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/command.py +0 -0
  20. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/conditions.py +0 -0
  21. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/context.py +0 -0
  22. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/dispatch.py +0 -0
  23. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/events.py +0 -0
  24. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/file.py +0 -0
  25. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/llm/__init__.py +0 -0
  26. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/llm/backends.py +0 -0
  27. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/loader.py +0 -0
  28. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/log.py +0 -0
  29. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/primitives/audit.py +0 -0
  30. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/primitives/commands.py +0 -0
  31. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/primitives/lint.py +0 -0
  32. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/primitives/llm.py +0 -0
  33. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/primitives/nudge.py +0 -0
  34. {capt_hook-0.2.0/captain_hook → capt_hook-0.3.0/captain_hook/primitives}/workflow.py +0 -0
  35. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/prompt.py +0 -0
  36. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/py.typed +0 -0
  37. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/session.py +0 -0
  38. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/settings.py +0 -0
  39. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/signals/__init__.py +0 -0
  40. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/signals/nlp.py +0 -0
  41. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/state.py +0 -0
  42. {capt_hook-0.2.0/captain_hook/styleguide → capt_hook-0.3.0/captain_hook/style}/scope.py +0 -0
  43. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/tasks.py +0 -0
  44. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
  45. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/testing/__init__.py +0 -0
  46. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/testing/helpers.py +0 -0
  47. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/testing/session_cache.py +0 -0
  48. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/testing/types.py +0 -0
  49. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/tests/__init__.py +0 -0
  50. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/tests/helpers.py +0 -0
  51. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/tools.py +0 -0
  52. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/transcript/__init__.py +0 -0
  53. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/transcript/inputs.py +0 -0
  54. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/transcript/models.py +0 -0
  55. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/types.py +0 -0
  56. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/util/__init__.py +0 -0
  57. {capt_hook-0.2.0 → capt_hook-0.3.0}/captain_hook/util/model_cache.py +0 -0
  58. {capt_hook-0.2.0 → capt_hook-0.3.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.3.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
@@ -22,27 +22,25 @@ from captain_hook.primitives import (
22
22
  GateVerdict,
23
23
  NudgeVerdict,
24
24
  PromptCheckVerdict,
25
- audit,
26
25
  block_command,
27
26
  diff_lint,
28
27
  gate,
29
- lint,
30
28
  llm_gate,
31
29
  llm_nudge,
32
- nudge,
33
30
  prompt_check,
34
31
  session_id_for,
35
- styleguide,
36
32
  warn_command,
37
33
  )
34
+ from captain_hook.primitives.audit import audit
35
+ from captain_hook.primitives.lint import lint
38
36
  from captain_hook.primitives.llm import llm_evaluate
37
+ from captain_hook.primitives.nudge import nudge
39
38
  from captain_hook.prompt import Prompt, PromptMessage
40
39
  from captain_hook.session import SessionSlot, SessionStore, session_state
41
40
  from captain_hook.settings import AutoConf, HooksSettings, build_settings
42
41
  from captain_hook.signals import cite_message, extract_signal_context, resolve_signals, score_signals, transcript_texts
43
42
  from captain_hook.signals.nlp import Clause, NlpSignal, Phrase
44
43
  from captain_hook.state import EchoTracker, HookState, PrimitiveState, workflow_state
45
- from captain_hook.styleguide import StyleDiffRule, StyleRule, Violation
46
44
  from captain_hook.tasks import Task, Tasks
47
45
  from captain_hook.testing import Allow, Block, InlineTests, Input, TranscriptFixture, Warn
48
46
  from captain_hook.tools import EditOp, TaskOp, WriteOp
@@ -101,8 +99,8 @@ from captain_hook.types import (
101
99
  Waiting,
102
100
  )
103
101
  from captain_hook.utils import read_json
104
- from captain_hook.workflow import Artifact, Step, Workflow, text_matches
105
- from captain_hook.workflow import workflow as workflow
102
+ from captain_hook.primitives.workflow import Artifact, Step, Workflow, text_matches
103
+ from captain_hook.primitives.workflow import workflow as workflow
106
104
 
107
105
  __all__ = [
108
106
  # registration
@@ -165,10 +163,6 @@ __all__ = [
165
163
  "prompt_check",
166
164
  "PromptCheckVerdict",
167
165
  "session_id_for",
168
- "StyleDiffRule",
169
- "StyleRule",
170
- "styleguide",
171
- "Violation",
172
166
  "warn_command",
173
167
  # signals
174
168
  "cite_message",
@@ -1,14 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
- import argparse
4
3
  import importlib.resources
5
4
  import json
6
5
  import os
7
6
  import sys
8
7
  from collections import defaultdict
8
+ from dataclasses import dataclass
9
9
  from pathlib import Path
10
10
  from typing import Any
11
11
 
12
+ import click
13
+
12
14
  from captain_hook.app import _state, load_gitignore, reset
13
15
  from captain_hook.context import HookContext
14
16
  from captain_hook.dispatch import dispatch
@@ -19,6 +21,18 @@ from captain_hook.transcript import Transcript
19
21
  from captain_hook.types import Event
20
22
 
21
23
  DIST_NAME = "capt-hook"
24
+ EVENT_NAMES = ", ".join(n for e in Event if (n := e.name))
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class CliState:
29
+ root: Path
30
+ hooks: str
31
+
32
+ def discover(self) -> None:
33
+ reset()
34
+ load_gitignore(self.root)
35
+ discover_hooks(self.hooks)
22
36
 
23
37
 
24
38
  def example_hook_source() -> str:
@@ -212,41 +226,6 @@ def show_logs(session: str | None = None, tail: int | None = None) -> None:
212
226
  print("\n".join(lines[-tail:] if tail else lines))
213
227
 
214
228
 
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
229
  def expected_kinds_from_state() -> dict[str, str]:
251
230
  out: dict[str, str] = {}
252
231
  for entry in _state.hooks:
@@ -305,37 +284,77 @@ def run_tests(json_output: bool = False) -> None:
305
284
  sys.exit(1)
306
285
 
307
286
 
308
- def main() -> None:
309
- parser = build_parser()
310
- args = parser.parse_args()
287
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
288
+ @click.option(
289
+ "--hooks",
290
+ default=None,
291
+ help="Path to hooks package directory (default: $CLAUDE_PROJECT_DIR/.claude/hooks)",
292
+ )
293
+ @click.option("--root", "root_path", default=None, help="Project root for gitignore and session resolution")
294
+ @click.pass_context
295
+ def cli(ctx: click.Context, hooks: str | None, root_path: str | None) -> None:
296
+ """Captain Hook — declarative hook framework for Claude Code lifecycle events."""
297
+ root = Path(root_path) if root_path else Path(env) if (env := os.environ.get("CLAUDE_PROJECT_DIR")) else Path.cwd()
298
+ ctx.obj = CliState(root=root, hooks=hooks or str(root / ".claude" / "hooks"))
299
+
300
+
301
+ @cli.command(
302
+ short_help="Dispatch a hook event (reads JSON from stdin, writes JSON to stdout)",
303
+ help=(
304
+ "Dispatch a hook event (reads JSON from stdin, writes JSON to stdout).\n\n"
305
+ f"EVENT is one of: {EVENT_NAMES}."
306
+ ),
307
+ )
308
+ @click.argument("event")
309
+ @click.option("--async", "async_", is_flag=True, default=False, help="Run async hooks only")
310
+ @click.pass_obj
311
+ def run(state: CliState, event: str, async_: bool) -> None:
312
+ state.discover()
313
+ run_event(event, async_=async_, root=state.root)
314
+
315
+
316
+ @cli.command(name="generate-settings")
317
+ @click.option("--hooks-dir", default=".claude/hooks", help="Hooks directory relative to project root")
318
+ @click.option("--no-merge", is_flag=True, default=False, help="Output standalone JSON instead of merging")
319
+ @click.option(
320
+ "--from",
321
+ "from_source",
322
+ default=DIST_NAME,
323
+ help=f"Package source for uvx --from (local path or PyPI spec, default: {DIST_NAME})",
324
+ )
325
+ @click.pass_obj
326
+ def generate_settings_cmd(state: CliState, hooks_dir: str, no_merge: bool, from_source: str) -> None:
327
+ """Generate Claude Code settings JSON for .claude/settings.local.json."""
328
+ state.discover()
329
+ if no_merge:
330
+ click.echo(generate_settings_json(hooks_dir, from_source=from_source))
331
+ else:
332
+ settings_path = state.root / ".claude" / "settings.local.json"
333
+ click.echo(json.dumps(merge_settings(hooks_dir, settings_path, from_source=from_source), indent=2))
311
334
 
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
335
 
316
- if args.command == "init":
317
- init_project(root)
318
- return
336
+ @cli.command()
337
+ @click.option("--json", "json_output", is_flag=True, default=False, help="Emit one JSON record per test (CI mode)")
338
+ @click.pass_obj
339
+ def test(state: CliState, json_output: bool) -> None:
340
+ """Run inline tests from all registered hooks."""
341
+ state.discover()
342
+ run_tests(json_output=json_output)
319
343
 
320
- if args.command == "logs":
321
- show_logs(session=args.session, tail=args.tail)
322
- return
323
344
 
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}")
345
+ @cli.command()
346
+ @click.pass_obj
347
+ def init(state: CliState) -> None:
348
+ """Scaffold hooks directory, bin script, and settings."""
349
+ init_project(state.root)
350
+
351
+
352
+ @cli.command()
353
+ @click.option("--session", default=None, help="Session id or transcript path (hashed) to view")
354
+ @click.option("--tail", type=int, default=None, help="Show only the last N lines")
355
+ def logs(session: str | None, tail: int | None) -> None:
356
+ """View a recent captain-hook session log."""
357
+ show_logs(session=session, tail=tail)
358
+
359
+
360
+ main = cli
@@ -29,7 +29,6 @@ from captain_hook.primitives.llm import (
29
29
  )
30
30
  from captain_hook.primitives.nudge import gate as gate
31
31
  from captain_hook.primitives.nudge import nudge as nudge
32
- from captain_hook.styleguide import styleguide as styleguide
33
32
 
34
33
  __all__ = [
35
34
  "GateVerdict",
@@ -46,6 +45,5 @@ __all__ = [
46
45
  "nudge",
47
46
  "prompt_check",
48
47
  "session_id_for",
49
- "styleguide",
50
48
  "warn_command",
51
49
  ]
@@ -8,32 +8,10 @@ from typing import TYPE_CHECKING
8
8
 
9
9
  from captain_hook.app import on
10
10
  from captain_hook.state import hook_name
11
- from captain_hook.styleguide.query import (
12
- Assignment,
13
- Call,
14
- Class,
15
- ControlFlow,
16
- Definition,
17
- Function,
18
- Import,
19
- Kind,
20
- Module,
21
- Query,
22
- Slot,
23
- TypeChecking,
24
- annotated_slots,
25
- annotations,
26
- calls,
27
- has_future_annotations,
28
- has_keyword,
29
- is_name,
30
- name_of,
31
- named,
32
- parent_map,
33
- string_literals,
34
- )
35
- from captain_hook.styleguide.scope import changed_lines, read_source, reconstruct_pre
36
- from captain_hook.styleguide.types import StyleDiffRule, StyleRule, Violation
11
+ from captain_hook.style import matchers
12
+ from captain_hook.style.matchers import Matcher
13
+ from captain_hook.style.scope import changed_lines, read_source, reconstruct_pre
14
+ from captain_hook.style.types import StyleDiffRule, StyleRule, Violation
37
15
  from captain_hook.types import Action, Event, FilePath, HookResult, TCondition, TestFile, Tool
38
16
 
39
17
  if TYPE_CHECKING:
@@ -45,31 +23,11 @@ GUARD_ONLY_IF: tuple[TCondition, ...] = (Tool("Edit|Write"), FilePath("*.py", pr
45
23
  GUARD_SKIP_IF: tuple[TCondition, ...] = (TestFile(),)
46
24
 
47
25
  __all__ = [
48
- "Assignment",
49
- "Call",
50
- "Class",
51
- "ControlFlow",
52
- "Definition",
53
- "Function",
54
- "Import",
55
- "Kind",
56
- "Module",
57
- "Query",
58
- "Slot",
26
+ "Matcher",
59
27
  "StyleDiffRule",
60
28
  "StyleRule",
61
- "TypeChecking",
62
29
  "Violation",
63
- "annotated_slots",
64
- "annotations",
65
- "calls",
66
- "has_future_annotations",
67
- "has_keyword",
68
- "is_name",
69
- "name_of",
70
- "named",
71
- "parent_map",
72
- "string_literals",
30
+ "matchers",
73
31
  "styleguide",
74
32
  ]
75
33
 
@@ -84,8 +42,8 @@ def styleguide(
84
42
  ) -> None:
85
43
  """Register one change-scoped hook applying the given style rules to Python edits and writes.
86
44
 
87
- Each rule is a [`StyleRule`][captain_hook.styleguide.StyleRule] (or
88
- [`StyleDiffRule`][captain_hook.styleguide.StyleDiffRule]) subclass whose docstring is its
45
+ Each rule is a [`StyleRule`][captain_hook.style.StyleRule] (or
46
+ [`StyleDiffRule`][captain_hook.style.StyleDiffRule]) subclass whose docstring is its
89
47
  message. The single registered hook parses the edited file once, runs every rule against the
90
48
  post-edit tree, scopes each violation to the changed lines, and emits one aggregated warning
91
49
  (or block, when ``block`` is set). Call again with different ``only_if`` / ``skip_if`` /
@@ -123,11 +81,14 @@ def styleguide(
123
81
  )(handler)
124
82
 
125
83
 
126
- def validate(rule: type[StyleRule]) -> type[StyleRule]:
84
+ def validate(rule: object) -> type[StyleRule]:
127
85
  if not (isinstance(rule, type) and issubclass(rule, StyleRule)):
128
86
  raise TypeError(f"styleguide() expects StyleRule subclasses, got {rule!r}")
129
87
  if rule.__doc__ is None:
130
88
  raise ValueError(f"{rule.__name__} must define a docstring — it is the rule's message")
89
+ base = StyleDiffRule if issubclass(rule, StyleDiffRule) else StyleRule
90
+ if rule.match is None and rule.check is base.check:
91
+ raise TypeError(f"{rule.__name__} must define `match` or override `check`")
131
92
  return rule
132
93
 
133
94
 
@@ -145,7 +106,7 @@ def run_rules(rules: list[StyleRule], evt: BaseHookEvent, *, block: bool, max_sh
145
106
  sections := [
146
107
  section
147
108
  for rule in rules
148
- if (section := run_one(rule, tree, pre_tree, source, changed, max_shown)) is not None
109
+ if (section := run_one(rule, tree, pre_tree, changed, max_shown)) is not None
149
110
  ]
150
111
  ):
151
112
  return None
@@ -156,12 +117,9 @@ def run_one(
156
117
  rule: StyleRule,
157
118
  tree: ast.Module,
158
119
  pre_tree: ast.Module | None,
159
- source: str,
160
120
  changed: set[int],
161
121
  max_shown: int,
162
122
  ) -> str | None:
163
- if rule.trigger and rule.trigger not in source:
164
- return None
165
123
  match rule:
166
124
  case StyleDiffRule() if pre_tree is not None:
167
125
  violations = rule.check(pre_tree, tree)