askeroo
Version:
A modern CLI prompt library with flow control, history navigation, and conditional prompts
323 lines (238 loc) • 9.06 kB
Markdown
# Askeroo
A modern CLI prompt library with flow control, back navigation, and conditional fields.
> [!WARNING]
> This library is still in alpha and things might change before release.
## Features
- Works great out of the box, with key prompts, like text, radio, multi
- Create your own bespoke prompts using a highly flexible framework
- Runs prompts in a flow which you can configure and customise to suit your needs
- Stateful navigation that preserves user's inputs and supports back navigation
- Return structured data your way with imperative-style functions
- Write dynamic branching with groups and conditionals
- Run tasks with progress tracking, parallel or sequential execution, and error handling
- Display notes with support for markdown and chalk syntax
- Cancel event listeners for cleanup when users exit with Ctrl+C
## Installation
```bash
npm i askeroo
```
## Quick Start
Askeroo comes with a default runtime and set of prompts that you can use out of the box.
```typescript
import { ask, group, text, confirm, note } from "askeroo";
const flow = async () => {
// Display notes
await note("[Hello world!]{bgBlue}");
// Call prompts on their own
const nickname = await text({ label: "Nickname" });
// Group prompts together
const profile = await group(async () => {
const first = await text({ label: "First name" });
const last = await text({ label: "Last name" });
return { first, last };
});
// Create conditional inputs
const prefs = await group(
async () => {
const role = await text({ label: "Role (user/admin)" });
if (role === "admin") {
const code = await text({ label: "Access code" });
return { role, code };
}
const news = await confirm({ label: "Subscribe to newsletter?" });
return { role, news };
},
{ label: "Preferences" } // Optional group labels
);
// Return structured data your way
return { nickname, profile, prefs };
};
const result = await ask(flow);
console.log(result);
```
## How does Askeroo work?
Askeroo is built around the concept of a flow, where each prompt is executed in sequence. Some prompts can auto-submit, while others wait for user input, and each prompt can appear in different states—before activation, while active, and after completion. Prompts are also dynamic: they can be updated or changed at any point within your flow function.
When you call `ask(flow)`, Askeroo sets up a runtime that runs your flow function **multiple times** using a replay mechanism. This allows for a smooth and interactive experience:
1. **First run**: The flow executes, prompts are shown one by one, and answers are stored.
2. **User navigates back**: The runtime replays the flow, automatically filling in previous answers.
3. **User changes an answer**: The runtime clears any answers that came after the changed prompt and continues from there.
4. **Repeat**: This process continues until the user completes the flow.
From the user's perspective, this feels like a seamless way to move back and forth between prompts. Behind the scenes, Askeroo is replaying your flow function as needed to determine which prompts should be shown at each step.
```ts
// Your flow function runs multiple times
const flow = async () => {
const role = await radio({
label: "Select your role",
options: ["Developer", "Designer"],
});
if (role === "Developer") {
const language = await radio({
label: "Preferred language",
options: ["TypeScript", "JavaScript"],
});
return { role, language };
}
const tool = await radio({
label: "Design tool",
options: ["Figma", "Sketch"],
});
return { role, tool };
};
```
**Flow:**
```
Select your role: Developer
↓
Preferred language: TypeScript
↓
User presses back ⬆
↓
Select your role: Designer
↓
Design tool: Figma
↓
Complete
```
The flow replays automatically when the user navigates back, clearing subsequent answers and showing the appropriate prompts based on the new selection.
## Running Flows
- ### Run prompt flows
**`ask(flow: FlowFunction, options: FlowOpts): Promise<T>`**
Executes a prompt flow with replay and navigation support.
```ts
const result = await ask(async () => {
const name = await text({
message: "Name",
});
return { name };
});
```
**Options**
```ts
interface FlowOpts {
allowBack?: boolean;
}
```
**Flow API**
The flow function receives an API object with the following properties:
- `onCancel(callback: () => void)`: Register a callback to be called when the flow is cancelled (e.g., via Ctrl+C)
```ts
const result = await ask(async ({ text, confirm, onCancel }) => {
// Register cleanup callback
onCancel(() => {
console.log("Flow cancelled! Cleaning up...");
// Close connections, remove temp files, etc.
});
const name = await text({ label: "Name" });
const confirmed = await confirm({ label: "Confirm?" });
return { name, confirmed };
});
```
**Cancellation Behavior:**
- **First Ctrl+C**: Triggers `onCancel` callbacks for graceful cleanup. A hint message `"(Press Ctrl+C again to force quit)"` is displayed.
- **Second Ctrl+C**: Forces immediate exit with `process.exit(1)`, bypassing any cleanup. Useful if cleanup is stuck or taking too long.
Multiple cancel callbacks can be registered, and they will all be called when the flow is cancelled:
```ts
await ask(async ({ onCancel }) => {
// Create temp file
const tempFile = "temp.json";
fs.writeFileSync(tempFile, "{}");
// Register multiple cleanup callbacks
onCancel(() => {
console.log("Removing temp file...");
fs.unlinkSync(tempFile);
});
onCancel(() => {
console.log("Closing database connection...");
db.close();
});
// ... rest of flow
});
```
- ### Group prompts
**`group(flow: () => Promise<T>, options: GroupOpts): Promise<T>`**
Group prompts visually and control their behaviour together.
## Prompts
- ### `text(options: TextOpts)`
Show a text input.
- ### `confirm(options: ConfirmOpts)`
Show a confirmation with choice of yes or no.
- ### `radio(options: RadioOpts)`
Show a single-choice selection from multiple options.
- ### `multi(options: MultiOpts)`
Show a multi-choice selection allowing multiple options.
- ### `note(MarkdownString)`
Show a note using markdown.
- ### `component(options: ComponentOpts)`
Render a React component using Ink.
- ### `tasks(taskList: Task[], options?: TasksOpts)`
Execute a list of tasks with progress indication and error handling.
## Create Custom Prompts
Askeroo uses **automatic plugin registration** - when you create a prompt with `createPrompt`, it registers itself when imported. This means:
- ✅ No manual registration needed
- ✅ Only bundle the prompts you actually use
- ✅ Custom prompts work exactly like built-in ones
```ts
import React, { useState } from "react";
import { Text, useInput } from "ink";
import { createPrompt } from "askeroo";
// Define your options interface
export interface CustomOptions {
label: string;
placeholder?: string;
}
// Create and export the plugin - it auto-registers when imported!
export const customField = createPrompt<CustomOptions, string>({
type: "custom-field",
component: ({ node, options, events }: any) {
const [value, setValue] = useState("");
useInput((input, key) => {
if (key.return) {
events.onSubmit?.(value);
} else if (input) {
setValue((prev) => prev + input);
}
});
// Handle different states
if (node.state === "completed") {
return (
<Text>
{options.label}: <Text color="blue">{node.completedValue}</Text>
</Text>
);
}
if (node.state === "disabled") {
return <Text dimColor>{options.label}: ...</Text>;
}
return (
<Text>
{options.label}: {value}
</Text>
);
}
});
```
### Usage
```ts
import { ask } from "askeroo";
import { customField } from "./custom-field.js"; // Auto-registers when imported!
const result = await ask(async () => {
const input = await customField({
label: "Enter something",
});
return { input };
});
```
## Examples
Run the included example:
```bash
npm run example
```
Try `npm run build && npm run example test-run`
## Development
```bash
npm install
npm run build
npm run dev # Watch mode
```
## License
MIT