Coverage for cli / commands / autopilot_cmd.py: 100.00%

397 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1"""Autopilot command: one command from a plain terminal to a working agent.""" 

2 

3import json 

4import sys 

5import tomllib 

6from pathlib import Path 

7from typing import Any 

8 

9import click 

10 

11from ..autopilot import ( 

12 CLAUDE_CODE_PACKAGE, 

13 CLAUDE_CODE_VERSION, 

14 CODEX_BEDROCK_PROVIDER, 

15 CODEX_PACKAGE, 

16 CODEX_VERSION, 

17 OPENCODE_BEDROCK_PROVIDER, 

18 OPENCODE_PACKAGE, 

19 OPENCODE_VERSION, 

20 AutopilotEngine, 

21 build_claude_env, 

22 build_codex_config_toml, 

23 build_codex_env, 

24 build_codex_launch_argv, 

25 build_codex_owned_args, 

26 build_launch_argv, 

27 build_mcp_config, 

28 build_opencode_config, 

29 build_opencode_env, 

30 build_opencode_launch_argv, 

31 build_opencode_owned_args, 

32 build_plugin_args, 

33 claude_install_command, 

34 codex_config_path, 

35 codex_install_command, 

36 config_path, 

37 effective_aws_region, 

38 exec_claude, 

39 exec_codex, 

40 exec_opencode, 

41 find_claude_binary, 

42 find_codex_binary, 

43 find_opencode_binary, 

44 has_resumable_session, 

45 install_claude_code, 

46 install_codex, 

47 install_opencode, 

48 opencode_config_path, 

49 opencode_install_command, 

50 opencode_utility_invocation, 

51 plugin_paths_requested, 

52 resolve_codex_model, 

53 resolve_codex_reasoning_effort, 

54 resolve_engine, 

55 resolve_mcp_flags, 

56 resolve_model, 

57 resolve_opencode_model, 

58 resolve_opencode_skills_paths, 

59 resolve_plugin_paths, 

60 resolve_small_fast_model, 

61 stage_codex_skills, 

62 stage_imports, 

63 validate_imports, 

64 write_codex_config, 

65 write_mcp_config, 

66 write_opencode_config, 

67) 

68from ..config import GCOConfig 

69from ..output import confirm, emit_structured_document, get_output_formatter 

70 

71# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit 

72# Generated at (UTC): 2026-09-22T01:41:35Z 

73# Generated from Git commit: e43f6110f7bed86e6c879a3f03c1f1ca34674311 

74# Flowchart(s) generated from this file: 

75# * ``_plan`` -> ``diagrams/code_diagrams/cli/commands/autopilot_cmd._plan.html`` 

76# (PNG: ``diagrams/code_diagrams/cli/commands/autopilot_cmd._plan.png``) 

77# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``. 

78# <pyflowchart-code-diagram> END 

79 

80 

81#: claude's own session-resumption flags. When one of these appears in the 

82#: passthrough args (after ``--``), the caller has already made a resume 

83#: choice and autopilot neither prompts nor injects its own flags. 

84_CLAUDE_RESUME_FLAGS = frozenset({"-c", "--continue", "-r", "--resume"}) 

85 

86#: How each engine keeps the generated config authoritative, as shown in the 

87#: dry-run summary next to the config path. 

88_ENGINE_ISOLATION_LABELS = { 

89 AutopilotEngine.CLAUDE_CODE.value: "--strict-mcp-config", 

90 AutopilotEngine.CODEX.value: "isolated CODEX_HOME", 

91 AutopilotEngine.OPENCODE.value: "OPENCODE_CONFIG, project config disabled", 

92} 

93 

94#: Large generated documents the machine-readable dry-run plan omits; each 

95#: has its own ``--print-config`` surface. 

96_PLAN_GENERATED_CONFIG_KEYS = frozenset({"mcp_config", "codex_config", "opencode_config"}) 

97 

98pass_config = click.make_pass_decorator(GCOConfig, ensure=True) 

99 

100 

101def _stdin_is_interactive() -> bool: 

102 """Whether a human is on the other end (gates the resume prompt).""" 

103 return sys.stdin.isatty() 

104 

105 

106def _parse_mcp_env(pairs: tuple[str, ...]) -> dict[str, str]: 

107 """Parse ``--mcp-env KEY=VALUE`` pairs, rejecting malformed input.""" 

108 env: dict[str, str] = {} 

109 for pair in pairs: 

110 key, separator, value = pair.partition("=") 

111 if not separator or not key.strip(): 

112 raise ValueError(f"--mcp-env expects KEY=VALUE, got {pair!r}.") 

113 env[key.strip()] = value 

114 return env 

115 

116 

117def _plan( 

118 config: Any, 

119 model: str | None, 

120 small_fast_model: str | None, 

121 companions: bool, 

122 enable: tuple[str, ...] = (), 

123 mcp_env: tuple[str, ...] = (), 

124 plugins: tuple[str, ...] = (), 

125 skills: tuple[str, ...] = (), 

126 agents: tuple[str, ...] = (), 

127 engine: str | AutopilotEngine | None = None, 

128) -> tuple[dict[str, Any], list[str]]: 

129 """Resolve an engine launch plan without installing or writing files.""" 

130 resolved_engine = resolve_engine(engine) 

131 gco_mcp_env = resolve_mcp_flags(enable) 

132 gco_mcp_env.update(_parse_mcp_env(mcp_env)) 

133 workspace = Path.cwd() 

