diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
 # Revision history for erebos-tester
 
+## 0.3.2 -- 2025-05-16
+
+* Asset files and directories for use during tests
+* Select tests from project configuration using only test name on command line without script path
+* Added `args` parameter to `spawn` command to pass extra command-line arguments to the spawned tool
+* Experimental shell interpreter
+
 ## 0.3.1 -- 2025-03-03
 
 * Fix executing test tool given with relative path
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -56,14 +56,26 @@
 erebos-tester --verbose
 ```
 
-To run tests from a given test file, pass it as command-line argument:
+To run all tests from project configuration (see below), run the tester without any argument:
 ```
-erebos-tester path/to/script.test
+erebos-tester
 ```
 
+To run only some named tests, list the names on command line:
+```
+erebos-tester FirstTest SecondTest
+```
+
+To run tests from a given test file, pass it as command-line argument (the path
+must contain a slash, so use e.g. `./script.et` for script in the current
+directory):
+```
+erebos-tester path/to/script.et
+```
+
 To select single test from a file, use `:` separator:
 ```
-erebos-tester path/to/script.test:TestName
+erebos-tester path/to/script.et:TestName
 ```
 
 Configuration
@@ -196,6 +208,15 @@
 `node`
 : Node on which the process is running.
 
+#### asset
+
+Represents an asset (file or directory), which can be used during test execution.
+
+Members:
+
+`path`
+: Path to the asset valid during the test execution.
+
 #### list
 
 Lists are written using bracket notation:
@@ -222,11 +243,12 @@
 Create a node on network `<network>` (or context network if omitted) and assign the new node to the variable `<name>`.
 
 ```
-spawn as <name> [on (<node> | <network>)]
+spawn as <name> [on (<node> | <network>)] [args <arguments>]
 ```
 
 Spawn a new test process on `<node>` or `<network>` (or one from context) and assign the new process to variable `<name>`.
 When spawning on network, create a new node for this process.
+Extra `<arguments>` to the tool can be given as a list of strings using the `args` keyword.
 
 The process is terminated when the variable `<name>` goes out of scope (at the end of the block in which it was created) by closing its stdin.
 When the process fails to terminate successfully within a timeout, the test fails.
@@ -422,6 +444,31 @@
 
 import foo
 ```
+
+### Assets
+
+To provide the used test tool with access to auxiliary files needed for the
+test execution, asset objects can be defined. The definition is done on the
+toplevel using the `asset` keyword, giving the asset object name and a path to
+the asset on the filesystem, relative to the directory containing the test
+script:
+
+```
+asset my_asset:
+    path: ../path/to/file
+```
+
+Such defined asset object can then be used in expressions within tests or function definitions:
+
+```
+test:
+    spawn p
+    send to p "use-asset ${my_asset.path}"
+```
+
+The `my_asset.path` expression expands to a strict containing path to the asset
+that can be used by the spawn process `p`. The process should not try to modify
+the file.
 
 
 Optional dependencies
diff --git a/erebos-tester.cabal b/erebos-tester.cabal
--- a/erebos-tester.cabal
+++ b/erebos-tester.cabal
@@ -1,7 +1,7 @@
 cabal-version:       3.0
 
 name:                erebos-tester
-version:             0.3.1
+version:             0.3.2
 synopsis:            Test framework with virtual network using Linux namespaces
 description:
     This framework is intended mainly for networking libraries/applications and
@@ -47,6 +47,7 @@
         Main.hs
 
     other-modules:
+        Asset
         Config
         GDB
         Network
@@ -55,6 +56,7 @@
         Parser
         Parser.Core
         Parser.Expr
+        Parser.Shell
         Parser.Statement
         Paths_erebos_tester
         Process
@@ -63,9 +65,11 @@
         Script.Expr
         Script.Expr.Class
         Script.Module
+        Script.Shell
         Script.Var
         Test
         Test.Builtins
+        TestMode
         Util
         Version
         Version.Git
@@ -77,6 +81,7 @@
         src/main.c
 
     other-extensions:
+        CPP
         TemplateHaskell
     default-extensions:
         DefaultSignatures
diff --git a/src/Asset.hs b/src/Asset.hs
new file mode 100644
--- /dev/null
+++ b/src/Asset.hs
@@ -0,0 +1,33 @@
+module Asset (
+    Asset(..),
+    AssetPath(..),
+) where
+
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.Typeable
+
+import Script.Expr.Class
+
+data Asset = Asset
+    { assetPath :: AssetPath
+    }
+
+newtype AssetPath = AssetPath FilePath
+
+textAssetPath :: AssetPath -> Text
+textAssetPath (AssetPath path) = T.pack path
+
+instance ExprType Asset where
+    textExprType _ = "asset"
+    textExprValue asset = "asset:" <> textAssetPath (assetPath asset)
+
+    recordMembers =
+        [ ( "path", RecordSelector $ assetPath )
+        ]
+
+instance ExprType AssetPath where
+    textExprType _ = "filepath"
+    textExprValue = ("filepath:" <>) . textAssetPath
+
+    exprExpansionConvTo = cast textAssetPath
diff --git a/src/GDB.hs b/src/GDB.hs
--- a/src/GDB.hs
+++ b/src/GDB.hs
@@ -75,7 +75,7 @@
 
     let process = Process
             { procName = ProcNameGDB
-            , procHandle = handle
+            , procHandle = Left handle
             , procStdin = hin
             , procOutput = pout
             , procKillWith = Nothing
@@ -144,7 +144,7 @@
 
 addInferior :: MonadOutput m => GDB -> Process -> m ()
 addInferior gdb process = do
-    liftIO (getPid $ procHandle process) >>= \case
+    liftIO (either getPid (\_ -> return Nothing) $ procHandle process) >>= \case
         Nothing -> outProc OutputError process $ "failed to get PID"
         Just pid -> do
             tgid <- liftIO (atomically $ tryReadTChan $ gdbThreadGroups gdb) >>= \case
diff --git a/src/Main.hs b/src/Main.hs
--- a/src/Main.hs
+++ b/src/Main.hs
@@ -8,6 +8,7 @@
 import Data.Text qualified as T
 
 import Text.Read (readMaybe)
+import Text.Megaparsec (errorBundlePretty, showErrorComponent)
 
 import System.Console.GetOpt
 import System.Directory
@@ -26,6 +27,7 @@
 import Run
 import Script.Module
 import Test
+import TestMode
 import Util
 import Version
 
@@ -36,6 +38,7 @@
     , optColor :: Maybe Bool
     , optShowHelp :: Bool
     , optShowVersion :: Bool
+    , optTestMode :: Bool
     }
 
 defaultCmdlineOptions :: CmdlineOptions
@@ -46,9 +49,10 @@
     , optColor = Nothing
     , optShowHelp = False
     , optShowVersion = False
+    , optTestMode = False
     }
 
