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.
#_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.
#_write_tier_stub
def _write_tier_stub(ws: 'Workspace', profile: str | None, config_dir: str | None) -> NoneWrite a rateLimitTier stub into .credentials.json if tokens.json has tier data.
This lets downstream tools (e.g. howmuchleft) read the tier from .credentials.json even when auth is via CLAUDE_CODE_OAUTH_TOKEN. Short-circuits if .credentials.json already has the same tier value. A corrupt tokens.json raises TokenStoreError (surfaced cleanly by the launch handler); the .credentials.json write remains best-effort.
#_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.
#_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.
#_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 + tokens), 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) -> intRemove a credential shadow, or a stale token entry for a missing profile.
Two repair kinds through one surface:
- existing profile: strip session credentials that shadow a long-lived token
- missing profile: remove a stale tokens.json entry whose profile dir is gone
#_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) -> 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) -> intImport session data from an external Claude Code directory.
#_handle_deploy_hooks
def _handle_deploy_hooks(ws: 'Workspace', name: str, all: bool, force_overwrite: bool) -> intDeploy built-in hook scripts to the scripts directory.
#_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, and permissions deny/ask) 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) -> intReconcile every managed target to EXACTLY the canonical guardrail model.
Delegates to the unified reconcile core. Makes each target's hooks, the disallowedTools list, and permissions deny/ask 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.
#_handle_permission_add
def _handle_permission_add(ws: 'Workspace', category: str, rule: str, profile: str, all_profiles: bool) -> 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, profile: str, all_profiles: bool) -> intRemove a permission rule from the specified category for one or all profiles.
#_handle_permission_list
def _handle_permission_list(ws: 'Workspace', profile: str, all_profiles: bool, format: str, category: str) -> intList permission rules for one or all profiles in the chosen format.
#_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.
#_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.
#_handle_launch
def _handle_launch(ws: 'Workspace', locator: 'BinaryLocator', cont: bool, resume: str, print_prompt: str, picker: bool, profile: str, github: str, model: str, directory: str, mcp: str, permissions: str, 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.
#_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.