Extending kitr

Package contract

How a package plugs into kitr add, the CLI and the build: questions, codemods, commands, generators.

Everything in kitr beyond the core is a package you add, and every package, official or not, plugs in the same way. A kitr package is an npm package with two additions: a static kitr block in package.json, read by the site, the registry and kitr add --list without executing anything, and a setup module exported as <package>/kitr, which the CLI loads from the installed package and runs on kitr add, kitr remove, kitr doctor and kitr make:*. Core knows no package by name: kitr add @acme/anything does exactly what kitr add jobs does.

The setup module

// src/kitr.ts, published as the "./kitr" export
import { definePackage } from '@kitr/core/package'

export default definePackage({
  name: 'jobs',
  requires: ['server'],

  // Asked by `kitr add jobs`. Every question is also a flag (`--worker` / `--no-worker`),
  // so CI and agents never see a prompt; `when` makes a question depend on earlier answers.
  questions: [
    { name: 'worker', type: 'confirm', message: 'Run the job worker inside the server process?', default: true },
  ],

  // Codemods. Every call is idempotent and queued: the CLI writes once, installs once, formats once.
  add(ctx) {
    ctx.provider.add('@kitr/jobs/provider')
    ctx.config('jobs', jobsConfig(ctx.answers.worker !== false))   // writes config/jobs.ts
    ctx.write('server/jobs/example.ts', exampleJob)                 // skipped when it exists
    if (ctx.answers.worker === false) ctx.scripts.add('worker', 'kitr jobs:work')
    ctx.agents.add('jobs', '- Background work is a job in server/jobs/*.ts ...')
  },
  remove(ctx) {
    ctx.provider.remove('@kitr/jobs/provider')
    ctx.scripts.remove('worker', 'kitr jobs:work')
    ctx.agents.remove('jobs')
  },

  commands: () => import('./commands'),       // `kitr jobs:list`, `kitr jobs:work`
  generators: { job: { description: 'A job with a schema, retries and a schedule', target, stub } },
  doctor: (project) => [],                    // extra `kitr doctor` findings
  vite: { serverOnlyConfig: true },           // config/jobs.ts never reaches a browser bundle
})

The CLI installs the package itself and lists it in kitrrc.ts packages; add only does what is specific to the package. The service a package exposes is a container binding, declared the way every kitr package already does it: augment ContainerBindings in @kitr/core and bind it in the provider's register.

