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.
- capt_hook-0.2.0.dist-info/METADATA +113 -0
- capt_hook-0.2.0.dist-info/RECORD +57 -0
- capt_hook-0.2.0.dist-info/WHEEL +4 -0
- capt_hook-0.2.0.dist-info/entry_points.txt +3 -0
- capt_hook-0.2.0.dist-info/licenses/LICENSE +73 -0
- captain_hook/__init__.py +246 -0
- captain_hook/__main__.py +6 -0
- captain_hook/app.py +278 -0
- captain_hook/classifiers/__init__.py +30 -0
- captain_hook/classifiers/conductor.py +35 -0
- captain_hook/classifiers/droid.py +20 -0
- captain_hook/classifiers/native.py +19 -0
- captain_hook/cli.py +341 -0
- captain_hook/command.py +356 -0
- captain_hook/conditions.py +136 -0
- captain_hook/context.py +161 -0
- captain_hook/dispatch.py +107 -0
- captain_hook/events.py +318 -0
- captain_hook/file.py +120 -0
- captain_hook/llm/__init__.py +9 -0
- captain_hook/llm/backends.py +152 -0
- captain_hook/loader.py +62 -0
- captain_hook/log.py +60 -0
- captain_hook/primitives/__init__.py +51 -0
- captain_hook/primitives/audit.py +71 -0
- captain_hook/primitives/commands.py +61 -0
- captain_hook/primitives/lint.py +216 -0
- captain_hook/primitives/llm.py +376 -0
- captain_hook/primitives/nudge.py +95 -0
- captain_hook/prompt.py +103 -0
- captain_hook/py.typed +1 -0
- captain_hook/session.py +158 -0
- captain_hook/settings.py +120 -0
- captain_hook/signals/__init__.py +86 -0
- captain_hook/signals/nlp.py +105 -0
- captain_hook/state.py +221 -0
- captain_hook/styleguide/__init__.py +183 -0
- captain_hook/styleguide/query.py +238 -0
- captain_hook/styleguide/scope.py +46 -0
- captain_hook/styleguide/types.py +70 -0
- captain_hook/tasks.py +112 -0
- captain_hook/templates/example_hook.py.tmpl +85 -0
- captain_hook/testing/__init__.py +10 -0
- captain_hook/testing/helpers.py +392 -0
- captain_hook/testing/session_cache.py +50 -0
- captain_hook/testing/types.py +88 -0
- captain_hook/tests/__init__.py +27 -0
- captain_hook/tests/helpers.py +361 -0
- captain_hook/tools.py +59 -0
- captain_hook/transcript/__init__.py +572 -0
- captain_hook/transcript/inputs.py +226 -0
- captain_hook/transcript/models.py +186 -0
- captain_hook/types.py +381 -0
- captain_hook/util/__init__.py +0 -0
- captain_hook/util/model_cache.py +87 -0
- captain_hook/utils.py +27 -0
- 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}")
|
captain_hook/command.py
ADDED
|
@@ -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)
|