Week 4 · The Finale · Night 26 of 31
Night 26 · Speaking to Machines
📺 “Not every viewer is a person. Some are scripts, some are other programs, and one, I’m told, is a spreadsheet. Tonight every command learns to speak JSON, the whole program learns to take a
--packflag, and a single environment variable makes every random thing in it repeatable. Boring? Perhaps. But the machines have been very patient.”
Tonight you’ll learn
--jsonon every command, and one helper to print itjson.Encoderversusjson.Marshal, andSetEscapeHTML- Shaping output types for machines, not screens
- Persistent flags and
PersistentPreRunE TERMINAL_OF_TERROR_SEED: reproducible randomness- What “the Unix way” means for a CLI
Where we are: every command prints for people. Tonight, for programs too.
One printer
Every command with data to offer has a --json flag, and they all end up in one place, cmd/output.go:
// printJSON writes v to stdout as indented JSON.
func printJSON(v any) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
return enc.Encode(v)
}
any is Go’s “anything” type (an alias for interface{}). json.NewEncoder writes straight to a stream, where json.Marshal returns bytes for you to print; for output the encoder is the natural fit and adds the trailing newline. SetEscapeHTML(false) matters: by default Go’s JSON escapes <, > and & as < and friends, for safety when JSON is embedded in HTML. In a terminal that turns “Abbott & Costello” into “Abbott & Costello”, which is correct JSON and ugly. Nothing here goes into HTML, so it’s off.
The command side is a two-line early return, in list, monster, random, quiz, crypt, packs:
if listJSON {
return printJSON(all)
}
Shapes for machines
Some commands print their internal type as-is: list --json gives the full Monster records, with every JSON tag you’ve been adding since Night 5. That’s why the tags were worth getting right: hostIntro, releaseDate, omitempty on the optional fields, all of it is now the program’s public data format.
Others define a small type just for output:
// factJSON is the machine-readable shape of a single fact.
type factJSON struct {
ID string `json:"id"`
Monster string `json:"monster"`
Fact string `json:"fact"`
Notes []string `json:"notes,omitempty"`
}
random --json prints {"id": "dracula", "monster": "Dracula", "fact": "...", "notes": [...]}, not the entire monster. A machine that asked for a fact wants a fact. Deciding the shape of output is the design work; the encoding is free. Similarly packs --json lists monster ids per pack, and quiz --json prints the questions with answers, which is how Night 18 let you inspect the generator, and how CI smoke-tests the quiz without a terminal.
A flag on every command
--pack limits any command to some packs: quiz --pack folklore, mash --pack universal. Adding it to eleven commands would be eleven copies, so it’s a persistent flag on the root, inherited by every subcommand, in cmd/root.go:
var rootCmd = &cobra.Command{
...
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
loadCommunityPacks()
if len(packFilter) > 0 {
return monsters.UsePacks(packFilter)
}
return nil
},
}
func init() {
...
rootCmd.PersistentFlags().StringSliceVar(&packFilter, "pack", nil, "Only use monsters from these packs, e.g. --pack folklore")
}
PersistentPreRunE runs before any command’s RunE, which makes it the place for setup every command needs: loading the community packs (tomorrow) and applying the filter. StringSliceVar accepts --pack a,b or --pack a --pack b. UsePacks narrows what GetAllMonsters returns and errors on an unknown id, so quiz --pack hammer says so instead of running an empty quiz.
There’s a subtlety in what the filter doesn’t apply to: the crypt’s badges use monsters.EveryMonster(), which ignores the filter, so “capture every monster” means every monster, not every monster in tonight’s selection. A global filter needs a way to see past it; that’s the second function.
Repeatable randomness
// envSeed fixes every random choice (questions, portraits, fights, host
// lines) so a run can be repeated exactly. docs/demos/render.sh sets it so
// re-recording the README demos gives the same games every time.
const envSeed = "TERMINAL_OF_TERROR_SEED"
// seed returns the seed from TERMINAL_OF_TERROR_SEED, or the clock.
func seed() int64 {
if s := os.Getenv(envSeed); s != "" {
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
return n
}
fmt.Fprintf(os.Stderr, "⚠️ ignoring %s=%q: it should be a whole number\n", envSeed, s)
}
return time.Now().UnixNano()
}
// newRand returns a random source seeded by seed().
func newRand() *rand.Rand {
return rand.New(rand.NewSource(seed()))
}
This is where Night 4’s decision, “every random function takes a *rand.Rand”, becomes a feature. Because the quiz, the fog, the fight and the host all draw from a newRand(), one environment variable makes the entire program deterministic. TERMINAL_OF_TERROR_SEED=13 terminal-of-terror quiz asks the same questions every time. Night 30’s demo recordings depend on it; a bug report with a seed in it can be reproduced exactly.
A bad value is warned about and ignored, not fatal: someone with a typo in their environment should still get a working program. The warning goes to stderr, so --json output stays clean.
Why an environment variable and not a --seed flag? Because it’s not something a person chooses per command; it’s something a script sets once for a whole recording or test run. Flags for choices, environment for context.
The Unix way
A well-mannered command-line tool follows a few habits, all of which the program now has:
- stdout is for output, stderr is for everything else. Warnings, progress errors, the bad-seed notice: all stderr.
terminal-of-terror list --json | jqworks. - Exit status means something.
RunEerrors exit 1 (Night 8). A script can check. - Colour only in a terminal. Lip Gloss handles it (Night 9); pauses only in a terminal (Night 23).
- Read the environment for context, flags for choices, arguments for the thing itself.
--jsonwhen there’s data, so the tool can be a building block.
None of it is hard. All of it is easy to forget, and the difference shows the first time someone tries to use your tool from a script.
Run it
go run . random --json
go run . list --json | head -30
go run . quiz --json --pack folklore -n 2
TERMINAL_OF_TERROR_SEED=13 go run . random
TERMINAL_OF_TERROR_SEED=13 go run . random
The last two print the same card. The CI smoke test on Night 29 runs most of these.
Try it
- Pipe
list --jsonthroughjq '.[] | select(.film.year < 1930) | .name'(or a Python one-liner) and get the silent-era names. - Add
--jsontocountdown. Decide the shape first: nights, moon phase, next anniversary. - Set
TERMINAL_OF_TERROR_SEED=abcand run anything. Read the warning. Then make it fatal and decide which you prefer.
💀 Terrifying fact
encoding/json marshals a time.Time as RFC 3339 with nanoseconds and the zone, and unmarshals it back exactly, which is how the crypt’s captured dates survive the round trip. But a map[string]time.Time sorts its keys on output, so the JSON is stable across runs, while a map[time.Time]int can’t be marshalled at all: JSON object keys must be strings. Choose map key types with the file format in mind.
🕯️ Before dawn
Write a shell script (or a Python one) that runs quiz --json -n 5 and prints only the prompts, one per line. Then run it with a seed, twice, and diff the output. That’s your first automated check of the program’s data format.
📺 “JSON for the machines, a seed for the scripts, and a pack flag for the picky. Tomorrow night we open the doors and let strangers in. Strangers with their own monsters.”