From ac2eaca5b57e306ca968c45ca04c40d5959545e6 Mon Sep 17 00:00:00 2001 From: barry Date: Sat, 14 Mar 2026 21:26:36 +0800 Subject: [PATCH 01/76] chore: quick update feat/mcp at 2026-03-14 21:26:34 --- README.md | 27 ++++ args.go | 11 ++ command.go | 10 ++ command_test.go | 254 +++++++++++++++++++++++++++++++++++++ docs/CHANGELOG.md | 9 ++ docs/USAGE_AT_A_GLANCE.md | 15 +++ env_preload.go | 256 ++++++++++++++++++++++++++++++++++++++ env_preload_test.go | 199 +++++++++++++++++++++++++++++ 8 files changed, 781 insertions(+) create mode 100644 env_preload.go create mode 100644 env_preload_test.go diff --git a/README.md b/README.md index 9b8d107..061e327 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,33 @@ flowchart TD - `--help, -h`:显示帮助 - `--list-commands`:列出命令树 - `--list-flags`:列出所有标志 +- `--env, -e KEY=VALUE`:设置环境变量(支持重复与 CSV 批量) +- `--env-file FILE`:从 env 文件加载环境变量(支持重复与 CSV 批量) + +### 环境标志使用示例 + +```text +# 单个变量 +app demo --env APP_MODE=dev + +# 简写 +app demo -e APP_MODE=dev + +# 重复传参 +app demo -e APP_MODE=dev -e APP_REGION=cn + +# CSV 批量 +app demo --env APP_MODE=dev,APP_REGION=cn + +# 读取单个 env 文件 +app demo --env-file .env + +# 重复读取多个 env 文件 +app demo --env-file .env --env-file .env.local + +# CSV 批量读取多个 env 文件 +app demo --env-file .env,.env.local +``` ## 示例目录 diff --git a/args.go b/args.go index 41cc542..cb177d3 100644 --- a/args.go +++ b/args.go @@ -268,6 +268,17 @@ func GlobalFlags() OptionSet { Description: "List all flags.", Value: BoolOf(new(bool)), }, + { + Flag: "env", + Shorthand: "e", + Description: "Set environment variables (format: KEY=VALUE). Supports repeat and CSV.", + Value: StringArrayOf(new([]string)), + }, + { + Flag: "env-file", + Description: "Load environment variables from file(s). Supports repeat and CSV.", + Value: StringArrayOf(new([]string)), + }, } } diff --git a/command.go b/command.go index 3fcc016..8d2c868 100644 --- a/command.go +++ b/command.go @@ -991,6 +991,16 @@ func parseAndSetArgs(argsDef ArgSet, args []string) error { // //nolint:revive func (inv *Invocation) Run() (err error) { + restoreEnv, preloadErr := preloadEnvFromArgs(inv.Args) + if preloadErr != nil { + return fmt.Errorf("preloading environment variables: %w", preloadErr) + } + defer func() { + if restoreEnv != nil { + err = errors.Join(err, restoreEnv()) + } + }() + for _, child := range inv.Command.Children { child.parent = inv.Command } diff --git a/command_test.go b/command_test.go index 64afc0e..ef7016d 100644 --- a/command_test.go +++ b/command_test.go @@ -3,6 +3,8 @@ package redant import ( "bytes" "context" + "os" + "path/filepath" "strings" "testing" ) @@ -387,6 +389,258 @@ func TestEnvVarFlag(t *testing.T) { } } +func TestGlobalEnvFlagSetsOptionEnvAndRestores(t *testing.T) { + const envName = "REDANT_TEST_GLOBAL_ENV" + t.Setenv(envName, "original") + + var value string + cmd := &Command{ + Use: "test", + Short: "Test command", + Options: OptionSet{ + { + Flag: "value", + Description: "A value from env", + Value: StringOf(&value), + Envs: []string{envName}, + }, + }, + Handler: func(ctx context.Context, inv *Invocation) error { + return nil + }, + } + + inv := cmd.Invoke("--env", envName+"=from-flag") + inv.Stdout = &bytes.Buffer{} + inv.Stderr = &bytes.Buffer{} + + err := inv.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if value != "from-flag" { + t.Errorf("value = %q, want %q", value, "from-flag") + } + + if got := os.Getenv(envName); got != "original" { + t.Errorf("env %s after run = %q, want %q", envName, got, "original") + } +} + +func TestGlobalEnvFileFlagSetsOptionEnvAndRestores(t *testing.T) { + const envName = "REDANT_TEST_ENV_FILE" + t.Setenv(envName, "original") + + tmpFile := filepath.Join(t.TempDir(), ".env") + err := os.WriteFile(tmpFile, []byte("# comment\nexport REDANT_TEST_ENV_FILE=from-file\n"), 0o600) + if err != nil { + t.Fatalf("write env file: %v", err) + } + + var value string + cmd := &Command{ + Use: "test", + Short: "Test command", + Options: OptionSet{ + { + Flag: "value", + Description: "A value from env file", + Value: StringOf(&value), + Envs: []string{envName}, + }, + }, + Handler: func(ctx context.Context, inv *Invocation) error { + return nil + }, + } + + inv := cmd.Invoke("--env-file", tmpFile) + inv.Stdout = &bytes.Buffer{} + inv.Stderr = &bytes.Buffer{} + + err = inv.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if value != "from-file" { + t.Errorf("value = %q, want %q", value, "from-file") + } + + if got := os.Getenv(envName); got != "original" { + t.Errorf("env %s after run = %q, want %q", envName, got, "original") + } +} + +func TestGlobalEnvFileCSVAndEnvOrder(t *testing.T) { + const envName = "REDANT_TEST_ENV_FILES_ORDER" + t.Setenv(envName, "original") + + tmpDir := t.TempDir() + file1 := filepath.Join(tmpDir, "a.env") + file2 := filepath.Join(tmpDir, "b.env") + if err := os.WriteFile(file1, []byte(envName+"=from-file1\n"), 0o600); err != nil { + t.Fatalf("write file1: %v", err) + } + if err := os.WriteFile(file2, []byte(envName+"=from-file2\n"), 0o600); err != nil { + t.Fatalf("write file2: %v", err) + } + + var value string + cmd := &Command{ + Use: "test", + Short: "Test command", + Options: OptionSet{ + { + Flag: "value", + Description: "A value from env", + Value: StringOf(&value), + Envs: []string{envName}, + }, + }, + Handler: func(ctx context.Context, inv *Invocation) error { + return nil + }, + } + + inv := cmd.Invoke( + "--env-file", file1+","+file2, + "--env", envName+"=from-env", + ) + inv.Stdout = &bytes.Buffer{} + inv.Stderr = &bytes.Buffer{} + + err := inv.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if value != "from-env" { + t.Errorf("value = %q, want %q", value, "from-env") + } + + if got := os.Getenv(envName); got != "original" { + t.Errorf("env %s after run = %q, want %q", envName, got, "original") + } +} + +func TestGlobalEnvShorthandAndCSV(t *testing.T) { + const envName = "REDANT_TEST_GLOBAL_ENV_SHORT" + t.Setenv(envName, "original") + + var value string + cmd := &Command{ + Use: "test", + Short: "Test command", + Options: OptionSet{ + { + Flag: "value", + Description: "A value from env", + Value: StringOf(&value), + Envs: []string{envName}, + }, + }, + Handler: func(ctx context.Context, inv *Invocation) error { + return nil + }, + } + + inv := cmd.Invoke("-e", "ANOTHER_KEY=123,"+envName+"=from-short-csv") + inv.Stdout = &bytes.Buffer{} + inv.Stderr = &bytes.Buffer{} + + err := inv.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if value != "from-short-csv" { + t.Errorf("value = %q, want %q", value, "from-short-csv") + } + + if got := os.Getenv(envName); got != "original" { + t.Errorf("env %s after run = %q, want %q", envName, got, "original") + } +} + +func TestGlobalEnvShorthandRepeat(t *testing.T) { + const envA = "REDANT_TEST_SHORT_REPEAT_A" + const envB = "REDANT_TEST_SHORT_REPEAT_B" + t.Setenv(envA, "orig-a") + t.Setenv(envB, "orig-b") + + var valueA string + var valueB string + cmd := &Command{ + Use: "test", + Short: "Test command", + Options: OptionSet{ + { + Flag: "value-a", + Description: "A value from env A", + Value: StringOf(&valueA), + Envs: []string{envA}, + }, + { + Flag: "value-b", + Description: "A value from env B", + Value: StringOf(&valueB), + Envs: []string{envB}, + }, + }, + Handler: func(ctx context.Context, inv *Invocation) error { + return nil + }, + } + + inv := cmd.Invoke("-e", envA+"=1", "-e", envB+"=2") + inv.Stdout = &bytes.Buffer{} + inv.Stderr = &bytes.Buffer{} + + err := inv.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if valueA != "1" { + t.Errorf("valueA = %q, want %q", valueA, "1") + } + if valueB != "2" { + t.Errorf("valueB = %q, want %q", valueB, "2") + } + + if got := os.Getenv(envA); got != "orig-a" { + t.Errorf("env %s after run = %q, want %q", envA, got, "orig-a") + } + if got := os.Getenv(envB); got != "orig-b" { + t.Errorf("env %s after run = %q, want %q", envB, got, "orig-b") + } +} + +func TestGlobalEnvFlagInvalidAssignment(t *testing.T) { + cmd := &Command{ + Use: "test", + Short: "Test command", + Handler: func(ctx context.Context, inv *Invocation) error { + return nil + }, + } + + inv := cmd.Invoke("--env", "INVALID") + inv.Stdout = &bytes.Buffer{} + inv.Stderr = &bytes.Buffer{} + + err := inv.Run() + if err == nil { + t.Fatalf("expected error for invalid --env assignment") + } + + if !strings.Contains(err.Error(), "invalid --env value") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestDeprecatedFlag(t *testing.T) { var deprecated string diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2a1a0e7..4ff256c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,14 @@ flowchart LR > - 使用 LLM 提示词自动更新:[`CHANGELOG_LLM_PROMPT.md`](CHANGELOG_LLM_PROMPT.md) > - 建议通过 agent 提示词执行:`/changelog-maintenance draft|release` +### 新增 + +- 新增内建全局环境标志:`--env`(简写 `-e`)与 `--env-file`,支持在命令解析前注入环境变量。 + +### 修复 + +- 修复 `preloadEnvFromArgs` 在预解析失败时可能残留已写入环境变量的问题,失败路径会自动回滚已变更项。 + ### 变更 - 将 `github.com/coder/pretty` 迁移为内部实现 `internal/pretty`,以消除上游停止维护带来的依赖风险。 @@ -27,6 +35,7 @@ flowchart LR ### 文档 - 新增内部维护文档:`internal/pretty/README.md`。 +- 更新 `README.md` 与 `docs/USAGE_AT_A_GLANCE.md`,补充全局环境标志说明与使用示例。 ## [v0.0.5] - 2026-01-20 diff --git a/docs/USAGE_AT_A_GLANCE.md b/docs/USAGE_AT_A_GLANCE.md index febbf53..833cbd8 100644 --- a/docs/USAGE_AT_A_GLANCE.md +++ b/docs/USAGE_AT_A_GLANCE.md @@ -61,6 +61,21 @@ flowchart TD | 环境变量回退 | `GIT_AUTHOR=alice app repo commit` | `Envs` 配置生效 | | 默认值 | 未传值时自动应用 | 由 `Default` 指定 | +内建全局环境标志: + +- `--env, -e KEY=VALUE`:设置环境变量(支持重复与 CSV)。 +- `--env-file FILE`:从 env 文件加载环境变量(支持重复与 CSV)。 + +快速示例: + +```text +app demo -e A=1 -e B=2 +app demo --env A=1,B=2 +app demo --env-file .env +app demo --env-file .env --env-file .env.local +app demo --env-file .env,.env.local +``` + ## 4) 通用输入模板 ```text diff --git a/env_preload.go b/env_preload.go new file mode 100644 index 0000000..f88ba46 --- /dev/null +++ b/env_preload.go @@ -0,0 +1,256 @@ +package redant + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" +) + +type envSnapshot struct { + value string + existed bool +} + +// preloadEnvFromArgs scans global env-related flags from raw args, applies them +// to the process environment before normal flag parsing, and returns a restore +// function to avoid leaking state between invocations. +func preloadEnvFromArgs(args []string) (restore func() error, err error) { + snapshots := make(map[string]envSnapshot) + + defer func() { + if err != nil && len(snapshots) > 0 { + _ = restoreEnvSnapshots(snapshots) + } + }() + + setEnv := func(key, value string) error { + key = strings.TrimSpace(key) + if key == "" { + return fmt.Errorf("environment variable name cannot be empty") + } + if _, ok := snapshots[key]; !ok { + prev, existed := os.LookupEnv(key) + snapshots[key] = envSnapshot{value: prev, existed: existed} + } + return os.Setenv(key, value) + } + + for i := 0; i < len(args); i++ { + arg := strings.TrimSpace(args[i]) + if arg == "--" { + break + } + + if flagName, value, ok, parseErr := parseEnvFlagFromArgs(args, i); parseErr != nil { + return nil, parseErr + } else if ok { + if consumesNextArg(arg, flagName) { + i++ + } + + switch flagName { + case "env": + if err := applyEnvAssignmentsCSV(value, setEnv); err != nil { + return nil, fmt.Errorf("invalid --env value %q: %w", value, err) + } + case "env-file": + paths, err := readAsCSV(value) + if err != nil { + return nil, fmt.Errorf("parsing --env-file value %q: %w", value, err) + } + for _, path := range paths { + path = strings.TrimSpace(path) + if path == "" { + continue + } + if err := loadEnvFile(path, setEnv); err != nil { + return nil, fmt.Errorf("loading --env-file entry %q: %w", path, err) + } + } + } + } + } + + if len(snapshots) == 0 { + return nil, nil + } + + restore = func() error { + return restoreEnvSnapshots(snapshots) + } + + return restore, nil +} + +func restoreEnvSnapshots(snapshots map[string]envSnapshot) error { + var merr error + for key, snap := range snapshots { + var err error + if snap.existed { + err = os.Setenv(key, snap.value) + } else { + err = os.Unsetenv(key) + } + merr = errors.Join(merr, err) + } + return merr +} + +func parseLongFlag(arg string) (name, value string, hasInlineValue, ok bool) { + if !strings.HasPrefix(arg, "--") { + return "", "", false, false + } + token := strings.TrimPrefix(arg, "--") + if token == "" { + return "", "", false, false + } + parts := strings.SplitN(token, "=", 2) + name = parts[0] + if len(parts) == 2 { + value = parts[1] + hasInlineValue = true + } + return name, value, hasInlineValue, true +} + +func parseShortEFlag(arg string) (value string, hasInlineValue, ok bool) { + if strings.HasPrefix(arg, "--") || !strings.HasPrefix(arg, "-e") { + return "", false, false + } + if arg == "-e" { + return "", false, true + } + if strings.HasPrefix(arg, "-e=") { + return strings.TrimPrefix(arg, "-e="), true, true + } + return strings.TrimPrefix(arg, "-e"), true, true +} + +func parseEnvFlagFromArgs(args []string, i int) (name, value string, ok bool, err error) { + arg := strings.TrimSpace(args[i]) + + if flagName, flagValue, hasInlineValue, parsed := parseLongFlag(arg); parsed { + switch flagName { + case "env", "env-file": + if !hasInlineValue { + if i+1 >= len(args) { + return "", "", false, fmt.Errorf("flag --%s requires a value", flagName) + } + flagValue = args[i+1] + } + return flagName, flagValue, true, nil + default: + return "", "", false, nil + } + } + + if flagValue, hasInlineValue, parsed := parseShortEFlag(arg); parsed { + if !hasInlineValue { + if i+1 >= len(args) { + return "", "", false, fmt.Errorf("flag -e requires a value") + } + flagValue = args[i+1] + } + return "env", flagValue, true, nil + } + + return "", "", false, nil +} + +func consumesNextArg(currentArg, flagName string) bool { + if strings.HasPrefix(currentArg, "--") { + return currentArg == "--"+flagName + } + if flagName == "env" { + return currentArg == "-e" + } + return false +} + +func parseEnvAssignment(raw string) (key, value string, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", fmt.Errorf("empty environment assignment") + } + idx := strings.Index(raw, "=") + if idx <= 0 { + return "", "", fmt.Errorf("expected KEY=VALUE") + } + key = strings.TrimSpace(raw[:idx]) + value = strings.TrimSpace(raw[idx+1:]) + if key == "" { + return "", "", fmt.Errorf("environment variable name cannot be empty") + } + return key, value, nil +} + +func applyEnvAssignmentsCSV(raw string, setEnv func(key, value string) error) error { + entries, err := readAsCSV(raw) + if err != nil { + return err + } + for _, entry := range entries { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + key, value, err := parseEnvAssignment(entry) + if err != nil { + return err + } + if err := setEnv(key, value); err != nil { + return err + } + } + return nil +} + +func loadEnvFile(path string, setEnv func(key, value string) error) error { + path = strings.TrimSpace(path) + if path == "" { + return fmt.Errorf("env file path is empty") + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + + content := strings.ReplaceAll(string(data), "\r\n", "\n") + lines := strings.Split(content, "\n") + for i, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if strings.HasPrefix(line, "export ") { + line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) + } + + key, value, err := parseEnvAssignment(line) + if err != nil { + return fmt.Errorf("%s:%d: %w", path, i+1, err) + } + value = normalizeEnvValue(value) + if err := setEnv(key, value); err != nil { + return fmt.Errorf("%s:%d: %w", path, i+1, err) + } + } + return nil +} + +func normalizeEnvValue(v string) string { + v = strings.TrimSpace(v) + if len(v) >= 2 && v[0] == '\'' && v[len(v)-1] == '\'' { + return v[1 : len(v)-1] + } + if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' { + if unquoted, err := strconv.Unquote(v); err == nil { + return unquoted + } + } + return v +} diff --git a/env_preload_test.go b/env_preload_test.go new file mode 100644 index 0000000..e86073e --- /dev/null +++ b/env_preload_test.go @@ -0,0 +1,199 @@ +package redant + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseShortEFlag(t *testing.T) { + tests := []struct { + name string + arg string + wantValue string + wantInline bool + wantParsedFlag bool + }{ + {name: "short without inline", arg: "-e", wantValue: "", wantInline: false, wantParsedFlag: true}, + {name: "short attached value", arg: "-eA=1", wantValue: "A=1", wantInline: true, wantParsedFlag: true}, + {name: "short equals value", arg: "-e=A=1", wantValue: "A=1", wantInline: true, wantParsedFlag: true}, + {name: "other short flag", arg: "-x", wantValue: "", wantInline: false, wantParsedFlag: false}, + {name: "long flag should ignore", arg: "--env", wantValue: "", wantInline: false, wantParsedFlag: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value, inline, parsed := parseShortEFlag(tt.arg) + if value != tt.wantValue || inline != tt.wantInline || parsed != tt.wantParsedFlag { + t.Fatalf("got value=%q inline=%v parsed=%v, want value=%q inline=%v parsed=%v", + value, inline, parsed, + tt.wantValue, tt.wantInline, tt.wantParsedFlag, + ) + } + }) + } +} + +func TestParseEnvFlagFromArgs(t *testing.T) { + tests := []struct { + name string + args []string + index int + wantName string + wantValue string + wantOK bool + wantErr string + }{ + {name: "long env next arg", args: []string{"--env", "A=1"}, index: 0, wantName: "env", wantValue: "A=1", wantOK: true}, + {name: "long env inline", args: []string{"--env=A=1"}, index: 0, wantName: "env", wantValue: "A=1", wantOK: true}, + {name: "short env next arg", args: []string{"-e", "A=1"}, index: 0, wantName: "env", wantValue: "A=1", wantOK: true}, + {name: "short env inline", args: []string{"-eA=1"}, index: 0, wantName: "env", wantValue: "A=1", wantOK: true}, + {name: "env file next arg", args: []string{"--env-file", ".env"}, index: 0, wantName: "env-file", wantValue: ".env", wantOK: true}, + {name: "env file inline", args: []string{"--env-file=.env,.env.local"}, index: 0, wantName: "env-file", wantValue: ".env,.env.local", wantOK: true}, + {name: "missing long env value", args: []string{"--env"}, index: 0, wantErr: "requires a value"}, + {name: "missing short env value", args: []string{"-e"}, index: 0, wantErr: "requires a value"}, + {name: "unknown flag", args: []string{"--name", "demo"}, index: 0, wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name, value, ok, err := parseEnvFlagFromArgs(tt.args, tt.index) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err=%v, want contains %q", err, tt.wantErr) + } + return + } + + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if name != tt.wantName || value != tt.wantValue || ok != tt.wantOK { + t.Fatalf("got name=%q value=%q ok=%v, want name=%q value=%q ok=%v", + name, value, ok, + tt.wantName, tt.wantValue, tt.wantOK, + ) + } + }) + } +} + +func TestPreloadEnvFromArgs_AppliesAndRestores(t *testing.T) { + const existing = "REDANT_PRELOAD_EXISTING" + const created = "REDANT_PRELOAD_CREATED" + + t.Setenv(existing, "orig") + _ = os.Unsetenv(created) + + restore, err := preloadEnvFromArgs([]string{"-e", existing + "=override", "--env", created + "=1"}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if restore == nil { + t.Fatalf("restore func should not be nil") + } + + if got := os.Getenv(existing); got != "override" { + t.Fatalf("existing=%q, want override", got) + } + if got := os.Getenv(created); got != "1" { + t.Fatalf("created=%q, want 1", got) + } + + if err := restore(); err != nil { + t.Fatalf("restore error: %v", err) + } + + if got := os.Getenv(existing); got != "orig" { + t.Fatalf("existing after restore=%q, want orig", got) + } + if _, ok := os.LookupEnv(created); ok { + t.Fatalf("created should be unset after restore") + } +} + +func TestPreloadEnvFromArgs_EnvFileRepeatAndCSV(t *testing.T) { + const inherited = "REDANT_PRELOAD_COMMON" + const only1 = "REDANT_PRELOAD_FILE_A" + const only2 = "REDANT_PRELOAD_FILE_B" + + t.Setenv(inherited, "orig-common") + _ = os.Unsetenv(only1) + _ = os.Unsetenv(only2) + + tmpDir := t.TempDir() + file1 := filepath.Join(tmpDir, "a.env") + file2 := filepath.Join(tmpDir, "b.env") + + if err := os.WriteFile(file1, []byte(inherited+"=from-a\n"+only1+"=one\n"), 0o600); err != nil { + t.Fatalf("write file1: %v", err) + } + if err := os.WriteFile(file2, []byte("export "+inherited+"=\"from-b\"\n"+only2+"='two'\n"), 0o600); err != nil { + t.Fatalf("write file2: %v", err) + } + + restore, err := preloadEnvFromArgs([]string{"--env-file", file1, "--env-file", file2 + "," + file1}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if restore == nil { + t.Fatalf("restore func should not be nil") + } + + if got := os.Getenv(inherited); got != "from-a" { + t.Fatalf("inherited=%q, want from-a", got) + } + if got := os.Getenv(only1); got != "one" { + t.Fatalf("only1=%q, want one", got) + } + if got := os.Getenv(only2); got != "two" { + t.Fatalf("only2=%q, want two", got) + } + + if err := restore(); err != nil { + t.Fatalf("restore error: %v", err) + } + + if got := os.Getenv(inherited); got != "orig-common" { + t.Fatalf("inherited after restore=%q, want orig-common", got) + } + if _, ok := os.LookupEnv(only1); ok { + t.Fatalf("only1 should be unset after restore") + } + if _, ok := os.LookupEnv(only2); ok { + t.Fatalf("only2 should be unset after restore") + } +} + +func TestPreloadEnvFromArgs_StopAtDoubleDash(t *testing.T) { + const key = "REDANT_PRELOAD_STOP_AT_DASH" + _ = os.Unsetenv(key) + + restore, err := preloadEnvFromArgs([]string{"--", "-e", key + "=1"}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if restore != nil { + t.Fatalf("restore should be nil when no env is changed") + } + if _, ok := os.LookupEnv(key); ok { + t.Fatalf("%s should not be set after --", key) + } +} + +func TestPreloadEnvFromArgs_RollbackOnError(t *testing.T) { + const key = "REDANT_PRELOAD_ROLLBACK" + _ = os.Unsetenv(key) + + restore, err := preloadEnvFromArgs([]string{"-e", key + "=1", "-e", "INVALID"}) + if err == nil { + t.Fatalf("expected error") + } + if restore != nil { + t.Fatalf("restore should be nil on preload error") + } + if _, ok := os.LookupEnv(key); ok { + t.Fatalf("%s should be rolled back when preload fails", key) + } +} From 2fdfedabb85b2ed17bf8b4a542aaf7c925ab4906 Mon Sep 17 00:00:00 2001 From: barry Date: Sat, 14 Mar 2026 21:35:16 +0800 Subject: [PATCH 02/76] chore: quick update feat/mcp at 2026-03-14 21:35:16 --- AGENTS.md | 45 ++++++++ env_preload_test.go | 250 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 295 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5dee4b4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# AGENTS.md + +## 适用范围 +- 本指南面向在 `redant` 仓库内工作的 AI 编码代理。 +- 优先保证行为与 API 兼容性,避免无关重构;该项目是 CLI 框架核心。 + +## 架构速览 +- 命令运行主线在 `command.go`(`Invocation.Run` -> `inv.run`):命令定位、标志装配、参数解析、中间件链与处理器执行。 +- 标志模型在 `option.go`:`OptionSet.FlagSet()` 先应用默认值,再按 `Envs` 首个非空值做环境回退,最后由 CLI 输入覆盖。 +- 参数形态在 `args.go`:位置参数、query(`a=1&b=2`)、form(`a=1 b=2`)、JSON 对象/数组。 +- 帮助渲染由 `help.go` + `help.tpl` 模板驱动,样式层位于 `internal/pretty`。 +- Shell 补全作为命令模块集成在 `cmds/completioncmd/completion.go`。 + +## 关键运行规则(不要破坏) +- 子命令解析同时支持 `app repo commit` 与 `app repo:commit`(`command.go` 的 `getExecCommand`)。 +- 分发优先级:显式子命令 > `argv0` busybox 分发 > 根命令(见 `getExecCommand` + `resolveArgv0Command`)。 +- 根全局标志来自 `args.go` 的 `GlobalFlags()`,在命令初始化时注入。 +- 子命令继承父标志;出现重名时,深层命令标志覆盖浅层标志(`command.go` 的 `copyFlagSetWithout` 逻辑)。 +- `--list-commands` / `--list-flags` 会在 Handler 前短路执行(`command.go`)。 +- 环境预加载(`--env`、`-e`、`--env-file`)先从原始参数读取,再在运行结束后恢复(`env_preload.go`)。 +- Required 选项判定认可三类来源:显式改动 flag、默认值、配置了 env 键列表(`command.go` 必填校验逻辑)。 + +## 开发工作流 +- 任务入口(`taskfile.yml`): + - `task test`(内部使用 `go test -short -race -v ./... -cover`) + - `task vet` + - `task lint` +- `Taskfile` 会加载 `.env`(`dotenv: [".env"]`),测试行为可能受环境占位键影响。 + +## 项目约定(来自现有代码) +- 测试以表驱动 + 子测试为主(见 `command_test.go`、`env_preload_test.go`)。 +- 补测试优先覆盖边界语义,而非只测 happy path:argv0 分发、flag 继承、env-file 的 CSV/重复输入、`--` 停止符。 +- 文档默认中文;涉及流程文档变更时保留 Mermaid 图(`README.md`、`docs/DESIGN.md`、`docs/USAGE_AT_A_GLANCE.md`)。 + +## 集成点与依赖 +- CLI 标志引擎:`github.com/spf13/pflag`(自定义值类型见 `flags.go`)。 +- 帮助输出格式:`github.com/muesli/termenv`、`github.com/mitchellh/go-wordwrap`、`internal/pretty`。 +- YAML/JSON 值包装能力在 `flags.go` 中实现,用于类型化选项。 + +## 变更落点清单 +- 调整命令分发/执行:改 `command.go`,并在 `command_test.go` 增加针对性测试。 +- 调整 flag/env/default 语义:改 `option.go` / `env_preload.go`,并更新 `env_preload_test.go`。 +- 调整参数格式行为:改 `args.go`,并同步 `example/args-test/` 示例。 +- 调整帮助/补全体验:改 `help.go`/`help.tpl` 或 `cmds/completioncmd/`,并验证相关输出路径。 + diff --git a/env_preload_test.go b/env_preload_test.go index e86073e..5791844 100644 --- a/env_preload_test.go +++ b/env_preload_test.go @@ -35,6 +35,35 @@ func TestParseShortEFlag(t *testing.T) { } } +func TestParseLongFlag(t *testing.T) { + tests := []struct { + name string + arg string + wantName string + wantValue string + wantInline bool + wantParsed bool + }{ + {name: "plain long", arg: "--env", wantName: "env", wantValue: "", wantInline: false, wantParsed: true}, + {name: "long with equals", arg: "--env=A=1", wantName: "env", wantValue: "A=1", wantInline: true, wantParsed: true}, + {name: "single dash", arg: "-e", wantParsed: false}, + {name: "double dash only", arg: "--", wantParsed: false}, + {name: "non flag", arg: "env", wantParsed: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name, value, inline, parsed := parseLongFlag(tt.arg) + if name != tt.wantName || value != tt.wantValue || inline != tt.wantInline || parsed != tt.wantParsed { + t.Fatalf("got name=%q value=%q inline=%v parsed=%v, want name=%q value=%q inline=%v parsed=%v", + name, value, inline, parsed, + tt.wantName, tt.wantValue, tt.wantInline, tt.wantParsed, + ) + } + }) + } +} + func TestParseEnvFlagFromArgs(t *testing.T) { tests := []struct { name string @@ -113,6 +142,28 @@ func TestPreloadEnvFromArgs_AppliesAndRestores(t *testing.T) { } } +func TestPreloadEnvFromArgs_ShortInlineEquals(t *testing.T) { + const key = "REDANT_PRELOAD_SHORT_INLINE_EQUALS" + _ = os.Unsetenv(key) + + restore, err := preloadEnvFromArgs([]string{"-e=" + key + "=ok"}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if restore == nil { + t.Fatalf("restore should not be nil") + } + if got := os.Getenv(key); got != "ok" { + t.Fatalf("got %q, want ok", got) + } + if err := restore(); err != nil { + t.Fatalf("restore error: %v", err) + } + if _, ok := os.LookupEnv(key); ok { + t.Fatalf("%s should be unset after restore", key) + } +} + func TestPreloadEnvFromArgs_EnvFileRepeatAndCSV(t *testing.T) { const inherited = "REDANT_PRELOAD_COMMON" const only1 = "REDANT_PRELOAD_FILE_A" @@ -197,3 +248,202 @@ func TestPreloadEnvFromArgs_RollbackOnError(t *testing.T) { t.Fatalf("%s should be rolled back when preload fails", key) } } + +func TestPreloadEnvFromArgs_RollbackOnParseErrorAfterMutation(t *testing.T) { + const key = "REDANT_PRELOAD_ROLLBACK_PARSE" + _ = os.Unsetenv(key) + + restore, err := preloadEnvFromArgs([]string{"-e", key + "=1", "--env"}) + if err == nil { + t.Fatalf("expected error") + } + if restore != nil { + t.Fatalf("restore should be nil on preload error") + } + if _, ok := os.LookupEnv(key); ok { + t.Fatalf("%s should be rolled back when parse fails", key) + } +} + +func TestParseEnvAssignment(t *testing.T) { + tests := []struct { + name string + raw string + wantKey string + wantValue string + wantErr string + }{ + {name: "basic", raw: "A=1", wantKey: "A", wantValue: "1"}, + {name: "trim spaces", raw: " A = 1 ", wantKey: "A", wantValue: "1"}, + {name: "value includes equals", raw: "A=a=b", wantKey: "A", wantValue: "a=b"}, + {name: "empty raw", raw: "", wantErr: "empty environment assignment"}, + {name: "missing equals", raw: "A", wantErr: "expected KEY=VALUE"}, + {name: "missing key", raw: "=1", wantErr: "expected KEY=VALUE"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key, value, err := parseEnvAssignment(tt.raw) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err=%v, want contains %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if key != tt.wantKey || value != tt.wantValue { + t.Fatalf("got key=%q value=%q, want key=%q value=%q", key, value, tt.wantKey, tt.wantValue) + } + }) + } +} + +func TestApplyEnvAssignmentsCSV(t *testing.T) { + got := make(map[string]string) + setEnv := func(key, value string) error { + got[key] = value + return nil + } + + err := applyEnvAssignmentsCSV(`A=1,"B=hello,world",C=3`, setEnv) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + + if got["A"] != "1" { + t.Fatalf("A=%q, want 1", got["A"]) + } + if got["B"] != "hello,world" { + t.Fatalf("B=%q, want hello,world", got["B"]) + } + if got["C"] != "3" { + t.Fatalf("C=%q, want 3", got["C"]) + } +} + +func TestLoadEnvFile_InvalidLineIncludesPosition(t *testing.T) { + tmp := filepath.Join(t.TempDir(), ".env") + if err := os.WriteFile(tmp, []byte("A=1\nINVALID\n"), 0o600); err != nil { + t.Fatalf("write env file: %v", err) + } + + err := loadEnvFile(tmp, func(key, value string) error { return nil }) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), ":2:") { + t.Fatalf("error should contain line number, got: %v", err) + } +} + +func TestConsumesNextArg(t *testing.T) { + tests := []struct { + name string + current string + flagName string + want bool + }{ + {name: "long env", current: "--env", flagName: "env", want: true}, + {name: "long env-file", current: "--env-file", flagName: "env-file", want: true}, + {name: "long inline", current: "--env=A=1", flagName: "env", want: false}, + {name: "short e", current: "-e", flagName: "env", want: true}, + {name: "short inline", current: "-eA=1", flagName: "env", want: false}, + {name: "other", current: "--name", flagName: "env", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := consumesNextArg(tt.current, tt.flagName); got != tt.want { + t.Fatalf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestPreloadEnvFromArgs_RollbackOnEnvFileLoadError(t *testing.T) { + const key = "REDANT_PRELOAD_ROLLBACK_FILE_ERROR" + _ = os.Unsetenv(key) + + missing := filepath.Join(t.TempDir(), "not-exists.env") + restore, err := preloadEnvFromArgs([]string{"-e", key + "=1", "--env-file", missing}) + if err == nil { + t.Fatalf("expected error") + } + if restore != nil { + t.Fatalf("restore should be nil on preload error") + } + if _, ok := os.LookupEnv(key); ok { + t.Fatalf("%s should be rolled back when env-file load fails", key) + } +} + +func TestPreloadEnvFromArgs_NoEnvFlagsNoChange(t *testing.T) { + const key = "REDANT_PRELOAD_NO_CHANGE" + t.Setenv(key, "orig") + + restore, err := preloadEnvFromArgs([]string{"--name", "demo", "subcmd"}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if restore != nil { + t.Fatalf("restore should be nil when env flags are absent") + } + if got := os.Getenv(key); got != "orig" { + t.Fatalf("got %q, want orig", got) + } +} + +func TestNormalizeEnvValue(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "single quoted", in: "'abc'", want: "abc"}, + {name: "double quoted", in: "\"abc\"", want: "abc"}, + {name: "double quoted escape", in: "\"a\\nb\"", want: "a\nb"}, + {name: "invalid double quote keep raw", in: "\"abc", want: "\"abc"}, + {name: "trim spaces", in: " abc ", want: "abc"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeEnvValue(tt.in); got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestRestoreEnvSnapshots(t *testing.T) { + const existing = "REDANT_RESTORE_SNAPSHOT_EXISTING" + const created = "REDANT_RESTORE_SNAPSHOT_CREATED" + + t.Setenv(existing, "current") + _ = os.Unsetenv(created) + + if err := os.Setenv(created, "temp"); err != nil { + t.Fatalf("set created: %v", err) + } + if err := os.Setenv(existing, "override"); err != nil { + t.Fatalf("set existing: %v", err) + } + + snapshots := map[string]envSnapshot{ + existing: {value: "orig", existed: true}, + created: {value: "", existed: false}, + } + + if err := restoreEnvSnapshots(snapshots); err != nil { + t.Fatalf("restoreEnvSnapshots err: %v", err) + } + + if got := os.Getenv(existing); got != "orig" { + t.Fatalf("existing=%q, want orig", got) + } + if _, ok := os.LookupEnv(created); ok { + t.Fatalf("created should be unset") + } +} From 1905d7c3aba6daeb952519b7201c4533e74294fd Mon Sep 17 00:00:00 2001 From: barry Date: Sat, 14 Mar 2026 21:52:22 +0800 Subject: [PATCH 03/76] chore: quick update feat/mcp at 2026-03-14 21:52:22 --- .github/copilot-instructions.md | 2 +- .../instructions/changelog.instructions.md | 17 +++-- .../documentation.instructions.md | 4 +- .../prompts/changelog-maintenance.prompt.md | 20 ++--- .version/changelog/README.md | 22 ++++++ .version/changelog/Unreleased.md | 23 ++++++ .version/changelog/v0.0.4.md | 13 ++++ .version/changelog/v0.0.5.md | 24 ++++++ .version/changelog/v0.0.6.md | 20 +++++ README.md | 8 +- docs/CHANGELOG.md | 75 ------------------- docs/CHANGELOG_LLM_PROMPT.md | 27 +++---- docs/DESIGN.md | 2 +- docs/EVALUATION.md | 4 +- docs/INDEX.md | 4 +- internal/pretty/README.md | 2 +- 16 files changed, 149 insertions(+), 118 deletions(-) create mode 100644 .version/changelog/README.md create mode 100644 .version/changelog/Unreleased.md create mode 100644 .version/changelog/v0.0.4.md create mode 100644 .version/changelog/v0.0.5.md create mode 100644 .version/changelog/v0.0.6.md delete mode 100644 docs/CHANGELOG.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 855423d..6519ac1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -43,7 +43,7 @@ - 文档入口:`docs/INDEX.md`。 - 涉及架构或流程变化时,先更新 `docs/DESIGN.md`,再补示例/说明文档。 -- 行为变更需同步 `docs/CHANGELOG.md`,必要时更新 `docs/EVALUATION.md`。 +- 行为变更需同步 `.version/changelog/Unreleased.md`,必要时更新 `docs/EVALUATION.md`。 - 文档默认使用中文,流程图优先 Mermaid。 ## 实施原则(对 AI 代理) diff --git a/.github/instructions/changelog.instructions.md b/.github/instructions/changelog.instructions.md index 916cea3..a0383d3 100644 --- a/.github/instructions/changelog.instructions.md +++ b/.github/instructions/changelog.instructions.md @@ -1,17 +1,16 @@ --- name: Changelog 专项规范 -description: 仅用于维护 docs/CHANGELOG.md,保证 Unreleased 与版本落版结构稳定、分类一致、条目可追溯 -applyTo: "docs/CHANGELOG.md" +description: 仅用于维护 .version/changelog,保证 Unreleased 与版本文件结构稳定、分类一致、条目可追溯 +applyTo: ".version/changelog/*.md" --- # Redant Changelog 维护规范 -本规则仅适用于 `docs/CHANGELOG.md`。 +本规则仅适用于 `.version/changelog/*.md`。 ## 结构约束 -- 保持顶层结构稳定:`[Unreleased]` 在前,历史版本按既有顺序保留。 -- `Unreleased` 推荐分类:`新增` / `修复` / `变更` / `文档`。 +- `Unreleased.md` 推荐分类:`新增` / `修复` / `变更` / `文档`。 - 若某分类暂无内容,写“暂无”。 ## 内容约束 @@ -19,13 +18,15 @@ applyTo: "docs/CHANGELOG.md" - 仅基于可见改动编写条目,不杜撰能力或影响。 - 单条应简洁、可读、可追溯,尽量以动词开头。 - 重复事项需合并去重,避免同义重复。 -- 不改写历史版本块语义,不重排已发布版本。 +- 不改写历史版本文件语义,不重排已发布版本。 ## 落版约束(release) - 版本号来源于 `.version/VERSION`。 -- 落版格式:`## [] - `。 -- 落版后需在顶部重建新的 `[Unreleased]` 模板(四个分类)。 +- 落版文件:`.version/changelog/.md`。 +- 文件头格式:`# [] - `。 +- 落版后需重建 `.version/changelog/Unreleased.md` 模板(四个分类)。 +- 落版后需同步更新 `.version/changelog/README.md` 索引。 ## 协同建议 diff --git a/.github/instructions/documentation.instructions.md b/.github/instructions/documentation.instructions.md index 634452e..2007d6b 100644 --- a/.github/instructions/documentation.instructions.md +++ b/.github/instructions/documentation.instructions.md @@ -19,7 +19,7 @@ applyTo: "**/*.md" - 文档入口为 `docs/INDEX.md`,新增文档时需补充索引关系(如适用)。 - 涉及架构或流程变化时,先更新 `docs/DESIGN.md`,再补示例/说明文档。 -- 行为变更需同步 `docs/CHANGELOG.md`;必要时同步 `docs/EVALUATION.md`。 +- 行为变更需同步 `.version/changelog/Unreleased.md`;必要时同步 `docs/EVALUATION.md`。 - 术语遵循 `docs/INDEX.md`,明确区分“参数(Args)”与“标志(Flag)”。 ## 写作与更新策略 @@ -31,6 +31,6 @@ applyTo: "**/*.md" ## Changelog 联动 -- 变更日志遵循 `docs/CHANGELOG.md` 现有结构:`新增 / 修复 / 变更 / 文档`。 +- 变更日志遵循 `.version/changelog/` 现有结构:`新增 / 修复 / 变更 / 文档`。 - 自动维护建议优先参考 `docs/CHANGELOG_LLM_PROMPT.md`。 - 发布前落版建议通过 agent 提示词执行:`/changelog-maintenance draft|release`。 diff --git a/.github/prompts/changelog-maintenance.prompt.md b/.github/prompts/changelog-maintenance.prompt.md index 7b1307c..eb0d369 100644 --- a/.github/prompts/changelog-maintenance.prompt.md +++ b/.github/prompts/changelog-maintenance.prompt.md @@ -1,6 +1,6 @@ --- name: changelog-maintenance -description: 维护 docs/CHANGELOG.md(更新 Unreleased 或执行版本落版) +description: 维护 .version/changelog(更新 Unreleased 或执行版本落版) argument-hint: "模式:draft(更新 Unreleased)或 release(按 .version/VERSION 落版)" agent: agent --- @@ -9,15 +9,16 @@ agent: agent ## 目标 -- `draft` 模式:根据当前改动更新 `docs/CHANGELOG.md` 的 `[Unreleased]`。 -- `release` 模式:将 `[Unreleased]` 落版为 `.version/VERSION` 对应版本,并重建空的 `[Unreleased]` 模板。 +- `draft` 模式:根据当前改动更新 `.version/changelog/Unreleased.md`。 +- `release` 模式:将 `Unreleased.md` 落版为 `.version/VERSION` 对应版本文件,并重建空模板。 ## 必读上下文 在开始前先读取并遵循: - `.github/copilot-instructions.md` -- `docs/CHANGELOG.md` +- `.version/changelog/README.md` +- `.version/changelog/Unreleased.md` - `docs/CHANGELOG_LLM_PROMPT.md` - `.version/VERSION` - 当前工作区 diff(如可获取) @@ -33,20 +34,21 @@ agent: agent ### draft -- 仅更新 `[Unreleased]` 区域。 +- 仅更新 `.version/changelog/Unreleased.md`。 - 若缺少分类小节则补齐;无内容的小节写“暂无”。 - 直接基于当前工作区改动与提交语义生成草稿,不依赖本地脚本输出。 ### release - 读取 `.version/VERSION` 作为目标版本号(如 `v0.0.6`)。 -- 将 `[Unreleased]` 内容迁移为新版本块:`## [] - `。 -- 在顶部重建新的 `[Unreleased]`,包含四个小节且初始值为“暂无”。 -- 直接在文档中完成落版,不依赖本地 task 或脚本。 +- 将 `Unreleased.md` 内容迁移到新版本文件:`.version/changelog/.md`。 +- 版本文件标题格式:`# [] - `。 +- 重建 `.version/changelog/Unreleased.md` 空模板(四个分类且初始值为“暂无”)。 +- 同步更新 `.version/changelog/README.md` 中的版本索引。 ## 输出要求 -- 直接给出对 `docs/CHANGELOG.md` 的修改(补丁或已应用结果)。 +- 直接给出对 `.version/changelog/` 相关文件的修改(补丁或已应用结果)。 - 末尾附一段简短自检: - 是否仅改动允许范围; - 是否完成分类与去重; diff --git a/.version/changelog/README.md b/.version/changelog/README.md new file mode 100644 index 0000000..3051840 --- /dev/null +++ b/.version/changelog/README.md @@ -0,0 +1,22 @@ +# Changelog 索引 + +本目录保存项目变更记录,采用“一个版本一个文件”的方式维护。 + +## 文件约定 + +- `Unreleased.md`:当前开发中变更(待发布)。 +- `vX.Y.Z.md`:已发布版本变更(例如 `v0.0.5.md`)。 + +## 当前版本文件 + +- [`Unreleased.md`](Unreleased.md) +- [`v0.0.6.md`](v0.0.6.md) +- [`v0.0.5.md`](v0.0.5.md) +- [`v0.0.4.md`](v0.0.4.md) + +## 维护约定 + +- 分类保持:`新增` / `修复` / `变更` / `文档`。 +- 发布时将 `Unreleased.md` 内容迁移到新版本文件,并重建空模板。 +- 历史版本文件只做勘误,不改写语义与顺序。 + diff --git a/.version/changelog/Unreleased.md b/.version/changelog/Unreleased.md new file mode 100644 index 0000000..8e40834 --- /dev/null +++ b/.version/changelog/Unreleased.md @@ -0,0 +1,23 @@ +# [Unreleased] + +> 推荐维护方式: +> +> - 使用 LLM 提示词自动更新:[`docs/CHANGELOG_LLM_PROMPT.md`](../../docs/CHANGELOG_LLM_PROMPT.md) +> - 建议通过 agent 提示词执行:`/changelog-maintenance draft|release` + +## 新增 + +暂无 + +## 修复 + +暂无 + +## 变更 + +暂无 + +## 文档 + +暂无 + diff --git a/.version/changelog/v0.0.4.md b/.version/changelog/v0.0.4.md new file mode 100644 index 0000000..fbca6e6 --- /dev/null +++ b/.version/changelog/v0.0.4.md @@ -0,0 +1,13 @@ +# [v0.0.4] - 2025-12-24 + +## 新增 + +- 发布 Redant 初始版本。 +- 支持命令树结构与多级子命令。 +- 支持命令行标志与环境变量多来源配置。 +- 支持中间件链式编排。 +- 支持自动帮助系统。 +- 支持多格式参数:位置参数、查询串、表单、JSON。 +- 支持统一全局标志管理。 +- 提供示例工程与基础测试。 + diff --git a/.version/changelog/v0.0.5.md b/.version/changelog/v0.0.5.md new file mode 100644 index 0000000..42040d8 --- /dev/null +++ b/.version/changelog/v0.0.5.md @@ -0,0 +1,24 @@ +# [v0.0.5] - 2026-01-20 + +## 修复 + +- 修复 `Int64.Type()` 返回类型错误导致 `pflag.GetInt64()` 获取失败的问题。 +- 修复废弃标志告警重复显示的问题。 +- 修复子命令无法继承父命令标志的问题。 +- 修复 `Option.Default` 默认值未正确应用到实际值的问题。 + +## 新增 + +- 增加命令执行相关单元测试(`command_test.go`)。 +- 增加标志值类型相关单元测试(`flags_test.go`)。 +- 增加框架评估文档(`docs/EVALUATION.md`)。 + +## 变更 + +- 优化目录结构,分离核心框架与命令实现。 +- 将补全命令移动到 `cmds/completioncmd`。 +- 移除配置文件与热更新相关能力,以保持框架简洁。 +- 优化 `--list-commands` 输出格式:去掉冗余标题、增强参数展示。 +- 优化 `--list-flags` 输出格式:精简子命令路径展示。 +- 增强全局标志显示:根命令中非隐藏标志可统一展示为全局标志。 + diff --git a/.version/changelog/v0.0.6.md b/.version/changelog/v0.0.6.md new file mode 100644 index 0000000..ec42e06 --- /dev/null +++ b/.version/changelog/v0.0.6.md @@ -0,0 +1,20 @@ +# [v0.0.6] - 2026-03-14 + +## 新增 + +- 新增内建全局环境标志:`--env`(简写 `-e`)与 `--env-file`,支持在命令解析前注入环境变量。 + +## 修复 + +- 修复 `preloadEnvFromArgs` 在预解析失败时可能残留已写入环境变量的问题,失败路径会自动回滚已变更项。 + +## 变更 + +- 将 `github.com/coder/pretty` 迁移为内部实现 `internal/pretty`,以消除上游停止维护带来的依赖风险。 +- `help.go` 改为使用内部导入路径:`github.com/pubgo/redant/internal/pretty`。 + +## 文档 + +- 新增内部维护文档:`internal/pretty/README.md`。 +- 更新 `README.md` 与 `docs/USAGE_AT_A_GLANCE.md`,补充全局环境标志说明与使用示例。 + diff --git a/README.md b/README.md index 061e327..d031118 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ flowchart TD A[README 总览] --> B[docs/INDEX.md 文档索引] B --> C[docs/DESIGN.md 架构与执行设计] B --> D[docs/EVALUATION.md 质量评估与改进] - B --> E[docs/CHANGELOG.md 版本变更] + B --> E[.version/changelog/README.md 版本变更] B --> F[example/args-test/README.md 参数解析示例] ``` @@ -17,7 +17,7 @@ flowchart TD - 使用规范速览:[`docs/USAGE_AT_A_GLANCE.md`](docs/USAGE_AT_A_GLANCE.md) - 架构设计:[`docs/DESIGN.md`](docs/DESIGN.md) - 评估报告:[`docs/EVALUATION.md`](docs/EVALUATION.md) -- 版本记录:[`docs/CHANGELOG.md`](docs/CHANGELOG.md) +- 版本记录:[`.version/changelog/README.md`](.version/changelog/README.md) - 参数示例:[`example/args-test/README.md`](example/args-test/README.md) 术语使用请参考:[`docs/INDEX.md`](docs/INDEX.md) 的“术语约定”章节。 @@ -218,9 +218,9 @@ app demo --env-file .env,.env.local 1. 常规文档维护(`README.md`、`docs/**`、`example/**/README.md` 等): - 直接在聊天中描述文档修改需求,文档专项规则会自动参与。 -2. 维护 `docs/CHANGELOG.md`(推荐): +2. 维护 `.version/changelog/Unreleased.md`(推荐): - 在聊天输入:`/changelog-maintenance draft` - - 用于根据当前改动更新 `Unreleased`。 + - 用于根据当前改动更新 `Unreleased.md`。 3. 发布前落版 `CHANGELOG`: - 在聊天输入:`/changelog-maintenance release` - 由 agent 按 `.version/VERSION` 自动执行版本落版。 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md deleted file mode 100644 index 4ff256c..0000000 --- a/docs/CHANGELOG.md +++ /dev/null @@ -1,75 +0,0 @@ -# 变更日志 - -本文档记录项目的关键版本变化。 - -> 关联文档:[`文档索引`](INDEX.md) · [`设计文档`](DESIGN.md) · [`评估报告`](EVALUATION.md) - -## 版本演进图 - -```mermaid -flowchart LR - U[Unreleased 当前开发中] --> V005[v0.0.5 稳定性与可用性增强] - V004[v0.0.4 初始版本] --> V005[v0.0.5 稳定性与可用性增强] -``` - -## [Unreleased] - -> 推荐维护方式: -> -> - 使用 LLM 提示词自动更新:[`CHANGELOG_LLM_PROMPT.md`](CHANGELOG_LLM_PROMPT.md) -> - 建议通过 agent 提示词执行:`/changelog-maintenance draft|release` - -### 新增 - -- 新增内建全局环境标志:`--env`(简写 `-e`)与 `--env-file`,支持在命令解析前注入环境变量。 - -### 修复 - -- 修复 `preloadEnvFromArgs` 在预解析失败时可能残留已写入环境变量的问题,失败路径会自动回滚已变更项。 - -### 变更 - -- 将 `github.com/coder/pretty` 迁移为内部实现 `internal/pretty`,以消除上游停止维护带来的依赖风险。 -- `help.go` 改为使用内部导入路径:`github.com/pubgo/redant/internal/pretty`。 - -### 文档 - -- 新增内部维护文档:`internal/pretty/README.md`。 -- 更新 `README.md` 与 `docs/USAGE_AT_A_GLANCE.md`,补充全局环境标志说明与使用示例。 - -## [v0.0.5] - 2026-01-20 - -### 修复 - -- 修复 `Int64.Type()` 返回类型错误导致 `pflag.GetInt64()` 获取失败的问题。 -- 修复废弃标志告警重复显示的问题。 -- 修复子命令无法继承父命令标志的问题。 -- 修复 `Option.Default` 默认值未正确应用到实际值的问题。 - -### 新增 - -- 增加命令执行相关单元测试(`command_test.go`)。 -- 增加标志值类型相关单元测试(`flags_test.go`)。 -- 增加框架评估文档(`docs/EVALUATION.md`)。 - -### 变更 - -- 优化目录结构,分离核心框架与命令实现。 -- 将补全命令移动到 `cmds/completioncmd`。 -- 移除配置文件与热更新相关能力,以保持框架简洁。 -- 优化 `--list-commands` 输出格式:去掉冗余标题、增强参数展示。 -- 优化 `--list-flags` 输出格式:精简子命令路径展示。 -- 增强全局标志显示:根命令中非隐藏标志可统一展示为全局标志。 - -## [v0.0.4] - 2025-12-24 - -### 新增 - -- 发布 Redant 初始版本。 -- 支持命令树结构与多级子命令。 -- 支持命令行标志与环境变量多来源配置。 -- 支持中间件链式编排。 -- 支持自动帮助系统。 -- 支持多格式参数:位置参数、查询串、表单、JSON。 -- 支持统一全局标志管理。 -- 提供示例工程与基础测试。 diff --git a/docs/CHANGELOG_LLM_PROMPT.md b/docs/CHANGELOG_LLM_PROMPT.md index d603a7b..6dc3231 100644 --- a/docs/CHANGELOG_LLM_PROMPT.md +++ b/docs/CHANGELOG_LLM_PROMPT.md @@ -4,22 +4,22 @@ ## 使用目标 -- 基于当前代码改动自动更新 `docs/CHANGELOG.md` 的 `Unreleased` 区域。 -- 发布前将 `Unreleased` 落版为 `.version/VERSION` 对应版本。 +- 基于当前代码改动自动更新 `.version/changelog/Unreleased.md`。 +- 发布前将 `Unreleased.md` 落版为 `.version/VERSION` 对应版本文件。 ## 模板 A:开发阶段(自动更新 Unreleased) 将以下提示词完整复制给 LLM: ```text -你是本仓库的 Changelog 维护助手。请根据当前工作区改动,自动更新 docs/CHANGELOG.md 的 [Unreleased] 区域。 +你是本仓库的 Changelog 维护助手。请根据当前工作区改动,自动更新 .version/changelog/Unreleased.md。 请严格执行: 1) 读取并理解以下文件: - .version/VERSION - - docs/CHANGELOG.md + - .version/changelog/Unreleased.md - 本次改动涉及的文件 diff(若可用) -2) 仅更新 docs/CHANGELOG.md 的 [Unreleased] 区域,不修改已发布版本历史。 +2) 仅更新 .version/changelog/Unreleased.md,不修改已发布版本文件。 3) 将变更归类到以下小节(不存在则创建): - 新增 - 修复 @@ -35,7 +35,7 @@ 7) 不杜撰内容,只基于可见改动生成。 输出要求: -- 直接给出对 docs/CHANGELOG.md 的修改结果(或补丁)。 +- 直接给出对 .version/changelog/Unreleased.md 的修改结果(或补丁)。 - 若某小节没有内容,写“暂无”。 ``` @@ -44,21 +44,22 @@ 将以下提示词完整复制给 LLM: ```text -你是本仓库的 Release Changelog 助手。请把 docs/CHANGELOG.md 中的 [Unreleased] 落版为 .version/VERSION 对应版本。 +你是本仓库的 Release Changelog 助手。请把 .version/changelog/Unreleased.md 落版为 .version/VERSION 对应版本文件。 请严格执行: 1) 读取: - .version/VERSION(例如 v0.0.6) - - docs/CHANGELOG.md + - .version/changelog/Unreleased.md 2) 在 changelog 中执行: - - 将 [Unreleased] 的现有内容迁移到新版本块: - ## [] - - - 在顶部重建新的 [Unreleased] 模板,包含四个小节:新增/修复/变更/文档,内容先写“暂无”。 -3) 不修改历史发布块内容顺序,不改写历史语义。 + - 创建新版本文件:`.version/changelog/.md`,格式为: + # [] - + - 将 `Unreleased.md` 的内容迁移到该版本文件(分类保持:新增/修复/变更/文档)。 + - 将 `Unreleased.md` 重建为空模板(四个分类,内容写“暂无”)。 +3) 不改写历史版本文件语义,不重排已发布版本顺序。 4) 保持现有 Markdown 风格与中文术语风格一致。 输出要求: -- 直接给出 docs/CHANGELOG.md 的修改结果(或补丁)。 +- 直接给出 `.version/changelog/.md` 与 `.version/changelog/Unreleased.md` 的修改结果(或补丁)。 ``` ## 推荐工作流 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f7b737c..c813428 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -9,7 +9,7 @@ Redant 的目标是提供一套可组合、可测试、可扩展的命令行框 - 命令执行链路的可观测与可扩展 - 帮助信息、补全、测试支持的一体化 -> 关联文档:[`README`](../README.md) · [`评估报告`](EVALUATION.md) · [`变更日志`](CHANGELOG.md) +> 关联文档:[`README`](../README.md) · [`评估报告`](EVALUATION.md) · [`变更日志`](../.version/changelog/README.md) ## 2. 总体架构 diff --git a/docs/EVALUATION.md b/docs/EVALUATION.md index 4dcc72a..9698c09 100644 --- a/docs/EVALUATION.md +++ b/docs/EVALUATION.md @@ -9,7 +9,7 @@ - 可测试性与可维护性 - 文档一致性与可读性 -> 关联文档:[`设计文档`](DESIGN.md) · [`变更日志`](CHANGELOG.md) · [`参数示例`](../example/args-test/README.md) +> 关联文档:[`设计文档`](DESIGN.md) · [`变更日志`](../.version/changelog/README.md) · [`参数示例`](../example/args-test/README.md) ## 2. 评估流程 @@ -69,5 +69,5 @@ stateDiagram-v2 ## 6. 版本关联建议 - 每次功能变化先更新 `DESIGN.md` 的流程或状态图。 -- 合并前更新 `CHANGELOG.md`,记录“新增/修复/变更”。 +- 合并前更新 `.version/changelog/Unreleased.md`,记录“新增/修复/变更/文档”。 - 复杂参数变化同步更新 `example/args-test/README.md`。 diff --git a/docs/INDEX.md b/docs/INDEX.md index 9cb452a..9532550 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -19,7 +19,7 @@ flowchart TD 2. [`USAGE_AT_A_GLANCE.md`](USAGE_AT_A_GLANCE.md):子命令命名、参数形态与标志(Flag)规范速览。 3. [`DESIGN.md`](DESIGN.md):核心模型、解析流程、状态机、扩展点。 4. [`EVALUATION.md`](EVALUATION.md):当前质量评估、风险、优化建议。 -5. [`CHANGELOG.md`](CHANGELOG.md):版本增量变化,便于追踪设计演进。 +5. [`../.version/changelog/README.md`](../.version/changelog/README.md):版本增量变化,便于追踪设计演进。 6. [`../example/args-test/README.md`](../example/args-test/README.md):参数解析实操样例。 7. [`../internal/pretty/README.md`](../internal/pretty/README.md):内部样式库维护说明(依赖迁移与维护边界)。 8. [`CHANGELOG_LLM_PROMPT.md`](CHANGELOG_LLM_PROMPT.md):基于 LLM 自动维护 changelog 的提示词模板。 @@ -27,7 +27,7 @@ flowchart TD ## 维护约定 - 新增模块时:先更新 `DESIGN.md`,再补充对应示例文档。 -- 变更行为时:同步更新 `CHANGELOG.md` 与 `EVALUATION.md` 的风险项。 +- 变更行为时:同步更新 `.version/changelog/Unreleased.md` 与 `EVALUATION.md` 的风险项。 - 文档统一使用中文,并优先使用 Mermaid 图表达流程、结构与状态。 - 外部依赖迁移到内部实现时,需补充对应内部模块维护文档(如 `internal/*/README.md`)。 diff --git a/internal/pretty/README.md b/internal/pretty/README.md index 48bbf50..d0c5467 100644 --- a/internal/pretty/README.md +++ b/internal/pretty/README.md @@ -42,7 +42,7 @@ 1. 明确变更动机(兼容性、性能、可维护性)。 2. 评估是否影响 `help.go` 现有输出。 3. 完成代码改动后执行格式化与测试。 -4. 更新 `docs/CHANGELOG.md` 记录变更。 +4. 更新 `.version/changelog/Unreleased.md` 记录变更。 ## 6. 验证建议 From 631d1aa090135c85d31f124fbc55dd5b8b7a9422 Mon Sep 17 00:00:00 2001 From: barry Date: Sat, 14 Mar 2026 21:53:57 +0800 Subject: [PATCH 04/76] chore: quick update feat/mcp at 2026-03-14 21:53:56 --- taskfile.yml => Taskfile.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename taskfile.yml => Taskfile.yml (100%) diff --git a/taskfile.yml b/Taskfile.yml similarity index 100% rename from taskfile.yml rename to Taskfile.yml From 16af2378ae61f08c0d185333be33d4d1990e7276 Mon Sep 17 00:00:00 2001 From: barry Date: Sat, 14 Mar 2026 22:12:34 +0800 Subject: [PATCH 05/76] chore: quick update feat/mcp at 2026-03-14 22:12:33 --- .version/changelog/Unreleased.md | 4 ++-- README.md | 3 +++ args.go | 8 +++++++ command.go | 17 ++++++++++++++ command_test.go | 39 ++++++++++++++++++++++++++++++++ docs/DESIGN.md | 1 + docs/USAGE_AT_A_GLANCE.md | 7 +++++- 7 files changed, 76 insertions(+), 3 deletions(-) diff --git a/.version/changelog/Unreleased.md b/.version/changelog/Unreleased.md index 8e40834..430e022 100644 --- a/.version/changelog/Unreleased.md +++ b/.version/changelog/Unreleased.md @@ -7,7 +7,7 @@ ## 新增 -暂无 +- 新增隐藏全局标志 `--args`:支持以重复参数或 CSV 形式覆盖命令位置参数,用于在需要时通过 flag 直接注入/替代 `args`。 ## 修复 @@ -19,5 +19,5 @@ ## 文档 -暂无 +- 补充 `README.md`、`docs/USAGE_AT_A_GLANCE.md` 与 `docs/DESIGN.md`:新增隐藏内部标志 `--args` 的用途、示例与 `RawArgs` 交互说明。 diff --git a/README.md b/README.md index d031118..0b22806 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,9 @@ flowchart TD - `--list-flags`:列出所有标志 - `--env, -e KEY=VALUE`:设置环境变量(支持重复与 CSV 批量) - `--env-file FILE`:从 env 文件加载环境变量(支持重复与 CSV 批量) +- `--args VALUE`:内部隐藏标志;支持重复与 CSV,用于直接覆盖命令位置参数 + +> 说明:`--args` 为内部能力,默认不会出现在帮助与标志列表中。 ### 环境标志使用示例 diff --git a/args.go b/args.go index cb177d3..c0e71bc 100644 --- a/args.go +++ b/args.go @@ -63,6 +63,8 @@ import ( // ArgValidator is a function that validates an argument. +const internalArgsOverrideFlag = "args" + type ArgSet []Arg type Arg struct { @@ -279,6 +281,12 @@ func GlobalFlags() OptionSet { Description: "Load environment variables from file(s). Supports repeat and CSV.", Value: StringArrayOf(new([]string)), }, + { + Flag: internalArgsOverrideFlag, + Description: "Internal: override parsed args using repeated/CSV values.", + Value: StringArrayOf(new([]string)), + Hidden: true, + }, } } diff --git a/command.go b/command.go index 8d2c868..93a0df0 100644 --- a/command.go +++ b/command.go @@ -720,6 +720,23 @@ func (inv *Invocation) run(state *runState) error { inv.Args = parsedArgs[state.commandDepth:] } + if inv.Flags != nil { + if internalArgsFlag := inv.Flags.Lookup(internalArgsOverrideFlag); internalArgsFlag != nil && internalArgsFlag.Changed { + var overriddenArgs []string + switch v := internalArgsFlag.Value.(type) { + case *StringArray: + overriddenArgs = append(overriddenArgs, (*v)...) + default: + parsed, err := readAsCSV(internalArgsFlag.Value.String()) + if err != nil { + return fmt.Errorf("reading %q override values: %w", internalArgsOverrideFlag, err) + } + overriddenArgs = append(overriddenArgs, parsed...) + } + inv.Args = append([]string(nil), overriddenArgs...) + } + } + // Parse args and set values to Arg.Value if Args are defined // Skip args parsing and validation if help was requested if len(inv.Command.Args) > 0 && !isHelpRequested && !errors.Is(state.flagParseErr, pflag.ErrHelp) { diff --git a/command_test.go b/command_test.go index ef7016d..937eaf8 100644 --- a/command_test.go +++ b/command_test.go @@ -763,3 +763,42 @@ func TestBusyboxArgv0DoesNotOverrideExplicitArgs(t *testing.T) { t.Fatalf("expected explicit args to win (bar), got %q", executed) } } + +func TestInternalArgsFlagOverridesParsedArgs(t *testing.T) { + var gotFirst string + var gotSecond string + + cmd := &Command{ + Use: "app", + Short: "test internal args flag", + Args: ArgSet{ + {Name: "first", Value: StringOf(&gotFirst)}, + {Name: "second", Value: StringOf(&gotSecond)}, + }, + Handler: func(ctx context.Context, inv *Invocation) error { + if len(inv.Args) != 2 { + t.Fatalf("inv.Args length = %d, want 2", len(inv.Args)) + } + if inv.Args[0] != "from-flag-1" || inv.Args[1] != "from-flag-2" { + t.Fatalf("inv.Args = %#v, want [from-flag-1 from-flag-2]", inv.Args) + } + return nil + }, + } + + inv := cmd.Invoke("from-cli-1", "from-cli-2", "--args", "from-flag-1", "--args", "from-flag-2") + inv.Stdout = &bytes.Buffer{} + inv.Stderr = &bytes.Buffer{} + + err := inv.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if gotFirst != "from-flag-1" { + t.Fatalf("first arg value = %q, want %q", gotFirst, "from-flag-1") + } + if gotSecond != "from-flag-2" { + t.Fatalf("second arg value = %q, want %q", gotSecond, "from-flag-2") + } +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index c813428..08fa832 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -77,6 +77,7 @@ flowchart TD 关键点: - 参数解析发生在命令定位与标志合并之后。 +- 非 `RawArgs` 模式下,若设置隐藏内部标志 `--args`,则在参数解析前用其值覆盖 `inv.Args`(支持重复与 CSV)。 - `RawArgs=true` 时,命令自行处理参数;框架不做常规标志解析。 - 对于复杂参数场景,建议在处理器中显式调用 `ParseQueryArgs`、`ParseFormArgs`、`ParseJSONArgs`。 diff --git a/docs/USAGE_AT_A_GLANCE.md b/docs/USAGE_AT_A_GLANCE.md index 833cbd8..cb3b5b7 100644 --- a/docs/USAGE_AT_A_GLANCE.md +++ b/docs/USAGE_AT_A_GLANCE.md @@ -61,10 +61,11 @@ flowchart TD | 环境变量回退 | `GIT_AUTHOR=alice app repo commit` | `Envs` 配置生效 | | 默认值 | 未传值时自动应用 | 由 `Default` 指定 | -内建全局环境标志: +内建全局标志: - `--env, -e KEY=VALUE`:设置环境变量(支持重复与 CSV)。 - `--env-file FILE`:从 env 文件加载环境变量(支持重复与 CSV)。 +- `--args VALUE`:内部隐藏标志;支持重复与 CSV,用于覆盖命令位置参数。 快速示例: @@ -74,8 +75,12 @@ app demo --env A=1,B=2 app demo --env-file .env app demo --env-file .env --env-file .env.local app demo --env-file .env,.env.local +app demo --args first --args second +app demo --args first,second ``` +说明:`--args` 为内部能力,默认不会出现在帮助信息与 `--list-flags` 输出中。 + ## 4) 通用输入模板 ```text From efee0f4b3ccb267c670417720bbc8dff4ac028b5 Mon Sep 17 00:00:00 2001 From: barry Date: Sat, 14 Mar 2026 23:56:03 +0800 Subject: [PATCH 06/76] chore: quick update feat/mcp at 2026-03-14 23:56:02 --- .version/changelog/Unreleased.md | 6 +- README.md | 13 + cmds/mcpcmd/mcp.go | 78 ++++ cmds/mcpcmd/mcp_test.go | 93 ++++ docs/DESIGN.md | 18 +- docs/INDEX.md | 1 + go.mod | 6 + go.sum | 18 + internal/mcpserver/server.go | 187 ++++++++ internal/mcpserver/server_test.go | 564 +++++++++++++++++++++++ internal/mcpserver/tools.go | 557 ++++++++++++++++++++++ internal/mcpserver/tools_mapping_test.go | 529 +++++++++++++++++++++ 12 files changed, 2061 insertions(+), 9 deletions(-) create mode 100644 cmds/mcpcmd/mcp.go create mode 100644 cmds/mcpcmd/mcp_test.go create mode 100644 internal/mcpserver/server.go create mode 100644 internal/mcpserver/server_test.go create mode 100644 internal/mcpserver/tools.go create mode 100644 internal/mcpserver/tools_mapping_test.go diff --git a/.version/changelog/Unreleased.md b/.version/changelog/Unreleased.md index 430e022..330ab05 100644 --- a/.version/changelog/Unreleased.md +++ b/.version/changelog/Unreleased.md @@ -8,6 +8,7 @@ ## 新增 - 新增隐藏全局标志 `--args`:支持以重复参数或 CSV 形式覆盖命令位置参数,用于在需要时通过 flag 直接注入/替代 `args`。 +- 新增 MCP 能力:提供 `cmds/mcpcmd` 与 `internal/mcpserver`,支持将命令树映射为 MCP Tools,并通过 `mcp serve --transport stdio` 对外服务。 ## 修复 @@ -15,9 +16,12 @@ ## 变更 -暂无 +- `internal/mcpserver` 的 MCP 协议处理切换为基于 `github.com/modelcontextprotocol/go-sdk`,移除自实现报文编解码,复用官方 Server/Transport 能力简化维护。 +- MCP `serverInfo.name` 改为从根命令名动态推导(为空时回退 `redant-mcp`),使对外标识与 CLI 应用名一致。 +- MCP `tools/call` 在保留文本 `Content` 的同时,新增 `StructuredContent`(`ok/stdout/stderr/error/combined`)并声明 `OutputSchema`,便于上层程序化消费。 ## 文档 - 补充 `README.md`、`docs/USAGE_AT_A_GLANCE.md` 与 `docs/DESIGN.md`:新增隐藏内部标志 `--args` 的用途、示例与 `RawArgs` 交互说明。 +- 补充 `README.md`、`docs/DESIGN.md` 与 `docs/INDEX.md`:新增 MCP 集成入口、模块职责与阅读路径。 diff --git a/README.md b/README.md index 0b22806..d71c055 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ flowchart TD - 自动帮助信息与全局标志 - 多格式参数解析(位置参数、查询串、表单、JSON) - Busybox 风格 argv0 调度(软链接命令入口) +- MCP 工具暴露(将命令树映射为 Model Context Protocol Tools) ## 架构总览 @@ -205,6 +206,18 @@ app demo --env-file .env,.env.local - `example/globalflags`:全局标志示例 - `example/args-test`:参数格式解析示例 +## MCP 集成 + +Redant 支持将命令树直接映射为 MCP Tools。推荐通过 `cmds/mcpcmd` 挂载命令: + +- `mcpcmd.AddMCPCommand(rootCmd)`:将 `mcp` 子命令添加到根命令。 +- `mcp serve --transport stdio`:以 stdio 方式启动 MCP 服务。 + +说明: + +- 工具列表来自当前命令树(默认过滤隐藏命令/隐藏标志)。 +- `tools/call` 会复用现有命令执行链路(标志解析、参数解析、中间件、Handler)。 + ## AI 协作:文档与 Changelog 维护 本仓库已提供面向 Copilot Chat 的文档与变更日志维护配置,便于在多人协作时保持文风一致、结构稳定、条目可追溯。 diff --git a/cmds/mcpcmd/mcp.go b/cmds/mcpcmd/mcp.go new file mode 100644 index 0000000..35b77c8 --- /dev/null +++ b/cmds/mcpcmd/mcp.go @@ -0,0 +1,78 @@ +package mcpcmd + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/pubgo/redant" + "github.com/pubgo/redant/internal/mcpserver" +) + +func New() *redant.Command { + var transport string + + serveCmd := &redant.Command{ + Use: "serve", + Short: "Start MCP server for current command tree.", + Long: "Expose current redant command tree as MCP tools over selected transport.", + Options: redant.OptionSet{ + { + Flag: "transport", + Description: "MCP transport type.", + Value: redant.EnumOf(&transport, "stdio"), + Default: "stdio", + }, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + transport = strings.TrimSpace(transport) + if transport == "" { + transport = "stdio" + } + + root := inv.Command + for root.Parent() != nil { + root = root.Parent() + } + + switch transport { + case "stdio": + return mcpserver.ServeStdio(ctx, root, inv.Stdin, inv.Stdout) + default: + return fmt.Errorf("unsupported mcp transport: %s", transport) + } + }, + } + + listCmd := &redant.Command{ + Use: "list", + Short: "List all MCP tools metadata.", + Long: "Print all mapped MCP tools (name, description, path, input/output schema) as JSON.", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + root := inv.Command + for root.Parent() != nil { + root = root.Parent() + } + + infos := mcpserver.ListToolInfos(root) + enc := json.NewEncoder(inv.Stdout) + enc.SetIndent("", " ") + return enc.Encode(infos) + }, + } + + return &redant.Command{ + Use: "mcp", + Short: "Model Context Protocol integration commands.", + Long: "Expose redant CLI definitions (commands/flags/args) as MCP tools.", + Children: []*redant.Command{ + listCmd, + serveCmd, + }, + } +} + +func AddMCPCommand(rootCmd *redant.Command) { + rootCmd.Children = append(rootCmd.Children, New()) +} diff --git a/cmds/mcpcmd/mcp_test.go b/cmds/mcpcmd/mcp_test.go new file mode 100644 index 0000000..bdff413 --- /dev/null +++ b/cmds/mcpcmd/mcp_test.go @@ -0,0 +1,93 @@ +package mcpcmd + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/pubgo/redant" +) + +func TestAddMCPCommand(t *testing.T) { + root := &redant.Command{Use: "app"} + AddMCPCommand(root) + + if len(root.Children) != 1 { + t.Fatalf("children len = %d, want 1", len(root.Children)) + } + + mcp := root.Children[0] + if mcp.Name() != "mcp" { + t.Fatalf("child name = %q, want %q", mcp.Name(), "mcp") + } + + if len(mcp.Children) != 2 { + t.Fatalf("mcp children len = %d, want 2", len(mcp.Children)) + } + + hasList := false + hasServe := false + for _, child := range mcp.Children { + switch child.Name() { + case "list": + hasList = true + case "serve": + hasServe = true + } + } + + if !hasList || !hasServe { + t.Fatalf("expected mcp list and mcp serve subcommands") + } +} + +func TestMCPListCommandPrintsToolInfos(t *testing.T) { + root := &redant.Command{Use: "app"} + + var message string + root.Children = append(root.Children, &redant.Command{ + Use: "echo", + Short: "echo one message", + Args: redant.ArgSet{ + {Name: "message", Required: true, Value: redant.StringOf(&message), Description: "text to echo"}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + }) + + AddMCPCommand(root) + + var stdout bytes.Buffer + inv := root.Invoke("mcp", "list") + inv.Stdout = &stdout + + if err := inv.Run(); err != nil { + t.Fatalf("run mcp list: %v", err) + } + + var tools []map[string]any + if err := json.Unmarshal(stdout.Bytes(), &tools); err != nil { + t.Fatalf("parse mcp list output as json: %v\noutput:\n%s", err, stdout.String()) + } + if len(tools) == 0 { + t.Fatalf("mcp list output is empty") + } + + var echoTool map[string]any + for _, tool := range tools { + if name, _ := tool["name"].(string); name == "echo" { + echoTool = tool + break + } + } + if echoTool == nil { + t.Fatalf("echo tool not found in mcp list output: %#v", tools) + } + + if _, ok := echoTool["inputSchema"].(map[string]any); !ok { + t.Fatalf("echo inputSchema missing: %#v", echoTool) + } + if _, ok := echoTool["outputSchema"].(map[string]any); !ok { + t.Fatalf("echo outputSchema missing: %#v", echoTool) + } +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 08fa832..dbad792 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -121,14 +121,15 @@ stateDiagram-v2 ## 5. 模块职责 -| 模块 | 主要文件 | 说明 | -| -------------- | ---------------------- | ---------------------------------- | -| 命令系统 | `command.go` | 命令树、命令查找、执行流程 | -| 选项系统 | `option.go` | 标志定义、FlagSet 构建 | -| 参数系统 | `args.go` | 多格式参数解析(查询串/表单/JSON) | -| 值类型系统 | `flags.go` | 自定义 `pflag.Value` 类型集合 | -| 帮助系统 | `help.go` / `help.tpl` | 帮助渲染、命令与标志展示 | -| 中间件与处理器 | `handler.go` | 执行链组装与业务回调 | +| 模块 | 主要文件 | 说明 | +| -------------- | ------------------------------------ | -------------------------------------- | +| 命令系统 | `command.go` | 命令树、命令查找、执行流程 | +| 选项系统 | `option.go` | 标志定义、FlagSet 构建 | +| 参数系统 | `args.go` | 多格式参数解析(查询串/表单/JSON) | +| 值类型系统 | `flags.go` | 自定义 `pflag.Value` 类型集合 | +| 帮助系统 | `help.go` / `help.tpl` | 帮助渲染、命令与标志展示 | +| 中间件与处理器 | `handler.go` | 执行链组装与业务回调 | +| MCP 集成 | `internal/mcpserver` + `cmds/mcpcmd` | 命令树到 MCP Tools 的映射与 stdio 服务 | ## 6. Busybox 风格 argv0 分发 @@ -181,6 +182,7 @@ sequenceDiagram - 自定义中间件:包装 `HandlerFunc` 实现统一鉴权、日志、超时控制。 - 自定义帮助模板:修改 `help.tpl`。 - 新增子命令:扩展 `Command.Children`。 +- MCP 暴露:挂载 `mcp` 子命令并复用现有命令执行链路对外提供 Tools。 ## 8. 文档关联 diff --git a/docs/INDEX.md b/docs/INDEX.md index 9532550..6969383 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -23,6 +23,7 @@ flowchart TD 6. [`../example/args-test/README.md`](../example/args-test/README.md):参数解析实操样例。 7. [`../internal/pretty/README.md`](../internal/pretty/README.md):内部样式库维护说明(依赖迁移与维护边界)。 8. [`CHANGELOG_LLM_PROMPT.md`](CHANGELOG_LLM_PROMPT.md):基于 LLM 自动维护 changelog 的提示词模板。 +9. [`../README.md#mcp-集成`](../README.md#mcp-集成):MCP 工具暴露与命令挂载入口。 ## 维护约定 diff --git a/go.mod b/go.mod index 1bed32c..e1b0cd1 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/mitchellh/go-wordwrap v1.0.1 + github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/muesli/termenv v0.16.0 github.com/spf13/pflag v1.0.10 golang.org/x/term v0.41.0 @@ -12,11 +13,16 @@ require ( require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sys v0.42.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index 7dfd336..c509be3 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,12 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -14,6 +20,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= +github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -22,13 +30,23 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go new file mode 100644 index 0000000..9be9013 --- /dev/null +++ b/internal/mcpserver/server.go @@ -0,0 +1,187 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/pubgo/redant" +) + +const ( + defaultMCPServerName = "redant-mcp" + mcpServerVersionPrefix = "v" +) + +type Server struct { + root *redant.Command + tools []toolDef + server *mcp.Server +} + +type ToolInfo struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Path []string `json:"path"` + InputSchema map[string]any `json:"inputSchema"` + OutputSchema map[string]any `json:"outputSchema"` +} + +func ListToolInfos(root *redant.Command) []ToolInfo { + defs := collectTools(root) + out := make([]ToolInfo, 0, len(defs)) + for _, td := range defs { + out = append(out, ToolInfo{ + Name: td.Name, + Description: td.Description, + Path: append([]string(nil), td.PathTokens...), + InputSchema: td.InputSchema, + OutputSchema: td.OutputSchema, + }) + } + return out +} + +func New(root *redant.Command) *Server { + s := &Server{ + root: root, + tools: collectTools(root), + server: mcp.NewServer(&mcp.Implementation{ + Name: serverNameFromRoot(root), + Version: mcpServerVersionPrefix + strings.TrimSpace(redant.Version()), + }, &mcp.ServerOptions{}), + } + s.registerTools() + return s +} + +func ServeStdio(ctx context.Context, root *redant.Command, r io.Reader, w io.Writer) error { + return New(root).ServeStdio(ctx, r, w) +} + +func (s *Server) ServeStdio(ctx context.Context, r io.Reader, w io.Writer) error { + if s == nil || s.root == nil || s.server == nil { + return errors.New("mcp server root command is nil") + } + if r == nil { + r = strings.NewReader("") + } + if w == nil { + w = io.Discard + } + + transport := &mcp.IOTransport{ + Reader: nopReadCloser{Reader: r}, + Writer: nopWriteCloser{Writer: w}, + } + return s.server.Run(ctx, transport) +} + +func (s *Server) registerTools() { + if s == nil || s.server == nil { + return + } + + for _, td := range s.tools { + tool := td + s.server.AddTool(&mcp.Tool{ + Name: tool.Name, + Description: tool.Description, + InputSchema: tool.InputSchema, + OutputSchema: tool.OutputSchema, + }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := map[string]any{} + if raw := req.Params.Arguments; len(raw) > 0 { + if err := json.Unmarshal(raw, &args); err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("invalid tool arguments: %v", err)}}, + IsError: true, + }, nil + } + } + + result, err := s.callTool(ctx, toolsCallParams{ + Name: tool.Name, + Arguments: args, + }) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: err.Error()}}, + IsError: true, + }, nil + } + + return mapToolResultToSDK(result), nil + }) + } +} + +func mapToolResultToSDK(result map[string]any) *mcp.CallToolResult { + text := "ok" + var structured any + if content, ok := result["content"]; ok { + switch vv := content.(type) { + case []map[string]any: + if len(vv) > 0 { + if t, ok := vv[0]["text"].(string); ok && t != "" { + text = t + } + } + case []any: + if len(vv) > 0 { + if m, ok := vv[0].(map[string]any); ok { + if t, ok := m["text"].(string); ok && t != "" { + text = t + } + } + } + } + } + if sc, ok := result["structuredContent"]; ok { + structured = sc + } + + isErr, _ := result["isError"].(bool) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + StructuredContent: structured, + IsError: isErr, + } +} + +type nopReadCloser struct { + io.Reader +} + +func (nopReadCloser) Close() error { + return nil +} + +type nopWriteCloser struct { + io.Writer +} + +func (nopWriteCloser) Close() error { + return nil +} + +type toolsCallParams struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments,omitempty"` +} + +func serverNameFromRoot(root *redant.Command) string { + if root == nil { + return defaultMCPServerName + } + + name := strings.TrimSpace(root.Name()) + if name == "" { + return defaultMCPServerName + } + return name +} diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go new file mode 100644 index 0000000..3a523d8 --- /dev/null +++ b/internal/mcpserver/server_test.go @@ -0,0 +1,564 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/pubgo/redant" +) + +func TestCollectToolsAndSchema(t *testing.T) { + var msg string + var upper bool + + root := &redant.Command{Use: "app"} + root.Children = append(root.Children, &redant.Command{ + Use: "echo", + Short: "echo message", + Args: redant.ArgSet{ + {Name: "message", Required: true, Value: redant.StringOf(&msg)}, + }, + Options: redant.OptionSet{ + {Flag: "upper", Value: redant.BoolOf(&upper), Description: "uppercase"}, + {Flag: "secret", Value: redant.StringOf(new(string)), Hidden: true}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + }) + + s := New(root) + if len(s.tools) != 1 { + t.Fatalf("tools count = %d, want 1", len(s.tools)) + } + + tool := s.tools[0] + if tool.Name != "echo" { + t.Fatalf("tool name = %q, want %q", tool.Name, "echo") + } + + flags, ok := tool.InputSchema["properties"].(map[string]any)["flags"].(map[string]any) + if !ok { + t.Fatalf("flags schema missing") + } + flagProps, ok := flags["properties"].(map[string]any) + if !ok { + t.Fatalf("flags properties missing") + } + if _, exists := flagProps["upper"]; !exists { + t.Fatalf("expected upper flag in schema") + } + if _, exists := flagProps["secret"]; exists { + t.Fatalf("hidden flag should not be exposed") + } +} + +func TestCallToolSuccess(t *testing.T) { + var msg string + var upper bool + + root := &redant.Command{Use: "app"} + root.Children = append(root.Children, &redant.Command{ + Use: "echo", + Args: redant.ArgSet{ + {Name: "message", Required: true, Value: redant.StringOf(&msg)}, + }, + Options: redant.OptionSet{ + {Flag: "upper", Value: redant.BoolOf(&upper)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + if upper { + _, _ = fmt.Fprint(inv.Stdout, strings.ToUpper(msg)) + return nil + } + _, _ = fmt.Fprint(inv.Stdout, msg) + return nil + }, + }) + + s := New(root) + result, err := s.callTool(context.Background(), toolsCallParams{ + Name: "echo", + Arguments: map[string]any{ + "args": map[string]any{"message": "hello"}, + "flags": map[string]any{"upper": true}, + }, + }) + if err != nil { + t.Fatalf("callTool error: %v", err) + } + + content, ok := result["content"].([]map[string]any) + if !ok || len(content) == 0 { + t.Fatalf("invalid content payload: %#v", result["content"]) + } + text, _ := content[0]["text"].(string) + if !strings.Contains(text, "HELLO") { + t.Fatalf("content text = %q, want contains HELLO", text) + } + + isError, _ := result["isError"].(bool) + if isError { + t.Fatalf("expected success result, got error") + } +} + +func TestServeSDKClientListAndCallTool(t *testing.T) { + var msg string + root := &redant.Command{Use: "app"} + root.Children = append(root.Children, &redant.Command{ + Use: "echo", + Args: redant.ArgSet{ + {Name: "message", Required: true, Value: redant.StringOf(&msg)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _, _ = fmt.Fprint(inv.Stdout, strings.ToUpper(msg)) + return nil + }, + }) + + srv := New(root) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serverErrCh := make(chan error, 1) + go func() { + serverErrCh <- srv.server.Run(ctx, serverTransport) + }() + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v1"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer func() { _ = session.Close() }() + if initRes := session.InitializeResult(); initRes == nil || initRes.ServerInfo == nil { + t.Fatalf("initialize result or server info is nil") + } else if initRes.ServerInfo.Name != "app" { + t.Fatalf("server info name = %q, want %q", initRes.ServerInfo.Name, "app") + } + + listRes, err := session.ListTools(ctx, &mcp.ListToolsParams{}) + if err != nil { + t.Fatalf("list tools: %v", err) + } + if len(listRes.Tools) == 0 { + t.Fatalf("expected at least one tool") + } + if listRes.Tools[0].Name != "echo" { + t.Fatalf("tool name = %q, want %q", listRes.Tools[0].Name, "echo") + } + + callRes, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "echo", + Arguments: map[string]any{ + "args": map[string]any{"message": "hello"}, + }, + }) + if err != nil { + t.Fatalf("call tool: %v", err) + } + if callRes.IsError { + t.Fatalf("call tool returned error result") + } + if len(callRes.Content) == 0 { + t.Fatalf("call tool content is empty") + } + text, ok := callRes.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("first content is not text") + } + if !strings.Contains(text.Text, "HELLO") { + t.Fatalf("content text = %q, want contains HELLO", text.Text) + } + + structured, ok := callRes.StructuredContent.(map[string]any) + if !ok { + t.Fatalf("structured content is not object: %#v", callRes.StructuredContent) + } + if okVal, _ := structured["ok"].(bool); !okVal { + t.Fatalf("structured ok = %#v, want true", structured["ok"]) + } + if stdout, _ := structured["stdout"].(string); !strings.Contains(stdout, "HELLO") { + t.Fatalf("structured stdout = %q, want contains HELLO", stdout) + } + + cancel() + if err := <-serverErrCh; err != nil && !strings.Contains(err.Error(), "context canceled") { + t.Fatalf("server run error: %v", err) + } +} + +func TestServeSDKClientValidatesToolDescriptionAndParameters(t *testing.T) { + var ( + service string + stage string + dryRun bool + ) + + root := &redant.Command{Use: "app"} + root.Children = append(root.Children, &redant.Command{ + Use: "deploy", + Short: "deploy service", + Long: "deploy service to target environment", + Args: redant.ArgSet{ + {Name: "service", Required: true, Value: redant.StringOf(&service), Description: "service name"}, + }, + Options: redant.OptionSet{ + {Flag: "stage", Value: redant.EnumOf(&stage, "dev", "prod"), Required: true, Description: "target environment"}, + {Flag: "dry-run", Value: redant.BoolOf(&dryRun), Description: "only print action"}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + if dryRun { + _, _ = fmt.Fprintf(inv.Stdout, "dry-run deploy %s to %s", service, stage) + return nil + } + _, _ = fmt.Fprintf(inv.Stdout, "deploy %s to %s", service, stage) + return nil + }, + }) + + srv := New(root) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serverErrCh := make(chan error, 1) + go func() { + serverErrCh <- srv.server.Run(ctx, serverTransport) + }() + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v1"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer func() { _ = session.Close() }() + + listRes, err := session.ListTools(ctx, &mcp.ListToolsParams{}) + if err != nil { + t.Fatalf("list tools: %v", err) + } + + var deployTool *mcp.Tool + for i := range listRes.Tools { + if listRes.Tools[i] != nil && listRes.Tools[i].Name == "deploy" { + deployTool = listRes.Tools[i] + break + } + } + if deployTool == nil { + t.Fatalf("tool deploy not found in %#v", listRes.Tools) + } + + if deployTool.Description != "deploy service\n\ndeploy service to target environment" { + t.Fatalf("description = %q", deployTool.Description) + } + + assertJSONSubset(t, deployTool.InputSchema, `{ + "type": "object", + "additionalProperties": false, + "properties": { + "args": { + "type": "object", + "required": ["service"], + "properties": { + "service": {"type": "string", "description": "service name"} + } + }, + "flags": { + "type": "object", + "required": ["stage"], + "properties": { + "stage": {"type": "string", "enum": ["dev", "prod"], "description": "target environment"}, + "dry-run": {"type": "boolean", "description": "only print action"} + } + } + } + }`) + + assertJSONSubset(t, deployTool.OutputSchema, `{ + "type": "object", + "required": ["ok", "stdout", "stderr", "error", "combined"], + "properties": { + "ok": {"type": "boolean"}, + "stdout": {"type": "string"}, + "stderr": {"type": "string"}, + "error": {"type": "string"}, + "combined": {"type": "string"} + } + }`) + + callRes, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "deploy", + Arguments: map[string]any{ + "args": map[string]any{"service": "api"}, + "flags": map[string]any{ + "stage": "dev", + "dry-run": true, + }, + }, + }) + if err != nil { + t.Fatalf("call tool valid params: %v", err) + } + if callRes.IsError { + t.Fatalf("valid call should not be error, got: %q", firstText(callRes.Content)) + } + if len(callRes.Content) == 0 { + t.Fatalf("call tool content is empty") + } + text, ok := callRes.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("first content is not text") + } + if !strings.Contains(text.Text, "dry-run deploy api to dev") { + t.Fatalf("content text = %q", text.Text) + } + + assertJSONSubset(t, callRes.StructuredContent, `{ + "ok": true, + "stdout": "dry-run deploy api to dev", + "stderr": "", + "error": "", + "combined": "dry-run deploy api to dev" + }`) + + cancel() + if err := <-serverErrCh; err != nil && !strings.Contains(err.Error(), "context canceled") { + t.Fatalf("server run error: %v", err) + } +} + +func TestServeSDKClientStructFlagAndArg(t *testing.T) { + type payload struct { + Name string `json:"name" yaml:"name"` + Port int `json:"port" yaml:"port"` + } + + argPayload := &redant.Struct[payload]{} + flagPayload := &redant.Struct[payload]{} + + root := &redant.Command{Use: "app"} + root.Children = append(root.Children, &redant.Command{ + Use: "apply", + Short: "apply config", + Long: "apply config with structured arg and structured flag", + Args: redant.ArgSet{ + {Name: "config", Required: true, Value: argPayload, Description: "config payload"}, + }, + Options: redant.OptionSet{ + {Flag: "meta", Value: flagPayload, Required: true, Description: "meta payload"}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _, _ = fmt.Fprintf(inv.Stdout, + "arg=%s:%d flag=%s:%d", + argPayload.Value.Name, + argPayload.Value.Port, + flagPayload.Value.Name, + flagPayload.Value.Port, + ) + return nil + }, + }) + + srv := New(root) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serverErrCh := make(chan error, 1) + go func() { + serverErrCh <- srv.server.Run(ctx, serverTransport) + }() + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v1"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer func() { _ = session.Close() }() + + listRes, err := session.ListTools(ctx, &mcp.ListToolsParams{}) + if err != nil { + t.Fatalf("list tools: %v", err) + } + + var applyTool *mcp.Tool + for i := range listRes.Tools { + if listRes.Tools[i] != nil && listRes.Tools[i].Name == "apply" { + applyTool = listRes.Tools[i] + break + } + } + if applyTool == nil { + t.Fatalf("tool apply not found in %#v", listRes.Tools) + } + + assertJSONSubset(t, applyTool.InputSchema, `{ + "type": "object", + "additionalProperties": false, + "properties": { + "args": { + "type": "object", + "required": ["config"], + "properties": { + "config": {"type": "object", "description": "config payload"} + } + }, + "flags": { + "type": "object", + "required": ["meta"], + "properties": { + "meta": {"type": "object", "description": "meta payload"} + } + } + } + }`) + + callRes, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "apply", + Arguments: map[string]any{ + "args": map[string]any{ + "config": map[string]any{ + "name": "api", + "port": 8080, + }, + }, + "flags": map[string]any{ + "meta": map[string]any{ + "name": "prod", + "port": 9000, + }, + }, + }, + }) + if err != nil { + t.Fatalf("call tool: %v", err) + } + if callRes.IsError { + t.Fatalf("call with struct payloads should succeed, got: %q", firstText(callRes.Content)) + } + + if len(callRes.Content) == 0 { + t.Fatalf("call tool content is empty") + } + text, ok := callRes.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("first content is not text") + } + if !strings.Contains(text.Text, "arg=api:8080 flag=prod:9000") { + t.Fatalf("content text = %q", text.Text) + } + + assertJSONSubset(t, callRes.StructuredContent, `{ + "ok": true, + "stdout": "arg=api:8080 flag=prod:9000", + "stderr": "", + "error": "", + "combined": "arg=api:8080 flag=prod:9000" + }`) + + cancel() + if err := <-serverErrCh; err != nil && !strings.Contains(err.Error(), "context canceled") { + t.Fatalf("server run error: %v", err) + } +} + +func firstText(content []mcp.Content) string { + if len(content) == 0 { + return "" + } + t, ok := content[0].(*mcp.TextContent) + if !ok { + return "" + } + return t.Text +} + +func assertJSONSubset(t *testing.T, got any, wantJSON string) { + t.Helper() + + gotNormalized := normalizeJSONLike(t, got) + + var want any + if err := json.Unmarshal([]byte(wantJSON), &want); err != nil { + t.Fatalf("invalid expected json: %v\n%s", err, wantJSON) + } + + if err := checkJSONSubset(gotNormalized, want, "$", true); err != nil { + t.Fatalf("json contract mismatch: %v\nwant subset:\n%s\ngot:\n%s", err, prettyJSON(want), prettyJSON(gotNormalized)) + } +} + +func normalizeJSONLike(t *testing.T, v any) any { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal json-like value failed: %v", err) + } + var out any + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("unmarshal json-like value failed: %v", err) + } + return out +} + +func checkJSONSubset(got, want any, path string, exactArray bool) error { + switch wantTyped := want.(type) { + case map[string]any: + gotMap, ok := got.(map[string]any) + if !ok { + return fmt.Errorf("%s expected object, got %T", path, got) + } + for k, wantV := range wantTyped { + gotV, exists := gotMap[k] + if !exists { + return fmt.Errorf("%s.%s missing", path, k) + } + if err := checkJSONSubset(gotV, wantV, path+"."+k, exactArray); err != nil { + return err + } + } + return nil + + case []any: + gotArr, ok := got.([]any) + if !ok { + return fmt.Errorf("%s expected array, got %T", path, got) + } + if exactArray && len(gotArr) != len(wantTyped) { + return fmt.Errorf("%s expected array len %d, got %d", path, len(wantTyped), len(gotArr)) + } + for i := range wantTyped { + if i >= len(gotArr) { + return fmt.Errorf("%s[%d] missing", path, i) + } + if err := checkJSONSubset(gotArr[i], wantTyped[i], fmt.Sprintf("%s[%d]", path, i), exactArray); err != nil { + return err + } + } + return nil + + default: + if !reflect.DeepEqual(got, want) { + return fmt.Errorf("%s expected %#v, got %#v", path, want, got) + } + return nil + } +} + +func prettyJSON(v any) string { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Sprintf("", err) + } + return string(b) +} diff --git a/internal/mcpserver/tools.go b/internal/mcpserver/tools.go new file mode 100644 index 0000000..82dc221 --- /dev/null +++ b/internal/mcpserver/tools.go @@ -0,0 +1,557 @@ +package mcpserver + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/pubgo/redant" +) + +type toolDef struct { + Name string + Description string + PathTokens []string + Command *redant.Command + Options redant.OptionSet + InputSchema map[string]any + OutputSchema map[string]any +} + +func collectTools(root *redant.Command) []toolDef { + if root == nil { + return nil + } + + var tools []toolDef + var walk func(cmd *redant.Command, path []string, inheritedOptions redant.OptionSet) + walk = func(cmd *redant.Command, path []string, inheritedOptions redant.OptionSet) { + if cmd == nil || cmd.Hidden { + return + } + + effectiveOptions := make(redant.OptionSet, 0, len(inheritedOptions)+len(cmd.Options)) + effectiveOptions = append(effectiveOptions, inheritedOptions...) + effectiveOptions = append(effectiveOptions, cmd.Options...) + + if cmd.Handler != nil { + tools = append(tools, toolDef{ + Name: strings.Join(path, "."), + Description: commandDescription(cmd), + PathTokens: append([]string(nil), path...), + Command: cmd, + Options: append(redant.OptionSet(nil), effectiveOptions...), + InputSchema: buildInputSchema(cmd.Args, effectiveOptions), + OutputSchema: buildOutputSchema(), + }) + } + + for _, child := range cmd.Children { + walk(child, append(path, child.Name()), effectiveOptions) + } + } + + for _, child := range root.Children { + walk(child, []string{child.Name()}, root.Options) + } + + return tools +} + +func commandDescription(cmd *redant.Command) string { + short := strings.TrimSpace(cmd.Short) + long := strings.TrimSpace(cmd.Long) + switch { + case short != "" && long != "": + return short + "\n\n" + long + case short != "": + return short + case long != "": + return long + default: + return "" + } +} + +func buildInputSchema(args redant.ArgSet, options redant.OptionSet) map[string]any { + argsSchema := buildArgsSchema(args) + flagsSchema := buildFlagsSchema(options) + + properties := map[string]any{"flags": flagsSchema} + + if len(args) > 0 { + properties["args"] = argsSchema + } else { + properties["args"] = map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "string", + }, + "description": "Positional args array for commands without ArgSet definition.", + } + } + + schema := map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": properties, + } + return schema +} + +func buildOutputSchema() map[string]any { + return map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "ok": map[string]any{ + "type": "boolean", + }, + "stdout": map[string]any{ + "type": "string", + }, + "stderr": map[string]any{ + "type": "string", + }, + "error": map[string]any{ + "type": "string", + }, + "combined": map[string]any{ + "type": "string", + }, + }, + "required": []string{"ok", "stdout", "stderr", "error", "combined"}, + } +} + +func buildArgsSchema(args redant.ArgSet) map[string]any { + props := map[string]any{} + var required []string + + for i, arg := range args { + name := arg.Name + if name == "" { + name = fmt.Sprintf("arg%d", i+1) + } + + argSchema := valueTypeToSchema(typeOfValue(arg.Value)) + if arg.Description != "" { + argSchema["description"] = arg.Description + } + if arg.Default != "" { + argSchema["default"] = arg.Default + } + + props[name] = argSchema + if arg.Required && arg.Default == "" { + required = append(required, name) + } + } + + schema := map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": props, + } + if len(required) > 0 { + schema["required"] = required + } + return schema +} + +func buildFlagsSchema(opts redant.OptionSet) map[string]any { + props := map[string]any{} + var required []string + + for _, opt := range opts { + if opt.Flag == "" || opt.Hidden || isSystemFlag(opt.Flag) { + continue + } + + flagSchema := valueTypeToSchema(opt.Type()) + if opt.Description != "" { + flagSchema["description"] = opt.Description + } + if opt.Default != "" { + flagSchema["default"] = opt.Default + } + if len(opt.Envs) > 0 { + flagSchema["x-env"] = opt.Envs + } + + props[opt.Flag] = flagSchema + if opt.Required && opt.Default == "" && len(opt.Envs) == 0 { + required = append(required, opt.Flag) + } + } + + schema := map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": props, + } + if len(required) > 0 { + schema["required"] = required + } + return schema +} + +func typeOfValue(v any) string { + if v == nil { + return "string" + } + t, ok := v.(interface{ Type() string }) + if !ok { + return "string" + } + return t.Type() +} + +func valueTypeToSchema(typ string) map[string]any { + typ = strings.TrimSpace(typ) + if typ == "" { + return map[string]any{"type": "string"} + } + + if strings.HasPrefix(typ, "enum[") && strings.HasSuffix(typ, "]") { + choices := strings.TrimSuffix(strings.TrimPrefix(typ, "enum["), "]") + return map[string]any{ + "type": "string", + "enum": splitEnumChoices(choices), + } + } + + if strings.HasPrefix(typ, "enum-array[") && strings.HasSuffix(typ, "]") { + choices := strings.TrimSuffix(strings.TrimPrefix(typ, "enum-array["), "]") + return map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "string", + "enum": splitEnumChoices(choices), + }, + } + } + + if strings.HasPrefix(typ, "struct[") && strings.HasSuffix(typ, "]") { + return map[string]any{ + "type": "object", + "additionalProperties": true, + "x-redant-value-type": typ, + } + } + + switch typ { + case "int", "int64": + return map[string]any{"type": "integer"} + case "float", "float64": + return map[string]any{"type": "number"} + case "bool": + return map[string]any{"type": "boolean"} + case "string-array": + return map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "string", + }, + } + default: + return map[string]any{"type": "string"} + } +} + +func splitEnumChoices(raw string) []string { + parts := strings.Split(raw, "\\|") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + out = append(out, p) + } + return out +} + +func (s *Server) callTool(ctx context.Context, params toolsCallParams) (map[string]any, error) { + if params.Name == "" { + return nil, errorsNew("missing tool name") + } + + tool, err := s.findTool(params.Name) + if err != nil { + return nil, err + } + + argv, err := buildArgv(tool, params.Arguments) + if err != nil { + return nil, err + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + inv := s.root.Invoke(argv...) + inv.Stdout = &stdout + inv.Stderr = &stderr + inv.Stdin = bytes.NewReader(nil) + + runErr := inv.WithContext(ctx).Run() + return buildToolResult(stdout.String(), stderr.String(), runErr), nil +} + +func (s *Server) findTool(name string) (toolDef, error) { + for _, t := range s.tools { + if t.Name == name { + return t, nil + } + } + return toolDef{}, fmt.Errorf("tool %q not found", name) +} + +func buildArgv(tool toolDef, input map[string]any) ([]string, error) { + argv := append([]string(nil), tool.PathTokens...) + + flagsInput := map[string]any{} + if raw, ok := input["flags"]; ok { + m, ok := raw.(map[string]any) + if !ok { + return nil, errorsNew("arguments.flags must be an object") + } + flagsInput = m + } + + flagByName := map[string]redant.Option{} + for _, opt := range tool.Options { + if opt.Flag == "" || opt.Hidden || isSystemFlag(opt.Flag) { + continue + } + flagByName[opt.Flag] = opt + } + + keys := make([]string, 0, len(flagsInput)) + for k := range flagsInput { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + v := flagsInput[k] + opt, ok := flagByName[k] + if !ok { + return nil, fmt.Errorf("unknown flag %q for tool %q", k, tool.Name) + } + + flagTokens, err := serializeFlag(opt, v) + if err != nil { + return nil, fmt.Errorf("flag %q: %w", k, err) + } + argv = append(argv, flagTokens...) + } + + argsTokens, err := serializeArgs(tool.Command.Args, input["args"]) + if err != nil { + return nil, err + } + argv = append(argv, argsTokens...) + + return argv, nil +} + +func serializeArgs(def redant.ArgSet, raw any) ([]string, error) { + if len(def) == 0 { + if raw == nil { + return nil, nil + } + vals, ok := raw.([]any) + if !ok { + return nil, errorsNew("arguments.args must be an array for commands without ArgSet") + } + out := make([]string, 0, len(vals)) + for _, v := range vals { + out = append(out, toString(v)) + } + return out, nil + } + + if raw == nil { + return nil, nil + } + argMap, ok := raw.(map[string]any) + if !ok { + return nil, errorsNew("arguments.args must be an object") + } + + out := make([]string, 0, len(def)) + for i, arg := range def { + name := arg.Name + if name == "" { + name = fmt.Sprintf("arg%d", i+1) + } + + v, ok := argMap[name] + if !ok { + if arg.Required && arg.Default == "" { + return nil, fmt.Errorf("missing required arg %q", name) + } + continue + } + + encoded, err := serializeValueByType(typeOfValue(arg.Value), v) + if err != nil { + return nil, fmt.Errorf("arg %q: %w", name, err) + } + out = append(out, encoded) + } + + return out, nil +} + +func serializeFlag(opt redant.Option, v any) ([]string, error) { + flag := "--" + opt.Flag + schema := valueTypeToSchema(opt.Type()) + typ, _ := schema["type"].(string) + + switch typ { + case "boolean": + bv, ok := v.(bool) + if !ok { + return nil, errorsNew("expected boolean") + } + if bv { + return []string{flag}, nil + } + return []string{flag + "=false"}, nil + + case "array": + arr, ok := v.([]any) + if !ok { + return nil, errorsNew("expected array") + } + out := make([]string, 0, len(arr)*2) + for _, item := range arr { + out = append(out, flag, toString(item)) + } + return out, nil + + case "object": + encoded, err := serializeObjectLike(v) + if err != nil { + return nil, err + } + return []string{flag, encoded}, nil + + default: + encoded, err := serializeValueByType(opt.Type(), v) + if err != nil { + return nil, err + } + return []string{flag, encoded}, nil + } +} + +func serializeValueByType(valueType string, v any) (string, error) { + typeSchema := valueTypeToSchema(valueType) + typeName, _ := typeSchema["type"].(string) + + switch typeName { + case "object": + return serializeObjectLike(v) + default: + return toString(v), nil + } +} + +func serializeObjectLike(v any) (string, error) { + if s, ok := v.(string); ok { + return s, nil + } + + b, err := json.Marshal(v) + if err != nil { + return "", fmt.Errorf("expected object-compatible value: %w", err) + } + + return string(b), nil +} + +func toString(v any) string { + switch vv := v.(type) { + case nil: + return "" + case string: + return vv + case bool: + return strconv.FormatBool(vv) + case float64: + if vv == float64(int64(vv)) { + return strconv.FormatInt(int64(vv), 10) + } + return strconv.FormatFloat(vv, 'f', -1, 64) + case json.Number: + return vv.String() + default: + return fmt.Sprintf("%v", vv) + } +} + +func errorsNew(msg string) error { + return errors.New(msg) +} + +func buildToolResult(stdout, stderr string, runErr error) map[string]any { + var out bytes.Buffer + errText := "" + if stdout != "" { + _, _ = out.WriteString(stdout) + } + if stderr != "" { + if out.Len() > 0 { + _, _ = out.WriteString("\n") + } + _, _ = out.WriteString("stderr:\n") + _, _ = out.WriteString(stderr) + } + if runErr != nil { + errText = runErr.Error() + if out.Len() > 0 { + _, _ = out.WriteString("\n") + } + _, _ = out.WriteString("error:\n") + _, _ = out.WriteString(errText) + } + if out.Len() == 0 { + _, _ = out.WriteString("ok") + } + + combined := out.String() + structured := map[string]any{ + "ok": runErr == nil, + "stdout": stdout, + "stderr": stderr, + "error": errText, + "combined": combined, + } + + return map[string]any{ + "content": []map[string]any{{ + "type": "text", + "text": combined, + }}, + "isError": runErr != nil, + "structuredContent": structured, + } +} + +func isSystemFlag(flag string) bool { + switch flag { + case "help", "list-commands", "list-flags", "args": + return true + default: + return false + } +} diff --git a/internal/mcpserver/tools_mapping_test.go b/internal/mcpserver/tools_mapping_test.go new file mode 100644 index 0000000..37b39b0 --- /dev/null +++ b/internal/mcpserver/tools_mapping_test.go @@ -0,0 +1,529 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" + + "github.com/pubgo/redant" +) + +func TestCollectToolsCommandToToolDefComprehensive(t *testing.T) { + var ( + verbose bool + parentVal string + runVal string + target string + ) + + root := &redant.Command{ + Use: "app", + Options: redant.OptionSet{ + {Flag: "verbose", Value: redant.BoolOf(&verbose), Description: "enable verbose output"}, + {Flag: "internal", Value: redant.StringOf(new(string)), Hidden: true}, + }, + } + + group := &redant.Command{ + Use: "group", + Short: "group command", + Options: redant.OptionSet{ + {Flag: "parent-flag", Value: redant.StringOf(&parentVal), Description: "inherited from parent"}, + }, + } + + run := &redant.Command{ + Use: "run", + Short: "run short", + Long: "run long description", + Args: redant.ArgSet{ + {Name: "target", Required: true, Value: redant.StringOf(&target), Description: "target name"}, + }, + Options: redant.OptionSet{ + {Flag: "run-flag", Value: redant.StringOf(&runVal), Description: "child flag"}, + {Flag: "hidden-child", Value: redant.StringOf(new(string)), Hidden: true}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + } + + hidden := &redant.Command{ + Use: "hidden", + Hidden: true, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + return nil + }, + } + + echo := &redant.Command{ + Use: "echo", + Short: "echo short", + Aliases: []string{"e"}, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + return nil + }, + } + + group.Children = append(group.Children, run, hidden) + root.Children = append(root.Children, group, echo) + + tools := collectTools(root) + if len(tools) != 2 { + t.Fatalf("tools len = %d, want 2", len(tools)) + } + + runTool := mustFindToolByName(t, tools, "group.run") + if runTool.Description != "run short\n\nrun long description" { + t.Fatalf("run tool description = %q", runTool.Description) + } + + if got := runTool.PathTokens; !reflect.DeepEqual(got, []string{"group", "run"}) { + t.Fatalf("run tool path tokens = %#v", got) + } + + flagsSchema, ok := runTool.InputSchema["properties"].(map[string]any)["flags"].(map[string]any) + if !ok { + t.Fatalf("run tool flags schema missing") + } + flagProps, ok := flagsSchema["properties"].(map[string]any) + if !ok { + t.Fatalf("run tool flags properties missing") + } + + for _, want := range []string{"verbose", "parent-flag", "run-flag"} { + if _, exists := flagProps[want]; !exists { + t.Fatalf("missing expected flag %q in schema", want) + } + } + for _, notWant := range []string{"internal", "hidden-child", "help", "list-commands", "list-flags", "args"} { + if _, exists := flagProps[notWant]; exists { + t.Fatalf("unexpected flag %q in schema", notWant) + } + } + + argsSchema, ok := runTool.InputSchema["properties"].(map[string]any)["args"].(map[string]any) + if !ok { + t.Fatalf("run tool args schema missing") + } + required, ok := argsSchema["required"].([]string) + if !ok || len(required) != 1 || required[0] != "target" { + t.Fatalf("args required = %#v, want [target]", argsSchema["required"]) + } + + echoTool := mustFindToolByName(t, tools, "echo") + if got := echoTool.PathTokens; !reflect.DeepEqual(got, []string{"echo"}) { + t.Fatalf("echo tool path tokens = %#v", got) + } + if _, exists := echoTool.InputSchema["properties"].(map[string]any)["args"]; !exists { + t.Fatalf("echo tool args schema missing") + } +} + +func TestBuildArgvDeterministicAndInheritedFlags(t *testing.T) { + var ( + verbose bool + parentVal string + runVal string + target string + ) + + root := &redant.Command{ + Use: "app", + Options: redant.OptionSet{ + {Flag: "verbose", Value: redant.BoolOf(&verbose)}, + }, + } + group := &redant.Command{ + Use: "group", + Options: redant.OptionSet{ + {Flag: "parent-flag", Value: redant.StringOf(&parentVal)}, + }, + } + run := &redant.Command{ + Use: "run", + Args: redant.ArgSet{ + {Name: "target", Required: true, Value: redant.StringOf(&target)}, + }, + Options: redant.OptionSet{ + {Flag: "run-flag", Value: redant.StringOf(&runVal)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + } + group.Children = append(group.Children, run) + root.Children = append(root.Children, group) + + runTool := mustFindToolByName(t, collectTools(root), "group.run") + argv, err := buildArgv(runTool, map[string]any{ + "flags": map[string]any{ + "run-flag": "rv", + "parent-flag": "pv", + "verbose": true, + }, + "args": map[string]any{"target": "svc"}, + }) + if err != nil { + t.Fatalf("buildArgv error: %v", err) + } + + want := []string{"group", "run", "--parent-flag", "pv", "--run-flag", "rv", "--verbose", "svc"} + if !reflect.DeepEqual(argv, want) { + t.Fatalf("argv = %#v, want %#v", argv, want) + } +} + +func TestBuildArgvRejectsUnknownFlag(t *testing.T) { + root := &redant.Command{Use: "app"} + root.Children = append(root.Children, &redant.Command{ + Use: "echo", + Args: redant.ArgSet{ + {Name: "message", Value: redant.StringOf(new(string))}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + }) + + echoTool := mustFindToolByName(t, collectTools(root), "echo") + _, err := buildArgv(echoTool, map[string]any{ + "flags": map[string]any{"not-exists": "x"}, + }) + if err == nil { + t.Fatalf("expected unknown flag error, got nil") + } + if !strings.Contains(err.Error(), "unknown flag") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCallToolWithInheritedFlags(t *testing.T) { + var ( + parentVal string + runVal string + target string + ) + + root := &redant.Command{Use: "app"} + group := &redant.Command{ + Use: "group", + Options: redant.OptionSet{ + {Flag: "parent-flag", Value: redant.StringOf(&parentVal)}, + }, + } + run := &redant.Command{ + Use: "run", + Args: redant.ArgSet{ + {Name: "target", Required: true, Value: redant.StringOf(&target)}, + }, + Options: redant.OptionSet{ + {Flag: "run-flag", Value: redant.StringOf(&runVal)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _, _ = fmt.Fprintf(inv.Stdout, "parent=%s run=%s target=%s", parentVal, runVal, target) + return nil + }, + } + group.Children = append(group.Children, run) + root.Children = append(root.Children, group) + + s := New(root) + result, err := s.callTool(context.Background(), toolsCallParams{ + Name: "group.run", + Arguments: map[string]any{ + "flags": map[string]any{ + "parent-flag": "pv", + "run-flag": "rv", + }, + "args": map[string]any{"target": "svc"}, + }, + }) + if err != nil { + t.Fatalf("callTool error: %v", err) + } + + structured, ok := result["structuredContent"].(map[string]any) + if !ok { + t.Fatalf("structuredContent missing: %#v", result) + } + stdout, _ := structured["stdout"].(string) + if !strings.Contains(stdout, "parent=pv run=rv target=svc") { + t.Fatalf("stdout = %q", stdout) + } +} + +func TestBuildFlagsSchemaComplexTypesAndRequiredRules(t *testing.T) { + var ( + count int64 + ratio float64 + enable bool + items []string + mode string + tags []string + token string + port string + ) + + schema := buildFlagsSchema(redant.OptionSet{ + {Flag: "count", Value: redant.Int64Of(&count), Required: true}, + {Flag: "ratio", Value: redant.Float64Of(&ratio)}, + {Flag: "enable", Value: redant.BoolOf(&enable)}, + {Flag: "items", Value: redant.StringArrayOf(&items)}, + {Flag: "mode", Value: redant.EnumOf(&mode, "fast", "slow")}, + {Flag: "tags", Value: redant.EnumArrayOf(&tags, "a", "b")}, + {Flag: "token", Value: redant.StringOf(&token), Required: true, Envs: []string{"TOKEN"}}, + {Flag: "port", Value: redant.StringOf(&port), Required: true, Default: "8080"}, + }) + + props, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatalf("flags properties missing") + } + + assertSchemaType(t, props["count"], "integer") + assertSchemaType(t, props["ratio"], "number") + assertSchemaType(t, props["enable"], "boolean") + assertSchemaType(t, props["items"], "array") + assertSchemaType(t, props["mode"], "string") + assertSchemaType(t, props["tags"], "array") + + modeSchema := props["mode"].(map[string]any) + modeEnum, ok := modeSchema["enum"].([]string) + if !ok || !reflect.DeepEqual(modeEnum, []string{"fast", "slow"}) { + t.Fatalf("mode enum = %#v", modeSchema["enum"]) + } + + tagsItems := props["tags"].(map[string]any)["items"].(map[string]any) + tagsEnum, ok := tagsItems["enum"].([]string) + if !ok || !reflect.DeepEqual(tagsEnum, []string{"a", "b"}) { + t.Fatalf("tags enum = %#v", tagsItems["enum"]) + } + + tokenSchema := props["token"].(map[string]any) + xenv, ok := tokenSchema["x-env"].([]string) + if !ok || !reflect.DeepEqual(xenv, []string{"TOKEN"}) { + t.Fatalf("token x-env = %#v", tokenSchema["x-env"]) + } + + portSchema := props["port"].(map[string]any) + if got, _ := portSchema["default"].(string); got != "8080" { + t.Fatalf("port default = %q", got) + } + + required, ok := schema["required"].([]string) + if !ok { + t.Fatalf("required missing") + } + if !reflect.DeepEqual(required, []string{"count"}) { + t.Fatalf("required = %#v, want [count]", required) + } +} + +func TestBuildArgsSchemaUnnamedAndDefault(t *testing.T) { + var ( + first string + mode string + ) + + schema := buildArgsSchema(redant.ArgSet{ + {Name: "", Required: true, Value: redant.StringOf(&first), Description: "first positional"}, + {Name: "mode", Required: true, Default: "auto", Value: redant.StringOf(&mode)}, + }) + + props, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatalf("args properties missing") + } + + arg1, ok := props["arg1"].(map[string]any) + if !ok { + t.Fatalf("arg1 schema missing") + } + if got, _ := arg1["description"].(string); got != "first positional" { + t.Fatalf("arg1 description = %q", got) + } + + modeSchema, ok := props["mode"].(map[string]any) + if !ok { + t.Fatalf("mode schema missing") + } + if got, _ := modeSchema["default"].(string); got != "auto" { + t.Fatalf("mode default = %q", got) + } + + required, ok := schema["required"].([]string) + if !ok { + t.Fatalf("required missing") + } + if !reflect.DeepEqual(required, []string{"arg1"}) { + t.Fatalf("required = %#v, want [arg1]", required) + } +} + +func TestBuildArgvArrayFlagAndNoArgSetArgs(t *testing.T) { + var tags []string + + root := &redant.Command{Use: "app"} + root.Children = append(root.Children, &redant.Command{ + Use: "scan", + Options: redant.OptionSet{ + {Flag: "tags", Value: redant.StringArrayOf(&tags)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + }) + + tool := mustFindToolByName(t, collectTools(root), "scan") + argv, err := buildArgv(tool, map[string]any{ + "flags": map[string]any{"tags": []any{"x", "y"}}, + "args": []any{"p1", "p2"}, + }) + if err != nil { + t.Fatalf("buildArgv error: %v", err) + } + + want := []string{"scan", "--tags", "x", "--tags", "y", "p1", "p2"} + if !reflect.DeepEqual(argv, want) { + t.Fatalf("argv = %#v, want %#v", argv, want) + } +} + +func TestBuildArgvMissingRequiredArg(t *testing.T) { + var target string + + root := &redant.Command{Use: "app"} + root.Children = append(root.Children, &redant.Command{ + Use: "run", + Args: redant.ArgSet{ + {Name: "target", Required: true, Value: redant.StringOf(&target)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + }) + + tool := mustFindToolByName(t, collectTools(root), "run") + _, err := buildArgv(tool, map[string]any{ + "args": map[string]any{}, + }) + if err == nil { + t.Fatalf("expected missing required arg error, got nil") + } + if !strings.Contains(err.Error(), "missing required arg") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBuildFlagsSchemaStructGeneric(t *testing.T) { + type payload struct { + Name string `json:"name" yaml:"name"` + Port int `json:"port" yaml:"port"` + } + + var cfg payload + schema := buildFlagsSchema(redant.OptionSet{ + {Flag: "config", Value: &redant.Struct[payload]{Value: cfg}, Description: "service config"}, + }) + + props, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatalf("flags properties missing") + } + + configSchema, ok := props["config"].(map[string]any) + if !ok { + t.Fatalf("config schema missing") + } + if got, _ := configSchema["type"].(string); got != "object" { + t.Fatalf("config type = %q, want object", got) + } + if ap, _ := configSchema["additionalProperties"].(bool); !ap { + t.Fatalf("config additionalProperties = %#v, want true", configSchema["additionalProperties"]) + } + if xvt, _ := configSchema["x-redant-value-type"].(string); !strings.HasPrefix(xvt, "struct[") { + t.Fatalf("x-redant-value-type = %q, want prefix struct[", xvt) + } +} + +func TestBuildArgvSupportsStructFlagAndArg(t *testing.T) { + type payload struct { + Name string `json:"name" yaml:"name"` + Port int `json:"port" yaml:"port"` + } + + var ( + argCfg payload + flagCfg payload + ) + + root := &redant.Command{Use: "app"} + root.Children = append(root.Children, &redant.Command{ + Use: "apply", + Args: redant.ArgSet{ + {Name: "config", Required: true, Value: &redant.Struct[payload]{Value: argCfg}}, + }, + Options: redant.OptionSet{ + {Flag: "meta", Value: &redant.Struct[payload]{Value: flagCfg}}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + }) + + tool := mustFindToolByName(t, collectTools(root), "apply") + argv, err := buildArgv(tool, map[string]any{ + "flags": map[string]any{ + "meta": map[string]any{"name": "svc", "port": 9000}, + }, + "args": map[string]any{ + "config": map[string]any{"name": "api", "port": 8080}, + }, + }) + if err != nil { + t.Fatalf("buildArgv error: %v", err) + } + + if len(argv) != 4 { + t.Fatalf("argv len = %d, want 4, argv=%#v", len(argv), argv) + } + if argv[0] != "apply" || argv[1] != "--meta" { + t.Fatalf("argv prefix = %#v, want [apply --meta ...]", argv[:2]) + } + + assertJSONStringObjectContains(t, argv[2], map[string]any{"name": "svc", "port": float64(9000)}) + assertJSONStringObjectContains(t, argv[3], map[string]any{"name": "api", "port": float64(8080)}) +} + +func assertJSONStringObjectContains(t *testing.T, raw string, want map[string]any) { + t.Helper() + + var got map[string]any + if err := json.Unmarshal([]byte(raw), &got); err != nil { + t.Fatalf("expected JSON object string, got %q (err: %v)", raw, err) + } + + for k, v := range want { + gv, ok := got[k] + if !ok { + t.Fatalf("json key %q missing in %q", k, raw) + } + if !reflect.DeepEqual(gv, v) { + t.Fatalf("json key %q value = %#v, want %#v", k, gv, v) + } + } +} + +func assertSchemaType(t *testing.T, raw any, want string) { + t.Helper() + schema, ok := raw.(map[string]any) + if !ok { + t.Fatalf("schema is not object: %#v", raw) + } + if got, _ := schema["type"].(string); got != want { + t.Fatalf("schema type = %q, want %q", got, want) + } +} + +func mustFindToolByName(t *testing.T, tools []toolDef, name string) toolDef { + t.Helper() + for _, td := range tools { + if td.Name == name { + return td + } + } + t.Fatalf("tool %q not found in %#v", name, tools) + return toolDef{} +} From 3f17731499083437915615b930691951675fed4c Mon Sep 17 00:00:00 2001 From: barry Date: Sun, 15 Mar 2026 12:39:34 +0800 Subject: [PATCH 07/76] chore: quick update feat/mcp at 2026-03-15 12:39:34 --- cmds/completioncmd/completion.go | 113 ++++++------- cmds/completioncmd/completion_test.go | 137 ++++++++++++++++ .../testdata/testapp.bash.golden | 137 ++++++++++++++++ .../testdata/testapp.fish.golden | 70 ++++++++ .../completioncmd/testdata/testapp.zsh.golden | 149 ++++++++++++++++++ cmds/mcpcmd/mcp.go | 61 ++++++- cmds/mcpcmd/mcp_test.go | 40 ++++- example/fastcommit/main.go | 2 + 8 files changed, 649 insertions(+), 60 deletions(-) create mode 100644 cmds/completioncmd/testdata/testapp.bash.golden create mode 100644 cmds/completioncmd/testdata/testapp.fish.golden create mode 100644 cmds/completioncmd/testdata/testapp.zsh.golden diff --git a/cmds/completioncmd/completion.go b/cmds/completioncmd/completion.go index 0a37c52..989f6ae 100644 --- a/cmds/completioncmd/completion.go +++ b/cmds/completioncmd/completion.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "github.com/pubgo/redant" @@ -173,29 +172,46 @@ func generateZshCompletion(ctx context.Context, inv *redant.Invocation) error { } progName := filepath.Base(os.Args[0]) + funcName := "_" + progName // Header header := fmt.Sprintf(`#compdef %s +compdef %s %s # %s completion for zsh # Autogenerated by redant %s() { - local context state line - typeset -A opt_args + local -a cmd_path options subcommands + local word i - _arguments -C \ -`, progName, progName, progName) + for (( i = 2; i < CURRENT; i++ )); do + word="${words[i]}" + [[ -z "$word" ]] && continue + [[ "$word" == "--" ]] && break + [[ "$word" == -* ]] && continue + cmd_path+=("$word") + done + + local cmd_key="${(j: :)cmd_path}" + case "$cmd_key" in +`, progName, funcName, progName, progName, funcName) // Generate command completions var commandsBuf bytes.Buffer - generateZshCommandCompletions(cmd, &commandsBuf) + generateZshCommandCases(cmd, nil, &commandsBuf) // Footer - footer := fmt.Sprintf(`)} - -%s -`, progName) + footer := ` esac + + if (( ${#subcommands[@]} > 0 )); then + _describe 'subcommands' subcommands + fi + if (( ${#options[@]} > 0 )) && [[ "${words[CURRENT]}" == -* ]]; then + _describe 'options' options + fi +} +` // Write the full script if _, err := fmt.Fprint(inv.Stdout, header); err != nil { @@ -211,68 +227,55 @@ func generateZshCompletion(ctx context.Context, inv *redant.Invocation) error { return nil } -// generateZshCommandCompletions generates command completion for zsh recursively -func generateZshCommandCompletions(cmd *redant.Command, buf *bytes.Buffer) { - cmdName := cmd.Name() - fullName := cmd.FullName() - - // Generate command entry - if fullName == cmdName { // Root command - fmt.Fprintf(buf, " '::subcommands:(%s)'", getZshSubcommands(cmd)) - } +// generateZshCommandCases emits command-path based case branches for zsh completion. +func generateZshCommandCases(cmd *redant.Command, path []string, buf *bytes.Buffer) { + caseKey := strings.Join(path, " ") + buf.WriteString(fmt.Sprintf(" %q)\n", caseKey)) - // Add options + buf.WriteString(" options=(\n") for _, opt := range cmd.FullOptions() { - if opt.Hidden { + if opt.Hidden || opt.Flag == "" { continue } - - var optDef string + desc := optionZshDescription(opt) + buf.WriteString(fmt.Sprintf(" '--%s:%s'\n", opt.Flag, desc)) if opt.Shorthand != "" { - optDef = fmt.Sprintf("'-%s[--%s]' '--%s[%s]'", opt.Shorthand, opt.Flag, opt.Flag, opt.Description) - } else { - optDef = fmt.Sprintf("'--%s[%s]'", opt.Flag, opt.Description) + buf.WriteString(fmt.Sprintf(" '-%s:%s'\n", opt.Shorthand, desc)) } - - buf.WriteString(" " + optDef) } + buf.WriteString(" )\n") - // Generate completions for subcommands + buf.WriteString(" subcommands=(\n") for _, child := range cmd.Children { - if !child.Hidden { - buf.WriteString(" '1: :->subcmds'") - break + if child.Hidden { + continue } + buf.WriteString(fmt.Sprintf(" '%s:%s'\n", child.Name(), escapeZshDescription(child.Short))) } + buf.WriteString(" )\n") + buf.WriteString(" ;;\n") - // Add subcommand handlers - if len(cmd.Children) > 0 { - buf.WriteString(`\ case $state in - subcmds) - local subcommands=(") - for _, child := range cmd.Children { - if !child.Hidden { - buf.WriteString("'" + child.Name() + ":" + child.Short + "' ") - } + for _, child := range cmd.Children { + if child.Hidden { + continue } - buf.WriteString(")") - buf.WriteString(" - _describe 'subcommands' subcommands - ;; - esac`) + generateZshCommandCases(child, append(path, child.Name()), buf) } } -// getZshSubcommands returns a string of subcommands for zsh completion -func getZshSubcommands(cmd *redant.Command) string { - var subcmds []string - for _, child := range cmd.Children { - if !child.Hidden { - subcmds = append(subcmds, child.Name()) - } +func escapeZshDescription(s string) string { + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, "'", "\\'") + s = strings.ReplaceAll(s, ":", "\\:") + return s +} + +func optionZshDescription(opt redant.Option) string { + desc := strings.TrimSpace(opt.Description) + if desc == "" { + desc = opt.Flag } - sort.Strings(subcmds) - return strings.Join(subcmds, " ") + return escapeZshDescription(desc) } // generateFishCompletion generates fish completion script diff --git a/cmds/completioncmd/completion_test.go b/cmds/completioncmd/completion_test.go index 9f83cf7..32a80e7 100644 --- a/cmds/completioncmd/completion_test.go +++ b/cmds/completioncmd/completion_test.go @@ -3,6 +3,8 @@ package completioncmd import ( "bytes" "context" + "os" + "path/filepath" "testing" "github.com/pubgo/redant" @@ -76,6 +78,10 @@ func TestCompletionCommandMissingShell(t *testing.T) { if !bytes.Contains(stderr.Bytes(), []byte("Available shells: bash, zsh, fish")) { t.Fatalf("Expected available shells list, got: %s", stderr.String()) } + + if stdout.Len() != 0 { + t.Fatalf("Expected empty stdout on missing shell, got: %s", stdout.String()) + } } // TestCompletionCommandUnsupportedShell tests error handling for unsupported shell @@ -122,4 +128,135 @@ func TestCompletionCommandUnsupportedShell(t *testing.T) { if !bytes.Contains(stderr.Bytes(), []byte("Available shells: bash, zsh, fish")) { t.Fatalf("Expected available shells list, got: %s", stderr.String()) } + + if stdout.Len() != 0 { + t.Fatalf("Expected empty stdout on unsupported shell, got: %s", stdout.String()) + } +} + +func TestCompletionCommandGeneratesScriptsForSupportedShells(t *testing.T) { + oldArg0 := os.Args[0] + os.Args[0] = "testapp" + defer func() { os.Args[0] = oldArg0 }() + + tests := []struct { + name string + shell string + golden string + }{ + {name: "bash", shell: "bash", golden: "testapp.bash.golden"}, + {name: "zsh", shell: "zsh", golden: "testapp.zsh.golden"}, + {name: "fish", shell: "fish", golden: "testapp.fish.golden"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rootCmd := newCompletionTestRoot() + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + inv := rootCmd.Invoke("completion", tt.shell) + inv.Stdout = stdout + inv.Stderr = stderr + + if err := inv.Run(); err != nil { + t.Fatalf("run completion %s: %v", tt.shell, err) + } + if stderr.Len() != 0 { + t.Fatalf("expected empty stderr, got: %s", stderr.String()) + } + + wantPath := filepath.Join("testdata", tt.golden) + if os.Getenv("UPDATE_GOLDEN") == "1" { + if err := os.WriteFile(wantPath, []byte(stdout.String()), 0o644); err != nil { + t.Fatalf("update golden %s: %v", wantPath, err) + } + } + want, err := os.ReadFile(wantPath) + if err != nil { + t.Fatalf("read golden %s: %v", wantPath, err) + } + + got := stdout.String() + if got != string(want) { + t.Fatalf("generated %s script mismatch\n--- got ---\n%s\n--- want ---\n%s", tt.shell, got, string(want)) + } + }) + } +} + +func newCompletionTestRoot() *redant.Command { + var ( + verbose bool + outputFormat string + configFile string + + projectNS string + projectAll bool + + repoRegion string + repoForce bool + + createPrivate bool + templateFile string + tags []string + ) + + rootCmd := &redant.Command{ + Use: "testapp", + Short: "Test application", + Options: redant.OptionSet{ + {Flag: "verbose", Shorthand: "v", Description: "verbose mode", Value: redant.BoolOf(&verbose)}, + {Flag: "output", Description: "output format", Value: redant.EnumOf(&outputFormat, "text", "json", "yaml"), Default: "text"}, + {Flag: "config", Description: "config file", Value: redant.StringOf(&configFile)}, + }, + } + + projectCmd := &redant.Command{ + Use: "project", + Short: "manage projects", + Args: redant.ArgSet{ + {Name: "project_name", Required: false, Value: redant.StringOf(new(string)), Description: "project name"}, + }, + Options: redant.OptionSet{ + {Flag: "namespace", Description: "project namespace", Value: redant.StringOf(&projectNS)}, + {Flag: "all", Description: "apply to all projects", Value: redant.BoolOf(&projectAll)}, + }, + } + + repoCmd := &redant.Command{ + Use: "repo", + Short: "manage repositories", + Args: redant.ArgSet{ + {Name: "repo_name", Required: false, Value: redant.StringOf(new(string)), Description: "repository name"}, + }, + Options: redant.OptionSet{ + {Flag: "region", Description: "target region", Value: redant.EnumOf(&repoRegion, "cn", "us", "eu"), Default: "cn"}, + {Flag: "force", Description: "force operation", Value: redant.BoolOf(&repoForce)}, + }, + } + + createCmd := &redant.Command{ + Use: "create", + Short: "create repository", + Args: redant.ArgSet{ + {Name: "repo", Required: true, Value: redant.StringOf(new(string)), Description: "repository id"}, + }, + Options: redant.OptionSet{ + {Flag: "private", Description: "create private repository", Value: redant.BoolOf(&createPrivate)}, + {Flag: "template", Description: "template file", Value: redant.StringOf(&templateFile)}, + {Flag: "tags", Description: "repository tags", Value: redant.StringArrayOf(&tags)}, + }, + } + + repoCmd.Children = append(repoCmd.Children, createCmd) + projectCmd.Children = append(projectCmd.Children, repoCmd) + + rootCmd.Children = append(rootCmd.Children, + &redant.Command{Use: "hello", Short: "say hello", Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }}, + &redant.Command{Use: "secret", Short: "hidden command", Hidden: true, Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }}, + projectCmd, + ) + AddCompletionCommand(rootCmd) + return rootCmd } diff --git a/cmds/completioncmd/testdata/testapp.bash.golden b/cmds/completioncmd/testdata/testapp.bash.golden new file mode 100644 index 0000000..0dedf42 --- /dev/null +++ b/cmds/completioncmd/testdata/testapp.bash.golden @@ -0,0 +1,137 @@ +#!/bin/bash + +# testapp completion for bash +# Autogenerated by redant + +testapp() { + local cur prev opts cmd + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + + # Command stack tracking + local cmd_stack=() + local i=1 + local found_cmd=false + + # Build command stack + while [ $i -lt $COMP_CWORD ]; do + cmd="${COMP_WORDS[$i]}" + cmd_stack+=($cmd) + i=$((i+1)) + done + + # Handle completion based on command stack + case "${cmd_stack[@]}" in + "testapp") + # Completions for testapp + local opts="--help " + opts+='--config ' + opts+='--env ' + opts+='-e ' + opts+='--env-file ' + opts+='--help ' + opts+='-h ' + opts+='--list-commands ' + opts+='--list-flags ' + opts+='--output ' + opts+='--verbose ' + opts+='-v ' + local subcmds="completion hello project " + COMPREPLY=( $(compgen -W "$opts $subcmds" -- "$cur") ) + ;; "testapp completion") + # Completions for completion + local opts="--help " + opts+='--config ' + opts+='--env ' + opts+='-e ' + opts+='--env-file ' + opts+='--help ' + opts+='-h ' + opts+='--list-commands ' + opts+='--list-flags ' + opts+='--output ' + opts+='--verbose ' + opts+='-v ' + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + ;; "testapp hello") + # Completions for hello + local opts="--help " + opts+='--config ' + opts+='--env ' + opts+='-e ' + opts+='--env-file ' + opts+='--help ' + opts+='-h ' + opts+='--list-commands ' + opts+='--list-flags ' + opts+='--output ' + opts+='--verbose ' + opts+='-v ' + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + ;; "testapp project") + # Completions for project + local opts="--help " + opts+='--config ' + opts+='--env ' + opts+='-e ' + opts+='--env-file ' + opts+='--help ' + opts+='-h ' + opts+='--list-commands ' + opts+='--list-flags ' + opts+='--output ' + opts+='--verbose ' + opts+='-v ' + opts+='--all ' + opts+='--namespace ' + local subcmds="repo " + COMPREPLY=( $(compgen -W "$opts $subcmds" -- "$cur") ) + ;; "testapp project repo") + # Completions for repo + local opts="--help " + opts+='--config ' + opts+='--env ' + opts+='-e ' + opts+='--env-file ' + opts+='--help ' + opts+='-h ' + opts+='--list-commands ' + opts+='--list-flags ' + opts+='--output ' + opts+='--verbose ' + opts+='-v ' + opts+='--all ' + opts+='--namespace ' + opts+='--force ' + opts+='--region ' + local subcmds="create " + COMPREPLY=( $(compgen -W "$opts $subcmds" -- "$cur") ) + ;; "testapp project repo create") + # Completions for create + local opts="--help " + opts+='--config ' + opts+='--env ' + opts+='-e ' + opts+='--env-file ' + opts+='--help ' + opts+='-h ' + opts+='--list-commands ' + opts+='--list-flags ' + opts+='--output ' + opts+='--verbose ' + opts+='-v ' + opts+='--all ' + opts+='--namespace ' + opts+='--force ' + opts+='--region ' + opts+='--private ' + opts+='--tags ' + opts+='--template ' + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + ;; *) + COMPREPLY=() + ;; + esac +} + +complete -F testapp testapp diff --git a/cmds/completioncmd/testdata/testapp.fish.golden b/cmds/completioncmd/testdata/testapp.fish.golden new file mode 100644 index 0000000..afdce41 --- /dev/null +++ b/cmds/completioncmd/testdata/testapp.fish.golden @@ -0,0 +1,70 @@ +# testapp completion for fish +# Autogenerated by redant + +complete -c testapp -n "__fish_use_subcommand" -a "completion" -d "Generate the autocompletion script for the specified shell" +complete -c testapp -l config -d "CONFIG FILE." -r -f -a "(__fish_complete_placeholder string)" +complete -c testapp -s e -l env -d "SET ENVIRONMENT VARIABLES (FORMAT: KEY=VALUE). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -l env-file -d "LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -s h -l help -d "SHOW HELP FOR COMMAND." +complete -c testapp -l list-commands -d "LIST ALL COMMANDS, INCLUDING SUBCOMMANDS." +complete -c testapp -l list-flags -d "LIST ALL FLAGS." +complete -c testapp -l output -d "OUTPUT FORMAT." -r -f -a "(__fish_complete_placeholder enum[text\|json\|yaml])" +complete -c testapp -s v -l verbose -d "VERBOSE MODE." +complete -c testapp -n "__fish_seen_subcommand_from " -a "completion" -d "Generate the autocompletion script for the specified shell" +complete -c testapp -n "__fish_seen_subcommand_from " -a "hello" -d "say hello" +complete -c testapp -n "__fish_seen_subcommand_from " -a "project" -d "manage projects" +complete -c testapp -n "__fish_seen_subcommand_from completion" -l config -d "CONFIG FILE." -r -f -a "(__fish_complete_placeholder string)" +complete -c testapp -n "__fish_seen_subcommand_from completion" -s e -l env -d "SET ENVIRONMENT VARIABLES (FORMAT: KEY=VALUE). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from completion" -l env-file -d "LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from completion" -s h -l help -d "SHOW HELP FOR COMMAND." +complete -c testapp -n "__fish_seen_subcommand_from completion" -l list-commands -d "LIST ALL COMMANDS, INCLUDING SUBCOMMANDS." +complete -c testapp -n "__fish_seen_subcommand_from completion" -l list-flags -d "LIST ALL FLAGS." +complete -c testapp -n "__fish_seen_subcommand_from completion" -l output -d "OUTPUT FORMAT." -r -f -a "(__fish_complete_placeholder enum[text\|json\|yaml])" +complete -c testapp -n "__fish_seen_subcommand_from completion" -s v -l verbose -d "VERBOSE MODE." +complete -c testapp -n "__fish_seen_subcommand_from hello" -l config -d "CONFIG FILE." -r -f -a "(__fish_complete_placeholder string)" +complete -c testapp -n "__fish_seen_subcommand_from hello" -s e -l env -d "SET ENVIRONMENT VARIABLES (FORMAT: KEY=VALUE). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from hello" -l env-file -d "LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from hello" -s h -l help -d "SHOW HELP FOR COMMAND." +complete -c testapp -n "__fish_seen_subcommand_from hello" -l list-commands -d "LIST ALL COMMANDS, INCLUDING SUBCOMMANDS." +complete -c testapp -n "__fish_seen_subcommand_from hello" -l list-flags -d "LIST ALL FLAGS." +complete -c testapp -n "__fish_seen_subcommand_from hello" -l output -d "OUTPUT FORMAT." -r -f -a "(__fish_complete_placeholder enum[text\|json\|yaml])" +complete -c testapp -n "__fish_seen_subcommand_from hello" -s v -l verbose -d "VERBOSE MODE." +complete -c testapp -n "__fish_seen_subcommand_from project" -l config -d "CONFIG FILE." -r -f -a "(__fish_complete_placeholder string)" +complete -c testapp -n "__fish_seen_subcommand_from project" -s e -l env -d "SET ENVIRONMENT VARIABLES (FORMAT: KEY=VALUE). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from project" -l env-file -d "LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from project" -s h -l help -d "SHOW HELP FOR COMMAND." +complete -c testapp -n "__fish_seen_subcommand_from project" -l list-commands -d "LIST ALL COMMANDS, INCLUDING SUBCOMMANDS." +complete -c testapp -n "__fish_seen_subcommand_from project" -l list-flags -d "LIST ALL FLAGS." +complete -c testapp -n "__fish_seen_subcommand_from project" -l output -d "OUTPUT FORMAT." -r -f -a "(__fish_complete_placeholder enum[text\|json\|yaml])" +complete -c testapp -n "__fish_seen_subcommand_from project" -s v -l verbose -d "VERBOSE MODE." +complete -c testapp -n "__fish_seen_subcommand_from project" -l all -d "APPLY TO ALL PROJECTS." +complete -c testapp -n "__fish_seen_subcommand_from project" -l namespace -d "PROJECT NAMESPACE." -r -f -a "(__fish_complete_placeholder string)" +complete -c testapp -n "__fish_seen_subcommand_from project" -a "repo" -d "manage repositories" +complete -c testapp -n "__fish_seen_subcommand_from project repo" -l config -d "CONFIG FILE." -r -f -a "(__fish_complete_placeholder string)" +complete -c testapp -n "__fish_seen_subcommand_from project repo" -s e -l env -d "SET ENVIRONMENT VARIABLES (FORMAT: KEY=VALUE). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from project repo" -l env-file -d "LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from project repo" -s h -l help -d "SHOW HELP FOR COMMAND." +complete -c testapp -n "__fish_seen_subcommand_from project repo" -l list-commands -d "LIST ALL COMMANDS, INCLUDING SUBCOMMANDS." +complete -c testapp -n "__fish_seen_subcommand_from project repo" -l list-flags -d "LIST ALL FLAGS." +complete -c testapp -n "__fish_seen_subcommand_from project repo" -l output -d "OUTPUT FORMAT." -r -f -a "(__fish_complete_placeholder enum[text\|json\|yaml])" +complete -c testapp -n "__fish_seen_subcommand_from project repo" -s v -l verbose -d "VERBOSE MODE." +complete -c testapp -n "__fish_seen_subcommand_from project repo" -l all -d "APPLY TO ALL PROJECTS." +complete -c testapp -n "__fish_seen_subcommand_from project repo" -l namespace -d "PROJECT NAMESPACE." -r -f -a "(__fish_complete_placeholder string)" +complete -c testapp -n "__fish_seen_subcommand_from project repo" -l force -d "FORCE OPERATION." +complete -c testapp -n "__fish_seen_subcommand_from project repo" -l region -d "TARGET REGION." -r -f -a "(__fish_complete_placeholder enum[cn\|us\|eu])" +complete -c testapp -n "__fish_seen_subcommand_from project repo" -a "create" -d "create repository" +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l config -d "CONFIG FILE." -r -f -a "(__fish_complete_placeholder string)" +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -s e -l env -d "SET ENVIRONMENT VARIABLES (FORMAT: KEY=VALUE). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l env-file -d "LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -s h -l help -d "SHOW HELP FOR COMMAND." +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l list-commands -d "LIST ALL COMMANDS, INCLUDING SUBCOMMANDS." +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l list-flags -d "LIST ALL FLAGS." +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l output -d "OUTPUT FORMAT." -r -f -a "(__fish_complete_placeholder enum[text\|json\|yaml])" +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -s v -l verbose -d "VERBOSE MODE." +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l all -d "APPLY TO ALL PROJECTS." +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l namespace -d "PROJECT NAMESPACE." -r -f -a "(__fish_complete_placeholder string)" +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l force -d "FORCE OPERATION." +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l region -d "TARGET REGION." -r -f -a "(__fish_complete_placeholder enum[cn\|us\|eu])" +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l private -d "CREATE PRIVATE REPOSITORY." +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l tags -d "REPOSITORY TAGS." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l template -d "TEMPLATE FILE." -r -f -a "(__fish_complete_placeholder string)" diff --git a/cmds/completioncmd/testdata/testapp.zsh.golden b/cmds/completioncmd/testdata/testapp.zsh.golden new file mode 100644 index 0000000..eef64a3 --- /dev/null +++ b/cmds/completioncmd/testdata/testapp.zsh.golden @@ -0,0 +1,149 @@ +#compdef testapp +compdef _testapp testapp + +# testapp completion for zsh +# Autogenerated by redant + +_testapp() { + local -a cmd_path options subcommands + local word i + + for (( i = 2; i < CURRENT; i++ )); do + word="${words[i]}" + [[ -z "$word" ]] && continue + [[ "$word" == "--" ]] && break + [[ "$word" == -* ]] && continue + cmd_path+=("$word") + done + + local cmd_key="${(j: :)cmd_path}" + case "$cmd_key" in + "") + options=( + '--config:CONFIG FILE.' + '--env:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '-e:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '--env-file:LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV.' + '--help:SHOW HELP FOR COMMAND.' + '-h:SHOW HELP FOR COMMAND.' + '--list-commands:LIST ALL COMMANDS, INCLUDING SUBCOMMANDS.' + '--list-flags:LIST ALL FLAGS.' + '--output:OUTPUT FORMAT.' + '--verbose:VERBOSE MODE.' + '-v:VERBOSE MODE.' + ) + subcommands=( + 'completion:Generate the autocompletion script for the specified shell' + 'hello:say hello' + 'project:manage projects' + ) + ;; + "completion") + options=( + '--config:CONFIG FILE.' + '--env:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '-e:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '--env-file:LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV.' + '--help:SHOW HELP FOR COMMAND.' + '-h:SHOW HELP FOR COMMAND.' + '--list-commands:LIST ALL COMMANDS, INCLUDING SUBCOMMANDS.' + '--list-flags:LIST ALL FLAGS.' + '--output:OUTPUT FORMAT.' + '--verbose:VERBOSE MODE.' + '-v:VERBOSE MODE.' + ) + subcommands=( + ) + ;; + "hello") + options=( + '--config:CONFIG FILE.' + '--env:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '-e:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '--env-file:LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV.' + '--help:SHOW HELP FOR COMMAND.' + '-h:SHOW HELP FOR COMMAND.' + '--list-commands:LIST ALL COMMANDS, INCLUDING SUBCOMMANDS.' + '--list-flags:LIST ALL FLAGS.' + '--output:OUTPUT FORMAT.' + '--verbose:VERBOSE MODE.' + '-v:VERBOSE MODE.' + ) + subcommands=( + ) + ;; + "project") + options=( + '--config:CONFIG FILE.' + '--env:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '-e:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '--env-file:LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV.' + '--help:SHOW HELP FOR COMMAND.' + '-h:SHOW HELP FOR COMMAND.' + '--list-commands:LIST ALL COMMANDS, INCLUDING SUBCOMMANDS.' + '--list-flags:LIST ALL FLAGS.' + '--output:OUTPUT FORMAT.' + '--verbose:VERBOSE MODE.' + '-v:VERBOSE MODE.' + '--all:APPLY TO ALL PROJECTS.' + '--namespace:PROJECT NAMESPACE.' + ) + subcommands=( + 'repo:manage repositories' + ) + ;; + "project repo") + options=( + '--config:CONFIG FILE.' + '--env:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '-e:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '--env-file:LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV.' + '--help:SHOW HELP FOR COMMAND.' + '-h:SHOW HELP FOR COMMAND.' + '--list-commands:LIST ALL COMMANDS, INCLUDING SUBCOMMANDS.' + '--list-flags:LIST ALL FLAGS.' + '--output:OUTPUT FORMAT.' + '--verbose:VERBOSE MODE.' + '-v:VERBOSE MODE.' + '--all:APPLY TO ALL PROJECTS.' + '--namespace:PROJECT NAMESPACE.' + '--force:FORCE OPERATION.' + '--region:TARGET REGION.' + ) + subcommands=( + 'create:create repository' + ) + ;; + "project repo create") + options=( + '--config:CONFIG FILE.' + '--env:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '-e:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '--env-file:LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV.' + '--help:SHOW HELP FOR COMMAND.' + '-h:SHOW HELP FOR COMMAND.' + '--list-commands:LIST ALL COMMANDS, INCLUDING SUBCOMMANDS.' + '--list-flags:LIST ALL FLAGS.' + '--output:OUTPUT FORMAT.' + '--verbose:VERBOSE MODE.' + '-v:VERBOSE MODE.' + '--all:APPLY TO ALL PROJECTS.' + '--namespace:PROJECT NAMESPACE.' + '--force:FORCE OPERATION.' + '--region:TARGET REGION.' + '--private:CREATE PRIVATE REPOSITORY.' + '--tags:REPOSITORY TAGS.' + '--template:TEMPLATE FILE.' + ) + subcommands=( + ) + ;; + esac + + if (( ${#subcommands[@]} > 0 )); then + _describe 'subcommands' subcommands + fi + if (( ${#options[@]} > 0 )) && [[ "${words[CURRENT]}" == -* ]]; then + _describe 'options' options + fi +} diff --git a/cmds/mcpcmd/mcp.go b/cmds/mcpcmd/mcp.go index 35b77c8..358fca6 100644 --- a/cmds/mcpcmd/mcp.go +++ b/cmds/mcpcmd/mcp.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "strings" "github.com/pubgo/redant" @@ -12,6 +13,7 @@ import ( func New() *redant.Command { var transport string + var listFormat string serveCmd := &redant.Command{ Use: "serve", @@ -48,7 +50,15 @@ func New() *redant.Command { listCmd := &redant.Command{ Use: "list", Short: "List all MCP tools metadata.", - Long: "Print all mapped MCP tools (name, description, path, input/output schema) as JSON.", + Long: "List all mapped MCP tools (name, description, path, input/output schema).", + Options: redant.OptionSet{ + { + Flag: "format", + Description: "Output format.", + Value: redant.EnumOf(&listFormat, "json", "text"), + Default: "json", + }, + }, Handler: func(ctx context.Context, inv *redant.Invocation) error { root := inv.Command for root.Parent() != nil { @@ -56,9 +66,21 @@ func New() *redant.Command { } infos := mcpserver.ListToolInfos(root) - enc := json.NewEncoder(inv.Stdout) - enc.SetIndent("", " ") - return enc.Encode(infos) + format := strings.TrimSpace(strings.ToLower(listFormat)) + if format == "" { + format = "json" + } + + switch format { + case "json": + enc := json.NewEncoder(inv.Stdout) + enc.SetIndent("", " ") + return enc.Encode(infos) + case "text": + return writeToolInfosText(inv.Stdout, infos) + default: + return fmt.Errorf("unsupported format: %s", format) + } }, } @@ -76,3 +98,34 @@ func New() *redant.Command { func AddMCPCommand(rootCmd *redant.Command) { rootCmd.Children = append(rootCmd.Children, New()) } + +func writeToolInfosText(w io.Writer, infos []mcpserver.ToolInfo) error { + if len(infos) == 0 { + _, err := fmt.Fprintln(w, "No MCP tools found.") + return err + } + + for i, info := range infos { + if _, err := fmt.Fprintf(w, "%d. %s\n", i+1, info.Name); err != nil { + return err + } + desc := strings.TrimSpace(info.Description) + if desc == "" { + desc = "(no description)" + } + if _, err := fmt.Fprintf(w, " description: %s\n", desc); err != nil { + return err + } + if _, err := fmt.Fprintf(w, " path: %s\n", strings.Join(info.Path, " > ")); err != nil { + return err + } + if _, err := fmt.Fprintln(w, " inputSchema: yes"); err != nil { + return err + } + if _, err := fmt.Fprintln(w, " outputSchema: yes"); err != nil { + return err + } + } + + return nil +} diff --git a/cmds/mcpcmd/mcp_test.go b/cmds/mcpcmd/mcp_test.go index bdff413..508a27e 100644 --- a/cmds/mcpcmd/mcp_test.go +++ b/cmds/mcpcmd/mcp_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "strings" "testing" "github.com/pubgo/redant" @@ -42,7 +43,7 @@ func TestAddMCPCommand(t *testing.T) { } } -func TestMCPListCommandPrintsToolInfos(t *testing.T) { +func TestMCPListCommandPrintsToolInfosJSONByDefault(t *testing.T) { root := &redant.Command{Use: "app"} var message string @@ -91,3 +92,40 @@ func TestMCPListCommandPrintsToolInfos(t *testing.T) { t.Fatalf("echo outputSchema missing: %#v", echoTool) } } + +func TestMCPListCommandPrintsToolInfosText(t *testing.T) { + root := &redant.Command{Use: "app"} + + var message string + root.Children = append(root.Children, &redant.Command{ + Use: "echo", + Short: "echo one message", + Args: redant.ArgSet{ + {Name: "message", Required: true, Value: redant.StringOf(&message), Description: "text to echo"}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + }) + + AddMCPCommand(root) + + var stdout bytes.Buffer + inv := root.Invoke("mcp", "list", "--format", "text") + inv.Stdout = &stdout + + if err := inv.Run(); err != nil { + t.Fatalf("run mcp list --format text: %v", err) + } + + out := stdout.String() + for _, mustContain := range []string{ + "1. echo", + "description: echo one message", + "path: echo", + "inputSchema: yes", + "outputSchema: yes", + } { + if !strings.Contains(out, mustContain) { + t.Fatalf("text output missing %q\noutput:\n%s", mustContain, out) + } + } +} diff --git a/example/fastcommit/main.go b/example/fastcommit/main.go index 4ed9522..18b2875 100644 --- a/example/fastcommit/main.go +++ b/example/fastcommit/main.go @@ -7,6 +7,7 @@ import ( "github.com/pubgo/redant" "github.com/pubgo/redant/cmds/completioncmd" + "github.com/pubgo/redant/cmds/mcpcmd" ) // mkdir -p ~/.zsh/completions @@ -128,6 +129,7 @@ func main() { commitCmd.Children = append(commitCmd.Children, detailedCmd) rootCmd.Children = append(rootCmd.Children, commitCmd) rootCmd.Children = append(rootCmd.Children, completioncmd.New()) + rootCmd.Children = append(rootCmd.Children, mcpcmd.New()) // Run command err := rootCmd.Invoke().WithOS().Run() From b31be41ed4757f993a53647357a459c8b8d903f1 Mon Sep 17 00:00:00 2001 From: barry Date: Sun, 15 Mar 2026 13:14:01 +0800 Subject: [PATCH 08/76] chore: quick update feat/mcp at 2026-03-15 13:14:00 --- cmds/webcmd/web.go | 110 +++++++ cmds/webcmd/web_test.go | 65 ++++ example/fastcommit/main.go | 2 + internal/webui/assets.go | 6 + internal/webui/server.go | 494 +++++++++++++++++++++++++++++++ internal/webui/server_test.go | 212 +++++++++++++ internal/webui/static/index.html | 402 +++++++++++++++++++++++++ 7 files changed, 1291 insertions(+) create mode 100644 cmds/webcmd/web.go create mode 100644 cmds/webcmd/web_test.go create mode 100644 internal/webui/assets.go create mode 100644 internal/webui/server.go create mode 100644 internal/webui/server_test.go create mode 100644 internal/webui/static/index.html diff --git a/cmds/webcmd/web.go b/cmds/webcmd/web.go new file mode 100644 index 0000000..1873cb3 --- /dev/null +++ b/cmds/webcmd/web.go @@ -0,0 +1,110 @@ +package webcmd + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/pubgo/redant" + "github.com/pubgo/redant/internal/webui" +) + +func New() *redant.Command { + var addr string + var autoOpen bool + + return &redant.Command{ + Use: "web", + Short: "打开可视化命令执行页面", + Long: "启动本地 Web 控制台:左侧命令列表,右侧 flags/args 输入,并展示完整调用过程与执行结果。", + Options: redant.OptionSet{ + { + Flag: "addr", + Description: "Web 服务监听地址", + Value: redant.StringOf(&addr), + Default: "127.0.0.1:18080", + }, + { + Flag: "open", + Description: "启动后自动打开浏览器", + Value: redant.BoolOf(&autoOpen), + Default: "true", + }, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + root := inv.Command + for root.Parent() != nil { + root = root.Parent() + } + + listenAddr := strings.TrimSpace(addr) + if listenAddr == "" { + listenAddr = "127.0.0.1:18080" + } + + ln, err := net.Listen("tcp", listenAddr) + if err != nil { + return err + } + defer func() { _ = ln.Close() }() + + url := "http://" + ln.Addr().String() + _, _ = fmt.Fprintf(inv.Stdout, "web ui listening on %s\n", url) + _, _ = fmt.Fprintf(inv.Stdout, "press Ctrl+C to stop\n") + + if autoOpen { + if openErr := openBrowser(url); openErr != nil { + _, _ = fmt.Fprintf(inv.Stderr, "open browser failed: %v\n", openErr) + } + } + + server := &http.Server{Handler: webui.New(root).Handler()} + errCh := make(chan error, 1) + go func() { + errCh <- server.Serve(ln) + }() + + select { + case serveErr := <-errCh: + if errors.Is(serveErr, http.ErrServerClosed) { + return nil + } + return serveErr + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + serveErr := <-errCh + if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + return serveErr + } + return nil + } + }, + } +} + +func AddWebCommand(rootCmd *redant.Command) { + rootCmd.Children = append(rootCmd.Children, New()) +} + +func openBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "linux": + cmd = exec.Command("xdg-open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + return cmd.Start() +} diff --git a/cmds/webcmd/web_test.go b/cmds/webcmd/web_test.go new file mode 100644 index 0000000..23b34e3 --- /dev/null +++ b/cmds/webcmd/web_test.go @@ -0,0 +1,65 @@ +package webcmd + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/pubgo/redant" +) + +func TestAddWebCommand(t *testing.T) { + root := &redant.Command{Use: "app"} + AddWebCommand(root) + + if len(root.Children) != 1 { + t.Fatalf("expected one child command, got %d", len(root.Children)) + } + if root.Children[0].Name() != "web" { + t.Fatalf("expected child command web, got %s", root.Children[0].Name()) + } +} + +func TestWebCommandRunAndShutdown(t *testing.T) { + root := &redant.Command{Use: "app"} + root.Children = append(root.Children, &redant.Command{ + Use: "hello", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _, _ = inv.Stdout.Write([]byte("hello")) + return nil + }, + }) + AddWebCommand(root) + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + inv := root.Invoke("web", "--addr", "127.0.0.1:0", "--open=false") + inv.Stdout = stdout + inv.Stderr = stderr + + done := make(chan error, 1) + go func() { + done <- inv.WithContext(ctx).Run() + }() + + time.Sleep(150 * time.Millisecond) + cancel() + + select { + case err := <-done: + if err != nil { + t.Fatalf("web command run failed: %v (stderr=%s)", err, stderr.String()) + } + case <-time.After(3 * time.Second): + t.Fatal("web command did not shutdown in time") + } + + if !strings.Contains(stdout.String(), "web ui listening on") { + t.Fatalf("expected startup output, got %q", stdout.String()) + } +} diff --git a/example/fastcommit/main.go b/example/fastcommit/main.go index 18b2875..c3ae056 100644 --- a/example/fastcommit/main.go +++ b/example/fastcommit/main.go @@ -8,6 +8,7 @@ import ( "github.com/pubgo/redant" "github.com/pubgo/redant/cmds/completioncmd" "github.com/pubgo/redant/cmds/mcpcmd" + "github.com/pubgo/redant/cmds/webcmd" ) // mkdir -p ~/.zsh/completions @@ -130,6 +131,7 @@ func main() { rootCmd.Children = append(rootCmd.Children, commitCmd) rootCmd.Children = append(rootCmd.Children, completioncmd.New()) rootCmd.Children = append(rootCmd.Children, mcpcmd.New()) + rootCmd.Children = append(rootCmd.Children, webcmd.New()) // Run command err := rootCmd.Invoke().WithOS().Run() diff --git a/internal/webui/assets.go b/internal/webui/assets.go new file mode 100644 index 0000000..7b45f32 --- /dev/null +++ b/internal/webui/assets.go @@ -0,0 +1,6 @@ +package webui + +import _ "embed" + +//go:embed static/index.html +var indexHTML string diff --git a/internal/webui/server.go b/internal/webui/server.go new file mode 100644 index 0000000..b368d97 --- /dev/null +++ b/internal/webui/server.go @@ -0,0 +1,494 @@ +package webui + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + + "github.com/pubgo/redant" +) + +type ArgMeta struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Type string `json:"type"` + Required bool `json:"required"` + Default string `json:"default,omitempty"` +} + +type FlagMeta struct { + Name string `json:"name"` + Shorthand string `json:"shorthand,omitempty"` + Envs []string `json:"envs,omitempty"` + Description string `json:"description,omitempty"` + Type string `json:"type"` + Required bool `json:"required"` + Default string `json:"default,omitempty"` +} + +type CommandMeta struct { + ID string `json:"id"` + Name string `json:"name"` + Use string `json:"use"` + Aliases []string `json:"aliases,omitempty"` + Short string `json:"short,omitempty"` + Long string `json:"long,omitempty"` + Deprecated string `json:"deprecated,omitempty"` + RawArgs bool `json:"rawArgs"` + Path []string `json:"path"` + Description string `json:"description,omitempty"` + Flags []FlagMeta `json:"flags"` + Args []ArgMeta `json:"args"` +} + +type RunRequest struct { + Command string `json:"command"` + Flags map[string]any `json:"flags,omitempty"` + Args map[string]any `json:"args,omitempty"` + RawArgs []string `json:"rawArgs,omitempty"` +} + +type RunResponse struct { + OK bool `json:"ok"` + Command string `json:"command"` + Invocation string `json:"invocation"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Error string `json:"error"` + Combined string `json:"combined"` +} + +type commandListResponse struct { + Commands []CommandMeta `json:"commands"` +} + +type App struct { + root *redant.Command + commands []CommandMeta + byID map[string]CommandMeta + mu sync.Mutex +} + +func New(root *redant.Command) *App { + cmds := collectCommands(root) + byID := make(map[string]CommandMeta, len(cmds)) + for _, c := range cmds { + byID[c.ID] = c + } + return &App{root: root, commands: cmds, byID: byID} +} + +func (a *App) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/", a.handleIndex) + mux.HandleFunc("/api/commands", a.handleCommands) + mux.HandleFunc("/api/run", a.handleRun) + return mux +} + +func (a *App) handleIndex(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(indexHTML)) +} + +func (a *App) handleCommands(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(commandListResponse{Commands: a.commands}) +} + +func (a *App) handleRun(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req RunRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) + return + } + + meta, ok := a.byID[strings.TrimSpace(req.Command)] + if !ok { + http.Error(w, fmt.Sprintf("unknown command: %q", req.Command), http.StatusBadRequest) + return + } + + argv, invocation, err := buildInvocation(meta, req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + a.mu.Lock() + runErr := func() error { + root := cloneCommandTree(a.root) + inv := root.Invoke(argv...) + inv.Stdout = &stdout + inv.Stderr = &stderr + inv.Stdin = bytes.NewReader(nil) + return inv.WithContext(r.Context()).Run() + }() + a.mu.Unlock() + + resp := RunResponse{ + OK: runErr == nil, + Command: meta.ID, + Invocation: invocation, + Stdout: stdout.String(), + Stderr: stderr.String(), + Combined: combineOutput(stdout.String(), stderr.String(), runErr), + } + if runErr != nil { + resp.Error = runErr.Error() + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +func buildInvocation(meta CommandMeta, req RunRequest) ([]string, string, error) { + argv := append([]string(nil), meta.Path...) + + for _, flag := range meta.Flags { + v, ok := req.Flags[flag.Name] + if !ok { + continue + } + tokens, err := serializeFlag(flag, v) + if err != nil { + return nil, "", fmt.Errorf("flag %q: %w", flag.Name, err) + } + argv = append(argv, tokens...) + } + + if len(meta.Args) > 0 { + for _, arg := range meta.Args { + v, ok := req.Args[arg.Name] + if !ok { + if arg.Required && arg.Default == "" { + return nil, "", fmt.Errorf("missing required arg %q", arg.Name) + } + continue + } + val, err := serializeValueByType(arg.Type, v) + if err != nil { + return nil, "", fmt.Errorf("arg %q: %w", arg.Name, err) + } + argv = append(argv, val) + } + } else if len(req.RawArgs) > 0 { + argv = append(argv, req.RawArgs...) + } + + prog := filepath.Base(os.Args[0]) + invocation := prog + for _, token := range argv { + invocation += " " + shellQuote(token) + } + + return argv, invocation, nil +} + +func serializeFlag(flag FlagMeta, raw any) ([]string, error) { + name := "--" + flag.Name + if flag.Type == "bool" { + b, ok := parseBool(raw) + if !ok { + return nil, fmt.Errorf("expected boolean") + } + if b { + return []string{name}, nil + } + return []string{name + "=false"}, nil + } + + if isArrayType(flag.Type) { + vals, err := toStringSlice(raw) + if err != nil { + return nil, err + } + out := make([]string, 0, len(vals)*2) + for _, v := range vals { + out = append(out, name, v) + } + return out, nil + } + + value, err := serializeValueByType(flag.Type, raw) + if err != nil { + return nil, err + } + return []string{name, value}, nil +} + +func serializeValueByType(typ string, raw any) (string, error) { + if strings.HasPrefix(typ, "struct[") { + if s, ok := raw.(string); ok { + return s, nil + } + b, err := json.Marshal(raw) + if err != nil { + return "", fmt.Errorf("expected object-compatible value: %w", err) + } + return string(b), nil + } + return toString(raw), nil +} + +func toString(raw any) string { + switch v := raw.(type) { + case nil: + return "" + case string: + return v + case bool: + return strconv.FormatBool(v) + case float64: + if v == float64(int64(v)) { + return strconv.FormatInt(int64(v), 10) + } + return strconv.FormatFloat(v, 'f', -1, 64) + case json.Number: + return v.String() + default: + return fmt.Sprintf("%v", v) + } +} + +func parseBool(raw any) (bool, bool) { + switch v := raw.(type) { + case bool: + return v, true + case string: + b, err := strconv.ParseBool(strings.TrimSpace(v)) + return b, err == nil + default: + return false, false + } +} + +func toStringSlice(raw any) ([]string, error) { + switch v := raw.(type) { + case []string: + return append([]string(nil), v...), nil + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + out = append(out, toString(item)) + } + return out, nil + case string: + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + out = append(out, p) + } + return out, nil + default: + return nil, fmt.Errorf("expected array") + } +} + +func isArrayType(typ string) bool { + return typ == "string-array" || strings.HasPrefix(typ, "enum-array[") +} + +func shellQuote(s string) string { + if s == "" { + return "''" + } + needQuote := false + for _, ch := range s { + if !(ch == '_' || ch == '-' || ch == '.' || ch == '/' || ch == ':' || ch == '=' || (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')) { + needQuote = true + break + } + } + if !needQuote { + return s + } + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} + +func combineOutput(stdout, stderr string, runErr error) string { + var out bytes.Buffer + if stdout != "" { + _, _ = out.WriteString(stdout) + } + if stderr != "" { + if out.Len() > 0 { + _, _ = out.WriteString("\n") + } + _, _ = out.WriteString("stderr:\n") + _, _ = out.WriteString(stderr) + } + if runErr != nil { + if out.Len() > 0 { + _, _ = out.WriteString("\n") + } + _, _ = out.WriteString("error:\n") + _, _ = out.WriteString(runErr.Error()) + } + if out.Len() == 0 { + return "ok" + } + return out.String() +} + +func cloneCommandTree(cmd *redant.Command) *redant.Command { + if cmd == nil { + return nil + } + cpy := *cmd + cpy.Options = append(redant.OptionSet(nil), cmd.Options...) + cpy.Args = append(redant.ArgSet(nil), cmd.Args...) + cpy.Children = make([]*redant.Command, 0, len(cmd.Children)) + for _, child := range cmd.Children { + cpy.Children = append(cpy.Children, cloneCommandTree(child)) + } + return &cpy +} + +func collectCommands(root *redant.Command) []CommandMeta { + if root == nil { + return nil + } + + var out []CommandMeta + var walk func(cmd *redant.Command, path []string, inherited redant.OptionSet) + walk = func(cmd *redant.Command, path []string, inherited redant.OptionSet) { + if cmd == nil || cmd.Hidden { + return + } + + effective := make(redant.OptionSet, 0, len(inherited)+len(cmd.Options)) + effective = append(effective, inherited...) + effective = append(effective, cmd.Options...) + + if cmd.Handler != nil && len(path) > 0 && path[0] != "web" { + out = append(out, toCommandMeta(cmd, path, effective)) + } + + for _, child := range cmd.Children { + walk(child, append(path, child.Name()), effective) + } + } + + for _, child := range root.Children { + walk(child, []string{child.Name()}, root.Options) + } + + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +func toCommandMeta(cmd *redant.Command, path []string, opts redant.OptionSet) CommandMeta { + return CommandMeta{ + ID: strings.Join(path, " "), + Name: cmd.Name(), + Use: cmd.Use, + Aliases: append([]string(nil), cmd.Aliases...), + Short: strings.TrimSpace(cmd.Short), + Long: strings.TrimSpace(cmd.Long), + Deprecated: strings.TrimSpace(cmd.Deprecated), + RawArgs: cmd.RawArgs, + Path: append([]string(nil), path...), + Description: commandDescription(cmd), + Flags: toFlagMeta(opts), + Args: toArgMeta(cmd.Args), + } +} + +func toFlagMeta(opts redant.OptionSet) []FlagMeta { + byName := map[string]redant.Option{} + for _, opt := range opts { + if opt.Hidden || opt.Flag == "" || isSystemFlag(opt.Flag) { + continue + } + byName[opt.Flag] = opt + } + + names := make([]string, 0, len(byName)) + for n := range byName { + names = append(names, n) + } + sort.Strings(names) + + out := make([]FlagMeta, 0, len(names)) + for _, n := range names { + opt := byName[n] + out = append(out, FlagMeta{ + Name: opt.Flag, + Shorthand: opt.Shorthand, + Envs: append([]string(nil), opt.Envs...), + Description: strings.TrimSpace(opt.Description), + Type: opt.Type(), + Required: opt.Required, + Default: opt.Default, + }) + } + return out +} + +func toArgMeta(args redant.ArgSet) []ArgMeta { + out := make([]ArgMeta, 0, len(args)) + for i, arg := range args { + name := strings.TrimSpace(arg.Name) + if name == "" { + name = fmt.Sprintf("arg%d", i+1) + } + typ := "string" + if arg.Value != nil { + if v, ok := arg.Value.(interface{ Type() string }); ok { + typ = v.Type() + } + } + out = append(out, ArgMeta{ + Name: name, + Description: strings.TrimSpace(arg.Description), + Type: typ, + Required: arg.Required, + Default: arg.Default, + }) + } + return out +} + +func commandDescription(cmd *redant.Command) string { + short := strings.TrimSpace(cmd.Short) + long := strings.TrimSpace(cmd.Long) + switch { + case short != "" && long != "": + return short + "\n\n" + long + case short != "": + return short + case long != "": + return long + default: + return "" + } +} + +func isSystemFlag(flag string) bool { + switch flag { + case "help", "list-commands", "list-flags", "args": + return true + default: + return false + } +} diff --git a/internal/webui/server_test.go b/internal/webui/server_test.go new file mode 100644 index 0000000..04df2e2 --- /dev/null +++ b/internal/webui/server_test.go @@ -0,0 +1,212 @@ +package webui + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + + "github.com/pubgo/redant" +) + +func TestCommandsEndpoint(t *testing.T) { + var global string + var local string + + root := &redant.Command{ + Use: "testapp", + Options: redant.OptionSet{ + {Flag: "global", Description: "global flag", Envs: []string{"GLOBAL_ENV"}, Value: redant.StringOf(&global)}, + }, + } + + echoCmd := &redant.Command{ + Use: "echo [text]", + Aliases: []string{"e"}, + Short: "echo text", + Long: "echo text long description", + Options: redant.OptionSet{ + {Flag: "local", Description: "local flag", Value: redant.StringOf(&local)}, + }, + Args: redant.ArgSet{ + {Name: "text", Description: "text to print", Required: true, Value: redant.StringOf(new(string))}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _, _ = inv.Stdout.Write([]byte("ok")) + return nil + }, + } + + webCmd := &redant.Command{Use: "web", Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }} + root.Children = append(root.Children, echoCmd, webCmd) + + ts := httptest.NewServer(New(root).Handler()) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/api/commands") + if err != nil { + t.Fatalf("request commands: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", resp.StatusCode) + } + + var payload commandListResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatalf("decode commands response: %v", err) + } + + if len(payload.Commands) != 1 { + t.Fatalf("expected 1 command (web should be excluded), got %d", len(payload.Commands)) + } + + cmd := payload.Commands[0] + if cmd.ID != "echo" { + t.Fatalf("expected command id echo, got %s", cmd.ID) + } + if cmd.Use != "echo [text]" { + t.Fatalf("expected use echo [text], got %s", cmd.Use) + } + if !slices.Equal(cmd.Aliases, []string{"e"}) { + t.Fatalf("unexpected aliases: %+v", cmd.Aliases) + } + if cmd.Short != "echo text" { + t.Fatalf("unexpected short: %s", cmd.Short) + } + if cmd.Long != "echo text long description" { + t.Fatalf("unexpected long: %s", cmd.Long) + } + if len(cmd.Args) != 1 || cmd.Args[0].Name != "text" { + t.Fatalf("unexpected args metadata: %+v", cmd.Args) + } + + flagNames := make([]string, 0, len(cmd.Flags)) + flagByName := make(map[string]FlagMeta, len(cmd.Flags)) + for _, f := range cmd.Flags { + flagNames = append(flagNames, f.Name) + flagByName[f.Name] = f + } + if !slices.Contains(flagNames, "global") || !slices.Contains(flagNames, "local") { + t.Fatalf("expected global+local flags, got: %v", flagNames) + } + if strings.TrimSpace(flagByName["local"].Description) == "" { + t.Fatalf("expected local flag description, got empty") + } + if !slices.Equal(flagByName["global"].Envs, []string{"GLOBAL_ENV"}) { + t.Fatalf("unexpected global envs: %+v", flagByName["global"].Envs) + } +} + +func TestIndexPageServedFromStatic(t *testing.T) { + root := &redant.Command{Use: "testapp"} + root.Children = append(root.Children, &redant.Command{Use: "echo", Handler: func(ctx context.Context, inv *redant.Invocation) error { + return nil + }}) + + ts := httptest.NewServer(New(root).Handler()) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/") + if err != nil { + t.Fatalf("request index: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read index body: %v", err) + } + + page := string(body) + if !strings.Contains(page, "cdn.tailwindcss.com") { + t.Fatalf("expected tailwindcdn tag in page") + } + if !strings.Contains(page, "alpinejs") { + t.Fatalf("expected alpinejs tag in page") + } +} + +func TestRunEndpoint(t *testing.T) { + var text string + var upper bool + + root := &redant.Command{Use: "testapp"} + echoCmd := &redant.Command{ + Use: "echo", + Options: redant.OptionSet{ + {Flag: "upper", Description: "uppercase", Value: redant.BoolOf(&upper)}, + }, + Args: redant.ArgSet{ + {Name: "text", Required: true, Value: redant.StringOf(&text)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + out := text + if upper { + out = strings.ToUpper(text) + } + _, _ = inv.Stdout.Write([]byte(out)) + return nil + }, + } + root.Children = append(root.Children, echoCmd) + + ts := httptest.NewServer(New(root).Handler()) + defer ts.Close() + + requestBody := `{"command":"echo","flags":{"upper":true},"args":{"text":"hello"}}` + resp, err := http.Post(ts.URL+"/api/run", "application/json", bytes.NewBufferString(requestBody)) + if err != nil { + t.Fatalf("run command request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", resp.StatusCode) + } + + var runResp RunResponse + if err := json.NewDecoder(resp.Body).Decode(&runResp); err != nil { + t.Fatalf("decode run response: %v", err) + } + + if !runResp.OK { + t.Fatalf("expected ok response, got error=%s stderr=%s", runResp.Error, runResp.Stderr) + } + if runResp.Stdout != "HELLO" { + t.Fatalf("expected HELLO, got %q", runResp.Stdout) + } + if !strings.Contains(runResp.Invocation, "echo") || !strings.Contains(runResp.Invocation, "--upper") { + t.Fatalf("unexpected invocation: %s", runResp.Invocation) + } +} + +func TestRunEndpointMissingRequiredArg(t *testing.T) { + root := &redant.Command{Use: "testapp"} + root.Children = append(root.Children, &redant.Command{ + Use: "echo", + Args: redant.ArgSet{{Name: "text", Required: true, Value: redant.StringOf(new(string))}}, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + return nil + }, + }) + + ts := httptest.NewServer(New(root).Handler()) + defer ts.Close() + + resp, err := http.Post(ts.URL+"/api/run", "application/json", bytes.NewBufferString(`{"command":"echo","args":{}}`)) + if err != nil { + t.Fatalf("run command request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for missing arg, got %d", resp.StatusCode) + } +} diff --git a/internal/webui/static/index.html b/internal/webui/static/index.html new file mode 100644 index 0000000..b9d5331 --- /dev/null +++ b/internal/webui/static/index.html @@ -0,0 +1,402 @@ + + + + + + + redant web command + + + + + +
+ + +
+ + + +
+
+ + + + + \ No newline at end of file From 0514c2ab3c2b96861a4b51809605c92fc6f5d377 Mon Sep 17 00:00:00 2001 From: barry Date: Sun, 15 Mar 2026 15:45:35 +0800 Subject: [PATCH 09/76] chore: quick update feat/mcp at 2026-03-15 15:45:34 --- cmds/completioncmd/completion.go | 8 +- cmds/completioncmd/completion_test.go | 2 +- command.go | 26 +- command_init_test.go | 54 ++++ example/fastcommit/main.go | 362 ++++++++++++++++++++----- internal/mcpserver/server.go | 1 + internal/mcpserver/server_test.go | 1 + internal/webui/server.go | 152 +++++++++-- internal/webui/server_test.go | 106 +++++++- internal/webui/static/index.html | 375 ++++++++++++++++++++++++-- 10 files changed, 969 insertions(+), 118 deletions(-) create mode 100644 command_init_test.go diff --git a/cmds/completioncmd/completion.go b/cmds/completioncmd/completion.go index 989f6ae..095d6ac 100644 --- a/cmds/completioncmd/completion.go +++ b/cmds/completioncmd/completion.go @@ -230,7 +230,7 @@ compdef %s %s // generateZshCommandCases emits command-path based case branches for zsh completion. func generateZshCommandCases(cmd *redant.Command, path []string, buf *bytes.Buffer) { caseKey := strings.Join(path, " ") - buf.WriteString(fmt.Sprintf(" %q)\n", caseKey)) + fmt.Fprintf(buf, " %q)\n", caseKey) buf.WriteString(" options=(\n") for _, opt := range cmd.FullOptions() { @@ -238,9 +238,9 @@ func generateZshCommandCases(cmd *redant.Command, path []string, buf *bytes.Buff continue } desc := optionZshDescription(opt) - buf.WriteString(fmt.Sprintf(" '--%s:%s'\n", opt.Flag, desc)) + fmt.Fprintf(buf, " '--%s:%s'\n", opt.Flag, desc) if opt.Shorthand != "" { - buf.WriteString(fmt.Sprintf(" '-%s:%s'\n", opt.Shorthand, desc)) + fmt.Fprintf(buf, " '-%s:%s'\n", opt.Shorthand, desc) } } buf.WriteString(" )\n") @@ -250,7 +250,7 @@ func generateZshCommandCases(cmd *redant.Command, path []string, buf *bytes.Buff if child.Hidden { continue } - buf.WriteString(fmt.Sprintf(" '%s:%s'\n", child.Name(), escapeZshDescription(child.Short))) + fmt.Fprintf(buf, " '%s:%s'\n", child.Name(), escapeZshDescription(child.Short)) } buf.WriteString(" )\n") buf.WriteString(" ;;\n") diff --git a/cmds/completioncmd/completion_test.go b/cmds/completioncmd/completion_test.go index 32a80e7..e7cbbc3 100644 --- a/cmds/completioncmd/completion_test.go +++ b/cmds/completioncmd/completion_test.go @@ -168,7 +168,7 @@ func TestCompletionCommandGeneratesScriptsForSupportedShells(t *testing.T) { wantPath := filepath.Join("testdata", tt.golden) if os.Getenv("UPDATE_GOLDEN") == "1" { - if err := os.WriteFile(wantPath, []byte(stdout.String()), 0o644); err != nil { + if err := os.WriteFile(wantPath, stdout.Bytes(), 0o644); err != nil { t.Fatalf("update golden %s: %v", wantPath, err) } } diff --git a/command.go b/command.go index 93a0df0..7888f98 100644 --- a/command.go +++ b/command.go @@ -69,6 +69,30 @@ func ascendingSortFn[T cmp.Ordered](a, b T) int { return 1 } +func appendMissingGlobalOptions(base, globals OptionSet) OptionSet { + existing := make(map[string]struct{}, len(base)) + for _, opt := range base { + if opt.Flag == "" { + continue + } + existing[opt.Flag] = struct{}{} + } + + for _, opt := range globals { + if opt.Flag == "" { + base = append(base, opt) + continue + } + if _, ok := existing[opt.Flag]; ok { + continue + } + base = append(base, opt) + existing[opt.Flag] = struct{}{} + } + + return base +} + // init performs initialization and linting on the command and all its children. func (c *Command) init() error { if c.Use == "" { @@ -79,7 +103,7 @@ func (c *Command) init() error { // Add global flags to the root command only if c.parent == nil { globalFlags := GlobalFlags() - c.Options = append(c.Options, globalFlags...) + c.Options = appendMissingGlobalOptions(c.Options, globalFlags) } for i := range c.Options { diff --git a/command_init_test.go b/command_init_test.go new file mode 100644 index 0000000..fb1ae99 --- /dev/null +++ b/command_init_test.go @@ -0,0 +1,54 @@ +package redant + +import "testing" + +func TestCommandInitIsIdempotentForGlobalFlags(t *testing.T) { + root := &Command{Use: "app"} + + if err := root.init(); err != nil { + t.Fatalf("first init failed: %v", err) + } + if err := root.init(); err != nil { + t.Fatalf("second init failed: %v", err) + } + + counts := map[string]int{} + for _, opt := range root.Options { + if opt.Flag == "" { + continue + } + counts[opt.Flag]++ + } + + for _, flag := range []string{"help", "list-commands", "list-flags", "env", "env-file", internalArgsOverrideFlag} { + if counts[flag] != 1 { + t.Fatalf("expected global flag %q exactly once, got %d", flag, counts[flag]) + } + } + + globals := root.GetGlobalFlags() + _ = globals.FlagSet(root.Name()) +} + +func TestCommandInitDoesNotOverrideExistingRootGlobalFlag(t *testing.T) { + root := &Command{ + Use: "app", + Options: OptionSet{ + {Flag: "env", Description: "custom env", Value: StringArrayOf(new([]string))}, + }, + } + + if err := root.init(); err != nil { + t.Fatalf("init failed: %v", err) + } + + envCount := 0 + for _, opt := range root.Options { + if opt.Flag == "env" { + envCount++ + } + } + if envCount != 1 { + t.Fatalf("expected env flag exactly once, got %d", envCount) + } +} diff --git a/example/fastcommit/main.go b/example/fastcommit/main.go index c3ae056..7558f13 100644 --- a/example/fastcommit/main.go +++ b/example/fastcommit/main.go @@ -2,8 +2,11 @@ package main import ( "context" + "encoding/json" "fmt" + "net/url" "os" + "time" "github.com/pubgo/redant" "github.com/pubgo/redant/cmds/completioncmd" @@ -14,67 +17,153 @@ import ( // mkdir -p ~/.zsh/completions // go run example/fastcommit/main.go completion zsh > ~/.zsh/completions/_fastcommit +type CommitMetadata struct { + Ticket string `json:"ticket" yaml:"ticket"` + Priority string `json:"priority" yaml:"priority"` + Labels []string `json:"labels" yaml:"labels"` + Extra map[string]string `json:"extra" yaml:"extra"` +} + +type ReleasePlan struct { + Strategy string `json:"strategy" yaml:"strategy"` + Canary int `json:"canary" yaml:"canary"` + Services []string `json:"services" yaml:"services"` +} + +type RepoPolicy struct { + ProtectedBranches []string `json:"protectedBranches" yaml:"protectedBranches"` + RequireReview bool `json:"requireReview" yaml:"requireReview"` + MinApprovals int `json:"minApprovals" yaml:"minApprovals"` +} + +func toJSON(v any) string { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Sprintf("", err) + } + return string(b) +} + func main() { - // Create root command rootCmd := &redant.Command{ Use: "fastcommit", Short: "A fast commit tool.", - Long: "A tool for making fast commits with various options.", + Long: "A tool for making fast commits with rich command tree and complex option types.", } - // Create commit command + var ( + commitMessage string + commitAmend bool + commitFormat string + commitLabels []string + commitReviewers []string + commitTimeout time.Duration + commitWeight float64 + commitMaxRetries int64 + commitWebhookURL url.URL + ) + commitEndpoint := &redant.HostPort{} + commitPattern := &redant.Regexp{} + commitMetadata := &redant.Struct[CommitMetadata]{Value: CommitMetadata{ + Ticket: "JIRA-100", + Priority: "high", + Labels: []string{"feat", "backend"}, + Extra: map[string]string{"source": "cli"}, + }} + commitCmd := &redant.Command{ Use: "commit", Short: "Commit changes.", - Long: "Commit changes with a message and other options.", + Long: "Commit changes with advanced options and typed values.", Options: redant.OptionSet{ { Flag: "message", Shorthand: "m", Description: "Commit message.", - Value: redant.StringOf(new(string)), + Value: redant.StringOf(&commitMessage), + Default: "update: default message", }, { Flag: "amend", Description: "Amend the previous commit.", - Value: redant.BoolOf(new(bool)), + Value: redant.BoolOf(&commitAmend), + }, + { + Flag: "format", + Description: "Output format.", + Value: redant.EnumOf(&commitFormat, "text", "json", "yaml"), + Default: "text", + }, + { + Flag: "labels", + Description: "Commit labels enum-array.", + Value: redant.EnumArrayOf(&commitLabels, "feat", "fix", "docs", "refactor", "test", "chore"), + }, + { + Flag: "reviewers", + Description: "Reviewers list.", + Value: redant.StringArrayOf(&commitReviewers), + }, + { + Flag: "timeout", + Description: "Commit timeout duration.", + Value: redant.DurationOf(&commitTimeout), + Default: "30s", + }, + { + Flag: "weight", + Description: "Commit score weight.", + Value: redant.Float64Of(&commitWeight), + Default: "1.5", + }, + { + Flag: "max-retries", + Description: "Max retry count.", + Value: redant.Int64Of(&commitMaxRetries), + Default: "3", + }, + { + Flag: "webhook", + Description: "Webhook URL.", + Value: redant.URLOf(&commitWebhookURL), + Default: "https://example.com/hook", + }, + { + Flag: "endpoint", + Description: "Commit target endpoint (host:port).", + Value: commitEndpoint, + Default: "127.0.0.1:9000", + }, + { + Flag: "pattern", + Description: "Filter regexp.", + Value: commitPattern, + Default: "^(feat|fix|docs):", + }, + { + Flag: "metadata", + Description: "Commit metadata as struct (JSON/YAML).", + Value: commitMetadata, }, }, Args: redant.ArgSet{ - {Name: "files", Description: "Files to commit."}, + {Name: "files", Description: "Files to commit (positional).", Value: redant.StringOf(new(string))}, }, Handler: func(ctx context.Context, inv *redant.Invocation) error { - fmt.Printf("Commit command executed\n") - fmt.Printf("Args: %v\n", inv.Args) - - // Get flag values - if inv.Flags != nil { - // Get values directly from options - var message string - var amend bool - - for _, opt := range inv.Command.Options { - switch opt.Flag { - case "message": - if strVal, ok := opt.Value.(*redant.String); ok { - message = strVal.String() - } - case "amend": - if boolVal, ok := opt.Value.(*redant.Bool); ok { - amend = boolVal.Value() - } - } - } - - fmt.Printf("Message: %s\n", message) - fmt.Printf("Amend: %v\n", amend) - } - + fmt.Printf("[commit] args=%v\n", inv.Args) + fmt.Printf("[commit] message=%q amend=%v format=%s timeout=%s weight=%.2f max-retries=%d\n", commitMessage, commitAmend, commitFormat, commitTimeout, commitWeight, commitMaxRetries) + fmt.Printf("[commit] labels=%v reviewers=%v endpoint=%s webhook=%s pattern=%s\n", commitLabels, commitReviewers, commitEndpoint.String(), redant.URLOf(&commitWebhookURL).String(), commitPattern.String()) + fmt.Printf("[commit] metadata=%s\n", toJSON(commitMetadata.Value)) return nil }, } - // Create detailed subcommand for commit + var ( + detailedAuthor string + detailedVerbose bool + detailedMode string + ) + detailedCmd := &redant.Command{ Use: "detailed", Short: "Detailed commit.", @@ -83,57 +172,194 @@ func main() { { Flag: "author", Description: "Author of the commit.", - Value: redant.StringOf(new(string)), + Value: redant.StringOf(&detailedAuthor), }, { Flag: "verbose", Shorthand: "v", Description: "Verbose output.", - Value: redant.BoolOf(new(bool)), + Value: redant.BoolOf(&detailedVerbose), + }, + { + Flag: "mode", + Description: "Detailed mode.", + Value: redant.EnumOf(&detailedMode, "diff", "stat", "full"), + Default: "diff", }, }, Args: redant.ArgSet{ - {Name: "files", Description: "Files to commit."}, + {Name: "files", Description: "Files to commit.", Value: redant.StringOf(new(string))}, }, Handler: func(ctx context.Context, inv *redant.Invocation) error { - fmt.Printf("Detailed commit command executed\n") - fmt.Printf("Args: %v\n", inv.Args) - - // Get flag values - if inv.Flags != nil { - // Get values directly from options - var author string - var verbose bool - - for _, opt := range inv.Command.Options { - switch opt.Flag { - case "author": - if strVal, ok := opt.Value.(*redant.String); ok { - author = strVal.String() - } - case "verbose": - if boolVal, ok := opt.Value.(*redant.Bool); ok { - verbose = boolVal.Value() - } - } - } - - fmt.Printf("Author: %s\n", author) - fmt.Printf("Verbose: %v\n", verbose) - } + fmt.Printf("[commit detailed] args=%v author=%q verbose=%v mode=%s\n", inv.Args, detailedAuthor, detailedVerbose, detailedMode) + return nil + }, + } + var ( + releaseChannel string + releaseRegions []string + releaseBatchSize int64 + releaseWindow time.Duration + releaseDryRun bool + releaseVersion string + ) + releaseFilter := &redant.Regexp{} + releasePlan := &redant.Struct[ReleasePlan]{Value: ReleasePlan{ + Strategy: "canary", + Canary: 10, + Services: []string{"api", "worker"}, + }} + releaseShipCmd := &redant.Command{ + Use: "release ship", + Short: "Ship a release with rollout controls.", + Long: "Ship release with enum, enum-array, duration, struct, regexp and integer options.", + Options: redant.OptionSet{ + {Flag: "channel", Description: "Release channel.", Value: redant.EnumOf(&releaseChannel, "alpha", "beta", "stable"), Default: "beta"}, + {Flag: "regions", Description: "Target regions.", Value: redant.EnumArrayOf(&releaseRegions, "cn", "us", "eu", "ap")}, + {Flag: "batch-size", Description: "Batch size.", Value: redant.Int64Of(&releaseBatchSize), Default: "100"}, + {Flag: "window", Description: "Release window.", Value: redant.DurationOf(&releaseWindow), Default: "5m"}, + {Flag: "dry-run", Description: "Preview only.", Value: redant.BoolOf(&releaseDryRun)}, + {Flag: "filter", Description: "Service name filter regexp.", Value: releaseFilter, Default: "^(api|worker)$"}, + {Flag: "plan", Description: "Rollout plan object.", Value: releasePlan}, + }, + Args: redant.ArgSet{ + {Name: "version", Required: true, Description: "Release version.", Value: redant.StringOf(&releaseVersion)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + fmt.Printf("[release ship] version=%s channel=%s dry-run=%v regions=%v batch-size=%d window=%s filter=%s\n", releaseVersion, releaseChannel, releaseDryRun, releaseRegions, releaseBatchSize, releaseWindow, releaseFilter.String()) + fmt.Printf("[release ship] plan=%s\n", toJSON(releasePlan.Value)) + return nil + }, + } + + var ( + repoName string + repoVisibility string + repoTags []string + repoMirrorURL url.URL + ) + repoPolicy := &redant.Struct[RepoPolicy]{Value: RepoPolicy{ + ProtectedBranches: []string{"main", "release"}, + RequireReview: true, + MinApprovals: 2, + }} + repoCreateCmd := &redant.Command{ + Use: "create", + Short: "Create repository with policy.", + Long: "Create repository under project scope with enum and struct options.", + Options: redant.OptionSet{ + {Flag: "visibility", Description: "Repo visibility.", Value: redant.EnumOf(&repoVisibility, "public", "private", "internal"), Default: "private"}, + {Flag: "tags", Description: "Repo tags.", Value: redant.StringArrayOf(&repoTags)}, + {Flag: "policy", Description: "Repo policy object.", Value: repoPolicy}, + {Flag: "mirror", Description: "Mirror upstream URL.", Value: redant.URLOf(&repoMirrorURL), Default: "https://github.com/pubgo/redant"}, + }, + Args: redant.ArgSet{ + {Name: "repo_name", Required: true, Description: "Repository name.", Value: redant.StringOf(&repoName)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + fmt.Printf("[project repo create] name=%s visibility=%s tags=%v mirror=%s\n", repoName, repoVisibility, repoTags, redant.URLOf(&repoMirrorURL).String()) + fmt.Printf("[project repo create] policy=%s\n", toJSON(repoPolicy.Value)) + return nil + }, + } + + var ( + mirrorName string + mirrorMode string + mirrorForce bool + ) + projectRepoMirrorCmd := &redant.Command{ + Use: "mirror", + Short: "Mirror repository.", + Long: "Mirror repository with enum mode and bool options.", + Options: redant.OptionSet{ + {Flag: "mode", Description: "Mirror mode.", Value: redant.EnumOf(&mirrorMode, "fetch", "push", "bidirectional"), Default: "fetch"}, + {Flag: "force", Description: "Force mirror sync.", Value: redant.BoolOf(&mirrorForce)}, + }, + Args: redant.ArgSet{{Name: "repo_name", Required: true, Description: "Repository name.", Value: redant.StringOf(&mirrorName)}}, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + fmt.Printf("[project repo mirror] name=%s mode=%s force=%v\n", mirrorName, mirrorMode, mirrorForce) + return nil + }, + } + + projectRepoCmd := &redant.Command{ + Use: "repo", + Short: "Repository operations.", + Long: "Repository operations for integration tests.", + Children: []*redant.Command{ + repoCreateCmd, + projectRepoMirrorCmd, + }, + } + + var ( + envName string + envTargets []string + ) + projectEnvPromoteCmd := &redant.Command{ + Use: "promote", + Short: "Promote environment.", + Long: "Promote env with enum-array targets.", + Options: redant.OptionSet{ + {Flag: "targets", Description: "Promotion targets.", Value: redant.EnumArrayOf(&envTargets, "staging", "pre", "prod")}, + }, + Args: redant.ArgSet{{Name: "env", Required: true, Description: "Environment name.", Value: redant.StringOf(&envName)}}, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + fmt.Printf("[project env promote] env=%s targets=%v\n", envName, envTargets) + return nil + }, + } + + projectEnvCmd := &redant.Command{ + Use: "env", + Short: "Environment operations.", + Children: []*redant.Command{ + projectEnvPromoteCmd, + }, + } + + projectCmd := &redant.Command{ + Use: "project", + Short: "Project operations.", + Long: "Project command group with 3-level subcommands for completion and web testing.", + Children: []*redant.Command{ + projectRepoCmd, + projectEnvCmd, + }, + } + + var ( + profileName string + profileContent string + ) + profileCmd := &redant.Command{ + Use: "profile", + Short: "Profile parser playground.", + Long: "Playground command for args formats (query/form/json/positional).", + Args: redant.ArgSet{ + {Name: "name", Required: true, Description: "Profile name.", Value: redant.StringOf(&profileName)}, + {Name: "content", Required: false, Description: "Profile content.", Value: redant.StringOf(&profileContent)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + fmt.Printf("[profile] args=%v name=%s content=%s\n", inv.Args, profileName, profileContent) return nil }, } - // Build command tree commitCmd.Children = append(commitCmd.Children, detailedCmd) - rootCmd.Children = append(rootCmd.Children, commitCmd) - rootCmd.Children = append(rootCmd.Children, completioncmd.New()) - rootCmd.Children = append(rootCmd.Children, mcpcmd.New()) - rootCmd.Children = append(rootCmd.Children, webcmd.New()) - // Run command + rootCmd.Children = append(rootCmd.Children, + commitCmd, + releaseShipCmd, + projectCmd, + profileCmd, + completioncmd.New(), + mcpcmd.New(), + webcmd.New(), + ) + err := rootCmd.Invoke().WithOS().Run() if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 9be9013..4580f7a 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/pubgo/redant" ) diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index 3a523d8..1c79900 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/pubgo/redant" ) diff --git a/internal/webui/server.go b/internal/webui/server.go index b368d97..05d85fc 100644 --- a/internal/webui/server.go +++ b/internal/webui/server.go @@ -12,15 +12,18 @@ import ( "strings" "sync" + "github.com/spf13/pflag" + "github.com/pubgo/redant" ) type ArgMeta struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Type string `json:"type"` - Required bool `json:"required"` - Default string `json:"default,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Type string `json:"type"` + EnumValues []string `json:"enumValues,omitempty"` + Required bool `json:"required"` + Default string `json:"default,omitempty"` } type FlagMeta struct { @@ -29,6 +32,7 @@ type FlagMeta struct { Envs []string `json:"envs,omitempty"` Description string `json:"description,omitempty"` Type string `json:"type"` + EnumValues []string `json:"enumValues,omitempty"` Required bool `json:"required"` Default string `json:"default,omitempty"` } @@ -56,13 +60,15 @@ type RunRequest struct { } type RunResponse struct { - OK bool `json:"ok"` - Command string `json:"command"` - Invocation string `json:"invocation"` - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` - Error string `json:"error"` - Combined string `json:"combined"` + OK bool `json:"ok"` + Command string `json:"command"` + Program string `json:"program,omitempty"` + Argv []string `json:"argv,omitempty"` + Invocation string `json:"invocation"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Error string `json:"error"` + Combined string `json:"combined"` } type commandListResponse struct { @@ -121,7 +127,7 @@ func (a *App) handleRun(w http.ResponseWriter, r *http.Request) { return } - argv, invocation, err := buildInvocation(meta, req) + argv, program, invocation, err := buildInvocation(meta, req) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -144,6 +150,8 @@ func (a *App) handleRun(w http.ResponseWriter, r *http.Request) { resp := RunResponse{ OK: runErr == nil, Command: meta.ID, + Program: program, + Argv: append([]string(nil), argv...), Invocation: invocation, Stdout: stdout.String(), Stderr: stderr.String(), @@ -157,7 +165,7 @@ func (a *App) handleRun(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(resp) } -func buildInvocation(meta CommandMeta, req RunRequest) ([]string, string, error) { +func buildInvocation(meta CommandMeta, req RunRequest) ([]string, string, string, error) { argv := append([]string(nil), meta.Path...) for _, flag := range meta.Flags { @@ -167,23 +175,27 @@ func buildInvocation(meta CommandMeta, req RunRequest) ([]string, string, error) } tokens, err := serializeFlag(flag, v) if err != nil { - return nil, "", fmt.Errorf("flag %q: %w", flag.Name, err) + return nil, "", "", fmt.Errorf("flag %q: %w", flag.Name, err) } argv = append(argv, tokens...) } if len(meta.Args) > 0 { - for _, arg := range meta.Args { - v, ok := req.Args[arg.Name] + for i, arg := range meta.Args { + v, ok := lookupArgValue(req.Args, arg.Name) + if !ok && i < len(req.RawArgs) { + v = req.RawArgs[i] + ok = true + } if !ok { if arg.Required && arg.Default == "" { - return nil, "", fmt.Errorf("missing required arg %q", arg.Name) + return nil, "", "", fmt.Errorf("missing required arg %q", arg.Name) } continue } val, err := serializeValueByType(arg.Type, v) if err != nil { - return nil, "", fmt.Errorf("arg %q: %w", arg.Name, err) + return nil, "", "", fmt.Errorf("arg %q: %w", arg.Name, err) } argv = append(argv, val) } @@ -197,7 +209,32 @@ func buildInvocation(meta CommandMeta, req RunRequest) ([]string, string, error) invocation += " " + shellQuote(token) } - return argv, invocation, nil + return argv, prog, invocation, nil +} + +func lookupArgValue(args map[string]any, name string) (any, bool) { + if len(args) == 0 { + return nil, false + } + + if v, ok := args[name]; ok { + return v, true + } + + trimmed := strings.TrimSpace(name) + if trimmed != name { + if v, ok := args[trimmed]; ok { + return v, true + } + } + + for k, v := range args { + if strings.TrimSpace(k) == trimmed { + return v, true + } + } + + return nil, false } func serializeFlag(flag FlagMeta, raw any) ([]string, error) { @@ -438,6 +475,7 @@ func toFlagMeta(opts redant.OptionSet) []FlagMeta { Envs: append([]string(nil), opt.Envs...), Description: strings.TrimSpace(opt.Description), Type: opt.Type(), + EnumValues: extractEnumValues(opt.Value, opt.Type()), Required: opt.Required, Default: opt.Default, }) @@ -462,6 +500,7 @@ func toArgMeta(args redant.ArgSet) []ArgMeta { Name: name, Description: strings.TrimSpace(arg.Description), Type: typ, + EnumValues: extractEnumValues(arg.Value, typ), Required: arg.Required, Default: arg.Default, }) @@ -469,6 +508,79 @@ func toArgMeta(args redant.ArgSet) []ArgMeta { return out } +func extractEnumValues(value pflag.Value, typ string) []string { + vals := extractEnumValuesFromValue(value) + if len(vals) == 0 { + vals = parseEnumValuesFromType(typ) + } + return normalizeEnumValues(vals) +} + +func extractEnumValuesFromValue(value pflag.Value) []string { + if value == nil { + return nil + } + + switch v := value.(type) { + case *redant.Enum: + return append([]string(nil), v.Choices...) + case *redant.EnumArray: + return append([]string(nil), v.Choices...) + case interface{ Underlying() pflag.Value }: + return extractEnumValuesFromValue(v.Underlying()) + default: + return nil + } +} + +func parseEnumValuesFromType(typ string) []string { + if !(strings.HasPrefix(typ, "enum[") || strings.HasPrefix(typ, "enum-array[")) || !strings.HasSuffix(typ, "]") { + return nil + } + + start := strings.IndexByte(typ, '[') + if start < 0 || start+1 >= len(typ)-1 { + return nil + } + inner := typ[start+1 : len(typ)-1] + + var parts []string + if strings.Contains(inner, `\|`) { + parts = strings.Split(inner, `\|`) + } else { + parts = strings.Split(inner, "|") + } + return parts +} + +func normalizeEnumValues(values []string) []string { + if len(values) == 0 { + return nil + } + + out := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, raw := range values { + v := normalizeEnumValue(raw) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} + +func normalizeEnumValue(value string) string { + v := strings.TrimSpace(value) + v = strings.ReplaceAll(v, `\|`, "|") + v = strings.Trim(v, " \\|,;[](){}\"'`") + return strings.TrimSpace(v) +} + func commandDescription(cmd *redant.Command) string { short := strings.TrimSpace(cmd.Short) long := strings.TrimSpace(cmd.Long) diff --git a/internal/webui/server_test.go b/internal/webui/server_test.go index 04df2e2..ceb14a6 100644 --- a/internal/webui/server_test.go +++ b/internal/webui/server_test.go @@ -12,11 +12,13 @@ import ( "testing" "github.com/pubgo/redant" + "github.com/pubgo/redant/cmds/completioncmd" ) func TestCommandsEndpoint(t *testing.T) { var global string var local string + var format string root := &redant.Command{ Use: "testapp", @@ -32,6 +34,7 @@ func TestCommandsEndpoint(t *testing.T) { Long: "echo text long description", Options: redant.OptionSet{ {Flag: "local", Description: "local flag", Value: redant.StringOf(&local)}, + {Flag: "format", Description: "output format", Value: redant.EnumOf(&format, "json", "text")}, }, Args: redant.ArgSet{ {Name: "text", Description: "text to print", Required: true, Value: redant.StringOf(new(string))}, @@ -52,7 +55,7 @@ func TestCommandsEndpoint(t *testing.T) { if err != nil { t.Fatalf("request commands: %v", err) } - defer resp.Body.Close() + defer closeResponseBody(t, resp) if resp.StatusCode != http.StatusOK { t.Fatalf("unexpected status: %d", resp.StatusCode) @@ -96,6 +99,9 @@ func TestCommandsEndpoint(t *testing.T) { if !slices.Contains(flagNames, "global") || !slices.Contains(flagNames, "local") { t.Fatalf("expected global+local flags, got: %v", flagNames) } + if !slices.Equal(flagByName["format"].EnumValues, []string{"json", "text"}) { + t.Fatalf("unexpected format enum values: %+v", flagByName["format"].EnumValues) + } if strings.TrimSpace(flagByName["local"].Description) == "" { t.Fatalf("expected local flag description, got empty") } @@ -117,7 +123,7 @@ func TestIndexPageServedFromStatic(t *testing.T) { if err != nil { t.Fatalf("request index: %v", err) } - defer resp.Body.Close() + defer closeResponseBody(t, resp) body, err := io.ReadAll(resp.Body) if err != nil { @@ -165,7 +171,7 @@ func TestRunEndpoint(t *testing.T) { if err != nil { t.Fatalf("run command request: %v", err) } - defer resp.Body.Close() + defer closeResponseBody(t, resp) if resp.StatusCode != http.StatusOK { t.Fatalf("unexpected status: %d", resp.StatusCode) @@ -204,9 +210,101 @@ func TestRunEndpointMissingRequiredArg(t *testing.T) { if err != nil { t.Fatalf("run command request: %v", err) } - defer resp.Body.Close() + defer closeResponseBody(t, resp) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected 400 for missing arg, got %d", resp.StatusCode) } } + +func TestRunEndpointUsesRawArgsFallback(t *testing.T) { + var text string + root := &redant.Command{Use: "testapp"} + root.Children = append(root.Children, &redant.Command{ + Use: "echo", + Args: redant.ArgSet{{Name: "text", Required: true, Value: redant.StringOf(&text)}}, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _, _ = inv.Stdout.Write([]byte(text)) + return nil + }, + }) + + ts := httptest.NewServer(New(root).Handler()) + defer ts.Close() + + resp, err := http.Post(ts.URL+"/api/run", "application/json", bytes.NewBufferString(`{"command":"echo","rawArgs":["fallback-text"]}`)) + if err != nil { + t.Fatalf("run command request: %v", err) + } + defer closeResponseBody(t, resp) + + if resp.StatusCode != http.StatusOK { + payload, _ := io.ReadAll(resp.Body) + t.Fatalf("unexpected status: %d body=%s", resp.StatusCode, string(payload)) + } + + var runResp RunResponse + if err := json.NewDecoder(resp.Body).Decode(&runResp); err != nil { + t.Fatalf("decode run response: %v", err) + } + if !runResp.OK { + t.Fatalf("expected ok, got error=%s", runResp.Error) + } + if runResp.Stdout != "fallback-text" { + t.Fatalf("expected fallback-text, got %q", runResp.Stdout) + } + if !strings.Contains(runResp.Invocation, "fallback-text") { + t.Fatalf("expected invocation contains arg, got %q", runResp.Invocation) + } +} + +func TestRunEndpointWithPreInitializedRootNoDuplicateEnvPanic(t *testing.T) { + root := &redant.Command{Use: "testapp"} + completioncmd.AddCompletionCommand(root) + + // 先执行一次命令,模拟 web 子命令启动前根命令已初始化的真实场景。 + pre := root.Invoke("completion", "bash") + pre.Stdout = &bytes.Buffer{} + pre.Stderr = &bytes.Buffer{} + if err := pre.Run(); err != nil { + t.Fatalf("pre-initialize root failed: %v", err) + } + + ts := httptest.NewServer(New(root).Handler()) + defer ts.Close() + + body := `{"command":"completion","args":{"shell":"bash"}}` + for i := 0; i < 2; i++ { + resp, err := http.Post(ts.URL+"/api/run", "application/json", bytes.NewBufferString(body)) + if err != nil { + t.Fatalf("run request %d failed: %v", i+1, err) + } + + if resp.StatusCode != http.StatusOK { + payload, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + t.Fatalf("unexpected status on run %d: %d body=%s", i+1, resp.StatusCode, string(payload)) + } + + var out RunResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + _ = resp.Body.Close() + t.Fatalf("decode run response %d: %v", i+1, err) + } + _ = resp.Body.Close() + + if !out.OK { + t.Fatalf("run %d failed, error=%s stderr=%s", i+1, out.Error, out.Stderr) + } + } +} + +func closeResponseBody(t *testing.T, resp *http.Response) { + t.Helper() + if resp == nil || resp.Body == nil { + return + } + if err := resp.Body.Close(); err != nil { + t.Errorf("close response body: %v", err) + } +} diff --git a/internal/webui/static/index.html b/internal/webui/static/index.html index b9d5331..5acad7a 100644 --- a/internal/webui/static/index.html +++ b/internal/webui/static/index.html @@ -101,6 +101,7 @@

Flags

required default: + json object
env: Flags x-text="flag.envs.join(', ')">
+
+ +
@@ -123,28 +132,63 @@

Flags