Moving from Tuft to Ocho

This page is for the Tuft agent that moves its user from Tuft to Ocho on the user's office Mac. If you are not that agent, this page is not for you.

You talk to the user only through the current Tuft conversation in iMessage or Slack. The user is not at the Mac, so every step that needs the user is a message you send them, and each step says what to send and what reply to wait for.

How the Move Works

Tuft and Ocho run side by side until the very end. You install Ocho next to Tuft and help the user sign in. Then you start a new Ocho agent, called the helper, and connect Ocho's iMessage or Slack to it. From then on, the user talks to the helper in a new conversation. The helper tests Ocho with the user, offers to bring recent Tuft conversations over, and removes Tuft last.

Follow these rules throughout:

Tuft's shells don't have ~/.local/bin on their PATH, so the Tuft agent's commands below write ~/.local/bin/ocho in full.

1. Ask the User

Send the user a message like this, in your own words:

"Tuft is being replaced by Ocho, which runs the same Claude Code and Codex agents on this Mac. I can set Ocho up now, next to Tuft, so nothing stops working. You'll sign in to Claude or Codex from your phone, then start talking to Ocho in a new conversation. Once you've checked that Ocho works, it removes Tuft. Should I start?"

Wait for the user to agree. If they say no, stop here.

2. Install Ocho

Run this on the Mac:

curl -fsSL https://fleet-service.eliot-4cd.workers.dev/releases/install-cli.sh | bash

This installs ocho to ~/.local/bin and starts the Ocho service on this Mac. It changes nothing in Tuft. Check that it worked:

~/.local/bin/ocho doctor

Tell the user: "Ocho is installed. Next, you'll sign in to Claude or Codex so that Ocho can use your account."

3. Sign In to Claude or Codex

Ask the user which accounts they use: Claude, Codex or both. The user must sign in to Ocho with the same account they use in Tuft, so that their Tuft conversations can come over. First, find the email of each account Tuft uses. Tuft keeps each session's login in its own folder. Most conversations keep claude-config/ and codex-home/ in the conversation folder itself, and a few keep them in agents/claude/ and agents/codex/. These commands read the newest conversation's login and print only the email:

cfg=$(ls -dt ~/.config/tuft/sessions/*/claude-config ~/.config/tuft/sessions/*/agents/claude/claude-config 2>/dev/null | head -1)
CLAUDE_CONFIG_DIR="$cfg" claude auth status | plutil -extract email raw -o - -
home=$(ls -dt ~/.config/tuft/sessions/*/codex-home ~/.config/tuft/sessions/*/agents/codex/codex-home 2>/dev/null | head -1)
osascript -l JavaScript -e 'function run(a){var d=JSON.parse(ObjC.unwrap($.NSString.stringWithContentsOfFileEncodingError(a[0],4,null)));var p=d.tokens.id_token.split(".")[1].replace(/-/g,"+").replace(/_/g,"/");while(p.length%4)p+="=";return JSON.parse(ObjC.unwrap($.NSString.alloc.initWithDataEncoding($.NSData.alloc.initWithBase64EncodedStringOptions(p,0),4))).email||""}' "$home/auth.json"

claude auth status prints JSON whose email is the account. codex login status doesn't name the account, so the second command reads the email from the login Tuft saved, without printing the login itself. Each command prints nothing for an agent the user doesn't use in Tuft.

When you send the user a sign-in link below, name the account: "Sign in with EMAIL, the account you use in Tuft." Add each one with ocho accounts add. The sign-in happens on the user's phone, and the command on the Mac waits for it to finish.

Run each command in a tmux session of its own, so that you can read what it prints and type into it. Every Tuft Mac has tmux. Use the -L ocho-login socket every time, so that the session stays out of Tuft's own tmux:

tmux -L ocho-login new-session -d -s ocho-login -x 250 '~/.local/bin/ocho accounts add --provider codex'
tmux -L ocho-login capture-pane -p -J -t ocho-login

-J joins the lines that a long link wraps over, so copy the link from that output.

Codex prints a link and a short code. Send both to the user: "Open this link on your phone, sign in to your ChatGPT account, and enter this code: CODE." The command finishes on its own once the user signs in. Wait until the pane shows "Connected Codex".

Claude (--provider claude) prints a sign-in link and waits at "Paste code here if prompted". It also tries to open a browser on the Mac. Ignore that, because nobody is at the Mac. Send the link to the user: "Open this link on your phone and sign in to Claude. At the end, Claude shows you a code. Send me that code." When the user sends the code, type it into the waiting command exactly as they sent it:

