Skip to content

Quarry CLI

Quarry is Stratal’s built-in CLI framework, inspired by Laravel Artisan. It lets you define custom commands that run against your full application, with access to dependency injection, services, and Cloudflare bindings. Commands extend the Command base class from stratal/quarry.

Quarry loads your application through a dedicated entry file at src/quarry.ts, kept separate from your Worker entry (src/index.ts). Create it once and export a QuarryRunner:

src/quarry.ts
import { QuarryRunner } from 'stratal/quarry/runner'
import { AppModule } from './app.module'
export default QuarryRunner.run({
imports: [AppModule],
})
Terminal window
npx quarry <command> [arguments] [options]

By default, Quarry uses ./src/quarry.ts as the application entry point. To use a different entry file, pass it as the first argument:

Terminal window
npx quarry ./src/other-entry.ts <command> [arguments] [options]

Use --env <name> (or -e <name>) to target a wrangler.jsonc environment such as env.staging or env.production. Quarry loads that environment’s bindings and vars before running the command. The flag is position-tolerant, so it works before or after the entry path:

Terminal window
npx quarry --env staging route:list
npx quarry -e production db:seed
npx quarry ./custom/entry.ts --env staging route:list

Omit the flag to use the top-level (default) Wrangler config.

Extend Command, define a static command signature and description, then implement handle():

import { Command } from 'stratal/quarry'
import { inject } from 'stratal/di'
import { TaskService } from '../services/task.service'
export class AddTaskCommand extends Command {
static command = 'task:add {title : The task title} {--p|priority= : Task priority (low, normal, high)}'
static description = 'Add a new task'
constructor(
@inject(TaskService) private readonly tasks: TaskService,
) {
super()
}
async handle(): Promise<number | undefined> {
const title = this.string('title')
const priority = this.string('priority') || 'normal'
const task = await this.tasks.add(title, priority)
this.success(`Task #${task.id} created: "${task.title}" [${task.priority}]`)
}
}

The handle() method contains your command’s logic. Return a number to set the exit code, or undefined (or nothing) for exit code 0.

Command signatures use a Laravel-style syntax to define the command name, arguments, and options in a single string.

StyleSignatureInvocation
Flat'greet'npx quarry greet
Namespaced'task:add'npx quarry task:add
Subcommand'task add'npx quarry task add
SyntaxDescription
{name}Required argument
{name?}Optional argument
{name=default}Argument with default value
{name*}Array/variadic argument
{name : description}Argument with description
SyntaxDescription
{--flag}Boolean flag
{--name=}Option that accepts a value
{--name=default}Option with default value
{--name=*}Array option (multiple values)
{--A|name}Option with single-char alias
{--name= : description}Option with description

Use these methods inside handle() to retrieve parsed input values:

MethodReturn typeDescription
this.string(name)stringGet a string input (empty string if missing)
this.boolean(name)booleanGet a boolean input (false if missing)
this.number(name)numberGet a numeric input (coerces strings, 0 if missing)
this.array(name)string[]Get an array input (empty array if missing)
this.input<T>(name)TGet an input with a generic type

Use these methods inside handle() to write output:

MethodDescription
this.info(message)Write an informational message
this.success(message)Write a success message
this.warn(message)Write a warning message (prefixed with Warning:)
this.error(message)Write an error message to the error stream
this.line(message?)Write a plain line (or empty line if no argument)
this.table(headers, rows)Write a formatted table
this.fail(message, exitCode?)Write an error and set exit code (default 1)
import { Command } from 'stratal/quarry'
import { inject } from 'stratal/di'
import { TaskService } from '../services/task.service'
export class ListTasksCommand extends Command {
static command = 'task:list {--s|status= : Filter by status (pending, done)}'
static description = 'List all tasks'
constructor(
@inject(TaskService) private readonly tasks: TaskService,
) {
super()
}
async handle(): Promise<number | undefined> {
const status = this.string('status')
let tasks = await this.tasks.list()
if (status) {
tasks = tasks.filter((t) => t.status === status)
}
if (tasks.length === 0) {
this.info('No tasks found.')
return
}
this.table(
['ID', 'Title', 'Priority', 'Status', 'Tags'],
tasks.map((t) => [
String(t.id),
t.title,
t.priority,
t.status,
t.tags.join(', ') || '-',
]),
)
}
}

