cli/docs/plans/2026-01-31-semantic-cli-output.md
Snider a93cc3540a feat(plugin): add Claude Code plugin for host-uk framework
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>
2026-01-31 10:27:04 +00:00

1685 lines
37 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 |