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.
- plugins/agy/_sequential_hooks/__init__.py +1 -0
- plugins/agy/_sequential_hooks/_adapter.py +566 -0
- plugins/agy/_sequential_hooks/_doctor.py +413 -0
- plugins/claude/_sequential_hooks/__init__.py +1 -0
- plugins/claude/_sequential_hooks/_adapter.py +560 -0
- plugins/claude/_sequential_hooks/_doctor.py +455 -0
- plugins/codex/_sequential_hooks/__init__.py +1 -0
- plugins/codex/_sequential_hooks/_adapter.py +466 -0
- plugins/codex/_sequential_hooks/_doctor.py +563 -0
- sequential_hooks/__init__.py +3 -0
- sequential_hooks/__main__.py +5 -0
- sequential_hooks/_arguments.py +222 -0
- sequential_hooks/_cleanup.py +88 -0
- sequential_hooks/_cli.py +440 -0
- sequential_hooks/_containment/__init__.py +410 -0
- sequential_hooks/_containment/_posix.py +236 -0
- sequential_hooks/_containment/_uncontained.py +239 -0
- sequential_hooks/_containment/_windows.py +853 -0
- sequential_hooks/_containment/_windows_api.py +570 -0
- sequential_hooks/_containment/_windows_launcher.py +189 -0
- sequential_hooks/_diagnostics.py +265 -0
- sequential_hooks/_doctor/__init__.py +342 -0
- sequential_hooks/_doctor/_command.py +469 -0
- sequential_hooks/_doctor/_common.py +448 -0
- sequential_hooks/_doctor/_types.py +94 -0
- sequential_hooks/_downstream.py +181 -0
- sequential_hooks/_executable.py +187 -0
- sequential_hooks/_executor.py +825 -0
- sequential_hooks/_registry.py +103 -0
- sequential_hooks/_runner.py +48 -0
- sequential_hooks/_types.py +125 -0
- sequential_hooks/hosts/__init__.py +130 -0
- sequential_hooks/hosts/_contract.py +434 -0
- sequential_hooks/hosts/_inspection.py +78 -0
- sequential_hooks/hosts/_json.py +150 -0
- sequential_hooks/hosts/_records.py +273 -0
- sequential_hooks/hosts/_skeleton.py +1326 -0
- sequential_hooks/py.typed +0 -0
- sequential_hooks-0.1.0.dist-info/METADATA +90 -0
- sequential_hooks-0.1.0.dist-info/RECORD +43 -0
- sequential_hooks-0.1.0.dist-info/WHEEL +4 -0
- sequential_hooks-0.1.0.dist-info/entry_points.txt +7 -0
- sequential_hooks-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Orchestrate deterministic, read-only host diagnostics."""
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from sequential_hooks._doctor._command import inspect_command, redact_command
|
|
11
|
+
from sequential_hooks._doctor._common import (
|
|
12
|
+
analyze_registration,
|
|
13
|
+
analyze_registration_pairs,
|
|
14
|
+
inspect_platform,
|
|
15
|
+
)
|
|
16
|
+
from sequential_hooks._doctor._types import (
|
|
17
|
+
ConfigSource,
|
|
18
|
+
DoctorReport,
|
|
19
|
+
Finding,
|
|
20
|
+
FindingSeverity,
|
|
21
|
+
PlatformReport,
|
|
22
|
+
Registration,
|
|
23
|
+
)
|
|
24
|
+
from sequential_hooks._executable import ExecutableStatus, resolve_executable
|
|
25
|
+
from sequential_hooks._registry import HostLoadError, load_host
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DoctorOutput(Protocol):
|
|
29
|
+
"""Write doctor report text."""
|
|
30
|
+
|
|
31
|
+
def write(self, value: str, /) -> int:
|
|
32
|
+
"""Write report text.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
value: Text to write.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
The number of characters written.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
OSError: If writing report text fails.
|
|
42
|
+
"""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
def writelines(self, lines: Iterable[str], /) -> None:
|
|
46
|
+
"""Write report lines.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
lines: Report lines to write in order.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
OSError: If writing report lines fails.
|
|
53
|
+
"""
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _installed_version() -> str:
|
|
58
|
+
"""Return the installed package version or `0.0.0` without metadata."""
|
|
59
|
+
try:
|
|
60
|
+
return importlib.metadata.version('sequential-hooks')
|
|
61
|
+
except importlib.metadata.PackageNotFoundError:
|
|
62
|
+
return '0.0.0'
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _render_boolean(value: bool | None) -> str:
|
|
66
|
+
"""Render one inspectable, unknown, or inapplicable state."""
|
|
67
|
+
if value is None:
|
|
68
|
+
return 'unknown'
|
|
69
|
+
return 'true' if value else 'false'
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _render_scalar(value: object) -> str:
|
|
73
|
+
"""Return one ASCII-safe line scalar without losing code-point identity."""
|
|
74
|
+
rendered: list[str] = []
|
|
75
|
+
for character in str(value):
|
|
76
|
+
if character == '\\':
|
|
77
|
+
escaped = '\\\\'
|
|
78
|
+
elif ' ' <= character <= '~':
|
|
79
|
+
escaped = character
|
|
80
|
+
else:
|
|
81
|
+
escaped = character.encode('unicode_escape').decode('ascii')
|
|
82
|
+
rendered.append(escaped)
|
|
83
|
+
return ''.join(rendered)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _render_findings(findings: tuple[Finding, ...], stream: DoctorOutput) -> None:
|
|
87
|
+
"""Render source problems, hook groups, and cross-hook findings with exact corrections."""
|
|
88
|
+
stream.write('\nfindings:\n')
|
|
89
|
+
if not findings:
|
|
90
|
+
stream.write('- none\n')
|
|
91
|
+
return
|
|
92
|
+
for finding in findings:
|
|
93
|
+
stream.write(
|
|
94
|
+
f'- [{_render_scalar(finding.severity.value)}] '
|
|
95
|
+
f'{_render_scalar(finding.code)}: {_render_scalar(finding.message)}\n'
|
|
96
|
+
)
|
|
97
|
+
if finding.correction is not None:
|
|
98
|
+
stream.write(f' correction: {_render_scalar(finding.correction)}\n')
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _render_limitations(
|
|
102
|
+
platform: PlatformReport,
|
|
103
|
+
limitations: tuple[str, ...],
|
|
104
|
+
stream: DoctorOutput,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Render platform and host limitations in stable order."""
|
|
107
|
+
stream.write('\nlimitations:\n')
|
|
108
|
+
combined = (*platform.limitations, *limitations)
|
|
109
|
+
if not combined:
|
|
110
|
+
stream.write('- none\n')
|
|
111
|
+
return
|
|
112
|
+
stream.writelines(f'- {_render_scalar(limitation)}\n' for limitation in combined)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _render_registrations(
|
|
116
|
+
registrations: tuple[Registration, ...],
|
|
117
|
+
stream: DoctorOutput,
|
|
118
|
+
*,
|
|
119
|
+
windows: bool,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Render registrations without exposing args or unredacted commands."""
|
|
122
|
+
stream.write('\nregistrations:\n')
|
|
123
|
+
if not registrations:
|
|
124
|
+
stream.write('- none\n')
|
|
125
|
+
return
|
|
126
|
+
for registration in registrations:
|
|
127
|
+
matcher = registration.matcher if registration.matcher is not None else 'any'
|
|
128
|
+
timeout = str(registration.timeout) if registration.timeout is not None else 'unknown'
|
|
129
|
+
registration_type = registration.type if registration.type is not None else 'unknown'
|
|
130
|
+
windows_launch = (
|
|
131
|
+
registration.windows_launch_semantics.value
|
|
132
|
+
if registration.windows_launch_semantics is not None
|
|
133
|
+
else 'unavailable'
|
|
134
|
+
)
|
|
135
|
+
stream.write(
|
|
136
|
+
f'- source: {_render_scalar(registration.source.path)}\n'
|
|
137
|
+
f' event: {_render_scalar(registration.event)}\n'
|
|
138
|
+
f' matcher: {_render_scalar(matcher)}\n'
|
|
139
|
+
f' command: {_render_scalar(redact_command(registration, windows=windows))}\n'
|
|
140
|
+
f' timeout: {_render_scalar(timeout)}\n'
|
|
141
|
+
f' enabled: {_render_scalar(_render_boolean(registration.enabled))}\n'
|
|
142
|
+
f' type: {_render_scalar(registration_type)}\n'
|
|
143
|
+
f' launch: {_render_scalar(registration.launch_semantics.value)}\n'
|
|
144
|
+
f' windows launch: {_render_scalar(windows_launch)}\n'
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _render_sources(sources: tuple[ConfigSource, ...], stream: DoctorOutput) -> None:
|
|
149
|
+
"""Render every configuration source in discovery order."""
|
|
150
|
+
stream.write('\nconfiguration sources:\n')
|
|
151
|
+
if not sources:
|
|
152
|
+
stream.write('- none\n')
|
|
153
|
+
return
|
|
154
|
+
for source in sources:
|
|
155
|
+
state = 'exists' if source.exists else 'missing'
|
|
156
|
+
stream.write(
|
|
157
|
+
f'- [{_render_scalar(source.kind.value)}] {_render_scalar(source.path)}: {state}\n'
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _render_report(report: DoctorReport, stream: DoctorOutput) -> None:
|
|
162
|
+
"""Render one deterministic, redaction-safe human report."""
|
|
163
|
+
wrapper = str(report.executable) if report.executable is not None else 'not found'
|
|
164
|
+
version = report.version if report.version is not None else 'unknown'
|
|
165
|
+
job_object = (
|
|
166
|
+
'not applicable'
|
|
167
|
+
if report.platform.job_object_configurable is None
|
|
168
|
+
else _render_boolean(report.platform.job_object_configurable)
|
|
169
|
+
)
|
|
170
|
+
stream.write(
|
|
171
|
+
'sequential-hooks doctor\n'
|
|
172
|
+
'display: ASCII backslash escapes; \\\\ represents one literal backslash\n'
|
|
173
|
+
f'agent: {_render_scalar(report.host)}\n'
|
|
174
|
+
f'wrapper: {_render_scalar(wrapper)}\n'
|
|
175
|
+
f'version: {_render_scalar(version)}\n'
|
|
176
|
+
f'interpreter: {_render_scalar(report.platform.interpreter)}\n'
|
|
177
|
+
f'containment: {_render_scalar(report.platform.containment)}\n'
|
|
178
|
+
f'job object configurable: {_render_scalar(job_object)}\n'
|
|
179
|
+
)
|
|
180
|
+
_render_sources(report.sources, stream)
|
|
181
|
+
_render_registrations(report.registrations, stream, windows=sys.platform == 'win32')
|
|
182
|
+
_render_findings(report.findings, stream)
|
|
183
|
+
_render_limitations(report.platform, report.limitations, stream)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _wrapper_finding(
|
|
187
|
+
status: ExecutableStatus,
|
|
188
|
+
*,
|
|
189
|
+
existing_codes: set[str],
|
|
190
|
+
) -> Finding | None:
|
|
191
|
+
"""Return one aggregate wrapper-resolution problem without duplicates."""
|
|
192
|
+
if status is ExecutableStatus.NOT_FOUND and 'wrapper_executable_missing' not in existing_codes:
|
|
193
|
+
return Finding(
|
|
194
|
+
FindingSeverity.ERROR,
|
|
195
|
+
'wrapper_executable_missing',
|
|
196
|
+
'wrapper executable cannot be resolved',
|
|
197
|
+
'Install or configure a runnable wrapper executable.',
|
|
198
|
+
)
|
|
199
|
+
if (
|
|
200
|
+
status is ExecutableStatus.UNSUPPORTED_SHIM
|
|
201
|
+
and 'wrapper_executable_unsupported' not in existing_codes
|
|
202
|
+
):
|
|
203
|
+
return Finding(
|
|
204
|
+
FindingSeverity.ERROR,
|
|
205
|
+
'wrapper_executable_unsupported',
|
|
206
|
+
'wrapper executable resolves to an unsupported Windows shim',
|
|
207
|
+
'Replace the Windows batch shim with a real .exe or .com executable entry point.',
|
|
208
|
+
)
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _report(
|
|
213
|
+
report: DoctorReport,
|
|
214
|
+
*,
|
|
215
|
+
path_value: str | None,
|
|
216
|
+
path_ext: str | None,
|
|
217
|
+
) -> DoctorReport:
|
|
218
|
+
"""Resolve the wrapper executable and complete one inspection report."""
|
|
219
|
+
resolution = resolve_executable(
|
|
220
|
+
'sequential-hooks',
|
|
221
|
+
path_value=path_value,
|
|
222
|
+
windows=sys.platform == 'win32',
|
|
223
|
+
pathext_value=path_ext,
|
|
224
|
+
)
|
|
225
|
+
wrapper_finding = _wrapper_finding(
|
|
226
|
+
resolution.status,
|
|
227
|
+
existing_codes={finding.code for finding in report.findings},
|
|
228
|
+
)
|
|
229
|
+
findings = report.findings
|
|
230
|
+
if wrapper_finding is not None:
|
|
231
|
+
findings = (*findings, wrapper_finding)
|
|
232
|
+
return replace(report, executable=resolution.resolved, findings=findings)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _inspect_host( # noqa: PLR0913
|
|
236
|
+
host: str,
|
|
237
|
+
*,
|
|
238
|
+
home: Path,
|
|
239
|
+
cwd: Path,
|
|
240
|
+
system_root: Path,
|
|
241
|
+
path_value: str | None,
|
|
242
|
+
path_ext: str | None,
|
|
243
|
+
) -> DoctorReport:
|
|
244
|
+
"""Inspect one host without executing or editing configuration."""
|
|
245
|
+
try:
|
|
246
|
+
spec = load_host(host)
|
|
247
|
+
except HostLoadError as error:
|
|
248
|
+
unavailable = Finding(
|
|
249
|
+
FindingSeverity.ERROR,
|
|
250
|
+
'host_unavailable',
|
|
251
|
+
str(error),
|
|
252
|
+
'Install an adapter package that registers this agent in the '
|
|
253
|
+
'sequential_hooks.hosts entry-point group.',
|
|
254
|
+
)
|
|
255
|
+
return _report(
|
|
256
|
+
DoctorReport(
|
|
257
|
+
host, None, _installed_version(), inspect_platform(), (), (), (unavailable,), ()
|
|
258
|
+
),
|
|
259
|
+
path_value=path_value,
|
|
260
|
+
path_ext=path_ext,
|
|
261
|
+
)
|
|
262
|
+
loader = spec.doctor_loader()
|
|
263
|
+
windows = sys.platform == 'win32'
|
|
264
|
+
sources = loader.discover_sources(home, cwd, system_root)
|
|
265
|
+
registrations, source_findings = loader.load_registrations(sources)
|
|
266
|
+
inspected_registrations = tuple(
|
|
267
|
+
(registration, inspect_command(registration, windows=windows))
|
|
268
|
+
for registration in registrations
|
|
269
|
+
)
|
|
270
|
+
findings = list(source_findings)
|
|
271
|
+
for registration, inspected in inspected_registrations:
|
|
272
|
+
findings.extend(registration.configuration_findings)
|
|
273
|
+
findings.extend(
|
|
274
|
+
analyze_registration(
|
|
275
|
+
spec,
|
|
276
|
+
(registration, inspected),
|
|
277
|
+
path_value=path_value,
|
|
278
|
+
path_ext=path_ext,
|
|
279
|
+
windows=windows,
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
findings.extend(loader.analyze_registration(registration, inspected, windows=windows))
|
|
283
|
+
findings.extend(analyze_registration_pairs(inspected_registrations, windows=windows))
|
|
284
|
+
return _report(
|
|
285
|
+
DoctorReport(
|
|
286
|
+
host,
|
|
287
|
+
None,
|
|
288
|
+
_installed_version(),
|
|
289
|
+
inspect_platform(),
|
|
290
|
+
sources,
|
|
291
|
+
registrations,
|
|
292
|
+
tuple(findings),
|
|
293
|
+
loader.inspection_limitations(),
|
|
294
|
+
),
|
|
295
|
+
path_value=path_value,
|
|
296
|
+
path_ext=path_ext,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def run_doctor( # noqa: PLR0913
|
|
301
|
+
host: str,
|
|
302
|
+
*,
|
|
303
|
+
home: Path,
|
|
304
|
+
cwd: Path,
|
|
305
|
+
system_root: Path,
|
|
306
|
+
path_value: str | None,
|
|
307
|
+
path_ext: str | None,
|
|
308
|
+
stream: DoctorOutput,
|
|
309
|
+
) -> int:
|
|
310
|
+
"""Inspect, render, and return the doctor status.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
host: Entry-point name of the host to inspect; an unloadable host is
|
|
314
|
+
reported as a `host_unavailable` finding.
|
|
315
|
+
home: Injected home directory used for user configuration.
|
|
316
|
+
cwd: Injected working directory used for project configuration.
|
|
317
|
+
system_root: Injected filesystem root used for system configuration.
|
|
318
|
+
path_value: Injected executable search path, if set.
|
|
319
|
+
path_ext: Injected Windows executable suffix list, if set.
|
|
320
|
+
stream: Text output destination for the report.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Zero when findings are informational, otherwise one.
|
|
324
|
+
|
|
325
|
+
Raises:
|
|
326
|
+
OSError: If rendering the report to `stream` fails.
|
|
327
|
+
"""
|
|
328
|
+
report = _inspect_host(
|
|
329
|
+
host,
|
|
330
|
+
home=home,
|
|
331
|
+
cwd=cwd,
|
|
332
|
+
system_root=system_root,
|
|
333
|
+
path_value=path_value,
|
|
334
|
+
path_ext=path_ext,
|
|
335
|
+
)
|
|
336
|
+
_render_report(report, stream)
|
|
337
|
+
return int(
|
|
338
|
+
any(
|
|
339
|
+
finding.severity in {FindingSeverity.ERROR, FindingSeverity.WARNING}
|
|
340
|
+
for finding in report.findings
|
|
341
|
+
)
|
|
342
|
+
)
|