134 mcp_config = build_mcp_config( 

135 workspace, 

136 include_companions=companions, 

137 gco_mcp_env=gco_mcp_env, 

138 ) 

139 resolved_region = effective_aws_region(config.default_region) 

140 

141 if resolved_engine is AutopilotEngine.CODEX: 

142 resolved_small = resolve_small_fast_model(small_fast_model) 

143 if resolved_small is not None: 

144 raise ValueError( 

145 "--small-fast-model and GCO_AUTOPILOT_SMALL_FAST_MODEL are " 

146 "supported only by the claude-code engine" 

147 ) 

148 if plugin_paths_requested(plugins): 

149 raise ValueError( 

150 "--plugin and GCO_AUTOPILOT_PLUGIN_DIRS are Claude Code plugin " 

151 "inputs and are not supported by the codex engine" 

152 ) 

153 plugin_paths: list[Path] = [] 

154 if agents: 

155 raise ValueError( 

156 "--agents imports Claude Code agent files and is not supported by the codex engine" 

157 ) 

158 validate_imports(skills, ()) 

159 resolved_model, warnings = resolve_codex_model(model) 

160 reasoning_effort = resolve_codex_reasoning_effort(model) 

161 codex_config = build_codex_config_toml( 

162 mcp_config, 

163 model=resolved_model, 

164 region=resolved_region, 

165 reasoning_effort=reasoning_effort, 

166 ) 

167 opencode_config = None 

168 binary = find_codex_binary() 

169 pin = f"{CODEX_PACKAGE}@{CODEX_VERSION}" 

170 install_command = codex_install_command() 

171 config_file = codex_config_path() 

172 display_name = "Codex" 

173 resumable = False 

174 elif resolved_engine is AutopilotEngine.OPENCODE: 

175 # OpenCode has a native ``small_model`` for title generation and other 

176 # lightweight calls, so the fast-model option maps onto it directly. 

177 resolved_small = resolve_small_fast_model(small_fast_model) 

178 if plugin_paths_requested(plugins): 

179 raise ValueError( 

180 "--plugin and GCO_AUTOPILOT_PLUGIN_DIRS are Claude Code plugin " 

181 "inputs and are not supported by the opencode engine" 

182 ) 

183 plugin_paths = [] 

184 if agents: 

185 raise ValueError( 

186 "--agents imports Claude Code agent files and is not supported by " 

187 "the opencode engine" 

188 ) 

189 skills_paths = resolve_opencode_skills_paths(skills) 

190 resolved_model, warnings = resolve_opencode_model(model) 

191 reasoning_effort = None 

192 codex_config = None 

193 opencode_config = build_opencode_config( 

194 mcp_config, 

195 model=resolved_model, 

196 region=resolved_region, 

197 small_model=resolved_small, 

198 skills_paths=skills_paths, 

199 ) 

200 binary = find_opencode_binary() 

201 pin = f"{OPENCODE_PACKAGE}@{OPENCODE_VERSION}" 

202 install_command = opencode_install_command() 

203 config_file = opencode_config_path() 

204 display_name = "OpenCode" 

205 resumable = False 

206 else: 

207 resolved_model, warnings = resolve_model(model) 

208 resolved_small = resolve_small_fast_model(small_fast_model) 

209 plugin_paths = resolve_plugin_paths(plugins) 

210 validate_imports(skills, agents) 

211 reasoning_effort = None 

212 codex_config = None 

213 opencode_config = None 

214 binary = find_claude_binary() 

215 pin = f"{CLAUDE_CODE_PACKAGE}@{CLAUDE_CODE_VERSION}" 

216 install_command = claude_install_command() 

217 config_file = config_path() 

218 display_name = "Claude Code" 

219 resumable = has_resumable_session(workspace) 

220 

221 plan = { 

222 "engine": resolved_engine.value, 

223 "engine_display_name": display_name, 

224 "engine_binary": binary, 

225 "engine_pin": pin, 

226 "model": resolved_model, 

227 "small_fast_model": resolved_small, 

228 "reasoning_effort": reasoning_effort, 

229 "region": resolved_region, 

230 "workspace": str(workspace), 

231 "mcp_config_path": str(config_file), 

232 "mcp_servers": sorted(mcp_config["mcpServers"]), 

233 "gco_mcp_env": dict(sorted(gco_mcp_env.items())), 

234 "plugins": [str(path) for path in plugin_paths], 

235 "import_skills": [str(Path(item).expanduser()) for item in skills], 

236 "import_agents": [str(Path(item).expanduser()) for item in agents], 

237 "claude_binary": binary if resolved_engine is AutopilotEngine.CLAUDE_CODE else None, 

238 "claude_code_pin": (pin if resolved_engine is AutopilotEngine.CLAUDE_CODE else None), 

239 "codex_binary": binary if resolved_engine is AutopilotEngine.CODEX else None, 

240 "codex_pin": pin if resolved_engine is AutopilotEngine.CODEX else None, 

241 "opencode_binary": binary if resolved_engine is AutopilotEngine.OPENCODE else None, 

242 "opencode_pin": pin if resolved_engine is AutopilotEngine.OPENCODE else None, 

243 "install_command": " ".join(install_command), 

244 "resumable_session": resumable, 

245 "mcp_config": mcp_config, 

246 "codex_config": codex_config, 

247 "opencode_config": opencode_config, 

248 } 

249 return plan, warnings 

250 

251 