-options :: [OptDescr (CmdlineOptions -> CmdlineOptions)]
+options :: [ OptDescr (CmdlineOptions -> CmdlineOptions) ]
 options =
     [ Option ['T'] ["tool"]
         (ReqArg (\str -> to $ \opts -> case break (==':') str of
@@ -95,6 +99,13 @@
   where
     to f opts = opts { optTest = f (optTest opts) }
 
+hiddenOptions :: [ OptDescr (CmdlineOptions -> CmdlineOptions) ]
+hiddenOptions =
+    [ Option [] [ "test-mode" ]
+        (NoArg (\opts -> opts { optTestMode = True }))
+        "test mode"
+    ]
+
 main :: IO ()
 main = do
     configPath <- findConfig
@@ -112,15 +123,21 @@
             }
 
     args <- getArgs
-    (opts, ofiles) <- case getOpt Permute options args of
+    (opts, oselection) <- case getOpt Permute (options ++ hiddenOptions) args of
         (o, files, []) -> return (foldl (flip id) initOpts o, files)
         (_, _, errs) -> do
             hPutStrLn stderr $ concat errs <> "Try `erebos-tester --help' for more information."
             exitFailure
 
+    let ( ofiles, otests )
+            | any (any isPathSeparator) oselection = ( oselection, [] )
+            | otherwise = ( [], map T.pack oselection )
+
     when (optShowHelp opts) $ do
         let header = unlines
-                [ "Usage: erebos-tester [<option>...] [<script>[:<test>]...]"
+                [ "Usage: erebos-tester [<option>...] [<test-name>...]"
+                , "   or: erebos-tester [<option>...] <script>[:<test>]..."
+                , "  <test-name> name of a test from project configuration"
                 , "  <script>    path to test script file"
                 , "  <test>      name of the test to run"
                 , ""
@@ -133,10 +150,16 @@
         putStrLn versionLine
         exitSuccess
 
-    getPermissions (head $ words $ optDefaultTool $ optTest opts) >>= \perms -> do
-        when (not $ executable perms) $ do
-            fail $ optDefaultTool (optTest opts) <> " is not executable"
+    when (optTestMode opts) $ do
+        testMode
+        exitSuccess
 
+    case words $ optDefaultTool $ optTest opts of
+        (path : _) -> getPermissions path >>= \perms -> do
+            when (not $ executable perms) $ do
+                fail $ "‘" <> path <> "’ is not executable"
+        _ -> fail $ "invalid tool argument: ‘" <> optDefaultTool (optTest opts) <> "’"
+
     files <- if not (null ofiles)
         then return $ flip map ofiles $ \ofile ->
             case span (/= ':') ofile of
@@ -149,24 +172,46 @@
     useColor <- case optColor opts of
         Just use -> return use
         Nothing -> queryTerminal (Fd 1)
-    out <- startOutput (optVerbose opts) useColor
+    let outputStyle
+            | optVerbose opts = OutputStyleVerbose
+            | otherwise       = OutputStyleQuiet
+    out <- startOutput outputStyle useColor
 
-    ( modules, allModules ) <- parseTestFiles $ map fst files
-    tests <- forM (zip modules files) $ \( Module {..}, ( filePath, mbTestName )) -> do
-        case mbTestName of
-            Nothing -> return moduleTests
-            Just name
-                | Just test <- find ((==name) . testName) moduleTests
-                -> return [ test ]
+    ( modules, allModules ) <- parseTestFiles (map fst files) >>= \case
+        Right res -> do
+            return res
+        Left err -> do
+            case err of
+                ImportModuleError bundle ->
+                    putStr (errorBundlePretty bundle)
+                _ -> do
+                    putStrLn (showErrorComponent err)
+            exitFailure
+
+    tests <- if null otests
+        then fmap concat $ forM (zip modules files) $ \( Module {..}, ( filePath, mbTestName )) -> do
+            case mbTestName of
+                Nothing -> return moduleTests
+                Just name
+                    | Just test <- find ((name ==) . testName) moduleTests
+                    -> return [ test ]
+                    | otherwise
+                    -> do
+                        hPutStrLn stderr $ "Test ‘" <> T.unpack name <> "’ not found in ‘" <> filePath <> "’"
+                        exitFailure
+        else forM otests $ \name -> if
+                | Just test <- find ((name ==) . testName) $ concatMap moduleTests modules
+                -> return test
                 | otherwise
                 -> do
-                    hPutStrLn stderr $ "Test `" <> T.unpack name <> "' not found in `" <> filePath <> "'"
+                    hPutStrLn stderr $ "Test ‘" <> T.unpack name <> "’ not found"
                     exitFailure
 
+
     let globalDefs = evalGlobalDefs $ concatMap (\m -> map (first ( moduleName m, )) $ moduleDefinitions m) allModules
 
     ok <- allM (runTest out (optTest opts) globalDefs) $
-        concat $ replicate (optRepeat opts) $ concat tests
+        concat $ replicate (optRepeat opts) tests
     when (not ok) exitFailure
 
 foreign export ccall testerMain :: IO ()
diff --git a/src/Output.hs b/src/Output.hs
--- a/src/Output.hs
+++ b/src/Output.hs
@@ -1,5 +1,5 @@
 module Output (
-    Output, OutputType(..),
+    Output, OutputStyle(..), OutputType(..),
     MonadOutput(..),
     startOutput,
     resetOutputTime,
@@ -9,7 +9,6 @@
 ) where
 
 import Control.Concurrent.MVar
-import Control.Monad
 import Control.Monad.IO.Class
 import Control.Monad.Reader
 
@@ -18,9 +17,10 @@
 import Data.Text.Lazy qualified as TL
 import Data.Text.Lazy.IO qualified as TL
 
+import System.Clock
 import System.Console.Haskeline
 import System.Console.Haskeline.History
-import System.Clock
+import System.IO
 
 import Text.Printf
 
@@ -31,7 +31,7 @@
     }
 
 data OutputConfig = OutputConfig
-    { outVerbose :: Bool
+    { outStyle :: OutputStyle
     , outUseColor :: Bool
     }
 
@@ -40,27 +40,36 @@
     , outHistory :: History
     }
 
-data OutputType = OutputChildStdout
-                | OutputChildStderr
-                | OutputChildStdin
-                | OutputChildInfo
-                | OutputChildFail
-                | OutputMatch
-                | OutputMatchFail
-                | OutputError
-                | OutputAlways
+data OutputStyle
+    = OutputStyleQuiet
+    | OutputStyleVerbose
+    | OutputStyleTest
+    deriving (Eq)
 
+data OutputType
+    = OutputChildStdout
+    | OutputChildStderr
+    | OutputChildStdin
+    | OutputChildInfo
+    | OutputChildFail
+    | OutputMatch
+    | OutputMatchFail
+    | OutputError
+    | OutputAlways
+    | OutputTestRaw
+
 class MonadIO m => MonadOutput m where
     getOutput :: m Output
 
 instance MonadIO m => MonadOutput (ReaderT Output m) where
     getOutput = ask
 
-startOutput :: Bool -> Bool -> IO Output
-startOutput outVerbose outUseColor = do
+startOutput :: OutputStyle -> Bool -> IO Output
+startOutput outStyle outUseColor = do
     outState <- newMVar OutputState { outPrint = TL.putStrLn, outHistory = emptyHistory }
     outConfig <- pure OutputConfig {..}
     outStartedAt <- newMVar =<< getTime Monotonic
+    hSetBuffering stdout LineBuffering
     return Output {..}
 
 resetOutputTime :: Output -> IO ()
@@ -77,6 +86,7 @@
 outColor OutputMatchFail = T.pack "31"
 outColor OutputError = T.pack "31"
 outColor OutputAlways = "0"
+outColor OutputTestRaw = "0"
 
 outSign :: OutputType -> Text
 outSign OutputChildStdout = T.empty
@@ -88,11 +98,25 @@
 outSign OutputMatchFail = T.pack "/"
 outSign OutputError = T.pack "!!"
 outSign OutputAlways = T.empty
+outSign OutputTestRaw = T.empty
 
 outArr :: OutputType -> Text
 outArr OutputChildStdin = "<"
 outArr _ = ">"
 
+outTestLabel :: OutputType -> Text
+outTestLabel = \case
+    OutputChildStdout -> "child-stdout"
+    OutputChildStderr -> "child-stderr"
+    OutputChildStdin -> "child-stdin"
+    OutputChildInfo -> "child-info"
+    OutputChildFail -> "child-fail"
+    OutputMatch -> "match"
+    OutputMatchFail -> "match-fail"
+    OutputError -> "error"
+    OutputAlways -> "other"
+    OutputTestRaw -> ""
+
 printWhenQuiet :: OutputType -> Bool
 printWhenQuiet = \case
     OutputChildStderr -> True
@@ -107,7 +131,14 @@
 
 outLine :: MonadOutput m => OutputType -> Maybe Text -> Text -> m ()
 outLine otype prompt line = ioWithOutput $ \out ->
-    when (outVerbose (outConfig out) || printWhenQuiet otype) $ do
+    case outStyle (outConfig out) of
+        OutputStyleQuiet
+            | printWhenQuiet otype -> normalOutput out
+            | otherwise -> return ()
+        OutputStyleVerbose -> normalOutput out
+        OutputStyleTest -> testOutput out
+  where
+    normalOutput out = do
         stime <- readMVar (outStartedAt out)
         nsecs <- toNanoSecs . (`diffTimeSpec` stime) <$> getTime Monotonic
         withMVar (outState out) $ \st -> do
@@ -122,6 +153,16 @@
                     then [ T.pack "\ESC[0m" ]
                     else []
                 ]
+
+    testOutput out = do
+        withMVar (outState out) $ \st -> do
+            outPrint st $ case otype of
+                OutputTestRaw -> TL.fromStrict line
+                _ -> TL.fromChunks
+                    [ outTestLabel otype, " "
+                    , maybe "-" id prompt, " "
+                    , line
+                    ]
 
 outPromptGetLine :: MonadOutput m => Text -> m (Maybe Text)
 outPromptGetLine = outPromptGetLineCompletion noCompletion
diff --git a/src/Parser.hs b/src/Parser.hs
--- a/src/Parser.hs
+++ b/src/Parser.hs
@@ -2,9 +2,11 @@
 
 module Parser (
     parseTestFiles,
+    CustomTestError(..),
 ) where
 
 import Control.Monad
+import Control.Monad.Except
 import Control.Monad.State
 
 import Data.IORef
@@ -22,10 +24,10 @@
 import Text.Megaparsec.Char.Lexer qualified as L
 
 import System.Directory
-import System.Exit
 import System.FilePath
 import System.IO.Error
 
+import Asset
 import Network
 import Parser.Core
 import Parser.Expr
@@ -47,9 +49,9 @@
         wsymbol "test"
         lexeme $ TL.toStrict <$> takeWhileP (Just "test name") (/=':')
 
-parseDefinition :: TestParser ( VarName, SomeExpr )
-parseDefinition = label "symbol definition" $ do
-    def@( name, expr ) <- localState $ L.indentBlock scn $ do
+parseDefinition :: Pos -> TestParser ( VarName, SomeExpr )
+parseDefinition href = label "symbol definition" $ do
+    def@( name, expr ) <- localState $ do
         wsymbol "def"
         name <- varName
         argsDecl <- functionArguments (\off _ -> return . ( off, )) varName mzero (\_ -> return . VarName)
@@ -57,19 +59,20 @@
             tvar <- newTypeVar
             modify $ \s -> s { testVars = ( vname, ( LocalVarName vname, ExprTypeVar tvar )) : testVars s }
             return ( off, vname, tvar )
-        choice
+        SomeExpr expr <- choice
             [ do
                 osymbol ":"
-                let finish steps = do
-                        atypes' <- getInferredTypes atypes
-                        ( name, ) . SomeExpr . ArgsReq atypes' . FunctionAbstraction <$> replaceDynArgs (mconcat steps)
-                return $ L.IndentSome Nothing finish testStep
+                scn
+                ref <- L.indentGuard scn GT href
+                SomeExpr <$> blockOf ref testStep
             , do
                 osymbol "="
-                SomeExpr (expr :: Expr e) <- someExpr
-                atypes' <- getInferredTypes atypes
-                L.IndentNone . ( name, ) . SomeExpr . ArgsReq atypes' . FunctionAbstraction <$> replaceDynArgs expr
+                someExpr <* eol
             ]
+        scn
+        atypes' <- getInferredTypes atypes
+        sexpr <- SomeExpr . ArgsReq atypes' . FunctionAbstraction <$> replaceDynArgs expr
+        return ( name, sexpr )
     modify $ \s -> s { testVars = ( name, ( GlobalVarName (testCurrentModuleName s) name, someExprType expr )) : testVars s }
     return def
   where
@@ -98,12 +101,37 @@
                 replaceArgs (SomeExpr e) = SomeExpr (go unif e)
             e -> e
 
+parseAsset :: Pos -> TestParser ( VarName, SomeExpr )
+parseAsset href = label "asset definition" $ do
+    wsymbol "asset"
+    name <- varName
+    osymbol ":"
+    void eol
+    ref <- L.indentGuard scn GT href
+
+    wsymbol "path"
+    osymbol ":"
+    off <- stateOffset <$> getParserState
+    path <- TL.unpack <$> takeWhile1P Nothing (/= '\n')
+    dir <- takeDirectory <$> gets testSourcePath
+    absPath <- liftIO (makeAbsolute $ dir </> path)
+    let assetPath = AssetPath absPath
+    liftIO (doesPathExist absPath) >>= \case
+        True -> return ()
+        False -> registerParseError $ FancyError off $ S.singleton $ ErrorCustom $ FileNotFound absPath
+
+    void $ L.indentGuard scn LT ref
+    let expr = SomeExpr $ Pure Asset {..}
+    modify $ \s -> s { testVars = ( name, ( GlobalVarName (testCurrentModuleName s) name, someExprType expr )) : testVars s }
+    return ( name, expr )
+
 parseExport :: TestParser [ Toplevel ]
 parseExport = label "export declaration" $ toplevel id $ do
+    ref <- L.indentLevel
     wsymbol "export"
     choice
       [ do
-        def@( name, _ ) <- parseDefinition
+        def@( name, _ ) <- parseDefinition ref <|> parseAsset ref
         return [ ToplevelDefinition def, ToplevelExport name ]
       , do
         names <- listOf varName
@@ -138,7 +166,8 @@
     modify $ \s -> s { testCurrentModuleName = moduleName }
     toplevels <- fmap concat $ many $ choice
         [ (: []) <$> parseTestDefinition
-        , (: []) <$> toplevel ToplevelDefinition parseDefinition
+        , (: []) <$> toplevel ToplevelDefinition (parseDefinition pos1)
+        , (: []) <$> toplevel ToplevelDefinition (parseAsset pos1)
         , parseExport
         , parseImport
         ]
@@ -148,51 +177,48 @@
     eof
     return Module {..}
 
-parseTestFiles :: [ FilePath ] -> IO ( [ Module ], [ Module ] )
+parseTestFiles :: [ FilePath ] -> IO (Either CustomTestError ( [ Module ], [ Module ] ))
 parseTestFiles paths = do
     parsedModules <- newIORef []
-    requestedModules <- reverse <$> foldM (go parsedModules) [] paths
-    allModules <- map snd <$> readIORef parsedModules
-    return ( requestedModules, allModules )
+    runExceptT $ do
+        requestedModules <- reverse <$> foldM (go parsedModules) [] paths
+        allModules <- map snd <$> liftIO (readIORef parsedModules)
+        return ( requestedModules, allModules )
   where
     go parsedModules res path = do
-        let moduleName = error "current module name should be set at the beginning of parseTestModule"
-        parseTestFile parsedModules moduleName path >>= \case
-            Left (ImportModuleError bundle) -> do
-                putStr (errorBundlePretty bundle)
-                exitFailure
+        liftIO (parseTestFile parsedModules Nothing path) >>= \case
             Left err -> do
-                putStr (showErrorComponent err)
-                exitFailure
+                throwError err
             Right cur -> do
                 return $ cur : res
 
-parseTestFile :: IORef [ ( FilePath, Module ) ] -> ModuleName -> FilePath -> IO (Either CustomTestError Module)
-parseTestFile parsedModules moduleName path = do
+parseTestFile :: IORef [ ( FilePath, Module ) ] -> Maybe ModuleName -> FilePath -> IO (Either CustomTestError Module)
+parseTestFile parsedModules mbModuleName path = do
     absPath <- makeAbsolute path
     (lookup absPath <$> readIORef parsedModules) >>= \case
         Just found -> return $ Right found
         Nothing -> do
             let initState = TestParserState
-                    { testVars = concat
+                    { testSourcePath = path
+                    , testVars = concat
                         [ map (\(( mname, name ), value ) -> ( name, ( GlobalVarName mname name, someVarValueType value ))) $ M.toList builtins
                         ]
                     , testContext = SomeExpr (Undefined "void" :: Expr Void)
                     , testNextTypeVar = 0
                     , testTypeUnif = M.empty
-                    , testCurrentModuleName = moduleName
+                    , testCurrentModuleName = fromMaybe (error "current module name should be set at the beginning of parseTestModule") mbModuleName
                     , testParseModule = \(ModuleName current) mname@(ModuleName imported) -> do
                         let projectRoot = iterate takeDirectory absPath !! length current
-                        parseTestFile parsedModules mname $ projectRoot </> foldr (</>) "" (map T.unpack imported) <.> takeExtension absPath
+                        parseTestFile parsedModules (Just mname) $ projectRoot </> foldr (</>) "" (map T.unpack imported) <.> takeExtension absPath
                     }
             mbContent <- (Just <$> TL.readFile path) `catchIOError` \e ->
                 if isDoesNotExistError e then return Nothing else ioError e
             case mbContent of
                 Just content -> do
-                    runTestParser path content initState (parseTestModule absPath) >>= \case
+                    runTestParser content initState (parseTestModule absPath) >>= \case
                         Left bundle -> do
                             return $ Left $ ImportModuleError bundle
                         Right testModule -> do
                             modifyIORef parsedModules (( absPath, testModule ) : )
                             return $ Right testModule
-                Nothing -> return $ Left $ ModuleNotFound moduleName
+                Nothing -> return $ Left $ maybe (FileNotFound path) ModuleNotFound mbModuleName
diff --git a/src/Parser/Core.hs b/src/Parser/Core.hs
--- a/src/Parser/Core.hs
+++ b/src/Parser/Core.hs
@@ -27,6 +27,7 @@
         , MonadState TestParserState
         , MonadPlus
         , MonadFail
+        , MonadIO
         , MonadParsec CustomTestError TestStream
         )
 
@@ -36,6 +37,7 @@
 
 data CustomTestError
     = ModuleNotFound ModuleName
+    | FileNotFound FilePath
     | ImportModuleError (ParseErrorBundle TestStream CustomTestError)
     deriving (Eq)
 
@@ -44,17 +46,22 @@
     compare (ModuleNotFound _) _                  = LT
     compare _                  (ModuleNotFound _) = GT
 
+    compare (FileNotFound a) (FileNotFound b) = compare a b
+    compare (FileNotFound _) _                = LT
+    compare _                (FileNotFound _) = GT
+
     -- Ord instance is required to store errors in Set, but there shouldn't be
     -- two ImportModuleErrors at the same possition, so "dummy" comparison
     -- should be ok.
     compare (ImportModuleError _) (ImportModuleError _) = EQ
 
 instance ShowErrorComponent CustomTestError where
-    showErrorComponent (ModuleNotFound name) = "module `" <> T.unpack (textModuleName name) <> "' not found"
+    showErrorComponent (ModuleNotFound name) = "module ‘" <> T.unpack (textModuleName name) <> "’ not found"
+    showErrorComponent (FileNotFound path) = "file ‘" <> path <> "’ not found"
     showErrorComponent (ImportModuleError bundle) = "error parsing imported module:\n" <> errorBundlePretty bundle
 
-runTestParser :: String -> TestStream -> TestParserState -> TestParser a -> IO (Either (ParseErrorBundle TestStream CustomTestError) a)
-runTestParser path content initState (TestParser parser) = flip (flip runParserT path) content . flip evalStateT initState $ parser
+runTestParser :: TestStream -> TestParserState -> TestParser a -> IO (Either (ParseErrorBundle TestStream CustomTestError) a)
+runTestParser content initState (TestParser parser) = flip (flip runParserT (testSourcePath initState)) content . flip evalStateT initState $ parser
 
 data Toplevel
     = ToplevelTest Test
@@ -63,7 +70,8 @@
     | ToplevelImport ( ModuleName, VarName )
 
 data TestParserState = TestParserState
-    { testVars :: [ ( VarName, ( FqVarName, SomeExprType )) ]
+    { testSourcePath :: FilePath
+    , testVars :: [ ( VarName, ( FqVarName, SomeExprType )) ]
     , testContext :: SomeExpr
     , testNextTypeVar :: Int
     , testTypeUnif :: Map TypeVar SomeExprType
@@ -254,6 +262,18 @@
 listOf item = do
     x <- item
     (x:) <$> choice [ symbol "," >> listOf item, return [] ]
+
+blockOf :: Monoid a => Pos -> TestParser a -> TestParser a
+blockOf indent step = go
+  where
+    go = do
+        scn
+        pos <- L.indentLevel
+        optional eof >>= \case
+            Just _ -> return mempty
+            _ | pos <  indent -> return mempty
+              | pos == indent -> mappend <$> step <*> go
+              | otherwise     -> L.incorrectIndent EQ indent pos
 
 
 getSourceLine :: TestParser SourceLine
diff --git a/src/Parser/Expr.hs b/src/Parser/Expr.hs
--- a/src/Parser/Expr.hs
+++ b/src/Parser/Expr.hs
@@ -11,6 +11,8 @@
     literal,
     variable,
 
+    stringExpansion,
+
     checkFunctionArguments,
     functionArguments,
 ) where
@@ -35,8 +37,6 @@
 import Text.Megaparsec.Char
 import Text.Megaparsec.Char.Lexer qualified as L
 import Text.Megaparsec.Error.Builder qualified as Err
-import Text.Regex.TDFA qualified as RE
-import Text.Regex.TDFA.Text qualified as RE
 
 import Parser.Core
 import Script.Expr
@@ -94,8 +94,8 @@
         , between (char '{') (char '}') someExpr
         ]
 
-stringExpansion :: ExprType a => Text -> (forall b. ExprType b => Expr b -> [Maybe (Expr a)]) -> TestParser (Expr a)
-stringExpansion tname conv = do
+expressionExpansion :: forall a. ExprType a => Text -> TestParser (Expr a)
+expressionExpansion tname = do
     off <- stateOffset <$> getParserState
     SomeExpr e <- someExpansion
     let err = do
@@ -103,8 +103,11 @@
                 [ tname, T.pack " expansion not defined for '", textExprType e, T.pack "'" ]
             return $ Undefined "expansion not defined for type"
 
-    maybe err return $ listToMaybe $ catMaybes $ conv e
+    maybe err (return . (<$> e)) $ listToMaybe $ catMaybes [ cast (id :: a -> a), exprExpansionConvTo, exprExpansionConvFrom ]
 
+stringExpansion :: TestParser (Expr Text)
+stringExpansion = expressionExpansion "string"
+
 numberLiteral :: TestParser SomeExpr
 numberLiteral = label "number" $ lexeme $ do
     x <- L.scientific
@@ -131,11 +134,7 @@
                     , char 't' >> return '\t'
                     ]
                 (Pure (T.singleton c) :) <$> inner
-            ,do e <- stringExpansion (T.pack "string") $ \e ->
-                    [ cast e
-                    , fmap (T.pack . show @Integer) <$> cast e
-                    , fmap (T.pack . show @Scientific) <$> cast e
-                    ]
+            ,do e <- stringExpansion
                 (e:) <$> inner
             ]
     Concat <$> inner
@@ -153,19 +152,14 @@
                     , anySingle >>= \c -> return (Pure $ RegexPart $ T.pack ['\\', c])
                     ]
                 (s:) <$> inner
