Skip to content

Commit 55115ed

Browse files
committed
feat(cmd): add JSON output mode for programmatic parsing
- Add global --json flag for structured output - Create CommandOutput struct with success/error/claims/token fields - Update all encode commands to output JSON when --json flag is used - Update all decode commands to output JSON when --json flag is used - Maintain backward compatibility with human-readable output as default - Add SilenceErrors and SilenceUsage to prevent duplicate error messages - Ensure consistent exit codes (0 for success, 1 for error) Resolves #58
1 parent 178c22e commit 55115ed

8 files changed

Lines changed: 130 additions & 15 deletions

File tree

cmd/decode-es.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package cmd
22

33
import (
4+
"encoding/json"
5+
"errors"
46
"fmt"
57

68
"github.com/sgaunet/jwt-cli/pkg/cryptojwt"
@@ -71,11 +73,19 @@ Tip: The token is the three-part string (header.payload.signature) produced by t
7173
j = privKeyDecoderWithValidation(privateKeyFile, validationOpts)
7274
}
7375

74-
t, err := j.Decode(token)
76+
claims, err := j.Decode(token)
7577
if err != nil {
76-
return fmt.Errorf("decoding failed: %w", err)
78+
errMsg := fmt.Sprintf("decoding failed: %v", err)
79+
output(CommandOutput{Success: false, Error: errMsg})
80+
return errors.New(errMsg)
7781
}
78-
fmt.Println(t)
82+
// Parse claims string as JSON for structured output
83+
var claimsData any
84+
if err := json.Unmarshal([]byte(claims), &claimsData); err != nil {
85+
// If claims aren't valid JSON, treat as raw string
86+
claimsData = claims
87+
}
88+
output(CommandOutput{Success: true, Claims: claimsData})
7989
return nil
8090
},
8191
}

cmd/decode-hs.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package cmd
22

33
import (
4+
"encoding/json"
5+
"errors"
46
"fmt"
57

68
"github.com/sgaunet/jwt-cli/pkg/cryptojwt"
@@ -58,11 +60,19 @@ Tip: The token is the three-part string (header.payload.signature) produced by t
5860
}
5961

6062
j := decoderWithValidation([]byte(secret), allowWeakSecret, validationOpts)
61-
t, err := j.Decode(token)
63+
claims, err := j.Decode(token)
6264
if err != nil {
63-
return fmt.Errorf("decoding failed: %w", err)
65+
errMsg := fmt.Sprintf("decoding failed: %v", err)
66+
output(CommandOutput{Success: false, Error: errMsg})
67+
return errors.New(errMsg)
6468
}
65-
fmt.Println(t)
69+
// Parse claims string as JSON for structured output
70+
var claimsData any
71+
if err := json.Unmarshal([]byte(claims), &claimsData); err != nil {
72+
// If claims aren't valid JSON, treat as raw string
73+
claimsData = claims
74+
}
75+
output(CommandOutput{Success: true, Claims: claimsData})
6676
return nil
6777
},
6878
}

cmd/decode-rs.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package cmd
22

33
import (
4+
"encoding/json"
5+
"errors"
46
"fmt"
57

68
"github.com/sgaunet/jwt-cli/pkg/cryptojwt"
@@ -70,11 +72,19 @@ Tip: The token is the three-part string (header.payload.signature) produced by t
7072
j = privKeyDecoderWithValidation(privateKeyFile, validationOpts)
7173
}
7274

73-
t, err := j.Decode(token)
75+
claims, err := j.Decode(token)
7476
if err != nil {
75-
return fmt.Errorf("decoding failed: %w", err)
77+
errMsg := fmt.Sprintf("decoding failed: %v", err)
78+
output(CommandOutput{Success: false, Error: errMsg})
79+
return errors.New(errMsg)
7680
}
77-
fmt.Println(t)
81+
// Parse claims string as JSON for structured output
82+
var claimsData any
83+
if err := json.Unmarshal([]byte(claims), &claimsData); err != nil {
84+
// If claims aren't valid JSON, treat as raw string
85+
claimsData = claims
86+
}
87+
output(CommandOutput{Success: true, Claims: claimsData})
7888
return nil
7989
},
8090
}

cmd/encode-es.go

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

33
import (
4+
"errors"
45
"fmt"
56

67
"github.com/sgaunet/jwt-cli/pkg/cryptojwt"
@@ -54,9 +55,11 @@ Tip: Payload must be valid JSON. Common claims include 'sub' (subject), 'exp' (e
5455
j := encoder(privateKeyFile)
5556
t, err := j.Encode(payload)
5657
if err != nil {
57-
return fmt.Errorf("encoding failed: %w", err)
58+
errMsg := fmt.Sprintf("encoding failed: %v", err)
59+
output(CommandOutput{Success: false, Error: errMsg})
60+
return errors.New(errMsg)
5861
}
59-
fmt.Println(t)
62+
output(CommandOutput{Success: true, Token: t})
6063
return nil
6164
},
6265
}

cmd/encode-hs.go

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