252def _resolve_resume_args( 

253 plan: dict[str, Any], 

254 continue_session: bool, 

255 resume: str | None, 

256 engine_args: tuple[str, ...], 

257 yes: bool, 

258) -> tuple[str, ...]: 

259 """Decide which claude resume flags this launch carries. 

260 

261 Explicit wins: ``--continue`` / ``--resume`` (ours, or claude's own in 

262 the passthrough args) are honored as given. Otherwise, when Claude Code 

263 already has a session for this workspace and we're on an interactive 

264 terminal, offer to pick it up — one keypress instead of retyping 

265 context. The prompt is skipped for ``--yes`` and non-TTY runs so 

266 scripted invocations never hang, and a fresh session stays the default. 

267 """ 

268 if continue_session: 

269 return ("--continue",) 

270 if resume is not None: 

271 return ("--resume",) if resume == "" else ("--resume", resume) 

272 if _CLAUDE_RESUME_FLAGS & set(engine_args): 

273 return () 

274 if ( 

275 not yes 

276 and plan["resumable_session"] 

277 and _stdin_is_interactive() 

278 and confirm( 

279 "Resume your previous Claude Code session in this workspace?", 

280 default=False, 

281 ) 

282 ): 

283 return ("--continue",) 

284 return () 

285 

286 

287def _resolve_codex_resume_args( 

288 continue_session: bool, 

289 resume: str | None, 

290) -> tuple[str, ...]: 

291 """Map generic resume options onto Codex's resume subcommand.""" 

292 if continue_session: 

293 return ("resume", "--last") 

294 if resume is not None: 

295 if resume == "": 

296 return ("resume",) 

297 if resume.startswith("-"): 

298 return ("resume", "--", resume) 

299 return ("resume", resume) 

300 return () 

301 

302 

303def _resolve_opencode_resume_args( 

304 continue_session: bool, 

305 resume: str | None, 

306) -> tuple[str, ...]: 

307 """Map generic resume options onto OpenCode's ``--continue`` / ``--session``. 

308 

309 OpenCode has no interactive session picker flag, so a bare ``--resume`` 

310 is an error with the native way to find an id rather than a silent 

311 fallback to the last session. 

312 """ 

313 if continue_session: 

314 return ("--continue",) 

315 if resume is not None: 

316 if resume == "": 

317 raise ValueError( 

318 "OpenCode has no interactive session picker. Pass --resume <session-id> " 

319 "(list ids with `opencode session list`) or use --continue for the " 

320 "most recent session." 

321 ) 

322 return ("--session", resume) 

323 return () 

324 

325 

326#: OpenCode flags and subcommands that would take the launch off the plan. 

327#: ``--model`` is owned by Autopilot (use the top-level ``--model``); 

328#: ``upgrade`` would replace the pinned binary in place. 

329_OPENCODE_RESERVED_ARGS = frozenset({"-m", "--model", "upgrade"}) 

330_OPENCODE_RESERVED_PREFIXES = ("--model=",) 

331 

332 

333def _validate_opencode_engine_args( 

334 engine_args: tuple[str, ...], 

335 *, 

336 resuming: bool = False, 

337) -> None: 

338 """Reject native OpenCode overrides that would invalidate the launch plan. 

339 

340 Everything else passes through untouched: ``run`` for non-interactive 

341 prompts, ``--auto`` to lift the ``ask`` permission floor for one session, 

342 ``--agent``, ``--print-logs``, and so on. Scanning stops at a bare ``--`` 

343 because OpenCode treats the remainder as prompt text. 

344 

345 Utility subcommands (``mcp list``, ``models``, ...) run against the 

346 generated config without the session flags, so combining one with 

347 ``--continue``/``--resume`` is a contradiction reported here instead of 

348 a silently dropped selector. 

349 """ 

350 if resuming and opencode_utility_invocation(engine_args): 

351 raise ValueError( 

352 f"OpenCode subcommand {engine_args[0]!r} is a utility, not a session; " 

353 "--continue/--resume do not apply to it." 

354 ) 

355 for argument in engine_args: 

356 if argument == "--": 

357 break 

358 attached_short_override = argument.startswith("-m") and not argument.startswith("--") 

359 if ( 

360 argument in _OPENCODE_RESERVED_ARGS 

361 or argument.startswith(_OPENCODE_RESERVED_PREFIXES) 

362 or attached_short_override 

363 ): 

364 raise ValueError( 

365 f"OpenCode passthrough option {argument!r} would override Autopilot's " 

366 "Bedrock launch plan. Use the top-level `gco autopilot --model` option " 

367 "for model overrides; in-place upgrades are incompatible with the " 

368 "pinned engine install." 

369 ) 

370 

371 

372def _validate_codex_engine_args(engine_args: tuple[str, ...]) -> None: 

373 """Reject native overrides that would invalidate Autopilot's launch plan. 

374 

375 GCO owns the Bedrock provider/model, project-layer isolation, update policy, 

376 and generated MCP process definitions. Native config may still tighten the 

377 GCO server to the two documentation tools used by the reviewed live demo. 

378 """ 

379 direct_overrides = { 

380 "-m", 

381 "--model", 

382 "-p", 

383 "--profile", 

384 "--oss", 

385 "--local-provider", 

386 "-C", 

387 "--cd", 

388 "--remote", 

389 "--remote-auth-token-env", 

390 "update", 

391 } 

392 direct_prefixes = ( 

393 "--model=", 

394 "--profile=", 

395 "--local-provider=", 

396 "--cd=", 

397 "--remote=", 

398 "--remote-auth-token-env=", 

399 ) 