-            ,do e <- stringExpansion (T.pack "regex") $ \e ->
-                    [ cast e
-                    , fmap RegexString <$> cast e
-                    , fmap (RegexString . T.pack . show @Integer) <$> cast e
-                    , fmap (RegexString . T.pack . show @Scientific) <$> cast e
-                    ]
+            ,do e <- expressionExpansion (T.pack "regex")
                 (e:) <$> inner
             ]
     parts <- inner
     let testEval = \case
             Pure (RegexPart p) -> p
             _ -> ""
-    case RE.compile RE.defaultCompOpt RE.defaultExecOpt $ T.concat $ map testEval parts of
+    case regexCompile $ T.concat $ map testEval parts of
         Left err -> registerParseError $ FancyError off $ S.singleton $ ErrorFail $ T.unpack $ T.concat
             [ "failed to parse regular expression: ", T.pack err ]
         Right _ -> return ()
@@ -400,7 +394,7 @@
         Nothing -> do
             registerParseError $ FancyError poff $ S.singleton $ ErrorFail $ T.unpack $
                 case kw of
-                    Just (ArgumentKeyword tkw) -> "unexpected parameter with keyword `" <> tkw <> "'"
+                    Just (ArgumentKeyword tkw) -> "unexpected parameter with keyword ‘" <> tkw <> "’"
                     Nothing                    -> "unexpected parameter"
             return sexpr
 
