CLI Programming with Golang

Okan Özşahin
8 min readFeb 29, 2024

--

Image from BHM Pics

Command-line interface (CLI) programming is a fundamental skill for software developers, enabling the creation of powerful, efficient, and user-friendly tools and applications. CLI programs provide a text-based interface for users to interact with software, issuing commands and providing input directly from the terminal.

In the world of Golang CLI development, the github.com/peterbourgon/ff package stands out as a versatile and powerful tool for building command-line applications. Developed by Peter Bourgon, ffcli provides a flexible and intuitive framework for defining and organizing CLI commands and subcommands, parsing command-line arguments, and handling user input.

Now, let’s develop our CLI application by defining commands, flags, and subcommands using the ffcli.Command API. Here’s a simple example of a basic CLI application using ffcli:

package main

import (
"context"
"flag"
"fmt"
"os"
"strconv"

"github.com/peterbourgon/ff/v3/ffcli"
)

func main() {
root := &ffcli.Command{
ShortUsage: "mytool [flags] <operation> <operands...>",
ShortHelp: "A simple CLI tool for basic arithmetic operations",
Exec: run,
}

add := &ffcli.Command{
Name: "add",
ShortHelp: "Perform addition",
Exec: runOperation(addOperands),
}

sub := &ffcli.Command{
Name: "sub",
ShortHelp: "Perform subtraction",
Exec: runOperation(subOperands),
}

mul := &ffcli.Command{
Name: "mul",
ShortHelp: "Perform multiplication",
Exec: runOperation(mulOperands),
}

div := &ffcli.Command{
Name: "div",
ShortHelp: "Perform division",
Exec: runOperation(divOperands),
}

root.Subcommands = []*ffcli.Command{add, sub, mul, div}

if err := root.ParseAndRun(context.Background(), os.Args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}

func run(ctx context.Context, args []string) error {
return flag.ErrHelp
}

func runOperation(operationFunc func([]string) (float64, error)) func(context.Context, []string) error {
return func(ctx context.Context, args []string) error {
if len(args) < 2 {
return fmt.Errorf("insufficient operands")
}
result, err := operationFunc(args[0:])
if err != nil {
return err
}
fmt.Printf("Result: %.2f\n", result)
return nil
}
}

func addOperands(operands []string) (float64, error) {
result := 0.0
for _, op := range operands {
num, err := strconv.ParseFloat(op, 64)
if err != nil {
return 0, fmt.Errorf("invalid operand: %s", op)
}
result += num
}
return result, nil
}

func subOperands(operands []string) (float64, error) {
result, _ := strconv.ParseFloat(operands[0], 64)
for _, op := range operands[1:] {
num, err := strconv.ParseFloat(op, 64)
if err != nil {
return 0, fmt.Errorf("invalid operand: %s", op)
}
result -= num
}
return result, nil
}

func mulOperands(operands []string) (float64, error) {
result := 1.0
for _, op := range operands {
num, err := strconv.ParseFloat(op, 64)
if err != nil {
return 0, fmt.Errorf("invalid operand: %s", op)
}
result *= num
}
return result, nil
}

func divOperands(operands []string) (float64, error) {
result, _ := strconv.ParseFloat(operands[0], 64)
for _, op := range operands[1:] {
num, err := strconv.ParseFloat(op, 64)
if err != nil {
return 0, fmt.Errorf("invalid operand: %s", op)
}
if num == 0 {
return 0, fmt.Errorf("division by zero")
}
result /= num
}
return result, nil
}
  • We define the root command using ffcli.Command.
  • Each arithmetic operation (add, sub, mul, div) is defined as a subcommand of the root command.
  • We use the Exec field of ffcli.Command to specify the function to execute for each subcommand.
  • The runOperation function returns a function that executes the specified arithmetic operation.
  • We parse the command-line arguments and execute the root command using root.ParseAndRun().
  • The execution logic of each operation is encapsulated in separate functions (addOperands, subOperands, mulOperands, divOperands).

Use the go run command to execute the main.go file. However, note that the go run command compiles and runs only a single file, so changes made in other files won't take effect.

go run main.go add 3 1

This command will compile and execute the main.go file, and then run the add subcommand. However, when using this method, any changes you make will require recompiling the files. Therefore, if you plan to make frequent changes to your code, it's more convenient to compile once using the go build command and then use the generated executable file. Or do the next one.

  1. Build the CLI Application: Navigate to the directory containing your main.go file (mycliapp directory) in your terminal, and run the following command to build the application:
go build -o mycliapp

This command will compile your Go source code and generate an executable binary file named mycliapp in the same directory.

2. Run the CLI Application: Once the build process is complete, you can run your CLI application by executing the generated binary file:

./mycliapp mul 3 1

This command will execute the mul subcommand of your CLI application, and you should see the "Hello, world!" message printed to the console.

To understand how ffcli handles command-line arguments and flags, as well as parsing and validating user input, let's break it down step by step:

  • Command-Line Arguments and Flags Handling: ffcli allows you to define commands and flags using structures and annotations. You can use the Flag type to define flags for your command-line interface. Here's a basic example:
package main

import (
"flag"
"fmt"
"os"

"github.com/peterbourgon/ff"
)

func main() {
var (
name string
age int
married bool
)

fs := flag.NewFlagSet("example", flag.ExitOnError)
fs.StringVar(&name, "name", "", "your name")
fs.IntVar(&age, "age", 0, "your age")
fs.BoolVar(&married, "married", false, "are you married")

err := ff.Parse(fs, os.Args[1:])
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v/n", err)
os.Exit(1)
}

fmt.Printf("Name: %s, Age: %d, Married: %t\n", name, age, married)
}

