JavaRush /Courses /Go SELF /CLI design — commands, flags, positional arguments

CLI design — commands, flags, positional arguments

Go SELF
Level 48, Lesson 2
Available

1. Commands and arguments

When you build a console program for the first time, you usually want to get to “it works” as fast as possible: read arguments, do something, print the result. But a CLI is a small public API. Even if the only person besides you who uses it is your colleague, they will quickly start hating a program where parameters are sometimes positional, sometimes flags, sometimes in different order, and sometimes “for some reason it’s different today.” Good CLI design makes the command predictable, which means fewer mistakes, fewer questions, and less magic.

Let’s imagine we’re building a small educational CLI project todo — a task manager. Our goal today is to understand how to organize input parameters.

A command as a “verb”

If you look at a CLI as a phrase, you often get a very human structure: “do an action on an object.” The action is the command (command), and the object and details are either positional arguments or flags.

The most common and understandable style looks like this:

todo <command> [options] [args]

Where:

  • <command> — the “verb” (add, list, done, delete).
  • [options] — flags that configure the command (-priority, -limit, -format).
  • [args] — positional arguments without which the command makes no sense (for example, the task text).

Important: the command should almost always be the first positional argument after global flags (we’ll talk about global flags a bit later). Why? Because it’s easier for the user to “read” the command with their eyes: first the action, then the details.

A small table: how commands are usually named

Task Good command name Why
Add a task
add
short, like a verb
Show the list
list
expected by analogy with other CLIs
Mark as done
done
short and clear
Delete delete or rm depends on the audience (for beginners, delete is clearer)

Try to avoid “internal” names like doTaskMutation (yes, it’s a joke, but commands like that really exist, and yes — people cry).

Positional arguments

Positional arguments look very simple: they go “as is,” without a name, and the meaning is defined by order. This is convenient while you have 1–2 arguments. But as soon as there are many, the user turns into a sapper: get the order wrong — and the program does the wrong thing.

A good rule for beginners (and not only beginners): positional arguments are the required core of the command — what the command cannot make sense without.

For example:

  • todo add "Buy milk" — without task text, there’s nothing to add.
  • todo done 3 — without an identifier, it’s unclear what to mark.

But “output format,” “limit,” “filter” are better as flags, because they are optional and do not depend on order.

Example: getting the command and its arguments from flag.Args()

Let’s start with a mini skeleton: after flag.Parse(), everything that remains is in flag.Args().

package main

import (
	"flag"
	"fmt"
)

func main() {
	flag.Parse()

	args := flag.Args()
	fmt.Printf("args=%#v\n", args) // args=["add" "Buy milk"]
}

Here it’s important to get used to the idea: positional arguments are the “remainder” after parsing flags.

Example: “the command is the first argument”

package main

import (
	"flag"
	"fmt"
)

func main() {
	flag.Parse()

	if flag.NArg() == 0 {
		fmt.Println("missing command") // missing command
		return
	}

	cmd := flag.Arg(0)
	fmt.Println("command:", cmd) // command: add
}

flag.NArg() is your best friend to avoid shooting yourself in the foot by reading flag.Arg(0) from an empty list.

About quotes and spaces

When an argument contains spaces, the user needs to wrap it in quotes. And this is not Go being picky; that’s just how the command line works:

todo add Buy milk

Most likely it will turn into two arguments: "Buy" and "milk". So the correct way is:

todo add "Buy milk"

Your program should be designed so that these things are intuitive: if your task text is a single argument, the user should understand that quotes are needed.

3. Flags as options

Flags are named parameters. They are good because they do not depend on order and can have default values. If positional arguments are “who/what,” then flags are “which one? how many? in what mode?”

Typical examples for todo:

  • -priority 3 — task priority.
  • -limit 10 — how many entries to show.
  • -all — show everything (a boolean flag).

Why flags are almost always better for options

Because they are readable at a glance:

todo list -limit 10

it’s immediately clear what 10 is. But this:

todo list 10

already requires remembering the contract: “is 10 the limit or, for example, a filter priority?”

Mini example: a flag with a default value

package main

import (
	"flag"
	"fmt"
)

func main() {
	limit := flag.Int("limit", 10, "max items")
	flag.Parse()

	fmt.Println("limit:", *limit) // limit: 10
}

The pointer *limit is normal. flag.Int returns *int, because the flag package fills the value “inside” the variable.

Required flag

The flag package doesn’t know what you consider required. So you check it yourself.

package main

import (
	"flag"
	"fmt"
)

func main() {
	title := flag.String("title", "", "task title (required)")
	flag.Parse()

	if *title == "" {
		fmt.Println("missing -title") // missing -title
		return
	}
}

This is not a “Go shortcoming,” it’s a deliberate philosophy: flag handles syntax, while meaning and rules are your responsibility.

4. Commands and flags: agreeing on a format

