Repository navigation
Troubleshooting
purple writes structured logs to ~/.purple/purple.log (or purple.log in the state directory when you set XDG_STATE_HOME or PURPLE_STATE_DIR, see Getting Started#where-purple-keeps-its-files). This page is a complete reference for diagnosing every kind of failure: connection, sync, password, certificate, container, tunnel, file transfer, snippet, key push, MCP, theme, persistence, updates, rendering. Each section names the exact log strings to grep for and the concrete fix.
Before you dig into the log, run these three commands:
purple logs # prints the log path (~/.purple/purple.log)
purple logs --tail # follows the log in real time
purple --verbose # restart purple with debug-level loggingIf you opened an issue or asked someone for help, attach:
- The startup banner (top of the log, see Startup banner).
- The 50 lines around the failure.
- Your OS and terminal:
uname -a,echo "$TERM $COLORTERM". - The output of
ssh -V.
Never paste the entire log without redaction. The log contains host aliases and provider names. Tokens, passwords and TLS certificates are not logged on purpose (see What is never logged), but it is good hygiene to scan once before sharing.
The log file is created automatically on first run. It rotates at 5 MB. The previous file is renamed to purple.log.1 and the new file starts empty. There is exactly one backup. Older content is discarded.
purple logs # print the log file path
purple logs --tail # follow in real time (uses tail -f)
purple logs --clear # delete the log file (next run starts fresh)When ~/.purple/ is not writable (read-only home, hardened user, container without HOME) purple still runs but writes no log. The TUI shows status normally. The fix is to create ~/.purple/ with mode 700, set HOME to a writable directory or point PURPLE_STATE_DIR at one.
By default only warnings and errors are logged. The signal-to-noise ratio is intentionally high so a default install never floods the disk.
Raise verbosity in one of two ways:
| Mechanism | Effect | Use when |
|---|---|---|
--verbose flag |
Sets level to debug | Reproducing one bug from a fresh start |
PURPLE_LOG=trace|debug|info|warn|error|off env var |
Overrides everything | Persistent verbose mode without retyping the flag |
The env var takes precedence over the flag, including PURPLE_LOG=off which silences logging completely.
purple --verbose # debug level (most useful default)
PURPLE_LOG=trace purple # maximum detail (slow on large configs)
PURPLE_LOG=off purple # disable logging entirelyDebug-level logs include every state transition (toast routing, queue drops, filter changes, container refresh batch lifecycle, vault thread spawn and join). Trace-level adds nothing extra in purple itself but downstream libraries occasionally emit at trace.
Warnings and errors carry a prefix that identifies the source of the problem. The prefix is the first thing to grep for when you do not know where to look.
| Prefix | Meaning | Examples |
|---|---|---|
[external] |
Remote host or third-party tool | SSH exit code, SCP failure, Vault unreachable, provider API error, container runtime missing on remote, tmux subprocess failed |
[config] |
Local configuration issue | Missing password manager binary, SSH key permissions, unrecognized config line, theme file invalid, preferences write failed, provider config parse error |
[purple] |
Internal error in purple itself | Atomic write failed, SSH config write failed, snippet thread panicked, history save failed, vault sign thread spawn failed |
Info and debug messages are operational flow markers: they describe what purple did, not what went wrong. Many of them carry the same [external]/[config]/[purple] prefix as warnings and errors (for example the [purple] apply_filter:, [purple] refresh_batch: and [config] reload_hosts: lines referenced below), so a prefix alone does not mean a line is a problem. Filter by WARN/ERROR when you only want failures.
Quick grep recipes:
grep '\[external\]' ~/.purple/purple.log # blame: remote side
grep '\[config\]' ~/.purple/purple.log # blame: local environment
grep '\[purple\]' ~/.purple/purple.log # blame: purple itself
grep -E 'WARN|ERROR' ~/.purple/purple.log # anything that needs attentionEvery session writes a banner to the log file with diagnostic context. The banner bypasses level filters: even with PURPLE_LOG=off the banner is still written. This guarantees a support bundle always has the environment context.
--- purple v3.30.0 started at 2026-09-22 18:30:00Z ---
os=macos arch=aarch64 config=/Users/you/.ssh/config
ssh=OpenSSH_9.6p1, LibreSSL 3.3.6
term=xterm-256color colorterm=truecolor
theme=Purple
hosts=42 patterns=3 snippets=7
providers=aws,hetzner,tailscale
askpass=keychain,vault:secret/ssh#password
proxy_env=none
vault_ssh=enabled (addr=https://vault.example.com:8200)
dirs: config=/Users/you/.purple data=/Users/you/.purple state=/Users/you/.purple cache=/Users/you/.purple
log_level=debug
| Field | What to check |
|---|---|
os / arch
|
Platform-specific bug (macOS keychain, Linux SELinux, etc.) |
config |
SSH config path actually being read |
ssh |
ssh binary version. Old OpenSSH (<8.0) lacks features purple expects |
term / colorterm
|
Color tier detection (NO_COLOR / ANSI 16 / truecolor) |
theme |
Active theme. Mismatch with expectations means preferences load failed |
dirs |
Where config, data, state and cache resolved. All four read ~/.purple unless XDG_*_HOME or PURPLE_*_DIR is set |
hosts / patterns / snippets
|
Parsed counts. Zero on a non-empty config means parse error elsewhere |
providers |
Configured cloud providers. Empty means none configured |
askpass |
Password sources in use. Helps diagnose auth flow |
proxy_env |
HTTP/HTTPS/ALL/NO_PROXY env vars in effect (names only, never values) |
vault_ssh |
Vault SSH secrets engine integration state and resolved address |
log_level |
Effective verbosity for this session |
Look for [external] SSH connection failed: <alias> (exit <code>) (interactive shell) or [external] SSH exec failed: <alias> (exit <code>) (one-shot command). Common exit codes:
| Exit | Meaning | Fix |
|---|---|---|
| 1 | Generic SSH error | Read the captured stderr in the next log line |
| 2 | Misuse of command | Usually a malformed alias or option in ~/.ssh/config
|
| 5 | Permission denied (publickey) | Check IdentityFile, key permissions (chmod 600), key loaded in agent |
| 124 | Timeout | Network or firewall. Try ssh -v <alias> for the SSH handshake trace |
| 255 | SSH could not connect at all | DNS, route, firewall. ping, traceroute, ssh -v
|
Look for [external] Host key CHANGED detected. The remote host's identity changed since the last connection. Either the host was re-installed or someone is intercepting.
Safe response when you trust the change (re-install, new VM, IP recycled):
ssh-keygen -R <hostname>
ssh-keygen -R <ip-address>Then reconnect. purple offers an inline reset prompt for some flows (Containers tab); the dialog runs the same ssh-keygen -R for you.
Look for [external] Host key UNKNOWN detected. On the first connection SSH asks you to accept the new key. purple shows a toast: "Host key unknown. Connect first (Enter) to trust the host." Press Enter from the host list to launch an interactive SSH session that will prompt for trust.
Look for [external] tmux new-window failed for <alias> (exit <code>) or [external] tmux exec window failed. Causes:
- No tmux session running. Start one first (
tmux new -s work) or open purple outside of tmux. - tmux server crashed.
tmux kill-serverand start fresh. - An outdated tmux. Update to a current release and retry.
Look for [purple] container exec queued or [purple] cli tunnel start: alias=<alias> lines and check if the next event ever fires. If purple queues the work but no completion arrives, the underlying ssh is blocked. Causes:
- TCP timeout (host unreachable, slow DNS).
-
ProxyJumpchain stalled.
Workaround: open a vanilla ssh -v <alias> in another terminal to see the prompt or the hang point.
A password or host key question is not one of the causes. Background runs pass -o BatchMode=yes and -o StrictHostKeyChecking=yes, so such a question comes back as an error that purple reports or turns into a dialog. A ProxyJump hop runs without those options, because OpenSSH builds its command from -l, -p, -J, -F and -v alone. It inherits the askpass environment instead, which routes its question to purple rather than to the terminal. See Password Management.
Expected. The host takes a password and has no source configured, so purple asks in the TUI rather than letting ssh write a prompt into a screen it is about to paint over. Choose remember in keychain to answer once and for good. Choose this session only to keep it in memory until you quit.
Look for [purple] password prompt: open alias=<alias> in the log. The line right before it says why the host refused.
If you expected a configured source to answer, read the message. It names the source that did not deliver. The fix is on that side: unlock the vault with bw unlock, op signin or pass-cli login. A renamed item or field needs correcting in the host's # purple:askpass value instead. See Password Management.
Expected on first contact. The host is not in known_hosts yet. A background run cannot ask on a terminal, so purple asks in the TUI. y records the key, n skips the host. Look for [purple] host key trust: open alias=<alias>.
The dialog shows no fingerprint because ssh prints none under strict checking. Compare the key out of band when the host matters: ssh-keyscan -t ed25519 <host> | ssh-keygen -lf - next to what the server operator published.
A host key that changed never gets this dialog. That keeps the reset prompt described above, because a changed key means either a reinstall or an interception.
Expected. ssh authenticates each hop in turn and names the one that refused or that it does not know. When that is the jump host rather than your target, no dialog opens: a password typed for the target would never reach that hop. Trusting a key under the target's name would record the wrong one. Look for [external] key_push: a hop refused or [external] key_push: unknown host key came from a hop in the log.
The fix is on the jump host's own entry. Give it a password source of its own. You can also connect to it once so its key lands in known_hosts.
Look for [config] ignoring PURPLE_SSH_COMMAND=<value>: <error> or [config] ignoring preference ssh_command=<value>: <error>. The configured wrapper (kitten ssh, sshpass, a company script) could not be parsed, for example through unbalanced quotes or an empty command. purple falls back to plain ssh so connecting keeps working. Fix the value where it is set: kitty.conf, your shell init or ~/.purple/preferences. The environment variable wins over the preference.
Look for [config] Provider sync returned 0 hosts: <name> (check API token permissions). Common causes:
- API token has read scope but lacks the specific permission the provider requires (e.g. AWS
ec2:DescribeInstances, Hetzner read-only token without server list). - Region filter excludes all instances. Check
--regionsyou passed when configuring the provider. Remove the filter (purple provider add <name> --regions "") to fetch all regions and confirm. - Account is empty (correct outcome). Spin up a test VM and re-sync.
Look for [external] Provider sync partial: <name>, N hosts, M failures. Some pages or regions failed. purple keeps the partial result and refuses --remove so a flaky API does not wipe your config. Re-run sync once the provider recovers.
Detail per failure is logged at debug level just above this line. Look for [external] HTTP 4xx/5xx or [external] Request failed.
Look for [external] HTTP 401: authentication failed or HTTP 403. The token is invalid, expired or revoked. Fix:
purple provider remove <name>
purple provider add <name> --token <new-token>For AWS, check ~/.aws/config and ~/.aws/credentials. Both are read and merged per profile name. Set --profile explicitly in purple provider add aws to pin one. An access key starting with ASIA is a temporary STS credential. Those expire within the hour on most setups, so refresh the session and sync again.
A profile that assumes a role reports what went wrong by name: Could not assume <role-arn>. AccessDenied: ... means the trust policy or your permissions, not purple. Profile 'x' sets role_arn but no source_profile and Profile 'x' uses credential_process, which purple does not run say exactly which line in ~/.aws/config to look at.
A host whose HostName is an instance ID reaches AWS through Session Manager. Check in this order:
-
session-manager-plugin --versionandaws --version. Both have to be on the PATH purple startssshwith. A missing plugin fails withSessionManagerPlugin is not found. -
TargetNotConnectedmeans the instance is not a managed node, is in another region or is in another account. The message is identical for all three, so check the region on theProxyCommandline matches the instance. -
ssm:StartSessionhas to be allowed on the instance and on theAWS-StartSSHSessiondocument. A policy that grants only the instance fails with an access denied that names the document. - SSH keys still apply. Session Manager carries the connection, it does not authenticate you.
If the sync itself fails with Could not list Session Manager nodes in <region>, your credentials may open sessions but not list nodes. Set Session Manager to always to skip the lookup.
Look for [external] HTTP 429: rate limited. The provider throttled the request. Wait, then re-run the sync after the provider's cool-off window (varies per provider). purple does not auto-retry a throttled sync: it surfaces the 429 and leaves your existing hosts untouched.
Look for [config] cli sync: skipping unknown provider '<name>' (CLI sync) or [config] Unknown provider requested for sync: <name> (TUI), or [config] Skipping invalid section header [<header>]. Your ~/.purple/providers file references a provider purple does not know (typo, removed provider, future-only provider). Fix by editing the file or running purple provider list to see what is configured.
Related parse warnings from the same file: [config] Skipping duplicate section header [<header>] (the first section wins) and [config] Skipping [<header>]: mixing bare and labeled sections for provider '<name>' is not allowed (give the bare section a label or remove the labeled one). A save that would produce an invalid file is refused with [config] Refusing to save invalid provider config: <reason>.
Three refusal lines, all protective:
-
[config] sync write refused: external config change for provider=<name>. Your~/.ssh/configchanged on disk while the sync ran. Nothing was written; re-run the sync. -
[config] sync skipped: no config for provider=<name>or[config] sync skipped: unknown provider=<name>. The requested provider has no configuration or is not recognized. -
[config] cli sync: skipping ssh config write due to hard failures. One or more providers failed hard, so the whole write was skipped to avoid losing hosts.purple syncthen exits 1 and logs[purple] cli sync: completed with failures (exit 1).
Look for [external] Proxmox resolve failed: <node>/<type>/<vmid> '<name>': <reason>. Proxmox sync makes a per-VM detail call after listing; a VM that fails to resolve is skipped while the rest continues. The authentication (401/403) variant means the API token lacks permission on that specific VM or node; align its privileges with the VMs that do sync.
Look for [config] Password manager binary not found: <tool>. Tools purple integrates with:
| Source | CLI binary | Install |
|---|---|---|
op:// (1Password) |
op |
brew install 1password-cli |
pass: (pass) |
pass |
brew install pass or distro package |
bw: (Bitwarden) |
bw |
brew install bitwarden-cli |
proton: (Proton Pass) |
pass-cli |
curl -fsSL https://proton.me/download/pass-cli/install.sh | bash |
vault: (Vault KV) |
vault |
Official HashiCorp installer |
keychain (OS keychain) |
built-in | n/a |
keychain is the only source without a trailing colon. it has no payload argument because the lookup key is the host alias itself. All other sources use prefix:argument form (e.g. vault:secret/ssh#password).
Fix: install the binary, then verify with which op (or the relevant tool). purple does not need a configuration step beyond that.
Every failed lookup logs a per-source line with the tool, lookup key, exit code and first line of scrubbed stderr, e.g. [external] askpass keychain lookup failed: alias=<alias> exit=<code> stderr=<...>. Variants exist for secret-tool, 1Password, pass, Bitwarden, Vault KV and the custom command source.
Look for [external] Password retrieval failed via bw:. Bitwarden vaults stay locked between sessions. Unlock first:
bw login # only once per machine
bw unlock # captures session token
export BW_SESSION=$(bw unlock --raw)
purplepurple inherits the BW_SESSION env var. When it is missing or expired, retrieval fails silently with a warning toast and falls back to the SSH password prompt.
Look for [external] Password retrieval failed via vault:secret/.... The vault: askpass prefix queries the Vault KV secrets engine (distinct from the Vault SSH secrets engine; see Vault SSH Certificates for that). Check:
vault status # Vault reachable and unsealed?
vault kv get secret/ssh # the path your askpass points atvault: paths use #field syntax: vault:secret/ssh#password reads the password field. Mistyped field names produce empty strings, which purple treats as retrieval failure.
Look for [external] Password retrieval failed via proton:.... The proton: askpass prefix calls pass-cli item view --vault-name Vault --item-title Item --field field. purple runs pass-cli test before every connect and prompts for a Personal Access Token on stdin when not authenticated. If the prompt was dismissed, or you connect from a non-interactive context where the PAT prompt cannot complete (a script with no stdin, an automation runner, an ssh -F invocation that bypasses purple), log in manually:
pass-cli login # default: web sign-in (opens a URL)
pass-cli login --interactive # username + password on the terminal
PROTON_PASS_PERSONAL_ACCESS_TOKEN=pst_xxx... pass-cli login # headless
pass-cli item view --vault-name Vault --item-title Item --field password # verify the lookup pathproton: paths use the Vault/Item/field form (no leading slash, no scheme). purple maps this to name-based lookup flags (--vault-name, --item-title, --field) on every retrieval, so Proton's internal share/item IDs never leak into your SSH config.
The pre-flight and login steps log their own lines: [external] Proton Pass PAT prompt read failed: <error>, [external] Proton Pass login failed after retries: <error> and [external] Proton Pass returned empty secret (the item exists but the field name is wrong or the field is empty).
Look for [config] SSH key permission issue. SSH refuses keys with loose permissions. Fix:
chmod 600 ~/.ssh/id_* # private keys: owner-only
chmod 644 ~/.ssh/id_*.pub # public keys: world-readable is fine
chmod 700 ~/.ssh # directory itself: owner-onlypurple integrates two distinct HashiCorp Vault secrets engines. Do not confuse them when troubleshooting:
| Engine | Purpose | purple integration |
|---|---|---|
| Vault SSH secrets engine | Signs SSH public keys into short-lived certificates |
purple vault sign, # purple:vault-ssh <role> per host, Vault SSH role per provider |
| Vault KV secrets engine | Stores arbitrary key/value secrets |
vault: askpass prefix (password source) |
This section covers the Vault SSH secrets engine. For Vault KV password retrieval see the previous section.
Look for any of the following Vault SSH signing errors. They surface as toasts; in the log, connection problems appear under [external] Vault unreachable: and auth problems under [external] Vault auth failed:, with [external] Vault SSH signing failed: for anything unclassified:
-
Vault SSH connection refused.. server is not accepting connections (down, wrong port). -
Vault SSH connection timed out.. network path or firewall. -
Vault SSH timed out. Server unreachable.. long-running operation aborted. -
Vault SSH host not found.. DNS resolution failed. -
Vault SSH server uses HTTP, not HTTPS. Set address to http://.. protocol mismatch. -
Vault SSH TLS error. Check certificate or use http://.. self-signed cert or expired cert.
Verify in this order:
echo $VAULT_ADDR # is it set? required unless overridden
vault status # reachable, unsealed, initializedpurple resolves the Vault address in this precedence order, from highest to lowest: --vault-addr CLI flag, # purple:vault-addr <url> per-host comment, provider-level vault_addr default, VAULT_ADDR env var. The startup banner shows the resolved address under vault_ssh=enabled (addr=...).
Look for Vault SSH token missing or expired. Run \vault login`.orVault SSH permission denied. Check token and policy.`. Vault tokens have TTL. Re-authenticate:
vault login # interactive
# or
VAULT_TOKEN=$(...) purple # one-shotFor Vault Agent / approle setups, refresh the token file purple's vault CLI reads.
Look for [external] Vault SSH signing failed: <reason> with a generic reason (the stderr scrubber strips secrets). Causes:
- Role name typo: check
# purple:vault-ssh <mount>/sign/<role>on the host (or providervault_roledefault). The role must be inmount/sign/roleform. - Role does not exist on the Vault server:
vault read <mount>/roles/<role>on the Vault side. - Public key not allowed by the role: the role's
allowed_usersorkey_typerejects what purple sent.
The signed certificate is cached under ~/.purple/certs/<alias>-cert.pub. Inspect it with ssh-keygen -L -f ~/.purple/certs/<alias>-cert.pub to see what was actually signed.
Look for Vault SSH certificate cache hit: alias=... role=... path=... (info-level) or Vault SSH certificate cache miss: alias=... role=... status=... -> signing (debug-level). The cache hit means an existing cert at check_path is still valid and was reused. A miss triggers re-signing when the status is Missing, Expired, Invalid, or a Valid cert with less time left than the renewal threshold.
If you expect a fresh cert but always see cache hit, delete the cached file: rm ~/.purple/certs/<alias>-cert.pub.
Rare internal failures: [purple] vault sign thread: spawn failed: <error> (the worker thread could not start) and [external] Failed to read vault stdout pipe: <error> or stderr pipe (the vault child died mid-read). Both surface as a failed sign; retry it.
See Vault SSH Certificates for setup, policy and role configuration.
Look for [external] Container fetch failed: alias=<alias>: Docker or Podman not found on remote host. purple SSHes in and runs docker ps or podman ps; neither exists in $PATH. Fix on the remote host:
# Debian/Ubuntu
sudo apt-get install docker.io
sudo usermod -aG docker $USER
# RHEL/Fedora/Rocky
sudo dnf install podmanLook for [external] Container fetch failed: alias=<alias>: Permission denied. Is your user in the docker group? The SSH user lacks access to the docker daemon socket. Fix:
sudo usermod -aG docker <user> # add to group
# log out and back in so the group takes effectFor Podman, rootless mode does not need a group; check that podman info works as the SSH user.
Look for [external] Container fetch failed: ...: Container daemon is not running. Start the service on the remote:
sudo systemctl start docker # or podman.socket for rootful PodmanWhen you press K (restart) or S (stop) on a container, the result lands as a green Success toast. Look for Container restart complete. or Container stop complete. in the log. Missing entry usually means the SSH call failed; check the preceding line for [external] Container <action> failed: alias=... (the action verb is interpolated, e.g. Container restart failed).
Since v3.10 the result routes to the Success toast because the action is user-initiated.
Look for [external] container inspect failed: alias=<alias> id=<id>: <error> or [external] container logs tail failed: alias=<alias> id=<id>: <error>. The listing worked but the per-container docker inspect or log tail failed. The l log stream ends with [external] container_logs_complete: alias=<alias> id=<id> error=<error> when it dies on an error.
Look for [external] Container list parse failed: alias=<alias>: <error>. The remote docker ps or podman ps produced output purple could not parse (a very old runtime, a shell wrapper printing extra lines). Run the same command manually over SSH to see what the runtime emits.
A cache save failure logs [config] Failed to write container cache <path>: <error>; the tab still works and refetches next time.
Look for [purple] refresh_batch: alias=... done=X/Y in_flight=Z queue=Q spawned_next=N. Each completion logs one line. If in_flight > 0 but no new lines arrive, an SSH child is stuck. Either kill the ssh child manually (pkill -f "ssh.*<alias>") or wait for the per-host TCP timeout.
Look for [purple] cli tunnel start: alias=<alias> followed by no progress. Then look for the SSH exit code. Common causes:
- The host has no
LocalForward/RemoteForward/DynamicForwarddirectives configured. Add one withpurple tunnel add <alias> L:8080:localhost:80or via the TUI. - The local port is already bound (another process or a stale tunnel). Find it with
lsof -nP -iTCP:<port> -sTCP:LISTENand free it. - The remote does not allow port forwarding. Set
AllowTcpForwarding yeson the SSH server.
Look for [purple] cli tunnel start: no forwards for alias=<alias>. You ran purple tunnel start <alias> but the host has zero forwarding directives. Add one first.
The TUI shows the tunnel as configured but the connection dot stays dim. The tunnel path runs ssh -N under the hood. Failures surface as [external] tunnel start failed: alias=<alias>: <error>. Often this is a ProxyJump chain timing out or the remote refusing the forward.
Look for [external] Tunnel exited unexpectedly: alias=<alias> exit=<code>. The ssh -N process ended without you stopping it: the remote closed the connection, an idle timeout fired or the network dropped. Exit 255 is ssh's own transport failure; the surrounding lines carry the captured stderr.
The live per-tunnel counters on the Tunnels page come from netlink INET_DIAG. Look for [external] netlink INET_DIAG family=<family> dump failed: <error>, [external] netlink OVERRUN: kernel dropped messages, counters incomplete this tick or [external] netlink dump truncated at <N> sockets. All three only degrade the display for a tick; the tunnels themselves keep running.
Look for [external] scp transfer failed: alias=<alias> exit=<code> stderr=<...>. The exit code comes straight from the scp binary; OpenSSH does not publish a stable code table, so treat any non-zero value as "something went wrong" and read the captured stderr at the end of the same line. When scp itself could not start (binary missing, exec failure) the line is [external] scp spawn failed: alias=<alias>: <error> instead.
Common failure modes:
- Permission denied on the destination (read-only filesystem, missing directory).
- Source file missing or unreadable locally.
- Connection lost mid-transfer (network instability).
- Remote disk full.
The file browser overlay shows the same error inline; the log adds the exact exit code so you can correlate with man scp exit semantics for your platform.
Look for [external] remote ls failed: alias=<alias> path=<path> exit=<code> stderr=<...> or [external] remote ls spawn failed: alias=<alias> path=<path>: <error>. The file browser runs ls on the remote for every directory you enter. Permission denied and deleted directories produce the first form; the second means the ssh child itself could not start. The overlay shows the same error inline.
The host shows exit 127 with a line like bash: line 1: pm2: command not found. The snippet runs in a non-interactive shell, the same as ssh yourhost 'pm2 status'. In that shell ~/.bashrc stops before the PATH lines that nvm and similar tools add. You can confirm this from your own terminal: that ssh command fails the same way.
Turn on Interactive shell for the snippet (e, then Space on that field) or add it again with purple snippet add NAME "COMMAND" --interactive. See Command Snippets#interactive-shell.
Look for [purple] Snippet stdout reader thread panicked or [purple] Snippet stderr reader thread panicked. The reader thread for one target host died. Other hosts continue. Causes:
- Remote command produced binary output that broke UTF-8 assumptions.
- SSH child exited mid-stream.
Inspect the affected host's stderr in the snippet output overlay; the captured tail is shown verbatim.
Look for [purple] cli snippet: dispatch followed by nothing. The dispatcher hit an early-exit branch (no host, snippet name typo). Check the next stderr line.
Look for [external] snippet ssh spawn failed: run_id=<id> alias=<alias> err=<error>. The ssh child for that host could not start, for example when file descriptors run out during a large parallel run. Other hosts in the run continue.
Look for [external] key_push: failed alias=<alias> err=<error>. The per-host append failed: connection error, no write permission on ~/.ssh, read-only home on the remote. Other hosts in the run continue and the summary counts Appended, AlreadyPresent and Failed.
Three validation refusals, all logged before anything is sent:
-
[config] key_push: pubkey too large path=<path> bytes=<n>. The.pubexceeds the 16 KiB cap. -
[config] key_push: pubkey not a regular file path=<path>. The path is a symlink or a special file; purple opens the key withO_NOFOLLOWby design. -
[config] key_push: invalid pubkey path=<path> err=<reason>. Not a single-line OpenSSH public key of an allowed algorithm. Certificate tokens (*-cert-v01@openssh.com) are intentionally not pushable.
[purple] key_push: failed to spawn worker: <error> means the background thread could not start; retry the push.
Look for [config] Invalid theme file: <path>. Custom themes live in ~/.purple/themes/*.toml. Parse failure causes purple to fall back to the previous (or default) theme. Validate the TOML with cat ~/.purple/themes/<name>.toml | python -c 'import sys,tomllib; tomllib.loads(sys.stdin.read())' or any TOML linter.
The startup banner shows theme=<name>. If the name matches your custom theme but colors look wrong, the file parsed but the color tier mismatched: a custom theme that only defines truecolor slots will render flat on an ANSI-16 terminal.
Check the banner: colorterm=truecolor is required for the full palette. colorterm=unset or anything other than truecolor/24bit collapses purple to ANSI 16. Fix:
echo $COLORTERM # check what your terminal setsIf your terminal supports truecolor but the env var is missing, export it from your shell init: export COLORTERM=truecolor. Common terminals that support truecolor: iTerm2, Alacritty, WezTerm, Kitty, modern xterm, modern tmux (with tmux -2 or set-option -sa terminal-features ',xterm-256color:RGB').
Setting NO_COLOR=1 (per no-color.org) collapses purple to modifiers-only (bold, dim, no color). Helpful for screen readers and accessibility-first workflows. The banner shows the corresponding effective tier.
Look for [config] failed to save <key>=<value>: <error>. Every preference write logs a debug line before the save and a warn on failure. The common causes:
-
~/.purple/directory is read-only or not writable by the current user. - Disk full or filesystem mounted read-only.
- File system permissions blocking atomic rename (
atomic_writeuses temp file + rename; rename fails on cross-filesystem boundaries).
Each preference has its own debug line:
[purple] saving sort_mode=<value>[purple] saving group_by=<value>[purple] saving view_mode=<value>[purple] saving containers_sort_mode=<value>[purple] saving containers_view_mode=<value>[purple] saving containers_collapsed_hosts=<csv> (N aliases)[purple] saving askpass default=<value>[purple] saving slow_threshold_ms=<value>[purple] saving theme=<value>[purple] saving ssh_command=<value>[purple] saving auto_ping=<value>[purple] saving last_seen_version=<value>
If the debug line is there but no warn follows, the write succeeded. If both are missing, the code path was never reached. Check the keybinding or screen handler.
Other state files follow the same pattern on failure: [config] Failed to save connection history: <error> (history.tsv), [purple] failed to save recents: <error> (recents.json), [config] snippet_runs flush failed: <error> (snippet_runs.json) and [config] failed to save sync_history.tsv at <path>: <error>.
Look for [purple] SSH config write failed: <path>: <error> or [purple] Atomic write failed: <path>: <error>. Causes:
-
~/.ssh/configis read-only. - Disk full.
-
~/.ssh/configis a symlink whose target is on a different filesystem (atomic rename fails). - Another process holds an advisory lock (purple uses
flock; concurrent writes block until the lock releases).
purple keeps the last 5 backups: ~/.ssh/config.bak.<timestamp>. Restore with cp ~/.ssh/config.bak.<latest> ~/.ssh/config if a write corrupted the file.
Host and tag saves log their own [config] failed to save ...: alias=<alias>: <error> line before the toast, tunnel saves add the directive ([config] failed to save tunnel forward: alias=<alias> <key> <value>: <error>) and snippet saves log [config] snippet store save failed, rolling back: <error>. The log names the exact operation that failed. A field value containing line-breaking characters is sanitized before the write and logs [purple] sanitized line-breaking characters from value before writing to ssh_config.
Look for [config] check_config_changed: mtime drift detected on <path> -> reloading. Someone (you, another purple instance, an editor) modified ~/.ssh/config while purple was running. purple reloads automatically and clears ping results. When the screen is a form, picker or confirm overlay, the reload is deferred until you close the overlay.
For the Vault SSH bulk sign path, an external edit during a long-running sign batch is detected separately. Look for External ssh config edits detected, merged N CertificateFile directives (good) or External ssh config edits detected; certs on disk, no CertificateFile written (the sign succeeded but the directive merge was skipped to avoid clobbering your edit).
Hosts from Include files are shown but never modified. Two parse warnings:
-
[config] Could not read Include file <path>: <error>. The referenced file is missing or unreadable. The rest of the config still loads. -
[config] Include cycle detected, skipping <path> (already visited up the chain). Two files include each other; purple reads each file once and skips the loop.
Look for [purple] apply_filter: query=... down_only=... scope=.... The function logs every invocation. Missing entry means the keystroke did not dispatch the filter handler. Use PURPLE_LOG=trace to capture every key event. Common cause: a sticky toast is consuming the key (rare).
Look for [purple] apply_sort: mode=... group_by=... hosts=.... Same pattern as filter. Missing entry means the keystroke dispatched somewhere else (focus on a search bar instead of the host list, for example).
Look for [purple] background status suppressed (sticky active, dropped: <text>). When a sticky message is on screen (Vault SSH progress, sticky error, container refresh progress) purple suppresses lower-priority background toasts so the sticky stays visible. Press a key or wait for the sticky to clear, then the next message routes normally.
User-initiated toasts (Success on host save, Warning on validation failure) are never suppressed. Only Info background messages are.
Look for [purple] host form submit: screen=... alias='...' is_pattern=... followed by either:
-
[purple] host form submit: external config change, aborting(mtime drift; reopen the form), -
[purple] host form validate failed: <reason>(validation rejected; the toast shows the reason), or - success (no follow-up, the form closes).
A submit that produces no log line means the Enter key never reached the submit handler. Run with PURPLE_LOG=trace and check whether the keystroke dispatched to a picker or input bar.
Look for [purple] Could not determine home directory; MCP audit log disabled. Set --audit-log <PATH> explicitly to enable auditing. This means dirs::home_dir() failed. Set the path explicitly:
purple mcp --audit-log /tmp/mcp-audit.logOr disable auditing entirely with purple mcp --no-audit.
Look for Tool denied. Server started with --read-only. Restart without --read-only to enable state-changing tools. You started the MCP server with --read-only. Stop and restart without the flag if you want write capability. Read-only mode is recommended when exposing purple to autonomous agents; only relax it for trusted clients.
Look for [external] MCP SSH command timed out after <N>s (pid <pid>). The remote command exceeded the per-call timeout. Either the command is genuinely slow or the SSH connection stalled. For long-running commands the MCP server is not the right interface; use purple snippet or a regular interactive session.
Look for [external] MCP ssh_exec failed: alias=<alias> exit=<code>. The remote command failed; the exit code goes back to the client in the tool result. Exit 255 is ssh's own transport failure (auth, network, host key); any other code is the command's own status.
Look for [external] Version check failed: <error>. The GitHub release check failed (offline, proxy, rate limit). The result is cached in last_version_check (1h TTL); delete the file to force a fresh check.
purple update self-updates curl-installed binaries only. When the binary sits in a Homebrew Cellar or in ~/.cargo/bin, it stops and prints the right command instead (brew upgrade erickochen/purple/purple or cargo install purple-ssh). Nix and AUR installs update through their own package manager.
Demo mode is purple running on synthetic data (purple --demo). Connection, sync, signing, file transfer and container actions are disabled and surface yellow Warning toasts instead of executing. The startup banner shows the same fields with providers= listing the synthetic providers.
Demo mode logs [purple] ssh_config.write skipped (demo mode) for every config write that would have happened, so you can verify which paths trigger writes in normal operation.
Paths below show the default layout. With XDG_*_HOME or PURPLE_*_DIR set, each category moves to its own directory as described in Getting Started#where-purple-keeps-its-files; the startup banner (dirs: line) shows where they resolved.
When a category moves, existing files are copied on the first run. A failed copy logs [config] Could not copy <file> to its new directory: <error> and the whole category is retried on the next start. On the very first launch, [config] Failed to create data directory <dir>: <error> or [config] Failed to backup SSH config to <path>: <error> means the config.original snapshot could not be written.
| File | Category | Purpose | Recovery |
|---|---|---|---|
~/.ssh/config |
Source of truth for hosts | Backups at ~/.ssh/config.bak.<timestamp> (5 most recent) |
|
~/.purple/preferences |
config | UI preferences (sort, theme, view, askpass default, ssh_command) | Delete to reset to defaults |
~/.purple/providers |
config | Cloud provider configurations | Backups not kept. Re-add via purple provider add
|
~/.purple/snippets |
config | Saved snippets (INI format) | Recreate via TUI or purple snippet add
|
~/.purple/themes/*.toml |
config | Custom themes | User-managed, not autosaved |
~/.purple/certs/<alias>-cert.pub |
data | Vault SSH signed certificates | Safe to delete. Will re-sign on next connect |
~/.purple/config.original |
data | Your SSH config as it was before the first launch | Never overwritten |
~/.purple/history.tsv |
state | Connection history (frecency) | Safe to delete. Sort order resets |
~/.purple/sync_history.tsv |
state | Per-provider sync result history | Safe to delete. Sync banner timing resets |
~/.purple/recents.json |
state | Jump bar recent picks (cap 50) | Safe to delete. Jump empty-state resets |
~/.purple/key_activity.json |
state | Per-key SSH connection activity log (90-day retention) | Safe to delete. Keys-tab sparkline resets |
~/.purple/snippet_runs.json |
state | Per-snippet run ledger | Safe to delete. Reliability history resets |
~/.purple/.askpass_<alias> |
state | Askpass retry markers (60s lifetime, cleared after a successful connect) | Safe to delete |
~/.purple/mcp-audit.log |
state | MCP server audit log | Tail or rotate as needed |
~/.purple/purple.log |
state | This log file | purple logs --clear |
~/.purple/container_cache.jsonl |
cache | Last-seen container list per host | Safe to delete. Will refetch on next visit |
~/.purple/last_version_check |
cache | Update check result (1h TTL) | Safe to delete |
By design, the log file never contains:
- API tokens, Vault tokens, SSH passwords. The
[external] Password retrieval failedlines never include the retrieved value. - Vault stderr is scrubbed before logging: any line containing "token", "secret", "x-vault-", "cookie" or "authorization" is filtered.
- Clipboard contents. The
Copied <name>toast never includes the data. - Snippet stdout. Only metadata (host, exit code, lines emitted) appears in the log; the actual output is shown in the TUI overlay and discarded when you close it.
Proxy env var names are recorded in the startup banner but values are not (proxy URLs can contain user:password@host credentials).
When in doubt, grep your shared snippet for token=, password=, secret= before sharing.
The startup banner logs the time of session start. If purple takes more than 1-2 seconds to draw the first frame, look at:
- Number of hosts. The banner's
hosts=field shows the parsed count. Configs with 10000+ hosts can stress the parser; check[config] reload_hosts: hosts=N patterns=M display_items=Kto see what was loaded. -
ssh -Vdetection has a 2-second hard timeout. A brokensshbinary causes a one-time 2s delay. - Provider autosync runs in the background and does not block startup. If you suspect autosync slowness, run
purple --verboseand watch for[external] cli sync: starting provider=<name>entries.
Look for the [purple] refresh_batch: lifecycle. Each host's docker ps runs in parallel (up to 4 concurrent SSH calls). Slowness per host is the SSH round trip; reducing it requires faster SSH (e.g. ControlMaster reuse).
If after reading this page the failure mode is still unclear:
- Reproduce with
purple --verbose. - Capture the startup banner and the failure window (50 lines around the WARN/ERROR).
- Scrub host aliases and provider names if they are sensitive.
- Open an issue on github.com/erickochen/purple/issues with the snippet and your platform info from
uname -aandssh -V.
For security disclosures, follow SECURITY.md instead of opening a public issue.
- CLI Reference for the complete command surface
- Cloud Providers for provider-specific setup notes
- Vault SSH Certificates for cert signing details
- Password Management for askpass source configuration
- MCP Server for AI agent integration
- Themes for theme customization
Getting started
Features
- Jump
- Cloud Providers
- File Explorer
- Command Snippets
- Password Management
- Vault SSH Certificates
- Container Management
- SSH Tunnels
- Keys
- Tags and Search
- Host Patterns
- Themes
- MCP Server
- Whats New
Reference