capt-hook 0.2.0__py3-none-any.whl

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 (57) hide show
  1. capt_hook-0.2.0.dist-info/METADATA +113 -0
  2. capt_hook-0.2.0.dist-info/RECORD +57 -0
  3. capt_hook-0.2.0.dist-info/WHEEL +4 -0
  4. capt_hook-0.2.0.dist-info/entry_points.txt +3 -0
  5. capt_hook-0.2.0.dist-info/licenses/LICENSE +73 -0
  6. captain_hook/__init__.py +246 -0
  7. captain_hook/__main__.py +6 -0
  8. captain_hook/app.py +278 -0
  9. captain_hook/classifiers/__init__.py +30 -0
  10. captain_hook/classifiers/conductor.py +35 -0
  11. captain_hook/classifiers/droid.py +20 -0
  12. captain_hook/classifiers/native.py +19 -0
  13. captain_hook/cli.py +341 -0
  14. captain_hook/command.py +356 -0
  15. captain_hook/conditions.py +136 -0
  16. captain_hook/context.py +161 -0
  17. captain_hook/dispatch.py +107 -0
  18. captain_hook/events.py +318 -0
  19. captain_hook/file.py +120 -0
  20. captain_hook/llm/__init__.py +9 -0
  21. captain_hook/llm/backends.py +152 -0
  22. captain_hook/loader.py +62 -0
  23. captain_hook/log.py +60 -0
  24. captain_hook/primitives/__init__.py +51 -0
  25. captain_hook/primitives/audit.py +71 -0
  26. captain_hook/primitives/commands.py +61 -0
  27. captain_hook/primitives/lint.py +216 -0
  28. captain_hook/primitives/llm.py +376 -0
  29. captain_hook/primitives/nudge.py +95 -0
  30. captain_hook/prompt.py +103 -0
  31. captain_hook/py.typed +1 -0
  32. captain_hook/session.py +158 -0
  33. captain_hook/settings.py +120 -0
  34. captain_hook/signals/__init__.py +86 -0
  35. captain_hook/signals/nlp.py +105 -0
  36. captain_hook/state.py +221 -0
  37. captain_hook/styleguide/__init__.py +183 -0
  38. captain_hook/styleguide/query.py +238 -0
  39. captain_hook/styleguide/scope.py +46 -0
  40. captain_hook/styleguide/types.py +70 -0
  41. captain_hook/tasks.py +112 -0
  42. captain_hook/templates/example_hook.py.tmpl +85 -0
  43. captain_hook/testing/__init__.py +10 -0
  44. captain_hook/testing/helpers.py +392 -0
  45. captain_hook/testing/session_cache.py +50 -0
  46. captain_hook/testing/types.py +88 -0
  47. captain_hook/tests/__init__.py +27 -0
  48. captain_hook/tests/helpers.py +361 -0
  49. captain_hook/tools.py +59 -0
  50. captain_hook/transcript/__init__.py +572 -0
  51. captain_hook/transcript/inputs.py +226 -0
  52. captain_hook/transcript/models.py +186 -0
  53. captain_hook/types.py +381 -0
  54. captain_hook/util/__init__.py +0 -0
  55. captain_hook/util/model_cache.py +87 -0
  56. captain_hook/utils.py +27 -0
  57. captain_hook/workflow.py +119 -0