400 config_options = {"-c", "--config"} 

401 reserved_config_roots = { 

402 "check_for_update_on_startup", 

403 "mcp_servers", 

404 "model", 

405 "model_provider", 

406 "model_providers", 

407 "model_reasoning_effort", 

408 "profile", 

409 "profiles", 

410 "project_root_markers", 

411 "projects", 

412 } 

413 recorder_tools = {"find_docs", "read_resource"} 

414 

415 def config_root(assignment: str) -> str | None: 

416 key_expression, separator, _value = assignment.partition("=") 

417 if not separator: 

418 return None 

419 try: 

420 parsed = tomllib.loads(f"{key_expression}=0") 

421 except tomllib.TOMLDecodeError: 

422 # Invalid TOML will fail in Codex itself, but retain a conservative 

423 # lexical fallback so malformed quoting cannot bypass an owned root. 

424 normalized = key_expression.strip().lstrip("\"'") 

425 return normalized.split(".", 1)[0].strip().rstrip("\"'") or None 

426 return next(iter(parsed), None) 

427 

428 def is_safe_gco_tool_narrowing(assignment: str) -> bool: 

429 """Allow only the recorder's fail-closed GCO documentation overlay.""" 

430 try: 

431 parsed = tomllib.loads(assignment) 

432 except tomllib.TOMLDecodeError: 

433 return False 

434 if set(parsed) != {"mcp_servers"}: 

435 return False 

436 servers = parsed["mcp_servers"] 

437 if not isinstance(servers, dict) or set(servers) != {"gco"}: 

438 return False 

439 gco = servers["gco"] 

440 if not isinstance(gco, dict) or not set(gco) <= { 

441 "enabled_tools", 

442 "required", 

443 "tools", 

444 }: 

445 return False 

446 if "required" in gco and gco["required"] is not True: 

447 return False 

448 if "enabled_tools" in gco: 

449 enabled = gco["enabled_tools"] 

450 if ( 

451 not isinstance(enabled, list) 

452 or not enabled 

453 or not all(isinstance(tool, str) for tool in enabled) 

454 or not set(enabled) <= recorder_tools 

455 ): 

456 return False 

457 if "tools" in gco: 

458 tools = gco["tools"] 

459 if not isinstance(tools, dict) or not set(tools) <= recorder_tools: 

460 return False 

461 for policy in tools.values(): 

462 if not isinstance(policy, dict) or policy != {"approval_mode": "approve"}: 

463 return False 

464 return True 

465 

466 index = 0 

467 while index < len(engine_args): 

468 argument = engine_args[index] 

469 if argument == "--": 

470 # Codex treats its own separator as the end of native options; the 

471 # remaining tokens are prompt text and must not be option-scanned. 

472 break 

473 

474 attached_short_override = ( 

475 (argument.startswith("-m") and argument != "-m") 

476 or (argument.startswith("-p") and argument != "-p") 

477 or (argument.startswith("-C") and argument != "-C") 

478 ) and not argument.startswith("--") 

479 if ( 

480 argument in direct_overrides 

481 or argument.startswith(direct_prefixes) 

482 or attached_short_override 

483 ): 

484 raise ValueError( 

485 f"Codex passthrough option {argument!r} would override Autopilot's " 

486 "isolated Bedrock launch plan. Use the top-level `gco autopilot " 

487 "--model` option for model overrides; alternate projects, " 

488 "profiles, providers, remote sessions, and in-place updates are " 

489 "incompatible with this engine." 

490 ) 

491 

492 assignment: str | None = None 

493 option = argument 

494 if argument in config_options: 

495 if index + 1 < len(engine_args): 

496 assignment = engine_args[index + 1] 

497 index += 1 

498 elif argument.startswith("--config="): 

499 assignment = argument.removeprefix("--config=") 

500 option = "--config" 

501 elif argument.startswith("-c") and argument != "-c": 

502 assignment = argument[2:].removeprefix("=") 

503 option = "-c" 

504 

505 root = config_root(assignment) if assignment is not None else None 

506 if ( 

507 root == "mcp_servers" 

508 and assignment is not None 

509 and is_safe_gco_tool_narrowing(assignment) 

510 ): 

511 index += 1 

512 continue 

513 if root in reserved_config_roots and assignment is not None: 

514 key = assignment.partition("=")[0] 

515 raise ValueError( 

516 f"Codex passthrough option {option!r} cannot override {key!r}; " 

517 "that setting is owned by Autopilot's isolated Bedrock plan. " 

518 "Use top-level `--model` or context.bedrock.codex in cdk.json." 

519 ) 

520 index += 1 

521 

522 

523def _print_dry_run(formatter: Any, plan: dict[str, Any]) -> None: 

524 """Render the launch plan as the table-format summary.""" 

525 print() 

526 print(" GCO Autopilot — launch plan") 

527 print(" " + "-" * 68) 

528 print(f" Engine: {plan['engine_display_name']}") 

529 print(f" Model (Bedrock): {plan['model']}") 

530 if plan["reasoning_effort"]: 

531 print(f" Reasoning effort: {plan['reasoning_effort']}") 

532 if plan["small_fast_model"]: 

533 print(f" Fast model: {plan['small_fast_model']}") 

534 print(f" AWS region: {plan['region']}") 

535 print(f" Workspace: {plan['workspace']}") 

536 isolation = _ENGINE_ISOLATION_LABELS[plan["engine"]] 

