diff --git a/README.file.md b/README.file.md
index f1432dd..c8057d4 100644
--- a/README.file.md
+++ b/README.file.md
@@ -1,6 +1,6 @@
# file - 文件查找
-按大小、修改时间、名称查找文件,不用记 `find` 的复杂语法。
+按大小、修改时间、名称查找文件,查找空文件和空目录,不用记 `find` 的复杂语法。
## 命令
@@ -9,6 +9,7 @@
| `kopsc file findbig [路径] [--threshold 大小]` | 查找大于指定阈值的文件,按大小排序 |
| `kopsc file findrecent [路径] [--days 天数]` | 查找最近 N 天内修改过的文件 |
| `kopsc file findname <模式> [路径]` | 按文件名查找(忽略大小写,支持 `*` `?` 通配符) |
+| `kopsc file empty [路径]` | 查找空文件和空目录 |
## 示例
@@ -51,3 +52,25 @@ kopsc file findname "*.CONF"
| --- | --- |
| 第一个位置参数 | 文件名模式(必填) |
| 第二个位置参数 | 搜索路径(默认 `.`) |
+
+### empty
+
+```bash
+# 查找当前目录下的空文件和空目录
+kopsc file empty
+
+# 查找指定目录
+kopsc file empty /var/log
+
+# 只查找空文件
+kopsc file empty --files-only
+
+# 只查找空目录
+kopsc file empty --dirs-only
+```
+
+| 选项 | 说明 |
+| --- | --- |
+| `--files-only` | 只查找空文件 |
+| `--dirs-only` | 只查找空目录 |
+| 位置参数 | 搜索路径(默认 `.`) |
diff --git a/README.md b/README.md
index 2aa62ed..e1a7141 100644
--- a/README.md
+++ b/README.md
@@ -143,18 +143,18 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、
| 分组 | 用途 | 文档 |
| --- | --- | --- |
| **docker** | 容器与镜像:查看、启停、日志、清理、导出/导入、Compose | [README.docker.md](README.docker.md) |
-| **net** | 网络与端口:端口被谁占用、监听端口、ping、DNS、路由追踪、HTTP 诊断 | [README.net.md](README.net.md) |
+| **net** | 网络与端口:端口被谁占用、监听端口、ping、DNS、路由追踪、HTTP 诊断、网卡信息、连接统计、网速测试 | [README.net.md](README.net.md) |
| **disk** | 磁盘与文件占用:哪个目录、哪个文件最占空间 | [README.disk.md](README.disk.md) |
| **diagnose** | 容器 / Pod 状态根因诊断(自动识别 Docker 与 K8s) | [README.diagnose.md](README.diagnose.md) |
-| **proc** | 本机进程:后台运行、等待退出、按资源排序、按名称终止 | [README.proc.md](README.proc.md) |
+| **proc** | 本机进程:后台运行、等待退出、按资源排序、按名称终止、进程树 | [README.proc.md](README.proc.md) |
| **java** | Java 应用:按 JDK 版本套用调优参数并运行 jar | [README.java.md](README.java.md) |
| **git** | Git 历史:按日期范围调整提交时间并强推 | [README.git.md](README.git.md) |
| **pkg** | 系统软件包:刷新包管理器索引 | [README.pkg.md](README.pkg.md) |
-| **user** | 用户管理:授予或移除用户的 root/sudo 权限 | [README.user.md](README.user.md) |
-| **file** | 文件查找:按大小、修改时间、名称查找文件 | [README.file.md](README.file.md) |
+| **user** | 用户管理:授予或移除用户的 root/sudo 权限、查看用户信息 | [README.user.md](README.user.md) |
+| **file** | 文件查找:按大小、修改时间、名称查找文件、查找空文件/空目录 | [README.file.md](README.file.md) |
| **svc** | 服务管理:查看状态、启用/禁用、查看日志 | [README.svc.md](README.svc.md) |
| **archive** | 压缩文件:自动识别格式并解压 | [README.archive.md](README.archive.md) |
-| **sys** | 系统信息:OS、CPU、内存、磁盘一览 | [README.sys.md](README.sys.md) |
+| **sys** | 系统信息:OS、CPU、内存、磁盘一览、运行时间 | [README.sys.md](README.sys.md) |
| **log** | 系统日志:搜索关键字、查看最近日志 | [README.log.md](README.log.md) |
| **fw** | 防火墙:查看状态、开放/关闭端口 | [README.fw.md](README.fw.md) |
@@ -205,6 +205,13 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、
| 看最近日志 | `kopsc log last 100` |
| 看防火墙状态 | `kopsc fw status` |
| 开放/关闭端口 | `kopsc fw allow 8080/tcp --yes` / `kopsc fw deny 8080/tcp --yes` |
+| 看系统运行时间 | `kopsc sys uptime` |
+| 看网卡和 IP | `kopsc net iface` |
+| 看 TCP 连接统计 | `kopsc net conn` |
+| 测网速 | `kopsc net speed` |
+| 看进程树 | `kopsc proc tree` |
+| 找空文件/空目录 | `kopsc file empty /var` |
+| 看用户信息 | `kopsc user info` |
## 平台识别
diff --git a/README.net.md b/README.net.md
index 060f824..5dab4aa 100644
--- a/README.net.md
+++ b/README.net.md
@@ -2,7 +2,7 @@
> 返回 [README.md](README.md)
-查看端口被谁占用、本机监听了哪些端口、测试连通性、DNS 查询、路由追踪、HTTP 诊断。
+查看端口被谁占用、本机监听了哪些端口、测试连通性、DNS 查询、路由追踪、HTTP 诊断、网卡信息、连接统计、网速测试。
```bash
kopsc net pids 8080 # 谁占用了 8080 端口(输出 PID)
@@ -11,6 +11,9 @@ kopsc net ping example.com # 测试连通性
kopsc net trace example.com # 路由追踪
kopsc net dns example.com # DNS 查询
kopsc net curl https://example.com # HTTP 诊断
+kopsc net iface # 查看网卡和 IP 地址
+kopsc net conn # 查看 TCP 连接状态统计
+kopsc net speed # 简单网速测试
```
## 端口占用查询(net.pids)
@@ -123,3 +126,63 @@ TLS 握手: 0.089s
| `-L` / `--follow` | 跟随重定向 |
| `-X` / `--method` | HTTP 方法(GET/POST/PUT 等) |
| `-H` / `--header` | 添加请求头,可重复 |
+
+## 网卡信息(net.iface)
+
+```bash
+kopsc net iface
+```
+
+列出所有非回环网卡的信息:名称、状态(UP/DOWN)、MAC 地址、IPv4/IPv6 地址、MTU。
+
+纯 Go 实现,跨平台一致,不依赖外部命令。
+
+## 连接统计(net.conn)
+
+```bash
+kopsc net conn
+```
+
+统计各状态的 TCP 连接数量,输出示例:
+
+```
+=== TCP 连接统计 (共 108 个) ===
+
+ESTABLISHED 61
+LISTEN 32
+TIME_WAIT 14
+CLOSE_WAIT 1
+```
+
+Linux 优先使用 `ss -tan`(更快),回退到 `netstat -tan`;macOS/Windows 使用 `netstat -tan`。
+
+## 网速测试(net.speed)
+
+```bash
+kopsc net speed # 默认测试 10MB
+kopsc net speed --size 50M # 测试 50MB
+kopsc net speed --download # 只测下载
+kopsc net speed --upload # 只测上传
+```
+
+从 Cloudflare 下载测试文件测量下载速度,向 httpbin.org 上传数据测量上传速度。
+
+输出示例:
+
+```
+=== 网络速度测试 ===
+
+测试下载速度...
+下载速度: 12.5M/s
+
+测试上传速度...
+上传速度: 3.2M/s
+```
+
+### 选项
+
+| 选项 | 说明 |
+| --- | --- |
+| `--size` / `-s` | 测试文件大小(默认 10M,支持 K/M/G 后缀) |
+| `--download` / `-d` | 只测下载 |
+| `--upload` / `-u` | 只测上传 |
diff --git a/README.proc.md b/README.proc.md
index 754267e..edcb8aa 100644
--- a/README.proc.md
+++ b/README.proc.md
@@ -3,7 +3,7 @@
> 返回 [README.md](README.md)
在后台运行命令并写日志,关掉终端也不会退出;也可以等某个进程退出后再继续;
-还能按资源占用排序查看进程、按名称终止进程。
+还能按资源占用排序查看进程、按名称终止进程、查看进程树。
```bash
kopsc proc nohup ./server # 后台运行命令,输出写日志
@@ -11,6 +11,7 @@ kopsc proc wait nginx # 等 nginx 退出后返回
kopsc proc topmem # 内存占用前 10 的进程
kopsc proc topcpu # CPU 占用前 10 的进程
kopsc proc killname nginx --yes # 终止名为 nginx 的进程
+kopsc proc tree # 查看进程树
```
## 后台运行(proc.nohup)
@@ -128,3 +129,13 @@ kopsc proc killname nginx --yes # 确认终止
匹配规则:进程名或命令行中包含指定关键字(忽略大小写)。
> 这是破坏性命令,未加 `--yes` 时只列出匹配进程并以退出码 2 结束。
+
+## 进程树(proc.tree)
+
+```bash
+kopsc proc tree
+```
+
+以树形结构显示进程的父子关系。
+
+Linux/macOS 优先使用 `pstree -p`(最佳输出),回退到 `ps -ejH`(Linux)或 `ps -eo pid,ppid,comm`(macOS);Windows 使用 `tasklist /tree`。
diff --git a/README.sys.md b/README.sys.md
index 47a9c0c..ed0909f 100644
--- a/README.sys.md
+++ b/README.sys.md
@@ -7,9 +7,12 @@
| 命令 | 作用 |
| --- | --- |
| `kopsc sysinfo` | 显示系统信息一览 |
+| `kopsc sys uptime` | 查看系统运行时间和启动时间 |
## 示例
+### sysinfo
+
```bash
kopsc sysinfo
```
@@ -41,6 +44,20 @@ Filesystem Size Used Avail Use% Mounted on
/dev/vda1 50G 22G 28G 44% /
```
+### sys uptime
+
+```bash
+kopsc sys uptime
+```
+
+输出示例:
+
+```
+运行时间: 15 天 3 小时 42 分钟
+启动时间: 2026-10-19 08:30:15
+负载均值: 0.15 0.22 0.18 (1/5/15分钟)
+```
+
## 平台实现
| 信息 | Linux | macOS | Windows |
@@ -49,3 +66,4 @@ Filesystem Size Used Avail Use% Mounted on
| CPU | `/proc/cpuinfo` | `sysctl` | runtime |
| 内存 | `/proc/meminfo` + cgroup | `sysctl hw.memsize` | `wmic` |
| 磁盘 | `df -h` | `df -h` | `wmic logicaldisk` |
+| 运行时间 | `/proc/uptime` | `sysctl kern.boottime` | `wmic os get LastBootUpTime` |
diff --git a/README.user.md b/README.user.md
index 2ac24f5..41714ea 100644
--- a/README.user.md
+++ b/README.user.md
@@ -8,6 +8,7 @@
| --- | --- | --- |
| `kopsc user grant <用户名>` | 授予用户 root/sudo 权限 | Linux: `usermod -aG wheel/sudo <用户名>`
macOS: `dseditgroup -o edit -t user -a <用户名> admin`
Windows: `net localgroup Administrators <用户名> /add` |
| `kopsc user revoke <用户名>` | 移除用户的 root/sudo 权限 | Linux: `gpasswd -d <用户名> wheel/sudo`
macOS: `dseditgroup -o edit -t user -d <用户名> admin`
Windows: `net localgroup Administrators <用户名> /delete` |
+| `kopsc user info [用户名]` | 查看用户详细信息 | Linux/macOS: `id` + `/etc/passwd` + `groups`
Windows: `net user` |
## 用法
@@ -129,3 +130,27 @@ done
**原因**:当前用户没有 sudo/管理员权限
**解决**:使用已有管理员权限的账户执行,或临时使用 `sudo` 前缀
+
+## 查看用户信息(user.info)
+
+```bash
+# 查看当前用户信息
+kopsc user info
+
+# 查看指定用户信息
+kopsc user info alice
+```
+
+输出示例:
+
+```
+用户: uid=501(alice) gid=20(staff) groups=20(staff),80(admin),204(_developer)
+UID: 501
+GID: 20
+描述: Alice Wang
+家目录: /Users/alice
+Shell: /bin/zsh
+组: staff admin _developer
+```
+
+显示用户的 UID、GID、所属组、描述信息、家目录、默认 Shell 等。不指定用户名时显示当前用户信息。
diff --git a/internal/executor/builtin.go b/internal/executor/builtin.go
index b7c6da2..2183bfa 100644
--- a/internal/executor/builtin.go
+++ b/internal/executor/builtin.go
@@ -47,6 +47,7 @@ var builtins = map[string]Builtin{
"docker.save": runDockerSave,
"docker.stats": runDockerStats,
"docker.stop": runDockerStop,
+ "file.empty": runFileEmpty,
"file.findbig": runFileFindBig,
"file.findname": runFileFindName,
"file.findrecent": runFileFindRecent,
@@ -59,23 +60,29 @@ var builtins = map[string]Builtin{
"java.run.preview": runJavaPreview,
"log.last": runLogLast,
"log.search": runLogSearch,
+ "net.conn": runNetConn,
"net.curl": runNetCurl,
"net.dns": runNetDNS,
+ "net.iface": runNetIface,
"net.ping": runNetPing,
"net.pids": runNetPIDs,
"net.ports": runNetPorts,
+ "net.speed": runNetSpeed,
"net.trace": runNetTrace,
"proc.killname": runProcKillName,
"proc.nohup": runNohup,
"proc.topcpu": runProcTopCPU,
"proc.topmem": runProcTopMem,
+ "proc.tree": runProcTree,
"proc.wait": runProcWait,
"svc.disable": runSvcDisable,
"svc.enable": runSvcEnable,
"svc.logs": runSvcLogs,
"svc.status": runSvcStatus,
+ "sys.uptime": runSysUptime,
"sysinfo": runSysInfo,
"user.grant": runUserGrant,
+ "user.info": runUserInfo,
"user.revoke": runUserRevoke,
}
diff --git a/internal/executor/fileempty.go b/internal/executor/fileempty.go
new file mode 100644
index 0000000..a9a87a2
--- /dev/null
+++ b/internal/executor/fileempty.go
@@ -0,0 +1,135 @@
+package executor
+
+import (
+ "context"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+// runFileEmpty implements `kopsc file empty`: find empty files and directories.
+func runFileEmpty(ctx context.Context, req BuiltinRequest) error {
+ opts, err := parseFileEmptyArgs(req.Args)
+ if err != nil {
+ return err
+ }
+
+ root, err := filepath.Abs(opts.path)
+ if err != nil {
+ return fmt.Errorf("无法解析路径 %q: %w", opts.path, err)
+ }
+
+ var emptyFiles []string
+ var emptyDirs []string
+
+ err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return nil
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+
+ if d.IsDir() {
+ // Check if directory is empty
+ entries, err := os.ReadDir(path)
+ if err != nil {
+ return nil
+ }
+ if len(entries) == 0 && path != root {
+ rel, _ := filepath.Rel(root, path)
+ if rel == "" {
+ rel = path
+ }
+ emptyDirs = append(emptyDirs, rel)
+ }
+ return nil
+ }
+
+ // Check if file is empty
+ info, err := d.Info()
+ if err != nil {
+ return nil
+ }
+ if info.Size() == 0 {
+ rel, _ := filepath.Rel(root, path)
+ if rel == "" {
+ rel = path
+ }
+ emptyFiles = append(emptyFiles, rel)
+ }
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+
+ sort.Strings(emptyFiles)
+ sort.Strings(emptyDirs)
+
+ if !opts.dirsOnly {
+ if len(emptyFiles) == 0 {
+ fmt.Fprintln(req.Stdout, "没有空文件")
+ } else {
+ fmt.Fprintf(req.Stdout, "=== 空文件 (%d 个) ===\n", len(emptyFiles))
+ for _, f := range emptyFiles {
+ fmt.Fprintln(req.Stdout, f)
+ }
+ fmt.Fprintln(req.Stdout)
+ }
+ }
+
+ if !opts.filesOnly {
+ if len(emptyDirs) == 0 {
+ fmt.Fprintln(req.Stdout, "没有空目录")
+ } else {
+ fmt.Fprintf(req.Stdout, "=== 空目录 (%d 个) ===\n", len(emptyDirs))
+ for _, d := range emptyDirs {
+ fmt.Fprintln(req.Stdout, d)
+ }
+ }
+ }
+
+ return nil
+}
+
+type fileEmptyOptions struct {
+ path string
+ filesOnly bool
+ dirsOnly bool
+}
+
+func parseFileEmptyArgs(args []string) (fileEmptyOptions, error) {
+ opts := fileEmptyOptions{path: "."}
+ var paths []string
+
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ switch {
+ case arg == "--":
+ paths = append(paths, args[i+1:]...)
+ i = len(args)
+ case arg == "--files-only":
+ opts.filesOnly = true
+ case arg == "--dirs-only":
+ opts.dirsOnly = true
+ case strings.HasPrefix(arg, "-") && arg != "-":
+ return opts, fmt.Errorf("未知选项 %q", arg)
+ default:
+ paths = append(paths, arg)
+ }
+ }
+ if len(paths) > 1 {
+ return opts, fmt.Errorf("一次只支持一个路径,收到 %d 个", len(paths))
+ }
+ if len(paths) == 1 {
+ opts.path = paths[0]
+ }
+ if opts.filesOnly && opts.dirsOnly {
+ return opts, fmt.Errorf("--files-only 和 --dirs-only 不能同时使用")
+ }
+ return opts, nil
+}
diff --git a/internal/executor/netconn.go b/internal/executor/netconn.go
new file mode 100644
index 0000000..9cdd5ba
--- /dev/null
+++ b/internal/executor/netconn.go
@@ -0,0 +1,125 @@
+package executor
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "os/exec"
+ "runtime"
+ "sort"
+ "strings"
+)
+
+// runNetConn implements `kopsc net conn`: show network connection statistics.
+func runNetConn(ctx context.Context, req BuiltinRequest) error {
+ switch runtime.GOOS {
+ case "linux":
+ return netConnLinux(ctx, req)
+ case "darwin":
+ return netConnDarwin(ctx, req)
+ case "windows":
+ return netConnWindows(ctx, req)
+ default:
+ return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS)
+ }
+}
+
+func netConnLinux(ctx context.Context, req BuiltinRequest) error {
+ // Try ss first (faster)
+ if path, err := exec.LookPath("ss"); err == nil {
+ return netConnSS(ctx, path, req)
+ }
+ // Fallback to netstat
+ return netConnNetstat(ctx, req)
+}
+
+func netConnSS(ctx context.Context, path string, req BuiltinRequest) error {
+ out, err := exec.CommandContext(ctx, path, "-tan").Output()
+ if err != nil {
+ return fmt.Errorf("ss 执行失败: %w", err)
+ }
+ return parseNetstatOutput(string(out), req)
+}
+
+func netConnNetstat(ctx context.Context, req BuiltinRequest) error {
+ out, err := exec.CommandContext(ctx, "netstat", "-tan").Output()
+ if err != nil {
+ return fmt.Errorf("netstat 执行失败: %w", err)
+ }
+ return parseNetstatOutput(string(out), req)
+}
+
+func netConnDarwin(ctx context.Context, req BuiltinRequest) error {
+ out, err := exec.CommandContext(ctx, "netstat", "-tan").Output()
+ if err != nil {
+ return fmt.Errorf("netstat 执行失败: %w", err)
+ }
+ return parseNetstatOutput(string(out), req)
+}
+
+func netConnWindows(ctx context.Context, req BuiltinRequest) error {
+ out, err := exec.CommandContext(ctx, "netstat", "-an").Output()
+ if err != nil {
+ return fmt.Errorf("netstat 执行失败: %w", err)
+ }
+ return parseNetstatOutput(string(out), req)
+}
+
+func parseNetstatOutput(output string, req BuiltinRequest) error {
+ stats := make(map[string]int)
+ total := 0
+
+ scanner := bufio.NewScanner(strings.NewReader(output))
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ continue
+ }
+ fields := strings.Fields(line)
+ if len(fields) < 4 {
+ continue
+ }
+
+ // Detect state column (usually last for TCP)
+ state := fields[len(fields)-1]
+ if isConnectionState(state) {
+ stats[state]++
+ total++
+ }
+ }
+
+ if total == 0 {
+ fmt.Fprintln(req.Stdout, "没有活动的 TCP 连接")
+ return nil
+ }
+
+ fmt.Fprintf(req.Stdout, "=== TCP 连接统计 (共 %d 个) ===\n\n", total)
+
+ // Sort by count descending
+ type stateCount struct {
+ state string
+ count int
+ }
+ var sorted []stateCount
+ for state, count := range stats {
+ sorted = append(sorted, stateCount{state, count})
+ }
+ sort.Slice(sorted, func(i, j int) bool {
+ return sorted[i].count > sorted[j].count
+ })
+
+ for _, sc := range sorted {
+ fmt.Fprintf(req.Stdout, "%-15s %d\n", sc.state, sc.count)
+ }
+
+ return nil
+}
+
+func isConnectionState(s string) bool {
+ switch s {
+ case "ESTABLISHED", "SYN_SENT", "SYN_RECV", "FIN_WAIT1", "FIN_WAIT2",
+ "TIME_WAIT", "CLOSE", "CLOSE_WAIT", "LAST_ACK", "LISTEN", "CLOSING":
+ return true
+ }
+ return false
+}
diff --git a/internal/executor/netiface.go b/internal/executor/netiface.go
new file mode 100644
index 0000000..4aacdeb
--- /dev/null
+++ b/internal/executor/netiface.go
@@ -0,0 +1,50 @@
+package executor
+
+import (
+ "context"
+ "fmt"
+ "net"
+)
+
+// runNetIface implements `kopsc net iface`: show network interfaces and IPs.
+func runNetIface(ctx context.Context, req BuiltinRequest) error {
+ ifaces, err := net.Interfaces()
+ if err != nil {
+ return fmt.Errorf("获取网卡列表失败: %w", err)
+ }
+
+ for _, iface := range ifaces {
+ // Skip loopback if not requested
+ if iface.Flags&net.FlagLoopback != 0 {
+ continue
+ }
+
+ // Status
+ status := "DOWN"
+ if iface.Flags&net.FlagUp != 0 {
+ status = "UP"
+ }
+
+ fmt.Fprintf(req.Stdout, "%s: <%s> mtu %d\n", iface.Name, status, iface.MTU)
+
+ // MAC address
+ if len(iface.HardwareAddr) > 0 {
+ fmt.Fprintf(req.Stdout, " MAC: %s\n", iface.HardwareAddr.String())
+ }
+
+ // IP addresses
+ addrs, err := iface.Addrs()
+ if err != nil {
+ continue
+ }
+ for _, addr := range addrs {
+ fmt.Fprintf(req.Stdout, " IP: %s\n", addr.String())
+ }
+
+ // Flags
+ fmt.Fprintf(req.Stdout, " 标志: %s\n", iface.Flags.String())
+ fmt.Fprintln(req.Stdout)
+ }
+
+ return nil
+}
diff --git a/internal/executor/netspeed.go b/internal/executor/netspeed.go
new file mode 100644
index 0000000..6818fee
--- /dev/null
+++ b/internal/executor/netspeed.go
@@ -0,0 +1,168 @@
+package executor
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// runNetSpeed implements `kopsc net speed`: simple network speed test.
+func runNetSpeed(ctx context.Context, req BuiltinRequest) error {
+ opts, err := parseNetSpeedArgs(req.Args)
+ if err != nil {
+ return err
+ }
+
+ fmt.Fprint(req.Stdout, "=== 网络速度测试 ===\n")
+
+ // Download test
+ if !opts.uploadOnly {
+ fmt.Fprintln(req.Stdout, "测试下载速度...")
+ downloadSpeed, err := testDownload(ctx, opts.size)
+ if err != nil {
+ fmt.Fprintf(req.Stderr, "下载测试失败: %v\n", err)
+ } else {
+ fmt.Fprintf(req.Stdout, "下载速度: %s/s\n", humanSize(downloadSpeed))
+ }
+ fmt.Fprintln(req.Stdout)
+ }
+
+ // Upload test
+ if !opts.downloadOnly {
+ fmt.Fprintln(req.Stdout, "测试上传速度...")
+ uploadSpeed, err := testUpload(ctx, opts.size)
+ if err != nil {
+ fmt.Fprintf(req.Stderr, "上传测试失败: %v\n", err)
+ } else {
+ fmt.Fprintf(req.Stdout, "上传速度: %s/s\n", humanSize(uploadSpeed))
+ }
+ }
+
+ return nil
+}
+
+type netSpeedOptions struct {
+ size int64
+ downloadOnly bool
+ uploadOnly bool
+}
+
+func parseNetSpeedArgs(args []string) (netSpeedOptions, error) {
+ opts := netSpeedOptions{size: 10 * 1024 * 1024} // 10MB default
+
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ switch {
+ case arg == "--":
+ remaining := args[i+1:]
+ if len(remaining) > 0 {
+ n, err := parseSizeArg(remaining[0])
+ if err != nil {
+ return opts, fmt.Errorf("大小参数无效: %w", err)
+ }
+ opts.size = n
+ }
+ i = len(args)
+ case arg == "--size" || arg == "-s":
+ if i+1 >= len(args) {
+ return opts, fmt.Errorf("%s 缺少大小参数", arg)
+ }
+ i++
+ n, err := parseSizeArg(args[i])
+ if err != nil {
+ return opts, fmt.Errorf("--size: %w", err)
+ }
+ opts.size = n
+ case strings.HasPrefix(arg, "--size="):
+ n, err := parseSizeArg(strings.TrimPrefix(arg, "--size="))
+ if err != nil {
+ return opts, fmt.Errorf("--size: %w", err)
+ }
+ opts.size = n
+ case arg == "--download" || arg == "-d":
+ opts.downloadOnly = true
+ case arg == "--upload" || arg == "-u":
+ opts.uploadOnly = true
+ default:
+ return opts, fmt.Errorf("未知选项 %q", arg)
+ }
+ }
+
+ if opts.downloadOnly && opts.uploadOnly {
+ return opts, fmt.Errorf("--download 和 --upload 不能同时使用")
+ }
+
+ return opts, nil
+}
+
+func testDownload(ctx context.Context, size int64) (int64, error) {
+ // Use Cloudflare's speed test endpoint
+ url := fmt.Sprintf("https://speed.cloudflare.com/__down?bytes=%d", size)
+
+ client := &http.Client{Timeout: 30 * time.Second}
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return 0, err
+ }
+
+ start := time.Now()
+ resp, err := client.Do(req)
+ if err != nil {
+ return 0, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return 0, fmt.Errorf("HTTP %d", resp.StatusCode)
+ }
+
+ n, err := io.Copy(io.Discard, resp.Body)
+ if err != nil {
+ return 0, err
+ }
+
+ elapsed := time.Since(start).Seconds()
+ if elapsed == 0 {
+ elapsed = 0.001
+ }
+ return int64(float64(n) / elapsed), nil
+}
+
+func testUpload(ctx context.Context, size int64) (int64, error) {
+ // Use httpbin.org for upload test
+ url := "https://httpbin.org/post"
+
+ // Create a reader that generates data on the fly
+ data := make([]byte, size)
+ for i := range data {
+ data[i] = 'A'
+ }
+
+ client := &http.Client{Timeout: 30 * time.Second}
+ req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(data)))
+ if err != nil {
+ return 0, err
+ }
+ req.Header.Set("Content-Type", "application/octet-stream")
+
+ start := time.Now()
+ resp, err := client.Do(req)
+ if err != nil {
+ return 0, err
+ }
+ defer resp.Body.Close()
+ io.Copy(io.Discard, resp.Body)
+
+ if resp.StatusCode != http.StatusOK {
+ return 0, fmt.Errorf("HTTP %d", resp.StatusCode)
+ }
+
+ elapsed := time.Since(start).Seconds()
+ if elapsed == 0 {
+ elapsed = 0.001
+ }
+ return int64(float64(size) / elapsed), nil
+}
diff --git a/internal/executor/proctree.go b/internal/executor/proctree.go
new file mode 100644
index 0000000..d929673
--- /dev/null
+++ b/internal/executor/proctree.go
@@ -0,0 +1,51 @@
+package executor
+
+import (
+ "context"
+ "fmt"
+ "os/exec"
+ "runtime"
+)
+// runProcTree implements `kopsc proc tree`: show process tree.
+func runProcTree(ctx context.Context, req BuiltinRequest) error {
+ switch runtime.GOOS {
+ case "linux", "darwin":
+ return procTreeUnix(ctx, req)
+ case "windows":
+ return procTreeWindows(ctx, req)
+ default:
+ return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS)
+ }
+}
+
+func procTreeUnix(ctx context.Context, req BuiltinRequest) error {
+ // Try pstree first (best output)
+ if path, err := exec.LookPath("pstree"); err == nil {
+ cmd := exec.CommandContext(ctx, path, "-p")
+ cmd.Stdout = req.Stdout
+ cmd.Stderr = req.Stderr
+ return cmd.Run()
+ }
+
+ // Fallback: use ps with PPID and format a simple tree
+ // macOS doesn't support -H, so we use a different approach
+ if runtime.GOOS == "darwin" {
+ cmd := exec.CommandContext(ctx, "ps", "-eo", "pid,ppid,comm")
+ cmd.Stdout = req.Stdout
+ cmd.Stderr = req.Stderr
+ return cmd.Run()
+ }
+
+ // Linux fallback: ps with hierarchical output
+ cmd := exec.CommandContext(ctx, "ps", "-ejH")
+ cmd.Stdout = req.Stdout
+ cmd.Stderr = req.Stderr
+ return cmd.Run()
+}
+
+func procTreeWindows(ctx context.Context, req BuiltinRequest) error {
+ cmd := exec.CommandContext(ctx, "tasklist", "/tree")
+ cmd.Stdout = req.Stdout
+ cmd.Stderr = req.Stderr
+ return cmd.Run()
+}
diff --git a/internal/executor/sysuptime.go b/internal/executor/sysuptime.go
new file mode 100644
index 0000000..34c05fd
--- /dev/null
+++ b/internal/executor/sysuptime.go
@@ -0,0 +1,133 @@
+package executor
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "runtime"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// runSysUptime implements `kopsc sys uptime`: show system uptime.
+func runSysUptime(ctx context.Context, req BuiltinRequest) error {
+ switch runtime.GOOS {
+ case "linux":
+ return sysUptimeLinux(req)
+ case "darwin":
+ return sysUptimeDarwin(ctx, req)
+ case "windows":
+ return sysUptimeWindows(ctx, req)
+ default:
+ return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS)
+ }
+}
+
+func sysUptimeLinux(req BuiltinRequest) error {
+ data, err := os.ReadFile("/proc/uptime")
+ if err != nil {
+ return fmt.Errorf("读取 /proc/uptime 失败: %w", err)
+ }
+ fields := strings.Fields(string(data))
+ if len(fields) < 1 {
+ return fmt.Errorf("/proc/uptime 格式异常")
+ }
+ seconds, err := strconv.ParseFloat(fields[0], 64)
+ if err != nil {
+ return fmt.Errorf("解析 uptime 失败: %w", err)
+ }
+
+ bootTime := time.Now().Add(-time.Duration(seconds) * time.Second)
+ fmt.Fprintf(req.Stdout, "运行时间: %s\n", formatDuration(time.Duration(seconds)*time.Second))
+ fmt.Fprintf(req.Stdout, "启动时间: %s\n", bootTime.Format("2006-01-02 15:04:05"))
+
+ // Load average
+ if len(fields) >= 2 {
+ idle, _ := strconv.ParseFloat(fields[1], 64)
+ fmt.Fprintf(req.Stdout, "空闲时间: %s\n", formatDuration(time.Duration(idle)*time.Second))
+ }
+
+ // Load average from /proc/loadavg
+ if loadavg, err := os.ReadFile("/proc/loadavg"); err == nil {
+ parts := strings.Fields(string(loadavg))
+ if len(parts) >= 3 {
+ fmt.Fprintf(req.Stdout, "负载均值: %s %s %s (1/5/15分钟)\n", parts[0], parts[1], parts[2])
+ }
+ }
+ return nil
+}
+
+func sysUptimeDarwin(ctx context.Context, req BuiltinRequest) error {
+ out, err := exec.CommandContext(ctx, "sysctl", "-n", "kern.boottime").Output()
+ if err != nil {
+ return fmt.Errorf("获取启动时间失败: %w", err)
+ }
+ // Output: { sec = 1234567890, usec = 123456 } Mon Jan 01 00:00:00 2024
+ line := string(out)
+ secStart := strings.Index(line, "sec = ")
+ if secStart < 0 {
+ return fmt.Errorf("无法解析 kern.boottime: %s", line)
+ }
+ secStr := strings.TrimSpace(line[secStart+6:])
+ if idx := strings.Index(secStr, ","); idx >= 0 {
+ secStr = secStr[:idx]
+ }
+ sec, err := strconv.ParseInt(secStr, 10, 64)
+ if err != nil {
+ return fmt.Errorf("解析启动时间失败: %w", err)
+ }
+
+ bootTime := time.Unix(sec, 0)
+ uptime := time.Since(bootTime)
+ fmt.Fprintf(req.Stdout, "运行时间: %s\n", formatDuration(uptime))
+ fmt.Fprintf(req.Stdout, "启动时间: %s\n", bootTime.Format("2006-01-02 15:04:05"))
+
+ // Load average
+ if out, err := exec.CommandContext(ctx, "sysctl", "-n", "vm.loadavg").Output(); err == nil {
+ fmt.Fprintf(req.Stdout, "负载均值: %s\n", strings.TrimSpace(string(out)))
+ }
+ return nil
+}
+
+func sysUptimeWindows(ctx context.Context, req BuiltinRequest) error {
+ // Use wmic to get boot time
+ out, err := exec.CommandContext(ctx, "wmic", "os", "get", "LastBootUpTime", "/value").Output()
+ if err != nil {
+ return fmt.Errorf("获取启动时间失败: %w", err)
+ }
+ line := strings.TrimSpace(string(out))
+ // LastBootUpTime=20240101000000.000000+480
+ if !strings.HasPrefix(line, "LastBootUpTime=") {
+ return fmt.Errorf("无法解析 wmic 输出: %s", line)
+ }
+ timeStr := strings.TrimPrefix(line, "LastBootUpTime=")
+ // Parse: 20240101000000.000000+480
+ if len(timeStr) < 14 {
+ return fmt.Errorf("时间格式异常: %s", timeStr)
+ }
+ bootTime, err := time.Parse("20060102150405", timeStr[:14])
+ if err != nil {
+ return fmt.Errorf("解析时间失败: %w", err)
+ }
+ uptime := time.Since(bootTime)
+ fmt.Fprintf(req.Stdout, "运行时间: %s\n", formatDuration(uptime))
+ fmt.Fprintf(req.Stdout, "启动时间: %s\n", bootTime.Format("2006-01-02 15:04:05"))
+ return nil
+}
+
+// formatDuration formats a duration into a human-readable string.
+func formatDuration(d time.Duration) string {
+ days := int(d.Hours()) / 24
+ hours := int(d.Hours()) % 24
+ minutes := int(d.Minutes()) % 60
+
+ if days > 0 {
+ return fmt.Sprintf("%d 天 %d 小时 %d 分钟", days, hours, minutes)
+ }
+ if hours > 0 {
+ return fmt.Sprintf("%d 小时 %d 分钟", hours, minutes)
+ }
+ return fmt.Sprintf("%d 分钟", minutes)
+}
diff --git a/internal/executor/userinfo.go b/internal/executor/userinfo.go
new file mode 100644
index 0000000..c111164
--- /dev/null
+++ b/internal/executor/userinfo.go
@@ -0,0 +1,78 @@
+package executor
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "os/user"
+ "runtime"
+ "strings"
+)
+
+// runUserInfo implements `kopsc user info`: show user details.
+func runUserInfo(ctx context.Context, req BuiltinRequest) error {
+ var username string
+ if len(req.Args) == 0 {
+ // Current user
+ u, err := user.Current()
+ if err != nil {
+ return fmt.Errorf("获取当前用户失败: %w", err)
+ }
+ username = u.Username
+ } else {
+ username = req.Args[0]
+ }
+
+ switch runtime.GOOS {
+ case "linux", "darwin":
+ return userInfoUnix(ctx, username, req)
+ case "windows":
+ return userInfoWindows(ctx, username, req)
+ default:
+ return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS)
+ }
+}
+
+func userInfoUnix(ctx context.Context, username string, req BuiltinRequest) error {
+ // Use id command for comprehensive info
+ cmd := exec.CommandContext(ctx, "id", username)
+ out, err := cmd.Output()
+ if err != nil {
+ return fmt.Errorf("用户 %q 不存在或 id 命令失败: %w", username, err)
+ }
+ fmt.Fprintf(req.Stdout, "用户: %s\n", strings.TrimSpace(string(out)))
+
+ // Get home directory and shell from /etc/passwd
+ if data, err := os.ReadFile("/etc/passwd"); err == nil {
+ for _, line := range strings.Split(string(data), "\n") {
+ fields := strings.Split(line, ":")
+ if len(fields) >= 7 && fields[0] == username {
+ fmt.Fprintf(req.Stdout, "UID: %s\n", fields[2])
+ fmt.Fprintf(req.Stdout, "GID: %s\n", fields[3])
+ fmt.Fprintf(req.Stdout, "描述: %s\n", fields[4])
+ fmt.Fprintf(req.Stdout, "家目录: %s\n", fields[5])
+ fmt.Fprintf(req.Stdout, "Shell: %s\n", fields[6])
+ break
+ }
+ }
+ }
+
+ // Groups
+ groupsCmd := exec.CommandContext(ctx, "groups", username)
+ if groupsOut, err := groupsCmd.Output(); err == nil {
+ fmt.Fprintf(req.Stdout, "组: %s\n", strings.TrimSpace(string(groupsOut)))
+ }
+
+ return nil
+}
+
+func userInfoWindows(ctx context.Context, username string, req BuiltinRequest) error {
+ cmd := exec.CommandContext(ctx, "net", "user", username)
+ out, err := cmd.Output()
+ if err != nil {
+ return fmt.Errorf("用户 %q 不存在或 net user 失败: %w", username, err)
+ }
+ fmt.Fprintln(req.Stdout, string(out))
+ return nil
+}
diff --git a/internal/registry/builtin.go b/internal/registry/builtin.go
index b09b9db..a152d1c 100644
--- a/internal/registry/builtin.go
+++ b/internal/registry/builtin.go
@@ -836,6 +836,77 @@ func Builtin() *Registry {
RequiresYes: true,
Variants: threePlatformBuiltins("fw.deny"),
},
+
+ // ----- sys additions -----
+ {
+ ID: "sys.uptime",
+ Group: "sys",
+ Summary: "查看系统运行时间和启动时间",
+ Action: "显示系统已运行多长时间、启动时间、负载均值。" +
+ "Linux: /proc/uptime + /proc/loadavg;macOS: sysctl kern.boottime;" +
+ "Windows: wmic os get LastBootUpTime",
+ Variants: threePlatformBuiltins("sys.uptime"),
+ },
+
+ // ----- net additions -----
+ {
+ ID: "net.iface",
+ Group: "net",
+ Summary: "查看网卡信息和 IP 地址",
+ Action: "列出所有非回环网卡的信息:名称、状态、MAC 地址、IPv4/IPv6 地址、MTU。" +
+ "纯 Go 实现,跨平台一致",
+ Variants: threePlatformBuiltins("net.iface"),
+ },
+ {
+ ID: "net.conn",
+ Group: "net",
+ Summary: "查看 TCP 连接状态统计",
+ Action: "统计各状态的 TCP 连接数量(ESTABLISHED、TIME_WAIT、CLOSE_WAIT 等)。" +
+ "Linux: ss -s 或 netstat;macOS: netstat;Windows: netstat",
+ Variants: threePlatformBuiltins("net.conn"),
+ },
+ {
+ ID: "net.speed",
+ Group: "net",
+ Summary: "简单网速测试(下载/上传)",
+ Action: "从 Cloudflare 下载测试文件测量下载速度,向 httpbin.org 上传数据测量上传速度。" +
+ "默认测试 10MB,--size 可指定大小,--download 或 --upload 可单独测试",
+ Usage: "[--size 大小] [--download] [--upload]",
+ Variants: threePlatformBuiltins("net.speed"),
+ },
+
+ // ----- proc additions -----
+ {
+ ID: "proc.tree",
+ Group: "proc",
+ Summary: "查看进程树(父子关系)",
+ Action: "以树形结构显示进程的父子关系。" +
+ "Linux/macOS: pstree -p 或 ps -ejH;Windows: tasklist /tree",
+ Variants: threePlatformBuiltins("proc.tree"),
+ },
+
+ // ----- file additions -----
+ {
+ ID: "file.empty",
+ Group: "file",
+ Summary: "查找空文件和空目录",
+ Action: "递归扫描目录,找出大小为 0 的文件和没有任何内容的目录。" +
+ "--files-only 只查找空文件,--dirs-only 只查找空目录",
+ Usage: "[路径] [--files-only] [--dirs-only]",
+ Variants: threePlatformBuiltins("file.empty"),
+ },
+
+ // ----- user additions -----
+ {
+ ID: "user.info",
+ Group: "user",
+ Summary: "查看用户详细信息",
+ Action: "显示用户的 UID、GID、组、家目录、Shell 等信息。" +
+ "不指定用户名时显示当前用户。" +
+ "Linux/macOS: id + /etc/passwd + groups;Windows: net user",
+ Usage: "[用户名]",
+ Variants: threePlatformBuiltins("user.info"),
+ },
}}
}
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go
index b637438..bfd9972 100644
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -35,6 +35,11 @@ func TestBuiltinIDsStable(t *testing.T) {
"net.ping", "net.trace", "net.dns", "net.curl",
"log.search", "log.last",
"fw.status", "fw.allow", "fw.deny",
+ "sys.uptime",
+ "net.iface", "net.conn", "net.speed",
+ "proc.tree",
+ "file.empty",
+ "user.info",
}
if got := reg.IDs(); !reflect.DeepEqual(got, want) {
t.Fatalf("IDs() =\n%v\nwant\n%v", got, want)