Skip to content

Custom Prompts

This guide explores advanced techniques for creating and customizing prompts in your clapp applications.

Creating Custom Prompts

clapp allows you to create your own custom prompts by extending the built-in functionality.

Basic Custom Prompt

Create a simple custom prompt:

ts
import { createPrompt } from '@stacksjs/clapp'

// Create an email prompt
const emailPrompt = createPrompt({
  name: 'email',
  validate: (value) => {
    if (!value.includes('@'))
      return 'Please enter a valid email address'
    return true
  },
  transform: value => value.toLowerCase().trim(),
})

// Use the custom prompt
const userEmail = await emailPrompt('Enter your email:')
console.log(`Email: ${userEmail}`)

Advanced Custom Prompt

Build a more sophisticated custom prompt with custom rendering:

ts
import { createPrompt, cursor, renderPrompt, style } from '@stacksjs/clapp'

// Create a rating prompt (1-5 stars)
const ratingPrompt = createPrompt({
  name: 'rating',
  initialValue: 3,
  validate: (value) => {
    const rating = Number(value)
    if (Number.isNaN(rating) || rating < 1 || rating > 5)
      return 'Rating must be between 1 and 5'
    return true
  },
  render: (state) => {
    // Clear any previous prompt render
    if (state.firstRender)
      cursor.hide()
    else
      renderPrompt.restoreCursor()

    // Render prompt message
    renderPrompt.message(state.message)

    // Render stars
    const rating = Number(state.value) || 0
    const stars = '★'.repeat(rating) + '☆'.repeat(5 - rating)
    renderPrompt.value(style.yellow(stars))

    // Render hint
    renderPrompt.hint(' (1-5)')

    // Save cursor position
    renderPrompt.saveCursor()
  },
  keybindings: {
    left: (state) => {
      const current = Number(state.value) || 3
      state.value = String(Math.max(1, current - 1))
    },
    right: (state) => {
      const current = Number(state.value) || 3
      state.value = String(Math.min(5, current + 1))
    },
    number: (state, key) => {
      const num = Number(key.name)
      if (num >= 1 && num <= 5)
        state.value = String(num)
    },
  },
})

// Use the rating prompt
const userRating = await ratingPrompt('Rate your experience:')
console.log(`User rating: ${userRating}/5`)

Extending Existing Prompts

You can extend the built-in prompts to create specialized variants:

ts
import { prompt } from '@stacksjs/clapp'

// Extend the text prompt for file paths
async function filePathPrompt(message, options = {}) {
  return prompt.text(message, {
    ...options,
    validate: (value) => {
      // Check if the path has the correct format
      if (!value.match(/^([a-z]:)?[/\\]?([^/:*?"<>|]+[/\\])*([^/:*?"<>|]+)?$/i))
        return 'Please enter a valid file path'

      // You could also check if the file exists
      // const exists = fs.existsSync(value)
      // if (!exists) return 'File does not exist'

      return true
    },
  })
}

// Use the extended prompt
const configPath = await filePathPrompt('Path to config file:')

Custom Prompt Layouts

Create prompts with custom layouts:

ts
import { createPrompt, renderPrompt, style } from '@stacksjs/clapp'