In this example, ff.Parse parses the command-line arguments using the provided FlagSet. If there's an error, it's handled appropriately.

Here are some examples of how you can run the commands defined in the ffcli example provided earlier:

go build -o myapp
❯ ./myapp --name=okan --married=true --age=30
Name: okan, Age: 30, Married: true
❯ ./myapp --name=okan
Name: okan, Age: 0, Married: false
❯ ./myapp --age=30
Name: , Age: 30, Married: false
  • Parsing and Validating User Input: ffcli doesn't specifically handle validation, but you can perform validation after parsing the command-line arguments. For example:
if age <= 0 {
fmt.Println("Age must be a positive number")
os.Exit(1)
}

You can add validation checks like this to ensure that the input meets your requirements.

  • Configuring Commands and Subcommands: ffcli allows you to define commands and subcommands with default values and usage messages. Here's a basic example:
package main

import (
"context"
"flag"
"fmt"
"os"

"github.com/peterbourgon/ff/v3/ffcli"
)

func main() {
rootCmd := &ffcli.Command{
Name: "myapp",
ShortUsage: "myapp [flags] <command>",
ShortHelp: "Example comman-line app",
FlagSet: flag.NewFlagSet("myapp", flag.ExitOnError),
Exec: func(context context.Context, args []string) error { return flag.ErrHelp },
}

greetCmd := &ffcli.Command{
Name: "greet",
ShortUsage: "myapp greet [flags] <name>",
ShortHelp: "Greet someone",
FlagSet: flag.NewFlagSet("greet", flag.ExitOnError),
Exec: func(ctx context.Context, args []string) error {
if len(args) < 1 {
return fmt.Errorf("missing name")
}

name := args[0]
fmt.Printf("Hello, %s\n", name)
return nil
},
}

var s string
greetCmd.FlagSet.StringVar(&s, "saying", "", "write your say")

rootCmd.Subcommands = []*ffcli.Command{greetCmd}

err := rootCmd.ParseAndRun(context.Background(), os.Args[1:])
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}

