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
9 changes: 9 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"WebFetch(domain:github.com)"
],
"deny": [],
"ask": []
}
}
33 changes: 33 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
repos:
# Go hooks
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-mod-tidy
- id: go-unit-tests
- id: golangci-lint

# Markdown hooks
- repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.43.0
hooks:
- id: markdownlint
args: ['--fix']

# Spellcheck
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
hooks:
- id: codespell
name: Check spelling

# General hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: check-merge-conflict
17 changes: 11 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,18 @@ goreleaser release --snapshot --clean
goreleaser check
```

### Linting
### Code Quality

```bash
# Markdown linting (runs in GitHub Actions, installed via brew)
markdownlint-cli2 "**/*.md"
# Run pre-commit hooks on all files
pre-commit run --all-files

# Go linting (if golangci-lint is installed)
golangci-lint run
# Run pre-commit hooks on staged files
pre-commit run

# Manually run specific linters
golangci-lint run # Go linting
markdownlint-cli2 "**/*.md" # Markdown linting
```

## Architecture
Expand Down Expand Up @@ -99,6 +103,7 @@ The project follows a standard Go CLI application structure:
- The root command is named "example" and should be renamed for your application
- Debug logging is available via the `--debug` flag
- Configuration precedence: flags > env vars > config file > defaults
- Logger instance is globally available as `cmd.Logger`
- Logger instance is passed via dependency injection to `NewRootCmd()`
- Releases are automated via GoReleaser when tags are pushed to GitHub
- Binary builds have CGO disabled for maximum portability
- Pre-commit hooks automatically run on git commit to ensure code quality
81 changes: 80 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,86 @@ go test -cover ./...
go test -v ./...
```

### Linting
### Code Quality

#### Pre-commit Hooks

This project uses [pre-commit](https://pre-commit.com/) to ensure code quality
and consistency. The hooks automatically run before each commit.

##### Installing Pre-commit and Required Tools

macOS (Homebrew)

```bash
# Install all tools via Homebrew
brew install pre-commit golangci-lint markdownlint-cli codespell
```

Linux (Ubuntu/Debian)

```bash
# Install available packages via apt
sudo apt update
sudo apt install pre-commit golang-golangci-lint

# Install Node.js and markdownlint-cli
sudo apt install nodejs npm
sudo npm install -g markdownlint-cli

# Install codespell via pip
sudo apt install python3-pip
pip3 install codespell
```

Windows

```bash
# Using winget for golangci-lint
winget install GolangCI.golangci-lint

# Using pip for Python tools (requires Python installed)
pip install pre-commit codespell

# Using npm for markdownlint-cli (requires Node.js installed)
npm install -g markdownlint-cli
```

Alternative: Using pip and npm directly (all platforms)

```bash
# Install Go linter
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest

# Install Python tools
pip install pre-commit codespell

# Install Node.js tools
npm install -g markdownlint-cli
```

##### Setting up pre-commit in your repository

```bash
# Install the git hooks
pre-commit install

# Run hooks manually on all files
pre-commit run --all-files

# Run hooks on staged files only
pre-commit run
```

The following hooks are configured:

- **Go**: `go fmt`, `go mod tidy`, unit tests, and `golangci-lint`
- **Markdown**: `markdownlint` with auto-fix
- **Spelling**: `codespell` for checking spelling errors
- **General**: trailing whitespace, end-of-file fixer, YAML validation,
large file detection, merge conflict detection

#### Manual Linting

```bash
# Install Go linter
Expand Down
112 changes: 62 additions & 50 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Package cmd implements the command-line interface using Cobra and Viper.
package cmd

import (
"errors"
"fmt"
"os"

"github.com/charmbracelet/log"
Expand All @@ -9,49 +12,70 @@ import (
"github.com/spf13/viper"
)

var (
configFile string
Debug bool
Logger *log.Logger
EnvVar string
)
// ErrRootCmd indicates a failure in root command execution.
var ErrRootCmd = errors.New("failed to run example command")

// NewRootCmd creates the root command with configured flags and pre-run hooks.
func NewRootCmd(logger *log.Logger) *cobra.Command {
cmd := &cobra.Command{
Use: "example",
Short: "An example application.",
Long: "An example application, it doesn't do anything.",
PreRunE: func(cmd *cobra.Command, args []string) error {
if err := viper.BindPFlag("debug", cmd.PersistentFlags().Lookup("debug")); err != nil {
return fmt.Errorf("%w: %s", ErrRootCmd, err)
}

return nil
},
}

// RootCmd is the base command when called without any subcommands.
var RootCmd = &cobra.Command{
Use: "example",
Short: "An example application.",
Long: "An example application, it doesn't do anything.",
}
var configFile string
var debug bool

// Execute adds initialization.
func Execute() {
err := RootCmd.Execute()
if err != nil {
Logger.Error("error running command")
os.Exit(1)
}
cmd.PersistentFlags().StringVar(&configFile, "config", "", "config file (default is $HOME/.config.toml)")
cmd.PersistentFlags().BoolVar(&debug, "debug", false, "enable debug mode")

// cmd.AddCommand(NewChildCommand())

return cmd
}

// init sets and binds flags.
func init() {
cobra.OnInitialize(initConfig)
// Execute initializes the logger, configuration, and runs the root command.
// It returns the command instance regardless of execution success.
func Execute() *cobra.Command {
logger := log.NewWithOptions(os.Stderr, log.Options{
ReportCaller: true,
ReportTimestamp: true,
Level: log.WarnLevel,
})

RootCmd.PersistentFlags().StringVar(&configFile, "config", "", "config file (default is $HOME/.config.toml)")
RootCmd.PersistentFlags().BoolVar(&Debug, "debug", false, "enable debug mode")
cobra.OnInitialize(func() {
initConfig(logger)
})

_ = viper.BindPFlag("debug", RootCmd.PersistentFlags().Lookup("debug"))
}
cmd := NewRootCmd(logger)

if err := cmd.Execute(); err != nil {
logger.Error(ErrRootCmd.Error(), "error", err)

return nil
}

// initConfig loads env variables and the config file.
func initConfig() {
initLogger()
return cmd
}

// initConfig loads configuration from environment variables, config files, and flags.
// Configuration precedence: flags > env > config file > defaults.
func initConfig(logger *log.Logger) {
if err := godotenv.Load(); err != nil {
Logger.Debug(".env file not found, using environment variables")
logger.Debug(".env file not found, using environment variables")
} else {
Logger.Debug(".env file loaded successfully")
logger.Debug(".env file loaded successfully")
}

configFile := viper.GetString("config")

if configFile != "" {
viper.SetConfigFile(configFile)
} else {
Expand All @@ -65,33 +89,21 @@ func initConfig() {
}

viper.AutomaticEnv()
_ = viper.BindEnv("envVar", "ENV_VAR")
if err := viper.BindEnv("debug", "DEBUG"); err != nil {
logger.Error(ErrRootCmd.Error(), "error", err)
}

if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
Logger.Debug("config file not found")
logger.Debug("config file not found")
} else {
Logger.Error("error loading config file", "error", err)
logger.Error("error loading config file", "error", err)
}
} else {
Logger.Debug("using config file", "file", viper.ConfigFileUsed())
logger.Debug("using config file", "file", viper.ConfigFileUsed())
}

if viper.GetBool("debug") {
Debug = true
initLogger()
}
}

// initLogger initializes the logger.
func initLogger() {
Logger = log.New(os.Stderr)
Logger.SetReportCaller(true)
Logger.SetReportTimestamp(true)

if Debug {
Logger.SetLevel(log.DebugLevel)
} else {
Logger.SetLevel(log.WarnLevel)
logger.SetLevel(log.DebugLevel)
}
}
Loading
Loading