tmux -L ocho-login send-keys -t ocho-login -l 'CODE'
tmux -L ocho-login send-keys -t ocho-login Enter

-l types the code as text, so that tmux never reads part of it as a key name. A code works only for the sign-in that printed its link. If Claude says "That code wasn't accepted", start the command again and send the user the new link.

Wait until the pane shows "Connected Claude". Then close the tmux session with tmux -L ocho-login kill-server.

Run ~/.local/bin/ocho accounts to check that each account is listed, with the same email Tuft uses. If an email differs, tell the user, remove that account with ~/.local/bin/ocho accounts remove ACCOUNT, and repeat the sign-in with the right account. Don't continue with a different account, because conversations from Tuft must continue under the account they were made with. Tell the user which accounts are connected. Write down the account for the helper's agent, such as claude:EMAIL, as ACCOUNT.

4. Start the Helper

Start the helper on this Mac. Use Claude if the user has a Claude account, and Codex otherwise. --account makes the helper run on the account from step 3, which Slack needs: Slack's !attach refuses a session on the Mac's own login.

~/.local/bin/ocho new local --provider claude --account ACCOUNT --cwd ~ --title "Ocho helper" --no-attach "You are the Ocho helper for a user who is moving from Tuft to Ocho. Read https://docs.ocho.app/agents/moving-from-tuft.md and follow the part titled For the Helper. Use the account ACCOUNT for every session you start or resume. Wait for the user's first message in iMessage or Slack before you start. The user's name is NAME."

The last line of the output contains the helper's session ID in "id". Write it down as HELPER. Then wait until the helper has read its instructions and is idle:

~/.local/bin/ocho sessions wait local/HELPER --until idle

5. Connect iMessage or Slack to the Helper

Connect the app the user talks to Tuft in. Keep this Tuft conversation open while you do.

iMessage. Ask the user: "Which phone number do you use for iMessage, with the country code?" When they reply, run:

~/.local/bin/ocho imessage connect HELPER --phone +1PHONE

Without a terminal, it prints JSON at once and returns: code, recipient (the number to text), expires_in_minutes and a QR. It doesn't wait for the code to arrive. Send the user both values: "From your iPhone, text CODE to RECIPIENT. Ocho replies Paired, and that starts your conversation with Ocho." The code works once, from the number they gave, for 60 minutes. Wait for the user to tell you that Ocho replied. If it doesn't reply, run the command again for a new code.

Slack. Run:

~/.local/bin/ocho slack connect

It prints a link. Send it to the user: "Open this link and add Ocho to your Slack workspace." When the user tells you it's done, check that ~/.local/bin/ocho slack status says connected. Then send them: "In Slack, send a direct message to @Ocho with the text !attach HELPER. That connects you to the Ocho helper."

6. Hand Over to the Helper

Send the user a last message: "Ocho is ready. Send a message to Ocho, in the new iMessage conversation or in your Slack DM with @Ocho, to start testing it. I'll keep running here until Ocho has removed Tuft, so you can come back to me if anything goes wrong."

Your part ends here. Keep answering the user in this conversation if they come back. If Ocho doesn't reply to them, find the cause with ~/.local/bin/ocho doctor and ~/.local/bin/ocho sessions, and repeat step 5 if needed.

For the Helper

You are the Ocho helper. The user talks to you in iMessage or Slack, and Tuft still runs next to you. Tell the user what you are doing before each step. Use ~/.local/bin/ocho for every ocho command, and the account named in your first message for every session you start or resume.

Test Ocho with the User

Introduce yourself, then go through these checks with the user one at a time. Keep the user in this conversation the whole time, and don't ask them to switch sessions until the move is done. Tell the user what each one tests, and wait for them to confirm that it worked:

1. The user sends you a question, and your answer arrives. 2. You start a test session yourself and read its answer:

~/.local/bin/ocho new local --provider claude --account ACCOUNT --cwd ~ --no-attach "Reply with the word hello." ~/.local/bin/ocho sessions wait local/SESSION --until idle ~/.local/bin/ocho sessions last local SESSION

SESSION is the id the first command prints. Tell the user that it replied. This checks that new sessions start, and the user never leaves this conversation. 3. The user sends you a photo, and you describe it. 4. You send the user a file, for example a short text file, and it arrives as an attachment. 5. You request a test secret: print ocho://secret/TEST_VALUE on its own line, run ~/.local/bin/ocho secret ask TEST_VALUE, and ask the user to open the link and enter any word. Confirm that the file arrived, then delete it and tell the user that the test value was deleted.