fmt.Println(s)
}
  1. In this example, rootCmd is the root command with a subcommand greetCmd. Each command has its own FlagSet for handling flags and an Exec function for executing the command logic. The Exec function receives the command-line arguments after the flags.

Here are some examples of how you can run the commands defined in the ffcli example provided earlier:

go build -o myapp
❯ ./myapp greet --saying='how are you' okan
Hello, okan
how are you

When you’re building big command-line programs with lots of different tasks to handle, it’s important to organize your code well. One way to do this is by using a modular design strategy. This means breaking your program into smaller, more manageable parts, with each part handling a specific task or group of related tasks. For example, you might have one module for handling file operations, another for handling network requests, and so on. Within this modular structure, you’ll often use the idea of commands and subcommands. Think of commands as the main actions your program can perform, like “open file” or “send message.” Subcommands are like smaller, more specific versions of commands. For instance, under “send message,” you might have subcommands like “send email” or “send text.” This modular approach makes your code easier to understand and maintain, especially as your program grows.

It’s really important to keep things consistent when you’re designing the interface that users interact with. This means making sure that every command and subcommand looks and behaves the same way, so users don’t have to think too hard about how to use them. One way to do this is by making sure that the options and arguments you use with each command are consistent. This helps users understand what to expect and how to use each command effectively. It’s also a good idea to provide clear and helpful messages that explain what each command does and how to use it. Another thing to consider is how you organize the flags (options) that users can use with each command. It’s helpful to group related flags together and give them clear and descriptive names and descriptions. This makes it easier for users to understand what each flag does and how it affects the command’s behavior.

Real-world examples showcase the versatility and effectiveness of ffcli in various CLI applications:

  1. etcd: A distributed key-value store used for configuration management and service discovery, etcd employs ffcli for its command-line interface. This demonstrates ffcli's robustness in managing complex distributed systems' configuration and operation.
  2. CockroachDB: A distributed SQL database, CockroachDB leverages ffcli to provide a user-friendly command-line interface for database administration tasks. This illustrates how ffcli facilitates interactions with sophisticated database systems, allowing users to perform tasks efficiently.
  3. Terraform Plugin Framework: Terraform, a popular infrastructure as code tool, utilizes ffcli in its plugin framework for creating custom providers and modules. This highlights ffcli's suitability for building extensible CLI applications, enabling seamless integration with existing systems and workflows.
  4. Kubernetes Kubectl Plugin Development: Kubectl, the command-line interface for Kubernetes, utilizes ffcli for developing plugins that extend its functionality. This demonstrates ffcli's effectiveness in creating modular and interoperable CLI components within the Kubernetes ecosystem.

ffcli excels in several use cases compared to other CLI frameworks:

  1. Complex CLI Hierarchies: ffcli offers a straightforward approach for defining hierarchical command structures with nested subcommands, making it ideal for managing complex CLI hierarchies with ease.
  2. Customizable Flag Parsing: ffcli provides extensive support for defining and parsing command-line flags, allowing developers to customize flag behavior and validation rules according to their specific requirements.
  3. Plugin and Extension Development: ffcli's modular design and flexible architecture make it well-suited for building plugins and extensions for existing CLI tools and frameworks, enabling seamless integration and extensibility.
  4. Cross-Platform Compatibility: ffcli is designed to be platform-agnostic, ensuring consistent behavior across different operating systems and environments, making it suitable for developing CLI applications targeting diverse platforms.

The needs of diverse environments and workflows. Its flexibility, extensibility, and ease of use make it a valuable tool for creating sophisticated command-line interfaces that streamline operations and enhance productivity. Whether managing distributed systems, administering databases, or orchestrating cloud infrastructure, ffcli proves to be a reliable choice for building robust and scalable CLI applications in various domains.

--

--

Okan Özşahin
Okan Özşahin

Written by Okan Özşahin

Backend Developer at hop | Civil Engineer | MS Computer Engineering

No responses yet