如何编写类似 shorin 的 CLI 工具
本文档详细介绍如何创建一个支持多子命令的命令行工具,以 shorin 为参考。
目录
- 架构概述
- 目录结构
- 主调度器详解
- 子命令脚本编写
- 完整示例:创建 mytool
- 进阶功能
- 安装与部署
- 调试技巧
架构概述
shorin 采用 主调度器 + 子命令脚本 的架构:
用户输入: shorin check-battery
↓
主调度器 (shorin)
↓
查找子命令脚本
↓
执行 /usr/lib/shorin-contrib/check-battery
优点:
- 每个子命令独立,易于维护
- 新增子命令只需添加脚本文件,无需修改主程序
- 支持动态生成帮助信息
目录结构
~/.local/
├── bin/
│ └── mytool # 主调度器(可执行)
└── lib/
└── mytool/ # 子命令目录
├── check-battery # 子命令1
├── clean # 子命令2
├── compressvideos # 子命令3
└── ...
或者系统级安装:
/usr/
├── bin/
│ └── mytool
└── lib/
└── mytool/
└── ...
主调度器详解
基础版本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| #!/bin/bash set -euo pipefail
LIB_DIR="$HOME/.local/lib/mytool"
BLUE='\033[0;34m' RED='\033[0;31m' NC='\033[0m'
if [ $# -eq 0 ]; then echo -e "用法: ${BLUE}mytool${NC} <子命令> [选项]" echo "" echo "可用子命令:" for script in "$LIB_DIR"/*; do if [ -x "$script" ]; then name=$(basename "$script") desc=$(sed -n '2p' "$script" | sed -E 's/^#[[:space:]]*描述:[[:space:]]*//') [ -z "$desc" ] && desc="-" printf " ${BLUE}%-20s${NC} %s\n" "$name" "$desc" fi done | sort exit 0 fi
COMMAND="$1" shift
TARGET_SCRIPT="$LIB_DIR/$COMMAND"
if [ -x "$TARGET_SCRIPT" ]; then exec "$TARGET_SCRIPT" "$@" else echo -e "${RED}错误: 未知子命令 '$COMMAND'${NC}" >&2 echo "运行 'mytool' 查看可用子命令" >&2 exit 1 fi
|
进阶版本(支持中英文)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
| #!/bin/bash set -euo pipefail
LIB_DIR="$HOME/.local/lib/mytool"
if [[ "${LANG:-}" == zh_CN* ]]; then IS_CN=true else IS_CN=false fi
if $IS_CN; then USAGE_STR="用法:" AVAIL_STR="可用子命令:" UNKNOWN_CMD="未知子命令" else USAGE_STR="Usage:" AVAIL_STR="Available subcommands:" UNKNOWN_CMD="unknown subcommand" fi
BLUE='\033[0;34m' NC='\033[0m'
if [ $# -eq 0 ]; then echo -e "${USAGE_STR} ${BLUE}mytool${NC} <子命令> [选项]" echo -e "\n${AVAIL_STR}" for script in "$LIB_DIR"/*; do if [ -x "$script" ]; then name=$(basename "$script") if $IS_CN; then desc=$(sed -n '2p' "$script" | sed -E 's/^#[[:space:]]*描述:[[:space:]]*//') else desc=$(sed -n '3p' "$script" | sed -E 's/^#[[:space:]]*Description:[[:space:]]*//') fi [ -z "$desc" ] && desc="-" printf " ${BLUE}%-20s${NC} %s\n" "$name" "$desc" fi done | sort exit 0 fi
COMMAND="$1" shift
TARGET_SCRIPT="$LIB_DIR/$COMMAND" if [ -x "$TARGET_SCRIPT" ]; then exec "$TARGET_SCRIPT" "$@" else echo "mytool: ${UNKNOWN_CMD} '$COMMAND'" >&2 exit 1 fi
|
子命令脚本编写
基本模板
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
| #!/bin/bash
set -euo pipefail
BOLD='\033[1m' RED='\033[1;31m' GREEN='\033[1;32m' YELLOW='\033[1;33m' BLUE='\033[1;34m' CYAN='\033[1;36m' NC='\033[0m'
show_help() { echo "用法: mytool check-battery [选项]" echo "" echo "选项:" echo " -h, --help 显示此帮助" echo " -v, --verbose 显示详细信息" }
VERBOSE=false
while [[ $# -gt 0 ]]; do case "$1" in -h|--help) show_help exit 0 ;; -v|--verbose) VERBOSE=true shift ;; *) echo -e "${RED}错误: 未知选项 '$1'${NC}" >&2 show_help exit 1 ;; esac done
echo -e "${BOLD}电池信息${NC}" echo "----------------------------------------"
BAT_PATH=$(upower -e | grep battery | head -n 1)
if [ -z "$BAT_PATH" ]; then echo -e "${RED}未检测到电池设备${NC}" exit 1 fi
INFO=$(upower -i "$BAT_PATH") PERCENT=$(echo "$INFO" | grep "percentage" | awk '{print $2}') STATUS=$(echo "$INFO" | grep "state" | awk '{print $2}')
echo -e "电量: ${GREEN}${PERCENT}${NC}" echo -e "状态: ${CYAN}${STATUS}${NC}"
|
子命令脚本规范
- 第 1 行: Shebang (
#!/bin/bash)
- 第 2 行: 中文描述 (
# 描述:xxx)
- 第 3 行: 英文描述 (
# Description: xxx)
- 第 4 行起: 实际代码
步骤 1:创建目录结构
1 2
| mkdir -p ~/.local/lib/mytool mkdir -p ~/.local/bin
|
步骤 2:创建主调度器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| cat > ~/.local/bin/mytool << 'EOF'
set -euo pipefail
LIB_DIR="$HOME/.local/lib/mytool" BLUE='\033[0;34m' NC='\033[0m'
if [ $# -eq 0 ]; then echo -e "用法: ${BLUE}mytool${NC} <子命令> [选项]" echo "" echo "可用子命令:" for script in "$LIB_DIR"/*; do if [ -x "$script" ]; then name=$(basename "$script") desc=$(sed -n '2p' "$script" | sed -E 's/^#[[:space:]]*描述:[[:space:]]*//') [ -z "$desc" ] && desc="-" printf " ${BLUE}%-20s${NC} %s\n" "$name" "$desc" fi done | sort exit 0 fi
COMMAND="$1" shift TARGET_SCRIPT="$LIB_DIR/$COMMAND"
if [ -x "$TARGET_SCRIPT" ]; then exec "$TARGET_SCRIPT" "$@" else echo "mytool: 未知子命令 '$COMMAND'" >&2 exit 1 fi EOF
chmod +x ~/.local/bin/mytool
|
步骤 3:创建子命令示例
示例 1:hello 子命令
1 2 3 4 5 6 7 8 9 10
| cat > ~/.local/lib/mytool/hello << 'EOF'
set -euo pipefail
NAME="${1:-World}" echo "你好, $NAME!" EOF
chmod +x ~/.local/lib/mytool/hello
|
示例 2:sysinfo 子命令
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| cat > ~/.local/lib/mytool/sysinfo << 'EOF'
set -euo pipefail
BOLD='\033[1m' CYAN='\033[1;36m' NC='\033[0m'
echo -e "${BOLD}=== 系统信息 ===${NC}" echo -e "主机名: ${CYAN}$(hostname)${NC}" echo -e "内核: ${CYAN}$(uname -r)${NC}" echo -e "系统: ${CYAN}$(uname -o)${NC}" echo -e "架构: ${CYAN}$(uname -m)${NC}" echo -e "运行时间: ${CYAN}$(uptime -p)${NC}" EOF
chmod +x ~/.local/lib/mytool/sysinfo
|
示例 3:count 子命令(带参数解析)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| cat > ~/.local/lib/mytool/count << 'EOF'
set -euo pipefail
show_help() { echo "用法: mytool count [选项]" echo "" echo "选项:" echo " -n NUM 计数到 NUM(默认 10)" echo " -s STEP 步长(默认 1)" echo " -h 显示帮助" }
N=10 STEP=1
while [[ $# -gt 0 ]]; do case "$1" in -n) N="$2"; shift 2 ;; -s) STEP="$2"; shift 2 ;; -h|--help) show_help; exit 0 ;; *) echo "未知选项: $1" >&2; exit 1 ;; esac done
for ((i=1; i<=N; i+=STEP)); do echo "$i" done EOF
chmod +x ~/.local/lib/mytool/count
|
步骤 4:添加到 PATH
1 2 3
| echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc source ~/.bashrc
|
步骤 5:测试
1 2 3 4 5 6 7 8 9
| mytool
mytool hello mytool hello 张三 mytool sysinfo mytool count -n 5 mytool count -n 20 -s 2
|
进阶功能
1. 自动补全
创建补全脚本 ~/.local/share/bash-completion/completions/mytool:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| #!/bin/bash _mytool_completions() { local cur prev commands cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" if [ "$COMP_CWORD" -eq 1 ]; then commands=$(ls "$HOME/.local/lib/mytool/" 2>/dev/null | xargs -n1 basename) COMPREPLY=($(compgen -W "$commands" -- "$cur")) return 0 fi case "$prev" in count) COMPREPLY=($(compgen -W "-n -s -h" -- "$cur")) ;; *) ;; esac }
complete -F _mytool_completions mytool
|
加载补全:
1
| source ~/.local/share/bash-completion/completions/mytool
|
2. 子命令分组
在主调度器中添加分组逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| declare -A GROUPS=( ["系统管理"]="sysinfo clean update" ["开发工具"]="build test deploy" ["实用工具"]="hello count" )
for group in "${!GROUPS[@]}"; do echo -e "\n${group}:" for cmd in ${GROUPS[$group]}; do ... done done
|
3. 配置文件支持
1 2 3 4 5 6 7 8 9
| CONFIG_FILE="$HOME/.config/mytool/config"
if [ -f "$CONFIG_FILE" ]; then source "$CONFIG_FILE" fi
echo "默认编辑器: ${EDITOR:-vim}"
|
4. 日志功能
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| LOG_FILE="$HOME/.local/log/mytool.log"
log() { local level="$1" shift local msg="$*" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') echo "[$timestamp] [$level] $msg" >> "$LOG_FILE" }
log "INFO" "执行子命令: $COMMAND" log "ERROR" "发生错误: $?"
|
5. 版本管理
在主调度器中添加版本信息:
1 2 3 4 5 6 7 8 9
| VERSION="1.0.0"
case "$1" in -v|--version) echo "mytool $VERSION" exit 0 ;; ... esac
|
安装与部署
用户级安装(推荐)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| mkdir -p ~/.local/bin ~/.local/lib/mytool
cp mytool ~/.local/bin/ chmod +x ~/.local/bin/mytool
cp subcommands/* ~/.local/lib/mytool/ chmod +x ~/.local/lib/mytool/*
if ! echo "$PATH" | grep -q "$HOME/.local/bin"; then echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc fi
|
系统级安装
1 2 3 4 5
| sudo cp mytool /usr/bin/ sudo mkdir -p /usr/lib/mytool sudo cp subcommands/* /usr/lib/mytool/ sudo chmod +x /usr/bin/mytool /usr/lib/mytool/*
|
创建安装脚本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| #!/bin/bash
set -euo pipefail
INSTALL_DIR="${1:-$HOME/.local}" LIB_DIR="$INSTALL_DIR/lib/mytool" BIN_DIR="$INSTALL_DIR/bin"
echo "安装 mytool 到 $INSTALL_DIR ..."
mkdir -p "$LIB_DIR" "$BIN_DIR"
cp mytool "$BIN_DIR/" chmod +x "$BIN_DIR/mytool"
for script in subcommands/*; do cp "$script" "$LIB_DIR/" chmod +x "$LIB_DIR/$(basename "$script")" done
echo "安装完成!" echo "确保 $BIN_DIR 在你的 PATH 中"
|
调试技巧
1. 启用调试模式
在脚本开头添加:
1 2 3 4
| if [[ "${DEBUG:-0}" == "1" ]]; then set -x fi
|
使用:
2. 检查脚本语法
1 2
| bash -n mytool shellcheck mytool
|
3. 常见问题
问题:找不到子命令
1 2 3 4 5
| ls -la ~/.local/lib/mytool/
chmod +x ~/.local/lib/mytool/*
|
问题:PATH 中没有 mytool
1 2 3 4 5 6 7 8
| echo $PATH
export PATH="$HOME/.local/bin:$PATH"
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
|
问题:描述不显示
确保子命令脚本第 2 行格式正确:
参考资源
快速参考
目录结构
~/.local/bin/mytool # 主调度器
~/.local/lib/mytool/* # 子命令脚本
子命令模板
1 2 3 4 5
| #!/bin/bash
set -euo pipefail
|
主调度器关键代码
1 2
| exec "$TARGET_SCRIPT" "$@"
|