From b6617a4b176f5e1c622e40fbe282b102231e3692 Mon Sep 17 00:00:00 2001 From: dataCenter430 <161712630+dataCenter430@users.noreply.github.com> Date: Sun, 12 Apr 2026 03:44:24 -0700 Subject: [PATCH] feat(cli): structured terminal UI for PicoClaw CLI like modern CLIs (#2229) * feat(cli): add boxed help/error UI with no-color support * fix: CI testing error * fix: lint errors * fix linter error * fix: address review --- cmd/picoclaw/internal/cliui/cliui.go | 147 +++++++++++ cmd/picoclaw/internal/cliui/cliui_test.go | 180 +++++++++++++ cmd/picoclaw/internal/cliui/help_cmd.go | 298 ++++++++++++++++++++++ cmd/picoclaw/internal/cliui/help_error.go | 75 ++++++ cmd/picoclaw/internal/cliui/onboard.go | 110 ++++++++ cmd/picoclaw/internal/cliui/status.go | 168 ++++++++++++ cmd/picoclaw/internal/cliui/version.go | 61 +++++ cmd/picoclaw/internal/onboard/helpers.go | 25 +- cmd/picoclaw/internal/status/helpers.go | 130 ++++++++-- cmd/picoclaw/internal/version/command.go | 11 +- cmd/picoclaw/main.go | 84 +++++- cmd/picoclaw/main_test.go | 9 +- go.mod | 11 +- go.sum | 19 ++ 14 files changed, 1257 insertions(+), 71 deletions(-) create mode 100644 cmd/picoclaw/internal/cliui/cliui.go create mode 100644 cmd/picoclaw/internal/cliui/cliui_test.go create mode 100644 cmd/picoclaw/internal/cliui/help_cmd.go create mode 100644 cmd/picoclaw/internal/cliui/help_error.go create mode 100644 cmd/picoclaw/internal/cliui/onboard.go create mode 100644 cmd/picoclaw/internal/cliui/status.go create mode 100644 cmd/picoclaw/internal/cliui/version.go diff --git a/cmd/picoclaw/internal/cliui/cliui.go b/cmd/picoclaw/internal/cliui/cliui.go new file mode 100644 index 000000000..b1ba636c9 --- /dev/null +++ b/cmd/picoclaw/internal/cliui/cliui.go @@ -0,0 +1,147 @@ +// Package cliui renders human-oriented CLI output: bordered panels and columns +// on wide interactive terminals. Layout (boxes/columns) is independent of ANSI +// color: use --no-color or NO_COLOR to disable colors only; narrow or non-TTY +// stdout falls back to plain line-oriented output. +package cliui + +import ( + "os" + "sync" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "golang.org/x/term" +) + +// Minimum terminal width (columns) for bordered / structured layout. +// Below this, plain line-oriented output is used so boxes do not wrap badly. +const minWidthFancy = 88 + +// Minimum width to lay out some views in two columns (e.g. status providers). +const minWidthColumns = 104 + +var initMu sync.Mutex + +// Init configures lipgloss for this process. When disableAnsiColors is true +// (e.g. --no-color, NO_COLOR, or TERM=dumb), only color is turned off; Unicode +// borders still render when UseFancyLayout() is true. +func Init(disableAnsiColors bool) { + initMu.Lock() + defer initMu.Unlock() + if disableAnsiColors { + lipgloss.SetColorProfile(termenv.Ascii) + return + } + lipgloss.SetColorProfile(termenv.EnvColorProfile()) +} + +// StdoutWidth returns the terminal width or a sane default if unknown. +func StdoutWidth() int { + w, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || w < 20 { + return 80 + } + return w +} + +// UseFancyLayout is true when styled boxes/columns should be used. +func UseFancyLayout() bool { + if !term.IsTerminal(int(os.Stdout.Fd())) { + return false + } + return StdoutWidth() >= minWidthFancy +} + +// UseColumnLayout is true when a second content column is viable. +func UseColumnLayout() bool { + return UseFancyLayout() && StdoutWidth() >= minWidthColumns +} + +// InnerWidth is the target content width inside borders/margins. +func InnerWidth() int { + w := StdoutWidth() + // Rounded border + horizontal padding (lipgloss borders ~= 2 cols each side + padding). + const borderBudget = 8 + if w > borderBudget+48 { + return w - borderBudget + } + return 48 +} + +// StderrWidth returns stderr terminal width or a sane default. +func StderrWidth() int { + w, _, err := term.GetSize(int(os.Stderr.Fd())) + if err != nil || w < 20 { + return 80 + } + return w +} + +// UseFancyStderr is true when stderr can show boxed errors without ugly wraps. +func UseFancyStderr() bool { + if !term.IsTerminal(int(os.Stderr.Fd())) { + return false + } + return StderrWidth() >= minWidthFancy +} + +// InnerStderrWidth mirrors InnerWidth but for stderr. +func InnerStderrWidth() int { + w := StderrWidth() + const borderBudget = 8 + if w > borderBudget+48 { + return w - borderBudget + } + return 48 +} + +var ( + accentBlue = lipgloss.Color("#3E5DB9") + accentRed = lipgloss.Color("#D54646") + colorMuted = lipgloss.Color("#6B6B6B") + colorOK = lipgloss.Color("#2E7D32") +) + +func borderStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(accentBlue). + Padding(0, 1) +} + +func titleBarStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(accentRed). + Bold(true) +} + +func mutedStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(colorMuted) +} + +func bodyStyle() lipgloss.Style { + return lipgloss.NewStyle() +} + +func kvKeyStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(accentBlue).Bold(true) +} + +func kvValStyle() lipgloss.Style { + return lipgloss.NewStyle() +} + +// helpIntroStyle is the top tagline (PicoClaw blue, matches ASCII banner left side). +func helpIntroStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(accentBlue).Bold(true) +} + +// helpIdentStyle is the left column for commands and flags (blue identifiers). +func helpIdentStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(accentBlue).Bold(true) +} + +// helpPlaceholderStyle highlights in usage lines (red accent). +func helpPlaceholderStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(accentRed).Bold(true) +} diff --git a/cmd/picoclaw/internal/cliui/cliui_test.go b/cmd/picoclaw/internal/cliui/cliui_test.go new file mode 100644 index 000000000..c07e220ee --- /dev/null +++ b/cmd/picoclaw/internal/cliui/cliui_test.go @@ -0,0 +1,180 @@ +package cliui + +import ( + "testing" + + flag "github.com/spf13/pflag" +) + +func init() { + // Disable ANSI colors in tests so output is predictable plain text. + Init(true) +} + +// --------------------------------------------------------------------------- +// showErrHint +// --------------------------------------------------------------------------- + +func TestShowErrHint(t *testing.T) { + cases := []struct { + msg string + want bool + }{ + // Cobra flag errors — should show hint + {"unknown flag: --foo", true}, + {"unknown shorthand flag: 'f' in -f", true}, + {"flag needs an argument: --output", true}, + {"required flag(s) \"model\" not set", true}, + // Generic invalid-argument errors — should show hint + {"invalid argument \"abc\" for --count", true}, + // required flag errors — should show hint + {"required flag(s) \"model\" not set", true}, + // usage: in message — should show hint + {"bad input\nusage: picoclaw ...", true}, + // Should NOT false-positive on broad words + {"connection flagged by remote", false}, + {"feature flag not set", false}, + {"invalid API key provided", false}, + {"authentication required", false}, + // Unrelated messages — no hint + {"something went wrong", false}, + {"network timeout", false}, + } + + for _, tc := range cases { + got := showErrHint(tc.msg) + if got != tc.want { + t.Errorf("showErrHint(%q) = %v, want %v", tc.msg, got, tc.want) + } + } +} + +// --------------------------------------------------------------------------- +// styleUsageTokens +// --------------------------------------------------------------------------- + +func TestStyleUsageTokensContainsTokens(t *testing.T) { + cases := []struct { + input string + contains []string // substrings that must appear in plain output + }{ + { + "picoclaw agent ", + []string{"picoclaw agent", ""}, + }, + { + "picoclaw [command] [flags]", + []string{"picoclaw", "[command]", "[flags]"}, + }, + { + "picoclaw", + []string{"picoclaw"}, + }, + { + "cmd [--flag]", + []string{"cmd", "", "[--flag]"}, + }, + } + + for _, tc := range cases { + out := styleUsageTokens(tc.input) + for _, sub := range tc.contains { + if !containsStripped(out, sub) { + t.Errorf("styleUsageTokens(%q): output %q does not contain %q", tc.input, out, sub) + } + } + } +} + +// containsStripped checks whether plain contains sub after stripping ANSI escapes. +// Since Init(true) sets Ascii profile, lipgloss emits no escape codes in tests, +// so this is just a plain substring check. +func containsStripped(plain, sub string) bool { + return len(plain) >= len(sub) && findSubstring(plain, sub) +} + +func findSubstring(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// collectFlagRows +// --------------------------------------------------------------------------- + +func TestCollectFlagRows_Empty(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + rows := collectFlagRows(fs) + if len(rows) != 0 { + t.Fatalf("expected 0 rows for empty FlagSet, got %d", len(rows)) + } +} + +func TestCollectFlagRows_BasicFlags(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.String("output", "", "output file path") + fs.Bool("verbose", false, "enable verbose mode") + fs.Int("count", 1, "number of items") + + rows := collectFlagRows(fs) + + if len(rows) != 3 { + t.Fatalf("expected 3 rows, got %d", len(rows)) + } + + // Rows must be sorted alphabetically by flag name. + names := make([]string, 0, len(rows)) + for _, r := range rows { + names = append(names, r[0]) + } + if names[0] > names[1] || names[1] > names[2] { + t.Errorf("rows not sorted: %v", names) + } +} + +func TestCollectFlagRows_Shorthand(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.StringP("model", "m", "", "model name") + + rows := collectFlagRows(fs) + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + left := rows[0][0] + if !findSubstring(left, "-m") || !findSubstring(left, "--model") { + t.Errorf("expected shorthand and long form in %q", left) + } +} + +func TestCollectFlagRows_HiddenFlagsExcluded(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.String("visible", "", "this shows up") + hidden := fs.String("hidden", "", "this should not show up") + _ = hidden + _ = fs.MarkHidden("hidden") + + rows := collectFlagRows(fs) + if len(rows) != 1 { + t.Fatalf("expected 1 row (hidden excluded), got %d", len(rows)) + } + if !findSubstring(rows[0][0], "visible") { + t.Errorf("expected visible flag in rows, got %q", rows[0][0]) + } +} + +func TestCollectFlagRows_UsageInRightColumn(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.String("format", "json", "output format: json or text") + + rows := collectFlagRows(fs) + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0][1] != "output format: json or text" { + t.Errorf("expected usage in right column, got %q", rows[0][1]) + } +} diff --git a/cmd/picoclaw/internal/cliui/help_cmd.go b/cmd/picoclaw/internal/cliui/help_cmd.go new file mode 100644 index 000000000..72956afaa --- /dev/null +++ b/cmd/picoclaw/internal/cliui/help_cmd.go @@ -0,0 +1,298 @@ +package cliui + +import ( + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" + flag "github.com/spf13/pflag" +) + +// RenderCommandHelp builds Ruff-style sectioned, two-column help when +// UseFancyLayout(); otherwise plain Cobra-style text. +func RenderCommandHelp(c *cobra.Command) string { + if !UseFancyLayout() { + return plainCommandHelp(c) + } + syncFlags(c) + + var b strings.Builder + head, sub := helpIntro(c) + if head != "" { + b.WriteString(helpIntroStyle().Render(head)) + b.WriteString("\n") + } + if sub != "" { + b.WriteString(mutedStyle().Render(sub)) + b.WriteString("\n") + } + if head != "" || sub != "" { + b.WriteString("\n") + } + + inner := InnerWidth() + contentW := inner - 6 + if contentW < 36 { + contentW = 36 + } + + // Usage + usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine())) + b.WriteString(sectionPanel("Usage", usageBody, inner)) + b.WriteString("\n") + + // Examples + if ex := strings.TrimSpace(c.Example); ex != "" { + exBody := bodyStyle().Width(contentW).Render(ex) + b.WriteString(sectionPanel("Examples", exBody, inner)) + b.WriteString("\n") + } + + // Subcommands + subs := visibleSubcommands(c) + if len(subs) > 0 { + rows := make([][2]string, 0, len(subs)) + for _, sub := range subs { + left := sub.Name() + if a := sub.Aliases; len(a) > 0 { + left += " (" + strings.Join(a, ", ") + ")" + } + rows = append(rows, [2]string{left, sub.Short}) + } + b.WriteString(sectionPanel("Commands", renderTwoColPairs(rows, contentW), inner)) + b.WriteString("\n") + } + + // Local options + local := c.LocalFlags() + opts := collectFlagRows(local) + if len(opts) > 0 { + title := "Options" + if !c.HasParent() { + title = "Flags" + } + b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), inner)) + b.WriteString("\n") + } + + // Global (inherited) options + if c.HasAvailableInheritedFlags() { + inh := collectFlagRows(c.InheritedFlags()) + if len(inh) > 0 { + b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), inner)) + b.WriteString("\n") + } + } + + return b.String() +} + +// RenderCommandQuickRef prints the same Usage / Flags / Global sections as help, +// for embedding after errors (stderr). outerW is typically InnerStderrWidth(). +func RenderCommandQuickRef(c *cobra.Command, outerW int) string { + if c == nil || outerW < 40 { + return "" + } + syncFlags(c) + contentW := outerW - 6 + if contentW < 36 { + contentW = 36 + } + var b strings.Builder + usageBody := bodyStyle().MaxWidth(contentW).Render(styleUsageTokens(c.UseLine())) + b.WriteString(sectionPanel("Usage", usageBody, outerW)) + b.WriteString("\n") + if len(c.Aliases) > 0 { + al := "Aliases: " + strings.Join(c.Aliases, ", ") + alBody := mutedStyle().MaxWidth(contentW).Render(al) + b.WriteString(sectionPanel("Aliases", alBody, outerW)) + b.WriteString("\n") + } + opts := collectFlagRows(c.LocalFlags()) + if len(opts) > 0 { + title := "Options" + if !c.HasParent() { + title = "Flags" + } + b.WriteString(sectionPanel(title, renderTwoColPairs(opts, contentW), outerW)) + b.WriteString("\n") + } + if c.HasAvailableInheritedFlags() { + inh := collectFlagRows(c.InheritedFlags()) + if len(inh) > 0 { + b.WriteString(sectionPanel("Global options", renderTwoColPairs(inh, contentW), outerW)) + b.WriteString("\n") + } + } + return b.String() +} + +func syncFlags(c *cobra.Command) { + _ = c.LocalFlags() + if c.HasAvailableInheritedFlags() { + _ = c.InheritedFlags() + } +} + +func plainCommandHelp(c *cobra.Command) string { + desc := c.Long + if desc == "" { + desc = c.Short + } + desc = strings.TrimRight(desc, " \t\n\r") + var b strings.Builder + if desc != "" { + fmt.Fprintln(&b, desc) + fmt.Fprintln(&b) + } + if c.Runnable() || c.HasSubCommands() { + b.WriteString(c.UsageString()) + } + return b.String() +} + +func helpIntro(c *cobra.Command) (head, sub string) { + head = strings.TrimSpace(c.Short) + long := strings.TrimSpace(c.Long) + if long == "" || long == head { + return head, "" + } + lines := strings.Split(long, "\n") + var rest []string + for i, ln := range lines { + ln = strings.TrimSpace(ln) + if ln == "" { + continue + } + if i == 0 && ln == head { + continue + } + rest = append(rest, ln) + } + sub = strings.Join(rest, "\n") + return head, sub +} + +func visibleSubcommands(c *cobra.Command) []*cobra.Command { + var out []*cobra.Command + for _, sub := range c.Commands() { + if sub.Hidden { + continue + } + out = append(out, sub) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() }) + return out +} + +func sectionPanel(title, body string, width int) string { + head := titleBarStyle().Render(title) + "\n\n" + return borderStyle().Width(width).Render(head + body) +} + +// styleUsageTokens highlights PicoClaw-blue command tokens and red /[groups]. +func styleUsageTokens(s string) string { + var b strings.Builder + for len(s) > 0 { + ia := strings.Index(s, "<") + ib := strings.Index(s, "[") + next, kind := -1, 0 // 1 = angle, 2 = bracket + switch { + case ia >= 0 && (ib < 0 || ia < ib): + next, kind = ia, 1 + case ib >= 0: + next, kind = ib, 2 + } + if next < 0 { + b.WriteString(helpIdentStyle().Render(s)) + break + } + if next > 0 { + b.WriteString(helpIdentStyle().Render(s[:next])) + } + s = s[next:] + if kind == 1 { + j := strings.Index(s, ">") + if j < 0 { + b.WriteString(helpIdentStyle().Render(s)) + break + } + b.WriteString(helpPlaceholderStyle().Render(s[:j+1])) + s = s[j+1:] + continue + } + j := strings.Index(s, "]") + if j < 0 { + b.WriteString(helpIdentStyle().Render(s)) + break + } + b.WriteString(helpPlaceholderStyle().Render(s[:j+1])) + s = s[j+1:] + } + return b.String() +} + +func collectFlagRows(fs *flag.FlagSet) [][2]string { + var names []string + seen := map[string][2]string{} + fs.VisitAll(func(f *flag.Flag) { + if f.Hidden { + return + } + left := formatFlagLeft(f) + right := f.Usage + if f.Deprecated != "" { + right += " (deprecated: " + f.Deprecated + ")" + } + names = append(names, f.Name) + seen[f.Name] = [2]string{left, right} + }) + sort.Strings(names) + rows := make([][2]string, 0, len(names)) + for _, n := range names { + rows = append(rows, seen[n]) + } + return rows +} + +func formatFlagLeft(f *flag.Flag) string { + if len(f.Shorthand) > 0 { + return "-" + f.Shorthand + ", --" + f.Name + } + return "--" + f.Name +} + +func renderTwoColPairs(rows [][2]string, contentW int) string { + if len(rows) == 0 { + return "" + } + leftW := 0 + for _, r := range rows { + if w := lipgloss.Width(r[0]); w > leftW { + leftW = w + } + } + const minLeft, maxLeft = 16, 34 + if leftW < minLeft { + leftW = minLeft + } + if leftW > maxLeft { + leftW = maxLeft + } + gap := " " + rightW := contentW - leftW - lipgloss.Width(gap) + if rightW < 24 { + rightW = 24 + } + + var b strings.Builder + for _, r := range rows { + left := helpIdentStyle().Width(leftW).Align(lipgloss.Left).Render(r[0]) + right := bodyStyle().Width(rightW).Render(strings.TrimSpace(r[1])) + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, left, gap, right)) + b.WriteString("\n") + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/cmd/picoclaw/internal/cliui/help_error.go b/cmd/picoclaw/internal/cliui/help_error.go new file mode 100644 index 000000000..1e859b08f --- /dev/null +++ b/cmd/picoclaw/internal/cliui/help_error.go @@ -0,0 +1,75 @@ +package cliui + +import ( + "strings" + + "github.com/spf13/cobra" +) + +// FormatCLIError formats errors with the same boxed sections as help. When ctx +// is the command that was running when the error occurred, Usage / Flags panels +// are appended so styling matches picoclaw -h. +func FormatCLIError(msg string, ctx *cobra.Command) string { + msg = strings.TrimRight(msg, "\n") + if !UseFancyStderr() { + s := "Error: " + msg + "\n" + if ctx != nil && showErrHint(msg) { + s += "\n" + plainCommandHelp(ctx) + } + return s + } + w := InnerStderrWidth() + contentW := w - 6 + if contentW < 36 { + contentW = 36 + } + + title := titleBarStyle().Render("Error") + "\n\n" + + paras := strings.Split(msg, "\n") + var body strings.Builder + for i, p := range paras { + p = strings.TrimRight(p, " ") + if p == "" { + continue + } + st := bodyStyle().Width(contentW) + if i > 0 { + body.WriteString("\n") + } + if i == 0 { + body.WriteString(st.Render(p)) + } else { + body.WriteString(mutedStyle().Width(contentW).Render(p)) + } + } + + foot := "" + if showErrHint(msg) { + if ctx != nil { + foot = "\n\n" + mutedStyle().Width(contentW). + Render("Full command help: "+ctx.CommandPath()+" --help") + } else { + foot = "\n\n" + mutedStyle().Width(contentW). + Render("Tip: picoclaw --help · picoclaw --help") + } + } + + out := borderStyle().Width(w).Render(title+body.String()+foot) + "\n" + if ctx != nil && showErrHint(msg) { + if ref := RenderCommandQuickRef(ctx, w); ref != "" { + out += "\n" + ref + } + } + return out +} + +func showErrHint(msg string) bool { + m := strings.ToLower(msg) + return strings.Contains(m, "unknown flag") || + strings.Contains(m, "unknown shorthand flag") || + strings.Contains(m, "flag needs an argument") || + strings.Contains(m, "invalid argument") || + strings.Contains(m, "required flag") || + strings.Contains(m, "usage:") +} diff --git a/cmd/picoclaw/internal/cliui/onboard.go b/cmd/picoclaw/internal/cliui/onboard.go new file mode 100644 index 000000000..e74cf68c6 --- /dev/null +++ b/cmd/picoclaw/internal/cliui/onboard.go @@ -0,0 +1,110 @@ +package cliui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// PrintOnboardComplete prints the post-onboard “ready” message and next steps. +func PrintOnboardComplete(logo string, encrypt bool, configPath string) { + if !UseFancyLayout() { + printOnboardPlain(logo, encrypt, configPath) + return + } + printOnboardFancy(logo, encrypt, configPath) +} + +func printOnboardPlain(logo string, encrypt bool, configPath string) { + fmt.Printf("\n%s picoclaw is ready!\n", logo) + fmt.Println("\nNext steps:") + if encrypt { + fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:") + fmt.Println(" export PICOCLAW_KEY_PASSPHRASE= # Linux/macOS") + fmt.Println(" set PICOCLAW_KEY_PASSPHRASE= # Windows cmd") + fmt.Println("") + fmt.Println(" 2. Add your API key to", configPath) + } else { + fmt.Println(" 1. Add your API key to", configPath) + } + fmt.Println("") + fmt.Println(" Recommended:") + fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)") + fmt.Println(" - Ollama: https://ollama.com (local, free)") + fmt.Println("") + fmt.Println(" See README.md for 17+ supported providers.") + fmt.Println("") + if encrypt { + fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"") + } else { + fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"") + } +} + +func printOnboardFancy(logo string, encrypt bool, configPath string) { + inner := InnerWidth() + box := borderStyle().MaxWidth(inner + 8) + + ready := titleBarStyle().Render(logo+" picoclaw is ready!") + "\n" + fmt.Println() + fmt.Println(box.Width(inner).Render(strings.TrimSpace(ready))) + fmt.Println() + + steps := buildOnboardingSteps(encrypt, configPath) + rec := recommendedBlock() + chat := chatStep(encrypt) + + if UseColumnLayout() { + leftW := min(inner/2-2, 52) + rightW := inner - leftW - 4 + if rightW < 36 { + rightW = 36 + } + leftBlock := borderStyle().MaxWidth(leftW + 8).Width(leftW). + Render(titleBarStyle().Render("Next steps") + "\n\n" + bodyStyle().Width(leftW).Render(steps)) + rightBlock := borderStyle().MaxWidth(rightW + 8).Width(rightW). + Render(mutedStyle().Bold(true).Render("Recommended") + "\n\n" + bodyStyle().Width(rightW).Render(rec)) + gap := strings.Repeat(" ", 2) + fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, leftBlock, gap, rightBlock)) + fmt.Println() + full := borderStyle().Width(inner).Render(bodyStyle().Width(inner - 4).Render(chat)) + fmt.Println(full) + return + } + + // Same order as plain output: numbered steps → recommended → chat line. + next := titleBarStyle().Render("Next steps") + "\n\n" + + bodyStyle().Width(inner-4).Render(steps+"\n\n"+rec+"\n\n"+chat) + fmt.Println(borderStyle().Width(inner).Render(next)) +} + +func buildOnboardingSteps(encrypt bool, configPath string) string { + var b strings.Builder + if encrypt { + b.WriteString("1. Set your encryption passphrase before starting picoclaw:\n") + b.WriteString(" export PICOCLAW_KEY_PASSPHRASE= # Linux/macOS\n") + b.WriteString(" set PICOCLAW_KEY_PASSPHRASE= # Windows cmd\n\n") + b.WriteString("2. Add your API key to\n ") + b.WriteString(configPath) + b.WriteString("\n") + } else { + b.WriteString("1. Add your API key to\n ") + b.WriteString(configPath) + b.WriteString("\n") + } + return b.String() +} + +func recommendedBlock() string { + return "• OpenRouter: https://openrouter.ai/keys\n (access 100+ models)\n\n" + + "• Ollama: https://ollama.com\n (local, free)\n\n" + + "See README.md for 17+ supported providers." +} + +func chatStep(encrypt bool) string { + if encrypt { + return "3. Chat:\n picoclaw agent -m \"Hello!\"" + } + return "2. Chat:\n picoclaw agent -m \"Hello!\"" +} diff --git a/cmd/picoclaw/internal/cliui/status.go b/cmd/picoclaw/internal/cliui/status.go new file mode 100644 index 000000000..f01fe296d --- /dev/null +++ b/cmd/picoclaw/internal/cliui/status.go @@ -0,0 +1,168 @@ +package cliui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// ProviderRow holds one provider's display name and status value. +type ProviderRow struct { + Name string + Val string +} + +// StatusReport is a structured status view for PrintStatus. +type StatusReport struct { + Logo string + Version string + Build string + ConfigPath string + ConfigOK bool + WorkspacePath string + WorkspaceOK bool + Model string + Providers []ProviderRow + OAuthLines []string // each full line "provider (method): state" +} + +// PrintStatus renders picoclaw status (plain or fancy). +func PrintStatus(r StatusReport) { + if !UseFancyLayout() { + printStatusPlain(r) + return + } + printStatusFancy(r) +} + +func printStatusPlain(r StatusReport) { + fmt.Printf("%s picoclaw Status\n", r.Logo) + fmt.Printf("Version: %s\n", r.Version) + if r.Build != "" { + fmt.Printf("Build: %s\n", r.Build) + } + fmt.Println() + + printPathLine("Config", r.ConfigPath, r.ConfigOK) + printPathLine("Workspace", r.WorkspacePath, r.WorkspaceOK) + + if r.ConfigOK { + fmt.Printf("Model: %s\n", r.Model) + for _, p := range r.Providers { + fmt.Printf("%s: %s\n", p.Name, p.Val) + } + if len(r.OAuthLines) > 0 { + fmt.Println("\nOAuth/Token Auth:") + for _, line := range r.OAuthLines { + fmt.Printf(" %s\n", line) + } + } + } +} + +func printPathLine(label, path string, ok bool) { + mark := "✗" + if ok { + mark = "✓" + } + fmt.Println(label+":", path, mark) +} + +func printStatusFancy(r StatusReport) { + inner := InnerWidth() + topBox := borderStyle().Width(inner) + + var head strings.Builder + head.WriteString(titleBarStyle().Render(r.Logo + " picoclaw Status")) + head.WriteString("\n\n") + head.WriteString(kvKeyStyle().Render("Version") + " " + kvValStyle().Render(r.Version)) + if r.Build != "" { + head.WriteString("\n") + head.WriteString(kvKeyStyle().Render("Build") + " " + kvValStyle().Render(r.Build)) + } + fmt.Println(topBox.Render(head.String())) + fmt.Println() + + if UseColumnLayout() && len(r.Providers) > 0 && r.ConfigOK { + leftW := (inner - 2) / 2 + rightW := inner - leftW - 2 + pathsNarrow := pathStatusPanel(r, leftW) + prov := providerTablePanel(r, rightW) + gap := strings.Repeat(" ", 2) + fmt.Println(lipgloss.JoinHorizontal(lipgloss.Top, pathsNarrow, gap, prov)) + } else { + fmt.Println(pathStatusPanel(r, inner)) + if len(r.Providers) > 0 && r.ConfigOK { + fmt.Println(providerTablePanel(r, inner)) + } + } + + if len(r.OAuthLines) > 0 && r.ConfigOK { + var ob strings.Builder + ob.WriteString(titleBarStyle().Render("OAuth / token auth") + "\n\n") + for _, line := range r.OAuthLines { + ob.WriteString(" • " + line + "\n") + } + fmt.Println() + fmt.Println(borderStyle().Width(inner).Render(ob.String())) + } +} + +func pathStatusPanel(r StatusReport, inner int) string { + cfgMark := statusMark(r.ConfigOK) + wsMark := statusMark(r.WorkspaceOK) + var b strings.Builder + b.WriteString(kvKeyStyle().Render("Config") + "\n") + b.WriteString(mutedStyle().Render(r.ConfigPath)) + b.WriteString(" " + cfgMark + "\n\n") + b.WriteString(kvKeyStyle().Render("Workspace") + "\n") + b.WriteString(mutedStyle().Render(r.WorkspacePath)) + b.WriteString(" " + wsMark + "\n") + if r.ConfigOK { + b.WriteString("\n") + b.WriteString(kvKeyStyle().Render("Model") + " " + kvValStyle().Render(r.Model)) + } + return borderStyle().Width(inner).Render(b.String()) +} + +func statusMark(ok bool) string { + if ok { + return lipgloss.NewStyle().Foreground(colorOK).Render("✓") + } + return lipgloss.NewStyle().Foreground(accentRed).Render("✗") +} + +func providerTablePanel(r StatusReport, colW int) string { + if len(r.Providers) == 0 { + return "" + } + keyW := min(22, colW/3) + if keyW < 14 { + keyW = 14 + } + valW := colW - keyW - 3 + if valW < 12 { + valW = 12 + } + + var b strings.Builder + b.WriteString(titleBarStyle().Render("Providers & local") + "\n\n") + for _, p := range r.Providers { + k := lipgloss.NewStyle().Foreground(accentBlue).Bold(true).Width(keyW).Render(p.Name) + v := styleProviderVal(p.Val).Width(valW).Render(p.Val) + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, k, " ", v)) + b.WriteString("\n") + } + return borderStyle().Width(colW).Render(strings.TrimRight(b.String(), "\n")) +} + +func styleProviderVal(s string) lipgloss.Style { + if s == "✓" || strings.HasPrefix(s, "✓ ") { + return lipgloss.NewStyle().Foreground(colorOK) + } + if s == "not set" { + return mutedStyle() + } + return lipgloss.NewStyle() +} diff --git a/cmd/picoclaw/internal/cliui/version.go b/cmd/picoclaw/internal/cliui/version.go new file mode 100644 index 000000000..7ecbdae7f --- /dev/null +++ b/cmd/picoclaw/internal/cliui/version.go @@ -0,0 +1,61 @@ +package cliui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// PrintVersion prints version, optional build info, and Go toolchain line. +func PrintVersion(logo, versionLine string, build, goVer string) { + if !UseFancyLayout() { + fmt.Printf("%s %s\n", logo, versionLine) + if build != "" { + fmt.Printf(" Build: %s\n", build) + } + if goVer != "" { + fmt.Printf(" Go: %s\n", goVer) + } + return + } + + inner := InnerWidth() + box := borderStyle().Width(inner) + + if UseColumnLayout() { + leftCol := kvKeyStyle().Width(12).Align(lipgloss.Right) + rightW := inner - 16 + rightStyle := kvValStyle().Width(rightW) + + rows := [][]string{ + {leftCol.Render("Version"), rightStyle.Render(versionLine)}, + } + if build != "" { + rows = append(rows, []string{leftCol.Render("Build"), rightStyle.Render(build)}) + } + if goVer != "" { + rows = append(rows, []string{leftCol.Render("Go"), rightStyle.Render(goVer)}) + } + var body strings.Builder + for _, r := range rows { + body.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, r[0], " ", r[1])) + body.WriteString("\n") + } + header := titleBarStyle().Render(logo+" picoclaw") + "\n\n" + fmt.Println(box.Render(header + body.String())) + return + } + + var lines []string + lines = append(lines, titleBarStyle().Render(logo+" picoclaw")) + lines = append(lines, "") + lines = append(lines, kvKeyStyle().Render("Version")+" "+kvValStyle().Render(versionLine)) + if build != "" { + lines = append(lines, kvKeyStyle().Render("Build")+" "+kvValStyle().Render(build)) + } + if goVer != "" { + lines = append(lines, kvKeyStyle().Render("Go")+" "+kvValStyle().Render(goVer)) + } + fmt.Println(box.Render(strings.Join(lines, "\n"))) +} diff --git a/cmd/picoclaw/internal/onboard/helpers.go b/cmd/picoclaw/internal/onboard/helpers.go index 626698fec..721d74552 100644 --- a/cmd/picoclaw/internal/onboard/helpers.go +++ b/cmd/picoclaw/internal/onboard/helpers.go @@ -9,6 +9,7 @@ import ( "golang.org/x/term" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/credential" ) @@ -79,29 +80,7 @@ func onboard(encrypt bool) { workspace := cfg.WorkspacePath() createWorkspaceTemplates(workspace) - fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo) - fmt.Println("\nNext steps:") - if encrypt { - fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:") - fmt.Println(" export PICOCLAW_KEY_PASSPHRASE= # Linux/macOS") - fmt.Println(" set PICOCLAW_KEY_PASSPHRASE= # Windows cmd") - fmt.Println("") - fmt.Println(" 2. Add your API key to", configPath) - } else { - fmt.Println(" 1. Add your API key to", configPath) - } - fmt.Println("") - fmt.Println(" Recommended:") - fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)") - fmt.Println(" - Ollama: https://ollama.com (local, free)") - fmt.Println("") - fmt.Println(" See README.md for 17+ supported providers.") - fmt.Println("") - if encrypt { - fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"") - } else { - fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"") - } + cliui.PrintOnboardComplete(internal.Logo, encrypt, configPath) } // promptPassphrase reads the encryption passphrase twice from the terminal diff --git a/cmd/picoclaw/internal/status/helpers.go b/cmd/picoclaw/internal/status/helpers.go index 43c5786a8..e8e4fee9a 100644 --- a/cmd/picoclaw/internal/status/helpers.go +++ b/cmd/picoclaw/internal/status/helpers.go @@ -3,8 +3,10 @@ package status import ( "fmt" "os" + "strings" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" ) @@ -17,43 +19,125 @@ func statusCmd() { } configPath := internal.GetConfigPath() - - fmt.Printf("%s picoclaw Status\n", internal.Logo) - fmt.Printf("Version: %s\n", config.FormatVersion()) build, _ := config.FormatBuildInfo() - if build != "" { - fmt.Printf("Build: %s\n", build) - } - fmt.Println() - if _, err := os.Stat(configPath); err == nil { - fmt.Println("Config:", configPath, "✓") - } else { - fmt.Println("Config:", configPath, "✗") - } + _, configStatErr := os.Stat(configPath) + configOK := configStatErr == nil workspace := cfg.WorkspacePath() - if _, err := os.Stat(workspace); err == nil { - fmt.Println("Workspace:", workspace, "✓") - } else { - fmt.Println("Workspace:", workspace, "✗") + _, wsErr := os.Stat(workspace) + wsOK := wsErr == nil + + report := cliui.StatusReport{ + Logo: internal.Logo, + Version: config.FormatVersion(), + Build: build, + ConfigPath: configPath, + ConfigOK: configOK, + WorkspacePath: workspace, + WorkspaceOK: wsOK, + Model: cfg.Agents.Defaults.GetModelName(), } - if _, err := os.Stat(configPath); err == nil { - fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName()) + if configOK { + // PicoClaw moved to a model-centric configuration (model_list). Status should + // not depend on a legacy cfg.Providers field (which may not exist under some + // build tags). We infer provider availability from model_list entries. + hasProtocolKey := func(protocol string) bool { + prefix := protocol + "/" + for _, m := range cfg.ModelList { + if m == nil { + continue + } + if strings.HasPrefix(m.Model, prefix) && m.APIKey() != "" { + return true + } + } + return false + } + findLocalModelBase := func(modelName string) (string, bool) { + for _, m := range cfg.ModelList { + if m == nil { + continue + } + if m.ModelName == modelName && m.APIBase != "" { + return m.APIBase, true + } + } + return "", false + } + findProtocolBase := func(protocol string) (string, bool) { + prefix := protocol + "/" + for _, m := range cfg.ModelList { + if m == nil { + continue + } + if strings.HasPrefix(m.Model, prefix) && m.APIBase != "" { + return m.APIBase, true + } + } + return "", false + } + + hasOpenRouter := hasProtocolKey("openrouter") + hasAnthropic := hasProtocolKey("anthropic") + hasOpenAI := hasProtocolKey("openai") + hasGemini := hasProtocolKey("gemini") + hasZhipu := hasProtocolKey("zhipu") + hasQwen := hasProtocolKey("qwen") + hasGroq := hasProtocolKey("groq") + hasMoonshot := hasProtocolKey("moonshot") + hasDeepSeek := hasProtocolKey("deepseek") + hasVolcEngine := hasProtocolKey("volcengine") + hasNvidia := hasProtocolKey("nvidia") + + // Local endpoints: allow both the special reserved name and protocol-based entries. + vllmBase, hasVLLM := findLocalModelBase("local-model") + if !hasVLLM { + vllmBase, hasVLLM = findProtocolBase("vllm") + } + ollamaBase, hasOllama := findProtocolBase("ollama") + + val := func(enabled bool, extra ...string) string { + if enabled { + if len(extra) > 0 && extra[0] != "" { + return "✓ " + extra[0] + } + return "✓" + } + return "not set" + } + + report.Providers = []cliui.ProviderRow{ + {Name: "OpenRouter API", Val: val(hasOpenRouter)}, + {Name: "Anthropic API", Val: val(hasAnthropic)}, + {Name: "OpenAI API", Val: val(hasOpenAI)}, + {Name: "Gemini API", Val: val(hasGemini)}, + {Name: "Zhipu API", Val: val(hasZhipu)}, + {Name: "Qwen API", Val: val(hasQwen)}, + {Name: "Groq API", Val: val(hasGroq)}, + {Name: "Moonshot API", Val: val(hasMoonshot)}, + {Name: "DeepSeek API", Val: val(hasDeepSeek)}, + {Name: "VolcEngine API", Val: val(hasVolcEngine)}, + {Name: "Nvidia API", Val: val(hasNvidia)}, + {Name: "vLLM / local", Val: val(hasVLLM, vllmBase)}, + {Name: "Ollama", Val: val(hasOllama, ollamaBase)}, + } store, _ := auth.LoadStore() if store != nil && len(store.Credentials) > 0 { - fmt.Println("\nOAuth/Token Auth:") for provider, cred := range store.Credentials { - status := "authenticated" + st := "authenticated" if cred.IsExpired() { - status = "expired" + st = "expired" } else if cred.NeedsRefresh() { - status = "needs refresh" + st = "needs refresh" } - fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status) + report.OAuthLines = append(report.OAuthLines, + fmt.Sprintf("%s (%s): %s", provider, cred.AuthMethod, st)) } } } + + cliui.PrintStatus(report) } diff --git a/cmd/picoclaw/internal/version/command.go b/cmd/picoclaw/internal/version/command.go index 71c7dd2f8..81da4b878 100644 --- a/cmd/picoclaw/internal/version/command.go +++ b/cmd/picoclaw/internal/version/command.go @@ -1,11 +1,10 @@ package version import ( - "fmt" - "github.com/spf13/cobra" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/pkg/config" ) @@ -23,12 +22,6 @@ func NewVersionCommand() *cobra.Command { } func printVersion() { - fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion()) build, goVer := config.FormatBuildInfo() - if build != "" { - fmt.Printf(" Build: %s\n", build) - } - if goVer != "" { - fmt.Printf(" Go: %s\n", goVer) - } + cliui.PrintVersion(internal.Logo, "picoclaw "+config.FormatVersion(), build, goVer) } diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 543577e68..0867203a6 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -16,6 +16,7 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate" @@ -28,15 +29,57 @@ import ( "github.com/sipeed/picoclaw/pkg/updater" ) +var rootNoColor bool + +func syncCliUIColor(root *cobra.Command) { + no, _ := root.PersistentFlags().GetBool("no-color") + cliui.Init(no || os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb") +} + +// earlyColorDisabled matches lipgloss/banner behavior from env and argv before Cobra parses flags. +func earlyColorDisabled() bool { + if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" { + return true + } + for i := 1; i < len(os.Args); i++ { + arg := os.Args[i] + if arg == "--no-color" || arg == "--no-color=true" || arg == "--no-color=1" { + return true + } + } + return false +} + func NewPicoclawCommand() *cobra.Command { - short := fmt.Sprintf("%s picoclaw - Personal AI Assistant %s\n\n", internal.Logo, config.GetVersion()) + short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo) + long := fmt.Sprintf(`%s PicoClaw is a lightweight personal AI assistant. + +Version: %s`, internal.Logo, config.FormatVersion()) cmd := &cobra.Command{ - Use: "picoclaw", - Short: short, - Example: "picoclaw version", + Use: "picoclaw", + Short: short, + Long: long, + Example: `picoclaw version +picoclaw onboard +picoclaw --no-color status`, + SilenceErrors: true, + // Avoid plain UsageString() on stderr/stdout when a command fails; cliui + // renders matching panels on stderr instead. + SilenceUsage: true, + PersistentPreRun: func(c *cobra.Command, _ []string) { + syncCliUIColor(c.Root()) + }, } + cmd.PersistentFlags().BoolVar(&rootNoColor, "no-color", false, + "Disable colors (boxed layout unchanged)") + + cmd.SetHelpFunc(func(c *cobra.Command, _ []string) { + syncCliUIColor(c.Root()) + fmt.Fprint(c.OutOrStdout(), cliui.RenderCommandHelp(c)) + }) + cmd.AddCommand( onboard.NewOnboardCommand(), agent.NewAgentCommand(), @@ -65,17 +108,31 @@ const ( colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" + colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " + "\033[0m\r\n" + plainBanner = "\r\n" + + "██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" + + "██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" + + "██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" + + "██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" + + "██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" + + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " + + "\r\n" ) func main() { - fmt.Printf("%s", banner) + cliui.Init(earlyColorDisabled()) - tz_env := os.Getenv("TZ") - if tz_env != "" { - fmt.Println("TZ environment:", tz_env) - zoneinfo_env := os.Getenv("ZONEINFO") - fmt.Println("ZONEINFO environment:", zoneinfo_env) - loc, err := time.LoadLocation(tz_env) + if earlyColorDisabled() { + fmt.Print(plainBanner) + } else { + fmt.Printf("%s", banner) + } + + tzEnv := os.Getenv("TZ") + if tzEnv != "" { + fmt.Println("TZ environment:", tzEnv) + zoneinfoEnv := os.Getenv("ZONEINFO") + fmt.Println("ZONEINFO environment:", zoneinfoEnv) + loc, err := time.LoadLocation(tzEnv) if err != nil { fmt.Println("Error loading time zone:", err) } else { @@ -85,7 +142,10 @@ func main() { } cmd := NewPicoclawCommand() - if err := cmd.Execute(); err != nil { + last, err := cmd.ExecuteC() + if err != nil { + syncCliUIColor(cmd) + fmt.Fprint(os.Stderr, cliui.FormatCLIError(err.Error(), last)) os.Exit(1) } } diff --git a/cmd/picoclaw/main_test.go b/cmd/picoclaw/main_test.go index 3e147cbfe..309e60ba9 100644 --- a/cmd/picoclaw/main_test.go +++ b/cmd/picoclaw/main_test.go @@ -3,6 +3,7 @@ package main import ( "fmt" "slices" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -17,20 +18,22 @@ func TestNewPicoclawCommand(t *testing.T) { require.NotNil(t, cmd) - short := fmt.Sprintf("%s picoclaw - Personal AI Assistant %s\n\n", internal.Logo, config.GetVersion()) + short := fmt.Sprintf("%s PicoClaw — personal AI assistant", internal.Logo) + longHas := strings.Contains(cmd.Long, config.FormatVersion()) assert.Equal(t, "picoclaw", cmd.Use) assert.Equal(t, short, cmd.Short) + assert.True(t, longHas) assert.True(t, cmd.HasSubCommands()) assert.True(t, cmd.HasAvailableSubCommands()) - assert.False(t, cmd.HasFlags()) + assert.True(t, cmd.PersistentFlags().Lookup("no-color") != nil) assert.Nil(t, cmd.Run) assert.Nil(t, cmd.RunE) - assert.Nil(t, cmd.PersistentPreRun) + assert.NotNil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) allowedCommands := []string{ diff --git a/go.mod b/go.mod index 9eaa72a0b..b7259bde7 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 github.com/bwmarrin/discordgo v0.29.0 github.com/caarlos0/env/v11 v11.4.0 + github.com/charmbracelet/lipgloss v1.1.0 github.com/creack/pty v1.1.24 github.com/ergochat/irc-go v0.6.0 github.com/ergochat/readline v0.1.3 @@ -25,6 +26,7 @@ require ( github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mdp/qrterminal/v3 v3.2.1 github.com/minio/selfupdate v0.6.0 + github.com/muesli/termenv v0.16.0 github.com/modelcontextprotocol/go-sdk v1.5.0 github.com/mymmrac/telego v1.8.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 @@ -35,6 +37,7 @@ require ( github.com/rs/zerolog v1.35.0 github.com/slack-go/slack v0.17.3 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 github.com/tencent-connect/botgo v0.2.1 go.mau.fi/util v0.9.7 @@ -65,7 +68,12 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect github.com/aws/smithy-go v1.24.2 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beeper/argo-go v1.1.2 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -79,6 +87,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect @@ -88,10 +97,10 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect - github.com/spf13/pflag v1.0.10 // indirect github.com/vektah/gqlparser/v2 v2.5.27 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.mau.fi/libsignal v0.2.1 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect diff --git a/go.sum b/go.sum index 6a2194960..8306976c4 100644 --- a/go.sum +++ b/go.sum @@ -55,6 +55,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBU github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= @@ -67,6 +69,16 @@ github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoG github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= @@ -179,12 +191,16 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU= github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA= github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow= @@ -218,6 +234,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= @@ -276,6 +293,8 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=