diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..a7347f1
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,150 @@
+name: CI
+
+on:
+ pull_request:
+ branches: [main]
+
+permissions:
+ contents: read
+ pull-requests: write
+
+jobs:
+ test:
+ name: Test & Coverage
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+
+ - name: Run tests with coverage
+ id: test
+ run: |
+ go vet ./...
+
+ set +e
+ go test ./... -coverprofile=coverage.out -covermode=atomic > test-output.txt 2>&1
+ exit_code=$?
+ set -e
+
+ total=$(go tool cover -func=coverage.out | grep total | awk '{print $NF}')
+ echo "coverage=$total" >> $GITHUB_OUTPUT
+ echo "exit_code=$exit_code" >> $GITHUB_OUTPUT
+
+ go tool cover -func=coverage.out > coverage_detail.txt
+
+ EXIT_CODE=$exit_code TOTAL=$total python3 << 'PYEOF'
+ import os, re
+
+ exit_code = int(os.environ.get("EXIT_CODE", "0"))
+ total = os.environ.get("TOTAL", "0.0%")
+
+ # Parse go tool cover -func output
+ with open("coverage_detail.txt") as f:
+ lines = f.readlines()
+
+ pkg_data = {}
+ for line in lines:
+ if not line.strip() or "total" in line.lower():
+ continue
+ # Format: path/file.go:line: func pct%
+ parts = line.split()
+ if len(parts) < 2:
+ continue
+ file_func = parts[0]
+ pct_str = parts[-1].rstrip('%')
+ try:
+ pct = float(pct_str)
+ except ValueError:
+ continue
+
+ # Extract package path
+ if ':' in file_func:
+ file_path = file_func.split(':')[0]
+ else:
+ continue
+
+ # Normalize package
+ if file_path.startswith("github.com/"):
+ segments = file_path.split("/")
+ if "internal" in segments:
+ idx = segments.index("internal")
+ # Keep internal/subpackage structure
+ pkg = "/".join(segments[:idx+2]) if idx+1 < len(segments) else file_path
+ else:
+ pkg = "/".join(segments[:-1])
+ if not pkg:
+ pkg = "github.com/PyratLabs/ugo"
+ else:
+ pkg = file_path
+
+ if pkg not in pkg_data:
+ pkg_data[pkg] = []
+ pkg_data[pkg].append(pct)
+
+ # Write report
+ with open("report.md", "w") as f:
+ f.write("## Test Results\n\n")
+
+ if exit_code == 0:
+ f.write("✅ **All tests passed**\n\n")
+ else:
+ f.write("❌ **Some tests failed**\n\n")
+ f.write("Test output
\n\n")
+ f.write("```\n")
+ with open("test-output.txt") as tf:
+ f.write(tf.read())
+ f.write("```\n\n")
+ f.write(" \n\n")
+
+ f.write(f"📊 **Total Coverage**: `{total}`\n\n")
+ f.write("### Coverage by Package\n\n")
+ f.write("| Package | Coverage |\n")
+ f.write("|---------|----------|\n")
+
+ for pkg in sorted(pkg_data.keys()):
+ pcts = pkg_data[pkg]
+ avg = sum(pcts) / len(pcts)
+ f.write(f"| `{pkg}` | `{avg:.1f}%` |\n")
+ PYEOF
+
+ - name: Post/Update Coverage Comment
+ if: github.event_name == 'pull_request'
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ REPO="${{ github.repository }}"
+ PR_NUM="${{ github.event.pull_request.number }}"
+
+ COMMENT_ID=$(gh api repos/$REPO/issues/$PR_NUM/comments \
+ --jq '.[] | select(.body | contains("")) | .id' | head -1)
+
+ python3 << 'PYEOF'
+ import json
+
+ with open("report.md") as f:
+ report = f.read()
+
+ body = "\n" + report
+ payload = json.dumps({"body": body})
+
+ with open("payload.json", "w") as f:
+ f.write(payload)
+ PYEOF
+
+ if [ -n "$COMMENT_ID" ]; then
+ gh api repos/$REPO/issues/comments/$COMMENT_ID \
+ -X PATCH --input payload.json
+ else
+ gh api repos/$REPO/issues/$PR_NUM/comments \
+ -X POST --input payload.json
+ fi
+
+ - name: Fail if tests failed
+ if: steps.test.outputs.exit_code != '0'
+ run: exit 1
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..ecf7c94
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,42 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ name: Release
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+
+ - name: Build binaries
+ run: |
+ PLATFORMS="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64"
+ for platform in $PLATFORMS; do
+ os="${platform%/*}"
+ arch="${platform#*/}"
+ bin="ugo-${os}-${arch}"
+ [ "$os" = "windows" ] && bin="${bin}.exe"
+ GOOS=$os GOARCH=$arch go build -o "$bin" .
+ tar czf "${bin}.tar.gz" "$bin"
+ done
+ ls -la ugo-*.tar.gz
+
+ - name: Create Release
+ uses: softprops/action-gh-release@v2
+ with:
+ files: ugo-*.tar.gz
+ generate_release_notes: true
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..98c2d0c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+# Built binary
+ugo
+
+# Local project config (global config lives in ~/.config//)
+ugo.yaml
+*.yaml
+
+# IDE / editor
+.idea/
+.vscode/
+*.swp
+*~
+
+# OS
+.DS_Store
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..1382eb7
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,42 @@
+# AGENTS.md
+
+## What is this repo?
+
+- uGo — a Go CLI using Cobra that executes project-specific commands defined in YAML config.
+- Binary is renameable; config file name and help text follow the binary name automatically.
+- Config loading: global (`~/.config//config.yaml`) merged with local (`./.yaml`), local overrides.
+
+## Commands
+
+```bash
+go build -o ugo . # build
+go test ./... # run all tests
+go test ./... -cover # run tests with coverage
+go vet ./... # vet
+```
+
+## Structure
+
+- `main.go` — entry point, calls `cmd.RootCmd().Execute()`
+- `cmd/root.go` — Cobra root command; dynamically creates subcommands from YAML config
+- `internal/config/config.go` — loads global + local config, merges them
+- `internal/checker/checker.go` — pre-flight tool dependency validation
+- `internal/version/version.go` — semver extraction and comparison
+- `internal/output/output.go` — colored/emoji output with `--no-color` flag support
+- `internal/output/output_test.go` — ANSI stripping verification
+- `internal/args/args.go` — argument validation (enum, glob, regex)
+
+Tests: `cmd/root_test.go`, `internal/{config,checker,version,output,args}/*_test.go`
+
+## Working conventions
+
+- Verbs are defined in YAML, not hardcoded. Adding a new verb means editing config, not code.
+- Config schema:
+ - `commands..{cmd, description, arguments[]}` — arguments are objects with `name`, optional `values` (enum), optional `match` (glob or regex)
+ - `tools..{min_version, max_version, version_cmd, download_url}` — pre-flight checks run before every verb
+- Arguments after the verb are mapped positionally to `arguments` entries and expanded into `${name}` placeholders in `cmd`
+- `match` is auto-detected: contains `*` or `?` → glob (checks files on disk); otherwise → regex (full string match, auto-anchored)
+- Glob matching accepts full path, basename, or basename without extension
+- `ugo check` runs tool checks and prints status for each tool
+- Version comparison uses `golang.org/x/mod/semver`; `version_cmd` output is scanned for a semver pattern
+- Running a verb without required arguments (or with invalid args) prints the error then the help, then exits
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..fe03510
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020 Xan Manning
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index d0b4353..df88a5e 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,186 @@
# uGo
-## The Ubiquitous "Go script".
+The ubiquitous `./go` script — as a Go binary.
-### Description
+## Overview
-The public release of my "go script" that I use to abstract away common
-operations for personal projects. The aim is to provide a common vocabulary for
-each project, providing the verbs dependent on the context of the current
-working directory.
+uGo is a CLI tool that executes project-specific commands defined in YAML configuration.
+It follows the ThoughtWorks `./go` script pattern: provide a common vocabulary for each
+project, with verbs that are context-aware based on the current working directory.
-### Further Reading
+The binary name is flexible — rename it and the help text and config file name follow
+automatically, allowing multiple instances for different projects.
- - In Praise of the `./go` Script:
- - [Part I](https://www.thoughtworks.com/insights/blog/praise-go-script-part-i)
- - [Part II](https://www.thoughtworks.com/insights/blog/praise-go-script-part-ii)
+## Quick Start
-### Author
+### Install
+
+```bash
+go install github.com/xanmanning/ugo@latest
+```
+
+Or build from source:
+
+```bash
+go build -o ugo .
+```
+
+### Create a config
+
+Create `.yaml` in your project root:
+
+```yaml
+tools:
+ ansible-playbook:
+ min_version: "2.10.0"
+ version_cmd: "ansible-playbook --version"
+ download_url: "https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html"
+ go:
+ min_version: "1.25.0"
+ version_cmd: "go version"
+ kubectl:
+ download_url: "https://kubernetes.io/docs/tasks/tools/"
+
+commands:
+ plan:
+ cmd: ansible-playbook --check environments/${environment}.yaml playbooks/${playbook}.yaml
+ description: "Run an ansible playbook in check mode."
+ arguments:
+ - name: environment
+ values: [dev, staging, prod]
+ - name: playbook
+ match: "playbooks/*.yaml"
+ lint:
+ cmd: go test ./...
+ description: "Run test suite"
+ deploy:
+ cmd: kubectl apply -f ./deployments/${service} --context ${region}
+ description: "Deploy a service"
+ arguments:
+ - name: service
+ match: "[a-z][a-z0-9-]+"
+ - name: region
+ values: [us-east-1, eu-west-1, ap-southeast-1]
+```
+
+### Run commands
+
+```bash
+ugo plan dev ensure-ssh # runs: ansible-playbook --check environments/dev.yaml playbooks/ensure-ssh.yaml
+ugo lint # runs: go test ./...
+ugo check # verify tool dependencies
+ugo plan --help # shows argument validation rules
+ugo --no-color # disable colored output
+```
+
+## Configuration
+
+uGo loads configuration from two locations and merges them (local overrides global):
+
+| Scope | Path |
+|----------|--------------------------------------------|
+| Global | `~/.config//config.yaml` |
+| Local | `/.yaml` |
+
+### Config format
+
+#### Tools
+
+```yaml
+tools:
+ :
+ min_version: "" # optional minimum version
+ max_version: "" # optional maximum version
+ version_cmd: "" # how to get the version string (defaults to " --version")
+ download_url: "" # shown if tool is missing (optional)
+```
+
+#### Commands
+
+```yaml
+commands:
+ :
+ cmd: ""
+ description: ""
+ arguments: # positional argument definitions (optional)
+ - name:
+ values: [val1, val2] # optional: restrict to enum values
+ match: "" # optional: validate with file glob or regex
+```
+
+#### Argument validation
+
+Arguments support three validation modes:
+
+| Mode | Config | Behavior |
+|------|--------|----------|
+| **Enum** | `values: [dev, staging, prod]` | Value must be in the list |
+| **Glob** | `match: "playbooks/*.yaml"` | Checks files on disk; accepts full path, basename, or basename without extension |
+| **Regex** | `match: "[a-z]+"` | Full-string regex match (auto-anchored) |
+
+Glob vs regex is auto-detected: if the pattern contains `*` or `?` it's treated as a file glob.
+
+### Example
+
+```bash
+# config: cmd: ansible-playbook environments/${environment}.yaml
+# args: environment with values [dev, prod]
+
+ugo plan dev → ansible-playbook environments/dev.yaml
+ugo plan test → ❌ argument 'environment': value "test" not in allowed values: [dev, prod]
+```
+
+Running a verb without required arguments shows the error followed by argument validation rules:
+
+```bash
+$ ugo plan
+
+❌ expected 2 argument(s) for 'plan': environment, playbook
+
+Arguments:
+ environment dev, staging, prod
+ playbook ensure-ssh, setup-db
+
+Usage:
+ ugo plan [flags]
+```
+
+## Tool Dependency Checks
+
+Before executing any verb, uGo checks configured tools:
+
+- Verifies each binary exists in `$PATH`
+- Extracts version from `version_cmd` output
+- Compares against `min_version` and `max_version` using semver
+
+Run `ugo check` manually to inspect all tool status.
+
+```bash
+$ ugo check
+ ✅ ansible-playbook (v2.20.5)
+ ✅ docker (v28.5.1)
+ ✅ All tool dependencies satisfied
+
+$ ugo check
+ ❌ nonexistent-tool is not installed, download at: https://example.com
+ ✅ docker (v28.5.1)
+
+ ❌ Tool checks failed
+```
+
+## Colored Output
+
+uGo uses UTF-8 icons and colors for status output. Use `--no-color` to disable:
+
+```bash
+ugo --no-color plan dev ensure-ssh
+```
+
+## Further Reading
+
+- [In Praise of the `./go` Script — Part I](https://www.thoughtworks.com/insights/blog/praise-go-script-part-i)
+- [In Praise of the `./go` Script — Part II](https://www.thoughtworks.com/insights/blog/praise-go-script-part-ii)
+
+## Author
Xan Manning, 2020
diff --git a/cmd/root.go b/cmd/root.go
new file mode 100644
index 0000000..bdc60e5
--- /dev/null
+++ b/cmd/root.go
@@ -0,0 +1,259 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "github.com/spf13/cobra"
+ "github.com/PyratLabs/ugo/internal/args"
+ "github.com/PyratLabs/ugo/internal/checker"
+ "github.com/PyratLabs/ugo/internal/config"
+ "github.com/PyratLabs/ugo/internal/output"
+)
+
+var (
+ binaryName string
+ appCfg *config.Config
+ noColor bool
+)
+
+func RootCmd() *cobra.Command {
+ binaryName = config.BinaryName()
+
+ var err error
+ appCfg, err = config.Load(binaryName)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
+ os.Exit(1)
+ }
+
+ root := &cobra.Command{
+ Use: binaryName,
+ Short: fmt.Sprintf("%s — context-aware project verbs", binaryName),
+ Long: fmt.Sprintf(`%s executes project-specific commands defined in YAML configuration.
+
+Global config: %s
+Local config: %s
+
+Local config overrides global config for the same verb names.`,
+ binaryName,
+ func() string { g, _ := config.ConfigPaths(binaryName); return g }(),
+ func() string { _, l := config.ConfigPaths(binaryName); return l }(),
+ ),
+ CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
+ PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+ output.SetNoColor(noColor)
+ if cmd.Name() == "check" || cmd.Name() == "help" {
+ return nil
+ }
+ return runToolChecks()
+ },
+ }
+
+ root.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable color output")
+ root.SetFlagErrorFunc(func(c *cobra.Command, err error) error {
+ return fmt.Errorf("unknown flag: %s\nRun '%s help' for usage", err.Error(), binaryName)
+ })
+
+ // Build subcommands from config
+ for name, cmdDef := range appCfg.Commands {
+ root.AddCommand(buildCommand(name, cmdDef))
+ }
+
+ root.AddCommand(checkCmd())
+
+ return root
+}
+
+func checkCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "check",
+ Short: "Check required tool dependencies",
+ Run: func(cmd *cobra.Command, args []string) {
+ output.SetNoColor(noColor)
+ if len(appCfg.Tools) == 0 {
+ output.Info("No tool dependencies configured.")
+ return
+ }
+
+ output.Bold("Checking tool dependencies...\n")
+
+ issues := checker.CheckTools(appCfg.Tools)
+ printToolStatus(appCfg.Tools, issues)
+
+ if checker.HasErrors(issues) {
+ fmt.Fprintln(os.Stderr)
+ output.CheckFail("Tool checks failed")
+ os.Exit(1)
+ }
+
+ fmt.Fprintln(os.Stderr)
+ output.CheckPass("All tool dependencies satisfied")
+ },
+ }
+}
+
+func printToolStatus(tools map[string]config.Tool, issues []checker.Issue) {
+ issueMap := make(map[string]checker.Issue)
+ for _, i := range issues {
+ issueMap[i.Tool] = i
+ }
+
+ for name := range tools {
+ if issue, ok := issueMap[name]; ok {
+ for _, e := range issue.Errors {
+ if strings.HasPrefix(e, "version:") {
+ output.CheckPass(fmt.Sprintf("%s (%s)", name, strings.TrimPrefix(e, "version: ")))
+ } else {
+ output.CheckFail(e)
+ }
+ }
+ } else {
+ output.CheckPass(name)
+ }
+ }
+}
+
+func runToolChecks() error {
+ if len(appCfg.Tools) == 0 {
+ return nil
+ }
+
+ issues := checker.CheckTools(appCfg.Tools)
+ if !checker.HasErrors(issues) {
+ return nil
+ }
+
+ output.CheckFail("Tool dependency errors:")
+ for _, issue := range issues {
+ for _, e := range issue.Errors {
+ if strings.HasPrefix(e, "version:") {
+ continue
+ }
+ output.CheckFail(fmt.Sprintf("%s: %s", issue.Tool, e))
+ }
+ }
+ os.Exit(1)
+ return nil
+}
+
+func buildCommand(name string, def config.Command) *cobra.Command {
+ c := &cobra.Command{
+ Use: buildUse(name, def.Arguments),
+ Short: def.Description,
+ Long: buildLong(def.Arguments),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return executeCommand(cmd, name, def, args)
+ },
+ }
+
+ return c
+}
+
+func buildLong(arguments []config.Argument) string {
+ if len(arguments) == 0 {
+ return ""
+ }
+
+ var b strings.Builder
+ b.WriteString("\nArguments:\n")
+ for _, arg := range arguments {
+ b.WriteString(fmt.Sprintf(" %-20s", arg.Name))
+ switch {
+ case len(arg.Values) > 0:
+ b.WriteString(strings.Join(arg.Values, ", "))
+ case arg.Match != "":
+ if args.IsGlob(arg.Match) {
+ matches, _ := filepath.Glob(arg.Match)
+ if len(matches) > 0 {
+ names := make([]string, len(matches))
+ for i, m := range matches {
+ base := filepath.Base(m)
+ names[i] = strings.TrimSuffix(base, filepath.Ext(base))
+ }
+ b.WriteString(strings.Join(names, ", "))
+ } else {
+ b.WriteString("(no files found)")
+ }
+ } else {
+ b.WriteString(fmt.Sprintf("^%s$", arg.Match))
+ }
+ default:
+ b.WriteString("(no validation)")
+ }
+ b.WriteString("\n")
+ }
+
+ return b.String()
+}
+
+func buildUse(name string, arguments []config.Argument) string {
+ if len(arguments) == 0 {
+ return name
+ }
+ parts := make([]string, len(arguments))
+ for i, arg := range arguments {
+ parts[i] = fmt.Sprintf("<%s>", arg.Name)
+ }
+ return fmt.Sprintf("%s %s", name, strings.Join(parts, " "))
+}
+
+func executeCommand(cmd *cobra.Command, name string, def config.Command, values []string) error {
+ if len(values) != len(def.Arguments) {
+ argNames := args.ArgNames(def.Arguments)
+ output.CheckFail(fmt.Sprintf("expected %d argument(s) for '%s': %s",
+ len(def.Arguments), name, strings.Join(argNames, ", ")))
+ fmt.Fprintln(os.Stderr)
+ cmd.Help()
+ os.Exit(1)
+ }
+
+ if errs := args.ValidateArgs(def.Arguments, values); len(errs) > 0 {
+ for _, e := range errs {
+ output.CheckFail(e.Error())
+ }
+ fmt.Fprintln(os.Stderr)
+ cmd.Help()
+ os.Exit(1)
+ }
+
+ vars := args.ArgMap(def.Arguments, values)
+
+ expanded := os.Expand(def.Cmd, func(key string) string {
+ val, ok := vars[key]
+ if !ok {
+ return fmt.Sprintf("${%s}", key)
+ }
+ return val
+ })
+
+ parts := strings.Fields(expanded)
+ if len(parts) == 0 {
+ output.CheckFail(fmt.Sprintf("empty command configured for '%s'", name))
+ os.Exit(1)
+ }
+
+ bin := parts[0]
+ cmdArgs := parts[1:]
+
+ output.CommandRunning(name, expanded)
+
+ command := exec.Command(bin, cmdArgs...)
+ command.Stdout = os.Stdout
+ command.Stderr = os.Stderr
+ command.Stdin = os.Stdin
+
+ if err := command.Run(); err != nil {
+ output.CommandFail(name)
+ if exitErr, ok := err.(*exec.ExitError); ok {
+ os.Exit(exitErr.ExitCode())
+ }
+ os.Exit(1)
+ }
+
+ output.CommandSuccess(name)
+ return nil
+}
diff --git a/cmd/root_test.go b/cmd/root_test.go
new file mode 100644
index 0000000..bbf32e0
--- /dev/null
+++ b/cmd/root_test.go
@@ -0,0 +1,284 @@
+package cmd
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/PyratLabs/ugo/internal/config"
+)
+
+func TestBuildUse(t *testing.T) {
+ tests := []struct {
+ name string
+ verb string
+ arguments []config.Argument
+ want string
+ }{
+ {
+ name: "no arguments",
+ verb: "lint",
+ arguments: nil,
+ want: "lint",
+ },
+ {
+ name: "single argument",
+ verb: "plan",
+ arguments: []config.Argument{{Name: "environment"}},
+ want: "plan ",
+ },
+ {
+ name: "multiple arguments",
+ verb: "deploy",
+ arguments: []config.Argument{{Name: "environment"}, {Name: "service"}, {Name: "region"}},
+ want: "deploy ",
+ },
+ {
+ name: "empty arguments slice",
+ verb: "test",
+ arguments: []config.Argument{},
+ want: "test",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := buildUse(tt.verb, tt.arguments)
+ if got != tt.want {
+ t.Errorf("buildUse(%q, %v) = %q, want %q", tt.verb, tt.arguments, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestRootCmd(t *testing.T) {
+ // Save and restore os.Args
+ oldArgs := os.Args
+ defer func() { os.Args = oldArgs }()
+
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "testgo.yaml")
+ if err := os.WriteFile(configPath, []byte(`
+commands:
+ plan:
+ cmd: echo plan
+ description: "Plan the environment"
+ arguments:
+ - name: environment
+ - name: playbook
+ lint:
+ cmd: echo lint
+ description: "Run linter"
+`), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ os.Args = []string{"testgo"}
+ os.Chdir(dir)
+
+ root := RootCmd()
+
+ // Verify root command properties
+ if root.Use != "testgo" {
+ t.Errorf("root.Use = %q, want %q", root.Use, "testgo")
+ }
+ if !strings.Contains(root.Short, "testgo") {
+ t.Errorf("root.Short = %q, want to contain %q", root.Short, "testgo")
+ }
+
+ // Verify subcommands were created
+ sub := root.Commands()
+ found := map[string]bool{}
+ for _, c := range sub {
+ found[c.Name()] = true
+ }
+
+ if !found["plan"] {
+ t.Error("expected subcommand 'plan'")
+ }
+ if !found["lint"] {
+ t.Error("expected subcommand 'lint'")
+ }
+ if !found["check"] {
+ t.Error("expected built-in subcommand 'check'")
+ }
+
+ // Verify help is available (Cobra adds it implicitly)
+ _, _, err := root.Find([]string{"help"})
+ if err != nil {
+ t.Logf("help command lookup: %v (may be implicit)", err)
+ }
+}
+
+func TestRootCmdNoConfig(t *testing.T) {
+ oldArgs := os.Args
+ defer func() { os.Args = oldArgs }()
+
+ dir := t.TempDir()
+ os.Args = []string{"noconfig"}
+ os.Chdir(dir)
+
+ // Should not panic when no config exists
+ root := RootCmd()
+ if root.Use != "noconfig" {
+ t.Errorf("root.Use = %q, want %q", root.Use, "noconfig")
+ }
+}
+
+func TestRootCmdNoColorFlag(t *testing.T) {
+ oldArgs := os.Args
+ defer func() { os.Args = oldArgs }()
+
+ dir := t.TempDir()
+ os.Args = []string{"testgo"}
+ os.Chdir(dir)
+
+ root := RootCmd()
+
+ // Verify --no-color flag exists
+ flag := root.PersistentFlags().Lookup("no-color")
+ if flag == nil {
+ t.Fatal("expected --no-color flag")
+ }
+ if flag.DefValue != "false" {
+ t.Errorf("no-color default = %q, want %q", flag.DefValue, "false")
+ }
+}
+
+func TestBuildCommand(t *testing.T) {
+ tests := []struct {
+ name string
+ cmdName string
+ def config.Command
+ wantUse string
+ wantDesc string
+ }{
+ {
+ name: "with arguments",
+ cmdName: "plan",
+ def: config.Command{
+ Cmd: "echo plan",
+ Description: "Plan the env",
+ Arguments: []config.Argument{{Name: "environment"}},
+ },
+ wantUse: "plan ",
+ wantDesc: "Plan the env",
+ },
+ {
+ name: "without arguments",
+ cmdName: "lint",
+ def: config.Command{
+ Cmd: "echo lint",
+ Description: "Run linter",
+ },
+ wantUse: "lint",
+ wantDesc: "Run linter",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cmd := buildCommand(tt.cmdName, tt.def)
+ if cmd.Use != tt.wantUse {
+ t.Errorf("command.Use = %q, want %q", cmd.Use, tt.wantUse)
+ }
+ if cmd.Short != tt.wantDesc {
+ t.Errorf("command.Short = %q, want %q", cmd.Short, tt.wantDesc)
+ }
+ })
+ }
+}
+
+func TestRootCmdExecute(t *testing.T) {
+ oldArgs := os.Args
+ defer func() { os.Args = oldArgs }()
+
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "execgo.yaml")
+ if err := os.WriteFile(configPath, []byte(`
+commands:
+ hello:
+ cmd: echo hello
+ description: "Say hello"
+`), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ os.Args = []string{"execgo"}
+ os.Chdir(dir)
+
+ root := RootCmd()
+
+ // Capture stdout since the command writes to os.Stdout
+ oldStdout := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+
+ root.SetArgs([]string{"hello"})
+ if err := root.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+
+ w.Close()
+ var out bytes.Buffer
+ out.ReadFrom(r)
+ os.Stdout = oldStdout
+
+ if !strings.Contains(out.String(), "hello") {
+ t.Errorf("output = %q, want to contain %q", out.String(), "hello")
+ }
+}
+
+func TestBuildLong(t *testing.T) {
+ tests := []struct {
+ name string
+ arguments []config.Argument
+ want []string
+ }{
+ {
+ name: "no arguments",
+ arguments: nil,
+ want: nil,
+ },
+ {
+ name: "enum values",
+ arguments: []config.Argument{
+ {Name: "env", Values: []string{"dev", "staging", "prod"}},
+ },
+ want: []string{"dev, staging, prod"},
+ },
+ {
+ name: "regex match",
+ arguments: []config.Argument{
+ {Name: "service", Match: "[a-z][a-z0-9-]+"},
+ },
+ want: []string{"^[a-z][a-z0-9-]+$"},
+ },
+ {
+ name: "no validation",
+ arguments: []config.Argument{
+ {Name: "freeform"},
+ },
+ want: []string{"(no validation)"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := buildLong(tt.arguments)
+ if len(tt.want) == 0 {
+ if got != "" {
+ t.Errorf("buildLong() = %q, want empty", got)
+ }
+ return
+ }
+ for _, w := range tt.want {
+ if !strings.Contains(got, w) {
+ t.Errorf("buildLong() = %q, want to contain %q", got, w)
+ }
+ }
+ })
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..f142cc5
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,25 @@
+module github.com/PyratLabs/ugo
+
+go 1.25.4
+
+require (
+ github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // 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
+ github.com/spf13/cast v1.10.0 // indirect
+ github.com/spf13/cobra v1.10.2 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
+ github.com/spf13/viper v1.21.0 // indirect
+ github.com/subosito/gotenv v1.6.0 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/mod v0.35.0 // indirect
+ golang.org/x/sys v0.29.0 // indirect
+ golang.org/x/text v0.28.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..ac65af3
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,44 @@
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
+github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+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/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
+github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
+github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
+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=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
+github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+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.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
+golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
+golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+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/args/args.go b/internal/args/args.go
new file mode 100644
index 0000000..c39a2ef
--- /dev/null
+++ b/internal/args/args.go
@@ -0,0 +1,113 @@
+package args
+
+import (
+ "fmt"
+ "path/filepath"
+ "regexp"
+
+ "github.com/PyratLabs/ugo/internal/config"
+)
+
+// Validate checks a single argument value against its validation rules.
+// Returns nil if the argument is valid or has no rules.
+func Validate(arg config.Argument, value string) error {
+ if len(arg.Values) > 0 {
+ if err := validateEnum(arg.Name, arg.Values, value); err != nil {
+ return err
+ }
+ }
+
+ if arg.Match != "" {
+ if err := validateMatch(arg.Name, arg.Match, value); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func validateEnum(name string, values []string, actual string) error {
+ for _, v := range values {
+ if v == actual {
+ return nil
+ }
+ }
+ return fmt.Errorf("argument '%s': value %q not in allowed values: %s", name, actual, values)
+}
+
+func validateMatch(name string, pattern string, actual string) error {
+ if IsGlob(pattern) {
+ matches, err := filepath.Glob(pattern)
+ if err != nil {
+ return fmt.Errorf("argument '%s': invalid glob pattern %q: %w", name, pattern, err)
+ }
+ for _, m := range matches {
+ if m == actual || filepath.Base(m) == actual || stripExt(filepath.Base(m)) == actual {
+ return nil
+ }
+ }
+ return fmt.Errorf("argument '%s': no file matching pattern %q found for value %q", name, pattern, actual)
+ }
+
+ pattern = "^" + pattern + "$"
+ re, err := regexp.Compile(pattern)
+ if err != nil {
+ return fmt.Errorf("argument '%s': invalid regex pattern %q: %w", name, pattern, err)
+ }
+ if !re.MatchString(actual) {
+ return fmt.Errorf("argument '%s': value %q does not match pattern %q", name, actual, pattern)
+ }
+
+ return nil
+}
+
+func IsGlob(pattern string) bool {
+ for _, c := range pattern {
+ if c == '*' || c == '?' {
+ return true
+ }
+ }
+ return false
+}
+
+func stripExt(name string) string {
+ ext := filepath.Ext(name)
+ if ext != "" {
+ return name[:len(name)-len(ext)]
+ }
+ return name
+}
+
+// ArgNames extracts argument names for usage display and error messages
+func ArgNames(args []config.Argument) []string {
+ names := make([]string, len(args))
+ for i, a := range args {
+ names[i] = a.Name
+ }
+ return names
+}
+
+// ValidateArgs validates all arguments in order
+func ValidateArgs(arguments []config.Argument, values []string) []error {
+ var errs []error
+ for i, arg := range arguments {
+ if i >= len(values) {
+ break
+ }
+ if err := Validate(arg, values[i]); err != nil {
+ errs = append(errs, err)
+ }
+ }
+ return errs
+}
+
+// ArgMap builds a name-to-value map for template expansion
+func ArgMap(arguments []config.Argument, values []string) map[string]string {
+ m := make(map[string]string)
+ for i, arg := range arguments {
+ if i < len(values) {
+ m[arg.Name] = values[i]
+ }
+ }
+ return m
+}
diff --git a/internal/args/args_test.go b/internal/args/args_test.go
new file mode 100644
index 0000000..6e86026
--- /dev/null
+++ b/internal/args/args_test.go
@@ -0,0 +1,277 @@
+package args
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/PyratLabs/ugo/internal/config"
+)
+
+func TestValidate(t *testing.T) {
+ t.Run("no rules", func(t *testing.T) {
+ arg := config.Argument{Name: "foo"}
+ if err := Validate(arg, "anything"); err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+ })
+
+ t.Run("enum pass", func(t *testing.T) {
+ arg := config.Argument{
+ Name: "env",
+ Values: []string{"dev", "staging", "prod"},
+ }
+ for _, v := range arg.Values {
+ if err := Validate(arg, v); err != nil {
+ t.Errorf("Validate(%q) = %v, want nil", v, err)
+ }
+ }
+ })
+
+ t.Run("enum fail", func(t *testing.T) {
+ arg := config.Argument{
+ Name: "env",
+ Values: []string{"dev", "staging", "prod"},
+ }
+ err := Validate(arg, "invalid")
+ if err == nil {
+ t.Error("expected error for invalid enum value")
+ }
+ })
+
+ t.Run("regex pass", func(t *testing.T) {
+ arg := config.Argument{
+ Name: "service",
+ Match: "[a-z][a-z0-9-]+",
+ }
+ for _, v := range []string{"my-service", "api", "web-app-2"} {
+ if err := Validate(arg, v); err != nil {
+ t.Errorf("Validate(%q) = %v, want nil", v, err)
+ }
+ }
+ })
+
+ t.Run("regex fail", func(t *testing.T) {
+ arg := config.Argument{
+ Name: "service",
+ Match: "[a-z][a-z0-9-]+",
+ }
+ for _, v := range []string{"InvalidService", "123start", ""} {
+ err := Validate(arg, v)
+ if err == nil {
+ t.Errorf("Validate(%q) = nil, want error", v)
+ }
+ }
+ })
+
+ t.Run("invalid regex", func(t *testing.T) {
+ arg := config.Argument{
+ Name: "foo",
+ Match: "[invalid",
+ }
+ err := Validate(arg, "anything")
+ if err == nil {
+ t.Error("expected error for invalid regex")
+ }
+ })
+
+ t.Run("glob pass - full path", func(t *testing.T) {
+ dir := t.TempDir()
+ for _, f := range []string{"a.yaml", "b.yaml"} {
+ if err := os.WriteFile(filepath.Join(dir, f), nil, 0644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ arg := config.Argument{
+ Name: "file",
+ Match: filepath.Join(dir, "*.yaml"),
+ }
+ if err := Validate(arg, filepath.Join(dir, "a.yaml")); err != nil {
+ t.Errorf("Validate(full path) = %v, want nil", err)
+ }
+ })
+
+ t.Run("glob pass - basename", func(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "config.yaml"), nil, 0644); err != nil {
+ t.Fatal(err)
+ }
+ arg := config.Argument{
+ Name: "file",
+ Match: filepath.Join(dir, "*.yaml"),
+ }
+ if err := Validate(arg, "config.yaml"); err != nil {
+ t.Errorf("Validate(basename) = %v, want nil", err)
+ }
+ })
+
+ t.Run("glob pass - basename without ext", func(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "playbook.yaml"), nil, 0644); err != nil {
+ t.Fatal(err)
+ }
+ arg := config.Argument{
+ Name: "file",
+ Match: filepath.Join(dir, "*.yaml"),
+ }
+ if err := Validate(arg, "playbook"); err != nil {
+ t.Errorf("Validate(basename without ext) = %v, want nil", err)
+ }
+ })
+
+ t.Run("glob fail", func(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "only.yaml"), nil, 0644); err != nil {
+ t.Fatal(err)
+ }
+ arg := config.Argument{
+ Name: "file",
+ Match: filepath.Join(dir, "*.yaml"),
+ }
+ err := Validate(arg, "nonexistent")
+ if err == nil {
+ t.Error("expected error for nonexistent file")
+ }
+ })
+
+ t.Run("glob no matches", func(t *testing.T) {
+ dir := t.TempDir()
+ arg := config.Argument{
+ Name: "file",
+ Match: filepath.Join(dir, "*.yaml"),
+ }
+ err := Validate(arg, "anything")
+ if err == nil {
+ t.Error("expected error when no files match glob")
+ }
+ })
+
+ t.Run("both enum and regex fail enum", func(t *testing.T) {
+ arg := config.Argument{
+ Name: "env",
+ Values: []string{"dev", "prod"},
+ Match: "[a-z]+",
+ }
+ err := Validate(arg, "invalid")
+ if err == nil {
+ t.Error("expected error")
+ }
+ })
+}
+
+func TestValidateArgs(t *testing.T) {
+ argDefs := []config.Argument{
+ {Name: "env", Values: []string{"dev", "prod"}},
+ {Name: "service", Match: "[a-z]+"},
+ }
+
+ t.Run("all valid", func(t *testing.T) {
+ errs := ValidateArgs(argDefs, []string{"dev", "api"})
+ if len(errs) != 0 {
+ t.Errorf("expected no errors, got %v", errs)
+ }
+ })
+
+ t.Run("first invalid", func(t *testing.T) {
+ errs := ValidateArgs(argDefs, []string{"staging", "api"})
+ if len(errs) != 1 {
+ t.Fatalf("expected 1 error, got %d", len(errs))
+ }
+ })
+
+ t.Run("both invalid", func(t *testing.T) {
+ errs := ValidateArgs(argDefs, []string{"staging", "123"})
+ if len(errs) != 2 {
+ t.Errorf("expected 2 errors, got %d: %v", len(errs), errs)
+ }
+ })
+}
+
+func TestArgNames(t *testing.T) {
+ args := []config.Argument{
+ {Name: "env"},
+ {Name: "service"},
+ }
+ names := ArgNames(args)
+ if len(names) != 2 {
+ t.Fatalf("expected 2 names, got %d", len(names))
+ }
+ if names[0] != "env" || names[1] != "service" {
+ t.Errorf("names = %v, want [env, service]", names)
+ }
+}
+
+func TestArgMap(t *testing.T) {
+ argDefs := []config.Argument{
+ {Name: "env"},
+ {Name: "service"},
+ }
+ m := ArgMap(argDefs, []string{"dev", "api"})
+ if m["env"] != "dev" {
+ t.Errorf("m[env] = %q, want %q", m["env"], "dev")
+ }
+ if m["service"] != "api" {
+ t.Errorf("m[service] = %q, want %q", m["service"], "api")
+ }
+}
+
+func TestIsGlob(t *testing.T) {
+ tests := []struct {
+ pattern string
+ want bool
+ }{
+ {"*.yaml", true},
+ {"playbooks/*", true},
+ {"?.txt", true},
+ {"[a-z]+", false},
+ {"^[a-z]+$", false},
+ {"exact.yaml", false},
+ {"simple", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.pattern, func(t *testing.T) {
+ got := IsGlob(tt.pattern)
+ if got != tt.want {
+ t.Errorf("isGlob(%q) = %v, want %v", tt.pattern, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestStripExt(t *testing.T) {
+ tests := []struct {
+ name string
+ want string
+ }{
+ {"playbook.yaml", "playbook"},
+ {"config.json", "config"},
+ {"noext", "noext"},
+ {".hidden", ""}, // filepath.Ext treats .hidden as extension
+ {"file.tar.gz", "file.tar"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := stripExt(tt.name)
+ if got != tt.want {
+ t.Errorf("stripExt(%q) = %q, want %q", tt.name, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestRegexFullMatch(t *testing.T) {
+ arg := config.Argument{
+ Name: "id",
+ Match: "[a-z][0-9]",
+ }
+
+ if err := Validate(arg, "a1"); err != nil {
+ t.Errorf("Validate(a1) = %v, want nil", err)
+ }
+
+ if err := Validate(arg, "xa1x"); err == nil {
+ t.Error("Validate(xa1x) = nil, want error (should not partial match)")
+ }
+}
diff --git a/internal/checker/checker.go b/internal/checker/checker.go
new file mode 100644
index 0000000..712a3bb
--- /dev/null
+++ b/internal/checker/checker.go
@@ -0,0 +1,81 @@
+package checker
+
+import (
+ "fmt"
+ "os/exec"
+ "strings"
+
+ "github.com/PyratLabs/ugo/internal/config"
+ "github.com/PyratLabs/ugo/internal/version"
+)
+
+// Issue represents a problem found during tool checking
+type Issue struct {
+ Tool string
+ Errors []string
+}
+
+// CheckTools validates all required tools are available with correct versions
+func CheckTools(tools map[string]config.Tool) []Issue {
+ var issues []Issue
+
+ for name, tool := range tools {
+ var errs []string
+
+ if _, err := exec.LookPath(name); err != nil {
+ msg := fmt.Sprintf("%s is not installed", name)
+ if tool.DownloadURL != "" {
+ msg += fmt.Sprintf(", download at: %s", tool.DownloadURL)
+ }
+ errs = append(errs, msg)
+ issues = append(issues, Issue{Tool: name, Errors: errs})
+ continue
+ }
+
+ if tool.MinVersion != "" || tool.MaxVersion != "" {
+ cmd := tool.VersionCmd
+ if cmd == "" {
+ cmd = fmt.Sprintf("%s --version", name)
+ }
+
+ foundVer, err := version.Check(name, cmd, tool.MinVersion, tool.MaxVersion)
+ if err != nil {
+ errs = append(errs, err.Error())
+ } else if tool.MinVersion != "" || tool.MaxVersion != "" {
+ errs = append(errs, fmt.Sprintf("version: %s", foundVer))
+ }
+ }
+
+ if len(errs) > 0 {
+ issues = append(issues, Issue{Tool: name, Errors: errs})
+ }
+ }
+
+ return issues
+}
+
+// FormatErrors renders issues as user-friendly error messages
+func FormatErrors(issues []Issue) string {
+ var b strings.Builder
+ for _, issue := range issues {
+ for _, err := range issue.Errors {
+ if strings.HasPrefix(err, "version:") {
+ continue // version info, not an error
+ }
+ b.WriteString(fmt.Sprintf(" - %s: %s\n", issue.Tool, err))
+ }
+ }
+ return strings.TrimSuffix(b.String(), "\n")
+}
+
+// HasErrors returns true if any issues contain actual errors (not just version info)
+func HasErrors(issues []Issue) bool {
+ for _, issue := range issues {
+ for _, err := range issue.Errors {
+ if !strings.HasPrefix(err, "version:") {
+ return true
+ }
+ }
+ }
+ return false
+}
diff --git a/internal/checker/checker_test.go b/internal/checker/checker_test.go
new file mode 100644
index 0000000..24dcd6a
--- /dev/null
+++ b/internal/checker/checker_test.go
@@ -0,0 +1,174 @@
+package checker
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/PyratLabs/ugo/internal/config"
+)
+
+func TestCheckTools(t *testing.T) {
+ tmp := t.TempDir()
+ scriptPath := filepath.Join(tmp, "fake-tool")
+ if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\necho \"fake-tool v1.5.0\"\n"), 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ badScriptPath := filepath.Join(tmp, "bad-version")
+ if err := os.WriteFile(badScriptPath, []byte("#!/bin/sh\necho \"no version here\"\n"), 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("installed tool with passing version", func(t *testing.T) {
+ tools := map[string]config.Tool{
+ scriptPath: {
+ MinVersion: "1.0.0",
+ VersionCmd: scriptPath,
+ },
+ }
+ issues := CheckTools(tools)
+ if HasErrors(issues) {
+ t.Errorf("expected no errors, got: %v", FormatErrors(issues))
+ }
+ })
+
+ t.Run("installed tool with failing version", func(t *testing.T) {
+ tools := map[string]config.Tool{
+ scriptPath: {
+ MinVersion: "2.0.0",
+ VersionCmd: scriptPath,
+ },
+ }
+ issues := CheckTools(tools)
+ if !HasErrors(issues) {
+ t.Error("expected errors for version below minimum")
+ }
+ if len(issues) != 1 {
+ t.Fatalf("expected 1 issue, got %d", len(issues))
+ }
+ })
+
+ t.Run("missing tool without download URL", func(t *testing.T) {
+ tools := map[string]config.Tool{
+ "nonexistent-tool-xyz": {},
+ }
+ issues := CheckTools(tools)
+ if !HasErrors(issues) {
+ t.Error("expected errors for missing tool")
+ }
+ if len(issues) != 1 {
+ t.Fatalf("expected 1 issue, got %d", len(issues))
+ }
+ if issues[0].Tool != "nonexistent-tool-xyz" {
+ t.Errorf("issue.Tool = %q, want %q", issues[0].Tool, "nonexistent-tool-xyz")
+ }
+ })
+
+ t.Run("missing tool with download URL", func(t *testing.T) {
+ tools := map[string]config.Tool{
+ "nonexistent-tool-xyz": {
+ DownloadURL: "https://example.com/download",
+ },
+ }
+ issues := CheckTools(tools)
+ if !HasErrors(issues) {
+ t.Error("expected errors for missing tool")
+ }
+ if len(issues) != 1 {
+ t.Fatalf("expected 1 issue, got %d", len(issues))
+ }
+ found := false
+ for _, e := range issues[0].Errors {
+ if e == "nonexistent-tool-xyz is not installed, download at: https://example.com/download" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("expected download URL in error, got: %v", issues[0].Errors)
+ }
+ })
+
+ t.Run("tool with unparseable version", func(t *testing.T) {
+ tools := map[string]config.Tool{
+ badScriptPath: {
+ MinVersion: "1.0.0",
+ VersionCmd: badScriptPath,
+ },
+ }
+ issues := CheckTools(tools)
+ if !HasErrors(issues) {
+ t.Error("expected errors for unparseable version")
+ }
+ })
+
+ t.Run("empty tools map", func(t *testing.T) {
+ tools := map[string]config.Tool{}
+ issues := CheckTools(tools)
+ if len(issues) != 0 {
+ t.Errorf("expected no issues, got %d", len(issues))
+ }
+ })
+}
+
+func TestHasErrors(t *testing.T) {
+ tests := []struct {
+ name string
+ issues []Issue
+ wantErr bool
+ }{
+ {
+ name: "no issues",
+ issues: nil,
+ wantErr: false,
+ },
+ {
+ name: "only version info",
+ issues: []Issue{
+ {Tool: "docker", Errors: []string{"version: v24.0.7"}},
+ },
+ wantErr: false,
+ },
+ {
+ name: "with error",
+ issues: []Issue{
+ {Tool: "missing", Errors: []string{"missing is not installed"}},
+ },
+ wantErr: true,
+ },
+ {
+ name: "mixed",
+ issues: []Issue{
+ {Tool: "docker", Errors: []string{"version: v24.0.7"}},
+ {Tool: "missing", Errors: []string{"missing is not installed"}},
+ },
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := HasErrors(tt.issues)
+ if got != tt.wantErr {
+ t.Errorf("HasErrors() = %v, want %v", got, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestFormatErrors(t *testing.T) {
+ issues := []Issue{
+ {Tool: "missing", Errors: []string{"missing is not installed"}},
+ {Tool: "docker", Errors: []string{"version: v24.0.7"}},
+ }
+
+ got := FormatErrors(issues)
+ if got == "" {
+ t.Error("expected non-empty output")
+ }
+ // Should not include version info
+ if len(got) > 0 && got[0:2] != " " {
+ t.Errorf("expected indented output, got: %q", got)
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..7092c5e
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,135 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/spf13/viper"
+)
+
+// Tool defines a required tool dependency
+type Tool struct {
+ MinVersion string `mapstructure:"min_version"`
+ MaxVersion string `mapstructure:"max_version"`
+ VersionCmd string `mapstructure:"version_cmd"`
+ DownloadURL string `mapstructure:"download_url"`
+}
+
+// Argument defines a single command argument with optional validation
+type Argument struct {
+ Name string `mapstructure:"name"`
+ Values []string `mapstructure:"values"`
+ Match string `mapstructure:"match"`
+}
+
+// Command defines a single verb's configuration
+type Command struct {
+ Name string `mapstructure:"name"`
+ Cmd string `mapstructure:"cmd"`
+ Description string `mapstructure:"description"`
+ Arguments []Argument `mapstructure:"arguments"`
+}
+
+// Config represents the full YAML configuration
+type Config struct {
+ Commands map[string]Command `mapstructure:"commands"`
+ Tools map[string]Tool `mapstructure:"tools"`
+}
+
+// Load merges global and local configs. Local overrides global.
+// binaryName is used to locate both config locations.
+func Load(binaryName string) (*Config, error) {
+ global, err := loadGlobalConfig(binaryName)
+ if err != nil {
+ return nil, fmt.Errorf("loading global config: %w", err)
+ }
+
+ local, err := loadLocalConfig(binaryName)
+ if err != nil {
+ return nil, fmt.Errorf("loading local config: %w", err)
+ }
+
+ merged := mergeConfigs(global, local)
+ return merged, nil
+}
+
+func loadGlobalConfig(binaryName string) (*Config, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return nil, fmt.Errorf("getting home directory: %w", err)
+ }
+
+ configPath := filepath.Join(home, ".config", binaryName, "config.yaml")
+ return loadConfigFile(configPath)
+}
+
+func loadLocalConfig(binaryName string) (*Config, error) {
+ configPath := filepath.Join(".", binaryName+".yaml")
+ return loadConfigFile(configPath)
+}
+
+func loadConfigFile(path string) (*Config, error) {
+ cfg := &Config{
+ Commands: make(map[string]Command),
+ Tools: make(map[string]Tool),
+ }
+
+ if _, err := os.Stat(path); os.IsNotExist(err) {
+ return cfg, nil
+ }
+
+ v := viper.New()
+ v.SetConfigFile(path)
+ v.SetConfigType("yaml")
+
+ if err := v.ReadInConfig(); err != nil {
+ return nil, err
+ }
+
+ if err := v.Unmarshal(cfg); err != nil {
+ return nil, err
+ }
+
+ return cfg, nil
+}
+
+func mergeConfigs(global, local *Config) *Config {
+ merged := &Config{
+ Commands: make(map[string]Command),
+ Tools: make(map[string]Tool),
+ }
+
+ for name, cmd := range global.Commands {
+ merged.Commands[name] = cmd
+ }
+
+ for name, cmd := range local.Commands {
+ merged.Commands[name] = cmd
+ }
+
+ for name, tool := range global.Tools {
+ merged.Tools[name] = tool
+ }
+
+ for name, tool := range local.Tools {
+ merged.Tools[name] = tool
+ }
+
+ return merged
+}
+
+// ConfigPaths returns the human-readable paths checked during load
+func ConfigPaths(binaryName string) (global, local string) {
+ home, _ := os.UserHomeDir()
+ global = filepath.Join(home, ".config", binaryName, "config.yaml")
+ local = filepath.Join(".", binaryName+".yaml")
+ return
+}
+
+// BinaryName extracts the base name from os.Args[0], stripping path and extension
+func BinaryName() string {
+ base := filepath.Base(os.Args[0])
+ return strings.TrimSuffix(base, filepath.Ext(base))
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..9cf2f46
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,174 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestLoadConfigFile(t *testing.T) {
+ t.Run("missing file returns empty config", func(t *testing.T) {
+ cfg, err := loadConfigFile("/nonexistent/path/config.yaml")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(cfg.Commands) != 0 {
+ t.Error("expected empty commands")
+ }
+ if len(cfg.Tools) != 0 {
+ t.Error("expected empty tools")
+ }
+ })
+
+ t.Run("valid config file", func(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.yaml")
+ if err := os.WriteFile(path, []byte(`
+commands:
+ plan:
+ cmd: echo plan
+ description: "Plan"
+ arguments:
+ - name: env
+ values: [dev, prod]
+tools:
+ docker:
+ min_version: "20.0.0"
+`), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg, err := loadConfigFile(path)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if _, ok := cfg.Commands["plan"]; !ok {
+ t.Error("expected command 'plan'")
+ }
+ if cfg.Commands["plan"].Cmd != "echo plan" {
+ t.Errorf("plan.cmd = %q, want %q", cfg.Commands["plan"].Cmd, "echo plan")
+ }
+ if len(cfg.Commands["plan"].Arguments) != 1 || cfg.Commands["plan"].Arguments[0].Name != "env" {
+ t.Errorf("plan.arguments = %v, want [{env}]", cfg.Commands["plan"].Arguments)
+ }
+ if len(cfg.Commands["plan"].Arguments[0].Values) != 2 {
+ t.Errorf("plan.arguments[0].values = %v, want [dev, prod]", cfg.Commands["plan"].Arguments[0].Values)
+ }
+ if _, ok := cfg.Tools["docker"]; !ok {
+ t.Error("expected tool 'docker'")
+ }
+ if cfg.Tools["docker"].MinVersion != "20.0.0" {
+ t.Errorf("docker.min_version = %q, want %q", cfg.Tools["docker"].MinVersion, "20.0.0")
+ }
+ })
+
+ t.Run("invalid yaml", func(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.yaml")
+ if err := os.WriteFile(path, []byte(":\n - invalid yaml{{"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ _, err := loadConfigFile(path)
+ if err == nil {
+ t.Error("expected error for invalid yaml")
+ }
+ })
+}
+
+func TestMergeConfigs(t *testing.T) {
+ t.Run("empty configs", func(t *testing.T) {
+ global := &Config{
+ Commands: make(map[string]Command),
+ Tools: make(map[string]Tool),
+ }
+ local := &Config{
+ Commands: make(map[string]Command),
+ Tools: make(map[string]Tool),
+ }
+
+ merged := mergeConfigs(global, local)
+ if len(merged.Commands) != 0 {
+ t.Error("expected empty commands")
+ }
+ if len(merged.Tools) != 0 {
+ t.Error("expected empty tools")
+ }
+ })
+
+ t.Run("local overrides global", func(t *testing.T) {
+ global := &Config{
+ Commands: map[string]Command{
+ "plan": {Cmd: "echo global"},
+ "lint": {Cmd: "echo global lint"},
+ },
+ Tools: map[string]Tool{
+ "docker": {MinVersion: "1.0.0"},
+ "go": {MinVersion: "1.20"},
+ },
+ }
+ local := &Config{
+ Commands: map[string]Command{
+ "plan": {Cmd: "echo local"},
+ "test": {Cmd: "echo test"},
+ },
+ Tools: map[string]Tool{
+ "docker": {MinVersion: "2.0.0"},
+ "make": {MinVersion: "4.0"},
+ },
+ }
+
+ merged := mergeConfigs(global, local)
+
+ // Local overrides
+ if merged.Commands["plan"].Cmd != "echo local" {
+ t.Errorf("plan.cmd = %q, want %q", merged.Commands["plan"].Cmd, "echo local")
+ }
+ if merged.Tools["docker"].MinVersion != "2.0.0" {
+ t.Errorf("docker.min_version = %q, want %q", merged.Tools["docker"].MinVersion, "2.0.0")
+ }
+
+ // Global preserved
+ if merged.Commands["lint"].Cmd != "echo global lint" {
+ t.Errorf("lint.cmd = %q, want %q", merged.Commands["lint"].Cmd, "echo global lint")
+ }
+ if merged.Tools["go"].MinVersion != "1.20" {
+ t.Errorf("go.min_version = %q, want %q", merged.Tools["go"].MinVersion, "1.20")
+ }
+
+ // Local additions
+ if _, ok := merged.Commands["test"]; !ok {
+ t.Error("expected command 'test' from local")
+ }
+ if _, ok := merged.Tools["make"]; !ok {
+ t.Error("expected tool 'make' from local")
+ }
+ })
+}
+
+func TestConfigPaths(t *testing.T) {
+ global, local := ConfigPaths("ugo")
+ if global == "" {
+ t.Error("expected non-empty global path")
+ }
+ if local != "ugo.yaml" {
+ t.Errorf("local = %q, want %q", local, "ugo.yaml")
+ }
+
+ global2, local2 := ConfigPaths("myproject")
+ if global2 == "" {
+ t.Error("expected non-empty global path")
+ }
+ if local2 != "myproject.yaml" {
+ t.Errorf("local = %q, want %q", local2, "myproject.yaml")
+ }
+}
+
+func TestBinaryName(t *testing.T) {
+ // BinaryName uses os.Args[0], which in tests is usually the test binary path
+ name := BinaryName()
+ if name == "" {
+ t.Error("expected non-empty binary name")
+ }
+}
diff --git a/internal/output/output.go b/internal/output/output.go
new file mode 100644
index 0000000..1fa6f6a
--- /dev/null
+++ b/internal/output/output.go
@@ -0,0 +1,93 @@
+package output
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/mgutz/ansi"
+)
+
+var noColor bool
+
+// SetNoColor enables or disables color output
+func SetNoColor(v bool) {
+ noColor = v
+ ansi.DisableColors(v)
+}
+
+const (
+ checkPass = "✅"
+ checkFail = "❌"
+ info = "💡"
+ rocket = "🚀"
+)
+
+// CheckPass prints a passing check with green checkmark
+func CheckPass(msg string) {
+ fmt.Fprintf(os.Stdout, " %s %s\n", green(checkPass), msg)
+}
+
+// CheckFail prints a failing check with red cross
+func CheckFail(msg string) {
+ fmt.Fprintf(os.Stderr, " %s %s\n", red(checkFail), msg)
+}
+
+// Info prints an info message with blue icon
+func Info(msg string) {
+ fmt.Fprintf(os.Stdout, " %s %s\n", blue(info), msg)
+}
+
+// CommandRunning prints a command about to execute with rocket
+func CommandRunning(verb string, cmd string) {
+ fmt.Fprintf(os.Stdout, "%s %s: %s\n\n", yellow(rocket), yellow(verb), cmd)
+}
+
+// CommandSuccess prints a success after command execution
+func CommandSuccess(verb string) {
+ fmt.Fprintf(os.Stdout, "\n %s %s completed successfully\n", green(checkPass), green(verb))
+}
+
+// CommandFail prints a failure after command execution
+func CommandFail(verb string) {
+ fmt.Fprintf(os.Stderr, "\n %s %s failed\n", red(checkFail), red(verb))
+}
+
+func green(s string) string {
+ if noColor {
+ return s
+ }
+ return ansi.Color(s, "green")
+}
+
+func red(s string) string {
+ if noColor {
+ return s
+ }
+ return ansi.Color(s, "red")
+}
+
+func yellow(s string) string {
+ if noColor {
+ return s
+ }
+ return ansi.Color(s, "yellow+b")
+}
+
+func blue(s string) string {
+ if noColor {
+ return s
+ }
+ return ansi.Color(s, "blue")
+}
+
+func bold(s string) string {
+ if noColor {
+ return s
+ }
+ return ansi.Color(s, "default+b")
+}
+
+// Bold returns a bold string
+func Bold(s string) string {
+ return bold(s)
+}
diff --git a/internal/output/output_test.go b/internal/output/output_test.go
new file mode 100644
index 0000000..4e2af76
--- /dev/null
+++ b/internal/output/output_test.go
@@ -0,0 +1,94 @@
+package output
+
+import (
+ "bytes"
+ "os"
+ "testing"
+)
+
+func TestSetNoColor(t *testing.T) {
+ SetNoColor(true)
+ if !noColor {
+ t.Error("expected noColor to be true")
+ }
+
+ SetNoColor(false)
+ if noColor {
+ t.Error("expected noColor to be false")
+ }
+}
+
+func TestOutputWithColor(t *testing.T) {
+ SetNoColor(false)
+
+ tests := []struct {
+ name string
+ fn func()
+ }{
+ {"CheckPass", func() { CheckPass("test passed") }},
+ {"CheckFail", func() { CheckFail("test failed") }},
+ {"Info", func() { Info("some info") }},
+ {"CommandRunning", func() { CommandRunning("plan", "echo plan") }},
+ {"CommandSuccess", func() { CommandSuccess("plan") }},
+ {"CommandFail", func() { CommandFail("plan") }},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Just verify these don't panic
+ tt.fn()
+ })
+ }
+}
+
+func TestOutputNoColor(t *testing.T) {
+ SetNoColor(true)
+ defer SetNoColor(false)
+
+ oldStdout := os.Stdout
+ oldStderr := os.Stderr
+ rOut, wOut, _ := os.Pipe()
+ rErr, wErr, _ := os.Pipe()
+ os.Stdout = wOut
+ os.Stderr = wErr
+
+ CheckPass("test passed")
+ CheckFail("test failed")
+
+ wOut.Close()
+ wErr.Close()
+
+ var bufOut, bufErr bytes.Buffer
+ bufOut.ReadFrom(rOut)
+ bufErr.ReadFrom(rErr)
+
+ os.Stdout = oldStdout
+ os.Stderr = oldStderr
+
+ out := bufOut.String()
+ err := bufErr.String()
+
+ if out == "" {
+ t.Error("expected output on stdout")
+ }
+ if err == "" {
+ t.Error("expected output on stderr")
+ }
+
+ // Verify no ANSI escape codes when noColor is true
+ if containsANSI(out) {
+ t.Errorf("stdout contains ANSI codes: %q", out)
+ }
+ if containsANSI(err) {
+ t.Errorf("stderr contains ANSI codes: %q", err)
+ }
+}
+
+func containsANSI(s string) bool {
+ for i := 0; i < len(s); i++ {
+ if s[i] == '\x1b' {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/version/version.go b/internal/version/version.go
new file mode 100644
index 0000000..9fb2f62
--- /dev/null
+++ b/internal/version/version.go
@@ -0,0 +1,65 @@
+package version
+
+import (
+ "fmt"
+ "os/exec"
+ "regexp"
+ "strings"
+
+ "golang.org/x/mod/semver"
+)
+
+var versionRe = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
+
+// ExtractVersion finds the first semver-compatible version string in output
+func ExtractVersion(output string) string {
+ match := versionRe.FindStringSubmatch(output)
+ if match == nil {
+ return ""
+ }
+ v := match[1]
+ if !strings.HasPrefix(v, "v") {
+ v = "v" + v
+ }
+ return v
+}
+
+// Check verifies a tool's version against min/max constraints
+// versionCmd is the command to run (e.g. "ansible-playbook --version").
+// Returns the found version and any error.
+func Check(name string, versionCmd string, minVersion string, maxVersion string) (string, error) {
+ parts := strings.Fields(versionCmd)
+ if len(parts) == 0 {
+ return "", fmt.Errorf("empty version command for %s", name)
+ }
+
+ out, err := exec.Command(parts[0], parts[1:]...).Output()
+ if err != nil {
+ return "", fmt.Errorf("%s: failed to run version command: %w", name, err)
+ }
+
+ found := ExtractVersion(string(out))
+ if found == "" {
+ return "", fmt.Errorf("%s: could not parse version from output", name)
+ }
+
+ if minVersion != "" {
+ if !strings.HasPrefix(minVersion, "v") {
+ minVersion = "v" + minVersion
+ }
+ if semver.Compare(found, minVersion) < 0 {
+ return found, fmt.Errorf("%s: version %s is below minimum %s", name, found, minVersion)
+ }
+ }
+
+ if maxVersion != "" {
+ if !strings.HasPrefix(maxVersion, "v") {
+ maxVersion = "v" + maxVersion
+ }
+ if semver.Compare(found, maxVersion) > 0 {
+ return found, fmt.Errorf("%s: version %s exceeds maximum %s", name, found, maxVersion)
+ }
+ }
+
+ return found, nil
+}
diff --git a/internal/version/version_test.go b/internal/version/version_test.go
new file mode 100644
index 0000000..c1e8bf9
--- /dev/null
+++ b/internal/version/version_test.go
@@ -0,0 +1,125 @@
+package version
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestExtractVersion(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expect string
+ }{
+ {"bare semver", "2.10.5", "v2.10.5"},
+ {"with v prefix", "v1.2.3", "v1.2.3"},
+ {"embedded in text", "ansible-playbook 2.14.0", "v2.14.0"},
+ {"with v in text", "version v3.0.1-beta", "v3.0.1"},
+ {"docker style", "Docker version 24.0.7, build afdd53b", "v24.0.7"},
+ {"terraform style", "Terraform v1.6.2\non linux_amd64", "v1.6.2"},
+ {"no version", "some random output", ""},
+ {"empty", "", ""},
+ {"partial version", "v1.2", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := ExtractVersion(tt.input)
+ if got != tt.expect {
+ t.Errorf("ExtractVersion(%q) = %q, want %q", tt.input, got, tt.expect)
+ }
+ })
+ }
+}
+
+func TestCheck(t *testing.T) {
+ tmp := t.TempDir()
+ scriptPath := filepath.Join(tmp, "fake-version")
+ if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\necho \"fake-tool v1.5.0\"\n"), 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ tests := []struct {
+ name string
+ versionCmd string
+ minVersion string
+ maxVersion string
+ wantVer string
+ wantErr bool
+ errContains string
+ }{
+ {
+ name: "version within range",
+ versionCmd: scriptPath,
+ minVersion: "1.0.0",
+ maxVersion: "2.0.0",
+ wantVer: "v1.5.0",
+ },
+ {
+ name: "below minimum",
+ versionCmd: scriptPath,
+ minVersion: "2.0.0",
+ wantErr: true,
+ errContains: "below minimum",
+ },
+ {
+ name: "above maximum",
+ versionCmd: scriptPath,
+ maxVersion: "1.4.0",
+ wantErr: true,
+ errContains: "exceeds maximum",
+ },
+ {
+ name: "no constraints",
+ versionCmd: scriptPath,
+ wantVer: "v1.5.0",
+ },
+ {
+ name: "min only",
+ versionCmd: scriptPath,
+ minVersion: "1.5.0",
+ wantVer: "v1.5.0",
+ },
+ {
+ name: "max only",
+ versionCmd: scriptPath,
+ maxVersion: "1.5.0",
+ wantVer: "v1.5.0",
+ },
+ {
+ name: "command not found",
+ versionCmd: "nonexistent-command-xyz --version",
+ wantErr: true,
+ errContains: "failed to run version command",
+ },
+ {
+ name: "empty version command",
+ versionCmd: "",
+ wantErr: true,
+ errContains: "empty version command",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotVer, err := Check("test-tool", tt.versionCmd, tt.minVersion, tt.maxVersion)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("Check() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if tt.wantErr && tt.errContains != "" {
+ if err == nil {
+ t.Errorf("Check() expected error containing %q, got nil", tt.errContains)
+ } else if !strings.Contains(err.Error(), tt.errContains) {
+ t.Errorf("Check() error = %q, want to contain %q", err.Error(), tt.errContains)
+ }
+ return
+ }
+ if gotVer != tt.wantVer {
+ t.Errorf("Check() version = %q, want %q", gotVer, tt.wantVer)
+ }
+ })
+ }
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..9e1709a
--- /dev/null
+++ b/main.go
@@ -0,0 +1,9 @@
+package main
+
+import (
+ "github.com/PyratLabs/ugo/cmd"
+)
+
+func main() {
+ cmd.RootCmd().Execute()
+}