captain_hook/cli.py ADDED
@@ -0,0 +1,341 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import importlib.resources
5
+ import json
6
+ import os
7
+ import sys
8
+ from collections import defaultdict
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from captain_hook.app import _state, load_gitignore, reset
13
+ from captain_hook.context import HookContext
14
+ from captain_hook.dispatch import dispatch
15
+ from captain_hook.loader import discover_hooks
16
+ from captain_hook.log import setup_logging
17
+ from captain_hook.session import SessionStore, ensure_session
18
+ from captain_hook.transcript import Transcript
19
+ from captain_hook.types import Event
20
+
21
+ DIST_NAME = "capt-hook"
22
+
23
+
24
+ def example_hook_source() -> str:
25
+ """Read the bundled ``example.py`` scaffold from ``templates/example_hook.py.tmpl``."""
26
+ return (importlib.resources.files("captain_hook") / "templates" / "example_hook.py.tmpl").read_text()
27
+
28
+
29
+ def generate_settings(hooks_dir: str = ".claude/hooks", from_source: str = DIST_NAME) -> dict[str, Any]:
30
+ events_by_async: defaultdict[bool, set[str]] = defaultdict(set)
31
+ for entry in _state.hooks:
32
+ for member in Event:
33
+ if member in entry.spec.events and (name := member.name):
34
+ events_by_async[entry.spec.async_].add(name)
35
+
36
+ from_flag = "" if from_source == DIST_NAME else f" --from {from_source}"
37
+ hooks_flag = "" if hooks_dir == ".claude/hooks" else f" --hooks $CLAUDE_PROJECT_DIR/{hooks_dir}"
38
+
39
+ def commands(event: str) -> list[dict[str, Any]]:
40
+ return [
41
+ {
42
+ "type": "command",
43
+ "command": (
44
+ f"uvx{from_flag} capt-hook"
45
+ f"{hooks_flag}"
46
+ f" run {event}"
47
+ f"{' --async' if is_async else ''}"
48
+ ),
49
+ }
50
+ | ({"async": True} if is_async else {})
51
+ for is_async, events in sorted(events_by_async.items())
52
+ if event in events
53
+ ]
54
+
55
+ return {
56
+ "hooks": {
57
+ event: [{"hooks": commands(event)}] for event in sorted(events_by_async[False] | events_by_async[True])
58
+ }
59
+ }
60
+
61
+
62
+ def generate_settings_json(hooks_dir: str = ".claude/hooks", from_source: str = DIST_NAME) -> str:
63
+ return json.dumps(generate_settings(hooks_dir, from_source=from_source), indent=2)
64
+
65
+
66
+ def merge_settings(hooks_dir: str, settings_path: Path, from_source: str = DIST_NAME) -> dict[str, Any]:
67
+ hook_settings = generate_settings(hooks_dir, from_source=from_source)
68
+ if settings_path.exists():
69
+ existing = json.loads(settings_path.read_text())
70
+ existing["hooks"] = hook_settings["hooks"]
71
+ return existing
72
+ return hook_settings
73
+
74
+
75
+ def is_captain_hook_group(group: dict[str, Any]) -> bool:
76
+ return any("capt-hook" in (h.get("command") or "") for h in group.get("hooks") or [])
77
+
78
+
79
+ def merge_init_settings(
80
+ hooks_dir: str, settings_path: Path, from_source: str = DIST_NAME
81
+ ) -> tuple[dict[str, Any], dict[str, str]]:
82
+ hook_settings = generate_settings(hooks_dir, from_source=from_source)
83
+ new_hooks: dict[str, list[dict[str, Any]]] = hook_settings["hooks"]
84
+
85
+ if not settings_path.exists():
86
+ return hook_settings, {event: "added" for event in new_hooks}
87
+
88
+ existing = json.loads(settings_path.read_text())
89
+ existing_hooks = existing.setdefault("hooks", {})
90
+ summary: dict[str, str] = {}
91
+
92
+ for event, new_entries in new_hooks.items():
93
+ existing_entries = existing_hooks.get(event, [])
94
+ if any(is_captain_hook_group(g) for g in existing_entries):
95
+ summary[event] = "unchanged"
96
+ else:
97
+ existing_hooks[event] = existing_entries + new_entries
98
+ summary[event] = "added"
99
+
100
+ return existing, summary
101
+
102
+
103
+ def run_event(
104
+ event_name: str,
105
+ *,
106
+ async_: bool = False,
107
+ root: Path | None = None,
108
+ ) -> None:
109
+ try:
110
+ event = Event[event_name]
111
+ except KeyError:
112
+ valid = ", ".join(n for e in Event if (n := e.name))
113
+ print(
114
+ f"Invalid event type: {event_name!r}. Valid event names are: {valid}",
115
+ file=sys.stderr,
116
+ )
117
+ sys.exit(1)
118
+
119
+ raw_text = sys.stdin.read()
120
+ if not raw_text.strip():
121
+ return
122
+
123
+ try:
124
+ raw = json.loads(raw_text)
125
+ except (json.JSONDecodeError, ValueError) as e:
126
+ print(f"Malformed stdin: {e}", file=sys.stderr)
127
+ return
128
+
129
+ transcript_path = raw.get("transcript_path")
130
+ setup_logging(transcript_path)
131
+ resolved_path = raw.get("agent_transcript_path") or transcript_path
132
+
133
+ session_dir = ensure_session(transcript_path) if transcript_path else ensure_session(root or Path.cwd())
134
+ ctx = HookContext(
135
+ session=SessionStore(session_dir),
136
+ transcript=Transcript.from_path(resolved_path),
137
+ settings=_state.settings,
138
+ project_root=root,
139
+ )
140
+ evt = event.event_class(_raw=raw, ctx=ctx)
141
+
142
+ if output := dispatch(event, evt, session_dir=session_dir, async_=async_):
143
+ print(json.dumps(output))
144
+
145
+
146
+ def init_project(root: Path) -> None:
147
+ hooks_dir = root / ".claude" / "hooks"
148
+ hooks_dir.mkdir(parents=True, exist_ok=True)
149
+
150
+ example = hooks_dir / "example.py"
151
+ example_created = not example.exists()
152
+ if example_created:
153
+ example.write_text(example_hook_source())
154
+
155
+ settings_path = root / ".claude" / "settings.local.json"
156
+ reset()
157
+ discover_hooks(str(hooks_dir))
158
+ merged, summary = merge_init_settings(".claude/hooks", settings_path)
159
+ settings_path.write_text(json.dumps(merged, indent=2) + "\n")
160
+
161
+ print(f"Scaffolded {example.relative_to(root)} + {settings_path.relative_to(root)}.")
162
+ print()
163
+ print(f"{settings_path.relative_to(root)}:")
164
+ added = [e for e, status in summary.items() if status == "added"]
165
+ unchanged = [e for e, status in summary.items() if status == "unchanged"]
166
+ if not added and not unchanged:
167
+ print(" no hook entries to add")
168
+ for event in added:
169
+ print(f" + added {event} hook entry")
170
+ if unchanged:
171
+ print(f" unchanged: {', '.join(unchanged)} (already present)")
172
+ print()
173
+ print("Next:")
174
+ print(" 1. Read the quickstart: docs/getting-started/quickstart.md")
175
+ print(" 2. Edit example.py or add new files under .claude/hooks/")
176
+ print(" 3. capt-hook test # verify inline tests")
177
+ print(" 4. capt-hook generate-settings # rewire after adding events")
178
+
179
+
180
+ def show_logs(session: str | None = None, tail: int | None = None) -> None:
181
+ """Print a captain-hook session log.
182
+
183
+ Args:
184
+ session: A session id, or a transcript path (hashed via ``session_hash``)
185
+ to locate its log file. When ``None``, the most recently modified log
186
+ is shown.
187
+ tail: When set, print only the last ``tail`` lines.
188
+ """
189
+ from captain_hook.session import session_hash
190
+ from captain_hook.settings import resolve_log_dir
191
+
192
+ log_dir = resolve_log_dir()
193
+ if not log_dir.exists():
194
+ print(f"No captain-hook log directory at {log_dir}", file=sys.stderr)
195
+ return
196
+
197
+ if session is None:
198
+ logs = sorted(log_dir.glob("*.log"), key=lambda p: p.stat().st_mtime)
199
+ if not logs:
200
+ print(f"No log files in {log_dir}", file=sys.stderr)
201
+ return
202
+ log_file = logs[-1]
203
+ else:
204
+ session_id = session_hash(session) if ("/" in session or session.endswith(".jsonl")) else session
205
+ log_file = log_dir / f"{session_id}.log"
206
+
207
+ if not log_file.exists():
208
+ print(f"No log file at {log_file}", file=sys.stderr)
209
+ return
210
+
211
+ lines = log_file.read_text().splitlines()
212
+ print("\n".join(lines[-tail:] if tail else lines))
213
+
214
+
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
+ def expected_kinds_from_state() -> dict[str, str]:
251
+ out: dict[str, str] = {}
252
+ for entry in _state.hooks:
253
+ if not entry.spec.tests:
254
+ continue
255
+ for key, expected in entry.spec.tests.items():
256
+ out[f"{entry.name}:{key!r}"] = type(expected).__name__.lower()
257
+ return out
258
+
259
+
260
+ def run_tests(json_output: bool = False) -> None:
261
+ from captain_hook.testing.helpers import run_inline_tests
262
+
263
+ results = run_inline_tests()
264
+ if not results:
265
+ if json_output:
266
+ print(json.dumps({"status": "empty", "reason": "no inline tests"}))
267
+ else:
268
+ print("No inline tests found.")
269
+ return
270
+
271
+ expected_by_id = expected_kinds_from_state()
272
+ passed = failed = errors = skipped = 0
273
+ for name, status, _ok, detail in results:
274
+ if json_output:
275
+ print(json.dumps({
276
+ "id": name,
277
+ "status": status,
278
+ "expected": expected_by_id.get(name, ""),
279
+ "reason": detail,
280
+ }))
281
+ match status:
282
+ case "pass":
283
+ passed += 1
284
+ if not json_output:
285
+ print(f" PASS {name}")
286
+ case "skip":
287
+ skipped += 1
288
+ if not json_output:
289
+ print(f" SKIP {name}: {detail}")
290
+ case "fail":
291
+ failed += 1
292
+ if not json_output:
293
+ print(f" FAIL {name}: {detail}")
294
+ case "error":
295
+ errors += 1
296
+ if not json_output:
297
+ print(f" ERROR {name}: {detail}")
298
+ case _:
299
+ pass
300
+
301
+ if not json_output:
302
+ total = passed + failed + errors + skipped
303
+ print(f"\n{total} tests: {passed} passed, {failed} failed, {errors} errors, {skipped} skipped")
304
+ if failed or errors:
305
+ sys.exit(1)
306
+
307
+
308
+ def main() -> None:
309
+ parser = build_parser()
310
+ args = parser.parse_args()
311
+
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
+
316
+ if args.command == "init":
317
+ init_project(root)
318
+ return
319
+
320
+ if args.command == "logs":
321
+ show_logs(session=args.session, tail=args.tail)
322
+ return
323
+
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}")
@@ -0,0 +1,356 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from functools import cached_property
6
+ from typing import TYPE_CHECKING
7
+
8
+ import tree_sitter_bash as tsbash # type: ignore[import-untyped]
9
+ from loguru import logger
10
+ from tree_sitter import Language, Node, Parser
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Callable, Iterator
14
+
15
+ BASH_LANGUAGE = Language(tsbash.language()) # pyright: ignore[reportDeprecated]
16
+ BASH_PARSER = Parser(BASH_LANGUAGE)
17
+
18
+ COMPOUND_OPS = frozenset({"&&", "||", ";", "|", "&"})
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Redirect:
23
+ """A shell redirect parsed from a bash command (e.g. ``> file.txt``, ``2>&1``)."""
24
+
25
+ op: str
26
+ target: str
27
+ fd: int | None = None
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Command:
32
+ """A single parsed shell command with executable, arguments, env vars, and redirects.
33
+
34
+ Use ``Command.parse(raw)`` to parse a command string, or access via ``CommandLine``.
35
+ """
36
+
37
+ raw: str
38
+ executable: str
39
+ args: tuple[str, ...]
40
+ env: tuple[tuple[str, str], ...] = ()
41
+ redirects: tuple[Redirect, ...] = ()
42
+
43
+ @classmethod
44
+ def parse(cls, raw: str) -> Command:
45
+ return CommandLine.parse(raw).primary
46
+
47
+ @classmethod
48
+ def empty(cls) -> Command:
49
+ return cls(raw="", executable="", args=())
50
+
51
+ @cached_property
52
+ def argv(self) -> tuple[str, ...]:
53
+ return (self.executable, *self.args) if self.executable else ()
54
+
55
+ @cached_property
56
+ def program(self) -> str:
57
+ if self.executable == "uv" and len(self.args) >= 2 and self.args[0] == "run":
58
+ return self.args[1]
59
+ if re.match(r"python3?$", self.executable) and len(self.args) >= 2 and self.args[0] == "-m":
60
+ return self.args[1]
61
+ return self.executable
62
+
63
+ @cached_property
64
+ def env_dict(self) -> dict[str, str]:
65
+ return dict(self.env)
66
+
67
+ def matches(self, pattern: str) -> bool:
68
+ return bool(re.search(pattern, str(self)))
69
+
70
+ def has_arg(self, *patterns: str) -> bool:
71
+ return any(re.search(p, a) for p in patterns for a in self.args)
72
+
73
+ def __str__(self) -> str:
74
+ return " ".join(self.argv) if self.argv else self.raw
75
+
76
+ def __contains__(self, item: str) -> bool:
77
+ return item in str(self)
78
+
79
+ def __bool__(self) -> bool:
80
+ return bool(self.executable)
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class CommandLine:
85
+ """A full parsed bash command line, potentially containing multiple commands joined by operators.
86
+
87
+ Use ``CommandLine.parse(raw)`` to parse. Access individual commands via ``.commands``
88
+ or the final command via ``.primary``.
89
+ """
90
+
91
+ raw: str
92
+ parts: tuple[tuple[Command, str | None], ...]
93
+
94
+ @classmethod
95
+ def parse(cls, raw: str) -> CommandLine:
96
+ tree = BASH_PARSER.parse(raw.encode())
97
+ if parts := cls.walk_node(tree.root_node):
98
+ return cls(raw=raw, parts=tuple(parts))
99
+ logger.bind(raw=raw).warning("tree-sitter bash parse produced no commands; falling back to naive split")
100
+ return cls(raw=raw, parts=((cls.fallback(raw), None),))
101
+
102
+ @cached_property
103
+ def commands(self) -> tuple[Command, ...]:
104
+ return tuple(cmd for cmd, _ in self.parts)
105
+
106
+ @cached_property
107
+ def primary(self) -> Command:
108
+ return self.parts[-1][0] if self.parts else Command.empty()
109
+
110
+ @cached_property
111
+ def head(self) -> Command:
112
+ return self.parts[0][0] if self.parts else Command.empty()
113
+
114
+ def __iter__(self) -> Iterator[Command]:
115
+ return iter(self.commands)
116
+
117
+ def __len__(self) -> int:
118
+ return len(self.parts)
119
+
120
+ def __str__(self) -> str:
121
+ return self.raw
122
+
123
+ def __contains__(self, item: str) -> bool:
124
+ return item in self.raw
125
+
126
+ def __bool__(self) -> bool:
127
+ return bool(self.parts)
128
+
129
+ @cached_property
130
+ def q(self) -> CommandLineQuery:
131
+ return CommandLineQuery(self)
132
+
133
+ @staticmethod
134
+ def node_text(node: Node) -> str:
135
+ return node.text.decode() if node.text else ""
136
+
137
+ @staticmethod
138
+ def word_text(node: Node) -> str:
139
+ return (
140
+ CommandLine.node_text(node).strip("'\"")
141
+ if node.type in ("string", "raw_string")
142
+ else CommandLine.node_text(node)
143
+ )
144
+
145
+ @staticmethod
146
+ def extract_redirect(node: Node) -> Redirect:
147
+ op = ""
148
+ target = ""
149
+ fd: int | None = None
150
+
151
+ for child in node.children:
152
+ match child.type:
153
+ case "file_descriptor":
154
+ fd = int(CommandLine.node_text(child)) if CommandLine.node_text(child).isdigit() else None
155
+ case t if t in (">", ">>", "<", "<<", ">&", "<&", ">|"):
156
+ op = t
157
+ case _:
158
+ text = CommandLine.node_text(child)
159
+ if not op and text in (">", ">>", "<", "<<", ">&", "<&", ">|"):
160
+ op = text
161
+ elif op:
162
+ target = text
163
+ else:
164
+ target = text
165
+
166
+ return Redirect(op=op, target=target, fd=fd)
167
+
168
+ @staticmethod
169
+ def extract_command(node: Node) -> Command:
170
+ executable = ""
171
+ args: list[str] = []
172
+ env: list[tuple[str, str]] = []
173
+ redirects: list[Redirect] = []
174
+
175
+ for child in node.children:
176
+ match child.type:
177
+ case "command_name":
178
+ executable = CommandLine.word_text(child)
179
+ case "variable_assignment":
180
+ name = next((c for c in child.children if c.type == "variable_name"), None)
181
+ val = child.children[-1] if len(child.children) >= 3 else None
182
+ if name:
183
+ env.append(
184
+ (
185
+ CommandLine.node_text(name),
186
+ CommandLine.word_text(val) if val and val.type != "=" else "",
187
+ )
188
+ )
189
+ case "file_redirect":
190
+ redirects.append(CommandLine.extract_redirect(child))
191
+ case _ if child.type in (
192
+ "word",
193
+ "string",
194
+ "raw_string",
195
+ "number",
196
+ "concatenation",
197
+ "simple_expansion",
198
+ "expansion",
199
+ ):
200
+ if executable:
201
+ args.append(CommandLine.word_text(child))
202
+ else:
203
+ executable = CommandLine.word_text(child)
204
+ case _:
205
+ pass
206
+
207
+ return Command(
208
+ raw=CommandLine.node_text(node),
209
+ executable=executable,
210
+ args=tuple(args),
211
+ env=tuple(env),
212
+ redirects=tuple(redirects),
213
+ )
214
+
215
+ @staticmethod
216
+ def collect_parts(children: list[Node], ops: frozenset[str]) -> list[tuple[Command, str | None]]:
217
+ parts: list[tuple[Command, str | None]] = []
218
+ for child in children:
219
+ text = CommandLine.node_text(child)
220
+ if child.type in ops or text in ops:
221
+ if parts:
222
+ cmd, _ = parts[-1]
223
+ parts[-1] = (cmd, text)
224
+ continue
225
+ if sub := CommandLine.walk_node(child):
226
+ parts.extend(sub)
227
+ return parts
228
+
229
+ @staticmethod
230
+ def walk_redirected(node: Node) -> list[tuple[Command, str | None]]:
231
+ redirects: list[Redirect] = []
232
+ inner_parts: list[tuple[Command, str | None]] = []
233
+ for child in node.children:
234
+ if child.type == "file_redirect":
235
+ redirects.append(CommandLine.extract_redirect(child))
236
+ else:
237
+ inner_parts.extend(CommandLine.walk_node(child))
238
+ if redirects and inner_parts:
239
+ inner_parts = [
240
+ (
241
+ Command(
242
+ raw=cmd.raw,
243
+ executable=cmd.executable,
244
+ args=cmd.args,
245
+ env=cmd.env,
246
+ redirects=(*cmd.redirects, *redirects),
247
+ ),
248
+ op,
249
+ )
250
+ for cmd, op in inner_parts
251
+ ]
252
+ return inner_parts or [
253
+ (
254
+ Command(
255
+ raw=CommandLine.node_text(node),
256
+ executable="",
257
+ args=(),
258
+ redirects=tuple(redirects),
259
+ ),
260
+ None,
261
+ )
262
+ ]
263
+
264
+ @staticmethod
265
+ def walk_node(node: Node) -> list[tuple[Command, str | None]]:
266
+ match node.type:
267
+ case "program":
268
+ return CommandLine.collect_parts(node.children, frozenset({";"}))
269
+ case "list":
270
+ return CommandLine.collect_parts(node.children, COMPOUND_OPS)
271
+ case "pipeline":
272
+ return CommandLine.collect_parts(node.children, frozenset({"|"}))
273
+ case "command":
274
+ return [(CommandLine.extract_command(node), None)]
275
+ case "redirected_statement":
276
+ return CommandLine.walk_redirected(node)
277
+ case _:
278
+ parts: list[tuple[Command, str | None]] = []
279
+ for child in node.children:
280
+ parts.extend(CommandLine.walk_node(child))
281
+ return parts
282
+
283
+ @staticmethod
284
+ def fallback(raw: str) -> Command:
285
+ return Command(raw=raw, executable=raw.split()[0] if raw.split() else raw, args=())
286
+
287
+
288
+ @dataclass(frozen=True)
289
+ class CommandLineQuery:
290
+ """Predicate helpers for inspecting a parsed ``CommandLine``.
291
+
292
+ Wraps a ``CommandLine`` to answer common yes/no questions a hook condition
293
+ needs — which executable runs, whether a subcommand or token appears, or
294
+ whether the line redirects/pipes. Obtain one via ``CommandLine.q``.
295
+ """
296
+
297
+ line: CommandLine
298
+
299
+ def runs(self, *argv: str) -> bool:
300
+ """Return whether the primary command's argv starts with ``argv``.
301
+
302
+ Args:
303
+ *argv: Leading argv tokens to match, e.g. ``("git", "push")``.
304
+
305
+ Returns:
306
+ ``True`` if ``argv`` is non-empty and is a prefix of the primary
307
+ command's ``argv``.
308
+ """
309
+ return bool(argv) and self.line.primary.argv[: len(argv)] == argv
310
+
311
+ def has_subcommand(self, name: str) -> bool:
312
+ """Return whether any command in the line carries ``name`` as an argument.
313
+
314
+ Args:
315
+ name: The subcommand/argument token to look for (e.g. ``"push"``).
316
+
317
+ Returns:
318
+ ``True`` if ``name`` appears in the arguments of any parsed command.
319
+ """
320
+ return any(name in cmd.args for cmd in self.line.commands)
321
+
322
+ def any_command(self, pred: Callable[[Command], bool]) -> bool:
323
+ """Return whether any command in the line satisfies ``pred``.
324
+
325
+ Args:
326
+ pred: Predicate applied to each parsed ``Command``.
327
+
328
+ Returns:
329
+ ``True`` if ``pred`` returns truthy for at least one command.
330
+ """
331
+ return any(pred(cmd) for cmd in self.line.commands)
332
+
333
+ def uses_redirect(self) -> bool:
334
+ """Return whether the line redirects output or pipes between commands.
335
+
336
+ Returns:
337
+ ``True`` if any command has a file redirect or the parts are joined
338
+ by a pipe (``|``) operator.
339
+ """
340
+ return any(cmd.redirects for cmd in self.line.commands) or any(
341
+ op == "|" for _, op in self.line.parts if op
342
+ )
343
+
344
+ def contains_token(self, token: str) -> bool:
345
+ """Return whether ``token`` appears as a whole argv element in any command.
346
+
347
+ Unlike ``has_subcommand`` this matches the executable as well as the
348
+ arguments and requires an exact element match, not a substring.
349
+
350
+ Args:
351
+ token: The exact argv token to look for.
352
+
353
+ Returns:
354
+ ``True`` if ``token`` equals an argv element of any parsed command.
355
+ """
356
+ return any(token == a for cmd in self.line.commands for a in cmd.argv)