Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 24 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# go-readme

`go-readme` is a README automation CLI for Go modules. It parses project metadata
from `go.mod` and git, renders a template, and idempotently updates `README.md`
between managed markers so custom content is preserved.
from `go.mod`, git, Go source, and common repo files, renders a template, and
idempotently updates `README.md` between managed markers so custom content is
preserved.

If you are new to the project, start with `go-readme generate --dry-run` to see
what will be written before changing any files.
Expand Down Expand Up @@ -40,6 +41,10 @@ Run from inside a Go module to generate or update `README.md`:
go-readme generate
```

In interactive mode, `go-readme` now asks for a short project description, key
features, a usage example, configuration notes, and contributing notes when
those values were not already supplied by flags.

Preview without writing:

```sh
Expand All @@ -66,10 +71,14 @@ go-readme generate --dir ./path/to/module
|------|---------|-------------|
| `--dir` | `.` | Target project directory |
| `--description`, `-d` | empty | Project description |
| `--features` | empty | Comma-separated key project features |
| `--usage-example` | empty | Usage command or code snippet |
| `--configuration` | empty | Configuration notes |
| `--contributing-notes` | empty | Contributor guidance |
| `--template`, `-t` | `go_default.md` | Embedded template name |
| `--dry-run` | `false` | Print output without writing README |
| `--force` | `false` | Overwrite entire README (skip marker replacement) |
| `--non-interactive` | `false` | Disable interactive prompt |
| `--non-interactive` | `false` | Disable the interactive questionnaire |

### Doctor Flags

Expand All @@ -79,10 +88,18 @@ go-readme generate --dir ./path/to/module

## What gets generated

- **Title / metadata** — module name, install command, go version
- **Repository** — git remote URL when configured
- **Description** — from flag or interactive prompt
- **License** — detected from license file name
- **Overview** — project name plus description from a flag, prompt, or package
doc comment fallback
- **Features** — optional bullet list from prompt or `--features`
- **Installation / usage** — install command plus a smarter default usage
example that can be overridden
- **Configuration / development** — optional config notes plus standard Go
build and test commands
- **Requirements / dependencies** — Go version and direct dependencies from
`go.mod`
- **Repository / contributing / security** — git remote plus links to
`CONTRIBUTING.md` and `SECURITY.md` when present
- **License** — detected from the license file name and linked in the output

## Managed markers (beginner-friendly)

Expand Down
101 changes: 87 additions & 14 deletions cmd/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import (
var (
generateDir string
generateDescription string
generateFeatures []string
generateUsageExample string
generateConfiguration string
generateContributing string
generateTemplate string
generateDryRun bool
generateForce bool
Expand All @@ -32,24 +36,38 @@ idempotently updates) README.md.`,
func init() {
generateCmd.Flags().StringVar(&generateDir, "dir", ".", "target project directory")
generateCmd.Flags().StringVarP(&generateDescription, "description", "d", "", "project description")
generateCmd.Flags().StringSliceVar(&generateFeatures, "features", nil, "comma-separated key project features")
generateCmd.Flags().StringVar(&generateUsageExample, "usage-example", "", "usage example command or snippet")
generateCmd.Flags().StringVar(&generateConfiguration, "configuration", "", "configuration notes")
generateCmd.Flags().StringVar(&generateContributing, "contributing-notes", "", "contributing guidance")
generateCmd.Flags().StringVarP(&generateTemplate, "template", "t", "go_default.md", "template file name")
generateCmd.Flags().BoolVar(&generateDryRun, "dry-run", false, "print the README without writing to disk")
generateCmd.Flags().BoolVar(&generateForce, "force", false, "overwrite the entire README (ignore markers)")
generateCmd.Flags().BoolVar(&generateNonInteractive, "non-interactive", false, "disable interactive prompts")
}

func runGenerate(cmd *cobra.Command, _ []string) error {
description := generateDescription
if description == "" && !generateNonInteractive {
description = promptDescription(cmd)
answers := generatePromptAnswers{
Description: strings.TrimSpace(generateDescription),
Features: append([]string(nil), generateFeatures...),
UsageExample: strings.TrimSpace(generateUsageExample),
Configuration: strings.TrimSpace(generateConfiguration),
Contributing: strings.TrimSpace(generateContributing),
}
if !generateNonInteractive {
answers = promptGenerateMetadata(cmd, answers)
}

opts := app.GenerateOptions{
Dir: generateDir,
Description: description,
Template: generateTemplate,
DryRun: generateDryRun,
Force: generateForce,
Dir: generateDir,
Description: answers.Description,
Features: answers.Features,
UsageExample: answers.UsageExample,
Configuration: answers.Configuration,
Contributing: answers.Contributing,
Template: generateTemplate,
DryRun: generateDryRun,
Force: generateForce,
}

result, err := app.Generate(opts)
Expand All @@ -70,16 +88,71 @@ func runGenerate(cmd *cobra.Command, _ []string) error {
return nil
}

// promptDescription asks the user for a short project description on stdin.
// Returns an empty string if stdin is not a terminal or the user skips.
func promptDescription(cmd *cobra.Command) string {
if fi, err := os.Stdin.Stat(); err != nil || (fi.Mode()&os.ModeCharDevice) == 0 {
return ""
type generatePromptAnswers struct {
Description string
Features []string
UsageExample string
Configuration string
Contributing string
}

// promptGenerateMetadata asks the user for optional README metadata on stdin.
// Returns the existing answers unchanged if stdin is not a terminal.
func promptGenerateMetadata(cmd *cobra.Command, answers generatePromptAnswers) generatePromptAnswers {
if !stdinIsInteractive() {
return answers
}
fmt.Fprint(cmd.OutOrStdout(), "Project description (leave blank to skip): ")

scanner := bufio.NewScanner(os.Stdin)
if answers.Description == "" {
answers.Description = promptLine(cmd, scanner, "Project description (leave blank to auto-detect/skip): ")
}
if len(answers.Features) == 0 {
answers.Features = parsePromptList(promptLine(cmd, scanner, "Key features (comma-separated, leave blank to skip): "))
}
if answers.UsageExample == "" {
answers.UsageExample = promptLine(cmd, scanner, "Usage example or command (leave blank for default): ")
}
if answers.Configuration == "" {
answers.Configuration = promptLine(cmd, scanner, "Configuration notes (leave blank to skip): ")
}
if answers.Contributing == "" {
answers.Contributing = promptLine(cmd, scanner, "Contributing notes (leave blank to skip): ")
}
return answers
}

func stdinIsInteractive() bool {
fi, err := os.Stdin.Stat()
return err == nil && (fi.Mode()&os.ModeCharDevice) != 0
}

func promptLine(cmd *cobra.Command, scanner *bufio.Scanner, prompt string) string {
fmt.Fprint(cmd.OutOrStdout(), prompt)
if scanner.Scan() {
return strings.TrimSpace(scanner.Text())
}
return ""
}

func parsePromptList(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}

parts := strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == '\n'
})
items := make([]string, 0, len(parts))
for _, part := range parts {
item := strings.TrimSpace(part)
if item == "" {
continue
}
items = append(items, item)
}
if len(items) == 0 {
return nil
}
return items
}
29 changes: 25 additions & 4 deletions cmd/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ func TestRunGenerate_UsesConfiguredDir(t *testing.T) {
}
}

func TestPromptDescription_NonTTYReturnsEmpty(t *testing.T) {
func TestPromptGenerateMetadata_NonTTYReturnsEmpty(t *testing.T) {
originalStdin := os.Stdin
r, w, err := os.Pipe()
if err != nil {
Expand All @@ -99,15 +99,28 @@ func TestPromptDescription_NonTTYReturnsEmpty(t *testing.T) {
})

c := &cobraCommandStub{}
got := promptDescription(c.command())
if got != "" {
t.Fatalf("promptDescription = %q, want empty string for non-tty stdin", got)
got := promptGenerateMetadata(c.command(), generatePromptAnswers{})
if got.Description != "" || len(got.Features) != 0 || got.UsageExample != "" || got.Configuration != "" || got.Contributing != "" {
t.Fatalf("promptGenerateMetadata = %#v, want zero values for non-tty stdin", got)
}
if c.output() != "" {
t.Fatalf("expected no prompt output for non-tty stdin, got: %q", c.output())
}
}

func TestParsePromptList(t *testing.T) {
got := parsePromptList("fast, simple,\nportable")
want := []string{"fast", "simple", "portable"}
if len(got) != len(want) {
t.Fatalf("parsePromptList length = %d, want %d (%v)", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("parsePromptList[%d] = %q, want %q", i, got[i], want[i])
}
}
}

type cobraCommandStub struct {
out bytes.Buffer
}
Expand Down Expand Up @@ -146,13 +159,21 @@ func withWorkingDir(t *testing.T, dir string) {
func resetGenerateFlags(t *testing.T) {
t.Helper()
generateDescription = ""
generateFeatures = nil
generateUsageExample = ""
generateConfiguration = ""
generateContributing = ""
generateDir = "."
generateTemplate = "go_default.md"
generateDryRun = false
generateForce = false
generateNonInteractive = false
t.Cleanup(func() {
generateDescription = ""
generateFeatures = nil
generateUsageExample = ""
generateConfiguration = ""
generateContributing = ""
generateDir = "."
generateTemplate = "go_default.md"
generateDryRun = false
Expand Down
Loading
Loading