diff --git a/Hranker/CommandLine.hs b/Hranker/CommandLine.hs
new file mode 100644
--- /dev/null
+++ b/Hranker/CommandLine.hs
@@ -0,0 +1,38 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+module Hranker.CommandLine (clMain, oneRound) where
+
+import Hranker.Commands (allCommands, cmdFn, Command, findCmd, printInverseMapping, pushAt)
+import Hranker.State (AnnotatedItem(..), MyState(..), OutputState)
+
+import Data.List.NonEmpty (listToNonEmpty)
+import System.Console.HCL (execReq, prompt, reqCont, reqFail, reqIO, reqIterate, reqResp, Request, reqUntil)
+
+import Control.Monad (liftM)
+import Data.Maybe (fromJust, isJust)
+
+-- | Perform one "round" of the user interaction (i.e. asking for and executing one command)
+oneRound :: forall a. (Show a, Eq a, Ord a) => OutputState a -> Request (OutputState a)
+oneRound prevOS = do
+    -- Immediately quit if the unranked list is empty
+    u <- maybe (printInverseMapping prevOS >> reqFail) return . listToNonEmpty $ unranked prevOS
+    -- Convert the previous OutputState into an InputState
+    let inS = prevOS { unranked = u }
+    -- Show the current state
+    reqIO . putStrLn $ show inS
+    -- Ask for a command until a valid one is entered
+    c <- reqUntil (return . isJust . (findCmd :: Char -> Maybe (Command a)))
+         . prompt (show (allCommands :: [Command a]) ++ "> ") . liftM head $ reqResp `reqCont` return "!" -- bogus command
+    -- Execute the selected command
+    cmdFn ((fromJust $ findCmd c) :: Command a) inS
+
+notEnough :: IO a
+notEnough = fail "At least one item to rank must be provided"
+
+-- | Run the program, given the items to rank
+clMain :: (Show a, Eq a, Ord a) => [a] -> IO ()
+clMain xs     = do
+    putStrLn "WARNING 1: This program does not have any save or export feature. You have to manually read and transfer the results."
+    putStrLn "WARNING 2: If there are no more items to rank, the program immediately outputs a mapping from items to ranks, and then quits."
+    putStrLn "Your scrollback buffer must be large enough."
+    putStrLn ""
+    execReq . reqIterate oneRound . pushAt id . MyState [] =<< maybe notEnough return (listToNonEmpty $ map (`AnnotatedItem` "") xs)
diff --git a/Hranker/Commands.hs b/Hranker/Commands.hs
new file mode 100644
--- /dev/null
+++ b/Hranker/Commands.hs
@@ -0,0 +1,128 @@
+module Hranker.Commands 
+( allCommands
+, Command(..)
+, findCmd
+, printInverseMapping
+, pushAt
+) where
+
+import Hranker.Rank (highestRank, indexToRank, Rank, rankToIndex)
+import Hranker.State (AnnotatedItem(..), idS, InputState, MyState(..), OutputState, validRank)
+
+import Data.List.NonEmpty (NonEmpty(..), nonEmptyToList)
+import System.Console.HCL (prompt, reqCont, reqFail, reqIO, reqRead, reqResp, reqUntil, Request)
+
+import Control.Applicative ((<*>))
+import Control.Monad (liftM)
+import Data.List (find, sort)
+
+-- | A command which the user can invoke
+data (Show a, Eq a, Ord a) => Command a
+    = Command { cmdName :: String
+              , cmdFn   :: InputState a -> Request (OutputState a)
+              }
+
+instance (Show a, Eq a, Ord a) => Show (Command a) where
+    show (Command { cmdName = n }) = '(' : head n : ')' : tail n
+
+-- | Request a rank for the given operation
+reqRank :: (Show a, Eq a, Ord a) => Bool -> String -> InputState a -> Request Rank
+reqRank blankAllowed action s
+    | not blankAllowed && (null . tail $ ranked s) = return highestRank  -- because that's the only possible rank
+    | otherwise = reqUntil (return . validRank s)
+                . prompt (action ++ " item(s) at which rank" ++ if blankAllowed then " (blank for next unranked)? " else "? ")
+                $ reqRead reqResp
+
+-- | Request a rank for the given operation, and then turn it into a list index
+reqIndex :: (Show a, Eq a, Ord a) => Bool -> String -> InputState a -> Request Int
+reqIndex = ((liftM rankToIndex .) .) . reqRank
+
+-- | Removes the user's choice of item(s) and puts them back in the unranked list
+removeCmd :: (Show a, Eq a, Ord a) => InputState a -> Request (OutputState a)
+removeCmd s = do
+    i <- reqIndex False "Remove" s
+    let r = ranked s
+        (h:t) = drop i r
+    return $ MyState { ranked   = take i r ++ t
+                     , unranked = h ++ nonEmptyToList (unranked s)
+                     }
+
+-- | Applies a function to only the head of a list (if any)
+mapHead :: (a -> a) -> [a] -> [a]
+mapHead _ []     = []
+mapHead f (x:xs) = (f x : xs)
+
+-- | Add the user's annotation to all items at a rank - replacing the annotations that were already there, if any
+annotateCmd :: (Show a, Eq a, Ord a) => InputState a -> Request (OutputState a)
+annotateCmd s@(MyState { ranked = r, unranked = u}) = do
+    i <- reqIndex True "Annotate" s `reqCont` (return (-1))
+    a <- prompt ("New annotation> ") . reqCont reqResp $ return ""
+    return $ if i < 0
+             then (idS s) { unranked = (neHead u) { annotation = a } : neTail u }
+                  else (idS s) { ranked = take i r ++ mapHead (map (\ai -> ai { annotation = a })) (drop i r) }
+
+-- | Given a function which takes the lowest rank and yields the rank to insert at, inserts the next item there
+pushAt :: (Show a, Eq a, Ord a) => (Rank -> Rank) -> InputState a -> OutputState a
+pushAt f (MyState { ranked = r, unranked = u })
+    = MyState { ranked = take i r ++ [neHead u] : drop i r
+              , unranked = neTail u
+              }
+    where i = rankToIndex . f . indexToRank $ length r - 1
+
+tieCmd :: (Show a, Eq a, Ord a) => InputState a -> Request (OutputState a)
+tieCmd s@(MyState { ranked = r, unranked = u }) = do
+    i <- reqIndex False "Tie with" s
+    return $ MyState { ranked = take i r ++ mapHead (neHead u:) (drop i r)
+                     , unranked = neTail u
+                     }
+
+insertCmd :: (Show a, Eq a, Ord a) => InputState a -> Request (OutputState a)
+insertCmd s = do
+    r <- reqRank False "Insert" s
+    return $ pushAt (const r) s
+
+inverseMapping :: (Show a, Eq a, Ord a) => [[AnnotatedItem a]] -> String
+inverseMapping = unlines . ("Item:\tRank:" :) . map showInv . sort . concat . zipWith (map . flip (,)) [highestRank..]
+    where
+        showInv (ai, r) = show ai ++ '\t' : show r
+
+passThruM :: (Monad m, Show a, Eq a, Ord a) => (InputState a -> m b) -> InputState a -> m (OutputState a)
+passThruM f = ((>>) . f) <*> return . idS
+
+printInverseMapping :: (Show a, Eq a, Ord a) => MyState c a -> Request ()
+printInverseMapping = reqIO . putStrLn . inverseMapping . ranked
+
+inverseMappingCmd :: (Show a, Eq a, Ord a) => InputState a -> Request (OutputState a)
+inverseMappingCmd = passThruM printInverseMapping
+
+-- | A list of all the commands the user can choose between
+allCommands :: (Show a, Eq a, Ord a) => [Command a]
+allCommands = Command { cmdName = "quit"
+                      , cmdFn = const reqFail
+                      } : map (\c -> c { cmdFn = \s -> cmdFn c s `reqCont` return (idS s) })
+                              [ Command { cmdName = "remove"
+                                        , cmdFn   = removeCmd
+                                        }
+                              , Command { cmdName = "annotate"
+                                        , cmdFn   = annotateCmd
+                                        }
+                              , Command { cmdName = "best"
+                                        , cmdFn   = return . pushAt (const highestRank)
+                                        }
+                              , Command { cmdName = "worst"
+                                        , cmdFn   = return . pushAt succ
+                                        }
+                              , Command { cmdName = "tie"
+                                        , cmdFn   = tieCmd
+                                        }
+                              , Command { cmdName = "insert"
+                                        , cmdFn   = insertCmd
+                                        }
+                              , Command { cmdName = "mapping"
+                                        , cmdFn   = inverseMappingCmd
+                                        }
+                              ]
+
+-- | Which command a given character represents - or Nothing if it doesn't represent any valid command
+findCmd :: (Show a, Eq a, Ord a) => Char -> Maybe (Command a)
+findCmd c = find ((c ==) . head . cmdName) allCommands
diff --git a/Hranker/Rank.hs b/Hranker/Rank.hs
new file mode 100644
--- /dev/null
+++ b/Hranker/Rank.hs
@@ -0,0 +1,30 @@
+{-# LANGUAGE GeneralizedNewtypeDeriving, TypeSynonymInstances #-}
+module Hranker.Rank
+( highestRank
+, indexToRank
+, Rank
+, rankToIndex
+) where
+
+import Control.Arrow (first)
+
+newtype Rank = Rank { getRank :: Int } deriving (Enum, Eq, Ord)
+
+instance Read Rank where
+    -- Although this may look complicated, all it does is delegates to Read for Int and then converts the result
+    readsPrec i = fmap (first Rank) . readsPrec i
+
+instance Show Rank where
+    show = show . getRank
+
+-- | The highest possible rank. Ranks count upwards from this rank.
+highestRank :: Rank
+highestRank = Rank 1
+
+-- | Convert a rank to a zero-based position in the list
+rankToIndex :: Rank -> Int
+rankToIndex r = getRank r - getRank highestRank
+
+-- | Inverse function of rankToIndex
+indexToRank :: Int -> Rank
+indexToRank = Rank . (+ getRank highestRank)
diff --git a/Hranker/State.hs b/Hranker/State.hs
new file mode 100644
--- /dev/null
+++ b/Hranker/State.hs
@@ -0,0 +1,53 @@
+{-# LANGUAGE GeneralizedNewtypeDeriving, TypeSynonymInstances #-}
+module Hranker.State 
+( AnnotatedItem(..)
+, idS
+, InputState
+, MyState(..)
+, OutputState
+, validRank
+) where
+
+import Hranker.Rank (highestRank, Rank, rankToIndex)
+
+import Data.List.NonEmpty (length', NonEmpty(..), nonEmptyToList)
+
+-- | A possibly-annotated item to be ranked. Empty annotations are treated as no annotation.
+data (Show a, Eq a, Ord a) => AnnotatedItem a
+    = AnnotatedItem { item :: a
+                    , annotation :: String
+                    } deriving (Eq, Ord)
+
+instance (Show a, Eq a, Ord a) => Show (AnnotatedItem a) where
+    show (AnnotatedItem { annotation = "", item = i}) = show i
+    show (AnnotatedItem { annotation = a , item = i}) = show i ++ " (" ++ a ++ ")"
+
+-- | Program state
+data (Show a, Eq a, Ord a) => MyState c a
+    = MyState { ranked   :: [[AnnotatedItem a]]
+              , unranked :: c (AnnotatedItem a)
+              }
+
+-- | Input state of a command
+type InputState = MyState NonEmpty
+
+-- | Output state of a command
+type OutputState = MyState []
+
+-- | Turn an InputState into an OutputState
+idS :: (Show a, Eq a, Ord a) => InputState a -> OutputState a
+idS s = s { unranked = nonEmptyToList $ unranked s }
+
+instance (Show a, Eq a, Ord a) => Show (InputState a) where
+    show s = unlines $ "Rank:\tItem(s):" : zipWith showRank [highestRank..] (ranked s)
+                       ++ [ ""
+                          , show (length' $ unranked s) ++ " unranked items, starting with: " ++ show (neHead $ unranked s)
+                          ]
+
+-- | (Show a, Eq a, Ord a) particular rank, given the rank number and the AnnotatedItems at that rank
+showRank :: (Show a, Eq a, Ord a) => Rank -> [AnnotatedItem a] -> String
+showRank rank ais = show rank ++ '\t' : show ais
+
+-- | Is the given rank currently listed?
+validRank :: (Show a, Eq a, Ord a) => MyState c a -> Rank -> Bool
+validRank s r = rankToIndex r < length (ranked s) && r >= highestRank
diff --git a/hranker.cabal b/hranker.cabal
--- a/hranker.cabal
+++ b/hranker.cabal
@@ -1,5 +1,5 @@
 name:                hranker
-version:             0.1
+version:             0.1.1
 Cabal-Version:	     >= 1.6
 synopsis:            Basic utility for ranking a list of items
 description:         A CLI utility for helping the user to rank a list of items in order.
@@ -18,4 +18,5 @@
 Executable hranker
   build-Depends:	base, HCL >= 1.4, HCL < 1.5, NonEmpty >= 0.1, NonEmpty < 0.2
   main-is:	Hranker.hs
+  other-modules:  Hranker.Rank, Hranker.CommandLine, Hranker.State, Hranker.Commands
   ghc-options:          -Wall -fno-warn-type-defaults
