Skip to content

Latest commit

 

History

History
510 lines (415 loc) · 19.2 KB

AGENTS.md

File metadata and controls

510 lines (415 loc) · 19.2 KB

Karahi Development Guide

Project Overview

Karahi is a Go CLI that runs shell commands defined in karahi.json. It has two execution modes:

  1. CLI mode — karahi <command> [--param value ...]
  2. TUI mode — karahi or karahi tui (interactive terminal UI)

The module path is github.com/eshanclio/karahi. The Go version is 1.25.8 (see go.mod). No CGO. No build tags.

Architecture

main.go                    Entry point: loads config, routes to CLI or TUI
internal/
  domain/
    config.go              Config loading, merging, lookup
    command.go             Core types: Command, Param, ValidationRule, ResolvedCommand
  cli/
    cli.go                 CLI execution: flag parsing, validation, help output
  executor/
    executor.go            Shell execution: param substitution, running exec steps
  tui/
    model.go               Bubble Tea model, state machine, layout, styles
    commands.go            Filterable command list overlay (bubbles/list)
    form.go                Dynamic huh.Form builder from command params
    output.go              Viewport + spinner + scroll for command output
    keys.go                Key binding definitions
  validation/
    validator.go           Composable validator registry (RuleFactory pattern)
    rules.go               Built-in rules: required, minLength, maxLength, pattern, oneOf

Data Flow

main.go
  ├─ domain.LoadConfig() → []ResolvedCommand
  │    Reads ./karahi.json and ~/.karahi/karahi.json.
  │    IMPORTANT: file-read and JSON-parse errors are silently ignored
  │    (both return values discarded). If a config file is missing or
  │    malformed, that source contributes zero commands with no error.
  │    Project commands override global commands with the same name.
  │
  ├─ CLI path (len(args) > 0 && args[0] != "tui"):
  │    cli.Run(args, commands, os.Stdout)
  │      → parseFlags → validateParams → executor.RunCapture → writer
  │
  └─ TUI path (no args or args[0] == "tui"):
       tui.New(commands) → tea.NewProgram(model) → Run()
         State machine: idle → commandSelect → formInput → executing
         Runs executor.RunStructured in a background goroutine.

Dependencies

Package Role
charm.land/bubbletea/v2 TUI framework (Elm architecture). View() returns tea.View, not string.
charm.land/bubbles/v2 Reusable TUI components: list, textinput, viewport, spinner, key.
charm.land/huh/v2 Form builder for interactive parameter input with validation.
charm.land/lipgloss/v2 Terminal styling (colors, borders, padding). Uses LightDark for theme adaptation.

Build and Run

There is no Makefile or Taskfile. Use standard go commands.

go build .                        # produces ./karahi binary
go run .                          # run directly in TUI mode

./karahi                          # TUI mode (default)
./karahi tui                      # TUI mode (explicit)
./karahi greet --name Alice       # CLI mode
./karahi greet --name=Alice       # CLI mode (= syntax also works)
./karahi help                     # list all commands
./karahi help deploy              # help for a specific command

Testing

No tests exist yet. When adding tests, follow these rules strictly.

Commands

go test ./...                                      # run all tests
go test ./internal/validation                      # run one package
go test ./internal/validation -run TestRequired    # run one test
go test ./... -v                                   # verbose output
go test ./... -count=1                             # disable test caching

Rules

  • Use only the standard library testing package. Do not add testify, gomega, or any other third-party assertion/testing library.
  • Do not mock. No mock frameworks, no generated mocks, no interface fakes for the sake of testing. Test real behavior with real types.
  • Use t.Parallel() in every test and subtest unless the test mutates shared package-level state (e.g., the validation registry).
  • Use t.TempDir() when a test needs a temporary directory. It is auto-cleaned. Never use os.MkdirTemp in tests.
  • Use t.Helper() in any test helper function so failure messages report the caller's line number.
  • Use subtests (t.Run) to group related cases. Prefer table-driven tests for functions with multiple input/output combinations.

Assertions

Since no third-party assertion library is used, write assertions directly:

if got != want {
    t.Errorf("Foo(%q) = %q, want %q", input, got, want)
}

For errors:

if err != nil {
    t.Fatalf("unexpected error: %v", err)
}
if err == nil {
    t.Fatal("expected error, got nil")
}

