Coverage for cli / autopilot.py: 100.00%
317 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1"""Autopilot: launch a configured Claude Code or Codex session against GCO.
3``gco autopilot`` defaults to Claude Code for backward compatibility and can
4select Codex with ``--engine codex`` or ``GCO_AUTOPILOT_ENGINE=codex``. Both
5engines use the caller's AWS credentials, GCO's canonical Bedrock defaults,
6the GCO MCP server, and the recommended companion MCP servers. Claude Code
7keeps its generated JSON config plus ``--strict-mcp-config`` behavior. Codex
8uses a generated TOML config and skills inside GCO's isolated
9``~/.gco/autopilot/codex`` home, leaving personal ``~/.codex`` state alone.
11Neither CLI is baked into the development container. Autopilot detects the
12selected engine's binary and offers to install its exact npm pin lazily.
14Scanner contract (``.github/scripts/lib_dependency_scan.sh``):
16* ``extract_claude_code_pin`` and ``extract_codex_pin`` read
17 :data:`CLAUDE_CODE_VERSION` and :data:`CODEX_VERSION` from this file with
18 regexes — keep both as single-line, double-quoted assignments.
19* ``extract_companion_mcp_packages`` pairs the ``registry=`` / ``package=``
20 keywords inside each ``CompanionServer(`` block — keep those two fields
21 on their own lines when editing the registry below.
22"""
24from __future__ import annotations
26import json
27import os
28import re
29import shutil
30import subprocess
31import sys
32from dataclasses import dataclass, field
33from enum import StrEnum
34from pathlib import Path
36from gco.bedrock import (
37 get_default_claude_code_model_id,
38 get_default_codex_model_id,
39 get_default_codex_reasoning_effort,
40)
42from . import __version__
44#: Exact Claude Code release installed by ``gco autopilot`` when the
45#: ``claude`` binary is absent. Pinned (never ``latest``) so installs are
46#: reproducible; the monthly deps-scan reports drift against npm.
47CLAUDE_CODE_VERSION = "2.1.270"
49#: npm package that ships the ``claude`` binary.
50CLAUDE_CODE_PACKAGE = "@anthropic-ai/claude-code"
52#: Exact Codex CLI release installed by the Codex engine.
53#: Keep this literal assignment scanner-friendly like CLAUDE_CODE_VERSION.
54CODEX_VERSION = "0.154.0"
56#: npm package that ships the ``codex`` binary.
57CODEX_PACKAGE = "@openai/codex"
59#: Built-in Codex provider that signs Bedrock Runtime requests and supports
60#: geographic/global cross-Region inference profile IDs.
61CODEX_BEDROCK_PROVIDER = "amazon-bedrock-runtime"
63#: Parallel npx/uvx startup can include a first-use package download. Codex's
64#: generated config gives every curated MCP server one bounded minute.
65CODEX_MCP_STARTUP_TIMEOUT_SECONDS = 60.0
67#: Engine override environment variable; Claude Code remains the compatibility default.
68_ENGINE_ENV = "GCO_AUTOPILOT_ENGINE"
69_CODEX_MODEL_ENV = "GCO_AUTOPILOT_CODEX_MODEL"
72class AutopilotEngine(StrEnum):
73 """Interactive agent runtimes supported by ``gco autopilot``."""
75 CLAUDE_CODE = "claude-code"
76 CODEX = "codex"
79def resolve_engine(explicit: str | AutopilotEngine | None) -> AutopilotEngine:
80 """Resolve engine flag > environment > backward-compatible Claude default."""
81 if explicit is not None:
82 raw: str | AutopilotEngine = explicit
83 elif _ENGINE_ENV in os.environ:
84 raw = os.environ[_ENGINE_ENV]
85 else:
86 return AutopilotEngine.CLAUDE_CODE
87 if isinstance(raw, AutopilotEngine):
88 return raw
89 candidate = str(raw).strip().lower()
90 try:
91 return AutopilotEngine(candidate)
92 except ValueError as exc:
93 supported = ", ".join(engine.value for engine in AutopilotEngine)
94 raise ValueError(f"Unknown autopilot engine {raw!r}. Choose one of: {supported}.") from exc
97#: Where the generated session MCP config lands. Regenerated on every
98#: launch, so hand edits do not survive — persistent customization belongs
99#: in your own MCP config (see gco_mcp/README.md).
100_CONFIG_DIR_ENV = "GCO_AUTOPILOT_CONFIG_DIR"
101_DEFAULT_CONFIG_DIR = Path.home() / ".gco" / "autopilot"
102_CONFIG_FILENAME = "mcp.json"
103_CODEX_HOME_DIRNAME = "codex"
104_CODEX_CONFIG_FILENAME = "config.toml"
105_CODEX_SKILLS_DIRNAME = "skills"
107#: Model override environment variable (the ``--model`` flag wins over it).
108_MODEL_ENV = "GCO_AUTOPILOT_MODEL"
110#: Optional Bedrock model for Claude Code's background/fast tasks. Left
111#: unset by default: the right haiku-class profile depends on what the
112#: account has enabled, and Claude Code degrades gracefully without it.
113_SMALL_FAST_MODEL_ENV = "GCO_AUTOPILOT_SMALL_FAST_MODEL"
115#: Placeholder in companion ``args`` replaced with the launch directory.
116_WORKSPACE_PLACEHOLDER = "{workspace}"
118#: Colon-separated plugin dirs/zips always loaded into autopilot sessions
119#: (merged with per-launch ``--plugin`` flags).
120_PLUGIN_DIRS_ENV = "GCO_AUTOPILOT_PLUGIN_DIRS"
122#: Name of the synthetic session plugin that packages loose ``--skills`` /
123#: ``--agents`` directories so Claude Code can load them.
124_IMPORTS_PLUGIN_NAME = "gco-autopilot-imports"
126#: Read-only command allowlist for the shell companion. Deliberately tight —
127#: no ``rm``, no ``git`` — matching the guidance in gco_mcp/README.md.
128_SHELL_ALLOW_COMMANDS = "ls,cat,pwd,grep,wc,touch,find"
131@dataclass(frozen=True)
132class CompanionServer:
133 """One recommended companion MCP server from ``gco_mcp/README.md``.
135 ``registry`` + ``package`` identify the distribution (``npm`` or
136 ``pypi``) for the deps-scan liveness check; ``command`` + ``args`` +
137 ``env`` are the stdio launch recipe written into the session config.
138 """
140 name: str
141 registry: str
142 package: str
143 command: str
144 args: tuple[str, ...]
145 env: dict[str, str] = field(default_factory=dict)
148#: The companion MCP servers wired into every autopilot session, one entry
149#: per row of the "Recommended Companion MCP Servers" tables in
150#: ``gco_mcp/README.md``. ``tests/test_cli_autopilot.py`` enforces that the
151#: two stay in lockstep, and the monthly deps-scan verifies each package is
152#: still published (and not deprecated/yanked) on its registry.
153#:
154#: The EKS server is configured read-only on purpose: an auto-generated
155#: agent session should not silently hold cluster write access. Add
156#: ``--allow-write`` / ``--allow-sensitive-data-access`` in your own MCP
157#: config if you want the mutating tools.
158COMPANION_MCP_SERVERS: tuple[CompanionServer, ...] = (
159 CompanionServer(
160 name="aws-docs",
161 registry="pypi",
162 package="awslabs.aws-documentation-mcp-server",
163 command="uvx",
164 args=("awslabs.aws-documentation-mcp-server@latest",),
165 env={"FASTMCP_LOG_LEVEL": "ERROR"},
166 ),
167 CompanionServer(
168 name="aws-pricing",
169 registry="pypi",
170 package="awslabs.aws-pricing-mcp-server",
171 command="uvx",
172 args=("awslabs.aws-pricing-mcp-server@latest",),
173 env={"FASTMCP_LOG_LEVEL": "ERROR"},
174 ),
175 CompanionServer(
176 name="eks",
177 registry="pypi",
178 package="awslabs.eks-mcp-server",
179 command="uvx",
180 args=("awslabs.eks-mcp-server@latest",),
181 env={"FASTMCP_LOG_LEVEL": "ERROR"},
182 ),
183 CompanionServer(
184 name="filesystem",
185 registry="npm",
186 package="@modelcontextprotocol/server-filesystem",
187 command="npx",
188 args=("-y", "@modelcontextprotocol/server-filesystem", _WORKSPACE_PLACEHOLDER),
189 ),
190 CompanionServer(
191 name="ddg-search",
192 registry="pypi",
193 package="duckduckgo-mcp-server",
194 command="uvx",
195 args=("duckduckgo-mcp-server",),
196 ),
197 CompanionServer(
198 name="deepwiki",
199 registry="npm",
200 package="mcp-deepwiki",
201 command="npx",
202 args=("-y", "mcp-deepwiki@latest"),
203 ),
204 CompanionServer(
205 name="playwright",
206 registry="npm",
207 package="@playwright/mcp",
208 command="npx",
209 args=("-y", "@playwright/mcp@latest"),
210 ),
211 CompanionServer(
212 name="sequential-thinking",
213 registry="npm",
214 package="@modelcontextprotocol/server-sequential-thinking",
215 command="npx",
216 args=("-y", "@modelcontextprotocol/server-sequential-thinking"),
217 ),
218 CompanionServer(
219 name="inner-monologue",
220 registry="npm",
221 package="inner-monologue-mcp",
222 command="npx",
223 args=("-y", "inner-monologue-mcp"),
224 ),
225 CompanionServer(
226 name="memory",
227 registry="npm",
228 package="@modelcontextprotocol/server-memory",
229 command="npx",
230 args=("-y", "@modelcontextprotocol/server-memory"),
231 ),
232 CompanionServer(
233 name="mcp-tasks",
234 registry="npm",
235 package="mcp-tasks",
236 command="npx",
237 args=("-y", "mcp-tasks"),
238 ),
239 CompanionServer(
240 name="shell",
241 registry="pypi",
242 package="mcp-shell-server",
243 command="uvx",
244 args=("mcp-shell-server",),
245 env={"ALLOW_COMMANDS": _SHELL_ALLOW_COMMANDS},
246 ),
247)
250def _source_checkout_root(candidate: Path | None = None) -> Path | None:
251 """Return the GCO checkout root when autopilot runs from source.
253 Mirrors the marker discipline in :mod:`gco.bedrock`: only the checkout
254 that owns *this* file counts (``candidate`` exists for tests) — the
255 current working directory is deliberately ignored so an unrelated
256 project cannot redirect which MCP server code the session runs.
257 """
258 root = candidate if candidate is not None else Path(__file__).resolve().parent.parent
259 markers = (root / "app.py", root / "pyproject.toml", root / "gco_mcp" / "run_mcp.py")
260 if all(marker.is_file() for marker in markers):
261 return root
262 return None
265#: Every feature flag the GCO MCP server understands: the umbrella flag
266#: first (a perfectly reasonable thing to pass to ``--enable``), then the
267#: per-tool flags. This mirrors ``gco_mcp/feature_flags.py`` rather than
268#: importing it — the CLI must not depend on the MCP package at runtime
269#: (mypy also maps the PEP 420 namespace file under two module names when
270#: both trees are checked together). ``tests/test_cli_autopilot.py`` holds
271#: the two registries in lockstep, so drift fails the PR that introduces it.
272_KNOWN_GCO_MCP_FLAGS: tuple[str, ...] = (
273 "GCO_ENABLE_ALL_TOOLS",
274 "GCO_ENABLE_CAPACITY_PURCHASE",
275 "GCO_ENABLE_MODEL_UPLOAD",
276 "GCO_ENABLE_IMAGE_PUBLISH",
277 "GCO_ENABLE_INFRASTRUCTURE_DEPLOY",
278 "GCO_ENABLE_INFRASTRUCTURE_DESTROY",
279 "GCO_ENABLE_DESTRUCTIVE_OPERATIONS",
280 "GCO_ENABLE_MISSION",
281 "GCO_ENABLE_LOCAL_METRICS",
282 "GCO_ENABLE_LOCAL_STORAGE_SYNC",
283 "GCO_ENABLE_SEMANTIC_PROGRESS",
284 "GCO_ENABLE_CONFIG_MANAGEMENT",
285 "GCO_ENABLE_SWARM",
286)
289def known_gco_mcp_flags() -> tuple[str, ...]:
290 """Return every feature flag the GCO MCP server understands."""
291 return _KNOWN_GCO_MCP_FLAGS
294def resolve_mcp_flags(enable: tuple[str, ...]) -> dict[str, str]:
295 """Translate ``--enable`` values into env vars for the gco MCP server.
297 Accepts either the full env-var form (``GCO_ENABLE_MISSION``) or the
298 bare suffix (``mission``, ``all-tools``, ``ALL_TOOLS``) and normalizes
299 to the canonical name. Unknown flags raise ``ValueError`` listing the
300 valid set — a typo should fail loudly at launch, not silently launch a
301 session missing the tools the caller asked for.
302 """
303 known = known_gco_mcp_flags()
304 by_name = {flag: flag for flag in known}
305 by_suffix = {flag.removeprefix("GCO_ENABLE_"): flag for flag in known}
307 resolved: dict[str, str] = {}
308 for raw in enable:
309 candidate = raw.strip().upper().replace("-", "_")
310 flag = by_name.get(candidate) or by_suffix.get(candidate)
311 if flag is None:
312 suffixes = ", ".join(sorted(s.lower().replace("_", "-") for s in by_suffix))
313 raise ValueError(
314 f"Unknown GCO MCP feature flag {raw!r}. Valid flags: {suffixes} "
315 "(or their full GCO_ENABLE_* names)."
316 )
317 resolved[flag] = "true"
318 return resolved
321def _gco_server_entry(mcp_env: dict[str, str] | None = None) -> dict[str, object]:
322 """Build the ``gco`` MCP server entry for the session config.
324 From a source checkout the server runs straight off the working tree
325 (``python3 gco_mcp/run_mcp.py``) so local MCP changes are live. From an
326 installed ``gco-cli`` it runs the release tag matching ``__version__``
327 via ``uvx`` — the same no-clone form gco_mcp/README.md documents.
329 ``mcp_env`` carries feature flags (``GCO_ENABLE_*``) and any other
330 server environment (for example ``GCO_MCP_TOOL_SEARCH``) into the
331 server process.
332 """
333 checkout = _source_checkout_root()
334 entry: dict[str, object]
335 if checkout is not None:
336 entry = {
337 "command": sys.executable or "python3",
338 "args": [str(checkout / "gco_mcp" / "run_mcp.py")],
339 }
340 else:
341 entry = {
342 "command": "uvx",
343 "args": [
344 "--python",
345 "3.14",
346 "--from",
347 "git+https://github.com/aws-solutions-library-samples/global-capacity-orchestrator-on-aws.git"
348 f"@v{__version__}",
349 "gco-mcp",
350 ],
351 }
352 if mcp_env:
353 entry["env"] = dict(sorted(mcp_env.items()))
354 return entry
357def build_mcp_config(
358 workspace: Path,
359 include_companions: bool = True,
360 gco_mcp_env: dict[str, str] | None = None,
361) -> dict[str, dict[str, dict[str, object]]]:
362 """Return the session ``mcpServers`` config for Claude Code.
364 ``workspace`` replaces the ``{workspace}`` placeholder in companion
365 args (the filesystem server's root), so file access is scoped to the
366 directory autopilot was launched from. ``gco_mcp_env`` is applied to
367 the gco server entry only — feature flags gate GCO tools, not the
368 companions.
369 """
370 servers: dict[str, dict[str, object]] = {"gco": _gco_server_entry(gco_mcp_env)}
371 if include_companions:
372 for companion in COMPANION_MCP_SERVERS:
373 entry: dict[str, object] = {
374 "command": companion.command,
375 "args": [
376 str(workspace) if arg == _WORKSPACE_PLACEHOLDER else arg
377 for arg in companion.args
378 ],
379 }
380 if companion.env:
381 entry["env"] = dict(companion.env)
382 servers[companion.name] = entry
383 return {"mcpServers": servers}
386def config_path() -> Path:
387 """Return the on-disk location of the generated session MCP config."""
388 override = os.environ.get(_CONFIG_DIR_ENV)
389 directory = Path(override).expanduser() if override else _DEFAULT_CONFIG_DIR
390 return directory / _CONFIG_FILENAME
393def write_mcp_config(config: dict[str, dict[str, dict[str, object]]]) -> Path:
394 """Write the session MCP config and return its path."""
395 path = config_path()
396 path.parent.mkdir(parents=True, exist_ok=True)
397 path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
398 return path
401def codex_home() -> Path:
402 """Return GCO's isolated, persistent Codex home directory."""
403 return config_path().parent / _CODEX_HOME_DIRNAME
406def codex_config_path() -> Path:
407 """Return the generated Codex config path under the isolated home."""
408 return codex_home() / _CODEX_CONFIG_FILENAME
411def _toml_string(value: str) -> str:
412 """Encode one TOML basic string using JSON's compatible escaping."""
413 return json.dumps(value, ensure_ascii=False)
416def _toml_array(values: list[str]) -> str:
417 return "[" + ", ".join(_toml_string(value) for value in values) + "]"
420def build_codex_config_toml(
421 mcp_config: dict[str, dict[str, dict[str, object]]],
422 *,
423 model: str,
424 region: str,
425 reasoning_effort: str | None,
426) -> str:
427 """Render a complete isolated Codex config with Bedrock and MCP servers.
429 ``model_reasoning_effort`` is emitted only for the canonical GCO model.
430 Explicit CLI/environment model overrides must use the selected model's own
431 defaults instead of inheriting a potentially incompatible effort value.
432 """
433 lines = [
434 f"model = {_toml_string(model)}",
435 f"model_provider = {_toml_string(CODEX_BEDROCK_PROVIDER)}",
436 ]
437 if reasoning_effort is not None:
438 lines.append(f"model_reasoning_effort = {_toml_string(reasoning_effort)}")
439 lines.extend(
440 [
441 "check_for_update_on_startup = false",
442 "",
443 f"[model_providers.{CODEX_BEDROCK_PROVIDER}]",
444 'wire_api = "responses"',
445 "",
446 f"[model_providers.{CODEX_BEDROCK_PROVIDER}.aws]",
447 f"region = {_toml_string(region)}",
448 ]
449 )
450 servers = mcp_config["mcpServers"]
451 for name in sorted(servers):
452 entry = servers[name]
453 command = entry.get("command")
454 args = entry.get("args", [])
455 if not isinstance(command, str) or not command:
456 raise ValueError(f"Codex MCP server {name!r} has no command")
457 if not isinstance(args, list) or not all(isinstance(item, str) for item in args):
458 raise ValueError(f"Codex MCP server {name!r} args must be strings")
459 table_name = _toml_string(name)
460 lines.extend(
461 [
462 "",
463 f"[mcp_servers.{table_name}]",
464 f"command = {_toml_string(command)}",
465 f"args = {_toml_array(args)}",
466 "enabled = true",
467 f"startup_timeout_sec = {CODEX_MCP_STARTUP_TIMEOUT_SECONDS}",
468 ]
469 )
470 environment = entry.get("env")
471 if environment:
472 if not isinstance(environment, dict) or not all(
473 isinstance(key, str) and isinstance(value, str)
474 for key, value in environment.items()
475 ):
476 raise ValueError(f"Codex MCP server {name!r} env must contain strings")
477 lines.extend(["", f"[mcp_servers.{table_name}.env]"])
478 for key, value in sorted(environment.items()):
479 lines.append(f"{_toml_string(key)} = {_toml_string(value)}")
480 return "\n".join(lines) + "\n"
483def write_codex_config(content: str) -> Path:
484 """Write the generated Codex configuration inside isolated CODEX_HOME."""
485 path = codex_config_path()
486 path.parent.mkdir(parents=True, exist_ok=True)
487 path.write_text(content, encoding="utf-8")
488 return path
491def _selected_model_override(
492 explicit: str | None,
493 env_names: tuple[str, ...],
494) -> tuple[str | None, str | None]:
495 """Return the highest-precedence model override and its user-facing source.
497 Presence, not truthiness, selects a source so a blank high-precedence value
498 fails closed instead of silently falling through to another model.
499 """
500 if explicit is not None:
501 raw_value = explicit
502 source = "--model"
503 else:
504 for env_name in env_names:
505 if env_name in os.environ:
506 raw_value = os.environ[env_name]
507 source = env_name
508 break
509 else:
510 return None, None
511 value = raw_value.strip()
512 if not value:
513 raise ValueError(f"{source} must be a non-empty Bedrock model id")
514 return value, source
517def resolve_model(explicit: str | None) -> tuple[str, list[str]]:
518 """Resolve the Claude Bedrock model with strict override validation.
520 Precedence: ``--model`` flag > ``GCO_AUTOPILOT_MODEL`` env > the
521 ``cdk.json`` Claude Code default
522 (``context.bedrock.claude_code_default_model_id``, deliberately separate
523 from the advisory default Mission and the capacity advisor share). The
524 result is advisory-validated only: Bedrock ids for Claude contain
525 ``anthropic``/``claude``, but application inference-profile ARNs are
526 opaque, so an unfamiliar id produces a warning rather than a refusal.
527 """
528 warnings: list[str] = []
529 override, _source = _selected_model_override(explicit, (_MODEL_ENV,))
530 model = override if override is not None else get_default_claude_code_model_id()
531 lowered = model.lower()
532 if "anthropic" not in lowered and "claude" not in lowered:
533 warnings.append(
534 f"Model id {model!r} does not look like a Claude model on Bedrock. "
535 "Claude Code is tuned for Claude models; continuing anyway."
536 )
537 return model, warnings
540def resolve_codex_model(explicit: str | None) -> tuple[str, list[str]]:
541 """Resolve Codex model: flag > Codex env > generic env > cdk.json."""
542 warnings: list[str] = []
543 override, _source = _selected_model_override(
544 explicit,
545 (_CODEX_MODEL_ENV, _MODEL_ENV),
546 )
547 model = override if override is not None else get_default_codex_model_id()
548 lowered = model.lower()
549 if "openai" not in lowered and "gpt" not in lowered:
550 warnings.append(
551 f"Model id {model!r} does not look like an OpenAI model on Bedrock; "
552 "continuing with the explicit override."
553 )
554 return model, warnings
557def resolve_codex_reasoning_effort(explicit_model: str | None = None) -> str | None:
558 """Return canonical Codex effort only when no model override was selected."""
559 override, _source = _selected_model_override(
560 explicit_model,
561 (_CODEX_MODEL_ENV, _MODEL_ENV),
562 )
563 if override is not None:
564 return None
565 return get_default_codex_reasoning_effort()
568def resolve_small_fast_model(explicit: str | None) -> str | None:
569 """Resolve Claude's optional fast model, rejecting configured blank values."""
570 if explicit is not None:
571 raw_value = explicit
572 source = "--small-fast-model"
573 elif _SMALL_FAST_MODEL_ENV in os.environ:
574 raw_value = os.environ[_SMALL_FAST_MODEL_ENV]
575 source = _SMALL_FAST_MODEL_ENV
576 else:
577 return None
578 value = raw_value.strip()
579 if not value:
580 raise ValueError(f"{source} must be a non-empty Bedrock model id")
581 return value
584def build_claude_env(
585 model: str,
586 region: str,
587 small_fast_model: str | None = None,
588) -> dict[str, str]:
589 """Return the environment for the Claude Code process.
591 Starts from the caller's environment so AWS credentials, profiles, and
592 proxies pass through untouched. An ``AWS_REGION`` already set by the
593 caller wins over the GCO-configured region — least surprise for anyone
594 juggling AWS environments — and ``ANTHROPIC_SMALL_FAST_MODEL`` is only
595 set when a background model was explicitly chosen.
596 """
597 env = dict(os.environ)
598 env["CLAUDE_CODE_USE_BEDROCK"] = "1"
599 env["DISABLE_AUTOUPDATER"] = "1"
600 env["ANTHROPIC_MODEL"] = model
601 env.setdefault("AWS_REGION", region)
602 if small_fast_model:
603 env["ANTHROPIC_SMALL_FAST_MODEL"] = small_fast_model
604 return env
607def build_codex_env(region: str) -> dict[str, str]:
608 """Return an isolated Codex environment while preserving AWS credentials."""
609 env = dict(os.environ)
610 env.setdefault("AWS_REGION", region)
611 env["CODEX_HOME"] = str(codex_home())
612 return env
615def effective_aws_region(default_region: str) -> str:
616 """Resolve the AWS SDK region Codex and its generated config should share."""
617 return os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or default_region
620def find_claude_binary() -> str | None:
621 """Return the resolved ``claude`` executable path, or ``None``."""
622 return shutil.which("claude")
625def find_codex_binary() -> str | None:
626 """Return the resolved ``codex`` executable path, or ``None``."""
627 return shutil.which("codex")
630def plugin_paths_requested(cli_plugins: tuple[str, ...]) -> bool:
631 """Return whether CLI or environment requested a Claude Code plugin."""
632 if cli_plugins:
633 return True
634 return any(part.strip() for part in os.environ.get(_PLUGIN_DIRS_ENV, "").split(":"))
637def resolve_plugin_paths(cli_plugins: tuple[str, ...]) -> list[Path]:
638 """Resolve the plugin dirs/zips this session loads, validating each.
640 Merges ``--plugin`` flags with the ``GCO_AUTOPILOT_PLUGIN_DIRS``
641 environment variable (colon-separated, for the "always bring my team's
642 plugin" case). A missing path raises ``ValueError`` — silently launching
643 without the skills someone asked for is the failure mode this guards.
644 """
645 raw: list[str] = list(cli_plugins)
646 env_value = os.environ.get(_PLUGIN_DIRS_ENV, "")
647 raw.extend(part for part in env_value.split(":") if part.strip())
649 resolved: list[Path] = []
650 seen: set[Path] = set()
651 for item in raw:
652 path = Path(item).expanduser()
653 if not path.exists():
654 raise ValueError(f"Plugin path does not exist: {path}")
655 path = path.resolve()
656 if path not in seen:
657 seen.add(path)
658 resolved.append(path)
659 return resolved
662def validate_imports(
663 skills_dirs: tuple[str, ...],
664 agents_dirs: tuple[str, ...],
665) -> None:
666 """Validate ``--skills`` / ``--agents`` sources without staging anything.
668 Shared by the dry-run/plan path (which must not write) and by
669 :func:`stage_imports`. ``skills_dirs`` entries must contain at least one
670 ``*/SKILL.md``; ``agents_dirs`` entries at least one ``*.md``. Anything
671 else raises ``ValueError`` — an empty import is a typo'd path, not a
672 preference.
673 """
674 for kind, sources, marker in (
675 ("skills", skills_dirs, "*/SKILL.md"),
676 ("agents", agents_dirs, "*.md"),
677 ):
678 for source_raw in sources:
679 source = Path(source_raw).expanduser()
680 if not source.is_dir():
681 raise ValueError(f"--{kind} path is not a directory: {source}")
682 if not any(source.glob(marker)):
683 raise ValueError(
684 f"--{kind} directory {source} contains no {marker} — "
685 "check the path (skills are one subdirectory per skill "
686 "with a SKILL.md; agents are *.md files)."
687 )
690def stage_imports(
691 skills_dirs: tuple[str, ...],
692 agents_dirs: tuple[str, ...],
693) -> Path | None:
694 """Package loose skills/agents directories as a session plugin.
696 Claude Code loads skills and agents from ``~/.claude`` and the
697 workspace's ``.claude`` automatically; this exists for everything
698 *else* — a team repo of skills, a scratch directory of agent files —
699 without copying anything into the user's project or personal config.
700 The staged plugin lives next to the generated MCP config, is rebuilt
701 from scratch on every launch (hand edits do not survive), and is
702 handed to claude with ``--plugin-dir``.
704 Sources are validated by :func:`validate_imports` first. Returns the
705 plugin directory, or ``None`` when nothing was imported.
706 """
707 if not skills_dirs and not agents_dirs:
708 return None
709 validate_imports(skills_dirs, agents_dirs)
711 plugin_root = config_path().parent / _IMPORTS_PLUGIN_NAME
712 shutil.rmtree(plugin_root, ignore_errors=True)
713 manifest_dir = plugin_root / ".claude-plugin"
714 manifest_dir.mkdir(parents=True)
715 manifest = {
716 "name": _IMPORTS_PLUGIN_NAME,
717 "version": __version__,
718 "description": (
719 "Session-scoped skills/agents imported by `gco autopilot "
720 "--skills/--agents`. Regenerated on every launch."
721 ),
722 }
723 (manifest_dir / "plugin.json").write_text(
724 json.dumps(manifest, indent=2) + "\n", encoding="utf-8"
725 )
727 for kind, sources in (("skills", skills_dirs), ("agents", agents_dirs)):
728 destination = plugin_root / kind
729 for source_raw in sources:
730 source = Path(source_raw).expanduser()
731 shutil.copytree(source, destination, dirs_exist_ok=True)
732 return plugin_root
735def stage_codex_skills(skills_dirs: tuple[str, ...]) -> Path | None:
736 """Rebuild GCO's isolated Codex skills directory from validated sources."""
737 destination = codex_home() / _CODEX_SKILLS_DIRNAME
738 shutil.rmtree(destination, ignore_errors=True)
739 if not skills_dirs:
740 return None
741 validate_imports(skills_dirs, ())
742 destination.mkdir(parents=True)
743 for source_raw in skills_dirs:
744 shutil.copytree(
745 Path(source_raw).expanduser(),
746 destination,
747 dirs_exist_ok=True,
748 )
749 return destination
752def build_plugin_args(plugin_paths: list[Path]) -> tuple[str, ...]:
753 """Render plugin paths as claude's repeatable ``--plugin-dir`` flags."""
754 args: list[str] = []
755 for path in plugin_paths:
756 args.extend(("--plugin-dir", str(path)))
757 return tuple(args)
760#: Where Claude Code keeps per-project conversation transcripts.
761_CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects"
764def _claude_project_dir_name(workspace: Path) -> str:
765 """Return Claude Code's directory name for a workspace path.
767 Claude Code names each entry under ``~/.claude/projects`` by replacing
768 every non-alphanumeric character of the absolute workspace path with
769 ``-`` (so ``/Users/dev/my_repo`` becomes ``-Users-dev-my-repo``).
770 """
771 return re.sub(r"[^A-Za-z0-9]", "-", str(workspace))
774def has_resumable_session(workspace: Path) -> bool:
775 """Return whether Claude Code has a previous session for ``workspace``.
777 Peeks at Claude Code's own transcript store (one ``*.jsonl`` per
778 conversation). The layout is Claude Code internal, so this check is
779 deliberately fail-quiet: if the directory scheme ever changes, the
780 resume prompt silently stops appearing while the explicit
781 ``--continue`` / ``--resume`` flags — which claude interprets itself —
782 keep working unchanged.
783 """
784 try:
785 project_dir = _CLAUDE_PROJECTS_DIR / _claude_project_dir_name(workspace)
786 return any(project_dir.glob("*.jsonl"))
787 except OSError:
788 return False
791def claude_install_command() -> list[str]:
792 """Return the pinned, reproducible Claude Code install command.
794 ``--allow-scripts`` names exactly this one package: Claude Code's
795 postinstall downloads the platform-native binary, and npm >= 12 blocks
796 lifecycle scripts by default, which would otherwise leave a shim on
797 PATH that fails with ``Exec format error`` on launch. Older npm (< 12)
798 accepts and ignores the flag, so one command form works everywhere.
799 """
800 return [
801 "npm",
802 "install",
803 "-g",
804 f"--allow-scripts={CLAUDE_CODE_PACKAGE}",
805 f"{CLAUDE_CODE_PACKAGE}@{CLAUDE_CODE_VERSION}",
806 ]
809def install_claude_code() -> int:
810 """Install the pinned Claude Code release; return the npm exit code."""
811 if shutil.which("npm") is None:
812 return 127
813 return subprocess.call(claude_install_command()) # noqa: S603
816def codex_install_command() -> list[str]:
817 """Return the exact pinned Codex npm install command."""
818 return ["npm", "install", "-g", f"{CODEX_PACKAGE}@{CODEX_VERSION}"]
821def install_codex() -> int:
822 """Install the pinned Codex release; return the npm exit code."""
823 if shutil.which("npm") is None:
824 return 127
825 return subprocess.call(codex_install_command()) # noqa: S603
828def build_launch_argv(
829 claude_binary: str,
830 mcp_config: Path,
831 extra_args: tuple[str, ...] = (),
832 resume_args: tuple[str, ...] = (),
833 plugin_args: tuple[str, ...] = (),
834) -> list[str]:
835 """Return the Claude Code argv for a hermetic autopilot session.
837 ``--strict-mcp-config`` makes the generated config the *only* MCP
838 config: personal ``~/.claude`` servers and project ``.mcp.json`` files
839 are ignored, so every autopilot session starts from the same known-good
840 server set. ``resume_args`` carries claude's native session-resumption
841 flags (``--continue`` / ``--resume [id]``) when the caller asked to
842 pick up an earlier conversation; the resumed session still runs under
843 this launch's MCP config and Bedrock environment. ``plugin_args``
844 carries the ``--plugin-dir`` flags for session-scoped plugins and the
845 staged skills/agents imports.
846 """
847 return [
848 claude_binary,
849 "--mcp-config",
850 str(mcp_config),
851 "--strict-mcp-config",
852 *plugin_args,
853 *resume_args,
854 *extra_args,
855 ]
858def codex_project_root(workspace: Path) -> Path:
859 """Return Codex's normalized project root for a run-scoped trust policy.
861 Codex 0.150.1 loads a trusted project's ``.codex/config.toml`` above its
862 user config, even when ``CODEX_HOME`` is isolated. Its Git project identity
863 follows the common Git directory, so linked worktrees resolve to the main
864 checkout. Outside Git, the launch directory itself is the project root.
865 """
866 resolved = workspace.resolve()
867 try:
868 result = subprocess.run(
869 ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
870 cwd=resolved,
871 capture_output=True,
872 text=True,
873 check=False,
874 timeout=5,
875 )
876 except OSError, subprocess.TimeoutExpired:
877 return resolved
878 if result.returncode != 0 or not result.stdout.strip():
879 return resolved
880 return Path(result.stdout.strip()).resolve().parent
883def build_codex_owned_args(
884 *,
885 model: str,
886 region: str,
887 reasoning_effort: str | None,
888 workspace: Path,
889) -> tuple[str, ...]:
890 """Return session-precedence controls that keep the Codex plan authoritative.
892 ``CODEX_HOME`` isolates personal user state, but Codex 0.150.1 otherwise
893 layers trusted project configuration above the generated TOML. Marking the
894 discovered project root untrusted for this launch disables that project
895 layer without persisting trust state. Scalar Bedrock/update controls are
896 repeated at session precedence as defense in depth. Organization-managed
897 policy remains authoritative by Codex design.
898 """
899 project_root = codex_project_root(workspace)
900 assignments = [
901 f"model_provider={_toml_string(CODEX_BEDROCK_PROVIDER)}",
902 (f"model_providers.{CODEX_BEDROCK_PROVIDER}.wire_api={_toml_string('responses')}"),
903 (f"model_providers.{CODEX_BEDROCK_PROVIDER}.aws.region={_toml_string(region)}"),
904 "check_for_update_on_startup=false",
905 (f'projects={{{_toml_string(str(project_root))}={{trust_level="untrusted"}}}}'),
906 ]
907 if reasoning_effort is not None:
908 assignments.insert(
909 1,
910 f"model_reasoning_effort={_toml_string(reasoning_effort)}",
911 )
913 args = ["--model", model]
914 for assignment in assignments:
915 args.extend(("-c", assignment))
916 return tuple(args)
919def build_codex_launch_argv(
920 codex_binary: str,
921 *,
922 root_args: tuple[str, ...] = (),
923 resume_args: tuple[str, ...] = (),
924 extra_args: tuple[str, ...] = (),
925) -> list[str]:
926 """Return Codex argv in native root → resume-selector → passthrough order."""
927 return [codex_binary, *root_args, *resume_args, *extra_args]
930def exec_claude(argv: list[str], env: dict[str, str]) -> int:
931 """Hand the terminal over to Claude Code.
933 On POSIX the process image is replaced (``execvpe``) so the terminal
934 *becomes* the session — no wrapper process lingers, signals and TTY
935 behavior are exactly claude's own. ``execvpe`` does not return on
936 success. Windows has no true exec, so the session runs as a child
937 process and its exit code is propagated.
938 """
939 if sys.platform == "win32":
940 return subprocess.call(argv, env=env) # noqa: S603
941 os.execvpe(argv[0], argv, env) # noqa: S606
942 raise AssertionError("unreachable: execvpe replaces the process on success")
945def exec_codex(argv: list[str], env: dict[str, str]) -> int:
946 """Hand the terminal over to Codex using the same process semantics."""
947 return exec_claude(argv, env)