Skip to content

Commit b616282

Browse files
Dumbrisclaude
andcommitted
feat: implement CLI output formatting system (Spec 014)
Add unified output formatting for CLI commands with support for table, JSON, and YAML formats. Key features: - Global `-o/--output` flag for format selection (table/json/yaml) - `--json` shorthand for `-o json` - `--help-json` flag for machine-readable command discovery - `MCPPROXY_OUTPUT` environment variable for default format - Structured error output with recovery hints Implementation: - New `internal/cli/output/` package with OutputFormatter interface - JSONFormatter: pretty-printed, snake_case fields, empty arrays as [] - TableFormatter: aligned columns, Unicode indicators, NO_COLOR support - YAMLFormatter: using yaml.v3 - HelpInfo types for structured help output Migrated commands: - upstream list: full format support - tools list: full format support - secrets list: full format support (removed local --json flag) - call tool: JSON formatter for -o json Documentation: - docs/cli-output-formatting.md: complete usage guide - Updated CLAUDE.md with output formatting section 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 76a4d6b commit b616282

28 files changed

Lines changed: 3868 additions & 197 deletions

CLAUDE.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,19 @@ mcpproxy doctor # Run health checks
6161

6262
See [docs/cli-management-commands.md](docs/cli-management-commands.md) for complete reference.
6363

64+
### CLI Output Formatting
65+
```bash
66+
mcpproxy upstream list -o json # JSON output for scripting
67+
mcpproxy upstream list -o yaml # YAML output
68+
mcpproxy upstream list --json # Shorthand for -o json
69+
mcpproxy --help-json # Machine-readable help for AI agents
70+
```
71+
72+
**Formats**: `table` (default), `json`, `yaml`
73+
**Environment**: `MCPPROXY_OUTPUT=json` sets default format
74+
75+
See [docs/cli-output-formatting.md](docs/cli-output-formatting.md) for complete reference.
76+
6477
## Architecture Overview
6578

6679
### Core Components
@@ -69,6 +82,7 @@ See [docs/cli-management-commands.md](docs/cli-management-commands.md) for compl
6982
|-----------|---------|
7083
| `cmd/mcpproxy/` | CLI entry point, Cobra commands |
7184
| `cmd/mcpproxy-tray/` | System tray application with state machine |
85+
| `internal/cli/output/` | CLI output formatters (table, JSON, YAML) |
7286
| `internal/runtime/` | Lifecycle, event bus, background services |
7387
| `internal/server/` | HTTP server, MCP proxy |
7488
| `internal/httpapi/` | REST API endpoints (`/api/v1`) |
@@ -333,6 +347,8 @@ See `docs/prerelease-builds.md` for download instructions.
333347
## Active Technologies
334348
- Go 1.24 (toolchain go1.24.10) (001-update-version-display)
335349
- In-memory only for version cache (no persistence per clarification) (001-update-version-display)
350+
- Go 1.24 (toolchain go1.24.10) + Cobra CLI framework, encoding/json, gopkg.in/yaml.v3 (014-cli-output-formatting)
351+
- N/A (CLI output only) (014-cli-output-formatting)
336352

337353
## Recent Changes
338354
- 001-update-version-display: Added Go 1.24 (toolchain go1.24.10)

cmd/mcpproxy/call_cmd.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"time"
1111

1212
"mcpproxy-go/internal/cache"
13+
"mcpproxy-go/internal/cli/output"
1314
"mcpproxy-go/internal/cliclient"
1415
"mcpproxy-go/internal/config"
1516
"mcpproxy-go/internal/index"
@@ -25,6 +26,8 @@ import (
2526
"go.uber.org/zap"
2627
)
2728

