How agentbox dispatches subcommands before flag.Parse, guards key rotation with syscall.Flock, and hides Claude Code and Codex behind one Go interface.
· 7 min read

Agentbox: the Go CLI patterns behind a self-hosted Claude Code and Codex workspace


Most Go projects reach for Cobra the moment a second subcommand shows up. Agentbox has five maintenance subcommands, a hidden re-exec mode, a companion binary, and no Cobra. No Viper either, and no DI container. Just flag, os.Args, syscall, and interfaces.

The project itself is a self-hosted browser workspace that runs Claude Code and Codex CLI inside Docker containers, with a terminal, file browser, Git integration, and token accounting bolted on. The feature list isn’t the interesting part. The CLI plumbing is.

Three things in that codebase are worth stealing: argv dispatch that runs before the global flag set, a flock-guarded maintenance mode, and a string-typed adapter that skips the struct entirely.

One binary, several roles, dispatched from os.Args

cmd/agentbox/main.go inspects os.Args directly before touching flags, which is the move the flag package can’t make on your behalf.

First it checks for a hidden subcommand. When the server needs a network helper process, it re-execs itself with network-helper as the only argument and passes configuration through environment variables (ABOX_NETWORK_CONTROL, ABOX_NETWORK_SESSION, ABOX_NETWORK_SECRET) instead of flags. That keeps the secret out of the command-line arguments shown by ordinary ps output, although environment variables can still be visible to sufficiently privileged processes. The helper then runs under a context from signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM).

The second check routes maintenance commands (backup, backup-verify, restore, check-config, git-key-rotate) into maintenance(os.Args[1:]) in cmd/agentbox/backup.go. Only when neither check matches does main define -config and call flag.Parse().

That ordering is doing real work. flag.Parse stops at the first non-flag argument, so agentbox -config foo.json backup takes the server path, parses -config, and leaves backup in flag.Args(); it does not invoke the backup command. By contrast, agentbox backup -config foo.json dispatches directly to the backup flag set. Agentbox therefore has a strict grammar: a maintenance command must be the first argument, and once selected it owns the remaining flags.

Each maintenance command builds its own flag set:

flags := flag.NewFlagSet(args[0], flag.ContinueOnError)
switch args[0] {
case "backup":
    cfg := flags.String("config", "config.json", "configuration path")
    output := flags.String("output", "", "new backup file (default: data_dir/backups)")
    full := flags.Bool("full", false, "include all user data; requires stopped service and containers")
    if err := flags.Parse(args[1:]); err != nil {
        return err
    }
    if flags.NArg() != 0 {
        return errors.New("unexpected backup arguments")
    }
    // ...
}

flag.ContinueOnError rather than the default flag.ExitOnError is the choice to copy. ExitOnError calls os.Exit(2) from inside the flag package: no deferred cleanup, no chance for the caller to decide anything. ContinueOnError hands the error back so maintenance can bubble it up with context. For the mechanics of the standard flag package, we covered it in Go flag Package: Parse Command-Line Arguments in Golang, and the Cobra alternative in Generating A CLI Application with Cobra in Golang.

Every successful maintenance command writes structured JSON to stdout via json.NewEncoder(os.Stdout).Encode(...). The backup command also includes a SHA-256 of the archive, computed by streaming through sha256.New() and io.Copy. Machine-readable output costs little and makes the commands easy to wire into a cron job without parsing prose.

Repeatable flags with a custom flag.Value

cmd/abox-link/main.go is the companion binary. It runs on your laptop, dials the server’s /api/tunnel WebSocket, and proxies connections back into your LAN against a default-deny whitelist. Whitelists need repeatable flags, and the standard library already handles that through flag.Var and the flag.Value interface:

type stringList []string

func (s *stringList) String() string { return strings.Join(*s, ",") }
func (s *stringList) Set(v string) error {
	*s = append(*s, v)
	return nil
}

That’s it. Register with flag.Var(&allow, "allow", ...) and --allow 192.168.1.0/24 --allow db.corp.local:5432 accumulates instead of clobbering. The pointer receiver isn’t stylistic; Set has to mutate the slice header.

The same file uses flag presence as a mode switch. Empty --server means abox-link opens a local control panel via linkapp.RunPanel. A non-empty --server means headless operation with the flag-driven config. The source comment explains why that flag and not another: --server is the one setting the panel can’t invent a default for, so supplying it is the clearest signal that a script is driving rather than a human.

The password falls back to os.Getenv("ABOX_PASSWORD") when --password is empty. That avoids putting the password in argv and shell history, but it is still worth remembering that environment variables are not a secret store.

Guarding destructive maintenance with syscall.Flock

Rotating the Git credential encryption key means decrypting and re-encrypting every stored secret. Do that while the server is writing and you get corrupted data. cmd/agentbox/git_keys.go takes an advisory lock on the data directory first:

lock, err := os.OpenFile(filepath.Join(lockedDir, "agentbox.lock"), os.O_RDWR|os.O_CREATE, 0600)
if err != nil {
	return err
}
defer lock.Close()
if err = syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
	return errors.New("Git 密钥轮换需要先停止 agentbox 服务;数据目录仍被使用")
}
// Reload after locking, so startup/config writes cannot race this snapshot.
cfg, err = config.Load(*cfgPath)

Two details here are easy to miss. LOCK_NB makes the call non-blocking, so the command fails immediately with a message telling you to stop the server, instead of hanging behind a process that may never exit. And the config gets loaded twice: once to locate the data directory so the lock file has somewhere to live, then again after the lock is held. Skip that second load and a concurrent startup could rewrite the config in the window between the first read and the lock, leaving the rotation working from a stale snapshot. The code then compares cfg.DataDir against what it locked and aborts on a mismatch.

syscall.Flock is Unix-only. If Windows matters to you, put golang.org/x/sys/windows.LockFileEx behind build tags. Agentbox keeps a separate internal/app/lock.go for the runtime side of the same problem, which is the right split: the long-running server and the one-shot CLI have different failure modes.

The rotation logic has a shape I like. store.RekeyGitCredentials takes a func(store.GitConnection) ([]byte, error) transform, and the code runs it twice. The first pass calls vault.Open and returns the secret unchanged; OAuth app secrets are validated separately. Only after all current material decrypts does the command activate a new key and run the real re-encryption. That catches unreadable credentials before mutation starts, while retained old keys and --resume cover failures that can still happen later.

Failures wrap with %w and name the recovery path:

return fmt.Errorf("Git 连接重加密中断,旧密钥已保留,可用 --resume 重试: %w", err)

Old keys stick around on purpose so --resume can pick up an interrupted rotation.

A string type that implements the agent interface

internal/agent/adapter.go flattens Claude Code and Codex CLI into one interface:

type Adapter interface {
	Type() string
	Capabilities() Capabilities
	Chat(permission, resume, model, effort string, control ...string) ([]string, error)
	Title() ([]string, error)
	ParseTitle(string) (string, []byte)
	Decode([]byte) (Event, bool)
}

type cliAdapter string

The concrete type is type cliAdapter string. Not a struct. The adapter’s entire state is which agent it represents, so a named string type carries the discriminator without a separate field. Lookup returns cliAdapter(kind) for the two supported values and an error for anything else. Capabilities come straight off the value:

func (a cliAdapter) Capabilities() Capabilities {
	return Capabilities{AppServer: string(a) == config.AgentCodex, TerminalUsage: true}
}

Go developers underuse this. When an implementation needs exactly one discriminator and nothing else, a defined string type can be clearer than a struct with a lonely kind field. If the adapter later needs a client handle or configuration, its representation and method bodies will have to grow with it.

Building argv, never shell strings

Chat returns []string, the argv for a single headless turn, and internal/agent/agent.go is picky about what gets in. Model names face a regexp first:

var modelRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}(\[1m\])?$`)

Square brackets are legal only in that trailing position, because Claude Code uses an opus[1m] style context-window suffix. The source comment points out that the model lands in its own argv slot and gets expanded with "$@" inside sh, so it can never be read as a glob.

The prompt never touches argv at all. It goes to the process’s stdin, so it does not need shell quoting. The sh -c wrapper records the PID in /tmp/.agentbox-chat.pid so the server can deliver SIGINT when a user cancels a turn; for Claude Code it can also set or clear the environment variables used by the configured thinking mode before exec "$@".

Streaming goes through Decode, which probes each line before committing to a full unmarshal:

var probe struct {
	Type string `json:"type"`
}
if len(line) == 0 || line[0] != '{' || json.Unmarshal(line, &probe) != nil {
	return Event{}, false
}

The line[0] != '{' check throws away the CLI’s non-JSON chatter without paying for a failed unmarshal. And Event.Raw copies the line with append(json.RawMessage(nil), line...), which is non-negotiable when the caller holds a bufio.Scanner buffer that the next Scan will overwrite. Omitting that copy produces events that look fine when you log them and are garbage by the time anything reads them, which is a miserable bug to chase.

On the Docker side, cmd/agentbox/backup.go pulls in github.com/docker/docker/client and github.com/docker/docker/api/types/container. Before a --full backup, it lists running containers and rejects the operation if one mounts any of the backup roots. If you want to know what’s underneath that client library, see Did you know Docker is actually built on Moby?.

Where does this approach run out of road? The standard package supplies basic per-flag usage, but not a cohesive command tree, shell completion, or nested-command structure. Meanwhile, maintenance grows a longer switch every time someone adds a command. Agentbox has five, which is still easy to follow; a much larger CLI would make a framework more attractive. The patterns that survive that switch have little to do with parsing: non-blocking locks with a useful error, validation before mutation, copying scanner-owned bytes, and keeping secrets out of command-line arguments.