const formPrompt = createPrompt({
  name: 'form',
  initialValue: { name: '', email: '', age: '' },
  validate: (value) => {
    const errors = []

    if (!value.name)
      errors.push('Name is required')

    if (!value.email.includes('@'))
      errors.push('Email must be valid')

    const age = Number(value.age)
    if (Number.isNaN(age) || age < 18)
      errors.push('Age must be at least 18')

    return errors.length ? errors.join('\n') : true
  },
  render: (state) => {
    renderPrompt.restoreCursor()
    renderPrompt.message(state.message)
    renderPrompt.newline()

    const { name, email, age } = state.value
    const fieldActive = state.cursor || 'name'

    // Render each form field
    renderPrompt.field(
      'Name:     ',
      name,
      fieldActive === 'name' ? style.inverse : style.dim
    )
    renderPrompt.newline()

    renderPrompt.field(
      'Email:    ',
      email,
      fieldActive === 'email' ? style.inverse : style.dim
    )
    renderPrompt.newline()

    renderPrompt.field(
      'Age:      ',
      age,
      fieldActive === 'age' ? style.inverse : style.dim
    )
    renderPrompt.newline()

    // Render validation errors
    if (state.error) {
      renderPrompt.newline()
      renderPrompt.error(state.error)
    }

    renderPrompt.saveCursor()
  },
  keybindings: {
    tab: (state) => {
      // Cycle through fields
      const fields = ['name', 'email', 'age']
      const currentIndex = fields.indexOf(state.cursor || 'name')
      state.cursor = fields[(currentIndex + 1) % fields.length]
    },
    up: (state) => {
      // Move to previous field
      const fields = ['name', 'email', 'age']
      const currentIndex = fields.indexOf(state.cursor || 'name')
      state.cursor = fields[(currentIndex - 1 + fields.length) % fields.length]
    },
    down: (state) => {
      // Move to next field
      const fields = ['name', 'email', 'age']
      const currentIndex = fields.indexOf(state.cursor || 'name')
      state.cursor = fields[(currentIndex + 1) % fields.length]
    },
    key: (state, key) => {
      // Update the current field
      const field = state.cursor || 'name'
      if (key.name === 'backspace') {
        state.value[field] = state.value[field].slice(0, -1)
      }
      else if (key.name !== 'return') {
        state.value[field] += key.sequence
      }
    },
  },
})

// Use the form prompt
const userData = await formPrompt('Please fill out your information:')
console.log('User data:', userData)

Interactive Wizards

Create more complex multi-step wizards:

ts
import { box, prompt, style } from '@stacksjs/clapp'

async function setupWizard() {
  // Welcome screen
  box(style.bold('Project Setup Wizard'), {
    padding: 1,
    borderColor: 'blue',
  })

  // Step 1: Project information
  const projectName = await prompt.text('Project name:', {
    validate: value => value.length > 0 || 'Project name is required',
  })

  const projectType = await prompt.select('Project type:', [
    { value: 'app', label: 'Application' },
    { value: 'lib', label: 'Library' },
    { value: 'api', label: 'API Service' },
  ])

  // Step 2: Dependencies (conditional based on project type)
  let dependencies = []

  if (projectType === 'app') {
    dependencies = await prompt.multiselect('Select frontend dependencies:', [
      { name: 'React', value: 'react', checked: true },
      { name: 'Vue', value: 'vue' },
      { name: 'Tailwind CSS', value: 'tailwind' },
      { name: 'TypeScript', value: 'typescript', checked: true },
    ])
  }
  else if (projectType === 'api') {
    dependencies = await prompt.multiselect('Select backend dependencies:', [
      { name: 'Express', value: 'express', checked: true },
      { name: 'Database ORM', value: 'orm' },
      { name: 'Authentication', value: 'auth' },
      { name: 'TypeScript', value: 'typescript', checked: true },
    ])
  }

  // Step 3: Configuration options
  const config = {}

  if (dependencies.includes('typescript')) {
    config.strict = await prompt.confirm('Enable strict TypeScript mode?', {
      default: true,
    })
  }

  if (projectType === 'app' || projectType === 'api') {
    config.port = await prompt.number('Port number:', {
      default: 3000,
      min: 1024,
      max: 65535,
    })
  }

  // Final confirmation
  const summary = `
Project: ${style.bold(projectName)} (${projectType})
Dependencies: ${dependencies.join(', ') || 'none'}
Configuration: ${Object.entries(config)
  .map(([key, value]) => `${key}: ${value}`)
  .join(', ') || 'default'}
  `

  box(`${style.bold('Project Summary')}\n${summary}`, {
    padding: 1,
  })

  const confirmed = await prompt.confirm('Create project with these settings?')

  if (confirmed) {
    return {
      name: projectName,
      type: projectType,
      dependencies,
      config,
    }
  }

  return null
}