diff --git a/src/Parser/Shell.hs b/src/Parser/Shell.hs
new file mode 100644
--- /dev/null
+++ b/src/Parser/Shell.hs
@@ -0,0 +1,78 @@
+module Parser.Shell (
+    ShellScript,
+    shellScript,
+) where
+
+import Control.Monad
+
+import Data.Char
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.Text.Lazy qualified as TL
+
+import Text.Megaparsec
+import Text.Megaparsec.Char
+import Text.Megaparsec.Char.Lexer qualified as L
+
+import Parser.Core
+import Parser.Expr
+import Script.Expr
+import Script.Shell
+
+parseArgument :: TestParser (Expr Text)
+parseArgument = lexeme $ fmap (App AnnNone (Pure T.concat) <$> foldr (liftA2 (:)) (Pure [])) $ some $ choice
+    [ doubleQuotedString
+    , singleQuotedString
+    , escapedChar
+    , stringExpansion
+    , unquotedString
+    ]
+  where
+    specialChars = [ '\"', '\\', '$' ]
+
+    unquotedString :: TestParser (Expr Text)
+    unquotedString = do
+        Pure . TL.toStrict <$> takeWhile1P Nothing (\c -> not (isSpace c) && c `notElem` specialChars)
+
+    doubleQuotedString :: TestParser (Expr Text)
+    doubleQuotedString = do
+        void $ char '"'
+        let inner = choice
+                [ char '"' >> return []
+                , (:) <$> (Pure . TL.toStrict <$> takeWhile1P Nothing (`notElem` specialChars)) <*> inner
+                , (:) <$> escapedChar <*> inner
+                , (:) <$> stringExpansion <*> inner
+                ]
+        App AnnNone (Pure T.concat) . foldr (liftA2 (:)) (Pure []) <$> inner
+
+    singleQuotedString :: TestParser (Expr Text)
+    singleQuotedString = do
+        Pure . TL.toStrict <$> (char '\'' *> takeWhileP Nothing (/= '\'') <* char '\'')
+
+    escapedChar :: TestParser (Expr Text)
+    escapedChar = do
+        void $ char '\\'
+        Pure <$> choice
+            [ char '\\' >> return "\\"
+            , char '"' >> return "\""
+            , char '$' >> return "$"
+            , char 'n' >> return "\n"
+            , char 'r' >> return "\r"
+            , char 't' >> return "\t"
+            ]
+
+parseArguments :: TestParser (Expr [ Text ])
+parseArguments = foldr (liftA2 (:)) (Pure []) <$> many parseArgument
+
+shellStatement :: TestParser (Expr [ ShellStatement ])
+shellStatement = label "shell statement" $ do
+    command <- parseArgument
+    args <- parseArguments
+    return $ fmap (: []) $ ShellStatement
+        <$> command
+        <*> args
+
+shellScript :: TestParser (Expr ShellScript)
+shellScript = do
+    indent <- L.indentLevel
+    fmap ShellScript <$> blockOf indent shellStatement
diff --git a/src/Parser/Statement.hs b/src/Parser/Statement.hs
--- a/src/Parser/Statement.hs
+++ b/src/Parser/Statement.hs
@@ -21,13 +21,14 @@
 import Network (Network, Node)
 import Parser.Core
 import Parser.Expr