If a check fails, try to fix it yourself first. If you can't, follow Reporting a Problem.

Offer Recent Tuft Conversations

Ask the user once: "Do you want me to bring any of your recent Tuft conversations into Ocho?" If they say no, skip to the next section.

If they say yes, list their recent Tuft conversations, numbered, each with its title and date, newest first. Start with the last two weeks, and tell the user you can list older ones too. Bring over whichever ones they pick. If they want everything, bring over every conversation. Tuft keeps its data here:

ContentLocation
Each conversation~/.config/tuft/sessions/CONVERSATION/ (older installations: ~/.tuft/sessions/)
Each agent's filesMost conversations keep them in the conversation folder itself. A few keep them in agents/claude/ or agents/codex/ in that folder. Read both, the conversation folder first.
The agent's recordsession.json there: spec.backend (the agent), native_session_id, launch_cwd and transcript_path
The titletext in title.json in the same folder

This command lists every agent's record, newest first, from both places:

ls -t ~/.config/tuft/sessions/*/session.json ~/.config/tuft/sessions/*/agents/*/session.json ~/.tuft/sessions/*/session.json 2>/dev/null

Skip any session.json without transcript_path, because it holds no conversation. Bring over each conversation the user picks:

~/.local/bin/ocho resume NATIVE_ID --account ACCOUNT --no-attach

Use the complete native_session_id. Codex IDs created a few minutes apart begin with the same characters, so a shortened ID can pick the wrong conversation. Ocho copies the conversation and continues it in launch_cwd with the account you name. It never moves or changes Tuft's copy. The last line of the output is {"id": …, "machine": …, "state": …}. A conversation with a Tuft title keeps it. If the session has no title, name it with the id from the output:

~/.local/bin/ocho rename local SESSION "TITLE"

A new name can take half a minute to show in ~/.local/bin/ocho sessions --local --fields id,title,state.

If ocho resume says that no login is available, ask the user whether Ocho may use the login that Tuft saved for that conversation. If they agree, add --use-tuft-login. If ocho resume says there is nothing to resume, tell the user, and leave the conversation in Tuft's folder. Don't start it another way, such as ocho new --resume, because that creates a session without the conversation.

Tell the user which conversations came over. Don't switch the user to them yet. Explain that after the move, !sessions lists their sessions and !3 switches to the third one. "Ocho helper" stays in that list, so they can always get back to you.

Remove Tuft

Do this only after every check above passed. Ask the user: "Ocho is working. Should I remove Tuft now? Your Tuft conversations stay on this Mac, but Tuft will stop replying." Wait for the user to agree. Then run:

tuft uninstall

This stops Tuft's services and removes its LaunchAgents. Tuft.app, the tuft command and ~/.config/tuft stay. Don't delete ~/.config/tuft. It leaves the Mac's Tailscale alone, so the Mac stays on Tuft's tailnet. Check that Tuft is gone:

ls ~/Library/LaunchAgents | grep com.expo.tuft

This prints nothing once Tuft is removed. Tell the user: "Tuft is removed. From now on, message Ocho instead of Tuft."

Move the Mac to the User's Own Tailnet

Tuft connected this Mac to Tuft's tailnet. Once Tuft is removed, offer to move the Mac to the user's own Tailscale account, so they can reach it from their other computers. Ocho's iMessage and Slack don't go through Tailscale, so this conversation keeps working while you do it.

Ask the user: "Do you want to reach this Mac from your other computers? I can move it from Tuft's Tailscale network to your own Tailscale account." If they say no, skip this section.

Never run tailscale down or tailscale logout here. Tuft's profile is the way back if the move fails.

First, find the Tailscale command and note the current network, Tuft's, so that you can switch back to it:

mkdir -p ~/ocho-handover && chmod 700 ~/ocho-handover && cd ~/ocho-handover
for t in /Applications/Tailscale.app/Contents/MacOS/Tailscale /opt/homebrew/bin/tailscale /usr/local/bin/tailscale; do
  "$t" status --json > status-before.json 2>/dev/null && { echo "$t" > tailscale-cli; break; }
