├── .github ├── dependabot.yml ├── funding.json └── workflows │ └── ci.yml ├── LICENSE ├── README.md ├── climate.go ├── cmd ├── cligen │ ├── go.mod │ ├── go.sum │ ├── main.go │ └── md.cli └── examples │ ├── go.mod │ ├── go.sum │ ├── greet │ ├── main.go │ ├── main_test.go │ └── md.cli │ └── jj │ ├── main.go │ ├── main_test.go │ ├── md.cli │ └── util │ ├── completion.go │ └── util.go ├── command.go ├── error.go ├── go.mod ├── go.sum ├── internal ├── metadata.go ├── param.go ├── run.go ├── strings.go └── strings_test.go ├── options.go ├── plan.go ├── reflect.go ├── strconv.go ├── strconv_test.go └── testing ├── clitest └── clitest.go ├── go.mod └── go.sum /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | commit-message: 8 | prefix: ci 9 | - package-ecosystem: gomod 10 | directories: ['**/*'] 11 | schedule: 12 | interval: weekly 13 | commit-message: 14 | prefix: gomod 15 | groups: 16 | all: 17 | patterns: ['*'] 18 | -------------------------------------------------------------------------------- /.github/funding.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1.0.0", 3 | "entity": { 4 | "type": "individual", 5 | "role": "owner", 6 | "name": "Vamsi Avula", 7 | "email": "avamsi07@gmail.com", 8 | "phone": "", 9 | "description": "Python fanboy", 10 | "webpageUrl": { 11 | "url": "https://github.com/avamsi", 12 | "wellKnown": "" 13 | } 14 | }, 15 | "projects": [ 16 | { 17 | "guid": "climate", 18 | "name": "CLI Mate", 19 | "description": "CLI Mate autogenerates CLIs from structs / functions (nested subcommands, global / local flags, help generation, typo suggestions, shell completion etc.)", 20 | "webpageUrl": { 21 | "url": "https://github.com/avamsi/climate", 22 | "wellKnown": "" 23 | }, 24 | "repositoryUrl": { 25 | "url": "https://github.com/avamsi/climate", 26 | "wellKnown": "" 27 | }, 28 | "licenses": [ 29 | "unlicense" 30 | ], 31 | "tags": [ 32 | "cli", 33 | "golang", 34 | "library" 35 | ] 36 | } 37 | ], 38 | "funding": { 39 | "channels": [ 40 | { 41 | "guid": "github-sponsors", 42 | "type": "payment-provider", 43 | "address": "https://github.com/sponsors/avamsi", 44 | "description": "" 45 | }, 46 | { 47 | "guid": "paypal", 48 | "type": "payment-provider", 49 | "address": "https://paypal.me/avamsi07", 50 | "description": "" 51 | } 52 | ], 53 | "plans": [ 54 | { 55 | "guid": "im-feeling-lucky", 56 | "status": "active", 57 | "name": "I'm Feeling Lucky", 58 | "description": "", 59 | "amount": 0, 60 | "currency": "USD", 61 | "frequency": "monthly", 62 | "channels": [ 63 | "github-sponsors", 64 | "paypal" 65 | ] 66 | }, 67 | { 68 | "guid": "20-project", 69 | "status": "active", 70 | "name": "20% Project", 71 | "description": "", 72 | "amount": 40000, 73 | "currency": "USD", 74 | "frequency": "yearly", 75 | "channels": [ 76 | "github-sponsors", 77 | "paypal" 78 | ] 79 | } 80 | ], 81 | "history": [ 82 | { 83 | "year": 2024, 84 | "income": 250, 85 | "expenses": 20000, 86 | "taxes": 0, 87 | "currency": "USD", 88 | "description": "" 89 | }, 90 | { 91 | "year": 2023, 92 | "income": 0, 93 | "expenses": 40000, 94 | "taxes": 0, 95 | "currency": "USD", 96 | "description": "" 97 | }, 98 | { 99 | "year": 2022, 100 | "income": 0, 101 | "expenses": 20000, 102 | "taxes": 0, 103 | "currency": "USD", 104 | "description": "" 105 | } 106 | ] 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | permissions: read-all 3 | 4 | jobs: 5 | ci: 6 | uses: avamsi/go-nogo/.github/workflows/ci.yml@main 7 | with: 8 | runs-on: ubuntu-latest 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Climate 2 | 3 | Climate "CLI Mate" aims to make creating CLIs in Go easy (and fun!), similar to 4 | [python-fire](https://github.com/google/python-fire). 5 | It's also built on top of [Cobra](https://github.com/spf13/cobra) and so comes 6 | with "batteries included" (help, shell completion etc.). 7 | 8 | ## Usage 9 | 10 | https://github.com/avamsi/climate/blob/3aef84cce318eaf6af6c2684002196e635faf4a5/cmd/examples/greet/main.go#L11-L54 11 | 12 | ``` 13 | $ greet --help 14 | 15 | Greet someone. 16 | 17 | Usage: 18 | greet [opts] 19 | 20 | Flags: 21 | -g, --greeting string (default Hello) greeting to use 22 | -n, --name string (default World) name to greet 23 | -t, --times int number of times to greet 24 | -h, --help help for greet 25 | -v, --version version for greet 26 | ``` 27 | 28 | ### Subcommands 29 | 30 | https://github.com/avamsi/climate/blob/3aef84cce318eaf6af6c2684002196e635faf4a5/cmd/examples/jj/main.go#L14-L87 31 | 32 | ``` 33 | $ jj --help 34 | 35 | Jujutsu (an experimental VCS). 36 | 37 | Usage: 38 | jj [command] 39 | 40 | Available Commands: 41 | completion Generate the autocompletion script for the specified shell 42 | git Commands for working with the underlying Git repo 43 | help Help about any command 44 | init Create a new repo in the given directory 45 | squash Move changes from a revision into its parent 46 | util Infrequently used commands such as for generating shell completions 47 | version Display jj's version information 48 | 49 | Flags: 50 | -R, --repository path path to the repo to operate on 51 | --ignore-working-copy don't snapshot / update the working copy 52 | -h, --help help for jj 53 | -v, --version version for jj 54 | 55 | Use "jj [command] --help" for more information about a command. 56 | ``` 57 | 58 | ``` 59 | $ jj git --help 60 | 61 | Commands for working with the underlying Git repo. 62 | 63 | Usage: 64 | jj git [command] 65 | 66 | Available Commands: 67 | export Update the underlying Git repo with changes made in the repo 68 | remote Manage Git remotes 69 | 70 | Flags: 71 | -h, --help help for git 72 | 73 | Global Flags: 74 | --ignore-working-copy don't snapshot / update the working copy 75 | -R, --repository path path to the repo to operate on 76 | 77 | Use "jj git [command] --help" for more information about a command. 78 | ``` 79 | -------------------------------------------------------------------------------- /climate.go: -------------------------------------------------------------------------------- 1 | // Package climate "CLI Mate" provides a set of APIs to autogenerate CLIs from 2 | // structs/functions with support for nested subcommands, global/local flags, 3 | // help generation from comments, typo suggestions, shell completion and more. 4 | // 5 | // See https://github.com/avamsi/climate/blob/main/README.md for more details. 6 | package climate 7 | 8 | import ( 9 | "context" 10 | "errors" 11 | "os" 12 | "os/exec" 13 | "reflect" 14 | 15 | "github.com/avamsi/ergo" 16 | "github.com/avamsi/ergo/assert" 17 | 18 | "github.com/avamsi/climate/internal" 19 | ) 20 | 21 | // Func returns an executable plan for the given function, which must conform to 22 | // the following signatures (excuse the partial [optional] notation): 23 | // 24 | // func([ctx context.Context], [opts *T], [args []string]) [(err error)] 25 | // 26 | // All of ctx, opts, args and error are optional. If opts is present, T must be 27 | // a struct (whose fields are used as flags). 28 | func Func(f any) *funcPlan { 29 | t := reflect.TypeOf(f) 30 | assert.Truef(t.Kind() == reflect.Func, "not a func: %v", t) 31 | v := reflect.ValueOf(f) 32 | return &funcPlan{reflection{ot: t, ov: &v}} 33 | } 34 | 35 | var _ internal.Plan = (*funcPlan)(nil) 36 | 37 | // Struct returns an executable plan for the struct given as the type parameter, 38 | // with its methods* (and "child" structs) as subcommands. 39 | // 40 | // * Only methods with pointer receiver are considered (and they must otherwise 41 | // conform to the same signatures described in Func). 42 | func Struct[T any](subcommands ...*structPlan) *structPlan { 43 | t := reflect.TypeFor[T]() 44 | assert.Truef(t.Kind() == reflect.Struct, "not a struct: %v", t) 45 | if n := t.NumMethod(); n > 0 { 46 | ms := make([]string, n) 47 | for i := 0; i < n; i++ { 48 | ms[i] = t.Method(i).Name 49 | } 50 | ergo.Panicf("nonzero methods %v on: %v", ms, t) 51 | } 52 | ptr := reflect.PointerTo(t) 53 | assert.Truef(ptr.NumMethod() > 0, "no methods on: %v", ptr) 54 | return &structPlan{ 55 | reflection{ptr: &reflection{ot: ptr}, ot: t}, 56 | subcommands, 57 | } 58 | } 59 | 60 | var _ internal.Plan = (*structPlan)(nil) 61 | 62 | func exitCode(err error) int { 63 | if err == nil { // if _no_ error 64 | return 0 65 | } 66 | if eerr := new(exitError); errors.As(err, &eerr) { 67 | return eerr.code 68 | } else if eerr := new(exec.ExitError); errors.As(err, &eerr) { 69 | return eerr.ExitCode() 70 | } 71 | return 1 72 | } 73 | 74 | // WithMetadata returns a modifier that sets the metadata to be used by Run for 75 | // augmenting the CLI with additional information (for --help etc.). 76 | func WithMetadata(b []byte) func(*internal.RunOptions) { 77 | return func(opts *internal.RunOptions) { 78 | opts.Metadata = &b 79 | } 80 | } 81 | 82 | // Run executes the given plan and returns the exit code. 83 | func Run(ctx context.Context, p internal.Plan, mods ...func(*internal.RunOptions)) int { 84 | var opts internal.RunOptions 85 | for _, mod := range mods { 86 | mod(&opts) 87 | } 88 | var md *internal.Metadata 89 | if opts.Metadata != nil { 90 | md = internal.DecodeAsMetadata(*opts.Metadata) 91 | } 92 | ctx, cancel := context.WithCancel(ctx) 93 | defer cancel() 94 | // Cobra already prints the error to stderr, so just return exit code here. 95 | return exitCode(p.Execute(ctx, md)) 96 | } 97 | 98 | // RunAndExit executes the given plan and exits with the exit code. 99 | func RunAndExit(p internal.Plan, mods ...func(*internal.RunOptions)) { 100 | os.Exit(Run(context.Background(), p, mods...)) 101 | } 102 | -------------------------------------------------------------------------------- /cmd/cligen/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/avamsi/climate/cmd/cligen 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/avamsi/climate v0.0.0-20241207052824-3e55dcec3f7d 7 | github.com/avamsi/ergo v0.0.0-20250210165756-c19aaccc8346 8 | github.com/sanity-io/litter v1.5.8 9 | golang.org/x/tools v0.33.0 10 | ) 11 | 12 | tool github.com/avamsi/climate/cmd/cligen 13 | 14 | require ( 15 | github.com/davecgh/go-spew v1.1.1 // indirect 16 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 17 | github.com/pmezard/go-difflib v1.0.0 // indirect 18 | github.com/spf13/cobra v1.9.1 // indirect 19 | github.com/spf13/pflag v1.0.6 // indirect 20 | golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa // indirect 21 | golang.org/x/mod v0.24.0 // indirect 22 | golang.org/x/sync v0.14.0 // indirect 23 | golang.org/x/text v0.25.0 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /cmd/cligen/go.sum: -------------------------------------------------------------------------------- 1 | github.com/avamsi/climate v0.0.0-20241207052824-3e55dcec3f7d h1:SeYGWdyV3sXJv1v7FFljGequW3uwmOp7UcbReVXHQ4s= 2 | github.com/avamsi/climate v0.0.0-20241207052824-3e55dcec3f7d/go.mod h1:EoU+4pmQSAfTcBQiQkIcU9RWD5vkBM67OdCD/eSlJYM= 3 | github.com/avamsi/ergo v0.0.0-20250210165756-c19aaccc8346 h1:IffBmxusWG5vSyCqVdOWXBbuEhy3gKGX2M0Nr8YRkUc= 4 | github.com/avamsi/ergo v0.0.0-20250210165756-c19aaccc8346/go.mod h1:woiGOfYKn9S9DM0VmN2pLci6qmmHIiIcrAvjFi018Ys= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 6 | github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 10 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 11 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 12 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 13 | github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 15 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 16 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 17 | github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg= 18 | github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= 19 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 20 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 21 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 22 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 23 | github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 24 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 25 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 26 | golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrCPyJZ+eyRsABUPs1mz4= 27 | golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk= 28 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 29 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 30 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 31 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 32 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 33 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 34 | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= 35 | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= 36 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 37 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 38 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 39 | -------------------------------------------------------------------------------- /cmd/cligen/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "go/ast" 7 | "io/fs" 8 | "os" 9 | "path/filepath" 10 | "reflect" 11 | 12 | _ "embed" 13 | 14 | "github.com/avamsi/ergo/assert" 15 | "github.com/sanity-io/litter" 16 | "golang.org/x/tools/go/packages" 17 | 18 | "github.com/avamsi/climate" 19 | "github.com/avamsi/climate/internal" 20 | ) 21 | 22 | func parseFunc(f *ast.FuncDecl, pkgMd *internal.RawMetadata) { 23 | parentMd := pkgMd 24 | if f.Recv != nil { 25 | assert.Truef(len(f.Recv.List) == 1, 26 | "not exactly one receiver: %v", litter.Sdump(f.Recv.List)) 27 | recv := f.Recv.List[0] 28 | // We only support pointer receivers, skip others. 29 | e, ok := recv.Type.(*ast.StarExpr) 30 | if !ok { 31 | return 32 | } 33 | parentMd = pkgMd.Child(e.X.(*ast.Ident).Name) 34 | } 35 | md := parentMd.Child(f.Name.Name) 36 | md.SetDoc(f.Doc) 37 | for _, param := range f.Type.Params.List { 38 | for _, n := range param.Names { 39 | md.Params = append(md.Params, n.Name) 40 | } 41 | } 42 | } 43 | 44 | func parseType(g *ast.GenDecl, pkgMd *internal.RawMetadata) { 45 | for _, spec := range g.Specs { 46 | spec, ok := spec.(*ast.TypeSpec) 47 | if !ok { 48 | continue 49 | } 50 | s, ok := spec.Type.(*ast.StructType) 51 | if !ok { 52 | continue 53 | } 54 | structMd := pkgMd.Child(spec.Name.Name) 55 | structMd.SetDoc(g.Doc) 56 | for _, f := range s.Fields.List { 57 | for _, n := range f.Names { 58 | md := structMd.Child(n.Name) 59 | md.SetDoc(f.Doc) 60 | md.SetComment(f.Comment) 61 | } 62 | } 63 | } 64 | } 65 | 66 | func parsePkg(pkg *packages.Package, rootMd *internal.RawMetadata) { 67 | pkgMd := rootMd.Child(pkg.PkgPath) 68 | if pkg.Name == "main" { 69 | // main packages are special, in that they're standalone in production 70 | // (reflect will report the package path as "main", for example) but 71 | // they're importable in tests "normally". 72 | // TODO: stop duplicating the package metadata here. 73 | rootMd.Children["main"] = pkgMd 74 | } 75 | for node := range pkg.TypesInfo.Scopes { 76 | file, ok := node.(*ast.File) 77 | if !ok { 78 | continue 79 | } 80 | for _, decl := range file.Decls { 81 | switch decl := decl.(type) { 82 | case *ast.FuncDecl: 83 | parseFunc(decl, pkgMd) 84 | case *ast.GenDecl: 85 | parseType(decl, pkgMd) 86 | } 87 | } 88 | } 89 | } 90 | 91 | func pkgDir(pkg *packages.Package) string { 92 | if len(pkg.GoFiles) > 0 { 93 | return filepath.Dir(pkg.GoFiles[0]) 94 | } 95 | return "" 96 | } 97 | 98 | type options struct { 99 | Debug bool // whether to print metadata 100 | } 101 | 102 | // cligen recursively parses the metadata of all Go packages in the current 103 | // directory and its subdirectories, and writes it to the given output file. 104 | func cligen(opts *options, out string) { 105 | var ( 106 | rootMd internal.RawMetadata 107 | mode = (packages.NeedName | packages.NeedFiles | 108 | packages.NeedTypes | packages.NeedTypesInfo) 109 | cfg = &packages.Config{Mode: mode} 110 | pkgs = assert.Ok(packages.Load(cfg, "./...")) 111 | rootDir = assert.Ok(filepath.Abs(assert.Ok(os.Getwd()))) 112 | mainPkgs []string 113 | ) 114 | for _, pkg := range pkgs { 115 | if pkg.Name == "main" { 116 | if pkgDir(pkg) != rootDir { 117 | // Skip non-root main packages. 118 | continue 119 | } 120 | mainPkgs = append(mainPkgs, pkg.PkgPath) 121 | assert.Truef(len(mainPkgs) <= 1, 122 | "more than one main packages: %v", mainPkgs) 123 | } 124 | parsePkg(pkg, &rootMd) 125 | } 126 | if opts.Debug { 127 | litter.Dump(rootMd) 128 | } 129 | // gob encoding is not deterministic with maps, which means the encoded 130 | // metadata (and hence the output file) will keep changing on each 131 | // invocation even if nothing about the metadata actually changed, which is 132 | // not ideal (causes VCS noise, for example). To avoid this, we compare the 133 | // new metadata with the old one and write only if they are different. 134 | oldEncoded, err := os.ReadFile(out) 135 | if !errors.Is(err, fs.ErrNotExist) { // if exists 136 | assert.Nil(err) 137 | oldDecoded := internal.DecodeAsRawMetadata(oldEncoded) 138 | if reflect.DeepEqual(rootMd, *oldDecoded) { 139 | return 140 | } 141 | } 142 | // #nosec G306 -- G306 expects 0o600 or less but 0o644 is fine here as the 143 | // metadata is not really sensitive (and is expected to be committed). 144 | assert.Nil(os.WriteFile(out, rootMd.Encode(), 0o644)) 145 | fmt.Println("cligen: (re)generated", out) 146 | } 147 | 148 | //go:generate go tool cligen md.cli 149 | //go:embed md.cli 150 | var md []byte 151 | 152 | func main() { 153 | climate.RunAndExit(climate.Func(cligen), climate.WithMetadata(md)) 154 | } 155 | -------------------------------------------------------------------------------- /cmd/cligen/md.cli: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avamsi/climate/b00c79dda296f7f8ea9e75b33ba82962b5750f93/cmd/cligen/md.cli -------------------------------------------------------------------------------- /cmd/examples/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/avamsi/climate/cmd/examples 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/avamsi/climate v0.0.0 7 | github.com/avamsi/climate/testing v0.0.0 8 | github.com/google/go-cmp v0.7.0 9 | ) 10 | 11 | tool github.com/avamsi/climate/cmd/cligen 12 | 13 | require ( 14 | github.com/avamsi/climate/cmd/cligen v0.0.0 // indirect 15 | github.com/avamsi/ergo v0.0.0-20250210165756-c19aaccc8346 // indirect 16 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 17 | github.com/sanity-io/litter v1.5.8 // indirect 18 | github.com/spf13/cobra v1.9.1 // indirect 19 | github.com/spf13/pflag v1.0.6 // indirect 20 | golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa // indirect 21 | golang.org/x/mod v0.24.0 // indirect 22 | golang.org/x/sync v0.14.0 // indirect 23 | golang.org/x/text v0.25.0 // indirect 24 | golang.org/x/tools v0.33.0 // indirect 25 | ) 26 | 27 | replace ( 28 | github.com/avamsi/climate v0.0.0 => ../.. 29 | github.com/avamsi/climate/cmd/cligen v0.0.0 => ../cligen 30 | github.com/avamsi/climate/testing v0.0.0 => ../../testing 31 | ) 32 | -------------------------------------------------------------------------------- /cmd/examples/go.sum: -------------------------------------------------------------------------------- 1 | github.com/avamsi/ergo v0.0.0-20250210165756-c19aaccc8346 h1:IffBmxusWG5vSyCqVdOWXBbuEhy3gKGX2M0Nr8YRkUc= 2 | github.com/avamsi/ergo v0.0.0-20250210165756-c19aaccc8346/go.mod h1:woiGOfYKn9S9DM0VmN2pLci6qmmHIiIcrAvjFi018Ys= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 4 | github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 8 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 9 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 10 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 11 | github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 12 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 13 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 15 | github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg= 16 | github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= 17 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 18 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 19 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 20 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 21 | github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 22 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 23 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 24 | golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrCPyJZ+eyRsABUPs1mz4= 25 | golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk= 26 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 27 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 28 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 29 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 30 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 31 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 32 | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= 33 | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= 34 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 35 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 36 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 37 | -------------------------------------------------------------------------------- /cmd/examples/greet/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | _ "embed" 7 | 8 | "github.com/avamsi/climate" 9 | ) 10 | 11 | // Exported struct fields are automatically declared as flags -- 12 | // 1. Field names are converted to kebab-case and are used as flag names. 13 | // That said, users can pass flags in camelCase, PascalCase, snake_case or 14 | // SCREAMING_SNAKE_CASE and everything just works (thanks to normalization). 15 | // 2. Field types are used as flag types (string, bool, int, etc.). 16 | // 3. "short" subfield tags (under the "cli" tags) are used as short flag names 17 | // (as is). It's also possible to omit the value, in which case the first 18 | // letter of the field name is used. 19 | // 4. "default" field tags are used as default values (of course, with 20 | // automatic type conversion from raw string to the actual field type). 21 | // 5. Field docs / comments are used* as flag usage strings (as is). 22 | // 6. "required" subfield tags (under the "cli" tags) are used to mark the 23 | // flags as required (i.e., the command is errored out without these flags). 24 | 25 | type greetOptions struct { 26 | Greeting string `cli:"short" default:"Hello"` // greeting to use 27 | Name string `cli:"short=n" default:"World"` // name to greet 28 | Times int `cli:"short,required"` // number of times to greet 29 | } 30 | 31 | // Func is automatically converted to a command -- 32 | // 1. Param names are converted to kebab-case and used* as part of the usage 33 | // string ("command [opts] [args]", for example). 34 | // 2. (Optional) First argument if a struct pointer, is used to declare flags. 35 | // 3. (Optional) Next argument if a string slice is used to collect args. 36 | // 4. Doc is used* as long help string (as is). 37 | // 5. Usage directive is used* to explicitly set the usage string. 38 | 39 | // Greet someone. 40 | func greet(opts *greetOptions) { 41 | for i := 0; i < opts.Times; i++ { 42 | fmt.Printf("%v, %v!\n", opts.Greeting, opts.Name) 43 | } 44 | } 45 | 46 | // * These only work if you generate and pass along "metadata" like below -- 47 | 48 | //go:generate go tool cligen md.cli 49 | //go:embed md.cli 50 | var md []byte 51 | 52 | func main() { 53 | climate.RunAndExit(climate.Func(greet), climate.WithMetadata(md)) 54 | } 55 | -------------------------------------------------------------------------------- /cmd/examples/greet/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/avamsi/climate" 8 | "github.com/avamsi/climate/testing/clitest" 9 | "github.com/google/go-cmp/cmp" 10 | ) 11 | 12 | func TestMain(t *testing.T) { 13 | var ( 14 | greet = clitest.New(climate.Func(greet), climate.WithMetadata(md)) 15 | want = `Greet someone. 16 | 17 | Usage: 18 | greet [opts] 19 | 20 | Flags: 21 | -g, --greeting string (default Hello) greeting to use 22 | -n, --name string (default World) name to greet 23 | -t, --times int number of times to greet 24 | -h, --help help for greet 25 | ` 26 | ) 27 | // TODO(golang/go#36532): replace with t.Context(). 28 | ctx, cancel := context.WithCancel(context.Background()) 29 | defer cancel() 30 | got := greet(ctx, []string{"--help"}).Stdout 31 | if diff := cmp.Diff(want, got); diff != "" { 32 | t.Errorf("want:\n%v", want) 33 | t.Errorf("got:\n%v", got) 34 | t.Errorf("diff(-want +got):\n%v", diff) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /cmd/examples/greet/md.cli: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avamsi/climate/b00c79dda296f7f8ea9e75b33ba82962b5750f93/cmd/examples/greet/md.cli -------------------------------------------------------------------------------- /cmd/examples/jj/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | _ "embed" 9 | 10 | "github.com/avamsi/climate" 11 | "github.com/avamsi/climate/cmd/examples/jj/util" 12 | ) 13 | 14 | // See ../greet/main.go first for some details that are not covered here. 15 | 16 | // Struct is automatically converted to a command -- 17 | // 1. Struct names are converted to lowercase and used as the command name. 18 | // 2. Struct fields are automatically declared as "global" flags. 19 | // 3. Struct methods are automatically converted to subcommands -- 20 | // 1. Method names are converted to lowercase and used as the command name. 21 | // 2. Method docs are truncated and are used* as short help strings. 22 | // 3. Method directives are used* to declare aliases or explicitly set the 23 | // short help strings (//cli:aliases, for example). 24 | // 4. "Sub-structs" are automatically converted to subcommands, recursively. 25 | 26 | // Jujutsu (an experimental VCS). 27 | type jj struct { 28 | Repository string `cli:"short=R"` // `path` to the repo to operate on 29 | IgnoreWorkingCopy bool // don't snapshot / update the working copy 30 | } 31 | 32 | // Create a new repo in the given directory. 33 | func (j *jj) Init(ctx context.Context, dir *string) { 34 | fmt.Println("init", ctx, j, dir) 35 | } 36 | 37 | type squashOptions struct { 38 | Revision string `cli:"short" default:"@"` 39 | Interactive bool `cli:"short"` // interactively choose which parts to squash 40 | } 41 | 42 | // Move changes from a revision into its parent. 43 | // 44 | // After moving the changes into the parent, the child revision will have the 45 | // same content state as before. If that means that the change is now empty 46 | // compared to its parent, it will be abandoned. Without `--interactive`, the 47 | // child change will always be empty. 48 | // 49 | //cli:aliases am, amend 50 | func (j *jj) Squash(opts *squashOptions, paths [5]string) { 51 | fmt.Println("squash", j, opts, paths) 52 | } 53 | 54 | // Commands for working with the underlying Git repo. 55 | type git struct { 56 | J *jj 57 | } 58 | 59 | // Manage Git remotes. 60 | func (g *git) Remote() error { 61 | return errors.New("not implemented") 62 | } 63 | 64 | // Update the underlying Git repo with changes made in the repo. 65 | func (g *git) Export() { 66 | fmt.Println("export", g.J) 67 | } 68 | 69 | //go:generate go tool cligen md.cli 70 | //go:embed md.cli 71 | var md []byte 72 | 73 | func main() { 74 | // Note the recursive struct embedding below, which lets us create "deep" 75 | // subcommands like this (indentation implies subcommand) -- 76 | // 77 | // jj 78 | // init 79 | // squash 80 | // git 81 | // remote 82 | // export 83 | // util 84 | // completion 85 | p := climate.Struct[jj](climate.Struct[git](), climate.Struct[util.Util]()) 86 | climate.RunAndExit(p, climate.WithMetadata(md)) 87 | } 88 | -------------------------------------------------------------------------------- /cmd/examples/jj/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/avamsi/climate" 8 | "github.com/avamsi/climate/cmd/examples/jj/util" 9 | "github.com/avamsi/climate/testing/clitest" 10 | "github.com/google/go-cmp/cmp" 11 | ) 12 | 13 | func TestMain(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | args []string 17 | want clitest.Result 18 | }{ 19 | { 20 | name: "jj", 21 | args: nil, 22 | want: clitest.Result{ 23 | Stdout: `Jujutsu (an experimental VCS). 24 | 25 | Usage: 26 | jj [command] 27 | 28 | Available Commands: 29 | completion Generate the autocompletion script for the specified shell 30 | git Commands for working with the underlying Git repo 31 | help Help about any command 32 | init Create a new repo in the given directory 33 | squash Move changes from a revision into its parent 34 | util Infrequently used commands such as for generating shell completions 35 | 36 | Flags: 37 | -R, --repository path path to the repo to operate on 38 | --ignore-working-copy don't snapshot / update the working copy 39 | -h, --help help for jj 40 | 41 | Use "jj [command] --help" for more information about a command. 42 | `, 43 | }, 44 | }, 45 | { 46 | name: "jj--help", 47 | args: []string{"--help"}, 48 | want: clitest.Result{ 49 | Stdout: `Jujutsu (an experimental VCS). 50 | 51 | Usage: 52 | jj [command] 53 | 54 | Available Commands: 55 | completion Generate the autocompletion script for the specified shell 56 | git Commands for working with the underlying Git repo 57 | help Help about any command 58 | init Create a new repo in the given directory 59 | squash Move changes from a revision into its parent 60 | util Infrequently used commands such as for generating shell completions 61 | 62 | Flags: 63 | -R, --repository path path to the repo to operate on 64 | --ignore-working-copy don't snapshot / update the working copy 65 | -h, --help help for jj 66 | 67 | Use "jj [command] --help" for more information about a command. 68 | `, 69 | }, 70 | }, 71 | { 72 | name: "jj-git--help", 73 | args: []string{"git", "--help"}, 74 | want: clitest.Result{ 75 | Stdout: `Commands for working with the underlying Git repo. 76 | 77 | Usage: 78 | jj git [command] 79 | 80 | Available Commands: 81 | export Update the underlying Git repo with changes made in the repo 82 | remote Manage Git remotes 83 | 84 | Flags: 85 | -h, --help help for git 86 | 87 | Global Flags: 88 | --ignore-working-copy don't snapshot / update the working copy 89 | -R, --repository path path to the repo to operate on 90 | 91 | Use "jj git [command] --help" for more information about a command. 92 | `, 93 | }, 94 | }, 95 | { 96 | name: "jj-git-exp", 97 | args: []string{"git", "exp"}, 98 | want: clitest.Result{ 99 | Stderr: `Error: unknown command "exp" for "jj git" 100 | 101 | Did you mean this? 102 | export 103 | 104 | Run 'jj git --help' for usage. 105 | `, 106 | Code: 1, 107 | }, 108 | }, 109 | { 110 | name: "jj-git-x", 111 | args: []string{"git", "x"}, 112 | want: clitest.Result{ 113 | Stderr: `Error: unknown command "x" for "jj git" 114 | Run 'jj git --help' for usage. 115 | `, 116 | Code: 1, 117 | }, 118 | }, 119 | } 120 | var ( 121 | p = climate.Struct[jj](climate.Struct[git](), climate.Struct[util.Util]()) 122 | jj = clitest.New(p, climate.WithMetadata(md)) 123 | ) 124 | for _, test := range tests { 125 | t.Run(test.name, func(t *testing.T) { 126 | // TODO(golang/go#36532): replace with t.Context(). 127 | ctx, cancel := context.WithCancel(context.Background()) 128 | defer cancel() 129 | got := jj(ctx, test.args) 130 | if diff := cmp.Diff(test.want, got); diff != "" { 131 | t.Errorf("want:\n%v", test.want) 132 | t.Errorf("got:\n%v", got) 133 | t.Errorf("diff(-want +got):\n%v", diff) 134 | } 135 | }) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /cmd/examples/jj/md.cli: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avamsi/climate/b00c79dda296f7f8ea9e75b33ba82962b5750f93/cmd/examples/jj/md.cli -------------------------------------------------------------------------------- /cmd/examples/jj/util/completion.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "fmt" 4 | 5 | type completionOptions struct { 6 | Bash, Zsh, Fish bool // print a completion script for the given shell 7 | } 8 | 9 | // Print a command-line-completion script. 10 | func (u *Util) Completion(opts *completionOptions) { 11 | fmt.Println("completion", u, opts) 12 | } 13 | -------------------------------------------------------------------------------- /cmd/examples/jj/util/util.go: -------------------------------------------------------------------------------- 1 | // util is just a proof of concept to show climate works across directories / 2 | // packages / files. There's otherwise no reason to have a util package. 3 | package util 4 | 5 | // Infrequently used commands such as for generating shell completions. 6 | type Util struct{} 7 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | package climate 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "reflect" 8 | "runtime/debug" 9 | "strconv" 10 | "strings" 11 | "text/tabwriter" 12 | "time" 13 | 14 | "github.com/avamsi/ergo" 15 | "github.com/avamsi/ergo/assert" 16 | "github.com/spf13/cobra" 17 | "github.com/spf13/pflag" 18 | "golang.org/x/mod/module" 19 | 20 | "github.com/avamsi/climate/internal" 21 | ) 22 | 23 | type command struct { 24 | delegate cobra.Command 25 | } 26 | 27 | func newCommand(name string, md *internal.Metadata, params []internal.ParamType) *command { 28 | delegate := cobra.Command{ 29 | Use: md.Usage(name, params), 30 | Aliases: md.Aliases(), 31 | Short: md.Short(), 32 | Long: md.Long(), 33 | } 34 | delegate.Flags().SortFlags = false 35 | delegate.PersistentFlags().SortFlags = false 36 | if md != nil { 37 | delegate.DisableFlagsInUseLine = true 38 | } 39 | return &command{delegate} 40 | } 41 | 42 | func (cmd *command) addCommand(sub *command) { 43 | cmd.delegate.AddCommand(&sub.delegate) 44 | } 45 | 46 | func version() string { 47 | info, ok := debug.ReadBuildInfo() 48 | if !ok { 49 | return "" 50 | } 51 | if info.Main.Version != "(devel)" { 52 | return info.Main.Version 53 | } 54 | var ( 55 | rev string 56 | t time.Time 57 | modified bool 58 | ) 59 | for _, kv := range info.Settings { 60 | switch kv.Key { 61 | case "vcs.revision": 62 | rev = kv.Value[:12] 63 | case "vcs.time": 64 | t = assert.Ok(time.Parse(time.RFC3339Nano, kv.Value)) 65 | case "vcs.modified": 66 | modified = assert.Ok(strconv.ParseBool(kv.Value)) 67 | } 68 | } 69 | if t.IsZero() || rev == "" { 70 | return "" 71 | } 72 | if modified { 73 | rev += "*" 74 | } 75 | return module.PseudoVersion("", "", t, rev) 76 | } 77 | 78 | func flagUsages(fset *pflag.FlagSet) string { 79 | var ( 80 | b strings.Builder 81 | t = tabwriter.NewWriter(&b, 0, 0, 0, ' ', 0) 82 | ) 83 | fset.VisitAll(func(f *pflag.Flag) { 84 | var short string 85 | if f.Shorthand != "" { 86 | short = fmt.Sprintf("-%v, ", f.Shorthand) 87 | } 88 | var ( 89 | qtype, usage = pflag.UnquoteUsage(f) 90 | value string 91 | ) 92 | if qtype != "" { 93 | qtype += " " 94 | } 95 | if _, ok := f.Annotations[nonZeroDefault]; ok { 96 | value = fmt.Sprintf("(default %v) ", f.DefValue) 97 | } 98 | fmt.Fprintf(t, " %v\t--%v\t %v\t%v \t%v\n", short, f.Name, qtype, value, usage) 99 | }) 100 | t.Flush() 101 | return b.String() 102 | } 103 | 104 | func versionCommand(name, v string) *cobra.Command { 105 | help := fmt.Sprintf("Display %v's version information", name) 106 | return &cobra.Command{ 107 | Use: "version", 108 | Short: help, 109 | Long: help + ".", 110 | Args: cobra.ExactArgs(0), 111 | Run: func(cmd *cobra.Command, _ []string) { 112 | cmd.Println(v) 113 | }, 114 | } 115 | } 116 | 117 | func (cmd *command) run(ctx context.Context) error { 118 | normalize := func(_ *pflag.FlagSet, name string) pflag.NormalizedName { 119 | return pflag.NormalizedName(internal.NormalizeToKebabCase(name)) 120 | } 121 | // While we prefer kebab-case for flags, we do support other well-formed, 122 | // cases through normalization (but only kebab-case shows up in --help). 123 | cmd.delegate.SetGlobalNormalizationFunc(normalize) 124 | if v := version(); v != "" { 125 | // Add the version subcommand only when the root command already has 126 | // subcommands (similar to how Cobra does it for help / completion). 127 | if cmd.delegate.HasSubCommands() { 128 | cmd.delegate.AddCommand(versionCommand(cmd.delegate.Name(), v)) 129 | } 130 | cmd.delegate.Version = v 131 | } 132 | // Align the flag usages as a table (pflag's FlagUsages already does this to 133 | // some extent but doesn't align types and default values). 134 | cobra.AddTemplateFunc("flagUsages", flagUsages) 135 | t := cmd.delegate.UsageTemplate() 136 | t = strings.ReplaceAll(t, ".FlagUsages", " | flagUsages") 137 | cmd.delegate.SetUsageTemplate(t) 138 | return cmd.delegate.ExecuteContext(ctx) 139 | } 140 | 141 | type funcCommandBuilder struct { 142 | name string 143 | reflection 144 | md *internal.Metadata 145 | } 146 | 147 | type runSignature struct { 148 | numIn int 149 | inCtx bool 150 | inOpts *reflect.Value 151 | inArgs internal.ParamType 152 | outErr bool 153 | } 154 | 155 | func (fcb *funcCommandBuilder) run(sig *runSignature) func(*cobra.Command, []string) error { 156 | return func(cmd *cobra.Command, args []string) error { 157 | var in []reflect.Value 158 | if sig.inCtx { 159 | in = append(in, reflect.ValueOf(cmd.Context())) 160 | } 161 | if sig.inOpts != nil { 162 | in = append(in, *sig.inOpts) 163 | } 164 | switch sig.inArgs { 165 | case internal.RequiredParam: 166 | in = append(in, reflect.ValueOf(args[0])) 167 | case internal.OptionalParam: 168 | var ptr *string 169 | if len(args) == 1 { 170 | ptr = &args[0] 171 | } 172 | in = append(in, reflect.ValueOf(ptr)) 173 | case internal.FixedLengthParam: 174 | arr := reflect.New(fcb.t().In(sig.numIn - 1)).Elem() 175 | reflect.Copy(arr, reflect.ValueOf(args)) 176 | in = append(in, arr) 177 | case internal.ArbitraryLengthParam: 178 | in = append(in, reflect.ValueOf(args)) 179 | } 180 | out := fcb.v().Call(in) 181 | if sig.outErr { 182 | if out[0].IsNil() { // if _no_ error 183 | return nil 184 | } 185 | err := out[0].Interface().(error) 186 | if uerr := new(usageError); errors.As(err, &uerr) { 187 | // Let Cobra print both the error and usage information. 188 | return err 189 | } 190 | // err is not a usage error (anymore), so set SilenceUsage to true 191 | // to prevent Cobra from printing usage information. 192 | cmd.SilenceUsage = true 193 | // exitError may just be used to exit with a particular exit code 194 | // and not necessarily have anything to print. 195 | if eerr := new(exitError); errors.As(err, &eerr) { 196 | cmd.SilenceErrors = len(eerr.errs) == 0 197 | } 198 | return err 199 | } 200 | return nil 201 | } 202 | } 203 | 204 | func (fcb *funcCommandBuilder) build() *command { 205 | var ( 206 | cmd = newCommand(fcb.name, fcb.md, internal.ParamTypes(fcb.t())) 207 | i = 0 208 | n = fcb.t().NumIn() 209 | inCtx bool 210 | inOpts *reflect.Value 211 | inArgs = internal.NoParam 212 | ) 213 | // We support the signatures (excuse the partial [optional] notation) 214 | // func([ctx context.Context], [opts *T], [args []string]) [(err error)], 215 | // which is to say all of ctx, opts, args and error are optional. If opts is 216 | // present, T must be a struct (and we use its fields as flags). 217 | // TODO: maybe support variadic, array and normal string arguments too. 218 | if i < n && typeIsContext(fcb.t().In(i)) { 219 | i++ 220 | inCtx = true 221 | } 222 | if i < n { 223 | if t := fcb.t().In(i); typeIsStructPointer(t) { 224 | var ( 225 | r = reflection{ptr: &reflection{ot: t}} 226 | opts = &options{ 227 | r, 228 | nil, // no parent 229 | cmd.delegate.Flags(), 230 | fcb.md.LookupType(t.Elem()), 231 | } 232 | ) 233 | opts.declare() 234 | i++ 235 | inOpts = r.ptr.v() 236 | } 237 | } 238 | if i < n { 239 | switch t := fcb.t().In(i); t.Kind() { 240 | case reflect.String: 241 | i++ 242 | inArgs = internal.RequiredParam 243 | cmd.delegate.Args = cobra.ExactArgs(1) 244 | case reflect.Pointer, reflect.Array, reflect.Slice: 245 | if t.Elem().Kind() != reflect.String { 246 | break 247 | } 248 | i++ 249 | switch t.Kind() { 250 | case reflect.Pointer: 251 | inArgs = internal.OptionalParam 252 | cmd.delegate.Args = cobra.MaximumNArgs(1) 253 | case reflect.Array: 254 | inArgs = internal.FixedLengthParam 255 | cmd.delegate.Args = cobra.ExactArgs(t.Len()) 256 | case reflect.Slice: 257 | inArgs = internal.ArbitraryLengthParam 258 | } 259 | } 260 | } else { 261 | cmd.delegate.Args = cobra.ExactArgs(0) 262 | } 263 | outErr := fcb.t().NumOut() == 1 && typeIsError(fcb.t().Out(0)) 264 | if i != n || fcb.t().IsVariadic() || (fcb.t().NumOut() != 0 && !outErr) { 265 | ergo.Panicf("not func([context.Context], [*struct], [[]string]) [error]: %v", fcb.t()) 266 | } 267 | cmd.delegate.RunE = fcb.run(&runSignature{n, inCtx, inOpts, inArgs, outErr}) 268 | return cmd 269 | } 270 | 271 | type structCommandBuilder struct { 272 | reflection 273 | parent *reflection 274 | md *internal.Metadata 275 | } 276 | 277 | func validateNoArgs(cmd *cobra.Command, args []string) error { 278 | cmd.SilenceUsage = true 279 | err := cobra.NoArgs(cmd, args) 280 | if err == nil { // if _no_ error 281 | return cmd.Help() 282 | } 283 | var b strings.Builder 284 | fmt.Fprintf(&b, "%v", err) 285 | if suggestions := cmd.SuggestionsFor(args[0]); len(suggestions) > 0 { 286 | b.WriteString("\n\nDid you mean this?\n") 287 | for _, s := range suggestions { 288 | fmt.Fprintf(&b, "\t%v\n", s) 289 | } 290 | } 291 | fmt.Fprintf(&b, "\nRun '%v --help' for usage.", cmd.CommandPath()) 292 | return errors.New(b.String()) 293 | } 294 | 295 | func (scb *structCommandBuilder) build() *command { 296 | var ( 297 | cmd = newCommand(scb.t().Name(), scb.md, nil) 298 | opts = &options{ 299 | scb.reflection, 300 | scb.parent, 301 | cmd.delegate.PersistentFlags(), 302 | scb.md, 303 | } 304 | ) 305 | opts.declare() 306 | for i := 0; i < scb.ptr.v().NumMethod(); i++ { 307 | var ( 308 | m = scb.ptr.t().Method(i) 309 | v = scb.ptr.v().Method(i) 310 | fcb = &funcCommandBuilder{ 311 | m.Name, 312 | reflection{ov: &v}, 313 | scb.md.Child(m.Name), 314 | } 315 | ) 316 | // TODO: maybe provide an option to default to a subcommand. 317 | cmd.addCommand(fcb.build()) 318 | } 319 | // This should ideally be as simple as setting cobra.NoArgs, but for 320 | // whatever reason, Cobra doesn't really honor that for subcommands 321 | // (see spf13/cobra#706, spf13/cobra#981) -- so, we do it ourselves. 322 | cmd.delegate.RunE = validateNoArgs 323 | // We only make this command "runnable" to validate NoArgs, so hack the 324 | // usage template and pretend it's not really runnable. 325 | // Note: Cobra subcommands will inherit any custom attributes set on the 326 | // parent command, so we need to be careful here to only apply the changes 327 | // to the parent command and not any subcommands. 328 | defaultHelpFunc := cmd.delegate.HelpFunc() 329 | cmd.delegate.SetHelpFunc(func(c *cobra.Command, _ []string) { 330 | if c == &cmd.delegate { 331 | t := cmd.delegate.UsageTemplate() 332 | t = strings.ReplaceAll(t, "{{if .Runnable}}", "{{if false}}") 333 | c.SetUsageTemplate(t) 334 | } 335 | defaultHelpFunc(c, nil) 336 | }) 337 | return cmd 338 | } 339 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package climate 2 | 3 | import "errors" 4 | 5 | type usageError struct { 6 | error 7 | } 8 | 9 | // ErrUsage returns the given error wrapped in a usageError or nil otherwise. 10 | // usageError is used to indicate there's something wrong with the user input 11 | // and that the usage information should be printed along with the error. 12 | func ErrUsage(err error) *usageError { 13 | if err != nil { 14 | return &usageError{err} 15 | } 16 | return nil 17 | } 18 | 19 | func (uerr *usageError) Unwrap() error { 20 | return uerr.error 21 | } 22 | 23 | type exitError struct { 24 | code int 25 | errs []error 26 | } 27 | 28 | // ErrExit returns an exitError with the given exit code and errors. 29 | // exitError is used to indicate that the CLI should exit with the given exit 30 | // code (as returned by Run and respected by RunAndExit). 31 | func ErrExit(code int, errs ...error) *exitError { 32 | return &exitError{code, errs} 33 | } 34 | 35 | func (eerr *exitError) Error() string { 36 | // We panic here if errs is empty, but this is somewhat intentional as we 37 | // should only use the error for exit code purposes in that case. 38 | return errors.Join(eerr.errs...).Error() 39 | } 40 | 41 | func (eerr *exitError) Unwrap() []error { 42 | return eerr.errs 43 | } 44 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/avamsi/climate 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/avamsi/ergo v0.0.0-20250210165756-c19aaccc8346 7 | github.com/google/go-cmp v0.7.0 8 | github.com/sanity-io/litter v1.5.8 9 | github.com/spf13/cobra v1.9.1 10 | github.com/spf13/pflag v1.0.6 11 | golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa 12 | golang.org/x/mod v0.24.0 13 | golang.org/x/text v0.25.0 14 | ) 15 | 16 | require ( 17 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 18 | github.com/stretchr/testify v1.8.4 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/avamsi/ergo v0.0.0-20250210165756-c19aaccc8346 h1:IffBmxusWG5vSyCqVdOWXBbuEhy3gKGX2M0Nr8YRkUc= 2 | github.com/avamsi/ergo v0.0.0-20250210165756-c19aaccc8346/go.mod h1:woiGOfYKn9S9DM0VmN2pLci6qmmHIiIcrAvjFi018Ys= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 4 | github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 8 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 9 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 10 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 11 | github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 12 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 13 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 15 | github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg= 16 | github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= 17 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 18 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 19 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 20 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 21 | github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 22 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 23 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 24 | golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrCPyJZ+eyRsABUPs1mz4= 25 | golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk= 26 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 27 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 28 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 29 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 30 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 31 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 32 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 33 | -------------------------------------------------------------------------------- /internal/metadata.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "bytes" 5 | "encoding/gob" 6 | "go/ast" 7 | "reflect" 8 | "strings" 9 | "unicode" 10 | 11 | "github.com/avamsi/ergo" 12 | "github.com/avamsi/ergo/assert" 13 | "github.com/sanity-io/litter" 14 | ) 15 | 16 | // Note: it's important that all fields of RawMetadata be exported, otherwise 17 | // gob won't be able to encode / decode them correctly. 18 | type RawMetadata struct { 19 | Doc string 20 | Directives map[string]string 21 | Comment string 22 | Params []string 23 | Children map[string]*RawMetadata 24 | } 25 | 26 | func DecodeAsRawMetadata(b []byte) *RawMetadata { 27 | var rmd RawMetadata 28 | assert.Nil(gob.NewDecoder(bytes.NewReader(b)).Decode(&rmd)) 29 | return &rmd 30 | } 31 | 32 | const directivePrefix = "//cli:" 33 | 34 | func (rmd *RawMetadata) SetDoc(doc *ast.CommentGroup) { 35 | if doc == nil { 36 | return 37 | } 38 | rmd.Doc = strings.TrimSpace(doc.Text()) 39 | rmd.Directives = map[string]string{} 40 | for _, comment := range doc.List { 41 | if !strings.HasPrefix(comment.Text, directivePrefix) { 42 | continue 43 | } 44 | d, value, _ := strings.Cut(comment.Text, " ") 45 | d = strings.TrimPrefix(d, directivePrefix) 46 | if _, ok := rmd.Directives[d]; ok { 47 | ergo.Panicf("more than one %v directive: %v", d, litter.Sdump(doc)) 48 | } 49 | rmd.Directives[d] = strings.TrimSpace(value) 50 | } 51 | } 52 | 53 | func (rmd *RawMetadata) SetComment(comment *ast.CommentGroup) { 54 | rmd.Comment = strings.TrimSpace(comment.Text()) 55 | } 56 | 57 | func (rmd *RawMetadata) Child(name string) *RawMetadata { 58 | if rmd.Children == nil { 59 | rmd.Children = map[string]*RawMetadata{} 60 | } 61 | child, ok := rmd.Children[name] 62 | if !ok { 63 | child = &RawMetadata{} 64 | rmd.Children[name] = child 65 | } 66 | return child 67 | } 68 | 69 | func (rmd *RawMetadata) Encode() []byte { 70 | var b bytes.Buffer 71 | assert.Nil(gob.NewEncoder(&b).Encode(rmd)) 72 | return b.Bytes() 73 | } 74 | 75 | type Metadata struct { 76 | root *Metadata 77 | raw *RawMetadata 78 | children map[string]*Metadata 79 | } 80 | 81 | func DecodeAsMetadata(b []byte) *Metadata { 82 | md := &Metadata{raw: DecodeAsRawMetadata(b)} 83 | md.root = md 84 | return md 85 | } 86 | 87 | func (md *Metadata) Lookup(pkgPath, name string) *Metadata { 88 | if md == nil { 89 | return nil 90 | } 91 | return md.root.Child(pkgPath).Child(name) 92 | } 93 | 94 | func (md *Metadata) LookupType(t reflect.Type) *Metadata { 95 | return md.Lookup(t.PkgPath(), t.Name()) 96 | } 97 | 98 | func (md *Metadata) Aliases() []string { 99 | if md == nil { 100 | return nil 101 | } 102 | var ( 103 | tmp = strings.Split(md.raw.Directives["aliases"], ",") 104 | aliases []string 105 | ) 106 | for _, alias := range tmp { 107 | if alias := strings.TrimSpace(alias); alias != "" { 108 | aliases = append(aliases, alias) 109 | } 110 | } 111 | return aliases 112 | } 113 | 114 | func (md *Metadata) Long() string { 115 | if md == nil { 116 | return "" 117 | } 118 | return md.raw.Doc 119 | } 120 | 121 | func (md *Metadata) Short() string { 122 | if md == nil { 123 | return "" 124 | } 125 | if short, ok := md.raw.Directives["short"]; ok { 126 | return short 127 | } 128 | if md.raw.Comment != "" { 129 | return md.raw.Comment 130 | } 131 | // Auto generate a short description from the long description. 132 | var ( 133 | long = md.Long() 134 | i = strings.Index(long, "\n\n") 135 | ) 136 | if i != -1 { 137 | long = long[:i] 138 | } 139 | long = strings.Join(strings.Fields(long), " ") 140 | if long == "" { 141 | return "" 142 | } 143 | rs := []rune(long) 144 | rs[0] = unicode.ToUpper(rs[0]) 145 | if l := len(rs); l > 80 { 146 | rs = append(rs[:77], []rune("...")...) 147 | } else if rs[l-1] == '.' && !strings.HasSuffix(long, "..") { 148 | // Clip the period at the end, by (Cobra's) convention. 149 | rs = rs[:l-1] 150 | } 151 | return string(rs) 152 | } 153 | 154 | func (md *Metadata) Usage(name string, args []ParamType) string { 155 | if md == nil { 156 | return strings.ToLower(name) 157 | } 158 | if usage, ok := md.raw.Directives["usage"]; ok { 159 | return usage 160 | } 161 | return strings.ToLower(name) + ParamsUsage(md.raw.Params, args) 162 | } 163 | 164 | func (md *Metadata) Child(name string) *Metadata { 165 | if md == nil { 166 | return nil 167 | } 168 | if md.children == nil { 169 | md.children = map[string]*Metadata{} 170 | } 171 | child, ok := md.children[name] 172 | if !ok { 173 | child = &Metadata{root: md.root, raw: md.raw.Child(name)} 174 | md.children[name] = child 175 | } 176 | return child 177 | } 178 | -------------------------------------------------------------------------------- /internal/param.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | ) 8 | 9 | type ParamType int 10 | 11 | const ( 12 | NoParam ParamType = iota 13 | RequiredParam 14 | OptionalParam 15 | FixedLengthParam 16 | ArbitraryLengthParam 17 | ) 18 | 19 | func ParamTypes(f reflect.Type) []ParamType { 20 | var types []ParamType 21 | for i := 0; i < f.NumIn(); i++ { 22 | switch f.In(i).Kind() { 23 | case reflect.Interface: 24 | // Only context.Context for now, which counts as NoParam for CLI. 25 | types = append(types, NoParam) 26 | case reflect.String: 27 | types = append(types, RequiredParam) 28 | case reflect.Pointer: 29 | types = append(types, OptionalParam) 30 | case reflect.Array: 31 | types = append(types, FixedLengthParam) 32 | case reflect.Slice: 33 | types = append(types, ArbitraryLengthParam) 34 | } 35 | } 36 | return types 37 | } 38 | 39 | func ParamsUsage(names []string, types []ParamType) string { 40 | var usage strings.Builder 41 | for i, name := range names { 42 | name = NormalizeToKebabCase(name) 43 | switch types[i] { 44 | case RequiredParam: 45 | usage.WriteString(fmt.Sprintf(" <%v>", name)) 46 | case OptionalParam: 47 | usage.WriteString(fmt.Sprintf(" [%v]", name)) 48 | case FixedLengthParam: 49 | usage.WriteString(fmt.Sprintf(" <%v...>", name)) 50 | case ArbitraryLengthParam: 51 | usage.WriteString(fmt.Sprintf(" [%v...]", name)) 52 | } 53 | } 54 | return usage.String() 55 | } 56 | -------------------------------------------------------------------------------- /internal/run.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import "context" 4 | 5 | type Plan interface { 6 | Execute(context.Context, *Metadata) error 7 | } 8 | 9 | type RunOptions struct { 10 | Metadata *[]byte 11 | } 12 | -------------------------------------------------------------------------------- /internal/strings.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "unicode" 7 | 8 | "github.com/avamsi/ergo/assert" 9 | "golang.org/x/text/runes" 10 | "golang.org/x/text/transform" 11 | "golang.org/x/text/unicode/norm" 12 | ) 13 | 14 | var ( 15 | anyUpperishLower = regexp.MustCompile("(.)([A-Z][0-9]*)([a-z])") 16 | lowerishUpper = regexp.MustCompile("([a-z][0-9]*)([A-Z])") 17 | invalids = regexp.MustCompile("[^a-zA-Z0-9]+") 18 | ) 19 | 20 | // NormalizeToKebabCase normalizes the input string to ASCII kebab-case. 21 | // It tries to convert non-ASCII runes in the input string to ASCII by 22 | // decomposing and then dropping all non-ASCII runes (and so is lossy). 23 | // It supports camelCase, PascalCase, snake_case, and SCREAMING_SNAKE_CASE -- 24 | // anything else (including digits mixed in) working is a happy accident. 25 | func NormalizeToKebabCase(s string) string { 26 | // Decompose and remove all non-spacing marks. 27 | t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn))) 28 | s, _, err := transform.String(t, s) 29 | assert.Nil(err) 30 | s = anyUpperishLower.ReplaceAllString(s, "${1}-${2}${3}") 31 | s = lowerishUpper.ReplaceAllString(s, "${1}-${2}") 32 | s = invalids.ReplaceAllString(s, "-") 33 | return strings.ToLower(strings.Trim(s, "-")) 34 | } 35 | -------------------------------------------------------------------------------- /internal/strings_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/avamsi/climate/internal" 7 | ) 8 | 9 | func TestNormalizeToKebabCase(t *testing.T) { 10 | tests := []struct { 11 | in, want string 12 | }{ 13 | { 14 | in: "", 15 | want: "", 16 | }, 17 | { 18 | in: "quick", 19 | want: "quick", 20 | }, 21 | { 22 | in: "quick-brown-fox", 23 | want: "quick-brown-fox", 24 | }, 25 | { 26 | in: "quickBrownFox", 27 | want: "quick-brown-fox", 28 | }, 29 | { 30 | in: "QuickBrownFox", 31 | want: "quick-brown-fox", 32 | }, 33 | { 34 | in: "quick_brown_fox", 35 | want: "quick-brown-fox", 36 | }, 37 | { 38 | in: "QUICK_BROWN_FOX", 39 | want: "quick-brown-fox", 40 | }, 41 | { 42 | in: "qu42ck", 43 | want: "qu42ck", 44 | }, 45 | { 46 | in: "Quick42Brown", 47 | want: "quick42-brown", 48 | }, 49 | { 50 | in: "quickBrownFOX42", 51 | want: "quick-brown-fox42", 52 | }, 53 | { 54 | in: "q̀úîc̃k̄", 55 | want: "quick", 56 | }, 57 | { 58 | in: "--quick", 59 | want: "quick", 60 | }, 61 | { 62 | in: "q̀úîβc̃k̄BrownFOX_JUMPSOver-the", 63 | want: "qui-ck-brown-fox-jumps-over-the", 64 | }, 65 | } 66 | for _, test := range tests { 67 | if got := internal.NormalizeToKebabCase(test.in); got != test.want { 68 | t.Errorf("normalizeToKebabCase(%v) = %v, want %v", test.in, got, test.want) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package climate 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "unsafe" 7 | 8 | "github.com/avamsi/ergo" 9 | "github.com/avamsi/ergo/assert" 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/pflag" 12 | "golang.org/x/exp/utf8string" 13 | 14 | "github.com/avamsi/climate/internal" 15 | ) 16 | 17 | type flagTypeVarP[T any] func(*T, string, string, T, string) 18 | 19 | type tags struct { 20 | m map[string]string 21 | } 22 | 23 | func newTags(st reflect.StructTag) tags { 24 | m := make(map[string]string) 25 | if v, ok := st.Lookup("default"); ok { 26 | m["default"] = v 27 | } 28 | for _, kv := range strings.Split(st.Get("cli"), ",") { 29 | k, v, _ := strings.Cut(kv, "=") 30 | m[k] = v 31 | } 32 | return tags{m} 33 | } 34 | 35 | func (ts tags) shorthand() (string, bool) { 36 | v, ok := ts.m["short"] 37 | return v, ok 38 | } 39 | 40 | func (ts tags) defaultValue() (string, bool) { 41 | v, ok := ts.m["default"] 42 | return v, ok 43 | } 44 | 45 | func (ts tags) required() bool { 46 | _, ok := ts.m["required"] 47 | return ok 48 | } 49 | 50 | type option struct { 51 | fset *pflag.FlagSet 52 | t reflect.Type 53 | p unsafe.Pointer 54 | name string 55 | tags 56 | usage string 57 | } 58 | 59 | const nonZeroDefault = "climate_annotation_non_zero_default" 60 | 61 | func declareOption[T any](flagVarP flagTypeVarP[T], opt *option, typer typeParser[T]) { 62 | var ( 63 | p = (*T)(opt.p) 64 | value T 65 | ) 66 | if v, ok := opt.defaultValue(); ok { 67 | value = typer(v) 68 | defer func() { 69 | assert.Nil(opt.fset.SetAnnotation(opt.name, nonZeroDefault, nil)) 70 | }() 71 | } 72 | assert.Truef(utf8string.NewString(opt.name).IsASCII(), "not ASCII: %v", opt.name) 73 | var shorthand string 74 | if v, ok := opt.shorthand(); ok { 75 | if v == "" { 76 | v = strings.ToLower(opt.name[:1]) 77 | } 78 | shorthand = v 79 | } 80 | flagVarP(p, opt.name, shorthand, value, opt.usage) 81 | if opt.required() { 82 | assert.Nil(cobra.MarkFlagRequired(opt.fset, opt.name)) 83 | } 84 | } 85 | 86 | func (opt *option) declare() bool { 87 | switch k := opt.t.Kind(); k { 88 | case reflect.Bool: 89 | declareOption( 90 | opt.fset.BoolVarP, 91 | opt, 92 | parseBool, 93 | ) 94 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: 95 | declareOption( 96 | opt.fset.Int64VarP, 97 | opt, 98 | parseInt64, 99 | ) 100 | case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: 101 | declareOption( 102 | opt.fset.Uint64VarP, 103 | opt, 104 | parseUint64, 105 | ) 106 | case reflect.Float32, reflect.Float64: 107 | declareOption( 108 | opt.fset.Float64VarP, 109 | opt, 110 | parseFloat64, 111 | ) 112 | case reflect.String: 113 | declareOption( 114 | opt.fset.StringVarP, 115 | opt, 116 | parseString, 117 | ) 118 | case reflect.Slice: 119 | switch e := opt.t.Elem(); e.Kind() { 120 | case reflect.Bool: 121 | declareOption( 122 | opt.fset.BoolSliceVarP, 123 | opt, 124 | sliceParser(parseBool), 125 | ) 126 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: 127 | declareOption( 128 | opt.fset.Int64SliceVarP, 129 | opt, 130 | sliceParser(parseInt64), 131 | ) 132 | case reflect.Float32, reflect.Float64: 133 | declareOption( 134 | opt.fset.Float64SliceVarP, 135 | opt, 136 | sliceParser(parseFloat64), 137 | ) 138 | case reflect.String: 139 | declareOption( 140 | opt.fset.StringSliceVarP, 141 | opt, 142 | sliceParser(parseString), 143 | ) 144 | default: 145 | ergo.Panicf("not []bool | []Signed | []Float | []string: %v", e) 146 | } 147 | default: 148 | if typeIsStructPointer(opt.t) { 149 | return false 150 | } 151 | ergo.Panicf("not bool | Integer | Float | string | []T: %v", opt.t) 152 | } 153 | return true 154 | } 155 | 156 | type options struct { 157 | reflection 158 | parent *reflection 159 | fset *pflag.FlagSet 160 | md *internal.Metadata 161 | } 162 | 163 | func (opts *options) declare() { 164 | parentSet := (opts.parent == nil) 165 | for i := 0; i < opts.t().NumField(); i++ { 166 | var ( 167 | f = opts.t().Field(i) 168 | md = opts.md.Child(f.Name) 169 | ) 170 | // Long() returns the "Doc" part of the field and Short() returns the 171 | // "Comment" part. Other Metadata is neither collected, nor used. 172 | usage := md.Long() 173 | if usage == "" { 174 | usage = md.Short() 175 | } 176 | var ( 177 | v = opts.v().Field(i) 178 | opt = option{ 179 | fset: opts.fset, 180 | t: f.Type, 181 | p: v.Addr().UnsafePointer(), 182 | name: f.Name, 183 | tags: newTags(f.Tag), 184 | usage: usage, 185 | } 186 | ) 187 | if !opt.declare() { 188 | if opts.parent == nil { 189 | ergo.Panicf("not bool | Integer | Float | string | []T: %v", f.Type) 190 | } 191 | if f.Type != opts.parent.ptr.t() { 192 | ergo.Panicf( 193 | "not bool | Integer | Float | string | []T | %v: %v", 194 | opts.parent.t(), f.Type) 195 | } 196 | if parentSet { 197 | ergo.Panicf("more than one parent: %v", f.Type) 198 | } 199 | v.Set(*opts.parent.ptr.v()) 200 | } 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /plan.go: -------------------------------------------------------------------------------- 1 | package climate 2 | 3 | import ( 4 | "context" 5 | "runtime" 6 | "strings" 7 | 8 | "github.com/avamsi/climate/internal" 9 | ) 10 | 11 | type funcPlan struct { 12 | reflection 13 | } 14 | 15 | func (fp *funcPlan) Execute(ctx context.Context, md *internal.Metadata) error { 16 | var ( 17 | name = runtime.FuncForPC(fp.v().Pointer()).Name() 18 | dot = strings.LastIndex(name, ".") 19 | ) 20 | pkgPath, name := name[:dot], name[dot+1:] 21 | fcb := &funcCommandBuilder{ 22 | name, 23 | fp.reflection, 24 | md.Lookup(pkgPath, name), 25 | } 26 | cmd := fcb.build() 27 | return cmd.run(ctx) 28 | } 29 | 30 | type structPlan struct { 31 | reflection 32 | subcommands []*structPlan 33 | } 34 | 35 | func (sp *structPlan) buildRecursive(parent *reflection, md *internal.Metadata) *command { 36 | scb := &structCommandBuilder{ 37 | sp.reflection, 38 | parent, 39 | md.LookupType(sp.t()), 40 | } 41 | cmd := scb.build() 42 | for _, sub := range sp.subcommands { 43 | cmd.addCommand(sub.buildRecursive(&sp.reflection, md)) 44 | } 45 | return cmd 46 | } 47 | 48 | func (sp *structPlan) Execute(ctx context.Context, m *internal.Metadata) error { 49 | root := sp.buildRecursive(nil, m) // no parent 50 | return root.run(ctx) 51 | } 52 | -------------------------------------------------------------------------------- /reflect.go: -------------------------------------------------------------------------------- 1 | package climate 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | ) 7 | 8 | var contextType = reflect.TypeFor[context.Context]() 9 | 10 | func typeIsContext(t reflect.Type) bool { 11 | return t.Kind() == reflect.Interface && t.Implements(contextType) 12 | } 13 | 14 | var errorType = reflect.TypeFor[error]() 15 | 16 | func typeIsError(t reflect.Type) bool { 17 | return t.Kind() == reflect.Interface && t.Implements(errorType) 18 | } 19 | 20 | func typeIsStructPointer(t reflect.Type) bool { 21 | return t.Kind() == reflect.Pointer && t.Elem().Kind() == reflect.Struct 22 | } 23 | 24 | type reflection struct { 25 | ptr *reflection 26 | ot reflect.Type 27 | ov *reflect.Value 28 | } 29 | 30 | func (r *reflection) t() reflect.Type { 31 | if r == nil { 32 | return nil 33 | } 34 | if r.ot == nil { 35 | if r.ptr.t() != nil { 36 | r.ot = r.ptr.ot.Elem() 37 | } else { 38 | r.ot = r.ov.Type() 39 | } 40 | } 41 | return r.ot 42 | } 43 | 44 | func (r *reflection) v() *reflect.Value { 45 | if r == nil { 46 | return nil 47 | } 48 | if r.ov == nil { 49 | if r.ptr == nil { 50 | r.ptr = &reflection{} 51 | } 52 | if r.ptr.ov == nil { 53 | v := reflect.New(r.t()) 54 | r.ptr.ov = &v 55 | } 56 | v := r.ptr.ov.Elem() 57 | r.ov = &v 58 | } 59 | return r.ov 60 | } 61 | -------------------------------------------------------------------------------- /strconv.go: -------------------------------------------------------------------------------- 1 | package climate 2 | 3 | import ( 4 | "encoding/csv" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/avamsi/ergo/assert" 9 | ) 10 | 11 | func parseBool(s string) bool { 12 | return assert.Ok(strconv.ParseBool(s)) 13 | } 14 | 15 | func parseInt64(s string) int64 { 16 | return assert.Ok(strconv.ParseInt(s, 10, 64)) 17 | } 18 | 19 | func parseUint64(s string) uint64 { 20 | return assert.Ok(strconv.ParseUint(s, 10, 64)) 21 | } 22 | 23 | func parseFloat64(s string) float64 { 24 | return assert.Ok(strconv.ParseFloat(s, 64)) 25 | } 26 | 27 | func parseString(s string) string { 28 | return s 29 | } 30 | 31 | type typeParser[T any] func(string) T 32 | 33 | func sliceParser[T any](typer typeParser[T]) typeParser[[]T] { 34 | return func(s string) []T { 35 | // Plumb through csv.Reader (instead of strings.Split(s, ",") or 36 | // something similar) to account for quotes etc. 37 | ss := assert.Ok(csv.NewReader(strings.NewReader(s)).Read()) 38 | var ts []T 39 | for _, s := range ss { 40 | if s := strings.TrimSpace(s); s != "" { 41 | ts = append(ts, typer(s)) 42 | } 43 | } 44 | return ts 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /strconv_test.go: -------------------------------------------------------------------------------- 1 | package climate 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | ) 8 | 9 | func TestSliceParser(t *testing.T) { 10 | { 11 | var ( 12 | in = "true" 13 | want = []bool{true} 14 | ) 15 | if got := sliceParser(parseBool)(in); !cmp.Equal(got, want) { 16 | t.Errorf("sliceParser(parseBool)(%v) = %v, want %v", in, got, want) 17 | } 18 | } 19 | { 20 | var ( 21 | in = "false,true" // no space 22 | want = []bool{false, true} 23 | ) 24 | if got := sliceParser(parseBool)(in); !cmp.Equal(got, want) { 25 | t.Errorf("sliceParser(parseBool)(%v) = %v, want %v", in, got, want) 26 | } 27 | } 28 | { 29 | var ( 30 | in = "1, 2, 3, 4, 5" // with space 31 | want = []int64{1, 2, 3, 4, 5} 32 | ) 33 | if got := sliceParser(parseInt64)(in); !cmp.Equal(got, want) { 34 | t.Errorf("sliceParser(parseInt64)(%v) = %v, want %v", in, got, want) 35 | } 36 | } 37 | { 38 | var ( 39 | in = "4398046511104" // 2^42 40 | want = []int64{4398046511104} 41 | ) 42 | if got := sliceParser(parseInt64)(in); !cmp.Equal(got, want) { 43 | t.Errorf("sliceParser(parseInt64)(%v) = %v, want %v", in, got, want) 44 | } 45 | } 46 | { 47 | var ( 48 | in = "18446744073709551615" // 2^64 - 1 49 | want = []uint64{18446744073709551615} 50 | ) 51 | if got := sliceParser(parseUint64)(in); !cmp.Equal(got, want) { 52 | t.Errorf("sliceParser(parseUint64)(%v) = %v, want %v", in, got, want) 53 | } 54 | } 55 | { 56 | var ( 57 | in = "3.14" 58 | want = []float64{3.14} 59 | ) 60 | if got := sliceParser(parseFloat64)(in); !cmp.Equal(got, want) { 61 | t.Errorf("sliceParser(parseFloat64)(%v) = %v, want %v", in, got, want) 62 | } 63 | } 64 | { 65 | var ( 66 | in = "1.7976931348623157e+308" 67 | want = []float64{1.7976931348623157e+308} 68 | ) 69 | if got := sliceParser(parseFloat64)(in); !cmp.Equal(got, want) { 70 | t.Errorf("sliceParser(parseFloat64)(%v) = %v, want %v", in, got, want) 71 | } 72 | } 73 | { 74 | var ( 75 | in = "a,b,c," // trailing comma 76 | want = []string{"a", "b", "c"} 77 | ) 78 | if got := sliceParser(parseString)(in); !cmp.Equal(got, want) { 79 | t.Errorf("sliceParser(parseString)(%v) = %v, want %v", in, got, want) 80 | } 81 | } 82 | { 83 | var ( 84 | in = "a,b,c,\"d,e\"," // "d,e" is quoted 85 | want = []string{"a", "b", "c", "d,e"} 86 | ) 87 | if got := sliceParser(parseString)(in); !cmp.Equal(got, want) { 88 | t.Errorf("sliceParser(parseString)(%v) = %v, want %v", in, got, want) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /testing/clitest/clitest.go: -------------------------------------------------------------------------------- 1 | package clitest 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | "os" 8 | 9 | "github.com/avamsi/climate" 10 | "github.com/avamsi/climate/internal" 11 | "github.com/avamsi/ergo/assert" 12 | ) 13 | 14 | type Result struct { 15 | Stdout, Stderr string 16 | Code int 17 | } 18 | 19 | type TestCLI func(ctx context.Context, args []string) Result 20 | 21 | func New(p internal.Plan, mods ...func(*internal.RunOptions)) TestCLI { 22 | return func(ctx context.Context, args []string) Result { 23 | var ( 24 | // TODO: do these pipes have enough capacity? 25 | stdoutR, stdoutW, err1 = os.Pipe() 26 | stderrR, stderrW, err2 = os.Pipe() 27 | osArgs = os.Args 28 | osStdout, osStderr = os.Stdout, os.Stderr 29 | ) 30 | assert.Nil(errors.Join(err1, err2)) 31 | os.Args = append([]string{""}, args...) 32 | os.Stdout, os.Stderr = stdoutW, stderrW 33 | defer func() { 34 | os.Args = osArgs 35 | os.Stdout, os.Stderr = osStdout, osStderr 36 | }() 37 | code := climate.Run(ctx, p, mods...) 38 | assert.Nil(errors.Join(stdoutW.Close(), stderrW.Close())) 39 | return Result{ 40 | Stdout: string(assert.Ok(io.ReadAll(stdoutR))), 41 | Stderr: string(assert.Ok(io.ReadAll(stderrR))), 42 | Code: code, 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /testing/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/avamsi/climate/testing 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/avamsi/climate v0.0.0-20241207052824-3e55dcec3f7d 7 | github.com/avamsi/ergo v0.0.0-20250210165756-c19aaccc8346 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | github.com/sanity-io/litter v1.5.8 // indirect 15 | github.com/spf13/cobra v1.9.1 // indirect 16 | github.com/spf13/pflag v1.0.6 // indirect 17 | golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa // indirect 18 | golang.org/x/mod v0.24.0 // indirect 19 | golang.org/x/text v0.25.0 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /testing/go.sum: -------------------------------------------------------------------------------- 1 | github.com/avamsi/climate v0.0.0-20241207052824-3e55dcec3f7d h1:SeYGWdyV3sXJv1v7FFljGequW3uwmOp7UcbReVXHQ4s= 2 | github.com/avamsi/climate v0.0.0-20241207052824-3e55dcec3f7d/go.mod h1:EoU+4pmQSAfTcBQiQkIcU9RWD5vkBM67OdCD/eSlJYM= 3 | github.com/avamsi/ergo v0.0.0-20250210165756-c19aaccc8346 h1:IffBmxusWG5vSyCqVdOWXBbuEhy3gKGX2M0Nr8YRkUc= 4 | github.com/avamsi/ergo v0.0.0-20250210165756-c19aaccc8346/go.mod h1:woiGOfYKn9S9DM0VmN2pLci6qmmHIiIcrAvjFi018Ys= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 6 | github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 10 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 11 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 12 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 13 | github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 15 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 16 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 17 | github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg= 18 | github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= 19 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 20 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 21 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 22 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 23 | github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 24 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 25 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 26 | golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrCPyJZ+eyRsABUPs1mz4= 27 | golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk= 28 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 29 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 30 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 31 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 32 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 33 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 34 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 35 | --------------------------------------------------------------------------------