Skip to content

Commit 0b271fa

Browse files
somanshreddyclaude
andcommitted
core types + interfaces: CLIError, RequestSpec, Formatter, CredentialResolver, Provider
Stable types and interfaces that codegen and framework features build against: - CLIError with exit codes (0/1/2/3), ToErrorEnvelope for JSON output - APIError matching HeyGen's standard error envelope - FromAPIError mapping HTTP status → exit code - RequestSpec: []QueryParam, []FieldSpec, BodyEncoding, FilePath, *PollConfig - output.Formatter interface (Data + Error) - auth.CredentialResolver interface (priority chain for API keys) - config.Provider interface (non-secret settings, BaseURL only) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c9aaa10 commit 0b271fa

7 files changed

Lines changed: 371 additions & 0 deletions

File tree

‎internal/auth/resolver.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package auth
2+
3+
// CredentialResolver locates an API key by searching a priority chain of
4+
// credential sources (environment variable, OS keychain, credentials file).
5+
// The first source that returns a key wins.
6+
//
7+
// Currently only EnvCredentialResolver is implemented (reads HEYGEN_API_KEY).
8+
type CredentialResolver interface {
9+
Resolve() (string, error)
10+
}

‎internal/client/request_spec.go‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package client
2+
3+
// RequestSpec is the contract between commands and the HTTP executor.
4+
// A command (hand-written or auto-generated) populates a RequestSpec;
5+
// the executor converts it to an HTTP request, handles the response,
6+
// and returns raw JSON.
7+
//
8+
// The full struct is defined upfront so that codegen, pagination, polling,
9+
// and multipart upload can rely on a stable shape.
10+
type RequestSpec struct {
11+
Endpoint string // "/v3/videos"
12+
Method string // GET, POST, DELETE, PATCH
13+
PathParams map[string]string // {video_id: "abc123"}
14+
QueryParams []QueryParam // typed query params (supports repeated keys)
15+
Body []FieldSpec // typed body fields (supports nested objects)
16+
BodyEncoding string // "json" (default) or "multipart"
17+
FilePath string // local file path for multipart upload
18+
Paginated bool // enables --all accumulation
19+
TokenField string // pagination cursor field in response
20+
DataField string // response array field name
21+
Pollable bool // enables --wait
22+
PollConfig *PollConfig // status field, terminal states
23+
Destructive bool // triggers confirmation prompt (--force skips)
24+
Columns []Column // TUI table column definitions
25+
}
26+
27+
// QueryParam represents a single query parameter. Repeated allows
28+
// multiple values for the same key (e.g., ?status=a&status=b).
29+
type QueryParam struct {
30+
Key string
31+
Value string
32+
Repeated bool
33+
}
34+
35+
// FieldSpec represents a typed body field for JSON request bodies.
36+
// The codegen pipeline emits these from OpenAPI request body schemas.
37+
type FieldSpec struct {
38+
Name string // JSON field name
39+
Type string // "string", "int", "bool", "object", "array"
40+
Value any // typed value from flag
41+
Required bool
42+
}
43+
44+
// PollConfig defines how --wait polling works for async commands.
45+
type PollConfig struct {
46+
StatusEndpoint string // GET endpoint to check status
47+
StatusField string // JSON field containing status (e.g., "status")
48+
TerminalOK []string // Success states: ["completed"]
49+
TerminalFail []string // Failure states: ["failed", "error"]
50+
IDField string // Field in create response with resource ID
51+
}
52+
53+
// Column defines a TUI table column for --human output.
54+
type Column struct {
55+
Header string // Table column header ("Status")
56+
Field string // JSON field path, supports dot notation ("avatar.name")
57+
Width int // Optional fixed width (0 = auto-size)
58+
}

‎internal/config/config.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package config
2+
3+
// Provider supplies non-secret configuration values using a precedence chain:
4+
// CLI flag > environment variable > config file > built-in default.
5+
//
6+
// Credentials are not part of config — see auth.CredentialResolver.
7+
// Currently only EnvProvider is implemented (reads HEYGEN_API_BASE env var).
8+
type Provider interface {
9+
BaseURL() string
10+
}

‎internal/errors/api_error.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package errors
2+
3+
// APIError represents the HeyGen standard error envelope.
4+
// Maps to: {"error": {"code": ..., "message": ..., "param": ..., "doc_url": ...}}
5+
type APIError struct {
6+
Code string `json:"code"`
7+
Message string `json:"message"`
8+
Param *string `json:"param,omitempty"`
9+
DocURL *string `json:"doc_url,omitempty"`
10+
}