Now to the most subtle part: you want commands (add, list…), you want flags (-priority…), and you want the user not to suffer.

From a UX perspective, you want to write this:

todo add -priority 3 "Buy milk"

But the standard flag package by default doesn’t like it when flags come after the first positional argument (it usually stops parsing flags as soon as it sees a “non-flag”). So at the “basic level,” without extra tools, the simplest contract looks like this:

todo [global flags] <command> [command args]

That is, flags go before the command:

todo -priority 3 add "Buy milk"

Yes, it’s a bit less familiar. But it’s honest and simple to implement with a single flag.Parse().

To avoid the feeling of “this is some nonsense,” people usually do this: keep global flags (applicable to any command), and add command-specific flags later in a more advanced design (where separate flag sets are used). Today we are fixing a basic but working model.

Argument parsing scheme

flowchart TD
    A["os.Args (raw input)"] --> B["flag.Parse() (ate flags)"]
    B --> C["flag.Args() (remainder)"]
    C --> D["cmd = Args[0]"]
    C --> E["cmdArgs = Args[1:]"]
    D --> F["switch cmd { ... }"]

This scheme is great because it’s easy to keep in mind while debugging: if the command “isn’t found,” the problem is almost always that the arguments went to the wrong place, or the flags were written after the command.

5. The todo contract: flags and positional arguments

Now we’ll do the most important exercise: we’ll formalize the agreement.

Let’s say we want 3 commands:

  • add — add a task
  • list — show tasks
  • done — mark a task as completed

And we want a couple of flags:

  • -priority (for add)
  • -limit (for list)

But as we discussed, within a single flag.Parse() it’s easier to make the flags global. Then the contract can look like this:

todo -priority <n> add <TITLE>
todo -limit <n> list
todo done <ID>

done has no flag — and that’s fine: you don’t need to force everything into flags.

Table: example decisions

Parameter Make it a flag? Make it positional? Why
command (add/list/done) no yes it’s the key behavior switch
TITLE no yes it’s the main meaning of add
ID no yes it’s the main meaning of done
priority yes no an option, has a default, must not depend on order
limit yes no an output option, has a default

This isn’t “the only correct way,” but it’s a good starting design that’s easy to explain and easy to maintain.

6. Command dispatcher via switch

Now we’ll assemble a small but coherent piece of code. It won’t store tasks in a file (that’s a separate big topic), but it will already behave like a real CLI in terms of parameter structure.

Step 1: take the command and arguments

package main

import (
	"flag"
	"fmt"
)

func main() {
	flag.Parse()

	if flag.NArg() == 0 {
		fmt.Println("missing command") // missing command
		return
	}

	cmd := flag.Arg(0)
	cmdArgs := flag.Args()[1:]

	fmt.Println("cmd:", cmd)         // cmd: add
	fmt.Println("cmdArgs:", cmdArgs) // cmdArgs: ["Buy milk"]
}

Yes, there is some duplication (Arg(0) and Args()[1:]), but right now clarity is more important.

Step 2: branch by command

package main

import (
	"flag"
	"fmt"
)

func main() {
	flag.Parse()

	if flag.NArg() == 0 {
		fmt.Println("missing command") // missing command
		return
	}

	switch flag.Arg(0) {
	case "add":
		fmt.Println("adding...") // adding...
	case "list":
		fmt.Println("listing...") // listing...
	case "done":
		fmt.Println("marking done...") // marking done...
	default:
		fmt.Println("unknown command:", flag.Arg(0)) // unknown command: x
	}
}

switch here is exactly what the doctor ordered: commands are a fixed set of strings.

7. Validation and command assembly

Validating positional arguments

Now let’s add meaning: add must have a TITLE, and done must have an ID. And here it’s very important not to try to “guess,” but to strictly validate the contract.

add: you need at least one argument.

package main

import (
	"flag"
	"fmt"
)

func main() {
	flag.Parse()

	if flag.NArg() < 2 {
		fmt.Println("Usage: todo add <TITLE>") // Usage: todo add <TITLE>
		return
	}

	cmd := flag.Arg(0)
	if cmd != "add" {
		fmt.Println("only add supported here") // only add supported here
		return
	}

	title := flag.Arg(1)
	fmt.Println("title:", title) // title: Buy milk
}

Yes, this is an “only add” example, but it shows the principle: before reading Arg(1) we check that there are enough arguments.

Validating flags and working with errors

When you design a CLI, input errors are the norm, not an “exception.” So it’s useful to start treating errors as values now: return error, check err != nil, and don’t try to “heal” everything by printing in random places. This style is one of the fundamentals in Go: an error is compared to nil, and if needed, you can enrich it with context via fmt.Errorf.

Below is a simple example: validate the -priority range.

package main

import (
	"flag"
	"fmt"
)

