On this page
Gate steps between state-save and exec: guardrail choice, reconciliation, model-version guard, hook approval, and scratchpad cleanup.
#claudewheel.preflight
#claudewheel.preflight
Pre-launch step framework: a deterministic sequence of gate steps.
A "preflight" is a fixed, registration-ordered list of steps that run after state has been saved but before the launch config is resolved and the child process is exec'd. Each step inspects a shared :class:PreflightContext and returns a :class:StepResult that either lets the sequence CONTINUE or ABORTs it with an actionable message. A step that decides it has nothing to do simply CONTINUEs (there is no separate "skip" verdict -- skipping is CONTINUE without acting).
The framework is intentionally content-free: :data:PREFLIGHT_STEPS starts empty and later phases register concrete steps. The runner is fully testable with synthetic steps.
UI-rendering steps (renders_ui=True) are responsible for constructing and tearing down their own raw-mode terminal; the call site runs in cooked mode.
#Decision
The two verdicts a preflight step can return.
#StepResult
The outcome of running a single preflight step.
ABORT carries an actionable message explaining why the launch was stopped; CONTINUE carries no message.
#cont
def cont(cls) -> 'StepResult'A CONTINUE result (the sequence proceeds to the next step).
#abort
def abort(cls, message: str) -> 'StepResult'An ABORT result carrying an actionable message.
#is_abort
def is_abort(self) -> boolTrue when this result stops the sequence.
#PreflightContext
Shared, read-only-ish state handed to every preflight step.
interactive is False on the skip-TUI/print path; steps that render UI or otherwise require a human are gated on it via :attr:PreflightStep.runs_in_non_interactive.
#PreflightStep
A single registered step in the preflight sequence.
name: stable identifier, used in diagnostics.runs_in_non_interactive: when False, the step is skipped entirely on
the non-interactive (print/skip-TUI) path.
renders_ui: when True, the step manages its own raw-mode terminal; the
call site guarantees the terminal is in cooked mode on entry.
run: the callable that inspects the context and returns a StepResult.
#_canonical_hook_scripts
def _canonical_hook_scripts() -> set[str]The canonical hook script basenames cw manages (from the guardrail model).
#ensure_vanilla_guardrails
def ensure_vanilla_guardrails(ws: 'Workspace') -> boolAdditively inject cw's canonical hook wiring into ~/.claude/settings.json.
Deploys any missing guardrail hook scripts, then merges the canonical hook wiring into ~/.claude/settings.json via merge_hooks -- never pruning, never touching non-hook keys. Idempotent: when the wiring is already present the file is left byte-identical (no write). Returns True iff a write happened.
#remove_vanilla_guardrails
def remove_vanilla_guardrails(ws: 'Workspace') -> boolRemove EXACTLY cw's known hook entries from ~/.claude/settings.json.
Matches by the canonical hook script basenames -- user-authored hooks and all non-hook keys are left byte-identical. Hook entries emptied of every cw hook are dropped; events emptied of every entry are dropped. Idempotent: no cw hooks present -> no write. Returns True iff a write happened.
#_prompt_vanilla_choice
def _prompt_vanilla_choice(ctx: PreflightContext) -> boolRender the one-time vanilla/guardrails choice; return True iff opt-in.
Builds a themed Terminal the same way the other UI-rendering steps do and offers two keys: stay vanilla (the default, any non-g key) or inject cw guardrails (g). The terminal is closed on the way out.
#_vanilla_choice_run
def _vanilla_choice_run(ctx: PreflightContext) -> StepResultOne-time vanilla-vs-guardrails choice for the default profile.
Acts only when the selected/effective profile is the default (explicit "default" or the no-profile fallback). Reads the machine-global opt-in tri-state (the ~/.claude guardrail surface is machine-global, so this choice is per-user, not per-project):
- unset + interactive -> render the one-time choice page and persist the
answer; if the user opts in, inject cw's guardrail hooks;
- unset + non-interactive -> proceed vanilla WITHOUT prompting or persisting
(the offer stays open for the next interactive launch);
- already opted in -> (idempotently) ensure the guardrail hooks are present;
- already opted out -> do nothing.
Never ABORTs -- this is a setup choice, not a gate.
#_reconcile_guardrails_run
def _reconcile_guardrails_run(ctx: PreflightContext) -> StepResultHeal the guardrail surface to canonical before every launch.
Runs the unified reconcile core over shared-settings and all managed profiles (the "default" profile is excluded by the core). This is a best-effort self-heal: it deploys missing hook scripts and prunes drift to canonical, and it NEVER aborts a launch. Per-target load/write errors are already captured inside the core; any residual, unexpected failure is swallowed here so a reconcile problem can never block launching. Concurrent launches racing on the same files are fine -- the output is idempotent.
#_model_version_guard_run
def _model_version_guard_run(ctx: PreflightContext) -> StepResultBlock launching a model on a Claude Code binary that is too old.
Reads the selected model (stripping a trailing [1m] context-window suffix) and looks up its minimum CLI version in :data:MODEL_MIN_CLI_VERSION. Models absent from the table pass unguarded. The effective binary version comes from :func:claudewheel.binaries.effective_cli_version, the one place that resolution is written down -- the model picker's dimming calls it too. If no version can be determined the guard passes (it only acts on a positive too-old determination). A binary older than the model's minimum aborts with an actionable message.
#_release_notes_seen_run
def _release_notes_seen_run(ctx: PreflightContext) -> StepResultMark the launched Claude Code version as seen, so no update summary shows.
Claude Code keeps a lastReleaseNotesSeen key in its global config file (.claude.json, which lives INSIDE the profile directory because CLAUDE_CONFIG_DIR points there). At startup, whenever that key holds a version string LOWER than the running version, the client prints "Updated to latest. Got N features, N bugfixes, and N other changes." plus a changelog-URL line -- and then writes the running version into the key itself. An absent or non-version value shows nothing. There is no settings key and no environment variable that turns the summary off, so the only way to prevent it is to pre-seed the key with the version about to be launched.
This is undocumented client surface, read out of the Claude Code 2.1.263 binary: the key name, the lower-than comparison and the client's own write-back are behavior claudewheel observes rather than an interface Claude Code promises.
- no profile, or the vanilla
default(Claude Code's own~/.claude,
strictly read-only to claudewheel) -> CONTINUE;
- no determinable effective version -> CONTINUE;
- a resolved version that is not
MAJOR.MINOR.PATCH-> CONTINUE,
touching nothing: with no version selected the effective version is the claude symlink target's directory name, which need not be a version at all, and a non-version value written into the key would be rewritten by the client on every launch;
- no
.claude.jsonyet -> CONTINUE without creating one: the client
creates the file on first run with the key absent, which shows nothing;
- a file that is not a JSON object -> CONTINUE with one informational line,
touching nothing: a file claudewheel cannot read safely is not one it rewrites;
- a stored version at or above the launched one -> CONTINUE;
- otherwise the key is set to the launched version and the file written back
with every other key preserved.
Never aborts: a read or write failure is reported as one informational line and the launch proceeds. Under --dry-run the write is recorded rather than performed -- the effects layer handles that.
#_prompt_plan
def _prompt_plan(ctx: PreflightContext, profile: str) -> 'PlanTier | None'Render the composite plan picker and return the chosen plan.
Builds a themed Terminal the way the other prompting steps do and defers to :func:claudewheel.wizard.pick_plan -- the one picker the creation flow uses too, so the two surfaces cannot offer different plans or store different fields. Returns None when the user cancels.
#_plan_declaration_run
def _plan_declaration_run(ctx: PreflightContext) -> StepResultRequire a declared plan for a profile launching on a stored token.
Claude Code resolves its subscription tier from the environment and ONLY from the environment when auth arrives as a setup token: its own fallback (fetching the OAuth profile) is refused for lack of the user:profile scope. With no declared plan the tier is null and tier-dependent checks fail closed, so this is the last place to ask before that happens.
- no profile, or the vanilla
default(Claude Code's own~/.claude,
which cw injects nothing into) -> CONTINUE;
- no stored token -> CONTINUE: the profile authenticates from Claude Code's
own credential file, which carries the tier already;
- a declared plan -> CONTINUE, nothing to ask;
- interactive -> render the picker and store the answer; a cancelled picker
ABORTs naming the scripted command;
- non-interactive -> ABORT naming the scripted command and the valid plans.
A headless launch has nobody to ask, and proceeding would silently be the broken-tier launch this step exists to prevent.
A corrupt token entry raises :class:TokenStoreError, which the launch handler reports as the actionable message it is.
#_make_terminal
def _make_terminal() -> 'Terminal'Construct the raw-capable Terminal for an approval page.
Isolated so tests can substitute a FakeTerminal. Requires a real TTY; a headless environment fails here, loudly.
#_prompt_hook_approval
def _prompt_hook_approval(ctx: PreflightContext, listing: list[str], changed: bool) -> boolRender the approval page and return True iff the user approves.
Constructs a themed Terminal the way cli.py's non-TUI interactive flows do, lists every hook (event, matcher, command), and offers approve/decline keys. Approve is the y key; anything else -- including n, q, ESC, or an interrupt -- declines. The terminal is closed on the way out.
#_approved_hooks_run
def _approved_hooks_run(ctx: PreflightContext) -> StepResultGate the launch on the target project's Claude Code hooks being approved.
Reads the target project's hooks (.claude/settings.json + settings.local.json). Malformed config aborts, naming the broken file. No hooks -> CONTINUE (nothing stored). Otherwise the combined fingerprint is compared against the stored approval for this project (keyed by the realpath-canonical directory):
- matching fingerprint -> CONTINUE, no prompt;
- missing or changed fingerprint, interactive -> render the approval page;
approve persists the fingerprint and CONTINUEs, decline (or ESC/quit) ABORTs;
- missing or changed fingerprint, non-interactive -> ABORT with an
actionable message (never silent trust).
#_prompt_scratchpad_cleanup
def _prompt_scratchpad_cleanup(ctx: PreflightContext, stale: 'list[ScratchpadDir]', now_ts: float) -> boolRender the scratchpad-cleanup page and return True iff the user confirms.
Builds a themed Terminal the same way :func:_prompt_hook_approval does, lists each stale directory (name, human-readable size, age in whole days), and offers a single delete-all key. Confirm is the y key; anything else -- n, q, ESC, or an interrupt -- declines. The terminal is closed on the way out.
#_scratchpad_cleanup_run
def _scratchpad_cleanup_run(ctx: PreflightContext) -> StepResultOffer to delete stale Claude Code scratchpad dirs under /tmp (confirmed).
Interactive-only (skipped by the runner on the non-interactive path). Honors a snooze: if the stored scratchpad_snooze_until deadline is in the future, CONTINUE silently WITHOUT scanning (no filesystem work at all). Otherwise the scratchpad tree is scanned; when no directory is stale, CONTINUE silently. When stale dirs exist, render a confirmation page:
- confirm ->
effects.rmtreeeach stale dir. Per-dir errors are collected
and reported to stderr but NEVER abort the launch; deletion continues for the remaining dirs. CONTINUE.
- decline -> set the snooze to now + :data:
SCRATCHPAD_SNOOZE_DAYSdays and
CONTINUE.
This step never ABORTs -- scratchpad cleanup is best-effort housekeeping.
#run_preflight
def run_preflight(ctx: PreflightContext, steps: Sequence[PreflightStep] | None=None) -> StepResult | NoneRun steps in order against ctx, honoring the non-interactive gate.
When ctx.interactive is False, steps whose runs_in_non_interactive is False are skipped. The first ABORT halts the sequence and is returned to the caller. Returns None when every applicable step CONTINUEs.
steps defaults to the module-level :data:PREFLIGHT_STEPS; tests pass a synthetic list.