☰
CLI-Anything:用Go构建跨平台可扩展的命令行任务工具箱
2026/9/28 16:32:06 网站建设 项目流程

"CLI-Anything"这个名字,说穿了就是"什么都能用命令行干"。我自己是个重度终端用户,日常开发里至少有三分之一的操作都堆在命令行里:查日志、转格式、批量改文件、看端口、查时间戳、清分支……以前每个需求都要临时去找对应的命令或脚本,有的用awk硬凑,有的还得现场写Python。功能倒是都能实现,但问题是零散、难记、没沉淀,换个环境就全得重来。所以我想着干脆做一个统一入口的CLI工具箱,把高频的、零碎的任务全部收拢成标准子命令,配上统一的配置和扩展机制,这就是CLI-Anything的由来。

这篇文章适合对自己动手攒工具感兴趣的开发者,也适合刚入门命令行、想找一个"现成工具箱"参考的人。我会把项目从定位、选型、编码到落地部署的全过程拆开讲,中间穿插大量实操细节和踩坑记录,你可以直接照着复现,也可以把它当成一个模块化CLI项目的样板。

1. 整体设计与技术选型

1.1 需求拆解:为什么不直接用 Shell 脚本或现成工具

开始动手前,我先列了需求清单。目标不是做一个"又一个jq"或者"又一个ripgrep",而是要做一个有扩展机制的任务聚合器。很多单点工具已经很好用,jq处理JSON、yq处理YAML、rg搜文件、fzf做交互选择,但它们之间没有任何联系,组合起来全靠记忆力。CLI-Anything的定位不是替代它们,而是做它们的调度层。

第二个需求是"跨平台、零依赖"。我经常在Linux服务器、macOS本机和Windows WSL之间切换,如果工具依赖Python环境和一堆pip包,每台机器都要折腾一遍,体验会大打折扣。所以语言层面我选了Go,原因很直接:编译出来是单个静态二进制,扔到任何一台机器上就能跑,连解释器都不用装。这一点在容器镜像里特别省心,FROM scratch或者alpine镜像里直接COPY一个二进制进去就完事。

第三,它必须是一个"可扩展平台",而不只是固定几个命令。我需要的模式是:用户定义任务,平台统一调度。所以核心架构是内置任务 + 自定义任务两层。内置任务覆盖高频操作,自定义任务通过任务描述文件接入,平台负责执行、传参、格式化输出。

1.2 选型对比:Go、Rust、Node 之间我为什么选 Go

我也认真考虑过Rust和Node。Rust的性能和安全性都很好,但开发效率是我当时的顾虑,而且生态里cli框架虽然多,真要写起来还是比Go多不少心智负担。Node做CLI其实也香,npm上oclif、commander都成熟,脚本写起来快,但问题在于"可执行文件"最终要依赖Node运行时,分发和使用门槛都高。

Go的优势我总结三点:编译期静态检查能挡住一大堆低级错误;goroutine处理并发批量任务非常顺手;Cobra这个CLI框架让子命令、参数解析、补全这些工程化问题都有了标准答案。

依赖管理上我用Go module,UI输出用fatih/color做简单的着色,配置解析用gopkg.in/yaml.v3,完全不引入重型框架。整套依赖加在一起,编译出来的二进制在3MB左右,这个体量放在容器和服务器上都毫无压力。

2. 核心命令架构与实现思路

2.1 命令体系:一切皆子命令

CLI-Anything的基础命令结构如下:

anything run <task> [flags] # 运行指定任务 anything list # 列出所有内置和自定义任务 anything new <task-template> # 根据模板生成新任务 anything config # 查看、初始化配置文件 anything alias # 输出/安装推荐的shell别名 anything doctor # 环境自检,排查运行时问题

anything run是核心入口。所有的任务统一通过它执行,参数用--key value或--key=value的形式传递。我刻意避免设计为anything <task>这种"魔法子命令"模式,那样虽然少打一个词,但会和Cobra的子命令注册机制冲突,也不利于任务列表的规整展示。

