Repository: alecthomas/kong
Pull request #428 (2024-05-21 17:49:07)

feat: allow non-structs to be used as commands
This small MR allows using the func-to-interface trick to implement a command (see commandFunc in kong_test.go).

This is useful e.g. for commands that have no flags or arguments of their own, but instead receive all required information via bound  parameters.

:question: we could (but don't need to) also provide a `CommandFunc` type, analogous to `http.HandlerFunc`.


Patch:
diff --git a/.golangci.yml b/.golangci.yml
index e8980bf..2c5bfe3 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -87,3 +87,7 @@ issues:
     # Duplicate words are okay in tests.
     - linters: [dupword]
       path: _test\.go
+
+    - linters: [maintidx]
+      path: context\.go
+      text: 'Function name: trace'
diff --git a/README.md b/README.md
index 22e00f0..916ca6f 100644
--- a/README.md
+++ b/README.md
@@ -733,7 +733,7 @@ All builtin Go types (as well as a bunch of useful stdlib types like `time.Time`
 The default help output is usually sufficient, but if not there are two solutions.
 
 1. Use `ConfigureHelp(HelpOptions)` to configure how help is formatted (see [HelpOptions](https://godoc.org/github.com/alecthomas/kong#HelpOptions) for details).
-2. Custom help can be wired into Kong via the `Help(HelpFunc)` option. The `HelpFunc` is passed a `Context`, which contains the parsed context for the current command-line. See the implementation of `PrintHelp` for an example.
+2. Custom help can be wired into Kong via the `Help(HelpFunc)` option. The `HelpFunc` is passed a `Context`, which contains the parsed context for the current command-line. See the implementation of `DefaultHelpPrinter` for an example.
 3. Use `ValueFormatter(HelpValueFormatter)` if you want to just customize the help text that is accompanied by flags and arguments.
 4. Use `Groups([]Group)` if you want to customize group titles or add a header.
 
diff --git a/_examples/server/go.mod b/_examples/server/go.mod
index d216f01..1feb248 100644
--- a/_examples/server/go.mod
+++ b/_examples/server/go.mod
@@ -12,5 +12,5 @@ require (
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
 	github.com/kr/pty v1.1.8
 	github.com/mattn/go-isatty v0.0.12 // indirect
-	golang.org/x/crypto v0.22.0
+	golang.org/x/crypto v0.25.0
 )
diff --git a/_examples/server/go.sum b/_examples/server/go.sum
index bd8ec62..bf1cd72 100644
--- a/_examples/server/go.sum
+++ b/_examples/server/go.sum
@@ -36,6 +36,7 @@ github.com/gliderlabs/ssh v0.3.6 h1:ZzjlDa05TcFRICb3anf/dSPN3ewz1Zx6CMLPWgkm3b8=
 github.com/gliderlabs/ssh v0.3.6/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
 github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
 github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
@@ -56,6 +57,7 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
 golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
 golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
 golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
 golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
 golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
@@ -67,8 +69,14 @@ golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
 golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
 golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
 golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
+golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@@ -77,10 +85,15 @@ golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfS
 golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
 golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -95,6 +108,7 @@ golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
 golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
@@ -104,6 +118,10 @@ golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
 golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
 golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
+golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -111,6 +129,7 @@ golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw=
 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
 golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
 golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
 golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
@@ -120,6 +139,9 @@ golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
 golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
 golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
 golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
+golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
+golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
+golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -127,9 +149,14 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/_examples/shell/help/main.go b/_examples/shell/help/main.go
index 56b8ce6..4d1d154 100644
--- a/_examples/shell/help/main.go
+++ b/_examples/shell/help/main.go
@@ -7,14 +7,16 @@ import (
 )
 
 var cli struct {
-	Flag flagWithHelp    `help:"Regular flag help"`
+	Flag flagWithHelp    `help:"${flag_help}"`
 	Echo commandWithHelp `cmd:"" help:"Regular command help"`
 }
 
 type flagWithHelp bool
 
-func (f *flagWithHelp) Help() string {
-	return "🏁 additional flag help"
+// See https://github.com/alecthomas/kong?tab=readme-ov-file#variable-interpolation
+var vars = kong.Vars{
+	"flag_help": "Extended flag help that might be too long for directly " +
+		"including in the struct tag field",
 }
 
 type commandWithHelp struct {
@@ -41,7 +43,8 @@ func main() {
 		kong.ConfigureHelp(kong.HelpOptions{
 			Compact: true,
 			Summary: false,
-		}))
+		}),
+		vars)
 	switch ctx.Command() {
 	case "echo <msg>":
 		fmt.Println(cli.Echo.Msg)
diff --git a/bin/.go-1.22.2.pkg b/bin/.go-1.22.5.pkg
similarity index 100%
rename from bin/.go-1.22.2.pkg
rename to bin/.go-1.22.5.pkg
diff --git a/bin/go b/bin/go
index d9bfd88..5c26cb9 120000
--- a/bin/go
+++ b/bin/go
@@ -1,1 +1,1 @@
-.go-1.22.2.pkg
\ No newline at end of file
+.go-1.22.5.pkg
\ No newline at end of file
diff --git a/bin/gofmt b/bin/gofmt
index d9bfd88..5c26cb9 120000
--- a/bin/gofmt
+++ b/bin/gofmt
@@ -1,1 +1,1 @@
-.go-1.22.2.pkg
\ No newline at end of file
+.go-1.22.5.pkg
\ No newline at end of file
diff --git a/build.go b/build.go
index 1cfe1c3..aed1f72 100644
--- a/build.go
+++ b/build.go
@@ -51,6 +51,9 @@ type flattenedField struct {
 
 func flattenedFields(v reflect.Value, ptag *Tag) (out []flattenedField, err error) {
 	v = reflect.Indirect(v)
+	if v.Kind() != reflect.Struct {
+		return out, nil
+	}
 	for i := 0; i < v.NumField(); i++ {
 		ft := v.Type().Field(i)
 		fv := v.Field(i)
diff --git a/context.go b/context.go
index b2bfea6..03044ec 100644
--- a/context.go
+++ b/context.go
@@ -420,12 +420,22 @@ func (c *Context) trace(node *Node) (err error) { //nolint: gocyclo
 
 		case FlagToken:
 			if err := c.parseFlag(flags, token.String()); err != nil {
-				return err
+				if isUnknownFlagError(err) && positional < len(node.Positional) && node.Positional[positional].Passthrough {
+					c.scan.Pop()
+					c.scan.PushTyped(token.String(), PositionalArgumentToken)
+				} else {
+					return err
+				}
 			}
 
 		case ShortFlagToken:
 			if err := c.parseFlag(flags, token.String()); err != nil {
-				return err
+				if isUnknownFlagError(err) && positional < len(node.Positional) && node.Positional[positional].Passthrough {
+					c.scan.Pop()
+					c.scan.PushTyped(token.String(), PositionalArgumentToken)
+				} else {
+					return err
+				}
 			}
 
 		case FlagValueToken:
@@ -728,9 +738,19 @@ func (c *Context) parseFlag(flags []*Flag, match string) (err error) {
 		c.Path = append(c.Path, &Path{Flag: flag})
 		return nil
 	}
-	return findPotentialCandidates(match, candidates, "unknown flag %s", match)
+	return &unknownFlagError{Cause: findPotentialCandidates(match, candidates, "unknown flag %s", match)}
+}
+
+func isUnknownFlagError(err error) bool {
+	var unknown *unknownFlagError
+	return errors.As(err, &unknown)
 }
 
+type unknownFlagError struct{ Cause error }
+
+func (e *unknownFlagError) Unwrap() error { return e.Cause }
+func (e *unknownFlagError) Error() string { return e.Cause.Error() }
+
 // Call an arbitrary function filling arguments with bound values.
 func (c *Context) Call(fn any, binds ...interface{}) (out []interface{}, err error) {
 	fv := reflect.ValueOf(fn)
@@ -883,7 +903,7 @@ func checkMissingChildren(node *Node) error {
 	if len(missing) == 1 {
 		return fmt.Errorf("expected %s", missing[0])
 	}
-	return fmt.Errorf("expected one of %s", strings.Join(missing, ",  "))
+	return fmt.Errorf("expected one of %s", strings.Join(missing, ", "))
 }
 
 // If we're missing any positionals and they're required, return an error.
diff --git a/go.mod b/go.mod
index 8cc06b5..dad659b 100644
--- a/go.mod
+++ b/go.mod
@@ -1,7 +1,7 @@
 module github.com/alecthomas/kong
 
 require (
-	github.com/alecthomas/assert/v2 v2.8.1
+	github.com/alecthomas/assert/v2 v2.10.0
 	github.com/alecthomas/repr v0.4.0
 )
 
diff --git a/go.sum b/go.sum
index 6e6441f..fe9dd12 100644
--- a/go.sum
+++ b/go.sum
@@ -6,6 +6,8 @@ github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v
 github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
 github.com/alecthomas/assert/v2 v2.8.1 h1:YCxnYR6jjpfnEK5AK5SysALKdUEBPGH4Y7As6tBnDw0=
 github.com/alecthomas/assert/v2 v2.8.1/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
+github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY=
+github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
 github.com/alecthomas/repr v0.3.0 h1:NeYzUPfjjlqHY4KtzgKJiWd6sVq2eNUPTi34PiFGjY8=
 github.com/alecthomas/repr v0.3.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
diff --git a/options.go b/options.go
index d01aeec..3ab383b 100644
--- a/options.go
+++ b/options.go
@@ -89,6 +89,10 @@ type dynamicCommand struct {
 // "tags" is a list of extra tag strings to parse, in the form <key>:"<value>".
 func DynamicCommand(name, help, group string, cmd interface{}, tags ...string) Option {
 	return OptionFunc(func(k *Kong) error {
+		if run := getMethod(reflect.Indirect(reflect.ValueOf(cmd)), "Run"); !run.IsValid() {
+			return fmt.Errorf("kong: DynamicCommand %q must be a type with a 'Run' method; got %T", name, cmd)
+		}
+
 		k.dynamicCommands = append(k.dynamicCommands, &dynamicCommand{
 			name:  name,
 			help:  help,


Test patch:
diff --git a/kong_test.go b/kong_test.go
index 0e9fa9a..c4ed7f6 100644
--- a/kong_test.go
+++ b/kong_test.go
@@ -1297,6 +1297,12 @@ func (d *dynamicCommand) Run() error {
 	return nil
 }
 
+type commandFunc func() error
+
+func (cf commandFunc) Run() error {
+	return cf()
+}
+
 func TestDynamicCommands(t *testing.T) {
 	cli := struct {
 		One struct{} `cmd:"one"`
@@ -1304,9 +1310,12 @@ func TestDynamicCommands(t *testing.T) {
 	help := &strings.Builder{}
 	two := &dynamicCommand{}
 	three := &dynamicCommand{}
+	fourRan := false
+	four := commandFunc(func() error { fourRan = true; return nil })
 	p := mustNew(t, &cli,
 		kong.DynamicCommand("two", "", "", &two),
 		kong.DynamicCommand("three", "", "", three, "hidden"),
+		kong.DynamicCommand("four", "", "", &four),
 		kong.Writers(help, help),
 		kong.Exit(func(int) {}))
 	kctx, err := p.Parse([]string{"two", "--flag=flag"})
@@ -1317,8 +1326,15 @@ func TestDynamicCommands(t *testing.T) {
 	assert.NoError(t, err)
 	assert.True(t, two.ran)
 
+	kctx, err = p.Parse([]string{"four"})
+	assert.NoError(t, err)
+	assert.False(t, fourRan)
+	err = kctx.Run()
+	assert.NoError(t, err)
+	assert.True(t, fourRan)
+
 	_, err = p.Parse([]string{"--help"})
-	assert.EqualError(t, err, `expected one of "one",  "two"`)
+	assert.EqualError(t, err, `expected one of "one", "two", "four"`)
 	assert.NotContains(t, help.String(), "three", help.String())
 }
 
@@ -1526,6 +1542,54 @@ func TestEnumValidation(t *testing.T) {
 	}
 }
 
+func TestPassthroughArgs(t *testing.T) {
+	tests := []struct {
+		name    string
+		args    []string
+		flag    string
+		cmdArgs []string
+	}{
+		{
+			"NoArgs",
+			[]string{},
+			"",
+			[]string(nil),
+		},
+		{
+			"RecognizedFlagAndArgs",
+			[]string{"--flag", "foobar", "something"},
+			"foobar",
+			[]string{"something"},
+		},
+		{
+			"DashDashBeforeRecognizedFlag",
+			[]string{"--", "--flag", "foobar"},
+			"",
+			[]string{"--flag", "foobar"},
+		},
+		{
+			"UnrecognizedFlagAndArgs",
+			[]string{"--unrecognized-flag", "something"},
+			"",
+			[]string{"--unrecognized-flag", "something"},
+		},
+	}
+	for _, test := range tests {
+		test := test
+		t.Run(test.name, func(t *testing.T) {
+			var cli struct {
+				Flag string
+				Args []string `arg:"" optional:"" passthrough:""`
+			}
+			p := mustNew(t, &cli)
+			_, err := p.Parse(test.args)
+			assert.NoError(t, err)
+			assert.Equal(t, test.flag, cli.Flag)
+			assert.Equal(t, test.cmdArgs, cli.Args)
+		})
+	}
+}
+
 func TestPassthroughCmd(t *testing.T) {
 	tests := []struct {
 		name    string