Core plugin providing: - Skills: core CLI reference, PHP patterns, Go patterns - Commands: /core:remember for context persistence - Hooks: - PreToolUse: block dangerous commands (rm -rf, sed -i, grep -l |) - PreToolUse: enforce core CLI over raw go/php commands - PostToolUse: auto-format Go/PHP, check for debug statements - PostToolUse: warn about uncommitted work after git commit - PreCompact: save state to prevent amnesia after auto-compact - SessionStart: restore context from recent sessions (<3h) - MCP: core CLI server integration Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1685 lines
37 KiB
Markdown
1685 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 |
|