Writing a Simple Shell in C Sharp

In this post, we will write a minimalistic shell for UNIX(-like) operating systems in C# programming language. I create this for learning C# purpose.

Since its purpose demonstration (not feature completeness or even fitness for causual use), it has many limitations, including:

Before we start, make sure you have .NET Core environment and knownledge.

What is a shell?

In computing, a shell is a computer program that exposes an operating system’s services to a human user or other programs. In general, operating system shells use either a command-line interface (CLI) or graphical user interface (GUI), depending on a computer’s role and particular operation. It is named a shell because it is the outermost layer around the operating system.

Some examples:

In this article, I describe a simple text-based, non-graphical shell with the basic functionality: give an input command and receive the output of this command.

# Input
$ ls

# Output
Documents   Downloads   Desktop
...

That’s it!

Basic lifetime of a shell

Let’s look at a shell from the top down. A shell does three main things its lifetime.

But in our case, the shell will be simple as possible that there won’t be any configuration files, and there won’t be any shutdown command. So, we’ll just call the input looping function and then terminate.

The basic structure looks like the following:

namespace KShell;

class KShell
{
    // Test it in Linux only
    private static string _currUser = Environment.UserName;
    private static string _currDir = Directory.GetCurrentDirectory();
    private static string _hostname = Environment.MachineName;

    static void Main(string[] args)
    {
        // Load config files, if any.

        // Run the input loop
        while (true)
        {
            Console.Write($"{_currUser}@{_hostname}:{_currDir}$ ");

            // Read the keyboard input
            string? input = Console.ReadLine();
            if (String.IsNullOrEmpty(input))
                continue;

            ExecCommand(input);
        }
    }

    static void ExecCommand(string input)
    {
        // Just print
        Console.WriteLine($"The input commmand: {input}");
    }
}

Build, run and we have the result, do you feel familiar?

kiennt@kiennt-ROG-Strix-G513IH-G513IH:/home/kiennt/Workspace/github.com/ntk148v/Solution1/KShell/bin/Debug/net6.0$ cd
The input commmand: cd

Execute command

Now, we want to execute the entered command in ExecCommand(string input) function.

From it.uu.se: Operating systems

From it.uu.se: Operating systems

static void ExecCommand(string input)
{
    // Split the input separate the command and the arguments
    string[] args = input.TrimEnd().Split(" ");

    // Execute command
    ProcessStartInfo startInfo = new ProcessStartInfo()
    {
        FileName = args[0],
        Arguments = string.Join("", args.Skip(1).ToArray()),
        RedirectStandardOutput = true,
        RedirectStandardError = true,
    };

    Process proc = new Process() { StartInfo = startInfo };

    // Clear the standard output
    // NOTE(kiennt26): This is a trick to remove the prefix from the command output
    proc.OutputDataReceived += (sender, e) => Console.WriteLine(e.Data);
    proc.Start();
    proc.BeginOutputReadLine();
    proc.WaitForExit();
}

Build and run the shell, enter your desired command.

kiennt@kiennt-ROG-Strix-G513IH-G513IH:/home/kiennt/Workspace/github.com/ntk148v/Solution1/KShell/bin/Debug/net6.0$ ls
KShell
KShell.deps.json
KShell.dll
KShell.pdb
KShell.runtimeconfig.json

kiennt@kiennt-ROG-Strix-G513IH-G513IH:/home/kiennt/Workspace/github.com/ntk148v/Solution1/KShell/bin/Debug/net6.0$ ls -la
total 176
drwxrwxr-x 2 kiennt kiennt   4096 Thg 8  21 14:33 .
drwxrwxr-x 3 kiennt kiennt   4096 Thg 8  21 14:28 ..
-rwxr-xr-x 1 kiennt kiennt 142840 Thg 8  21 14:52 KShell
-rw-rw-r-- 1 kiennt kiennt    388 Thg 8  21 14:33 KShell.deps.json
-rw-rw-r-- 1 kiennt kiennt   6656 Thg 8  21 14:52 KShell.dll
-rw-rw-r-- 1 kiennt kiennt  10676 Thg 8  21 14:52 KShell.pdb
-rw-rw-r-- 1 kiennt kiennt    139 Thg 8  21 14:33 KShell.runtimeconfig.json

kiennt@kiennt-ROG-Strix-G513IH-G513IH:/home/kiennt/Workspace/github.com/ntk148v/Solution1/KShell/bin/Debug/net6.0$ pwd
/home/kiennt/Workspace/github.com/ntk148v/Solution1/KShell/bin/Debug/net6.0

