diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1a15496e6e1bea95f350d821afbc91ccc9b3e500..aba74f202d1accd8aa285c6c3db8c02ad5582078 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,18 @@ in the wrapper would cause all of the module `make` commands to fail.
 
 ### Other changes
 
+* Rewrite `iocsh` converting it from being a shell script to a python (3.6) script
+  * Remove support for file extensions: `.so`, `.dbd`, `.db`, `.substitutions`, `.template`, `.iocsh`
+  * Remove support for `nice`
+  * Remove support for sequencer programs
+  * Remove optional argument passing to `gdb`
+  * Change the IOC shell to use both stdout and stderr (previously only stdout)
+  * Change default prompt
+  * Change fallback IOC-name (used when `IOCNAME` is not set)
+  * Multiple argument changes; e.g.
+    * Shortened argument for printing version and exit changed from `-v` to `-V`
+    * Make running IOC as realtime or with debuggers mutually exclusive
+    * Change how arguments are passed to `gdb` and `valgrind` (see help: `--help`)
 * Replaced `tclx` script to expand .dbd files with a python script
 * Removed `hdrs` target. Note that this has been entirely replaced by the automatic db expansion.
 * Removed ability to pass `args` to require (which have not been used within e3)
diff --git a/require-ess/tools/iocsh b/require-ess/tools/iocsh
old mode 100644
new mode 100755
index 383006b0a3a8917c32c833b1d38c2aa05a719fb5..cbf364c285fcbb4be1721531bdabcd5555bd8c5b
--- a/require-ess/tools/iocsh
+++ b/require-ess/tools/iocsh
@@ -1,127 +1,186 @@
-#!/bin/bash
-#
-#  Copyright (c) 2004 - 2017       Paul Scherrer Institute
-#  Copyright (c) 2017 - Present    European Spallation Source ERIC
-#
-#  The program is free software: you can redistribute
-#  it and/or modify it under the terms of the GNU General Public License
-#  as published by the Free Software Foundation, either version 2 of the
-#  License, or any newer version.
-#
-#  This program is distributed in the hope that it will be useful, but WITHOUT
-#  ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
-#  FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
-#  more details.
-#
-#  You should have received a copy of the GNU General Public License along with
-#  this program. If not, see https://www.gnu.org/licenses/gpl-2.0.txt
-#
-
-SC_SCRIPT="$(readlink -e "$0")"
-SC_SCRIPTNAME=${0##*/}
-SC_TOP="${SC_SCRIPT%/*}"
-TMP_PATH="/tmp/systemd-private-e3-iocsh-$(whoami)"
-declare -r SC_SCRIPT SC_SCRIPTNAME SC_TOP TMP_PATH
-declare SC_VERSION="${E3_REQUIRE_VERSION}"
-
-# shellcheck source=require-ess/tools/iocsh_functions.bash
-. "${SC_TOP}"/iocsh_functions.bash
-
-# To get the absolute path where iocsh is executed
-IOCSH_TOP=${PWD}
-
-# Check whether an expected e3 environment variable is defined.
-#
-if [[ -z "${EPICS_DRIVER_PATH}" ]]; then
-  set -a
-  # shellcheck source=require-ess/tools/activate
-  . "${SC_TOP}"/activate "no_msg"
-  set +a
-fi
-
-# Check that the e3 environment definition and the path for this script
-# are consistent
-if [ "${E3_REQUIRE_BIN}" != "${SC_TOP}" ]; then
-  echo "Error: Configured e3 environment does not match path for iocsh executable."
-  echo "Please source the appropriate activation file for the version of iocsh"
-  echo "you wish to use, or run iocsh from a clean environment so it can set the"
-  echo "environment correctly."
-  echo "Expected path to iocsh from environment = ${E3_REQUIRE_BIN}"
-  echo "Actual path to iocsh = ${SC_TOP}"
-  die 2
-fi
-
-check_mandatory_env_settings
-
-# ${BASHPID} returns iocsh PID
-iocsh_id=${BASHPID}
-#
-SC_VERSION+=-PID-${iocsh_id}
-
-#
-# We define HOSTNAME + iocsh_id
-IOCSH_PS1=$(iocsh_ps1 "${iocsh_id}")
-REQUIRE_IOC=$(require_ioc "${iocsh_id}")
-#
-# Default Initial Startup file for REQUIRE and minimal environment
-# Create TMP_PATH path in order to keep tmp files secure until
-# an IOC will be closed.
-
-mkdir -p "${TMP_PATH}"
-
-IOC_STARTUP=$(mktemp -p "${TMP_PATH}" -q --suffix=_iocsh_${SC_VERSION}) || die 1 "${SC_SCRIPTNAME} CANNOT create the startup file, please check the disk space"
-
-# EPICS_DRIVER_PATH defined in iocsh and startup.script_common
-# Remember, driver is equal to module, so EPICS_DRIVER_PATH is the module directory
-# In our jargon. It is the same as ${EPICS_MODULES}
-print_iocsh_header
-
-# shellcheck disable=SC2064
-trap "softIoc_end ${IOC_STARTUP}" EXIT HUP INT TERM
-
-{
-  printIocEnv
-
-  print_header_line "IOC startup commands"
-
-  printf "# // Set REQUIRE_IOC for its internal PVs\n"
-  printf "epicsEnvSet REQUIRE_IOC \"%s\"\n" "${REQUIRE_IOC}"
-  printf "#\n"
-  printf "# // Set E3_IOCSH_TOP for the absolute path where %s is executed.\n" "${SC_SCRIPTNAME}"
-  printf "epicsEnvSet E3_IOCSH_TOP \"%s\"\n" "${IOCSH_TOP}"
-  printf "#\n"
-
-  loadRequire
-
-  setPaths "$@"
-
-  loadFiles "$@"
-
-  printf "# // Set the IOC Prompt String\n"
-  printf "epicsEnvSet IOCSH_PS1 \"%s\"\n" "$IOCSH_PS1"
-  printf "#\n"
-
-  if [ "$REALTIME" == "RT" ]; then
-    printf "# Real Time \"%s\"\n" "$REALTIME"
-  fi
-
-  if [ "$init" != NO ]; then
-    printf "iocInit\n"
-  fi
-
-} >"${IOC_STARTUP}"
-
-ulimit -c unlimited
-
-if [ "$REALTIME" == "RT" ]; then
-  export LD_BIND_NOW=1
-  printf "## \n"
-  printf "## Better support for Real-Time IOC Application.\n"
-  printf "## Now we set 'export LD_BIND_NOW=%s'\n" "$LD_BIND_NOW"
-  printf "## If one may meet the 'Operation not permitted' message, \n"
-  printf "## please run %s without the real-time option\n" "$SC_SCRIPTNAME"
-  printf "##\n"
-fi
-
-# shellcheck disable=SC2086
-${__LOADER__}${EPICS_BASE}/bin/${EPICS_HOST_ARCH}/softIocPVA -D ${EPICS_BASE}/dbd/softIocPVA.dbd "${IOC_STARTUP}" 2>&1
+#! /usr/bin/env python3
+"""Entry point for the IOC shell."""
+
+import argparse
+import os
+import logging
+from pathlib import Path
+import subprocess
+import sys
+import re
+from typing import List
+
+from iocsh_utils import verify_environment_and_return_require_version
+from iocsh_utils import TemporaryStartupScript
+from iocsh_utils import generate_banner
+
+
+def iocsh(
+    file: Path,
+    module: List[str],
+    command: List[str],
+    database: List[Path],
+    cell_path: List[Path],
+    no_init: bool,
+    realtime: bool,
+    gdb: bool,
+    valgrind: str,
+    debug: bool,
+):
+    with TemporaryStartupScript() as tmp_startup_script:
+        require_version = verify_environment_and_return_require_version()
+        epics_base_dir = Path(os.environ["EPICS_BASE"])
+
+        print(generate_banner())
+        print(f"Starting e3 IOC shell version {require_version}")
+        try:
+            iocname = os.environ["IOCNAME"]
+        except KeyError:
+            logging.warning("Environment variable IOCNAME is not set.")
+        else:
+            logging.info(f"IOCNAME is set to {iocname}")
+        logging.debug(f"PID for iocsh {os.getpid()}")
+        logging.debug(f"Script path is {Path(__file__).resolve()}")
+        logging.debug(f"Executed from {Path.cwd()}")
+        logging.debug(f"Temporary startup script at {tmp_startup_script.name}")
+
+        if cell_path:
+            cell_paths = ":".join(
+                [
+                    str(
+                        p.resolve() / epics_base_dir.name / f"require-{require_version}"
+                    )
+                    for p in reversed(cell_path)
+                ]
+            )
+            tmp_startup_script.set_variable(
+                "EPICS_DRIVER_PATH", f"{cell_paths}:{os.environ['EPICS_DRIVER_PATH']}"
+            )
+
+        if debug:
+            tmp_startup_script.add_command("var requireDebug 1")
+
+        for entry in module:
+            tmp_startup_script.load_module(entry)
+
+        if file:
+            tmp_startup_script.set_variable("E3_CMD_TOP", {str(file.parent)})
+            tmp_startup_script.load_snippet(file)
+
+        for entry in command:
+            tmp_startup_script.add_command(entry)
+
+        for entry in database:
+            tmp_startup_script.load_database(entry)
+
+        if not no_init:
+            # if file isn't passed we shouldn't try to read its' content
+            if not file or re.search(r"^\s*iocInit\b", file.read_text()) is None:
+                tmp_startup_script.add_command("iocInit")
+
+        tmp_startup_script.save()
+
+        cmd = [
+            str(epics_base_dir / "bin" / os.environ["EPICS_HOST_ARCH"] / "softIocPVA"),
+            "-D",
+            str(epics_base_dir / "dbd" / "softIocPVA.dbd"),
+            tmp_startup_script.name,
+        ]
+
+        if gdb:
+            cmd = ["gdb", "--args", cmd]
+        elif valgrind:
+            cmd = ["valgrind"] + valgrind.split(" ") + cmd
+        elif realtime:
+            os.environ["LD_BIND_NOW"] = 1
+            cmd = "chrt --fifo 1".split(" ") + cmd
+
+        logging.debug(f"Running command `{' '.join(cmd)}`")
+        try:
+            subprocess.run(cmd, check=True)
+        except (KeyboardInterrupt, subprocess.CalledProcessError):
+            sys.exit(-1)
+
+
+def generate_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(
+        prog="IOC shell for e3",
+        description="ESS EPICS environment (e3) wrapper for softIocPVA",
+    )
+
+    # How this is set up has a funny but sort of good side-effect:
+    # if no environment is sourced the IOC shell will refuse to start in the first place
+    # even if you try to execute something like `iocsh -h`
+    parser.add_argument(
+        "-V",
+        "--version",
+        action="version",
+        version="%(prog)s version {_version}".format(
+            _version=verify_environment_and_return_require_version()
+        ),
+        help="print version and exit",
+    )
+
+    parser.add_argument(
+        "file", type=Path, nargs="?", default=None, help="path to startup script"
+    )
+    parser.add_argument(
+        "-r",
+        "--require",
+        dest="module",
+        action="append",
+        default=list(),
+        help="load module(s), optionally with version using the syntax `module,version`",
+    )
+    parser.add_argument(
+        "-c", "--command", action="append", default=list(), help="execute command(s)"
+    )
+    parser.add_argument(
+        "-d",
+        "--database",
+        action="append",
+        default=list(),
+        type=Path,
+        help="load database file(s) (`dbLoadRecords`)",
+    )
+    parser.add_argument(
+        "-l",
+        "--cell-path",
+        type=Path,
+        action="append",
+        default=list(),
+        metavar="PATH",
+        help="path(s) to cell(s)",
+    )
+
+    mutex_group_modifiers = parser.add_mutually_exclusive_group()
+    mutex_group_modifiers.add_argument("-rt", "--realtime", action="store_true")
+    mutex_group_modifiers.add_argument(
+        "-dg",
+        "--gdb",
+        action="store_true",
+        help="run with gdb",
+    )
+    mutex_group_modifiers.add_argument(
+        "-dv",
+        "--valgrind",
+        nargs="?",
+        const="--leak-check=full",
+        metavar="ARGUMENT",
+        help="run with valgrind",
+    )
+    mutex_group_modifiers.add_argument(
+        "--debug",
+        action="store_true",
+    )
+
+    parser.add_argument("-i", "--no-init", action="store_true")
+
+    return parser
+
+
+if __name__ == "__main__":
+    logging.basicConfig(format="%(levelname)s: %(message)s ", level=logging.DEBUG)
+    parser = generate_parser()
+    args = parser.parse_args()
+    iocsh(**vars(args))
diff --git a/require-ess/tools/iocsh_functions.bash b/require-ess/tools/iocsh_functions.bash
deleted file mode 100644
index 52594f9c4a6bf0d915cb7e58bc443c8130fcf109..0000000000000000000000000000000000000000
--- a/require-ess/tools/iocsh_functions.bash
+++ /dev/null
@@ -1,469 +0,0 @@
-#!/usr/bin/env bash
-# shellcheck disable=SC2034
-# -*- mode: sh -*-
-#
-#  Copyright (c) 2004 - 2017    Paul Scherrer Institute
-#  Copyright (c) 2017 - 2021    European Spallation Source ERIC
-#
-#  The program is free software: you can redistribute
-#  it and/or modify it under the terms of the GNU General Public License
-#  as published by the Free Software Foundation, either version 2 of the
-#  License, or any newer version.
-#
-#  This program is distributed in the hope that it will be useful, but WITHOUT
-#  ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
-#  FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
-#  more details.
-#
-#  You should have received a copy of the GNU General Public License along with
-#  this program. If not, see https://www.gnu.org/licenses/gpl-2.0.txt
-#
-#
-#  PSI original iocsh author : Dirk Zimoch
-#  ESS author                : Jeong Han Lee
-#  ESS maintainer            : Simon Rose
-#                     email  : simon.rose@ess.eu
-#                      date  : 2021-12-10
-#
-
-REALTIME=
-__LOADER__=
-
-# Usage :
-# e3_version="$(read_file_get_string  "${file_name}" "E3_VERSION:=")";
-function read_file_get_string {
-  local FILENAME=$1
-  local PREFIX=$2
-
-  sed -n "s/^$PREFIX\(.*\)/\1/p" "$FILENAME"
-}
-
-# Base Code is defined with 8 digits numbers
-# Two digits are enough to cover all others (I think)
-# 7.0.6.1   : 07000601
-#
-# First  Two : 00 (EPICS VERSION)
-# Second Two : 00 (EPICS_REVISION)
-# Third  Two : 00 (EPICS_MODIFICATION)
-# Fourth Two : 00 (EPICS_PATCH_LEVEL)
-
-function basecode_generator() { #@ Generator BASECODE
-  #@ USAGE: BASECODE=$(basecode_generator)
-
-  local epics_ver_maj epics_ver_mid epics_ver_min epics_ver_patch
-  epics_ver_maj="$(read_file_get_string "${EPICS_BASE}/configure/CONFIG_BASE_VERSION" "EPICS_VERSION = ")"
-  epics_ver_mid="$(read_file_get_string "${EPICS_BASE}/configure/CONFIG_BASE_VERSION" "EPICS_REVISION = ")"
-  epics_ver_min="$(read_file_get_string "${EPICS_BASE}/configure/CONFIG_BASE_VERSION" "EPICS_MODIFICATION = ")"
-  epics_ver_patch="$(read_file_get_string "${EPICS_BASE}/configure/CONFIG_BASE_VERSION" "EPICS_PATCH_LEVEL = ")"
-
-  local base_code=""
-
-  if [[ ${#epics_ver_maj} -lt 2 ]]; then
-    epics_ver_maj="00${epics_ver_maj}"
-    epics_ver_maj="${epics_ver_maj: -2}"
-  fi
-
-  if [[ ${#epics_ver_mid} -lt 2 ]]; then
-    epics_ver_mid="00${epics_ver_mid}"
-    epics_ver_mid="${epics_ver_mid: -2}"
-  fi
-
-  if [[ ${#epics_ver_min} -lt 2 ]]; then
-    epics_ver_min="00${epics_ver_min}"
-    epics_ver_min="${epics_ver_min: -2}"
-  fi
-
-  if [[ ${#epics_ver_patch} -lt 2 ]]; then
-    epics_ver_patch="00${epics_ver_patch}"
-    epics_ver_patch="${epics_ver_patch: -2}"
-  fi
-
-  base_code=${epics_ver_maj}${epics_ver_mid}${epics_ver_min}${epics_ver_patch}
-
-  echo "$base_code"
-
-}
-
-function version() {
-  printf "%s : %s%s\n" "European Spallation Source ERIC" "$SC_SCRIPTNAME" ${SC_VERSION:+" ($SC_VERSION)"} >&2
-
-  exit
-}
-
-function print_iocsh_header() {
-
-  printf "███████╗██████╗     ██╗ ██████╗  ██████╗    ███████╗██╗  ██╗███████╗██╗     ██╗     \n"
-  printf "██╔════╝╚════██╗    ██║██╔═══██╗██╔════╝    ██╔════╝██║  ██║██╔════╝██║     ██║     \n"
-  printf "█████╗   █████╔╝    ██║██║   ██║██║         ███████╗███████║█████╗  ██║     ██║     \n"
-  printf "██╔══╝   ╚═══██╗    ██║██║   ██║██║         ╚════██║██╔══██║██╔══╝  ██║     ██║     \n"
-  printf "███████╗██████╔╝    ██║╚██████╔╝╚██████╗    ███████║██║  ██║███████╗███████╗███████╗\n"
-  printf "╚══════╝╚═════╝     ╚═╝ ╚═════╝  ╚═════╝    ╚══════╝╚═╝  ╚═╝╚══════╝╚══════╝╚══════╝\n"
-  printf "\n"
-
-}
-
-function print_header_line() {
-  printf "############################################################################\n"
-  printf "##  %s\n" "${1}"
-  printf "############################################################################\n"
-}
-
-function print_vars() {
-  print_header_line "$1"
-  shift
-  for var in "$@"; do
-    printf "# %s = \"%s\"\n" "${var}" "${!var}"
-  done
-  printf "#\n"
-}
-
-function printIocEnv() {
-
-  printf "# Start at \"%s\"\n" "$(date +%Y-W%V-%b%d-%H%M-%S-%Z)"
-  printf "# %s : %s%s\n" "European Spallation Source ERIC" "$SC_SCRIPTNAME" ${SC_VERSION:+" ($SC_VERSION)"}
-  printf "#\n"
-
-  print_vars "Shell and environment variables" PWD USER LOGNAME PATH
-  print_vars "EPICS variables" EPICS_BASE EPICS_HOST_ARCH EPICS_DRIVER_PATH EPICS_CA_AUTO_ADDR_LIST EPICS_CA_ADDR_LIST EPICS_PVA_AUTO_ADDR_LIST EPICS_PVA_ADDR_LIST
-  print_vars "e3-specific variables" E3_REQUIRE_VERSION E3_REQUIRE_LOCATION E3_REQUIRE_BIN E3_REQUIRE_DB E3_REQUIRE_DBD E3_REQUIRE_INC E3_REQUIRE_LIB
-
-}
-
-# Ctrl+c : OK
-# exit   : OK
-# kill softioc process : OK
-# kill main process : Enter twice in terminal,
-#                     close softIoc, but STARTUP file is remained.
-#
-
-function softIoc_end() {
-  local startup_file=$1
-  rm -f "${startup_file}"
-  # only clean terminal when stdout is opened on a terminal
-  # avoid "stty: standard input: Inappropriate ioctl for device" otherwise
-  [[ -t 1 ]] && stty sane
-  exit
-}
-
-function die() { #@ Print error message and exit with error code
-  #@ USAGE: die [errno [message]]
-  error=${1:-1}
-  ## exits with 1 if error number not given
-  shift
-  [ -n "$*" ] &&
-    printf "%s%s: %s\n" "$SC_SCRIPTNAME" ${SC_VERSION:+" ($SC_VERSION)"} "$*" >&2
-  exit "$error"
-}
-
-function iocsh_ps1() {
-  local iocsh_ps1=""
-  local pid="$1"
-
-  # If IOCNAME is not set use pid instead
-  if [ -z "${IOCNAME}" ]; then
-    iocsh_ps1+=${pid}
-  else
-    iocsh_ps1+="${IOCNAME}"
-  fi
-
-  iocsh_ps1+=" > "
-
-  echo "${iocsh_ps1}"
-}
-
-# Please look at the limitation in require.c in  registerModule()
-# /*
-#    Require DB has the following four PVs:
-#    - $(REQUIRE_IOC):$(MODULE)_VER
-#    - $(REQUIRE_IOC):MOD_VER
-#    - $(REQUIRE_IOC):VERSIONS
-#    - $(REQUIRE_IOC):MODULES
-#    We reserved 30 chars for :$(MODULE)_VER, so MODULE has the maximum 24 chars.
-#    And we've reserved for 30 chars for $(REQUIRE_IOC).
-#    So, the whole PV and record name in moduleversion.template has 59 + 1.
-#  */
-
-function require_ioc() {
-  # e3-ioc-hash-hostname-pid fails when host has icslab-ser03 and IOCUSER-VIRTUALBOX
-  # so better to keep simple in case when hostname is long.
-  # And it has the limitation of PV length
-  #  #define PVNAME_STRINGSZ 61 in EPICS_BASE/include/dbDefs.h
-
-  local require_ioc=""
-
-  # Test if IOCNAME is defined
-  if [ -z "${IOCNAME}" ]; then
-    local pid="$1"
-    # Keep only short hostname (without domain)
-    local hostname=${HOSTNAME%%.*}
-    # Record name should not have . character, because it is used inside record
-
-    require_ioc="REQMOD"          # char 6  ( 6)
-    require_ioc+=":"              # char 1  ( 7)
-    require_ioc+=${hostname:0:15} # char 15 (22)
-    require_ioc+="-"              # char 1  (23)
-    require_ioc+=${pid}           # char 7  (30),  max pid in  64 bit  4194304 (7),
-  else
-    require_ioc=${IOCNAME:0:30}
-  fi
-
-  echo "${require_ioc}"
-}
-
-function loadRequire() {
-  local libPrefix=lib
-  local libPostfix=.so
-  local libName=${libPrefix}${E3_REQUIRE_NAME}${libPostfix}
-
-  local require_lib=${E3_REQUIRE_LIB}/${EPICS_HOST_ARCH}/${libName}
-  local require_dbd=${E3_REQUIRE_DBD}/${E3_REQUIRE_NAME}.dbd
-
-  printf "# // Load %s module, version %s\n" "${E3_REQUIRE_NAME}" "${E3_REQUIRE_VERSION}"
-  printf "#\n"
-  printf "dlload %s\n" "${require_lib}"
-  printf "dbLoadDatabase %s\n" "${require_dbd}"
-  printf "%s_registerRecordDeviceDriver\n\n" "${E3_REQUIRE_NAME%-*}"
-  printf "# \n"
-
-}
-
-function check_mandatory_env_settings() {
-  declare -a var_list=()
-  var_list+=(EPICS_HOST_ARCH)
-  var_list+=(EPICS_BASE)
-  var_list+=(E3_REQUIRE_NAME)
-  var_list+=(E3_REQUIRE_BIN)
-  var_list+=(E3_REQUIRE_LIB)
-  var_list+=(E3_REQUIRE_DB)
-  var_list+=(E3_REQUIRE_DBD)
-  var_list+=(E3_REQUIRE_VERSION)
-  for var in "${var_list[@]}"; do
-    if [[ -z "${!var}" ]]; then
-      die 1 " $var is not defined!. Please source ${E3_REQUIRE_BIN}/activate"
-    fi
-  done
-
-  if [[ -z "$IOCNAME" ]]; then
-    echo "Warning: environment variable IOCNAME is not set." >&2
-  else
-    echo "IOCNAME is set to $IOCNAME"
-  fi
-}
-
-function setPaths() {
-  while [ "$#" -gt 0 ]; do
-    arg="$1"
-
-    case $arg in
-      -l)
-        shift
-        add_path="$1/$(basename "${EPICS_BASE}")/require-${E3_REQUIRE_VERSION}"
-        printf "epicsEnvSet EPICS_DRIVER_PATH %s:${EPICS_DRIVER_PATH}\n" "$add_path"
-        EPICS_DRIVER_PATH="$add_path:$EPICS_DRIVER_PATH"
-        ;;
-    esac
-    shift
-  done
-}
-
-function loadFiles() {
-  while [ "$#" -gt 0 ]; do
-
-    arg=$1
-
-    case $arg in
-      -rt | -RT | -realtime | --realtime)
-        REALTIME="RT"
-        __LOADER__="chrt --fifo 1 "
-        ;;
-      @*)
-        loadFiles "$(cat "${arg#@}")"
-        ;;
-      *=*)
-        echo -n "$arg" | awk -F '=' '{printf "epicsEnvSet %s '\''%s'\''\n" $1 $2}'
-        ;;
-      -c)
-        shift
-        case $1 in
-          seq*)
-            if [ "$init" != NO ]; then
-              echo "iocInit"
-              init=NO
-            fi
-            ;;
-          iocInit)
-            init=NO
-            ;;
-        esac
-        echo "$1"
-        ;;
-      -s)
-        shift
-        if [ "$init" != NO ]; then
-          echo "iocInit"
-          init=NO
-        fi
-        echo "seq $1"
-        ;;
-      -i | -noinit | --noinit)
-        init=NO
-        ;;
-      -r)
-        shift
-        echo "require $1"
-        ;;
-      -l) # This is taken care of in setPaths()
-        shift
-        ;;
-      -dg)
-        if [[ -n "${2%--dgarg=*}" ]]; then
-          __LOADER__="gdb --eval-command run --args "
-        else
-          shift
-          if [[ -z "${1#*=}" ]]; then
-            __LOADER__="gdb "
-          else
-            __LOADER__="gdb ${1#*=} "
-          fi
-        fi
-        ;;
-      -dv)
-        if [[ -n "${2%--dvarg=*}" ]]; then
-          __LOADER__="valgrind --leak-check=full "
-        else
-          shift
-          if [[ -z "${1#*=}" ]]; then
-            __LOADER__="valgrind "
-          else
-            __LOADER__="valgrind ${1#*=} "
-          fi
-        fi
-        ;;
-      -n)
-        __LOADER__="nice --10 "
-        shift
-        ;;
-      -*)
-        printf "Unknown option %s\n\n" "$1" >&2
-        help
-        ;;
-      *.so)
-        echo "dlload \"$arg\""
-        ;;
-      *)
-        subst=""
-        while [ "$#" -gt 1 ]; do
-          case $2 in
-            *=*)
-              subst="$subst,$2"
-              shift
-              ;;
-            *)
-              break
-              ;;
-          esac
-        done
-        subst=${subst#,}
-        case $arg in
-          *.db | *.template)
-            echo "dbLoadRecords '$arg','$subst'"
-            ;;
-          *.subs | *.subst)
-            echo "dbLoadTemplate '$arg','$subst'"
-            ;;
-          *.dbd)
-            # some dbd files must be loaded before main to take effect
-            echo "dbLoadDatabase '$arg','$DBD','$subst'"
-            ;;
-          *)
-            set_e3_cmd_top "$arg"
-            echo "iocshLoad '$arg','$subst'"
-
-            # Search for any instance of iocInit at the start of the line.
-            # If found, do not add the iocInit to the startup script. Any
-            # other occurrence of iocInit (e.g. in comments) is not matched
-            # and the script will add an active iocInit.
-            if grep -q "^\s*iocInit\b" "$arg"; then
-              init=NO
-            fi
-            ;;
-        esac
-        ;;
-
-    esac
-    shift
-  done
-
-}
-
-function set_e3_cmd_top() {
-  local file=$1
-  local file_path=""
-  local file_top=""
-  local file_name=""
-
-  if [ -f "$file" ]; then
-    file_path="$(readlink -e "$file")"
-    file_top="${file_path%/*}"
-    file_name=${file##*/}
-    printf "# Set E3_CMD_TOP for the absolute path where %s exists\n" "$file_name"
-    printf "epicsEnvSet E3_CMD_TOP \"%s\"\n" "$file_top"
-    printf "#\n"
-
-  fi
-}
-
-function help() {
-  {
-    printf "\n"
-    printf "USAGE: iocsh [startup files]\n"
-    printf "\n"
-    printf "Start the ESS iocsh and load startup scripts.\n\n"
-    printf "Options:\n\n"
-    printf "  -?, -h, --help   Show this page and exit.\n"
-    printf "  -v, --version    Show version and exit.\n"
-    printf "  -rt              Execute in realtime mode.\n"
-    printf "                   (Also -RT, -realtime, --realtime)\n"
-    printf "  -c 'cmd args'    Ioc shell command.\n"
-    printf "  -s 'prog m=v'    Sequencer program (and arguments), run with 'seq'.\n"
-    printf "                   This forces an 'iocInit' before running the program.\n"
-    printf "  -i               Do not add iocInit. This option does not override\n"
-    printf "                   a valid iocInit in the startup script.\n"
-    printf "                   (Also -noinit, --noinit)\n"
-    printf "  -r module[,ver]  Module (optionally with version) loaded via 'require'.\n"
-    printf "  -l 'cell path'   Run Ioc with a cell path.\n"
-    printf "  -dg [--dgarg='gdb-options']          Run with debugger gdb with user selected options or default option.\n"
-    printf "  -dv [--dvarg='valgrind-options']     Run with valgrind with user selected options or default option.\n"
-    printf "  -n               Run with 'nice --10' (requires sudo).\n"
-    printf "  @file            More arguments are read from file.\n\n"
-    printf "Supported filetypes:\n\n"
-    printf " *.db, *.dbt, *.template  loaded via 'dbLoadRecords'\n"
-    printf " *.subs, *.subst          loaded via 'dbLoadTemplate'\n"
-    printf " *.dbd                    loaded via 'dbLoadDatabase'\n"
-    printf " *.so                     loaded via 'dlload'\n"
-    printf "\n"
-    printf "All other files are executed as startup scripts by the EPICS shell.\n"
-    printf "After a file you can specify substitutions like m1=v1 m2=v1 for that file.\n\n"
-    printf "Examples:\n"
-    printf "  iocsh st.cmd\n"
-    printf "  iocsh my_database.template P=XY M=3\n"
-    printf "  iocsh -r my_module,version -c 'initModule()'\n"
-    printf "  iocsh -c 'var requireDebug 1' st.cmd\n"
-    printf "  iocsh -i st.cmd\n"
-    printf "  iocsh -dv --dvarg='--vgdb=full'\n"
-    printf "  iocsh -dv st.cmd\n\n"
-  } >&2
-  exit
-}
-
-for arg in "$@"; do
-  case $arg in
-    -h | "-?" | -help | --help)
-      help
-      ;;
-    -v | -ver | --ver | -version | --version)
-      version
-      ;;
-    *) ;;
-  esac
-done
diff --git a/require-ess/tools/iocsh_utils.py b/require-ess/tools/iocsh_utils.py
new file mode 100755
index 0000000000000000000000000000000000000000..19c11c7be01138e2f200ec0e2c0eef33082624d7
--- /dev/null
+++ b/require-ess/tools/iocsh_utils.py
@@ -0,0 +1,158 @@
+"""Utilities for iocsh."""
+
+import atexit
+import os
+import logging
+import socket
+import sys
+from tempfile import NamedTemporaryFile
+from pathlib import Path
+
+
+@atexit.register
+def graceful_shutdown() -> None:
+    print("\nExiting e3 IOC shell")
+    os.system("stty sane")
+
+
+class TemporaryStartupScript:
+    """Class to manage IOC shell commands.
+
+    Holds on to commands in a buffer which it writes to file.
+    """
+
+    def __init__(self) -> None:
+        self.file = NamedTemporaryFile(delete=False)
+        self.command_buffer = []
+        self._saved = False
+
+        self.set_variable("REQUIRE_IOC", generate_prefix())
+        self.set_variable("IOCSH_TOP", Path.cwd())
+        self.set_variable("IOCSH_PS1", generate_prompt())
+
+        # load require
+        self.add_command(
+            f"dlload {str(Path(os.environ['E3_REQUIRE_LIB']) / os.environ['EPICS_HOST_ARCH'] / 'librequire.so')}"
+        )
+        self.add_command(
+            f"dbLoadDatabase {str(Path(os.environ['E3_REQUIRE_DBD']) / 'require.dbd')}"
+        )
+        self.add_command("require_registerRecordDeviceDriver")
+
+    @property
+    def name(self) -> str:
+        return self.file.name
+
+    def __enter__(self) -> None:
+        return self
+
+    def __exit__(self, *args) -> None:
+        self.file.close()
+
+    def _get_content(self) -> str:
+        """Return list of commands as multi-line string."""
+        return "\n".join(self.command_buffer) + "\n"  # ensure newline at EOF
+
+    def save(self) -> None:
+        """Store command 'buffer' to file."""
+        if self._saved:
+            raise RuntimeError("File has already been saved")
+        self.file.write(self._get_content().encode())
+        self.file.flush()
+        self._saved = True
+
+    def set_variable(self, var: str, val: str) -> None:
+        self.add_command(
+            f'epicsEnvSet {var} "{val}"'
+        )  # add quotation marks to avoid symbols being interpreted
+
+    def load_snippet(self, name: str) -> None:
+        self.add_command(f"iocshLoad {name}")
+
+    def load_module(self, name: str) -> None:
+        self.add_command(f"require {name}")
+
+    def load_database(self, name: str) -> None:
+        self.add_command(f"dbLoadRecords {name}")
+
+    def add_command(self, command: str) -> None:
+        self.command_buffer.append(command)
+
+
+def verify_environment_and_return_require_version() -> str:
+    """Verify select known EPICS and e3 variables and return current require version."""
+
+    def check_mandatory_env_vars() -> None:
+        mandatory_vars = (
+            "EPICS_HOST_ARCH",
+            "EPICS_BASE",
+            "E3_REQUIRE_VERSION",
+            "E3_REQUIRE_NAME",
+            "E3_REQUIRE_BIN",
+            "E3_REQUIRE_LIB",
+            "E3_REQUIRE_DB",
+            "E3_REQUIRE_DBD",
+        )
+        for var in mandatory_vars:
+            _ = os.environ[var]
+
+    try:
+        check_mandatory_env_vars()
+    except KeyError as e:
+        logging.debug(f"Environment variable {e} is not set")
+        sys.exit("Please source an environment before you try to use the IOC shell")
+
+    # compare path of this script to sourced environment's executables
+    if not str(Path(__file__).resolve().parent) == os.environ["E3_REQUIRE_BIN"]:
+        logging.debug(
+            f"Sourced environment is '{os.environ['E3_REQUIRE_BIN']}' and this script is from '{Path(__file__).resolve().parent}'"
+        )
+        sys.exit(
+            "You have sourced a different environment than what this IOC shell is from"
+        )
+
+    return os.environ["E3_REQUIRE_VERSION"]
+
+
+def extract_require_version() -> str:
+    """Return loaded environment's version of require."""
+    try:
+        return os.environ["E3_REQUIRE_VERSION"]
+    except KeyError:
+        sys.exit("Please source an environment before you try to use the IOC shell")
+
+
+def generate_prompt(separator: str = " > ") -> str:
+    """Return IOC shell prompt."""
+    try:
+        prompt = os.environ["IOCNAME"]
+    except KeyError:
+        fqdn = socket.gethostname()
+        hostname, *_ = fqdn.partition(".")
+        prompt = f"{hostname}-{os.getpid()}"
+    return f"{prompt}{separator}"
+
+
+def generate_prefix(prefix: str = "TEST:") -> str:
+    """Return fallback IOC-name."""
+    try:
+        iocname = os.environ["IOCNAME"]
+    except KeyError:
+        try:
+            user = os.getlogin()
+        except OSError:  # for wonky cases
+            user = "UNKNOWN"
+        iocname = f"{prefix}{user}-{os.getpid()}"
+    return iocname
+
+
+def generate_banner() -> str:
+    """Return ascii art banner."""
+    ascii_art = """
+       ,----.     ,--. ,-----.  ,-----.           ,--.            ,--.,--.
+ ,---. '.-.  |    |  |'  .-.  ''  .--./     ,---. |  ,---.  ,---. |  ||  |
+| .-. :  .' <     |  ||  | |  ||  |        (  .-' |  .-.  || .-. :|  ||  |
+\   --./'-'  |    |  |'  '-'  ''  '--'\    .-'  `)|  | |  |\   --.|  ||  |
+ `----'`----'     `--' `-----'  `-----'    `----' `--' `--' `----'`--'`--'
+"""
+    return ascii_art
diff --git a/tests/test_e3.py b/tests/test_e3.py
index b79327fd6921423deb1327fb8d710b7317e40ef2..94c1ebe718824941ff90d396b5e96d8a45785df9 100644
--- a/tests/test_e3.py
+++ b/tests/test_e3.py
@@ -1,3 +1,5 @@
+import os
+
 import pytest
 
 from .utils import Wrapper
