On this page
The effects chokepoint: every subprocess, filesystem mutation and network call in claudewheel routes through it, so --dry-run can preview them all.
#claudewheel.effects
#claudewheel.effects
The single authorized surface for effectful calls in claudewheel production code.
Every subprocess launch, filesystem mutation and network call made by claudewheel/ goes through this module. Nothing else in the package may call subprocess.run, open(path, "w"), Path.write_text, os.makedirs, shutil.rmtree, urllib.request.urlopen or their siblings directly -- tests/test_effects_chokepoint.py enforces that with an AST scan and a two-entry exemption list (this module, which holds the primitives, and claudewheel/pty_runner.py, whose post-pty.fork child branch is unreachable from the parent process).
Why a chokepoint: claudewheel rides strictcli's ctx.effects regime, where every mutation is declared, previewable under --dry-run, and recorded into the would-do log. This CLI creates, renames and deletes Claude Code profile directories, writes OAuth tokens into ~/.claudewheel/tokens.json, rewrites every managed profile's settings.json to the canonical guardrail model, deploys hook scripts and downloads and installs release binaries -- a --dry-run that executed any of that would be worse than no dry run at all. With every effect funnelled through this one module the regime is adapted in one file rather than at ~70 call sites.
The mode rule (declared, never inferred) ----------------------------------------
The dispatch context is bound here for the length of a command handler (claudewheel.cli._bind does it for every registered command), and from then on:
- Preview mode (
--dry-run;ctx.dry_runis true) -- every mutating
operation below is minted on ctx.effects. It is recorded, never executed, and returns strictcli's Unsettled carrier. Forwarding that carrier into a later effect keeps the preview going; reading a field off it truncates the preview with the framework's own error, which is the honest outcome when nothing ran.
- Live mode -- the operations execute directly, with their full claudewheel
semantics: per-call timeouts, the write_text_atomic temp-file + rename that preserves a target's mode, the 0600-from-creation secret write, chunked downloads with progress callbacks, and the exist_ok / missing_ok distinctions call sites branch on. The contract's closed method set expresses none of those, so routing a live run through it would silently drop a hang guard or leak a token through a umask-readable temp file.
The split is by mode, decided before anything runs, and identical on every invocation -- it is not a fallback: nothing here ever tries the handle, fails, and retries elsewhere.
Reads are never effects -----------------------
read=True marks a subprocess run or an HTTP request as a declared read. A declared read executes in every mode and is never minted, never recorded and never logged -- the same treatment strictcli gives an allowlisted observe, and for the same reason: a preview that could not look at the world would have nothing to preview.
It is declared per call site rather than through an app-level proc_observe_allowlist because the argv cannot classify these: the claude binary is both --version and auth login, and a user hook script's argv is whatever the user put in their settings. An allowlist prefix short enough to cover the reads would be a blanket exemption over the writes -- exactly the breadth hazard strictcli's own observe-allowlist-breadth check warns about. HTTP reads need the same escape for a second reason: the closed method set's http is a NET_MUTATE, so a GET that validates a stored OAuth token could not be issued at all from the read_only profile check-tokens (ยง9.1) if it had to be minted.
Unbound calls -- the library path -- execute directly too. The TUI event loop, the wizard's form runner and the test suite call these functions outside any command dispatch, and there is no handle to mint on there. tests/test_effects_binding.py asserts that every registered command handler is bound, so a bound path is never missed by accident.
#bound
def bound(ctx: Any) -> Iterator[None]Bind ctx as the dispatch context for the length of the block.
claudewheel.cli._bind wraps every registered command handler in this, which is why no handler carries a decorator of its own: claudewheel already funnels every dispatch through one wrapper, and that wrapper is the honest place to bind.
#unsettled
def unsettled(value: Any) -> boolTrue when value is a carrier standing in for a recorded mutation.
The one thing a caller may do with a carrier besides forwarding it into a later effect: recognize it, and decline to read a result that does not exist. Call sites that would otherwise reach for .returncode return the carrier itself instead, so a preview walks past a mutation whose output nobody needed and truncates (honestly) at the first caller that does need it.
#previewing
def previewing() -> boolTrue when the current dispatch is previewing rather than executing.
#issue
def issue(dry_run: bool) -> boolTrue when a mutation a caller has already flagged dry_run should run.
Several claudewheel cores (run_mv, run_import, migrate_sessions, run_reconcile, run_stats) take their own dry_run parameter and narrate a preview far richer than the would-do log -- per-session counts, per-file collision reports, per-target guardrail diffs. Those parameters stay, and the CLI passes :func:previewing into them, so the user still has exactly one switch.
What this predicate decides is whether the mutation is nevertheless issued:
- **Bound dispatch under
--dry-run** -- yes. Issuing it is what fills
the would-do log; the chokepoint records it and nothing runs. Skipping it would leave a mutating command's log empty, which reads as "this command would do nothing" -- the one answer the contract says the framework must never give.
- **Unbound library call with
dry_run=True** -- no. There is no handle
to record on, so issuing the call would perform it, and a core asked for a dry run must not mutate. selfdoc-style suppression by the handle alone cannot cover this path, and a dry_run=True that writes anyway would be the worst footgun in the package.
Live mode (dry_run false) always issues, in both cases.
#_handle
def _handle() -> AnyThe strictcli effects handle to mint on, or None to execute directly.
#_p
def _p(path: Any) -> strRender a path operand as text for the handle.
#run
def run(argv: list[str], *, cwd: Any=None, env: dict[str, str] | None=None, timeout: float | None=None, check: bool=False, capture_output: bool=False, text: bool=False, input: Any=None, stdin: Any=None, stdout: Any=None, stderr: Any=None, read: bool=False, resource: str | None=None, skip_if_current: str | None=None, grant: str | None=None) -> AnyRun argv to completion and return the CompletedProcess.
In preview mode a declared read (read true) still executes and returns a real CompletedProcess; anything else is recorded on ctx.effects.run and returns the Unsettled carrier standing in for the run that did not happen.
Args:
argv: argument list.cwd: working directory for the child process.env: complete environment mapping for the child (None inherits).timeout: seconds beforeTimeoutExpiredis raised.check: raiseCalledProcessErroron a non-zero exit.capture_output: capture stdout/stderr instead of inheriting them.text: decode captured streams as text.input: payload written to the child's stdin.stdin: explicit stdin redirection.stdout: explicit stdout redirection.stderr: explicit stderr redirection.read: declare this run an observation -- it changes nothing, so it
executes in every mode and is never recorded.
resource: opaque token naming what this run produces (preview only).skip_if_current: token the preview annotates the line with, spelling
out that the handler skips this step when the resource is current.
grant: name of a grant declared on the running command, whose reason is
rendered beside the step in the preview.
#exec_replace
def exec_replace(cwd: str, argv: list[str], env: dict[str, str], *, resource: str | None=None, grant: str | None=None) -> AnyChdir to cwd and replace this process with argv. Does not return.
os.execvpe is a process replacement, which the contract's closed method set has no member for. A preview records it as the run: it effectively is -- the child runs to completion and this process never comes back -- and then does return the carrier, because there is no replacement to perform and the caller's dispatch must be allowed to finish rendering the log.
#run_under_pty
def run_under_pty(argv: list[str], env: dict[str, str], *, input_bytes: bytes | None=None, proxy_terminal: bool=True, resource: str | None=None, grant: str | None=None) -> AnyRun argv under a fresh PTY; return (exit_code, captured_bytes).
In preview mode the run is recorded and the Unsettled carrier is returned in place of the tuple: nothing ran, so there is neither an exit code nor captured output, and inventing either would make the preview lie about an interactive login the user never performed.
#_RecordedWriter
A file-like sink that mints one write effect when it is closed.
:func:open_write hands streaming writers (json.dump, loops of f.write) a real file object in live mode. The contract has no streaming write, so in preview mode the content accumulates here and the single resulting write carries the byte count the file would have had.
#close
def close(self) -> None#open_write
def open_write(path: Any, mode: str='w', *, encoding: str | None=None) -> AnyOpen path for writing and return the file object.
A thin open wrapper for streaming writers. Use it as a context manager, exactly like open. Whole-content writers should prefer :func:write_text / :func:write_bytes / :func:write_text_atomic.
#write_text
def write_text(path: Any, text: str, *, encoding: str | None=None) -> NoneWrite text to path, truncating any existing file.
#write_bytes
def write_bytes(path: Any, data: Any) -> NoneWrite data to path, truncating any existing file.
#write
def write(path: Any, content: Any) -> NoneWrite content to path, truncating any existing file.
The one writer that accepts a forwarded Unsettled carrier as its content: it exists so a recorded download can be named as the source of a recorded file without anything being transferred. In live mode content must be real str or bytes.
#write_text_atomic
def write_text_atomic(path: Any, text: str) -> NoneAtomic tmp+rename text write that preserves the target's file mode.
The rename replaces the target inode, so without a chmod any pre-existing restrictive mode on the target would be silently reset to the umask default on every update. Fresh targets (no existing file to stat) keep the umask default. Because the rename is a directory operation the write also succeeds when path itself is read-only, which is why live mode keeps the temp-file dance instead of routing through the contract's plain write.
#write_json_atomic
def write_json_atomic(path: Any, data: Any) -> NoneAtomic JSON write (indent=2, trailing newline), preserving file mode.
#write_json_atomic_secret
def write_json_atomic_secret(path: Any, data: Any) -> NoneAtomic JSON write for secret-holding files: target is always 0600.
The tmp file is created 0600 from the start (never umask-readable, even transiently) and chmod'd to exactly 0600 before the rename in case the umask stripped owner bits at creation.
#mkdir
def mkdir(path: Any, *, parents: bool=False, exist_ok: bool=False) -> NoneCreate the directory path.
The defaults mirror Path.mkdir exactly (missing parents and an existing path both raise) so translating a call site never changes its behavior. The contract's mkdir always creates parents and never minds an existing directory, which is a superset of every shape used here.
#remove
def remove(path: Any, *, missing_ok: bool=False) -> NoneDelete the file or symlink at path.
#rmdir
def rmdir(path: Any) -> NoneRemove the empty directory at path.
Kept distinct from :func:rmtree because the "must be empty" check is a safety property at several call sites: a non-empty profile directory means the per-child removal above missed something, and that must raise rather than take the whole tree with it.
#rmtree
def rmtree(path: Any, *, ignore_errors: bool=False) -> NoneRecursively delete the directory tree at path.
#rename
def rename(src: Any, dst: Any) -> NoneRename src to dst (Path.rename semantics: no cross-device move).
Path.rename rather than os.rename on purpose: it is the single commit seam the write canary in tests/wheelhelpers.py patches to prove no test ever renames anything over the real ~/.claude.
#move
def move(src: Any, dst: Any) -> NoneMove src to dst, falling back to copy+delete across devices.
#chmod
def chmod(path: Any, mode: int) -> NoneSet the permission bits of path.
#symlink
def symlink(link: Any, target: Any) -> NoneCreate link as a symbolic link pointing at target.
The contract's closed method set has no symlink, so a preview records the ln -s that performs it -- a faithful rendering of the work, and one a reader of the would-do log can act on, rather than an invented verb.
#copy_file
def copy_file(src: Any, dst: Any) -> AnyCopy src to dst, preserving metadata (shutil.copy2).
#copytree
def copytree(src: Any, dst: Any, *, dirs_exist_ok: bool=False) -> AnyRecursively copy the directory tree src to dst.
#http_read
def http_read(url: str, *, headers: dict[str, str] | None=None, timeout: float | None=None, method: str='GET', data: bytes | None=None) -> bytesPerform a declared-read HTTP request and return the response body.
A declared read executes in every mode, exactly like an allowlisted observe: it changes nothing on the far side, and a preview that could not validate a token or list the available versions would have nothing to preview. Raises urllib.error.HTTPError / URLError exactly as urlopen does, so callers keep their existing error handling.
#http_status
def http_status(url: str, *, headers: dict[str, str] | None=None, timeout: float | None=None, method: str='GET') -> intPerform a declared-read HTTP request and return its status code.
The probe shape: callers that only need "did the far side accept this credential" get the number without a body. urllib's HTTPError still propagates for a non-2xx response, exactly as it does from urlopen, so callers keep their existing 401 handling.
#http_stream
def http_stream(url: str, *, headers: dict[str, str] | None=None, timeout: float | None=None) -> AnyOpen a declared-read HTTP GET and return the response for chunked reads.
The streaming shape, for payloads too large to hold in memory: the caller reads the response in chunks and writes them through :func:open_write. Live mode only -- a preview never opens the stream (see :func:install_version), because there is nothing to stream into.
#http
def http(method: str, url: str, *, headers: dict[str, str] | None=None, timeout: float | None=None, resource: str | None=None, grant: str | None=None) -> AnyPerform a recorded HTTP request, or record it in preview mode.
Live mode returns the response body as bytes. Preview mode records a net: line and returns the Unsettled carrier, which may be forwarded into a later write so the preview names the file the download would land in without transferring it.
#_direct_run
def _direct_run(argv: list[str], *, cwd: Any, env: dict[str, str] | None, timeout: float | None, check: bool, capture_output: bool, text: bool, input: Any, stdin: Any, stdout: Any, stderr: Any) -> AnyExecute argv with the full subprocess semantics claudewheel relies on.
#_completed_from
def _completed_from(result: Any, argv: list[str], capture_output: bool, text: bool, check: bool) -> AnyAdapt a settled strictcli Completed to CompletedProcess.