Building Generators

Actions Reference

Actions are reusable, atomic units of work. Steps in a workflow reference them with uses:. Built-in actions ship with xo (xo/ prefix); custom actions live in your generator repo (./) or on GitHub (@github/).

Using an action

Assign id: to any step to capture its outputs and reference them in later steps via {{ steps.<id>.outputs.<key> }}.

steps:
  - uses: xo/install-pkg        # built-in
    with:
      pkg: stripe

  - uses: xo/detect-pm          # built-in — produces outputs
    id: pm

  - uses: ./actions/setup.yaml  # custom composite action
    id: setup
    with:
      configFile: tailwind.config.ts

  - run: "{{ steps.pm.outputs.value }} db:push"

Step execution

Step outputs

Any step with id: exposes its results as {{ steps.<id>.outputs.<key> }} in subsequent steps.

- uses: xo/detect-pm
  id: pm

- run: "{{ steps.pm.outputs.value }} install"

Parallel steps

Add parallel: true to steps that can safely run concurrently. Consecutive parallel steps form a batch executed via Promise.all. The batch completes before the next non-parallel step begins.

steps:
  - uses: xo/detect-pm
    id: pm
    parallel: true
  - uses: xo/detect-lang
    id: lang
    parallel: true
  - uses: xo/file-exists    # waits for both above to finish
    id: hasPrisma
    with:
      path: prisma/schema.prisma
Parallelise independent detection steps to reduce workflow time — especially useful when each check makes a separate file-system or network call.

Detection actions

Detection actions inspect the project without modifying it. They always run — even in --dry-run mode — because they have no side effects. Assign an id to read their outputs in later steps.

xo/detect-pm

Detects the package manager from lockfile presence.

- uses: xo/detect-pm
  id: pm
  # outputs: { value: "pnpm" | "npm" | "yarn" | "bun" }

- run: "{{ steps.pm.outputs.value }} add stripe"
xo/detect-lang

Detects the primary language of the project.

- uses: xo/detect-lang
  id: lang
  # outputs: { value: "typescript" | "javascript" | "dart" | "go" | "rust" | "python" }

- if: "steps.lang.outputs.value == 'typescript'"
  uses: xo/copy
  with:
    from: templates/config.ts
    to: src/config.ts
xo/file-exists

Checks whether a file or directory exists at a path relative to the project root.

- uses: xo/file-exists
  id: hasPrisma
  with:
    path: prisma/schema.prisma
  # outputs: { exists: true | false }

- if: "steps.hasPrisma.outputs.exists == false"
  run: pnpm dlx prisma init
xo/pkg-installed

Checks whether a package is in dependencies, devDependencies, or peerDependencies.

- uses: xo/pkg-installed
  id: hasNext
  with:
    pkg: next
  # outputs: { installed: true | false, version: "^14.0.0" | null }

- if: "steps.hasNext.outputs.installed == true"
  uses: xo/copy
  with:
    from: templates/next-route.ts
    to: app/api/stripe/route.ts
xo/read-json

Reads a value from a JSON file at a dot-notation path.

- uses: xo/read-json
  id: pkgName
  with:
    file: package.json
    path: name
  # outputs: { value: "my-app" }

File actions

xo/copy

Copies a file, directory, or glob match from the generator to the project. For remote generators (GitHub), directories are enumerated via the GitHub Contents API. For local generators, fs.copy handles them natively.

# Single file
- uses: xo/copy
  with:
    from: templates/button.tsx
    to: src/components/button.tsx

# Entire directory (recursive)
- uses: xo/copy
  with:
    from: templates/components/
    to: src/components/

# Glob pattern — supports *, **, ?
- uses: xo/copy
  with:
    from: "lib/**/*.dart"
    to: lib/
Glob wildcards: * matches any characters within a path segment, ** matches across directory separators, ? matches a single character. When from has no glob characters and points at a directory, the whole directory is copied recursively.
xo/template

Renders a Handlebars template from the generator and writes the output. Supports {{ inputs.* }}, {{ steps.*.outputs.* }}, and all Handlebars helpers.

