Skip to content
claudewheel.archiver
Edit
On this page

How claudewheel makes profile deletion recoverable: finding saferm, negotiating on its declared features rather than on a version number, handing the whole profile directory over through the effects chokepoint, and installing it from a checksum-verified release when it is missing.

#claudewheel.archiver

#claudewheel.archiver

Delegate profile deletion to saferm, so a deleted profile can be restored.

Deleting a profile used to be a removal loop: unlink the shared-store symlinks, remove every real child, rmdir the directory. What went that way was gone -- settings.json, .credentials.json and the profile's own stored OAuth token among it. Deletion now hands the whole directory to saferm <https://github.com/smm-h/saferm>_ instead, which archives it and then removes it, so the same operation is recoverable with one saferm undelete.

What this module owns ---------------------

  • Finding saferm -- claudewheel's own installed copy first

(~/.claudewheel/bin/saferm, where :func:install puts one), then whatever PATH resolves.

  • Negotiating on features, never on a version string. :func:probe asks

saferm capabilities --json what this binary ships and compares the answer against :data:REQUIRED_FEATURES. A missing verb and a missing feature are treated exactly like saferm not being installed at all -- one code path, one remedy. A version comparison is deliberately not done: a locally built saferm reports a Go pseudo-version no semver parser accepts, and a release number says nothing about what a build actually carries.

  • The delegation itself -- :meth:Saferm.archive, routed through

claudewheel's effects chokepoint so a --dry-run records the invocation and removes nothing.

  • Installing saferm -- :func:install, shaped on

:mod:claudewheel.install's verified download: fetch the release's checksums.txt, pick this platform's asset, download it, verify its SHA-256 against the manifest line, and only then unpack and rename the binary into place.

What this module deliberately does not own ------------------------------------------

No record of the archive is kept anywhere in claudewheel. saferm's archive already holds everything needed to restore, and it has its own audit trail, so a second copy of the handle in a launcher-side file would be state that can go stale and that nobody owns the lifetime of. The handle is reported -- printed where the user can see it and see the command that uses it -- and after that it lives in saferm list like every other deletion.

#ArchiveError

saferm refused or failed, and nothing was destroyed.

Raised from :meth:Saferm.archive for every answer that stops the deletion before claudewheel has mutated anything of its own: the delegation is the first destructive step, so the profile is still on disk and every store still names it. Each of these messages says exactly that.

:class:ArchiveUnreadable is the deliberate exception to the sentence above and subclasses this one, because a caller's next move -- stop, report, change nothing else -- is the same either way.

#ArchiveUnreadable

saferm reported success, and its answer could not be read.

The distinction from a plain :class:ArchiveError is the only thing that is true here and false there: saferm exited 0, so the archival ran and the profile directory is gone. What is missing is claudewheel's handle for it.

Nothing about that is quietly recoverable, so it is a hard error like any other -- but its message never claims the profile is still there, always carries whatever handle information the answer did contain (a uuid alone is enough to restore with), and always names saferm list, where the record is regardless of what claudewheel could parse.

#InstallError

The install could not be completed. saferm is still absent.

#ArchiveHandle

What one delegated archival hands back.

uuid is the durable handle: saferm undelete <uuid> restores the whole profile directory, the stored token included. group_id names the invocation, so an archival of several paths stays recoverable as a batch -- claudewheel always hands over exactly one directory, but the identifier is minted either way and is worth reporting.

#restore_command

python
def restore_command(self) -> str

The one command that puts the profile back.

--no-update-git-index is on both sides of the round trip, for one reason. A profile directory can sit inside a git worktree -- a version-controlled ~/dotfiles is the ordinary case -- and undelete stages the restored path by default, which would put the profile's .credentials.json and its stored OAuth token into an index claudewheel does not own. The archival refuses to touch that index (see :meth:Saferm.archive); the restore claudewheel prints refuses too, so following the printed instruction cannot stage a secret somebody is about to commit.

#ProfileArchiver

What :meth:claudewheel.profile_store.ProfileStore.delete requires.

The store never locates, probes or installs anything -- it is handed something that can archive a directory, and refuses to delete without one. :class:Saferm is the implementation; tests supply their own.

#archive

python
def archive(self, path: Path, *, description: str) -> ArchiveHandle | None

Archive path and remove it, or record the invocation in a preview.

Returns None when the invocation was recorded rather than performed (--dry-run): nothing ran, so there is no handle to hand back. Raises :class:ArchiveError when the archival failed.

#Saferm

A saferm binary that has answered the capabilities probe.

Constructed only by :func:detect and :func:install, so holding one is itself the statement that the negotiation succeeded.

#archive

python
def archive(self, path: Path, *, description: str) -> ArchiveHandle | None

