diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@
 The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
 and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
 
+## 0.2.5 - 2022-03-07
+
+### Added
+
+ - `hci secret echo`: write secret data on stdout. A side-effect free version of `hci secret add`.
+
+ - `hci secret echo/add --password`: another safe way of inputting a secret field
+
 ## 0.2.4 - 2021-11-17
 
 ### Added
diff --git a/hercules-ci-cli.cabal b/hercules-ci-cli.cabal
--- a/hercules-ci-cli.cabal
+++ b/hercules-ci-cli.cabal
@@ -1,7 +1,7 @@
 cabal-version: 1.12
 
 name:           hercules-ci-cli
-version:        0.2.4
+version:        0.2.5
 synopsis:       The hci command for working with Hercules CI
 homepage:       https://docs.hercules-ci.com
 bug-reports:    https://github.com/hercules-ci/hercules-ci-agent/issues
diff --git a/src/Hercules/CLI/JSON.hs b/src/Hercules/CLI/JSON.hs
--- a/src/Hercules/CLI/JSON.hs
+++ b/src/Hercules/CLI/JSON.hs
@@ -17,6 +17,7 @@
 import Protolude
 import System.AtomicWrite.Writer.ByteString (atomicWriteFile)
 import System.Environment (getEnvironment, lookupEnv)
+import System.IO (hGetEcho, hSetEcho)
 
 mergePaths :: [([Text], Value)] -> Either Text Value
 mergePaths = mergeLeafPaths [] . toLeafPaths
@@ -48,7 +49,7 @@
         toLeafPaths [(path ++ [subpath], subitem)]
     _ -> [(path, item)]
 
-options :: Optparse.Parser (IO Value)
+options :: Optparse.Parser (Maybe Text -> IO Value)
 options = do
   strings <-
     many
@@ -61,6 +62,15 @@
               <> Optparse.metavar2 "STRING"
           )
       )
+  stringPasswords <-
+    many
+      ( Optparse.option
+          Optparse.str
+          ( Optparse.long "password"
+              <> Optparse.help "Define a string at dot-separated PATH in the secret data using password input on stdin"
+              <> Optparse.metavar "PATH"
+          )
+      )
   jsons <-
     many
       ( Optparse.biOption
@@ -120,7 +130,7 @@
               <> Optparse.completer2 envCompleter
           )
       )
-  pure do
+  pure \maybeSecretName -> do
     fileStrings <- for stringFiles readFileWithKey
     fileJsons <- for jsonFiles readJsonFileWithKey
     envStrings <- for stringEnvs readEnvWithKey
@@ -133,11 +143,13 @@
               Left e -> throwIO $ UserException $ "Value for key " <> key <> " is not valid JSON: " <> show e
               Right r -> pure (key, r :: Value)
         )
+    passwordStrings <- for stringPasswords (askPasswordWithKey maybeSecretName)
     let items =
           map (first split) $
             (fmap String <$> strings)
               <> (fmap String <$> envStrings)
               <> (fmap String <$> fileStrings)
+              <> (fmap String <$> passwordStrings)
               <> validJsons
               <> envJsons
               <> fileJsons
@@ -194,6 +206,9 @@
 writeJsonFile filePath v =
   atomicWriteFile filePath $ BL.toStrict $ encodePretty' prettyConf v
 
+printJson :: ToJSON a => a -> IO ()
+printJson = BS.putStr . BL.toStrict . (<> "\n") . encodePretty' prettyConf
+
 prettyConf :: Data.Aeson.Encode.Pretty.Config
 prettyConf =
   defConfig
@@ -202,3 +217,19 @@
       -- UNIX convention
       confTrailingNewline = True
     }
+
+askPasswordWithKey :: Maybe Text -> Text -> IO (Text, Text)
+askPasswordWithKey secretNameMaybe key = do
+  case secretNameMaybe of
+    Nothing -> putErrText $ "Enter value for " <> key <> ":"
+    Just secretName -> putErrText $ "Enter value for " <> key <> " in secret " <> secretName <> ":"
+  s <- T.strip <$> withEcho False getLine
+  putErrText ""
+  case s of
+    "" -> throwIO $ UserException $ "Value must not be empty for " <> show key
+    _ -> pure (key, s)
+
+withEcho :: Bool -> IO a -> IO a
+withEcho echo action = do
+  old <- hGetEcho stdin
+  bracket_ (hSetEcho stdin echo) (hSetEcho stdin old) action
diff --git a/src/Hercules/CLI/Login.hs b/src/Hercules/CLI/Login.hs
--- a/src/Hercules/CLI/Login.hs
+++ b/src/Hercules/CLI/Login.hs
@@ -34,7 +34,7 @@
           }
     putErrText "Please confirm your login at "
     putErrText $ "  " <> CLIAuthorizationRequestCreateResponse.browserURL r
-    putErrText "Waiting for confirmation..."
+    putErrText "Waiting for you to confirm using the link..."
     let tmpTok = CLIAuthorizationRequestCreateResponse.temporaryCLIToken r
         -- TODO do something pretty with 404
         pollLoop = do
diff --git a/src/Hercules/CLI/Secret.hs b/src/Hercules/CLI/Secret.hs
--- a/src/Hercules/CLI/Secret.hs
+++ b/src/Hercules/CLI/Secret.hs
@@ -4,6 +4,7 @@
 module Hercules.CLI.Secret where
 
 import qualified Data.Aeson as A
+import qualified Data.Aeson.Types as A
 import qualified Data.Map as M
 import qualified Data.Text as T
 import Hercules.CLI.Common (exitMsg, runAuthenticated)
@@ -15,7 +16,7 @@
 import System.FilePath (takeDirectory, (</>))
 import UnliftIO.Directory (XdgDirectory (XdgConfig), createDirectoryIfMissing, doesFileExist, getXdgDirectory)
 
-commandParser, initLocal, add :: Optparse.Parser (IO ())
+commandParser, initLocal, add, echo :: Optparse.Parser (IO ())
 commandParser =
   subparser
     ( mkCommand
@@ -26,6 +27,10 @@
           "add"
           (Optparse.progDesc "Insert a secret into the local secrets file")
           add
+        <> mkCommand
+          "echo"
+          (Optparse.progDesc "Assemble a secret for stdout")
+          echo
     )
 initLocal = do
   projectOptionMaybe <- optional projectOption
@@ -44,7 +49,7 @@
   mkJson <- JSON.options
   projectOptionMaybe <- optional projectOption
   pure $ runAuthenticated do
-    secretData <- liftIO mkJson
+    secretData <- liftIO (mkJson (Just secretName))
     projectPath <- getProjectPath projectOptionMaybe
     secretsFilePath <- liftIO $ getSecretsFilePath projectPath
     liftIO $
@@ -60,6 +65,16 @@
     liftIO $ writeJsonFile secretsFilePath secrets'
     putErrText $ "hci: successfully wrote " <> secretName <> " to " <> toS secretsFilePath
     putErrText "NOTE: Remember to synchronize this file with your agents!"
+echo = do
+  mkJson <- JSON.options
+  pure do
+    secretDataValue <- liftIO (mkJson Nothing)
+    secretData <- case A.parse A.parseJSON secretDataValue of
+      A.Error e -> exitMsg $ "The secret data must be an object. " <> toS e
+      A.Success a -> pure a
+    let secret =
+          A.object ["kind" A..= A.String "Secret", "data" A..= (secretData :: Map Text A.Value)]
+    liftIO $ JSON.printJson secret
 
 getSecretsFilePath :: ProjectPath -> IO FilePath
 getSecretsFilePath projectPath = do
