From 5142b48ffb5adbf29c8b2216d6e29788ada575ae Mon Sep 17 00:00:00 2001 From: phil-accelbyte <225106921+phil-accelbyte@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:49:34 +0800 Subject: [PATCH 1/2] feat: add workflow engine and interactive terminal UI Convert to the 3-crate workspace (ags-protocol / ags-runtime / accelbyte-ags-cli); add the runtime workflow engine and built-in workflows (player-overview, in-game-store, season-pass, competitive-multiplayer); add the plain/inline/fullscreen interactive terminal surfaces, dynamic-enum pickers, JSON editor, and date-time widget; restructure frontend into output/ + terminal/ and invocation into routes/ + handlers/; add ratatui/crossterm; allow workspace path-deps and ignore RUSTSEC-2024-0436 in deny.toml. --- .claude/settings.json | 3 +- .claude/skills/spec-update/SKILL.md | 126 + .claude/skills/vhs-demo/SKILL.md | 239 + .gitignore | 5 + CLAUDE.md | 301 +- CONTRIBUTING.md | 347 +- Cargo.lock | 500 +- Cargo.toml | 36 +- README.md | 36 +- crates/accelbyte-ags-cli/Cargo.toml | 59 + .../accelbyte-ags-cli/src}/errors.rs | 14 +- .../src/frontend/dynamic_options.rs | 458 ++ .../accelbyte-ags-cli/src/frontend/event.rs | 93 + crates/accelbyte-ags-cli/src/frontend/mod.rs | 864 +++ .../frontend/output}/human/commands/auth.rs | 20 +- .../output}/human/commands/completions.rs | 2 +- .../frontend/output/human/commands/config.rs | 109 + .../frontend/output}/human/commands/doctor.rs | 7 +- .../frontend/output}/human/commands/mod.rs | 2 + .../output}/human/commands/profile.rs | 29 +- .../output}/human/commands/refresh_specs.rs | 6 +- .../output}/human/commands/service.rs | 69 +- .../output}/human/commands/version.rs | 2 +- .../output/human/commands/workflow.rs | 523 ++ .../output/human/commands/workflow_dry_run.rs | 95 + .../src/frontend/output}/human/mod.rs | 9 +- .../src/frontend/output}/human/templates.rs | 6 +- .../frontend/output}/json/commands/auth.rs | 77 +- .../output}/json/commands/completions.rs | 4 +- .../frontend/output}/json/commands/config.rs | 29 +- .../frontend/output}/json/commands/doctor.rs | 4 +- .../src/frontend/output}/json/commands/mod.rs | 1 + .../frontend/output}/json/commands/profile.rs | 4 +- .../output}/json/commands/refresh_specs.rs | 4 +- .../frontend/output}/json/commands/service.rs | 8 +- .../frontend/output}/json/commands/version.rs | 4 +- .../frontend/output/json/commands/workflow.rs | 230 + .../src/frontend/output}/json/frontend.rs | 51 +- .../src/frontend/output}/json/mod.rs | 7 +- .../src/frontend/output/mod.rs | 6 + .../src/frontend/output/render.rs | 307 + .../src/frontend/output}/templates.rs | 28 +- .../src}/frontend/presenters/auth.rs | 2 +- .../src}/frontend/presenters/mod.rs | 0 .../src}/frontend/presenters/service.rs | 4 +- crates/accelbyte-ags-cli/src/frontend/sink.rs | 592 ++ .../accelbyte-ags-cli/src/frontend/streams.rs | 39 + .../src}/frontend/style/ansi.rs | 76 +- .../src/frontend/style/mod.rs | 13 + .../src}/frontend/style/span.rs | 0 .../src}/frontend/style/text.rs | 3 +- .../src}/frontend/style/tone.rs | 0 .../src/frontend/terminal/backend.rs | 39 + .../src/frontend/terminal/date_field.rs | 437 ++ .../src/frontend/terminal/dynamic_enums.rs | 114 + .../src/frontend/terminal/form_runner.rs | 2132 ++++++ .../frontend/terminal/fullscreen/frontend.rs | 914 +++ .../frontend/terminal/fullscreen/header.rs | 188 + .../terminal/fullscreen/interaction.rs | 2421 +++++++ .../frontend/terminal/fullscreen/layout.rs | 147 + .../frontend/terminal/fullscreen/lifecycle.rs | 54 + .../src/frontend/terminal/fullscreen/mod.rs | 26 + .../src/frontend/terminal/fullscreen/nav.rs | 395 ++ .../terminal/fullscreen/phases/briefing.rs | 429 ++ .../terminal/fullscreen/phases/confirm.rs | 82 + .../terminal/fullscreen/phases/enum_picker.rs | 488 ++ .../terminal/fullscreen/phases/error.rs | 63 + .../terminal/fullscreen/phases/fields.rs | 548 ++ .../terminal/fullscreen/phases/json_edit.rs | 269 + .../terminal/fullscreen/phases/mod.rs | 62 + .../terminal/fullscreen/phases/result.rs | 215 + .../terminal/fullscreen/phases/running.rs | 114 + .../terminal/fullscreen/step_strip.rs | 586 ++ .../frontend/terminal/fullscreen/summary.rs | 196 + .../frontend/terminal/fullscreen/surface.rs | 600 ++ .../src/frontend/terminal/inline/chrome.rs | 76 + .../src/frontend/terminal/inline/form.rs | 4110 ++++++++++++ .../frontend/terminal/inline/form_builder.rs | 906 +++ .../src/frontend/terminal/inline/frontend.rs | 265 + .../frontend/terminal/inline/interaction.rs | 787 +++ .../terminal/inline/json_editor/mod.rs | 499 ++ .../terminal/inline/json_editor/navigation.rs | 146 + .../terminal/inline/json_editor/node.rs | 455 ++ .../terminal/inline/json_editor/raw.rs | 454 ++ .../terminal/inline/json_editor/render.rs | 424 ++ .../src/frontend/terminal/inline/lifecycle.rs | 90 + .../src/frontend/terminal/inline/mod.rs | 14 + .../terminal/inline/phases/confirm_card.rs | 620 ++ .../frontend/terminal/inline/phases/form.rs | 986 +++ .../frontend/terminal/inline/phases/mod.rs | 120 + .../frontend/terminal/inline/phases/result.rs | 185 + .../terminal/inline/progress_state.rs | 253 + .../src/frontend/terminal/inline/session.rs | 103 + .../src/frontend/terminal/machine_json.rs | 182 + .../src/frontend/terminal/mod.rs | 14 + .../src/frontend/terminal/no_color_backend.rs | 141 + .../src/frontend/terminal/picker_list.rs | 280 + .../src/frontend/terminal/plain/frontend.rs | 143 + .../frontend/terminal/plain/interaction.rs | 438 ++ .../src/frontend/terminal/plain/mod.rs | 6 + .../src/frontend/terminal/plain}/progress.rs | 78 +- .../src/frontend/terminal/plain/prompt.rs | 248 + .../src/frontend/terminal/views/fields.rs | 606 ++ .../frontend/terminal/views/inline_format.rs | 181 + .../src/frontend/terminal/views/mod.rs | 6 + .../src/frontend/terminal/views/nav.rs | 294 + .../src/frontend/terminal/views/result.rs | 44 + .../frontend/terminal/views/step_failure.rs | 18 + .../src}/invocation/builder.rs | 159 +- .../src}/invocation/clap_helpers.rs | 0 .../src/invocation/completions_generator.rs | 6 +- .../src/invocation/context.rs | 1021 +++ .../src}/invocation/errors.rs | 6 +- .../src}/invocation/flags.rs | 195 +- .../src/invocation/handlers}/completions.rs | 15 +- .../src/invocation/handlers}/config.rs | 12 +- .../invocation/handlers}/describe/envelope.rs | 204 +- .../src/invocation/handlers}/describe/mod.rs | 184 +- .../src/invocation/handlers}/doctor.rs | 13 +- .../src/invocation/handlers}/mod.rs | 2 - .../src/invocation/handlers}/profile.rs | 10 +- .../src/invocation/handlers}/refresh_specs.rs | 17 +- .../src/invocation/handlers}/version.rs | 6 +- .../accelbyte-ags-cli/src/invocation/mod.rs | 319 + .../src/invocation/phase_execution.rs | 676 ++ .../src/invocation/policy.rs | 103 + .../src}/invocation/resolve.rs | 5 +- .../src/invocation/router.rs | 246 + .../src/invocation/routes/auth/mod.rs | 977 +++ .../src/invocation/routes}/auth/oauth.rs | 19 +- .../src/invocation/routes/builtin/mod.rs | 417 ++ .../src/invocation/routes/mod.rs | 45 + .../invocation/routes}/service/clap_tree.rs | 99 +- .../src/invocation/routes}/service/help.rs | 4 +- .../src/invocation/routes/service/mod.rs | 490 ++ .../src/invocation/routes}/service/parser.rs | 141 +- .../src/invocation/routes}/service/request.rs | 275 +- .../src/invocation/routes/workflow/mod.rs | 702 ++ .../accelbyte-ags-cli/src/invocation/shape.rs | 282 + .../src/invocation/workflows.rs | 161 + crates/accelbyte-ags-cli/src/lib.rs | 8 + {src => crates/accelbyte-ags-cli/src}/main.rs | 10 +- .../accelbyte-ags-cli/tests/architecture.rs | 112 + .../tests}/common/cli_helpers.rs | 0 .../tests}/common/env_guard.rs | 0 .../tests}/common/error_helpers.rs | 2 +- .../tests}/common/fixture_helpers.rs | 0 .../accelbyte-ags-cli/tests}/common/mod.rs | 0 .../tests}/common/wiremock_helpers.rs | 0 .../tests}/contract_input.rs | 3 + .../tests/contract_input/services.rs | 631 ++ .../tests/contract_input/workflows.rs | 298 + .../tests}/contract_output.rs | 0 .../tests}/contract_output/auth/logout.rs | 0 .../tests}/contract_output/auth/mod.rs | 0 .../tests}/contract_output/auth/status.rs | 0 .../tests}/contract_output/channels.rs | 0 .../tests}/contract_output/config.rs | 0 .../tests}/contract_output/describe.rs | 100 + .../tests}/contract_output/doctor.rs | 0 .../tests}/contract_output/emit_order.rs | 36 +- .../tests}/contract_output/error_structure.rs | 10 +- .../tests}/contract_output/errors.rs | 0 .../tests}/contract_output/human_readable.rs | 13 +- .../tests}/contract_output/json_mode.rs | 11 +- .../tests}/contract_output/profile.rs | 22 +- .../baselines/achievement_input_contract.json | 28 + .../baselines/ams_input_contract.json | 49 +- .../baselines/basic_input_contract.json | 58 + .../baselines/challenge_input_contract.json | 38 + .../baselines/chat_input_contract.json | 63 + .../baselines/cloudsave_input_contract.json | 97 + .../baselines/csm_input_contract.json | 80 + .../gametelemetry_input_contract.json | 4 + .../baselines/gdpr_input_contract.json | 42 + .../baselines/group_input_contract.json | 73 + .../baselines/iam_input_contract.json | 306 + .../baselines/inventory_input_contract.json | 43 + .../baselines/leaderboard_input_contract.json | 62 + .../baselines/legal_input_contract.json | 69 + .../baselines/lobby_input_contract.json | 71 + .../baselines/loginqueue_input_contract.json | 5 + .../baselines/match2_input_contract.json | 42 + .../baselines/platform_input_contract.json | 481 +- .../baselines/reporting_input_contract.json | 35 + .../baselines/seasonpass_input_contract.json | 45 + .../baselines/session_input_contract.json | 88 + .../sessionhistory_input_contract.json | 2 + .../baselines/social_input_contract.json | 74 + .../baselines/ugc_input_contract.json | 147 + ...ompetitive-multiplayer_input_contract.json | 60 + .../in-game-store_input_contract.json | 18 + .../player-overview_input_contract.json | 36 + .../workflows/season-pass_input_contract.json | 60 + .../accelbyte-ags-cli/tests}/functional.rs | 14 + .../tests}/functional/auth/login.rs | 92 +- .../tests}/functional/auth/logout.rs | 0 .../tests}/functional/auth/mod.rs | 1 + .../tests/functional/auth/refresh.rs | 160 + .../tests}/functional/auth/status.rs | 0 .../functional/competitive_multiplayer.rs | 202 + .../functional/completions_enum_permissive.rs | 0 .../tests/functional/completions_full_tree.rs | 76 + .../tests}/functional/config/commands.rs | 78 + .../tests}/functional/config/mod.rs | 0 .../tests}/functional/describe/commands.rs | 31 +- .../tests}/functional/describe/mod.rs | 0 .../tests}/functional/doctor.rs | 11 +- .../tests}/functional/iam/cache.rs | 0 .../tests}/functional/iam/confirmation.rs | 0 .../tests}/functional/iam/dry_run.rs | 0 .../tests}/functional/iam/help.rs | 0 .../tests}/functional/iam/json_input.rs | 0 .../tests}/functional/iam/mod.rs | 0 .../tests}/functional/iam/modes.rs | 0 .../tests}/functional/iam/users.rs | 0 .../tests}/functional/iam/validation.rs | 0 .../tests/functional/in_game_store.rs | 114 + .../tests/functional/multipart_guard.rs | 42 + .../tests}/functional/pagination/commands.rs | 0 .../tests}/functional/pagination/mod.rs | 0 .../player_overview_direct_user_id.rs | 59 + .../tests}/functional/profile/commands.rs | 4 +- .../tests}/functional/profile/logout_all.rs | 0 .../tests}/functional/profile/mod.rs | 0 .../tests}/functional/root.rs | 0 .../tests/functional/season_pass.rs | 180 + .../tests}/functional/skeleton/commands.rs | 0 .../tests}/functional/skeleton/mod.rs | 0 .../tests/functional/workflow_list.rs | 50 + .../tests/functional/workflow_parity.rs | 147 + .../accelbyte-ags-cli/tests}/integration.rs | 12 + .../tests}/integration/auth.rs | 41 +- .../tests}/integration/binary_response.rs | 0 .../tests}/integration/builder.rs | 4 +- .../tests}/integration/completions.rs | 0 .../tests}/integration/config.rs | 2 +- .../tests}/integration/error_pipeline.rs | 118 +- .../tests/integration/format_precedence.rs | 45 + .../tests}/integration/namespace.rs | 20 +- .../tests}/integration/output_flag.rs | 0 .../tests}/integration/parser.rs | 2 +- .../tests}/integration/profile.rs | 13 +- .../tests}/integration/renderer.rs | 15 +- .../tests/integration/service_naming.rs | 78 + .../tests/integration/stream_ownership.rs | 74 + .../tests}/integration/token_refresh_race.rs | 10 +- .../tests/integration/tty_topology.rs | 130 + .../tests/integration/tui_e2e.rs | 756 +++ .../tests/integration/tui_format.rs | 48 + .../tests/integration/ui_flag.rs | 20 + .../accelbyte-ags-cli/tests}/performance.rs | 0 .../tests}/performance/release.rs | 50 +- .../tests}/performance/spec_loading.rs | 0 .../tests}/performance/startup.rs | 0 .../accelbyte-ags-cli/tests}/scope_version.rs | 0 .../accelbyte-ags-cli/tests}/security.rs | 0 .../tests}/security/config_permissions.rs | 2 +- .../tests}/security/credentials.rs | 0 .../tests}/security/file_access.rs | 0 .../tests}/security/injection.rs | 0 .../accelbyte-ags-cli/tests}/snapshot.rs | 0 .../tests}/snapshot/cli_output.rs | 1 + .../tests}/snapshot/describe.rs | 18 + .../tests}/snapshot/errors.rs | 8 +- .../tests}/snapshot/fields.rs | 8 +- .../tests}/snapshot/help_text.rs | 73 +- ...en__nav__tests__snapshot_nav_briefing.snap | 9 + ...een__nav__tests__snapshot_nav_confirm.snap | 9 + ...tests__snapshot_nav_confirm_skippable.snap | 9 + ...creen__nav__tests__snapshot_nav_error.snap | 9 + ...reen__nav__tests__snapshot_nav_fields.snap | 9 + ..._tests__snapshot_nav_fields_skippable.snap | 9 + ...av__tests__snapshot_nav_json_edit_raw.snap | 9 + ..._tests__snapshot_nav_json_edit_scalar.snap | 9 + ...v__tests__snapshot_nav_json_edit_tree.snap | 9 + ...een__nav__tests__snapshot_nav_loading.snap | 9 + ...reen__nav__tests__snapshot_nav_result.snap | 9 + ...een__nav__tests__snapshot_nav_running.snap | 9 + ..._tests__snapshot_confirm_panel_layout.snap | 28 + ..._snapshot_fields_panel_gather_variant.snap | 28 + ..._snapshot_fields_panel_review_variant.snap | 28 + .../snapshot__describe__error_envelope.snap | 0 .../snapshot__describe__method_matrix.snap | 0 ...pshot__describe__root_catalogue_child.snap | 0 ..._describe__service_catalogue_envelope.snap | 0 ...t__describe__workflow_detail_envelope.snap | 154 + ...napshot__help_text__auth_refresh_help.snap | 22 + .../snapshot__help_text__config_help.snap | 0 .../snapshot__help_text__doctor_help.snap | 0 ...pshot__help_text__profile_create_help.snap | 0 .../snapshot__help_text__profile_help.snap | 0 .../tests/workflow_integration.rs | 871 +++ crates/ags-protocol/Cargo.toml | 13 + .../ags-protocol/src}/catalogue.rs | 87 +- .../ags-protocol/src}/config.rs | 8 +- .../ags-protocol/src}/diagnostics.rs | 2 - .../ags-protocol/src}/error.rs | 42 +- .../ags-protocol/src}/event.rs | 1 - .../mod.rs => crates/ags-protocol/src/lib.rs | 3 +- .../ags-protocol/src}/output.rs | 48 +- .../ags-protocol/src}/output_views.rs | 123 +- .../ags-protocol/src}/request.rs | 45 +- .../ags-protocol/src}/result.rs | 17 +- crates/ags-protocol/src/workflow.rs | 2085 ++++++ crates/ags-runtime/Cargo.toml | 34 + .../ags-runtime/specs}/achievement.json.gz | Bin crates/ags-runtime/specs/ams.json.gz | Bin 0 -> 13220 bytes .../ags-runtime/specs}/basic.json.gz | Bin .../ags-runtime/specs}/challenge.json.gz | Bin .../ags-runtime/specs}/chat.json.gz | Bin .../ags-runtime/specs}/cloudsave.json.gz | Bin .../ags-runtime/specs}/csm.json.gz | Bin .../ags-runtime/specs}/gametelemetry.json.gz | Bin .../ags-runtime/specs}/gdpr.json.gz | Bin .../ags-runtime/specs}/group.json.gz | Bin .../ags-runtime/specs}/iam.json.gz | Bin .../ags-runtime/specs}/inventory.json.gz | Bin .../ags-runtime/specs}/leaderboard.json.gz | Bin .../ags-runtime/specs}/legal.json.gz | Bin .../ags-runtime/specs}/lobby.json.gz | Bin .../ags-runtime/specs}/loginqueue.json.gz | Bin .../ags-runtime/specs}/match2.json.gz | Bin crates/ags-runtime/specs/platform.json.gz | Bin 0 -> 162705 bytes .../ags-runtime/specs}/reporting.json.gz | Bin .../ags-runtime/specs}/seasonpass.json.gz | Bin .../ags-runtime/specs}/session.json.gz | Bin .../ags-runtime/specs}/sessionhistory.json.gz | Bin .../ags-runtime/specs}/social.json.gz | Bin .../ags-runtime/specs}/ugc.json.gz | Bin crates/ags-runtime/src/catalogue/aliases.rs | 53 + .../ags-runtime/src}/catalogue/bundled.rs | 2 +- .../ags-runtime/src}/catalogue/cache.rs | 34 +- .../ags-runtime/src}/catalogue/manifest.rs | 33 +- .../src}/catalogue/memory_cache.rs | 29 +- .../ags-runtime/src}/catalogue/mod.rs | 34 +- .../ags-runtime/src}/catalogue/openapi.rs | 25 +- .../ags-runtime/src}/catalogue/parser.rs | 551 +- .../ags-runtime/src}/catalogue/repository.rs | 129 +- .../ags-runtime/src}/catalogue/skeleton.rs | 104 +- crates/ags-runtime/src/lib.rs | 16 + .../src}/runtime/auth/credentials.rs | 6 +- .../ags-runtime/src}/runtime/auth/errors.rs | 132 +- .../ags-runtime/src}/runtime/auth/locking.rs | 2 +- .../ags-runtime/src}/runtime/auth/mod.rs | 2 +- .../src}/runtime/auth/operations.rs | 487 +- .../ags-runtime/src}/runtime/auth/session.rs | 283 +- .../ags-runtime/src}/runtime/auth/store.rs | 32 +- .../ags-runtime/src}/runtime/auth/tokens.rs | 20 +- .../ags-runtime/src}/runtime/cleanup.rs | 0 .../src}/runtime/config/environment.rs | 0 .../ags-runtime/src}/runtime/config/errors.rs | 2 +- .../ags-runtime/src}/runtime/config/keys.rs | 23 + .../ags-runtime/src}/runtime/config/mod.rs | 0 .../ags-runtime/src}/runtime/config/paths.rs | 2 +- .../ags-runtime/src}/runtime/config/store.rs | 33 +- .../src}/runtime/diagnostics/checks.rs | 2 +- .../src}/runtime/diagnostics/mod.rs | 2 +- .../src}/runtime/diagnostics/runner.rs | 46 +- .../src}/runtime/dispatch/classify.rs | 392 +- .../src}/runtime/dispatch/confirmation.rs | 2 +- .../runtime/dispatch/error_codes/basic.rs | 32 +- .../runtime/dispatch/error_codes/challenge.rs | 6 +- .../runtime/dispatch/error_codes/cloudsave.rs | 58 +- .../runtime/dispatch/error_codes/group.rs | 28 +- .../src}/runtime/dispatch/error_codes/iam.rs | 174 +- .../dispatch/error_codes/leaderboard.rs | 26 +- .../runtime/dispatch/error_codes/legal.rs | 2 +- .../src}/runtime/dispatch/error_codes/mod.rs | 20 + .../runtime/dispatch/error_codes/platform.rs | 404 +- .../dispatch/error_codes/seasonpass.rs | 60 +- .../runtime/dispatch/error_codes/social.rs | 48 +- .../runtime/dispatch/error_codes/standard.rs | 36 +- .../src}/runtime/dispatch/execute.rs | 589 +- .../ags-runtime/src}/runtime/dispatch/http.rs | 108 +- .../ags-runtime/src/runtime/dispatch/mod.rs | 33 + .../src}/runtime/dispatch/pagination.rs | 164 +- .../ags-runtime/src}/runtime/dispatch/path.rs | 2 +- .../src}/runtime/dispatch/shape.rs | 15 +- .../ags-runtime/src}/runtime/execution.rs | 4 +- .../ags-runtime/src}/runtime/facade/auth.rs | 73 +- .../ags-runtime/src}/runtime/facade/config.rs | 80 +- .../src}/runtime/facade/diagnostics.rs | 8 +- .../ags-runtime/src}/runtime/facade/mod.rs | 0 .../src}/runtime/facade/profile.rs | 26 +- .../src}/runtime/facade/service.rs | 134 +- .../ags-runtime/src}/runtime/mod.rs | 59 +- .../src/runtime/workflows/auto_derive.rs | 641 ++ .../builtins/competitive_multiplayer.rs | 805 +++ .../workflows/builtins/in_game_store.rs | 1042 +++ .../src/runtime/workflows/builtins/mod.rs | 20 + .../workflows/builtins/player_overview.rs | 522 ++ .../runtime/workflows/builtins/season_pass.rs | 1087 +++ .../src/runtime/workflows/compile.rs | 2481 +++++++ .../src/runtime/workflows/dry_run.rs | 155 + .../src/runtime/workflows/executor.rs | 2483 +++++++ .../src/runtime/workflows/jsonpath.rs | 161 + .../ags-runtime/src/runtime/workflows/mod.rs | 495 ++ .../src/runtime/workflows/nested_path.rs | 430 ++ .../src/runtime/workflows/options.rs | 763 +++ .../src/runtime/workflows/resolve.rs | 3800 +++++++++++ .../src/runtime/workflows/synthesised.rs | 520 ++ .../src/runtime/workflows/tests.rs | 5915 +++++++++++++++++ .../ags-runtime/src}/support/file_system.rs | 0 .../ags-runtime/src}/support/mod.rs | 0 .../ags-runtime/src}/support/output_sink.rs | 61 +- .../ags-runtime/src}/support/strings.rs | 55 +- .../ags-runtime/src}/support/test_helpers.rs | 0 demo/demo-server.py | 104 - demo/record.sh | 46 - demo/reel.gif | Bin 364644 -> 0 bytes demos/auto-surfaces/auto-surfaces.gif | Bin 0 -> 592888 bytes demos/auto-surfaces/auto-surfaces.routes.json | 25 + demos/auto-surfaces/auto-surfaces.tape | 122 + demos/auto-surfaces/prelogin | 2 + demos/auto-surfaces/prewarm | 1 + demos/command-surfaces/command-surfaces.gif | Bin 0 -> 609796 bytes .../command-surfaces.routes.json | 31 + demos/command-surfaces/command-surfaces.tape | 165 + demos/command-surfaces/prelogin | 2 + demos/command-surfaces/prewarm | 2 + demos/engine/mock-server.py | 149 + demos/engine/spec-reader.py | 151 + demos/onboarding/onboarding.gif | Bin 0 -> 373720 bytes demos/onboarding/onboarding.routes.json | 27 + .../onboarding/onboarding.tape | 16 +- demos/onboarding/prewarm | 1 + demos/record.sh | 85 + demos/workflow-surfaces/prelogin | 2 + demos/workflow-surfaces/prewarm | 5 + demos/workflow-surfaces/workflow-surfaces.gif | Bin 0 -> 2827446 bytes .../workflow-surfaces.routes.json | 84 + .../workflow-surfaces/workflow-surfaces.tape | 191 + deny.toml | 9 +- docs/reference/cli-command-catalogue.md | 6 +- docs/reference/cli-reference.md | 146 +- docs/reference/output-reference.md | 157 +- docs/reference/testing-reference.md | 64 +- scripts/generate_cli_command_catalogue.py | 75 +- specs/ams.json.gz | Bin 13222 -> 0 bytes specs/platform.json.gz | Bin 156875 -> 0 bytes src/frontend/human/commands/config.rs | 77 - src/frontend/human/frontend.rs | 87 - src/frontend/human/prompt.rs | 33 - src/frontend/json/progress.rs | 10 - src/frontend/mod.rs | 327 - src/frontend/render.rs | 164 - src/frontend/style/mod.rs | 13 - src/invocation/commands/auth/mod.rs | 539 -- src/invocation/commands/service/dispatch.rs | 74 - src/invocation/commands/service/mod.rs | 52 - src/invocation/mod.rs | 85 - src/invocation/router.rs | 97 - src/lib.rs | 16 - src/runtime/dispatch/mod.rs | 14 - tests/architecture.rs | 349 - tests/contract_input/services.rs | 202 - tests/functional/completions_full_tree.rs | 29 - tests/integration/service_naming.rs | 62 - 459 files changed, 71330 insertions(+), 4614 deletions(-) create mode 100644 .claude/skills/spec-update/SKILL.md create mode 100644 .claude/skills/vhs-demo/SKILL.md create mode 100644 crates/accelbyte-ags-cli/Cargo.toml rename {src => crates/accelbyte-ags-cli/src}/errors.rs (96%) create mode 100644 crates/accelbyte-ags-cli/src/frontend/dynamic_options.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/event.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/mod.rs rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/human/commands/auth.rs (92%) rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/human/commands/completions.rs (92%) create mode 100644 crates/accelbyte-ags-cli/src/frontend/output/human/commands/config.rs rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/human/commands/doctor.rs (96%) rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/human/commands/mod.rs (76%) rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/human/commands/profile.rs (83%) rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/human/commands/refresh_specs.rs (97%) rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/human/commands/service.rs (87%) rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/human/commands/version.rs (90%) create mode 100644 crates/accelbyte-ags-cli/src/frontend/output/human/commands/workflow.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/output/human/commands/workflow_dry_run.rs rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/human/mod.rs (63%) rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/human/templates.rs (95%) rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/json/commands/auth.rs (57%) rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/json/commands/completions.rs (86%) rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/json/commands/config.rs (67%) rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/json/commands/doctor.rs (96%) rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/json/commands/mod.rs (89%) rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/json/commands/profile.rs (95%) rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/json/commands/refresh_specs.rs (97%) rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/json/commands/service.rs (89%) rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/json/commands/version.rs (80%) create mode 100644 crates/accelbyte-ags-cli/src/frontend/output/json/commands/workflow.rs rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/json/frontend.rs (52%) rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/json/mod.rs (77%) create mode 100644 crates/accelbyte-ags-cli/src/frontend/output/mod.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/output/render.rs rename {src/frontend => crates/accelbyte-ags-cli/src/frontend/output}/templates.rs (89%) rename {src => crates/accelbyte-ags-cli/src}/frontend/presenters/auth.rs (96%) rename {src => crates/accelbyte-ags-cli/src}/frontend/presenters/mod.rs (100%) rename {src => crates/accelbyte-ags-cli/src}/frontend/presenters/service.rs (94%) create mode 100644 crates/accelbyte-ags-cli/src/frontend/sink.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/streams.rs rename {src => crates/accelbyte-ags-cli/src}/frontend/style/ansi.rs (66%) create mode 100644 crates/accelbyte-ags-cli/src/frontend/style/mod.rs rename {src => crates/accelbyte-ags-cli/src}/frontend/style/span.rs (100%) rename {src => crates/accelbyte-ags-cli/src}/frontend/style/text.rs (77%) rename {src => crates/accelbyte-ags-cli/src}/frontend/style/tone.rs (100%) create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/backend.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/date_field.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/dynamic_enums.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/form_runner.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/fullscreen/frontend.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/fullscreen/header.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/fullscreen/interaction.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/fullscreen/layout.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/fullscreen/lifecycle.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/fullscreen/mod.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/fullscreen/nav.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/fullscreen/phases/briefing.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/fullscreen/phases/confirm.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/fullscreen/phases/enum_picker.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/fullscreen/phases/error.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/fullscreen/phases/fields.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/fullscreen/phases/json_edit.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/fullscreen/phases/mod.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/fullscreen/phases/result.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/fullscreen/phases/running.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/fullscreen/step_strip.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/fullscreen/summary.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/fullscreen/surface.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/inline/chrome.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/inline/form.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/inline/form_builder.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/inline/frontend.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/inline/interaction.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/inline/json_editor/mod.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/inline/json_editor/navigation.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/inline/json_editor/node.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/inline/json_editor/raw.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/inline/json_editor/render.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/inline/lifecycle.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/inline/mod.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/inline/phases/confirm_card.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/inline/phases/form.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/inline/phases/mod.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/inline/phases/result.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/inline/progress_state.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/inline/session.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/machine_json.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/mod.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/no_color_backend.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/picker_list.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/plain/frontend.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/plain/interaction.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/plain/mod.rs rename {src/frontend/human => crates/accelbyte-ags-cli/src/frontend/terminal/plain}/progress.rs (56%) create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/plain/prompt.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/views/fields.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/views/inline_format.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/views/mod.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/views/nav.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/views/result.rs create mode 100644 crates/accelbyte-ags-cli/src/frontend/terminal/views/step_failure.rs rename {src => crates/accelbyte-ags-cli/src}/invocation/builder.rs (76%) rename {src => crates/accelbyte-ags-cli/src}/invocation/clap_helpers.rs (100%) rename src/runtime/completions.rs => crates/accelbyte-ags-cli/src/invocation/completions_generator.rs (91%) create mode 100644 crates/accelbyte-ags-cli/src/invocation/context.rs rename {src => crates/accelbyte-ags-cli/src}/invocation/errors.rs (92%) rename {src => crates/accelbyte-ags-cli/src}/invocation/flags.rs (70%) rename {src/invocation/commands => crates/accelbyte-ags-cli/src/invocation/handlers}/completions.rs (93%) rename {src/invocation/commands => crates/accelbyte-ags-cli/src/invocation/handlers}/config.rs (90%) rename {src/invocation/commands => crates/accelbyte-ags-cli/src/invocation/handlers}/describe/envelope.rs (66%) rename {src/invocation/commands => crates/accelbyte-ags-cli/src/invocation/handlers}/describe/mod.rs (62%) rename {src/invocation/commands => crates/accelbyte-ags-cli/src/invocation/handlers}/doctor.rs (79%) rename {src/invocation/commands => crates/accelbyte-ags-cli/src/invocation/handlers}/mod.rs (80%) rename {src/invocation/commands => crates/accelbyte-ags-cli/src/invocation/handlers}/profile.rs (85%) rename {src/invocation/commands => crates/accelbyte-ags-cli/src/invocation/handlers}/refresh_specs.rs (88%) rename {src/invocation/commands => crates/accelbyte-ags-cli/src/invocation/handlers}/version.rs (70%) create mode 100644 crates/accelbyte-ags-cli/src/invocation/mod.rs create mode 100644 crates/accelbyte-ags-cli/src/invocation/phase_execution.rs create mode 100644 crates/accelbyte-ags-cli/src/invocation/policy.rs rename {src => crates/accelbyte-ags-cli/src}/invocation/resolve.rs (98%) create mode 100644 crates/accelbyte-ags-cli/src/invocation/router.rs create mode 100644 crates/accelbyte-ags-cli/src/invocation/routes/auth/mod.rs rename {src/invocation/commands => crates/accelbyte-ags-cli/src/invocation/routes}/auth/oauth.rs (96%) create mode 100644 crates/accelbyte-ags-cli/src/invocation/routes/builtin/mod.rs create mode 100644 crates/accelbyte-ags-cli/src/invocation/routes/mod.rs rename {src/invocation/commands => crates/accelbyte-ags-cli/src/invocation/routes}/service/clap_tree.rs (89%) rename {src/invocation/commands => crates/accelbyte-ags-cli/src/invocation/routes}/service/help.rs (97%) create mode 100644 crates/accelbyte-ags-cli/src/invocation/routes/service/mod.rs rename {src/invocation/commands => crates/accelbyte-ags-cli/src/invocation/routes}/service/parser.rs (63%) rename {src/invocation/commands => crates/accelbyte-ags-cli/src/invocation/routes}/service/request.rs (61%) create mode 100644 crates/accelbyte-ags-cli/src/invocation/routes/workflow/mod.rs create mode 100644 crates/accelbyte-ags-cli/src/invocation/shape.rs create mode 100644 crates/accelbyte-ags-cli/src/invocation/workflows.rs create mode 100644 crates/accelbyte-ags-cli/src/lib.rs rename {src => crates/accelbyte-ags-cli/src}/main.rs (85%) create mode 100644 crates/accelbyte-ags-cli/tests/architecture.rs rename {tests => crates/accelbyte-ags-cli/tests}/common/cli_helpers.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/common/env_guard.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/common/error_helpers.rs (96%) rename {tests => crates/accelbyte-ags-cli/tests}/common/fixture_helpers.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/common/mod.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/common/wiremock_helpers.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/contract_input.rs (62%) create mode 100644 crates/accelbyte-ags-cli/tests/contract_input/services.rs create mode 100644 crates/accelbyte-ags-cli/tests/contract_input/workflows.rs rename {tests => crates/accelbyte-ags-cli/tests}/contract_output.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/contract_output/auth/logout.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/contract_output/auth/mod.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/contract_output/auth/status.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/contract_output/channels.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/contract_output/config.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/contract_output/describe.rs (66%) rename {tests => crates/accelbyte-ags-cli/tests}/contract_output/doctor.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/contract_output/emit_order.rs (79%) rename {tests => crates/accelbyte-ags-cli/tests}/contract_output/error_structure.rs (97%) rename {tests => crates/accelbyte-ags-cli/tests}/contract_output/errors.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/contract_output/human_readable.rs (95%) rename {tests => crates/accelbyte-ags-cli/tests}/contract_output/json_mode.rs (96%) rename {tests => crates/accelbyte-ags-cli/tests}/contract_output/profile.rs (92%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/achievement_input_contract.json (95%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/ams_input_contract.json (94%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/basic_input_contract.json (93%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/challenge_input_contract.json (95%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/chat_input_contract.json (94%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/cloudsave_input_contract.json (94%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/csm_input_contract.json (94%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/gametelemetry_input_contract.json (95%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/gdpr_input_contract.json (94%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/group_input_contract.json (94%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/iam_input_contract.json (94%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/inventory_input_contract.json (95%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/leaderboard_input_contract.json (95%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/legal_input_contract.json (94%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/lobby_input_contract.json (94%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/loginqueue_input_contract.json (92%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/match2_input_contract.json (94%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/platform_input_contract.json (95%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/reporting_input_contract.json (94%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/seasonpass_input_contract.json (94%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/session_input_contract.json (94%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/sessionhistory_input_contract.json (93%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/social_input_contract.json (95%) rename {tests => crates/accelbyte-ags-cli/tests}/fixtures/baselines/ugc_input_contract.json (95%) create mode 100644 crates/accelbyte-ags-cli/tests/fixtures/baselines/workflows/competitive-multiplayer_input_contract.json create mode 100644 crates/accelbyte-ags-cli/tests/fixtures/baselines/workflows/in-game-store_input_contract.json create mode 100644 crates/accelbyte-ags-cli/tests/fixtures/baselines/workflows/player-overview_input_contract.json create mode 100644 crates/accelbyte-ags-cli/tests/fixtures/baselines/workflows/season-pass_input_contract.json rename {tests => crates/accelbyte-ags-cli/tests}/functional.rs (57%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/auth/login.rs (91%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/auth/logout.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/auth/mod.rs (73%) create mode 100644 crates/accelbyte-ags-cli/tests/functional/auth/refresh.rs rename {tests => crates/accelbyte-ags-cli/tests}/functional/auth/status.rs (100%) create mode 100644 crates/accelbyte-ags-cli/tests/functional/competitive_multiplayer.rs rename {tests => crates/accelbyte-ags-cli/tests}/functional/completions_enum_permissive.rs (100%) create mode 100644 crates/accelbyte-ags-cli/tests/functional/completions_full_tree.rs rename {tests => crates/accelbyte-ags-cli/tests}/functional/config/commands.rs (84%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/config/mod.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/describe/commands.rs (92%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/describe/mod.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/doctor.rs (93%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/iam/cache.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/iam/confirmation.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/iam/dry_run.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/iam/help.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/iam/json_input.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/iam/mod.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/iam/modes.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/iam/users.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/iam/validation.rs (100%) create mode 100644 crates/accelbyte-ags-cli/tests/functional/in_game_store.rs create mode 100644 crates/accelbyte-ags-cli/tests/functional/multipart_guard.rs rename {tests => crates/accelbyte-ags-cli/tests}/functional/pagination/commands.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/pagination/mod.rs (100%) create mode 100644 crates/accelbyte-ags-cli/tests/functional/player_overview_direct_user_id.rs rename {tests => crates/accelbyte-ags-cli/tests}/functional/profile/commands.rs (99%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/profile/logout_all.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/profile/mod.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/root.rs (100%) create mode 100644 crates/accelbyte-ags-cli/tests/functional/season_pass.rs rename {tests => crates/accelbyte-ags-cli/tests}/functional/skeleton/commands.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/functional/skeleton/mod.rs (100%) create mode 100644 crates/accelbyte-ags-cli/tests/functional/workflow_list.rs create mode 100644 crates/accelbyte-ags-cli/tests/functional/workflow_parity.rs rename {tests => crates/accelbyte-ags-cli/tests}/integration.rs (68%) rename {tests => crates/accelbyte-ags-cli/tests}/integration/auth.rs (92%) rename {tests => crates/accelbyte-ags-cli/tests}/integration/binary_response.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/integration/builder.rs (98%) rename {tests => crates/accelbyte-ags-cli/tests}/integration/completions.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/integration/config.rs (98%) rename {tests => crates/accelbyte-ags-cli/tests}/integration/error_pipeline.rs (77%) create mode 100644 crates/accelbyte-ags-cli/tests/integration/format_precedence.rs rename {tests => crates/accelbyte-ags-cli/tests}/integration/namespace.rs (82%) rename {tests => crates/accelbyte-ags-cli/tests}/integration/output_flag.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/integration/parser.rs (94%) rename {tests => crates/accelbyte-ags-cli/tests}/integration/profile.rs (83%) rename {tests => crates/accelbyte-ags-cli/tests}/integration/renderer.rs (93%) create mode 100644 crates/accelbyte-ags-cli/tests/integration/service_naming.rs create mode 100644 crates/accelbyte-ags-cli/tests/integration/stream_ownership.rs rename {tests => crates/accelbyte-ags-cli/tests}/integration/token_refresh_race.rs (96%) create mode 100644 crates/accelbyte-ags-cli/tests/integration/tty_topology.rs create mode 100644 crates/accelbyte-ags-cli/tests/integration/tui_e2e.rs create mode 100644 crates/accelbyte-ags-cli/tests/integration/tui_format.rs create mode 100644 crates/accelbyte-ags-cli/tests/integration/ui_flag.rs rename {tests => crates/accelbyte-ags-cli/tests}/performance.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/performance/release.rs (80%) rename {tests => crates/accelbyte-ags-cli/tests}/performance/spec_loading.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/performance/startup.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/scope_version.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/security.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/security/config_permissions.rs (97%) rename {tests => crates/accelbyte-ags-cli/tests}/security/credentials.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/security/file_access.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/security/injection.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/snapshot.rs (100%) rename {tests => crates/accelbyte-ags-cli/tests}/snapshot/cli_output.rs (98%) rename {tests => crates/accelbyte-ags-cli/tests}/snapshot/describe.rs (78%) rename {tests => crates/accelbyte-ags-cli/tests}/snapshot/errors.rs (97%) rename {tests => crates/accelbyte-ags-cli/tests}/snapshot/fields.rs (93%) rename {tests => crates/accelbyte-ags-cli/tests}/snapshot/help_text.rs (95%) create mode 100644 crates/accelbyte-ags-cli/tests/snapshot/snapshots/ags__frontend__terminal__fullscreen__nav__tests__snapshot_nav_briefing.snap create mode 100644 crates/accelbyte-ags-cli/tests/snapshot/snapshots/ags__frontend__terminal__fullscreen__nav__tests__snapshot_nav_confirm.snap create mode 100644 crates/accelbyte-ags-cli/tests/snapshot/snapshots/ags__frontend__terminal__fullscreen__nav__tests__snapshot_nav_confirm_skippable.snap create mode 100644 crates/accelbyte-ags-cli/tests/snapshot/snapshots/ags__frontend__terminal__fullscreen__nav__tests__snapshot_nav_error.snap create mode 100644 crates/accelbyte-ags-cli/tests/snapshot/snapshots/ags__frontend__terminal__fullscreen__nav__tests__snapshot_nav_fields.snap create mode 100644 crates/accelbyte-ags-cli/tests/snapshot/snapshots/ags__frontend__terminal__fullscreen__nav__tests__snapshot_nav_fields_skippable.snap create mode 100644 crates/accelbyte-ags-cli/tests/snapshot/snapshots/ags__frontend__terminal__fullscreen__nav__tests__snapshot_nav_json_edit_raw.snap create mode 100644 crates/accelbyte-ags-cli/tests/snapshot/snapshots/ags__frontend__terminal__fullscreen__nav__tests__snapshot_nav_json_edit_scalar.snap create mode 100644 crates/accelbyte-ags-cli/tests/snapshot/snapshots/ags__frontend__terminal__fullscreen__nav__tests__snapshot_nav_json_edit_tree.snap create mode 100644 crates/accelbyte-ags-cli/tests/snapshot/snapshots/ags__frontend__terminal__fullscreen__nav__tests__snapshot_nav_loading.snap create mode 100644 crates/accelbyte-ags-cli/tests/snapshot/snapshots/ags__frontend__terminal__fullscreen__nav__tests__snapshot_nav_result.snap create mode 100644 crates/accelbyte-ags-cli/tests/snapshot/snapshots/ags__frontend__terminal__fullscreen__nav__tests__snapshot_nav_running.snap create mode 100644 crates/accelbyte-ags-cli/tests/snapshot/snapshots/ags__frontend__terminal__fullscreen__phases__confirm__tests__snapshot_confirm_panel_layout.snap create mode 100644 crates/accelbyte-ags-cli/tests/snapshot/snapshots/ags__frontend__terminal__fullscreen__phases__fields__tests__snapshot_fields_panel_gather_variant.snap create mode 100644 crates/accelbyte-ags-cli/tests/snapshot/snapshots/ags__frontend__terminal__fullscreen__phases__fields__tests__snapshot_fields_panel_review_variant.snap rename {tests => crates/accelbyte-ags-cli/tests}/snapshot/snapshots/snapshot__describe__error_envelope.snap (100%) rename {tests => crates/accelbyte-ags-cli/tests}/snapshot/snapshots/snapshot__describe__method_matrix.snap (100%) rename {tests => crates/accelbyte-ags-cli/tests}/snapshot/snapshots/snapshot__describe__root_catalogue_child.snap (100%) rename {tests => crates/accelbyte-ags-cli/tests}/snapshot/snapshots/snapshot__describe__service_catalogue_envelope.snap (100%) create mode 100644 crates/accelbyte-ags-cli/tests/snapshot/snapshots/snapshot__describe__workflow_detail_envelope.snap create mode 100644 crates/accelbyte-ags-cli/tests/snapshot/snapshots/snapshot__help_text__auth_refresh_help.snap rename {tests => crates/accelbyte-ags-cli/tests}/snapshot/snapshots/snapshot__help_text__config_help.snap (100%) rename {tests => crates/accelbyte-ags-cli/tests}/snapshot/snapshots/snapshot__help_text__doctor_help.snap (100%) rename {tests => crates/accelbyte-ags-cli/tests}/snapshot/snapshots/snapshot__help_text__profile_create_help.snap (100%) rename {tests => crates/accelbyte-ags-cli/tests}/snapshot/snapshots/snapshot__help_text__profile_help.snap (100%) create mode 100644 crates/accelbyte-ags-cli/tests/workflow_integration.rs create mode 100644 crates/ags-protocol/Cargo.toml rename {src/protocol => crates/ags-protocol/src}/catalogue.rs (86%) rename {src/protocol => crates/ags-protocol/src}/config.rs (57%) rename {src/protocol => crates/ags-protocol/src}/diagnostics.rs (98%) rename {src/protocol => crates/ags-protocol/src}/error.rs (84%) rename {src/protocol => crates/ags-protocol/src}/event.rs (99%) rename src/protocol/mod.rs => crates/ags-protocol/src/lib.rs (93%) rename {src/protocol => crates/ags-protocol/src}/output.rs (50%) rename {src/protocol => crates/ags-protocol/src}/output_views.rs (72%) rename {src/protocol => crates/ags-protocol/src}/request.rs (86%) rename {src/protocol => crates/ags-protocol/src}/result.rs (95%) create mode 100644 crates/ags-protocol/src/workflow.rs create mode 100644 crates/ags-runtime/Cargo.toml rename {specs => crates/ags-runtime/specs}/achievement.json.gz (100%) create mode 100644 crates/ags-runtime/specs/ams.json.gz rename {specs => crates/ags-runtime/specs}/basic.json.gz (100%) rename {specs => crates/ags-runtime/specs}/challenge.json.gz (100%) rename {specs => crates/ags-runtime/specs}/chat.json.gz (100%) rename {specs => crates/ags-runtime/specs}/cloudsave.json.gz (100%) rename {specs => crates/ags-runtime/specs}/csm.json.gz (100%) rename {specs => crates/ags-runtime/specs}/gametelemetry.json.gz (100%) rename {specs => crates/ags-runtime/specs}/gdpr.json.gz (100%) rename {specs => crates/ags-runtime/specs}/group.json.gz (100%) rename {specs => crates/ags-runtime/specs}/iam.json.gz (100%) rename {specs => crates/ags-runtime/specs}/inventory.json.gz (100%) rename {specs => crates/ags-runtime/specs}/leaderboard.json.gz (100%) rename {specs => crates/ags-runtime/specs}/legal.json.gz (100%) rename {specs => crates/ags-runtime/specs}/lobby.json.gz (100%) rename {specs => crates/ags-runtime/specs}/loginqueue.json.gz (100%) rename {specs => crates/ags-runtime/specs}/match2.json.gz (100%) create mode 100644 crates/ags-runtime/specs/platform.json.gz rename {specs => crates/ags-runtime/specs}/reporting.json.gz (100%) rename {specs => crates/ags-runtime/specs}/seasonpass.json.gz (100%) rename {specs => crates/ags-runtime/specs}/session.json.gz (100%) rename {specs => crates/ags-runtime/specs}/sessionhistory.json.gz (100%) rename {specs => crates/ags-runtime/specs}/social.json.gz (100%) rename {specs => crates/ags-runtime/specs}/ugc.json.gz (100%) create mode 100644 crates/ags-runtime/src/catalogue/aliases.rs rename {src => crates/ags-runtime/src}/catalogue/bundled.rs (99%) rename {src => crates/ags-runtime/src}/catalogue/cache.rs (85%) rename {src => crates/ags-runtime/src}/catalogue/manifest.rs (96%) rename {src => crates/ags-runtime/src}/catalogue/memory_cache.rs (55%) rename {src => crates/ags-runtime/src}/catalogue/mod.rs (77%) rename {src => crates/ags-runtime/src}/catalogue/openapi.rs (78%) rename {src => crates/ags-runtime/src}/catalogue/parser.rs (75%) rename {src => crates/ags-runtime/src}/catalogue/repository.rs (65%) rename {src => crates/ags-runtime/src}/catalogue/skeleton.rs (70%) create mode 100644 crates/ags-runtime/src/lib.rs rename {src => crates/ags-runtime/src}/runtime/auth/credentials.rs (97%) rename {src => crates/ags-runtime/src}/runtime/auth/errors.rs (82%) rename {src => crates/ags-runtime/src}/runtime/auth/locking.rs (99%) rename {src => crates/ags-runtime/src}/runtime/auth/mod.rs (86%) rename {src => crates/ags-runtime/src}/runtime/auth/operations.rs (65%) rename {src => crates/ags-runtime/src}/runtime/auth/session.rs (72%) rename {src => crates/ags-runtime/src}/runtime/auth/store.rs (95%) rename {src => crates/ags-runtime/src}/runtime/auth/tokens.rs (94%) rename {src => crates/ags-runtime/src}/runtime/cleanup.rs (100%) rename {src => crates/ags-runtime/src}/runtime/config/environment.rs (100%) rename {src => crates/ags-runtime/src}/runtime/config/errors.rs (84%) rename {src => crates/ags-runtime/src}/runtime/config/keys.rs (82%) rename {src => crates/ags-runtime/src}/runtime/config/mod.rs (100%) rename {src => crates/ags-runtime/src}/runtime/config/paths.rs (99%) rename {src => crates/ags-runtime/src}/runtime/config/store.rs (96%) rename {src => crates/ags-runtime/src}/runtime/diagnostics/checks.rs (99%) rename {src => crates/ags-runtime/src}/runtime/diagnostics/mod.rs (89%) rename {src => crates/ags-runtime/src}/runtime/diagnostics/runner.rs (90%) rename {src => crates/ags-runtime/src}/runtime/dispatch/classify.rs (50%) rename {src => crates/ags-runtime/src}/runtime/dispatch/confirmation.rs (98%) rename {src => crates/ags-runtime/src}/runtime/dispatch/error_codes/basic.rs (84%) rename {src => crates/ags-runtime/src}/runtime/dispatch/error_codes/challenge.rs (86%) rename {src => crates/ags-runtime/src}/runtime/dispatch/error_codes/cloudsave.rs (85%) rename {src => crates/ags-runtime/src}/runtime/dispatch/error_codes/group.rs (81%) rename {src => crates/ags-runtime/src}/runtime/dispatch/error_codes/iam.rs (82%) rename {src => crates/ags-runtime/src}/runtime/dispatch/error_codes/leaderboard.rs (84%) rename {src => crates/ags-runtime/src}/runtime/dispatch/error_codes/legal.rs (89%) rename {src => crates/ags-runtime/src}/runtime/dispatch/error_codes/mod.rs (70%) rename {src => crates/ags-runtime/src}/runtime/dispatch/error_codes/platform.rs (84%) rename {src => crates/ags-runtime/src}/runtime/dispatch/error_codes/seasonpass.rs (85%) rename {src => crates/ags-runtime/src}/runtime/dispatch/error_codes/social.rs (84%) rename {src => crates/ags-runtime/src}/runtime/dispatch/error_codes/standard.rs (85%) rename {src => crates/ags-runtime/src}/runtime/dispatch/execute.rs (51%) rename {src => crates/ags-runtime/src}/runtime/dispatch/http.rs (81%) create mode 100644 crates/ags-runtime/src/runtime/dispatch/mod.rs rename {src => crates/ags-runtime/src}/runtime/dispatch/pagination.rs (75%) rename {src => crates/ags-runtime/src}/runtime/dispatch/path.rs (97%) rename {src => crates/ags-runtime/src}/runtime/dispatch/shape.rs (99%) rename {src => crates/ags-runtime/src}/runtime/execution.rs (99%) rename {src => crates/ags-runtime/src}/runtime/facade/auth.rs (80%) rename {src => crates/ags-runtime/src}/runtime/facade/config.rs (76%) rename {src => crates/ags-runtime/src}/runtime/facade/diagnostics.rs (86%) rename {src => crates/ags-runtime/src}/runtime/facade/mod.rs (100%) rename {src => crates/ags-runtime/src}/runtime/facade/profile.rs (92%) rename {src => crates/ags-runtime/src}/runtime/facade/service.rs (55%) rename {src => crates/ags-runtime/src}/runtime/mod.rs (54%) create mode 100644 crates/ags-runtime/src/runtime/workflows/auto_derive.rs create mode 100644 crates/ags-runtime/src/runtime/workflows/builtins/competitive_multiplayer.rs create mode 100644 crates/ags-runtime/src/runtime/workflows/builtins/in_game_store.rs create mode 100644 crates/ags-runtime/src/runtime/workflows/builtins/mod.rs create mode 100644 crates/ags-runtime/src/runtime/workflows/builtins/player_overview.rs create mode 100644 crates/ags-runtime/src/runtime/workflows/builtins/season_pass.rs create mode 100644 crates/ags-runtime/src/runtime/workflows/compile.rs create mode 100644 crates/ags-runtime/src/runtime/workflows/dry_run.rs create mode 100644 crates/ags-runtime/src/runtime/workflows/executor.rs create mode 100644 crates/ags-runtime/src/runtime/workflows/jsonpath.rs create mode 100644 crates/ags-runtime/src/runtime/workflows/mod.rs create mode 100644 crates/ags-runtime/src/runtime/workflows/nested_path.rs create mode 100644 crates/ags-runtime/src/runtime/workflows/options.rs create mode 100644 crates/ags-runtime/src/runtime/workflows/resolve.rs create mode 100644 crates/ags-runtime/src/runtime/workflows/synthesised.rs create mode 100644 crates/ags-runtime/src/runtime/workflows/tests.rs rename {src => crates/ags-runtime/src}/support/file_system.rs (100%) rename {src => crates/ags-runtime/src}/support/mod.rs (100%) rename {src => crates/ags-runtime/src}/support/output_sink.rs (80%) rename {src => crates/ags-runtime/src}/support/strings.rs (91%) rename {src => crates/ags-runtime/src}/support/test_helpers.rs (100%) delete mode 100755 demo/demo-server.py delete mode 100755 demo/record.sh delete mode 100644 demo/reel.gif create mode 100644 demos/auto-surfaces/auto-surfaces.gif create mode 100644 demos/auto-surfaces/auto-surfaces.routes.json create mode 100644 demos/auto-surfaces/auto-surfaces.tape create mode 100644 demos/auto-surfaces/prelogin create mode 100644 demos/auto-surfaces/prewarm create mode 100644 demos/command-surfaces/command-surfaces.gif create mode 100644 demos/command-surfaces/command-surfaces.routes.json create mode 100644 demos/command-surfaces/command-surfaces.tape create mode 100644 demos/command-surfaces/prelogin create mode 100644 demos/command-surfaces/prewarm create mode 100644 demos/engine/mock-server.py create mode 100644 demos/engine/spec-reader.py create mode 100644 demos/onboarding/onboarding.gif create mode 100644 demos/onboarding/onboarding.routes.json rename demo/reel.tape => demos/onboarding/onboarding.tape (66%) create mode 100644 demos/onboarding/prewarm create mode 100755 demos/record.sh create mode 100644 demos/workflow-surfaces/prelogin create mode 100644 demos/workflow-surfaces/prewarm create mode 100644 demos/workflow-surfaces/workflow-surfaces.gif create mode 100644 demos/workflow-surfaces/workflow-surfaces.routes.json create mode 100644 demos/workflow-surfaces/workflow-surfaces.tape delete mode 100644 specs/ams.json.gz delete mode 100644 specs/platform.json.gz delete mode 100644 src/frontend/human/commands/config.rs delete mode 100644 src/frontend/human/frontend.rs delete mode 100644 src/frontend/human/prompt.rs delete mode 100644 src/frontend/json/progress.rs delete mode 100644 src/frontend/mod.rs delete mode 100644 src/frontend/render.rs delete mode 100644 src/frontend/style/mod.rs delete mode 100644 src/invocation/commands/auth/mod.rs delete mode 100644 src/invocation/commands/service/dispatch.rs delete mode 100644 src/invocation/commands/service/mod.rs delete mode 100644 src/invocation/mod.rs delete mode 100644 src/invocation/router.rs delete mode 100644 src/lib.rs delete mode 100644 src/runtime/dispatch/mod.rs delete mode 100644 tests/architecture.rs delete mode 100644 tests/contract_input/services.rs delete mode 100644 tests/functional/completions_full_tree.rs delete mode 100644 tests/integration/service_naming.rs diff --git a/.claude/settings.json b/.claude/settings.json index ce5d273..d0ebbc8 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,6 @@ { "attribution": { "commit": "" - } + }, + "disableWorkflows": true } diff --git a/.claude/skills/spec-update/SKILL.md b/.claude/skills/spec-update/SKILL.md new file mode 100644 index 0000000..3060c54 --- /dev/null +++ b/.claude/skills/spec-update/SKILL.md @@ -0,0 +1,126 @@ +--- +name: spec-update +description: Land an already-finalized, enriched OpenAPI 2.0 spec into the AGS CLI — gzip it into the bundled specs, run the breaking-change gate, regenerate the catalogue + contract baselines, and guide the hand-authored manifest/alias edits. The mechanical, post-lint half of a spec refresh. Use after openapi-lint has produced a finalized .json. Downstream sibling of the openapi-lint skill. +argument-hint: [] — the service whose finalized spec to land; prompts if omitted +disable-model-invocation: false +--- + +You land an already-finalized, enriched OpenAPI 2.0 spec into the AGS CLI product repo. This is the mechanical, post-lint half of a spec refresh: the `openapi-lint` skill produces the finalized `.json` (merge/enrich + OID assignment); you gzip it into the bundled specs, run the breaking-change gate, regenerate the catalogue and contract baselines, and guide the hand-authored manifest/alias edits. Chain: `openapi-lint` → `spec-update`. + +Two paths: **refresh** an existing service (the common, near-mechanical case) and **new-service onboarding** (rarer, more hand-authored, clearly flagged). You detect which from whether the service is already bundled (Phase 0). + +## Critical constraints + +You MUST follow these without exception: + +1. **One service per run.** Multiple services = run the skill again, once each. +2. **Source spec.** Default `.claude/output/openapi-lint//.json` (openapi-lint's output). Always confirm the path with the user before reading it; accept an override path. +3. **The skill runs cargo itself** so a run is self-contained. Always run cargo serially (`--test-threads=1`); set the **Bash tool's** timeout to `300000` ms (5 minutes) — this is the tool's millisecond timeout parameter, NOT the shell `timeout` command (whose argument is seconds) — and never start a second cargo run while one is in flight. +4. **The breaking-change gate runs BEFORE any baseline regeneration** — regenerating first would erase the signal. +5. **Reproducible gzip.** Use `gzip -n` so the bundled bytes are reproducible (it strips the original name + mtime). +6. **Never git commit.** End with a change summary and let the user review and commit. +7. **No trailing full stop** on resource/service descriptions or headings. + +## Phase 0: Select service and locate the spec + +1. Determine the service: from the `` argument, or — if omitted — list the services that have a finalized `.json` under `.claude/output/openapi-lint/` and ask which one. +2. Confirm the source path `.claude/output/openapi-lint//.json` with the user; accept an override path. +3. Detect the branch: + - If `crates/ags-runtime/specs/.json.gz` exists AND `` is in `SERVICES` in `crates/ags-runtime/src/catalogue/manifest.rs` → **refresh** (skip Phase 2b). + - Otherwise → **new-service onboarding** (Phase 2 is skipped — no baseline yet — and Phase 2b runs before Phase 3). + +## Phase 1: Install the spec bytes + +Gzip the finalized spec directly into the bundled specs, overwriting for a refresh (`` is the path confirmed in Phase 0). Gzip straight from the source — no intermediate `/tmp` copy — so a retry can never pick up stale bytes from a prior run: +``` +gzip -n -c > crates/ags-runtime/specs/.json.gz +``` +`-n` suppresses the original filename and zeroes the mtime header (verified on both Apple gzip and GNU gzip), so the bytes are reproducible across machines and runs. + +## Phase 2: Breaking-change gate (refresh only) + +Skip this phase for a new service (it has no baseline yet — its first baseline is created in Phase 3). + +Run (Bash tool timeout `300000` ms): +``` +cargo test -p accelbyte-ags-cli --test contract_input test_no_breaking_changes -- --test-threads=1 +``` + +- **Green** → go to Phase 3. (This phase runs only for a refresh, so the next step is always Phase 3; the new-service branch reaches Phase 3 via Phase 2b instead.) +- **Red** → classify **each** failing finding mechanically, not by impression. For the failing operation, find the same **HTTP path + HTTP method** in the new spec and compare its `x-operationId` to the baseline's: + - **Renamed method segment** — the path+method **still exists** in the new spec but its `x-operationId` method segment differs (so the derived CLI method name changed; the old name is what the gate reports as deleted) → add a `former_method_names` entry in `crates/ags-runtime/src/catalogue/aliases.rs`, keyed `(service, resource, current-method) → [old-name]`. Re-run the gate and re-classify; **repeat while any rename-shaped failure remains** — a single spec update may rename several operations, and each needs its own alias. + - **Genuinely removed / changed operation** — the path+method is **gone** from the new spec (or the whole path is absent) → add no alias; STOP and surface it to the user. Accepting a breaking change is the user's judgment call, not the skill's. + + The path+method identity is the load-bearing signal: an alias makes the old CLI name resolve to the new operation, so it is only ever correct when that operation still exists. Never add an alias for a path+method that is gone — that would leave a broken command name resolving to nothing. Keep resolving rename-shaped failures (one alias each, re-running) until the gate is green or a removal is found. A still-red gate is not itself the stop signal — the *kind* of remaining failure is. + +## Phase 2b: Register a new service (new-service branch only) + +Skip this phase for a refresh. Do all four edits BEFORE Phase 3 — the generator only emits a service in its own `SERVICES` list, and the build/tests only embed a service in `BUNDLED_SPECS`. + +1. **`crates/ags-runtime/src/catalogue/bundled.rs` — `BUNDLED_SPECS`:** add + `("", include_bytes!("../../specs/.json.gz"))`, positioned to keep the table in lockstep order with `manifest::SERVICES`. +2. **`crates/ags-runtime/src/catalogue/bundled.rs` — `test_bundled_specs_count`:** bump the asserted count (e.g. `24` → `25`). It also asserts `BUNDLED_SPECS.len() == manifest::SERVICES.len()`, so both tables must move together. +3. **`crates/ags-runtime/src/catalogue/manifest.rs` — `SERVICES`:** add a `ServiceManifest` entry (the display name + description are user-authored, no trailing full stop on the description): + ```rust + ServiceManifest { + internal: "", + display: "", + description: "", + }, + ``` +4. **`scripts/generate_cli_command_catalogue.py` — `SERVICES`:** add the internal id to the list. Add a `DISPLAY_NAMES` entry ONLY if the display name differs from the internal id (when they are equal, omit it — `DISPLAY_NAMES.get(service, service)` already falls back to the internal id). If you do add one, its value must be copied **character-for-character** from the `display` field of the `ServiceManifest` entry authored in step 3 — a mismatch makes the catalogue document a display name the CLI does not use. Cross-check the two files before Phase 3. + +Surface these four edits to the user for review — a missed one fails the lockstep count test or silently drops the service from the catalogue. + +## Phase 3: Regenerate the catalogue and baselines (only after the gate is green) + +For a new service this depends on Phase 2b — the generator will not emit a service absent from its `SERVICES` list. + +Run: +``` +python3 scripts/generate_cli_command_catalogue.py --emit-baselines crates/accelbyte-ags-cli/tests/fixtures/baselines/ +``` + +This rewrites both the catalogue (`docs/reference/cli-command-catalogue.md`) and the per-service contract baselines in one pass. It does NOT touch the workflow baselines under `tests/fixtures/baselines/workflows/` (those come from the workflow registry, not specs). + +The generator regenerates **all** services' baselines from the specs currently on disk — it has no per-service flag. Combined with one-service-per-run (constraint 1), this is safe only if the target service's `.gz` is the **only** changed spec in the working tree; otherwise another service's baseline would be silently overwritten, masking an ungated breaking change. Before running, assert the working tree is clean apart from the target — checking **both** modified tracked specs (refresh) and untracked new specs (new service: its `.gz` is brand-new and invisible to `git diff`): +``` +git diff --name-only HEAD -- crates/ags-runtime/specs/ +git ls-files --others --exclude-standard -- crates/ags-runtime/specs/ +``` +A file is either tracked or untracked, never both, so the two outputs are disjoint. Their **union** must be exactly the single path `crates/ags-runtime/specs/.json.gz` — equivalently, the first command lists at most that one path and the second lists at most that one path, and no *other* path appears in **either**. If any other path appears in either output, STOP and resolve it before regenerating. + +After the generator runs, surface **every** changed path — modified and newly created — and confirm it is intentional (new ops/params/summaries) before proceeding to Phase 4: +``` +git status --short -- docs/reference/cli-command-catalogue.md crates/accelbyte-ags-cli/tests/fixtures/baselines/ +``` +Show the line-level diff of tracked changes with `git diff HEAD -- `. For a new service the per-service baseline `crates/accelbyte-ags-cli/tests/fixtures/baselines/_input_contract.json` is **untracked**, so `git diff` shows nothing — review it directly with `git diff --no-index /dev/null ` (or just read the file) so the user sees the first baseline. Unexpected changes to an untargeted service mean the working tree was not clean — STOP and investigate. + +## Phase 4: Hand-authored resource metadata + +Diff the resources present in the new spec against `RESOURCE_DESCRIPTIONS` in `crates/ags-runtime/src/catalogue/manifest.rs`. For each **new** resource, prompt the user to author a one-line description (kebab-case resource id, no trailing full stop) and add the `(service, resource, description)` triple. + +**New-service branch:** add every one of the service's resources to `RESOURCE_DESCRIPTIONS` (the `SERVICES` allowlist + `service_description` were already added in Phase 2b). Resource descriptions are not consumed by the generator or baselines, so authoring them here — after regeneration — is fine. + +## Phase 5: Cache / version check + +Usually **no** bump is needed: the workspace-shared version + the release version bump auto-invalidate users' caches (`cache.rs` keys on `env!("CARGO_PKG_VERSION")`). Note this and move on unless the user explicitly wants a dev-side cache-busting bump. + +## Phase 6: Full verification + +Run (Bash tool timeout `300000` ms; never start a second cargo run while one is in flight): +``` +cargo test -- --test-threads=1 +``` + +`test_baseline_is_current` should now be green (baselines were regenerated in Phase 3), and `test_bundled_specs_count` should be green for a new service (count bumped in Phase 2b). Report the result. If anything is red, return to the relevant phase. + +## Phase 7: Summary and handoff + +Print an in-session summary: +- the gzipped spec (`crates/ags-runtime/specs/.json.gz`), +- the regenerated catalogue + baselines, +- any `aliases.rs` / `RESOURCE_DESCRIPTIONS` / `SERVICES` / count edits, +- the breaking findings (if any) and how they were resolved. + +Do NOT commit. End by asking the user to review and commit. diff --git a/.claude/skills/vhs-demo/SKILL.md b/.claude/skills/vhs-demo/SKILL.md new file mode 100644 index 0000000..38b91d3 --- /dev/null +++ b/.claude/skills/vhs-demo/SKILL.md @@ -0,0 +1,239 @@ +--- +name: vhs-demo +description: Author a VHS demo (tape + mock + runner) for an ags command or workflow, grounded in `ags describe` and `--dry-run --format json`. Produces convention-correct artifacts under demos// and hands off the record command; never runs vhs itself. Use when asked to create or update a demo GIF/recording of the CLI. +argument-hint: — then describe the command(s)/workflow and surface mode +disable-model-invocation: false +--- + +You author a VHS demo for the AGS CLI: a `.tape`, a mock routing table, and the +runner wiring, all under `demos//`. You **never run `vhs`** — you produce +artifacts and hand off the exact record command. Correctness comes from grounding +every command, input, endpoint, and response in the CLI's own introspection, not +from guessing. + +## Inputs to collect first + +1. **Demo name** (``, kebab-case) → artifacts live in `demos//`. +2. **What to show:** the single command(s) (`ags `) + and/or a workflow (`ags workflow run `), with the concrete input values. +3. **Surface mode** (exactly one): `no-ui` (all inputs as flags, deterministic), + `plain` (`--ui plain`, line prompts), `inline` (`--ui inline`), or `fullscreen` + (`--ui fullscreen`). + +## Procedure + +1. **Ground inputs.** Run `ags describe ` (single) or + `ags describe workflow ` (workflow). Use this for exact flag names (kebab), + types, required/default, enum values, and — for workflows — the input order and + the step list (`service` + `operation` per step). +2. **Ground endpoints.** Run the matching dry-run JSON: + - Single: `ags --dry-run --format json --` + - Workflow: `ags workflow run --dry-run --format json --` + Parse the request (`method`, `url`, `body`) / `steps[]`. Strip each `url`'s query + string — the mock matches path only. +3. **Build `demos//.routes.json`.** One route per request/step plus the + OAuth token route. For each route's response `body`, run + `python3 demos/engine/spec-reader.py `. Read the + operation-id from the right field: for a **single command** it is the + per-contract `x_operation_id` (nested at + `data.scopes..contracts..x_operation_id`, e.g. + `social/admin/stat-definitions/v1/create`) in `ags describe + --format json`; for a **workflow step** it is the step's + `operation` field from `ags + describe workflow --format json` (e.g. `iam/admin/roles/v4/list`) — NOT the + step's `service` or `id` (the short label). On a non-zero exit, fall back to a + minimal payload (`{}` or `{"id": "..."}`); if a downstream workflow step binds a + captured field, add just that field with a plausible value. Give the token route + `delay: 1.2` and others `delay: 0.6`. +4. **Build `demos//.tape`** for the surface mode (see below), using the + fixed look + timing conventions. +5. **Build `demos//prewarm`** — one service *display* name per line for every + service the demo touches (for a workflow, map each step's `service` to its CLI + display name via `ags describe`). This warms specs so the first on-camera command + shows no "Preparing specs…". For a non-dry-run demo that should NOT show auth, + also drop an empty `demos//prelogin` marker so `record.sh` logs in + off-camera (see Auth bootstrap) — and do not put a login in the tape. +6. **Scaffold the engine on first use.** If `demos/engine/spec-reader.py`, + `demos/engine/mock-server.py`, or `demos/record.sh` are missing, copy them from + the `onboarding` reference (they already exist once this skill has shipped). +7. **Hand off.** Print `demos/record.sh ` and the requirements (cargo, + python3, vhs). For `inline`/`fullscreen`, also print the timing caveat and the + 404-loop instruction (below). + +## Mandatory conventions (every generated demo must meet these) + +### Fixed look (baseline for all demos) +``` +Set Theme "Dracula" +Set FontSize 14 +Set Width 1200 +Set Height 700 +Set TypingSpeed 55ms +Set PlaybackSpeed 1.0 +``` +**Viewport override for inline-vs-fullscreen demos.** A demo that shows the +`inline` and `fullscreen` surfaces side by side needs a *taller* viewport +(e.g. `Width 1400`, `Height 1000`) — inline renders in a fixed-height region, so +on a small terminal it fills the screen and looks almost identical to fullscreen. +A taller terminal makes inline occupy a visibly smaller fraction. Single-surface +demos keep the baseline size. + +### Isolation header (every tape) +``` +Env AGS_BASE_URL "http://localhost:8765" +Env AGS_HOME "/tmp/ags-demo-state" +Env AGS_NO_KEYCHAIN "1" +``` + +### Auth bootstrap (every non-dry-run demo) +A fresh `AGS_HOME` starts unauthenticated, so a live command/workflow needs a token +first or it fails. + +**Default: authenticate off-camera via `record.sh`, NOT in the tape.** Drop a +`demos//prelogin` marker file; `record.sh` then runs `ags auth login --grant +client-credentials --client-id demo --client-secret-stdin` (against the mocked +token route) into the same throwaway `AGS_HOME` before recording starts. The token +persists, so every section runs authenticated and **no login ever appears in the +GIF**. This is more reliable than VHS `Hide`/`Show`, which has been observed to +leak the login into the recording — prefer `prelogin`. + +Show auth **on-camera only when authentication is itself the subject of the demo** +(e.g. an onboarding/login reel): omit the `prelogin` marker and `Type` the login +as a visible step in the tape. Dry-run-only demos skip auth entirely (no live +call). + +### Timing +`Sleep` between logical steps sized to read time: ~`1500ms` for a short result, +~`3s`-`4s` for a table or multi-line result. `Ctrl+L` between sections to clear. +After a printed section header (`Type "# N. …"` + `Enter`), add a second `Enter` +so a blank line separates the header from the command beneath it. + +**Interactive forms read far faster on playback than they feel while authoring** — +pace them deliberately. Settled values from the workflow-surfaces demo: +- **Per-step review pause ~`2800ms`.** A multi-step workflow auto-advances each + per-step confirm with an `Enter` from the tape; without a generous pause the + Continue fires before the viewer can register the step. Hold ~2.8s per step. +- **Post-navigation delay ~`700ms`.** After a `Tab` (or `Tab N`) that moves focus, + pause before typing so the focus move registers before input appears. +- **Pre-input settle ~`2s`-`3s`** after launching a TUI (briefing/first frame) and + ~`900ms` between successive `plain` line answers. +- **Final result pause ~`6s`-`7s`** after a workflow completes so the result/summary + is readable before `Ctrl+L`. + +## Surface modes + +- **no-ui** — type the full flagged command (`--yes` for confirm-gated workflow + steps), `Enter`, `Sleep`. Optionally also show `--dry-run` and/or `--format json` + variants. Fully deterministic. +- **plain** — append `--ui plain`. The CLI prompts line-by-line; the tape `Type`s + each value in `ags describe` order, `Enter` per value. +- **inline** — append `--ui inline`. Keystroke-driven; ground the sequence in the + describe field order. Inline has **no dynamic-enum picker** (`options_source` + inputs are plain text), so no option endpoints are needed. +- **fullscreen** — append `--ui fullscreen`. Keystroke-driven; ground in field + order + the nav keymap. Fullscreen **does** enable the dynamic-enum picker for + `dynamic: true` inputs — either pre-supply those inputs as flags to skip it, or + drive filter+select keystrokes and add the picker's options `GET` to the routes + (found via the 404 loop). + +**For `inline` and `fullscreen`:** verify the keystroke sequence headlessly under a +pty first (see "Verifying interactive sections" below) so it reaches completion; +recording is then only for tuning `Sleep` durations and confirming the visual +layout (e.g. that inline reads differently from fullscreen). + +### Demonstrating `--ui auto` (surface chosen by missing-input count) +`--ui auto` (the default when no `--ui` is passed) picks the surface from how many +**required** inputs are still missing after flag resolution — so one command can be +shown three ways by varying only which flags you supply, with no `--ui` anywhere: +- **0 missing → runs unattended.** Auto resolves to plain, but there is nothing to + prompt, so the command executes straight to the result. +- **1–2 missing scalars → plain.** Auto resolves to plain and prompts line-by-line + for the missing scalars. +- **a missing *structured* body field (object/array), or ≥3 missing scalars → + inline** (the `Form` shape). + +What counts as a scalar (`classify_service_like` + `is_body_field_input`): a +**structured** body field — an object or array, the same fields the inline form +opens in the JSON editor — sets "has body field" and forces inline. **Scalar** +body fields (string / number / bool / enum) instead count as `required_scalars`, +because plain can prompt them line-by-line; path/query params are always scalars. +`--json` pre-supplies the *whole* body at once (clearing both counts) — there are +no per-body-field flags. Consequences: +- To show all three branches from ONE command it needs **≥1 required scalar param + (path/query) AND a body**: supply both → unattended; omit the scalar(s) but keep + `--json` → plain; omit `--json` → inline (the missing body makes it a `Form`). A + **body-only** command (no scalar param, e.g. `iam roles create`) can only ever + show unattended-vs-inline — it cannot reach the plain middle case. +- The inline branch needs a **structured** (object/array) body field *or* ≥3 + missing scalars; a command whose only missing body field is a single scalar + lands in plain, not inline. +- `--format json` forces the automation consumer (JSON, never prompts), and an + auto-selected inline/fullscreen degrades to plain when stdin+stderr aren't both + TTYs — neither is the "auto by missing count" behaviour, so don't conflate them. + +See `demos/auto-surfaces/` for a worked example (`iam roles add-permissions`: +role-id scalar + a `permissions` array body). + +## Verifying interactive sections headlessly (do this BEFORE recording) +**Every surface — plain, inline, fullscreen — can be verified without `vhs`** by +driving the real binary under a Python pseudo-terminal and checking the run reaches +"completed". This is the single most valuable step: it confirms the keystroke +sequence is correct so a section can't run long and leak keystrokes into the next +one (the #1 demo failure). The driver: + +1. `pid, fd = pty.fork()`; in the child `os.execv` the `ags` workflow run. +2. Set a window size: `fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))` — without a size the TUI bails. +3. **Answer the cursor-position query.** crossterm (inline/fullscreen) writes `ESC[6n` (DSR) and waits; if you don't reply it errors with "cursor position could not be read". Whenever the master output contains `b"\x1b[6n"`, write back `b"\x1b[1;1R"`. (plain doesn't query, but answering is harmless.) +4. Start the mock with the demo's routes, `auth login` off-camera first, then feed keystrokes (`\r` Enter, `\t` Tab, `\x13` Ctrl+S, literal chars) with small waits and read between sends. +5. Strip ANSI from the transcript and assert it contains "completed". + +Two gotchas this reveals (and that bite the tape if missed): +- **Briefing screen.** A workflow with a `briefing` shows it FIRST on inline/ + fullscreen (an `[Enter] continue` screen); `plain` skips it. So the tape's first + inline/fullscreen keystroke is `Enter` to dismiss the briefing, *then* the form. +- **Form keymap.** `Enter` begins editing the focused field and commits it; `Tab` + moves to the next field; `Ctrl+S` submits. Default fields are skipped by tabbing + past. In `fullscreen`, the dynamic-enum fields open a picker on `Enter` — type to + filter, `Enter` to select (the options come from the mocked endpoint). A `plain` + run prompts line-by-line only when stdin AND stderr are TTYs (a pipe hits the + no-input precheck), so the pty is required there too. +- **JSON editor (object/array body fields).** A body field that is an object or + array renders as a `JsonBody` form field; `Enter` on it opens the structured JSON + editor. Tree keymap: `↑↓` move, `→` expand / `←` collapse, `Enter` edit a scalar + (toggles a bool / cycles an enum in place), `+`/`−` add/remove an array entry, + `Ctrl+R` raw view, `Ctrl+S` save, `Esc` cancel. Both modes are drivable; the + catch in each: + - **Structured (tree).** `+` adds an entry but leaves focus on the *array root*, + not the new entry — so after `+` you must `Down` onto the entry, `Right` to + expand it, `Down` onto a field, `Enter` to open its scalar editor, `Type`, + `Enter` to commit. (Getting this wrong is why edits "silently miss": you were + editing the wrong node.) Scalar-edit keymap: type a value, `Enter` save, `Esc` + cancel. + - **Raw.** `Ctrl+R` seeds a pretty-printed buffer; the cursor starts *before* + the seed, so `Right` moves past the opening `[`/`{`. `Enter` inserts a newline + (no auto-indent) and `Tab` inserts two spaces, so an inserted entry can be + hand-indented to match the seed's 2-/4-space formatting. Add a trailing `,` + when inserting before an existing entry. `Ctrl+S` saves. (VHS has no + `Home`/`End` keys — use `Right`/`Left`; since the seed's first line is just + `[`, one `Right` already reaches its end.) + After the editor, `Ctrl+S` submits the form. Empty required fields are dropped on + save, so the JSON must be valid or the later submit fails. `demos/auto-surfaces/` + shows both modes (structured entry, then a raw entry) — and `--ui auto` routes + such a command to inline because the missing object/array body sets "has body + field" (see the auto-mode section above). + +Use the pty to read the exact prompt/field order and confirm completion for each +interactive section; then translate the verified key sequence into tape commands +and record. Recording is still where you tune `Sleep` durations and confirm the +visual layout (e.g. inline vs fullscreen distinctness) — but the keystrokes should +already be correct. + +## The authoring loop (workflows / TUI) +The mock returns `404` and logs any unmatched request. After the first recording +attempt, read the mock's stderr log: any logged 404 (commonly a fullscreen dynamic- +enum options fetch) is an endpoint to add to `.routes.json`, then re-record. + +## Response models +Response bodies come from `demos/engine/spec-reader.py`, which reads the bundled +OpenAPI specs (`ags describe` does not expose response schemas — that is a known +gap). Do not hand-write response shapes when the spec-reader can supply them. diff --git a/.gitignore b/.gitignore index 8e06ffe..679dc0f 100644 --- a/.gitignore +++ b/.gitignore @@ -27,12 +27,17 @@ Thumbs.db *.profdata lcov.info +# Brainstorming companion working directory (HTML mockups, session state) +.superpowers/ + # Claude Code workspace — only settings.json and the qa-test skill are tracked .claude/* !.claude/settings.json !.claude/skills/ .claude/skills/* !.claude/skills/qa-test/ +!.claude/skills/vhs-demo/ +!.claude/skills/spec-update/ # Docs — only the reference folder is tracked docs/* diff --git a/CLAUDE.md b/CLAUDE.md index 6cdf2b2..064bf9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,120 +2,197 @@ A Rust CLI that dynamically generates commands from AccelByte's 24 OpenAPI 2.0 specs. -## Source layout +## Workflows + +Workflows are runtime-owned sequences of API operations. A single command +(`ags `) is internally a 1-step synthesised workflow; +`ags workflow run ` runs a registered multi-step workflow. The shared +engine lives in `crates/ags-runtime/src/runtime/workflows/`; built-in +workflows are in `runtime/workflows/builtins/`. See +`docs/private/workflow-protocol.md`. + +## Workspace layout + +Three crates under `crates/`. Dependency direction is strictly `accelbyte-ags-cli → ags-runtime → ags-protocol`. Reverse edges are forbidden. + +### `ags-protocol/` — leaf crate (serde, serde_json, thiserror only) + +Typed protocol contracts shared across crates. + +``` +crates/ags-protocol/src/ +├── lib.rs # Crate root: re-exports every protocol module +├── catalogue.rs # ServiceId, OperationId, ServiceSchema, command catalogue types +├── config.rs # Config operation types +├── diagnostics.rs # Diagnostic check types +├── error.rs # RuntimeError, ErrorMetadata, SuggestionKind +├── event.rs # Runtime event types (progress, lifecycle) +├── output.rs # Structured output envelope +├── output_views.rs # View/payload types attached to command outputs +├── request.rs # API request types, GrantType, CommandFormat +├── result.rs # Operation result types +└── workflow.rs # Workflow contract types: definitions, steps, bindings, inputs, options_source +``` + +### `ags-runtime/` — business logic, depends on ags-protocol + +``` +crates/ags-runtime/ +├── specs/ # 24 gzip-compressed OpenAPI 2.0 specs, bundled via include_bytes! +└── src/ + ├── lib.rs # Crate root: pub mod runtime; catalogue; support; + ├── catalogue/ # OpenAPI spec loading, parsing, caching + │ ├── bundled.rs # Bundled spec loading (include_bytes! + gzip) + │ ├── cache.rs # On-disk parsed-schema cache I/O + │ ├── manifest.rs # 24-service allowlist + display names + descriptions + │ ├── memory_cache.rs # In-process cache of parsed ServiceSchema values + │ ├── openapi.rs # OpenAPI 2.0 (Swagger) wire types used by the parser + │ ├── parser.rs # SwaggerSpec → ServiceSchema (driven by x-operationId) + │ ├── repository.rs # Orchestrates bundled + cache + memory_cache loads + │ └── skeleton.rs # Request body template generation + ├── runtime/ # All business logic and external interaction + │ ├── cleanup.rs # Startup cleanup of stale temp files + │ ├── execution.rs # Top-level command execution coordinator + │ ├── auth/ # OAuth2 flows, credential storage, sessions + │ │ ├── credentials.rs # Client/base URL credential resolution + │ │ ├── errors.rs # AuthError domain type + │ │ ├── locking.rs # Cross-process token lock coordination + │ │ ├── operations.rs # Login, logout, status operations + │ │ ├── session.rs # Access-token lifecycle policy + │ │ ├── store.rs # OS keychain/file token persistence + │ │ └── tokens.rs # OAuth token endpoint types and calls + │ ├── config/ # Configuration management + │ │ ├── environment.rs # AGS_* environment variables and defaults + │ │ ├── errors.rs # Config-layer error helpers + │ │ ├── keys.rs # Config key definitions and validation + │ │ ├── paths.rs # Config and cache path derivation + │ │ └── store.rs # ConfigStore, GlobalConfig, ProfileConfig + │ ├── diagnostics/ # Health checks and troubleshooting + │ │ ├── checks.rs # Individual diagnostic checks + │ │ └── runner.rs # Diagnostic runner and reporting + │ ├── dispatch/ # API call execution and error classification + │ │ ├── classify.rs # HTTP status + error code → user-friendly message + │ │ ├── confirmation.rs # Confirmation rules for risky operations + │ │ ├── error_codes/ # AccelByte error code lookup tables (one file per service) + │ │ ├── execute.rs # Main API call execution pipeline + │ │ ├── http.rs # HTTP client and request execution (incl. network_error helper) + │ │ ├── pagination.rs # Paginated response handling + │ │ ├── path.rs # Path placeholder substitution + │ │ └── shape.rs # Response shape detection and normalization + │ ├── facade/ # High-level orchestration consumed by invocation + │ │ ├── auth.rs # Auth facade + │ │ ├── config.rs # Config facade + │ │ ├── diagnostics.rs # Diagnostics facade + │ │ ├── profile.rs # Profile facade + │ │ └── service.rs # Service call facade + │ └── workflows/ # Workflow engine (data types live in ags-protocol::workflow) + │ ├── synthesised.rs # Build a 1-step workflow from a single CLI command + │ ├── auto_derive.rs # Expand a step's OpenAPI schema into auto-derived input fields + │ ├── compile.rs # WorkflowDefinition → CompiledWorkflow (validates bindings, options_source, nested paths) + │ ├── resolve.rs # Compute inputs still to gather; assemble the dispatch request + │ ├── executor.rs # Drive a compiled workflow: gather → confirm → dispatch → capture + │ ├── options.rs # Dynamic-enum option resolution (run a GET, project response → choices) + │ ├── jsonpath.rs # JSONPath subset for transforms and capture paths + │ ├── nested_path.rs # Parser for nested-field binding paths (`data.x[0].y`) + │ ├── dry_run.rs # Synthesise placeholder step outputs for --dry-run previews + │ └── builtins/ # Registered built-in workflows (competitive_multiplayer.rs) + registry + └── support/ # Shared utilities (also used by frontend) + ├── mod.rs # Time, TTY, and small shared helpers + ├── file_system.rs # Restricted writes, advisory locks, temp cleanup, FileLock + ├── output_sink.rs # Stdout/file destination resolution; OutputSinkError + ├── strings.rs # Naming, sanitization, and display transforms + └── test_helpers.rs # Shared test fixtures (cfg(test)) +``` + +### `accelbyte-ags-cli/` — produces the `ags` binary, depends on both ``` -src/ -├── main.rs # Entry point: SIGPIPE reset, Tokio bootstrap, delegates to invocation::run -├── lib.rs # Library root (for integration tests) -├── catalogue/ # OpenAPI spec loading, parsing, caching -│ ├── bundled.rs # Bundled spec loading (include_bytes! + gzip) -│ ├── cache.rs # On-disk parsed-schema cache I/O -│ ├── manifest.rs # 24-service allowlist + display names + descriptions -│ ├── memory_cache.rs # In-process cache of parsed ServiceSchema values -│ ├── openapi.rs # OpenAPI 2.0 (Swagger) wire types used by the parser -│ ├── parser.rs # SwaggerSpec → ServiceSchema (driven by x-operationId) -│ ├── repository.rs # Orchestrates bundled + cache + memory_cache loads -│ └── skeleton.rs # Request body template generation -├── errors.rs # CliError enum, ErrorMetadata, exit codes -├── invocation/ # CLI layer: flag parsing, command tree, routing -│ ├── builder.rs # Dynamic Clap tree from ServiceSchema -│ ├── errors.rs # Invocation error types -│ ├── flags.rs # GlobalFlags, pre-scan, namespace resolution -│ ├── resolve.rs # Resolves --api-scope/--api-version to a concrete contract -│ ├── router.rs # Page-limit parsing and per-command dispatch -│ └── commands/ # Command handlers -│ ├── auth/ # Auth subcommands -│ │ ├── mod.rs # Login/logout/status dispatch -│ │ └── oauth.rs # OAuth callback server for browser flow -│ ├── completions.rs # Shell completion generation -│ ├── config.rs # Config get/set/unset dispatch -│ ├── describe/ # Machine-readable command introspection -│ │ ├── mod.rs # `ags describe` command dispatch -│ │ └── envelope.rs # JSON envelope shapes for describe output -│ ├── doctor.rs # Diagnostic check dispatch -│ ├── profile.rs # Profile CRUD dispatch -│ ├── refresh_specs.rs # `ags refresh-specs` subcommand dispatch -│ ├── service/ # Dynamic service-command pipeline -│ │ ├── mod.rs # Top-level service handler (parse → dispatch) -│ │ ├── clap_tree.rs # Clap subtree construction for a service -│ │ ├── dispatch.rs # Dry-run, confirmation, and execution -│ │ ├── help.rs # Contextual help rendering -│ │ ├── parser.rs # Args → ParsedServiceCommand -│ │ └── request.rs # ParsedServiceCommand → CommandRequest -│ └── version.rs # Version output dispatch -├── protocol/ # Boundary types between invocation and runtime -│ ├── catalogue.rs # Catalogue query/result types -│ ├── config.rs # Config operation types -│ ├── diagnostics.rs # Diagnostic check types -│ ├── error.rs # Protocol-level error types -│ ├── event.rs # Runtime event types -│ ├── output.rs # Structured output envelope -│ ├── output_views.rs # View/payload types attached to command outputs -│ ├── request.rs # API request types -│ └── result.rs # Operation result types -├── frontend/ # All user-facing output -│ ├── render.rs # Top-level CommandOutput → RenderedOutput dispatch -│ ├── templates.rs # Backend-agnostic response templates -│ ├── presenters/ # Format-neutral presentation helpers -│ │ ├── auth.rs # Auth-source labels, token-state views -│ │ └── service.rs # Dry-run and API-response views -│ ├── human/ # Human-readable frontend -│ │ ├── frontend.rs # impl Frontend for HumanFrontend -│ │ ├── progress.rs # Status lines and spinner helpers -│ │ ├── prompt.rs # Interactive confirmation prompts -│ │ ├── templates.rs # ANSI-applying text adapters -│ │ └── commands/ # Per-command human renderers -│ ├── json/ # Machine-readable JSON frontend -│ │ ├── frontend.rs # impl Frontend for JsonFrontend -│ │ ├── progress.rs # Noop progress sink for JSON mode -│ │ └── commands/ # Per-command JSON emitters -│ ├── style/ # Styling subsystem -│ │ ├── ansi.rs # ANSI backend: colour functions, respects NO_COLOR -│ │ ├── span.rs # StyledSpan / StyledLine IR -│ │ ├── text.rs # Symbol constants (✔ ✖ › …) -│ │ └── tone.rs # Tone enum (semantic style vocabulary) -├── runtime/ # All business logic and external interaction -│ ├── cleanup.rs # Startup cleanup of stale temp files -│ ├── completions.rs # Completion script generation -│ ├── execution.rs # Top-level command execution coordinator -│ ├── auth/ # OAuth2 flows, credential storage, sessions -│ │ ├── credentials.rs # Client/base URL credential resolution -│ │ ├── errors.rs # AuthError domain type -│ │ ├── locking.rs # Cross-process token lock coordination -│ │ ├── operations.rs # Login, logout, status operations -│ │ ├── session.rs # Access-token lifecycle policy -│ │ ├── store.rs # OS keychain/file token persistence -│ │ └── tokens.rs # OAuth token endpoint types and calls -│ ├── config/ # Configuration management -│ │ ├── environment.rs # AGS_* environment variables and defaults -│ │ ├── errors.rs # Config-layer error helpers -│ │ ├── keys.rs # Config key definitions and validation -│ │ ├── paths.rs # Config and cache path derivation -│ │ └── store.rs # ConfigStore, GlobalConfig, ProfileConfig -│ ├── diagnostics/ # Health checks and troubleshooting -│ │ ├── checks.rs # Individual diagnostic checks -│ │ └── runner.rs # Diagnostic runner and reporting -│ ├── dispatch/ # API call execution and error classification -│ │ ├── classify.rs # HTTP status + error code → user-friendly message -│ │ ├── confirmation.rs # Confirmation rules for risky operations -│ │ ├── error_codes.rs # AccelByte error code lookup table -│ │ ├── execute.rs # Main API call execution pipeline -│ │ ├── http.rs # HTTP client and request execution -│ │ ├── pagination.rs # Paginated response handling -│ │ ├── path.rs # Path placeholder substitution -│ │ └── shape.rs # Response shape detection and normalization -│ └── facade/ # High-level orchestration -│ ├── auth.rs # Auth facade -│ ├── config.rs # Config facade -│ ├── diagnostics.rs # Diagnostics facade -│ ├── profile.rs # Profile facade -│ └── service.rs # Service call facade -└── support/ # Shared utilities - ├── file_system.rs # Restricted writes, advisory locks, temp cleanup - ├── mod.rs # Time, TTY, and small shared helpers - ├── output_sink.rs # Stdout/file destination resolution and writes - └── strings.rs # Naming, sanitization, and display transforms +crates/accelbyte-ags-cli/ +├── src/ +│ ├── main.rs # Entry point: SIGPIPE reset, Tokio bootstrap, delegates to invocation::run +│ ├── lib.rs # Library root (for integration tests) +│ ├── errors.rs # CliError enum, ErrorView, exit codes +│ ├── invocation/ # CLI layer: flag parsing, command tree, routing +│ │ ├── builder.rs # Dynamic Clap tree from ServiceSchema +│ │ ├── clap_helpers.rs # Reusable clap value-parser and argument builders +│ │ ├── completions_generator.rs # Completion script generation (clap_complete) +│ │ ├── errors.rs # Invocation error types +│ │ ├── flags.rs # GlobalFlags, pre-scan, namespace resolution +│ │ ├── context.rs # Frontend context: consumer kind + interaction surface resolution +│ │ ├── policy.rs # (route, shape) → base surface decision matrix +│ │ ├── shape.rs # Interaction-shape classification +│ │ ├── workflows.rs # Bridge between parsed CLI commands and the workflow executor +│ │ ├── phase_execution.rs # Shared post-prologue lifecycle for phase-owned runs +│ │ ├── resolve.rs # Resolves --api-scope/--api-version to a concrete contract +│ │ ├── router.rs # Root-route classification + page-limit parsing +│ │ ├── routes/ # Root execution routes +│ │ │ ├── auth/ # `ags auth ...` route ownership + OAuth callback server +│ │ │ ├── service/ # Dynamic service-command route (parse → synthesize → execute) +│ │ │ ├── builtin/ # Root help/version + built-in command route +│ │ │ └── workflow/ # `ags workflow run ...` route ownership +│ │ └── handlers/ # Leaf handlers invoked by the routes +│ │ ├── completions.rs # `ags completions` dispatch +│ │ ├── config.rs # Config get/set/unset dispatch +│ │ ├── describe/ # `ags describe` — machine-readable introspection +│ │ ├── doctor.rs # Diagnostic check dispatch +│ │ ├── profile.rs # Profile CRUD dispatch +│ │ ├── refresh_specs.rs # `ags refresh-specs` subcommand dispatch +│ │ └── version.rs # Version output dispatch +│ └── frontend/ # All user-facing output, split by responsibility +│ ├── mod.rs # Frontend/ExecutionInteraction traits, surface selectors, RenderFormat +│ ├── event.rs # Frontend lifecycle and progress event types +│ ├── sink.rs # FrontendSink: bridges runtime ProgressSink to Frontend events +│ ├── streams.rs # UiSink: stderr-only writes for UI chrome (spinners, prompts, hints) +│ ├── dynamic_options.rs # DynamicOptionResolver trait + ProductionResolver (dynamic-enum picker bridge) +│ ├── output/ # Output-format rendering: dispatch + text serialization +│ │ ├── render.rs # Shared CommandOutput → RenderedOutput dispatch +│ │ ├── templates.rs # Backend-agnostic response templates +│ │ ├── human/ # Human-readable output rendering +│ │ │ ├── templates.rs # ANSI-applying text adapters over core templates +│ │ │ └── commands/ # Per-command human renderers +│ │ └── json/ # Machine-readable JSON output rendering +│ │ ├── frontend.rs # impl Frontend for JsonFrontend +│ │ └── commands/ # Per-command JSON emitters +│ ├── terminal/ # Terminal interaction surfaces +│ │ ├── machine_json.rs # JsonInteraction: JSON workflow contract seam +│ │ ├── form_runner.rs # Surface-agnostic form ↔ JSON-editor ↔ confirm driver (Press-only key reads) +│ │ ├── plain/ # Plain (line-oriented) terminal surface +│ │ │ ├── frontend.rs # impl Frontend for PlainFrontend +│ │ │ ├── interaction.rs # PlainInteraction workflow interaction +│ │ │ ├── progress.rs # Status lines and spinner helpers +│ │ │ └── prompt.rs # Interactive confirmation prompts +│ │ ├── inline/ # Inline (stderr-viewport) TUI surface +│ │ │ ├── frontend.rs # impl Frontend for InlineFrontend +│ │ │ ├── interaction.rs # InlineInteraction workflow interaction +│ │ │ ├── form.rs # Inline form widget (fields, focus, hints) +│ │ │ ├── form_builder.rs # Build form fields from workflow inputs +│ │ │ ├── json_editor/ # Structured JSON request-body editor +│ │ │ ├── lifecycle.rs # Inline-viewport acquisition and teardown +│ │ │ ├── progress_state.rs # Inline-viewport progress state and redraw +│ │ │ ├── session.rs # InlineSession: shared live-terminal handle +│ │ │ └── phases/ # Per-phase inline UI (form, confirm, confirm_card, result) +│ │ └── fullscreen/ # Fullscreen alt-screen workflow TUI (§13 four-region layout) +│ │ ├── frontend.rs # impl Frontend for FullscreenFrontend + dismiss loop +│ │ ├── interaction.rs # FullscreenInteraction: gather/confirm/picker in-layout +│ │ ├── surface.rs # FullscreenSurface: owns the terminal + render model +│ │ ├── lifecycle.rs # Alt-screen acquire/release +│ │ ├── layout.rs # §13 region split (header / main / summary / nav) +│ │ ├── nav.rs # Contextual keymap nav bar +│ │ ├── step_strip.rs # Header step strip +│ │ ├── summary.rs # Summary panel +│ │ ├── support/ # Briefing inline-format (**bold**, `code`) helpers +│ │ └── phases/ # Per-phase main-area widgets: briefing, fields, confirm, +│ │ # enum_picker (dynamic-enum modal), json_edit, running, result, error +│ ├── presenters/ # Format-neutral presentation helpers +│ │ ├── auth.rs # Auth-source labels, token-state views +│ │ └── service.rs # Dry-run and API-response views +│ └── style/ # Styling subsystem +│ ├── ansi.rs # ANSI backend: colour functions, respects NO_COLOR +│ ├── span.rs # StyledSpan / StyledLine IR +│ ├── text.rs # Symbol constants (✔ ✖ › …) +│ └── tone.rs # Tone enum (semantic style vocabulary) +└── tests/ # Integration tests — functional/, integration/, contract_input/, + # contract_output/, snapshot/, security/, performance/, architecture ``` All project conventions, coding rules, design standards, testing, and gotchas are in [CONTRIBUTING.md](CONTRIBUTING.md). Read it before making changes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2676c67..31d0d00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,163 +50,63 @@ cargo run -- refresh-specs # Re-parse every bundled spec ### Regenerating the demo reel -The README GIF is produced from `demo/reel.tape` using [VHS](https://github.com/charmbracelet/vhs) driven against a local mock server. To regenerate after tape or server changes: +The README GIF is produced from `demos/onboarding/onboarding.tape` using [VHS](https://github.com/charmbracelet/vhs) driven against a local mock server. To regenerate after tape or mock changes: ``` brew install vhs # or see charmbracelet/vhs for other platforms -./demo/record.sh +./demos/record.sh onboarding ``` -This rebuilds the CLI in release mode, starts `demo/demo-server.py` on localhost:8765, drives the tape, and writes `demo/reel.gif`. Commit the updated GIF alongside any tape or server changes. +This rebuilds the CLI in release mode, starts `demos/engine/mock-server.py` with the demo's routes on localhost:8765, drives the tape, and writes `demos/onboarding/onboarding.gif`. Commit the updated GIF alongside any tape or routes changes. + +New demos are authored with the `vhs-demo` skill, which grounds the tape and mock in `ags describe`. Each demo lives in `demos//` (`.tape` + `.routes.json`) and records via `./demos/record.sh `. ## Architecture -``` -src/ -├── main.rs # Entry point: SIGPIPE reset, Tokio bootstrap, delegates to invocation::run -├── lib.rs # Library root (for integration tests) -├── catalogue/ # OpenAPI spec loading, parsing, caching -│ ├── bundled.rs # Bundled spec loading (include_bytes! + gzip) -│ ├── cache.rs # On-disk parsed-schema cache I/O -│ ├── manifest.rs # 24-service allowlist + display names + descriptions -│ ├── memory_cache.rs # In-process cache of parsed ServiceSchema values -│ ├── openapi.rs # OpenAPI 2.0 (Swagger) wire types used by the parser -│ ├── parser.rs # SwaggerSpec → ServiceSchema (driven by x-operationId) -│ ├── repository.rs # Orchestrates bundled + cache + memory_cache loads -│ └── skeleton.rs # Request body template generation -├── errors.rs # CliError enum, ErrorMetadata, exit codes -├── invocation/ # CLI layer: flag parsing, command tree, routing -│ ├── builder.rs # Dynamic Clap tree from ServiceSchema -│ ├── errors.rs # Invocation error types -│ ├── flags.rs # GlobalFlags, pre-scan, namespace resolution -│ ├── resolve.rs # Resolves --api-scope/--api-version to a concrete contract -│ ├── router.rs # Page-limit parsing and per-command dispatch -│ └── commands/ # Command handlers -│ ├── auth/ # Auth subcommands -│ │ ├── mod.rs # Login/logout/status dispatch -│ │ └── oauth.rs # OAuth callback server for browser flow -│ ├── completions.rs # Shell completion generation -│ ├── config.rs # Config get/set/unset dispatch -│ ├── describe/ # Machine-readable command introspection -│ │ ├── mod.rs # `ags describe` command dispatch -│ │ └── envelope.rs # JSON envelope shapes for describe output -│ ├── doctor.rs # Diagnostic check dispatch -│ ├── profile.rs # Profile CRUD dispatch -│ ├── refresh_specs.rs # `ags refresh-specs` subcommand dispatch -│ ├── service/ # Dynamic service-command pipeline -│ │ ├── mod.rs # Top-level service handler (parse → dispatch) -│ │ ├── clap_tree.rs # Clap subtree construction for a service -│ │ ├── dispatch.rs # Dry-run, confirmation, and execution -│ │ ├── help.rs # Contextual help rendering -│ │ ├── parser.rs # Args → ParsedServiceCommand -│ │ └── request.rs # ParsedServiceCommand → CommandRequest -│ └── version.rs # Version output dispatch -├── protocol/ # Boundary types between invocation and runtime -│ ├── catalogue.rs # Catalogue query/result types -│ ├── config.rs # Config operation types -│ ├── diagnostics.rs # Diagnostic check types -│ ├── error.rs # Protocol-level error types -│ ├── event.rs # Runtime event types -│ ├── output.rs # Structured output envelope -│ ├── output_views.rs # View/payload types attached to command outputs -│ ├── request.rs # API request types -│ └── result.rs # Operation result types -├── frontend/ # User-facing I/O — output rendering, prompts, progress -│ ├── render.rs # Top-level CommandOutput → RenderedOutput dispatch -│ ├── templates.rs # Backend-agnostic templates (return StyledLine IR) -│ ├── presenters/ # Format-neutral presentation helpers -│ │ ├── auth.rs # Auth-source labels, token-state views -│ │ └── service.rs # Dry-run and API-response views -│ ├── style/ # Semantic tones, styled-line IR, ANSI backend -│ │ ├── ansi.rs # Tone → ANSI, is_stdout/stderr_enabled, init -│ │ ├── span.rs # StyledSpan, StyledLine -│ │ ├── text.rs # Prefix symbol constants -│ │ └── tone.rs # Tone enum (Plain, Dim, Success, …) -│ ├── human/ # Human-readable frontend -│ │ ├── frontend.rs # impl Frontend for HumanFrontend -│ │ ├── progress.rs # StatusLine, StatusLineSink -│ │ ├── prompt.rs # Interactive confirmation prompts -│ │ ├── templates.rs # ANSI-applying text adapters (_text variants) -│ │ └── commands/ # Per-command human renderers -│ │ ├── auth.rs -│ │ ├── completions.rs -│ │ ├── config.rs -│ │ ├── doctor.rs -│ │ ├── profile.rs -│ │ ├── refresh_specs.rs -│ │ ├── service.rs # API response rendering (tables, inspect) -│ │ └── version.rs -│ └── json/ # Machine-readable JSON frontend -│ ├── frontend.rs # impl Frontend for JsonFrontend -│ ├── progress.rs # NoopProgressSink (JSON mode emits no progress) -│ └── commands/ # Per-command JSON emitters -├── runtime/ # All business logic and external interaction -│ ├── cleanup.rs # Startup cleanup of stale temp files -│ ├── completions.rs # Completion script generation -│ ├── execution.rs # Top-level command execution coordinator -│ ├── auth/ # OAuth2 flows, credential storage, sessions -│ │ ├── credentials.rs # Client/base URL credential resolution -│ │ ├── errors.rs # AuthError domain type -│ │ ├── locking.rs # Cross-process token lock coordination -│ │ ├── operations.rs # Login, logout, status operations -│ │ ├── session.rs # Access-token lifecycle policy -│ │ ├── store.rs # OS keychain/file token persistence -│ │ └── tokens.rs # OAuth token endpoint types and calls -│ ├── config/ # Configuration management -│ │ ├── environment.rs # AGS_* environment variables and defaults -│ │ ├── errors.rs # Config-layer error helpers -│ │ ├── keys.rs # Config key definitions and validation -│ │ ├── paths.rs # Config and cache path derivation -│ │ └── store.rs # ConfigStore, GlobalConfig, ProfileConfig -│ ├── diagnostics/ # Health checks and troubleshooting -│ │ ├── checks.rs # Individual diagnostic checks -│ │ └── runner.rs # Diagnostic runner and reporting -│ ├── dispatch/ # API call execution and error classification -│ │ ├── classify.rs # HTTP status + error code → user-friendly message -│ │ ├── confirmation.rs # Confirmation rules for risky operations -│ │ ├── error_codes.rs # AccelByte error code lookup table -│ │ ├── execute.rs # Main API call execution pipeline -│ │ ├── http.rs # HTTP client and request execution -│ │ ├── pagination.rs # Paginated response handling -│ │ ├── path.rs # Path placeholder substitution -│ │ └── shape.rs # Response shape detection and normalization -│ └── facade/ # High-level orchestration -│ ├── auth.rs # Auth facade -│ ├── config.rs # Config facade -│ ├── diagnostics.rs # Diagnostics facade -│ ├── profile.rs # Profile facade -│ └── service.rs # Service call facade -└── support/ # Shared utilities - ├── file_system.rs # Restricted writes, advisory locks, temp cleanup - ├── mod.rs # Time, TTY, and small shared helpers - ├── output_sink.rs # Stdout/file destination resolution and writes - └── strings.rs # Naming, sanitization, and display transforms -``` +### Workspace layout + +This repository is a Cargo workspace with three crates under `crates/`: + +- `ags-protocol` — leaf crate containing the typed protocol contracts (request, result, event, error, output shapes, and the `catalogue` identifier/schema types). It may also hold pure **port traits** shared across crates — behavioural contracts with no logic that the runtime calls and the frontend implements (e.g. `WorkflowFrontend`), so neither side depends on the other. No `tokio`, `reqwest`, `clap`, or other CLI/HTTP deps. Backwards-compatible types only. +- `ags-runtime` — depends on `ags-protocol`. Contains all business logic: command execution, auth, config, diagnostics, dispatch, the OpenAPI catalogue, and the shared `support` utilities (`output_sink`, `file_system`, `strings`, etc.). +- `accelbyte-ags-cli` — depends on both. Produces the `ags` binary. Contains argv parsing (`invocation/`), rendering (`frontend/`), the top-level `CliError`, the `lib.rs`/`main.rs`, and all integration tests under `crates/accelbyte-ags-cli/tests/`. + +Dependency direction is strictly `accelbyte-ags-cli → ags-runtime → ags-protocol`. Reverse edges are forbidden and rejected by `cargo check --workspace`. When adding a new module, place it in the lowest crate that needs to expose it. + +`cargo test --workspace` from the repo root runs everything across all three crates. The `ags` binary builds with `cargo build -p accelbyte-ags-cli` (or `cargo run -p accelbyte-ags-cli -- `). ### Architecture guardrails Each module has a single responsibility. Cross-module imports must follow the allowed dependency directions. -| Module | Responsibility | -|--------|----------------| -| `catalogue` | OpenAPI spec loading, x-operationId-driven parsing, and caching | -| `protocol` | Boundary types shared across modules — request, result, event, error, and output envelopes | -| `invocation` | Turns user input into typed requests — argv parsing, flag extraction, command routing | -| `runtime` | Owns execution — endpoint calls, auth, config, diagnostics, validation, workflow transitions | -| `frontend` | Owns all user-visible formatting — tables, inspect views, JSON output, progress, colour | -| `support` | Small shared utilities — filesystem helpers, string transforms, TTY/time utilities | +| Module | Crate | Responsibility | +|--------|-------|----------------| +| `catalogue` | `ags-runtime` (types re-used from `ags-protocol`) | OpenAPI spec loading, x-operationId-driven parsing, and caching | +| `protocol` | `ags-protocol` | Boundary types shared across crates — request, result, event, error, and output envelopes | +| `invocation` | `accelbyte-ags-cli` | Turns user input into typed requests — argv parsing, flag extraction, command routing | +| `runtime` | `ags-runtime` | Owns execution — endpoint calls, auth, config, diagnostics, validation, workflow transitions | +| `frontend` | `accelbyte-ags-cli` | Owns all user-visible formatting — tables, inspect views, JSON output, progress, colour | +| `support` | `ags-runtime` | Small shared utilities — filesystem helpers, string transforms, TTY/time utilities | **Forbidden dependencies:** +Crate-level (enforced by `cargo check --workspace`): + +- `ags-protocol` must not depend on any other workspace crate +- `ags-runtime` must not depend on `accelbyte-ags-cli` +- `accelbyte-ags-cli` is the only crate allowed to expose `frontend` or `invocation` + +Module-level within a crate: + - `runtime` must not depend on `frontend` or `invocation` -- `frontend` must not depend on `runtime` or `invocation` -- `catalogue` must not depend on any other application module -- `support` must not depend on any other application module +- `frontend` must not depend on `runtime`, `catalogue`, or `invocation`. It may use `support` utilities (`output_sink`, `strings`, TTY/time helpers). The workflow execution port (`WorkflowFrontend`, `WorkflowEvent`, `StepOutcome`, `RunOutcome`, `ResolvedOptions`) lives in `ags-protocol`, so the frontend implements the port without depending on `runtime`. **One sanctioned exception:** the production dynamic-enum resolver (`frontend/dynamic_options.rs::ProductionResolver`) calls `runtime::workflows::resolve_options` to run an interactive option fetch while animating a live spinner on the frontend surface. This is the single place the frontend bridges to `runtime`; it exists because the fetch and the surface spinner are inseparable, and it is kept behind the frontend-owned `DynamicOptionResolver` port +- `catalogue` may import `runtime::config` (path helpers) and `support` (filesystem/string helpers), but must not depend on `frontend` or `invocation` +- `support` must not depend on `runtime`, `catalogue`, `frontend`, or `invocation` **Operational rules:** - `runtime` must not print or prompt — all user-visible output flows as structured data through `frontend` -- `frontend` must not call endpoints, execute commands, or decide what action to take +- `frontend` must not call endpoints, execute commands, or decide what action to take (the one sanctioned exception is the dynamic-enum option fetch noted above) - `invocation` must not own business logic — it builds typed requests and lets `runtime` validate **Required invariants:** @@ -219,10 +119,10 @@ Each module has a single responsibility. Cross-module imports must follow the al | File | Purpose | |------|---------| | `scripts/generate_cli_command_catalogue.py` | **Canonical Python reference** for the command catalogue. Rust must match its output exactly | -| `src/runtime/dispatch/error_codes.rs` | AccelByte error code lookup table | -| `src/runtime/dispatch/classify.rs` | Error classification pipeline: error code → HTTP status → user-friendly message | -| `specs/*.json.gz` | All 24 gzip-compressed OpenAPI 2.0 specs bundled into the binary via `include_bytes!` | -| `tests/fixtures/baselines/_input_contract.json` | Per-service Python-generated reference data for parser validation | +| `crates/ags-runtime/src/runtime/dispatch/error_codes/` | AccelByte error code lookup tables (one file per service) | +| `crates/ags-runtime/src/runtime/dispatch/classify.rs` | Error classification pipeline: error code → HTTP status → user-friendly message | +| `crates/ags-runtime/specs/*.json.gz` | All 24 gzip-compressed OpenAPI 2.0 specs bundled into the binary via `include_bytes!` | +| `crates/accelbyte-ags-cli/tests/fixtures/baselines/_input_contract.json` | Per-service Python-generated reference data for parser validation | ## Reference docs @@ -269,19 +169,19 @@ Scopes match the top-level module structure: | Scope | Maps to | |-------|---------| -| `catalogue` | `src/catalogue/` — spec loading, parsing, manifest, caching | -| `invocation` | `src/invocation/` — CLI tree, flags, command handlers | -| `protocol` | `src/protocol/` — boundary types between invocation and runtime | -| `frontend` | `src/frontend/` — human/JSON frontends, progress, style, templates, command renderers | -| `runtime` | `src/runtime/` — auth, config, diagnostics, dispatch, facade | -| `support` | `src/support/` — file_system, strings, shared helpers | +| `catalogue` | `crates/ags-runtime/src/catalogue/` — spec loading, parsing, manifest, caching | +| `invocation` | `crates/accelbyte-ags-cli/src/invocation/` — CLI tree, flags, command handlers | +| `protocol` | `crates/ags-protocol/src/` — boundary types shared across crates | +| `frontend` | `crates/accelbyte-ags-cli/src/frontend/` — human/JSON frontends, progress, style, templates, command renderers | +| `runtime` | `crates/ags-runtime/src/runtime/` — auth, config, diagnostics, dispatch, facade | +| `support` | `crates/ags-runtime/src/support/` — file_system, strings, shared helpers | For root-level or cross-cutting changes: | Scope | When to use | |-------|-------------| -| `errors` | `src/errors.rs` | -| `specs` | `specs/` — bundled OpenAPI specs | +| `errors` | `crates/accelbyte-ags-cli/src/errors.rs` | +| `specs` | `crates/ags-runtime/specs/` — bundled OpenAPI specs | Omit the scope only when a commit genuinely spans the entire codebase (for example `chore: bump MSRV to 1.85`). @@ -347,6 +247,23 @@ Test modules (`#[cfg(test)] mod tests`) sit at the bottom of the file. Within an - Only add an inline comment when the code does something unexpected, counter-intuitive, or requires context the reader cannot derive from the code alone - Do not narrate what the code is doing step by step +### Terminal input (TUI) + +- **Act on key *presses* only.** Windows emits both a Press and a Release `KeyEvent` for every keystroke, so any reader that doesn't filter will register each input twice (a double-typed character, a double-submitted Enter). macOS/Linux only send Press, so this bug is invisible there — it must be guarded proactively. +- Prefer reading keys through `frontend::terminal::form_runner::crossterm_next_key`, which already returns only `KeyEventKind::Press` events. +- Any loop that calls `crossterm::event::read()` directly must skip non-press events before acting on them: + + ```rust + if let Ok(Event::Key(key)) = event::read() { + if key.kind != crossterm::event::KeyEventKind::Press { + continue; + } + // … handle the keystroke + } + ``` + + Existing direct readers that follow this: the fullscreen dismiss loop (`fullscreen/frontend.rs`), the inline phase loop (`inline/phases/mod.rs`), and the dynamic-enum spinner loop (`dynamic_options.rs`). + ### Security - **Path parameters**: Always use `strings::encode_url_path_segment()` when interpolating user input into URL path segments. Never use raw `str::replace` with unvalidated values. @@ -369,12 +286,19 @@ cargo test --test snapshot # Snapshot tests cargo test --test security # Security tests cargo test --test performance # Performance tests (debug thresholds) cargo test --release --test performance -- --ignored # Performance tests (release thresholds) -cargo test --release --lib catalogue # Parser/catalogue release-mode fallback paths +cargo test --release -p ags-runtime --lib catalogue # Parser/catalogue release-mode fallback paths ``` -> **Note:** Some parser and catalogue tests are gated on `#[cfg(not(debug_assertions))]` and only run under `cargo test --release`. These cover the graceful-fallback paths for unsupported HTTP verbs, parameter locations, and value types. Run `cargo test --release --lib catalogue` when modifying parser error-handling code. +> **Note:** Some parser and catalogue tests are gated on `#[cfg(not(debug_assertions))]` and only run under `cargo test --release`. These cover the graceful-fallback paths for unsupported HTTP verbs, parameter locations, and value types. Run `cargo test --release -p ags-runtime --lib catalogue` when modifying parser error-handling code. + +The input contract tests loop over all 24 services. For each one they load the bundled spec, parse it via `parser::parse_spec`, and compare the result against the per-service baseline at `crates/accelbyte-ags-cli/tests/fixtures/baselines/_input_contract.json`. The comparison is split into two tests: + +- **`test_no_breaking_changes`** — a one-way gate. Every resource, operation, parameter (`name`/`location`/`required`), `http_method`, `path`, and `has_request_body` recorded in the baseline must still be present and unchanged in the parse. Additions (new operations or parameters) are **tolerated**; removals or mutations **fail**. A failure here is a genuine breaking change — **do not regenerate the baselines to silence it**. +- **`test_baseline_is_current`** — the freshness check. Any divergence from the baseline (including additions and summary changes) fails, signalling the baseline is stale. This is the only signal that should prompt regeneration — and only **after** `test_no_breaking_changes` is green: -The input contract test loops over all 24 services. For each one it loads the bundled spec, parses it via `parser::parse_spec`, and compares the result against the per-service baseline at `tests/fixtures/baselines/_input_contract.json`. Any drift from the Python reference causes the test to fail. + ```bash + python3 scripts/generate_cli_command_catalogue.py --emit-baselines crates/accelbyte-ags-cli/tests/fixtures/baselines + ``` ### Updating snapshots @@ -405,10 +329,127 @@ The command catalogue and test baseline are generated from the raw OpenAPI specs python3 scripts/generate_cli_command_catalogue.py > docs/reference/cli-command-catalogue.md # Regenerate the per-service test baseline fixtures (JSON) -python3 scripts/generate_cli_command_catalogue.py --emit-baselines tests/fixtures/baselines/ +python3 scripts/generate_cli_command_catalogue.py --emit-baselines crates/accelbyte-ags-cli/tests/fixtures/baselines/ ``` -The Rust parser is tested against the per-service baselines to ensure it matches the Python reference exactly. If you change parser semantics in `src/catalogue/parser.rs` (or string helpers in `src/support/strings.rs`), regenerate the baselines and verify the diffs are intentional. +The Rust parser is tested against the per-service baselines to ensure it matches the Python reference exactly. If you change parser semantics in `crates/ags-runtime/src/catalogue/parser.rs` (or string helpers in `crates/ags-runtime/src/support/strings.rs`), regenerate the baselines and verify the diffs are intentional. + +## Adding a Workflow + +Workflows are runtime-owned sequences of API operations defined in +`crates/ags-runtime/src/runtime/workflows/`. Follow the steps below when +adding a new built-in workflow. Use `builtins/competitive_multiplayer.rs` as +the worked example throughout. + +### Location + +Create the workflow in a new file at +`crates/ags-runtime/src/runtime/workflows/builtins/.rs`, where +`` is the workflow's id in kebab-case (for example +`competitive-multiplayer` → `competitive_multiplayer.rs` — note the +underscore in the filename, kebab in the id). + +### Structure + +Each workflow file contains: + +- A `struct` holding a single `definition: WorkflowDefinition` field. +- An `impl Workflow` that returns `&self.definition` from `definition()`. +- A `fn build_definition() -> WorkflowDefinition` that constructs the + complete literal definition. Keep this function private; `new()` calls it. +- Small private helpers that mirror those in `competitive_multiplayer.rs`: + - `literal(value: Value) -> BindingSource` — wraps a JSON value in a + non-sensitive `LiteralBinding`. + - `workflow_ref(input: &str) -> BindingSource` — produces a + `from: workflow/` reference binding. + - `bind(field: &str, source: BindingSource) -> StepInputBinding` — pairs + a field name with its source. + - `step(id, description, service, operation, dependencies, inputs) -> StepDefinition` — + builds one step with `confirm: false` and no captured outputs by default. + - `input(name, description, required, default, schema) -> WorkflowInputSpec` — + declares one workflow-level input. + - `input_with_options(name, description, required, default, schema, options_source) -> WorkflowInputSpec` — + declares a **dynamic-enum** input whose choices are fetched at runtime. The + `OptionsSource` names a read-only (GET) catalogue operation, its parameter + bindings, and JSONPath projections (`items_path` → the array, `value` → each + choice's value, optional `label` → its display text). This is an interactive + affordance only: the fullscreen Phase-1 form renders it as a type-to-filter + modal picker, while non-interactive and `--format json` runs treat the input + as a plain string. `compile_workflow` validates the source (operation exists, + is GET, paths parse). See `fleetImageId` / `fleetRegion` / `fleetInstanceId` + in `competitive_multiplayer.rs`. + +If your workflow needs step-output capture, extend the `outputs` field on +the relevant `StepDefinition` directly rather than adding a helper. + +### Registration + +Add a `registry.register(...)` call inside `register_builtins` in +`crates/ags-runtime/src/runtime/workflows/builtins/mod.rs`. Also add the +module declaration (`pub mod ;`) at the top of that file. +`registry()` populates the process-wide registry through a `OnceLock` on +first access; `register_builtins` is the init closure. + +### Bindings can target nested fields + +A `StepInputBinding` binds one operation field to one source. The field is +usually a top-level path parameter, query parameter, or body field, but +**nested-field paths are supported** via the wire form +`field: "data.matching_rule[0].attribute"` — parsed by +`workflows/nested_path.rs` and validated against the operation schema by +`compile_workflow`. To supply a whole free-form object in one go, bind the +top-level field as a single `literal(json!({...}))` value. + +### References + +Two reference forms are available: + +- `workflow_ref("input-name")` — re-uses a workflow-level input, declared in + the `inputs` vec of `WorkflowDefinition`. +- For step-output references, construct the binding directly using + `BindingSource::Reference(ReferenceBinding { from: ReferenceTarget::Step { id }, output, transform })`. + The `from: step/` source re-uses a captured output from an earlier step; + the earlier step must declare that capture in its `outputs` vec. No helper + function for this form exists in the built-in module — add one if your + workflow uses step outputs extensively. + +### Required fields + +Every required operation field must either be bound in `inputs` or will +auto-derive into a gather slot (the runtime will prompt the user or fail +under `--no-input`). The `compile_workflow` test (below) catches unbound +required fields, so a failing test is a reliable signal that a binding is +missing. + +### `confirm: true` + +Set `confirm: true` on a step only for risky operations — deletes, bans, or +irreversible mutations. This follows the same policy as the `requires_confirmation` +keyword in the dispatch layer. All steps in `competitive_multiplayer.rs` use +`confirm: false` because they are non-risky creates and updates. Introduce +`confirm: true` deliberately and document why in an inline comment. + +### Testing + +Add three tests inside a `#[cfg(test)] mod tests` block at the bottom of +your workflow file: + +1. **Compile test** — calls `compile_workflow(workflow.definition(), &mut Catalogue::new())` + and asserts it succeeds, then checks the returned step ids match the + expected order. See `test_competitive_multiplayer_compiles` for the + pattern. + +2. **No unbound required fields** — iterates `compiled.steps`, filters + `step.auto_derived` to entries where `f.required` is true, and asserts the + slice is empty for every step. See `test_no_required_field_left_unbound` + for the pattern. + +3. **Offline dry-run end-to-end test** — add an integration test file under + `crates/accelbyte-ags-cli/tests/functional/` that runs the workflow with + `--dry-run` and all required inputs supplied, and asserts the expected + output (step previews, no network calls). Register it in + `crates/accelbyte-ags-cli/tests/functional.rs` via a `#[path = ...]` + attribute so it is picked up by `cargo test --test functional`. ## Key Design Decisions diff --git a/Cargo.lock b/Cargo.lock index 79f52d3..ad648c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,6 +6,8 @@ version = 4 name = "accelbyte-ags-cli" version = "0.3.0" dependencies = [ + "ags-protocol", + "ags-runtime", "anstream", "anyhow", "assert_cmd", @@ -13,16 +15,14 @@ dependencies = [ "base64", "clap", "clap_complete", - "dirs", + "crossterm", "filetime", - "flate2", - "fs4", "insta", - "keyring", "libc", - "percent-encoding", + "portable-pty", "predicates", "rand", + "ratatui", "regex", "reqwest", "rpassword", @@ -31,12 +31,11 @@ dependencies = [ "serial_test", "sha2", "tempfile", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-test", "url", "wiremock", - "zeroize", ] [[package]] @@ -45,6 +44,43 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ags-protocol" +version = "0.3.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "ags-runtime" +version = "0.3.0" +dependencies = [ + "ags-protocol", + "anyhow", + "async-trait", + "base64", + "dirs", + "filetime", + "flate2", + "fs4", + "keyring", + "percent-encoding", + "regex", + "reqwest", + "serde", + "serde_json", + "serial_test", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-test", + "url", + "wiremock", + "zeroize", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -54,6 +90,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "1.0.0" @@ -106,9 +148,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "assert-json-diff" @@ -164,6 +206,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -208,6 +256,21 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.57" @@ -224,6 +287,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -285,6 +354,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "console" version = "0.15.11" @@ -341,6 +424,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -351,6 +459,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -403,7 +545,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -417,6 +559,18 @@ dependencies = [ "syn", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -436,7 +590,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -445,6 +599,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.27" @@ -675,6 +840,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -891,6 +1058,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -924,6 +1097,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "insta" version = "1.46.3" @@ -939,6 +1121,19 @@ dependencies = [ "tempfile", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -961,6 +1156,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -1006,9 +1210,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" @@ -1016,7 +1220,7 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ - "bitflags", + "bitflags 2.11.0", "libc", "plain", "redox_syscall 0.7.4", @@ -1028,7 +1232,7 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e" dependencies = [ - "bitflags", + "bitflags 2.11.0", "libc", ] @@ -1065,6 +1269,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1094,10 +1307,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -1164,6 +1390,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1231,6 +1463,27 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1305,14 +1558,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -1333,7 +1586,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1345,7 +1598,7 @@ version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "once_cell", "socket2", @@ -1403,13 +1656,34 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.11.0", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -1418,7 +1692,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -1429,7 +1703,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1515,13 +1789,13 @@ dependencies = [ [[package]] name = "rpassword" -version = "7.4.0" +version = "7.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +checksum = "5ac5b223d9738ef56e0b98305410be40fa0941bf6036c56f1506751e43552d64" dependencies = [ "libc", "rtoolbox", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1546,7 +1820,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -1559,11 +1833,11 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1640,7 +1914,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -1653,7 +1927,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -1731,6 +2005,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serial2" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "serial_test" version = "3.4.0" @@ -1768,12 +2053,49 @@ dependencies = [ "digest", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1824,12 +2146,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1877,7 +2227,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1886,13 +2236,33 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2025,7 +2395,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-util", "http", @@ -2092,6 +2462,35 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -2263,7 +2662,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -2298,6 +2697,28 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -2469,6 +2890,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "wiremock" version = "0.6.5" @@ -2550,7 +2980,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index 0c3ba7d..99bf2ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,26 +1,13 @@ -[package] -name = "accelbyte-ags-cli" -version = "0.3.0" -edition = "2021" -description = "Unified CLI for AccelByte Gaming Services" -license = "MIT" -rust-version = "1.84" -repository = "https://github.com/AccelByte/accelbyte-ags-cli" -homepage = "https://accelbyte.io/gaming-services" -readme = "README.md" -keywords = ["accelbyte", "cli", "gaming"] -categories = ["command-line-utilities"] -publish = false - -[lib] -name = "ags" -path = "src/lib.rs" +[workspace] +members = ["crates/accelbyte-ags-cli", "crates/ags-protocol", "crates/ags-runtime"] +resolver = "2" -[[bin]] -name = "ags" -path = "src/main.rs" +[workspace.package] +version = "0.3.0" -[dependencies] +[workspace.dependencies] +ags-protocol = { path = "crates/ags-protocol" } +ags-runtime = { path = "crates/ags-runtime" } tokio = { version = "1", features = ["full"] } clap = { version = "4", features = ["derive", "string"] } clap_complete = "4" @@ -44,11 +31,9 @@ zeroize = { version = "1", features = ["derive"] } tempfile = "3" fs4 = "0.8" anstream = "1" - -[target.'cfg(unix)'.dependencies] +crossterm = "0.28" libc = "0.2" - -[dev-dependencies] +ratatui = { version = "0.29", default-features = false, features = ["crossterm"] } assert_cmd = "2" predicates = "3" insta = { version = "1", features = ["json", "redactions", "yaml"] } @@ -56,3 +41,4 @@ wiremock = "0.6" tokio-test = "0.4" serial_test = "3" filetime = "0.2" +portable-pty = "0.9" diff --git a/README.md b/README.md index bf9dc4e..7a57bf3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ AGS CLI is a unified command-line interface for AccelByte Gaming Services. Manage players, entitlements, inventories, sessions, and other live-service workflows from your terminal, scripts, or AI agents. -![AGS CLI demo](demo/reel.gif) +![AGS CLI demo](demos/onboarding/onboarding.gif) ## Install @@ -127,12 +127,16 @@ Use `ags describe` for machine-readable introspection: ```bash ags describe ags describe iam users get +ags describe workflow +ags describe workflow competitive-multiplayer ``` ### Request bodies Operations that take a body accept `--json ''` or `--json @path/to/body.json`. Use `--skeleton` to print a starter template you can edit. +> **PowerShell:** quote the `@` form — `--json '@body.json'` — otherwise PowerShell treats the leading `@` as a splatting operator and the file is not read. + ### Global flags
@@ -237,6 +241,7 @@ When the OS keychain is unavailable or `AGS_NO_KEYCHAIN=1` is set, tokens fall b ```bash ags auth status +ags auth refresh # re-mint the access token from stored credentials ags auth logout ags auth logout --all # clear credentials from all profiles ``` @@ -366,6 +371,35 @@ AGS CLI covers every AccelByte Gaming Services API. Run `ags --help` for the liv
+## Workflows + +A *workflow* is a multi-step operation: it chains several API calls, passing +values from one step to the next. Run one with: + + ags workflow run [-- ]… + +Each workflow declares its own inputs as `--` flags; run with `--help` +to list them: + + ags workflow run competitive-multiplayer --help + +`competitive-multiplayer` is the bundled example: it stands up competitive +matchmaking with dedicated servers (skill stat → ruleset → session template +→ match pool → AMS fleet → fleet wiring). Its required inputs are +`--namespace`, `--fleet-image-id`, `--fleet-region`, and `--fleet-instance-id`; +the resource names default to `ranked-*` (override with `--resource-prefix`). +When run interactively, the AMS image, region, and instance-type fields are +runtime-fetched pickers — type to filter, ↑/↓ to scroll, Enter to select. + +Single commands (`ags `) are internally +1-step workflows — this is invisible in normal use. Add `--dry-run` to preview a +workflow without calling the API, and `--no-input` to fail instead of +prompting for missing inputs. Under `--format json` a workflow runs +non-interactively: supply every input as a flag (and `--yes` for any +confirm-gated step), and the result is emitted as a JSON envelope. + +List registered workflows with `ags workflow list` (human) or `ags describe workflow` (machine-readable). + ## Contributing Build, test, and contribution instructions live in [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/crates/accelbyte-ags-cli/Cargo.toml b/crates/accelbyte-ags-cli/Cargo.toml new file mode 100644 index 0000000..a8150e4 --- /dev/null +++ b/crates/accelbyte-ags-cli/Cargo.toml @@ -0,0 +1,59 @@ +[package] +name = "accelbyte-ags-cli" +version.workspace = true +edition = "2021" +description = "Unified CLI for AccelByte Gaming Services" +license = "MIT" +rust-version = "1.85" +repository = "https://github.com/AccelByte/accelbyte-ags-cli" +homepage = "https://accelbyte.io/gaming-services" +readme = "README.md" +keywords = ["accelbyte", "cli", "gaming"] +categories = ["command-line-utilities"] +publish = false + +[lib] +name = "ags" +path = "src/lib.rs" + +[[bin]] +name = "ags" +path = "src/main.rs" + +[dependencies] +ags-protocol = { workspace = true } +ags-runtime = { workspace = true } +tokio = { workspace = true } +clap = { workspace = true } +clap_complete = { workspace = true } +crossterm = { workspace = true } +reqwest = { workspace = true } +ratatui = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tempfile = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +rpassword = { workspace = true } +base64 = { workspace = true } +sha2 = { workspace = true } +rand = { workspace = true } +url = { workspace = true } +anstream = { workspace = true } + +[target.'cfg(unix)'.dependencies] +libc = { workspace = true } + +[dev-dependencies] +ags-runtime = { workspace = true } +async-trait = { workspace = true } +assert_cmd = { workspace = true } +predicates = { workspace = true } +insta = { workspace = true } +wiremock = { workspace = true } +tokio-test = { workspace = true } +serial_test = { workspace = true } +filetime = { workspace = true } +tempfile = { workspace = true } +regex = { workspace = true } +portable-pty = { workspace = true } diff --git a/src/errors.rs b/crates/accelbyte-ags-cli/src/errors.rs similarity index 96% rename from src/errors.rs rename to crates/accelbyte-ags-cli/src/errors.rs index 0c63be2..def9bba 100644 --- a/src/errors.rs +++ b/crates/accelbyte-ags-cli/src/errors.rs @@ -1,6 +1,6 @@ //! Error types, metadata, and exit code mapping. -pub use crate::protocol::error::{ErrorMetadata, SuggestionKind}; +pub use ags_protocol::error::{ErrorMetadata, SuggestionKind}; /// Top-level error enum that maps each failure category to a distinct exit code #[derive(Debug, thiserror::Error)] @@ -58,7 +58,7 @@ pub struct ErrorView { pub suggestion_kind: SuggestionKind, pub tip: Option, pub exit_code: i32, - pub trace: Option>, + pub trace: Option>, } impl CliError { @@ -107,9 +107,9 @@ impl CliError { } } -impl From for CliError { - fn from(error: crate::protocol::error::RuntimeError) -> Self { - use crate::protocol::error::RuntimeErrorKind; +impl From for CliError { + fn from(error: ags_protocol::error::RuntimeError) -> Self { + use ags_protocol::error::RuntimeErrorKind; let suggestion_kind = error .details @@ -263,7 +263,7 @@ mod tests { mod runtime_error_conversion { use super::*; - use crate::protocol::error::{RuntimeError, RuntimeErrorKind}; + use ags_protocol::error::{RuntimeError, RuntimeErrorKind}; /// Build a placeholder `RuntimeError` so each test only varies the kind it cares about. fn make(kind: RuntimeErrorKind) -> RuntimeError { @@ -350,7 +350,7 @@ mod tests { /// diagnostic block on error paths under `--verbose`. #[test] fn test_runtime_error_trace_propagates_to_error_view() { - use crate::protocol::output_views::{ + use ags_protocol::output_views::{ ExecutionTrace, RequestTrace, ResolutionTrace, ResponseTrace, }; diff --git a/crates/accelbyte-ags-cli/src/frontend/dynamic_options.rs b/crates/accelbyte-ags-cli/src/frontend/dynamic_options.rs new file mode 100644 index 0000000..16fa7fd --- /dev/null +++ b/crates/accelbyte-ags-cli/src/frontend/dynamic_options.rs @@ -0,0 +1,458 @@ +//! Synchronous resolver seam the interactive Phase-1 form calls when a +//! dynamic-enum field is activated. The form depends only on this sync trait +//! and is agnostic to how resolution happens: the production impl bridges to +//! the async runtime resolver off-thread (Task 15); tests inject a canned +//! double with no I/O. + +use std::collections::BTreeMap; + +use ags_protocol::workflow::{OptionsSource, ResolvedOptions}; + +use crate::errors::CliError; + +/// Resolve a dynamic-enum field's choices. Returning `Err` (or a cancellation, +/// surfaced as `Err`) leaves the field on its free-text fallback; it is never +/// fatal to the run. +pub trait DynamicOptionResolver { + /// Resolve a dynamic-enum field's choices from `source` and the current + /// `inputs`. + fn resolve( + &self, + source: &OptionsSource, + inputs: &BTreeMap, + ) -> Result; +} + +// ── Async fetch seam (inline picker) ─────────────────────────────────────── +// +// The sync `DynamicOptionResolver` cannot be `Handle::spawn`ed (no `Send`; the +// canned double holds `RefCell`). The inline picker sub-loop needs a fetch it +// can start, poll, and abort while it animates the spinner through the terminal +// it already holds, so it uses this seam over the async `resolve_options`. + +/// A started, pollable, abortable options fetch. +pub struct FetchTask { + /// Receives the fetch result exactly once. + pub rx: mpsc::Receiver>, + /// Production tasks carry a join handle so the caller can abort the real + /// fetch on cancel; the test double leaves this `None`. + abort: Option>, +} + +impl FetchTask { + /// Abort the in-flight fetch (best-effort). No-op for the test double. + pub fn abort(&mut self) { + if let Some(handle) = self.abort.take() { + handle.abort(); + } + } +} + +/// Start an options fetch. Production spawns `resolve_options`; tests return a +/// ready (or perpetually-pending) channel with no I/O. +pub trait OptionsFetch { + fn start( + &self, + source: &OptionsSource, + inputs: &BTreeMap, + ) -> FetchTask; +} + +/// Production fetch: spawns `resolve_options` on the async runtime and returns +/// its join handle (for `abort`) plus the result receiver. Mirrors the spawn in +/// `ProductionResolver::resolve` (lines 74-84) but without any drawing. +pub struct RuntimeOptionsFetch { + runtime: Arc>, + handle: Handle, +} + +impl RuntimeOptionsFetch { + pub fn new(runtime: Runtime, handle: Handle) -> Self { + Self { + runtime: Arc::new(tokio::sync::Mutex::new(runtime)), + handle, + } + } +} + +impl OptionsFetch for RuntimeOptionsFetch { + fn start( + &self, + source: &OptionsSource, + inputs: &BTreeMap, + ) -> FetchTask { + let runtime = Arc::clone(&self.runtime); + let source = source.clone(); + let inputs = inputs.clone(); + let (tx, rx) = + mpsc::channel::>(); + let join = self.handle.spawn(async move { + let mut guard = runtime.lock().await; + let result = resolve_options(&mut guard, &source, &inputs).await; + let _ = tx.send(result); + }); + FetchTask { + rx, + abort: Some(join), + } + } +} + +#[cfg(test)] +pub struct CannedFetch { + result: std::cell::RefCell>>, +} + +#[cfg(test)] +impl CannedFetch { + pub fn ok(choices: Vec) -> Self { + Self { + result: std::cell::RefCell::new(Some(Ok(ResolvedOptions { + choices, + truncated: false, + }))), + } + } + + /// A fetch that never delivers — for exercising Esc/abort. + pub fn pending() -> Self { + Self { + result: std::cell::RefCell::new(None), + } + } +} + +#[cfg(test)] +impl OptionsFetch for CannedFetch { + fn start( + &self, + _source: &OptionsSource, + _inputs: &BTreeMap, + ) -> FetchTask { + let (tx, rx) = mpsc::channel(); + // Decide forget-vs-drop at the branch point: an `ok()` double sends and + // then drops `tx` (channel closes, a second recv sees Disconnected); a + // `pending()` double leaks `tx` so the receiver keeps reporting Empty, + // matching an in-flight production fetch. + match self.result.borrow_mut().take() { + Some(result) => { + let _ = tx.send(result); + } + None => std::mem::forget(tx), + } + FetchTask { rx, abort: None } + } +} + +// ── Production resolver ──────────────────────────────────────────────────── + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::mpsc; +use std::sync::Arc; +use std::time::Duration; + +use ags_runtime::runtime::workflows::resolve_options; +use ags_runtime::runtime::Runtime; +use tokio::runtime::Handle; + +use crate::frontend::terminal::fullscreen::surface::FullscreenSurface; + +use crate::frontend::terminal::dynamic_enums::SPINNER_FRAMES; + +/// Production resolver: bridges the sync trait to the async runtime resolver via +/// `Handle::spawn` + a blocking `mpsc` poll loop. The cloned `Runtime` is the +/// resolver's private one (never the executor's). The poll loop animates a +/// spinner through the shared surface and polls crossterm for Esc / Ctrl-C so a +/// slow/hung fetch is cancellable. +pub struct ProductionResolver { + runtime: Arc>, + handle: Handle, + surface: Rc>, +} + +impl ProductionResolver { + /// Build a production resolver bound to a private `runtime` clone, the async + /// `handle` it spawns fetches on, and the shared fullscreen surface it + /// animates the spinner through. + pub fn new(runtime: Runtime, handle: Handle, surface: Rc>) -> Self { + Self { + runtime: Arc::new(tokio::sync::Mutex::new(runtime)), + handle, + surface, + } + } +} + +impl DynamicOptionResolver for ProductionResolver { + fn resolve( + &self, + source: &OptionsSource, + inputs: &BTreeMap, + ) -> Result { + use crossterm::event::{self, Event, KeyCode, KeyModifiers}; + + let runtime = Arc::clone(&self.runtime); + let source = source.clone(); + let inputs = inputs.clone(); + let (tx, rx) = + mpsc::channel::>(); + + let join = self.handle.spawn(async move { + let mut guard = runtime.lock().await; + let result = resolve_options(&mut guard, &source, &inputs).await; + let _ = tx.send(result); + }); + + let mut frame = 0usize; + loop { + match rx.try_recv() { + Ok(Ok(resolved)) => { + self.surface.borrow_mut().set_options_loading(None); + return Ok(resolved); + } + Ok(Err(err)) => { + self.surface.borrow_mut().set_options_loading(None); + return Err(err.into()); + } + Err(mpsc::TryRecvError::Disconnected) => { + self.surface.borrow_mut().set_options_loading(None); + return Err(CliError::Usage { + message: "options fetch ended unexpectedly".into(), + metadata: None, + }); + } + Err(mpsc::TryRecvError::Empty) => {} + } + + { + let mut s = self.surface.borrow_mut(); + s.set_options_loading(Some(format!( + "Loading choices… {}", + SPINNER_FRAMES[frame % SPINNER_FRAMES.len()] + ))); + let _ = s.render(); + } + frame += 1; + + if event::poll(Duration::from_millis(80)).unwrap_or(false) { + if let Ok(Event::Key(k)) = event::read() { + // Windows emits Press + Release per keystroke; act on Press + // only so a key release doesn't spuriously cancel the fetch. + if k.kind != crossterm::event::KeyEventKind::Press { + continue; + } + let cancel = k.code == KeyCode::Esc + || (k.code == KeyCode::Char('c') + && k.modifiers.contains(KeyModifiers::CONTROL)); + if cancel { + join.abort(); + self.surface.borrow_mut().set_options_loading(None); + return Err(CliError::Usage { + message: "options fetch cancelled".into(), + metadata: None, + }); + } + } + } + } + } +} + +// ── Test double ──────────────────────────────────────────────────────────── + +/// Test double: returns a scripted result with no I/O and no spawn. The form↔ +/// picker flow is fully unit-testable with this + `TestBackend` + scripted keys. +#[cfg(test)] +pub struct CannedResolver { + pub result: std::cell::RefCell>, + pub calls: std::cell::RefCell, +} + +#[cfg(test)] +impl CannedResolver { + pub fn ok(choices: Vec) -> Self { + Self { + result: std::cell::RefCell::new(Ok(ResolvedOptions { + choices, + truncated: false, + })), + calls: std::cell::RefCell::new(0), + } + } +} + +#[cfg(test)] +impl DynamicOptionResolver for CannedResolver { + fn resolve( + &self, + _source: &OptionsSource, + _inputs: &BTreeMap, + ) -> Result { + *self.calls.borrow_mut() += 1; + match &*self.result.borrow() { + Ok(r) => Ok(r.clone()), + Err(_) => Err(CliError::Usage { + message: "canned error".into(), + metadata: None, + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ags_protocol::workflow::{OperationReference, OptionChoice, OptionsSource}; + + fn dummy_source() -> OptionsSource { + OptionsSource { + operation: OperationReference { + service: ags_protocol::catalogue::ServiceId::new("ams"), + operation: ags_protocol::catalogue::OperationId::new("ams/admin/images/v1/list"), + }, + parameters: BTreeMap::new(), + items_path: "$.images".into(), + value: "$.id".into(), + label: None, + label_detail: None, + fallback_description: None, + filter: None, + } + } + + #[test] + fn test_canned_resolver_returns_choices_and_counts_calls() { + let resolver = CannedResolver::ok(vec![OptionChoice { + label: "Prod".into(), + value: "img-1".into(), + }]); + let out = resolver.resolve(&dummy_source(), &BTreeMap::new()).unwrap(); + assert_eq!(out.choices.len(), 1); + assert_eq!(*resolver.calls.borrow(), 1); + } +} + +#[cfg(test)] +mod production_tests { + use super::*; + use ags_protocol::error::RuntimeError; + use ags_protocol::workflow::{OperationReference, OptionParameterBinding, OptionsSource}; + use ags_runtime::runtime::dispatch::http::{HttpBody, HttpClient, HttpRequest, HttpResponse}; + use ags_runtime::runtime::execution::ExecutionContext; + use ags_runtime::runtime::Runtime; + use async_trait::async_trait; + use std::cell::RefCell; + use std::collections::BTreeMap; + use std::rc::Rc; + + struct CannedClient(String); + #[async_trait] + impl HttpClient for CannedClient { + async fn send(&self, _r: HttpRequest) -> Result { + Ok(HttpResponse { + status: 200, + body: HttpBody::Text(self.0.clone()), + }) + } + } + + fn source() -> OptionsSource { + OptionsSource { + operation: OperationReference { + service: ags_protocol::catalogue::ServiceId::new("ams"), + operation: ags_protocol::catalogue::OperationId::new("ams/admin/images/v1/list"), + }, + parameters: BTreeMap::from([( + "namespace".to_string(), + OptionParameterBinding::FromInput("namespace".to_string()), + )]), + items_path: "$.images".into(), + value: "$.id".into(), + label: Some("$.name".into()), + label_detail: None, + fallback_description: None, + filter: None, + } + } + + /// A completed fetch returns choices through the spawn-and-channel bridge. + #[test] + fn test_production_resolver_returns_choices_on_completion() { + use crate::frontend::terminal::fullscreen::surface::FullscreenSurface; + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let ctx = ExecutionContext { + base_url: "https://example.com".to_string(), + access_token: "t".to_string(), + ..ExecutionContext::default() + }; + let runtime = Runtime::new( + ctx, + Box::new(CannedClient( + r#"{"images":[{"id":"img-1","name":"Prod"}]}"#.into(), + )), + reqwest::Client::new(), + ); + let surface = Rc::new(RefCell::new(FullscreenSurface::without_terminal())); + let resolver = + ProductionResolver::new(runtime, tokio::runtime::Handle::current(), surface); + let inputs = BTreeMap::from([("namespace".to_string(), serde_json::json!("dev"))]); + let resolved = resolver.resolve(&source(), &inputs).expect("ok"); + assert_eq!(resolved.choices.len(), 1); + assert_eq!(resolved.choices[0].label, "Prod"); + }); + } +} + +#[cfg(test)] +mod fetch_tests { + use super::*; + use ags_protocol::workflow::{OptionChoice, OptionsSource}; + use std::collections::BTreeMap; + + fn source() -> OptionsSource { + OptionsSource { + operation: ags_protocol::workflow::OperationReference { + service: ags_protocol::catalogue::ServiceId::new("iam"), + operation: ags_protocol::catalogue::OperationId::new("iam/admin/users/v3/search"), + }, + parameters: BTreeMap::new(), + items_path: "$.data".into(), + value: "$.userId".into(), + label: None, + label_detail: None, + fallback_description: None, + filter: None, + } + } + + #[test] + fn test_canned_fetch_delivers_ready_choices() { + let fetch = CannedFetch::ok(vec![OptionChoice { + label: "Ada".into(), + value: "u-1".into(), + }]); + let task = fetch.start(&source(), &BTreeMap::new()); + // Ready channel: the result is immediately receivable. + let got = task.rx.recv().expect("canned result present"); + let resolved = got.expect("Ok result"); + assert_eq!(resolved.choices.len(), 1); + assert_eq!(resolved.choices[0].value, "u-1"); + } + + #[test] + fn test_canned_fetch_pending_stays_empty_until_dropped() { + // A "pending" double never sends — exercises the sub-loop's Esc/abort path. + let fetch = CannedFetch::pending(); + let task = fetch.start(&source(), &BTreeMap::new()); + assert!(matches!( + task.rx.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + )); + } +} diff --git a/crates/accelbyte-ags-cli/src/frontend/event.rs b/crates/accelbyte-ags-cli/src/frontend/event.rs new file mode 100644 index 0000000..b6d9acd --- /dev/null +++ b/crates/accelbyte-ags-cli/src/frontend/event.rs @@ -0,0 +1,93 @@ +//! Frontend lifecycle and progress events. + +use ags_protocol::event::ProgressEvent; + +/// Lifecycle events the CLI's invocation layer pushes into the frontend. +/// +/// Every invocation — workflow run, synthesised single command, or static +/// command — is bracketed by a single paired `RunStarted` / `RunFinished` +/// model. Per-step events (`StepStarted`, `StepFinished`) and `Progress` +/// nest between them. There is no separate "workflow progress" variant — +/// progress events from a dispatch in flight always flow through +/// `Progress` and carry an optional `step_index` so the frontend can +/// attach the right context. +#[derive(Debug, Clone)] +pub enum FrontendEvent { + /// The invocation is about to call into the runtime. Emitted once per + /// run. `workflow_banner` carries the registered-workflow display name + /// when a "Running workflow: " banner should render, `None` + /// otherwise (synthesised single commands and static commands). A + /// registered workflow run emits this twice: a generic `None` from the + /// shared lifecycle helper, then a `Some(name)` from the workflow + /// adapter once execution clears the `--no-input` precheck. + RunStarted { + /// Registered-workflow display name, when a banner should render. + workflow_banner: Option, + }, + /// The invocation's runtime call has returned. Emitted once per run as + /// the single terminal lifecycle event. + RunFinished { + /// Final invocation outcome. + outcome: RunOutcome, + }, + /// Progress signal from a dispatch in flight. `step_index` is + /// `Some(N)` when the progress originated from step N of a workflow + /// run, `None` for non-workflow invocations. Populated by + /// `FrontendSink`; not yet read by any terminal surface (the inline + /// and plain frontends currently render progress without per-step + /// context). + Progress { + /// 0-based step index, if the progress originated from a workflow. + #[allow(dead_code)] + step_index: Option, + /// Underlying dispatch progress event. + event: ProgressEvent, + }, + /// Emitted at the top of each per-step iteration. + StepStarted { + /// 0-based step index. + index: usize, + /// Stable step id. + id: String, + }, + /// Emitted exactly once per started step, immediately after the + /// step's final outcome is known. + StepFinished { + /// 0-based step index. + index: usize, + /// Single-line summary destined for the user and scrollback. Carries + /// " ", so the step id is not a separate field. + summary: String, + /// Per-step captures (label, value) — the resolved inputs and step- + /// local options the step used, in display order. Empty for failure + /// paths that abort before request assembly. + captures: Vec<(String, String)>, + /// Per-step outcome. + outcome: StepOutcome, + }, +} + +/// Outcome of the entire invocation (workflow or single-command). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RunOutcome { + /// Every step succeeded and render succeeded. + Success, + /// At least one step failed, or render failed after a successful loop. + Failed, + /// User declined a confirmation prompt for some step. + Cancelled, +} + +/// Outcome of an individual workflow step. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StepOutcome { + /// Dispatch + capture succeeded. + Success, + /// Dispatch, capture, gather, confirm, or preview failed for this step. + Failed, + /// User declined a confirmation prompt for this step. + Cancelled, + /// An optional step whose dispatch failed: the step was skipped and the + /// run continues. + Skipped, +} diff --git a/crates/accelbyte-ags-cli/src/frontend/mod.rs b/crates/accelbyte-ags-cli/src/frontend/mod.rs new file mode 100644 index 0000000..11c778f --- /dev/null +++ b/crates/accelbyte-ags-cli/src/frontend/mod.rs @@ -0,0 +1,864 @@ +//! Presentation layer: format and render all CLI output. + +pub mod dynamic_options; +pub mod event; +pub mod output; +mod presenters; +pub mod sink; +pub mod streams; +pub mod style; +pub mod terminal; + +use crate::errors::CliError; +use ags_protocol::output::CommandOutput; +use ags_protocol::workflow::{ + CompiledStep, GatherResult, StepPreview, SuppliedInputView, WorkflowInputNeeded, +}; +use std::cell::RefCell; +use std::rc::Rc; + +/// Pagination metadata for display in list output. +#[derive(Debug, Clone)] +pub struct PaginationHint { + pub total: Option, + pub has_next: bool, +} + +/// Text-family selector used by the pure formatting helpers in `output/render.rs`. +/// Decoupled from `PhaseBackend` so the inline-surface→plain mapping happens +/// once at inline-surface construction, not in every per-output match arm. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RenderFormat { + Human, + Json, +} + +/// Final rendered text ready for emission to stdout and/or stderr +#[derive(Debug, Clone, Default)] +pub struct RenderedOutput { + pub stdout: Option, + pub stderr: Option, + /// When true, print stdout before stderr (e.g. status headline first). + /// When false (default), print stderr before stdout (e.g. verbose trace first). + pub is_stdout_first: bool, +} + +/// Emit rendered output honouring the caller's `--output` flag. When +/// `output` is `Some(path)` or `Some("-")`, the stdout portion goes +/// through `OutputSink` instead of `println!`. The stderr portion +/// always goes to stderr (spinners, confirmation lines, warnings). +pub fn emit_with_options( + rendered: RenderedOutput, + options: &RenderOptions, +) -> Result<(), crate::errors::CliError> { + // stderr is unaffected by --output — always goes to real stderr. + // stdout goes to OutputSink when --output is set. + if rendered.is_stdout_first { + if let Some(stdout) = rendered.stdout.filter(|s| !s.is_empty()) { + write_stdout(&stdout, options)?; + } + if let Some(stderr) = rendered.stderr.filter(|s| !s.is_empty()) { + write_stderr_line(&stderr); + } + } else { + if let Some(stderr) = rendered.stderr.filter(|s| !s.is_empty()) { + write_stderr_line(&stderr); + } + if let Some(stdout) = rendered.stdout.filter(|s| !s.is_empty()) { + write_stdout(&stdout, options)?; + } + } + Ok(()) +} + +pub use event::{FrontendEvent, RunOutcome}; +pub(crate) use output::render::render_output; +pub use sink::FrontendSink; + +/// Route stdout text either to the console or to the `--output` destination resolved by `OutputSink`. +fn write_stdout(text: &str, options: &RenderOptions) -> Result<(), crate::errors::CliError> { + use ags_runtime::support::output_sink::OutputSink; + + match options.output.as_ref() { + // Common case: no --output flag. Writes go through anstream::stdout(), + // which enables Windows VT mode on first use and surfaces a closed + // pipe as ErrorKind::BrokenPipe instead of panicking. On Unix, + // reset_sigpipe() still drives the SIGPIPE-based exit-141 path. + None => write_stdout_line(text), + Some(destination) => { + let sink = OutputSink::resolve(Some(destination), false) + .map_err(map_output_sink_error_to_cli_error)?; + // Append a trailing newline to match println! semantics — users + // expect text files to end with a newline. + // + // This is intentionally asymmetric with the binary --output path + // in runtime::dispatch, which writes raw bytes verbatim. + let mut bytes = text.as_bytes().to_vec(); + if !bytes.ends_with(b"\n") { + bytes.push(b'\n'); + } + sink.write(&bytes) + .map_err(map_output_sink_error_to_cli_error) + } + } +} + +/// Convert an output-sink error into the CLI's top-level error type. +fn map_output_sink_error_to_cli_error( + err: ags_runtime::support::output_sink::OutputSinkError, +) -> crate::errors::CliError { + use ags_runtime::support::output_sink::OutputSinkError; + match err { + OutputSinkError::Usage(message) => crate::errors::CliError::Usage { + message, + metadata: None, + }, + OutputSinkError::Internal(inner) => crate::errors::CliError::Internal(inner), + } +} + +/// Interaction methods for execution: gathering inputs and confirming +/// steps. Each presentation surface provides its own implementation. +/// +/// This trait is separate from [`Frontend`] so that the interaction logic can +/// be unit-tested in isolation and later composed independently of rendering. +pub trait ExecutionInteraction { + /// Show the workflow's long-form briefing before any inputs are + /// gathered. `Ok(true)` proceeds, `Ok(false)` cancels the run. + /// Default: `Ok(true)` — only `FullscreenInteraction` overrides. + fn present_briefing( + &mut self, + _briefing: &ags_protocol::workflow::WorkflowBriefing, + _workflow_name: &str, + ) -> Result { + Ok(true) + } + + /// Gather values for the listed workflow input slots. `supplied` carries + /// the already-resolved inputs (with provenance) the form may pre-fill. + /// Returns a `GatherResult` with values for the missing slots and any + /// edited overrides for already-supplied inputs. + fn gather_workflow_inputs( + &mut self, + needed: &[WorkflowInputNeeded], + step_context: &CompiledStep, + supplied: &[SuppliedInputView], + ) -> Result; + + /// Per-step confirmation prompt during a workflow run. Returns + /// `Ok(StepConfirmOutcome::Proceed)` to proceed, `Skip` to skip an optional + /// step and continue the run, `Cancel` to cancel the workflow. + fn confirm_step( + &mut self, + step: &CompiledStep, + preview: &StepPreview, + ) -> Result; + + /// Review/edit a step's full request before it runs. Default: proceed with + /// no edits — only `FullscreenInteraction` overrides this. + fn review_step( + &mut self, + _plan: &ags_protocol::workflow::StepFieldPlan, + ) -> Result { + Ok(ags_protocol::workflow::StepReviewOutcome::Proceed( + ags_protocol::workflow::StepFieldEdits::default(), + )) + } + + /// Interactive failure gate: Retry / Skip / Cancel. `allow_skip` is false + /// when skipping would break downstream. Default: `Cancel` — `machine_json` + /// and any surface that does not render a gate keep today's fail-fast. + fn resolve_step_failure( + &mut self, + _step: &CompiledStep, + _error: &ags_protocol::error::RuntimeError, + _allow_skip: bool, + ) -> Result { + Ok(ags_protocol::workflow::StepFailureAction::Cancel) + } + + /// Phase 1: collect declared workflow inputs. `Ok(Some(outcome))` = proceed + /// with the declared-input map plus the chosen run stop-mode, `Ok(None)` = + /// user cancelled (clean cancel), `Err` = I/O failure. Default: + /// `Ok(Some(CollectOutcome { inputs: current.clone(), run_mode: + /// RunMode::ReviewInputSteps }))` — only `FullscreenInteraction` and + /// `InlineInteraction` override this. + fn collect_workflow_inputs( + &mut self, + _specs: &[ags_protocol::workflow::WorkflowInputSpec], + current: &std::collections::BTreeMap, + ) -> Result, CliError> { + Ok(Some(ags_protocol::workflow::CollectOutcome { + inputs: current.clone(), + run_mode: ags_protocol::workflow::RunMode::ReviewInputSteps, + })) + } +} + +/// The frontend abstraction: renders output and errors to the appropriate medium, +/// and consumes structured events emitted by the CLI invocation layer (lifecycle) +/// and by the runtime (progress, via `FrontendSink`). +pub trait Frontend { + /// Consume a structured event (lifecycle or progress). Default: no-op. + fn on_event(&mut self, _event: &crate::frontend::event::FrontendEvent) {} + /// Render a command's structured output in this frontend's format. + /// `RenderOptions` is held on `self` (captured at construction); the trait + /// method does not take it as a parameter. + fn render(&mut self, output: &CommandOutput) -> Result<(), CliError>; + /// Render a fatal error in this frontend's format. + fn render_error(&mut self, err: &CliError); + /// Emit a non-fatal warning to stderr. + fn render_warning(&mut self, message: &str, reason: Option<&str>, tip: Option<&str>); + /// Emit the verbose resolution trace shown for `--dry-run --verbose`. + fn render_resolution_trace(&mut self, trace: &ags_protocol::output::ResolutionTrace); + /// Explicit happy-path teardown. May surface terminal-restore failures + /// to the user. A `Drop` impl on the concrete frontend type provides + /// the panic-safety fallback. + fn finish(self: Box) -> Result<(), CliError>; +} + +/// Phase-resolved presentation surfaces for one self-owned invocation +/// (`workflow run` or a synthesised service command). +/// +/// `Split` keeps progress and final rendering on separate surfaces (the +/// existing path for all inline and plain-terminal runs). `Unified` routes +/// both progress events and the final result through the same surface, used +/// by the fullscreen TUI so teardown and result emission happen as one unit. +pub enum ExecutionPhaseSurfaces { + /// Separate surfaces for progress and final rendering (existing path). + Split { + /// Drives lifecycle/progress events during the run. + progress_frontend: Box, + /// Renders the final result (`render`) or failure (`render_error`). + final_frontend: Box, + /// Drives input gathering and per-step confirmation. + interaction: Box, + }, + /// Single surface drives both progress events and the final render, with + /// one teardown at the end. Used by the fullscreen TUI. + Unified { + /// Drives lifecycle/progress events AND renders the final result. + surface: Box, + /// Drives input gathering and per-step confirmation. + interaction: Box, + }, +} + +/// Assemble fullscreen `Unified` surfaces from a built surface: the +/// `FullscreenFrontend` is the progress + final + dismiss surface, and the +/// `FullscreenInteraction` drives gather/confirm — both share one +/// [`FullscreenSurface`], so every phase draws through the identical layout. +fn fullscreen_phase_surfaces_from_surface( + surface: Rc>, + resolver: Option>, +) -> ExecutionPhaseSurfaces { + let frontend = + crate::frontend::terminal::fullscreen::frontend::FullscreenFrontend::from_surface( + Rc::clone(&surface), + ); + let mut interaction = + crate::frontend::terminal::fullscreen::interaction::FullscreenInteraction::new(surface); + if let Some(resolver) = resolver { + interaction = interaction.with_resolver(resolver); + } + ExecutionPhaseSurfaces::Unified { + surface: Box::new(frontend), + interaction: Box::new(interaction), + } +} + +/// Construct fullscreen phase surfaces for a workflow run. One +/// [`FullscreenSurface`] owns the alt-screen terminal **and** the render +/// model, shared by the unified [`FullscreenFrontend`] (progress + final + +/// dismiss) and [`FullscreenInteraction`] (gather/confirm in-surface). +/// +/// Workflow routes that want the fullscreen surface call this directly +/// with the compiled step list. +pub fn select_fullscreen_workflow_surfaces( + ctx: &crate::invocation::context::FrontendContext, + options: RenderOptions, + workflow_title: String, + header_kind: crate::frontend::terminal::fullscreen::step_strip::HeaderKind, + steps: Vec, + workflow_description: Option, + resolver_factory: impl FnOnce( + Rc>, + ) -> Option< + Box, + >, +) -> Result { + let mut surface_inner = crate::frontend::terminal::fullscreen::surface::FullscreenSurface::new( + options, + workflow_title, + steps, + ctx.terminal.stdout_is_tty, + )?; + surface_inner.header_kind = header_kind; + surface_inner.workflow_description = workflow_description; + let surface = Rc::new(RefCell::new(surface_inner)); + let resolver = resolver_factory(Rc::clone(&surface)); + Ok(fullscreen_phase_surfaces_from_surface(surface, resolver)) +} + +/// Construct the phase-resolved [`ExecutionPhaseSurfaces`] for a workflow run. +pub(crate) fn select_workflow_phase_surfaces( + ctx: &crate::invocation::context::FrontendContext, + options: RenderOptions, + full_surface_inputs: Option>, + options_fetch: Option>, +) -> Result { + use crate::invocation::context::{InteractionPhase, PhaseBackend}; + + let progress_backend = ctx.backend_for_phase(InteractionPhase::Progress); + let final_backend = ctx.backend_for_phase(InteractionPhase::Result); + debug_assert_eq!( + final_backend, + ctx.backend_for_phase(InteractionPhase::Error), + "Result and Error must share the final frontend" + ); + let interaction_backend = ctx.backend_for_phase(InteractionPhase::Input); + debug_assert_eq!( + interaction_backend, + ctx.backend_for_phase(InteractionPhase::Confirmation), + "Input and Confirmation must share the interaction surface" + ); + + // Inline runs share one terminal session between progress and interaction. + if progress_backend == PhaseBackend::InlineTerminalUi + || interaction_backend == PhaseBackend::InlineTerminalUi + { + debug_assert_eq!( + progress_backend, + PhaseBackend::InlineTerminalUi, + "inline workflows route progress through the inline terminal" + ); + debug_assert_eq!( + interaction_backend, + PhaseBackend::InlineTerminalUi, + "inline workflows route interaction through the inline terminal" + ); + // One terminal acquisition, shared by progress + interaction. + let session = Rc::new(RefCell::new( + crate::frontend::terminal::inline::session::InlineSession::new()?, + )); + return inline_phase_surfaces_from_session( + session, + final_backend, + options, + full_surface_inputs, + options_fetch, + ); + } + + Ok(ExecutionPhaseSurfaces::Split { + progress_frontend: frontend_for_surface(progress_backend, options.clone())?, + final_frontend: frontend_for_surface(final_backend, options)?, + interaction: interaction_for_surface(interaction_backend), + }) +} + +/// Build a standalone interaction surface for a phase backend. +fn interaction_for_surface( + backend: crate::invocation::context::PhaseBackend, +) -> Box { + use crate::invocation::context::PhaseBackend; + match backend { + PhaseBackend::PlainTerminal => { + Box::new(crate::frontend::terminal::plain::interaction::PlainInteraction) + } + PhaseBackend::StructuredJson => { + Box::new(crate::frontend::terminal::machine_json::JsonInteraction) + } + PhaseBackend::InlineTerminalUi => { + unreachable!("InlineTerminalUi interaction is built via the shared-session path") + } + PhaseBackend::FullscreenTerminalUi => { + unreachable!("FullscreenTerminalUi interaction is built via the shared-session path") + } + } +} + +/// Assemble inline-terminal [`ExecutionPhaseSurfaces`] from an acquired session. +fn inline_phase_surfaces_from_session( + session: Rc>, + final_backend: crate::invocation::context::PhaseBackend, + options: RenderOptions, + full_surface_inputs: Option>, + options_fetch: Option>, +) -> Result { + let progress_frontend = + crate::frontend::terminal::inline::frontend::InlineFrontend::from_session( + Rc::clone(&session), + options.clone(), + ); + let mut interaction = crate::frontend::terminal::inline::interaction::InlineInteraction::new( + session, + full_surface_inputs, + ); + if let Some(fetch) = options_fetch { + interaction = interaction.with_options_fetch(fetch); + } + Ok(ExecutionPhaseSurfaces::Split { + progress_frontend: Box::new(progress_frontend), + final_frontend: frontend_for_surface(final_backend, options)?, + interaction: Box::new(interaction), + }) +} + +/// Whether `frontend_for_surface` builds a PLAIN-text frontend for this +/// backend. True for `PlainTerminal` and for `FullscreenTerminalUi` — the +/// latter degrades to plain *here* because `frontend_for_surface` is the +/// single-shot path with no step list; real fullscreen workflow runs use +/// `select_fullscreen_workflow_surfaces` instead. The lock-contention reporter +/// gate keys off this so it never registers for a real TUI surface (inline) or JSON. +pub(crate) fn factory_renders_plain(backend: crate::invocation::context::PhaseBackend) -> bool { + use crate::invocation::context::PhaseBackend; + matches!( + backend, + PhaseBackend::PlainTerminal | PhaseBackend::FullscreenTerminalUi + ) +} + +/// Construct the frontend for a presentation surface. +pub fn frontend_for_surface( + backend: crate::invocation::context::PhaseBackend, + options: RenderOptions, +) -> Result, CliError> { + use crate::invocation::context::PhaseBackend; + // `factory_renders_plain` and this factory stay in sync: both + // `PlainTerminal` and `FullscreenTerminalUi` build a `PlainFrontend`. + // `FullscreenTerminalUi` degrades because `frontend_for_surface` returns + // surfaces that don't need workflow metadata. Fullscreen needs a step list + // to render the step strip — workflow routes use + // `select_fullscreen_workflow_surfaces` instead, which threads the compiled + // steps in. Reaching this arm means an unsupported single-shot route forced + // `--ui=fullscreen`; fall back to plain for now. + // TODO: wire the single-shot fullscreen fallback through the frontend + // factory. The layout layer already supports it; only this factory arm + // still degrades to plain. + if factory_renders_plain(backend) { + return Ok(Box::new( + crate::frontend::terminal::plain::frontend::PlainFrontend::new(options), + )); + } + match backend { + PhaseBackend::StructuredJson => Ok(Box::new( + crate::frontend::output::json::frontend::JsonFrontend::new(options), + )), + PhaseBackend::InlineTerminalUi => { + let inline = crate::frontend::terminal::inline::frontend::InlineFrontend::new(options)?; + Ok(Box::new(inline)) + } + PhaseBackend::PlainTerminal | PhaseBackend::FullscreenTerminalUi => { + unreachable!( + "handled by the factory_renders_plain early return: PlainTerminal and \ + FullscreenTerminalUi both build PlainFrontend" + ) + } + } +} + +/// Render-layer options extracted from global CLI flags. +/// +/// Only the fields the render layer actually needs — keeps the render module +/// independent of invocation types. Format is not here: each frontend +/// already embodies its own format. +#[derive(Debug, Default, Clone)] +pub struct RenderOptions { + pub verbosity: ags_protocol::request::Verbosity, + pub is_page_all: bool, + pub output: Option, +} + +/// Internal stdout helper parameterised by writer for unit testing. +/// Translates `BrokenPipe` to `Ok(())` (matches Unix SIGPIPE-driven exit +/// semantics — a downstream consumer closing the pipe is a stop-signal, +/// not an error). All other I/O errors are mapped to `CliError::Usage`. +/// Flushes after a successful write so downstream consumers see the +/// bytes without depending on platform-specific stream-buffering +/// behaviour. On a `BrokenPipe` from either `writeln!` or the trailing +/// `flush`, the helper returns `Ok(())` without retrying — the stream +/// is broken and any further write would also fail. +fn write_stdout_line_into( + mut w: W, + text: &str, +) -> Result<(), crate::errors::CliError> { + match writeln!(w, "{text}").and_then(|_| w.flush()) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()), + Err(e) => Err(crate::errors::CliError::Usage { + message: format!("Cannot write to stdout: {e}."), + metadata: None, + }), + } +} + +/// Internal no-newline stderr helper parameterised by writer for unit testing. +/// Used for inline prompts (e.g. "Continue? [y/N] ") where a trailing newline +/// would break the input flow. Always flushes after writing so the prompt +/// is visible before the program blocks on stdin. Same best-effort error +/// policy as the line variant. +fn write_stderr_into(mut w: W, text: &str) { + let _ = w.write_all(text.as_bytes()).and_then(|_| w.flush()); +} + +/// Write `text` followed by a newline to stdout via `anstream::stdout()`. +/// On Windows, `anstream` enables the console's virtual terminal mode on +/// first use, so escape codes produced upstream render correctly. +pub(crate) fn write_stdout_line(text: &str) -> Result<(), crate::errors::CliError> { + write_stdout_line_into(anstream::stdout().lock(), text) +} + +/// Write `text` followed by a newline to stderr via `UiSink`. +/// Errors are silently ignored — stderr is best-effort. +pub(crate) fn write_stderr_line(text: &str) { + let _ = crate::frontend::streams::UiSink.write_line(text); +} + +/// Write `text` to stderr without a trailing newline. Used for inline +/// prompts where the next character on the line is user input. +pub(crate) fn write_stderr(text: &str) { + write_stderr_into(anstream::stderr().lock(), text); +} + +/// Coerce a raw string to the JSON type indicated by `schema["type"]`, using +/// the soft-coercion policy: on parse failure preserve as `Value::String` and +/// let the executor surface the validation error rather than re-prompting. +/// +/// - `"string"` → passthrough as `Value::String`. +/// - `"integer"` → parse as `i64`; fall back to `Value::String` on failure. +/// - `"number"` → parse as `f64`; fall back to `Value::String` on failure. +/// - `"boolean"` → accept `"true"` / `"false"` (case-insensitive); fall back to `Value::String`. +/// - `"array"` / `"object"` → parse as JSON; fall back to `Value::String`. +/// - `"null"` → `Value::Null` (input is ignored). +/// - Unknown or absent type → `Value::String` fallback. +pub(crate) fn coerce_to_schema(raw: &str, schema: &serde_json::Value) -> serde_json::Value { + let type_hint = schema + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("string"); + + match type_hint { + "string" => serde_json::Value::String(raw.to_string()), + "integer" => raw + .parse::() + .map(|n| serde_json::json!(n)) + .unwrap_or_else(|_| serde_json::Value::String(raw.to_string())), + "number" => raw + .parse::() + .map(|n| serde_json::json!(n)) + .unwrap_or_else(|_| serde_json::Value::String(raw.to_string())), + "boolean" => match raw.to_lowercase().as_str() { + "true" => serde_json::json!(true), + "false" => serde_json::json!(false), + _ => serde_json::Value::String(raw.to_string()), + }, + "array" | "object" => { + serde_json::from_str(raw).unwrap_or_else(|_| serde_json::Value::String(raw.to_string())) + } + "null" => serde_json::Value::Null, + _ => serde_json::Value::String(raw.to_string()), + } +} + +#[cfg(test)] +mod surface_tests { + use super::{ + factory_renders_plain, inline_phase_surfaces_from_session, select_workflow_phase_surfaces, + RenderOptions, + }; + use crate::frontend::terminal::inline::session::InlineSession; + use crate::invocation::context::{ + ConsumerKind, FrontendContext, InteractionPolicy, PhaseBackend, TerminalCapabilities, + }; + use std::cell::RefCell; + use std::rc::Rc; + + /// Build a `FrontendContext` with the given consumer/rich-ui combination. + fn ctx(consumer: ConsumerKind, prefer_rich_ui: bool) -> FrontendContext { + FrontendContext { + consumer, + interaction: InteractionPolicy { + allow_input: true, + prefer_rich_ui, + prefer_fullscreen: false, + }, + terminal: TerminalCapabilities { + stdin_is_tty: true, + stdout_is_tty: true, + stderr_is_tty: true, + color_force_off: true, + }, + ui_intent: crate::invocation::flags::UiFlag::Auto, + } + } + + /// Plain-terminal surfaces construct without acquiring an inline session. + #[test] + fn test_phase_surfaces_human_constructs_without_terminal_acquisition() { + let surfaces = select_workflow_phase_surfaces( + &ctx(ConsumerKind::Human, false), + RenderOptions::default(), + None, + None, + ) + .expect("human phase surfaces"); + drop(surfaces); + } + + /// Structured-JSON surfaces construct without acquiring an inline session. + #[test] + fn test_phase_surfaces_json_constructs_without_terminal_acquisition() { + let surfaces = select_workflow_phase_surfaces( + &ctx(ConsumerKind::Automation, false), + RenderOptions::default(), + None, + None, + ) + .expect("json phase surfaces"); + drop(surfaces); + } + + /// Inline progress and interaction share one session; final rendering does not. + #[test] + fn test_phase_surfaces_inline_progress_and_interaction_share_one_session() { + let session = Rc::new(RefCell::new(InlineSession::without_terminal())); + let surfaces = inline_phase_surfaces_from_session( + session.clone(), + PhaseBackend::PlainTerminal, + RenderOptions::default(), + None, + None, + ) + .expect("inline phase surfaces"); + // test's ref + progress_frontend's clone + interaction's clone. + // The human `final_frontend` holds no session reference. + assert_eq!(Rc::strong_count(&session), 3); + drop(surfaces); + assert_eq!(Rc::strong_count(&session), 1); + } + + /// `factory_renders_plain` is the single source of truth for the lock- + /// reporter gate; it must agree with which backends `frontend_for_surface` + /// builds a plain frontend for. Plain + fullscreen (degrades to plain) → + /// true; inline (real TUI) + json → false. + #[test] + fn test_factory_renders_plain_truth_table() { + assert!(factory_renders_plain(PhaseBackend::PlainTerminal)); + assert!(factory_renders_plain(PhaseBackend::FullscreenTerminalUi)); + assert!(!factory_renders_plain(PhaseBackend::InlineTerminalUi)); + assert!(!factory_renders_plain(PhaseBackend::StructuredJson)); + } +} + +#[cfg(test)] +mod coerce_tests { + use super::coerce_to_schema; + + #[test] + fn test_coerce_inline_string_passthrough() { + let schema = serde_json::json!({"type": "string"}); + assert_eq!( + coerce_to_schema("hello", &schema), + serde_json::Value::String("hello".to_string()) + ); + } + + #[test] + fn test_coerce_inline_integer_valid() { + let schema = serde_json::json!({"type": "integer"}); + assert_eq!(coerce_to_schema("42", &schema), serde_json::json!(42i64)); + } + + #[test] + fn test_coerce_inline_integer_invalid_falls_back_to_string() { + let schema = serde_json::json!({"type": "integer"}); + assert_eq!( + coerce_to_schema("abc", &schema), + serde_json::Value::String("abc".to_string()) + ); + } + + #[test] + fn test_coerce_inline_number_valid() { + let schema = serde_json::json!({"type": "number"}); + assert_eq!(coerce_to_schema("2.5", &schema), serde_json::json!(2.5f64)); + } + + #[test] + fn test_coerce_inline_number_invalid_falls_back_to_string() { + let schema = serde_json::json!({"type": "number"}); + assert_eq!( + coerce_to_schema("abc", &schema), + serde_json::Value::String("abc".to_string()) + ); + } + + #[test] + fn test_coerce_inline_boolean_true() { + let schema = serde_json::json!({"type": "boolean"}); + assert_eq!(coerce_to_schema("true", &schema), serde_json::json!(true)); + assert_eq!(coerce_to_schema("True", &schema), serde_json::json!(true)); + } + + #[test] + fn test_coerce_inline_boolean_false() { + let schema = serde_json::json!({"type": "boolean"}); + assert_eq!(coerce_to_schema("false", &schema), serde_json::json!(false)); + assert_eq!(coerce_to_schema("FALSE", &schema), serde_json::json!(false)); + } + + #[test] + fn test_coerce_inline_boolean_invalid_falls_back_to_string() { + let schema = serde_json::json!({"type": "boolean"}); + assert_eq!( + coerce_to_schema("yes", &schema), + serde_json::Value::String("yes".to_string()) + ); + } + + #[test] + fn test_coerce_inline_array_valid_json() { + let schema = serde_json::json!({"type": "array"}); + assert_eq!( + coerce_to_schema("[1,2,3]", &schema), + serde_json::json!([1, 2, 3]) + ); + } + + #[test] + fn test_coerce_inline_array_invalid_falls_back_to_string() { + let schema = serde_json::json!({"type": "array"}); + assert_eq!( + coerce_to_schema("not json", &schema), + serde_json::Value::String("not json".to_string()) + ); + } + + #[test] + fn test_coerce_inline_null_ignores_input() { + let schema = serde_json::json!({"type": "null"}); + assert_eq!( + coerce_to_schema("anything", &schema), + serde_json::Value::Null + ); + } + + #[test] + fn test_coerce_inline_unknown_type_falls_back_to_string() { + let schema = serde_json::json!({"type": "exotic"}); + assert_eq!( + coerce_to_schema("value", &schema), + serde_json::Value::String("value".to_string()) + ); + } + + #[test] + fn test_coerce_inline_absent_type_falls_back_to_string() { + let schema = serde_json::json!({}); + assert_eq!( + coerce_to_schema("value", &schema), + serde_json::Value::String("value".to_string()) + ); + } +} + +#[cfg(test)] +mod tests { + use super::{write_stderr_into, write_stdout_line_into}; + use std::io::{self, Write}; + + /// A `Write` that records bytes, tracks flush calls, and optionally returns + /// a configured error from `write`. + struct Mock { + buf: Vec, + err: Option, + flushed: usize, + } + + impl Mock { + /// Build a Mock writer that accepts every write — used for the success-path tests. + fn ok() -> Self { + Self { + buf: Vec::new(), + err: None, + flushed: 0, + } + } + + /// Build a Mock writer that fails every write with the given `io::ErrorKind`. + fn failing(kind: io::ErrorKind) -> Self { + Self { + buf: Vec::new(), + err: Some(kind), + flushed: 0, + } + } + } + + impl Write for Mock { + fn write(&mut self, data: &[u8]) -> io::Result { + if let Some(kind) = self.err { + return Err(io::Error::from(kind)); + } + self.buf.extend_from_slice(data); + Ok(data.len()) + } + fn flush(&mut self) -> io::Result<()> { + self.flushed += 1; + Ok(()) + } + } + + #[test] + fn test_write_stdout_line_into_writes_text_with_trailing_newline() { + let mut m = Mock::ok(); + write_stdout_line_into(&mut m, "hello").unwrap(); + assert_eq!(m.buf, b"hello\n"); + } + + #[test] + fn test_write_stdout_line_into_returns_ok_on_broken_pipe() { + let mut m = Mock::failing(io::ErrorKind::BrokenPipe); + assert!(write_stdout_line_into(&mut m, "x").is_ok()); + } + + #[test] + fn test_write_stdout_line_into_returns_clierror_on_other_error() { + let mut m = Mock::failing(io::ErrorKind::Other); + let err = write_stdout_line_into(&mut m, "x").unwrap_err(); + match err { + crate::errors::CliError::Usage { message, .. } => { + assert!(message.contains("stdout")); + } + other => panic!("expected Usage, got {other:?}"), + } + } + + #[test] + fn test_write_stderr_into_writes_text_without_trailing_newline() { + let mut m = Mock::ok(); + write_stderr_into(&mut m, "Continue? [y/N] "); + assert_eq!(m.buf, b"Continue? [y/N] "); + } + + #[test] + fn test_write_stderr_into_does_not_panic_on_broken_pipe() { + let mut m = Mock::failing(io::ErrorKind::BrokenPipe); + write_stderr_into(&mut m, "x"); // returns () + } + + #[test] + fn test_write_stdout_line_into_flushes_after_write() { + let mut m = Mock::ok(); + write_stdout_line_into(&mut m, "hello").unwrap(); + assert_eq!(m.flushed, 1); + } + + #[test] + fn test_write_stderr_into_flushes_after_write() { + let mut m = Mock::ok(); + write_stderr_into(&mut m, "Continue? [y/N] "); + assert_eq!(m.flushed, 1); + } +} diff --git a/src/frontend/human/commands/auth.rs b/crates/accelbyte-ags-cli/src/frontend/output/human/commands/auth.rs similarity index 92% rename from src/frontend/human/commands/auth.rs rename to crates/accelbyte-ags-cli/src/frontend/output/human/commands/auth.rs index ce695a0..18b6279 100644 --- a/src/frontend/human/commands/auth.rs +++ b/crates/accelbyte-ags-cli/src/frontend/output/human/commands/auth.rs @@ -1,12 +1,12 @@ //! Human-readable rendering for auth command output. use crate::errors::CliError; -use crate::frontend::human::templates; +use crate::frontend::output::human::templates; use crate::frontend::presenters::auth as auth_presenter; use crate::frontend::style; use crate::frontend::RenderOptions; use crate::frontend::RenderedOutput; -use crate::protocol::output::{ +use ags_protocol::output::{ AuthActionData, AuthActionStatus, AuthOutput, AuthStatusData, AuthView, LogoutData, Presence, TokenState, }; @@ -50,6 +50,10 @@ fn render_auth_view_text(view: &AuthView) -> (String, Option) { Some(render_login_success_details(data)), ) } + AuthView::RefreshSuccess(data) => ( + style::success("Token refreshed", style::is_stdout_enabled()), + Some(render_login_success_details(data)), + ), AuthView::LogoutSuccess(data) => ( style::success("Credentials cleared", style::is_stdout_enabled()), Some(render_logout_details(data)), @@ -76,9 +80,11 @@ fn render_auth_view_text(view: &AuthView) -> (String, Option) { /// Format the credential detail block for an authenticated status fn render_authenticated_details(data: &AuthStatusData) -> String { let rows = build_auth_status_rows(data); + // Values are primary information, rendered plain (not dimmed) to match + // `ags profile show`. let mut lines = vec![templates::render_label_value_block_text( &rows, - crate::frontend::style::Tone::Dim, + crate::frontend::style::Tone::Plain, style::is_stderr_enabled(), )]; if let Some(next_step) = &data.next_step { @@ -89,9 +95,11 @@ fn render_authenticated_details(data: &AuthStatusData) -> String { /// Format the credential detail block when attention is needed fn render_attention_details(data: &AuthStatusData) -> String { + // Values render plain (not dimmed), matching `render_authenticated_details` + // and `ags profile show`. let mut lines = vec![templates::render_label_value_block_text( &build_auth_status_rows(data), - crate::frontend::style::Tone::Dim, + crate::frontend::style::Tone::Plain, style::is_stderr_enabled(), )]; if let Some(next_step) = &data.next_step { @@ -115,7 +123,7 @@ fn render_login_success_details(data: &AuthActionData) -> String { if let Some(token_expires_in_secs) = data.token_expires_in_secs { rows.push(( "Token Expires:", - crate::support::format_duration(token_expires_in_secs), + ags_runtime::support::format_duration(token_expires_in_secs), )); } @@ -229,7 +237,7 @@ fn format_token_state(state: &TokenState, color_enabled: bool) -> String { return style::apply_tone( &format!( "valid (expires in {})", - crate::support::format_duration(expires_in_secs) + ags_runtime::support::format_duration(expires_in_secs) ), style::Tone::Success, color_enabled, diff --git a/src/frontend/human/commands/completions.rs b/crates/accelbyte-ags-cli/src/frontend/output/human/commands/completions.rs similarity index 92% rename from src/frontend/human/commands/completions.rs rename to crates/accelbyte-ags-cli/src/frontend/output/human/commands/completions.rs index 316528c..c996a6b 100644 --- a/src/frontend/human/commands/completions.rs +++ b/crates/accelbyte-ags-cli/src/frontend/output/human/commands/completions.rs @@ -3,7 +3,7 @@ use crate::errors::CliError; use crate::frontend::{RenderOptions, RenderedOutput}; -use crate::protocol::output::CompletionsOutput; +use ags_protocol::output::CompletionsOutput; /// Render a completions script for human terminals: shell script on stdout, optional install hint on stderr. pub fn render_completions_output( diff --git a/crates/accelbyte-ags-cli/src/frontend/output/human/commands/config.rs b/crates/accelbyte-ags-cli/src/frontend/output/human/commands/config.rs new file mode 100644 index 0000000..2699eeb --- /dev/null +++ b/crates/accelbyte-ags-cli/src/frontend/output/human/commands/config.rs @@ -0,0 +1,109 @@ +//! Human-readable rendering for config command output. + +use crate::errors::CliError; +use crate::frontend::output::templates::config_source_label; +use crate::frontend::style; +use crate::frontend::RenderOptions; +use crate::frontend::RenderedOutput; +use ags_protocol::config::{ConfigSource, ResolvedEntry}; +use ags_protocol::output::{ConfigOutput, ConfigView}; + +/// Render config command output as human-readable text +pub fn render_config_output( + output: &ConfigOutput, + _options: &RenderOptions, +) -> Result { + render_config_view_text(&output.view) +} + +/// Render config output as human-readable text +fn render_config_view_text(view: &ConfigView) -> Result { + let color = style::is_stderr_enabled(); + + let stdout = match view { + ConfigView::GetAll { profile, entries } => { + render_all_config_entries(profile, entries, color) + } + ConfigView::GetOne { + key, + value, + source, + read_only, + } => render_single_config_value(key, value, source, *read_only), + ConfigView::Set { key, value } => style::success(&format!("{key} = {value}"), color), + ConfigView::Unset { key } => style::success(&format!("{key} unset"), color), + }; + + Ok(RenderedOutput { + stdout: Some(stdout), + stderr: None, + is_stdout_first: false, + }) +} + +/// Render the dump-all config view with source annotations +fn render_all_config_entries(profile: &str, entries: &[ResolvedEntry], color: bool) -> String { + let mut lines = vec![style::info( + &format!("Configuration (profile: {profile})"), + color, + )]; + + let max_key_len = entries.iter().map(|e| e.key.len()).max().unwrap_or(0); + + for entry in entries { + let padding = " ".repeat(max_key_len - entry.key.len() + 2); + let value_str = entry_value_display(entry); + let source_str = match &entry.source { + ConfigSource::NotSet => String::new(), + other => format!(" ({})", config_source_label(other)), + }; + lines.push(format!(" {}{padding}{value_str}{source_str}", entry.key)); + } + + lines.join("\n") +} + +/// The value-column text for a get-all entry. Read-only entries (the keychain +/// client secret) never show a value — only masked presence — so the secret is +/// never printed. +fn entry_value_display(entry: &ResolvedEntry) -> String { + if entry.read_only { + return masked_presence(&entry.source); + } + entry.value.as_deref().unwrap_or("not set").to_string() +} + +/// Masked display for a read-only secret: bullets when set, "not set" otherwise, +/// always flagged read-only. +fn masked_presence(source: &ConfigSource) -> String { + if matches!(source, ConfigSource::NotSet) { + "not set (read-only)".to_string() + } else { + "•••••••• (read-only)".to_string() + } +} + +/// Render a single config key value. Source is normally omitted for single-key +/// output (shown only in get-all), but a read-only secret shows its masked +/// presence and source so the user learns where it lives. +fn render_single_config_value( + key: &str, + value: &Option, + source: &ConfigSource, + read_only: bool, +) -> String { + if read_only { + return match source { + ConfigSource::NotSet => format!("{key}: {}", masked_presence(source)), + other => format!( + "{} ({})", + masked_presence(source), + config_source_label(other) + ), + }; + } + match value { + Some(v) => v.to_string(), + None => format!("{key}: not set"), + } +} diff --git a/src/frontend/human/commands/doctor.rs b/crates/accelbyte-ags-cli/src/frontend/output/human/commands/doctor.rs similarity index 96% rename from src/frontend/human/commands/doctor.rs rename to crates/accelbyte-ags-cli/src/frontend/output/human/commands/doctor.rs index 344985f..6f47317 100644 --- a/src/frontend/human/commands/doctor.rs +++ b/crates/accelbyte-ags-cli/src/frontend/output/human/commands/doctor.rs @@ -4,7 +4,7 @@ use crate::errors::CliError; use crate::frontend::style; use crate::frontend::RenderOptions; use crate::frontend::RenderedOutput; -use crate::protocol::diagnostics::{CheckResult, CheckStatus, CheckTier, DoctorResult}; +use ags_protocol::diagnostics::{CheckResult, CheckStatus, CheckTier, DoctorResult}; /// Render doctor output as human-readable text pub(crate) fn render_doctor_output( @@ -54,7 +54,6 @@ fn render_doctor_text(output: &DoctorResult) -> String { .find(|c| c.status == CheckStatus::Warning) }); - // Render passing/skipped checks as detail rows render_tier_section( &mut lines, &report.checks, @@ -71,7 +70,7 @@ fn render_doctor_text(output: &DoctorResult) -> String { color, ); - // Headline at the bottom — like auth status + // Headline goes last so it reads as the report's conclusion. if let Some(check) = failed_check { let headline = format!("{} — {}", check.title, check.message); lines.push(style::error(&headline, color)); @@ -128,7 +127,7 @@ fn render_doctor_text(output: &DoctorResult) -> String { lines.join("\n") } -/// Append a heading and per-check lines for a single tier (e.g. "Required") into the rendered output. +/// Append a heading and per-check lines for a single tier (e.g. "Config", "Auth", "Network") into the rendered output. fn render_tier_section( lines: &mut Vec, checks: &[CheckResult], diff --git a/src/frontend/human/commands/mod.rs b/crates/accelbyte-ags-cli/src/frontend/output/human/commands/mod.rs similarity index 76% rename from src/frontend/human/commands/mod.rs rename to crates/accelbyte-ags-cli/src/frontend/output/human/commands/mod.rs index ea1ec27..8801796 100644 --- a/src/frontend/human/commands/mod.rs +++ b/crates/accelbyte-ags-cli/src/frontend/output/human/commands/mod.rs @@ -6,3 +6,5 @@ pub mod profile; pub mod refresh_specs; pub mod service; pub mod version; +pub mod workflow; +pub mod workflow_dry_run; diff --git a/src/frontend/human/commands/profile.rs b/crates/accelbyte-ags-cli/src/frontend/output/human/commands/profile.rs similarity index 83% rename from src/frontend/human/commands/profile.rs rename to crates/accelbyte-ags-cli/src/frontend/output/human/commands/profile.rs index 052ce61..8341363 100644 --- a/src/frontend/human/commands/profile.rs +++ b/crates/accelbyte-ags-cli/src/frontend/output/human/commands/profile.rs @@ -1,11 +1,11 @@ //! Human-readable rendering for profile command output. use crate::errors::CliError; -use crate::frontend::human::templates; +use crate::frontend::output::human::templates; use crate::frontend::style; use crate::frontend::RenderOptions; use crate::frontend::RenderedOutput; -use crate::protocol::output::{ +use ags_protocol::output::{ OperationWarning, ProfileOutput, ProfileShowData, ProfileSummary, ProfileView, }; @@ -107,13 +107,26 @@ fn render_profile_details( let value_or_unset = |value: &Option| value.as_deref().unwrap_or("not set").to_string(); let presence_or_unset = |present: bool| if present { "stored" } else { "not set" }.to_string(); + // Labels are the `ags config get/set` keys so the mapping is direct. The + // last two rows aren't writable config keys, so they carry a trailing note: + // `client-secret` is keychain-managed (read-only here) and `token` is + // session state, not configuration. let rows: Vec<(&str, String)> = vec![ - ("Base URL", value_or_unset(&config.base_url)), - ("Client ID", value_or_unset(&config.client_id)), - ("Namespace", value_or_unset(&config.namespace)), - ("Grant type", value_or_unset(&config.grant_type)), - ("Secret", presence_or_unset(config.has_secret)), - ("Token", presence_or_unset(config.has_token)), + ("base-url", value_or_unset(&config.base_url)), + ("client-id", value_or_unset(&config.client_id)), + ("namespace", value_or_unset(&config.namespace)), + ("grant-type", value_or_unset(&config.grant_type)), + ( + "client-secret", + format!( + "{} (keychain, read-only)", + presence_or_unset(config.has_secret) + ), + ), + ( + "token", + format!("{} (session)", presence_or_unset(config.has_token)), + ), ]; let block = diff --git a/src/frontend/human/commands/refresh_specs.rs b/crates/accelbyte-ags-cli/src/frontend/output/human/commands/refresh_specs.rs similarity index 97% rename from src/frontend/human/commands/refresh_specs.rs rename to crates/accelbyte-ags-cli/src/frontend/output/human/commands/refresh_specs.rs index 44ce93d..d8ae538 100644 --- a/src/frontend/human/commands/refresh_specs.rs +++ b/crates/accelbyte-ags-cli/src/frontend/output/human/commands/refresh_specs.rs @@ -3,8 +3,8 @@ use crate::errors::CliError; use crate::frontend::style; use crate::frontend::{RenderOptions, RenderedOutput}; -use crate::protocol::output::{RefreshMode, RefreshSpecsOutput}; -use crate::support::strings::pluralize; +use ags_protocol::output::{RefreshMode, RefreshSpecsOutput}; +use ags_runtime::support::strings::pluralize; /// Render refresh-specs output as human-readable text. pub(crate) fn render_refresh_specs_output( @@ -80,7 +80,7 @@ mod tests { /// Build a `RenderOptions` with quiet mode enabled for the suppression tests. fn quiet() -> RenderOptions { RenderOptions { - verbosity: crate::protocol::request::Verbosity::Quiet, + verbosity: ags_protocol::request::Verbosity::Quiet, ..RenderOptions::default() } } diff --git a/src/frontend/human/commands/service.rs b/crates/accelbyte-ags-cli/src/frontend/output/human/commands/service.rs similarity index 87% rename from src/frontend/human/commands/service.rs rename to crates/accelbyte-ags-cli/src/frontend/output/human/commands/service.rs index ad9ef12..aa0b0c3 100644 --- a/src/frontend/human/commands/service.rs +++ b/crates/accelbyte-ags-cli/src/frontend/output/human/commands/service.rs @@ -1,14 +1,14 @@ //! Human-readable rendering for API service responses. use crate::errors::CliError; -use crate::frontend::human::templates; +use crate::frontend::output::human::templates; use crate::frontend::presenters::service as service_presenter; use crate::frontend::style; use crate::frontend::PaginationHint; use crate::frontend::RenderOptions; use crate::frontend::RenderedOutput; -use crate::protocol::catalogue::OperationSchema; -use crate::protocol::output::{ +use ags_protocol::catalogue::OperationSchema; +use ags_protocol::output::{ ApiBody, ApiOutput, ApiSuccess, CommandIntent, ExecutionTrace, FieldEntry, ResolutionTrace, Section, }; @@ -43,11 +43,19 @@ pub(crate) fn render_api_output( }) } -/// Render a dry-run output showing the HTTP request that would be sent +/// Render a dry-run output showing the HTTP request that would be sent. +/// +/// The method+URL line stays at the default colour (headline), and the +/// detail lines below (Query, headers, Body) render in dim — secondary +/// information for inspecting the composed request. The Body is +/// pretty-printed across multiple lines so JSON is readable. pub(crate) fn render_dry_run_output( - report: &crate::protocol::result::DryRunResult, + report: &ags_protocol::result::DryRunResult, ) -> Result { let request_view = service_presenter::present_dry_run(report); + let color_enabled = style::is_stdout_enabled(); + let dim = |s: String| style::apply_tone(&s, style::Tone::Dim, color_enabled); + let mut lines = vec![format!("{} {}", request_view.http_method, request_view.url)]; if !request_view.query.is_empty() { let pairs: Vec = request_view @@ -55,16 +63,18 @@ pub(crate) fn render_dry_run_output( .iter() .map(|(k, v)| format!("{k}={v}")) .collect(); - lines.push(format!(" Query: {}", pairs.join(", "))); + lines.push(dim(format!(" Query: {}", pairs.join(", ")))); } for (key, value) in &request_view.headers { - lines.push(format!("{key}: {value}")); + lines.push(dim(format!("{key}: {value}"))); } if let Some(body) = &request_view.body { - lines.push(format!( - "Body: {}", - serde_json::to_string(body).expect("serializing serde_json::Value is infallible") - )); + let pretty = serde_json::to_string_pretty(body) + .expect("serializing serde_json::Value is infallible"); + lines.push(dim("Body:".into())); + for body_line in pretty.lines() { + lines.push(dim(body_line.to_string())); + } } Ok(RenderedOutput { stdout: Some(lines.join("\n")), @@ -119,12 +129,12 @@ pub(crate) fn render_resolution_trace(resolution: &ResolutionTrace) -> String { /// Render a `CommandResult` as a human-readable string. fn render_command_result( - result: &crate::protocol::result::CommandResult, + result: &ags_protocol::result::CommandResult, operation: &OperationSchema, resource_name: &str, options: &RenderOptions, ) -> Result { - use crate::protocol::result::CommandResult; + use ags_protocol::result::CommandResult; let color_enabled = style::is_stdout_enabled(); match result { CommandResult::Collection(collection) => { @@ -139,13 +149,13 @@ fn render_command_result( color_enabled, )), CommandResult::Empty(_) => Ok(String::new()), - CommandResult::Raw(raw) => crate::frontend::json::format_json(&raw.value), + CommandResult::Raw(raw) => crate::frontend::output::json::format_json(&raw.value), } } /// Render a list-style API response as a column table, honouring quiet mode and pagination hints. fn render_collection( - collection: &crate::protocol::result::CollectionResult, + collection: &ags_protocol::result::CollectionResult, options: &RenderOptions, color_enabled: bool, ) -> String { @@ -187,14 +197,14 @@ fn render_collection( /// Render a single-entity API response — heading, fields, and grouped sections — for action, inspect, or list intents. fn render_entity( - entity: &crate::protocol::result::EntityResult, + entity: &ags_protocol::result::EntityResult, intent: CommandIntent, resource_name: &str, operation: &OperationSchema, options: &RenderOptions, color_enabled: bool, ) -> String { - use crate::protocol::result::HeadingStyle; + use ags_protocol::result::HeadingStyle; let field_entries: Vec = entity.fields.iter().map(protocol_field_to_entry).collect(); @@ -210,7 +220,7 @@ fn render_entity( match intent { CommandIntent::Action => { - // The verb-specific success line ("✔ Updated stat-definition.") + // The verb-specific success line ("✔ Updated stat-definition") // is rendered separately to stderr from the `ApiSuccess` summary, // so the stdout block here is just the field rows. A second // generic "✔ operation completed" heading would be @@ -226,7 +236,7 @@ fn render_entity( } CommandIntent::Inspect | CommandIntent::List => { if field_entries.is_empty() && section_entries.is_empty() { - let noun = crate::support::strings::derive_noun_from_method( + let noun = ags_runtime::support::strings::derive_noun_from_method( &operation.name, resource_name, ); @@ -262,7 +272,7 @@ fn render_entity( } /// Convert a protocol-level `Field` into the `FieldEntry` shape the human templates expect. -fn protocol_field_to_entry(field: &crate::protocol::result::Field) -> FieldEntry { +fn protocol_field_to_entry(field: &ags_protocol::result::Field) -> FieldEntry { FieldEntry { label: field.label.clone(), value: field_value_to_display(&field.value), @@ -270,8 +280,8 @@ fn protocol_field_to_entry(field: &crate::protocol::result::Field) -> FieldEntry } /// Render a single typed field value as the human-friendly string used in tables and inspect views. -fn field_value_to_display(value: &crate::protocol::result::FieldValue) -> String { - use crate::protocol::result::FieldValue; +fn field_value_to_display(value: &ags_protocol::result::FieldValue) -> String { + use ags_protocol::result::FieldValue; match value { FieldValue::Text(string) => string.clone(), FieldValue::Number(number) => { @@ -300,8 +310,7 @@ pub(crate) fn render_execution_trace_string(trace: &ExecutionTrace) -> String { render_execution_trace(trace) } -/// Internal: render the verbose trace block (private helper used by both the -/// success-path renderer and the error-path renderer in [`crate::frontend::human::frontend`]). +/// Render the verbose trace block, shared by the success-path and error-path renderers. fn render_execution_trace(trace: &ExecutionTrace) -> String { let color_enabled = style::is_stderr_enabled(); let mut lines = Vec::new(); @@ -365,11 +374,11 @@ fn render_execution_trace(trace: &ExecutionTrace) -> String { #[cfg(test)] mod tests { use super::*; - use crate::protocol::catalogue::{ + use ags_protocol::catalogue::{ ApiVersion, HttpMethod, MutationClass, OperationId, OperationSchema, }; - use crate::protocol::output::{ApiBody, ApiOutput, ApiSuccess}; - use crate::protocol::result::DryRunResult; + use ags_protocol::output::{ApiBody, ApiOutput, ApiSuccess}; + use ags_protocol::result::DryRunResult; /// Build a minimal DELETE operation schema for tests. fn make_delete_operation() -> OperationSchema { @@ -389,6 +398,7 @@ mod tests { api_version: ApiVersion(1), deprecated: false, response_content_type: None, + has_file_upload: false, } } @@ -404,16 +414,17 @@ mod tests { resource_name: "stat-definitions".to_string(), body: ApiBody::Empty, success: Some(ApiSuccess { - summary: "Deleted stat-definition.".to_string(), + summary: "Deleted stat-definition".to_string(), }), trace: None, + raw_body: None, }; let options = RenderOptions::default(); let rendered = render_api_output(&output, &options).expect("render must succeed"); assert!(rendered.stdout.is_none(), "no body → no stdout"); let stderr = rendered.stderr.expect("success line must be on stderr"); assert!( - stderr.contains("Deleted stat-definition."), + stderr.contains("Deleted stat-definition"), "stderr should contain success summary; got: {stderr:?}" ); } diff --git a/src/frontend/human/commands/version.rs b/crates/accelbyte-ags-cli/src/frontend/output/human/commands/version.rs similarity index 90% rename from src/frontend/human/commands/version.rs rename to crates/accelbyte-ags-cli/src/frontend/output/human/commands/version.rs index 3c97d97..25e68ba 100644 --- a/src/frontend/human/commands/version.rs +++ b/crates/accelbyte-ags-cli/src/frontend/output/human/commands/version.rs @@ -2,7 +2,7 @@ use crate::errors::CliError; use crate::frontend::{RenderOptions, RenderedOutput}; -use crate::protocol::output::VersionOutput; +use ags_protocol::output::VersionOutput; /// Render version as human-readable text. pub fn render_version_output( diff --git a/crates/accelbyte-ags-cli/src/frontend/output/human/commands/workflow.rs b/crates/accelbyte-ags-cli/src/frontend/output/human/commands/workflow.rs new file mode 100644 index 0000000..b52d7a4 --- /dev/null +++ b/crates/accelbyte-ags-cli/src/frontend/output/human/commands/workflow.rs @@ -0,0 +1,523 @@ +//! Human-readable rendering for completed workflow runs. + +use crate::errors::CliError; +use crate::frontend::RenderedOutput; +use ags_protocol::output_views::{ + WorkflowOutputItem, WorkflowOutputProvenance, WorkflowOutputView, +}; +use ags_protocol::workflow::{WorkflowId, WorkflowListEntry}; +use std::collections::BTreeMap; + +/// Render the final output of a completed workflow run. +/// +/// Emits a short header plus any completion guidance (Created / Next steps) to +/// stderr, and the output data to stdout. `is_stdout_first` is `false`, so the +/// stderr guidance appears before the stdout data, separated by a blank line. +/// Per-step summaries are not repeated here — they stream live during the run. +/// +/// When `output_view` is `Some`, stdout is built from the structured view: +/// items are grouped in declaration order under their `section` headings, +/// each line `