Add command classes to a module’s providers array. Quarry automatically discovers all Command subclasses in the module tree:

import { Module } from 'stratal/module'
import { AddTaskCommand } from './commands/add-task.command'
import { ListTasksCommand } from './commands/list-tasks.command'
import { TaskService } from './services/task.service'
@Module({
providers: [
TaskService,
AddTaskCommand,
ListTasksCommand,
],
})
export class AppModule {}

Call another command from within a command using this.call():

async handle(): Promise<number | undefined> {
// Call another command with arguments and options
const result = await this.call('task:list', { status: 'pending' })
if (result.exitCode !== 0) {
this.error('Failed to list tasks')
}
}

Define alternative names for a command using the static aliases property:

export class ListTasksCommand extends Command {
static command = 'task:list'
static description = 'List all tasks'
static aliases = ['tasks', 'task:ls']
async handle(): Promise<number | undefined> {
// ...
}
}

Stratal ships with built-in Quarry commands. Each command is documented in detail on its respective page.

CommandDescriptionDocs
helpShow help or list all commands-
route:listList all registered routesControllers and Routing
route:typesGenerate TypeScript types for named routesControllers and Routing
event:listList all registered event listenersEvents
schedule:listList all registered cron jobsCron Jobs
CommandDescriptionDocs
queue:listList all registered queue consumersQueues
queue:failedList failed queue jobsQueues
queue:retryRetry failed queue jobsQueues
queue:purgeDelete failed queue jobs without retryingQueues
CommandDescriptionDocs
i18n:checkAudit locales for missing or extra keysI18n
i18n:statsShow translation coverage per localeI18n
i18n:listList message keys with per-locale coverageI18n
i18n:searchSearch message keys or valuesI18n
i18n:namespacesList namespaces with key countsI18n
i18n:duplicatesFind keys sharing identical valuesI18n
CommandDescriptionDocs
apiCall an API route directlyControllers and Routing
mcp:serveStart MCP stdio server exposing routes as toolsAI Integration
mcp:toolsList API routes exposed as MCP toolsAI Integration
CommandDescriptionDocs
db:seedRun database seedersSeeders
db:seed:listList available database seedersSeeders
CommandDescriptionDocs
db:generateGenerate ZenStack ORM clientDatabase
db:pullIntrospect database and generate schemaDatabase
db:pushPush schema changes to databaseDatabase
migrate:devCreate and apply a migrationDatabase
migrate:deployDeploy pending migrationsDatabase
migrate:resetReset databaseDatabase
migrate:statusCheck migration statusDatabase

Quarry includes commands to inspect queue consumers and work with the failed-job store.

queue:list prints every registered consumer alongside the message types it handles:

Terminal window
npx quarry queue:list

queue:failed lists jobs that exhausted their retries and landed in the failed-job store. It accepts two options:

OptionDescription
--queue=<name>Filter results to a single queue
--limit=<n>Maximum number of jobs to return (default 50)
Terminal window
npx quarry queue:failed
npx quarry queue:failed --queue=NOTIFICATIONS_QUEUE
npx quarry queue:failed --limit=100

The output table includes each job’s ID, queue, message type, consumer, attempt count, and the time it failed. The job ID is what you pass to queue:retry and queue:purge.

queue:retry re-sends failed jobs back onto their original queue. Pass a single message ID, or use --all to retry every failed job. You must provide one or the other:

Argument or optionDescription
idOptional message ID to retry
--allRetry all failed jobs
--queue=<name>When used with --all, only retry jobs from this queue
Terminal window
npx quarry queue:retry 0c1d2e3f-... # retry one job by ID
npx quarry queue:retry --all # retry every failed job
npx quarry queue:retry --all --queue=EMAIL_QUEUE

queue:purge deletes failed jobs from the store without re-sending them. It mirrors the arguments of queue:retry:

Argument or optionDescription
idOptional message ID to purge
--allPurge all failed jobs
--queue=<name>When used with --all, only purge jobs from this queue
Terminal window
npx quarry queue:purge 0c1d2e3f-... # delete one job by ID
npx quarry queue:purge --all # delete every failed job
npx quarry queue:purge --all --queue=EMAIL_QUEUE

schedule:list prints every registered cron job grouped by its schedule expression, so you can confirm your jobs match the triggers declared in wrangler.jsonc:

Terminal window
npx quarry schedule:list

The output is a table of schedule expression and job class. If no cron jobs are registered, it reports No cron jobs found.

mcp:serve starts a Model Context Protocol server over stdio that exposes your API routes as tools for AI agents. Each OpenAPI route becomes a tool, and the full OpenAPI spec is registered as an MCP resource.

Terminal window
npx quarry mcp:serve

By default, tool calls are dispatched in-process through your app. The following options tune what is exposed and where requests go:

OptionDescription
--url=<base-url>Dispatch requests to an external URL instead of the in-process app
--header=<Key:Value>Add a header to dispatched requests (repeatable)
--tag=<tag>Only expose routes carrying these OpenAPI tags (repeatable)
--path=<prefix>Only expose routes matching this path prefix
Terminal window
# Expose only routes tagged "Notes"
npx quarry mcp:serve --tag=Notes
# Expose routes under /api/v1 and dispatch to a running server
npx quarry mcp:serve --path=/api/v1 --url=http://localhost:8787

To preview which routes would be exposed without starting the server, use mcp:tools, which accepts the same --tag and --path filters:

Terminal window
npx quarry mcp:tools
npx quarry mcp:tools --tag=Notes --path=/api/v1

See AI Integration for the full MCP workflow.

Quarry ships a set of i18n:* commands for inspecting translation coverage. The base locale is always en.

Terminal window
# Audit non-en locales for missing or extra keys (exit code 1 on issues, CI-friendly)
npx quarry i18n:check
npx quarry i18n:check --locale=fr --prefix=common
# Coverage dashboard per locale
npx quarry i18n:stats
# List keys with per-locale coverage; --values shows translated strings
npx quarry i18n:list --locale=fr --values
# Search keys or values by substring; --keys-only skips value matching
npx quarry i18n:search email --keys-only
# Show namespaces with key counts; --depth drills into sub-namespaces
npx quarry i18n:namespaces --depth=2
# Find keys that share identical translation values
npx quarry i18n:duplicates

See I18n for the translation system these commands operate on.

When you run npx quarry, the CLI:

  1. Uses Wrangler’s getPlatformProxy() to obtain your Cloudflare bindings (D1, KV, R2, etc.) locally
  2. Imports your application entry file to bootstrap the full module tree
  3. Discovers all Command subclasses registered as providers
  4. Parses command signatures and delegates to the matched command’s handle() method

This means your commands have full access to dependency injection, services, and Cloudflare bindings, just like your HTTP handlers.

Use TestingModule.quarry() to test commands with a fluent API:

import { describe, it } from 'vitest'
import { Test } from '@stratal/testing'
import { AppModule } from '../src/app.module'
describe('AddTaskCommand', () => {
it('creates a task', async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile()
const result = await module
.quarry('task:add')
.withInput({ title: 'Buy milk', priority: 'high' })
.run()
result.assertSuccessful()
result.assertOutputContains('Task #')
})
it('fails without a title', async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile()
const result = await module
.quarry('task:add')
.withInput({})
.run()
result.assertFailed()
})
})

The TestCommandResult object returned by .run() provides these assertion methods:

MethodDescription
assertSuccessful()Assert exit code is 0 and no errors
assertFailed(exitCode?)Assert non-zero exit code (optionally a specific code)
assertExitCode(code)Assert a specific exit code
assertOutputContains(text)Assert output contains the given text
assertOutputMissing(text)Assert output does not contain the given text
assertErrorContains(text)Assert error output contains the given text
assertErrorMissing(text)Assert error output does not contain the given text

You can also access result.exitCode, result.output, and result.errors directly for custom assertions.