537 print(f" MCP config: {plan['mcp_config_path']} ({isolation})") 

538 print(f" MCP servers ({len(plan['mcp_servers'])}): " + ", ".join(plan["mcp_servers"])) 

539 if plan["gco_mcp_env"]: 

540 rendered = ", ".join(f"{k}={v}" for k, v in plan["gco_mcp_env"].items()) 

541 print(f" GCO MCP env: {rendered}") 

542 else: 

543 print(" GCO MCP env: (none — default read-only toolset)") 

544 if plan["plugins"]: 

545 print(f" Plugins: {', '.join(plan['plugins'])}") 

546 if plan["engine"] == AutopilotEngine.CODEX.value and plan["import_skills"]: 

547 skills = ", ".join(plan["import_skills"]) 

548 print(f" Skills: {skills} (copied into isolated CODEX_HOME)") 

549 elif plan["engine"] == AutopilotEngine.OPENCODE.value and plan["import_skills"]: 

550 skills = ", ".join(plan["import_skills"]) 

551 print(f" Skills: {skills} (referenced via skills.paths)") 

552 else: 

553 imports = [f"skills:{path}" for path in plan["import_skills"]] + [ 

554 f"agents:{path}" for path in plan["import_agents"] 

555 ] 

556 if imports: 

557 print(f" Imports: {', '.join(imports)} (staged as a session plugin)") 

558 if plan["engine_binary"]: 

559 print(f" {plan['engine_display_name']}: {plan['engine_binary']}") 

560 else: 

561 print( 

562 f" {plan['engine_display_name']}: not installed — will offer: " 

563 f"{plan['install_command']}" 

564 ) 

565 if plan["resumable_session"]: 

566 print(" Previous session: found — launch will offer to resume (or pass --continue)") 

567 elif plan["engine"] == "claude-code": 

568 print(" Previous session: none for this workspace") 

569 elif plan["engine"] == AutopilotEngine.OPENCODE.value: 

570 print( 

571 " Previous session: use --continue or --resume <session-id> to reopen an " 

572 "OpenCode session" 

573 ) 

574 else: 

575 print(" Previous session: use --continue or --resume to reopen a Codex session") 

576 print(" " + "-" * 68) 

577 print(" Dry run only — nothing was written or launched.") 

578 print() 

579 

580 

581@click.command("autopilot") 

582@click.option( 

583 "--engine", 

584 type=click.Choice([engine.value for engine in AutopilotEngine], case_sensitive=False), 

585 default=None, 

586 help=( 

587 "Agent runtime (default: claude-code; env override: GCO_AUTOPILOT_ENGINE). " 

588 "Codex uses Amazon Bedrock and an isolated CODEX_HOME; OpenCode uses " 

589 "Amazon Bedrock through a generated OPENCODE_CONFIG." 

590 ), 

591) 

592@click.option( 

593 "--model", 

594 "-m", 

595 default=None, 

596 help=( 

597 "Bedrock model or inference-profile id for the selected engine. " 

598 "Defaults: context.bedrock.claude_code_default_model_id, " 

599 "context.bedrock.codex_default_model_id, or " 

600 "context.bedrock.opencode_default_model_id. Engine env overrides: " 

601 "GCO_AUTOPILOT_CODEX_MODEL, GCO_AUTOPILOT_OPENCODE_MODEL; shared " 

602 "fallback: GCO_AUTOPILOT_MODEL." 

603 ), 

604) 

605@click.option( 

606 "--small-fast-model", 

607 default=None, 

608 help=( 

609 "Optional Bedrock model for background/fast tasks: Claude Code's " 

610 "ANTHROPIC_SMALL_FAST_MODEL or OpenCode's small_model (env override: " 

611 "GCO_AUTOPILOT_SMALL_FAST_MODEL; unset by default). Codex rejects " 

612 "fast-model configuration." 

613 ), 

614) 

615@click.option( 

616 "--companions/--no-companions", 

617 "companions", 

618 default=True, 

619 help="Include the recommended companion MCP servers (default: yes).", 

620) 

621@click.option( 

622 "--enable", 

623 "-e", 

624 "enable", 

625 multiple=True, 

626 metavar="FLAG", 

627 help=( 

628 "Enable a GCO MCP feature flag for the session (repeatable). " 

629 "Accepts the short form (mission, all-tools, infrastructure-deploy) " 

630 "or the full GCO_ENABLE_* name; unknown flags fail with the valid list." 

631 ), 

632) 

633@click.option( 

634 "--mcp-env", 

635 "mcp_env", 

636 multiple=True, 

637 metavar="KEY=VALUE", 

638 help=( 

639 "Set an arbitrary environment variable on the GCO MCP server " 

640 "(repeatable), e.g. GCO_MCP_TOOL_SEARCH=bm25. Wins over --enable " 

641 "for the same key." 

642 ), 

643) 

644@click.option( 

645 "--plugin", 

646 "plugins", 

647 multiple=True, 

648 metavar="PATH", 

649 help=( 

650 "Load a Claude Code plugin directory or .zip into the session " 

651 "(repeatable; env: GCO_AUTOPILOT_PLUGIN_DIRS, colon-separated). " 

652 "Claude-only; Codex and OpenCode reject plugins." 

653 ), 

654) 

