Repository: reubeno/brush
Pull request #116 (2024-07-04 06:48:24)

feat: get building on windows and wasm-wasip1 targets
Adopt a more maintainable pattern for platform-specific code, and use this pattern to get minimally building for Windows x86_64 as well as wasm/WASI 0.1.

github-actions[bot]: ## Test Results
403 tests   388 :white_check_mark:  40s :stopwatch:
 64 suites   15 :zzz:
  8 files      0 :x:

Results for commit 5e286d93.

[test-results]:data:application/gzip;base64,H4sIAH5HhmYC/1XMSw6CMBSF4a2Qjh0U2tKrmyHYR3IjUNPHyLh3r1WhDr//JOfBPC4usUsHp46lgrlilCRb4pwxbGTJyTTlVCF+mlIxhpIAONIN75R6tRc/40Jl/5hcjCF+SyzbcfrG/+enNJc1NI/V7aEJ64qZwJQbYLRn4bTVErjVHoyXavC9MEIo7pW8mplr9nwBMc8RIQcBAAA=

github-actions[bot]: # Performance Benchmark Report
| Benchmark name                       | Baseline (ns) |  Test/PR (ns) |    Delta (ns) | Delta % |
| ------------------------------------ | ------------- | ------------- | ------------- | -------
| `expand_one_string                   ` | `      3604 ns` | `      3604 ns` | `         0 ns` | `⚪  0.00%` |
| `instantiate_shell                   ` | `     61481 ns` | `     61536 ns` | `       +55 ns` | `🟠 +0.09%` |
| `instantiate_shell_with_init_scripts ` | `  27746180 ns` | `  28143517 ns` | `   +397337 ns` | `🟠 +1.43%` |
| `parse_bash_completion               ` | `   5648632 ns` | `   5600216 ns` | `    -48416 ns` | `🟢 -0.86%` |
| `parse_sample_script                 ` | `      9128 ns` | `      9279 ns` | `      +151 ns` | `🟠 +1.65%` |
| `run_echo_builtin_command            ` | `     94842 ns` | `     96005 ns` | `     +1163 ns` | `🟠 +1.23%` |
| `run_one_builtin_command             ` | `    113661 ns` | `    113344 ns` | `      -317 ns` | `🟢 -0.28%` |
| `run_one_external_command            ` | `   1890306 ns` | `   1926987 ns` | `    +36681 ns` | `🟠 +1.94%` |
| `run_one_external_command_directly   ` | `   1001630 ns` | `   1000613 ns` | `     -1017 ns` | `🟢 -0.10%` |

# Code Coverage Report

| Package | Base Coverage | New Coverage | Difference |
| ------- | ------------- | ------------ | ---------- |
| /home/runner/work/brush/brush/brush-core/src/arithmetic.rs | 🟢 98.6% | 🟢 98.6% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtin.rs | 🔴 31.21% | 🔴 31.21% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/alias.rs | 🟢 83.87% | 🟢 83.87% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/bg.rs | 🔴 0% | 🔴 0% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/break_.rs | 🟢 90% | 🟢 90% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/builtin_.rs | 🟢 100% | 🟢 100% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/cd.rs | 🟢 88.57% | 🟢 88.57% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/colon.rs | 🟢 100% | 🟢 100% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/command.rs | 🟢 93.26% | 🟢 93.26% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/complete.rs | 🔴 21.48% | 🔴 21.48% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/continue_.rs | 🟢 90% | 🟢 90% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/declare.rs | 🟢 82.83% | 🟢 82.83% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/dirs.rs | 🟢 90.48% | 🟢 90.48% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/dot.rs | 🟢 95.65% | 🟢 95.65% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/echo.rs | 🟢 86.84% | 🟢 86.84% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/enable.rs | 🟢 91.07% | 🟢 91.07% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/eval.rs | 🟢 100% | 🟢 100% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/exec.rs | 🟢 90.32% | 🟢 90.32% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/exit.rs | 🟢 100% | 🟢 100% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/export.rs | 🟢 88.73% | 🟢 88.73% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/false_.rs | 🟢 100% | 🟢 100% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/fg.rs | 🔴 0% | 🔴 0% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/getopts.rs | 🟢 97.7% | 🟢 97.7% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/help.rs | 🟢 90.7% | 🟢 90.7% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/jobs.rs | 🔴 41.86% | 🔴 41.86% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/kill.rs | 🔴 0% | 🔴 0% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/let_.rs | 🟢 100% | 🟢 100% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/mod.rs | 🟢 94.61% | 🟢 94.63% | 🟢 0.02% |
| /home/runner/work/brush/brush/brush-core/src/builtins/popd.rs | 🟢 94.12% | 🟢 94.12% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/printf.rs | 🟢 91.18% | 🟢 91.18% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/pushd.rs | 🟢 91.3% | 🟢 91.3% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/pwd.rs | 🟢 89.47% | 🟢 89.47% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/read.rs | 🟠 73.53% | 🟢 76.69% | 🟢 3.16% |
| /home/runner/work/brush/brush/brush-core/src/builtins/return_.rs | 🟢 83.33% | 🟢 83.33% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/set.rs | 🟢 76.64% | 🟢 76.64% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/shift.rs | 🟢 100% | 🟢 100% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/shopt.rs | 🟢 88.39% | 🟢 88.39% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/test.rs | 🟢 100% | 🟢 100% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/trap.rs | 🟢 83.15% | 🟢 82.95% | 🔴 -0.2% |
| /home/runner/work/brush/brush/brush-core/src/builtins/true_.rs | 🟢 100% | 🟢 100% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/type_.rs | 🟢 90.27% | 🟢 90.27% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/umask.rs | 🔴 0% | 🔴 0% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/unalias.rs | 🔴 0% | 🔴 0% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/unimp.rs | 🔴 0% | 🔴 0% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/unset.rs | 🟢 94.64% | 🟢 94.64% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/builtins/wait.rs | 🟢 85.71% | 🟢 85.71% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/commands.rs | 🟢 92.86% | 🟢 92.82% | 🔴 -0.04% |
| /home/runner/work/brush/brush/brush-core/src/completion.rs | 🔴 42.49% | 🔴 42.49% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/env.rs | 🟢 87.85% | 🟢 87.85% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/error.rs | 🔴 25% | 🔴 25% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/escape.rs | 🔴 14.91% | 🔴 14.91% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/expansion.rs | 🟢 95.95% | 🟢 95.95% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/extendedtests.rs | 🟠 64.15% | 🟠 69.2% | 🟢 5.05% |
| /home/runner/work/brush/brush/brush-core/src/functions.rs | 🟢 100% | 🟢 100% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/interp.rs | 🟢 93.17% | 🟢 93.15% | 🔴 -0.02% |
| /home/runner/work/brush/brush/brush-core/src/jobs.rs | 🔴 39.38% | 🔴 41.3% | 🟢 1.92% |
| /home/runner/work/brush/brush/brush-core/src/keywords.rs | 🟢 96.88% | 🟢 96.88% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/namedoptions.rs | 🟠 51.92% | 🟠 51.92% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/openfiles.rs | 🔴 46.15% | 🔴 47.22% | 🟢 1.07% |
| /home/runner/work/brush/brush/brush-core/src/options.rs | 🟢 79.17% | 🟢 79.17% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/patterns.rs | 🟢 96.96% | 🟢 96.96% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/prompt.rs | 🟢 78.48% | 🟢 78.48% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/regex.rs | 🟢 100% | 🟢 100% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/shell.rs | 🟢 77.62% | 🟢 77.78% | 🟢 0.16% |
| /home/runner/work/brush/brush/brush-core/src/sys/hostname.rs | 🔴 0% | 🟢 100% | 🟢 100% |
| /home/runner/work/brush/brush/brush-core/src/sys/tokio_process.rs | 🔴 0% | 🟢 100% | 🟢 100% |
| /home/runner/work/brush/brush/brush-core/src/sys/unix/fs.rs | 🔴 0% | 🟠 54.76% | 🟢 54.76% |
| /home/runner/work/brush/brush/brush-core/src/sys/unix/network.rs | 🔴 0% | 🟢 100% | 🟢 100% |
| /home/runner/work/brush/brush/brush-core/src/sys/unix/signal.rs | 🔴 0% | 🔴 40% | 🟢 40% |
| /home/runner/work/brush/brush/brush-core/src/sys/unix/terminal.rs | 🔴 0% | 🔴 0% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/sys/unix/users.rs | 🔴 0% | 🔴 16.13% | 🟢 16.13% |
| /home/runner/work/brush/brush/brush-core/src/tests.rs | 🟠 50% | 🟠 50% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/traps.rs | 🟠 50% | 🟠 50% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-core/src/variables.rs | 🟢 79.91% | 🟢 79.91% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-interactive/src/interactive_shell.rs | 🟢 77.78% | 🟢 78.11% | 🟢 0.33% |
| /home/runner/work/brush/brush/brush-parser/src/arithmetic.rs | 🟢 100% | 🟢 100% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-parser/src/ast.rs | 🔴 24.91% | 🔴 24.91% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-parser/src/error.rs | 🟢 76.92% | 🟢 76.92% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-parser/src/parser.rs | 🟢 99.6% | 🟢 99.6% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-parser/src/pattern.rs | 🟢 100% | 🟢 100% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-parser/src/prompt.rs | 🟢 100% | 🟢 100% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-parser/src/test_command.rs | 🟢 100% | 🟢 100% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-parser/src/tokenizer.rs | 🟢 96.01% | 🟢 96.01% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-parser/src/word.rs | 🟢 100% | 🟢 100% | ⚪ 0% |
| /home/runner/work/brush/brush/brush-shell/src/main.rs | 🟠 71.24% | 🟠 71.97% | 🟢 0.73% |
| /home/runner/work/brush/brush/brush-shell/src/productinfo.rs | 🟢 100% | 🟢 100% | ⚪ 0% |
| **Overall Coverage** | **🟢 77.91%** | **🟢 77.93%** | **🟢 0.02%** |

_Minimum allowed coverage is_ `0%`_, this run produced_ `77.93%`
<!-- Sticky Pull Request Comment -->

Patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 8b2f333..2669427 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -50,9 +50,23 @@ jobs:
           name: binaries-x86_64-linux
           path: target/release/brush
 
-  build_aarch64:
-    name: "Build (aarch64/Linux)"
+  build_cross:
+    name: "Build (${{ matrix.arch }}/${{ matrix.os }})"
     runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        include:
+          - target: "aarch64-unknown-linux-gnu"
+            arch: "aarch64"
+            os: "Linux"
+          - target: "wasm32-wasip1"
+            arch: "wasm32"
+            os: "WASI-0.1"
+          - target: "x86_64-pc-windows-gnu"
+            arch: "x86_64"
+            os: "Windows"
+
     steps:
       - name: Checkout
         uses: actions/checkout@v4
