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 is the selected version if set, else the resolved symlink target's version name. 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.
#_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.