655@click.option( 

656 "--skills", 

657 "skills", 

658 multiple=True, 

659 metavar="DIR", 

660 help=( 

661 "Import a directory of skills (one subdirectory per skill, each " 

662 "with a SKILL.md) into the session (repeatable). Claude stages a " 

663 "session plugin; Codex copies skills into GCO's isolated CODEX_HOME; " 

664 "OpenCode references the directories through skills.paths." 

665 ), 

666) 

667@click.option( 

668 "--agents", 

669 "agents", 

670 multiple=True, 

671 metavar="DIR", 

672 help=( 

673 "Import a directory of Claude Code agent files (*.md subagent " 

674 "definitions) into the session (repeatable). Claude-only; Codex and " 

675 "OpenCode reject agent imports." 

676 ), 

677) 

678@click.option( 

679 "--continue", 

680 "-c", 

681 "continue_session", 

682 is_flag=True, 

683 help="Resume the most recent session for the selected engine and workspace.", 

684) 

685@click.option( 

686 "--resume", 

687 "resume", 

688 is_flag=False, 

689 flag_value="", 

690 default=None, 

691 metavar="[SESSION_ID]", 

692 help=( 

693 "Resume a specific session by id, or open the selected engine's " 

694 "interactive session picker when no id is given (Claude Code and " 

695 "Codex; OpenCode has no picker and requires the id)." 

696 ), 

697) 

698@click.option( 

699 "--dry-run", 

700 is_flag=True, 

701 help="Show the resolved launch plan without installing, writing, or launching.", 

702) 

703@click.option( 

704 "--print-config", 

705 "print_config", 

706 is_flag=True, 

707 help="Print the selected engine's generated MCP/agent config and exit.", 

708) 

709@click.option("--yes", "-y", is_flag=True, help="Install the selected engine without prompting.") 

710@click.argument("engine_args", nargs=-1, type=click.UNPROCESSED) 

711@pass_config 

712def autopilot( 

713 config: Any, 

714 engine: Any, 

715 model: Any, 

716 small_fast_model: Any, 

717 companions: Any, 

718 enable: Any, 

719 mcp_env: Any, 

720 plugins: Any, 

721 skills: Any, 

722 agents: Any, 

723 continue_session: Any, 

724 resume: Any, 

725 dry_run: Any, 

726 print_config: Any, 

727 yes: Any, 

728 engine_args: Any, 

729) -> None: 

730 """Launch a fully configured Claude Code, Codex, or OpenCode session for GCO. 

731 

732 Claude Code remains the default engine. Select Codex with ``--engine 

733 codex`` (``GCO_AUTOPILOT_ENGINE=codex``) or OpenCode with ``--engine 

734 opencode`` (``GCO_AUTOPILOT_ENGINE=opencode``). Every engine uses Amazon 

735 Bedrock through your AWS credentials and receives the GCO MCP server plus 

736 the recommended companion servers. 

737 

738 Each engine has an independent model default in ``cdk.json`` and supports 

739 ``--model`` / environment overrides. Codex uses GCO's isolated 

740 ``~/.gco/autopilot/codex`` home and official Amazon Bedrock provider 

741 configuration; OpenCode reads a generated ``opencode.json`` through 

742 ``OPENCODE_CONFIG`` with project config disabled and edit/shell 

743 permissions set to ask (pass ``-- --auto`` to lift that for one session); 

744 Claude preserves its JSON config and strict MCP mode. 

745 

746 If the selected CLI is absent, Autopilot offers to install its exact npm 

747 pin. Arguments after ``--`` pass through unchanged to that CLI. 

748 ``--continue`` and ``--resume`` map to the selected engine's native 

749 resume syntax; Claude also keeps its interactive previous-session prompt. 

750 

751 GCO MCP feature flags gate opt-in tool groups. By default the session gets 

752 the read-only toolset; pass ``--enable`` per flag or ``--enable all-tools``. 

753 ``--mcp-env`` sets any other GCO server variable. 

754 

755 ``--skills`` works with every engine. ``--small-fast-model`` works with 

756 Claude Code and OpenCode. ``--plugin`` and ``--agents`` are Claude-only 

757 because they use Claude Code plugin and agent formats. 

758 

759 \b 

760 Examples: 

761 gco autopilot 

762 gco autopilot --engine codex 

763 gco autopilot --engine opencode 

764 GCO_AUTOPILOT_ENGINE=codex gco autopilot --continue 

765 GCO_AUTOPILOT_ENGINE=opencode gco autopilot --continue 

766 gco autopilot --resume 

767 gco autopilot -e mission -e infrastructure-deploy 

768 gco autopilot -e all-tools 

769 gco autopilot --mcp-env GCO_MCP_TOOL_SEARCH=bm25 

770 gco autopilot --skills ~/team-skills 

771 gco autopilot --skills ~/team-skills --agents ~/my-agents 

772 gco autopilot --plugin ~/plugins/incident-response 

773 gco autopilot -m global.anthropic.claude-sonnet-4-6 

774 gco autopilot --engine codex -m global.openai.gpt-5.6-terra 

775 gco autopilot --engine opencode -m us.moonshotai.kimi-k3 

776 gco autopilot --engine codex --print-config 

777 gco autopilot --engine opencode --print-config 

778 gco autopilot --dry-run 

779 gco autopilot -y -- --permission-mode plan 

780 gco autopilot --engine opencode -y -- --auto 

781 

782 \b 

783 Requirements: 

784 - AWS credentials with Bedrock model invocation access 

785 - The selected model enabled in the resolved AWS Region 

786 - npm only when the selected CLI is not already installed 

787 """ 

