Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 81 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/prolific-oss/cli/cmd/submission"
"github.com/prolific-oss/cli/cmd/user"
"github.com/prolific-oss/cli/cmd/workspace"
"github.com/prolific-oss/cli/ui"
"github.com/prolific-oss/cli/version"
"github.com/spf13/cobra"
"github.com/spf13/viper"
Expand All @@ -31,9 +32,15 @@ var cfgFile string
// ApplicationName is the name of the cli binary
const ApplicationName = "prolific"

// BannerFilePath is the path to the ASCII art banner file
const BannerFilePath = "banner.txt"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: What does this get us? We would have to ship another file for it to render

Copy link
Contributor Author

@ajmalkhan-eng ajmalkhan-eng Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I just see this.. I hate gh notifications..

I'll be honest.. it breaks the ascii 🥹 .. I can embed it in to binary on build? or yeet.

it's soo last year anyway 🙈


// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once
func Execute() {
// Register custom template functions before building commands
registerTemplateFuncs()

// We need the configuration loaded before we create a NewCli
// as that needs the viper configuration up and running
initConfig()
Expand All @@ -43,17 +50,25 @@ func Execute() {

// Execute the application
if err := cmd.Execute(); err != nil {
fmt.Println(err)
ui.WriteError(err.Error())
os.Exit(1)
}
}

// NewRootCommand builds the main cli application and
// adds all the child commands
func NewRootCommand() *cobra.Command {
// Load banner from file
banner, err := os.ReadFile(BannerFilePath)
bannerText := ""
if err == nil {
bannerText = string(banner)
}

var cmd = &cobra.Command{
Use: ApplicationName,
Short: "CLI application for retrieving data from the Prolific Platform",
Long: ui.RenderBanner(bannerText) + "\nCLI application for retrieving data from the Prolific Platform",
Version: version.GITCOMMIT,
}

Expand Down Expand Up @@ -81,9 +96,73 @@ func NewRootCommand() *cobra.Command {
workspace.NewWorkspaceCommand(&client, w),
)

// Apply custom templates to all commands recursively (including root)
applyTemplateToAllCommands(cmd)

return cmd
}

// applyTemplateToAllCommands recursively applies custom help templates to all commands
func applyTemplateToAllCommands(cmd *cobra.Command) {
cmd.SetHelpTemplate(getHelpTemplate())
cmd.SetUsageTemplate(getUsageTemplate())

for _, subCmd := range cmd.Commands() {
applyTemplateToAllCommands(subCmd)
}
}

// getHelpTemplate returns a custom help template with colors
func getHelpTemplate() string {
return `{{with (or .Long .Short)}}{{. | trimTrailingWhitespaces}}

{{end}}{{if or .Runnable .HasSubCommands}}{{.UsageString}}{{end}}`
}

// getUsageTemplate returns a custom usage template with colors
func getUsageTemplate() string {
return `{{bold "Usage:"}}{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}

{{bold "Aliases:"}}
{{.NameAndAliases}}{{end}}{{if .HasExample}}

{{bold "Examples:"}}
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}

{{bold "Available Commands:"}}{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{highlight (rpad .Name .NamePadding)}} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}

{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
{{highlight (rpad .Name .NamePadding)}} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}

Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
{{highlight (rpad .Name .NamePadding)}} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}

{{bold "Flags:"}}
{{.LocalFlags.FlagUsagesWrapped 100 | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}

{{bold "Global Flags:"}}
{{.InheritedFlags.FlagUsagesWrapped 100 | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}

Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
{{highlight (rpad .CommandPath .CommandPathPadding)}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}

{{dim (print "Use \"" .CommandPath " [command] --help\" for more information about a command.")}}{{end}}
`
}

// registerTemplateFuncs registers custom template functions for cobra commands
func registerTemplateFuncs() {
cobra.AddTemplateFunc("trimTrailingWhitespaces", func(s string) string {
return strings.TrimRight(s, " \t")
})
cobra.AddTemplateFunc("bold", ui.Bold)
cobra.AddTemplateFunc("highlight", ui.Highlight)
cobra.AddTemplateFunc("dim", ui.Dim)
}

// initConfig reads in config file and ENV variables if set.
func initConfig() {
if cfgFile != "" {
Expand All @@ -93,7 +172,7 @@ func initConfig() {
// Find home directory.
home, err := homedir.Dir()
if err != nil {
fmt.Println(err)
ui.WriteError(err.Error())
os.Exit(1)
}

Expand Down
185 changes: 183 additions & 2 deletions ui/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ package ui

import (
"fmt"
"io"
"os"
"sync"

"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-isatty"
"github.com/muesli/termenv"
"github.com/prolific-oss/cli/config"
"github.com/prolific-oss/cli/model"
"golang.org/x/text/currency"
Expand All @@ -14,19 +19,80 @@ import (
const (
// DarkGrey is the colour for Grey
DarkGrey = "#989898"

// Color constants for minimal formatting
Green = "#00D787"
Red = "#FF6B6B"
Yellow = "#FFD93D"
Cyan = "#6FC3DF"
ProlificBlue = "#0F2BC9" // Official Prolific brand blue
)

// Symbol constants
const (
// Unicode symbols for TTY output
SymbolSuccess = "✓"
SymbolError = "✗"
SymbolWarning = "!"
SymbolInfo = "•"

// ASCII fallback symbols for non-TTY
SymbolSuccessAscii = "[ok]"
SymbolErrorAscii = "[error]"
SymbolWarningAscii = "[warn]"
SymbolInfoAscii = "[info]"
)

// AppDateTimeFormat The format for date/times in the application.
const AppDateTimeFormat string = "02-01-2006 15:04"

var (
isTTYStdout = isatty.IsTerminal(os.Stdout.Fd())
isTTYStderr = isatty.IsTerminal(os.Stderr.Fd())
noColor = os.Getenv("NO_COLOR") != ""
colorProfileOnce sync.Once
)

// initColorProfile sets lipgloss color profile based on terminal capabilities
// This is called lazily using sync.Once to ensure it runs exactly once
func initColorProfile() {
colorProfileOnce.Do(func() {
if noColor {
lipgloss.SetColorProfile(termenv.Ascii)
} else if isTTYStdout {
// Auto-detect color profile for the terminal
lipgloss.SetColorProfile(termenv.EnvColorProfile())
} else {
lipgloss.SetColorProfile(termenv.Ascii)
}
})
}

// Pre-configured lipgloss styles for consistent, vibrant output
var (
successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(Green)).Bold(true)
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(Red)).Bold(true)
warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(Yellow)).Bold(true)
infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(Cyan))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(DarkGrey))
boldStyle = lipgloss.NewStyle().Bold(true)
highlightStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(Cyan)) // Cyan color, no underline
)

