DEV Community

Mohammad Reza Karimi
Mohammad Reza Karimi

Posted on

How to make a simple interactive shell in Go

What is more exciting than making real-world applications? 🤔
for me, it's teaching people how to make them again!

Our Study

As I've spoken about my Dockeroller project earlier in my articles, it's a project that help you control your docker deamon through different gates, such as Telegram or API.

I realized this gates should be easily configurable by user,
We should be able to turn some gates on or off, change their tokens, ports, and passwords, etc.
One approach, was using Viper and dockeroller.yml file (implementing and teaching this approach is in my todo list)

Another approach, can be an interactive shell!
Let's do it!

Completed code is available in my Github gists (Link).

Welcome to the stage!

Try to be clean by separating things!
In this case, I separated my code into different stages,
such as:

  • Welcome stage
  • Help stage
  • Gates stage
  • Telegram
  • API

Each stage has a message that we defined using constants at the beginning of the code, in bigger projects, they could be placed in for example a msg package.

We used backtick for multiline strings and we wrote constant type only once because all of them are in same type.

const (
    msg_welcome string = `
Welcome to Dockeroller!
It's sample multiline!
`

    msg_help = `
Dockeroller is an open-source project made for fun.
It's sample multiline!
`
Enter fullscreen mode Exit fullscreen mode

Do it forever!

The simplest form of interactive shells, use an infinite loop and it iterates until you break it,
But don't worry, it's not bad at all, I'll tell you why.

In any iteration, we get something new from the user, in that time, our app is not consuming any resource,
After passing desired input to the shell, it'll process the result and go to the next iteration.
Actually, our shell is constantly sleeping between iterations, it's waiting for us to pass it our input.

Have a look at this code:

func main() {
  var stage int = 0
  for {
    switch stage {
    case 0:
      stage = StageWelcome()
    case 1:
      stage = StageHelp()
    case 2:
      stage = StageGates()
    case 11:
      stage = StageTelegram()
    case 12:
      stage = StageAPI()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Functions started with the word StageXxx() are our handlers, they handle what happens in (on 😅) a stage, we'll get into them soon.

Also it may be clear that any stage is assigned to a number, but it could be assigned to anything else, but that may not be needed for a project this size.

As a recap
We have a for loop, we iterate over it and in every iteration, we check which stage we are on (using switch) and handle that stage.

Also, we update our stage after each iteration, but why?

Where would you like to go after?

It's time to update our welcome message!

const (
    msg_welcome string = `
Welcome to Dockeroller!
Where would you like to go after? (choose only number)
1 - help
2 - gates
`
Enter fullscreen mode Exit fullscreen mode

So we let user decide which stage is its next place!
and it's the reason we update stage every time.
Updating stage variable is a part of navigating between Stages.
And each handler, according to its inputs, will decide where we would go next.

Shell input & output

What is the point of this article without going into terminal itself?
This is just so easy, you may know how to use fmt.Print() function, it Prints something on the terminal, Without any default formatting.
There are some other variants of it such as fmt.Println() for printing with a line break and fmt.Printf() for printing with a specified format.

OK, there are a few more functions in fmt package, here we need fmt.Scanln() or fmt.Scan() (there is also a fmt.Scanf() for scanning according to a specified format)

Let's have a very simple example:

package main

import "fmt"

func main() {
  var name string
  fmt.Scanln(&name)
  fmt.Println("hello", name)
}
Enter fullscreen mode Exit fullscreen mode

If you don't pass pointer to Scanln, it won't have any effect.

We can add extra print statements to make it more beautiful.
We can also pass multiple pointers of different types to it, Scan will separate them by space and then populate them.

package main

import "fmt"

func main() {
  var name string
  var age int
  fmt.Print("> ") // Just for beauty
  fmt.Scanln(&name, &age)
  fmt.Println("Hello", name, "you're", age, "years old.")
}
Enter fullscreen mode Exit fullscreen mode

Output will be like:
go interactive shell result

Basic Handlers

I think this should make sense now:

// Welcome stage handler
func StageWelcome() int {
  fmt.Print(msg_welcome)
  return getStage()
}

// Help stage handler
func StageHelp() int {
  fmt.Print(msg_help)
  return getStage()
}

// Helper function for asking stage number and return it for next decisions
func getStage() (stage int) {
  fmt.Print("> ")
  fmt.Scanln(&stage)
  return
}
Enter fullscreen mode Exit fullscreen mode

If you think it's not clear, I suggest you to put this codes together now and then move on.

Gates handlers

Welcome is a first level stage
Help and Gates are Second level
Telegram & API are third level
and there can be more levels.

How can we realize it?
well, there are many algorithms for it, but here, if we are in gates stage and we should go on with option 1 (telegram) or 2 (api) I add 10 to stage number, and in main switch, telegram will be 11 and api will be 12.
if we want to come back from this level, we will return 0 that means Welcome (stage) handler.

func StageGates() int {
  fmt.Print(msg_gates)
  if stage := getStage(); stage != 0 {
    return stage + 10
  }
  return 0
}
Enter fullscreen mode Exit fullscreen mode

Telegram & API handlers

I just explain one of them, follow the same approach.

func StageTelegram() int {
  fmt.Print(msg_telegram)

  // Get a value (token) 
  var token string
  getInput("Token: ", &token)

  // Get another value (username) 
  var username string
  getInput("Username: @", &username)
  // Telegram usernames start with @,
  // Some users may include it, some may not,
  // So we helped users by including it.
  // And username variable won't have @ in it.

  fmt.Println("Username and token successfully sat!")
  return 0 // Return to the main menu after succefull config
}

// Helper function to print a message and get a value (by populating a pointer)
func getInput(msg string, value interface{}) {
  fmt.Print(msg)
  fmt.Scanln(value) // DO NOT include &, because it's a pointer when is passed to this function!
}
Enter fullscreen mode Exit fullscreen mode

Final quote

I hope you enjoyed this article, feel free to share your thoughts and critics with me.
For more beautiful UIs, you can use this open source package: promptui

Top comments (0)