Compare commits

..

14 commits
dev ... main

Author SHA1 Message Date
Snider
138927baa5 docs: update plans to reflect WithCommands lifecycle pattern
- Rewrite cli-meta-package-design to document current state:
  WithCommands(), completed migrations, no init()/blank imports
- Add completion status note to MCP integration plan
- Update pkg-batch2-analysis RegisterCommands → WithCommands

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 22:13:22 +00:00
Snider
2a90ae65b7 refactor(cli): register commands through Core framework lifecycle
Replace the RegisterCommands/attachRegisteredCommands side-channel with
WithCommands(), which wraps command registration functions as framework
services. Commands now participate in the Core lifecycle via OnStartup,
receiving the root cobra.Command through Core.App.

Main() accepts variadic framework.Option so binaries pass their commands
explicitly — no init(), no blank imports, no global state.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 22:06:40 +00:00
Snider
8e7fb0e5a3 feat: absorb Go tooling commands from CLI
cmd/gocmd/ provides: fmt, test, fuzz, qa, cov, tools wrappers.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 21:45:52 +00:00
Snider
d091fa6202 chore: resolve go-crypt from forge, remove local replace
Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 19:11:03 +00:00
Snider
58ca902320 feat(cli): add Viewport for scrollable content (logs, diffs, docs)
Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 18:13:37 +00:00
Snider
a0660e5802 feat(cli): add TextInput with placeholder, masking, validation
Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 18:13:07 +00:00
Snider
fcdccdbe87 feat(cli): add InteractiveList with keyboard navigation and terminal fallback
Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 18:12:37 +00:00
Snider
c2418a2737 feat(cli): stub Form, FilePicker, Tabs with simple fallbacks
Interfaces defined for future charmbracelet/huh upgrade.
Current implementations use sequential prompts.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 18:10:33 +00:00
Snider
175ad1e361 feat(cli): add ProgressBar with Increment, Set, SetMessage, Done
Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 18:10:01 +00:00
Snider
50afecea6d feat(cli): add Spinner with async handle (Update, Done, Fail)
Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 18:09:40 +00:00
Snider
92a2260e21 feat(cli): add RunTUI escape hatch with Model/Msg/Cmd/KeyMsg types
Wraps bubbletea v1 behind our own interface so domain packages
never import charmbracelet directly.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 18:08:35 +00:00
Snider
e3fdbe9809 docs: add CLI SDK expansion implementation plan (Phase 0)
9-task plan for adding charmbracelet TUI primitives to go/pkg/cli:
Spinner, ProgressBar, RunTUI, List, TextInput, Viewport, and stubs
for Form/FilePicker/Tabs. All charm deps stay inside pkg/cli —
domain packages import only forge.lthn.ai/core/go/pkg/cli.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 18:02:19 +00:00
Snider
e66115f036 docs: CLI meta-package restructure design
Domain repos own their commands via self-registration. cli/ becomes
a thin assembly repo shipping variant binaries (core, core-ci,
core-mlx, core-ops). go/pkg/cli wraps cobra + charmbracelet as the
single import for all CLI concerns.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 17:55:53 +00:00
Snider
2aff7a3503 docs: add go-forge design and implementation plan
Full-coverage Forgejo API client (450 endpoints, 229 types).
Generic Resource[T,C,U] for 91% CRUD + codegen from swagger.v1.json.
20-task plan across 6 waves.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 15:18:27 +00:00
34 changed files with 8035 additions and 61 deletions

3
.gitignore vendored
View file

