1686 lines
37 KiB
Markdown
1686 lines
37 KiB
Markdown
|
|
# Semantic CLI Output Abstraction
|
|||
|
|
|
|||
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|||
|
|
|
|||
|
|
**Goal:** Zero external dependencies for CLI output. Consuming code only imports `cli` - no `fmt`, `i18n`, or `lipgloss`.
|
|||
|
|
|
|||
|
|
**Restore Point:** `96eaed5` - all deleted code recoverable from git history.
|
|||
|
|
|
|||
|
|
**Architecture:**
|
|||
|
|
- Internal ANSI styling (~100 lines replaces lipgloss)
|
|||
|
|
- Glyph system with themes (unicode/emoji/ascii)
|
|||
|
|
- Semantic output functions (`cli.Success`, `cli.Error`, `cli.Progress`)
|
|||
|
|
- HLCRF layout system for structured output (ported from RFC-001)
|
|||
|
|
- Simple stdin prompts (replaces huh wizard)
|
|||
|
|
|
|||
|
|
**Tech Stack:** Go standard library only. Zero external dependencies for CLI output.
|
|||
|
|
|
|||
|
|
**Reference:** RFC-001-HLCRF-COMPOSITOR.md (lab/host.uk.com/doc/rfc/)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Design Decisions
|
|||
|
|
|
|||
|
|
### 1. Explicit Styled Functions (NOT Prefix Detection)
|
|||
|
|
|
|||
|
|
The codebase uses keys like `cmd.dev.ci.short`, not `i18n.success.*`. Instead of prefix detection, use explicit functions:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
cli.Success("Build complete") // ✓ Build complete (green)
|
|||
|
|
cli.Error("Connection failed") // ✗ Connection failed (red)
|
|||
|
|
cli.Warn("Rate limited") // ⚠ Rate limited (amber)
|
|||
|
|
cli.Info("Connecting...") // ℹ Connecting... (blue)
|
|||
|
|
|
|||
|
|
// With i18n
|
|||
|
|
cli.Success(i18n.T("build.complete")) // Caller handles translation
|
|||
|
|
cli.Echo(key, args...) // Just translate + print, no styling
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2. Delete-and-Replace Approach
|
|||
|
|
|
|||
|
|
No backward compatibility. Delete all lipgloss-based code, rewrite with internal ANSI:
|
|||
|
|
- Delete `var Style = struct {...}` namespace (output.go)
|
|||
|
|
- Delete all 50+ helper functions (styles.go)
|
|||
|
|
- Delete `Symbol*` constants - replaced by glyph system
|
|||
|
|
- Delete `Table` struct - rewrite with internal styling
|
|||
|
|
|
|||
|
|
### 3. Glyph System Replaces Symbol Constants
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// Before (styles.go)
|
|||
|
|
const SymbolCheck = "✓"
|
|||
|
|
fmt.Print(SuccessStyle.Render(SymbolCheck))
|
|||
|
|
|
|||
|
|
// After
|
|||
|
|
cli.Success("Done") // Internally uses Glyph(":check:")
|
|||
|
|
cli.Print(":check: Done") // Or explicit glyph
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4. Simple Wizard Prompts
|
|||
|
|
|
|||
|
|
Replace huh forms with basic stdin:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
cli.Prompt("Project name", "my-project") // text input
|
|||
|
|
cli.Confirm("Continue?") // y/n
|
|||
|
|
cli.Select("Choose", []string{"a", "b"}) // numbered list
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase -1: Zero-Dependency ANSI Styling
|
|||
|
|
|
|||
|
|
### Why
|
|||
|
|
|
|||
|
|
Current dependencies for ANSI escape codes:
|
|||
|
|
- `lipgloss` → 15 transitive deps
|
|||
|
|
- `huh` → 30 transitive deps
|
|||
|
|
- Supply chain attack surface: ~45 packages
|
|||
|
|
|
|||
|
|
What we actually use: `style.Bold(true).Foreground(color).Render(text)`
|
|||
|
|
|
|||
|
|
This is ~100 lines of ANSI codes. We own it completely.
|
|||
|
|
|
|||
|
|
### Task -1.1: ANSI Style Package
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `pkg/cli/ansi.go`
|
|||
|
|
|
|||
|
|
**Step 1: Create ansi.go with complete implementation**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
package cli
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"fmt"
|
|||
|
|
"strconv"
|
|||
|
|
"strings"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// ANSI escape codes
|
|||
|
|
const (
|
|||
|
|
ansiReset = "\033[0m"
|
|||
|
|
ansiBold = "\033[1m"
|
|||
|
|
ansiDim = "\033[2m"
|
|||
|
|
ansiItalic = "\033[3m"
|
|||
|
|
ansiUnderline = "\033[4m"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// AnsiStyle represents terminal text styling.
|
|||
|
|
// Use NewStyle() to create, chain methods, call Render().
|
|||
|
|
type AnsiStyle struct {
|
|||
|
|
bold bool
|
|||
|
|
dim bool
|
|||
|
|
italic bool
|
|||
|
|
underline bool
|
|||
|
|
fg string
|
|||
|
|
bg string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewStyle creates a new empty style.
|
|||
|
|
func NewStyle() *AnsiStyle {
|
|||
|
|
return &AnsiStyle{}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Bold enables bold text.
|
|||
|
|
func (s *AnsiStyle) Bold() *AnsiStyle {
|
|||
|
|
s.bold = true
|
|||
|
|
return s
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Dim enables dim text.
|
|||
|
|
func (s *AnsiStyle) Dim() *AnsiStyle {
|
|||
|
|
s.dim = true
|
|||
|
|
return s
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Italic enables italic text.
|
|||
|
|
func (s *AnsiStyle) Italic() *AnsiStyle {
|
|||
|
|
s.italic = true
|
|||
|
|
return s
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Underline enables underlined text.
|
|||
|
|
func (s *AnsiStyle) Underline() *AnsiStyle {
|
|||
|
|
s.underline = true
|
|||
|
|
return s
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Foreground sets foreground color from hex string.
|
|||
|
|
func (s *AnsiStyle) Foreground(hex string) *AnsiStyle {
|
|||
|
|
s.fg = fgColorHex(hex)
|
|||
|
|
return s
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Background sets background color from hex string.
|
|||
|
|
func (s *AnsiStyle) Background(hex string) *AnsiStyle {
|
|||
|
|
s.bg = bgColorHex(hex)
|
|||
|
|
return s
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Render applies the style to text.
|
|||
|
|
func (s *AnsiStyle) Render(text string) string {
|
|||
|
|
if s == nil {
|
|||
|
|
return text
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var codes []string
|
|||
|
|
if s.bold {
|
|||
|
|
codes = append(codes, ansiBold)
|
|||
|
|
}
|
|||
|
|
if s.dim {
|
|||
|
|
codes = append(codes, ansiDim)
|
|||
|
|
}
|
|||
|
|
if s.italic {
|
|||
|
|
codes = append(codes, ansiItalic)
|
|||
|
|
}
|
|||
|
|
if s.underline {
|
|||
|
|
codes = append(codes, ansiUnderline)
|
|||
|
|
}
|
|||
|
|
if s.fg != "" {
|
|||
|
|
codes = append(codes, s.fg)
|
|||
|
|
}
|
|||
|
|
if s.bg != "" {
|
|||
|
|
codes = append(codes, s.bg)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if len(codes) == 0 {
|
|||
|
|
return text
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return strings.Join(codes, "") + text + ansiReset
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Hex color support
|
|||
|
|
func fgColorHex(hex string) string {
|
|||
|
|
r, g, b := hexToRGB(hex)
|
|||
|
|
return fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func bgColorHex(hex string) string {
|
|||
|
|
r, g, b := hexToRGB(hex)
|
|||
|
|
return fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func hexToRGB(hex string) (int, int, int) {
|
|||
|
|
hex = strings.TrimPrefix(hex, "#")
|
|||
|
|
if len(hex) != 6 {
|
|||
|
|
return 255, 255, 255
|
|||
|
|
}
|
|||
|
|
r, _ := strconv.ParseInt(hex[0:2], 16, 64)
|
|||
|
|
g, _ := strconv.ParseInt(hex[2:4], 16, 64)
|
|||
|
|
b, _ := strconv.ParseInt(hex[4:6], 16, 64)
|
|||
|
|
return int(r), int(g), int(b)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Verify build**
|
|||
|
|
|
|||
|
|
Run: `go build ./pkg/cli/...`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add pkg/cli/ansi.go
|
|||
|
|
git commit -m "feat(cli): add zero-dependency ANSI styling
|
|||
|
|
|
|||
|
|
Replaces lipgloss with ~100 lines of owned code.
|
|||
|
|
Supports bold, dim, italic, underline, RGB/hex colors.
|
|||
|
|
|
|||
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task -1.2: Rewrite styles.go
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Rewrite: `pkg/cli/styles.go` (delete 672 lines, write ~150)
|
|||
|
|
|
|||
|
|
**Step 1: Delete entire file content and rewrite**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// Package cli provides semantic CLI output with zero external dependencies.
|
|||
|
|
package cli
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"fmt"
|
|||
|
|
"strings"
|
|||
|
|
"time"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Tailwind colour palette (hex strings)
|
|||
|
|
const (
|
|||
|
|
ColourBlue50 = "#eff6ff"
|
|||
|
|
ColourBlue100 = "#dbeafe"
|
|||
|
|
ColourBlue200 = "#bfdbfe"
|
|||
|
|
ColourBlue300 = "#93c5fd"
|
|||
|
|
ColourBlue400 = "#60a5fa"
|
|||
|
|
ColourBlue500 = "#3b82f6"
|
|||
|
|
ColourBlue600 = "#2563eb"
|
|||
|
|
ColourBlue700 = "#1d4ed8"
|
|||
|
|
ColourGreen400 = "#4ade80"
|
|||
|
|
ColourGreen500 = "#22c55e"
|
|||
|
|
ColourGreen600 = "#16a34a"
|
|||
|
|
ColourRed400 = "#f87171"
|
|||
|
|
ColourRed500 = "#ef4444"
|
|||
|
|
ColourRed600 = "#dc2626"
|
|||
|
|
ColourAmber400 = "#fbbf24"
|
|||
|
|
ColourAmber500 = "#f59e0b"
|
|||
|
|
ColourAmber600 = "#d97706"
|
|||
|
|
ColourOrange500 = "#f97316"
|
|||
|
|
ColourYellow500 = "#eab308"
|
|||
|
|
ColourEmerald500= "#10b981"
|
|||
|
|
ColourPurple500 = "#a855f7"
|
|||
|
|
ColourViolet400 = "#a78bfa"
|
|||
|
|
ColourViolet500 = "#8b5cf6"
|
|||
|
|
ColourIndigo500 = "#6366f1"
|
|||
|
|
ColourCyan500 = "#06b6d4"
|
|||
|
|
ColourGray50 = "#f9fafb"
|
|||
|
|
ColourGray100 = "#f3f4f6"
|
|||
|
|
ColourGray200 = "#e5e7eb"
|
|||
|
|
ColourGray300 = "#d1d5db"
|
|||
|
|
ColourGray400 = "#9ca3af"
|
|||
|
|
ColourGray500 = "#6b7280"
|
|||
|
|
ColourGray600 = "#4b5563"
|
|||
|
|
ColourGray700 = "#374151"
|
|||
|
|
ColourGray800 = "#1f2937"
|
|||
|
|
ColourGray900 = "#111827"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Core styles
|
|||
|
|
var (
|
|||
|
|
SuccessStyle = NewStyle().Bold().Foreground(ColourGreen500)
|
|||
|
|
ErrorStyle = NewStyle().Bold().Foreground(ColourRed500)
|
|||
|
|
WarningStyle = NewStyle().Bold().Foreground(ColourAmber500)
|
|||
|
|
InfoStyle = NewStyle().Foreground(ColourBlue400)
|
|||
|
|
DimStyle = NewStyle().Dim().Foreground(ColourGray500)
|
|||
|
|
MutedStyle = NewStyle().Foreground(ColourGray600)
|
|||
|
|
BoldStyle = NewStyle().Bold()
|
|||
|
|
KeyStyle = NewStyle().Foreground(ColourGray400)
|
|||
|
|
ValueStyle = NewStyle().Foreground(ColourGray200)
|
|||
|
|
AccentStyle = NewStyle().Foreground(ColourCyan500)
|
|||
|
|
LinkStyle = NewStyle().Foreground(ColourBlue500).Underline()
|
|||
|
|
HeaderStyle = NewStyle().Bold().Foreground(ColourGray200)
|
|||
|
|
TitleStyle = NewStyle().Bold().Foreground(ColourBlue500)
|
|||
|
|
CodeStyle = NewStyle().Foreground(ColourGray300)
|
|||
|
|
NumberStyle = NewStyle().Foreground(ColourBlue300)
|
|||
|
|
RepoStyle = NewStyle().Bold().Foreground(ColourBlue500)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Truncate shortens a string to max length with ellipsis.
|
|||
|
|
func Truncate(s string, max int) string {
|
|||
|
|
if len(s) <= max {
|
|||
|
|
return s
|
|||
|
|
}
|
|||
|
|
if max <= 3 {
|
|||
|
|
return s[:max]
|
|||
|
|
}
|
|||
|
|
return s[:max-3] + "..."
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Pad right-pads a string to width.
|
|||
|
|
func Pad(s string, width int) string {
|
|||
|
|
if len(s) >= width {
|
|||
|
|
return s
|
|||
|
|
}
|
|||
|
|
return s + strings.Repeat(" ", width-len(s))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 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()))
|
|||
|
|
case d < 24*time.Hour:
|
|||
|
|
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
|||
|
|
case d < 7*24*time.Hour:
|
|||
|
|
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
|||
|
|
case d < 30*24*time.Hour:
|
|||
|
|
return fmt.Sprintf("%dw ago", int(d.Hours()/(24*7)))
|
|||
|
|
default:
|
|||
|
|
return fmt.Sprintf("%dmo ago", int(d.Hours()/(24*30)))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Table renders tabular data with aligned columns.
|
|||
|
|
// HLCRF is for layout; Table is for tabular data - they serve different purposes.
|
|||
|
|
type Table struct {
|
|||
|
|
Headers []string
|
|||
|
|
Rows [][]string
|
|||
|
|
Style TableStyle
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type TableStyle struct {
|
|||
|
|
HeaderStyle *AnsiStyle
|
|||
|
|
CellStyle *AnsiStyle
|
|||
|
|
Separator string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DefaultTableStyle returns sensible defaults.
|
|||
|
|
func DefaultTableStyle() TableStyle {
|
|||
|
|
return TableStyle{
|
|||
|
|
HeaderStyle: HeaderStyle,
|
|||
|
|
CellStyle: nil,
|
|||
|
|
Separator: " ",
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewTable creates a table with headers.
|
|||
|
|
func NewTable(headers ...string) *Table {
|
|||
|
|
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
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// String renders the table.
|
|||
|
|
func (t *Table) String() string {
|
|||
|
|
if len(t.Headers) == 0 && len(t.Rows) == 0 {
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Calculate column widths
|
|||
|
|
cols := len(t.Headers)
|
|||
|
|
if cols == 0 && len(t.Rows) > 0 {
|
|||
|
|
cols = len(t.Rows[0])
|
|||
|
|
}
|
|||
|
|
widths := make([]int, cols)
|
|||
|
|
|
|||
|
|
for i, h := range t.Headers {
|
|||
|
|
if len(h) > widths[i] {
|
|||
|
|
widths[i] = len(h)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
for _, row := range t.Rows {
|
|||
|
|
for i, cell := range row {
|
|||
|
|
if i < cols && len(cell) > widths[i] {
|
|||
|
|
widths[i] = len(cell)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var sb strings.Builder
|
|||
|
|
sep := t.Style.Separator
|
|||
|
|
|
|||
|
|
// Headers
|
|||
|
|
if len(t.Headers) > 0 {
|
|||
|
|
for i, h := range t.Headers {
|
|||
|
|
if i > 0 {
|
|||
|
|
sb.WriteString(sep)
|
|||
|
|
}
|
|||
|
|
styled := Pad(h, widths[i])
|
|||
|
|
if t.Style.HeaderStyle != nil {
|
|||
|
|
styled = t.Style.HeaderStyle.Render(styled)
|
|||
|
|
}
|
|||
|
|
sb.WriteString(styled)
|
|||
|
|
}
|
|||
|
|
sb.WriteString("\n")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Rows
|
|||
|
|
for _, row := range t.Rows {
|
|||
|
|
for i, cell := range row {
|
|||
|
|
if i > 0 {
|
|||
|
|
sb.WriteString(sep)
|
|||
|
|
}
|
|||
|
|
styled := Pad(cell, widths[i])
|
|||
|
|
if t.Style.CellStyle != nil {
|
|||
|
|
styled = t.Style.CellStyle.Render(styled)
|
|||
|
|
}
|
|||
|
|
sb.WriteString(styled)
|
|||
|
|
}
|
|||
|
|
sb.WriteString("\n")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return sb.String()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Render prints the table to stdout.
|
|||
|
|
func (t *Table) Render() {
|
|||
|
|
fmt.Print(t.String())
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Verify build**
|
|||
|
|
|
|||
|
|
Run: `go build ./pkg/cli/...`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add pkg/cli/styles.go
|
|||
|
|
git commit -m "refactor(cli): rewrite styles with zero-dep ANSI
|
|||
|
|
|
|||
|
|
Deletes 672 lines of lipgloss code, replaces with ~150 lines.
|
|||
|
|
Previous code available at 96eaed5 if needed.
|
|||
|
|
|
|||
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task -1.3: Rewrite output.go
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Rewrite: `pkg/cli/output.go` (delete Style namespace, add semantic functions)
|
|||
|
|
|
|||
|
|
**Step 1: Delete entire file content and rewrite**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
package cli
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"fmt"
|
|||
|
|
|
|||
|
|
"github.com/host-uk/core/pkg/i18n"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Blank prints an empty line.
|
|||
|
|
func Blank() {
|
|||
|
|
fmt.Println()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 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.Println(i18n.T(key, args...))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Print outputs formatted text (no newline).
|
|||
|
|
// Glyph shortcodes like :check: are converted.
|
|||
|
|
func Print(format string, args ...any) {
|
|||
|
|
fmt.Print(compileGlyphs(fmt.Sprintf(format, args...)))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Println outputs formatted text with newline.
|
|||
|
|
// Glyph shortcodes like :check: are converted.
|
|||
|
|
func Println(format string, args ...any) {
|
|||
|
|
fmt.Println(compileGlyphs(fmt.Sprintf(format, args...)))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Success prints a success message with checkmark (green).
|
|||
|
|
func Success(msg string) {
|
|||
|
|
fmt.Println(SuccessStyle.Render(Glyph(":check:") + " " + msg))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Successf prints a formatted success message.
|
|||
|
|
func Successf(format string, args ...any) {
|
|||
|
|
Success(fmt.Sprintf(format, args...))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Error prints an error message with cross (red).
|
|||
|
|
func Error(msg string) {
|
|||
|
|
fmt.Println(ErrorStyle.Render(Glyph(":cross:") + " " + msg))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Errorf prints a formatted error message.
|
|||
|
|
func Errorf(format string, args ...any) {
|
|||
|
|
Error(fmt.Sprintf(format, args...))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Warn prints a warning message with warning symbol (amber).
|
|||
|
|
func Warn(msg string) {
|
|||
|
|
fmt.Println(WarningStyle.Render(Glyph(":warn:") + " " + msg))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Warnf prints a formatted warning message.
|
|||
|
|
func Warnf(format string, args ...any) {
|
|||
|
|
Warn(fmt.Sprintf(format, args...))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Info prints an info message with info symbol (blue).
|
|||
|
|
func Info(msg string) {
|
|||
|
|
fmt.Println(InfoStyle.Render(Glyph(":info:") + " " + msg))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Infof prints a formatted info message.
|
|||
|
|
func Infof(format string, args ...any) {
|
|||
|
|
Info(fmt.Sprintf(format, args...))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Dim prints dimmed text.
|
|||
|
|
func Dim(msg string) {
|
|||
|
|
fmt.Println(DimStyle.Render(msg))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Progress prints a progress indicator that overwrites the current line.
|
|||
|
|
// Uses i18n.Progress for gerund form ("Checking...").
|
|||
|
|
func Progress(verb string, current, total int, item ...string) {
|
|||
|
|
msg := i18n.Progress(verb)
|
|||
|
|
if len(item) > 0 && item[0] != "" {
|
|||
|
|
fmt.Printf("\033[2K\r%s %d/%d %s", DimStyle.Render(msg), current, total, item[0])
|
|||
|
|
} else {
|
|||
|
|
fmt.Printf("\033[2K\r%s %d/%d", DimStyle.Render(msg), current, total)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ProgressDone clears the progress line.
|
|||
|
|
func ProgressDone() {
|
|||
|
|
fmt.Print("\033[2K\r")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Label prints a "Label: value" line.
|
|||
|
|
func Label(word, value string) {
|
|||
|
|
fmt.Printf("%s %s\n", KeyStyle.Render(i18n.Label(word)), value)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Scanln reads from stdin.
|
|||
|
|
func Scanln(a ...any) (int, error) {
|
|||
|
|
return fmt.Scanln(a...)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Verify build**
|
|||
|
|
|
|||
|
|
Run: `go build ./pkg/cli/...`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add pkg/cli/output.go
|
|||
|
|
git commit -m "refactor(cli): rewrite output with semantic functions
|
|||
|
|
|
|||
|
|
Replaces Style namespace with explicit Success/Error/Warn/Info.
|
|||
|
|
Previous code available at 96eaed5 if needed.
|
|||
|
|
|
|||
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task -1.4: Rewrite strings.go
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Rewrite: `pkg/cli/strings.go` (remove lipgloss import)
|
|||
|
|
|
|||
|
|
**Step 1: Delete and rewrite**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
package cli
|
|||
|
|
|
|||
|
|
import "fmt"
|
|||
|
|
|
|||
|
|
// Sprintf formats a string (fmt.Sprintf wrapper).
|
|||
|
|
func Sprintf(format string, args ...any) string {
|
|||
|
|
return fmt.Sprintf(format, args...)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Sprint formats using default formats (fmt.Sprint wrapper).
|
|||
|
|
func Sprint(args ...any) string {
|
|||
|
|
return fmt.Sprint(args...)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Styled returns text with a style applied.
|
|||
|
|
func Styled(style *AnsiStyle, text string) string {
|
|||
|
|
return style.Render(text)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Styledf returns formatted text with a style applied.
|
|||
|
|
func Styledf(style *AnsiStyle, format string, args ...any) string {
|
|||
|
|
return style.Render(fmt.Sprintf(format, args...))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SuccessStr returns success-styled string.
|
|||
|
|
func SuccessStr(msg string) string {
|
|||
|
|
return SuccessStyle.Render(Glyph(":check:") + " " + msg)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ErrorStr returns error-styled string.
|
|||
|
|
func ErrorStr(msg string) string {
|
|||
|
|
return ErrorStyle.Render(Glyph(":cross:") + " " + msg)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// WarnStr returns warning-styled string.
|
|||
|
|
func WarnStr(msg string) string {
|
|||
|
|
return WarningStyle.Render(Glyph(":warn:") + " " + msg)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// InfoStr returns info-styled string.
|
|||
|
|
func InfoStr(msg string) string {
|
|||
|
|
return InfoStyle.Render(Glyph(":info:") + " " + msg)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DimStr returns dim-styled string.
|
|||
|
|
func DimStr(msg string) string {
|
|||
|
|
return DimStyle.Render(msg)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Verify build**
|
|||
|
|
|
|||
|
|
Run: `go build ./pkg/cli/...`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add pkg/cli/strings.go
|
|||
|
|
git commit -m "refactor(cli): rewrite strings with zero-dep styling
|
|||
|
|
|
|||
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task -1.5: Update errors.go
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `pkg/cli/errors.go`
|
|||
|
|
|
|||
|
|
**Step 1: Replace SymbolCross with Glyph**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// Before
|
|||
|
|
fmt.Println(ErrorStyle.Render(SymbolCross + " " + msg))
|
|||
|
|
|
|||
|
|
// After
|
|||
|
|
fmt.Println(ErrorStyle.Render(Glyph(":cross:") + " " + msg))
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Apply to: `Fatalf`, `FatalWrap`, `FatalWrapVerb`
|
|||
|
|
|
|||
|
|
**Step 2: Verify build**
|
|||
|
|
|
|||
|
|
Run: `go build ./pkg/cli/...`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add pkg/cli/errors.go
|
|||
|
|
git commit -m "refactor(cli): update errors to use glyph system
|
|||
|
|
|
|||
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task -1.6: Migrate pkg/php and pkg/vm
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `pkg/php/cmd_quality.go`
|
|||
|
|
- Modify: `pkg/php/cmd_dev.go`
|
|||
|
|
- Modify: `pkg/php/cmd.go`
|
|||
|
|
- Modify: `pkg/vm/cmd_vm.go`
|
|||
|
|
|
|||
|
|
**Step 1: Replace lipgloss imports with cli**
|
|||
|
|
|
|||
|
|
In each file:
|
|||
|
|
- Remove `"github.com/charmbracelet/lipgloss"` import
|
|||
|
|
- Replace `lipgloss.NewStyle()...` with `cli.NewStyle()...`
|
|||
|
|
- Replace colour references: `lipgloss.Color(...)` → hex string
|
|||
|
|
|
|||
|
|
**Step 2: Verify build**
|
|||
|
|
|
|||
|
|
Run: `go build ./pkg/php/... ./pkg/vm/...`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add pkg/php/*.go pkg/vm/*.go
|
|||
|
|
git commit -m "refactor(php,vm): migrate to cli ANSI styling
|
|||
|
|
|
|||
|
|
Removes direct lipgloss imports.
|
|||
|
|
|
|||
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task -1.7: Simple Wizard Prompts
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `pkg/cli/prompt.go`
|
|||
|
|
- Rewrite: `pkg/setup/cmd_wizard.go`
|
|||
|
|
|
|||
|
|
**Step 1: Create prompt.go**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
package cli
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"bufio"
|
|||
|
|
"fmt"
|
|||
|
|
"os"
|
|||
|
|
"strconv"
|
|||
|
|
"strings"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
var stdin = bufio.NewReader(os.Stdin)
|
|||
|
|
|
|||
|
|
// Prompt asks for text input with a default value.
|
|||
|
|
func Prompt(label, defaultVal string) (string, error) {
|
|||
|
|
if defaultVal != "" {
|
|||
|
|
fmt.Printf("%s [%s]: ", label, defaultVal)
|
|||
|
|
} else {
|
|||
|
|
fmt.Printf("%s: ", label)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
input, err := stdin.ReadString('\n')
|
|||
|
|
if err != nil {
|
|||
|
|
return "", err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
input = strings.TrimSpace(input)
|
|||
|
|
if input == "" {
|
|||
|
|
return defaultVal, nil
|
|||
|
|
}
|
|||
|
|
return input, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Confirm asks a yes/no question.
|
|||
|
|
func Confirm(label string) (bool, error) {
|
|||
|
|
fmt.Printf("%s [y/N]: ", label)
|
|||
|
|
|
|||
|
|
input, err := stdin.ReadString('\n')
|
|||
|
|
if err != nil {
|
|||
|
|
return false, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
input = strings.ToLower(strings.TrimSpace(input))
|
|||
|
|
return input == "y" || input == "yes", nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Select presents numbered options and returns the selected value.
|
|||
|
|
func Select(label string, options []string) (string, error) {
|
|||
|
|
fmt.Println(label)
|
|||
|
|
for i, opt := range options {
|
|||
|
|
fmt.Printf(" %d. %s\n", i+1, opt)
|
|||
|
|
}
|
|||
|
|
fmt.Printf("Choose [1-%d]: ", len(options))
|
|||
|
|
|
|||
|
|
input, err := stdin.ReadString('\n')
|
|||
|
|
if err != nil {
|
|||
|
|
return "", err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
n, err := strconv.Atoi(strings.TrimSpace(input))
|
|||
|
|
if err != nil || n < 1 || n > len(options) {
|
|||
|
|
return "", fmt.Errorf("invalid selection")
|
|||
|
|
}
|
|||
|
|
return options[n-1], nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MultiSelect presents checkboxes (space-separated numbers).
|
|||
|
|
func MultiSelect(label string, options []string) ([]string, error) {
|
|||
|
|
fmt.Println(label)
|
|||
|
|
for i, opt := range options {
|
|||
|
|
fmt.Printf(" %d. %s\n", i+1, opt)
|
|||
|
|
}
|
|||
|
|
fmt.Printf("Choose (space-separated) [1-%d]: ", len(options))
|
|||
|
|
|
|||
|
|
input, err := stdin.ReadString('\n')
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var selected []string
|
|||
|
|
for _, s := range strings.Fields(input) {
|
|||
|
|
n, err := strconv.Atoi(s)
|
|||
|
|
if err != nil || n < 1 || n > len(options) {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
selected = append(selected, options[n-1])
|
|||
|
|
}
|
|||
|
|
return selected, nil
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Rewrite cmd_wizard.go to use simple prompts**
|
|||
|
|
|
|||
|
|
Remove huh import, replace form calls with cli.Prompt/Confirm/Select/MultiSelect.
|
|||
|
|
|
|||
|
|
**Step 3: Verify build**
|
|||
|
|
|
|||
|
|
Run: `go build ./pkg/cli/... ./pkg/setup/...`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 4: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add pkg/cli/prompt.go pkg/setup/cmd_wizard.go
|
|||
|
|
git commit -m "refactor(setup): replace huh with simple stdin prompts
|
|||
|
|
|
|||
|
|
Removes ~30 transitive dependencies.
|
|||
|
|
Previous wizard at 96eaed5 if needed.
|
|||
|
|
|
|||
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task -1.8: Remove Charmbracelet from go.mod
|
|||
|
|
|
|||
|
|
**Step 1: Run go mod tidy**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
go mod tidy
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Verify no charmbracelet deps remain**
|
|||
|
|
|
|||
|
|
Run: `grep charmbracelet go.mod`
|
|||
|
|
Expected: No output
|
|||
|
|
|
|||
|
|
**Step 3: Check binary size reduction**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
go build -o /tmp/core-new ./cmd/core-cli
|
|||
|
|
ls -lh /tmp/core-new
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 4: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add go.mod go.sum
|
|||
|
|
git commit -m "chore: remove charmbracelet dependencies
|
|||
|
|
|
|||
|
|
Zero external dependencies for CLI output.
|
|||
|
|
Binary size reduced.
|
|||
|
|
|
|||
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 0: HLCRF Layout System
|
|||
|
|
|
|||
|
|
### Task 0.1: Layout Parser
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `pkg/cli/layout.go`
|
|||
|
|
|
|||
|
|
**Step 1: Create layout.go**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
package cli
|
|||
|
|
|
|||
|
|
import "fmt"
|
|||
|
|
|
|||
|
|
// Region represents one of the 5 HLCRF regions.
|
|||
|
|
type Region rune
|
|||
|
|
|
|||
|
|
const (
|
|||
|
|
RegionHeader Region = 'H'
|
|||
|
|
RegionLeft Region = 'L'
|
|||
|
|
RegionContent Region = 'C'
|
|||
|
|
RegionRight Region = 'R'
|
|||
|
|
RegionFooter Region = 'F'
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Composite represents an HLCRF layout node.
|
|||
|
|
type Composite struct {
|
|||
|
|
variant string
|
|||
|
|
path string
|
|||
|
|
regions map[Region]*Slot
|
|||
|
|
parent *Composite
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Slot holds content for a region.
|
|||
|
|
type Slot struct {
|
|||
|
|
region Region
|
|||
|
|
path string
|
|||
|
|
blocks []Renderable
|
|||
|
|
child *Composite
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Renderable is anything that can be rendered to terminal.
|
|||
|
|
type Renderable interface {
|
|||
|
|
Render() string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// StringBlock is a simple string that implements Renderable.
|
|||
|
|
type StringBlock string
|
|||
|
|
|
|||
|
|
func (s StringBlock) Render() string { return string(s) }
|
|||
|
|
|
|||
|
|
// Layout creates a new layout from a variant string.
|
|||
|
|
func Layout(variant string) *Composite {
|
|||
|
|
c, err := ParseVariant(variant)
|
|||
|
|
if err != nil {
|
|||
|
|
return &Composite{variant: variant, regions: make(map[Region]*Slot)}
|
|||
|
|
}
|
|||
|
|
return c
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ParseVariant parses a variant string like "H[LC]C[HCF]F".
|
|||
|
|
func ParseVariant(variant string) (*Composite, error) {
|
|||
|
|
c := &Composite{
|
|||
|
|
variant: variant,
|
|||
|
|
path: "",
|
|||
|
|
regions: make(map[Region]*Slot),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
i := 0
|
|||
|
|
for i < len(variant) {
|
|||
|
|
r := Region(variant[i])
|
|||
|
|
if !isValidRegion(r) {
|
|||
|
|
return nil, fmt.Errorf("invalid region: %c", r)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
slot := &Slot{region: r, path: string(r)}
|
|||
|
|
c.regions[r] = slot
|
|||
|
|
i++
|
|||
|
|
|
|||
|
|
if i < len(variant) && variant[i] == '[' {
|
|||
|
|
end := findMatchingBracket(variant, i)
|
|||
|
|
if end == -1 {
|
|||
|
|
return nil, fmt.Errorf("unmatched bracket at %d", i)
|
|||
|
|
}
|
|||
|
|
nested, err := ParseVariant(variant[i+1 : end])
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
nested.path = string(r) + "-"
|
|||
|
|
nested.parent = c
|
|||
|
|
slot.child = nested
|
|||
|
|
i = end + 1
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return c, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func isValidRegion(r Region) bool {
|
|||
|
|
return r == 'H' || r == 'L' || r == 'C' || r == 'R' || r == 'F'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func findMatchingBracket(s string, start int) int {
|
|||
|
|
depth := 0
|
|||
|
|
for i := start; i < len(s); i++ {
|
|||
|
|
if s[i] == '[' {
|
|||
|
|
depth++
|
|||
|
|
} else if s[i] == ']' {
|
|||
|
|
depth--
|
|||
|
|
if depth == 0 {
|
|||
|
|
return i
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return -1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// H adds content to Header region.
|
|||
|
|
func (c *Composite) H(items ...any) *Composite { c.addToRegion(RegionHeader, items...); return c }
|
|||
|
|
|
|||
|
|
// L adds content to Left region.
|
|||
|
|
func (c *Composite) L(items ...any) *Composite { c.addToRegion(RegionLeft, items...); return c }
|
|||
|
|
|
|||
|
|
// C adds content to Content region.
|
|||
|
|
func (c *Composite) C(items ...any) *Composite { c.addToRegion(RegionContent, items...); return c }
|
|||
|
|
|
|||
|
|
// R adds content to Right region.
|
|||
|
|
func (c *Composite) R(items ...any) *Composite { c.addToRegion(RegionRight, items...); return c }
|
|||
|
|
|
|||
|
|
// F adds content to Footer region.
|
|||
|
|
func (c *Composite) F(items ...any) *Composite { c.addToRegion(RegionFooter, items...); return c }
|
|||
|
|
|
|||
|
|
func (c *Composite) addToRegion(r Region, items ...any) {
|
|||
|
|
slot, ok := c.regions[r]
|
|||
|
|
if !ok {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
for _, item := range items {
|
|||
|
|
slot.blocks = append(slot.blocks, toRenderable(item))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func toRenderable(item any) Renderable {
|
|||
|
|
switch v := item.(type) {
|
|||
|
|
case Renderable:
|
|||
|
|
return v
|
|||
|
|
case string:
|
|||
|
|
return StringBlock(v)
|
|||
|
|
default:
|
|||
|
|
return StringBlock(fmt.Sprint(v))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Verify build**
|
|||
|
|
|
|||
|
|
Run: `go build ./pkg/cli/...`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add pkg/cli/layout.go
|
|||
|
|
git commit -m "feat(cli): add HLCRF layout parser
|
|||
|
|
|
|||
|
|
Implements RFC-001 compositor pattern for terminal output.
|
|||
|
|
|
|||
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 0.2: Terminal Renderer
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `pkg/cli/render.go`
|
|||
|
|
|
|||
|
|
**Step 1: Create render.go**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
package cli
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"fmt"
|
|||
|
|
"strings"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// RenderStyle controls how layouts are rendered.
|
|||
|
|
type RenderStyle int
|
|||
|
|
|
|||
|
|
const (
|
|||
|
|
RenderFlat RenderStyle = iota // No borders
|
|||
|
|
RenderSimple // --- separators
|
|||
|
|
RenderBoxed // Unicode box drawing
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
var currentRenderStyle = RenderFlat
|
|||
|
|
|
|||
|
|
func UseRenderFlat() { currentRenderStyle = RenderFlat }
|
|||
|
|
func UseRenderSimple() { currentRenderStyle = RenderSimple }
|
|||
|
|
func UseRenderBoxed() { currentRenderStyle = RenderBoxed }
|
|||
|
|
|
|||
|
|
// Render outputs the layout to terminal.
|
|||
|
|
func (c *Composite) Render() {
|
|||
|
|
fmt.Print(c.String())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// String returns the rendered layout.
|
|||
|
|
func (c *Composite) String() string {
|
|||
|
|
var sb strings.Builder
|
|||
|
|
c.renderTo(&sb, 0)
|
|||
|
|
return sb.String()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (c *Composite) renderTo(sb *strings.Builder, depth int) {
|
|||
|
|
order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter}
|
|||
|
|
|
|||
|
|
var active []Region
|
|||
|
|
for _, r := range order {
|
|||
|
|
if slot, ok := c.regions[r]; ok {
|
|||
|
|
if len(slot.blocks) > 0 || slot.child != nil {
|
|||
|
|
active = append(active, r)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for i, r := range active {
|
|||
|
|
slot := c.regions[r]
|
|||
|
|
if i > 0 && currentRenderStyle != RenderFlat {
|
|||
|
|
c.renderSeparator(sb, depth)
|
|||
|
|
}
|
|||
|
|
c.renderSlot(sb, slot, depth)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (c *Composite) renderSeparator(sb *strings.Builder, depth int) {
|
|||
|
|
indent := strings.Repeat(" ", depth)
|
|||
|
|
switch currentRenderStyle {
|
|||
|
|
case RenderBoxed:
|
|||
|
|
sb.WriteString(indent + "├" + strings.Repeat("─", 40) + "┤\n")
|
|||
|
|
case RenderSimple:
|
|||
|
|
sb.WriteString(indent + strings.Repeat("─", 40) + "\n")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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") {
|
|||
|
|
if line != "" {
|
|||
|
|
sb.WriteString(indent + line + "\n")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if slot.child != nil {
|
|||
|
|
slot.child.renderTo(sb, depth+1)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Verify build**
|
|||
|
|
|
|||
|
|
Run: `go build ./pkg/cli/...`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add pkg/cli/render.go
|
|||
|
|
git commit -m "feat(cli): add HLCRF terminal renderer
|
|||
|
|
|
|||
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 1: Glyph System
|
|||
|
|
|
|||
|
|
### Task 1.1: Glyph Core
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `pkg/cli/glyph.go`
|
|||
|
|
|
|||
|
|
**Step 1: Create glyph.go**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
package cli
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"bytes"
|
|||
|
|
"unicode"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// GlyphTheme defines which symbols to use.
|
|||
|
|
type GlyphTheme int
|
|||
|
|
|
|||
|
|
const (
|
|||
|
|
ThemeUnicode GlyphTheme = iota
|
|||
|
|
ThemeEmoji
|
|||
|
|
ThemeASCII
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
var currentTheme = ThemeUnicode
|
|||
|
|
|
|||
|
|
func UseUnicode() { currentTheme = ThemeUnicode }
|
|||
|
|
func UseEmoji() { currentTheme = ThemeEmoji }
|
|||
|
|
func UseASCII() { currentTheme = ThemeASCII }
|
|||
|
|
|
|||
|
|
func glyphMap() map[string]string {
|
|||
|
|
switch currentTheme {
|
|||
|
|
case ThemeEmoji:
|
|||
|
|
return glyphMapEmoji
|
|||
|
|
case ThemeASCII:
|
|||
|
|
return glyphMapASCII
|
|||
|
|
default:
|
|||
|
|
return glyphMapUnicode
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Glyph converts a shortcode to its symbol.
|
|||
|
|
func Glyph(code string) string {
|
|||
|
|
if sym, ok := glyphMap()[code]; ok {
|
|||
|
|
return sym
|
|||
|
|
}
|
|||
|
|
return code
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func compileGlyphs(x string) string {
|
|||
|
|
if x == "" {
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
input := bytes.NewBufferString(x)
|
|||
|
|
output := bytes.NewBufferString("")
|
|||
|
|
|
|||
|
|
for {
|
|||
|
|
r, _, err := input.ReadRune()
|
|||
|
|
if err != nil {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
if r == ':' {
|
|||
|
|
output.WriteString(replaceGlyph(input))
|
|||
|
|
} else {
|
|||
|
|
output.WriteRune(r)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return output.String()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func replaceGlyph(input *bytes.Buffer) string {
|
|||
|
|
code := bytes.NewBufferString(":")
|
|||
|
|
for {
|
|||
|
|
r, _, err := input.ReadRune()
|
|||
|
|
if err != nil {
|
|||
|
|
return code.String()
|
|||
|
|
}
|
|||
|
|
if r == ':' && code.Len() == 1 {
|
|||
|
|
return code.String() + replaceGlyph(input)
|
|||
|
|
}
|
|||
|
|
code.WriteRune(r)
|
|||
|
|
if unicode.IsSpace(r) {
|
|||
|
|
return code.String()
|
|||
|
|
}
|
|||
|
|
if r == ':' {
|
|||
|
|
return Glyph(code.String())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Verify build**
|
|||
|
|
|
|||
|
|
Run: `go build ./pkg/cli/...`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add pkg/cli/glyph.go
|
|||
|
|
git commit -m "feat(cli): add glyph shortcode system
|
|||
|
|
|
|||
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 1.2: Glyph Maps
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `pkg/cli/glyph_maps.go`
|
|||
|
|
|
|||
|
|
**Step 1: Create glyph_maps.go**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
package cli
|
|||
|
|
|
|||
|
|
var glyphMapUnicode = map[string]string{
|
|||
|
|
":check:": "✓", ":cross:": "✗", ":warn:": "⚠", ":info:": "ℹ",
|
|||
|
|
":question:": "?", ":skip:": "○", ":dot:": "●", ":circle:": "◯",
|
|||
|
|
":arrow_right:": "→", ":arrow_left:": "←", ":arrow_up:": "↑", ":arrow_down:": "↓",
|
|||
|
|
":pointer:": "▶", ":bullet:": "•", ":dash:": "─", ":pipe:": "│",
|
|||
|
|
":corner:": "└", ":tee:": "├", ":pending:": "…", ":spinner:": "⠋",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var glyphMapEmoji = map[string]string{
|
|||
|
|
":check:": "✅", ":cross:": "❌", ":warn:": "⚠️", ":info:": "ℹ️",
|
|||
|
|
":question:": "❓", ":skip:": "⏭️", ":dot:": "🔵", ":circle:": "⚪",
|
|||
|
|
":arrow_right:": "➡️", ":arrow_left:": "⬅️", ":arrow_up:": "⬆️", ":arrow_down:": "⬇️",
|
|||
|
|
":pointer:": "▶️", ":bullet:": "•", ":dash:": "─", ":pipe:": "│",
|
|||
|
|
":corner:": "└", ":tee:": "├", ":pending:": "⏳", ":spinner:": "🔄",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var glyphMapASCII = map[string]string{
|
|||
|
|
":check:": "[OK]", ":cross:": "[FAIL]", ":warn:": "[WARN]", ":info:": "[INFO]",
|
|||
|
|
":question:": "[?]", ":skip:": "[SKIP]", ":dot:": "[*]", ":circle:": "[ ]",
|
|||
|
|
":arrow_right:": "->", ":arrow_left:": "<-", ":arrow_up:": "^", ":arrow_down:": "v",
|
|||
|
|
":pointer:": ">", ":bullet:": "*", ":dash:": "-", ":pipe:": "|",
|
|||
|
|
":corner:": "`", ":tee:": "+", ":pending:": "...", ":spinner:": "-",
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Verify build**
|
|||
|
|
|
|||
|
|
Run: `go build ./pkg/cli/...`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add pkg/cli/glyph_maps.go
|
|||
|
|
git commit -m "feat(cli): add glyph maps for unicode/emoji/ascii
|
|||
|
|
|
|||
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 2: DX-Focused Semantic Output
|
|||
|
|
|
|||
|
|
### Task 2.0: Semantic Patterns for Consuming Packages
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `pkg/cli/check.go`
|
|||
|
|
- Modify: `pkg/cli/output.go`
|
|||
|
|
|
|||
|
|
**Goal:** Eliminate display logic from consuming packages. Only `cli` knows about styling.
|
|||
|
|
|
|||
|
|
**Step 1: Create check.go with fluent Check builder**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
package cli
|
|||
|
|
|
|||
|
|
import "fmt"
|
|||
|
|
|
|||
|
|
// CheckBuilder provides fluent API for check results.
|
|||
|
|
type CheckBuilder struct {
|
|||
|
|
name string
|
|||
|
|
status string
|
|||
|
|
style *AnsiStyle
|
|||
|
|
icon string
|
|||
|
|
duration string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Check starts building a check result line.
|
|||
|
|
//
|
|||
|
|
// cli.Check("audit").Pass()
|
|||
|
|
// cli.Check("fmt").Fail().Duration("2.3s")
|
|||
|
|
// cli.Check("test").Skip()
|
|||
|
|
func Check(name string) *CheckBuilder {
|
|||
|
|
return &CheckBuilder{name: name}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Pass marks the check as passed.
|
|||
|
|
func (c *CheckBuilder) Pass() *CheckBuilder {
|
|||
|
|
c.status = "passed"
|
|||
|
|
c.style = SuccessStyle
|
|||
|
|
c.icon = Glyph(":check:")
|
|||
|
|
return c
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Fail marks the check as failed.
|
|||
|
|
func (c *CheckBuilder) Fail() *CheckBuilder {
|
|||
|
|
c.status = "failed"
|
|||
|
|
c.style = ErrorStyle
|
|||
|
|
c.icon = Glyph(":cross:")
|
|||
|
|
return c
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Skip marks the check as skipped.
|
|||
|
|
func (c *CheckBuilder) Skip() *CheckBuilder {
|
|||
|
|
c.status = "skipped"
|
|||
|
|
c.style = DimStyle
|
|||
|
|
c.icon = "-"
|
|||
|
|
return c
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Warn marks the check as warning.
|
|||
|
|
func (c *CheckBuilder) Warn() *CheckBuilder {
|
|||
|
|
c.status = "warning"
|
|||
|
|
c.style = WarningStyle
|
|||
|
|
c.icon = Glyph(":warn:")
|
|||
|
|
return c
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Duration adds duration to the check result.
|
|||
|
|
func (c *CheckBuilder) Duration(d string) *CheckBuilder {
|
|||
|
|
c.duration = d
|
|||
|
|
return c
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Message adds a custom message instead of status.
|
|||
|
|
func (c *CheckBuilder) Message(msg string) *CheckBuilder {
|
|||
|
|
c.status = msg
|
|||
|
|
return c
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// String returns the formatted check line.
|
|||
|
|
func (c *CheckBuilder) String() string {
|
|||
|
|
icon := c.icon
|
|||
|
|
if c.style != nil {
|
|||
|
|
icon = c.style.Render(c.icon)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
status := c.status
|
|||
|
|
if c.style != nil && c.status != "" {
|
|||
|
|
status = c.style.Render(c.status)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if c.duration != "" {
|
|||
|
|
return fmt.Sprintf(" %s %-20s %-10s %s", icon, c.name, status, DimStyle.Render(c.duration))
|
|||
|
|
}
|
|||
|
|
if status != "" {
|
|||
|
|
return fmt.Sprintf(" %s %s %s", icon, c.name, status)
|
|||
|
|
}
|
|||
|
|
return fmt.Sprintf(" %s %s", icon, c.name)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Print outputs the check result.
|
|||
|
|
func (c *CheckBuilder) Print() {
|
|||
|
|
fmt.Println(c.String())
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Add semantic output functions to output.go**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// Task prints a task header: "[label] message"
|
|||
|
|
//
|
|||
|
|
// cli.Task("php", "Running tests...") // [php] Running tests...
|
|||
|
|
// cli.Task("go", i18n.Progress("build")) // [go] Building...
|
|||
|
|
func Task(label, message string) {
|
|||
|
|
fmt.Printf("%s %s\n\n", DimStyle.Render("["+label+"]"), message)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Section prints a section header: "── SECTION ──"
|
|||
|
|
//
|
|||
|
|
// cli.Section("audit") // ── AUDIT ──
|
|||
|
|
func Section(name string) {
|
|||
|
|
header := "── " + strings.ToUpper(name) + " ──"
|
|||
|
|
fmt.Println(AccentStyle.Render(header))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Hint prints a labelled hint: "label: message"
|
|||
|
|
//
|
|||
|
|
// cli.Hint("install", "composer require vimeo/psalm")
|
|||
|
|
// cli.Hint("fix", "core php fmt --fix")
|
|||
|
|
func Hint(label, message string) {
|
|||
|
|
fmt.Printf(" %s %s\n", DimStyle.Render(label+":"), message)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Severity prints a severity-styled message.
|
|||
|
|
//
|
|||
|
|
// cli.Severity("critical", "SQL injection") // red, bold
|
|||
|
|
// cli.Severity("high", "XSS vulnerability") // orange
|
|||
|
|
// cli.Severity("medium", "Missing CSRF") // amber
|
|||
|
|
// cli.Severity("low", "Debug enabled") // gray
|
|||
|
|
func Severity(level, message string) {
|
|||
|
|
var style *AnsiStyle
|
|||
|
|
switch strings.ToLower(level) {
|
|||
|
|
case "critical":
|
|||
|
|
style = NewStyle().Bold().Foreground(ColourRed500)
|
|||
|
|
case "high":
|
|||
|
|
style = NewStyle().Bold().Foreground(ColourOrange500)
|
|||
|
|
case "medium":
|
|||
|
|
style = NewStyle().Foreground(ColourAmber500)
|
|||
|
|
case "low":
|
|||
|
|
style = NewStyle().Foreground(ColourGray500)
|
|||
|
|
default:
|
|||
|
|
style = DimStyle
|
|||
|
|
}
|
|||
|
|
fmt.Printf(" %s %s\n", style.Render("["+level+"]"), message)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Result prints a result line: "✓ message" or "✗ message"
|
|||
|
|
//
|
|||
|
|
// cli.Result(passed, "All tests passed")
|
|||
|
|
// cli.Result(false, "3 tests failed")
|
|||
|
|
func Result(passed bool, message string) {
|
|||
|
|
if passed {
|
|||
|
|
Success(message)
|
|||
|
|
} else {
|
|||
|
|
Error(message)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 3: Add strings import to output.go**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
import (
|
|||
|
|
"fmt"
|
|||
|
|
"strings"
|
|||
|
|
|
|||
|
|
"github.com/host-uk/core/pkg/i18n"
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 4: Verify build**
|
|||
|
|
|
|||
|
|
Run: `go build ./pkg/cli/...`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add pkg/cli/check.go pkg/cli/output.go
|
|||
|
|
git commit -m "feat(cli): add DX-focused semantic output patterns
|
|||
|
|
|
|||
|
|
- Check() fluent builder for check results
|
|||
|
|
- Task() for task headers
|
|||
|
|
- Section() for section headers
|
|||
|
|
- Hint() for labelled hints
|
|||
|
|
- Severity() for severity-styled output
|
|||
|
|
- Result() for pass/fail results
|
|||
|
|
|
|||
|
|
Consuming packages now have zero display logic.
|
|||
|
|
|
|||
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 3: Full Migration
|
|||
|
|
|
|||
|
|
### Task 3.1: Migrate All pkg/* Files
|
|||
|
|
|
|||
|
|
**Files:** All files in pkg/ that use:
|
|||
|
|
- `i18n.T()` directly (should use `cli.Echo()`)
|
|||
|
|
- `lipgloss.*` (should use `cli.*Style`)
|
|||
|
|
- `fmt.Printf/Println` for output (should use `cli.Print/Println`)
|
|||
|
|
|
|||
|
|
**Step 1: Find all files needing migration**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
grep -r "i18n\.T\|lipgloss\|fmt\.Print" pkg/ --include="*.go" | grep -v "pkg/cli/" | grep -v "_test.go"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Migrate each file**
|
|||
|
|
|
|||
|
|
Pattern replacements:
|
|||
|
|
- `fmt.Printf(...)` → `cli.Print(...)`
|
|||
|
|
- `fmt.Println(...)` → `cli.Println(...)`
|
|||
|
|
- `i18n.T("key")` → `cli.Echo("key")` or keep for values
|
|||
|
|
- `successStyle.Render(...)` → `cli.SuccessStyle.Render(...)`
|
|||
|
|
|
|||
|
|
**Step 3: Verify build**
|
|||
|
|
|
|||
|
|
Run: `go build ./...`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 4: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add pkg/
|
|||
|
|
git commit -m "refactor: migrate all pkg/* to cli abstraction
|
|||
|
|
|
|||
|
|
No direct fmt/i18n/lipgloss imports outside pkg/cli.
|
|||
|
|
|
|||
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 3.2: Tests
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `pkg/cli/ansi_test.go`
|
|||
|
|
- Create: `pkg/cli/glyph_test.go`
|
|||
|
|
- Create: `pkg/cli/layout_test.go`
|
|||
|
|
|
|||
|
|
**Step 1: Write tests**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// ansi_test.go
|
|||
|
|
package cli
|
|||
|
|
|
|||
|
|
import "testing"
|
|||
|
|
|
|||
|
|
func TestAnsiStyle_Render(t *testing.T) {
|
|||
|
|
s := NewStyle().Bold().Foreground("#ff0000")
|
|||
|
|
got := s.Render("test")
|
|||
|
|
if got == "test" {
|
|||
|
|
t.Error("Expected styled output")
|
|||
|
|
}
|
|||
|
|
if !contains(got, "test") {
|
|||
|
|
t.Error("Output should contain text")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func contains(s, sub string) bool {
|
|||
|
|
return len(s) >= len(sub) && s[len(s)-len(sub)-4:len(s)-4] == sub
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Run tests**
|
|||
|
|
|
|||
|
|
Run: `go test ./pkg/cli/... -v`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add pkg/cli/*_test.go
|
|||
|
|
git commit -m "test(cli): add unit tests for ANSI, glyph, layout
|
|||
|
|
|
|||
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 3.3: Final Verification
|
|||
|
|
|
|||
|
|
**Step 1: Full build**
|
|||
|
|
|
|||
|
|
Run: `go build ./...`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 2: All tests**
|
|||
|
|
|
|||
|
|
Run: `go test ./...`
|
|||
|
|
Expected: PASS
|
|||
|
|
|
|||
|
|
**Step 3: Verify zero charmbracelet**
|
|||
|
|
|
|||
|
|
Run: `grep charmbracelet go.mod`
|
|||
|
|
Expected: No output
|
|||
|
|
|
|||
|
|
**Step 4: Binary test**
|
|||
|
|
|
|||
|
|
Run: `./bin/core dev health`
|
|||
|
|
Expected: Output displays correctly
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Summary of New API
|
|||
|
|
|
|||
|
|
| Function | Purpose |
|
|||
|
|
|----------|---------|
|
|||
|
|
| `cli.Blank()` | Empty line |
|
|||
|
|
| `cli.Echo(key, args...)` | Translate + print |
|
|||
|
|
| `cli.Print(fmt, args...)` | Printf with glyphs |
|
|||
|
|
| `cli.Println(fmt, args...)` | Println with glyphs |
|
|||
|
|
| `cli.Success(msg)` | ✓ green |
|
|||
|
|
| `cli.Error(msg)` | ✗ red |
|
|||
|
|
| `cli.Warn(msg)` | ⚠ amber |
|
|||
|
|
| `cli.Info(msg)` | ℹ blue |
|
|||
|
|
| `cli.Dim(msg)` | Dimmed text |
|
|||
|
|
| `cli.Progress(verb, n, total)` | Overwriting progress |
|
|||
|
|
| `cli.ProgressDone()` | Clear progress |
|
|||
|
|
| `cli.Label(word, value)` | "Label: value" |
|
|||
|
|
| `cli.Prompt(label, default)` | Text input |
|
|||
|
|
| `cli.Confirm(label)` | y/n |
|
|||
|
|
| `cli.Select(label, opts)` | Numbered list |
|
|||
|
|
| `cli.MultiSelect(label, opts)` | Multi-select |
|
|||
|
|
| `cli.Glyph(code)` | Get symbol |
|
|||
|
|
| `cli.UseUnicode/Emoji/ASCII()` | Set theme |
|
|||
|
|
| `cli.Layout(variant)` | HLCRF layout |
|
|||
|
|
| `cli.NewTable(headers...)` | Create table |
|
|||
|
|
| `cli.FormatAge(time)` | "2h ago" |
|
|||
|
|
| `cli.Truncate(s, max)` | Ellipsis truncation |
|
|||
|
|
| `cli.Pad(s, width)` | Right-pad string |
|
|||
|
|
| **DX Patterns** | |
|
|||
|
|
| `cli.Task(label, msg)` | `[php] Running...` |
|
|||
|
|
| `cli.Section(name)` | `── AUDIT ──` |
|
|||
|
|
| `cli.Check(name).Pass/Fail/Skip()` | Fluent check result |
|
|||
|
|
| `cli.Hint(label, msg)` | `install: composer...` |
|
|||
|
|
| `cli.Severity(level, msg)` | Critical/high/med/low |
|
|||
|
|
| `cli.Result(ok, msg)` | Pass/fail result |
|