Karahi is a Go CLI that runs shell commands defined in karahi.json. It has
two execution modes:
- CLI mode —
karahi <command> [--param value ...] - TUI mode —
karahiorkarahi 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.
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
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.
| 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. |
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 commandNo tests exist yet. When adding tests, follow these rules strictly.
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- Use only the standard library
testingpackage. 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 validationregistry). - Use
t.TempDir()when a test needs a temporary directory. It is auto-cleaned. Never useos.MkdirTempin 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.
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)
}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).
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.
Commands are defined in karahi.json. Two locations are checked (in order):
- Project —
./karahi.json(current working directory) - 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).
{
"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 | 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. |
- Each string in
execis run viaexec.Command("sh", "-c", resolved). {{param_name}}placeholders are replaced usingstrings.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.
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.
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 }
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.
Run(args []string, commands []domain.ResolvedCommand, w io.Writer) error— entry point for CLI mode. All output is written tow, making it testable with abytes.Buffer.parseFlags(args []string) (map[string]string, error)— accepts--name valueand--name=value. Bare positional args return an error.helpis a built-in subcommand, not a config-defined command.
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 = trueinView()). - 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 thetuiStylesstruct inmodel.gofor layout chrome. Both must be updated when changing the color scheme. - Terminal background detection: listens for
tea.BackgroundColorMsgand passeshasDarkBgtonewTUIStyles()which useslipgloss.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
outputPaneexposes scroll methods (scrollUp,scrollDown,pageUp,pageDown,halfPageUp,halfPageDown,gotoTop,gotoBottom) that delegate to the viewport.
- Formatting: use
gofumpt -w .(preferred), orgoimports, orgofmtas 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_casewithomitemptywhere appropriate. - Pointer fields:
MinLengthandMaxLengthare*intto distinguish "not set" from zero. Check== nilbefore dereferencing. - No context.Context: the project does not currently thread contexts.
- Bubble Tea v2:
View()returnstea.View(notstring). Construct viatea.NewView(content). Setv.AltScreen = truefor full-screen.
Add an entry to the commands array. No code changes required.
- Add a
ParamTypeXxxconstant ininternal/domain/command.go. - Add a
case domain.ParamTypeXxx:branch inbuildForm()ininternal/tui/form.go— create the appropriatehuhfield. - If CLI flag parsing needs special behavior for this type, update
parseFlags()ininternal/cli/cli.go.
- Add the field to
ValidationRuleininternal/domain/command.go(use*intor pointer types for optional numeric fields,omitemptytag). - Write a factory function in
internal/validation/rules.gomatching theRuleFactorysignature:func(domain.ValidationRule) ValidateFunc. - Register it in the
init()function ininternal/validation/validator.goby appending toregistry. - No changes needed in CLI or TUI — both use
BuildValidatorswhich picks up all registered factories automatically.
- Add the state constant to the
uiStateiota block ininternal/tui/model.go. - Write a
handleXxxKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd)method. - Add a
case stateXxx:in thetea.KeyPressMsgswitch inUpdate(). - Add rendering logic in the
switch m.stateblock inView(). - Add a layout case in
recalcLayout()if the new state has a custom bottom panel height.
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.
LoadConfig() silently discards errors from both config paths. Check:
- Is
karahi.jsonin the current working directory? - Is the JSON valid? (
json.Unmarshalerrors are silently ignored.) - Is the top-level key
"commands"(not"Commands"or other casing)?