‎internal/errors/errors.go‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package errors
2+
3+
import "fmt"
4+
5+
// Exit codes. Kept minimal — the primary consumer is an LLM agent that reads
6+
// the error JSON for details; exit codes just provide coarse routing.
7+
const (
8+
ExitSuccess = 0
9+
ExitGeneral = 1
10+
ExitUsage = 2
11+
ExitAuth = 3
12+
)
13+
14+
// CLIError is the canonical error type for all CLI operations.
15+
type CLIError struct {
16+
Code string // machine-readable: "auth_error", "not_found", "network_error"
17+
Message string // human-readable description
18+
Hint string // actionable fix: "Run heygen auth login"
19+
RequestID string // from API X-Request-Id header (if applicable)
20+
ExitCode int // process exit code (0/1/2/3)
21+
}
22+
23+
// Error implements the error interface.
24+
func (e *CLIError) Error() string {
25+
if e.Hint != "" {
26+
return fmt.Sprintf("%s (hint: %s)", e.Message, e.Hint)
27+
}
28+
return e.Message
29+
}
30+
31+
// ToErrorEnvelope returns the canonical JSON error envelope shape:
32+
// {"error": {"code": ..., "message": ..., "hint": ..., "request_id": ...}}
33+
func (e *CLIError) ToErrorEnvelope() map[string]any {
34+
inner := map[string]any{
35+
"code": e.Code,
36+
"message": e.Message,
37+
}
38+
if e.Hint != "" {
39+
inner["hint"] = e.Hint
40+
}
41+
if e.RequestID != "" {
42+
inner["request_id"] = e.RequestID
43+
}
44+
return map[string]any{"error": inner}
45+
}
46+
47+
// New creates a CLIError with ExitGeneral.
48+
func New(message string) *CLIError {
49+
return &CLIError{
50+
Code: "error",
51+
Message: message,
52+
ExitCode: ExitGeneral,
53+
}
54+
}
55+
56+
// NewAuth creates a CLIError with ExitAuth.
57+
func NewAuth(message, hint string) *CLIError {
58+
return &CLIError{
59+
Code: "auth_error",
60+
Message: message,
61+
Hint: hint,
62+
ExitCode: ExitAuth,
63+
}
64+
}
65+
66+
// NewUsage creates a CLIError with ExitUsage.
67+
func NewUsage(message string) *CLIError {
68+
return &CLIError{
69+
Code: "usage_error",
70+
Message: message,
71+
ExitCode: ExitUsage,
72+
}
73+
}
74+
75+
// FromAPIError converts an API error envelope and HTTP status code into a CLIError.
76+
func FromAPIError(statusCode int, apiErr *APIError, requestID string) *CLIError {
77+
exitCode := ExitGeneral
78+
code := apiErr.Code
79+
80+
switch {
81+
case statusCode == 401 || statusCode == 403:
82+
exitCode = ExitAuth
83+
if code == "" {
84+
code = "auth_error"
85+
}
86+
case statusCode == 400:
87+
if code == "" {
88+
code = "validation_error"
89+
}
90+
case statusCode == 404:
91+
if code == "" {
92+
code = "not_found"
93+
}
94+
case statusCode == 429:
95+
if code == "" {
96+
code = "rate_limited"
97+
}
98+
case statusCode >= 500:
99+
if code == "" {
100+
code = "server_error"
101+
}
102+
default:
103+
if code == "" {
104+
code = "error"
105+
}
106+
}
107+
108+
return &CLIError{
109+
Code: code,
110+
Message: apiErr.Message,
111+
RequestID: requestID,
112+
ExitCode: exitCode,
113+
}
114+
}