For string containment:

if !strings.Contains(got, want) {
    t.Errorf("output missing %q:\n%s", want, got)
}

What to Test and How

Each package has a clear testability boundary. Here is how to test each one without mocks.

internal/executor — test Substitute with various placeholder combinations (including no placeholders, missing keys, multiple occurrences). For RunStructured/Run/RunCapture, construct a domain.Command with real shell commands like echo hello or false (to test error paths). These run actual sh -c — that is intentional, not something to mock.

func TestSubstitute(t *testing.T) {
    t.Parallel()
    got := executor.Substitute("Hello, {{name}}!", map[string]string{"name": "World"})
    if got != "Hello, World!" {
        t.Errorf("got %q, want %q", got, "Hello, World!")
    }
}

internal/validation — test each rule factory through BuildValidators. Construct a domain.Param with the desired ValidationRule fields, call BuildValidators, and assert the returned function accepts/rejects the right values. No mocks needed — validation is pure functions over structs.

func TestRequiredRejectsEmpty(t *testing.T) {
    t.Parallel()
    p := domain.Param{Validation: domain.ValidationRule{Required: true}}
    v := validation.BuildValidators(p)
    if err := v(""); err == nil {
        t.Fatal("expected error for empty string")
    }
}

internal/domain — test FindCommand directly with constructed []ResolvedCommand slices. For LoadConfig and mergeCommands, write karahi.json files to t.TempDir(), then os.Chdir into that directory before calling LoadConfig. Note: os.Chdir affects the whole process, so these tests must not use t.Parallel().

func TestLoadConfig(t *testing.T) {
    dir := t.TempDir()
    os.WriteFile(filepath.Join(dir, "karahi.json"),
        []byte(`{"commands":[{"name":"test","exec":["echo hi"]}]}`), 0o644)

    origDir, _ := os.Getwd()
    os.Chdir(dir)
    t.Cleanup(func() { os.Chdir(origDir) })

    cmds, err := domain.LoadConfig()
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if len(cmds) != 1 || cmds[0].Command.Name != "test" {
        t.Fatalf("expected 1 command named 'test', got %v", cmds)
    }
}

internal/cli — cli.Run accepts an io.Writer, making it directly testable. Pass a &bytes.Buffer as the writer, construct commands as []domain.ResolvedCommand, and assert on the buffer contents and returned error. No mocks needed.

func TestRunHelp(t *testing.T) {
    t.Parallel()
    var buf bytes.Buffer
    cmds := []domain.ResolvedCommand{{
        Command: domain.Command{Name: "greet", Description: "Say hi"},
        Source:  domain.SourceProject,
    }}
    err := cli.Run([]string{"help"}, cmds, &buf)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if !strings.Contains(buf.String(), "greet") {
        t.Errorf("help output missing 'greet':\n%s", buf.String())
    }
}

internal/tui — the TUI is the hardest to unit test because it depends on Bubble Tea's runtime. Do not attempt to unit test Model with mock messages. If TUI tests are needed, test the pure helper functions that don't require the Bubble Tea runtime (e.g., helpText, collectValues). These are unexported, so tests must live in the tui package (package tui, not package tui_test).

Test File Placement

Place test files next to the code they test:

internal/validation/rules_test.go      (package validation)
internal/executor/executor_test.go     (package executor)
internal/domain/config_test.go         (package domain)
internal/cli/cli_test.go               (package cli_test — external test)

Use external test packages (package xxx_test) for public API tests. Use internal test packages (package xxx) only when testing unexported functions.

Configuration

Commands are defined in karahi.json. Two locations are checked (in order):

  1. Project — ./karahi.json (current working directory)
  2. Global — ~/.karahi/karahi.json

The filename constant is configFileName = "karahi.json" in internal/domain/config.go.

Project commands take precedence over global commands with the same name. If neither file exists, karahi starts with zero commands (no error).

Schema

