feat: 补全 explain 的说明与输出
- 帮助补齐:解释变体机制、命令名两种写法(点号/空格)、别名与分组名、 输出各项含义、打分规则、退出码,并给出示例 - 支持 `kopsc explain docker clearsafe` 空格写法;未知命令改为列出全部 可用命令并提示写法,不再只说「未知命令」 - 单命令输出补上别名、用法、破坏性提示;候选列表分「执行/说明」两行 - 新增分组模式 `kopsc explain net`,表格汇总该组各命令的匹配结果 - list 与分组表格改用显示宽度对齐,修正中文表头错位
This commit is contained in:
@@ -80,6 +80,8 @@ kopsc proc nohup ./server # 后台运行命令,输出写日志(
|
|||||||
|
|
||||||
kopsc list # 全部命令及当前平台可用性
|
kopsc list # 全部命令及当前平台可用性
|
||||||
kopsc explain docker.clearsafe # 匹配过程、全部候选及各自被选中/拒绝的原因
|
kopsc explain docker.clearsafe # 匹配过程、全部候选及各自被选中/拒绝的原因
|
||||||
|
kopsc explain docker clearsafe # 同上,空格写法也支持
|
||||||
|
kopsc explain net # 只看分组:列出该组每条命令在本平台的匹配结果
|
||||||
kopsc platform # 探测到的 OS/ARCH/发行版/运行时
|
kopsc platform # 探测到的 OS/ARCH/发行版/运行时
|
||||||
kopsc version # 版本与平台
|
kopsc version # 版本与平台
|
||||||
```
|
```
|
||||||
|
|||||||
+189
-38
@@ -7,7 +7,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
@@ -413,8 +412,7 @@ func (a *App) newListCmd() *cobra.Command {
|
|||||||
Short: "列出全部命令及其在当前平台的可用性",
|
Short: "列出全部命令及其在当前平台的可用性",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
tw := tabwriter.NewWriter(a.Stdout, 0, 0, 2, ' ', 0)
|
rows := [][]string{{"命令", "状态", "将执行"}}
|
||||||
fmt.Fprintln(tw, "命令\t状态\t将执行")
|
|
||||||
for i := range a.Registry.Commands {
|
for i := range a.Registry.Commands {
|
||||||
entry := &a.Registry.Commands[i]
|
entry := &a.Registry.Commands[i]
|
||||||
status, planned := "可用", "-"
|
status, planned := "可用", "-"
|
||||||
@@ -423,9 +421,10 @@ func (a *App) newListCmd() *cobra.Command {
|
|||||||
} else {
|
} else {
|
||||||
status = "不可用"
|
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{
|
return &cobra.Command{
|
||||||
Use: "explain <命令>",
|
Use: "explain <命令>",
|
||||||
Short: "解释某条命令在当前平台的匹配过程",
|
Short: "解释某条命令在当前平台的匹配过程",
|
||||||
Args: cobra.ExactArgs(1),
|
Long: `解释 kopsc 为什么在当前平台选择了某个底层命令。
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
entry, ok := a.Registry.Lookup(args[0])
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("未知命令 %q", args[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
w := a.Stdout
|
kopsc 的每条命令都由若干「变体」组成,每个变体声明它适用的环境
|
||||||
fmt.Fprintf(w, "命令: %s\n", entry.ID)
|
(操作系统、发行版、CPU 架构、容器运行时)。explain 会打印当前探测到的
|
||||||
fmt.Fprintf(w, "说明: %s\n", entry.Summary)
|
平台信息、命中的变体及其得分,并列出全部候选以及每个候选被选中或被排除的原因,
|
||||||
fmt.Fprintf(w, "动作: %s\n", entry.Action)
|
方便排查“为什么执行的是这条命令”“为什么本平台不可用”。
|
||||||
fmt.Fprintf(w, "平台: %s\n\n", resolve.DescribeInfo(a.Info))
|
|
||||||
|
|
||||||
result, err := resolve.Resolve(entry, a.Info)
|
命令名有两种写法,等价:
|
||||||
if err != nil {
|
kopsc explain docker.clearsafe 点号分隔的完整 ID
|
||||||
fmt.Fprintf(w, "结果: 当前平台不可用 — %v\n\n", err)
|
kopsc explain docker clearsafe 命令行中的写法
|
||||||
} else {
|
|
||||||
fmt.Fprintf(w, "结果: 命中变体 #%d(%d 分)→ %s\n\n",
|
|
||||||
result.VariantIdx, result.Score, variantDisplay(result.Variant))
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(w, "候选:")
|
也可以使用别名(如 docker.prunesafe),或只写分组名查看该组内匹配较好的命令
|
||||||
for _, c := range result.Candidates {
|
(如 kopsc explain docker)。
|
||||||
mark := "✗"
|
|
||||||
if c.Matched {
|
输出各项含义:
|
||||||
mark = "✓"
|
命令 / 说明 / 动作 该命令的用途与安全约定
|
||||||
}
|
平台 当前探测结果:系统/架构、发行版、已安装的运行时
|
||||||
note := ""
|
结果 命中的变体序号、得分与最终要执行的命令
|
||||||
if c.Variant.Note != "" {
|
候选 全部变体:✓ 为满足约束并参与比较,✗ 为被排除
|
||||||
note = " # " + c.Variant.Note
|
得分 发行版精确命中 +3、ID_LIKE 命中 +2、运行时命中 +2,
|
||||||
}
|
通用变体 0 分;同分时按运行时偏好与约束数量取舍
|
||||||
fmt.Fprintf(w, " %s #%d %d 分 %s -> %s%s\n",
|
|
||||||
mark, c.Index, c.Score, c.Reason, variantDisplay(c.Variant), note)
|
退出码:找到命令即返回 0(即使本平台不可用,也会说明原因);命令名不存在返回 1。`,
|
||||||
}
|
Example: ` kopsc explain docker.clearsafe 查看清理命令在本平台为什么这样执行
|
||||||
return nil
|
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 {
|
func (a *App) newPlatformCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "platform",
|
Use: "platform",
|
||||||
|
|||||||
+106
-3
@@ -166,13 +166,17 @@ func TestExplainUnavailableStillListsCandidates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExplainUnknownCommand(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 {
|
if code := app.Execute([]string{"explain", "nope"}); code != 1 {
|
||||||
t.Fatalf("exit code = %d, want 1", code)
|
t.Fatalf("exit code = %d, want 1", code)
|
||||||
}
|
}
|
||||||
if !strings.Contains(stderr.String(), "未知命令") {
|
out := stdout.String()
|
||||||
t.Errorf("stderr = %s", stderr.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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user