+import Parser.Shell
 import Process (Process)
 import Script.Expr
 import Script.Expr.Class
 import Test
 import Util
 
-letStatement :: TestParser (Expr TestBlock)
+letStatement :: TestParser (Expr (TestBlock ()))
 letStatement = do
     line <- getSourceLine
     indent <- L.indentLevel
@@ -44,7 +45,7 @@
         body <- testBlock indent
         return $ Let line tname e body
 
-forStatement :: TestParser (Expr TestBlock)
+forStatement :: TestParser (Expr (TestBlock ()))
 forStatement = do
     ref <- L.indentLevel
     wsymbol "for"
@@ -69,7 +70,52 @@
             <$> (unpack <$> e)
             <*> LambdaAbstraction tname body
 
-exprStatement :: TestParser (Expr TestBlock)
+shellStatement :: TestParser (Expr (TestBlock ()))
+shellStatement = do
+    ref <- L.indentLevel
+    wsymbol "shell"
+    parseParams ref Nothing Nothing
+
+  where
+    parseParamKeyword kw prev = do
+        off <- stateOffset <$> getParserState
+        wsymbol kw
+        when (isJust prev) $ do
+            registerParseError $ FancyError off $ S.singleton $ ErrorFail $
+                "unexpected parameter with keyword ‘" <> kw <> "’"
+
+    parseParams ref mbpname mbnode = choice
+        [ do
+            parseParamKeyword "as" mbpname
+            pname <- newVarName
+            parseParams ref (Just pname) mbnode
+
+        , do
+            parseParamKeyword "on" mbnode
+            node <- typedExpr
+            parseParams ref mbpname (Just node)
+
+        , do
+            off <- stateOffset <$> getParserState
+            symbol ":"
+            node <- case mbnode of
+                Just node -> return node
+                Nothing -> do
+                    registerParseError $ FancyError off $ S.singleton $ ErrorFail $
+                        "missing parameter with keyword ‘on’"
+                    return $ Undefined ""
+
+            void eol
+            void $ L.indentGuard scn GT ref
+            script <- shellScript
+            cont <- testBlock ref
+            let expr | Just pname <- mbpname = LambdaAbstraction pname cont
+                     | otherwise = const <$> cont
+            return $ TestBlockStep EmptyTestBlock <$>
+                (SpawnShell mbpname <$> node <*> script <*> expr)
+        ]
+
+exprStatement :: TestParser (Expr (TestBlock ()))
 exprStatement = do
     ref <- L.indentLevel
     off <- stateOffset <$> getParserState
@@ -79,11 +125,11 @@
         , unifyExpr off Proxy expr
         ]
   where
-    continuePartial :: ExprType a => Int -> Pos -> Expr a -> TestParser (Expr TestBlock)
+    continuePartial :: ExprType a => Int -> Pos -> Expr a -> TestParser (Expr (TestBlock ()))
     continuePartial off ref expr = do
         symbol ":"
         void eol
-        (fun :: Expr (FunctionType TestBlock)) <- unifyExpr off Proxy expr
+        (fun :: Expr (FunctionType (TestBlock ()))) <- unifyExpr off Proxy expr
         scn
         indent <- L.indentGuard scn GT ref
         blockOf indent $ do
@@ -229,10 +275,10 @@
 cmdLine :: CommandDef SourceLine
 cmdLine = param ""
 
-newtype InnerBlock a = InnerBlock { fromInnerBlock :: [ a ] -> TestBlock }
+newtype InnerBlock a = InnerBlock { fromInnerBlock :: [ a ] -> TestBlock () }
 
 instance ExprType a => ParamType (InnerBlock a) where
-    type ParamRep (InnerBlock a) = ( [ TypedVarName a ], Expr TestBlock )
+    type ParamRep (InnerBlock a) = ( [ TypedVarName a ], Expr (TestBlock ()) )
     parseParam _ = mzero
     showParamType _ = "<code block>"
     paramExpr ( vars, expr ) = fmap InnerBlock $ helper vars $ const <$> expr
@@ -244,13 +290,13 @@
         combine f (x : xs) = f x xs
         combine _ [] = error "inner block parameter count mismatch"
 
-innerBlock :: CommandDef TestBlock
+innerBlock :: CommandDef (TestBlock ())
 innerBlock = ($ ([] :: [ Void ])) <$> innerBlockFun
 
-innerBlockFun :: ExprType a => CommandDef (a -> TestBlock)
+innerBlockFun :: ExprType a => CommandDef (a -> TestBlock ())
 innerBlockFun = (\f x -> f [ x ]) <$> innerBlockFunList
 
-innerBlockFunList :: ExprType a => CommandDef ([ a ] -> TestBlock)
+innerBlockFunList :: ExprType a => CommandDef ([ a ] -> TestBlock ())
 innerBlockFunList = fromInnerBlock <$> param ""
 
 newtype ExprParam a = ExprParam { fromExprParam :: a }
@@ -265,7 +311,7 @@
     showParamType _ = "<" ++ T.unpack (textExprType @a Proxy) ++ ">"
     paramExpr = fmap ExprParam
 
-command :: String -> CommandDef TestStep -> TestParser (Expr TestBlock)
+command :: String -> CommandDef (TestStep ()) -> TestParser (Expr (TestBlock ()))
 command name (CommandDef types ctor) = do
     indent <- L.indentLevel
     line <- getSourceLine
@@ -273,7 +319,7 @@
     localState $ do
         restOfLine indent [] line $ map (fmap $ \(SomeParam p@(_ :: Proxy p) Proxy) -> SomeParam p $ Nothing @(ParamRep p)) types
   where
-    restOfLine :: Pos -> [(Pos, [(String, SomeParam Maybe)])] -> SourceLine -> [(String, SomeParam Maybe)] -> TestParser (Expr TestBlock)
+    restOfLine :: Pos -> [(Pos, [(String, SomeParam Maybe)])] -> SourceLine -> [(String, SomeParam Maybe)] -> TestParser (Expr (TestBlock ()))
     restOfLine cmdi partials line params = choice
         [do void $ lookAhead eol
             let definedVariables = mconcat $ map (someParamVars . snd) params
@@ -290,7 +336,7 @@
                     , fail $ "missing " ++ (if null sym then "" else "'" ++ sym ++ "' ") ++ showParamType p
                     ]
                 (_, SomeParam (p :: Proxy p) (Just x)) -> return $ SomeParam p $ Identity x
-            return $ (TestBlock . (: [])) <$> ctor iparams
+            return $ (TestBlockStep EmptyTestBlock) <$> ctor iparams
 
         ,do symbol ":"
             scn
@@ -300,7 +346,7 @@
         ,do tryParams cmdi partials line [] params
         ]
 
-    restOfParts :: Pos -> [(Pos, [(String, SomeParam Maybe)])] -> TestParser (Expr TestBlock)
+    restOfParts :: Pos -> [(Pos, [(String, SomeParam Maybe)])] -> TestParser (Expr (TestBlock ()))
     restOfParts cmdi [] = testBlock cmdi
     restOfParts cmdi partials@((partIndent, params) : rest) = do
         scn
@@ -326,7 +372,7 @@
         ]
     tryParams _ _ _ _ [] = mzero
 
-testLocal :: TestParser (Expr TestBlock)
+testLocal :: TestParser (Expr (TestBlock ()))
 testLocal = do
     ref <- L.indentLevel
     wsymbol "local"
