Compare commits

...
Sign in to create a new pull request.

2 commits
main ... dev

Author SHA1 Message Date
Claude
dcde802ea2
refactor: update imports from core/go/pkg/cli to core/cli/pkg/cli
Some checks failed
Security Scan / Secret Detection (push) Successful in 15s
Security Scan / Go Vulnerability Check (push) Failing after 38s
Security Scan / Dependency & Config Scan (push) Successful in 21s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:06:55 +00:00
Claude
fa000d57c1
feat: move pkg/cli from core/go, add TUI components (#14, #15)
Move pkg/cli package from core/go to its correct home in core/cli.
Includes Frame AppShell, Stream, TaskTracker, Tree, Rich Table.
Update go.mod deps to use main branch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:06:37 +00:00
116 changed files with 6127 additions and 85 deletions

View file

@ -1,6 +1,6 @@
package config package config
import "forge.lthn.ai/core/go/pkg/cli" import "forge.lthn.ai/core/cli/pkg/cli"
func init() { func init() {
cli.RegisterCommands(AddConfigCommands) cli.RegisterCommands(AddConfigCommands)

View file

@ -3,7 +3,7 @@ package config
import ( import (
"fmt" "fmt"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/config" "forge.lthn.ai/core/go/pkg/config"
) )

View file

@ -3,7 +3,7 @@ package config
import ( import (
"fmt" "fmt"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )

View file

@ -3,7 +3,7 @@ package config
import ( import (
"fmt" "fmt"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
) )
func addPathCommand(parent *cli.Command) { func addPathCommand(parent *cli.Command) {

View file

@ -1,7 +1,7 @@
package config package config
import ( import (
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
) )
func addSetCommand(parent *cli.Command) { func addSetCommand(parent *cli.Command) {

View file

@ -1,7 +1,7 @@
package dev package dev
import ( import (
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -14,7 +14,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
core "forge.lthn.ai/core/go/pkg/framework/core" core "forge.lthn.ai/core/go/pkg/framework/core"
"forge.lthn.ai/core/go-scm/git" "forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"

View file

@ -8,7 +8,7 @@ import (
"strings" "strings"
"time" "time"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos" "forge.lthn.ai/core/go/pkg/repos"

View file

@ -5,7 +5,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-scm/git" "forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
coreio "forge.lthn.ai/core/go/pkg/io" coreio "forge.lthn.ai/core/go/pkg/io"

View file

@ -33,7 +33,7 @@
package dev package dev
import ( import (
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -14,7 +14,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-scm/git" "forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
coreio "forge.lthn.ai/core/go/pkg/io" coreio "forge.lthn.ai/core/go/pkg/io"

View file

@ -6,7 +6,7 @@ import (
"sort" "sort"
"strings" "strings"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-scm/git" "forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -4,7 +4,7 @@ import (
"errors" "errors"
"sort" "sort"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos" "forge.lthn.ai/core/go/pkg/repos"

View file

@ -8,7 +8,7 @@ import (
"strings" "strings"
"time" "time"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -4,7 +4,7 @@ import (
"context" "context"
"os/exec" "os/exec"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-scm/git" "forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -5,7 +5,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-scm/git" "forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -8,7 +8,7 @@ import (
"strings" "strings"
"time" "time"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -8,7 +8,7 @@ import (
"path/filepath" "path/filepath"
"text/template" "text/template"
"forge.lthn.ai/core/go/pkg/cli" // Added "forge.lthn.ai/core/cli/pkg/cli" // Added
"forge.lthn.ai/core/go/pkg/i18n" // Added "forge.lthn.ai/core/go/pkg/i18n" // Added
coreio "forge.lthn.ai/core/go/pkg/io" coreio "forge.lthn.ai/core/go/pkg/io"
// Added // Added

View file

@ -6,7 +6,7 @@ import (
"os" "os"
"time" "time"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-devops/devops" "forge.lthn.ai/core/go-devops/devops"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/io"

View file

@ -8,7 +8,7 @@ import (
"strings" "strings"
"forge.lthn.ai/core/go-agentic" "forge.lthn.ai/core/go-agentic"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-scm/git" "forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -5,7 +5,7 @@ import (
"sort" "sort"
"strings" "strings"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/io"
) )

View file

@ -6,7 +6,7 @@ import (
"strings" "strings"
"forge.lthn.ai/core/cli/cmd/workspace" "forge.lthn.ai/core/cli/cmd/workspace"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos" "forge.lthn.ai/core/go/pkg/repos"

View file

@ -6,7 +6,7 @@ import (
"strings" "strings"
"forge.lthn.ai/core/go-agentic" "forge.lthn.ai/core/go-agentic"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/framework" "forge.lthn.ai/core/go/pkg/framework"
"forge.lthn.ai/core/go-scm/git" "forge.lthn.ai/core/go-scm/git"
) )

View file

@ -8,7 +8,7 @@
// to a central location for unified documentation builds. // to a central location for unified documentation builds.
package docs package docs
import "forge.lthn.ai/core/go/pkg/cli" import "forge.lthn.ai/core/cli/pkg/cli"
func init() { func init() {
cli.RegisterCommands(AddDocsCommands) cli.RegisterCommands(AddDocsCommands)

View file

@ -2,7 +2,7 @@
package docs package docs
import ( import (
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -3,7 +3,7 @@ package docs
import ( import (
"strings" "strings"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -7,7 +7,7 @@ import (
"strings" "strings"
"forge.lthn.ai/core/cli/cmd/workspace" "forge.lthn.ai/core/cli/cmd/workspace"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos" "forge.lthn.ai/core/go/pkg/repos"

View file

@ -6,7 +6,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos" "forge.lthn.ai/core/go/pkg/repos"

View file

@ -11,7 +11,7 @@
package doctor package doctor
import ( import (
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )

View file

@ -4,7 +4,7 @@ package doctor
import ( import (
"fmt" "fmt"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )

View file

@ -14,7 +14,7 @@ package gitcmd
import ( import (
"forge.lthn.ai/core/cli/cmd/dev" "forge.lthn.ai/core/cli/cmd/dev"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -14,7 +14,7 @@
// Sets MACOSX_DEPLOYMENT_TARGET to suppress linker warnings on macOS. // Sets MACOSX_DEPLOYMENT_TARGET to suppress linker warnings on macOS.
package gocmd package gocmd
import "forge.lthn.ai/core/go/pkg/cli" import "forge.lthn.ai/core/cli/pkg/cli"
func init() { func init() {
cli.RegisterCommands(AddGoCommands) cli.RegisterCommands(AddGoCommands)

View file

@ -7,7 +7,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -9,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -4,7 +4,7 @@
package gocmd package gocmd
import ( import (
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -12,7 +12,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -11,7 +11,7 @@ import (
"time" "time"
"forge.lthn.ai/core/cli/cmd/qa" "forge.lthn.ai/core/cli/cmd/qa"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -6,7 +6,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -4,7 +4,7 @@ import (
"os" "os"
"testing" "testing"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )

View file

@ -3,7 +3,7 @@ package help
import ( import (
"fmt" "fmt"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/help" "forge.lthn.ai/core/go/pkg/help"
) )

View file

@ -8,7 +8,7 @@ import (
"os/signal" "os/signal"
"time" "time"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/lab" "forge.lthn.ai/core/go/pkg/lab"
"forge.lthn.ai/core/go/pkg/lab/collector" "forge.lthn.ai/core/go/pkg/lab/collector"
"forge.lthn.ai/core/go/pkg/lab/handler" "forge.lthn.ai/core/go/pkg/lab/handler"

View file

@ -11,7 +11,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/marketplace" "forge.lthn.ai/core/go/pkg/marketplace"
"forge.lthn.ai/core/go/pkg/store" "forge.lthn.ai/core/go/pkg/store"

View file

@ -4,7 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/marketplace" "forge.lthn.ai/core/go/pkg/marketplace"
) )

View file

@ -3,7 +3,7 @@ package module
import ( import (
"fmt" "fmt"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -1,7 +1,7 @@
package module package module
import ( import (
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -4,7 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -10,7 +10,7 @@
package monitor package monitor
import ( import (
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -16,7 +16,7 @@ import (
"sort" "sort"
"strings" "strings"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/log" "forge.lthn.ai/core/go/pkg/log"

View file

@ -2,7 +2,7 @@
package pkgcmd package pkgcmd
import ( import (
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )

View file

@ -9,7 +9,7 @@
package plugin package plugin
import ( import (
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/plugin" "forge.lthn.ai/core/go/pkg/plugin"

View file

@ -5,7 +5,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/plugin" "forge.lthn.ai/core/go/pkg/plugin"

View file

@ -3,7 +3,7 @@ package plugin
import ( import (
"fmt" "fmt"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/plugin" "forge.lthn.ai/core/go/pkg/plugin"

View file

@ -1,7 +1,7 @@
package plugin package plugin
import ( import (
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/plugin" "forge.lthn.ai/core/go/pkg/plugin"

View file

@ -4,7 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/plugin" "forge.lthn.ai/core/go/pkg/plugin"

View file

@ -18,7 +18,7 @@ import (
"sort" "sort"
"strings" "strings"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -12,7 +12,7 @@ import (
"sort" "sort"
"strings" "strings"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/log" "forge.lthn.ai/core/go/pkg/log"

View file

@ -15,7 +15,7 @@ import (
"strings" "strings"
"time" "time"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/log" "forge.lthn.ai/core/go/pkg/log"

View file

@ -11,7 +11,7 @@
package qa package qa
import ( import (
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -15,7 +15,7 @@ import (
"strings" "strings"
"time" "time"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/log" "forge.lthn.ai/core/go/pkg/log"
) )

View file

@ -16,7 +16,7 @@ import (
"strings" "strings"
"time" "time"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/log" "forge.lthn.ai/core/go/pkg/log"
) )

View file

@ -7,7 +7,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/session" "forge.lthn.ai/core/go/pkg/session"
) )

View file

@ -6,7 +6,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
coreio "forge.lthn.ai/core/go/pkg/io" coreio "forge.lthn.ai/core/go/pkg/io"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"

View file

@ -24,7 +24,7 @@
package setup package setup
import ( import (
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )

View file

@ -22,7 +22,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
coreio "forge.lthn.ai/core/go/pkg/io" coreio "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos" "forge.lthn.ai/core/go/pkg/repos"

View file

@ -14,7 +14,7 @@ import (
"strings" "strings"
"forge.lthn.ai/core/cli/cmd/workspace" "forge.lthn.ai/core/cli/cmd/workspace"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
coreio "forge.lthn.ai/core/go/pkg/io" coreio "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos" "forge.lthn.ai/core/go/pkg/repos"

View file

@ -2,7 +2,7 @@
package setup package setup
import ( import (
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )

View file

@ -6,7 +6,7 @@ import (
"os" "os"
"sort" "sort"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/repos" "forge.lthn.ai/core/go/pkg/repos"
"golang.org/x/term" "golang.org/x/term"

View file

@ -7,7 +7,7 @@ import (
"sort" "sort"
"strings" "strings"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/i18n"
) )

View file

@ -12,7 +12,7 @@ import (
"os/exec" "os/exec"
"strings" "strings"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
) )
// GitHubLabel represents a label as returned by the GitHub API. // GitHubLabel represents a label as returned by the GitHub API.

View file

@ -12,7 +12,7 @@ import (
"os/exec" "os/exec"
"strings" "strings"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
) )
// GitHubBranchProtection represents branch protection rules from the GitHub API. // GitHubBranchProtection represents branch protection rules from the GitHub API.

View file

@ -14,7 +14,7 @@ import (
"os/exec" "os/exec"
"strings" "strings"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
) )
// GitHubSecurityStatus represents the security settings status of a repository. // GitHubSecurityStatus represents the security settings status of a repository.

View file

@ -12,7 +12,7 @@ import (
"os/exec" "os/exec"
"strings" "strings"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
) )
// GitHubWebhook represents a webhook as returned by the GitHub API. // GitHubWebhook represents a webhook as returned by the GitHub API.

View file

@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"runtime" "runtime"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )

View file

@ -1,6 +1,6 @@
package workspace package workspace
import "forge.lthn.ai/core/go/pkg/cli" import "forge.lthn.ai/core/cli/pkg/cli"
func init() { func init() {
cli.RegisterCommands(AddWorkspaceCommands) cli.RegisterCommands(AddWorkspaceCommands)

View file

@ -28,7 +28,7 @@ import (
"strings" "strings"
"time" "time"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
coreio "forge.lthn.ai/core/go/pkg/io" coreio "forge.lthn.ai/core/go/pkg/io"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )

View file

@ -17,7 +17,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
coreio "forge.lthn.ai/core/go/pkg/io" coreio "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos" "forge.lthn.ai/core/go/pkg/repos"
"github.com/spf13/cobra" "github.com/spf13/cobra"

View file

@ -3,7 +3,7 @@ package workspace
import ( import (
"strings" "strings"
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )

12
go.mod
View file

@ -3,12 +3,12 @@ module forge.lthn.ai/core/cli
go 1.26.0 go 1.26.0
require ( require (
forge.lthn.ai/core/go v0.0.0-20260221191103-d091fa62023f forge.lthn.ai/core/go main
forge.lthn.ai/core/go-agentic v0.0.0-20260221191948-ad0cf5c932a3 forge.lthn.ai/core/go-agentic main
forge.lthn.ai/core/go-crypt v0.0.0-20260221193816-fde12e1539b2 // indirect forge.lthn.ai/core/go-crypt main // indirect
forge.lthn.ai/core/go-devops v0.0.0-20260221193818-400d8a76901e forge.lthn.ai/core/go-devops main
forge.lthn.ai/core/go-scm v0.0.0-20260221193836-7eb28df79d0b forge.lthn.ai/core/go-scm main
forge.lthn.ai/core/go-store v0.1.1-0.20260220151120-0284110ccadf // indirect forge.lthn.ai/core/go-store main // indirect
) )
require ( require (

View file

@ -1,7 +1,7 @@
package main package main
import ( import (
"forge.lthn.ai/core/go/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
// Commands via self-registration (local to CLI) // Commands via self-registration (local to CLI)
_ "forge.lthn.ai/core/cli/cmd/config" _ "forge.lthn.ai/core/cli/cmd/config"

163
pkg/cli/ansi.go Normal file
View file

@ -0,0 +1,163 @@
package cli
import (
"fmt"
"os"
"strconv"
"strings"
"sync"
)
// ANSI escape codes
const (
ansiReset = "\033[0m"
ansiBold = "\033[1m"
ansiDim = "\033[2m"
ansiItalic = "\033[3m"
ansiUnderline = "\033[4m"
)
var (
colorEnabled = true
colorEnabledMu sync.RWMutex
)
func init() {
// NO_COLOR standard: https://no-color.org/
// If NO_COLOR is set (to any value, including empty), disable colors.
if _, exists := os.LookupEnv("NO_COLOR"); exists {
colorEnabled = false
return
}
// TERM=dumb indicates a terminal without color support.
if os.Getenv("TERM") == "dumb" {
colorEnabled = false
}
}
// ColorEnabled returns true if ANSI color output is enabled.
func ColorEnabled() bool {
colorEnabledMu.RLock()
defer colorEnabledMu.RUnlock()
return colorEnabled
}
// SetColorEnabled enables or disables ANSI color output.
// This overrides the NO_COLOR environment variable check.
func SetColorEnabled(enabled bool) {
colorEnabledMu.Lock()
colorEnabled = enabled
colorEnabledMu.Unlock()
}
// AnsiStyle represents terminal text styling.
// Use NewStyle() to create, chain methods, call Render().
type AnsiStyle struct {
bold bool
dim bool
italic bool
underline bool
fg string
bg string
}
// NewStyle creates a new empty style.
func NewStyle() *AnsiStyle {
return &AnsiStyle{}
}
// Bold enables bold text.
func (s *AnsiStyle) Bold() *AnsiStyle {
s.bold = true
return s
}
// Dim enables dim text.
func (s *AnsiStyle) Dim() *AnsiStyle {
s.dim = true
return s
}
// Italic enables italic text.
func (s *AnsiStyle) Italic() *AnsiStyle {
s.italic = true
return s
}
// Underline enables underlined text.
func (s *AnsiStyle) Underline() *AnsiStyle {
s.underline = true
return s
}
// Foreground sets foreground color from hex string.
func (s *AnsiStyle) Foreground(hex string) *AnsiStyle {
s.fg = fgColorHex(hex)
return s
}
// Background sets background color from hex string.
func (s *AnsiStyle) Background(hex string) *AnsiStyle {
s.bg = bgColorHex(hex)
return s
}
// Render applies the style to text.
// Returns plain text if NO_COLOR is set or colors are disabled.
func (s *AnsiStyle) Render(text string) string {
if s == nil || !ColorEnabled() {
return text
}
var codes []string
if s.bold {
codes = append(codes, ansiBold)
}
if s.dim {
codes = append(codes, ansiDim)
}
if s.italic {
codes = append(codes, ansiItalic)
}
if s.underline {
codes = append(codes, ansiUnderline)
}
if s.fg != "" {
codes = append(codes, s.fg)
}
if s.bg != "" {
codes = append(codes, s.bg)
}
if len(codes) == 0 {
return text
}
return strings.Join(codes, "") + text + ansiReset
}
// fgColorHex converts a hex string to an ANSI foreground color code.
func fgColorHex(hex string) string {
r, g, b := hexToRGB(hex)
return fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b)
}
// bgColorHex converts a hex string to an ANSI background color code.
func bgColorHex(hex string) string {
r, g, b := hexToRGB(hex)
return fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b)
}
// hexToRGB converts a hex string to RGB values.
func hexToRGB(hex string) (int, int, int) {
hex = strings.TrimPrefix(hex, "#")
if len(hex) != 6 {
return 255, 255, 255
}
// Use 8-bit parsing since RGB values are 0-255, avoiding integer overflow on 32-bit systems.
r, _ := strconv.ParseUint(hex[0:2], 16, 8)
g, _ := strconv.ParseUint(hex[2:4], 16, 8)
b, _ := strconv.ParseUint(hex[4:6], 16, 8)
return int(r), int(g), int(b)
}

97
pkg/cli/ansi_test.go Normal file
View file

@ -0,0 +1,97 @@
package cli
import (
"strings"
"testing"
)
func TestAnsiStyle_Render(t *testing.T) {
// Ensure colors are enabled for this test
SetColorEnabled(true)
defer SetColorEnabled(true) // Reset after test
s := NewStyle().Bold().Foreground("#ff0000")
got := s.Render("test")
if got == "test" {
t.Error("Expected styled output")
}
if !strings.Contains(got, "test") {
t.Error("Output should contain text")
}
if !strings.Contains(got, "[1m") {
t.Error("Output should contain bold code")
}
}
func TestColorEnabled_Good(t *testing.T) {
// Save original state
original := ColorEnabled()
defer SetColorEnabled(original)
// Test enabling
SetColorEnabled(true)
if !ColorEnabled() {
t.Error("ColorEnabled should return true")
}
// Test disabling
SetColorEnabled(false)
if ColorEnabled() {
t.Error("ColorEnabled should return false")
}
}
func TestRender_ColorDisabled_Good(t *testing.T) {
// Save original state
original := ColorEnabled()
defer SetColorEnabled(original)
// Disable colors
SetColorEnabled(false)
s := NewStyle().Bold().Foreground("#ff0000")
got := s.Render("test")
// Should return plain text without ANSI codes
if got != "test" {
t.Errorf("Expected plain 'test', got %q", got)
}
}
func TestRender_ColorEnabled_Good(t *testing.T) {
// Save original state
original := ColorEnabled()
defer SetColorEnabled(original)
// Enable colors
SetColorEnabled(true)
s := NewStyle().Bold()
got := s.Render("test")
// Should contain ANSI codes
if !strings.Contains(got, "\033[") {
t.Error("Expected ANSI codes when colors enabled")
}
}
func TestUseASCII_Good(t *testing.T) {
// Save original state
original := ColorEnabled()
defer SetColorEnabled(original)
// Enable first, then UseASCII should disable colors
SetColorEnabled(true)
UseASCII()
if ColorEnabled() {
t.Error("UseASCII should disable colors")
}
}
func TestRender_NilStyle_Good(t *testing.T) {
var s *AnsiStyle
got := s.Render("test")
if got != "test" {
t.Errorf("Nil style should return plain text, got %q", got)
}
}

163
pkg/cli/app.go Normal file
View file

@ -0,0 +1,163 @@
package cli
import (
"fmt"
"os"
"runtime/debug"
"forge.lthn.ai/core/go/pkg/crypt/openpgp"
"forge.lthn.ai/core/go/pkg/framework"
"forge.lthn.ai/core/go/pkg/log"
"forge.lthn.ai/core/go/pkg/workspace"
"github.com/spf13/cobra"
)
// AppName is the default CLI application name.
// Override with WithAppName before calling Main.
var AppName = "core"
// Build-time variables set via ldflags (SemVer 2.0.0):
//
// go build -ldflags="-X forge.lthn.ai/core/cli/pkg/cli.AppVersion=1.2.0 \
// -X forge.lthn.ai/core/cli/pkg/cli.BuildCommit=df94c24 \
// -X forge.lthn.ai/core/cli/pkg/cli.BuildDate=2026-02-06 \
// -X forge.lthn.ai/core/cli/pkg/cli.BuildPreRelease=dev.8"
var (
AppVersion = "0.0.0"
BuildCommit = "unknown"
BuildDate = "unknown"
BuildPreRelease = ""
)
// SemVer returns the full SemVer 2.0.0 version string.
// - Release: 1.2.0
// - Pre-release: 1.2.0-dev.8
// - Full: 1.2.0-dev.8+df94c24.20260206
func SemVer() string {
v := AppVersion
if BuildPreRelease != "" {
v += "-" + BuildPreRelease
}
if BuildCommit != "unknown" {
v += "+" + BuildCommit
if BuildDate != "unknown" {
v += "." + BuildDate
}
}
return v
}
// WithAppName sets the application name used in help text and shell completion.
// Call before Main for variant binaries (e.g. "lem", "devops").
//
// cli.WithAppName("lem")
// cli.Main()
func WithAppName(name string) {
AppName = name
}
// Main initialises and runs the CLI application.
// This is the main entry point for the CLI.
// Exits with code 1 on error or panic.
func Main() {
// Recovery from panics
defer func() {
if r := recover(); r != nil {
log.Error("recovered from panic", "error", r, "stack", string(debug.Stack()))
Shutdown()
Fatal(fmt.Errorf("panic: %v", r))
}
}()
// 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("crypt", openpgp.New),
framework.WithName("workspace", workspace.New),
},
}); err != nil {
Error(err.Error())
os.Exit(1)
}
defer Shutdown()
// Add completion command to the CLI's root
RootCmd().AddCommand(newCompletionCmd())
if err := Execute(); err != nil {
code := 1
var exitErr *ExitError
if As(err, &exitErr) {
code = exitErr.Code
}
Error(err.Error())
os.Exit(code)
}
}
// newCompletionCmd creates the shell completion command using the current AppName.
func newCompletionCmd() *cobra.Command {
return &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion script",
Long: fmt.Sprintf(`Generate shell completion script for the specified shell.
To load completions:
Bash:
$ source <(%s completion bash)
# To load completions for each session, execute once:
# Linux:
$ %s completion bash > /etc/bash_completion.d/%s
# macOS:
$ %s completion bash > $(brew --prefix)/etc/bash_completion.d/%s
Zsh:
# If shell completion is not already enabled in your environment,
# you will need to enable it. You can execute the following once:
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
# To load completions for each session, execute once:
$ %s completion zsh > "${fpath[1]}/_%s"
# You will need to start a new shell for this setup to take effect.
Fish:
$ %s completion fish | source
# To load completions for each session, execute once:
$ %s completion fish > ~/.config/fish/completions/%s.fish
PowerShell:
PS> %s completion powershell | Out-String | Invoke-Expression
# To load completions for every new session, run:
PS> %s completion powershell > %s.ps1
# and source this file from your PowerShell profile.
`, AppName, AppName, AppName, AppName, AppName,
AppName, AppName, AppName, AppName, AppName,
AppName, AppName, AppName),
DisableFlagsInUseLine: true,
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
Run: func(cmd *cobra.Command, args []string) {
switch args[0] {
case "bash":
_ = cmd.Root().GenBashCompletion(os.Stdout)
case "zsh":
_ = cmd.Root().GenZshCompletion(os.Stdout)
case "fish":
_ = cmd.Root().GenFishCompletion(os.Stdout, true)
case "powershell":
_ = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
}
},
}
}

164
pkg/cli/app_test.go Normal file
View file

@ -0,0 +1,164 @@
package cli
import (
"bytes"
"fmt"
"runtime/debug"
"sync"
"testing"
"github.com/stretchr/testify/assert"
)
// TestPanicRecovery_Good verifies that the panic recovery mechanism
// catches panics and calls the appropriate shutdown and error handling.
func TestPanicRecovery_Good(t *testing.T) {
t.Run("recovery captures panic value and stack", func(t *testing.T) {
var recovered any
var capturedStack []byte
var shutdownCalled bool
// Simulate the panic recovery pattern from Main()
func() {
defer func() {
if r := recover(); r != nil {
recovered = r
capturedStack = debug.Stack()
shutdownCalled = true // simulates Shutdown() call
}
}()
panic("test panic")
}()
assert.Equal(t, "test panic", recovered)
assert.True(t, shutdownCalled, "Shutdown should be called after panic recovery")
assert.NotEmpty(t, capturedStack, "Stack trace should be captured")
assert.Contains(t, string(capturedStack), "TestPanicRecovery_Good")
})
t.Run("recovery handles error type panics", func(t *testing.T) {
var recovered any
func() {
defer func() {
if r := recover(); r != nil {
recovered = r
}
}()
panic(fmt.Errorf("error panic"))
}()
err, ok := recovered.(error)
assert.True(t, ok, "Recovered value should be an error")
assert.Equal(t, "error panic", err.Error())
})
t.Run("recovery handles nil panic gracefully", func(t *testing.T) {
recoveryExecuted := false
func() {
defer func() {
if r := recover(); r != nil {
recoveryExecuted = true
}
}()
// No panic occurs
}()
assert.False(t, recoveryExecuted, "Recovery block should not execute without panic")
})
}
// TestPanicRecovery_Bad tests error conditions in panic recovery.
func TestPanicRecovery_Bad(t *testing.T) {
t.Run("recovery handles concurrent panics", func(t *testing.T) {
var wg sync.WaitGroup
recoveryCount := 0
var mu sync.Mutex
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
mu.Lock()
recoveryCount++
mu.Unlock()
}
}()
panic(fmt.Sprintf("panic from goroutine %d", id))
}(i)
}
wg.Wait()
assert.Equal(t, 3, recoveryCount, "All goroutine panics should be recovered")
})
}
// TestPanicRecovery_Ugly tests edge cases in panic recovery.
func TestPanicRecovery_Ugly(t *testing.T) {
t.Run("recovery handles typed panic values", func(t *testing.T) {
type customError struct {
code int
msg string
}
var recovered any
func() {
defer func() {
recovered = recover()
}()
panic(customError{code: 500, msg: "internal error"})
}()
ce, ok := recovered.(customError)
assert.True(t, ok, "Should recover custom type")
assert.Equal(t, 500, ce.code)
assert.Equal(t, "internal error", ce.msg)
})
}
// TestMainPanicRecoveryPattern verifies the exact pattern used in Main().
func TestMainPanicRecoveryPattern(t *testing.T) {
t.Run("pattern logs error and calls shutdown", func(t *testing.T) {
var logBuffer bytes.Buffer
var shutdownCalled bool
var fatalErr error
// Mock implementations
mockLogError := func(msg string, args ...any) {
fmt.Fprintf(&logBuffer, msg, args...)
}
mockShutdown := func() {
shutdownCalled = true
}
mockFatal := func(err error) {
fatalErr = err
}
// Execute the pattern from Main()
func() {
defer func() {
if r := recover(); r != nil {
mockLogError("recovered from panic: %v", r)
mockShutdown()
mockFatal(fmt.Errorf("panic: %v", r))
}
}()
panic("simulated crash")
}()
assert.Contains(t, logBuffer.String(), "recovered from panic: simulated crash")
assert.True(t, shutdownCalled, "Shutdown must be called on panic")
assert.NotNil(t, fatalErr, "Fatal must be called with error")
assert.Equal(t, "panic: simulated crash", fatalErr.Error())
})
}

91
pkg/cli/check.go Normal file
View file

@ -0,0 +1,91 @@
package cli
import "fmt"
// CheckBuilder provides fluent API for check results.
type CheckBuilder struct {
name string
status string
style *AnsiStyle
icon string
duration string
}
// Check starts building a check result line.
//
// cli.Check("audit").Pass()
// cli.Check("fmt").Fail().Duration("2.3s")
// cli.Check("test").Skip()
func Check(name string) *CheckBuilder {
return &CheckBuilder{name: name}
}
// Pass marks the check as passed.
func (c *CheckBuilder) Pass() *CheckBuilder {
c.status = "passed"
c.style = SuccessStyle
c.icon = Glyph(":check:")
return c
}
// Fail marks the check as failed.
func (c *CheckBuilder) Fail() *CheckBuilder {
c.status = "failed"
c.style = ErrorStyle
c.icon = Glyph(":cross:")
return c
}
// Skip marks the check as skipped.
func (c *CheckBuilder) Skip() *CheckBuilder {
c.status = "skipped"
c.style = DimStyle
c.icon = "-"
return c
}
// Warn marks the check as warning.
func (c *CheckBuilder) Warn() *CheckBuilder {
c.status = "warning"
c.style = WarningStyle
c.icon = Glyph(":warn:")
return c
}
// Duration adds duration to the check result.
func (c *CheckBuilder) Duration(d string) *CheckBuilder {
c.duration = d
return c
}
// Message adds a custom message instead of status.
func (c *CheckBuilder) Message(msg string) *CheckBuilder {
c.status = msg
return c
}
// String returns the formatted check line.
func (c *CheckBuilder) String() string {
icon := c.icon
if c.style != nil {
icon = c.style.Render(c.icon)
}
status := c.status
if c.style != nil && c.status != "" {
status = c.style.Render(c.status)
}
if c.duration != "" {
return fmt.Sprintf(" %s %-20s %-10s %s", icon, c.name, status, DimStyle.Render(c.duration))
}
if status != "" {
return fmt.Sprintf(" %s %s %s", icon, c.name, status)
}
return fmt.Sprintf(" %s %s", icon, c.name)
}
// Print outputs the check result.
func (c *CheckBuilder) Print() {
fmt.Println(c.String())
}

49
pkg/cli/check_test.go Normal file
View file

@ -0,0 +1,49 @@
package cli
import "testing"
func TestCheckBuilder(t *testing.T) {
UseASCII() // Deterministic output
// Pass
c := Check("foo").Pass()
got := c.String()
if got == "" {
t.Error("Empty output for Pass")
}
// Fail
c = Check("foo").Fail()
got = c.String()
if got == "" {
t.Error("Empty output for Fail")
}
// Skip
c = Check("foo").Skip()
got = c.String()
if got == "" {
t.Error("Empty output for Skip")
}
// Warn
c = Check("foo").Warn()
got = c.String()
if got == "" {
t.Error("Empty output for Warn")
}
// Duration
c = Check("foo").Pass().Duration("1s")
got = c.String()
if got == "" {
t.Error("Empty output for Duration")
}
// Message
c = Check("foo").Message("status")
got = c.String()
if got == "" {
t.Error("Empty output for Message")
}
}

210
pkg/cli/command.go Normal file
View file

@ -0,0 +1,210 @@
package cli
import (
"github.com/spf13/cobra"
)
// ─────────────────────────────────────────────────────────────────────────────
// Command Type Re-export
// ─────────────────────────────────────────────────────────────────────────────
// Command is the cobra command type.
// Re-exported for convenience so packages don't need to import cobra directly.
type Command = cobra.Command
// ─────────────────────────────────────────────────────────────────────────────
// Command Builders
// ─────────────────────────────────────────────────────────────────────────────
// NewCommand creates a new command with a RunE handler.
// This is the standard way to create commands that may return errors.
//
// cmd := cli.NewCommand("build", "Build the project", "", func(cmd *cli.Command, args []string) error {
// // Build logic
// return nil
// })
func NewCommand(use, short, long string, run func(cmd *Command, args []string) error) *Command {
cmd := &Command{
Use: use,
Short: short,
RunE: run,
}
if long != "" {
cmd.Long = long
}
return cmd
}
// NewGroup creates a new command group (no RunE).
// Use this for parent commands that only contain subcommands.
//
// devCmd := cli.NewGroup("dev", "Development commands", "")
// devCmd.AddCommand(buildCmd, testCmd)
func NewGroup(use, short, long string) *Command {
cmd := &Command{
Use: use,
Short: short,
}
if long != "" {
cmd.Long = long
}
return cmd
}
// NewRun creates a new command with a simple Run handler (no error return).
// Use when the command cannot fail.
//
// cmd := cli.NewRun("version", "Show version", "", func(cmd *cli.Command, args []string) {
// cli.Println("v1.0.0")
// })
func NewRun(use, short, long string, run func(cmd *Command, args []string)) *Command {
cmd := &Command{
Use: use,
Short: short,
Run: run,
}
if long != "" {
cmd.Long = long
}
return cmd
}
// NewPassthrough creates a command that passes all arguments (including flags)
// to the given function. Used for commands that do their own flag parsing
// (e.g. incremental migration from flag.FlagSet to cobra).
//
// cmd := cli.NewPassthrough("train", "Train a model", func(args []string) {
// // args includes all flags: ["--model", "gemma-3-1b", "--epochs", "10"]
// fs := flag.NewFlagSet("train", flag.ExitOnError)
// // ...
// })
func NewPassthrough(use, short string, fn func(args []string)) *Command {
cmd := NewRun(use, short, "", func(_ *Command, args []string) {
fn(args)
})
cmd.DisableFlagParsing = true
return cmd
}
// ─────────────────────────────────────────────────────────────────────────────
// Flag Helpers
// ─────────────────────────────────────────────────────────────────────────────
// StringFlag adds a string flag to a command.
// The value will be stored in the provided pointer.
//
// var output string
// cli.StringFlag(cmd, &output, "output", "o", "", "Output file path")
func StringFlag(cmd *Command, ptr *string, name, short, def, usage string) {
if short != "" {
cmd.Flags().StringVarP(ptr, name, short, def, usage)
} else {
cmd.Flags().StringVar(ptr, name, def, usage)
}
}
// BoolFlag adds a boolean flag to a command.
// The value will be stored in the provided pointer.
//
// var verbose bool
// cli.BoolFlag(cmd, &verbose, "verbose", "v", false, "Enable verbose output")
func BoolFlag(cmd *Command, ptr *bool, name, short string, def bool, usage string) {
if short != "" {
cmd.Flags().BoolVarP(ptr, name, short, def, usage)
} else {
cmd.Flags().BoolVar(ptr, name, def, usage)
}
}
// IntFlag adds an integer flag to a command.
// The value will be stored in the provided pointer.
//
// var count int
// cli.IntFlag(cmd, &count, "count", "n", 10, "Number of items")
func IntFlag(cmd *Command, ptr *int, name, short string, def int, usage string) {
if short != "" {
cmd.Flags().IntVarP(ptr, name, short, def, usage)
} else {
cmd.Flags().IntVar(ptr, name, def, usage)
}
}
// StringSliceFlag adds a string slice flag to a command.
// The value will be stored in the provided pointer.
//
// var tags []string
// cli.StringSliceFlag(cmd, &tags, "tag", "t", nil, "Tags to apply")
func StringSliceFlag(cmd *Command, ptr *[]string, name, short string, def []string, usage string) {
if short != "" {
cmd.Flags().StringSliceVarP(ptr, name, short, def, usage)
} else {
cmd.Flags().StringSliceVar(ptr, name, def, usage)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Persistent Flag Helpers
// ─────────────────────────────────────────────────────────────────────────────
// PersistentStringFlag adds a persistent string flag (inherited by subcommands).
func PersistentStringFlag(cmd *Command, ptr *string, name, short, def, usage string) {
if short != "" {
cmd.PersistentFlags().StringVarP(ptr, name, short, def, usage)
} else {
cmd.PersistentFlags().StringVar(ptr, name, def, usage)
}
}
// PersistentBoolFlag adds a persistent boolean flag (inherited by subcommands).
func PersistentBoolFlag(cmd *Command, ptr *bool, name, short string, def bool, usage string) {
if short != "" {
cmd.PersistentFlags().BoolVarP(ptr, name, short, def, usage)
} else {
cmd.PersistentFlags().BoolVar(ptr, name, def, usage)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Command Configuration
// ─────────────────────────────────────────────────────────────────────────────
// WithArgs sets the Args validation function for a command.
// Returns the command for chaining.
//
// cmd := cli.NewCommand("build", "Build", "", run).WithArgs(cobra.ExactArgs(1))
func WithArgs(cmd *Command, args cobra.PositionalArgs) *Command {
cmd.Args = args
return cmd
}
// WithExample sets the Example field for a command.
// Returns the command for chaining.
func WithExample(cmd *Command, example string) *Command {
cmd.Example = example
return cmd
}
// ExactArgs returns a PositionalArgs that accepts exactly N arguments.
func ExactArgs(n int) cobra.PositionalArgs {
return cobra.ExactArgs(n)
}
// MinimumNArgs returns a PositionalArgs that accepts minimum N arguments.
func MinimumNArgs(n int) cobra.PositionalArgs {
return cobra.MinimumNArgs(n)
}
// MaximumNArgs returns a PositionalArgs that accepts maximum N arguments.
func MaximumNArgs(n int) cobra.PositionalArgs {
return cobra.MaximumNArgs(n)
}
// NoArgs returns a PositionalArgs that accepts no arguments.
func NoArgs() cobra.PositionalArgs {
return cobra.NoArgs
}
// ArbitraryArgs returns a PositionalArgs that accepts any arguments.
func ArbitraryArgs() cobra.PositionalArgs {
return cobra.ArbitraryArgs
}

50
pkg/cli/commands.go Normal file
View file

@ -0,0 +1,50 @@
// Package cli provides the CLI runtime and utilities.
package cli
import (
"sync"
"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.
//
// 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)
}
}
// 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
}

185
pkg/cli/commands_test.go Normal file
View file

@ -0,0 +1,185 @@
package cli
import (
"sync"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// resetGlobals clears the CLI singleton and command registry for test isolation.
func resetGlobals(t *testing.T) {
t.Helper()
t.Cleanup(func() {
// Restore clean state after each test.
registeredCommandsMu.Lock()
registeredCommands = nil
commandsAttached = false
registeredCommandsMu.Unlock()
if instance != nil {
Shutdown()
}
instance = nil
once = sync.Once{}
})
registeredCommandsMu.Lock()
registeredCommands = nil
commandsAttached = false
registeredCommandsMu.Unlock()
if instance != nil {
Shutdown()
}
instance = nil
once = sync.Once{}
}
// TestRegisterCommands_Good tests the happy path for command registration.
func TestRegisterCommands_Good(t *testing.T) {
t.Run("registers on startup", func(t *testing.T) {
resetGlobals(t)
RegisterCommands(func(root *cobra.Command) {
root.AddCommand(&cobra.Command{Use: "hello", Short: "Say hello"})
})
err := Init(Options{AppName: "test"})
require.NoError(t, err)
// The "hello" command should be on the root.
cmd, _, err := RootCmd().Find([]string{"hello"})
require.NoError(t, err)
assert.Equal(t, "hello", cmd.Use)
})
t.Run("multiple groups compose", func(t *testing.T) {
resetGlobals(t)
RegisterCommands(func(root *cobra.Command) {
root.AddCommand(&cobra.Command{Use: "alpha", Short: "Alpha"})
})
RegisterCommands(func(root *cobra.Command) {
root.AddCommand(&cobra.Command{Use: "beta", Short: "Beta"})
})
err := Init(Options{AppName: "test"})
require.NoError(t, err)
for _, name := range []string{"alpha", "beta"} {
cmd, _, err := RootCmd().Find([]string{name})
require.NoError(t, err)
assert.Equal(t, name, cmd.Use)
}
})
t.Run("group with subcommands", func(t *testing.T) {
resetGlobals(t)
RegisterCommands(func(root *cobra.Command) {
grp := &cobra.Command{Use: "ml", Short: "ML commands"}
grp.AddCommand(&cobra.Command{Use: "train", Short: "Train a model"})
grp.AddCommand(&cobra.Command{Use: "serve", Short: "Serve a model"})
root.AddCommand(grp)
})
err := Init(Options{AppName: "test"})
require.NoError(t, err)
cmd, _, err := RootCmd().Find([]string{"ml", "train"})
require.NoError(t, err)
assert.Equal(t, "train", cmd.Use)
cmd, _, err = RootCmd().Find([]string{"ml", "serve"})
require.NoError(t, err)
assert.Equal(t, "serve", cmd.Use)
})
t.Run("executes registered command", func(t *testing.T) {
resetGlobals(t)
executed := false
RegisterCommands(func(root *cobra.Command) {
root.AddCommand(&cobra.Command{
Use: "ping",
Short: "Ping",
RunE: func(_ *cobra.Command, _ []string) error {
executed = true
return nil
},
})
})
err := Init(Options{AppName: "test"})
require.NoError(t, err)
RootCmd().SetArgs([]string{"ping"})
err = Execute()
require.NoError(t, err)
assert.True(t, executed, "registered command should have been executed")
})
}
// TestRegisterCommands_Bad tests expected error conditions.
func TestRegisterCommands_Bad(t *testing.T) {
t.Run("late registration attaches immediately", func(t *testing.T) {
resetGlobals(t)
err := Init(Options{AppName: "test"})
require.NoError(t, err)
// Register after Init — should attach immediately.
RegisterCommands(func(root *cobra.Command) {
root.AddCommand(&cobra.Command{Use: "late", Short: "Late arrival"})
})
cmd, _, err := RootCmd().Find([]string{"late"})
require.NoError(t, err)
assert.Equal(t, "late", cmd.Use)
})
}
// TestWithAppName_Good tests the app name override.
func TestWithAppName_Good(t *testing.T) {
t.Run("overrides root command use", func(t *testing.T) {
resetGlobals(t)
WithAppName("lem")
defer WithAppName("core") // restore
err := Init(Options{AppName: AppName})
require.NoError(t, err)
assert.Equal(t, "lem", RootCmd().Use)
})
t.Run("default is core", func(t *testing.T) {
resetGlobals(t)
err := Init(Options{AppName: AppName})
require.NoError(t, err)
assert.Equal(t, "core", RootCmd().Use)
})
}
// TestNewPassthrough_Good tests the passthrough command builder.
func TestNewPassthrough_Good(t *testing.T) {
t.Run("passes all args including flags", func(t *testing.T) {
var received []string
cmd := NewPassthrough("train", "Train", func(args []string) {
received = args
})
cmd.SetArgs([]string{"--model", "gemma", "--epochs", "10"})
err := cmd.Execute()
require.NoError(t, err)
assert.Equal(t, []string{"--model", "gemma", "--epochs", "10"}, received)
})
t.Run("flag parsing is disabled", func(t *testing.T) {
cmd := NewPassthrough("run", "Run", func(_ []string) {})
assert.True(t, cmd.DisableFlagParsing)
})
}

446
pkg/cli/daemon.go Normal file
View file

@ -0,0 +1,446 @@
// Package cli provides the CLI runtime and utilities.
package cli
import (
"context"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"syscall"
"time"
"forge.lthn.ai/core/go/pkg/io"
"golang.org/x/term"
)
// Mode represents the CLI execution mode.
type Mode int
const (
// ModeInteractive indicates TTY attached with coloured output.
ModeInteractive Mode = iota
// ModePipe indicates stdout is piped, colours disabled.
ModePipe
// ModeDaemon indicates headless execution, log-only output.
ModeDaemon
)
// String returns the string representation of the Mode.
func (m Mode) String() string {
switch m {
case ModeInteractive:
return "interactive"
case ModePipe:
return "pipe"
case ModeDaemon:
return "daemon"
default:
return "unknown"
}
}
// DetectMode determines the execution mode based on environment.
// Checks CORE_DAEMON env var first, then TTY status.
func DetectMode() Mode {
if os.Getenv("CORE_DAEMON") == "1" {
return ModeDaemon
}
if !IsTTY() {
return ModePipe
}
return ModeInteractive
}
// IsTTY returns true if stdout is a terminal.
func IsTTY() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
}
// IsStdinTTY returns true if stdin is a terminal.
func IsStdinTTY() bool {
return term.IsTerminal(int(os.Stdin.Fd()))
}
// IsStderrTTY returns true if stderr is a terminal.
func IsStderrTTY() bool {
return term.IsTerminal(int(os.Stderr.Fd()))
}
// --- PID File Management ---
// PIDFile manages a process ID file for single-instance enforcement.
type PIDFile struct {
path string
mu sync.Mutex
}
// NewPIDFile creates a PID file manager.
func NewPIDFile(path string) *PIDFile {
return &PIDFile{path: path}
}
// Acquire writes the current PID to the file.
// Returns error if another instance is running.
func (p *PIDFile) Acquire() error {
p.mu.Lock()
defer p.mu.Unlock()
// Check if PID file exists
if data, err := io.Local.Read(p.path); err == nil {
pid, err := strconv.Atoi(data)
if err == nil && pid > 0 {
// Check if process is still running
if process, err := os.FindProcess(pid); err == nil {
if err := process.Signal(syscall.Signal(0)); err == nil {
return fmt.Errorf("another instance is running (PID %d)", pid)
}
}
}
// Stale PID file, remove it
_ = io.Local.Delete(p.path)
}
// Ensure directory exists
if dir := filepath.Dir(p.path); dir != "." {
if err := io.Local.EnsureDir(dir); err != nil {
return fmt.Errorf("failed to create PID directory: %w", err)
}
}
// Write current PID
pid := os.Getpid()
if err := io.Local.Write(p.path, strconv.Itoa(pid)); err != nil {
return fmt.Errorf("failed to write PID file: %w", err)
}
return nil
}
// Release removes the PID file.
func (p *PIDFile) Release() error {
p.mu.Lock()
defer p.mu.Unlock()
return io.Local.Delete(p.path)
}
// Path returns the PID file path.
func (p *PIDFile) Path() string {
return p.path
}
// --- Health Check Server ---
// HealthServer provides a minimal HTTP health check endpoint.
type HealthServer struct {
addr string
server *http.Server
listener net.Listener
mu sync.Mutex
ready bool
checks []HealthCheck
}
// HealthCheck is a function that returns nil if healthy.
type HealthCheck func() error
// NewHealthServer creates a health check server.
func NewHealthServer(addr string) *HealthServer {
return &HealthServer{
addr: addr,
ready: true,
}
}
// AddCheck registers a health check function.
func (h *HealthServer) AddCheck(check HealthCheck) {
h.mu.Lock()
h.checks = append(h.checks, check)
h.mu.Unlock()
}
// SetReady sets the readiness status.
func (h *HealthServer) SetReady(ready bool) {
h.mu.Lock()
h.ready = ready
h.mu.Unlock()
}
// Start begins serving health check endpoints.
// Endpoints:
// - /health - liveness probe (always 200 if server is up)
// - /ready - readiness probe (200 if ready, 503 if not)
func (h *HealthServer) Start() error {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
h.mu.Lock()
checks := h.checks
h.mu.Unlock()
for _, check := range checks {
if err := check(); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = fmt.Fprintf(w, "unhealthy: %v\n", err)
return
}
}
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintln(w, "ok")
})
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
h.mu.Lock()
ready := h.ready
h.mu.Unlock()
if !ready {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = fmt.Fprintln(w, "not ready")
return
}
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintln(w, "ready")
})
listener, err := net.Listen("tcp", h.addr)
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", h.addr, err)
}
h.listener = listener
h.server = &http.Server{Handler: mux}
go func() {
if err := h.server.Serve(listener); err != http.ErrServerClosed {
LogError(fmt.Sprintf("health server error: %v", err))
}
}()
return nil
}
// Stop gracefully shuts down the health server.
func (h *HealthServer) Stop(ctx context.Context) error {
if h.server == nil {
return nil
}
return h.server.Shutdown(ctx)
}
// Addr returns the actual address the server is listening on.
// Useful when using port 0 for dynamic port assignment.
func (h *HealthServer) Addr() string {
if h.listener != nil {
return h.listener.Addr().String()
}
return h.addr
}
// --- Daemon Runner ---
// DaemonOptions configures daemon mode execution.
type DaemonOptions struct {
// PIDFile path for single-instance enforcement.
// Leave empty to skip PID file management.
PIDFile string
// ShutdownTimeout is the maximum time to wait for graceful shutdown.
// Default: 30 seconds.
ShutdownTimeout time.Duration
// HealthAddr is the address for health check endpoints.
// Example: ":8080", "127.0.0.1:9000"
// Leave empty to disable health checks.
HealthAddr string
// HealthChecks are additional health check functions.
HealthChecks []HealthCheck
// OnReload is called when SIGHUP is received.
// Use for config reloading. Leave nil to ignore SIGHUP.
OnReload func() error
}
// Daemon manages daemon lifecycle.
type Daemon struct {
opts DaemonOptions
pid *PIDFile
health *HealthServer
reload chan struct{}
running bool
mu sync.Mutex
}
// NewDaemon creates a daemon runner with the given options.
func NewDaemon(opts DaemonOptions) *Daemon {
if opts.ShutdownTimeout == 0 {
opts.ShutdownTimeout = 30 * time.Second
}
d := &Daemon{
opts: opts,
reload: make(chan struct{}, 1),
}
if opts.PIDFile != "" {
d.pid = NewPIDFile(opts.PIDFile)
}
if opts.HealthAddr != "" {
d.health = NewHealthServer(opts.HealthAddr)
for _, check := range opts.HealthChecks {
d.health.AddCheck(check)
}
}
return d
}
// Start initialises the daemon (PID file, health server).
// Call this after cli.Init().
func (d *Daemon) Start() error {
d.mu.Lock()
defer d.mu.Unlock()
if d.running {
return fmt.Errorf("daemon already running")
}
// Acquire PID file
if d.pid != nil {
if err := d.pid.Acquire(); err != nil {
return err
}
}
// Start health server
if d.health != nil {
if err := d.health.Start(); err != nil {
if d.pid != nil {
_ = d.pid.Release()
}
return err
}
}
d.running = true
return nil
}
// Run blocks until the context is cancelled or a signal is received.
// Handles graceful shutdown with the configured timeout.
func (d *Daemon) Run(ctx context.Context) error {
d.mu.Lock()
if !d.running {
d.mu.Unlock()
return fmt.Errorf("daemon not started - call Start() first")
}
d.mu.Unlock()
// Wait for context cancellation (from signal handler)
<-ctx.Done()
return d.Stop()
}
// Stop performs graceful shutdown.
func (d *Daemon) Stop() error {
d.mu.Lock()
defer d.mu.Unlock()
if !d.running {
return nil
}
var errs []error
// Create shutdown context with timeout
shutdownCtx, cancel := context.WithTimeout(context.Background(), d.opts.ShutdownTimeout)
defer cancel()
// Stop health server
if d.health != nil {
d.health.SetReady(false)
if err := d.health.Stop(shutdownCtx); err != nil {
errs = append(errs, fmt.Errorf("health server: %w", err))
}
}
// Release PID file
if d.pid != nil {
if err := d.pid.Release(); err != nil && !os.IsNotExist(err) {
errs = append(errs, fmt.Errorf("pid file: %w", err))
}
}
d.running = false
if len(errs) > 0 {
return fmt.Errorf("shutdown errors: %v", errs)
}
return nil
}
// SetReady sets the daemon readiness status for health checks.
func (d *Daemon) SetReady(ready bool) {
if d.health != nil {
d.health.SetReady(ready)
}
}
// HealthAddr returns the health server address, or empty if disabled.
func (d *Daemon) HealthAddr() string {
if d.health != nil {
return d.health.Addr()
}
return ""
}
// --- Convenience Functions ---
// Run blocks until context is cancelled or signal received.
// Simple helper for daemon mode without advanced features.
//
// cli.Init(cli.Options{AppName: "myapp"})
// defer cli.Shutdown()
// cli.Run(cli.Context())
func Run(ctx context.Context) error {
mustInit()
<-ctx.Done()
return ctx.Err()
}
// RunWithTimeout wraps Run with a graceful shutdown timeout.
// The returned function should be deferred to replace cli.Shutdown().
//
// cli.Init(cli.Options{AppName: "myapp"})
// shutdown := cli.RunWithTimeout(30 * time.Second)
// defer shutdown()
// cli.Run(cli.Context())
func RunWithTimeout(timeout time.Duration) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// Create done channel for shutdown completion
done := make(chan struct{})
go func() {
Shutdown()
close(done)
}()
select {
case <-done:
// Clean shutdown
case <-ctx.Done():
// Timeout - force exit
LogWarn("shutdown timeout exceeded, forcing exit")
}
}
}

234
pkg/cli/daemon_test.go Normal file
View file

@ -0,0 +1,234 @@
package cli
import (
"context"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDetectMode(t *testing.T) {
t.Run("daemon mode from env", func(t *testing.T) {
t.Setenv("CORE_DAEMON", "1")
assert.Equal(t, ModeDaemon, DetectMode())
})
t.Run("mode string", func(t *testing.T) {
assert.Equal(t, "interactive", ModeInteractive.String())
assert.Equal(t, "pipe", ModePipe.String())
assert.Equal(t, "daemon", ModeDaemon.String())
assert.Equal(t, "unknown", Mode(99).String())
})
}
func TestPIDFile(t *testing.T) {
t.Run("acquire and release", func(t *testing.T) {
pidPath := filepath.Join(t.TempDir(), "test.pid")
pid := NewPIDFile(pidPath)
err := pid.Acquire()
require.NoError(t, err)
err = pid.Release()
require.NoError(t, err)
})
t.Run("stale pid file", func(t *testing.T) {
pidPath := filepath.Join(t.TempDir(), "stale.pid")
// Write a stale PID (non-existent process).
require.NoError(t, os.WriteFile(pidPath, []byte("999999999"), 0644))
pid := NewPIDFile(pidPath)
// Should acquire successfully (stale PID removed).
err := pid.Acquire()
require.NoError(t, err)
err = pid.Release()
require.NoError(t, err)
})
t.Run("creates parent directory", func(t *testing.T) {
pidPath := filepath.Join(t.TempDir(), "subdir", "nested", "test.pid")
pid := NewPIDFile(pidPath)
err := pid.Acquire()
require.NoError(t, err)
err = pid.Release()
require.NoError(t, err)
})
t.Run("path getter", func(t *testing.T) {
pid := NewPIDFile("/tmp/test.pid")
assert.Equal(t, "/tmp/test.pid", pid.Path())
})
}
func TestHealthServer(t *testing.T) {
t.Run("health and ready endpoints", func(t *testing.T) {
hs := NewHealthServer("127.0.0.1:0") // Random port
err := hs.Start()
require.NoError(t, err)
defer func() { _ = hs.Stop(context.Background()) }()
addr := hs.Addr()
require.NotEmpty(t, addr)
// Health should be OK
resp, err := http.Get("http://" + addr + "/health")
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
_ = resp.Body.Close()
// Ready should be OK by default
resp, err = http.Get("http://" + addr + "/ready")
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
_ = resp.Body.Close()
// Set not ready
hs.SetReady(false)
resp, err = http.Get("http://" + addr + "/ready")
require.NoError(t, err)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
_ = resp.Body.Close()
})
t.Run("with health checks", func(t *testing.T) {
hs := NewHealthServer("127.0.0.1:0")
healthy := true
hs.AddCheck(func() error {
if !healthy {
return assert.AnError
}
return nil
})
err := hs.Start()
require.NoError(t, err)
defer func() { _ = hs.Stop(context.Background()) }()
addr := hs.Addr()
// Should be healthy
resp, err := http.Get("http://" + addr + "/health")
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
_ = resp.Body.Close()
// Make unhealthy
healthy = false
resp, err = http.Get("http://" + addr + "/health")
require.NoError(t, err)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
_ = resp.Body.Close()
})
}
func TestDaemon(t *testing.T) {
t.Run("start and stop", func(t *testing.T) {
pidPath := filepath.Join(t.TempDir(), "test.pid")
d := NewDaemon(DaemonOptions{
PIDFile: pidPath,
HealthAddr: "127.0.0.1:0",
ShutdownTimeout: 5 * time.Second,
})
err := d.Start()
require.NoError(t, err)
// Health server should be running
addr := d.HealthAddr()
require.NotEmpty(t, addr)
resp, err := http.Get("http://" + addr + "/health")
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
_ = resp.Body.Close()
// Stop should succeed
err = d.Stop()
require.NoError(t, err)
})
t.Run("double start fails", func(t *testing.T) {
d := NewDaemon(DaemonOptions{
HealthAddr: "127.0.0.1:0",
})
err := d.Start()
require.NoError(t, err)
defer func() { _ = d.Stop() }()
err = d.Start()
assert.Error(t, err)
assert.Contains(t, err.Error(), "already running")
})
t.Run("run without start fails", func(t *testing.T) {
d := NewDaemon(DaemonOptions{})
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := d.Run(ctx)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not started")
})
t.Run("set ready", func(t *testing.T) {
d := NewDaemon(DaemonOptions{
HealthAddr: "127.0.0.1:0",
})
err := d.Start()
require.NoError(t, err)
defer func() { _ = d.Stop() }()
addr := d.HealthAddr()
// Initially ready
resp, _ := http.Get("http://" + addr + "/ready")
assert.Equal(t, http.StatusOK, resp.StatusCode)
_ = resp.Body.Close()
// Set not ready
d.SetReady(false)
resp, _ = http.Get("http://" + addr + "/ready")
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
_ = resp.Body.Close()
})
t.Run("no health addr returns empty", func(t *testing.T) {
d := NewDaemon(DaemonOptions{})
assert.Empty(t, d.HealthAddr())
})
t.Run("default shutdown timeout", func(t *testing.T) {
d := NewDaemon(DaemonOptions{})
assert.Equal(t, 30*time.Second, d.opts.ShutdownTimeout)
})
}
func TestRunWithTimeout(t *testing.T) {
t.Run("creates shutdown function", func(t *testing.T) {
// Just test that it returns a function
shutdown := RunWithTimeout(100 * time.Millisecond)
assert.NotNil(t, shutdown)
})
}

162
pkg/cli/errors.go Normal file
View file

@ -0,0 +1,162 @@
package cli
import (
"errors"
"fmt"
"os"
"forge.lthn.ai/core/go/pkg/i18n"
)
// ─────────────────────────────────────────────────────────────────────────────
// Error Creation (replace fmt.Errorf)
// ─────────────────────────────────────────────────────────────────────────────
// Err creates a new error from a format string.
// This is a direct replacement for fmt.Errorf.
func Err(format string, args ...any) error {
return fmt.Errorf(format, args...)
}
// Wrap wraps an error with a message.
// Returns nil if err is nil.
//
// return cli.Wrap(err, "load config") // "load config: <original error>"
func Wrap(err error, msg string) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", msg, err)
}
// WrapVerb wraps an error using i18n grammar for "Failed to verb subject".
// Uses the i18n.ActionFailed function for proper grammar composition.
// Returns nil if err is nil.
//
// return cli.WrapVerb(err, "load", "config") // "Failed to load config: <original error>"
func WrapVerb(err error, verb, subject string) error {
if err == nil {
return nil
}
msg := i18n.ActionFailed(verb, subject)
return fmt.Errorf("%s: %w", msg, err)
}
// WrapAction wraps an error using i18n grammar for "Failed to verb".
// Uses the i18n.ActionFailed function for proper grammar composition.
// Returns nil if err is nil.
//
// return cli.WrapAction(err, "connect") // "Failed to connect: <original error>"
func WrapAction(err error, verb string) error {
if err == nil {
return nil
}
msg := i18n.ActionFailed(verb, "")
return fmt.Errorf("%s: %w", msg, err)
}
// ─────────────────────────────────────────────────────────────────────────────
// Error Helpers
// ─────────────────────────────────────────────────────────────────────────────
// Is reports whether any error in err's tree matches target.
// This is a re-export of errors.Is for convenience.
func Is(err, target error) bool {
return errors.Is(err, target)
}
// As finds the first error in err's tree that matches target.
// This is a re-export of errors.As for convenience.
func As(err error, target any) bool {
return errors.As(err, target)
}
// Join returns an error that wraps the given errors.
// This is a re-export of errors.Join for convenience.
func Join(errs ...error) error {
return errors.Join(errs...)
}
// ExitError represents an error that should cause the CLI to exit with a specific code.
type ExitError struct {
Code int
Err error
}
func (e *ExitError) Error() string {
if e.Err == nil {
return ""
}
return e.Err.Error()
}
func (e *ExitError) Unwrap() error {
return e.Err
}
// Exit creates a new ExitError with the given code and error.
// Use this to return an error from a command with a specific exit code.
func Exit(code int, err error) error {
if err == nil {
return nil
}
return &ExitError{Code: code, Err: err}
}
// ─────────────────────────────────────────────────────────────────────────────
// Fatal Functions (Deprecated - return error from command instead)
// ─────────────────────────────────────────────────────────────────────────────
// Fatal prints an error message to stderr, logs it, and exits with code 1.
//
// Deprecated: return an error from the command instead.
func Fatal(err error) {
if err != nil {
LogError("Fatal error", "err", err)
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
os.Exit(1)
}
}
// Fatalf prints a formatted error message to stderr, logs it, and exits with code 1.
//
// Deprecated: return an error from the command instead.
func Fatalf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
LogError("Fatal error", "msg", msg)
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg))
os.Exit(1)
}
// FatalWrap prints a wrapped error message to stderr, logs it, and exits with code 1.
// Does nothing if err is nil.
//
// Deprecated: return an error from the command instead.
//
// cli.FatalWrap(err, "load config") // Prints "✗ load config: <error>" and exits
func FatalWrap(err error, msg string) {
if err == nil {
return
}
LogError("Fatal error", "msg", msg, "err", err)
fullMsg := fmt.Sprintf("%s: %v", msg, err)
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
os.Exit(1)
}
// FatalWrapVerb prints a wrapped error using i18n grammar to stderr, logs it, and exits with code 1.
// Does nothing if err is nil.
//
// Deprecated: return an error from the command instead.
//
// cli.FatalWrapVerb(err, "load", "config") // Prints "✗ Failed to load config: <error>" and exits
func FatalWrapVerb(err error, verb, subject string) {
if err == nil {
return
}
msg := i18n.ActionFailed(verb, subject)
LogError("Fatal error", "msg", msg, "err", err, "verb", verb, "subject", subject)
fullMsg := fmt.Sprintf("%s: %v", msg, err)
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
os.Exit(1)
}

358
pkg/cli/frame.go Normal file
View file

@ -0,0 +1,358 @@
package cli
import (
"fmt"
"io"
"os"
"strings"
"sync"
"time"
"golang.org/x/term"
)
// Model is the interface for components that slot into Frame regions.
// View receives the allocated width and height and returns rendered text.
type Model interface {
View(width, height int) string
}
// ModelFunc is a convenience adapter for using a function as a Model.
type ModelFunc func(width, height int) string
// View implements Model.
func (f ModelFunc) View(width, height int) string { return f(width, height) }
// Frame is a live compositional AppShell for TUI.
// Uses HLCRF variant strings for region layout — same as the static Layout system,
// but with live-updating Model components instead of static strings.
//
// frame := cli.NewFrame("HCF")
// frame.Header(cli.StatusLine("core dev", "18 repos", "main"))
// frame.Content(myTableModel)
// frame.Footer(cli.KeyHints("↑/↓ navigate", "enter select", "q quit"))
// frame.Run()
type Frame struct {
variant string
layout *Composite
models map[Region]Model
history []Model // content region stack for Navigate/Back
out io.Writer
done chan struct{}
mu sync.Mutex
}
// NewFrame creates a new Frame with the given HLCRF variant string.
//
// frame := cli.NewFrame("HCF") // header, content, footer
// frame := cli.NewFrame("H[LC]F") // header, [left + content], footer
func NewFrame(variant string) *Frame {
return &Frame{
variant: variant,
layout: Layout(variant),
models: make(map[Region]Model),
out: os.Stdout,
done: make(chan struct{}),
}
}
// Header sets the Header region model.
func (f *Frame) Header(m Model) *Frame { f.setModel(RegionHeader, m); return f }
// Left sets the Left sidebar region model.
func (f *Frame) Left(m Model) *Frame { f.setModel(RegionLeft, m); return f }
// Content sets the Content region model.
func (f *Frame) Content(m Model) *Frame { f.setModel(RegionContent, m); return f }
// Right sets the Right sidebar region model.
func (f *Frame) Right(m Model) *Frame { f.setModel(RegionRight, m); return f }
// Footer sets the Footer region model.
func (f *Frame) Footer(m Model) *Frame { f.setModel(RegionFooter, m); return f }
func (f *Frame) setModel(r Region, m Model) {
f.mu.Lock()
defer f.mu.Unlock()
f.models[r] = m
}
// Navigate replaces the Content region with a new model, pushing the current one
// onto the history stack for Back().
func (f *Frame) Navigate(m Model) {
f.mu.Lock()
defer f.mu.Unlock()
if current, ok := f.models[RegionContent]; ok {
f.history = append(f.history, current)
}
f.models[RegionContent] = m
}
// Back pops the content history stack, restoring the previous Content model.
// Returns false if the history is empty.
func (f *Frame) Back() bool {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.history) == 0 {
return false
}
f.models[RegionContent] = f.history[len(f.history)-1]
f.history = f.history[:len(f.history)-1]
return true
}
// Stop signals the Frame to exit its Run loop.
func (f *Frame) Stop() {
select {
case <-f.done:
default:
close(f.done)
}
}
// Run renders the frame and blocks. In TTY mode, it live-refreshes at ~12fps.
// In non-TTY mode, it renders once and returns immediately.
func (f *Frame) Run() {
if !f.isTTY() {
fmt.Fprint(f.out, f.String())
return
}
f.runLive()
}
// RunFor runs the frame for a fixed duration, then stops.
// Useful for dashboards that refresh periodically.
func (f *Frame) RunFor(d time.Duration) {
go func() {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
f.Stop()
case <-f.done:
}
}()
f.Run()
}
// String renders the frame as a static string (no ANSI, no live updates).
// This is the non-TTY fallback path.
func (f *Frame) String() string {
f.mu.Lock()
defer f.mu.Unlock()
w, h := f.termSize()
var sb strings.Builder
order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter}
for _, r := range order {
if _, exists := f.layout.regions[r]; !exists {
continue
}
m, ok := f.models[r]
if !ok {
continue
}
rw, rh := f.regionSize(r, w, h)
view := m.View(rw, rh)
if view != "" {
sb.WriteString(view)
if !strings.HasSuffix(view, "\n") {
sb.WriteByte('\n')
}
}
}
return sb.String()
}
func (f *Frame) isTTY() bool {
if file, ok := f.out.(*os.File); ok {
return term.IsTerminal(int(file.Fd()))
}
return false
}
func (f *Frame) termSize() (int, int) {
if file, ok := f.out.(*os.File); ok {
w, h, err := term.GetSize(int(file.Fd()))
if err == nil {
return w, h
}
}
return 80, 24 // sensible default
}
func (f *Frame) regionSize(r Region, totalW, totalH int) (int, int) {
// Simple allocation: Header/Footer get 1 line, sidebars get 1/4 width,
// Content gets the rest.
switch r {
case RegionHeader, RegionFooter:
return totalW, 1
case RegionLeft, RegionRight:
return totalW / 4, totalH - 2 // minus header + footer
case RegionContent:
sideW := 0
if _, ok := f.models[RegionLeft]; ok {
sideW += totalW / 4
}
if _, ok := f.models[RegionRight]; ok {
sideW += totalW / 4
}
return totalW - sideW, totalH - 2
}
return totalW, totalH
}
func (f *Frame) runLive() {
// Enter alt-screen.
fmt.Fprint(f.out, "\033[?1049h")
// Hide cursor.
fmt.Fprint(f.out, "\033[?25l")
defer func() {
// Show cursor.
fmt.Fprint(f.out, "\033[?25h")
// Leave alt-screen.
fmt.Fprint(f.out, "\033[?1049l")
}()
ticker := time.NewTicker(80 * time.Millisecond)
defer ticker.Stop()
for {
f.renderFrame()
select {
case <-f.done:
return
case <-ticker.C:
}
}
}
func (f *Frame) renderFrame() {
f.mu.Lock()
defer f.mu.Unlock()
w, h := f.termSize()
// Move to top-left.
fmt.Fprint(f.out, "\033[H")
// Clear screen.
fmt.Fprint(f.out, "\033[2J")
order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter}
for _, r := range order {
if _, exists := f.layout.regions[r]; !exists {
continue
}
m, ok := f.models[r]
if !ok {
continue
}
rw, rh := f.regionSize(r, w, h)
view := m.View(rw, rh)
if view != "" {
fmt.Fprint(f.out, view)
if !strings.HasSuffix(view, "\n") {
fmt.Fprintln(f.out)
}
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Built-in Region Components
// ─────────────────────────────────────────────────────────────────────────────
// statusLineModel renders a "title key:value key:value" bar.
type statusLineModel struct {
title string
pairs []string
}
// StatusLine creates a header/footer bar with a title and key:value pairs.
//
// frame.Header(cli.StatusLine("core dev", "18 repos", "main"))
func StatusLine(title string, pairs ...string) Model {
return &statusLineModel{title: title, pairs: pairs}
}
func (s *statusLineModel) View(width, _ int) string {
parts := []string{BoldStyle.Render(s.title)}
for _, p := range s.pairs {
parts = append(parts, DimStyle.Render(p))
}
line := strings.Join(parts, " ")
if width > 0 {
line = Truncate(line, width)
}
return line
}
// keyHintsModel renders keyboard shortcut hints.
type keyHintsModel struct {
hints []string
}
// KeyHints creates a footer showing keyboard shortcuts.
//
// frame.Footer(cli.KeyHints("↑/↓ navigate", "enter select", "q quit"))
func KeyHints(hints ...string) Model {
return &keyHintsModel{hints: hints}
}
func (k *keyHintsModel) View(width, _ int) string {
parts := make([]string, len(k.hints))
for i, h := range k.hints {
parts[i] = DimStyle.Render(h)
}
line := strings.Join(parts, " ")
if width > 0 {
line = Truncate(line, width)
}
return line
}
// breadcrumbModel renders a navigation path.
type breadcrumbModel struct {
parts []string
}
// Breadcrumb creates a navigation breadcrumb bar.
//
// frame.Header(cli.Breadcrumb("core", "dev", "health"))
func Breadcrumb(parts ...string) Model {
return &breadcrumbModel{parts: parts}
}
func (b *breadcrumbModel) View(width, _ int) string {
styled := make([]string, len(b.parts))
for i, p := range b.parts {
if i == len(b.parts)-1 {
styled[i] = BoldStyle.Render(p)
} else {
styled[i] = DimStyle.Render(p)
}
}
line := strings.Join(styled, DimStyle.Render(" > "))
if width > 0 {
line = Truncate(line, width)
}
return line
}
// staticModel wraps a plain string as a Model.
type staticModel struct {
text string
}
// StaticModel wraps a static string as a Model, for use in Frame regions.
func StaticModel(text string) Model {
return &staticModel{text: text}
}
func (s *staticModel) View(_, _ int) string {
return s.text
}

207
pkg/cli/frame_test.go Normal file
View file

@ -0,0 +1,207 @@
package cli
import (
"bytes"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFrame_Good(t *testing.T) {
t.Run("static render HCF", func(t *testing.T) {
SetColorEnabled(false)
defer SetColorEnabled(true)
f := NewFrame("HCF")
f.out = &bytes.Buffer{}
f.Header(StaticModel("header"))
f.Content(StaticModel("content"))
f.Footer(StaticModel("footer"))
out := f.String()
assert.Contains(t, out, "header")
assert.Contains(t, out, "content")
assert.Contains(t, out, "footer")
})
t.Run("region order preserved", func(t *testing.T) {
SetColorEnabled(false)
defer SetColorEnabled(true)
f := NewFrame("HCF")
f.out = &bytes.Buffer{}
f.Header(StaticModel("AAA"))
f.Content(StaticModel("BBB"))
f.Footer(StaticModel("CCC"))
out := f.String()
posA := indexOf(out, "AAA")
posB := indexOf(out, "BBB")
posC := indexOf(out, "CCC")
assert.Less(t, posA, posB, "header before content")
assert.Less(t, posB, posC, "content before footer")
})
t.Run("navigate and back", func(t *testing.T) {
SetColorEnabled(false)
defer SetColorEnabled(true)
f := NewFrame("HCF")
f.out = &bytes.Buffer{}
f.Header(StaticModel("nav"))
f.Content(StaticModel("page-1"))
f.Footer(StaticModel("hints"))
assert.Contains(t, f.String(), "page-1")
// Navigate to page 2
f.Navigate(StaticModel("page-2"))
assert.Contains(t, f.String(), "page-2")
assert.NotContains(t, f.String(), "page-1")
// Navigate to page 3
f.Navigate(StaticModel("page-3"))
assert.Contains(t, f.String(), "page-3")
// Back to page 2
ok := f.Back()
require.True(t, ok)
assert.Contains(t, f.String(), "page-2")
// Back to page 1
ok = f.Back()
require.True(t, ok)
assert.Contains(t, f.String(), "page-1")
// No more history
ok = f.Back()
assert.False(t, ok)
})
t.Run("empty regions skipped", func(t *testing.T) {
SetColorEnabled(false)
defer SetColorEnabled(true)
f := NewFrame("HCF")
f.out = &bytes.Buffer{}
f.Content(StaticModel("only content"))
out := f.String()
assert.Equal(t, "only content\n", out)
})
t.Run("non-TTY run renders once", func(t *testing.T) {
SetColorEnabled(false)
defer SetColorEnabled(true)
var buf bytes.Buffer
f := NewFrame("HCF")
f.out = &buf
f.Header(StaticModel("h"))
f.Content(StaticModel("c"))
f.Footer(StaticModel("f"))
f.Run() // non-TTY, should return immediately
assert.Contains(t, buf.String(), "h")
assert.Contains(t, buf.String(), "c")
assert.Contains(t, buf.String(), "f")
})
t.Run("ModelFunc adapter", func(t *testing.T) {
called := false
m := ModelFunc(func(w, h int) string {
called = true
return "dynamic"
})
out := m.View(80, 24)
assert.True(t, called)
assert.Equal(t, "dynamic", out)
})
t.Run("RunFor exits after duration", func(t *testing.T) {
var buf bytes.Buffer
f := NewFrame("C")
f.out = &buf // non-TTY → RunFor renders once and returns
f.Content(StaticModel("timed"))
start := time.Now()
f.RunFor(50 * time.Millisecond)
elapsed := time.Since(start)
assert.Less(t, elapsed, 200*time.Millisecond)
assert.Contains(t, buf.String(), "timed")
})
}
func TestFrame_Bad(t *testing.T) {
t.Run("empty frame", func(t *testing.T) {
f := NewFrame("HCF")
f.out = &bytes.Buffer{}
assert.Equal(t, "", f.String())
})
t.Run("back on empty history", func(t *testing.T) {
f := NewFrame("C")
f.out = &bytes.Buffer{}
f.Content(StaticModel("x"))
assert.False(t, f.Back())
})
t.Run("invalid variant degrades gracefully", func(t *testing.T) {
f := NewFrame("XYZ")
f.out = &bytes.Buffer{}
// No valid regions, so nothing renders
assert.Equal(t, "", f.String())
})
}
func TestStatusLine_Good(t *testing.T) {
SetColorEnabled(false)
defer SetColorEnabled(true)
m := StatusLine("core dev", "18 repos", "main")
out := m.View(80, 1)
assert.Contains(t, out, "core dev")
assert.Contains(t, out, "18 repos")
assert.Contains(t, out, "main")
}
func TestKeyHints_Good(t *testing.T) {
SetColorEnabled(false)
defer SetColorEnabled(true)
m := KeyHints("↑/↓ navigate", "q quit")
out := m.View(80, 1)
assert.Contains(t, out, "navigate")
assert.Contains(t, out, "quit")
}
func TestBreadcrumb_Good(t *testing.T) {
SetColorEnabled(false)
defer SetColorEnabled(true)
m := Breadcrumb("core", "dev", "health")
out := m.View(80, 1)
assert.Contains(t, out, "core")
assert.Contains(t, out, "dev")
assert.Contains(t, out, "health")
assert.Contains(t, out, ">")
}
func TestStaticModel_Good(t *testing.T) {
m := StaticModel("hello")
assert.Equal(t, "hello", m.View(80, 24))
}
// indexOf returns the position of substr in s, or -1 if not found.
func indexOf(s, substr string) int {
for i := range len(s) - len(substr) + 1 {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}

92
pkg/cli/glyph.go Normal file
View file

@ -0,0 +1,92 @@
package cli
import (
"bytes"
"unicode"
)
// GlyphTheme defines which symbols to use.
type GlyphTheme int
const (
// ThemeUnicode uses standard Unicode symbols.
ThemeUnicode GlyphTheme = iota
// ThemeEmoji uses Emoji symbols.
ThemeEmoji
// ThemeASCII uses ASCII fallback symbols.
ThemeASCII
)
var currentTheme = ThemeUnicode
// UseUnicode switches the glyph theme to Unicode.
func UseUnicode() { currentTheme = ThemeUnicode }
// UseEmoji switches the glyph theme to Emoji.
func UseEmoji() { currentTheme = ThemeEmoji }
// UseASCII switches the glyph theme to ASCII and disables colors.
func UseASCII() {
currentTheme = ThemeASCII
SetColorEnabled(false)
}
func glyphMap() map[string]string {
switch currentTheme {
case ThemeEmoji:
return glyphMapEmoji
case ThemeASCII:
return glyphMapASCII
default:
return glyphMapUnicode
}
}
// Glyph converts a shortcode (e.g. ":check:") to its symbol based on the current theme.
func Glyph(code string) string {
if sym, ok := glyphMap()[code]; ok {
return sym
}
return code
}
func compileGlyphs(x string) string {
if x == "" {
return ""
}
input := bytes.NewBufferString(x)
output := bytes.NewBufferString("")
for {
r, _, err := input.ReadRune()
if err != nil {
break
}
if r == ':' {
output.WriteString(replaceGlyph(input))
} else {
output.WriteRune(r)
}
}
return output.String()
}
func replaceGlyph(input *bytes.Buffer) string {
code := bytes.NewBufferString(":")
for {
r, _, err := input.ReadRune()
if err != nil {
return code.String()
}
if r == ':' && code.Len() == 1 {
return code.String() + replaceGlyph(input)
}
code.WriteRune(r)
if unicode.IsSpace(r) {
return code.String()
}
if r == ':' {
return Glyph(code.String())
}
}
}

25
pkg/cli/glyph_maps.go Normal file
View file

@ -0,0 +1,25 @@
package cli
var glyphMapUnicode = map[string]string{
":check:": "✓", ":cross:": "✗", ":warn:": "⚠", ":info:": "",
":question:": "?", ":skip:": "○", ":dot:": "●", ":circle:": "◯",
":arrow_right:": "→", ":arrow_left:": "←", ":arrow_up:": "↑", ":arrow_down:": "↓",
":pointer:": "▶", ":bullet:": "•", ":dash:": "─", ":pipe:": "│",
":corner:": "└", ":tee:": "├", ":pending:": "…", ":spinner:": "⠋",
}
var glyphMapEmoji = map[string]string{
":check:": "✅", ":cross:": "❌", ":warn:": "⚠️", ":info:": "",
":question:": "❓", ":skip:": "⏭️", ":dot:": "🔵", ":circle:": "⚪",
":arrow_right:": "➡️", ":arrow_left:": "⬅️", ":arrow_up:": "⬆️", ":arrow_down:": "⬇️",
":pointer:": "▶️", ":bullet:": "•", ":dash:": "─", ":pipe:": "│",
":corner:": "└", ":tee:": "├", ":pending:": "⏳", ":spinner:": "🔄",
}
var glyphMapASCII = map[string]string{
":check:": "[OK]", ":cross:": "[FAIL]", ":warn:": "[WARN]", ":info:": "[INFO]",
":question:": "[?]", ":skip:": "[SKIP]", ":dot:": "[*]", ":circle:": "[ ]",
":arrow_right:": "->", ":arrow_left:": "<-", ":arrow_up:": "^", ":arrow_down:": "v",
":pointer:": ">", ":bullet:": "*", ":dash:": "-", ":pipe:": "|",
":corner:": "`", ":tee:": "+", ":pending:": "...", ":spinner:": "-",
}

23
pkg/cli/glyph_test.go Normal file
View file

@ -0,0 +1,23 @@
package cli
import "testing"
func TestGlyph(t *testing.T) {
UseUnicode()
if Glyph(":check:") != "✓" {
t.Errorf("Expected ✓, got %s", Glyph(":check:"))
}
UseASCII()
if Glyph(":check:") != "[OK]" {
t.Errorf("Expected [OK], got %s", Glyph(":check:"))
}
}
func TestCompileGlyphs(t *testing.T) {
UseUnicode()
got := compileGlyphs("Status: :check:")
if got != "Status: ✓" {
t.Errorf("Expected Status: ✓, got %s", got)
}
}

170
pkg/cli/i18n.go Normal file
View file

@ -0,0 +1,170 @@
package cli
import (
"context"
"sync"
"forge.lthn.ai/core/go/pkg/framework"
"forge.lthn.ai/core/go/pkg/i18n"
)
// I18nService wraps i18n as a Core service.
type I18nService struct {
*framework.ServiceRuntime[I18nOptions]
svc *i18n.Service
// Collect mode state
missingKeys []i18n.MissingKey
missingKeysMu sync.Mutex
}
// I18nOptions configures the i18n service.
type I18nOptions struct {
// Language overrides auto-detection (e.g., "en-GB", "de")
Language string
// Mode sets the translation mode (Normal, Strict, Collect)
Mode i18n.Mode
}
// NewI18nService creates an i18n service factory.
func NewI18nService(opts I18nOptions) func(*framework.Core) (any, error) {
return func(c *framework.Core) (any, error) {
svc, err := i18n.New()
if err != nil {
return nil, err
}
if opts.Language != "" {
_ = svc.SetLanguage(opts.Language)
}
// Set mode if specified
svc.SetMode(opts.Mode)
// Set as global default so i18n.T() works everywhere
i18n.SetDefault(svc)
return &I18nService{
ServiceRuntime: framework.NewServiceRuntime(c, opts),
svc: svc,
missingKeys: make([]i18n.MissingKey, 0),
}, nil
}
}
// OnStartup initialises the i18n service.
func (s *I18nService) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
// Register action handler for collect mode
if s.svc.Mode() == i18n.ModeCollect {
i18n.OnMissingKey(s.handleMissingKey)
}
return nil
}
// handleMissingKey accumulates missing keys in collect mode.
func (s *I18nService) handleMissingKey(mk i18n.MissingKey) {
s.missingKeysMu.Lock()
defer s.missingKeysMu.Unlock()
s.missingKeys = append(s.missingKeys, mk)
}
// MissingKeys returns all missing keys collected in collect mode.
// Call this at the end of a QA session to report missing translations.
func (s *I18nService) MissingKeys() []i18n.MissingKey {
s.missingKeysMu.Lock()
defer s.missingKeysMu.Unlock()
result := make([]i18n.MissingKey, len(s.missingKeys))
copy(result, s.missingKeys)
return result
}
// ClearMissingKeys resets the collected missing keys.
func (s *I18nService) ClearMissingKeys() {
s.missingKeysMu.Lock()
defer s.missingKeysMu.Unlock()
s.missingKeys = s.missingKeys[:0]
}
// SetMode changes the translation mode.
func (s *I18nService) SetMode(mode i18n.Mode) {
s.svc.SetMode(mode)
// Update action handler registration
if mode == i18n.ModeCollect {
i18n.OnMissingKey(s.handleMissingKey)
} else {
i18n.OnMissingKey(nil)
}
}
// Mode returns the current translation mode.
func (s *I18nService) Mode() i18n.Mode {
return s.svc.Mode()
}
// Queries for i18n service
// QueryTranslate requests a translation.
type QueryTranslate struct {
Key string
Args map[string]any
}
func (s *I18nService) handleQuery(c *framework.Core, q framework.Query) (any, bool, error) {
switch m := q.(type) {
case QueryTranslate:
return s.svc.T(m.Key, m.Args), true, nil
}
return nil, false, nil
}
// T translates a key with optional arguments.
func (s *I18nService) T(key string, args ...map[string]any) string {
if len(args) > 0 {
return s.svc.T(key, args[0])
}
return s.svc.T(key)
}
// SetLanguage changes the current language.
func (s *I18nService) SetLanguage(lang string) {
_ = s.svc.SetLanguage(lang)
}
// Language returns the current language.
func (s *I18nService) Language() string {
return s.svc.Language()
}
// AvailableLanguages returns all available languages.
func (s *I18nService) AvailableLanguages() []string {
return s.svc.AvailableLanguages()
}
// --- Package-level convenience ---
// T translates a key using the CLI's i18n service.
// Falls back to the global i18n.T if CLI not initialised.
func T(key string, args ...map[string]any) string {
if instance == nil {
// CLI not initialised, use global i18n
if len(args) > 0 {
return i18n.T(key, args[0])
}
return i18n.T(key)
}
svc, err := framework.ServiceFor[*I18nService](instance.core, "i18n")
if err != nil {
// i18n service not registered, use global
if len(args) > 0 {
return i18n.T(key, args[0])
}
return i18n.T(key)
}
return svc.T(key, args...)
}

148
pkg/cli/layout.go Normal file
View file

@ -0,0 +1,148 @@
package cli
import "fmt"
// Region represents one of the 5 HLCRF regions.
type Region rune
const (
// RegionHeader is the top region of the layout.
RegionHeader Region = 'H'
// RegionLeft is the left sidebar region.
RegionLeft Region = 'L'
// RegionContent is the main content region.
RegionContent Region = 'C'
// RegionRight is the right sidebar region.
RegionRight Region = 'R'
// RegionFooter is the bottom region of the layout.
RegionFooter Region = 'F'
)
// Composite represents an HLCRF layout node.
type Composite struct {
variant string
path string
regions map[Region]*Slot
parent *Composite
}
// Slot holds content for a region.
type Slot struct {
region Region
path string
blocks []Renderable
child *Composite
}
// Renderable is anything that can be rendered to terminal.
type Renderable interface {
Render() string
}
// StringBlock is a simple string that implements Renderable.
type StringBlock string
// Render returns the string content.
func (s StringBlock) Render() string { return string(s) }
// Layout creates a new layout from a variant string.
func Layout(variant string) *Composite {
c, err := ParseVariant(variant)
if err != nil {
return &Composite{variant: variant, regions: make(map[Region]*Slot)}
}
return c
}
// ParseVariant parses a variant string like "H[LC]C[HCF]F".
func ParseVariant(variant string) (*Composite, error) {
c := &Composite{
variant: variant,
path: "",
regions: make(map[Region]*Slot),
}
i := 0
for i < len(variant) {
r := Region(variant[i])
if !isValidRegion(r) {
return nil, fmt.Errorf("invalid region: %c", r)
}
slot := &Slot{region: r, path: string(r)}
c.regions[r] = slot
i++
if i < len(variant) && variant[i] == '[' {
end := findMatchingBracket(variant, i)
if end == -1 {
return nil, fmt.Errorf("unmatched bracket at %d", i)
}
nested, err := ParseVariant(variant[i+1 : end])
if err != nil {
return nil, err
}
nested.path = string(r) + "-"
nested.parent = c
slot.child = nested
i = end + 1
}
}
return c, nil
}
func isValidRegion(r Region) bool {
return r == 'H' || r == 'L' || r == 'C' || r == 'R' || r == 'F'
}
func findMatchingBracket(s string, start int) int {
depth := 0
for i := start; i < len(s); i++ {
switch s[i] {
case '[':
depth++
case ']':
depth--
if depth == 0 {
return i
}
}
}
return -1
}
// H adds content to Header region.
func (c *Composite) H(items ...any) *Composite { c.addToRegion(RegionHeader, items...); return c }
// L adds content to Left region.
func (c *Composite) L(items ...any) *Composite { c.addToRegion(RegionLeft, items...); return c }
// C adds content to Content region.
func (c *Composite) C(items ...any) *Composite { c.addToRegion(RegionContent, items...); return c }
// R adds content to Right region.
func (c *Composite) R(items ...any) *Composite { c.addToRegion(RegionRight, items...); return c }
// F adds content to Footer region.
func (c *Composite) F(items ...any) *Composite { c.addToRegion(RegionFooter, items...); return c }
func (c *Composite) addToRegion(r Region, items ...any) {
slot, ok := c.regions[r]
if !ok {
return
}
for _, item := range items {
slot.blocks = append(slot.blocks, toRenderable(item))
}
}
func toRenderable(item any) Renderable {
switch v := item.(type) {
case Renderable:
return v
case string:
return StringBlock(v)
default:
return StringBlock(fmt.Sprint(v))
}
}

25
pkg/cli/layout_test.go Normal file
View file

@ -0,0 +1,25 @@
package cli
import "testing"
func TestParseVariant(t *testing.T) {
c, err := ParseVariant("H[LC]F")
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if _, ok := c.regions[RegionHeader]; !ok {
t.Error("Expected Header region")
}
if _, ok := c.regions[RegionFooter]; !ok {
t.Error("Expected Footer region")
}
hSlot := c.regions[RegionHeader]
if hSlot.child == nil {
t.Error("Header should have child layout")
} else {
if _, ok := hSlot.child.regions[RegionLeft]; !ok {
t.Error("Child should have Left region")
}
}
}

Some files were not shown because too many files have changed in this diff Show more