diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,4 @@
+
+## 1.0.0.0
+
+*   Initial release.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,28 @@
+BSD 3-Clause License
+
+Copyright (c) 2024, Dennis Gosnell
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived from
+   this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,134 @@
+# shebanger
+
+`shebanger` is a small CLI tool to transform a _boring_ shell script into an
+**exciting** series of shebang lines!
+
+`shebanger` takes a shell script as input, and outputs a series of shell
+scripts. Each of the output shell scripts only consists of a shebang line, they
+don't have any code in their body.  Executing the first output shell script will
+run your original input shell program.
+
+## Installation
+
+This section lists installation instructions for various Linux distros.
+
+### Generic Linux
+
+There is a statically-linked x86\_64 Linux ELF binary for `shebanger` available on each of
+the [GitHub Releases](https://github.com/cdepillabout/shebanger/releases).
+
+### Nix / NixOS
+
+Cloudy can be built with Nix by using the `.nix` files in this repo:
+
+```console
+$ nix-build
+```
+
+You can find the `shebanger` binary in `./result/bin/shebanger`.
+
+You can also install `shebanger` from Nixpkgs by using the `haskellPackages.shebanger`
+derivation:
+
+```console
+$ nix-build '<nixpkgs>' -A haskellPackages.shebanger
+```
+
+### Other Distros
+
+PRs are welcome adding instructions for installing Cloudy on other Linux
+distributions!
+
+## Usage
+
+`shebanger` assumes it will be available on your `PATH`.  You might see errors
+with the following commands if `shebanger` can't be found on your `PATH`.
+
+There is an example shell script to use with `shebanger` in this repo called
+[`test.sh`](./test.sh):
+
+```console
+$ cat test.sh
+#!/usr/bin/env bash
+
+echo "arguments to this script: $@"
+...
+```
+
+First, run `shebanger` on this script:
+
+```console
+$ shebanger ./test.sh
+```
+
+This outputs a bunch of scripts named like the following:
+
+```console
+$ ls test.sh.shebanged*
+test.sh.shebanged
+test.sh.shebanged.1
+test.sh.shebanged.2
+test.sh.shebanged.3
+test.sh.shebanged.4
+test.sh.shebanged.5
+...
+```
+
+Each of these scripts is just a single line, and they look like the following:
+
+```console
+$ cat ./test.sh.shebanged
+#!/usr/bin/env -S shebanger exec IyEvdXNyL2Jpbi9lbnYgYmFzaAoKZWNobyAiYXJndW1lbnRzIHRvIHRoaXMgc2NyaXA=
+```
+
+You can see that this is a script that only consists of a shebang line.  The
+contents of the original script are split into 50-byte chunks, base-64 encoded,
+passed within the shebang lines of the output scripts.
+
+If you run `./test.sh.shebanged`, the original `test.sh` will be reconstructed
+and executed:
+
+```console
+$ ./test.sh.shebanged hello this is an argument
+arguments to this script: hello this is an argument
+...
+```
+
+## How Does `shebanger` Work?
+
+`shebanger` works by base-64-encoding the original input script, and splitting
+it up into the shebang lines of a series of output scripts.  Each of these
+output scripts consist of only a shebang line.
+
+When running the output script, by executing each output script one-by-one in
+turn, `shebanger` collects the contents of the original script, reconstructs
+it, and executes it.
+
+`shebanger` actually doesn't require the input to be a script, it works on any
+executable.  However, see the following sections on limitations of this.
+
+## Limitations
+
+Currently `shebanger` collects the contents of the original scripts into an
+environment variable, and passes that along when `exec`'ing the next script.
+
+This means that `shebanger` is not able to execute scripts longer than a few
+hundred kilobytes, since they tend to overflow the max size of all environment
+variables.
+
+## Inspiration
+
+`shebanger` is inspired by [bangscript](https://github.com/viperML/bangscript)
+by [@viperML](https://github.com/viperML).  The `About` explanation of `bangscript` is:
+
+> Embed scripts in a shebang
+
+When I read this, I immediately thought it would be what `shebanger` currently
+is.  Since `bangscript` is instead something actually useful, I knew I needed to write
+`shebanger`.
+
+## FAQs
+
+1.  **Q: Why would someone ever actually use `shebanger`?**
+
+    A: ...
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,6 @@
+module Main where
+
+import Distribution.Extra.Doctest (defaultMainWithDoctests)
+
+main :: IO ()
+main = defaultMainWithDoctests "doctests"
diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,7 @@
+
+module Main where
+
+import Shebanger (defaultMain)
+
+main :: IO ()
+main = defaultMain
diff --git a/default.nix b/default.nix
new file mode 100644
--- /dev/null
+++ b/default.nix
@@ -0,0 +1,3 @@
+{...}:
+
+(import ./nix {}).shebanger-just-exe
diff --git a/nix/default.nix b/nix/default.nix
new file mode 100644
--- /dev/null
+++ b/nix/default.nix
@@ -0,0 +1,15 @@
+
+{...}:
+
+let
+  nixpkgs-src = builtins.fetchTarball {
+    # haskell-updates as of 2024-10-05
+    url = "https://github.com/NixOS/nixpkgs/archive/edd71fd02c1f9a5dee102e2df2281327244c0119.tar.gz";
+    sha256 = "03zs0ghm8kadgdpv9fgrppldnhxwsna3rcxy5wimjgs5y32jif6q";
+  };
+
+  my-overlay = import ./overlay.nix;
+
+in
+
+import nixpkgs-src { overlays = [ my-overlay ]; }
diff --git a/nix/overlay.nix b/nix/overlay.nix
new file mode 100644
--- /dev/null
+++ b/nix/overlay.nix
@@ -0,0 +1,121 @@
+final: prev: {
+
+  ###############################
+  ## Haskell package overrides ##
+  ###############################
+
+  myhaskell =
+    let
+      myHaskellOverride = oldAttrs: {
+        overrides =
+          final.lib.composeExtensions
+            (oldAttrs.overrides or (_: _: {}))
+            (hfinal: hprev: {
+              shebanger =
+                let
+                  filesToIgnore = [
+                    ".git"
+                    ".github"
+                    ".stack-work"
+                    ".travis.yml"
+                    "cabal.project"
+                    "default.nix"
+                    "flake.lock"
+                    "flake.nix"
+                    "nix"
+                    "result"
+                    "shell.nix"
+                    "stack-nightly.yaml"
+                    "stack.yaml"
+                  ];
+
+                  src =
+                    builtins.path {
+                      name = "shebanger-src";
+                      path = ./..;
+                      filter = path: type:
+                        with final.lib;
+                        ! elem (baseNameOf path) filesToIgnore &&
+                        ! any (flip hasPrefix (baseNameOf path)) [ "dist" ".ghc" ];
+                    };
+
+                  extraCabal2nixOptions = "";
+                in
+                hfinal.callCabal2nixWithOptions "shebanger" src extraCabal2nixOptions {};
+            });
+      };
+    in
+    prev.haskell // {
+      packages = prev.haskell.packages // {
+        ${final.shebanger-ghc-version} =
+          prev.haskell.packages.${final.shebanger-ghc-version}.override
+            myHaskellOverride;
+
+        native-bignum = prev.haskell.packages.native-bignum // {
+          ${final.shebanger-ghc-version} =
+            prev.haskell.packages.native-bignum.${final.shebanger-ghc-version}.override
+              myHaskellOverride;
+        };
+      };
+    };
+
+  shebanger-ghc-version-short = "948";
+
+  shebanger-ghc-version = "ghc" + final.shebanger-ghc-version-short;
+
+  shebanger-haskell-pkg-set = final.myhaskell.packages.${final.shebanger-ghc-version};
+
+  shebanger = final.shebanger-haskell-pkg-set.shebanger;
+
+  shebanger-just-exe =
+    final.lib.pipe
+      final.shebanger
+      [
+        # TODO: No optparse-applicative yet
+        # (final.shebanger-haskell-pkg-set.generateOptparseApplicativeCompletions ["shebanger"])
+        final.haskell.lib.justStaticExecutables
+      ];
+
+  shebanger-shell = final.shebanger-haskell-pkg-set.shellFor {
+    packages = hpkgs: [ hpkgs.shebanger ];
+  };
+
+  my-haskell-language-server =
+    final.haskell-language-server.override {
+      supportedGhcVersions = [ final.shebanger-ghc-version-short ];
+    };
+
+  dev-shell = final.mkShell {
+    nativeBuildInputs = [
+      final.cabal-install
+      final.my-haskell-language-server
+    ];
+    inputsFrom = [
+      final.shebanger-shell
+    ];
+    # These environment variables are important. Without these,
+    # doctest doesn't pick up nix's version of ghc, and will fail
+    # claiming it can't find your dependencies
+    shellHook = ''
+      export NIX_GHC="${final.shebanger-shell.NIX_GHC}"
+      export NIX_GHC_LIBDIR="${final.shebanger-shell.NIX_GHC_LIBDIR}"
+    '';
+  };
+
+  ##############################
+  ## Statically-linked builds ##
+  ##############################
+
+  shebanger-static-haskell-pkg-set = final.pkgsStatic.myhaskell.packages.native-bignum.${final.shebanger-ghc-version};
+
+  shebanger-static = final.shebanger-static-haskell-pkg-set.shebanger;
+
+  shebanger-static-just-exe =
+    final.lib.pipe
+      final.shebanger-static
+      [
+        (final.shebanger-static-haskell-pkg-set.generateOptparseApplicativeCompletions ["shebanger"])
+        final.haskell.lib.justStaticExecutables
+      ];
+}
+
diff --git a/shebanger.cabal b/shebanger.cabal
new file mode 100644
--- /dev/null
+++ b/shebanger.cabal
@@ -0,0 +1,117 @@
+cabal-version:       3.4
+
+name:                shebanger
+version:             1.0.0.0
+synopsis:            Transform a shell script into a series of scripts with only shebang lines
+-- description:         CLI tool
+homepage:            https://github.com/cdepillabout/shebanger
+license:             BSD-3-Clause
+license-file:        LICENSE
+author:              Dennis Gosnell
+maintainer:          cdep.illabout@gmail.com
+copyright:           2024-2024 Dennis Gosnell
+category:            Productivity
+build-type:          Custom
+extra-source-files:  README.md
+                   , CHANGELOG.md
+                   , default.nix
+                   , nix/default.nix
+                   , nix/overlay.nix
+                   , shell.nix
+
+custom-setup
+                   -- Hackage apparently gives an error if you don't specify an
+                   -- upper bound on your setup-depends. But I don't want to
+                   -- have to remember to bump the upper bound of each of these
+                   -- setup depends every time I want to make a release of shebanger,
+                   -- especially sense I already have upper bounds on important things
+                   -- like base below.
+  setup-depends:     base <999
+                   , cabal-doctest >=1.0.2 && <1.1
+
+library
+  default-language:    Haskell2010
+  hs-source-dirs:      src
+  exposed-modules:     Shebanger
+                     , Shebanger.Cli
+  other-modules:       Paths_shebanger
+  autogen-modules:     Paths_shebanger
+  build-depends:       base < 5
+                     , base64-bytestring
+                     , bytestring
+                     , containers
+                     , directory
+                     , filepath
+                     , from-sum
+                     , optparse-applicative
+                     , pretty-simple
+                     , process
+                     , text
+                     , time
+                     , unix
+  ghc-options:         -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates
+  default-extensions:  DataKinds
+                       DeriveDataTypeable
+                       DeriveFunctor
+                       DeriveGeneric
+                       DerivingStrategies
+                       DuplicateRecordFields
+                       GeneralizedNewtypeDeriving
+                       InstanceSigs
+                       LambdaCase
+                       MultiParamTypeClasses
+                       NamedFieldPuns
+                       NumericUnderscores
+                       OverloadedLabels
+                       OverloadedRecordDot
+                       OverloadedStrings
+                       PolyKinds
+                       RankNTypes
+                       RecordWildCards
+                       ScopedTypeVariables
+                       StandaloneKindSignatures
+                       TupleSections
+                       TypeApplications
+                       TypeFamilies
+                       TypeOperators
+  other-extensions:    DeriveAnyClass
+                       OverloadedRecordDot
+                       QuasiQuotes
+                       TemplateHaskell
+                       UndecidableInstances
+
+executable shebanger
+  default-language:    Haskell2010
+  main-is:             Main.hs
+  hs-source-dirs:      app
+  build-depends:       base < 5
+                     , shebanger
+  ghc-options:         -Wall -threaded -rtsopts -with-rtsopts=-N
+
+test-suite doctests
+  default-language:    Haskell2010
+  type:                exitcode-stdio-1.0
+  main-is:             DocTest.hs
+  hs-source-dirs:      test
+  build-depends:       base < 5
+                     , doctest
+                     , QuickCheck
+                     , template-haskell
+                     , shebanger
+  ghc-options:         -Wall
+
+test-suite shebanger-tests
+  default-language:    Haskell2010
+  type:                exitcode-stdio-1.0
+  main-is:             Test.hs
+  hs-source-dirs:      test
+  other-modules:       Test.Shebanger
+  build-depends:       base < 5
+                     , shebanger
+                     , tasty
+                     , tasty-hunit
+  ghc-options:         -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates -threaded -rtsopts -with-rtsopts=-N
+
+source-repository head
+  type:     git
+  location: git@github.com:cdepillabout/shebanger.git
diff --git a/shell.nix b/shell.nix
new file mode 100644
--- /dev/null
+++ b/shell.nix
@@ -0,0 +1,3 @@
+{...}:
+
+(import ./nix {}).dev-shell
diff --git a/src/Shebanger.hs b/src/Shebanger.hs
new file mode 100644
--- /dev/null
+++ b/src/Shebanger.hs
@@ -0,0 +1,208 @@
+module Shebanger where
+
+import Control.Exception (finally)
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as ByteString
+import Data.ByteString.Base64 (encode)
+import Data.Foldable (for_)
+import Data.List (isSuffixOf)
+import Data.List.NonEmpty (NonEmpty (..))
+import qualified Data.List.NonEmpty as NonEmpty
+import Data.Maybe (fromMaybe)
+import Shebanger.Cli (Command (..), ExecArgs (..), TranslateArgs (..), parseCliOpts)
+import System.Directory (doesFileExist, removeFile)
+import System.FilePath (takeFileName, (<.>), (-<.>))
+import System.Posix (setFileMode, fileMode, getFileStatus, unionFileModes, ownerExecuteMode, groupExecuteMode, otherExecuteMode)
+import System.Posix.Process (executeFile)
+import System.Posix.ByteString (getEnv, setEnv, unsetEnv)
+import System.Process (callProcess)
+import Text.Read (readMaybe)
+
+-- $setup
+--
+-- We need things from QuickCheck for some of the tests.
+--
+-- >>> import Test.QuickCheck
+
+defaultMain :: IO ()
+defaultMain = do
+  cmd <- parseCliOpts
+  runCmd cmd
+
+runCmd :: Command -> IO ()
+runCmd = \case
+  Translate transArgs -> runCmdTranslate transArgs
+  Exec execArgs -> runCmdExec execArgs
+
+-- | The length of chunks of the input script.
+--
+-- The input script will be chunked into parts of this many bytes, then base-64
+-- encoded (which increases the length by about 33%).  This base-64-encoded string
+-- will then be put into the shebang line of all the shebanged scripts.
+--
+-- Note that Linux has a limit on how long a shebang line can be, so in practice this
+-- has to be below 150 characters or so.
+inputScriptChunkLength :: Int
+inputScriptChunkLength = 50
+
+runCmdTranslate :: TranslateArgs -> IO ()
+runCmdTranslate transArgs = do
+  let inputScriptFileName = takeFileName transArgs.scriptFilePath
+  inputScriptContents <- ByteString.readFile transArgs.scriptFilePath
+  let chunkedInputScript = chunkByteString inputScriptChunkLength inputScriptContents
+      b64ChunkedInputScript = fmap encode chunkedInputScript
+  writeShebanged inputScriptFileName b64ChunkedInputScript
+
+writeShebanged :: String -> [ByteString] -> IO ()
+writeShebanged scriptName b64Chunks =
+  for_ (zip [0..] b64Chunks) $ \(i :: Int, chunk) -> do
+    let fnameBase = scriptName <.> "shebanged"
+        fname = if i == 0 then fnameBase else fnameBase <.> show i
+        scriptContents = "#!/usr/bin/env -S shebanger exec " <> chunk
+    ByteString.writeFile fname scriptContents
+    makeExecutable fname
+
+makeExecutable :: FilePath -> IO ()
+makeExecutable path = do
+    -- Get the current file permissions
+    status <- getFileStatus path
+    let currentMode = fileMode status
+    -- Define the new mode with executable permissions
+    let newMode = currentMode `unionFileModes` ownerExecuteMode `unionFileModes` groupExecuteMode `unionFileModes` otherExecuteMode
+    -- Set the file mode to the new mode
+    setFileMode path newMode
+
+chunkByteString :: Int -> ByteString -> [ByteString]
+chunkByteString n bs
+    | ByteString.null bs = []
+    | otherwise =
+        let (chunk, rest) = ByteString.splitAt n bs
+        in chunk : chunkByteString n rest
+
+runCmdExec :: ExecArgs -> IO ()
+runCmdExec execArgs = do
+  maybeShebangerScriptContents <- getEnv "SHEBANGER_SCRIPT_CONTENTS"
+  case getShebangedIndex execArgs.shebangScriptFilePath of
+    Left badIndex ->
+      error $
+        "ERROR! Failed when trying to parse the current script's " <>
+        "shebang index.  From file path \"" <>
+        execArgs.shebangScriptFilePath <>
+        "\", failed when trying to parse index: " <>
+        badIndex
+    Right idx -> do
+      maybeNextScript <- findNextScript execArgs.shebangScriptFilePath idx
+      case maybeNextScript of
+        -- There is a next script that exists.  Update env var and execute the
+        -- next script.
+        Just nextScript -> do
+          case maybeShebangerScriptContents of
+            Nothing ->
+              if idx == 0
+              then
+                -- This is the initial script (script.sh.shebanger), so we
+                -- expect that there is not yet a SHEBANGER_SCRIPT_CONTENTS
+                -- env var.  We create the env var with the initial part of
+                -- the script.
+                setEnv "SHEBANGER_SCRIPT_CONTENTS" execArgs.shebangScriptPart True
+              else
+                -- This is not the initial script (so it has a name like
+                -- script.sh.shebanger.07), but there is no
+                -- SHEBANGER_SCRIPT_CONTENTS env var.  This is unexpected.
+                error $
+                  "ERROR! This is not the first script, but no " <>
+                  "SHEBANGER_SCRIPT_CONTENTS env var was found."
+            Just envVarScriptContents ->
+              setEnv
+                "SHEBANGER_SCRIPT_CONTENTS"
+                (envVarScriptContents <> execArgs.shebangScriptPart)
+                True
+          -- exec the next script
+          executeFile nextScript True execArgs.additionalArgs Nothing
+        -- There is not a next script.  This script is the last script.
+        Nothing -> do
+          let fullScript =
+                fromMaybe "" maybeShebangerScriptContents <> execArgs.shebangScriptPart
+              finalScriptName =
+                if idx == 0
+                then execArgs.shebangScriptFilePath <.> "final"
+                else execArgs.shebangScriptFilePath -<.> "final"
+
+          ByteString.writeFile finalScriptName fullScript
+          makeExecutable finalScriptName
+
+          -- unset the SHEBANGER_SCRIPT_CONTENTS env var, since we don't want
+          -- it to be inherited by the child.
+          unsetEnv "SHEBANGER_SCRIPT_CONTENTS"
+
+          -- Call the new executable, making sure to unlink it afterwards.
+          finally
+            (callProcess finalScriptName execArgs.additionalArgs)
+            (removeFile finalScriptName)
+
+findNextScript :: FilePath -> Int -> IO (Maybe FilePath)
+findNextScript fp currIdx = do
+  let nextIdx = currIdx + 1
+      nextScriptName =
+        -- The initial script just ends with `.shebanged` (which we consider
+        -- index 0), so in that case just add ".1".  Otherwise, increment the
+        -- index by one.
+        if currIdx == 0
+        then fp <.> ".1"
+        else fp -<.> show nextIdx
+  exists <- doesFileExist nextScriptName
+  pure $ if exists then Just nextScriptName else Nothing
+
+-- | Returns 'Left' 'String' of the last part of the filename that it is trying
+-- to parse a number on error.
+--
+-- Returns 'Right' 'Int' with the index if it is found correctly.
+getShebangedIndex :: FilePath -> Either String Int
+getShebangedIndex fname =
+  if ".shebanged" `isSuffixOf` fname
+  -- The .shebanged file is the initial file that represents index 0
+  then Right 0
+  else do
+    -- The shebanged filenames are going to look like:
+    -- my-script.sh.shebanged.XX, where XX is a number.
+    let splitFname = split (== '.') fname
+        -- pull out just the number
+        strNum = NonEmpty.last splitFname
+        -- try to parse it as a number
+        maybeNum = readMaybe strNum
+    case maybeNum of
+      -- could not read file name part as number
+      Nothing -> Left strNum
+      Just num
+        -- the file name part should never be less than 1
+        | num < 1 -> Left strNum
+        | otherwise -> Right num
+
+-- | Split a list based on a predicate.
+--
+-- >>> split (== ' ') "hello world my name is bob"
+-- "hello" :| ["world","my","name","is","bob"]
+--
+-- A predicate of @'const' 'True'@ splits on everything, leaving you with
+-- a list of empty lists, with one more entry than your original list:
+--
+-- >>> split (const True) "bye"
+-- "" :| ["","",""]
+--
+-- A predicate of @'const' 'False'@ produces no splits:
+--
+-- >>> split (const False) "bye"
+-- "bye" :| []
+--
+-- An empty list doesn't get split, regardless of the predicate:
+--
+-- prop> \(Fun _ f) -> split f "" == ("" :| [])
+split :: forall a. (a -> Bool) -> [a] -> NonEmpty [a]
+split _ [] = [] :| []
+split p t = loop t
+  where
+    loop :: [a] -> NonEmpty [a]
+    loop s
+      | null s'   = l :| []
+      | otherwise = NonEmpty.cons l $ loop (tail s')
+      where (l, s') = break p s
diff --git a/src/Shebanger/Cli.hs b/src/Shebanger/Cli.hs
new file mode 100644
--- /dev/null
+++ b/src/Shebanger/Cli.hs
@@ -0,0 +1,127 @@
+module Shebanger.Cli
+  ( parseCliOpts
+  , Command (..)
+  , TranslateArgs (..)
+  , ExecArgs (..)
+  ) where
+
+import Data.ByteString (ByteString)
+import qualified Data.ByteString.Char8 as ByteString.Char8
+import Data.ByteString.Base64 (decode)
+import Data.Version (showVersion)
+import Control.Applicative ((<**>), Alternative (many, (<|>)))
+import Options.Applicative
+    ( action, argument, command, eitherReader, fullDesc, header, help, info,
+      metavar, progDesc, strArgument, execParser, helper, hsubparser,
+      simpleVersioner, CommandFields, Mod, Parser, ParserInfo
+    )
+import Paths_shebanger (version)
+
+data TranslateArgs = TranslateArgs { scriptFilePath :: FilePath }
+  deriving stock Show
+
+data ExecArgs = ExecArgs
+  { shebangScriptPart :: ByteString
+    -- ^ The base64-encoded part of the original script.
+    --
+    -- Example: @\"ICAgZWNobyAkaQpkb25lCmVjaG8KCiMgTGlzdCBzeXN0ZW0gaW5mb3JtYXRpb24KdW4=\"@
+  , shebangScriptFilePath :: FilePath
+    -- ^ The path of this shebanged script.  This is normally passed
+    -- automatically by the kernel when calling a script with a shebang line.
+    --
+    -- Example: @\"./test.sh.shebanged.7\"@
+  , additionalArgs :: [String]
+    -- ^ Additional arguments that have been passed to the script on the command line.
+    --
+    -- Example @[\"-l\", \"-a\", \"somedirectory/\"]@
+  }
+  deriving stock Show
+
+data Command
+  = Translate TranslateArgs
+  | Exec ExecArgs
+  deriving stock Show
+
+parseCliOpts :: IO Command
+parseCliOpts = execParser cliCmdParserInfo
+
+cliCmdParserInfo :: ParserInfo Command
+cliCmdParserInfo =
+  info
+    ( cliCmdParser <**>
+      helper <**>
+      simpleVersioner (showVersion version)
+    )
+    ( fullDesc <>
+      header "shebanger - translate a shell script into a series of shebang lines"
+    )
+
+cliCmdParser :: Parser Command
+cliCmdParser = hsubparser (translateCommandMod <> execCommandMod) <|> translateCommandParser
+  where
+    translateCommandMod :: Mod CommandFields Command
+    translateCommandMod =
+      command
+        "translate"
+        ( info
+            translateCommandParser
+            (progDesc "Translate a shell script into a series of scripts with only shebang lines")
+        )
+
+    execCommandMod :: Mod CommandFields Command
+    execCommandMod =
+      command
+        "exec"
+        ( info
+            (fmap Exec execArgsParser)
+            (progDesc "Execute shebanged shell scripts")
+        )
+
+    translateCommandParser :: Parser Command
+    translateCommandParser = fmap Translate translateArgsParser
+
+translateArgsParser :: Parser TranslateArgs
+translateArgsParser = TranslateArgs <$> inputFileParser
+
+inputFileParser :: Parser FilePath
+inputFileParser =
+  strArgument
+    ( metavar "FILE" <>
+      help "Input shell scripts to shebang" <>
+      action "file"
+    )
+
+execArgsParser :: Parser ExecArgs
+execArgsParser =
+  ExecArgs
+    <$> shebangedScriptParser
+    <*> shebangScriptFilePathParser
+    <*> additionalArgsParser
+
+
+shebangedScriptParser :: Parser ByteString
+shebangedScriptParser =
+  argument
+    (eitherReader b64Reader)
+    ( metavar "SHEBANG_SCRIPT_PART" <>
+      help "Part of a shebang translated script file.  Expected to be in base 64."
+    )
+  where
+    b64Reader :: String -> Either String ByteString
+    b64Reader b64Str = decode $ ByteString.Char8.pack b64Str
+
+shebangScriptFilePathParser :: Parser FilePath
+shebangScriptFilePathParser =
+  strArgument
+    ( metavar "FILE" <>
+      help "Input shebanged script name.  Normally passed automatically by kernel." <>
+      action "file"
+    )
+
+additionalArgsParser :: Parser [String]
+additionalArgsParser =
+  many $
+    strArgument
+      ( metavar "ARG" <>
+        help "arguments to pass to the underlying script being called"
+      )
diff --git a/test/DocTest.hs b/test/DocTest.hs
new file mode 100644
--- /dev/null
+++ b/test/DocTest.hs
@@ -0,0 +1,12 @@
+
+module Main where
+
+import Build_doctests (flags, pkgs, module_sources)
+import Test.DocTest (doctest)
+
+main :: IO ()
+main = do
+  doctest args
+  where
+    args :: [String]
+    args = flags ++ pkgs ++ module_sources
diff --git a/test/Test.hs b/test/Test.hs
new file mode 100644
--- /dev/null
+++ b/test/Test.hs
@@ -0,0 +1,8 @@
+
+module Main where
+
+import Test.Shebanger (shebangerTests)
+import Test.Tasty (defaultMain)
+
+main :: IO ()
+main = defaultMain shebangerTests
diff --git a/test/Test/Shebanger.hs b/test/Test/Shebanger.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/Shebanger.hs
@@ -0,0 +1,11 @@
+
+module Test.Shebanger where
+
+import Test.Tasty (TestTree, testGroup)
+
+shebangerTests :: TestTree
+shebangerTests =
+  testGroup
+    "Shebanger"
+    [ -- foobarTest
+    ]
