Skip to content

Commit 19309b6

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/mapon/go.mod to depend on cli submodule - document embedding pattern in AGENTS.md
1 parent baf0bb3 commit 19309b6

12 files changed

Lines changed: 139 additions & 55 deletions

File tree

AGENTS.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,40 @@ This separation lets consumers embed the CLI in a larger tool or swap the storag
3232

3333
The Mapon API uses a single API key for all requests. The key is stored in the credential store.
3434

35+
### Embedding in a Parent CLI
36+
37+
The CLI can be embedded as a subcommand in a larger tool (e.g. a unified `way` CLI). Key design rules:
38+
39+
- **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).
40+
- **`WithHTTPClient`** — the parent injects an `*http.Client` via `cli.WithHTTPClient()`. The SDK layers (auth, retry) stack on top of the injected client's transport.
41+
- **`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).
42+
43+
```go
44+
var debug bool
45+
cmd := cli.NewCommand(
46+
cli.WithCredentialStore(store),
47+
cli.WithHTTPClient(&http.Client{
48+
Transport: &mapon.DebugTransport{
49+
Enabled: &debug,
50+
Next: http.DefaultTransport,
51+
},
52+
}),
53+
)
54+
cmd.PersistentFlags().BoolVar(&debug, "debug", false, "Enable debug logging")
55+
```
56+
57+
### Module Structure
58+
59+
Three separate Go modules prevent Cobra/CLI dependencies from leaking into the SDK library:
60+
61+
```
62+
go.mod # SDK client library (no cobra, no CLI deps)
63+
cli/go.mod # CLI commands (depends on root SDK + cobra)
64+
cmd/mapon/go.mod # Standalone binary (depends on cli module)
65+
```
66+
67+
Consumers who only need the Go client import the root module without pulling in CLI dependencies.
68+
3569
### Conventions
3670

3771
- 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
)
@@ -24,13 +25,19 @@ type Option func(*config)
2425

2526
type config struct {
2627
credentialStore Store
28+
httpClient *http.Client
2729
}
2830

2931
// WithCredentialStore sets the credential store.
3032
func WithCredentialStore(s Store) Option {
3133
return func(c *config) { c.credentialStore = s }
3234
}
3335

36+
// WithHTTPClient sets the base HTTP client passed to the SDK.
37+
func WithHTTPClient(c *http.Client) Option {
38+
return func(cfg *config) { cfg.httpClient = c }
39+
}
40+
3441
// FileStore is a JSON file-backed store.
3542
type FileStore struct {
3643
path string

cli/command.go

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ func NewCommand(opts ...Option) *cobra.Command {
2525
Use: "mapon",
2626
Short: "Mapon API CLI",
2727
}
28-
cmd.PersistentFlags().Bool("debug", false, "Enable debug mode")
2928

3029
cmd.AddGroup(&cobra.Group{ID: "units", Title: "Units"})
3130
cmd.AddCommand(newListUnitsCommand(&cfg))
@@ -138,7 +137,6 @@ func newLogoutCommand(cfg *config) *cobra.Command {
138137
// --- Client ---
139138

140139
func newClient(cmd *cobra.Command, cfg *config) (*mapon.Client, error) {
141-
debug, _ := cmd.Root().PersistentFlags().GetBool("debug")
142140
var creds Credentials
143141
if cfg.credentialStore != nil {
144142
if err := cfg.credentialStore.Read(&creds); err != nil {
@@ -148,11 +146,12 @@ func newClient(cmd *cobra.Command, cfg *config) (*mapon.Client, error) {
148146
return nil, fmt.Errorf("read credentials: %w", err)
149147
}
150148
}
151-
return mapon.NewClient(
152-
cmd.Context(),
153-
mapon.WithDebug(debug),
154-
mapon.WithAPIKey(creds.APIKey),
155-
)
149+
var opts []mapon.ClientOption
150+
if cfg.httpClient != nil {
151+
opts = append(opts, mapon.WithHTTPClient(cfg.httpClient))
152+
}
153+
opts = append(opts, mapon.WithAPIKey(creds.APIKey))
154+
return mapon.NewClient(cmd.Context(), opts...)
156155
}
157156

158157
// --- Units ---

cli/go.mod

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
module github.com/way-platform/mapon-go/cli
2+
3+
go 1.25.0
4+
5+
require (
6+
github.com/spf13/cobra v1.10.2
7+
github.com/way-platform/mapon-go v0.0.0
8+
golang.org/x/term v0.41.0
9+
google.golang.org/protobuf v1.36.11
10+
)
11+
12+
require (
13+
github.com/inconshreveable/mousetrap v1.1.0 // indirect
14+
github.com/spf13/pflag v1.0.9 // indirect
15+
golang.org/x/sys v0.42.0 // indirect
16+
)
17+
18+
replace github.com/way-platform/mapon-go => ../

cli/go.sum

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
2+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
3+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
7+
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
8+
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
9+
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
10+
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
11+
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
12+
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
13+
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
14+
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
15+
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
16+
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
17+
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
18+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

client.go

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func NewClient(ctx context.Context, opts ...ClientOption) (*Client, error) {
3232
// clientConfig configures a [Client].
3333
type clientConfig struct {
3434
apiKey string
35-
debug bool
35+
httpClient *http.Client
3636
retryCount int
3737
timeout time.Duration
3838
interceptors []func(http.RoundTripper) http.RoundTripper
@@ -62,10 +62,11 @@ func WithAPIKey(apiKey string) ClientOption {
6262
}
6363
}
6464

65-
// WithDebug toggles debug mode (request/response dumps to stderr).
66-
func WithDebug(debug bool) ClientOption {
65+
// WithHTTPClient sets the base HTTP client for the SDK client.
66+
// The client's transport is used as the innermost transport in the chain.
67+
func WithHTTPClient(httpClient *http.Client) ClientOption {
6768
return func(config *clientConfig) {
68-
config.debug = debug
69+
config.httpClient = httpClient
6970
}
7071
}
7172

@@ -91,9 +92,15 @@ func WithInterceptor(interceptor func(http.RoundTripper) http.RoundTripper) Clie
9192
}
9293

9394
func (c *Client) httpClient(cfg clientConfig) *http.Client {
94-
transport := http.DefaultTransport
95-
if cfg.debug {
96-
transport = &debugTransport{next: transport}
95+
var transport http.RoundTripper = http.DefaultTransport
96+
timeout := cfg.timeout
97+
if cfg.httpClient != nil {
98+
if cfg.httpClient.Transport != nil {
99+
transport = cfg.httpClient.Transport
100+
}
101+
if cfg.httpClient.Timeout > 0 {
102+
timeout = cfg.httpClient.Timeout
103+
}
97104
}
98105
if cfg.apiKey != "" {
99106
transport = &apiKeyTransport{
@@ -114,7 +121,7 @@ func (c *Client) httpClient(cfg clientConfig) *http.Client {
114121
}
115122
}
116123
return &http.Client{
117-
Timeout: cfg.timeout,
124+
Timeout: timeout,
118125
Transport: transport,
119126
}
120127
}

cmd/mapon/go.mod

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ require (
66
charm.land/fang/v2 v2.0.1
77
charm.land/lipgloss/v2 v2.0.2
88
github.com/adrg/xdg v0.5.3
9-
github.com/way-platform/mapon-go v0.0.0-00010101000000-000000000000
9+
github.com/way-platform/mapon-go v0.0.0
10+
github.com/way-platform/mapon-go/cli v0.0.0-00010101000000-000000000000
1011
)
1112

1213
require (
@@ -36,7 +37,10 @@ require (
3637
golang.org/x/sys v0.42.0 // indirect
3738
golang.org/x/term v0.41.0 // indirect
3839
golang.org/x/text v0.31.0 // indirect
39-
google.golang.org/protobuf v1.36.10 // indirect
40+
google.golang.org/protobuf v1.36.11 // indirect
4041
)
4142

42-
replace github.com/way-platform/mapon-go => ../..
43+
replace (
44+
github.com/way-platform/mapon-go => ../..
45+
github.com/way-platform/mapon-go/cli => ../../cli
46+
)

cmd/mapon/go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
7474
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
7575
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
7676
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
77-
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
78-
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
77+
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
78+
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
7979
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
8080
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
8181
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

cmd/mapon/main.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +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+
mapon "github.com/way-platform/mapon-go"
1113
"github.com/way-platform/mapon-go/cli"
1214
)
1315

1416
func main() {
1517
credPath, _ := xdg.ConfigFile("mapon-go/credentials.json")
18+
var debug bool
1619
cmd := cli.NewCommand(
1720
cli.WithCredentialStore(cli.NewFileStore(credPath)),
21+
cli.WithHTTPClient(&http.Client{
22+
Transport: &mapon.DebugTransport{
23+
Enabled: &debug,
24+
},
25+
}),
1826
)
27+
cmd.PersistentFlags().BoolVar(&debug, "debug", false, "Enable debug mode")
1928
if err := fang.Execute(
2029
context.Background(),
2130
cmd,

debug.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,24 @@ 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+
// The Enabled field supports lazy evaluation via a *bool, allowing it to be bound
14+
// to a flag that is parsed after the transport is constructed.
15+
type DebugTransport struct {
16+
Enabled *bool
17+
Next http.RoundTripper
1418
}
1519

16-
func (t *debugTransport) RoundTrip(request *http.Request) (*http.Response, error) {
20+
func (t *DebugTransport) RoundTrip(request *http.Request) (*http.Response, error) {
21+
if t.Enabled == nil || !*t.Enabled {
22+
return t.next().RoundTrip(request)
23+
}
1724
requestDump, err := httputil.DumpRequestOut(request, true)
1825
if err != nil {
1926
return nil, fmt.Errorf("failed to dump request for debug: %w", err)
2027
}
2128
prettyPrintDump(os.Stderr, requestDump, "> ")
22-
response, err := t.next.RoundTrip(request)
29+
response, err := t.next().RoundTrip(request)
2330
if err != nil {
2431
return nil, err
2532
}
@@ -31,6 +38,13 @@ func (t *debugTransport) RoundTrip(request *http.Request) (*http.Response, error
3138
return response, nil
3239
}
3340

41+
func (t *DebugTransport) next() http.RoundTripper {
42+
if t.Next != nil {
43+
return t.Next
44+
}
45+
return http.DefaultTransport
46+
}
47+
3448
func prettyPrintDump(w io.Writer, dump []byte, prefix string) {
3549
var output bytes.Buffer
3650
output.Grow(len(dump) * 2)

0 commit comments

Comments
 (0)