// RenderSectionMarker will render a section marker in the output.
func RenderSectionMarker() string {
return fmt.Sprintf("\n%s\n\n", lipgloss.NewStyle().Foreground(lipgloss.Color(DarkGrey)).Render("---"))
if !shouldUseColor(isTTYStdout) {
return "\n---\n\n"
}
return fmt.Sprintf("\n%s\n\n", dimStyle.Render("---"))
}

// RenderHeading will render a heading in the output.
func RenderHeading(heading string) string {
return lipgloss.NewStyle().Bold(true).Render(heading)
if !shouldUseColor(isTTYStdout) {
return heading
}
return boldStyle.Render(heading)
}

// RenderMoney will return a symbolised string of money, e.g. £10.00
Expand Down Expand Up @@ -61,6 +127,121 @@ func RenderApplicationLink(entity, slug string) string {
return content
}

// shouldUseColor returns true if color output should be used based on TTY detection and NO_COLOR env.
func shouldUseColor(isTTY bool) bool {
initColorProfile() // Lazy initialization
return isTTY && !noColor
}

// Success renders a success message with a green checkmark.
// Output format: "✓ message" (or "[ok] message" for non-TTY).
func Success(msg string) string {
if !shouldUseColor(isTTYStdout) {
return fmt.Sprintf("%s %s", SymbolSuccessAscii, msg)
}
symbol := successStyle.Render(SymbolSuccess)
return fmt.Sprintf("%s %s", symbol, msg)
}