{
  "commands": [
    {
      "name": "deploy",
      "description": "Deploy to an environment",
      "params": [
        {
          "name": "environment",
          "description": "Target environment",
          "type": "choice",
          "options": ["staging", "production"],
          "validation": { "required": true }
        },
        {
          "name": "version",
          "description": "Version tag",
          "type": "input",
          "validation": {
            "required": true,
            "min_length": 2,
            "max_length": 50,
            "pattern": "^v\\d+\\.\\d+\\.\\d+$"
          }
        }
      ],
      "exec": [
        "echo 'Deploying {{version}} to {{environment}}...'",
        "echo 'Done!'"
      ]
    }
  ]
}

Field Reference

Field Type Required Notes
name string yes Command identifier, used in CLI and TUI.
description string no Shown in help and command list.
params array no Omit for parameterless commands.
params[].name string yes Used as --name in CLI and {{name}} in exec.
params[].description string no Shown in forms and help.
params[].type string yes "input" (free text) or "choice" (select from options).
params[].options string[] choice only Values for choice-type params.
params[].validation object no All sub-fields optional.
validation.required bool no Non-empty check (s == "").
validation.min_length int no Minimum rune count. Maps to *int in Go.
validation.max_length int no Maximum rune count. Maps to *int in Go.
validation.pattern string no Regex the value must match.
validation.one_of string[] no Allowlist of valid values.
exec string[] yes Shell commands run sequentially via sh -c.

Exec Behavior

  • Each string in exec is run via exec.Command("sh", "-c", resolved).
  • {{param_name}} placeholders are replaced using strings.ReplaceAll — this is literal string substitution, not a template engine. No escaping, no conditionals, no defaults.
  • Execution stops on first error. All three executor functions (Run, RunStructured, RunCapture) break on the first non-nil error.

Package Details

internal/domain — Core Types

type ParamType string         // "input" or "choice"
type Source string             // "project" or "global"

type ValidationRule struct {
    Required  bool     `json:"required,omitempty"`
    MinLength *int     `json:"min_length,omitempty"`  // pointer, not int
    MaxLength *int     `json:"max_length,omitempty"`  // pointer, not int
    Pattern   string   `json:"pattern,omitempty"`
    OneOf     []string `json:"one_of,omitempty"`
}

type Param struct { Name, Description string; Type ParamType; Options []string; Validation ValidationRule }
type Command struct { Name, Description string; Params []Param; Exec []string }
type ResolvedCommand struct { Command Command; Source Source }

The config struct (unexported) holds the top-level JSON shape: type config struct { Commands []Command }.

Key exported functions:

  • LoadConfig() ([]ResolvedCommand, error) — loads and merges both configs.
  • FindCommand(commands, name) (ResolvedCommand, bool) — lookup by name.

internal/executor — Shell Execution

Three execution modes, all stop on first error:

Function Output Use case
RunStructured(cmd, values) []Result Captured per-step in Result.Output TUI (styled rendering)
Run(cmd, values, writer) []Result Streamed to writer Direct output
RunCapture(cmd, values, writer) (string, error) Streamed + captured CLI mode

Result struct: { Command string, Output string, Err error }

internal/validation — Pluggable Validator Registry

Pattern: each RuleFactory inspects ValidationRule fields and returns a ValidateFunc (or nil if irrelevant). BuildValidators(param) composes all matching factories into a single function that runs validators in order.

The registry (unexported) is a package-level []RuleFactory populated in init(). Extend via Register(factory).

Both CLI (cli.validateParams) and TUI (form.go) use BuildValidators for validation — the same validation logic runs in both paths.

internal/cli — CLI Runner

  • Run(args []string, commands []domain.ResolvedCommand, w io.Writer) error — entry point for CLI mode. All output is written to w, making it testable with a bytes.Buffer.
  • parseFlags(args []string) (map[string]string, error) — accepts --name value and --name=value. Bare positional args return an error.
  • help is a built-in subcommand, not a config-defined command.

internal/tui — Interactive Terminal UI

State machine (defined as uiState iota in model.go):

State What's shown Key behavior
stateIdle Input prompt + output viewport / opens command list (only when input empty), q quits (only when input empty), ? shows help, scroll keys navigate viewport
stateCommandSelect Filterable command list overlay enter selects, esc returns to idle, typing filters
stateFormInput huh.Form for command params esc cancels, form submit triggers execution
stateExecuting Spinner + output viewport No input accepted, waits for execDoneMsg

Key bindings (defined in keys.go):

