1209 lines
38 KiB
Bash
1209 lines
38 KiB
Bash
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# === himi ===
|
|
# Memorable secret generator with configurable word lists and templates.
|
|
#
|
|
# Usage:
|
|
# himi rebuild Rebuild pool from enabled lists
|
|
# himi Generate secret (default template) → clipboard
|
|
# himi <template> Generate secret (named template) → clipboard
|
|
# himi -o [template] Output to stdout + clipboard
|
|
# himi -q [template] Output to stdout only (no clipboard)
|
|
# himi -n N Generate N secrets
|
|
# himi entropy [name] Show entropy for template(s)
|
|
# himi -h Show help
|
|
|
|
readonly VERSION="1.1.0"
|
|
readonly CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/himi"
|
|
readonly CONFIG_FILE="${CONFIG_DIR}/config.sh"
|
|
readonly LISTS_DIR="${CONFIG_DIR}/lists"
|
|
readonly POOL_DIR="${CONFIG_DIR}/pool"
|
|
readonly POOL_FILE="${POOL_DIR}/pool.txt"
|
|
readonly POOL_SUM_FILE="${POOL_DIR}/.pool.sha256"
|
|
|
|
# Bump when config.sh gains new options; configs declaring an older
|
|
# CONFIG_VERSION (or none) get a nag until merged and re-stamped
|
|
readonly CONFIG_SCHEMA_VERSION=2
|
|
|
|
# Generic charset token: {name:N} or {name:N-M}
|
|
readonly TOKEN_RE='\{([a-z][a-z0-9_]*):([0-9]+)(-([0-9]+))?\}'
|
|
# Anything that still looks like a token after expansion (typo'd or unknown).
|
|
# Shape-based so charsets containing { } (e.g. ascii) don't false-positive.
|
|
readonly LEFTOVER_RE='\{[A-Za-z][A-Za-z0-9_]*(!|:[0-9]+(-[0-9]+)?)?\}'
|
|
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Defaults (overridden by config.sh)
|
|
# ------------------------------------------------------------------------------
|
|
ALPHA_CHARSET='0123456789ABCDEFGHJKMNPQRSTVWXYZ' # Crockford Base32 (32 chars, 5 bits)
|
|
SAFE_CHARSET='ACDEFGHJKMNPQRTWXY234679' # Ultra-safe (24 chars, 4.6 bits) - no B/8, 0/O, 1/I/L, 5/S
|
|
NUM_CHARSET='0123456789'
|
|
SPECIAL_CHARSET='!?@#%~=+'
|
|
HEX_CHARSET='0123456789abcdef' # 16 chars, 4.0 bits
|
|
B58_CHARSET='123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' # Base58: no 0OIl (58 chars, 5.86 bits)
|
|
B64_CHARSET='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' # URL-safe base64 (64 chars, 6.0 bits)
|
|
ALNUM_CHARSET='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' # 62 chars, 5.95 bits
|
|
ASCII_CHARSET='!"#$%&'\''()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~' # all printable, no space (94 chars, 6.55 bits)
|
|
WORD_MIN_LENGTH=4
|
|
WORD_MAX_LENGTH=8
|
|
|
|
# Custom charsets: "name|characters". Names are lowercase [a-z][a-z0-9_]*.
|
|
# Entries here override the built-in four (alpha, safe, num, special).
|
|
CHARSETS=()
|
|
|
|
TEMPLATES=(
|
|
"default|{word}-{word}-{alpha:5}"
|
|
"easy|{word}-{alpha:3}-{num:3}"
|
|
"secure|{word}-{word}-{alpha:5}-{word}-{alpha:5}{special:1}"
|
|
"pin|{num:6}"
|
|
"diceware|{word}-{word}-{word}-{word}-{word}"
|
|
)
|
|
|
|
DEFAULT_TEMPLATE="default"
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Colors (disabled if not a terminal)
|
|
# ------------------------------------------------------------------------------
|
|
if [[ -t 1 ]]; then
|
|
readonly RED='\033[0;31m'
|
|
readonly GREEN='\033[0;32m'
|
|
readonly YELLOW='\033[0;33m'
|
|
readonly DIM='\033[0;90m'
|
|
readonly BOLD='\033[1m'
|
|
readonly NC='\033[0m'
|
|
else
|
|
readonly RED='' GREEN='' YELLOW='' DIM='' BOLD='' NC=''
|
|
fi
|
|
|
|
# Output helpers
|
|
err() { printf "${RED}error:${NC} %s\n" "$1" >&2; }
|
|
ok() { printf "${GREEN}✓${NC} %s\n" "$1" >&2; }
|
|
warn() { printf "${YELLOW}!${NC} %s\n" "$1" >&2; }
|
|
dim() { printf "${DIM}%s${NC}" "$1"; }
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Platform detection
|
|
# ------------------------------------------------------------------------------
|
|
detect_platform() {
|
|
case "$(uname -s)" in
|
|
Darwin*) PLATFORM="macos" ;;
|
|
Linux*) PLATFORM="linux" ;;
|
|
*) PLATFORM="unknown" ;;
|
|
esac
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Cross-platform shuffle (requires GNU coreutils)
|
|
# ------------------------------------------------------------------------------
|
|
shuffle_lines() {
|
|
if command -v shuf &>/dev/null; then
|
|
shuf "$@"
|
|
elif command -v gshuf &>/dev/null; then
|
|
gshuf "$@"
|
|
else
|
|
err "GNU coreutils required"
|
|
if [[ "$PLATFORM" == "macos" ]]; then
|
|
printf " Install with: brew install coreutils\n" >&2
|
|
else
|
|
printf " Install with: sudo apt install coreutils (or equivalent)\n" >&2
|
|
fi
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Refuse to trust files that aren't ours or that others can write to
|
|
# (ssh-config style check; the config file is sourced = code execution)
|
|
# ------------------------------------------------------------------------------
|
|
require_safe_file() {
|
|
local f="$1"
|
|
[[ -e "$f" ]] || return 0
|
|
|
|
if [[ ! -O "$f" ]]; then
|
|
err "$f is not owned by you — refusing to use it"
|
|
return 1
|
|
fi
|
|
|
|
local mode
|
|
case "$PLATFORM" in
|
|
macos) mode=$(stat -f '%Lp' "$f") ;;
|
|
*) mode=$(stat -c '%a' "$f") ;;
|
|
esac
|
|
|
|
if (( (8#$mode & 8#022) != 0 )); then
|
|
err "$f is group/world-writable (mode $mode) — refusing to use it"
|
|
err "fix with: chmod go-w '$f'"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Load user config if exists
|
|
# ------------------------------------------------------------------------------
|
|
load_config() {
|
|
if [[ -f "$CONFIG_FILE" ]]; then
|
|
require_safe_file "$CONFIG_DIR" || exit 1
|
|
require_safe_file "$CONFIG_FILE" || exit 1
|
|
source "$CONFIG_FILE"
|
|
validate_charsets
|
|
check_config_version
|
|
fi
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Nag (don't fail) when the user's config predates the current schema —
|
|
# new options like CHARSETS won't exist in it. Silenced by merging the new
|
|
# default config and setting CONFIG_VERSION to the current schema version.
|
|
# ------------------------------------------------------------------------------
|
|
check_config_version() {
|
|
local have="${CONFIG_VERSION:-0}"
|
|
[[ "$have" =~ ^[0-9]+$ ]] || have=0
|
|
|
|
if (( have < CONFIG_SCHEMA_VERSION )); then
|
|
warn "config.sh is from an older himi (config v${have} < v${CONFIG_SCHEMA_VERSION}) — new options are missing"
|
|
warn "merge the repo's config.sh (or ${CONFIG_FILE}.new if the installer left one),"
|
|
warn "then set CONFIG_VERSION=${CONFIG_SCHEMA_VERSION} in it to silence this"
|
|
elif (( have > CONFIG_SCHEMA_VERSION )); then
|
|
warn "config.sh declares v${have} but this himi understands v${CONFIG_SCHEMA_VERSION} — update the binary?"
|
|
fi
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Sanity-check user-defined charsets (run once after config load)
|
|
# ------------------------------------------------------------------------------
|
|
validate_charsets() {
|
|
local entry name set dupes
|
|
for entry in ${CHARSETS[@]+"${CHARSETS[@]}"}; do
|
|
IFS='|' read -r name set <<< "$entry"
|
|
if [[ ! "$name" =~ ^[a-z][a-z0-9_]*$ ]]; then
|
|
warn "charset '$name' ignored: names must match [a-z][a-z0-9_]*"
|
|
continue
|
|
fi
|
|
if [[ -z "$set" ]]; then
|
|
warn "charset '$name' is empty"
|
|
continue
|
|
fi
|
|
dupes=$(printf '%s' "$set" | grep -o . | sort | uniq -d | tr -d '\n')
|
|
if [[ -n "$dupes" ]]; then
|
|
warn "charset '$name' has duplicate chars ($dupes): distribution skewed, entropy overstated"
|
|
fi
|
|
done
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Look up a charset by name: user CHARSETS first, then built-ins
|
|
# ------------------------------------------------------------------------------
|
|
get_charset() {
|
|
local name="$1" entry cname cset
|
|
for entry in ${CHARSETS[@]+"${CHARSETS[@]}"}; do
|
|
IFS='|' read -r cname cset <<< "$entry"
|
|
if [[ "$cname" == "$name" ]]; then
|
|
printf '%s' "$cset"
|
|
return 0
|
|
fi
|
|
done
|
|
case "$name" in
|
|
alpha) printf '%s' "$ALPHA_CHARSET" ;;
|
|
safe) printf '%s' "$SAFE_CHARSET" ;;
|
|
num) printf '%s' "$NUM_CHARSET" ;;
|
|
special) printf '%s' "$SPECIAL_CHARSET" ;;
|
|
hex) printf '%s' "$HEX_CHARSET" ;;
|
|
b58) printf '%s' "$B58_CHARSET" ;;
|
|
b64) printf '%s' "$B64_CHARSET" ;;
|
|
alnum) printf '%s' "$ALNUM_CHARSET" ;;
|
|
ascii) printf '%s' "$ASCII_CHARSET" ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Ensure directories exist
|
|
# ------------------------------------------------------------------------------
|
|
init_dirs() {
|
|
mkdir -p "$LISTS_DIR" "$POOL_DIR"
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Filter words: length range + de-duplicate derivatives
|
|
# ------------------------------------------------------------------------------
|
|
filter_words() {
|
|
local min_len="${1:-$WORD_MIN_LENGTH}"
|
|
local max_len="${2:-$WORD_MAX_LENGTH}"
|
|
|
|
grep -E "^[a-z]{${min_len},${max_len}}$" \
|
|
| sort -u \
|
|
| awk '{
|
|
if (prev && index($0, prev) == 1 && length($0) - length(prev) <= 4) next
|
|
prev = $0; print
|
|
}'
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Add: import and enable a local word list
|
|
# ------------------------------------------------------------------------------
|
|
cmd_add() {
|
|
local file="${1:-}"
|
|
|
|
if [[ -z "$file" ]]; then
|
|
err "usage: himi add <wordlist-file>"
|
|
return 1
|
|
fi
|
|
|
|
if [[ ! -f "$file" ]]; then
|
|
err "file not found: $file"
|
|
return 1
|
|
fi
|
|
|
|
init_dirs
|
|
|
|
# Derive name from filename (strip path and extension, sanitize to [a-z0-9_-])
|
|
local name
|
|
name=$(basename "$file" | sed 's/\.[^.]*$//' | tr 'A-Z' 'a-z' | tr -cd 'a-z0-9_-')
|
|
|
|
if [[ -z "$name" ]]; then
|
|
err "cannot derive a valid list name from '$file'"
|
|
return 1
|
|
fi
|
|
|
|
local listfile="${LISTS_DIR}/${name}.txt"
|
|
|
|
# Clean: lowercase, alpha only, sort, dedupe
|
|
tr 'A-Z' 'a-z' < "$file" \
|
|
| grep -E '^[a-z]+$' \
|
|
| sort -u \
|
|
> "$listfile"
|
|
|
|
local count
|
|
count=$(wc -l < "$listfile")
|
|
ok "added $name ($count words)"
|
|
|
|
# Enable by default
|
|
cmd_enable "$name"
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Remove: delete list and disable it
|
|
# ------------------------------------------------------------------------------
|
|
cmd_remove() {
|
|
if [[ $# -eq 0 ]]; then
|
|
err "usage: himi remove <list>..."
|
|
return 1
|
|
fi
|
|
|
|
init_dirs
|
|
local had_errors=0
|
|
|
|
for name in "$@"; do
|
|
local listfile="${LISTS_DIR}/${name}.txt"
|
|
local linkfile="${POOL_DIR}/${name}.txt"
|
|
|
|
if [[ ! -f "$listfile" ]]; then
|
|
warn "list '$name' not found"
|
|
had_errors=1
|
|
continue
|
|
fi
|
|
|
|
# Remove symlink if it's currently enabled
|
|
[[ -L "$linkfile" ]] && rm "$linkfile"
|
|
|
|
# Remove the actual list
|
|
rm "$listfile"
|
|
ok "removed $name"
|
|
done
|
|
|
|
rebuild_pool
|
|
return $had_errors
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Enable: symlink list(s) into pool/
|
|
# ------------------------------------------------------------------------------
|
|
enable_one() {
|
|
local name="$1"
|
|
local listfile="${LISTS_DIR}/${name}.txt"
|
|
local linkfile="${POOL_DIR}/${name}.txt"
|
|
|
|
if [[ ! -f "$listfile" ]]; then
|
|
err "list '$name' not found"
|
|
return 1
|
|
fi
|
|
|
|
if [[ -L "$linkfile" ]]; then
|
|
warn "'$name' already enabled"
|
|
return 0
|
|
fi
|
|
|
|
ln -s "../lists/${name}.txt" "$linkfile"
|
|
ok "enabled $name"
|
|
}
|
|
|
|
cmd_enable() {
|
|
if [[ $# -eq 0 ]]; then
|
|
err "usage: himi enable <list>... | all"
|
|
return 1
|
|
fi
|
|
|
|
init_dirs
|
|
|
|
local had_errors=0
|
|
for name in "$@"; do
|
|
if [[ "$name" == "all" ]]; then
|
|
local f
|
|
for f in "$LISTS_DIR"/*.txt; do
|
|
[[ -f "$f" ]] || continue
|
|
enable_one "$(basename "$f" .txt)" || had_errors=1
|
|
done
|
|
else
|
|
enable_one "$name" || had_errors=1
|
|
fi
|
|
done
|
|
|
|
rebuild_pool
|
|
return $had_errors
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Disable: remove symlink(s) from pool/
|
|
# ------------------------------------------------------------------------------
|
|
disable_one() {
|
|
local name="$1"
|
|
local linkfile="${POOL_DIR}/${name}.txt"
|
|
|
|
if [[ ! -L "$linkfile" ]]; then
|
|
warn "'$name' not enabled"
|
|
return 0
|
|
fi
|
|
|
|
rm "$linkfile"
|
|
ok "disabled $name"
|
|
}
|
|
|
|
cmd_disable() {
|
|
if [[ $# -eq 0 ]]; then
|
|
err "usage: himi disable <list>... | all"
|
|
return 1
|
|
fi
|
|
|
|
init_dirs
|
|
|
|
local had_errors=0
|
|
for name in "$@"; do
|
|
if [[ "$name" == "all" ]]; then
|
|
local f
|
|
for f in "$POOL_DIR"/*.txt; do
|
|
[[ -L "$f" ]] || continue
|
|
disable_one "$(basename "$f" .txt)" || had_errors=1
|
|
done
|
|
else
|
|
disable_one "$name" || had_errors=1
|
|
fi
|
|
done
|
|
|
|
rebuild_pool
|
|
return $had_errors
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Rebuild: regenerate pool.txt from enabled lists
|
|
# ------------------------------------------------------------------------------
|
|
cmd_rebuild() {
|
|
init_dirs
|
|
rebuild_pool
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Pool manifest: detect pool.txt changes made outside 'himi rebuild'
|
|
# (catches accidental edits, sync corruption, casual tampering; an attacker
|
|
# with write access to $HOME can defeat this, but they can also edit config.sh)
|
|
# ------------------------------------------------------------------------------
|
|
sha256_file() {
|
|
if command -v sha256sum &>/dev/null; then
|
|
sha256sum "$1" | awk '{print $1}'
|
|
elif command -v shasum &>/dev/null; then
|
|
shasum -a 256 "$1" | awk '{print $1}'
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
write_pool_manifest() {
|
|
local sum
|
|
sum=$(sha256_file "$POOL_FILE") || return 0 # no hash tool: skip quietly
|
|
printf '%s\n' "$sum" > "$POOL_SUM_FILE"
|
|
}
|
|
|
|
verify_pool_manifest() {
|
|
[[ -f "$POOL_SUM_FILE" ]] || return 0 # pre-1.1 install: no manifest yet
|
|
local want have
|
|
want=$(<"$POOL_SUM_FILE")
|
|
have=$(sha256_file "$POOL_FILE") || return 0
|
|
if [[ "$want" != "$have" ]]; then
|
|
err "pool.txt changed outside 'himi rebuild' (checksum mismatch)"
|
|
warn "if this was you, run 'himi rebuild'; if not, inspect $POOL_DIR and $LISTS_DIR"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Rebuild pool from enabled lists (symlinks in pool/)
|
|
# ------------------------------------------------------------------------------
|
|
rebuild_pool() {
|
|
local before=0
|
|
[[ -f "$POOL_FILE" ]] && before=$(wc -l < "$POOL_FILE")
|
|
|
|
# Only include symlinked files (enabled lists), exclude pool.txt itself.
|
|
# find -exec is whitespace-safe (list names can contain '-' and '_' only,
|
|
# but belt and suspenders).
|
|
local enabled_count
|
|
enabled_count=$(find "$POOL_DIR" -maxdepth 1 -type l -name '*.txt' 2>/dev/null | wc -l)
|
|
|
|
if (( enabled_count == 0 )); then
|
|
warn "no lists enabled"
|
|
> "$POOL_FILE"
|
|
write_pool_manifest
|
|
return
|
|
fi
|
|
|
|
find "$POOL_DIR" -maxdepth 1 -type l -name '*.txt' -exec cat {} + \
|
|
| filter_words "$WORD_MIN_LENGTH" "$WORD_MAX_LENGTH" \
|
|
> "$POOL_FILE"
|
|
|
|
write_pool_manifest
|
|
|
|
local after
|
|
after=$(wc -l < "$POOL_FILE")
|
|
local diff=$((after - before))
|
|
|
|
if [[ $before -eq 0 ]]; then
|
|
ok "pool: $after words $(dim "($(calc_log2 "$after") bits/word)")"
|
|
elif [[ $diff -ne 0 ]]; then
|
|
local sign="+"
|
|
[[ $diff -lt 0 ]] && sign=""
|
|
ok "pool: $after words $(dim "($(calc_log2 "$after") bits/word)") [${sign}${diff}]"
|
|
fi
|
|
|
|
if [[ "$after" -gt 0 && "$after" -lt 256 ]]; then
|
|
warn "pool has only $after words ($(calc_log2 "$after") bits/word) — secrets will be weak"
|
|
warn "enable more lists with 'himi enable' or add lists with 'himi add'"
|
|
fi
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# List: show all lists and enabled status
|
|
# ------------------------------------------------------------------------------
|
|
cmd_list() {
|
|
init_dirs
|
|
|
|
if ! ls "${LISTS_DIR}"/*.txt &>/dev/null; then
|
|
warn "no word lists found — run 'himi add <file>'"
|
|
return 0
|
|
fi
|
|
|
|
printf " %-16s %7s\n" "LIST" "WORDS"
|
|
printf " %-16s %7s\n" "────────────────" "───────"
|
|
|
|
for f in "${LISTS_DIR}"/*.txt; do
|
|
local name count marker
|
|
name=$(basename "$f" .txt)
|
|
count=$(wc -l < "$f")
|
|
|
|
if [[ -L "${POOL_DIR}/${name}.txt" ]]; then
|
|
marker="${GREEN}●${NC}"
|
|
else
|
|
marker="${DIM}○${NC}"
|
|
fi
|
|
|
|
printf " %b %-16s %7d\n" "$marker" "$name" "$count"
|
|
done
|
|
|
|
printf " %-16s %7s\n" "────────────────" "───────"
|
|
|
|
if [[ -f "$POOL_FILE" ]]; then
|
|
local pool_count
|
|
pool_count=$(wc -l < "$POOL_FILE")
|
|
if [[ "$pool_count" -gt 0 ]]; then
|
|
printf " %-16s %7d ${DIM}(%.1f bits/word)${NC}\n" "pool" "$pool_count" "$(calc_log2 "$pool_count")"
|
|
else
|
|
printf " %-16s %7d ${DIM}(empty)${NC}\n" "pool" "0"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Uninstall: remove himi and all data
|
|
# ------------------------------------------------------------------------------
|
|
cmd_uninstall() {
|
|
local purge="false"
|
|
[[ "${1:-}" == "--purge" ]] && purge="true"
|
|
|
|
read -p "Are you sure you want to remove himi and all custom wordlists? (y/N) " -n 1 -r
|
|
echo
|
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
|
return 1
|
|
fi
|
|
|
|
# 1. Clean up Shell Profiles
|
|
local profiles=(".bashrc" ".zshrc" ".bash_profile" ".profile")
|
|
local start_marker="# >>> himi initialize >>>"
|
|
local end_marker="# <<< himi initialize <<<"
|
|
|
|
for p in "${profiles[@]}"; do
|
|
if [[ -f "$HOME/$p" ]]; then
|
|
# Uses sed to delete everything between the markers (inclusive)
|
|
if grep -q "$start_marker" "$HOME/$p"; then
|
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
|
# macOS requires the extension to be attached to the -i flag
|
|
sed -i '.bak' "/$start_marker/,/$end_marker/d" "$HOME/$p"
|
|
else
|
|
# Linux/GNU
|
|
sed -i.bak "/$start_marker/,/$end_marker/d" "$HOME/$p"
|
|
fi
|
|
if [[ "$purge" == "true" ]]; then
|
|
rm -f "$HOME/$p.bak"
|
|
ok "Cleaned up $p (no backup, --purge)"
|
|
else
|
|
ok "Cleaned up $p (backup created as $p.bak)"
|
|
fi
|
|
fi
|
|
fi
|
|
done
|
|
|
|
# 2. Remove completions (and the .zfunc dir if we leave it empty)
|
|
rm -f "${HOME}/.zfunc/_himi"
|
|
rmdir "${HOME}/.zfunc" 2>/dev/null || true
|
|
rm -f "${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion/completions/himi"
|
|
|
|
# 3. Remove data and binary
|
|
rm -rf "$CONFIG_DIR"
|
|
ok "Removed configuration and wordlists."
|
|
|
|
# Final step: binary deletes itself. Prefer the file actually running
|
|
# (works for ./himi too); fall back to PATH lookup without tripping set -e.
|
|
local self="${BASH_SOURCE[0]}"
|
|
if [[ ! -f "$self" ]]; then
|
|
self=$(command -v himi) || self=""
|
|
fi
|
|
[[ -n "$self" ]] && rm -f "$self"
|
|
|
|
# Flag any other copies still lurking in PATH
|
|
local leftover
|
|
if leftover=$(command -v himi 2>/dev/null) && [[ -n "$leftover" ]]; then
|
|
warn "another himi remains at: $leftover — remove it manually"
|
|
fi
|
|
|
|
if [[ "$purge" != "true" ]]; then
|
|
dim "Profile backups (*.bak) were kept; use 'himi uninstall --purge' for none."
|
|
printf "\n"
|
|
fi
|
|
|
|
ok "himi has been uninstalled."
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Calculate log base 2 using awk
|
|
# ------------------------------------------------------------------------------
|
|
calc_log2() {
|
|
awk -v n="$1" 'BEGIN { printf "%.2f", log(n)/log(2) }'
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Get charset length (characters, not bytes — multibyte-safe)
|
|
# ------------------------------------------------------------------------------
|
|
charset_len() {
|
|
local s="$1"
|
|
printf '%s' "${#s}"
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Count occurrences of a fixed token in a string (pure bash, no pipelines)
|
|
# ------------------------------------------------------------------------------
|
|
count_token() {
|
|
local s="$1" t="$2" stripped
|
|
stripped="${s//"$t"/}"
|
|
printf '%d' $(( (${#s} - ${#stripped}) / ${#t} ))
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Calculate entropy for a template string
|
|
# ------------------------------------------------------------------------------
|
|
calc_entropy() {
|
|
local template="$1"
|
|
local entropy=0
|
|
local pool_size word_bits
|
|
|
|
pool_size=$(wc -l < "$POOL_FILE" 2>/dev/null || echo 0)
|
|
[[ "$pool_size" -eq 0 ]] && { echo "0"; return; }
|
|
|
|
word_bits=$(calc_log2 "$pool_size")
|
|
|
|
# Count word variants (most specific first)
|
|
# {word!} = pool + 1 bit (2 cases)
|
|
local word_bang_count
|
|
word_bang_count=$(count_token "$template" '{word!}')
|
|
if [[ "$word_bang_count" -gt 0 ]]; then
|
|
entropy=$(awk -v e="$entropy" -v w="$word_bang_count" -v b="$word_bits" 'BEGIN { print e + w * (b + 1) }')
|
|
template="${template//\{word!\}/}"
|
|
fi
|
|
|
|
# {Word!} = pool + log2(3) bits (3 cases)
|
|
local Word_bang_count
|
|
Word_bang_count=$(count_token "$template" '{Word!}')
|
|
if [[ "$Word_bang_count" -gt 0 ]]; then
|
|
entropy=$(awk -v e="$entropy" -v w="$Word_bang_count" -v b="$word_bits" 'BEGIN { print e + w * (b + log(3)/log(2)) }')
|
|
template="${template//\{Word!\}/}"
|
|
fi
|
|
|
|
# {WORD}, {Word}, {word} = pool bits only (no case entropy)
|
|
local WORD_count Word_count word_count
|
|
WORD_count=$(count_token "$template" '{WORD}')
|
|
template="${template//\{WORD\}/}"
|
|
|
|
Word_count=$(count_token "$template" '{Word}')
|
|
template="${template//\{Word\}/}"
|
|
|
|
word_count=$(count_token "$template" '{word}')
|
|
|
|
local total_plain_words=$(( WORD_count + Word_count + word_count ))
|
|
if [[ "$total_plain_words" -gt 0 ]]; then
|
|
entropy=$(awk -v e="$entropy" -v w="$total_plain_words" -v b="$word_bits" 'BEGIN { print e + (w * b) }')
|
|
fi
|
|
|
|
# Generic charset tokens: {name:N} or {name:N-M}
|
|
# Entropy = log2( sum of charset_size^L for L in N..M )
|
|
while [[ "$template" =~ $TOKEN_RE ]]; do
|
|
local token="${BASH_REMATCH[0]}"
|
|
local cname="${BASH_REMATCH[1]}"
|
|
local lmin="${BASH_REMATCH[2]}"
|
|
local lmax="${BASH_REMATCH[4]:-${BASH_REMATCH[2]}}"
|
|
lmin=$(( 10#$lmin ))
|
|
lmax=$(( 10#$lmax ))
|
|
|
|
local charset
|
|
if charset=$(get_charset "$cname") && (( lmin <= lmax )); then
|
|
local cs_size bits
|
|
cs_size=$(charset_len "$charset")
|
|
bits=$(awk -v lmin="$lmin" -v lmax="$lmax" -v cs="$cs_size" 'BEGIN {
|
|
t = 0
|
|
for (l = lmin; l <= lmax; l++) t += cs ^ l
|
|
printf "%.4f", log(t) / log(2)
|
|
}')
|
|
entropy=$(awk -v e="$entropy" -v b="$bits" 'BEGIN { print e + b }')
|
|
fi
|
|
# Unknown charsets and bad ranges contribute 0 bits; generation will warn.
|
|
template="${template/"$token"/}"
|
|
done
|
|
|
|
printf "%.1f" "$entropy"
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Pick N random words from pool
|
|
# ------------------------------------------------------------------------------
|
|
pick_words() {
|
|
local n="${1:-1}"
|
|
shuffle_lines -n "$n" "$POOL_FILE"
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Uniform random integer 0..n-1 from /dev/urandom
|
|
# (replaces bash $RANDOM, which is a weak PRNG; modulo bias for the n=2,3
|
|
# used here is ≤ 0.002% on 16 bits — negligible)
|
|
# ------------------------------------------------------------------------------
|
|
rand_int() {
|
|
local n="$1" r
|
|
r=$(od -An -N2 -tu2 /dev/urandom | tr -d ' \n')
|
|
printf '%d' $(( r % n ))
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Generate random string from charset
|
|
# Indexes into the charset with rejection sampling: uniform, no modulo bias,
|
|
# no tr metacharacter pitfalls, no SIGPIPE games with pipefail.
|
|
# ------------------------------------------------------------------------------
|
|
rand_chars() {
|
|
local charset="$1"
|
|
local count="$2"
|
|
local cs_len="${#charset}"
|
|
local out="" limit b idx
|
|
|
|
if (( cs_len < 1 || cs_len > 256 )); then
|
|
err "charset must have 1-256 characters (got $cs_len)"
|
|
return 1
|
|
fi
|
|
|
|
# Largest multiple of cs_len that fits in a byte; reject bytes above it
|
|
limit=$(( 256 / cs_len * cs_len ))
|
|
|
|
while (( ${#out} < count )); do
|
|
for b in $(od -An -N64 -tu1 /dev/urandom); do
|
|
if (( b < limit )); then
|
|
idx=$(( b % cs_len ))
|
|
out+="${charset:idx:1}"
|
|
fi
|
|
(( ${#out} >= count )) && break
|
|
done
|
|
done
|
|
|
|
printf '%s' "${out:0:count}"
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Pick a length in [lmin, lmax], weighted by charset_size^L so that every
|
|
# possible output string is equally likely (uniform length would skew toward
|
|
# short strings and tank the min-entropy)
|
|
# ------------------------------------------------------------------------------
|
|
pick_length() {
|
|
local lmin="$1" lmax="$2" cs_len="$3"
|
|
|
|
if (( lmin == lmax )); then
|
|
printf '%s' "$lmin"
|
|
return 0
|
|
fi
|
|
|
|
local r
|
|
r=$(od -An -N4 -tu4 /dev/urandom | tr -d ' \n')
|
|
|
|
awk -v r="$r" -v lmin="$lmin" -v lmax="$lmax" -v cs="$cs_len" 'BEGIN {
|
|
total = 0
|
|
for (l = lmin; l <= lmax; l++) total += cs ^ l
|
|
x = (r / 4294967296.0) * total
|
|
acc = 0
|
|
for (l = lmin; l <= lmax; l++) {
|
|
acc += cs ^ l
|
|
if (x < acc) { print l; exit }
|
|
}
|
|
print lmax
|
|
}'
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Portable case conversion (works on bash 3.2/macOS)
|
|
# ------------------------------------------------------------------------------
|
|
to_upper() { tr '[:lower:]' '[:upper:]'; }
|
|
to_lower() { tr '[:upper:]' '[:lower:]'; }
|
|
to_title() {
|
|
local w
|
|
read -r w
|
|
printf "%s%s" "$(printf '%s' "${w:0:1}" | to_upper)" "$(printf '%s' "${w:1}" | to_lower)"
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Expand a template into a secret
|
|
# ------------------------------------------------------------------------------
|
|
expand_template() {
|
|
local template="$1"
|
|
local result="$template"
|
|
local word
|
|
|
|
# Process word tokens (most specific first to avoid partial matches)
|
|
|
|
# {word!} - random lower/UPPER (+1 bit)
|
|
while [[ "$result" =~ \{word!\} ]]; do
|
|
word=$(pick_words 1)
|
|
if (( $(rand_int 2) )); then
|
|
word=$(printf '%s' "$word" | to_upper)
|
|
fi
|
|
result="${result/\{word!\}/"$word"}"
|
|
done
|
|
|
|
# {Word!} - random lower/Title/UPPER (+1.58 bits)
|
|
while [[ "$result" =~ \{Word!\} ]]; do
|
|
word=$(pick_words 1)
|
|
case "$(rand_int 3)" in
|
|
1) word=$(printf '%s' "$word" | to_title) ;;
|
|
2) word=$(printf '%s' "$word" | to_upper) ;;
|
|
esac
|
|
result="${result/\{Word!\}/"$word"}"
|
|
done
|
|
|
|
# {WORD} - all upper
|
|
while [[ "$result" =~ \{WORD\} ]]; do
|
|
word=$(pick_words 1 | to_upper)
|
|
result="${result/\{WORD\}/"$word"}"
|
|
done
|
|
|
|
# {Word} - title case
|
|
while [[ "$result" =~ \{Word\} ]]; do
|
|
word=$(pick_words 1 | to_title)
|
|
result="${result/\{Word\}/"$word"}"
|
|
done
|
|
|
|
# {word} - all lower (default)
|
|
while [[ "$result" =~ \{word\} ]]; do
|
|
word=$(pick_words 1)
|
|
result="${result/\{word\}/"$word"}"
|
|
done
|
|
|
|
# Generic charset tokens: {name:N} or {name:N-M}
|
|
while [[ "$result" =~ $TOKEN_RE ]]; do
|
|
local token="${BASH_REMATCH[0]}"
|
|
local cname="${BASH_REMATCH[1]}"
|
|
local lmin="${BASH_REMATCH[2]}"
|
|
local lmax="${BASH_REMATCH[4]:-${BASH_REMATCH[2]}}"
|
|
lmin=$(( 10#$lmin ))
|
|
lmax=$(( 10#$lmax ))
|
|
|
|
local charset n chars
|
|
if ! charset=$(get_charset "$cname") || (( lmin > lmax )); then
|
|
# Unknown charset or bad range: neutralize the opening brace so the
|
|
# loop can move on; restored below so the leftover-token warning fires.
|
|
result="${result/"$token"/$'\x01'${token:1}}"
|
|
continue
|
|
fi
|
|
|
|
n=$(pick_length "$lmin" "$lmax" "$(charset_len "$charset")")
|
|
chars=$(rand_chars "$charset" "$n")
|
|
# Quoted replacement: keeps bash 5.2's patsub_replacement from
|
|
# interpreting '&' in user charsets as "the matched text"
|
|
result="${result/"$token"/"$chars"}"
|
|
done
|
|
|
|
# Restore any neutralized tokens so they're visible (and warnable)
|
|
result="${result//$'\x01'/\{}"
|
|
|
|
printf "%s" "$result"
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Get template string by name
|
|
# ------------------------------------------------------------------------------
|
|
get_template() {
|
|
local name="$1"
|
|
for entry in "${TEMPLATES[@]}"; do
|
|
IFS='|' read -r tname tpattern <<< "$entry"
|
|
if [[ "$tname" == "$name" ]]; then
|
|
printf "%s" "$tpattern"
|
|
return 0
|
|
fi
|
|
done
|
|
err "unknown template '$name'"
|
|
return 1
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Copy to clipboard (cross-platform)
|
|
# Consumes stdin in all cases; NEVER leaks the secret to stdout (the caller
|
|
# decides what to print — this used to double-print on headless boxes)
|
|
# ------------------------------------------------------------------------------
|
|
to_clipboard() {
|
|
case "$PLATFORM" in
|
|
macos)
|
|
pbcopy
|
|
;;
|
|
linux)
|
|
if command -v xclip &>/dev/null; then
|
|
xclip -selection clipboard
|
|
elif command -v xsel &>/dev/null; then
|
|
xsel --clipboard --input
|
|
elif command -v wl-copy &>/dev/null; then
|
|
wl-copy
|
|
else
|
|
cat > /dev/null
|
|
return 1
|
|
fi
|
|
;;
|
|
*)
|
|
cat > /dev/null
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Show entropy for templates
|
|
# ------------------------------------------------------------------------------
|
|
cmd_entropy() {
|
|
local specific="${1:-}"
|
|
|
|
if [[ ! -f "$POOL_FILE" ]] || [[ ! -s "$POOL_FILE" ]]; then
|
|
err "pool empty — run 'himi rebuild' first"
|
|
return 1
|
|
fi
|
|
|
|
printf "%-12s %-40s %s\n" "TEMPLATE" "EXAMPLE" "ENTROPY"
|
|
printf "%-12s %-40s %s\n" "--------" "-------" "-------"
|
|
|
|
for entry in "${TEMPLATES[@]}"; do
|
|
IFS='|' read -r name pattern <<< "$entry"
|
|
|
|
# If specific template requested, skip others
|
|
[[ -n "$specific" && "$name" != "$specific" ]] && continue
|
|
|
|
local example entropy
|
|
example=$(expand_template "$pattern")
|
|
entropy=$(calc_entropy "$pattern")
|
|
|
|
printf "%-12s %-40s ≈%s bits\n" "$name" "$example" "$entropy"
|
|
done
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Config: open config file in editor
|
|
# ------------------------------------------------------------------------------
|
|
cmd_config() {
|
|
local editor="${EDITOR:-${VISUAL:-vi}}"
|
|
|
|
if [[ ! -f "$CONFIG_FILE" ]]; then
|
|
err "config not found: $CONFIG_FILE"
|
|
return 1
|
|
fi
|
|
|
|
local before after
|
|
before=$(cksum "$CONFIG_FILE")
|
|
"$editor" "$CONFIG_FILE"
|
|
after=$(cksum "$CONFIG_FILE")
|
|
|
|
if [[ "$before" != "$after" ]]; then
|
|
ok "config changed — rebuilding pool"
|
|
load_config
|
|
rebuild_pool
|
|
fi
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Test: one-off pattern test with entropy comparison
|
|
# ------------------------------------------------------------------------------
|
|
cmd_test() {
|
|
local pattern="${1:-}"
|
|
|
|
if [[ -z "$pattern" ]]; then
|
|
err "usage: himi test '{pattern}'"
|
|
printf "\n Example: himi test '{word!}-{num:2}-{word!}'\n"
|
|
return 1
|
|
fi
|
|
|
|
if [[ ! -f "$POOL_FILE" ]] || [[ ! -s "$POOL_FILE" ]]; then
|
|
err "pool empty — run 'himi rebuild' first"
|
|
return 1
|
|
fi
|
|
|
|
local entropy
|
|
entropy=$(calc_entropy "$pattern")
|
|
|
|
printf "\n"
|
|
printf " ${BOLD}Pattern:${NC} %s\n" "$pattern"
|
|
printf " ${BOLD}Entropy:${NC} ≈%s bits\n" "$entropy"
|
|
printf "\n"
|
|
printf " ${BOLD}Examples:${NC}\n"
|
|
local example leftovers=""
|
|
for ((i = 0; i < 5; i++)); do
|
|
example=$(expand_template "$pattern")
|
|
[[ "$example" =~ $LEFTOVER_RE ]] && leftovers="true"
|
|
printf " %s\n" "$example"
|
|
done
|
|
printf "\n"
|
|
|
|
if [[ -n "$leftovers" ]]; then
|
|
warn "pattern contains unexpanded {token}s — unknown charset or typo? (0 bits counted for them)"
|
|
printf "\n"
|
|
fi
|
|
|
|
# Show comparison with built-in templates
|
|
printf " ${DIM}%-12s %s${NC}\n" "TEMPLATE" "ENTROPY"
|
|
printf " ${DIM}%-12s %s${NC}\n" "────────────" "───────"
|
|
printf " ${BOLD}%-12s ≈%s bits${NC} ← your pattern\n" "(test)" "$entropy"
|
|
|
|
for entry in "${TEMPLATES[@]}"; do
|
|
IFS='|' read -r tname tpattern <<< "$entry"
|
|
local tent
|
|
tent=$(calc_entropy "$tpattern")
|
|
printf " %-12s ≈%s bits\n" "$tname" "$tent"
|
|
done
|
|
printf "\n"
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Generate secrets
|
|
# ------------------------------------------------------------------------------
|
|
cmd_generate() {
|
|
local template_name="${1:-$DEFAULT_TEMPLATE}"
|
|
local count="${2:-1}"
|
|
local use_stdout="${3:-false}"
|
|
local use_clipboard="${4:-true}"
|
|
|
|
if [[ ! -f "$POOL_FILE" ]] || [[ ! -s "$POOL_FILE" ]]; then
|
|
err "pool empty — run 'himi rebuild' first"
|
|
return 1
|
|
fi
|
|
|
|
# Refuse if pool.txt was modified behind himi's back
|
|
verify_pool_manifest || return 1
|
|
|
|
# Sanity check: ensure pool has enough unique words for meaningful entropy
|
|
local unique_words
|
|
unique_words=$(sort -u "$POOL_FILE" | wc -l)
|
|
if [[ "$unique_words" -lt 256 ]]; then
|
|
err "pool has only $unique_words unique words — refusing to generate (need ≥256)"
|
|
warn "run 'himi rebuild' or 'himi enable' to fix"
|
|
return 1
|
|
fi
|
|
|
|
local pattern
|
|
pattern=$(get_template "$template_name") || return 1
|
|
|
|
local secrets=""
|
|
for ((i = 0; i < count; i++)); do
|
|
[[ -n "$secrets" ]] && secrets+=$'\n'
|
|
secrets+=$(expand_template "$pattern")
|
|
done
|
|
|
|
if [[ "$secrets" =~ $LEFTOVER_RE ]]; then
|
|
warn "output contains unexpanded {token}s — check the template ('himi test' helps)"
|
|
fi
|
|
|
|
local copied="false"
|
|
if [[ "$use_clipboard" == "true" ]]; then
|
|
if printf "%s" "$secrets" | to_clipboard; then
|
|
ok "copied to clipboard"
|
|
copied="true"
|
|
else
|
|
warn "clipboard unavailable (install xclip, xsel, or wl-copy) — printing instead"
|
|
fi
|
|
fi
|
|
|
|
# Print if asked for stdout, or if the secret would otherwise be lost
|
|
if [[ "$use_stdout" == "true" || "$copied" == "false" ]]; then
|
|
printf "%s\n" "$secrets"
|
|
fi
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Show help
|
|
# ------------------------------------------------------------------------------
|
|
cmd_help() {
|
|
cat <<EOF
|
|
himi v${VERSION} — memorable secret generator
|
|
|
|
Usage:
|
|
himi [template] Generate → clipboard (default: $DEFAULT_TEMPLATE)
|
|
himi -o [template] Generate → clipboard + stdout
|
|
himi -q [template] Generate → stdout only (no clipboard; or HIMI_NO_CLIP=1)
|
|
himi -n N [template] Generate N secrets
|
|
|
|
himi add <file> Add and enable a word list
|
|
himi enable <list>... Enable word list(s), or 'all'
|
|
himi disable <list>... Disable word list(s), or 'all'
|
|
himi list Show all lists and status
|
|
himi rebuild Rebuild pool from enabled lists
|
|
|
|
himi entropy [name] Show entropy for template(s)
|
|
himi test '{pattern}' Test a custom pattern
|
|
himi config Edit config in \$EDITOR
|
|
himi uninstall Remove himi and all data (--purge: no profile backups)
|
|
himi -h, --help Show this help
|
|
|
|
Templates (configure in $CONFIG_FILE):
|
|
EOF
|
|
for entry in "${TEMPLATES[@]}"; do
|
|
IFS='|' read -r name pattern <<< "$entry"
|
|
printf " %-12s %s\n" "$name" "$pattern"
|
|
done
|
|
|
|
cat <<EOF
|
|
|
|
Tokens:
|
|
{word} Random word (lowercase)
|
|
{WORD} Random word (UPPERCASE)
|
|
{Word} Random word (Title Case)
|
|
{word!} Random word (lower or UPPER, +1 bit)
|
|
{Word!} Random word (lower/Title/UPPER, +1.58 bits)
|
|
{NAME:N} N chars from charset NAME
|
|
{NAME:N-M} N to M chars from charset NAME (weighted so every
|
|
output is equally likely; entropy = log2 of total outcomes)
|
|
|
|
Charsets:
|
|
EOF
|
|
printf " %-12s %s\n" "alpha" "$ALPHA_CHARSET"
|
|
printf " %-12s %s\n" "safe" "$SAFE_CHARSET"
|
|
printf " %-12s %s\n" "num" "$NUM_CHARSET"
|
|
printf " %-12s %s\n" "special" "$SPECIAL_CHARSET"
|
|
printf " %-12s %s\n" "hex" "$HEX_CHARSET"
|
|
printf " %-12s %s\n" "b58" "$B58_CHARSET"
|
|
printf " %-12s %s\n" "b64" "$B64_CHARSET"
|
|
printf " %-12s %s\n" "alnum" "$ALNUM_CHARSET"
|
|
printf " %-12s %s\n" "ascii" "$ASCII_CHARSET"
|
|
for entry in ${CHARSETS[@]+"${CHARSETS[@]}"}; do
|
|
IFS='|' read -r name pattern <<< "$entry"
|
|
printf " %-12s %s\n" "$name" "$pattern"
|
|
done
|
|
|
|
cat <<EOF
|
|
|
|
Custom charsets: add "name|chars" entries to CHARSETS in config
|
|
|
|
Config: $CONFIG_FILE
|
|
Pool: $POOL_FILE
|
|
EOF
|
|
}
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Main
|
|
# ------------------------------------------------------------------------------
|
|
main() {
|
|
detect_platform
|
|
load_config
|
|
init_dirs
|
|
|
|
# 1. Intercept top-level commands first
|
|
case "${1:-}" in
|
|
add|enable|disable|remove|rebuild|list|test|config|entropy|uninstall)
|
|
local cmd="cmd_$1"
|
|
shift
|
|
"$cmd" "$@" # Pass all remaining arguments correctly
|
|
return $?
|
|
;;
|
|
esac
|
|
|
|
# 2. If it's not a command, parse generation flags and templates
|
|
local use_stdout="false"
|
|
local use_clipboard="true"
|
|
local count=1
|
|
local template_name=""
|
|
|
|
[[ -n "${HIMI_NO_CLIP:-}" ]] && use_clipboard="false"
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-o|--stdout)
|
|
use_stdout="true"
|
|
shift
|
|
;;
|
|
-q|--quiet)
|
|
use_clipboard="false"
|
|
shift
|
|
;;
|
|
-n|--number)
|
|
if [[ -z "${2:-}" || ! "${2:-}" =~ ^[1-9][0-9]*$ ]]; then
|
|
err "-n requires a positive integer (got: '${2:-}')"
|
|
return 1
|
|
fi
|
|
count="$2"
|
|
shift 2
|
|
;;
|
|
-h|--help|help)
|
|
cmd_help
|
|
return 0
|
|
;;
|
|
-*)
|
|
err "Unknown option: $1"
|
|
cmd_help >&2
|
|
return 1
|
|
;;
|
|
*)
|
|
if [[ -n "$template_name" ]]; then
|
|
err "Unexpected argument: $1. Did you mean to use a command?"
|
|
return 1
|
|
fi
|
|
template_name="$1"
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
|
|
[[ -z "$template_name" ]] && template_name="$DEFAULT_TEMPLATE"
|
|
cmd_generate "$template_name" "$count" "$use_stdout" "$use_clipboard"
|
|
}
|
|
|
|
main "$@"
|