Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
69f8399
build: add fatih/color and schollz/progressbar dependencies for CLI UX
spencercjh Mar 30, 2026
7c00ee7
feat(cli): add colored output package with success/error/hint helpers
spencercjh Mar 30, 2026
b555236
feat(cli): add shell completion subcommand (bash/zsh/fish/powershell)
spencercjh Mar 31, 2026
af5b506
feat(enricher): add progress bar to batch processing
spencercjh Mar 31, 2026
04912b4
feat(cli): add shell completion for enum flags (provider, language, f…
spencercjh Mar 31, 2026
a22f6e0
feat(cli): apply colored output to root and spring commands
spencercjh Mar 31, 2026
16d04d0
style: add nolint comments for errcheck on completion registration an…
spencercjh Mar 31, 2026
ba4d50f
refactor: replace nolint errcheck with proper error handling
spencercjh Mar 31, 2026
3efe8f9
build: promote progressbar to direct dependency in go.mod
spencercjh Mar 31, 2026
1e0471a
docs: add CLI UX improvements design spec and implementation plan
spencercjh Mar 31, 2026
071fd4c
chore: remove CLI UX design spec and plan docs after implementation
spencercjh Mar 31, 2026
13dec60
refactor(cli): clean up output package per code review
spencercjh Mar 31, 2026
01839b3
fix(cli): use os.LookupEnv for NO_COLOR and preserve fatih/color TTY …
spencercjh Mar 31, 2026
eb6679b
fix(enricher): move progress bar Add inside mutex for atomic updates
spencercjh Mar 31, 2026
dbf950e
docs: align design doc with actual implementation
spencercjh Mar 31, 2026
4bc8d5a
fix(cli): remove invalid yaml/json completion for enrich --output flag
spencercjh Mar 31, 2026
e7844a3
test(cli): rewrite ColorEnabled tests to verify invariants
spencercjh Mar 31, 2026
cf4cd62
fix(cli): remove duplicate error output from Execute()
spencercjh Mar 31, 2026
68eaf27
fix(enricher): show consistent failure count in concurrent progress bar
spencercjh Mar 31, 2026
c471da0
fix(test): preserve NO_COLOR presence/absence state in TestStatusFunc…
spencercjh Mar 31, 2026
e1a76e7
refactor(cmd): demote user-facing slog.Info to slog.Debug in generate
spencercjh Mar 31, 2026
df687da
fix(cli): make Hintf emit single atomic write to prevent interleaved …
spencercjh Mar 31, 2026
34d0354
docs(design): align auto-detection section with actual initColorState…
spencercjh Mar 31, 2026
7f64d80
fix(cmd): restore generation start status message via cli.Statusf
spencercjh Mar 31, 2026
73bd593
fix(cmd): include source spec file path in publish status output
spencercjh Mar 31, 2026
46547ce
fix(cmd): promote build file restore messages to user-facing cli output
spencercjh Mar 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions cmd/completion.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
19 changes: 12 additions & 7 deletions cmd/enrich.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -298,6 +295,10 @@ 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"})
registerCompletion(c, "output", []string{"yaml", "json"})

Comment thread
spencercjh marked this conversation as resolved.
return c
}

Expand Down Expand Up @@ -328,4 +329,8 @@ 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"})
registerCompletion(enrichCmd, "output", []string{"yaml", "json"})
Comment thread
spencercjh marked this conversation as resolved.
Outdated
}
55 changes: 26 additions & 29 deletions cmd/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Comment thread
spencercjh marked this conversation as resolved.
// Step 2: Patch project if needed
patchOpts := &extractor.PatchOptions{
Expand All @@ -116,13 +113,13 @@ func runGenerate(cmd *cobra.Command, args []string) error { //nolint:gocyclo //
}

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
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
20 changes: 11 additions & 9 deletions cmd/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -51,15 +51,15 @@ 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 spec to %s", target)

Comment thread
spencercjh marked this conversation as resolved.
Outdated
// Create publisher using factory
pub, err := publisher.NewPublisher(target)
if err != nil {
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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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"})
}
Loading
Loading