Esto junta en un solo programa casi todo lo de los ejemplos anteriores: banderas, subórdenes, variables de entorno, archivos, errores y pruebas. Son 370 líneas en cuatro archivos, y al final sale un ejecutable único que se copia a cualquier máquina sin instalar nada.
También te puede interesar
Qué hace
Una lista de pendientes con cuatro subórdenes, guardada en un archivo JSON:
tareas add comprar pan integral tareas list tareas done 2 tareas rm 3
La estructura
el proyecto entero./go.mod module ejemplo/tareas ./main.go 184 líneas banderas, subórdenes, presentación ./main_test.go 37 líneas ./almacen/almacen.go 98 líneas los datos y su persistencia ./almacen/almacen_test.go 51 líneas
El corte está donde siempre debería estar: almacen no sabe nada de banderas ni de la terminal —solo de tareas y de un archivo—, y main no sabe cómo se guardan. Por eso el paquete almacen se puede probar sin ejecutar el programa.
Subórdenes con la biblioteca estándar
El paquete flag no trae subórdenes, pero flag.NewFlagSet deja montarlas en diez líneas: un conjunto de banderas globales, se mira el primer argumento que queda, y cada suborden analiza el resto con su propio conjunto.
fs := flag.NewFlagSet("tareas", flag.ContinueOnError)
fs.SetOutput(salida)
fs.Usage = func() { fmt.Fprint(salida, uso) }
ruta := fs.String("f", porOmision, "archivo de datos")
verVersion := fs.Bool("v", false, "muestra la versión")
if err := fs.Parse(args); err != nil {
return err
}
...
orden, resto := fs.Arg(0), fs.Args()[1:]
switch orden {
case "add":
return ordenAdd(salida, lista, *ruta, resto)
case "list":
return ordenList(salida, lista, resto)
case "done":
return ordenDone(salida, lista, *ruta, resto)
case "rm":
return ordenRm(salida, lista, *ruta, resto)
default:
return fmt.Errorf("orden desconocida %q (prueba con -h)", orden)
}
Y la suborden list declara las suyas propias:
func ordenList(w io.Writer, l *almacen.Lista, args []string) error {
fs := flag.NewFlagSet("list", flag.ContinueOnError)
fs.SetOutput(w)
todas := fs.Bool("a", false, "incluye también las hechas")
comoJSON := fs.Bool("json", false, "salida en JSON")
if err := fs.Parse(args); err != nil {
return err
}
...
}
flag.ContinueOnError en vez del flag.ExitOnError de flag.Parse(). Con ExitOnError el programa llama a os.Exit dentro de la biblioteca: no hay forma de probarlo ni de limpiar nada. Con ContinueOnError, un argumento mal escrito devuelve un error normal y tú decides qué hacer.Todo pasa por una función que devuelve error
El main no hace nada salvo traducir errores a códigos de salida:
func main() {
err := ejecutar(os.Args[1:], os.Stdout)
switch {
case err == nil:
return
case errors.Is(err, flag.ErrHelp):
os.Exit(0) // pedir la ayuda no es un fallo
case errors.Is(err, almacen.ErrNoExiste):
fmt.Fprintln(os.Stderr, "tareas:", err)
os.Exit(2) // ese número de tarea no existe
default:
fmt.Fprintln(os.Stderr, "tareas:", err)
os.Exit(1)
}
}
func ejecutar(args []string, salida io.Writer) error { ... }
El caso de flag.ErrHelp no es un adorno. La primera versión de este programa devolvía ese error como cualquier otro, y tareas -h imprimía la ayuda entera y luego, en la salida de errores, tareas: flag: help requested, saliendo con código 1.
Es decir: pedir ayuda contaba como fallo, y cualquier guion que comprobara el código de salida se habría confundido. Se detecta en cuanto miras el $?, y no se detecta nunca si solo miras la pantalla.
Las tres piezas de este main, que valen para cualquier herramienta:
| Pieza | Por qué |
|---|---|
Una función ejecutar que devuelve error |
Los defer se ejecutan; os.Exit en medio se los salta |
El os.Exit solo en main |
Un único sitio donde se decide el código de salida |
Los errores a os.Stderr |
Para que tareas list > archivo no los meta dentro |
Un io.Writer en vez de os.Stdout |
Es lo que hace la herramienta probable |
códigos de salida reales-h -> 0 done 99 -> 2 done dos -> 1 list -> 0
De dónde sale la configuración
El orden habitual, de menos a más prioridad: valor por omisión, variable de entorno, bandera.
porOmision := filepath.Join(os.Getenv("HOME"), ".tareas.json")
if v := os.Getenv("TAREAS_ARCHIVO"); v != "" {
porOmision = v
}
ruta := fs.String("f", porOmision, "archivo de datos")
Sale casi gratis porque flag.String ya recibe el valor por omisión: basta con calcularlo antes. Quien no defina nada usa ~/.tareas.json, quien exporte TAREAS_ARCHIVO usa eso, y -f otra.json gana siempre.
Guardar sin romper el archivo
func Guardar(ruta string, l *Lista) error {
datos, err := json.MarshalIndent(l, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(ruta), 0755); err != nil {
return err
}
// escritura atómica: primero a un temporal, después un rename
tmp, err := os.CreateTemp(filepath.Dir(ruta), ".tareas-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(datos); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), ruta)
}
Rename son lo importante de esta función. Si escribieras directamente sobre tareas.json y el programa muriera a mitad —un Ctrl+C, un disco lleno—, el usuario se quedaría con un JSON cortado por la mitad y perdería la lista entera. El rename dentro del mismo sistema de archivos es atómico: o está el archivo viejo o está el nuevo, nunca medio. El defer os.Remove limpia el temporal si algo falla por el camino; si el Rename funciona, ya no hay nada que borrar y el Remove falla en silencio.Y al leer, que el archivo no exista no es un error: es la primera ejecución.
func Cargar(ruta string) (*Lista, error) {
datos, err := os.ReadFile(ruta)
if errors.Is(err, fs.ErrNotExist) {
return &Lista{}, nil // la primera vez no hay archivo
}
if err != nil {
return nil, err
}
var l Lista
if err := json.Unmarshal(datos, &l); err != nil {
return nil, fmt.Errorf("%s está corrupto: %w", ruta, err)
}
return &l, nil
}
Una salida que se lee y otra que se procesa
Para la persona, columnas alineadas con text/tabwriter, que es de la biblioteca estándar y casi nadie conoce:
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tESTADO\tCREADA\tTAREA")
for _, t := range visibles {
fmt.Fprintf(tw, "%d\t%s\t%s\t%s\n", t.ID, estado, t.Creada.Format("2006-01-02"), t.Texto)
}
return tw.Flush()
salidaID ESTADO CREADA TAREA 1 pendiente 2026-10-06 comprar pan integral 2 hecha 2026-10-06 llamar al fontanero 3 pendiente 2026-10-06 revisar la factura de la luz
Tú separas los campos con tabuladores y el tabwriter calcula los anchos. El Flush() no es opcional: va acumulando para poder medir, y sin él no sale nada.
Y para otro programa, -json:
salida[
{
"id": 1,
"texto": "comprar pan integral",
"creada": "2026-10-06T05:08:44.485257274Z"
},
{
"id": 3,
"texto": "revisar la factura de la luz",
"creada": "2026-10-06T05:08:44.49003973Z"
}
]
Que una herramienta tenga una bandera así es lo que permite encadenarla con jq o llamarla desde otro guion sin tener que analizar texto con expresiones regulares.
Probarla entera
Aquí se cobra la decisión de que ejecutar reciba un io.Writer y devuelva un error: la prueba llama a la herramienta como lo haría la terminal, pero recogiendo la salida en un búfer.
func TestOrdenes(t *testing.T) {
ruta := filepath.Join(t.TempDir(), "tareas.json")
correr := func(args ...string) (string, error) {
var buf bytes.Buffer
err := ejecutar(append([]string{"-f", ruta}, args...), &buf)
return buf.String(), err
}
if s, err := correr("add", "comprar", "pan"); err != nil || !strings.Contains(s, "tarea 1") {
t.Fatalf("add: %q, %v", s, err)
}
if s, err := correr("list"); err != nil || !strings.Contains(s, "comprar pan") {
t.Fatalf("list: %q, %v", s, err)
}
if _, err := correr("done", "1"); err != nil {
t.Fatalf("done: %v", err)
}
s, err := correr("list")
...
if _, err := correr("volar"); err == nil {
t.Error("una orden inexistente debería dar error")
}
}
salida=== RUN TestOrdenes --- PASS: TestOrdenes (0.00s) ok ejemplo/tareas 0.005s === RUN TestCicloCompleto --- PASS: TestCicloCompleto (0.00s) ok ejemplo/tareas/almacen 0.005s
t.TempDir() crea una carpeta nueva para cada prueba y la borra al terminar, así que las pruebas no se pisan entre ellas ni dejan basura. Nada de ficheros en /tmp con nombres fijos.
Compilar y repartir
var version = "dev" // se cambia al compilar
go build -ldflags "-X main.version=1.0.0" -o tareas .
tareas -vtareas 1.0.0
-X paquete.variable=valor escribe el valor en una variable de cadena durante el enlazado. Es la forma habitual de meter el número de versión y el hash del commit sin tocar el código.
Un binario para cada sistema
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o tareas . CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o tareas.exe . CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o tareas-mac .
salidatareas 3.0M tareas.exe 3.2M tareas-mac 3.0M
Tres sistemas desde la misma máquina, sin contenedores ni máquinas virtuales. Esto es, probablemente, la mejor razón para escribir herramientas de línea de órdenes en Go: el resultado es un archivo que se copia y funciona, sin tiempo de ejecución que instalar.
Si el tamaño importa, -ldflags "-s -w" quita la tabla de símbolos y la información de depuración:
con -s -wtareas-min 2.1M
Instalarla
go install . # a $GOBIN o ~/go/bin go install ejemplo/tareas@latest # desde su repositorio
Antes de darla por terminada
| Comprobación | Orden |
|---|---|
| Formato | gofmt -l . (no debe imprimir nada) |
| Errores que el compilador no ve | go vet ./... |
| Pruebas, también las de carreras | go test -race ./... |
| Los códigos de salida | orden; echo $? en cada caso |
| La ayuda | -h debe salir con 0 y por la salida normal |
| Que se pueda encadenar | Errores a stderr, datos a stdout |
Para algo más grande que esto —decenas de subórdenes, autocompletado, ayuda generada— la biblioteca habitual es cobra. Pero merece la pena escribir una herramienta así al menos una vez con la biblioteca estándar: se ve exactamente qué hace cada pieza, y para la mayoría de las herramientas pequeñas es más que suficiente.
Todo el código se compiló, se pasó por go vet y gofmt, se probó con go test ./... y se ejecutó con Go 1.25 antes de publicarla; las salidas y los códigos de salida están copiados de esa ejecución. Documentación oficial: paquete flag.