@@ -61,10 +75,12 @@ jobs:
         uses: dtolnay/rust-toolchain@stable
         with:
           toolchain: stable
-          targets: aarch64-unknown-linux-gnu
+          targets: ${{ matrix.target }}
 
       - name: Enable cargo cache
         uses: Swatinem/rust-cache@v2
+        with:
+          key: "${{ matrix.target }}"
 
       - name: Install cargo-binstall
         uses: cargo-bins/cargo-binstall@v1.7.3
@@ -78,13 +94,13 @@ jobs:
           tool: cross
 
       - name: Build
-        run: cross build --release --target=aarch64-unknown-linux-gnu
+        run: cross build --release --target=${{ matrix.target }}
 
       - name: "Upload binaries"
         uses: actions/upload-artifact@v4
         with:
-          name: binaries-aarch64-linux
-          path: target/aarch64-unknown-linux-gnu/release/brush
+          name: binaries-${{ matrix.target }}
+          path: target/${{ matrix.target }}/release/brush
 
   test:
     name: "Test"
diff --git a/brush-core/Cargo.toml b/brush-core/Cargo.toml
index 39ce9f1..8da1879 100644
--- a/brush-core/Cargo.toml
+++ b/brush-core/Cargo.toml
@@ -25,12 +25,22 @@ cached = "0.51.3"
 clap = { version = "=4.4.18", features = ["derive", "wrap_help"] }
 fancy-regex = "0.13.0"
 futures = "0.3.30"
-hostname = "0.4.0"
 itertools = "0.13.0"
 lazy_static = "1.5.0"
-os_pipe = { version = "1.1.5", features = ["io_safety"] }
 rand = "0.8.5"
 thiserror = "1.0.61"