It works nicely! It’s starting to look like a real shell! But hold on, enter the super casual command - cd:

kiennt@kiennt-ROG-Strix-G513IH-G513IH:/home/kiennt/Workspace/github.com/ntk148v/Solution1/KShell/bin/Debug/net6.0$ cd
Unhandled exception. System.ComponentModel.Win32Exception (2): An error occurred trying to start process 'cd' with working directory '/home/kiennt/Workspace/github.com/ntk148v/Solution1/KShell/bin/Debug/net6.0'. No such file or directory
...

Huh, something went wrong here. Why does the cd command not work? cd is not real command, the functionality is a built-in command of the shell.

Shell Built-in Commands

Now, we will create some built-in commands. We have to modify the ExecCommand function: add a switch statement to the first argument (the command to execute) which is stored in args[0].

cd

First, implement cd:

    static void ExecCommand(string input)
    {
        // Split the input separate the command and the arguments
        string[] args = input.TrimEnd().Split(" ");

        // Check for the built-in shell commands
        switch (args[0])
        {
            case "cd":
                BuiltInCD(args);
                break;
            case "#":
                // Handle the comment case
                break;
            default:
                // Execute command
                // ...
        }
    }

    /// <summary>
    /// Built-in cd - Change the shell working directory.
    /// Change the current directory to DIR. The default DIR is the value of the
    /// HOME shell variable.
    /// cd [dir]
    /// </summary>
    /// <param name="args"></param>
    static void BuiltInCD(string[] args)
    {
        string newWorkingDir;
        if (args.Length < 2)
        {
            // cd to home with empty path
            newWorkingDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
        }
        else if (args[1] == "~") // handle a special character
        {
            newWorkingDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
        }
        else
        {
            newWorkingDir = args[1];
        }

        // Change the directory
        Directory.SetCurrentDirectory(newWorkingDir);
        _currDir = Directory.GetCurrentDirectory();
    }

exit

Similiarly, implement the exit command, it’s quite simple.

    // ...
    // ExecCommand
            case "exit":
                BuiltInExit(0);
                break;
    // ...

    /// <summary>
    /// Built-in exit - Exit the shell with a status of n. If n is omitted,
    /// the exit status is that of the last command executed.
    /// exit [n]
    /// </summary>
    /// <param name="exitCode"></param>
    static void BuiltInExit(int exitCode)
    {
        // TODO(kiennt26): Handle the given exit code, it should be in range 0-255
        Environment.Exit(exitCode);
    }

which

which returns the pathnames of the files (or links) which would be executed in the current environment. It does this by searching the PATH for executable files matching the file names of the arguments.

// Get the PATH environment variable for a list of directories.
    private static string[] _path = Environment.GetEnvironmentVariable("PATH").Split(":");

Create a function that searches the PATH for executable files matching the file names of the arguments.

    static List<string> SearchInPath(string executable)
    {
        List<string> pathNames = new List<string>();
        // string[] pathNames = new string[0];
        foreach (string p in _path)
            pathNames.AddRange(Directory.GetFiles(p, executable));

        return pathNames;
    }

Then, create BuiltInWhich function to print out the result. If there is no matching executable file, returns nothing.

    // ExecCommand
            case "which":
                BuiltInWhich(args);
                break;


    /// <summary>
    /// Built-in which - locate a command
    /// which returns the pathnames of the files (or links) which would be executed in the current environment.
    /// It does this by searching the PATH for executable files matching the file names of the arguments.
    /// </summary>
    /// <param name="args"></param>
    static void BuiltInWhich(string[] args)
    {
        if (args.Length < 2)
            return;
        foreach (string executable in args.Skip(1).ToArray())
        {
            foreach (string p in SearchInPath(executable))
                Console.WriteLine(p);
        }
    }
kiennt@kiennt-ROG-Strix-G513IH-G513IH:/home/kiennt/Workspace/github.com/ntk148v/Solution1/KShell/bin/Debug/net6.0$ which ls
/usr/bin/ls
/bin/ls
kiennt@kiennt-ROG-Strix-G513IH-G513IH:/home/kiennt/Workspace/github.com/ntk148v/Solution1/KShell/bin/Debug/net6.0$ which pwd
/usr/bin/pwd
/bin/pwd
kiennt@kiennt-ROG-Strix-G513IH-G513IH:/home/kiennt/Workspace/github.com/ntk148v/Solution1/KShell/bin/Debug/net6.0$ which cat grep
/usr/bin/cat
/bin/cat
/usr/bin/grep
/bin/grep
kiennt@kiennt-ROG-Strix-G513IH-G513IH:/home/kiennt/Workspace/github.com/ntk148v/Solution1/KShell/bin/Debug/net6.0$

