Skip to content

Commit 5365d6b

Browse files
committed
feat(cli): root global flags, render helpers, exit codes (#24)
- Root persistent flags: -e/--env, -o/--output (table|json|yaml), --project, --state, --no-color (design doc 11.1) - render: format constants, WriteJSON with sorted map keys, WriteTable, WriteYAML - cli: ExitError + ExitCodeOf mapping (11.2), Main() for app wiring - version respects -o json|yaml|table; JSON uses stable struct keys - App.Run returns exit code; main uses RunAndExit - Tests: help lists globals, JSON version shape, invalid output -> code 2 Closes #24 Made-with: Cursor
1 parent d00df71 commit 5365d6b

14 files changed

Lines changed: 426 additions & 22 deletions

File tree

cmd/agentctl/main.go

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,7 @@
11
package main
22

3-
import (
4-
"fmt"
5-
"os"
6-
7-
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/app"
8-
)
3+
import "github.com/LAA-Software-Engineering/agentic-control-plane/internal/app"
94

105
func main() {
11-
if err := app.New().Run(); err != nil {
12-
fmt.Fprintln(os.Stderr, err)
13-
os.Exit(1)
14-
}
6+
app.New().RunAndExit()
157
}

internal/app/app.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package app
22

3+
import "os"
4+
35
// App is the composition root for agentctl. Subsystems (stores, planner,
46
// runtime) will be assembled here in later phases.
57
type App struct{}
@@ -9,7 +11,12 @@ func New() *App {
911
return &App{}
1012
}
1113

12-
// Run starts the CLI and blocks until the root command finishes.
13-
func (a *App) Run() error {
14+
// Run starts the CLI and returns a process exit code (design doc section 11.2).
15+
func (a *App) Run() int {
1416
return runCLI()
1517
}
18+
19+
// RunAndExit runs the CLI and terminates the process (convenience for main).
20+
func (a *App) RunAndExit() {
21+
os.Exit(a.Run())
22+
}

internal/app/wiring.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@ package app
22

33
import "github.com/LAA-Software-Engineering/agentic-control-plane/internal/cli"
44

5-
func runCLI() error {
6-
return cli.Execute()
5+
func runCLI() int {
6+
return cli.Main()
77
}

internal/cli/config.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package cli
2+
3+
import (
4+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/render"
5+
"github.com/spf13/cobra"
6+
)
7+
8+
// Global holds root persistent flags shared by all subcommands (design doc section 11.1).
9+
type Global struct {
10+
Env string
11+
Output string
12+
ProjectRoot string
13+
StatePath string
14+
NoColor bool
15+
}
16+
17+
var global Global
18+
19+
// Globals returns the process-wide CLI global options after flags are parsed.
20+
func Globals() *Global {
21+
return &global
22+
}
23+
24+
// BindPersistentFlags registers -e/--env, -o/--output, --project, --state, --no-color on cmd.
25+
func BindPersistentFlags(cmd *cobra.Command) {
26+
f := cmd.PersistentFlags()
27+
f.StringVarP(&global.Env, "env", "e", "", "environment name (target context)")
28+
f.StringVarP(&global.Output, "output", "o", render.FormatTable, "output format: table, json, or yaml")
29+
f.StringVar(&global.ProjectRoot, "project", ".", "project root directory (contains project.yaml)")
30+
f.StringVar(&global.StatePath, "state", "", "path to SQLite state database (optional override)")
31+
f.BoolVar(&global.NoColor, "no-color", false, "disable color output")
32+
}
33+
34+
// ValidateGlobals checks flag values after parsing.
35+
func ValidateGlobals() error {
36+
if !render.ValidFormat(global.Output) {
37+
return NewExitErrorf(ExitValidationError, "invalid --output %q (want table, json, or yaml)", global.Output)
38+
}
39+
return nil
40+
}
41+
42+
// ResetGlobalsForTest resets global flags (for tests only).
43+
func ResetGlobalsForTest() {
44+
global = Global{}
45+
}

internal/cli/doc.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Package cli defines agentctl commands, global flags (design doc section 11.1), and exit-code
2+
// mapping (section 11.2). Output formatting lives in [render].
3+
package cli

internal/cli/exit.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package cli
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
)
7+
8+
// Exit codes (design doc section 11.2).
9+
const (
10+
ExitSuccess = 0
11+
ExitGenericFailure = 1
12+
ExitValidationError = 2
13+
ExitPlanApplyConflict = 3
14+
ExitExecutionError = 4
15+
ExitPolicyDenied = 5
16+
)
17+
18+
// ExitError carries a non-zero exit status for [ExitCodeOf].
19+
type ExitError struct {
20+
Code int
21+
Err error
22+
}
23+
24+
func (e *ExitError) Error() string {
25+
if e == nil || e.Err == nil {
26+
return "exit error"
27+
}
28+
return e.Err.Error()
29+
}
30+
31+
// Unwrap returns the underlying error.
32+
func (e *ExitError) Unwrap() error {
33+
if e == nil {
34+
return nil
35+
}
36+
return e.Err
37+
}
38+
39+
// ExitCodeOf maps errors to process exit codes. Unknown errors use [ExitGenericFailure].
40+
func ExitCodeOf(err error) int {
41+
if err == nil {
42+
return ExitSuccess
43+
}
44+
var ee *ExitError
45+
if errors.As(err, &ee) && ee != nil && ee.Code >= 0 && ee.Code <= 255 {
46+
return ee.Code
47+
}
48+
return ExitGenericFailure
49+
}
50+
51+
// NewExitError wraps err with a specific exit code.
52+
func NewExitError(code int, err error) error {
53+
if err == nil {
54+
return nil
55+
}
56+
return &ExitError{Code: code, Err: err}
57+
}
58+
59+
// NewExitErrorf is like [fmt.Errorf] with an exit code.
60+
func NewExitErrorf(code int, format string, a ...any) error {
61+
return &ExitError{Code: code, Err: fmt.Errorf(format, a...)}
62+
}

internal/cli/root.go

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package cli
22

33
import (
44
"fmt"
5+
"os"
56

7+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/render"
68
"github.com/spf13/cobra"
79
)
810

@@ -11,18 +13,25 @@ var Version = "0.0.0-dev"
1113

1214
// Execute runs the root command.
1315
func Execute() error {
14-
return newRootCmd().Execute()
16+
return NewRootCmd().Execute()
1517
}
1618

17-
func newRootCmd() *cobra.Command {
19+
// NewRootCmd builds the agentctl command tree (exposed for tests).
20+
func NewRootCmd() *cobra.Command {
21+
global = Global{}
1822
root := &cobra.Command{
19-
Use: "agentctl",
20-
Short: "Declarative control plane for agent systems",
21-
Long: "agentctl validates, plans, applies, and runs declarative agent systems defined as YAML.",
23+
Use: "agentctl",
24+
Short: "Declarative control plane for agent systems",
25+
Long: "agentctl validates, plans, applies, and runs declarative agent systems defined as YAML.",
26+
SilenceErrors: true,
2227
Run: func(cmd *cobra.Command, args []string) {
2328
_ = cmd.Help()
2429
},
30+
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
31+
return ValidateGlobals()
32+
},
2533
}
34+
BindPersistentFlags(root)
2635
root.AddCommand(newVersionCmd())
2736
return root
2837
}
@@ -31,8 +40,36 @@ func newVersionCmd() *cobra.Command {
3140
return &cobra.Command{
3241
Use: "version",
3342
Short: "Print version information",
34-
Run: func(cmd *cobra.Command, args []string) {
35-
fmt.Println(Version)
43+
RunE: func(cmd *cobra.Command, args []string) error {
44+
g := Globals()
45+
out := cmd.OutOrStdout()
46+
switch g.Output {
47+
case render.FormatJSON:
48+
payload := struct {
49+
Version string `json:"version"`
50+
}{Version: Version}
51+
if err := render.WriteJSON(out, payload); err != nil {
52+
return err
53+
}
54+
case render.FormatYAML:
55+
if err := render.WriteYAML(out, map[string]string{"version": Version}); err != nil {
56+
return err
57+
}
58+
default:
59+
if _, err := render.Fprintf(out, "%s\n", Version); err != nil {
60+
return err
61+
}
62+
}
63+
return nil
3664
},
3765
}
3866
}
67+
68+
// Main is an optional entrypoint that maps errors to exit codes and writes diagnostics to stderr.
69+
func Main() int {
70+
if err := Execute(); err != nil {
71+
fmt.Fprintln(os.Stderr, err)
72+
return ExitCodeOf(err)
73+
}
74+
return ExitSuccess
75+
}

internal/cli/root_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"io"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func TestRootHelp_listsGlobalFlags(t *testing.T) {
12+
ResetGlobalsForTest()
13+
cmd := NewRootCmd()
14+
var buf bytes.Buffer
15+
cmd.SetOut(&buf)
16+
cmd.SetErr(&buf)
17+
cmd.SetArgs([]string{"--help"})
18+
if err := cmd.Execute(); err != nil {
19+
t.Fatal(err)
20+
}
21+
out := buf.String()
22+
for _, flag := range []string{"--env", "-e", "--output", "-o", "--project", "--state", "--no-color"} {
23+
if !strings.Contains(out, flag) {
24+
t.Fatalf("help output missing %q\n%s", flag, out)
25+
}
26+
}
27+
}
28+
29+
func TestVersion_JSON_stableKeys(t *testing.T) {
30+
ResetGlobalsForTest()
31+
cmd := NewRootCmd()
32+
var buf bytes.Buffer
33+
cmd.SetOut(&buf)
34+
cmd.SetArgs([]string{"-o", "json", "version"})
35+
if err := cmd.Execute(); err != nil {
36+
t.Fatal(err)
37+
}
38+
raw := bytes.TrimSpace(buf.Bytes())
39+
if !json.Valid(raw) {
40+
t.Fatalf("invalid json: %s", raw)
41+
}
42+
var m map[string]any
43+
if err := json.Unmarshal(raw, &m); err != nil {
44+
t.Fatal(err)
45+
}
46+
if len(m) != 1 {
47+
t.Fatalf("want single top-level key, got %v", m)
48+
}
49+
if _, ok := m["version"]; !ok {
50+
t.Fatalf("missing version key: %v", m)
51+
}
52+
}
53+
54+
func TestInvalidOutput_exitCode2(t *testing.T) {
55+
ResetGlobalsForTest()
56+
cmd := NewRootCmd()
57+
cmd.SetOut(io.Discard)
58+
cmd.SetErr(io.Discard)
59+
cmd.SetArgs([]string{"-o", "nope", "version"})
60+
err := cmd.Execute()
61+
if err == nil {
62+
t.Fatal("expected error")
63+
}
64+
if ExitCodeOf(err) != ExitValidationError {
65+
t.Fatalf("code=%d err=%v", ExitCodeOf(err), err)
66+
}
67+
}

internal/render/doc.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
1-
// Package render formats output as YAML, JSON, or tables.
1+
// Package render formats CLI output as tables, JSON, or YAML (design doc sections 5.1, 11.1, 18).
2+
//
3+
// JSON helpers sort object keys so machine-oriented output is stable (issue #24).
24
package render

internal/render/format.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package render
2+
3+
// Output format names (design doc section 11.1; issue #24 MVP uses table|json, yaml included for parity).
4+
const (
5+
FormatTable = "table"
6+
FormatJSON = "json"
7+
FormatYAML = "yaml"
8+
)
9+
10+
// ValidFormat reports whether s is a supported --output value.
11+
func ValidFormat(s string) bool {
12+
switch s {
13+
case FormatTable, FormatJSON, FormatYAML:
14+
return true
15+
default:
16+
return false
17+
}
18+
}

0 commit comments

Comments
 (0)