Skip to content

Commit ce04a0e

Browse files
committed
feat(cli): add WithHTTPClient injection, separate cli module
- export DebugTransport with lazy Enabled *bool for embedding - add WithHTTPClient option, remove WithDebug from client - move --debug flag from cli package to standalone main.go - create separate cli/go.mod to prevent cobra leaking into SDK - update cmd/mbz/go.mod to depend on cli submodule - document embedding pattern in AGENTS.md
1 parent 380e74f commit ce04a0e

File tree

11 files changed

+151
-64
lines changed

11 files changed

+151
-64
lines changed

AGENTS.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,41 @@ The Mercedes-Benz API uses two auth methods depending on the endpoint:
4545

4646
Both are stored in the credential store. The OAuth2 token is cached separately in the token store.
4747

48+
### Embedding in a Parent CLI
49+
50+
The CLI can be embedded as a subcommand in a larger tool (e.g. a unified `way` CLI). Key design rules:
51+
52+
- **Never use `cmd.Root()`** — resolves to the parent CLI's root when embedded, breaking flag lookups. Use `cmd.Flags()` instead (works for both persistent and local flags).
53+
- **`WithHTTPClient`** — the parent injects an `*http.Client` via `cli.WithHTTPClient()`. The SDK layers (auth, retry) stack on top of the injected client's transport.
54+
- **`DebugTransport`** — exported in `debug.go` with a lazy `Enabled *bool` field. The parent owns the `--debug` flag and points `Enabled` at the flag variable. The transport checks the pointer at request time, solving the chicken-and-egg problem (transport constructed before flag parsing).
55+
56+
```go
57+
var debug bool
58+
cmd := cli.NewCommand(
59+
cli.WithCredentialStore(store),
60+
cli.WithTokenStore(tokenStore),
61+
cli.WithHTTPClient(&http.Client{
62+
Transport: &mbz.DebugTransport{
63+
Enabled: &debug,
64+
Next: http.DefaultTransport,
65+
},
66+
}),
67+
)
68+
cmd.PersistentFlags().BoolVar(&debug, "debug", false, "Enable debug logging")
69+
```
70+
71+
### Module Structure
72+
73+
Three separate Go modules prevent Cobra/CLI dependencies from leaking into the SDK library:
74+
75+
```
76+
go.mod # SDK client library (no cobra, no CLI deps)
77+
cli/go.mod # CLI commands (depends on root SDK + cobra)
78+
cmd/mbz/go.mod # Standalone binary (depends on cli module)
79+
```
80+
81+
Consumers who only need the Go client import the root module without pulling in CLI dependencies.
82+
4883
### Conventions
4984

5085
- Subcommands are organized by entity using `cobra.Group`

cli/cli.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cli
33
import (
44
"encoding/json"
55
"fmt"
6+
"net/http"
67
"os"
78
"path/filepath"
89
)
@@ -28,6 +29,7 @@ type Option func(*config)
2829
type config struct {
2930
credentialStore Store
3031
tokenStore Store
32+
httpClient *http.Client
3133
}
3234

3335
// WithCredentialStore sets the credential store.
@@ -40,6 +42,11 @@ func WithTokenStore(s Store) Option {
4042
return func(c *config) { c.tokenStore = s }
4143
}
4244

