From 86863653f8eb98acd7b98fde718d93dcfea153b8 Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 28 Sep 2026 23:34:51 +0000 Subject: [PATCH 1/2] Move the command tree into internal/cli (closes #8) cmd/simplexcalc/ now holds only main.go: it passes the version the linker sets to cli.Main and exits with the status that returns. root.go and run.go move to internal/cli with their comments, changed only to take the version as a parameter. Main reads the process's arguments and output; execute takes them as parameters so the new tests can check the version line and the non-zero status of an unknown command. Commands, flags, output, log lines and exit status are unchanged, and the -X main.version flag in the Dockerfile still applies. Model: opus-5-5 --- AGENTS.md | 5 +-- cmd/simplexcalc/main.go | 14 ++------ docs/TODO.md | 3 ++ internal/cli/cli.go | 38 +++++++++++++++++++++ internal/cli/cli_test.go | 41 +++++++++++++++++++++++ internal/cli/export_test.go | 7 ++++ {cmd/simplexcalc => internal/cli}/root.go | 8 ++--- {cmd/simplexcalc => internal/cli}/run.go | 8 ++--- 8 files changed, 103 insertions(+), 21 deletions(-) create mode 100644 internal/cli/cli.go create mode 100644 internal/cli/cli_test.go create mode 100644 internal/cli/export_test.go rename {cmd/simplexcalc => internal/cli}/root.go (88%) rename {cmd/simplexcalc => internal/cli}/run.go (87%) diff --git a/AGENTS.md b/AGENTS.md index 59ec12d..67def55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ --- title: Agent Guidance -last_modified: 2026-09-26 +last_modified: 2026-09-28 --- This file is the single source of guidance for any automated agent @@ -118,9 +118,10 @@ Do not weaken them. ## Layout ``` -cmd/simplexcalc/ cobra command tree; main(), run and version +cmd/simplexcalc/ main(), a single call into internal/cli internal/bot/ startup, address setup, and the reply to a message internal/calc/ the arithmetic: go/parser and go/constant +internal/cli/ cobra command tree: run and version internal/config/ viper-backed configuration; the abort-on-garbage rule internal/logger/ log/slog, JSON always internal/simplex/ the simplex-chat child process and its WebSocket API diff --git a/cmd/simplexcalc/main.go b/cmd/simplexcalc/main.go index ff0dbed..bb06922 100644 --- a/cmd/simplexcalc/main.go +++ b/cmd/simplexcalc/main.go @@ -2,12 +2,10 @@ package main import ( - "fmt" "os" -) -// appname is the name the program reports in its logs and its version. -const appname = "simplexcalc" + "sneak.berlin/go/simplexcalc/internal/cli" +) // version is injected at build time with -ldflags "-X main.version=...". // The Dockerfile passes VERSION; a `go build` without it says "dev", @@ -16,11 +14,5 @@ const appname = "simplexcalc" var version = "dev" func main() { - err := rootCmd().Execute() - if err != nil { - // cobra has already printed the error; this only sets the exit - // status, which is what a supervisor and a shell script read. - fmt.Fprintln(os.Stderr, "exiting: "+err.Error()) - os.Exit(1) - } + os.Exit(cli.Main(version)) } diff --git a/docs/TODO.md b/docs/TODO.md index 970fe8a..57eef19 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -27,6 +27,9 @@ with no deprecation warning. # Completed Steps +- 2026-09-28 Moved the command tree and the `run` and `version` commands + from `cmd/simplexcalc/` into `internal/cli`; `cmd/simplexcalc/main.go` + is now a single call to `cli.Main` - 2026-09-26 Seeded from `go-template-repo` (renamed, its HTTP service removed) and built the bot: `internal/calc` for the arithmetic, `internal/simplex` for the chat client process and its WebSocket API, diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..4460b60 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,38 @@ +// Package cli is the simplexcalc command tree: the run and version +// commands, and the exit status the program ends with. +package cli + +import ( + "fmt" + "io" + "os" +) + +// appname is the name the program reports in its logs and its version. +const appname = "simplexcalc" + +// Main runs the command named on the command line and returns the exit +// status for main to pass to os.Exit. +func Main(version string) int { + return execute(version, os.Args[1:], os.Stdout, os.Stderr) +} + +// execute is Main with the arguments and the output passed in, so that +// a test can supply them. +func execute(version string, args []string, stdout, stderr io.Writer) int { + root := rootCmd(version) + root.SetArgs(args) + root.SetOut(stdout) + root.SetErr(stderr) + + err := root.Execute() + if err != nil { + // cobra has already printed the error; this only sets the exit + // status, which is what a supervisor and a shell script read. + _, _ = fmt.Fprintln(stderr, "exiting: "+err.Error()) + + return 1 + } + + return 0 +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..ad82d34 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,41 @@ +package cli_test + +import ( + "bytes" + "runtime" + "strings" + "testing" + + "sneak.berlin/go/simplexcalc/internal/cli" +) + +func TestVersionPrintsVersion(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + + status := cli.Execute("1.2.3", []string{"version"}, &stdout, &stderr) + if status != 0 { + t.Fatalf("status = %d, want 0; stderr: %q", status, stderr.String()) + } + + want := "simplexcalc 1.2.3 " + runtime.GOOS + "/" + runtime.GOARCH + "\n" + if stdout.String() != want { + t.Errorf("stdout = %q, want %q", stdout.String(), want) + } +} + +func TestUnknownCommandFails(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + + status := cli.Execute("1.2.3", []string{"frobnicate"}, &stdout, &stderr) + if status == 0 { + t.Fatal("status = 0, want non-zero") + } + + if !strings.HasPrefix(stderr.String(), "exiting: ") { + t.Errorf("stderr = %q, want the error after \"exiting: \"", stderr.String()) + } +} diff --git a/internal/cli/export_test.go b/internal/cli/export_test.go new file mode 100644 index 0000000..fc75527 --- /dev/null +++ b/internal/cli/export_test.go @@ -0,0 +1,7 @@ +package cli + +// Execute is execute, exported for the external test package, which +// needs to supply the arguments and read the output. +// +//nolint:gochecknoglobals // a test seam, not mutable state. +var Execute = execute diff --git a/cmd/simplexcalc/root.go b/internal/cli/root.go similarity index 88% rename from cmd/simplexcalc/root.go rename to internal/cli/root.go index 42e4bc6..a312a22 100644 --- a/cmd/simplexcalc/root.go +++ b/internal/cli/root.go @@ -1,4 +1,4 @@ -package main +package cli import ( "fmt" @@ -11,7 +11,7 @@ import ( // environment (see internal/config), not from flags: the bot runs in a // container, where the environment is the interface, and one source of // truth means there is no precedence rule to get wrong. -func rootCmd() *cobra.Command { +func rootCmd(version string) *cobra.Command { root := &cobra.Command{ Use: appname, Short: appname + " — a SimpleX Chat bot that answers arithmetic", @@ -26,12 +26,12 @@ func rootCmd() *cobra.Command { SilenceErrors: true, } - root.AddCommand(runCmd(), versionCmd()) + root.AddCommand(runCmd(version), versionCmd(version)) return root } -func versionCmd() *cobra.Command { +func versionCmd(version string) *cobra.Command { return &cobra.Command{ Use: "version", Short: "print the version and exit", diff --git a/cmd/simplexcalc/run.go b/internal/cli/run.go similarity index 87% rename from cmd/simplexcalc/run.go rename to internal/cli/run.go index 5616b19..bbf0782 100644 --- a/cmd/simplexcalc/run.go +++ b/internal/cli/run.go @@ -1,4 +1,4 @@ -package main +package cli import ( "context" @@ -14,13 +14,13 @@ import ( "sneak.berlin/go/simplexcalc/internal/logger" ) -func runCmd() *cobra.Command { +func runCmd(version string) *cobra.Command { return &cobra.Command{ Use: "run", Short: "run the bot", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return run(cmd.Context()) + return run(cmd.Context(), version) }, } } @@ -28,7 +28,7 @@ func runCmd() *cobra.Command { // run reads the configuration and runs the bot until SIGINT or SIGTERM, // or until it fails. A failure is the process's exit status, so a // supervisor restarts it. -func run(ctx context.Context) error { +func run(ctx context.Context, version string) error { cfg, err := config.New() if err != nil { return fmt.Errorf("reading configuration: %w", err) -- 2.54.0 From 54596ef54b6e4b60d349f13b6e89329c99192f75 Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 28 Sep 2026 23:42:59 +0000 Subject: [PATCH 2/2] Keep cobra's output streams at their defaults in cli.Main cli.Main no longer calls SetOut or SetErr. SetOut also moved the usage and an unknown help topic, which cobra writes to stderr, onto stdout. execute now takes the command tree, so the tests build it, set its arguments and output streams, and run it; the exit line goes to the command's error stream, which outside a test is stderr as before. Model: opus-5-5 --- internal/cli/cli.go | 23 +++++++++++------------ internal/cli/cli_test.go | 14 ++++++++++++-- internal/cli/export_test.go | 12 ++++++++---- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 4460b60..3043bb1 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -4,8 +4,8 @@ package cli import ( "fmt" - "io" - "os" + + "github.com/spf13/cobra" ) // appname is the name the program reports in its logs and its version. @@ -13,23 +13,22 @@ const appname = "simplexcalc" // Main runs the command named on the command line and returns the exit // status for main to pass to os.Exit. +// +// It leaves cobra's output streams at their defaults. cobra writes the +// usage and an unknown help topic to stderr, but SetOut moves them onto +// the stream it is given. func Main(version string) int { - return execute(version, os.Args[1:], os.Stdout, os.Stderr) + return execute(rootCmd(version)) } -// execute is Main with the arguments and the output passed in, so that -// a test can supply them. -func execute(version string, args []string, stdout, stderr io.Writer) int { - root := rootCmd(version) - root.SetArgs(args) - root.SetOut(stdout) - root.SetErr(stderr) - +// execute runs root and returns the exit status. It takes the command +// so that a test can set its arguments and output streams first. +func execute(root *cobra.Command) int { err := root.Execute() if err != nil { // cobra has already printed the error; this only sets the exit // status, which is what a supervisor and a shell script read. - _, _ = fmt.Fprintln(stderr, "exiting: "+err.Error()) + _, _ = fmt.Fprintln(root.ErrOrStderr(), "exiting: "+err.Error()) return 1 } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index ad82d34..e27d5bd 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -14,7 +14,12 @@ func TestVersionPrintsVersion(t *testing.T) { var stdout, stderr bytes.Buffer - status := cli.Execute("1.2.3", []string{"version"}, &stdout, &stderr) + root := cli.RootCmd("1.2.3") + root.SetArgs([]string{"version"}) + root.SetOut(&stdout) + root.SetErr(&stderr) + + status := cli.Execute(root) if status != 0 { t.Fatalf("status = %d, want 0; stderr: %q", status, stderr.String()) } @@ -30,7 +35,12 @@ func TestUnknownCommandFails(t *testing.T) { var stdout, stderr bytes.Buffer - status := cli.Execute("1.2.3", []string{"frobnicate"}, &stdout, &stderr) + root := cli.RootCmd("1.2.3") + root.SetArgs([]string{"frobnicate"}) + root.SetOut(&stdout) + root.SetErr(&stderr) + + status := cli.Execute(root) if status == 0 { t.Fatal("status = 0, want non-zero") } diff --git a/internal/cli/export_test.go b/internal/cli/export_test.go index fc75527..5d8b0fe 100644 --- a/internal/cli/export_test.go +++ b/internal/cli/export_test.go @@ -1,7 +1,11 @@ package cli -// Execute is execute, exported for the external test package, which -// needs to supply the arguments and read the output. +// RootCmd and Execute are rootCmd and execute, exported for the external +// test package, which builds the command tree itself so that it can +// supply the arguments and read the output. // -//nolint:gochecknoglobals // a test seam, not mutable state. -var Execute = execute +//nolint:gochecknoglobals // test seams, not mutable state. +var ( + RootCmd = rootCmd + Execute = execute +) -- 2.54.0