@@ -336,7 +382,7 @@
     indent <- L.indentGuard scn GT ref
     localState $ testBlock indent
 
-testWith :: TestParser (Expr TestBlock)
+testWith :: TestParser (Expr (TestBlock ()))
 testWith = do
     ref <- L.indentLevel
     wsymbol "with"
@@ -362,25 +408,26 @@
         modify $ \s -> s { testContext = ctx }
         testBlock indent
 
-testSubnet :: TestParser (Expr TestBlock)
+testSubnet :: TestParser (Expr (TestBlock ()))
 testSubnet = command "subnet" $ Subnet
     <$> param ""
     <*> (fromExprParam <$> paramOrContext "of")
     <*> innerBlockFun
 
-testNode :: TestParser (Expr TestBlock)
+testNode :: TestParser (Expr (TestBlock ()))
 testNode = command "node" $ DeclNode
     <$> param ""
     <*> (fromExprParam <$> paramOrContext "on")
     <*> innerBlockFun
 
-testSpawn :: TestParser (Expr TestBlock)
+testSpawn :: TestParser (Expr (TestBlock ()))
 testSpawn = command "spawn" $ Spawn
     <$> param "as"
     <*> (bimap fromExprParam fromExprParam <$> paramOrContext "on")
+    <*> (maybe [] fromExprParam <$> param "args")
     <*> innerBlockFun
 
-testExpect :: TestParser (Expr TestBlock)
+testExpect :: TestParser (Expr (TestBlock ()))
 testExpect = command "expect" $ Expect
     <$> cmdLine
     <*> (fromExprParam <$> paramOrContext "from")
@@ -388,47 +435,36 @@
     <*> param "capture"
     <*> innerBlockFunList
 
-testDisconnectNode :: TestParser (Expr TestBlock)
+testDisconnectNode :: TestParser (Expr (TestBlock ()))
 testDisconnectNode = command "disconnect_node" $ DisconnectNode
     <$> (fromExprParam <$> paramOrContext "")
     <*> innerBlock
 
-testDisconnectNodes :: TestParser (Expr TestBlock)
+testDisconnectNodes :: TestParser (Expr (TestBlock ()))
 testDisconnectNodes = command "disconnect_nodes" $ DisconnectNodes
     <$> (fromExprParam <$> paramOrContext "")
     <*> innerBlock
 
-testDisconnectUpstream :: TestParser (Expr TestBlock)
+testDisconnectUpstream :: TestParser (Expr (TestBlock ()))
 testDisconnectUpstream = command "disconnect_upstream" $ DisconnectUpstream
     <$> (fromExprParam <$> paramOrContext "")
     <*> innerBlock
 
-testPacketLoss :: TestParser (Expr TestBlock)
+testPacketLoss :: TestParser (Expr (TestBlock ()))
 testPacketLoss = command "packet_loss" $ PacketLoss
     <$> (fromExprParam <$> paramOrContext "")
     <*> (fromExprParam <$> paramOrContext "on")
     <*> innerBlock
 
 
-testBlock :: Pos -> TestParser (Expr TestBlock)
+testBlock :: Pos -> TestParser (Expr (TestBlock ()))
 testBlock indent = blockOf indent testStep
 