help

A help page always necessary, let’s create one. Logic is simple:

    // ExecCommand
            case "help":
                BuiltInHelp(args);
                break;

    static void BuiltInHelp(string[] args)
    {
        string help;
        if (args.Length < 2)
        {
            help = @"
KShell aka. Kien's Shell, written in C#.

    Type program names and arguments, and hit <enter>.
    These shell commands are defined internally.  Type `help` to see this list.
    Type `help name` to find out more about the function `name'.

    cd [dir]
    exit [n]
    which filename ...
    help";
        }
        else
        {
            switch (args[1])
            {
                case "cd":
                    help = @"
cd: cd [dir]

    Change the shell working directory.

    Change the current directory to 'dir'. The default 'dir' is the value of the user's home directory.";
                    break;
                case "exit":
                    help = @"
exit: exit [n]

    Exit the shell.

    Exits the shell with a status of 'n'.";
                    break;
                case "which":
                    help = @"
which: which filename ...

    Locate a command.

    which returns the pathnames of the files (or links) which would be executed in the current environment.
    It does this by searching the PATH for executable files matching the names of the arguments.
";
                    break;
                default:
                    help = @"
KShell aka. Kien's Shell, written in C#.

    Type program names and arguments, and hit <enter>.
    These shell commands are defined internally.  Type `help` to see this list.

    cd [dir]
    exit [n]
    help";
                    break;
            }
        }

        Console.WriteLine(help);
    }
kiennt@kiennt-ROG-Strix-G513IH-G513IH:/home/kiennt/Workspace/github.com/ntk148v/Solution1/KShell/bin/Debug/net6.0$ help cd

cd: cd [dir]

    Change the shell working directory.

    Change the current directory to 'dir'. The default 'dir' is the value of the user's home directory.
kiennt@kiennt-ROG-Strix-G513IH-G513IH:/home/kiennt/Workspace/github.com/ntk148v/Solution1/KShell/bin/Debug/net6.0$ help

KShell aka. Kien's Shell, written in C#.

    Type program names and arguments, and hit <enter>.
    These shell commands are defined internally.  Type `help` to see this list.
    Type `help name` to find out more about the function `name'.

    cd [dir]
    exit [n]
    which filename ...
    help

Improvement

Handle exception

Entering the wrong command, and a long stacktrace returns. It makes nonsense for the end user. The end user just need a message like: “command not found”. Just it.

We will wrap the main input loop in try/catch.

    static void Main(string[] args)
    {
        // Load config files, if any.

        // Run the input loop
        while (true)
        {
            try
            {
                Console.Write($"{_currUser}@{_hostname}:{_currDir}$ ");

                // Read the keyboard input
                string? input = Console.ReadLine();
                if (String.IsNullOrEmpty(input))
                    continue;

                ExecCommand(input);
            }
            catch (Exception e)
            {
                Console.WriteLine(e.Message);
                // 0 - Success
                // 1 - Fail
                BuiltInExit(1);
            }
        }
    }

Looks much better now:

kiennt@kiennt-ROG-Strix-G513IH-G513IH:/home/kiennt/Workspace/github.com/ntk148v/Solution1/KShell/bin/Debug/net6.0$ wrongcommand
An error occurred trying to start process 'wrongcommand' with working directory '/home/kiennt/Workspace/github.com/ntk148v/Solution1/KShell/bin/Debug/net6.0'. No such file or directory

Handle command not found

The exception’s message is still not clear enough for the end user. We can check if the command is existing before execute it. Remember search3

    // ExecCommand
            default:
                // Check if args[0] is an executable file
                if (SearchInPath(args[0]).Count < 1)
                {
                    throw new Exception($"{args[0]}: command not found");
                }

                // ...
kiennt@kiennt-ROG-Strix-G513IH-G513IH:/home/kiennt/Workspace/github.com/ntk148v/Solution1/KShell/bin/Debug/net6.0$ wrongcommand
wrongcommand: command not found

Wrap up

I hope you enjoyed it. I think, when you understand the concepts behind it, it’s quite simple (especially with high-level programming language like C#).

Feel free to extend the shell with new features.

This post is highly inspired by:

The code for KShell is available on Github.