+tracing = "0.1.40"
+
+[target.'cfg(target_family = "wasm")'.dependencies]
+tokio = { version = "1.37.0", features = [
+    "io-util",
+    "macros",
+    "rt",
+] }
+
+[target.'cfg(any(windows, unix))'.dependencies]
+hostname = "0.4.0"
+os_pipe = { version = "1.1.5", features = ["io_safety"] }
 tokio = { version = "1.37.0", features = [
     "io-util",
     "macros",
@@ -39,7 +49,6 @@ tokio = { version = "1.37.0", features = [
     "rt-multi-thread",
     "signal",
 ] }
-tracing = "0.1.40"
 
 [target.'cfg(windows)'.dependencies]
 homedir = "0.2.1"
diff --git a/brush-core/src/builtins/command.rs b/brush-core/src/builtins/command.rs
index 139054a..59afb4b 100644
--- a/brush-core/src/builtins/command.rs
+++ b/brush-core/src/builtins/command.rs
@@ -1,7 +1,7 @@
 use clap::Parser;
 use std::{fmt::Display, io::Write};
 
-use crate::{builtin, commands, error, files::PathExt, shell};
+use crate::{builtin, commands, error, shell, sys::fs::PathExt};
 
 /// Directly invokes an external command, without going through typical search order.
 #[derive(Parser)]
@@ -126,7 +126,7 @@ impl CommandCommand {
 
         #[allow(clippy::cast_possible_truncation)]
         #[allow(clippy::cast_sign_loss)]
-        match commands::execute(context, args, false /*use functions?*/).await? {
+        match commands::execute(context, args, false /* use functions? */).await? {
             commands::SpawnResult::SpawnedChild(mut child) => {
                 let real_result = child.wait().await?;
                 let exit_code = real_result.code().unwrap_or(1);
diff --git a/brush-core/src/builtins/mod.rs b/brush-core/src/builtins/mod.rs
index 0b88bea..d2afae2 100644
--- a/brush-core/src/builtins/mod.rs
+++ b/brush-core/src/builtins/mod.rs
@@ -46,6 +46,7 @@ mod test;
 mod trap;
 mod true_;
 mod type_;
+#[cfg(unix)]
 mod umask;
 mod unalias;
 mod unimp;
@@ -235,6 +236,7 @@ pub(crate) fn get_default_builtins(
     m.insert("read".into(), builtin::<read::ReadCommand>());
     m.insert("true".into(), builtin::<true_::TrueCommand>());
     m.insert("type".into(), builtin::<type_::TypeCommand>());
+    #[cfg(unix)]
     m.insert("umask".into(), builtin::<umask::UmaskCommand>());
     m.insert("unalias".into(), builtin::<unalias::UnaliasCommand>());
     m.insert("wait".into(), builtin::<wait::WaitCommand>());
diff --git a/brush-core/src/builtins/read.rs b/brush-core/src/builtins/read.rs
index f199f81..3bd78f2 100644
--- a/brush-core/src/builtins/read.rs
+++ b/brush-core/src/builtins/read.rs
@@ -3,7 +3,7 @@ use itertools::Itertools;
 use std::collections::VecDeque;
 use std::io::{Read, Write};
 
-use crate::{builtin, commands, env, error, openfiles, variables};
+use crate::{builtin, commands, env, error, openfiles, sys, variables};
 
 /// Parse standard input.
 #[derive(Parser)]
@@ -219,7 +219,7 @@ impl ReadCommand {
         }
 
         if let Some(orig_term_attr) = &orig_term_attr {
-            input_file.set_term_attr(nix::sys::termios::SetArg::TCSANOW, orig_term_attr)?;
+            input_file.set_term_attr(orig_term_attr)?;
         }
 
         if line.is_empty() {
@@ -232,25 +232,18 @@ impl ReadCommand {
     fn setup_terminal_settings(
         &self,
         file: &openfiles::OpenFile,
-    ) -> Result<Option<nix::sys::termios::Termios>, crate::Error> {
+    ) -> Result<Option<sys::terminal::TerminalSettings>, crate::Error> {
         let orig_term_attr = file.get_term_attr()?;
         if let Some(orig_term_attr) = &orig_term_attr {
             let mut updated_term_attr = orig_term_attr.to_owned();
 
-            updated_term_attr
-                .local_flags
-                .remove(nix::sys::termios::LocalFlags::ICANON);
-            updated_term_attr
-                .local_flags
-                .remove(nix::sys::termios::LocalFlags::ISIG);
-
+            updated_term_attr.set_canonical(false);
+            updated_term_attr.set_int_signal(false);
             if self.silent {
-                updated_term_attr
-                    .local_flags
-                    .remove(nix::sys::termios::LocalFlags::ECHO);
+                updated_term_attr.set_echo(false);
             }
 
-            file.set_term_attr(nix::sys::termios::SetArg::TCSANOW, &updated_term_attr)?;
+            file.set_term_attr(&updated_term_attr)?;
         }
         Ok(orig_term_attr)
     }
diff --git a/brush-core/src/builtins/trap.rs b/brush-core/src/builtins/trap.rs
index 86d3f1a..6468c7b 100644
--- a/brush-core/src/builtins/trap.rs
+++ b/brush-core/src/builtins/trap.rs
@@ -1,7 +1,7 @@
 use clap::Parser;
-use std::{io::Write, str::FromStr};
+use std::io::Write;
 
-use crate::{builtin, commands, error, traps};
+use crate::{builtin, commands, error, sys, traps};
 
 /// Manage signal traps.
 #[derive(Parser)]
@@ -55,8 +55,10 @@ impl builtin::Command for TrapCommand {
     }
 }
 
+#[allow(unused_variables)]
 impl TrapCommand {
     fn display_signals(context: &commands::ExecutionContext<'_>) -> Result<(), error::Error> {
+        #[cfg(unix)]
         for signal in nix::sys::signal::Signal::iterator() {
             writeln!(context.stdout(), "{}: {signal}", signal as i32)?;
         }
@@ -108,9 +110,7 @@ fn parse_signal(signal: &str) -> Result<traps::TrapSignal, error::Error> {
             .parse::<i32>()
             .map_err(|_| error::Error::InvalidSignal)?;
 
-        Ok(traps::TrapSignal::Signal(
-            nix::sys::signal::Signal::try_from(digits).map_err(|_| error::Error::InvalidSignal)?,
-        ))
+        sys::signal::parse_numeric_signal(digits)
     } else {
         let mut signal_to_parse = signal.to_ascii_uppercase();
 
@@ -122,10 +122,7 @@ fn parse_signal(signal: &str) -> Result<traps::TrapSignal, error::Error> {
             s if s == "SIGDEBUG" => Ok(traps::TrapSignal::Debug),
             s if s == "SIGERR" => Ok(traps::TrapSignal::Err),
             s if s == "SIGEXIT" => Ok(traps::TrapSignal::Exit),
-            _ => Ok(traps::TrapSignal::Signal(
-                nix::sys::signal::Signal::from_str(signal_to_parse.as_str())
-                    .map_err(|_| error::Error::InvalidSignal)?,
-            )),
+            s => sys::signal::parse_os_signal_name(s.as_str()),
         }
     }
 }
diff --git a/brush-core/src/builtins/type_.rs b/brush-core/src/builtins/type_.rs
index 9720436..95562cb 100644
--- a/brush-core/src/builtins/type_.rs
+++ b/brush-core/src/builtins/type_.rs
@@ -4,8 +4,8 @@ use std::{io::Write, sync::Arc};
 use brush_parser::ast;
 use clap::Parser;
 
-use crate::files::PathExt;
 use crate::keywords;
+use crate::sys::fs::PathExt;
 use crate::{builtin, commands, Shell};
 
 /// Inspect the type of a named shell item.
diff --git a/brush-core/src/commands.rs b/brush-core/src/commands.rs
index 94ce7ed..70b0160 100644
--- a/brush-core/src/commands.rs
+++ b/brush-core/src/commands.rs
@@ -1,26 +1,29 @@
-use std::{ffi::OsStr, fmt::Display, os::unix::process::CommandExt, process::Stdio, sync::Arc};
+#[cfg(unix)]
+use std::os::unix::process::CommandExt;
+use std::{ffi::OsStr, fmt::Display, process::Stdio, sync::Arc};
 
 use brush_parser::ast;
+#[cfg(unix)]
 use command_fds::{CommandFdExt, FdMapping};
 use itertools::Itertools;
-use tokio::process;
 
 use crate::{
     builtin, error,
     interp::{self, Execute},
     openfiles::{self, OpenFile, OpenFiles},
-    Shell,
+    sys, Shell,
 };
 
 /// Represents the result of spawning a command.
 pub(crate) enum SpawnResult {
     /// The child process was spawned.
-    SpawnedChild(process::Child),
+    SpawnedChild(sys::process::Child),
     /// The command immediatedly exited with the given numeric exit code.
     ImmediateExit(u8),
     /// The shell should exit after this command, yielding the given numeric exit code.
     ExitShell(u8),
-    /// The shell should return from the current function or script, yielding the given numeric exit code.
+    /// The shell should return from the current function or script, yielding the given numeric
+    /// exit code.
     ReturnFromFunctionOrScript(u8),
     /// The shell should break out of the containing loop, identified by the given depth count.
     BreakLoop(u8),
@@ -91,6 +94,7 @@ impl From<&String> for CommandArg {
     }
 }
 
+#[allow(unused_variables)]
 pub(crate) fn compose_std_command<S: AsRef<OsStr>>(
     shell: &mut Shell,
     command_name: &str,
@@ -102,6 +106,7 @@ pub(crate) fn compose_std_command<S: AsRef<OsStr>>(
     let mut cmd = std::process::Command::new(command_name);
 
     // Override argv[0].
+    #[cfg(unix)]
     cmd.arg0(argv0);
 
     // Pass through args.
@@ -252,10 +257,7 @@ pub(crate) fn execute_external_command(
             .join(" ")
     );
 
-    // Convert to a tokio::process::Command so we can execute it asynchronously.
-    let mut cmd = tokio::process::Command::from(cmd);
-
-    match cmd.spawn() {
+    match sys::process::spawn(cmd) {
         Ok(child) => Ok(SpawnResult::SpawnedChild(child)),
         Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
             if context.shell.options.sh_mode {
diff --git a/brush-core/src/completion.rs b/brush-core/src/completion.rs
index 3aa83a2..ea88924 100644
--- a/brush-core/src/completion.rs
+++ b/brush-core/src/completion.rs
@@ -7,7 +7,11 @@ use std::{
 };
 
 use crate::{
-    env, error, jobs, namedoptions, patterns, traps, users, variables::ShellValueLiteral, Shell,
+    env, error, jobs, namedoptions, patterns,
+    sys::{self, users},
+    traps,
+    variables::ShellValueLiteral,
+    Shell,
 };
 
 /// Type of action to take to generate completion candidates.
@@ -139,7 +143,6 @@ pub struct Config {
 pub struct GenerationOptions {
     //
     // Options
-    //
     /// Perform rest of default completions if no completions are generated.
     pub bash_default: bool,
     /// Use default filename completion if no completions are generated.
@@ -164,13 +167,11 @@ pub struct GenerationOptions {
 pub struct Spec {
     //
     // Options
-    //
     /// Options to use for completion.
     pub options: GenerationOptions,
 
     //
     // Generators
-    //
     /// Actions to take to generate completions.
     pub actions: Vec<CompleteAction>,
     /// Optionally, a glob pattern whose expansion will be used as completions.
@@ -184,7 +185,6 @@ pub struct Spec {
 
     //
     // Filters
-    //
     /// Optionally, a pattern to filter completions.
     pub filter_pattern: Option<String>,
     /// If true, completion candidates matching `filter_pattern` are removed;
@@ -193,7 +193,6 @@ pub struct Spec {
 
     //
     // Transformers
-    //
     /// Optionally, provides a prefix to be prepended to all completion candidates.
     pub prefix: Option<String>,
     /// Optionally, provides a suffix to be prepended to all completion candidates.
@@ -312,7 +311,7 @@ impl Spec {
                 }
                 CompleteAction::HostName => {
                     // N.B. We only retrieve one hostname.
-                    if let Ok(name) = hostname::get() {
+                    if let Ok(name) = sys::network::get_hostname() {
                         candidates.push(name.to_string_lossy().to_string());
                     }
                 }
diff --git a/brush-core/src/error.rs b/brush-core/src/error.rs
index ea8f8ae..fd6ed79 100644
--- a/brush-core/src/error.rs
+++ b/brush-core/src/error.rs
@@ -144,6 +144,7 @@ pub enum Error {
     InvalidSignal,
 
     /// A system error occurred.
+    #[cfg(unix)]
     #[error("system error: {0}")]
     ErrnoError(#[from] nix::errno::Errno),
 
@@ -152,6 +153,7 @@ pub enum Error {
     InvalidUmask,
 
     /// An error occurred reading from procfs.
+    #[cfg(unix)]
     #[error("procfs error: {0}")]
     ProcfsError(#[from] procfs::ProcError),
 
diff --git a/brush-core/src/expansion.rs b/brush-core/src/expansion.rs
index 8e60c45..04e5324 100644
--- a/brush-core/src/expansion.rs
+++ b/brush-core/src/expansion.rs
@@ -13,6 +13,7 @@ use crate::openfiles;
 use crate::patterns;
 use crate::prompt;
 use crate::shell::Shell;
+use crate::sys;
 use crate::variables::ShellValueUnsetType;
 use crate::variables::ShellVariable;
 use crate::variables::{self, ShellValue};
@@ -552,7 +553,8 @@ impl<'a> WordExpander<'a> {
                             .flatten()
                             .collect();
 
-                        // If there were no pieces, make sure there's an empty string after concatenation.
+                        // If there were no pieces, make sure there's an empty string after
+                        // concatenation.
                         if concatenated.is_empty() {
                             concatenated.push(ExpansionPiece::Splittable(String::new()));
                         }
@@ -580,8 +582,8 @@ impl<'a> WordExpander<'a> {
                     }
                 }
 
-                // If there were no pieces, then make sure we yield a single field containing an empty,
-                // unsplittable string.
+                // If there were no pieces, then make sure we yield a single field containing an
+                // empty, unsplittable string.
                 if pieces_is_empty {
                     fields.push(WordField::from(ExpansionPiece::Unsplittable(String::new())));
                 }
@@ -603,7 +605,7 @@ impl<'a> WordExpander<'a> {
                 let mut subshell = self.shell.clone();
 
                 // Set up pipe so we can read the output.
-                let (reader, writer) = os_pipe::pipe()?;
+                let (reader, writer) = sys::pipes::pipe()?;
                 subshell
                     .open_files
                     .files
diff --git a/brush-core/src/files.rs b/brush-core/src/files.rs
deleted file mode 100644
index 428cb30..0000000
--- a/brush-core/src/files.rs
+++ /dev/null
@@ -1,21 +0,0 @@
-use std::path::Path;
-
-pub(crate) trait PathExt {
-    fn readable(&self) -> bool;
-    fn writable(&self) -> bool;
-    fn executable(&self) -> bool;
-}
-
-impl PathExt for Path {
-    fn readable(&self) -> bool {
-        nix::unistd::access(self, nix::unistd::AccessFlags::R_OK).is_ok()
-    }
-
-    fn writable(&self) -> bool {
-        nix::unistd::access(self, nix::unistd::AccessFlags::W_OK).is_ok()
-    }
-
-    fn executable(&self) -> bool {
-        nix::unistd::access(self, nix::unistd::AccessFlags::X_OK).is_ok()
-    }
-}
diff --git a/brush-core/src/interp.rs b/brush-core/src/interp.rs
index 993f661..58fc755 100644
--- a/brush-core/src/interp.rs
+++ b/brush-core/src/interp.rs
@@ -2,6 +2,7 @@ use brush_parser::ast::{self, CommandPrefixOrSuffixItem};
 use itertools::Itertools;
 use std::collections::VecDeque;
 use std::io::Write;
+#[cfg(unix)]
 use std::os::fd::{AsFd, AsRawFd};
 #[cfg(unix)]
 use std::os::unix::process::ExitStatusExt;
@@ -15,7 +16,7 @@ use crate::shell::Shell;
 use crate::variables::{
     ArrayLiteral, ShellValue, ShellValueLiteral, ShellValueUnsetType, ShellVariable,
 };
-use crate::{error, expansion, extendedtests, jobs, openfiles, traps};
+use crate::{error, expansion, extendedtests, jobs, openfiles, sys, traps};
 
 /// Encapsulates the result of executing a command.
 #[derive(Debug, Default)]
@@ -80,7 +81,7 @@ struct PipelineExecutionContext<'a> {
 
     current_pipeline_index: usize,
     pipeline_len: usize,
-    output_pipes: &'a mut Vec<os_pipe::PipeReader>,
+    output_pipes: &'a mut Vec<sys::pipes::PipeReader>,
 
     params: ExecutionParameters,
 }
@@ -334,18 +335,15 @@ impl Execute for ast::Pipeline {
             spawn_results.push_back(spawn_result);
         }
 
-        let mut result = ExecutionResult::success();
-
         const SIGTSTP: std::os::raw::c_int = 20;
 
-        #[cfg(unix)]
-        let mut sigtstp =
-            tokio::signal::unix::signal(tokio::signal::unix::SignalKind::from_raw(SIGTSTP))?;
-        #[cfg(not(unix))]
-        let sigtstp = FakeSignal::new();
+        #[allow(unused_mut)]
+        let mut sigtstp = sys::signal::tstp_signal_listener()?;
 
+        let mut result = ExecutionResult::success();
         let mut stopped: VecDeque<jobs::JobJoinHandle> = VecDeque::new();
         let mut pids = vec![];
+
         while let Some(child) = spawn_results.pop_front() {
             match child {
                 SpawnResult::SpawnedChild(child) => {
@@ -367,7 +365,7 @@ impl Execute for ast::Pipeline {
                                 _ = sigtstp.recv() => {
                                     break ExecuteWaitResult::Sigtstp
                                 },
-                                _ = tokio::signal::ctrl_c() => {
+                                _ = sys::signal::await_ctrl_c() => {
                                     // SIGINT got thrown. Handle it and continue looping. The child should
                                     // have received it as well, and either handled it or ended up getting
                                     // terminated (in which case we'll see the child exit).
@@ -1257,7 +1255,7 @@ fn setup_pipeline_redirection(
     // to a pipe that we can read later.
     if context.pipeline_len > 1 && context.current_pipeline_index < context.pipeline_len - 1 {
         // Set up stdout of this process to go to stdin of the succeeding process.
-        let (reader, writer) = os_pipe::pipe()?;
+        let (reader, writer) = sys::pipes::pipe()?;
         context.output_pipes.push(reader);
         open_files.files.insert(1, OpenFile::PipeWriter(writer));
     }
@@ -1388,7 +1386,7 @@ pub(crate) async fn setup_redirect<'a>(
                             let mut subshell = shell.clone();
 
                             // Set up pipe so we can connect to the command.
-                            let (reader, writer) = os_pipe::pipe()?;
+                            let (reader, writer) = sys::pipes::pipe()?;
 
                             if matches!(kind, ast::IoFileRedirectKind::Read) {
                                 subshell
@@ -1464,12 +1462,14 @@ pub(crate) async fn setup_redirect<'a>(
     }
 }
 
+#[allow(unused_variables)]
 fn setup_open_file_with_contents(contents: &str) -> Result<OpenFile, error::Error> {
-    let (reader, mut writer) = os_pipe::pipe()?;
+    let (reader, mut writer) = sys::pipes::pipe()?;
 
     let bytes = contents.as_bytes();
     let len = i32::try_from(bytes.len())?;
 
+    #[cfg(unix)]
     nix::fcntl::fcntl(
         reader.as_fd().as_raw_fd(),
         nix::fcntl::FcntlArg::F_SETPIPE_SZ(len),
@@ -1480,17 +1480,3 @@ fn setup_open_file_with_contents(contents: &str) -> Result<OpenFile, error::Erro
 
     Ok(OpenFile::PipeReader(reader))
 }
-
-#[cfg(not(unix))]
-struct FakeSignal {}
-
-#[cfg(not(unix))]
-impl FakeSignal {
-    fn new() -> Self {
-        Self {}
-    }
-
-    async fn recv(&self) -> () {
-        futures::future::pending::<()>().await
-    }
-}
diff --git a/brush-core/src/jobs.rs b/brush-core/src/jobs.rs
index 247966a..6c1ced1 100644
--- a/brush-core/src/jobs.rs
+++ b/brush-core/src/jobs.rs
@@ -4,6 +4,7 @@ use std::fmt::Display;
 use futures::FutureExt;
 
 use crate::error;
+use crate::sys;
 use crate::ExecutionResult;
 
 pub(crate) type JobJoinHandle = tokio::task::JoinHandle<Result<ExecutionResult, error::Error>>;
@@ -316,20 +317,13 @@ impl Job {
     }
 
     /// Moves the job to execute in the foreground.
-    #[cfg(unix)]
     pub fn move_to_foreground(&mut self) -> Result<(), error::Error> {
         if !matches!(self.state, JobState::Stopped) {
             return error::unimp("move job to foreground for not stopped job");
         }
 
-        #[allow(clippy::cast_possible_wrap)]
         if let Some(pid) = self.get_representative_pid() {
-            nix::sys::signal::kill(
-                nix::unistd::Pid::from_raw(pid as i32),
-                nix::sys::signal::SIGCONT,
-            )
-            .map_err(|_errno| error::Error::FailedToSendSignal)?;
-
+            sys::signal::continue_process(pid)?;
             self.state = JobState::Running;
             Ok(())
         } else {
@@ -337,34 +331,15 @@ impl Job {
         }
     }
 
-    /// Moves the job to execute in the foreground.
-    #[cfg(not(unix))]
-    pub fn move_to_foreground(&mut self) -> Result<(), error::Error> {
-        error::unimp("move job to foreground")
-    }
-
     /// Kills the job.
-    #[cfg(unix)]
     pub fn kill(&mut self) -> Result<(), error::Error> {
         if let Some(pid) = self.get_representative_pid() {
-            #[allow(clippy::cast_possible_wrap)]
-            nix::sys::signal::kill(
-                nix::unistd::Pid::from_raw(pid as i32),
-                nix::sys::signal::SIGKILL,
-            )
-            .map_err(|_errno| error::Error::FailedToSendSignal)?;
-            Ok(())
+            sys::signal::kill_process(pid)
         } else {
             Err(error::Error::FailedToSendSignal)
         }
     }
 
-    /// Kills the job.
-    #[cfg(not(unix))]
-    pub fn kill(&mut self) -> Result<(), error::Error> {
-        error::unimp("kill job")
-    }
-
     /// Tries to retrieve a "representative" pid for the job.
     pub fn get_representative_pid(&self) -> Option<u32> {
         self.pids.first().copied()
diff --git a/brush-core/src/lib.rs b/brush-core/src/lib.rs
index 8775ecd..45fc6e1 100644
--- a/brush-core/src/lib.rs
+++ b/brush-core/src/lib.rs
@@ -14,7 +14,6 @@ mod error;
 mod escape;
 mod expansion;
 mod extendedtests;
-mod files;
 mod functions;
 mod interp;
 mod jobs;
@@ -26,9 +25,9 @@ mod patterns;
 mod prompt;
 mod regex;
 mod shell;
+mod sys;
 mod tests;
 mod traps;
-mod users;
 mod variables;
 
 pub use error::Error;
diff --git a/brush-core/src/openfiles.rs b/brush-core/src/openfiles.rs
index d1e5cbe..59d0082 100644
--- a/brush-core/src/openfiles.rs
+++ b/brush-core/src/openfiles.rs
@@ -9,6 +9,7 @@ use std::os::fd::OwnedFd;
 use std::process::Stdio;
 
 use crate::error;
+use crate::sys;
 
 /// Represents a file open in a shell context.
 pub enum OpenFile {
@@ -23,9 +24,9 @@ pub enum OpenFile {
     /// A file open for reading or writing.
     File(std::fs::File),
     /// A read end of a pipe.
-    PipeReader(os_pipe::PipeReader),
+    PipeReader(sys::pipes::PipeReader),
     /// A write end of a pipe.
-    PipeWriter(os_pipe::PipeWriter),
+    PipeWriter(sys::pipes::PipeWriter),
 }
 
 impl OpenFile {
@@ -85,17 +86,19 @@ impl OpenFile {
         }
     }
 
-    pub(crate) fn get_term_attr(&self) -> Result<Option<nix::sys::termios::Termios>, error::Error> {
+    pub(crate) fn get_term_attr(
+        &self,
+    ) -> Result<Option<sys::terminal::TerminalSettings>, error::Error> {
         if !self.is_term() {
             return Ok(None);
         }
 
         let result = match self {
-            OpenFile::Stdin => Some(nix::sys::termios::tcgetattr(std::io::stdin())?),
-            OpenFile::Stdout => Some(nix::sys::termios::tcgetattr(std::io::stdout())?),
-            OpenFile::Stderr => Some(nix::sys::termios::tcgetattr(std::io::stderr())?),
+            OpenFile::Stdin => Some(sys::terminal::get_term_attr(std::io::stdin())?),
+            OpenFile::Stdout => Some(sys::terminal::get_term_attr(std::io::stdout())?),
+            OpenFile::Stderr => Some(sys::terminal::get_term_attr(std::io::stderr())?),
             OpenFile::Null => None,
-            OpenFile::File(f) => Some(nix::sys::termios::tcgetattr(f)?),
+            OpenFile::File(f) => Some(sys::terminal::get_term_attr(f)?),
             OpenFile::PipeReader(_) => None,
             OpenFile::PipeWriter(_) => None,
         };
@@ -104,15 +107,14 @@ impl OpenFile {
 
     pub(crate) fn set_term_attr(
         &self,
-        action: nix::sys::termios::SetArg,
-        termios: &nix::sys::termios::Termios,
+        termios: &sys::terminal::TerminalSettings,
     ) -> Result<(), error::Error> {
         match self {
-            OpenFile::Stdin => nix::sys::termios::tcsetattr(std::io::stdin(), action, termios)?,
-            OpenFile::Stdout => nix::sys::termios::tcsetattr(std::io::stdout(), action, termios)?,
-            OpenFile::Stderr => nix::sys::termios::tcsetattr(std::io::stderr(), action, termios)?,
+            OpenFile::Stdin => sys::terminal::set_term_attr_now(std::io::stdin(), termios)?,
+            OpenFile::Stdout => sys::terminal::set_term_attr_now(std::io::stdout(), termios)?,
+            OpenFile::Stderr => sys::terminal::set_term_attr_now(std::io::stderr(), termios)?,
             OpenFile::Null => (),
-            OpenFile::File(f) => nix::sys::termios::tcsetattr(f, action, termios)?,
+            OpenFile::File(f) => sys::terminal::set_term_attr_now(f, termios)?,
             OpenFile::PipeReader(_) => (),
             OpenFile::PipeWriter(_) => (),
         }
diff --git a/brush-core/src/prompt.rs b/brush-core/src/prompt.rs
index 4244aa2..682d720 100644
--- a/brush-core/src/prompt.rs
+++ b/brush-core/src/prompt.rs
@@ -1,4 +1,8 @@
-use crate::{error, shell::Shell, users};
+use crate::{
+    error,
+    shell::Shell,
+    sys::{self, users},
+};
 use std::path::Path;
 
 const VERSION_MAJOR: &str = env!("CARGO_PKG_VERSION_MAJOR");
@@ -62,7 +66,7 @@ pub(crate) fn format_prompt_piece(
         brush_parser::prompt::PromptPiece::Hostname {
             only_up_to_first_dot,
         } => {
-            let hn = hostname::get()
+            let hn = sys::network::get_hostname()
                 .unwrap_or_default()
                 .to_string_lossy()
                 .to_string();
diff --git a/brush-core/src/shell.rs b/brush-core/src/shell.rs
index 3adc7af..1a13bf1 100644
--- a/brush-core/src/shell.rs
+++ b/brush-core/src/shell.rs
@@ -7,13 +7,13 @@ use std::sync::Arc;
 
 use crate::arithmetic::Evaluatable;
 use crate::env::{EnvironmentLookup, EnvironmentScope, ShellEnvironment};
-use crate::files::PathExt;
 use crate::interp::{Execute, ExecutionParameters, ExecutionResult};
 use crate::options::RuntimeOptions;
+use crate::sys::fs::PathExt;
 use crate::variables::{self, ShellValue, ShellVariable};
 use crate::{
     builtin, builtins, commands, completion, env, error, expansion, functions, jobs, keywords,
-    openfiles, patterns, prompt, traps, users,
+    openfiles, patterns, prompt, sys::users, traps,
 };
 
 /// Represents an instance of a shell.
diff --git a/brush-core/src/sys.rs b/brush-core/src/sys.rs
new file mode 100644
index 0000000..271a290
--- /dev/null
+++ b/brush-core/src/sys.rs
@@ -0,0 +1,33 @@
+#[cfg(unix)]
+pub(crate) mod unix;
+#[cfg(unix)]
+pub(crate) use unix as platform;
+
+#[cfg(windows)]
+pub(crate) mod windows;
+#[cfg(windows)]
+pub(crate) use windows as platform;
+
+#[cfg(target_family = "wasm")]
+pub(crate) mod wasm;
+#[cfg(target_family = "wasm")]
+pub(crate) use wasm as platform;
+
+#[cfg(not(unix))]
+pub(crate) mod stubs;
+
+#[cfg(any(unix, windows))]
+pub(crate) mod hostname;
+#[cfg(any(unix, windows))]
+pub(crate) mod os_pipe;
+#[cfg(any(unix, windows))]
+pub(crate) mod tokio_process;
+
+pub(crate) mod fs;
+
+pub(crate) use platform::network;
+pub(crate) use platform::pipes;
+pub(crate) use platform::process;
+pub(crate) use platform::signal;
+pub(crate) use platform::terminal;
+pub(crate) use platform::users;
diff --git a/brush-core/src/sys/fs.rs b/brush-core/src/sys/fs.rs
new file mode 100644
index 0000000..d78fbf1
--- /dev/null
+++ b/brush-core/src/sys/fs.rs
@@ -0,0 +1,21 @@
+#[allow(unused_imports)]
+pub(crate) use super::platform::fs::*;
+
+#[cfg(unix)]
+pub(crate) use std::os::unix::fs::MetadataExt;
+#[cfg(not(unix))]
+pub(crate) use StubMetadataExt as MetadataExt;
+
+pub(crate) trait PathExt {
+    fn readable(&self) -> bool;
+    fn writable(&self) -> bool;
+    fn executable(&self) -> bool;
+
+    fn exists_and_is_block_device(&self) -> bool;
+    fn exists_and_is_char_device(&self) -> bool;
+    fn exists_and_is_fifo(&self) -> bool;
+    fn exists_and_is_socket(&self) -> bool;
+    fn exists_and_is_setgid(&self) -> bool;
+    fn exists_and_is_setuid(&self) -> bool;
+    fn exists_and_is_sticky_bit(&self) -> bool;
+}
diff --git a/brush-core/src/sys/hostname.rs b/brush-core/src/sys/hostname.rs
new file mode 100644
index 0000000..422b880
--- /dev/null
+++ b/brush-core/src/sys/hostname.rs
@@ -0,0 +1,3 @@
+pub(crate) fn get() -> std::io::Result<std::ffi::OsString> {
+    hostname::get()
+}
diff --git a/brush-core/src/sys/os_pipe.rs b/brush-core/src/sys/os_pipe.rs
new file mode 100644
index 0000000..db83517
--- /dev/null
+++ b/brush-core/src/sys/os_pipe.rs
@@ -0,0 +1,1 @@
+pub(crate) use os_pipe::{pipe, PipeReader, PipeWriter};
diff --git a/brush-core/src/sys/stubs.rs b/brush-core/src/sys/stubs.rs
new file mode 100644
index 0000000..c46dd8e
--- /dev/null
+++ b/brush-core/src/sys/stubs.rs
@@ -0,0 +1,7 @@
+pub(crate) mod fs;
+pub(crate) mod network;
+pub(crate) mod pipes;
+pub(crate) mod process;
+pub(crate) mod signal;
+pub(crate) mod terminal;
+pub(crate) mod users;
diff --git a/brush-core/src/sys/stubs/fs.rs b/brush-core/src/sys/stubs/fs.rs
new file mode 100644
index 0000000..d3ee337
--- /dev/null
+++ b/brush-core/src/sys/stubs/fs.rs
@@ -0,0 +1,59 @@
+#[cfg(not(unix))]
+impl crate::sys::fs::PathExt for std::path::Path {
+    fn readable(&self) -> bool {
+        // TODO: implement
+        true
+    }
+
+    fn writable(&self) -> bool {
+        // TODO: implement
+        true
+    }
+
+    fn executable(&self) -> bool {
+        // TODO: implement
+        true
+    }
+
+    fn exists_and_is_block_device(&self) -> bool {
+        false
+    }
+
+    fn exists_and_is_char_device(&self) -> bool {
+        false
+    }
+
+    fn exists_and_is_fifo(&self) -> bool {
+        false
+    }
+
+    fn exists_and_is_socket(&self) -> bool {
+        false
+    }
+
+    fn exists_and_is_setgid(&self) -> bool {
+        false
+    }
+
+    fn exists_and_is_setuid(&self) -> bool {
+        false
+    }
+
+    fn exists_and_is_sticky_bit(&self) -> bool {
+        false
+    }
+}
+
+pub(crate) trait StubMetadataExt {
+    fn gid(&self) -> u32 {
+        // TODO: implement
+        0
+    }
+
+    fn uid(&self) -> u32 {
+        // TODO: implement
+        0
+    }
+}
+
+impl StubMetadataExt for std::fs::Metadata {}
diff --git a/brush-core/src/sys/stubs/network.rs b/brush-core/src/sys/stubs/network.rs
new file mode 100644
index 0000000..73ed315
--- /dev/null
+++ b/brush-core/src/sys/stubs/network.rs
@@ -0,0 +1,3 @@
+pub(crate) fn get_hostname() -> std::io::Result<std::ffi::OsString> {
+    Ok("".into())
+}
diff --git a/brush-core/src/sys/stubs/pipes.rs b/brush-core/src/sys/stubs/pipes.rs
new file mode 100644
index 0000000..531235f
--- /dev/null
+++ b/brush-core/src/sys/stubs/pipes.rs
@@ -0,0 +1,51 @@
+#[derive(Clone)]
+pub struct PipeReader {}
+
+impl PipeReader {
+    pub fn try_clone(&self) -> std::io::Result<PipeReader> {
+        Ok((*self).clone())
+    }
+}
+
+impl From<PipeReader> for std::process::Stdio {
+    fn from(_reader: PipeReader) -> Self {
+        std::process::Stdio::null()
+    }
+}
+
+impl std::io::Read for PipeReader {
+    fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
+        // TODO: implement
+        Ok(0)
+    }
+}
+
+#[derive(Clone)]
+pub struct PipeWriter {}
+
+impl PipeWriter {
+    pub fn try_clone(&self) -> std::io::Result<PipeWriter> {
+        Ok((*self).clone())
+    }
+}
+
+impl From<PipeWriter> for std::process::Stdio {
+    fn from(_writer: PipeWriter) -> Self {
+        std::process::Stdio::null()
+    }
+}
+
+impl std::io::Write for PipeWriter {
+    fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
+        // TODO: implement
+        Ok(0)
+    }
+
+    fn flush(&mut self) -> std::io::Result<()> {
+        Ok(())
+    }
+}
+
+pub(crate) fn pipe() -> std::io::Result<(PipeReader, PipeWriter)> {
+    Ok((PipeReader {}, PipeWriter {}))
+}
diff --git a/brush-core/src/sys/stubs/process.rs b/brush-core/src/sys/stubs/process.rs
new file mode 100644
index 0000000..df56ecd
--- /dev/null
+++ b/brush-core/src/sys/stubs/process.rs
@@ -0,0 +1,25 @@
+pub(crate) struct Child {
+    inner: std::process::Child,
+}
+
+pub(crate) use std::process::ExitStatus;
+pub(crate) use std::process::Output;
+
+impl Child {
+    pub fn id(&self) -> Option<u32> {
+        None
+    }
+
+    pub async fn wait(&mut self) -> std::io::Result<ExitStatus> {
+        self.inner.wait()
+    }
+
+    pub async fn wait_with_output(self) -> std::io::Result<Output> {
+        self.inner.wait_with_output()
+    }
+}
+
+pub(crate) fn spawn(mut command: std::process::Command) -> std::io::Result<Child> {
+    let child = command.spawn()?;
+    Ok(Child { inner: child })
+}
diff --git a/brush-core/src/sys/stubs/signal.rs b/brush-core/src/sys/stubs/signal.rs
new file mode 100644
index 0000000..429e5b7
--- /dev/null
+++ b/brush-core/src/sys/stubs/signal.rs
@@ -0,0 +1,38 @@
+use crate::{error, traps};
+
+pub(crate) fn parse_numeric_signal(_signal: i32) -> Result<traps::TrapSignal, error::Error> {
+    Err(error::Error::InvalidSignal)
+}
+
+pub(crate) fn parse_os_signal_name(_signal: &str) -> Result<traps::TrapSignal, error::Error> {
+    Err(error::Error::InvalidSignal)
+}
+
+pub(crate) fn continue_process(_pid: u32) -> Result<(), error::Error> {
+    error::unimp("continue process")
+}
+
+pub(crate) fn kill_process(_pid: u32) -> Result<(), error::Error> {
+    error::unimp("kill process")
+}
+
+pub(crate) struct FakeSignal {}
+
+impl FakeSignal {
+    fn new() -> Self {
+        Self {}
+    }
+
+    pub async fn recv(&self) {
+        futures::future::pending::<()>().await;
+    }
+}
+
+pub(crate) fn tstp_signal_listener() -> Result<FakeSignal, error::Error> {
+    Ok(FakeSignal::new())
+}
+
+pub(crate) async fn await_ctrl_c() -> std::io::Result<()> {
+    FakeSignal::new().recv().await;
+    Ok(())
+}
diff --git a/brush-core/src/sys/stubs/terminal.rs b/brush-core/src/sys/stubs/terminal.rs
new file mode 100644
index 0000000..8fd7289
--- /dev/null
+++ b/brush-core/src/sys/stubs/terminal.rs
@@ -0,0 +1,29 @@
+use crate::error;
+
+#[derive(Clone)]
+pub(crate) struct TerminalSettings {}
+
+impl TerminalSettings {
+    pub fn set_canonical(&mut self, _value: bool) {
+        // TODO: implement
+    }
+
+    pub fn set_echo(&mut self, _value: bool) {
+        // TODO: implement
+    }
+
+    pub fn set_int_signal(&mut self, _value: bool) {
+        // TODO: implement
+    }
+}
+
+pub(crate) fn get_term_attr<Fd>(_fd: Fd) -> Result<TerminalSettings, error::Error> {
+    Ok(TerminalSettings {})
+}
+
+pub(crate) fn set_term_attr_now<Fd>(
+    _fd: Fd,
+    _settings: &TerminalSettings,
+) -> Result<(), error::Error> {
+    Ok(())
+}
diff --git a/brush-core/src/sys/stubs/users.rs b/brush-core/src/sys/stubs/users.rs
new file mode 100644
index 0000000..ed2dd0e
--- /dev/null
+++ b/brush-core/src/sys/stubs/users.rs
@@ -0,0 +1,30 @@
+use crate::error;
+use std::path::PathBuf;
+
+pub(crate) fn get_user_home_dir() -> Option<PathBuf> {
+    None
+}
+
+pub(crate) fn is_root() -> bool {
+    false
+}
+
+pub(crate) fn get_effective_uid() -> Result<u32, error::Error> {
+    error::unimp("get effective uid")
+}
+
+pub(crate) fn get_effective_gid() -> Result<u32, error::Error> {
+    error::unimp("get effective gid")
+}
+
+pub(crate) fn get_current_username() -> Result<String, error::Error> {
+    error::unimp("get current username")
+}
+
+pub(crate) fn get_all_users() -> Result<Vec<String>, error::Error> {
+    Ok(vec![])
+}
+
+pub(crate) fn get_all_groups() -> Result<Vec<String>, error::Error> {
+    Ok(vec![])
+}
diff --git a/brush-core/src/sys/tokio_process.rs b/brush-core/src/sys/tokio_process.rs
new file mode 100644
index 0000000..f876097
--- /dev/null
+++ b/brush-core/src/sys/tokio_process.rs
@@ -0,0 +1,6 @@
+pub(crate) use tokio::process::Child;
+
+pub(crate) fn spawn(command: std::process::Command) -> std::io::Result<Child> {
+    let mut command = tokio::process::Command::from(command);
+    command.spawn()
+}
diff --git a/brush-core/src/sys/unix.rs b/brush-core/src/sys/unix.rs
new file mode 100644
index 0000000..919d3a6
--- /dev/null
+++ b/brush-core/src/sys/unix.rs
@@ -0,0 +1,7 @@
+pub(crate) use crate::sys::os_pipe as pipes;
+pub(crate) mod fs;
+pub(crate) mod network;
+pub(crate) use crate::sys::tokio_process as process;
+pub(crate) mod signal;
+pub(crate) mod terminal;
+pub(crate) mod users;
diff --git a/brush-core/src/sys/unix/fs.rs b/brush-core/src/sys/unix/fs.rs
new file mode 100644
index 0000000..b1a5952
--- /dev/null
+++ b/brush-core/src/sys/unix/fs.rs
@@ -0,0 +1,58 @@
+use std::os::unix::fs::{FileTypeExt, MetadataExt};
+use std::path::Path;
+
+impl crate::sys::fs::PathExt for Path {
+    fn readable(&self) -> bool {
+        nix::unistd::access(self, nix::unistd::AccessFlags::R_OK).is_ok()
+    }
+
+    fn writable(&self) -> bool {
+        nix::unistd::access(self, nix::unistd::AccessFlags::W_OK).is_ok()
+    }
+
+    fn executable(&self) -> bool {
+        nix::unistd::access(self, nix::unistd::AccessFlags::X_OK).is_ok()
+    }
+
+    fn exists_and_is_block_device(&self) -> bool {
+        try_get_file_type(self).map_or(false, |ft| ft.is_block_device())
+    }
+
+    fn exists_and_is_char_device(&self) -> bool {
+        try_get_file_type(self).map_or(false, |ft| ft.is_char_device())
+    }
+
+    fn exists_and_is_fifo(&self) -> bool {
+        try_get_file_type(self).map_or(false, |ft: std::fs::FileType| ft.is_fifo())
+    }
+
+    fn exists_and_is_socket(&self) -> bool {
+        try_get_file_type(self).map_or(false, |ft| ft.is_socket())
+    }
+
+    fn exists_and_is_setgid(&self) -> bool {
+        const S_ISGID: u32 = 0o2000;
+        let file_mode = try_get_file_mode(self);
+        file_mode.map_or(false, |mode| mode & S_ISGID != 0)
+    }
+
+    fn exists_and_is_setuid(&self) -> bool {
+        const S_ISUID: u32 = 0o4000;
+        let file_mode = try_get_file_mode(self);
+        file_mode.map_or(false, |mode| mode & S_ISUID != 0)
+    }
+
+    fn exists_and_is_sticky_bit(&self) -> bool {
+        const S_ISVTX: u32 = 0o1000;
+        let file_mode = try_get_file_mode(self);
+        file_mode.map_or(false, |mode| mode & S_ISVTX != 0)
+    }
+}
+
+fn try_get_file_type(path: &Path) -> Option<std::fs::FileType> {
+    path.metadata().map(|metadata| metadata.file_type()).ok()
+}
+
+fn try_get_file_mode(path: &Path) -> Option<u32> {
+    path.metadata().map(|metadata| metadata.mode()).ok()
+}
diff --git a/brush-core/src/sys/unix/network.rs b/brush-core/src/sys/unix/network.rs
new file mode 100644
index 0000000..b2ae829
--- /dev/null
+++ b/brush-core/src/sys/unix/network.rs
@@ -0,0 +1,3 @@
+pub(crate) fn get_hostname() -> std::io::Result<std::ffi::OsString> {
+    crate::sys::hostname::get()
+}
diff --git a/brush-core/src/sys/unix/signal.rs b/brush-core/src/sys/unix/signal.rs
new file mode 100644
index 0000000..3b6d4d3
--- /dev/null
+++ b/brush-core/src/sys/unix/signal.rs
@@ -0,0 +1,45 @@
+use std::str::FromStr;
+
+use crate::{error, traps};
+
+pub(crate) fn parse_numeric_signal(signal: i32) -> Result<traps::TrapSignal, error::Error> {
+    Ok(traps::TrapSignal::Signal(
+        nix::sys::signal::Signal::try_from(signal).map_err(|_| error::Error::InvalidSignal)?,
+    ))
+}
+
+pub(crate) fn parse_os_signal_name(signal: &str) -> Result<traps::TrapSignal, error::Error> {
+    Ok(traps::TrapSignal::Signal(
+        nix::sys::signal::Signal::from_str(signal).map_err(|_| error::Error::InvalidSignal)?,
+    ))
+}
+
+pub(crate) fn continue_process(pid: u32) -> Result<(), error::Error> {
+    #[allow(clippy::cast_possible_wrap)]
+    nix::sys::signal::kill(
+        nix::unistd::Pid::from_raw(pid as i32),
+        nix::sys::signal::SIGCONT,
+    )
+    .map_err(|_errno| error::Error::FailedToSendSignal)?;
+    Ok(())
+}
+
+pub(crate) fn kill_process(pid: u32) -> Result<(), error::Error> {
+    #[allow(clippy::cast_possible_wrap)]
+    nix::sys::signal::kill(
+        nix::unistd::Pid::from_raw(pid as i32),
+        nix::sys::signal::SIGKILL,
+    )
+    .map_err(|_errno| error::Error::FailedToSendSignal)?;
+
+    Ok(())
+}
+
+const SIGTSTP: std::os::raw::c_int = 20;
+
+pub(crate) fn tstp_signal_listener() -> Result<tokio::signal::unix::Signal, error::Error> {
+    let signal = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::from_raw(SIGTSTP))?;
+    Ok(signal)
+}
+
+pub(crate) use tokio::signal::ctrl_c as await_ctrl_c;
diff --git a/brush-core/src/sys/unix/terminal.rs b/brush-core/src/sys/unix/terminal.rs
new file mode 100644
index 0000000..30ea027
--- /dev/null
+++ b/brush-core/src/sys/unix/terminal.rs
@@ -0,0 +1,43 @@
+use crate::error;
+use std::os::fd::AsFd;
+
+#[derive(Clone)]
+pub(crate) struct TerminalSettings {
+    termios: nix::sys::termios::Termios,
+}
+
+impl TerminalSettings {
+    pub fn set_canonical(&mut self, value: bool) {
+        self.set_local_flag(nix::sys::termios::LocalFlags::ICANON, value);
+    }
+
+    pub fn set_echo(&mut self, value: bool) {
+        self.set_local_flag(nix::sys::termios::LocalFlags::ICANON, value);
+    }
+
+    pub fn set_int_signal(&mut self, value: bool) {
+        self.set_local_flag(nix::sys::termios::LocalFlags::ISIG, value);
+    }
+
+    fn set_local_flag(&mut self, flag: nix::sys::termios::LocalFlags, value: bool) {
+        if value {
+            self.termios.local_flags.insert(flag);
+        } else {
+            self.termios.local_flags.remove(flag);
+        }
+    }
+}
+
+pub(crate) fn get_term_attr<Fd: AsFd>(fd: Fd) -> Result<TerminalSettings, error::Error> {
+    Ok(TerminalSettings {
+        termios: nix::sys::termios::tcgetattr(fd)?,
+    })
+}
+
+pub(crate) fn set_term_attr_now<Fd: AsFd>(
+    fd: Fd,
+    settings: &TerminalSettings,
+) -> Result<(), error::Error> {
+    nix::sys::termios::tcsetattr(fd, nix::sys::termios::SetArg::TCSANOW, &settings.termios)?;
+    Ok(())
+}
diff --git a/brush-core/src/users.rs b/brush-core/src/sys/unix/users.rs
similarity index 56%
rename from brush-core/src/users.rs
rename to brush-core/src/sys/unix/users.rs
index 1c91b41..75b0e1d 100644
--- a/brush-core/src/users.rs
+++ b/brush-core/src/sys/unix/users.rs
@@ -1,15 +1,12 @@
 use crate::error;
 use std::path::PathBuf;
 
-#[cfg(unix)]
 use uzers::os::unix::UserExt;
 
-#[cfg(unix)]
 pub(crate) fn is_root() -> bool {
     uzers::get_current_uid() == 0
 }
 
-#[cfg(unix)]
 pub(crate) fn get_user_home_dir() -> Option<PathBuf> {
     if let Some(username) = uzers::get_current_username() {
         if let Some(user_info) = uzers::get_user_by_name(&username) {
@@ -20,26 +17,22 @@ pub(crate) fn get_user_home_dir() -> Option<PathBuf> {
     None
 }
 
-#[cfg(unix)]
 #[allow(clippy::unnecessary_wraps)]
 pub(crate) fn get_effective_uid() -> Result<u32, error::Error> {
     Ok(uzers::get_effective_uid())
 }
 
-#[cfg(unix)]
 #[allow(clippy::unnecessary_wraps)]
 pub(crate) fn get_effective_gid() -> Result<u32, error::Error> {
     Ok(uzers::get_effective_gid())
 }
 
-#[cfg(unix)]
 pub(crate) fn get_current_username() -> Result<String, error::Error> {
     let username = uzers::get_current_username().ok_or_else(|| error::Error::NoCurrentUser)?;
     Ok(username.to_string_lossy().to_string())
 }
 
 #[allow(clippy::unnecessary_wraps)]
-#[cfg(unix)]
 pub(crate) fn get_all_users() -> Result<Vec<String>, error::Error> {
     // TODO: implement this
     tracing::debug!("UNIMPLEMENTED: get_all_users");
@@ -47,47 +40,8 @@ pub(crate) fn get_all_users() -> Result<Vec<String>, error::Error> {
 }
 
 #[allow(clippy::unnecessary_wraps)]
-#[cfg(unix)]
 pub(crate) fn get_all_groups() -> Result<Vec<String>, error::Error> {
     // TODO: implement this
     tracing::debug!("UNIMPLEMENTED: get_all_groups");
     Ok(vec![])
 }
-
-#[cfg(windows)]
-pub(crate) fn get_user_home_dir() -> Option<PathBuf> {
-    homedir::get_my_home().unwrap_or_default()
-}
-
-#[cfg(windows)]
-pub(crate) fn is_root() -> bool {
-    // TODO: implement some version of this for Windows
-    false
-}
-
-#[cfg(windows)]
-pub(crate) fn get_effective_uid() -> Result<u32, error::Error> {
-    error::unimp("get effective uid")
-}
-
-#[cfg(windows)]
-pub(crate) fn get_effective_gid() -> Result<u32, error::Error> {
-    error::unimp("get effective gid")
-}
-
-#[cfg(windows)]
-pub(crate) fn get_current_username() -> Result<String, error::Error> {
-    Ok(whoami::username())
-}
-
-#[cfg(windows)]
-pub(crate) fn get_all_users() -> Result<Vec<String>, error::Error> {
-    // TODO: implement some version of this for Windows
-    Ok(vec![])
-}
-
-#[cfg(windows)]
-pub(crate) fn get_all_groups() -> Result<Vec<String>, error::Error> {
-    // TODO: implement some version of this for Windows
-    Ok(vec![])
-}
diff --git a/brush-core/src/sys/wasm.rs b/brush-core/src/sys/wasm.rs
new file mode 100644
index 0000000..df8e29b
--- /dev/null
+++ b/brush-core/src/sys/wasm.rs
@@ -0,0 +1,7 @@
+pub(crate) use crate::sys::stubs::fs;
+pub(crate) use crate::sys::stubs::network;
+pub(crate) use crate::sys::stubs::pipes;
+pub(crate) use crate::sys::stubs::process;
+pub(crate) use crate::sys::stubs::signal;
+pub(crate) use crate::sys::stubs::terminal;
+pub(crate) use crate::sys::stubs::users;
diff --git a/brush-core/src/sys/windows.rs b/brush-core/src/sys/windows.rs
new file mode 100644
index 0000000..4e909cb
--- /dev/null
+++ b/brush-core/src/sys/windows.rs
@@ -0,0 +1,12 @@
+pub(crate) use crate::sys::os_pipe as pipes;
+pub(crate) use crate::sys::stubs::fs;
+pub(crate) use crate::sys::stubs::network;
+
+pub(crate) mod signal {
+    pub(crate) use crate::sys::stubs::signal::*;
+    pub(crate) use tokio::signal::ctrl_c as await_ctrl_c;
+}
+
+pub(crate) use crate::sys::stubs::terminal;
+pub(crate) use crate::sys::tokio_process as process;
+pub(crate) mod users;
diff --git a/brush-core/src/sys/windows/network.rs b/brush-core/src/sys/windows/network.rs
new file mode 100644
index 0000000..b2ae829
--- /dev/null
+++ b/brush-core/src/sys/windows/network.rs
@@ -0,0 +1,3 @@
+pub(crate) fn get_hostname() -> std::io::Result<std::ffi::OsString> {
+    crate::sys::hostname::get()
+}
diff --git a/brush-core/src/sys/windows/users.rs b/brush-core/src/sys/windows/users.rs
new file mode 100644
index 0000000..04ab007
--- /dev/null
+++ b/brush-core/src/sys/windows/users.rs
@@ -0,0 +1,37 @@
+use crate::error;
+use std::path::PathBuf;
+
+//
+// Non-Unix implementation
+//
+
+pub(crate) fn get_user_home_dir() -> Option<PathBuf> {
+    homedir::get_my_home().unwrap_or_default()
+}
+
+pub(crate) fn is_root() -> bool {
+    // TODO: implement some version of this for Windows
+    false
+}
+
+pub(crate) fn get_effective_uid() -> Result<u32, error::Error> {
+    error::unimp("get effective uid")
+}
+
+pub(crate) fn get_effective_gid() -> Result<u32, error::Error> {
+    error::unimp("get effective gid")
+}
+
+pub(crate) fn get_current_username() -> Result<String, error::Error> {
+    Ok(whoami::username())
+}
+
+pub(crate) fn get_all_users() -> Result<Vec<String>, error::Error> {
+    // TODO: implement some version of this for Windows
+    Ok(vec![])
+}
+
+pub(crate) fn get_all_groups() -> Result<Vec<String>, error::Error> {
+    // TODO: implement some version of this for Windows
+    Ok(vec![])
+}
diff --git a/brush-core/src/traps.rs b/brush-core/src/traps.rs
index e46efc3..f4004ca 100644
--- a/brush-core/src/traps.rs
+++ b/brush-core/src/traps.rs
@@ -4,6 +4,7 @@ use std::{collections::HashMap, fmt::Display};
 #[derive(Clone, Copy, Eq, Hash, PartialEq)]
 pub enum TrapSignal {
     /// A system signal.
+    #[cfg(unix)]
     Signal(nix::sys::signal::Signal),
     /// The `DEBUG` trap.
     Debug,
@@ -16,6 +17,7 @@ pub enum TrapSignal {
 impl Display for TrapSignal {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         match self {
+            #[cfg(unix)]
             TrapSignal::Signal(s) => s.fmt(f),
             TrapSignal::Debug => write!(f, "DEBUG"),
             TrapSignal::Err => write!(f, "ERR"),
@@ -26,9 +28,11 @@ impl Display for TrapSignal {
 
 impl TrapSignal {
     /// Returns all possible values of `TrapSignal`.
+    #[allow(unused_mut)]
     pub fn all_values() -> Vec<TrapSignal> {
         let mut signals = vec![TrapSignal::Debug, TrapSignal::Err, TrapSignal::Exit];
 
+        #[cfg(unix)]
         for signal in nix::sys::signal::Signal::iterator() {
             signals.push(TrapSignal::Signal(signal));
         }
diff --git a/brush-interactive/Cargo.toml b/brush-interactive/Cargo.toml
index 864c4dd..8633fd6 100644
--- a/brush-interactive/Cargo.toml
+++ b/brush-interactive/Cargo.toml
@@ -20,9 +20,11 @@ workspace = true
 [dependencies]
 brush-parser = { version = "^0.2.3", path = "../brush-parser" }
 brush-core = { version = "^0.2.3", path = "../brush-core" }
+thiserror = "1.0.61"
+tracing = "0.1.40"
+
+[target.'cfg(any(windows, unix))'.dependencies]
 rustyline = { package = "brush-rustyline-fork", version = "14.0.1", features = [
     "derive",
 ] }
-thiserror = "1.0.61"
 tokio = { version = "1.37.0", features = ["macros", "signal"] }
-tracing = "0.1.40"
diff --git a/brush-interactive/src/basic_shell.rs b/brush-interactive/src/basic_shell.rs
new file mode 100644
index 0000000..e51a962
--- /dev/null
+++ b/brush-interactive/src/basic_shell.rs
@@ -0,0 +1,112 @@
+use std::io::Write;
+
+/// Represents a minimal shell capable of taking commands from standard input
+/// and reporting results to standard output and standard error streams.
+pub struct InteractiveShell {
+    shell: brush_core::Shell,
+}
+
+impl InteractiveShell {
+    /// Returns a new interactive shell instance, created with the provided options.
+    ///
+    /// # Arguments
+    ///
+    /// * `options` - Options for creating the interactive shell.
+    pub async fn new(options: &crate::Options) -> Result<InteractiveShell, ShellError> {
+        let shell = brush_core::Shell::new(&options.shell).await?;
+        Ok(InteractiveShell { shell })
+    }
+
+    /// Returns an immutable reference to the inner shell object.
+    pub fn shell(&self) -> &brush_core::Shell {
+        &self.shell
+    }
+
+    /// Returns a mutable reference to the inner shell object.
+    pub fn shell_mut(&mut self) -> &mut brush_core::Shell {
+        &mut self.shell
+    }
+
+    /// Runs the interactive shell loop, reading commands from standard input and writing
+    /// results to standard output and standard error. Continues until the shell
+    /// normally exits or until a fatal error occurs.
+    pub async fn run_interactively(&mut self) -> Result<(), ShellError> {
+        loop {
+            // Check for any completed jobs.
+            self.shell_mut().check_for_completed_jobs()?;
+
+            let result = self.run_interactively_once().await;
+            match result {
+                Ok(Some(brush_core::ExecutionResult {
+                    exit_shell,
+                    return_from_function_or_script,
+                    ..
+                })) => {
+                    if exit_shell {
+                        break;
+                    }
+
+                    if return_from_function_or_script {
+                        tracing::error!("return from non-function/script");
+                    }
+                }
+                Ok(None) => {
+                    break;
+                }
+                Err(e) => {
+                    // Report the error, but continue to execute.
+                    tracing::error!("error: {:#}", e);
+                }
+            }
+        }
+
+        Ok(())
+    }
+
+    async fn run_interactively_once(
+        &mut self,
+    ) -> Result<Option<brush_core::ExecutionResult>, ShellError> {
+        // Compose the prompt.
+        let prompt = self.shell_mut().compose_prompt().await?;
+
+        match self.readline(prompt.as_str()) {
+            Some(read_result) => {
+                let params = self.shell().default_exec_params();
+                match self.shell_mut().run_string(read_result, &params).await {
+                    Ok(result) => Ok(Some(result)),
+                    Err(e) => Err(e.into()),
+                }
+            }
+            None => Ok(None),
+        }
+    }
+
+    fn readline(&mut self, prompt: &str) -> Option<String> {
+        let _ = print!("{prompt}");
+        let _ = std::io::stdout().flush();
+
+        let mut buffer = String::new();
+        let stdin = std::io::stdin(); // We get `Stdin` here.
+        if let Ok(bytes_read) = stdin.read_line(&mut buffer) {
+            if bytes_read > 0 {
+                Some(buffer)
+            } else {
+                None
+            }
+        } else {
+            None
+        }
+    }
+}
+
+/// Represents an error encountered while running or otherwise managing an interactive shell.
+#[derive(thiserror::Error, Debug)]
+pub enum ShellError {
+    /// An error occurred with the embedded shell.
+    #[error("{0}")]
+    ShellError(#[from] brush_core::Error),
+
+    /// A generic I/O error occurred.
+    #[error("I/O error: {0}")]
+    IoError(#[from] std::io::Error),
+}
diff --git a/brush-interactive/src/interactive_shell.rs b/brush-interactive/src/interactive_shell.rs
index 789890a..f720cbe 100644
--- a/brush-interactive/src/interactive_shell.rs
+++ b/brush-interactive/src/interactive_shell.rs
@@ -7,14 +7,6 @@ use std::{
 
 type Editor = rustyline::Editor<EditorHelper, rustyline::history::FileHistory>;
 
-/// Options for creating an interactive shell.
-pub struct Options {
-    /// Lower-level options for creating the shell.
-    pub shell: brush_core::CreateOptions,
-    /// Whether to disable bracketed paste mode.
-    pub disable_bracketed_paste: bool,
-}
-
 /// Represents an interactive shell capable of taking commands from standard input
 /// and reporting results to standard output and standard error streams.
 pub struct InteractiveShell {
@@ -52,7 +44,7 @@ impl InteractiveShell {
     /// # Arguments
     ///
     /// * `options` - Options for creating the interactive shell.
-    pub async fn new(options: &Options) -> Result<InteractiveShell, ShellError> {
+    pub async fn new(options: &crate::Options) -> Result<InteractiveShell, ShellError> {
         // Set up shell first. Its initialization may influence how the
         // editor needs to operate.
         let shell = brush_core::Shell::new(&options.shell).await?;
@@ -83,7 +75,10 @@ impl InteractiveShell {
         &mut self.editor.helper_mut().unwrap().shell
     }
 
-    fn new_editor(options: &Options, shell: brush_core::Shell) -> Result<Editor, ShellError> {
+    fn new_editor(
+        options: &crate::Options,
+        shell: brush_core::Shell,
+    ) -> Result<Editor, ShellError> {
         let config = rustyline::config::Builder::new()
             .max_history_size(1000)?
             .history_ignore_dups(true)?
diff --git a/brush-interactive/src/lib.rs b/brush-interactive/src/lib.rs
index cc91cc6..d286424 100644
--- a/brush-interactive/src/lib.rs
+++ b/brush-interactive/src/lib.rs
@@ -2,6 +2,15 @@
 
 #![deny(missing_docs)]
 
+#[cfg(any(unix, windows))]
 mod interactive_shell;
+#[cfg(any(unix, windows))]
+pub use interactive_shell::{InteractiveShell, ShellError};
 
-pub use interactive_shell::{InteractiveShell, Options, ShellError};
+#[cfg(not(any(unix, windows)))]
+mod basic_shell;
+#[cfg(not(any(unix, windows)))]
+pub use basic_shell::{InteractiveShell, ShellError};
+
+mod options;
+pub use options::Options;
diff --git a/brush-interactive/src/options.rs b/brush-interactive/src/options.rs
new file mode 100644
index 0000000..4bd57ee
--- /dev/null
+++ b/brush-interactive/src/options.rs
@@ -0,0 +1,7 @@
+/// Options for creating an interactive shell.
+pub struct Options {
+    /// Lower-level options for creating the shell.
+    pub shell: brush_core::CreateOptions,
+    /// Whether to disable bracketed paste mode.
+    pub disable_bracketed_paste: bool,
+}
diff --git a/brush-shell/Cargo.toml b/brush-shell/Cargo.toml
index 60187ee..f7051d5 100644
--- a/brush-shell/Cargo.toml
+++ b/brush-shell/Cargo.toml
@@ -32,10 +32,15 @@ brush-core = { version = "^0.2.3", path = "../brush-core" }
 clap = { version = "=4.4.18", features = ["derive", "wrap_help"] }
 const_format = "0.2.32"
 git-version = "0.3.9"
-tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread"] }
 tracing = "0.1.40"
 tracing-subscriber = "0.3.18"
 
+[target.'cfg(not(any(windows, unix)))'.dependencies]
+tokio = { version = "1.37.0", features = ["rt"] }
+
+[target.'cfg(any(windows, unix))'.dependencies]
+tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread"] }
+
 [dev-dependencies]
 anyhow = "1.0.86"
 # N.B. Pin to 2.0.13 for now to keep to 1.72.0 as MSRV; 2.0.14 requires a later version.
diff --git a/brush-shell/src/main.rs b/brush-shell/src/main.rs
index ba75480..96f2f57 100644
--- a/brush-shell/src/main.rs
+++ b/brush-shell/src/main.rs
@@ -205,7 +205,12 @@ fn main() {
     //
     // Run.
     //
-    let result = tokio::runtime::Builder::new_multi_thread()
+    #[cfg(any(unix, windows))]
+    let mut builder = tokio::runtime::Builder::new_multi_thread();
+    #[cfg(not(any(unix, windows)))]
+    let mut builder = tokio::runtime::Builder::new_current_thread();
+
+    let result = builder
         .enable_all()
         .build()
         .unwrap()


Test patch:
diff --git a/brush-core/src/extendedtests.rs b/brush-core/src/extendedtests.rs
index 44af114..2a25eac 100644
--- a/brush-core/src/extendedtests.rs
+++ b/brush-core/src/extendedtests.rs
@@ -1,12 +1,9 @@
 use brush_parser::ast;
-#[cfg(unix)]
-use std::os::unix::fs::{FileTypeExt, MetadataExt};
 use std::path::Path;
 
 use crate::{
-    env, error, expansion,
-    files::PathExt,
-    namedoptions, patterns, users,
+    env, error, expansion, namedoptions, patterns,
+    sys::{fs::MetadataExt, fs::PathExt, users},
     variables::{self, ArrayLiteral},
     Shell,
 };
@@ -72,11 +69,11 @@ pub(crate) fn apply_unary_predicate_to_str(
         }
         ast::UnaryPredicate::FileExistsAndIsBlockSpecialFile => {
             let path = Path::new(operand);
-            Ok(path_exists_and_is_block_device(path))
+            Ok(path.exists_and_is_block_device())
         }
         ast::UnaryPredicate::FileExistsAndIsCharSpecialFile => {
             let path = Path::new(operand);
-            Ok(path_exists_and_is_char_device(path))
+            Ok(path.exists_and_is_char_device())
         }
         ast::UnaryPredicate::FileExistsAndIsDir => {
             let path = Path::new(operand);
@@ -88,7 +85,7 @@ pub(crate) fn apply_unary_predicate_to_str(
         }
         ast::UnaryPredicate::FileExistsAndIsSetgid => {
             let path = Path::new(operand);
-            Ok(path_exists_and_is_setgid(path))
+            Ok(path.exists_and_is_setgid())
         }
         ast::UnaryPredicate::FileExistsAndIsSymlink => {
             let path = Path::new(operand);
@@ -96,11 +93,11 @@ pub(crate) fn apply_unary_predicate_to_str(
         }
         ast::UnaryPredicate::FileExistsAndHasStickyBit => {
             let path = Path::new(operand);
-            Ok(path_exists_and_is_sticky_bit(path))
+            Ok(path.exists_and_is_sticky_bit())
         }
         ast::UnaryPredicate::FileExistsAndIsFifo => {
             let path = Path::new(operand);
-            Ok(path_exists_and_is_fifo(path))
+            Ok(path.exists_and_is_fifo())
         }
         ast::UnaryPredicate::FileExistsAndIsReadable => {
             let path = Path::new(operand);
@@ -127,7 +124,7 @@ pub(crate) fn apply_unary_predicate_to_str(
         }
         ast::UnaryPredicate::FileExistsAndIsSetuid => {
             let path = Path::new(operand);
-            Ok(path_exists_and_is_setuid(path))
+            Ok(path.exists_and_is_setuid())
         }
         ast::UnaryPredicate::FileExistsAndIsWritable => {
             let path = Path::new(operand);
@@ -160,7 +157,7 @@ pub(crate) fn apply_unary_predicate_to_str(
         }
         ast::UnaryPredicate::FileExistsAndIsSocket => {
             let path = Path::new(operand);
-            Ok(path_exists_and_is_socket(path))
+            Ok(path.exists_and_is_socket())
         }
         ast::UnaryPredicate::ShellOptionEnabled => {
             let shopt_name = operand;
@@ -447,103 +444,3 @@ fn apply_binary_arithmetic_predicate(left: &str, right: &str, op: fn(i64, i64) -
         false
     }
 }
-
-#[cfg(unix)]
-fn try_get_file_type(path: &Path) -> Option<std::fs::FileType> {
-    path.metadata().map(|metadata| metadata.file_type()).ok()
-}
-
-#[cfg(unix)]
-fn try_get_file_mode(path: &Path) -> Option<u32> {
-    path.metadata().map(|metadata| metadata.mode()).ok()
-}
-
-#[allow(unused_variables)]
-fn path_exists_and_is_block_device(path: &Path) -> bool {
-    #[cfg(unix)]
-    {
-        try_get_file_type(path).map_or(false, |ft| ft.is_block_device())
-    }
-    #[cfg(not(unix))]
-    {
-        false
-    }
-}
-
-#[allow(unused_variables)]
-fn path_exists_and_is_char_device(path: &Path) -> bool {
-    #[cfg(unix)]
-    {
-        try_get_file_type(path).map_or(false, |ft| ft.is_char_device())
-    }
-    #[cfg(not(unix))]
-    {
-        false
-    }
-}
-
-#[allow(unused_variables)]
-fn path_exists_and_is_fifo(path: &Path) -> bool {
-    #[cfg(unix)]
-    {
-        try_get_file_type(path).map_or(false, |ft: std::fs::FileType| ft.is_fifo())
-    }
-    #[cfg(not(unix))]
-    {
-        false
-    }
-}
-
-#[allow(unused_variables)]
-fn path_exists_and_is_socket(path: &Path) -> bool {
-    #[cfg(unix)]
-    {
-        try_get_file_type(path).map_or(false, |ft| ft.is_socket())
-    }
-    #[cfg(not(unix))]
-    {
-        false
-    }
-}
-
-#[allow(unused_variables)]
-fn path_exists_and_is_setgid(path: &Path) -> bool {
-    #[cfg(unix)]
-    {
-        const S_ISGID: u32 = 0o2000;
-        let file_mode = try_get_file_mode(path);
-        file_mode.map_or(false, |mode| mode & S_ISGID != 0)
-    }
-    #[cfg(not(unix))]
-    {
-        false
-    }
-}
-
-#[allow(unused_variables)]
-fn path_exists_and_is_setuid(path: &Path) -> bool {
-    #[cfg(unix)]
-    {
-        const S_ISUID: u32 = 0o4000;
-        let file_mode = try_get_file_mode(path);
-        file_mode.map_or(false, |mode| mode & S_ISUID != 0)
-    }
-    #[cfg(not(unix))]
-    {
-        false
-    }
-}
-
-#[allow(unused_variables)]
-fn path_exists_and_is_sticky_bit(path: &Path) -> bool {
-    #[cfg(unix)]
-    {
-        const S_ISVTX: u32 = 0o1000;
-        let file_mode = try_get_file_mode(path);
-        file_mode.map_or(false, |mode| mode & S_ISVTX != 0)
-    }
-    #[cfg(not(unix))]
-    {
-        false
-    }
-}