done
"$(cat tailscale-cli)" switch --list --json > profiles-before.json 2>/dev/null
plutil -extract CurrentTailnet.Name raw -o - status-before.json > before-tailnet
osascript -l JavaScript -e 'function run(a){var l=JSON.parse(ObjC.unwrap($.NSString.stringWithContentsOfFileEncodingError(a[0],4,null)));for(var i=0;i<l.length;i++)if(l[i].selected)return l[i].id;return ""}' "$PWD/profiles-before.json" > before-profile
echo office-mac > hostname
cat before-profile before-tailnet

It prints the ID of Tuft's Tailscale profile and the name of Tuft's tailnet. If it prints no profile ID, the Mac has no profile to switch back to, so tell the user that and stop here. hostname is the Mac's name on the user's tailnet. Ask the user if they want another name.

Then ask the user for an auth key: "If you don't have a Tailscale account yet, create one at https://login.tailscale.com/start. Install the Tailscale app on your phone, sign in with that account, and keep it connected. Then open https://login.tailscale.com/admin/settings/keys, choose Generate auth key, leave Reusable and Ephemeral off, turn Pre-approved on if it's offered, and generate the key. Don't send me the key. I'll send you a link to enter it." When they have it, print this on its own line, and run ~/.local/bin/ocho secret ask TS_AUTHKEY:

ocho://secret/TS_AUTHKEY

The user enters the key in the form the link opens. Wait for the file:

until test -s "$OCHO_HOME/secrets/$OCHO_EXECUTION_ID/TS_AUTHKEY"; do sleep 5; done

Save this script as ~/ocho-handover/join-tailnet.sh, exactly as it is:

#!/bin/bash
# join-tailnet.sh KEYFILE: move this Mac to the user's own tailnet with the
# auth key in KEYFILE, then check it. If any check fails, or this script stops
# early, the Mac returns to the network it was on before (Tuft's).
# Exit 0 means the Mac is on the user's tailnet and one of their devices answered.
set -u
key=${1:?usage: join-tailnet.sh KEYFILE}
dir=$(cd "$(dirname "$0")" && pwd)
cd "$dir" || exit 2
ts=$(cat tailscale-cli)
before=$(cat before-profile)
before_tailnet=$(cat before-tailnet)
log() { echo "$(date '+%H:%M:%S') $*" | tee -a join-tailnet.log; }
field() { /usr/bin/plutil -extract "$2" raw -o - "$1" 2>/dev/null; }
json() { osascript -l JavaScript -e "function run(a){var d=JSON.parse(ObjC.unwrap(\$.NSString.stringWithContentsOfFileEncodingError(a[0],4,null)));$1}" "$2" 2>/dev/null; }

# running_on = NAME | != NAME: wait up to 60 seconds for Tailscale to run on that tailnet, or on another one.
running_on() {
  for _ in $(seq 1 30); do
    "$ts" status --json > status.json 2>/dev/null
    name=$(field status.json CurrentTailnet.Name)
    if [ "$(field status.json BackendState)" = Running ]; then
      if { [ "$1" = "=" ] && [ "$name" = "$2" ]; } || { [ "$1" = "!=" ] && [ -n "$name" ] && [ "$name" != "$2" ]; }; then
        return 0
      fi
    fi
    sleep 2
  done
  return 1
}

# The safety net: a launchd job of its own, so nothing that stops this script
# stops it. Unless the checks passed, it switches back 4 minutes from now,
# then removes itself.
label=ocho-tailnet-safety-net
rm -f passed
launchctl remove "$label" 2>/dev/null
launchctl submit -l "$label" -- /bin/sh -c "sleep 240; [ -f '$dir/passed' ] || { echo \"\$(date '+%H:%M:%S') safety net: switching back to $before_tailnet\" >> '$dir/join-tailnet.log'; '$ts' switch '$before' >> '$dir/join-tailnet.log' 2>&1; }; launchctl remove $label"
disarm() { launchctl remove "$label" 2>/dev/null; }

fail() {
  log "FAILED: $*"
  log "switching back to $before_tailnet"
  "$ts" switch "$before" >> join-tailnet.log 2>&1
  if running_on = "$before_tailnet"; then log "back on $before_tailnet"; disarm; else log "NOT back on $before_tailnet: run: $ts switch $before"; fi
  exit 1
}

if [ -s user-profile ]; then
  # A retry: the one-off key was spent on the first try, and its profile stays.
  log "switching to the user's tailnet again"
  "$ts" switch "$(cat user-profile)" >> join-tailnet.log 2>&1 || fail "tailscale switch didn't succeed"
