diff --git a/ChangeLog.md b/ChangeLog.md
new file mode 100644
--- /dev/null
+++ b/ChangeLog.md
@@ -0,0 +1,3 @@
+# 0.1.0.0
+
+* Initial release
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2015 FP Complete
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,18 @@
+# stackage-build-plan
+
+[![Build Status](https://travis-ci.org/fpco/stackage-build-plan.svg?branch=master)](https://travis-ci.org/fpco/stackage-build-plan)
+
+Calculate and print (in different formats) Stackage build plans. This includes
+generating bootstrap shell scripts, such as needed by cabal-install. In fact,
+that use case is [the primary
+motivation](https://github.com/fpco/stackage-server/issues/95) for releasing
+this code as its own package.
+
+## Why Stackage?
+
+This package takes advantage of the Stackage Nightly and LTS Haskell snapshots
+to calculate known-good build plans to display to users.
+
+## Future enhancements
+
+None planned
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/Stackage/BuildPlan.hs b/Stackage/BuildPlan.hs
new file mode 100644
--- /dev/null
+++ b/Stackage/BuildPlan.hs
@@ -0,0 +1,406 @@
+{-# LANGUAGE DeriveDataTypeable    #-}
+{-# LANGUAGE FlexibleContexts      #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE OverloadedStrings     #-}
+{-# LANGUAGE ScopedTypeVariables   #-}
+module Stackage.BuildPlan
+    ( Settings
+    , SnapshotSpec
+    , parseSnapshotSpec
+    , defaultSettings
+    , setMirror
+    , setSnapshot
+    , ShellCommands
+    , setShellCommands
+    , abstractCommands
+    , simpleCommands
+    , getBuildPlan
+    , toSimpleText
+    , toShellScript
+    , mkPackageName
+    ) where
+
+import           Control.Applicative         ((<|>))
+import qualified Control.Exception           as E
+import           Control.Monad               (forM_, unless, when)
+import           Control.Monad.Catch         (Exception, MonadThrow, throwM)
+import           Control.Monad.State.Strict  (MonadState, execStateT, get,
+                                              modify)
+import           Control.Monad.Writer.Strict (execWriter, tell)
+import           Data.Aeson                  (object, (.=))
+import qualified Data.Aeson                  as A
+import qualified Data.ByteString             as S
+import qualified Data.ByteString.Lazy        as L
+import           Data.Foldable               (Foldable)
+import qualified Data.Foldable               as F
+import           Data.Function               (fix)
+import           Data.Map                    (Map)
+import qualified Data.Map                    as Map
+import           Data.Monoid                 ((<>))
+import           Data.Set                    (Set)
+import qualified Data.Set                    as Set
+import           Data.Text                   (Text)
+import qualified Data.Text                   as T
+import           Data.Text.Read              (decimal)
+import           Data.Time                   (Day)
+import           Data.Typeable               (Typeable)
+import           Data.Version                (Version)
+import           Data.Yaml                   (decodeFileEither)
+import           Distribution.Package        (PackageName)
+import           Network.HTTP.Client         (Manager, brRead, httpLbs,
+                                              newManager, parseUrl,
+                                              responseBody, withResponse)
+import           Network.HTTP.Client.TLS     (tlsManagerSettings)
+import           Stackage.Types              (BuildPlan (..), Component (..),
+                                              DepInfo (..),
+                                              PackageConstraints (..),
+                                              PackagePlan (..), SystemInfo (..),
+                                              display, mkPackageName,
+                                              sdPackages, unFlagName)
+import           System.Directory            (createDirectoryIfMissing,
+                                              doesFileExist,
+                                              getAppUserDataDirectory,
+                                              renameFile)
+import           System.FilePath             ((<.>), (</>))
+import           System.IO                   (IOMode (WriteMode),
+                                              withBinaryFile)
+import           Text.Read                   (readMaybe)
+
+catchIO :: IO a -> (E.IOException -> IO a) -> IO a
+catchIO = E.catch
+
+-- | Parse a snapshot specification from the given @Text@.
+--
+-- Since 0.1.0.0
+parseSnapshotSpec :: MonadThrow m => Text -> m SnapshotSpec
+parseSnapshotSpec "lts" = return $ IncompleteSpec LTSNewest
+parseSnapshotSpec "nightly" = return $ IncompleteSpec NightlyNewest
+parseSnapshotSpec s
+    | Just t <- T.stripPrefix "nightly-" s
+    , Just d <- readMaybe $ T.unpack t = return $ CompleteSpec $ Nightly d
+parseSnapshotSpec s
+    | Just t <- T.stripPrefix "lts-" s
+    , Just x <- go t = return x
+  where
+    go t1 = do
+        Right (x, t2) <- Just $ decimal t1
+        if T.null t2
+            then return $ IncompleteSpec $ LTSMajor x
+            else do
+                t3 <- T.stripPrefix "." t2
+                Right (y, "") <- Just $ decimal t3
+                return $ CompleteSpec $ LTS x y
+parseSnapshotSpec s = throwM $ InvalidSnapshotSpec s
+
+data CompleteSpec
+    = Nightly !Day
+    | LTS !Int !Int
+instance Show CompleteSpec where
+    show (Nightly d) = "nightly-" ++ show d
+    show (LTS x y) = concat ["lts-", show x, ".", show y]
+data IncompleteSpec
+    = LTSMajor !Int
+    | LTSNewest
+    | NightlyNewest
+    deriving Show
+data SnapshotSpec
+    = CompleteSpec !CompleteSpec
+    | IncompleteSpec !IncompleteSpec
+    deriving Show
+
+resolveSpec :: Manager -> SnapshotSpec -> IO CompleteSpec
+resolveSpec _ (CompleteSpec x) = return x
+resolveSpec man (IncompleteSpec spec) = do
+    res <- httpLbs "https://www.stackage.org/download/lts-snapshots.json" man
+    let lbs = responseBody res
+    m <-
+        case A.eitherDecode' lbs of
+            Left e -> throwM $ InvalidSnapshotsJson lbs e
+            Right m -> return m
+    case Map.lookup key m of
+        Nothing -> throwM $ SpecNotResolved key m
+        Just val -> parseCompleteSpec val
+  where
+    key =
+        case spec of
+            LTSMajor m -> T.pack $ "lts-" ++ show m
+            LTSNewest -> "lts"
+            NightlyNewest -> "nightly"
+
+parseCompleteSpec :: MonadThrow m => Text -> m CompleteSpec
+parseCompleteSpec t =
+    maybe (throwM $ InvalidSpec t) return $
+        parseNightly <|> parseLts
+  where
+    parseNightly = do
+        d <- T.stripPrefix "nightly-" t
+        x <- readMaybe $ T.unpack d
+        Just $ Nightly x
+    parseLts = do
+        t1 <- T.stripPrefix "lts-" t
+        Right (x, t2) <- Just $ decimal t1
+        t3 <- T.stripPrefix "." t2
+        Right (y, "") <- Just $ decimal t3
+        Just $ LTS x y
+
+data BuildPlanException
+    = InvalidSnapshotsJson !L.ByteString !String
+    | SpecNotResolved !Text !(Map Text Text)
+    | InvalidSpec !Text
+    | PackageNotFound !PackageName
+    | InvalidSnapshotSpec !Text
+    deriving (Show, Typeable)
+instance Exception BuildPlanException
+
+-- | Settings affecting various functions in this module.
+--
+-- Since 0.1.0.0
+data Settings = Settings
+    { _snapshot   :: !SnapshotSpec
+    , _getManager :: !(IO Manager)
+    , _fullDeps   :: !Bool
+    -- ^ include test and benchmark deps
+    , _mirror     :: !Text
+    , _shellCmds  :: !ShellCommands
+    }
+
+-- | How to generate commands for shell output.
+--
+-- Since 0.1.0.0
+data ShellCommands = ShellCommands
+    { scFetch :: Text -> Text
+    -- ^ URL
+    , scUnpack :: Text -> Text
+    -- ^ Tarball
+    , scBuild :: Map Text Bool -> Text
+    -- ^ Flags
+    }
+
+-- | Use abstract commands like build_plan_fetch.
+--
+-- See: https://github.com/fpco/stackage-server/issues/95#issuecomment-97146188
+--
+-- Since 0.1.0.0
+abstractCommands :: ShellCommands
+abstractCommands = ShellCommands
+    { scFetch = ("build_plan_fetch " <>)
+    , scUnpack = ("build_plan_unpack " <>)
+    , scBuild = ("build_plan_build " <>) . showFlags
+    }
+
+-- | Use simple commands requiring no wrapper shell script
+--
+-- Since 0.1.0.0
+simpleCommands :: ShellCommands
+simpleCommands = ShellCommands
+    { scFetch = ("wget " <>)
+    , scUnpack = ("tar xf " <>)
+    , scBuild = \flags -> T.concat
+        [ T.concat
+            [ "runghc Setup configure --user --flags='"
+            , showFlags flags
+            , "'"
+            ]
+        , "\nrunghc Setup build"
+        , "\nrunghc Setup copy"
+        , "\nrunghc Setup register"
+        ]
+    }
+
+showFlags :: Map Text Bool -> Text
+showFlags =
+    T.unwords . map go . Map.toList
+  where
+    go (name, isOn) = (if isOn then id else (T.cons '-')) name
+
+
+-- | Set the shell commands when using shell formatting.
+--
+-- Default: 'abstractCommands'
+--
+-- Since 0.1.0.0
+setShellCommands :: ShellCommands -> Settings -> Settings
+setShellCommands x s = s { _shellCmds = x }
+
+-- | Default settings, to be tweaked via setter functions.
+--
+-- Since 0.1.0.0
+defaultSettings :: Settings
+defaultSettings = Settings
+    { _snapshot = IncompleteSpec LTSNewest
+    , _getManager = newManager tlsManagerSettings
+    , _fullDeps = False
+    , _mirror = "https://s3.amazonaws.com/hackage.fpcomplete.com/package/"
+    , _shellCmds = abstractCommands
+    }
+
+-- | Set the snapshot from which to pull the build plan.
+--
+-- Default: latest LTS release
+--
+-- Since 0.1.0.0
+setSnapshot :: SnapshotSpec -> Settings -> Settings
+setSnapshot x s = s { _snapshot = x }
+
+-- | Set the mirror prefix for tarball downloads (shell script only).
+--
+-- Default: "https://s3.amazonaws.com/hackage.fpcomplete.com/package/"
+--
+-- Since 0.1.0.0
+setMirror :: Text -> Settings -> Settings
+setMirror x s = s { _mirror = x }
+
+yamlFP :: Manager -> SnapshotSpec -> IO FilePath
+yamlFP man spec' = do
+    spec <- resolveSpec man spec'
+    root <- getAppUserDataDirectory "stackage"
+        `catchIO` \_ -> return "/tmp/.stackage" -- server does not set HOME
+    let dir = root </> "build-plan"
+        fp = dir </> show spec <.> "yaml"
+    exists <- doesFileExist fp
+    if exists
+        then return fp
+        else do
+            createDirectoryIfMissing True dir
+            let tmp = fp <.> "tmp"
+            download man spec tmp
+            renameFile tmp fp
+            return fp
+
+download :: Manager -> CompleteSpec -> FilePath -> IO ()
+download man spec dest = do
+    req <- parseUrl $ specUrl spec
+    withResponse req man $ \res -> withBinaryFile dest WriteMode $ \h ->
+        fix $ \loop -> do
+            bs <- brRead $ responseBody res
+            unless (S.null bs) $ S.hPut h bs >> loop
+
+specUrl :: CompleteSpec -> String
+specUrl (LTS x y) = concat
+    [ "https://raw.githubusercontent.com/fpco/lts-haskell/master/lts-"
+    , show x
+    , "."
+    , show y
+    , ".yaml"
+    ]
+specUrl (Nightly x) = concat
+    [ "https://raw.githubusercontent.com/fpco/stackage-nightly/master/nightly-"
+    , show x
+    , ".yaml"
+    ]
+
+data ToInstall = ToInstall
+    { tiPackage :: !PackageName
+    , tiVersion :: !Version
+    , tiIsCore  :: !Bool
+    , tiFlags   :: !(Map Text Bool)
+    }
+    deriving Show
+
+instance A.ToJSON ToInstall where
+    toJSON ti = object
+        [ "name" .= display (tiPackage ti)
+        , "version" .= display (tiVersion ti)
+        , "flags" .= tiFlags ti
+        , "is-core" .= tiIsCore ti
+        ]
+
+getBuildPlan :: Settings -> [PackageName] -> IO [ToInstall]
+getBuildPlan _ [] = return []
+getBuildPlan set packages = do
+    man <- _getManager set
+    fp <- yamlFP man $ _snapshot set
+    bp <- decodeFileEither fp >>= either throwM return
+    (_, front) <- execStateT (getDeps bp (_fullDeps set) packages) (Set.empty, id)
+    return $ front []
+
+type TheState =
+    ( Set PackageName
+    , DList ToInstall
+    )
+type DList a = [a] -> [a]
+
+getDeps :: forall f m. (MonadThrow m, MonadState TheState m, Foldable f)
+        => BuildPlan
+        -> Bool
+        -> f PackageName
+        -> m ()
+getDeps bp fullDeps =
+    F.mapM_ goName
+  where
+    goName :: PackageName -> m ()
+    goName name = do
+        (s, _) <- get
+        when (name `Set.notMember` s) $
+            case Map.lookup name $ bpPackages bp of
+                Just pkg -> goPkg name pkg
+                Nothing ->
+                    case Map.lookup name $ siCorePackages $ bpSystemInfo bp of
+                        Just version -> do
+                            addToSet name
+                            addToList name version Map.empty True
+                        Nothing -> throwM $ PackageNotFound name
+
+    goPkg :: PackageName -> PackagePlan -> m ()
+    goPkg name pp = do
+        addToSet name
+        forM_ (Map.toList $ sdPackages $ ppDesc pp) $ \(name', depInfo) ->
+            when (includeDep depInfo) (goName name')
+        addToList name (ppVersion pp)
+            (Map.mapKeysWith const unFlagName
+             $ pcFlagOverrides $ ppConstraints pp)
+            False
+
+    addToSet name = modify $ \(s, front) -> (Set.insert name s, front)
+
+    addToList name version flags isCore =
+        modify $ \(s, front) -> (s, front . (x:))
+      where
+        x = ToInstall
+            { tiPackage = name
+            , tiVersion = version
+            , tiFlags = flags
+            , tiIsCore = isCore
+            }
+
+    includeDep di =
+        fullDeps ||
+        CompLibrary    `Set.member` diComponents di ||
+        CompExecutable `Set.member` diComponents di
+
+toSimpleText :: [ToInstall] -> Text
+toSimpleText =
+    T.unlines . map go
+  where
+    go ti = T.unwords
+        [ display $ tiPackage ti
+        , display $ tiVersion ti
+        ]
+
+toShellScript :: Settings -> [ToInstall] -> Text
+toShellScript set packages = T.unlines $ ($ []) $ execWriter $ do
+    yield "#!/usr/bin/env bash\nset -eux\n"
+    forM_ packages $ \ti -> unless (tiIsCore ti) $ do
+        let prefix = T.concat
+                [ display $ tiPackage ti
+                , "-"
+                , display $ tiVersion ti
+                ]
+            tarball = prefix <> ".tar.gz"
+        mapM_ yield
+            [ ""
+            , T.concat
+                [ "rm -rf "
+                , prefix
+                , " "
+                , tarball
+                ]
+            , scFetch sc $ _mirror set <> tarball
+            , scUnpack sc tarball
+            , "cd " <> prefix
+            , scBuild sc $ tiFlags ti
+            , "cd .."
+            ]
+  where
+    yield x = tell (x:)
+    sc = _shellCmds set
diff --git a/app/stackage-build-plan.hs b/app/stackage-build-plan.hs
new file mode 100644
--- /dev/null
+++ b/app/stackage-build-plan.hs
@@ -0,0 +1,80 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TemplateHaskell   #-}
+import           Data.Aeson                (toJSON)
+import           Data.Aeson.Encode         (encodeToTextBuilder)
+import qualified Data.Text                 as T
+import qualified Data.Text.Lazy            as TL
+import           Data.Text.Lazy.Builder    (toLazyText)
+import qualified Data.Text.Lazy.IO         as TLIO
+import           Options.Applicative
+import           Paths_stackage_build_plan (version)
+import           Stackage.BuildPlan
+import           Stackage.CLI              (simpleOptions, simpleVersion)
+
+main :: IO ()
+main = do
+    ((set, render, packages), ()) <- simpleOptions
+        $(simpleVersion version)
+        "Calculate and print (in different formats) Stackage build plans"
+        "Calculate and print (in different formats) Stackage build plans"
+        options
+        empty
+    tis <- getBuildPlan set $ map (mkPackageName . T.pack) packages
+    TLIO.putStr $ render set tis
+  where
+    options = (,,)
+        <$> setOptions
+        <*> renderOptions
+        <*> some (argument str (metavar "PACKAGES..."))
+
+    mkSettings mmirror msnapshot mcommands =
+          maybe id (setMirror . T.pack) mmirror
+        $ maybe id setSnapshot msnapshot
+        $ maybe id setShellCommands mcommands
+        $ defaultSettings
+
+    setOptions = mkSettings
+        <$> ((fmap Just $ strOption
+            ( long "mirror"
+           <> help "Mirror to download packages from"
+           <> metavar "URL"
+            )) <|> pure Nothing)
+        <*> ((fmap Just $ option readSnapshot
+            ( long "snapshot"
+           <> help "Which snapshot to pull the build plan from"
+           <> metavar "SNAPSHOT"
+            )) <|> pure Nothing)
+        <*> ((fmap Just $ option readCommands
+            ( long "commands"
+           <> help "Shell commands do use: abstract, simple"
+           <> metavar "COMMANDS"
+            )) <|> pure Nothing)
+
+    readSnapshot =
+        str >>=
+        either (fail . show) return . parseSnapshotSpec . T.pack
+
+    readCommands = do
+        s <- str
+        case s of
+            "abstract" -> return abstractCommands
+            "simple" -> return simpleCommands
+            _ -> fail $ "Invalid commands: " ++ s
+
+    renderOptions =
+        (option readRender
+            ( long "format"
+           <> help "Output format: shell, simple, json"
+           <> metavar "FORMAT"
+           ))
+
+    readRender = do
+        x <- str
+        case x of
+            "shell" -> return $ \set tis ->
+                TL.fromStrict $ toShellScript set tis
+            "simple" -> return $ \_set tis ->
+                TL.fromStrict $ toSimpleText tis
+            "json" -> return $ \_set tis ->
+                toLazyText $ encodeToTextBuilder $ toJSON tis
+            _ -> fail $ "Invalid renderer: " ++ x
diff --git a/stackage-build-plan.cabal b/stackage-build-plan.cabal
new file mode 100644
--- /dev/null
+++ b/stackage-build-plan.cabal
@@ -0,0 +1,49 @@
+name:                stackage-build-plan
+version:             0.1.0.0
+synopsis:            Calculate and print (in different formats) Stackage build plans
+description:         For more information, see <https://www.stackage.org/package/stackage-build-plan>
+homepage:            https://github.com/fpco/stackage-build-plan
+license:             MIT
+license-file:        LICENSE
+author:              Michael Snoyman
+maintainer:          michael@snoyman.com
+category:            Distribution
+build-type:          Simple
+extra-source-files:  ChangeLog.md, README.md
+cabal-version:       >=1.10
+
+library
+  exposed-modules:     Stackage.BuildPlan
+  build-depends:       base            >= 4.5 && < 5
+                     , bytestring      >= 0.9
+                     , text            >= 0.11
+                     , http-client     >= 0.4
+                     , http-client-tls >= 0.2
+                     , directory       >= 1.1
+                     , filepath        >= 1.2
+                     , containers
+                     , yaml
+                     , time
+                     , stackage-types
+                     , Cabal
+                     , aeson
+                     , mtl
+                     , exceptions
+  default-language:    Haskell2010
+
+executable stackage-build-plan
+  main-is:             stackage-build-plan.hs
+  other-modules:       Paths_stackage_build_plan
+  hs-source-dirs:      app
+  build-depends:       base
+                     , stackage-build-plan 
+                     , stackage-cli
+                     , optparse-applicative
+                     , text
+                     , aeson
+  default-language:    Haskell2010
+  other-extensions:    TemplateHaskell
+
+source-repository head
+  type:     git
+  location: git://github.com/fpco/stackage-build-plan.git
