Skip to content
claudewheel.preflight
Edit
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

python
def cont(cls) -> 'StepResult'

A CONTINUE result (the sequence proceeds to the next step).

#abort

python
def abort(cls, message: str) -> 'StepResult'

An ABORT result carrying an actionable message.

#is_abort

python
def is_abort(self) -> bool

True 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

python
def _canonical_hook_scripts() -> set[str]

The canonical hook script basenames cw manages (from the guardrail model).

#ensure_vanilla_guardrails

python
def ensure_vanilla_guardrails(ws: 'Workspace') -> bool

Additively 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

python
def remove_vanilla_guardrails(ws: 'Workspace') -> bool

Remove 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

python
def _prompt_vanilla_choice(ctx: PreflightContext) -> bool

Render 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

python
def _vanilla_choice_run(ctx: PreflightContext) -> StepResult

One-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

python
def _reconcile_guardrails_run(ctx: PreflightContext) -> StepResult

Heal 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

python
def _model_version_guard_run(ctx: PreflightContext) -> StepResult

Block 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

python
def _release_notes_seen_run(ctx: PreflightContext) -> StepResult

Mark 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.json yet -> 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

python
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

python
def _plan_declaration_run(ctx: PreflightContext) -> StepResult

Require 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

python
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

python
def _prompt_hook_approval(ctx: PreflightContext, listing: list[str], changed: bool) -> bool

Render 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

python
def _approved_hooks_run(ctx: PreflightContext) -> StepResult

Gate 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

python
def _prompt_scratchpad_cleanup(ctx: PreflightContext, stale: 'list[ScratchpadDir]', now_ts: float) -> bool

Render 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

python
def _scratchpad_cleanup_run(ctx: PreflightContext) -> StepResult

Offer 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.rmtree each 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_DAYS days and

CONTINUE.

This step never ABORTs -- scratchpad cleanup is best-effort housekeeping.

#run_preflight

python
def run_preflight(ctx: PreflightContext, steps: Sequence[PreflightStep] | None=None) -> StepResult | None

Run 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.

More tools from this site

  • claudestream Drive Claude Code from Python: run it as a subprocess and read its output as typed events, with async and sync sessions, sandbox policies, and tools you define in Python
  • dirstat Fast, single-binary directory statistics CLI: every file under a tree grouped by format, with counts, sizes, and lines of code, as a colored terminal table or as JSON
  • fastware A batteries-included ASGI framework: msgspec JSON, a managed Granian server, dependency injection, SSE, WebSockets, auth, and a test client
  • go-toml-edit Zero-dep TOML editing library for Go with comment preservation
  • howmuchleft The fastest Claude Code statusline: context window, 5-hour, and weekly limit usage as three customizable gradient bars, rendering in about 6 ms
  • orxtra
  • pgdesign
  • predraw Declarative rendering pipeline: describe a scene in JSON and get SVG, PNG and WebP out, with light and dark style tokens, reusable components and text converted to path outlines
  • reposummary Turn a git repository's history into a Markdown journal: pick a time window or revision range and get a readable digest of what changed, optionally narrated by an LLM
  • rlsbl Release orchestration and project scaffolding CLI that bumps versions, validates a structured JSONL changelog, tags only the commit CI verified, and publishes to npm, PyPI, Go and more
  • safegit git wrapper CLI that gives each commit its own temporary index and retries ref updates on conflict, so concurrent agents share one repository
  • saferm Command-line replacement for rm that archives every deletion with a mandatory reason and the context it ran in, so deleted files can be listed, inspected and restored
  • selfdoc Static Site Generator that builds a project's documentation site directly from its source code, so the docs can never drift from the code they describe, with SEO/AEO, first-class blog, search, and cross-project linking built in
  • strictcli
  • stricttest An always-on test-isolation floor: a pytest plugin and a Go env-hygiene module that make a test suite structurally unable to reach real credentials, the real HOME, the network, or the development repository.
  • wesktop A Python framework that turns an ASGI web app into a desktop application, serving it from a local Granian server and displaying it in a native OS window via pywebview
Search