788 formatter = get_output_formatter(config) 

789 

790 if continue_session and resume is not None: 

791 formatter.print_error("Pass either --continue or --resume, not both.") 

792 sys.exit(1) 

793 

794 if config.output_format != "table" and not (dry_run or print_config): 

795 formatter.print_error( 

796 "Live Autopilot sessions require terminal output. Use `--output table`, " 

797 "or combine machine output with `--dry-run` or `--print-config`." 

798 ) 

799 sys.exit(2) 

800 

801 try: 

802 resolved_engine = resolve_engine(engine) 

803 if resolved_engine is AutopilotEngine.CODEX: 

804 _validate_codex_engine_args(tuple(engine_args)) 

805 elif resolved_engine is AutopilotEngine.OPENCODE: 

806 _validate_opencode_engine_args( 

807 tuple(engine_args), 

808 resuming=continue_session or resume is not None, 

809 ) 

810 # Fail before the plan resolves so a bare --resume never installs. 

811 _resolve_opencode_resume_args(continue_session, resume) 

812 plan, warnings = _plan( 

813 config, 

814 model, 

815 small_fast_model, 

816 companions, 

817 tuple(enable), 

818 tuple(mcp_env), 

819 tuple(plugins), 

820 tuple(skills), 

821 tuple(agents), 

822 engine=resolved_engine, 

823 ) 

824 except ValueError as e: 

825 formatter.print_error(str(e)) 

826 sys.exit(1) 

827 except Exception as e: 

828 formatter.print_error(f"Failed to resolve the autopilot launch plan: {e}") 

829 sys.exit(1) 

830 

831 for warning in warnings: 

832 formatter.print_warning(warning) 

833 

834 if print_config: 

835 if plan["engine"] == AutopilotEngine.CODEX.value: 

836 click.echo(plan["codex_config"], nl=False) 

837 elif plan["engine"] == AutopilotEngine.OPENCODE.value: 

838 emit_structured_document( 

839 plan["opencode_config"], 

840 output_format="json", 

841 rendered=json.dumps(plan["opencode_config"], indent=2), 

842 ) 

843 else: 

844 # Preserve Claude's raw JSON machine-readable surface. 

845 emit_structured_document( 

846 plan["mcp_config"], 

847 output_format="json", 

848 rendered=json.dumps(plan["mcp_config"], indent=2), 

849 ) 

850 return 

851 

852 if dry_run: 

853 if config.output_format == "table": 

854 _print_dry_run(formatter, plan) 

855 else: 

856 formatter.print( 

857 { 

858 key: value 

859 for key, value in plan.items() 

860 if key not in _PLAN_GENERATED_CONFIG_KEYS 

861 } 

862 ) 

863 return 

864 

865 if plan["engine"] == AutopilotEngine.OPENCODE.value: 

866 opencode_binary = plan["opencode_binary"] 

867 if opencode_binary is None: 

868 formatter.print_info(f"OpenCode is not installed (pinned: {plan['opencode_pin']}).") 

869 if not yes and not confirm(f"Install it now with `{plan['install_command']}`?"): 

870 formatter.print_error( 

871 "OpenCode is required for this engine. Install it manually with " 

872 f"`{plan['install_command']}` and re-run `gco autopilot --engine opencode`." 

873 ) 

874 sys.exit(1) 

875 rc = install_opencode() 

876 if rc == 127: 

877 formatter.print_error( 

878 "npm was not found on PATH. The GCO dev container ships the required " 

879 "Node.js/npm toolchain; rebuild it or install npm and re-run." 

880 ) 

881 sys.exit(1) 

882 if rc != 0: 

883 formatter.print_error(f"`{plan['install_command']}` failed with exit code {rc}.") 

884 sys.exit(1) 

885 opencode_binary = find_opencode_binary() 

886 if opencode_binary is None: 

887 formatter.print_error( 

888 "OpenCode installed but the `opencode` binary is not on PATH. Open a " 

889 "new shell (or fix your npm global bin path) and re-run." 

890 ) 

891 sys.exit(1) 

892 try: 

893 write_opencode_config(plan["opencode_config"]) 

894 except (OSError, ValueError) as e: 

895 formatter.print_error(f"Failed to prepare the OpenCode session: {e}") 

896 sys.exit(1) 

897 resume_args = _resolve_opencode_resume_args(continue_session, resume) 

898 env = build_opencode_env(plan["region"]) 

899 argv = build_opencode_launch_argv( 

900 opencode_binary, 

901 root_args=build_opencode_owned_args(model=plan["model"]), 

902 resume_args=resume_args, 

903 extra_args=tuple(engine_args), 

904 ) 

905 formatter.print_info( 

906 f"Launching OpenCode on Bedrock ({plan['model']}, " 

907 f"provider={OPENCODE_BEDROCK_PROVIDER})..." 

908 ) 

909 try: 

910 rc = exec_opencode(argv, env) 

911 except OSError as e: 

912 formatter.print_error( 

913 f"Failed to launch OpenCode at {argv[0]}: {e}. The install may be " 

914 "incomplete — reinstall with " 

915 f"`{' '.join(opencode_install_command())}` and re-run." 

916 ) 

917 sys.exit(1) 

918 sys.exit(rc) 

919 

920 if plan["engine"] == AutopilotEngine.CODEX.value: 

921 codex_binary = plan["codex_binary"] 

922 if codex_binary is None: 

923 formatter.print_info(f"Codex is not installed (pinned: {plan['codex_pin']}).") 

