Contributing a Calavera integration

Project Calavera is a CLI tool that scaffolds linters, formatters, AI tooling, and common project infrastructure for web projects. Calavera ❤️ Vite. This post walks through the full contribution path for a new integration, using the Varlock PR as a concrete example.

Project Calavera is an open-source CLI tool that scaffolds linters, formatters, TypeScript configs, AI tooling such as agent skills, hooks, and subagents, and other common project infrastructure for web projects. It works standalone for vanilla JavaScript, TypeScript, and library projects, and it works as a complement to framework scaffolding tools like Vite+ and vp create, giving any project a consistent, repeatable setup through a single recipe file.

A new integration starts with the Calavera catalog. Metadata should describe the integration first; integration-specific behavior should only follow when the tool needs scripts, files, diagnostics, or follow-up guidance that metadata cannot express. Theo Ephraim’s Varlock contribution in PR #127 is a good walkthrough because it touches the entire contribution path: catalog metadata, dependencies, scripts, apply, doctor, the web composer, and tests.

Thank you to Theo Ephraim for contributing the Varlock integration example.

Start with the catalog

For Varlock, the catalog entry introduced a small optional integration:

{
  id: "varlock",
  label: "Varlock",
  group: "Environment variables",
  platform: "varlock",
  status: "optional",
  dependencies: ["varlock"],
}

That single entry establishes the contract Calavera needs. The id is the stable recipe value project developers add to calavera.config.json. The label is the human-readable name shown in prompts and the composer. The group determines where the integration belongs in the catalog. The platform is the tool family that custom generation code can key off. The status indicates whether the integration is recommended, optional, experimental, or framework-specific. Finally, dependencies lists the development packages Calavera installs when the recipe selects the integration.

Start in src/catalog.js, then decide whether metadata is enough or whether the integration needs a small behavior hook.

Metadata is enough for integrations that only add packages or plugin settings to an existing generated config. A Stylelint plugin, for example, can often declare its package dependency, parent stylelint integration, plugin name, and rules in the catalog. An Oxlint plugin can often declare just the plugin name and included parent integration. In those cases, the existing config builders consume the catalog entry directly.

Custom behavior is needed when the integration crosses out of pure metadata. Varlock is a good example: it needed a package script, a starter .env.schema, a small .gitignore merge, a doctor warning when the schema is missing, and clear dry-run output. React Doctor is another familiar case: it needs package scripts plus a generated react-doctor.config.json.

The pattern in src/index.js is to key custom behavior from the resolved integration list. A simplified version of the Varlock branch looks like this:

if (resolvedIntegrations.includes("varlock")) {
  scripts["env:load"] = "varlock load";
  qualityParts.push(`${pmRun} env:load`);

  if (!existsSync(join(targetDir, ".env.schema"))) {
    writeFileSync(join(targetDir, ".env.schema"), varlockStarterSchema);
    changes.push({ type: "write", path: ".env.schema" });
  }

  mergeGitignore(targetDir, varlockIgnoreLines);
  changes.push({ type: "update", path: ".gitignore" });
}

When an integration needs custom behavior, keep it narrow and keyed from the resolved integration ID or platform in that same file. Add the script, file plan, diagnostic, pointer, or dry-run output in the same flow that already handles similar integrations, then protect that behavior with focused tests.

The matching recipe entry a project developer would write is intentionally small:

{
  "$schema": "https://calavera.schalkneethling.com/calavera.config.schema.json",
  "profile": "modern",
  "packageManager": "pnpm",
  "integrations": ["editorconfig", "typescript", "oxlint", "varlock"],
  "scripts": {
    "lint": true,
    "format:check": true,
    "typecheck": true,
    "quality": true
  }
}

That recipe should be enough for the CLI and web composer to resolve Varlock from the shared catalog. The composer now derives integration options from the shared recipe core instead of carrying a browser-only catalog copy.

Add dependencies through metadata

Varlock did not need installer code. The catalog entry declared dependencies: ["varlock"], and the existing apply flow collected dependency metadata from the resolved integrations.