29+
// Call command output format constants (kept for backward compatibility)
30+
// Note: "pretty" is the default for call command (different from global "table" default)
2831
const (
2932
outputFormatJSON = "json"
3033
outputFormatPretty = "pretty"
@@ -208,13 +211,14 @@ func loadCallConfig() (*config.Config, error) {
208211
return globalConfig, nil
209212
}
210213

211-
// outputCallResultAsJSON outputs the result in JSON format
214+
// outputCallResultAsJSON outputs the result in JSON format using unified formatter
212215
func outputCallResultAsJSON(result interface{}) error {
213-
output, err := json.MarshalIndent(result, "", " ")
216+
formatter := &output.JSONFormatter{Indent: true}
217+
formatted, err := formatter.Format(result)
214218
if err != nil {
215219
return fmt.Errorf("failed to format result as JSON: %w", err)
216220
}
217-
fmt.Println(string(output))
221+
fmt.Println(formatted)
218222
return nil
219223
}
220224

cmd/mcpproxy/main.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import (
3838
bbolterrors "go.etcd.io/bbolt/errors"
3939
"go.uber.org/zap"
4040

41+
"mcpproxy-go/internal/cli/output"
4142
"mcpproxy-go/internal/config"
4243
"mcpproxy-go/internal/experiments"
4344
"mcpproxy-go/internal/logs"
@@ -66,6 +67,10 @@ var (
6667
allowServerRemove bool
6768
enablePrompts bool
6869

70+
// Output formatting flags (global)
71+
globalOutputFormat string
72+
globalJSONOutput bool
73+
6974
version = "v0.1.0" // This will be injected by -ldflags during build
7075
)
7176

@@ -98,6 +103,11 @@ func main() {
98103
rootCmd.PersistentFlags().BoolVar(&logToFile, "log-to-file", false, "Enable logging to file in standard OS location (default: console only)")
99104
rootCmd.PersistentFlags().StringVar(&logDir, "log-dir", "", "Custom log directory path (overrides standard OS location)")
100105

106+
// Output formatting flags (global)
107+
rootCmd.PersistentFlags().StringVarP(&globalOutputFormat, "output", "o", "", "Output format: table, json, yaml")
108+
rootCmd.PersistentFlags().BoolVar(&globalJSONOutput, "json", false, "Shorthand for -o json")
109+
rootCmd.MarkFlagsMutuallyExclusive("output", "json")
110+
101111
// Add server command
102112
serverCmd := &cobra.Command{
103113
Use: "serve",
@@ -157,6 +167,10 @@ func main() {
157167
rootCmd.AddCommand(upstreamCmd)
158168
rootCmd.AddCommand(doctorCmd)
159169

170+
// Setup --help-json for machine-readable help discovery
171+
// This must be called AFTER all commands are added
172+
output.SetupHelpJSON(rootCmd)
173+
160174
// Default to server command for backward compatibility
161175
rootCmd.RunE = runServer
162176

@@ -633,3 +647,14 @@ func classifyError(err error) int {
633647
// Default to general error
634648
return ExitCodeGeneralError
635649
}
650+
651+
// ResolveOutputFormat determines the output format from global flags and environment.
652+
// Priority: --json alias > -o flag > MCPPROXY_OUTPUT env var > default (table)
653+
func ResolveOutputFormat() string {
654+
return output.ResolveFormat(globalOutputFormat, globalJSONOutput)
655+
}
656+
657+
// GetOutputFormatter creates a formatter for the resolved output format.
658+
func GetOutputFormatter() (output.OutputFormatter, error) {
659+
return output.NewFormatter(ResolveOutputFormat())
660+
}

cmd/mcpproxy/secrets_cmd.go

Lines changed: 41 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ package main
22

33
import (
44
"context"
5-
"encoding/json"
65
"fmt"
76
"os"
87
"strings"
98
"time"
109

1110
"github.com/spf13/cobra"
1211

12+
"mcpproxy-go/internal/cli/output"
1313
"mcpproxy-go/internal/config"
1414
"mcpproxy-go/internal/logs"
1515
"mcpproxy-go/internal/secret"
@@ -185,10 +185,7 @@ func getSecretsDeleteCommand() *cobra.Command {
185185

186186
// getSecretsListCommand returns the secrets list command
187187
func getSecretsListCommand() *cobra.Command {
188-
var (
189-
jsonOutput bool
190-
allTypes bool
191-
)
188+
var allTypes bool
192189

193190
cmd := &cobra.Command{
194191
Use: "list",
@@ -199,58 +196,70 @@ func getSecretsListCommand() *cobra.Command {
199196
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
200197
defer cancel()
201198

199+
// Get output format from global flags
200+
outputFormat := ResolveOutputFormat()
201+
formatter, err := GetOutputFormatter()
202+
if err != nil {
203+
return output.NewStructuredError(output.ErrCodeInvalidOutputFormat, err.Error()).
204+
WithGuidance("Use -o table, -o json, or -o yaml")
205+
}
206+
207+
var refs []secret.Ref
208+
var providerName string
209+
202210
if allTypes {
203211
// List from all available providers
204-
refs, err := resolver.ListAll(ctx)
212+
refs, err = resolver.ListAll(ctx)
205213
if err != nil {
206214
return fmt.Errorf("failed to list secrets: %w", err)
207215
}
208-
209-
if jsonOutput {
210-
return json.NewEncoder(os.Stdout).Encode(refs)
211-
}
212-
213-
if len(refs) == 0 {
214-
fmt.Println("No secrets found")
215-
return nil
216-
}
217-
218-
fmt.Printf("Found %d secrets:\n", len(refs))
219-
for _, ref := range refs {
220-
fmt.Printf(" %s (%s)\n", ref.Name, ref.Type)
221-
}
216+
providerName = "all providers"
222217
} else {
223218
// List from keyring only
224219
keyringProvider := secret.NewKeyringProvider()
225220
if !keyringProvider.IsAvailable() {
226221
return fmt.Errorf("keyring is not available on this system")
227222
}
228223

229-
refs, err := keyringProvider.List(ctx)
224+
refs, err = keyringProvider.List(ctx)
230225
if err != nil {
231226
return fmt.Errorf("failed to list keyring secrets: %w", err)
232227
}
228+
providerName = "keyring"
229+
}
233230

234-
if jsonOutput {
235-
return json.NewEncoder(os.Stdout).Encode(refs)
231+
// Handle JSON/YAML output
232+
if outputFormat == "json" || outputFormat == "yaml" {
233+
result, fmtErr := formatter.Format(refs)
234+
if fmtErr != nil {
235+
return fmt.Errorf("failed to format output: %w", fmtErr)
236236
}
237+
fmt.Println(result)
238+
return nil
239+
}
237240

238-
if len(refs) == 0 {
239-
fmt.Println("No secrets found in keyring")
240-
return nil
241-
}
241+
// Table output
242+
if len(refs) == 0 {
243+
fmt.Printf("No secrets found in %s\n", providerName)
244+
return nil
245+
}
242246

243-
fmt.Printf("Found %d secrets in keyring:\n", len(refs))
244-
for _, ref := range refs {
245-
fmt.Printf(" %s\n", ref.Name)
246-
}
247+
headers := []string{"NAME", "TYPE"}
248+
var rows [][]string
249+
for _, ref := range refs {
250+
rows = append(rows, []string{ref.Name, ref.Type})
247251
}
248252

253+
result, fmtErr := formatter.FormatTable(headers, rows)
254+
if fmtErr != nil {
255+
return fmt.Errorf("failed to format table: %w", fmtErr)
256+
}
257+
fmt.Print(result)
249258
return nil
250259
},
251260
}
252261

253-
cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
262+
// Note: --json flag removed, use global -o json instead
254263
cmd.Flags().BoolVar(&allTypes, "all", false, "List secrets from all available providers")
255264

256265
return cmd

0 commit comments

Comments
 (0)