45+
// WithHTTPClient sets a custom [http.Client] for the SDK client.
46+
func WithHTTPClient(httpClient *http.Client) Option {
47+
return func(c *config) { c.httpClient = httpClient }
48+
}
49+
4350
// FileStore is a JSON file-backed store.
4451
type FileStore struct {
4552
path string

cli/command.go

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ func NewCommand(opts ...Option) *cobra.Command {
3131
Use: "mbz",
3232
Short: "Mercedes-Benz API CLI",
3333
}
34-
cmd.PersistentFlags().Bool("debug", false, "Enable debug mode")
3534
cmd.AddGroup(&cobra.Group{ID: "vehicles", Title: "Vehicles"})
3635
cmd.AddCommand(newListVehiclesCommand(&cfg))
3736
cmd.AddCommand(newAssignVehiclesCommand(&cfg))
@@ -681,10 +680,6 @@ func newConsumeVehicleSignalsCommand(cfg *config) *cobra.Command {
681680
// Client constructors.
682681

683682
func newOAuth2Client(cmd *cobra.Command, cfg *config) (*mbz.Client, error) {
684-
debug, err := cmd.Root().PersistentFlags().GetBool("debug")
685-
if err != nil {
686-
return nil, err
687-
}
688683
var creds Credentials
689684
if cfg.credentialStore != nil {
690685
if err := cfg.credentialStore.Read(&creds); err != nil && !errors.Is(err, fs.ErrNotExist) {
@@ -703,19 +698,17 @@ func newOAuth2Client(cmd *cobra.Command, cfg *config) (*mbz.Client, error) {
703698
if token.Expiry.Before(time.Now()) {
704699
return nil, fmt.Errorf("invalid token, please login using `mbz auth login`")
705700
}
706-
return mbz.NewClient(
707-
cmd.Context(),
708-
mbz.WithDebug(debug),
701+
opts := []mbz.ClientOption{
709702
mbz.WithRegion(mbz.Region(creds.Region)),
710703
mbz.WithOAuth2TokenSource(oauth2.StaticTokenSource(&token)),
711-
)
704+
}
705+
if cfg.httpClient != nil {
706+
opts = append(opts, mbz.WithHTTPClient(cfg.httpClient))
707+
}
708+
return mbz.NewClient(cmd.Context(), opts...)
712709
}
713710

714711
func newClientWithAPIKey(cmd *cobra.Command, cfg *config) (*mbz.Client, error) {
715-
debug, err := cmd.Root().PersistentFlags().GetBool("debug")
716-
if err != nil {
717-
return nil, err
718-
}
719712
var creds Credentials
720713
if cfg.credentialStore != nil {
721714
if err := cfg.credentialStore.Read(&creds); err != nil {
@@ -732,11 +725,13 @@ func newClientWithAPIKey(cmd *cobra.Command, cfg *config) (*mbz.Client, error) {
732725
"no API key found, please login using `mbz auth login --api-key <api-key>`",
733726
)
734727
}
735-
return mbz.NewClient(
736-
cmd.Context(),
737-
mbz.WithDebug(debug),
728+
opts := []mbz.ClientOption{
738729
mbz.WithAPIKey(creds.APIKey),
739-
)
730+
}
731+
if cfg.httpClient != nil {
732+
opts = append(opts, mbz.WithHTTPClient(cfg.httpClient))
733+
}
734+
return mbz.NewClient(cmd.Context(), opts...)
740735
}
741736

742737
// Helpers.

cli/go.mod

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
module github.com/way-platform/mbz-go/cli
2+
3+
go 1.25.0
4+
5+
toolchain go1.26.0
6+
7+
require (
8+
github.com/spf13/cobra v1.10.2
9+
github.com/twmb/franz-go v1.20.7
10+
github.com/way-platform/mbz-go v0.0.0-00010101000000-000000000000
11+
golang.org/x/oauth2 v0.30.0
12+
golang.org/x/term v0.41.0
13+
google.golang.org/protobuf v1.36.6
14+
)
15+
16+
require (
17+
github.com/inconshreveable/mousetrap v1.1.0 // indirect
18+
github.com/klauspost/compress v1.18.4 // indirect
19+
github.com/pierrec/lz4/v4 v4.1.25 // indirect
20+
github.com/spf13/pflag v1.0.9 // indirect
21+
github.com/twmb/franz-go/pkg/kmsg v1.12.0 // indirect
22+
golang.org/x/sys v0.42.0 // indirect
23+
)
24+
25+
replace github.com/way-platform/mbz-go => ../

cli/go.sum

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
2+
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
3+
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
4+
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
5+
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
6+
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
7+
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
8+
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
9+
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
10+
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
11+
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
12+
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
13+
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
14+
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
15+
github.com/twmb/franz-go v1.20.7 h1:P4MGSXJjjAPP3NRGPCks/Lrq+j+twWMVl1qYCVgNmWY=
16+
github.com/twmb/franz-go v1.20.7/go.mod h1:0bRX9HZVaoueqFWhPZNi2ODnJL7DNa6mK0HeCrC2bNU=
17+
github.com/twmb/franz-go/pkg/kmsg v1.12.0 h1:CbatD7ers1KzDNgJqPbKOq0Bz/WLBdsTH75wgzeVaPc=
18+
github.com/twmb/franz-go/pkg/kmsg v1.12.0/go.mod h1:+DPt4NC8RmI6hqb8G09+3giKObE6uD2Eya6CfqBpeJY=
19+
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
20+
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
21+
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
22+
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
23+
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
24+
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
25+
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
26+
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
27+
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
28+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
29+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
30+
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
31+
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
32+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

client.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ type clientConfig struct {
5151
clientSecret string
5252
apiKey string
5353
tokenSource oauth2.TokenSource
54-
debug bool
54+
httpClient *http.Client
5555
retryCount int
5656
timeout time.Duration
5757
interceptors []func(http.RoundTripper) http.RoundTripper
@@ -110,10 +110,11 @@ func WithAPIKey(apiKey string) ClientOption {
110110
}
111111
}
112112

113-
// WithDebug toggles debug mode (request/response dumps to stderr).
114-
func WithDebug(debug bool) ClientOption {
113+
// WithHTTPClient sets a custom [http.Client] for the SDK client.
114+
// The client's transport is used as the base transport in the middleware chain.
115+
func WithHTTPClient(httpClient *http.Client) ClientOption {
115116
return func(config *clientConfig) {
116-
config.debug = debug
117+
config.httpClient = httpClient
117118
}
118119
}
119120

@@ -140,8 +141,8 @@ func WithInterceptor(interceptor func(http.RoundTripper) http.RoundTripper) Clie
140141

141142
func (c *Client) httpClient(cfg clientConfig) *http.Client {
142143
transport := http.DefaultTransport
143-
if cfg.debug {
144-
transport = &debugTransport{next: transport}
144+
if cfg.httpClient != nil && cfg.httpClient.Transport != nil {
145+
transport = cfg.httpClient.Transport
145146
}
146147
if cfg.tokenSource != nil {
147148
transport = &oauth2.Transport{

cmd/mbz/go.mod

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ require (
77
charm.land/lipgloss/v2 v2.0.2
88
github.com/adrg/xdg v0.5.3
99
github.com/way-platform/mbz-go v0.0.0-00010101000000-000000000000
10+
github.com/way-platform/mbz-go/cli v0.0.0-00010101000000-000000000000
1011
)
1112

1213
require (
@@ -44,4 +45,7 @@ require (
4445
google.golang.org/protobuf v1.36.10 // indirect
4546
)
4647

47-
replace github.com/way-platform/mbz-go => ../..
48+
replace (
49+
github.com/way-platform/mbz-go => ../..
50+
github.com/way-platform/mbz-go/cli => ../../cli
51+
)

cmd/mbz/main.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,28 @@ package main
33
import (
44
"context"
55
"image/color"
6+
"net/http"
67
"os"
78

89
"charm.land/fang/v2"
910
"charm.land/lipgloss/v2"
1011
"github.com/adrg/xdg"
12+
"github.com/way-platform/mbz-go"
1113
"github.com/way-platform/mbz-go/cli"
1214
)
1315

1416
func main() {
1517
credPath, _ := xdg.ConfigFile("mbz-go/credentials.json")
1618
tokenPath, _ := xdg.ConfigFile("mbz-go/token.json")
19+
var debug bool
1720
cmd := cli.NewCommand(
1821
cli.WithCredentialStore(cli.NewFileStore(credPath)),
1922
cli.WithTokenStore(cli.NewFileStore(tokenPath)),
23+
cli.WithHTTPClient(&http.Client{
24+
Transport: &mbz.DebugTransport{Enabled: &debug},
25+
}),
2026
)
27+
cmd.PersistentFlags().BoolVar(&debug, "debug", false, "Enable debug mode")
2128
if err := fang.Execute(
2229
context.Background(),
2330
cmd,

debug.go

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,34 @@ import (
99
"os"
1010
)
1111

12-
type debugTransport struct {
13-
next http.RoundTripper
12+
// DebugTransport is a [http.RoundTripper] that dumps requests and responses to stderr.
13+
// When Enabled is non-nil, it checks the pointed-to bool on each request,
14+
// allowing lazy binding to a CLI flag.
15+
type DebugTransport struct {
16+
// Enabled controls whether debug output is active.
17+
// When nil, debug output is always active.
18+
Enabled *bool
19+
// Next is the underlying transport. When nil, [http.DefaultTransport] is used.
20+
Next http.RoundTripper
1421
}
1522

16-
func (t *debugTransport) RoundTrip(request *http.Request) (*http.Response, error) {
23+
func (t *DebugTransport) next() http.RoundTripper {
24+
if t.Next != nil {
25+
return t.Next
26+
}
27+
return http.DefaultTransport
28+
}
29+
30+
func (t *DebugTransport) RoundTrip(request *http.Request) (*http.Response, error) {
31+
if t.Enabled != nil && !*t.Enabled {
32+
return t.next().RoundTrip(request)
33+
}
1734
requestDump, err := httputil.DumpRequestOut(request, true)
1835
if err != nil {
1936
return nil, fmt.Errorf("failed to dump request for debug: %w", err)
2037
}
2138
prettyPrintDump(os.Stderr, requestDump, "> ")
22-
response, err := t.next.RoundTrip(request)
39+
response, err := t.next().RoundTrip(request)
2340
if err != nil {
2441
return nil, err
2542
}

go.mod

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,6 @@ go 1.25.0
55
toolchain go1.26.0
66

77
require (
8-
github.com/spf13/cobra v1.10.2
9-
github.com/twmb/franz-go v1.20.7
108
golang.org/x/oauth2 v0.30.0
11-
golang.org/x/term v0.41.0
129
google.golang.org/protobuf v1.36.6
1310
)
14-
15-
require (
16-
github.com/inconshreveable/mousetrap v1.1.0 // indirect
17-
github.com/klauspost/compress v1.18.4 // indirect
18-
github.com/pierrec/lz4/v4 v4.1.25 // indirect
19-
github.com/spf13/pflag v1.0.9 // indirect
20-
github.com/twmb/franz-go/pkg/kmsg v1.12.0 // indirect
21-
golang.org/x/sys v0.42.0 // indirect
22-
)

0 commit comments

Comments
 (0)