Hand path to saferm: archived, then removed.

The invocation is fully explicit, because every one of its flags is a decision claudewheel is making on the user's behalf:

  • --on-error abort -- a profile is exactly one directory, so there

is no remainder to carry on with. continue would only mean "report the failure later".

  • --no-update-git-index -- claudewheel is archiving somebody else's

directory. A profile that happens to sit inside a git worktree must not have its removal staged in that worktree's index.

  • --description -- mandatory, and the audit trail's whole point.
  • --json -- the envelope is where the handle comes from. Parsing

prose would be a second interface nobody declared.

Routed through the effects chokepoint with an explicit grant, so a --dry-run records run: saferm delete ... and removes nothing.

#Unavailable

Why deletion cannot proceed, and what to do about it.

Three shapes, one remedy. absent is no binary anywhere; no-verb is a binary too old to answer the probe at all; missing-features is one that answers but does not ship what the delegation uses. All three route to the same install-or-upgrade offer, which is the whole point of negotiating on features rather than on a version number.

#upgrade

python
def upgrade(self) -> bool

True when a saferm exists and is merely too old.

#diagnosis

python
def diagnosis(self) -> str

One line naming saferm and saying exactly what is wrong with it.

#stakes

python
def stakes(self, name: str) -> str

Why the deletion stops here rather than proceeding without saferm.

#remedy

python
def remedy(self) -> str

The install or upgrade lines, indented for a message body.

#refusal_error

python
def refusal_error(self, name: str, *, previewing: bool) -> str

The hard-abort message for a deletion that got no install offer.

Two different runs reach it, and the reason has to be the true one for each. A run with no terminal is a machine -- an agent, or a monitored job -- and there is nobody to ask. A preview has somebody to ask and may still not install a program, because it promised to change nothing; that one happens at a real terminal, where "there is no terminal" would be plainly false and would send the reader looking for the wrong fix.

Both say the same three things otherwise: nothing happened, the profile is still there, and the remedy. There is deliberately no flag that proceeds anyway, so neither has an override to teach.

#offer_lines

python
def offer_lines(self, name: str) -> list[str]

The interactive introduction to the install offer.

#bin_dir

python
def bin_dir(root: Path) -> Path

Where claudewheel keeps the saferm it installed itself.

#locate

python
def locate(root: Path) -> Path | None

The saferm binary claudewheel would use, or None.

claudewheel's own copy wins over PATH: a user who accepted the install offer gets the binary that offer produced, whatever an older one on PATH would have answered.

#probe

python
def probe(binary: Path) -> frozenset[str] | None

Ask binary what it ships. None when it cannot answer at all.

A declared read: capabilities is read_only, reads no database and creates no state directory, so it executes in every mode -- a preview that could not find out whether saferm is usable could not preview a deletion.

#detect

python
def detect(root: Path) -> Saferm | Unavailable

Find a saferm that ships everything the delegation uses.

The single entry point every deletion path calls before it decides anything. A missing binary, a binary with no capabilities verb and a binary missing one feature are three different findings with one answer.

#asset_name

python
def asset_name(version: str) -> str

The release asset for this platform, named the way GoReleaser named it.

#fetch_checksums

python
def fetch_checksums() -> dict[str, str]

The latest release's checksum manifest, as {asset: sha256}.

A declared read, exactly like the Claude Code manifest fetch: it is what names the version, the asset and the digest, so a preview that could not read it could not say what it would install.

#_version_from

python
def _version_from(manifest: dict[str, str]) -> str

The version every asset in manifest carries (saferm_<v>_<os>_...).

#install

python
def install(root: Path, progress_callback: Callable[[int, int], None] | None=None) -> Path

Download, verify and install the latest saferm under root.

The shape is :func:claudewheel.install.install_version's, and for the same reason: an executable fetched over the network is installed only after its content has been checked against a digest the release published separately. A mismatch removes the staged file and raises -- there is no branch that installs it anyway.

#_payload

python
def _payload(stdout: Any) -> dict[str, Any] | None

The payload object out of a strictcli machine-mode envelope, or None.

In machine mode the envelope is the sole document on stdout, so this is one parse and one member read -- never a scan for JSON inside prose.

#_unreadable

python
def _unreadable(path: Path, detail: str, *, uuid: str='', group_id: str='') -> ArchiveUnreadable

The error for a success whose payload claudewheel could not use.

Three things every one of these says, in this order: the archival happened (so the profile is not where the reader might assume), whatever handle information did survive, and where the record is regardless -- saferm list. A uuid alone is a complete restore, so when there is one it is spelled out as the command rather than mentioned as a fact.

#_detail

python
def _detail(result: Any) -> str

saferm's own stderr, appended to an error when it said anything.

Search