Field Type What the CLI does with it
name string The short name: kitr add jobs, config/jobs.ts, the jobs: command prefix
kind 'package' (default), 'platform', 'kit' A platform is added as kitr add web site; a kit is used by kitr new --kit
requires string[] Added first, in order, each with its own questions. Short names or full package names
client boolean Refused in a project with no React
dev boolean Installed as a devDependency (tooling the app never imports at run time, such as testing)
questions Question[] or (project) => Question[] Asked on add; --yes, CI or no TTY takes the defaults
add(ctx) / remove(ctx) codemods Run with the answers; --dry-run prints the plan
commands () => import(...) A module of command objects (citty's shape), mounted as kitr <name>. The <name>: prefix is the convention; a core name or a name another package took is an error
generators Record<string, Generator> Mounted as kitr make:<kind>; the project's stubs/<kind>.ts overrides the stub
doctor(project) Finding[] Appended to kitr doctor. Pure: read, never write
types () => import(...) A module exporting a defineTypes() generator that kitr types runs into .kitr/
wraps Record<string, string> Upstream libraries this package re-exports, by the kitr entry to import instead; kitr doctor flags direct imports
vite { serverOnlyConfig, optimizeDeps, plugin, provider } Read by @kitr/vite when it builds a web platform
platform { factory, export, from, defaultName } For kind: 'platform': the definePlatform factory kitrrc.ts calls, how it imports it, and the name kitr add <type> suggests
kit { platforms, packages, components, files } For kind: 'kit': what kitr new --kit adds and writes
routes string The module exporting this package's route-table extension
eject Record<string, string> Import rewrites kitr eject all prints

Questions

Type Flag Answer
select --driver redis one of options
multiselect --targets ios,android a list of options
confirm --worker / --no-worker boolean
text --port 4000 string

when(answers, project) skips a question (and its answer) when it returns false; validate(value) rejects a prompt or a flag with a message. kitr add <pkg> --help prints the questions as flags, read from the module, so the help is never stale.

What ctx can do

ctx owns the wiring files and nothing else. Every call is idempotent.

ctx.deps.add('ioredis', { name: 'vite', dev: true }), ctx.deps.remove(...) package.json dependencies, installed once at the end
ctx.packageJson.set('msw', { workerDirectory: ['public'] }), ctx.packageJson.remove(...) A top-level package.json field; remove only when it still holds that value
ctx.provider.add('@kitr/jobs/provider'), .remove(...) the providers list in kitrrc.ts
ctx.config('jobs', source) config/jobs.ts, skipped when it exists
ctx.write(path, content, { overwrite }), ctx.unlink(path) any new file; delete a file kitr wrote
ctx.edit(path, (mod, src) => ...) an existing file through magicast, or return replacement text
ctx.scripts.add('worker', 'kitr jobs:work'), .remove(name, command?) package.json scripts
ctx.env.add({ REDIS_URL: '...' }), ctx.env.schema('server', 'REDIS_URL', "e.url()") .env.example and app/env.ts
ctx.agents.add('jobs', body), .remove('jobs') a ## kitr: jobs section in AGENTS.md
ctx.tsconfig.addTypes(...), .addLib(...), .addInclude(...), .addPaths({...}), .set(option, value) tsconfig.json
ctx.vite.addPlugin('tailwind', '@tailwindcss/vite') plugins in vite.config.ts, when the file exists
ctx.routes.extend('@kitr/api/routes'), .remove(...) the extensions list of the project's defineRoutes([...]) call, in directories.routeTable (default app/routes.ts); created when missing
ctx.server.attach({ marker, import, line, first }) one line (and its import) after createServer() in the server platform's entry, once; first puts it straight after createServer() for middleware that must see every request
ctx.replaceImport(file, from, to) swap one side-effect import for another (kitr/theme/styles.css for the Tailwind preset)
ctx.types.enable() the #kitr/* path and .kitr include in tsconfig.json, .kitr in .gitignore, kitr types before tsc in the typecheck script
ctx.app.option('operations', '#kitr/actions'), .removeOption(...) pass a named value to createApp({ ... }) in app/app.ts, imported from the given module

The project facts it may read: ctx.root, ctx.pm, ctx.pkg, ctx.rc (packages, providers, directories, platforms), ctx.isServerOnly, ctx.has('react') (a dependency, present or queued), ctx.hasPackage('server') (in kitrrc.ts, or added earlier in this run), ctx.exists(path), ctx.read(path), and ctx.answers.

The static block

{
  "name": "@kitr/jobs",
  "exports": { ".": "./dist/index.js", "./provider": "./dist/provider.js", "./kitr": "./dist/kitr.js" },
  "kitr": {
    "name": "jobs",
    "title": "Jobs",
    "summary": "Run work in the background or on a schedule, with retries when it fails.",
    "category": "backend",
    "runsOn": "server",
    "requires": ["server"],
    "docs": "docs",
    "setup": "./kitr"
  }
}

setup says the package ships a setup module; the CLI looks for the ./kitr export either way. kitr package:check validates the block, and the generated manifest table in the CLI (pnpm manifests) is what the site lists.

How kitr add resolves a name

jobs means @kitr/jobs; anything with a scope or a slash is taken as written. When the package is not resolvable from the project, the CLI installs it first (pnpm add @kitr/jobs) and then loads its kitr module; with --no-install that is an error naming the flag. Inside this monorepo the module resolves from the CLI's own tree, which is how kitr new --no-install regenerates the examples. A package with no setup module falls back to the recipe of the same name inside the CLI, until every official package has moved its setup into its own code.

Commands

A commands module exports plain command objects in citty's shape (meta, args, run); the kitr CLI mounts them next to its own. The <name>: prefix (jobs:work) is the convention, so a project with several packages never sees two meanings of one word; a bare name is allowed, and a collision with a core command or another package's command is an error at load time. Commands are loaded only when kitr --help runs or the word typed is not a core command, so an unrelated kitr dev pays nothing for them.

Where the official setups are

Package Setup
jobs, web packages/<name>/src/kitr.ts (the contract above)
every other package packages/cli/src/recipes*.ts (legacy recipes, moving into their packages next)