-blockOf :: Monoid a => Pos -> TestParser a -> TestParser a
-blockOf indent step = go
-  where
-    go = do
-        scn
-        pos <- L.indentLevel
-        optional eof >>= \case
-            Just _ -> return mempty
-            _ | pos <  indent -> return mempty
-              | pos == indent -> mappend <$> step <*> go
-              | otherwise     -> L.incorrectIndent EQ indent pos
-
-testStep :: TestParser (Expr TestBlock)
+testStep :: TestParser (Expr (TestBlock ()))
 testStep = choice
     [ letStatement
     , forStatement
+    , shellStatement
     , testLocal
     , testWith
     , testSubnet
diff --git a/src/Process.hs b/src/Process.hs
--- a/src/Process.hs
+++ b/src/Process.hs
@@ -23,6 +23,7 @@
 import qualified Data.Text.IO as T
 
 import System.Directory
+import System.Environment
 import System.Exit
 import System.FilePath
 import System.IO
@@ -39,7 +40,7 @@
 
 data Process = Process
     { procName :: ProcName
-    , procHandle :: ProcessHandle
+    , procHandle :: Either ProcessHandle ( ThreadId, MVar ExitCode )
     , procStdin :: Handle
     , procOutput :: TVar [Text]
     , procKillWith :: Maybe Signal
@@ -104,26 +105,27 @@
 
     let netns = either getNetns getNetns target
     let prefix = T.unpack $ "ip netns exec \"" <> textNetnsName netns <> "\" "
+    currentEnv <- liftIO $ getEnvironment
     (Just hin, Just hout, Just herr, handle) <- liftIO $ createProcess (shell $ prefix ++ cmd')
         { std_in = CreatePipe, std_out = CreatePipe, std_err = CreatePipe
         , cwd = Just (either netDir nodeDir target)
-        , env = Just [ ( "EREBOS_DIR", "." ) ]
+        , env = Just $ ( "EREBOS_DIR", "." ) : currentEnv
         }
     pout <- liftIO $ newTVarIO []
 
     let process = Process
             { procName = pname
-            , procHandle = handle
+            , procHandle = Left handle
             , procStdin = hin
             , procOutput = pout
             , procKillWith = killWith
             , procNode = either (const undefined) id target
             }
 
-    forkTest $ lineReadingLoop process hout $ \line -> do
+    void $ forkTest $ lineReadingLoop process hout $ \line -> do
         outProc OutputChildStdout process line
         liftIO $ atomically $ modifyTVar pout (++[line])
-    forkTest $ lineReadingLoop process herr $ \line -> do
+    void $ forkTest $ lineReadingLoop process herr $ \line -> do
         case pname of
              ProcNameTcpdump -> return ()
              _ -> outProc OutputChildStderr process line
@@ -139,14 +141,14 @@
     liftIO $ hClose $ procStdin p
     case procKillWith p of
         Nothing -> return ()
-        Just sig -> liftIO $ getPid (procHandle p) >>= \case
+        Just sig -> liftIO $ either getPid (\_ -> return Nothing) (procHandle p) >>= \case
             Nothing -> return ()
             Just pid -> signalProcess sig pid
 
     liftIO $ void $ forkIO $ do
         threadDelay 1000000
-        terminateProcess $ procHandle p
-    liftIO (waitForProcess (procHandle p)) >>= \case
+        either terminateProcess (killThread . fst) $ procHandle p
+    liftIO (either waitForProcess (takeMVar . snd) (procHandle p)) >>= \case
         ExitSuccess -> return ()
         ExitFailure code -> do
             outProc OutputChildFail p $ T.pack $ "exit code: " ++ show code
diff --git a/src/Run.hs b/src/Run.hs
--- a/src/Run.hs
+++ b/src/Run.hs
@@ -33,6 +33,7 @@
 import Process
 import Run.Monad
 import Script.Expr
+import Script.Shell
 import Test
 import Test.Builtins
 
@@ -72,7 +73,7 @@
     let sigHandler SignalInfo { siginfoSpecific = chld } = do
             processes <- readMVar procVar
             forM_ processes $ \p -> do
-                mbpid <- getPid (procHandle p)
+                mbpid <- either getPid (\_ -> return Nothing) (procHandle p)
                 when (mbpid == Just (siginfoPid chld)) $ flip runReaderT out $ do
                     let err detail = outProc OutputChildFail p detail
                     case siginfoStatus chld of
@@ -111,15 +112,16 @@
 evalGlobalDefs exprs = fix $ \gdefs ->
     builtins `M.union` M.fromList (map (fmap (evalSomeWith gdefs)) exprs)
 
-evalBlock :: TestBlock -> TestRun ()
-evalBlock (TestBlock steps) = forM_ steps $ \case
+evalBlock :: TestBlock () -> TestRun ()
+evalBlock EmptyTestBlock = return ()
+evalBlock (TestBlockStep prev step) = evalBlock prev >> case step of
     Subnet name parent inner -> do
         withSubnet parent (Just name) $ evalBlock . inner
 
     DeclNode name net inner -> do
         withNode net (Left name) $ evalBlock . inner
 
-    Spawn tvname@(TypedVarName (VarName tname)) target inner -> do
+    Spawn tvname@(TypedVarName (VarName tname)) target args inner -> do
         case target of
             Left net -> withNode net (Right tvname) go
             Right node -> go node
@@ -128,7 +130,15 @@
             opts <- asks $ teOptions . fst
             let pname = ProcName tname
                 tool = fromMaybe (optDefaultTool opts) (lookup pname $ optProcTools opts)
-            withProcess (Right node) pname Nothing tool $ evalBlock . inner
+                cmd = unwords $ tool : map (T.unpack . escape) args
+                escape = ("'" <>) . (<> "'") . T.replace "'" "'\\''"
+            withProcess (Right node) pname Nothing cmd $ evalBlock . inner
+
+    SpawnShell mbname node script inner -> do
+        let tname | Just (TypedVarName (VarName name)) <- mbname = name
+                  | otherwise = "shell"
+        let pname = ProcName tname
+        withShellProcess node pname script $ evalBlock . inner
 
     Send p line -> do
         outProc OutputChildStdin p line
diff --git a/src/Run/Monad.hs b/src/Run/Monad.hs
--- a/src/Run/Monad.hs
+++ b/src/Run/Monad.hs
@@ -109,10 +109,10 @@
     void handler
     return x
 
-forkTest :: TestRun () -> TestRun ()
+forkTest :: TestRun () -> TestRun ThreadId
 forkTest act = do
     tenv <- ask
-    void $ liftIO $ forkIO $ do
+    liftIO $ forkIO $ do
         runExceptT (flip runReaderT tenv $ fromTestRun act) >>= \case
             Left e -> atomically $ writeTVar (teFailed $ fst tenv) (Just e)
             Right () -> return ()
diff --git a/src/Script/Expr.hs b/src/Script/Expr.hs
--- a/src/Script/Expr.hs
+++ b/src/Script/Expr.hs
@@ -23,7 +23,8 @@
 
     module Script.Var,
 
-    Regex(RegexPart, RegexString), regexMatch,
+    Regex(RegexPart, RegexString),
+    regexCompile, regexMatch,
 ) where
 
 import Control.Monad
@@ -34,6 +35,8 @@
 import Data.List
 import Data.Map (Map)
 import Data.Map qualified as M
+import Data.Maybe
+import Data.Scientific
 import Data.String
 import Data.Text (Text)
 import Data.Text qualified as T
@@ -424,6 +427,12 @@
 instance ExprType Regex where
     textExprType _ = T.pack "regex"
     textExprValue _ = T.pack "<regex>"
+
+    exprExpansionConvFrom = listToMaybe $ catMaybes
+        [ cast (RegexString)
+        , cast (RegexString . T.pack . show @Integer)
+        , cast (RegexString . T.pack . show @Scientific)
+        ]
 
 regexCompile :: Text -> Either String Regex
 regexCompile src = either Left (Right . RegexCompiled src) $ RE.compile RE.defaultCompOpt RE.defaultExecOpt $
diff --git a/src/Script/Expr/Class.hs b/src/Script/Expr/Class.hs
--- a/src/Script/Expr/Class.hs
+++ b/src/Script/Expr/Class.hs
@@ -5,6 +5,7 @@
     ExprEnumerator(..),
 ) where
 
+import Data.Maybe
 import Data.Scientific
 import Data.Text (Text)
 import Data.Text qualified as T
@@ -18,6 +19,12 @@
     recordMembers :: [(Text, RecordSelector a)]
     recordMembers = []
 
+    exprExpansionConvTo :: ExprType b => Maybe (a -> b)
+    exprExpansionConvTo = Nothing
+
+    exprExpansionConvFrom :: ExprType b => Maybe (b -> a)
+    exprExpansionConvFrom = Nothing
+
     exprListUnpacker :: proxy a -> Maybe (ExprListUnpacker a)
     exprListUnpacker _ = Nothing
 
@@ -36,11 +43,19 @@
     textExprType _ = T.pack "integer"
     textExprValue x = T.pack (show x)
 
+    exprExpansionConvTo = listToMaybe $ catMaybes
+        [ cast (T.pack . show :: Integer -> Text)
+        ]
+
     exprEnumerator _ = Just $ ExprEnumerator enumFromTo enumFromThenTo
 
 instance ExprType Scientific where
     textExprType _ = T.pack "number"
     textExprValue x = T.pack (show x)
+
+    exprExpansionConvTo = listToMaybe $ catMaybes
+        [ cast (T.pack . show :: Scientific -> Text)
+        ]
 
 instance ExprType Bool where
     textExprType _ = T.pack "bool"
diff --git a/src/Script/Shell.hs b/src/Script/Shell.hs
new file mode 100644
--- /dev/null
+++ b/src/Script/Shell.hs
@@ -0,0 +1,89 @@
+module Script.Shell (
+    ShellStatement(..),
+    ShellScript(..),
+    withShellProcess,
+) where
+
+import Control.Concurrent
+import Control.Concurrent.STM
+import Control.Monad
+import Control.Monad.Except
+import Control.Monad.IO.Class
+import Control.Monad.Reader
+
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.Text.IO qualified as T
+
+import System.Exit
+import System.IO
+import System.Process hiding (ShellCommand)
+
+import Network
+import Output
+import Process
+import Run.Monad
+
+
+data ShellStatement = ShellStatement
+    { shellCommand :: Text
+    , shellArguments :: [ Text ]
+    }
+
+newtype ShellScript = ShellScript [ ShellStatement ]
+
+
+executeScript :: Node -> ProcName -> Handle -> Handle -> Handle -> ShellScript -> TestRun ()
+executeScript node pname pstdin pstdout pstderr (ShellScript statements) = do
+    forM_ statements $ \ShellStatement {..} -> case shellCommand of
+        "echo" -> liftIO $ do
+            T.hPutStrLn pstdout $ T.intercalate " " shellArguments
+            hFlush pstdout
+        cmd -> do
+            (_, _, _, phandle) <- liftIO $ createProcess_ "shell"
+                (proc (T.unpack cmd) (map T.unpack shellArguments))
+                    { std_in = UseHandle pstdin
+                    , std_out = UseHandle pstdout
+                    , std_err = UseHandle pstderr
+                    , cwd = Just (nodeDir node)
+                    , env = Just []
+                    }
+            liftIO (waitForProcess phandle) >>= \case
+                ExitSuccess -> return ()
+                ExitFailure code -> do
+                    outLine OutputChildFail (Just $ textProcName pname) $ T.pack $ "exit code: " ++ show code
+                    throwError Failed
+
+spawnShell :: Node -> ProcName -> ShellScript -> TestRun Process
+spawnShell procNode procName script = do
+    procOutput <- liftIO $ newTVarIO []
+    statusVar <- liftIO $ newEmptyMVar
+    ( pstdin, procStdin ) <- liftIO $ createPipe
+    ( hout, pstdout ) <- liftIO $ createPipe
+    ( herr, pstderr ) <- liftIO $ createPipe
+    procHandle <- fmap (Right . (, statusVar)) $ forkTest $ do
+        executeScript procNode procName pstdin pstdout pstderr script
+        liftIO $ putMVar statusVar ExitSuccess
+
+    let procKillWith = Nothing
+    let process = Process {..}
+
+    void $ forkTest $ lineReadingLoop process hout $ \line -> do
+        outProc OutputChildStdout process line
+        liftIO $ atomically $ modifyTVar procOutput (++ [ line ])
+    void $ forkTest $ lineReadingLoop process herr $ \line -> do
+        outProc OutputChildStderr process line
+
+    return process
+
+withShellProcess :: Node -> ProcName -> ShellScript -> (Process -> TestRun a) -> TestRun a
+withShellProcess node pname script inner = do
+    procVar <- asks $ teProcesses . fst
+
+    process <- spawnShell node pname script
+    liftIO $ modifyMVar_ procVar $ return . (process:)
+
+    inner process `finally` do
+        ps <- liftIO $ takeMVar procVar
+        closeProcess process `finally` do
+            liftIO $ putMVar procVar $ filter (/=process) ps
diff --git a/src/Test.hs b/src/Test.hs
--- a/src/Test.hs
+++ b/src/Test.hs
@@ -6,33 +6,45 @@
 
 import Data.Scientific
 import Data.Text (Text)
+import Data.Typeable
 
 import Network
 import Process
 import Script.Expr
+import Script.Shell
 
 data Test = Test
     { testName :: Text
-    , testSteps :: Expr TestBlock
+    , testSteps :: Expr (TestBlock ())
     }
 
-newtype TestBlock = TestBlock [ TestStep ]
-    deriving (Semigroup, Monoid)
+data TestBlock a where
+    EmptyTestBlock :: TestBlock ()
+    TestBlockStep :: TestBlock () -> TestStep a -> TestBlock a
 
-data TestStep
-    = Subnet (TypedVarName Network) Network (Network -> TestBlock)
-    | DeclNode (TypedVarName Node) Network (Node -> TestBlock)
-    | Spawn (TypedVarName Process) (Either Network Node) (Process -> TestBlock)
-    | Send Process Text
-    | Expect SourceLine Process (Traced Regex) [ TypedVarName Text ] ([ Text ] -> TestBlock)
-    | Flush Process (Maybe Regex)
-    | Guard SourceLine EvalTrace Bool
-    | DisconnectNode Node TestBlock
-    | DisconnectNodes Network TestBlock
-    | DisconnectUpstream Network TestBlock
-    | PacketLoss Scientific Node TestBlock
-    | Wait
+instance Semigroup (TestBlock ()) where
+    EmptyTestBlock <> block = block
+    block <> EmptyTestBlock = block
+    block <> TestBlockStep block' step = TestBlockStep (block <> block') step
 