func main() {
	priority := flag.Int("priority", 1, "priority 1..5")
	flag.Parse()

	if *priority < 1 || *priority > 5 {
		fmt.Println("priority must be 1..5") // priority must be 1..5
		return
	}

	fmt.Println("priority:", *priority) // priority: 1
}

We haven’t discussed where exactly to print errors yet (that’s a separate discussion), but the fact of validation should become a habit: flag gives you the type (int), and you define the meaning (1..5).

Assembly: “flags + command + arguments”

Now we’ll make a complete example that already feels like a “real command.”

The functionality will be simple:

  • todo -priority 3 add "Buy milk" → prints that we added a task.
  • todo -limit 5 list → prints that we’re showing the list.
  • todo done 2 → prints that we marked a task as done.

Code: one file, one flag.Parse(), a clear contract.

package main

import (
	"flag"
	"fmt"
	"strconv"
)

func main() {
	priority := flag.Int("priority", 1, "priority for add (1..5)")
	limit := flag.Int("limit", 10, "limit for list")
	flag.Parse()

	if flag.NArg() == 0 {
		fmt.Println("missing command") // missing command
		return
	}

	cmd := flag.Arg(0)

	switch cmd {
	case "add":
		if flag.NArg() < 2 {
			fmt.Println("Usage: todo -priority N add <TITLE>") // Usage: todo -priority N add <TITLE>
			return
		}
		if *priority < 1 || *priority > 5 {
			fmt.Println("priority must be 1..5") // priority must be 1..5
			return
		}
		title := flag.Arg(1)
		fmt.Println("added:", title, "p=", *priority) // added: Buy milk p= 3

	case "list":
		if *limit <= 0 {
			fmt.Println("limit must be positive") // limit must be positive
			return
		}
		fmt.Println("listing, limit:", *limit) // listing, limit: 10

	case "done":
		if flag.NArg() != 2 {
			fmt.Println("Usage: todo done <ID>") // Usage: todo done <ID>
			return
		}
		id, err := strconv.Atoi(flag.Arg(1))
		if err != nil {
			fmt.Println("ID must be int") // ID must be int
			return
		}
		fmt.Println("done id:", id) // done id: 2

	default:
		fmt.Println("unknown command:", cmd) // unknown command: xxx
	}
}

This example matters not because it’s “feature-rich,” but because it shows the key idea: the command selects the behavior branch, positional arguments provide required data, and flags configure the mode.

The -- nuance

Sometimes an unpleasant situation happens: a positional argument starts with - and looks like a flag. For example, you want to add a task with the text -buy milk (weird, but let’s say it’s allowed). Then the command line may try to treat -buy as a flag.

For such cases there is the terminator --: it tells the flag parser, “that’s it, everything after this is positional only.”

In practice it looks like this:

todo add -- "-buy milk"

From a CLI design perspective this isn’t a “must-have feature,” but it’s useful to remember that the user has this escape hatch.

8. Typical mistakes in CLI design

Mistake #1: using too many positional arguments “because it’s shorter.”
At first it seems convenient to do todo add TITLE PRIORITY TAG. But a week later nobody remembers the order, and the circus begins: priority and tag get swapped, and the program silently accepts the input. In such places flags win, because the parameter name is built into the command itself.

Mistake #2: mixing required data and options into one pile.
If you do todo list all, and tomorrow you add todo list 10, it becomes unclear: is all a filter or a limit? Things like this quickly turn into a “language” you accidentally invented. It’s better to keep the rule: required things are positional, optional things are flags, and don’t change the meaning of an argument depending on its value.

Mistake #3: assuming flag will validate “meaning” for you.
flag.Int("priority", 1, "...") guarantees only that if the user enters -priority abc you won’t get an int, but it doesn’t know the 1..5 range and it doesn’t have to. If you forget semantic validation, you’ll get a “valid” priority=-1000, and the bug will look like “why is the app behaving strangely.”

Mistake #4: accepting an “empty string” as normal input where it is meaningless.
For add, an empty TITLE is almost always an error. The same is true for required flags: -title "" is technically a string, but logically it’s emptiness. If you don’t check this explicitly, you’ll get tasks without titles and the feeling that the program is “its own enemy.”

Mistake #5: not checking the number of arguments before flag.Arg(i).
This is a classic: in the done branch, someone reads flag.Arg(1) but forgets to check flag.NArg(). Best case you get incorrect logic; worst case you get a panic. The CLI contract should be protected with checks at the boundary: “do we have enough arguments,” “is the format correct,” “is the range correct.”

1
Task
Go SELF, level 48, lesson 2
Locked
Argument X-ray
Argument X-ray
1
Task
Go SELF, level 48, lesson 2
Locked
Command dispatcher
Command dispatcher
1
Task
Go SELF, level 48, lesson 2
Locked
Repeat command
Repeat command
1
Task
Go SELF, level 48, lesson 2
Locked
Console manager
Console manager
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION