diff --git a/cmd/flutree/main.go b/cmd/flutree/main.go index 312ded3..a433c7f 100644 --- a/cmd/flutree/main.go +++ b/cmd/flutree/main.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "errors" "flag" "fmt" @@ -32,25 +33,64 @@ func main() { } cmd := os.Args[1] + // Check if --json flag is passed early to use JSON error formatting + jsonOutput := containsFlag(os.Args, "--json") + switch cmd { case "create": - runtime.ExitOnError(runCreate(os.Args[2:])) + if jsonOutput { + runtime.ExitOnErrorJSON(runCreate(os.Args[2:]), true) + } else { + runtime.ExitOnError(runCreate(os.Args[2:])) + } case "add-repo": - runtime.ExitOnError(runAddRepo(os.Args[2:])) + if jsonOutput { + runtime.ExitOnErrorJSON(runAddRepo(os.Args[2:]), true) + } else { + runtime.ExitOnError(runAddRepo(os.Args[2:])) + } case "list": - runtime.ExitOnError(runList(os.Args[2:])) + if jsonOutput { + runtime.ExitOnErrorJSON(runList(os.Args[2:]), true) + } else { + runtime.ExitOnError(runList(os.Args[2:])) + } case "complete": - runtime.ExitOnError(runComplete(os.Args[2:])) + if jsonOutput { + runtime.ExitOnErrorJSON(runComplete(os.Args[2:]), true) + } else { + runtime.ExitOnError(runComplete(os.Args[2:])) + } case "pubget": - runtime.ExitOnError(runPubGet(os.Args[2:])) + if jsonOutput { + runtime.ExitOnErrorJSON(runPubGet(os.Args[2:]), true) + } else { + runtime.ExitOnError(runPubGet(os.Args[2:])) + } case "clean": - runtime.ExitOnError(runClean(os.Args[2:])) + if jsonOutput { + runtime.ExitOnErrorJSON(runClean(os.Args[2:]), true) + } else { + runtime.ExitOnError(runClean(os.Args[2:])) + } case "update": - runtime.ExitOnError(runUpdate(os.Args[2:])) + if jsonOutput { + runtime.ExitOnErrorJSON(runUpdate(os.Args[2:]), true) + } else { + runtime.ExitOnError(runUpdate(os.Args[2:])) + } case "config": - runtime.ExitOnError(runConfig(os.Args[2:])) + if jsonOutput { + runtime.ExitOnErrorJSON(runConfig(os.Args[2:]), true) + } else { + runtime.ExitOnError(runConfig(os.Args[2:])) + } case "version", "--version": - runtime.ExitOnError(runVersion(os.Args[2:])) + if jsonOutput { + runtime.ExitOnErrorJSON(runVersion(os.Args[2:]), true) + } else { + runtime.ExitOnError(runVersion(os.Args[2:])) + } case "--help", "-h", "help": printHelp() default: @@ -58,10 +98,32 @@ func main() { } } +func containsFlag(args []string, flag string) bool { + for _, arg := range args { + if arg == flag { + return true + } + } + return false +} + +type commandResult struct { + data interface{} + err error + json bool +} + +func printJSON(data interface{}, w *os.File) { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + enc.Encode(data) +} + func runList(args []string) error { fs := newFlagSet("list", printListHelp) showAll := fs.Bool("all", false, "Include unmanaged Git worktrees.") globalScope := fs.Bool("global", false, "List managed worktrees across all registered repositories.") + jsonFlag := fs.Bool("json", false, "Output results as JSON.") if len(args) > 0 && isHelpToken(args[0]) { printListHelp() return nil @@ -79,6 +141,10 @@ func runList(args []string) error { if err != nil { return err } + if *jsonFlag { + printJSON(rows, os.Stdout) + return nil + } ui.RenderList(rows, *showAll) return nil } @@ -88,6 +154,7 @@ func runComplete(args []string) error { yes := fs.Bool("yes", false, "Skip interactive confirmation.") force := fs.Bool("force", false, "Force worktree removal.") nonInteractive := fs.Bool("non-interactive", false, "Disable prompts.") + jsonFlag := fs.Bool("json", false, "Output results as JSON.") if len(args) > 0 && isHelpToken(args[0]) { printCompleteHelp() return nil @@ -114,6 +181,10 @@ func runComplete(args []string) error { if err != nil { return err } + if *jsonFlag { + printJSON(result, os.Stdout) + return nil + } ui.RenderCompleteSuccess(result) return nil } @@ -130,6 +201,7 @@ func runCreate(args []string) error { nonInteractive := fs.Bool("non-interactive", false, "Disable prompts.") reuseExistingBranch := fs.Bool("reuse-existing-branch", false, "Allow non-interactive reuse when target branch already exists.") noPackage := fs.Bool("no-package", false, "Create root-only worktree without package selection.") + jsonFlag := fs.Bool("json", false, "Output results as JSON.") var packages multiFlag var packageBase multiFlag @@ -318,6 +390,10 @@ func runCreate(args []string) error { if err != nil { return err } + if *jsonFlag { + printJSON(result, os.Stdout) + return nil + } ui.RenderCreateSuccess(result) return nil } @@ -325,6 +401,7 @@ func runCreate(args []string) error { func runPubGet(args []string) error { fs := newFlagSet("pubget", printPubGetHelp) force := fs.Bool("force", false, "Clean cache and remove pubspec.lock before pub get.") + jsonFlag := fs.Bool("json", false, "Output results as JSON.") if len(args) > 0 && isHelpToken(args[0]) { printPubGetHelp() return nil @@ -352,7 +429,10 @@ func runPubGet(args []string) error { if err != nil { return err } - + if *jsonFlag { + printJSON(result, os.Stdout) + return nil + } ui.RenderPubGetSuccess(result) return nil } @@ -360,6 +440,7 @@ func runPubGet(args []string) error { func runClean(args []string) error { fs := newFlagSet("clean", printCleanHelp) force := fs.Bool("force", false, "Remove pubspec.lock after clean.") + jsonFlag := fs.Bool("json", false, "Output results as JSON.") if len(args) > 0 && isHelpToken(args[0]) { printCleanHelp() return nil @@ -377,7 +458,10 @@ func runClean(args []string) error { if err != nil { return err } - + if *jsonFlag { + printJSON(result, os.Stdout) + return nil + } ui.RenderCleanSuccess(result) return nil } @@ -388,6 +472,7 @@ func runAddRepo(args []string) error { syncPolicy := fs.String("sync-policy", string(domain.AddRepoSyncAuto), "Sync policy before worktree creation: auto|always|never.") nonInteractive := fs.Bool("non-interactive", false, "Disable prompts.") reuseExistingBranch := fs.Bool("reuse-existing-branch", false, "Allow non-interactive reuse when target branch already exists.") + jsonFlag := fs.Bool("json", false, "Output results as JSON.") var repos multiFlag var packageBranchSource multiFlag var packageBase multiFlag @@ -495,12 +580,17 @@ func runAddRepo(args []string) error { if err != nil { return err } + if *jsonFlag { + printJSON(result, os.Stdout) + return nil + } ui.RenderAddRepoSuccess(result) return nil } func runVersion(args []string) error { fs := newFlagSet("version", printVersionHelp) + jsonFlag := fs.Bool("json", false, "Output results as JSON.") if len(args) > 0 && isHelpToken(args[0]) { printVersionHelp() return nil @@ -522,6 +612,10 @@ func runVersion(args []string) error { v = strings.TrimPrefix(v, "v") v = strings.TrimPrefix(v, "V") } + if *jsonFlag { + printJSON(map[string]string{"version": v}, os.Stdout) + return nil + } fmt.Println(v) return nil } @@ -532,6 +626,21 @@ func runConfig(args []string) error { return nil } + // Parse --json flag before action to support JSON output for all config operations + // We need to handle this manually since config has subcommands + jsonFlag := false + for i, arg := range args { + if arg == "--json" { + jsonFlag = true + args = append(args[:i], args[i+1:]...) + break + } + } + if jsonFlag && len(args) == 0 { + printConfigHelp() + return nil + } + configRepo := infraConfig.NewDefault() service := app.NewConfigService(configRepo) @@ -545,6 +654,10 @@ func runConfig(args []string) error { if err != nil { return err } + if jsonFlag { + printJSON(map[string]string{"key": args[1], "value": stored}, os.Stdout) + return nil + } fmt.Println(stored) return nil case "get": @@ -555,6 +668,10 @@ func runConfig(args []string) error { if err != nil { return err } + if jsonFlag { + printJSON(map[string]string{"key": args[1], "value": value}, os.Stdout) + return nil + } fmt.Println(value) return nil default: @@ -566,6 +683,7 @@ func runUpdate(args []string) error { fs := newFlagSet("update", printUpdateHelp) check := fs.Bool("check", false, "Check whether a brew update is available.") apply := fs.Bool("apply", false, "Apply brew update now.") + jsonFlag := fs.Bool("json", false, "Output results as JSON.") if len(args) > 0 && isHelpToken(args[0]) { printUpdateHelp() return nil @@ -587,6 +705,11 @@ func runUpdate(args []string) error { return err } + if *jsonFlag { + printJSON(result, os.Stdout) + return nil + } + if result.Mode == "check" { fmt.Printf("mode=check outdated=%t current=%s latest=%s\n", result.Outdated, safeVersion(result.Current), safeVersion(result.Latest)) return nil @@ -707,6 +830,7 @@ func printCreateHelp() { fmt.Println(" " + flagStyle.Render("--copy-root-file") + " " + muted.Render("Extra root file/pattern to copy (repeatable)")) fmt.Println(" " + flagStyle.Render("--package") + " " + muted.Render("Package repository selector (repeatable)")) fmt.Println(" " + flagStyle.Render("--package-base") + " = " + muted.Render("Override package base branch (repeatable)")) + fmt.Println(" " + flagStyle.Render("--json") + " " + muted.Render("Output results as JSON")) fmt.Println(" " + flagStyle.Render("-h, --help") + " " + muted.Render("Show this help")) } @@ -730,6 +854,7 @@ func printAddRepoHelp() { fmt.Println(" " + flagStyle.Render("--reuse-existing-branch") + " " + muted.Render("Allow non-interactive reuse of existing branch")) fmt.Println(" " + flagStyle.Render("--copy-root-file") + " " + muted.Render("Extra root file/pattern to copy (repeatable)")) fmt.Println(" " + flagStyle.Render("--non-interactive") + " " + muted.Render("Disable interactive wizard/prompts and require deterministic selectors")) + fmt.Println(" " + flagStyle.Render("--json") + " " + muted.Render("Output results as JSON")) fmt.Println(" " + flagStyle.Render("-h, --help") + " " + muted.Render("Show this help")) } @@ -747,6 +872,7 @@ func printConfigHelp() { fmt.Println("") fmt.Println(accent.Render("Supported keys:")) fmt.Println(" " + flagStyle.Render("scope.root") + " " + muted.Render("Default discovery root for create/add-repo when --scope is omitted")) + fmt.Println(" " + flagStyle.Render("--json") + " " + muted.Render("Output results as JSON")) fmt.Println(" " + flagStyle.Render("-h, --help") + " " + muted.Render("Show this help")) } @@ -783,6 +909,7 @@ func printListHelp() { fmt.Println(accent.Render("Options:")) fmt.Println(" " + flagStyle.Render("--all") + " " + muted.Render("Include unmanaged Git worktrees")) fmt.Println(" " + flagStyle.Render("--global") + " " + muted.Render("List across all registered repositories")) + fmt.Println(" " + flagStyle.Render("--json") + " " + muted.Render("Output results as JSON")) fmt.Println(" " + flagStyle.Render("-h, --help") + " " + muted.Render("Show this help")) } @@ -801,6 +928,7 @@ func printCompleteHelp() { fmt.Println(" " + flagStyle.Render("--yes") + " " + muted.Render("Skip interactive confirmation")) fmt.Println(" " + flagStyle.Render("--force") + " " + muted.Render("Force worktree removal")) fmt.Println(" " + flagStyle.Render("--non-interactive") + " " + muted.Render("Disable prompts")) + fmt.Println(" " + flagStyle.Render("--json") + " " + muted.Render("Output results as JSON")) fmt.Println(" " + flagStyle.Render("-h, --help") + " " + muted.Render("Show this help")) } @@ -817,6 +945,7 @@ func printPubGetHelp() { fmt.Println("") fmt.Println(accent.Render("Options:")) fmt.Println(" " + flagStyle.Render("--force") + " " + muted.Render("Clean cache and remove pubspec.lock before pub get")) + fmt.Println(" " + flagStyle.Render("--json") + " " + muted.Render("Output results as JSON")) fmt.Println(" " + flagStyle.Render("-h, --help") + " " + muted.Render("Show this help")) } @@ -833,6 +962,7 @@ func printCleanHelp() { fmt.Println("") fmt.Println(accent.Render("Options:")) fmt.Println(" " + flagStyle.Render("--force") + " " + muted.Render("Also remove pubspec.lock")) + fmt.Println(" " + flagStyle.Render("--json") + " " + muted.Render("Output results as JSON")) fmt.Println(" " + flagStyle.Render("-h, --help") + " " + muted.Render("Show this help")) } @@ -850,6 +980,7 @@ func printUpdateHelp() { fmt.Println(accent.Render("Options:")) fmt.Println(" " + flagStyle.Render("--check") + " " + muted.Render("Check whether a brew update is available")) fmt.Println(" " + flagStyle.Render("--apply") + " " + muted.Render("Apply brew update now")) + fmt.Println(" " + flagStyle.Render("--json") + " " + muted.Render("Output results as JSON")) fmt.Println(" " + flagStyle.Render("-h, --help") + " " + muted.Render("Show this help")) } @@ -865,5 +996,6 @@ func printVersionHelp() { fmt.Println(" flutree version") fmt.Println("") fmt.Println(accent.Render("Options:")) + fmt.Println(" " + flagStyle.Render("--json") + " " + muted.Render("Output results as JSON")) fmt.Println(" " + flagStyle.Render("-h, --help") + " " + muted.Render("Show this help")) } diff --git a/integration/cli_contract_test.go b/integration/cli_contract_test.go index 20a4aa3..37028ad 100644 --- a/integration/cli_contract_test.go +++ b/integration/cli_contract_test.go @@ -1571,3 +1571,136 @@ func TestUpdateFailsWhenBrewUnavailable(t *testing.T) { t.Fatalf("unexpected stderr: %s", res.stderr) } } + +func TestVersionJSONFlag(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + + res := runCLI(t, bin, projectRoot(t), testEnv(home), "", "version", "--json") + if res.code != 0 { + t.Fatalf("expected 0, got %d (%s)", res.code, res.stderr) + } + + var out map[string]string + if err := json.Unmarshal([]byte(res.stdout), &out); err != nil { + t.Fatalf("invalid JSON output: %s\nerr: %v", res.stdout, err) + } + if _, ok := out["version"]; !ok { + t.Fatalf("expected 'version' key in JSON output: %s", res.stdout) + } +} + +func TestListJSONFlag(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + + writeRegistry(t, home, map[string]any{ + "version": 1, + "records": []map[string]any{ + { + "name": "feature-login", + "branch": "feature/login", + "path": "/tmp/worktrees/feature-login", + "repo_root": "/tmp/repo", + "status": "active", + }, + }, + }) + outside := filepath.Join(t.TempDir(), "outside") + _ = os.MkdirAll(outside, 0o755) + + res := runCLI(t, bin, outside, testEnv(home), "", "list", "--json") + if res.code != 0 { + t.Fatalf("expected 0, got %d (%s)", res.code, res.stderr) + } + + // Should be a JSON array + var rows []map[string]any + if err := json.Unmarshal([]byte(res.stdout), &rows); err != nil { + t.Fatalf("invalid JSON output: %s\nerr: %v", res.stdout, err) + } + if len(rows) == 0 { + t.Fatalf("expected at least one row in JSON output: %s", res.stdout) + } + if _, ok := rows[0]["name"]; !ok { + t.Fatalf("expected 'name' key in first row: %s", res.stdout) + } +} + +func TestConfigSetJSONFlag(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + scope := t.TempDir() + + res := runCLI(t, bin, projectRoot(t), testEnv(home), "", "config", "set", "scope.root", scope, "--json") + if res.code != 0 { + t.Fatalf("expected 0, got %d (%s)", res.code, res.stderr) + } + + var out map[string]string + if err := json.Unmarshal([]byte(res.stdout), &out); err != nil { + t.Fatalf("invalid JSON output: %s\nerr: %v", res.stdout, err) + } + if _, ok := out["key"]; !ok { + t.Fatalf("expected 'key' key in JSON output: %s", res.stdout) + } + if _, ok := out["value"]; !ok { + t.Fatalf("expected 'value' key in JSON output: %s", res.stdout) + } +} + +func TestConfigGetJSONFlag(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + scope := t.TempDir() + + // First set a value + setRes := runCLI(t, bin, projectRoot(t), testEnv(home), "", "config", "set", "scope.root", scope) + if setRes.code != 0 { + t.Fatalf("config set failed: %d (%s)", setRes.code, setRes.stderr) + } + + // Now get with --json + res := runCLI(t, bin, projectRoot(t), testEnv(home), "", "config", "get", "scope.root", "--json") + if res.code != 0 { + t.Fatalf("expected 0, got %d (%s)", res.code, res.stderr) + } + + var out map[string]string + if err := json.Unmarshal([]byte(res.stdout), &out); err != nil { + t.Fatalf("invalid JSON output: %s\nerr: %v", res.stdout, err) + } + if out["key"] != "scope.root" { + t.Fatalf("expected key 'scope.root', got: %s", out["key"]) + } + if out["value"] == "" { + t.Fatalf("expected non-empty value in JSON output: %s", res.stdout) + } +} + +func TestErrorJSONOutput(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + + // Create a valid git repo to run create inside + scope := t.TempDir() + repo := filepath.Join(scope, "root-app") + initRepo(t, repo) + + // Test that errors output as JSON when --json is set + // Use --non-interactive with missing --root-repo to trigger a validation error + res := runCLI(t, bin, repo, testEnv(home), "", "create", "feature-test", "--json", "--non-interactive") + if res.code != 2 { + t.Fatalf("expected exit code 2, got %d (%s)", res.code, res.stderr) + } + + // The error should be JSON formatted since --json was passed + // CombinedOutput merges stdout and stderr, but the JSON error should be in stderr + var errOut map[string]interface{} + if err := json.Unmarshal([]byte(res.stderr), &errOut); err != nil { + t.Fatalf("expected JSON error output, got stderr: %s, stdout: %s", res.stderr, res.stdout) + } + if _, ok := errOut["error"]; !ok { + t.Fatalf("expected 'error' key in JSON output: %s", res.stderr) + } +} diff --git a/internal/domain/types.go b/internal/domain/types.go index 4b4da54..c96d81d 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -13,11 +13,11 @@ const ( ) type AppError struct { - Category ErrorCategory - Message string - Hint string - Code int - Cause error + Category ErrorCategory `json:"category"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` + Code int `json:"code"` + Cause error `json:"-"` } func (e *AppError) Error() string { return e.Message } @@ -67,12 +67,12 @@ type GitWorktreeEntry struct { } type ListRow struct { - Name string - Branch string - Path string - RepoRoot string - Status string - PackageCount int + Name string `json:"name"` + Branch string `json:"branch"` + Path string `json:"path"` + RepoRoot string `json:"repo_root"` + Status string `json:"status"` + PackageCount int `json:"package_count"` } type ListInput struct { @@ -88,10 +88,10 @@ type CompleteInput struct { } type CompleteResult struct { - Record RegistryRecord - RemovedBranch bool - NextStep string - StaleCleaned bool + Record RegistryRecord `json:"record"` + RemovedBranch bool `json:"removed_branch"` + NextStep string `json:"next_step"` + StaleCleaned bool `json:"stale_cleaned"` } type PubTool string @@ -107,17 +107,17 @@ type PubGetInput struct { } type PubGetRepoResult struct { - Name string - Path string - Tool PubTool - Role string + Name string `json:"name"` + Path string `json:"path"` + Tool PubTool `json:"tool"` + Role string `json:"role"` } type PubGetResult struct { - WorkspaceName string - Root PubGetRepoResult - Packages []PubGetRepoResult - Force bool + WorkspaceName string `json:"workspace_name"` + Root PubGetRepoResult `json:"root"` + Packages []PubGetRepoResult `json:"packages"` + Force bool `json:"force"` } type CleanInput struct { @@ -125,10 +125,10 @@ type CleanInput struct { } type CleanResult struct { - Record RegistryRecord - Tool PubTool - Force bool - LockRemoved bool + Record RegistryRecord `json:"record"` + Tool PubTool `json:"tool"` + Force bool `json:"force"` + LockRemoved bool `json:"lock_removed"` } type CreateInput struct { @@ -173,10 +173,10 @@ type CreateApplyOptions struct { } type CreateResult struct { - Record RegistryRecord - NextStep string - SelectedPackages []string - WorkspacePath string + Record RegistryRecord `json:"record"` + NextStep string `json:"next_step"` + SelectedPackages []string `json:"selected_packages"` + WorkspacePath string `json:"workspace_path"` } type AddRepoSyncPolicy string @@ -200,10 +200,10 @@ type AddRepoInput struct { } type AddRepoResult struct { - WorkspaceName string - AddedRepos []string - OverridePath string - SelectedBranch string + WorkspaceName string `json:"workspace_name"` + AddedRepos []string `json:"added_repos"` + OverridePath string `json:"override_path"` + SelectedBranch string `json:"selected_branch"` } type UpdateInput struct { @@ -212,11 +212,11 @@ type UpdateInput struct { } type UpdateResult struct { - Mode string - Outdated bool - Current string - Latest string - UpgradeNotes string + Mode string `json:"mode"` + Outdated bool `json:"outdated"` + Current string `json:"current"` + Latest string `json:"latest"` + UpgradeNotes string `json:"upgrade_notes,omitempty"` } func NormalizePath(path string) string { diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 0366430..57e78c8 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -1,6 +1,7 @@ package runtime import ( + "encoding/json" "errors" "fmt" "os" @@ -8,6 +9,17 @@ import ( "github.com/EndersonPro/flutree/internal/domain" ) +type ErrorJSONOutput struct { + Error ErrorInfo `json:"error"` +} + +type ErrorInfo struct { + Category string `json:"category"` + Code int `json:"code"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` +} + func ExitOnError(err error) { if err == nil { return @@ -26,3 +38,47 @@ func ExitOnError(err error) { fmt.Fprintln(os.Stderr, "[unexpected] Command failed.") os.Exit(1) } + +func ExitOnErrorJSON(err error, jsonFlag bool) { + if err == nil { + return + } + var appErr *domain.AppError + if errors.As(err, &appErr) { + if jsonFlag { + jsonErr := ErrorJSONOutput{ + Error: ErrorInfo{ + Category: string(appErr.Category), + Code: appErr.Code, + Message: appErr.Message, + Hint: appErr.Hint, + }, + } + data, _ := json.MarshalIndent(jsonErr, "", " ") + fmt.Fprintln(os.Stderr, string(data)) + } else { + fmt.Fprintf(os.Stderr, "[%s] %s\n", appErr.Category, appErr.Message) + if appErr.Hint != "" { + fmt.Fprintf(os.Stderr, "Hint: %s\n", appErr.Hint) + } + } + if appErr.Category == domain.CategoryInput { + os.Exit(2) + } + os.Exit(1) + } + if jsonFlag { + jsonErr := ErrorJSONOutput{ + Error: ErrorInfo{ + Category: string(domain.CategoryUnexpected), + Code: 1, + Message: "Command failed.", + }, + } + data, _ := json.MarshalIndent(jsonErr, "", " ") + fmt.Fprintln(os.Stderr, string(data)) + } else { + fmt.Fprintln(os.Stderr, "[unexpected] Command failed.") + } + os.Exit(1) +}