cortex-loop 0.1.0a1__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.
- cortex/__init__.py +7 -0
- cortex/adapters.py +339 -0
- cortex/blocklist.py +51 -0
- cortex/challenges.py +210 -0
- cortex/cli.py +7 -0
- cortex/core.py +601 -0
- cortex/core_helpers.py +190 -0
- cortex/data/identity_preamble.md +5 -0
- cortex/data/layer1_part_a.md +65 -0
- cortex/data/layer1_part_b.md +17 -0
- cortex/executive.py +295 -0
- cortex/foundation.py +185 -0
- cortex/genome.py +348 -0
- cortex/graveyard.py +226 -0
- cortex/hooks/__init__.py +27 -0
- cortex/hooks/_shared.py +167 -0
- cortex/hooks/post_tool_use.py +13 -0
- cortex/hooks/pre_tool_use.py +13 -0
- cortex/hooks/session_start.py +13 -0
- cortex/hooks/stop.py +13 -0
- cortex/invariants.py +258 -0
- cortex/packs.py +118 -0
- cortex/repomap.py +6 -0
- cortex/requirements.py +497 -0
- cortex/retry.py +312 -0
- cortex/stop_contract.py +217 -0
- cortex/stop_payload.py +122 -0
- cortex/stop_policy.py +100 -0
- cortex/stop_runtime.py +400 -0
- cortex/stop_signals.py +75 -0
- cortex/store.py +793 -0
- cortex/templates/__init__.py +10 -0
- cortex/utils.py +58 -0
- cortex_loop-0.1.0a1.dist-info/METADATA +121 -0
- cortex_loop-0.1.0a1.dist-info/RECORD +52 -0
- cortex_loop-0.1.0a1.dist-info/WHEEL +5 -0
- cortex_loop-0.1.0a1.dist-info/entry_points.txt +3 -0
- cortex_loop-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- cortex_loop-0.1.0a1.dist-info/top_level.txt +3 -0
- cortex_ops_cli/__init__.py +3 -0
- cortex_ops_cli/_adapter_validation.py +119 -0
- cortex_ops_cli/_check_report.py +454 -0
- cortex_ops_cli/_check_report_output.py +270 -0
- cortex_ops_cli/_openai_bridge_probe.py +241 -0
- cortex_ops_cli/_openai_bridge_protocol.py +469 -0
- cortex_ops_cli/_runtime_profile_templates.py +341 -0
- cortex_ops_cli/_runtime_profiles.py +445 -0
- cortex_ops_cli/gemini_hooks.py +301 -0
- cortex_ops_cli/main.py +911 -0
- cortex_ops_cli/openai_app_server_bridge.py +375 -0
- cortex_repomap/__init__.py +1 -0
- cortex_repomap/engine.py +1201 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
from ._runtime_profile_templates import (
|
|
12
|
+
render_claude_md,
|
|
13
|
+
render_claude_settings_json,
|
|
14
|
+
render_gemini_md,
|
|
15
|
+
render_gemini_settings_json,
|
|
16
|
+
render_openai_bridge_profile_json,
|
|
17
|
+
render_openai_md,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
CLAUDE_REQUIRED_HOOK_COMMANDS = {
|
|
21
|
+
"PreToolUse": "cortex.hooks.pre_tool_use",
|
|
22
|
+
"PostToolUse": "cortex.hooks.post_tool_use",
|
|
23
|
+
"Stop": "cortex.hooks.stop",
|
|
24
|
+
}
|
|
25
|
+
CLAUDE_HOOK_SCHEMA_NATIVE = "claude_native_v1"
|
|
26
|
+
CLAUDE_HOOK_SCHEMA_LEGACY = "legacy_json_v0"
|
|
27
|
+
CLAUDE_SUPPORTED_HOOK_SCHEMAS = {CLAUDE_HOOK_SCHEMA_NATIVE, CLAUDE_HOOK_SCHEMA_LEGACY}
|
|
28
|
+
CLAUDE_PINNED_HOOK_SCHEMA = CLAUDE_HOOK_SCHEMA_NATIVE
|
|
29
|
+
GEMINI_REQUIRED_HOOK_COMMANDS = {
|
|
30
|
+
"SessionStart": "cortex_ops_cli.gemini_hooks SessionStart",
|
|
31
|
+
"BeforeTool": "cortex_ops_cli.gemini_hooks BeforeTool",
|
|
32
|
+
"AfterTool": "cortex_ops_cli.gemini_hooks AfterTool",
|
|
33
|
+
"AfterAgent": "cortex_ops_cli.gemini_hooks AfterAgent",
|
|
34
|
+
}
|
|
35
|
+
GEMINI_OPTIONAL_HOOK_COMMANDS = {
|
|
36
|
+
"BeforeAgent": "cortex_ops_cli.gemini_hooks BeforeAgent",
|
|
37
|
+
}
|
|
38
|
+
GEMINI_AFTER_AGENT_BRIDGE_PATTERN = "cortex_ops_cli.gemini_hooks AfterAgent"
|
|
39
|
+
CLAUDE_DIRNAME, LEGACY_CLAUDE_DIRNAME = ".claude", "claude"
|
|
40
|
+
GEMINI_DIRNAME = ".gemini"
|
|
41
|
+
OPENAI_DIRNAME = ".codex"
|
|
42
|
+
OPENAI_BRIDGE_PROFILE_FILENAME = "cortex_openai_bridge.json"
|
|
43
|
+
OPENAI_BRIDGE_SCHEMA_VERSION = "openai_app_server_v1"
|
|
44
|
+
OPENAI_BRIDGE_COMMAND_PATTERN = "cortex_ops_cli.openai_app_server_bridge run"
|
|
45
|
+
OPENAI_CODEX_MIN_VERSION = (0, 111, 0)
|
|
46
|
+
OPENAI_CODEX_MIN_VERSION_LABEL = "0.111.0"
|
|
47
|
+
CLAUDE_ADAPTER_PATH = "cortex.adapters.claude:ClaudeAdapter"
|
|
48
|
+
CLAUDE_ADAPTER_ALIASES = {CLAUDE_ADAPTER_PATH, "cortex.adapters:ClaudeAdapter"}
|
|
49
|
+
GEMINI_ADAPTER_PATH = "cortex.adapters.gemini:GeminiAdapter"
|
|
50
|
+
GEMINI_ADAPTER_ALIASES = {GEMINI_ADAPTER_PATH, "cortex.adapters:GeminiAdapter"}
|
|
51
|
+
OPENAI_ADAPTER_PATH = "cortex.adapters.openai:OpenAIAdapter"
|
|
52
|
+
OPENAI_ADAPTER_ALIASES = {OPENAI_ADAPTER_PATH, "cortex.adapters:OpenAIAdapter"}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def runtime_profile_install_spec(
|
|
56
|
+
*,
|
|
57
|
+
root: Path,
|
|
58
|
+
profile: str,
|
|
59
|
+
python_executable: str | None,
|
|
60
|
+
) -> tuple[Path, dict[Path, str], str]:
|
|
61
|
+
normalized = str(profile).strip().lower()
|
|
62
|
+
if normalized == "claude":
|
|
63
|
+
runtime_dir = root / CLAUDE_DIRNAME
|
|
64
|
+
return (
|
|
65
|
+
runtime_dir,
|
|
66
|
+
{
|
|
67
|
+
runtime_dir / "settings.json": render_claude_settings_json(
|
|
68
|
+
python_executable=python_executable,
|
|
69
|
+
schema_version=CLAUDE_PINNED_HOOK_SCHEMA,
|
|
70
|
+
),
|
|
71
|
+
runtime_dir / "CLAUDE.md": render_claude_md(),
|
|
72
|
+
},
|
|
73
|
+
CLAUDE_ADAPTER_PATH,
|
|
74
|
+
)
|
|
75
|
+
if normalized == "gemini":
|
|
76
|
+
runtime_dir = root / GEMINI_DIRNAME
|
|
77
|
+
return (
|
|
78
|
+
runtime_dir,
|
|
79
|
+
{
|
|
80
|
+
runtime_dir / "settings.json": render_gemini_settings_json(
|
|
81
|
+
python_executable=python_executable
|
|
82
|
+
),
|
|
83
|
+
runtime_dir / "GEMINI.md": render_gemini_md(),
|
|
84
|
+
},
|
|
85
|
+
GEMINI_ADAPTER_PATH,
|
|
86
|
+
)
|
|
87
|
+
if normalized == "openai":
|
|
88
|
+
runtime_dir = root / OPENAI_DIRNAME
|
|
89
|
+
return (
|
|
90
|
+
runtime_dir,
|
|
91
|
+
{
|
|
92
|
+
runtime_dir / OPENAI_BRIDGE_PROFILE_FILENAME: render_openai_bridge_profile_json(
|
|
93
|
+
python_executable=python_executable,
|
|
94
|
+
schema_version=OPENAI_BRIDGE_SCHEMA_VERSION,
|
|
95
|
+
),
|
|
96
|
+
runtime_dir / "OPENAI.md": render_openai_md(),
|
|
97
|
+
},
|
|
98
|
+
OPENAI_ADAPTER_PATH,
|
|
99
|
+
)
|
|
100
|
+
raise ValueError(f"Unsupported runtime profile: {profile}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def set_runtime_adapter(config_path: Path, adapter_path: str) -> str:
|
|
104
|
+
if not config_path.exists():
|
|
105
|
+
raise OSError(f"Missing config file: {config_path}")
|
|
106
|
+
lines = config_path.read_text(encoding="utf-8").splitlines()
|
|
107
|
+
out: list[str] = []
|
|
108
|
+
in_runtime = False
|
|
109
|
+
runtime_seen = False
|
|
110
|
+
adapter_written = False
|
|
111
|
+
for line in lines:
|
|
112
|
+
stripped = line.strip()
|
|
113
|
+
section = stripped.startswith("[") and stripped.endswith("]")
|
|
114
|
+
if section:
|
|
115
|
+
if in_runtime and not adapter_written:
|
|
116
|
+
out.append(f'adapter = "{adapter_path}"')
|
|
117
|
+
adapter_written = True
|
|
118
|
+
in_runtime = stripped == "[runtime]"
|
|
119
|
+
runtime_seen = runtime_seen or in_runtime
|
|
120
|
+
out.append(line)
|
|
121
|
+
continue
|
|
122
|
+
if in_runtime and stripped.startswith("adapter"):
|
|
123
|
+
if not adapter_written:
|
|
124
|
+
out.append(f'adapter = "{adapter_path}"')
|
|
125
|
+
adapter_written = True
|
|
126
|
+
continue
|
|
127
|
+
out.append(line)
|
|
128
|
+
if in_runtime and not adapter_written:
|
|
129
|
+
out.append(f'adapter = "{adapter_path}"')
|
|
130
|
+
adapter_written = True
|
|
131
|
+
if not runtime_seen:
|
|
132
|
+
if out and out[-1].strip():
|
|
133
|
+
out.append("")
|
|
134
|
+
out.extend(["[runtime]", f'adapter = "{adapter_path}"'])
|
|
135
|
+
rendered = "\n".join(out).rstrip() + "\n"
|
|
136
|
+
before = config_path.read_text(encoding="utf-8")
|
|
137
|
+
if rendered == before:
|
|
138
|
+
return "unchanged"
|
|
139
|
+
config_path.write_text(rendered, encoding="utf-8")
|
|
140
|
+
return "updated"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def resolve_claude_settings_path(root: Path) -> tuple[Path | None, Path | None]:
|
|
144
|
+
preferred = root / CLAUDE_DIRNAME / "settings.json"
|
|
145
|
+
legacy = root / LEGACY_CLAUDE_DIRNAME / "settings.json"
|
|
146
|
+
if preferred.exists():
|
|
147
|
+
return preferred, legacy
|
|
148
|
+
if legacy.exists():
|
|
149
|
+
return legacy, None
|
|
150
|
+
return None, legacy
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def validate_claude_settings(settings_path: Path) -> tuple[list[str], list[str]]:
|
|
154
|
+
errors: list[str] = []
|
|
155
|
+
warnings: list[str] = []
|
|
156
|
+
try:
|
|
157
|
+
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
158
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
159
|
+
return [f"Failed to read {settings_path}: {exc}"], []
|
|
160
|
+
|
|
161
|
+
hooks = data.get("hooks")
|
|
162
|
+
if not isinstance(hooks, dict):
|
|
163
|
+
return [f"Invalid {settings_path}: missing top-level hooks object"], []
|
|
164
|
+
|
|
165
|
+
for event_name, command_fragment in CLAUDE_REQUIRED_HOOK_COMMANDS.items():
|
|
166
|
+
event_entries = hooks.get(event_name)
|
|
167
|
+
if not isinstance(event_entries, list):
|
|
168
|
+
errors.append(f"Invalid {settings_path}: missing hooks.{event_name} list")
|
|
169
|
+
continue
|
|
170
|
+
commands = _event_hook_commands(event_entries)
|
|
171
|
+
if not any(command_fragment in command for command in commands):
|
|
172
|
+
errors.append(
|
|
173
|
+
f"Invalid {settings_path}: hooks.{event_name} does not contain "
|
|
174
|
+
f"command fragment '{command_fragment}'"
|
|
175
|
+
)
|
|
176
|
+
continue
|
|
177
|
+
matched = [command for command in commands if command_fragment in command]
|
|
178
|
+
schema_versions = {_command_schema_version(command) for command in matched}
|
|
179
|
+
schema_versions.discard(None)
|
|
180
|
+
if not schema_versions:
|
|
181
|
+
errors.append(
|
|
182
|
+
f"Invalid {settings_path}: hooks.{event_name} command must pin --schema-version "
|
|
183
|
+
f"{CLAUDE_PINNED_HOOK_SCHEMA}"
|
|
184
|
+
)
|
|
185
|
+
continue
|
|
186
|
+
if len(schema_versions) != 1:
|
|
187
|
+
errors.append(
|
|
188
|
+
f"Invalid {settings_path}: hooks.{event_name} has mixed schema versions: "
|
|
189
|
+
+ ", ".join(sorted(str(item) for item in schema_versions))
|
|
190
|
+
)
|
|
191
|
+
continue
|
|
192
|
+
schema_version = next(iter(schema_versions))
|
|
193
|
+
if schema_version not in CLAUDE_SUPPORTED_HOOK_SCHEMAS:
|
|
194
|
+
errors.append(
|
|
195
|
+
f"Invalid {settings_path}: hooks.{event_name} schema version '{schema_version}' "
|
|
196
|
+
f"is unsupported (supported: {', '.join(sorted(CLAUDE_SUPPORTED_HOOK_SCHEMAS))})"
|
|
197
|
+
)
|
|
198
|
+
continue
|
|
199
|
+
if schema_version != CLAUDE_PINNED_HOOK_SCHEMA:
|
|
200
|
+
errors.append(
|
|
201
|
+
f"Invalid {settings_path}: hooks.{event_name} schema version '{schema_version}' "
|
|
202
|
+
f"does not match pinned runtime schema '{CLAUDE_PINNED_HOOK_SCHEMA}'"
|
|
203
|
+
)
|
|
204
|
+
return errors, warnings
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def resolve_gemini_settings_path(root: Path) -> Path | None:
|
|
208
|
+
path = root / GEMINI_DIRNAME / "settings.json"
|
|
209
|
+
return path if path.exists() else None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def validate_gemini_settings(settings_path: Path) -> tuple[list[str], list[str]]:
|
|
213
|
+
errors: list[str] = []
|
|
214
|
+
warnings: list[str] = []
|
|
215
|
+
try:
|
|
216
|
+
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
217
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
218
|
+
return [f"Failed to read {settings_path}: {exc}"], []
|
|
219
|
+
|
|
220
|
+
hooks_config = data.get("hooksConfig")
|
|
221
|
+
if isinstance(hooks_config, dict) and hooks_config.get("enabled") is False:
|
|
222
|
+
errors.append(
|
|
223
|
+
"Gemini hooks are globally disabled. "
|
|
224
|
+
"Cortex cannot enforce without hooks. Set hooksConfig.enabled to true or remove the field (defaults to true).",
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
hooks = data.get("hooks")
|
|
228
|
+
if not isinstance(hooks, dict):
|
|
229
|
+
errors.append(f"Invalid {settings_path}: missing top-level hooks object")
|
|
230
|
+
return errors, warnings
|
|
231
|
+
|
|
232
|
+
after_agent_entries = hooks.get("AfterAgent")
|
|
233
|
+
if not isinstance(after_agent_entries, list) or not after_agent_entries:
|
|
234
|
+
errors.append("No AfterAgent hook configured. Cortex stop path cannot fire without this hook.")
|
|
235
|
+
after_agent_configs = _flatten_hook_configs(
|
|
236
|
+
after_agent_entries if isinstance(after_agent_entries, list) else []
|
|
237
|
+
)
|
|
238
|
+
if isinstance(after_agent_entries, list) and after_agent_entries and not after_agent_configs:
|
|
239
|
+
errors.append("No AfterAgent hook configured. Cortex stop path cannot fire without this hook.")
|
|
240
|
+
if after_agent_configs and not all(_is_command_hook(config) for config in after_agent_configs):
|
|
241
|
+
errors.append(
|
|
242
|
+
"AfterAgent hook configuration is invalid. Each hook config requires type: 'command' and a command string."
|
|
243
|
+
)
|
|
244
|
+
after_agent_commands = [
|
|
245
|
+
str(config.get("command") or "").strip()
|
|
246
|
+
for config in after_agent_configs
|
|
247
|
+
if str(config.get("command") or "").strip()
|
|
248
|
+
]
|
|
249
|
+
if after_agent_commands and not any(
|
|
250
|
+
GEMINI_AFTER_AGENT_BRIDGE_PATTERN in command for command in after_agent_commands
|
|
251
|
+
):
|
|
252
|
+
warnings.append(
|
|
253
|
+
"AfterAgent hook command does not appear to be a Cortex hook bridge. "
|
|
254
|
+
"Stop path enforcement may not work. Expected command containing "
|
|
255
|
+
f"'{GEMINI_AFTER_AGENT_BRIDGE_PATTERN}'."
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
before_tool_entries = hooks.get("BeforeTool")
|
|
259
|
+
if not isinstance(before_tool_entries, list) or not before_tool_entries:
|
|
260
|
+
warnings.append("No BeforeTool hook. Tool blocklist enforcement will not run.")
|
|
261
|
+
elif not _flatten_hook_configs(before_tool_entries):
|
|
262
|
+
warnings.append("No BeforeTool hook. Tool blocklist enforcement will not run.")
|
|
263
|
+
|
|
264
|
+
after_tool_entries = hooks.get("AfterTool")
|
|
265
|
+
if not isinstance(after_tool_entries, list) or not after_tool_entries:
|
|
266
|
+
warnings.append("No AfterTool hook. Post-tool failure detection will not run.")
|
|
267
|
+
elif not _flatten_hook_configs(after_tool_entries):
|
|
268
|
+
warnings.append("No AfterTool hook. Post-tool failure detection will not run.")
|
|
269
|
+
|
|
270
|
+
before_agent_entries = hooks.get("BeforeAgent")
|
|
271
|
+
if not isinstance(before_agent_entries, list) or not before_agent_entries:
|
|
272
|
+
warnings.append("No BeforeAgent hook. Per-turn persistent executive anchor injection will not run.")
|
|
273
|
+
elif not _flatten_hook_configs(before_agent_entries):
|
|
274
|
+
warnings.append("No BeforeAgent hook. Per-turn persistent executive anchor injection will not run.")
|
|
275
|
+
|
|
276
|
+
project_root = settings_path.parent.parent
|
|
277
|
+
for command in _hook_commands(hooks):
|
|
278
|
+
if _command_references_missing_path(command, project_root):
|
|
279
|
+
warnings.append(
|
|
280
|
+
f"Hook command may reference a missing path: {command}. Hooks may fail at runtime."
|
|
281
|
+
)
|
|
282
|
+
return errors, warnings
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def resolve_openai_bridge_profile_path(root: Path) -> Path | None:
|
|
286
|
+
path = root / OPENAI_DIRNAME / OPENAI_BRIDGE_PROFILE_FILENAME
|
|
287
|
+
return path if path.exists() else None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def validate_openai_bridge_profile(
|
|
291
|
+
profile_path: Path,
|
|
292
|
+
*,
|
|
293
|
+
which: Callable[[str], str | None] = shutil.which,
|
|
294
|
+
run_exec: Callable[..., Any] = subprocess.run,
|
|
295
|
+
) -> tuple[list[str], list[str]]:
|
|
296
|
+
errors: list[str] = []
|
|
297
|
+
warnings: list[str] = []
|
|
298
|
+
try:
|
|
299
|
+
data = json.loads(profile_path.read_text(encoding="utf-8"))
|
|
300
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
301
|
+
return [f"Failed to read {profile_path}: {exc}"], []
|
|
302
|
+
|
|
303
|
+
if not isinstance(data, dict):
|
|
304
|
+
return [f"Invalid {profile_path}: expected JSON object"], []
|
|
305
|
+
|
|
306
|
+
schema_version = str(data.get("schema_version") or "").strip()
|
|
307
|
+
if schema_version != OPENAI_BRIDGE_SCHEMA_VERSION:
|
|
308
|
+
errors.append(
|
|
309
|
+
f"Invalid {profile_path}: schema_version must be '{OPENAI_BRIDGE_SCHEMA_VERSION}'."
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
bridge = data.get("bridge")
|
|
313
|
+
if not isinstance(bridge, dict):
|
|
314
|
+
errors.append(f"Invalid {profile_path}: missing bridge object.")
|
|
315
|
+
return errors, warnings
|
|
316
|
+
|
|
317
|
+
command = str(bridge.get("command") or "").strip()
|
|
318
|
+
if not command:
|
|
319
|
+
errors.append(f"Invalid {profile_path}: bridge.command is required.")
|
|
320
|
+
return errors, warnings
|
|
321
|
+
if OPENAI_BRIDGE_COMMAND_PATTERN not in command:
|
|
322
|
+
errors.append(
|
|
323
|
+
f"Invalid {profile_path}: bridge.command must contain '{OPENAI_BRIDGE_COMMAND_PATTERN}'."
|
|
324
|
+
)
|
|
325
|
+
command_schema = _command_schema_version(command)
|
|
326
|
+
if command_schema != OPENAI_BRIDGE_SCHEMA_VERSION:
|
|
327
|
+
errors.append(
|
|
328
|
+
f"Invalid {profile_path}: bridge.command must pin --schema-version {OPENAI_BRIDGE_SCHEMA_VERSION}."
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
codex_bin = str(bridge.get("codex_bin") or "").strip()
|
|
332
|
+
if not codex_bin:
|
|
333
|
+
warnings.append(
|
|
334
|
+
f"{profile_path}: bridge.codex_bin is empty; runtime will fallback to 'codex' on PATH."
|
|
335
|
+
)
|
|
336
|
+
return errors, warnings
|
|
337
|
+
|
|
338
|
+
resolved = which(codex_bin)
|
|
339
|
+
if resolved is None:
|
|
340
|
+
errors.append(f"{profile_path}: bridge.codex_bin '{codex_bin}' is not on PATH.")
|
|
341
|
+
return errors, warnings
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
version_run = run_exec(
|
|
345
|
+
[codex_bin, "--version"],
|
|
346
|
+
capture_output=True,
|
|
347
|
+
text=True,
|
|
348
|
+
timeout=5,
|
|
349
|
+
check=False,
|
|
350
|
+
)
|
|
351
|
+
except Exception as exc: # noqa: BLE001
|
|
352
|
+
warnings.append(f"{profile_path}: failed to run '{codex_bin} --version': {exc}")
|
|
353
|
+
return errors, warnings
|
|
354
|
+
|
|
355
|
+
version_text = f"{version_run.stdout}\n{version_run.stderr}"
|
|
356
|
+
version_tuple = _parse_semver_tuple(version_text)
|
|
357
|
+
if version_tuple is None:
|
|
358
|
+
warnings.append(
|
|
359
|
+
f"{profile_path}: unable to parse Codex version from '{codex_bin} --version' output."
|
|
360
|
+
)
|
|
361
|
+
return errors, warnings
|
|
362
|
+
if version_tuple < OPENAI_CODEX_MIN_VERSION:
|
|
363
|
+
warnings.append(
|
|
364
|
+
f"{profile_path}: Codex version {version_tuple[0]}.{version_tuple[1]}.{version_tuple[2]} is below tested baseline {OPENAI_CODEX_MIN_VERSION_LABEL}."
|
|
365
|
+
)
|
|
366
|
+
return errors, warnings
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _parse_semver_tuple(text: str) -> tuple[int, int, int] | None:
|
|
370
|
+
match = re.search(r"(\d+)\.(\d+)\.(\d+)", str(text))
|
|
371
|
+
if not match:
|
|
372
|
+
return None
|
|
373
|
+
return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _flatten_hook_configs(event_entries: list[Any]) -> list[dict[str, Any]]:
|
|
377
|
+
configs: list[dict[str, Any]] = []
|
|
378
|
+
for entry in event_entries:
|
|
379
|
+
if not isinstance(entry, dict):
|
|
380
|
+
continue
|
|
381
|
+
hook_items = entry.get("hooks")
|
|
382
|
+
if not isinstance(hook_items, list):
|
|
383
|
+
continue
|
|
384
|
+
for config in hook_items:
|
|
385
|
+
if isinstance(config, dict):
|
|
386
|
+
configs.append(config)
|
|
387
|
+
return configs
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _is_command_hook(config: dict[str, Any]) -> bool:
|
|
391
|
+
return (
|
|
392
|
+
str(config.get("type") or "").strip() == "command"
|
|
393
|
+
and bool(str(config.get("command") or "").strip())
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _hook_commands(hooks: dict[str, Any]) -> list[str]:
|
|
398
|
+
commands: list[str] = []
|
|
399
|
+
for event_name in [*GEMINI_REQUIRED_HOOK_COMMANDS, *GEMINI_OPTIONAL_HOOK_COMMANDS]:
|
|
400
|
+
event_entries = hooks.get(event_name)
|
|
401
|
+
if not isinstance(event_entries, list):
|
|
402
|
+
continue
|
|
403
|
+
for config in _flatten_hook_configs(event_entries):
|
|
404
|
+
command = str(config.get("command") or "").strip()
|
|
405
|
+
if command:
|
|
406
|
+
commands.append(command)
|
|
407
|
+
return commands
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _command_references_missing_path(command: str, root: Path) -> bool:
|
|
411
|
+
tokens = str(command).split()
|
|
412
|
+
for token in tokens:
|
|
413
|
+
candidate = token.strip().strip("'\"")
|
|
414
|
+
if not candidate or "/" not in candidate:
|
|
415
|
+
continue
|
|
416
|
+
if candidate.startswith("-"):
|
|
417
|
+
continue
|
|
418
|
+
path = Path(candidate)
|
|
419
|
+
if path.is_absolute():
|
|
420
|
+
if path.exists():
|
|
421
|
+
continue
|
|
422
|
+
return True
|
|
423
|
+
if (root / path).exists() or path.exists():
|
|
424
|
+
continue
|
|
425
|
+
return True
|
|
426
|
+
return False
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _event_hook_commands(event_entries: list[Any]) -> list[str]:
|
|
430
|
+
commands: list[str] = []
|
|
431
|
+
for config in _flatten_hook_configs(event_entries):
|
|
432
|
+
command = str(config.get("command") or "").strip()
|
|
433
|
+
if command:
|
|
434
|
+
commands.append(command)
|
|
435
|
+
return commands
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _command_schema_version(command: str) -> str | None:
|
|
439
|
+
parts = shlex.split(command)
|
|
440
|
+
for idx, token in enumerate(parts):
|
|
441
|
+
if token == "--schema-version" and idx + 1 < len(parts):
|
|
442
|
+
return str(parts[idx + 1]).strip() or None
|
|
443
|
+
if token.startswith("--schema-version="):
|
|
444
|
+
return token.split("=", 1)[1].strip() or None
|
|
445
|
+
return None
|