如何编写可维护的 CLI 工具

如何编写类似 shorin 的 CLI 工具

本文档详细介绍如何创建一个支持多子命令的命令行工具,以 shorin 为参考。


目录

  1. 架构概述
  2. 目录结构
  3. 主调度器详解
  4. 子命令脚本编写
  5. 完整示例:创建 mytool
  6. 进阶功能
  7. 安装与部署
  8. 调试技巧

架构概述

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")
# 从脚本第 2 行提取描述(去掉 "# 描述:" 前缀)
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 替换当前进程,传递剩余参数
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
# 中文:提取第 2 行
desc=$(sed -n '2p' "$script" | sed -E 's/^#[[:space:]]*描述:[[:space:]]*//')
else
# 英文:提取第 3 行
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
# 描述:这里写中文描述(会显示在帮助中)
# Description: English description here
set -euo pipefail

# =============================================================================
# 子命令: check-battery
# 功能: 显示电池信息
# =============================================================================

# 颜色定义
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. 第 1 行: Shebang (#!/bin/bash)
  2. 第 2 行: 中文描述 (# 描述:xxx)
  3. 第 3 行: 英文描述 (# Description: xxx)
  4. 第 4 行起: 实际代码

完整示例:创建 mytool

步骤 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'
#!/bin/bash
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'
#!/bin/bash
# 描述:打招呼示例
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'
#!/bin/bash
# 描述:显示系统信息
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'
#!/bin/bash
# 描述:计数工具
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
# 确保 ~/.local/bin 在 PATH 中
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/*

# 确保 PATH 包含 ~/.local/bin
if ! echo "$PATH" | grep -q "$HOME/.local/bin"; then
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
fi

系统级安装

1
2
3
4
5
# 需要 root 权限
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
# install.sh

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

使用:

1
DEBUG=1 mytool hello

2. 检查脚本语法

1
2
bash -n mytool        # 语法检查
shellcheck mytool # 更详细的检查(需要安装 shellcheck)

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
# 检查 PATH
echo $PATH

# 临时添加
export PATH="$HOME/.local/bin:$PATH"

# 永久添加
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc

问题:描述不显示

确保子命令脚本第 2 行格式正确:

1
2
#!/bin/bash
# 描述:这里是描述

参考资源


快速参考

目录结构

~/.local/bin/mytool           # 主调度器
~/.local/lib/mytool/*         # 子命令脚本

子命令模板

1
2
3
4
5
#!/bin/bash
# 描述:功能描述
set -euo pipefail

# 你的代码

主调度器关键代码

1
2
# 执行子命令
exec "$TARGET_SCRIPT" "$@"

如何编写可维护的 CLI 工具
https://tingfeng347.github.io/2026/05/16/如何编写可维护的 CLI 工具/
作者
Tingfeng
发布于
2026年5月16日
许可协议