plans/2026-02-05-cli-cyclopts-migration.md
Migrate the Prefect CLI from Typer to Cyclopts in an incremental, low-risk way that preserves CLI behavior and improves help/discovery startup time. The plan must define command registration, incremental adoption, and rollback strategy before large-scale command rewrites begin.
Each command in the CLI is in one of two states during the migration:
src/prefect/cli/_cyclopts/<command>.py. Cyclopts handles parsing and execution directly._delegate(). Behavior is identical to typer-only mode.A command moves from delegated to migrated by replacing its stub with a real cyclopts implementation and adding parity tests.
# delegated (stub that forwards to typer)
deploy_app = cyclopts.App(name="deploy", help="Create and manage deployments.")
_app.command(deploy_app)
@deploy_app.default
def deploy_default(*tokens: str):
_delegate("deploy", tokens)
# migrated (real cyclopts implementation)
@config_app.command()
def view(
*,
show_defaults: Annotated[bool, cyclopts.Parameter("--show-defaults", negative="--hide-defaults")] = False,
show_sources: Annotated[bool, cyclopts.Parameter("--show-sources", negative="--hide-sources")] = True,
):
...
We will keep Typer as the default until parity is established, and introduce a parallel Cyclopts entrypoint for migration testing behind an internal migration toggle. Delegated commands will forward to Typer. This provides a safe, incremental path with clear escape hatches.
Toggle: internal environment variable (not user-facing).
Empirically implemented in #20549:
PREFECT_CLI_FAST=1 enables Cyclopts.Wiring (internal-only, not documented for users):
src/prefect/cli/__init__.py (the console entrypoint for prefect).prefect.cli._cyclopts.app; otherwise route to prefect.cli.root.app.Routing code (from #20549):
_USE_CYCLOPTS = os.environ.get("PREFECT_CLI_FAST", "").lower() in ("1", "true")
def app() -> None:
if _should_delegate_to_typer(sys.argv[1:]):
load_typer_commands()
typer_app()
else:
cyclopts_app()
Notes:
Problem: The Typer root callback currently sets up settings, console configuration, logging, and Windows event loop policy. Cyclopts must honor the same behavior and global flags, or behavior will diverge.
Plan (implemented in #20549):
src/prefect/cli/_cyclopts/__init__.py:
prefect.context.use_profile(...)PREFECT_CLI_PROMPTprefect --profile <x> and prefect --prompt/--no-prompt behave identically in Typer and Cyclopts modes (validated by parity tests).Entrypoint snippet (from #20549):
@_app.meta.default
def _root_callback(..., profile: Optional[str] = None, prompt: Optional[bool] = None):
...
if profile and prefect.context.get_settings_context().profile.name != profile:
with prefect.context.use_profile(profile, override_environment_variables=True):
_run_with_settings()
else:
_run_with_settings()
Acceptance:
Scope: Root callback, global flags, console/logging setup. Pattern proven in #20549; to be landed as part of this work.
Problem: We need a single source of truth for command registration and a safe, incremental migration path that preserves parity.
Plan (implemented in #20549):
src/prefect/cli/_cyclopts/__init__.py.src/prefect/cli/_typer_loader.py (used by both entrypoints).Delegation mechanism (from #20549):
def _delegate(command: str, tokens: tuple[str, ...]) -> None:
load_typer_commands()
typer_app([command, *tokens], standalone_mode=False)
Acceptance:
Scope: Toggle wiring, delegation stubs for all commands, typer loader module. Pattern proven in #20549; to be landed as part of this work.
Problem: We need a repeatable migration pattern and an ordering that reduces risk.
Plan:
src/prefect/cli/_cyclopts/__init__.py (pattern proven in #20549).Migration template for a command group (aligned with #20549):
src/prefect/cli/_cyclopts/<command>.py with Cyclopts app + commands.src/prefect/cli/_cyclopts/__init__.py.tests/cli/test_cyclopts_parity.py for exit codes and core output.benches/cli-bench.toml.Worked example (Config → Cyclopts, abridged from #20549):
# Typer (today)
@config_app.command()
def view(...): ...
# Cyclopts (target)
@config_app.command()
def view(
show_defaults: Annotated[bool, cyclopts.Parameter("--show-defaults", negative="--hide-defaults")] = False,
...
): ...
Suggested order (all top-level command groups):
Wave 1 — low risk, minimal network/server interaction, good for proving parity:
config (view, set, unset, validate)profile (ls, create, delete, rename, populate-defaults, use, inspect)versionWave 2 — high-traffic, primarily CLI orchestration:
server (start, services, status)worker (start)shell (serve, watch)Wave 3 — complex behavior, larger surface area:
deploy (entrypoint, init)flow-run (ls, inspect, cancel, delete, logs, execute)flow (ls, serve)deployment (ls, inspect, run, schedule, pause, resume, delete, apply, build)Wave 4 — moderate complexity, server-backed CRUD:
work-pool (ls, create, delete, inspect, pause, resume, set-concurrency-limit, clear-concurrency-limit, preview, get-default-base-job-template, update)work-queue (ls, create, delete, inspect, pause, resume, set-concurrency-limit, clear-concurrency-limit)variable (ls, get, set, unset, inspect)block (ls, create, delete, inspect, register)concurrency-limit / global-concurrency-limitWave 5 — remaining commands:
cloud (login, logout, workspace ls/set/create, webhook, asset, ip-allowlist)artifact (ls, inspect, delete)automation (ls, inspect, delete, pause, resume, create)event (stream, emit)task / task-runapi (raw HTTP verbs)dashboard (open)dev (start, build-image, container, api-ref)transfersdk (generate)Acceptance:
Status: Phase 2 complete. Full test suite passes under PREFECT_CLI_FAST=1 (1189 passed, 8 skipped). All command groups have native cyclopts implementations.
Problem: Cyclopts implementations live in src/prefect/cli/_cyclopts/ alongside the typer originals. We need to make cyclopts the sole CLI, promote the _cyclopts/ files to be the primary modules, and delete typer.
src/prefect/cli/
├── _cyclopts/ ← 29 files, ~11,850 lines (new impl)
│ ├── __init__.py ← app, root callback, command registrations
│ ├── _utilities.py ← exit helpers, exception handling
│ └── <command>.py ← one per command group
│
├── <command>.py ← ~20 typer command files, ~9,200 lines (to delete)
├── root.py, _typer_loader.py ← typer infrastructure (to delete)
├── cloud/ ← typer cloud subpackage (to delete)
│
├── __init__.py ← toggle/routing logic (to simplify)
│
├── _prompts.py ← shared — 14 cyclopts files import from it
├── _server_utils.py ← shared — cyclopts server.py imports from it
├── _cloud_utils.py ← shared — cyclopts cloud.py imports from it
├── _worker_utils.py ← shared — cyclopts worker.py imports from it
├── _transfer_utils.py ← shared — cyclopts transfer.py imports from it
├── flow_runs_watching.py ← shared — cyclopts deployment.py imports from it
├── deploy/ ← shared business logic (cyclopts deploy.py imports from it)
└── transfer/ ← shared business logic (cyclopts transfer.py imports from it)
Two typer command files export non-CLI functions that the cyclopts side imports:
profile.py exports ConnectionStatus, check_server_connection → used by _cyclopts/profile.pyshell.py exports run_shell_process → used by _cyclopts/shell.pyWhen the cyclopts files move up to replace the typer files, these functions move into the new profile.py and shell.py directly — no intermediate utility modules needed.
Invert the toggle so cyclopts is the default and typer is the opt-in escape hatch.
src/prefect/cli/__init__.py: _USE_TYPER = os.environ.get("PREFECT_CLI_TYPER", "").lower() in ("1", "true"); default path goes straight to _cyclopts_app()src/prefect/testing/cli.py: invert runner selectionPREFECT_CLI_FAST → removed, PREFECT_CLI_TYPER → opt-inStatus:
src/prefect/cli/__init__.py — invert togglesrc/prefect/testing/cli.py — invert runner selection.github/workflows/python-tests.yaml — update CI matrixbenches/cli-bench.toml — update benchmark env varsPREFECT_CLI_FAST or _USE_CYCLOPTSuv run pytest tests/cli/ -n4 passes (cyclopts default)PREFECT_CLI_TYPER=1 uv run pytest tests/cli/ -n4 passes (typer fallback)Move _cyclopts/ contents up to cli/, absorb shared functions from the typer files they replace, delete all typer code, remove the toggle.
Rename: git mv each _cyclopts/<command>.py to cli/<command>.py, replacing the typer version. _cyclopts/__init__.py merges into cli/__init__.py. _cyclopts/_utilities.py replaces cli/_utilities.py.
Absorb shared functions: When _cyclopts/profile.py becomes cli/profile.py, add ConnectionStatus and check_server_connection directly into it (moved from the typer profile.py being replaced). Same for run_shell_process into the new shell.py.
Import path rewrite: 84 occurrences of prefect.cli._cyclopts across 31 source files → prefect.cli. ~17 test files with monkeypatch targets.
Delete:
root.py, _typer_loader.py, _types.py, typer _utilities.py, typer cloud/, events/cli/automations.py)_should_delegate_to_typer, _CYCLOPTS_COMMANDS, _DELEGATE_FLAGS)test_cyclopts_parity.py, test_cyclopts_runner.py)invoke_and_assert in testing/cli.pyPREFECT_CLI_TYPER env var and CI matrix legtyper from pyproject.toml dependencies (and client/pyproject.toml if listed)Status:
git mv all _cyclopts/ files up to cli/ConnectionStatus/check_server_connection into new profile.pyrun_shell_process + helpers into new shell.pyprefect.cli._cyclopts import paths (~50 files)root.py, _typer_loader.py, _types.py)cli/__init__.py — remove toggle, routing, delegate flagstesting/cli.py — remove typer runner branchtyper from dependenciesrg "prefect.cli._cyclopts" src/ tests/ returns zero matchesrg "PREFECT_CLI_FAST|PREFECT_CLI_TYPER" src/ tests/ .github/ returns zero matchesrg "import typer" src/prefect/ returns zero matchesuv run pytest tests/cli/ tests/events/client/cli/ -n4 passesinvoke_and_assertThe CLI test suite uses invoke_and_assert (src/prefect/testing/cli.py), which wraps an internal CycloptsCliRunner to test commands in-process. There are ~950 call sites across 35 test files.
During the migration (Phases 0–2), invoke_and_assert supported both frameworks — CycloptsCliRunner when PREFECT_CLI_FAST=1, typer's CliRunner otherwise. After Phase 3, the typer branch is removed and CycloptsCliRunner becomes the sole runner.
CycloptsCliRunner (src/prefect/testing/cli.py)Analogous to Click's CliRunner, this provides in-process invocation of the cyclopts CLI with proper I/O isolation. Cyclopts does not ship a built-in test runner (issue #238 was closed by design), so we maintain our own.
Design:
TTY-emulating StringIO — a StringIO subclass with isatty() -> True. Rich Console resolves sys.stdout dynamically via its file property, so redirecting sys.stdout to this buffer means all Console output is captured. The TTY emulation makes Console.is_interactive return True, enabling Confirm.ask() / Prompt.ask() to work correctly — matching real terminal behavior.
State isolation — saves and restores sys.stdout, sys.stderr, sys.stdin, os.environ["COLUMNS"], and the global _cli.console in a try/finally block. Not thread-safe (mutates interpreter globals), but safe with pytest-xdist which forks separate worker processes.
Exit code handling — catches SystemExit to extract exit codes.
Wide terminal — sets COLUMNS=500 to prevent Rich from wrapping long lines, which would cause brittle output assertions.
prefect.cli._cyclopts.
Mitigation: _cyclopts is a private module (leading underscore), not public API.rg sweep for all _cyclopts references before and after.rg "import typer" src/prefect/ as final validation.uv run pytest tests/cli/ tests/events/client/cli/ -n4 passes with no env varsrg "prefect.cli._cyclopts" src/ tests/ returns zero matchesrg "PREFECT_CLI_FAST|PREFECT_CLI_TYPER" src/ tests/ .github/ returns zero matchesrg "import typer" src/prefect/ returns zero matchesprefect --help, prefect --version, prefect config view work correctlybenches/cli-bench.toml.github/workflows/benchmarks.yamlsrc/prefect/cli/__init__.py