diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+## v1.1.1
+
+* Fix running tests with sdist bundle
+
 ## v1.1.0
 
 * Rename `KDL.text` to `KDL.string`
diff --git a/kdl-hs.cabal b/kdl-hs.cabal
--- a/kdl-hs.cabal
+++ b/kdl-hs.cabal
@@ -1,7 +1,7 @@
 cabal-version: 3.0
 
 name: kdl-hs
-version: 1.1.0
+version: 1.1.1
 synopsis: KDL language parser and API
 description: KDL language parser and API.
 homepage: https://github.com/brandonchinn178/kdl-hs#readme
@@ -15,10 +15,10 @@
 extra-source-files:
   README.md
   CHANGELOG.md
+  test/KDL/Decoder/SharedSpec/Template.hs
   test/KDL/__snapshots__/DecoderSpec.snap.md
   test/KDL/__snapshots__/ParserSpec.snap.md
   test/KDL/__snapshots__/RenderSpec.snap.md
-  tools/kdl-test
 
 source-repository head
   type: git
diff --git a/test/KDL/Decoder/SharedSpec/Template.hs b/test/KDL/Decoder/SharedSpec/Template.hs
new file mode 100644
--- /dev/null
+++ b/test/KDL/Decoder/SharedSpec/Template.hs
@@ -0,0 +1,1125 @@
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE DisambiguateRecordFields #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+-- Apostrophes confuse CPP macros
+#define _APOS_(x) x'
+
+#ifdef IS_MONAD
+#define _DO_ do
+#define _RETURN_(x) pure x
+#define _STMT_(x) x
+#endif
+
+#ifdef IS_ARROW
+#define _DO_ proc () -> do
+#define _RETURN_(x) returnA -< x
+#define _STMT_(x) x -< ()
+#endif
+
+#ifdef IS_ARROW
+{-# LANGUAGE Arrows #-}
+#endif
+
+module KDL.Decoder.SharedSpec.MODULE_NAME (
+  apiSpec,
+  decodeNodeSpec,
+  decodeValueSpec,
+) where
+
+import Control.Monad (forM_, unless)
+import Data.Map qualified as Map
+import Data.Text (Text)
+import Data.Text qualified as Text
+import KDL.TestUtils.AST (scrubFormat)
+import KDL.TestUtils.Error (decodeErrorMsg)
+import KDL.Types (
+  Entry (..),
+  Identifier (..),
+  Node (..),
+  NodeList (..),
+  Value (..),
+  ValueData (..),
+ )
+import Skeletest
+
+#ifdef IS_MONAD
+import KDL qualified
+#endif
+
+#ifdef IS_ARROW
+import Control.Arrow (returnA)
+import KDL.Arrow qualified as KDL
+#endif
+
+apiSpec :: Spec
+apiSpec = do
+  describe "NodeListDecoder" $ do
+    describe "node" $ do
+      it "decodes a node" $ do
+        let config = "foo 1.0"
+            decoder = KDL.document $ _DO_
+              _STMT_(scrubFormat <$> KDL.node "foo")
+            expected =
+              Node
+                { ann = Nothing
+                , name = Identifier{value = "foo", ext = KDL.def}
+                , entries =
+                    [ Entry
+                        { name = Nothing
+                        , value = Value{ann = Nothing, data_ = Number 1.0, ext = KDL.def}
+                        , ext = KDL.def
+                        }
+                    ]
+                , children = Nothing
+                , ext = KDL.def
+                }
+        KDL.decodeWith decoder config `shouldBe` Right expected
+
+      it "decodes multiple nodes" $ do
+        let config = "foo; foo"
+            decoder = KDL.document $ _DO_
+              _STMT_(fmap (map scrubFormat) . KDL.many $ KDL.node "foo")
+            expected = [fooNode, fooNode]
+            fooNode =
+              Node
+                { ann = Nothing
+                , name = Identifier{value = "foo", ext = KDL.def}
+                , entries = []
+                , children = Nothing
+                , ext = KDL.def
+                }
+        KDL.decodeWith decoder config `shouldBe` Right expected
+
+      it "decodes nodes in any order" $ do
+        let config = "foo; bar"
+            decoder = KDL.document $ _DO_
+              bar <- _STMT_(scrubFormat <$> KDL.node "bar")
+              foo <- _STMT_(scrubFormat <$> KDL.node "foo")
+              _RETURN_((bar, foo))
+            expected = (node "bar", node "foo")
+            node name =
+              Node
+                { ann = Nothing
+                , name = Identifier{value = name, ext = KDL.def}
+                , entries = []
+                , children = Nothing
+                , ext = KDL.def
+                }
+        KDL.decodeWith decoder config `shouldBe` Right expected
+
+      it "fails when not enough nodes" $ do
+        let config = "foo"
+            decoder = KDL.document $ _DO_
+              foo1 <- _STMT_(scrubFormat <$> KDL.node @Node "foo")
+              foo2 <- _STMT_(scrubFormat <$> KDL.node @Node "foo")
+              _RETURN_((foo1, foo2))
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: <root>"
+            , "  Expected another node: foo"
+            ]
+
+    -- Most behaviors tested with `node`
+    describe "nodeWith" $ do
+      it "decodes a node" $ do
+        let config = "foo 1.0 { hello world; }"
+            decodeFoo = _DO_
+              arg <- _STMT_(KDL.arg @Int)
+              child <- _STMT_(KDL.children $ KDL.argAt @Text "hello")
+              _RETURN_((arg, child))
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.nodeWith "foo" decodeFoo)
+        KDL.decodeWith decoder config `shouldBe` Right (1, "world")
+
+      it "fails when node fails to parse" $ do
+        let config = "foo 1.0"
+            decodeFoo = _DO_
+              _STMT_(KDL.arg @Text)
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.nodeWith "foo" decodeFoo)
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0 > arg #0"
+            , "  Expected string, got: 1.0"
+            ]
+
+    -- Most behaviors tested with `nodeWith`
+    describe "nodeWith'" $ do
+      it "decodes a node with an annotation" $ do
+        let testCases =
+              [ []
+              , ["test"]
+              , ["test", "other"]
+              ]
+        forM_ testCases $ \anns -> do
+          let config = "(test)foo 1"
+              decoder = KDL.document $ _DO_
+                _STMT_(_APOS_(KDL.nodeWith) "foo" anns $ KDL.arg @Int)
+          KDL.decodeWith decoder config `shouldBe` Right 1
+
+      it "decodes a node without an annotation" $ do
+        let testCases =
+              [ []
+              , ["test"]
+              , ["test", "other"]
+              ]
+        forM_ testCases $ \anns -> do
+          let config = "foo 1"
+              decoder = KDL.document $ _DO_
+                _STMT_(_APOS_(KDL.nodeWith) "foo" anns $ KDL.arg @Int)
+          KDL.decodeWith decoder config `shouldBe` Right 1
+
+      it "fails when node has unexpected annotation" $ do
+        let config = "(test)foo 2"
+            decoder = KDL.document $ _DO_
+              _STMT_(_APOS_(KDL.nodeWith) "foo" ["FOO"] $ KDL.arg @Int)
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0"
+            , "  Expected annotation to be one of [\"FOO\"], got: test"
+            ]
+
+    describe "remainingNodes" $ do
+      it "returns all remaining nodes" $ do
+        let config = "foo 1.0; foo 2.0; bar"
+            decoder = KDL.document $ _DO_
+              _ <- _STMT_(KDL.node @Node "foo")
+              _STMT_(fmap (map scrubFormat) <$> KDL.remainingNodes)
+            expected =
+              Map.fromList
+                [ ("foo", [fooNode2])
+                , ("bar", [barNode])
+                ]
+            fooNode2 =
+              Node
+                { ann = Nothing
+                , name = Identifier{value = "foo", ext = KDL.def}
+                , entries =
+                    [ Entry
+                        { name = Nothing
+                        , value = Value{ann = Nothing, data_ = Number 2.0, ext = KDL.def}
+                        , ext = KDL.def
+                        }
+                    ]
+                , children = Nothing
+                , ext = KDL.def
+                }
+            barNode =
+              Node
+                { ann = Nothing
+                , name = Identifier{value = "bar", ext = KDL.def}
+                , entries = []
+                , children = Nothing
+                , ext = KDL.def
+                }
+        KDL.decodeWith decoder config `shouldBe` Right expected
+
+    -- Most behaviors tested with `remainingNodes`
+    describe "remainingNodesWith" $ do
+      it "returns all remaining nodes" $ do
+        let config = "foo 1.0; foo 2.0; bar"
+            decodeNode = _DO_
+              _STMT_(KDL.optional $ KDL.arg @Int)
+            decoder = KDL.document $ _DO_
+              _ <- _STMT_(KDL.node @Node "foo")
+              _STMT_(KDL.remainingNodesWith decodeNode)
+            expected =
+              Map.fromList
+                [ ("foo", [Just 2])
+                , ("bar", [Nothing])
+                ]
+        KDL.decodeWith decoder config `shouldBe` Right expected
+
+      it "fails when node fails to parse" $ do
+        let config = "foo 1; bar 1; bar hello"
+            decodeNode = _DO_
+              _STMT_(KDL.arg @Int)
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.remainingNodesWith decodeNode)
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: bar #1 > arg #0"
+            , "  Expected number, got: hello"
+            ]
+
+    -- Most behaviors tested with `remainingNodesWith`
+    describe "remainingNodesWith'" $ do
+      it "decodes a node with an annotation" $ do
+        let testCases =
+              [ []
+              , ["test"]
+              , ["test", "other"]
+              ]
+        forM_ testCases $ \anns -> do
+          let config = "(test)foo 1"
+              decoder = KDL.document $ _DO_
+                _STMT_(_APOS_(KDL.remainingNodesWith) anns $ KDL.arg @Int)
+          KDL.decodeWith decoder config
+            `shouldBe` (Right . Map.fromList) [("foo", [1])]
+
+      it "decodes a node without an annotation" $ do
+        let testCases =
+              [ []
+              , ["test"]
+              , ["test", "other"]
+              ]
+        forM_ testCases $ \anns -> do
+          let config = "foo 1"
+              decoder = KDL.document $ _DO_
+                _STMT_(_APOS_(KDL.remainingNodesWith) anns $ KDL.arg @Int)
+          KDL.decodeWith decoder config
+            `shouldBe` (Right . Map.fromList) [("foo", [1])]
+
+      it "fails when node has unexpected annotation" $ do
+        let config = "(FOO)foo 1; (test)foo 2"
+            decoder = KDL.document $ _DO_
+              _STMT_(_APOS_(KDL.remainingNodesWith) ["FOO"] $ KDL.arg @Int)
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #1"
+            , "  Expected annotation to be one of [\"FOO\"], got: test"
+            ]
+
+    describe "argAt" $ do
+      it "gets argument at a node" $ do
+        let config = "foo bar; hello world"
+            decoder = KDL.document $ _DO_
+              hello <- _STMT_(KDL.argAt @Text "hello")
+              foo <- _STMT_(KDL.argAt @Text "foo")
+              _RETURN_((hello, foo))
+        KDL.decodeWith decoder config `shouldBe` Right ("world", "bar")
+
+      it "fails if no node" $ do
+        let config = "other_node"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.argAt @Int "foo")
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: <root>"
+            , "  Expected node: foo"
+            ]
+
+      it "fails if node has no args" $ do
+        let config = "foo"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.argAt @Int "foo")
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0"
+            , "  Expected arg #0"
+            ]
+
+      it "fails if arg fails to parse" $ do
+        let config = "foo 1"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.argAt @Text "foo")
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0 > arg #0"
+            , "  Expected string, got: 1"
+            ]
+
+    -- Most behaviors tested with `argAt`
+    describe "argAtWith" $ do
+      it "gets argument at a node" $ do
+        let config = "foo 1"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.argAtWith "foo" $ show . (* 10) <$> KDL.number)
+        KDL.decodeWith decoder config `shouldBe` Right "10.0"
+
+    -- Most behaviors tested with `argAtWith`
+    describe "argAtWith'" $ do
+      it "decodes argument with an annotation" $ do
+        let testCases =
+              [ []
+              , ["test"]
+              , ["test", "other"]
+              ]
+        forM_ testCases $ \anns -> do
+          let config = "foo (test)a"
+              decoder = KDL.document $ _DO_
+                _STMT_(_APOS_(KDL.argAtWith) "foo" anns KDL.string)
+          KDL.decodeWith decoder config `shouldBe` Right "a"
+
+      it "decodes argument without an annotation" $ do
+        let testCases =
+              [ []
+              , ["test"]
+              , ["test", "other"]
+              ]
+        forM_ testCases $ \anns -> do
+          let config = "foo a"
+              decoder = KDL.document $ _DO_
+                _STMT_(_APOS_(KDL.argAtWith) "foo" anns KDL.string)
+          KDL.decodeWith decoder config `shouldBe` Right "a"
+
+      it "fails when argument has unexpected annotation" $ do
+        let config = "foo (test)a"
+            decoder = KDL.document $ _DO_
+              _STMT_(_APOS_(KDL.argAtWith) "foo" ["VAL"] KDL.string)
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0 > arg #0"
+            , "  Expected annotation to be one of [\"VAL\"], got: test"
+            ]
+
+    describe "argsAt" $ do
+      it "gets arguments at a node" $ do
+        let config = "foo 1 2 3"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.argsAt @Int "foo")
+        KDL.decodeWith decoder config `shouldBe` Right [1, 2, 3]
+
+      it "returns empty list if no node" $ do
+        let config = ""
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.argsAt @Int "foo")
+        KDL.decodeWith decoder config `shouldBe` Right []
+
+      it "returns empty list if node has no args" $ do
+        let config = "foo"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.argsAt @Int "foo")
+        KDL.decodeWith decoder config `shouldBe` Right []
+
+      it "fails if any arg fails to parse" $ do
+        let config = "foo 1 asdf"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.argsAt @Int "foo")
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0 > arg #1"
+            , "  Expected number, got: asdf"
+            ]
+
+    -- Most behaviors tested with `argsAt`
+    describe "argsAtWith" $ do
+      it "gets arguments at a node" $ do
+        let config = "foo 1 2"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.argsAtWith "foo" $ show . (* 10) <$> KDL.number)
+        KDL.decodeWith decoder config `shouldBe` Right ["10.0", "20.0"]
+
+    -- Most behaviors tested with `argsAtWith`
+    describe "argsAtWith'" $ do
+      it "decodes arguments with an annotation" $ do
+        let testCases =
+              [ []
+              , ["test"]
+              , ["test", "other"]
+              ]
+        forM_ testCases $ \anns -> do
+          let config = "foo (test)a (test)b"
+              decoder = KDL.document $ _DO_
+                _STMT_(_APOS_(KDL.argsAtWith) "foo" anns KDL.string)
+          KDL.decodeWith decoder config `shouldBe` Right ["a", "b"]
+
+      it "decodes arguments without an annotation" $ do
+        let testCases =
+              [ []
+              , ["test"]
+              , ["test", "other"]
+              ]
+        forM_ testCases $ \anns -> do
+          let config = "foo a b"
+              decoder = KDL.document $ _DO_
+                _STMT_(_APOS_(KDL.argsAtWith) "foo" anns KDL.string)
+          KDL.decodeWith decoder config `shouldBe` Right ["a", "b"]
+
+      it "fails when argument has unexpected annotation" $ do
+        let config = "foo (VAL)a (test)b"
+            decoder = KDL.document $ _DO_
+              _STMT_(_APOS_(KDL.argsAtWith) "foo" ["VAL"] KDL.string)
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0 > arg #1"
+            , "  Expected annotation to be one of [\"VAL\"], got: test"
+            ]
+
+    describe "dashChildrenAt" $ do
+      it "gets dash children at a node" $ do
+        let config = "foo { - 1; - 2; - 3; }"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.dashChildrenAt @Int "foo")
+        KDL.decodeWith decoder config `shouldBe` Right [1, 2, 3]
+
+      it "returns empty list if no node" $ do
+        let config = ""
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.dashChildrenAt @Int "foo")
+        KDL.decodeWith decoder config `shouldBe` Right []
+
+      it "returns empty list if node has no dash children" $ do
+        forM_ ["foo", "foo {}"] $ \config -> do
+          let decoder = KDL.document $ _DO_
+                _STMT_(KDL.dashChildrenAt @Int "foo")
+          KDL.decodeWith decoder config `shouldBe` Right []
+
+      it "fails if dash children have multiple args" $ do
+        let config = "foo { - 1 2; - 3 4; }"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.dashChildrenAt @Int "foo")
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0 > - #0"
+            , "  Unexpected arg #1: 2"
+            ]
+
+      it "fails if node has non-dash children" $ do
+        let config = "foo { - 1; bar 1 2 3; }"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.dashChildrenAt @Int "foo")
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0"
+            , "  Unexpected node: bar #0"
+            , "  Expected another node: -"
+            ]
+
+      it "fails if any child fails to parse" $ do
+        let config = "foo { - 1; - asdf; }"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.dashChildrenAt @Int "foo")
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0 > - #1 > arg #0"
+            , "  Expected number, got: asdf"
+            ]
+
+    -- Most behaviors tested with `dashChildrenAt`
+    describe "dashChildrenAtWith" $ do
+      it "gets dash children at a node" $ do
+        let config = "foo { - 1; - 2; }"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.dashChildrenAtWith "foo" $ show . (* 10) <$> KDL.number)
+        KDL.decodeWith decoder config `shouldBe` Right ["10.0", "20.0"]
+
+    -- Most behaviors tested with `dashChildrenAtWith`
+    describe "dashChildrenAtWith'" $ do
+      it "decodes dash children with an annotation" $ do
+        let testCases =
+              [ []
+              , ["test"]
+              , ["test", "other"]
+              ]
+        forM_ testCases $ \anns -> do
+          let config = "foo { - (test)a; - (test)b; }"
+              decoder = KDL.document $ _DO_
+                _STMT_(_APOS_(KDL.dashChildrenAtWith) "foo" anns KDL.string)
+          KDL.decodeWith decoder config `shouldBe` Right ["a", "b"]
+
+      it "decodes dash children without an annotation" $ do
+        let testCases =
+              [ []
+              , ["test"]
+              , ["test", "other"]
+              ]
+        forM_ testCases $ \anns -> do
+          let config = "foo { - a; - b; }"
+              decoder = KDL.document $ _DO_
+                _STMT_(_APOS_(KDL.dashChildrenAtWith) "foo" anns KDL.string)
+          KDL.decodeWith decoder config `shouldBe` Right ["a", "b"]
+
+      it "fails when child has unexpected annotation" $ do
+        let config = "foo { - (test)a; }"
+            decoder = KDL.document $ _DO_
+              _STMT_(_APOS_(KDL.dashChildrenAtWith) "foo" ["VAL"] KDL.string)
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0 > - #0 > arg #0"
+            , "  Expected annotation to be one of [\"VAL\"], got: test"
+            ]
+
+    describe "dashNodesAt" $ do
+      it "gets dash nodes at a node" $ do
+        let config = "foo { - { bar; }; - { baz; }; }"
+            decoder = KDL.document $ _DO_
+              _STMT_(map scrubFormat <$> KDL.dashNodesAt "foo")
+            expected = [node "-" [node "bar" []], node "-" [node "baz" []]]
+            node name children =
+              Node
+                { ann = Nothing
+                , name = Identifier{value = name, ext = KDL.def}
+                , entries = []
+                , children =
+                    if null children
+                      then Nothing
+                      else Just NodeList{nodes = children, ext = KDL.def}
+                , ext = KDL.def
+                }
+        KDL.decodeWith decoder config `shouldBe` Right expected
+
+      it "returns empty list if no node" $ do
+        let config = ""
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.dashNodesAt @Node "foo")
+        KDL.decodeWith decoder config `shouldBe` Right []
+
+      it "returns empty list if node has no dash nodes" $ do
+        forM_ ["foo", "foo {}"] $ \config -> do
+          let decoder = KDL.document $ _DO_
+                _STMT_(KDL.dashNodesAt @Node "foo")
+          KDL.decodeWith decoder config `shouldBe` Right []
+
+      it "fails if node has non-dash nodes" $ do
+        let config = "foo { - 1; bar 1 2 3; }"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.dashNodesAt @Node "foo")
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0"
+            , "  Unexpected node: bar #0"
+            , "  Expected another node: -"
+            ]
+
+    -- Most behaviors tested with `dashNodesAt`
+    describe "dashNodesAtWith" $ do
+      it "gets dash nodes at a node" $ do
+        let config = "foo { - 1 { bar hello; }; - 2 { bar world; }; }"
+            decodeChild = _DO_
+              arg <- _STMT_(KDL.arg @Int)
+              child <- _STMT_(KDL.children $ KDL.nodeWith "bar" $ KDL.arg @Text)
+              _RETURN_((arg, child))
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.dashNodesAtWith "foo" decodeChild)
+        KDL.decodeWith decoder config `shouldBe` Right [(1, "hello"), (2, "world")]
+
+      it "fails if any child fails to parse" $ do
+        let config = "foo { - { bar 1; }; - { bar test; }; }"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.dashNodesAtWith "foo" $ KDL.children $ KDL.argAt @Int "bar")
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0 > - #1 > bar #0 > arg #0"
+            , "  Expected number, got: test"
+            ]
+
+  describe "NodeDecoder" $ do
+    let decodeNode name decoder config =
+          KDL.decodeWith
+            ( KDL.document $ _DO_
+                _STMT_(KDL.nodeWith name decoder)
+            )
+            config
+
+    describe "arg" $ do
+      it "decodes an argument" $ do
+        let config = "foo 1 bar"
+            decoder = _DO_
+              arg1 <- _STMT_(KDL.arg @Int)
+              arg2 <- _STMT_(KDL.arg @Text)
+              _RETURN_((arg1, arg2))
+        decodeNode "foo" decoder config `shouldBe` Right (1, "bar")
+
+      it "decodes multiple arguments" $ do
+        let config = "foo 1 2 3"
+            decoder = _DO_
+              _STMT_(KDL.many $ KDL.arg @Int)
+        decodeNode "foo" decoder config `shouldBe` Right [1, 2, 3]
+
+      it "fails if argument doesn't exist" $ do
+        let config = "foo"
+            decoder = _DO_
+              _STMT_(KDL.arg @Int)
+        decodeNode "foo" decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0"
+            , "  Expected arg #0"
+            ]
+
+      it "fails if argument fails to parse" $ do
+        let config = "foo test"
+            decoder = _DO_
+              _STMT_(KDL.arg @Int)
+        decodeNode "foo" decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0 > arg #0"
+            , "  Expected number, got: test"
+            ]
+
+      it "fails if not all arguments are decoded" $ do
+        let config = "foo 1 2 3"
+            decoder = _DO_
+              _STMT_(KDL.arg @Int)
+        decodeNode "foo" decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0"
+            , "  Unexpected arg #1: 2"
+            ]
+
+    -- Most behaviors tested with `arg`
+    describe "argWith" $ do
+      it "decodes an argument" $ do
+        let config = "foo bar"
+            decoder = _DO_
+              _STMT_(KDL.argWith KDL.string)
+        decodeNode "foo" decoder config `shouldBe` Right "bar"
+
+    -- Most behaviors tested with `argWith`
+    describe "argWith'" $ do
+      it "decodes argument with an annotation" $ do
+        let testCases =
+              [ []
+              , ["test"]
+              , ["test", "other"]
+              ]
+        forM_ testCases $ \anns -> do
+          let config = "foo (test)a"
+              decoder = _DO_
+                _STMT_(_APOS_(KDL.argWith) anns KDL.string)
+          decodeNode "foo" decoder config `shouldBe` Right "a"
+
+      it "decodes argument without an annotation" $ do
+        let testCases =
+              [ []
+              , ["test"]
+              , ["test", "other"]
+              ]
+        forM_ testCases $ \anns -> do
+          let config = "foo a"
+              decoder = _DO_
+                _STMT_(_APOS_(KDL.argWith) anns KDL.string)
+          decodeNode "foo" decoder config `shouldBe` Right "a"
+
+      it "fails when argument has unexpected annotation" $ do
+        let config = "foo (test)a"
+            decoder = _DO_
+              _STMT_(_APOS_(KDL.argWith) ["VAL"] KDL.string)
+        decodeNode "foo" decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0 > arg #0"
+            , "  Expected annotation to be one of [\"VAL\"], got: test"
+            ]
+
+      it "supports backtracking annotations" $ do
+        let config = "foo (l)1 (r)2"
+            decodeArg =
+              KDL.oneOf
+                [ Left <$> KDL.argWith' ["l"] KDL.number
+                , Right <$> KDL.argWith' ["r"] KDL.number
+                ]
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.nodeWith "foo" . KDL.many $ decodeArg)
+        KDL.decodeWith decoder config `shouldBe` Right [Left 1, Right 2]
+
+    describe "prop" $ do
+      it "decodes a prop" $ do
+        let config = "foo test1=1 test2=hello"
+            decoder = _DO_
+              prop1 <- _STMT_(KDL.prop @Text "test2")
+              prop2 <- _STMT_(KDL.prop @Int "test1")
+              _RETURN_((prop1, prop2))
+        decodeNode "foo" decoder config `shouldBe` Right ("hello", 1)
+
+      it "can optionally decode a prop" $ do
+        let config = "foo a=1"
+            decoder = _DO_
+              a <- _STMT_(KDL.optional $ KDL.prop @Int "a")
+              b <- _STMT_(KDL.optional $ KDL.prop @Int "b")
+              _RETURN_((a, b))
+        decodeNode "foo" decoder config `shouldBe` Right (Just 1, Nothing)
+
+      it "decodes last prop" $ do
+        let config = "foo test=1 test=2"
+            decoder = _DO_
+              _STMT_(KDL.prop @Int "test")
+        decodeNode "foo" decoder config `shouldBe` Right 2
+
+      it "fails if prop doesn't exist" $ do
+        let config = "foo 123"
+            decoder = _DO_
+              _STMT_(KDL.prop @Int "test")
+        decodeNode "foo" decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0"
+            , "  Expected prop: test"
+            ]
+
+      it "fails if prop fails to parse" $ do
+        let config = "foo hello=world"
+            decoder = _DO_
+              _STMT_(KDL.prop @Int "hello")
+        decodeNode "foo" decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0 > prop hello"
+            , "  Expected number, got: world"
+            ]
+
+      it "fails if not all props are decoded" $ do
+        let config = "foo a=1 b=2"
+            decoder = _DO_
+              _STMT_(KDL.prop @Int "a")
+        decodeNode "foo" decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0"
+            , "  Unexpected prop: b=2"
+            ]
+
+    -- Most behaviors tested with `prop`
+    describe "propWith" $ do
+      it "decodes a prop" $ do
+        let config = "foo a=1"
+            decoder = _DO_
+              _STMT_(KDL.propWith "a" KDL.number)
+        decodeNode "foo" decoder config `shouldBe` Right 1
+
+    -- Most behaviors tested with `propWith`
+    describe "propWith'" $ do
+      it "decodes prop with an annotation" $ do
+        let testCases =
+              [ []
+              , ["test"]
+              , ["test", "other"]
+              ]
+        forM_ testCases $ \anns -> do
+          let config = "foo a=(test)1"
+              decoder = _DO_
+                _STMT_(_APOS_(KDL.propWith) "a" anns KDL.number)
+          decodeNode "foo" decoder config `shouldBe` Right 1
+
+      it "decodes prop without an annotation" $ do
+        let testCases =
+              [ []
+              , ["test"]
+              , ["test", "other"]
+              ]
+        forM_ testCases $ \anns -> do
+          let config = "foo a=1"
+              decoder = _DO_
+                _STMT_(_APOS_(KDL.propWith) "a" anns KDL.number)
+          decodeNode "foo" decoder config `shouldBe` Right 1
+
+      it "fails when prop has unexpected annotation" $ do
+        let config = "foo a=(test)1"
+            decoder = _DO_
+              _STMT_(_APOS_(KDL.propWith) "a" ["VAL"] KDL.number)
+        decodeNode "foo" decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0 > prop a"
+            , "  Expected annotation to be one of [\"VAL\"], got: test"
+            ]
+
+    describe "remainingProps" $ do
+      it "decodes remaining props" $ do
+        let config = "foo a=1 b=2 c=3 b=4"
+            decoder = _DO_
+              _ <- _STMT_(KDL.prop @Int "a")
+              _STMT_(KDL.remainingProps @Int)
+        decodeNode "foo" decoder config
+          `shouldBe` (Right . Map.fromList) [("b", 4), ("c", 3)]
+
+      it "returns empty map if no props left" $ do
+        let config = "foo a=1"
+            decoder = _DO_
+              _ <- _STMT_(KDL.prop @Int "a")
+              _STMT_(KDL.remainingProps @Int)
+        decodeNode "foo" decoder config `shouldBe` Right Map.empty
+
+      it "fails if prop fails to parse" $ do
+        let config = "foo a=1 b=1 c=2 c=test"
+            decoder = _DO_
+              _ <- _STMT_(KDL.prop @Int "a")
+              _STMT_(KDL.remainingProps @Int)
+        decodeNode "foo" decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0 > prop c"
+            , "  Expected number, got: test"
+            ]
+
+    -- Most behaviors tested with `remainingProps`
+    describe "remainingPropsWith" $ do
+      it "decodes remaining props" $ do
+        let config = "foo a=1 b=2 c=3 b=4"
+            decoder = _DO_
+              _ <- _STMT_(KDL.prop @Int "a")
+              _STMT_(KDL.remainingPropsWith KDL.number)
+        decodeNode "foo" decoder config
+          `shouldBe` (Right . Map.fromList) [("b", 4), ("c", 3)]
+
+    -- Most behaviors tested with `remainingPropsWith`
+    describe "remainingPropsWith'" $ do
+      it "decodes props with an annotation" $ do
+        let testCases =
+              [ []
+              , ["test"]
+              , ["test", "other"]
+              ]
+        forM_ testCases $ \anns -> do
+          let config = "foo a=(test)1 b=(test)2"
+              decoder = _DO_
+                _STMT_(_APOS_(KDL.remainingPropsWith) anns KDL.number)
+          decodeNode "foo" decoder config
+            `shouldBe` (Right . Map.fromList) [("a", 1), ("b", 2)]
+
+      it "decodes props without an annotation" $ do
+        let testCases =
+              [ []
+              , ["test"]
+              , ["test", "other"]
+              ]
+        forM_ testCases $ \anns -> do
+          let config = "foo a=1 b=2"
+              decoder = _DO_
+                _STMT_(_APOS_(KDL.remainingPropsWith) anns KDL.number)
+          decodeNode "foo" decoder config
+            `shouldBe` (Right . Map.fromList) [("a", 1), ("b", 2)]
+
+      it "fails when prop has unexpected annotation" $ do
+        let config = "foo a=(VAL)1 b=(test)2"
+            decoder = _DO_
+              _STMT_(_APOS_(KDL.remainingPropsWith) ["VAL"] KDL.number)
+        decodeNode "foo" decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0 > prop b"
+            , "  Expected annotation to be one of [\"VAL\"], got: test"
+            ]
+
+    describe "children" $ do
+      it "decodes children" $ do
+        let config = "foo { bar test; }"
+            decoder = _DO_
+              _STMT_(fmap scrubFormat . KDL.children $ KDL.node @Node "bar")
+            expected =
+              Node
+                { ann = Nothing
+                , name = Identifier{value = "bar", ext = KDL.def}
+                , entries =
+                    [ Entry
+                        { name = Nothing
+                        , value = Value{ann = Nothing, data_ = String "test", ext = KDL.def}
+                        , ext = KDL.def
+                        }
+                    ]
+                , children = Nothing
+                , ext = KDL.def
+                }
+        decodeNode "foo" decoder config `shouldBe` Right expected
+
+      it "can be re-entered" $ do
+        let config = "foo { bar a; baz b; }"
+            decoder = _DO_
+              arg1 <- _STMT_(KDL.children $ KDL.argAt @Text "bar")
+              arg2 <- _STMT_(KDL.children $ KDL.argAt @Text "baz")
+              _RETURN_((arg1, arg2))
+        decodeNode "foo" decoder config `shouldBe` Right ("a", "b")
+
+      it "fails if not all children are decoded" $ do
+        let config = "foo { asdf; bar; }"
+            decoder = _DO_
+              _STMT_(KDL.children $ KDL.node @Node "bar")
+        decodeNode "foo" decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0"
+            , "  Unexpected node: asdf #0"
+            ]
+
+  describe "ValueDecoder" $ do
+    describe "any" $ do
+      it "decodes any value" $ do
+        let config = "foo 1.0 asdf #true"
+            decoder = KDL.document $ _DO_
+              _STMT_(map scrubFormat <$> KDL.argsAtWith "foo" KDL.any)
+            val data_ =
+              Value
+                { ann = Nothing
+                , data_ = data_
+                , ext = KDL.def
+                }
+        KDL.decodeWith decoder config
+          `shouldBe` Right [val $ Number 1, val $ String "asdf", val $ Bool True]
+
+    describe "string" $ do
+      it "decodes string value" $ do
+        let config = "foo asdf"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.argAtWith "foo" KDL.string)
+        KDL.decodeWith decoder config `shouldBe` Right "asdf"
+
+      it "fails when value is not string" $ do
+        let config = "foo 1"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.argAtWith "foo" KDL.string)
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0 > arg #0"
+            , "  Expected string, got: 1"
+            ]
+
+    describe "number" $ do
+      it "decodes number value" $ do
+        let config = "foo 1"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.argAtWith "foo" KDL.number)
+        KDL.decodeWith decoder config `shouldBe` Right 1
+
+      it "fails when value is not number" $ do
+        let config = "foo asdf"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.argAtWith "foo" KDL.number)
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0 > arg #0"
+            , "  Expected number, got: asdf"
+            ]
+
+    describe "bool" $ do
+      it "decodes bool value" $ do
+        let config = "foo #true"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.argAtWith "foo" KDL.bool)
+        KDL.decodeWith decoder config `shouldBe` Right True
+
+      it "fails when value is not bool" $ do
+        let config = "foo 1"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.argAtWith "foo" KDL.bool)
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0 > arg #0"
+            , "  Expected bool, got: 1"
+            ]
+
+    describe "null" $ do
+      it "decodes null value" $ do
+        let config = "foo #null"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.argAtWith "foo" KDL.null)
+        KDL.decodeWith decoder config `shouldBe` Right ()
+
+      it "fails when value is not null" $ do
+        let config = "foo 1"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.argAtWith "foo" KDL.null)
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0 > arg #0"
+            , "  Expected null, got: 1"
+            ]
+
+  describe "Combinators" $ do
+    describe "oneOf" $ do
+      it "decodes one of the options" $ do
+        let config = "foo 123 hello"
+            decodeVal = [Left <$> KDL.number, Right <$> KDL.string]
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.nodeWith "foo" . KDL.many . KDL.argWith $ KDL.oneOf decodeVal)
+        KDL.decodeWith decoder config `shouldBe` Right [Left 123, Right "hello"]
+
+      it "fails if none can be decoded" $ do
+        let config = "foo 123 hello"
+            decodeVal = [Left <$> KDL.number, Right <$> KDL.bool]
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.nodeWith "foo" . KDL.many . KDL.argWith $ KDL.oneOf decodeVal)
+        KDL.decodeWith decoder config
+          `shouldSatisfy` decodeErrorMsg
+            [ "At: foo #0 > arg #1"
+            , "  Expected bool, got: hello"
+            , "  Expected number, got: hello"
+            ]
+
+    describe "option" $ do
+      it "defaults to the given value" $ do
+        let config = "foo"
+            decoder = KDL.document $ _DO_
+              _STMT_(KDL.nodeWith "foo" $ KDL.option 123 $ KDL.arg @Int)
+        KDL.decodeWith decoder config `shouldBe` Right 123
+
+newtype MyNode = MyNode Int
+  deriving (Eq)
+
+instance KDL.DecodeNode MyNode where
+  validNodeTypeAnns _ = ["MyNode"]
+  nodeDecoder = _DO_
+    x <- _STMT_(KDL.arg)
+#ifdef IS_ARROW
+    if not (0 < x && x < 10)
+      then KDL.fail -< "Invalid argument: " <> (Text.pack . show) x
+      else returnA -< ()
+    returnA -< MyNode x
+#endif
+#ifdef IS_MONAD
+    unless (0 < x && x < 10) $ do
+      KDL.fail $ "Invalid argument: " <> (Text.pack . show) x
+    pure $ MyNode x
+#endif
+
+decodeNodeSpec :: Spec
+decodeNodeSpec = do
+  describe "DecodeNode" $ do
+    it "decodes a custom node" $ do
+      let config = "foo 1"
+          decoder = KDL.document $ _DO_
+            _STMT_(KDL.node "foo")
+      KDL.decodeWith decoder config `shouldBe` Right (MyNode 1)
+
+    it "throws user-specified error" $ do
+      let config = "foo 100"
+          decoder = KDL.document $ _DO_
+            _STMT_(KDL.node @MyNode "foo")
+      KDL.decodeWith decoder config
+        `shouldSatisfy` decodeErrorMsg
+          [ "At: foo #0"
+          , "  Invalid argument: 100"
+          ]
+
+    it "decodes valid type ann" $ do
+      let config = "(MyNode)foo 1"
+          decoder = KDL.document $ _DO_
+            _STMT_(KDL.node "foo")
+      KDL.decodeWith decoder config `shouldBe` Right (MyNode 1)
+
+    it "fails on invalid type ann" $ do
+      let config = "(bad)foo 1"
+          decoder = KDL.document $ _DO_
+            _STMT_(KDL.node @MyNode "foo")
+      KDL.decodeWith decoder config
+        `shouldSatisfy` decodeErrorMsg
+          [ "At: foo #0"
+          , "  Expected annotation to be one of [\"MyNode\"], got: bad"
+          ]
+
+newtype MyVal = MyVal Double
+  deriving (Eq)
+
+instance KDL.DecodeValue MyVal where
+  validValueTypeAnns _ = ["MyVal"]
+  valueDecoder = KDL.withDecoder KDL.number $ \x -> do
+    unless (0 < x && x < 10) $ do
+      KDL.failM $ "Invalid value: " <> (Text.pack . show) x
+    pure $ MyVal (realToFrac x)
+
+decodeValueSpec :: Spec
+decodeValueSpec = do
+  describe "DecodeValue" $ do
+    it "decodes a custom value" $ do
+      let config = "foo 1"
+          decoder = KDL.document $ _DO_
+            _STMT_(KDL.argAt "foo")
+      KDL.decodeWith decoder config `shouldBe` Right (MyVal 1)
+
+    it "throws user-specified error" $ do
+      let config = "foo 100.0"
+          decoder = KDL.document $ _DO_
+            _STMT_(KDL.argAt @MyVal "foo")
+      KDL.decodeWith decoder config
+        `shouldSatisfy` decodeErrorMsg
+          [ "At: foo #0 > arg #0"
+          , "  Invalid value: 100.0"
+          ]
+
+    it "decodes valid type ann" $ do
+      let config = "foo (MyVal)1"
+          decoder = KDL.document $ _DO_
+            _STMT_(KDL.argAt "foo")
+      KDL.decodeWith decoder config `shouldBe` Right (MyVal 1)
+
+    it "fails on invalid type ann" $ do
+      let config = "foo (bad)1"
+          decoder = KDL.document $ _DO_
+            _STMT_(KDL.argAt @MyVal "foo")
+      KDL.decodeWith decoder config
+        `shouldSatisfy` decodeErrorMsg
+          [ "At: foo #0 > arg #0"
+          , "  Expected annotation to be one of [\"MyVal\"], got: bad"
+          ]
diff --git a/test/KDL/ParserSpec.hs b/test/KDL/ParserSpec.hs
--- a/test/KDL/ParserSpec.hs
+++ b/test/KDL/ParserSpec.hs
@@ -9,7 +9,7 @@
 import KDL qualified
 import Skeletest
 import Skeletest.Predicate qualified as P
