Architecture
repl/
├── cmd/yupsh/main.go — entry point: wires stdin/stdout/stderr + afero.NewOsFs
│ into the app session; drives a TTY through
│ golang.org/x/term for history/editing, else plain scanning.
├── internal/
│ ├── app/ — APP TIER: the read-eval-print loop, built-ins, banner, help
│ ├── line/ — ORCHESTRATION TIER: one input line → a planned pipeline.Assembly
│ ├── token/ — tokenize a line into pipeline segments
│ ├── expansion/ — tilde + glob expansion (POSIX-style)
│ ├── flags/ — Unix flag → typed-option translation
│ ├── command/ — the command registry + segment builders
│ ├── pipeline/ — assemble + run the typed-stream pipeline
│ └── constants/ — sentinel Error type and Err* constants
├── integration_test.go — black-box tests of the compiled binary
└── Makefile — the quality gate (make check) + integration targetThe three tiers#
| Tier | Location | Responsibility | Forbidden |
|---|---|---|---|
| app | internal/app |
The REPL surface: loop, built-ins, banner, help, line reading, rendering a planned pipeline | Tokenizing, expansion, flag translation, pipeline assembly |
| orchestration | internal/line |
Turns one line into a pipeline.Assembly by composing the implementation packages |
Importing the app tier; output formatting |
| implementation | internal/<concept> |
The actual reusable work, named for its concept (token, expansion, flags, command, pipeline) |
Any knowledge of the REPL or of being "a command" |
There's a single orchestration package because the REPL has a single "command": the input line. Errors are constant sentinels in internal/constants, matched with errors.Is.
Why a hand-written parser, not urfave/cli#
template.cli targets urfave/cli apps, which parse os.Args once against a fixed command tree. yupsh interprets live shell lines instead — pipes, quoting, runtime globbing, per-command flag translation onto third-party constructors — none of which urfave/cli models. So yupsh keeps its own tokenizer/expansion/flag layer and maps only the template's structure (three tiers, cmd/ entry, internal/constants, the quality gate), not its argument parser.
How a line is executed#
token.Parse tokenizes the line, recording quoting, and splits on |.
expansion.Expand applies tilde then glob expansion to each command's arguments (unquoted tokens only), against the injected afero.Fs.
flags.Parse translates each segment's flags against the command's flag table into the typed options its constructor expects.
command builds each segment; the first stage selects the input source (source command, files, or stdin), later stages become filters.
pipeline.Plan folds the stages into an Assembly, which app runs via gloo.RunContext.
Adding a command#
Add one entry to the map in internal/command/registry.go. Most filters are a single line:
"wc": {Flags: wcFlags, Build: filter(func(o []any) gloo.Command[[]byte, []byte] {
return wc.Wc(o...)
}), Summary: "count lines, words, and bytes"},Declare its flags as a flags.Set of flags.Bool/flags.Value/flags.Num entries mapping each Unix flag to the typed option, cover the new builder in command_test.go to 100%, and add a behavioral case to the integration tests if it introduces a new capability.
Next: Commands for the full catalogue this registry produces.