demo/record_autopilot.sh166 of 166 statements covered (100.00%).
coveredmissednever traced by Bash (not counted)A line ending in … continues the statement above it and shares its fate.
| 1 | 8 | #!/usr/bin/env bash |
| 2 | # ───────────────────────────────────────────────────────────────────────────── | |
| 3 | # Record the GCO Autopilot demo as an animated GIF | |
| 4 | # ───────────────────────────────────────────────────────────────────────────── | |
| 5 | # Records a short terminal session of `gco autopilot` and converts it to a | |
| 6 | # GIF with agg. Select the engine with DEMO_ENGINE (``claude-code`` by | |
| 7 | # default, or ``codex``) and the scenario with DEMO_MODE: | |
| 8 | # | |
| 9 | # live (default) A real interactive session on Amazon Bedrock. The recorder | |
| 10 | # launches the selected TUI, types a GCO question, waits for | |
| 11 | # an MCP-grounded answer, then exits. Requires the selected | |
| 12 | # engine binary, expect(1), and Bedrock-enabled credentials. | |
| 13 | # plan Credential-free recording of the selected engine's | |
| 14 | # `--dry-run` plan: model, reasoning, MCP set, config path, | |
| 15 | # and exact lazy-install pin. This is the Codex demo mode. | |
| 16 | # | |
| 17 | # The recording drives the *checked-out* CLI through a `gco` PATH shim | |
| 18 | # (`python3 -m cli.main`), never a globally installed gco, so the GIF always | |
| 19 | # reflects the code in this working tree. Model latency is compressed by | |
| 20 | # asciinema's --idle-time-limit, so the live mode stays a short GIF. | |
| 21 | # | |
| 22 | # Output files (deposited in demo/): | |
| 23 | # Claude: demo/autopilot-claude-code.cast + demo/autopilot-claude-code.gif | |
| 24 | # Codex: demo/autopilot-codex.cast + demo/autopilot-codex.gif | |
| 25 | # | |
| 26 | # Prerequisites: | |
| 27 | # - asciinema: brew install asciinema (or pip install asciinema) | |
| 28 | # - agg: brew install agg (or cargo install agg) | |
| 29 | # - python3 with the repo's dependencies importable (dev container, or | |
| 30 | # an environment where `python3 -m cli.main --help` works) | |
| 31 | # | |
| 32 | # Usage: | |
| 33 | # bash demo/record_autopilot.sh | |
| 34 | # | |
| 35 | # Options (via environment variables): | |
| 36 | # DEMO_ENGINE=claude-code "claude-code" (default) or "codex" | |
| 37 | # DEMO_MODE=live "live" (real selected-engine Bedrock session, default) | |
| 38 | # or "plan" (credential-free engine launch plan) | |
| 39 | # DEMO_COLS=110 Terminal width for recording (default: 110) | |
| 40 | # DEMO_ROWS=30 Terminal height for recording (default: 30) | |
| 41 | # DEMO_SPEED=1.6 Playback speed multiplier for GIF (default: 1.6) | |
| 42 | # DEMO_THEME=monokai agg color theme (default: monokai) | |
| 43 | # DEMO_FONT_FAMILY agg font fallback chain (default: see lib_demo.sh) | |
| 44 | # SKIP_GIF=1 Only produce the .cast file, skip GIF conversion | |
| 45 | # SKIP_SANITIZE=1 Skip AWS-account-ID redaction (debugging only) | |
| 46 | # SKIP_EMOJI_STRIP=1 Skip emoji substitution (debugging only) | |
| 47 | # | |
| 48 | # The recorded .cast is post-processed exactly like the other demo | |
| 49 | # recordings before the GIF is rendered: sanitize_cast redacts anything | |
| 50 | # shaped like an AWS account ID or access-key ID (verified afterwards by | |
| 51 | # verify_cast_sanitized), and strip_emoji_from_cast rewrites codepoints | |
| 52 | # agg's text engine can't render. See demo/lib_demo.sh. | |
| 53 | # ───────────────────────────────────────────────────────────────────────────── | |
| 54 | ||
| 55 | 12 | set -euo pipefail |
| 56 | ||
| 57 | # ── Configuration ──────────────────────────────────────────────────────────── | |
| 58 | ||
| 59 | 48 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| 60 | # The checkout being recorded: normally the one this script lives in. The BATS | |
| 61 | # suite points GCO_RECORDING_REPO_ROOT at a disposable fixture repository so | |
| 62 | # the tracked recorder runs in place against it; left unset, every path below | |
| 63 | # is the same as before the override existed. | |
| 64 | 36 | REPO_ROOT="$(cd "${GCO_RECORDING_REPO_ROOT:-$SCRIPT_DIR/..}" && pwd)" |
| 65 | 12 | DEMO_DIR="${REPO_ROOT}/demo" |
| 66 | ||
| 67 | # shellcheck source=demo/lib_demo.sh | |
| 68 | 12 | source "${SCRIPT_DIR}/lib_demo.sh" |
| 69 | 12 | setup_colors |
| 70 | ||
| 71 | 12 | DEMO_ENGINE="${DEMO_ENGINE:-claude-code}" |
| 72 | 12 | DEMO_MODE="${DEMO_MODE:-live}" |
| 73 | ||
| 74 | 12 | case "$DEMO_ENGINE" in |
| 75 | claude-code) | |
| 76 | 7 | CAST_FILE="${DEMO_DIR}/autopilot-claude-code.cast" |
| 77 | 7 | GIF_FILE="${DEMO_DIR}/autopilot-claude-code.gif" |
| 78 | ;; | |
| 79 | codex) | |
| 80 | 4 | CAST_FILE="${DEMO_DIR}/autopilot-codex.cast" |
| 81 | 4 | GIF_FILE="${DEMO_DIR}/autopilot-codex.gif" |
| 82 | ;; | |
| 83 | 2 | *) echo "error: DEMO_ENGINE must be 'claude-code' or 'codex', got '$DEMO_ENGINE'" >&2; exit 1 ;; |
| 84 | esac | |
| 85 | ||
| 86 | 11 | COLS="${DEMO_COLS:-110}" |
| 87 | 11 | ROWS="${DEMO_ROWS:-30}" |
| 88 | 11 | SPEED="${DEMO_SPEED:-1.6}" |
| 89 | 11 | THEME="${DEMO_THEME:-monokai}" |
| 90 | ||
| 91 | 11 | case "$DEMO_MODE" in |
| 92 | 10 | live|plan) : ;; |
| 93 | 2 | *) echo "error: DEMO_MODE must be 'live' or 'plan', got '$DEMO_MODE'" >&2; exit 1 ;; |
| 94 | esac | |
| 95 | ||
| 96 | # ── Preflight Checks ──────────────────────────────────────────────────────── | |
| 97 | ||
| 98 | 10 | PREFLIGHT_FAIL=0 |
| 99 | ||
| 100 | preflight_pass() { | |
| 101 | 62 | echo " ${GREEN}${BOLD}✓${RESET} $1" |
| 102 | } | |
| 103 | ||
| 104 | preflight_fail() { | |
| 105 | 10 | echo " ${RED}${BOLD}✗${RESET} $1" |
| 106 | 10 | echo " ${DIM}Fix: $2${RESET}" |
| 107 | 10 | PREFLIGHT_FAIL=$((PREFLIGHT_FAIL + 1)) |
| 108 | } | |
| 109 | ||
| 110 | 10 | echo "=== GCO Autopilot Demo Recorder (${DEMO_ENGINE}, ${DEMO_MODE}) ===" |
| 111 | 10 | echo "" |
| 112 | ||
| 113 | 10 | if command -v asciinema &>/dev/null; then |
| 114 | 27 | preflight_pass "asciinema installed ($(asciinema --version 2>&1 | head -1))" |
| 115 | else | |
| 116 | 1 | preflight_fail "asciinema not installed" \ |
| 117 | "brew install asciinema (macOS) or pip install asciinema (Linux)" | |
| 118 | fi | |
| 119 | ||
| 120 | 10 | if [ "${SKIP_GIF:-}" != "1" ]; then |
| 121 | 8 | if command -v agg &>/dev/null; then |
| 122 | 21 | preflight_pass "agg installed ($(agg --version 2>&1 | head -1))" |
| 123 | else | |
| 124 | 1 | preflight_fail "agg not installed" \ |
| 125 | "brew install agg (macOS) or cargo install agg (Rust), or set SKIP_GIF=1" | |
| 126 | fi | |
| 127 | fi | |
| 128 | ||
| 129 | 20 | if (cd "$REPO_ROOT" && python3 -m cli.main --version &>/dev/null); then |
| 130 | 9 | preflight_pass "GCO CLI importable (python3 -m cli.main)" |
| 131 | else | |
| 132 | 1 | preflight_fail "GCO CLI not importable from this python3" \ |
| 133 | "Run inside the dev container, or install the repo's deps (pip install -e .)" | |
| 134 | fi | |
| 135 | ||
| 136 | 20 | if [ -f "${SCRIPT_DIR}/lib_demo.sh" ] && [ -f "${REPO_ROOT}/cdk.json" ]; then |
| 137 | 9 | preflight_pass "Repository layout looks right" |
| 138 | else | |
| 139 | 1 | preflight_fail "Repository layout unexpected" "Run from a full GCO checkout" |
| 140 | fi | |
| 141 | ||
| 142 | 10 | if [ "$DEMO_MODE" = "live" ]; then |
| 143 | 8 | if [ "$DEMO_ENGINE" = "codex" ]; then |
| 144 | 3 | ENGINE_BINARY="codex" |
| 145 | 3 | ENGINE_LABEL="Codex" |
| 146 | 3 | INSTALL_HINT="gco autopilot --engine codex -y" |
| 147 | else | |
| 148 | 5 | ENGINE_BINARY="claude" |
| 149 | 5 | ENGINE_LABEL="Claude Code" |
| 150 | 5 | INSTALL_HINT="gco autopilot -y" |
| 151 | fi | |
| 152 | 8 | if command -v "$ENGINE_BINARY" &>/dev/null; then |
| 153 | 18 | preflight_pass "$ENGINE_LABEL installed ($("$ENGINE_BINARY" --version 2>&1 | head -1))" |
| 154 | else | |
| 155 | 2 | preflight_fail "$ENGINE_LABEL not installed (live mode launches a real session)" \ |
| 156 | "Run '$INSTALL_HINT' once to install the pin, or set DEMO_MODE=plan" | |
| 157 | fi | |
| 158 | 8 | if [ "$DEMO_ENGINE" != "codex" ]; then |
| 159 | 10 | for companion_runtime in uvx npx; do |
| 160 | 10 | if command -v "$companion_runtime" &>/dev/null; then |
| 161 | 8 | preflight_pass "$companion_runtime installed" |
| 162 | else | |
| 163 | 2 | preflight_fail "$companion_runtime not installed (Claude live mode starts companions)" \ |
| 164 | "Use gco-dev, install the missing runtime, or set DEMO_MODE=plan" | |
| 165 | fi | |
| 166 | done | |
| 167 | fi | |
| 168 | 8 | if command -v expect &>/dev/null; then |
| 169 | 7 | preflight_pass "expect installed (drives the interactive TUI)" |
| 170 | else | |
| 171 | 1 | preflight_fail "expect not installed (live mode scripts the TUI)" \ |
| 172 | "macOS ships /usr/bin/expect; on Linux: apt install expect. Or set DEMO_MODE=plan" | |
| 173 | fi | |
| 174 | 8 | if aws sts get-caller-identity &>/dev/null; then |
| 175 | 7 | preflight_pass "AWS credentials resolve (Bedrock access is exercised by the recording)" |
| 176 | else | |
| 177 | 1 | preflight_fail "No AWS credentials (live mode makes a real Bedrock call)" \ |
| 178 | "Configure credentials with Bedrock model access, or set DEMO_MODE=plan" | |
| 179 | fi | |
| 180 | fi | |
| 181 | ||
| 182 | 10 | if [ "$PREFLIGHT_FAIL" -gt 0 ]; then |
| 183 | 2 | echo "" |
| 184 | 2 | echo " ${RED}${BOLD}${PREFLIGHT_FAIL} check(s) failed. Fix the issues above before recording.${RESET}" |
| 185 | 2 | exit 1 |
| 186 | fi | |
| 187 | ||
| 188 | 8 | echo "" |
| 189 | ||
| 190 | # ── Build the demo driver ─────────────────────────────────────────────────── | |
| 191 | # A `gco` PATH shim keeps the on-screen command honest (`$ gco autopilot …`) | |
| 192 | # while guaranteeing the recording exercises this checkout's code. | |
| 193 | ||
| 194 | 16 | SHIM_DIR="$(mktemp -d)" |
| 195 | 16 | DRIVER="$(mktemp)" |
| 196 | 8 | EXPECT_SCRIPT="" |
| 197 | 8 | trap 'rm -rf "$SHIM_DIR" "$DRIVER" ${EXPECT_SCRIPT:+"$EXPECT_SCRIPT"}' EXIT |
| 198 | ||
| 199 | 8 | cat > "${SHIM_DIR}/gco" <<'GCO_SHIM' |
| 200 | #!/usr/bin/env bash | |
| 201 | exec python3 -m cli.main "$@" | |
| 202 | GCO_SHIM | |
| 203 | 8 | chmod +x "${SHIM_DIR}/gco" |
| 204 | ||
| 205 | 14 | if [ "$DEMO_MODE" = "live" ] && [ "$DEMO_ENGINE" = "codex" ]; then |
| 206 | # Mirror the Claude recording with Codex's real inline TUI. Read-only, | |
| 207 | # never-approve, and run-scoped trust for this reviewed checkout are hidden | |
| 208 | # recording plumbing: the prompt explicitly uses GCO MCP documentation | |
| 209 | # tools, no workspace mutation is needed, and no trust setting is persisted. | |
| 210 | 4 | EXPECT_SCRIPT="$(mktemp)" |
| 211 | 2 | cat > "$EXPECT_SCRIPT" <<'EXPECT_DRIVER' |
| 212 | #!/usr/bin/expect -f | |
| 213 | set timeout 420 | |
| 214 | set stty_init "rows 30 columns 110" | |
| 215 | ||
| 216 | # Codex enables CSI-u keyboard reporting; this emits a physical Enter only | |
| 217 | # after a positively identified composer redraw below. | |
| 218 | proc press_enter {} { | |
| 219 | send -- "\033\[13u" | |
| 220 | } | |
| 221 | ||
| 222 | # The recording uses only the local GCO MCP server and exposes exactly the two | |
| 223 | # documentation tools needed on camera. GCO startup is required, both tools are | |
| 224 | # explicitly preapproved, and Codex's built-in shell is removed entirely. | |
| 225 | set gco_required {mcp_servers.gco.required=true} | |
| 226 | set gco_tools {mcp_servers.gco.enabled_tools=["find_docs","read_resource"]} | |
| 227 | set find_docs_approval {mcp_servers.gco.tools.find_docs.approval_mode="approve"} | |
| 228 | set read_resource_approval {mcp_servers.gco.tools.read_resource.approval_mode="approve"} | |
| 229 | set prompt {Use only the GCO MCP find_docs tool, then read_resource on a returned documentation URI; do not use shell commands or other tools. Which gco command submits a job through SQS, and why is that recommended? Answer in two short lines. Begin when ready} | |
| 230 | log_user 0 | |
| 231 | spawn gco autopilot --engine codex --no-companions -- -c $gco_required -c $gco_tools -c $find_docs_approval -c $read_resource_approval --disable shell_tool --sandbox read-only --ask-for-approval never --no-alt-screen -- $prompt | |
| 232 | log_user 1 | |
| 233 | ||
| 234 | # Codex accepts the initial prompt as prefilled composer text. Monitor startup | |
| 235 | # for dialogs, then append one harmless space to force a current composer redraw; | |
| 236 | # only a positive match of that redraw is allowed to submit the turn. | |
| 237 | set submitted 0 | |
| 238 | set timeout 20 | |
| 239 | expect { | |
| 240 | -nocase -re {trust the contents|trust the files|do you trust} { exit 6 } | |
| 241 | -nocase -re {do you want to (proceed|allow)|allow this tool} { exit 7 } | |
| 242 | -re {Working|Calling gco\.find_docs} { set submitted 1 } | |
| 243 | timeout {} | |
| 244 | eof { exit 3 } | |
| 245 | } | |
| 246 | if {!$submitted} { | |
| 247 | send -- "." | |
| 248 | set timeout 10 | |
| 249 | expect { | |
| 250 | -re {Begin when ready.*\.} { press_enter } | |
| 251 | -nocase -re {trust the contents|trust the files|do you trust} { exit 6 } | |
| 252 | -nocase -re {do you want to (proceed|allow)|allow this tool} { exit 7 } | |
| 253 | timeout { exit 8 } | |
| 254 | eof { exit 3 } | |
| 255 | } | |
| 256 | } | |
| 257 | # Wait for the explanatory final answer, not an intermediate tool query that | |
| 258 | # happens to mention the command. Trust and approval dialogs remain explicit | |
| 259 | # failures for the entire turn; never send an unqualified keypress on timeout. | |
| 260 | set timeout 420 | |
| 261 | expect { | |
| 262 | -nocase -re {trust the contents|trust the files|do you trust} { exit 6 } | |
| 263 | -nocase -re {do you want to (proceed|allow)|allow this tool} { exit 7 } | |
| 264 | -nocase -re {recommended (for production )?because|resilient production|durable, asynchronous} {} | |
| 265 | timeout { exit 4 } | |
| 266 | eof { exit 5 } | |
| 267 | } | |
| 268 | # Leave the complete short answer on screen before exiting. The matched phrase | |
| 269 | # can appear just before the final line finishes rendering. | |
| 270 | sleep 35 | |
| 271 | ||
| 272 | # Codex uses Ctrl+C for a clean TUI exit; unlike Claude Code, `/exit` is not | |
| 273 | # a terminating slash command in this version. | |
| 274 | send "\003" | |
| 275 | expect eof | |
| 276 | EXPECT_DRIVER | |
| 277 | 2 | |
| 278 | 2 | cat > "$DRIVER" <<DRIVER_SCRIPT |
| 279 | #!/usr/bin/env bash | |
| 280 | set -euo pipefail | |
| 281 | cd "\$REPO_ROOT" | |
| 282 | export PATH="\${SHIM_DIR}:\${PATH}" | |
| 283 | export COLUMNS="\${COLS}" LINES="\${ROWS}" | |
| 284 | ||
| 285 | # shellcheck source=demo/lib_demo.sh | |
| 286 | source "\${REPO_ROOT}/demo/lib_demo.sh" | |
| 287 | setup_colors | |
| 288 | ||
| 289 | banner "GCO Autopilot — Codex" | |
| 290 | narrate "A live Codex session scoped to GCO documentation tools:" | |
| 291 | narrate "Amazon Bedrock + required GCO MCP; no shell or companion servers." | |
| 292 | sleep 3 | |
| 293 | ||
| 294 | echo "" | |
| 295 | echo " \${MAGENTA}\\\$ \${WHITE}\${BOLD}gco autopilot --engine codex --no-companions\${RESET}" | |
| 296 | sleep 1 | |
| 297 | ||
| 298 | expect -f "$EXPECT_SCRIPT" | |
| 299 | ||
| 300 | printf '\033[2J\033[H' | |
| 301 | banner "GCO Autopilot — Codex" | |
| 302 | spacer | |
| 303 | highlight "A real session: Codex used only GCO's approved documentation tools." | |
| 304 | narrate "Default Autopilot can include companions; this recording is least-privilege." | |
| 305 | narrate "Get started: gco autopilot --engine codex" | |
| 306 | sleep 4 | |
| 307 | DRIVER_SCRIPT | |
| 308 | 6 | elif [ "$DEMO_MODE" = "live" ]; then |
| 309 | 2 | # A real interactive session, driven end-to-end: expect(1) spawns the |
| 310 | 2 | # actual `gco autopilot` TUI, types a question with human-ish pacing, |
| 311 | 2 | # approves the GCO MCP tool-permission dialog on camera (the security |
| 312 | 2 | # model is part of the demo), waits for the grounded answer, and exits |
| 313 | 2 | # with /exit. Timing-based matches keep it robust to cosmetic TUI |
| 314 | 2 | # changes; the post-recording check below verifies the answer actually |
| 315 | 2 | # landed before the GIF is rendered. |
| 316 | 8 | EXPECT_SCRIPT="$(mktemp)" |
| 317 | 4 | cat > "$EXPECT_SCRIPT" <<'EXPECT_DRIVER' |
| 318 | #!/usr/bin/expect -f | |
| 319 | set timeout 300 | |
| 320 | set stty_init "rows 30 columns 110" | |
| 321 | # Human-ish typing: avg 80ms/char, 400ms max — visible but not sluggish. | |
| 322 | set send_human {0.08 0.12 1 0.02 0.4} | |
| 323 | ||
| 324 | # Type with Expect's humanized per-character timing. Claude Code's native TUI | |
| 325 | # processes terminal key events individually; word-sized writes can be discarded | |
| 326 | # while its keyboard protocol is active. | |
| 327 | proc type_words {text} { | |
| 328 | send -h -- "$text" | |
| 329 | } | |
| 330 | ||
| 331 | # Claude Code 2.1.235 enables CSI-u keyboard reporting after startup. A raw | |
| 332 | # carriage return is text input in that mode; CSI 13 u is the physical Enter | |
| 333 | # key. The resume prompt appears before CSI-u is enabled and still uses \r. | |
| 334 | proc press_enter {} { | |
| 335 | send -- "\033\[13u" | |
| 336 | } | |
| 337 | ||
| 338 | # The GCO MCP server's tools are pre-approved for the session with | |
| 339 | # claude's own --allowedTools flag (through autopilot's passthrough), so | |
| 340 | # the recording is deterministic — no version-specific permission-dialog | |
| 341 | # text to script against. The driver deliberately displays plain | |
| 342 | # `gco autopilot`: the flag is recording plumbing, not part of the user | |
| 343 | # journey — an interactive user running the plain command gets the | |
| 344 | # identical session, with claude's ordinary one-click permission prompt | |
| 345 | # standing in for the pre-approval. | |
| 346 | # log_user is toggled off around spawn so expect's own echo of the spawn | |
| 347 | # line doesn't appear in the recording (the driver already printed the | |
| 348 | # pretty prompt line). | |
| 349 | log_user 0 | |
| 350 | spawn gco autopilot -- --allowedTools mcp__gco | |
| 351 | log_user 1 | |
| 352 | ||
| 353 | # Autopilot's own resume prompt (when this workspace has previous | |
| 354 | # sessions), first-run dialogs if any (workspace trust, theme picker), | |
| 355 | # then wait for the input prompt. Every match keeps consuming until the | |
| 356 | # composer is up; a quiet timeout just falls through to the settle sleep. | |
| 357 | expect { | |
| 358 | -re {Resume your previous Claude Code session} { sleep 2; send "n\r"; exp_continue } | |
| 359 | -nocase -re {trust the files|do you trust|project you created or one you trust|yes, i trust this folder|enter.*confirm} { press_enter; exp_continue } | |
| 360 | -nocase -re {choose the text style|select theme} { press_enter; exp_continue } | |
| 361 | -re {Welcome|\? for shortcuts|Try "} {} | |
| 362 | timeout {} | |
| 363 | } | |
| 364 | sleep 4 | |
| 365 | ||
| 366 | type_words "Which gco command submits a job via SQS, and why is that recommended? Check the GCO MCP docs (no shell commands). Answer in exactly two short lines." | |
| 367 | sleep 1 | |
| 368 | press_enter | |
| 369 | ||
| 370 | # Wait for the grounded answer itself (it inevitably names submit-sqs), | |
| 371 | # then let the complete two-line answer finish rendering. Recorded idle is | |
| 372 | # capped, so the generous hold improves reliability without bloating the GIF. | |
| 373 | expect { | |
| 374 | -nocase -re {do you want to (proceed|allow)|allow this tool} { sleep 2; send "2"; press_enter; exp_continue } | |
| 375 | -timeout 240 -re {submit-sqs} {} | |
| 376 | timeout {} | |
| 377 | } | |
| 378 | sleep 60 | |
| 379 | ||
| 380 | send -- "/exit" | |
| 381 | press_enter | |
| 382 | expect eof | |
| 383 | EXPECT_DRIVER | |
| 384 | ||
| 385 | 4 | cat > "$DRIVER" <<DRIVER_SCRIPT |
| 386 | #!/usr/bin/env bash | |
| 387 | set -euo pipefail | |
| 388 | cd "\$REPO_ROOT" | |
| 389 | export PATH="\${SHIM_DIR}:\${PATH}" | |
| 390 | # tput cols runs inside command substitutions in lib_demo.sh, where stdout | |
| 391 | # is a pipe rather than the recording PTY, so it falls back to 80 unless | |
| 392 | # COLUMNS is exported. Without this the banner renders 80 wide on a | |
| 393 | # ${COLS}-column recording and sits awkwardly off-center. | |
| 394 | export COLUMNS="\${COLS}" LINES="\${ROWS}" | |
| 395 | ||
| 396 | # shellcheck source=demo/lib_demo.sh | |
| 397 | source "\${REPO_ROOT}/demo/lib_demo.sh" | |
| 398 | setup_colors | |
| 399 | ||
| 400 | banner "GCO Autopilot" | |
| 401 | narrate "One command turns your terminal into a working Claude Code setup:" | |
| 402 | narrate "Claude Code on Amazon Bedrock + the GCO MCP server + companion MCPs." | |
| 403 | sleep 3 | |
| 404 | ||
| 405 | echo "" | |
| 406 | echo " \${MAGENTA}\\\$ \${WHITE}\${BOLD}gco autopilot\${RESET}" | |
| 407 | sleep 1 | |
| 408 | ||
| 409 | expect -f "$EXPECT_SCRIPT" | |
| 410 | ||
| 411 | # The TUI leaves residual chrome behind on exit; give the outro its own | |
| 412 | # clean screen instead of printing into the leftovers. | |
| 413 | printf '\033[2J\033[H' | |
| 414 | banner "GCO Autopilot" | |
| 415 | spacer | |
| 416 | highlight "A real session: the model grounded its answer in GCO's MCP server." | |
| 417 | narrate "Sessions resume next launch; import your own skills with --skills." | |
| 418 | narrate "Get started: gco autopilot" | |
| 419 | sleep 4 | |
| 420 | DRIVER_SCRIPT | |
| 421 | 2 | elif [ "$DEMO_ENGINE" = "codex" ]; then |
| 422 | 4 | cat > "$DRIVER" <<'DRIVER_SCRIPT' |
| 423 | #!/usr/bin/env bash | |
| 424 | set -euo pipefail | |
| 425 | cd "$REPO_ROOT" | |
| 426 | export PATH="${SHIM_DIR}:${PATH}" | |
| 427 | export COLUMNS="${COLS}" LINES="${ROWS}" | |
| 428 | ||
| 429 | # shellcheck source=demo/lib_demo.sh | |
| 430 | source "${REPO_ROOT}/demo/lib_demo.sh" | |
| 431 | setup_colors | |
| 432 | ||
| 433 | banner "GCO Autopilot — Codex" | |
| 434 | narrate "Choose Codex without giving up GCO's one-command setup:" | |
| 435 | narrate "OpenAI Codex + the GCO MCP server + companion MCPs on Amazon Bedrock." | |
| 436 | sleep 3 | |
| 437 | ||
| 438 | run_cmd "gco autopilot --engine codex --dry-run" | |
| 439 | sleep 5 | |
| 440 | ||
| 441 | spacer | |
| 442 | highlight "Launch it for real with: gco autopilot --engine codex" | |
| 443 | narrate "The exact Codex pin and isolated CODEX_HOME persist in gco-dev." | |
| 444 | sleep 4 | |
| 445 | DRIVER_SCRIPT | |
| 446 | 4 | else |
| 447 | 4 | cat > "$DRIVER" <<'DRIVER_SCRIPT' |
| 448 | #!/usr/bin/env bash | |
| 449 | set -euo pipefail | |
| 450 | cd "$REPO_ROOT" | |
| 451 | export PATH="${SHIM_DIR}:${PATH}" | |
| 452 | # See the live driver: COLUMNS keeps tput-in-substitution honest so the | |
| 453 | # banner spans the full recording width. | |
| 454 | export COLUMNS="${COLS}" LINES="${ROWS}" | |
| 455 | ||
| 456 | # shellcheck source=demo/lib_demo.sh | |
| 457 | source "${REPO_ROOT}/demo/lib_demo.sh" | |
| 458 | setup_colors | |
| 459 | ||
| 460 | banner "GCO Autopilot" | |
| 461 | narrate "One command from a plain terminal to a working Claude Code setup:" | |
| 462 | narrate "Claude Code + the GCO MCP server + the recommended companion MCPs," | |
| 463 | narrate "on Amazon Bedrock with GCO's default Claude Code model." | |
| 464 | sleep 3 | |
| 465 | ||
| 466 | run_cmd "gco autopilot --dry-run" | |
| 467 | sleep 4 | |
| 468 | ||
| 469 | spacer | |
| 470 | highlight "That's the whole setup. Launch it for real with: gco autopilot" | |
| 471 | narrate "Missing Claude Code? Autopilot offers the exact pinned install first." | |
| 472 | sleep 3 | |
| 473 | DRIVER_SCRIPT | |
| 474 | fi | |
| 475 | 8 | chmod +x "$DRIVER" |
| 476 | ||
| 477 | # ── Record ─────────────────────────────────────────────────────────────────── | |
| 478 | ||
| 479 | 8 | echo "Recording autopilot demo (${COLS}x${ROWS})..." |
| 480 | 8 | echo "Output: ${CAST_FILE}" |
| 481 | 8 | echo "" |
| 482 | ||
| 483 | 8 | rm -f "$CAST_FILE" |
| 484 | ||
| 485 | # --idle-time-limit caps recorded pauses (model thinking time in live mode) | |
| 486 | # so the GIF stays short without editing the cast by hand. | |
| 487 | 8 | export REPO_ROOT SHIM_DIR COLS ROWS |
| 488 | 8 | asciinema rec \ |
| 489 | --return \ | |
| 490 | --cols "$COLS" \ | |
| 491 | --rows "$ROWS" \ | |
| 492 | --idle-time-limit 1.5 \ | |
| 493 | --overwrite \ | |
| 494 | --command "bash --norc --noprofile $DRIVER" \ | |
| 495 | "$CAST_FILE" | |
| 496 | ||
| 497 | 7 | echo "" |
| 498 | 7 | echo "✓ Recording saved: ${CAST_FILE}" |
| 499 | ||
| 500 | # In live mode, prove the answer actually landed before rendering: the | |
| 501 | # TUI drive is timing-based, so a slow model or a changed dialog could | |
| 502 | # produce a cast that cuts off early. Fail loudly instead of publishing it. | |
| 503 | 7 | if [ "$DEMO_MODE" = "live" ]; then |
| 504 | # TUI redraws interleave ANSI escapes and can split words across output | |
| 505 | # events, so checks join the rendered stream and normalize to alphanumerics. | |
| 506 | # Codex has a stricter contract: successful calls to both approved GCO docs | |
| 507 | # tools, no shell/companions/prompts, and no live credential values. | |
| 508 | 5 | if python3 - "$CAST_FILE" "$DEMO_ENGINE" <<'PYEOF' |
| 509 | import json | |
| 510 | import os | |
| 511 | import re | |
| 512 | import sys | |
| 513 | from pathlib import Path | |
| 514 | ||
| 515 | documents = [] | |
| 516 | stream = [] | |
| 517 | for line in Path(sys.argv[1]).read_text(encoding="utf-8").splitlines(): | |
| 518 | if not line.strip(): | |
| 519 | continue | |
| 520 | doc = json.loads(line) | |
| 521 | documents.append(doc) | |
| 522 | if isinstance(doc, list) and len(doc) >= 3 and doc[1] == "o": | |
| 523 | stream.append(doc[2]) | |
| 524 | exit_events = [ | |
| 525 | doc | |
| 526 | for doc in documents | |
| 527 | if isinstance(doc, list) and len(doc) >= 3 and doc[1] == "x" | |
| 528 | ] | |
| 529 | header_version = documents[0].get("version") if isinstance(documents[0], dict) else None | |
| 530 | valid_exit = header_version != 3 or ( | |
| 531 | bool(exit_events) and str(exit_events[-1][2]) == "0" | |
| 532 | ) | |
| 533 | joined = "".join(stream) | |
| 534 | plain = re.sub( | |
| 535 | r"\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(\x07|\x1b\\\\)|\x1b[P^_].*?\x1b\\\\|\x1b.", | |
| 536 | "", | |
| 537 | joined, | |
| 538 | ) | |
| 539 | normalized = re.sub(r"[^a-z0-9]", "", plain.lower()) | |
| 540 | required = ["submitsqs"] | |
| 541 | forbidden = [] | |
| 542 | if sys.argv[2] == "codex": | |
| 543 | required.extend(("calledgcofinddocs", "calledgcoreadresource")) | |
| 544 | forbidden.extend( | |
| 545 | ( | |
| 546 | "callingshell", | |
| 547 | "calledshell", | |
| 548 | "trustthecontents", | |
| 549 | "trustthefiles", | |
| 550 | "doyoutrust", | |
| 551 | "doyouwanttoproceed", | |
| 552 | "doyouwanttoallow", | |
| 553 | "allowthistool", | |
| 554 | "awsdocs", | |
| 555 | "awspricing", | |
| 556 | "ddgsearch", | |
| 557 | "deepwiki", | |
| 558 | "filesystem", | |
| 559 | "innermonologue", | |
| 560 | "mcptasks", | |
| 561 | "memorymcp", | |
| 562 | "playwright", | |
| 563 | "sequentialthinking", | |
| 564 | ) | |
| 565 | ) | |
| 566 | credential_names = ( | |
| 567 | "AWS_ACCESS_KEY_ID", | |
| 568 | "AWS_SECRET_ACCESS_KEY", | |
| 569 | "AWS_SESSION_TOKEN", | |
| 570 | "AWS_WEB_IDENTITY_TOKEN_FILE", | |
| 571 | ) | |
| 572 | credential_leak = any( | |
| 573 | value and len(value) >= 8 and value in joined | |
| 574 | for name in credential_names | |
| 575 | if (value := os.environ.get(name)) | |
| 576 | ) | |
| 577 | raise SystemExit( | |
| 578 | 0 | |
| 579 | if valid_exit | |
| 580 | and all(marker in normalized for marker in required) | |
| 581 | and not any(marker in normalized for marker in forbidden) | |
| 582 | and not credential_leak | |
| 583 | else 1 | |
| 584 | ) | |
| 585 | PYEOF | |
| 586 | 5 | then |
| 587 | 2 | if [ "$DEMO_ENGINE" = "codex" ]; then |
| 588 | 1 | echo "✓ Live Codex recording verified (GCO docs tools only; no credentials/prompts)" |
| 589 | 5 | else |
| 590 | 1 | echo "✓ Live answer verified in the recording (mentions submit-sqs)" |
| 591 | 5 | fi |
| 592 | 5 | else |
| 593 | 3 | echo "✗ The recording failed its required answer/tool/security contract." >&2 |
| 594 | 3 | echo " The session may have stalled, used another tool, prompted, or exposed credentials." >&2 |
| 595 | 3 | exit 1 |
| 596 | 5 | fi |
| 597 | 5 | fi |
| 598 | 5 | |
| 599 | 5 | # ── Sanitize and verify ───────────────────────────────────────────────────── |
| 600 | 5 | |
| 601 | 4 | sanitize_cast "$CAST_FILE" |
| 602 | 4 | verify_cast_sanitized "$CAST_FILE" |
| 603 | 4 | echo "✓ Cast sanitized and verified (AWS account IDs → 000000000000)" |
| 604 | 5 | |
| 605 | 4 | strip_emoji_from_cast "$CAST_FILE" |
| 606 | 4 | echo "✓ Tofu-triggering codepoints stripped" |
| 607 | 5 | |
| 608 | 5 | # ── Strip terminal query/response artifacts and TUI tofu glyphs ───────────── |
| 609 | 5 | # Two Claude-Code-specific cleanups on top of lib_demo.sh's shared passes: |
| 610 | 5 | # |
| 611 | 5 | # 1. The TUI probes the terminal (focus tracking, OSC 11 background color, |
| 612 | 5 | # device attributes, XTVERSION), and pieces of those query/response |
| 613 | 5 | # exchanges land in the recorded output stream. agg's renderer doesn't |
| 614 | 5 | # understand them and paints fragments like ``^[[O`` or ``^[]11;rgb:...`` |
| 615 | 5 | # literally. They carry no visual content, so they are removed outright. |
| 616 | 5 | # |
| 617 | 5 | # 2. The TUI emits three codepoints Menlo has no glyph for, and agg's |
| 618 | 5 | # first-family-wins renderer paints them as tofu boxes (same root cause |
| 619 | 5 | # strip_emoji_from_cast documents). Verified against Menlo.ttc's cmap: |
| 620 | 5 | # ⏺ U+23FA BLACK CIRCLE FOR RECORD → ● U+25CF (in Menlo, same intent) |
| 621 | 5 | # ⏸ U+23F8 DOUBLE VERTICAL BAR → ║ U+2551 (in Menlo, same width) |
| 622 | 5 | # ⎿ U+23BF DENTISTRY SYMBOL ... → └ U+2514 (in Menlo, same elbow) |
| 623 | 5 | # Everything else the TUI uses (box drawing, quadrant blocks, the |
| 624 | 5 | # spinner asterisks ✻✶✳✢✽, ❯, arrows) is covered by Menlo. |
| 625 | 5 | python3 - "$CAST_FILE" <<'PYEOF' |
| 626 | import json | |
| 627 | import re | |
| 628 | import sys | |
| 629 | from pathlib import Path | |
| 630 | ||
| 631 | ARTIFACTS = re.compile( | |
| 632 | r"\x1b\[[IO]" # focus in/out events | |
| 633 | r"|\x1b\]1[01];[^\x07\x1b]*(?:\x07|\x1b\\)" # OSC 10/11 color query/response | |
| 634 | r"|\x1b\[\?\d+(?:;\d+)*c" # device-attribute responses | |
| 635 | r"|\x1b\[>\d+(?:;\d+)*c" # secondary DA responses | |
| 636 | r"|\x1bP>\|[^\x1b]*\x1b\\" # XTVERSION response | |
| 637 | ) | |
| 638 | ||
| 639 | # Single-codepoint substitutions (str.translate); multi-char targets are | |
| 640 | # fine as translate values. | |
| 641 | TUI_TOFU = { | |
| 642 | 0x23FA: "\u25cf", # ⏺ → ● | |
| 643 | 0x23F8: "\u2551", # ⏸ → ║ | |
| 644 | 0x23BF: "\u2514", # ⎿ → └ | |
| 645 | } | |
| 646 | ||
| 647 | path = Path(sys.argv[1]) | |
| 648 | documents = [] | |
| 649 | for line in path.read_text(encoding="utf-8").splitlines(): | |
| 650 | if not line.strip(): | |
| 651 | continue | |
| 652 | documents.append(json.loads(line)) | |
| 653 | ||
| 654 | for document in documents: | |
| 655 | if isinstance(document, list) and len(document) >= 3 and document[1] == "o": | |
| 656 | document[2] = ARTIFACTS.sub("", document[2]).translate(TUI_TOFU) | |
| 657 | ||
| 658 | # Static GIF previews show frame zero without advancing the animation. Make the | |
| 659 | # first rendered frame the recorder banner rather than the empty PTY that | |
| 660 | # precedes it. Asciicast v2 uses absolute timestamps; v3 uses per-event delays. | |
| 661 | events = [ | |
| 662 | document | |
| 663 | for document in documents | |
| 664 | if isinstance(document, list) and len(document) >= 3 | |
| 665 | ] | |
| 666 | banner_event = next( | |
| 667 | ( | |
| 668 | event | |
| 669 | for event in events | |
| 670 | if event[1] == "o" | |
| 671 | and isinstance(event[2], str) | |
| 672 | and "GCO Autopilot" in event[2] | |
| 673 | ), | |
| 674 | None, | |
| 675 | ) | |
| 676 | header_version = documents[0].get("version") if isinstance(documents[0], dict) else None | |
| 677 | if banner_event is not None and header_version == 2: | |
| 678 | banner_time = float(banner_event[0]) | |
| 679 | for event in events: | |
| 680 | event[0] = round(max(0.0, float(event[0]) - banner_time), 6) | |
| 681 | elif banner_event is not None and header_version == 3: | |
| 682 | for event in events: | |
| 683 | event[0] = 0 | |
| 684 | if event is banner_event: | |
| 685 | break | |
| 686 | ||
| 687 | path.write_text( | |
| 688 | "\n".join(json.dumps(d, ensure_ascii=False, separators=(",", ":")) for d in documents) | |
| 689 | + "\n", | |
| 690 | encoding="utf-8", | |
| 691 | ) | |
| 692 | PYEOF | |
| 693 | 4 | echo "✓ Terminal query/response artifacts stripped, TUI tofu glyphs substituted" |
| 694 | ||
| 695 | # ── Convert to GIF ────────────────────────────────────────────────────────── | |
| 696 | ||
| 697 | 4 | if [ "${SKIP_GIF:-}" != "1" ]; then |
| 698 | 3 | echo "" |
| 699 | 3 | echo "Converting to GIF (speed=${SPEED}x, theme=${THEME})..." |
| 700 | 3 | render_gif "$CAST_FILE" "$GIF_FILE" "$SPEED" "$THEME" "$COLS" "$ROWS" |
| 701 | 3 | echo "✓ GIF saved: ${GIF_FILE}" |
| 702 | 12 | GIF_SIZE=$(du -h "$GIF_FILE" | cut -f1); echo " Size: $GIF_SIZE" |
| 703 | fi | |
| 704 | ||
| 705 | # ── Summary ────────────────────────────────────────────────────────────────── | |
| 706 | ||
| 707 | 4 | echo "" |
| 708 | 4 | echo "=== Done ===" |
| 709 | 4 | echo "" |
| 710 | 4 | echo "Files:" |
| 711 | 4 | echo " ${CAST_FILE}" |
| 712 | 7 | [ "${SKIP_GIF:-}" != "1" ] && echo " ${GIF_FILE}" |
| 713 | 4 | echo "" |
| 714 | 4 | echo "Any new GIF must satisfy the reviewed policy in" |
| 715 | 4 | echo ".github/scripts/validate_demo_gifs.py (size/dimensions/frames)." |
| 716 | 4 | echo "" |
| 717 | 4 | echo "Embed in README:" |
| 718 | 8 | GIF_BASENAME="$(basename "$GIF_FILE")" |
| 719 | 4 | echo " " |