Binding Keys Active in
SlashCmd / idle (input empty)
Quit q idle (input empty)
Help ? idle (input empty)
Enter enter commandSelect
Escape esc all states
ForceQuit ctrl+c all states
ScrollUp up, k idle
ScrollDown down, j idle
PageUp pgup idle
PageDown pgdown idle
HalfPageUp ctrl+u idle
HalfPageDown ctrl+d idle
ScrollTop home idle
ScrollBottom end idle

Important TUI details:

  • Uses alt-screen mode (v.AltScreen = true in View()).
  • Mouse scrolling enabled: v.MouseMode = tea.MouseModeCellMotion.
  • The command list injects a synthetic "help" command that doesn't come from config. Selecting it renders help text instead of executing a shell command.
  • Two style locations: package-level vars in output.go (styleStatus, styleCommand, styleOutput, styleError) for output rendering, and the tuiStyles struct in model.go for layout chrome. Both must be updated when changing the color scheme.
  • Terminal background detection: listens for tea.BackgroundColorMsg and passes hasDarkBg to newTUIStyles() which uses lipgloss.LightDark() for adaptive colors.
  • Dynamic help bar: shows scroll hints (↑↓ scroll pgup/pgdn page) and a scroll percentage indicator only when the viewport content is scrollable (outputPane.isScrollable()).
  • Layout: recalcLayout() distributes terminal height among header (2 lines), viewport, bottom panel (input/list/form), and help bar (1 line).
  • The outputPane exposes scroll methods (scrollUp, scrollDown, pageUp, pageDown, halfPageUp, halfPageDown, gotoTop, gotoBottom) that delegate to the viewport.

Code Conventions

  • Formatting: use gofumpt -w . (preferred), or goimports, or gofmt as fallback. No Taskfile is configured.
  • Imports: group into three blocks — stdlib, external, internal — separated by blank lines.
  • Naming: PascalCase for exported, camelCase for unexported. Standard Go.
  • Errors: return explicitly. Use fmt.Errorf("...: %w", err) for wrapping, errors.New("...") for static messages.
  • Comments: standalone comments start with a capital letter and end with a period. End-of-line comments do not need a period.
  • JSON tags: use snake_case with omitempty where appropriate.
  • Pointer fields: MinLength and MaxLength are *int to distinguish "not set" from zero. Check == nil before dereferencing.
  • No context.Context: the project does not currently thread contexts.
  • Bubble Tea v2: View() returns tea.View (not string). Construct via tea.NewView(content). Set v.AltScreen = true for full-screen.

Common Tasks

Adding a Command to karahi.json

Add an entry to the commands array. No code changes required.

Adding a New Param Type

  1. Add a ParamTypeXxx constant in internal/domain/command.go.
  2. Add a case domain.ParamTypeXxx: branch in buildForm() in internal/tui/form.go — create the appropriate huh field.
  3. If CLI flag parsing needs special behavior for this type, update parseFlags() in internal/cli/cli.go.

Adding a New Validation Rule

  1. Add the field to ValidationRule in internal/domain/command.go (use *int or pointer types for optional numeric fields, omitempty tag).
  2. Write a factory function in internal/validation/rules.go matching the RuleFactory signature: func(domain.ValidationRule) ValidateFunc.
  3. Register it in the init() function in internal/validation/validator.go by appending to registry.
  4. No changes needed in CLI or TUI — both use BuildValidators which picks up all registered factories automatically.

Adding a New TUI State

  1. Add the state constant to the uiState iota block in internal/tui/model.go.
  2. Write a handleXxxKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) method.
  3. Add a case stateXxx: in the tea.KeyPressMsg switch in Update().
  4. Add rendering logic in the switch m.state block in View().
  5. Add a layout case in recalcLayout() if the new state has a custom bottom panel height.

Modifying Shell Execution

All shell execution flows through internal/executor/executor.go. Substitute() handles {{param}} interpolation. Execution stops on the first error (break on non-nil err). To change error-handling behavior (e.g., continue on failure), modify the if err != nil { break } blocks in RunStructured and Run.

Debugging "No Commands Found"

LoadConfig() silently discards errors from both config paths. Check:

  1. Is karahi.json in the current working directory?
  2. Is the JSON valid? (json.Unmarshal errors are silently ignored.)
  3. Is the top-level key "commands" (not "Commands" or other casing)?