---
title: "Service audit, cli cards"
description: "The cli context of the service review: 3 cards, each with a FAIL and a PASS example and the organisations that back the rule."
canonical: "https://goodcodereview.com/service-cli"
updated: "2026-11-06"
---

# service, cli

The stable service cards for the `cli` context. The whole level is at https://goodcodereview.com/service.md.

## svc-cli-destructive-dry-run

severity: should-fix  
concerns: reliability, maintainability  
applies_when: A command-line tool has a command that deletes, overwrites or sends something.  
context: cli  
tool gate: CLI test that runs the command with the preview flag and checks nothing changed.  
Backed by: Kubernetes (unjudged), Git (unjudged), GNU Project (unjudged), GitHub (unjudged), Microsoft (unjudged), Google (unjudged), HashiCorp (unjudged), Cursor (unjudged). Backing: unjudged. People cited: Command Line Interface Guidelines (unjudged).

**Rule.** Give a destructive command a preview flag that changes nothing and a flag that skips the confirmation prompt.

**Why.** Without a preview, the first run is the real one. Without a bypass, a script cannot run the command at all, so people write wrappers that pipe "yes" into it.

**FAIL**

```go
func main() {
	fmt.Printf("Delete %d backups? [y/N] ", len(old))
	var ans string
	fmt.Scanln(&ans) // no preview flag, no way to skip the prompt
	if ans == "y" {
		for _, b := range old {
			os.Remove(b)
		}
	}
}
```

**PASS**

```go
dry := flag.Bool("dry-run", false, "list what would be deleted, delete nothing")
yes := flag.Bool("yes", false, "do not ask for confirmation")
flag.Parse()
for _, b := range old {
	fmt.Println(b)
}
if *dry {
	return
}
if !*yes && !confirm(fmt.Sprintf("Delete %d backups?", len(old))) {
	os.Exit(1)
}
for _, b := range old {
	if err := os.Remove(b); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}
```

**Counterpoint.** "A bypass flag invites accidents." The prompt stays the default for people, and the flag has to be typed on purpose. Pair it with [svc-cli-non-interactive](service.md#svc-cli-non-interactive).

## svc-cli-exit-codes-and-streams

severity: should-fix  
concerns: correctness, maintainability  
applies_when: The code ships a command-line tool that scripts or other programs may run.  
context: cli  
tool gate: CLI test that runs a failing command and checks the exit code and that stdout is empty.  
Backed by: Google (unjudged), GitHub (unjudged), AWS (unjudged), HashiCorp (unjudged), The Open Group (unjudged), Heroku (unjudged), Rust project (unjudged). Backing: unjudged. People cited: Command Line Interface Guidelines (unjudged).

**Rule.** Exit non-zero when a command fails, and write results to stdout and errors and progress to stderr.

**Why.** Scripts branch on the exit code and pipe stdout into the next program. A failure that exits 0 reads as success, and an error line on stdout lands in the next program's input. Add a `--json` flag for structured results, and keep prompts out of non-terminal runs (see [svc-cli-non-interactive](service.md#svc-cli-non-interactive)).

**FAIL**

```go
if err := run(); err != nil {
	fmt.Println("error:", err) // error goes into the pipe
}
// main returns: exit status 0
```

**PASS**

```go
if err := run(); err != nil {
	fmt.Fprintln(os.Stderr, "error:", err)
	os.Exit(1)
}
```

**Counterpoint.** "Some tools use exit codes for non-error results." Documented codes for states such as "differences found" are fine. The rule is that a failure never exits 0.

## svc-cli-non-interactive

severity: should-fix  
concerns: maintainability, process  
applies_when: The code ships a command-line tool that scripts or agents may run.  
context: cli  
tool gate: CLI test that runs every command with stdin closed.  
Backed by: GitHub (unjudged), Google (unjudged), HashiCorp (unjudged), Microsoft (unjudged), Cursor (unjudged). Backing: unjudged. People cited: Command Line Interface Guidelines (unjudged).

**Rule.** Let the caller turn every prompt off with a flag or an environment variable, so the command never waits on a terminal.

**Why.** A script or an agent has no one to answer a prompt, so the command hangs until a timeout kills it. When stdin is not a terminal, the best behaviour is to fail at once with an error that names the missing flag and shows an example call, so the caller fixes it in one step.

**FAIL**

```go
env := flag.String("env", "", "target environment")
flag.Parse()
if *env == "" {
	fmt.Print("Choose environment: ")
	fmt.Scanln(env) // always prompts: no way to turn it off, hangs in CI
}
```

**PASS**

```go
env := flag.String("env", "", "target environment")
noInput := flag.Bool("no-input", false, "never prompt")
flag.Parse()
if *env == "" {
	if *noInput || !term.IsTerminal(int(os.Stdin.Fd())) { // golang.org/x/term
		fmt.Fprintln(os.Stderr, "error: --env is required. Example: deploy --env staging")
		os.Exit(2)
	}
	fmt.Fprint(os.Stderr, "Choose environment: ")
	fmt.Scanln(env)
}
```

**Counterpoint.** "Prompts are friendlier for people." Keep them. The rule only asks that the caller can switch prompts off.
