diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c10a125360107b464dcf1743b216a387e4536f4..348a34dba88bbfffeaf72d9fc4d0d0d2cdcc856b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [Unreleased]
+### Changed
+- Moved all arguments into subcommands, for a less confusing end user experience
+
 ## [0.4.0] - 2022-08-08
 ### Added
 - Support for GlobRef definitions in a `pipeline.toml` file
diff --git a/Cargo.lock b/Cargo.lock
index 9b2200f444326575a8df0ede0fac5239ef54094f..9285e542d3c79017d981b10a52dd3d6bb9f4e081 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -25,20 +25,9 @@ dependencies = [
 
 [[package]]
 name = "anyhow"
-version = "1.0.58"
+version = "1.0.69"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb07d2053ccdbe10e2af2995a2f116c1330396493dc1269f6a91d0ae82e19704"
-
-[[package]]
-name = "atty"
-version = "0.2.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
-dependencies = [
- "hermit-abi",
- "libc",
- "winapi",
-]
+checksum = "224afbd727c3d6e4b90103ece64b8d1b67fbb1973b1046c2281eed3f3803f800"
 
 [[package]]
 name = "autocfg"
@@ -76,6 +65,12 @@ version = "1.4.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"
 
+[[package]]
+name = "cc"
+version = "1.0.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f"
+
 [[package]]
 name = "cfg-if"
 version = "1.0.0"
@@ -84,26 +79,24 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
 
 [[package]]
 name = "clap"
-version = "3.2.8"
+version = "4.1.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "190814073e85d238f31ff738fcb0bf6910cedeb73376c87cd69291028966fd83"
+checksum = "f13b9c79b5d1dd500d20ef541215a6423c75829ef43117e1b4d17fd8af0b5d76"
 dependencies = [
- "atty",
  "bitflags",
  "clap_derive",
  "clap_lex",
- "indexmap",
+ "is-terminal",
  "once_cell",
  "strsim",
  "termcolor",
- "textwrap",
 ]
 
 [[package]]
 name = "clap_derive"
-version = "3.2.7"
+version = "4.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "759bf187376e1afa7b85b959e6a664a3e7a95203415dba952ad19139e798f902"
+checksum = "684a277d672e91966334af371f1a7b5833f9aa00b07c84e92fbce95e00208ce8"
 dependencies = [
  "heck",
  "proc-macro-error",
@@ -114,9 +107,9 @@ dependencies = [
 
 [[package]]
 name = "clap_lex"
-version = "0.2.4"
+version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5"
+checksum = "783fe232adfca04f90f56201b26d79682d4cd2625e0bc7290b95123afe558ade"
 dependencies = [
  "os_str_bytes",
 ]
@@ -138,9 +131,9 @@ dependencies = [
 
 [[package]]
 name = "crossbeam-channel"
-version = "0.5.5"
+version = "0.5.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c02a4d71819009c192cf4872265391563fd6a84c81ff2c0f2a7026ca4c1d85c"
+checksum = "c2dd04ddaf88237dc3b8d8f9a3c1004b506b54b3313403944054d23c0870c521"
 dependencies = [
  "cfg-if",
  "crossbeam-utils",
@@ -148,9 +141,9 @@ dependencies = [
 
 [[package]]
 name = "crossbeam-deque"
-version = "0.8.1"
+version = "0.8.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6455c0ca19f0d2fbf751b908d5c55c1f5cbc65e03c4225427254b46890bdde1e"
+checksum = "715e8152b692bba2d374b53d4875445368fdf21a94751410af607a5ac677d1fc"
 dependencies = [
  "cfg-if",
  "crossbeam-epoch",
@@ -159,26 +152,24 @@ dependencies = [
 
 [[package]]
 name = "crossbeam-epoch"
-version = "0.9.9"
+version = "0.9.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07db9d94cbd326813772c968ccd25999e5f8ae22f4f8d1b11effa37ef6ce281d"
+checksum = "01a9af1f4c2ef74bb8aa1f7e19706bc72d03598c8a570bb5de72243c7a9d9d5a"
 dependencies = [
  "autocfg",
  "cfg-if",
  "crossbeam-utils",
  "memoffset",
- "once_cell",
  "scopeguard",
 ]
 
 [[package]]
 name = "crossbeam-utils"
-version = "0.8.10"
+version = "0.8.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d82ee10ce34d7bc12c2122495e7593a9c41347ecdd64185af4ecf72cb1a7f83"
+checksum = "4fb766fa798726286dbbb842f174001dab8abc7b627a1dd86e0b7222a95d929f"
 dependencies = [
  "cfg-if",
- "once_cell",
 ]
 
 [[package]]
@@ -219,23 +210,44 @@ checksum = "e412cd91a4ec62fcc739ea50c40babe21e3de60d69f36393cce377c7c04ead5a"
 
 [[package]]
 name = "either"
-version = "1.7.0"
+version = "1.8.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f107b87b6afc2a64fd13cac55fe06d6c8859f12d4b14cbcdd2c67d0976781be"
+checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91"
 
 [[package]]
 name = "env_logger"
-version = "0.9.0"
+version = "0.10.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b2cf0344971ee6c64c31be0d530793fba457d322dfec2810c453d0ef228f9c3"
+checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0"
 dependencies = [
- "atty",
  "humantime",
+ "is-terminal",
  "log",
  "regex",
  "termcolor",
 ]
 
+[[package]]
+name = "errno"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1"
+dependencies = [
+ "errno-dragonfly",
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "errno-dragonfly"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf"
+dependencies = [
+ "cc",
+ "libc",
+]
+
 [[package]]
 name = "exr"
 version = "1.4.2"
@@ -312,15 +324,15 @@ dependencies = [
 
 [[package]]
 name = "glam"
-version = "0.20.5"
+version = "0.22.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f43e957e744be03f5801a55472f593d43fabdebf25a4585db250f04d86b1675f"
+checksum = "12f597d56c1bd55a811a1be189459e8fad2bbc272616375602443bdfb37fa774"
 
 [[package]]
 name = "glob"
-version = "0.3.0"
+version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574"
+checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
 
 [[package]]
 name = "half"
@@ -342,13 +354,19 @@ checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9"
 
 [[package]]
 name = "hermit-abi"
-version = "0.1.19"
+version = "0.2.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
+checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7"
 dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "hermit-abi"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "856b5cb0902c2b6d65d5fd97dfa30f9b70c7538e770b98eab5ed52d8db923e01"
+
 [[package]]
 name = "humantime"
 version = "2.1.0"
@@ -394,11 +412,33 @@ dependencies = [
  "adler32",
 ]
 
+[[package]]
+name = "io-lifetimes"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1abeb7a0dd0f8181267ff8adc397075586500b81b28a73e8a0208b00fc170fb3"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "is-terminal"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e18b0a45d56fe973d6db23972bf5bc46f988a4a2385deac9cc29572f09daef"
+dependencies = [
+ "hermit-abi 0.3.0",
+ "io-lifetimes",
+ "rustix",
+ "windows-sys",
+]
+
 [[package]]
 name = "itoa"
-version = "1.0.2"
+version = "1.0.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d"
+checksum = "fad582f4b9e86b6caa621cabeb0963332d92eea04729ab12892c2533951e6440"
 
 [[package]]
 name = "jpeg-decoder"
@@ -438,9 +478,15 @@ checksum = "7efd1d698db0759e6ef11a7cd44407407399a910c774dd804c64c032da7826ff"
 
 [[package]]
 name = "libc"
-version = "0.2.126"
+version = "0.2.139"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836"
+checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f051f77a7c8e6957c0696eac88f26b0117e54f52d3fc682ab19397a8812846a4"
 
 [[package]]
 name = "lock_api"
@@ -469,9 +515,9 @@ checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
 
 [[package]]
 name = "memoffset"
-version = "0.6.5"
+version = "0.7.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce"
+checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4"
 dependencies = [
  "autocfg",
 ]
@@ -494,6 +540,15 @@ dependencies = [
  "getrandom",
 ]
 
+[[package]]
+name = "nom8"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae01545c9c7fc4486ab7debaf2aad7003ac19431791868fb2e8066df97fad2f8"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 name = "num-integer"
 version = "0.1.45"
@@ -537,19 +592,19 @@ dependencies = [
 
 [[package]]
 name = "num_cpus"
-version = "1.13.1"
+version = "1.15.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1"
+checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b"
 dependencies = [
- "hermit-abi",
+ "hermit-abi 0.2.6",
  "libc",
 ]
 
 [[package]]
 name = "once_cell"
-version = "1.13.0"
+version = "1.17.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "18a6dbe30758c9f83eb00cbea4ac95966305f5a7772f3f42ebfc7fc7eddbd8e1"
+checksum = "6f61fba1741ea2b3d6a1e3178721804bb716a68a6aeba1149b5d52e3d464ea66"
 
 [[package]]
 name = "os_str_bytes"
@@ -615,39 +670,37 @@ dependencies = [
 
 [[package]]
 name = "proc-macro2"
-version = "1.0.40"
+version = "1.0.51"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dd96a1e8ed2596c337f8eae5f24924ec83f5ad5ab21ea8e455d3566c69fbcaf7"
+checksum = "5d727cae5b39d21da60fa540906919ad737832fe0b1c165da3a34d6548c849d6"
 dependencies = [
  "unicode-ident",
 ]
 
 [[package]]
 name = "quote"
-version = "1.0.20"
+version = "1.0.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3bcdf212e9776fbcb2d23ab029360416bb1706b1aea2d1a5ba002727cbcab804"
+checksum = "8856d8364d252a14d474036ea1358d63c9e6965c8e5c1885c18f73d70bff9c7b"
 dependencies = [
  "proc-macro2",
 ]
 
 [[package]]
 name = "rayon"
-version = "1.5.3"
+version = "1.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bd99e5772ead8baa5215278c9b15bf92087709e9c1b2d1f97cdb5a183c933a7d"
+checksum = "6db3a213adf02b3bcfd2d3846bb41cb22857d131789e01df434fb7e7bc0759b7"
 dependencies = [
- "autocfg",
- "crossbeam-deque",
  "either",
  "rayon-core",
 ]
 
 [[package]]
 name = "rayon-core"
-version = "1.9.3"
+version = "1.10.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "258bcdb5ac6dad48491bb2992db6b7cf74878b0384908af124823d118c99683f"
+checksum = "356a0625f1954f730c0201cdab48611198dc6ce21f4acff55089b5a78e6e835b"
 dependencies = [
  "crossbeam-channel",
  "crossbeam-deque",
@@ -672,11 +725,25 @@ version = "0.6.27"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244"
 
+[[package]]
+name = "rustix"
+version = "0.36.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f43abb88211988493c1abb44a70efa56ff0ce98f233b7b276146f1f3f7ba9644"
+dependencies = [
+ "bitflags",
+ "errno",
+ "io-lifetimes",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
 [[package]]
 name = "ryu"
-version = "1.0.10"
+version = "1.0.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3f6f92acf49d1b98f7a81226834412ada05458b7364277387724a237f062695"
+checksum = "7b4b9743ed687d4b4bcedf9ff5eaa7398495ae14e61cba0a295704edbc7decde"
 
 [[package]]
 name = "scoped_threadpool"
@@ -692,18 +759,18 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
 
 [[package]]
 name = "serde"
-version = "1.0.138"
+version = "1.0.152"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1578c6245786b9d168c5447eeacfb96856573ca56c9d68fdcf394be134882a47"
+checksum = "bb7d1f0d3021d347a83e556fc4683dea2ea09d87bccdf88ff5c12545d89d5efb"
 dependencies = [
  "serde_derive",
 ]
 
 [[package]]
 name = "serde_derive"
-version = "1.0.138"
+version = "1.0.152"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "023e9b1467aef8a10fb88f25611870ada9800ef7e22afce356bb0d2387b6f27c"
+checksum = "af487d118eecd09402d70a5d72551860e788df87b464af30e5ea6a38c75c541e"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -712,15 +779,24 @@ dependencies = [
 
 [[package]]
 name = "serde_json"
-version = "1.0.82"
+version = "1.0.92"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "82c2c1fdcd807d1098552c5b9a36e425e42e9fbd7c6a37a8425f390f781f7fa7"
+checksum = "7434af0dc1cbd59268aa98b4c22c131c0584d2232f6fb166efb993e2832e896a"
 dependencies = [
  "itoa",
  "ryu",
  "serde",
 ]
 
+[[package]]
+name = "serde_spanned"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0efd8caf556a6cebd3b285caf480045fcc1ac04f6bd786b09a6f11af30c4fcf4"
+dependencies = [
+ "serde",
+]
+
 [[package]]
 name = "smallvec"
 version = "1.9.0"
@@ -744,9 +820,9 @@ checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
 
 [[package]]
 name = "syn"
-version = "1.0.98"
+version = "1.0.107"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c50aef8a904de4c23c788f104b7dddc7d6f79c647c7c8ce4cc8f73eb0ca773dd"
+checksum = "1f4064b5b16e03ae50984a5a8ed5d4f8803e6bc1fd170a3cda91a1be4b18e3f5"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -762,26 +838,20 @@ dependencies = [
  "winapi-util",
 ]
 
-[[package]]
-name = "textwrap"
-version = "0.15.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb"
-
 [[package]]
 name = "thiserror"
-version = "1.0.31"
+version = "1.0.38"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a"
+checksum = "6a9cd18aa97d5c45c6603caea1da6628790b37f7a34b6ca89522331c5180fed0"
 dependencies = [
  "thiserror-impl",
 ]
 
 [[package]]
 name = "thiserror-impl"
-version = "1.0.31"
+version = "1.0.38"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a"
+checksum = "1fb327af4685e4d03fa8cbcf1716380da910eeb2bb8be417e7f9fd3fb164f36f"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -810,18 +880,43 @@ dependencies = [
 
 [[package]]
 name = "toml"
-version = "0.5.9"
+version = "0.7.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7"
+checksum = "772c1426ab886e7362aedf4abc9c0d1348a979517efedfc25862944d10137af0"
 dependencies = [
  "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ab8ed2edee10b50132aed5f331333428b011c99402b5a534154ed15746f9622"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f35c303ea3e062b6131be4de16debe23860b9d3f2396658f13b7af1987fb473"
+dependencies = [
+ "indexmap",
+ "nom8",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
 ]
 
 [[package]]
 name = "unicode-ident"
-version = "1.0.1"
+version = "1.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5bd2fe26506023ed7b5e1e315add59d6f584c621d037f9368fea9cfb988f368c"
+checksum = "84a22b9f218b40614adcb3f4ff08b703773ad44fa9423e4e0d346d5db86e4ebc"
 
 [[package]]
 name = "version_check"
@@ -925,3 +1020,69 @@ name = "winapi-x86_64-pc-windows-gnu"
 version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.42.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2522491fbfcd58cc84d47aeb2958948c4b8982e9a2d8a2a35bbaed431390e7"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.42.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c9864e83243fdec7fc9c5444389dcbbfd258f745e7853198f365e3c4968a608"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.42.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c8b1b673ffc16c47a9ff48570a9d85e25d265735c503681332589af6253c6c7"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.42.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de3887528ad530ba7bdbb1faa8275ec7a1155a45ffa57c37993960277145d640"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.42.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf4d1122317eddd6ff351aa852118a2418ad4214e6613a50e0191f7004372605"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.42.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1040f221285e17ebccbc2591ffdc2d44ee1f9186324dd3e84e99ac68d699c45"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.42.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "628bfdf232daa22b0d64fdb62b09fcc36bb01f05a3939e20ab73aaf9470d0463"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.42.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "447660ad36a13288b1db4d4248e857b510e8c3a225c822ba4fb748c0aafecffd"
diff --git a/Cargo.toml b/Cargo.toml
index a9b05e19cb0a761cc14a08dcbaab759dd42cc72d..c03ac1044846e142e9257d156cfd45e6ec2dea07 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,19 +3,28 @@ name = "crunch"
 version = "0.4.0"
 edition = "2021"
 
+license = "GPL-3"
+description = "Command line asset manipulation, set up a pipeline once and run it against all of your files"
+authors = [
+	"Louis Capitanchik <louis@microhacks.co.uk>"
+]
+
 [dependencies]
-anyhow = "1.0.53"
-clap = { version = "3.0.14", features = ["derive"] }
+clap = { version = "4.1.4", features = ["derive"] }
+env_logger = "0.10.0"
+log = "0.4.17"
+
+anyhow = "1.0.69"
+thiserror = "1.0.38"
+
 deltae = "0.3.0"
-env_logger = "0.9.0"
-glam = "0.20.5"
+glam = "0.22.0"
 image = "0.24"
 lab = "0.11.0"
-log = "0.4.14"
-num-traits = "0.2.14"
-rayon = "1.5.1"
-thiserror = "1.0.30"
-serde = { version = "1.0.131", features = ["derive"] }
-toml = "0.5.9"
-serde_json = "1.0.81"
-glob = "0.3.0"
+
+num-traits = "0.2.15"
+rayon = "1.6.1"
+toml = "0.7.1"
+serde = { version = "1.0.152", features = ["derive"] }
+serde_json = "1.0.92"
+glob = "0.3.1"
diff --git a/src/cli_args.rs b/src/cli_args.rs
index f178bbbfd11564a5fc6ae8d5772dc7398d5f1cef..ddc5234a332d068443d38bb0dbe1fe0c0d00377a 100644
--- a/src/cli_args.rs
+++ b/src/cli_args.rs
@@ -1,165 +1,91 @@
 use clap::{Parser, Subcommand};
 use serde::{Deserialize, Serialize};
 
-use crate::commands::{
-	calculate_mapping, execute_pipeline, extrude, flip, palette, remap_image, rescale, rotate,
-	write_palette, FlipDirection, RotateDegree,
-};
+// use crate::commands::{calculate_mapping, execute_pipeline, extrude, flip, palette, remap_image, rescale, rotate, write_palette, FlipDirection, RotateDegree, Rotate};
+use crate::commands::{Extrude, Flip, Palette, Pipeline, Remap, Rotate, Scale};
 use crate::format::PaletteFormat;
 use crate::{load_image, Format};
 
-/// Image utilities for Advent
-#[derive(Parser, Debug, Clone)]
+/// Crunch is a set of utilities for quickly and easily processing a batch of files, either directly
+/// or by defining pipelines
+#[derive(Parser, Debug, Clone, Serialize, Deserialize)]
 #[clap(name = "Crunch")]
 #[clap(author = "Louis Capitanchik <louis@microhacks.co.uk>")]
-#[clap(version = "0.4.0")]
+#[clap(version = "0.5.0-beta.1")]
 #[clap(about, long_about = None)]
-pub struct Args {
-	/// The path to the spritesheet file
-	in_path: String,
-	/// The path to the output file
-	out_path: String,
-
-	/// Force Crunch to read the input file as a specific format
-	#[clap(short, long, arg_enum)]
-	format: Option<Format>,
-
-	#[clap(subcommand)]
-	command: CrunchCommand,
-}
-
-#[inline]
-pub fn u32_zero() -> u32 {
-	0
-}
-#[inline]
-pub fn u32_32() -> u32 {
-	32
-}
-#[inline]
-pub fn f32_one() -> f32 {
-	1.0
-}
-
-#[derive(Debug, Clone, Subcommand, Serialize, Deserialize)]
 #[serde(tag = "command", content = "params")]
-pub enum CrunchCommand {
-	/// Take each tile in an image and expand its borders by a given amount
-	#[clap(version = "0.3.0")]
-	Extrude {
-		/// The amount of horizontal padding to add between each sprite in the image
-		#[clap(long, default_value_t = 0)]
-		#[serde(default = "u32_zero")]
-		space_x: u32,
-		/// The amount of vertical padding to add between each sprite in the image
-		#[clap(long, default_value_t = 0)]
-		#[serde(default = "u32_zero")]
-		space_y: u32,
-		/// The amount of horizontal padding to add between each sprite in the image
-		#[clap(long, default_value_t = 0)]
-		#[serde(default = "u32_zero")]
-		pad_x: u32,
-		/// The amount of vertical padding to add between each sprite in the image
-		#[clap(long, default_value_t = 0)]
-		#[serde(default = "u32_zero")]
-		pad_y: u32,
-		/// The size of each tile in the spritesheet. Assumed to be square tiles
-		#[clap(short, long, default_value_t = 32)]
-		#[serde(default = "u32_32")]
-		tile_size: u32,
-		/// Use nearby pixels for padding
-		#[clap(short, long)]
-		#[serde(default)]
-		extrude: bool,
-	},
-	/// Create a palette file containing every distinct colour from the input image
-	#[clap(version = "0.3.0")]
-	Palette {
-		#[clap(short, long, arg_enum, default_value_t)]
-		#[serde(default)]
-		format: PaletteFormat,
-	},
-	/// Convert the colour space of an input image to the given palette
-	#[clap(version = "0.3.0")]
-	Remap {
-		/// The path to the palette file containing the output colours
-		palette_file: String,
-	},
-	/// Make an image larger or smaller
-	#[clap(version = "0.3.0")]
-	Scale {
-		/// The scale factor to use; numbers between 0-1 shrink the image; numbers > 1 enlarge
-		#[clap(long, default_value_t = 1.0)]
-		#[serde(default = "f32_one")]
-		factor: f32,
-	},
-	/// Apply a clockwise rotation to the image
-	#[clap(version = "0.3.0")]
-	Rotate {
-		/// How many 90 degree steps should this image be rotated by
-		#[clap(long, arg_enum)]
-		amount: RotateDegree,
-	},
-	/// Flip an image along one or both axis
-	#[clap(version = "0.3.0")]
-	Flip {
-		/// The axis along which the image should be flipped
-		#[clap(long, arg_enum)]
-		direction: FlipDirection,
-	},
-	/// Execute a pipeline file to run multiple commands on one or more images
-	#[clap(version = "0.3.0")]
-	Pipeline,
+pub enum Args {
+	#[clap(name = "rotate")]
+	#[serde(alias = "rotate")]
+	Rotate(Rotate),
+	#[clap(name = "extrude")]
+	#[serde(alias = "extrude")]
+	Extrude(Extrude),
+	#[clap(name = "palette")]
+	#[serde(alias = "palette")]
+	Palette(Palette),
+	#[clap(name = "scale")]
+	#[serde(alias = "scale")]
+	Scale(Scale),
+	#[clap(name = "flip")]
+	#[serde(alias = "flip")]
+	Flip(Flip),
+	#[clap(name = "remap")]
+	#[serde(alias = "remap")]
+	Remap(Remap),
+	#[clap(name = "remap")]
+	#[serde(alias = "remap")]
+	Pipeline(Pipeline),
 }
 
 impl Args {
 	pub fn run(&self) -> anyhow::Result<()> {
-		match &self.command {
-			CrunchCommand::Extrude {
-				extrude: ext,
-				pad_x,
-				pad_y,
-				space_x,
-				space_y,
-				tile_size,
-			} => {
-				let image = load_image(&self.in_path, self.format)?;
-				let output = extrude(image, *tile_size, *pad_x, *pad_y, *space_x, *space_y, *ext)?;
-				output.save(&self.out_path).map_err(anyhow::Error::from)
+		match &self {
+			Args::Rotate(rotate) => {
+				let image = load_image(&rotate.input, None)?;
+				let output = rotate.run(&image)?;
+				output
+					.save(rotate.output.as_str())
+					.map_err(anyhow::Error::from)
 			}
-			CrunchCommand::Palette { format } => {
-				let image = load_image(&self.in_path, self.format)?;
-				let output = palette(&image)?;
-				write_palette(output, *format, &self.out_path)
+			Args::Extrude(extrude) => {
+				let image = load_image(&extrude.input, None)?;
+				let output = extrude.run(&image)?;
+				output
+					.save(extrude.output.as_str())
+					.map_err(anyhow::Error::from)
 			}
-			CrunchCommand::Remap { palette_file } => {
-				let image_data = load_image(&self.in_path, self.format)?;
-				let palette_data = load_image(&palette_file, self.format)?;
-
-				let image_palette = palette(&image_data)?;
-				let target_palette = palette(&palette_data)?;
-
-				let mappings = calculate_mapping(&image_palette, &target_palette);
-				let output = remap_image(image_data, mappings)?;
-
-				output.save(&self.out_path).map_err(anyhow::Error::from)
+			Args::Palette(palette) => {
+				let image = load_image(&palette.input, None)?;
+				palette.run(&image)
 			}
-			CrunchCommand::Scale { factor } => {
-				let image = load_image(self.in_path.clone(), self.format)?;
-				let output = rescale(&image, *factor)?;
-				output.save(&self.out_path).map_err(anyhow::Error::from)
+			Args::Scale(scale) => {
+				let image = load_image(&scale.input, None)?;
+				let output = scale.run(&image)?;
+				output
+					.save(scale.output.as_str())
+					.map_err(anyhow::Error::from)
 			}
-			CrunchCommand::Rotate { amount } => {
-				let image = load_image(self.in_path.clone(), self.format)?;
-				let output = rotate(&image, *amount)?;
-				output.save(&self.out_path).map_err(anyhow::Error::from)
+			Args::Flip(flip) => {
+				let image = load_image(&flip.input, None)?;
+				let output = flip.run(&image)?;
+				output
+					.save(flip.output.as_str())
+					.map_err(anyhow::Error::from)
 			}
-			CrunchCommand::Flip { direction } => {
-				let image = load_image(self.in_path.clone(), self.format)?;
-				let output = flip(&image, *direction)?;
-				output.save(&self.out_path).map_err(anyhow::Error::from)
+			Args::Remap(remap) => {
+				let image_data = load_image(&remap.input, None)?;
+				let palette_data = load_image(&remap.palette, None)?;
+
+				let image_palette = Palette::extract_from(&image_data)?;
+				let target_palette = Palette::extract_from(&palette_data)?;
+
+				let mappings = Palette::calculate_mapping(&image_palette, &target_palette);
+				let output = Remap::remap_image(image_data, mappings)?;
+
+				output.save(&remap.output).map_err(anyhow::Error::from)
 			}
-			CrunchCommand::Pipeline => execute_pipeline(&self.in_path, &self.out_path),
+			Args::Pipeline(pipeline) => pipeline.execute(),
 		}
 	}
 }
diff --git a/src/commands/extrude.rs b/src/commands/extrude.rs
index 168e5057e48d0883a414787ee657576d7f2343de..4aa0a4776fcb5df3b82e5d005f50f7e9d77461c0 100644
--- a/src/commands/extrude.rs
+++ b/src/commands/extrude.rs
@@ -1,84 +1,122 @@
+use crate::utils::{OutputFormat, RgbaOutputFormat, SpriteData};
+use clap::Parser;
 use image::{GenericImage, GenericImageView, Pixel, Rgba, RgbaImage};
 use num_traits::cast::ToPrimitive;
+use serde::{Deserialize, Serialize};
 
-use crate::{OutputFormat, SpriteData};
+#[inline(always)]
+fn tile_size() -> u32 {
+	32
+}
 
-pub fn extrude(
-	image: impl GenericImage,
-	tile_size: u32,
-	pad_x: u32,
-	pad_y: u32,
+/// Take each tile in an image and expand its borders by a given amount. Optionally fill with
+/// nearby pixels instead of empty space
+#[derive(Parser, Serialize, Deserialize, Clone, Debug)]
+#[clap(author, version = "0.3.0")]
+pub struct Extrude {
+	/// The path to the spritesheet file
+	#[serde(default)]
+	pub input: String,
+	/// The path to write the extruded spritesheet
+	#[serde(default)]
+	pub output: String,
+
+	/// The amount of horizontal padding to add between each sprite in the image
+	#[clap(long, default_value_t = 0)]
+	#[serde(default)]
 	space_x: u32,
+	/// The amount of vertical padding to add between each sprite in the image
+	#[clap(long, default_value_t = 0)]
+	#[serde(default)]
 	space_y: u32,
-	_extrude: bool,
-) -> anyhow::Result<OutputFormat> {
-	log::info!(
-		"Image loaded with size {} x {}",
-		image.width(),
-		image.height()
-	);
+	/// The amount of horizontal padding to add between each sprite in the image
+	#[clap(long, default_value_t = 0)]
+	#[serde(default)]
+	pad_x: u32,
+	/// The amount of vertical padding to add between each sprite in the image
+	#[clap(long, default_value_t = 0)]
+	#[serde(default)]
+	pad_y: u32,
+	/// The size of each tile in the spritesheet. Assumed to be square tiles
+	#[clap(short, long, default_value_t = 32)]
+	#[serde(default = "tile_size")]
+	tile_size: u32,
+	/// Use nearby pixels for padding
+	#[clap(short, long)]
+	#[serde(default)]
+	extrude: bool,
+}
+
+impl Extrude {
+	pub fn run(&self, image: &impl GenericImage) -> anyhow::Result<RgbaOutputFormat> {
+		log::info!(
+			"Image loaded with size {} x {}",
+			image.width(),
+			image.height()
+		);
 
-	let columns = image.width() / tile_size;
-	let rows = image.height() / tile_size;
-	log::info!("Inferred sheet contains {} columns", columns);
-	log::info!("Inferred sheet contains {} rows", rows);
+		let columns = image.width() / self.tile_size;
+		let rows = image.height() / self.tile_size;
+		log::info!("Inferred sheet contains {} columns", columns);
+		log::info!("Inferred sheet contains {} rows", rows);
 
-	let mut views = Vec::with_capacity((columns * rows) as usize);
-	for x in 0..columns {
-		for y in 0..rows {
-			let img_x = x * tile_size;
-			let img_y = y * tile_size;
+		let mut views = Vec::with_capacity((columns * rows) as usize);
+		for x in 0..columns {
+			for y in 0..rows {
+				let img_x = x * self.tile_size;
+				let img_y = y * self.tile_size;
 
-			let payload = SpriteData {
-				data: image.view(img_x, img_y, tile_size, tile_size),
-				x: img_x,
-				y: img_y,
-				tx: x,
-				ty: y,
-				width: tile_size,
-				height: tile_size,
-			};
+				let payload = SpriteData {
+					data: image.view(img_x, img_y, self.tile_size, self.tile_size),
+					x: img_x,
+					y: img_y,
+					tx: x,
+					ty: y,
+					width: self.tile_size,
+					height: self.tile_size,
+				};
 
-			views.push(payload);
+				views.push(payload);
+			}
 		}
-	}
 
-	let new_width = (pad_x * 2 + space_x * columns) + image.width();
-	let new_height = (pad_y * 2 + space_y * rows) + image.height();
+		let new_width = (self.pad_x * 2 + self.space_x * columns) + image.width();
+		let new_height = (self.pad_y * 2 + self.space_y * rows) + image.height();
 
-	log::info!(
-		"Using new image width {} / height {}",
-		new_width,
-		new_height
-	);
-	let mut new_image = RgbaImage::new(new_width, new_height);
-	let (new_image_x, new_image_y, new_image_width, new_image_height) = new_image.bounds();
-	for x in new_image_x..new_image_width {
-		for y in new_image_y..new_image_height {
-			new_image.put_pixel(x, y, Rgba::from([0, 0, 0, 0]));
+		log::info!(
+			"Using new image width {} / height {}",
+			new_width,
+			new_height
+		);
+		let mut new_image = RgbaImage::new(new_width, new_height);
+		let (new_image_x, new_image_y, new_image_width, new_image_height) = new_image.bounds();
+		for x in new_image_x..new_image_width {
+			for y in new_image_y..new_image_height {
+				new_image.put_pixel(x, y, Rgba::from([0, 0, 0, 0]));
+			}
 		}
-	}
 
-	for sprite in views.iter() {
-		let (img_x, img_y, width, height) = sprite.data.bounds();
-		for x in 0..width {
-			for y in 0..height {
-				let pix = sprite.data.get_pixel(x, y).to_rgba();
-				let p = Rgba::from([
-					pix.0[0].to_u8().unwrap(),
-					pix.0[1].to_u8().unwrap(),
-					pix.0[2].to_u8().unwrap(),
-					pix.0[3].to_u8().unwrap(),
-				]);
+		for sprite in views.iter() {
+			let (img_x, img_y, width, height) = sprite.data.bounds();
+			for x in 0..width {
+				for y in 0..height {
+					let pix = sprite.data.get_pixel(x, y).to_rgba();
+					let p = Rgba::from([
+						pix.0[0].to_u8().unwrap(),
+						pix.0[1].to_u8().unwrap(),
+						pix.0[2].to_u8().unwrap(),
+						pix.0[3].to_u8().unwrap(),
+					]);
 
-				new_image.put_pixel(
-					pad_x + (sprite.tx * space_x) + img_x + x,
-					pad_y + (sprite.ty * space_y) + img_y + y,
-					p,
-				);
+					new_image.put_pixel(
+						self.pad_x + (sprite.tx * self.space_x) + img_x + x,
+						self.pad_y + (sprite.ty * self.space_y) + img_y + y,
+						p,
+					);
+				}
 			}
 		}
-	}
 
-	Ok(new_image)
+		Ok(new_image)
+	}
 }
diff --git a/src/commands/flip.rs b/src/commands/flip.rs
index 6343cca356401ff2c24d12b28a307078dc196a0d..787c1d72378fe5e93ccd39d22ef13e5c1c12087f 100644
--- a/src/commands/flip.rs
+++ b/src/commands/flip.rs
@@ -1,10 +1,9 @@
-use clap::ArgEnum;
+use crate::utils::TypedOutputFormat;
+use clap::{Parser, ValueEnum};
 use image::{imageops, GenericImage, Pixel};
 use serde::{Deserialize, Serialize};
 
-use crate::TypedOutputFormat;
-
-#[derive(Copy, Clone, Serialize, Deserialize, ArgEnum, Debug)]
+#[derive(Copy, Clone, Serialize, Deserialize, ValueEnum, Debug)]
 pub enum FlipDirection {
 	#[serde(rename = "vertical")]
 	Vertical,
@@ -14,19 +13,37 @@ pub enum FlipDirection {
 	Both,
 }
 
-pub fn flip<T: GenericImage>(image: &T, dir: FlipDirection) -> anyhow::Result<TypedOutputFormat<T>>
-where
-	T::Pixel: 'static,
-	<T::Pixel as Pixel>::Subpixel: 'static,
-{
-	use FlipDirection::*;
-	match dir {
-		Vertical => Ok(imageops::flip_vertical(image)),
-		Horizontal => Ok(imageops::flip_horizontal(image)),
-		Both => {
-			let mut image = imageops::flip_horizontal(image);
-			imageops::flip_vertical_in_place(&mut image);
-			Ok(image)
+/// Rotate an image clockwise by the given degree
+#[derive(Debug, Clone, Parser, Serialize, Deserialize)]
+#[clap(author, version = "0.3.0")]
+pub struct Flip {
+	/// The path to the spritesheet file
+	#[serde(default)]
+	pub input: String,
+	/// The path to write the extruded spritesheet
+	#[serde(default)]
+	pub output: String,
+
+	/// The axis along which the image should be flipped
+	#[clap(long, value_enum)]
+	direction: FlipDirection,
+}
+
+impl Flip {
+	pub fn run<T: GenericImage>(&self, image: &T) -> anyhow::Result<TypedOutputFormat<T>>
+	where
+		T::Pixel: 'static,
+		<T::Pixel as Pixel>::Subpixel: 'static,
+	{
+		use FlipDirection::*;
+		match self.direction {
+			Vertical => Ok(imageops::flip_vertical(image)),
+			Horizontal => Ok(imageops::flip_horizontal(image)),
+			Both => {
+				let mut image = imageops::flip_horizontal(image);
+				imageops::flip_vertical_in_place(&mut image);
+				Ok(image)
+			}
 		}
 	}
 }
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
index be37ccd3cea104aa778091a5b261140f0f252dda..f54759816d033750adb8144beb52bbb147f72e7c 100644
--- a/src/commands/mod.rs
+++ b/src/commands/mod.rs
@@ -6,10 +6,10 @@ mod remap;
 mod rotate;
 mod scale;
 
-pub use extrude::extrude;
-pub use flip::{flip, FlipDirection};
-pub use palette::{calculate_mapping, palette, write_palette, ColourMapping};
-pub use pipeline::execute_pipeline;
-pub use remap::remap_image;
-pub use rotate::{rotate, RotateDegree};
-pub use scale::rescale;
+pub use extrude::Extrude;
+pub use flip::Flip;
+pub use palette::Palette;
+pub use pipeline::Pipeline;
+pub use remap::Remap;
+pub use rotate::Rotate;
+pub use scale::Scale;
diff --git a/src/commands/palette.rs b/src/commands/palette.rs
index 201f0cf307984a8482e6bb124d2c574b69402104..0c489a8eb710d2ba516032f5665beab7510d5025 100644
--- a/src/commands/palette.rs
+++ b/src/commands/palette.rs
@@ -1,3 +1,4 @@
+use clap::Parser;
 use std::cmp::{min, Ordering};
 use std::collections::hash_map::RandomState;
 use std::collections::{HashMap, HashSet};
@@ -8,63 +9,16 @@ use std::path::Path;
 use deltae::{Delta, LabValue, DE2000};
 use image::{GenericImage, Pixel, Rgba};
 use num_traits::ToPrimitive;
+use serde::{Deserialize, Serialize};
 
 use crate::format::PaletteFormat;
 use crate::utils::{new_image, BasicRgba};
 
-pub type Palette = Vec<BasicRgba>;
-
-pub fn palette(image: &impl GenericImage) -> anyhow::Result<Palette> {
-	let mut colours = HashSet::new();
-	for (_, _, pixel) in image.pixels() {
-		let pixel = pixel.to_rgba();
-		colours.insert(Rgba::from([
-			pixel.0[0].to_u8().unwrap(),
-			pixel.0[1].to_u8().unwrap(),
-			pixel.0[2].to_u8().unwrap(),
-			pixel.0[3].to_u8().unwrap(),
-		]));
-	}
-
-	Ok(colours.iter().map(BasicRgba::from).collect())
-}
-
-struct HexStringValue(String);
-trait HexString {
-	fn as_hex_string(&self) -> HexStringValue;
-}
-impl HexString for Rgba<u8> {
-	fn as_hex_string(&self) -> HexStringValue {
-		HexStringValue(format!(
-			"{:02X}{:02X}{:02X}{:02X}",
-			self.0[0], self.0[1], self.0[2], self.0[3]
-		))
-	}
-}
-impl<T: HexString + Clone> HexString for &T {
-	fn as_hex_string(&self) -> HexStringValue {
-		(*self).clone().as_hex_string()
-	}
-}
-impl UpperHex for HexStringValue {
-	fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
-		f.write_str(&self.0.to_uppercase())
-	}
-}
-impl LowerHex for HexStringValue {
-	fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
-		f.write_str(&self.0.to_lowercase())
-	}
-}
+pub type PixelPalette = Vec<BasicRgba>;
+pub type ColourMapping = HashMap<BasicRgba, BasicRgba>;
 
-pub fn write_palette<T: AsRef<Path>>(
-	colours: Palette,
-	format: PaletteFormat,
-	outpath: T,
-) -> anyhow::Result<()> {
-	#[allow(clippy::redundant_clone)]
-	let mut sorted = colours.clone();
-	sorted.sort_by(|pa, pb| {
+pub fn sort_by_hue(palette: &mut PixelPalette) {
+	palette.sort_by(|pa, pb| {
 		let hue_a = pa.hue();
 		let hue_b = pb.hue();
 
@@ -78,74 +32,128 @@ pub fn write_palette<T: AsRef<Path>>(
 			Ordering::Equal
 		}
 	});
+}
 
-	match format {
-		PaletteFormat::Png => {
-			let num_colours = sorted.len();
-			let image_width = min(16, num_colours);
-			let image_height = if num_colours % 16 > 0 {
-				num_colours / image_width + 1
-			} else {
-				num_colours / image_width
-			};
-
-			let mut out_image = new_image(image_width as u32, image_height as u32);
-			for (idx, colour) in sorted.iter().enumerate() {
-				out_image.put_pixel(
-					(idx % image_width) as u32,
-					(idx / image_width) as u32,
-					Rgba::from(colour),
-				);
-			}
+/// Create a palette file containing every distinct colour from the input image
+#[derive(Parser, Clone, Serialize, Deserialize, Debug)]
+#[clap(author, version = "0.3.0")]
+pub struct Palette {
+	/// The path to the spritesheet file
+	#[serde(default)]
+	pub input: String,
+	/// The path to write the extruded spritesheet
+	#[serde(default)]
+	pub output: String,
+
+	/// The format for the palette file. PNG will output an image with colours, while TXT will
+	/// output a text file with one hex value per line
+	#[clap(short, long, value_enum, default_value_t)]
+	#[serde(default)]
+	format: PaletteFormat,
+}
 
-			out_image.save(outpath)?;
-		}
-		PaletteFormat::Txt => {
-			let mut file = std::fs::File::create(outpath)?;
-			for colour in sorted.iter() {
-				let line = format!("#{:X}\n", colour);
-				file.write_all(line.as_bytes())?;
-			}
-		}
+impl Palette {
+	pub fn run(&self, image: &impl GenericImage) -> anyhow::Result<()> {
+		let palette = Palette::extract_from(image)?;
+		self.write_palette(palette)
 	}
 
-	Ok(())
-}
+	pub fn extract_from(image: &impl GenericImage) -> anyhow::Result<PixelPalette> {
+		let mut colours = HashSet::new();
+		for (_, _, pixel) in image.pixels() {
+			let pixel = pixel.to_rgba();
+			colours.insert(Rgba::from([
+				pixel.0[0].to_u8().unwrap(),
+				pixel.0[1].to_u8().unwrap(),
+				pixel.0[2].to_u8().unwrap(),
+				pixel.0[3].to_u8().unwrap(),
+			]));
+		}
 
-pub type ColourMapping = HashMap<BasicRgba, BasicRgba>;
+		Ok(colours.iter().map(BasicRgba::from).collect())
+	}
 
-pub fn calculate_mapping(from: &Palette, to: &Palette) -> ColourMapping {
-	let colour_labs = Vec::from_iter(to.iter().map(LabValue::from));
-
-	let to_palette_vectors: HashMap<usize, &BasicRgba, RandomState> =
-		HashMap::from_iter(to.iter().enumerate());
-	let mut out_map: ColourMapping = HashMap::with_capacity(from.len());
-
-	for colour in from {
-		let closest = to_palette_vectors
-			.keys()
-			.fold(None, |lowest, idx| match lowest {
-				Some(num) => {
-					let current = colour_labs[*idx];
-					let previous: LabValue = colour_labs[num];
-
-					if colour.delta(current, DE2000) < colour.delta(previous, DE2000) {
-						Some(*idx)
-					} else {
-						Some(num)
-					}
+	pub fn write_palette(&self, mut colours: PixelPalette) -> anyhow::Result<()> {
+		sort_by_hue(&mut colours);
+
+		match self.format {
+			PaletteFormat::Png => {
+				let num_colours = colours.len();
+				let image_width = min(16, num_colours);
+				let image_height = if num_colours % 16 > 0 {
+					num_colours / image_width + 1
+				} else {
+					num_colours / image_width
+				};
+
+				let mut out_image = new_image(image_width as u32, image_height as u32);
+				for (idx, colour) in colours.iter().enumerate() {
+					out_image.put_pixel(
+						(idx % image_width) as u32,
+						(idx / image_width) as u32,
+						Rgba::from(colour),
+					);
 				}
-				None => Some(*idx),
-			});
 
-		match closest {
-			Some(idx) => match to_palette_vectors.get(&idx) {
-				Some(col) => {
-					out_map.insert(*colour, **col);
+				out_image.save(self.output.as_str())?;
+			}
+			PaletteFormat::Txt => {
+				let mut file = std::fs::File::create(self.output.as_str())?;
+				for colour in colours.iter() {
+					let line = format!("#{:X}\n", colour);
+					file.write_all(line.as_bytes())?;
 				}
-				None => {
-					println!("No matching vec for {} with col {:?}", idx, &colour);
+			}
+		}
+
+		Ok(())
+	}
+
+	pub fn calculate_mapping(from: &PixelPalette, to: &PixelPalette) -> ColourMapping {
+		let colour_labs = Vec::from_iter(to.iter().map(LabValue::from));
+
+		let to_palette_vectors: HashMap<usize, &BasicRgba, RandomState> =
+			HashMap::from_iter(to.iter().enumerate());
+		let mut out_map: ColourMapping = HashMap::with_capacity(from.len());
+
+		for colour in from {
+			let closest = to_palette_vectors
+				.keys()
+				.fold(None, |lowest, idx| match lowest {
+					Some(num) => {
+						let current = colour_labs[*idx];
+						let previous: LabValue = colour_labs[num];
+
+						if colour.delta(current, DE2000) < colour.delta(previous, DE2000) {
+							Some(*idx)
+						} else {
+							Some(num)
+						}
+					}
+					None => Some(*idx),
+				});
 
+			match closest {
+				Some(idx) => match to_palette_vectors.get(&idx) {
+					Some(col) => {
+						out_map.insert(*colour, **col);
+					}
+					None => {
+						log::warn!("No matching set for {} with col {:?}", idx, &colour);
+
+						out_map.insert(
+							*colour,
+							BasicRgba {
+								r: 0,
+								g: 0,
+								b: 0,
+								a: 0,
+							},
+						);
+					}
+				},
+				None => {
+					log::warn!("No closest for {:?}", &colour);
 					out_map.insert(
 						*colour,
 						BasicRgba {
@@ -156,21 +164,9 @@ pub fn calculate_mapping(from: &Palette, to: &Palette) -> ColourMapping {
 						},
 					);
 				}
-			},
-			None => {
-				println!("No closest for {:?}", &colour);
-				out_map.insert(
-					*colour,
-					BasicRgba {
-						r: 0,
-						g: 0,
-						b: 0,
-						a: 0,
-					},
-				);
 			}
 		}
-	}
 
-	out_map
+		out_map
+	}
 }
diff --git a/src/commands/pipeline.rs b/src/commands/pipeline.rs
index bf86883d9e24491bb658efc41a1c4923712ff274..724e336653ddfa1cbf0065978fee300dc7926729 100644
--- a/src/commands/pipeline.rs
+++ b/src/commands/pipeline.rs
@@ -1,3 +1,4 @@
+use clap::Parser;
 use std::collections::HashMap;
 use std::path::{Path, PathBuf};
 
@@ -5,10 +6,11 @@ use rayon::prelude::*;
 use serde::{Deserialize, Serialize};
 use thiserror::Error;
 
-use crate::cli_args::CrunchCommand;
+use crate::cli_args::Args;
+use crate::commands::{Palette, Remap};
 use crate::format::make_paths;
+use crate::load_image;
 use crate::utils::normalize_path;
-use crate::{commands, load_image};
 
 #[derive(Error, Debug)]
 pub enum PipelineError {
@@ -22,7 +24,7 @@ pub enum PipelineType {
 	Pipeline {
 		input_path: String,
 		output_path: String,
-		actions: Vec<CrunchCommand>,
+		actions: Vec<Args>,
 	},
 	Ref {
 		input_path: String,
@@ -38,7 +40,7 @@ pub enum PipelineType {
 
 #[derive(Clone, Debug, Serialize, Deserialize)]
 pub struct PipelineRef {
-	pub actions: Vec<CrunchCommand>,
+	pub actions: Vec<Args>,
 }
 
 #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -47,215 +49,132 @@ pub struct PipelineFile {
 	pub pipelines: Vec<PipelineType>,
 }
 
-pub fn execute_pipeline<IN: ToString, OUT: ToString>(
-	file_path: IN,
-	_outpath: OUT,
-) -> anyhow::Result<()> {
-	let path = std::env::current_dir().map(|path| path.join(file_path.to_string()))?;
-	let path_string = format!("{}", &path.display());
+/// Rotate an image clockwise by the given degree
+#[derive(Debug, Clone, Parser, Serialize, Deserialize)]
+#[clap(author, version = "0.3.0")]
+pub struct Pipeline {
+	/// The path to the pipeline definition file
+	#[serde(default)]
+	pub config: String,
+}
 
-	log::debug!("Trying to read from input file: {}", &path.display());
+macro_rules! result {
+	($value: expr) => {
+		match $value {
+			Ok(val) => val,
+			Err(e) => {
+				log::error!("{}", e);
+				return;
+			}
+		}
+	};
+}
 
-	if !&path_string.ends_with(".toml") && !&path_string.ends_with(".json") {
-		Err(PipelineError::FormatDetection)?;
-	}
+impl Pipeline {
+	pub fn execute(&self) -> anyhow::Result<()> {
+		let path = std::env::current_dir().map(|path| path.join(&self.config))?;
+		let path_string = format!("{}", &path.display());
 
-	let file_contents = std::fs::read(&path)?;
+		log::debug!("Trying to read from input file: {}", &path.display());
 
-	log::debug!("Found correct file type and read bytes, trying to parse");
-	let pipeline_data: PipelineFile = if path_string.ends_with(".toml") {
-		toml::from_slice(&file_contents)?
-	} else {
-		serde_json::from_slice(&file_contents)?
-	};
+		if !&path_string.ends_with(".toml") && !&path_string.ends_with(".json") {
+			Err(PipelineError::FormatDetection)?;
+		}
 
-	log::debug!("Expanding pipeline file into targets");
-	let base_path = PathBuf::from(path.parent().unwrap());
-	get_targets(base_path.clone(), &pipeline_data).for_each(
-		|(input_path, output_path, actions)| {
-			match make_paths(&output_path) {
-				Ok(_) => {}
-				Err(e) => {
-					log::error!("Failed to create target directory {}; {}", &output_path, e);
-					return;
-				}
-			}
+		let file_contents = std::fs::read(&path)?;
 
-			if actions.is_empty() {
-				match std::fs::copy(&input_path, &output_path) {
+		log::debug!("Found correct file type and read bytes, trying to parse");
+		let pipeline_data: PipelineFile = if path_string.ends_with(".toml") {
+			toml::from_str(String::from_utf8(file_contents)?.as_str())?
+		} else {
+			serde_json::from_slice(&file_contents)?
+		};
+
+		log::debug!("Expanding pipeline file into targets");
+		let base_path = PathBuf::from(path.parent().unwrap());
+		get_targets(base_path.clone(), &pipeline_data).for_each(
+			|(input_path, output_path, actions)| {
+				match make_paths(&output_path) {
 					Ok(_) => {}
 					Err(e) => {
-						log::error!("Failed to copy {} to {}; {}", input_path, output_path, e);
+						log::error!("Failed to create target directory {}; {}", &output_path, e);
+						return;
 					}
-				};
-				return;
-			}
+				}
 
-			let mut file = match load_image(&input_path, None) {
-				Ok(image) => image,
-				Err(e) => {
-					log::error!("Error loading {}; {:?}", &input_path, e);
+				if actions.is_empty() {
+					match std::fs::copy(&input_path, &output_path) {
+						Ok(_) => {}
+						Err(e) => {
+							log::error!("Failed to copy {} to {}; {}", input_path, output_path, e);
+						}
+					};
 					return;
 				}
-			};
 
-			log::debug!(
-				"Loaded {}, Executing {} actions",
-				&input_path,
-				actions.len()
-			);
-			let mut count = 1;
-			for step in actions {
-				match step {
-					CrunchCommand::Extrude {
-						tile_size,
-						space_y,
-						space_x,
-						pad_y,
-						pad_x,
-						extrude,
-					} => {
-						file = match commands::extrude(
-							file, tile_size, pad_x, pad_y, space_x, space_y, extrude,
-						) {
-							Ok(f) => f,
-							Err(e) => {
-								log::error!(
-									"Failed to extrude {} at step {}; {}",
-									input_path,
-									count,
-									e
-								);
-								return;
-							}
-						};
+				let mut file = result!(load_image(&input_path, None));
+
+				log::debug!(
+					"Loaded {}, Executing {} actions",
+					&input_path,
+					actions.len()
+				);
+
+				let mut count = 1;
+				for step in actions {
+					match step {
+						Args::Rotate(rotate) => {
+							file = result!(rotate.run(&file));
+						}
+						Args::Extrude(extrude) => {
+							file = result!(extrude.run(&file));
+						}
+						Args::Scale(scale) => {
+							file = result!(scale.run(&file));
+						}
+						Args::Flip(flip) => {
+							file = result!(flip.run(&file));
+						}
+						Args::Remap(remap) => {
+							let palette = result!(load_image(&remap.palette, None));
+							let image_palette = result!(Palette::extract_from(&file));
+							let target_palette = result!(Palette::extract_from(&palette));
+
+							let mappings =
+								Palette::calculate_mapping(&image_palette, &target_palette);
+							file = result!(Remap::remap_image(file, mappings));
+						}
+						_ => {}
 					}
-					CrunchCommand::Remap { palette_file } => {
-						let palette_data = match load_image(join(&base_path, &palette_file), None) {
-							Ok(p) => p,
-							Err(e) => {
-								log::error!(
-									"Failed to load palette {} at step {}; {:?}",
-									&palette_file,
-									count,
-									e
-								);
-								return;
-							}
-						};
-
-						let image_palette = match commands::palette(&file) {
-							Ok(ip) => ip,
-							Err(e) => {
-								log::error!(
-									"Failed to extract palette from {} at step {}; {}",
-									input_path,
-									count,
-									e
-								);
-								return;
-							}
-						};
 
-						let target_palette = match commands::palette(&palette_data) {
-							Ok(tp) => tp,
-							Err(e) => {
-								log::error!(
-									"Failed to extract palette from {} at step {}; {}",
-									&palette_file,
-									count,
-									e
-								);
-								return;
-							}
-						};
-
-						let mappings = commands::calculate_mapping(&image_palette, &target_palette);
-						file = match commands::remap_image(file, mappings) {
-							Ok(f) => f,
-							Err(e) => {
-								log::error!(
-									"Failed to remap {} at step {}; {}",
-									input_path,
-									count,
-									e
-								);
-								return;
-							}
-						};
-					}
-					CrunchCommand::Scale { factor } => {
-						file = match commands::rescale(&file, factor) {
-							Ok(f) => f,
-							Err(e) => {
-								log::error!(
-									"Failed to scale {} at step {}; {}",
-									input_path,
-									count,
-									e
-								);
-								return;
-							}
-						};
-					}
-					CrunchCommand::Rotate { amount } => {
-						file = match commands::rotate(&file, amount) {
-							Ok(f) => f,
-							Err(e) => {
-								log::error!(
-									"Failed to rotate {} by {:?} step(s); {}",
-									input_path,
-									amount,
-									e
-								);
-								return;
-							}
-						};
-					}
-					CrunchCommand::Flip { direction } => {
-						file = match commands::flip(&file, direction) {
-							Ok(f) => f,
-							Err(e) => {
-								log::error!(
-									"Failed to flip {} in the following direction: {:?}; {}",
-									input_path,
-									direction,
-									e
-								);
-								return;
-							}
-						};
-					}
-					CrunchCommand::Palette { .. } | CrunchCommand::Pipeline => continue,
+					count += 1;
 				}
 
-				count += 1;
-			}
-
-			let mut outer_target_path = PathBuf::from(&output_path);
-			outer_target_path.pop();
-
-			if let Err(e) = std::fs::create_dir(&outer_target_path) {
-				match e.kind() {
-					std::io::ErrorKind::AlreadyExists => { /* This is fine */ }
-					_ => log::error!(
-						"Failed to create containing directory {}; {}",
-						outer_target_path.to_string_lossy(),
-						e
-					),
+				let mut outer_target_path = PathBuf::from(&output_path);
+				outer_target_path.pop();
+
+				if let Err(e) = std::fs::create_dir(&outer_target_path) {
+					match e.kind() {
+						std::io::ErrorKind::AlreadyExists => { /* This is fine */ }
+						_ => log::error!(
+							"Failed to create containing directory {}; {}",
+							outer_target_path.to_string_lossy(),
+							e
+						),
+					}
 				}
-			}
 
-			match file.save(&output_path) {
-				Ok(_) => {}
-				Err(e) => {
-					log::error!("Failed to save to {}; {}", output_path, e);
+				match file.save(&output_path) {
+					Ok(_) => {}
+					Err(e) => {
+						log::error!("Failed to save to {}; {}", output_path, e);
+					}
 				}
-			}
-		},
-	);
+			},
+		);
 
-	Ok(())
+		Ok(())
+	}
 }
 
 fn join<T: AsRef<Path>>(root: &Path, rest: &T) -> String {
@@ -266,7 +185,7 @@ fn join<T: AsRef<Path>>(root: &Path, rest: &T) -> String {
 fn get_targets(
 	base_path: PathBuf,
 	pipeline_data: &PipelineFile,
-) -> impl ParallelIterator<Item = (String, String, Vec<CrunchCommand>)> + '_ {
+) -> impl ParallelIterator<Item = (String, String, Vec<Args>)> + '_ {
 	pipeline_data
 		.pipelines
 		.par_iter()
diff --git a/src/commands/remap.rs b/src/commands/remap.rs
index d1b554541cb73bfd9a7bacd7c6091642d55c9cff..8e34444dca8ab63bd79dd576593726c7e8adc6c7 100644
--- a/src/commands/remap.rs
+++ b/src/commands/remap.rs
@@ -1,43 +1,62 @@
+use crate::commands::palette::ColourMapping;
+use clap::Parser;
 use image::{GenericImage, Pixel, Rgba};
 use num_traits::ToPrimitive;
+use serde::{Deserialize, Serialize};
 
-use crate::commands::ColourMapping;
-use crate::utils::{new_image, BasicRgba};
-use crate::OutputFormat;
-
-pub fn remap_image(
-	image: impl GenericImage,
-	mappings: ColourMapping,
-) -> anyhow::Result<OutputFormat> {
-	let mut output = new_image(image.width(), image.height());
-	for (x, y, pixel) in image.pixels() {
-		let pixel = pixel.to_rgba();
-
-		if pixel.0[3].to_u8().unwrap() == 0 {
-			output.put_pixel(x, y, Rgba::from(BasicRgba::transparent()));
-			continue;
+use crate::utils::{new_image, BasicRgba, OutputFormat, TypedOutputFormat};
+
+/// Rotate an image clockwise by the given degree
+#[derive(Debug, Clone, Parser, Serialize, Deserialize)]
+#[clap(author, version = "0.3.0")]
+pub struct Remap {
+	/// The path to the spritesheet file
+	#[serde(default)]
+	pub input: String,
+	/// The path to write the extruded spritesheet
+	#[serde(default)]
+	pub output: String,
+
+	/// The path to the palette file containing the output colours
+	#[clap(short, long)]
+	pub palette: String,
+}
+
+impl Remap {
+	pub fn remap_image(
+		image: impl GenericImage,
+		mappings: ColourMapping,
+	) -> anyhow::Result<OutputFormat> {
+		let mut output = new_image(image.width(), image.height());
+		for (x, y, pixel) in image.pixels() {
+			let pixel = pixel.to_rgba();
+
+			if pixel.0[3].to_u8().unwrap() == 0 {
+				output.put_pixel(x, y, Rgba::from(BasicRgba::transparent()));
+				continue;
+			}
+
+			let data = Rgba::from([
+				pixel.0[0].to_u8().unwrap(),
+				pixel.0[1].to_u8().unwrap(),
+				pixel.0[2].to_u8().unwrap(),
+				pixel.0[3].to_u8().unwrap(),
+			]);
+			let basic = BasicRgba::from(data);
+
+			match mappings.get(&basic) {
+				Some(mapped_data) => output.put_pixel(
+					x,
+					y,
+					Rgba::from(BasicRgba {
+						a: basic.a,
+						..*mapped_data
+					}),
+				),
+				None => output.put_pixel(x, y, Rgba::from(BasicRgba::transparent())),
+			};
 		}
 
-		let data = Rgba::from([
-			pixel.0[0].to_u8().unwrap(),
-			pixel.0[1].to_u8().unwrap(),
-			pixel.0[2].to_u8().unwrap(),
-			pixel.0[3].to_u8().unwrap(),
-		]);
-		let basic = BasicRgba::from(data);
-
-		match mappings.get(&basic) {
-			Some(mapped_data) => output.put_pixel(
-				x,
-				y,
-				Rgba::from(BasicRgba {
-					a: basic.a,
-					..*mapped_data
-				}),
-			),
-			None => output.put_pixel(x, y, Rgba::from(BasicRgba::transparent())),
-		};
+		Ok(output)
 	}
-
-	Ok(output)
 }
diff --git a/src/commands/rotate.rs b/src/commands/rotate.rs
index bcb1c7e1dcd5b7734e84f12604808e8af5cbb43f..3531cb211e2c3d9625975aeda8c538ad22e86969 100644
--- a/src/commands/rotate.rs
+++ b/src/commands/rotate.rs
@@ -1,10 +1,9 @@
-use clap::ArgEnum;
+use crate::utils::TypedOutputFormat;
+use clap::{Parser, ValueEnum};
 use image::{imageops, GenericImage, Pixel};
 use serde::{Deserialize, Serialize};
 
-use crate::TypedOutputFormat;
-
-#[derive(Copy, Clone, Serialize, Deserialize, ArgEnum, Debug)]
+#[derive(Copy, Clone, Serialize, Deserialize, ValueEnum, Debug)]
 pub enum RotateDegree {
 	#[serde(rename = "90")]
 	One,
@@ -14,18 +13,33 @@ pub enum RotateDegree {
 	Three,
 }
 
-pub fn rotate<T: GenericImage>(
-	image: &T,
-	degree: RotateDegree,
-) -> anyhow::Result<TypedOutputFormat<T>>
-where
-	T::Pixel: 'static,
-	<T::Pixel as Pixel>::Subpixel: 'static,
-{
-	use RotateDegree::*;
-	match degree {
-		One => Ok(imageops::rotate90(image)),
-		Two => Ok(imageops::rotate180(image)),
-		Three => Ok(imageops::rotate270(image)),
+/// Rotate an image clockwise by the given degree
+#[derive(Debug, Clone, Parser, Serialize, Deserialize)]
+#[clap(author, version = "0.3.0")]
+pub struct Rotate {
+	/// The path to the spritesheet file
+	#[serde(default)]
+	pub input: String,
+	/// The path to write the extruded spritesheet
+	#[serde(default)]
+	pub output: String,
+
+	/// How many 90 degree steps should this image be rotated by
+	#[clap(short, long, value_enum)]
+	pub amount: RotateDegree,
+}
+
+impl Rotate {
+	pub fn run<T: GenericImage>(&self, image: &T) -> anyhow::Result<TypedOutputFormat<T>>
+	where
+		T::Pixel: 'static,
+		<T::Pixel as Pixel>::Subpixel: 'static,
+	{
+		use RotateDegree::*;
+		match self.amount {
+			One => Ok(imageops::rotate90(image)),
+			Two => Ok(imageops::rotate180(image)),
+			Three => Ok(imageops::rotate270(image)),
+		}
 	}
 }
diff --git a/src/commands/scale.rs b/src/commands/scale.rs
index 66b3ad69c04dd340fbfcbbfb26722052f5e79633..f9933fff342b7fc4b2f87d9ebaddd59f7a6f2262 100644
--- a/src/commands/scale.rs
+++ b/src/commands/scale.rs
@@ -1,20 +1,40 @@
+use crate::utils::TypedOutputFormat;
+use clap::Parser;
 use image::imageops::FilterType;
 use image::{imageops, GenericImage, Pixel};
+use serde::{Deserialize, Serialize};
 
-use crate::TypedOutputFormat;
+#[inline(always)]
+fn one() -> f32 {
+	1.0
+}
+
+/// Rotate an image clockwise by the given degree
+#[derive(Debug, Clone, Parser, Serialize, Deserialize)]
+#[clap(author, version = "0.3.0")]
+pub struct Scale {
+	/// The path to the spritesheet file
+	#[serde(default)]
+	pub input: String,
+	/// The path to write the extruded spritesheet
+	#[serde(default)]
+	pub output: String,
+
+	/// The scale factor to use; numbers between 0-1 shrink the image; numbers > 1 enlarge
+	#[clap(long, default_value_t = 1.0)]
+	#[serde(default = "one")]
+	factor: f32,
+}
 
-pub fn rescale<T: GenericImage>(image: &T, factor: f32) -> anyhow::Result<TypedOutputFormat<T>>
-where
-	T::Pixel: 'static,
-	<T::Pixel as Pixel>::Subpixel: 'static,
-{
-	let nwidth = (image.width() as f32 * factor) as u32;
-	let nheight = (image.height() as f32 * factor) as u32;
+impl Scale {
+	pub fn run<T: GenericImage>(&self, image: &T) -> anyhow::Result<TypedOutputFormat<T>>
+	where
+		T::Pixel: 'static,
+		<T::Pixel as Pixel>::Subpixel: 'static,
+	{
+		let width = (image.width() as f32 * self.factor) as u32;
+		let height = (image.height() as f32 * self.factor) as u32;
 
-	Ok(imageops::resize(
-		image,
-		nwidth,
-		nheight,
-		FilterType::Nearest,
-	))
+		Ok(imageops::resize(image, width, height, FilterType::Nearest))
+	}
 }
diff --git a/src/format.rs b/src/format.rs
index 82ee1a40dd2b46d5cdc08812e166dffb725fbaf9..eb70c53baf50de3efb046f2ccc43b33e37e40c27 100644
--- a/src/format.rs
+++ b/src/format.rs
@@ -1,21 +1,23 @@
 use std::path::Path;
 
-use clap::ArgEnum;
+use clap::ValueEnum;
 use image::io::Reader;
 use image::{ImageError, ImageFormat, RgbaImage};
 use serde::{Deserialize, Serialize};
 use thiserror::Error;
 
 #[derive(
-	Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ArgEnum, Debug, Serialize, Deserialize, Default,
+	Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug, Serialize, Deserialize, Default,
 )]
 pub enum PaletteFormat {
+	#[serde(alias = "txt")]
 	Txt,
 	#[default]
+	#[serde(alias = "png")]
 	Png,
 }
 
-#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ArgEnum, Debug)]
+#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
 pub enum Format {
 	Png,
 	Jpg,
@@ -49,11 +51,11 @@ pub enum ImageLoadingError {
 	ImageError(#[from] ImageError),
 }
 
-pub fn load_image<T: AsRef<Path>>(
-	path: T,
+pub fn load_image(
+	path: impl ToString,
 	format: Option<Format>,
 ) -> anyhow::Result<RgbaImage, ImageLoadingError> {
-	let mut image = Reader::open(path)?;
+	let mut image = Reader::open(path.to_string())?;
 	let file = match format {
 		Some(format) => {
 			image.set_format(format.as_image_format());
diff --git a/src/main.rs b/src/main.rs
index 71f8d0141448d571881ea45d2dc0badb402fe386..84689c5a8a19941421cc6eed9ac5c68a0678ffe3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -9,26 +9,6 @@ use image::{GenericImage, Rgba, SubImage};
 use crate::cli_args::Args;
 use crate::format::{load_image, Format};
 
-#[derive(Clone, Copy)]
-struct SpriteData<'a, T: GenericImage> {
-	pub data: SubImage<&'a T>,
-	#[allow(dead_code)]
-	pub x: u32,
-	#[allow(dead_code)]
-	pub y: u32,
-	pub tx: u32,
-	pub ty: u32,
-	#[allow(dead_code)]
-	pub width: u32,
-	#[allow(dead_code)]
-	pub height: u32,
-}
-
-pub type OutputFormat = image::ImageBuffer<Rgba<u8>, Vec<u8>>;
-#[allow(type_alias_bounds)]
-pub type TypedOutputFormat<T: image::GenericImage> =
-	image::ImageBuffer<T::Pixel, Vec<<T::Pixel as image::Pixel>::Subpixel>>;
-
 fn main() -> anyhow::Result<(), anyhow::Error> {
 	env_logger::Builder::from_env("LOG_LEVEL").init();
 	let args: Args = Args::parse();
diff --git a/src/utils.rs b/src/utils.rs
index deff07c28e32d587d03e3bd9466699afb73ba3b1..e5c952b57a2491e26586a01729a2db514d157097 100644
--- a/src/utils.rs
+++ b/src/utils.rs
@@ -3,11 +3,30 @@ use std::path::{Component, Path, PathBuf};
 
 use deltae::LabValue;
 use glam::Vec3;
-use image::{GenericImageView, Rgb, Rgba, RgbaImage};
+use image::{GenericImage, GenericImageView, Rgb, Rgba, RgbaImage, SubImage};
 use lab::Lab;
 use serde::{Deserialize, Serialize};
 
-use crate::OutputFormat;
+#[derive(Clone, Copy)]
+pub struct SpriteData<'a, T: GenericImage> {
+	pub data: SubImage<&'a T>,
+	#[allow(dead_code)]
+	pub x: u32,
+	#[allow(dead_code)]
+	pub y: u32,
+	pub tx: u32,
+	pub ty: u32,
+	#[allow(dead_code)]
+	pub width: u32,
+	#[allow(dead_code)]
+	pub height: u32,
+}
+
+pub type OutputFormat = image::ImageBuffer<Rgba<u8>, Vec<u8>>;
+#[allow(type_alias_bounds)]
+pub type TypedOutputFormat<T: GenericImage> =
+	image::ImageBuffer<T::Pixel, Vec<<T::Pixel as image::Pixel>::Subpixel>>;
+pub type RgbaOutputFormat = TypedOutputFormat<RgbaImage>;
 
 pub fn max_f32(a: f32, b: f32) -> f32 {
 	if a > b {
@@ -244,11 +263,32 @@ impl LowerHex for BasicRgba {
 	}
 }
 
-#[derive(Clone, Debug, Serialize, Deserialize)]
-pub struct Pipeline {
-	pub input_path: String,
-	pub output_path: String,
-	pub actions: Vec<crate::cli_args::CrunchCommand>,
+pub struct HexStringValue(String);
+pub trait HexString {
+	fn as_hex_string(&self) -> HexStringValue;
+}
+impl HexString for Rgba<u8> {
+	fn as_hex_string(&self) -> HexStringValue {
+		HexStringValue(format!(
+			"{:02X}{:02X}{:02X}{:02X}",
+			self.0[0], self.0[1], self.0[2], self.0[3]
+		))
+	}
+}
+impl<T: HexString + Clone> HexString for &T {
+	fn as_hex_string(&self) -> HexStringValue {
+		(*self).clone().as_hex_string()
+	}
+}
+impl UpperHex for HexStringValue {
+	fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+		f.write_str(&self.0.to_uppercase())
+	}
+}
+impl LowerHex for HexStringValue {
+	fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+		f.write_str(&self.0.to_lowercase())
+	}
 }
 
 pub fn normalize_path<T: AsRef<Path>>(path: T) -> PathBuf {