任务注册表的实现很直接:内置任务用一个map注册,key是任务名,value是一个结构体,里面存放任务描述、参数定义和执行函数。自定义任务则从配置文件里加载。运行时先查内置表,再查配置文件,都没找到就报错并列相近任务名。这个"相近任务名"的提示是做搜索功能时顺手加的,用了简单的编辑距离,排错体验提升非常明显。

2.2 内置任务的分类与设计逻辑

内置任务我分了五类:信息查询、格式转换、文件操作、网络诊断、编码辅助。

  • timestamp:时间戳与可读时间的双向转换,接收--ts或--date参数,内部处理时区。
  • json:JSON格式化、压缩、字段提取,支持--indent和--jq表达式。
  • yaml:YAML与JSON互相转换,配合统一的输入输出流设计。
  • encode:base64、URL编码、hex编码的互转。
  • file:批量重命名、按扩展名归类、统计目录文件数量。
  • port:检测本地端口占用情况,可批量传入端口号。
  • git:检查当前仓库状态、清理已合并分支、生成.gitignore。

这些任务共享一套输出约定:机器可读的走stdout,人看的日志走stderr。这个习惯是我在写自动化脚本时养成的,因为经常需要anything run json --input '{}' | jq ...做管道,如果执行信息打印在stdout里,管道就直接断掉了。设计上每一类任务都尽量保持纯函数式,不修改全局状态,方便组合。

还有一个细节:输出格式统一支持--format json。调试或者被其他工具调用时,JSON结构化输出比表格可解析得多。这个功能加得很便宜,却让工具的"被自动化"能力上了一个台阶。

3. 从零搭建 CLI-Anything 的完整实操

3.1 初始化项目和目录结构

我习惯用一个干净的项目目录开始:

mkdir clianyth && cd clianyth go mod init github.com/yourname/clianyth

目录结构这样安排:

clianyth/ ├── main.go ├── cmd/ │ ├── root.go # 根命令与全局flag │ ├── run.go # run子命令 │ ├── list.go # list子命令 │ ├── new.go # new子命令 │ └── config.go # config子命令 ├── internal/ │ ├── tasks/ │ │ ├── registry.go # 任务注册表 │ │ ├── builtin.go # 内置任务列表 │ │ ├── timestamp.go │ │ ├── json.go │ │ ├── yaml.go │ │ └── file.go │ └── config/ │ └── loader.go # 配置加载器 ├── config.example.yaml └── Makefile

internal目录强制私有,外部包无法引用,这能防止后面有人(包括未来的我自己)写出绕口的结构。根命令挂在Cobra上之后,自动就有了--help、子命令补全、错误时的高亮显示,这些工程能力省下了大量手工代码。

3.2 任务注册表与执行器实现

任务注册表是整个系统的核心。我用一个结构体描述任务:

type Task struct { Name string Description string Args []Arg Run func(ctx *Context, cfg *TConfig) error } type Arg struct { Name string Required bool Default string }

Context里主要放stdin/stdout的封装、日志实例和临时目录句柄。执行器拿到任务名后,先从内置注册表查,如果查不到就去配置里加载外部脚本任务。外部脚本任务有一个统一约定:入参通过环境变量注入,脚本路径配置在YAML里,执行完成后脚本的stdout会被平台原样输出。

这个设计解决了"平台语言和脚本语言割裂"的问题。我用Go写框架,但如果用户想塞一段Python、Bash甚至Lua脚本,都能作为任务接入。平台这边只管三件事:准备好环境变量、拉起进程、收集输出。

核心执行流程伪代码如下:

func RunTask(name string, flags map[string]string, cfg *TConfig) error { t, ok := registry.Get(name) if ok { ctx := NewContext(cfg) return t.Run(ctx, cfg) } cfgTask, ok := cfg.Tasks[name] if !ok { return fmt.Errorf("任务 %q 不存在,试试 anything list", name) } return runExternalScript(cfgTask, flags) }

runExternalScript里我会用exec.Command拉起脚本,同时把必要的参数写进设置的环境变量。这一层完全没做复杂的进程通信,因为绝大多数任务都不需要持续交互,一次性调用就够。

3.3 构建、安装与补全配置

开发完成后,直接编译整个项目:

go build -o anything . sudo mv anything /usr/local/bin/

