A Claude Code skill for SSH remote operations via WezTerm-SSH (a WezTerm fork).
- One WezTerm-SSH window per Claude Code project, one pane per remote host
- Each pane holds a real PTY — character-level live view that the user can watch
- Commands are injected via
WezTerm-SSH-cli send-textand the output is sliced back to Claude using marker delimiters - Full asciinema recording of every session, with a separate command-level index
- Does not write
~/.ssh/config, does not cache host info — all UI lives inside WezTerm-SSH
Status: Phase 1a (MVP main loop works in temporary-args mode). Phase 1b adds SecureCRT integration. Full roadmap in
docs/Implementation_Plan.md.
WezTerm vs WezTerm-SSH:
wezterm-src/is a fork of WezTerm. After build & deploy it produces three renamed binaries inside an isolated.appbundle, fully namespace-isolated from upstream WezTerm (separate socket / data dir / window class / bundle id), so it can coexist with the official WezTerm:
Component WezTerm-SSH (this fork) Upstream WezTerm macOS bundle ~/Applications/WezTerm-SSH.app/Applications/WezTerm.appBundle id com.wezterm-ssh.guicom.github.wez.weztermGUI binary WezTerm-SSHwezterm-guiCLI subcommand WezTerm-SSH-cliweztermmux server WezTerm-SSH-muxwezterm-mux-serverruntime dir ~/.local/share/WezTerm-SSH/~/.local/share/wezterm/The ssh-ops business CLI (
bin/sshops) does not collide with the GUI bundle — the former is a shell/Rust binary onPATH, the latter lives in~/Applications/WezTerm-SSH.app.
# macOS — note: do NOT install the upstream wezterm cask, this project uses the wezterm-src/ fork
brew install asciinema jq
brew install hudochenkov/sshpass/sshpass # optional, only required if you use --password
brew install pass # optional, password backend
# Rust toolchain (needed to build the wezterm-src fork locally)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Enable "Remote Login" so self-test can ssh localhost
# System Settings → General → Sharing → Remote Login
# Add your public key to ~/.ssh/authorized_keysbash: 3.2 / 4 / 5 all supported. The macOS default
/bin/bash3.2 is fine; brew bash 5.x works too. Empty-array +set -ucorner cases are explicitly guarded. Tested on 3.2.57 and 5.3.9.
git clone https://github.com/bjarne56/claude-ssh ~/Code/ssh-ops
cd ~/Code/ssh-ops
bash install.shinstall.sh is idempotent — running it repeatedly only skips already-deployed pieces. It will:
- Verify system dependencies (asciinema/jq/ssh/cargo/...) and
brew installmissing ones automatically (macOS) - (macOS) Run
wezterm-src/install-local.shto build & deploy the WezTerm-SSH bundle:cargo build --releasefor the three core crates (wezterm/wezterm-gui/wezterm-mux-server, ~2 min on first build)- Deploy to
~/Applications/WezTerm-SSH.app/Contents/MacOS/{WezTerm-SSH, WezTerm-SSH-cli, WezTerm-SSH-mux} - ad-hoc codesign +
lsregister -fto refresh LaunchServices - Install wrapper scripts under
~/.local/bin/{WezTerm-SSH-cli, WezTerm-SSH-mux}(theyexecinto the .app so macOS can correctly associate the icon)
- Deploy the skill to
~/.claude/skills/sshops/(slash command is/sshops)SKILL.mddescription is auto-translated based on system locale (31 languages, seeskill-locales/descriptions.json)- Unknown locales fall back to English
- Business files (
bin/,rust/, ...) are symlinked to the source repo
- Append PATH entries to
~/.zshenv(~/Code/ssh-ops/binand~/.local/bin, both idempotent)
After install:
source ~/.zshenv # apply PATH immediately (or just open a new shell)
sshops setup # interactive wizard to write config.json
open -a WezTerm-SSH # launch the GUI (double-clicking the .app does the same)Invoke from Claude Code:
/sshops <your request> # e.g. /sshops connect to 10.0.0.5 and run uname
The slash command name sshops matches the skill directory name and the name field in SKILL.md frontmatter. The description shown in the skill list is picked automatically from your system locale (macOS uses defaults read -g AppleLocale, Linux uses $LANG).
Install options:
bash install.sh --no-build-wezterm # skip wezterm build (already deployed, or running on Linux)
bash install.sh --link-only # only relink the skill (skip dep check + wezterm build)
bash install.sh --locale en # force English description (default: system locale)
bash install.sh --link-only --locale ja # switch description to Japanese, no rebuild
bash install.sh --status # show current deployment state
bash install.sh --uninstall # remove all installed parts (asks for confirmation)Multi-language description maintenance:
All 31 description translations live in a single file: skill-locales/descriptions.json (English + Simplified/Traditional Chinese + Japanese, Korean, French, German, Spanish, Italian, Portuguese, Russian, Ukrainian, etc., 32 KV entries total). Adding a new language requires only one new line in the JSON. The body of SKILL.md is the English master — to update wording, edit it in one place.
bash tests/self-test.shPassing means marker slicing + recording + state writes are all working end-to-end.
Describe the task in your Claude Code chat. Based on SKILL.md, Claude decides when to invoke this skill. For example:
Connect to 10.1.2.3, run uptime to check the load, and record it.
Claude will run:
sshops run --host 10.1.2.3 --user ec2-user --key ~/.ssh/aws.pem "uptime"# Run a short command
sshops run --host 10.1.2.3 --user root --key ~/.ssh/k.pem "uptime"
# Just spawn a pane, no command (you'll type interactively)
sshops open --host 10.1.2.3 --user root --key ~/.ssh/k.pem
# Close the pane
sshops close --host 10.1.2.3 --user root --port 22
# List all panes for the current project
sshops list-panesJSON output:
{
"exit": 0,
"duration_ms": 340,
"cast_offset": 12.4,
"session_id": "10.1.2.3-20260502-142301-a3f4b1",
"dangerous": false,
"blocked": false,
"output": "14:23:01 up 47 days, load 0.5"
}When blocked:
{
"exit": -1,
"blocked": true,
"dangerous": true,
"reason": "...(pattern: rm\\s+-rf\\s+/)",
"session_id": "...",
"output": "(not executed)"
}Built-in dangerous-command interceptor (rm -rf /, reboot, mkfs, dd of=/dev/, shutdown, :(){, chmod -R 777 /, etc., customizable in config.json).
| Scenario | Behavior |
|---|---|
Dangerous + --prod flag + no --i-mean-it |
Refused, exit 5 |
Dangerous + --prod + --i-mean-it |
Warns but allows |
| Dangerous + non-prod | Warns but allows |
--i-mean-it is never added by Claude on its own — the user must explicitly confirm in chat.
claude-ssh/
├── SKILL.md Claude decision manual (English master)
├── README.md this file
├── LICENSE MIT
├── bin/
│ ├── sshops business CLI dispatcher (bash, forwards subcommands to Rust)
│ └── sshops-setup interactive setup wizard
├── rust/ business implementation (Rust + daemon, primary path)
│ ├── core/ shared logic (config / state / pane / wezterm_mux / safety / recorder ...)
│ ├── bin/ sshops-rs binary (short-lived CLI)
│ └── daemon/ sshops-daemon (persistent IPC)
├── wezterm-src/ WezTerm fork → WezTerm-SSH (subdirectory, merged into this repo)
│ └── install-local.sh local build + deploy ~/Applications/WezTerm-SSH.app
├── cast-player/ Tauri-based recording replay GUI (32-locale i18n)
├── lua/ keyword-highlight rules for WezTerm-SSH
├── skill-locales/
│ └── descriptions.json 31-language SKILL frontmatter descriptions
├── tools/ SecureCRT .ini → wezterm rules converter
├── config.example.json config template
├── install.sh dependency check + wezterm-src build + skill deploy (idempotent)
├── tests/self-test.sh localhost echo smoke test
└── docs/
├── PROJECT_OVERVIEW.md architecture overview
└── Implementation_Plan.md task-level status
Recording data (produced at runtime, not committed) defaults to <project>/.ssh-ops/recordings/<session_id>/ in the current project root. The skill auto-appends .ssh-ops/ to the project's .gitignore on first write.
If you prefer centralized storage (all projects record to one location, useful for unified auditing), set "log_dir": "~/.ssh-recordings" in config.json. The directory structure becomes <log_dir>/<project_slug>/<session_id>/.
The skill does not require creating new accounts on the target host. The default scheme uses your existing SecureCRT config + PS1 string audit + asciinema recording:
| Signal | Value |
|---|---|
| ssh login user | from SecureCRT .ini (e.g. roy) |
| auto_sudo to root | enabled by default; the target host needs NOPASSWD configured, or you type the sudo password manually in the pane |
| Remote PS1 prompt | [root(roy:claude)@host ~]# or [roy(roy:claude)@host ~]$ |
| Recording | <project>/.ssh-ops/recordings/<session-id>/ (stream.cast + commands.jsonl + meta.json) |
The (roy:claude) prompt segment means "original ssh login is roy, current operator is claude (AI)" — anyone watching the asciinema replay or sitting next to the pane can immediately tell AI from human. In production environments where you usually can't create new accounts on target hosts, this scheme (existing account + PS1 string audit + full recording) is the standard approach.
- Phase 1a ✅ Temporary args + marker slicing + recording + safety guards
- Phase 1b SecureCRT integration (
@path/ keyword / jumphost / SSH2.ini global fallback) - Phase 2
bgfanhealthsyncgridpush/pullforward - Phase 3 Replay / search / annotate / retention gc
- Phase 4 WezTerm Lua visuals (AI/HUMAN/REPLAY border colors + replay key bindings)
Q: Claude calls sshops and gets "WezTerm-SSH-cli not reachable".
A: Is the WezTerm-SSH GUI running? The skill auto-runs open -a WezTerm-SSH and retries for 5 seconds; if that fails, the fork isn't deployed — run bash wezterm-src/install-local.sh to redeploy (idempotent).
Q: Command-injection timeout (exit 4).
A: The command is a TUI (vim, top) or a long-running task. Phase 2 will support sshops bg; for now, sshops close and run it manually in your own terminal.
Q: exit 3 shell not supported.
A: The login shell on the target host must be bash or zsh. fish / tcsh / network-device CLIs are not supported.
Q: exit 5 blocked.
A: A dangerous command was blocked on a production host. To run it anyway, explicitly tell Claude in chat "I confirm running X on prod" — Claude will then add --i-mean-it.
Q: bash version requirements?
A: 3.2 / 4 / 5 all supported. macOS default /bin/bash 3.2 works; brew bash 5.x also works. The code uses [[ ${#arr[@]} -gt 0 ]] to explicitly guard the empty-array + set -u corner case (a known bug before bash 4.4). Tested on 3.2.57 (macOS Sonoma default) and 5.3.9 (Homebrew).
Q: Build failure: library 'git2' not found.
A: Fixed in v1.0.1 — the git2 crate now uses vendored-libgit2, so wezterm-src no longer depends on the system Homebrew libgit2. If you're on v1.0.0 and hit this after brew upgrade libgit2, run:
cd wezterm-src
rm -rf target/release/build/libgit2-sys-* target/release/build/git2-*
git pull && bash install-local.shSKILL.md— Decision manual for Claudedocs/PROJECT_OVERVIEW.md— Architecture overviewdocs/Implementation_Plan.md— Task-level statusCLAUDE.md— Project coding constraints
MIT. The bundled WezTerm fork (wezterm-src/) inherits its own MIT license — see wezterm-src/LICENSE.md.