33
import (
4+
"errors"
45
"fmt"
56

67
"github.com/sgaunet/jwt-cli/pkg/cryptojwt"
@@ -52,9 +53,11 @@ Tip: Payload must be valid JSON. Common claims include 'sub' (subject), 'exp' (e
5253
j := encoderWithOpts([]byte(secret), allowWeakSecret)
5354
t, err := j.Encode(payload)
5455
if err != nil {
55-
return fmt.Errorf("encoding failed: %w", err)
56+
errMsg := fmt.Sprintf("encoding failed: %v", err)
57+
output(CommandOutput{Success: false, Error: errMsg})
58+
return errors.New(errMsg)
5659
}
57-
fmt.Println(t)
60+
output(CommandOutput{Success: true, Token: t})
5861
return nil
5962
},
6063
}

cmd/encode-rs.go

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

33
import (
4+
"errors"
45
"fmt"
56

67
"github.com/sgaunet/jwt-cli/pkg/cryptojwt"
@@ -53,9 +54,11 @@ Tip: Payload must be valid JSON. Common claims include 'sub' (subject), 'exp' (e
5354
j := encoder(privateKeyFile)
5455
t, err := j.Encode(payload)
5556
if err != nil {
56-
return fmt.Errorf("encoding failed: %w", err)
57+
errMsg := fmt.Sprintf("encoding failed: %v", err)
58+
output(CommandOutput{Success: false, Error: errMsg})
59+
return errors.New(errMsg)
5760
}
58-
fmt.Println(t)
61+
output(CommandOutput{Success: true, Token: t})
5962
return nil
6063
},
6164
}

cmd/output.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
)
8+
9+
// CommandOutput represents the structured output format for all commands.
10+
// It provides a consistent interface for both JSON and human-readable output modes.
11+
type CommandOutput struct {
12+
// Success indicates whether the operation completed successfully
13+
Success bool `json:"success"`
14+
15+
// Data holds generic output data (rarely used, reserved for future extensions)
16+
Data any `json:"data,omitempty"`
17+
18+
// Claims holds the decoded JWT claims/payload
19+
Claims any `json:"claims,omitempty"`
20+
21+
// Token holds the encoded JWT token string
22+
Token string `json:"token,omitempty"`
23+
24+
// Error holds the error message if Success is false
25+
Error string `json:"error,omitempty"`
26+
}
27+
28+
// output writes the command result to stdout in either JSON or human-readable format.
29+
// In JSON mode, it always outputs valid JSON regardless of success/failure.
30+
// In human-readable mode, errors are written to stderr.
31+
// Note: This function does NOT call os.Exit(). Exit handling is done by the caller
32+
// through Cobra's error return mechanism and root.Execute().
33+
func output(out CommandOutput) {
34+
if jsonOutput {
35+
outputJSON(out)
36+
return
37+
}
38+
outputHumanReadable(out)
39+
}
40+
41+
// outputJSON writes the output in JSON format.
42+
func outputJSON(out CommandOutput) {
43+
enc := json.NewEncoder(os.Stdout)
44+
enc.SetIndent("", " ")
45+
if err := enc.Encode(out); err != nil {
46+
// This should never happen, but if JSON encoding fails, output a minimal error
47+
fmt.Fprintf(os.Stderr, `{"success":false,"error":"failed to encode JSON output: %s"}`+"\n", err)
48+
}
49+
}
50+
51+
// outputHumanReadable writes the output in human-readable format.
52+
func outputHumanReadable(out CommandOutput) {
53+
if out.Error != "" {
54+
fmt.Fprintln(os.Stderr, out.Error)
55+
return
56+
}
57+
if out.Token != "" {
58+
fmt.Println(out.Token)
59+
}
60+
if out.Claims != nil {
61+
enc := json.NewEncoder(os.Stdout)
62+
enc.SetIndent("", " ")
63+
if err := enc.Encode(out.Claims); err != nil {
64+
fmt.Fprintf(os.Stderr, "failed to encode claims: %s\n", err)
65+
}
66+
}
67+
}

cmd/root.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import (
2222
"github.com/spf13/cobra"
2323
)
2424

25+
// jsonOutput controls whether to output in JSON format or human-readable format.
26+
var jsonOutput bool
27+
2528
// rootCmd represents the base command when called without any subcommands.
2629
var rootCmd = &cobra.Command{
2730
Use: "jwt-cli",
@@ -46,6 +49,9 @@ Use RSA or ECDSA for scenarios requiring public/private key pairs.`,
4649
4750
# Encode with RS256 using private key
4851
jwt-cli encode rs256 --payload '{"user":"alice"}' --private-key RS256.key`,
52+
// Silence errors because the output() function already prints them appropriately
53+
SilenceErrors: true,
54+
SilenceUsage: true,
4955
}
5056

5157
// Execute runs the root command.
@@ -58,6 +64,9 @@ func Execute() {
5864

5965
//nolint:funlen // init function requires many statements for command setup
6066
func init() {
67+
// Global flags for all commands
68+
rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "output in JSON format")
69+
6170
rootCmd.AddCommand(encodeCmd)
6271
rootCmd.CompletionOptions.DisableDefaultCmd = false
6372
encodeCmd.PersistentFlags().StringP("payload", "p", "", "JSON payload to encode into JWT (e.g., '{\"user\":\"alice\"}')")

0 commit comments

Comments
 (0)