Coverage for .github / scripts / autopilot_ci_contract.py: 100.00%
321 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""Single source for the Autopilot facts and assertions CI jobs share.
3Unit, dev-container, and boot jobs use this module instead of duplicating
4expected models, pins, generated-config shapes, or companion-server lists.
5Every fact is derived from production modules, so no release literal lives
6here to update separately.
8Print subcommands accept ``--engine`` (default: ``claude-code``):
10 pin selected engine's pinned CLI version
11 install-command exact lazy npm install command
12 default-model shipped Bedrock default model id
13 expected-servers expected MCP server names, one per line
15Verification subcommands:
17 verify-config PATH validate Claude's --print-config JSON
18 verify-codex-config PATH validate Codex's --print-config TOML
19 [--region REGION] require the Bedrock provider region
20 verify-opencode-config PATH validate OpenCode's --print-config JSON
21 [--region REGION] require the Bedrock provider region
22 [--small-model MODEL] expected small_model (default: session model)
23 [--profile NAME] require this pinned AWS profile
24 [--no-profile] require no pinned AWS profile (env creds)
25 [--no-companions] expect only the gco server
26 [--expect-gco-env K=V] require a gco-only env pair (repeatable)
27 [--gco-args ARG] require exact gco args (repeatable, ordered)
28 verify-plan PATH validate a -o json --dry-run plan
29 [--engine ENGINE]
30 [--claude-binary present|absent]
31 [--codex-binary present|absent]
32 [--opencode-binary present|absent]
34Importable for pytest: ``expected_servers()``, ``verify_config()``,
35``verify_codex_config()``, ``verify_opencode_config()``, ``verify_plan()``,
36``main()``.
37"""
39from __future__ import annotations
41import argparse
42import json
43import sys
44import tomllib
45from pathlib import Path
46from typing import Any
48_REPO_ROOT = Path(__file__).resolve().parents[2]
49if str(_REPO_ROOT) not in sys.path:
50 sys.path.insert(0, str(_REPO_ROOT))
52from cli.autopilot import ( # noqa: E402
53 CLAUDE_CODE_PACKAGE,
54 CLAUDE_CODE_VERSION,
55 CODEX_BEDROCK_PROVIDER,
56 CODEX_MCP_STARTUP_TIMEOUT_SECONDS,
57 CODEX_PACKAGE,
58 CODEX_VERSION,
59 COMPANION_MCP_SERVERS,
60 OPENCODE_BEDROCK_PROVIDER,
61 OPENCODE_MCP_TIMEOUT_MS,
62 OPENCODE_PACKAGE,
63 OPENCODE_VERSION,
64 AutopilotEngine,
65 claude_install_command,
66 codex_install_command,
67 opencode_install_command,
68)
69from gco.bedrock import ( # noqa: E402
70 get_default_claude_code_model_id,
71 get_default_codex_model_id,
72 get_default_codex_reasoning_effort,
73 get_default_opencode_model_id,
74)
76#: Companions deliberately pruned from the curated registry; their names
77#: must never reappear anywhere in a generated session config.
78PRUNED_PACKAGES: tuple[str, ...] = ("mcp-server-fetch", "mcp-server-calculator")
79_ENGINE_CHOICES = tuple(engine.value for engine in AutopilotEngine)
81#: Dry-run plan fields that must be populated for the selected engine and
82#: ``None`` for every other engine (``(pin_field, binary_field)``).
83_PLAN_ENGINE_FIELDS: dict[AutopilotEngine, tuple[str, str]] = {
84 AutopilotEngine.CLAUDE_CODE: ("claude_code_pin", "claude_binary"),
85 AutopilotEngine.CODEX: ("codex_pin", "codex_binary"),
86 AutopilotEngine.OPENCODE: ("opencode_pin", "opencode_binary"),
87}
89#: Generated-config plan fields, keyed by the only engine allowed to fill them.
90_PLAN_GENERATED_CONFIG_FIELDS: dict[str, AutopilotEngine] = {
91 "codex_config": AutopilotEngine.CODEX,
92 "opencode_config": AutopilotEngine.OPENCODE,
93}
95#: Permission floor the generated OpenCode config must carry.
96OPENCODE_PERMISSION_FLOOR: dict[str, str] = {"edit": "ask", "bash": "ask"}
99def expected_servers(include_companions: bool = True) -> list[str]:
100 """Return the exact MCP server set a generated session config must carry."""
101 names = {"gco"}
102 if include_companions:
103 names |= {companion.name for companion in COMPANION_MCP_SERVERS}
104 return sorted(names)
107def _verify_server_mapping(
108 servers: Any,
109 *,
110 include_companions: bool,
111 expect_gco_env: dict[str, str] | None,
112 gco_args: list[str] | None,
113) -> list[str]:
114 if not isinstance(servers, dict):
115 return ["config carries no MCP server mapping"]
117 problems: list[str] = []
118 expected = expected_servers(include_companions)
119 actual = sorted(servers)
120 if actual != expected:
121 missing = sorted(set(expected) - set(actual))
122 extra = sorted(set(actual) - set(expected))
123 problems.append(f"server set mismatch: missing={missing} unexpected={extra}")
125 for name, entry in sorted(servers.items()):
126 if not isinstance(entry, dict):
127 problems.append(f"{name}: entry must be a mapping, got {entry!r}")
128 continue
129 command = entry.get("command")
130 if not isinstance(command, str) or not command:
131 problems.append(f"{name}: command must be a non-empty string, got {command!r}")
132 args = entry.get("args")
133 if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args):
134 problems.append(f"{name}: args must be a list of strings, got {args!r}")
136 text = json.dumps(servers)
137 for pruned in PRUNED_PACKAGES:
138 if pruned in text:
139 problems.append(f"pruned package {pruned!r} reappeared in the generated config")
141 gco_entry = servers.get("gco", {})
142 if not isinstance(gco_entry, dict):
143 gco_entry = {}
144 if expect_gco_env is not None:
145 gco_env = gco_entry.get("env", {})
146 if not isinstance(gco_env, dict):
147 gco_env = {}
148 for key, value in sorted(expect_gco_env.items()):
149 if gco_env.get(key) != value:
150 problems.append(f"gco env {key!r}: expected {value!r}, got {gco_env.get(key)!r}")
151 for name, entry in sorted(servers.items()):
152 if name == "gco" or not isinstance(entry, dict):
153 continue
154 environment = entry.get("env", {})
155 leaked = set(expect_gco_env) & set(environment if isinstance(environment, dict) else {})
156 if leaked:
157 problems.append(f"gco-only env leaked onto {name}: {sorted(leaked)}")
158 if gco_args is not None and gco_entry.get("args") != gco_args:
159 problems.append(f"gco args: expected {gco_args!r}, got {gco_entry.get('args')!r}")
161 return problems
164def verify_config(
165 config: dict[str, Any],
166 include_companions: bool = True,
167 expect_gco_env: dict[str, str] | None = None,
168 gco_args: list[str] | None = None,
169) -> list[str]:
170 """Return every problem with Claude's generated MCP JSON (empty = valid)."""
171 servers = config.get("mcpServers")
172 if not isinstance(servers, dict):
173 return ["config carries no mcpServers mapping"]
174 return _verify_server_mapping(
175 servers,
176 include_companions=include_companions,
177 expect_gco_env=expect_gco_env,
178 gco_args=gco_args,
179 )
182def verify_codex_config(
183 config: dict[str, Any],
184 include_companions: bool = True,
185 expect_gco_env: dict[str, str] | None = None,
186 gco_args: list[str] | None = None,
187 expected_region: str | None = None,
188) -> list[str]:
189 """Return every problem with Codex's generated TOML config (empty = valid)."""
190 problems: list[str] = []
191 expected_model = get_default_codex_model_id()
192 if config.get("model") != expected_model:
193 problems.append(
194 f"Codex model {config.get('model')!r} != shipped default {expected_model!r}"
195 )
196 if config.get("model_provider") != CODEX_BEDROCK_PROVIDER:
197 problems.append(
198 f"Codex provider {config.get('model_provider')!r} != {CODEX_BEDROCK_PROVIDER!r}"
199 )
200 expected_reasoning = get_default_codex_reasoning_effort()
201 if config.get("model_reasoning_effort") != expected_reasoning:
202 problems.append(
203 "Codex reasoning effort "
204 f"{config.get('model_reasoning_effort')!r} != {expected_reasoning!r}"
205 )
206 if config.get("check_for_update_on_startup") is not False:
207 problems.append("Codex update checks must be disabled in the generated config")
209 providers = config.get("model_providers")
210 provider = providers.get(CODEX_BEDROCK_PROVIDER) if isinstance(providers, dict) else None
211 if not isinstance(provider, dict) or provider.get("wire_api") != "responses":
212 actual_wire_api = provider.get("wire_api") if isinstance(provider, dict) else None
213 problems.append(f"Codex wire API {actual_wire_api!r} != 'responses'")
214 aws = provider.get("aws") if isinstance(provider, dict) else None
215 if not isinstance(aws, dict):
216 problems.append(f"Codex config carries no {CODEX_BEDROCK_PROVIDER}.aws provider table")
217 else:
218 region = aws.get("region")
219 if not isinstance(region, str) or not region:
220 problems.append(f"Codex provider region must be non-empty, got {region!r}")
221 elif expected_region is not None and region != expected_region:
222 problems.append(f"Codex provider region {region!r} != expected {expected_region!r}")
224 servers = config.get("mcp_servers")
225 problems.extend(
226 _verify_server_mapping(
227 servers,
228 include_companions=include_companions,
229 expect_gco_env=expect_gco_env,
230 gco_args=gco_args,
231 )
232 )
233 if isinstance(servers, dict):
234 for name, entry in sorted(servers.items()):
235 if isinstance(entry, dict) and entry.get("enabled") is not True:
236 problems.append(f"{name}: Codex MCP server must be enabled")
237 if (
238 isinstance(entry, dict)
239 and entry.get("startup_timeout_sec") != CODEX_MCP_STARTUP_TIMEOUT_SECONDS
240 ):
241 problems.append(
242 f"{name}: Codex MCP startup timeout must be "
243 f"{CODEX_MCP_STARTUP_TIMEOUT_SECONDS} seconds"
244 )
245 return problems
248def _normalize_opencode_servers(servers: Any) -> Any:
249 """Project OpenCode's ``local`` MCP entries onto the shared launch-recipe shape.
251 OpenCode stores the whole argv under ``command`` and the environment
252 under ``environment``; the shared checks expect ``command``/``args``/
253 ``env``. Non-mapping entries and non-list commands pass through so the
254 shared checker reports them instead of this helper raising.
255 """
256 if not isinstance(servers, dict):
257 return servers
258 normalized: dict[str, Any] = {}
259 for name, entry in servers.items():
260 if not isinstance(entry, dict):
261 normalized[name] = entry
262 continue
263 projected: dict[str, Any] = {}
264 argv = entry.get("command")
265 if isinstance(argv, list) and argv and all(isinstance(item, str) for item in argv):
266 projected["command"] = argv[0]
267 projected["args"] = argv[1:]
268 else:
269 projected["command"] = argv if isinstance(argv, str) else None
270 projected["args"] = None
271 if "environment" in entry:
272 projected["env"] = entry["environment"]
273 normalized[name] = projected
274 return normalized
277def verify_opencode_config(
278 config: dict[str, Any],
279 include_companions: bool = True,
280 expect_gco_env: dict[str, str] | None = None,
281 gco_args: list[str] | None = None,
282 expected_region: str | None = None,
283 expected_small_model: str | None = None,
284 expected_profile: str | None = None,
285 forbid_profile: bool = False,
286) -> list[str]:
287 """Return every problem with OpenCode's generated JSON config (empty = valid).
289 ``expected_small_model`` is the bare Bedrock model id the session pins for
290 title generation; it defaults to the session model because GCO never lets
291 OpenCode auto-pick a "cheaper" catalog model (see ``build_opencode_config``).
293 The provider ``profile`` is only pinned when the environment carries no
294 AWS credential source (see ``opencode_aws_profile``). ``forbid_profile``
295 asserts it is absent — the shape a session with exported static keys must
296 have, because a pinned profile makes the SDK skip those keys — while
297 ``expected_profile`` asserts a specific pinned name.
298 """
299 problems: list[str] = []
300 expected_model = get_default_opencode_model_id()
301 expected_selector = f"{OPENCODE_BEDROCK_PROVIDER}/{expected_model}"
302 if config.get("model") != expected_selector:
303 problems.append(
304 f"OpenCode model {config.get('model')!r} != shipped default {expected_selector!r}"
305 )
306 small_selector = f"{OPENCODE_BEDROCK_PROVIDER}/{expected_small_model or expected_model}"
307 if config.get("small_model") != small_selector:
308 problems.append(f"OpenCode small_model {config.get('small_model')!r} != {small_selector!r}")
309 if config.get("autoupdate") is not False:
310 problems.append("OpenCode auto-update must be disabled in the generated config")
311 if config.get("share") != "disabled":
312 problems.append(f"OpenCode share policy {config.get('share')!r} != 'disabled'")
313 permission = config.get("permission")
314 if not isinstance(permission, dict):
315 problems.append("OpenCode config carries no permission mapping")
316 else:
317 for key, expected in sorted(OPENCODE_PERMISSION_FLOOR.items()):
318 if permission.get(key) != expected:
319 problems.append(
320 f"OpenCode permission {key!r} must be {expected!r}, got {permission.get(key)!r}"
321 )
323 providers = config.get("provider")
324 provider = providers.get(OPENCODE_BEDROCK_PROVIDER) if isinstance(providers, dict) else None
325 if not isinstance(provider, dict):
326 problems.append(f"OpenCode config carries no provider.{OPENCODE_BEDROCK_PROVIDER} table")
327 else:
328 options = provider.get("options")
329 if not isinstance(options, dict):
330 problems.append(f"OpenCode provider.{OPENCODE_BEDROCK_PROVIDER} carries no options")
331 else:
332 region = options.get("region")
333 if not isinstance(region, str) or not region:
334 problems.append(f"OpenCode provider region must be non-empty, got {region!r}")
335 elif expected_region is not None and region != expected_region:
336 problems.append(
337 f"OpenCode provider region {region!r} != expected {expected_region!r}"
338 )
339 profile = options.get("profile")
340 if forbid_profile:
341 if "profile" in options:
342 problems.append(
343 f"OpenCode provider pins profile {profile!r}; with credentials in the "
344 "environment it must be omitted (the SDK would skip them otherwise)"
345 )
346 elif expected_profile is not None:
347 if profile != expected_profile:
348 problems.append(
349 f"OpenCode provider profile {profile!r} != expected {expected_profile!r}"
350 )
351 elif "profile" in options and (not isinstance(profile, str) or not profile):
352 problems.append(f"OpenCode provider profile must be non-empty, got {profile!r}")
353 models = provider.get("models")
354 if not isinstance(models, dict):
355 problems.append("OpenCode provider carries no models declaration")
356 else:
357 for model_id in sorted({expected_model, expected_small_model or expected_model}):
358 if not isinstance(models.get(model_id), dict):
359 problems.append(f"OpenCode provider does not declare model {model_id!r}")
361 servers = config.get("mcp")
362 problems.extend(
363 _verify_server_mapping(
364 _normalize_opencode_servers(servers),
365 include_companions=include_companions,
366 expect_gco_env=expect_gco_env,
367 gco_args=gco_args,
368 )
369 )
370 if isinstance(servers, dict):
371 for name, entry in sorted(servers.items()):
372 if not isinstance(entry, dict):
373 continue
374 if entry.get("type") != "local":
375 problems.append(f"{name}: OpenCode MCP server type must be 'local'")
376 if entry.get("enabled") is not True:
377 problems.append(f"{name}: OpenCode MCP server must be enabled")
378 if entry.get("timeout") != OPENCODE_MCP_TIMEOUT_MS:
379 problems.append(
380 f"{name}: OpenCode MCP timeout must be {OPENCODE_MCP_TIMEOUT_MS} ms"
381 )
382 return problems
385def _engine_facts(engine: AutopilotEngine) -> tuple[str, str, list[str], str | None]:
386 if engine is AutopilotEngine.CODEX:
387 return (
388 CODEX_VERSION,
389 f"{CODEX_PACKAGE}@{CODEX_VERSION}",
390 codex_install_command(),
391 get_default_codex_reasoning_effort(),
392 )
393 if engine is AutopilotEngine.OPENCODE:
394 return (
395 OPENCODE_VERSION,
396 f"{OPENCODE_PACKAGE}@{OPENCODE_VERSION}",
397 opencode_install_command(),
398 None,
399 )
400 return (
401 CLAUDE_CODE_VERSION,
402 f"{CLAUDE_CODE_PACKAGE}@{CLAUDE_CODE_VERSION}",
403 claude_install_command(),
404 None,
405 )
408def _default_model(engine: AutopilotEngine) -> str:
409 if engine is AutopilotEngine.CODEX:
410 return get_default_codex_model_id()
411 if engine is AutopilotEngine.OPENCODE:
412 return get_default_opencode_model_id()
413 return get_default_claude_code_model_id()
416def verify_plan(
417 plan: dict[str, Any],
418 claude_binary: str | None = None,
419 *,
420 engine: str | AutopilotEngine = AutopilotEngine.CLAUDE_CODE,
421 codex_binary: str | None = None,
422 opencode_binary: str | None = None,
423) -> list[str]:
424 """Return every problem with a JSON dry-run plan (empty = valid)."""
425 resolved_engine = AutopilotEngine(engine)
426 problems: list[str] = []
427 _version, expected_pin, install_command, expected_reasoning = _engine_facts(resolved_engine)
428 expected_model = _default_model(resolved_engine)
430 if plan.get("engine") != resolved_engine.value:
431 problems.append(f"plan engine {plan.get('engine')!r} != {resolved_engine.value!r}")
432 if plan.get("model") != expected_model:
433 problems.append(f"plan model {plan.get('model')!r} != shipped default {expected_model!r}")
434 if plan.get("reasoning_effort") != expected_reasoning:
435 problems.append(
436 f"plan reasoning {plan.get('reasoning_effort')!r} != {expected_reasoning!r}"
437 )
438 if sorted(plan.get("mcp_servers", [])) != expected_servers():
439 problems.append(
440 f"plan servers {sorted(plan.get('mcp_servers', []))} != {expected_servers()}"
441 )
442 if plan.get("engine_pin") != expected_pin:
443 problems.append(f"plan engine pin {plan.get('engine_pin')!r} != {expected_pin!r}")
444 if plan.get("install_command") != " ".join(install_command):
445 problems.append(
446 "plan install command does not match the selected engine's production command"
447 )
449 binary_states = {
450 AutopilotEngine.CLAUDE_CODE: claude_binary,
451 AutopilotEngine.CODEX: codex_binary,
452 AutopilotEngine.OPENCODE: opencode_binary,
453 }
454 binary_state = binary_states[resolved_engine]
455 binary = plan.get("engine_binary")
456 if binary_state == "absent" and binary is not None:
457 problems.append(f"expected no {resolved_engine.value} binary, plan found {binary!r}")
458 if binary_state == "present" and not binary:
459 problems.append(f"expected an installed {resolved_engine.value} binary, plan detected none")
461 selected_pin_field, selected_binary_field = _PLAN_ENGINE_FIELDS[resolved_engine]
462 if plan.get(selected_pin_field) != expected_pin:
463 problems.append(
464 f"plan {selected_pin_field} {plan.get(selected_pin_field)!r} != {expected_pin!r}"
465 )
466 if plan.get(selected_binary_field) != binary:
467 problems.append(f"plan {selected_binary_field} disagrees with engine_binary")
468 for other_engine, (other_pin_field, other_binary_field) in _PLAN_ENGINE_FIELDS.items():
469 if other_engine is resolved_engine:
470 continue
471 if plan.get(other_pin_field) is not None or plan.get(other_binary_field) is not None:
472 problems.append(
473 f"plan leaks selected-engine state into {other_pin_field}/{other_binary_field}"
474 )
476 # The public JSON formatter intentionally omits the large generated
477 # configs; when an in-process caller supplies one, validate it too, and
478 # make sure no engine carries another engine's document.
479 for field, owner in _PLAN_GENERATED_CONFIG_FIELDS.items():
480 rendered = plan.get(field)
481 if rendered is None:
482 continue
483 if owner is not resolved_engine:
484 problems.append(
485 f"{resolved_engine.value} plan unexpectedly carries a {owner.value} config ({field})"
486 )
487 elif owner is AutopilotEngine.CODEX:
488 if not isinstance(rendered, str):
489 problems.append("Codex plan config must be TOML text when present")
490 else:
491 try:
492 codex_config = tomllib.loads(rendered)
493 except tomllib.TOMLDecodeError as exc:
494 problems.append(f"Codex plan config is invalid TOML: {exc}")
495 else:
496 problems.extend(
497 verify_codex_config(codex_config, expected_region=plan.get("region"))
498 )
499 else:
500 if not isinstance(rendered, dict):
501 problems.append("OpenCode plan config must be a JSON object when present")
502 else:
503 problems.extend(
504 verify_opencode_config(
505 rendered,
506 expected_region=plan.get("region"),
507 expected_small_model=plan.get("small_fast_model"),
508 )
509 )
511 return problems
514def _load_json(path: str) -> dict[str, Any]:
515 with open(path, encoding="utf-8") as handle:
516 payload: dict[str, Any] = json.load(handle)
517 return payload
520def _load_toml(path: str) -> dict[str, Any]:
521 with open(path, "rb") as handle:
522 return tomllib.load(handle)
525def _parse_env_pair(pair: str) -> tuple[str, str]:
526 key, separator, value = pair.partition("=")
527 if not separator or not key:
528 raise argparse.ArgumentTypeError(f"--expect-gco-env expects KEY=VALUE, got {pair!r}")
529 return key, value
532def _add_engine_argument(parser: argparse.ArgumentParser) -> None:
533 parser.add_argument(
534 "--engine", choices=_ENGINE_CHOICES, default=AutopilotEngine.CLAUDE_CODE.value
535 )
538def _add_config_arguments(parser: argparse.ArgumentParser) -> None:
539 parser.add_argument("path")
540 parser.add_argument("--no-companions", action="store_true")
541 parser.add_argument("--expect-gco-env", action="append", type=_parse_env_pair, default=None)
542 parser.add_argument("--gco-args", action="append", default=None)
545def main(argv: list[str] | None = None) -> int:
546 parser = argparse.ArgumentParser(description=__doc__)
547 sub = parser.add_subparsers(dest="command", required=True)
549 for name in ("pin", "install-command", "default-model"):
550 _add_engine_argument(sub.add_parser(name))
551 sub.add_parser("expected-servers")
553 config_parser = sub.add_parser("verify-config")
554 _add_config_arguments(config_parser)
556 codex_config_parser = sub.add_parser("verify-codex-config")
557 _add_config_arguments(codex_config_parser)
558 codex_config_parser.add_argument("--region", default=None)
560 opencode_config_parser = sub.add_parser("verify-opencode-config")
561 _add_config_arguments(opencode_config_parser)
562 opencode_config_parser.add_argument("--region", default=None)
563 opencode_config_parser.add_argument("--small-model", default=None)
564 profile_group = opencode_config_parser.add_mutually_exclusive_group()
565 profile_group.add_argument("--profile", default=None)
566 profile_group.add_argument("--no-profile", action="store_true")
568 plan_parser = sub.add_parser("verify-plan")
569 plan_parser.add_argument("path")
570 _add_engine_argument(plan_parser)
571 plan_parser.add_argument("--claude-binary", choices=("present", "absent"), default=None)
572 plan_parser.add_argument("--codex-binary", choices=("present", "absent"), default=None)
573 plan_parser.add_argument("--opencode-binary", choices=("present", "absent"), default=None)
575 args = parser.parse_args(argv)
577 if args.command in {"pin", "install-command", "default-model"}:
578 engine = AutopilotEngine(args.engine)
579 version, _pin, install_command, _reasoning = _engine_facts(engine)
580 if args.command == "pin":
581 print(version)
582 elif args.command == "install-command":
583 print(" ".join(install_command))
584 else:
585 print(_default_model(engine))
586 return 0
587 if args.command == "expected-servers":
588 print("\n".join(expected_servers()))
589 return 0
591 expect_gco_env = dict(args.expect_gco_env) if getattr(args, "expect_gco_env", None) else None
592 if args.command == "verify-config":
593 problems = verify_config(
594 _load_json(args.path),
595 include_companions=not args.no_companions,
596 expect_gco_env=expect_gco_env,
597 gco_args=args.gco_args,
598 )
599 label = "Claude config"
600 elif args.command == "verify-codex-config":
601 problems = verify_codex_config(
602 _load_toml(args.path),
603 include_companions=not args.no_companions,
604 expect_gco_env=expect_gco_env,
605 gco_args=args.gco_args,
606 expected_region=args.region,
607 )
608 label = "Codex config"
609 elif args.command == "verify-opencode-config":
610 problems = verify_opencode_config(
611 _load_json(args.path),
612 include_companions=not args.no_companions,
613 expect_gco_env=expect_gco_env,
614 gco_args=args.gco_args,
615 expected_region=args.region,
616 expected_small_model=args.small_model,
617 expected_profile=args.profile,
618 forbid_profile=args.no_profile,
619 )
620 label = "OpenCode config"
621 else:
622 problems = verify_plan(
623 _load_json(args.path),
624 engine=args.engine,
625 claude_binary=args.claude_binary,
626 codex_binary=args.codex_binary,
627 opencode_binary=args.opencode_binary,
628 )
629 label = f"{args.engine} plan"
631 if problems:
632 for problem in problems:
633 print(f"ERROR: {problem}", file=sys.stderr)
634 return 1
635 print(f"autopilot {label} OK ({args.path})")
636 return 0
639if __name__ == "__main__":
640 raise SystemExit(main())