Skip to content

Commit 380e74f

Browse files
committed
feat(cli): add reusable cli package
- Extract CLI commands into cli/ package with injected Store interface - cmd/mbz/main.go is now a thin wrapper over cli.NewCommand() - Split single auth.json into separate credentials.json + token.json - Document CLI architecture and dual auth model in AGENTS.md - Migrate to charm.land/fang/v2 v2.0.1 and charm.land/lipgloss/v2 v2.0.2
1 parent f491797 commit 380e74f

File tree

11 files changed

+1033
-880
lines changed

11 files changed

+1033
-880
lines changed

AGENTS.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,51 @@
11
# Agent Instructions
22

33
## Package Manager
4+
45
Use **Go Modules**: `go mod tidy`, `go test ./...`
56
Use **Mage**: `go tool mage [target]` (e.g. `go tool mage Build`)
67

78
## Key Conventions
9+
810
- **Testing**: Use standard `testing` and `github.com/google/go-cmp/cmp` **only**. No frameworks (Testify, Ginkgo, etc.).
911
- **Linting**: Run `GolangCI-Lint` v2. Configure via project-specific `.golangci.yml`.
1012
- **Build**: Use `way-magefile` skill.
1113
- **Encore**: Use `encore-go-*` skills. Encore conventions (e.g., globals) take precedence.
1214

1315
## Local Skills
16+
1417
- **Way Go Style**: Use `.agents/skills/way-go-style/SKILL.md`
1518
- **Way Magefile**: Use `.agents/skills/way-magefile/SKILL.md`
1619
- **Agents.md**: Use `.agents/skills/agents-md/SKILL.md`
20+
21+
## CLI Architecture
22+
23+
The CLI is split into two layers to keep credential storage pluggable:
24+
25+
```
26+
cli/
27+
├── cli.go # Store interface, Credentials, FileStore, Options
28+
└── command.go # NewCommand() — full command tree
29+
cmd/mbz/
30+
└── main.go # Thin wrapper: wires FileStore to XDG paths
31+
```
32+
33+
- `cli.Store` — interface with `Read(any)`, `Write(any)`, `Clear()` methods
34+
- `cli.NewCommand(...Option)` — builds the Cobra command tree; receives stores via functional options (`WithCredentialStore`, `WithTokenStore`)
35+
- `cmd/mbz/main.go` — only wires `FileStore` instances and calls `cli.NewCommand()`
36+
37+
This separation lets consumers embed the CLI in a larger tool or swap the storage backend (e.g. use an in-memory store in tests, or a keychain-backed store) without forking.
38+
39+
### Dual Authentication
40+
41+
The Mercedes-Benz API uses two auth methods depending on the endpoint:
42+
43+
- **OAuth2 client credentials** — for vehicle management, data services, delta push
44+
- **API key** — for vehicle specification and images
45+
46+
Both are stored in the credential store. The OAuth2 token is cached separately in the token store.
47+
48+
### Conventions
49+
50+
- Subcommands are organized by entity using `cobra.Group`
51+
- Flat command structure: `vehicles`, `services`, not `vehicles list`

cli/cli.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
)
9+
10+
// Store reads and writes JSON-serializable data.
11+
type Store interface {
12+
Read(target any) error
13+
Write(data any) error
14+
Clear() error
15+
}
16+
17+
// Credentials for Mercedes-Benz API authentication.
18+
type Credentials struct {
19+
Region string `json:"region"`
20+
ClientID string `json:"clientId,omitempty"`
21+
ClientSecret string `json:"clientSecret,omitempty"`
22+
APIKey string `json:"apiKey,omitempty"`
23+
}
24+
25+
// Option configures the CLI command tree.
26+
type Option func(*config)
27+
28+
type config struct {
29+
credentialStore Store
30+
tokenStore Store
31+
}
32+
33+
// WithCredentialStore sets the credential store.
34+
func WithCredentialStore(s Store) Option {
35+
return func(c *config) { c.credentialStore = s }
36+
}
37+
38+
// WithTokenStore sets the token store.
39+
func WithTokenStore(s Store) Option {
40+
return func(c *config) { c.tokenStore = s }
41+
}
42+
43+
// FileStore is a JSON file-backed store.
44+
type FileStore struct {
45+
path string
46+
}
47+
48+
// NewFileStore creates a new file-backed store at the given path.
49+
func NewFileStore(path string) *FileStore {
50+
return &FileStore{path: path}
51+
}
52+
53+
// Read unmarshals the file contents into target.
54+
func (s *FileStore) Read(target any) error {
55+
data, err := os.ReadFile(s.path)
56+
if err != nil {
57+
return fmt.Errorf("read store: %w", err)
58+
}
59+
if err := json.Unmarshal(data, target); err != nil {
60+
return fmt.Errorf("unmarshal store: %w", err)
61+
}
62+
return nil
63+
}
64+
65+
// Write marshals data and writes it to the file.
66+
func (s *FileStore) Write(data any) error {
67+
bytes, err := json.MarshalIndent(data, "", " ")
68+
if err != nil {
69+
return fmt.Errorf("marshal store: %w", err)
70+
}
71+
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
72+
return fmt.Errorf("create store dir: %w", err)
73+
}
74+
return os.WriteFile(s.path, bytes, 0o600)
75+
}
76+
77+
// Clear removes the file.
78+
func (s *FileStore) Clear() error {
79+
err := os.Remove(s.path)
80+
if err != nil && os.IsNotExist(err) {
81+
return nil
82+
}
83+
return err
84+
}

0 commit comments

Comments
 (0)