// Run the wizard
const project = await setupWizard()
if (project) {
  console.log('Creating project:', project)
  // Implementation details...
}

State Management in Prompts

Manage complex state in custom prompts:

ts
import { createPrompt, style } from '@stacksjs/clapp'

// Create a shopping cart prompt
const cartPrompt = createPrompt({
  name: 'cart',
  initialValue: {
    items: [
      { id: 1, name: 'Item 1', price: 10, quantity: 1 },
      { id: 2, name: 'Item 2', price: 15, quantity: 2 },
    ],
    cursor: 0,
  },

  render: (state) => {
    // Render cart items with quantities and prices
    // Allow user to navigate, increase/decrease quantities, etc.
    // Calculate and show total
  },

  keybindings: {
    up: (state) => {
      // Move cursor up
      if (state.value.cursor > 0)
        state.value.cursor--
    },
    down: (state) => {
      // Move cursor down
      if (state.value.cursor < state.value.items.length - 1)
        state.value.cursor++
    },
    plus: (state) => {
      // Increase quantity
      state.value.items[state.value.cursor].quantity++
    },
    minus: (state) => {
      // Decrease quantity (minimum 0)
      const item = state.value.items[state.value.cursor]
      if (item.quantity > 0)
        item.quantity--
    },
    d: (state) => {
      // Delete item
      state.value.items.splice(state.value.cursor, 1)
      if (state.value.cursor >= state.value.items.length)
        state.value.cursor = state.value.items.length - 1
    },
  },

  submit: (state) => {
    // Return only the items, filtering out those with quantity 0
    return state.value.items.filter(item => item.quantity > 0)
  },
})

// Use the shopping cart prompt
const cart = await cartPrompt('Your shopping cart:')
console.log('Cart items:', cart)

Custom Prompt Appearance

Customize the appearance of prompts with themes:

ts
import { configPrompts, style } from '@stacksjs/clapp'

// Configure global prompt appearance
configPrompts({
  symbols: {
    // Custom symbols
    pointer: '→',
    check: '✓',
    cross: '✗',
    bullet: '•',
  },

  colors: {
    // Custom colors
    primary: style.blue,
    success: style.green,
    error: style.red,
    muted: style.dim,
  },

  formatting: {
    // Custom text formatting
    message: text => style.bold(text),
    hint: text => style.dim(`(${text})`),
    highlight: text => style.underline.cyan(text),
  },
})

// Prompts will now use the custom appearance

Integrating with System State

Create prompts that interact with the system:

ts
import { execSync } from 'node:child_process'
import * as fs from 'node:fs'
import { prompt, spinner } from '@stacksjs/clapp'

// Prompt that shows available disk space
async function diskSpacePrompt(message) {
  const spin = spinner('Checking disk space...')
  spin.start()

  try {
    // Get disk space (this is platform-specific)
    const diskInfo = execSync('df -h').toString()
    spin.stop()

    console.log(diskInfo)

    return prompt.confirm(message)
  }
  catch (err) {
    spin.stop('Failed to check disk space')
    return prompt.confirm(message)
  }
}

// Prompt that shows available npm packages
async function packagePrompt(message) {
  // Get package.json if it exists
  let currentDeps = []
  try {
    const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
    currentDeps = Object.keys(packageJson.dependencies || {})
  }
  catch (err) {
    // No package.json found, that's okay
  }

  return prompt.multiselect(message, [
    { name: 'Express', value: 'express', checked: currentDeps.includes('express') },
    { name: 'Lodash', value: 'lodash', checked: currentDeps.includes('lodash') },
    { name: 'Axios', value: 'axios', checked: currentDeps.includes('axios') },
    { name: 'Dotenv', value: 'dotenv', checked: currentDeps.includes('dotenv') },
  ])
}

// Use the system-aware prompts
const shouldProceed = await diskSpacePrompt('Continue with installation?')
if (shouldProceed) {
  const packages = await packagePrompt('Select packages to install:')
  console.log('Installing:', packages)
}

For more information on creating and customizing prompts, see the Prompts API Reference.

Released under the MIT License.