I never really knew what a shell actually was until I switched my entire workflow to Linux and started using EndeavourOS as my daily driver. That completely changed the way I think and work inside a terminal.
As I started using the terminal more interactively, I realized that the terminal itself is just a container that provides a medium for interaction. The actual thing working behind it is the shell, which sits between the user and the kernel for communication. This made me curious about shells — how they process commands and what they are actually made of.
So I started building my own shell.
The basic idea was simple: start a loop that takes input and performs some operation. But as I moved ahead, lots of things started appearing in the way.
The first thing I faced was handling basic input/output, but without using printf and scanf, because these methods require formatting and are more one-ended operations. Instead, I switched to read() and write() system calls, which can continuously read and write through specified streams (STDIN_FILENO / STDOUT_FILENO).
Parsing User Input
The first thing to do was parsing the user input into commands.
Suppose you enter:
echo "hello world"
Here, the shell must identify what is the command and what are the arguments. That is exactly what the parser does — it parses user input into an array of strings.
Executing Commands using fork()
After parsing, the next step was executing commands entered by the user.
This was a very intuitive part for me because I used the fork() child-process mechanism for command execution. Every command is basically a child process of the shell process.
Here I handled two cases:
- what the child process should do when created
- what the parent process should do while the child is executing
The parent process must wait until the child process completes.
pid_t pid = fork();
if (pid == 0) {
// child operations
} else if (pid < 0) {
perror("failed forking child");
}
waitpid(pid, &status, 0);
Pipes and Multiple Processes
Things became even more interesting when handling pipes.
For executing multiple commands together, we need to fork multiple child processes. Currently, I have implemented support for only two child processes, meaning only one pipe is allowed in a command.
int fds[2];
pipe(fds);
This is how a pipe is created with two ends:
- one for reading
- one for writing
Another important thing here is dup2().
I like to think of dup2() as linking two bottle openings together into a single flow. It helps connect two stream ends and treat them as one stream.
Here it connects:
- the writing end of the pipe with
stdout - the reading end of the pipe with
stdin
But one important thing to watch out for is resource management. The OS has limited resources, so if we are not using any pipe ends, we must close them.
if (child1 == 0) {
close(fds[0]); // read end not needed here
dup2(fds[1], STDOUT_FILENO);
close(fds[1]); // close after usage
execvp(left[0], left);
perror("execvp failed");
exit(1);
}
The exact opposite happens for the second child process with fds[0] and stdin.
Raw Mode and Autocompletion
The next thing that really tinkered with my mind was runtime user tracking.
Suppose you are typing a command and want autocompletion. You press Tab, and the shell completes the command.
But here is the tricky part:
How does the shell know you pressed Tab if you never pressed Enter?
This is solved using a special package in C called termios, which changes terminal settings and switches it from canonical mode to raw mode.
Handling user commands in raw mode is itself a hard and complex task because we must manually:
- print every character typed by the user
- control cursor movement
But the tradeoff is worth it because now we get exact keypress data in real time. This improves the user experience a lot.
As I spent more time working on this, I realized one thing:
A great shell has a lot of headaches hidden behind a smooth user experience.
// termios to enter raw mode
struct termios orig_termios;
void disable_raw_mode() {
tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_termios);
}
void enable_raw_mode() {
tcgetattr(STDIN_FILENO, &orig_termios);
atexit(disable_raw_mode);
struct termios raw = orig_termios;
raw.c_lflag &= ~(ECHO | ICANON);
raw.c_lflag |= ISIG;
tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}
My shell also supports:
- command autocompletion
- redirections
Both of these became possible because of raw mode handling.
Building Extra Features
Once the user experience improved, the next part was adding flexibility to the shell.
1. Built-in Tree View
This was probably my favorite part while building the shell.
I used getcwd() to read the current working directory, then read all directory contents, stored them inside an array, and printed them recursively.
struct Direntry *entries = malloc(512 * sizeof(struct Direntry));
int count = 0;
DIR *dir;
struct dirent *entry;
dir = opendir(path);
if (dir == NULL) {
perror("failed opening dir");
return;
}
while ((entry = readdir(dir)) != NULL) {
if (strcmp(entry->d_name, ".") == 0 ||
strcmp(entry->d_name, "..") == 0)
continue;
entries[count].name = strdup(entry->d_name);
entries[count].is_dir = (entry->d_type == DT_DIR);
count++;
}
The most exciting phase was printing the tree connectors while maintaining indentation levels.
The idea is simple:
print all entries until the last entry in the current directory, then break the connector.
for (int i = 0; i < count; i++) {
printf("\n");
for (int j = 0; j < depth; j++)
printf("│ ");
if (i == count - 1) {
printf("└── %s", entries[i].name);
} else {
printf("├── %s", entries[i].name);
}
if (entries[i].is_dir) {
char fullpath[512];
snprintf(fullpath,
sizeof(fullpath),
"%s/%s",
path,
entries[i].name);
tree(fullpath, depth + 1, maxdepth);
}
}
2. Directory Jumps
I also created a feature that lets you jump to recently visited directories directly, similar to zoxide.
Ion stores all visited directories inside a history file and checks for matching strings to jump directly using chdir().
That’s basically how I built my shell from scratch.
I still feel like there are many things left to talk about, but I think the blog is already too long at this point.
I’m writing a post, not the full documentation of my shell haha.
Would love to hear your thoughts in comments and thank you for your time!.
Repository: Ion Shell on GitHub
Top comments (0)