vendor/forge/.gitignore 🔗
@@ -0,0 +1,3 @@
+.DS_Store
+*.swp
+*.swo
Amolith created
vendor/forge/.gitignore | 3
vendor/forge/CLAUDE.md | 59
vendor/forge/LICENSE | 21
vendor/forge/README.md | 82
vendor/forge/skills/charm-bubbles/SKILL.md | 358
vendor/forge/skills/charm-bubbles/references/components.md | 417
vendor/forge/skills/charm-bubbletea/SKILL.md | 449
vendor/forge/skills/charm-bubbletea/references/api.md | 189
vendor/forge/skills/charm-ecosystem/SKILL.md | 270
vendor/forge/skills/charm-ecosystem/references/cookbook.md | 430
vendor/forge/skills/charm-fang/SKILL.md | 214
vendor/forge/skills/charm-freeze/SKILL.md | 161
vendor/forge/skills/charm-glamour/SKILL.md | 401
vendor/forge/skills/charm-glow/SKILL.md | 93
vendor/forge/skills/charm-gum/SKILL.md | 359
vendor/forge/skills/charm-harmonica/SKILL.md | 181
vendor/forge/skills/charm-huh/SKILL.md | 457
vendor/forge/skills/charm-lipgloss/SKILL.md | 313
vendor/forge/skills/charm-lipgloss/references/api-details.md | 183
vendor/forge/skills/charm-lipgloss/references/v1-to-v2-migration.md | 88
vendor/forge/skills/charm-pop/SKILL.md | 96
vendor/forge/skills/charm-ultraviolet/SKILL.md | 366
vendor/forge/skills/charm-vhs/SKILL.md | 219
vendor/forge/skills/claude-headless/SKILL.md | 358
vendor/forge/skills/claude-headless/references/code-examples.md | 764
vendor/forge/skills/claude-headless/references/event-types.md | 324
vendor/forge/skills/claude-md-forge/SKILL.md | 201
vendor/forge/skills/commit-forge/SKILL.md | 150
vendor/forge/skills/prompt-forge/SKILL.md | 254
vendor/forge/skills/prompt-forge/references/agentic-patterns.md | 119
vendor/forge/skills/prompt-forge/references/code-patterns.md | 84
vendor/forge/skills/readme-forge/SKILL.md | 170
vendor/forge/skills/skill-forge/SKILL.md | 177
33 files changed, 8,010 insertions(+)
@@ -0,0 +1,3 @@
+.DS_Store
+*.swp
+*.swo
@@ -0,0 +1,59 @@
+# Forge
+
+Research-backed skills and configs for LLM agent tools.
+
+## Critical Rules
+
+- ALWAYS add new skills to the Available Skills table in README.md when creating them
+- ALWAYS follow the SKILL.md frontmatter spec (name, description, optional: disable-model-invocation, argument-hint)
+- NEVER add co-author tags to commits
+- NEVER commit without explicit ask
+
+## Architecture
+
+- `skills/` - each skill is a directory with `SKILL.md` following the Agent Skills open standard
+- Skills are portable across Claude Code, Cursor, Copilot, Codex, Crush, and any SKILL.md-compatible agent
+- Each skill is self-contained - no cross-skill dependencies
+
+## Stack Decisions (Locked)
+
+- Skills use SKILL.md format (YAML frontmatter + markdown body)
+- No build step, no compilation, pure markdown
+- MIT licensed
+
+## Commands
+
+No build system. Skills are markdown files.
+
+```
+# Test a skill locally
+cp -r skills/<name> ~/.claude/skills/
+
+# Symlink for dev
+ln -s $(pwd)/skills/<name> ~/.claude/skills/<name>
+```
+
+## Implementation Pitfalls
+
+- Skill descriptions over 60 words waste token budget
+- `disable-model-invocation: true` removes description from context entirely - don't over-optimize descriptions for these
+- Skill body over 500 lines should be split into supporting files
+- Don't duplicate content between description and body
+
+## Commit Style
+
+Conventional commits: `type(scope): description`
+
+NEVER add co-author tags.
+
+## Compact Instructions
+
+Always keep: current skill being edited, research findings being applied, README.md update status.
+
+## Do NOT
+
+- Create skills without adding them to README.md Available Skills table
+- Write descriptions longer than 60 words
+- Add Claude-specific language when the skill works across agents
+- Use long dashes
+- Add AI slop phrases
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 alxx
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
@@ -0,0 +1,82 @@
+# Forge
+
+**Research-backed skills and configs for LLM agent tools.**
+
+## Overview
+
+Forge is a toolkit of **battle-tested skills, prompts, and agent configurations** built from reverse engineering top AI products and studying arxiv papers on LLM instruction following.
+
+> Portable across projects. Works with Claude Code, Codex, Cursor, and any SKILL.md-compatible agent.
+
+
+## Skills
+
+### Install
+
+```bash
+# All skills (works with Claude Code, Cursor, Copilot, Codex, 40+ agents)
+npx skills add https://github.com/alxxpersonal/forge
+
+# Or manual
+git clone https://github.com/alxxpersonal/forge.git
+ln -s $(pwd)/forge/skills/* ~/.claude/skills/
+```
+
+
+<details>
+<summary><b>Available Skills</b></summary>
+<br>
+
+| Skill | Description |
+|-------|-------------|
+| [claude-md-forge](skills/claude-md-forge) | Optimized CLAUDE.md and .claude/ infrastructure |
+| [skill-forge](skills/skill-forge) | Skills for any LLM agent tool |
+| [readme-forge](skills/readme-forge) | READMEs in a consistent, dense style |
+| [commit-forge](skills/commit-forge) | Clean, atomic git commits with conventional format |
+| [prompt-forge](skills/prompt-forge) | Universal prompt engineering guide for Claude and GPT |
+| [claude-headless](skills/claude-headless) | Build custom UIs on Claude Code's headless NDJSON protocol |
+
+</details>
+
+<details>
+<summary><b>Charmbracelet Skills</b></summary>
+<br>
+
+Production-grade skills for the entire [charmbracelet](https://github.com/charmbracelet) Go TUI ecosystem. Built by reading actual v2 source code - real API, real patterns, no hallucinated functions.
+
+| Skill | Description |
+|-------|-------------|
+| [charm-ecosystem](skills/charm-ecosystem) | Architect's guide - which libraries to combine, decision tree, integration cookbook |
+
+**Libraries:**
+
+| Skill | Description |
+|-------|-------------|
+| [charm-bubbletea](skills/charm-bubbletea) | Elm Architecture TUI framework - Model/Update/View, commands, subscriptions |
+| [charm-lipgloss](skills/charm-lipgloss) | CSS-like terminal styling - colors, borders, layout, tables, lists, trees |
+| [charm-bubbles](skills/charm-bubbles) | Pre-built TUI components - spinner, textinput, list, table, viewport, progress |
+| [charm-huh](skills/charm-huh) | Terminal forms and prompts - input, select, confirm, validation, theming |
+| [charm-glamour](skills/charm-glamour) | Stylesheet-based markdown rendering for terminal |
+| [charm-harmonica](skills/charm-harmonica) | Physics-based spring animations for TUI |
+| [charm-ultraviolet](skills/charm-ultraviolet) | Low-level terminal primitives powering bubbletea v2 |
+
+**CLI Tools:**
+
+| Skill | Description |
+|-------|-------------|
+| [charm-gum](skills/charm-gum) | Shell scripting UI - prompts, spinners, filters, styled output |
+| [charm-glow](skills/charm-glow) | Terminal markdown viewer |
+| [charm-vhs](skills/charm-vhs) | Record terminal sessions to GIF/MP4/WebM via .tape files |
+| [charm-freeze](skills/charm-freeze) | Screenshot terminal output to PNG/SVG |
+| [charm-pop](skills/charm-pop) | Send emails from terminal |
+| [charm-fang](skills/charm-fang) | CLI starter kit wrapping Cobra with styled help and auto-versioning |
+
+</details>
+
+## Why
+
+Forge skills are built on actual research - primacy effects, instruction decay curves, token budget math, and structural patterns extracted from arxiv papers and top open-source repos.
+
+## License
+
+[MIT](LICENSE)
@@ -0,0 +1,358 @@
+---
+name: charm-bubbles
+description: "Pre-built TUI components for Bubble Tea apps - spinner, text input, textarea, list, table, viewport, paginator, progress bar. Use when adding Go TUI components, bubbles, or terminal widgets to a Bubble Tea app. NOT for the core TUI framework (use bubbletea)."
+---
+
+# Bubbles - TUI Components for Bubble Tea
+
+Bubbles (`charm.land/bubbles/v2`) is a component library for [Bubble Tea](https://github.com/charmbracelet/bubbletea) applications. Each bubble is a self-contained Model with `Update` and `View` methods you embed in your own model.
+
+## Quick Start
+
+Embed a bubble (e.g. spinner) inside your Bubble Tea app:
+
+```go
+package main
+
+import (
+ "fmt"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/bubbles/v2/spinner"
+)
+
+type model struct {
+ spinner spinner.Model
+}
+
+func initialModel() model {
+ return model{spinner: spinner.New(spinner.WithSpinner(spinner.Dot))}
+}
+
+func (m model) Init() tea.Cmd {
+ return m.spinner.Tick // start the spinner
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyPressMsg:
+ if msg.String() == "q" {
+ return m, tea.Quit
+ }
+ }
+ var cmd tea.Cmd
+ m.spinner, cmd = m.spinner.Update(msg)
+ return m, cmd
+}
+
+func (m model) View() string {
+ return fmt.Sprintf("\n %s Loading...\n", m.spinner.View())
+}
+
+func main() {
+ tea.NewProgram(initialModel()).Run()
+}
+```
+
+## How Bubbles Work
+
+### The Update/View Contract
+
+Every bubble follows the same pattern:
+
+1. **Model** - a struct holding component state
+2. **New()** - constructor returning a configured Model (usually with functional options)
+3. **Update(msg tea.Msg) (Model, tea.Cmd)** - processes messages, returns updated model + commands
+4. **View() string** - renders current state to a string
+
+Bubbles return their own Model type from Update (not `tea.Model`), so you assign back to the embedded field:
+
+```go
+// Correct: assign back to the field
+m.textinput, cmd = m.textinput.Update(msg)
+
+// Wrong: this loses the update
+m.textinput.Update(msg)
+```
+
+### Message Flow
+
+Bubbles communicate via typed messages. When you embed a bubble, you pass all messages through to its Update:
+
+```go
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+
+ // Handle your own messages first
+ switch msg := msg.(type) {
+ case tea.KeyPressMsg:
+ // your key handling
+ }
+
+ // Forward to embedded bubbles
+ var cmd tea.Cmd
+ m.spinner, cmd = m.spinner.Update(msg)
+ cmds = append(cmds, cmd)
+
+ m.textinput, cmd = m.textinput.Update(msg)
+ cmds = append(cmds, cmd)
+
+ return m, tea.Batch(cmds...)
+}
+```
+
+### Commands and Init
+
+Some bubbles require commands to start (spinner needs `Tick`, timer needs `Init`). Return these from your top-level `Init()`:
+
+```go
+func (m model) Init() tea.Cmd {
+ return tea.Batch(
+ m.spinner.Tick,
+ m.timer.Init(),
+ )
+}
+```
+
+## Common Patterns
+
+### Focus Management
+
+Interactive bubbles (textinput, textarea, table, filepicker) have Focus/Blur methods. Only focused components process keyboard input.
+
+```go
+// Focus returns a tea.Cmd for textinput (starts cursor blinking)
+cmd := m.textinput.Focus()
+
+// Table focus is simpler, no cmd needed
+m.table.Focus()
+
+// Blur removes focus
+m.textinput.Blur()
+```
+
+When managing multiple inputs, blur all then focus the active one:
+
+```go
+for i := range m.inputs {
+ m.inputs[i].Blur()
+}
+m.inputs[m.focusIndex].Focus()
+```
+
+### Functional Options
+
+Most constructors accept variadic options:
+
+```go
+s := spinner.New(spinner.WithSpinner(spinner.Dot), spinner.WithStyle(myStyle))
+t := table.New(table.WithColumns(cols), table.WithRows(rows), table.WithHeight(10))
+v := viewport.New(viewport.WithWidth(80), viewport.WithHeight(24))
+p := progress.New(progress.WithDefaultBlend(), progress.WithoutPercentage())
+tmr := timer.New(30*time.Second, timer.WithInterval(100*time.Millisecond))
+```
+
+### Styling with Lipgloss
+
+All visual bubbles accept lipgloss styles. Common pattern:
+
+```go
+// Spinner: direct Style field
+s := spinner.New()
+s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
+
+// Table: Styles struct
+t := table.New(table.WithStyles(table.Styles{
+ Header: lipgloss.NewStyle().Bold(true).Padding(0, 1),
+ Cell: lipgloss.NewStyle().Padding(0, 1),
+ Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")),
+}))
+
+// TextInput/TextArea: SetStyles method with Focused/Blurred states
+ti := textinput.New()
+ti.SetStyles(textinput.DefaultDarkStyles())
+
+// Viewport: Style field for borders/padding
+vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(24))
+vp.Style = lipgloss.NewStyle().Border(lipgloss.RoundedBorder())
+```
+
+### Key Bindings
+
+Use the `key` package for remappable bindings that integrate with the `help` bubble:
+
+```go
+type KeyMap struct {
+ Quit key.Binding
+ Help key.Binding
+}
+
+var keys = KeyMap{
+ Quit: key.NewBinding(
+ key.WithKeys("q", "ctrl+c"),
+ key.WithHelp("q", "quit"),
+ ),
+ Help: key.NewBinding(
+ key.WithKeys("?"),
+ key.WithHelp("?", "help"),
+ ),
+}
+
+// In Update:
+case tea.KeyPressMsg:
+ switch {
+ case key.Matches(msg, keys.Quit):
+ return m, tea.Quit
+ }
+
+// For help integration, implement help.KeyMap interface:
+func (k KeyMap) ShortHelp() []key.Binding { return []key.Binding{k.Quit, k.Help} }
+func (k KeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{{k.Quit, k.Help}} }
+```
+
+### Composing Multiple Bubbles
+
+The list component is a good example of composition - it internally uses spinner, textinput, paginator, and help:
+
+```go
+type model struct {
+ list list.Model
+ viewport viewport.Model
+ help help.Model
+ spinner spinner.Model
+}
+```
+
+Combine their views with lipgloss layout:
+
+```go
+func (m model) View() string {
+ left := m.list.View()
+ right := m.viewport.View()
+ return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
+}
+```
+
+### Window Size Handling
+
+Resize bubbles when the terminal size changes:
+
+```go
+case tea.WindowSizeMsg:
+ m.viewport.SetWidth(msg.Width)
+ m.viewport.SetHeight(msg.Height - headerHeight)
+ m.list.SetSize(msg.Width, msg.Height)
+ m.table.SetWidth(msg.Width)
+ m.table.SetHeight(msg.Height)
+ m.progress.SetWidth(msg.Width - padding)
+ m.help.SetWidth(msg.Width)
+```
+
+### ID-Based Message Routing
+
+Animated bubbles (spinner, progress, timer, stopwatch) use internal IDs so multiple instances don't interfere. Each instance only processes messages with its own ID. Just forward all messages to all instances:
+
+```go
+m.spinner1, cmd1 = m.spinner1.Update(msg)
+m.spinner2, cmd2 = m.spinner2.Update(msg)
+```
+
+## Integration with Bubbletea and Lipgloss
+
+### Import Paths (v2)
+
+```go
+import (
+ tea "charm.land/bubbletea/v2"
+ "charm.land/bubbles/v2/spinner"
+ "charm.land/bubbles/v2/textinput"
+ "charm.land/lipgloss/v2"
+)
+```
+
+### Key Message Types
+
+Bubbles v2 uses `tea.KeyPressMsg` (not `tea.KeyMsg` from v1). Match with `key.Matches`:
+
+```go
+case tea.KeyPressMsg:
+ switch {
+ case key.Matches(msg, m.KeyMap.Up):
+ // handle up
+ }
+```
+
+### Progress Bar - Static vs Animated
+
+Progress supports two modes:
+
+```go
+// Animated: use SetPercent (returns cmd), Update processes FrameMsg
+cmd := m.progress.SetPercent(0.75)
+
+// Static: use ViewAs directly, no Update needed
+view := m.progress.ViewAs(0.75)
+```
+
+### List Item Interface
+
+The list component requires items to implement the `Item` interface:
+
+```go
+type Item interface {
+ FilterValue() string
+}
+```
+
+And a delegate implementing `ItemDelegate`:
+
+```go
+type ItemDelegate interface {
+ Render(w io.Writer, m Model, index int, item Item)
+ Height() int
+ Spacing() int
+ Update(msg tea.Msg, m *Model) tea.Cmd
+}
+```
+
+### Filepicker Selection
+
+Check for file selection in your Update:
+
+```go
+case tea.KeyPressMsg:
+ m.filepicker, cmd = m.filepicker.Update(msg)
+ if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect {
+ m.selectedFile = path
+ }
+```
+
+## Common Mistakes
+
+1. **Not returning commands from Update.** Bubbles like spinner and timer stop working if you drop their commands. Always capture and return: `m.spinner, cmd = m.spinner.Update(msg)`
+
+2. **Forgetting to call Init/Tick.** Spinner needs `m.spinner.Tick` returned from Init. Timer needs `m.timer.Init()`. Without these, the component never starts animating.
+
+3. **Not assigning Update result back.** Bubbles return value types (not pointers). `m.spinner.Update(msg)` without assignment discards the update.
+
+4. **Forwarding messages only to focused bubble.** Most bubbles self-filter (spinners ignore wrong IDs, unfocused inputs ignore keys). Forward all messages to all bubbles and let them decide.
+
+5. **Using v1 message types.** In v2, it's `tea.KeyPressMsg` not `tea.KeyMsg`. Check the upgrade guide if migrating.
+
+6. **Not handling WindowSizeMsg.** Components with fixed dimensions (viewport, list, table, progress) need resizing or they clip/overflow.
+
+7. **Setting width/height to 0.** Viewport and table render empty strings when dimensions are 0. Always set dimensions before first render.
+
+8. **Calling Focus() without using the cmd.** `textinput.Focus()` returns a `tea.Cmd` for cursor blinking. If you drop it, the cursor won't blink.
+
+## Checklist
+
+- [ ] Embed bubble Model as a field in your model (not a pointer)
+- [ ] Call constructor with `New()` or `New(opts...)`
+- [ ] Return Init commands from your `Init()` (Tick for spinner, Init for timer/stopwatch)
+- [ ] Forward messages to bubble's `Update` and capture both return values
+- [ ] Collect commands with `tea.Batch` when using multiple bubbles
+- [ ] Call `Focus()` on interactive components and use returned cmd
+- [ ] Handle `tea.WindowSizeMsg` to resize dimension-aware components
+- [ ] Implement `Item` and `ItemDelegate` interfaces when using list
+- [ ] Use `key.Matches(msg, binding)` for key matching in v2
+- [ ] Style with lipgloss via Style fields or SetStyles methods
@@ -0,0 +1,417 @@
+# Bubbles Component Catalog
+
+All components live under `charm.land/bubbles/v2/<package>`.
+
+## spinner
+
+Animated loading indicator with preset and custom frame sets.
+
+**Key Types:**
+- `Model` - component state (Spinner, Style fields)
+- `Spinner` - frame definition (`Frames []string`, `FPS time.Duration`)
+- `TickMsg` - animation frame message
+
+**Presets:** `Line`, `Dot`, `MiniDot`, `Jump`, `Pulse`, `Points`, `Globe`, `Moon`, `Monkey`, `Meter`, `Hamburger`, `Ellipsis`
+
+**Constructor:**
+```go
+s := spinner.New(spinner.WithSpinner(spinner.Dot), spinner.WithStyle(style))
+```
+
+**Start:** Return `m.spinner.Tick` from your `Init()`.
+
+**Usage:**
+```go
+m.spinner, cmd = m.spinner.Update(msg)
+view := m.spinner.View()
+```
+
+---
+
+## textinput
+
+Single-line text input with cursor, placeholder, validation, echo modes, and autocomplete suggestions.
+
+**Key Types:**
+- `Model` - component state
+- `KeyMap` - customizable keybindings
+- `EchoMode` - `EchoNormal`, `EchoPassword`, `EchoNone`
+- `ValidateFunc` - `func(string) error`
+- `Styles` - focused/blurred state styles
+
+**Constructor:**
+```go
+ti := textinput.New()
+ti.Placeholder = "Type here..."
+ti.CharLimit = 100
+ti.SetWidth(40)
+```
+
+**Key Methods:**
+- `Focus() tea.Cmd` / `Blur()` - focus management
+- `Value() string` / `SetValue(string)` - get/set content
+- `SetSuggestions([]string)` - autocomplete suggestions (set `ShowSuggestions = true`)
+- `SetStyles(Styles)` - apply styles
+- `Validate ValidateFunc` - assign validation function, errors go to `Err` field
+
+---
+
+## textarea
+
+Multi-line text editor with line numbers, word wrapping, cursor navigation, and viewport scrolling.
+
+**Key Types:**
+- `Model` - component state
+- `KeyMap` - extensive keybindings (character/word/line movement, case transforms, transpose)
+- `LineInfo` - cursor position metadata
+
+**Constructor:**
+```go
+ta := textarea.New()
+ta.SetWidth(60)
+ta.SetHeight(10)
+ta.Placeholder = "Enter text..."
+ta.ShowLineNumbers = true
+```
+
+**Key Methods:**
+- `Focus() tea.Cmd` / `Blur()` - focus management
+- `Value() string` / `SetValue(string)` - get/set content
+- `Line() int` / `LineCount() int` - cursor line info
+- `SetStyles(Styles)` - focused/blurred styles
+
+---
+
+## list
+
+Batteries-included list browser with fuzzy filtering, pagination, help, spinner, and status messages.
+
+**Key Types:**
+- `Model` - component state
+- `Item` interface - must implement `FilterValue() string`
+- `ItemDelegate` interface - `Render`, `Height`, `Spacing`, `Update` methods
+- `FilterState` - `Unfiltered`, `Filtering`, `FilterApplied`
+- `FilterFunc` - custom filter function
+- `Rank` - filter match result
+
+**Constructor:**
+```go
+items := []list.Item{myItem1, myItem2}
+l := list.New(items, myDelegate, width, height)
+l.Title = "My List"
+```
+
+**Key Methods:**
+- `SetItems([]Item)` / `Items() []Item` - manage items
+- `SelectedItem() Item` / `Index() int` - get selection
+- `SetSize(w, h int)` - resize
+- `SetFilteringEnabled(bool)` - toggle filtering
+- `NewStatusMessage(string) tea.Cmd` - show temporary status
+- `SetShowHelp(bool)` / `SetShowTitle(bool)` / `SetShowFilter(bool)` - toggle UI elements
+
+**Built-in Filter:** `list.DefaultFilter` (fuzzy), `list.UnsortedFilter`
+
+---
+
+## table
+
+Tabular data display with row selection and keyboard navigation.
+
+**Key Types:**
+- `Model` - component state
+- `Row` - `[]string`
+- `Column` - `Title string`, `Width int`
+- `KeyMap` - up/down/page/goto keybindings
+- `Styles` - `Header`, `Cell`, `Selected`
+
+**Constructor:**
+```go
+t := table.New(
+ table.WithColumns([]table.Column{
+ {Title: "Name", Width: 20},
+ {Title: "Age", Width: 5},
+ }),
+ table.WithRows([]table.Row{
+ {"Alice", "30"},
+ {"Bob", "25"},
+ }),
+ table.WithHeight(10),
+ table.WithFocused(true),
+)
+```
+
+**Key Methods:**
+- `Focus()` / `Blur()` - focus management
+- `SelectedRow() Row` / `Cursor() int` - get selection
+- `SetRows([]Row)` / `SetColumns([]Column)` - update data
+- `SetWidth(int)` / `SetHeight(int)` - resize
+- `SetCursor(int)` - set selection index
+- `FromValues(value, separator string)` - parse string data
+- `HelpView() string` - render help from keymap
+- `UpdateViewport()` - refresh after data changes
+
+---
+
+## viewport
+
+Scrollable content viewer with vertical/horizontal scrolling, soft wrap, mouse wheel, line gutters, and text highlighting.
+
+**Key Types:**
+- `Model` - component state
+- `KeyMap` - scroll keybindings (Up/Down/Left/Right/PageUp/PageDown/HalfPageUp/HalfPageDown)
+- `GutterFunc` / `GutterContext` - left gutter rendering (line numbers, etc.)
+
+**Constructor:**
+```go
+vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(24))
+vp.SetContent("long text content here...")
+```
+
+**Key Methods:**
+- `SetContent(string)` / `GetContent() string` - set/get content
+- `SetContentLines([]string)` - set content as lines
+- `SetWidth(int)` / `SetHeight(int)` - resize
+- `ScrollDown(n)` / `ScrollUp(n)` / `ScrollLeft(n)` / `ScrollRight(n)` - scroll
+- `GotoTop()` / `GotoBottom()` - jump to extremes
+- `AtTop() bool` / `AtBottom() bool` - position checks
+- `ScrollPercent() float64` - scroll progress (0-1)
+- `YOffset() int` / `SetYOffset(int)` - vertical position
+- `SetHighlights([][]int)` / `HighlightNext()` / `HighlightPrevious()` - search highlighting
+- `EnsureVisible(line, colstart, colend int)` - scroll to show position
+
+**Fields:**
+- `SoftWrap bool` - enable word wrap (disables horizontal scroll)
+- `FillHeight bool` - pad with empty lines
+- `MouseWheelEnabled bool` / `MouseWheelDelta int` - mouse config
+- `LeftGutterFunc GutterFunc` - line number gutter
+- `Style lipgloss.Style` - border/padding style
+- `HighlightStyle` / `SelectedHighlightStyle` - search match styles
+- `StyleLineFunc func(int) lipgloss.Style` - per-line styling
+
+---
+
+## paginator
+
+Pagination logic and display (dot or numeric style).
+
+**Key Types:**
+- `Model` - component state
+- `Type` - `Arabic` (1/5), `Dots` (bullet dots)
+- `KeyMap` - `PrevPage`, `NextPage`
+
+**Constructor:**
+```go
+p := paginator.New(paginator.WithPerPage(10), paginator.WithTotalPages(5))
+```
+
+**Key Methods:**
+- `NextPage()` / `PrevPage()` - navigate
+- `SetTotalPages(itemCount int) int` - calculate pages from item count
+- `GetSliceBounds(length int) (start, end int)` - get slice indices for current page
+- `ItemsOnPage(totalItems int) int` - items on current page
+- `OnFirstPage() bool` / `OnLastPage() bool` - position checks
+
+**Fields:** `Page`, `PerPage`, `TotalPages`, `ActiveDot`, `InactiveDot`, `ArabicFormat`
+
+---
+
+## progress
+
+Animated progress bar with solid, gradient, and dynamic color fills.
+
+**Key Types:**
+- `Model` - component state
+- `FrameMsg` - animation tick
+- `ColorFunc` - `func(total, current float64) color.Color`
+
+**Constructor:**
+```go
+p := progress.New(
+ progress.WithDefaultBlend(), // purple-to-pink gradient
+ progress.WithWidth(40),
+ progress.WithoutPercentage(),
+)
+// Or solid color:
+p := progress.New(progress.WithColors(lipgloss.Color("#FF0000")))
+// Or dynamic:
+p := progress.New(progress.WithColorFunc(myColorFunc))
+```
+
+**Key Methods:**
+- `SetPercent(float64) tea.Cmd` - animate to percentage (0-1), returns cmd
+- `IncrPercent(float64) tea.Cmd` / `DecrPercent(float64) tea.Cmd` - relative changes
+- `ViewAs(float64) string` - static render at given percentage (no animation)
+- `View() string` - render current animated state
+- `Percent() float64` - current target percentage
+- `IsAnimating() bool` - whether animation is in progress
+- `SetWidth(int)` / `Width() int` - resize
+- `SetSpringOptions(frequency, damping float64)` - animation physics
+
+**Fields:** `Full`, `Empty` (runes), `FullColor`, `EmptyColor`, `ShowPercentage`, `PercentFormat`, `PercentageStyle`
+
+---
+
+## help
+
+Auto-generated horizontal help view from keybindings. Supports short (single line) and full (multi-column) modes.
+
+**Key Types:**
+- `Model` - component state
+- `KeyMap` interface - `ShortHelp() []key.Binding`, `FullHelp() [][]key.Binding`
+- `Styles` - separate styles for short/full key/desc/separator
+
+**Constructor:**
+```go
+h := help.New()
+h.SetWidth(80)
+```
+
+**Usage:**
+```go
+// Pass your KeyMap implementation
+view := h.View(myKeyMap)
+
+// Or render directly
+view := h.ShortHelpView(bindings)
+view := h.FullHelpView(bindingGroups)
+```
+
+**Fields:** `ShowAll bool` (toggle short/full), `ShortSeparator`, `FullSeparator`, `Ellipsis`
+
+---
+
+## filepicker
+
+File system browser for selecting files/directories.
+
+**Key Types:**
+- `Model` - component state
+- `KeyMap` - navigation keys (Up/Down/Back/Open/Select/PageUp/PageDown/GoToTop/GoToLast)
+- `Styles` - styles for cursor, directory, file, symlink, permissions, etc.
+
+**Constructor:**
+```go
+fp := filepicker.New()
+fp.CurrentDirectory = "/home/user"
+fp.AllowedTypes = []string{".go", ".md"}
+fp.DirAllowed = false
+fp.FileAllowed = true
+fp.ShowHidden = true
+```
+
+**Key Methods:**
+- `DidSelectFile(msg tea.Msg) (bool, string)` - check if user selected a valid file
+- `DidSelectDisabledFile(msg tea.Msg) (bool, string)` - check if user tried to select a disallowed file
+
+**Fields:** `CurrentDirectory`, `AllowedTypes`, `ShowPermissions`, `ShowSize`, `ShowHidden`, `DirAllowed`, `FileAllowed`, `AutoHeight`, `Path`, `FileSelected`
+
+---
+
+## timer
+
+Countdown timer with configurable interval.
+
+**Key Types:**
+- `Model` - component state
+- `TickMsg` - periodic tick (has `Timeout bool` flag)
+- `TimeoutMsg` - sent once when timer expires
+- `StartStopMsg` - control message
+
+**Constructor:**
+```go
+t := timer.New(30*time.Second, timer.WithInterval(100*time.Millisecond))
+```
+
+**Start:** Return `m.timer.Init()` from your `Init()`.
+
+**Key Methods:**
+- `Start() tea.Cmd` / `Stop() tea.Cmd` / `Toggle() tea.Cmd` - control
+- `Running() bool` / `Timedout() bool` - state queries
+- `View() string` - renders remaining time
+
+**Fields:** `Timeout time.Duration`, `Interval time.Duration`
+
+---
+
+## stopwatch
+
+Count-up timer with start/stop/reset.
+
+**Key Types:**
+- `Model` - component state
+- `TickMsg` - periodic tick
+- `StartStopMsg` / `ResetMsg` - control messages
+
+**Constructor:**
+```go
+sw := stopwatch.New(stopwatch.WithInterval(100*time.Millisecond))
+```
+
+**Start:** Return `m.stopwatch.Init()` from your `Init()`.
+
+**Key Methods:**
+- `Start() tea.Cmd` / `Stop() tea.Cmd` / `Toggle() tea.Cmd` / `Reset() tea.Cmd` - control
+- `Running() bool` - state query
+- `Elapsed() time.Duration` - get elapsed time
+- `View() string` - renders elapsed time
+
+**Fields:** `Interval time.Duration`
+
+---
+
+## cursor
+
+Virtual cursor used internally by textinput and textarea. Supports blink, static, and hidden modes.
+
+**Key Types:**
+- `Model` - cursor state
+- `Mode` - `CursorBlink`, `CursorStatic`, `CursorHide`
+- `BlinkMsg` - blink tick
+
+**Constructor:**
+```go
+c := cursor.New()
+```
+
+**Key Methods:**
+- `Focus() tea.Cmd` / `Blur()` - focus management
+- `SetMode(Mode) tea.Cmd` - set cursor behavior
+- `Mode() Mode` - get current mode
+- `SetChar(string)` - character under cursor
+- `Blink() tea.Cmd` - start blink cycle
+
+**Fields:** `Style`, `TextStyle`, `BlinkSpeed`, `IsBlinked`
+
+---
+
+## key
+
+Non-visual utility for defining remappable keybindings with help text integration.
+
+**Key Types:**
+- `Binding` - a set of keys with optional help text
+- `BindingOpt` - functional option for NewBinding
+- `Help` - `Key string`, `Desc string`
+
+**Constructor:**
+```go
+b := key.NewBinding(
+ key.WithKeys("k", "up"),
+ key.WithHelp("up/k", "move up"),
+)
+```
+
+**Key Functions:**
+- `key.Matches(key fmt.Stringer, bindings ...Binding) bool` - check if key matches any binding
+
+**Key Methods on Binding:**
+- `Enabled() bool` / `SetEnabled(bool)` - enable/disable
+- `Keys() []string` / `SetKeys(...string)` - get/set keys
+- `Help() Help` / `SetHelp(key, desc string)` - get/set help text
+- `Unbind()` - remove all keys and help
+
+---
+
+## runeutil (internal)
+
+Internal sanitizer for cleaning rune input (tab/newline replacement). Not part of the public API.
@@ -0,0 +1,449 @@
+---
+name: charm-bubbletea
+description: "Build terminal UIs in Go with Bubble Tea v2's Elm Architecture (Model/Update/View). Use when building Go TUI apps, tea.Model, tea.Cmd, Elm architecture, or terminal applications. NOT for pre-built TUI components (use bubbles)."
+argument-hint: "[component or pattern name]"
+---
+
+# Bubble Tea v2
+
+Build terminal UIs in Go using the Elm Architecture. Import as `tea "charm.land/bubbletea/v2"`.
+
+**$ARGUMENTS context**: If a specific component or pattern is requested, focus guidance on that area.
+
+## Quick Start
+
+Minimal working program - a counter:
+
+```go
+package main
+
+import (
+ "fmt"
+ "os"
+
+ tea "charm.land/bubbletea/v2"
+)
+
+type model struct{ count int }
+
+func (m model) Init() tea.Cmd { return nil }
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyPressMsg:
+ switch msg.String() {
+ case "q", "ctrl+c":
+ return m, tea.Quit
+ case "up", "k":
+ m.count++
+ case "down", "j":
+ m.count--
+ }
+ }
+ return m, nil
+}
+
+func (m model) View() tea.View {
+ return tea.NewView(fmt.Sprintf("Count: %d\n\nup/down to change, q to quit\n", m.count))
+}
+
+func main() {
+ if _, err := tea.NewProgram(model{}).Run(); err != nil {
+ fmt.Println("Error:", err)
+ os.Exit(1)
+ }
+}
+```
+
+## Core API Reference
+
+### The Elm Architecture
+
+Bubble Tea follows the Elm Architecture - a unidirectional data flow pattern:
+
+1. **Model** holds all application state
+2. **Update** receives messages, returns updated model + optional command
+3. **View** renders the model to a string (called after every Update)
+4. **Commands** perform async I/O, returning messages back to Update
+
+The runtime owns the loop. You never mutate state outside Update. You never call View yourself.
+
+### tea.Model Interface
+
+```go
+type Model interface {
+ Init() Cmd // initial command (return nil for none)
+ Update(Msg) (Model, Cmd) // handle messages, return new state + command
+ View() View // render UI
+}
+```
+
+### tea.Cmd and tea.Msg
+
+```go
+type Msg = any // messages can be any type
+type Cmd func() Msg // commands are functions that return a message
+```
+
+**A Cmd is a function that performs I/O and returns a Msg.** Return `nil` for no command.
+
+### tea.View
+
+```go
+// Create a view from a string
+v := tea.NewView("Hello, World!")
+
+// View struct fields (set after creation):
+v.AltScreen = true // fullscreen mode
+v.MouseMode = tea.MouseModeCellMotion // enable mouse
+v.ReportFocus = true // get FocusMsg/BlurMsg
+v.Cursor = tea.NewCursor(x, y) // show cursor at position
+v.WindowTitle = "My App" // set terminal title
+v.BackgroundColor = someColor // set terminal bg
+v.KeyboardEnhancements.ReportEventTypes = true // key release events
+```
+
+### Program
+
+```go
+p := tea.NewProgram(model{}, opts...) // create program
+m, err := p.Run() // run (blocks until quit)
+p.Send(msg) // send message from outside
+p.Quit() // quit from outside
+p.Kill() // force kill
+p.Wait() // block until shutdown
+```
+
+### Program Options
+
+```go
+tea.WithContext(ctx) // cancellable context
+tea.WithInput(reader) // custom input (nil to disable)
+tea.WithOutput(writer) // custom output
+tea.WithFilter(func(Model, Msg) Msg) // intercept/filter messages
+tea.WithFPS(fps) // custom FPS (default 60, max 120)
+tea.WithEnvironment([]string) // custom env vars (SSH)
+tea.WithWindowSize(w, h) // initial size (testing)
+tea.WithColorProfile(profile) // force color profile
+tea.WithoutRenderer() // no TUI rendering
+tea.WithoutSignalHandler() // handle signals yourself
+tea.WithoutCatchPanics() // disable panic recovery
+```
+
+### Built-in Messages
+
+| Message | When |
+|---------|------|
+| `tea.KeyPressMsg` | Key pressed. Use `msg.String()` to match (e.g. `"ctrl+c"`, `"enter"`, `"a"`) |
+| `tea.KeyReleaseMsg` | Key released (needs keyboard enhancements) |
+| `tea.WindowSizeMsg` | Terminal resized. Fields: `Width`, `Height`. Sent on startup + resize |
+| `tea.MouseClickMsg` | Mouse click. Fields: `X`, `Y`, `Button`, `Mod` |
+| `tea.MouseReleaseMsg` | Mouse button released |
+| `tea.MouseWheelMsg` | Scroll wheel |
+| `tea.MouseMotionMsg` | Mouse moved (needs AllMotion mode) |
+| `tea.FocusMsg` | Terminal gained focus (needs `ReportFocus`) |
+| `tea.BlurMsg` | Terminal lost focus |
+| `tea.PasteMsg` | Bracketed paste. Field: `Content` |
+| `tea.ColorProfileMsg` | Terminal color profile on startup |
+| `tea.BackgroundColorMsg` | Response to `RequestBackgroundColor`. Has `IsDark()` |
+| `tea.ResumeMsg` | Program resumed after suspend |
+| `tea.KeyboardEnhancementsMsg` | Terminal keyboard capabilities |
+
+### Built-in Commands
+
+| Command | What it does |
+|---------|-------------|
+| `tea.Quit` | Exit the program |
+| `tea.Suspend` | Suspend (ctrl+z behavior) |
+| `tea.Interrupt` | Interrupt (returns `ErrInterrupted`) |
+| `tea.ClearScreen` | Clear terminal |
+| `tea.Batch(cmds...)` | Run commands concurrently |
+| `tea.Sequence(cmds...)` | Run commands in order |
+| `tea.Every(dur, fn)` | Tick synced with system clock |
+| `tea.Tick(dur, fn)` | Tick from invocation time |
+| `tea.Println(args...)` | Print above TUI (persists across renders) |
+| `tea.Printf(tmpl, args...)` | Printf above TUI |
+| `tea.SetClipboard(s)` | Set system clipboard (OSC52) |
+| `tea.ReadClipboard` | Read system clipboard |
+| `tea.RequestWindowSize` | Query current window size |
+| `tea.RequestBackgroundColor` | Query terminal background color |
+| `tea.ExecProcess(cmd, callback)` | Run external process (e.g. editor) |
+| `tea.Raw(seq)` | Send raw escape sequence |
+
+### Key Handling
+
+```go
+case tea.KeyPressMsg:
+ switch msg.String() {
+ case "ctrl+c", "q": // string matching (most common)
+ return m, tea.Quit
+ case "up", "k": // arrow keys have string names
+ case "enter", "space": // special keys
+ case "ctrl+s": // modifier combos
+ case "a": // regular characters
+ }
+
+// Or use the Key struct for type-safe matching:
+case tea.KeyPressMsg:
+ key := msg.Key()
+ switch key.Code {
+ case tea.KeyEnter: // typed constant
+ case tea.KeyTab:
+ case tea.KeyEsc:
+ }
+ // key.Mod for modifiers: tea.ModCtrl, tea.ModAlt, tea.ModShift
+ // key.Text for printable characters
+```
+
+### Mouse Handling
+
+Enable mouse in View, handle in Update:
+
+```go
+func (m model) View() tea.View {
+ v := tea.NewView(m.render())
+ v.MouseMode = tea.MouseModeCellMotion // or tea.MouseModeAllMotion
+ return v
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.MouseClickMsg:
+ x, y := msg.X, msg.Y
+ if msg.Button == tea.MouseLeft { /* handle click */ }
+ case tea.MouseWheelMsg:
+ if msg.Button == tea.MouseWheelUp { /* scroll up */ }
+ case tea.MouseMsg:
+ // catches all mouse events
+ mouse := msg.Mouse()
+ }
+ return m, nil
+}
+```
+
+## Common Patterns
+
+### Pattern 1: Async I/O (HTTP, file, etc.)
+
+Define a custom Msg type. Write a Cmd that does the I/O and returns it.
+
+```go
+type statusMsg int
+type errMsg struct{ error }
+
+func checkServer() tea.Msg {
+ res, err := http.Get("https://example.com")
+ if err != nil {
+ return errMsg{err}
+ }
+ defer res.Body.Close()
+ return statusMsg(res.StatusCode)
+}
+
+func (m model) Init() tea.Cmd { return checkServer } // note: pass function, don't call it
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case statusMsg:
+ m.status = int(msg)
+ return m, tea.Quit
+ case errMsg:
+ m.err = msg.error
+ return m, nil
+ }
+ return m, nil
+}
+```
+
+### Pattern 2: Ticking / Timers
+
+`tea.Tick` and `tea.Every` send a single message. Re-dispatch to loop.
+
+```go
+type tickMsg time.Time
+
+func doTick() tea.Cmd {
+ return tea.Tick(time.Second, func(t time.Time) tea.Msg {
+ return tickMsg(t)
+ })
+}
+
+func (m model) Init() tea.Cmd { return doTick() }
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg.(type) {
+ case tickMsg:
+ m.elapsed++
+ return m, doTick() // re-dispatch to keep ticking
+ }
+ return m, nil
+}
+```
+
+**`tea.Every`** syncs with the system clock (wall-clock aligned ticks). **`tea.Tick`** starts from invocation.
+
+### Pattern 3: Composing Child Components
+
+Child components follow the same Model/Update/View pattern. Parent delegates messages.
+
+```go
+type parentModel struct {
+ spinner spinner.Model
+ input textinput.Model
+ focus int
+}
+
+func (m parentModel) Init() tea.Cmd {
+ return tea.Batch(m.spinner.Tick, m.input.Focus())
+}
+
+func (m parentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+
+ // Always update spinner (it needs ticks regardless of focus)
+ var cmd tea.Cmd
+ m.spinner, cmd = m.spinner.Update(msg)
+ cmds = append(cmds, cmd)
+
+ // Only update focused component for key events
+ switch msg.(type) {
+ case tea.KeyPressMsg:
+ if m.focus == 0 {
+ m.input, cmd = m.input.Update(msg)
+ cmds = append(cmds, cmd)
+ }
+ }
+
+ return m, tea.Batch(cmds...)
+}
+
+func (m parentModel) View() tea.View {
+ return tea.NewView(m.spinner.View() + "\n" + m.input.View())
+}
+```
+
+### Pattern 4: External Messages via Channel
+
+Use `p.Send()` to push messages from goroutines, or use a channel-based Cmd.
+
+```go
+// Channel-based approach (preferred for subscription-like behavior)
+func waitForActivity(sub chan resultMsg) tea.Cmd {
+ return func() tea.Msg {
+ return <-sub // blocks until message arrives
+ }
+}
+
+func (m model) Init() tea.Cmd {
+ return waitForActivity(m.sub)
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case resultMsg:
+ m.results = append(m.results, msg)
+ return m, waitForActivity(m.sub) // re-subscribe
+ }
+ return m, nil
+}
+
+// p.Send approach (from outside the program)
+go func() {
+ p.Send(myMsg{data: "hello"}) // thread-safe
+}()
+```
+
+### Pattern 5: Fullscreen with Alt Screen
+
+Set `AltScreen` in your View. No Program option needed in v2.
+
+```go
+func (m model) View() tea.View {
+ v := tea.NewView(fmt.Sprintf("Fullscreen app! Size: %dx%d", m.width, m.height))
+ v.AltScreen = true
+ return v
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ }
+ return m, nil
+}
+```
+
+## Integration Patterns
+
+### Lip Gloss (Styling)
+
+```go
+import "charm.land/lipgloss/v2"
+
+var style = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(lipgloss.Color("205")).
+ Padding(0, 1)
+
+func (m model) View() tea.View {
+ return tea.NewView(style.Render("styled text"))
+}
+```
+
+### Bubbles (Components)
+
+Standard components: `spinner`, `textinput`, `textarea`, `list`, `table`, `viewport`, `paginator`, `timer`, `stopwatch`, `help`, `key`, `filepicker`, `progress`.
+
+Import: `"charm.land/bubbles/v2/<component>"`
+
+Each bubble follows the same Init/Update/View pattern. See Pattern 3 above.
+
+### Huh (Forms)
+
+```go
+import "charm.land/huh/v2"
+
+// Huh can run standalone or embed in a bubbletea program
+form := huh.NewForm(
+ huh.NewGroup(
+ huh.NewInput().Title("Name").Value(&name),
+ ),
+)
+```
+
+## Common Mistakes
+
+1. **Calling a Cmd instead of passing it.** `Init` returns `tea.Cmd`, not `tea.Msg`. Write `return checkServer` not `return checkServer()`. The runtime calls the function.
+
+2. **Forgetting to return a Cmd from tick handlers.** `tea.Tick` and `tea.Every` fire once. You must return a new tick command in your Update handler to keep ticking.
+
+3. **Mutating the model outside Update.** The Elm Architecture requires all state changes go through Update. Don't share model pointers with goroutines. Use `p.Send()` or channel-based Cmds to communicate.
+
+4. **Not handling WindowSizeMsg.** It's sent on startup and every resize. If you do any layout math, store the dimensions and use them in View.
+
+5. **Using fmt.Println for output.** stdout is owned by the TUI. Use `tea.LogToFile()` for debug logging. Use `tea.Println()` / `tea.Printf()` to print above the TUI.
+
+6. **Returning `tea.Batch()` with nil commands.** This is safe - `tea.Batch` filters nil commands - but returning a plain `nil` is cleaner when you have no commands.
+
+7. **AltScreen in v2 is a View property, not a Program option.** Set `v.AltScreen = true` in your View method. Same for mouse mode - set `v.MouseMode` in View.
+
+8. **Blocking in Update.** Update must return quickly. Any I/O (HTTP calls, file reads, sleeps) belongs in a Cmd, not directly in Update.
+
+9. **Not handling ctrl+c.** The terminal is in raw mode - ctrl+c won't kill your app automatically. Always match `"ctrl+c"` in your KeyPressMsg handler and return `tea.Quit` or `tea.Interrupt`.
+
+## Checklist
+
+- [ ] Model implements `Init() tea.Cmd`, `Update(tea.Msg) (tea.Model, tea.Cmd)`, `View() tea.View`
+- [ ] View returns `tea.NewView(s)`, not a raw string
+- [ ] ctrl+c / q handling exists in Update
+- [ ] WindowSizeMsg handled if doing any layout
+- [ ] All I/O in Cmds, never in Update
+- [ ] Tick commands re-dispatched in Update handler
+- [ ] Child component updates collected with `tea.Batch(cmds...)`
+- [ ] No `fmt.Println` - use `tea.LogToFile` for debugging
+- [ ] AltScreen and MouseMode set in View, not as Program options
+
+## Reference
+
+For full message/type/constant listings, see [references/api.md](references/api.md).
@@ -0,0 +1,189 @@
+# Bubble Tea v2 - Full API Reference
+
+Import: `tea "charm.land/bubbletea/v2"`
+
+## Key Constants
+
+Special keys (use with `Key.Code`):
+
+```
+KeyUp, KeyDown, KeyLeft, KeyRight, KeyHome, KeyEnd, KeyPgUp, KeyPgDown
+KeyInsert, KeyDelete, KeyFind, KeySelect, KeyBegin
+KeyBackspace, KeyTab, KeyEnter, KeyReturn, KeyEscape, KeyEsc, KeySpace
+KeyF1 through KeyF63
+KeyCapsLock, KeyScrollLock, KeyNumLock, KeyPrintScreen, KeyPause, KeyMenu
+```
+
+Keypad keys: `KeyKpEnter`, `KeyKp0`-`KeyKp9`, `KeyKpPlus`, `KeyKpMinus`, `KeyKpMultiply`, `KeyKpDivide`, `KeyKpDecimal`, `KeyKpEqual`, `KeyKpComma`, `KeyKpSep`
+
+Modifier keys: `KeyLeftShift`, `KeyRightShift`, `KeyLeftAlt`, `KeyRightAlt`, `KeyLeftCtrl`, `KeyRightCtrl`, `KeyLeftSuper`, `KeyRightSuper`, `KeyLeftMeta`, `KeyRightMeta`, `KeyLeftHyper`, `KeyRightHyper`
+
+## Modifier Constants
+
+```go
+ModShift // Shift key
+ModAlt // Alt/Option key
+ModCtrl // Control key
+ModMeta // Meta key
+ModHyper // Hyper key
+ModSuper // Super/Windows/Command key
+ModCapsLock // Caps Lock state
+ModNumLock // Num Lock state
+```
+
+## Key Struct
+
+```go
+type Key struct {
+ Text string // printable character(s) - empty for special keys
+ Mod KeyMod // modifier keys bitmask
+ Code rune // key code (e.g. KeyEnter, 'a')
+ ShiftedCode rune // shifted key (Kitty protocol only)
+ BaseCode rune // base key per US layout (Kitty protocol only)
+ IsRepeat bool // key repeat (Kitty protocol only)
+}
+```
+
+Methods: `String()`, `Keystroke()`
+
+String() returns the text if available, otherwise falls back to Keystroke().
+Keystroke() returns modifier+key format: `"ctrl+shift+alt+a"` (modifiers always in this order: ctrl, alt, shift, meta, hyper, super).
+
+## Mouse Constants
+
+```go
+MouseNone, MouseLeft, MouseMiddle, MouseRight
+MouseWheelUp, MouseWheelDown, MouseWheelLeft, MouseWheelRight
+MouseBackward, MouseForward, MouseButton10, MouseButton11
+```
+
+## Mouse Struct
+
+```go
+type Mouse struct {
+ X, Y int // zero-based position, (0,0) = top-left
+ Button MouseButton
+ Mod KeyMod
+}
+```
+
+## Mouse Messages
+
+- `MouseClickMsg` - button pressed
+- `MouseReleaseMsg` - button released
+- `MouseWheelMsg` - scroll wheel
+- `MouseMotionMsg` - mouse moved
+
+All satisfy `MouseMsg` interface with `.Mouse() Mouse` and `.String() string`.
+
+## Mouse Modes
+
+```go
+MouseModeNone // disabled (default)
+MouseModeCellMotion // click, release, wheel, drag
+MouseModeAllMotion // all above + movement without buttons
+```
+
+## View Struct
+
+```go
+type View struct {
+ Content string
+ OnMouse func(MouseMsg) Cmd
+ Cursor *Cursor
+ BackgroundColor color.Color
+ ForegroundColor color.Color
+ WindowTitle string
+ ProgressBar *ProgressBar
+ AltScreen bool
+ ReportFocus bool
+ DisableBracketedPasteMode bool
+ MouseMode MouseMode
+ KeyboardEnhancements KeyboardEnhancements
+}
+```
+
+`NewView(s string) View` - create View from string.
+`SetContent(s string)` - set view content.
+
+## Cursor
+
+```go
+type Cursor struct {
+ Position // X, Y int
+ Color color.Color
+ Shape CursorShape // CursorBlock, CursorUnderline, CursorBar
+ Blink bool
+}
+```
+
+`NewCursor(x, y int) *Cursor` - create cursor at position.
+
+## Program Methods
+
+```go
+NewProgram(model Model, opts ...ProgramOption) *Program
+(p *Program) Run() (Model, error)
+(p *Program) Send(msg Msg)
+(p *Program) Quit()
+(p *Program) Kill()
+(p *Program) Wait()
+```
+
+## Error Variables
+
+```go
+ErrProgramPanic // recovered from panic
+ErrProgramKilled // Kill() was called
+ErrInterrupted // SIGINT or InterruptMsg
+```
+
+## Clipboard
+
+```go
+SetClipboard(s string) Cmd // set system clipboard
+ReadClipboard() Msg // request clipboard content
+SetPrimaryClipboard(s string) Cmd // X11/Wayland primary
+ReadPrimaryClipboard() Msg // X11/Wayland primary
+```
+
+`ClipboardMsg` - received clipboard content. Fields: `Content string`, `Selection byte`.
+
+## Color Queries
+
+```go
+RequestBackgroundColor() Msg // returns BackgroundColorMsg
+RequestForegroundColor() Msg // returns ForegroundColorMsg
+RequestCursorColor() Msg // returns CursorColorMsg
+```
+
+Each response has `IsDark() bool` and `String() string`.
+
+## Progress Bar
+
+```go
+type ProgressBar struct {
+ State ProgressBarState // ProgressBarNone/Default/Error/Indeterminate/Warning
+ Value int // 0-100
+}
+
+NewProgressBar(state ProgressBarState, value int) *ProgressBar
+```
+
+## Logging
+
+```go
+LogToFile(path, prefix string) (*os.File, error)
+LogToFileWith(path, prefix string, log LogOptionsSetter) (*os.File, error)
+```
+
+## Exec
+
+```go
+Exec(cmd ExecCommand, fn ExecCallback) Cmd
+ExecProcess(cmd *exec.Cmd, fn ExecCallback) Cmd
+
+type ExecCallback func(error) Msg
+```
+
+Pauses the TUI, runs external process (e.g. vim), resumes on completion.
@@ -0,0 +1,270 @@
+---
+name: charm-ecosystem
+description: "Architect's guide to the charmbracelet Go TUI ecosystem - which libraries to combine, dependency hierarchy, integration patterns. Use when choosing charm libraries, planning TUI architecture, combining bubbletea/lipgloss/bubbles/huh, or asking 'which charm library for X'. NOT for specific library API details (use individual charm-* skills)."
+argument-hint: "[what you're building or which libraries to combine]"
+---
+
+# Charm Ecosystem
+
+The architect's guide to charmbracelet's terminal UI ecosystem. Individual charm-* skills teach HOW to use each library. This skill teaches WHICH libraries to combine and WHY.
+
+**$ARGUMENTS context**: If a specific project type or library question is mentioned, focus on that decision path.
+
+## Ecosystem Map
+
+### Go Libraries (code you import)
+
+| Library | Import Path | Role |
+|---------|-------------|------|
+| **bubbletea** | `charm.land/bubbletea/v2` | TUI framework - Elm architecture (Model/Update/View) |
+| **lipgloss** | `charm.land/lipgloss/v2` | Terminal styling - colors, borders, layout, tables, lists, trees |
+| **bubbles** | `charm.land/bubbles/v2/*` | Pre-built TUI components - spinner, textinput, list, table, viewport |
+| **huh** | `charm.land/huh/v2` | Interactive forms - input, select, confirm, validation |
+| **glamour** | `charm.land/glamour/v2` | Markdown-to-ANSI rendering |
+| **harmonica** | `github.com/charmbracelet/harmonica` | Physics-based animation - spring oscillator, projectile |
+| **ultraviolet** | `github.com/charmbracelet/ultraviolet` | Low-level terminal primitives - cell buffers, screen management |
+| **fang** | `charm.land/fang/v2` | Cobra wrapper - styled help, auto versioning, manpages |
+
+### CLI Tools (binaries you run)
+
+| Tool | Role |
+|------|------|
+| **gum** | Shell script UI - prompts, filters, spinners, styled output |
+| **glow** | Markdown viewer - CLI and TUI browser |
+| **vhs** | Terminal demo recorder - .tape scripts to GIF/MP4 |
+| **freeze** | Code/terminal screenshot - PNG/SVG/WebP |
+| **pop** | Send email from terminal - TUI and CLI modes |
+
+### Dependency Hierarchy
+
+```
+ultraviolet (cell-level primitives)
+ |
+ +-- lipgloss v2 (styling, layout, composition)
+ |
+ +-- bubbletea v2 (framework: Elm architecture, commands)
+ |
+ +-- bubbles v2 (components: spinner, list, table, viewport...)
+ | |
+ | +-- harmonica (physics animations, optional addition to any bubbletea app)
+ | +-- lipgloss v2 (component styling)
+ |
+ +-- huh v2 (forms, standalone or embedded in bubbletea)
+ |
+ +-- bubbles v2 (huh uses bubbles internally)
+ +-- lipgloss v2 (theming)
+```
+
+Go libraries: `charm.land/*` vanity imports. CLI tools: `github.com/charmbracelet/*` or Homebrew.
+
+## Decision Tree
+
+### "I want to build a full TUI app"
+
+**Use: bubbletea + lipgloss + bubbles**
+
+The standard stack. Bubbletea provides the Elm architecture runtime, lipgloss handles all styling and layout, bubbles gives you pre-built components (list, table, viewport, spinner, textinput, progress, etc).
+
+Add huh if you need form flows. Add harmonica if you need animated transitions.
+
+### "I want shell script UI / interactive bash prompts"
+
+**Use: gum**
+
+No Go code needed. Gum provides choose, filter, input, confirm, spin, style, join, file, pager, table, log. All output to stdout for shell capture.
+
+### "I want terminal forms"
+
+**Go app: huh** (standalone or embedded in bubbletea)
+**Shell script: gum** (input, choose, confirm commands)
+
+Huh runs standalone with `.Run()` for simple cases, or embeds in bubbletea as a `tea.Model` for complex apps. Use gum when you're in bash/zsh and don't want to write Go.
+
+### "I want to render markdown in the terminal"
+
+**Programmatically (Go code): glamour**
+**View files from CLI: glow**
+
+Glamour is a library - import it, call `glamour.Render()`. Glow is a tool - run `glow README.md`.
+
+### "I want animations in my TUI"
+
+**Use: harmonica + bubbletea**
+
+Harmonica provides spring (damped oscillator) and projectile physics. Drive with `tea.Tick` at 60fps. Create `NewSpring` once, call `Update` per frame.
+
+### "I want an SSH-accessible TUI"
+
+**Use: bubbletea + wish**
+
+Wish (not covered by individual skills) wraps bubbletea for SSH serving. Wish provides SSH middleware that wraps your bubbletea handler per-connection. See the wish repo for setup.
+
+### "I want to record terminal demos"
+
+**Use: vhs** (optionally with gum for the demo content)
+
+Write a `.tape` script with Type/Enter/Sleep/Wait commands. VHS renders to GIF/MP4/WebM. Use `gum` commands in your tape for pretty interactive demos.
+
+### "I want terminal screenshots"
+
+**Use: freeze**
+
+Pipe code or use `--execute` to capture command output. Outputs PNG/SVG/WebP with syntax highlighting, window decorations, shadows.
+
+### "I want a CLI framework with styled help"
+
+**Use: fang + cobra**
+
+Fang wraps Cobra - you still write `*cobra.Command` structs. Fang adds lipgloss-styled help, auto version from build info, manpage generation, signal handling.
+
+### "I want to send emails from the terminal"
+
+**Use: pop**
+
+TUI and CLI modes. Supports SMTP and Resend API. Pipe body from stdin, attach files.
+
+### "I need cell-level terminal control"
+
+**Use: ultraviolet** (only if bubbletea is insufficient)
+
+Low-level primitives: cell buffers, screen management, input decoding. Powers bubbletea and lipgloss internally. Unstable API. Only use for custom renderers or framework-building.
+
+## Architecture Patterns
+
+### The Elm Architecture for Go Developers
+
+Bubbletea follows The Elm Architecture (TEA), a unidirectional data flow:
+
+1. **Model** - a struct holding ALL application state (like a Redux store)
+2. **Update(msg) -> (model, cmd)** - pure function: receives a message, returns new state + side effect
+3. **View(model) -> string** - pure function: renders state to terminal output
+4. **Cmd** - a function that performs I/O and returns a Msg back to Update
+
+The runtime owns the loop. You never mutate state outside Update, never call View yourself, never do I/O in Update.
+
+**Go-specific mental model**: think of it like an HTTP handler. The request is a `Msg`, the response is the new model + a `Cmd` to run next. The framework calls your handler, not the other way around.
+
+### Parent-Child Component Composition
+
+Children are just fields on the parent model. Each child has its own Update/View. Parent delegates:
+
+```go
+type parent struct {
+ header textinput.Model
+ body viewport.Model
+ footer help.Model
+}
+
+func (p parent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+
+ // Forward to all children, collect commands
+ var cmd tea.Cmd
+ p.header, cmd = p.header.Update(msg)
+ cmds = append(cmds, cmd)
+
+ p.body, cmd = p.body.Update(msg)
+ cmds = append(cmds, cmd)
+
+ return p, tea.Batch(cmds...)
+}
+```
+
+Children self-filter messages (spinners ignore wrong IDs, unfocused inputs ignore keys). Forward everything, let children decide.
+
+### Message Passing Between Components
+
+Components communicate through the parent via typed messages:
+
+```go
+// Child emits a "done" message
+type formDoneMsg struct{ name string }
+
+// Parent catches it and routes to another child
+case formDoneMsg:
+ m.body.SetContent("Welcome, " + msg.name)
+ m.state = stateMain
+```
+
+No direct child-to-child communication. Always go through the parent's Update.
+
+### When to Use What
+
+| Scenario | Use |
+|----------|-----|
+| Full interactive TUI with state | bubbletea + bubbles + lipgloss |
+| Simple form/prompt then exit | huh standalone (`.Run()`) |
+| Form inside a larger TUI | huh embedded in bubbletea |
+| One-off styled output (no interaction) | lipgloss only |
+| Shell script needs user input | gum |
+| Quick markdown preview | glow CLI |
+| Markdown in a Go app | glamour (+ viewport for scrolling) |
+
+## v2 Migration Quick Reference
+
+The ecosystem moved from `github.com/charmbracelet/*` to `charm.land/*` vanity imports.
+
+### bubbletea v1 -> v2
+
+| v1 | v2 |
+|----|-----|
+| `github.com/charmbracelet/bubbletea` | `charm.land/bubbletea/v2` |
+| `View() string` | `View() tea.View` (use `tea.NewView(s)`) |
+| `tea.KeyMsg` | `tea.KeyPressMsg` |
+| `tea.WithAltScreen()` program option | `v.AltScreen = true` in View |
+| `tea.WithMouseCellMotion()` | `v.MouseMode = tea.MouseModeCellMotion` in View |
+| `tea.WindowSizeMsg` `Width`/`Height` | Same, unchanged |
+
+### lipgloss v1 -> v2
+
+| v1 | v2 |
+|----|-----|
+| `github.com/charmbracelet/lipgloss` | `charm.land/lipgloss/v2` |
+| `lipgloss.Color("#F00")` is a type | `lipgloss.Color("#F00")` is a function returning `color.Color` |
+| `lipgloss.AdaptiveColor{}` | `lipgloss.LightDark(hasDark)` |
+| `style.Copy()` | Just assign (value type) |
+| `lipgloss.NewRenderer()` | Removed. No renderers in v2. |
+| Sub-packages at github path | `charm.land/lipgloss/v2/{table,list,tree}` |
+
+### bubbles v1 -> v2
+
+| v1 | v2 |
+|----|-----|
+| `github.com/charmbracelet/bubbles/*` | `charm.land/bubbles/v2/*` |
+| `tea.KeyMsg` matching | `tea.KeyPressMsg` + `key.Matches()` |
+
+### huh v1 -> v2
+
+| v1 | v2 |
+|----|-----|
+| `github.com/charmbracelet/huh` | `charm.land/huh/v2` |
+| `huh.NewTheme()` | `huh.ThemeFunc(fn)` |
+
+### glamour v1 -> v2
+
+| v1 | v2 |
+|----|-----|
+| `github.com/charmbracelet/glamour` | `charm.land/glamour/v2` |
+| `WithAutoStyle()` | Removed. Use `WithStandardStyle("dark")` |
+| Auto color detection | Pure output. Use lipgloss for downsampling. |
+
+## Integration Cookbook
+
+Five multi-library combinations with working code. See [references/cookbook.md](references/cookbook.md):
+
+1. **Standard TUI app** - bubbletea + lipgloss + bubbles (list with styled header/footer)
+2. **Forms in TUI** - bubbletea + huh (form then result display)
+3. **Markdown viewer** - bubbletea + glamour + viewport (scrollable rendered markdown)
+4. **Animated transitions** - bubbletea + harmonica (spring-animated position)
+5. **Scripted demo** - gum + vhs (tape file demoing a gum script)
+
+## Checklist
+
+- [ ] Import paths use `charm.land/*/v2` (not `github.com/charmbracelet/*`)
+- [ ] harmonica still uses `github.com/charmbracelet/harmonica` (no vanity import)
+- [ ] ultraviolet still uses `github.com/charmbracelet/ultraviolet` (no vanity import)
+- [ ] View returns `tea.NewView(s)` not raw string (bubbletea v2)
+- [ ] AltScreen/MouseMode set in View, not as Program options (bubbletea v2)
+- [ ] Colors use `lipgloss.Color()` function, not type literal (lipgloss v2)
+- [ ] WindowSizeMsg handled for responsive layout
+- [ ] huh forms embedded via Init/Update/View, not `.Run()`, when inside bubbletea
@@ -0,0 +1,430 @@
+# Integration Cookbook
+
+Working multi-library code examples. Import paths verified against go.mod.
+
+## 1. Standard TUI App (bubbletea + lipgloss + bubbles)
+
+A list with styled header and footer. The three core libraries working together.
+
+```go
+package main
+
+import (
+ "fmt"
+ "os"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/bubbles/v2/list"
+ "charm.land/lipgloss/v2"
+)
+
+var (
+ titleStyle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(lipgloss.Color("#FAFAFA")).
+ Background(lipgloss.Color("#7D56F4")).
+ Padding(0, 1)
+
+ statusStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("241"))
+)
+
+type item string
+
+func (i item) FilterValue() string { return string(i) }
+func (i item) Title() string { return string(i) }
+func (i item) Description() string { return "" }
+
+type model struct {
+ list list.Model
+ width int
+ height int
+}
+
+func initialModel() model {
+ items := []list.Item{
+ item("Buy groceries"),
+ item("Clean the house"),
+ item("Write some Go"),
+ item("Touch grass"),
+ }
+ l := list.New(items, list.NewDefaultDelegate(), 40, 15)
+ l.Title = "Tasks"
+ return model{list: l}
+}
+
+func (m model) Init() tea.Cmd { return nil }
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyPressMsg:
+ if msg.String() == "q" || msg.String() == "ctrl+c" {
+ return m, tea.Quit
+ }
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ m.list.SetSize(msg.Width, msg.Height-2)
+ }
+
+ var cmd tea.Cmd
+ m.list, cmd = m.list.Update(msg)
+ return m, cmd
+}
+
+func (m model) View() tea.View {
+ header := titleStyle.Render("My App")
+ body := m.list.View()
+ footer := statusStyle.Render("q to quit")
+ v := tea.NewView(fmt.Sprintf("%s\n%s\n%s", header, body, footer))
+ v.AltScreen = true
+ return v
+}
+
+func main() {
+ if _, err := tea.NewProgram(initialModel()).Run(); err != nil {
+ fmt.Println("Error:", err)
+ os.Exit(1)
+ }
+}
+```
+
+## 2. Forms Embedded in TUI (bubbletea + huh)
+
+A bubbletea app that shows a huh form, then displays results.
+
+```go
+package main
+
+import (
+ "fmt"
+ "os"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/huh/v2"
+ "charm.land/lipgloss/v2"
+)
+
+type state int
+
+const (
+ stateForm state = iota
+ stateDone
+)
+
+type model struct {
+ form *huh.Form
+ state state
+ name string
+ lang string
+}
+
+func newModel() model {
+ var name, lang string
+
+ form := huh.NewForm(
+ huh.NewGroup(
+ huh.NewInput().
+ Key("name").
+ Title("Your name").
+ Value(&name),
+ huh.NewSelect[string]().
+ Key("lang").
+ Title("Favorite language").
+ Options(huh.NewOptions("Go", "Rust", "Python", "TypeScript")...).
+ Value(&lang),
+ ),
+ )
+
+ return model{form: form, name: name, lang: lang}
+}
+
+func (m model) Init() tea.Cmd {
+ return m.form.Init()
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyPressMsg:
+ if msg.String() == "ctrl+c" {
+ return m, tea.Quit
+ }
+ }
+
+ form, cmd := m.form.Update(msg)
+ if f, ok := form.(*huh.Form); ok {
+ m.form = f
+ }
+
+ if m.form.State == huh.StateCompleted {
+ m.state = stateDone
+ m.name = m.form.GetString("name")
+ m.lang = m.form.GetString("lang")
+ return m, tea.Quit
+ }
+
+ return m, cmd
+}
+
+func (m model) View() tea.View {
+ if m.state == stateDone {
+ result := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")).
+ Render(fmt.Sprintf("%s loves %s!", m.name, m.lang))
+ return tea.NewView(result + "\n")
+ }
+ return tea.NewView(m.form.View())
+}
+
+func main() {
+ if _, err := tea.NewProgram(newModel()).Run(); err != nil {
+ fmt.Println("Error:", err)
+ os.Exit(1)
+ }
+}
+```
+
+## 3. Scrollable Markdown Viewer (bubbletea + glamour + viewport)
+
+Renders markdown with glamour, displays in a scrollable viewport.
+
+```go
+package main
+
+import (
+ "fmt"
+ "os"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/bubbles/v2/viewport"
+ "charm.land/glamour/v2"
+ "charm.land/lipgloss/v2"
+)
+
+const sampleMD = `# Welcome
+
+This is a **scrollable** markdown viewer built with:
+
+- Bubble Tea (framework)
+- Glamour (markdown rendering)
+- Bubbles viewport (scrolling)
+
+## Features
+
+Scroll with j/k or arrow keys. Press q to quit.
+
+> "The terminal is the ultimate UI." - someone, probably
+
+## Code Example
+
+` + "```go" + `
+fmt.Println("Hello from glamour!")
+` + "```" + `
+
+More content here to make it scrollable...
+`
+
+type model struct {
+ viewport viewport.Model
+ rawMarkdown string
+ ready bool
+}
+
+func (m model) Init() tea.Cmd { return nil }
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyPressMsg:
+ if msg.String() == "q" || msg.String() == "ctrl+c" {
+ return m, tea.Quit
+ }
+ case tea.WindowSizeMsg:
+ if !m.ready {
+ m.viewport = viewport.New(
+ viewport.WithWidth(msg.Width),
+ viewport.WithHeight(msg.Height-2),
+ )
+ m.ready = true
+ } else {
+ m.viewport.SetWidth(msg.Width)
+ m.viewport.SetHeight(msg.Height - 2)
+ }
+
+ r, _ := glamour.NewTermRenderer(
+ glamour.WithStandardStyle("dark"),
+ glamour.WithWordWrap(msg.Width-4),
+ )
+ rendered, _ := r.Render(m.rawMarkdown)
+ m.viewport.SetContent(rendered)
+ }
+
+ var cmd tea.Cmd
+ m.viewport, cmd = m.viewport.Update(msg)
+ return m, cmd
+}
+
+func (m model) View() tea.View {
+ if !m.ready {
+ return tea.NewView("Loading...")
+ }
+
+ header := lipgloss.NewStyle().Bold(true).
+ Foreground(lipgloss.Color("205")).
+ Render("Markdown Viewer")
+ footer := lipgloss.NewStyle().
+ Foreground(lipgloss.Color("241")).
+ Render(fmt.Sprintf("scroll: %d%%", int(m.viewport.ScrollPercent()*100)))
+
+ v := tea.NewView(header + "\n" + m.viewport.View() + "\n" + footer)
+ v.AltScreen = true
+ return v
+}
+
+func main() {
+ m := model{rawMarkdown: sampleMD}
+ if _, err := tea.NewProgram(m).Run(); err != nil {
+ fmt.Println("Error:", err)
+ os.Exit(1)
+ }
+}
+```
+
+## 4. Animated Transitions (bubbletea + harmonica)
+
+Spring-animated horizontal bar that follows a target position.
+
+```go
+package main
+
+import (
+ "fmt"
+ "math"
+ "os"
+ "strings"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/harmonica"
+)
+
+const fps = 60
+
+type frameMsg time.Time
+
+func animate() tea.Cmd {
+ return tea.Tick(time.Second/fps, func(t time.Time) tea.Msg {
+ return frameMsg(t)
+ })
+}
+
+type model struct {
+ spring harmonica.Spring
+ x float64
+ xVel float64
+ target float64
+ width int
+}
+
+func (m model) Init() tea.Cmd { return animate() }
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyPressMsg:
+ switch msg.String() {
+ case "q", "ctrl+c":
+ return m, tea.Quit
+ case "left":
+ m.target = float64(m.width) * 0.2
+ case "right":
+ m.target = float64(m.width) * 0.8
+ case "space":
+ m.target = float64(m.width) * 0.5
+ }
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.target = float64(msg.Width) * 0.5
+ case frameMsg:
+ m.x, m.xVel = m.spring.Update(m.x, m.xVel, m.target)
+
+ if math.Abs(m.x-m.target) < 0.01 && math.Abs(m.xVel) < 0.01 {
+ return m, nil // stop animating when settled
+ }
+ return m, animate()
+ }
+ return m, nil
+}
+
+func (m model) View() tea.View {
+ if m.width == 0 {
+ return tea.NewView("")
+ }
+
+ pos := int(m.x)
+ if pos < 0 {
+ pos = 0
+ }
+ if pos >= m.width {
+ pos = m.width - 1
+ }
+
+ bar := lipgloss.NewStyle().
+ Foreground(lipgloss.Color("205")).
+ Bold(true).
+ Render("@")
+
+ line := strings.Repeat(" ", pos) + bar
+ help := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).
+ Render("\nleft/right/space to move, q to quit")
+
+ return tea.NewView(line + help)
+}
+
+func main() {
+ m := model{
+ spring: harmonica.NewSpring(harmonica.FPS(fps), 7.0, 0.35),
+ }
+ if _, err := tea.NewProgram(m).Run(); err != nil {
+ fmt.Println("Error:", err)
+ os.Exit(1)
+ }
+}
+```
+
+## 5. Scripted Demo Recording (gum + vhs)
+
+A VHS tape that demos a gum-powered script.
+
+```tape
+Output demo.gif
+
+Set FontSize 14
+Set Width 1000
+Set Height 500
+Set Theme "Catppuccin Frappe"
+Set WindowBar Colorful
+Set TypingSpeed 0.06
+Set Framerate 30
+
+Require gum
+
+# Show a styled banner
+Type `gum style --foreground 212 --border rounded --padding "1 2" "Deploy Helper"`
+Enter
+Sleep 1s
+
+# Choose environment
+Type `ENV=$(gum choose "staging" "production")`
+Enter
+Sleep 500ms
+Down
+Sleep 300ms
+Enter
+Sleep 1s
+
+# Confirm
+Type `gum confirm "Deploy to $ENV?" && echo "Deploying..." || echo "Cancelled"`
+Enter
+Sleep 500ms
+Enter
+Sleep 2s
+```
@@ -0,0 +1,214 @@
+---
+name: charm-fang
+description: "Wrap Cobra with styled help, error output, auto versioning, and manpage generation via fang. Use when building Go CLIs with fang, styled Cobra help, or adding lipgloss-rendered help pages to a Go CLI."
+---
+
+# charm-fang
+
+Fang is NOT a Cobra replacement - it wraps Cobra. You still write `*cobra.Command` structs exactly as you would with plain Cobra. Fang intercepts execution to add:
+
+- styled help/usage output (lipgloss-rendered)
+- styled error output with an `ERROR` header block
+- auto `--version` from build info or a string you provide
+- hidden `man` subcommand (manpage via mango/roff)
+- `completion` subcommand for shell completions
+- `SilenceUsage = true` by default (no help dump on error)
+- signal handling via `WithNotifySignal`
+
+## Install
+
+```bash
+go get charm.land/fang/v2
+```
+
+Import path (v2, note the vanity domain):
+
+```go
+import "charm.land/fang/v2"
+```
+
+## Minimal App
+
+```go
+package main
+
+import (
+ "context"
+ "os"
+
+ "charm.land/fang/v2"
+ "github.com/spf13/cobra"
+)
+
+func main() {
+ root := &cobra.Command{
+ Use: "myapp",
+ Short: "Does something useful",
+ }
+ if err := fang.Execute(context.Background(), root); err != nil {
+ os.Exit(1)
+ }
+}
+```
+
+That's the full swap from `root.Execute()` to `fang.Execute()`.
+
+## Complete CLI Skeleton
+
+```go
+package main
+
+import (
+ "context"
+ "os"
+
+ "charm.land/fang/v2"
+ "github.com/spf13/cobra"
+)
+
+func main() {
+ var name string
+ var verbose bool
+
+ root := &cobra.Command{
+ Use: "myapp [flags]",
+ Short: "One-line description",
+ Long: "Longer description shown in full help.",
+ Version: "1.0.0", // overridden by fang if you use WithVersion
+ Example: `
+ # basic usage
+ myapp --name alice
+
+ # with subcommand
+ myapp greet --name alice`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cmd.Printf("Hello, %s\n", name)
+ return nil
+ },
+ }
+
+ // persistent flags available to all subcommands
+ root.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
+ // local flags for root only
+ root.Flags().StringVar(&name, "name", "world", "name to greet")
+
+ // subcommand
+ greet := &cobra.Command{
+ Use: "greet",
+ Short: "Greet someone",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cmd.Println("greet subcommand")
+ return nil
+ },
+ }
+ root.AddCommand(greet)
+
+ if err := fang.Execute(
+ context.Background(),
+ root,
+ fang.WithVersion("1.2.3"),
+ fang.WithCommit("abc1234"),
+ fang.WithNotifySignal(os.Interrupt),
+ ); err != nil {
+ os.Exit(1)
+ }
+}
+```
+
+## Options Reference
+
+| Option | Effect |
+|--------|--------|
+| `WithVersion(v string)` | Sets version string shown by `--version` |
+| `WithCommit(sha string)` | Appends short commit SHA to version |
+| `WithoutVersion()` | Disables `-v`/`--version` entirely |
+| `WithoutCompletions()` | Removes the `completion` subcommand |
+| `WithoutManpage()` | Removes the hidden `man` subcommand |
+| `WithNotifySignal(signals...)` | Cancels context on given OS signals |
+| `WithColorSchemeFunc(fn)` | Custom theme, light/dark-adaptive |
+| `WithErrorHandler(fn)` | Custom error rendering |
+
+If no `WithVersion` is passed, fang reads `debug.ReadBuildInfo()` automatically (works when installed via `go install`).
+
+## Custom Theme
+
+```go
+import "charm.land/lipgloss/v2"
+
+fang.Execute(ctx, root, fang.WithColorSchemeFunc(func(ld lipgloss.LightDarkFunc) fang.ColorScheme {
+ return fang.ColorScheme{
+ Title: ld(lipgloss.Color("#FF6B6B"), lipgloss.Color("#4ECDC4")),
+ Flag: lipgloss.Color("#0CB37F"),
+ Command: lipgloss.Color("#A550DF"),
+ // ... other fields
+ }
+}))
+```
+
+`LightDarkFunc` lets you return different colors based on terminal background. Use `fang.DefaultColorScheme` or `fang.AnsiColorScheme` as a reference.
+
+## Differences from Plain Cobra
+
+| Cobra | Fang |
+|-------|------|
+| `root.Execute()` | `fang.Execute(ctx, root, opts...)` |
+| plain text help | lipgloss-styled help |
+| errors printed raw | styled error block |
+| manual signal handling | `WithNotifySignal` |
+| manual manpage setup | built-in hidden `man` cmd |
+| `SilenceUsage` off by default | on by default |
+| no version auto-detect | reads build info automatically |
+
+## Flag Patterns (Cobra, unchanged)
+
+```go
+// string flag with short form
+cmd.Flags().StringVarP(&val, "name", "n", "default", "description")
+
+// persistent (inherited by subcommands)
+cmd.PersistentFlags().BoolVar(&debug, "debug", false, "enable debug")
+
+// hidden flag
+cmd.Flags().String("internal", "", "internal use")
+_ = cmd.Flags().MarkHidden("internal")
+
+// required flag
+cmd.Flags().String("config", "", "config file")
+_ = cmd.MarkFlagRequired("config")
+```
+
+## Command Groups
+
+```go
+root.AddGroup(&cobra.Group{
+ ID: "core",
+ Title: "Core Commands",
+})
+sub.GroupID = "core"
+```
+
+Groups show as sections in fang's styled help output.
+
+## Context Usage
+
+Subcommands get the context fang passes (with signal cancellation if configured):
+
+```go
+RunE: func(cmd *cobra.Command, args []string) error {
+ select {
+ case <-time.After(5 * time.Second):
+ return nil
+ case <-cmd.Context().Done():
+ return cmd.Context().Err()
+ }
+},
+```
+
+## go.mod
+
+```
+require (
+ charm.land/fang/v2 v2.x.x
+ github.com/spf13/cobra v1.x.x
+)
+```
@@ -0,0 +1,161 @@
+---
+name: charm-freeze
+description: "Generate PNG, SVG, or WebP screenshots of code and terminal output with freeze. Use when screenshotting code, freeze, terminal-to-image, or capturing styled code snippets as images."
+---
+
+# charm-freeze
+
+Generate images of code and terminal output using `freeze`.
+
+## Basic Usage
+
+```bash
+# screenshot a file (auto-detects language)
+freeze main.go -o main.png
+
+# pipe code in
+cat artichoke.hs | freeze -o out.svg
+
+# capture live terminal command output
+freeze --execute "eza -lah" -o eza.png
+```
+
+## Output Formats
+
+Default output is `freeze.png`. Supports `.svg`, `.png`, `.webp`.
+
+```bash
+freeze main.go -o out.svg
+freeze main.go -o out.png
+freeze main.go -o out.webp
+
+# all at once
+freeze main.go -o out.{svg,png,webp}
+```
+
+If piped (e.g. `freeze main.go > out.svg`), outputs to stdout.
+
+## Language Detection
+
+Auto-detects from filename or content. Override with `--language`:
+
+```bash
+cat script.sh | freeze --language bash
+freeze file.txt --language python
+```
+
+## Themes
+
+```bash
+freeze main.go --theme dracula
+freeze main.go --theme charm # default
+freeze main.go --theme github
+```
+
+## Fonts
+
+Defaults: JetBrains Mono, 14px, 1.2 line-height.
+
+```bash
+freeze main.go \
+ --font.family "SF Mono" \
+ --font.size 16 \
+ --line-height 1.4
+
+# embed a font file (TTF, WOFF, WOFF2)
+freeze main.go --font.file ./FiraCode.ttf
+
+# enable ligatures
+freeze main.go --font.ligatures
+```
+
+## Decorations
+
+```bash
+# window controls (macOS style)
+freeze main.go --window
+
+# rounded corners
+freeze main.go --border.radius 8
+
+# border outline
+freeze main.go --border.width 1 --border.color "#515151" --border.radius 8
+
+# drop shadow
+freeze main.go --shadow.blur 20 --shadow.x 0 --shadow.y 10
+
+# background color
+freeze main.go --background "#08163f"
+```
+
+## Layout
+
+```bash
+# padding (1, 2, or 4 values)
+freeze main.go --padding 20
+freeze main.go --padding 20,40
+freeze main.go --padding 20,60,20,40 # top right bottom left
+
+# margin (same syntax)
+freeze main.go --margin 20,40
+
+# fixed height
+freeze main.go --height 400
+```
+
+## Line Numbers
+
+```bash
+freeze main.go --show-line-numbers
+
+# capture specific lines only
+freeze main.go --show-line-numbers --lines 10,25
+```
+
+## Configurations
+
+Three built-in presets:
+
+```bash
+freeze -c base main.go # minimal
+freeze -c full main.go # macOS-like, shadow + window controls
+freeze -c user main.go # your saved config (~/.config/freeze/user.json)
+
+# custom JSON config
+freeze -c ./custom.json main.go
+```
+
+Example `custom.json`:
+
+```json
+{
+ "window": true,
+ "border": { "radius": 8, "width": 1, "color": "#515151" },
+ "shadow": { "blur": 20, "x": 0, "y": 10 },
+ "padding": [20, 40, 20, 20],
+ "font": { "family": "JetBrains Mono", "size": 14 },
+ "line_height": 1.2
+}
+```
+
+## Interactive Mode
+
+```bash
+freeze --interactive
+```
+
+Opens a TUI to tweak all settings visually. Saves result to `~/.config/freeze/user.json`.
+
+## Screenshot TUIs
+
+Capture a running TUI via tmux:
+
+```bash
+tmux capture-pane -pet 1 | freeze -c full -o helix.png
+```
+
+## Wrap Long Lines
+
+```bash
+freeze main.go --wrap 80
+```
@@ -0,0 +1,401 @@
+---
+name: charm-glamour
+description: "Render markdown to styled ANSI terminal output in Go with glamour v2. Use when rendering markdown programmatically in Go, glamour, terminal markdown rendering, or styled markdown output. NOT for viewing markdown files in terminal (use glow)."
+---
+
+# glamour - Terminal Markdown Rendering
+
+`charm.land/glamour/v2` renders markdown to styled ANSI output. Built on goldmark, supports GFM (tables, task lists, strikethrough), syntax highlighting via Chroma, emoji, and fully customizable stylesheets.
+
+## Quick Start
+
+```bash
+go get charm.land/glamour/v2@latest
+```
+
+### One-liner
+
+```go
+import "charm.land/glamour/v2"
+
+out, err := glamour.Render("# Hello\n\nSome **bold** text.", "dark")
+fmt.Print(out)
+```
+
+### With renderer (reusable)
+
+```go
+r, err := glamour.NewTermRenderer(
+ glamour.WithStandardStyle("dark"),
+ glamour.WithWordWrap(80),
+)
+if err != nil {
+ log.Fatal(err)
+}
+
+out, err := r.Render(markdown)
+fmt.Print(out)
+```
+
+## Core API
+
+### Package-level functions
+
+| Function | Description |
+|---|---|
+| `Render(in, stylePath string) (string, error)` | One-shot render with a style name or file path |
+| `RenderBytes(in []byte, stylePath string) ([]byte, error)` | Same but bytes in/out |
+| `RenderWithEnvironmentConfig(in string) (string, error)` | Uses `GLAMOUR_STYLE` env var, defaults to `"dark"` |
+
+### TermRenderer
+
+Created via `NewTermRenderer(options ...TermRendererOption)`. Reusable for multiple renders.
+
+**Methods:**
+
+| Method | Description |
+|---|---|
+| `Render(in string) (string, error)` | Render markdown string |
+| `RenderBytes(in []byte) ([]byte, error)` | Render markdown bytes |
+| `Write(b []byte) (int, error)` | Implements `io.Writer`, buffer markdown input |
+| `Close() error` | Flush buffered input, call before `Read` |
+| `Read(b []byte) (int, error)` | Implements `io.Reader`, read rendered output |
+
+**io.ReadWriter pattern** (streaming):
+
+```go
+r, _ := glamour.NewTermRenderer(glamour.WithWordWrap(80))
+r.Write([]byte("# Streamed\n\nContent here."))
+r.Close()
+
+rendered, _ := io.ReadAll(r)
+fmt.Print(string(rendered))
+```
+
+### Options
+
+| Option | Description |
+|---|---|
+| `WithStandardStyle(name string)` | Use a built-in style by name |
+| `WithStylePath(path string)` | Style name OR path to JSON file |
+| `WithStyles(cfg ansi.StyleConfig)` | Programmatic style struct |
+| `WithStylesFromJSONBytes(b []byte)` | Parse style from JSON bytes |
+| `WithStylesFromJSONFile(path string)` | Load style from JSON file |
+| `WithEnvironmentConfig()` | Use `GLAMOUR_STYLE` env var |
+| `WithWordWrap(width int)` | Word wrap width (default: 80) |
+| `WithTableWrap(wrap bool)` | Wrap table content (default: true). False truncates with ellipsis |
+| `WithInlineTableLinks(inline bool)` | Render links inline in tables instead of footer list |
+| `WithPreservedNewLines()` | Keep newlines instead of reflowing |
+| `WithEmoji()` | Enable `:emoji_code:` rendering |
+| `WithBaseURL(url string)` | Resolve relative URLs against this base |
+| `WithChromaFormatter(fmt string)` | Set Chroma formatter for code blocks |
+| `WithOptions(opts ...TermRendererOption)` | Combine multiple options |
+
+### Built-in styles
+
+| Constant | String | Use case |
+|---|---|---|
+| `styles.DarkStyle` | `"dark"` | Dark terminal backgrounds (default) |
+| `styles.LightStyle` | `"light"` | Light terminal backgrounds |
+| `styles.DraculaStyle` | `"dracula"` | Dracula color scheme |
+| `styles.TokyoNightStyle` | `"tokyo-night"` | Tokyo Night color scheme |
+| `styles.PinkStyle` | `"pink"` | Pink accent theme |
+| `styles.AsciiStyle` | `"ascii"` | ASCII-only, no unicode box chars |
+| `styles.NoTTYStyle` | `"notty"` | No ANSI codes at all, plain text |
+
+Each has a corresponding `StyleConfig` variable: `styles.DarkStyleConfig`, `styles.LightStyleConfig`, etc.
+
+## Common Patterns
+
+### Custom style (programmatic)
+
+Start from a built-in config and modify fields. Style fields use pointers for optional values.
+
+```go
+import (
+ "charm.land/glamour/v2"
+ "charm.land/glamour/v2/ansi"
+ "charm.land/glamour/v2/styles"
+)
+
+func boolPtr(b bool) *bool { return &b }
+func strPtr(s string) *string { return &s }
+func uintPtr(u uint) *uint { return &u }
+
+func customRenderer() (*glamour.TermRenderer, error) {
+ style := styles.DarkStyleConfig
+
+ // Custom H1: green text, no background
+ style.H1 = ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Color: strPtr("34"),
+ Bold: boolPtr(true),
+ Prefix: "# ",
+ },
+ }
+
+ // Wider margins
+ style.Document.Margin = uintPtr(4)
+
+ // Custom code block theme
+ style.CodeBlock.Theme = "monokai"
+
+ return glamour.NewTermRenderer(
+ glamour.WithStyles(style),
+ glamour.WithWordWrap(100),
+ )
+}
+```
+
+### Custom style (JSON file)
+
+```json
+{
+ "document": {
+ "color": "252",
+ "margin": 2,
+ "block_prefix": "\n",
+ "block_suffix": "\n"
+ },
+ "heading": {
+ "color": "39",
+ "bold": true,
+ "block_suffix": "\n"
+ },
+ "h1": {
+ "color": "228",
+ "background_color": "63",
+ "bold": true,
+ "prefix": " ",
+ "suffix": " "
+ },
+ "h2": {
+ "prefix": "## "
+ },
+ "code_block": {
+ "theme": "dracula",
+ "margin": 2
+ },
+ "link": {
+ "color": "123",
+ "underline": true
+ },
+ "strong": {
+ "bold": true
+ },
+ "emph": {
+ "italic": true
+ }
+}
+```
+
+```go
+r, err := glamour.NewTermRenderer(
+ glamour.WithStylesFromJSONFile("./my-style.json"),
+ glamour.WithWordWrap(80),
+)
+```
+
+### StyleConfig structure reference
+
+```
+StyleConfig
+ Document, BlockQuote, Paragraph -> StyleBlock
+ List -> StyleList (StyleBlock + LevelIndent)
+ Heading, H1-H6 -> StyleBlock
+ Text, Emph, Strong, Strikethrough -> StylePrimitive
+ HorizontalRule -> StylePrimitive (use Format for custom rule)
+ Item, Enumeration -> StylePrimitive (BlockPrefix for bullet char)
+ Task -> StyleTask (Ticked/Unticked strings)
+ Link, LinkText -> StylePrimitive
+ Image, ImageText -> StylePrimitive
+ Code -> StyleBlock (inline code)
+ CodeBlock -> StyleCodeBlock (Theme + Chroma)
+ Table -> StyleTable (separators)
+ DefinitionList/Term/Description -> StyleBlock/StylePrimitive
+ HTMLBlock, HTMLSpan -> StyleBlock
+
+StyleBlock
+ Indent *uint, IndentToken *string, Margin *uint
+ + StylePrimitive (all fields below)
+
+StylePrimitive
+ Color, BackgroundColor *string // ANSI color number or hex "#RRGGBB"
+ Bold, Italic, Underline *bool
+ CrossedOut, Faint *bool
+ Inverse, Conceal, Blink *bool
+ Upper, Lower, Title *bool // text transform
+ Prefix, Suffix string // per-line prefix/suffix
+ BlockPrefix, BlockSuffix string // before/after entire block
+ Format string // Go template, e.g. link format
+```
+
+### Color downsampling with lipgloss (v2)
+
+Glamour v2 is "pure" - same input always gives same output. It does NOT auto-detect terminal color capabilities. Use lipgloss to downsample colors for the actual terminal.
+
+```go
+import (
+ "charm.land/glamour/v2"
+ "charm.land/lipgloss/v2"
+)
+
+r, _ := glamour.NewTermRenderer(glamour.WithWordWrap(80))
+out, _ := r.Render(markdown)
+
+// lipgloss detects terminal capabilities and downsamples
+lipgloss.Print(out)
+```
+
+Alternative with `colorprofile` for explicit control:
+
+```go
+import "github.com/charmbracelet/colorprofile"
+
+w := colorprofile.NewWriter(os.Stdout, os.Environ())
+fmt.Fprintf(w, "%s", out)
+```
+
+### Detect terminal background for style selection
+
+```go
+import "charm.land/lipgloss/v2"
+
+style := "dark"
+if !lipgloss.HasDarkBackground() {
+ style = "light"
+}
+r, _ := glamour.NewTermRenderer(glamour.WithStandardStyle(style))
+```
+
+### Environment-based style
+
+```bash
+export GLAMOUR_STYLE=dracula
+# or a file path:
+export GLAMOUR_STYLE=/path/to/custom.json
+```
+
+```go
+// Picks up GLAMOUR_STYLE, falls back to "dark"
+out, err := glamour.RenderWithEnvironmentConfig(markdown)
+
+// Or with a renderer:
+r, err := glamour.NewTermRenderer(glamour.WithEnvironmentConfig())
+```
+
+## Integration
+
+### Bubbletea viewport (scrollable markdown)
+
+```go
+import (
+ "charm.land/glamour/v2"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/bubbles/v2/viewport"
+)
+
+type model struct {
+ viewport viewport.Model
+ content string
+}
+
+func initialModel(markdown string) model {
+ r, _ := glamour.NewTermRenderer(
+ glamour.WithStandardStyle("dark"),
+ glamour.WithWordWrap(78), // viewport width minus padding
+ )
+ rendered, _ := r.Render(markdown)
+
+ vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(24))
+ vp.SetContent(rendered)
+
+ return model{viewport: vp, content: rendered}
+}
+
+func (m model) Init() tea.Cmd {
+ return nil
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmd tea.Cmd
+ m.viewport, cmd = m.viewport.Update(msg)
+ return m, cmd
+}
+
+func (m model) View() string {
+ return m.viewport.View()
+}
+```
+
+Key point: set `WithWordWrap` to viewport width minus any horizontal padding/margin. Re-render when terminal resizes.
+
+### Re-render on resize
+
+```go
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.viewport.SetWidth(msg.Width)
+ m.viewport.SetHeight(msg.Height)
+
+ r, _ := glamour.NewTermRenderer(
+ glamour.WithStandardStyle("dark"),
+ glamour.WithWordWrap(msg.Width - 2),
+ )
+ rendered, _ := r.Render(m.rawMarkdown)
+ m.viewport.SetContent(rendered)
+ }
+
+ var cmd tea.Cmd
+ m.viewport, cmd = m.viewport.Update(msg)
+ return m, cmd
+}
+```
+
+### Lipgloss styled container around rendered markdown
+
+```go
+import "charm.land/lipgloss/v2"
+
+border := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ Padding(1, 2)
+
+r, _ := glamour.NewTermRenderer(
+ glamour.WithStandardStyle("dark"),
+ glamour.WithWordWrap(76), // account for border + padding (2 border + 4 padding = 6)
+)
+rendered, _ := r.Render(markdown)
+
+fmt.Println(border.Render(rendered))
+```
+
+## Common Mistakes
+
+**Using `WithAutoStyle()` or `WithColorProfile()`** - Removed in v2. Use `WithStandardStyle("dark")` and `lipgloss.Print()` for color handling.
+
+**Not accounting for margin/padding in word wrap** - If you wrap a glamour-rendered block in a lipgloss container with padding/border, subtract that width from the word wrap value or text will overflow.
+
+**Creating a new renderer per render when unnecessary** - `TermRenderer` is reusable. Create once, call `Render()` many times.
+
+**Using `fmt.Print` instead of `lipgloss.Print`** - If colors look wrong on some terminals, you need color downsampling. Use `lipgloss.Print(out)` instead of `fmt.Print(out)`.
+
+**Forgetting `Close()` when using Write/Read pattern** - After writing markdown via `r.Write()`, you must call `r.Close()` before reading with `r.Read()` or `io.ReadAll(r)`.
+
+**Import path still on v1** - v2 uses `charm.land/glamour/v2`, not `github.com/charmbracelet/glamour`.
+
+**Setting style fields directly instead of via pointer** - `Color`, `Bold`, `Italic`, etc. are pointer types. Use helper functions like `func boolPtr(b bool) *bool { return &b }`.
+
+**Using `WithStandardStyle` with a file path** - `WithStandardStyle` only accepts built-in style names. For file paths, use `WithStylePath` or `WithStylesFromJSONFile`.
+
+## Checklist
+
+- [ ] Import `charm.land/glamour/v2` (not the old github path)
+- [ ] Pick a style: built-in name, JSON file, or programmatic `StyleConfig`
+- [ ] Set `WithWordWrap` to match your output width minus borders/padding
+- [ ] Use `lipgloss.Print()` for proper color downsampling on real terminals
+- [ ] For bubbletea: re-render on `WindowSizeMsg` with updated wrap width
+- [ ] Handle errors from `NewTermRenderer` and `Render` (malformed styles, etc.)
+- [ ] For env-based config: use `WithEnvironmentConfig()` or `RenderWithEnvironmentConfig()`
+- [ ] Test with `"notty"` style for CI/non-terminal environments
@@ -0,0 +1,93 @@
+---
+name: charm-glow
+description: "View and browse markdown files in the terminal with glow - CLI and TUI modes, pager, word wrapping, styles. Use when viewing markdown in the terminal, glow, or browsing markdown files from the command line. NOT for rendering markdown programmatically in Go (use glamour)."
+---
+
+# charm-glow
+
+`glow` renders markdown in the terminal with styled output. Has two modes: TUI (browse/stash) and CLI (direct render).
+
+## CLI Usage
+
+```bash
+# Read a local file
+glow README.md
+
+# Read from stdin
+echo "# Hello" | glow -
+
+# Fetch from GitHub/GitLab
+glow github.com/charmbracelet/glow
+
+# Fetch from URL
+glow https://host.tld/file.md
+```
+
+## Pager Mode
+
+```bash
+# Enable pager (defaults to less -r if $PAGER not set)
+glow -p README.md
+```
+
+## Word Wrap
+
+```bash
+# Wrap at N columns
+glow -w 80 README.md
+```
+
+## Styles
+
+```bash
+# Auto-detect from terminal background (default)
+glow README.md
+
+# Force dark or light
+glow -s dark README.md
+glow -s light README.md
+
+# Custom JSON stylesheet (glamour format)
+glow -s mystyle.json README.md
+```
+
+Styles come from [glamour](https://github.com/charmbracelet/glamour/blob/master/styles/gallery/README.md). Custom styles are JSON files following glamour's schema.
+
+## TUI Mode
+
+Run `glow` with no args to open the TUI. It scans the current directory (or git repo root) for `.md` files.
+
+- Navigate with arrow keys / vim keys
+- Open file to enter pager, press `?` for hotkeys
+- Pager uses `less`-compatible keybindings
+
+## Config File
+
+```bash
+glow config # opens $EDITOR
+```
+
+Config lives at platform default path (check `glow --help`). Example `glow.yml`:
+
+```yaml
+style: "auto" # "dark" | "light" | "auto" | path to JSON
+pager: true # always use pager
+width: 80 # word wrap column
+mouse: true # mouse support in TUI
+all: false # show hidden/gitignored files
+showLineNumbers: false
+preserveNewLines: false
+```
+
+## Key Flags Summary
+
+| Flag | Description |
+|------|-------------|
+| `-s` | Style: dark, light, auto, or path to JSON |
+| `-w` | Word wrap width |
+| `-p` | Enable pager |
+
+## Notes
+
+- Stash feature (syncing to Charm Cloud) was removed in v2
+- `glow --help` lists config file location per platform
@@ -0,0 +1,359 @@
+---
+name: charm-gum
+description: "Interactive shell script prompts, fuzzy filters, spinners, and styled output with gum. Use when building bash/shell script UIs, gum commands, interactive shell prompts, or CLI script workflows. NOT for Go terminal forms (use huh)."
+---
+
+# charm-gum
+
+gum is a CLI for glamorous shell scripts. All interaction writes to stdout; capture with `$()`. All commands render to stderr so stdout stays clean for piping.
+
+## Quick Start
+
+```bash
+brew install gum # macOS/Linux
+go install github.com/charmbracelet/gum@latest
+
+NAME=$(gum input --placeholder "your name")
+gum confirm "Continue?" && echo "hello $NAME"
+```
+
+Every flag has an env var equivalent: `--placeholder` = `GUM_INPUT_PLACEHOLDER`. Export env vars to set defaults project-wide.
+
+## Command Reference
+
+### input - single-line prompt
+
+```bash
+gum input [flags]
+# key flags:
+# --placeholder "text" hint text
+# --value "text" pre-filled value
+# --password mask input
+# --header "text" label above input
+# --width N fixed width (0 = terminal width)
+# --char-limit N max chars (default 400, 0 = unlimited)
+# --timeout 30s auto-submit after duration
+
+NAME=$(gum input --placeholder "full name" --header "Enter your name")
+PASS=$(gum input --password --placeholder "password")
+```
+
+### write - multi-line textarea
+
+```bash
+gum write [flags]
+# key flags:
+# --placeholder "text"
+# --header "text"
+# --width N, --height N
+# --show-line-numbers
+# --show-cursor-line
+# --max-lines N
+# ctrl+d to submit, ctrl+c to cancel
+
+BODY=$(gum write --placeholder "PR description..." --header "Description" --width 80)
+```
+
+### choose - pick from a list
+
+```bash
+gum choose [options...] [flags]
+# pipe options or pass as args
+# key flags:
+# --limit N max selectable (default 1)
+# --no-limit unlimited selection
+# --header "text"
+# --height N visible rows (default 10)
+# --cursor "> " cursor prefix
+# --selected "val" pre-selected item
+# --ordered preserve selection order
+# --timeout 30s
+
+TYPE=$(gum choose "fix" "feat" "docs" "chore" "refactor")
+PKGS=$(brew list | gum choose --no-limit --header "Remove packages")
+```
+
+### filter - fuzzy search a list
+
+```bash
+gum filter [options...] [flags]
+# reads from stdin or args; fuzzy match by default
+# key flags:
+# --limit N
+# --no-limit
+# --placeholder "text"
+# --header "text"
+# --height N
+# --value "text" initial filter query
+# --no-fuzzy prefix match only
+# --no-strict return query if no match
+# --reverse render from bottom
+
+SESSION=$(tmux list-sessions -F '#S' | gum filter --placeholder "pick session...")
+BRANCH=$(git branch | cut -c 3- | gum filter --placeholder "checkout...")
+```
+
+### confirm - yes/no prompt
+
+```bash
+gum confirm [prompt] [flags]
+# exits 0 = yes, 1 = no; use with && / ||
+# key flags:
+# --affirmative "Yes" confirm button label
+# --negative "No" cancel button label
+# --default which is pre-selected (true = yes)
+# --timeout 30s
+# --show-output echo chosen action to stdout
+
+gum confirm "Delete branch?" && git branch -D "$BRANCH"
+gum confirm "Overwrite?" --affirmative "Overwrite" --negative "Skip" || exit 0
+```
+
+### spin - spinner while command runs
+
+```bash
+gum spin [flags] -- <command>
+# key flags:
+# --title "text" message shown next to spinner
+# --spinner dot type: line,dot,minidot,jump,pulse,points,globe,moon,monkey,meter,hamburger
+# --show-output stream stdout/stderr live
+# --show-error show output only on failure
+# --timeout 60s
+
+gum spin --title "Installing deps..." -- npm install
+OUTPUT=$(gum spin --show-output --title "Fetching..." -- curl -s https://api.example.com/data)
+```
+
+### style - styled text output
+
+```bash
+gum style [flags] "text" ["text2" ...]
+# multiple strings are rendered as separate lines in one block
+# key flags:
+# --foreground "#hex"|"256color"
+# --background "#hex"|"256color"
+# --border none|hidden|normal|rounded|thick|double
+# --border-foreground
+# --align left|center|right
+# --width N, --height N
+# --margin "T R B L" (css shorthand)
+# --padding "T R B L"
+# --bold, --italic, --underline, --strikethrough, --faint
+
+gum style --foreground 212 --border rounded --padding "1 2" "Done!"
+```
+
+### format - render markdown, code, templates, emoji
+
+```bash
+gum format [flags] [text...]
+# key flags:
+# -t markdown|template|code|emoji (default: markdown)
+# -l python language hint for code type
+# --theme pink glamour theme for markdown
+
+echo "# Hello\n- item 1\n- item 2" | gum format
+cat script.sh | gum format -t code -l bash
+echo '{{ Bold "OK" }} {{ Color "99" "0" " gum " }}' | gum format -t template
+echo "I :heart: gum :candy:" | gum format -t emoji
+```
+
+### join - compose styled blocks side by side or stacked
+
+```bash
+gum join [flags] "block1" "block2"
+# flags:
+# --vertical stack top to bottom (default is horizontal)
+# --align left|center|right
+
+# always quote gum style output to preserve newlines
+A=$(gum style --border rounded --padding "0 2" "left")
+B=$(gum style --border rounded --padding "0 2" "right")
+gum join "$A" "$B"
+```
+
+### file - file picker from tree
+
+```bash
+gum file [path] # defaults to current dir
+# flags: --cursor, --height, --show-hidden
+
+$EDITOR "$(gum file $HOME)"
+```
+
+### pager - scrollable viewer
+
+```bash
+gum pager < README.md
+gum pager --show-line-numbers < file.txt
+```
+
+### table - pick a row from CSV
+
+```bash
+gum table < data.csv
+gum table -c Name,Age,Role < users.csv | cut -d',' -f1
+```
+
+### log - structured log output
+
+```bash
+# levels: debug, info, warn, error, fatal
+gum log --level info "Server started" port 8080
+gum log --structured --level error "Failed" file foo.txt
+gum log --time rfc822 --level warn "Slow response"
+```
+
+## Shell Script Patterns
+
+### 1. conventional commit helper
+
+```bash
+#!/bin/bash
+set -e
+
+TYPE=$(gum choose "fix" "feat" "docs" "style" "refactor" "test" "chore")
+SCOPE=$(gum input --placeholder "scope (optional)")
+[ -n "$SCOPE" ] && SCOPE="($SCOPE)"
+
+SUMMARY=$(gum input --value "$TYPE$SCOPE: " --placeholder "short summary")
+BODY=$(gum write --placeholder "longer description (ctrl+d to skip)" --height 6)
+
+gum confirm "Commit?" || exit 0
+git commit -m "$SUMMARY" ${BODY:+-m "$BODY"}
+```
+
+### 2. interactive branch cleanup
+
+```bash
+#!/bin/bash
+set -e
+
+# pick branches to delete
+BRANCHES=$(git branch | cut -c 3- | gum choose --no-limit --header "Branches to delete")
+[ -z "$BRANCHES" ] && exit 0
+
+gum style --foreground 196 --bold "Will delete:"
+echo "$BRANCHES" | gum format
+
+gum confirm "Delete these branches?" --affirmative "Delete" --negative "Cancel" || exit 0
+
+echo "$BRANCHES" | while IFS= read -r branch; do
+ gum spin --title "Deleting $branch..." -- git branch -D "$branch"
+ gum log --level info "Deleted" branch "$branch"
+done
+```
+
+### 3. deploy script with env selection
+
+```bash
+#!/bin/bash
+set -e
+
+ENV=$(gum choose "staging" "production" --header "Deploy target")
+TAG=$(git tag --sort=-v:refname | gum filter --placeholder "pick version tag...")
+
+gum style \
+ --border rounded --border-foreground 214 \
+ --padding "1 3" --margin "1" \
+ "Deploy $TAG to $ENV?"
+
+gum confirm "Proceed?" --default=false || exit 0
+
+gum spin --title "Deploying $TAG to $ENV..." --show-error -- \
+ ./deploy.sh "$ENV" "$TAG"
+
+gum style --foreground 82 --bold "Deployed $TAG to $ENV"
+```
+
+### 4. quick file notes launcher
+
+```bash
+#!/bin/bash
+# browse vault notes, open selected in editor
+VAULT="$HOME/notes"
+
+FILE=$(find "$VAULT" -name "*.md" | sed "s|$VAULT/||" \
+ | gum filter --placeholder "search notes..." --height 20)
+
+[ -z "$FILE" ] && exit 0
+$EDITOR "$VAULT/$FILE"
+```
+
+### 5. package manager TUI
+
+```bash
+#!/bin/bash
+set -e
+
+ACTION=$(gum choose "install" "remove" "update" --header "Package action")
+
+case "$ACTION" in
+ install)
+ PKG=$(gum input --placeholder "package name")
+ gum spin --title "Installing $PKG..." -- brew install "$PKG"
+ ;;
+ remove)
+ PKGS=$(brew list | gum choose --no-limit --header "Select packages to remove")
+ [ -z "$PKGS" ] && exit 0
+ gum confirm "Remove selected?" || exit 0
+ echo "$PKGS" | xargs gum spin --title "Removing..." -- brew uninstall
+ ;;
+ update)
+ gum spin --title "Updating Homebrew..." --show-error -- brew update
+ gum spin --title "Upgrading packages..." --show-output -- brew upgrade
+ ;;
+esac
+
+gum log --level info "Done" action "$ACTION"
+```
+
+## Styling
+
+Colors accept ANSI 256 codes (`212`) or hex (`#FF79C6`). Padding/margin use CSS shorthand: `"1 2"` = top/bottom 1, left/right 2.
+
+```bash
+# banner helper pattern
+banner() {
+ gum style \
+ --foreground 212 --border-foreground 212 \
+ --border double --align center \
+ --padding "1 4" --margin "1 2" \
+ "$@"
+}
+banner "Build Complete" "v1.4.2"
+
+# side by side layout
+LEFT=$(gum style --border rounded --padding "0 3" --foreground 82 "OK")
+RIGHT=$(gum style --border rounded --padding "0 3" --foreground 196 "ERR")
+gum join "$LEFT" "$RIGHT"
+```
+
+Style env vars use `GUM_<CMD>_<FLAG>` format. Set in shell profile for persistent defaults:
+
+```bash
+export GUM_CHOOSE_CURSOR_FOREGROUND="#FF79C6"
+export GUM_INPUT_PLACEHOLDER="..."
+export GUM_SPIN_SPINNER="dot"
+```
+
+## Common Mistakes
+
+- **capturing output**: gum writes the prompt to stderr, result to stdout. `VAL=$(gum input)` works correctly.
+- **spin command separator**: `--` is required before the command: `gum spin --title "..." -- npm install`. Without it gum parses your command as its own flags.
+- **confirm exit code**: `gum confirm` returns 0 for yes, 1 for no. Use `&&`/`||` not `if [ $? -eq 0 ]` - both work but `&&` is idiomatic.
+- **join with newlines**: always quote `$(gum style ...)` in join args or newlines collapse: `gum join "$A" "$B"` not `gum join $A $B`.
+- **filter with no match**: by default `--strict` is on - filter returns nothing if no match. Use `--no-strict` to return the query string instead.
+- **choose vs filter**: `choose` = static list, cursor navigation. `filter` = fuzzy search while typing. Use filter for long lists.
+- **multi-select output**: each selection on its own line. Iterate with `while IFS= read -r item; do ... done <<< "$SELECTION"`.
+
+## Checklist
+
+- [ ] capture interactive output with `$()`, not redirect
+- [ ] add `-- command` separator for `gum spin`
+- [ ] handle empty selection (`[ -z "$VAR" ] && exit 0`)
+- [ ] use `--no-limit` for multi-select, iterate output line by line
+- [ ] use `--default=false` on destructive confirms
+- [ ] quote `$(gum style ...)` when passing to `gum join`
+- [ ] set `--timeout` for unattended or CI-adjacent scripts
+- [ ] test ctrl+c behavior - gum exits non-zero, handle with `set -e` or explicit checks
@@ -0,0 +1,181 @@
+---
+name: charm-harmonica
+description: "Physics-based animation for Go TUIs - damped spring oscillator and projectile motion. Use when adding spring animations, physics-based motion, or smooth transitions to Go terminal apps. No Ease function exists in this library."
+---
+
+# charm-harmonica
+
+Physics-based animation primitives: spring (damped harmonic oscillator) and projectile motion.
+
+## Quick Start
+
+```go
+import "github.com/charmbracelet/harmonica"
+
+// Create spring once - expensive coefficients computed here
+spring := harmonica.NewSpring(harmonica.FPS(60), 6.0, 0.5)
+
+// Per-frame state - YOU own these
+var pos, vel float64
+
+// Each frame:
+pos, vel = spring.Update(pos, vel, targetPos)
+```
+
+## Core API
+
+### FPS(n int) float64
+
+Converts a frame rate to a delta time in seconds. Pass directly to `NewSpring` or `NewProjectile`.
+
+```go
+dt := harmonica.FPS(60) // 0.01666...
+```
+
+Use the engine's actual delta time instead when available (e.g. real elapsed time between frames).
+
+### NewSpring(deltaTime, angularFrequency, dampingRatio float64) Spring
+
+Pre-computes spring coefficients. Call once, reuse across frames.
+
+| Parameter | Effect |
+|-----------|--------|
+| `deltaTime` | Frame duration in seconds - use `FPS(n)` |
+| `angularFrequency` | Speed of motion. Typical range: 1-20 |
+| `dampingRatio` | Oscillation behavior |
+
+**Damping ratio guide:**
+- `< 1.0` - under-damped, overshoots and oscillates
+- `= 1.0` - critically damped, fastest without overshoot
+- `> 1.0` - over-damped, slow and sluggish
+
+Practical starting points: frequency `6.0`, damping `0.5` (smooth). For bouncy: frequency `8.0`, damping `0.15`. For snappy: frequency `12.0`, damping `1.0`.
+
+### Spring.Update(pos, vel, target float64) (newPos, newVel float64)
+
+Advances one frame. Returns new position and velocity - always capture both.
+
+```go
+// One spring can drive multiple independent axes
+x, xVel = spring.Update(x, xVel, targetX)
+y, yVel = spring.Update(y, yVel, targetY)
+radius, radiusVel = spring.Update(radius, radiusVel, targetRadius)
+```
+
+### NewProjectile(deltaTime float64, pos Point, vel, acc Vector) *Projectile
+
+Kinematic projectile - no spring, just position + velocity + acceleration per frame.
+
+```go
+p := harmonica.NewProjectile(
+ harmonica.FPS(60),
+ harmonica.Point{X: 0, Y: 0, Z: 0},
+ harmonica.Vector{X: 5, Y: 0, Z: 0}, // initial velocity
+ harmonica.TerminalGravity, // acceleration: {0, 9.81, 0}
+)
+
+// Each frame:
+pos := p.Update() // returns harmonica.Point
+```
+
+**Gravity constants:**
+- `harmonica.Gravity` - `{0, -9.81, 0}` - origin bottom-left
+- `harmonica.TerminalGravity` - `{0, 9.81, 0}` - origin top-left (standard TUI)
+
+**Projectile accessors:** `p.Position()`, `p.Velocity()`, `p.Acceleration()`
+
+### No Ease Function
+
+harmonica does not have an Ease function. It has Spring and Projectile only.
+
+## bubbletea Integration Pattern
+
+The canonical pattern uses a `frameMsg` sentinel type and `tea.Tick` to drive the loop.
+
+```go
+const fps = 60
+
+type frameMsg time.Time
+
+// Schedules the next frame tick
+func animate() tea.Cmd {
+ return tea.Tick(time.Second/fps, func(t time.Time) tea.Msg {
+ return frameMsg(t)
+ })
+}
+
+type model struct {
+ x float64
+ xVel float64
+ spring harmonica.Spring
+}
+
+func (m model) Init() tea.Cmd {
+ return animate() // kick off the loop
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg.(type) {
+ case frameMsg:
+ const target = 60.0
+ m.x, m.xVel = m.spring.Update(m.x, m.xVel, target)
+
+ // Stop ticking when close enough
+ if math.Abs(m.x-target) < 0.01 {
+ return m, nil
+ }
+
+ return m, animate() // schedule next frame
+ }
+ return m, nil
+}
+
+func main() {
+ m := model{
+ spring: harmonica.NewSpring(harmonica.FPS(fps), 7.0, 0.15),
+ }
+ tea.NewProgram(m).Run()
+}
+```
+
+### Changing spring parameters mid-animation
+
+When frequency or damping changes, call `NewSpring` again with the same `deltaTime`. The current `pos` and `vel` carry over - do not reset them.
+
+```go
+// User changed settings - recompute spring, keep state
+spring = harmonica.NewSpring(harmonica.FPS(fps), newFreq, newDamp)
+// x, xVel unchanged - animation continues smoothly from current state
+```
+
+### Stopping the animation loop
+
+Return `nil` (no command) instead of `animate()` to stop. Resume by returning `animate()` again on the next relevant message.
+
+## Common Mistakes
+
+**Forgetting to capture velocity.** `spring.Update` returns two values. Discarding the velocity breaks the simulation on the next frame.
+```go
+// wrong
+pos, _ = spring.Update(pos, vel, target)
+
+// right
+pos, vel = spring.Update(pos, vel, target)
+```
+
+**Creating Spring inside the update loop.** `NewSpring` is expensive - it computes trig/exp coefficients. Create it once in `main` or `Init`, store it on the model.
+
+**Using `Gravity` instead of `TerminalGravity` in TUIs.** TUI coordinate systems have Y increasing downward. Use `TerminalGravity` (`{0, 9.81, 0}`) so things fall down the screen, not up.
+
+**Not passing real delta time in non-fixed-FPS contexts.** In game loops with variable frame time, pass the actual `time.Since(last).Seconds()` to `NewSpring` each frame instead of `FPS(60)`. Recreating the spring with the real dt each frame is correct and expected.
+
+**Calling `animate()` unconditionally.** Always check if the animation has converged before scheduling the next frame, or it runs forever at 60fps.
+
+## Checklist
+
+- [ ] `NewSpring` called once, stored on model struct
+- [ ] Both return values from `Update` captured (`pos, vel = ...`)
+- [ ] `animate()` returns `nil` when animation is done (convergence check)
+- [ ] Using `TerminalGravity` for TUI projectiles (Y-down coordinate space)
+- [ ] `frameMsg` type defined as `type frameMsg time.Time`
+- [ ] Spring recomputed (not state reset) when parameters change at runtime
@@ -0,0 +1,457 @@
+---
+name: charm-huh
+description: "Build interactive terminal forms and prompts in Go with huh - input, select, confirm, multiselect, validation, theming. Use when building Go terminal forms, huh, interactive Go prompts, or form fields with validation. NOT for shell script prompts (use gum)."
+---
+
+# charmbracelet/huh
+
+Interactive terminal forms and prompts for Go. Built on Bubble Tea.
+
+Import: `charm.land/huh/v2`
+
+## Quick Start
+
+```go
+package main
+
+import (
+ "fmt"
+ "log"
+
+ "charm.land/huh/v2"
+)
+
+func main() {
+ var name string
+ var confirm bool
+
+ err := huh.NewForm(
+ huh.NewGroup(
+ huh.NewInput().
+ Title("What's your name?").
+ Value(&name).
+ Validate(huh.ValidateNotEmpty()),
+ huh.NewConfirm().
+ Title("Ready?").
+ Value(&confirm),
+ ),
+ ).Run()
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("Hello, %s!\n", name)
+}
+```
+
+Single field shorthand (no Form/Group wrapper needed):
+
+```go
+var name string
+huh.NewInput().Title("Name?").Value(&name).Run()
+```
+
+## Core API
+
+### Architecture
+
+`Form` > `Group` (pages) > `Field` (inputs)
+
+Groups are displayed one at a time. The form advances to the next group when all fields in the current group pass validation.
+
+### Form
+
+```go
+form := huh.NewForm(groups...*Group) *Form
+```
+
+Key methods:
+
+| Method | Purpose |
+|--------|---------|
+| `.Run()` | Block and run the form |
+| `.RunWithContext(ctx)` | Run with context (supports cancellation) |
+| `.WithTheme(theme)` | Set theme |
+| `.WithWidth(w)` / `.WithHeight(h)` | Set dimensions |
+| `.WithAccessible(bool)` | Screen reader mode |
+| `.WithShowHelp(bool)` | Toggle help bar |
+| `.WithShowErrors(bool)` | Toggle error display |
+| `.WithTimeout(duration)` | Auto-cancel after duration |
+| `.WithLayout(layout)` | Set group layout |
+| `.WithKeyMap(keymap)` | Custom keybindings |
+| `.WithOutput(w)` / `.WithInput(r)` | Custom IO |
+
+Retrieve values by key after completion:
+
+```go
+form.GetString("key")
+form.GetInt("key")
+form.GetBool("key")
+form.Get("key") // any
+```
+
+Form states: `huh.StateNormal`, `huh.StateCompleted`, `huh.StateAborted`
+
+Errors: `huh.ErrUserAborted` (ctrl+c), `huh.ErrTimeout`
+
+### Group
+
+```go
+group := huh.NewGroup(fields ...Field) *Group
+```
+
+| Method | Purpose |
+|--------|---------|
+| `.Title(s)` / `.Description(s)` | Group header |
+| `.WithHide(bool)` | Skip this group |
+| `.WithHideFunc(func() bool)` | Conditionally skip group |
+| `.WithShowHelp(bool)` | Toggle help for group |
+
+### Field Types
+
+Every field supports: `.Title(s)`, `.Description(s)`, `.Key(s)`, `.Value(&v)`, `.Validate(fn)`, `.Run()`.
+
+Dynamic variants exist for most properties: `.TitleFunc(fn, binding)`, `.DescriptionFunc(fn, binding)`, etc.
+
+#### Input
+
+Single line text. Type: `string`.
+
+```go
+huh.NewInput().
+ Title("Email").
+ Placeholder("you@example.com").
+ Prompt("> ").
+ CharLimit(100).
+ Suggestions([]string{"gmail.com", "outlook.com"}).
+ EchoMode(huh.EchoModePassword). // or EchoModeNone
+ Inline(true). // title and input on same line
+ Validate(huh.ValidateNotEmpty()).
+ Value(&email)
+```
+
+#### Text
+
+Multi-line textarea. Type: `string`.
+
+```go
+huh.NewText().
+ Title("Description").
+ Lines(5).
+ CharLimit(500).
+ Placeholder("Enter details...").
+ ShowLineNumbers(true).
+ Editor("vim"). // external editor support (ctrl+e)
+ EditorExtension("md").
+ ExternalEditor(false). // disable external editor
+ Value(&description)
+```
+
+#### Select
+
+Pick one from a list. Generic: `Select[T comparable]`.
+
+```go
+huh.NewSelect[string]().
+ Title("Country").
+ Options(
+ huh.NewOption("United States", "US"),
+ huh.NewOption("Canada", "CA"),
+ ).
+ Height(8). // scrollable if options exceed height
+ Inline(true). // horizontal left/right navigation
+ Filtering(true). // start with filter active
+ Value(&country)
+```
+
+Shorthand for simple options:
+
+```go
+Options(huh.NewOptions("Warrior", "Mage", "Rogue")...)
+```
+
+#### MultiSelect
+
+Pick zero or more. Generic: `MultiSelect[T comparable]`.
+
+```go
+huh.NewMultiSelect[string]().
+ Title("Toppings").
+ Options(
+ huh.NewOption("Lettuce", "lettuce").Selected(true),
+ huh.NewOption("Tomato", "tomato"),
+ huh.NewOption("Cheese", "cheese"),
+ ).
+ Limit(3).
+ Height(6).
+ Filterable(false). // disable "/" filter
+ Value(&toppings)
+```
+
+Space to toggle, `a` to select all (when no limit), `/` to filter.
+
+#### Confirm
+
+Yes/No. Type: `bool`.
+
+```go
+huh.NewConfirm().
+ Title("Continue?").
+ Affirmative("Yes!").
+ Negative("No way").
+ Inline(true).
+ Value(&ok)
+```
+
+Keys: `h`/`l` or left/right to toggle, `y` to accept, `n` to reject.
+
+#### Note
+
+Display-only. Not interactive by default (auto-skipped).
+
+```go
+huh.NewNote().
+ Title("Welcome").
+ Description("This form collects your _preferences_.").
+ Height(10).
+ Next(true). // show a "Next" button, makes it interactive
+ NextLabel("Continue")
+```
+
+Description supports basic markdown: `_italic_`, `*bold*`, `` `code` ``.
+
+## Common Patterns
+
+### Multi-Step Forms
+
+Groups act as pages. Users navigate forward/back between them.
+
+```go
+huh.NewForm(
+ huh.NewGroup(/* step 1 fields */).Title("Step 1"),
+ huh.NewGroup(/* step 2 fields */).Title("Step 2"),
+ huh.NewGroup(/* step 3 fields */).Title("Step 3"),
+).Run()
+```
+
+### Conditional Groups
+
+Hide groups based on previous answers:
+
+```go
+var wantExtras bool
+
+huh.NewForm(
+ huh.NewGroup(
+ huh.NewConfirm().Title("Want extras?").Value(&wantExtras),
+ ),
+ huh.NewGroup(
+ huh.NewInput().Title("Extra details").Value(&details),
+ ).WithHideFunc(func() bool { return !wantExtras }),
+).Run()
+```
+
+### Dynamic Fields
+
+Use `*Func` variants to recompute properties when bindings change. Pass a pointer to the bound variable.
+
+```go
+var country string
+
+huh.NewSelect[string]().
+ Value(&state).
+ TitleFunc(func() string {
+ if country == "Canada" { return "Province" }
+ return "State"
+ }, &country).
+ OptionsFunc(func() []huh.Option[string] {
+ return huh.NewOptions(statesByCountry[country]...)
+ }, &country)
+```
+
+The binding (`&country`) tells huh when to recompute. Results are cached per binding hash.
+
+### Validation
+
+Built-in validators:
+
+```go
+huh.ValidateNotEmpty()
+huh.ValidateMinLength(3)
+huh.ValidateMaxLength(100)
+huh.ValidateLength(3, 100) // min and max
+huh.ValidateOneOf("a", "b", "c")
+```
+
+Custom validation:
+
+```go
+.Validate(func(s string) error {
+ if !strings.Contains(s, "@") {
+ return fmt.Errorf("must be a valid email")
+ }
+ return nil
+})
+```
+
+Validation runs on blur (when leaving a field). Forms block progression if any field in the current group has errors.
+
+### Theming
+
+Built-in themes: `ThemeCharm` (default), `ThemeDracula`, `ThemeCatppuccin`, `ThemeBase16`, `ThemeDefault`.
+
+```go
+form.WithTheme(huh.ThemeFunc(huh.ThemeDracula))
+```
+
+Custom theme - implement the `Theme` interface:
+
+```go
+type Theme interface {
+ Theme(isDark bool) *Styles
+}
+```
+
+Or use `ThemeFunc`:
+
+```go
+form.WithTheme(huh.ThemeFunc(func(isDark bool) *huh.Styles {
+ s := huh.ThemeCharm(isDark) // start from a base
+ s.Focused.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212"))
+ return s
+}))
+```
+
+### Layouts
+
+```go
+form.WithLayout(huh.LayoutDefault) // one group at a time (default)
+form.WithLayout(huh.LayoutStack) // all groups stacked vertically
+form.WithLayout(huh.LayoutColumns(2)) // groups in 2 columns
+form.WithLayout(huh.LayoutGrid(2, 3)) // 2 rows, 3 columns
+```
+
+### Accessibility
+
+```go
+accessible := os.Getenv("ACCESSIBLE") != ""
+form.WithAccessible(accessible)
+```
+
+When `TERM=dumb`, accessible mode activates automatically. Replaces TUI with plain text prompts. Timeout is not supported in accessible mode.
+
+### Spinner
+
+Separate package for loading indicators after form submission:
+
+```go
+import "charm.land/huh/v2/spinner"
+
+err := spinner.New().
+ Title("Processing...").
+ Action(func() { /* do work */ }).
+ Run()
+```
+
+Or with context:
+
+```go
+go doWork()
+spinner.New().Title("Working...").Context(ctx).Run()
+```
+
+## Integration: Standalone vs Bubble Tea
+
+### Standalone
+
+Call `.Run()` on a form or individual field. Blocks until complete.
+
+```go
+form.Run()
+// or
+huh.NewInput().Title("Name?").Value(&name).Run()
+```
+
+### Embedded in Bubble Tea
+
+`*huh.Form` implements `tea.Model`. Use it as a component in your Bubble Tea app.
+
+```go
+type Model struct {
+ form *huh.Form
+}
+
+func NewModel() Model {
+ return Model{
+ form: huh.NewForm(
+ huh.NewGroup(
+ huh.NewSelect[string]().
+ Key("class").
+ Options(huh.NewOptions("Warrior", "Mage", "Rogue")...).
+ Title("Choose your class"),
+ ),
+ ),
+ }
+}
+
+func (m Model) Init() tea.Cmd {
+ return m.form.Init()
+}
+
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ form, cmd := m.form.Update(msg)
+ if f, ok := form.(*huh.Form); ok {
+ m.form = f
+ }
+
+ if m.form.State == huh.StateCompleted {
+ return m, tea.Quit
+ }
+ return m, cmd
+}
+
+func (m Model) View() string {
+ if m.form.State == huh.StateCompleted {
+ return fmt.Sprintf("You picked: %s", m.form.GetString("class"))
+ }
+ return m.form.View()
+}
+```
+
+Key differences when embedded:
+- Do NOT call `.Run()`, use Init/Update/View cycle instead
+- Set `SubmitCmd` and `CancelCmd` if you want custom behavior on form completion
+- Use `.Key("name")` on fields, retrieve with `form.GetString("name")`
+- Check `form.State` to know when the form is done
+- Type assert the Update result: `form.(*huh.Form)`
+
+Navigation methods available for programmatic control:
+- `form.NextGroup()`, `form.PrevGroup()`
+- `form.NextField()`, `form.PrevField()`
+- `form.GetFocusedField()`
+
+## Common Mistakes
+
+1. **Forgetting `.Value(&v)`** - Without it, answers go nowhere. The field uses an internal `EmbeddedAccessor` that you cannot read after form completes unless you use `.Key()` + `form.GetString()`.
+
+2. **Using `.Run()` inside Bubble Tea** - Never call `.Run()` on an embedded form. Use the Init/Update/View pattern.
+
+3. **Missing type parameter on Select/MultiSelect** - `huh.NewSelect[string]()` not `huh.NewSelect()`. The generic parameter determines the option value type.
+
+4. **Dynamic binding without pointer** - `TitleFunc(fn, country)` will not work. Must be `TitleFunc(fn, &country)` with a pointer so huh can detect changes.
+
+5. **Timeout in accessible mode** - `WithTimeout()` returns `ErrTimeoutUnsupported` in accessible mode. Guard it.
+
+6. **Not handling ErrUserAborted** - `form.Run()` returns `huh.ErrUserAborted` when user presses ctrl+c. Always check the error.
+
+7. **Form outputs to stderr** - By default, the TUI renders to stderr (stdout stays clean for piping). Use `.WithOutput(os.Stdout)` to change.
+
+## Checklist
+
+- [ ] Import `charm.land/huh/v2`
+- [ ] Every field that stores data has `.Value(&var)` or `.Key("name")`
+- [ ] Custom validators return `nil` on success, `error` on failure
+- [ ] Dynamic fields use `*Func` variants with pointer bindings
+- [ ] Accessible mode handled via env var or config flag
+- [ ] `ErrUserAborted` handled after `.Run()`
+- [ ] Embedded forms use Init/Update/View, not `.Run()`
+- [ ] Form state checked via `form.State == huh.StateCompleted`
@@ -0,0 +1,313 @@
+---
+name: charm-lipgloss
+description: "CSS-like terminal styling for Go with lipgloss v2 - styles, colors, borders, layout, tables, lists, and trees. Use when styling Go terminal output, lipgloss, terminal layout composition, or building styled tables/lists/trees in Go."
+---
+
+# Lip Gloss v2
+
+CSS-like terminal styling for Go. Import: `charm.land/lipgloss/v2`
+
+v1 (`github.com/charmbracelet/lipgloss`) is deprecated. See `references/v1-to-v2-migration.md`.
+
+## Quick Start
+
+```go
+package main
+
+import "charm.land/lipgloss/v2"
+
+func main() {
+ style := lipgloss.NewStyle().
+ Bold(true).
+ Foreground(lipgloss.Color("#FAFAFA")).
+ Background(lipgloss.Color("#7D56F4")).
+ Padding(1, 2).
+ Width(30)
+
+ lipgloss.Println(style.Render("Hello, terminal!"))
+}
+```
+
+Rules:
+- Use `lipgloss.Println` not `fmt.Println` for automatic color downsampling
+- `Style` is a value type. Assignment copies. No renderer, no pointers.
+- `lipgloss.Color()` is a function returning `color.Color`, not a type
+
+## Core API Reference
+
+### Style
+
+Create: `lipgloss.NewStyle()`. All methods return new `Style` (immutable).
+
+```go
+s := lipgloss.NewStyle().
+ Bold(true).Italic(true).Faint(true).Strikethrough(true).Reverse(true).Blink(true).
+ Underline(true). // or UnderlineStyle(lipgloss.UnderlineCurly)
+ UnderlineColor(lipgloss.Color("#FF0000")).
+ Foreground(lipgloss.Color("#FF0000")).
+ Background(lipgloss.Color("63")).
+ Width(40).Height(10).MaxWidth(80).MaxHeight(20).
+ Align(lipgloss.Center). // horizontal; or Align(hPos, vPos)
+ Padding(1, 2).PaddingChar('.'). // CSS shorthand: 1-4 args
+ Margin(1, 2).MarginBackground(c). // same shorthand as padding
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("63")).
+ Inline(true). // single line, no block formatting
+ Transform(strings.ToUpper).
+ Hyperlink("https://example.com")
+
+output := s.Render("text", "more") // renders with style applied
+```
+
+Position constants: `Left`/`Top` (0.0), `Center` (0.5), `Right`/`Bottom` (1.0).
+
+Padding/Margin shorthand follows CSS: 1 arg = all, 2 = vert/horiz, 3 = top/horiz/bottom, 4 = clockwise.
+
+Underline styles: `UnderlineNone`, `UnderlineSingle`, `UnderlineDouble`, `UnderlineCurly`, `UnderlineDotted`, `UnderlineDashed`.
+
+Inheritance: `child.Inherit(parent)` copies unset rules. Margins/padding are NOT inherited.
+
+Copying: `copy := style` (value type). `.Copy()` is deprecated.
+
+Getters/Unsetters: every property has `Get*()` and `Unset*()` variants. Key sizing getters: `GetHorizontalFrameSize()`, `GetVerticalFrameSize()` (border + margin + padding). See `references/api-details.md`.
+
+### Color
+
+All implement `image/color.Color`.
+
+```go
+lipgloss.Color("#FF0000") // hex TrueColor
+lipgloss.Color("#F00") // short hex
+lipgloss.Color("21") // ANSI256
+lipgloss.Color("5") // ANSI 16
+lipgloss.Magenta // named constant (ansi.BasicColor)
+lipgloss.NoColor{} // absence of color
+lipgloss.RGBColor{R: 255} // direct RGB
+lipgloss.ANSIColor(134) // ANSI256 by number
+```
+
+Named ANSI 16: `Black`, `Red`, `Green`, `Yellow`, `Blue`, `Magenta`, `Cyan`, `White`, `BrightBlack` through `BrightWhite`.
+
+Utilities: `Darken(c, 0.5)`, `Lighten(c, 0.35)`, `Complementary(c)`, `Alpha(c, 0.5)`, `Blend1D(steps, colors...)`, `Blend2D(w, h, angle, colors...)`.
+
+**Adaptive colors:**
+```go
+// Standalone
+hasDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
+ld := lipgloss.LightDark(hasDark)
+fg := ld(lipgloss.Color("#333"), lipgloss.Color("#EEE"))
+
+// Bubble Tea
+case tea.BackgroundColorMsg:
+ ld := lipgloss.LightDark(msg.IsDark())
+
+// Per-profile exact colors
+complete := lipgloss.Complete(colorprofile.Detect(os.Stdout, os.Environ()))
+c := complete(lipgloss.Color("1"), lipgloss.Color("124"), lipgloss.Color("#ff34ac"))
+```
+
+### Border
+
+```go
+// Built-in: NormalBorder, RoundedBorder, ThickBorder, DoubleBorder,
+// BlockBorder, OuterHalfBlockBorder, InnerHalfBlockBorder,
+// HiddenBorder, MarkdownBorder, ASCIIBorder
+
+s.Border(lipgloss.RoundedBorder()) // all sides
+s.Border(lipgloss.NormalBorder(), true, false) // top+bottom only
+s.BorderStyle(lipgloss.RoundedBorder()).BorderTop(true).BorderLeft(false)
+s.BorderForeground(lipgloss.Color("63")) // all sides
+s.BorderTopForeground(c).BorderBackground(c) // per-side
+
+// Gradient borders (2+ colors required)
+s.BorderForegroundBlend(c1, c2, c1) // wrap for seamless loop
+
+// Custom: lipgloss.Border{Top, Bottom, Left, Right, TopLeft, TopRight, ...}
+```
+
+If `BorderStyle()` is set without any side booleans, all 4 sides render. Setting any side explicitly means only those render.
+
+### Layout
+
+```go
+// Join blocks
+lipgloss.JoinHorizontal(lipgloss.Top, a, b, c) // vertical alignment
+lipgloss.JoinVertical(lipgloss.Center, a, b) // horizontal alignment
+
+// Place in whitespace
+lipgloss.Place(80, 30, lipgloss.Right, lipgloss.Bottom, content)
+lipgloss.PlaceHorizontal(80, lipgloss.Center, content)
+lipgloss.PlaceVertical(30, lipgloss.Bottom, content,
+ lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(c)),
+ lipgloss.WithWhitespaceChars("."),
+)
+
+// Measure
+w, h := lipgloss.Size(rendered) // also Width(), Height()
+
+// Compositor
+a := lipgloss.NewLayer(content).X(4).Y(2).Z(1)
+lipgloss.Compose(a, b).Render()
+
+// Wrap (preserves ANSI)
+lipgloss.Wrap(text, 40, " ")
+```
+
+### Table
+
+```go
+import "charm.land/lipgloss/v2/table"
+
+t := table.New().
+ Headers("NAME", "AGE").
+ Row("Alice", "30").
+ Rows([][]string{{"Bob", "25"}}...).
+ Border(lipgloss.RoundedBorder()).
+ BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99"))).
+ BorderHeader(true).BorderColumn(true).BorderRow(false).
+ Width(60).Height(20).Wrap(true).
+ StyleFunc(func(row, col int) lipgloss.Style {
+ if row == table.HeaderRow { // HeaderRow == -1
+ return lipgloss.NewStyle().Bold(true).Align(lipgloss.Center)
+ }
+ return lipgloss.NewStyle().Padding(0, 1)
+ })
+lipgloss.Println(t)
+
+// Custom data: implement table.Data{At(row,cell)string, Rows()int, Columns()int}
+// Filtering: table.NewFilter(data).Filter(func(row int) bool { ... })
+// Markdown: table.New().Border(lipgloss.MarkdownBorder()).BorderTop(false).BorderBottom(false)
+```
+
+### List
+
+```go
+import "charm.land/lipgloss/v2/list"
+
+l := list.New("A", "B", "C")
+l := list.New("Fruits", list.New("Apple", "Banana"), "Veggies", list.New("Carrot"))
+
+// Enumerators: Bullet (default), Arabic, Alphabet, Roman, Dash, Asterisk
+l.Enumerator(list.Arabic)
+l.EnumeratorStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99")).MarginRight(1))
+l.ItemStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("212")))
+l.ItemStyleFunc(func(items list.Items, i int) lipgloss.Style { ... })
+
+// Custom: func(items list.Items, i int) string
+l.Item("incremental").Offset(1, -1) // pagination
+```
+
+### Tree
+
+```go
+import "charm.land/lipgloss/v2/tree"
+
+t := tree.Root("Project").
+ Child("src", tree.Root("cmd").Child("main.go")).
+ Child("README.md")
+
+// Enumerators: DefaultEnumerator (square), RoundedEnumerator (rounded)
+t.Enumerator(tree.RoundedEnumerator)
+t.RootStyle(s).ItemStyle(s).EnumeratorStyle(s)
+t.ItemStyleFunc(func(children tree.Children, i int) lipgloss.Style { ... })
+t.Width(40).Hide(true)
+```
+
+## Common Patterns
+
+### 1. Styled card
+
+```go
+func Card(title, body string, w int) string {
+ head := lipgloss.NewStyle().Bold(true).
+ Foreground(lipgloss.Color("#FAFAFA")).Background(lipgloss.Color("#7D56F4")).
+ Padding(0, 1).Width(w)
+ frame := lipgloss.NewStyle().Padding(1, 2).Width(w).
+ Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#7D56F4"))
+ return lipgloss.JoinVertical(lipgloss.Left, head.Render(title), frame.Render(body))
+}
+```
+
+### 2. Two-column layout
+
+```go
+func Columns(left, right string, total int) string {
+ col := lipgloss.NewStyle().Width(total / 2).Padding(1, 2)
+ return lipgloss.JoinHorizontal(lipgloss.Top, col.Render(left), col.Render(right))
+}
+```
+
+### 3. Adaptive theme
+
+```go
+func NewTheme(hasDark bool) Theme {
+ ld := lipgloss.LightDark(hasDark)
+ return Theme{
+ Primary: ld(lipgloss.Color("#5A56E0"), lipgloss.Color("#7571F9")),
+ Subtle: ld(lipgloss.Color("#999"), lipgloss.Color("#666")),
+ }
+}
+```
+
+### 4. Alternating-row table
+
+```go
+t := table.New().Headers(headers...).Rows(rows...).
+ Border(lipgloss.RoundedBorder()).
+ BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99"))).
+ StyleFunc(func(row, col int) lipgloss.Style {
+ base := lipgloss.NewStyle().Padding(0, 1)
+ if row == table.HeaderRow { return base.Bold(true) }
+ if row%2 == 0 { return base.Foreground(lipgloss.Color("245")) }
+ return base.Foreground(lipgloss.Color("241"))
+ })
+```
+
+## Integration Patterns
+
+### Bubble Tea
+
+```go
+func (m model) Init() tea.Cmd { return tea.RequestBackgroundColor }
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.BackgroundColorMsg:
+ m.styles = newStyles(msg.IsDark())
+ }
+ return m, nil
+}
+func (m model) View() string {
+ return m.styles.title.Render("My App")
+ // Bubble Tea handles downsampling - no lipgloss.Println needed
+}
+```
+
+### Glamour (Markdown)
+
+```go
+md, _ := glamour.Render(content, "dark")
+frame := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(1, 2).Width(80)
+lipgloss.Println(frame.Render(md))
+```
+
+## Common Mistakes
+
+1. **`fmt.Println` instead of `lipgloss.Println`** - no color downsampling. Always use lipgloss writers for standalone apps.
+2. **Width includes padding and borders** - `Width(40)` is 40 total cells, not content width.
+3. **`Color()` is a function in v2** - returns `color.Color`, not a type literal.
+4. **`Inherit()` skips margins/padding** - only text formatting and colors are inherited.
+5. **v1/v2 import mixing** - v2 is `charm.land/lipgloss/v2`. Sub-packages: `charm.land/lipgloss/v2/{table,list,tree}`.
+6. **`table.HeaderRow` is -1** - not 0. Data rows start at 0.
+7. **Border side defaults** - `BorderStyle()` alone renders all 4 sides. Setting any `BorderTop/Right/Bottom/Left` explicitly means only those render.
+8. **Renderer usage** - `*Renderer` does not exist in v2. Remove all renderer references.
+
+## Checklist
+
+- [ ] Import `charm.land/lipgloss/v2` (not `github.com/charmbracelet/lipgloss`)
+- [ ] Colors: `lipgloss.Color("...")` function call, returns `color.Color`
+- [ ] Output: `lipgloss.Println` for standalone apps
+- [ ] Width accounts for padding + border sizes
+- [ ] Table `StyleFunc` handles `table.HeaderRow` (-1)
+- [ ] Adaptive colors: `lipgloss.LightDark()`, not removed `lipgloss.AdaptiveColor`
+- [ ] No `*Renderer`, no `.Copy()` - both removed in v2
+- [ ] Sub-package imports: `charm.land/lipgloss/v2/{table,list,tree}`
@@ -0,0 +1,183 @@
+# Lip Gloss v2 API Details
+
+## Style Getters
+
+```go
+s.GetBold() // bool
+s.GetItalic() // bool
+s.GetUnderline() // bool
+s.GetUnderlineStyle() // Underline
+s.GetUnderlineColor() // color.Color
+s.GetStrikethrough() // bool
+s.GetReverse() // bool
+s.GetBlink() // bool
+s.GetFaint() // bool
+s.GetForeground() // color.Color
+s.GetBackground() // color.Color
+s.GetWidth() // int
+s.GetHeight() // int
+s.GetAlign() // Position (horizontal)
+s.GetAlignHorizontal() // Position
+s.GetAlignVertical() // Position
+s.GetPadding() // top, right, bottom, left int
+s.GetPaddingTop() // int
+s.GetPaddingRight() // int
+s.GetPaddingBottom() // int
+s.GetPaddingLeft() // int
+s.GetHorizontalPadding() // int (left + right)
+s.GetVerticalPadding() // int (top + bottom)
+s.GetMargin() // top, right, bottom, left int
+s.GetMarginTop() // int
+s.GetMarginRight() // int
+s.GetMarginBottom() // int
+s.GetMarginLeft() // int
+s.GetHorizontalMargins() // int (left + right)
+s.GetVerticalMargins() // int (top + bottom)
+s.GetBorderStyle() // Border
+s.GetBorderTop() // bool
+s.GetBorderRight() // bool
+s.GetBorderBottom() // bool
+s.GetBorderLeft() // bool
+s.GetHorizontalBorderSize() // int
+s.GetVerticalBorderSize() // int
+s.GetHorizontalFrameSize() // int (border + margin + padding)
+s.GetVerticalFrameSize() // int (border + margin + padding)
+s.GetMaxWidth() // int
+s.GetMaxHeight() // int
+s.GetTabWidth() // int
+s.GetHyperlink() // link, params string
+```
+
+## All Unset Methods
+
+Every setter has a corresponding `Unset*` method:
+`UnsetBold`, `UnsetItalic`, `UnsetUnderline`, `UnsetUnderlineStyle`, `UnsetUnderlineColor`,
+`UnsetStrikethrough`, `UnsetReverse`, `UnsetBlink`, `UnsetFaint`,
+`UnsetForeground`, `UnsetBackground`, `UnsetWidth`, `UnsetHeight`,
+`UnsetAlign`, `UnsetAlignHorizontal`, `UnsetAlignVertical`,
+`UnsetPadding`, `UnsetPaddingTop`, `UnsetPaddingRight`, `UnsetPaddingBottom`, `UnsetPaddingLeft`,
+`UnsetMargin`, `UnsetMarginTop`, `UnsetMarginRight`, `UnsetMarginBottom`, `UnsetMarginLeft`,
+`UnsetMarginBackground`, `UnsetBorderStyle`, `UnsetBorderTop`, `UnsetBorderRight`,
+`UnsetBorderBottom`, `UnsetBorderLeft`, `UnsetBorderForeground`, `UnsetBorderBackground`,
+`UnsetMaxWidth`, `UnsetMaxHeight`, `UnsetTabWidth`, `UnsetInline`
+
+## Border Struct Fields
+
+```go
+type Border struct {
+ Top, Bottom, Left, Right string
+ TopLeft, TopRight string
+ BottomLeft, BottomRight string
+ MiddleLeft, MiddleRight string // used by tables for row separators
+ Middle string // used by tables for intersections
+ MiddleTop, MiddleBottom string // used by tables for column header joins
+}
+```
+
+## Table Data Interface
+
+```go
+type Data interface {
+ At(row, cell int) string
+ Rows() int
+ Columns() int
+}
+```
+
+Built-in implementations:
+- `table.NewStringData(rows ...[]string)` - basic string data
+- `table.NewFilter(data).Filter(func(row int) bool)` - filtered view
+
+## Tree Node Interface
+
+```go
+type Node interface {
+ fmt.Stringer
+ Value() string
+ Children() Children
+ Hidden() bool
+ SetHidden(bool)
+ SetValue(any)
+}
+```
+
+Types: `*tree.Tree` (has children), `*tree.Leaf` (no children)
+
+## Color Types
+
+```go
+// Function - parse string to color
+func Color(s string) color.Color
+
+// Struct types
+type NoColor struct{} // absence of color
+type RGBColor struct{ R, G, B uint8 } // RGB values
+type ANSIColor = ansi.IndexedColor // ANSI 256 by number
+
+// Named ANSI 16 constants (type ansi.BasicColor)
+Black, Red, Green, Yellow, Blue, Magenta, Cyan, White
+BrightBlack, BrightRed, BrightGreen, BrightYellow,
+BrightBlue, BrightMagenta, BrightCyan, BrightWhite
+
+// Utility functions
+func Darken(c color.Color, percent float64) color.Color
+func Lighten(c color.Color, percent float64) color.Color
+func Complementary(c color.Color) color.Color
+func Alpha(c color.Color, alpha float64) color.Color
+func Blend1D(steps int, stops ...color.Color) []color.Color
+func Blend2D(width, height int, angle float64, stops ...color.Color) []color.Color
+
+// Adaptive color helpers
+func HasDarkBackground(in *os.File, out *os.File) bool
+func LightDark(isDark bool) LightDarkFunc // returns func(light, dark color.Color) color.Color
+func Complete(p colorprofile.Profile) CompleteFunc // returns func(ansi, ansi256, truecolor color.Color) color.Color
+```
+
+## Writer Functions
+
+All auto-downsample colors for the terminal's capability:
+
+```go
+var Writer = colorprofile.NewWriter(os.Stdout, os.Environ())
+
+func Print(v ...any) (int, error)
+func Println(v ...any) (int, error)
+func Printf(format string, v ...any) (int, error)
+func Fprint(w io.Writer, v ...any) (int, error)
+func Fprintln(w io.Writer, v ...any) (int, error)
+func Fprintf(w io.Writer, format string, v ...any) (int, error)
+func Sprint(v ...any) string
+func Sprintln(v ...any) string
+func Sprintf(format string, v ...any) string
+```
+
+## Whitespace Options
+
+Used with `Place`, `PlaceHorizontal`, `PlaceVertical`:
+
+```go
+lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(c))
+lipgloss.WithWhitespaceChars(".")
+```
+
+## List Enumerators
+
+```go
+list.Bullet // "." (default)
+list.Arabic // "1.", "2.", "3."
+list.Alphabet // "A.", "B.", "C."
+list.Roman // "I.", "II.", "III."
+list.Dash // "-"
+list.Asterisk // "*"
+
+// Custom: func(items list.Items, index int) string
+```
+
+## Tree Enumerators
+
+```go
+tree.DefaultEnumerator // ├── and └──
+tree.RoundedEnumerator // ├── and ╰──
+
+// Custom: func(children tree.Children, index int) string
+```
@@ -0,0 +1,88 @@
+# Lip Gloss v1 to v2 Migration Quick Reference
+
+## Import Path
+
+```
+github.com/charmbracelet/lipgloss -> charm.land/lipgloss/v2
+github.com/charmbracelet/lipgloss/table -> charm.land/lipgloss/v2/table
+github.com/charmbracelet/lipgloss/tree -> charm.land/lipgloss/v2/tree
+github.com/charmbracelet/lipgloss/list -> charm.land/lipgloss/v2/list
+```
+
+## Removed APIs
+
+| v1 | v2 Replacement |
+|---|---|
+| `type Renderer` | Removed entirely |
+| `DefaultRenderer()` | Not needed |
+| `NewRenderer(w, opts...)` | Not needed |
+| `ColorProfile()` | `colorprofile.Detect(w, env)` |
+| `SetColorProfile(p)` | Set `lipgloss.Writer.Profile` |
+| `HasDarkBackground()` (no args) | `lipgloss.HasDarkBackground(in, out)` |
+| `type TerminalColor` | `image/color.Color` |
+| `type Color string` | `func Color(string) color.Color` |
+| `type AdaptiveColor` | `compat.AdaptiveColor` or `LightDark()` |
+| `type CompleteColor` | `compat.CompleteColor` or `Complete()` |
+| `WithWhitespaceForeground(c)` | `WithWhitespaceStyle(s)` |
+| `WithWhitespaceBackground(c)` | `WithWhitespaceStyle(s)` |
+| `renderer.NewStyle()` | `lipgloss.NewStyle()` |
+| `style.Copy()` | plain assignment `copy := style` |
+
+## Color System Changes
+
+```go
+// v1: Color is a string type
+var c lipgloss.Color = "#ff00ff"
+
+// v2: Color is a function returning color.Color
+var c color.Color = lipgloss.Color("#ff00ff")
+
+// v1: methods accept TerminalColor
+func (s Style) Foreground(c TerminalColor) Style
+
+// v2: methods accept color.Color (from image/color)
+func (s Style) Foreground(c color.Color) Style
+```
+
+## Printing Changes
+
+```go
+// v1: fmt works, renderer handles downsampling
+fmt.Println(style.Render("hello"))
+
+// v2: use lipgloss writers for downsampling
+lipgloss.Println(style.Render("hello"))
+// Available: Print, Println, Printf, Fprint, Fprintln, Fprintf,
+// Sprint, Sprintln, Sprintf
+```
+
+## Adaptive Colors
+
+```go
+// v1
+color := lipgloss.AdaptiveColor{Light: "#fff", Dark: "#000"}
+
+// v2 - recommended
+hasDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
+ld := lipgloss.LightDark(hasDark)
+color := ld(lipgloss.Color("#fff"), lipgloss.Color("#000"))
+
+// v2 - compat (quick migration)
+import "charm.land/lipgloss/v2/compat"
+color := compat.AdaptiveColor{
+ Light: lipgloss.Color("#fff"),
+ Dark: lipgloss.Color("#000"),
+}
+```
+
+## New in v2
+
+- `UnderlineStyle()` / `UnderlineColor()` - curly, dotted, dashed, double underlines
+- `PaddingChar()` / `MarginChar()` - custom fill characters
+- `Hyperlink()` - clickable links in supporting terminals
+- `BorderForegroundBlend()` - gradient borders
+- `Blend1D()` / `Blend2D()` - color gradient generation
+- `NewLayer()` / `Compose()` - cell-based compositor
+- `Wrap()` - ANSI-aware text wrapping
+- Named ANSI color constants (`lipgloss.Red`, `lipgloss.BrightCyan`, etc.)
+- `lipgloss.Alpha()`, `lipgloss.Darken()`, `lipgloss.Lighten()`, `lipgloss.Complementary()`
@@ -0,0 +1,96 @@
+---
+name: charm-pop
+description: "Send emails from the terminal with pop - TUI and CLI modes, SMTP and Resend support, attachments. Use when sending email from terminal, pop, CLI email, or piping email content from shell scripts."
+---
+
+# charm-pop
+
+CLI and TUI tool for sending emails from the terminal. Requires either a `RESEND_API_KEY` or SMTP config.
+
+## Launch
+
+```bash
+pop # open TUI
+```
+
+## CLI Usage
+
+```bash
+pop < message.md \
+ --from "me@example.com" \
+ --to "you@example.com" \
+ --subject "Hello" \
+ --attach invoice.pdf
+
+pop --preview # preview before sending
+```
+
+Body is read from stdin. `--preview` opens TUI for review before send.
+
+## Auth: Resend
+
+```bash
+export RESEND_API_KEY=re_xxxx
+```
+
+Get key at https://resend.com/api-keys. No custom domain: use `onboarding@resend.dev` as sender.
+
+## Auth: SMTP
+
+```bash
+export POP_SMTP_HOST=smtp.gmail.com
+export POP_SMTP_PORT=587
+export POP_SMTP_USERNAME=you@gmail.com
+export POP_SMTP_PASSWORD=hunter2
+```
+
+## Env Defaults
+
+```bash
+export POP_FROM=you@example.com
+export POP_SIGNATURE="Sent with Pop!"
+```
+
+Saves typing `--from` every time.
+
+## Attachments
+
+```bash
+pop --attach file.pdf --attach image.png < body.txt
+```
+
+Multiple `--attach` flags supported.
+
+## Pipelines
+
+```bash
+# AI-written body via mods
+pop <<< "$(mods -f 'Write a status update')" \
+ --subject "Weekly update" --preview
+
+# Pick sender with gum
+pop --from $(gum choose "a@x.com" "b@x.com") \
+ --to $(gum filter < contacts.txt)
+
+# Generate and send invoice
+invoice generate --item "Work" --rate 100 --output inv.pdf
+pop --attach inv.pdf --body "Invoice attached."
+```
+
+## Flags Reference
+
+| Flag | Description |
+|------|-------------|
+| `--from` | Sender address |
+| `--to` | Recipient (repeatable) |
+| `--subject` | Email subject |
+| `--body` | Body text (or use stdin) |
+| `--attach` | File path to attach (repeatable) |
+| `--preview` | Open TUI preview before sending |
+
+## Install
+
+```bash
+brew install pop # macOS/Linux
+go install github.com/charmbracelet/pop@latest
+```
@@ -0,0 +1,366 @@
+---
+name: charm-ultraviolet
+description: "Low-level Go terminal primitives - cell-based rendering, input handling, screen management. Use when building custom Go terminal renderers, ultraviolet, cell buffers, or performance-critical TUI work below Bubble Tea's abstraction level."
+---
+
+# Ultraviolet (charmbracelet/ultraviolet)
+
+## What Is Ultraviolet (and When NOT to Use It)
+
+Ultraviolet is a set of low-level primitives for terminal manipulation in Go. It provides cell-based rendering, unified input handling, and screen management. It powers Bubble Tea v2 and Lip Gloss v2 internally.
+
+**This is NOT a framework.** It is infrastructure. Think of it like syscalls vs stdlib - you can use it directly, but most of the time you should not.
+
+### When NOT to use it
+
+- Building a standard TUI app - use Bubble Tea v2 instead
+- Styling terminal output - use Lip Gloss v2 instead
+- You want an architecture/framework with state management - use Bubble Tea v2
+- Prototyping - too low-level, too much boilerplate
+
+### When to use it directly
+
+- Building your own TUI framework on top of these primitives
+- Writing a custom renderer that needs cell-level control
+- Performance-critical rendering where you need direct buffer manipulation
+- Embedding terminal rendering into a non-Bubble Tea application
+- Working on Bubble Tea / Lip Gloss internals
+
+## API Stability Warning
+
+The project README explicitly states: "This project currently exists to serve internal use cases. API stability is a goal, but expect no stability guarantees as of now." Plan accordingly.
+
+## Core Abstractions
+
+The library lives in a single flat Go package `uv` (import path: `github.com/charmbracelet/ultraviolet`), with helper sub-packages `screen/` and `layout/`.
+
+### Cell
+
+The fundamental unit. One terminal cell = one grapheme cluster.
+
+```go
+type Cell struct {
+ Content string // single grapheme cluster
+ Style Style // fg, bg, attrs (bold, italic, etc.)
+ Link Link // OSC 8 hyperlink
+ Width int // columns occupied (1 for normal, 2 for wide chars like CJK)
+}
+```
+
+Key constants/values:
+- `EmptyCell` - a cell with `" "`, width 1, no style
+- Zero-width cells (`Width == 0`) are placeholders for wide characters
+
+### Buffer
+
+A 2D grid of cells, organized as `Lines []Line` where `Line []Cell`.
+
+```go
+buf := uv.NewBuffer(80, 24) // width, height
+buf.SetCell(x, y, &cell) // write a cell
+cell := buf.CellAt(x, y) // read a cell (nil if out of bounds)
+buf.Resize(newW, newH) // resize, preserving content
+buf.Clear() // fill with EmptyCell
+buf.Fill(&cell) // fill with custom cell
+buf.FillArea(&cell, area) // fill rectangular region
+clone := buf.Clone() // deep copy
+```
+
+Buffer implements `Drawable`, so you can `buf.Draw(screen, area)` to composite buffers onto screens.
+
+### RenderBuffer
+
+Wraps Buffer with change tracking. Only touched lines/cells get re-rendered.
+
+```go
+rbuf := uv.NewRenderBuffer(80, 24)
+rbuf.SetCell(x, y, &cell) // auto-marks line as touched
+rbuf.TouchLine(x, y, n) // manually mark region dirty
+rbuf.TouchedLines() // count of dirty lines
+```
+
+### Screen (Interface)
+
+The core abstraction that anything drawable targets.
+
+```go
+type Screen interface {
+ Bounds() Rectangle
+ CellAt(x, y int) *Cell
+ SetCell(x, y int, c *Cell)
+ WidthMethod() WidthMethod
+}
+```
+
+Implemented by: `Buffer`, `ScreenBuffer`, `Window`, `TerminalScreen`.
+
+### Drawable (Interface)
+
+Anything that can render itself onto a Screen.
+
+```go
+type Drawable interface {
+ Draw(scr Screen, area Rectangle)
+}
+```
+
+Implemented by: `Buffer`, `Window`, `StyledString`, and your own components.
+
+### Window
+
+A rectangular area that can own its own buffer or share a parent's buffer (view).
+
+```go
+// Root window (owns its buffer)
+root := uv.NewScreen(80, 24)
+
+// Child window with own buffer
+child := root.NewWindow(x, y, width, height)
+
+// View into parent buffer (shared memory)
+view := root.NewView(x, y, width, height)
+```
+
+Windows support `MoveTo`, `MoveBy`, `Resize`, `Clone`.
+
+### Terminal
+
+The main entry point for standalone UV apps. Manages console I/O, raw mode, event loop.
+
+```go
+t := uv.DefaultTerminal()
+// or: t := uv.NewTerminal(console, opts)
+
+t.Start() // enter raw mode, start event loop
+defer t.Stop() // restore terminal, clean up
+
+scr := t.Screen() // returns *TerminalScreen
+
+for ev := range t.Events() {
+ switch ev := ev.(type) {
+ case uv.WindowSizeEvent:
+ scr.Resize(ev.Width, ev.Height)
+ case uv.KeyPressEvent:
+ if ev.MatchString("ctrl+c") { return }
+ }
+}
+```
+
+### TerminalScreen
+
+The concrete screen for terminal output. Manages alt screen, cursor, colors, mouse mode, keyboard enhancements, synchronized updates.
+
+```go
+scr := t.Screen()
+
+// Screen modes
+scr.EnterAltScreen() // alternate screen buffer
+scr.ExitAltScreen()
+
+// Rendering cycle
+scr.SetCell(x, y, &cell)
+scr.Render() // diff current vs previous state
+scr.Flush() // write changes to terminal
+
+// Or use Display for Drawable components
+scr.Display(myDrawable) // clear + draw + render + flush
+
+// Terminal features
+scr.ShowCursor()
+scr.SetCursorPosition(x, y)
+scr.SetMouseMode(uv.MouseModeClick)
+scr.SetBackgroundColor(color)
+scr.SetWindowTitle("My App")
+scr.SetSynchronizedUpdates(true) // mode 2026
+scr.SetKeyboardEnhancements(enh) // kitty protocol
+
+// Inline mode helper
+scr.InsertAbove(content) // insert text above without disrupting screen
+```
+
+### StyledString
+
+Converts ANSI-styled strings into cell-based representation. Implements Drawable.
+
+```go
+ss := uv.NewStyledString("Hello \x1b[1mWorld\x1b[0m")
+ss.Draw(screen, area)
+```
+
+## Sub-Packages
+
+### screen/ - Screen Helpers
+
+Utility functions that work with any `Screen` implementation.
+
+```go
+import "github.com/charmbracelet/ultraviolet/screen"
+
+screen.Clear(scr) // clear entire screen
+screen.ClearArea(scr, area) // clear region
+screen.Fill(scr, &cell) // fill screen
+screen.FillArea(scr, &cell, area) // fill region
+screen.Clone(scr) // deep copy to Buffer
+screen.CloneArea(scr, area) // deep copy region
+
+// Drawing context with stateful style
+ctx := screen.NewContext(scr)
+ctx.SetForeground(ansi.Red)
+ctx.SetBold(true)
+ctx.DrawString("hello", x, y)
+ctx.Printf("count: %d", n) // implements io.Writer
+```
+
+### layout/ - Constraint-Based Layout
+
+Cassowary-based layout solver (ported from Ratatui). Splits areas into non-overlapping rectangles.
+
+```go
+import "github.com/charmbracelet/ultraviolet/layout"
+
+// Split area vertically into 3 parts
+chunks := layout.New().
+ Direction(layout.Vertical).
+ Constraints(
+ layout.Len(3), // fixed 3 rows
+ layout.Fill(1), // fill remaining
+ layout.Len(1), // fixed 1 row
+ ).
+ Split(area)
+```
+
+Constraint types: `Len`, `Ratio`, `Percent`, `Fill`, `Min`, `Max`.
+
+## Events
+
+Events come from `t.Events()` channel. Key types:
+
+| Event | Description |
+|---|---|
+| `WindowSizeEvent` | Terminal resized (width, height in cells) |
+| `PixelSizeEvent` | Terminal resized (width, height in pixels) |
+| `KeyPressEvent` | Key pressed. Use `ev.MatchString("ctrl+c", "q")` |
+| `KeyReleaseEvent` | Key released (requires kitty keyboard protocol) |
+| `MouseClickEvent` | Mouse click with position and button |
+| `MouseMotionEvent` | Mouse moved (requires mouse mode enabled) |
+| `PasteEvent` | Bracketed paste content |
+
+Key matching uses human-readable strings: `"ctrl+a"`, `"shift+enter"`, `"alt+tab"`, `"f1"`, `"space"`.
+
+## Geometry
+
+Uses `image.Point` and `image.Rectangle` from stdlib:
+
+```go
+pos := uv.Pos(x, y) // == image.Point{X: x, Y: y}
+rect := uv.Rect(x, y, width, height) // origin + size (NOT min/max)
+```
+
+Note: `uv.Rect(x, y, w, h)` takes width/height, not max coordinates. This differs from `image.Rect(x0, y0, x1, y1)`.
+
+## Style System
+
+Styles are value types with bitfield attributes:
+
+```go
+style := uv.Style{
+ Fg: ansi.Red,
+ Bg: ansi.Black,
+ UnderlineColor: ansi.Blue,
+ Underline: uv.UnderlineCurly,
+ Attrs: uv.AttrBold | uv.AttrItalic,
+}
+```
+
+Attributes: `AttrBold`, `AttrFaint`, `AttrItalic`, `AttrBlink`, `AttrReverse`, `AttrConceal`, `AttrStrikethrough`.
+
+Underline styles: `UnderlineNone`, `UnderlineSingle`, `UnderlineDouble`, `UnderlineCurly`, `UnderlineDotted`, `UnderlineDashed`.
+
+Style diffing is built in - the renderer computes minimal ANSI sequences to transition between styles.
+
+## Rendering Pipeline
+
+The "Cursed Renderer" is a cell-based diffing engine inspired by ncurses:
+
+1. You write cells to the screen buffer via `SetCell`
+2. `Render()` diffs current buffer against previous state
+3. Renderer emits minimal ANSI escape sequences (cursor movement, style changes, text)
+4. `Flush()` writes the accumulated output to the terminal
+
+Optimizations include:
+- Only touched lines are re-rendered
+- Style diffs minimize SGR sequence length
+- Cursor movement uses shortest path (absolute, relative, tabs, backspace)
+- Supports synchronized updates (mode 2026) to prevent flicker
+- Hash-based scroll detection for efficient content shifts
+
+## Minimal Hello World
+
+```go
+package main
+
+import (
+ "log"
+ uv "github.com/charmbracelet/ultraviolet"
+ "github.com/charmbracelet/ultraviolet/screen"
+)
+
+func main() {
+ t := uv.DefaultTerminal()
+ scr := t.Screen()
+ scr.EnterAltScreen()
+
+ if err := t.Start(); err != nil {
+ log.Fatal(err)
+ }
+ defer t.Stop()
+
+ ctx := screen.NewContext(scr)
+
+ for ev := range t.Events() {
+ switch ev := ev.(type) {
+ case uv.WindowSizeEvent:
+ scr.Resize(ev.Width, ev.Height)
+ case uv.KeyPressEvent:
+ if ev.MatchString("q", "ctrl+c") {
+ return
+ }
+ }
+
+ screen.Clear(scr)
+ ctx.DrawString("Hello, World!", 0, 0)
+ scr.Render()
+ scr.Flush()
+ }
+}
+```
+
+## Relationship to Bubble Tea v2
+
+```
+ultraviolet (primitives)
+ |
+ +-- Lip Gloss v2 (styling, composition)
+ |
+ +-- Bubble Tea v2 (framework: Elm architecture, state management, commands)
+ |
+ +-- Bubbles (components: text input, list, table, etc.)
+```
+
+- Ultraviolet provides: cells, buffers, screen management, input decoding, rendering
+- Bubble Tea v2 provides: `Program`, `Model`, `Update`, `View`, commands, subscriptions
+- Lip Gloss v2 provides: `Style`, layout, borders, padding, composition
+
+If you are building a TUI application, start with Bubble Tea v2. Only reach for ultraviolet when Bubble Tea's abstractions get in your way.
+
+## Checklist
+
+Before using ultraviolet directly, confirm:
+
+- [ ] Bubble Tea v2 genuinely cannot solve your problem
+- [ ] You need cell-level rendering control
+- [ ] You accept API instability risk
+- [ ] You understand the rendering pipeline (SetCell -> Render -> Flush)
+- [ ] You handle WindowSizeEvent and call Resize yourself
+- [ ] You manage terminal raw mode and cleanup (Start/Stop)
+- [ ] You have read the examples in the `examples/` directory
@@ -0,0 +1,219 @@
+---
+name: charm-vhs
+description: "Record terminal sessions as GIF/MP4/WebM from declarative .tape scripts with VHS. Use when creating terminal demos, recording CLI sessions, VHS tape files, or generating terminal GIFs."
+---
+
+# charm-vhs
+
+VHS records terminal sessions from `.tape` scripts. Requires `ttyd` and `ffmpeg` on PATH.
+
+```sh
+brew install vhs # also installs deps on macOS
+vhs demo.tape # run a tape file
+vhs new demo.tape # scaffold a new tape
+vhs record > out.tape # record interactively, then exit
+vhs publish demo.gif # host on vhs.charm.sh
+```
+
+## Tape File Structure
+
+Order matters: `Output` and `Set` must come before action commands. `Require` goes at the very top.
+
+```
+Require <program> # fail early if missing from PATH
+Output <path> # .gif / .mp4 / .webm / .ascii / frames/
+Set <Setting> Value # terminal config (must precede actions)
+<actions> # Type, Enter, Sleep, etc.
+```
+
+## Output Formats
+
+```elixir
+Output demo.gif
+Output demo.mp4
+Output demo.webm
+Output frames/ # PNG sequence
+Output golden.ascii # for CI golden file diffing
+```
+
+Multiple `Output` lines are fine - all render in one run.
+
+## Settings Reference
+
+```elixir
+Set Shell "zsh"
+Set FontSize 14
+Set FontFamily "JetBrains Mono"
+Set Width 1200
+Set Height 600
+Set Padding 20
+Set Margin 40
+Set MarginFill "#6B50FF"
+Set BorderRadius 10
+Set WindowBar Colorful # Colorful, ColorfulRight, Rings, RingsRight
+Set Theme "Catppuccin Frappe" # run `vhs themes` for full list
+Set TypingSpeed 0.05 # seconds per keypress
+Set Framerate 60
+Set PlaybackSpeed 1.0
+Set LoopOffset 50% # where GIF loop starts
+Set CursorBlink false
+```
+
+`TypingSpeed` is the only setting that can change mid-tape. All others are ignored after the first action command.
+
+## Action Commands
+
+### Typing + input
+
+```elixir
+Type "git status" # types the string
+Type@500ms "slowly..." # override typing speed for this line
+Type `VAR="backtick escapes quotes"`
+Enter
+Enter 2 # press N times
+Tab
+Tab@200ms 3
+Backspace 5
+Space 2
+```
+
+### Navigation
+
+```elixir
+Up / Down / Left / Right # arrow keys
+Up 3 # repeat N times
+PageUp / PageDown
+ScrollUp 10
+ScrollDown@100ms 5
+Ctrl+C
+Ctrl+Alt+Delete
+```
+
+### Timing
+
+```elixir
+Sleep 500ms
+Sleep 2s
+Sleep 0.5 # seconds (float ok)
+Wait /regex/ # wait until last line matches (default timeout 15s)
+Wait+Screen /regex/ # check whole screen
+Wait+Line@10ms /regex/ # poll every 10ms
+```
+
+`Wait` is better than `Sleep` for commands with unpredictable runtime (builds, network calls).
+
+### Hide / Show
+
+```elixir
+Hide
+Type "setup stuff not shown in recording"
+Enter
+Wait /\$/
+Show
+```
+
+Use `Hide`/`Show` to run setup or cleanup without polluting the demo.
+
+### Other
+
+```elixir
+Screenshot path/out.png # capture current frame as PNG
+Copy "text" # put text on clipboard
+Paste # paste clipboard
+Env KEY "value" # set env var
+Source config.tape # include another tape
+```
+
+## Example Tapes
+
+### 1. Simple CLI demo
+
+```elixir
+Output demo.gif
+
+Set FontSize 14
+Set Width 900
+Set Height 400
+Set Theme "Catppuccin Frappe"
+Set WindowBar Colorful
+Set TypingSpeed 0.05
+
+Type "ls -la"
+Sleep 300ms
+Enter
+Sleep 2s
+```
+
+### 2. Build + run with hidden setup
+
+```elixir
+Output demo.gif
+
+Set FontSize 13
+Set Width 1200
+Set Height 600
+Set Theme "Dracula"
+
+Require go
+
+Hide
+Type "go build -o myapp . && clear"
+Enter
+Wait /\$/
+Show
+
+Type "./myapp --help"
+Sleep 200ms
+Enter
+Sleep 3s
+
+Hide
+Type "rm myapp"
+Enter
+```
+
+### 3. Interactive TUI demo with Wait
+
+```elixir
+Output tui-demo.gif
+Output tui-demo.mp4
+
+Set FontSize 14
+Set Width 1000
+Set Height 500
+Set WindowBar Rings
+Set Margin 30
+Set MarginFill "#1a1b26"
+Set BorderRadius 8
+Set TypingSpeed 0.07
+
+Require gum
+
+Type "gum choose 'Option A' 'Option B' 'Option C'"
+Enter
+Sleep 500ms
+Down
+Sleep 300ms
+Down
+Sleep 500ms
+Enter
+Sleep 2s
+```
+
+## CI Integration
+
+Use the [vhs-action](https://github.com/charmbracelet/vhs-action) GitHub Action to regenerate GIFs on push.
+
+For integration testing, output `.ascii` and commit as golden files - diff them in CI to catch terminal output regressions.
+
+```elixir
+Output golden.ascii
+```
+
+## Tips
+
+- `vhs record > cassette.tape` then edit the generated tape to add `Set` blocks and clean up timing
+- Use `Source` to share a `config.tape` with common `Set` defaults across multiple tapes
+- `Wait` beats `Sleep` for anything async - no need to guess how long a build takes
+- `LoopOffset` makes the GIF preview frame more interesting than frame 0
+- `vhs themes` lists all built-in theme names
@@ -0,0 +1,358 @@
+---
+name: claude-headless
+description: Build custom UIs on top of Claude Code's headless mode. Covers spawning, NDJSON protocol, permission hooks, and session management. Use when building a desktop app, TUI, web UI, or any custom interface that wraps Claude Code as a subprocess.
+---
+
+# Claude Headless
+
+Build custom UIs and applications on top of Claude Code by running it as a headless subprocess. Claude Code exposes a bidirectional NDJSON protocol over stdin/stdout that gives you full control over prompts, streaming responses, tool approvals, and session continuity.
+
+For complete event type catalog, read `references/event-types.md`.
+For working code examples in Go and TypeScript, read `references/code-examples.md`.
+
+## Architecture Overview
+
+```
+Your App (any language/framework)
+ |
+ ├── spawn: claude -p --input-format stream-json --output-format stream-json --verbose
+ |
+ ├── stdin → write NDJSON messages (prompts, permission responses)
+ ├── stdout ← read NDJSON events (text chunks, tool calls, results)
+ └── stderr ← diagnostic logs (not structured, for debugging only)
+```
+
+Claude Code runs as a child process. You write JSON lines to stdin, read JSON lines from stdout. No API key needed - it uses the existing OAuth login from `claude login`. Same subscription limits as the interactive CLI.
+
+## Spawning Claude in Headless Mode
+
+### Required Flags
+
+```
+claude -p \
+ --input-format stream-json \
+ --output-format stream-json \
+ --verbose \
+ --include-partial-messages
+```
+
+| Flag | Purpose |
+|------|---------|
+| `-p` | Print mode - non-interactive, reads from stdin |
+| `--input-format stream-json` | Accept NDJSON on stdin (bidirectional) |
+| `--output-format stream-json` | Emit NDJSON on stdout |
+| `--verbose` | Include streaming events (content deltas, tool call updates) |
+| `--include-partial-messages` | Emit partial content block events during streaming |
+
+### Optional Flags
+
+| Flag | Purpose |
+|------|---------|
+| `--resume <session-id>` | Continue an existing session |
+| `--model <model>` | Choose model (e.g. `claude-sonnet-4-20250514`) |
+| `--permission-mode default` | Use default permission behavior |
+| `--allowedTools <tools>` | Comma-separated list of pre-approved tools |
+| `--settings <path>` | Path to settings JSON with hook config |
+| `--system-prompt <text>` | Replace the default system prompt entirely |
+| `--append-system-prompt <text>` | Append to the default system prompt (additive, can coexist with `--system-prompt`) |
+| `--max-turns <n>` | Limit number of agentic turns |
+| `--max-budget-usd <n>` | Set spending cap per run |
+| `--add-dir <path>` | Add extra directories to context (repeatable) |
+
+### Stdio Config
+
+Spawn with all three pipes: `stdin`, `stdout`, `stderr` as `pipe`. Stdin must stay open for follow-up messages. Set stdout encoding to UTF-8.
+
+### Environment
+
+Delete the `CLAUDECODE` env var if it exists in your process - it interferes with subprocess spawning. Ensure the `claude` binary is on `PATH` or use an absolute path.
+
+## NDJSON Input Protocol (stdin)
+
+### Sending a Prompt
+
+Write a single JSON line to stdin:
+
+```json
+{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Your prompt here"}]}}
+```
+
+**Important:** append `\n` after each JSON object. Stdin stays open - do not close it after writing. The process accepts multiple messages over its lifetime.
+
+### Content Types
+
+Text message:
+```json
+{
+ "type": "user",
+ "message": {
+ "role": "user",
+ "content": [{"type": "text", "text": "Explain this code"}]
+ }
+}
+```
+
+The content array follows the Anthropic messages API format. Each element has a `type` field.
+
+### Permission Response
+
+When Claude requests tool approval and you're using the stdin-based permission flow (not HTTP hooks):
+
+```json
+{
+ "type": "permission_response",
+ "question_id": "the-question-id-from-the-event",
+ "option_id": "allow"
+}
+```
+
+Valid option IDs: `allow`, `allow-session`, `deny`. The `question_id` comes from the `permission_request` event.
+
+### Follow-up Messages
+
+Write additional user messages to stdin at any time. Claude processes them sequentially. After receiving a `result` event, close stdin to trigger a clean process exit.
+
+## NDJSON Output Protocol (stdout)
+
+Every line on stdout is a JSON object with a `type` field. Events arrive in this lifecycle order:
+
+```
+system (init) -> stream_event* -> assistant -> result
+ ^ |
+ | (tool loop) |
+ +----------------+
+```
+
+### Event Lifecycle
+
+1. **`system`** (subtype `init`) - first event, contains session metadata
+2. **`stream_event`** - streaming content: text deltas, tool call starts/updates/stops
+3. **`assistant`** - assembled message with all content blocks (after streaming completes)
+4. **`result`** - final event, contains cost/usage/session_id
+
+Between steps 2-3, tool calls may trigger `permission_request` events (if using stdin-based permissions) or HTTP hook requests (if using a hook server).
+
+Rate limits produce `rate_limit_event` at any point.
+
+### Parsing Strategy
+
+Buffer incoming stdout data. Split on `\n`. Parse each non-empty line as JSON. Handle incomplete lines by keeping a buffer of the trailing fragment.
+
+```
+buffer += chunk
+lines = buffer.split('\n')
+buffer = lines.pop() // keep incomplete trailing line
+for each line in lines:
+ if line.trim() is empty: skip
+ event = JSON.parse(line.trim())
+ handle(event)
+```
+
+On stream end, flush the buffer (parse any remaining content).
+
+### Detecting Completion
+
+The `result` event signals the run is complete. After receiving it, close stdin to trigger process exit. The process stays alive in `stream-json` input mode waiting for more input - closing stdin is what triggers the clean shutdown.
+
+```json
+{"type":"result","subtype":"success","result":"...","session_id":"...","total_cost_usd":0.003,...}
+```
+
+Check `is_error` and `subtype` on the result event. If `is_error` is true or `subtype` is `"error"`, the run failed.
+
+## Permission Hook Server
+
+For production UIs, use an HTTP-based PreToolUse hook instead of stdin-based permission flow. This gives you a proper request/response cycle with timeouts and scoped approvals.
+
+### How It Works
+
+1. Start a local HTTP server before spawning Claude
+2. Generate a per-run settings JSON file pointing Claude to your hook URL
+3. Pass the settings file via `--settings <path>`
+4. When Claude wants to use a tool, it POSTs to your hook URL
+5. Your server returns allow/deny
+6. Claude proceeds or skips the tool
+
+### Settings File Format
+
+```json
+{
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "^(Bash|Edit|Write|MultiEdit|mcp__.*)$",
+ "hooks": [
+ {
+ "type": "http",
+ "url": "http://127.0.0.1:19836/hook/pre-tool-use/<app-secret>/<run-token>",
+ "timeout": 300
+ }
+ ]
+ }
+ ]
+ }
+}
+```
+
+The `matcher` is a regex against tool names. Only matched tools trigger the hook - unmatched tools need `--allowedTools` to run.
+
+**Security pattern:** embed a per-launch app secret and per-run token in the URL path. Validate both on every request. This prevents local spoofing and cross-run confusion.
+
+**File lifecycle:** write the settings file to a temp directory with restrictive permissions (0o600), clean it up when the run ends.
+
+### Hook Request (POST body from Claude)
+
+```json
+{
+ "session_id": "abc-123",
+ "hook_event_name": "PreToolUse",
+ "tool_name": "Bash",
+ "tool_input": {"command": "rm -rf /tmp/test"},
+ "tool_use_id": "toolu_xyz",
+ "cwd": "/Users/me/project",
+ "permission_mode": "default",
+ "transcript_path": "/path/to/transcript.jsonl"
+}
+```
+
+### Hook Response (your server returns)
+
+Allow:
+```json
+{
+ "hookSpecificOutput": {
+ "hookEventName": "PreToolUse",
+ "permissionDecision": "allow",
+ "permissionDecisionReason": "Approved by user"
+ }
+}
+```
+
+Deny:
+```json
+{
+ "hookSpecificOutput": {
+ "hookEventName": "PreToolUse",
+ "permissionDecision": "deny",
+ "permissionDecisionReason": "User denied"
+ }
+}
+```
+
+### Tool Safety Tiers
+
+Split tools into safe (auto-approve) and dangerous (require approval):
+
+**Safe tools** (pass via `--allowedTools`):
+`Read`, `Glob`, `Grep`, `LS`, `TodoRead`, `TodoWrite`, `Agent`, `Task`, `TaskOutput`, `Notebook`, `WebSearch`, `WebFetch`
+
+**Dangerous tools** (route through hook server):
+`Bash`, `Edit`, `Write`, `MultiEdit`, and any `mcp__*` tools
+
+You can additionally auto-approve read-only Bash commands by inspecting `tool_input.command` before prompting the user.
+
+### Timeout Behavior
+
+The hook has a `timeout` field in seconds (300 = 5 minutes). If your server doesn't respond in time, Claude treats it as a denial. Always deny-by-default on every failure path (parse errors, invalid tokens, timeouts).
+
+### Scoped Approvals
+
+Track user decisions to reduce permission fatigue:
+
+- **Session-scoped:** user approves "Edit" once, auto-allow for the rest of the session. Key: `session:<id>:tool:<name>`
+- **Domain-scoped:** for WebFetch, approve a domain once. Key: `session:<id>:webfetch:<domain>`
+- **Per-command:** Bash commands are too diverse for blanket approval - review each individually
+
+## Session Management
+
+### Session IDs
+
+The `system` init event returns a `session_id`. Store it. Pass it back via `--resume <session-id>` on subsequent runs to continue the conversation.
+
+### Multiple Concurrent Sessions
+
+Each session is a separate `claude -p` child process. You can run many in parallel. Track each by a unique request ID mapped to its process handle.
+
+### Session Lifecycle
+
+```
+idle -> connecting -> running -> completed
+ | |
+ v v
+ failed idle (new prompt)
+ |
+ v
+ dead (unrecoverable)
+```
+
+- **connecting:** process spawned, waiting for `system` init event
+- **running:** init received, streaming in progress
+- **completed:** `result` event received with `subtype: "success"`
+- **failed:** non-zero exit, SIGINT/SIGKILL, or error result
+- **dead:** process error (binary not found, spawn failure)
+
+### Tab Pattern
+
+For multi-tab UIs, maintain a registry mapping tab IDs to session state:
+
+```
+Tab Registry:
+ tabId -> {
+ claudeSessionId: string | null,
+ status: TabStatus,
+ activeRequestId: string | null,
+ promptCount: number,
+ }
+```
+
+Queue prompts if a tab already has an active run. Process the queue when the current run completes.
+
+### Cancellation
+
+Send SIGINT to the child process. If it hasn't exited after 5 seconds, send SIGKILL.
+
+## Model Routing
+
+Pass `--model <model-id>` when spawning. To switch models mid-conversation, start a new process with `--resume <session-id> --model <new-model>`. The session context carries over.
+
+## Common Patterns
+
+### Streaming Text to UI
+
+Listen for `stream_event` events where the inner event type is `content_block_delta` with `delta.type === "text_delta"`. Append `delta.text` to your display buffer.
+
+### Tracking Tool Calls
+
+1. `content_block_start` with `content_block.type === "tool_use"` - tool call begins, extract `name` and `id`
+2. `content_block_delta` with `delta.type === "input_json_delta"` - partial tool input JSON arrives
+3. `content_block_stop` - tool call input is complete
+
+The `assistant` event arrives after all content blocks, containing the fully assembled message with all tool calls and their complete inputs.
+
+### Idempotent Request IDs
+
+Use unique request IDs for each prompt submission. If a duplicate ID is submitted while inflight, return the existing promise instead of spawning a new process. This prevents double-submissions from UI race conditions.
+
+### Request Queuing
+
+If a tab already has an active run, queue the new request. Process the queue (FIFO) when the current run's exit event fires. Set a max queue depth (32 is reasonable) and reject with backpressure when full.
+
+### Warm-up Init
+
+To pre-populate session metadata (available tools, model, MCP servers) without showing a visible message, fire a minimal prompt like `"hi"` with `--max-turns 1` at tab creation. Suppress all events except the `session_init` from this request.
+
+## What NOT to Do
+
+1. **Don't close stdin after the first prompt.** The process stays alive for follow-up messages. Only close stdin after receiving the `result` event to trigger clean exit.
+
+2. **Don't parse stderr as structured data.** It contains diagnostic logs, not NDJSON. Read it for debugging only.
+
+3. **Don't use `--output-format json`** (non-streaming). You get a single JSON blob at the end with no intermediate events. Always use `stream-json`.
+
+4. **Don't skip `--verbose` and `--include-partial-messages`.** Without these, you miss streaming content deltas and tool call updates. Your UI will appear frozen until the full response completes.
+
+5. **Don't auto-approve all tools without a hook server.** If you pass every tool in `--allowedTools`, Claude will execute destructive operations (file writes, shell commands) without user consent.
+
+6. **Don't ignore the `CLAUDECODE` env var.** If your app is itself running inside Claude Code, this var will be set and can interfere with subprocess spawning. Delete it from the child's environment.
+
+7. **Don't forget request ID idempotency.** UI double-clicks and network retries can cause duplicate submissions. Always check if a request ID is already inflight or queued before spawning.
@@ -0,0 +1,764 @@
+# Code Examples
+
+Working code for spawning Claude headless, parsing NDJSON, and handling permissions. TypeScript and Go.
+
+## TypeScript: Spawn and Parse
+
+### NDJSON Stream Parser
+
+Buffers incoming data and emits complete JSON objects line by line.
+
+```typescript
+import { Readable } from 'stream'
+import { EventEmitter } from 'events'
+
+interface ClaudeEvent {
+ type: string
+ [key: string]: unknown
+}
+
+class StreamParser extends EventEmitter {
+ private buffer = ''
+
+ feed(chunk: string): void {
+ this.buffer += chunk
+ const lines = this.buffer.split('\n')
+ this.buffer = lines.pop() || '' // keep incomplete trailing line
+
+ for (const line of lines) {
+ const trimmed = line.trim()
+ if (!trimmed) continue
+ try {
+ const parsed = JSON.parse(trimmed) as ClaudeEvent
+ this.emit('event', parsed)
+ } catch {
+ this.emit('parse-error', trimmed)
+ }
+ }
+ }
+
+ flush(): void {
+ const trimmed = this.buffer.trim()
+ if (trimmed) {
+ try {
+ this.emit('event', JSON.parse(trimmed))
+ } catch {
+ this.emit('parse-error', trimmed)
+ }
+ }
+ this.buffer = ''
+ }
+
+ static fromStream(stream: Readable): StreamParser {
+ const parser = new StreamParser()
+ stream.setEncoding('utf-8')
+ stream.on('data', (chunk: string) => parser.feed(chunk))
+ stream.on('end', () => parser.flush())
+ return parser
+ }
+}
+```
+
+### Spawning Claude
+
+```typescript
+import { spawn, ChildProcess } from 'child_process'
+
+interface RunOptions {
+ prompt: string
+ cwd: string
+ sessionId?: string
+ model?: string
+ hookSettingsPath?: string
+ allowedTools?: string[]
+ maxTurns?: number
+}
+
+function startRun(options: RunOptions): ChildProcess {
+ const args: string[] = [
+ '-p',
+ '--input-format', 'stream-json',
+ '--output-format', 'stream-json',
+ '--verbose',
+ '--include-partial-messages',
+ '--permission-mode', 'default',
+ ]
+
+ if (options.sessionId) {
+ args.push('--resume', options.sessionId)
+ }
+ if (options.model) {
+ args.push('--model', options.model)
+ }
+ if (options.hookSettingsPath) {
+ args.push('--settings', options.hookSettingsPath)
+ }
+ if (options.allowedTools?.length) {
+ args.push('--allowedTools', options.allowedTools.join(','))
+ }
+ if (options.maxTurns) {
+ args.push('--max-turns', String(options.maxTurns))
+ }
+
+ // Remove CLAUDECODE env var to avoid subprocess conflicts
+ const env = { ...process.env }
+ delete env.CLAUDECODE
+
+ const child = spawn('claude', args, {
+ stdio: ['pipe', 'pipe', 'pipe'],
+ cwd: options.cwd,
+ env,
+ })
+
+ // Write initial prompt
+ const userMessage = JSON.stringify({
+ type: 'user',
+ message: {
+ role: 'user',
+ content: [{ type: 'text', text: options.prompt }],
+ },
+ })
+ child.stdin!.write(userMessage + '\n')
+
+ return child
+}
+```
+
+### Full Lifecycle Example
+
+```typescript
+async function runPrompt(prompt: string, cwd: string): Promise<string> {
+ const child = startRun({ prompt, cwd })
+
+ const parser = StreamParser.fromStream(child.stdout!)
+ let sessionId: string | null = null
+ let resultText = ''
+ const textChunks: string[] = []
+
+ return new Promise((resolve, reject) => {
+ parser.on('event', (event: ClaudeEvent) => {
+ switch (event.type) {
+ case 'system':
+ if ((event as any).subtype === 'init') {
+ sessionId = (event as any).session_id
+ }
+ break
+
+ case 'stream_event': {
+ const sub = (event as any).event
+ if (sub?.type === 'content_block_delta' && sub.delta?.type === 'text_delta') {
+ textChunks.push(sub.delta.text)
+ process.stdout.write(sub.delta.text) // stream to terminal
+ }
+ break
+ }
+
+ case 'result':
+ resultText = (event as any).result || textChunks.join('')
+ // Close stdin to trigger clean exit
+ try { child.stdin?.end() } catch {}
+ break
+ }
+ })
+
+ child.on('close', (code) => {
+ if (code === 0) {
+ resolve(resultText)
+ } else {
+ reject(new Error(`claude exited with code ${code}`))
+ }
+ })
+
+ child.on('error', reject)
+ })
+}
+```
+
+## TypeScript: Permission Hook Server
+
+### Minimal HTTP Hook Server
+
+```typescript
+import { createServer, IncomingMessage, ServerResponse } from 'http'
+import { writeFileSync, mkdirSync, unlinkSync } from 'fs'
+import { tmpdir } from 'os'
+import { join } from 'path'
+import { randomUUID } from 'crypto'
+
+const PORT = 19836
+const APP_SECRET = randomUUID()
+
+interface HookRequest {
+ session_id: string
+ hook_event_name: string
+ tool_name: string
+ tool_input: Record<string, unknown>
+ tool_use_id: string
+ cwd: string
+}
+
+// Tools that need user approval
+const DANGEROUS_TOOLS = new Set(['Bash', 'Edit', 'Write', 'MultiEdit'])
+
+// Tools to auto-approve via --allowedTools
+const SAFE_TOOLS = [
+ 'Read', 'Glob', 'Grep', 'LS',
+ 'TodoRead', 'TodoWrite',
+ 'Agent', 'Task', 'TaskOutput',
+ 'Notebook', 'WebSearch', 'WebFetch',
+]
+
+function allowResponse(reason: string) {
+ return {
+ hookSpecificOutput: {
+ hookEventName: 'PreToolUse',
+ permissionDecision: 'allow',
+ permissionDecisionReason: reason,
+ },
+ }
+}
+
+function denyResponse(reason: string) {
+ return {
+ hookSpecificOutput: {
+ hookEventName: 'PreToolUse',
+ permissionDecision: 'deny',
+ permissionDecisionReason: reason,
+ },
+ }
+}
+
+// Your approval callback - wire this to your UI
+type ApprovalCallback = (tool: string, input: Record<string, unknown>) => Promise<boolean>
+
+function startHookServer(onApproval: ApprovalCallback) {
+ const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
+ if (req.method !== 'POST') {
+ res.writeHead(404)
+ res.end(JSON.stringify(denyResponse('Not found')))
+ return
+ }
+
+ // Validate URL path: /hook/pre-tool-use/<secret>/<token>
+ const segments = (req.url || '').split('/').filter(Boolean)
+ if (segments.length < 3 || segments[2] !== APP_SECRET) {
+ res.writeHead(403)
+ res.end(JSON.stringify(denyResponse('Invalid credentials')))
+ return
+ }
+
+ // Read body
+ let body = ''
+ for await (const chunk of req) body += chunk
+
+ const toolReq: HookRequest = JSON.parse(body)
+
+ // Auto-approve if not a dangerous tool (belt-and-suspenders with matcher)
+ if (!DANGEROUS_TOOLS.has(toolReq.tool_name)) {
+ res.writeHead(200, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify(allowResponse('Safe tool')))
+ return
+ }
+
+ // Ask user via your UI
+ const approved = await onApproval(toolReq.tool_name, toolReq.tool_input)
+
+ res.writeHead(200, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify(
+ approved
+ ? allowResponse('User approved')
+ : denyResponse('User denied')
+ ))
+ })
+
+ server.listen(PORT, '127.0.0.1')
+ return server
+}
+
+// Generate settings file for a run
+function generateSettingsFile(runToken: string): string {
+ const matcher = '^(Bash|Edit|Write|MultiEdit|mcp__.*)$'
+ const settings = {
+ hooks: {
+ PreToolUse: [{
+ matcher,
+ hooks: [{
+ type: 'http',
+ url: `http://127.0.0.1:${PORT}/hook/pre-tool-use/${APP_SECRET}/${runToken}`,
+ timeout: 300,
+ }],
+ }],
+ },
+ }
+
+ const dir = join(tmpdir(), 'my-app-hooks')
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
+ const filePath = join(dir, `hook-${runToken}.json`)
+ writeFileSync(filePath, JSON.stringify(settings), { mode: 0o600 })
+ return filePath
+}
+```
+
+## Go: Spawn and Parse
+
+### NDJSON Parser
+
+```go
+package claude
+
+import (
+ "bufio"
+ "encoding/json"
+ "io"
+)
+
+// Event represents any NDJSON event from Claude's stdout.
+type Event struct {
+ Type string `json:"type"`
+ Subtype string `json:"subtype,omitempty"`
+ SessionID string `json:"session_id,omitempty"`
+ Raw json.RawMessage `json:"-"` // full original JSON
+}
+
+// StreamSubEvent is the nested event inside stream_event.
+type StreamSubEvent struct {
+ Type string `json:"type"`
+ Index int `json:"index,omitempty"`
+ ContentBlock ContentBlock `json:"content_block,omitempty"`
+ Delta Delta `json:"delta,omitempty"`
+}
+
+type ContentBlock struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ ID string `json:"id,omitempty"`
+ Name string `json:"name,omitempty"`
+}
+
+type Delta struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ PartialJSON string `json:"partial_json,omitempty"`
+}
+
+// StreamEvent is the full stream_event with its nested event.
+type StreamEvent struct {
+ Type string `json:"type"`
+ Event StreamSubEvent `json:"event"`
+ SessionID string `json:"session_id"`
+ ParentToolUseID *string `json:"parent_tool_use_id"`
+}
+
+// ResultEvent is the final event of a run.
+type ResultEvent struct {
+ Type string `json:"type"`
+ Subtype string `json:"subtype"`
+ IsError bool `json:"is_error"`
+ Result string `json:"result"`
+ SessionID string `json:"session_id"`
+ TotalCostUSD float64 `json:"total_cost_usd"`
+ DurationMs int `json:"duration_ms"`
+ NumTurns int `json:"num_turns"`
+}
+
+// ParseEvents reads NDJSON lines from a reader and sends parsed events to a channel.
+func ParseEvents(r io.Reader, events chan<- json.RawMessage, errs chan<- error) {
+ scanner := bufio.NewScanner(r)
+ // Increase buffer for large events
+ scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
+
+ for scanner.Scan() {
+ line := scanner.Bytes()
+ if len(line) == 0 {
+ continue
+ }
+ // Make a copy since scanner reuses the buffer
+ cp := make([]byte, len(line))
+ copy(cp, line)
+ events <- json.RawMessage(cp)
+ }
+
+ if err := scanner.Err(); err != nil {
+ errs <- err
+ }
+ close(events)
+}
+```
+
+### Spawning Claude
+
+```go
+package claude
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+)
+
+type RunConfig struct {
+ Prompt string
+ Cwd string
+ SessionID string
+ Model string
+ SettingsPath string
+ AllowedTools []string
+ MaxTurns int
+}
+
+// UserMessage is the NDJSON input format for stdin.
+type UserMessage struct {
+ Type string `json:"type"`
+ Message MessagePayload `json:"message"`
+}
+
+type MessagePayload struct {
+ Role string `json:"role"`
+ Content []ContentPart `json:"content"`
+}
+
+type ContentPart struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+}
+
+// SpawnResult holds the started process and its I/O handles.
+type SpawnResult struct {
+ Cmd *exec.Cmd
+ Stdin io.WriteCloser
+ Stdout io.ReadCloser
+}
+
+func SpawnClaude(cfg RunConfig) (*SpawnResult, error) {
+ args := []string{
+ "-p",
+ "--input-format", "stream-json",
+ "--output-format", "stream-json",
+ "--verbose",
+ "--include-partial-messages",
+ "--permission-mode", "default",
+ }
+
+ if cfg.SessionID != "" {
+ args = append(args, "--resume", cfg.SessionID)
+ }
+ if cfg.Model != "" {
+ args = append(args, "--model", cfg.Model)
+ }
+ if cfg.SettingsPath != "" {
+ args = append(args, "--settings", cfg.SettingsPath)
+ }
+ if len(cfg.AllowedTools) > 0 {
+ args = append(args, "--allowedTools", strings.Join(cfg.AllowedTools, ","))
+ }
+ if cfg.MaxTurns > 0 {
+ args = append(args, "--max-turns", fmt.Sprintf("%d", cfg.MaxTurns))
+ }
+
+ cmd := exec.Command("claude", args...)
+ cmd.Dir = cfg.Cwd
+
+ // Clean env
+ env := os.Environ()
+ filtered := make([]string, 0, len(env))
+ for _, e := range env {
+ if !strings.HasPrefix(e, "CLAUDECODE=") {
+ filtered = append(filtered, e)
+ }
+ }
+ cmd.Env = filtered
+ cmd.Stderr = os.Stderr // or capture separately
+
+ // Get pipes before Start - cannot mix StdinPipe with cmd.Stdin assignment
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ return nil, err
+ }
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return nil, err
+ }
+
+ if err := cmd.Start(); err != nil {
+ return nil, err
+ }
+
+ return &SpawnResult{Cmd: cmd, Stdin: stdin, Stdout: stdout}, nil
+}
+
+// WritePrompt sends a user message to Claude's stdin.
+func WritePrompt(stdin io.WriteCloser, prompt string) error {
+ msg := UserMessage{
+ Type: "user",
+ Message: MessagePayload{
+ Role: "user",
+ Content: []ContentPart{
+ {Type: "text", Text: prompt},
+ },
+ },
+ }
+
+ data, err := json.Marshal(msg)
+ if err != nil {
+ return err
+ }
+
+ _, err = fmt.Fprintf(stdin, "%s\n", data)
+ return err
+}
+```
+
+### Bubbletea Integration Skeleton
+
+The key pattern: each `tea.Cmd` reads one event and returns it. Bubbletea calls `Update` with that message, then the model returns the next read command - creating a pull loop without goroutines racing against the program.
+
+```go
+package main
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os/exec"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type eventMsg struct{ raw json.RawMessage }
+type doneMsg struct{ err error }
+
+type model struct {
+ output strings.Builder
+ scanner *bufio.Scanner
+ stdin io.WriteCloser
+ done bool
+ err error
+}
+
+func (m model) Init() tea.Cmd {
+ cmd := exec.Command("claude", "-p",
+ "--input-format", "stream-json",
+ "--output-format", "stream-json",
+ "--verbose", "--include-partial-messages",
+ )
+ stdin, _ := cmd.StdinPipe()
+ stdout, _ := cmd.StdoutPipe()
+ if err := cmd.Start(); err != nil {
+ return func() tea.Msg { return doneMsg{err: err} }
+ }
+
+ prompt, _ := json.Marshal(map[string]any{
+ "type": "user",
+ "message": map[string]any{
+ "role": "user",
+ "content": []map[string]string{{"type": "text", "text": "Explain what a goroutine is"}},
+ },
+ })
+ fmt.Fprintf(stdin, "%s\n", prompt)
+
+ m.stdin = stdin
+ scanner := bufio.NewScanner(stdout)
+ scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
+ m.scanner = scanner
+
+ return readNext(scanner)
+}
+
+// readNext returns a tea.Cmd that blocks until the next NDJSON line arrives.
+func readNext(s *bufio.Scanner) tea.Cmd {
+ return func() tea.Msg {
+ for s.Scan() {
+ line := s.Bytes()
+ if len(line) == 0 {
+ continue
+ }
+ cp := make([]byte, len(line))
+ copy(cp, line)
+ return eventMsg{raw: cp}
+ }
+ return doneMsg{err: s.Err()}
+ }
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case eventMsg:
+ var base struct {
+ Type string `json:"type"`
+ Event struct {
+ Type string `json:"type"`
+ Delta struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ } `json:"delta"`
+ } `json:"event"`
+ }
+ json.Unmarshal(msg.raw, &base)
+ if base.Type == "stream_event" &&
+ base.Event.Type == "content_block_delta" &&
+ base.Event.Delta.Type == "text_delta" {
+ m.output.WriteString(base.Event.Delta.Text)
+ }
+ if base.Type == "result" {
+ m.done = true
+ m.stdin.Close()
+ }
+ return m, readNext(m.scanner)
+
+ case doneMsg:
+ m.done = true
+ m.err = msg.err
+ return m, tea.Quit
+
+ case tea.KeyMsg:
+ if msg.String() == "q" || msg.String() == "ctrl+c" {
+ return m, tea.Quit
+ }
+ }
+ return m, nil
+}
+
+func (m model) View() string {
+ status := "streaming..."
+ if m.done {
+ status = "done"
+ }
+ if m.err != nil {
+ return fmt.Sprintf("error: %v\n", m.err)
+ }
+ return fmt.Sprintf("[%s]\n\n%s\n\nPress q to quit.", status, m.output.String())
+}
+
+func main() {
+ p := tea.NewProgram(model{})
+ if _, err := p.Run(); err != nil {
+ fmt.Printf("error: %v\n", err)
+ }
+}
+```
+
+## Patterns
+
+### Cancellation (TypeScript)
+
+```typescript
+function cancelRun(child: ChildProcess): void {
+ child.kill('SIGINT')
+
+ // Fallback: SIGKILL after 5s if SIGINT didn't work
+ setTimeout(() => {
+ if (child.exitCode === null) {
+ child.kill('SIGKILL')
+ }
+ }, 5000)
+}
+```
+
+### Follow-up Message (TypeScript)
+
+```typescript
+function sendFollowUp(child: ChildProcess, text: string): void {
+ const msg = JSON.stringify({
+ type: 'user',
+ message: {
+ role: 'user',
+ content: [{ type: 'text', text }],
+ },
+ })
+ child.stdin!.write(msg + '\n')
+}
+```
+
+### Track Tool Calls (TypeScript)
+
+```typescript
+interface ToolCall {
+ id: string
+ name: string
+ inputFragments: string[]
+ complete: boolean
+}
+
+const activeTools = new Map<number, ToolCall>() // index -> tool
+
+function handleStreamEvent(event: any): void {
+ const sub = event.event
+ if (!sub) return
+
+ switch (sub.type) {
+ case 'content_block_start':
+ if (sub.content_block.type === 'tool_use') {
+ activeTools.set(sub.index, {
+ id: sub.content_block.id,
+ name: sub.content_block.name,
+ inputFragments: [],
+ complete: false,
+ })
+ }
+ break
+
+ case 'content_block_delta':
+ if (sub.delta.type === 'input_json_delta') {
+ const tool = activeTools.get(sub.index)
+ if (tool) {
+ tool.inputFragments.push(sub.delta.partial_json)
+ }
+ }
+ break
+
+ case 'content_block_stop': {
+ const tool = activeTools.get(sub.index)
+ if (tool) {
+ tool.complete = true
+ const fullInput = JSON.parse(tool.inputFragments.join(''))
+ console.log(`Tool ${tool.name}: ${JSON.stringify(fullInput)}`)
+ }
+ break
+ }
+ }
+}
+```
+
+### Diagnostic Ring Buffer (TypeScript)
+
+Keep a ring buffer of the last N stderr lines for error reporting.
+
+```typescript
+const MAX_LINES = 100
+
+class RingBuffer {
+ private lines: string[] = []
+
+ push(line: string): void {
+ this.lines.push(line)
+ if (this.lines.length > MAX_LINES) {
+ this.lines.shift()
+ }
+ }
+
+ tail(n: number): string[] {
+ return this.lines.slice(-n)
+ }
+}
+
+// Usage:
+const stderrBuf = new RingBuffer()
+child.stderr?.setEncoding('utf-8')
+child.stderr?.on('data', (data: string) => {
+ for (const line of data.split('\n').filter(l => l.trim())) {
+ stderrBuf.push(line)
+ }
+})
+
+// On error, include last 20 stderr lines in diagnostics
+child.on('close', (code) => {
+ if (code !== 0) {
+ console.error('Last stderr:', stderrBuf.tail(20))
+ }
+})
+```
@@ -0,0 +1,324 @@
+# Event Types Reference
+
+Complete catalog of all NDJSON events emitted by `claude -p --output-format stream-json --verbose`.
+
+## Top-Level Event Types
+
+Every stdout line is a JSON object with a `type` field. These are the possible values:
+
+| Type | When | Contains |
+|------|------|----------|
+| `system` | First event of a run | Session metadata, tools, model |
+| `stream_event` | During streaming | Content deltas, tool call fragments |
+| `assistant` | After streaming completes | Fully assembled message |
+| `result` | Final event | Cost, usage, session ID, errors |
+| `rate_limit_event` | When rate limited | Reset time, limit type |
+| `permission_request` | When tool needs approval | Tool name, input, options |
+
+## system (init)
+
+The first event. Subtype is always `init`.
+
+```json
+{
+ "type": "system",
+ "subtype": "init",
+ "cwd": "/Users/me/project",
+ "session_id": "abc-def-123",
+ "tools": ["Read", "Write", "Edit", "Bash", "Glob", "Grep", "LS", "Agent", "Task", "WebSearch", "WebFetch"],
+ "mcp_servers": [{"name": "my-server", "status": "connected"}],
+ "model": "claude-sonnet-4-20250514",
+ "permissionMode": "default",
+ "agents": [],
+ "skills": ["skill-name"],
+ "plugins": [],
+ "claude_code_version": "2.1.63",
+ "fast_mode_state": "disabled",
+ "uuid": "event-uuid"
+}
+```
+
+Key fields:
+- `session_id` - store this for `--resume`
+- `tools` - available tool names (varies by installation and MCP servers)
+- `model` - the active model
+- `mcp_servers` - connected MCP servers and their status
+- `claude_code_version` - for compatibility checks
+
+## stream_event
+
+Wraps Anthropic API streaming events. Has a nested `event` object.
+
+```json
+{
+ "type": "stream_event",
+ "event": { ... },
+ "session_id": "abc-def-123",
+ "parent_tool_use_id": null,
+ "uuid": "event-uuid"
+}
+```
+
+The `parent_tool_use_id` is non-null when the event comes from a subagent (tool-within-tool execution).
+
+### Sub-event: message_start
+
+Signals beginning of a new assistant message.
+
+```json
+{
+ "type": "stream_event",
+ "event": {
+ "type": "message_start",
+ "message": {
+ "model": "claude-sonnet-4-20250514",
+ "id": "msg_abc123",
+ "role": "assistant",
+ "content": [],
+ "stop_reason": null,
+ "usage": {"input_tokens": 100, "output_tokens": 0}
+ }
+ }
+}
+```
+
+### Sub-event: content_block_start (text)
+
+A text content block begins.
+
+```json
+{
+ "type": "stream_event",
+ "event": {
+ "type": "content_block_start",
+ "index": 0,
+ "content_block": {"type": "text", "text": ""}
+ }
+}
+```
+
+### Sub-event: content_block_start (tool_use)
+
+A tool call begins. Extract the tool `name` and `id`.
+
+```json
+{
+ "type": "stream_event",
+ "event": {
+ "type": "content_block_start",
+ "index": 1,
+ "content_block": {
+ "type": "tool_use",
+ "id": "toolu_abc123",
+ "name": "Read",
+ "input": {}
+ }
+ }
+}
+```
+
+### Sub-event: content_block_delta (text)
+
+Streaming text fragment. Append `delta.text` to your display.
+
+```json
+{
+ "type": "stream_event",
+ "event": {
+ "type": "content_block_delta",
+ "index": 0,
+ "delta": {"type": "text_delta", "text": "Here is the "}
+ }
+}
+```
+
+### Sub-event: content_block_delta (tool input)
+
+Streaming tool input JSON fragment. Accumulate `partial_json` to reconstruct the full tool input.
+
+```json
+{
+ "type": "stream_event",
+ "event": {
+ "type": "content_block_delta",
+ "index": 1,
+ "delta": {"type": "input_json_delta", "partial_json": "{\"file_path\":\""}
+ }
+}
+```
+
+### Sub-event: content_block_stop
+
+A content block (text or tool_use) is complete.
+
+```json
+{
+ "type": "stream_event",
+ "event": {
+ "type": "content_block_stop",
+ "index": 0
+ }
+}
+```
+
+### Sub-event: message_delta
+
+Message-level update with stop reason and usage.
+
+```json
+{
+ "type": "stream_event",
+ "event": {
+ "type": "message_delta",
+ "delta": {"stop_reason": "end_turn"},
+ "usage": {"input_tokens": 100, "output_tokens": 250}
+ }
+}
+```
+
+Stop reasons: `"end_turn"` (normal), `"tool_use"` (needs to call a tool), `"max_tokens"` (hit limit).
+
+### Sub-event: message_stop
+
+Message streaming is complete.
+
+```json
+{
+ "type": "stream_event",
+ "event": {"type": "message_stop"}
+}
+```
+
+## assistant
+
+Assembled message after streaming completes. Contains all content blocks with their full content.
+
+```json
+{
+ "type": "assistant",
+ "message": {
+ "model": "claude-sonnet-4-20250514",
+ "id": "msg_abc123",
+ "role": "assistant",
+ "content": [
+ {"type": "text", "text": "I'll read that file for you."},
+ {"type": "tool_use", "id": "toolu_abc123", "name": "Read", "input": {"file_path": "/tmp/test.txt"}}
+ ],
+ "stop_reason": "tool_use",
+ "usage": {"input_tokens": 100, "output_tokens": 50}
+ },
+ "parent_tool_use_id": null,
+ "session_id": "abc-def-123",
+ "uuid": "event-uuid"
+}
+```
+
+Use this event to get complete tool inputs (instead of reconstructing from `input_json_delta` fragments).
+
+## result
+
+Final event of a run. Always present, even on errors.
+
+### Success
+
+```json
+{
+ "type": "result",
+ "subtype": "success",
+ "is_error": false,
+ "duration_ms": 15234,
+ "num_turns": 3,
+ "result": "The file contains a list of...",
+ "total_cost_usd": 0.0034,
+ "session_id": "abc-def-123",
+ "usage": {
+ "input_tokens": 5000,
+ "output_tokens": 1200,
+ "cache_read_input_tokens": 3000,
+ "cache_creation_input_tokens": 500
+ },
+ "permission_denials": [],
+ "uuid": "event-uuid"
+}
+```
+
+### Error
+
+```json
+{
+ "type": "result",
+ "subtype": "error",
+ "is_error": true,
+ "result": "Error: rate limit exceeded",
+ "session_id": "abc-def-123",
+ "duration_ms": 1200,
+ "num_turns": 0,
+ "total_cost_usd": 0,
+ "usage": {},
+ "permission_denials": [],
+ "uuid": "event-uuid"
+}
+```
+
+Key fields:
+- `total_cost_usd` - cost of this run
+- `session_id` - store for `--resume`
+- `permission_denials` - tools denied during the run. Runtime sends `{tool_name, tool_use_id}` objects; types.ts declares it as `string[]`. Treat as `{tool_name: string, tool_use_id: string}[]` in practice.
+- `num_turns` - number of agentic turns taken
+
+## rate_limit_event
+
+Emitted when the subscription rate limit is hit.
+
+```json
+{
+ "type": "rate_limit_event",
+ "rate_limit_info": {
+ "status": "rate_limited",
+ "resetsAt": 1700000000,
+ "rateLimitType": "model"
+ },
+ "session_id": "abc-def-123",
+ "uuid": "event-uuid"
+}
+```
+
+`resetsAt` is a Unix timestamp. Show the user a countdown or retry after that time.
+
+## permission_request
+
+Emitted when Claude wants to use a tool that requires approval (stdin-based permission flow, not HTTP hooks).
+
+```json
+{
+ "type": "permission_request",
+ "tool": {
+ "name": "Bash",
+ "description": "Execute a bash command",
+ "input": {"command": "npm install express"}
+ },
+ "question_id": "perm-abc-123",
+ "options": [
+ {"id": "allow", "label": "Allow Once", "kind": "allow"},
+ {"id": "allow-session", "label": "Allow for Session", "kind": "allow"},
+ {"id": "deny", "label": "Deny", "kind": "deny"}
+ ],
+ "session_id": "abc-def-123",
+ "uuid": "event-uuid"
+}
+```
+
+Respond via stdin with:
+```json
+{"type":"permission_response","question_id":"perm-abc-123","option_id":"allow"}
+```
+
+## Event Ordering Guarantees
+
+1. `system` (init) is always the first event
+2. `result` is always the last event
+3. `stream_event` events arrive in order within a content block
+4. `content_block_start` always precedes its corresponding deltas and stop
+5. `assistant` arrives after all stream events for that message
+6. `permission_request` can arrive at any point between init and result
+7. `rate_limit_event` can arrive at any point
@@ -0,0 +1,201 @@
+---
+name: claude-md-forge
+description: Generate or optimize a CLAUDE.md and .claude/ infrastructure for a repository. Use when user says "create CLAUDE.md", "optimize CLAUDE.md", "set up .claude", "audit my CLAUDE.md", or is bootstrapping a new project.
+argument-hint: "[repo path or leave blank for current]"
+---
+
+# CLAUDE.md Forge
+
+Generate the best possible CLAUDE.md and .claude/ infrastructure for a repository. Research-backed principles, patterns from top open-source repos.
+
+## When to Use
+
+- Bootstrapping a new project
+- Optimizing an existing CLAUDE.md
+- Setting up .claude/ directory (rules, skills, settings, hooks)
+- Auditing current CLAUDE.md for anti-patterns
+
+## Process
+
+Target repository: $ARGUMENTS (if blank, use current working directory).
+
+### Step 1: Analyze the Repository
+
+Read the codebase to understand:
+- Languages and frameworks used
+- Build system and package manager
+- Test framework and commands
+- Project structure (monorepo vs single package)
+- Existing CLAUDE.md, .claude/, AGENTS.md, CONTRIBUTING.md
+- CI/CD setup
+- Linting and formatting tools
+
+### Step 2: Apply Research-Backed Principles
+
+The following principles are derived from 23+ academic papers and official Anthropic documentation.
+
+#### Attention and Positioning
+
+**Primacy effect dominates.** Evidence shows LLMs attend most strongly to the beginning and end of context, with 30%+ accuracy drop for middle content. The primacy effect is stronger than recency.
+
+**Action:** Put the 3-5 most critical behavioral rules at the TOP of CLAUDE.md. Put the "Do NOT" prohibitions at the BOTTOM. Reference material goes in the middle where lower attention is acceptable.
+
+#### Length and Density
+
+**More context hurts even with perfect retrieval.** Evidence shows performance degrades 13-85% as input length increases, regardless of content quality.
+
+**Instruction following degrades with count.** Studies across 20 frontier models show threshold decay at ~150 instructions for reasoning models, linear decay for Claude Sonnet. Errors shift from "wrong thing" to "forgetting entirely" above ~100 instructions.
+
+**Action:** Target under 200 lines per CLAUDE.md (official Anthropic guidance). Target 30-70 distinct instructions (sweet spot). Apply the pruning test: "Would removing this line cause Claude to make mistakes?" If no, cut it.
+
+#### Formatting
+
+**Markdown formatting improves performance up to 40%** compared to unformatted text for Claude-class models.
+
+**Action:** Use `##` headers for sections, `-` bullets for lists, backtick code blocks for commands. Max 2 levels of heading nesting.
+
+#### Instruction Framing
+
+**Negative instructions are disproportionately forgotten** under cognitive load. Evidence shows excessive constraints can cause advanced models to over-focus on avoidance instead of achieving goals.
+
+**Positive instructions outperform negative ones.** "All SQL in .sql files via QueryLoader" beats "Do NOT write inline SQL."
+
+**Action:** State rules positively in their relevant sections. Reserve "Do NOT" for 3-5 genuinely critical prohibitions that have no positive equivalent. Use ALWAYS/NEVER/PREFER/AVOID verb prefixes for unambiguous scanning (astral-sh/uv pattern).
+
+#### Context Persistence
+
+**CLAUDE.md survives compaction.** It is re-injected fresh on every API request. It is the single most durable piece of project context.
+
+**At 70% context utilization, instruction precision drops.** At 90%+, behavior becomes erratic. Shorter CLAUDE.md = more room for actual work.
+
+**Action:** Keep CLAUDE.md short. Add a "Compact Instructions" section telling the compaction process what to preserve.
+
+#### Instruction Hierarchy
+
+**System/user priority is unreliable.** Even simple formatting conflicts produce inconsistent behavior in choosing which instruction wins across all major LLMs (Control Illusion, 2025).
+
+**Action:** Make CLAUDE.md instructions non-conflicting with likely user requests. Do not depend on CLAUDE.md always overriding user messages.
+
+### Step 3: Apply Structural Patterns from Top Repos
+
+Based on analysis of React (240k stars), Deno (103k stars), PyTorch (90k stars), uv (55k stars), Next.js (138k stars), Biome (17k stars), Crush (21k stars), LangChain (105k stars), and Anthropic's own repos.
+
+#### CLAUDE.md Structure (target 80-150 lines)
+
+```markdown
+# Project Name (use proper capitalized display name, e.g. "Exo Teams" not "exo-teams")
+One-line description.
+
+## Critical Rules
+3-5 ALWAYS/NEVER rules that matter most. Primacy effect = maximum attention here.
+
+## Architecture
+Stack decisions that affect daily coding. 5-10 bullets, NOT a directory tree.
+Claude explores filesystems natively - do not waste lines on project structure.
+
+## Stack Decisions (Locked)
+Prevent the model from second-guessing locked choices.
+
+## Commands
+Build, test, lint, deploy. Only commands Claude cannot guess from the codebase.
+
+## Implementation Pitfalls
+What WILL break if you are not careful. Non-obvious gotchas.
+(Pattern from Anthropic's claude-code-action repo.)
+
+## Commit Style
+Convention + any repo-specific rules. NEVER add co-author tags.
+
+## Compact Instructions
+Tell the compaction process what to preserve (e.g. "always keep: current task, file paths being edited, test results, architectural decisions made this session").
+
+## Do NOT
+3-5 hard prohibitions. Benefits from recency effect at bottom of file.
+```
+
+#### What to EXCLUDE from CLAUDE.md
+
+These are explicitly called out by Anthropic's official documentation as anti-patterns:
+
+- Project structure trees (Claude explores filesystems)
+- Standard language conventions Claude already knows
+- Detailed API documentation (link to docs instead)
+- Information that changes frequently (entity types, enum values)
+- Long explanations or tutorials
+- File-by-file codebase descriptions
+- Self-evident practices ("write clean code")
+- Code conventions details (put in path-scoped rules instead)
+
+#### .claude/ Directory Infrastructure
+
+Before generating, **analyze the codebase** and determine which path-scoped rules are needed based on:
+- What languages/frameworks exist in the repo
+- What file patterns need specific conventions
+- What testing patterns are used
+- What database/query patterns exist
+
+Only create rules for languages and patterns that actually exist in the codebase. Don't generate a `go.md` rule for a Python-only project.
+
+```
+.claude/
+├── settings.json # hooks, deny list, project metadata
+├── rules/ # path-scoped rules (auto-load by file pattern)
+│ ├── python.md # paths: ["**/*.py"] - only if Python in repo
+│ ├── typescript.md # paths: ["**/*.ts", "**/*.tsx"] - only if TS in repo
+│ ├── go.md # paths: ["**/*.go"] - only if Go in repo
+│ ├── sql.md # paths: ["**/queries/**/*.sql"] - only if SQL queries exist
+│ └── testing.md # paths: ["**/tests/**", "**/*_test.*"] - only if tests exist
+└── skills/ # on-demand invokable skills
+ └── code-conventions/
+ └── SKILL.md # full style guide with examples
+```
+
+Each rule file should contain the **specific conventions, linters, formatters, and patterns** for that language as used in THIS codebase. Read existing code to extract the actual patterns being followed, don't assume generic defaults.
+
+**settings.json patterns to include:**
+
+```json
+{
+ "hooks": {
+ "PostToolUse": [
+ {
+ "matcher": "Edit|Write|MultiEdit",
+ "command": "auto-format.sh \"$CLAUDE_FILE_PATH\""
+ }
+ ]
+ },
+ "permissions": {
+ "deny": ["wrong-package-manager", "git push --force", "git reset --hard"]
+ }
+}
+```
+
+The PostToolUse auto-format hook (from uv, React, Anthropic repos) eliminates "run the formatter" reminders entirely. The deny list prevents wrong tools. Detect the actual formatter from the repo (ruff for Python, gofmt for Go, prettier for TS/JS, biome, etc.) and wire it into the auto-format script.
+
+**Path-scoped rules vs CLAUDE.md vs skills:**
+
+| Put here | When |
+|----------|------|
+| CLAUDE.md | Rule applies to ALL work in the repo, every session |
+| .claude/rules/ | Rule applies only when touching specific file types |
+| .claude/skills/ | Detailed reference or workflow invoked on demand |
+
+Rules are more token-efficient than CLAUDE.md because they only load when relevant. A python style rule does not burn context during Go work.
+
+### Step 4: Generate the Files
+
+1. Write CLAUDE.md following the structure above
+2. Create settings.json with auto-format hook and deny list
+3. Create path-scoped rules for each language/domain in the repo
+4. Optionally create skills for complex workflows
+5. Create the auto-format script if the repo has linters/formatters
+
+### Step 5: Validate
+
+- Count lines: CLAUDE.md should be under 200
+- Count distinct instructions: should be 30-70
+- Apply pruning test to every line
+- Check for duplication across CLAUDE.md, rules, and skills
+- Verify no project structure trees or enumerable data that will go stale
+- Confirm critical rules are at the top, prohibitions at the bottom
+
@@ -0,0 +1,150 @@
+---
+name: commit-forge
+description: Write clean, atomic git commits with conventional format. Use when committing code, staging changes, or user says "commit", "commit this", "stage and commit", or is done with a task and needs to commit. NOT for git troubleshooting or branch management.
+---
+
+# Commit Forge
+
+Atomic, descriptive git commits. Every commit is one logical change that leaves the codebase working.
+
+## Hard Rules
+
+- **NEVER** commit without the user explicitly asking
+- **NEVER** add co-author tags to commits
+- **NEVER** skip hooks (`--no-verify`, `--no-gpg-sign`)
+- **NEVER** force push to main/master
+- **NEVER** use `git add .` or `git add -A` - stage specific files by name
+- **PREFER** creating a new commit over amending an existing one
+
+## Commit Message Format
+
+```
+type(scope): short description (max 72 chars)
+
+Optional body - what and why, not how.
+
+Optional footer - breaking changes, issue refs.
+```
+
+### Types
+
+| Type | Use For |
+|------|---------|
+| `feat` | New feature or capability |
+| `fix` | Bug fix |
+| `docs` | Documentation only |
+| `refactor` | Code restructuring, no behavior change |
+| `test` | Adding or fixing tests |
+| `chore` | Maintenance, deps, config, CI |
+| `style` | Formatting, no logic change |
+| `perf` | Performance improvement |
+
+### Scope
+
+Use short, lowercase scope matching the area of change. Read the repo's existing commits (`git log --oneline -20`) to match the project's conventions.
+
+### Examples
+
+```
+feat(auth): add JWT refresh token rotation
+
+fix(parser): handle empty broker email attachments
+
+refactor(cli): extract table renderer to shared package
+
+chore: update go dependencies
+
+test(api): add integration tests for driver matching
+```
+
+## Process
+
+### Step 1: Review Changes
+
+```bash
+git status
+git diff
+git diff --staged
+```
+
+Understand what changed before staging anything.
+
+### Step 2: Stage Selectively
+
+Stage files that belong to one logical change:
+
+```bash
+git add src/auth/handler.go
+git add src/auth/handler_test.go
+```
+
+If the diff contains multiple unrelated changes, split into separate commits.
+
+### Step 3: Verify Before Commit
+
+Auto-detect and run the repo's checks:
+
+| Indicator | Command |
+|-----------|---------|
+| `Makefile` with test target | `make test` |
+| `package.json` with test script | `npm test` / `pnpm test` |
+| `go.mod` exists | `go test ./...` |
+| `Cargo.toml` exists | `cargo test` |
+| `pyproject.toml` / `setup.py` | `pytest` |
+
+Only run if tests exist and are fast. Skip if the user explicitly says to commit without testing.
+
+### Step 4: Commit
+
+Write a message that answers "what changed and why" - not "how."
+
+```bash
+git commit -m "feat(auth): add rate limiting to login endpoint"
+```
+
+For complex changes, use a body:
+
+```bash
+git commit -m "refactor(api): split monolithic handler into middleware chain
+
+Request validation, auth, and response formatting were tangled
+in a single 400-line handler. Split into composable middleware
+for independent testing and reuse."
+```
+
+### Step 5: Verify
+
+```bash
+git log --oneline -3
+git show --stat
+```
+
+Confirm the commit looks right.
+
+## What Makes a Good Commit
+
+| Good | Bad |
+|------|-----|
+| One logical change | Multiple unrelated changes |
+| All tests pass | Breaks something |
+| Message says why | Message says "update stuff" |
+| Can be reverted cleanly | Reverting would break other things |
+| Specific files staged | `git add .` |
+
+## What Makes a Bad Commit Message
+
+- "fix stuff" / "update" / "changes" / "WIP"
+- Using "and" to describe two unrelated things
+- Describing how instead of what/why
+- Over 72 characters in the subject line
+
+## Checklist
+
+Before committing:
+
+- [ ] Changes are atomic (one logical unit)
+- [ ] Specific files staged (not `git add .`)
+- [ ] Tests pass (if applicable)
+- [ ] Message follows `type(scope): description` format
+- [ ] No co-author tags
+- [ ] No sensitive files staged (.env, credentials, keys)
@@ -0,0 +1,254 @@
+---
+name: prompt-forge
+description: Universal prompt engineering guide for writing, reviewing, and optimizing LLM prompts across Claude and OpenAI models. Use when writing system prompts, designing extraction pipelines, building classification or summarization prompts, optimizing for cost/latency, reviewing existing prompts for quality, or any task involving prompt design for production AI systems. Trigger on keywords like "prompt", "system prompt", "few-shot", "extraction prompt", "prompt engineering", "prompt review", or when the user is building any AI-powered feature that needs a well-crafted prompt.
+---
+
+# Prompt Forge
+
+Universal prompt engineering reference for production-grade LLM prompts. Covers Claude and GPT models.
+
+For SDK code examples and implementation patterns, read `references/code-patterns.md`.
+
+## Core Principles
+
+1. **Be Explicit, Not Implicit.** Treat every prompt like onboarding a new hire - spell out role, task, constraints, output format, and edge cases.
+
+2. **Structure Beats Prose.** Structured prompts with clear sections outperform wall-of-text instructions. Use XML tags for Claude, markdown headers or XML for GPT.
+
+3. **Show, Don't Just Tell.** Few-shot examples are the single highest-leverage technique. 3-5 examples covering happy paths and edge cases. With prompt caching, afford 20+.
+
+4. **Constrain the Output Space.** Define exactly what success looks like. Schemas, templates, or format specs. Tighter output contract = more reliable results.
+
+5. **Null Over Hallucination.** For extraction tasks, always instruct the model to return null for missing fields rather than guessing.
+
+6. **Positive Instructions Over Negative.** "Write in plain prose paragraphs" beats "Don't use markdown". Tell the model what TO do.
+
+7. **Order Matters.** Place long documents ABOVE instructions. Place critical instructions at beginning and end (primacy and recency effects).
+
+## Prompt Section Ordering
+
+System prompts are not monolithic strings. They are ordered arrays of sections. The ordering matters for cache efficiency and model attention.
+
+**Canonical section order:**
+
+1. **Identity** - who/what the agent is (1-2 sentences)
+2. **Preamble** - mode of operation, security boundaries
+3. **System rules** - universal behavioral rules (output format, permission handling, error handling)
+4. **Task guidelines** - domain-specific rules (coding, analysis, support, etc.)
+5. **Action safety** - reversibility awareness, blast radius thinking, confirmation rules
+6. **Tool usage** - tool preferences, parallelism rules, delegation patterns
+7. **Tone and style** - output length, formatting, emoji rules
+8. **--- cache boundary ---** - everything above is static, everything below is dynamic
+9. **Environment context** - runtime info (CWD, platform, model ID, date)
+10. **User instructions** - user-provided rules (config files, overrides)
+11. **Memory** - persistent cross-session context
+
+**Why this order works:**
+- Static sections first = cacheable prefix (cache matches prefixes)
+- Identity and rules before tools = model internalizes constraints before seeing capabilities
+- User instructions AFTER defaults but marked as overrides = user can override any default
+- Dynamic sections last = only the tail changes between turns, maximizing cache hits
+
+**User instruction override pattern:**
+```
+User instructions are shown below. Be sure to adhere to these instructions.
+IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.
+```
+
+Place this header before user-provided instructions to explicitly grant override authority.
+
+## Claude vs GPT Quick Reference
+
+| Feature | Claude | GPT-5.x |
+|---|---|---|
+| Structured output | `messages.parse()` + Pydantic | `response_format` + JSON Schema |
+| Prompt structure | XML tags (trained on them) | XML tags or markdown headers |
+| Reasoning control | Extended thinking on/off | `reasoning_effort` knob (none to xhigh) |
+| Caching | `cache_control` on system/messages | Automatic with prefix matching |
+| Prefilling | Supported (assistant turn) | Not directly supported |
+| Long context | Up to 1M tokens | Compaction for extended sessions |
+
+**Claude:** use XML tags liberally, `cache_control` on system prompts, `messages.parse()` for guaranteed schema output.
+
+**GPT:** use `reasoning_effort` parameter (start low, increase if evals regress), XML tags work despite common belief.
+
+## XML Tag Template
+
+```xml
+You are a [domain-specific role].
+
+<rules>
+- Extract only explicitly stated information
+- Return null for missing fields, never guess
+- [Domain-specific normalization rules]
+</rules>
+
+<examples>
+<example>
+<description>[What this example demonstrates]</description>
+<input>...</input>
+<output>...</output>
+</example>
+</examples>
+
+<input>
+{{USER_INPUT}}
+</input>
+```
+
+## Prompt Template Patterns
+
+### Extraction
+```
+1. Role definition (domain-specific extractor)
+2. <rules> block (extract only stated, null for missing, normalization)
+3. <schema> block (field descriptions, types, required vs optional)
+4. <examples> block (3-5 covering happy path, sparse, ambiguous)
+5. <input> block (actual content)
+```
+
+Schema design: every field Optional with None default, `Field(description=...)` on each, specific types (int/float/date not str), include per-field confidence (HIGH/MEDIUM/LOW/MISSING), include `fields_needing_review` list.
+
+Preprocess inputs: strip signatures, disclaimers, HTML, whitespace. Set max_chars limit.
+
+### Classification
+```
+1. Role definition
+2. <categories> block (name + description for each)
+3. <rules> block (single category, tiebreaker rule, confidence + rationale)
+4. <examples> block (boundary cases between categories)
+5. <input> block
+```
+
+### Summarization
+```
+1. Role definition
+2. <rules> block (length, focus, what to include/exclude)
+3. <format> block (output template)
+4. <input> block
+```
+
+### Code Generation
+Role + `<conventions>` (existing patterns, stack, style) + `<rules>` (scope tightly, error handling, follow patterns) + `<context>` (relevant existing code).
+
+### Multi-Step Reasoning (ReAct)
+```
+For each step:
+1. THOUGHT: reason about what information you need
+2. ACTION: call the appropriate tool
+3. OBSERVATION: analyze the result
+4. Repeat until you have enough to answer
+5. ANSWER: provide the final response
+```
+
+Use Claude's extended thinking or GPT's reasoning_effort for complex reasoning rather than forcing CoT when the model natively supports it.
+
+### Agent Delegation
+Subagents should NOT inherit the full parent prompt. Strip to: identity, task scope, constraints, environment. For full patterns, read `references/agentic-patterns.md`.
+
+## Agentic Systems
+
+For tool-using agents, subagents, mid-conversation injection, conditional assembly, action safety, and tool result management, read `references/agentic-patterns.md`. Key concepts:
+
+- **Conditional sections** - inject/omit prompt sections based on active tools, mode, or feature flags
+- **System-reminder injection** - mid-conversation context via XML tags, separate from user messages
+- **Tool prompt architecture** - 3-layer split: tool description (routing), parameter descriptions (arg filling), system prompt (cross-tool strategy)
+- **Action safety** - reversibility spectrum: freely take (local) / confirm (hard-to-reverse) / never without ask (visible to others)
+- **Subagent minimalism** - stripped identity, no parent prompt inheritance
+- **Tool result shrinking** - summarize large outputs, prompt model to self-extract before compaction
+
+## Few-Shot Examples
+
+**Quantity:** minimum 3, ideal 5, with caching 20+.
+
+**Diversity:** 60% common cases, 30% edge cases, 10% failure/empty/ambiguous cases.
+
+**Quality:** real data over synthetic. Include exact expected output format. Show tricky situations handled correctly.
+
+**Improvement loop:** log raw output vs corrected, identify weak fields, add examples targeting those fields, rotate periodically.
+
+## Confidence and Verification
+
+Build confidence tracking into schemas: per-field confidence (HIGH/MEDIUM/LOW/MISSING), overall confidence = lowest individual, list uncertain fields in `fields_needing_review`.
+
+**Self-verification:** before returning, re-read source, check each field, verify no hallucination, confirm schema match.
+
+**Two-tier strategy:** parse with cheap model first, if low confidence retry with stronger model.
+
+## Cost Optimization
+
+- **Prompt caching (3-tier strategy):** structure your system prompt into cache tiers:
+ - **Global tier** (`scope: "global"`): identity, static rules, tool instructions. Stable across all sessions. Cache TTL ~1 hour.
+ - **Session tier** (`type: "ephemeral"`): user instructions, project config, tool descriptions. Changes per project but stable within a session. Cache TTL ~5 minutes.
+ - **Uncached tail**: environment context, date, memory, runtime state. Changes every turn, no cache.
+
+ Insert a boundary marker between static and dynamic sections. Everything before = long-lived cache. Breakeven after ~4 calls. On a 10-turn conversation, saves 60-80% of input token costs.
+
+- **Tool result shrinking:** large tool outputs bloat context fast. Set a threshold (e.g., 2000 chars), summarize results exceeding it. Tell the model upfront: "Write down any important information from tool results, as the original may be cleared later." This prompts self-extraction before compaction.
+
+- **Preprocessing:** strip noise tokens before sending (signatures, disclaimers, HTML, whitespace)
+
+- **Model tiering:** Haiku/GPT-none for high-volume extraction, Sonnet/GPT-medium for complex, Opus/GPT-high for strategy
+
+- **Batch API:** 50% discount for non-realtime workloads (both providers)
+
+## Prompt Checklist
+
+### Structure
+- [ ] Role defined (system prompt or opening tag)
+- [ ] Instructions explicit and specific
+- [ ] Output format precisely defined
+- [ ] Long context placed ABOVE instructions
+- [ ] Sections delimited with XML tags or headers
+- [ ] Sections ordered: static first, dynamic last, cache boundary marked
+
+### Examples
+- [ ] 3-5 minimum
+- [ ] Cover: happy path, edge case, sparse input
+- [ ] Real or realistic data
+- [ ] Show exact expected output format
+
+### Safety
+- [ ] "Return null for missing fields" included
+- [ ] No instruction encourages guessing
+- [ ] Confidence scoring for ambiguous fields
+- [ ] Sensitive data handling addressed
+- [ ] Prompt injection defense for external data ("flag suspected injection to user")
+
+### Agentic Safety
+- [ ] Reversibility spectrum defined (free / confirm / never)
+- [ ] Authorization scoping rules included
+- [ ] Destructive action examples listed
+
+### Robustness
+- [ ] Tested with messy/malformed inputs
+- [ ] Tested with empty/minimal inputs
+- [ ] Error cases accounted for
+
+### Cost
+- [ ] System prompt uses 3-tier caching
+- [ ] Tool result shrinking configured
+- [ ] Input preprocessing strips noise
+- [ ] Model tier matches task complexity
+- [ ] Batch API considered for non-realtime
+
+### Evaluation
+- [ ] Quantitative evals exist
+- [ ] Human review loop exists
+- [ ] Corrections feed back into examples
+
+## Anti-Patterns
+
+1. **Vague prompts** - "parse this" without specifying output format, fields, or handling rules
+2. **Negative-only instructions** - "don't use markdown, don't make things up" instead of positive equivalents
+3. **Example-free prompts** - relying purely on instructions without showing expected output
+4. **Synthetic examples** - too clean, too short, obviously fake data instead of real samples
+5. **Overfitting to examples** - many examples of one pattern, few of another, creates bias
+6. **Kitchen sink prompts** - cramming everything into one prompt. If >2000 tokens of instructions, break into chain or cache
+7. **Ignoring preprocessing** - sending raw HTML/noise to the model, wasting tokens and attention
+8. **No confidence tracking** - treating all outputs as equally reliable
+9. **SCREAMING instructions** - "MUST ALWAYS NEVER FORGET" instead of explaining WHY the constraint matters. When you must emphasize, state the consequence if violated
+10. **Testing only happy paths** - only evaluating on clean inputs when real data is messy
+11. **Monolithic system prompts** - one giant string instead of ordered, conditionally assembled sections
+12. **Full prompt inheritance for subagents** - copying the entire parent prompt into delegated agents, wasting tokens and causing conflicts
+13. **Mixing tool description layers** - putting cross-tool strategy in individual tool descriptions instead of the system prompt
@@ -0,0 +1,119 @@
+# Prompt Forge - Agentic Patterns Reference
+
+Read this file when building agentic systems with tool use, subagents, or real-world actions.
+
+## Conditional Section Assembly
+
+Production prompts are not static. Sections should be injected or omitted based on runtime state.
+
+**Tool-aware instructions.** Only include rules about a tool when that tool is available:
+```
+if "search" in available_tools:
+ sections.append("For codebase searches, use the Search tool instead of grep.")
+```
+
+**Mode-aware sections.** Skip domain-specific instructions when the agent operates in a different mode:
+```
+if mode == "code":
+ sections.append(coding_guidelines)
+elif mode == "support":
+ sections.append(support_guidelines)
+```
+
+**Feature-flagged sections.** Gate experimental instructions behind flags:
+```
+if feature_flags.get("verbose_output"):
+ sections.append(output_efficiency_section)
+```
+
+Every unnecessary token in the system prompt costs money and dilutes attention. A prompt with 20 tools that includes instructions for all 20 wastes tokens on tools the model may never use.
+
+## Mid-Conversation Context Injection
+
+Not all instructions belong in the system prompt. Some context should be injected at runtime as tagged messages within the conversation.
+
+**System-reminder pattern.** Wrap injected context in XML tags as a user message:
+```xml
+<system-reminder>
+Today's date is March 22, 2026.
+
+IMPORTANT: this context may or may not be relevant to your tasks.
+You should not respond to this context unless it is highly relevant.
+</system-reminder>
+```
+
+**When to use system-reminders vs system prompt:**
+
+| Content | Where |
+|---------|-------|
+| Static rules, identity, tool instructions | System prompt |
+| User config files, project instructions | First user message (system-reminder wrapper) |
+| Date changes, background task results | Injected mid-conversation as system-reminder |
+| Warnings about specific tool results | Appended to the tool result content |
+| Memory staleness alerts | Appended after memory content |
+
+Tell the model explicitly that these tags "bear no direct relation to the specific tool results or user messages in which they appear." Otherwise the model may attribute the reminder to the wrong context.
+
+## Tool Prompt Architecture
+
+When building agentic systems with tool use, instructions for tools live in three separate places.
+
+| Layer | What goes here | Example |
+|-------|---------------|---------|
+| **Tool `description`** | What the tool does, when to use it. Model reads this to decide whether to call it. | "Reads a file from the local filesystem." |
+| **Tool `parameter.description`** | Per-parameter guidance. Model reads this when filling in arguments. | "The absolute path to the file to read" |
+| **System prompt tool section** | Cross-tool preferences, delegation rules, parallelism instructions. | "Use Read instead of cat. Call multiple tools in parallel when independent." |
+
+**Anti-pattern:** putting usage strategy in tool descriptions. "Always prefer this tool over Bash" belongs in the system prompt, not the tool's description.
+
+**Anti-pattern:** duplicating instructions across tool descriptions and system prompt. Pick one location.
+
+## Subagent Prompt Minimalism
+
+Subagents should NOT inherit the full parent prompt. Strip everything except identity, task scope, correctness constraints, and environment info. Omit tone/style, methodology, tool preferences, action safety rules.
+
+**Subagent prompt structure:**
+```
+1. Tight identity (what you are, scope of work, how to report back)
+2. Operational constraints (absolute paths, no emojis, concise responses)
+3. Minimal environment context (CWD, platform, model)
+```
+
+**Subagent identity pattern:**
+```
+Given the user's message, use the tools available to complete the task.
+Do what has been asked; nothing more, nothing less.
+When you complete the task, respond with a concise report covering what was done
+and any key findings.
+```
+
+This prevents subagents from over-explaining, expanding scope, or duplicating parent work.
+
+## Action Safety (Reversibility Spectrum)
+
+For agents that take real-world actions (file edits, git operations, API calls, deployments), include a reversibility framework in the system prompt.
+
+**Reversibility spectrum:**
+```
+Freely take: local, reversible actions (editing files, running tests)
+Confirm first: hard-to-reverse actions (force push, delete branch, deploy)
+Never without explicit ask: actions visible to others (push, comment on PRs, send messages)
+```
+
+**Key rules:**
+- "A user approving an action once does NOT mean they approve it in all contexts"
+- "Authorization stands for the scope specified, not beyond"
+- "Do not use destructive actions as a shortcut to make obstacles go away"
+- "If you discover unexpected state, investigate before deleting or overwriting"
+
+**Anti-pattern:** binary "ask for everything" or "do everything autonomously." The spectrum approach gives the model judgment within guardrails.
+
+## Tool Result Shrinking (Microcompaction)
+
+Large tool outputs (file contents, search results, command output) bloat context fast. Apply automatic replacement:
+
+- Set a threshold (e.g., 2000 chars). Tool results exceeding it get summarized.
+- Track replacements in state so you can restore if needed.
+- Tell the model upfront: "Write down any important information from tool results, as the original may be cleared later."
+
+This prompts the model to self-extract key facts before compaction removes the raw output.
@@ -0,0 +1,84 @@
+# Prompt Forge - Code Patterns Reference
+
+Read this file when you need SDK-specific code examples for implementing prompts.
+
+## Claude SDK Patterns
+
+### System Prompt with Caching
+
+```python
+system=[{
+ "type": "text",
+ "text": SYSTEM_PROMPT,
+ "cache_control": {"type": "ephemeral"},
+}]
+```
+
+- Writing to cache costs 25% more than base input price
+- Reading from cache costs only 10% of base input price
+- Breakeven after ~4 calls with the same cached content
+
+### Structured Outputs (messages.parse)
+
+```python
+response = client.messages.parse(
+ model="claude-haiku-4-5",
+ max_tokens=1024,
+ system=[{"type": "text", "text": SYSTEM_PROMPT, "cache_control": {"type": "ephemeral"}}],
+ messages=[{"role": "user", "content": user_content}],
+ output_format=OutputSchema, # Pydantic model
+)
+result = response.parsed_output # typed instance
+```
+
+This is NOT tool use. It enforces the schema at the API level.
+
+### Prefilling
+
+```python
+messages=[
+ {"role": "user", "content": "Extract the data from this document: ..."},
+ {"role": "assistant", "content": "{"} # Forces JSON output
+]
+```
+
+Use sparingly - structured outputs are preferred for JSON.
+
+## OpenAI SDK Patterns
+
+### Structured Outputs
+
+```python
+response = client.chat.completions.create(
+ model="gpt-5.2",
+ messages=[...],
+ response_format={"type": "json_schema", "json_schema": {...}}
+)
+```
+
+### Reasoning Effort
+
+```python
+response = client.chat.completions.create(
+ model="gpt-5.2",
+ messages=[...],
+ reasoning_effort="medium" # none, minimal, low, medium, high, xhigh
+)
+```
+
+## Confidence Schema Pattern
+
+```python
+class ConfidenceLevel(str, Enum):
+ HIGH = "HIGH" # Clearly stated, unambiguous
+ MEDIUM = "MEDIUM" # Reasonable inference from context
+ LOW = "LOW" # Weak signal, likely needs human review
+ MISSING = "MISSING" # Not found in source
+
+class ExtractedData(BaseModel):
+ field_a: Optional[str] = None
+ field_a_confidence: ConfidenceLevel = ConfidenceLevel.MISSING
+ overall_confidence: ConfidenceLevel = ConfidenceLevel.MISSING
+ fields_needing_review: list[str] = []
+```
+
@@ -0,0 +1,170 @@
+---
+name: readme-forge
+description: Generate a README in alxx's personal style. Use when user says "write a README", "create README", "readme for this project", "update README", or needs a repo README.
+disable-model-invocation: true
+argument-hint: "[repo path or leave blank for current]"
+---
+
+# README Forge
+
+Generate a README following alxx's personal style.
+
+## Step 1: Analyze the Repository
+
+Target: $ARGUMENTS (if blank, use current working directory).
+
+Read the codebase to understand:
+- What it does (one sentence)
+- Who it's for
+- Tech stack and architecture
+- How to install and run it
+- Key features
+- Build/test commands
+- License
+- Dependencies and credits
+
+Read existing README if one exists. Check CLAUDE.md, package.json, go.mod, Makefile, Cargo.toml for project metadata.
+
+## Step 2: Apply alxx's README Pattern
+
+Every README follows this exact structure. Do not skip sections, do not reorder.
+
+### Structure
+
+```markdown
+# Project Name
+
+OR replace h1 with a banner image:
+- Full-width branded banner at `assets/project-github-banner.png` (recommended: ~2075x208px, 10:1 aspect ratio)
+- Followed by `---` separator
+- Optional: centered badge row (CI, coverage, tests) under the separator using `<p align="center">`
+- When banner is present, use `## 🚀 Overview` (with rocket emoji)
+- When no banner (just h1), use `## Overview` (no emoji)
+
+**Bold tagline - one line, punchy, states the core value prop.**
+
+## Overview (or 🚀 Overview if banner present)
+
+What this is in 2-3 sentences. Include a **bold analogy** comparing it to something familiar
+(e.g. "Git for agent context", "your Teams data in your terminal").
+
+***Bold italic one-liner for the key technical differentiator.***
+
+## Quickstart
+
+Copy-paste commands to get running. No explanation between commands unless critical.
+Start from git clone or go install, end with the app running.
+
+```bash
+git clone ...
+cd ...
+make setup
+make dev
+```
+
+## Why
+
+2-3 sentences on the problem. Why existing solutions suck.
+Direct, slightly opinionated. No corporate speak.
+
+## Features
+
+- **Feature Name** - what it does, one line
+- **Feature Name** - what it does, one line
+- **Feature Name** - what it does, one line
+
+Bold the feature name, dash separator, brief description. 5-8 features max.
+
+## Vision (optional - include only for ambitious/platform projects, skip for focused tools)
+
+Where this is going. 2-3 sentences. Forward-looking but concrete.
+Mention what's next without over-promising.
+
+## Architecture
+
+Mermaid diagram showing the main flow. Keep it simple - major components and their connections.
+Flowchart LR preferred (left-to-right reads naturally).
+
+```mermaid
+flowchart LR
+ A[Input] --> B[Core]
+ B --> C[Output]
+```
+
+
+## Contributing
+
+2-3 lines. "PRs welcome. For major changes, open an issue first."
+Mention CLA if one exists. Keep it short.
+
+## Legal
+
+One line on licensing. Mention any API usage disclaimers if relevant.
+
+## License
+
+One line linking to LICENSE file. e.g. `[MIT](LICENSE)`
+```
+
+**Sections can vary from project to project** - not every project needs Why, Vision, or Architecture. Adapt based on what's relevant. But the style is always kept: short, concise, dense, no fluff. Every section earns its place or gets cut.
+
+## Step 3: Style Rules
+
+These are non-negotiable:
+
+- **h1 or banner** - either `# Name` or full-width banner image with `---` separator
+- **Bold tagline** directly under h1/banner, no gap
+- **Overview** - first section is always Overview. use 🚀 emoji only when banner is present, plain `## Overview` otherwise
+- **Blockquote pitch** for the audience, in or after overview
+- **Bold analogy** in overview (comparing to something familiar)
+- **Bold italic** (`***text***`) for punchy one-liners
+- **Bold feature names** with dash separator in features list
+- **Mermaid diagram** - always include one, flowchart LR preferred
+- **Quickstart must be copy-paste** - someone should be able to run every command in order
+- **FAQ is optional** - rarely used, only include for projects with common objections (e.g. "why not X?"). if something needs explaining, prefer putting it in the relevant section
+- **No badges** unless the project has CI set up
+- **Bold key phrases in longer text** - in paragraphs, `**bold**` the important terms/concepts so readers can scan (e.g. "Nebula is **Git for agent context & tasks**")
+- **Dense, no fluff** - every sentence carries information
+- **No "Getting Started" header** - use "Quickstart" for apps/CLIs, "Install" for packages/skills/libraries
+- **No separate "Installation" header** - fold into Quickstart or Install
+- **No "Usage" header** - fold into Quickstart or Features
+- **No long dashes** - never use "—" (em dash), use "-" or commas instead
+- **No AI slop** - no "furthermore", "it's worth noting", "comprehensive"
+- **Casual but professional** - not corporate, not too informal for a repo README
+
+## Step 4: Tone Calibration
+
+Read the project's CLAUDE.md or existing docs to match tone. If it's a serious infrastructure tool, lean professional. If it's a personal tool, lean slightly casual. Never go full bro-mode in a public README.
+
+Key phrases that fit alxx's style:
+- "No middleman" / "no admin consent"
+- "Single binary" / "your data, your machine"
+- "Local-first, privacy-first"
+- Direct comparisons: "Every existing X requires Y. This needs none of that."
+- Forward-looking vision without buzzwords
+
+## Step 5: Generate
+
+Write the full README following the structure above. Include sections that are relevant to the project, skip ones that aren't. Keep the order.
+
+## Step 6: Validate
+
+- [ ] h1 or banner image at top
+- [ ] Bold tagline under h1/banner
+- [ ] Overview as first section (🚀 emoji only if banner present)
+- [ ] Blockquote audience pitch
+- [ ] Overview with bold analogy
+- [ ] Bold italic differentiator line
+- [ ] Quickstart with copy-paste commands
+- [ ] Why section (problem statement)
+- [ ] Features with bold names + dash + description
+- [ ] Vision section (optional - only for platform/ambitious projects)
+- [ ] Mermaid architecture diagram
+- [ ] Contributing section (short)
+- [ ] Legal section
+- [ ] Credits section (if applicable)
+- [ ] FAQ only if truly needed (rare)
+- [ ] No "Getting Started", "Installation", or "Usage" headers
+- [ ] No AI slop phrases
+- [ ] Under 200 lines (aim for 80-150)
+- [ ] Every command in Quickstart actually works
@@ -0,0 +1,177 @@
+---
+name: skill-forge
+description: Write or improve an LLM agent skill. Use when user says "create skill", "write a skill", "improve this skill", "new skill", "skill for X", or wants to build a SKILL.md file.
+disable-model-invocation: true
+argument-hint: "[skill name or path to existing SKILL.md]"
+---
+
+# Skill Forge
+
+Generate or improve skills for LLM agent tools (Claude Code, Cursor, Crush, and any SKILL.md-compatible system). Research-backed, based on official docs, plugin source, and real-world skill audits.
+
+## When to Use
+
+- Creating a new skill from scratch
+- Improving an existing skill's description, structure, or settings
+- Auditing a skill for common mistakes
+- Deciding between rule vs skill vs CLAUDE.md for a piece of context
+
+## Step 1: Understand the Request
+
+If $ARGUMENTS points to an existing SKILL.md, read it first. Otherwise, ask:
+
+1. **What does it do?** - the core capability or workflow
+2. **Who triggers it?** - user only (`disable-model-invocation: true`), model auto (`default`), or background (`user-invocable: false`)
+3. **Side effects?** - does it commit, deploy, send messages, modify state?
+4. **Scope?** - personal (`~/.<agent>/skills/`), project (`.<agent>/skills/`), or plugin?
+
+## Step 2: Choose the Right Mechanism
+
+These paths use `.claude/` as example but apply to any agent that supports SKILL.md format (Crush, Cursor, etc.):
+
+| Put here | When |
+|----------|------|
+| CLAUDE.md / agent config | Rule applies to ALL work, every session, short |
+| `.<agent>/rules/` | Applies only when touching specific file types (use `paths` frontmatter) |
+| `.<agent>/skills/` | On-demand workflow, detailed reference, or behavioral mode |
+
+Don't make a skill when a rule or CLAUDE.md line would suffice.
+
+## Step 3: Write the Frontmatter
+
+### Required fields
+
+```yaml
+---
+name: skill-name # lowercase, hyphens, max 64 chars
+description: ... # THE most important field. See below.
+---
+```
+
+### Optional fields
+
+| Field | Use when |
+|-------|----------|
+| `disable-model-invocation: true` | Side effects (commits, deploys), behavioral modes, timing-sensitive workflows |
+| `user-invocable: false` | Background knowledge Claude should absorb passively |
+| `argument-hint: "[hint]"` | Skill takes input - shows in autocomplete |
+| `allowed-tools: Read, Grep` | Restrict or pre-approve tools |
+| `context: fork` | Run in isolated subagent (research, batch ops, analysis) |
+| `agent: Explore` | Subagent type when using `context: fork` |
+
+### Invocation matrix
+
+| Setting | User invokes | Claude invokes | Description in context |
+|---------|-------------|---------------|----------------------|
+| (default) | yes | yes | yes |
+| `disable-model-invocation: true` | yes | no | no |
+| `user-invocable: false` | no | yes | yes |
+
+**Key insight:** with `disable-model-invocation: true`, the description is NOT in context at all. Don't over-optimize the description - write it for your own docs reference only.
+
+## Step 4: Write the Description
+
+The description is what Claude pattern-matches against to decide whether to load the skill. There is NO algorithmic routing - Claude's language model reads all descriptions and semantically matches.
+
+### Formula
+
+1. **What it does** - one sentence, outcome-first
+2. **Trigger phrases** - exact words: "Use when user says X, Y, Z"
+3. **Anti-triggers** - "NOT for..." if false positives are a risk
+4. **Prerequisites** - required setup if any
+
+### Rules
+
+- Under 60 words
+- Every word costs token budget (2% of context window shared across ALL skill descriptions)
+- Description handles matching only - body handles instructions
+- Don't repeat body content in description
+- Be slightly "pushy" - Claude undertriggers by default
+
+### Good example
+
+> "Translates Figma designs into production-ready code with 1:1 visual fidelity. Use when implementing UI from Figma files, when user mentions 'implement design', 'generate code', 'build Figma design', or provides Figma URLs. Requires Figma MCP server connection."
+
+### Bad example
+
+> "Use when user wants to plan something" - too vague, false positives everywhere.
+
+## Step 5: Write the Body
+
+### Structure
+
+```markdown
+# Skill Title
+
+Brief context sentence.
+
+## When to Use
+(if not covered by description)
+
+## Process / Steps
+1. Step one
+2. Step two
+
+## Rules / Constraints
+- Rule one
+- Rule two
+
+## Examples (if applicable)
+Show patterns Claude should follow.
+
+## Validation / Checklist (if applicable)
+How to verify output quality.
+```
+
+### Rules
+
+- **Under 500 lines.** Full SKILL.md loads into context when invoked. Move reference material to supporting files.
+- **Use $ARGUMENTS** if the skill takes input. Without it, Claude Code appends `ARGUMENTS: <value>` at the end which is messier.
+- **Numbered lists** for sequential steps, bullets for options/rules.
+- **Bold critical constraints** with `**IMPORTANT:**`.
+- **Say "do not skip steps"** explicitly when order matters.
+- **H2 for major sections, H3 for sub-sections.** Max 2 heading levels.
+
+### Supporting files
+
+Put adjacent to SKILL.md in the skill directory:
+- `examples.md` - example inputs/outputs
+- `reference.md` - detailed reference docs
+- `scripts/helper.sh` - executable scripts
+
+Reference them from SKILL.md so Claude knows to load them. Files not accessed consume zero tokens.
+
+### Dynamic content injection
+
+Skills support shell command output injection with `!` prefix:
+- `!`\`git branch --show-current\` - inject current branch
+- `!`\`ls src/\` - inject file listing
+
+Use for context that changes between invocations.
+
+## Step 6: Validate
+
+Run this checklist on the generated skill:
+
+- [ ] Description under 60 words
+- [ ] Description is trigger-focused (what/when), not instruction-focused (how)
+- [ ] Body under 500 lines
+- [ ] No duplication between description and body
+- [ ] Correct invocation setting for the use case (side effects = `disable-model-invocation: true`)
+- [ ] `$ARGUMENTS` used if skill takes input
+- [ ] No vague trigger phrases that cause false positives
+- [ ] No `context: fork` on reference-only content (subagent gets instructions with no task)
+- [ ] Critical constraints are bolded
+- [ ] Steps are numbered when order matters
+- [ ] Supporting files referenced if body would exceed 500 lines
+
+## Common Mistakes
+
+1. **Vague descriptions** - "Use when user wants to plan" triggers on everything
+2. **Missing `disable-model-invocation: true` on side-effect skills** - Claude will auto-commit, auto-deploy
+3. **Body too long** - 500+ lines burns context. Split into supporting files.
+4. **Redundant description + body** - description matches, body instructs. Don't duplicate.
+5. **`context: fork` on reference content** - subagent gets instructions with no actionable task
+6. **Over-explaining in description** - the description isn't the instructions
+7. **Missing NOT conditions** - for behavioral modes, add explicit "NEVER activate unless..."
+8. **`user-invocable: false` on things users should manually trigger** - confusing