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.
@@ -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