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

python
def _absent(value: Any, fallback: Any) -> Any

Resolve 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

python
def _do_uninstall(locator: 'BinaryLocator', version: str) -> int

Delete 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

python
def _do_reset_options(ws: 'Workspace') -> int

Delete 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

python
def _do_show(cfg: 'AppConfigStore') -> int

Print a git-status-like summary of last_config, segments, theme, and recent dirs.

#_launch_is_interactive

python
def _launch_is_interactive(print_prompt: str | None) -> bool

Whether 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

python
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) -> None

Run health check, hooks, save state, resolve, and exec. Does not return on success.

#_handle_health

python
def _handle_health(ws: 'Workspace') -> int

Run diagnostic health checks and print results.

#_handle_config

python
def _handle_config(ws: 'Workspace') -> int

Open the config directory in the user's preferred editor.

#_handle_versions

python
def _handle_versions(locator: 'BinaryLocator') -> int

List installed Claude Code versions, marking the current symlink target.

#_handle_install

python
def _handle_install(locator: 'BinaryLocator', version: str) -> int

Download and install a specific Claude Code version.

#_handle_uninstall

python
def _handle_uninstall(locator: 'BinaryLocator', version: str) -> int

Uninstall a specific Claude Code version binary.

#_handle_reset_options

python
def _handle_reset_options(ws: 'Workspace') -> int

Delete options.json so defaults regenerate on next run.

#_handle_new_profile

python
def _handle_new_profile(ws: 'Workspace', locator: 'BinaryLocator') -> int

Run 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

python
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

python
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

python
def _handle_delete_profile(ws: 'Workspace', name: str, force_delete: bool, force_delete_data: bool) -> int

Delete 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

python
def _handle_rename_profile(ws: 'Workspace', old: str, new: str) -> int

Rename 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

python
def _handle_check_tokens(ws: 'Workspace') -> int

Validate stored tokens for all discovered profiles against the Anthropic API.

#_handle_fix_auth

python
def _handle_fix_auth(ws: 'Workspace', name: str) -> int

Strip session credentials that shadow a profile's long-lived token.

#_handle_set_plan

python
def _handle_set_plan(ws: 'Workspace', name: str, plan: str) -> int

Declare 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

python
def _handle_show(ws: 'Workspace') -> int

Print a summary of current selections, theme, and recent directories.

#_handle_migrate

python
def _handle_migrate(ws: 'Workspace', src: str, dst: str, uuid: str) -> int

Move session data files between profiles, optionally filtered by UUID.

#_handle_stats

python
def _handle_stats(ws: 'Workspace') -> int

Report shared-store statistics and optionally clean up legacy data.

#_handle_mv

python
def _handle_mv(ws: 'Workspace', old: str, new: str, post_hoc: bool | None) -> int

Rename a project directory and migrate its session data.

#_handle_import

python
def _handle_import(ws: 'Workspace', source: str, from_: list[str], to: list[str], reid: bool | None) -> int

Import session data from an external Claude Code directory.

#_handle_deploy_hooks

python
def _handle_deploy_hooks(ws: 'Workspace', name: str | None, all: bool | None, force_overwrite: bool | None) -> int

Deploy 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

python
def _handle_patch_profiles(ws: 'Workspace') -> int

Reconcile 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

python
def _handle_reconcile_permissions(ws: 'Workspace', profile: str | None) -> int

Reconcile 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

python
def one_profile(name: str) -> OneProfile

Construct 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

python
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

python
def _handle_purge_plugins(ws: 'Workspace', target: OneProfile | AllProfiles) -> int

Remove 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

python
def _handle_permission_add(ws: 'Workspace', category: str, rule: str, target: OneProfile | AllProfiles) -> int

Add a permission rule to the specified category for one or all profiles.

#_handle_permission_remove

python
def _handle_permission_remove(ws: 'Workspace', category: str, rule: str, target: OneProfile | AllProfiles) -> int

Remove a permission rule from the specified category for one or all profiles.

#_handle_permission_list

python
def _handle_permission_list(ws: 'Workspace', target: OneProfile | AllProfiles, format: str, category: str | None) -> int

List 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

python
def _resolve_resume_title(ws: 'Workspace', resume_val: str, directory: str) -> str

Resolve 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

python
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

python
def _check_resume_session(ws: 'Workspace', session_id: str, directory: str) -> None

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

python
def _check_cont_session(ws: 'Workspace', directory: str) -> None

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

python
def _reject_claude_only_overrides(client_val: str, segment_overrides: dict[str, Any]) -> None

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

python
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) -> int

Handle the launch subcommand: run the TUI or skip it when args suffice.

#_inject_launch

python
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

python
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

python
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.effects for 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

python
def _build_app(ws: 'Workspace', locator: 'BinaryLocator') -> App

Build the strictcli App with all subcommands registered.

#main

python
def main() -> None

CLI entry point that parses arguments and dispatches to subcommands or the TUI.

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