diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 0000000..cdf5efd --- /dev/null +++ b/cmd/completion.go @@ -0,0 +1,95 @@ +package cmd + +import ( + "log/slog" + "os" + + "github.com/spf13/cobra" +) + +// completionCmd represents the completion command +var completionCmd = &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion script", + Long: `Generate shell completion script for spec-forge. + +To load completions: + +Bash: + source <(spec-forge completion bash) + + # To load completions for each session, execute once: + # Linux: + spec-forge completion bash > /etc/bash_completion.d/spec-forge + # macOS: + spec-forge completion bash > $(brew --prefix)/etc/bash_completion.d/spec-forge + +Zsh: + # If shell completion is not already enabled in your environment, + # you will need to enable it. Add the following to your ~/.zshrc: + autoload -Uz compinit + compinit + + # Then load completions: + spec-forge completion zsh > "${fpath[1]}/_spec-forge" + + # You will need to start a new shell for this setup to take effect. + +Fish: + spec-forge completion fish | source + + # To load completions for each session, execute once: + spec-forge completion fish > ~/.config/fish/completions/spec-forge.fish + +PowerShell: + spec-forge completion powershell | Out-String | Invoke-Expression + + # To load completions for every new session, run: + spec-forge completion powershell > spec-forge.ps1 + # and source this file from your PowerShell profile. +`, + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + RunE: func(cmd *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return cmd.Root().GenBashCompletion(os.Stdout) + case "zsh": + return cmd.Root().GenZshCompletion(os.Stdout) + case "fish": + return cmd.Root().GenFishCompletion(os.Stdout, true) + case "powershell": + return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) + default: + return nil + } + }, +} + +func init() { + rootCmd.AddCommand(completionCmd) +} + +// newCompletionCmd creates a new completion command instance for testing. +func newCompletionCmd() *cobra.Command { + return &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion script", + Long: completionCmd.Long, + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + RunE: completionCmd.RunE, + } +} + +// registerCompletion registers shell completion for a flag. Errors are logged +// as warnings since completion is best-effort and should not block startup. +func registerCompletion(cmd *cobra.Command, flag string, completions []string) { + if err := cmd.RegisterFlagCompletionFunc(flag, + cobra.FixedCompletions(completions, cobra.ShellCompDirectiveNoFileComp), + ); err != nil { + slog.Warn("failed to register flag completion", "flag", flag, "error", err) + } +} diff --git a/cmd/enrich.go b/cmd/enrich.go index 7b99a6e..dfe7196 100644 --- a/cmd/enrich.go +++ b/cmd/enrich.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/cobra" "gopkg.in/yaml.v3" + "github.com/spencercjh/spec-forge/internal/cli" "github.com/spencercjh/spec-forge/internal/config" "github.com/spencercjh/spec-forge/internal/enricher" "github.com/spencercjh/spec-forge/internal/enricher/processor" @@ -105,12 +106,7 @@ func runEnrich(cmd *cobra.Command, args []string) error { return err } - slog.InfoContext(ctx, "Enriching OpenAPI spec", - "file", specFile, - "provider", prov, - "model", model, - "language", lang, - ) + cli.Statusf(os.Stderr, "Enriching OpenAPI spec (provider: %s, model: %s, language: %s)", prov, model, lang) // Load spec loader := openapi3.NewLoader() @@ -170,6 +166,7 @@ func runEnrich(cmd *cobra.Command, args []string) error { "failed_batches", partialErr.FailedBatches, "total_batches", partialErr.TotalBatches, ) + cli.Statusf(os.Stderr, "Partial enrichment: %d/%d batches succeeded", partialErr.TotalBatches-partialErr.FailedBatches, partialErr.TotalBatches) } else { return fmt.Errorf("enrichment failed: %w", err) } @@ -201,7 +198,7 @@ func runEnrich(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to write enriched spec: %w", writeErr) } - slog.InfoContext(ctx, "Enrichment complete", "output", outputFile) + cli.Successf(os.Stderr, "Enrichment complete: %s", outputFile) return nil } @@ -298,6 +295,9 @@ Examples: c.Flags().Bool("no-stream", false, "Disable streaming to enable concurrent processing (faster, but no real-time output)") c.Flags().Bool("force", false, "Force regeneration of all descriptions, ignoring existing ones") + registerCompletion(c, "provider", []string{"openai", "anthropic", "ollama", "custom"}) + registerCompletion(c, "language", []string{"en", "zh"}) + return c } @@ -328,4 +328,7 @@ func init() { enrichCmd.Flags().StringVar(&enrichCustomAPIKeyEnv, "custom-api-key-env", "LLM_API_KEY", "Environment variable for custom API key") enrichCmd.Flags().BoolVar(&enrichNoStream, "no-stream", false, "Disable streaming output to enable concurrent LLM calls (faster)") enrichCmd.Flags().BoolVar(&enrichForce, "force", false, "Force regeneration of all descriptions, ignoring existing ones") + + registerCompletion(enrichCmd, "provider", []string{"openai", "anthropic", "ollama", "custom"}) + registerCompletion(enrichCmd, "language", []string{"en", "zh"}) } diff --git a/cmd/generate.go b/cmd/generate.go index 8861878..5d64061 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -16,6 +16,7 @@ import ( "github.com/spf13/cobra" "gopkg.in/yaml.v3" + "github.com/spencercjh/spec-forge/internal/cli" "github.com/spencercjh/spec-forge/internal/config" "github.com/spencercjh/spec-forge/internal/enricher" "github.com/spencercjh/spec-forge/internal/enricher/processor" @@ -53,7 +54,7 @@ func runGenerate(cmd *cobra.Command, args []string) error { //nolint:gocyclo // path = args[0] } - slog.InfoContext(ctx, "Generating OpenAPI spec", "path", path) + cli.Statusf(os.Stderr, "Generating OpenAPI spec in %s...", path) // Get all flag values from command (isolated per command instance) //nolint:errcheck // flags are bound at command creation, errors not possible @@ -87,11 +88,7 @@ func runGenerate(cmd *cobra.Command, args []string) error { //nolint:gocyclo // return errWrap("no supported framework detected", err) } - slog.InfoContext(ctx, "Detected project", - "framework", extractorImpl.Name(), - "tool", info.BuildTool, - "build_file", info.BuildFilePath, - ) + cli.Statusf(os.Stderr, "Detected %s project (tool: %s, build: %s)", extractorImpl.Name(), info.BuildTool, info.BuildFilePath) // Step 2: Patch project if needed patchOpts := &extractor.PatchOptions{ @@ -106,23 +103,23 @@ func runGenerate(cmd *cobra.Command, args []string) error { //nolint:gocyclo // // Step 3: If we patched the file and should restore later, defer the restore if !keepPatched && patchResult.OriginalContent != "" { defer func() { - slog.InfoContext(ctx, "Restoring original build file...") + cli.Statusf(os.Stderr, "Restoring original build file...") if restoreErr := extractorImpl.Restore(patchResult.BuildFilePath, patchResult.OriginalContent); restoreErr != nil { slog.WarnContext(ctx, "failed to restore original file", "error", restoreErr) } else { - slog.InfoContext(ctx, "Original build file restored", "status", "✅") + cli.Successf(os.Stderr, "Original build file restored") } }() } if patchResult.DependencyAdded { - slog.InfoContext(ctx, "dependencies added temporarily", "status", "✅") + cli.Successf(os.Stderr, "Dependencies added temporarily") } if patchResult.PluginAdded { - slog.InfoContext(ctx, "plugin added temporarily", "status", "✅") + cli.Successf(os.Stderr, "Plugin added temporarily") } if patchResult.SpringBootConfigured { - slog.InfoContext(ctx, "spring-boot-maven-plugin configured with start/stop goals", "status", "✅") + cli.Successf(os.Stderr, "spring-boot-maven-plugin configured with start/stop goals") } // Step 4: Generate OpenAPI spec @@ -164,10 +161,7 @@ func runGenerate(cmd *cobra.Command, args []string) error { //nolint:gocyclo // return errWrap("generation failed", err) } - slog.InfoContext(ctx, "OpenAPI spec generated", - "path", genResult.SpecFilePath, - "format", genResult.Format, - ) + cli.Statusf(os.Stderr, "OpenAPI spec generated: %s (%s)", genResult.SpecFilePath, genResult.Format) // Step 5: Validate the generated spec if !skipValidate { @@ -178,16 +172,16 @@ func runGenerate(cmd *cobra.Command, args []string) error { //nolint:gocyclo // } if !valResult.Valid { - slog.ErrorContext(ctx, "OpenAPI spec validation failed") + cli.Errorf(os.Stderr, "OpenAPI spec validation failed") for _, validationErr := range valResult.Errors { - slog.ErrorContext(ctx, " - "+validationErr) + cli.Errorf(os.Stderr, " - %s", validationErr) } return errors.New("generated OpenAPI spec is invalid") } - slog.InfoContext(ctx, "OpenAPI spec validated", "status", "✅") + cli.Successf(os.Stderr, "OpenAPI spec validated") } else { - slog.InfoContext(ctx, "Validation skipped", "status", "⏭️") + cli.Skipf(os.Stderr, "Validation skipped") } // Step 6: Enrich with AI (optional) @@ -198,7 +192,7 @@ func runGenerate(cmd *cobra.Command, args []string) error { //nolint:gocyclo // slog.WarnContext(ctx, "Enrichment failed (non-fatal)", "error", enrichErr) } } else { - slog.InfoContext(ctx, "Enrichment skipped", "status", "⏭️") + cli.Skipf(os.Stderr, "Enrichment skipped") } // Step 7: Ensure spec is in the output directory @@ -221,11 +215,11 @@ func runGenerate(cmd *cobra.Command, args []string) error { //nolint:gocyclo // if err := copySpecToOutput(genResult.SpecFilePath, outputDir, overwriteOutput); err != nil { return errWrap("failed to copy spec to output directory", err) } - slog.InfoContext(ctx, "Spec saved", "path", targetPath) + cli.Successf(os.Stderr, "Spec saved: %s", targetPath) // Update genResult.SpecFilePath to point to the copied file for publishing genResult.SpecFilePath = targetPath } else { - slog.InfoContext(ctx, "Spec saved", "path", genResult.SpecFilePath) + cli.Successf(os.Stderr, "Spec saved: %s", genResult.SpecFilePath) } // Step 8: Publish the spec to remote platforms (optional) @@ -267,19 +261,14 @@ func runGenerate(cmd *cobra.Command, args []string) error { //nolint:gocyclo // return errWrap("failed to publish spec", err) } - slog.InfoContext(ctx, "Spec published", - "target", pub.Name(), - "path", pubResult.Path, - "format", pubResult.Format, - "bytes", pubResult.BytesWritten, - ) + cli.Successf(os.Stderr, "Spec published to %s", pub.Name()) if pubResult.Message != "" { - slog.InfoContext(ctx, "Publisher output", "message", pubResult.Message) + cli.Statusf(os.Stderr, "%s", pubResult.Message) } } // Step 8: Output final result - slog.InfoContext(ctx, "Generation complete") + cli.Successf(os.Stderr, "Generation complete") return nil } @@ -325,6 +314,10 @@ to preserve your project's formatting. Use --keep-patched to keep the changes.`, c.Flags().StringSlice("proto-import-path", nil, "additional import paths for protoc (-I flags), can be specified multiple times") + registerCompletion(c, "output", []string{"yaml", "json"}) + registerCompletion(c, "language", []string{"en", "zh"}) + registerCompletion(c, "publish-target", []string{"readme"}) + return c } @@ -371,11 +364,15 @@ func init() { "overwrite existing local spec file if it already exists") generateCmd.Flags().StringSliceVar(&generateProtoImportPaths, "proto-import-path", nil, "additional import paths for protoc (-I flags), can be specified multiple times") + + registerCompletion(generateCmd, "output", []string{"yaml", "json"}) + registerCompletion(generateCmd, "language", []string{"en", "zh"}) + registerCompletion(generateCmd, "publish-target", []string{"readme"}) } // enrichGeneratedSpec enriches the generated spec with AI-generated descriptions func enrichGeneratedSpec(ctx context.Context, specFilePath string, cfg *config.Config, language string) error { - slog.InfoContext(ctx, "Enriching OpenAPI spec with AI descriptions...") + cli.Statusf(os.Stderr, "Enriching OpenAPI spec with AI descriptions...") // Determine language lang := language @@ -458,7 +455,7 @@ func enrichGeneratedSpec(ctx context.Context, specFilePath string, cfg *config.C return fmt.Errorf("failed to write enriched spec: %w", writeErr) } - slog.InfoContext(ctx, "OpenAPI spec enriched", "output", specFilePath) + cli.Successf(os.Stderr, "OpenAPI spec enriched: %s", specFilePath) return nil } diff --git a/cmd/publish.go b/cmd/publish.go index 3785956..71e42ae 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -3,12 +3,12 @@ package cmd import ( "fmt" - "log/slog" "os" "github.com/getkin/kin-openapi/openapi3" "github.com/spf13/cobra" + "github.com/spencercjh/spec-forge/internal/cli" "github.com/spencercjh/spec-forge/internal/publisher" ) @@ -51,7 +51,7 @@ func runPublish(cmd *cobra.Command, args []string) error { //nolint:errcheck readMeUseSpecVersion, _ := cmd.Flags().GetBool("readme-use-spec-version") - slog.InfoContext(ctx, "Publishing spec", "file", specFile, "target", target) + cli.Statusf(os.Stderr, "Publishing %s to %s", specFile, target) // Create publisher using factory pub, err := publisher.NewPublisher(target) @@ -59,7 +59,7 @@ func runPublish(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to create publisher: %w", err) } - slog.InfoContext(ctx, "Using publisher", "name", pub.Name()) + cli.Statusf(os.Stderr, "Using publisher: %s", pub.Name()) // Load spec file specData, err := os.ReadFile(specFile) @@ -100,13 +100,9 @@ func runPublish(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to publish: %w", err) } - slog.InfoContext(ctx, "Published successfully", - "path", result.Path, - "format", result.Format, - "bytes", result.BytesWritten, - ) + cli.Successf(os.Stderr, "Published successfully (%d bytes, %s)", result.BytesWritten, result.Format) if result.Message != "" { - slog.InfoContext(ctx, "Publisher output", "message", result.Message) + cli.Statusf(os.Stderr, "%s", result.Message) } return nil @@ -143,6 +139,9 @@ Note: Local file output is handled by the generate command.`, panic(fmt.Sprintf("failed to mark flag 'to' as required: %v", err)) } + registerCompletion(c, "format", []string{"yaml", "json"}) + registerCompletion(c, "to", []string{"readme"}) + return c } @@ -178,4 +177,7 @@ func init() { if err := publishCmd.MarkFlagRequired("to"); err != nil { panic(fmt.Sprintf("failed to mark flag 'to' as required: %v", err)) } + + registerCompletion(publishCmd, "format", []string{"yaml", "json"}) + registerCompletion(publishCmd, "to", []string{"readme"}) } diff --git a/cmd/root.go b/cmd/root.go index ff7613b..7a5b528 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,13 +3,13 @@ package cmd import ( "errors" - "fmt" "log/slog" "os" "github.com/spf13/cobra" "github.com/spf13/viper" + "github.com/spencercjh/spec-forge/internal/cli" forgeerrors "github.com/spencercjh/spec-forge/internal/errors" ) @@ -30,7 +30,6 @@ Core workflow: Source Code -> Extract -> Enrich -> Publish`, func Execute() { err := rootCmd.Execute() if err != nil { - slog.Error("command failed", "error", err) printHintAndExit(err) } } @@ -39,12 +38,14 @@ func Execute() { // with an appropriate exit code. func printHintAndExit(err error) { if fe, ok := errors.AsType[*forgeerrors.Error](err); ok { + cli.Errorf(os.Stderr, "%s", fe.Error()) hint := fe.Hint() if hint != "" { - fmt.Fprintf(os.Stderr, "Hint: %s\n", hint) + cli.Hintf(os.Stderr, "%s", hint) } os.Exit(exitCodeForCode(fe.Code)) } + cli.Errorf(os.Stderr, "%s", err.Error()) os.Exit(1) } @@ -86,6 +87,7 @@ Core workflow: Source Code -> Extract -> Enrich -> Publish`, c.AddCommand(newGenerateCmd()) c.AddCommand(newEnrichCmd()) c.AddCommand(newPublishCmd()) + c.AddCommand(newCompletionCmd()) return c } diff --git a/cmd/spring.go b/cmd/spring.go index e88a040..2143f03 100644 --- a/cmd/spring.go +++ b/cmd/spring.go @@ -2,12 +2,12 @@ package cmd import ( - "context" "fmt" - "log/slog" + "os" "github.com/spf13/cobra" + "github.com/spencercjh/spec-forge/internal/cli" "github.com/spencercjh/spec-forge/internal/extractor" "github.com/spencercjh/spec-forge/internal/extractor/spring" ) @@ -42,8 +42,7 @@ This command will identify: RunE: runSpringDetect, } -func runSpringDetect(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() +func runSpringDetect(_ *cobra.Command, args []string) error { path := "." if len(args) > 0 { path = args[0] @@ -56,43 +55,43 @@ func runSpringDetect(cmd *cobra.Command, args []string) error { } // Print human-readable output - printProjectInfo(ctx, info) + printProjectInfo(info) return nil } -func printProjectInfo(ctx context.Context, info *extractor.ProjectInfo) { - slog.InfoContext(ctx, "Spring Project Detection Results") - slog.InfoContext(ctx, "Build Tool", "tool", info.BuildTool) - slog.InfoContext(ctx, "Build File", "path", info.BuildFilePath) +func printProjectInfo(info *extractor.ProjectInfo) { + cli.Statusf(os.Stderr, "Spring Project Detection Results") + cli.Statusf(os.Stderr, "Build Tool: %s", info.BuildTool) + cli.Statusf(os.Stderr, "Build File: %s", info.BuildFilePath) springInfo, ok := info.FrameworkData.(*spring.Info) if !ok || springInfo == nil { springInfo = &spring.Info{} } - slog.InfoContext(ctx, "Spring Boot", "version", springInfo.SpringBootVersion) + cli.Statusf(os.Stderr, "Spring Boot: %s", springInfo.SpringBootVersion) if springInfo.IsMultiModule { - slog.InfoContext(ctx, "Multi-Module", "enabled", "✅ Yes") - slog.InfoContext(ctx, "Modules", "list", springInfo.Modules) + cli.Successf(os.Stderr, "Multi-Module: Yes") + cli.Statusf(os.Stderr, "Modules: %v", springInfo.Modules) if springInfo.MainModule != "" { - slog.InfoContext(ctx, "Main Module", "name", springInfo.MainModule) - slog.InfoContext(ctx, "Main Module Path", "path", springInfo.MainModulePath) + cli.Statusf(os.Stderr, "Main Module: %s", springInfo.MainModule) + cli.Statusf(os.Stderr, "Main Module Path: %s", springInfo.MainModulePath) } } else { - slog.InfoContext(ctx, "Multi-Module", "enabled", "❌ No") + cli.Skipf(os.Stderr, "Multi-Module: No") } if springInfo.HasSpringdocDeps { - slog.InfoContext(ctx, "springdoc Dependency", "status", "✅ Present", "version", springInfo.SpringdocVersion) + cli.Successf(os.Stderr, "springdoc Dependency: Present (version: %s)", springInfo.SpringdocVersion) } else { - slog.InfoContext(ctx, "springdoc Dependency", "status", "❌ Not found") + cli.Skipf(os.Stderr, "springdoc Dependency: Not found") } if springInfo.HasSpringdocPlugin { - slog.InfoContext(ctx, "springdoc Plugin", "status", "✅ Configured") + cli.Successf(os.Stderr, "springdoc Plugin: Configured") } else { - slog.InfoContext(ctx, "springdoc Plugin", "status", "❌ Not configured") + cli.Skipf(os.Stderr, "springdoc Plugin: Not configured") } } @@ -116,8 +115,7 @@ This command will: RunE: runSpringPatch, } -func runSpringPatch(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() +func runSpringPatch(_ *cobra.Command, args []string) error { path := "." if len(args) > 0 { path = args[0] @@ -139,29 +137,29 @@ func runSpringPatch(cmd *cobra.Command, args []string) error { // Print results if opts.DryRun { - slog.InfoContext(ctx, "Dry run mode - showing changes without modifying files") + cli.Statusf(os.Stderr, "Dry run mode - showing changes without modifying files") } - slog.InfoContext(ctx, "Build file", "path", result.BuildFilePath) + cli.Statusf(os.Stderr, "Build file: %s", result.BuildFilePath) if result.DependencyAdded { - slog.InfoContext(ctx, "springdoc dependency will be added", "status", "✅") + cli.Successf(os.Stderr, "springdoc dependency will be added") } else { - slog.InfoContext(ctx, "springdoc dependency already present", "status", "⏭️") + cli.Skipf(os.Stderr, "springdoc dependency already present") } if result.PluginAdded { - slog.InfoContext(ctx, "springdoc plugin will be added", "status", "✅") + cli.Successf(os.Stderr, "springdoc plugin will be added") } else { - slog.InfoContext(ctx, "springdoc plugin already configured", "status", "⏭️") + cli.Skipf(os.Stderr, "springdoc plugin already configured") } if !opts.DryRun && (result.DependencyAdded || result.PluginAdded) { - slog.InfoContext(ctx, "Patch applied successfully!") - slog.InfoContext(ctx, "Note: Build file format may differ from original due to XML serialization.") - slog.InfoContext(ctx, "Use 'spec-forge generate' to extract specs while preserving original file format.") + cli.Successf(os.Stderr, "Patch applied successfully!") + cli.Statusf(os.Stderr, "Note: Build file format may differ from original due to XML serialization.") + cli.Statusf(os.Stderr, "Use 'spec-forge generate' to extract specs while preserving original file format.") } else if !result.DependencyAdded && !result.PluginAdded { - slog.InfoContext(ctx, "No changes needed.") + cli.Skipf(os.Stderr, "No changes needed.") } return nil diff --git a/docs/plans/2026-03-30-cli-ux-improvements-design.md b/docs/plans/2026-03-30-cli-ux-improvements-design.md new file mode 100644 index 0000000..a5c93ab --- /dev/null +++ b/docs/plans/2026-03-30-cli-ux-improvements-design.md @@ -0,0 +1,153 @@ +# CLI UX Improvements Design + +**Date:** 2026-03-30 +**Status:** Approved +**Scope:** Completion subcommand, error message coloring, enricher progress bar + +## Overview + +Improve spec-forge CLI user experience through three targeted enhancements: +1. Shell completion subcommand (Bash/Zsh/Fish/PowerShell) +2. Colored error and status output +3. Enricher batch processing progress bar + +**Technical choice:** Lightweight dependency approach — `fatih/color` for coloring + `schollz/progressbar` for progress. No TUI framework. + +--- + +## 1. Completion Subcommand + +### Interface + +``` +spec-forge completion bash # Output bash completion script +spec-forge completion zsh # Output zsh completion script +spec-forge completion fish # Output fish completion script +spec-forge completion powershell # Output PowerShell completion script +``` + +### Implementation + +- New file: `cmd/completion.go` +- Use Cobra's native `rootCmd.GenBashCompletion()`, `GenZshCompletion()`, `GenFishCompletion()`, `GenPowerShellCompletion()` APIs +- Standard Cobra completion command pattern with `DisableFlagsInUseLine: true` and `Hidden: false` + +### Dynamic Completions (ValidArgsFunction) + +Register `ValidArgsFunction` on enum-type flags for shell completion: + +| Flag | Command | Valid Values | +|------|---------|-------------| +| `--provider` | enrich | openai, anthropic, ollama, custom | +| `--publish-target` | generate, publish | readme | +| `-o, --output` | generate, enrich, publish | yaml, json | +| `--language` | generate, enrich | en, zh | +| `-t, --to` | publish | readme | + +### Files Changed + +- **New:** `cmd/completion.go` +- **Modified:** `cmd/enrich.go`, `cmd/publish.go`, `cmd/generate.go` (add `ValidArgsFunction` to flag definitions) + +--- + +## 2. Colored Error and Status Output + +### Color Scheme + +| Element | Style | Example | +|---------|-------|---------| +| Error category prefix | Red + Bold | **[DETECT]** | +| Error message | Default | no supported framework detected | +| Hint marker | Cyan | 💡 Hint: | +| Hint content | Yellow | Verify the project structure... | +| Success status | Green | ✅ OpenAPI spec validated | +| Skip status | Dim (gray) | ⏭️ Validation skipped | +| Progress info | Default | Enriching OpenAPI spec... | + +### Implementation + +- New package: `internal/cli/output.go` +- Core functions: + - `Errorf(format, args...)` — prints red error prefix + message + - `Successf(format, args...)` — prints green success message + - `Hintf(format, args...)` — prints cyan hint prefix + yellow content + - `Skipf(format, args...)` — prints dim skip message +- Auto-detection: + - Respect `NO_COLOR` environment variable (https://no-color.org/) — mere presence disables color + - Otherwise rely on `fatih/color` defaults for terminal detection and color enable/disable behavior + - Initialize `color.NoColor` in `internal/cli/initColorState()` instead of performing explicit `IsTerminal` checks + +### Output Architecture + +``` +User-facing status messages → internal/cli (colored) +Debug/diagnostic logs → slog (plain text, -v only) +Spec file content → stdout (no color ever) +``` + +### Files Changed + +- **New:** `internal/cli/output.go` +- **Modified:** `cmd/generate.go`, `cmd/enrich.go`, `cmd/publish.go`, `cmd/root.go` (replace slog status calls with colored output) + +--- + +## 3. Enricher Progress Bar + +### Display + +``` +Enriching OpenAPI spec... ████████████████░░░░ 80% | 12/15 batches | 0 failed +``` + +### Implementation + +- Use `schollz/progressbar/v3` +- Create in `internal/enricher/processor/concurrent.go`: + - `progressbar.NewOptions(total, ...option)` with total = `len(batches)` + - Options: `progressbar.OptionSetWriter(os.Stderr)`, `progressbar.OptionShowCount()`, custom description with failed count + - `bar.Add(1)` after each batch completes + - `bar.Finish()` at end + +### Streaming Compatibility + +| Mode | Behavior | +|------|----------| +| Streaming (default) | Progress bar on stderr; streaming text prints above it via `bar.Describe()` or interleaved | +| Concurrent (`--no-stream`) | Thread-safe `bar.Add(1)` from multiple goroutines | +| Non-TTY / piped | Progress bar writes to stderr (schollz/progressbar handles non-TTY rendering); colors disabled via fatih/color TTY detection or `NO_COLOR` | + +### Progress Bar Options + +```go +progressbar.NewOptions(len(batches), + progressbar.OptionSetWriter(os.Stderr), + progressbar.OptionShowCount(), + progressbar.OptionSetDescription("Enriching OpenAPI spec..."), + progressbar.OptionOnCompletion(func() { fmt.Fprint(os.Stderr, "\n") }), +) +``` + +### Files Changed + +- **Modified:** `internal/enricher/processor/concurrent.go` + +--- + +## Dependencies + +``` +github.com/fatih/color v1.19.0 # Terminal color output +github.com/schollz/progressbar/v3 v3.19.0 # Progress bar +``` + +Both are well-maintained, widely-used, zero-TUI-framework dependencies. + +## Out of Scope + +- Full TUI interface (Bubbletea/Lipgloss) +- Multi-line formatted error panels +- Generate pipeline progress (only enricher phase) +- Interactive prompts or spinners +- Configuration file for color theme diff --git a/docs/plans/2026-03-30-cli-ux-improvements.md b/docs/plans/2026-03-30-cli-ux-improvements.md new file mode 100644 index 0000000..9e694c2 --- /dev/null +++ b/docs/plans/2026-03-30-cli-ux-improvements.md @@ -0,0 +1,854 @@ +# CLI UX Improvements Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add shell completion, colored output, and progress bar to spec-forge CLI. + +**Architecture:** Three independent features added incrementally. `fatih/color` for terminal coloring, `schollz/progressbar` for batch progress, Cobra native completion for shell support. All output goes to stderr; stdout reserved for spec content. + +**Tech Stack:** Go 1.26, Cobra v1.10.2, fatih/color, schollz/progressbar/v3 + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|----------------| +| Create | `cmd/completion.go` | Shell completion subcommand | +| Create | `internal/cli/output.go` | Colored output helpers | +| Create | `internal/cli/output_test.go` | Tests for colored output | +| Modify | `cmd/root.go` | Register completion cmd in `NewRootCommand()`, colored error output | +| Modify | `cmd/enrich.go` | ValidArgsFunction for flags, colored status output | +| Modify | `cmd/generate.go` | ValidArgsFunction for flags, colored status output | +| Modify | `cmd/publish.go` | ValidArgsFunction for flags, colored status output | +| Modify | `cmd/spring.go` | Colored status output | +| Modify | `internal/enricher/processor/concurrent.go` | Progress bar in batch processing loops | +| Modify | `internal/enricher/enricher.go` | Pass progress bar through to processor | +| Modify | `go.mod` / `go.sum` | Add fatih/color, schollz/progressbar/v3 | + +--- + +### Task 1: Add Dependencies + +**Files:** +- Modify: `go.mod` +- Modify: `go.sum` + +- [ ] **Step 1: Install fatih/color** + +Run: +```bash +cd +go get github.com/fatih/color@latest +``` + +- [ ] **Step 2: Install schollz/progressbar** + +Run: +```bash +go get github.com/schollz/progressbar/v3@latest +``` + +- [ ] **Step 3: Tidy dependencies** + +Run: +```bash +go mod tidy +``` + +- [ ] **Step 4: Verify build** + +Run: +```bash +make build +``` +Expected: Build succeeds with no errors. + +- [ ] **Step 5: Commit** + +```bash +git add go.mod go.sum +git commit -S -s -m "build: add fatih/color and schollz/progressbar dependencies for CLI UX" +``` + +--- + +### Task 2: Create Colored Output Package + +**Files:** +- Create: `internal/cli/output.go` +- Create: `internal/cli/output_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/cli/output_test.go`: + +```go +package cli + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestColorEnabled(t *testing.T) { + // When NO_COLOR is not set, color should be enabled (assuming terminal) + t.Run("color enabled by default", func(t *testing.T) { + // Save and restore NO_COLOR + orig := os.Getenv("NO_COLOR") + os.Unsetenv("NO_COLOR") + defer os.Setenv("NO_COLOR", orig) + + // Re-init color state + initColorState() + assert.True(t, ColorEnabled()) + }) + + t.Run("color disabled with NO_COLOR", func(t *testing.T) { + os.Setenv("NO_COLOR", "1") + defer os.Unsetenv("NO_COLOR") + + initColorState() + assert.False(t, ColorEnabled()) + }) +} + +func TestStatusFunctions(t *testing.T) { + // Force color on for testing + origNoColor := os.Getenv("NO_COLOR") + os.Unsetenv("NO_COLOR") + defer os.Setenv("NO_COLOR", origNoColor) + initColorState() + + t.Run("Successf contains checkmark", func(t *testing.T) { + var buf bytes.Buffer + Successf(&buf, "test message") + assert.Contains(t, buf.String(), "test message") + assert.Contains(t, buf.String(), "✅") + }) + + t.Run("Skipf contains skip mark", func(t *testing.T) { + var buf bytes.Buffer + Skipf(&buf, "skipped") + assert.Contains(t, buf.String(), "skipped") + assert.Contains(t, buf.String(), "⏭️") + }) + + t.Run("Errorf contains error marker", func(t *testing.T) { + var buf bytes.Buffer + Errorf(&buf, "something failed") + assert.Contains(t, buf.String(), "something failed") + }) + + t.Run("Hintf contains hint", func(t *testing.T) { + var buf bytes.Buffer + Hintf(&buf, "check your config") + assert.Contains(t, buf.String(), "check your config") + }) + + t.Run("Statusf formats with args", func(t *testing.T) { + var buf bytes.Buffer + Statusf(&buf, "found %d items", 5) + assert.Contains(t, buf.String(), "found 5 items") + }) +} + +func TestNoColorMode(t *testing.T) { + os.Setenv("NO_COLOR", "1") + defer os.Unsetenv("NO_COLOR") + initColorState() + + t.Run("Successf no ANSI codes", func(t *testing.T) { + var buf bytes.Buffer + Successf(&buf, "test") + assert.NotContains(t, buf.String(), "\x1b[") + assert.Contains(t, buf.String(), "✅") + }) + + t.Run("Errorf no ANSI codes", func(t *testing.T) { + var buf bytes.Buffer + Errorf(&buf, "test") + assert.NotContains(t, buf.String(), "\x1b[") + }) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test -v ./internal/cli/... -run TestColor` +Expected: FAIL — package does not exist + +- [ ] **Step 3: Write implementation** + +Create `internal/cli/output.go`: + +```go +// Package cli provides terminal output helpers for spec-forge CLI. +package cli + +import ( + "fmt" + "io" + "os" + + "github.com/fatih/color" +) + +var ( + colorEnabled bool + + // Pre-configured color printers (no-op when color is disabled) + green = color.New(color.FgGreen).FprintfFunc() + red = color.New(color.FgRed, color.Bold).FprintfFunc() + cyan = color.New(color.FgCyan).FprintfFunc() + yellow = color.New(color.FgYellow).FprintfFunc() + dim = color.New(color.Faint).FprintfFunc() +) + +func init() { + initColorState() +} + +// initColorState reads environment and configures color output. +func initColorState() { + colorEnabled = os.Getenv("NO_COLOR") == "" + color.NoColor = !colorEnabled +} + +// ColorEnabled reports whether colored output is active. +func ColorEnabled() bool { + return colorEnabled +} + +// Successf prints a green success message with checkmark prefix. +func Successf(w io.Writer, format string, args ...any) { + msg := fmt.Sprintf(format, args...) + green(w, "✅ %s\n", msg) +} + +// Skipf prints a dim skip message with skip prefix. +func Skipf(w io.Writer, format string, args ...any) { + msg := fmt.Sprintf(format, args...) + dim(w, "⏭️ %s\n", msg) +} + +// Errorf prints a red error message. +func Errorf(w io.Writer, format string, args ...any) { + msg := fmt.Sprintf(format, args...) + red(w, "❌ %s\n", msg) +} + +// Hintf prints a cyan hint prefix with yellow hint content. +func Hintf(w io.Writer, format string, args ...any) { + msg := fmt.Sprintf(format, args...) + cyan(w, "💡 Hint: ") + yellow(w, "%s\n", msg) +} + +// Statusf prints a neutral status message. +func Statusf(w io.Writer, format string, args ...any) { + fmt.Fprintf(w, format+"\n", args...) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test -v ./internal/cli/...` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/cli/output.go internal/cli/output_test.go +git commit -S -s -m "feat(cli): add colored output package with success/error/hint helpers" +``` + +--- + +### Task 3: Add Completion Subcommand + +**Files:** +- Create: `cmd/completion.go` +- Modify: `cmd/root.go:64-91` (add completion to `NewRootCommand()`) + +- [ ] **Step 1: Create completion command** + +Create `cmd/completion.go`: + +```go +package cmd + +import ( + "github.com/spf13/cobra" +) + +// completionCmd represents the completion command +var completionCmd = &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion script", + Long: `Generate shell completion script for spec-forge. + +To load completions: + +Bash: + source <(spec-forge completion bash) + + # To load completions for each session, execute once: + # Linux: + spec-forge completion bash > /etc/bash_completion.d/spec-forge + # macOS: + spec-forge completion bash > $(brew --prefix)/etc/bash_completion.d/spec-forge + +Zsh: + # If shell completion is not already enabled in your environment, + # you will need to enable it. Add the following to your ~/.zshrc: + autoload -Uz compinit + compinit + + # Then load completions: + spec-forge completion zsh > "${fpath[1]}/_spec-forge" + + # You will need to start a new shell for this setup to take effect. + +Fish: + spec-forge completion fish | source + + # To load completions for each session, execute once: + spec-forge completion fish > ~/.config/fish/completions/spec-forge.fish + +PowerShell: + spec-forge completion powershell | Out-String | Invoke-Expression + + # To load completions for every new session, run: + spec-forge completion powershell > spec-forge.ps1 + # and source this file from your PowerShell profile. +`, + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.ExactValidArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return cmd.Root().GenBashCompletion(os.Stdout) + case "zsh": + return cmd.Root().GenZshCompletion(os.Stdout) + case "fish": + return cmd.Root().GenFishCompletion(os.Stdout, true) + case "powershell": + return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) + default: + return nil + } + }, +} +``` + +Note: The `os` import will be resolved with `"os"` added to the import block. + +- [ ] **Step 2: Register completion in init() and NewRootCommand()** + +Add to `cmd/root.go`: + +In `init()` function, add after `rootCmd.AddCommand(enrichCmd)` (line ~88 equivalent — there is no enrichCmd added in init, but spring/publish/etc are): +```go +rootCmd.AddCommand(completionCmd) +``` + +In `NewRootCommand()` function (line ~86-90), add: +```go +c.AddCommand(newCompletionCmd()) +``` + +Create `newCompletionCmd()` factory in `cmd/completion.go`: + +```go +// newCompletionCmd creates a new completion command instance for testing. +func newCompletionCmd() *cobra.Command { + return &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion script", + Long: completionCmd.Long, + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.ExactValidArgs(1), + RunE: completionCmd.RunE, + } +} +``` + +- [ ] **Step 3: Verify completion works** + +Run: +```bash +go build -o ./build/spec-forge . && ./build/spec-forge completion bash | head -5 +``` +Expected: Outputs bash completion script (starts with `# bash completion` or similar). + +Run: +```bash +./build/spec-forge completion zsh | head -5 +``` +Expected: Outputs zsh completion script. + +- [ ] **Step 4: Commit** + +```bash +git add cmd/completion.go cmd/root.go +git commit -S -s -m "feat(cli): add shell completion subcommand (bash/zsh/fish/powershell)" +``` + +--- + +### Task 4: Add ValidArgsFunction for Enum Flags + +**Files:** +- Modify: `cmd/enrich.go:290-299` (newEnrichCmd flag registration) +- Modify: `cmd/publish.go:130-146` (newPublishCmd flag registration) +- Modify: `cmd/generate.go:288-329` (newGenerateCmd flag registration) + +- [ ] **Step 1: Add ValidArgsFunction to enrich command flags** + +In `cmd/enrich.go`, in `newEnrichCmd()`, after flag registration (after line 299), add: + +```go + // Shell completion for enum flags + c.RegisterFlagCompletionFunc("provider", cobra.FixedCompletions( + []string{"openai", "anthropic", "ollama", "custom"}, cobra.ShellCompDirectiveNoFileComp, + )) + c.RegisterFlagCompletionFunc("language", cobra.FixedCompletions( + []string{"en", "zh"}, cobra.ShellCompDirectiveNoFileComp, + )) + c.RegisterFlagCompletionFunc("output", cobra.FixedCompletions( + []string{"yaml", "json"}, cobra.ShellCompDirectiveNoFileComp, + )) +``` + +Also add same completions to `init()` for the global `enrichCmd` (after line 330). + +- [ ] **Step 2: Add ValidArgsFunction to publish command flags** + +In `cmd/publish.go`, in `newPublishCmd()`, after flag registration (after line 146), add: + +```go + c.RegisterFlagCompletionFunc("format", cobra.FixedCompletions( + []string{"yaml", "json"}, cobra.ShellCompDirectiveNoFileComp, + )) + c.RegisterFlagCompletionFunc("to", cobra.FixedCompletions( + []string{"readme"}, cobra.ShellCompDirectiveNoFileComp, + )) +``` + +Also add same completions to `init()` for the global `publishCmd` (after line 180). + +- [ ] **Step 3: Add ValidArgsFunction to generate command flags** + +In `cmd/generate.go`, in `newGenerateCmd()`, after flag registration (after line 327), add: + +```go + c.RegisterFlagCompletionFunc("output", cobra.FixedCompletions( + []string{"yaml", "json"}, cobra.ShellCompDirectiveNoFileComp, + )) + c.RegisterFlagCompletionFunc("language", cobra.FixedCompletions( + []string{"en", "zh"}, cobra.ShellCompDirectiveNoFileComp, + )) + c.RegisterFlagCompletionFunc("publish-target", cobra.FixedCompletions( + []string{"readme"}, cobra.ShellCompDirectiveNoFileComp, + )) +``` + +Also add same completions to `init()` for the global `generateCmd` (after line 374). + +- [ ] **Step 4: Verify completions work** + +Run: +```bash +go build -o ./build/spec-forge . && ./build/spec-forge enrich --provider +``` +Expected: Completes with "openai", "anthropic", "ollama", "custom". + +Alternatively verify the script content: +```bash +./build/spec-forge completion bash | grep -A2 "provider" +``` +Expected: Contains provider completion values. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/enrich.go cmd/publish.go cmd/generate.go +git commit -S -s -m "feat(cli): add shell completion for enum flags (provider, language, format, target)" +``` + +--- + +### Task 5: Apply Colored Output to Commands + +**Files:** +- Modify: `cmd/root.go:40-48` (printHintAndExit) +- Modify: `cmd/generate.go:56-284` (status messages) +- Modify: `cmd/enrich.go:108-204` (status messages) +- Modify: `cmd/publish.go:54-112` (status messages) +- Modify: `cmd/spring.go:63-166` (status messages) + +- [ ] **Step 1: Update root.go error output** + +Add import for `"github.com/spencercjh/spec-forge/internal/cli"` to `cmd/root.go`. + +Replace `printHintAndExit` function (lines 40-48): + +```go +func printHintAndExit(err error) { + if fe, ok := errors.AsType[*forgeerrors.Error](err); ok { + cli.Errorf(os.Stderr, fe.Error()) + hint := fe.Hint() + if hint != "" { + cli.Hintf(os.Stderr, hint) + } + os.Exit(exitCodeForCode(fe.Code)) + } + cli.Errorf(os.Stderr, err.Error()) + os.Exit(1) +} +``` + +- [ ] **Step 2: Update generate.go status output** + +Add import `"github.com/spencercjh/spec-forge/internal/cli"` to `cmd/generate.go`. + +Replace status slog calls with colored output. Key changes in `runGenerate()`: + +```go +// After detection (line ~90) +cli.Statusf(os.Stderr, "Detected %s project (tool: %s, build: %s)", + extractorImpl.Name(), info.BuildTool, info.BuildFilePath) + +// After patch (lines ~118-126) +if patchResult.DependencyAdded { + cli.Successf(os.Stderr, "Dependencies added temporarily") +} +if patchResult.PluginAdded { + cli.Successf(os.Stderr, "Plugin added temporarily") +} +if patchResult.SpringBootConfigured { + cli.Successf(os.Stderr, "spring-boot-maven-plugin configured with start/stop goals") +} + +// After generation (line ~167) +cli.Statusf(os.Stderr, "OpenAPI spec generated: %s (%s)", genResult.SpecFilePath, genResult.Format) + +// After validation (lines ~181-191) +if !valResult.Valid { + cli.Errorf(os.Stderr, "OpenAPI spec validation failed") + for _, validationErr := range valResult.Errors { + cli.Errorf(os.Stderr, " - %s", validationErr) + } + return errors.New("generated OpenAPI spec is invalid") +} +cli.Successf(os.Stderr, "OpenAPI spec validated") +} else { + cli.Skipf(os.Stderr, "Validation skipped") +} + +// Enrichment skipped (line ~201) +cli.Skipf(os.Stderr, "Enrichment skipped") + +// Spec saved (lines ~224-229) +cli.Successf(os.Stderr, "Spec saved: %s", targetPath) +// or +cli.Successf(os.Stderr, "Spec saved: %s", genResult.SpecFilePath) + +// Published (line ~270) +cli.Successf(os.Stderr, "Spec published to %s", pub.Name()) + +// Complete (line ~282) +cli.Successf(os.Stderr, "Generation complete") +``` + +Keep `slog.Debug*` calls for verbose logging — only replace `slog.Info*` status messages. + +- [ ] **Step 3: Update enrich.go status output** + +Add import `"github.com/spencercjh/spec-forge/internal/cli"` to `cmd/enrich.go`. + +Replace in `runEnrich()`: + +```go +// Line ~108 (start) +cli.Statusf(os.Stderr, "Enriching OpenAPI spec (provider: %s, model: %s, language: %s)", + prov, model, lang) + +// Line ~169 (partial enrichment) +cli.Statusf(os.Stderr, "Partial enrichment: %d/%d batches succeeded", + partialErr.TotalBatches-partialErr.FailedBatches, partialErr.TotalBatches) + +// Line ~204 (complete) +cli.Successf(os.Stderr, "Enrichment complete: %s", outputFile) +``` + +Replace in `enrichGeneratedSpec()` in `cmd/generate.go`: + +```go +// Line ~378 +cli.Statusf(os.Stderr, "Enriching OpenAPI spec with AI descriptions...") + +// Line ~461 +cli.Successf(os.Stderr, "OpenAPI spec enriched: %s", specFilePath) +``` + +- [ ] **Step 4: Update publish.go status output** + +Add import `"github.com/spencercjh/spec-forge/internal/cli"` to `cmd/publish.go`. + +Replace in `runPublish()`: + +```go +// Line ~54 +cli.Statusf(os.Stderr, "Publishing spec to %s", target) + +// Line ~62 +cli.Statusf(os.Stderr, "Using publisher: %s", pub.Name()) + +// Lines ~103-110 +cli.Successf(os.Stderr, "Published successfully (%d bytes, %s)", result.BytesWritten, result.Format) +if result.Message != "" { + cli.Statusf(os.Stderr, "%s", result.Message) +} +``` + +- [ ] **Step 5: Update spring.go status output** + +Add import `"github.com/spencercjh/spec-forge/internal/cli"` to `cmd/spring.go`. + +Replace `printProjectInfo()` slog calls: + +```go +func printProjectInfo(_ context.Context, info *extractor.ProjectInfo) { + cli.Statusf(os.Stderr, "Spring Project Detection Results") + cli.Statusf(os.Stderr, "Build Tool: %s", info.BuildTool) + cli.Statusf(os.Stderr, "Build File: %s", info.BuildFilePath) + + // ... (keep same logic, replace slog.InfoContext with cli.Statusf/Successf) + if springInfo.HasSpringdocDeps { + cli.Successf(os.Stderr, "springdoc Dependency: present (v%s)", springInfo.SpringdocVersion) + } else { + cli.Statusf(os.Stderr, "springdoc Dependency: not found") + } + // etc. +} +``` + +Replace `runSpringPatch()` status calls similarly. + +- [ ] **Step 6: Run tests** + +Run: +```bash +make test +``` +Expected: All existing tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add cmd/root.go cmd/generate.go cmd/enrich.go cmd/publish.go cmd/spring.go +git commit -S -s -m "feat(cli): apply colored output to all command status messages" +``` + +--- + +### Task 6: Add Progress Bar to Enricher Batch Processing + +**Files:** +- Modify: `internal/enricher/processor/concurrent.go` + +- [ ] **Step 1: Add progress bar to sequential processing** + +In `internal/enricher/processor/concurrent.go`, add import `"github.com/schollz/progressbar/v3"` and `"os"`. + +In `processSequential()`, add progress bar creation before the loop: + +```go +func (p *ConcurrentProcessor) processSequential(ctx context.Context, batches []*Batch) (*provider.TokenUsage, error) { + var ( + totalUsage provider.TokenUsage + failedCount int + failedErrors []error + ) + + bar := progressbar.NewOptions(len(batches), + progressbar.OptionSetWriter(os.Stderr), + progressbar.OptionShowCount(), + progressbar.OptionSetDescription("Enriching OpenAPI spec..."), + progressbar.OptionOnCompletion(func() { fmt.Fprint(os.Stderr, "\n") }), + ) + + for i, batch := range batches { + usage, err := p.batchProcessor.ProcessBatch(ctx, batch) + if usage != nil { + totalUsage.Add(usage) + } + if err != nil { + failedCount++ + failedErrors = append(failedErrors, err) + bar.Describe(fmt.Sprintf("Enriching OpenAPI spec... | %d failed", failedCount)) + slog.Warn("batch processing failed", + "batch_index", i, + "batch_type", batch.Type, + "error", err) + } + _ = bar.Add(1) + } + + _ = bar.Finish() + + if failedCount > 0 { + return &totalUsage, &PartialEnrichmentError{ + TotalBatches: len(batches), + FailedBatches: failedCount, + Errors: failedErrors, + } + } + return &totalUsage, nil +} +``` + +Note: Requires adding `"fmt"` to imports. + +- [ ] **Step 2: Add progress bar to concurrent processing** + +In `processConcurrent()`, add progress bar: + +```go +func (p *ConcurrentProcessor) processConcurrent(ctx context.Context, batches []*Batch) (*provider.TokenUsage, error) { + var ( + wg sync.WaitGroup + mu sync.Mutex + totalUsage provider.TokenUsage + failedCount int + failedErrors []error + ) + + bar := progressbar.NewOptions(len(batches), + progressbar.OptionSetWriter(os.Stderr), + progressbar.OptionShowCount(), + progressbar.OptionSetDescription("Enriching OpenAPI spec..."), + progressbar.OptionOnCompletion(func() { fmt.Fprint(os.Stderr, "\n") }), + ) + + semaphore := make(chan struct{}, p.concurrency) + + for i, batch := range batches { + wg.Add(1) + + go func(idx int, b *Batch) { + defer wg.Done() + + semaphore <- struct{}{} + defer func() { <-semaphore }() + + usage, err := p.batchProcessor.ProcessBatch(ctx, b) + mu.Lock() + if usage != nil { + totalUsage.Add(usage) + } + if err != nil { + failedCount++ + failedErrors = append(failedErrors, err) + bar.Describe(fmt.Sprintf("Enriching OpenAPI spec... | %d failed", failedCount)) + slog.Warn("batch processing failed", + "batch_index", idx, + "batch_type", b.Type, + "error", err) + } + mu.Unlock() + _ = bar.Add(1) + }(i, batch) + } + + wg.Wait() + _ = bar.Finish() + + if failedCount > 0 { + return &totalUsage, &PartialEnrichmentError{ + TotalBatches: len(batches), + FailedBatches: failedCount, + Errors: failedErrors, + } + } + + return &totalUsage, nil +} +``` + +- [ ] **Step 3: Run tests** + +Run: +```bash +make test +``` +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add internal/enricher/processor/concurrent.go +git commit -S -s -m "feat(enricher): add progress bar for batch processing" +``` + +--- + +### Task 7: Final Verification + +**Files:** None (verification only) + +- [ ] **Step 1: Run full test suite** + +Run: +```bash +make test +``` +Expected: All tests pass. + +- [ ] **Step 2: Run linter** + +Run: +```bash +make lint +``` +Expected: No new lint errors. + +- [ ] **Step 3: Format code** + +Run: +```bash +make fmt +``` + +- [ ] **Step 4: Build binary** + +Run: +```bash +make build +``` +Expected: Build succeeds. + +- [ ] **Step 5: Smoke test completion** + +Run: +```bash +./build/spec-forge completion bash | head -10 +./build/spec-forge completion zsh | head -10 +./build/spec-forge --help +``` +Expected: Completion scripts output correctly, help shows completion subcommand. + +- [ ] **Step 6: Commit any formatting fixes** + +```bash +git add -A +git diff --cached --quiet || git commit -S -s -m "style: format code for CLI UX improvements" +``` diff --git a/go.mod b/go.mod index c714bbf..389bda9 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,10 @@ module github.com/spencercjh/spec-forge go 1.26 require ( + github.com/fatih/color v1.19.0 github.com/getkin/kin-openapi v0.133.0 github.com/scagogogo/gradle-parser v0.0.0-20250731142659-dcb499383a50 + github.com/schollz/progressbar/v3 v3.19.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 @@ -25,6 +27,9 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect @@ -32,6 +37,7 @@ require ( github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pkoukk/tiktoken-go v0.1.6 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect @@ -40,6 +46,7 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/sys v0.35.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.28.0 // indirect golang.org/x/text v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index 5d48c26..5ada7a7 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,13 @@ +github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= +github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -32,6 +36,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= @@ -47,6 +59,8 @@ github.com/pkoukk/tiktoken-go v0.1.6/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYde github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -54,6 +68,8 @@ github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDc github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/scagogogo/gradle-parser v0.0.0-20250731142659-dcb499383a50 h1:Rt3pxBn0gdzlGhv3Z4825ZYCeV5bWWbo1SW2G07b92o= github.com/scagogogo/gradle-parser v0.0.0-20250731142659-dcb499383a50/go.mod h1:lmv+f8O/2kCmC5lx6J4INuH5WtOramwcfyYt6AkC0oI= +github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc= +github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -87,8 +103,11 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/output.go b/internal/cli/output.go new file mode 100644 index 0000000..c8a93bc --- /dev/null +++ b/internal/cli/output.go @@ -0,0 +1,64 @@ +// Package cli provides terminal output helpers for spec-forge CLI. +package cli + +import ( + "fmt" + "io" + "os" + + "github.com/fatih/color" +) + +var ( + green = color.New(color.FgGreen).FprintfFunc() + red = color.New(color.FgRed, color.Bold).FprintfFunc() + dim = color.New(color.Faint).FprintfFunc() +) + +func init() { + initColorState() +} + +// initColorState configures color output based on the NO_COLOR environment variable. +// Per the NO_COLOR convention (https://no-color.org/), mere presence of the variable +// disables color output regardless of its value (including empty string). +func initColorState() { + if _, ok := os.LookupEnv("NO_COLOR"); ok { + color.NoColor = true + } +} + +// ColorEnabled reports whether colored output is active. +func ColorEnabled() bool { + return !color.NoColor +} + +// Successf prints a green success message with checkmark prefix. +func Successf(w io.Writer, format string, args ...any) { + msg := fmt.Sprintf(format, args...) + green(w, "✅ %s\n", msg) +} + +// Skipf prints a dim skip message with skip prefix. +func Skipf(w io.Writer, format string, args ...any) { + msg := fmt.Sprintf(format, args...) + dim(w, "⏭️ %s\n", msg) +} + +// Errorf prints a red error message. +func Errorf(w io.Writer, format string, args ...any) { + msg := fmt.Sprintf(format, args...) + red(w, "❌ %s\n", msg) +} + +// Hintf prints a cyan hint prefix with yellow hint content as a single atomic write. +func Hintf(w io.Writer, format string, args ...any) { + msg := fmt.Sprintf(format, args...) + line := color.CyanString("💡 Hint: ") + color.YellowString("%s\n", msg) + fmt.Fprint(w, line) +} + +// Statusf prints a neutral status message. +func Statusf(w io.Writer, format string, args ...any) { + fmt.Fprintf(w, format+"\n", args...) +} diff --git a/internal/cli/output_test.go b/internal/cli/output_test.go new file mode 100644 index 0000000..866d31a --- /dev/null +++ b/internal/cli/output_test.go @@ -0,0 +1,131 @@ +package cli + +import ( + "bytes" + "os" + "testing" + + "github.com/fatih/color" + "github.com/stretchr/testify/assert" +) + +func TestColorEnabled(t *testing.T) { + t.Run("NO_COLOR=1 disables color", func(t *testing.T) { + os.Setenv("NO_COLOR", "1") + defer os.Unsetenv("NO_COLOR") + origColorNoColor := color.NoColor + defer func() { color.NoColor = origColorNoColor }() + + initColorState() + + assert.True(t, color.NoColor) + assert.False(t, ColorEnabled()) + }) + + t.Run("NO_COLOR empty string disables color", func(t *testing.T) { + os.Setenv("NO_COLOR", "") + defer os.Unsetenv("NO_COLOR") + origColorNoColor := color.NoColor + defer func() { color.NoColor = origColorNoColor }() + + initColorState() + + assert.True(t, color.NoColor) + assert.False(t, ColorEnabled()) + }) + + t.Run("no NO_COLOR preserves existing color.NoColor", func(t *testing.T) { + os.Unsetenv("NO_COLOR") + origColorNoColor := color.NoColor + defer func() { color.NoColor = origColorNoColor }() + + // Verify invariant: absent NO_COLOR, initColorState must not override color.NoColor + for _, initial := range []bool{false, true} { + color.NoColor = initial + initColorState() + assert.Equal(t, initial, color.NoColor, "initColorState should not override color.NoColor when NO_COLOR is absent") + } + }) +} + +func TestStatusFunctions(t *testing.T) { + origNoColor, noColorWasSet := os.LookupEnv("NO_COLOR") + os.Unsetenv("NO_COLOR") + defer func() { + if noColorWasSet { + os.Setenv("NO_COLOR", origNoColor) + } else { + os.Unsetenv("NO_COLOR") + } + }() + origColorNoColor := color.NoColor + defer func() { color.NoColor = origColorNoColor }() + initColorState() + + t.Run("Successf contains checkmark prefix", func(t *testing.T) { + var buf bytes.Buffer + Successf(&buf, "test message") + output := buf.String() + assert.Contains(t, output, "✅") + assert.Contains(t, output, "test message") + }) + + t.Run("Skipf contains skip prefix", func(t *testing.T) { + var buf bytes.Buffer + Skipf(&buf, "skipped") + output := buf.String() + assert.Contains(t, output, "⏭️") + assert.Contains(t, output, "skipped") + }) + + t.Run("Errorf contains error prefix", func(t *testing.T) { + var buf bytes.Buffer + Errorf(&buf, "something failed") + output := buf.String() + assert.Contains(t, output, "❌") + assert.Contains(t, output, "something failed") + }) + + t.Run("Hintf contains hint prefix", func(t *testing.T) { + var buf bytes.Buffer + Hintf(&buf, "check your config") + output := buf.String() + assert.Contains(t, output, "💡 Hint:") + assert.Contains(t, output, "check your config") + }) + + t.Run("Statusf formats with args", func(t *testing.T) { + var buf bytes.Buffer + Statusf(&buf, "found %d items", 5) + assert.Contains(t, buf.String(), "found 5 items") + }) +} + +func TestNoColorMode(t *testing.T) { + os.Setenv("NO_COLOR", "1") + defer os.Unsetenv("NO_COLOR") + origColorNoColor := color.NoColor + defer func() { color.NoColor = origColorNoColor }() + initColorState() + + t.Run("Successf no ANSI codes", func(t *testing.T) { + var buf bytes.Buffer + Successf(&buf, "test") + assert.NotContains(t, buf.String(), "\x1b[") + assert.Contains(t, buf.String(), "✅") + }) + + t.Run("Errorf no ANSI codes", func(t *testing.T) { + var buf bytes.Buffer + Errorf(&buf, "test") + assert.NotContains(t, buf.String(), "\x1b[") + assert.Contains(t, buf.String(), "❌") + }) + + t.Run("Hintf no ANSI codes", func(t *testing.T) { + var buf bytes.Buffer + Hintf(&buf, "test") + assert.NotContains(t, buf.String(), "\x1b[") + assert.Contains(t, buf.String(), "💡 Hint:") + }) +} diff --git a/internal/enricher/processor/concurrent.go b/internal/enricher/processor/concurrent.go index 6118026..68d1abb 100644 --- a/internal/enricher/processor/concurrent.go +++ b/internal/enricher/processor/concurrent.go @@ -2,9 +2,13 @@ package processor import ( "context" + "fmt" "log/slog" + "os" "sync" + "github.com/schollz/progressbar/v3" + "github.com/spencercjh/spec-forge/internal/enricher/provider" ) @@ -49,6 +53,13 @@ func (p *ConcurrentProcessor) processSequential(ctx context.Context, batches []* failedErrors []error ) + bar := progressbar.NewOptions(len(batches), + progressbar.OptionSetWriter(os.Stderr), + progressbar.OptionShowCount(), + progressbar.OptionSetDescription("Enriching OpenAPI spec... | 0 failed"), + progressbar.OptionOnCompletion(func() { fmt.Fprint(os.Stderr, "\n") }), + ) + for i, batch := range batches { usage, err := p.batchProcessor.ProcessBatch(ctx, batch) if usage != nil { @@ -57,11 +68,19 @@ func (p *ConcurrentProcessor) processSequential(ctx context.Context, batches []* if err != nil { failedCount++ failedErrors = append(failedErrors, err) + bar.Describe(fmt.Sprintf("Enriching OpenAPI spec... | %d failed", failedCount)) slog.Warn("batch processing failed", "batch_index", i, "batch_type", batch.Type, "error", err) } + if err := bar.Add(1); err != nil { + slog.Debug("progress bar add failed", "error", err) + } + } + + if err := bar.Finish(); err != nil { + slog.Debug("progress bar finish failed", "error", err) } if failedCount > 0 { @@ -84,6 +103,13 @@ func (p *ConcurrentProcessor) processConcurrent(ctx context.Context, batches []* failedErrors []error ) + bar := progressbar.NewOptions(len(batches), + progressbar.OptionSetWriter(os.Stderr), + progressbar.OptionShowCount(), + progressbar.OptionSetDescription("Enriching OpenAPI spec... | 0 failed"), + progressbar.OptionOnCompletion(func() { fmt.Fprint(os.Stderr, "\n") }), + ) + semaphore := make(chan struct{}, p.concurrency) for i, batch := range batches { @@ -92,7 +118,6 @@ func (p *ConcurrentProcessor) processConcurrent(ctx context.Context, batches []* go func(idx int, b *Batch) { defer wg.Done() - // Acquire semaphore semaphore <- struct{}{} defer func() { <-semaphore }() @@ -104,18 +129,24 @@ func (p *ConcurrentProcessor) processConcurrent(ctx context.Context, batches []* if err != nil { failedCount++ failedErrors = append(failedErrors, err) + bar.Describe(fmt.Sprintf("Enriching OpenAPI spec... | %d failed", failedCount)) slog.Warn("batch processing failed", "batch_index", idx, "batch_type", b.Type, "error", err) } + if err := bar.Add(1); err != nil { + slog.Debug("progress bar add failed", "error", err) + } mu.Unlock() }(i, batch) } wg.Wait() + if err := bar.Finish(); err != nil { + slog.Debug("progress bar finish failed", "error", err) + } - // Return partial error if some batches failed if failedCount > 0 { return &totalUsage, &PartialEnrichmentError{ TotalBatches: len(batches),