- uses: xo/template
  with:
    from: templates/service.hbs
    to: "src/{{ inputs.moduleName }}/{{ inputs.moduleName }}.service.ts"
xo/append

Appends content to a file. Creates the file if it does not exist.

- uses: xo/append
  with:
    file: .env.example
    content: "STRIPE_SECRET_KEY=\nSTRIPE_WEBHOOK_SECRET=\n"
xo/inject

Inserts content immediately after or before a marker string in an existing file. Throws if the marker is not found. Exactly one of after or before is required.

# After a marker
- uses: xo/inject
  with:
    file: app.module.ts
    after: "imports: ["
    content: "PaymentModule,"

# Before a marker
- uses: xo/inject
  with:
    file: src/index.ts
    before: "export default app"
    content: "import './payment';"
xo/replace

Finds a regex pattern in a file and replaces it. The search field is a regex string.

- uses: xo/replace
  with:
    file: config.ts
    find: "DEBUG=false"
    replace: "DEBUG=true"
xo/json

Reads a JSON file, sets a value at a dot-notation path, and writes it back. Deep paths are created if missing.

- uses: xo/json
  with:
    file: package.json
    path: dependencies.stripe
    value: "^14.0.0"
xo/env

Adds or updates variables in a .env file. Skips any key that already exists.

- uses: xo/env
  with:
    file: .env.example
    variables:
      STRIPE_SECRET_KEY: ""
      STRIPE_WEBHOOK_SECRET: ""

Prompt actions

xo/prompt

Displays a message or requests confirmation from the user mid-workflow. Use type: info or type: warn for status messages, and type: confirm to gate steps behind a yes/no answer.

- uses: xo/prompt
  with:
    type: info
    message: "Setting up your Flutter project..."
- uses: xo/prompt
  with:
    type: warn
    message: "This will overwrite your existing pubspec.yaml"
- id: proceed
  uses: xo/prompt
  with:
    type: confirm
    message: "Install heavy dependencies? (~200MB)"
    default: true

- if: "steps.proceed.outputs.confirmed == true"
  uses: xo/install-pkg
  with:
    pkg: some-heavy-pkg
typeDescriptionOutputs
infoPrints an informational message
warnPrints a warning message
successPrints a success message
confirmAsks a yes/no question{ confirmed: boolean }

Code actions

xo/ast-import

Adds an import statement to a TypeScript/JavaScript file using AST manipulation (ts-morph). No-ops if the import already exists.

- uses: xo/ast-import
  with:
    file: src/app.module.ts
    import: PaymentModule
    from: ./payment/payment.module

Package actions

xo/install-pkg

Installs a package using the auto-detected package manager. If a xo/detect-pm step with id: pm ran earlier in the workflow, its output is used directly. Skips if the package is already installed.

- uses: xo/install-pkg
  with:
    pkg: stripe
    dev: false

Execution actions

xo/run · run:

Runs a shell command in the project root. The inline run: shorthand is equivalent and preferred.

# Inline shorthand
- run: pnpm db:push

# As an action
- uses: xo/run
  with:
    command: pnpm db:push
xo/script

Runs a shell script file from the generator's directory.

- uses: xo/script
  with:
    file: scripts/post-install.sh

Custom actions

Package reusable logic as action.yaml files. Reference them locally with uses: ./actions/name.yaml or share them on GitHub with uses: @github/owner/repo.

Composite action (YAML)