我在Makefile里写了几个常用target,build和install最常用。

第一次运行anything run timestamp --ts 1710000000前,建议先跑一次anything config init,生成默认配置。默认配置路径是~/.clianyth/anything.yaml,内容类似:

theme: "dark" default_output: "table" external_tasks: mytask: cmd: "/home/user/scripts/my-task.sh" dir: "/home/user/scripts" env: APP_ENV: "production"

运行anything alias会打印推荐的shell别名。我常用的两行是:

alias any="anything run" alias anyl="anything list"

这样最终的使用姿势就是any timestamp --ts 1710000000,短到可以接受。anything completion bash还能生成bash补全脚本,在~/.bashrc里source一下,Tab键补全任务名、flag名都会变得很顺。

3.4 新增一个内置任务的完整流程

以添加port检测任务为例。前置是在internal/tasks/port.go里写实现:

func init() { registry.Register(Task{ Name: "port", Description: "检查端口占用情况,可批量传入", Args: []Arg{ {Name: "ports", Required: true, Description: "端口列表,逗号分隔"}, }, Run: func(ctx *Context, cfg *TConfig) error { for _, p := range strings.Split(ctx.Get("ports"), ",") { status := checkPort(strings.TrimSpace(p)) ctx.EmitJSON("port", map[string]string{"port": p, "status": status}) } return nil }, }) }

checkPort内部是用net.Listen("tcp", ":"+port)尝试监听,成功说明空闲,失败说明被占用。注意这个方式有个副作用:探测期间会把那个端口短暂占一下,在高频探测或者生产环境上运行时需要注意,持续时间极短,实际影响很小。写完代码后在builtin.go里加上_ "clianyth/internal/tasks"这个blank import,任务就会自动注册。

4. 三个真实场景下的实战过程

4.1 场景一:用一行命令完成日志 IP 统计

我有一段Nginx访问日志,约80万行,要统计每个IP的访问次数并取Top10。传统做法是:

awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -10

这套命令本身没问题,但我要的是"沉淀进工具箱"。于是我用CLI-Anything写了内置任务logstat:

scanner := bufio.NewScanner(file) freq := make(map[string]int) for scanner.Scan() { line := scanner.Text() parts := strings.Fields(line) if len(parts) > 0 { freq[parts[0]]++ } }

随后按频率倒序输出表格。实测80万行日志,处理耗时大约0.6秒,比awk的0.4秒稍慢但也完全可用,胜在统一入口和参数记忆成本低。之后我想按URL统计、按状态码统计,只需要加几个flag就行,不用换工具。这个任务启发我:很多"一次性"的运维命令,其实都值得封装成可重复调用的任务,参数化只是多写几行代码的事,长期收益非常可观。

4.2 场景二:批量文件重命名与归档

有一次我从手机导出了三百多张照片,文件名全是IMG_20230101_123456.jpg这种。我要把它们按日期目录归档:2023/01/IMG_20230101_123456.jpg。

命令行里用exiftool能提取拍摄时间,但基本操作逻辑比较复杂。我用CLI-Anything写了个filedate任务,处理规则是:从文件名正则提取日期段,然后按年月创建目录并移动文件。关键代码如下:

re := regexp.MustCompile(`IMG_(\d{4})(\d{2})(\d{2})_`) for _, f := range files { m := re.FindStringSubmatch(f.Name()) if m == nil { continue } dir := filepath.Join(base, m[1], m[2]) os.MkdirAll(dir, 0755) os.Rename(f.Path(), filepath.Join(dir, f.Name())) }

实测下来移动几百个文件只用了几秒。处理这类问题时最需要注意的是:先扫描一遍构建完整清单,再执行移动,确保中途失败也不会留下半个文件。我在任务里加了一行预检查,如果目标目录已存在同名文件,就跳过并在最后统计冲突数,防止意外覆盖。

4.3 场景三:Git 仓库的日常整理

Git仓库时间长了会堆很多本地分支。我写了一个git-clean任务,干三件事:列出已合并到当前分支的本地分支;让用户确认是否删除;执行删除后打印剩余分支列表。

用Go执行git命令不算复杂:

