sequential-hooks 0.1.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 (43) hide show
  1. plugins/agy/_sequential_hooks/__init__.py +1 -0
  2. plugins/agy/_sequential_hooks/_adapter.py +566 -0
  3. plugins/agy/_sequential_hooks/_doctor.py +413 -0
  4. plugins/claude/_sequential_hooks/__init__.py +1 -0
  5. plugins/claude/_sequential_hooks/_adapter.py +560 -0
  6. plugins/claude/_sequential_hooks/_doctor.py +455 -0
  7. plugins/codex/_sequential_hooks/__init__.py +1 -0
  8. plugins/codex/_sequential_hooks/_adapter.py +466 -0
  9. plugins/codex/_sequential_hooks/_doctor.py +563 -0
  10. sequential_hooks/__init__.py +3 -0
  11. sequential_hooks/__main__.py +5 -0
  12. sequential_hooks/_arguments.py +222 -0
  13. sequential_hooks/_cleanup.py +88 -0
  14. sequential_hooks/_cli.py +440 -0
  15. sequential_hooks/_containment/__init__.py +410 -0
  16. sequential_hooks/_containment/_posix.py +236 -0
  17. sequential_hooks/_containment/_uncontained.py +239 -0
  18. sequential_hooks/_containment/_windows.py +853 -0
  19. sequential_hooks/_containment/_windows_api.py +570 -0
  20. sequential_hooks/_containment/_windows_launcher.py +189 -0
  21. sequential_hooks/_diagnostics.py +265 -0
  22. sequential_hooks/_doctor/__init__.py +342 -0
  23. sequential_hooks/_doctor/_command.py +469 -0
  24. sequential_hooks/_doctor/_common.py +448 -0
  25. sequential_hooks/_doctor/_types.py +94 -0
  26. sequential_hooks/_downstream.py +181 -0
  27. sequential_hooks/_executable.py +187 -0
  28. sequential_hooks/_executor.py +825 -0
  29. sequential_hooks/_registry.py +103 -0
  30. sequential_hooks/_runner.py +48 -0
  31. sequential_hooks/_types.py +125 -0
  32. sequential_hooks/hosts/__init__.py +130 -0
  33. sequential_hooks/hosts/_contract.py +434 -0
  34. sequential_hooks/hosts/_inspection.py +78 -0
  35. sequential_hooks/hosts/_json.py +150 -0
  36. sequential_hooks/hosts/_records.py +273 -0
  37. sequential_hooks/hosts/_skeleton.py +1326 -0
  38. sequential_hooks/py.typed +0 -0
  39. sequential_hooks-0.1.0.dist-info/METADATA +90 -0
  40. sequential_hooks-0.1.0.dist-info/RECORD +43 -0
  41. sequential_hooks-0.1.0.dist-info/WHEEL +4 -0
  42. sequential_hooks-0.1.0.dist-info/entry_points.txt +7 -0
  43. sequential_hooks-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,440 @@
