sernixa-cli 0.1.1__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.
- sernixa_cli/__init__.py +3 -0
- sernixa_cli/__main__.py +3 -0
- sernixa_cli/adapters.py +279 -0
- sernixa_cli/config.py +129 -0
- sernixa_cli/formatting.py +151 -0
- sernixa_cli/hooks.py +108 -0
- sernixa_cli/local_agent_targets.py +1031 -0
- sernixa_cli/main.py +1021 -0
- sernixa_cli-0.1.1.dist-info/METADATA +179 -0
- sernixa_cli-0.1.1.dist-info/RECORD +13 -0
- sernixa_cli-0.1.1.dist-info/WHEEL +5 -0
- sernixa_cli-0.1.1.dist-info/entry_points.txt +2 -0
- sernixa_cli-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import re
|
|
8
|
+
import tomllib
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
Provider = Literal["claude", "codex"]
|
|
15
|
+
TargetID = Literal["claude", "claude-desktop", "codex"]
|
|
16
|
+
ConfigFormat = Literal["json", "toml"]
|
|
17
|
+
|
|
18
|
+
SERNIXA_TOML_BEGIN = "# BEGIN SERNIXA MANAGED CODEX HOOK"
|
|
19
|
+
SERNIXA_TOML_END = "# END SERNIXA MANAGED CODEX HOOK"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TargetConfigError(RuntimeError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class TargetCandidate:
|
|
28
|
+
path: Path
|
|
29
|
+
config_format: ConfigFormat
|
|
30
|
+
role: str
|
|
31
|
+
exists: bool
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class TargetResolution:
|
|
36
|
+
target_id: TargetID
|
|
37
|
+
display_name: str
|
|
38
|
+
provider: Provider
|
|
39
|
+
selected_path: Path
|
|
40
|
+
selected_format: ConfigFormat
|
|
41
|
+
config_scope: str
|
|
42
|
+
install_mechanism: str
|
|
43
|
+
status_detection: str
|
|
44
|
+
candidates: tuple[TargetCandidate, ...]
|
|
45
|
+
selected_reason: str
|
|
46
|
+
uncertainty: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class TargetStatus:
|
|
51
|
+
target_id: TargetID
|
|
52
|
+
target_name: str
|
|
53
|
+
provider: Provider
|
|
54
|
+
supported: bool
|
|
55
|
+
primary_write_target: Path
|
|
56
|
+
selected_format: ConfigFormat
|
|
57
|
+
config_scope: str
|
|
58
|
+
install_mode: str
|
|
59
|
+
status_detection: str
|
|
60
|
+
candidates: tuple[TargetCandidate, ...]
|
|
61
|
+
detected_sources: tuple[str, ...]
|
|
62
|
+
sernixa_installed: bool
|
|
63
|
+
sernixa_installed_in: tuple[str, ...]
|
|
64
|
+
wrapper_valid: bool
|
|
65
|
+
malformed: bool
|
|
66
|
+
parse_errors: tuple[str, ...]
|
|
67
|
+
stale_reasons: tuple[str, ...]
|
|
68
|
+
selected_reason: str
|
|
69
|
+
uncertainty: str | None
|
|
70
|
+
repair_hint_override: str | None = None
|
|
71
|
+
detected_hook_sources: tuple[str, ...] = ()
|
|
72
|
+
inline_toml_hooks_present: bool = False
|
|
73
|
+
hooks_json_present: bool = False
|
|
74
|
+
hooks_declared: bool = False
|
|
75
|
+
hooks_enabled: bool = False
|
|
76
|
+
hooks_effective: bool = False
|
|
77
|
+
activation_hint: str | None = None
|
|
78
|
+
hook_activation: dict[str, dict[str, bool]] | None = None
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def selected_path(self) -> Path:
|
|
82
|
+
return self.primary_write_target
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def installed(self) -> bool:
|
|
86
|
+
return self.sernixa_installed
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def parse_error(self) -> str | None:
|
|
90
|
+
return self.parse_errors[0] if self.parse_errors else None
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def install_command(self) -> str:
|
|
94
|
+
return f"sernixa hook install {self.target_id}"
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def run_command(self) -> str:
|
|
98
|
+
return f"sernixa hook run {self.provider}"
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def repair_hint(self) -> str | None:
|
|
102
|
+
if self.repair_hint_override:
|
|
103
|
+
return self.repair_hint_override
|
|
104
|
+
if self.parse_errors:
|
|
105
|
+
return (
|
|
106
|
+
f"Fix or move the invalid config at {self.primary_write_target}, then rerun "
|
|
107
|
+
f"{self.install_command}."
|
|
108
|
+
)
|
|
109
|
+
if self.malformed:
|
|
110
|
+
return f"Run sernixa hook repair {self.target_id} to normalize Sernixa-managed entries."
|
|
111
|
+
if not self.sernixa_installed:
|
|
112
|
+
return f"Run {self.install_command} to add the Sernixa wrapper."
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def existing_paths(self) -> tuple[Path, ...]:
|
|
117
|
+
return tuple(candidate.path for candidate in self.candidates if candidate.exists)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def alternate_files_found(self) -> tuple[Path, ...]:
|
|
121
|
+
return tuple(path for path in self.existing_paths if path != self.primary_write_target)
|
|
122
|
+
|
|
123
|
+
def to_dict(self) -> dict[str, Any]:
|
|
124
|
+
existing_paths = [_status_path(path) for path in self.existing_paths]
|
|
125
|
+
candidate_paths = [_status_path(candidate.path) for candidate in self.candidates]
|
|
126
|
+
primary_write_target = _status_path(self.primary_write_target)
|
|
127
|
+
return {
|
|
128
|
+
"target_id": self.target_id,
|
|
129
|
+
"target": self.target_id,
|
|
130
|
+
"target_name": self.target_name,
|
|
131
|
+
"supported": self.supported,
|
|
132
|
+
"provider": self.provider,
|
|
133
|
+
"primary_write_target": primary_write_target,
|
|
134
|
+
"selected_path": primary_write_target,
|
|
135
|
+
"path": primary_write_target,
|
|
136
|
+
"candidate_paths": candidate_paths,
|
|
137
|
+
"paths": candidate_paths,
|
|
138
|
+
"existing_paths": existing_paths,
|
|
139
|
+
"file_exists": self.primary_write_target.exists(),
|
|
140
|
+
"config_format": self.selected_format,
|
|
141
|
+
"config_scope": self.config_scope,
|
|
142
|
+
"install_mode": self.install_mode,
|
|
143
|
+
"install_mechanism": self.install_mode,
|
|
144
|
+
"status_detection": self.status_detection,
|
|
145
|
+
"detected_sources": list(self.detected_sources),
|
|
146
|
+
"detected_hook_sources": list(self.detected_hook_sources),
|
|
147
|
+
"sernixa_installed": self.sernixa_installed,
|
|
148
|
+
"sernixa_installed_in": list(self.sernixa_installed_in),
|
|
149
|
+
"installed": self.sernixa_installed,
|
|
150
|
+
"detected": self.sernixa_installed,
|
|
151
|
+
"wrapper_valid": self.wrapper_valid,
|
|
152
|
+
"malformed": self.malformed,
|
|
153
|
+
"parse_errors": list(self.parse_errors),
|
|
154
|
+
"parse_error": self.parse_errors[0] if self.parse_errors else None,
|
|
155
|
+
"stale_reasons": list(self.stale_reasons),
|
|
156
|
+
"stale_reason": self.stale_reasons[0] if self.stale_reasons else None,
|
|
157
|
+
"selected_reason": self.selected_reason,
|
|
158
|
+
"uncertainty": self.uncertainty,
|
|
159
|
+
"alternate_files_found": [
|
|
160
|
+
_status_path(path)
|
|
161
|
+
for path in self.existing_paths
|
|
162
|
+
if path != self.primary_write_target
|
|
163
|
+
],
|
|
164
|
+
"inline_toml_hooks_present": self.inline_toml_hooks_present,
|
|
165
|
+
"hooks_json_present": self.hooks_json_present,
|
|
166
|
+
"hooks_declared": self.hooks_declared,
|
|
167
|
+
"hooks_enabled": self.hooks_enabled,
|
|
168
|
+
"hooks_effective": self.hooks_effective,
|
|
169
|
+
"activation_hint": self.activation_hint,
|
|
170
|
+
"hook_activation": self.hook_activation or {},
|
|
171
|
+
"install_command": self.install_command,
|
|
172
|
+
"run_command": self.run_command,
|
|
173
|
+
"repair_hint": self.repair_hint,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
SUPPORTED_TARGETS: tuple[TargetID, ...] = ("claude", "claude-desktop", "codex")
|
|
178
|
+
|
|
179
|
+
TARGET_DISPLAY_NAMES: dict[TargetID, str] = {
|
|
180
|
+
"claude": "Claude Code",
|
|
181
|
+
"claude-desktop": "Claude Desktop",
|
|
182
|
+
"codex": "Codex",
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
TARGET_METADATA: dict[TargetID, tuple[Provider, str, str, str]] = {
|
|
186
|
+
"claude": (
|
|
187
|
+
"claude",
|
|
188
|
+
"global user config",
|
|
189
|
+
"hook block",
|
|
190
|
+
"JSON hooks.PreToolUse command wrapper",
|
|
191
|
+
),
|
|
192
|
+
"claude-desktop": (
|
|
193
|
+
"claude",
|
|
194
|
+
"global app config",
|
|
195
|
+
"MCP server block",
|
|
196
|
+
"JSON mcpServers.sernixa-governance wrapper",
|
|
197
|
+
),
|
|
198
|
+
"codex": (
|
|
199
|
+
"codex",
|
|
200
|
+
"global user config",
|
|
201
|
+
"hook block",
|
|
202
|
+
"Codex user hooks.json or config.toml Sernixa block",
|
|
203
|
+
),
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def normalize_target(target: str) -> TargetID:
|
|
208
|
+
normalized = target.strip().lower().replace("_", "-")
|
|
209
|
+
aliases: dict[str, TargetID] = {
|
|
210
|
+
"claude": "claude",
|
|
211
|
+
"claude-code": "claude",
|
|
212
|
+
"claude-cli": "claude",
|
|
213
|
+
"claude-desktop": "claude-desktop",
|
|
214
|
+
"codex": "codex",
|
|
215
|
+
}
|
|
216
|
+
try:
|
|
217
|
+
return aliases[normalized]
|
|
218
|
+
except KeyError as exc:
|
|
219
|
+
supported = ", ".join(SUPPORTED_TARGETS)
|
|
220
|
+
raise TargetConfigError(f"Unsupported local agent target '{target}'. Supported targets: {supported}.") from exc
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def resolve_local_agent_target(
|
|
224
|
+
target_name: str,
|
|
225
|
+
*,
|
|
226
|
+
os_name: str | None = None,
|
|
227
|
+
env: dict[str, str] | None = None,
|
|
228
|
+
home: Path | None = None,
|
|
229
|
+
) -> TargetResolution:
|
|
230
|
+
target_id = normalize_target(target_name)
|
|
231
|
+
system = os_name or platform.system()
|
|
232
|
+
env_map = dict(os.environ if env is None else env)
|
|
233
|
+
home_path = _absolute_path(home or Path.home(), env_map)
|
|
234
|
+
provider, config_scope, install_mechanism, status_detection = TARGET_METADATA[target_id]
|
|
235
|
+
if target_id == "claude":
|
|
236
|
+
candidates = (
|
|
237
|
+
TargetCandidate(
|
|
238
|
+
_env_or_default(
|
|
239
|
+
"SERNIXA_CLAUDE_CONFIG_FILE",
|
|
240
|
+
home_path / ".claude" / "settings.json",
|
|
241
|
+
env_map,
|
|
242
|
+
),
|
|
243
|
+
"json",
|
|
244
|
+
"Claude Code user settings",
|
|
245
|
+
False,
|
|
246
|
+
),
|
|
247
|
+
)
|
|
248
|
+
candidates = _with_exists(candidates)
|
|
249
|
+
selected = candidates[0]
|
|
250
|
+
return _resolution(
|
|
251
|
+
target_id,
|
|
252
|
+
provider,
|
|
253
|
+
selected,
|
|
254
|
+
candidates,
|
|
255
|
+
config_scope,
|
|
256
|
+
install_mechanism,
|
|
257
|
+
status_detection,
|
|
258
|
+
"legacy override" if env_map.get("SERNIXA_CLAUDE_CONFIG_FILE") else "Claude Code user settings default",
|
|
259
|
+
)
|
|
260
|
+
if target_id == "claude-desktop":
|
|
261
|
+
return _resolve_claude_desktop(
|
|
262
|
+
target_id,
|
|
263
|
+
provider,
|
|
264
|
+
config_scope,
|
|
265
|
+
install_mechanism,
|
|
266
|
+
status_detection,
|
|
267
|
+
system,
|
|
268
|
+
env_map,
|
|
269
|
+
home_path,
|
|
270
|
+
)
|
|
271
|
+
return _resolve_codex(
|
|
272
|
+
target_id,
|
|
273
|
+
provider,
|
|
274
|
+
config_scope,
|
|
275
|
+
install_mechanism,
|
|
276
|
+
status_detection,
|
|
277
|
+
env_map,
|
|
278
|
+
home_path,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def hook_snippet(target_name: str) -> dict[str, Any]:
|
|
283
|
+
target_id = normalize_target(target_name)
|
|
284
|
+
provider = TARGET_METADATA[target_id][0]
|
|
285
|
+
command = ["sernixa", "hook", "run", provider]
|
|
286
|
+
if target_id == "claude-desktop":
|
|
287
|
+
return {
|
|
288
|
+
"mcpServers": {
|
|
289
|
+
"sernixa-governance": {
|
|
290
|
+
"command": command[0],
|
|
291
|
+
"args": command[1:],
|
|
292
|
+
"env": {},
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if target_id == "claude":
|
|
297
|
+
handler = {
|
|
298
|
+
"type": "command",
|
|
299
|
+
"command": command[0],
|
|
300
|
+
"args": command[1:],
|
|
301
|
+
"timeout": 10,
|
|
302
|
+
"statusMessage": "Checking Sernixa policy",
|
|
303
|
+
}
|
|
304
|
+
else:
|
|
305
|
+
handler = {
|
|
306
|
+
"type": "command",
|
|
307
|
+
"command": " ".join(command),
|
|
308
|
+
"timeout": 10,
|
|
309
|
+
"statusMessage": "Checking Sernixa policy",
|
|
310
|
+
}
|
|
311
|
+
return {"hooks": {"PreToolUse": [{"matcher": "*", "hooks": [handler]}]}}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def install_target(target_name: str) -> tuple[TargetResolution, str, Path | None]:
|
|
315
|
+
resolution = resolve_local_agent_target(target_name)
|
|
316
|
+
path = resolution.selected_path
|
|
317
|
+
changed = False
|
|
318
|
+
if resolution.selected_format == "toml":
|
|
319
|
+
before = _read_text_or_empty(path)
|
|
320
|
+
after = _merge_codex_toml(path, before)
|
|
321
|
+
changed = before != after
|
|
322
|
+
backup_path = _backup(path) if path.exists() and changed else None
|
|
323
|
+
_write_private_text(path, after)
|
|
324
|
+
else:
|
|
325
|
+
existing = _read_json_object(path) if path.exists() else {}
|
|
326
|
+
snippet = hook_snippet(resolution.target_id)
|
|
327
|
+
if resolution.target_id == "claude-desktop":
|
|
328
|
+
merged, changed = _merge_mcp_server(existing, snippet, path)
|
|
329
|
+
else:
|
|
330
|
+
merged, changed = _merge_hooks(existing, snippet, resolution.provider)
|
|
331
|
+
backup_path = _backup(path) if path.exists() and changed else None
|
|
332
|
+
_write_private_text(path, json.dumps(merged, indent=2, sort_keys=True) + "\n")
|
|
333
|
+
return resolution, "installed" if changed else "updated", backup_path
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def target_status(target_name: str) -> TargetStatus:
|
|
337
|
+
resolution = resolve_local_agent_target(target_name)
|
|
338
|
+
if resolution.target_id == "codex":
|
|
339
|
+
return _codex_status(resolution)
|
|
340
|
+
parse_errors: list[str] = []
|
|
341
|
+
detected_sources: list[str] = []
|
|
342
|
+
installed_in: list[str] = []
|
|
343
|
+
stale_reasons: list[str] = []
|
|
344
|
+
malformed = False
|
|
345
|
+
wrapper_valid = False
|
|
346
|
+
path = resolution.selected_path
|
|
347
|
+
if path.exists():
|
|
348
|
+
detected_sources.append(resolution.target_id)
|
|
349
|
+
try:
|
|
350
|
+
parsed = _read_json_object(path)
|
|
351
|
+
if resolution.target_id == "claude-desktop":
|
|
352
|
+
_validate_claude_desktop_config_shape(parsed, path)
|
|
353
|
+
state = _mcp_server_state(parsed)
|
|
354
|
+
else:
|
|
355
|
+
state = _hook_state(parsed, resolution.provider)
|
|
356
|
+
wrapper_valid = state == "valid"
|
|
357
|
+
malformed = state == "malformed"
|
|
358
|
+
if wrapper_valid:
|
|
359
|
+
installed_in.append(resolution.target_id)
|
|
360
|
+
if malformed:
|
|
361
|
+
stale_reasons.append(
|
|
362
|
+
"A Sernixa-managed entry exists but its wrapper command is stale or malformed."
|
|
363
|
+
)
|
|
364
|
+
except (OSError, TargetConfigError, json.JSONDecodeError) as exc:
|
|
365
|
+
parse_errors.append(str(exc))
|
|
366
|
+
malformed = True
|
|
367
|
+
return TargetStatus(
|
|
368
|
+
target_id=resolution.target_id,
|
|
369
|
+
target_name=resolution.display_name,
|
|
370
|
+
provider=resolution.provider,
|
|
371
|
+
supported=True,
|
|
372
|
+
primary_write_target=path,
|
|
373
|
+
selected_format=resolution.selected_format,
|
|
374
|
+
config_scope=resolution.config_scope,
|
|
375
|
+
install_mode=resolution.install_mechanism,
|
|
376
|
+
status_detection=resolution.status_detection,
|
|
377
|
+
candidates=resolution.candidates,
|
|
378
|
+
detected_sources=tuple(detected_sources),
|
|
379
|
+
sernixa_installed=bool(installed_in),
|
|
380
|
+
sernixa_installed_in=tuple(installed_in),
|
|
381
|
+
wrapper_valid=wrapper_valid,
|
|
382
|
+
malformed=malformed,
|
|
383
|
+
parse_errors=tuple(parse_errors),
|
|
384
|
+
stale_reasons=tuple(stale_reasons),
|
|
385
|
+
selected_reason=resolution.selected_reason,
|
|
386
|
+
uncertainty=resolution.uncertainty,
|
|
387
|
+
detected_hook_sources=tuple(detected_sources),
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def all_target_statuses() -> dict[str, dict[str, Any]]:
|
|
392
|
+
return {target: target_status(target).to_dict() for target in SUPPORTED_TARGETS}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def repair_target(target_name: str) -> tuple[TargetResolution, str, tuple[Path, ...]]:
|
|
396
|
+
resolution = resolve_local_agent_target(target_name)
|
|
397
|
+
status = target_status(target_name)
|
|
398
|
+
backups: list[Path] = []
|
|
399
|
+
changed = False
|
|
400
|
+
if resolution.target_id == "codex":
|
|
401
|
+
primary = resolution.selected_path.resolve(strict=False)
|
|
402
|
+
for candidate in resolution.candidates:
|
|
403
|
+
if not candidate.exists:
|
|
404
|
+
continue
|
|
405
|
+
if candidate.config_format == "json":
|
|
406
|
+
parsed = _read_json_object(candidate.path)
|
|
407
|
+
if candidate.path.resolve(strict=False) == primary:
|
|
408
|
+
merged, candidate_changed = _merge_hooks(parsed, hook_snippet("codex"), "codex")
|
|
409
|
+
else:
|
|
410
|
+
merged, candidate_changed = _remove_sernixa_json_hooks(parsed, "codex")
|
|
411
|
+
if candidate_changed:
|
|
412
|
+
backups.append(_backup(candidate.path))
|
|
413
|
+
_write_private_text(candidate.path, json.dumps(merged, indent=2) + "\n")
|
|
414
|
+
changed = True
|
|
415
|
+
else:
|
|
416
|
+
before = _read_text_or_empty(candidate.path)
|
|
417
|
+
if candidate.path.resolve(strict=False) == primary:
|
|
418
|
+
after = _merge_codex_toml(candidate.path, before)
|
|
419
|
+
else:
|
|
420
|
+
after = _remove_sernixa_toml_block(before)
|
|
421
|
+
if after != before:
|
|
422
|
+
backups.append(_backup(candidate.path))
|
|
423
|
+
_write_private_text(candidate.path, after)
|
|
424
|
+
changed = True
|
|
425
|
+
else:
|
|
426
|
+
if status.parse_errors:
|
|
427
|
+
raise TargetConfigError("; ".join(status.parse_errors))
|
|
428
|
+
if not status.primary_write_target.exists():
|
|
429
|
+
resolution, install_status, backup = install_target(target_name)
|
|
430
|
+
return resolution, install_status, tuple(path for path in (backup,) if path)
|
|
431
|
+
parsed = _read_json_object(status.primary_write_target)
|
|
432
|
+
if resolution.target_id == "claude-desktop":
|
|
433
|
+
_validate_claude_desktop_config_shape(parsed, status.primary_write_target)
|
|
434
|
+
merged, changed = _merge_mcp_server(parsed, hook_snippet("claude-desktop"), status.primary_write_target)
|
|
435
|
+
else:
|
|
436
|
+
merged, changed = _merge_hooks(parsed, hook_snippet("claude"), "claude")
|
|
437
|
+
if changed:
|
|
438
|
+
backups.append(_backup(status.primary_write_target))
|
|
439
|
+
_write_private_text(status.primary_write_target, json.dumps(merged, indent=2) + "\n")
|
|
440
|
+
return resolution, "repaired" if changed else "already_valid", tuple(backups)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _codex_status(resolution: TargetResolution) -> TargetStatus:
|
|
444
|
+
parse_errors: list[str] = []
|
|
445
|
+
detected_sources: list[str] = []
|
|
446
|
+
detected_hook_sources: list[str] = []
|
|
447
|
+
installed_in: list[str] = []
|
|
448
|
+
stale_reasons: list[str] = []
|
|
449
|
+
malformed_sources: list[str] = []
|
|
450
|
+
hooks_json_present = False
|
|
451
|
+
inline_toml_hooks_present = False
|
|
452
|
+
hook_activation: dict[str, dict[str, bool]] = {
|
|
453
|
+
"hooks_json": {"declared": False, "enabled": False, "effective": False},
|
|
454
|
+
"config_toml": {"declared": False, "enabled": False, "effective": False},
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
for candidate in resolution.candidates:
|
|
458
|
+
if not candidate.exists:
|
|
459
|
+
continue
|
|
460
|
+
source_id = _codex_source_id(candidate)
|
|
461
|
+
detected_sources.append(source_id)
|
|
462
|
+
if source_id == "hooks_json":
|
|
463
|
+
hooks_json_present = True
|
|
464
|
+
try:
|
|
465
|
+
if candidate.config_format == "json":
|
|
466
|
+
parsed = _read_json_object(candidate.path)
|
|
467
|
+
declared = _json_hooks_declared(parsed)
|
|
468
|
+
if declared:
|
|
469
|
+
detected_hook_sources.append(source_id)
|
|
470
|
+
hook_activation[source_id]["declared"] = declared
|
|
471
|
+
hook_activation[source_id]["enabled"] = declared
|
|
472
|
+
state = _hook_state(parsed, "codex")
|
|
473
|
+
else:
|
|
474
|
+
text = candidate.path.read_text(encoding="utf-8")
|
|
475
|
+
parsed_toml = tomllib.loads(text) if text.strip() else {}
|
|
476
|
+
inline_toml_hooks_present = isinstance(parsed_toml.get("hooks"), dict)
|
|
477
|
+
if inline_toml_hooks_present:
|
|
478
|
+
detected_hook_sources.append(source_id)
|
|
479
|
+
hook_activation[source_id]["declared"] = inline_toml_hooks_present
|
|
480
|
+
hook_activation[source_id]["enabled"] = _toml_hooks_enabled(parsed_toml)
|
|
481
|
+
state = _toml_hook_state(text, parsed_toml)
|
|
482
|
+
if state == "valid":
|
|
483
|
+
installed_in.append(source_id)
|
|
484
|
+
hook_activation[source_id]["effective"] = hook_activation[source_id]["enabled"]
|
|
485
|
+
elif state == "malformed":
|
|
486
|
+
malformed_sources.append(source_id)
|
|
487
|
+
stale_reasons.append(
|
|
488
|
+
f"{source_id} contains a Sernixa-managed hook with stale or malformed wrapper args."
|
|
489
|
+
)
|
|
490
|
+
except tomllib.TOMLDecodeError as exc:
|
|
491
|
+
parse_errors.append(_format_toml_error(candidate.path, exc))
|
|
492
|
+
except (OSError, TargetConfigError, json.JSONDecodeError) as exc:
|
|
493
|
+
parse_errors.append(str(exc))
|
|
494
|
+
|
|
495
|
+
duplicate_install = len(installed_in) > 1
|
|
496
|
+
malformed = bool(parse_errors or malformed_sources or duplicate_install)
|
|
497
|
+
wrapper_valid = bool(installed_in) and not malformed_sources and not parse_errors
|
|
498
|
+
repair_hint_override = None
|
|
499
|
+
activation_hint = _codex_activation_hint(hook_activation)
|
|
500
|
+
if duplicate_install:
|
|
501
|
+
repair_hint_override = (
|
|
502
|
+
"Duplicate Sernixa Codex hooks were found in multiple global config files. "
|
|
503
|
+
"Run sernixa hook repair codex to keep the primary write target and remove duplicate managed entries."
|
|
504
|
+
)
|
|
505
|
+
return TargetStatus(
|
|
506
|
+
target_id=resolution.target_id,
|
|
507
|
+
target_name=resolution.display_name,
|
|
508
|
+
provider=resolution.provider,
|
|
509
|
+
supported=True,
|
|
510
|
+
primary_write_target=resolution.selected_path,
|
|
511
|
+
selected_format=resolution.selected_format,
|
|
512
|
+
config_scope=resolution.config_scope,
|
|
513
|
+
install_mode=resolution.install_mechanism,
|
|
514
|
+
status_detection=resolution.status_detection,
|
|
515
|
+
candidates=resolution.candidates,
|
|
516
|
+
detected_sources=tuple(detected_sources),
|
|
517
|
+
sernixa_installed=bool(installed_in),
|
|
518
|
+
sernixa_installed_in=tuple(installed_in),
|
|
519
|
+
wrapper_valid=wrapper_valid,
|
|
520
|
+
malformed=malformed,
|
|
521
|
+
parse_errors=tuple(parse_errors),
|
|
522
|
+
stale_reasons=tuple(stale_reasons),
|
|
523
|
+
selected_reason=resolution.selected_reason,
|
|
524
|
+
uncertainty=resolution.uncertainty,
|
|
525
|
+
repair_hint_override=repair_hint_override,
|
|
526
|
+
detected_hook_sources=tuple(detected_hook_sources),
|
|
527
|
+
inline_toml_hooks_present=inline_toml_hooks_present,
|
|
528
|
+
hooks_json_present=hooks_json_present,
|
|
529
|
+
hooks_declared=any(item["declared"] for item in hook_activation.values()),
|
|
530
|
+
hooks_enabled=any(item["enabled"] for item in hook_activation.values()),
|
|
531
|
+
hooks_effective=any(item["effective"] for item in hook_activation.values()),
|
|
532
|
+
activation_hint=activation_hint,
|
|
533
|
+
hook_activation=hook_activation,
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _resolve_claude_desktop(
|
|
538
|
+
target_id: TargetID,
|
|
539
|
+
provider: Provider,
|
|
540
|
+
config_scope: str,
|
|
541
|
+
install_mechanism: str,
|
|
542
|
+
status_detection: str,
|
|
543
|
+
system: str,
|
|
544
|
+
env_map: dict[str, str],
|
|
545
|
+
home_path: Path,
|
|
546
|
+
) -> TargetResolution:
|
|
547
|
+
override = env_map.get("SERNIXA_CLAUDE_DESKTOP_CONFIG_FILE", "").strip()
|
|
548
|
+
if override:
|
|
549
|
+
candidates = _with_exists((TargetCandidate(_absolute_path(override, env_map), "json", "env override", False),))
|
|
550
|
+
return _resolution(
|
|
551
|
+
target_id,
|
|
552
|
+
provider,
|
|
553
|
+
candidates[0],
|
|
554
|
+
candidates,
|
|
555
|
+
config_scope,
|
|
556
|
+
install_mechanism,
|
|
557
|
+
status_detection,
|
|
558
|
+
"SERNIXA_CLAUDE_DESKTOP_CONFIG_FILE override",
|
|
559
|
+
)
|
|
560
|
+
if system == "Darwin":
|
|
561
|
+
candidates = (
|
|
562
|
+
TargetCandidate(
|
|
563
|
+
home_path / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json",
|
|
564
|
+
"json",
|
|
565
|
+
"macOS documented Claude Desktop config",
|
|
566
|
+
False,
|
|
567
|
+
),
|
|
568
|
+
)
|
|
569
|
+
uncertainty = None
|
|
570
|
+
elif system == "Windows":
|
|
571
|
+
appdata = env_map.get("APPDATA")
|
|
572
|
+
localappdata = env_map.get("LOCALAPPDATA")
|
|
573
|
+
primary = _absolute_path(appdata, env_map) / "Claude" / "claude_desktop_config.json" if appdata else home_path / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json"
|
|
574
|
+
fallback_paths: list[Path] = []
|
|
575
|
+
if localappdata:
|
|
576
|
+
packages = _absolute_path(localappdata, env_map) / "Packages"
|
|
577
|
+
fallback_paths = sorted(
|
|
578
|
+
packages.glob("*/LocalCache/Roaming/Claude/claude_desktop_config.json")
|
|
579
|
+
)
|
|
580
|
+
candidates = (
|
|
581
|
+
TargetCandidate(primary, "json", "Windows documented Claude Desktop config", False),
|
|
582
|
+
*(
|
|
583
|
+
TargetCandidate(path, "json", "Windows packaged Claude Desktop fallback", False)
|
|
584
|
+
for path in fallback_paths
|
|
585
|
+
),
|
|
586
|
+
)
|
|
587
|
+
uncertainty = None if appdata else "APPDATA is not set; using the conventional AppData/Roaming fallback."
|
|
588
|
+
else:
|
|
589
|
+
candidates = (
|
|
590
|
+
TargetCandidate(
|
|
591
|
+
home_path / ".config" / "Claude" / "claude_desktop_config.json",
|
|
592
|
+
"json",
|
|
593
|
+
"Linux documented Claude Desktop config",
|
|
594
|
+
False,
|
|
595
|
+
),
|
|
596
|
+
)
|
|
597
|
+
uncertainty = None
|
|
598
|
+
candidates = _with_exists(candidates)
|
|
599
|
+
selected = candidates[0]
|
|
600
|
+
if system == "Windows" and not selected.exists:
|
|
601
|
+
selected = next((candidate for candidate in candidates[1:] if candidate.exists), selected)
|
|
602
|
+
reason = selected.role if selected.exists else f"{candidates[0].role} create target"
|
|
603
|
+
return _resolution(
|
|
604
|
+
target_id,
|
|
605
|
+
provider,
|
|
606
|
+
selected,
|
|
607
|
+
candidates,
|
|
608
|
+
config_scope,
|
|
609
|
+
install_mechanism,
|
|
610
|
+
status_detection,
|
|
611
|
+
reason,
|
|
612
|
+
uncertainty,
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _resolve_codex(
|
|
617
|
+
target_id: TargetID,
|
|
618
|
+
provider: Provider,
|
|
619
|
+
config_scope: str,
|
|
620
|
+
install_mechanism: str,
|
|
621
|
+
status_detection: str,
|
|
622
|
+
env_map: dict[str, str],
|
|
623
|
+
home_path: Path,
|
|
624
|
+
) -> TargetResolution:
|
|
625
|
+
hooks_path = _env_or_default("SERNIXA_CODEX_HOOKS_FILE", home_path / ".codex" / "hooks.json", env_map)
|
|
626
|
+
toml_path = _env_or_default("SERNIXA_CODEX_CONFIG_FILE", home_path / ".codex" / "config.toml", env_map)
|
|
627
|
+
hooks_overridden = bool(env_map.get("SERNIXA_CODEX_HOOKS_FILE", "").strip())
|
|
628
|
+
candidates = _with_exists(
|
|
629
|
+
(
|
|
630
|
+
TargetCandidate(hooks_path, "json", "Codex hooks.json", False),
|
|
631
|
+
TargetCandidate(toml_path, "toml", "Codex config.toml", False),
|
|
632
|
+
)
|
|
633
|
+
)
|
|
634
|
+
hooks_candidate, toml_candidate = candidates
|
|
635
|
+
if hooks_overridden:
|
|
636
|
+
selected = hooks_candidate
|
|
637
|
+
reason = "SERNIXA_CODEX_HOOKS_FILE override"
|
|
638
|
+
elif hooks_candidate.exists:
|
|
639
|
+
selected = hooks_candidate
|
|
640
|
+
reason = "Codex hooks.json exists and is the preferred hook target"
|
|
641
|
+
elif toml_candidate.exists:
|
|
642
|
+
selected = toml_candidate
|
|
643
|
+
reason = "Codex config.toml exists and hooks.json is absent"
|
|
644
|
+
else:
|
|
645
|
+
selected = hooks_candidate
|
|
646
|
+
reason = "Codex hooks.json preferred create target; project-scoped .codex is intentionally ignored"
|
|
647
|
+
return _resolution(
|
|
648
|
+
target_id,
|
|
649
|
+
provider,
|
|
650
|
+
selected,
|
|
651
|
+
candidates,
|
|
652
|
+
config_scope,
|
|
653
|
+
install_mechanism,
|
|
654
|
+
status_detection,
|
|
655
|
+
reason,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _resolution(
|
|
660
|
+
target_id: TargetID,
|
|
661
|
+
provider: Provider,
|
|
662
|
+
selected: TargetCandidate,
|
|
663
|
+
candidates: tuple[TargetCandidate, ...],
|
|
664
|
+
config_scope: str,
|
|
665
|
+
install_mechanism: str,
|
|
666
|
+
status_detection: str,
|
|
667
|
+
selected_reason: str,
|
|
668
|
+
uncertainty: str | None = None,
|
|
669
|
+
) -> TargetResolution:
|
|
670
|
+
return TargetResolution(
|
|
671
|
+
target_id=target_id,
|
|
672
|
+
display_name=TARGET_DISPLAY_NAMES[target_id],
|
|
673
|
+
provider=provider,
|
|
674
|
+
selected_path=selected.path,
|
|
675
|
+
selected_format=selected.config_format,
|
|
676
|
+
config_scope=config_scope,
|
|
677
|
+
install_mechanism=install_mechanism,
|
|
678
|
+
status_detection=status_detection,
|
|
679
|
+
candidates=candidates,
|
|
680
|
+
selected_reason=selected_reason,
|
|
681
|
+
uncertainty=uncertainty,
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _env_or_default(name: str, default: Path, env_map: dict[str, str]) -> Path:
|
|
686
|
+
override = env_map.get(name, "").strip()
|
|
687
|
+
return _absolute_path(override, env_map) if override else _absolute_path(default, env_map)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _absolute_path(value: str | Path, env_map: dict[str, str]) -> Path:
|
|
691
|
+
expanded = str(value)
|
|
692
|
+
for name, replacement in env_map.items():
|
|
693
|
+
expanded = expanded.replace(f"${name}", replacement)
|
|
694
|
+
expanded = expanded.replace(f"${{{name}}}", replacement)
|
|
695
|
+
expanded = os.path.expandvars(expanded)
|
|
696
|
+
for name, replacement in env_map.items():
|
|
697
|
+
expanded = expanded.replace(f"%{name}%", replacement)
|
|
698
|
+
return Path(expanded).expanduser().resolve(strict=False)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _status_path(path: Path) -> str:
|
|
702
|
+
return str(path.expanduser().resolve(strict=False))
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _with_exists(candidates: tuple[TargetCandidate, ...]) -> tuple[TargetCandidate, ...]:
|
|
706
|
+
return tuple(
|
|
707
|
+
TargetCandidate(candidate.path, candidate.config_format, candidate.role, candidate.path.exists())
|
|
708
|
+
for candidate in candidates
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _read_text_or_empty(path: Path) -> str:
|
|
713
|
+
if not path.exists():
|
|
714
|
+
return ""
|
|
715
|
+
return path.read_text(encoding="utf-8")
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _read_json_object(path: Path) -> dict[str, Any]:
|
|
719
|
+
try:
|
|
720
|
+
raw = path.read_text(encoding="utf-8")
|
|
721
|
+
except OSError as exc:
|
|
722
|
+
raise TargetConfigError(f"Unable to read hook config: {path}") from exc
|
|
723
|
+
if not raw.strip():
|
|
724
|
+
return {}
|
|
725
|
+
try:
|
|
726
|
+
parsed = json.loads(raw)
|
|
727
|
+
except json.JSONDecodeError as exc:
|
|
728
|
+
raise TargetConfigError(f"Invalid JSON in {path}: line {exc.lineno}, column {exc.colno}: {exc.msg}") from exc
|
|
729
|
+
if not isinstance(parsed, dict):
|
|
730
|
+
raise TargetConfigError(f"Hook config must be a JSON object: {path}")
|
|
731
|
+
return parsed
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _validate_claude_desktop_config_shape(config: dict[str, Any], path: Path) -> None:
|
|
735
|
+
servers = config.get("mcpServers")
|
|
736
|
+
if servers is not None and not isinstance(servers, dict):
|
|
737
|
+
raise TargetConfigError(
|
|
738
|
+
f"Claude Desktop config at {path} has an unexpected mcpServers shape; "
|
|
739
|
+
"expected an object keyed by MCP server name."
|
|
740
|
+
)
|
|
741
|
+
if isinstance(servers, dict):
|
|
742
|
+
for name, server in servers.items():
|
|
743
|
+
if not isinstance(server, dict):
|
|
744
|
+
raise TargetConfigError(
|
|
745
|
+
f"Claude Desktop config at {path} has an unexpected mcpServers.{name} shape; "
|
|
746
|
+
"expected an object with command/args/env fields."
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _merge_hooks(existing: dict[str, Any], snippet: dict[str, Any], provider: Provider) -> tuple[dict[str, Any], bool]:
|
|
751
|
+
merged = copy.deepcopy(existing)
|
|
752
|
+
hooks = merged.setdefault("hooks", {})
|
|
753
|
+
if not isinstance(hooks, dict):
|
|
754
|
+
raise TargetConfigError("Existing hooks field is not a JSON object.")
|
|
755
|
+
changed = False
|
|
756
|
+
for event, groups in snippet["hooks"].items():
|
|
757
|
+
existing_groups = hooks.setdefault(event, [])
|
|
758
|
+
if not isinstance(existing_groups, list):
|
|
759
|
+
raise TargetConfigError(f"Existing hooks.{event} field is not a JSON array.")
|
|
760
|
+
expected_group = groups[0]
|
|
761
|
+
has_expected = any(_group_has_expected_wrapper(group, provider) for group in existing_groups)
|
|
762
|
+
cleaned_groups: list[Any] = []
|
|
763
|
+
kept_expected = False
|
|
764
|
+
for group in existing_groups:
|
|
765
|
+
is_sernixa = _is_sernixa_group_for_provider(group, provider)
|
|
766
|
+
is_expected = _group_has_expected_wrapper(group, provider)
|
|
767
|
+
if not is_sernixa:
|
|
768
|
+
cleaned_groups.append(group)
|
|
769
|
+
continue
|
|
770
|
+
if is_expected and not kept_expected:
|
|
771
|
+
cleaned_groups.append(group)
|
|
772
|
+
kept_expected = True
|
|
773
|
+
continue
|
|
774
|
+
changed = True
|
|
775
|
+
if not has_expected:
|
|
776
|
+
cleaned_groups.append(expected_group)
|
|
777
|
+
changed = True
|
|
778
|
+
hooks[event] = cleaned_groups
|
|
779
|
+
return merged, changed
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _remove_sernixa_json_hooks(existing: dict[str, Any], provider: Provider) -> tuple[dict[str, Any], bool]:
|
|
783
|
+
merged = copy.deepcopy(existing)
|
|
784
|
+
hooks = merged.get("hooks")
|
|
785
|
+
if not isinstance(hooks, dict):
|
|
786
|
+
return merged, False
|
|
787
|
+
changed = False
|
|
788
|
+
for event, groups in list(hooks.items()):
|
|
789
|
+
if not isinstance(groups, list):
|
|
790
|
+
continue
|
|
791
|
+
cleaned = [
|
|
792
|
+
group
|
|
793
|
+
for group in groups
|
|
794
|
+
if not _is_sernixa_group_for_provider(group, provider)
|
|
795
|
+
]
|
|
796
|
+
if len(cleaned) != len(groups):
|
|
797
|
+
hooks[event] = cleaned
|
|
798
|
+
changed = True
|
|
799
|
+
return merged, changed
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _merge_mcp_server(existing: dict[str, Any], snippet: dict[str, Any], path: Path) -> tuple[dict[str, Any], bool]:
|
|
803
|
+
merged = copy.deepcopy(existing)
|
|
804
|
+
_validate_claude_desktop_config_shape(merged, path)
|
|
805
|
+
servers = merged.setdefault("mcpServers", {})
|
|
806
|
+
expected = snippet["mcpServers"]["sernixa-governance"]
|
|
807
|
+
current = servers.get("sernixa-governance")
|
|
808
|
+
if current == expected:
|
|
809
|
+
return merged, False
|
|
810
|
+
servers["sernixa-governance"] = expected
|
|
811
|
+
return merged, True
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _merge_codex_toml(path: Path, before: str) -> str:
|
|
815
|
+
if before.strip():
|
|
816
|
+
try:
|
|
817
|
+
tomllib.loads(before)
|
|
818
|
+
except tomllib.TOMLDecodeError as exc:
|
|
819
|
+
raise TargetConfigError(_format_toml_error(path, exc)) from exc
|
|
820
|
+
block = _codex_toml_block()
|
|
821
|
+
if SERNIXA_TOML_BEGIN in before or SERNIXA_TOML_END in before:
|
|
822
|
+
pattern = re.compile(
|
|
823
|
+
rf"{re.escape(SERNIXA_TOML_BEGIN)}.*?{re.escape(SERNIXA_TOML_END)}\n?",
|
|
824
|
+
re.DOTALL,
|
|
825
|
+
)
|
|
826
|
+
return pattern.sub(block, before)
|
|
827
|
+
separator = "\n" if before.endswith("\n") or not before else "\n\n"
|
|
828
|
+
return f"{before}{separator}{block}"
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _remove_sernixa_toml_block(text: str) -> str:
|
|
832
|
+
if SERNIXA_TOML_BEGIN not in text and SERNIXA_TOML_END not in text:
|
|
833
|
+
return text
|
|
834
|
+
pattern = re.compile(
|
|
835
|
+
rf"\n?{re.escape(SERNIXA_TOML_BEGIN)}.*?{re.escape(SERNIXA_TOML_END)}\n?",
|
|
836
|
+
re.DOTALL,
|
|
837
|
+
)
|
|
838
|
+
stripped = pattern.sub("\n", text)
|
|
839
|
+
return re.sub(r"\n{3,}", "\n\n", stripped).lstrip("\n")
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _codex_toml_block() -> str:
|
|
843
|
+
return (
|
|
844
|
+
f"{SERNIXA_TOML_BEGIN}\n"
|
|
845
|
+
"[hooks.PreToolUse.sernixa]\n"
|
|
846
|
+
'command = "sernixa"\n'
|
|
847
|
+
'args = ["hook", "run", "codex"]\n'
|
|
848
|
+
"timeout = 10\n"
|
|
849
|
+
'statusMessage = "Checking Sernixa policy"\n'
|
|
850
|
+
f"{SERNIXA_TOML_END}\n"
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _format_toml_error(path: Path, exc: tomllib.TOMLDecodeError) -> str:
|
|
855
|
+
line = getattr(exc, "lineno", None)
|
|
856
|
+
column = getattr(exc, "colno", None)
|
|
857
|
+
message = getattr(exc, "msg", None) or str(exc)
|
|
858
|
+
if line is not None and column is not None:
|
|
859
|
+
return f"Invalid TOML in {path}: line {line}, column {column}: {message}"
|
|
860
|
+
return f"Invalid TOML in {path}: {message}"
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _hook_state(config: dict[str, Any], provider: Provider) -> Literal["valid", "malformed", "missing"]:
|
|
864
|
+
hooks = config.get("hooks")
|
|
865
|
+
if not isinstance(hooks, dict):
|
|
866
|
+
return "missing"
|
|
867
|
+
groups = hooks.get("PreToolUse")
|
|
868
|
+
if not isinstance(groups, list):
|
|
869
|
+
return "missing"
|
|
870
|
+
saw_sernixa = False
|
|
871
|
+
for group in groups:
|
|
872
|
+
if _group_has_expected_wrapper(group, provider):
|
|
873
|
+
return "valid"
|
|
874
|
+
if _is_sernixa_group_for_provider(group, provider):
|
|
875
|
+
saw_sernixa = True
|
|
876
|
+
return "malformed" if saw_sernixa else "missing"
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _json_hooks_declared(config: dict[str, Any]) -> bool:
|
|
880
|
+
hooks = config.get("hooks")
|
|
881
|
+
if not isinstance(hooks, dict):
|
|
882
|
+
return False
|
|
883
|
+
return bool(hooks)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _toml_hooks_enabled(config: dict[str, Any]) -> bool:
|
|
887
|
+
hooks = config.get("hooks")
|
|
888
|
+
if isinstance(hooks, dict) and hooks.get("enabled") is True:
|
|
889
|
+
return True
|
|
890
|
+
return config.get("hooks_enabled") is True or config.get("enable_hooks") is True
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _codex_activation_hint(activation: dict[str, dict[str, bool]]) -> str | None:
|
|
894
|
+
declared = [source for source, item in activation.items() if item["declared"]]
|
|
895
|
+
enabled = [source for source, item in activation.items() if item["enabled"]]
|
|
896
|
+
effective = [source for source, item in activation.items() if item["effective"]]
|
|
897
|
+
if effective:
|
|
898
|
+
if len(declared) > 1:
|
|
899
|
+
return (
|
|
900
|
+
"Codex hooks are effective, but declarations exist in multiple global files; "
|
|
901
|
+
"review duplicate declarations if behavior is surprising."
|
|
902
|
+
)
|
|
903
|
+
return None
|
|
904
|
+
if declared and not enabled:
|
|
905
|
+
if declared == ["config_toml"]:
|
|
906
|
+
return (
|
|
907
|
+
"Codex hook declarations exist only in config.toml, but no hooks enablement flag "
|
|
908
|
+
"was found. Set hooks.enabled = true or install into hooks.json."
|
|
909
|
+
)
|
|
910
|
+
return "Codex hook declarations exist, but no active hook source was detected."
|
|
911
|
+
if enabled and not declared:
|
|
912
|
+
return "Codex hooks appear enabled, but no hook declarations were found."
|
|
913
|
+
return None
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _toml_hook_state(text: str, parsed: dict[str, Any]) -> Literal["valid", "malformed", "missing"]:
|
|
917
|
+
hooks = parsed.get("hooks")
|
|
918
|
+
if not isinstance(hooks, dict):
|
|
919
|
+
return "malformed" if _toml_has_sernixa_block(text) else "missing"
|
|
920
|
+
if _toml_hooks_have_expected_wrapper(hooks):
|
|
921
|
+
return "valid"
|
|
922
|
+
return "malformed" if _toml_hooks_mention_sernixa(hooks) or _toml_has_sernixa_block(text) else "missing"
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def _mcp_server_state(config: dict[str, Any]) -> Literal["valid", "malformed", "missing"]:
|
|
926
|
+
servers = config.get("mcpServers")
|
|
927
|
+
if not isinstance(servers, dict):
|
|
928
|
+
return "missing"
|
|
929
|
+
server = servers.get("sernixa-governance")
|
|
930
|
+
if not isinstance(server, dict):
|
|
931
|
+
return "missing"
|
|
932
|
+
if server.get("command") == "sernixa" and server.get("args") == ["hook", "run", "claude"]:
|
|
933
|
+
return "valid"
|
|
934
|
+
if "sernixa" in json.dumps(server, sort_keys=True):
|
|
935
|
+
return "malformed"
|
|
936
|
+
return "missing"
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _group_has_expected_wrapper(group: Any, provider: Provider) -> bool:
|
|
940
|
+
return _expected_command(provider) in _commands(group)
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _is_sernixa_group_for_provider(group: Any, provider: Provider) -> bool:
|
|
944
|
+
return any("sernixa" in command and provider in command for command in _commands(group))
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _commands(group: Any) -> set[str]:
|
|
948
|
+
if not isinstance(group, dict):
|
|
949
|
+
return set()
|
|
950
|
+
commands: set[str] = set()
|
|
951
|
+
for hook in group.get("hooks") or []:
|
|
952
|
+
if not isinstance(hook, dict):
|
|
953
|
+
continue
|
|
954
|
+
command = hook.get("command")
|
|
955
|
+
if not isinstance(command, str):
|
|
956
|
+
continue
|
|
957
|
+
args = hook.get("args")
|
|
958
|
+
if isinstance(args, list):
|
|
959
|
+
commands.add(" ".join([command, *[str(item) for item in args]]))
|
|
960
|
+
commands.add(command)
|
|
961
|
+
return commands
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _codex_source_id(candidate: TargetCandidate) -> str:
|
|
965
|
+
return "hooks_json" if candidate.config_format == "json" else "config_toml"
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _toml_hooks_have_expected_wrapper(hooks: dict[str, Any]) -> bool:
|
|
969
|
+
return _toml_find_sernixa_wrapper(hooks, expect_valid=True)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _toml_hooks_mention_sernixa(hooks: dict[str, Any]) -> bool:
|
|
973
|
+
return _toml_find_sernixa_wrapper(hooks, expect_valid=False)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _toml_find_sernixa_wrapper(value: Any, *, expect_valid: bool) -> bool:
|
|
977
|
+
if isinstance(value, dict):
|
|
978
|
+
command = value.get("command")
|
|
979
|
+
args = value.get("args")
|
|
980
|
+
if isinstance(command, str):
|
|
981
|
+
full = " ".join([command, *[str(item) for item in args]]) if isinstance(args, list) else command
|
|
982
|
+
if expect_valid and full == "sernixa hook run codex":
|
|
983
|
+
return True
|
|
984
|
+
if not expect_valid and "sernixa" in full and "codex" in full:
|
|
985
|
+
return True
|
|
986
|
+
return any(_toml_find_sernixa_wrapper(item, expect_valid=expect_valid) for item in value.values())
|
|
987
|
+
if isinstance(value, list):
|
|
988
|
+
return any(_toml_find_sernixa_wrapper(item, expect_valid=expect_valid) for item in value)
|
|
989
|
+
if isinstance(value, str) and not expect_valid:
|
|
990
|
+
return "sernixa" in value and "codex" in value
|
|
991
|
+
return False
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _expected_command(provider: Provider) -> str:
|
|
995
|
+
if provider == "claude":
|
|
996
|
+
return "sernixa hook run claude"
|
|
997
|
+
return "sernixa hook run codex"
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def _toml_has_sernixa_block(text: str) -> bool:
|
|
1001
|
+
return SERNIXA_TOML_BEGIN in text or "sernixa" in text and "hook" in text and "codex" in text
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _toml_has_expected_codex_block(text: str) -> bool:
|
|
1005
|
+
return (
|
|
1006
|
+
SERNIXA_TOML_BEGIN in text
|
|
1007
|
+
and 'command = "sernixa"' in text
|
|
1008
|
+
and 'args = ["hook", "run", "codex"]' in text
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def _write_private_text(path: Path, content: str) -> None:
|
|
1013
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
1014
|
+
temporary = path.with_suffix(path.suffix + ".tmp")
|
|
1015
|
+
temporary.write_text(content, encoding="utf-8")
|
|
1016
|
+
if os.name == "posix":
|
|
1017
|
+
temporary.chmod(0o600)
|
|
1018
|
+
temporary.replace(path)
|
|
1019
|
+
if os.name == "posix":
|
|
1020
|
+
path.chmod(0o600)
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def _backup(path: Path) -> Path:
|
|
1024
|
+
from datetime import datetime, timezone
|
|
1025
|
+
|
|
1026
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
1027
|
+
backup_path = path.with_name(f"{path.name}.sernixa-backup-{stamp}")
|
|
1028
|
+
backup_path.write_bytes(path.read_bytes())
|
|
1029
|
+
if os.name == "posix":
|
|
1030
|
+
backup_path.chmod(0o600)
|
|
1031
|
+
return backup_path
|