refactor(cli): replace stdlib imports with core/go primitives

Replace fmt, errors, path/filepath, strings, and strconv usages with
core/go equivalents where available. Fully removes "errors" and
"path/filepath" imports. Retains fmt (Fprint/Fprintf/Fscanln/Sscanf/Stringer),
strings (Builder/Repeat/LastIndex/NewReplacer/FieldsSeq/SplitSeq), and
strconv (Atoi/ParseUint) where core/go has no equivalent.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-04-08 11:08:02 +01:00
parent 437fa93918
commit bf53270631
14 changed files with 160 additions and 557 deletions

View file

@ -1,11 +1,11 @@
package cli
import (
"fmt"
"os"
"strconv"
"strings"
"sync"
"dappco.re/go/core"
)
// ANSI escape codes
@ -147,24 +147,24 @@ func (s *AnsiStyle) Render(text string) string {
return text
}
return strings.Join(codes, "") + text + ansiReset
return core.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)
return core.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)
return core.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, "#")
hex = core.TrimPrefix(hex, "#")
if len(hex) != 6 {
return 255, 255, 255
}

View file

@ -2,18 +2,16 @@ package cli
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"dappco.re/go/core"
)
// DaemonOptions configures a background process helper.
@ -68,7 +66,7 @@ var (
return false
}
err = proc.Signal(syscall.Signal(0))
return err == nil || errors.Is(err, syscall.EPERM)
return err == nil || core.Is(err, syscall.EPERM)
}
processSignal = func(pid int, sig syscall.Signal) error {
proc, err := os.FindProcess(pid)
@ -188,9 +186,9 @@ func StopPIDFile(pidFile string, timeout time.Duration) error {
return err
}
pid, err := parsePID(strings.TrimSpace(string(rawPID)))
pid, err := parsePID(core.Trim(string(rawPID)))
if err != nil {
return fmt.Errorf("parse pid file %q: %w", pidFile, err)
return core.E("StopPIDFile", core.Sprintf("parse pid file %q", pidFile), err)
}
if err := processSignal(pid, syscall.SIGTERM); err != nil && !isProcessGone(err) {
@ -213,7 +211,7 @@ func StopPIDFile(pidFile string, timeout time.Duration) error {
}
if processAlive(pid) {
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
return core.E("StopPIDFile", core.Sprintf("process %d did not exit after SIGKILL", pid), nil)
}
}
@ -222,20 +220,20 @@ func StopPIDFile(pidFile string, timeout time.Duration) error {
func parsePID(raw string) (int, error) {
if raw == "" {
return 0, fmt.Errorf("empty pid")
return 0, core.NewError("empty pid")
}
pid, err := strconv.Atoi(raw)
if err != nil {
return 0, err
}
if pid <= 0 {
return 0, fmt.Errorf("invalid pid %d", pid)
return 0, core.E("parsePID", core.Sprintf("invalid pid %d", pid), nil)
}
return pid, nil
}
func isProcessGone(err error) bool {
return errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH)
return core.Is(err, os.ErrProcessDone) || core.Is(err, syscall.ESRCH)
}
func (d *Daemon) writePIDFile() error {
@ -243,10 +241,10 @@ func (d *Daemon) writePIDFile() error {
return nil
}
if err := os.MkdirAll(filepath.Dir(d.opts.PIDFile), 0o755); err != nil {
if err := os.MkdirAll(core.PathDir(d.opts.PIDFile), 0o755); err != nil {
return err
}
return os.WriteFile(d.opts.PIDFile, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644)
return os.WriteFile(d.opts.PIDFile, []byte(core.Sprintf("%d", os.Getpid())+"\n"), 0o644)
}
func (d *Daemon) removePIDFile() error {
@ -318,5 +316,5 @@ func isClosedServerError(err error) bool {
}
func isListenerClosedError(err error) bool {
return err == nil || errors.Is(err, net.ErrClosed)
return err == nil || core.Is(err, net.ErrClosed)
}

View file

@ -1,10 +1,9 @@
package cli
import (
"errors"
"fmt"
"os"
"dappco.re/go/core"
"dappco.re/go/core/i18n"
)
@ -15,7 +14,7 @@ import (
// 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...)
return core.E("cli", core.Sprintf(format, args...), nil)
}
// Wrap wraps an error with a message.
@ -26,7 +25,7 @@ func Wrap(err error, msg string) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", msg, err)
return core.E("cli", msg, err)
}
// WrapVerb wraps an error using i18n grammar for "Failed to verb subject".
@ -39,7 +38,7 @@ func WrapVerb(err error, verb, subject string) error {
return nil
}
msg := i18n.ActionFailed(verb, subject)
return fmt.Errorf("%s: %w", msg, err)
return core.E("cli", msg, err)
}
// WrapAction wraps an error using i18n grammar for "Failed to verb".
@ -52,7 +51,7 @@ func WrapAction(err error, verb string) error {
return nil
}
msg := i18n.ActionFailed(verb, "")
return fmt.Errorf("%s: %w", msg, err)
return core.E("cli", msg, err)
}
// ─────────────────────────────────────────────────────────────────────────────
@ -62,19 +61,19 @@ func WrapAction(err error, verb string) error {
// 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)
return core.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)
return core.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...)
return core.ErrorJoin(errs...)
}
// ExitError represents an error that should cause the CLI to exit with a specific code.
@ -120,7 +119,7 @@ func Exit(code int, err error) error {
func Fatal(err error) {
if err != nil {
LogError("Fatal error", "err", err)
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
core.Print(stderrWriter(), "%s", ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
os.Exit(1)
}
}
@ -129,9 +128,9 @@ func Fatal(err error) {
//
// Deprecated: return an error from the command instead.
func Fatalf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
msg := core.Sprintf(format, args...)
LogError("Fatal error", "msg", msg)
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+msg))
core.Print(stderrWriter(), "%s", ErrorStyle.Render(Glyph(":cross:")+" "+msg))
os.Exit(1)
}
@ -146,8 +145,8 @@ func FatalWrap(err error, msg string) {
return
}
LogError("Fatal error", "msg", msg, "err", err)
fullMsg := fmt.Sprintf("%s: %v", msg, err)
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
fullMsg := core.Sprintf("%s: %v", msg, err)
core.Print(stderrWriter(), "%s", ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
os.Exit(1)
}
@ -163,7 +162,7 @@ func FatalWrapVerb(err error, verb, subject string) {
}
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(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
fullMsg := core.Sprintf("%s: %v", msg, err)
core.Print(stderrWriter(), "%s", ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
os.Exit(1)
}

View file

@ -4,10 +4,10 @@ import (
"fmt"
"io"
"os"
"strings"
"sync"
"time"
"dappco.re/go/core"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
@ -27,24 +27,15 @@ type ModelFunc func(width, height int) string
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
history []Model
out io.Writer
done chan struct{}
mu sync.Mutex
// Focus management (bubbletea upgrade)
focused Region
keyMap KeyMap
width int
@ -53,9 +44,6 @@ type Frame struct {
}
// 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,
@ -70,8 +58,6 @@ func NewFrame(variant string) *Frame {
}
}
// WithOutput sets the destination writer for rendered output.
// Pass nil to keep the current writer unchanged.
func (f *Frame) WithOutput(out io.Writer) *Frame {
if out != nil {
f.out = out
@ -79,20 +65,11 @@ func (f *Frame) WithOutput(out io.Writer) *Frame {
return f
}
// 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) Header(m Model) *Frame { f.setModel(RegionHeader, m); return f }
func (f *Frame) Left(m Model) *Frame { f.setModel(RegionLeft, m); return f }
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) Right(m Model) *Frame { f.setModel(RegionRight, m); return f }
func (f *Frame) Footer(m Model) *Frame { f.setModel(RegionFooter, m); return f }
func (f *Frame) setModel(r Region, m Model) {
f.mu.Lock()
@ -100,8 +77,6 @@ func (f *Frame) setModel(r Region, m Model) {
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()
@ -111,8 +86,6 @@ func (f *Frame) Navigate(m Model) {
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()
@ -124,7 +97,6 @@ func (f *Frame) Back() bool {
return true
}
// Stop signals the Frame to exit its Run loop.
func (f *Frame) Stop() {
if f.program != nil {
f.program.Quit()
@ -137,29 +109,23 @@ func (f *Frame) Stop() {
}
}
// Send injects a message into the Frame's tea.Program.
// Safe to call before Run() (message is discarded).
func (f *Frame) Send(msg tea.Msg) {
if f.program != nil {
f.program.Send(msg)
}
}
// WithKeyMap sets custom key bindings for Frame navigation.
func (f *Frame) WithKeyMap(km KeyMap) *Frame {
f.keyMap = km
return f
}
// Focused returns the currently focused region.
func (f *Frame) Focused() Region {
f.mu.Lock()
defer f.mu.Unlock()
return f.focused
}
// Focus sets focus to a specific region.
// Ignores the request if the region is not in this Frame's variant.
func (f *Frame) Focus(r Region) {
f.mu.Lock()
defer f.mu.Unlock()
@ -168,8 +134,6 @@ func (f *Frame) Focus(r Region) {
}
}
// buildFocusRing returns the ordered list of regions in this Frame's variant.
// Order follows HLCRF convention.
func (f *Frame) buildFocusRing() []Region {
order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter}
var ring []Region
@ -181,11 +145,9 @@ func (f *Frame) buildFocusRing() []Region {
return ring
}
// Init implements tea.Model. Collects Init() from all FrameModel regions.
func (f *Frame) Init() tea.Cmd {
f.mu.Lock()
defer f.mu.Unlock()
var cmds []tea.Cmd
for _, m := range f.models {
fm := adaptModel(m)
@ -196,7 +158,6 @@ func (f *Frame) Init() tea.Cmd {
return tea.Batch(cmds...)
}
// Update implements tea.Model. Routes messages based on type and focus.
func (f *Frame) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
f.mu.Lock()
defer f.mu.Unlock()
@ -206,52 +167,39 @@ func (f *Frame) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
f.width = msg.Width
f.height = msg.Height
return f, f.broadcastLocked(msg)
case tea.KeyMsg:
switch msg.Type {
case f.keyMap.Quit:
return f, tea.Quit
case f.keyMap.Back:
f.backLocked()
return f, nil
case f.keyMap.FocusNext:
f.cycleFocusLocked(1)
return f, nil
case f.keyMap.FocusPrev:
f.cycleFocusLocked(-1)
return f, nil
case f.keyMap.FocusUp:
f.spatialFocusLocked(RegionHeader)
return f, nil
case f.keyMap.FocusDown:
f.spatialFocusLocked(RegionFooter)
return f, nil
case f.keyMap.FocusLeft:
f.spatialFocusLocked(RegionLeft)
return f, nil
case f.keyMap.FocusRight:
f.spatialFocusLocked(RegionRight)
return f, nil
default:
// Forward to focused region
return f, f.updateFocusedLocked(msg)
}
default:
// Broadcast non-key messages to all regions
return f, f.broadcastLocked(msg)
}
}
// View implements tea.Model. Composes region views using lipgloss.
func (f *Frame) View() string {
f.mu.Lock()
defer f.mu.Unlock()
@ -264,7 +212,6 @@ func (f *Frame) viewLocked() string {
w, h = f.termSize()
}
// Calculate region dimensions
headerH, footerH := 0, 0
if _, ok := f.layout.regions[RegionHeader]; ok {
if _, ok := f.models[RegionHeader]; ok {
@ -278,11 +225,9 @@ func (f *Frame) viewLocked() string {
}
middleH := max(h-headerH-footerH, 1)
// Render each region
header := f.renderRegionLocked(RegionHeader, w, headerH)
footer := f.renderRegionLocked(RegionFooter, w, footerH)
// Calculate sidebar widths
leftW, rightW := 0, 0
if _, ok := f.layout.regions[RegionLeft]; ok {
if _, ok := f.models[RegionLeft]; ok {
@ -300,7 +245,6 @@ func (f *Frame) viewLocked() string {
right := f.renderRegionLocked(RegionRight, rightW, middleH)
content := f.renderRegionLocked(RegionContent, contentW, middleH)
// Compose middle row
var middleParts []string
if leftW > 0 {
middleParts = append(middleParts, left)
@ -315,7 +259,6 @@ func (f *Frame) viewLocked() string {
middle = lipgloss.JoinHorizontal(lipgloss.Top, middleParts...)
}
// Compose full layout
var verticalParts []string
if headerH > 0 {
verticalParts = append(verticalParts, header)
@ -340,8 +283,6 @@ func (f *Frame) renderRegionLocked(r Region, w, h int) string {
return fm.View(w, h)
}
// cycleFocusLocked moves focus forward (+1) or backward (-1) in the focus ring.
// Must be called with f.mu held.
func (f *Frame) cycleFocusLocked(dir int) {
ring := f.buildFocusRing()
if len(ring) == 0 {
@ -358,15 +299,12 @@ func (f *Frame) cycleFocusLocked(dir int) {
f.focused = ring[idx]
}
// spatialFocusLocked moves focus to a specific region if it exists in the layout.
// Must be called with f.mu held.
func (f *Frame) spatialFocusLocked(target Region) {
if _, exists := f.layout.regions[target]; exists {
f.focused = target
}
}
// backLocked pops the content history. Must be called with f.mu held.
func (f *Frame) backLocked() {
if len(f.history) == 0 {
return
@ -375,8 +313,6 @@ func (f *Frame) backLocked() {
f.history = f.history[:len(f.history)-1]
}
// broadcastLocked sends a message to all FrameModel regions.
// Must be called with f.mu held.
func (f *Frame) broadcastLocked(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
for r, m := range f.models {
@ -390,8 +326,6 @@ func (f *Frame) broadcastLocked(msg tea.Msg) tea.Cmd {
return tea.Batch(cmds...)
}
// updateFocusedLocked sends a message to only the focused region.
// Must be called with f.mu held.
func (f *Frame) updateFocusedLocked(msg tea.Msg) tea.Cmd {
m, ok := f.models[f.focused]
if !ok {
@ -403,8 +337,7 @@ func (f *Frame) updateFocusedLocked(msg tea.Msg) tea.Cmd {
return cmd
}
// Run renders the frame and blocks. In TTY mode, it live-refreshes at ~12fps.
// In non-TTY mode, it renders once and returns immediately.
// Run renders the frame and blocks.
func (f *Frame) Run() {
if !f.isTTY() {
fmt.Fprint(f.out, f.String())
@ -414,7 +347,6 @@ func (f *Frame) Run() {
}
// 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)
@ -429,7 +361,6 @@ func (f *Frame) RunFor(d time.Duration) {
}
// 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()
@ -439,8 +370,7 @@ func (f *Frame) String() string {
return ""
}
view = ansi.Strip(view)
// Ensure trailing newline for non-TTY consistency
if !strings.HasSuffix(view, "\n") {
if !core.HasSuffix(view, "\n") {
view += "\n"
}
return view
@ -460,7 +390,7 @@ func (f *Frame) termSize() (int, int) {
return w, h
}
}
return 80, 24 // sensible default
return 80, 24
}
func (f *Frame) runLive() {

View file

@ -1,8 +1,9 @@
package cli
import (
"fmt"
"iter"
"dappco.re/go/core"
)
// Region represents one of the 5 HLCRF regions.
@ -91,7 +92,7 @@ func ParseVariant(variant string) (*Composite, error) {
for i < len(variant) {
r := Region(variant[i])
if !isValidRegion(r) {
return nil, fmt.Errorf("invalid region: %c", r)
return nil, core.E("ParseVariant", core.Sprintf("invalid region: %c", r), nil)
}
slot := &Slot{region: r, path: string(r)}
@ -101,7 +102,7 @@ func ParseVariant(variant string) (*Composite, error) {
if i < len(variant) && variant[i] == '[' {
end := findMatchingBracket(variant, i)
if end == -1 {
return nil, fmt.Errorf("unmatched bracket at %d", i)
return nil, core.E("ParseVariant", core.Sprintf("unmatched bracket at %d", i), nil)
}
nested, err := ParseVariant(variant[i+1 : end])
if err != nil {
@ -168,6 +169,6 @@ func toRenderable(item any) Renderable {
case string:
return StringBlock(v)
default:
return StringBlock(fmt.Sprint(v))
return StringBlock(core.Sprint(v))
}
}

View file

@ -1,8 +1,7 @@
package cli
import (
"fmt"
"dappco.re/go/core"
"dappco.re/go/core/log"
)
@ -46,5 +45,5 @@ func LogSecurity(msg string, keyvals ...any) { log.Security(msg, keyvals...) }
//
// cli.LogSecurityf("login attempt from %s", username)
func LogSecurityf(format string, args ...any) {
log.Security(fmt.Sprintf(format, args...))
log.Security(core.Sprintf(format, args...))
}

View file

@ -2,58 +2,58 @@ package cli
import (
"fmt"
"strings"
"dappco.re/go/core"
"dappco.re/go/core/i18n"
)
// Blank prints an empty line.
func Blank() {
fmt.Fprintln(stdoutWriter())
core.Print(stdoutWriter(), "")
}
// Echo translates a key via i18n.T and prints with newline.
// No automatic styling - use Success/Error/Warn/Info for styled output.
func Echo(key string, args ...any) {
fmt.Fprintln(stdoutWriter(), compileGlyphs(i18n.T(key, args...)))
core.Print(stdoutWriter(), "%s", compileGlyphs(i18n.T(key, args...)))
}
// Print outputs formatted text (no newline).
// Glyph shortcodes like :check: are converted.
func Print(format string, args ...any) {
fmt.Fprint(stdoutWriter(), compileGlyphs(fmt.Sprintf(format, args...)))
fmt.Fprint(stdoutWriter(), compileGlyphs(core.Sprintf(format, args...)))
}
// Println outputs formatted text with newline.
// Glyph shortcodes like :check: are converted.
func Println(format string, args ...any) {
fmt.Fprintln(stdoutWriter(), compileGlyphs(fmt.Sprintf(format, args...)))
core.Print(stdoutWriter(), "%s", compileGlyphs(core.Sprintf(format, args...)))
}
// Text prints arguments like fmt.Println, but handling glyphs.
func Text(args ...any) {
fmt.Fprintln(stdoutWriter(), compileGlyphs(fmt.Sprint(args...)))
core.Print(stdoutWriter(), "%s", compileGlyphs(core.Sprint(args...)))
}
// Success prints a success message with checkmark (green).
func Success(msg string) {
fmt.Fprintln(stdoutWriter(), SuccessStyle.Render(Glyph(":check:")+" "+compileGlyphs(msg)))
core.Print(stdoutWriter(), "%s", SuccessStyle.Render(Glyph(":check:")+" "+compileGlyphs(msg)))
}
// Successf prints a formatted success message.
func Successf(format string, args ...any) {
Success(fmt.Sprintf(format, args...))
Success(core.Sprintf(format, args...))
}
// Error prints an error message with cross (red) to stderr and logs it.
func Error(msg string) {
LogError(msg)
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+compileGlyphs(msg)))
core.Print(stderrWriter(), "%s", ErrorStyle.Render(Glyph(":cross:")+" "+compileGlyphs(msg)))
}
// Errorf prints a formatted error message to stderr and logs it.
func Errorf(format string, args ...any) {
Error(fmt.Sprintf(format, args...))
Error(core.Sprintf(format, args...))
}
// ErrorWrap prints a wrapped error message to stderr and logs it.
@ -61,7 +61,7 @@ func ErrorWrap(err error, msg string) {
if err == nil {
return
}
Error(fmt.Sprintf("%s: %v", msg, err))
Error(core.Sprintf("%s: %v", msg, err))
}
// ErrorWrapVerb prints a wrapped error using i18n grammar to stderr and logs it.
@ -70,7 +70,7 @@ func ErrorWrapVerb(err error, verb, subject string) {
return
}
msg := i18n.ActionFailed(verb, subject)
Error(fmt.Sprintf("%s: %v", msg, err))
Error(core.Sprintf("%s: %v", msg, err))
}
// ErrorWrapAction prints a wrapped error using i18n grammar to stderr and logs it.
@ -79,33 +79,33 @@ func ErrorWrapAction(err error, verb string) {
return
}
msg := i18n.ActionFailed(verb, "")
Error(fmt.Sprintf("%s: %v", msg, err))
Error(core.Sprintf("%s: %v", msg, err))
}
// Warn prints a warning message with warning symbol (amber) to stderr and logs it.
func Warn(msg string) {
LogWarn(msg)
fmt.Fprintln(stderrWriter(), WarningStyle.Render(Glyph(":warn:")+" "+compileGlyphs(msg)))
core.Print(stderrWriter(), "%s", WarningStyle.Render(Glyph(":warn:")+" "+compileGlyphs(msg)))
}
// Warnf prints a formatted warning message to stderr and logs it.
func Warnf(format string, args ...any) {
Warn(fmt.Sprintf(format, args...))
Warn(core.Sprintf(format, args...))
}
// Info prints an info message with info symbol (blue).
func Info(msg string) {
fmt.Fprintln(stdoutWriter(), InfoStyle.Render(Glyph(":info:")+" "+compileGlyphs(msg)))
core.Print(stdoutWriter(), "%s", InfoStyle.Render(Glyph(":info:")+" "+compileGlyphs(msg)))
}
// Infof prints a formatted info message.
func Infof(format string, args ...any) {
Info(fmt.Sprintf(format, args...))
Info(core.Sprintf(format, args...))
}
// Dim prints dimmed text.
func Dim(msg string) {
fmt.Fprintln(stdoutWriter(), DimStyle.Render(compileGlyphs(msg)))
core.Print(stdoutWriter(), "%s", DimStyle.Render(compileGlyphs(msg)))
}
// Progress prints a progress indicator that overwrites the current line.
@ -126,7 +126,7 @@ func ProgressDone() {
// Label prints a "Label: value" line.
func Label(word, value string) {
fmt.Fprintf(stdoutWriter(), "%s %s\n", KeyStyle.Render(compileGlyphs(i18n.Label(word))), compileGlyphs(value))
core.Print(stdoutWriter(), "%s %s", KeyStyle.Render(compileGlyphs(i18n.Label(word))), compileGlyphs(value))
}
// Scanln reads from stdin.
@ -139,7 +139,7 @@ func Scanln(a ...any) (int, error) {
// cli.Task("php", "Running tests...") // [php] Running tests...
// cli.Task("go", i18n.Progress("build")) // [go] Building...
func Task(label, message string) {
fmt.Fprintf(stdoutWriter(), "%s %s\n\n", DimStyle.Render("["+compileGlyphs(label)+"]"), compileGlyphs(message))
core.Print(stdoutWriter(), "%s %s\n", DimStyle.Render("["+compileGlyphs(label)+"]"), compileGlyphs(message))
}
// Section prints a section header: "── SECTION ──"
@ -147,8 +147,8 @@ func Task(label, message string) {
// cli.Section("audit") // ── AUDIT ──
func Section(name string) {
dash := Glyph(":dash:")
header := dash + dash + " " + strings.ToUpper(compileGlyphs(name)) + " " + dash + dash
fmt.Fprintln(stdoutWriter(), AccentStyle.Render(header))
header := dash + dash + " " + core.Upper(compileGlyphs(name)) + " " + dash + dash
core.Print(stdoutWriter(), "%s", AccentStyle.Render(header))
}
// Hint prints a labelled hint: "label: message"
@ -156,7 +156,7 @@ func Section(name string) {
// cli.Hint("install", "composer require vimeo/psalm")
// cli.Hint("fix", "core php fmt --fix")
func Hint(label, message string) {
fmt.Fprintf(stdoutWriter(), " %s %s\n", DimStyle.Render(compileGlyphs(label)+":"), compileGlyphs(message))
core.Print(stdoutWriter(), " %s %s", DimStyle.Render(compileGlyphs(label)+":"), compileGlyphs(message))
}
// Severity prints a severity-styled message.
@ -167,7 +167,7 @@ func Hint(label, message string) {
// cli.Severity("low", "Debug enabled") // gray
func Severity(level, message string) {
var style *AnsiStyle
switch strings.ToLower(level) {
switch core.Lower(level) {
case "critical":
style = NewStyle().Bold().Foreground(ColourRed500)
case "high":
@ -179,7 +179,7 @@ func Severity(level, message string) {
default:
style = DimStyle
}
fmt.Fprintf(stdoutWriter(), " %s %s\n", style.Render("["+compileGlyphs(level)+"]"), compileGlyphs(message))
core.Print(stdoutWriter(), " %s %s", style.Render("["+compileGlyphs(level)+"]"), compileGlyphs(message))
}
// Result prints a result line: "✓ message" or "✗ message"

View file

@ -2,14 +2,13 @@ package cli
import (
"bufio"
"errors"
"fmt"
"io"
"strconv"
"strings"
"dappco.re/go/core"
)
// newReader wraps stdin in a bufio.Reader if it isn't one already.
func newReader() *bufio.Reader {
if br, ok := stdinReader().(*bufio.Reader); ok {
return br
@ -29,9 +28,9 @@ func Prompt(label, defaultVal string) (string, error) {
r := newReader()
input, err := r.ReadString('\n')
input = strings.TrimSpace(input)
input = core.Trim(input)
if err != nil {
if !errors.Is(err, io.EOF) {
if !core.Is(err, io.EOF) {
return "", err
}
if input == "" {
@ -53,7 +52,7 @@ func Select(label string, options []string) (string, error) {
return "", nil
}
fmt.Fprintln(stderrWriter(), compileGlyphs(label))
core.Print(stderrWriter(), "%s", compileGlyphs(label))
for i, opt := range options {
fmt.Fprintf(stderrWriter(), " %d. %s\n", i+1, compileGlyphs(opt))
}
@ -61,15 +60,15 @@ func Select(label string, options []string) (string, error) {
r := newReader()
input, err := r.ReadString('\n')
if err != nil && strings.TrimSpace(input) == "" {
if err != nil && core.Trim(input) == "" {
promptHint("No input received. Selection cancelled.")
return "", Wrap(err, "selection cancelled")
}
trimmed := strings.TrimSpace(input)
trimmed := core.Trim(input)
n, err := strconv.Atoi(trimmed)
if err != nil || n < 1 || n > len(options) {
promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(options)))
promptHint(core.Sprintf("Please enter a number between 1 and %d.", len(options)))
return "", Err("invalid selection %q: choose a number between 1 and %d", trimmed, len(options))
}
return options[n-1], nil
@ -81,7 +80,7 @@ func MultiSelect(label string, options []string) ([]string, error) {
return []string{}, nil
}
fmt.Fprintln(stderrWriter(), compileGlyphs(label))
core.Print(stderrWriter(), "%s", compileGlyphs(label))
for i, opt := range options {
fmt.Fprintf(stderrWriter(), " %d. %s\n", i+1, compileGlyphs(opt))
}
@ -89,17 +88,17 @@ func MultiSelect(label string, options []string) ([]string, error) {
r := newReader()
input, err := r.ReadString('\n')
trimmed := strings.TrimSpace(input)
trimmed := core.Trim(input)
if err != nil && trimmed == "" {
return []string{}, nil
}
if err != nil && !errors.Is(err, io.EOF) {
if err != nil && !core.Is(err, io.EOF) {
return nil, err
}
selected, parseErr := parseMultiSelection(trimmed, len(options))
if parseErr != nil {
return nil, Wrap(parseErr, fmt.Sprintf("invalid selection %q", trimmed))
return nil, Wrap(parseErr, core.Sprintf("invalid selection %q", trimmed))
}
selectedOptions := make([]string, 0, len(selected))

View file

@ -3,41 +3,24 @@ package cli
import (
"fmt"
"strings"
"dappco.re/go/core"
)
// RenderStyle controls how layouts are rendered.
//
// cli.UseRenderBoxed()
// frame := cli.NewFrame("HCF")
// fmt.Print(frame.String())
type RenderStyle int
// Render style constants for layout output.
const (
// RenderFlat uses no borders or decorations.
RenderFlat RenderStyle = iota
// RenderSimple uses --- separators between sections.
RenderSimple
// RenderBoxed uses Unicode box drawing characters.
RenderBoxed
)
var currentRenderStyle = RenderFlat
// UseRenderFlat sets the render style to flat (no borders).
//
// cli.UseRenderFlat()
func UseRenderFlat() { currentRenderStyle = RenderFlat }
// UseRenderSimple sets the render style to simple (--- separators).
//
// cli.UseRenderSimple()
func UseRenderFlat() { currentRenderStyle = RenderFlat }
func UseRenderSimple() { currentRenderStyle = RenderSimple }
// UseRenderBoxed sets the render style to boxed (Unicode box drawing).
//
// cli.UseRenderBoxed()
func UseRenderBoxed() { currentRenderStyle = RenderBoxed }
func UseRenderBoxed() { currentRenderStyle = RenderBoxed }
// Render outputs the layout to terminal.
func (c *Composite) Render() {
@ -85,7 +68,7 @@ func (c *Composite) renderSeparator(sb *strings.Builder, depth int) {
func (c *Composite) renderSlot(sb *strings.Builder, slot *Slot, depth int) {
indent := strings.Repeat(" ", depth)
for _, block := range slot.blocks {
for _, line := range strings.Split(block.Render(), "\n") {
for _, line := range core.Split(block.Render(), "\n") {
if line != "" {
sb.WriteString(indent + line + "\n")
}

View file

@ -6,6 +6,7 @@ import (
"strings"
"sync"
"dappco.re/go/core"
"github.com/mattn/go-runewidth"
)
@ -75,14 +76,14 @@ func (s *Stream) Write(text string) {
for _, r := range text {
if r == '\n' {
fmt.Fprintln(s.out)
core.Print(s.out, "")
s.col = 0
continue
}
rw := runewidth.RuneWidth(r)
if rw > 0 && s.col > 0 && s.col+rw > s.wrap {
fmt.Fprintln(s.out)
core.Print(s.out, "")
s.col = 0
}
@ -113,7 +114,7 @@ func (s *Stream) Done() {
s.once.Do(func() {
s.mu.Lock()
if s.col > 0 {
fmt.Fprintln(s.out) // ensure trailing newline
core.Print(s.out, "") // ensure trailing newline
}
s.mu.Unlock()
close(s.done)

View file

@ -6,6 +6,7 @@ import (
"strings"
"time"
"dappco.re/go/core"
"github.com/charmbracelet/x/ansi"
"github.com/mattn/go-runewidth"
)
@ -49,7 +50,6 @@ const (
ColourGray900 = "#111827"
)
// Core styles
var (
SuccessStyle = NewStyle().Bold().Foreground(ColourGreen500)
ErrorStyle = NewStyle().Bold().Foreground(ColourRed500)
@ -70,7 +70,6 @@ var (
RepoStyle = NewStyle().Bold().Foreground(ColourBlue500)
)
// Truncate shortens a string to max length with ellipsis.
func Truncate(s string, max int) string {
if max <= 0 || s == "" {
return ""
@ -84,7 +83,6 @@ func Truncate(s string, max int) string {
return truncateByWidth(s, max-3) + "..."
}
// Pad right-pads a string to width.
func Pad(s string, width int) string {
if displayWidth(s) >= width {
return s
@ -100,12 +98,10 @@ func truncateByWidth(s string, max int) string {
if max <= 0 || s == "" {
return ""
}
plain := ansi.Strip(s)
if displayWidth(plain) <= max {
return plain
}
var (
width int
out strings.Builder
@ -121,50 +117,39 @@ func truncateByWidth(s string, max int) string {
return out.String()
}
// FormatAge formats a time as human-readable age (e.g., "2h ago", "3d ago").
func FormatAge(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
return core.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
return core.Sprintf("%dh ago", int(d.Hours()))
case d < 7*24*time.Hour:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
return core.Sprintf("%dd ago", int(d.Hours()/24))
case d < 30*24*time.Hour:
return fmt.Sprintf("%dw ago", int(d.Hours()/(24*7)))
return core.Sprintf("%dw ago", int(d.Hours()/(24*7)))
default:
return fmt.Sprintf("%dmo ago", int(d.Hours()/(24*30)))
return core.Sprintf("%dmo ago", int(d.Hours()/(24*30)))
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Border Styles
// ─────────────────────────────────────────────────────────────────────────────
// BorderStyle selects the box-drawing character set for table borders.
type BorderStyle int
const (
// BorderNone disables borders (default).
BorderNone BorderStyle = iota
// BorderNormal uses standard box-drawing: ┌─┬┐ │ ├─┼┤ └─┴┘
BorderNormal
// BorderRounded uses rounded corners: ╭─┬╮ │ ├─┼┤ ╰─┴╯
BorderRounded
// BorderHeavy uses heavy box-drawing: ┏━┳┓ ┃ ┣━╋┫ ┗━┻┛
BorderHeavy
// BorderDouble uses double-line box-drawing: ╔═╦╗ ║ ╠═╬╣ ╚═╩╝
BorderDouble
)
type borderSet struct {
tl, tr, bl, br string // corners
h, v string // horizontal, vertical
tt, bt, lt, rt string // tees (top, bottom, left, right)
x string // cross
tl, tr, bl, br string
h, v string
tt, bt, lt, rt string
x string
}
var borderSets = map[BorderStyle]borderSet{
@ -181,25 +166,8 @@ var borderSetsASCII = map[BorderStyle]borderSet{
BorderDouble: {"+", "+", "+", "+", "=", "|", "+", "+", "+", "+", "+"},
}
// CellStyleFn returns a style based on the cell's raw value.
// Return nil to use the table's default CellStyle.
type CellStyleFn func(value string) *AnsiStyle
// ─────────────────────────────────────────────────────────────────────────────
// Table
// ─────────────────────────────────────────────────────────────────────────────
// Table renders tabular data with aligned columns.
// Supports optional box-drawing borders and per-column cell styling.
//
// t := cli.NewTable("REPO", "STATUS", "BRANCH").
// WithBorders(cli.BorderRounded).
// WithCellStyle(1, func(val string) *cli.AnsiStyle {
// if val == "clean" { return cli.SuccessStyle }
// return cli.WarningStyle
// })
// t.AddRow("core-php", "clean", "main")
// t.Render()
type Table struct {
Headers []string
Rows [][]string
@ -209,44 +177,24 @@ type Table struct {
maxWidth int
}
// TableStyle configures the appearance of table output.
type TableStyle struct {
HeaderStyle *AnsiStyle
CellStyle *AnsiStyle
Separator string
}
// DefaultTableStyle returns sensible defaults.
func DefaultTableStyle() TableStyle {
return TableStyle{
HeaderStyle: HeaderStyle,
CellStyle: nil,
Separator: " ",
}
return TableStyle{HeaderStyle: HeaderStyle, CellStyle: nil, Separator: " "}
}
// NewTable creates a table with headers.
func NewTable(headers ...string) *Table {
return &Table{
Headers: headers,
Style: DefaultTableStyle(),
}
return &Table{Headers: headers, Style: DefaultTableStyle()}
}
// AddRow adds a row to the table.
func (t *Table) AddRow(cells ...string) *Table {
t.Rows = append(t.Rows, cells)
return t
}
func (t *Table) AddRow(cells ...string) *Table { t.Rows = append(t.Rows, cells); return t }
// WithBorders enables box-drawing borders on the table.
func (t *Table) WithBorders(style BorderStyle) *Table {
t.borders = style
return t
}
func (t *Table) WithBorders(style BorderStyle) *Table { t.borders = style; return t }
// WithCellStyle sets a per-column style function.
// The function receives the raw cell value and returns a style.
func (t *Table) WithCellStyle(col int, fn CellStyleFn) *Table {
if t.cellStyleFns == nil {
t.cellStyleFns = make(map[int]CellStyleFn)
@ -255,25 +203,18 @@ func (t *Table) WithCellStyle(col int, fn CellStyleFn) *Table {
return t
}
// WithMaxWidth sets the maximum table width, truncating columns to fit.
func (t *Table) WithMaxWidth(w int) *Table {
t.maxWidth = w
return t
}
func (t *Table) WithMaxWidth(w int) *Table { t.maxWidth = w; return t }
// String renders the table.
func (t *Table) String() string {
if len(t.Headers) == 0 && len(t.Rows) == 0 {
return ""
}
if t.borders != BorderNone {
return t.renderBordered()
}
return t.renderPlain()
}
// Render prints the table to stdout.
func (t *Table) Render() {
fmt.Fprint(stdoutWriter(), t.String())
}
@ -289,7 +230,6 @@ func (t *Table) colCount() int {
func (t *Table) columnWidths() []int {
cols := t.colCount()
widths := make([]int, cols)
for i, h := range t.Headers {
if w := displayWidth(compileGlyphs(h)); w > widths[i] {
widths[i] = w
@ -304,7 +244,6 @@ func (t *Table) columnWidths() []int {
}
}
}
if t.maxWidth > 0 {
t.constrainWidths(widths)
}
@ -315,23 +254,17 @@ func (t *Table) constrainWidths(widths []int) {
cols := len(widths)
overhead := 0
if t.borders != BorderNone {
// │ cell │ cell │ = (cols+1) verticals + 2*cols padding spaces
overhead = (cols + 1) + (cols * 2)
} else {
// separator between columns
overhead = (cols - 1) * len(t.Style.Separator)
}
total := overhead
for _, w := range widths {
total += w
}
if total <= t.maxWidth {
return
}
// Shrink widest columns first until we fit.
budget := max(t.maxWidth-overhead, cols)
for total-overhead > budget {
maxIdx, maxW := 0, 0
@ -358,10 +291,8 @@ func (t *Table) resolveStyle(col int, value string) *AnsiStyle {
func (t *Table) renderPlain() string {
widths := t.columnWidths()
var sb strings.Builder
sep := t.Style.Separator
if len(t.Headers) > 0 {
for i, h := range t.Headers {
if i > 0 {
@ -375,7 +306,6 @@ func (t *Table) renderPlain() string {
}
sb.WriteByte('\n')
}
for _, row := range t.Rows {
for i := range t.colCount() {
if i > 0 {
@ -393,7 +323,6 @@ func (t *Table) renderPlain() string {
}
sb.WriteByte('\n')
}
return sb.String()
}
@ -401,10 +330,7 @@ func (t *Table) renderBordered() string {
b := tableBorderSet(t.borders)
widths := t.columnWidths()
cols := t.colCount()
var sb strings.Builder
// Top border: ╭──────┬──────╮
sb.WriteString(b.tl)
for i := range cols {
sb.WriteString(strings.Repeat(b.h, widths[i]+2))
@ -414,8 +340,6 @@ func (t *Table) renderBordered() string {
}
sb.WriteString(b.tr)
sb.WriteByte('\n')
// Header row
if len(t.Headers) > 0 {
sb.WriteString(b.v)
for i := range cols {
@ -433,8 +357,6 @@ func (t *Table) renderBordered() string {
sb.WriteString(b.v)
}
sb.WriteByte('\n')
// Header separator: ├──────┼──────┤
sb.WriteString(b.lt)
for i := range cols {
sb.WriteString(strings.Repeat(b.h, widths[i]+2))
@ -445,8 +367,6 @@ func (t *Table) renderBordered() string {
sb.WriteString(b.rt)
sb.WriteByte('\n')
}
// Data rows
for _, row := range t.Rows {
sb.WriteString(b.v)
for i := range cols {
@ -465,8 +385,6 @@ func (t *Table) renderBordered() string {
}
sb.WriteByte('\n')
}
// Bottom border: ╰──────┴──────╯
sb.WriteString(b.bl)
for i := range cols {
sb.WriteString(strings.Repeat(b.h, widths[i]+2))
@ -476,7 +394,6 @@ func (t *Table) renderBordered() string {
}
sb.WriteString(b.br)
sb.WriteByte('\n')
return sb.String()
}

View file

@ -9,14 +9,13 @@ import (
"sync"
"time"
"dappco.re/go/core"
"golang.org/x/term"
)
// Spinner frames for the live tracker.
var spinnerFramesUnicode = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
var spinnerFramesASCII = []string{"-", "\\", "|", "/"}
// taskState tracks the lifecycle of a tracked task.
type taskState int
const (
@ -26,8 +25,6 @@ const (
taskFailed
)
// TrackedTask represents a single task in a TaskTracker.
// Safe for concurrent use — call Update, Done, or Fail from any goroutine.
type TrackedTask struct {
name string
status string
@ -36,7 +33,6 @@ type TrackedTask struct {
mu sync.Mutex
}
// Update sets the task status message and marks it as running.
func (t *TrackedTask) Update(status string) {
t.mu.Lock()
t.status = status
@ -44,7 +40,6 @@ func (t *TrackedTask) Update(status string) {
t.mu.Unlock()
}
// Done marks the task as successfully completed with a final message.
func (t *TrackedTask) Done(message string) {
t.mu.Lock()
t.status = message
@ -52,7 +47,6 @@ func (t *TrackedTask) Done(message string) {
t.mu.Unlock()
}
// Fail marks the task as failed with an error message.
func (t *TrackedTask) Fail(message string) {
t.mu.Lock()
t.status = message
@ -66,18 +60,6 @@ func (t *TrackedTask) snapshot() (string, string, taskState) {
return t.name, t.status, t.state
}
// TaskTracker displays multiple concurrent tasks with individual spinners.
//
// tracker := cli.NewTaskTracker()
// for _, repo := range repos {
// t := tracker.Add(repo.Name)
// go func(t *cli.TrackedTask) {
// t.Update("pulling...")
// // ...
// t.Done("up to date")
// }(t)
// }
// tracker.Wait()
type TaskTracker struct {
tasks []*TrackedTask
out io.Writer
@ -85,14 +67,12 @@ type TaskTracker struct {
started bool
}
// Tasks returns an iterator over the tasks in the tracker.
func (tr *TaskTracker) Tasks() iter.Seq[*TrackedTask] {
return func(yield func(*TrackedTask) bool) {
tr.mu.Lock()
tasks := make([]*TrackedTask, len(tr.tasks))
copy(tasks, tr.tasks)
tr.mu.Unlock()
for _, t := range tasks {
if !yield(t) {
return
@ -101,14 +81,12 @@ func (tr *TaskTracker) Tasks() iter.Seq[*TrackedTask] {
}
}
// Snapshots returns an iterator over snapshots of tasks in the tracker.
func (tr *TaskTracker) Snapshots() iter.Seq2[string, string] {
return func(yield func(string, string) bool) {
tr.mu.Lock()
tasks := make([]*TrackedTask, len(tr.tasks))
copy(tasks, tr.tasks)
tr.mu.Unlock()
for _, t := range tasks {
name, status, _ := t.snapshot()
if !yield(name, status) {
@ -118,13 +96,10 @@ func (tr *TaskTracker) Snapshots() iter.Seq2[string, string] {
}
}
// NewTaskTracker creates a new parallel task tracker.
func NewTaskTracker() *TaskTracker {
return &TaskTracker{out: stderrWriter()}
}
// WithOutput sets the destination writer for tracker output.
// Pass nil to keep the current writer unchanged.
func (tr *TaskTracker) WithOutput(out io.Writer) *TaskTracker {
if out != nil {
tr.out = out
@ -132,23 +107,14 @@ func (tr *TaskTracker) WithOutput(out io.Writer) *TaskTracker {
return tr
}
// Add registers a task and returns it for goroutine use.
func (tr *TaskTracker) Add(name string) *TrackedTask {
t := &TrackedTask{
name: name,
status: "waiting",
state: taskPending,
tracker: tr,
}
t := &TrackedTask{name: name, status: "waiting", state: taskPending, tracker: tr}
tr.mu.Lock()
tr.tasks = append(tr.tasks, t)
tr.mu.Unlock()
return t
}
// Wait renders the task display and blocks until all tasks complete.
// Uses ANSI cursor manipulation for live updates when connected to a terminal.
// Falls back to line-by-line output for non-TTY.
func (tr *TaskTracker) Wait() {
if !tr.isTTY() {
tr.waitStatic()
@ -165,7 +131,6 @@ func (tr *TaskTracker) isTTY() bool {
}
func (tr *TaskTracker) waitStatic() {
// Non-TTY: print final state of each task when it completes.
reported := make(map[int]bool)
for {
tr.mu.Lock()
@ -203,7 +168,6 @@ func (tr *TaskTracker) waitLive() {
n := len(tr.tasks)
tr.mu.Unlock()
// Print initial lines.
frame := 0
for i := range n {
tr.renderLine(i, frame)
@ -223,7 +187,6 @@ func (tr *TaskTracker) waitLive() {
count := len(tr.tasks)
tr.mu.Unlock()
// Move cursor up to redraw all lines.
fmt.Fprintf(tr.out, "\033[%dA", count)
for i := range count {
tr.renderLine(i, frame)
@ -294,7 +257,6 @@ func (tr *TaskTracker) allDone() bool {
return true
}
// Summary returns a one-line summary of task results.
func (tr *TaskTracker) Summary() string {
tr.mu.Lock()
defer tr.mu.Unlock()
@ -312,12 +274,11 @@ func (tr *TaskTracker) Summary() string {
total := len(tr.tasks)
if failed > 0 {
return fmt.Sprintf("%d/%d passed, %d failed", passed, total, failed)
return core.Sprintf("%d/%d passed, %d failed", passed, total, failed)
}
return fmt.Sprintf("%d/%d passed", passed, total)
return core.Sprintf("%d/%d passed", passed, total)
}
// String returns the current state of all tasks as plain text (no ANSI).
func (tr *TaskTracker) String() string {
tr.mu.Lock()
tasks := tr.tasks

View file

@ -7,56 +7,38 @@ import (
)
// TreeNode represents a node in a displayable tree structure.
// Use NewTree to create a root, then Add children.
//
// tree := cli.NewTree("core-php")
// tree.Add("core-tenant").Add("core-bio")
// tree.Add("core-admin")
// tree.Add("core-api")
// fmt.Print(tree)
// // core-php
// // ├── core-tenant
// // │ └── core-bio
// // ├── core-admin
// // └── core-api
type TreeNode struct {
label string
style *AnsiStyle
children []*TreeNode
}
// NewTree creates a new tree with the given root label.
func NewTree(label string) *TreeNode {
return &TreeNode{label: label}
}
// Add appends a child node and returns the child for chaining.
func (n *TreeNode) Add(label string) *TreeNode {
child := &TreeNode{label: label}
n.children = append(n.children, child)
return child
}
// AddStyled appends a styled child node and returns the child for chaining.
func (n *TreeNode) AddStyled(label string, style *AnsiStyle) *TreeNode {
child := &TreeNode{label: label, style: style}
n.children = append(n.children, child)
return child
}
// AddTree appends an existing tree as a child and returns the parent for chaining.
func (n *TreeNode) AddTree(child *TreeNode) *TreeNode {
n.children = append(n.children, child)
return n
}
// WithStyle sets the style on this node and returns it for chaining.
func (n *TreeNode) WithStyle(style *AnsiStyle) *TreeNode {
n.style = style
return n
}
// Children returns an iterator over the node's children.
func (n *TreeNode) Children() iter.Seq[*TreeNode] {
return func(yield func(*TreeNode) bool) {
for _, child := range n.children {
@ -67,8 +49,6 @@ func (n *TreeNode) Children() iter.Seq[*TreeNode] {
}
}
// String renders the tree with box-drawing characters.
// Implements fmt.Stringer.
func (n *TreeNode) String() string {
var sb strings.Builder
sb.WriteString(n.renderLabel())
@ -77,7 +57,6 @@ func (n *TreeNode) String() string {
return sb.String()
}
// Render prints the tree to stdout.
func (n *TreeNode) Render() {
fmt.Fprint(stdoutWriter(), n.String())
}
@ -97,19 +76,16 @@ func (n *TreeNode) writeChildren(sb *strings.Builder, prefix string) {
for i, child := range n.children {
last := i == len(n.children)-1
connector := tee
next := pipe
if last {
connector = corner
next = " "
}
sb.WriteString(prefix)
sb.WriteString(connector)
sb.WriteString(child.renderLabel())
sb.WriteByte('\n')
child.writeChildren(sb, prefix+next)
}
}

View file

@ -2,38 +2,29 @@ package cli
import (
"context"
"errors"
"fmt"
"os/exec"
"strings"
"time"
"unicode"
"dappco.re/go/core"
"dappco.re/go/core/i18n"
"dappco.re/go/core/log"
)
// GhAuthenticated checks if the GitHub CLI is authenticated.
// Returns true if 'gh auth status' indicates a logged-in user.
func GhAuthenticated() bool {
cmd := exec.Command("gh", "auth", "status")
output, _ := cmd.CombinedOutput()
authenticated := strings.Contains(string(output), "Logged in")
authenticated := core.Contains(string(output), "Logged in")
if authenticated {
LogWarn("GitHub CLI authenticated", "user", log.Username())
} else {
LogWarn("GitHub CLI not authenticated", "user", log.Username())
}
return authenticated
}
// ConfirmOption configures Confirm behaviour.
//
// if cli.Confirm("Proceed?", cli.DefaultYes()) {
// cli.Success("continuing")
// }
type ConfirmOption func(*confirmConfig)
type confirmConfig struct {
@ -43,50 +34,25 @@ type confirmConfig struct {
}
func promptHint(msg string) {
fmt.Fprintln(stderrWriter(), DimStyle.Render(compileGlyphs(msg)))
core.Print(stderrWriter(), "%s", DimStyle.Render(compileGlyphs(msg)))
}
func promptWarning(msg string) {
fmt.Fprintln(stderrWriter(), WarningStyle.Render(compileGlyphs(msg)))
core.Print(stderrWriter(), "%s", WarningStyle.Render(compileGlyphs(msg)))
}
// DefaultYes sets the default response to "yes" (pressing Enter confirms).
func DefaultYes() ConfirmOption {
return func(c *confirmConfig) {
c.defaultYes = true
}
return func(c *confirmConfig) { c.defaultYes = true }
}
// Required prevents empty responses; user must explicitly type y/n.
func Required() ConfirmOption {
return func(c *confirmConfig) {
c.required = true
}
return func(c *confirmConfig) { c.required = true }
}
// Timeout sets a timeout after which the default response is auto-selected.
// If no default is set (not Required and not DefaultYes), defaults to "no".
//
// Confirm("Continue?", Timeout(30*time.Second)) // Auto-no after 30s
// Confirm("Continue?", DefaultYes(), Timeout(10*time.Second)) // Auto-yes after 10s
func Timeout(d time.Duration) ConfirmOption {
return func(c *confirmConfig) {
c.timeout = d
}
return func(c *confirmConfig) { c.timeout = d }
}
// Confirm prompts the user for yes/no confirmation.
// Returns true if the user enters "y" or "yes" (case-insensitive).
//
// Basic usage:
//
// if Confirm("Delete file?") { ... }
//
// With options:
//
// if Confirm("Save changes?", DefaultYes()) { ... }
// if Confirm("Dangerous!", Required()) { ... }
// if Confirm("Auto-continue?", Timeout(30*time.Second)) { ... }
func Confirm(prompt string, opts ...ConfirmOption) bool {
cfg := &confirmConfig{}
for _, opt := range opts {
@ -95,7 +61,6 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
prompt = compileGlyphs(prompt)
// Build the prompt suffix
var suffix string
if cfg.required {
suffix = "[y/n] "
@ -105,9 +70,8 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
suffix = "[y/N] "
}
// Add timeout indicator if set
if cfg.timeout > 0 {
suffix = fmt.Sprintf("%s(auto in %s) ", suffix, cfg.timeout.Round(time.Second))
suffix = core.Sprintf("%s(auto in %s) ", suffix, cfg.timeout.Round(time.Second))
}
reader := newReader()
@ -119,7 +83,6 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
var readErr error
if cfg.timeout > 0 {
// Use timeout-based reading
resultChan := make(chan string, 1)
errChan := make(chan error, 1)
go func() {
@ -131,9 +94,9 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
select {
case response = <-resultChan:
readErr = <-errChan
response = strings.ToLower(strings.TrimSpace(response))
response = core.Lower(core.Trim(response))
case <-time.After(cfg.timeout):
fmt.Fprintln(stderrWriter()) // New line after timeout
core.Print(stderrWriter(), "")
return cfg.defaultYes
}
} else {
@ -143,10 +106,9 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
return cfg.defaultYes
}
response = line
response = strings.ToLower(strings.TrimSpace(response))
response = core.Lower(core.Trim(response))
}
// Handle empty response
if response == "" {
if readErr == nil && cfg.required {
promptHint("Please enter y or n, then press Enter.")
@ -158,7 +120,6 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
return cfg.defaultYes
}
// Check for yes/no responses
if response == "y" || response == "yes" {
return true
}
@ -166,43 +127,28 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
return false
}
// Invalid response
if cfg.required {
promptHint("Please enter y or n, then press Enter.")
continue
}
// Non-required: treat invalid as default
return cfg.defaultYes
}
}
// ConfirmAction prompts for confirmation of an action using grammar composition.
//
// if ConfirmAction("delete", "config.yaml") { ... }
// if ConfirmAction("save", "changes", DefaultYes()) { ... }
func ConfirmAction(verb, subject string, opts ...ConfirmOption) bool {
question := i18n.Title(verb) + " " + subject + "?"
return Confirm(question, opts...)
}
// ConfirmDangerousAction prompts for double confirmation of a dangerous action.
// Shows initial question, then a "Really verb subject?" confirmation.
//
// if ConfirmDangerousAction("delete", "config.yaml") { ... }
func ConfirmDangerousAction(verb, subject string) bool {
question := i18n.Title(verb) + " " + subject + "?"
if !Confirm(question, Required()) {
return false
}
confirm := "Really " + verb + " " + subject + "?"
return Confirm(confirm, Required())
}
// QuestionOption configures Question behaviour.
//
// name := cli.Question("Project name:", cli.WithDefault("my-app"))
type QuestionOption func(*questionConfig)
type questionConfig struct {
@ -211,57 +157,36 @@ type questionConfig struct {
validator func(string) error
}
// WithDefault sets the default value shown in brackets.
func WithDefault(value string) QuestionOption {
return func(c *questionConfig) {
c.defaultValue = value
}
return func(c *questionConfig) { c.defaultValue = value }
}
// WithValidator adds a validation function for the response.
func WithValidator(fn func(string) error) QuestionOption {
return func(c *questionConfig) {
c.validator = fn
}
return func(c *questionConfig) { c.validator = fn }
}
// RequiredInput prevents empty responses.
func RequiredInput() QuestionOption {
return func(c *questionConfig) {
c.required = true
}
return func(c *questionConfig) { c.required = true }
}
// Question prompts the user for text input.
//
// name := Question("Enter your name:")
// name := Question("Enter your name:", WithDefault("Anonymous"))
// name := Question("Enter your name:", RequiredInput())
func Question(prompt string, opts ...QuestionOption) string {
cfg := &questionConfig{}
for _, opt := range opts {
opt(cfg)
}
prompt = compileGlyphs(prompt)
reader := newReader()
for {
// Build prompt with default
if cfg.defaultValue != "" {
fmt.Fprintf(stderrWriter(), "%s [%s] ", prompt, compileGlyphs(cfg.defaultValue))
} else {
fmt.Fprintf(stderrWriter(), "%s ", prompt)
}
response, err := reader.ReadString('\n')
response = strings.TrimSpace(response)
response = core.Trim(response)
if err != nil && response == "" {
return cfg.defaultValue
}
// Handle empty response
if response == "" {
if cfg.required {
promptHint("Please enter a value, then press Enter.")
@ -269,100 +194,63 @@ func Question(prompt string, opts ...QuestionOption) string {
}
response = cfg.defaultValue
}
// Validate if validator provided
if cfg.validator != nil {
if err := cfg.validator(response); err != nil {
promptWarning(fmt.Sprintf("Invalid: %v", err))
promptWarning(core.Sprintf("Invalid: %v", err))
continue
}
}
return response
}
}
// QuestionAction prompts for text input using grammar composition.
//
// name := QuestionAction("rename", "old.txt")
func QuestionAction(verb, subject string, opts ...QuestionOption) string {
question := i18n.Title(verb) + " " + subject + "?"
return Question(question, opts...)
}
// ChooseOption configures Choose behaviour.
//
// choice := cli.Choose("Pick one:", items, cli.Display(func(v Item) string {
// return v.Name
// }))
type ChooseOption[T any] func(*chooseConfig[T])
type chooseConfig[T any] struct {
displayFn func(T) string
defaultN int // 0-based index of default selection
filter bool // Enable type-to-filter selection
multi bool // Allow multiple selection
defaultN int
filter bool
multi bool
}
// WithDisplay sets a custom display function for items.
func WithDisplay[T any](fn func(T) string) ChooseOption[T] {
return func(c *chooseConfig[T]) {
c.displayFn = fn
}
return func(c *chooseConfig[T]) { c.displayFn = fn }
}
// WithDefaultIndex sets the default selection index (0-based).
func WithDefaultIndex[T any](idx int) ChooseOption[T] {
return func(c *chooseConfig[T]) {
c.defaultN = idx
}
return func(c *chooseConfig[T]) { c.defaultN = idx }
}
// Filter enables type-to-filter functionality.
// When enabled, typed text narrows the visible options before selection.
func Filter[T any]() ChooseOption[T] {
return func(c *chooseConfig[T]) {
c.filter = true
}
return func(c *chooseConfig[T]) { c.filter = true }
}
// Multi allows multiple selections.
// Use ChooseMulti instead of Choose when this option is needed.
func Multi[T any]() ChooseOption[T] {
return func(c *chooseConfig[T]) {
c.multi = true
}
return func(c *chooseConfig[T]) { c.multi = true }
}
// Display sets a custom display function for items.
// Alias for WithDisplay for shorter syntax.
//
// Choose("Select:", items, Display(func(f File) string { return f.Name }))
func Display[T any](fn func(T) string) ChooseOption[T] {
return WithDisplay[T](fn)
}
// Choose prompts the user to select from a list of items.
// Returns the selected item. Uses simple numbered selection for terminal compatibility.
//
// choice := Choose("Select a file:", files)
// choice := Choose("Select a file:", files, WithDisplay(func(f File) string { return f.Name }))
func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T {
var zero T
if len(items) == 0 {
return zero
}
cfg := &chooseConfig[T]{
displayFn: func(item T) string { return fmt.Sprint(item) },
displayFn: func(item T) string { return core.Sprint(item) },
defaultN: -1,
}
for _, opt := range opts {
opt(cfg)
}
prompt = compileGlyphs(prompt)
reader := newReader()
visible := make([]int, len(items))
for i := range items {
@ -372,15 +260,13 @@ func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T {
for {
renderChoices(prompt, items, visible, cfg.displayFn, cfg.defaultN, cfg.filter)
if cfg.filter {
fmt.Fprintf(stderrWriter(), "Enter number [1-%d] or filter: ", len(visible))
} else {
fmt.Fprintf(stderrWriter(), "Enter number [1-%d]: ", len(visible))
}
response, err := reader.ReadString('\n')
response = strings.TrimSpace(response)
response = core.Trim(response)
if err != nil && response == "" {
if idx, ok := defaultVisibleIndex(visible, cfg.defaultN); ok {
return items[idx]
@ -388,7 +274,6 @@ func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T {
var zero T
return zero
}
if response == "" {
if cfg.filter && len(visible) != len(allVisible) {
visible = append([]int(nil), allVisible...)
@ -402,106 +287,78 @@ func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T {
promptHint("Default selection is not available in the current list. Narrow the list or choose another number.")
continue
}
promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(visible)))
promptHint(core.Sprintf("Please enter a number between 1 and %d.", len(visible)))
continue
}
var n int
if _, err := fmt.Sscanf(response, "%d", &n); err == nil {
if n >= 1 && n <= len(visible) {
return items[visible[n-1]]
}
promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(visible)))
promptHint(core.Sprintf("Please enter a number between 1 and %d.", len(visible)))
continue
}
if cfg.filter {
nextVisible := filterVisible(items, visible, response, cfg.displayFn)
if len(nextVisible) == 0 {
promptHint(fmt.Sprintf("No matches for %q. Try a shorter search term or clear the filter.", response))
promptHint(core.Sprintf("No matches for %q. Try a shorter search term or clear the filter.", response))
continue
}
visible = nextVisible
continue
}
promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(visible)))
promptHint(core.Sprintf("Please enter a number between 1 and %d.", len(visible)))
}
}
// ChooseAction prompts for selection using grammar composition.
//
// file := ChooseAction("select", "file", files)
func ChooseAction[T any](verb, subject string, items []T, opts ...ChooseOption[T]) T {
question := i18n.Title(verb) + " " + subject + ":"
return Choose(question, items, opts...)
}
// ChooseMulti prompts the user to select multiple items from a list.
// Returns the selected items. Uses space-separated numbers or ranges.
//
// choices := ChooseMulti("Select files:", files)
// choices := ChooseMulti("Select files:", files, WithDisplay(func(f File) string { return f.Name }))
//
// Input format:
// - "1 3 5" - select items 1, 3, and 5
// - "1-3" - select items 1, 2, and 3
// - "1 3-5" - select items 1, 3, 4, and 5
// - "" (empty) - select none
func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T {
if len(items) == 0 {
return nil
}
cfg := &chooseConfig[T]{
displayFn: func(item T) string { return fmt.Sprint(item) },
displayFn: func(item T) string { return core.Sprint(item) },
defaultN: -1,
}
for _, opt := range opts {
opt(cfg)
}
prompt = compileGlyphs(prompt)
reader := newReader()
visible := make([]int, len(items))
for i := range items {
visible[i] = i
}
for {
renderChoices(prompt, items, visible, cfg.displayFn, -1, cfg.filter)
if cfg.filter {
fmt.Fprint(stderrWriter(), "Enter numbers (e.g., 1 3 5 or 1-3), or filter text, or empty for none: ")
} else {
fmt.Fprint(stderrWriter(), "Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ")
}
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(response)
// Empty response returns no selections.
response = core.Trim(response)
if response == "" {
return nil
}
// Parse the selection.
selected, err := parseMultiSelection(response, len(visible))
if err != nil {
if cfg.filter && !looksLikeMultiSelectionInput(response) {
nextVisible := filterVisible(items, visible, response, cfg.displayFn)
if len(nextVisible) == 0 {
promptHint(fmt.Sprintf("No matches for %q. Try a shorter search term or clear the filter.", response))
promptHint(core.Sprintf("No matches for %q. Try a shorter search term or clear the filter.", response))
continue
}
visible = nextVisible
continue
}
promptWarning(fmt.Sprintf("Invalid selection %q: enter numbers like 1 3 or 1-3.", response))
promptWarning(core.Sprintf("Invalid selection %q: enter numbers like 1 3 or 1-3.", response))
continue
}
// Build result
result := make([]T, 0, len(selected))
for _, idx := range selected {
result = append(result, items[visible[idx]])
@ -511,7 +368,7 @@ func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T {
}
func renderChoices[T any](prompt string, items []T, visible []int, displayFn func(T) string, defaultN int, filter bool) {
fmt.Fprintln(stderrWriter(), prompt)
core.Print(stderrWriter(), "%s", prompt)
for i, idx := range visible {
marker := " "
if defaultN >= 0 && idx == defaultN {
@ -520,7 +377,7 @@ func renderChoices[T any](prompt string, items []T, visible []int, displayFn fun
fmt.Fprintf(stderrWriter(), " %s%d. %s\n", marker, i+1, compileGlyphs(displayFn(items[idx])))
}
if filter {
fmt.Fprintln(stderrWriter(), " (type to filter the list)")
core.Print(stderrWriter(), " (type to filter the list)")
}
}
@ -537,14 +394,13 @@ func defaultVisibleIndex(visible []int, defaultN int) (int, bool) {
}
func filterVisible[T any](items []T, visible []int, query string, displayFn func(T) string) []int {
q := strings.ToLower(strings.TrimSpace(query))
q := core.Lower(core.Trim(query))
if q == "" {
return visible
}
filtered := make([]int, 0, len(visible))
for _, idx := range visible {
if strings.Contains(strings.ToLower(displayFn(items[idx])), q) {
if core.Contains(core.Lower(displayFn(items[idx])), q) {
filtered = append(filtered, idx)
}
}
@ -566,17 +422,11 @@ func looksLikeMultiSelectionInput(input string) bool {
return hasDigit
}
// parseMultiSelection parses a multi-selection string like "1 3 5", "1,3,5",
// or "1-3 5".
// Returns 0-based indices.
func parseMultiSelection(input string, maxItems int) ([]int, error) {
selected := make(map[int]bool)
normalized := strings.NewReplacer(",", " ").Replace(input)
for part := range strings.FieldsSeq(normalized) {
// Check for range (e.g., "1-3")
if strings.Contains(part, "-") {
if core.Contains(part, "-") {
var rangeParts []string
for p := range strings.SplitSeq(part, "-") {
rangeParts = append(rangeParts, p)
@ -595,10 +445,9 @@ func parseMultiSelection(input string, maxItems int) ([]int, error) {
return nil, Err("range out of bounds: %s", part)
}
for i := start; i <= end; i++ {
selected[i-1] = true // Convert to 0-based
selected[i-1] = true
}
} else {
// Single number
var n int
if _, err := fmt.Sscanf(part, "%d", &n); err != nil {
return nil, Err("invalid number: %s", part)
@ -606,11 +455,9 @@ func parseMultiSelection(input string, maxItems int) ([]int, error) {
if n < 1 || n > maxItems {
return nil, Err("number out of range: %d", n)
}
selected[n-1] = true // Convert to 0-based
selected[n-1] = true
}
}
// Convert map to sorted slice
result := make([]int, 0, len(selected))
for i := range maxItems {
if selected[i] {
@ -620,25 +467,18 @@ func parseMultiSelection(input string, maxItems int) ([]int, error) {
return result, nil
}
// ChooseMultiAction prompts for multiple selections using grammar composition.
//
// files := ChooseMultiAction("select", "files", files)
func ChooseMultiAction[T any](verb, subject string, items []T, opts ...ChooseOption[T]) []T {
question := i18n.Title(verb) + " " + subject + ":"
return ChooseMulti(question, items, opts...)
}
// GitClone clones a GitHub repository to the specified path.
// Prefers 'gh repo clone' if authenticated, falls back to SSH.
func GitClone(ctx context.Context, org, repo, path string) error {
return GitCloneRef(ctx, org, repo, path, "")
}
// GitCloneRef clones a GitHub repository at a specific ref to the specified path.
// Prefers 'gh repo clone' if authenticated, falls back to SSH.
func GitCloneRef(ctx context.Context, org, repo, path, ref string) error {
if GhAuthenticated() {
httpsURL := fmt.Sprintf("https://github.com/%s/%s.git", org, repo)
httpsURL := core.Sprintf("https://github.com/%s/%s.git", org, repo)
args := []string{"repo", "clone", httpsURL, path}
if ref != "" {
args = append(args, "--", "--branch", ref, "--single-branch")
@ -648,21 +488,20 @@ func GitCloneRef(ctx context.Context, org, repo, path, ref string) error {
if err == nil {
return nil
}
errStr := strings.TrimSpace(string(output))
if strings.Contains(errStr, "already exists") {
return errors.New(errStr)
errStr := core.Trim(string(output))
if core.Contains(errStr, "already exists") {
return core.NewError(errStr)
}
}
// Fall back to SSH clone
args := []string{"clone"}
if ref != "" {
args = append(args, "--branch", ref, "--single-branch")
}
args = append(args, fmt.Sprintf("git@github.com:%s/%s.git", org, repo), path)
args = append(args, core.Sprintf("git@github.com:%s/%s.git", org, repo), path)
cmd := exec.CommandContext(ctx, "git", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return errors.New(strings.TrimSpace(string(output)))
return core.NewError(core.Trim(string(output)))
}
return nil
}