Search
Search documentation
Interactivity
Updated Oct 27, 2026
Fireargs never reads stdin, so the handler is free to prompt the user. The same command still serves scripts and LLM agents, which cannot answer a prompt. Keep it working for both with four rules:
- Optional in the schema. A prompted field is
.optional(), so a value given as a flag or through--jsonskips the prompt. - Never in JSON mode.
context.jsonistrueunder--json, and an agent waiting on JSON would hang on a question. - Only on a terminal.
process.stdin.isTTYis false in a pipe or CI. Fail there with an error that names the flag to pass. - Prompts go to stderr. stdout carries the rendered or JSON output.
Asking for a missing value
import { createInterface } from "node:readline/promises"
import { f } from "fireargs"
import { z } from "zod"
const name = f.option().schema(z.string().optional().describe("Asked for when left out."))
const init = f
.command({
name: "init",
description: "Create a project.",
renderError: error => `error: ${error instanceof Error ? error.message : error}`,
})
.input(z.object({ name }))
.output(z.object({ name: z.string() }))
.render(output => `Created ${output.name}`)
.handler(async (input, context) => {
let name = input.name
if (name === undefined) {
if (context.json || !process.stdin.isTTY) {
throw new Error("--name is required when not run in a terminal")
}
name = await askQuestion("Project name")
}
return { name }
})
async function askQuestion(question: string) {
const readline = createInterface({ input: process.stdin, output: process.stderr })
const answer = await readline.question(`${question}: `)
readline.close()
return answer.trim()
}
init.parseAsync(process.argv)$ node init.ts # a terminal: asks
Project name: demo
Created demo
$ node init.ts --name demo # the flag skips the prompt
Created demo
$ node init.ts < /dev/null # no terminal: fails, exit code 1
error: --name is required when not run in a terminalThe handler is async, so it can await the answer. renderError turns the
thrown error into an error: … line instead of a stack trace — see
JSON mode for its JSON-aware form.
Confirming a destructive action
Give the command a --yes flag that answers the confirmation up front, so a
script never has to:
const yes = f.option({ short: "y" }).schema(z.boolean().default(false).describe("Skip the prompt."))
// in the handler
if (!input.yes) {
if (context.json || !process.stdin.isTTY) {
throw new Error("pass --yes to confirm when not run in a terminal")
}
const answer = await askQuestion(`Delete ${input.path}? [y/N]`)
if (answer.toLowerCase() !== "y") return { deleted: false }
}Richer prompts
node:readline covers plain questions. For selects, checkboxes or masked
passwords, call a prompt library such as
@inquirer/prompts from the
handler under the same rules; most of them take an output stream, which
should be process.stderr.
In the manifest
The --llms manifest shows a prompted field as
optional, which is what an agent sees: it passes the value through --json,
or gets the error. Say in the field's .describe(...) that it is asked for
when left out, so a person reading --help knows too.
Created with ❤ and Livemark