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
« 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."""
3import json
4import sys
5import tomllib
6from pathlib import Path
7from typing import Any
9import click
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
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
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"})
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}
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"})
98pass_config = click.make_pass_decorator(GCOConfig, ensure=True)
101def _stdin_is_interactive() -> bool:
102 """Whether a human is on the other end (gates the resume prompt)."""
103 return sys.stdin.isatty()
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
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)
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)
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
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.
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 ()
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 ()
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``.
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 ()
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=",)
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.
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.
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 )
372def _validate_codex_engine_args(engine_args: tuple[str, ...]) -> None:
373 """Reject native overrides that would invalidate Autopilot's launch plan.
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"}
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)
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
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
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 )
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"
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
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()
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.
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.
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.
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.
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.
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.
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
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)
790 if continue_session and resume is not None:
791 formatter.print_error("Pass either --continue or --resume, not both.")
792 sys.exit(1)
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)
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)
831 for warning in warnings:
832 formatter.print_warning(warning)
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
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
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)
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)
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)
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)
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)
1021 resume_args = _resolve_resume_args(plan, continue_session, resume, tuple(engine_args), yes)
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)
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)