@@ -58,7 +60,7 @@ def test_debug_arch_is_not_used_when_unspecified(wrapper: Wrapper):
     assert rc == 0
 
     # Note that output from run-iocsh goes to stderr
-    assert f'EPICS_HOST_ARCH = "{wrapper.host_arch}"' in errs
+    assert os.environ["EPICS_HOST_ARCH"] == wrapper.host_arch
 
 
 def test_debug_arch_is_used_when_specified(wrapper: Wrapper):
@@ -70,5 +72,4 @@ def test_debug_arch_is_used_when_specified(wrapper: Wrapper):
     rc, _, errs = wrapper.run_make("test", EPICS_HOST_ARCH=debug_arch)
     assert rc == 0
 
-    # Note that output from run-iocsh goes to stderr
-    assert f'EPICS_HOST_ARCH = "{debug_arch}"' in errs
+    assert os.environ["EPICS_HOST_ARCH"] == debug_arch
diff --git a/tests/test_versions.py b/tests/test_versions.py
index 055684e60294de8e8b09bc5096429170e316afce..c129c87330395a26e9347f9e7379038578ce5b2f 100644
--- a/tests/test_versions.py
+++ b/tests/test_versions.py
@@ -49,7 +49,7 @@ def test_version(wrapper: Wrapper, requested, expected, installed):
         )
         assert returncode == 0
 
-    rc, stdout, _ = run_ioc_get_output(
+    rc, stdout, stderr = run_ioc_get_output(
         module=wrapper.name, version=requested, cell_path=wrapper.path / "cellMods"
     )
 
@@ -65,7 +65,7 @@ def test_version(wrapper: Wrapper, requested, expected, installed):
             RE_MODULE_NOT_LOADED.format(
                 module=wrapper.name, required=re.escape(requested)
             ),
-            stdout,
+            stderr,
         )
         assert match
         assert rc != 0