@ -17,7 +17,8 @@ dist/
tasks
/core
/i18n-validate
cmd/
cmd/*
!cmd/gocmd/
.angular/
patch_cov.*

15
cmd/gocmd/cmd_commands.go Normal file
View file

@ -0,0 +1,15 @@
// Package gocmd provides Go development commands with enhanced output.
//
// Note: Package named gocmd because 'go' is a reserved keyword.
//
// Commands:
// - test: Run tests with colour-coded coverage summary
// - cov: Run tests with detailed coverage reports (HTML, thresholds)
// - fmt: Format code using goimports or gofmt
// - lint: Run golangci-lint
// - install: Install binary to $GOPATH/bin
// - mod: Module management (tidy, download, verify, graph)
// - work: Workspace management (sync, init, use)
//
// Sets MACOSX_DEPLOYMENT_TARGET to suppress linker warnings on macOS.
package gocmd

177
cmd/gocmd/cmd_format.go Normal file
View file

@ -0,0 +1,177 @@
package gocmd
import (
"bufio"
"os"
"os/exec"
"path/filepath"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
var (
fmtFix bool
fmtDiff bool
fmtCheck bool
fmtAll bool
)
func addGoFmtCommand(parent *cli.Command) {
fmtCmd := &cli.Command{
Use: "fmt",
Short: "Format Go code",
Long: "Format Go code using goimports or gofmt. By default only checks changed files.",
RunE: func(cmd *cli.Command, args []string) error {
// Get list of files to check
var files []string
if fmtAll {
// Check all Go files
files = []string{"."}
} else {
// Only check changed Go files (git-aware)
files = getChangedGoFiles()
if len(files) == 0 {
cli.Print("%s\n", i18n.T("cmd.go.fmt.no_changes"))
return nil
}
}
// Validate flag combinations
if fmtCheck && fmtFix {
return cli.Err("--check and --fix are mutually exclusive")
}
fmtArgs := []string{}
if fmtFix {
fmtArgs = append(fmtArgs, "-w")
}
if fmtDiff {
fmtArgs = append(fmtArgs, "-d")
}
if !fmtFix && !fmtDiff {
fmtArgs = append(fmtArgs, "-l")
}
fmtArgs = append(fmtArgs, files...)
// Try goimports first, fall back to gofmt
var execCmd *exec.Cmd
if _, err := exec.LookPath("goimports"); err == nil {
execCmd = exec.Command("goimports", fmtArgs...)
} else {
execCmd = exec.Command("gofmt", fmtArgs...)
}
// For --check mode, capture output to detect unformatted files
if fmtCheck {
output, err := execCmd.CombinedOutput()
if err != nil {
_, _ = os.Stderr.Write(output)
return err
}
if len(output) > 0 {
_, _ = os.Stdout.Write(output)
return cli.Err("files need formatting (use --fix)")
}
return nil
}
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
fmtCmd.Flags().BoolVar(&fmtFix, "fix", false, i18n.T("common.flag.fix"))
fmtCmd.Flags().BoolVar(&fmtDiff, "diff", false, i18n.T("common.flag.diff"))
fmtCmd.Flags().BoolVar(&fmtCheck, "check", false, i18n.T("cmd.go.fmt.flag.check"))
fmtCmd.Flags().BoolVar(&fmtAll, "all", false, i18n.T("cmd.go.fmt.flag.all"))
parent.AddCommand(fmtCmd)
}
// getChangedGoFiles returns Go files that have been modified, staged, or are untracked.
func getChangedGoFiles() []string {
var files []string
// Get modified and staged files
cmd := exec.Command("git", "diff", "--name-only", "--diff-filter=ACMR", "HEAD")
output, err := cmd.Output()
if err == nil {
files = append(files, filterGoFiles(string(output))...)
}
// Get untracked files
cmd = exec.Command("git", "ls-files", "--others", "--exclude-standard")
output, err = cmd.Output()
if err == nil {
files = append(files, filterGoFiles(string(output))...)
}
// Deduplicate
seen := make(map[string]bool)
var unique []string
for _, f := range files {
if !seen[f] {
seen[f] = true
// Verify file exists (might have been deleted)
if _, err := os.Stat(f); err == nil {
unique = append(unique, f)
}
}
}
return unique
}
// filterGoFiles filters a newline-separated list of files to only include .go files.
func filterGoFiles(output string) []string {
var goFiles []string
scanner := bufio.NewScanner(strings.NewReader(output))
for scanner.Scan() {
file := strings.TrimSpace(scanner.Text())
if file != "" && filepath.Ext(file) == ".go" {
goFiles = append(goFiles, file)
}
}
return goFiles
}
var (
lintFix bool
lintAll bool
)
func addGoLintCommand(parent *cli.Command) {
lintCmd := &cli.Command{
Use: "lint",
Short: "Run golangci-lint",
Long: "Run golangci-lint for comprehensive static analysis. By default only lints changed files.",
RunE: func(cmd *cli.Command, args []string) error {
lintArgs := []string{"run"}
if lintFix {
lintArgs = append(lintArgs, "--fix")
}
if !lintAll {
// Use --new-from-rev=HEAD to only report issues in uncommitted changes
// This is golangci-lint's native way to handle incremental linting
lintArgs = append(lintArgs, "--new-from-rev=HEAD")
}
// Always lint all packages
lintArgs = append(lintArgs, "./...")
execCmd := exec.Command("golangci-lint", lintArgs...)
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
lintCmd.Flags().BoolVar(&lintFix, "fix", false, i18n.T("common.flag.fix"))
lintCmd.Flags().BoolVar(&lintAll, "all", false, i18n.T("cmd.go.lint.flag.all"))
parent.AddCommand(lintCmd)
}

169
cmd/gocmd/cmd_fuzz.go Normal file
View file

@ -0,0 +1,169 @@
package gocmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
var (
fuzzDuration time.Duration
fuzzPkg string
fuzzRun string
fuzzVerbose bool
)
func addGoFuzzCommand(parent *cli.Command) {
fuzzCmd := &cli.Command{
Use: "fuzz",
Short: "Run Go fuzz tests",
Long: `Run Go fuzz tests with configurable duration.
Discovers Fuzz* functions across the project and runs each with go test -fuzz.
Examples:
core go fuzz # Run all fuzz targets for 10s each
core go fuzz --duration=30s # Run each target for 30s
core go fuzz --pkg=./pkg/... # Fuzz specific package
core go fuzz --run=FuzzE # Run only matching fuzz targets`,
RunE: func(cmd *cli.Command, args []string) error {
return runGoFuzz(fuzzDuration, fuzzPkg, fuzzRun, fuzzVerbose)
},
}
fuzzCmd.Flags().DurationVar(&fuzzDuration, "duration", 10*time.Second, "Duration per fuzz target")
fuzzCmd.Flags().StringVar(&fuzzPkg, "pkg", "", "Package to fuzz (default: auto-discover)")
fuzzCmd.Flags().StringVar(&fuzzRun, "run", "", "Only run fuzz targets matching pattern")
fuzzCmd.Flags().BoolVarP(&fuzzVerbose, "verbose", "v", false, "Verbose output")
parent.AddCommand(fuzzCmd)
}
// fuzzTarget represents a discovered fuzz function and its package.
type fuzzTarget struct {
Pkg string
Name string
}
func runGoFuzz(duration time.Duration, pkg, run string, verbose bool) error {
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("fuzz")), i18n.ProgressSubject("run", "fuzz tests"))
cli.Blank()
targets, err := discoverFuzzTargets(pkg, run)
if err != nil {
return cli.Wrap(err, "discover fuzz targets")
}
if len(targets) == 0 {
cli.Print(" %s no fuzz targets found\n", dimStyle.Render("—"))
return nil
}
cli.Print(" %s %d target(s), %s each\n", dimStyle.Render(i18n.Label("targets")), len(targets), duration)
cli.Blank()
passed := 0
failed := 0
for _, t := range targets {
cli.Print(" %s %s in %s\n", dimStyle.Render("→"), t.Name, t.Pkg)
args := []string{
"test",
fmt.Sprintf("-fuzz=^%s$", t.Name),
fmt.Sprintf("-fuzztime=%s", duration),
"-run=^$", // Don't run unit tests
}
if verbose {
args = append(args, "-v")
}
args = append(args, t.Pkg)
cmd := exec.Command("go", args...)
cmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0", "CGO_ENABLED=0")
cmd.Dir, _ = os.Getwd()
output, runErr := cmd.CombinedOutput()
outputStr := string(output)
if runErr != nil {
failed++
cli.Print(" %s %s\n", errorStyle.Render(cli.Glyph(":cross:")), runErr.Error())
if outputStr != "" {
cli.Text(outputStr)
}
} else {
passed++
cli.Print(" %s %s\n", successStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.done.pass"))
if verbose && outputStr != "" {
cli.Text(outputStr)
}
}
}
cli.Blank()
if failed > 0 {
cli.Print("%s %d passed, %d failed\n", errorStyle.Render(cli.Glyph(":cross:")), passed, failed)
return cli.Err("fuzz: %d target(s) failed", failed)
}
cli.Print("%s %d passed\n", successStyle.Render(cli.Glyph(":check:")), passed)
return nil
}
// discoverFuzzTargets scans for Fuzz* functions in test files.
func discoverFuzzTargets(pkg, pattern string) ([]fuzzTarget, error) {
root := "."
if pkg != "" {
// Convert Go package pattern to filesystem path
root = strings.TrimPrefix(pkg, "./")
root = strings.TrimSuffix(root, "/...")
}
fuzzRe := regexp.MustCompile(`^func\s+(Fuzz\w+)\s*\(\s*\w+\s+\*testing\.F\s*\)`)
var matchRe *regexp.Regexp
if pattern != "" {
var err error
matchRe, err = regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("invalid --run pattern: %w", err)
}
}
var targets []fuzzTarget
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() || !strings.HasSuffix(info.Name(), "_test.go") {
return nil
}
data, readErr := os.ReadFile(path)
if readErr != nil {
return nil
}
dir := "./" + filepath.Dir(path)
for line := range strings.SplitSeq(string(data), "\n") {
m := fuzzRe.FindStringSubmatch(line)
if m == nil {
continue
}
name := m[1]
if matchRe != nil && !matchRe.MatchString(name) {
continue
}
targets = append(targets, fuzzTarget{Pkg: dir, Name: name})
}
return nil
})
return targets, err
}

36
cmd/gocmd/cmd_go.go Normal file
View file

@ -0,0 +1,36 @@
// Package gocmd provides Go development commands.
//
// Note: Package named gocmd because 'go' is a reserved keyword.
package gocmd
import (
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
// Style aliases for shared styles
var (
successStyle = cli.SuccessStyle
errorStyle = cli.ErrorStyle
dimStyle = cli.DimStyle
)
// AddGoCommands adds Go development commands.
func AddGoCommands(root *cli.Command) {
goCmd := &cli.Command{
Use: "go",
Short: i18n.T("cmd.go.short"),
Long: i18n.T("cmd.go.long"),
}
root.AddCommand(goCmd)
addGoQACommand(goCmd)
addGoTestCommand(goCmd)
addGoCovCommand(goCmd)
addGoFmtCommand(goCmd)
addGoLintCommand(goCmd)
addGoInstallCommand(goCmd)
addGoModCommand(goCmd)
addGoWorkCommand(goCmd)
addGoFuzzCommand(goCmd)
}

430
cmd/gocmd/cmd_gotest.go Normal file
View file

@ -0,0 +1,430 @@
package gocmd
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
var (
testCoverage bool
testPkg string
testRun string
testShort bool
testRace bool
testJSON bool
testVerbose bool
)
func addGoTestCommand(parent *cli.Command) {
testCmd := &cli.Command{
Use: "test",
Short: "Run Go tests",
Long: "Run Go tests with optional coverage, filtering, and race detection",
RunE: func(cmd *cli.Command, args []string) error {
return runGoTest(testCoverage, testPkg, testRun, testShort, testRace, testJSON, testVerbose)
},
}
testCmd.Flags().BoolVar(&testCoverage, "coverage", false, "Generate coverage report")
testCmd.Flags().StringVar(&testPkg, "pkg", "", "Package to test")
testCmd.Flags().StringVar(&testRun, "run", "", "Run only tests matching pattern")
testCmd.Flags().BoolVar(&testShort, "short", false, "Run only short tests")
testCmd.Flags().BoolVar(&testRace, "race", false, "Enable race detector")
testCmd.Flags().BoolVar(&testJSON, "json", false, "Output as JSON")
testCmd.Flags().BoolVarP(&testVerbose, "verbose", "v", false, "Verbose output")
parent.AddCommand(testCmd)
}
func runGoTest(coverage bool, pkg, run string, short, race, jsonOut, verbose bool) error {
if pkg == "" {
pkg = "./..."
}
args := []string{"test"}
var covPath string
if coverage {
args = append(args, "-cover", "-covermode=atomic")
covFile, err := os.CreateTemp("", "coverage-*.out")
if err == nil {
covPath = covFile.Name()
_ = covFile.Close()
args = append(args, "-coverprofile="+covPath)
defer os.Remove(covPath)
}
}
if run != "" {
args = append(args, "-run", run)
}
if short {
args = append(args, "-short")
}
if race {
args = append(args, "-race")
}
if verbose {
args = append(args, "-v")
}
args = append(args, pkg)
if !jsonOut {
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("test")), i18n.ProgressSubject("run", "tests"))
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("package")), pkg)
cli.Blank()
}
cmd := exec.Command("go", args...)
cmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0", "CGO_ENABLED=0")
cmd.Dir, _ = os.Getwd()
output, err := cmd.CombinedOutput()
outputStr := string(output)
// Filter linker warnings
lines := strings.Split(outputStr, "\n")
var filtered []string
for _, line := range lines {
if !strings.Contains(line, "ld: warning:") {
filtered = append(filtered, line)
}
}
outputStr = strings.Join(filtered, "\n")
// Parse results
passed, failed, skipped := parseTestResults(outputStr)
cov := parseOverallCoverage(outputStr)
if jsonOut {
cli.Print(`{"passed":%d,"failed":%d,"skipped":%d,"coverage":%.1f,"exit_code":%d}`,
passed, failed, skipped, cov, cmd.ProcessState.ExitCode())
cli.Blank()
return err
}
// Print filtered output if verbose or failed
if verbose || err != nil {
cli.Text(outputStr)
}
// Summary
if err == nil {
cli.Print(" %s %s\n", successStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.count.test", passed)+" "+i18n.T("i18n.done.pass"))
} else {
cli.Print(" %s %s, %s\n", errorStyle.Render(cli.Glyph(":cross:")),
i18n.T("i18n.count.test", passed)+" "+i18n.T("i18n.done.pass"),
i18n.T("i18n.count.test", failed)+" "+i18n.T("i18n.done.fail"))
}
if cov > 0 {
cli.Print("\n %s %s\n", cli.KeyStyle.Render(i18n.Label("statements")), formatCoverage(cov))
if covPath != "" {
branchCov, err := calculateBlockCoverage(covPath)
if err != nil {
cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), cli.ErrorStyle.Render("unable to calculate"))
} else {
cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), formatCoverage(branchCov))
}
}
}
if err == nil {
cli.Print("\n%s\n", successStyle.Render(i18n.T("i18n.done.pass")))
} else {
cli.Print("\n%s\n", errorStyle.Render(i18n.T("i18n.done.fail")))
}
return err
}
func parseTestResults(output string) (passed, failed, skipped int) {
passRe := regexp.MustCompile(`(?m)^ok\s+`)
failRe := regexp.MustCompile(`(?m)^FAIL\s+`)
skipRe := regexp.MustCompile(`(?m)^\?\s+`)
passed = len(passRe.FindAllString(output, -1))
failed = len(failRe.FindAllString(output, -1))
skipped = len(skipRe.FindAllString(output, -1))
return
}
func parseOverallCoverage(output string) float64 {
re := regexp.MustCompile(`coverage:\s+([\d.]+)%`)
matches := re.FindAllStringSubmatch(output, -1)
if len(matches) == 0 {
return 0
}
var total float64
for _, m := range matches {
var cov float64
_, _ = fmt.Sscanf(m[1], "%f", &cov)
total += cov
}
return total / float64(len(matches))
}
var (
covPkg string
covHTML bool
covOpen bool
covThreshold float64
covBranchThreshold float64
covOutput string
)
func addGoCovCommand(parent *cli.Command) {
covCmd := &cli.Command{
Use: "cov",
Short: "Run tests with coverage report",
Long: "Run tests with detailed coverage reports, HTML output, and threshold checking",
RunE: func(cmd *cli.Command, args []string) error {
pkg := covPkg
if pkg == "" {
// Auto-discover packages with tests
pkgs, err := findTestPackages(".")
if err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.find", "test packages"))
}
if len(pkgs) == 0 {
return errors.New("no test packages found")
}
pkg = strings.Join(pkgs, " ")
}
// Create temp file for coverage data
covFile, err := os.CreateTemp("", "coverage-*.out")
if err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.create", "coverage file"))
}
covPath := covFile.Name()
_ = covFile.Close()
defer func() {
if covOutput == "" {
_ = os.Remove(covPath)
} else {
// Copy to output destination before removing
src, _ := os.Open(covPath)
dst, _ := os.Create(covOutput)
if src != nil && dst != nil {
_, _ = io.Copy(dst, src)
_ = src.Close()
_ = dst.Close()
}
_ = os.Remove(covPath)
}
}()
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("coverage")), i18n.ProgressSubject("run", "tests"))
// Truncate package list if too long for display
displayPkg := pkg
if len(displayPkg) > 60 {
displayPkg = displayPkg[:57] + "..."
}
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("package")), displayPkg)
cli.Blank()
// Run tests with coverage
// We need to split pkg into individual arguments if it contains spaces
pkgArgs := strings.Fields(pkg)
cmdArgs := append([]string{"test", "-coverprofile=" + covPath, "-covermode=atomic"}, pkgArgs...)
goCmd := exec.Command("go", cmdArgs...)
goCmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0")
goCmd.Stdout = os.Stdout
goCmd.Stderr = os.Stderr
testErr := goCmd.Run()
// Get coverage percentage
coverCmd := exec.Command("go", "tool", "cover", "-func="+covPath)
covOutput, err := coverCmd.Output()
if err != nil {
if testErr != nil {
return testErr
}
return cli.Wrap(err, i18n.T("i18n.fail.get", "coverage"))
}
// Parse total coverage from last line
lines := strings.Split(strings.TrimSpace(string(covOutput)), "\n")
var statementCov float64
if len(lines) > 0 {
lastLine := lines[len(lines)-1]
// Format: "total: (statements) XX.X%"
if strings.Contains(lastLine, "total:") {
parts := strings.Fields(lastLine)
if len(parts) >= 3 {
covStr := strings.TrimSuffix(parts[len(parts)-1], "%")
_, _ = fmt.Sscanf(covStr, "%f", &statementCov)
}
}
}
// Calculate branch coverage (block coverage)
branchCov, err := calculateBlockCoverage(covPath)
if err != nil {
return cli.Wrap(err, "calculate branch coverage")
}
// Print coverage summary
cli.Blank()
cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("statements")), formatCoverage(statementCov))
cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), formatCoverage(branchCov))
// Generate HTML if requested
if covHTML || covOpen {
htmlPath := "coverage.html"
htmlCmd := exec.Command("go", "tool", "cover", "-html="+covPath, "-o="+htmlPath)
if err := htmlCmd.Run(); err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.generate", "HTML"))
}
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("html")), htmlPath)
if covOpen {
// Open in browser
var openCmd *exec.Cmd
switch {
case exec.Command("which", "open").Run() == nil:
openCmd = exec.Command("open", htmlPath)
case exec.Command("which", "xdg-open").Run() == nil:
openCmd = exec.Command("xdg-open", htmlPath)
default:
cli.Print(" %s\n", dimStyle.Render("Open coverage.html in your browser"))
}
if openCmd != nil {
_ = openCmd.Run()
}
}
}
// Check thresholds
if covThreshold > 0 && statementCov < covThreshold {
cli.Print("\n%s Statements: %.1f%% < %.1f%%\n", errorStyle.Render(i18n.T("i18n.fail.meet", "threshold")), statementCov, covThreshold)
return errors.New("statement coverage below threshold")
}
if covBranchThreshold > 0 && branchCov < covBranchThreshold {
cli.Print("\n%s Branches: %.1f%% < %.1f%%\n", errorStyle.Render(i18n.T("i18n.fail.meet", "threshold")), branchCov, covBranchThreshold)
return errors.New("branch coverage below threshold")
}
if testErr != nil {
return testErr
}
cli.Print("\n%s\n", successStyle.Render(i18n.T("i18n.done.pass")))
return nil
},
}
covCmd.Flags().StringVar(&covPkg, "pkg", "", "Package to test")
covCmd.Flags().BoolVar(&covHTML, "html", false, "Generate HTML report")
covCmd.Flags().BoolVar(&covOpen, "open", false, "Open HTML report in browser")
covCmd.Flags().Float64Var(&covThreshold, "threshold", 0, "Minimum statement coverage percentage")
covCmd.Flags().Float64Var(&covBranchThreshold, "branch-threshold", 0, "Minimum branch coverage percentage")
covCmd.Flags().StringVarP(&covOutput, "output", "o", "", "Output file for coverage profile")
parent.AddCommand(covCmd)
}
// calculateBlockCoverage parses a Go coverage profile and returns the percentage of basic
// blocks that have a non-zero execution count. Go's coverage profile contains one line per
// basic block, where the last field is the execution count, not explicit branch coverage.
// The resulting block coverage is used here only as a proxy for branch coverage; computing
// true branch coverage would require more detailed control-flow analysis.
func calculateBlockCoverage(path string) (float64, error) {
file, err := os.Open(path)
if err != nil {
return 0, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
var totalBlocks, coveredBlocks int
// Skip the first line (mode: atomic/set/count)
if !scanner.Scan() {
return 0, nil
}
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
// Last field is the count
count, err := strconv.Atoi(fields[len(fields)-1])
if err != nil {
continue
}
totalBlocks++
if count > 0 {
coveredBlocks++
}
}
if err := scanner.Err(); err != nil {
return 0, err
}
if totalBlocks == 0 {
return 0, nil
}
return (float64(coveredBlocks) / float64(totalBlocks)) * 100, nil
}
func findTestPackages(root string) ([]string, error) {
pkgMap := make(map[string]bool)
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() && strings.HasSuffix(info.Name(), "_test.go") {
dir := filepath.Dir(path)
if !strings.HasPrefix(dir, ".") {
dir = "./" + dir
}
pkgMap[dir] = true
}
return nil
})
if err != nil {
return nil, err
}
var pkgs []string
for pkg := range pkgMap {
pkgs = append(pkgs, pkg)
}
return pkgs, nil
}
func formatCoverage(cov float64) string {
s := fmt.Sprintf("%.1f%%", cov)
if cov >= 80 {
return cli.SuccessStyle.Render(s)
} else if cov >= 50 {
return cli.WarningStyle.Render(s)
}
return cli.ErrorStyle.Render(s)
}

639
cmd/gocmd/cmd_qa.go Normal file
View file

@ -0,0 +1,639 @@
package gocmd
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"time"
"forge.lthn.ai/core/cli/cmd/qa"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
// QA command flags - comprehensive options for all agents
var (
qaFix bool
qaChanged bool
qaAll bool
qaSkip string
qaOnly string
qaCoverage bool
qaThreshold float64
qaBranchThreshold float64
qaDocblockThreshold float64
qaJSON bool
qaVerbose bool
qaQuiet bool
qaTimeout time.Duration
qaShort bool
qaRace bool
qaBench bool
qaFailFast bool
qaMod bool
qaCI bool
)
func addGoQACommand(parent *cli.Command) {
qaCmd := &cli.Command{
Use: "qa",
Short: "Run QA checks",
Long: `Run comprehensive code quality checks for Go projects.
Checks available: fmt, vet, lint, test, race, fuzz, vuln, sec, bench, docblock
Examples:
core go qa # Default: fmt, lint, test
core go qa --fix # Auto-fix formatting and lint issues
core go qa --only=test # Only run tests
core go qa --skip=vuln,sec # Skip vulnerability and security scans
core go qa --coverage --threshold=80 # Require 80% coverage
core go qa --changed # Only check changed files (git-aware)
core go qa --ci # CI mode: strict, coverage, fail-fast
core go qa --race --short # Quick tests with race detection
core go qa --json # Output results as JSON`,
RunE: runGoQA,
}
// Fix and modification flags (persistent so subcommands inherit them)
qaCmd.PersistentFlags().BoolVar(&qaFix, "fix", false, "Auto-fix issues where possible")
qaCmd.PersistentFlags().BoolVar(&qaMod, "mod", false, "Run go mod tidy before checks")
// Scope flags
qaCmd.PersistentFlags().BoolVar(&qaChanged, "changed", false, "Only check changed files (git-aware)")
qaCmd.PersistentFlags().BoolVar(&qaAll, "all", false, "Check all files (override git-aware)")
qaCmd.PersistentFlags().StringVar(&qaSkip, "skip", "", "Skip checks (comma-separated: fmt,vet,lint,test,race,fuzz,vuln,sec,bench)")
qaCmd.PersistentFlags().StringVar(&qaOnly, "only", "", "Only run these checks (comma-separated)")
// Coverage flags
qaCmd.PersistentFlags().BoolVar(&qaCoverage, "coverage", false, "Include coverage reporting")
qaCmd.PersistentFlags().BoolVarP(&qaCoverage, "cov", "c", false, "Include coverage reporting (shorthand)")
qaCmd.PersistentFlags().Float64Var(&qaThreshold, "threshold", 0, "Minimum statement coverage threshold (0-100), fail if below")
qaCmd.PersistentFlags().Float64Var(&qaBranchThreshold, "branch-threshold", 0, "Minimum branch coverage threshold (0-100), fail if below")
qaCmd.PersistentFlags().Float64Var(&qaDocblockThreshold, "docblock-threshold", 80, "Minimum docblock coverage threshold (0-100)")
// Test flags
qaCmd.PersistentFlags().BoolVar(&qaShort, "short", false, "Run tests with -short flag")
qaCmd.PersistentFlags().BoolVar(&qaRace, "race", false, "Include race detection in tests")
qaCmd.PersistentFlags().BoolVar(&qaBench, "bench", false, "Include benchmarks")
// Output flags
qaCmd.PersistentFlags().BoolVar(&qaJSON, "json", false, "Output results as JSON")
qaCmd.PersistentFlags().BoolVarP(&qaVerbose, "verbose", "v", false, "Show verbose output")
qaCmd.PersistentFlags().BoolVarP(&qaQuiet, "quiet", "q", false, "Only show errors")
// Control flags
qaCmd.PersistentFlags().DurationVar(&qaTimeout, "timeout", 10*time.Minute, "Timeout for all checks")
qaCmd.PersistentFlags().BoolVar(&qaFailFast, "fail-fast", false, "Stop on first failure")
qaCmd.PersistentFlags().BoolVar(&qaCI, "ci", false, "CI mode: strict checks, coverage required, fail-fast")
// Preset subcommands for convenience
qaCmd.AddCommand(&cli.Command{
Use: "quick",
Short: "Quick QA: fmt, vet, lint (no tests)",
RunE: func(cmd *cli.Command, args []string) error { qaOnly = "fmt,vet,lint"; return runGoQA(cmd, args) },
})
qaCmd.AddCommand(&cli.Command{
Use: "full",
Short: "Full QA: all checks including race, vuln, sec",
RunE: func(cmd *cli.Command, args []string) error {
qaOnly = "fmt,vet,lint,test,race,vuln,sec"
return runGoQA(cmd, args)
},
})
qaCmd.AddCommand(&cli.Command{
Use: "pre-commit",
Short: "Pre-commit checks: fmt --fix, lint --fix, test --short",
RunE: func(cmd *cli.Command, args []string) error {
qaFix = true
qaShort = true
qaOnly = "fmt,lint,test"
return runGoQA(cmd, args)
},
})
qaCmd.AddCommand(&cli.Command{
Use: "pr",
Short: "PR checks: full QA with coverage threshold",
RunE: func(cmd *cli.Command, args []string) error {
qaCoverage = true
if qaThreshold == 0 {
qaThreshold = 50 // Default PR threshold
}
qaOnly = "fmt,vet,lint,test"
return runGoQA(cmd, args)
},
})
parent.AddCommand(qaCmd)
}
// QAResult holds the result of a QA run for JSON output
type QAResult struct {
Success bool `json:"success"`
Duration string `json:"duration"`
Checks []CheckResult `json:"checks"`
Coverage *float64 `json:"coverage,omitempty"`
BranchCoverage *float64 `json:"branch_coverage,omitempty"`
Threshold *float64 `json:"threshold,omitempty"`
BranchThreshold *float64 `json:"branch_threshold,omitempty"`
}
// CheckResult holds the result of a single check
type CheckResult struct {
Name string `json:"name"`
Passed bool `json:"passed"`
Duration string `json:"duration"`
Error string `json:"error,omitempty"`
Output string `json:"output,omitempty"`
FixHint string `json:"fix_hint,omitempty"`
}
func runGoQA(cmd *cli.Command, args []string) error {
// Apply CI mode defaults
if qaCI {
qaCoverage = true
qaFailFast = true
if qaThreshold == 0 {
qaThreshold = 50
}
}
cwd, err := os.Getwd()
if err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.get", "working directory"))
}
// Detect if this is a Go project
if _, err := os.Stat("go.mod"); os.IsNotExist(err) {
return cli.Err("not a Go project (no go.mod found)")
}
// Determine which checks to run
checkNames := determineChecks()
if !qaJSON && !qaQuiet {
cli.Print("%s %s\n\n", cli.DimStyle.Render(i18n.Label("qa")), i18n.ProgressSubject("run", "Go QA"))
}
// Run go mod tidy if requested
if qaMod {
if !qaQuiet {
cli.Print("%s %s\n", cli.DimStyle.Render("→"), "Running go mod tidy...")
}
modCmd := exec.Command("go", "mod", "tidy")
modCmd.Dir = cwd
if err := modCmd.Run(); err != nil {
return cli.Wrap(err, "go mod tidy failed")
}
}
ctx, cancel := context.WithTimeout(context.Background(), qaTimeout)
defer cancel()
startTime := time.Now()
checks := buildChecks(checkNames)
results := make([]CheckResult, 0, len(checks))
passed := 0
failed := 0
for _, check := range checks {
checkStart := time.Now()
if !qaJSON && !qaQuiet {
cli.Print("%s %s\n", cli.DimStyle.Render("→"), i18n.Progress(check.Name))
}
output, err := runCheckCapture(ctx, cwd, check)
checkDuration := time.Since(checkStart)
result := CheckResult{
Name: check.Name,
Duration: checkDuration.Round(time.Millisecond).String(),
}
if err != nil {
result.Passed = false
result.Error = err.Error()
if qaVerbose {
result.Output = output
}
result.FixHint = fixHintFor(check.Name, output)
failed++
if !qaJSON && !qaQuiet {
cli.Print(" %s %s\n", cli.ErrorStyle.Render(cli.Glyph(":cross:")), err.Error())
if qaVerbose && output != "" {
cli.Text(output)
}
if result.FixHint != "" {
cli.Hint("fix", result.FixHint)
}
}
if qaFailFast {
results = append(results, result)
break
}
} else {
result.Passed = true
if qaVerbose {
result.Output = output
}
passed++
if !qaJSON && !qaQuiet {
cli.Print(" %s %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.done.pass"))
}
}
results = append(results, result)
}
// Run coverage if requested
var coverageVal *float64
var branchVal *float64
if qaCoverage && !qaFailFast || (qaCoverage && failed == 0) {
cov, branch, err := runCoverage(ctx, cwd)
if err == nil {
coverageVal = &cov
branchVal = &branch
if !qaJSON && !qaQuiet {
cli.Print("\n%s %.1f%%\n", cli.DimStyle.Render("Statement Coverage:"), cov)
cli.Print("%s %.1f%%\n", cli.DimStyle.Render("Branch Coverage:"), branch)
}
if qaThreshold > 0 && cov < qaThreshold {
failed++
if !qaJSON && !qaQuiet {
cli.Print(" %s Statement coverage %.1f%% below threshold %.1f%%\n",
cli.ErrorStyle.Render(cli.Glyph(":cross:")), cov, qaThreshold)
}
}
if qaBranchThreshold > 0 && branch < qaBranchThreshold {
failed++
if !qaJSON && !qaQuiet {
cli.Print(" %s Branch coverage %.1f%% below threshold %.1f%%\n",
cli.ErrorStyle.Render(cli.Glyph(":cross:")), branch, qaBranchThreshold)
}
}
if failed > 0 && !qaJSON && !qaQuiet {
cli.Hint("fix", "Run 'core go cov --open' to see uncovered lines, then add tests.")
}
}
}
duration := time.Since(startTime).Round(time.Millisecond)
// JSON output
if qaJSON {
qaResult := QAResult{
Success: failed == 0,
Duration: duration.String(),
Checks: results,
Coverage: coverageVal,
BranchCoverage: branchVal,
}
if qaThreshold > 0 {
qaResult.Threshold = &qaThreshold
}
if qaBranchThreshold > 0 {
qaResult.BranchThreshold = &qaBranchThreshold
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(qaResult)
}
// Summary
if !qaQuiet {
cli.Blank()
if failed > 0 {
cli.Print("%s %s, %s (%s)\n",
cli.ErrorStyle.Render(cli.Glyph(":cross:")),
i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"),
i18n.T("i18n.count.check", failed)+" "+i18n.T("i18n.done.fail"),
duration)
} else {
cli.Print("%s %s (%s)\n",
cli.SuccessStyle.Render(cli.Glyph(":check:")),
i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"),
duration)
}
}
if failed > 0 {
return cli.Err("QA checks failed: %d passed, %d failed", passed, failed)
}
return nil
}
func determineChecks() []string {
// If --only is specified, use those
if qaOnly != "" {
return strings.Split(qaOnly, ",")
}
// Default checks
checks := []string{"fmt", "lint", "test", "fuzz", "docblock"}
// Add race if requested
if qaRace {
// Replace test with race (which includes test)
for i, c := range checks {
if c == "test" {
checks[i] = "race"
break
}
}
}
// Add bench if requested
if qaBench {
checks = append(checks, "bench")
}
// Remove skipped checks
if qaSkip != "" {
skipMap := make(map[string]bool)
for _, s := range strings.Split(qaSkip, ",") {
skipMap[strings.TrimSpace(s)] = true
}
filtered := make([]string, 0, len(checks))
for _, c := range checks {
if !skipMap[c] {
filtered = append(filtered, c)
}
}
checks = filtered
}
return checks
}
// QACheck represents a single QA check.
type QACheck struct {
Name string
Command string
Args []string
}
func buildChecks(names []string) []QACheck {
var checks []QACheck
for _, name := range names {
name = strings.TrimSpace(name)
check := buildCheck(name)
if check.Command != "" {
checks = append(checks, check)
}
}
return checks
}
func buildCheck(name string) QACheck {
switch name {
case "fmt", "format":
args := []string{"-l", "."}
if qaFix {
args = []string{"-w", "."}
}
return QACheck{Name: "format", Command: "gofmt", Args: args}
case "vet":
return QACheck{Name: "vet", Command: "go", Args: []string{"vet", "./..."}}
case "lint":
args := []string{"run"}
if qaFix {
args = append(args, "--fix")
}
if qaChanged && !qaAll {
args = append(args, "--new-from-rev=HEAD")
}
args = append(args, "./...")
return QACheck{Name: "lint", Command: "golangci-lint", Args: args}
case "test":
args := []string{"test"}
if qaShort {
args = append(args, "-short")
}
if qaVerbose {
args = append(args, "-v")
}
args = append(args, "./...")
return QACheck{Name: "test", Command: "go", Args: args}
case "race":
args := []string{"test", "-race"}
if qaShort {
args = append(args, "-short")
}
if qaVerbose {
args = append(args, "-v")
}
args = append(args, "./...")
return QACheck{Name: "race", Command: "go", Args: args}
case "bench":
args := []string{"test", "-bench=.", "-benchmem", "-run=^$"}
args = append(args, "./...")
return QACheck{Name: "bench", Command: "go", Args: args}
case "vuln":
return QACheck{Name: "vuln", Command: "govulncheck", Args: []string{"./..."}}
case "sec":
return QACheck{Name: "sec", Command: "gosec", Args: []string{"-quiet", "./..."}}
case "fuzz":
return QACheck{Name: "fuzz", Command: "_internal_"}
case "docblock":
// Special internal check - handled separately
return QACheck{Name: "docblock", Command: "_internal_"}
default:
return QACheck{}
}
}
// fixHintFor returns an actionable fix instruction for a given check failure.
func fixHintFor(checkName, output string) string {
switch checkName {
case "format", "fmt":
return "Run 'core go qa fmt --fix' to auto-format."
case "vet":
return "Fix the issues reported by go vet — typically genuine bugs."
case "lint":
return "Run 'core go qa lint --fix' for auto-fixable issues."
case "test":
if name := extractFailingTest(output); name != "" {
return fmt.Sprintf("Run 'go test -run %s -v ./...' to debug.", name)
}
return "Run 'go test -run <TestName> -v ./path/' to debug."
case "race":
return "Data race detected. Add mutex, channel, or atomic to synchronise shared state."
case "bench":
return "Benchmark regression. Run 'go test -bench=. -benchmem' to reproduce."
case "vuln":
return "Run 'govulncheck ./...' for details. Update affected deps with 'go get -u'."
case "sec":
return "Review gosec findings. Common fixes: validate inputs, parameterised queries."
case "fuzz":
return "Add a regression test for the crashing input in testdata/fuzz/<Target>/."
case "docblock":
return "Add doc comments to exported symbols: '// Name does X.' before each declaration."
default:
return ""
}
}
var failTestRe = regexp.MustCompile(`--- FAIL: (\w+)`)
// extractFailingTest parses the first failing test name from go test output.
func extractFailingTest(output string) string {
if m := failTestRe.FindStringSubmatch(output); len(m) > 1 {
return m[1]
}
return ""
}
func runCheckCapture(ctx context.Context, dir string, check QACheck) (string, error) {
// Handle internal checks
if check.Command == "_internal_" {
return runInternalCheck(check)
}
// Check if command exists
if _, err := exec.LookPath(check.Command); err != nil {
return "", cli.Err("%s: not installed", check.Command)
}
cmd := exec.CommandContext(ctx, check.Command, check.Args...)
cmd.Dir = dir
// For gofmt -l, capture output to check if files need formatting
if check.Name == "format" && len(check.Args) > 0 && check.Args[0] == "-l" {
output, err := cmd.Output()
if err != nil {
return string(output), err
}
if len(output) > 0 {
// Show files that need formatting
if !qaQuiet && !qaJSON {
cli.Text(string(output))
}
return string(output), cli.Err("files need formatting (use --fix)")
}
return "", nil
}
// For other commands, stream or capture based on quiet mode
if qaQuiet || qaJSON {
output, err := cmd.CombinedOutput()
return string(output), err
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return "", cmd.Run()
}
func runCoverage(ctx context.Context, dir string) (float64, float64, error) {
// Create temp file for coverage data
covFile, err := os.CreateTemp("", "coverage-*.out")
if err != nil {
return 0, 0, err
}
covPath := covFile.Name()
_ = covFile.Close()
defer os.Remove(covPath)
args := []string{"test", "-cover", "-covermode=atomic", "-coverprofile=" + covPath}
if qaShort {
args = append(args, "-short")
}
args = append(args, "./...")
cmd := exec.CommandContext(ctx, "go", args...)
cmd.Dir = dir
if !qaQuiet && !qaJSON {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
if err := cmd.Run(); err != nil {
return 0, 0, err
}
// Parse statement coverage
coverCmd := exec.CommandContext(ctx, "go", "tool", "cover", "-func="+covPath)
output, err := coverCmd.Output()
if err != nil {
return 0, 0, err
}
// Parse last line for total coverage
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
var statementPct float64
if len(lines) > 0 {
lastLine := lines[len(lines)-1]
fields := strings.Fields(lastLine)
if len(fields) >= 3 {
// Parse percentage (e.g., "45.6%")
pctStr := strings.TrimSuffix(fields[len(fields)-1], "%")
_, _ = fmt.Sscanf(pctStr, "%f", &statementPct)
}
}
// Parse branch coverage
branchPct, err := calculateBlockCoverage(covPath)
if err != nil {
return statementPct, 0, err
}
return statementPct, branchPct, nil
}
// runInternalCheck runs internal Go-based checks (not external commands).
func runInternalCheck(check QACheck) (string, error) {
switch check.Name {
case "fuzz":
// Short burst fuzz in QA (3s per target)
duration := 3 * time.Second
if qaTimeout > 0 && qaTimeout < 30*time.Second {
duration = 2 * time.Second
}
return "", runGoFuzz(duration, "", "", qaVerbose)
case "docblock":
result, err := qa.CheckDocblockCoverage([]string{"./..."})
if err != nil {
return "", err
}
result.Threshold = qaDocblockThreshold
result.Passed = result.Coverage >= qaDocblockThreshold
if !result.Passed {
var output strings.Builder
output.WriteString(fmt.Sprintf("Docblock coverage: %.1f%% (threshold: %.1f%%)\n",
result.Coverage, qaDocblockThreshold))
for _, m := range result.Missing {
output.WriteString(fmt.Sprintf("%s:%d\n", m.File, m.Line))
}
return output.String(), cli.Err("docblock coverage %.1f%% below threshold %.1f%%",
result.Coverage, qaDocblockThreshold)
}
return fmt.Sprintf("Docblock coverage: %.1f%%", result.Coverage), nil
default:
return "", cli.Err("unknown internal check: %s", check.Name)
}
}

236
cmd/gocmd/cmd_tools.go Normal file
View file

@ -0,0 +1,236 @@
package gocmd
import (
"errors"
"os"
"os/exec"
"path/filepath"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
var (
installVerbose bool
installNoCgo bool
)
func addGoInstallCommand(parent *cli.Command) {
installCmd := &cli.Command{
Use: "install [path]",
Short: "Install Go binary",
Long: "Install Go binary to $GOPATH/bin",
RunE: func(cmd *cli.Command, args []string) error {
// Get install path from args or default to current dir
installPath := "./..."
if len(args) > 0 {
installPath = args[0]
}
// Detect if we're in a module with cmd/ subdirectories or a root main.go
if installPath == "./..." {
if _, err := os.Stat("core.go"); err == nil {
installPath = "."
} else if entries, err := os.ReadDir("cmd"); err == nil && len(entries) > 0 {
installPath = "./cmd/..."
} else if _, err := os.Stat("main.go"); err == nil {
installPath = "."
}
}
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.Progress("install"))
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("path")), installPath)
if installNoCgo {
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("cgo")), "disabled")
}
cmdArgs := []string{"install"}
if installVerbose {
cmdArgs = append(cmdArgs, "-v")
}
cmdArgs = append(cmdArgs, installPath)
execCmd := exec.Command("go", cmdArgs...)
if installNoCgo {
execCmd.Env = append(os.Environ(), "CGO_ENABLED=0")
}
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
if err := execCmd.Run(); err != nil {
cli.Print("\n%s\n", errorStyle.Render(i18n.T("i18n.fail.install", "binary")))
return err
}
// Show where it was installed
gopath := os.Getenv("GOPATH")
if gopath == "" {
home, _ := os.UserHomeDir()
gopath = filepath.Join(home, "go")
}
binDir := filepath.Join(gopath, "bin")
cli.Print("\n%s %s\n", successStyle.Render(i18n.T("i18n.done.install")), binDir)
return nil
},
}
installCmd.Flags().BoolVarP(&installVerbose, "verbose", "v", false, "Verbose output")
installCmd.Flags().BoolVar(&installNoCgo, "no-cgo", false, "Disable CGO")
parent.AddCommand(installCmd)
}
func addGoModCommand(parent *cli.Command) {
modCmd := &cli.Command{
Use: "mod",
Short: "Module management",
Long: "Go module management commands",
}
// tidy
tidyCmd := &cli.Command{
Use: "tidy",
Short: "Run go mod tidy",
RunE: func(cmd *cli.Command, args []string) error {
execCmd := exec.Command("go", "mod", "tidy")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
// download
downloadCmd := &cli.Command{
Use: "download",
Short: "Download module dependencies",
RunE: func(cmd *cli.Command, args []string) error {
execCmd := exec.Command("go", "mod", "download")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
// verify
verifyCmd := &cli.Command{
Use: "verify",
Short: "Verify module checksums",
RunE: func(cmd *cli.Command, args []string) error {
execCmd := exec.Command("go", "mod", "verify")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
// graph
graphCmd := &cli.Command{
Use: "graph",
Short: "Print module dependency graph",
RunE: func(cmd *cli.Command, args []string) error {
execCmd := exec.Command("go", "mod", "graph")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
modCmd.AddCommand(tidyCmd)
modCmd.AddCommand(downloadCmd)
modCmd.AddCommand(verifyCmd)
modCmd.AddCommand(graphCmd)
parent.AddCommand(modCmd)
}
func addGoWorkCommand(parent *cli.Command) {
workCmd := &cli.Command{
Use: "work",
Short: "Workspace management",
Long: "Go workspace management commands",
}
// sync
syncCmd := &cli.Command{
Use: "sync",
Short: "Sync workspace modules",
RunE: func(cmd *cli.Command, args []string) error {
execCmd := exec.Command("go", "work", "sync")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
// init
initCmd := &cli.Command{
Use: "init",
Short: "Initialise a new workspace",
RunE: func(cmd *cli.Command, args []string) error {
execCmd := exec.Command("go", "work", "init")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
if err := execCmd.Run(); err != nil {
return err
}
// Auto-add current module if go.mod exists
if _, err := os.Stat("go.mod"); err == nil {
execCmd = exec.Command("go", "work", "use", ".")
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
}
return nil
},
}
// use
useCmd := &cli.Command{
Use: "use [modules...]",
Short: "Add modules to workspace",
RunE: func(cmd *cli.Command, args []string) error {
if len(args) == 0 {
// Auto-detect modules
modules := findGoModules(".")
if len(modules) == 0 {
return errors.New("no Go modules found")
}
for _, mod := range modules {
execCmd := exec.Command("go", "work", "use", mod)
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
if err := execCmd.Run(); err != nil {
return err
}
cli.Print("%s %s\n", successStyle.Render(i18n.T("i18n.done.add")), mod)
}
return nil
}
cmdArgs := append([]string{"work", "use"}, args...)
execCmd := exec.Command("go", cmdArgs...)
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
},
}
workCmd.AddCommand(syncCmd)
workCmd.AddCommand(initCmd)
workCmd.AddCommand(useCmd)
parent.AddCommand(workCmd)
}
func findGoModules(root string) []string {
var modules []string
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.Name() == "go.mod" && path != "go.mod" {
modules = append(modules, filepath.Dir(path))
}
return nil
})
return modules
}

229
cmd/gocmd/coverage_test.go Normal file
View file

@ -0,0 +1,229 @@
package gocmd
import (
"os"
"testing"
"forge.lthn.ai/core/go/pkg/cli"
"github.com/stretchr/testify/assert"
)
func TestCalculateBlockCoverage(t *testing.T) {
// Create a dummy coverage profile
content := `mode: set
forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5 1
forge.lthn.ai/core/go/pkg/foo.go:5.6,7.8 2 0
forge.lthn.ai/core/go/pkg/bar.go:10.1,12.20 10 5
`
tmpfile, err := os.CreateTemp("", "test-coverage-*.out")
assert.NoError(t, err)
defer os.Remove(tmpfile.Name())
_, err = tmpfile.Write([]byte(content))
assert.NoError(t, err)
err = tmpfile.Close()
assert.NoError(t, err)
// Test calculation
// 3 blocks total, 2 covered (count > 0)
// Expect (2/3) * 100 = 66.666...
pct, err := calculateBlockCoverage(tmpfile.Name())
assert.NoError(t, err)
assert.InDelta(t, 66.67, pct, 0.01)
// Test empty file (only header)
contentEmpty := "mode: atomic\n"
tmpfileEmpty, _ := os.CreateTemp("", "test-coverage-empty-*.out")
defer os.Remove(tmpfileEmpty.Name())
tmpfileEmpty.Write([]byte(contentEmpty))
tmpfileEmpty.Close()
pct, err = calculateBlockCoverage(tmpfileEmpty.Name())
assert.NoError(t, err)
assert.Equal(t, 0.0, pct)
// Test non-existent file
pct, err = calculateBlockCoverage("non-existent-file")
assert.Error(t, err)
assert.Equal(t, 0.0, pct)
// Test malformed file
contentMalformed := `mode: set
forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5
forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5 notanumber
`
tmpfileMalformed, _ := os.CreateTemp("", "test-coverage-malformed-*.out")
defer os.Remove(tmpfileMalformed.Name())
tmpfileMalformed.Write([]byte(contentMalformed))
tmpfileMalformed.Close()
pct, err = calculateBlockCoverage(tmpfileMalformed.Name())
assert.NoError(t, err)
assert.Equal(t, 0.0, pct)
// Test malformed file - missing fields
contentMalformed2 := `mode: set
forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5
`
tmpfileMalformed2, _ := os.CreateTemp("", "test-coverage-malformed2-*.out")
defer os.Remove(tmpfileMalformed2.Name())
tmpfileMalformed2.Write([]byte(contentMalformed2))
tmpfileMalformed2.Close()
pct, err = calculateBlockCoverage(tmpfileMalformed2.Name())
assert.NoError(t, err)
assert.Equal(t, 0.0, pct)
// Test completely empty file
tmpfileEmpty2, _ := os.CreateTemp("", "test-coverage-empty2-*.out")
defer os.Remove(tmpfileEmpty2.Name())
tmpfileEmpty2.Close()
pct, err = calculateBlockCoverage(tmpfileEmpty2.Name())
assert.NoError(t, err)
assert.Equal(t, 0.0, pct)
}
func TestParseOverallCoverage(t *testing.T) {
output := `ok forge.lthn.ai/core/go/pkg/foo 0.100s coverage: 50.0% of statements
ok forge.lthn.ai/core/go/pkg/bar 0.200s coverage: 100.0% of statements
`
pct := parseOverallCoverage(output)
assert.Equal(t, 75.0, pct)
outputNoCov := "ok forge.lthn.ai/core/go/pkg/foo 0.100s"
pct = parseOverallCoverage(outputNoCov)
assert.Equal(t, 0.0, pct)
}
func TestFormatCoverage(t *testing.T) {
assert.Contains(t, formatCoverage(85.0), "85.0%")
assert.Contains(t, formatCoverage(65.0), "65.0%")
assert.Contains(t, formatCoverage(25.0), "25.0%")
}
func TestAddGoCovCommand(t *testing.T) {
cmd := &cli.Command{Use: "test"}
addGoCovCommand(cmd)
assert.True(t, cmd.HasSubCommands())
sub := cmd.Commands()[0]
assert.Equal(t, "cov", sub.Name())
}
func TestAddGoQACommand(t *testing.T) {
cmd := &cli.Command{Use: "test"}
addGoQACommand(cmd)
assert.True(t, cmd.HasSubCommands())
sub := cmd.Commands()[0]
assert.Equal(t, "qa", sub.Name())
}
func TestDetermineChecks(t *testing.T) {
// Default checks
qaOnly = ""
qaSkip = ""
qaRace = false
qaBench = false
checks := determineChecks()
assert.Contains(t, checks, "fmt")
assert.Contains(t, checks, "test")
// Only
qaOnly = "fmt,lint"
checks = determineChecks()
assert.Equal(t, []string{"fmt", "lint"}, checks)
// Skip
qaOnly = ""
qaSkip = "fmt,lint"
checks = determineChecks()
assert.NotContains(t, checks, "fmt")
assert.NotContains(t, checks, "lint")
assert.Contains(t, checks, "test")
// Race
qaSkip = ""
qaRace = true
checks = determineChecks()
assert.Contains(t, checks, "race")
assert.NotContains(t, checks, "test")
// Reset
qaRace = false
}
func TestBuildCheck(t *testing.T) {
qaFix = false
c := buildCheck("fmt")
assert.Equal(t, "format", c.Name)
assert.Equal(t, []string{"-l", "."}, c.Args)
qaFix = true
c = buildCheck("fmt")
assert.Equal(t, []string{"-w", "."}, c.Args)
c = buildCheck("vet")
assert.Equal(t, "vet", c.Name)
c = buildCheck("lint")
assert.Equal(t, "lint", c.Name)
c = buildCheck("test")
assert.Equal(t, "test", c.Name)
c = buildCheck("race")
assert.Equal(t, "race", c.Name)
c = buildCheck("bench")
assert.Equal(t, "bench", c.Name)
c = buildCheck("vuln")
assert.Equal(t, "vuln", c.Name)
c = buildCheck("sec")
assert.Equal(t, "sec", c.Name)
c = buildCheck("fuzz")
assert.Equal(t, "fuzz", c.Name)
c = buildCheck("docblock")
assert.Equal(t, "docblock", c.Name)
c = buildCheck("unknown")
assert.Equal(t, "", c.Name)
}
func TestBuildChecks(t *testing.T) {
checks := buildChecks([]string{"fmt", "vet", "unknown"})
assert.Equal(t, 2, len(checks))
assert.Equal(t, "format", checks[0].Name)
assert.Equal(t, "vet", checks[1].Name)
}
func TestFixHintFor(t *testing.T) {
assert.Contains(t, fixHintFor("format", ""), "core go qa fmt --fix")
assert.Contains(t, fixHintFor("vet", ""), "go vet")
assert.Contains(t, fixHintFor("lint", ""), "core go qa lint --fix")
assert.Contains(t, fixHintFor("test", "--- FAIL: TestFoo"), "TestFoo")
assert.Contains(t, fixHintFor("race", ""), "Data race")
assert.Contains(t, fixHintFor("bench", ""), "Benchmark regression")
assert.Contains(t, fixHintFor("vuln", ""), "govulncheck")
assert.Contains(t, fixHintFor("sec", ""), "gosec")
assert.Contains(t, fixHintFor("fuzz", ""), "crashing input")
assert.Contains(t, fixHintFor("docblock", ""), "doc comments")
assert.Equal(t, "", fixHintFor("unknown", ""))
}
func TestRunGoQA_NoGoMod(t *testing.T) {
// runGoQA should fail if go.mod is not present in CWD
// We run it in a temp dir without go.mod
tmpDir, _ := os.MkdirTemp("", "test-qa-*")
defer os.RemoveAll(tmpDir)
cwd, _ := os.Getwd()
os.Chdir(tmpDir)
defer os.Chdir(cwd)
cmd := &cli.Command{Use: "qa"}
err := runGoQA(cmd, []string{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "no go.mod found")
}

View file

@ -30,7 +30,7 @@ The `cli` package is a comprehensive application runtime and UI framework design
#### Command Building
- `func NewCommand(use, short, long string, run func(*Command, []string) error) *Command`: Factory for standard commands.
- `func NewGroup(use, short, long string) *Command`: Factory for parent commands (no run logic).
- `func RegisterCommands(fn CommandRegistration)`: Registers a callback to add commands to the root at runtime.
- `func WithCommands(name string, register func(root *Command)) framework.Option`: Registers a command group as a framework service.
#### Output & Styling
- `type AnsiStyle`: Fluent builder for text styling (Bold, Dim, Foreground, Background).
@ -67,7 +67,7 @@ The `cli` package is a comprehensive application runtime and UI framework design
### 5. Test Coverage Notes
- **Interactive Prompts**: Tests must mock `stdin` to verify `Confirm`, `Prompt`, and `Select` behavior without hanging.
- **Command Registration**: Verify `RegisterCommands` works both before and after `Init` is called.
- **Command Registration**: Verify `WithCommands` services receive the root command during `OnStartup`.
- **Daemon Lifecycle**: Tests needed for `PIDFile` locking and `HealthServer` endpoints (/health, /ready).
- **Layout Rendering**: Snapshot testing is recommended for `Layout` and `Table` rendering to ensure ANSI codes and alignment are correct.

View file

@ -1,5 +1,7 @@
# MCP Integration Implementation Plan
> **Status:** Completed. MCP command now lives in `go-ai/cmd/mcpcmd/`. Code examples below use the old `init()` + `RegisterCommands()` pattern — the current approach uses `cli.WithCommands()` (see cli-meta-package-design.md).
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add `core mcp serve` command with RAG and metrics tools, then configure the agentic-flows plugin to use it.

View file

@ -0,0 +1,128 @@
# CLI Meta-Package Restructure — Design
**Goal:** Transform `core/cli` from a 35K LOC monolith into a thin assembly repo that ships variant binaries. Domain repos own their commands. `go/pkg/cli` is the only import any domain package needs for CLI concerns.
**Architecture:** Commands register as framework services via `cli.WithCommands()`, passed to `cli.Main()`. Command code lives in the domain repos that own the business logic. The cli repo is a thin `main.go` that wires them together.
**Tech Stack:** go/pkg/cli (wraps cobra + charmbracelet), Core framework lifecycle, Taskfile
---
## 1. CLI SDK — The Single Import
`forge.lthn.ai/core/go/pkg/cli` is the **only** import domain packages use for CLI concerns. It wraps cobra, charmbracelet, and stdlib behind a stable API. If the underlying libraries change, only `go/pkg/cli` is touched — every domain repo is insulated.
### Already done
- **Cobra:** `Command` type alias, `NewCommand()`, `NewGroup()`, `NewRun()`, flag helpers (`StringFlag`, `BoolFlag`, `IntFlag`, `StringSliceFlag`), arg validators
- **Output:** `Success()`, `Error()`, `Warn()`, `Info()`, `Table`, `Section()`, `Label()`, `Task()`, `Hint()`
- **Prompts:** `Confirm()`, `Question()`, `Choose()`, `ChooseMulti()` with grammar-based action variants
- **Styles:** 17 pre-built styles, `AnsiStyle` builder, Tailwind colour constants (47 hex values)
- **Glyphs:** `:check:`, `:cross:`, `:warn:` etc. with Unicode/Emoji/ASCII themes
- **Layout:** HLCRF composite renderer (Header/Left/Content/Right/Footer)
- **Errors:** `Wrap()`, `WrapVerb()`, `ExitError`, `Is()`, `As()`
- **Logging:** `LogDebug()`, `LogInfo()`, `LogWarn()`, `LogError()`, `LogSecurity()`
- **TUI primitives:** `Spinner`, `ProgressBar`, `InteractiveList`, `TextInput`, `Viewport`, `RunTUI`
- **Command registration:** `WithCommands(name, fn)` — registers commands as framework services
### Stubbed for later (interface exists, returns simple fallback)
- `Form(fields []FormField) (map[string]string, error)` — multi-field form (backed by huh later)
- `FilePicker(opts ...FilePickerOption) (string, error)` — file browser
- `Tabs(items []TabItem) error` — tabbed content panes
### Rule
Domain packages import `forge.lthn.ai/core/go/pkg/cli` and **nothing else** for CLI concerns. No `cobra`, no `lipgloss`, no `bubbletea`.
---
## 2. Command Registration — Framework Lifecycle
Commands register through the Core framework's service lifecycle, not through global state or `init()` functions.
### The contract
Each domain repo exports an `Add*Commands(root *cli.Command)` function. The CLI binary wires it in via `cli.WithCommands()`:
```go
// go-ai/cmd/daemon/cmd.go
package daemon
import "forge.lthn.ai/core/go/pkg/cli"
// AddDaemonCommand adds the 'daemon' command group to the root.
func AddDaemonCommand(root *cli.Command) {
daemonCmd := cli.NewGroup("daemon", "Manage the core daemon", "")
root.AddCommand(daemonCmd)
// subcommands...
}
```
No `init()`. No blank imports. No `cli.RegisterCommands()`.
### How it works
`cli.WithCommands(name, fn)` wraps the registration function as a framework service implementing `Startable`. During `Core.ServiceStartup()`, the service's `OnStartup()` casts `Core.App` to `*cobra.Command` and calls the registration function. Core services (i18n, log, workspace) start first since they're registered before command services.
```go
// cli/main.go
func main() {
cli.Main(
cli.WithCommands("config", config.AddConfigCommands),
cli.WithCommands("doctor", doctor.AddDoctorCommands),
// ...
)
}
```
### Migration status (completed)
| Source | Destination | Status |
|--------|-------------|--------|
| `cmd/dev, setup, qa, docs, gitcmd, monitor` | `go-devops/cmd/` | Done |
| `cmd/lab` | `go-ai/cmd/` | Done |
| `cmd/workspace` | `go-agentic/cmd/` | Done |
| `cmd/go` | `core/go/cmd/gocmd` | Done |
| `cmd/vanity-import, community` | `go-devops/cmd/` | Done |
| `cmd/updater` | `go-update` | Done (own repo) |
| `cmd/daemon, mcpcmd, security` | `go-ai/cmd/` | Done |
| `cmd/crypt` | `go-crypt/cmd/` | Done |
| `cmd/rag` | `go-rag/cmd/` | Done |
| `cmd/unifi` | `go-netops/cmd/` | Done |
| `cmd/api` | `go-api/cmd/` | Done |
| `cmd/collect, forge, gitea` | `go-scm/cmd/` | Done |
| `cmd/deploy, prod, vm` | `go-devops/cmd/` | Done |
### Stays in cli/ (meta/framework commands)
`config`, `doctor`, `help`, `module`, `pkgcmd`, `plugin`, `session`
---
## 3. Variant Binaries (future)
The cli/ repo can produce variant binaries by creating multiple `main.go` files that wire different sets of commands.
```
cli/
├── main.go # Current — meta commands only
├── cmd/core-full/main.go # Full CLI — all ecosystem commands
├── cmd/core-ci/main.go # CI agent dispatch + SCM
├── cmd/core-mlx/main.go # ML inference subprocess
└── cmd/core-ops/main.go # DevOps + infra management
```
Each variant calls `cli.Main()` with its specific `cli.WithCommands()` set. No blank imports needed.
### Why variants matter
- `core-mlx` ships to the homelab as a ~10MB binary, not 50MB with devops/forge/netops
- `core-ci` deploys to agent machines without ML or CGO dependencies
- Adding a new variant = one new `main.go` with the right `WithCommands` calls
---
## 4. Current State
cli/ has 7 meta packages, one `main.go`, and zero business logic. Everything else lives in the domain repos that own it. Total cli/ LOC is ~2K.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,286 @@
# go-forge Design Document
## Overview
**go-forge** is a full-coverage Go client for the Forgejo API (450 endpoints, 284 paths, 229 types). It uses a generic `Resource[T, C, U]` pattern for CRUD operations (91% of endpoints) and hand-written methods for 39 unique action endpoints. Types are generated from Forgejo's `swagger.v1.json` spec.
**Module path:** `forge.lthn.ai/core/go-forge`
**Origin:** Extracted from `go-scm/forge/` (45 methods covering 10% of API), expanded to full coverage.
## Architecture
```
forge.lthn.ai/core/go-forge
├── client.go # HTTP client: auth, headers, rate limiting, context.Context
├── pagination.go # Generic paginated request helper
├── resource.go # Resource[T, C, U] generic CRUD (List/Get/Create/Update/Delete)
├── errors.go # Typed error handling (APIError, NotFound, Forbidden, etc.)
├── forge.go # Top-level Forge client aggregating all services
├── types/ # Generated from swagger.v1.json
│ ├── generate.go # //go:generate directive
│ ├── repo.go # Repository, CreateRepoOption, EditRepoOption
│ ├── issue.go # Issue, CreateIssueOption, EditIssueOption
│ ├── pr.go # PullRequest, CreatePullRequestOption
│ ├── user.go # User, CreateUserOption
│ ├── org.go # Organisation, CreateOrgOption
│ ├── team.go # Team, CreateTeamOption
│ ├── label.go # Label, CreateLabelOption
│ ├── release.go # Release, CreateReleaseOption
│ ├── branch.go # Branch, BranchProtection
│ ├── milestone.go # Milestone, CreateMilestoneOption
│ ├── hook.go # Hook, CreateHookOption
│ ├── key.go # DeployKey, PublicKey, GPGKey
│ ├── notification.go # NotificationThread, NotificationSubject
│ ├── package.go # Package, PackageFile
│ ├── action.go # ActionRunner, ActionSecret, ActionVariable
│ ├── commit.go # Commit, CommitStatus, CombinedStatus
│ ├── content.go # ContentsResponse, FileOptions
│ ├── wiki.go # WikiPage, WikiPageMetaData
│ ├── review.go # PullReview, PullReviewComment
│ ├── reaction.go # Reaction
│ ├── topic.go # TopicResponse
│ ├── misc.go # Markdown, License, GitignoreTemplate, NodeInfo
│ ├── admin.go # Cron, QuotaGroup, QuotaRule
│ ├── activity.go # Activity, Feed
│ └── common.go # Shared types: Permission, ExternalTracker, etc.
├── repos.go # RepoService: CRUD + fork, mirror, transfer, template
├── issues.go # IssueService: CRUD + pin, deadline, reactions, stopwatch
├── pulls.go # PullService: CRUD + merge, update, reviews, dismiss
├── orgs.go # OrgService: CRUD + members, avatar, block, hooks
├── users.go # UserService: CRUD + keys, followers, starred, settings
├── teams.go # TeamService: CRUD + members, repos
├── admin.go # AdminService: users, orgs, cron, runners, quota, unadopted
├── branches.go # BranchService: CRUD + protection rules
├── releases.go # ReleaseService: CRUD + assets
├── labels.go # LabelService: repo + org + issue labels
├── webhooks.go # WebhookService: CRUD + test hook
├── notifications.go # NotificationService: list, mark read
├── packages.go # PackageService: list, get, delete
├── actions.go # ActionsService: runners, secrets, variables, workflow dispatch
├── contents.go # ContentService: file read/write/delete via API
├── wiki.go # WikiService: pages
├── commits.go # CommitService: status, notes, diff
├── misc.go # MiscService: markdown, licenses, gitignore, nodeinfo
├── config.go # URL/token resolution: env → config file → flags
├── cmd/forgegen/ # Code generator: swagger.v1.json → types/*.go
│ ├── main.go
│ ├── parser.go # Parse OpenAPI 2.0 definitions
│ ├── generator.go # Render Go source files
│ └── templates/ # Go text/template files for codegen
└── testdata/
└── swagger.v1.json # Pinned spec for testing + generation
```
## Key Design Decisions
### 1. Generic Resource[T, C, U]
Three type parameters: T (resource type), C (create options), U (update options).
```go
type Resource[T any, C any, U any] struct {
client *Client
path string // e.g. "/api/v1/repos/{owner}/{repo}/issues"
}
func (r *Resource[T, C, U]) List(ctx context.Context, params Params, opts ListOptions) ([]T, error)
func (r *Resource[T, C, U]) Get(ctx context.Context, params Params, id string) (*T, error)
func (r *Resource[T, C, U]) Create(ctx context.Context, params Params, body *C) (*T, error)
func (r *Resource[T, C, U]) Update(ctx context.Context, params Params, id string, body *U) (*T, error)
func (r *Resource[T, C, U]) Delete(ctx context.Context, params Params, id string) error
```
`Params` is `map[string]string` resolving path variables: `{"owner": "core", "repo": "go-forge"}`.
This covers 411 of 450 endpoints (91%).
### 2. Service Structs Embed Resource
```go
type IssueService struct {
Resource[types.Issue, types.CreateIssueOption, types.EditIssueOption]
}
// CRUD comes free. Actions are hand-written:
func (s *IssueService) Pin(ctx context.Context, owner, repo string, index int64) error
func (s *IssueService) SetDeadline(ctx context.Context, owner, repo string, index int64, deadline *time.Time) error
```
### 3. Top-Level Forge Client
```go
type Forge struct {
client *Client
Repos *RepoService
Issues *IssueService
Pulls *PullService
Orgs *OrgService
Users *UserService
Teams *TeamService
Admin *AdminService
Branches *BranchService
Releases *ReleaseService
Labels *LabelService
Webhooks *WebhookService
Notifications *NotificationService
Packages *PackageService
Actions *ActionsService
Contents *ContentService
Wiki *WikiService
Commits *CommitService
Misc *MiscService
}
func NewForge(url, token string, opts ...Option) *Forge
```
### 4. Codegen from swagger.v1.json
The `cmd/forgegen/` tool reads the OpenAPI 2.0 spec and generates:
- Go struct definitions with JSON tags and doc comments
- Enum constants
- Type mapping (OpenAPI → Go)
229 type definitions → ~25 grouped Go files in `types/`.
Type mapping rules:
| OpenAPI | Go |
|---------|-----|
| `string` | `string` |
| `string` + `date-time` | `time.Time` |
| `integer` + `int64` | `int64` |
| `integer` | `int` |
| `boolean` | `bool` |
| `array` of T | `[]T` |
| `$ref` | `*T` (pointer) |
| nullable | pointer type |
| `binary` | `[]byte` |
### 5. HTTP Client
```go
type Client struct {
baseURL string
token string
httpClient *http.Client
userAgent string
}
func New(url, token string, opts ...Option) *Client
func (c *Client) Get(ctx context.Context, path string, out any) error
func (c *Client) Post(ctx context.Context, path string, body, out any) error
func (c *Client) Patch(ctx context.Context, path string, body, out any) error
func (c *Client) Put(ctx context.Context, path string, body, out any) error
func (c *Client) Delete(ctx context.Context, path string) error
```
Options: `WithHTTPClient`, `WithUserAgent`, `WithRateLimit`, `WithLogger`.
### 6. Pagination
Forgejo uses `page` + `limit` query params and `X-Total-Count` response header.
```go
type ListOptions struct {
Page int
Limit int // default 50, max configurable
}
type PagedResult[T any] struct {
Items []T
TotalCount int
Page int
HasMore bool
}
// ListAll fetches all pages automatically.
func (r *Resource[T, C, U]) ListAll(ctx context.Context, params Params) ([]T, error)
```
### 7. Error Handling
```go
type APIError struct {
StatusCode int
Message string
URL string
}
func IsNotFound(err error) bool
func IsForbidden(err error) bool
func IsConflict(err error) bool
```
### 8. Config Resolution (from go-scm/forge)
Priority: flags → environment → config file.
```go
func NewFromConfig(flagURL, flagToken string) (*Forge, error)
func ResolveConfig(flagURL, flagToken string) (url, token string, err error)
func SaveConfig(url, token string) error
```
Env vars: `FORGE_URL`, `FORGE_TOKEN`. Config file: `~/.config/forge/config.json`.
## API Coverage
| Category | Endpoints | CRUD | Actions |
|----------|-----------|------|---------|
| Repository | 175 | 165 | 10 (fork, mirror, transfer, template, avatar, diffpatch) |
| User | 74 | 70 | 4 (avatar, GPG verify) |
| Issue | 67 | 57 | 10 (pin, deadline, reactions, stopwatch, labels) |
| Organisation | 63 | 59 | 4 (avatar, block/unblock) |
| Admin | 39 | 35 | 4 (cron run, rename, adopt, quota set) |
| Miscellaneous | 12 | 7 | 5 (markdown render, markup, nodeinfo) |
| Notification | 7 | 7 | 0 |
| ActivityPub | 6 | 3 | 3 (inbox POST) |
| Package | 4 | 4 | 0 |
| Settings | 4 | 4 | 0 |
| **Total** | **450** | **411** | **39** |
## Integration Points
### go-api
Services implement `DescribableGroup` from go-api Phase 3, enabling:
- REST endpoint generation via ToolBridge
- Auto-generated OpenAPI spec
- Multi-language SDK codegen
### go-scm
go-scm/forge/ becomes a thin adapter importing go-forge types. Existing go-scm users are unaffected — the multi-provider abstraction layer stays.
### go-ai/mcp
The MCP subsystem can register go-forge operations as MCP tools, giving AI agents full Forgejo API access.
## 39 Unique Action Methods
These require hand-written implementation:
**Repository:** migrate, fork, generate (template), transfer, accept/reject transfer, mirror sync, push mirror sync, avatar, diffpatch, contents (multi-file modify)
**Pull Requests:** merge, update (rebase), submit review, dismiss/undismiss review
**Issues:** pin, set deadline, add reaction, start/stop stopwatch, add issue labels
**Comments:** add reaction
**Admin:** run cron task, adopt unadopted, rename user, set quota groups
**Misc:** render markdown, render raw markdown, render markup, GPG key verify
**ActivityPub:** inbox POST (actor, repo, user)
**Actions:** dispatch workflow
**Git:** set note on commit, test webhook

File diff suppressed because it is too large Load diff

22
go.mod
View file

@ -2,12 +2,13 @@ module forge.lthn.ai/core/go
go 1.25.5
require forge.lthn.ai/core/go-crypt v0.0.0
require forge.lthn.ai/core/go-crypt v0.0.0-20260221190941-9585da8e6649
require (
github.com/Snider/Borg v0.2.0
github.com/aws/aws-sdk-go-v2 v1.41.1
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/gorilla/websocket v1.5.3
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
@ -18,7 +19,7 @@ require (
google.golang.org/grpc v1.79.1
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.45.0
modernc.org/sqlite v1.46.1
)
require (
@ -32,24 +33,39 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/net v0.50.0 // indirect
@ -61,5 +77,3 @@ require (
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
replace forge.lthn.ai/core/go-crypt => ../go-crypt

40
go.sum
View file

@ -1,3 +1,5 @@
forge.lthn.ai/core/go-crypt v0.0.0-20260221190941-9585da8e6649 h1:Rs3bfSU8u1wkzYeL21asL7IcJIBVwOhtRidcEVj/PkA=
forge.lthn.ai/core/go-crypt v0.0.0-20260221190941-9585da8e6649/go.mod h1:RS+sz5lChrbc1AEmzzOULsTiMv3bwcwVtwbZi+c/Yjk=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/Snider/Borg v0.2.0 h1:iCyDhY4WTXi39+FexRwXbn2YpZ2U9FUXVXDZk9xRCXQ=
@ -24,8 +26,22 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIM
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@ -33,6 +49,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
@ -64,8 +82,20 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
@ -74,6 +104,9 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@ -94,6 +127,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
@ -118,6 +153,7 @@ golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
@ -162,8 +198,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs=
modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View file

@ -48,9 +48,16 @@ func SemVer() string {
}
// Main initialises and runs the CLI application.
// This is the main entry point for the CLI.
// Pass command services via WithCommands to register CLI commands
// through the Core framework lifecycle.
//
// cli.Main(
// cli.WithCommands("config", config.AddConfigCommands),
// cli.WithCommands("doctor", doctor.AddDoctorCommands),
// )
//
// Exits with code 1 on error or panic.
func Main() {
func Main(commands ...framework.Option) {
// Recovery from panics
defer func() {
if r := recover(); r != nil {
@ -60,17 +67,21 @@ func Main() {
}
}()
// Core services load first, then command services
services := []framework.Option{
framework.WithName("i18n", NewI18nService(I18nOptions{})),
framework.WithName("log", NewLogService(log.Options{
Level: log.LevelInfo,
})),
framework.WithName("workspace", workspace.New),
}
services = append(services, commands...)
// Initialise CLI runtime with services
if err := Init(Options{
AppName: AppName,
Version: SemVer(),
Services: []framework.Option{
framework.WithName("i18n", NewI18nService(I18nOptions{})),
framework.WithName("log", NewLogService(log.Options{
Level: log.LevelInfo,
})),
framework.WithName("workspace", workspace.New),
},
AppName: AppName,
Version: SemVer(),
Services: services,
}); err != nil {
Error(err.Error())
os.Exit(1)

View file

@ -2,49 +2,34 @@
package cli
import (
"sync"
"context"
"forge.lthn.ai/core/go/pkg/framework"
"github.com/spf13/cobra"
)
// CommandRegistration is a function that adds commands to the root.
type CommandRegistration func(root *cobra.Command)
var (
registeredCommands []CommandRegistration
registeredCommandsMu sync.Mutex
commandsAttached bool
)
// RegisterCommands registers a function that adds commands to the CLI.
// Call this in your package's init() to register commands.
// WithCommands creates a framework Option that registers a command group.
// The register function receives the root command during service startup,
// allowing commands to participate in the Core lifecycle.
//
// func init() {
// cli.RegisterCommands(AddCommands)
// }
//
// func AddCommands(root *cobra.Command) {
// root.AddCommand(myCmd)
// }
func RegisterCommands(fn CommandRegistration) {
registeredCommandsMu.Lock()
defer registeredCommandsMu.Unlock()
registeredCommands = append(registeredCommands, fn)
// If commands already attached (CLI already running), attach immediately
if commandsAttached && instance != nil && instance.root != nil {
fn(instance.root)
}
// cli.Main(
// cli.WithCommands("config", config.AddConfigCommands),
// cli.WithCommands("doctor", doctor.AddDoctorCommands),
// )
func WithCommands(name string, register func(root *Command)) framework.Option {
return framework.WithName("cmd."+name, func(c *framework.Core) (any, error) {
return &commandService{core: c, register: register}, nil
})
}
// attachRegisteredCommands calls all registered command functions.
// Called by Init() after creating the root command.
func attachRegisteredCommands(root *cobra.Command) {
registeredCommandsMu.Lock()
defer registeredCommandsMu.Unlock()
for _, fn := range registeredCommands {
fn(root)
}
commandsAttached = true
type commandService struct {
core *framework.Core
register func(root *Command)
}
func (s *commandService) OnStartup(_ context.Context) error {
if root, ok := s.core.App.(*cobra.Command); ok {
s.register(root)
}
return nil
}

144
pkg/cli/list.go Normal file
View file

@ -0,0 +1,144 @@
package cli
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"golang.org/x/term"
)
// listModel is the internal bubbletea model for interactive list selection.
type listModel struct {
items []string
cursor int
title string
selected bool
quitted bool
}
func newListModel(items []string, title string) *listModel {
return &listModel{
items: items,
title: title,
}
}
func (m *listModel) moveDown() {
m.cursor++
if m.cursor >= len(m.items) {
m.cursor = 0
}
}
func (m *listModel) moveUp() {
m.cursor--
if m.cursor < 0 {
m.cursor = len(m.items) - 1
}
}
func (m *listModel) Init() tea.Cmd {
return nil
}
func (m *listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyUp, tea.KeyShiftTab:
m.moveUp()
case tea.KeyDown, tea.KeyTab:
m.moveDown()
case tea.KeyEnter:
m.selected = true
return m, tea.Quit
case tea.KeyEscape, tea.KeyCtrlC:
m.quitted = true
return m, tea.Quit
case tea.KeyRunes:
switch string(msg.Runes) {
case "j":
m.moveDown()
case "k":
m.moveUp()
}
}
}
return m, nil
}
func (m *listModel) View() string {
var sb strings.Builder
if m.title != "" {
sb.WriteString(BoldStyle.Render(m.title) + "\n\n")
}
for i, item := range m.items {
cursor := " "
style := DimStyle
if i == m.cursor {
cursor = AccentStyle.Render(Glyph(":pointer:")) + " "
style = BoldStyle
}
sb.WriteString(fmt.Sprintf("%s%s\n", cursor, style.Render(item)))
}
sb.WriteString("\n" + DimStyle.Render("↑/↓ navigate • enter select • esc cancel"))
return sb.String()
}
// ListOption configures InteractiveList behaviour.
type ListOption func(*listConfig)
type listConfig struct {
height int
}
// WithListHeight sets the visible height of the list (number of items shown).
func WithListHeight(n int) ListOption {
return func(c *listConfig) {
c.height = n
}
}
// InteractiveList presents an interactive scrollable list and returns the
// selected item's index and value. Returns -1 and empty string if cancelled.
//
// Falls back to numbered Select() when stdin is not a terminal (e.g. piped input).
//
// idx, value := cli.InteractiveList("Pick a repo:", repos)
func InteractiveList(title string, items []string, opts ...ListOption) (int, string) {
if len(items) == 0 {
return -1, ""
}
// Fall back to simple Select if not a terminal
if !term.IsTerminal(0) {
result, err := Select(title, items)
if err != nil {
return -1, ""
}
for i, item := range items {
if item == result {
return i, result
}
}
return -1, ""
}
m := newListModel(items, title)
p := tea.NewProgram(m)
finalModel, err := p.Run()
if err != nil {
return -1, ""
}
final := finalModel.(*listModel)
if final.quitted || !final.selected {
return -1, ""
}
return final.cursor, final.items[final.cursor]
}

52
pkg/cli/list_test.go Normal file
View file

@ -0,0 +1,52 @@
package cli
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestListModel_Good_Create(t *testing.T) {
items := []string{"alpha", "beta", "gamma"}
m := newListModel(items, "Pick one:")
assert.Equal(t, 3, len(m.items))
assert.Equal(t, 0, m.cursor)
assert.Equal(t, "Pick one:", m.title)
}
func TestListModel_Good_MoveDown(t *testing.T) {
m := newListModel([]string{"a", "b", "c"}, "")
m.moveDown()
assert.Equal(t, 1, m.cursor)
m.moveDown()
assert.Equal(t, 2, m.cursor)
}
func TestListModel_Good_MoveUp(t *testing.T) {
m := newListModel([]string{"a", "b", "c"}, "")
m.moveDown()
m.moveDown()
m.moveUp()
assert.Equal(t, 1, m.cursor)
}
func TestListModel_Good_WrapAround(t *testing.T) {
m := newListModel([]string{"a", "b", "c"}, "")
m.moveUp() // Should wrap to bottom
assert.Equal(t, 2, m.cursor)
}
func TestListModel_Good_View(t *testing.T) {
m := newListModel([]string{"alpha", "beta"}, "Choose:")
view := m.View()
assert.Contains(t, view, "Choose:")
assert.Contains(t, view, "alpha")
assert.Contains(t, view, "beta")
}
func TestListModel_Good_Selected(t *testing.T) {
m := newListModel([]string{"a", "b", "c"}, "")
m.moveDown()
m.selected = true
assert.Equal(t, "b", m.items[m.cursor])
}

106
pkg/cli/progressbar.go Normal file
View file

@ -0,0 +1,106 @@
package cli
import (
"fmt"
"strings"
"sync"
)
// ProgressHandle controls a progress bar.
type ProgressHandle struct {
mu sync.Mutex
current int
total int
message string
width int
}
// NewProgressBar creates a new progress bar with the given total.
func NewProgressBar(total int) *ProgressHandle {
return &ProgressHandle{
total: total,
width: 30,
}
}
// Current returns the current progress value.
func (p *ProgressHandle) Current() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.current
}
// Total returns the total value.
func (p *ProgressHandle) Total() int {
return p.total
}
// Increment advances the progress by 1.
func (p *ProgressHandle) Increment() {
p.mu.Lock()
defer p.mu.Unlock()
if p.current < p.total {
p.current++
}
p.render()
}
// Set sets the progress to a specific value.
func (p *ProgressHandle) Set(n int) {
p.mu.Lock()
defer p.mu.Unlock()
if n > p.total {
n = p.total
}
if n < 0 {
n = 0
}
p.current = n
p.render()
}
// SetMessage sets the message displayed alongside the bar.
func (p *ProgressHandle) SetMessage(msg string) {
p.mu.Lock()
defer p.mu.Unlock()
p.message = msg
p.render()
}
// Done completes the progress bar and moves to a new line.
func (p *ProgressHandle) Done() {
p.mu.Lock()
defer p.mu.Unlock()
p.current = p.total
p.render()
fmt.Println()
}
// String returns the rendered progress bar without ANSI cursor control.
func (p *ProgressHandle) String() string {
pct := 0
if p.total > 0 {
pct = (p.current * 100) / p.total
}
filled := 0
if p.total > 0 {
filled = (p.width * p.current) / p.total
}
if filled > p.width {
filled = p.width
}
empty := p.width - filled
bar := "[" + strings.Repeat("\u2588", filled) + strings.Repeat("\u2591", empty) + "]"
if p.message != "" {
return fmt.Sprintf("%s %3d%% %s", bar, pct, p.message)
}
return fmt.Sprintf("%s %3d%%", bar, pct)
}
// render outputs the progress bar, overwriting the current line.
func (p *ProgressHandle) render() {
fmt.Printf("\033[2K\r%s", p.String())
}

View file

@ -0,0 +1,60 @@
package cli
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProgressBar_Good_Create(t *testing.T) {
pb := NewProgressBar(100)
require.NotNil(t, pb)
assert.Equal(t, 0, pb.Current())
assert.Equal(t, 100, pb.Total())
}
func TestProgressBar_Good_Increment(t *testing.T) {
pb := NewProgressBar(10)
pb.Increment()
assert.Equal(t, 1, pb.Current())
pb.Increment()
assert.Equal(t, 2, pb.Current())
}
func TestProgressBar_Good_SetMessage(t *testing.T) {
pb := NewProgressBar(10)
pb.SetMessage("Processing file.go")
assert.Equal(t, "Processing file.go", pb.message)
}
func TestProgressBar_Good_Set(t *testing.T) {
pb := NewProgressBar(100)
pb.Set(50)
assert.Equal(t, 50, pb.Current())
}
func TestProgressBar_Good_Done(t *testing.T) {
pb := NewProgressBar(5)
for i := 0; i < 5; i++ {
pb.Increment()
}
pb.Done()
// After Done, Current == Total
assert.Equal(t, 5, pb.Current())
}
func TestProgressBar_Bad_ExceedsTotal(t *testing.T) {
pb := NewProgressBar(2)
pb.Increment()
pb.Increment()
pb.Increment() // Should clamp to total
assert.Equal(t, 2, pb.Current())
}
func TestProgressBar_Good_Render(t *testing.T) {
pb := NewProgressBar(10)
pb.Set(5)
rendered := pb.String()
assert.Contains(t, rendered, "50%")
}

View file

@ -63,9 +63,6 @@ func Init(opts Options) error {
SilenceUsage: true,
}
// Attach all registered commands
attachRegisteredCommands(rootCmd)
// Build signal service options
var signalOpts []SignalOption
if opts.OnReload != nil {

107
pkg/cli/spinner.go Normal file
View file

@ -0,0 +1,107 @@
package cli
import (
"fmt"
"sync"
"time"
)
// SpinnerHandle controls a running spinner.
type SpinnerHandle struct {
mu sync.Mutex
message string
done bool
ticker *time.Ticker
stopCh chan struct{}
}
// NewSpinner starts an async spinner with the given message.
// Call Stop(), Done(), or Fail() to stop it.
func NewSpinner(message string) *SpinnerHandle {
s := &SpinnerHandle{
message: message,
ticker: time.NewTicker(100 * time.Millisecond),
stopCh: make(chan struct{}),
}
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
if !ColorEnabled() {
frames = []string{"|", "/", "-", "\\"}
}
go func() {
i := 0
for {
select {
case <-s.stopCh:
return
case <-s.ticker.C:
s.mu.Lock()
if !s.done {
fmt.Printf("\033[2K\r%s %s", DimStyle.Render(frames[i%len(frames)]), s.message)
}
s.mu.Unlock()
i++
}
}
}()
return s
}
// Message returns the current spinner message.
func (s *SpinnerHandle) Message() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.message
}
// Update changes the spinner message.
func (s *SpinnerHandle) Update(message string) {
s.mu.Lock()
defer s.mu.Unlock()
s.message = message
}
// Stop stops the spinner silently (clears the line).
func (s *SpinnerHandle) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if s.done {
return
}
s.done = true
s.ticker.Stop()
close(s.stopCh)
fmt.Print("\033[2K\r")
}
// Done stops the spinner with a success message.
func (s *SpinnerHandle) Done(message string) {
s.mu.Lock()
alreadyDone := s.done
s.done = true
s.mu.Unlock()
if alreadyDone {
return
}
s.ticker.Stop()
close(s.stopCh)
fmt.Printf("\033[2K\r%s\n", SuccessStyle.Render(Glyph(":check:")+" "+message))
}
// Fail stops the spinner with an error message.
func (s *SpinnerHandle) Fail(message string) {
s.mu.Lock()
alreadyDone := s.done
s.done = true
s.mu.Unlock()
if alreadyDone {
return
}
s.ticker.Stop()
close(s.stopCh)
fmt.Printf("\033[2K\r%s\n", ErrorStyle.Render(Glyph(":cross:")+" "+message))
}

41
pkg/cli/spinner_test.go Normal file
View file

@ -0,0 +1,41 @@
package cli
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSpinner_Good_CreateAndStop(t *testing.T) {
s := NewSpinner("Loading...")
require.NotNil(t, s)
assert.Equal(t, "Loading...", s.Message())
s.Stop()
}
func TestSpinner_Good_UpdateMessage(t *testing.T) {
s := NewSpinner("Step 1")
s.Update("Step 2")
assert.Equal(t, "Step 2", s.Message())
s.Stop()
}
func TestSpinner_Good_Done(t *testing.T) {
s := NewSpinner("Building")
s.Done("Build complete")
// After Done, spinner is stopped — calling Stop again is safe
s.Stop()
}
func TestSpinner_Good_Fail(t *testing.T) {
s := NewSpinner("Checking")
s.Fail("Check failed")
s.Stop()
}
func TestSpinner_Good_DoubleStop(t *testing.T) {
s := NewSpinner("Loading")
s.Stop()
s.Stop() // Should not panic
}

146
pkg/cli/stubs.go Normal file
View file

@ -0,0 +1,146 @@
package cli
// ──────────────────────────────────────────────────────────────────────────────
// Form (stubbed — simple fallback, will use charmbracelet/huh later)
// ──────────────────────────────────────────────────────────────────────────────
// FieldType defines the type of a form field.
type FieldType string
const (
FieldText FieldType = "text"
FieldPassword FieldType = "password"
FieldConfirm FieldType = "confirm"
FieldSelect FieldType = "select"
)
// FormField describes a single field in a form.
type FormField struct {
Label string
Key string
Type FieldType
Default string
Placeholder string
Options []string // For FieldSelect
Required bool
Validator func(string) error
}
// Form presents a multi-field form and returns the values keyed by FormField.Key.
// Currently falls back to sequential Question()/Confirm()/Select() calls.
// Will be replaced with charmbracelet/huh interactive form later.
//
// results, err := cli.Form([]cli.FormField{
// {Label: "Name", Key: "name", Type: cli.FieldText, Required: true},
// {Label: "Password", Key: "pass", Type: cli.FieldPassword},
// {Label: "Accept terms?", Key: "terms", Type: cli.FieldConfirm},
// })
func Form(fields []FormField) (map[string]string, error) {
results := make(map[string]string, len(fields))
for _, f := range fields {
switch f.Type {
case FieldPassword:
val := Question(f.Label+":", WithDefault(f.Default))
results[f.Key] = val
case FieldConfirm:
if Confirm(f.Label) {
results[f.Key] = "true"
} else {
results[f.Key] = "false"
}
case FieldSelect:
val, err := Select(f.Label, f.Options)
if err != nil {
return nil, err
}
results[f.Key] = val
default: // FieldText
var opts []QuestionOption
if f.Default != "" {
opts = append(opts, WithDefault(f.Default))
}
if f.Required {
opts = append(opts, RequiredInput())
}
if f.Validator != nil {
opts = append(opts, WithValidator(f.Validator))
}
results[f.Key] = Question(f.Label+":", opts...)
}
}
return results, nil
}
// ──────────────────────────────────────────────────────────────────────────────
// FilePicker (stubbed — will use charmbracelet/filepicker later)
// ──────────────────────────────────────────────────────────────────────────────
// FilePickerOption configures FilePicker behaviour.
type FilePickerOption func(*filePickerConfig)
type filePickerConfig struct {
dir string
extensions []string
}
// InDirectory sets the starting directory for the file picker.
func InDirectory(dir string) FilePickerOption {
return func(c *filePickerConfig) {
c.dir = dir
}
}
// WithExtensions filters to specific file extensions (e.g. ".go", ".yaml").
func WithExtensions(exts ...string) FilePickerOption {
return func(c *filePickerConfig) {
c.extensions = exts
}
}
// FilePicker presents a file browser and returns the selected path.
// Currently falls back to a text prompt. Will be replaced with an
// interactive file browser later.
//
// path, err := cli.FilePicker(cli.InDirectory("."), cli.WithExtensions(".go"))
func FilePicker(opts ...FilePickerOption) (string, error) {
cfg := &filePickerConfig{dir: "."}
for _, opt := range opts {
opt(cfg)
}
hint := "File path"
if cfg.dir != "." {
hint += " (from " + cfg.dir + ")"
}
return Question(hint + ":"), nil
}
// ──────────────────────────────────────────────────────────────────────────────
// Tabs (stubbed — will use bubbletea model later)
// ──────────────────────────────────────────────────────────────────────────────
// TabItem describes a tab with a title and content.
type TabItem struct {
Title string
Content string
}
// Tabs displays tabbed content. Currently prints all tabs sequentially.
// Will be replaced with an interactive tab switcher later.
//
// cli.Tabs([]cli.TabItem{
// {Title: "Overview", Content: summaryText},
// {Title: "Details", Content: detailText},
// })
func Tabs(items []TabItem) error {
for i, tab := range items {
if i > 0 {
Blank()
}
Section(tab.Title)
Println("%s", tab.Content)
}
return nil
}

35
pkg/cli/stubs_test.go Normal file
View file

@ -0,0 +1,35 @@
package cli
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestFormField_Good_Types(t *testing.T) {
fields := []FormField{
{Label: "Name", Key: "name", Type: FieldText},
{Label: "Password", Key: "pass", Type: FieldPassword},
{Label: "Accept", Key: "ok", Type: FieldConfirm},
}
assert.Equal(t, 3, len(fields))
assert.Equal(t, FieldText, fields[0].Type)
assert.Equal(t, FieldPassword, fields[1].Type)
assert.Equal(t, FieldConfirm, fields[2].Type)
}
func TestFieldType_Good_Constants(t *testing.T) {
assert.Equal(t, FieldType("text"), FieldText)
assert.Equal(t, FieldType("password"), FieldPassword)
assert.Equal(t, FieldType("confirm"), FieldConfirm)
assert.Equal(t, FieldType("select"), FieldSelect)
}
func TestTabItem_Good_Structure(t *testing.T) {
tabs := []TabItem{
{Title: "Overview", Content: "overview content"},
{Title: "Details", Content: "detail content"},
}
assert.Equal(t, 2, len(tabs))
assert.Equal(t, "Overview", tabs[0].Title)
}

183
pkg/cli/textinput.go Normal file
View file

@ -0,0 +1,183 @@
package cli
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"golang.org/x/term"
)
// textInputModel is the internal bubbletea model for text input.
type textInputModel struct {
title string
placeholder string
value string
masked bool
submitted bool
cancelled bool
cursorPos int
validator func(string) error
err error
}
func newTextInputModel(title, placeholder string) *textInputModel {
return &textInputModel{
title: title,
placeholder: placeholder,
}
}
func (m *textInputModel) insertChar(ch rune) {
m.value = m.value[:m.cursorPos] + string(ch) + m.value[m.cursorPos:]
m.cursorPos++
}
func (m *textInputModel) backspace() {
if m.cursorPos > 0 {
m.value = m.value[:m.cursorPos-1] + m.value[m.cursorPos:]
m.cursorPos--
}
}
func (m *textInputModel) Init() tea.Cmd {
return nil
}
func (m *textInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEnter:
if m.validator != nil {
if err := m.validator(m.value); err != nil {
m.err = err
return m, nil
}
}
if m.value == "" && m.placeholder != "" {
m.value = m.placeholder
}
m.submitted = true
return m, tea.Quit
case tea.KeyEscape, tea.KeyCtrlC:
m.cancelled = true
return m, tea.Quit
case tea.KeyBackspace:
m.backspace()
m.err = nil
case tea.KeyLeft:
if m.cursorPos > 0 {
m.cursorPos--
}
case tea.KeyRight:
if m.cursorPos < len(m.value) {
m.cursorPos++
}
case tea.KeyRunes:
for _, ch := range msg.Runes {
m.insertChar(ch)
}
m.err = nil
}
}
return m, nil
}
func (m *textInputModel) View() string {
var sb strings.Builder
sb.WriteString(BoldStyle.Render(m.title) + "\n\n")
display := m.value
if m.masked {
display = strings.Repeat("*", len(m.value))
}
if display == "" && m.placeholder != "" {
sb.WriteString(DimStyle.Render(m.placeholder))
} else {
sb.WriteString(display)
}
sb.WriteString(AccentStyle.Render("\u2588")) // Cursor block
if m.err != nil {
sb.WriteString("\n" + ErrorStyle.Render(fmt.Sprintf(" %s", m.err)))
}
sb.WriteString("\n\n" + DimStyle.Render("enter submit \u2022 esc cancel"))
return sb.String()
}
// TextInputOption configures TextInput behaviour.
type TextInputOption func(*textInputConfig)
type textInputConfig struct {
placeholder string
masked bool
validator func(string) error
}
// WithTextPlaceholder sets placeholder text shown when input is empty.
func WithTextPlaceholder(text string) TextInputOption {
return func(c *textInputConfig) {
c.placeholder = text
}
}
// WithMask hides input characters (for passwords).
func WithMask() TextInputOption {
return func(c *textInputConfig) {
c.masked = true
}
}
// WithInputValidator adds a validation function for the input.
func WithInputValidator(fn func(string) error) TextInputOption {
return func(c *textInputConfig) {
c.validator = fn
}
}
// TextInput presents a styled text input prompt and returns the entered value.
// Returns empty string if cancelled.
//
// Falls back to Question() when stdin is not a terminal.
//
// name, err := cli.TextInput("Enter your name:", cli.WithTextPlaceholder("Anonymous"))
// pass, err := cli.TextInput("Password:", cli.WithMask())
func TextInput(title string, opts ...TextInputOption) (string, error) {
cfg := &textInputConfig{}
for _, opt := range opts {
opt(cfg)
}
// Fall back to simple Question if not a terminal
if !term.IsTerminal(0) {
var qopts []QuestionOption
if cfg.placeholder != "" {
qopts = append(qopts, WithDefault(cfg.placeholder))
}
if cfg.validator != nil {
qopts = append(qopts, WithValidator(cfg.validator))
}
return Question(title, qopts...), nil
}
m := newTextInputModel(title, cfg.placeholder)
m.masked = cfg.masked
m.validator = cfg.validator
p := tea.NewProgram(m)
finalModel, err := p.Run()
if err != nil {
return "", err
}
final := finalModel.(*textInputModel)
if final.cancelled {
return "", nil
}
return final.value, nil
}

59
pkg/cli/textinput_test.go Normal file
View file

@ -0,0 +1,59 @@
package cli
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestTextInputModel_Good_Create(t *testing.T) {
m := newTextInputModel("Enter name:", "")
assert.Equal(t, "Enter name:", m.title)
assert.Equal(t, "", m.value)
}
func TestTextInputModel_Good_WithPlaceholder(t *testing.T) {
m := newTextInputModel("Name:", "John")
assert.Equal(t, "John", m.placeholder)
}
func TestTextInputModel_Good_TypeCharacters(t *testing.T) {
m := newTextInputModel("Name:", "")
m.insertChar('H')
m.insertChar('i')
assert.Equal(t, "Hi", m.value)
}
func TestTextInputModel_Good_Backspace(t *testing.T) {
m := newTextInputModel("Name:", "")
m.insertChar('A')
m.insertChar('B')
m.backspace()
assert.Equal(t, "A", m.value)
}
func TestTextInputModel_Good_BackspaceEmpty(t *testing.T) {
m := newTextInputModel("Name:", "")
m.backspace() // Should not panic
assert.Equal(t, "", m.value)
}
func TestTextInputModel_Good_Masked(t *testing.T) {
m := newTextInputModel("Password:", "")
m.masked = true
m.insertChar('s')
m.insertChar('e')
m.insertChar('c')
assert.Equal(t, "sec", m.value) // Internal value is real
view := m.View()
assert.NotContains(t, view, "sec") // Display is masked
assert.Contains(t, view, "***")
}
func TestTextInputModel_Good_View(t *testing.T) {
m := newTextInputModel("Enter:", "")
m.insertChar('X')
view := m.View()
assert.Contains(t, view, "Enter:")
assert.Contains(t, view, "X")
}

85
pkg/cli/tui.go Normal file
View file

@ -0,0 +1,85 @@
package cli
import (
tea "github.com/charmbracelet/bubbletea"
)
// Model is the interface for interactive TUI applications.
// It mirrors bubbletea's Model but uses our own types so domain
// packages never import bubbletea directly.
type Model interface {
// Init returns an initial command to run.
Init() Cmd
// Update handles a message and returns the updated model and command.
Update(msg Msg) (Model, Cmd)
// View returns the string representation of the UI.
View() string
}
// Msg is a message passed to Update. Can be any type.
type Msg = tea.Msg
// Cmd is a function that returns a message. Nil means no command.
type Cmd = tea.Cmd
// Quit is a command that tells the TUI to exit.
var Quit = tea.Quit
// KeyMsg represents a key press event.
type KeyMsg = tea.KeyMsg
// KeyType represents the type of key pressed.
type KeyType = tea.KeyType
// Key type constants.
const (
KeyEnter KeyType = tea.KeyEnter
KeyEsc KeyType = tea.KeyEscape
KeyCtrlC KeyType = tea.KeyCtrlC
KeyUp KeyType = tea.KeyUp
KeyDown KeyType = tea.KeyDown
KeyLeft KeyType = tea.KeyLeft
KeyRight KeyType = tea.KeyRight
KeyTab KeyType = tea.KeyTab
KeyBackspace KeyType = tea.KeyBackspace
KeySpace KeyType = tea.KeySpace
KeyHome KeyType = tea.KeyHome
KeyEnd KeyType = tea.KeyEnd
KeyPgUp KeyType = tea.KeyPgUp
KeyPgDown KeyType = tea.KeyPgDown
KeyDelete KeyType = tea.KeyDelete
KeyShiftTab KeyType = tea.KeyShiftTab
KeyRunes KeyType = tea.KeyRunes
)
// adapter wraps our Model interface into a bubbletea.Model.
type adapter struct {
inner Model
}
func (a adapter) Init() tea.Cmd {
return a.inner.Init()
}
func (a adapter) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m, cmd := a.inner.Update(msg)
return adapter{inner: m}, cmd
}
func (a adapter) View() string {
return a.inner.View()
}
// RunTUI runs an interactive TUI application using the provided Model.
// This is the escape hatch for complex interactive UIs that need the
// full bubbletea event loop. For simple spinners, progress bars, and
// lists, use the dedicated helpers instead.
//
// err := cli.RunTUI(&myModel{items: items})
func RunTUI(m Model) error {
p := tea.NewProgram(adapter{inner: m})
_, err := p.Run()
return err
}

55
pkg/cli/tui_test.go Normal file
View file

@ -0,0 +1,55 @@
package cli
import (
"testing"
"github.com/stretchr/testify/assert"
)
// testModel is a minimal Model that quits immediately.
type testModel struct {
initCalled bool
updateCalled bool
viewCalled bool
}
func (m *testModel) Init() Cmd {
m.initCalled = true
return Quit
}
func (m *testModel) Update(msg Msg) (Model, Cmd) {
m.updateCalled = true
return m, nil
}
func (m *testModel) View() string {
m.viewCalled = true
return "test view"
}
func TestModel_Good_InterfaceSatisfied(t *testing.T) {
var m Model = &testModel{}
assert.NotNil(t, m)
}
func TestQuitCmd_Good_ReturnsQuitMsg(t *testing.T) {
cmd := Quit
assert.NotNil(t, cmd)
}
func TestKeyMsg_Good_Type(t *testing.T) {
// Verify our re-exported KeyType constants match bubbletea's
assert.Equal(t, KeyEnter, KeyEnter)
assert.Equal(t, KeyEsc, KeyEsc)
}
func TestKeyTypes_Good_Constants(t *testing.T) {
// Verify key type constants exist and are distinct
keys := []KeyType{KeyEnter, KeyEsc, KeyCtrlC, KeyUp, KeyDown, KeyTab, KeyBackspace}
seen := make(map[KeyType]bool)
for _, k := range keys {
assert.False(t, seen[k], "duplicate key type")
seen[k] = true
}
}

176
pkg/cli/viewport.go Normal file
View file

@ -0,0 +1,176 @@
package cli
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"golang.org/x/term"
)
// viewportModel is the internal bubbletea model for scrollable content.
type viewportModel struct {
title string
lines []string
offset int
height int
quitted bool
}
func newViewportModel(content, title string, height int) *viewportModel {
lines := strings.Split(content, "\n")
return &viewportModel{
title: title,
lines: lines,
height: height,
}
}
func (m *viewportModel) scrollDown() {
maxOffset := len(m.lines) - m.height
if maxOffset < 0 {
maxOffset = 0
}
if m.offset < maxOffset {
m.offset++
}
}
func (m *viewportModel) scrollUp() {
if m.offset > 0 {
m.offset--
}
}
func (m *viewportModel) Init() tea.Cmd {
return nil
}
func (m *viewportModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyUp:
m.scrollUp()
case tea.KeyDown:
m.scrollDown()
case tea.KeyPgUp:
for i := 0; i < m.height; i++ {
m.scrollUp()
}
case tea.KeyPgDown:
for i := 0; i < m.height; i++ {
m.scrollDown()
}
case tea.KeyHome:
m.offset = 0
case tea.KeyEnd:
maxOffset := len(m.lines) - m.height
if maxOffset < 0 {
maxOffset = 0
}
m.offset = maxOffset
case tea.KeyEscape, tea.KeyCtrlC:
m.quitted = true
return m, tea.Quit
case tea.KeyRunes:
switch string(msg.Runes) {
case "q":
m.quitted = true
return m, tea.Quit
case "j":
m.scrollDown()
case "k":
m.scrollUp()
case "g":
m.offset = 0
case "G":
maxOffset := len(m.lines) - m.height
if maxOffset < 0 {
maxOffset = 0
}
m.offset = maxOffset
}
}
}
return m, nil
}
func (m *viewportModel) View() string {
var sb strings.Builder
if m.title != "" {
sb.WriteString(BoldStyle.Render(m.title) + "\n")
sb.WriteString(DimStyle.Render(strings.Repeat("\u2500", len(m.title))) + "\n")
}
// Visible window
end := m.offset + m.height
if end > len(m.lines) {
end = len(m.lines)
}
for _, line := range m.lines[m.offset:end] {
sb.WriteString(line + "\n")
}
// Scroll indicator
total := len(m.lines)
if total > m.height {
pct := (m.offset * 100) / (total - m.height)
sb.WriteString(DimStyle.Render(fmt.Sprintf("\n%d%% (%d/%d lines)", pct, m.offset+m.height, total)))
}
sb.WriteString("\n" + DimStyle.Render("\u2191/\u2193 scroll \u2022 PgUp/PgDn page \u2022 q quit"))
return sb.String()
}
// ViewportOption configures Viewport behaviour.
type ViewportOption func(*viewportConfig)
type viewportConfig struct {
title string
height int
}
// WithViewportTitle sets the title shown above the viewport.
func WithViewportTitle(title string) ViewportOption {
return func(c *viewportConfig) {
c.title = title
}
}
// WithViewportHeight sets the visible height in lines.
func WithViewportHeight(n int) ViewportOption {
return func(c *viewportConfig) {
c.height = n
}
}
// Viewport displays scrollable content in the terminal.
// Falls back to printing the full content when stdin is not a terminal.
//
// cli.Viewport(longContent, WithViewportTitle("Build Log"), WithViewportHeight(20))
func Viewport(content string, opts ...ViewportOption) error {
cfg := &viewportConfig{
height: 20,
}
for _, opt := range opts {
opt(cfg)
}
// Fall back to plain output if not a terminal
if !term.IsTerminal(0) {
if cfg.title != "" {
fmt.Println(BoldStyle.Render(cfg.title))
fmt.Println(DimStyle.Render(strings.Repeat("\u2500", len(cfg.title))))
}
fmt.Println(content)
return nil
}
m := newViewportModel(content, cfg.title, cfg.height)
p := tea.NewProgram(m)
_, err := p.Run()
return err
}

61
pkg/cli/viewport_test.go Normal file
View file

@ -0,0 +1,61 @@
package cli
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestViewportModel_Good_Create(t *testing.T) {
content := "line 1\nline 2\nline 3"
m := newViewportModel(content, "Title", 5)
assert.Equal(t, "Title", m.title)
assert.Equal(t, 3, len(m.lines))
assert.Equal(t, 0, m.offset)
}
func TestViewportModel_Good_ScrollDown(t *testing.T) {
lines := make([]string, 20)
for i := range lines {
lines[i] = strings.Repeat("x", 10)
}
m := newViewportModel(strings.Join(lines, "\n"), "", 5)
m.scrollDown()
assert.Equal(t, 1, m.offset)
}
func TestViewportModel_Good_ScrollUp(t *testing.T) {
lines := make([]string, 20)
for i := range lines {
lines[i] = strings.Repeat("x", 10)
}
m := newViewportModel(strings.Join(lines, "\n"), "", 5)
m.scrollDown()
m.scrollDown()
m.scrollUp()
assert.Equal(t, 1, m.offset)
}
func TestViewportModel_Good_NoScrollPastTop(t *testing.T) {
m := newViewportModel("a\nb\nc", "", 5)
m.scrollUp() // Already at top
assert.Equal(t, 0, m.offset)
}
func TestViewportModel_Good_NoScrollPastBottom(t *testing.T) {
m := newViewportModel("a\nb\nc", "", 5)
for i := 0; i < 10; i++ {
m.scrollDown()
}
// Should clamp -- can't scroll past content
assert.GreaterOrEqual(t, m.offset, 0)
}
func TestViewportModel_Good_View(t *testing.T) {
m := newViewportModel("line 1\nline 2", "My Title", 10)
view := m.View()
assert.Contains(t, view, "My Title")
assert.Contains(t, view, "line 1")
assert.Contains(t, view, "line 2")
}