Skip to content

Command Execution

When you run an Ahoy command, it:

  1. Checks for a --file (or -f) flag to use a specific config file
  2. Otherwise, searches upward from the current directory for .ahoy.yml
  3. Changes to the directory containing that file
  4. Executes your command relative to that directory

This means you can run ahoy from any subdirectory in your project and it always behaves consistently.

Arguments are passed to commands as bash positional parameters:

commands:
greet:
cmd: echo "Hello, $1!"
copy:
cmd: cp $1 $2
run-in-container:
cmd: docker-compose exec app "$@"
Terminal window
ahoy greet World # Hello, World!
ahoy copy src.txt dest.txt
ahoy run-in-container npm test

$@ passes all arguments through, which is handy for wrapper commands. Use $# to check the argument count, and $0 for the command name.

Everything after the command name reaches your command verbatim, flags included, so wrapping a tool that takes its own options needs nothing special:

commands:
phpunit:
cmd: docker compose exec app vendor/bin/phpunit "$@"
Terminal window
ahoy phpunit --filter=MyTest --stop-on-failure

Ahoy’s own flags - --verbose, --file and friends - are only recognised before the command name, so they never collide with your command’s options.

All three standard streams - stdin, stdout and stderr - are inherited from the process that invoked Ahoy. Ahoy does not read, buffer or copy them, so a command runs exactly as it would if you had typed it yourself:

commands:
shell:
usage: Open a shell in the app container
cmd: docker-compose exec app bash
mysql:
usage: Open a MySQL shell
cmd: mysql -u$DB_USER -p$DB_PASS $DB_NAME

When you run these from a terminal, the inherited streams are attached to a TTY, so they run fully interactively - you get a real prompt, readline support, and piping all work as expected. Invoked from a script or CI job, where the streams are pipes or files instead, the command sees exactly what it would see there without Ahoy.

Because the streams are the inherited descriptors rather than a copy of them, commands can ask about them directly - whether they are a TTY at all, and if so the terminal size - and get a true answer. When Ahoy is run from a terminal that is what full-screen and menu-driven tools need in order to draw:

commands:
pick-env:
usage: Choose an environment to deploy to
cmd: gum choose staging production
logs:
usage: Browse container logs interactively
cmd: docker compose logs --tail=1000 | fzf --tac

Tools built on Bubble Tea - gum, glow and similar - draw on stderr specifically, as do progress bars, spinners and anything that repaints in place.

Output is not buffered either - a long-running command’s output appears as it is produced, not in a burst when the command exits.

You can call other Ahoy commands from within a command:

commands:
ci:
usage: Run the full CI pipeline locally
cmd: |
ahoy install
ahoy lint
ahoy test
install:
cmd: npm install
lint:
cmd: npm run lint
test:
cmd: npm test

The default entrypoint is bash -c '{{cmd}}'. Override it at the file level to use a different interpreter:

ahoyapi: v2
entrypoint:
- node
- "-e"
- "{{cmd}}"
commands:
hello:
cmd: console.log("Hello from Node.js!")
ahoyapi: v2
entrypoint:
- python3
- "-c"
- "{{cmd}}"
commands:
hello:
cmd: print("Hello from Python!")

Entrypoint template variables:

  • {{cmd}} - replaced with the command body
  • {{name}} - replaced with the command name (also available as $0)

Ahoy passes exit codes through unchanged. If a command exits non-zero, Ahoy exits non-zero. This works naturally with shell pipelines:

Terminal window
ahoy test && ahoy deploy

Because the command body is bash, the usual operators control what runs next:

  • && - run the next command only if the previous one succeeded (exit 0)
  • || - run the next command only if the previous one failed (non-zero exit)
  • ; - always run the next command
commands:
only-run-1-2-and-4:
cmd: |
echo "1 - If this passes" &&
echo "2 - Then do this" ||
echo "3 - Or do this if 1 or 2 fails" ;
echo "4 - Do this no matter what"
  • Use --verbose to see what actually ran. It prints the resolved command before executing it, which is the fastest way to diagnose an unexpected result.
  • Commands run in a subshell. Environment variables are copied in, but nothing a command does can change your parent shell - it cannot change your current directory or set variables for your session.
  • Quotes can be tricky. When passing a command through to a subcommand, quotes can be lost along the way. Check --verbose first, then experiment with single versus double quotes. Prefer cmd: | for anything complex so YAML quoting stays out of the way.
  • Check your YAML if Ahoy complains. Indentation and structure errors surface as parse failures; ahoy config validate reports them along with unsupported fields.