else
  log "joining the user's tailnet with the auth key"
  "$ts" login --auth-key="file:$key" --hostname="$(cat hostname)" --timeout=60s >> join-tailnet.log 2>&1 || fail "tailscale login didn't succeed (see join-tailnet.log)"
fi
running_on != "$before_tailnet" || fail "Tailscale isn't running on another tailnet"
if [ ! -s user-profile ]; then
  "$ts" switch --list --json > profiles-after.json 2>/dev/null
  json 'for(var i=0;i<d.length;i++)if(d[i].selected)return d[i].id;return ""' "$dir/profiles-after.json" > user-profile
  [ -s user-profile ] || fail "can't tell the new profile's ID"
fi
log "running on $(field status.json CurrentTailnet.Name) as $(field status.json Self.DNSName)"
peer=$(json 'var p=d.Peer||{};for(var k in p){if(p[k].Online)return p[k].HostName}return ""' "$dir/status.json")
[ -n "$peer" ] || fail "none of the user's devices is online on the user's tailnet"
"$ts" ping --c 5 --timeout 5s "$peer" >> join-tailnet.log 2>&1 || fail "$peer didn't answer a Tailscale ping"
log "$peer answered"
touch passed
disarm
log "check passed: this Mac is on the user's tailnet"
exit 0

Tell the user: "I'm moving this Mac to your Tailscale network now. It takes about a minute." Then run it:

bash ~/ocho-handover/join-tailnet.sh "$OCHO_HOME/secrets/$OCHO_EXECUTION_ID/TS_AUTHKEY"

The script joins the user's tailnet with the key, waits until the Mac is online there, and pings one of the user's own devices. If a check fails, it switches back to Tuft's tailnet. A launchd job switches back within 4 minutes even if the script stops early. The key works once. The log is ~/ocho-handover/join-tailnet.log.

If it printed "check passed", tell the user: "This Mac is on your Tailscale network now, as NAME, and your DEVICE answered it." NAME is the name the log shows after "as". Then ask them: "On another computer of yours with Tailscale, can you see this Mac in the Tailscale app?"

If it failed, the log says which check failed, and the Mac is back on Tuft's tailnet. Tell the user what failed:

Then run the script again. A retry switches to the profile the first try made, so it needs no new key unless the login itself failed.

Once the user can see the Mac, ask whether to remove Tuft's network from this Mac. If they agree, remove Tuft's profile, which is not the current one:

"$(cat ~/ocho-handover/tailscale-cli)" switch remove "$(cat ~/ocho-handover/before-profile)"
"$(cat ~/ocho-handover/tailscale-cli)" status

The status shows the Mac on the user's network. Until you remove it, tailscale switch with that profile ID returns the Mac to Tuft's tailnet.

Use Ocho from the User's Laptop

Once the Mac is on the user's own tailnet, offer to set up Ocho on the user's laptop, so they can open these sessions there with ocho attach. The laptop joins this Mac's Ocho as a node. A node has its own key and the same authority as this Mac: it sees the same machines, accounts and sessions, and can add or remove machines. Ask the user: "Do you want to use Ocho from your laptop too?" If they say no, skip this section.

The laptop reaches this Mac over SSH, so Remote Login must be on here. Check it:

nc -z localhost 22 && echo on

If it prints nothing, Remote Login is off. Turning it on needs System Settings on this Mac, so tell the user: "When you're next at this Mac, open System Settings, General, Sharing, and turn on Remote Login." Continue with this section after that.

Find this Mac's name on the tailnet and your user name:

"$(cat ~/ocho-handover/tailscale-cli)" status --self
whoami

Then send the user these steps for their laptop, with the names filled in:

"On your laptop, open Terminal and run:

curl -fsSL https://fleet-service.eliot-4cd.workers.dev/releases/install-cli.sh | bash

Then run:

ocho join USER@MAC-NAME

It may ask for this Mac's login password once. When it finishes, ocho sessions on your laptop lists the sessions here, and ocho attach opens one."

The join works only while Ocho on the laptop is empty, with no accounts and no other machines. If the user already uses Ocho on that laptop, stop here and tell them. Joining would need their existing setup removed first, so follow Reporting a Problem instead.

When the user says the join finished, check it here:

~/.local/bin/ocho node list

The laptop appears as an active node. Ask the user to run ocho sessions on the laptop and confirm that "Ocho helper" is listed.

What Changes for the User