A YAML file with steps: that composes xo/* built-ins. No code needed. Only values declared under outputs: are visible to the parent workflow.

# actions/add-barrel.yaml
name: add-barrel-export
description: Append a barrel export to an index file

inputs:
  exportName:
    prompt: "Export name?"
  filePath:
    prompt: "Source file path (without extension)?"

outputs:
  exportLine:
    value: "export { default as {{ inputs.exportName }} } from './{{ inputs.filePath }}';"

steps:
  - uses: xo/append
    with:
      file: src/components/index.ts
      content: "export { default as {{ inputs.exportName }} } from './{{ inputs.filePath }}';"
- uses: ./actions/add-barrel.yaml
  id: barrel
  with:
    exportName: "{{ inputs.componentName }}"
    filePath: "{{ inputs.componentName }}"

- run: echo "Added: {{ steps.barrel.outputs.exportLine }}"

Script action (JS)

A plain .js file that exports an async function. Full Node.js — use any logic, call external APIs, read files, return outputs. The function receives { inputs, cwd, generatorDir, dryRun, env } and returns a plain object whose keys become step outputs.

// actions/log-info.js
export default async function run({ inputs, cwd, generatorDir, dryRun, env }) {
  const msg = `component "${inputs.componentName}" added to ${cwd}`;
  if (!dryRun) console.log(`    ✔ ${msg}`);
  return { message: msg };
}
- uses: ./actions/log-info.js
  id: logger
  with:
    componentName: "{{ inputs.componentName }}"

- run: echo "{{ steps.logger.outputs.message }}"

Prompt action — reusable input sets

An action.yaml with runs.using: prompt collects inputs interactively and exposes the answers as step outputs — no steps required. Works with local ./actions/ references, @github/ sharing, and pinned tags. Usewith: to pre-fill or override specific inputs.

# actions/app-naming.yaml
name: app-naming
description: Reusable app naming prompt set
runs:
  using: prompt

inputs:
  appName:
    prompt: "App display name?"
    type: text
    required: true
  projectName:
    prompt: "Project name (snake_case)?"
    type: text
    pattern: "^[a-z][a-z0-9_]*$"

outputs:
  appName: "{{ inputs.appName }}"
  projectName: "{{ inputs.projectName }}"
# In workflow.yaml — local reference
- id: naming
  uses: ./actions/app-naming.yaml

# Or shared from GitHub — same loading path as any action
- id: naming
  uses: @github/my-org/xo-prompts/app-naming

# Access outputs in later steps
- run: echo "Creating {{ steps.naming.outputs.appName }}"
Prompt actions are the recommended way to share common input sets — e.g. "app naming", "framework selector", "auth options" — across multiple generators.

Published action (GitHub)

An action in its own GitHub repo, shareable across generators. The repo must have an action.yaml at the root (or subpath). For private repos, set XO_GITHUB_TOKEN in your environment.

# @github/my-org/xo-ensure-ts — action.yaml
name: ensure-typescript
description: Installs TypeScript if missing

inputs:
  strict:
    default: "true"

steps:
  - uses: xo/pkg-installed
    id: hasTs
    with:
      pkg: typescript
  - if: "steps.hasTs.outputs.installed == false"
    uses: xo/install-pkg
    with:
      pkg: typescript
      dev: true
# Any version reference works
- uses: @github/my-org/xo-ensure-ts
- uses: @github/my-org/xo-ensure-ts@v1.2.0
- uses: @github/my-org/xo-actions/ensure-ts@v1.0.0
You want to…Use
Reuse a group of xo/* steps within one generatorComposite (./actions/name.yaml)
Run custom logic, call APIs, or do anything YAML can't expressScript (./actions/name.js)
Collect a reusable set of inputs and expose as outputsPrompt action (runs.using: prompt)
Share an action across many generator reposPublished (@github/owner/repo)

Idempotency

Most built-in actions are safe to re-run — they check before writing and skip if the result is already in place.

ActionGuarantee
xo/copyOverwrites destination — caller should use if to guard
xo/templateOverwrites destination with rendered output
xo/appendChecks for duplicate content before appending
xo/injectNo-ops if marker content is already present
xo/replaceNo-ops if find string is not found
xo/jsonMerges; does not overwrite existing keys unless value differs
xo/envSkips keys that already exist in the file
xo/ast-importNo-ops if import already present
xo/install-pkgSkips if package already in dependencies
xo/run / run:Caller's responsibility — use if conditions to guard
xo/promptSide-effect free for info/warn/success; confirm is interactive
Detection actionsRead-only — always safe to re-run