Project-local plugins
The fastest path to extend opensip: drop a .mjs file under <project>/opensip-cli/fit/{checks,recipes}/ or <project>/opensip-cli/sim/{scenarios,recipes}/. The scan is recursive, so category folders like opensip-cli/fit/checks/docs/no-fixme.mjs are valid; namespaces include the relative path. The loader picks it up on the next run. No publishing, no install, no config entry.
This page covers all three project-local shapes: a check, a recipe, and a sim scenario. Each is ~30 lines.
A project-local check
// <project>/opensip-cli/fit/checks/no-fixme.mjs
import { defineCheck } from '@opensip-cli/fitness';
export default defineCheck({
id: '0a0a0a0a-0a0a-4a0a-8a0a-0a0a0a0a0a0a',
slug: 'no-fixme-comments',
description: 'No FIXME comments left in source',
tags: ['quality', 'documentation'],
scope: { languages: ['typescript'], concerns: [] },
contentFilter: 'raw', // we WANT to see the comment text
analyze(content, filePath) {
const violations = [];
content.split('\n').forEach((line, idx) => {
if (/\bFIXME\b/.test(line)) {
violations.push({
line: idx + 1,
message: `FIXME comment found: ${line.trim()}`,
severity: 'warning',
});
}
});
return violations;
},
});
opensip fit list shows it. opensip fit runs it against every TypeScript file in matched targets.
The id is a UUID v4. Generate one with node -e "console.log(crypto.randomUUID())". It must be stable across renames — no central registry, but the framework uses the id to key baselines, so changing it breaks gates.
The five fields you'll touch most:
| Field | When to set |
|---|---|
| slug | Always. Kebab-case, human-readable. |
| description | Always. One-line summary shown in --list. |
| tags | Always. At least one tag — recipes select by tag. |
| scope | Almost always. Tells the framework what kind of code this check is for. |
| contentFilter | Set to 'strip-strings-and-comments' for regex-shaped checks; default 'raw' is for text scanners. |
For walking the TypeScript AST instead of regex, see Ban an API pattern for the AST shape, and @opensip-cli/lang-typescript for the helper exports.
A project-local recipe
// <project>/opensip-cli/fit/recipes/quick-smoke.mjs
import { defineRecipe } from '@opensip-cli/fitness';
// Recipes load only from a `recipes` array export — not a default export.
export const recipes = [defineRecipe({
name: 'quick-smoke',
displayName: 'Quick smoke',
description: 'Fast PR feedback — universal checks only',
checks: { type: 'tags', include: ['universal'] },
execution: { mode: 'parallel', timeout: 10_000, stopOnFirstFailure: false },
reporting: { format: 'table' },
})];
opensip fit recipes lists it. opensip fit --recipe quick-smoke runs it.
The four selectors: { type: 'all' }, { type: 'tags', include: [...] }, { type: 'pattern', include: [...] }, { type: 'explicit', checkIds: [...] }. See recipes and checks.
To override check parameters, add a config: map to the selector:
checks: {
type: 'all',
config: {
'complex-function': { maxComplexity: 15 },
},
},
The check reads its slice via getCheckConfig<T>('complex-function').
A project-local sim scenario
// <project>/opensip-cli/sim/scenarios/checkout-burst.mjs
import { defineLoadScenario } from '@opensip-cli/simulation';
// Scenarios load only from a `scenarios` array export — not a default export.
export const scenarios = [defineLoadScenario({
id: '11111111-1111-4111-8111-111111111111',
name: 'checkout-burst',
description: 'Sustain 200 RPS checkout traffic for 30s',
tags: ['load', 'checkout'],
duration: { value: 30, unit: 'seconds' },
rampUp: { value: 5, unit: 'seconds' },
targetRps: 200,
personas: [
{
name: 'shopper',
weight: 1.0,
action: async () => {
await fetch('http://localhost:3000/checkout', { method: 'POST', body: '{}' });
},
},
],
assertions: [
{ name: 'p99-under-500ms', assert: (r) => r.p99LatencyMs < 500 },
{ name: 'error-rate-under-1pct', assert: (r) => r.errorRate < 0.01 },
],
})];
Same shape for defineChaosScenario — each pinned to its own kind. See scenarios and recipes.
Loose source is current-epoch source, not a portable artifact
Loose project-local .mjs files (and authored project-local tools) are source authored against the CLI and domain epoch you have installed right now — they are not portable, package-compatible artifacts. They carry no apiVersion / minSupportedApiVersion / target-domain epoch and so do not participate in the bounded-epoch compatibility negotiation that published packs and whole-tool manifests use (ADR-0074). The contract is simply: they run against the current CLI. If you upgrade the CLI across a domain epoch and a loose file stops loading, re-author it against the new epoch (or graduate it to a versioned pack). Do not copy a loose file between projects on different CLI versions and expect epoch checks to protect you — there are none.
When to graduate
The .mjs shape works fine through ~10-15 checks. Past that you'll want types, shared helpers, colocated tests, and a way to publish. That's when you graduate to a publishable workspace pack — see Publishable packs.
Where to go next
| You want to … | Go to … |
|---|---|
| Graduate to a publishable pack | Publishable packs |
| Write a full Tool plugin (own subcommand) | Full Tool plugins |
| Walk a guided "first check" tutorial | Write your first check |
| Reference: recipe selectors and check fields | Recipes and checks |