When we talk about shells, a lot of programmers don’t really think of them as separate programs. Some assume they’re just part of the terminal application, while others see them as a piece of system software. Most systems come with a basic shell, but we often install others with better features and looks, like I use zsh.
So, I started wondering—what does it actually take to write a shell? At first, I thought it was just about reading user input and executing commands with a simple system call like system(<command>)
. But turns out, it’s not that simple. Shells provide a ton of features that we take for granted, like I/O redirection, piping, background and foreground process management, and much more.
To really understand how shells work, I decided to write one from scratch. I looked up some existing implementations online, but most were in C and covered only the basics, many didn’t even implement piping or I/O redirection properly.
I chose C++ over C, not because I wanted to bring in OOP, but simply because I’m more comfortable with C++ and wanted to take advantage of STL. That said, everything I’m going to cover here could also be done in C with a few minor changes.
In this blog, I’ll walk you through how I built my own shell in C++, sharing the concepts I learned along the way. I’ll include small code snippets but also leave room for you to implement things on your own—so you can follow along and build it yourself.
In this blog (more like a code walkthrough), I’ll walk you through how I built my own shell in C++, sharing the concepts I learned along the way. I’ll include small code snippets but also leave room for you to implement things on your own—so you can follow along and build it yourself.
Understanding What a Shell Is
At its core, a shell is just an interpreter program. It shows a prompt, accepts user commands, executes them, displays the output (if any), and then shows the prompt again. Essentially, it follows a basic interpreter loop.
The shell acts as an interface between you and the Unix system—it’s like a middleman between you and the kernel. Instead of interacting with the system directly, you give commands to the shell, and it takes care of running them for you.
If you want to look at more theory or some shell concepts, here are some resources:
What We’ll Be Building
We’re going to build our shell step by step. First, we’ll implement a simple shell loop that executes user commands. Then, we’ll add support for built-in commands like cd
. Later, we’ll work on more advanced features like:
I/O redirections (
>
,>>
)Piping (
|
)Background execution (
&
)
In Part 1, we’ll cover the basics—getting our shell loop working and adding simple commands.
In Part 2, we’ll implement the more advanced features.
» I’m considering you guys know basics of C++ and some libraries like STL, if you don’t you can learn as any new thing pops out in this blog. We will also get to know some low level functions in C/C++ which directly interacts with OS. I won’t explain every function and how they work but will give a high level idea of what they do. I recommend that you later search them up or read from the referees I provide in blog. Also this is my first blog so there might be some mistakes here n there, hope you guys understand.
Let’s goo!
Step 1: Creating the Shell Loop
Our basic shell loop follows a simple flow:
Display a prompt
Take user input
Execute the command
Show the output
Repeat
We'll start by writing main.cpp
. The shell should run in an infinite loop, waiting for user input. Here’s the basic structure:
#include <iostream>
#include <vector>
#include <string>
#include "inputParser.h"
#include "execute.h"
using namespace std;
int main() {
while (true) {
// Display prompt
cout << " $ ";
// Take input
string input;
getline(cin, input);
// Parse and execute command
vector<string> parsedInput = parser(input);
cout << executeCommand(parsedInput) << endl;
}
return 0;
}
Step 2: Parsing User Input
Before executing a command, we need to split the input string into separate words. We’ll create a function that takes a string like "ls -l /home"
and splits it into { "ls", "-l", "/home" }
.
Create a new file inputParser.cpp
:
#include "inputParser.h"
#include <sstream>
vector<string> parser(string s) {
stringstream ss(s);
vector<string> inputParsed;
string word;
while (ss >> word) {
inputParsed.push_back(word);
}
return inputParsed;
}
And its header file inputParser.h
:
#pragma once
#include <vector>
#include <string>
using namespace std;
vector<string> parser(string s);
Talking about the #pragma once
it’s added to avoid multiple inclusions of libraries which creates confusion for compiler later.
Now, our shell can break user input into separate arguments before execution.
Step 3: Executing Commands
Now that we have the user input in a parsed format (vector<string>
), we need a function to execute system commands.
Create a new file execute.cpp
:
#include "execute.h"
#include <unistd.h>
#include <sys/wait.h>
#include <iostream>
string executeCommand(vector<string> input) {
if (input.empty()) return "";
pid_t pid = fork();
if (pid == 0) {
// Convert vector<string> to char* array for execvp
char* args[input.size() + 1];
for (size_t i = 0; i < input.size(); i++) {
args[i] = const_cast<char*>(input[i].c_str());
}
args[input.size()] = NULL;
execvp(args[0], args);
perror("execvp failed");
exit(1);
}
else if (pid > 0) {
int status;
waitpid(pid, &status, 0);
}
else {
perror("fork failed");
}
return "";
}
And its header file execute.h
:
#pragma once
#include <vector>
#include <string>
using namespace std;
string executeCommand(vector<string> input);
If you want to learn about execvp() :
Step 4: Adding a Prompt with Current Directory
A good shell should show the current directory in the prompt. I use zsh prompt which looks like this
So, I’m gonna modify it to look similar
In main.cpp
:
#include <filesystem>
string getPathFromHome() {
string str = filesystem::current_path();
return str.substr(str.find_last_of("/") + 1);
}
int main() {
while (true) {
// Show prompt with current directory
cout << "[~/" << getPathFromHome() << "]$ ";
// Take input
string input;
getline(cin, input);
// Parse and execute command
vector<string> parsedInput = parser(input);
cout << executeCommand(parsedInput) << endl;
}
return 0;
}
Filesystem module : https://en.cppreference.com/w/cpp/filesystem
Now, our shell displays the current directory, you can also change it accoding to your test, I just like mine to be simple.
» If you want to learn more then you can try to add git indicator like current branch to the prompt.
Step 5: Handling cd
(Change Directory)
So far, our shell can run system commands, but cd
is a built-in shell command—it doesn’t work like execvp
. We need to handle it manually.
Create cd.cpp
:
#include "cd.h"
#include <filesystem>
#include <unistd.h>
#include <iostream>
void changeDir(string givenPath) {
filesystem::path newPath;
if(givenPath == "."){
return;
}else if(givenPath == ".."){
newPath = filesystem::current_path().parent_path();
}else{
newPath = filesystem::absolute(givenPath);
}
if(newPath.empty()){
cerr << "cd: No parent directory.\\n";
return;
}
if(chdir(newPath.c_str()) != 0){
perror("cd");
}
return;
}
And its header file cd.h
:
#pragma once
#include <string>
using namespace std;
void changeDir(string path);
Modify execute.cpp
to check for cd
:
if (input[0] == "cd") {
if (input.size() == 1) return "Path not provided...\\n";
changeDir(input[1]);
return "";
}
Now our shell supports changing directories!
What’s Next?
We now have:
A working shell loop
Command execution
A custom prompt with the current directory
The
cd
command
In Part 2, we’ll add:
Background execution (
&
)I/O redirection (
>
,>>
)Piping (
|
)
If you want to checkout the complete code : https://github.com/Sidd-77/cpp-sh