mh is a modern Linux command history manager written in Rust. It records shell commands into a local SQLite database, then makes that history searchable, filterable, taggable, auditable, exportable, and usable from an interactive terminal interface. Enterprise features include a unified policy engine, tamper-evident audit logs, session forensics, legal holds, runbooks, environment classification, SIEM streaming, and break-glass mode.
License: mh is distributed under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). You may use, modify, and redistribute it under the terms of that license. If you run a modified version as a network service, you must offer corresponding source to users interacting with it over the network.
The command name is intentionally short:
mh- Author: Cuma Kurt cumakurt@gmail.com
- GitHub: https://github.com/cumakurt/mh
- LinkedIn: https://www.linkedin.com/in/cuma-kurt-34414917/
- License: AGPL-3.0-or-later — see the License section below
Traditional shell history is plain text and usually answers only one question: "What did I type before?" mh stores structured command records so you can answer better questions:
- Which command failed in this project yesterday?
- Which Docker commands did I run last week?
- What did I run in this SSH session?
- Which command contained a token and was masked or skipped?
- Which commands should be pinned before cleanup?
- Which snippets do I reuse often?
- Which commands are worth moving into an encrypted vault?
- Which critical commands were blocked by policy in production?
- Can the audit log prove it was not tampered with?
- What exactly ran in this session during an incident?
The database is local-first. By default, regular users and root have separate data paths because they have different home directories.
- Shell hooks for Bash, Zsh, Fish, and Nushell.
- Up arrow binding that opens an interactive command history picker.
- SQLite storage with FTS5 full-text search.
- Command metadata: command text, working directory, shell, user, host, exit code, duration, session ID, TTY, SSH/root flags, Git repository, Git branch, Git commit, category, tags, environment context, and command hash.
- Secret detection, masking, ignore rules, private mode, audit logs, and oversized-command rejection (256 KiB max).
mh doctorhealth checks with human-readable output or--jsonfor automation/CI.- Safe file writes for config, exports, completions, and man pages (atomic temp+rename, mode
0600, symlink rejection). - Unified policy engine with allow, warn, deny, require-approval actions, and shared-key signed policy packs.
- Tamper-evident audit log with SHA-256 hash chain verification.
- Session timeline forensics, incident bundle export, legal holds, retention purge, and runbooks.
- Environment classification (production, staging, development) on every recorded command.
- SIEM-friendly audit streaming (syslog, webhook, CEF/JSON) via
mh watch. - Break-glass mode for emergency recording override with mandatory reason and TTL.
- Search modes: substring, regex, fuzzy, FTS, and local natural-language ranking.
- Filters for CWD, user, shell, date range, success/failure, tag, category, pinned state, and duration.
last,stats,delete,clear,export, andimportworkflows.- Tags, pins, reusable snippets, replay, diff, audit, policy, incident, timeline, hold, runbook, watch, and break-glass commands.
- Risk-aware replay guidance with optional preview commands for destructive operations.
- Ratatui terminal UI with live fuzzy filtering, dashboard mode, detail panel, clipboard copy, pin/unpin, tagging, and delete confirmation.
- Encrypted command vault using AES-256-GCM with passphrase input.
- Optional encrypted remote sync with
syncfeature flag (--features sync). - Installer that detects Linux distribution, package manager, and shell.
- Portable shell hooks (GNU/BusyBox
date, Bash 5+EPOCHREALTIME,python3, orperlfor duration timing). - Shell completions and man page generation.
- Debian package build script.
mh targets Linux on x86_64 and aarch64 with a local SQLite database and Unix-domain record daemon. The bundled SQLite build avoids distro-specific library mismatches.
The installer (install.sh) detects the OS via /etc/os-release and installs build dependencies for:
| Family | Package managers | Examples |
|---|---|---|
| Debian | apt |
Debian, Ubuntu, Linux Mint, Kali Linux, Pop!_OS |
| RHEL | dnf, yum |
Fedora, RHEL, CentOS Stream, Rocky, Alma |
| Arch | pacman |
Arch Linux, Manjaro, EndeavourOS |
| SUSE | zypper |
openSUSE, SLE |
| Alpine | apk |
Alpine Linux, postmarketOS |
Other FHS-compliant distributions usually work when Rust, a C toolchain, and pkg-config are available.
| Shell | Binary paths (examples) | Integration | Config file |
|---|---|---|---|
| Bash | /bin/bash, /usr/bin/bash, rbash (restricted bash) |
DEBUG trap + PROMPT_COMMAND |
~/.bashrc, ~/.bash_profile, ~/.profile |
| Zsh | /bin/zsh, /usr/bin/zsh |
preexec / precmd hooks |
~/.zshrc, ~/.zshenv |
| POSIX sh / dash | /bin/sh, /usr/bin/sh, /usr/bin/dash |
fc history + prompt hook (mh init sh) |
~/.profile |
| Fish | /usr/bin/fish |
fish_preexec / fish_postexec |
$XDG_CONFIG_HOME/fish/config.fish |
| Nushell | /usr/bin/nu |
pre_execution / pre_prompt |
$XDG_CONFIG_HOME/nushell/config.nu |
| PowerShell | /usr/bin/pwsh, /opt/microsoft/powershell/7/pwsh |
PSReadLine AddToHistoryHandler |
$XDG_CONFIG_HOME/powershell/Microsoft.PowerShell_profile.ps1 |
Not shells (no hook on the multiplexer itself): screen, tmux — run mh init <your-login-shell> inside the pane; $SHELL may point at tmux/screen but hooks belong in bash/zsh/etc.
When /bin/sh is a symlink to bash, mh init sh tells you to use mh init bash for full DEBUG trap support.
Install integration:
mh init # detects $SHELL, installs the managed block, prints the source command
mh init --install # same behavior, explicit
mh init bash --install # force a shell
mh init zsh --install
mh init sh --install # dash / posix sh
mh init pwsh --install # PowerShell 7+
mh init fish --install
mh init nushell --installOr use ./install.sh --shell <shell> which picks the first existing config file from the list above.
Hook scripts never rely on GNU-only date +%s%3N alone. Millisecond duration uses, in order:
- Bash 5+ / Zsh
EPOCHREALTIME(Zsh loadszmodload zsh/datetimewhen available) python3perlwithTime::HiRes- GNU/BusyBox
date +%s%3N - Second-precision
date +%s(×1000) as a last resort
Install python3 on minimal images (Alpine, slim containers) for best duration accuracy.
- Regular user vs root: separate home directories → separate
~/.local/share/mh/ config paths by default. - SSH sessions:
SSH_CONNECTION/SSH_CLIENTsetis_sshon records; useMH_SESSION_ID(set by hooks) withmh last --session. sudo/su: switching users switches the active home and database; hooks in the target user shell record to that user DB.- Record daemon: Unix socket under
$XDG_RUNTIME_DIR/mh/record.sockwhen set (typical on systemd/logind desktops), otherwise~/.local/share/mh/record.sock. Only the same UID may write (peer credential check). No fallback to world-writable/tmp.
Shell hooks record the command line as the shell presents it to preexec / DEBUG / fish_preexec. Commands built from HEREDOCs, continued lines, or editor buffers may appear as a single logical line or only capture the first line depending on the shell. Prefer mh record manually or snippets/runbooks for multi-step flows you need preserved verbatim.
- macOS / BSD (hooks are Linux-oriented; database code is portable but not CI-tested outside Linux)
- Windows / WSL is best-effort only
- Fleet control plane (
mh-server) — remote sync uses optionalmh syncwith--features syncand a compatible HTTP API; a dedicated multi-tenant server is not part of this repository
Every command below was verified with scripts/verify-all-commands.sh in an isolated demo environment.
./scripts/verify-all-commands.shThe SVG screenshots are generated from docs/examples/*.txt:
./scripts/render-example-screenshots.pymh doctor
mh doctor --strict # exit non-zero when warnings are reported
mh doctor --json # machine-readable report for scripts and CIJSON output includes status, warning_count, checks[], and summary (config/database paths, schema version, command count, daemon/private-mode state, mh version). Use with jq in pipelines:
mh doctor --json | jq -e '.status == "ok" and .warning_count == 0'mh init
mh init bash
mh init zsh
mh init fish
mh init nushellmh config show
mh config path
mh config validate
mh config set history.max_entries 200000
mh config resetShell hooks call this automatically. Manual example:
mh record --command "git status" --cwd "$PWD" --shell zsh --exit-code 0 --duration-ms 42When the record daemon is running, mh record sends events over a Unix socket instead of opening SQLite on every hook. Use --no-daemon or MH_NO_DAEMON=1 to force the direct database path.
Keeps one long-lived database connection for high-frequency shell hooks:
mh daemon start # background
mh daemon status
mh daemon stop
mh daemon run # foreground
mh daemon install # write ~/.config/systemd/user/mh-record-daemon.serviceDefault socket: $XDG_RUNTIME_DIR/mh/record.sock, or ~/.local/share/mh/record.sock when XDG_RUNTIME_DIR is unset (override with MH_DAEMON_SOCKET).
systemd user service (starts at login, survives until logout):
mh daemon install
systemctl --user daemon-reload
systemctl --user enable --now mh-record-daemon.service
systemctl --user status mh-record-daemon.serviceA reference unit file is also in contrib/systemd/user/mh-record-daemon.service. mh doctor reports whether the daemon is running or a stale socket is present.
mh last
mh last 20
mh last 1 --offset 3 --plain
mh last --failed
mh last --json
mh last --plainmh search docker --limit 10
mh search --fts git
mh search --fuzzy dps
mh search --regex '^git '
mh search docker --jsonmh pick
mh pick --query docker --limit 100
mh pick --fuzzy --query dpsmh pick ranks results by current directory, successful exit codes, and recency when display.context_ranking is true (default). Up-arrow in integrated shells opens the picker in recent-history mode: it lists only command text for the latest commands in entry order while still allowing in-picker filtering.
mh tui
mh tui --query docker --limit 500mh tag 152 deploy prod
mh tag --last 5 investigation
mh untag 152 prod
mh tags list
mh pin 152
mh unpin 152
mh pinnedmh snippet save docker-clean "docker system prune -af"
mh snippet list
mh snippet run docker-clean --dry-run
mh snippet export snippets.jsonmh stats
mh stats --today --top 20
mh stats --week --heatmapmh diff --session session-a --session session-b
mh diff --host laptop --host servermh risk list
mh risk check "rm -rf /"
mh risk scan --critical --todaymh context
mh context repos
mh context branches --repo /path/to/repo
mh context history --branch mainmh audit
mh audit --today --format json
mh audit --verify-chainmh policy list
mh policy check "rm -rf /"
mh policy check "rm -rf /" --hostname prod-web-01 --env production --json
# Shell hooks use --quiet (exit 2 = deny, 3 = require_approval):
mh policy check "rm -rf /" --cwd "$PWD" --quietWith policy.enforce_in_shell = true (default), bash and zsh block denied commands at Enter; fish cancels the line in preexec. One-off approval: MH_POLICY_APPROVE=1 your-command. Disable enforcement: mh config set policy.enforce_in_shell false.
Session forensics: list commands in chronological order for a session ID.
mh timeline --session demo-session-001
mh timeline --session demo-session-001 --plain
mh timeline --session demo-session-001 --jsonLegal hold and retention management.
mh hold add incident-1 --session demo-session-001 --reason "investigation"
mh hold add prod-freeze --git-repo /srv/app --reason "audit window"
mh hold list
mh hold remove 1
mh hold purge --dry-run
mh hold purgemh hold purge requires retention.enabled = true in config. Legal-hold records are never deleted by retention or mh clear.
Create reusable multi-step command sequences from a session timeline.
mh runbook create deploy --session demo-session-001 --desc "Deploy flow"
mh runbook list
mh runbook show deploy
mh runbook run deploy --dry-run
mh runbook run deployEmergency override when private mode would block recording. Requires a reason and expires automatically.
mh break-glass on --reason "prod incident response" --ttl-hours 4
mh break-glass status
mh break-glass offStream audit events for SIEM integration. Enable [siem] in config for live syslog/webhook delivery on new audit entries.
mh watch --limit 20 --format jsonmh private on
mh private off
mh private statusMH_VAULT_PASSPHRASE=secret mh vault add "kubectl get pods" --label prod
mh vault list
mh vault run 1 --dry-run
mh vault delete 1mh export --json history.json
mh export --csv history.csv
mh export --markdown history.md
mh export --sqlite backup.db
mh export --json full.json --include-secrets # opt out of redaction
mh import history.json --dry-run
mh import history.json --mergemh delete 152 --yes
mh delete --older-than 90d --yes
mh clear
mh clear --keep-pinnedmh clear asks for confirmation. Use mh delete --yes for scripted cleanup.
mh replay 152 --dry-run
mh replay 152
mh replay 152 --yes
mh replay 152 --reason "approved by on-call" --yes
mh replay 152 --risk-previewPolicy rules with require_approval need --reason or --yes before execution.
mh sync status
mh sync init --server https://mh.example.test # generates E2E token + device id
mh sync setup https://mh.example.test token-value # other machines
mh sync enable
mh sync push # or pull
mh sync disableRemote push/pull requires building with --features sync. Payloads are AES-256-GCM encrypted by default (sync.encrypt_payload, on by default).
mh completions bash
mh completions zsh --output _mh
mh completions fishmh man
mh man --output mh.1The recommended installation path is the included installer:
./install.shThe installer performs these steps:
- Confirms the system is Linux.
- Detects
/etc/os-release. - Detects the package manager:
apt,dnf,yum,pacman,zypper, orapk. - Installs build dependencies for the detected distribution.
- Installs Rust with
rustupifcargoandrustcare not already available. - Builds the release binary with
cargo build --release. - Installs
mhinto/usr/local/binor~/.local/bin. - Installs Bash, Zsh, and Fish completions.
- Installs the
mhman page. - Detects the current shell and enables shell integration via
mh init. - Runs
mh doctorat the end.
Shell hooks are installed by delegating to mh init (same code path as manual setup). If integration fails, run mh init --repair to repair the detected shell, or mh init <shell> --repair to force a shell.
Common installer options:
./install.sh --user
./install.sh --system
./install.sh --shell zsh
./install.sh --shell bash
./install.sh --shell fish
./install.sh --shell nushell
./install.sh --install-dir "$HOME/.local/bin"
./install.sh --no-deps
./install.sh --no-build
./install.sh --no-enable
./install.sh --no-completions
./install.sh --no-manUseful environment variables for the installer:
INSTALL_DIR="$HOME/.local/bin" ./install.sh
MH_SHELL=zsh ./install.shAfter installation, open a new shell session or reload your shell config. mh init prints the exact command for your detected shell:
source ~/.zshrc
source ~/.bashrcFor Fish:
source ~/.config/fish/config.fishFor Nushell:
source ~/.config/nushell/config.nuRequirements:
- Rust toolchain
- C compiler and build tools
pkg-configgitcurlca-certificates
Build and run:
cargo build
cargo run -- --helpBuild an optimized binary:
cargo build --release
./target/release/mh --helpRun tests:
cargo test
cargo clippy --all-targets -- -D warningsUse a temporary database while testing:
export MH_CONFIG=/tmp/mh-config.toml
export MH_DB=/tmp/mh-history.db
cargo run -- record --command "docker ps" --exit-code 0
cargo run -- lastmh init detects $SHELL, appends the managed integration block to the resolved shell config file, and prints the command needed to activate it in the current terminal. New shell sessions load the integration automatically.
Because mh runs as a child process, it cannot directly source the parent shell process for you. Run the printed source ... command once in the current terminal, or start a new shell.
mh init <shell> still prints shell integration code for manual setups and tests. Add --install when you want to force installation for a specific shell.
Supported shells:
mh init
mh init bash
mh init zsh
mh init fish
mh init nushellManual Zsh setup:
eval "$(mh init zsh)"Manual Bash setup:
eval "$(mh init bash)"Manual Fish setup:
mh init fish | sourceManual Nushell setup:
mh init nushell | save -f ~/.config/nushell/mh.nu
source ~/.config/nushell/mh.numh init and mh init <shell> --install append a managed block to the resolved config file (see Platform and Shell Compatibility). The installer uses the same path resolution rules.
Repair duplicate or broken integration:
mh init --repair
mh init zsh --repair
mh init bash --repair--repair removes all managed mh blocks from the config file, then reinstalls a single clean block. Re-run it if hooks were appended more than once.
On login shells that only source ~/.bash_profile (common on macOS-style layouts and some RHEL defaults), mh init and mh init bash --install target that file when .bashrc is missing.
The shell integration records commands by calling mh record after a command finishes.
Zsh uses:
zmodload zsh/datetimewhen available for sub-millisecond timing.preexecto capture the command text and start time.precmdto capture the exit code and duration.
Bash uses:
DEBUGtrap to capture the command before execution.PROMPT_COMMANDto record the command after execution.
Fish uses:
fish_preexecfish_postexec
Nushell uses:
pre_executionpre_prompt
Each shell integration sets MH_SESSION_ID if it is not already set. That lets you filter the current shell session:
mh last --sessionFor Bash, Zsh, Fish, and Nushell, the integration binds the up arrow key to the mh picker. Pressing up opens a selectable command-only recent list in entry order instead of only stepping through raw shell history. You can type in the picker to filter that recent list.
For Bash, Zsh, and Fish, you can optionally bind the left and right arrow keys to step through recent mh commands on the prompt (without opening the picker). Set MH_HISTORY_ARROWS=1 before loading shell integration (or add it to your shell rc file). With it enabled, press right repeatedly to move to older commands and left to move back toward the original prompt line.
Inside the picker:
- Type to fuzzy-filter commands.
- Use
UpandDownto move. - Use
PageUpandPageDownfor larger jumps. - Use
HomeandEndin the picker. - Press
Enterto select a command. - Press
EscorCtrl-Cto cancel.
Limit picker results:
MH_PICK_LIMIT=250Open the picker manually:
mh pick
mh pick --limit 200
mh pick --query docker
mh pick --cwd /srv/app
mh pick --failed
mh pick --tag deploy
mh pick --category git
mh pick --pinned
mh pick --fuzzy --query "dps"In non-interactive mode, mh pick prints the first matching command.
Default config path:
~/.config/mh/config.tomlDefault database path:
~/.local/share/mh/history.dbOverride paths:
export MH_CONFIG=/path/to/config.toml
export MH_DB=/path/to/history.dbMH_DB must point to a regular file, not a directory. Config and database files are created with restrictive permissions where the application controls the write path.
Root uses root's own home directory, so root history is separate by default:
sudo mh doctorShow the active config:
mh config showPrint the active config path:
mh config pathOpen the config in $EDITOR:
EDITOR=nano mh config editSet individual values:
mh config set history.max_entries 200000
mh config set history.ignore_duplicates true
mh config set history.dedupe_window_seconds 10
mh config set security.mask_secrets true
mh config set security.skip_secret_commands false
mh config set display.default_limit 100
mh config set database.max_size_mb 1024
mh config set sync.auto_sync_interval_minutes 30Validate the config:
mh config validateReset to defaults:
mh config resetDefault config shape:
[history]
max_entries = 100000
ignore_duplicates = true
ignore_space_prefix = true
save_failed_commands = true
save_successful_commands = true
auto_categorize = true
dedupe_window_seconds = 5
[security]
mask_secrets = true
skip_secret_commands = false
private_mode_env = "MH_PRIVATE"
audit_log = true
[database]
path = "/home/user/.local/share/mh/history.db"
auto_vacuum = true
max_size_mb = 512
[display]
default_limit = 50
color = true
date_format = "%Y-%m-%d %H:%M:%S"
show_duration = true
show_exit_code = true
[ignore]
commands = ["history", "clear", "exit", "logout", "mh record"]
patterns = [".*password.*", ".*token.*", ".*secret.*", ".*api[_-]?key.*", ".*bearer.*"]
[categories]
git = ["git ", "gh "]
docker = ["docker ", "docker-compose ", "podman "]
network = ["curl ", "wget ", "ssh ", "nc ", "nmap ", "ping "]
system = ["systemctl ", "journalctl ", "top ", "htop "]
package = ["apt ", "apt-get ", "dpkg ", "snap ", "cargo ", "pip "]
[sync]
enabled = false
server_url = ""
token = ""
auto_sync_interval_minutes = 60
[vault]
enabled = false
use_keyring = true
[policy]
default_action = "allow"
[[policy.rules]]
id = "deny-critical-prod"
action = "deny"
risk_level = "critical"
environment = "production"
message = "Critical commands are blocked in production"
[[policy.rules]]
id = "approval-critical"
action = "require_approval"
risk_level = "critical"
message = "Critical commands require explicit approval"
[[policy.rules]]
id = "warn-high"
action = "warn"
risk_level = "high"
message = "High risk command detected"
[retention]
enabled = false
retention_days = 365
respect_legal_hold = true
[environment]
[[environment.rules]]
tier = "production"
hostname_contains = "prod"
[[environment.rules]]
tier = "staging"
hostname_contains = "stage"
[[environment.rules]]
tier = "development"
hostname_contains = "dev"
[siem]
enabled = false
format = "syslog" # syslog | json | cef
syslog_url = "127.0.0.1:5140"
webhook_url = ""
[break_glass]
default_ttl_hours = 4Older config files that do not contain newer sections are filled with defaults at load time.
Normally you do not call mh record yourself. Shell hooks call it after each command.
For interactive shells that record very frequently, start mh daemon start once per login session. Hooks keep calling mh record; the CLI forwards to the daemon when the socket is available and falls back to SQLite otherwise.
Manual examples are useful for testing:
mh record --command "docker ps" --exit-code 0
mh record --command "curl https://example.test" --cwd /tmp --shell zsh --exit-code 0 --duration-ms 84
mh record --command "git status" --tags "work,git"
mh record --command "pytest tests" --exit-code 1 --duration-ms 2400 --env-context virtualenvFields captured by mh record include:
- Command text
- SHA-256 command hash
- Working directory
- Shell
- Username
- Hostname
- Exit code
- Duration in milliseconds
- Start and finish timestamps
- Session ID
- TTY
- SSH session flag
- Root user flag
- Git repository path
- Git branch
- Git commit
- Auto category
- Environment context
- Environment tier (
production,staging,development, orunknown) - User tags
- Pinned flag
- Masked flag
- Legal hold flag (set by
mh hold)
Environment context is detected for common cases:
dockervirtualenvnix-shell
Git context is detected when the command is recorded inside a Git work tree.
Environment tier is classified from hostname, working directory, and Git repository path using [environment] rules in config. Production hosts matching prod in the hostname are tagged automatically.
Policy evaluation runs on every recorded command. Critical commands can be denied in production, warned in other environments, or require approval on replay.
Show recent commands:
mh last
mh last 20Show only failed commands:
mh last --failedShow commands from a working directory:
mh last --cwd /srv/appShow commands from the current shell session:
mh last --sessionShow by tag, category, or pinned state:
mh last --tag deploy
mh last --category docker
mh last --pinnedMachine-readable output:
mh last --json
mh last --plainPlain output prints only command text. It is useful for pipes:
mh last --plain | headBasic substring search:
mh search docker
mh search "ssh root"
mh search "kubectl get pods"Limit results:
mh search docker --limit 10
mh search docker -n 10Search by working directory:
mh search --cwd /srv/app
mh search docker --cwd /srv/appSearch by command outcome:
mh search --failed
mh search --successSearch by user or shell:
mh search --user root
mh search --shell zshSearch by time range:
mh search --after 2026-05-01
mh search --before 2026-05-31
mh search docker --after 2026-05-01 --before 2026-05-31Regex search:
mh search --regex '^git (status|log)'
mh search --regex 'docker .*--format'Fuzzy search:
mh search --fuzzy "dps"
mh search --fuzzy "gco main"FTS5 full-text search:
mh search --fts "docker NEAR ps"
mh search --fts "git"Local natural-language search:
mh search --semantic "today failed deploy commands in prod"
mh search --nl "root ssh commands from last week"--semantic uses local parsing and ranking only. It derives likely filters such as failure/success, date range, environment, SSH/root state, category, and risk terms from the query. Explicit flags still win when you pass them.
Tag and category filters:
mh search --tag deploy
mh search --category git
mh search docker --tag prod --category dockerPinned commands:
mh search --pinnedDuration filters:
mh search --duration-gt 1000
mh search --duration-lt 50
mh search pytest --duration-gt 5000Git and environment filters:
mh search --git-repo /srv/app --git-branch main
mh search --env production
mh last --env stagingOutput modes:
mh search docker --json
mh search docker --plainSearch mode rule: --regex, --fuzzy, --fts, and --semantic are mutually exclusive.
Launch the TUI:
mh tui
mh tui --dashboardStart with a filter:
mh tui --query docker
mh tui --failed
mh tui --tag deploy
mh tui --category git
mh tui --pinned
mh tui --limit 1000Keyboard actions:
Up/Down: move selectionPageUp/PageDown: jump by 10 rows- Type text: fuzzy-filter visible rows
Backspace: remove filter characterEnter: print the selected command after exitingCtrl-C: copy the selected command to clipboardp: pin or unpin the selected commandt: open tag input moded: ask for delete confirmationq/Esc: exit
The TUI shows a list on the left and command details on the right. Details include ID, pinned state, masked state, timestamp, exit code, duration, shell, CWD, category, tags, and full command text.
Dashboard mode shows high-level command volume, success/failure split, top commands, risky recent commands, and active environment tiers in one terminal screen.
When stdin or stderr is not a terminal, mh tui falls back to normal table output.
Add tags to one command:
mh tag 152 deploy prodAdd tags to the last N commands:
mh tag --last 5 investigation
mh tag --last 3 docker cleanupRemove tags:
mh untag 152 prod
mh untag 152 deploy prodList known tags with counts:
mh tags listSearch or list by tag:
mh search --tag investigation
mh last --tag dockerPin commands that you want to preserve or find quickly:
mh pin 152
mh pin 152 153 154Unpin commands:
mh unpin 152Show pinned commands:
mh pinned
mh pinned 20
mh pinned --json
mh pinned --plainPinned commands can be preserved during clear operations:
mh clear --keep-pinnedShow all-time statistics:
mh statsTime periods:
mh stats --today
mh stats --week
mh stats --monthShow more top entries:
mh stats --top 20Include category breakdown:
mh stats --categoryShow hourly activity:
mh stats --heatmap
mh stats --week --heatmapStatistics include:
- Total command count
- Successful command count
- Failed command count
- Average duration
- Longest duration
- Top commands
- Top directories
- Shell usage
- Category counts
- Optional hourly heatmap
Only one of --today, --week, or --month can be used at a time.
Delete one command by ID:
mh delete 152Delete without interactive confirmation:
mh delete 152 --yesDelete by filters:
mh delete --older-than 90d
mh delete --older-than 12h
mh delete --older-than 30m
mh delete --contains "temporary command"
mh delete --failed
mh delete --tag scratchmh delete requires either an ID or at least one filter. Without --yes, it asks for confirmation on a terminal.
Clear matching history:
mh clear
mh clear --user root
mh clear --before 2026-01-01
mh clear --keep-pinnedmh clear always asks for confirmation. In non-interactive input, confirmation cannot be provided, so the clear is cancelled.
By default, text exports redact secrets (passwords, tokens, bearer headers). Use --include-secrets only for encrypted offline backups you control. SQLite exports support --sanitize, --sanitize-audit, and --without-audit.
Export files are written atomically with mode 0600. Export refuses to overwrite symlink targets.
Large exports (>100,000 rows) load the full result set into memory; narrow with --after, --before, or --tag when possible.
Export JSON:
mh export --json history.jsonExport CSV:
mh export --csv history.csvExport Markdown:
mh export --markdown history.mdExport compressed JSON with Zstandard:
mh export --compressed history.json.zstFilter exports:
mh export --after 2026-05-01 --json may-history.json
mh export --before 2026-01-01 --compressed old-history.json.zst
mh export --tag deploy --json deploy-history.json
mh export --category docker --csv docker-history.csvImport JSON:
mh import history.jsonImport CSV:
mh import history.csvImport compressed JSON:
mh import history.json.zstDry-run import:
mh import history.json --dry-runImport applies the same secret detection and masking rules as live recording. Compressed imports are bounded (64 MB compressed / 256 MB decompressed / 1,000,000 rows) to prevent decompression bombs.
Merge import and skip commands whose command hash already exists:
mh import history.json --mergeExport requires exactly one target format. Import supports JSON, CSV, and .zst compressed JSON.
Snippets are named reusable commands stored in the SQLite database.
Save a simple snippet:
mh snippet save docker-clean "docker system prune -af"Save with description and tags:
mh snippet save git-undo "git reset --soft HEAD~1" --desc "Undo the last commit softly" --tags "git,undo"List snippets:
mh snippet listRun a snippet:
mh snippet run docker-cleanPrint a snippet without executing:
mh snippet run docker-clean --dry-runUse placeholders:
mh snippet save ssh-host "ssh {{user}}@{{host}}" --desc "SSH to a host"
mh snippet run ssh-host --var user=admin --var host=192.168.1.10 --dry-run
mh snippet run ssh-host --var user=admin --var host=192.168.1.10Delete a snippet:
mh snippet delete docker-cleanExport snippets:
mh snippet export snippets.jsonPlaceholder variables must use KEY=VALUE syntax through repeated --var options.
Replay runs a command from history by ID.
Print the command without running it:
mh replay 152 --dry-runRun it immediately:
mh replay 152Ask before running:
mh replay 152 --confirmRun a safer preview first when mh can derive one:
mh replay 152 --risk-preview
mh replay 152 --no-risk-guidanceReplay with policy approval reason:
mh replay 152 --reason "change ticket INC-1234" --yesReplay uses $SHELL -c <command> (non-login). If $SHELL is not set, it falls back to /bin/sh.
Before execution, mh replay prints warnings when the stored command was masked, still matches secret heuristics, or will run with your current privileges. For risky commands, it also prints safer alternatives, preview commands, and a short review checklist when known. Use --dry-run to print the redacted command without executing. mh runbook run applies the same warnings per step.
When a matching policy rule has action deny, replay is blocked and an audit entry is written. When action is require_approval, pass --reason or --yes.
Compare unique command text between two sessions:
mh diff --session session-a --session session-bCompare two hosts:
mh diff --host laptop --host serverCompare today and yesterday:
mh diff --today --yesterdaymh diff prints counts for each side, then commands only present on the left and commands only present on the right.
mh processes every command before storing it.
Default skip rules:
- Empty commands are skipped.
- Commands that start with a space are skipped when
history.ignore_space_prefixis enabled. - Exact ignored commands are skipped:
history,clear,exit,logout, andmh record. - Private mode skips every command.
Default secret detection looks for sensitive terms and patterns such as:
password,passwd,pwd(context-aware; e.g.psql -p5432is a port, not a password)token,secret,api_key,authorization,bearer(includingAuthorization: Basic)- Inline PEM blocks (
-----BEGIN … PRIVATE KEY-----) - Database URLs with credentials (
postgresql://user:pass@host/…) aws_secret_access_key,aws_access_key_id(including bareKEY=valuewithoutexport)github_token,gitlab_token,PGPASSWORD=sshpass(-pandSSHPASS=),mysql/mariadb-p,docker login -pkubectl --token=,--from-literal=,redis-cli -anpm_config_*variables containingtoken,password,secret,auth, orkeyhelm … --set …password|secret|token…=pip install … --password …poetry config http-basic.*andPOETRY_*TOKEN*environment variablescargo logintokens andCARGO_REGISTRIES_*_TOKENcurl -u user:passandcurl --user(scoped tocurlonly)wget --password=- Credit-card-like number sequences (Luhn-validated to reduce false positives)
Examples that are masked by default:
mysql -u root -pSecret123
curl -H "Authorization: Bearer abc123" https://example.test
export AWS_SECRET_ACCESS_KEY=xxxx
AWS_SECRET_ACCESS_KEY=xxxx
sshpass -p password ssh root@1.1.1.1
docker login -u user -p password
kubectl config set-credentials user --token=abc
export GITHUB_TOKEN=ghp_secret
helm upgrade app chart --set secret.password=topsecret
npm_config_//registry.npmjs.org/:_authToken=npm_secret
mysql --password supersecret
sshpass -p mypassword ssh user@host
curl https://user:pass@example.testQuoted and spaced argument forms are handled (-p'Secret', -p "pass", -u user:pass on curl only). Inline OpenSSH/PEM private key material in a command is masked or skipped.
Switch from masking to skipping secret commands:
mh config set security.skip_secret_commands trueDisable masking:
mh config set security.mask_secrets falseView audit entries:
mh audit
mh audit --today
mh audit --limit 100
mh audit --format json
mh audit --verify-chainVerify the tamper-evident hash chain:
mh audit --verify-chainEach audit entry stores prev_hash and entry_hash fields. Verification walks the chain chronologically and fails if any entry was modified.
Audit entries are created for skipped, masked, risky, policy, replay, break-glass, and purge events when security.audit_log is enabled.
List configured rules:
mh policy listEvaluate a command against the active policy:
mh policy check "rm -rf /"
mh policy check "curl https://x.com | bash" --hostname prod-web --env production --jsonDefault rules:
| Rule ID | Action | Condition |
|---|---|---|
deny-critical-prod |
deny | critical risk in production |
approval-critical |
require_approval | critical risk elsewhere |
warn-high |
warn | high risk |
Customize rules in [policy] in config. Each rule can match on risk level, regex pattern, environment tier, or hostname pattern.
Export, verify, and apply a shared-key signed policy pack:
export MH_POLICY_PACK_KEY='change-this-shared-secret'
mh policy pack export policy-pack.json
mh policy pack verify policy-pack.json
mh policy pack apply policy-pack.jsonUse --key on individual commands when you do not want to read the key from MH_POLICY_PACK_KEY.
Inspect every command in a session for incident response:
mh timeline --session "$MH_SESSION_ID"
mh timeline --session abc-123 --jsonOutput includes command text, timestamps, exit codes, environment tier, and detected risk level.
Export a session bundle for review or handoff:
mh incident export --session "$MH_SESSION_ID" --output incident.jsonThe bundle includes the session timeline, risky commands, audit-chain verification status, and recent audit events. Command text and audit messages are redacted by default; add --include-secrets only for a controlled forensic handoff.
Place a legal hold on a session, command, tag, or Git repository:
mh hold add incident-2026 --session "$MH_SESSION_ID" --reason "security review"
mh hold add repo-freeze --git-repo /srv/payments --reason "compliance audit"
mh hold list
mh hold remove 1Held commands are excluded from mh clear, max-entry enforcement, and retention purge.
Enable retention and purge old records:
mh config set retention.enabled true
mh config set retention.retention_days 365
mh hold purge --dry-run
mh hold purgeTurn a session into a reusable playbook:
mh runbook create rollback --session "$MH_SESSION_ID" --desc "Rollback steps"
mh runbook show rollback
mh runbook run rollback --dry-runUse during incidents when private mode is on but recording must continue:
mh break-glass on --reason "database recovery" --ttl-hours 2
# run diagnostic commands — they are recorded and audited
mh break-glass offBreak-glass state is stored in ~/.config/mh/break_glass with an expiry timestamp.
Enable forwarding in config:
[siem]
enabled = true
format = "cef"
syslog_url = "siem.example.com:514"
webhook_url = "https://siem.example.com/hooks/mh"View recent audit events on stdout:
mh watch --limit 50 --format jsonWebhook delivery requires building with --features sync (uses the reqwest dependency). Syslog TCP delivery works in the default build.
Enable private mode:
mh private onDisable private mode:
mh private offCheck status:
mh private statusUse the environment variable configured by security.private_mode_env:
export MH_PRIVATE=1Commands are not recorded while private mode is active. The file-based private mode state is stored next to the config file.
When policy rules deny a command in production or other environments, recording is skipped silently by default. To surface policy denials in the shell hook stderr stream:
export MH_POLICY_VERBOSE=1This works together with MH_RECORD_VERBOSE, which shows all record diagnostics.
The vault stores commands encrypted with AES-256-GCM. Vault entries are stored in the vault table as encrypted bytes and a nonce. The plaintext command is not listed by mh vault list.
Set a passphrase for non-interactive use:
export MH_VAULT_PASSPHRASE='change-this-passphrase'Add a vault command:
mh vault add "kubectl exec -it prod-pod -- /bin/sh" --label prod-shell
mh vault add "psql postgresql://user:secret@db/prod" --label prod-dbList vault entries:
mh vault listPrint a decrypted command without running it:
mh vault run 3 --dry-runRun a vault command:
mh vault run 3Delete a vault entry:
mh vault delete 3Check passphrase prompting:
mh vault unlockClear persistent unlocked state:
mh vault lockCurrent vault behavior:
MH_VAULT_PASSPHRASEis used first when it is set.- Otherwise
mhprompts for a passphrase without echoing it. mh vault lockreports that there is no persistent unlocked process state.- The
vault.use_keyringconfig key is reserved for OS keyring integration; this build does not store vault passphrases in the OS keyring.
Build with sync support to enable encrypted remote push/pull:
cargo build --release --features syncmh sync stores local sync settings and, when built with the sync feature, encrypts history with AES-256-GCM before sending it to a compatible server at /api/v1/sync/push and /api/v1/sync/pull.
Show sync status:
mh sync statusSave server URL and token:
mh sync setup https://mh.example.test token-valueEnable sync state:
mh sync enableDisable sync state:
mh sync disableTune interval:
mh config set sync.auto_sync_interval_minutes 15Current push/pull behavior:
mh sync push
mh sync pullThese commands require building with --features sync and a compatible remote server. The token is stored in the local config file, so do not commit or share config.toml.
Run health checks:
mh doctor
mh doctor --strict # non-zero exit when any warning is reported
mh doctor --json # structured report on stdout (no styled text)mh doctor checks:
- Config file loading and validation (
mh config validatehints on failure) - Environment overrides (
MH_CONFIG,MH_DB) — rejectsMH_DBwhen it points to a directory; warns when the database path is owned by another user or lives under another user's/home - Database opening and writable data directory
- Config/database/WAL file permissions and symlink targets
- Database file size versus configured max size
- Available disk space on the database volume (≥100 MB recommended free)
- SQLite integrity
- Schema version (currently 11; pending migrations reported with upgrade hint)
- Tamper-evident audit chain verification when enterprise tables exist
- Command count
- Security and policy engine configuration (invalid ignore regex reported)
- Duplicate shell hook lines and managed integration blocks
- Private mode marker/env
- Record daemon status (running, stale PID/socket, or
MH_NO_DAEMON) - Sync enabled/configured state
- Vault enabled state and keyring preference
- Current shell and whether
mhis available inPATH
Human-readable example:
[OK] Config loaded from /home/user/.config/mh/config.toml
[OK] Database opened at /home/user/.local/share/mh/history.db
[INFO] Database size: 0.25 MB / 512 MB
[OK] Database integrity check passed
[OK] Schema version: 11
[INFO] Command count: 42
[INFO] Record daemon: running (pid 12345)
[INFO] Sync: disabled (no server configured)
[INFO] Vault config: disabled (keyring: enabled)
[INFO] Current shell: /usr/bin/zsh
[OK] Binary is available in PATH
JSON example (automation/CI):
mh doctor --json | jq .{
"status": "ok",
"warning_count": 0,
"checks": [
{ "code": "check_…", "level": "ok", "message": "Config loaded from …" }
],
"summary": {
"mh_version": "0.1.0",
"config_path": "/home/user/.config/mh/config.toml",
"database_path": "/home/user/.local/share/mh/history.db",
"schema_version": 11,
"command_count": 42,
"daemon_running": true,
"private_mode": false,
"strict": false
}
}Table output is the default for user-facing commands:
mh search docker
mh last
mh pinnedJSON output:
mh search docker --json
mh last --json
mh pinned --json
mh audit --format jsonPlain command output:
mh search docker --plain
mh last --plain
mh pinned --plainFile exports:
mh export --json history.json
mh export --csv history.csv
mh export --markdown history.md
mh export --compressed history.json.zstGenerate completions:
mh completions bash --output mh.bash
mh completions zsh --output _mh
mh completions fish --output mh.fish
mh completions power-shell --output mh.ps1
mh completions elvish --output mh.elvPrint completion script to stdout:
mh completions zshGenerate a man page:
mh man --output mh.1Print the man page to stdout:
mh manThe installer installs generated Bash, Zsh, and Fish completions and a man page automatically unless --no-completions or --no-man is used.
Show application and developer metadata:
mh aboutExample:
mh 0.1.0
Author: Cuma Kurt <cumakurt@gmail.com>
GitHub: https://github.com/cumakurt/mh
LinkedIn: https://www.linkedin.com/in/cuma-kurt-34414917/
The same developer metadata is also present in CLI help output.
Build a .deb package:
scripts/package-deb.shUse an existing release binary:
scripts/package-deb.sh --no-buildWrite artifacts to another directory:
scripts/package-deb.sh --output-dir /tmp/mh-packageThe package installs:
/usr/bin/mh- Bash completion
- Zsh completion
- Fish completion
mh.1.gzman page
Inspect the package:
dpkg-deb --info target/package/mh_0.1.0_amd64.deb
dpkg-deb --contents target/package/mh_0.1.0_amd64.debInstall it:
sudo dpkg -i target/package/mh_0.1.0_amd64.debThe schema is applied through numbered SQL migrations (currently version 11). Existing databases migrate automatically on first open after an upgrade. Core tables:
commands— command records with metadata, environment tier, and legal-hold flagsessionstagssnippetsaudit_log— security events with SHA-256 hash chain columns (prev_hash,entry_hash)vaultcommands_fts— FTS5 virtual table (kept in sync via triggers since schema v10)legal_holds— legal hold definitionsrunbooksandrunbook_steps— reusable command sequencespurge_audit— retention purge audit trail
Important indexes:
idx_commands_commandidx_commands_cwdidx_commands_started_atidx_commands_exit_codeidx_commands_useridx_commands_hostnameidx_commands_sessionidx_commands_categoryidx_commands_is_pinnedidx_commands_dedupe_lookup—(command, cwd, started_at DESC)for duplicate-window lookups (v11)idx_tags_tagidx_tags_command_id
FTS5 is used by mh search --fts. Duplicate commands within history.dedupe_window_seconds are suppressed using a transactional insert (BEGIN IMMEDIATE) so parallel terminals do not create duplicate rows.
Runtime:
MH_CONFIG: override config file path.MH_DB: override database file path.MH_CONFIG_NO_CACHE: bypass in-process config/engine cache (tests and debugging).MH_NO_DAEMON: force shell hooks to write directly to SQLite instead of the record daemon.MH_SKIP_GIT_DETECT: skip Git metadata subprocess during record (set by default in shell hooks).MH_PRIVATE: default private mode environment variable.MH_POLICY_VERBOSE: print policy denial messages from shell hooks to stderr.MH_RECORD_VERBOSE: print record diagnostics from shell hooks to stderr.MH_SESSION_ID: shell session identifier used by hooks.MH_PICK_LIMIT: default result limit for up-arrow picker integration.MH_HISTORY_ARROWS: when set (e.g.1), bind left/right arrow keys to step through recentmhcommands on the prompt (Bash, Zsh, Fish).MH_VAULT_PASSPHRASE: non-interactive vault passphrase source.SHELL: shell used by replay, snippets, vault run, and shell detection.EDITOR: editor used bymh config edit.
Installer:
INSTALL_DIR: binary install directory.MH_SHELL: shell override for installation.
Review recent failures in a project:
mh search --failed --cwd "$PWD"Find a Docker command from memory:
mh search --fuzzy "dps"Pin the last command and tag it:
mh pin "$(mh last 1 --json | jq '.[0].id')"
mh tag --last 1 importantExport this month's Git commands:
mh export --after 2026-05-01 --category git --json git-may.jsonSave and test a reusable SSH command:
mh snippet save ssh-host "ssh {{user}}@{{host}}"
mh snippet run ssh-host --var user=admin --var host=10.0.0.5 --dry-runWork without recording:
mh private on
# run sensitive commands
mh private offStore a sensitive command outside normal history:
export MH_VAULT_PASSPHRASE='change-this-passphrase'
mh vault add "kubectl exec -it prod-pod -- /bin/sh" --label prod-shell
mh vault run 1 --dry-runInvestigate a session after an incident:
mh timeline --session "$MH_SESSION_ID" --json
mh audit --verify-chain
mh hold add incident --session "$MH_SESSION_ID" --reason "forensics"Check policy before running a risky replay:
mh policy check "rm -rf /tmp/cache"
mh replay 42 --reason "approved cleanup" --yesFreeze production history during an audit:
mh hold add audit-q2 --git-repo /srv/payments --reason "quarterly review"
mh search --env production --limit 100- Remote sync requires
--features syncand a compatible server endpoint. - SIEM webhook delivery requires
--features sync; syslog TCP works in the default build. - Fleet control plane (
mh-server) is not included in this release. - Vault passphrases can be stored in the OS keyring when
vault.use_keyring = true.MH_VAULT_PASSPHRASEremains supported. - PowerShell integration targets PowerShell 7+ with PSReadLine available.
- Import supports JSON, CSV, and compressed JSON. Markdown export is for human-readable reports, not re-import.
mh initprints an activation command because a child process cannot directly source the already-running parent shell.- JSON/CSV export loads the full filtered result set into memory; very large exports may need narrower date/tag filters.
Run the full command verification suite before release:
./scripts/verify-all-commands.sh
cargo test # 250+ tests (unit, integration, security, shell, migration, doctor)
cargo clippy --all-targets -- -D warnings
cargo fmt --all -- --checkGitHub Actions workflows under .github/workflows/:
| Workflow | Trigger | Purpose |
|---|---|---|
ci.yml |
push/PR to main/master |
fmt, check, clippy, full test suite (incl. sync feature), record benchmarks (≤20 ms/op), search bench at 10k + 100k with MH_BENCH_ASSERT, mh doctor --json validation, cargo audit, release builds |
zsh-smoke.yml |
push/PR + manual | scripts/zsh-smoke.sh — Zsh hook + __mh_now_ms + record path (Kali-style non-interactive) |
bench-load.yml |
weekly + manual | Search/load benchmark at 100k–1M rows (MH_BENCH_SEARCH_SIZE) |
release.yml |
tags | Release artifacts |
Performance thresholds enforced in CI:
cargo bench --bench record_bench # direct insert; must stay under 20 ms/op
cargo bench --bench record_pipeline_bench # full record path; must stay under 20 ms/op
MH_BENCH_ASSERT=1 cargo bench --bench search_bench # default 10k rows
# Optional local load test (100k–1M rows)
MH_BENCH_SEARCH_SIZE=100000 MH_BENCH_ASSERT=1 \
MH_BENCH_MAX_FTS_MS=100 MH_BENCH_MAX_LAST_MS=50 MH_BENCH_MAX_FUZZY_MS=250 \
cargo bench --bench search_benchBefore submitting changes:
cargo fmt --all
cargo test
cargo clippy --all-targets -- -D warnings
cargo build --releaseUseful smoke test:
tmpdir="$(mktemp -d)"
export MH_CONFIG="$tmpdir/config.toml"
export MH_DB="$tmpdir/history.db"
target/release/mh record --command "git status" --cwd "$PWD" --shell zsh --exit-code 0 --duration-ms 12 --tags "git,test"
target/release/mh last
target/release/mh search --category git
target/release/mh stats --heatmap
target/release/mh export --json "$tmpdir/history.json"
target/release/mh import "$tmpdir/history.json" --dry-run
target/release/mh doctor
target/release/mh doctor --json | jq -e '.status'mh is free software released under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later).
- Full license text: LICENSE
- SPDX identifier:
AGPL-3.0-or-later
You are free to use, study, modify, and redistribute this program in accordance with the AGPL. Copyleft obligations apply, including when you offer the software to others over a network (SaaS/API). Derivative works must remain under the same license and include prominent notices as required by the AGPL.
Copyright © 2026 Cuma Kurt. See LICENSE for the complete terms.