1
+ # pyright: reportPrivateUsage=false
2
+
3
+ """Implement command-line entry points for hooks and doctor checks."""
4
+
5
+ import os
6
+ import sys
7
+ from collections.abc import Mapping, Sequence
8
+ from dataclasses import dataclass
9
+ from pathlib import Path, PureWindowsPath
10
+ from typing import BinaryIO
11
+
12
+ from sequential_hooks._arguments import HookInvocation, InvocationError, parse_hook_argv
13
+ from sequential_hooks._cleanup import CleanupState
14
+ from sequential_hooks._diagnostics import Diagnostics
15
+ from sequential_hooks._downstream import closable_stdout, is_downstream_closure, silence_descriptor
16
+ from sequential_hooks._executor import ForwardedSignal, ProcessExecutor
17
+ from sequential_hooks._registry import HostLoadError, load_host
18
+ from sequential_hooks._runner import run_pipeline
19
+ from sequential_hooks.hosts import (
20
+ FinalResponse,
21
+ HostAdapter,
22
+ HostInputError,
23
+ HostSpec,
24
+ JsonObject,
25
+ )
26
+ from sequential_hooks.hosts._json import JsonProtocolError, load_object
27
+
28
+ _DOCTOR_HELP = """usage: sequential-hooks doctor --agent NAME
29
+ sequential-hooks doctor --help
30
+
31
+ Check coding-agent configuration and platform containment support.
32
+
33
+ options:
34
+ -a, --agent NAME registered coding agent
35
+ -h, --help
36
+ """
37
+ _HELP = """usage: sequential-hooks --agent NAME [OPTIONS] CHILD [ARGS] [-- CHILD [ARGS]]...
38
+ sequential-hooks doctor --help
39
+
40
+ Compose one through three hook commands in order.
41
+
42
+ options:
43
+ -a, --agent NAME registered coding agent
44
+ -e, --event EVENT required when the agent payload carries no event
45
+ --step-timeout SECONDS
46
+ --allow-on-failure
47
+ -h, --help
48
+ --version
49
+
50
+ The exact -- token separates child commands and is unavailable to a child.
51
+ """
52
+ _INTERNAL_FAILURE_DIAGNOSTIC = 'unexpected internal failure'
53
+ _INVALID_INPUT_DIAGNOSTIC = 'native hook input is invalid'
54
+ _INVALID_INVOCATION_DIAGNOSTIC = 'wrapper invocation is invalid'
55
+ _LAST_RESORT_EXIT = 2
56
+ _VALID_DOCTOR_ARGUMENT_COUNT = 3
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class _NativeContext:
61
+ """Carry one parsed native input and its trusted event."""
62
+
63
+ _event: str
64
+ _raw_input: bytes
65
+ _payload: JsonObject
66
+
67
+
68
+ class _NoTrustedEventError(Exception):
69
+ """Report that no event-specific native response can be produced."""
70
+
71
+
72
+ def _doctor_system_root(
73
+ cwd: Path,
74
+ *,
75
+ windows: bool,
76
+ environment: Mapping[str, str],
77
+ ) -> Path:
78
+ """Return the filesystem root used for machine-managed sources."""
79
+ if not windows:
80
+ return Path(cwd.anchor)
81
+ if system_drive := environment.get('SystemDrive'):
82
+ anchor = PureWindowsPath(f'{system_drive}\\').anchor
83
+ if anchor:
84
+ return Path(anchor)
85
+ for variable in ('SystemRoot', 'ProgramFiles'):
86
+ location = environment.get(variable)
87
+ if not location:
88
+ continue
89
+ anchor = PureWindowsPath(location).anchor
90
+ if anchor:
91
+ return Path(anchor)
92
+ return Path(cwd.anchor)
93
+
94
+
95
+ def _explicit_event(spec: HostSpec, event: str | None) -> str:
96
+ """Return the trusted `--event` for a host that requires one."""
97
+ if event is None:
98
+ raise _NoTrustedEventError(f'--event is required for host {spec.name}')
99
+ if event not in spec.events:
100
+ raise _NoTrustedEventError(f'host {spec.name} does not serve event {event!r}')
101
+ return event
102
+
103
+
104
+ def _is_text_command(arguments: tuple[str, ...]) -> bool:
105
+ """Identify commands whose output is text rather than hook responses."""
106
+ return arguments in {('-h',), ('--help',), ('--version',)} or bool(
107
+ arguments and arguments[0] == 'doctor'
108
+ )
109
+
110
+
111
+ def _last_resort(message: str, diagnostics: Diagnostics) -> int:
112
+ """Answer with one diagnostic line, visible controls, and exit 2."""
113
+ diagnostics.write_global(message.encode('unicode_escape').decode('ascii'))
114
+ return _LAST_RESORT_EXIT
115
+
116
+
117
+ def _native_context(
118
+ spec: HostSpec,
119
+ event: str | None,
120
+ raw_input: bytes,
121
+ ) -> _NativeContext:
122
+ """Parse native input and bind the trusted event for the selected host."""
123
+ # Invalid JSON and unresolved host events remain distinct so callers can
124
+ # select the documented configuration or last-resort response.
125
+ if spec.requires_event:
126
+ trusted = _explicit_event(spec, event)
127
+ return _NativeContext(trusted, raw_input, load_object(raw_input))
128
+ payload = load_object(raw_input)
129
+ failure = CleanupState()
130
+ payload_event: str | None = None
131
+ with failure:
132
+ payload_event = spec.payload_event(payload)
133
+ if failure.first_error is not None:
134
+ raise _NoTrustedEventError(
135
+ f'host {spec.name} event resolution failed: {type(failure.first_error).__name__}'
136
+ ) from failure.first_error
137
+ if payload_event is None or payload_event not in spec.events:
138
+ raise _NoTrustedEventError('native event could not be established')
139
+ return _NativeContext(payload_event, raw_input, payload)
140
+
141
+
142
+ def _run_doctor_cli(
143
+ arguments: tuple[str, ...],
144
+ diagnostics: Diagnostics,
145
+ ) -> int:
146
+ """Parse and run the isolated doctor command grammar."""
147
+ if arguments in {('doctor', '-h'), ('doctor', '--help')}:
148
+ sys.stdout.write(_DOCTOR_HELP)
149
+ return 0
150
+ if (
151
+ len(arguments) != _VALID_DOCTOR_ARGUMENT_COUNT
152
+ or arguments[1] not in {'-a', '--agent'}
153
+ or not arguments[2]
154
+ or arguments[2].startswith('-')
155
+ ):
156
+ diagnostics.write_global('invalid doctor invocation; use doctor --help')
157
+ return 2
158
+ host = arguments[2]
159
+ home = Path.home()
160
+ cwd = Path.cwd()
161
+ from sequential_hooks._doctor import run_doctor # noqa: PLC0415
162
+
163
+ return run_doctor(
164
+ host,
165
+ home=home,
166
+ cwd=cwd,
167
+ system_root=_doctor_system_root(
168
+ cwd,
169
+ windows=sys.platform == 'win32',
170
+ environment=os.environ,
171
+ ),
172
+ path_value=os.environ.get('PATH'),
173
+ path_ext=os.environ.get('PATHEXT'),
174
+ stream=sys.stdout,
175
+ )
176
+
177
+
178
+ def _trusted_event_for_error(
179
+ spec: HostSpec,
180
+ event: str | None,
181
+ stdin: BinaryIO,
182
+ ) -> str:
183
+ """Establish the event a configuration response may answer."""
184
+ if spec.requires_event:
185
+ return _explicit_event(spec, event)
186
+ try:
187
+ return _native_context(spec, event, stdin.read())._event
188
+ except JsonProtocolError as error:
189
+ raise _NoTrustedEventError('native event could not be established') from error
190
+
191
+
192
+ def _version() -> str | None:
193
+ """Return the installed version, or none when metadata is unavailable."""
194
+ import importlib.metadata # noqa: PLC0415
195
+
196
+ try:
197
+ value = importlib.metadata.version('sequential-hooks')
198
+ except importlib.metadata.PackageNotFoundError:
199
+ return None
200
+ return f'sequential-hooks {value}\n'
201
+
202
+
203
+ def _run_text_command(arguments: tuple[str, ...], diagnostics: Diagnostics) -> int:
204
+ """Write top-level text or dispatch the doctor command."""
205
+ if arguments in {('-h',), ('--help',)}:
206
+ sys.stdout.write(_HELP)
207
+ return 0
208
+ if arguments == ('--version',):
209
+ version = _version()
210
+ if version is None:
211
+ diagnostics.write_global(
212
+ 'installed distribution metadata for sequential-hooks is unavailable'
213
+ )
214
+ return 2
215
+ sys.stdout.write(version)
216
+ return 0
217
+ return _run_doctor_cli(arguments, diagnostics)
218
+
219
+
220
+ def _write_response(stdout: BinaryIO, response: bytes) -> bool:
221
+ """Write one response, returning false after a closed downstream pipe."""
222
+ try:
223
+ stdout.write(response)
224
+ stdout.flush()
225
+ except OSError as error:
226
+ if not is_downstream_closure(error):
227
+ raise
228
+ silence_descriptor(sys.stdout)
229
+ return False
230
+ return True
231
+
232
+
233
+ def _configuration_response(
234
+ spec: HostSpec,
235
+ event: str,
236
+ message: str,
237
+ diagnostics: Diagnostics,
238
+ stdout: BinaryIO,
239
+ ) -> int:
240
+ """Resolve the host response before committing wrapper diagnostics."""
241
+ failure = CleanupState()
242
+ response: FinalResponse | None = None
243
+ response_exit_code = 0
244
+ response_stdout = b''
245
+ with failure:
246
+ response = spec.configuration_response(event, message, diagnostics)
247
+ if response is not None:
248
+ with failure:
249
+ response_stdout = response.stdout
250
+ response_exit_code = response.exit_code
251
+ if failure.first_error is not None:
252
+ return _last_resort(
253
+ f'{message}; host {spec.name} configuration response failed: '
254
+ f'{type(failure.first_error).__name__}',
255
+ diagnostics,
256
+ )
257
+ if response is None:
258
+ return _last_resort(
259
+ f'{message}; host {spec.name} returned no configuration response', diagnostics
260
+ )
261
+ diagnostics.write_global(message)
262
+ _write_response(stdout, response_stdout)
263
+ return response_exit_code
264
+
265
+
266
+ def _handle_parse_error(
267
+ error: InvocationError,
268
+ stdin: BinaryIO,
269
+ stdout: BinaryIO,
270
+ diagnostics: Diagnostics,
271
+ ) -> int:
272
+ """Translate syntax only after a host loads and its event is trusted."""
273
+ if error.host is None:
274
+ return _last_resort(str(error), diagnostics)
275
+ try:
276
+ spec = load_host(error.host)
277
+ except HostLoadError as load_error:
278
+ return _last_resort(f'{error}; {load_error}', diagnostics)
279
+ try:
280
+ event = _trusted_event_for_error(spec, error.event, stdin)
281
+ except _NoTrustedEventError as event_error:
282
+ return _last_resort(f'{error}; {event_error}', diagnostics)
283
+ return _configuration_response(
284
+ spec,
285
+ event,
286
+ _INVALID_INVOCATION_DIAGNOSTIC,
287
+ diagnostics,
288
+ stdout,
289
+ )
290
+
291
+
292
+ def _prepare_adapter(
293
+ spec: HostSpec,
294
+ invocation: HookInvocation,
295
+ *,
296
+ stdin: BinaryIO,
297
+ stdout: BinaryIO,
298
+ diagnostics: Diagnostics,
299
+ ) -> HostAdapter | int:
300
+ """Read native input and construct the adapter, or answer a failure tier."""
301
+ raw_input = stdin.read()
302
+ try:
303
+ native_context = _native_context(spec, invocation.event, raw_input)
304
+ except _NoTrustedEventError as error:
305
+ return _last_resort(str(error), diagnostics)
306
+ except JsonProtocolError:
307
+ if invocation.event is None:
308
+ return _last_resort('native event could not be established', diagnostics)
309
+ return _configuration_response(
310
+ spec,
311
+ invocation.event,
312
+ _INVALID_INPUT_DIAGNOSTIC,
313
+ diagnostics,
314
+ stdout,
315
+ )
316
+ failure = CleanupState()
317
+ adapter: HostAdapter | None = None
318
+ with failure:
319
+ adapter = spec.adapter(
320
+ native_context._event,
321
+ native_context._raw_input,
322
+ native_context._payload,
323
+ diagnostics,
324
+ allow_on_failure=invocation.allow_on_failure,
325
+ )
326
+ if isinstance(failure.first_error, HostInputError):
327
+ return _configuration_response(
328
+ spec,
329
+ native_context._event,
330
+ f'{_INVALID_INPUT_DIAGNOSTIC}: {failure.first_error}',
331
+ diagnostics,
332
+ stdout,
333
+ )
334
+ if failure.first_error is not None or adapter is None:
335
+ return _last_resort(
336
+ f'host {spec.name} adapter failed: {type(failure.first_error).__name__}',
337
+ diagnostics,
338
+ )
339
+ return adapter
340
+
341
+
342
+ def _run_chain(
343
+ invocation: HookInvocation,
344
+ *,
345
+ adapter: HostAdapter,
346
+ stdout: BinaryIO,
347
+ diagnostics: Diagnostics,
348
+ ) -> int:
349
+ """Run the child chain and write the adapter's response."""
350
+ executor = ProcessExecutor(diagnostics)
351
+ failure = CleanupState()
352
+ response: FinalResponse | None = None
353
+ response_exit_code = 0
354
+ response_stdout = b''
355
+ try:
356
+ with executor.signal_forwarding(), failure:
357
+ response = run_pipeline(
358
+ adapter,
359
+ invocation.steps,
360
+ executor,
361
+ invocation.step_timeout,
362
+ allow_on_failure=invocation.allow_on_failure,
363
+ )
364
+ response_stdout = response.stdout
365
+ response_exit_code = response.exit_code
366
+ except ForwardedSignal as error:
367
+ return 128 + error.signum
368
+ if failure.first_error is not None or response is None:
369
+ return _last_resort(_INTERNAL_FAILURE_DIAGNOSTIC, diagnostics)
370
+ _write_response(stdout, response_stdout)
371
+ return response_exit_code
372
+
373
+
374
+ def _run_hook(
375
+ invocation: HookInvocation,
376
+ stdin: BinaryIO,
377
+ stdout: BinaryIO,
378
+ diagnostics: Diagnostics,
379
+ ) -> int:
380
+ """Run one parsed hook invocation through the three failure tiers."""
381
+ try:
382
+ spec = load_host(invocation.host)
383
+ except HostLoadError as error:
384
+ return _last_resort(str(error), diagnostics)
385
+ if not spec.requires_event and invocation.event is not None:
386
+ message = f'--event is not accepted for host {spec.name}'
387
+ try:
388
+ event = _trusted_event_for_error(spec, invocation.event, stdin)
389
+ except _NoTrustedEventError as error:
390
+ return _last_resort(f'{message}; {error}', diagnostics)
391
+ return _configuration_response(spec, event, message, diagnostics, stdout)
392
+ try:
393
+ if spec.requires_event:
394
+ _explicit_event(spec, invocation.event)
395
+ except _NoTrustedEventError as error:
396
+ return _last_resort(str(error), diagnostics)
397
+ prepared = _prepare_adapter(
398
+ spec,
399
+ invocation,
400
+ stdin=stdin,
401
+ stdout=stdout,
402
+ diagnostics=diagnostics,
403
+ )
404
+ if isinstance(prepared, int):
405
+ return prepared
406
+ return _run_chain(
407
+ invocation,
408
+ adapter=prepared,
409
+ stdout=stdout,
410
+ diagnostics=diagnostics,
411
+ )
412
+
413
+
414
+ def main(argv: Sequence[str] | None = None) -> int:
415
+ """Run the command-line interface using the process standard streams.
416
+
417
+ Hook mode exchanges native bytes; help, version, and doctor write text to
418
+ stdout. Downstream closure is absorbed while preserving the command status.
419
+
420
+ Args:
421
+ argv: Tokens after the executable name, or `None` for `sys.argv[1:]`.
422
+
423
+ Returns:
424
+ Process exit status.
425
+
426
+ Raises:
427
+ OSError: If writing, flushing, or redirecting output fails for a reason
428
+ other than downstream closure. A diagnostic `OSError` raised during
429
+ pipeline execution is translated under the internal-failure policy.
430
+ """
431
+ arguments = tuple(sys.argv[1:] if argv is None else argv)
432
+ diagnostics = Diagnostics(sys.stderr)
433
+ if _is_text_command(arguments):
434
+ with closable_stdout():
435
+ return _run_text_command(arguments, diagnostics)
436
+ try:
437
+ invocation = parse_hook_argv(arguments)
438
+ except InvocationError as error:
439
+ return _handle_parse_error(error, sys.stdin.buffer, sys.stdout.buffer, diagnostics)
440
+ return _run_hook(invocation, sys.stdin.buffer, sys.stdout.buffer, diagnostics)