For ordinary npm development dependencies, the catalog is the right path. If a package should install whenever the integration is selected, put it in dependencies.

A separate installer should be rare and should start with a design discussion in a GitHub issue, not a one-off branch in apply. Reach for a new installer path only when the integration cannot be represented as package-manager development dependencies. Examples might include a tool that requires a non-npm runtime, a local binary outside node_modules, a service bootstrap step, or authentication with an external platform. In those cases, the issue should explain why package metadata is not enough, how dry-run and --json output will describe the work, and how Calavera will avoid hidden machine-level state. Only open a pull request once the approach has been discussed and agreed on.

Add package scripts

An integration contributor does not manually edit a target project’s package.json. Instead, they update Calavera’s script-building logic so future projects receive ordinary package scripts when they run apply.

The Varlock contribution added an env:load script:

{
  "env:load": "varlock load"
}

It also wired that script into the aggregate command used by the recipe. When Theo opened PR #127, that aggregate script was named check. Calavera has since renamed it to quality, so a new integration should follow the current quality model in src/index.js rather than copying the old check name from the PR.

Use the tool’s real validation command as the script body. Varlock uses varlock load because it prints the resolved redacted environment and exits non-zero on schema violations.

For a pnpm project that already has lint, format, and type-check scripts, the Varlock-specific result should look like this:

{
  "scripts": {
    "env:load": "varlock load",
    "quality": "pnpm lint && pnpm format:check && pnpm typecheck && pnpm env:load"
  }
}

What is important to understand here is that Varlock gets its own readable script, and the aggregate script invokes it through the selected package manager.

Decide what Calavera owns

Before adding file behavior, decide whether each destination is Calavera-managed or project-owned.

Managed files are generated from the recipe and recorded in .calavera/state.json with hashes. Calavera can later inspect, update, and clean them because it knows exactly what it wrote. Examples include generated lint configs, tsconfig.json, React Doctor config, and helper scripts.

Project-owned files are different. Calavera may create a starter file or merge a small block into an existing file, but it should not later assume ownership. The Varlock contribution treated two files as project-owned:

Those files were intentionally not added to .calavera/state.json. This is important because if a project developer later removes varlock from the recipe and runs clean, Calavera should not delete environment schema data or a project .gitignore that may now contain unrelated local intent.

As a rule of thumb, generate and track Calavera-owned files when Calavera can recreate the whole file safely from the recipe. Scaffold project-owned files when the generated content is only a starter. Merge project-owned files when Calavera needs to add a small conventional block. Never overwrite or clean project-owned files just because an integration was selected once.

A starter .env.schema is a good scaffold because it gives project developers a valid first file without pretending Calavera knows their production secrets:

# @defaultSensitive=false
# @defaultRequired=infer

# Application environment
# @type=enum(development, staging, production)
# @required
APP_ENV=development

The .gitignore merge is also project-owned. The integration can append the missing Varlock lines under a small heading:

# Varlock
!.env.schema
!.env.*
.env.local

After apply, scaffolded and merged files should not appear in managed state. A state file for a recipe that includes Varlock should track files Calavera fully generates and owns, but not .env.schema or .gitignore:

{
  "version": 1,
  "profile": "modern",
  "integrations": ["editorconfig", "typescript", "oxlint", "varlock"],
  "files": [".editorconfig", ".calavera/run-if-files.mjs", "oxlint.json", "tsconfig.json"],
  "managedFiles": [
    {
      "path": ".editorconfig",
      "hash": "..."
    },
    {
      "path": ".calavera/run-if-files.mjs",
      "hash": "..."
    },
    {
      "path": "oxlint.json",
      "hash": "..."
    },
    {
      "path": "tsconfig.json",
      "hash": "..."
    }
  ]
}

Implement apply behavior

The apply command should make the selected recipe real while preserving local edits. The previous sections covered what Varlock’s apply behavior adds — dependencies, scripts, scaffolded files, and merged .gitignore lines. The detail worth calling out here is idempotency. A second apply should not duplicate .gitignore lines, and it should not replace a developer’s edited .env.schema. For scaffolded files, check whether the destination exists before writing. For merged files, compare normalized lines and append only the missing ones.

