DEV Community

Cover image for Building gsh: A Minimal Shell in C
P Giri Kishore
P Giri Kishore

Posted on

Building gsh: A Minimal Shell in C

Earlier this year, I stumbled upon Stephen Brennan’s tutorial on writing a shell in C. It was clear, hands-on, and piqued my interest in Unix internals and systems programming.

While following along, I started wondering — what if I made this shell more interactive?

That’s how gsh was born: a minimal shell that builds on Brennan’s lsh with features like:

  1. Real-time input using raw terminal mode
  2. Arrow-key command history navigation
  3. A circular history buffer
  4. Modular code structure

In this post, I’ll walk you through how it works, why I built it, and what’s coming next.

Browse the source on GitHub


The Starting Point: Brennan’s Minimal Shell

Brennan’s tutorial provides a compact shell implementation that:

  • Reads a line of input using getline()
  • Splits it into tokens with strtok()
  • Implements built-ins like cd, help, and exit
  • Uses fork() and execvp() to run external commands

I kept this structure but added a few upgrades:

  • A Makefile for easy compilation
  • A shell.h header for prototypes and constants
  • Better memory safety and error handling

Handling Input: Raw Mode vs. Canonical Mode

By default, terminals operate in canonical mode, buffering input until Enter is pressed. That makes it impossible to respond to arrow keys and other keys in real time.

To enable character-by-character input, I switched the shell into raw mode using termios:

void enable_raw_mode(struct termios *orig) {
    tcgetattr(STDIN_FILENO, orig);
    struct termios raw = *orig;
    raw.c_lflag &= ~(ICANON | ECHO);
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}

void disable_raw_mode(struct termios *orig) {
    tcsetattr(STDIN_FILENO, TCSAFLUSH, orig);
}
Enter fullscreen mode Exit fullscreen mode

These are called at the start and end of gsh_read_line() to enable real-time input.


Decoding Arrow Keys with Escape Sequences

In raw mode, arrow keys emit escape sequences:

↑ → ESC [ A

↓ → ESC [ B

To handle this, I read characters one at a time and check for '\x1b' (Escape):

if (c == '\x1b') {
    char seq[2];
    seq[0] = getchar();
    seq[1] = getchar();
    if (seq[0] == '[') {
        if (seq[1] == 'A') {
            // Up arrow logic
        } else if (seq[1] == 'B') {
            // Down arrow logic
        }
    }
    continue;
}
Enter fullscreen mode Exit fullscreen mode

This allowed me to respond to arrow presses without needing Enter.


Storing Command History: Circular Buffer

I used a circular buffer to store command history:

#define HISTORY_SIZE 100
char *history[HISTORY_SIZE];
int history_head = 0;
int history_size = 0;
int history_index = -1;
Enter fullscreen mode Exit fullscreen mode

When the user presses Enter, the command is stored like this:

if (history[history_head]) free(history[history_head]);
history[history_head] = strdup(buffer);
history_head = (history_head + 1) % HISTORY_SIZE;
if (history_size < HISTORY_SIZE) history_size++;
history_index = -1;
Enter fullscreen mode Exit fullscreen mode

This way, we can store up to 100 commands and overwrite old ones as needed.

Navigating History with ↑ and ↓
When the user presses ↑, we step backward through the buffer:

if (history_size > 0) {
    if (history_index < history_size - 1) history_index++;
    int idx = (history_head - 1 - history_index + HISTORY_SIZE) % HISTORY_SIZE;
    strcpy(buffer, history[idx]);
    position = strlen(buffer);
    printf("\33[2K\r%s > %s", getcwd(NULL, 0), buffer);
}
Enter fullscreen mode Exit fullscreen mode

When ↓ is pressed, we move forward toward newer commands:

if (history_index > 0) {
    history_index--;
    // similar index math
} else {
    history_index = -1;
    buffer[0] = '\0';
    position = 0;
    printf("\33[2K\r%s > ", getcwd(NULL, 0));
}
Enter fullscreen mode Exit fullscreen mode

This gives a smooth experience like bash/zsh history browsing.


Executing Commands

Once input is complete, it’s split into tokens and checked against built-ins:

char **args = gsh_split_line(line);
if (!strcmp(args[0], "cd")) {
    gsh_cd(args);
} else {
    gsh_launch(args);
}
Enter fullscreen mode Exit fullscreen mode

gsh_launch() uses fork() and execvp() to launch programs:

pid_t pid = fork();
if (pid == 0) {
    execvp(args[0], args);
    perror("gsh"); exit(EXIT_FAILURE);
} else {
    waitpid(pid, &status, WUNTRACED);
}
Enter fullscreen mode Exit fullscreen mode

What Works So Far

  • Basic shell loop: read → parse → execute
  • Built-ins: cd, help, exit
  • External command execution
  • Arrow key history navigation (↑ / ↓)
  • Circular history buffer with wraparound
  • Backspace support and basic inline editing
  • Clean modular codebase (.c and .h files)
  • Build system using Makefile

What’s Next

  • Left/right arrow support and cursor movement
  • Full in-line editing (insert/delete mode)
  • Persistent history (.gsh_history) file
  • Output redirection (>, <, >>)
  • Pipes (|) and background execution (&)
  • Tab completion
  • A more colorful prompt: user@host:~/dir$

Credits

Stephen Brennan — Write a Shell in C
Thank you for the amazing tutorial. This project would not exist without it.


Try It Yourself 💻

git clone https://github.com/pgirikishore/gsh.git
cd gsh
make
./gsh
Enter fullscreen mode Exit fullscreen mode

Start typing commands, then use ↑ and ↓ to navigate your command history.

Feedback, forks, and pull requests are all welcome!

Top comments (0)