Who needs a mouse?

I needed something light and simple to take my mind of work, side projects and have a break from dealing with AI all day long. So what is more logical than starting to build your own shell. Totally makes sense, right? Right?! I am not exactly sure where this will go, but I got a few ideas of what I am missing from most shells.

The goal is not to reinvent the wheel and implement all bad utilities. I might actually replace some in the spirit of exa and bat where I add features that make my life a lot easier or more comfortable. The goal is still to only have a single binary I can quickly drop on a new system or VM and have all the tools around instead of cloning a git repository with five or six binaries and half of them not working because they require glibc and I ended up on an Alpine VM.

Compared to other LazerBunny projects this will move a lot slower and have considerably less code, so it will be easier to take you along for the ride. The basics of a shell sound fairly simple: read, evaluate, write. Sadly things are not that easy at all, but we are working towards it.

func (l *LBS) Run() {
    reader := bufio.NewReader(os.Stdin)

    for {
        fmt.Printf(l.prompt())

        input, err := reader.ReadString('\n')
        if err != nil {
            l.printError("error reading input", err)
            continue
        }

        input = strings.TrimSpace(input)

        // sometimes you need a few empty lines, don't you?
        if input == "" {
            continue
        }

        //split := strings.Fields(input)

        split, err := shlex.Split(input)
        if err != nil {
            l.printError("error shelx.Split", err)
            continue
        }

        l.handleCommand(split[0], split[1:])
    }
}

This loop worked fairly well to call standard system commands such as ls or cat. Where it failed was for git commits.

git commit -am "foo"
fatal: paths 'commit" ..' with -a does not make sense
error running command: exit status 128

Who would have thought a naive split := strings.Fields(input) will not do the trip to separate command and arguments. It keeps the quotes around "foo" intact. I used the google/shlex package to make sure this was the only issue with the loop for now. Long term I will likely implement my own tokenizer.

I have not decided if I will write everything from scratch or drop a few convenient libraries in, as long as there is no C dependency. But going standard library only does sound kind of fun and appealing for a recreational detour from a side project. (Sentences only software engineers use, I guess.)

One set of libraries I am considering to use is bubbletea, gloss and others, just to make the shell look nice and have an easier way for some features I am thinking about. One is a popover with the command and output history for commands. Another was live search through the history and a click and select option. Maybe command templates instead of messing with aliases. But introducing an event based system would also considerably change the base program and force me to fight bubbletea a bit on raw mode.

Most commands are just pass through for other binaries, the only two I really need to handle internally right now are exit and changing directories.

switch command {
    case "exit":
        fmt.Println("your loss.")
        os.Exit(0)
    case "cd":
        target := "."

        if len(args) > 0 {
            target = args[0]
        }

        if err := os.Chdir(target); err != nil {
            l.printError("cd error", err)
        }
        return
    }

    cmd := exec.Command(command, args...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    if err := cmd.Run(); err != nil {
        l.printError("error running commnad", err)
    }

The fun part about this project is that I actually never built a shell. So I assume I will run into a good amount of gotchas and issues caused by naive implementations many people will roll their eyes at.

Progress

I will likely work on the prompt next and see how much information I want to inject. I am thinking about at least information about git, such as the branch or maybe some language specifics. Either way, I never was a fan of fancy prompts. Knowing which user I am logged in as, the host, working directory and maybe git status was always sufficient for me.

Unrelated to shells - my 3D avatar finally has hair! I might need to do some adjustments for flow and volume, but it exists. Now to eyelashes and I can put an end to my suffering and get to UV and baking.

posted on May 31, 2026, 8:07 p.m. in golang, lazerbunny

I am perpetually a little bit annoyed by the state of software - projects constantly changing, being abandoned or adding features that make no sense for my use case - so I started writing small tools for myself which I use on a daily basis. And it has not only been fun, but also useful. For the rest of the year I will focus on a project I have been thinking about for a few years: Building a useful, personal AI assistant.