Interactive CLI with flag parity
Status: Accepted (2026-09-14) · Amends: ADR-0014, ADR-0021 · Amended by: ADR-0037 (aps add mail; secrets are asked with hidden input and never flags)
Context#
aps is the first thing developers touch. v0.1 asks nothing: every choice is a flag, and the CLI uses only the standard library. Developers expect guided creation: arrow keys to pick options, checkboxes for features, validated text inputs, and a summary before anything is written. The same commands must also run unattended in CI, scripts and AI agents, where prompts would hang (ADR-0014 already requires a flag for every prompt).
Options#
- Flags only (v0.1).
- Prompts built on
golang.org/x/termraw mode, hand-written. - A prompt library:
github.com/charmbracelet/huh(built on Bubble Tea), with every prompt mirrored by a flag.
Decision#
Option 3.
Interaction rules#
| Rule | Decision |
|---|---|
| When prompts appear | Only when stdin and stdout are terminals, --yes, --json and --no-input are absent, and CI is not set |
| Flags | Every prompt has a flag. A value given by flag skips its prompt; the remaining prompts are pre-filled with defaults |
| Non-interactive | Missing optional values take their defaults; a missing required value is a usage error (exit 2) that names the flag to pass |
--yes | Accept defaults for everything not given by flag and skip the confirmation |
| Confirmation | Interactive runs end with a summary of what will be created and a confirm (default yes); declining writes nothing |
| Validation | Prompts use exactly the validators flags use (names, module paths, schedules, durations), shown inline as the user types (threat 2) |
| Abort | Ctrl+C or Esc aborts with exit 130 and writes nothing |
| Accessibility | --plain or ACCESSIBLE=1 switches to line-by-line prompts that work with screen readers; NO_COLOR disables colour |
| Output | Progress and results go to stderr/stdout as today; --json prints only the machine-readable result |
aps new#
| Prompt | Flag | Default |
|---|---|---|
| App name | positional <name> | none (required) |
| Go module path | --module | the app name |
| Preset (select) | --preset | minimal; full and custom appear once their recipes exist |
| apistock checkout (until the library is published) | --local | the nearest directory at or above the current one whose go.mod is module apistock.dev |
| Initialise git (confirm) | --no-git | yes |
aps gen job#
Generates a Lambda-style job (ADR-0033) into a Full preset app, one-shot (ADR-0021).
| Prompt | Flag | Default |
|---|---|---|
Job name (Go identifier, CleanupSessions) | positional <Name> | none (required) |
| Description | --description | <Name> job. |
| Trigger (select: on a schedule, at an interval, on demand) | implied by --schedule / --every / neither | on a schedule |
| Schedule (select common cron expressions or enter one) | --schedule "0 3 * * *" | 0 3 * * * |
| Interval (select or enter) | --every 15m (becomes @every 15m) | 1h |
| Timeout (select or enter) | --timeout 5m | 1m |
| Max attempts | --max-attempts N | 5 |
| Queue | --queue NAME | default |
| Priority (select 1–4) | --priority N | 1 |
| Enabled (confirm) | --disabled | enabled |
Other flags: --dry-run (print the files and the anchor line, write nothing), --json, --allow-dirty (skip the clean git tree check), --yes, --no-input, --plain.
| Output | Content |
|---|---|
internal/jobs/<name>/<name>.go | Name, Args, Worker with a Work method to fill in |
internal/jobs/<name>/<name>_test.go | A worker test |
internal/app/job_<name>.go | jobs.Define with the chosen defaults |
internal/app/jobs.go | One define<Name>Job(defs, deps) line after //aps:anchor jobs |
Safety: runs only inside a Full preset app (the anchor must exist; otherwise it prints the line to add and stops), refuses existing files, validates the rendered Go with go/format, writes through os.Root. Golden test: aps gen job Heartbeat with the heartbeat job's defaults reproduces examples/full-single's heartbeat files exactly.
Why#
- Guided prompts make the first run approachable; flags keep every command scriptable and reviewable.
huhis the de facto Go library for terminal forms, with an accessible mode, and is far less code to maintain than raw-terminal handling.- Sharing validators between prompts and flags means both paths accept exactly the same inputs.
Trade-offs#
- The CLI gains its first third-party dependencies (Bubble Tea and Lip Gloss, about 27 modules). They run only on developer machines, never in generated apps, and are covered by govulncheck and pinned in
go.sum(threats 6 and 21). - Interactive behaviour needs pseudo-terminal-free tests: prompts are built from plain data and tested through their flag path; the form layer stays thin.
Consequences#
- ADR-0014's prompt table is implemented with this behaviour; architecture's "CLI built with the standard library only" no longer holds.
CI-set environments never prompt, so existing CI usage is unchanged.- New commands follow the same rules: prompt table, flag table, defaults, non-interactive errors.
v0.2 implementation notes (2026-09-14)#
aps newandaps gen jobimplement the tables above withhuhv1.0.0; prompts draw on stderr and read stdin;cli.Maintakes stdin so tests run the flag path with a non-terminal reader.aps gen jobalso accepts--on-demand, and asks for queue and priority only through flags (they rarely change at creation and remain editable in/ops/jobs).- Job names accept
CleanupSessions,cleanup-sessionsorcleanup_sessions; names that would be Go keywords as packages (for exampleDefault) are rejected. - Schedule validation in the CLI checks the cron shape, descriptors and the 1-minute
@everyminimum;modules/jobsvalidates fully at startup. - New
aps gen joblines are inserted directly after the anchor, so the newest job is listed first. - Follow-up questions are asked as separate short steps rather than hidden groups:
huh's accessible mode ignores group hide functions, so hiding would make--plainusers answer questions that don't apply. The question flows are tested in accessible mode with scripted answers. - Exit code 130 on cancel;
aps newprints which apistock checkout it detected when not prompting. aps add mail(ADR-0037) follows these rules with one exception: secrets (the Resend API key, the SMTP password) have no flag. They are asked with hidden input only on a terminal, saved only to.env, and never printed. In plain modehuhreads them without echo, which needs a terminal, so scripted prompt tests use a normal input instead.aps gen resource(ADR-0039) asks for the resource name and its fields (one line, in the same syntax as the positional arguments) with the validators the arguments use, then confirms a summary. Plural, ID prefix and scope are flags only. Flags may come before, between or after the positional arguments.aps gen migrationasks only for the name, with the validator the argument uses, and writes without a confirmation step: it creates one empty file, named in the output and undone by deleting it. Names acceptadd_customer_phone,AddCustomerPhoneoradd-customer-phone, split into words like job names.- User documentation: CLI guide.
v0.3 implementation notes (2026-09-15)#
- Every prompt uses one
huhtheme in the apistock palette (theme): no borders, dim hints, lime only on the open question's?and the option cursor, danger colour for validation errors. Colour follows the output: none when it isn't a terminal orNO_COLORis set. aps newasks one question at a time, in the style ofcreate-next-app: the open question is? label … answeron one line (options listed below it for the preset), and each answered question folds into✓ label … answer. Values given by flag are printed as answered lines too.- Because every answer is already on screen,
aps newends with a one-linecreate <name> in ./<name>?confirm (default yes) instead of a summary note. The rule "a summary of what will be created and a confirm" holds; the folded lines are the summary.aps gen job,aps gen resourceandaps add mailkeep their summary notes. - Folding needs one form per question, so Shift+Tab no longer goes back to an earlier
aps newquestion; Ctrl+C and running again is the way back. In plain mode nothing is folded, since each answer is already printed. aps newoutput is a log on stdout:creating <name> in ./<name>, the preset and library, one✓line per finished step (wrote N files,ran go mod tidy,initialised git),created <name>, where things are, andnext:with the commands to run.go mod tidyoutput is shown only when it fails.--jsonprints only the result. This replaces the stderr line naming a detected checkout: the log's library line says it was found and how to change it.