On this page
CLI argument parsing, subcommand routing, and launch orchestration for the claudewheel command-line interface.
#claudewheel.cli
#claudewheel.cli
CLI argument parsing, subcommand routing, and launch orchestration.
#_absent
def _absent(value: Any, fallback: Any) -> AnyResolve an optional flag's absence to the fallback its own help declares.
strictcli forbids default= on any flag or arg of a mutating command: a value the framework picks is a value the framework writes. The opt-in switches of claudewheel's mutating commands (--all, --force-overwrite, --reid, --post-hoc) therefore declare presence="optional" and name their fallback in their help text, and this is the single place where absence becomes that fallback -- so no downstream branch ever sees a None it would read as a value.
#_do_uninstall
def _do_uninstall(locator: 'BinaryLocator', version: str) -> intDelete an installed Claude Code version binary.
Refuses to delete the version the claude symlink currently points to, since that would break the default claude command. Returns a process exit code.
#_do_reset_options
def _do_reset_options(ws: 'Workspace') -> intDelete options.json so it regenerates from defaults on next run.
Does NOT instantiate AppConfigStore -- the next normal run will recreate options.json via _ensure_dir. Idempotent: missing file is not an error.
#_do_show
def _do_show(cfg: 'AppConfigStore') -> intPrint a git-status-like summary of last_config, segments, theme, and recent dirs.
#_launch_is_interactive
def _launch_is_interactive(print_prompt: str | None) -> boolWhether this launch may prompt: is there a terminal, and a human's session?
Two conditions, and the terminal one is the substantive half. A launch whose segments all came from flags skips the TUI but is otherwise an ordinary interactive run -- it may prompt when a person is there and must not when nobody is. Deriving that from print mode alone made a headless, flag-driven launch believe it was interactive, so every prompting step went ahead and tried to open a terminal that does not exist.
Print mode stays non-interactive regardless: --print is a machine invocation whose stdout is the answer, so a prompt would corrupt it even with a terminal attached.
#_do_launch_sequence
def _do_launch_sequence(ws: 'Workspace', locator: 'BinaryLocator', cfg: 'AppConfigStore', selections: dict[str, str | None], extra_flags: list[str] | None=None, interactive: bool=True, metadata: dict[str, dict[str, dict[str, Any]]] | None=None, client: str=DEFAULT_CLIENT, passthrough: list[str] | None=None) -> NoneRun health check, hooks, save state, resolve, and exec. Does not return on success.
#_handle_health
def _handle_health(ws: 'Workspace') -> intRun diagnostic health checks and print results.
#_handle_config
def _handle_config(ws: 'Workspace') -> intOpen the config directory in the user's preferred editor.
#_handle_versions
def _handle_versions(locator: 'BinaryLocator') -> intList installed Claude Code versions, marking the current symlink target.
#_handle_install
def _handle_install(locator: 'BinaryLocator', version: str) -> intDownload and install a specific Claude Code version.
#_handle_uninstall
def _handle_uninstall(locator: 'BinaryLocator', version: str) -> intUninstall a specific Claude Code version binary.
#_handle_reset_options
def _handle_reset_options(ws: 'Workspace') -> intDelete options.json so defaults regenerate on next run.
#_handle_new_profile
def _handle_new_profile(ws: 'Workspace', locator: 'BinaryLocator') -> intRun the create-profile flow as one continuous alt-screen session.
Mirrors the TUI path: wizard form, auth forms, and summary page all render borrowed in a single alt-screen raw session on a CLI-owned terminal. After the session ends, the summary and auth outcome are printed to stdout as a persistent record.
#_resolve_archiver
def _resolve_archiver(ws: 'Workspace', name: str) -> 'Saferm | Unavailable'The archiving tool this deletion will use, or why there is none.
Deletion delegates to saferm so it can be undone, which makes "is saferm here, and does it ship what the delegation uses" a precondition of the operation rather than a detail of it. This is where that is decided, for the scripted door.
A run with no terminal gets no second consent to ask for -- the framework's confirmation already happened, or was answered in advance with --approve-consequential -- so it does not get an install offer either. The caller turns the answer into a hard error, which is the input a machine caller can act on: nothing happened, and the profile is still there.
At a terminal there IS someone to ask, so the missing tool becomes an offer to install it. Declining aborts the deletion; a failed install aborts it too. Neither ever falls through to removing the directory some other way.
#_offer_saferm_install
def _offer_saferm_install(ws: 'Workspace', name: str, unavailable: 'Unavailable') -> 'Saferm | Unavailable'Ask whether to install saferm, and install it if the answer is yes.
Shaped on the Claude Code install: the release's published checksum manifest is fetched first, this platform's asset is downloaded, and its SHA-256 is checked against the manifest before anything is unpacked or put on disk. A mismatch installs nothing.
The answer to a declined offer, a failed download and a fresh binary that STILL does not answer the probe is the same one: hand the caller back an unavailable tool and let the deletion be refused. There is deliberately no branch here that gives up on the archive and deletes the profile anyway.
#_handle_delete_profile
def _handle_delete_profile(ws: 'Workspace', name: str, force_delete: bool, force_delete_data: bool) -> intDelete a profile via ProfileStore. The running check is CLI policy.
This is the scripted door, so it stops nothing: the interactive checklist that offers to stop the processes holding a profile belongs to the TUI, where there is a person to tick the boxes. What this path does instead is refuse to be silent about them -- every live holder is read BEFORE the removal (afterwards the registry is gone with the directory) and named in the summary, because a surviving process still carries CLAUDE_CONFIG_DIR and recreates the directory on its next write.
The archiving tool is resolved HERE, not in the store, and not by the framework's confirmation prompt: that prompt fires before dispatch with a string pinned verbatim by tests, so the handler is the first place that can say anything about saferm at all. What it says depends on whether there is anyone to say it to -- an offer where there is a terminal, a hard error where there is not.
#_handle_rename_profile
def _handle_rename_profile(ws: 'Workspace', old: str, new: str) -> intRename a profile: validate inputs, then delegate to ProfileStore.rename.
The charset, name-collision (options + directory), and running checks stay here as CLI policy -- they produce clean, targeted messages. The store enforces dir-existence and the 'default' reservation as a backstop; its ValueErrors are mapped to the same error-print + exit-1 style.
#_handle_check_tokens
def _handle_check_tokens(ws: 'Workspace') -> intValidate stored tokens for all discovered profiles against the Anthropic API.
#_handle_fix_auth
def _handle_fix_auth(ws: 'Workspace', name: str) -> intStrip session credentials that shadow a profile's long-lived token.
#_handle_set_plan
def _handle_set_plan(ws: 'Workspace', name: str, plan: str) -> intDeclare which plan a profile's account is on, without prompting.
The scripted writer of the three: the same closed list the interactive picker offers, resolved by key, stored through the same door. It is what a headless launch is told to run when the profile declares no plan.
#_handle_show
def _handle_show(ws: 'Workspace') -> intPrint a summary of current selections, theme, and recent directories.
#_handle_migrate
def _handle_migrate(ws: 'Workspace', src: str, dst: str, uuid: str) -> intMove session data files between profiles, optionally filtered by UUID.
#_handle_stats
def _handle_stats(ws: 'Workspace') -> intReport shared-store statistics and optionally clean up legacy data.
#_handle_mv
def _handle_mv(ws: 'Workspace', old: str, new: str, post_hoc: bool | None) -> intRename a project directory and migrate its session data.
#_handle_import
def _handle_import(ws: 'Workspace', source: str, from_: list[str], to: list[str], reid: bool | None) -> intImport session data from an external Claude Code directory.
#_handle_deploy_hooks
def _handle_deploy_hooks(ws: 'Workspace', name: str | None, all: bool | None, force_overwrite: bool | None) -> intDeploy built-in hook scripts to the scripts directory.
"Name one script or pass --all" is half a declaration and half a handler rule, and the split is the framework's own boundary: the at-least-one half is the deploy-target constraint on the command, while the exclusivity half stays here because exactly-one selection is a choice flag and a positional arg cannot be a member of one (nor be declared inside a choice's scope). Moving it would mean spelling the script name as --script <name>, which is not the argv this command has.
#_handle_patch_profiles
def _handle_patch_profiles(ws: 'Workspace') -> intReconcile every managed profile and shared-settings.json to exact canonical.
Delegates to the unified reconcile core. This PRUNES each target's guardrail sections (the entire hooks structure, the disallowedTools list, permissions deny/ask and the canonical settings keys) to EXACTLY the canonical model, removing drift and any user-added extras -- the old additive, extras-preserving semantics are gone. Also deploys any missing guardrail hook scripts. The 'default' profile (~/.claude) is never read from or written to.
Declared consequential, like reconcile-permissions it delegates to: the pruning is unrecoverable, so the framework confirms before dispatch and refuses outright without a terminal unless --approve-consequential is passed. --dry-run previews and is never gated.
#_handle_reconcile_permissions
def _handle_reconcile_permissions(ws: 'Workspace', profile: str | None) -> intReconcile every managed target to EXACTLY the canonical guardrail model.
Delegates to the unified reconcile core. Makes each target's hooks, the disallowedTools list, permissions deny/ask and the canonical settings keys EXACTLY canonical (allow keeps only its non-conflicting entries), pruning all drift and user-added extras -- the old additive, extras-preserving behavior is gone. The 'default' profile (~/.claude) is never read from or written to.
The hand-rolled --dry-run/--apply pair this command used to require is gone: --dry-run is now the framework's, and it is the only mode flag. The explicit-intent half of that pair is not gone, though -- the command declares itself consequential, so the framework confirms before dispatch and refuses a bare run on a non-interactive stdin with "pass --approve-consequential to confirm". The pruning is exact and nothing reconstructs a removed entry, which is what earns the interruption; the informative preview is still the per-target diff --dry-run prints, and --dry-run is never gated.
#OneProfile
The --profile <name> member: a single named profile.
#AllProfiles
The --all-profiles member: the whole registered fleet.
#one_profile
def one_profile(name: str) -> OneProfileConstruct the named-profile member.
@strictcli.choice builds the frozen dataclass at runtime; the decorator is not spelled as a dataclass_transform, so a type checker cannot see the generated __init__. One typed door here beats an ignore comment at every construction site.
#_target_selection
def _target_selection(target: 'OneProfile | AllProfiles') -> tuple[str | None, bool]Read an elected profile target as the (profile, all_profiles) pair.
--profile '' elects the named-profile member with an empty name -- electing says WHICH member was named and never that its value is usable -- so the empty name arrives here as "no profile named", and the callers refuse it rather than reading it as "every profile".
#_handle_purge_plugins
def _handle_purge_plugins(ws: 'Workspace', target: OneProfile | AllProfiles) -> intRemove the Claude Code plugin trees from the selected profiles.
Opt-in and separate from the canonical reconciliation on purpose: that one is exact and runs over every managed target, so folding a plugin purge into it would delete plugin state on every reconcile, including state somebody installed deliberately.
The vanilla default profile is never touched -- ~/.claude is Claude Code's own directory and claudewheel is read-only to it.
Naming NEITHER target is now the framework's refusal -- the selector declares required, so one of --profile, --all-profiles is required comes from the parser. What still belongs here is the empty NAME: --profile '' elects the named-profile member with a name that names no profile, and purging every profile off a flag that named none of them is exactly the outcome this refusal exists to prevent.
#_handle_permission_add
def _handle_permission_add(ws: 'Workspace', category: str, rule: str, target: OneProfile | AllProfiles) -> intAdd a permission rule to the specified category for one or all profiles.
#_handle_permission_remove
def _handle_permission_remove(ws: 'Workspace', category: str, rule: str, target: OneProfile | AllProfiles) -> intRemove a permission rule from the specified category for one or all profiles.
#_handle_permission_list
def _handle_permission_list(ws: 'Workspace', target: OneProfile | AllProfiles, format: str, category: str | None) -> intList permission rules for one or all profiles in the chosen format.
--format chooses between the two HUMAN renderings and nothing else. The machine form is the framework-owned --json: the whole answer -- every target, not one document per target -- goes into the single payload slot, validated against :data:_PERMISSION_LIST_PAYLOAD_SCHEMA. The payload is built on every run and the framework decides whether it becomes a document, so there is no mode branch here.
The human lines go through :func:claudewheel.effects.info rather than print, which is what keeps the envelope the sole document on stdout when a machine is reading.
#_resolve_resume_title
def _resolve_resume_title(ws: 'Workspace', resume_val: str, directory: str) -> strResolve a --resume argument to a session UUID.
If resume_val is UUID-shaped it is returned unchanged. Otherwise it is treated as a session title (Claude Code accepts either). Titles are resolved by scanning the current directory's project dir first, then all project dirs. Exactly one match rewrites the value to that session's UUID and the caller proceeds through the normal UUID machinery. Zero or multiple matches print guidance and exit nonzero.
#_run_mv_for_launch
def _run_mv_for_launch(ws: 'Workspace', old_cwd: str, current_dir: str, dry_run: bool) -> 'MvResult'Run the session migration from a launch interception, aborting on failure.
The interception mutates the shared store in the middle of a launch, so a failure part way through must not escape as a traceback on top of a partial mutation. Failures are reported exactly the way _handle_mv reports the same errors: Error: <e> on stderr, exit 1.
#_check_resume_session
def _check_resume_session(ws: 'Workspace', session_id: str, directory: str) -> NoneIntercept --resume to detect and offer to fix directory renames.
When a session exists under an old encoded path (because the project directory was renamed), this function detects the mismatch and offers to move all sessions to the new path via run_mv.
Returns normally when no interception is needed (session found under current directory, or sessions successfully moved). Calls sys.exit(1) when the session cannot be resumed from here.
#_check_cont_session
def _check_cont_session(ws: 'Workspace', directory: str) -> NoneIntercept --cont to detect and offer to fix directory renames.
When the current directory has no sessions but an orphaned project directory exists under the same parent (original cwd no longer on disk), this function offers to move those sessions to the current directory via run_mv.
#_reject_claude_only_overrides
def _reject_claude_only_overrides(client_val: str, segment_overrides: dict[str, Any]) -> NoneHard-error on explicit claude-only overrides combined with a non-claude client.
version and mcp=strict are claude-client-only inputs. An ambient value (remembered in last_config or a config default) is silently ignored for non-claude clients; but an explicit, same-invocation override (a segment flag or -s key=value) alongside a non-claude --client is contradictory intent and is rejected here, where the selection's provenance (an explicit override) is known -- the adapter downstream cannot tell explicit from ambient.
#ContinueSession
The --cont member: Claude Code's --continue.
#ResumeSession
The --resume <session> member: Claude Code's --resume <id>.
#PrintPrompt
The --print-prompt <prompt> member: Claude Code's --print.
#SessionPicker
The --picker member: claudewheel's own session picker screen.
#NewSession
The default member: no session is continued, resumed or printed.
#_handle_launch
def _handle_launch(ws: 'Workspace', locator: 'BinaryLocator', session: ContinueSession | ResumeSession | PrintPrompt | SessionPicker | NewSession, profile: str | None, github: str | None, model: str | None, directory: str | None, mcp: str | None, permissions: str | None, set: list[str], client: str | None) -> intHandle the launch subcommand: run the TUI or skip it when args suffice.
#_inject_launch
def _inject_launch(argv: list[str]) -> list[str]Return argv with the "launch" subcommand injected when appropriate.
argv includes argv[0] (the program name). When no subcommand is given, or the first token that is not a framework-reserved global flag is neither a known subcommand nor an app-level flag, the "launch" subcommand is injected at that position so the interactive TUI starts. App-level flags (see _APP_LEVEL_FLAGS) and known subcommands are left untouched.
#_plan_choices
def _plan_choices() -> list[Choice]The declarable plans, as profile set-plan's argument choices.
Constrained at parse time from the same closed list the interactive picker renders, so the scripted surface cannot accept a plan the picker has no entry for. Each entry carries the help strictcli requires a choice record to be able to carry, and that help is derived from the same PlanTier the value comes from -- so a plan's label and the fields it stores cannot drift from the value that writes them.
#_bind
def _bind(handler: Callable[..., int], *pre: Any) -> Callable[..., int]Pre-bind leading positional dependencies (workspace/locator) to a handler.
strictcli dispatches handlers with keyword arguments (handler(ctx, **parsed)) and builds the schema from the declared Flag/Arg objects -- NOT from the handler signature. The signature is what strictcli's guard v2 validates the declaration against. So the returned wrapper:
- forwards the pre-bound deps plus parsed kwargs to the real handler,
- binds the dispatch context to :mod:
claudewheel.effectsfor the length
of the call, which is what makes --dry-run record every mutation instead of performing it,
- carries an explicit
__signature__: the real handler's parameters with
the pre-bound positionals dropped and the framework's context slot put back in front.
That __signature__ is the point. The wrapper is physically (ctx, **kwargs), and a bare **kwargs handler is exactly the hole strictcli's guard v2 closes -- it would have to declare forwarding= and waive the signature cross-check for all 24 commands. Presenting the wrapped handler's real signature instead means every flag and arg is validated against a real parameter, which is the "declare everything" guarantee this wrapper used to opt out of.
We deliberately do NOT use functools.wraps: it would set __wrapped__, and inspect.signature follows that chain back to the real (ws-bearing) signature, re-triggering validation against parameters the framework never supplies.
#_build_app
def _build_app(ws: 'Workspace', locator: 'BinaryLocator') -> AppBuild the strictcli App with all subcommands registered.
#main
def main() -> NoneCLI entry point that parses arguments and dispatches to subcommands or the TUI.