Skip to main content

CLI internals

How the Go side is put together at the level you need to add features.

Dispatch​

cmd/glix/main.go holds a []command slice. Each entry has a name, summary, and a func(args []string) error. main() matches the first argv and dispatches.

To add a new command:

  1. Create cmd/glix/cmd_<verb>.go with func cmdVerb(args []string) error.
  2. Add an entry to the commands slice in main.go.
  3. That's it.

Flag parsing​

Every command uses newFlagSet(name) from main.go, which returns a *flag.FlagSet configured to print usage to stderr. Stick to the stdlib flag package; we don't use cobra.

Positional arguments come after flags in args. The flag package stops at the first non-flag argument, so callers can mix flags and positionals as long as flags come first. Document this in the command's usage string.

The mutation pattern​

Every command that changes state follows the same shape:

// 1. Parse flags and positionals.
// 2. Resolve the repo, validate input.
// 3. Load the manifest.
// 4. Compute the new manifest.
// 5. Take a snapshot.
// 6. Save manifest → regenerate flake → flake lock.
// On any failure, snapshot.Restore() and return the error.
// 7. r.Commit(message).
// 8. Optionally rebuild if --apply or settings.AutoApply.

Read cmd_set.go for the canonical example.

Snapshots​

snap, err := r.TakeSnapshot(*host)
if err != nil { return err }

// ... write things ...

if somethingFailed {
_ = snap.Restore()
return err
}

The snapshot captures three files in memory. Restore() re-writes them atomically. The intent is: between TakeSnapshot and the final r.Commit, the working tree may be inconsistent; outside that window it must be a valid glixos repo.

Commit messages​

By convention: glix <verb> <host>: <summary>.

Examples:

  • glix init: bootstrap host laptop
  • glix add laptop: pkg-hello (path:..., system, via uri)
  • glix remove laptop: pkg-hello
  • glix set laptop: pkg-hello (config.message=Hello)
  • glix update laptop: all inputs

glix rollback relies on these only being subject lines — the full commit message is fine to extend with a body if needed.

Validation​

cmd/glix/util.go has requireValidIdent(kind, name) error which checks the name against ^[A-Za-z][A-Za-z0-9_-]*$. Use it for any field that becomes a TOML bare key or a Nix attribute name.

For scope validation: manifest.Scope(v).Valid().

For host existence: r.HostExists(host).

Reading vs writing​

Read-only commands (list, show, info, search, doctor, version) must not write anything, must not invoke git, and must not invoke nix except for nix flake metadata/nix registry list calls that don't modify state. They should not take snapshots.

Errors​

  • Return Go errors. main() prints them as glix <cmd>: <message> and exits 1.
  • Wrap with %w when you want the caller's error chain preserved (rarely needed for a CLI).
  • Don't log.Fatal. Always return.

Adding a new manifest field​

  1. Add a field to manifest.Package (or manifest.Settings) with an omitempty TOML tag.
  2. Extend Encode to emit it (sorted, deterministic).
  3. Add a round-trip test in manifest_test.go.
  4. If a command should mutate it, add a case "field": to cmdSet in cmd_set.go.
  5. Surface it in cmd_show.go and (if relevant) cmd_list.go.
  6. If it changes Nix evaluation, thread it through core/lib/importManifest.nix and the host template.

This is the M6/M7 playbook (see ADR-009, ADR-010 for examples).

Adding a new region​

If a Nix-side change needs glix to manage a new region of flake.nix:

  1. Add a RegionFoo constant in internal/flake/patcher.go.
  2. Add the begin/end markers to templates/flake.nix.tmpl.
  3. Write a RenderFoo(...) string builder.
  4. Have regenerateFlake in cmd_init.go pass it to flake.PatchFile.

Don't add regions casually; each one is forever, since old user repos will outlive the addition. Prefer adding fields to existing structures.