diff --git a/docs/plans/2026-01-30-cli-commands-design.md b/docs/plans/2026-01-30-cli-commands-design.md deleted file mode 100644 index 73212af..0000000 --- a/docs/plans/2026-01-30-cli-commands-design.md +++ /dev/null @@ -1,185 +0,0 @@ -# CLI Commands Registration Design - -## Overview - -Move CLI commands from `cmd/` into self-registering packages in `pkg/`. This enables build variants with reduced attack surface - only compiled code exists in the binary. - -## Pattern - -Same pattern as `i18n.RegisterLocales()`: -- Packages register themselves during `init()` -- Registration is stored until `cli.Init()` runs -- Build tags control which packages are imported - -## Registration API - -```go -// pkg/cli/commands.go - -type CommandRegistration func(root *cobra.Command) - -var ( - registeredCommands []CommandRegistration - registeredCommandsMu sync.Mutex -) - -// RegisterCommands registers a function that adds commands to the CLI. -func RegisterCommands(fn CommandRegistration) { - registeredCommandsMu.Lock() - defer registeredCommandsMu.Unlock() - registeredCommands = append(registeredCommands, fn) -} - -func attachRegisteredCommands(root *cobra.Command) { - registeredCommandsMu.Lock() - defer registeredCommandsMu.Unlock() - for _, fn := range registeredCommands { - fn(root) - } -} -``` - -## Integration with Core.App - -The CLI stores `rootCmd` in `core.App`, unifying GUI and CLI under the same pattern: - -```go -// pkg/cli/runtime.go - -func Init(opts Options) error { - once.Do(func() { - rootCmd := &cobra.Command{ - Use: opts.AppName, - Version: opts.Version, - } - - attachRegisteredCommands(rootCmd) - - c, err := framework.New( - framework.WithApp(rootCmd), - // ... services ... - ) - // ... - }) - return initErr -} - -func RootCmd() *cobra.Command { - return framework.App().(*cobra.Command) -} - -func Execute() error { - return RootCmd().Execute() -} -``` - -## Package Structure - -Commands move from `cmd/` to `pkg/` with a `cmd.go` file: - -``` -pkg/ -├── php/ -│ ├── i18n.go # registers locales -│ ├── cmd.go # registers commands -│ ├── locales/ -│ └── ... -├── dev/ -│ ├── cmd.go # registers commands -│ └── ... -├── cli/ -│ ├── commands.go # RegisterCommands API -│ ├── runtime.go # Init, Execute -│ └── ... -``` - -Each `cmd.go`: - -```go -// pkg/php/cmd.go -package php - -import "github.com/host-uk/core/pkg/cli" - -func init() { - cli.RegisterCommands(AddCommands) -} - -func AddCommands(root *cobra.Command) { - // ... existing command setup ... -} -``` - -## Build Variants - -Import files with build tags in `cmd/variants/`: - -``` -cmd/ -├── main.go -└── variants/ - ├── full.go # default: all packages - ├── ci.go # CI/release only - ├── php.go # PHP tooling only - └── minimal.go # core only -``` - -```go -// cmd/variants/full.go -//go:build !ci && !php && !minimal - -package variants - -import ( - _ "github.com/host-uk/core/pkg/ai" - _ "github.com/host-uk/core/pkg/build" - _ "github.com/host-uk/core/pkg/ci" - _ "github.com/host-uk/core/pkg/dev" - _ "github.com/host-uk/core/pkg/docs" - _ "github.com/host-uk/core/pkg/doctor" - _ "github.com/host-uk/core/pkg/go" - _ "github.com/host-uk/core/pkg/php" - _ "github.com/host-uk/core/pkg/pkg" - _ "github.com/host-uk/core/pkg/sdk" - _ "github.com/host-uk/core/pkg/setup" - _ "github.com/host-uk/core/pkg/test" - _ "github.com/host-uk/core/pkg/vm" -) -``` - -```go -// cmd/variants/ci.go -//go:build ci - -package variants - -import ( - _ "github.com/host-uk/core/pkg/build" - _ "github.com/host-uk/core/pkg/ci" - _ "github.com/host-uk/core/pkg/doctor" - _ "github.com/host-uk/core/pkg/sdk" -) -``` - -## Build Commands - -- `go build` → full variant (default) -- `go build -tags ci` → CI variant -- `go build -tags php` → PHP-only variant - -## Benefits - -1. **Smaller attack surface** - only compiled code exists in binary -2. **Self-registering packages** - same pattern as `i18n.RegisterLocales()` -3. **Uses existing `core.App`** - no new framework concepts -4. **Simple build variants** - just add `-tags` flag -5. **Defence in depth** - no code = no vulnerabilities - -## Migration Steps - -1. Add `RegisterCommands()` to `pkg/cli/commands.go` -2. Update `pkg/cli/runtime.go` to use `core.App` for rootCmd -3. Move each `cmd/*` package to `pkg/*/cmd.go` -4. Create `cmd/variants/` with build tag files -5. Simplify `cmd/main.go` to minimal entry point -6. Remove old `cmd/core_dev.go` and `cmd/core_ci.go` diff --git a/docs/plans/2026-01-30-core-ipc-design.md b/docs/plans/2026-01-30-core-ipc-design.md deleted file mode 100644 index ec3c9c3..0000000 --- a/docs/plans/2026-01-30-core-ipc-design.md +++ /dev/null @@ -1,373 +0,0 @@ -# Core Framework IPC Design - -> Design document for refactoring CLI commands to use the Core framework's IPC system. - -## Overview - -The Core framework provides a dependency injection and inter-process communication (IPC) system originally designed for orchestrating services. This design extends the framework with request/response patterns and applies it to CLI commands. - -Commands build "worker bundles" - sandboxed Core instances with specific services. The bundle configuration acts as a permissions layer: if a service isn't registered, that capability isn't available. - -## Dispatch Patterns - -Four patterns for service communication: - -| Method | Behaviour | Returns | Use Case | -|--------|-----------|---------|----------| -| `ACTION` | Broadcast to all handlers | `error` | Events, notifications | -| `QUERY` | First responder wins | `(any, bool, error)` | Get data | -| `QUERYALL` | Broadcast, collect all | `([]any, error)` | Aggregate from multiple services | -| `PERFORM` | First responder executes | `(any, bool, error)` | Execute a task with side effects | - -### ACTION (existing) - -Fire-and-forget broadcast. All registered handlers receive the message. Errors are aggregated. - -```go -c.ACTION(ActionServiceStartup{}) -``` - -### QUERY (new) - -Request data from services. Stops at first handler that returns `handled=true`. - -```go -result, handled, err := c.QUERY(git.QueryStatus{Paths: paths}) -if !handled { - // No service registered to handle this query -} -statuses := result.([]git.RepoStatus) -``` - -### QUERYALL (new) - -Broadcast query to all handlers, collect all responses. Useful for aggregating results from multiple services (e.g., multiple QA/lint tools). - -```go -results, err := c.QUERYALL(qa.QueryLint{Paths: paths}) -for _, r := range results { - lint := r.(qa.LintResult) - fmt.Printf("%s found %d issues\n", lint.Tool, len(lint.Issues)) -} -``` - -### PERFORM (new) - -Execute a task with side effects. Stops at first handler that returns `handled=true`. - -```go -result, handled, err := c.PERFORM(agentic.TaskCommit{ - Path: repo.Path, - Name: repo.Name, -}) -if !handled { - // Agentic service not in bundle - commits not available -} -``` - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ cmd/dev/dev_work.go │ -│ - Builds worker bundle │ -│ - Triggers PERFORM(TaskWork{}) │ -└─────────────────────┬───────────────────────────────────────┘ - │ -┌─────────────────────▼───────────────────────────────────────┐ -│ cmd/dev/bundles.go │ -│ - NewWorkBundle() - git + agentic + dev │ -│ - NewStatusBundle() - git + dev only │ -│ - Bundle config = permissions │ -└─────────────────────┬───────────────────────────────────────┘ - │ -┌─────────────────────▼───────────────────────────────────────┐ -│ pkg/dev/service.go │ -│ - Orchestrates workflow │ -│ - QUERY(git.QueryStatus{}) │ -│ - PERFORM(agentic.TaskCommit{}) │ -│ - PERFORM(git.TaskPush{}) │ -└─────────────────────┬───────────────────────────────────────┘ - │ - ┌─────────────┴─────────────┐ - ▼ ▼ -┌───────────────────┐ ┌───────────────────┐ -│ pkg/git/service │ │ pkg/agentic/svc │ -│ │ │ │ -│ Queries: │ │ Tasks: │ -│ - QueryStatus │ │ - TaskCommit │ -│ - QueryDirtyRepos │ │ - TaskPrompt │ -│ - QueryAheadRepos │ │ │ -│ │ │ │ -│ Tasks: │ │ │ -│ - TaskPush │ │ │ -│ - TaskPull │ │ │ -└───────────────────┘ └───────────────────┘ -``` - -## Permissions Model - -Permissions are implicit through bundle configuration: - -```go -// Full capabilities - can commit and push -func NewWorkBundle(opts WorkBundleOptions) (*framework.Runtime, error) { - return framework.NewWithFactories(nil, map[string]framework.ServiceFactory{ - "dev": func() (any, error) { return dev.NewService(opts.Dev)(nil) }, - "git": func() (any, error) { return git.NewService(opts.Git)(nil) }, - "agentic": func() (any, error) { return agentic.NewService(opts.Agentic)(nil) }, - }) -} - -// Read-only - status queries only, no commits -func NewStatusBundle(opts StatusBundleOptions) (*framework.Runtime, error) { - return framework.NewWithFactories(nil, map[string]framework.ServiceFactory{ - "dev": func() (any, error) { return dev.NewService(opts.Dev)(nil) }, - "git": func() (any, error) { return git.NewService(opts.Git)(nil) }, - // No agentic service - TaskCommit will be unhandled - }) -} -``` - -Service options provide fine-grained control: - -```go -agentic.NewService(agentic.ServiceOptions{ - AllowEdit: false, // Claude can only use read-only tools -}) - -agentic.NewService(agentic.ServiceOptions{ - AllowEdit: true, // Claude can use Write/Edit tools -}) -``` - -**Key principle**: Code never checks permissions explicitly. It dispatches actions and either they're handled or they're not. The bundle configuration is the single source of truth for what's allowed. - -## Framework Changes - -### New Types (interfaces.go) - -```go -type Query interface{} -type Task interface{} - -type QueryHandler func(*Core, Query) (any, bool, error) -type TaskHandler func(*Core, Task) (any, bool, error) -``` - -### Core Struct Additions (interfaces.go) - -```go -type Core struct { - // ... existing fields - - queryMu sync.RWMutex - queryHandlers []QueryHandler - - taskMu sync.RWMutex - taskHandlers []TaskHandler -} -``` - -### New Methods (core.go) - -```go -// QUERY - first responder wins -func (c *Core) QUERY(q Query) (any, bool, error) - -// QUERYALL - broadcast, collect all responses -func (c *Core) QUERYALL(q Query) ([]any, error) - -// PERFORM - first responder executes -func (c *Core) PERFORM(t Task) (any, bool, error) - -// Registration -func (c *Core) RegisterQuery(h QueryHandler) -func (c *Core) RegisterTask(h TaskHandler) -``` - -### Re-exports (framework.go) - -```go -type Query = core.Query -type Task = core.Task -type QueryHandler = core.QueryHandler -type TaskHandler = core.TaskHandler -``` - -## Service Implementation Pattern - -Services register handlers during startup: - -```go -func (s *Service) OnStartup(ctx context.Context) error { - s.Core().RegisterAction(s.handleAction) - s.Core().RegisterQuery(s.handleQuery) - s.Core().RegisterTask(s.handleTask) - return nil -} - -func (s *Service) handleQuery(c *framework.Core, q framework.Query) (any, bool, error) { - switch m := q.(type) { - case QueryStatus: - result := s.getStatus(m.Paths, m.Names) - return result, true, nil - case QueryDirtyRepos: - return s.DirtyRepos(), true, nil - } - return nil, false, nil // Not handled -} - -func (s *Service) handleTask(c *framework.Core, t framework.Task) (any, bool, error) { - switch m := t.(type) { - case TaskPush: - err := s.push(m.Path) - return nil, true, err - case TaskPull: - err := s.pull(m.Path) - return nil, true, err - } - return nil, false, nil // Not handled -} -``` - -## Git Service Queries & Tasks - -```go -// pkg/git/queries.go -type QueryStatus struct { - Paths []string - Names map[string]string -} - -type QueryDirtyRepos struct{} -type QueryAheadRepos struct{} - -// pkg/git/tasks.go -type TaskPush struct { - Path string - Name string -} - -type TaskPull struct { - Path string - Name string -} - -type TaskPushMultiple struct { - Paths []string - Names map[string]string -} -``` - -## Agentic Service Tasks - -```go -// pkg/agentic/tasks.go -type TaskCommit struct { - Path string - Name string - CanEdit bool -} - -type TaskPrompt struct { - Prompt string - WorkDir string - AllowedTools []string -} -``` - -## Dev Workflow Service - -```go -// pkg/dev/tasks.go -type TaskWork struct { - RegistryPath string - StatusOnly bool - AutoCommit bool -} - -type TaskCommitAll struct { - RegistryPath string -} - -type TaskPushAll struct { - RegistryPath string - Force bool -} -``` - -## Command Simplification - -Before (dev_work.go - 327 lines of orchestration): - -```go -func runWork(registryPath string, statusOnly, autoCommit bool) error { - // Load registry - // Get git status - // Display table - // Loop dirty repos, shell out to claude - // Re-check status - // Confirm push - // Push repos - // Handle diverged branches - // ... -} -``` - -After (dev_work.go - minimal): - -```go -func runWork(registryPath string, statusOnly, autoCommit bool) error { - bundle, err := NewWorkBundle(WorkBundleOptions{ - RegistryPath: registryPath, - }) - if err != nil { - return err - } - - ctx := context.Background() - bundle.Core.ServiceStartup(ctx, nil) - defer bundle.Core.ServiceShutdown(ctx) - - _, _, err = bundle.Core.PERFORM(dev.TaskWork{ - StatusOnly: statusOnly, - AutoCommit: autoCommit, - }) - return err -} -``` - -All orchestration logic moves to `pkg/dev/service.go` where it can be tested independently and reused. - -## Implementation Tasks - -1. **Framework Core** - Add Query, Task types and QUERY/QUERYALL/PERFORM methods -2. **Framework Re-exports** - Update framework.go with new types -3. **Git Service** - Add query and task handlers -4. **Agentic Service** - Add task handlers -5. **Dev Service** - Create workflow orchestration service -6. **Bundles** - Create bundle factories in cmd/dev/ -7. **Commands** - Simplify cmd/dev/*.go to use bundles - -## Future: CLI-Wide Runtime - -Phase 2 will add a CLI-wide Core instance that: - -- Handles signals (SIGINT, SIGTERM) -- Manages UI state -- Spawns worker bundles as "interactable elements" -- Provides cross-bundle communication - -Worker bundles become sandboxed children of the CLI runtime, with the runtime controlling what capabilities each bundle receives. - -## Testing - -Each layer is independently testable: - -- **Framework**: Unit tests for QUERY/QUERYALL/PERFORM dispatch -- **Services**: Unit tests with mock Core instances -- **Bundles**: Integration tests with real services -- **Commands**: E2E tests via CLI invocation - -The permission model is testable by creating bundles with/without specific services and verifying behaviour. diff --git a/docs/plans/2026-01-30-i18n-v2-design.md b/docs/plans/2026-01-30-i18n-v2-design.md deleted file mode 100644 index c5a4cb8..0000000 --- a/docs/plans/2026-01-30-i18n-v2-design.md +++ /dev/null @@ -1,134 +0,0 @@ -# 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-30-semantic-i18n-design.md b/docs/plans/2026-01-30-semantic-i18n-design.md deleted file mode 100644 index ca28e9d..0000000 --- a/docs/plans/2026-01-30-semantic-i18n-design.md +++ /dev/null @@ -1,486 +0,0 @@ -# Semantic i18n System Design - -## Overview - -Extend the i18n system beyond simple key-value translation to support **semantic intents** that encode meaning, enabling: - -- Composite translations from reusable fragments -- Grammatical awareness (gender, plurality, formality) -- CLI prompt integration with localized options -- Reduced calling code complexity - -## Goals - -1. **Simple cases stay simple** - `_("key")` works as expected -2. **Complex cases become declarative** - Intent drives output, not caller logic -3. **Translators have power** - Grammar rules live in translations, not code -4. **CLI integration** - Questions, confirmations, choices are first-class - -## API Design - -### Function Reference (Stable API) - -These function names are **permanent** - choose carefully, they cannot change. - -| Function | Alias | Purpose | -|----------|-------|---------| -| `_()` | - | Simple gettext-style lookup | -| `T()` | `C()` | Compose - semantic intent resolution | -| `S()` | `Subject()` | Create typed subject with metadata | - -### Simple Translation: `_()` - -Standard gettext-style lookup. No magic, just key → value. - -```go -i18n._("cli.success") // "Success" -i18n._("common.label.error") // "Error:" -i18n._("common.error.failed", map[string]any{"Action": "load"}) // "Failed to load" -``` - -### Compose: `T()` / `C()` - -Semantic intent resolution. Takes an intent key from `core.*` namespace and returns a `Composed` result with multiple output forms. - -```go -// Full form -result := i18n.T("core.delete", i18n.S("file", path)) -result := i18n.C("core.delete", i18n.S("file", path)) // Alias - -// Result contains all forms -result.Question // "Delete /path/to/file.txt?" -result.Confirm // "Really delete /path/to/file.txt?" -result.Success // "File deleted" -result.Failure // "Failed to delete file" -result.Meta // IntentMeta{Dangerous: true, Default: "no", ...} -``` - -### Subject: `S()` / `Subject()` - -Creates a typed subject with optional metadata for grammar rules. - -```go -// Simple -i18n.S("file", "/path/to/file.txt") - -// With count (plurality) -i18n.S("commit", commits).Count(len(commits)) - -// With gender (for gendered languages) -i18n.S("user", name).Gender("female") - -// Chained -i18n.S("file", path).Count(3).In("/project") -``` - -### Type Signatures - -```go -// Simple lookup -func _(key string, args ...any) string - -// Compose (T and C are aliases) -func T(intent string, subject *Subject) *Composed -func C(intent string, subject *Subject) *Composed - -// Subject builder -func S(noun string, value any) *Subject -func Subject(noun string, value any) *Subject - -// Composed result -type Composed struct { - Question string - Confirm string - Success string - Failure string - Meta IntentMeta -} - -// Subject with metadata -type Subject struct { - Noun string - Value any - count int - gender string - // ... other metadata -} - -func (s *Subject) Count(n int) *Subject -func (s *Subject) Gender(g string) *Subject -func (s *Subject) In(location string) *Subject - -// Intent metadata -type IntentMeta struct { - Type string // "action", "question", "info" - Verb string // Reference to common.verb.* - Dangerous bool // Requires confirmation - Default string // "yes" or "no" - Supports []string // Extra options like "all", "skip" -} -``` - -## CLI Integration - -The CLI package uses `T()` internally for prompts: - -```go -// Confirm uses T() internally -confirmed := cli.Confirm("core.delete", i18n.S("file", path)) -// Internally: result := i18n.T("core.delete", subject) -// Displays: result.Question + localized [y/N] -// Returns: bool - -// Question with options -choice := cli.Question("core.save", i18n.S("changes", 3).Count(3), cli.Options{ - Default: "yes", - Extra: []string{"all"}, -}) -// Displays: "Save 3 changes? [a/y/N]" -// Returns: "yes" | "no" | "all" - -// Choice from list -selected := cli.Choose("core.select.branch", branches) -// Displays localized prompt with arrow selection -``` - -### cli.Confirm() - -```go -func Confirm(intent string, subject *i18n.Subject, opts ...ConfirmOption) bool - -// Options -cli.DefaultYes() // Default to yes instead of no -cli.DefaultNo() // Explicit default no -cli.Required() // No default, must choose -cli.Timeout(30*time.Second) // Auto-select default after timeout -``` - -### cli.Question() - -```go -func Question(intent string, subject *i18n.Subject, opts ...QuestionOption) string - -// Options -cli.Extra("all", "skip") // Extra options beyond y/n -cli.Default("yes") // Which option is default -cli.Validate(func(s string) bool) // Custom validation -``` - -### cli.Choose() - -```go -func Choose[T any](intent string, items []T, opts ...ChooseOption) T - -// Options -cli.Display(func(T) string) // How to display each item -cli.Filter() // Enable fuzzy filtering -cli.Multi() // Allow multiple selection -``` - -## Reserved Namespaces - -### `common.*` - Reusable Fragments - -Atomic translation units that can be composed: - -```json -{ - "common": { - "verb": { - "edit": "edit", - "delete": "delete", - "create": "create", - "save": "save", - "update": "update", - "commit": "commit" - }, - "noun": { - "file": { "one": "file", "other": "files" }, - "commit": { "one": "commit", "other": "commits" }, - "change": { "one": "change", "other": "changes" } - }, - "article": { - "the": "the", - "a": { "one": "a", "vowel": "an" } - }, - "prompt": { - "yes": "y", - "no": "n", - "all": "a", - "skip": "s", - "quit": "q" - } - } -} -``` - -### `core.*` - Semantic Intents - -Intents encode meaning and behavior: - -```json -{ - "core": { - "edit": { - "_meta": { - "type": "action", - "verb": "common.verb.edit", - "dangerous": false - }, - "question": "Should I {{.Verb}} {{.Subject}}?", - "confirm": "{{.Verb | title}} {{.Subject}}?", - "success": "{{.Subject | title}} {{.Verb | past}}", - "failure": "Failed to {{.Verb}} {{.Subject}}" - }, - "delete": { - "_meta": { - "type": "action", - "verb": "common.verb.delete", - "dangerous": true, - "default": "no" - }, - "question": "Delete {{.Subject}}? This cannot be undone.", - "confirm": "Really delete {{.Subject}}?", - "success": "{{.Subject | title}} deleted", - "failure": "Failed to delete {{.Subject}}" - }, - "save": { - "_meta": { - "type": "action", - "verb": "common.verb.save", - "supports": ["all", "skip"] - }, - "question": "Save {{.Subject}}?", - "success": "{{.Subject | title}} saved" - }, - "commit": { - "_meta": { - "type": "action", - "verb": "common.verb.commit", - "dangerous": false - }, - "question": "Commit {{.Subject}}?", - "success": "{{.Subject | title}} committed", - "failure": "Failed to commit {{.Subject}}" - } - } -} -``` - -## Template Functions - -Available in translation templates: - -| Function | Description | Example | -|----------|-------------|---------| -| `title` | Title case | `{{.Name \| title}}` → "Hello World" | -| `lower` | Lower case | `{{.Name \| lower}}` → "hello world" | -| `upper` | Upper case | `{{.Name \| upper}}` → "HELLO WORLD" | -| `past` | Past tense verb | `{{.Verb \| past}}` → "edited" | -| `plural` | Pluralize noun | `{{.Noun \| plural .Count}}` → "files" | -| `article` | Add article | `{{.Noun \| article}}` → "a file" | -| `quote` | Wrap in quotes | `{{.Path \| quote}}` → `"/path/to/file"` | - -## Implementation Plan - -### Phase 1: Foundation -1. Define `Composed` and `Subject` types -2. Add `S()` / `Subject()` builder -3. Add `T()` / `C()` with intent resolution -4. Parse `_meta` from JSON -5. Add template functions (title, lower, past, etc.) - -### Phase 2: CLI Integration -1. Implement `cli.Confirm()` using intents -2. Implement `cli.Question()` with options -3. Implement `cli.Choose()` for lists -4. Localize prompt characters [y/N] → [j/N] etc. - -### Phase 3: Grammar Engine -1. Verb conjugation (past tense, etc.) -2. Noun plurality with irregular forms -3. Article selection (a/an, gender) -4. Language-specific rules - -### Phase 4: Extended Languages -1. Gender agreement (French, German, etc.) -2. Formality levels (Japanese, Korean, etc.) -3. Right-to-left support -4. Plural forms beyond one/other (Russian, Arabic, etc.) - -## Example: Full Flow - -```go -// In cmd/dev/dev_commit.go -path := "/Users/dev/project" -files := []string{"main.go", "config.yaml"} - -// Old way (hardcoded English, manual prompt handling) -fmt.Printf("Commit %d files in %s? [y/N] ", len(files), path) -var response string -fmt.Scanln(&response) -if response != "y" && response != "Y" { - return -} - -// New way (semantic, localized, integrated) -if !cli.Confirm("core.commit", i18n.S("file", path).Count(len(files))) { - return -} - -// For German user, displays: -// "2 Dateien in /Users/dev/project committen? [j/N]" -// (note: "j" for "ja" instead of "y" for "yes") -``` - -## JSON Schema - -```json -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "common": { - "description": "Reusable translation fragments", - "type": "object" - }, - "core": { - "description": "Semantic intents with metadata", - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "_meta": { - "type": "object", - "properties": { - "type": { "enum": ["action", "question", "info"] }, - "verb": { "type": "string" }, - "dangerous": { "type": "boolean" }, - "default": { "enum": ["yes", "no"] }, - "supports": { "type": "array", "items": { "type": "string" } } - } - }, - "question": { "type": "string" }, - "confirm": { "type": "string" }, - "success": { "type": "string" }, - "failure": { "type": "string" } - } - } - } - } -} -``` - -## Grammar Fundamentals - -Parts of speech we need to handle: - -| Part | Role | Example | Transforms | -|------|------|---------|------------| -| **Verb** | Action | delete, save, commit | tense (past/present), mood (imperative) | -| **Noun** | Subject/Object | file, commit, user | plurality, gender, case | -| **Article** | Determiner | a/an, the | vowel-awareness, gender agreement | -| **Adjective** | Describes noun | modified, new, deleted | gender/number agreement | -| **Preposition** | Relation | in, from, to | - | - -### Verb Conjugation - -```json -{ - "common": { - "verb": { - "delete": { - "base": "delete", - "past": "deleted", - "gerund": "deleting", - "imperative": "delete" - } - } - } -} -``` - -For most English verbs, derive automatically: -- `past`: base + "ed" (or irregular lookup) -- `gerund`: base + "ing" - -### Noun Handling - -```json -{ - "common": { - "noun": { - "file": { - "one": "file", - "other": "files", - "gender": "neuter" - } - } - } -} -``` - -### Article Selection - -English: a/an based on next word's sound (not letter) -- "a file", "an item", "a user", "an hour" - -Other languages: gender agreement (der/die/das, le/la, etc.) - -## DX Improvements - -### 1. Compile-Time Validation -- `go generate` checks all `T("core.X")` calls have matching JSON keys -- Warns on missing `_meta` fields -- Type-checks template variables - -### 2. IDE Support -- JSON schema for autocomplete in translation files -- Go constants generated from JSON keys: `i18n.CoreDelete` instead of `"core.delete"` - -### 3. Fallback Chain -``` -T("core.delete", subject) - → try core.delete.question - → try core.delete (plain string) - → try common.action.delete - → return "Delete {{.Subject}}?" (hardcoded fallback) -``` - -### 4. Debug Mode -```go -i18n.Debug(true) // Shows: [core.delete] Delete file.txt? -``` - -### 5. Short Subject Syntax -```go -// Instead of: -i18n.T("core.delete", i18n.S("file", path)) - -// Allow: -i18n.T("core.delete", path) // Infers subject type from intent's expected noun -``` - -### 6. Fluent Chaining -```go -i18n.T("core.delete"). - Subject("file", path). - Count(3). - Question() // Returns just the question string -``` - -## Notes for Future Implementation - -- Use `github.com/gertd/go-pluralize` for English plurality -- Consider `github.com/nicksnyder/go-i18n` patterns for CLDR plural rules -- Store compiled templates in sync.Map for caching -- `_meta` parsing happens once at load time, not per-call -- CLI prompt chars from `common.prompt.*` - allows `[j/N]` for German - -## Open Questions - -1. **Verb conjugation library** - Use existing Go library or build custom? -2. **Gender detection** - How to infer gender for subjects in gendered languages? -3. **Fallback behavior** - What happens when intent metadata is missing? -4. **Caching** - Should compiled templates be cached? -5. **Validation** - How to validate intent definitions at build time? diff --git a/docs/plans/2026-01-31-semantic-cli-output.md b/docs/plans/2026-01-31-semantic-cli-output.md deleted file mode 100644 index 23f886c..0000000 --- a/docs/plans/2026-01-31-semantic-cli-output.md +++ /dev/null @@ -1,1685 +0,0 @@ -# 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 |