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