diff --git a/plugins/keyman/README.md b/plugins/keyman/README.md new file mode 100644 index 000000000..704fc3d77 --- /dev/null +++ b/plugins/keyman/README.md @@ -0,0 +1,149 @@ +# keyman plugin + +Provides a unified `keyman` command for managing SSH and GPG keys from the terminal. +Works on macOS, Linux (X11/Wayland), and WSL. + +> **Relationship to other plugins:** The [`ssh-agent`](../ssh-agent) and +> [`gpg-agent`](../gpg-agent) plugins manage agent *daemons* (auto-starting, +> identity loading, forwarding). `keyman` focuses on key *lifecycle* — creating, +> listing, copying, and deleting keys. They are complementary and can be used +> together. + +To enable it, add `keyman` to your plugins: + +```zsh +plugins=(... keyman) +``` + +Then type `keyman help` to see all available commands. + +## Requirements + +At least one of the following must be available: + +- `ssh-keygen` — for SSH key commands +- `gpg` — for GPG key commands + +### Clipboard support + +For clipboard commands (`keyman ssh copy`, `keyman gpg copy`), one of the tools +below must be installed: + +| Platform | Tool | Notes | +| --------------- | ---------- | -------------------------- | +| macOS | `pbcopy` | Built-in | +| Linux (X11) | `xclip` | `apt install xclip` | +| Linux (Wayland) | `wl-copy` | `apt install wl-clipboard` | +| WSL | `clip.exe` | Built-in (Windows side) | + +## Commands + +### SSH + +| Command | Description | +| --------------------------------------------- | --------------------------------------- | +| `keyman ssh new [comment] [file] [type]` | Create a new SSH key (default: ed25519) | +| `keyman ssh ls` | List all SSH public keys in `~/.ssh` | +| `keyman ssh copy [pubkey_file]` | Copy a public key to clipboard | +| `keyman ssh rm ` | Delete an SSH key pair | + +### GPG + +| Command | Description | +| -------------------------------------------------- | --------------------------------------------------------------- | +| `keyman gpg new` | Create a GPG key (interactive, via `gpg --full-generate-key`) | +| `keyman gpg quick-new "Name" "Email" [expiry]` | Create a GPG key non-interactively (ed25519, default 2y expiry) | +| `keyman gpg ls [-s\|--secret]` | List public keys, or secret keys with `-s` | +| `keyman gpg pub ` | Export a GPG public key (armored) | +| `keyman gpg priv ` | Export a GPG secret key (armored, with confirmation) | +| `keyman gpg copy ` | Copy a GPG public key to clipboard | +| `keyman gpg fp ` | Show a GPG key fingerprint | +| `keyman gpg rm ` | Delete a GPG key (secret + public) | + +### General + +| Command | Description | +| -------------- | ----------------- | +| `keyman help` | Show help message | + +## Tab Completion + +All commands support multi-level Zsh tab completion: + +``` +keyman → ssh | gpg | help +keyman ssh → new | ls | copy | rm +keyman gpg → new | quick-new | ls | pub | priv | copy | fp | rm +``` + +At the argument level: + +- **`keyman ssh new`** — completes key types (`ed25519`, `rsa`, `ecdsa`) and file paths +- **`keyman ssh copy`** — completes `~/.ssh/*.pub` files +- **`keyman ssh rm`** — completes private key files in `~/.ssh` +- **`keyman gpg ls`** — completes `--secret` / `-s` options +- **`keyman gpg quick-new`** — completes common expiry values (`1y`, `2y`, `3y`, `5y`, `0`) +- **`keyman gpg pub`**, **`priv`**, **`copy`**, **`fp`**, **`rm`** — complete GPG key IDs and emails from your keyring + +## Settings + +**IMPORTANT: put these settings _before_ the line that sources oh-my-zsh.** + +### `lang` + +Set the UI language. Supported values: `en` (default), `zh`. + +```zsh +zstyle :omz:plugins:keyman lang zh +``` + +### `debug` + +Show a status message when the plugin loads: + +```zsh +zstyle :omz:plugins:keyman debug true +``` + +### `default-ssh-type` + +Set the default SSH key type for `keyman ssh new`. Supported values: +`ed25519` (default), `rsa`, `ecdsa`. + +```zsh +zstyle :omz:plugins:keyman default-ssh-type rsa +``` + +## Examples + +```zsh +# Create a default ed25519 key +keyman ssh new + +# Create an RSA key with a custom comment and path +keyman ssh new "me@work" ~/.ssh/work_key rsa + +# List all SSH keys +keyman ssh ls + +# Copy the default public key to clipboard +keyman ssh copy + +# Delete an SSH key +keyman ssh rm ~/.ssh/id_ed25519 + +# Create a GPG key interactively +keyman gpg new + +# Create a GPG key quickly +keyman gpg quick-new "John Doe" "john@example.com" 1y + +# List GPG secret keys +keyman gpg ls --secret + +# Export and copy a GPG public key +keyman gpg copy john@example.com + +# Show GPG key fingerprint +keyman gpg fp john@example.com +``` diff --git a/plugins/keyman/keyman.plugin.zsh b/plugins/keyman/keyman.plugin.zsh new file mode 100644 index 000000000..720826ba1 --- /dev/null +++ b/plugins/keyman/keyman.plugin.zsh @@ -0,0 +1,765 @@ +#!/usr/bin/env zsh +# keyman.plugin.zsh -- SSH & GPG key management plugin for oh-my-zsh +# +# Author: keyman contributors +# Version: 0.2.0 +# License: MIT (same as oh-my-zsh) +# +# Usage: add 'keyman' to plugins in ~/.zshrc +# keyman ssh [args...] +# keyman gpg [args...] +# keyman help +# +# Configuration (in .zshrc, before plugins=(...)): +# zstyle ':omz:plugins:keyman' lang en # en (default) | zh +# zstyle ':omz:plugins:keyman' debug true # show load message (default: false) +# zstyle ':omz:plugins:keyman' default-ssh-type ed25519 # ed25519 | rsa | ecdsa + +# Require at least one of ssh-keygen or gpg +(( $+commands[ssh-keygen] + $+commands[gpg] )) || return + +# ===================================================== +# Persistent globals +# ===================================================== +typeset -gA _km_msg + +typeset -g _km_red=$'\033[0;31m' +typeset -g _km_green=$'\033[0;32m' +typeset -g _km_yellow=$'\033[0;33m' +typeset -g _km_blue=$'\033[0;34m' +typeset -g _km_cyan=$'\033[0;36m' +typeset -g _km_bold=$'\033[1m' +typeset -g _km_reset=$'\033[0m' + +_km_info() { print -r -- "${_km_blue}[keyman]${_km_reset} $*" } +_km_ok() { print -r -- "${_km_green}[keyman] ✅${_km_reset} $*" } +_km_warn() { print -r -- "${_km_yellow}[keyman] ⚠️${_km_reset} $*" } +_km_err() { print -r -- "${_km_red}[keyman] ❌${_km_reset} $*" } + +# Cross-platform clipboard +_km_copy_to_clipboard() { + local content="$1" + if command -v pbcopy &>/dev/null; then + printf '%s' "$content" | pbcopy + elif command -v xclip &>/dev/null; then + printf '%s' "$content" | xclip -selection clipboard + elif command -v wl-copy &>/dev/null; then + printf '%s' "$content" | wl-copy + elif command -v clip.exe &>/dev/null; then + printf '%s' "$content" | clip.exe + else + _km_warn "${_km_msg[clipboard_not_found]}" + return 1 + fi +} + +# ===================================================== +# Initialization (scoped via anonymous function) +# ===================================================== +function { + local lang + zstyle -s ':omz:plugins:keyman' lang lang || lang=en + + case "$lang" in + zh) + _km_msg=( + # -- clipboard -- + clipboard_not_found "未找到剪贴板工具 (pbcopy/xclip/wl-copy/clip.exe)" + # -- general -- + cancelled "已取消" + deleted "已删除" + confirm_delete "确认删除?(y/N) " + about_to_delete "即将删除:" + label_private_key "私钥:" + label_public_key "公钥:" + label_pubkey_content "公钥内容:" + label_file "文件:" + label_type "类型:" + label_fingerprint "指纹:" + label_comment "注释:" + # -- ssh new -- + ssh_dir_created "已创建 ~/.ssh 目录" + file_exists "文件已存在" + confirm_overwrite "是否覆盖?(y/N) " + creating_key "正在创建 %s 密钥..." + unsupported_key_type "不支持的密钥类型: %s (可选: ed25519, rsa, ecdsa)" + key_created "密钥已创建" + added_to_agent "已添加到 ssh-agent" + key_creation_failed "密钥创建失败" + # -- ssh ls -- + ssh_dir_not_found "~/.ssh 目录不存在" + no_ssh_keys_found "未找到任何 SSH 公钥" + # -- ssh copy -- + pubkey_not_found "公钥文件不存在" + available_pubkeys "可用的公钥:" + none "(无)" + pubkey_copied "公钥已复制到剪贴板" + # -- ssh rm -- + usage_ssh_rm "用法: keyman ssh rm " + key_not_found "密钥不存在" + # -- gpg quick-new -- + usage_gpg_quick_new "用法: keyman gpg quick-new \"姓名\" \"邮箱\" [过期时间]" + email_has_gpg_key "该邮箱已有 GPG 密钥:" + confirm_create_new "继续创建新密钥?(y/N) " + creating_gpg_key "正在创建 GPG 密钥..." + label_name " 姓名:" + label_email " 邮箱:" + label_expiry " 过期:" + gpg_key_created "GPG 密钥已创建" + gpg_key_creation_failed "GPG 密钥创建失败" + # -- gpg ls -- + gpg_secret_key_list "GPG 私钥列表:" + gpg_public_key_list "GPG 公钥列表:" + # -- gpg pub -- + usage_gpg_pub "用法: keyman gpg pub <邮箱或KeyID>" + gpg_key_not_found "未找到密钥" + gpg_public_key "GPG 公钥" + # -- gpg priv -- + usage_gpg_priv "用法: keyman gpg priv <邮箱或KeyID>" + gpg_secret_not_found "未找到私钥" + warn_export_secret "即将导出私钥!请确保在安全环境下操作" + confirm_export "确认导出?(y/N) " + # -- gpg copy -- + usage_gpg_copy "用法: keyman gpg copy <邮箱或KeyID>" + gpg_pubkey_copied "GPG 公钥已复制到剪贴板" + # -- gpg fp -- + usage_gpg_fp "用法: keyman gpg fp <邮箱或KeyID>" + # -- gpg rm -- + usage_gpg_rm "用法: keyman gpg rm <邮箱或KeyID>" + about_to_delete_gpg "即将删除 GPG 密钥" + key_info "密钥信息:" + # -- errors -- + unknown_group "未知命令组: %s (可用: ssh, gpg)" + unknown_ssh_action "未知 SSH 操作: %s (可用: new, ls, copy, rm)" + unknown_gpg_action "未知 GPG 操作: %s (可用: new, quick-new, ls, pub, priv, copy, fp, rm)" + # -- debug -- + loaded "已加载,输入 keyman help 查看帮助" + # -- help -- + help_text \ +"${_km_bold}keyman${_km_reset} — SSH & GPG 密钥管理 + +${_km_cyan}用法:${_km_reset} + keyman <命令组> <操作> [参数...] + keyman help + +${_km_cyan}SSH 命令:${_km_reset} + keyman ssh new [comment] [file] [type] 创建 SSH 密钥 + keyman ssh ls 列出所有 SSH 公钥 + keyman ssh copy [pubkey_file] 复制公钥到剪贴板 + keyman ssh rm 删除 SSH 密钥对 + +${_km_cyan}GPG 命令:${_km_reset} + keyman gpg new 创建密钥(交互式) + keyman gpg quick-new \"姓名\" \"邮箱\" [期限] 创建密钥(快速) + keyman gpg ls [-s|--secret] 列出密钥 + keyman gpg pub 导出公钥 + keyman gpg priv 导出私钥 + keyman gpg copy 复制公钥到剪贴板 + keyman gpg fp 查看指纹 + keyman gpg rm 删除密钥" + ) + ;; + *) + _km_msg=( + # -- clipboard -- + clipboard_not_found "Clipboard tool not found (pbcopy/xclip/wl-copy/clip.exe)" + # -- general -- + cancelled "Cancelled" + deleted "Deleted" + confirm_delete "Confirm deletion? (y/N) " + about_to_delete "About to delete:" + label_private_key "Private key:" + label_public_key "Public key:" + label_pubkey_content "Public key content:" + label_file "File:" + label_type "Type:" + label_fingerprint "Fingerprint:" + label_comment "Comment:" + # -- ssh new -- + ssh_dir_created "Created ~/.ssh directory" + file_exists "File already exists" + confirm_overwrite "Overwrite? (y/N) " + creating_key "Creating %s key..." + unsupported_key_type "Unsupported key type: %s (supported: ed25519, rsa, ecdsa)" + key_created "Key created" + added_to_agent "Added to ssh-agent" + key_creation_failed "Key creation failed" + # -- ssh ls -- + ssh_dir_not_found "~/.ssh directory does not exist" + no_ssh_keys_found "No SSH public keys found" + # -- ssh copy -- + pubkey_not_found "Public key file not found" + available_pubkeys "Available public keys:" + none "(none)" + pubkey_copied "Public key copied to clipboard" + # -- ssh rm -- + usage_ssh_rm "Usage: keyman ssh rm " + key_not_found "Key not found" + # -- gpg quick-new -- + usage_gpg_quick_new "Usage: keyman gpg quick-new \"Name\" \"Email\" [exp]" + email_has_gpg_key "This email already has a GPG key:" + confirm_create_new "Continue creating new key? (y/N) " + creating_gpg_key "Creating GPG key..." + label_name " Name:" + label_email " Email:" + label_expiry " Expiry:" + gpg_key_created "GPG key created" + gpg_key_creation_failed "GPG key creation failed" + # -- gpg ls -- + gpg_secret_key_list "GPG secret key list:" + gpg_public_key_list "GPG public key list:" + # -- gpg pub -- + usage_gpg_pub "Usage: keyman gpg pub " + gpg_key_not_found "Key not found" + gpg_public_key "GPG public key" + # -- gpg priv -- + usage_gpg_priv "Usage: keyman gpg priv " + gpg_secret_not_found "Secret key not found" + warn_export_secret "About to export secret key! Make sure you are in a secure environment" + confirm_export "Confirm export? (y/N) " + # -- gpg copy -- + usage_gpg_copy "Usage: keyman gpg copy " + gpg_pubkey_copied "GPG public key copied to clipboard" + # -- gpg fp -- + usage_gpg_fp "Usage: keyman gpg fp " + # -- gpg rm -- + usage_gpg_rm "Usage: keyman gpg rm " + about_to_delete_gpg "About to delete GPG key" + key_info "Key info:" + # -- errors -- + unknown_group "Unknown command group: %s (available: ssh, gpg)" + unknown_ssh_action "Unknown SSH action: %s (available: new, ls, copy, rm)" + unknown_gpg_action "Unknown GPG action: %s (available: new, quick-new, ls, pub, priv, copy, fp, rm)" + # -- debug -- + loaded "Loaded. Type 'keyman help' for help" + # -- help -- + help_text \ +"${_km_bold}keyman${_km_reset} — SSH & GPG Key Manager + +${_km_cyan}Usage:${_km_reset} + keyman [args...] + keyman help + +${_km_cyan}SSH Commands:${_km_reset} + keyman ssh new [comment] [file] [type] Create SSH key + keyman ssh ls List SSH public keys + keyman ssh copy [pubkey_file] Copy pubkey to clipboard + keyman ssh rm Delete SSH key pair + +${_km_cyan}GPG Commands:${_km_reset} + keyman gpg new Create key (interactive) + keyman gpg quick-new \"Name\" \"Email\" [exp] Create key (quick) + keyman gpg ls [-s|--secret] List keys + keyman gpg pub Export public key + keyman gpg priv Export secret key + keyman gpg copy Copy pubkey to clipboard + keyman gpg fp Show fingerprint + keyman gpg rm Delete key" + ) + ;; + esac + + # Debug output + zstyle -t ':omz:plugins:keyman' debug && _km_info "${_km_msg[loaded]}" +} + +# ===================================================== +# SSH Actions +# ===================================================== + +# keyman ssh new [comment] [keyfile] [type] +_km_ssh_new() { + local comment="${1:-${USER:-$(whoami)}@${HOST:-$(hostname)}}" + local keyfile="${2:-}" + local keytype="${3:-}" + + if [[ -z "$keytype" ]]; then + zstyle -s ':omz:plugins:keyman' default-ssh-type keytype || keytype=ed25519 + fi + + # Set default path by type + if [[ -z "$keyfile" ]]; then + case "$keytype" in + rsa) keyfile="$HOME/.ssh/id_rsa" ;; + ecdsa) keyfile="$HOME/.ssh/id_ecdsa" ;; + ed25519) keyfile="$HOME/.ssh/id_ed25519" ;; + *) keyfile="$HOME/.ssh/id_${keytype}" ;; + esac + fi + + # Ensure .ssh directory exists + if [[ ! -d "$HOME/.ssh" ]]; then + mkdir -p "$HOME/.ssh" + chmod 700 "$HOME/.ssh" + _km_info "${_km_msg[ssh_dir_created]}" + fi + + # Prevent overwriting existing key + if [[ -f "$keyfile" ]]; then + _km_warn "${_km_msg[file_exists]}: $keyfile" + read -q "REPLY?${_km_msg[confirm_overwrite]}" + echo + if [[ "$REPLY" != "y" ]]; then + _km_info "${_km_msg[cancelled]}" + return 1 + fi + fi + + _km_info "$(printf "${_km_msg[creating_key]}" "$keytype")" + + local _km_rc=0 + case "$keytype" in + rsa) + ssh-keygen -t rsa -b 4096 -C "$comment" -f "$keyfile" || _km_rc=$? + ;; + ecdsa) + ssh-keygen -t ecdsa -b 521 -C "$comment" -f "$keyfile" || _km_rc=$? + ;; + ed25519) + ssh-keygen -t ed25519 -C "$comment" -f "$keyfile" || _km_rc=$? + ;; + *) + _km_err "$(printf "${_km_msg[unsupported_key_type]}" "$keytype")" + return 1 + ;; + esac + + if [[ $_km_rc -eq 0 && -f "${keyfile}.pub" ]]; then + chmod 600 "$keyfile" + chmod 644 "${keyfile}.pub" + + _km_ok "${_km_msg[key_created]}" + print "" + print -r -- "${_km_cyan}${_km_msg[label_private_key]}${_km_reset} $keyfile" + print -r -- "${_km_cyan}${_km_msg[label_public_key]}${_km_reset} ${keyfile}.pub" + print "" + print -r -- "${_km_cyan}${_km_msg[label_pubkey_content]}${_km_reset}" + cat "${keyfile}.pub" + + # Add to ssh-agent (start agent if needed) + if [[ -z "$SSH_AUTH_SOCK" ]]; then + eval "$(ssh-agent -s)" >/dev/null 2>&1 + fi + if ssh-add "$keyfile" 2>/dev/null; then + _km_ok "${_km_msg[added_to_agent]}" + fi + else + _km_err "${_km_msg[key_creation_failed]}" + return 1 + fi +} + +# keyman ssh ls +_km_ssh_ls() { + local found=0 + + if [[ ! -d "$HOME/.ssh" ]]; then + _km_warn "${_km_msg[ssh_dir_not_found]}" + return 1 + fi + + for pubkey in "$HOME"/.ssh/*.pub(N); do + found=1 + local info + info=$(ssh-keygen -l -f "$pubkey" 2>/dev/null) || continue + local bits=${info%% *} + local rest=${info#* } + local fingerprint=${rest%% *} + rest=${rest#* } + local keytype=${rest##* } + local comment=${rest% *} + + print -r -- "${_km_cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${_km_reset}" + print -r -- "${_km_green}${_km_msg[label_file]}${_km_reset} $pubkey" + print -r -- "${_km_green}${_km_msg[label_type]}${_km_reset} ${keytype} (${bits} bits)" + print -r -- "${_km_green}${_km_msg[label_fingerprint]}${_km_reset} $fingerprint" + print -r -- "${_km_green}${_km_msg[label_comment]}${_km_reset} $comment" + print -r -- "${_km_green}${_km_msg[label_pubkey_content]}${_km_reset}" + cat "$pubkey" + echo "" + done + + if [[ $found -eq 0 ]]; then + _km_warn "${_km_msg[no_ssh_keys_found]}" + fi +} + +# keyman ssh copy [keyfile] +_km_ssh_copy() { + local pubkey="${1:-$HOME/.ssh/id_ed25519.pub}" + + [[ "$pubkey" != *.pub ]] && pubkey="${pubkey}.pub" + + if [[ ! -f "$pubkey" ]]; then + _km_err "${_km_msg[pubkey_not_found]}: $pubkey" + _km_info "${_km_msg[available_pubkeys]}" + ls "$HOME"/.ssh/*.pub 2>/dev/null || echo " ${_km_msg[none]}" + return 1 + fi + + local content=$(cat "$pubkey") + if _km_copy_to_clipboard "$content"; then + _km_ok "${_km_msg[pubkey_copied]}: $pubkey" + fi +} + +# keyman ssh rm +_km_ssh_rm() { + if [[ -z "${1:-}" ]]; then + _km_err "${_km_msg[usage_ssh_rm]}" + return 1 + fi + local keyfile="$1" + + keyfile="${keyfile%.pub}" + + if [[ ! -f "$keyfile" && ! -f "${keyfile}.pub" ]]; then + _km_err "${_km_msg[key_not_found]}: $keyfile" + return 1 + fi + + _km_warn "${_km_msg[about_to_delete]}" + [[ -f "$keyfile" ]] && echo " ${_km_msg[label_private_key]} $keyfile" + [[ -f "${keyfile}.pub" ]] && echo " ${_km_msg[label_public_key]} ${keyfile}.pub" + + read -q "REPLY?${_km_msg[confirm_delete]}" + echo + if [[ "$REPLY" == "y" ]]; then + ssh-add -d "$keyfile" 2>/dev/null + [[ -f "$keyfile" ]] && rm -f "$keyfile" + [[ -f "${keyfile}.pub" ]] && rm -f "${keyfile}.pub" + _km_ok "${_km_msg[deleted]}" + else + _km_info "${_km_msg[cancelled]}" + fi +} + +# ===================================================== +# GPG Actions +# ===================================================== + +# keyman gpg new (interactive) +_km_gpg_new() { + gpg --full-generate-key +} + +# keyman gpg quick-new "Name" "Email" [expiry] +_km_gpg_quick_new() { + if [[ -z "${1:-}" || -z "${2:-}" ]]; then + _km_err "${_km_msg[usage_gpg_quick_new]}" + return 1 + fi + local name="$1" + local email="$2" + local expire="${3:-2y}" + + if gpg --list-keys "$email" &>/dev/null; then + _km_warn "${_km_msg[email_has_gpg_key]}" + gpg --list-keys "$email" + read -q "REPLY?${_km_msg[confirm_create_new]}" + echo + [[ "$REPLY" != "y" ]] && return 1 + fi + + _km_info "${_km_msg[creating_gpg_key]}" + _km_info "${_km_msg[label_name]} $name" + _km_info "${_km_msg[label_email]} $email" + _km_info "${_km_msg[label_expiry]} $expire" + + local _km_rc=0 + gpg --batch --gen-key < +_km_gpg_pub() { + if [[ -z "${1:-}" ]]; then + _km_err "${_km_msg[usage_gpg_pub]}" + return 1 + fi + local key_id="$1" + + if ! gpg --list-keys "$key_id" &>/dev/null; then + _km_err "${_km_msg[gpg_key_not_found]}: $key_id" + return 1 + fi + + _km_info "${_km_msg[gpg_public_key]} ($key_id):" + echo "" + gpg --armor --export "$key_id" +} + +# keyman gpg priv +_km_gpg_priv() { + if [[ -z "${1:-}" ]]; then + _km_err "${_km_msg[usage_gpg_priv]}" + return 1 + fi + local key_id="$1" + + if ! gpg --list-secret-keys "$key_id" &>/dev/null; then + _km_err "${_km_msg[gpg_secret_not_found]}: $key_id" + return 1 + fi + + _km_warn "${_km_msg[warn_export_secret]}" + read -q "REPLY?${_km_msg[confirm_export]}" + echo + + if [[ "$REPLY" == "y" ]]; then + gpg --armor --export-secret-keys "$key_id" + else + _km_info "${_km_msg[cancelled]}" + fi +} + +# keyman gpg copy +_km_gpg_copy() { + if [[ -z "${1:-}" ]]; then + _km_err "${_km_msg[usage_gpg_copy]}" + return 1 + fi + local key_id="$1" + + if ! gpg --list-keys "$key_id" &>/dev/null; then + _km_err "${_km_msg[gpg_key_not_found]}: $key_id" + return 1 + fi + + local content=$(gpg --armor --export "$key_id") + if _km_copy_to_clipboard "$content"; then + _km_ok "${_km_msg[gpg_pubkey_copied]} ($key_id)" + fi +} + +# keyman gpg fp +_km_gpg_fp() { + if [[ -z "${1:-}" ]]; then + _km_err "${_km_msg[usage_gpg_fp]}" + return 1 + fi + gpg --fingerprint "$1" +} + +# keyman gpg rm +_km_gpg_rm() { + if [[ -z "${1:-}" ]]; then + _km_err "${_km_msg[usage_gpg_rm]}" + return 1 + fi + local key_id="$1" + + _km_warn "${_km_msg[about_to_delete_gpg]}: $key_id" + _km_info "${_km_msg[key_info]}" + gpg --list-keys "$key_id" 2>/dev/null + echo "" + + read -q "REPLY?${_km_msg[confirm_delete]}" + echo + + if [[ "$REPLY" == "y" ]]; then + gpg --delete-secret-and-public-key "$key_id" + _km_ok "${_km_msg[deleted]}" + else + _km_info "${_km_msg[cancelled]}" + fi +} + +# ===================================================== +# Main dispatcher +# ===================================================== +keyman() { + local group="${1:-help}" + local action="${2:-}" + shift 2 2>/dev/null + + case "$group" in + help|-h|--help) + print -r -- "${_km_msg[help_text]}" + ;; + ssh) + case "$action" in + new) _km_ssh_new "$@" ;; + ls) _km_ssh_ls "$@" ;; + copy) _km_ssh_copy "$@" ;; + rm) _km_ssh_rm "$@" ;; + *) + _km_err "$(printf "${_km_msg[unknown_ssh_action]}" "$action")" + return 1 + ;; + esac + ;; + gpg) + case "$action" in + new) _km_gpg_new "$@" ;; + quick-new) _km_gpg_quick_new "$@" ;; + ls) _km_gpg_ls "$@" ;; + pub) _km_gpg_pub "$@" ;; + priv) _km_gpg_priv "$@" ;; + copy) _km_gpg_copy "$@" ;; + fp) _km_gpg_fp "$@" ;; + rm) _km_gpg_rm "$@" ;; + *) + _km_err "$(printf "${_km_msg[unknown_gpg_action]}" "$action")" + return 1 + ;; + esac + ;; + *) + _km_err "$(printf "${_km_msg[unknown_group]}" "$group")" + return 1 + ;; + esac +} + +# ===================================================== +# Zsh Completions +# ===================================================== +_keyman() { + local curcontext="$curcontext" state line + typeset -A opt_args + + _arguments -C \ + '1:command group:->group' \ + '*::args:->args' + + case "$state" in + group) + local -a groups=( + 'ssh:Manage SSH keys' + 'gpg:Manage GPG keys' + 'help:Show help message' + ) + _describe 'command group' groups + ;; + args) + case "${line[1]}" in + ssh) + _arguments -C \ + '1:ssh action:->ssh_action' \ + '*::ssh_args:->ssh_args' + + case "$state" in + ssh_action) + local -a actions=( + 'new:Create SSH key' + 'ls:List SSH public keys' + 'copy:Copy public key to clipboard' + 'rm:Delete SSH key pair' + ) + _describe 'ssh action' actions + ;; + ssh_args) + case "${line[1]}" in + new) + _arguments \ + '1:comment:' \ + '2:keyfile:_files -W "$HOME/.ssh"' \ + '3:key type:(ed25519 rsa ecdsa)' + ;; + copy) + _arguments '1:public key file:_files -W "$HOME/.ssh" -g "*.pub"' + ;; + rm) + local -a keys + if [[ -d "$HOME/.ssh" ]]; then + keys=("$HOME"/.ssh/id_*(N:t)) + keys=(${keys:#*.pub}) + fi + _describe 'SSH key' keys + ;; + esac + ;; + esac + ;; + gpg) + _arguments -C \ + '1:gpg action:->gpg_action' \ + '*::gpg_args:->gpg_args' + + case "$state" in + gpg_action) + local -a actions=( + 'new:Create GPG key (interactive)' + 'quick-new:Create GPG key (quick)' + 'ls:List GPG keys' + 'pub:Export public key' + 'priv:Export secret key' + 'copy:Copy public key to clipboard' + 'fp:Show fingerprint' + 'rm:Delete GPG key' + ) + _describe 'gpg action' actions + ;; + gpg_args) + case "${line[1]}" in + ls) + _arguments '1:option:(--secret -s)' + ;; + quick-new) + _arguments \ + '1:name:' \ + '2:email:' \ + '3:expiry:(1y 2y 3y 5y 0)' + ;; + pub|priv|copy|fp|rm) + local -a key_ids + key_ids=(${(f)"$(gpg --list-keys --with-colons 2>/dev/null | awk -F: ' + /^pub/ { keyid = $5 } + /^uid/ && keyid != "" { + uid = $10 + if (uid != "") { + print keyid ":" uid + n = index(uid, "<") + if (n > 0) { + email = substr(uid, n + 1) + p = index(email, ">") + if (p > 0) email = substr(email, 1, p - 1) + if (email != "") print email ":" uid + } + keyid = "" + } + } + ')"}) + _describe 'GPG key ID or email' key_ids + ;; + esac + ;; + esac + ;; + esac + ;; + esac +} +compdef _keyman keyman