924 if not yes and not confirm(f"Install it now with `{plan['install_command']}`?"): 

925 formatter.print_error( 

926 "Codex is required for this engine. Install it manually with " 

927 f"`{plan['install_command']}` and re-run `gco autopilot --engine codex`." 

928 ) 

929 sys.exit(1) 

930 rc = install_codex() 

931 if rc == 127: 

932 formatter.print_error( 

933 "npm was not found on PATH. The GCO dev container ships the required " 

934 "Node.js/npm toolchain; rebuild it or install npm and re-run." 

935 ) 

936 sys.exit(1) 

937 if rc != 0: 

938 formatter.print_error(f"`{plan['install_command']}` failed with exit code {rc}.") 

939 sys.exit(1) 

940 codex_binary = find_codex_binary() 

941 if codex_binary is None: 

942 formatter.print_error( 

943 "Codex installed but the `codex` binary is not on PATH. Open a new " 

944 "shell (or fix your npm global bin path) and re-run." 

945 ) 

946 sys.exit(1) 

947 try: 

948 write_codex_config(str(plan["codex_config"])) 

949 stage_codex_skills(tuple(skills)) 

950 except (OSError, ValueError) as e: 

951 formatter.print_error(f"Failed to prepare the Codex session: {e}") 

952 sys.exit(1) 

953 resume_args = _resolve_codex_resume_args(continue_session, resume) 

954 env = build_codex_env(plan["region"]) 

955 owned_args = build_codex_owned_args( 

956 model=plan["model"], 

957 region=plan["region"], 

958 reasoning_effort=plan["reasoning_effort"], 

959 workspace=Path(plan["workspace"]), 

960 ) 

961 argv = build_codex_launch_argv( 

962 codex_binary, 

963 root_args=owned_args, 

964 resume_args=resume_args, 

965 extra_args=tuple(engine_args), 

966 ) 

967 details = f", provider={CODEX_BEDROCK_PROVIDER}" 

968 if plan["reasoning_effort"] is not None: 

969 details = f", reasoning={plan['reasoning_effort']}{details}" 

970 formatter.print_info(f"Launching Codex on Bedrock ({plan['model']}{details})...") 

971 try: 

972 rc = exec_codex(argv, env) 

973 except OSError as e: 

974 formatter.print_error( 

975 f"Failed to launch Codex at {argv[0]}: {e}. Reinstall with " 

976 f"`{' '.join(codex_install_command())}` and re-run." 

977 ) 

978 sys.exit(1) 

979 sys.exit(rc) 

980 

981 claude_binary = plan["claude_binary"] 

982 if claude_binary is None: 

983 formatter.print_info(f"Claude Code is not installed (pinned: {plan['claude_code_pin']}).") 

984 if not yes and not confirm(f"Install it now with `{plan['install_command']}`?"): 

985 formatter.print_error( 

986 "Claude Code is required. Install it manually with " 

987 f"`{plan['install_command']}` and re-run `gco autopilot`." 

988 ) 

989 sys.exit(1) 

990 rc = install_claude_code() 

991 if rc == 127: 

992 formatter.print_error( 

993 "npm was not found on PATH. Install Node.js/npm (the GCO dev " 

994 "container ships both), or install Claude Code another way, " 

995 "then re-run `gco autopilot`." 

996 ) 

997 sys.exit(1) 

998 if rc != 0: 

999 formatter.print_error(f"`{plan['install_command']}` failed with exit code {rc}.") 

1000 sys.exit(1) 

1001 claude_binary = find_claude_binary() 

1002 if claude_binary is None: 

1003 formatter.print_error( 

1004 "Claude Code installed but the `claude` binary is not on PATH. " 

1005 "Open a new shell (or fix your npm global bin path) and re-run." 

1006 ) 

1007 sys.exit(1) 

1008 

1009 try: 

1010 written = write_mcp_config(plan["mcp_config"]) 

1011 staged_plugin = stage_imports(tuple(skills), tuple(agents)) 

1012 except (OSError, ValueError) as e: 

1013 formatter.print_error(f"Failed to prepare the session: {e}") 

1014 sys.exit(1) 

1015 

1016 plugin_paths = [Path(path) for path in plan["plugins"]] 

1017 if staged_plugin is not None: 

1018 plugin_paths.append(staged_plugin) 

1019 plugin_args = build_plugin_args(plugin_paths) 

1020 

1021 resume_args = _resolve_resume_args(plan, continue_session, resume, tuple(engine_args), yes) 

1022 

1023 env = build_claude_env(plan["model"], plan["region"], plan["small_fast_model"]) 

1024 argv = build_launch_argv(claude_binary, written, tuple(engine_args), resume_args, plugin_args) 

1025 

1026 formatter.print_info(f"Launching Claude Code on Bedrock ({plan['model']})...") 

1027 try: 

1028 rc = exec_claude(argv, env) # returns only on Windows 

1029 except OSError as e: 

1030 # A claude on PATH that cannot exec (for example a shim whose 

1031 # blocked postinstall never fetched the native binary) must fail 

1032 # with a remediation, not a traceback. 

1033 formatter.print_error( 

1034 f"Failed to launch Claude Code at {argv[0]}: {e}. " 

1035 "The install may be incomplete — reinstall with " 

1036 f"`{' '.join(claude_install_command())}` and re-run `gco autopilot`." 

1037 ) 

1038 sys.exit(1) 

1039 sys.exit(rc)