‎internal/errors/errors_test.go‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package errors
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
)
7+
8+
func TestCLIError_Error(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
err *CLIError
12+
want string
13+
}{
14+
{
15+
name: "message only",
16+
err: &CLIError{Message: "something failed"},
17+
want: "something failed",
18+
},
19+
{
20+
name: "message with hint",
21+
err: &CLIError{Message: "auth failed", Hint: "run heygen auth login"},
22+
want: "auth failed (hint: run heygen auth login)",
23+
},
24+
}
25+
for _, tt := range tests {
26+
t.Run(tt.name, func(t *testing.T) {
27+
if got := tt.err.Error(); got != tt.want {
28+
t.Errorf("Error() = %q, want %q", got, tt.want)
29+
}
30+
})
31+
}
32+
}
33+
34+
func TestCLIError_ToErrorEnvelope(t *testing.T) {
35+
err := &CLIError{
36+
Code: "not_found",
37+
Message: "Video abc123 not found",
38+
Hint: "Check the video ID with: heygen video list",
39+
RequestID: "req_xyz",
40+
}
41+
42+
envelope := err.ToErrorEnvelope()
43+
44+
// Marshal and re-parse to verify JSON shape
45+
data, marshalErr := json.Marshal(envelope)
46+
if marshalErr != nil {
47+
t.Fatalf("failed to marshal envelope: %v", marshalErr)
48+
}
49+
50+
var parsed map[string]map[string]string
51+
if jsonErr := json.Unmarshal(data, &parsed); jsonErr != nil {
52+
t.Fatalf("failed to unmarshal envelope: %v", jsonErr)
53+
}
54+
55+
inner := parsed["error"]
56+
if inner["code"] != "not_found" {
57+
t.Errorf("code = %q, want %q", inner["code"], "not_found")
58+
}
59+
if inner["message"] != "Video abc123 not found" {
60+
t.Errorf("message = %q, want %q", inner["message"], "Video abc123 not found")
61+
}
62+
if inner["hint"] != "Check the video ID with: heygen video list" {
63+
t.Errorf("hint = %q, want expected value", inner["hint"])
64+
}
65+
if inner["request_id"] != "req_xyz" {
66+
t.Errorf("request_id = %q, want %q", inner["request_id"], "req_xyz")
67+
}
68+
}
69+
70+
func TestCLIError_ToErrorEnvelope_OmitsEmpty(t *testing.T) {
71+
err := &CLIError{Code: "error", Message: "fail"}
72+
envelope := err.ToErrorEnvelope()
73+
inner := envelope["error"].(map[string]any)
74+
75+
if _, ok := inner["hint"]; ok {
76+
t.Error("hint should be omitted when empty")
77+
}
78+
if _, ok := inner["request_id"]; ok {
79+
t.Error("request_id should be omitted when empty")
80+
}
81+
}
82+
83+
func TestFromAPIError_ExitCodes(t *testing.T) {
84+
tests := []struct {
85+
name string
86+
statusCode int
87+
wantExit int
88+
wantCode string
89+
}{
90+
{"401 → auth", 401, ExitAuth, "auth_error"},
91+
{"403 → auth", 403, ExitAuth, "auth_error"},
92+
{"400 → general", 400, ExitGeneral, "validation_error"},
93+
{"404 → general", 404, ExitGeneral, "not_found"},
94+
{"429 → general", 429, ExitGeneral, "rate_limited"},
95+
{"500 → general", 500, ExitGeneral, "server_error"},
96+
{"502 → general", 502, ExitGeneral, "server_error"},
97+
{"503 → general", 503, ExitGeneral, "server_error"},
98+
}
99+
for _, tt := range tests {
100+
t.Run(tt.name, func(t *testing.T) {
101+
apiErr := &APIError{Message: "test error"}
102+
cliErr := FromAPIError(tt.statusCode, apiErr, "req_123")
103+
104+
if cliErr.ExitCode != tt.wantExit {
105+
t.Errorf("ExitCode = %d, want %d", cliErr.ExitCode, tt.wantExit)
106+
}
107+
if cliErr.Code != tt.wantCode {
108+
t.Errorf("Code = %q, want %q", cliErr.Code, tt.wantCode)
109+
}
110+
if cliErr.RequestID != "req_123" {
111+
t.Errorf("RequestID = %q, want %q", cliErr.RequestID, "req_123")
112+
}
113+
})
114+
}
115+
}
116+
117+
func TestFromAPIError_PreservesAPICode(t *testing.T) {
118+
apiErr := &APIError{Code: "custom_code", Message: "custom error"}
119+
cliErr := FromAPIError(400, apiErr, "")
120+
121+
if cliErr.Code != "custom_code" {
122+
t.Errorf("Code = %q, want %q (should preserve API code)", cliErr.Code, "custom_code")
123+
}
124+
}
125+
126+
func TestConstructors(t *testing.T) {
127+
t.Run("New", func(t *testing.T) {
128+
err := New("something broke")
129+
if err.ExitCode != ExitGeneral {
130+
t.Errorf("ExitCode = %d, want %d", err.ExitCode, ExitGeneral)
131+
}
132+
})
133+
134+
t.Run("NewAuth", func(t *testing.T) {
135+
err := NewAuth("no key", "set HEYGEN_API_KEY")
136+
if err.ExitCode != ExitAuth {
137+
t.Errorf("ExitCode = %d, want %d", err.ExitCode, ExitAuth)
138+
}
139+
if err.Hint != "set HEYGEN_API_KEY" {
140+
t.Errorf("Hint = %q, want expected value", err.Hint)
141+
}
142+
})
143+
144+
t.Run("NewUsage", func(t *testing.T) {
145+
err := NewUsage("bad flag")
146+
if err.ExitCode != ExitUsage {
147+
t.Errorf("ExitCode = %d, want %d", err.ExitCode, ExitUsage)
148+
}
149+
})
150+
}

‎internal/output/formatter.go‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package output
2+
3+
import (
4+
"encoding/json"
5+
6+
clierrors "github.com/heygen-com/heygen-cli/internal/errors"
7+
)
8+
9+
// Formatter controls how the CLI renders output. Successful data goes to
10+
// stdout; errors go to stderr as structured JSON envelopes.
11+
//
12+
// JSONFormatter is the default (machine-readable). A TUIFormatter can be
13+
// added behind the same interface to support --human tables and spinners.
14+
type Formatter interface {
15+
// Data writes a successful API response to stdout.
16+
Data(v json.RawMessage) error
17+
// Error writes a CLIError as a JSON envelope to stderr.
18+
Error(err *clierrors.CLIError)
19+
}

0 commit comments

Comments
 (0)