From 905cc6a1025f5d9e3486b42287e0e90f5dbb9e9d Mon Sep 17 00:00:00 2001 From: Odboy Date: Sun, 1 Nov 2026 08:48:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A5=E5=85=A8=20explain=20?= =?UTF-8?q?=E7=9A=84=E8=AF=B4=E6=98=8E=E4=B8=8E=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 帮助补齐:解释变体机制、命令名两种写法(点号/空格)、别名与分组名、 输出各项含义、打分规则、退出码,并给出示例 - 支持 `kopsc explain docker clearsafe` 空格写法;未知命令改为列出全部 可用命令并提示写法,不再只说「未知命令」 - 单命令输出补上别名、用法、破坏性提示;候选列表分「执行/说明」两行 - 新增分组模式 `kopsc explain net`,表格汇总该组各命令的匹配结果 - list 与分组表格改用显示宽度对齐,修正中文表头错位 --- README.md | 2 + internal/cli/cli.go | 227 ++++++++++++++++++++++++++++++++------- internal/cli/cli_test.go | 109 ++++++++++++++++++- 3 files changed, 297 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 45ce99e..c3e28cc 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ kopsc proc nohup ./server # 后台运行命令,输出写日志( kopsc list # 全部命令及当前平台可用性 kopsc explain docker.clearsafe # 匹配过程、全部候选及各自被选中/拒绝的原因 +kopsc explain docker clearsafe # 同上,空格写法也支持 +kopsc explain net # 只看分组:列出该组每条命令在本平台的匹配结果 kopsc platform # 探测到的 OS/ARCH/发行版/运行时 kopsc version # 版本与平台 ``` diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3bd804e..0868078 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -7,7 +7,6 @@ import ( "io" "os" "strings" - "text/tabwriter" "github.com/spf13/cobra" @@ -413,8 +412,7 @@ func (a *App) newListCmd() *cobra.Command { Short: "列出全部命令及其在当前平台的可用性", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - tw := tabwriter.NewWriter(a.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "命令\t状态\t将执行") + rows := [][]string{{"命令", "状态", "将执行"}} for i := range a.Registry.Commands { entry := &a.Registry.Commands[i] status, planned := "可用", "-" @@ -423,9 +421,10 @@ func (a *App) newListCmd() *cobra.Command { } else { status = "不可用" } - fmt.Fprintf(tw, "%s\t%s\t%s\n", entry.ID, status, planned) + rows = append(rows, []string{entry.ID, status, planned}) } - return tw.Flush() + writeColumns(a.Stdout, rows) + return nil }, } } @@ -434,45 +433,197 @@ func (a *App) newExplainCmd() *cobra.Command { return &cobra.Command{ Use: "explain <命令>", Short: "解释某条命令在当前平台的匹配过程", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - entry, ok := a.Registry.Lookup(args[0]) - if !ok { - return fmt.Errorf("未知命令 %q", args[0]) - } + Long: `解释 kopsc 为什么在当前平台选择了某个底层命令。 - w := a.Stdout - fmt.Fprintf(w, "命令: %s\n", entry.ID) - fmt.Fprintf(w, "说明: %s\n", entry.Summary) - fmt.Fprintf(w, "动作: %s\n", entry.Action) - fmt.Fprintf(w, "平台: %s\n\n", resolve.DescribeInfo(a.Info)) +kopsc 的每条命令都由若干「变体」组成,每个变体声明它适用的环境 +(操作系统、发行版、CPU 架构、容器运行时)。explain 会打印当前探测到的 +平台信息、命中的变体及其得分,并列出全部候选以及每个候选被选中或被排除的原因, +方便排查“为什么执行的是这条命令”“为什么本平台不可用”。 - result, err := resolve.Resolve(entry, a.Info) - if err != nil { - fmt.Fprintf(w, "结果: 当前平台不可用 — %v\n\n", err) - } else { - fmt.Fprintf(w, "结果: 命中变体 #%d(%d 分)→ %s\n\n", - result.VariantIdx, result.Score, variantDisplay(result.Variant)) - } +命令名有两种写法,等价: + kopsc explain docker.clearsafe 点号分隔的完整 ID + kopsc explain docker clearsafe 命令行中的写法 - fmt.Fprintln(w, "候选:") - for _, c := range result.Candidates { - mark := "✗" - if c.Matched { - mark = "✓" - } - note := "" - if c.Variant.Note != "" { - note = " # " + c.Variant.Note - } - fmt.Fprintf(w, " %s #%d %d 分 %s -> %s%s\n", - mark, c.Index, c.Score, c.Reason, variantDisplay(c.Variant), note) - } - return nil - }, +也可以使用别名(如 docker.prunesafe),或只写分组名查看该组内匹配较好的命令 +(如 kopsc explain docker)。 + +输出各项含义: + 命令 / 说明 / 动作 该命令的用途与安全约定 + 平台 当前探测结果:系统/架构、发行版、已安装的运行时 + 结果 命中的变体序号、得分与最终要执行的命令 + 候选 全部变体:✓ 为满足约束并参与比较,✗ 为被排除 + 得分 发行版精确命中 +3、ID_LIKE 命中 +2、运行时命中 +2, + 通用变体 0 分;同分时按运行时偏好与约束数量取舍 + +退出码:找到命令即返回 0(即使本平台不可用,也会说明原因);命令名不存在返回 1。`, + Example: ` kopsc explain docker.clearsafe 查看清理命令在本平台为什么这样执行 + kopsc explain docker clearsafe 同上,与命令行写法一致 + kopsc explain disk.usage 查看磁盘占用命令的候选 + kopsc explain docker 查看 docker 分组内各命令的匹配情况`, + Args: a.explainArgs, + RunE: a.runExplain, } } +// explainArgs accepts the command name in either its dotted (docker.clearsafe) +// or spaced (docker clearsafe) form. +func (a *App) explainArgs(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("缺少命令名;用法: %s <命令>,例如 %s docker.clearsafe(可先用 kopsc list 查看全部命令)", + cmd.CommandPath(), cmd.CommandPath()) + } + return nil +} + +// resolveExplainTarget maps the arguments to a registry entry. A full dotted or +// spaced name wins; otherwise the arguments are treated as a command prefix, so +// `kopsc explain docker` explains every command in that group. +func (a *App) resolveExplainTarget(args []string) (*registry.Command, string, bool) { + joined := strings.Join(args, " ") + for _, name := range []string{joined, strings.Join(args, ".")} { + if cmd, ok := a.Registry.Lookup(name); ok { + return cmd, "", true + } + } + return nil, joined, false +} + +// explainGroup returns the commands whose ID starts with the given prefix, for +// `kopsc explain <分组名>`. +func (a *App) explainGroup(prefix string) []*registry.Command { + want := strings.TrimSuffix(prefix, ".") + "." + var out []*registry.Command + for i := range a.Registry.Commands { + if strings.HasPrefix(a.Registry.Commands[i].ID, want) { + out = append(out, &a.Registry.Commands[i]) + } + } + return out +} + +// explainCommands summarises a group: one line per command with the variant it +// would run on this platform. +func (a *App) explainCommands(cmds []*registry.Command) error { + w := a.Stdout + fmt.Fprintf(w, "分组 %q 下共 %d 条命令,平台: %s\n\n", strings.Split(cmds[0].ID, ".")[0], len(cmds), resolve.DescribeInfo(a.Info)) + + rows := [][]string{{"命令", "状态", "将执行", "说明"}} + for _, cmd := range cmds { + status, planned := "可用", "-" + if result, err := resolve.Resolve(cmd, a.Info); err == nil { + planned = variantDisplay(result.Variant) + } else { + status = "不可用" + } + rows = append(rows, []string{cmd.ID, status, planned, cmd.Summary}) + } + writeColumns(w, rows) + fmt.Fprintf(w, "\n查看单条命令的匹配过程:kopsc explain %s\n", cmds[0].ID) + return nil +} + +func (a *App) runExplain(cmd *cobra.Command, args []string) error { + w := a.Stdout + entry, joined, ok := a.resolveExplainTarget(args) + if !ok { + if group := a.explainGroup(joined); len(group) > 0 { + return a.explainCommands(group) + } + ids := a.Registry.IDs() + fmt.Fprintf(w, "没有名为 %q 的命令。\n\n", joined) + fmt.Fprintln(w, "可用的命令:") + for _, id := range ids { + fmt.Fprintf(w, " %s\n", id) + } + fmt.Fprintln(w, "\n提示:命令名可写点号形式(docker.clearsafe)或空格形式(docker clearsafe),可用 kopsc list 查看。") + a.exitCode = 1 + return nil + } + + fmt.Fprintf(w, "命令: %s\n", entry.ID) + if len(entry.Aliases) > 0 { + fmt.Fprintf(w, "别名: %s\n", strings.Join(entry.Aliases, ", ")) + } + fmt.Fprintf(w, "说明: %s\n", entry.Summary) + fmt.Fprintf(w, "动作: %s\n", entry.Action) + if entry.Usage != "" { + fmt.Fprintf(w, "用法: %s %s\n", entry.Invocation(), entry.Usage) + } + if entry.RequiresYes { + fmt.Fprintln(w, "确认: 破坏性命令,未加 --yes 时只列出将影响的对象并以退出码 2 结束") + } + fmt.Fprintf(w, "平台: %s\n\n", resolve.DescribeInfo(a.Info)) + + result, err := resolve.Resolve(entry, a.Info) + if err != nil { + fmt.Fprintf(w, "结果: 当前平台不可用 — %v\n\n", err) + } else { + fmt.Fprintf(w, "结果: 命中变体 #%d(%d 分)→ %s\n\n", + result.VariantIdx, result.Score, variantDisplay(result.Variant)) + } + + fmt.Fprintln(w, "候选:") + for _, c := range result.Candidates { + mark := "✗" + if c.Matched { + mark = "✓" + } + fmt.Fprintf(w, " %s #%d %d 分 %s\n", mark, c.Index, c.Score, c.Reason) + fmt.Fprintf(w, " 执行: %s\n", variantDisplay(c.Variant)) + if c.Variant.Note != "" { + fmt.Fprintf(w, " 说明: %s\n", c.Variant.Note) + } + } + return nil +} + +// writeColumns prints rows as a table, padding by terminal display width so +// CJK headings line up (text/tabwriter counts bytes and would misalign them). +func writeColumns(w io.Writer, rows [][]string) { + const gap = 2 + widths := make([]int, len(rows[0])) + for _, row := range rows { + for i, cell := range row { + if n := displayWidth(cell); n > widths[i] { + widths[i] = n + } + } + } + + var b strings.Builder + for _, row := range rows { + b.Reset() + for i, cell := range row { + b.WriteString(cell) + if i < len(row)-1 { + b.WriteString(strings.Repeat(" ", widths[i]-displayWidth(cell)+gap)) + } + } + fmt.Fprintln(w, strings.TrimRight(b.String(), " ")) + } +} + +// displayWidth counts the columns s occupies: two for wide (CJK) runes. +func displayWidth(s string) int { + width := 0 + for _, r := range s { + if r >= 0x1100 && (r <= 0x115F || + r >= 0x2E80 && r <= 0xA4CF || + r >= 0xAC00 && r <= 0xD7A3 || + r >= 0xF900 && r <= 0xFAFF || + r >= 0xFE30 && r <= 0xFE6F || + r >= 0xFF00 && r <= 0xFF60 || + r >= 0xFFE0 && r <= 0xFFE6 || + r >= 0x1F300 && r <= 0x1F64F || + r >= 0x20000 && r <= 0x3FFFD) { + width += 2 + continue + } + width++ + } + return width +} + func (a *App) newPlatformCmd() *cobra.Command { return &cobra.Command{ Use: "platform", diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index b518694..3ef47aa 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -166,13 +166,17 @@ func TestExplainUnavailableStillListsCandidates(t *testing.T) { } func TestExplainUnknownCommand(t *testing.T) { - app, _, stderr := testApp(centos("docker")) + app, stdout, _ := testApp(centos("docker")) if code := app.Execute([]string{"explain", "nope"}); code != 1 { t.Fatalf("exit code = %d, want 1", code) } - if !strings.Contains(stderr.String(), "未知命令") { - t.Errorf("stderr = %s", stderr.String()) + out := stdout.String() + // The error lists what does exist instead of only saying "unknown". + for _, want := range []string{`没有名为 "nope" 的命令`, "可用的命令:", "docker.clearsafe", "docker clearsafe"} { + if !strings.Contains(out, want) { + t.Errorf("explain output missing %q:\n%s", want, out) + } } } @@ -800,3 +804,102 @@ func TestGroupHelpIsChinese(t *testing.T) { t.Errorf("group help still says \"help for\":\n%s", out) } } + +func TestExplainAcceptsSpacedAndDottedForms(t *testing.T) { + for _, args := range [][]string{ + {"explain", "docker.clearsafe"}, + {"explain", "docker", "clearsafe"}, + {"explain", "docker.prunesafe"}, // alias + } { + app, stdout, stderr := testApp(centos("docker")) + if code := app.Execute(args); code != 0 { + t.Fatalf("args %v: exit code = %d, stderr=%s", args, code, stderr.String()) + } + out := stdout.String() + for _, want := range []string{ + "命令: docker.clearsafe", + "别名: docker.prunesafe", + "平台:", + "结果:", + "候选:", + "执行: docker system prune -f", + } { + if !strings.Contains(out, want) { + t.Errorf("args %v: output missing %q:\n%s", args, want, out) + } + } + } +} + +func TestExplainShowsUsageOnlyWhenDeclared(t *testing.T) { + // net.pids declares <端口>, so the usage line must appear. + app, stdout, _ := testApp(centos("docker")) + if code := app.Execute([]string{"explain", "net.pids"}); code != 0 { + t.Fatalf("exit code = %d", code) + } + if !strings.Contains(stdout.String(), "用法: kopsc net pids <端口>") { + t.Errorf("usage line missing:\n%s", stdout.String()) + } + + // docker.clearsafe takes no arguments, so it has no usage line. + app, stdout, _ = testApp(centos("docker")) + if code := app.Execute([]string{"explain", "docker.clearsafe"}); code != 0 { + t.Fatalf("exit code = %d", code) + } + if strings.Contains(stdout.String(), "用法:") { + t.Errorf("unexpected usage line:\n%s", stdout.String()) + } +} + +func TestExplainMentionsYesForDestructiveCommand(t *testing.T) { + app, stdout, _ := testApp(centos("docker")) + if code := app.Execute([]string{"explain", "docker.clearall"}); code != 0 { + t.Fatalf("exit code = %d", code) + } + if !strings.Contains(stdout.String(), "破坏性命令") { + t.Errorf("destructive hint missing:\n%s", stdout.String()) + } +} + +func TestExplainGroupSummarisesCommands(t *testing.T) { + app, stdout, _ := testApp(centos("docker")) + if code := app.Execute([]string{"explain", "net"}); code != 0 { + t.Fatalf("exit code = %d", code) + } + out := stdout.String() + for _, want := range []string{`分组 "net"`, "net.pids", "net.ports", "命令", "状态", "将执行", "说明"} { + if !strings.Contains(out, want) { + t.Errorf("group output missing %q:\n%s", want, out) + } + } +} + +func TestExplainRequiresCommandName(t *testing.T) { + app, _, stderr := testApp(centos("docker")) + if code := app.Execute([]string{"explain"}); code == 0 { + t.Fatal("expected a non-zero exit code without a command name") + } + if !strings.Contains(stderr.String(), "缺少命令名") { + t.Errorf("stderr = %s", stderr.String()) + } +} + +func TestExplainHelpDocumentsWaysAndExitCodes(t *testing.T) { + app, stdout, _ := testApp(centos("docker")) + if code := app.Execute([]string{"explain", "--help"}); code != 0 { + t.Fatalf("exit code = %d", code) + } + out := stdout.String() + for _, want := range []string{ + "docker.clearsafe", // dotted form + "docker clearsafe", // spaced form + "别名", // aliases are accepted + "退出码", // exit codes documented + "得分", // scoring explained + "kopsc explain docker cle", // an example + } { + if !strings.Contains(out, want) { + t.Errorf("explain --help missing %q:\n%s", want, out) + } + } +}