Calavera already handles safety checks for managed files through its existing state and hash logic. An integration contributor only needs custom checks for project-owned files that Calavera intentionally does not track. Varlock needed that custom branch because .env.schema and .gitignore are assets project developers are expected to edit.

Dry-run output should describe the intent accurately. The current apply --dry-run --json payload uses the shared changes structure from src/index.js, with fields for type, path, and optional data such as scripts and removedDefaultTestScript. It does not currently include ownership markers such as managed or scaffold (#190).

A useful dry-run result for a fresh project would include changes like:

{
  "command": "apply",
  "dryRun": true,
  "packageManager": "pnpm",
  "dependencies": ["typescript", "@types/node", "oxlint", "varlock"],
  "integrations": ["editorconfig", "typescript", "oxlint", "varlock"],
  "changes": [
    {
      "type": "update",
      "path": "package.json",
      "scripts": ["lint", "format:check", "typecheck", "env:load", "quality"]
    },
    {
      "type": "write",
      "path": ".editorconfig"
    },
    {
      "type": "write",
      "path": ".calavera/run-if-files.mjs"
    },
    {
      "type": "write",
      "path": "oxlint.json"
    },
    {
      "type": "write",
      "path": "tsconfig.json"
    },
    {
      "type": "write",
      "path": ".env.schema"
    },
    {
      "type": "update",
      "path": ".gitignore"
    }
  ],
  "pointers": []
}

The corresponding human output should follow the current dry-run printer:

Would update package.json
Would add scripts: lint, format:check, typecheck, env:load, quality
Would write .editorconfig
Would write .calavera/run-if-files.mjs
Would write oxlint.json
Would write tsconfig.json
Would write .env.schema
Would update .gitignore

Because the change list does not encode ownership, the surrounding documentation, state-file assertions, and tests make the managed versus project-owned distinction clear.

Add doctor coverage

The doctor command should report missing project assets that Calavera expects for the selected recipe.

For Varlock, the useful diagnostic is simple: if the recipe includes varlock but .env.schema is missing, doctor should warn. That warning is helpful in human output and in doctor --json so CI and agent workflows can make the same decision a person would make after reading the terminal.

Prefer diagnostics that explain missing prerequisites, stale generated files, or unsafe drift. Avoid diagnostics that try to reimplement the integration tool’s own validator. Varlock itself should validate the schema and environment values; Calavera only needs to notice when the project is missing the schema file that the recipe expects.

If a recipe selects Varlock but .env.schema has been deleted, doctor --json should expose a machine-readable warning with the same file path humans need to inspect:

{
  "command": "doctor",
  "ok": false,
  "issues": [
    {
      "level": "warning",
      "path": ".env.schema",
      "message": "Missing Varlock schema file. Run create-project-calavera apply to scaffold .env.schema."
    }
  ]
}

The exact message can evolve with the CLI, but the contract should stay clear: the recipe selected Varlock, the expected schema is absent, and apply can restore the starter file.

Expose the integration in the composer

Because the web composer derives from the shared recipe core, a new catalog entry is usually enough to surface it. The one detail to check is profile scoping: make sure src/recipe.js exposes the integration for the intended profile or profiles. For Varlock, that meant making it available in the Environment variables group so a project developer could include it in a generated calavera.config.json without hand-editing the recipe.

If the integration changes recipe structure, profile scoping, schema behavior, or the shared catalog response exposed to WebMCP, update the matching test or drift check. Calavera’s public recipe schema lives at web/public/calavera.config.schema.json, and repository drift checks live in scripts/check-config-schema.test.mjs.

Test the contribution

A good integration test plan should cover behavior, idempotency, diagnostics, and machine-readable output.

The Varlock contribution’s test plan covers the key assertions: applying a recipe with varlock adds the dependency and scripts, a fresh project gets a starter .env.schema, an existing .env.schema is preserved on re-apply, .gitignore receives the Varlock lines once without duplicates, doctor --json warns when .env.schema is missing, apply --dry-run reports scaffold and update changes clearly, .calavera/state.json does not list project-owned scaffolded files, and lint or repository checks still pass.

Keep the test tool matched to the risk. For a normal catalog integration, that usually means ordinary tests around the behavior the integration adds: resolved dependencies, package scripts, file ownership, dry-run output, and doctor diagnostics. Schema validation only matters when the integration changes calavera.config.json structure or the published recipe schema.

In practice, that translates to focused fixture-style tests. The exact helper names depend on the test harness, but the assertions should stay this concrete:

test("varlock scaffolds project-owned files without tracking them as managed", async () => {
  const project = await createFixtureProject({
    "calavera.config.json": JSON.stringify({
      profile: "modern",
      packageManager: "pnpm",
      integrations: ["editorconfig", "varlock"],
      scripts: { quality: true },
    }),
  });

  await runCalavera(project, ["apply", "--no-install", "--yes"]);

  assert.equal(await project.read(".env.schema"), expectedVarlockSchema);
  assert.match(await project.read(".gitignore"), /# Varlock/);
  assert.match(await project.read("package.json"), /"env:load": "varlock load"/);

  const state = JSON.parse(await project.read(".calavera/state.json"));
  assert.equal(state.files.includes(".env.schema"), false);
  assert.equal(state.files.includes(".gitignore"), false);
});

test("varlock apply is idempotent around project-owned files", async () => {
  const project = await createFixtureProject({
    "calavera.config.json": JSON.stringify({
      profile: "modern",
      packageManager: "pnpm",
      integrations: ["varlock"],
    }),
    ".env.schema": "APP_ENV=production\nCUSTOM_TOKEN=\n",
    ".gitignore": "# Varlock\n!.env.schema\n!.env.*\n.env.local\n",
  });

  await runCalavera(project, ["apply", "--no-install", "--yes"]);
  await runCalavera(project, ["apply", "--no-install", "--yes"]);

  assert.equal(await project.read(".env.schema"), "APP_ENV=production\nCUSTOM_TOKEN=\n");
  assert.equal((await project.read(".gitignore")).match(/\.env\.local/g).length, 1);
});

Those examples are illustrative, not a requirement to introduce those exact helpers. The important part is that the test names and assertions protect the integration contract a future maintainer might accidentally break.

Use post-install pointers for concise follow-up

Some integrations finish apply successfully but still need the project developer to do one small thing next. That is where post-install pointers belong.

Calavera already returns post-install pointers from apply in both human-readable output and JSON output. The existing AI artifact flow uses this mechanism to surface concise follow-up guidance, and future integrations can use the same pattern when a normal change list is not enough.

Use a pointer when the integration installed correctly but the project developer must review or connect a generated fragment before another tool consumes it, when a scaffolded starter file needs a short next action such as filling in project-specific values, or when the guidance is useful to both humans and automation reading apply --json.

Do not use a pointer for verbose documentation, warnings that belong in doctor, or details already clear from the change list. Keep each pointer short, specific, and actionable.

For a Varlock-style integration, a useful pointer might be:

Review .env.schema and add required project environment variables before enabling env:load in CI.

Pointers should be available in JSON as stable strings so agents can display, summarize, or route them, and they should print in human output after apply so project developers see the same follow-up without needing --json.

If Varlock chose to emit a pointer after scaffolding the starter schema, the apply --json output should remain simple:

{
  "command": "apply",
  "dryRun": false,
  "pointers": [
    "Review .env.schema and add required project environment variables before enabling env:load in CI."
  ]
}

Start from the current pointer behavior in src/index.js and the documented contract in docs/ai-module-contract.md.

Contributor starting points

When adding the next integration, begin with these files:

The shortest successful path is usually: catalog entry first, script behavior second, file ownership decision third, apply and doctor behavior fourth, composer exposure fifth, and tests around the contract you just created.

Further reading