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,3 @@
1
+ """Official Sernixa command-line client."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,3 @@
1
+ from .main import app
2
+
3
+ app()
@@ -0,0 +1,279 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any, Literal
7
+
8
+
9
+ ProviderMode = Literal["plain", "claude", "codex"]
10
+
11
+ SENSITIVE_KEY_PARTS = (
12
+ "api_key",
13
+ "apikey",
14
+ "authorization",
15
+ "bearer",
16
+ "client_secret",
17
+ "password",
18
+ "secret",
19
+ "token",
20
+ )
21
+
22
+
23
+ class AdapterError(ValueError):
24
+ pass
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class CanonicalGovernanceRequest:
29
+ input: dict[str, Any]
30
+ source: ProviderMode
31
+
32
+
33
+ def redact_sensitive(value: Any) -> Any:
34
+ if isinstance(value, dict):
35
+ redacted: dict[str, Any] = {}
36
+ for key, item in value.items():
37
+ normalized = str(key).lower().replace("-", "_")
38
+ if any(part in normalized for part in SENSITIVE_KEY_PARTS):
39
+ redacted[str(key)] = "[redacted]"
40
+ else:
41
+ redacted[str(key)] = redact_sensitive(item)
42
+ return redacted
43
+ if isinstance(value, list):
44
+ return [redact_sensitive(item) for item in value]
45
+ return value
46
+
47
+
48
+ def load_json_payload(content: str) -> Any:
49
+ if not content.strip():
50
+ raise AdapterError("No JSON payload received.")
51
+ try:
52
+ return json.loads(content)
53
+ except json.JSONDecodeError:
54
+ return _load_json_lines(content)
55
+
56
+
57
+ def _load_json_lines(content: str) -> list[Any]:
58
+ items: list[Any] = []
59
+ for line_number, line in enumerate(content.splitlines(), start=1):
60
+ if not line.strip():
61
+ continue
62
+ try:
63
+ items.append(json.loads(line))
64
+ except json.JSONDecodeError as exc:
65
+ raise AdapterError(
66
+ f"Invalid JSON at line {line_number}, column {exc.colno}: {exc.msg}"
67
+ ) from exc
68
+ if not items:
69
+ raise AdapterError("No JSON payload received.")
70
+ return items
71
+
72
+
73
+ def read_payload_source(path: Path | None) -> str:
74
+ import sys
75
+
76
+ try:
77
+ return path.read_text(encoding="utf-8") if path else sys.stdin.read()
78
+ except OSError as exc:
79
+ raise AdapterError(f"Unable to read payload: {exc}") from exc
80
+
81
+
82
+ def canonicalize_payload(raw: Any, *, mode: ProviderMode) -> CanonicalGovernanceRequest:
83
+ if mode == "plain":
84
+ if not isinstance(raw, dict):
85
+ raise AdapterError("Governance payload must be a JSON object.")
86
+ return CanonicalGovernanceRequest(input=raw, source="plain")
87
+ if mode == "claude":
88
+ return CanonicalGovernanceRequest(input=_claude_to_governance(raw), source="claude")
89
+ if mode == "codex":
90
+ return CanonicalGovernanceRequest(input=_codex_to_governance(raw), source="codex")
91
+ raise AdapterError(f"Unsupported payload adapter: {mode}")
92
+
93
+
94
+ def payload_from_content(content: str, *, mode: ProviderMode) -> CanonicalGovernanceRequest:
95
+ raw = load_json_payload(content)
96
+ return canonicalize_payload(raw, mode=mode)
97
+
98
+
99
+ def _claude_to_governance(raw: Any) -> dict[str, Any]:
100
+ if not isinstance(raw, dict):
101
+ raise AdapterError("Claude hook payload must be a JSON object.")
102
+ tool_name = _string(raw.get("tool_name") or raw.get("name"))
103
+ tool_input = raw.get("tool_input")
104
+ if tool_input is None and isinstance(raw.get("input"), dict):
105
+ tool_input = raw["input"]
106
+ if not tool_name:
107
+ raise AdapterError("Claude hook payload is missing tool_name.")
108
+ if tool_input is None:
109
+ raise AdapterError("Claude hook payload is missing tool_input.")
110
+ if not isinstance(tool_input, dict):
111
+ raise AdapterError("Claude hook tool_input must be a JSON object.")
112
+ return _provider_tool_request(
113
+ source="claude",
114
+ hook_event_name=_string(raw.get("hook_event_name")) or "PreToolUse",
115
+ tool_name=tool_name,
116
+ tool_input=tool_input,
117
+ provider_context={
118
+ "session_id": raw.get("session_id"),
119
+ "transcript_path": raw.get("transcript_path"),
120
+ "cwd": raw.get("cwd"),
121
+ "tool_use_id": raw.get("tool_use_id"),
122
+ "user": _first_present(raw, "user", "user_id", "email", "account_id"),
123
+ },
124
+ )
125
+
126
+
127
+ def _codex_to_governance(raw: Any) -> dict[str, Any]:
128
+ event = _select_codex_event(raw)
129
+ if not isinstance(event, dict):
130
+ raise AdapterError("Codex payload must be a JSON object or JSON-lines stream.")
131
+ tool_name = _string(event.get("tool_name") or event.get("name") or event.get("tool"))
132
+ tool_input = event.get("tool_input")
133
+ if tool_input is None:
134
+ if isinstance(event.get("input"), dict):
135
+ tool_input = event["input"]
136
+ elif isinstance(event.get("arguments"), dict):
137
+ tool_input = event["arguments"]
138
+ elif event.get("command") or event.get("cmd"):
139
+ tool_input = {"command": event.get("command") or event.get("cmd")}
140
+ tool_name = tool_name or "Bash"
141
+ if not tool_name:
142
+ raise AdapterError("Codex payload is missing tool_name.")
143
+ if tool_input is None:
144
+ raise AdapterError("Codex payload is missing tool_input.")
145
+ if not isinstance(tool_input, dict):
146
+ raise AdapterError("Codex hook tool_input must be a JSON object.")
147
+ return _provider_tool_request(
148
+ source="codex",
149
+ hook_event_name=_string(event.get("hook_event_name")) or "PreToolUse",
150
+ tool_name=tool_name,
151
+ tool_input=tool_input,
152
+ provider_context={
153
+ "session_id": event.get("session_id"),
154
+ "turn_id": event.get("turn_id"),
155
+ "transcript_path": event.get("transcript_path"),
156
+ "cwd": event.get("cwd"),
157
+ "model": event.get("model"),
158
+ "permission_mode": event.get("permission_mode"),
159
+ "tool_use_id": event.get("tool_use_id"),
160
+ },
161
+ )
162
+
163
+
164
+ def _select_codex_event(raw: Any) -> Any:
165
+ if isinstance(raw, dict):
166
+ return raw
167
+ if not isinstance(raw, list):
168
+ return raw
169
+ candidates = [
170
+ item
171
+ for item in raw
172
+ if isinstance(item, dict)
173
+ and (
174
+ item.get("hook_event_name") in {"PreToolUse", "PermissionRequest"}
175
+ or item.get("tool_name")
176
+ or item.get("command")
177
+ or item.get("cmd")
178
+ )
179
+ ]
180
+ if not candidates:
181
+ raise AdapterError("No recognizable Codex tool event found in JSON-lines stream.")
182
+ return candidates[-1]
183
+
184
+
185
+ def _provider_tool_request(
186
+ *,
187
+ source: ProviderMode,
188
+ hook_event_name: str,
189
+ tool_name: str,
190
+ tool_input: dict[str, Any],
191
+ provider_context: dict[str, Any],
192
+ ) -> dict[str, Any]:
193
+ sanitized_input = redact_sensitive(tool_input)
194
+ action = _action_for_tool(tool_name, sanitized_input)
195
+ return {
196
+ "schema_version": "sernixa.governance_request.v1",
197
+ "source": source,
198
+ "provider": source,
199
+ "hook_event_name": hook_event_name,
200
+ "tool_name": tool_name,
201
+ "action": action["action"],
202
+ "operation_class": action["operation_class"],
203
+ "resource": action.get("resource"),
204
+ "intent": action.get("intent"),
205
+ "parameters": sanitized_input,
206
+ "provider_context": {
207
+ key: value
208
+ for key, value in redact_sensitive(provider_context).items()
209
+ if value is not None
210
+ },
211
+ }
212
+
213
+
214
+ def _action_for_tool(tool_name: str, tool_input: dict[str, Any]) -> dict[str, Any]:
215
+ normalized = tool_name.lower()
216
+ if normalized in {"bash", "shell"}:
217
+ command = _string(tool_input.get("command"))
218
+ return {
219
+ "action": "shell.execute",
220
+ "operation_class": "shell",
221
+ "resource": command,
222
+ "intent": _string(tool_input.get("description")),
223
+ }
224
+ if normalized in {"write", "edit", "multiedit", "apply_patch"}:
225
+ return {
226
+ "action": "file.write",
227
+ "operation_class": "write",
228
+ "resource": _string(tool_input.get("file_path"))
229
+ or _string(tool_input.get("path"))
230
+ or _string(tool_input.get("command")),
231
+ "intent": _string(tool_input.get("description")),
232
+ }
233
+ if normalized in {"read", "glob", "grep", "ls"}:
234
+ return {
235
+ "action": "file.read",
236
+ "operation_class": "read",
237
+ "resource": _string(tool_input.get("file_path"))
238
+ or _string(tool_input.get("path"))
239
+ or _string(tool_input.get("pattern")),
240
+ "intent": _string(tool_input.get("description")),
241
+ }
242
+ if normalized in {"webfetch", "websearch"}:
243
+ return {
244
+ "action": "network.request",
245
+ "operation_class": "network",
246
+ "resource": _string(tool_input.get("url")) or _string(tool_input.get("query")),
247
+ "intent": _string(tool_input.get("prompt")),
248
+ }
249
+ if normalized.startswith("mcp__"):
250
+ return {
251
+ "action": "mcp.tool",
252
+ "operation_class": "mcp",
253
+ "resource": tool_name,
254
+ "intent": _string(tool_input.get("description")),
255
+ }
256
+ if normalized == "agent":
257
+ return {
258
+ "action": "agent.delegate",
259
+ "operation_class": "delegate",
260
+ "resource": _string(tool_input.get("subagent_type")),
261
+ "intent": _string(tool_input.get("prompt")) or _string(tool_input.get("description")),
262
+ }
263
+ return {
264
+ "action": f"tool.{normalized}",
265
+ "operation_class": "tool",
266
+ "resource": tool_name,
267
+ "intent": _string(tool_input.get("description")),
268
+ }
269
+
270
+
271
+ def _string(value: Any) -> str | None:
272
+ return value.strip() if isinstance(value, str) and value.strip() else None
273
+
274
+
275
+ def _first_present(payload: dict[str, Any], *keys: str) -> Any:
276
+ for key in keys:
277
+ if payload.get(key) is not None:
278
+ return payload[key]
279
+ return None
sernixa_cli/config.py ADDED
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path
7
+
8
+ from platformdirs import user_config_path
9
+
10
+ DEFAULT_BASE_URL = "https://api.sernixa.com"
11
+
12
+
13
+ class ConfigError(RuntimeError):
14
+ pass
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class CliConfig:
19
+ api_key: str
20
+ base_url: str = DEFAULT_BASE_URL
21
+
22
+
23
+ def config_path() -> Path:
24
+ override = os.getenv("SERNIXA_CONFIG_FILE", "").strip()
25
+ if override:
26
+ return Path(override).expanduser()
27
+ return user_config_path("sernixa", appauthor=False) / "config.json"
28
+
29
+
30
+ def plan_session_path() -> Path:
31
+ override = os.getenv("SERNIXA_PLAN_SESSION_FILE", "").strip()
32
+ if override:
33
+ return Path(override).expanduser()
34
+ return user_config_path("sernixa", appauthor=False) / "plan-session.json"
35
+
36
+
37
+ def save_plan_session(evaluation: dict[str, object]) -> Path:
38
+ token = evaluation.get("plan_token")
39
+ proofs = evaluation.get("step_proofs")
40
+ if not isinstance(token, str) or not token or not isinstance(proofs, list):
41
+ raise ConfigError(
42
+ "Approved plan response did not include a plan token and step proofs."
43
+ )
44
+ session = {
45
+ "plan_id": evaluation.get("plan_id"),
46
+ "plan_hash": evaluation.get("plan_hash"),
47
+ "plan_token": token,
48
+ "plan_token_id": evaluation.get("plan_token_id"),
49
+ "plan_token_expires_at": evaluation.get("plan_token_expires_at"),
50
+ "policy_version_id": evaluation.get("policy_version_id"),
51
+ "step_proofs": proofs,
52
+ "consumed_proof_ids": [],
53
+ "allowed": True,
54
+ }
55
+ path = plan_session_path()
56
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
57
+ temporary = path.with_suffix(".tmp")
58
+ temporary.write_text(
59
+ json.dumps(session, separators=(",", ":")) + "\n", encoding="utf-8"
60
+ )
61
+ if os.name == "posix":
62
+ temporary.chmod(0o600)
63
+ temporary.replace(path)
64
+ if os.name == "posix":
65
+ path.chmod(0o600)
66
+ return path
67
+
68
+
69
+ def remove_plan_session() -> bool:
70
+ path = plan_session_path()
71
+ if not path.exists():
72
+ return False
73
+ path.unlink()
74
+ return True
75
+
76
+
77
+ def normalize_base_url(value: str) -> str:
78
+ normalized = value.strip().rstrip("/")
79
+ if not normalized.startswith(("https://", "http://")):
80
+ raise ConfigError("API base URL must start with http:// or https://.")
81
+ return normalized
82
+
83
+
84
+ def save_config(config: CliConfig) -> Path:
85
+ path = config_path()
86
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
87
+ temporary = path.with_suffix(".tmp")
88
+ temporary.write_text(json.dumps(asdict(config), indent=2) + "\n", encoding="utf-8")
89
+ if os.name == "posix":
90
+ temporary.chmod(0o600)
91
+ temporary.replace(path)
92
+ if os.name == "posix":
93
+ path.chmod(0o600)
94
+ return path
95
+
96
+
97
+ def load_config(
98
+ *, require_key: bool = True, base_url_override: str | None = None
99
+ ) -> CliConfig:
100
+ env_key = os.getenv("SERNIXA_API_KEY", "").strip()
101
+ env_base_url = os.getenv("SERNIXA_BASE_URL", "").strip()
102
+ stored: dict[str, object] = {}
103
+ path = config_path()
104
+ if path.exists():
105
+ try:
106
+ parsed = json.loads(path.read_text(encoding="utf-8"))
107
+ except (OSError, json.JSONDecodeError) as exc:
108
+ raise ConfigError(f"Configuration file is unreadable: {path}") from exc
109
+ if not isinstance(parsed, dict):
110
+ raise ConfigError(f"Configuration file must contain a JSON object: {path}")
111
+ stored = parsed
112
+
113
+ api_key = env_key or str(stored.get("api_key") or "").strip()
114
+ base_url = normalize_base_url(
115
+ base_url_override
116
+ or env_base_url
117
+ or str(stored.get("base_url") or DEFAULT_BASE_URL)
118
+ )
119
+ if require_key and not api_key:
120
+ raise ConfigError("No API key configured. Run `sernixa auth login` first.")
121
+ return CliConfig(api_key=api_key, base_url=base_url)
122
+
123
+
124
+ def remove_config() -> bool:
125
+ path = config_path()
126
+ if not path.exists():
127
+ return False
128
+ path.unlink()
129
+ return True
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any, Literal
5
+
6
+ import typer
7
+
8
+
9
+ Decision = Literal["allow", "deny"]
10
+
11
+
12
+ def is_allowed(decision: dict[str, Any]) -> bool:
13
+ return str(decision.get("final_decision", "deny")).lower() == "allow"
14
+
15
+
16
+ def exit_code_for_decision(decision: dict[str, Any]) -> int:
17
+ return 0 if is_allowed(decision) else 1
18
+
19
+
20
+ def decision_summary(
21
+ backend_decision: dict[str, Any],
22
+ *,
23
+ include_raw: bool = True,
24
+ request: dict[str, Any] | None = None,
25
+ ) -> dict[str, Any]:
26
+ allowed = is_allowed(backend_decision)
27
+ summary: dict[str, Any] = {
28
+ "decision": "allow" if allowed else "deny",
29
+ "final_decision": backend_decision.get("final_decision", "deny"),
30
+ "violations": _violations(backend_decision, request=request) if not allowed else [],
31
+ "matched_policies": backend_decision.get("matched_policies") or [],
32
+ "reasons": backend_decision.get("reasons") or [],
33
+ }
34
+ if include_raw:
35
+ summary["raw_backend_response"] = backend_decision
36
+ return summary
37
+
38
+
39
+ def render_decision(
40
+ backend_decision: dict[str, Any],
41
+ *,
42
+ json_output: bool = False,
43
+ quiet: bool = False,
44
+ request: dict[str, Any] | None = None,
45
+ ) -> None:
46
+ if quiet:
47
+ return
48
+ allowed = is_allowed(backend_decision)
49
+ if json_output:
50
+ typer.echo(json.dumps(decision_summary(backend_decision, request=request), sort_keys=True))
51
+ return
52
+ if allowed:
53
+ typer.secho("Allowed by Sernixa policy.", fg=typer.colors.GREEN)
54
+ matched = backend_decision.get("matched_policies") or []
55
+ if matched:
56
+ typer.echo(f"Matched policies: {', '.join(str(item) for item in matched)}")
57
+ return
58
+ typer.secho("Denied by Sernixa policy.", fg=typer.colors.RED, err=True)
59
+ for violation in _violations(backend_decision, request=request):
60
+ label = (
61
+ violation.get("policy_id")
62
+ or violation.get("rule")
63
+ or violation.get("principle")
64
+ or "policy"
65
+ )
66
+ category = violation.get("risk_category") or violation.get("category")
67
+ severity = violation.get("severity")
68
+ message = violation.get("message") or violation.get("reason") or "Denied by policy."
69
+ prefix = f"{label}"
70
+ if category:
71
+ prefix = f"{prefix} [{category}]"
72
+ if severity:
73
+ prefix = f"{prefix} severity={severity}"
74
+ typer.echo(f"- {prefix}: {message}", err=True)
75
+ if violation.get("principle"):
76
+ typer.echo(f" principle: {violation['principle']}", err=True)
77
+ if category:
78
+ typer.echo(f" risk category: {category}", err=True)
79
+ if violation.get("severity"):
80
+ typer.echo(f" severity: {violation['severity']}", err=True)
81
+ if violation.get("resource"):
82
+ typer.echo(f" resource: {violation['resource']}", err=True)
83
+ if violation.get("action"):
84
+ typer.echo(f" action: {violation['action']}", err=True)
85
+ if violation.get("remediation"):
86
+ typer.echo(f" remediation: {violation['remediation']}", err=True)
87
+
88
+
89
+ def hook_response(provider: str, backend_decision: dict[str, Any]) -> dict[str, Any] | None:
90
+ if is_allowed(backend_decision):
91
+ return None
92
+ reason = "; ".join(str(item) for item in backend_decision.get("reasons") or []) or (
93
+ "Blocked by Sernixa policy."
94
+ )
95
+ if provider == "codex":
96
+ return {
97
+ "hookSpecificOutput": {
98
+ "hookEventName": "PreToolUse",
99
+ "permissionDecision": "deny",
100
+ "permissionDecisionReason": reason,
101
+ }
102
+ }
103
+ if provider == "claude":
104
+ return {
105
+ "hookSpecificOutput": {
106
+ "hookEventName": "PreToolUse",
107
+ "permissionDecision": "deny",
108
+ "permissionDecisionReason": reason,
109
+ }
110
+ }
111
+ return {"decision": "block", "reason": reason}
112
+
113
+
114
+ def _violations(
115
+ backend_decision: dict[str, Any],
116
+ *,
117
+ request: dict[str, Any] | None = None,
118
+ ) -> list[dict[str, Any]]:
119
+ existing = backend_decision.get("violations")
120
+ if isinstance(existing, list):
121
+ return [
122
+ item
123
+ for item in existing
124
+ if isinstance(item, dict)
125
+ ]
126
+ reasons = backend_decision.get("reasons") or ["No policy reason was returned."]
127
+ matched = backend_decision.get("matched_policies") or []
128
+ violations: list[dict[str, Any]] = []
129
+ for index, reason in enumerate(reasons):
130
+ policy = matched[index] if index < len(matched) else (matched[0] if matched else None)
131
+ violations.append(
132
+ {
133
+ "policy_id": policy,
134
+ "rule": policy,
135
+ "risk_category": backend_decision.get("risk_category"),
136
+ "severity": backend_decision.get("severity"),
137
+ "message": str(reason),
138
+ "resource": request.get("resource") if request else None,
139
+ "action": request.get("action") if request else None,
140
+ "remediation": _first_remediation(backend_decision.get("remediation")),
141
+ }
142
+ )
143
+ return violations
144
+
145
+
146
+ def _first_remediation(value: Any) -> str | None:
147
+ if isinstance(value, list) and value:
148
+ return str(value[0])
149
+ if isinstance(value, str):
150
+ return value
151
+ return None
sernixa_cli/hooks.py ADDED
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ from .local_agent_targets import (
9
+ Provider,
10
+ TargetConfigError,
11
+ TargetID,
12
+ hook_snippet,
13
+ install_target,
14
+ repair_target,
15
+ resolve_local_agent_target,
16
+ target_status,
17
+ )
18
+
19
+
20
+ HookTarget = TargetID
21
+ HookInstallError = TargetConfigError
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class HookInstallResult:
26
+ provider: Provider
27
+ target: HookTarget
28
+ status: str
29
+ path: Path
30
+ created: bool
31
+ backup_path: Path | None = None
32
+ error: str | None = None
33
+ manual_steps: list[str] | None = None
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class HookRepairResult:
38
+ provider: Provider
39
+ target: HookTarget
40
+ status: str
41
+ path: Path
42
+ backup_paths: tuple[Path, ...] = ()
43
+ error: str | None = None
44
+
45
+
46
+ def hook_config_path(provider: Literal["claude", "codex"]) -> Path:
47
+ return resolve_local_agent_target(provider).selected_path
48
+
49
+
50
+ def manual_steps(target: str) -> list[str]:
51
+ resolution = resolve_local_agent_target(target)
52
+ return [
53
+ f"Create or edit {resolution.selected_path}.",
54
+ "Merge in this managed Sernixa configuration:",
55
+ json.dumps(hook_snippet(resolution.target_id), indent=2, sort_keys=True),
56
+ f"Run 'sernixa hook {resolution.provider}' any time to print the hook snippet again.",
57
+ ]
58
+
59
+
60
+ def install_hook(target: str) -> HookInstallResult:
61
+ try:
62
+ resolution = resolve_local_agent_target(target)
63
+ existed_before = resolution.selected_path.exists()
64
+ resolution, status, backup_path = install_target(target)
65
+ except HookInstallError as exc:
66
+ resolution = resolve_local_agent_target(target)
67
+ return HookInstallResult(
68
+ provider=resolution.provider,
69
+ target=resolution.target_id,
70
+ status="manual_required",
71
+ path=resolution.selected_path,
72
+ created=False,
73
+ error=str(exc),
74
+ manual_steps=manual_steps(target),
75
+ )
76
+ return HookInstallResult(
77
+ provider=resolution.provider,
78
+ target=resolution.target_id,
79
+ status=status,
80
+ path=resolution.selected_path,
81
+ created=not existed_before,
82
+ backup_path=backup_path,
83
+ )
84
+
85
+
86
+ def hook_detected(provider: Literal["claude", "codex"]) -> bool:
87
+ return target_status(provider).installed
88
+
89
+
90
+ def repair_hook(target: str) -> HookRepairResult:
91
+ try:
92
+ resolution, status, backup_paths = repair_target(target)
93
+ except HookInstallError as exc:
94
+ resolution = resolve_local_agent_target(target)
95
+ return HookRepairResult(
96
+ provider=resolution.provider,
97
+ target=resolution.target_id,
98
+ status="manual_required",
99
+ path=resolution.selected_path,
100
+ error=str(exc),
101
+ )
102
+ return HookRepairResult(
103
+ provider=resolution.provider,
104
+ target=resolution.target_id,
105
+ status=status,
106
+ path=resolution.selected_path,
107
+ backup_paths=backup_paths,
108
+ )