-instance ExprType TestBlock where
+instance Monoid (TestBlock ()) where
+    mempty = EmptyTestBlock
+
+data TestStep a where
+    Subnet :: TypedVarName Network -> Network -> (Network -> TestBlock a) -> TestStep a
+    DeclNode :: TypedVarName Node -> Network -> (Node -> TestBlock a) -> TestStep a
+    Spawn :: TypedVarName Process -> Either Network Node -> [ Text ] -> (Process -> TestBlock a) -> TestStep a
+    SpawnShell :: Maybe (TypedVarName Process) -> Node -> ShellScript -> (Process -> TestBlock a) -> TestStep a
+    Send :: Process -> Text -> TestStep ()
+    Expect :: SourceLine -> Process -> Traced Regex -> [ TypedVarName Text ] -> ([ Text ] -> TestBlock a) -> TestStep a
+    Flush :: Process -> Maybe Regex -> TestStep ()
+    Guard :: SourceLine -> EvalTrace -> Bool -> TestStep ()
+    DisconnectNode :: Node -> TestBlock a -> TestStep a
+    DisconnectNodes :: Network -> TestBlock a -> TestStep a
+    DisconnectUpstream :: Network -> TestBlock a -> TestStep a
+    PacketLoss :: Scientific -> Node -> TestBlock a -> TestStep a
+    Wait :: TestStep ()
+
+instance Typeable a => ExprType (TestBlock a) where
     textExprType _ = "test block"
     textExprValue _ = "<test block>"
diff --git a/src/Test/Builtins.hs b/src/Test/Builtins.hs
--- a/src/Test/Builtins.hs
+++ b/src/Test/Builtins.hs
@@ -33,7 +33,7 @@
 
 builtinSend :: SomeVarValue
 builtinSend = SomeVarValue $ VarValue [] (FunctionArguments $ M.fromList atypes) $
-    \_ args -> TestBlock [ Send (getArg args (Just "to")) (getArg args Nothing) ]
+    \_ args -> TestBlockStep EmptyTestBlock $ Send (getArg args (Just "to")) (getArg args Nothing)
   where
     atypes =
         [ ( Just "to", SomeArgumentType (ContextDefault @Process) )
@@ -42,7 +42,7 @@
 
 builtinFlush :: SomeVarValue
 builtinFlush = SomeVarValue $ VarValue [] (FunctionArguments $ M.fromList atypes) $
-    \_ args -> TestBlock [ Flush (getArg args (Just "from")) (getArgMb args (Just "matching")) ]
+    \_ args -> TestBlockStep EmptyTestBlock $ Flush (getArg args (Just "from")) (getArgMb args (Just "matching"))
   where
     atypes =
         [ ( Just "from", SomeArgumentType (ContextDefault @Process) )
@@ -51,7 +51,7 @@
 
 builtinGuard :: SomeVarValue
 builtinGuard = SomeVarValue $ VarValue [] (FunctionArguments $ M.singleton Nothing (SomeArgumentType (RequiredArgument @Bool))) $
-    \sline args -> TestBlock [ Guard sline (getArgVars args Nothing) (getArg args Nothing) ]
+    \sline args -> TestBlockStep EmptyTestBlock $ Guard sline (getArgVars args Nothing) (getArg args Nothing)
 
 builtinWait :: SomeVarValue
-builtinWait = someConstValue $ TestBlock [ Wait ]
+builtinWait = someConstValue $ TestBlockStep EmptyTestBlock Wait
diff --git a/src/TestMode.hs b/src/TestMode.hs
new file mode 100644
--- /dev/null
+++ b/src/TestMode.hs
@@ -0,0 +1,130 @@
+{-# LANGUAGE CPP #-}
+
+module TestMode (
+    testMode,
+) where
+
+import Control.Monad.Except
+import Control.Monad.Reader
+import Control.Monad.State
+
+import Data.Bifunctor
+import Data.List
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.Text.IO qualified as T
+
+import System.IO.Error
+
+import Text.Megaparsec.Error
+import Text.Megaparsec.Pos
+
+import Output
+import Parser
+import Run
+import Script.Expr
+import Script.Module
+import Test
+
+
+data TestModeInput = TestModeInput
+    { tmiOutput :: Output
+    , tmiParams :: [ Text ]
+    }
+
+data TestModeState = TestModeState
+    { tmsModules :: [ Module ]
+    , tmsGlobals :: GlobalDefs
+    }
+
+initTestModeState :: TestModeState
+initTestModeState = TestModeState
+    { tmsModules = mempty
+    , tmsGlobals = mempty
+    }
+
+testMode :: IO ()
+testMode = do
+    out <- startOutput OutputStyleTest False
+    let testLoop = getLineMb >>= \case
+            Just line -> do
+                case T.words line of
+                    cname : params
+                        | Just (CommandM cmd) <- lookup cname commands -> do
+                            runReaderT cmd $ TestModeInput out params
+                        | otherwise -> fail $ "Unknown command '" ++ T.unpack cname ++ "'"
+                    [] -> return ()
+                testLoop
+
+            Nothing -> return ()
+
+    runExceptT (evalStateT testLoop initTestModeState) >>= \case
+        Left err -> flip runReaderT out $ outLine OutputError Nothing $ T.pack err
+        Right () -> return ()
+
+getLineMb :: MonadIO m => m (Maybe Text)
+getLineMb = liftIO $ catchIOError (Just <$> T.getLine) (\e -> if isEOFError e then return Nothing else ioError e)
+
+cmdOut :: Text -> Command
+cmdOut line = do
+    out <- asks tmiOutput
+    flip runReaderT out $ outLine OutputTestRaw Nothing line
+
+
+newtype CommandM a = CommandM (ReaderT TestModeInput (StateT TestModeState (ExceptT String IO)) a)
+    deriving
+    ( Functor, Applicative, Monad, MonadIO
+    , MonadReader TestModeInput, MonadState TestModeState, MonadError String
+    )
+
+instance MonadFail CommandM where
+    fail = throwError
+
+type Command = CommandM ()
+
+commands :: [ ( Text, Command ) ]
+commands =
+    [ ( "load", cmdLoad )
+    , ( "run", cmdRun )
+    ]
+
+cmdLoad :: Command
+cmdLoad = do
+    [ path ] <- asks tmiParams
+    liftIO (parseTestFiles [ T.unpack path ]) >>= \case
+        Right ( modules, allModules ) -> do
+            let globalDefs = evalGlobalDefs $ concatMap (\m -> map (first ( moduleName m, )) $ moduleDefinitions m) allModules
+            modify $ \s -> s
+                { tmsModules = modules
+                , tmsGlobals = globalDefs
+                }
+            cmdOut "load-done"
+
+        Left (ModuleNotFound moduleName) -> do
+            cmdOut $ "load-failed module-not-found" <> textModuleName moduleName
+        Left (FileNotFound notFoundPath) -> do
+            cmdOut $ "load-failed file-not-found " <> T.pack notFoundPath
+        Left (ImportModuleError bundle) -> do
+#if MIN_VERSION_megaparsec(9,7,0)
+            mapM_ (cmdOut . T.pack) $ lines $ errorBundlePrettyWith showParseError bundle
+#endif
+            cmdOut $ "load-failed parse-error"
+  where
+    showParseError _ SourcePos {..} _ = concat
+        [ "parse-error"
+        , " ", sourceName
+        , ":", show $ unPos sourceLine
+        , ":", show $ unPos sourceColumn
+        ]
+
+cmdRun :: Command
+cmdRun = do
+    [ name ] <- asks tmiParams
+    TestModeState {..} <- get
+    case find ((name ==) . testName) $ concatMap moduleTests tmsModules of
+        Nothing -> cmdOut "run-not-found"
+        Just test -> do
+            out <- asks tmiOutput
+            liftIO (runTest out defaultTestOptions tmsGlobals test) >>= \case
+                True -> cmdOut "run-done"
+                False -> cmdOut "run-failed"
