From a93cc3540ae17740d6183d3b17eda083d647599f Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 31 Jan 2026 10:27:04 +0000 Subject: [PATCH] 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 --- docs/plans/2026-01-30-i18n-v2-design.md | 134 ++ docs/plans/2026-01-31-semantic-cli-output.md | 1685 ++++++++++++++++++ pkg/cli/command.go | 193 ++ pkg/cli/errors.go | 116 ++ pkg/cli/output.go | 176 ++ pkg/cli/strings.go | 88 + plugin/commands/remember.md | 36 + plugin/hooks/prefer-core.sh | 102 ++ plugin/plugin.json | 109 ++ plugin/scripts/block-docs.sh | 27 + plugin/scripts/capture-context.sh | 44 + plugin/scripts/check-debug.sh | 27 + plugin/scripts/extract-actionables.sh | 34 + plugin/scripts/go-format.sh | 19 + plugin/scripts/php-format.sh | 17 + plugin/scripts/post-commit-check.sh | 51 + plugin/scripts/pr-created.sh | 18 + plugin/scripts/pre-compact.sh | 69 + plugin/scripts/session-start.sh | 34 + plugin/scripts/suggest-compact.sh | 28 + plugin/skills/core.md | 60 + plugin/skills/go.md | 107 ++ plugin/skills/php.md | 120 ++ 23 files changed, 3294 insertions(+) create mode 100644 docs/plans/2026-01-30-i18n-v2-design.md create mode 100644 docs/plans/2026-01-31-semantic-cli-output.md create mode 100644 pkg/cli/command.go create mode 100644 pkg/cli/errors.go create mode 100644 pkg/cli/output.go create mode 100644 pkg/cli/strings.go create mode 100644 plugin/commands/remember.md create mode 100755 plugin/hooks/prefer-core.sh create mode 100644 plugin/plugin.json create mode 100755 plugin/scripts/block-docs.sh create mode 100755 plugin/scripts/capture-context.sh create mode 100755 plugin/scripts/check-debug.sh create mode 100755 plugin/scripts/extract-actionables.sh create mode 100755 plugin/scripts/go-format.sh create mode 100755 plugin/scripts/php-format.sh create mode 100755 plugin/scripts/post-commit-check.sh create mode 100755 plugin/scripts/pr-created.sh create mode 100755 plugin/scripts/pre-compact.sh create mode 100755 plugin/scripts/session-start.sh create mode 100755 plugin/scripts/suggest-compact.sh create mode 100644 plugin/skills/core.md create mode 100644 plugin/skills/go.md create mode 100644 plugin/skills/php.md diff --git a/docs/plans/2026-01-30-i18n-v2-design.md b/docs/plans/2026-01-30-i18n-v2-design.md new file mode 100644 index 00000000..c5a4cb82 --- /dev/null +++ b/docs/plans/2026-01-30-i18n-v2-design.md @@ -0,0 +1,134 @@ +# i18n Package Refactor Design + +## Goal +Refactor pkg/i18n to be extensible without breaking changes in future. Based on Gemini review recommendations. + +## File Structure + +### Renamed/Merged +| Current | New | Reason | +|---------|-----|--------| +| `interfaces.go` | `types.go` | Contains types, not interfaces | +| `mutate.go` | `loader.go` | Loads/flattens JSON | +| `actions.go` | `hooks.go` | Missing key callbacks | +| `checks.go` | (merge into loader.go) | Loading helpers | +| `mode.go` | (merge into types.go) | Just one type | + +### New Files +| File | Purpose | +|------|---------| +| `handler.go` | KeyHandler interface + built-in handlers | +| `context.go` | TranslationContext + C() helper | + +### Unchanged +`grammar.go`, `language.go`, `localise.go`, `debug.go`, `numbers.go`, `time.go`, `i18n.go`, `intents.go`, `compose.go`, `transform.go` + +## Interfaces + +### KeyHandler +```go +// KeyHandler processes translation keys before standard lookup. +type KeyHandler interface { + Match(key string) bool + Handle(key string, args []any, next func() string) string +} +``` + +Built-in handlers: +- `LabelHandler` - `i18n.label.*` → "Status:" +- `ProgressHandler` - `i18n.progress.*` → "Building..." +- `CountHandler` - `i18n.count.*` → "5 files" +- `NumericHandler` - `i18n.numeric.*` → formatted numbers +- `DoneHandler` - `i18n.done.*` → "File deleted" +- `FailHandler` - `i18n.fail.*` → "Failed to delete file" + +### Loader +```go +// Loader provides translation data to the Service. +type Loader interface { + Load(lang string) (map[string]Message, *GrammarData, error) + Languages() []string +} +``` + +Built-in: `FSLoader` for embedded/filesystem JSON. + +### TranslationContext +```go +type TranslationContext struct { + Context string + Gender string + Formality Formality + Extra map[string]any +} + +func C(context string) *TranslationContext +``` + +## Service Changes + +```go +type Service struct { + loader Loader + messages map[string]map[string]Message + grammar map[string]*GrammarData + currentLang string + fallbackLang string + formality Formality + mode Mode + debug bool + handlers []KeyHandler + mu sync.RWMutex +} +``` + +### Constructors +```go +func New() (*Service, error) +func NewWithLoader(loader Loader, opts ...Option) (*Service, error) + +type Option func(*Service) +func WithDefaultHandlers() Option +func WithFallback(lang string) Option +func WithFormality(f Formality) Option +``` + +### T() Flow +1. Parse args → extract Context, Subject, data +2. Run handler chain (each can handle or call next) +3. Standard lookup with context suffix fallback + +## Public API + +### Keep +- `T(key, args...)`, `Raw(key, args...)` +- `S(noun, value)` - Subject builder +- `SetLanguage()`, `CurrentLanguage()`, `SetMode()`, `CurrentMode()` +- `SetFormality()`, `SetDebug()`, `Direction()`, `IsRTL()` +- Grammar: `PastTense()`, `Gerund()`, `Pluralize()`, `Article()`, `Title()`, `Label()`, `Progress()` + +### Add +- `C(context)` - Context builder +- `NewWithLoader()` - Custom loader support +- `AddHandler()`, `PrependHandler()` - Custom handlers + +### Remove (No Aliases) +- `NewSubject()` - use `S()` +- `N()` - use `T("i18n.numeric.*")` + +## Breaking Changes +- Constructor signature changes +- Internal file reorganisation +- No backwards compatibility layer + +## Implementation Order +1. Create new files (types.go, handler.go, loader.go, context.go, hooks.go) +2. Move types from interfaces.go → types.go +3. Implement Loader interface + FSLoader +4. Implement KeyHandler interface + built-in handlers +5. Implement TranslationContext +6. Update Service struct + constructors +7. Update T() to use handler chain +8. Update package-level functions in i18n.go +9. Delete old files +10. Update tests diff --git a/docs/plans/2026-01-31-semantic-cli-output.md b/docs/plans/2026-01-31-semantic-cli-output.md new file mode 100644 index 00000000..23f886c0 --- /dev/null +++ b/docs/plans/2026-01-31-semantic-cli-output.md @@ -0,0 +1,1685 @@ +# 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 " +``` + +--- + +### 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 " +``` + +--- + +### 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 " +``` + +--- + +### 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 " +``` + +--- + +### 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 " +``` + +--- + +### 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 " +``` + +--- + +### 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 " +``` + +--- + +### 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 " +``` + +--- + +## 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 " +``` + +--- + +### 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 " +``` + +--- + +## 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 " +``` + +--- + +### 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 " +``` + +--- + +## 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 " +``` + +--- + +## 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 " +``` + +--- + +### 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 " +``` + +--- + +### 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 | diff --git a/pkg/cli/command.go b/pkg/cli/command.go new file mode 100644 index 00000000..31b6e1b5 --- /dev/null +++ b/pkg/cli/command.go @@ -0,0 +1,193 @@ +package cli + +import ( + "github.com/spf13/cobra" +) + +// ───────────────────────────────────────────────────────────────────────────── +// Command Type Re-export +// ───────────────────────────────────────────────────────────────────────────── + +// Command is the cobra command type. +// Re-exported for convenience so packages don't need to import cobra directly. +type Command = cobra.Command + +// ───────────────────────────────────────────────────────────────────────────── +// Command Builders +// ───────────────────────────────────────────────────────────────────────────── + +// NewCommand creates a new command with a RunE handler. +// This is the standard way to create commands that may return errors. +// +// cmd := cli.NewCommand("build", "Build the project", "", func(cmd *cli.Command, args []string) error { +// // Build logic +// return nil +// }) +func NewCommand(use, short, long string, run func(cmd *Command, args []string) error) *Command { + cmd := &Command{ + Use: use, + Short: short, + RunE: run, + } + if long != "" { + cmd.Long = long + } + return cmd +} + +// NewGroup creates a new command group (no RunE). +// Use this for parent commands that only contain subcommands. +// +// devCmd := cli.NewGroup("dev", "Development commands", "") +// devCmd.AddCommand(buildCmd, testCmd) +func NewGroup(use, short, long string) *Command { + cmd := &Command{ + Use: use, + Short: short, + } + if long != "" { + cmd.Long = long + } + return cmd +} + +// NewRun creates a new command with a simple Run handler (no error return). +// Use when the command cannot fail. +// +// cmd := cli.NewRun("version", "Show version", "", func(cmd *cli.Command, args []string) { +// cli.Println("v1.0.0") +// }) +func NewRun(use, short, long string, run func(cmd *Command, args []string)) *Command { + cmd := &Command{ + Use: use, + Short: short, + Run: run, + } + if long != "" { + cmd.Long = long + } + return cmd +} + +// ───────────────────────────────────────────────────────────────────────────── +// Flag Helpers +// ───────────────────────────────────────────────────────────────────────────── + +// StringFlag adds a string flag to a command. +// The value will be stored in the provided pointer. +// +// var output string +// cli.StringFlag(cmd, &output, "output", "o", "", "Output file path") +func StringFlag(cmd *Command, ptr *string, name, short, def, usage string) { + if short != "" { + cmd.Flags().StringVarP(ptr, name, short, def, usage) + } else { + cmd.Flags().StringVar(ptr, name, def, usage) + } +} + +// BoolFlag adds a boolean flag to a command. +// The value will be stored in the provided pointer. +// +// var verbose bool +// cli.BoolFlag(cmd, &verbose, "verbose", "v", false, "Enable verbose output") +func BoolFlag(cmd *Command, ptr *bool, name, short string, def bool, usage string) { + if short != "" { + cmd.Flags().BoolVarP(ptr, name, short, def, usage) + } else { + cmd.Flags().BoolVar(ptr, name, def, usage) + } +} + +// IntFlag adds an integer flag to a command. +// The value will be stored in the provided pointer. +// +// var count int +// cli.IntFlag(cmd, &count, "count", "n", 10, "Number of items") +func IntFlag(cmd *Command, ptr *int, name, short string, def int, usage string) { + if short != "" { + cmd.Flags().IntVarP(ptr, name, short, def, usage) + } else { + cmd.Flags().IntVar(ptr, name, def, usage) + } +} + +// StringSliceFlag adds a string slice flag to a command. +// The value will be stored in the provided pointer. +// +// var tags []string +// cli.StringSliceFlag(cmd, &tags, "tag", "t", nil, "Tags to apply") +func StringSliceFlag(cmd *Command, ptr *[]string, name, short string, def []string, usage string) { + if short != "" { + cmd.Flags().StringSliceVarP(ptr, name, short, def, usage) + } else { + cmd.Flags().StringSliceVar(ptr, name, def, usage) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Persistent Flag Helpers +// ───────────────────────────────────────────────────────────────────────────── + +// PersistentStringFlag adds a persistent string flag (inherited by subcommands). +func PersistentStringFlag(cmd *Command, ptr *string, name, short, def, usage string) { + if short != "" { + cmd.PersistentFlags().StringVarP(ptr, name, short, def, usage) + } else { + cmd.PersistentFlags().StringVar(ptr, name, def, usage) + } +} + +// PersistentBoolFlag adds a persistent boolean flag (inherited by subcommands). +func PersistentBoolFlag(cmd *Command, ptr *bool, name, short string, def bool, usage string) { + if short != "" { + cmd.PersistentFlags().BoolVarP(ptr, name, short, def, usage) + } else { + cmd.PersistentFlags().BoolVar(ptr, name, def, usage) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Command Configuration +// ───────────────────────────────────────────────────────────────────────────── + +// WithArgs sets the Args validation function for a command. +// Returns the command for chaining. +// +// cmd := cli.NewCommand("build", "Build", "", run).WithArgs(cobra.ExactArgs(1)) +func WithArgs(cmd *Command, args cobra.PositionalArgs) *Command { + cmd.Args = args + return cmd +} + +// WithExample sets the Example field for a command. +// Returns the command for chaining. +func WithExample(cmd *Command, example string) *Command { + cmd.Example = example + return cmd +} + +// ExactArgs returns a PositionalArgs that accepts exactly N arguments. +func ExactArgs(n int) cobra.PositionalArgs { + return cobra.ExactArgs(n) +} + +// MinimumNArgs returns a PositionalArgs that accepts minimum N arguments. +func MinimumNArgs(n int) cobra.PositionalArgs { + return cobra.MinimumNArgs(n) +} + +// MaximumNArgs returns a PositionalArgs that accepts maximum N arguments. +func MaximumNArgs(n int) cobra.PositionalArgs { + return cobra.MaximumNArgs(n) +} + +// NoArgs returns a PositionalArgs that accepts no arguments. +func NoArgs() cobra.PositionalArgs { + return cobra.NoArgs +} + +// ArbitraryArgs returns a PositionalArgs that accepts any arguments. +func ArbitraryArgs() cobra.PositionalArgs { + return cobra.ArbitraryArgs +} diff --git a/pkg/cli/errors.go b/pkg/cli/errors.go new file mode 100644 index 00000000..503005fd --- /dev/null +++ b/pkg/cli/errors.go @@ -0,0 +1,116 @@ +package cli + +import ( + "errors" + "fmt" + "os" + + "github.com/host-uk/core/pkg/i18n" +) + +// ───────────────────────────────────────────────────────────────────────────── +// Error Creation (replace fmt.Errorf) +// ───────────────────────────────────────────────────────────────────────────── + +// Err creates a new error from a format string. +// This is a direct replacement for fmt.Errorf. +func Err(format string, args ...any) error { + return fmt.Errorf(format, args...) +} + +// Wrap wraps an error with a message. +// Returns nil if err is nil. +// +// return cli.Wrap(err, "load config") // "load config: " +func Wrap(err error, msg string) error { + if err == nil { + return nil + } + return fmt.Errorf("%s: %w", msg, err) +} + +// WrapVerb wraps an error using i18n grammar for "Failed to verb subject". +// Uses the i18n.ActionFailed function for proper grammar composition. +// Returns nil if err is nil. +// +// return cli.WrapVerb(err, "load", "config") // "Failed to load config: " +func WrapVerb(err error, verb, subject string) error { + if err == nil { + return nil + } + msg := i18n.ActionFailed(verb, subject) + return fmt.Errorf("%s: %w", msg, err) +} + +// WrapAction wraps an error using i18n grammar for "Failed to verb". +// Uses the i18n.ActionFailed function for proper grammar composition. +// Returns nil if err is nil. +// +// return cli.WrapAction(err, "connect") // "Failed to connect: " +func WrapAction(err error, verb string) error { + if err == nil { + return nil + } + msg := i18n.ActionFailed(verb, "") + return fmt.Errorf("%s: %w", msg, err) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Error Helpers +// ───────────────────────────────────────────────────────────────────────────── + +// Is reports whether any error in err's tree matches target. +// This is a re-export of errors.Is for convenience. +func Is(err, target error) bool { + return errors.Is(err, target) +} + +// As finds the first error in err's tree that matches target. +// This is a re-export of errors.As for convenience. +func As(err error, target any) bool { + return errors.As(err, target) +} + +// Join returns an error that wraps the given errors. +// This is a re-export of errors.Join for convenience. +func Join(errs ...error) error { + return errors.Join(errs...) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Fatal Functions (print and exit) +// ───────────────────────────────────────────────────────────────────────────── + +// Fatalf prints a formatted error message and exits with code 1. +func Fatalf(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + fmt.Println(ErrorStyle.Render(SymbolCross + " " + msg)) + os.Exit(1) +} + +// FatalWrap prints a wrapped error message and exits with code 1. +// Does nothing if err is nil. +// +// cli.FatalWrap(err, "load config") // Prints "✗ load config: " and exits +func FatalWrap(err error, msg string) { + if err == nil { + return + } + fullMsg := fmt.Sprintf("%s: %v", msg, err) + fmt.Println(ErrorStyle.Render(SymbolCross + " " + fullMsg)) + os.Exit(1) +} + +// FatalWrapVerb prints a wrapped error using i18n grammar and exits with code 1. +// Does nothing if err is nil. +// +// cli.FatalWrapVerb(err, "load", "config") // Prints "✗ Failed to load config: " and exits +func FatalWrapVerb(err error, verb, subject string) { + if err == nil { + return + } + msg := i18n.ActionFailed(verb, subject) + fullMsg := fmt.Sprintf("%s: %v", msg, err) + fmt.Println(ErrorStyle.Render(SymbolCross + " " + fullMsg)) + os.Exit(1) +} diff --git a/pkg/cli/output.go b/pkg/cli/output.go new file mode 100644 index 00000000..0a2c5649 --- /dev/null +++ b/pkg/cli/output.go @@ -0,0 +1,176 @@ +package cli + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/host-uk/core/pkg/i18n" +) + +// ───────────────────────────────────────────────────────────────────────────── +// Style Namespace +// ───────────────────────────────────────────────────────────────────────────── + +// Styles provides namespaced access to CLI styles. +// Usage: cli.Style.Dim.Render("text"), cli.Style.Success.Render("done") +var Style = struct { + // Text styles + Dim lipgloss.Style + Muted lipgloss.Style + Bold lipgloss.Style + Value lipgloss.Style + Accent lipgloss.Style + Code lipgloss.Style + Key lipgloss.Style + Number lipgloss.Style + Link lipgloss.Style + Header lipgloss.Style + Title lipgloss.Style + Stage lipgloss.Style + PrNum lipgloss.Style + AccentL lipgloss.Style + + // Status styles + Success lipgloss.Style + Error lipgloss.Style + Warning lipgloss.Style + Info lipgloss.Style + + // Git styles + Dirty lipgloss.Style + Ahead lipgloss.Style + Behind lipgloss.Style + Clean lipgloss.Style + Conflict lipgloss.Style + + // Repo name style + Repo lipgloss.Style + + // Coverage styles + CoverageHigh lipgloss.Style + CoverageMed lipgloss.Style + CoverageLow lipgloss.Style + + // Priority styles + PriorityHigh lipgloss.Style + PriorityMedium lipgloss.Style + PriorityLow lipgloss.Style + + // Severity styles + SeverityCritical lipgloss.Style + SeverityHigh lipgloss.Style + SeverityMedium lipgloss.Style + SeverityLow lipgloss.Style + + // Status indicator styles + StatusPending lipgloss.Style + StatusRunning lipgloss.Style + StatusSuccess lipgloss.Style + StatusError lipgloss.Style + StatusWarning lipgloss.Style + + // Deploy styles + DeploySuccess lipgloss.Style + DeployPending lipgloss.Style + DeployFailed lipgloss.Style + + // Box styles + Box lipgloss.Style + BoxHeader lipgloss.Style + ErrorBox lipgloss.Style + SuccessBox lipgloss.Style +}{ + // Text styles + Dim: DimStyle, + Muted: MutedStyle, + Bold: BoldStyle, + Value: ValueStyle, + Accent: AccentStyle, + Code: CodeStyle, + Key: KeyStyle, + Number: NumberStyle, + Link: LinkStyle, + Header: HeaderStyle, + Title: TitleStyle, + Stage: StageStyle, + PrNum: PrNumberStyle, + AccentL: AccentLabelStyle, + + // Status styles + Success: SuccessStyle, + Error: ErrorStyle, + Warning: WarningStyle, + Info: InfoStyle, + + // Git styles + Dirty: GitDirtyStyle, + Ahead: GitAheadStyle, + Behind: GitBehindStyle, + Clean: GitCleanStyle, + Conflict: GitConflictStyle, + + // Repo name style + Repo: RepoNameStyle, + + // Coverage styles + CoverageHigh: CoverageHighStyle, + CoverageMed: CoverageMedStyle, + CoverageLow: CoverageLowStyle, + + // Priority styles + PriorityHigh: PriorityHighStyle, + PriorityMedium: PriorityMediumStyle, + PriorityLow: PriorityLowStyle, + + // Severity styles + SeverityCritical: SeverityCriticalStyle, + SeverityHigh: SeverityHighStyle, + SeverityMedium: SeverityMediumStyle, + SeverityLow: SeverityLowStyle, + + // Status indicator styles + StatusPending: StatusPendingStyle, + StatusRunning: StatusRunningStyle, + StatusSuccess: StatusSuccessStyle, + StatusError: StatusErrorStyle, + StatusWarning: StatusWarningStyle, + + // Deploy styles + DeploySuccess: DeploySuccessStyle, + DeployPending: DeployPendingStyle, + DeployFailed: DeployFailedStyle, + + // Box styles + Box: BoxStyle, + BoxHeader: BoxHeaderStyle, + ErrorBox: ErrorBoxStyle, + SuccessBox: SuccessBoxStyle, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Core Output Functions +// ───────────────────────────────────────────────────────────────────────────── + +// Line translates a key via i18n.T and prints with newline. +// If no key is provided, prints an empty line. +// +// cli.Line("i18n.progress.check") // prints "Checking...\n" +// cli.Line("cmd.dev.ci.short") // prints translated text + \n +// cli.Line("greeting", map[string]any{"Name": "World"}) // with args +// cli.Line("") // prints empty line +func Line(key string, args ...any) { + if key == "" { + fmt.Println() + return + } + fmt.Println(i18n.T(key, args...)) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Input Functions +// ───────────────────────────────────────────────────────────────────────────── + +// Scanln reads from stdin, similar to fmt.Scanln. +func Scanln(a ...any) (int, error) { + return fmt.Scanln(a...) +} diff --git a/pkg/cli/strings.go b/pkg/cli/strings.go new file mode 100644 index 00000000..9322ced6 --- /dev/null +++ b/pkg/cli/strings.go @@ -0,0 +1,88 @@ +package cli + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +// ───────────────────────────────────────────────────────────────────────────── +// String Formatting (replace fmt.Sprintf) +// ───────────────────────────────────────────────────────────────────────────── + +// Sprintf formats a string. +// This is a direct replacement for fmt.Sprintf. +func Sprintf(format string, args ...any) string { + return fmt.Sprintf(format, args...) +} + +// Sprint formats using the default formats for its operands. +// This is a direct replacement for fmt.Sprint. +func Sprint(args ...any) string { + return fmt.Sprint(args...) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Styled String Functions +// ───────────────────────────────────────────────────────────────────────────── + +// Styled returns text formatted with a style. +// Example: cli.Styled(cli.Style.Success, "Done!") +func Styled(style lipgloss.Style, text string) string { + return style.Render(text) +} + +// Styledf returns formatted text with a style. +// Example: cli.Styledf(cli.Style.Success, "Processed %d items", count) +func Styledf(style lipgloss.Style, format string, args ...any) string { + return style.Render(fmt.Sprintf(format, args...)) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Pre-styled Formatting Functions +// ───────────────────────────────────────────────────────────────────────────── + +// SuccessStr returns a success-styled string with checkmark. +func SuccessStr(msg string) string { + return SuccessStyle.Render(SymbolCheck + " " + msg) +} + +// ErrorStr returns an error-styled string with cross. +func ErrorStr(msg string) string { + return ErrorStyle.Render(SymbolCross + " " + msg) +} + +// WarningStr returns a warning-styled string with warning symbol. +func WarningStr(msg string) string { + return WarningStyle.Render(SymbolWarning + " " + msg) +} + +// InfoStr returns an info-styled string with info symbol. +func InfoStr(msg string) string { + return InfoStyle.Render(SymbolInfo + " " + msg) +} + +// DimStr returns a dim-styled string. +func DimStr(msg string) string { + return DimStyle.Render(msg) +} + +// BoldStr returns a bold-styled string. +func BoldStr(msg string) string { + return BoldStyle.Render(msg) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Numeric Formatting +// ───────────────────────────────────────────────────────────────────────────── + +// Itoa converts an integer to a string. +// This is a convenience function similar to strconv.Itoa. +func Itoa(n int) string { + return fmt.Sprintf("%d", n) +} + +// Itoa64 converts an int64 to a string. +func Itoa64(n int64) string { + return fmt.Sprintf("%d", n) +} diff --git a/plugin/commands/remember.md b/plugin/commands/remember.md new file mode 100644 index 00000000..41b8effb --- /dev/null +++ b/plugin/commands/remember.md @@ -0,0 +1,36 @@ +--- +name: remember +description: Save a fact or decision to context for persistence across compacts +args: +--- + +# Remember Context + +Save the provided fact to `~/.claude/sessions/context.json`. + +## Usage + +``` +/core:remember Use Action pattern not Service +/core:remember User prefers UK English +/core:remember RFC: minimal state in pre-compact hook +``` + +## Action + +Run this command to save the fact: + +```bash +~/.claude/plugins/cache/core/scripts/capture-context.sh "" "user" +``` + +Or if running from the plugin directory: + +```bash +"${CLAUDE_PLUGIN_ROOT}/scripts/capture-context.sh" "" "user" +``` + +The fact will be: +- Stored in context.json (max 20 items) +- Included in pre-compact snapshots +- Auto-cleared after 3 hours of inactivity diff --git a/plugin/hooks/prefer-core.sh b/plugin/hooks/prefer-core.sh new file mode 100755 index 00000000..52ce773e --- /dev/null +++ b/plugin/hooks/prefer-core.sh @@ -0,0 +1,102 @@ +#!/bin/bash +# PreToolUse hook: Block dangerous commands, enforce core CLI +# +# BLOCKS: +# - Raw go commands (use core go *) +# - Destructive grep patterns (sed -i, xargs rm, etc.) +# - Mass file operations (rm -rf, mv/cp with wildcards) +# - Any sed outside of safe patterns +# +# This prevents "efficient shortcuts" that nuke codebases + +read -r input +command=$(echo "$input" | jq -r '.tool_input.command // empty') + +# === HARD BLOCKS - Never allow these === + +# Block rm -rf, rm -r (except for known safe paths like node_modules, vendor, .cache) +if echo "$command" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*r|--recursive)'; then + # Allow only specific safe directories + if ! echo "$command" | grep -qE 'rm\s+(-rf|-r)\s+(node_modules|vendor|\.cache|dist|build|__pycache__|\.pytest_cache|/tmp/)'; then + echo '{"decision": "block", "message": "BLOCKED: Recursive delete is not allowed. Delete files individually or ask the user to run this command."}' + exit 0 + fi +fi + +# Block mv/cp with wildcards (mass file moves) +if echo "$command" | grep -qE '(mv|cp)\s+.*\*'; then + echo '{"decision": "block", "message": "BLOCKED: Mass file move/copy with wildcards is not allowed. Move files individually."}' + exit 0 +fi + +# Block xargs with rm, mv, cp (mass operations) +if echo "$command" | grep -qE 'xargs\s+.*(rm|mv|cp)'; then + echo '{"decision": "block", "message": "BLOCKED: xargs with file operations is not allowed. Too risky for mass changes."}' + exit 0 +fi + +# Block find -exec with rm, mv, cp +if echo "$command" | grep -qE 'find\s+.*-exec\s+.*(rm|mv|cp)'; then + echo '{"decision": "block", "message": "BLOCKED: find -exec with file operations is not allowed. Too risky for mass changes."}' + exit 0 +fi + +# Block ALL sed -i (in-place editing) +if echo "$command" | grep -qE 'sed\s+(-[a-zA-Z]*i|--in-place)'; then + echo '{"decision": "block", "message": "BLOCKED: sed -i (in-place edit) is never allowed. Use the Edit tool for file changes."}' + exit 0 +fi + +# Block sed piped to file operations +if echo "$command" | grep -qE 'sed.*\|.*tee|sed.*>'; then + echo '{"decision": "block", "message": "BLOCKED: sed with file output is not allowed. Use the Edit tool for file changes."}' + exit 0 +fi + +# Block grep with -l piped to xargs/rm/sed (the classic codebase nuke pattern) +if echo "$command" | grep -qE 'grep\s+.*-l.*\|'; then + echo '{"decision": "block", "message": "BLOCKED: grep -l piped to other commands is the classic codebase nuke pattern. Not allowed."}' + exit 0 +fi + +# Block perl -i, awk with file redirection (sed alternatives) +if echo "$command" | grep -qE 'perl\s+-[a-zA-Z]*i|awk.*>'; then + echo '{"decision": "block", "message": "BLOCKED: In-place file editing with perl/awk is not allowed. Use the Edit tool."}' + exit 0 +fi + +# === REQUIRE CORE CLI === + +# Block raw go commands +case "$command" in + "go test"*|"go build"*|"go fmt"*|"go mod tidy"*|"go vet"*|"go run"*) + echo '{"decision": "block", "message": "Use `core go test`, `core build`, `core go fmt --fix`, etc. Raw go commands are not allowed."}' + exit 0 + ;; + "go "*) + # Other go commands - warn but allow + echo '{"decision": "block", "message": "Prefer `core go *` commands. If core does not have this command, ask the user."}' + exit 0 + ;; +esac + +# Block raw php commands +case "$command" in + "php artisan serve"*|"./vendor/bin/pest"*|"./vendor/bin/pint"*|"./vendor/bin/phpstan"*) + echo '{"decision": "block", "message": "Use `core php dev`, `core php test`, `core php fmt`, `core php analyse`. Raw php commands are not allowed."}' + exit 0 + ;; + "composer test"*|"composer lint"*) + echo '{"decision": "block", "message": "Use `core php test` or `core php fmt`. Raw composer commands are not allowed."}' + exit 0 + ;; +esac + +# Block golangci-lint directly +if echo "$command" | grep -qE '^golangci-lint'; then + echo '{"decision": "block", "message": "Use `core go lint` instead of golangci-lint directly."}' + exit 0 +fi + +# === APPROVED === +echo '{"decision": "approve"}' diff --git a/plugin/plugin.json b/plugin/plugin.json new file mode 100644 index 00000000..027d50dd --- /dev/null +++ b/plugin/plugin.json @@ -0,0 +1,109 @@ +{ + "name": "core", + "version": "1.0.0", + "description": "Host UK unified framework - Go CLI, PHP framework, multi-repo management", + "dependencies": [ + "superpowers@claude-plugins-official" + ], + "skills": [ + { + "name": "core", + "path": "skills/core.md", + "description": "Use when working in host-uk repositories. Provides core CLI command reference." + }, + { + "name": "core-php", + "path": "skills/php.md", + "description": "Use when creating PHP modules, services, or actions in core-* packages." + }, + { + "name": "core-go", + "path": "skills/go.md", + "description": "Use when creating Go packages or extending the core CLI." + } + ], + "commands": [ + { + "name": "remember", + "path": "commands/remember.md", + "description": "Save a fact or decision to context" + } + ], + "hooks": { + "SessionStart": [ + { + "matcher": "*", + "script": "scripts/session-start.sh", + "description": "Check for recent session state on startup" + } + ], + "PreCompact": [ + { + "matcher": "*", + "script": "scripts/pre-compact.sh", + "description": "Save state before auto-compact to prevent amnesia" + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "script": "hooks/prefer-core.sh", + "description": "Suggest core CLI instead of raw go/php commands" + }, + { + "matcher": "Write", + "script": "scripts/block-docs.sh", + "description": "Block random .md files, keep docs consolidated" + }, + { + "matcher": "Edit", + "script": "scripts/suggest-compact.sh", + "description": "Suggest /compact at logical intervals" + }, + { + "matcher": "Write", + "script": "scripts/suggest-compact.sh", + "description": "Suggest /compact at logical intervals" + } + ], + "PostToolUse": [ + { + "matcher": "Edit", + "script": "scripts/php-format.sh", + "description": "Auto-format PHP files after edits" + }, + { + "matcher": "Edit", + "script": "scripts/go-format.sh", + "description": "Auto-format Go files after edits" + }, + { + "matcher": "Edit", + "script": "scripts/check-debug.sh", + "description": "Warn about debug statements (dd, dump, fmt.Println)" + }, + { + "matcher": "Bash", + "script": "scripts/pr-created.sh", + "description": "Log PR URL after creation" + }, + { + "matcher": "Bash", + "script": "scripts/extract-actionables.sh", + "description": "Extract actionables from core CLI output" + }, + { + "matcher": "Bash", + "script": "scripts/post-commit-check.sh", + "description": "Warn about uncommitted work after git commit" + } + ] + }, + "mcp": { + "core": { + "command": "core", + "args": ["mcp", "serve"], + "description": "Core CLI MCP server for multi-repo operations" + } + } +} diff --git a/plugin/scripts/block-docs.sh b/plugin/scripts/block-docs.sh new file mode 100755 index 00000000..dfac1da3 --- /dev/null +++ b/plugin/scripts/block-docs.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Block creation of random .md files - keeps docs consolidated + +read -r input +FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty') + +if [[ -n "$FILE_PATH" ]]; then + # Allow known documentation files + case "$FILE_PATH" in + *README.md|*CLAUDE.md|*AGENTS.md|*CONTRIBUTING.md|*CHANGELOG.md|*LICENSE.md) + echo "$input" + exit 0 + ;; + # Allow docs/ directory + */docs/*.md|*/docs/**/*.md) + echo "$input" + exit 0 + ;; + # Block other .md files + *.md) + echo '{"decision": "block", "message": "Use README.md or docs/ for documentation. Random .md files clutter the repo."}' + exit 0 + ;; + esac +fi + +echo "$input" diff --git a/plugin/scripts/capture-context.sh b/plugin/scripts/capture-context.sh new file mode 100755 index 00000000..288e9be0 --- /dev/null +++ b/plugin/scripts/capture-context.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Capture context facts from tool output or conversation +# Called by PostToolUse hooks to extract actionable items +# +# Stores in ~/.claude/sessions/context.json as: +# [{"fact": "...", "source": "core go qa", "ts": 1234567890}, ...] + +CONTEXT_FILE="${HOME}/.claude/sessions/context.json" +TIMESTAMP=$(date '+%s') +THREE_HOURS=10800 + +mkdir -p "${HOME}/.claude/sessions" + +# Initialize if missing or stale +if [[ -f "$CONTEXT_FILE" ]]; then + FIRST_TS=$(jq -r '.[0].ts // 0' "$CONTEXT_FILE" 2>/dev/null) + NOW=$(date '+%s') + AGE=$((NOW - FIRST_TS)) + if [[ $AGE -gt $THREE_HOURS ]]; then + echo "[]" > "$CONTEXT_FILE" + fi +else + echo "[]" > "$CONTEXT_FILE" +fi + +# Read input (fact and source passed as args or stdin) +FACT="${1:-}" +SOURCE="${2:-manual}" + +if [[ -z "$FACT" ]]; then + # Try reading from stdin + read -r FACT +fi + +if [[ -n "$FACT" ]]; then + # Append to context (keep last 20 items) + jq --arg fact "$FACT" --arg source "$SOURCE" --argjson ts "$TIMESTAMP" \ + '. + [{"fact": $fact, "source": $source, "ts": $ts}] | .[-20:]' \ + "$CONTEXT_FILE" > "${CONTEXT_FILE}.tmp" && mv "${CONTEXT_FILE}.tmp" "$CONTEXT_FILE" + + echo "[Context] Saved: $FACT" >&2 +fi + +exit 0 diff --git a/plugin/scripts/check-debug.sh b/plugin/scripts/check-debug.sh new file mode 100755 index 00000000..079cc0e9 --- /dev/null +++ b/plugin/scripts/check-debug.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Warn about debug statements left in code after edits + +read -r input +FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty') + +if [[ -n "$FILE_PATH" && -f "$FILE_PATH" ]]; then + case "$FILE_PATH" in + *.go) + # Check for fmt.Println, log.Println debug statements + if grep -n "fmt\.Println\|log\.Println" "$FILE_PATH" 2>/dev/null | head -3 | grep -q .; then + echo "[Hook] WARNING: Debug prints found in $FILE_PATH" >&2 + grep -n "fmt\.Println\|log\.Println" "$FILE_PATH" 2>/dev/null | head -3 >&2 + fi + ;; + *.php) + # Check for dd(), dump(), var_dump(), print_r() + if grep -n "dd(\|dump(\|var_dump(\|print_r(" "$FILE_PATH" 2>/dev/null | head -3 | grep -q .; then + echo "[Hook] WARNING: Debug statements found in $FILE_PATH" >&2 + grep -n "dd(\|dump(\|var_dump(\|print_r(" "$FILE_PATH" 2>/dev/null | head -3 >&2 + fi + ;; + esac +fi + +# Pass through the input +echo "$input" diff --git a/plugin/scripts/extract-actionables.sh b/plugin/scripts/extract-actionables.sh new file mode 100755 index 00000000..86a2bbbd --- /dev/null +++ b/plugin/scripts/extract-actionables.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Extract actionable items from core CLI output +# Called PostToolUse on Bash commands that run core + +read -r input +COMMAND=$(echo "$input" | jq -r '.tool_input.command // empty') +OUTPUT=$(echo "$input" | jq -r '.tool_output.output // empty') + +CONTEXT_SCRIPT="$(dirname "$0")/capture-context.sh" + +# Extract actionables from specific core commands +case "$COMMAND" in + "core go qa"*|"core go test"*|"core go lint"*) + # Extract error/warning lines + echo "$OUTPUT" | grep -E "^(ERROR|WARN|FAIL|---)" | head -5 | while read -r line; do + "$CONTEXT_SCRIPT" "$line" "core go" + done + ;; + "core php test"*|"core php analyse"*) + # Extract PHP errors + echo "$OUTPUT" | grep -E "^(FAIL|Error|×)" | head -5 | while read -r line; do + "$CONTEXT_SCRIPT" "$line" "core php" + done + ;; + "core build"*) + # Extract build errors + echo "$OUTPUT" | grep -E "^(error|cannot|undefined)" | head -5 | while read -r line; do + "$CONTEXT_SCRIPT" "$line" "core build" + done + ;; +esac + +# Pass through +echo "$input" diff --git a/plugin/scripts/go-format.sh b/plugin/scripts/go-format.sh new file mode 100755 index 00000000..8f9d3227 --- /dev/null +++ b/plugin/scripts/go-format.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Auto-format Go files after edits using core go fmt + +read -r input +FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty') + +if [[ -n "$FILE_PATH" && -f "$FILE_PATH" ]]; then + # Run gofmt/goimports on the file silently + if command -v core &> /dev/null; then + core go fmt --fix "$FILE_PATH" 2>/dev/null || true + elif command -v goimports &> /dev/null; then + goimports -w "$FILE_PATH" 2>/dev/null || true + elif command -v gofmt &> /dev/null; then + gofmt -w "$FILE_PATH" 2>/dev/null || true + fi +fi + +# Pass through the input +echo "$input" diff --git a/plugin/scripts/php-format.sh b/plugin/scripts/php-format.sh new file mode 100755 index 00000000..e0e7ec11 --- /dev/null +++ b/plugin/scripts/php-format.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Auto-format PHP files after edits using core php fmt + +read -r input +FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty') + +if [[ -n "$FILE_PATH" && -f "$FILE_PATH" ]]; then + # Run Pint on the file silently + if command -v core &> /dev/null; then + core php fmt --fix "$FILE_PATH" 2>/dev/null || true + elif [[ -f "./vendor/bin/pint" ]]; then + ./vendor/bin/pint "$FILE_PATH" 2>/dev/null || true + fi +fi + +# Pass through the input +echo "$input" diff --git a/plugin/scripts/post-commit-check.sh b/plugin/scripts/post-commit-check.sh new file mode 100755 index 00000000..42418b65 --- /dev/null +++ b/plugin/scripts/post-commit-check.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Post-commit hook: Check for uncommitted work that might get lost +# +# After committing task-specific files, check if there's other work +# in the repo that should be committed or stashed + +read -r input +COMMAND=$(echo "$input" | jq -r '.tool_input.command // empty') + +# Only run after git commit +if ! echo "$COMMAND" | grep -qE '^git commit'; then + echo "$input" + exit 0 +fi + +# Check for remaining uncommitted changes +UNSTAGED=$(git diff --name-only 2>/dev/null | wc -l | tr -d ' ') +STAGED=$(git diff --cached --name-only 2>/dev/null | wc -l | tr -d ' ') +UNTRACKED=$(git ls-files --others --exclude-standard 2>/dev/null | wc -l | tr -d ' ') + +TOTAL=$((UNSTAGED + STAGED + UNTRACKED)) + +if [[ $TOTAL -gt 0 ]]; then + echo "" >&2 + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2 + echo "[PostCommit] WARNING: Uncommitted work remains" >&2 + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2 + + if [[ $UNSTAGED -gt 0 ]]; then + echo " Modified (unstaged): $UNSTAGED files" >&2 + git diff --name-only 2>/dev/null | head -5 | sed 's/^/ /' >&2 + [[ $UNSTAGED -gt 5 ]] && echo " ... and $((UNSTAGED - 5)) more" >&2 + fi + + if [[ $STAGED -gt 0 ]]; then + echo " Staged (not committed): $STAGED files" >&2 + git diff --cached --name-only 2>/dev/null | head -5 | sed 's/^/ /' >&2 + fi + + if [[ $UNTRACKED -gt 0 ]]; then + echo " Untracked: $UNTRACKED files" >&2 + git ls-files --others --exclude-standard 2>/dev/null | head -5 | sed 's/^/ /' >&2 + [[ $UNTRACKED -gt 5 ]] && echo " ... and $((UNTRACKED - 5)) more" >&2 + fi + + echo "" >&2 + echo "Consider: commit these, stash them, or confirm they're intentionally left" >&2 + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2 +fi + +echo "$input" diff --git a/plugin/scripts/pr-created.sh b/plugin/scripts/pr-created.sh new file mode 100755 index 00000000..82dd975b --- /dev/null +++ b/plugin/scripts/pr-created.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Log PR URL and provide review command after PR creation + +read -r input +COMMAND=$(echo "$input" | jq -r '.tool_input.command // empty') +OUTPUT=$(echo "$input" | jq -r '.tool_output.output // empty') + +if [[ "$COMMAND" == *"gh pr create"* ]]; then + PR_URL=$(echo "$OUTPUT" | grep -oE 'https://github.com/[^/]+/[^/]+/pull/[0-9]+' | head -1) + if [[ -n "$PR_URL" ]]; then + REPO=$(echo "$PR_URL" | sed -E 's|https://github.com/([^/]+/[^/]+)/pull/[0-9]+|\1|') + PR_NUM=$(echo "$PR_URL" | sed -E 's|.*/pull/([0-9]+)|\1|') + echo "[Hook] PR created: $PR_URL" >&2 + echo "[Hook] To review: gh pr review $PR_NUM --repo $REPO" >&2 + fi +fi + +echo "$input" diff --git a/plugin/scripts/pre-compact.sh b/plugin/scripts/pre-compact.sh new file mode 100755 index 00000000..bb9d8419 --- /dev/null +++ b/plugin/scripts/pre-compact.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Pre-compact: Save minimal state for Claude to resume after auto-compact +# +# Captures: +# - Working directory + branch +# - Git status (files touched) +# - Todo state (in_progress items) +# - Context facts (decisions, actionables) + +STATE_FILE="${HOME}/.claude/sessions/scratchpad.md" +CONTEXT_FILE="${HOME}/.claude/sessions/context.json" +TIMESTAMP=$(date '+%s') +CWD=$(pwd) + +mkdir -p "${HOME}/.claude/sessions" + +# Get todo state +TODOS="" +if [[ -f "${HOME}/.claude/todos/current.json" ]]; then + TODOS=$(cat "${HOME}/.claude/todos/current.json" 2>/dev/null | head -50) +fi + +# Get git status +GIT_STATUS="" +BRANCH="" +if git rev-parse --git-dir > /dev/null 2>&1; then + GIT_STATUS=$(git status --short 2>/dev/null | head -15) + BRANCH=$(git branch --show-current 2>/dev/null) +fi + +# Get context facts +CONTEXT="" +if [[ -f "$CONTEXT_FILE" ]]; then + CONTEXT=$(jq -r '.[] | "- [\(.source)] \(.fact)"' "$CONTEXT_FILE" 2>/dev/null | tail -10) +fi + +cat > "$STATE_FILE" << EOF +--- +timestamp: ${TIMESTAMP} +cwd: ${CWD} +branch: ${BRANCH:-none} +--- + +# Resume After Compact + +You were mid-task. Do NOT assume work is complete. + +## Project +\`${CWD}\` on \`${BRANCH:-no branch}\` + +## Files Changed +\`\`\` +${GIT_STATUS:-none} +\`\`\` + +## Todos (in_progress = NOT done) +\`\`\`json +${TODOS:-check /todos} +\`\`\` + +## Context (decisions & actionables) +${CONTEXT:-none captured} + +## Next +Continue the in_progress todo. +EOF + +echo "[PreCompact] Snapshot saved" >&2 +exit 0 diff --git a/plugin/scripts/session-start.sh b/plugin/scripts/session-start.sh new file mode 100755 index 00000000..3a44d972 --- /dev/null +++ b/plugin/scripts/session-start.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Session start: Read scratchpad if recent, otherwise start fresh +# 3 hour window - if older, you've moved on mentally + +STATE_FILE="${HOME}/.claude/sessions/scratchpad.md" +THREE_HOURS=10800 # seconds + +if [[ -f "$STATE_FILE" ]]; then + # Get timestamp from file + FILE_TS=$(grep -E '^timestamp:' "$STATE_FILE" 2>/dev/null | cut -d' ' -f2) + NOW=$(date '+%s') + + if [[ -n "$FILE_TS" ]]; then + AGE=$((NOW - FILE_TS)) + + if [[ $AGE -lt $THREE_HOURS ]]; then + # Recent - read it back + echo "[SessionStart] Found recent scratchpad ($(($AGE / 60)) min ago)" >&2 + echo "[SessionStart] Reading previous state..." >&2 + echo "" >&2 + cat "$STATE_FILE" >&2 + echo "" >&2 + else + # Stale - delete and start fresh + rm -f "$STATE_FILE" + echo "[SessionStart] Previous session >3h old - starting fresh" >&2 + fi + else + # No timestamp, delete it + rm -f "$STATE_FILE" + fi +fi + +exit 0 diff --git a/plugin/scripts/suggest-compact.sh b/plugin/scripts/suggest-compact.sh new file mode 100755 index 00000000..e958c501 --- /dev/null +++ b/plugin/scripts/suggest-compact.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Suggest /compact at logical intervals to manage context window +# Tracks tool calls per session, suggests compaction every 50 calls + +SESSION_ID="${CLAUDE_SESSION_ID:-$$}" +COUNTER_FILE="/tmp/claude-tool-count-${SESSION_ID}" +THRESHOLD="${COMPACT_THRESHOLD:-50}" + +# Read or initialize counter +if [[ -f "$COUNTER_FILE" ]]; then + COUNT=$(($(cat "$COUNTER_FILE") + 1)) +else + COUNT=1 +fi + +echo "$COUNT" > "$COUNTER_FILE" + +# Suggest compact at threshold +if [[ $COUNT -eq $THRESHOLD ]]; then + echo "[Compact] ${THRESHOLD} tool calls - consider /compact if transitioning phases" >&2 +fi + +# Suggest at intervals after threshold +if [[ $COUNT -gt $THRESHOLD ]] && [[ $((COUNT % 25)) -eq 0 ]]; then + echo "[Compact] ${COUNT} tool calls - good checkpoint for /compact" >&2 +fi + +exit 0 diff --git a/plugin/skills/core.md b/plugin/skills/core.md new file mode 100644 index 00000000..966d7e9e --- /dev/null +++ b/plugin/skills/core.md @@ -0,0 +1,60 @@ +--- +name: core +description: Use when working in host-uk repositories, running tests, building, releasing, or managing multi-repo workflows. Provides the core CLI command reference. +--- + +# Core CLI + +The `core` command provides a unified interface for Go/PHP development and multi-repo management. + +**Rule:** Always prefer `core ` over raw commands. + +## Quick Reference + +| Task | Command | +|------|---------| +| Go tests | `core go test` | +| Go coverage | `core go cov` | +| Go format | `core go fmt --fix` | +| Go lint | `core go lint` | +| PHP dev server | `core php dev` | +| PHP tests | `core php test` | +| PHP format | `core php fmt --fix` | +| Build | `core build` | +| Preview release | `core ci` | +| Publish | `core ci --were-go-for-launch` | +| Multi-repo status | `core dev health` | +| Commit dirty repos | `core dev commit` | +| Push repos | `core dev push` | + +## Decision Tree + +``` +Go project? + tests: core go test + format: core go fmt --fix + build: core build + +PHP project? + dev: core php dev + tests: core php test + format: core php fmt --fix + deploy: core php deploy + +Multiple repos? + status: core dev health + commit: core dev commit + push: core dev push +``` + +## Common Mistakes + +| Wrong | Right | +|-------|-------| +| `go test ./...` | `core go test` | +| `go build` | `core build` | +| `php artisan serve` | `core php dev` | +| `./vendor/bin/pest` | `core php test` | +| `git status` per repo | `core dev health` | + +Run `core --help` or `core --help` for full options. diff --git a/plugin/skills/go.md b/plugin/skills/go.md new file mode 100644 index 00000000..22a2227f --- /dev/null +++ b/plugin/skills/go.md @@ -0,0 +1,107 @@ +--- +name: core-go +description: Use when creating Go packages or extending the core CLI. +--- + +# Go Framework Patterns + +Core CLI uses `pkg/` for reusable packages. Use `core go` commands. + +## Package Structure + +``` +core/ +├── main.go # CLI entry point +├── pkg/ +│ ├── cli/ # CLI framework, output, errors +│ ├── {domain}/ # Domain package +│ │ ├── cmd_{name}.go # Cobra command definitions +│ │ ├── service.go # Business logic +│ │ └── *_test.go # Tests +│ └── ... +└── internal/ # Private packages +``` + +## Adding a CLI Command + +1. Create `pkg/{domain}/cmd_{name}.go`: + +```go +package domain + +import ( + "github.com/host-uk/core/pkg/cli" + "github.com/spf13/cobra" +) + +func NewNameCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "name", + Short: cli.T("domain.name.short"), + RunE: func(cmd *cobra.Command, args []string) error { + // Implementation + cli.Success("Done") + return nil + }, + } + return cmd +} +``` + +2. Register in parent command. + +## CLI Output Helpers + +```go +import "github.com/host-uk/core/pkg/cli" + +cli.Success("Operation completed") // Green check +cli.Warning("Something to note") // Yellow warning +cli.Error("Something failed") // Red error +cli.Info("Informational message") // Blue info +cli.Fatal(err) // Print error and exit 1 + +// Structured output +cli.Table(headers, rows) +cli.JSON(data) +``` + +## i18n Pattern + +```go +// Use cli.T() for translatable strings +cli.T("domain.action.success") +cli.T("domain.action.error", "details", value) + +// Define in pkg/i18n/locales/en.yaml: +domain: + action: + success: "Operation completed successfully" + error: "Failed: {{.details}}" +``` + +## Test Naming + +```go +func TestFeature_Good(t *testing.T) { /* happy path */ } +func TestFeature_Bad(t *testing.T) { /* expected errors */ } +func TestFeature_Ugly(t *testing.T) { /* panics, edge cases */ } +``` + +## Commands + +| Task | Command | +|------|---------| +| Run tests | `core go test` | +| Coverage | `core go cov` | +| Format | `core go fmt --fix` | +| Lint | `core go lint` | +| Build | `core build` | +| Install | `core go install` | + +## Rules + +- `CGO_ENABLED=0` for all builds +- UK English in user-facing strings +- All errors via `cli.E("context", "message", err)` +- Table-driven tests preferred diff --git a/plugin/skills/php.md b/plugin/skills/php.md new file mode 100644 index 00000000..2133a20b --- /dev/null +++ b/plugin/skills/php.md @@ -0,0 +1,120 @@ +--- +name: core-php +description: Use when creating PHP modules, services, or actions in core-* packages. +--- + +# PHP Framework Patterns + +Host UK PHP modules follow strict conventions. Use `core php` commands. + +## Module Structure + +``` +core-{name}/ +├── src/ +│ ├── Core/ # Namespace: Core\{Name} +│ │ ├── Boot.php # Module bootstrap (listens to lifecycle events) +│ │ ├── Actions/ # Single-purpose business logic +│ │ └── Models/ # Eloquent models +│ └── Mod/ # Namespace: Core\Mod\{Name} (optional extensions) +├── resources/views/ # Blade templates +├── routes/ # Route definitions +├── database/migrations/ # Migrations +├── tests/ # Pest tests +└── composer.json +``` + +## Boot Class Pattern + +```php + 'onWebRoutes', + AdminPanelBooting::class => ['onAdmin', 10], // With priority + ]; + + public function onWebRoutes(WebRoutesRegistering $event): void + { + $event->router->middleware('web')->group(__DIR__ . '/../routes/web.php'); + } + + public function onAdmin(AdminPanelBooting $event): void + { + $event->panel->resources([...]); + } +} +``` + +## Action Pattern + +```php + $user->id, + ...$data, + ]); + } +} + +// Usage: CreateThing::run($user, $validated); +``` + +## Multi-Tenant Models + +```php +