Share the parts that matter to the user:

In TuftIn Ocho
Message Tuft in Slack or MessagesMessage Ocho. !new TASK starts a session, !sessions lists recent ones, and !3 or !attach SESSION switches to one.
++ to queue, !! to interruptA new message interrupts the current turn.
/view for the live terminalocho attach SESSION on any of the user's computers
Preview links and *.tuft.hostLinks open from the session, or with ocho open
MCP servers and skills in the control panelThe user's own Claude Code and Codex settings

Reporting a Problem

When something fails, the Tuft agent or the helper first tries to find the cause and fix it, with ~/.local/bin/ocho doctor, ~/.local/bin/ocho diagnostics errors, ~/.local/bin/ocho diagnostics show ID, the logs and a retry. Only if that fails, make a report:

1. Write ~/ocho-report/report.md with what you were doing, what you tried, the exact error text and any diagnostic IDs. 2. Save the script below as ~/ocho-report/make-report.sh and run it with bash ~/ocho-report/make-report.sh. It prints the path of the zip. 3. List the zip with unzip -l ZIP, and check that it holds only the files the script names. 4. Send the zip to the user as an attachment, with: "I couldn't fix this myself. Please send this file to Eliot, who develops Ocho and Tuft, on Slack."

The script copies only these files: report.md, the output of ocho doctor and ocho diagnostics errors, and the last 2000 lines of Ocho's serve.log, supervisor-stderr.log, supervisor-updates.log and sessiond/fleet.log, and of Tuft's tuft.log, tuft-tmux-host.log and tuft-cloudflared.log. It never copies a folder. So it leaves out Ocho's secrets, accounts, keys and identity, ~/.claude, ~/.codex, Tuft's claude-config and codex-home, every auth.json and .credentials.json, ~/.ssh and every conversation transcript. It also drops every line that could hold a credential, notes in removed.txt how many it dropped, and checks the finished zip again:

#!/bin/bash
# make-report.sh: zip a problem report as ~/ocho-report-DATE.zip, from
# ~/ocho-report/report.md (write it first), the output of ocho doctor and
# ocho diagnostics errors, and the last 2000 lines of Ocho's and Tuft's logs.
# It copies only those files, never a folder, and drops every line that could
# hold a credential. Then it checks the zip. Prints the zip's path.
set -u
r="$HOME/ocho-report"
o="${OCHO_HOME:-$HOME/.local/share/fleet}"
t="$HOME/.config/tuft/logs"
ocho="$(command -v ocho || echo "$HOME/.local/bin/ocho")"
cd "$r" 2>/dev/null && [ -s report.md ] || { echo "Write $r/report.md first." >&2; exit 1; }
rm -rf logs removed.txt && mkdir logs
"$ocho" doctor > doctor.txt 2>&1
"$ocho" diagnostics errors > diagnostics-errors.txt 2>&1
for f in serve.log supervisor-stderr.log supervisor-updates.log sessiond/fleet.log; do
  [ -f "$o/$f" ] && tail -n 2000 "$o/$f" > "logs/ocho-$(basename "$f")"
done
for f in tuft.log tuft-tmux-host.log tuft-cloudflared.log; do
  [ -f "$t/$f" ] && tail -n 2000 "$t/$f" > "logs/$f"
done
pat='sk-ant-|tskey-|xox[abpr]-|gh[pousr]_[A-Za-z0-9]{20}|eyJ[A-Za-z0-9_-]{20,}|BEGIN [A-Z ]*PRIVATE KEY|access_token|refresh_token|id_token|api[_-]?key|bearer |password|secret|cookie|authorization'
touch removed.txt
for f in report.md doctor.txt diagnostics-errors.txt logs/*; do
  [ -f "$f" ] || continue
  n=$(grep -c -E -i "$pat" "$f")
  if [ "$n" -gt 0 ]; then
    grep -v -E -i "$pat" "$f" > "$f.tmp"; mv "$f.tmp" "$f"
    echo "$f: removed $n lines that could hold a credential" >> removed.txt
  fi
done
zip="$HOME/ocho-report-$(date +%Y%m%d-%H%M).zip"
rm -f "$zip"
zip -qr "$zip" report.md doctor.txt diagnostics-errors.txt removed.txt logs
if unzip -p "$zip" | grep -q -E -i "$pat"; then
  rm -f "$zip"; echo "The report still matched a credential pattern; nothing was zipped." >&2; exit 1
fi
echo "$zip"