-import System.Directory (findExecutable, listDirectory)
+import System.Directory (doesFileExist, findExecutable, listDirectory)
 import System.FilePath (takeExtension, (</>))
 import System.IO.Temp (withSystemTempDirectory)
 import System.IO.Unsafe (unsafePerformIO)
@@ -46,7 +46,7 @@
         -- tested in `parse`
         actual `shouldBe` expected
 
-  (if dotSlashInstalled then id else skip "dotslash not installed") . describe "kdl-test examples" $ do
+  (if runKdlTest then id else skip "cannot run kdl-test") . describe "kdl-test examples" $ do
     it "decodes correctly" $ do
       decoder <- findExecutable "kdl-hs-test-decoder" >>= maybe (error "Could not find kdl-hs-test-decoder") pure
       callProcess "tools/kdl-test" ["run", "--decoder", decoder]
@@ -61,5 +61,9 @@
           content <- Text.readFile (dir </> "valid" </> file)
           (fmap KDL.render . KDL.parse) content `shouldBe` Right content
 
-dotSlashInstalled :: Bool
-dotSlashInstalled = unsafePerformIO $ isJust <$> findExecutable "dotslash"
+runKdlTest :: Bool
+runKdlTest = unsafePerformIO $ do
+  -- Not included in sdist because sdist doesn't preserve executable perms
+  scriptExists <- doesFileExist "tools/kdl-test"
+  dotslashInstalled <- isJust <$> findExecutable "dotslash"
+  pure $ scriptExists && dotslashInstalled
diff --git a/tools/kdl-test b/tools/kdl-test
deleted file mode 100644
--- a/tools/kdl-test
+++ /dev/null
@@ -1,54 +0,0 @@
-#!/usr/bin/env dotslash
-{
-  "name": "kdl-test-0.2.0",
-  "platforms": {
-    "linux-x86_64": {
-      "size": 583741,
-      "hash": "sha256",
-      "digest": "1627ea50594c7c322ed5c7fb0d0063b2ac20a7a9460acc9363fa1777c92364c3",
-      "format": "tar.gz",
-      "path": "kdl-test",
-      "providers": [
-        {
-          "url": "https://github.com/brandonchinn178/kdl-test/releases/download/v0.2.0/kdl-test-0.2.0-linux-x86_64.tar.gz"
-        }
-      ]
-    },
-    "linux-aarch64": {
-      "size": 568646,
-      "hash": "sha256",
-      "digest": "f3ecbdb2225b6abc0d69a451be76eca4d095d835ee2e7830a880b4bfa051e6ee",
-      "format": "tar.gz",
-      "path": "kdl-test",
-      "providers": [
-        {
-          "url": "https://github.com/brandonchinn178/kdl-test/releases/download/v0.2.0/kdl-test-0.2.0-linux-arm64.tar.gz"
-        }
-      ]
-    },
-    "macos-x86_64": {
-      "size": 555773,
-      "hash": "sha256",
-      "digest": "2d66b5409889992554dbd9bf04c2817552b2c0416a2793204e2e4eea06bc4da4",
-      "format": "tar.gz",
-      "path": "kdl-test",
-      "providers": [
-        {
-          "url": "https://github.com/brandonchinn178/kdl-test/releases/download/v0.2.0/kdl-test-0.2.0-darwin-x86_64.tar.gz"
-        }
-      ]
-    },
-    "macos-aarch64": {
-      "size": 529715,
-      "hash": "sha256",
-      "digest": "abfcade1d1ce02810dc155b95ef0720e9c679d62cf50c7ab5400741909978eb2",
-      "format": "tar.gz",
-      "path": "kdl-test",
-      "providers": [
-        {
-          "url": "https://github.com/brandonchinn178/kdl-test/releases/download/v0.2.0/kdl-test-0.2.0-darwin-arm64.tar.gz"
-        }
-      ]
-    }
-  }
-}
