defineCliVerb pattern
The vobase CLI is catalog-driven: the binary at packages/cli/bin/vobase.ts has zero static knowledge of verbs. On every command it asks the tenant's /api/cli/verbs endpoint which verbs exist, then dispatches to the verb's HTTP route. Modules contribute verbs by calling ctx.cli.register(defineCliVerb({...})) (or ctx.cli.registerAll([...])) during their init hook.
When to apply
- Any time you want a module operation to be reachable from the standalone CLI binary OR from the agent's bash sandbox (in-process transport).
- Adding scripted operations to a tenant deployment without modifying the CLI source.
Skip when the operation should be HTTP-only (a customer-facing API endpoint) or only invoked from within the same module's UI.
Anatomy of a verb
// modules/<module>/cli.ts
import { defineCliVerb } from '@vobase/core'
import { z } from 'zod'
import * as svc from './service/<thing>'
export const exampleListVerb = defineCliVerb({
name: 'example list', // multi-word ⇒ `vobase example list`
description: 'List example rows.',
input: z.object({
limit: z.number().int().positive().max(500).default(50),
}),
body: async ({ input, ctx }) => {
const rows = await svc.list(ctx.organizationId, { limit: input.limit })
return { ok: true as const, data: rows }
},
formatHint: 'table:cols=id,name,createdAt',
})
export const exampleVerbs = [exampleListVerb /* , exampleShowVerb, … */] as const
// modules/<module>/module.ts
import type { ModuleDef } from '~/runtime'
import { exampleVerbs } from './cli'
const example: ModuleDef = {
name: 'example',
jobs: [],
init(ctx) {
// … other singleton service installation …
ctx.cli.registerAll(exampleVerbs)
},
}
Naming
- Whitespace-separated tokens map to nested CLI groups:
'example list pending'↦vobase example list pending. The resolver does longest-prefix matching, soexample listandexample list pendingcan coexist. - Names must be unique across modules; collisions throw
VobaseCliCollisionErrorat boot.
Roles
rolesAllowed: ['admin', 'developer'] — empty/undefined ⇒ any authenticated principal. Enforced by the dispatcher before body runs.
Output rendering
formatHint drives the CLI's generic renderer:
'table:cols=id,name,createdAt'— column-aligned table for arrays of objects'json'— pretty-printed JSON'lines:field=path'— one line per array element, drawn frompathfield- omitted — generic-object pretty-print + array count summary
--json always overrides the hint and emits raw JSON.
Tenancy
Every verb body has ctx.organizationId from the resolved API-key principal. Always filter writes/reads by ctx.organizationId. For verbs that take an id and pass it to a singleton service, double-check the row's organizationId === ctx.organizationId before acting:
const row = await svc.get(input.id)
if (row.organizationId !== ctx.organizationId) {
return { ok: false as const, error: 'row not in this organization', errorCode: 'forbidden' }
}
Cross-transport parity
The same body runs in-process (the agent's bash sandbox) and over HTTP-RPC (the binary). Consequences:
- Verb bodies must be pure with respect to the transport — no
c.req.*, noprocess.stdout.write, no streaming. - Return data must be JSON-serialisable — no class instances, no functions, no
bigint. - Errors flow through the typed
{ ok: false, error, errorCode }shape, not exceptions. The dispatcher catches and reports thrown errors but the typed path is preferred.
The cross-transport parity test in packages/core/src/workspace/cli/parity.test.ts enforces both transports return the same payload for the same input.
Custom routes
route defaults to /api/cli/<name-with-spaces-as-slashes>. Override only when migrating a legacy endpoint into the catalog.
Troubleshooting
- Verb missing from
vobase --help? Runvobase --refresh(the catalog is cached at~/.vobase/<config>.cache.json). invalid_inputeven though the schema looks right? Check that the CLI is coercing--limit=10to a number — the resolver auto-coerces based on the JSON Schema; verbs that declarez.coerce.number()instead ofz.number()work too but the coercion is redundant.- Body throws
service not installed? The verb body ran before its module'sinithook fired. Make sure the module'srequires:list includes any module whose service the verb body calls.