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

1"""Single source for the Autopilot facts and assertions CI jobs share. 

2 

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. 

7 

8Print subcommands accept ``--engine`` (default: ``claude-code``): 

9 

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 

14 

15Verification subcommands: 

16 

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] 

33 

34Importable for pytest: ``expected_servers()``, ``verify_config()``, 

35``verify_codex_config()``, ``verify_opencode_config()``, ``verify_plan()``, 

36``main()``. 

37""" 

38 

39from __future__ import annotations 

40 

41import argparse 

42import json 

43import sys 

44import tomllib 

45from pathlib import Path 

46from typing import Any 

47 

48_REPO_ROOT = Path(__file__).resolve().parents[2] 

49if str(_REPO_ROOT) not in sys.path: 

50 sys.path.insert(0, str(_REPO_ROOT)) 

51 

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) 

75 

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) 

80 

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} 

88 

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} 

94 

95#: Permission floor the generated OpenCode config must carry. 

96OPENCODE_PERMISSION_FLOOR: dict[str, str] = {"edit": "ask", "bash": "ask"} 

97 

98 

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) 

105 

106 

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"] 

116 

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}") 

124 

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}") 

135 

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") 

140 

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}") 

160 

161 return problems 

162 

163 

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 ) 

180 

181 

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") 

208 

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}") 

223 

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 

246 

247 

248def _normalize_opencode_servers(servers: Any) -> Any: 

249 """Project OpenCode's ``local`` MCP entries onto the shared launch-recipe shape. 

250 

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 

275 

276 

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

288 

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``). 

292 

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 ) 

322 

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}") 

360 

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 

383 

384 

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 ) 

406 

407 

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() 

414 

415 

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) 

429 

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 ) 

448 

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") 

460 

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 ) 

475 

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 ) 

510 

511 return problems 

512 

513 

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 

518 

519 

520def _load_toml(path: str) -> dict[str, Any]: 

521 with open(path, "rb") as handle: 

522 return tomllib.load(handle) 

523 

524 

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 

530 

531 

532def _add_engine_argument(parser: argparse.ArgumentParser) -> None: 

533 parser.add_argument( 

534 "--engine", choices=_ENGINE_CHOICES, default=AutopilotEngine.CLAUDE_CODE.value 

535 ) 

536 

537 

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) 

543 

544 

545def main(argv: list[str] | None = None) -> int: 

546 parser = argparse.ArgumentParser(description=__doc__) 

547 sub = parser.add_subparsers(dest="command", required=True) 

548 

549 for name in ("pin", "install-command", "default-model"): 

550 _add_engine_argument(sub.add_parser(name)) 

551 sub.add_parser("expected-servers") 

552 

553 config_parser = sub.add_parser("verify-config") 

554 _add_config_arguments(config_parser) 

555 

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) 

559 

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") 

567 

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) 

574 

575 args = parser.parse_args(argv) 

576 

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 

590 

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" 

630 

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 

637 

638 

639if __name__ == "__main__": 

640 raise SystemExit(main())