cmd := exec.Command("git", "branch", "--merged") out, _ := cmd.CombinedOutput() lines := strings.Split(strings.TrimSpace(string(out)), "\n")

每行去掉前面的星号和空格,过滤掉master、main、当前分支名,剩下的就是候选删除分支。我特意加了确认环节,因为这种批量操作误删了分支基本没法恢复。实际使用中,这个任务帮我省了很多手工敲git branch -d的时间,而且因为它统一走了CLI-Anything的配置系统,我可以在不同机器上保持完全一致的删除策略,团队里其他同事也能直接复用同一个工作流。

5. 常见问题、排查技巧与性能调优

5.1 高频问题速查表

现象原因解决办法
anything: command not found二进制不在PATH中确认go install后GOBIN是否在PATH,或直接把二进制mv到/usr/local/bin
运行时提示"任务不存在"配置文件名写错或缩进错误执行anything doctor检查配置加载,YAML注意统一用空格缩进
外部脚本任务没有输出脚本缺少可执行权限Linux下执行chmod +x script.sh
输出乱码或颜色丢失终端不支持ANSI颜色配置文件里把theme设为plain
Cobra提示unknown flagflag未注册到对应子命令确认是在runCmd.Flags().StringVar注册而非根命令上

5.2 三个排查思路

第一个思路是"无输出时先看stderr"。我早期调试很多脚本任务时,发现stdout没有内容就去怀疑脚本逻辑,最后发现其实是脚本的报错被Go的cmd.Stdout吞掉了,日志全部进了cmd.Stderr。所以执行外部进程时,我会把stderr和stdout同时接到io.MultiWriter,其中一份写到临时日志文件。这样排查时只要看日志文件就行,不会在管道里消失。

第二个思路是"超时兜底"。外部脚本如果进入死循环,CLI-Anything会把整个终端卡住。我在执行器外层加了context.WithTimeout,默认60秒,超时直接杀掉子进程并返回错误。生产环境里这一条非常重要,否则定时任务挂了都不知道。

第三个思路是"参数传递别用字符串拼接"。早期版本传入外部脚本的命令行参数是用空格拼的,一个文件名里带空格就让整个命令废了。后来全部改成环境变量传递,彻底绕开了shell转义问题。环境变量方式虽然在配置里没那么直观,但足够稳定。

5.3 性能优化的三个关键点

第一个关键点是扫描文件用bufio.Scanner而不是一次性ReadAll。处理大文件时ioutil.ReadFile会占满内存,我统计80万行日志时首版内存占用700MB,换成bufio.Scanner后降到30MB以下。这个差距在日志任务上可能是能不能跑完的区别。

第二个关键点是批量操作一定要做并发控制。我写filedate时同步处理300个文件,耗时极慢。改成worker pool后,10个并发worker,机器上资源消耗非常均匀。并发不是开越多越好,Go里sync.WaitGroup配合带缓冲channel是最经典的风控方式。

第三个关键点是配置文件要加缓存。首次启动加载YAML并解析成结构体,之后所有任务直接复用内存中的配置对象。配置只有几十KB,重新读也不慢,但缓存之后每次调用少做一次磁盘IO和一次解析,长期运行的工具积少成多还是有差别的。

6. 最后想分享的两点体会

实际写完这个项目后,最大的感受是:工具的价值在于长期复用。很多东西第一次写会觉得"网上有现成的,我干嘛要自己写",可真正落地成统一入口后,你会发现每次使用都在为之前的设计决策加杠杆。CLI-Anything的定位是"调度层"而不是"替代层",所以任何你觉得已经有完美命令的活儿,都可以先不内置,而是通过外部脚本方式接进来,用着顺手再考虑升级成内置任务。

另一个心得和工程没直接关系,但做项目时非常重要:尽量保证新机器上一分钟内能跑起来。我玩过一些工具,配置动辄上百行,环境依赖多到崩溃。所以CLI-Anything的配置默认非常精简,核心操作不需要任何配置也能跑。遇到问题先跑anything doctor,把所有环境检查项一次性列出来。这套思路在给团队分享时尤其好用,别人愿意用,工具才真正有生命力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询