// Error renders an error message with a red X.
// Output format: "✗ Error: message" (or "[error] Error: message" for non-TTY).
// This should be written to stderr.
func Error(msg string) string {
if !shouldUseColor(isTTYStderr) {
return fmt.Sprintf("%s Error: %s", SymbolErrorAscii, msg)
}
symbol := errorStyle.Render(SymbolError)
errorText := errorStyle.Render("Error:")
return fmt.Sprintf("%s %s %s", symbol, errorText, msg)
}

// ErrorWithHint renders an error message with a hint for next steps.
// The hint is displayed on a new line with 2-space indentation.
func ErrorWithHint(msg, hint string) string {
errorLine := Error(msg)
dimmedHint := Dim(hint)
return fmt.Sprintf("%s\n\n %s", errorLine, dimmedHint)
}

// Warn renders a warning message with a yellow exclamation mark.
// Output format: "! Warning: message" (or "[warn] Warning: message" for non-TTY).
func Warn(msg string) string {
if !shouldUseColor(isTTYStdout) {
return fmt.Sprintf("%s Warning: %s", SymbolWarningAscii, msg)
}
symbol := warningStyle.Render(SymbolWarning)
warningText := warningStyle.Render("Warning:")
return fmt.Sprintf("%s %s %s", symbol, warningText, msg)
}

// Info renders an informational message with a cyan bullet.
// Output format: "• message" (or "[info] message" for non-TTY).
func Info(msg string) string {
if !shouldUseColor(isTTYStdout) {
return fmt.Sprintf("%s %s", SymbolInfoAscii, msg)
}
symbol := infoStyle.Render(SymbolInfo)
return fmt.Sprintf("%s %s", symbol, msg)
}

// Dim renders text in a dimmed/grey color for secondary information.
func Dim(text string) string {
if !shouldUseColor(isTTYStdout) {
return text
}
return dimStyle.Render(text)
}

// Bold renders text in bold (no color).
func Bold(text string) string {
if !shouldUseColor(isTTYStdout) {
return text
}
return boldStyle.Render(text)
}

// Highlight renders text in cyan for emphasis (URLs, commands, etc.).
func Highlight(text string) string {
if !shouldUseColor(isTTYStdout) {
return text
}
return highlightStyle.Render(text)
}

// RenderHighlightedText renders text with bold orange background and black foreground.
func RenderHighlightedText(text string) string {
return lipgloss.NewStyle().Bold(true).Background(lipgloss.Color("#FFA500")).Foreground(lipgloss.Color("#000000")).Render(text)
}

// WriteSuccess writes a success message to the provided writer.
func WriteSuccess(w io.Writer, msg string) {
fmt.Fprintln(w, Success(msg))
}

// WriteError writes an error message to stderr.
func WriteError(msg string) {
fmt.Fprintln(os.Stderr, Error(msg))
}

// WriteErrorWithHint writes an error message with a hint to stderr.
func WriteErrorWithHint(msg, hint string) {
fmt.Fprintln(os.Stderr, ErrorWithHint(msg, hint))
}

// WriteWarn writes a warning message to the provided writer.
func WriteWarn(w io.Writer, msg string) {
fmt.Fprintln(w, Warn(msg))
}

// WriteInfo writes an informational message to the provided writer.
func WriteInfo(w io.Writer, msg string) {
fmt.Fprintln(w, Info(msg))
}

// RenderBanner renders the ASCII banner in Prolific blue.
func RenderBanner(banner string) string {
if !shouldUseColor(isTTYStdout) {
return banner
}
return lipgloss.NewStyle().Foreground(lipgloss.Color(ProlificBlue)).Render(banner)
}
Loading