Full Tool plugins
A Tool plugin contributes its own subcommand. Use this when you want something fundamentally different from fit, sim, graph, or yagni — an audit-sec, a bench, a custom report. Anything that has its own argv shape, its own logic, and its own result type.
This is the heaviest extension shape. Most teams never need it. If you just want to ship rules, Publishable packs is the right path.
Project-local authoring paths
| Path | Command | When to use |
|------|---------|-------------|
| minimal-js | opensip tools create <id> | Zero-dependency smoke tests inside a repo |
| ts-local | opensip tools create <id> --template ts-local | Typed authoring with defineTool() before packaging |
| Publishable npm | opensip tools install <spec> | Distribution to other repos (deferred scaffold — see ADR-0076) |
defineTool() is the canonical typed local tool contract. createTool() remains
a compatibility wrapper for older authored tools, but new scaffolds use
defineTool() plus command-spec drafts directly. Neither path synthesizes
lifecycle extensionPoints — absence is the safe default
(ADR-0076).
Once a Tool exists as a package, the customer-facing management surface is the tools command group: tools list, tools validate, tools install, tools uninstall, and tools data-purge.
Layout
@my-co/audit-sec/
├── package.json
├── src/
│ ├── index.ts # exports: tool
│ ├── audit.ts # the actual logic
│ └── …
├── dist/
└── README.md
package.json
{
"name": "@my-co/audit-sec",
"version": "1.0.0",
"main": "dist/index.js",
"type": "module",
"opensipTools": {
"kind": "tool",
"id": "audit-sec",
"identity": { "name": "audit-sec" },
"apiVersion": 1,
"requires": [
{ "resource": "filesystem", "access": "read", "scope": "project" }
],
"commands": [
{ "name": "audit-sec", "description": "Run the security audit" },
{ "name": "list", "parent": "audit-sec", "description": "List audit rules" },
{ "name": "recipes", "parent": "audit-sec", "description": "List audit recipes" },
{ "name": "export", "parent": "audit-sec", "description": "Export audit artifacts (--format sarif)" }
]
},
"peerDependencies": {
"@opensip-cli/contracts": "^0.8.5",
"@opensip-cli/core": "^0.8.5"
}
}
The opensipTools block is your tool's static manifest — read before your module is imported, so the host knows what it's admitting:
kind: "tool"— the marker that makes the CLI discover your package.id— your canonical human key; must equalidentity.nameand runtimetool.metadata.name.identity— the single source for the primary command, aliases, config namespace, and layout key.apiVersion— the plugin-API epoch you declare. The host admits manifests
when MIN_SUPPORTED_PLUGIN_API_VERSION <= apiVersion <= PLUGIN_API_VERSION
(currently 1..1). A tool that declares no apiVersion is not admitted (it
fail-closes when run explicitly, or is skipped with a diagnostic when discovered).
requires— optional, declaration-only resource requirements. The host
normalizes and hashes them for manifest provenance/trust UX, but they are not
a sandbox. An admitted external tool still runs with the current user's OS
privileges.
commands— the command names (with descriptions) your tool mounts. The host asserts this set equals your runtimetool.commandsat load (assertManifestMatchesTool) and throws on drift — the manifest is the cheap, no-import way to enumerate your surface for--help/completion, so it must stay in sync with the tool.
Peer-dep on @opensip-cli/contracts and @opensip-cli/core at ^0.1.0; the
consumer brings their own version. (While opensip-cli is pre-1.0, a ^0.x
caret locks to the minor — ^0.1.0 is >=0.1.0 <0.2.0 — so bump your peer
range when you adopt a new 0.y line.)
src/index.ts
A tool declares its commands as typed CommandSpecs and the host mounts them —
owning the common flags (--cwd, --json, …), parsing, help, completion, output
dispatch, and exit policy. You write a handler and a declaration; everything else
arrives for free, identically to a bundled tool. You never touch Commander, never
add --json yourself, and never write to stdout — the host renders your result and
wraps --json in a CommandOutcome.
| CommandSpec.output | Use for | External Tool support |
|---|---|---|
| command-result | Normal commands that return a renderable result | Supported |
| raw-stream | File export or worker/transport commands that own their stream | Supported, with rawStreamReason |
| live-view | Bundled in-process tools that register a renderer | Not supported for external manifests; validation fails fast |
import {
defineNestedCommand,
definePrimaryCommand,
defineTool,
type ToolCliContext,
} from '@opensip-cli/core';
import { listAuditRecipes, listAuditRules, runAudit } from './audit.js';
export const tool = defineTool({
identity: { name: 'audit-sec' },
metadata: {
id: '0c9d1b75-1d6c-4d42-a2f7-76907c3f0181',
version: '1.0.0',
description: 'Lightweight security audit',
},
// defineTool derives metadata.name, the primary command name, aliases,
// nested parents, and commands[] from identity + these specs.
commandSpecs: [
definePrimaryCommand<{ cwd: string }, ToolCliContext>({
description: 'Run the security audit',
commonFlags: ['cwd', 'json'],
scope: 'project',
output: 'command-result',
handler: async (opts, cli) => {
const result = await runAudit(opts.cwd);
cli.setExitCode(result.passed ? 0 : 1);
return result;
},
}),
defineNestedCommand<{ cwd: string }, ToolCliContext>({
name: 'list',
description: 'List audit rules',
commonFlags: ['cwd', 'json'],
scope: 'project',
output: 'command-result',
handler: async (opts) => listAuditRules(opts.cwd),
}),
defineNestedCommand<{ cwd: string }, ToolCliContext>({
name: 'recipes',
description: 'List audit recipes',
commonFlags: ['cwd', 'json'],
scope: 'project',
output: 'command-result',
handler: async (opts) => listAuditRecipes(opts.cwd),
}),
defineNestedCommand<{ cwd: string; out: string }, ToolCliContext>({
name: 'export',
description: 'Export audit artifacts',
commonFlags: ['cwd', 'json'],
options: [
{
flag: '--format',
value: '<fmt>',
required: true,
choices: ['sarif'],
description: 'Export artifact: sarif',
},
{ flag: '--out', value: '<path>', required: true, description: 'Output file path' },
],
scope: 'project',
output: 'raw-stream',
rawStreamReason: 'file-export',
handler: async (opts, cli) => {
// file-writing export — same pattern as `fit export` / `graph export`
await cli.writeSarif(/* … */, opts.out);
},
}),
],
});
Output modes
A command declares one output mode on its CommandSpec; that mode determines
the single path your handler uses to produce output. You never write to stdout or
add --json yourself — the host renders your result and wraps --json in a
CommandOutcome.
| output mode | What your handler does | Host behavior |
|---|---|---|
| command-result (default) | return a CommandResult (e.g. { type: 'text-lines', … }) | Renders for humans; wraps the result under CommandOutcome for --json |
| signal-envelope | return a SignalEnvelope (or cli.emitEnvelope(env)) | Wraps the envelope under CommandOutcome.envelope; routes baseline/SARIF/cloud seams |
| raw-stream | cli.emitRaw(...) (requires a rawStreamReason) | Writes your bytes verbatim — for human status lines / file-export confirmations, not machine JSON |
| live-view | cli.renderLive(key, args) | Renders an Ink/TTY live view. Bundled/in-process tools only — external manifest tools may not declare it (see External tool trust boundary) |
Error path (any mode). To fail a command, either throw a catalog-backed
ToolError via createToolError (the host maps definition axes to an exit code)
or call cli.reportFailure({ error, … }) — the host normalizes once
(normalizeFailure), derives the message/exit code, logs, renders the customer
surface, and sets the exit code. Do not process.exit, pre-stringify failures, or
format error JSON yourself. Prefer a package-owned
extensionPoints.errorCatalog (contract ≥ 1.1.0) so codes stay stable across
hosts. See Command failures vs findings and the
Logging and operational telemetry
During a normal command run, cli.logger resolves to the per-run scope logger
and writes structured JSONL under <project>/opensip-cli/.runtime/logs/ when the
host configures a log directory. Prefer stamping a stable module on every entry:
import { createToolLogger } from '@opensip-cli/core';
const log = createToolLogger('audit-sec:cli');
handler: async (opts, cli) => {
log.info({ evt: 'audit-sec.run.start', cwd: opts.cwd });
// …
};
Or call cli.logger directly with both evt and module fields. Event names follow
the three-segment convention documented in Coding standards.
--debug may mirror log lines to stderr; that channel is for operators, not
customer-facing command output.
Command failures vs findings
| Situation | Seam |
|---|---|
| Scan/analysis results (signals, score, verdict) | Build a SignalEnvelope and return it (or call cli.deliverSignals after render) |
| Command cannot run (missing file, bad config, not found) | await cli.reportFailure({ … }) |
| Uncaught ToolError in a handler | Host catches and calls reportFailure for you |
| Durable artifact export | await cli.writeArtifact(path, bytes) (or a narrower host seam such as cli.writeSarif) |
reportFailure fans out to structured log, human Ink / --json error CommandOutcome,
exit code, and diagnostics — the host owns routing. Example with an optional
immutable error catalog:
import { createToolError, defineErrorCatalog, defineTool } from '@opensip-cli/core';
const errorCatalog = defineErrorCatalog(
{ id: '11111111-1111-4111-8111-111111111111', displayName: 'audit-sec', packageName: '@my-co/audit-sec' },
{
'AUDIT.TARGET.MISSING': {
code: 'AUDIT.TARGET.MISSING',
source: 'application',
defaultResponsibility: 'user',
kind: 'not-found',
retry: 'never',
severity: 'error',
exposure: 'public',
exitClass: 'not-found',
operatorAction: 'Pass an existing path under the project root.',
stability: 'public',
lifecycle: 'active',
},
},
);
export const tool = defineTool({
// … identity, commandSpecs …
extensionPoints: {
// Attach the immutable catalog directly (matches ToolExtensionPoints.errorCatalog).
errorCatalog,
},
// handlers throw createToolError(errorCatalog.require('AUDIT.TARGET.MISSING'), '…')
});
handler: async (opts, cli) => {
try {
cli.logger.info({ evt: 'audit-sec.run.start', module: 'audit-sec:cli' });
return await runAudit(opts.cwd);
} catch (error) {
// Pass the thrown value through — do not String(error) first.
await cli.reportFailure({ error, jsonRequested: opts.json === true });
return;
}
};
Malformed or colliding catalogs are rejected at load with a registered safe
failure (never a silent half-mount). Older hosts may ignore the optional
contribution; new hosts validate schema version and owner identity. See
defineTool derives commands[] from commandSpecs (including parent for
nested children). The manifest lists every command by short name — list,
recipes, export — not as nested paths; external-host mounting uses the
serializable parent field in the manifest command shell.
See Command surface taxonomy for the full Tier-1/2/3
grammar.
The manifest and derived commands[] must agree on the command-name set. The
host asserts this at load, so a half-renamed command fails fast with a clear
error instead of a silent half-mounted surface.
That's the whole tool. Install it either way and opensip audit-sec works on the next invocation:
opensip tools install @my-co/audit-sec— validates the package against the Tool contract, then installs it user-global into~/.opensip-cli/plugins/tool/by default, so the subcommand is available in every project — the cross-project analogue ofnpm i -g. Add--projectto install it project-local under<project>/opensip-cli/.runtime/plugins/tool/instead (that copy is gitignored and not shared with teammates, and keeps provenanceinstalled— it is still an npm install, not authored content). Unlike fit/sim packs, a tool needs noplugins.<domain>config entry — it auto-discovers by itsopensipTools.kind: "tool"marker. (Whole Tool plugins are managed ONLY byopensip tools …; the per-toolplugingroup manages a pack-supporting tool's extension packs, not whole tools.)npm install @my-co/audit-secin your project — discovery walks the project tree'snode_modules, so a plain install is picked up too. A globalnpm i -g @my-co/audit-secnext to a globalopensip-cliis found via the CLI's own install tree.
Installed npm tools found ambiently in node_modules are deny-by-default. The
managed path is opensip tools install: it validates the package, installs the
validated bytes, and records trust for the selected scope. The tools install
result includes nextSteps with the first command to try:
opensip audit-sec
OPENSIP_CLI_ALLOW_INSTALLED_TOOLS still accepts exact ids for manual
experiments or incident response. The * wildcard is accepted but warns because
it admits every discovered installed Tool.
Authored Tool sidecars (tracked, no npm install)
The routes above all package your tool as npm (provenance installed). A
second mechanism lets you author a Tool as tracked source — the
whole-subcommand analogue of the opensip-cli/fit/checks/ and
opensip-cli/sim/scenarios/ convention — with no npm install and no
package.json marker. You declare identity via an opensip-tool.manifest.json
sidecar next to the tool's built entry, in one of two locations with
different trust postures:
<project>/opensip-cli/tools/<name>/opensip-tool.manifest.json— TRACKED,
committed alongside opensip-cli/fit/ and opensip-cli/sim/. It is
deny-by-default: it rides in with a git clone before you've read it, so
loading it would run untrusted code. It is admitted only when its id
appears in committed project config under tools.trusted or in the
OPENSIP_CLI_ALLOW_PROJECT_TOOLS override; otherwise the CLI **fail-closes
(exit 5) before importing it**. Provenance is project-local.
~/.opensip-cli/tools/<name>/opensip-tool.manifest.json— trusted-by-default:
you placed it in your own home dir (the npm i -g analogue for authored code),
so it loads without an allowlist. Provenance is user-global.
The sidecar is the manifest block (there is no package.json alongside it),
carrying the same identity fields inline — kind, id, identity, name,
version, apiVersion, commands — plus the path to the tool's own resolved
main entry:
// <project>/opensip-cli/tools/audit-sec/opensip-tool.manifest.json
{
"kind": "tool",
"id": "audit-sec",
"identity": { "name": "audit-sec" },
"name": "Security audit",
"version": "1.0.0",
"apiVersion": 1,
"main": "dist/index.js",
"commands": [
{ "name": "audit-sec", "description": "Run the security audit" },
{ "name": "list", "parent": "audit-sec", "description": "List audit rules" },
{ "name": "recipes", "parent": "audit-sec", "description": "List audit recipes" },
{ "name": "export", "parent": "audit-sec", "description": "Export audit artifacts (--format sarif)" }
]
}
The runtime contract is unchanged — the directory's resolved main must export
tool: Tool, and the host runs the same assertManifestMatchesTool drift guard.
Authored discovery, admission, dynamic import, and registration travel the exact
same path bundled and installed tools do (ADR-0030).
Sidecar vs
tools install --project.tools install --project*installs an
npm package* into the gitignored
.runtime/plugins/tool/and keeps provenance
installed. An authored sidecar is tracked source with provenance
project-local(project) oruser-global(home). They are different
mechanisms; the provenance label in
opensip tools listtells them apart.
What you don't need
- An entry-points declaration.
- A hook or middleware registration.
- A code change in
opensip-cli. - A code change in
@opensip-cli/core. - A schema migration.
The Tool contract is the seam. The CLI builds a per-invocation ToolRegistry,
discovers your package via the opensipTools.kind: 'tool' marker, admits it
(manifest + apiVersion check), dynamically imports it, and mounts your
declared commandSpecs via mountCommandSpec. commandSpecs is the one
command surface. For the architecture behind this decoupling, see
Contract versions (core TOOL_CONTRACT_VERSION + per-tool versions such as
FITNESS_CONTRACT_VERSION) are documented in the same model page and the
governing ADRs (0046/0047). Declare the relevant ones on your Tool object
(under extensionPoints is the preferred path) so hosts and agent-catalog
can see the exact surface you were written against.
Recipe listing (shared display, tool-owned execution)
Fitness, graph, and simulation expose recipes list commands that return the
shared ListRecipesResult shape. Core provides display-only helpers
(recipeDisplayInfo, allUnitsLabel, …) for name/description/tags and a neutral
selectionLabel. Each tool still owns selector semantics and execution:
- Fitness — check selectors, retry/timeout/reporting during runs.
- Graph — rule selectors only; recipes do not execute scenarios.
- Simulation — scenario execution and sim-only selector arms.
There is no generic recipe execution framework in core.
Tools that use the kernel registries
A Tool that wants to reuse the fitness check registry (e.g. an audit-fit that runs a custom recipe) imports @opensip-cli/fitness and reuses executeFit, defineRecipe, etc. The fitness package re-exports these so a Tool author doesn't have to assemble a runner from scratch.
A Tool that's structurally different (a benchmark runner, a custom report generator) doesn't need to import @opensip-cli/fitness at all — it can be entirely self-contained, with its own logic. For terminal output, return an existing renderable CommandResult shape such as text-lines; new host-specific result variants require a CLI/contracts change because the render mapping is intentionally closed and exhaustive.
Participating in init scaffolding
opensip init is registry-driven: it scaffolds one directory tree per registered tool, and your tool owns its example bytes. To opt in, declare three optional Tool members — the host owns the directory layout, the document header, and targets:; you own everything inside your domain:
pluginLayout—{ domain, userSubdirs }.initcreatesopensip-cli/<domain>/<subdir>/for eachuserSubdirsentry (fitness uses{ domain: 'fit', userSubdirs: ['checks', 'recipes'] }). A tool with nopluginLayout(e.g.graph) scaffolds nothing.scaffoldExamples(ctx)— returns theScaffoldFile[]to write (each{ kind, filename, content, stableId });kindmatches one of youruserSubdirs.ctx.languagesis the project's detected/selected language list, so you can emit per-language examples.stableExampleIds()— your tool's COMPLETE pinned-id universe (across every language), used byinit --keepto detect stale scaffolds left over from a config the project no longer uses.scaffoldConfigBlock()(optional) — returns your tool's YAML config block (e.g. fitness'sfitness:block), appended to the host-rendered document. Omit it if your tool needs no config block.
No packages/cli change is needed to add a tool to init — the scaffolded set is exactly the registered set.
Per-command options: one interface per command
Each built-in command has its own options interface in @opensip-cli/contracts, and that interface is the single source of truth for the command's flags — the executor takes it directly (executeFit(args: FitOptions, …), executeSim(args: ToolOptions), executeInit(args: InitOptions & {…})).
| Command | Options interface |
|---|---|
| fit | FitOptions |
| sim | ToolOptions |
| init | InitOptions |
| your tool | a new interface in your tool package, named after the command |
New flags are additive on the relevant interface. There is no shared cross-command union — each command's shape stands on its own.
For your own Tool plugin you don't reuse any of these: you declare each
command's options as OptionSpecs on its commandSpec, and the host wires
Commander and passes the parsed options to your handler as the first argument.
You never touch Commander or take a commander dependency — the host owns the
program.
External tool trust boundary (ADR-0054, ADR-0061)
Bundled first-party tools (fitness, graph, simulation) execute in the CLI host
process and are fail-closed on admission or mount failure (exit 5).
External-provenance tools (installed npm, project-local, user-global) use a
different posture — fault isolation, not capability isolation:
- Host registration (M4-G) — the host mounts command shells from the static
manifest via synthesizeExternalTool and does not import the untrusted
runtime module in the host process.
- Worker dispatch (M4-E) — command handlers for external provenance fork a
__tool-command-worker child that loads and runs the real runtime in a
fault-isolation boundary; results replay through the same ToolCliContext seams.
- Lifecycle gating (M4-F) — external lifecycle/capability hooks run in the
worker, not the host.
- Enforcement —
host-tool-runtime-import-boundaryfitness check forbids
host-side runtime imports outside the admission/dispatch modules.
An admitted external tool runs at full user privilege: it can read the filesystem (including
~/.sshand.env), and make arbitrary network calls. It is fault-isolated (a crash/hang/OOM does not take down the host), not capability-isolated.
The worker does deny ambient OpenSIP datastore access. Both the exact internal
worker command and the host-owned marker must select host-rpc-only mode;
either alone fails before the handler runs. Privileged OpenSIP effects are the
enumerated host RPC methods, not a general datastore or arbitrary host call.
The child environment is an explicit allowlist plus manifest-declared env
resources; additional names require OPENSIP_CLI_TOOL_ENV_PASSTHROUGH. These
controls do not reduce the current user's ordinary Node filesystem/network
authority and must not be described as an OS sandbox
(ADR-0145).
Capability packs (custom checks, scenario packs, graph adapters loaded via
plugins.<domain>) follow a split posture
(ADR-0171):
bundled first-party packs load in the host process; admitted external packs run
through the owning tool's capability worker bridge. That worker's resource
guard is sharpened-advisory defense-in-depth, not containment: it exists to
catch accidental undeclared filesystem/network/subprocess use by
operator-admitted code (and its diagnostic says so). **Admission is the
enforced boundary** — an admitted pack runs with the host user's real
filesystem and network authority. True containment is deferred to the
process-level plugin-isolation roadmap. The external-tool worker fork above
does not cover capability packs.
For the full extension trust-tier matrix, see
What is enforced at admission:
- Manifest compatibility,
apiVersion, and manifest⇔runtime drift checks
(admit-tool-package.ts).
- External manifest command shells may declare
output: "command-result"or
output: "raw-stream"; output: "live-view" is rejected by admission and by
tools validate. A live-view renderer is executable UI code and cannot be
mounted from a manifest-only external tool shell.
- Deny-by-default trust gates for project-local and installed tools. Project-local
authored tools are admitted by tools.trusted (or the
OPENSIP_CLI_ALLOW_PROJECT_TOOLS override). Installed tools are admitted by a
managed tools install trust record (or the OPENSIP_CLI_ALLOW_INSTALLED_TOOLS
override). Trust entries are exact ids. A * entry is ignored and emits one
bounded cli.trust.tool_wildcard_ignored warning; it never admits a Tool.
Managed install records and committed exact project trust remain the normal
paths (ADR-0145).
- Deny-by-default capability packs for in-process extensions
(ADR-0171).
Bundled first-party packs are trusted. A plugins.checkPackages,
plugins.scenarioPackages, or plugins.graphAdapters entry selects a
pack for discovery but never trusts it — the analyzed repo's own config is
exactly the surface an analysis tool must not take trust decisions from.
External packs are admitted only by the user-level global-config trust list,
written by the operator ceremony opensip policy trust <pack>: each grant
binds the exact package id to the pack's provenance (its opensipTools
manifest hash) resolved at grant time, so a repo shadowing a trusted name
with different code is denied — re-run policy trust after the pack
legitimately changes. The former OPENSIP_CLI_ALLOW_CAPABILITY_PACKS env
override is removed, and * is never honored for capability packs.
- Mount isolation — a broken external
commandSpecsdeclaration warns and
continues; bundled mount failures abort startup (exit 5).
tools validate— probes a not-yet-trusted package in a child process
(staticOnly on the admission pipeline).
What is not landed yet: consumption-side npm package verification at
install/load (publish-side npm publish --provenance already ships —
.github/workflows/release.yml:236,248). Public third-party ecosystem launch is
blocked until consumption-side verification + a capability/permission model ship
(ADR-0061). Until then, pin versions, review source, and use opensip tools validate
before enabling a new tool in CI.
Tips that come up
- Test every check with the same content filter the framework will use. The strip behavior is per-language; a check that works on raw content might break on filtered content. Use the language adapter's
stripCommentsdirectly in tests if needed. - Use
--debugaggressively while authoring. Your check's log lines (ctx.log(...)) appear in stderr; the day-level log file under<project>/opensip-cli/.runtime/logs/<YYYY-MM-DD>.jsonlarchives them. Filter byrunIdwithjqif multiple runs landed in the same file. - For pre-1.0 peer dependencies, pin to the current minor line. A caret range such as
^0.1.0allows patch updates but not0.2.0; revisit the range when you adopt a new0.yline. - Use the right discovery shape for the right export. A package marked
opensipTools.kind: 'tool'is treated as a Tool by the discovery walker — it must exporttool: Tool. A check pack useskind: 'fit-pack'and exportschecks/recipes; simulation scenario packs use thescenarios-*package-name convention or an explicitplugins.scenarioPackages:list. Mismatching these shapes leads to a load failure that's logged but not fatal. - An authored sidecar tool is discovered by file presence, not a marker. A tool under a
tools/root (<project>/opensip-cli/tools/or~/.opensip-cli/tools/) is found by the presence ofopensip-tool.manifest.json, not by anode_modulesopensipTools.kindmarker. Remember the project (project-local) location is deny-by-default — list itsidintools.trustedor use theOPENSIP_CLI_ALLOW_PROJECT_TOOLSoverride, otherwise it fail-closes before import.
Where to go next
- Command surface taxonomy — Tier-1/2/3 grammar, nested
parent, export--format, internal visibility. - The tool-plugin model — the architectural seam your Tool plugs into.
toolscommand — list, validate, install, uninstall, and purge data for whole Tool plugins.- Report — the HTML report's lifecycle (the renderer your Tool's findings end up in).
- Package catalog — the packages you can depend on.
- Coding standards — the style and structure conventions used throughout opensip-cli (handy if you're contributing back).