First CLI Tools with os.Args

Updated

September 8, 2026

First CLI Tools with os.Args

Overview

Before flag, understand raw arguments: os.Args[0] is the program name, os.Args[1:] is everything the user typed. Many tiny tools need only this.

Hello CLI

package main

import (
    "fmt"
    "os"
)

func main() {
    fmt.Println("args:", os.Args)
    if len(os.Args) < 2 {
        fmt.Fprintln(os.Stderr, "usage: greeter <name>")
        os.Exit(2)
    }
    fmt.Printf("hello, %s\n", os.Args[1])
}
go run . Ada
# hello, Ada
go run .
# usage: greeter <name>  (exit 2)

Separate main from logic

package main

import (
    "fmt"
    "io"
    "os"
)

func main() {
    if err := run(os.Args[1:], os.Stdout); err != nil {
        fmt.Fprintln(os.Stderr, "greeter:", err)
        os.Exit(1)
    }
}

func run(args []string, stdout io.Writer) error {
    if len(args) < 1 {
        return fmt.Errorf("usage: greeter <name>")
    }
    _, err := fmt.Fprintf(stdout, "hello, %s\n", args[0])
    return err
}

Now tests call run([]string{"Ada"}, &buf) without exiting the test process.

Positional args patterns

Required path

func run(args []string) error {
    if len(args) != 1 {
        return fmt.Errorf("usage: cat1 <file>")
    }
    b, err := os.ReadFile(args[0])
    if err != nil {
        return err
    }
    _, err = os.Stdout.Write(b)
    return err
}

Variadic files (like cat)

func run(args []string, stdout io.Writer) error {
    if len(args) == 0 {
        _, err := io.Copy(stdout, os.Stdin)
        return err
    }
    for _, path := range args {
        f, err := os.Open(path)
        if err != nil {
            return err
        }
        _, err = io.Copy(stdout, f)
        f.Close()
        if err != nil {
            return err
        }
    }
    return nil
}

Options before flags package (-n style DIY)

func parse(args []string) (count int, rest []string, err error) {
    count = 1
    i := 0
    for i < len(args) {
        a := args[i]
        if a == "--" {
            i++
            break
        }
        if len(a) > 1 && a[0] == '-' {
            // fragile DIY — prefer flag package next chapter
            return 0, nil, fmt.Errorf("unknown option %q", a)
        }
        break
    }
    return count, args[i:], nil
}

Lesson: hand-parsing flags gets messy quickly. Use flag (or Cobra) for real options.

Program name for usage

name := filepath.Base(os.Args[0])
fmt.Fprintf(os.Stderr, "usage: %s <file>\n", name)

Environment as “args”

if os.Getenv("DEBUG") != "" {
    fmt.Fprintln(os.Stderr, "debug on")
}

Env is global config; args are per-invocation. Chapter 305 covers layering.

Example: add calculator

package main

import (
    "fmt"
    "os"
    "strconv"
)

func main() {
    if err := run(os.Args[1:]); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

func run(args []string) error {
    if len(args) < 2 {
        return fmt.Errorf("usage: add <a> <b>")
    }
    a, err := strconv.ParseFloat(args[0], 64)
    if err != nil {
        return fmt.Errorf("a: %w", err)
    }
    b, err := strconv.ParseFloat(args[1], 64)
    if err != nil {
        return fmt.Errorf("b: %w", err)
    }
    fmt.Println(a + b)
    return nil
}

Example: whichdir — print working directory variants

func run(args []string) error {
    wd, err := os.Getwd()
    if err != nil {
        return err
    }
    fmt.Println(wd)
    if len(args) > 0 && args[0] == "-base" {
        fmt.Println(filepath.Base(wd))
    }
    return nil
}

Rules of thumb

Do Don’t
Put logic in run(...) Scatter os.Exit deep in packages
Usage on stderr, exit 2 for misuse Print usage to stdout
Validate len(args) early Index args[1] without checks

Try next

  1. Write echo1 that joins args with spaces (like echo).
  2. Write headn that prints the first line of a file (no flags yet).
  3. Add a unit test for run with a bytes.Buffer as stdout.